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

423 lines
13 KiB
Markdown

---
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 %}
<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`:
```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 -->
<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`:
```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
<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`:
```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('<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`:
```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 -->
<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
```
```