generated from coulomb/repo-seed
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:
@@ -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)
|
||||
|
||||
@@ -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}),
|
||||
}
|
||||
|
||||
28
vergabe_teilnahme/apps/aufgaben/issue_facade.py
Normal file
28
vergabe_teilnahme/apps/aufgaben/issue_facade.py
Normal 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
|
||||
27
vergabe_teilnahme/apps/aufgaben/link_registry.py
Normal file
27
vergabe_teilnahme/apps/aufgaben/link_registry.py
Normal 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()]
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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}'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user