From a72360cc94d09128d626326b482870d6c85cc057 Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 14 May 2026 03:36:43 +0200 Subject: [PATCH] =?UTF-8?q?docs(workplan):=20WP-0015=20=E2=80=94=20Aufgabe?= =?UTF-8?q?n-Verkn=C3=BCpfungen,=20implizite=20F=C3=A4lligkeit,=20Issue-Fa?= =?UTF-8?q?cade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- ...fgaben-verknuepfungen-frist-issuefacade.md | 422 ++++++++++++++++++ 1 file changed, 422 insertions(+) create mode 100644 workplans/WP-0015-aufgaben-verknuepfungen-frist-issuefacade.md diff --git a/workplans/WP-0015-aufgaben-verknuepfungen-frist-issuefacade.md b/workplans/WP-0015-aufgaben-verknuepfungen-frist-issuefacade.md new file mode 100644 index 0000000..926e5f9 --- /dev/null +++ b/workplans/WP-0015-aufgaben-verknuepfungen-frist-issuefacade.md @@ -0,0 +1,422 @@ +--- +id: WP-0015 +title: Aufgaben — Verknüpfungen, implizite Fälligkeit, Issue-Facade +status: todo +phase: 15-of-n +created: "2026-05-14" +depends_on: WP-0014 +--- + +# WP-0015 — Aufgaben: Verknüpfungen, implizite Fälligkeit, Issue-Facade + +Drei eigenständige Erweiterungen des Aufgaben-Moduls: + +1. **Verknüpfungen**: Jede Aufgabe kann mit beliebigen anderen Entitäten + (Anforderung, Los, Dokument, Bieterfrage, Preispunkt, …) verknüpft + werden — via ContentType/GenericForeignKey. Jede Verknüpfung trägt + einen Kommentar. Die Detailseite der Aufgabe zeigt alle Verknüpfungen + mit HTMX-Inline-Verwaltung (hinzufügen / entfernen). + +2. **Implizite Fälligkeit**: Hat eine Aufgabe kein `frist`-Datum, gilt sie + nach 7 Tagen ab Erstellungsdatum als überfällig. Dazu wird `erstellt_am` + auf `Aufgabe` ergänzt und die Überfälligkeitsprüfung angepasst. + +3. **Issue-Facade**: Eine optionale Schnittstelle, um eine Aufgabe mit einem + externen Issue-Tracker (GitHub Issues, Jira, Linear, …) zu verknüpfen. + Das Modell `ExternalIssue` hält System, URL/Key und Status. Ein + Service-Interface definiert die Adapter-API für spätere Implementierungen. + UI: Panel auf der Aufgaben-Detailseite zum Hinzufügen / Bearbeiten / + Entfernen der externen Verknüpfung. + +--- + +```task +id: WP-0015-T01 +title: Aufgabe.erstellt_am — Feld + Migration +status: todo + +`apps/aufgaben/models.py` — Feld ergänzen: + +```python +erstellt_am = models.DateTimeField(auto_now_add=True) +``` + +Migration erstellen und ausführen: + +```bash +uv run python manage.py makemigrations aufgaben --name erstellt_am +uv run python manage.py migrate +``` + +Bestehende Rows erhalten automatisch den NOW()-Zeitstempel (Django-Default +für auto_now_add bei ALTER TABLE). +``` + +```task +id: WP-0015-T02 +title: Implizite Fälligkeit — Property + Überfälligkeitsprüfung +status: todo + +**Modell** (`apps/aufgaben/models.py`): + +Property `frist_effektiv` auf `Aufgabe`: + +```python +from datetime import timedelta +from django.utils import timezone + +@property +def frist_effektiv(self): + if self.frist: + return self.frist + return (self.erstellt_am + timedelta(days=7)).date() +``` + +`ist_ueberfaellig` auf die neue Property umstellen: + +```python +@property +def ist_ueberfaellig(self): + return ( + self.frist_effektiv < date.today() + and self.status not in ['erledigt', 'verworfen'] + ) +``` + +**View** (`apps/aufgaben/views.py`), `aufgaben_liste`: + +Die bestehende Update-Zeile filtert nur nach `frist__lt=heute`. Aufgaben +ohne Frist werden nie als überfällig markiert. Korrektur: + +```python +# Aufgaben mit expliziter Frist markieren +qs.filter( + frist__lt=heute, + status__in=AKTIVE_STATUS, +).update(status='ueberfaellig') + +# Aufgaben ohne Frist — implizite 7-Tage-Frist +from datetime import timedelta +from django.utils import timezone +implizite_grenze = timezone.now() - timedelta(days=7) +qs.filter( + frist__isnull=True, + erstellt_am__lt=implizite_grenze, + status__in=AKTIVE_STATUS, +).update(status='ueberfaellig') +``` + +**Template** (`templates/aufgaben/detail.html`): Frist-Zeile ergänzen, +sodass bei leerem `frist` die effektive Frist als "(implizit: TT.MM.JJJJ)" +angezeigt wird: + +```html +{% if aufgabe.frist %} + {{ aufgabe.frist }} +{% else %} + keine + + (implizit fällig: {{ aufgabe.frist_effektiv|date:"d.m.Y" }}) + +{% endif %} +``` +``` + +```task +id: WP-0015-T03 +title: AufgabenVerknuepfung — Modell + Migration + Admin +status: todo + +Neues Modell in `apps/aufgaben/models.py`: + +```python +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType + +class AufgabenVerknuepfung(models.Model): + aufgabe = models.ForeignKey( + Aufgabe, on_delete=models.CASCADE, related_name='verknuepfungen' + ) + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + object_id = models.PositiveIntegerField() + ziel = GenericForeignKey('content_type', 'object_id') + kommentar = models.TextField(blank=True) + erstellt_am = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['erstellt_am'] + verbose_name = 'Aufgaben-Verknüpfung' + verbose_name_plural = 'Aufgaben-Verknüpfungen' + + def __str__(self): + return f'{self.aufgabe} → {self.content_type} #{self.object_id}' +``` + +Migration erstellen und ausführen. + +`apps/aufgaben/admin.py` — Inline unter `AufgabeAdmin` registrieren. + +Hilfsfunktion `verknuepfbare_typen()` in einem neuen Modul +`apps/aufgaben/link_registry.py` — gibt eine geordnete Liste der +zulässigen ContentTypes zurück (Anforderung, Los, Bieterfrage, Dokument, +Preispunkt). Wird in der Form als Auswahlfeld verwendet. +``` + +```task +id: WP-0015-T04 +title: Verknüpfungen-View — Liste + Hinzufügen (HTMX) +status: todo + +**URLs** (`apps/aufgaben/urls.py`) ergänzen: + +```python +path('/verknuepfungen/', views.verknuepfungen_liste, name='verknuepfungen'), +path('/verknuepfungen/neu/', views.verknuepfung_neu, name='verknuepfung_neu'), +``` + +**Form** `AufgabenVerknuepfungForm` in `apps/aufgaben/forms.py`: + +```python +class AufgabenVerknuepfungForm(forms.ModelForm): + # Auswahlfeld für den Ziel-Typ (ContentType) + ziel_typ = forms.ChoiceField(choices=...) # befüllt aus link_registry + ziel_id = forms.IntegerField() + kommentar = forms.CharField(widget=forms.Textarea(...), required=False) + + class Meta: + model = AufgabenVerknuepfung + fields = ['kommentar'] +``` + +Nach Auswahl des `ziel_typ` lädt ein zweites HTMX-Request die passenden +Objekte dynamisch nach (zweistufige Auswahl: erst Typ, dann Objekt). + +**Views** `verknuepfungen_liste` und `verknuepfung_neu` — +analog zu den bestehenden HTMX-Inline-Views (Lose, Aufgaben): + +- GET (HTMX) → `aufgaben/partials/verknuepfung_form_inline.html` +- POST (HTMX) → `aufgaben/partials/verknuepfung_row.html` +- Vollseite → Redirect zur Aufgaben-Detailseite + +**Template** `templates/aufgaben/detail.html` — neues Panel in der +rechten Spalte unterhalb der bestehenden Sidebar-Cards: + +```html + +
+
+

Verknüpfungen

+ +
+
+
    + {% for v in aufgabe.verknuepfungen.all %} + {% include "aufgaben/partials/verknuepfung_row.html" %} + {% endfor %} +
+
+``` + +`verknuepfung_row.html` zeigt: Typ-Badge, Link zum Zielobjekt (wenn URL +ermittelbar), Kommentar, Löschen-Button. +``` + +```task +id: WP-0015-T05 +title: Verknüpfungen-View — Entfernen (HTMX DELETE) +status: todo + +**URL** in `apps/aufgaben/urls.py`: + +```python +path('/verknuepfungen//loeschen/', + views.verknuepfung_loeschen, name='verknuepfung_loeschen'), +``` + +**View** `verknuepfung_loeschen`: + +```python +def verknuepfung_loeschen(request, ausschreibung_id, pk, vk_pk): + aufgabe = get_object_or_404(Aufgabe, pk=pk, ausschreibung_id=ausschreibung_id) + vk = get_object_or_404(AufgabenVerknuepfung, pk=vk_pk, aufgabe=aufgabe) + if request.method == 'POST': + vk.delete() + if _is_htmx(request): + return HttpResponse('') # leere Antwort → HTMX entfernt das Element + return redirect('ausschreibungen:aufgaben:detail', ...) + return render(request, 'aufgaben/verknuepfung_loeschen_confirm.html', {...}) +``` + +Im `verknuepfung_row.html` Löschen-Button als HTMX-POST mit +`hx-confirm` und `hx-target="closest li"` + `hx-swap="outerHTML"`: + +```html + +``` +``` + +```task +id: WP-0015-T06 +title: ExternalIssue — Modell + Migration + Service-Interface +status: todo + +**Modell** in einem neuen Modul `apps/aufgaben/issue_models.py` +(oder direkt in `models.py`): + +```python +class ExternalIssue(models.Model): + SYSTEM_CHOICES = [ + ('github', 'GitHub Issues'), + ('jira', 'Jira'), + ('linear', 'Linear'), + ('azure', 'Azure DevOps'), + ('sonstiges', 'Sonstiges'), + ] + SYNC_STATUS_CHOICES = [ + ('manuell', 'Manuell'), + ('offen', 'Offen (extern)'), + ('geschlossen', 'Geschlossen (extern)'), + ('fehler', 'Sync-Fehler'), + ] + + aufgabe = models.OneToOneField( + Aufgabe, on_delete=models.CASCADE, related_name='external_issue' + ) + system = models.CharField(max_length=20, choices=SYSTEM_CHOICES) + issue_url = models.URLField(blank=True) + issue_key = models.CharField(max_length=100, blank=True, + help_text='z.B. "GH-42" oder "PROJ-1234"') + sync_status = models.CharField(max_length=20, choices=SYNC_STATUS_CHOICES, + default='manuell') + letzter_sync = models.DateTimeField(null=True, blank=True) + notizen = models.TextField(blank=True) + erstellt_am = models.DateTimeField(auto_now_add=True) + + class Meta: + verbose_name = 'External Issue' + verbose_name_plural = 'External Issues' + + def __str__(self): + return f'{self.get_system_display()} {self.issue_key or self.issue_url}' +``` + +**Service-Interface** `apps/aufgaben/issue_facade.py`: + +```python +from abc import ABC, abstractmethod + +class IssueAdapter(ABC): + """ + Adapter-Basisklasse. Jeder externe Issue-Tracker implementiert diese + Schnittstelle. Registrierung via ISSUE_ADAPTERS-Dict in settings. + """ + + @abstractmethod + def create_issue(self, aufgabe) -> dict: + """Legt ein Issue im externen System an. Gibt {'url', 'key'} zurück.""" + + @abstractmethod + def fetch_status(self, external_issue) -> str: + """Liest den aktuellen Status aus dem externen System.""" + + @abstractmethod + def close_issue(self, external_issue) -> None: + """Schließt das Issue im externen System.""" + + +def get_adapter(system: str) -> IssueAdapter | None: + """Gibt den registrierten Adapter für `system` zurück, oder None.""" + from django.conf import settings + adapters = getattr(settings, 'ISSUE_ADAPTERS', {}) + cls = adapters.get(system) + return cls() if cls else None +``` + +Migration erstellen und ausführen. Admin registrieren. +``` + +```task +id: WP-0015-T07 +title: Issue-Facade UI — Panel auf Aufgaben-Detailseite +status: todo + +**URLs** in `apps/aufgaben/urls.py`: + +```python +path('/issue/', views.external_issue_bearbeiten, name='external_issue'), +path('/issue/loeschen/', views.external_issue_loeschen, name='external_issue_loeschen'), +``` + +**Form** `ExternalIssueForm` in `forms.py`: + +```python +class ExternalIssueForm(forms.ModelForm): + class Meta: + model = ExternalIssue + fields = ['system', 'issue_url', 'issue_key', 'notizen'] + widgets = {f: forms.Select/Input(attrs={'class': 'form-input'}) ...} +``` + +**Views**: + +`external_issue_bearbeiten` — GET/POST, erstellt oder aktualisiert das +`ExternalIssue`-Objekt zur Aufgabe (OneToOne: immer genau ein Objekt oder +keins). HTMX: gibt bei Erfolg das Panel-Fragment zurück. + +`external_issue_loeschen` — POST, löscht das `ExternalIssue`-Objekt. + +**Template** `templates/aufgaben/detail.html` — neues Card-Panel in der +rechten Spalte (unterhalb Verknüpfungen): + +```html + +
+

Externes Issue

+ {% if aufgabe.external_issue %} + {% include "aufgaben/partials/external_issue_card.html" %} + {% else %} +

Kein externes Issue verknüpft.

+ + {% endif %} +
+``` + +Partial `external_issue_card.html` zeigt: System-Badge, Link (issue_url), +Key, Sync-Status, Notizen, Buttons "Bearbeiten" und "Entfernen". +``` + +```task +id: WP-0015-T08 +title: Tests + Smoke-Check +status: todo + +Bestehende 68 Tests müssen grün bleiben. + +Neue Tests in `apps/aufgaben/tests.py`: + +- `test_frist_effektiv_mit_frist` — explizite Frist wird zurückgegeben +- `test_frist_effektiv_ohne_frist` — implizite Frist = erstellt_am + 7 Tage +- `test_ueberfaellig_ohne_frist_nach_7_tagen` — Aufgabe ohne Frist wird + nach 7 Tagen als überfällig eingestuft +- `test_aufgaben_verknuepfung_erstellen` — Verknüpfung anlegen via View +- `test_aufgaben_verknuepfung_loeschen` — Verknüpfung entfernen via View +- `test_external_issue_erstellen` — ExternalIssue via View anlegen +- `test_external_issue_loeschen` — ExternalIssue via View entfernen +- `test_issue_adapter_interface` — get_adapter gibt None zurück wenn kein + Adapter registriert ist + +```bash +uv run pytest vergabe_teilnahme/apps/aufgaben/tests.py -v +uv run pytest vergabe_teilnahme/ -q # alle Tests +``` +```