Files
vergabe-teilnahme/workplans/WP-0016-issue-facade-integration.md
2026-05-14 11:30:30 +02:00

19 KiB

id, title, status, phase, created, depends_on
id title status phase created depends_on
WP-0016 Issue-Facade Integration — lokale Aufgabenverfolgung + Remote-Delegation done 16-of-n 2026-05-14 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.Issueid (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.LocalSQLiteBackendconnect({'db_path': ...})
  • issue_tracker.backends.gitea.GiteaBackendconnect({'base_url', 'token', 'owner', 'repo'})

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:

uv sync

vergabe_teilnahme/settings/base.py — neue Optionen am Ende:

# 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:

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:

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.pyexternal_issue_bearbeiten anpassen:

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:

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:

path('<int:pk>/issue/push/', views.external_issue_push_remote, name='external_issue_push'),
path('<int:pk>/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 %}
<div class="space-y-2 text-sm">
  <div class="flex items-center gap-2 flex-wrap">
    <!-- Backend-Badge -->
    {% if ei.issue_facade_backend == 'local' %}
    <span class="text-xs bg-slate-100 text-slate-600 rounded px-1.5 py-0.5 font-medium">LOKAL</span>
    {% else %}
    <span class="text-xs bg-blue-100 text-blue-700 rounded px-1.5 py-0.5 font-medium">GITEA</span>
    {% endif %}

    <!-- Issue-Key -->
    {% if ei.issue_url %}
    <a href="{{ ei.issue_url }}" target="_blank" rel="noopener"
       class="text-xs font-mono text-blue-600 hover:underline">{{ ei.issue_key }}</a>
    {% else %}
    <span class="text-xs font-mono text-slate-600">{{ ei.issue_key }}</span>
    {% endif %}

    <!-- Status-Badge -->
    <span class="text-xs rounded px-1.5 py-0.5
      {% if ei.sync_status == 'closed' %}bg-green-100 text-green-700
      {% elif ei.sync_status == 'in_progress' %}bg-amber-100 text-amber-700
      {% elif ei.sync_status == 'blocked' %}bg-red-100 text-red-700
      {% elif ei.sync_status == 'error' %}bg-red-200 text-red-800
      {% else %}bg-slate-100 text-slate-600{% endif %}">
      {{ ei.get_sync_status_display }}
    </span>

    <!-- Letzter Sync -->
    {% if ei.letzter_sync %}
    <span class="text-xs text-slate-400 ml-auto">Sync: {{ ei.letzter_sync|date:"d.m.Y H:i" }}</span>
    {% endif %}
  </div>

  {% if ei.notizen %}
  <p class="text-xs text-slate-500 whitespace-pre-wrap">{{ ei.notizen }}</p>
  {% endif %}

  <div class="flex gap-2 flex-wrap mt-1">
    <!-- Bearbeiten -->
    <button class="btn-ghost text-xs"
            hx-get="{% url 'ausschreibungen:aufgaben:external_issue' ausschreibung.pk aufgabe.pk %}"
            hx-target="#external-issue-panel" hx-swap="innerHTML">Bearbeiten</button>

    <!-- Push to Gitea (nur wenn lokal und Gitea konfiguriert) -->
    {% if ei.issue_facade_backend == 'local' and gitea_configured %}
    <form hx-post="{% url 'ausschreibungen:aufgaben:external_issue_push' ausschreibung.pk aufgabe.pk %}"
          hx-target="#external-issue-panel" hx-swap="innerHTML">
      {% csrf_token %}
      <button type="submit" class="btn-ghost text-xs text-blue-600">↑ Nach Gitea</button>
    </form>
    {% endif %}

    <!-- Status syncen -->
    <form hx-post="{% url 'ausschreibungen:aufgaben:external_issue_sync' ausschreibung.pk aufgabe.pk %}"
          hx-target="#external-issue-panel" hx-swap="innerHTML">
      {% csrf_token %}
      <button type="submit" class="btn-ghost text-xs">⟳ Status syncen</button>
    </form>

    <!-- Entfernen -->
    <form hx-post="{% url 'ausschreibungen:aufgaben:external_issue_loeschen' ausschreibung.pk aufgabe.pk %}"
          hx-target="#external-issue-panel" hx-swap="innerHTML"
          hx-confirm="Externe Verknüpfung entfernen?">
      {% csrf_token %}
      <button type="submit" class="btn-ghost text-xs text-red-500">Entfernen</button>
    </form>
  </div>
</div>
{% 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)
uv run pytest vergabe_teilnahme/apps/aufgaben/tests.py -v
uv run pytest vergabe_teilnahme/ -q   # alle Tests