feat(aufgaben): Verknüpfungen, implizite Fälligkeit, Issue-Facade (WP-0015)

- Aufgabe.erstellt_am für implizite 7-Tage-Fälligkeit
- frist_effektiv property; ist_ueberfaellig nutzt sie
- AufgabenVerknuepfung (GenericForeignKey) mit HTMX-Panel
- ExternalIssue (OneToOne) mit IssueAdapter-ABC und HTMX-Panel
- link_registry.py und issue_facade.py als zentrale Registries
- 8 neue Tests, 76 gesamt grün

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-05-14 04:18:28 +02:00
parent a0c0ddf2eb
commit 816c281f6a
19 changed files with 713 additions and 19 deletions

View file

@ -17,7 +17,17 @@
{% render_field aufgabe "typ" "Typ" %}
{% render_field aufgabe "prioritaet" "Priorität" %}
{% render_field aufgabe "status" "Status" %}
{% render_field aufgabe "frist" "Frist" %}
<div class="flex justify-between text-sm py-0.5">
<dt class="text-slate-500">Frist</dt>
<dd class="text-slate-800">
{% if aufgabe.frist %}
{{ aufgabe.frist }}
{% else %}
<span class="text-slate-400">keine</span>
<span class="text-xs text-amber-600 ml-1">(implizit fällig: {{ aufgabe.frist_effektiv|date:"d.m.Y" }})</span>
{% endif %}
</dd>
</div>
{% render_field aufgabe "verantwortlicher" "Verantwortlich" %}
{% if aufgabe.beschreibung %}
<div>
@ -61,6 +71,28 @@
class="text-sm text-blue-600 hover:underline">{{ aufgabe.los.lostitel }}</a>
</div>
{% endif %}
<!-- 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>
<!-- Externes Issue -->
<div class="card" id="external-issue-panel">
{% include "aufgaben/partials/external_issue_panel.html" %}
</div>
</div>
</div>

View file

@ -0,0 +1,29 @@
<form hx-post="{% url 'ausschreibungen:aufgaben:neu' ausschreibung.pk %}"
hx-target="#aufgaben-tbody"
hx-swap="afterbegin"
hx-on::after-request="if(event.detail.successful) this.reset(); document.getElementById('aufgaben-form-container').innerHTML = '';"
class="bg-slate-50 border border-slate-200 rounded p-3 mt-2">
{% csrf_token %}
<div class="flex gap-2 items-end flex-wrap">
<div class="flex-1 min-w-48">
<label class="form-label">Titel *</label>
{{ form.titel }}
</div>
<div class="w-32">
<label class="form-label">Typ</label>
{{ form.typ }}
</div>
<div class="w-36">
<label class="form-label">Phase</label>
{{ form.phase }}
</div>
<div class="flex gap-2 shrink-0">
<button type="submit" class="btn-primary text-xs">Speichern</button>
<button type="button" class="btn-ghost text-xs"
onclick="document.getElementById('aufgaben-form-container').innerHTML = ''">Abbrechen</button>
</div>
</div>
{% if form.titel.errors %}
<p class="text-red-600 text-xs mt-1">{{ form.titel.errors.0 }}</p>
{% endif %}
</form>

View file

@ -0,0 +1,29 @@
{% with ei=aufgabe.external_issue %}
<div class="space-y-1 text-sm">
<div class="flex items-center gap-2">
<span class="text-xs bg-slate-100 text-slate-600 rounded px-1.5 py-0.5 font-medium">{{ ei.get_system_display }}</span>
{% if ei.issue_key %}<span class="text-slate-700 font-mono text-xs">{{ ei.issue_key }}</span>{% endif %}
<span class="text-xs text-slate-400 ml-auto">{{ ei.get_sync_status_display }}</span>
</div>
{% if ei.issue_url %}
<a href="{{ ei.issue_url }}" target="_blank" rel="noopener"
class="text-xs text-blue-600 hover:underline break-all">{{ ei.issue_url }}</a>
{% endif %}
{% if ei.notizen %}
<p class="text-xs text-slate-500 whitespace-pre-wrap">{{ ei.notizen }}</p>
{% endif %}
<div class="flex gap-2 mt-2">
<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>
<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 %}

View file

@ -0,0 +1,32 @@
<form hx-post="{% url 'ausschreibungen:aufgaben:external_issue' ausschreibung.pk aufgabe.pk %}"
hx-target="#external-issue-panel"
hx-swap="innerHTML"
class="space-y-2">
{% csrf_token %}
<div>
<label class="form-label">System</label>
{{ form.system }}
</div>
<div>
<label class="form-label">Issue-URL</label>
{{ form.issue_url }}
</div>
<div>
<label class="form-label">Issue-Key</label>
{{ form.issue_key }}
</div>
<div>
<label class="form-label">Notizen</label>
{{ form.notizen }}
</div>
{% if form.errors %}
<p class="text-xs text-red-600">{{ form.errors }}</p>
{% endif %}
<div class="flex gap-2">
<button type="submit" class="btn-primary text-xs">Speichern</button>
<button type="button" class="btn-ghost text-xs"
hx-get="{% url 'ausschreibungen:aufgaben:external_issue_panel' ausschreibung.pk aufgabe.pk %}"
hx-target="#external-issue-panel"
hx-swap="innerHTML">Abbrechen</button>
</div>
</form>

View file

@ -0,0 +1,10 @@
<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' ausschreibung.pk aufgabe.pk %}"
hx-target="#external-issue-panel"
hx-swap="innerHTML">+ Verknüpfen</button>
{% endif %}

View file

@ -0,0 +1,34 @@
<form hx-post="{% url 'ausschreibungen:aufgaben:verknuepfung_neu' ausschreibung.pk aufgabe.pk %}"
hx-target="#verknuepfungen-list"
hx-swap="beforeend"
hx-on::after-request="if(event.detail.successful) this.reset(); document.getElementById('verknuepfung-form-container').innerHTML = '';"
class="space-y-2 border border-slate-200 rounded p-3 mt-2">
{% csrf_token %}
<div>
<label class="form-label">Typ</label>
<select name="ziel_typ"
class="form-input text-sm"
hx-get="{% url 'ausschreibungen:aufgaben:verknuepfung_objekte' ausschreibung.pk aufgabe.pk %}"
hx-target="#verknuepfung-obj-container"
hx-swap="innerHTML"
hx-trigger="change"
required>
<option value="">— Typ wählen —</option>
{% for ct_id, label in form.fields.ziel_typ.choices %}
{% if ct_id %}<option value="{{ ct_id }}">{{ label }}</option>{% endif %}
{% endfor %}
</select>
</div>
<div id="verknuepfung-obj-container">
<p class="text-xs text-slate-400">Bitte zuerst einen Typ wählen.</p>
</div>
<div>
<label class="form-label">Kommentar</label>
{{ form.kommentar }}
</div>
<div class="flex gap-2">
<button type="submit" class="btn-primary text-xs">Verknüpfen</button>
<button type="button" class="btn-ghost text-xs"
onclick="document.getElementById('verknuepfung-form-container').innerHTML = ''">Abbrechen</button>
</div>
</form>

View file

@ -0,0 +1,10 @@
{% if objekte %}
<select name="ziel_id" class="form-input text-sm" required>
<option value="">— Objekt wählen —</option>
{% for obj in objekte %}
<option value="{{ obj.pk }}">{{ obj }}</option>
{% endfor %}
</select>
{% else %}
<p class="text-xs text-slate-400 py-1">Keine Objekte verfügbar.</p>
{% endif %}

View file

@ -0,0 +1,22 @@
<li class="flex items-start gap-2 py-1 text-sm" id="vk-{{ v.pk }}">
<div class="flex-1">
<span class="inline-block text-xs bg-slate-100 text-slate-600 rounded px-1 py-0.5 mr-1">
{{ v.content_type.model }}
</span>
{% if v.ziel %}
<span class="text-slate-800">{{ v.ziel }}</span>
{% else %}
<span class="text-slate-400">Objekt nicht gefunden (ID {{ v.object_id }})</span>
{% endif %}
{% if v.kommentar %}
<p class="text-xs text-slate-500 mt-0.5 ml-1">{{ v.kommentar }}</p>
{% endif %}
</div>
<form hx-post="{% url 'ausschreibungen:aufgaben:verknuepfung_loeschen' ausschreibung.pk aufgabe.pk v.pk %}"
hx-target="closest li"
hx-swap="outerHTML"
hx-confirm="Verknüpfung entfernen?">
{% csrf_token %}
<button type="submit" class="btn-ghost text-xs text-red-500 shrink-0">×</button>
</form>
</li>