Dokumentenmanagement
This commit is contained in:
parent
70ece97587
commit
c2c4ae3cbe
14 changed files with 759 additions and 13 deletions
82
vergabe_teilnahme/apps/dokumente/forms.py
Normal file
82
vergabe_teilnahme/apps/dokumente/forms.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import os
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
|
||||
from vergabe_teilnahme.apps.lose.models import Los
|
||||
|
||||
from .models import ALLOWED_EXTENSIONS, Dokument
|
||||
|
||||
|
||||
class DokumentForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Dokument
|
||||
fields = ['datei', 'kategorie', 'version', 'quelle', 'verantwortlicher', 'pruefer', 'los', 'beschreibung']
|
||||
widgets = {
|
||||
'datei': forms.FileInput(attrs={'class': 'form-input'}),
|
||||
'kategorie': forms.Select(attrs={'class': 'form-input'}),
|
||||
'version': forms.TextInput(attrs={'class': 'form-input'}),
|
||||
'quelle': forms.TextInput(attrs={'class': 'form-input'}),
|
||||
'verantwortlicher': forms.Select(attrs={'class': 'form-input'}),
|
||||
'pruefer': forms.Select(attrs={'class': 'form-input'}),
|
||||
'los': forms.Select(attrs={'class': 'form-input'}),
|
||||
'beschreibung': forms.Textarea(attrs={'class': 'form-input', 'rows': 3}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, ausschreibung=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if ausschreibung is not None:
|
||||
self.fields['los'].queryset = Los.objects.filter(ausschreibung=ausschreibung)
|
||||
self.fields['quelle'].required = False
|
||||
self.fields['verantwortlicher'].required = False
|
||||
self.fields['pruefer'].required = False
|
||||
self.fields['los'].required = False
|
||||
self.fields['beschreibung'].required = False
|
||||
|
||||
def clean_datei(self):
|
||||
datei = self.cleaned_data.get('datei')
|
||||
if datei:
|
||||
ext = os.path.splitext(datei.name)[1].lower()
|
||||
if ext not in ALLOWED_EXTENSIONS:
|
||||
raise forms.ValidationError(
|
||||
f'Dateityp "{ext}" nicht erlaubt. Erlaubt: {", ".join(sorted(ALLOWED_EXTENSIONS))}'
|
||||
)
|
||||
max_size = getattr(settings, 'MAX_UPLOAD_SIZE', 52428800)
|
||||
if hasattr(datei, 'size') and datei.size > max_size:
|
||||
raise forms.ValidationError(
|
||||
f'Datei zu groß. Maximum: {max_size // 1024 // 1024} MB'
|
||||
)
|
||||
return datei
|
||||
|
||||
|
||||
class DokumentVersionForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Dokument
|
||||
fields = ['datei', 'version']
|
||||
widgets = {
|
||||
'datei': forms.FileInput(attrs={'class': 'form-input'}),
|
||||
'version': forms.TextInput(attrs={'class': 'form-input'}),
|
||||
}
|
||||
|
||||
def clean_datei(self):
|
||||
datei = self.cleaned_data.get('datei')
|
||||
if datei:
|
||||
ext = os.path.splitext(datei.name)[1].lower()
|
||||
if ext not in ALLOWED_EXTENSIONS:
|
||||
raise forms.ValidationError(
|
||||
f'Dateityp "{ext}" nicht erlaubt. Erlaubt: {", ".join(sorted(ALLOWED_EXTENSIONS))}'
|
||||
)
|
||||
max_size = getattr(settings, 'MAX_UPLOAD_SIZE', 52428800)
|
||||
if hasattr(datei, 'size') and datei.size > max_size:
|
||||
raise forms.ValidationError(
|
||||
f'Datei zu groß. Maximum: {max_size // 1024 // 1024} MB'
|
||||
)
|
||||
return datei
|
||||
|
||||
|
||||
def naechste_version(alte_version: str) -> str:
|
||||
try:
|
||||
major = int(alte_version.split('.')[0])
|
||||
return f'{major + 1}.0'
|
||||
except (ValueError, IndexError):
|
||||
return '2.0'
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
# Generated by Django 6.0.5 on 2026-05-08 16:27
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bibliothek', '0001_initial'),
|
||||
('dokumente', '0002_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='dokument',
|
||||
name='bibliothek_nachweis',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='verwendete_dokumente', to='bibliothek.nachweis'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='dokument',
|
||||
name='quelle',
|
||||
field=models.CharField(blank=True, max_length=300),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='dokument',
|
||||
name='datei',
|
||||
field=models.FileField(blank=True, null=True, upload_to='dokumente/%Y/%m/'),
|
||||
),
|
||||
]
|
||||
|
|
@ -39,8 +39,13 @@ class Dokument(FlexibleModel):
|
|||
'lose.Los', on_delete=models.SET_NULL,
|
||||
null=True, blank=True, related_name='dokumente'
|
||||
)
|
||||
datei = models.FileField(upload_to='dokumente/%Y/%m/')
|
||||
datei = models.FileField(upload_to='dokumente/%Y/%m/', null=True, blank=True)
|
||||
dateiname = models.CharField(max_length=300, blank=True)
|
||||
quelle = models.CharField(max_length=300, blank=True)
|
||||
bibliothek_nachweis = models.ForeignKey(
|
||||
'bibliothek.Nachweis', on_delete=models.SET_NULL,
|
||||
null=True, blank=True, related_name='verwendete_dokumente'
|
||||
)
|
||||
kategorie = models.CharField(max_length=30, choices=KATEGORIE_CHOICES, default='sonstiges')
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='entwurf')
|
||||
version = models.CharField(max_length=20, default='1.0')
|
||||
|
|
|
|||
|
|
@ -1,3 +1,85 @@
|
|||
from django.test import TestCase
|
||||
import io
|
||||
|
||||
# Create your tests here.
|
||||
import factory
|
||||
import pytest
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.urls import reverse
|
||||
|
||||
from vergabe_teilnahme.apps.ausschreibungen.tests import AusschreibungFactory
|
||||
|
||||
from .models import Dokument
|
||||
|
||||
|
||||
class DokumentFactory(factory.django.DjangoModelFactory):
|
||||
class Meta:
|
||||
model = Dokument
|
||||
|
||||
ausschreibung = factory.SubFactory(AusschreibungFactory)
|
||||
dateiname = factory.Sequence(lambda n: f'dokument_{n}.pdf')
|
||||
kategorie = 'intern'
|
||||
status = 'entwurf'
|
||||
version = '1.0'
|
||||
|
||||
|
||||
def _pdf_file(name='test.pdf'):
|
||||
return SimpleUploadedFile(name, b'%PDF-1.4 fake content', content_type='application/pdf')
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_dokument_upload_valid(client, tmp_path, settings):
|
||||
settings.MEDIA_ROOT = tmp_path
|
||||
a = AusschreibungFactory()
|
||||
url = reverse('ausschreibungen:dokumente:upload', kwargs={'ausschreibung_id': a.pk})
|
||||
response = client.post(url, {'datei': _pdf_file(), 'kategorie': 'intern', 'version': '1.0'})
|
||||
assert response.status_code == 302
|
||||
assert Dokument.objects.filter(ausschreibung=a).exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_dokument_upload_invalid_extension(client, tmp_path, settings):
|
||||
settings.MEDIA_ROOT = tmp_path
|
||||
a = AusschreibungFactory()
|
||||
url = reverse('ausschreibungen:dokumente:upload', kwargs={'ausschreibung_id': a.pk})
|
||||
bad_file = SimpleUploadedFile('malware.exe', b'MZ bad', content_type='application/octet-stream')
|
||||
response = client.post(url, {'datei': bad_file, 'kategorie': 'intern', 'version': '1.0'})
|
||||
assert response.status_code == 200
|
||||
assert not Dokument.objects.filter(ausschreibung=a).exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_dokument_upload_too_large(client, tmp_path, settings):
|
||||
settings.MEDIA_ROOT = tmp_path
|
||||
settings.MAX_UPLOAD_SIZE = 10
|
||||
a = AusschreibungFactory()
|
||||
url = reverse('ausschreibungen:dokumente:upload', kwargs={'ausschreibung_id': a.pk})
|
||||
big_file = SimpleUploadedFile('big.pdf', b'%PDF' + b'x' * 100, content_type='application/pdf')
|
||||
response = client.post(url, {'datei': big_file, 'kategorie': 'intern', 'version': '1.0'})
|
||||
assert response.status_code == 200
|
||||
assert not Dokument.objects.filter(ausschreibung=a).exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_dokument_neue_version(client, tmp_path, settings):
|
||||
settings.MEDIA_ROOT = tmp_path
|
||||
altes_dok = DokumentFactory(version='1.0')
|
||||
a = altes_dok.ausschreibung
|
||||
url = reverse('ausschreibungen:dokumente:neue_version',
|
||||
kwargs={'ausschreibung_id': a.pk, 'pk': altes_dok.pk})
|
||||
response = client.post(url, {'datei': _pdf_file('v2.pdf'), 'version': '2.0'})
|
||||
assert response.status_code == 302
|
||||
altes_dok.refresh_from_db()
|
||||
assert altes_dok.status == 'ersetzt'
|
||||
assert Dokument.objects.filter(ausschreibung=a, version='2.0').exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_dokument_finale_version(client):
|
||||
dok = DokumentFactory(status='freigegeben')
|
||||
a = dok.ausschreibung
|
||||
url = reverse('ausschreibungen:dokumente:finale_version',
|
||||
kwargs={'ausschreibung_id': a.pk, 'pk': dok.pk})
|
||||
response = client.post(url)
|
||||
assert response.status_code == 200
|
||||
dok.refresh_from_db()
|
||||
assert dok.finale_abgabeversion is True
|
||||
assert dok.status == 'final_abgegeben'
|
||||
|
|
|
|||
|
|
@ -1,2 +1,15 @@
|
|||
from django.urls import path
|
||||
urlpatterns = []
|
||||
|
||||
from . import views
|
||||
|
||||
app_name = 'dokumente'
|
||||
|
||||
urlpatterns = [
|
||||
path('', views.dokumente_liste, name='liste'),
|
||||
path('hochladen/', views.dokument_upload, name='upload'),
|
||||
path('<int:pk>/', views.dokument_detail, name='detail'),
|
||||
path('<int:pk>/version/', views.dokument_neue_version, name='neue_version'),
|
||||
path('<int:pk>/status/', views.dokument_status, name='status'),
|
||||
path('<int:pk>/final/', views.dokument_finale_version, name='finale_version'),
|
||||
path('<int:pk>/bibliothek/', views.dokument_bibliothek_zuordnen, name='bibliothek_zuordnen'),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,3 +1,169 @@
|
|||
from django.shortcuts import render
|
||||
import os
|
||||
from itertools import groupby
|
||||
|
||||
# Create your views here.
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
|
||||
from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung
|
||||
from vergabe_teilnahme.apps.bibliothek.models import Nachweis
|
||||
from vergabe_teilnahme.apps.core.models import Freigabe
|
||||
|
||||
from .forms import DokumentForm, DokumentVersionForm, naechste_version
|
||||
from .models import Dokument
|
||||
|
||||
STATUS_WORKFLOW = [
|
||||
'entwurf',
|
||||
'in_pruefung',
|
||||
'freigegeben',
|
||||
'final_abgegeben',
|
||||
]
|
||||
|
||||
|
||||
def _get_ausschreibung(ausschreibung_id):
|
||||
return get_object_or_404(Ausschreibung, pk=ausschreibung_id)
|
||||
|
||||
|
||||
def dokumente_liste(request, ausschreibung_id):
|
||||
ausschreibung = _get_ausschreibung(ausschreibung_id)
|
||||
qs = Dokument.objects.filter(ausschreibung=ausschreibung).select_related('verantwortlicher', 'pruefer', 'los')
|
||||
|
||||
status_filter = request.GET.get('status', '')
|
||||
kategorie_filter = request.GET.get('kategorie', '')
|
||||
verantwortlicher_filter = request.GET.get('verantwortlicher', '')
|
||||
|
||||
if status_filter:
|
||||
qs = qs.filter(status=status_filter)
|
||||
if kategorie_filter:
|
||||
qs = qs.filter(kategorie=kategorie_filter)
|
||||
if verantwortlicher_filter:
|
||||
qs = qs.filter(verantwortlicher_id=verantwortlicher_filter)
|
||||
|
||||
grouped = {}
|
||||
for dok in qs:
|
||||
grouped.setdefault(dok.get_kategorie_display(), []).append(dok)
|
||||
|
||||
return render(request, 'dokumente/liste.html', {
|
||||
'ausschreibung': ausschreibung,
|
||||
'grouped_dokumente': grouped,
|
||||
'status_choices': Dokument.STATUS_CHOICES,
|
||||
'kategorie_choices': Dokument.KATEGORIE_CHOICES,
|
||||
'current_status': status_filter,
|
||||
'current_kategorie': kategorie_filter,
|
||||
})
|
||||
|
||||
|
||||
def dokument_upload(request, ausschreibung_id):
|
||||
ausschreibung = _get_ausschreibung(ausschreibung_id)
|
||||
if request.method == 'POST':
|
||||
form = DokumentForm(request.POST, request.FILES, ausschreibung=ausschreibung)
|
||||
if form.is_valid():
|
||||
dok = form.save(commit=False)
|
||||
dok.ausschreibung = ausschreibung
|
||||
if dok.datei:
|
||||
dok.dateiname = os.path.basename(dok.datei.name)
|
||||
dok.save()
|
||||
return redirect('ausschreibungen:dokumente:liste', ausschreibung_id=ausschreibung_id)
|
||||
else:
|
||||
form = DokumentForm(ausschreibung=ausschreibung)
|
||||
return render(request, 'dokumente/form.html', {
|
||||
'ausschreibung': ausschreibung,
|
||||
'form': form,
|
||||
'titel': 'Dokument hochladen',
|
||||
})
|
||||
|
||||
|
||||
def dokument_detail(request, ausschreibung_id, pk):
|
||||
ausschreibung = _get_ausschreibung(ausschreibung_id)
|
||||
dok = get_object_or_404(Dokument, pk=pk, ausschreibung=ausschreibung)
|
||||
ct = ContentType.objects.get_for_model(Dokument)
|
||||
freigaben = Freigabe.objects.filter(content_type=ct, object_id=dok.pk)
|
||||
versionen = Dokument.objects.filter(
|
||||
ausschreibung=ausschreibung,
|
||||
kategorie=dok.kategorie,
|
||||
dateiname=dok.dateiname,
|
||||
).order_by('version')
|
||||
return render(request, 'dokumente/detail.html', {
|
||||
'ausschreibung': ausschreibung,
|
||||
'dokument': dok,
|
||||
'freigaben': freigaben,
|
||||
'versionen': versionen,
|
||||
})
|
||||
|
||||
|
||||
def dokument_neue_version(request, ausschreibung_id, pk):
|
||||
ausschreibung = _get_ausschreibung(ausschreibung_id)
|
||||
altes_dokument = get_object_or_404(Dokument, pk=pk, ausschreibung=ausschreibung)
|
||||
initial_version = naechste_version(altes_dokument.version)
|
||||
if request.method == 'POST':
|
||||
form = DokumentVersionForm(request.POST, request.FILES)
|
||||
if form.is_valid():
|
||||
neues_dok = form.save(commit=False)
|
||||
neues_dok.ausschreibung = altes_dokument.ausschreibung
|
||||
neues_dok.los = altes_dokument.los
|
||||
neues_dok.kategorie = altes_dokument.kategorie
|
||||
neues_dok.verantwortlicher = altes_dokument.verantwortlicher
|
||||
if neues_dok.datei:
|
||||
neues_dok.dateiname = os.path.basename(neues_dok.datei.name)
|
||||
neues_dok.save()
|
||||
altes_dokument.status = 'ersetzt'
|
||||
altes_dokument.save(update_fields=['status'])
|
||||
return redirect('ausschreibungen:dokumente:detail', ausschreibung_id=ausschreibung_id, pk=neues_dok.pk)
|
||||
else:
|
||||
form = DokumentVersionForm(initial={'version': initial_version})
|
||||
return render(request, 'dokumente/neue_version.html', {
|
||||
'ausschreibung': ausschreibung,
|
||||
'form': form,
|
||||
'dokument': altes_dokument,
|
||||
})
|
||||
|
||||
|
||||
def dokument_status(request, ausschreibung_id, pk):
|
||||
ausschreibung = _get_ausschreibung(ausschreibung_id)
|
||||
dok = get_object_or_404(Dokument, pk=pk, ausschreibung=ausschreibung)
|
||||
if request.method == 'POST' and dok.status != 'final_abgegeben':
|
||||
neuer_status = request.POST.get('status', '')
|
||||
valid = [s for s, _ in Dokument.STATUS_CHOICES if s not in ('ersetzt', 'archiviert')]
|
||||
if neuer_status in valid:
|
||||
dok.status = neuer_status
|
||||
if neuer_status == 'final_abgegeben':
|
||||
dok.finale_abgabeversion = True
|
||||
dok.save(update_fields=['status', 'finale_abgabeversion'])
|
||||
return render(request, 'dokumente/partials/status_widget.html', {
|
||||
'ausschreibung': ausschreibung,
|
||||
'dokument': dok,
|
||||
})
|
||||
|
||||
|
||||
def dokument_finale_version(request, ausschreibung_id, pk):
|
||||
ausschreibung = _get_ausschreibung(ausschreibung_id)
|
||||
dok = get_object_or_404(Dokument, pk=pk, ausschreibung=ausschreibung)
|
||||
if request.method == 'POST':
|
||||
dok.finale_abgabeversion = True
|
||||
dok.status = 'final_abgegeben'
|
||||
dok.save(update_fields=['finale_abgabeversion', 'status'])
|
||||
return render(request, 'dokumente/partials/finaler_status_badge.html', {
|
||||
'dokument': dok,
|
||||
})
|
||||
|
||||
|
||||
def dokument_bibliothek_zuordnen(request, ausschreibung_id, pk):
|
||||
ausschreibung = _get_ausschreibung(ausschreibung_id)
|
||||
dok = get_object_or_404(Dokument, pk=pk, ausschreibung=ausschreibung)
|
||||
q = request.GET.get('q', '').strip()
|
||||
nachweise = []
|
||||
if q:
|
||||
nachweise = Nachweis.objects.filter(titel__icontains=q)
|
||||
if request.method == 'POST':
|
||||
nachweis_id = request.POST.get('nachweis_id')
|
||||
nachweis = get_object_or_404(Nachweis, pk=nachweis_id)
|
||||
dok.bibliothek_nachweis = nachweis
|
||||
dok.quelle = f'Bibliothek: {nachweis.titel}'
|
||||
dok.dateiname = nachweis.titel
|
||||
dok.save(update_fields=['bibliothek_nachweis', 'quelle', 'dateiname'])
|
||||
return redirect('ausschreibungen:dokumente:detail', ausschreibung_id=ausschreibung_id, pk=dok.pk)
|
||||
return render(request, 'dokumente/bibliothek_modal.html', {
|
||||
'ausschreibung': ausschreibung,
|
||||
'dokument': dok,
|
||||
'nachweise': nachweise,
|
||||
'q': q,
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue