diff --git a/workplans/WP-0016-issue-facade-integration.md b/workplans/WP-0016-issue-facade-integration.md new file mode 100644 index 0000000..786702c --- /dev/null +++ b/workplans/WP-0016-issue-facade-integration.md @@ -0,0 +1,574 @@ +--- +id: WP-0016 +title: Issue-Facade Integration — lokale Aufgabenverfolgung + Remote-Delegation +status: todo +phase: 16-of-n +created: "2026-05-14" +depends_on: WP-0015 +--- + +# WP-0016 — Issue-Facade Integration + +Ersetzt den Platzhalter-`issue_facade.py` aus WP-0015 durch eine echte +Integration mit dem Sister-Projekt `issue-facade` (`/home/worsch/issue-facade`, +Package-Name `universal-issue-tracker`). + +**Leitgedanke:** Aufgaben werden zunächst lokal im SQLite-Backend von +`issue-facade` verwaltet (offline-fähig, keine externe Abhängigkeit). Optional +können sie in ein entferntes System (zunächst Gitea) delegiert werden. Der +Status wird von dort zurückgelesen. + +**Key interfaces aus dem Sister-Projekt:** + +- `issue_tracker.core.models.Issue` — `id` (UUID), `number`, `title`, + `description`, `state` (IssueState: open/closed/in_progress/blocked) +- `issue_tracker.core.interfaces.IssueBackend` ABC — `create_issue()`, + `get_issue()`, `update_issue()`, `list_issues()` +- `issue_tracker.backends.local.LocalSQLiteBackend` — + `connect({'db_path': ...})` +- `issue_tracker.backends.gitea.GiteaBackend` — + `connect({'base_url', 'token', 'owner', 'repo'})` + +--- + +```task +id: WP-0016-T01 +title: Package-Installation + Django-Settings +status: todo + +**`pyproject.toml`** — Dependency ergänzen: + +```toml +[project] +dependencies = [ + ... + "universal-issue-tracker @ file:///home/worsch/issue-facade", +] +``` + +Danach: +```bash +uv sync +``` + +**`vergabe_teilnahme/settings/base.py`** — neue Optionen am Ende: + +```python +# Issue Facade — lokales SQLite-Backend (immer aktiv) +ISSUE_FACADE_LOCAL_DB = BASE_DIR / '.issue-facade' / 'issues.db' + +# Issue Facade — Gitea-Remote (optional, None = deaktiviert) +ISSUE_FACADE_GITEA: dict | None = None +# Beispiel: +# ISSUE_FACADE_GITEA = { +# 'base_url': 'https://gitea.example.com', +# 'token': env('GITEA_TOKEN', default=''), +# 'owner': 'org', +# 'repo': 'vergabe', +# } +``` + +**`.gitignore`** — SQLite-DB ausschließen: + +``` +.issue-facade/ +``` + +Smoke-Check: +```bash +uv run python -c "from issue_tracker.backends.local import LocalSQLiteBackend; print('ok')" +``` +``` + +```task +id: WP-0016-T02 +title: Backend-Utility — issue_backends.py +status: todo + +Neues Modul `vergabe_teilnahme/apps/aufgaben/issue_backends.py`: + +```python +from contextlib import contextmanager +from pathlib import Path + + +@contextmanager +def local_backend(): + from django.conf import settings + from issue_tracker.backends.local import LocalSQLiteBackend + + db_path = str(getattr(settings, 'ISSUE_FACADE_LOCAL_DB', '.issue-facade/issues.db')) + Path(db_path).parent.mkdir(parents=True, exist_ok=True) + + b = LocalSQLiteBackend() + b.connect({'db_path': db_path}) + try: + yield b + finally: + b.disconnect() + + +@contextmanager +def remote_backend(): + """Yields GiteaBackend wenn konfiguriert, sonst None.""" + from django.conf import settings + from issue_tracker.backends.gitea import GiteaBackend + + cfg = getattr(settings, 'ISSUE_FACADE_GITEA', None) + if not cfg: + yield None + return + + b = GiteaBackend() + b.connect(cfg) + try: + yield b + finally: + b.disconnect() + + +def gitea_configured() -> bool: + from django.conf import settings + cfg = getattr(settings, 'ISSUE_FACADE_GITEA', None) + return bool(cfg and cfg.get('token')) +``` + +Kontext-Manager-Pattern (kein Prozess-globaler Singleton, SQLite ist +pro Request cheap genug für eine Einzelbenutzer-App). +``` + +```task +id: WP-0016-T03 +title: ExternalIssue-Modell erweitern + Migration +status: todo + +In `apps/aufgaben/models.py` das Modell `ExternalIssue` anpassen: + +```python +class ExternalIssue(models.Model): + BACKEND_CHOICES = [ + ('local', 'Lokal (SQLite)'), + ('gitea', 'Gitea'), + ] + SYNC_STATUS_CHOICES = [ + ('open', 'Offen'), + ('in_progress', 'In Bearbeitung'), + ('blocked', 'Blockiert'), + ('closed', 'Geschlossen'), + ('error', 'Sync-Fehler'), + ] + + aufgabe = models.OneToOneField( + Aufgabe, on_delete=models.CASCADE, related_name='external_issue' + ) + issue_facade_backend = models.CharField( + max_length=20, choices=BACKEND_CHOICES, default='local' + ) + issue_facade_id = models.CharField( + max_length=200, blank=True, + help_text='UUID (lokal) oder Issue-Number (Gitea)' + ) + issue_url = models.URLField(blank=True) + issue_key = models.CharField(max_length=100, blank=True, + help_text='z.B. "#42" oder "PROJ-1234"') + sync_status = models.CharField( + max_length=20, choices=SYNC_STATUS_CHOICES, default='open' + ) + 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_issue_facade_backend_display()} #{self.issue_key or self.issue_facade_id[:8]}' +``` + +**Änderungen vs. WP-0015:** +- `system` (github/jira/linear/…) entfällt — ersetzt durch `issue_facade_backend` +- `issue_facade_id` neu — UUID für lokale Issues, Issue-Number-String für Gitea +- `sync_status`-Choices auf IssueState-Werte ausgerichtet + +Migration erstellen und ausführen: + +```bash +uv run python manage.py makemigrations aufgaben --name external_issue_redesign +uv run python manage.py migrate +``` +``` + +```task +id: WP-0016-T04 +title: Service-Schicht — issue_facade.py ersetzen +status: todo + +`vergabe_teilnahme/apps/aufgaben/issue_facade.py` **komplett ersetzen**: + +```python +from datetime import datetime, timezone + +from issue_tracker.core.models import ( + Issue, IssueState, IssueType, Label, Priority +) + +from .issue_backends import gitea_configured, local_backend, remote_backend + + +def aufgabe_zu_issue(aufgabe) -> Issue: + """Konvertiert eine Aufgabe in ein issue-facade Issue-Objekt.""" + prioritaet_map = {1: Priority.HIGH, 2: Priority.MEDIUM, 3: Priority.LOW} + labels = [ + Label(name='task'), + Label(name=f'priority:{prioritaet_map.get(aufgabe.prioritaet, Priority.MEDIUM).value}'), + ] + if aufgabe.typ: + labels.append(Label(name=aufgabe.typ)) + + now = datetime.now(timezone.utc) + return Issue( + id=None, number=0, + title=aufgabe.titel, + description=aufgabe.beschreibung or '', + state=IssueState.OPEN, + created_at=now, + updated_at=now, + labels=labels, + ) + + +def lokales_issue_erstellen(aufgabe) -> dict: + """ + Legt ein Issue im lokalen SQLite-Backend an. + Gibt {'issue_facade_id', 'issue_key', 'sync_status'} zurück. + """ + issue = aufgabe_zu_issue(aufgabe) + with local_backend() as b: + created = b.create_issue(issue) + return { + 'issue_facade_id': created.id, + 'issue_key': f'#{created.number}', + 'sync_status': created.state.value, + } + + +def an_gitea_delegieren(aufgabe, external_issue) -> dict: + """ + Schiebt ein lokales Issue nach Gitea. + Gibt {'issue_facade_id', 'issue_url', 'issue_key', 'sync_status'} zurück. + Wirft ValueError wenn Gitea nicht konfiguriert ist. + """ + with remote_backend() as b: + if b is None: + raise ValueError('Gitea nicht konfiguriert (ISSUE_FACADE_GITEA fehlt in settings)') + issue = aufgabe_zu_issue(aufgabe) + created = b.create_issue(issue) + return { + 'issue_facade_backend': 'gitea', + 'issue_facade_id': str(created.number), + 'issue_url': created.sync_metadata.get('url', '') if created.sync_metadata else '', + 'issue_key': f'#{created.number}', + 'sync_status': created.state.value, + } + + +def status_synchronisieren(external_issue) -> str: + """ + Liest den aktuellen Status aus dem konfigurierten Backend. + Gibt den neuen sync_status-String zurück. + """ + from django.utils import timezone as dj_tz + + backend_ctx = ( + remote_backend() if external_issue.issue_facade_backend == 'gitea' + else local_backend() + ) + with backend_ctx as b: + if b is None: + raise ValueError('Backend nicht verfügbar') + issue = b.get_issue(external_issue.issue_facade_id) + + neuer_status = issue.state.value if issue else 'error' + external_issue.sync_status = neuer_status + external_issue.letzter_sync = dj_tz.now() + external_issue.save(update_fields=['sync_status', 'letzter_sync']) + return neuer_status + + +def issue_schliessen(external_issue) -> None: + """Setzt das Issue im Backend auf CLOSED.""" + backend_ctx = ( + remote_backend() if external_issue.issue_facade_backend == 'gitea' + else local_backend() + ) + with backend_ctx as b: + if b is None: + return + issue = b.get_issue(external_issue.issue_facade_id) + if issue: + issue.state = IssueState.CLOSED + b.update_issue(issue) + external_issue.sync_status = 'closed' + external_issue.save(update_fields=['sync_status']) + + +def get_adapter(system: str): + """Rückwärtskompatible Stub — gibt None zurück (kein manuelles Adapter-Dict mehr).""" + return None +``` + +Der ABC `IssueAdapter` aus WP-0015 entfällt damit vollständig — +`issue-facade` übernimmt diese Rolle. +``` + +```task +id: WP-0016-T05 +title: ExternalIssueForm anpassen + Views verkabeln +status: todo + +**Form** (`forms.py`) — vereinfacht, da `system` entfällt: + +```python +class ExternalIssueForm(forms.ModelForm): + class Meta: + model = ExternalIssue + fields = ['notizen'] + widgets = { + 'notizen': forms.Textarea(attrs={'class': 'form-input', 'rows': 2, + 'placeholder': 'Notizen (optional)'}), + } +``` + +(Backend und issue_facade_id werden vom Service gesetzt, nicht vom User.) + +**`views.py`** — `external_issue_bearbeiten` anpassen: + +```python +def external_issue_bearbeiten(request, ausschreibung_id, pk): + from .issue_facade import lokales_issue_erstellen + from .forms import ExternalIssueForm + + ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id) + aufgabe = get_object_or_404(Aufgabe, pk=pk, ausschreibung=ausschreibung) + issue = getattr(aufgabe, 'external_issue', None) + + if request.method == 'POST': + form = ExternalIssueForm(request.POST, instance=issue) + if form.is_valid(): + ei = form.save(commit=False) + ei.aufgabe = aufgabe + if not issue: # Neues Issue — im lokalen Backend anlegen + daten = lokales_issue_erstellen(aufgabe) + ei.issue_facade_backend = 'local' + ei.issue_facade_id = daten['issue_facade_id'] + ei.issue_key = daten['issue_key'] + ei.sync_status = daten['sync_status'] + ei.save() + if _is_htmx(request): + return render(request, + 'aufgaben/partials/external_issue_panel.html', + {'aufgabe': aufgabe, 'ausschreibung': ausschreibung}) + return redirect('ausschreibungen:aufgaben:detail', + ausschreibung_id=ausschreibung_id, pk=pk) + else: + form = ExternalIssueForm(instance=issue) + + if _is_htmx(request): + return render(request, 'aufgaben/partials/external_issue_form.html', + {'form': form, 'aufgabe': aufgabe, 'ausschreibung': ausschreibung}) + return redirect('ausschreibungen:aufgaben:detail', + ausschreibung_id=ausschreibung_id, pk=pk) +``` + +Zwei neue Views ergänzen: + +```python +def external_issue_push_remote(request, ausschreibung_id, pk): + """Schiebt ein lokales Issue nach Gitea.""" + from .issue_facade import an_gitea_delegieren + ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id) + aufgabe = get_object_or_404(Aufgabe, pk=pk, ausschreibung=ausschreibung) + ei = get_object_or_404(ExternalIssue, aufgabe=aufgabe) + if request.method == 'POST': + try: + daten = an_gitea_delegieren(aufgabe, ei) + for k, v in daten.items(): + setattr(ei, k, v) + ei.save() + except ValueError as exc: + # Gitea nicht konfiguriert — ignorieren, Fehlermeldung im Panel + pass + if _is_htmx(request): + return render(request, + 'aufgaben/partials/external_issue_panel.html', + {'aufgabe': aufgabe, 'ausschreibung': ausschreibung, + 'push_error': str(exc) if 'exc' in dir() else None}) + return redirect('ausschreibungen:aufgaben:detail', + ausschreibung_id=ausschreibung_id, pk=pk) + + +def external_issue_sync(request, ausschreibung_id, pk): + """Aktualisiert sync_status aus dem Backend.""" + from .issue_facade import status_synchronisieren + ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id) + aufgabe = get_object_or_404(Aufgabe, pk=pk, ausschreibung=ausschreibung) + ei = get_object_or_404(ExternalIssue, aufgabe=aufgabe) + if request.method == 'POST': + try: + status_synchronisieren(ei) + except Exception: + ei.sync_status = 'error' + ei.save(update_fields=['sync_status']) + if _is_htmx(request): + return render(request, + 'aufgaben/partials/external_issue_panel.html', + {'aufgabe': aufgabe, 'ausschreibung': ausschreibung}) + return redirect('ausschreibungen:aufgaben:detail', + ausschreibung_id=ausschreibung_id, pk=pk) +``` + +URLs in `urls.py` ergänzen: + +```python +path('/issue/push/', views.external_issue_push_remote, name='external_issue_push'), +path('/issue/sync/', views.external_issue_sync, name='external_issue_sync'), +``` +``` + +```task +id: WP-0016-T06 +title: Admin + ExternalIssueAdmin aktualisieren +status: todo + +`apps/aufgaben/admin.py` — `ExternalIssueAdmin` an neue Felder anpassen: + +```python +@admin.register(ExternalIssue) +class ExternalIssueAdmin(admin.ModelAdmin): + list_display = ['aufgabe', 'issue_facade_backend', 'issue_key', + 'sync_status', 'letzter_sync'] + list_filter = ['issue_facade_backend', 'sync_status'] + readonly_fields = ['issue_facade_id', 'issue_key', 'issue_url', + 'sync_status', 'letzter_sync', 'erstellt_am'] +``` +``` + +```task +id: WP-0016-T07 +title: UI — ExternalIssue-Panel aktualisieren +status: todo + +`templates/aufgaben/partials/external_issue_card.html` überarbeiten: + +```html +{% with ei=aufgabe.external_issue %} +
+
+ + {% if ei.issue_facade_backend == 'local' %} + LOKAL + {% else %} + GITEA + {% endif %} + + + {% if ei.issue_url %} + {{ ei.issue_key }} + {% else %} + {{ ei.issue_key }} + {% endif %} + + + + {{ ei.get_sync_status_display }} + + + + {% if ei.letzter_sync %} + Sync: {{ ei.letzter_sync|date:"d.m.Y H:i" }} + {% endif %} +
+ + {% if ei.notizen %} +

{{ ei.notizen }}

+ {% endif %} + +
+ + + + + {% if ei.issue_facade_backend == 'local' and gitea_configured %} +
+ {% csrf_token %} + +
+ {% endif %} + + +
+ {% csrf_token %} + +
+ + +
+ {% csrf_token %} + +
+
+
+{% endwith %} +``` + +`gitea_configured` im Template benötigt einen Context-Processor oder muss im +View als Kontext-Variable übergeben werden. Einfachste Lösung: in +`external_issue_panel.html` und den Views direkt via +`from .issue_backends import gitea_configured` hinzufügen. + +`external_issue_form.html` vereinfachen — nur noch Notizen-Feld + Hinweis +"Issue wird automatisch im lokalen Backend angelegt". +``` + +```task +id: WP-0016-T08 +title: Tests + Smoke-Check +status: todo + +Alle 76 bestehenden Tests müssen grün bleiben. + +Neue Tests in `apps/aufgaben/tests.py` — lokales Backend per tmp-Datei: + +```python +@pytest.fixture +def tmp_issue_db(tmp_path, settings): + settings.ISSUE_FACADE_LOCAL_DB = tmp_path / 'test_issues.db' + settings.ISSUE_FACADE_GITEA = None + return settings.ISSUE_FACADE_LOCAL_DB +``` + +- `test_aufgabe_zu_issue_mapping` — title, description, priority-Label korrekt +- `test_lokales_issue_erstellen` — ExternalIssue bekommt issue_facade_id + issue_key +- `test_status_synchronisieren` — sync_status wird aktualisiert +- `test_external_issue_bearbeiten_view_erstellt_issue` — POST → Issue in DB +- `test_external_issue_sync_view` — POST → sync_status geändert +- `test_get_adapter_stub` — gibt None zurück (Rückwärtskompatibilität) + +```bash +uv run pytest vergabe_teilnahme/apps/aufgaben/tests.py -v +uv run pytest vergabe_teilnahme/ -q # alle Tests +``` +```