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:
2026-05-14 04:18:28 +02:00
parent a0c0ddf2eb
commit 816c281f6a
19 changed files with 713 additions and 19 deletions

View File

@@ -1,12 +1,31 @@
from django.contrib import admin
from .models import Aufgabe, Bieterfrage
from .models import Aufgabe, AufgabenVerknuepfung, Bieterfrage, ExternalIssue
class AufgabenVerknuepfungInline(admin.TabularInline):
model = AufgabenVerknuepfung
extra = 0
readonly_fields = ['ziel', 'erstellt_am']
@admin.register(Aufgabe)
class AufgabeAdmin(admin.ModelAdmin):
list_display = ['titel', 'typ', 'status', 'prioritaet', 'frist', 'verantwortlicher']
list_filter = ['typ', 'status', 'prioritaet']
inlines = [AufgabenVerknuepfungInline]
@admin.register(AufgabenVerknuepfung)
class AufgabenVerknuepfungAdmin(admin.ModelAdmin):
list_display = ['aufgabe', 'content_type', 'object_id', 'erstellt_am']
list_filter = ['content_type']
@admin.register(ExternalIssue)
class ExternalIssueAdmin(admin.ModelAdmin):
list_display = ['aufgabe', 'system', 'issue_key', 'sync_status', 'letzter_sync']
list_filter = ['system', 'sync_status']
@admin.register(Bieterfrage)

View File

@@ -1,6 +1,6 @@
from django import forms
from .models import Aufgabe, Bieterfrage
from .models import Aufgabe, Bieterfrage, ExternalIssue
class AufgabeForm(forms.ModelForm):
@@ -63,3 +63,31 @@ class BieterfragenForm(forms.ModelForm):
self.fields['anforderung'].required = False
self.fields['dokument'].required = False
self.fields['verfasser'].required = False
class AufgabenVerknuepfungForm(forms.Form):
ziel_typ = forms.ChoiceField(choices=[], label='Typ')
ziel_id = forms.IntegerField(label='Objekt-ID', widget=forms.HiddenInput())
kommentar = forms.CharField(
widget=forms.Textarea(attrs={'class': 'form-input', 'rows': 2, 'placeholder': 'Kommentar (optional)'}),
required=False,
label='Kommentar',
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
from .link_registry import ziel_choices
self.fields['ziel_typ'].choices = [('', '— Typ wählen —')] + ziel_choices()
self.fields['ziel_typ'].widget.attrs.update({'class': 'form-input'})
class ExternalIssueForm(forms.ModelForm):
class Meta:
model = ExternalIssue
fields = ['system', 'issue_url', 'issue_key', 'notizen']
widgets = {
'system': forms.Select(attrs={'class': 'form-input'}),
'issue_url': forms.URLInput(attrs={'class': 'form-input', 'placeholder': 'https://...'}),
'issue_key': forms.TextInput(attrs={'class': 'form-input', 'placeholder': 'z.B. GH-42'}),
'notizen': forms.Textarea(attrs={'class': 'form-input', 'rows': 2}),
}

View File

@@ -0,0 +1,28 @@
from abc import ABC, abstractmethod
class IssueAdapter(ABC):
"""
Adapter-Basisklasse. Jeder externe Issue-Tracker implementiert diese
Schnittstelle. Registrierung via ISSUE_ADAPTERS-Dict in settings.
"""
@abstractmethod
def create_issue(self, aufgabe) -> dict:
"""Legt ein Issue im externen System an. Gibt {'url', 'key'} zurück."""
@abstractmethod
def fetch_status(self, external_issue) -> str:
"""Liest den aktuellen Status aus dem externen System."""
@abstractmethod
def close_issue(self, external_issue) -> None:
"""Schließt das Issue im externen System."""
def get_adapter(system: str) -> 'IssueAdapter | None':
"""Gibt den registrierten Adapter für `system` zurück, oder None."""
from django.conf import settings
adapters = getattr(settings, 'ISSUE_ADAPTERS', {})
cls = adapters.get(system)
return cls() if cls else None

View File

@@ -0,0 +1,27 @@
from django.contrib.contenttypes.models import ContentType
_VERKNUEPFBARE_MODELLE = [
('lose', 'Anforderung'),
('lose', 'Los'),
('aufgaben', 'Bieterfrage'),
('dokumente', 'Dokument'),
('preise', 'Preispunkt'),
]
def verknuepfbare_typen():
"""Gibt eine geordnete Liste (ContentType, label) der verknüpfbaren Typen zurück."""
result = []
for app_label, model_name in _VERKNUEPFBARE_MODELLE:
try:
ct = ContentType.objects.get(app_label=app_label, model=model_name.lower())
result.append((ct, f'{ct.app_label} / {model_name}'))
except ContentType.DoesNotExist:
pass
return result
def ziel_choices():
"""Gibt (content_type_id, label)-Tupel für ein ChoiceField zurück."""
return [(ct.pk, label) for ct, label in verknuepfbare_typen()]

View File

@@ -0,0 +1,18 @@
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('aufgaben', '0003_phase_feld'),
]
operations = [
migrations.AddField(
model_name='aufgabe',
name='erstellt_am',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
]

View File

@@ -0,0 +1,49 @@
# Generated by Django 6.0.5 on 2026-05-14 02:11
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('aufgaben', '0004_erstellt_am'),
('contenttypes', '0002_remove_content_type_name'),
]
operations = [
migrations.CreateModel(
name='AufgabenVerknuepfung',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('object_id', models.PositiveIntegerField()),
('kommentar', models.TextField(blank=True)),
('erstellt_am', models.DateTimeField(auto_now_add=True)),
('aufgabe', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='verknuepfungen', to='aufgaben.aufgabe')),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
],
options={
'verbose_name': 'Aufgaben-Verknüpfung',
'verbose_name_plural': 'Aufgaben-Verknüpfungen',
'ordering': ['erstellt_am'],
},
),
migrations.CreateModel(
name='ExternalIssue',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('system', models.CharField(choices=[('github', 'GitHub Issues'), ('jira', 'Jira'), ('linear', 'Linear'), ('azure', 'Azure DevOps'), ('sonstiges', 'Sonstiges')], max_length=20)),
('issue_url', models.URLField(blank=True)),
('issue_key', models.CharField(blank=True, help_text='z.B. "GH-42" oder "PROJ-1234"', max_length=100)),
('sync_status', models.CharField(choices=[('manuell', 'Manuell'), ('offen', 'Offen (extern)'), ('geschlossen', 'Geschlossen (extern)'), ('fehler', 'Sync-Fehler')], default='manuell', max_length=20)),
('letzter_sync', models.DateTimeField(blank=True, null=True)),
('notizen', models.TextField(blank=True)),
('erstellt_am', models.DateTimeField(auto_now_add=True)),
('aufgabe', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='external_issue', to='aufgaben.aufgabe')),
],
options={
'verbose_name': 'External Issue',
'verbose_name_plural': 'External Issues',
},
),
]

View File

@@ -1,5 +1,7 @@
from datetime import date
from datetime import date, timedelta
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models
from vergabe_teilnahme.apps.core.models import FlexibleModel
@@ -61,6 +63,7 @@ class Aufgabe(FlexibleModel):
status = models.CharField(max_length=25, choices=STATUS_CHOICES, default='offen')
prioritaet = models.PositiveSmallIntegerField(choices=PRIORITAET_CHOICES, default=2)
frist = models.DateField(null=True, blank=True)
erstellt_am = models.DateTimeField(auto_now_add=True)
verantwortlicher = models.ForeignKey(
'accounts.Mitarbeiter', on_delete=models.SET_NULL, null=True, blank=True
)
@@ -75,11 +78,18 @@ class Aufgabe(FlexibleModel):
def __str__(self):
return self.titel
@property
def frist_effektiv(self):
if self.frist:
return self.frist
return (self.erstellt_am + timedelta(days=7)).date()
@property
def ist_ueberfaellig(self):
if not self.frist:
return False
return self.frist < date.today() and self.status not in ['erledigt', 'verworfen']
return (
self.frist_effektiv < date.today()
and self.status not in ['erledigt', 'verworfen']
)
class Bieterfrage(FlexibleModel):
@@ -122,3 +132,58 @@ class Bieterfrage(FlexibleModel):
def __str__(self):
return self.fragentext[:80]
class AufgabenVerknuepfung(models.Model):
aufgabe = models.ForeignKey(
Aufgabe, on_delete=models.CASCADE, related_name='verknuepfungen'
)
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
ziel = GenericForeignKey('content_type', 'object_id')
kommentar = models.TextField(blank=True)
erstellt_am = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['erstellt_am']
verbose_name = 'Aufgaben-Verknüpfung'
verbose_name_plural = 'Aufgaben-Verknüpfungen'
def __str__(self):
return f'{self.aufgabe}{self.content_type} #{self.object_id}'
class ExternalIssue(models.Model):
SYSTEM_CHOICES = [
('github', 'GitHub Issues'),
('jira', 'Jira'),
('linear', 'Linear'),
('azure', 'Azure DevOps'),
('sonstiges', 'Sonstiges'),
]
SYNC_STATUS_CHOICES = [
('manuell', 'Manuell'),
('offen', 'Offen (extern)'),
('geschlossen', 'Geschlossen (extern)'),
('fehler', 'Sync-Fehler'),
]
aufgabe = models.OneToOneField(
Aufgabe, on_delete=models.CASCADE, related_name='external_issue'
)
system = models.CharField(max_length=20, choices=SYSTEM_CHOICES)
issue_url = models.URLField(blank=True)
issue_key = models.CharField(max_length=100, blank=True,
help_text='z.B. "GH-42" oder "PROJ-1234"')
sync_status = models.CharField(max_length=20, choices=SYNC_STATUS_CHOICES,
default='manuell')
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_system_display()} {self.issue_key or self.issue_url}'

View File

@@ -5,7 +5,7 @@ from django.urls import reverse
from vergabe_teilnahme.apps.ausschreibungen.tests import AusschreibungFactory
from vergabe_teilnahme.apps.lose.tests import AnforderungFactory
from .models import Aufgabe, Bieterfrage
from .models import Aufgabe, AufgabenVerknuepfung, Bieterfrage, ExternalIssue
class AufgabeFactory(factory.django.DjangoModelFactory):
@@ -94,3 +94,110 @@ def test_bieterfrage_antwort_speichern(client):
bf.refresh_from_db()
assert bf.antwort == 'Die Antwort lautet 42.'
assert bf.status == 'beantwortet'
# ─── Implizite Fälligkeit ─────────────────────────────────────────────────────
@pytest.mark.django_db
def test_frist_effektiv_mit_frist():
from datetime import date
aufgabe = AufgabeFactory(frist=date(2026, 6, 1))
assert aufgabe.frist_effektiv == date(2026, 6, 1)
@pytest.mark.django_db
def test_frist_effektiv_ohne_frist():
from datetime import timedelta
from django.utils import timezone
aufgabe = AufgabeFactory(frist=None)
expected = (aufgabe.erstellt_am + timedelta(days=7)).date()
assert aufgabe.frist_effektiv == expected
@pytest.mark.django_db
def test_ueberfaellig_ohne_frist_nach_7_tagen(client):
from datetime import timedelta
from django.utils import timezone
a = AusschreibungFactory()
alte_erstellung = timezone.now() - timedelta(days=8)
aufgabe = AufgabeFactory(ausschreibung=a, frist=None, status='offen')
Aufgabe.objects.filter(pk=aufgabe.pk).update(erstellt_am=alte_erstellung)
url = reverse('ausschreibungen:aufgaben:liste', kwargs={'ausschreibung_id': a.pk})
client.get(url)
aufgabe.refresh_from_db()
assert aufgabe.status == 'ueberfaellig'
# ─── Aufgaben-Verknüpfungen ───────────────────────────────────────────────────
@pytest.mark.django_db
def test_aufgaben_verknuepfung_erstellen(client):
from django.contrib.contenttypes.models import ContentType
aufgabe = AufgabeFactory()
anf = AnforderungFactory(ausschreibung=aufgabe.ausschreibung)
ct = ContentType.objects.get_for_model(anf)
url = reverse('ausschreibungen:aufgaben:verknuepfung_neu',
kwargs={'ausschreibung_id': aufgabe.ausschreibung_id, 'pk': aufgabe.pk})
response = client.post(url, {
'ziel_typ': ct.pk,
'ziel_id': anf.pk,
'kommentar': 'Testverknüpfung',
}, HTTP_HX_REQUEST='true')
assert response.status_code == 200
assert AufgabenVerknuepfung.objects.filter(aufgabe=aufgabe, object_id=anf.pk).exists()
@pytest.mark.django_db
def test_aufgaben_verknuepfung_loeschen(client):
from django.contrib.contenttypes.models import ContentType
aufgabe = AufgabeFactory()
anf = AnforderungFactory(ausschreibung=aufgabe.ausschreibung)
ct = ContentType.objects.get_for_model(anf)
vk = AufgabenVerknuepfung.objects.create(
aufgabe=aufgabe, content_type=ct, object_id=anf.pk
)
url = reverse('ausschreibungen:aufgaben:verknuepfung_loeschen',
kwargs={'ausschreibung_id': aufgabe.ausschreibung_id, 'pk': aufgabe.pk, 'vk_pk': vk.pk})
response = client.post(url, {}, HTTP_HX_REQUEST='true')
assert response.status_code == 200
assert not AufgabenVerknuepfung.objects.filter(pk=vk.pk).exists()
# ─── ExternalIssue ────────────────────────────────────────────────────────────
@pytest.mark.django_db
def test_external_issue_erstellen(client):
aufgabe = AufgabeFactory()
url = reverse('ausschreibungen:aufgaben:external_issue',
kwargs={'ausschreibung_id': aufgabe.ausschreibung_id, 'pk': aufgabe.pk})
response = client.post(url, {
'system': 'github',
'issue_url': 'https://github.com/org/repo/issues/42',
'issue_key': 'GH-42',
'notizen': '',
}, HTTP_HX_REQUEST='true')
assert response.status_code == 200
assert ExternalIssue.objects.filter(aufgabe=aufgabe, system='github').exists()
@pytest.mark.django_db
def test_external_issue_loeschen(client):
aufgabe = AufgabeFactory()
ei = ExternalIssue.objects.create(
aufgabe=aufgabe, system='jira', issue_key='PROJ-1'
)
url = reverse('ausschreibungen:aufgaben:external_issue_loeschen',
kwargs={'ausschreibung_id': aufgabe.ausschreibung_id, 'pk': aufgabe.pk})
response = client.post(url, {}, HTTP_HX_REQUEST='true')
assert response.status_code == 200
assert not ExternalIssue.objects.filter(pk=ei.pk).exists()
@pytest.mark.django_db
def test_issue_adapter_interface():
from .issue_facade import get_adapter
assert get_adapter('github') is None
assert get_adapter('nichtexistent') is None

View File

@@ -12,4 +12,10 @@ urlpatterns = [
path('<int:pk>/loeschen/', views.aufgabe_loeschen, name='loeschen'),
path('<int:pk>/status/', views.aufgabe_status, name='status'),
path('<int:pk>/ergebnis/', views.aufgabe_ergebnis, name='ergebnis'),
path('<int:pk>/verknuepfungen/neu/', views.verknuepfung_neu, name='verknuepfung_neu'),
path('<int:pk>/verknuepfungen/objekte/', views.verknuepfung_objekte, name='verknuepfung_objekte'),
path('<int:pk>/verknuepfungen/<int:vk_pk>/loeschen/', views.verknuepfung_loeschen, name='verknuepfung_loeschen'),
path('<int:pk>/issue/', views.external_issue_bearbeiten, name='external_issue'),
path('<int:pk>/issue/panel/', views.external_issue_panel, name='external_issue_panel'),
path('<int:pk>/issue/loeschen/', views.external_issue_loeschen, name='external_issue_loeschen'),
]

View File

@@ -1,10 +1,13 @@
from datetime import date
from datetime import date, timedelta
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung
from .models import Aufgabe, Bieterfrage
from django.http import HttpResponse
from .models import Aufgabe, AufgabenVerknuepfung, Bieterfrage, ExternalIssue
AKTIVE_STATUS = [
'offen', 'in_bearbeitung', 'wartend_intern', 'wartend_sub', 'wartend_ausschreiber',
@@ -31,6 +34,12 @@ def aufgaben_liste(request, ausschreibung_id=None):
qs = Aufgabe.objects.select_related('ausschreibung').all()
qs.filter(frist__lt=heute, status__in=AKTIVE_STATUS).update(status='ueberfaellig')
implizite_grenze = timezone.now() - timedelta(days=7)
qs.filter(
frist__isnull=True,
erstellt_am__lt=implizite_grenze,
status__in=AKTIVE_STATUS,
).update(status='ueberfaellig')
status_filter = request.GET.get('status')
if status_filter:
@@ -99,6 +108,11 @@ def aufgabe_neu(request, ausschreibung_id):
else:
form = AufgabeForm(ausschreibung=ausschreibung)
if _is_htmx(request):
return render(request, 'aufgaben/partials/aufgabe_form_inline.html', {
'form': form,
'ausschreibung': ausschreibung,
})
return render(request, 'aufgaben/form.html', {
'form': form,
'ausschreibung': ausschreibung,
@@ -316,3 +330,138 @@ def bieterfrage_antwort(request, ausschreibung_id, pk):
'show_antwort_form': True,
'breadcrumbs': [],
})
# ─── Aufgaben-Verknüpfungen ───────────────────────────────────────────────────
def verknuepfung_neu(request, ausschreibung_id, pk):
from django.contrib.contenttypes.models import ContentType
from .forms import AufgabenVerknuepfungForm
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
aufgabe = get_object_or_404(Aufgabe, pk=pk, ausschreibung=ausschreibung)
if request.method == 'POST':
form = AufgabenVerknuepfungForm(request.POST)
if form.is_valid():
ct_id = form.cleaned_data['ziel_typ']
obj_id = form.cleaned_data['ziel_id']
ct = get_object_or_404(ContentType, pk=ct_id)
vk = AufgabenVerknuepfung.objects.create(
aufgabe=aufgabe,
content_type=ct,
object_id=obj_id,
kommentar=form.cleaned_data['kommentar'],
)
if _is_htmx(request):
return render(request, 'aufgaben/partials/verknuepfung_row.html',
{'v': vk, 'aufgabe': aufgabe, 'ausschreibung': ausschreibung})
return redirect('ausschreibungen:aufgaben:detail',
ausschreibung_id=ausschreibung_id, pk=pk)
else:
form = AufgabenVerknuepfungForm()
if _is_htmx(request):
return render(request, 'aufgaben/partials/verknuepfung_form_inline.html', {
'form': form,
'aufgabe': aufgabe,
'ausschreibung': ausschreibung,
})
return redirect('ausschreibungen:aufgaben:detail', ausschreibung_id=ausschreibung_id, pk=pk)
def verknuepfung_objekte(request, ausschreibung_id, pk):
from django.contrib.contenttypes.models import ContentType
ct_id = request.GET.get('ziel_typ') or request.GET.get('ct_id')
objekte = []
if ct_id:
try:
ct = ContentType.objects.get(pk=ct_id)
model_class = ct.model_class()
if model_class is not None:
objekte = list(model_class.objects.all()[:200])
except ContentType.DoesNotExist:
pass
return render(request, 'aufgaben/partials/verknuepfung_objekte_select.html', {
'objekte': objekte,
'ct_id': ct_id,
})
def verknuepfung_loeschen(request, ausschreibung_id, pk, vk_pk):
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
aufgabe = get_object_or_404(Aufgabe, pk=pk, ausschreibung=ausschreibung)
vk = get_object_or_404(AufgabenVerknuepfung, pk=vk_pk, aufgabe=aufgabe)
if request.method == 'POST':
vk.delete()
if _is_htmx(request):
return HttpResponse('')
return redirect('ausschreibungen:aufgaben:detail',
ausschreibung_id=ausschreibung_id, pk=pk)
return render(request, 'aufgaben/partials/verknuepfung_loeschen_confirm.html', {
'v': vk,
'aufgabe': aufgabe,
'ausschreibung': ausschreibung,
})
# ─── External Issue ───────────────────────────────────────────────────────────
def external_issue_bearbeiten(request, ausschreibung_id, pk):
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
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)
def external_issue_panel(request, ausschreibung_id, pk):
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
aufgabe = get_object_or_404(Aufgabe, pk=pk, ausschreibung=ausschreibung)
return render(request, 'aufgaben/partials/external_issue_panel.html', {
'aufgabe': aufgabe,
'ausschreibung': ausschreibung,
})
def external_issue_loeschen(request, ausschreibung_id, pk):
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
aufgabe = get_object_or_404(Aufgabe, pk=pk, ausschreibung=ausschreibung)
if request.method == 'POST':
ExternalIssue.objects.filter(aufgabe=aufgabe).delete()
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)
return redirect('ausschreibungen:aufgaben:detail', ausschreibung_id=ausschreibung_id, pk=pk)

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>

View File

@@ -1,7 +1,7 @@
---
id: WP-0015
title: Aufgaben — Verknüpfungen, implizite Fälligkeit, Issue-Facade
status: todo
status: done
phase: 15-of-n
created: "2026-05-14"
depends_on: WP-0014
@@ -33,7 +33,7 @@ Drei eigenständige Erweiterungen des Aufgaben-Moduls:
```task
id: WP-0015-T01
title: Aufgabe.erstellt_am — Feld + Migration
status: todo
status: done
`apps/aufgaben/models.py` — Feld ergänzen:
@@ -55,7 +55,7 @@ für auto_now_add bei ALTER TABLE).
```task
id: WP-0015-T02
title: Implizite Fälligkeit — Property + Überfälligkeitsprüfung
status: todo
status: done
**Modell** (`apps/aufgaben/models.py`):
@@ -125,7 +125,7 @@ angezeigt wird:
```task
id: WP-0015-T03
title: AufgabenVerknuepfung — Modell + Migration + Admin
status: todo
status: done
Neues Modell in `apps/aufgaben/models.py`:
@@ -165,7 +165,7 @@ Preispunkt). Wird in der Form als Auswahlfeld verwendet.
```task
id: WP-0015-T04
title: Verknüpfungen-View — Liste + Hinzufügen (HTMX)
status: todo
status: done
**URLs** (`apps/aufgaben/urls.py`) ergänzen:
@@ -227,7 +227,7 @@ ermittelbar), Kommentar, Löschen-Button.
```task
id: WP-0015-T05
title: Verknüpfungen-View — Entfernen (HTMX DELETE)
status: todo
status: done
**URL** in `apps/aufgaben/urls.py`:
@@ -265,7 +265,7 @@ Im `verknuepfung_row.html` Löschen-Button als HTMX-POST mit
```task
id: WP-0015-T06
title: ExternalIssue — Modell + Migration + Service-Interface
status: todo
status: done
**Modell** in einem neuen Modul `apps/aufgaben/issue_models.py`
(oder direkt in `models.py`):
@@ -345,7 +345,7 @@ Migration erstellen und ausführen. Admin registrieren.
```task
id: WP-0015-T07
title: Issue-Facade UI — Panel auf Aufgaben-Detailseite
status: todo
status: done
**URLs** in `apps/aufgaben/urls.py`:
@@ -398,7 +398,7 @@ Key, Sync-Status, Notizen, Buttons "Bearbeiten" und "Entfernen".
```task
id: WP-0015-T08
title: Tests + Smoke-Check
status: todo
status: done
Bestehende 68 Tests müssen grün bleiben.