diff --git a/vergabe_teilnahme/apps/aufgaben/admin.py b/vergabe_teilnahme/apps/aufgaben/admin.py index ebd321d..1df836c 100644 --- a/vergabe_teilnahme/apps/aufgaben/admin.py +++ b/vergabe_teilnahme/apps/aufgaben/admin.py @@ -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) diff --git a/vergabe_teilnahme/apps/aufgaben/forms.py b/vergabe_teilnahme/apps/aufgaben/forms.py index 4a5e5c3..369bff8 100644 --- a/vergabe_teilnahme/apps/aufgaben/forms.py +++ b/vergabe_teilnahme/apps/aufgaben/forms.py @@ -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}), + } diff --git a/vergabe_teilnahme/apps/aufgaben/issue_facade.py b/vergabe_teilnahme/apps/aufgaben/issue_facade.py new file mode 100644 index 0000000..bd42b78 --- /dev/null +++ b/vergabe_teilnahme/apps/aufgaben/issue_facade.py @@ -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 diff --git a/vergabe_teilnahme/apps/aufgaben/link_registry.py b/vergabe_teilnahme/apps/aufgaben/link_registry.py new file mode 100644 index 0000000..a73ee5e --- /dev/null +++ b/vergabe_teilnahme/apps/aufgaben/link_registry.py @@ -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()] diff --git a/vergabe_teilnahme/apps/aufgaben/migrations/0004_erstellt_am.py b/vergabe_teilnahme/apps/aufgaben/migrations/0004_erstellt_am.py new file mode 100644 index 0000000..b8e5399 --- /dev/null +++ b/vergabe_teilnahme/apps/aufgaben/migrations/0004_erstellt_am.py @@ -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, + ), + ] diff --git a/vergabe_teilnahme/apps/aufgaben/migrations/0005_verknuepfung_externalissue.py b/vergabe_teilnahme/apps/aufgaben/migrations/0005_verknuepfung_externalissue.py new file mode 100644 index 0000000..9e444eb --- /dev/null +++ b/vergabe_teilnahme/apps/aufgaben/migrations/0005_verknuepfung_externalissue.py @@ -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', + }, + ), + ] diff --git a/vergabe_teilnahme/apps/aufgaben/models.py b/vergabe_teilnahme/apps/aufgaben/models.py index 525e313..a56d254 100644 --- a/vergabe_teilnahme/apps/aufgaben/models.py +++ b/vergabe_teilnahme/apps/aufgaben/models.py @@ -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}' diff --git a/vergabe_teilnahme/apps/aufgaben/tests.py b/vergabe_teilnahme/apps/aufgaben/tests.py index 488baef..aae7c96 100644 --- a/vergabe_teilnahme/apps/aufgaben/tests.py +++ b/vergabe_teilnahme/apps/aufgaben/tests.py @@ -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 diff --git a/vergabe_teilnahme/apps/aufgaben/urls.py b/vergabe_teilnahme/apps/aufgaben/urls.py index 772486f..6b46ef4 100644 --- a/vergabe_teilnahme/apps/aufgaben/urls.py +++ b/vergabe_teilnahme/apps/aufgaben/urls.py @@ -12,4 +12,10 @@ urlpatterns = [ path('/loeschen/', views.aufgabe_loeschen, name='loeschen'), path('/status/', views.aufgabe_status, name='status'), path('/ergebnis/', views.aufgabe_ergebnis, name='ergebnis'), + path('/verknuepfungen/neu/', views.verknuepfung_neu, name='verknuepfung_neu'), + path('/verknuepfungen/objekte/', views.verknuepfung_objekte, name='verknuepfung_objekte'), + path('/verknuepfungen//loeschen/', views.verknuepfung_loeschen, name='verknuepfung_loeschen'), + path('/issue/', views.external_issue_bearbeiten, name='external_issue'), + path('/issue/panel/', views.external_issue_panel, name='external_issue_panel'), + path('/issue/loeschen/', views.external_issue_loeschen, name='external_issue_loeschen'), ] diff --git a/vergabe_teilnahme/apps/aufgaben/views.py b/vergabe_teilnahme/apps/aufgaben/views.py index 294a871..a029764 100644 --- a/vergabe_teilnahme/apps/aufgaben/views.py +++ b/vergabe_teilnahme/apps/aufgaben/views.py @@ -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) diff --git a/vergabe_teilnahme/templates/aufgaben/detail.html b/vergabe_teilnahme/templates/aufgaben/detail.html index 21392ab..39ad3f6 100644 --- a/vergabe_teilnahme/templates/aufgaben/detail.html +++ b/vergabe_teilnahme/templates/aufgaben/detail.html @@ -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" %} +
+
Frist
+
+ {% if aufgabe.frist %} + {{ aufgabe.frist }} + {% else %} + keine + (implizit fällig: {{ aufgabe.frist_effektiv|date:"d.m.Y" }}) + {% endif %} +
+
{% render_field aufgabe "verantwortlicher" "Verantwortlich" %} {% if aufgabe.beschreibung %}
@@ -61,6 +71,28 @@ class="text-sm text-blue-600 hover:underline">{{ aufgabe.los.lostitel }}
{% endif %} + + +
+
+

Verknüpfungen

+ +
+
+
    + {% for v in aufgabe.verknuepfungen.all %} + {% include "aufgaben/partials/verknuepfung_row.html" %} + {% endfor %} +
+
+ + +
+ {% include "aufgaben/partials/external_issue_panel.html" %} +
diff --git a/vergabe_teilnahme/templates/aufgaben/partials/aufgabe_form_inline.html b/vergabe_teilnahme/templates/aufgaben/partials/aufgabe_form_inline.html new file mode 100644 index 0000000..1598dab --- /dev/null +++ b/vergabe_teilnahme/templates/aufgaben/partials/aufgabe_form_inline.html @@ -0,0 +1,29 @@ +
+ {% csrf_token %} +
+
+ + {{ form.titel }} +
+
+ + {{ form.typ }} +
+
+ + {{ form.phase }} +
+
+ + +
+
+ {% if form.titel.errors %} +

{{ form.titel.errors.0 }}

+ {% endif %} +
diff --git a/vergabe_teilnahme/templates/aufgaben/partials/external_issue_card.html b/vergabe_teilnahme/templates/aufgaben/partials/external_issue_card.html new file mode 100644 index 0000000..32eff77 --- /dev/null +++ b/vergabe_teilnahme/templates/aufgaben/partials/external_issue_card.html @@ -0,0 +1,29 @@ +{% with ei=aufgabe.external_issue %} +
+
+ {{ ei.get_system_display }} + {% if ei.issue_key %}{{ ei.issue_key }}{% endif %} + {{ ei.get_sync_status_display }} +
+ {% if ei.issue_url %} + {{ ei.issue_url }} + {% endif %} + {% if ei.notizen %} +

{{ ei.notizen }}

+ {% endif %} +
+ +
+ {% csrf_token %} + +
+
+
+{% endwith %} diff --git a/vergabe_teilnahme/templates/aufgaben/partials/external_issue_form.html b/vergabe_teilnahme/templates/aufgaben/partials/external_issue_form.html new file mode 100644 index 0000000..0fd7ea3 --- /dev/null +++ b/vergabe_teilnahme/templates/aufgaben/partials/external_issue_form.html @@ -0,0 +1,32 @@ +
+ {% csrf_token %} +
+ + {{ form.system }} +
+
+ + {{ form.issue_url }} +
+
+ + {{ form.issue_key }} +
+
+ + {{ form.notizen }} +
+ {% if form.errors %} +

{{ form.errors }}

+ {% endif %} +
+ + +
+
diff --git a/vergabe_teilnahme/templates/aufgaben/partials/external_issue_panel.html b/vergabe_teilnahme/templates/aufgaben/partials/external_issue_panel.html new file mode 100644 index 0000000..12c7cb0 --- /dev/null +++ b/vergabe_teilnahme/templates/aufgaben/partials/external_issue_panel.html @@ -0,0 +1,10 @@ +

Externes Issue

+{% if aufgabe.external_issue %} + {% include "aufgaben/partials/external_issue_card.html" %} +{% else %} +

Kein externes Issue verknüpft.

+ +{% endif %} diff --git a/vergabe_teilnahme/templates/aufgaben/partials/verknuepfung_form_inline.html b/vergabe_teilnahme/templates/aufgaben/partials/verknuepfung_form_inline.html new file mode 100644 index 0000000..64389f7 --- /dev/null +++ b/vergabe_teilnahme/templates/aufgaben/partials/verknuepfung_form_inline.html @@ -0,0 +1,34 @@ +
+ {% csrf_token %} +
+ + +
+
+

Bitte zuerst einen Typ wählen.

+
+
+ + {{ form.kommentar }} +
+
+ + +
+
diff --git a/vergabe_teilnahme/templates/aufgaben/partials/verknuepfung_objekte_select.html b/vergabe_teilnahme/templates/aufgaben/partials/verknuepfung_objekte_select.html new file mode 100644 index 0000000..367a036 --- /dev/null +++ b/vergabe_teilnahme/templates/aufgaben/partials/verknuepfung_objekte_select.html @@ -0,0 +1,10 @@ +{% if objekte %} + +{% else %} +

Keine Objekte verfügbar.

+{% endif %} diff --git a/vergabe_teilnahme/templates/aufgaben/partials/verknuepfung_row.html b/vergabe_teilnahme/templates/aufgaben/partials/verknuepfung_row.html new file mode 100644 index 0000000..ee29194 --- /dev/null +++ b/vergabe_teilnahme/templates/aufgaben/partials/verknuepfung_row.html @@ -0,0 +1,22 @@ +
  • +
    + + {{ v.content_type.model }} + + {% if v.ziel %} + {{ v.ziel }} + {% else %} + Objekt nicht gefunden (ID {{ v.object_id }}) + {% endif %} + {% if v.kommentar %} +

    {{ v.kommentar }}

    + {% endif %} +
    +
    + {% csrf_token %} + +
    +
  • diff --git a/workplans/WP-0015-aufgaben-verknuepfungen-frist-issuefacade.md b/workplans/WP-0015-aufgaben-verknuepfungen-frist-issuefacade.md index 926e5f9..633bdb3 100644 --- a/workplans/WP-0015-aufgaben-verknuepfungen-frist-issuefacade.md +++ b/workplans/WP-0015-aufgaben-verknuepfungen-frist-issuefacade.md @@ -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.