Files
vergabe-teilnahme/workplans/WP-0015-aufgaben-verknuepfungen-frist-issuefacade.md
tegwick 816c281f6a 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>
2026-05-14 04:18:28 +02:00

13 KiB

id, title, status, phase, created, depends_on
id title status phase created depends_on
WP-0015 Aufgaben — Verknüpfungen, implizite Fälligkeit, Issue-Facade done 15-of-n 2026-05-14 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.


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:

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:

@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:

# 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:

{% if aufgabe.frist %}
  {{ aufgabe.frist }}
{% else %}
  <span class="text-slate-400">keine</span>
  <span class="text-xs text-amber-600">
    (implizit fällig: {{ aufgabe.frist_effektiv|date:"d.m.Y" }})
  </span>
{% 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('<int:pk>/verknuepfungen/', views.verknuepfungen_liste, name='verknuepfungen'),
path('<int:pk>/verknuepfungen/neu/', views.verknuepfung_neu, name='verknuepfung_neu'),

Form AufgabenVerknuepfungForm in apps/aufgaben/forms.py:

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:

<!-- Verknüpfungen -->
<div class="card">
  <div class="flex items-center justify-between mb-2">
    <p class="text-xs font-medium text-slate-500 uppercase tracking-wide">Verknüpfungen</p>
    <button class="btn-ghost text-xs"
            hx-get="{% url 'ausschreibungen:aufgaben:verknuepfung_neu' ausschreibung.pk aufgabe.pk %}"
            hx-target="#verknuepfung-form-container"
            hx-swap="innerHTML">+ Verknüpfen</button>
  </div>
  <div id="verknuepfung-form-container"></div>
  <ul id="verknuepfungen-list" class="space-y-1 mt-1">
    {% for v in aufgabe.verknuepfungen.all %}
    {% include "aufgaben/partials/verknuepfung_row.html" %}
    {% endfor %}
  </ul>
</div>

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('<int:pk>/verknuepfungen/<int:vk_pk>/loeschen/',
     views.verknuepfung_loeschen, name='verknuepfung_loeschen'),

View verknuepfung_loeschen:

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":

<button hx-post="{% url '...verknuepfung_loeschen' ausschreibung.pk aufgabe.pk v.pk %}"
        hx-confirm="Verknüpfung entfernen?"
        hx-target="closest li"
        hx-swap="outerHTML"
        class="btn-ghost text-xs text-red-500">Entfernen</button>

```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:

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('<int:pk>/issue/', views.external_issue_bearbeiten, name='external_issue'),
path('<int:pk>/issue/loeschen/', views.external_issue_loeschen, name='external_issue_loeschen'),

Form ExternalIssueForm in forms.py:

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):

<!-- Externes Issue -->
<div class="card" id="external-issue-panel">
  <p class="text-xs font-medium text-slate-500 uppercase tracking-wide mb-2">Externes Issue</p>
  {% if aufgabe.external_issue %}
    {% include "aufgaben/partials/external_issue_card.html" %}
  {% else %}
    <p class="text-sm text-slate-400">Kein externes Issue verknüpft.</p>
    <button class="btn-ghost text-xs mt-2"
            hx-get="{% url 'ausschreibungen:aufgaben:external_issue' aufgabe.ausschreibung_id aufgabe.pk %}"
            hx-target="#external-issue-panel"
            hx-swap="innerHTML">+ Verknüpfen</button>
  {% endif %}
</div>

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