generated from coulomb/repo-seed
docs(workplan): WP-0015 — Aufgaben-Verknüpfungen, implizite Fälligkeit, Issue-Facade
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
422
workplans/WP-0015-aufgaben-verknuepfungen-frist-issuefacade.md
Normal file
422
workplans/WP-0015-aufgaben-verknuepfungen-frist-issuefacade.md
Normal file
@@ -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 %}
|
||||
<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: 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('<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: todo
|
||||
|
||||
**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: 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('<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: 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
|
||||
```
|
||||
```
|
||||
Reference in New Issue
Block a user