- 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>
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:
-
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).
-
Implizite Fälligkeit: Hat eine Aufgabe kein
frist-Datum, gilt sie nach 7 Tagen ab Erstellungsdatum als überfällig. Dazu wirderstellt_amaufAufgabeergänzt und die Überfälligkeitsprüfung angepasst. -
Issue-Facade: Eine optionale Schnittstelle, um eine Aufgabe mit einem externen Issue-Tracker (GitHub Issues, Jira, Linear, …) zu verknüpfen. Das Modell
ExternalIssuehä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