--- id: WP-0016 title: Issue-Facade Integration — lokale Aufgabenverfolgung + Remote-Delegation status: done 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: done **`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: done 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: done 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: done `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: done **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: done `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: done `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: done 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 ``` ```