feat(aufgaben): Verknüpfungen, implizite Fälligkeit, Issue-Facade (WP-0015)

- Aufgabe.erstellt_am für implizite 7-Tage-Fälligkeit
- frist_effektiv property; ist_ueberfaellig nutzt sie
- AufgabenVerknuepfung (GenericForeignKey) mit HTMX-Panel
- ExternalIssue (OneToOne) mit IssueAdapter-ABC und HTMX-Panel
- link_registry.py und issue_facade.py als zentrale Registries
- 8 neue Tests, 76 gesamt grün

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-05-14 04:18:28 +02:00
parent a0c0ddf2eb
commit 816c281f6a
19 changed files with 713 additions and 19 deletions

View file

@ -1,10 +1,13 @@
from datetime import date
from datetime import date, timedelta
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung
from .models import Aufgabe, Bieterfrage
from django.http import HttpResponse
from .models import Aufgabe, AufgabenVerknuepfung, Bieterfrage, ExternalIssue
AKTIVE_STATUS = [
'offen', 'in_bearbeitung', 'wartend_intern', 'wartend_sub', 'wartend_ausschreiber',
@ -31,6 +34,12 @@ def aufgaben_liste(request, ausschreibung_id=None):
qs = Aufgabe.objects.select_related('ausschreibung').all()
qs.filter(frist__lt=heute, status__in=AKTIVE_STATUS).update(status='ueberfaellig')
implizite_grenze = timezone.now() - timedelta(days=7)
qs.filter(
frist__isnull=True,
erstellt_am__lt=implizite_grenze,
status__in=AKTIVE_STATUS,
).update(status='ueberfaellig')
status_filter = request.GET.get('status')
if status_filter:
@ -99,6 +108,11 @@ def aufgabe_neu(request, ausschreibung_id):
else:
form = AufgabeForm(ausschreibung=ausschreibung)
if _is_htmx(request):
return render(request, 'aufgaben/partials/aufgabe_form_inline.html', {
'form': form,
'ausschreibung': ausschreibung,
})
return render(request, 'aufgaben/form.html', {
'form': form,
'ausschreibung': ausschreibung,
@ -316,3 +330,138 @@ def bieterfrage_antwort(request, ausschreibung_id, pk):
'show_antwort_form': True,
'breadcrumbs': [],
})
# ─── Aufgaben-Verknüpfungen ───────────────────────────────────────────────────
def verknuepfung_neu(request, ausschreibung_id, pk):
from django.contrib.contenttypes.models import ContentType
from .forms import AufgabenVerknuepfungForm
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
aufgabe = get_object_or_404(Aufgabe, pk=pk, ausschreibung=ausschreibung)
if request.method == 'POST':
form = AufgabenVerknuepfungForm(request.POST)
if form.is_valid():
ct_id = form.cleaned_data['ziel_typ']
obj_id = form.cleaned_data['ziel_id']
ct = get_object_or_404(ContentType, pk=ct_id)
vk = AufgabenVerknuepfung.objects.create(
aufgabe=aufgabe,
content_type=ct,
object_id=obj_id,
kommentar=form.cleaned_data['kommentar'],
)
if _is_htmx(request):
return render(request, 'aufgaben/partials/verknuepfung_row.html',
{'v': vk, 'aufgabe': aufgabe, 'ausschreibung': ausschreibung})
return redirect('ausschreibungen:aufgaben:detail',
ausschreibung_id=ausschreibung_id, pk=pk)
else:
form = AufgabenVerknuepfungForm()
if _is_htmx(request):
return render(request, 'aufgaben/partials/verknuepfung_form_inline.html', {
'form': form,
'aufgabe': aufgabe,
'ausschreibung': ausschreibung,
})
return redirect('ausschreibungen:aufgaben:detail', ausschreibung_id=ausschreibung_id, pk=pk)
def verknuepfung_objekte(request, ausschreibung_id, pk):
from django.contrib.contenttypes.models import ContentType
ct_id = request.GET.get('ziel_typ') or request.GET.get('ct_id')
objekte = []
if ct_id:
try:
ct = ContentType.objects.get(pk=ct_id)
model_class = ct.model_class()
if model_class is not None:
objekte = list(model_class.objects.all()[:200])
except ContentType.DoesNotExist:
pass
return render(request, 'aufgaben/partials/verknuepfung_objekte_select.html', {
'objekte': objekte,
'ct_id': ct_id,
})
def verknuepfung_loeschen(request, ausschreibung_id, pk, vk_pk):
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
aufgabe = get_object_or_404(Aufgabe, pk=pk, ausschreibung=ausschreibung)
vk = get_object_or_404(AufgabenVerknuepfung, pk=vk_pk, aufgabe=aufgabe)
if request.method == 'POST':
vk.delete()
if _is_htmx(request):
return HttpResponse('')
return redirect('ausschreibungen:aufgaben:detail',
ausschreibung_id=ausschreibung_id, pk=pk)
return render(request, 'aufgaben/partials/verknuepfung_loeschen_confirm.html', {
'v': vk,
'aufgabe': aufgabe,
'ausschreibung': ausschreibung,
})
# ─── External Issue ───────────────────────────────────────────────────────────
def external_issue_bearbeiten(request, ausschreibung_id, pk):
from .forms import ExternalIssueForm
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
aufgabe = get_object_or_404(Aufgabe, pk=pk, ausschreibung=ausschreibung)
issue = getattr(aufgabe, 'external_issue', None)
if request.method == 'POST':
form = ExternalIssueForm(request.POST, instance=issue)
if form.is_valid():
ei = form.save(commit=False)
ei.aufgabe = aufgabe
ei.save()
if _is_htmx(request):
return render(request, 'aufgaben/partials/external_issue_panel.html', {
'aufgabe': aufgabe,
'ausschreibung': ausschreibung,
})
return redirect('ausschreibungen:aufgaben:detail',
ausschreibung_id=ausschreibung_id, pk=pk)
else:
form = ExternalIssueForm(instance=issue)
if _is_htmx(request):
return render(request, 'aufgaben/partials/external_issue_form.html', {
'form': form,
'aufgabe': aufgabe,
'ausschreibung': ausschreibung,
})
return redirect('ausschreibungen:aufgaben:detail', ausschreibung_id=ausschreibung_id, pk=pk)
def external_issue_panel(request, ausschreibung_id, pk):
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
aufgabe = get_object_or_404(Aufgabe, pk=pk, ausschreibung=ausschreibung)
return render(request, 'aufgaben/partials/external_issue_panel.html', {
'aufgabe': aufgabe,
'ausschreibung': ausschreibung,
})
def external_issue_loeschen(request, ausschreibung_id, pk):
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
aufgabe = get_object_or_404(Aufgabe, pk=pk, ausschreibung=ausschreibung)
if request.method == 'POST':
ExternalIssue.objects.filter(aufgabe=aufgabe).delete()
if _is_htmx(request):
return render(request, 'aufgaben/partials/external_issue_panel.html', {
'aufgabe': aufgabe,
'ausschreibung': ausschreibung,
})
return redirect('ausschreibungen:aufgaben:detail',
ausschreibung_id=ausschreibung_id, pk=pk)
return redirect('ausschreibungen:aufgaben:detail', ausschreibung_id=ausschreibung_id, pk=pk)