generated from coulomb/repo-seed
575 lines
19 KiB
Markdown
575 lines
19 KiB
Markdown
---
|
|
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('<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)
|
|
|
|
```bash
|
|
uv run pytest vergabe_teilnahme/apps/aufgaben/tests.py -v
|
|
uv run pytest vergabe_teilnahme/ -q # alle Tests
|
|
```
|
|
```
|