--- id: WP-0015 title: Aufgaben — Verknüpfungen, implizite Fälligkeit, Issue-Facade status: done 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: done `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: done **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: done 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: done **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: done **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: done **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: done **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: done 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 ``` ```