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:
parent
a0c0ddf2eb
commit
816c281f6a
19 changed files with 713 additions and 19 deletions
|
|
@ -1,12 +1,31 @@
|
||||||
from django.contrib import admin
|
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)
|
@admin.register(Aufgabe)
|
||||||
class AufgabeAdmin(admin.ModelAdmin):
|
class AufgabeAdmin(admin.ModelAdmin):
|
||||||
list_display = ['titel', 'typ', 'status', 'prioritaet', 'frist', 'verantwortlicher']
|
list_display = ['titel', 'typ', 'status', 'prioritaet', 'frist', 'verantwortlicher']
|
||||||
list_filter = ['typ', 'status', 'prioritaet']
|
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)
|
@admin.register(Bieterfrage)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from django import forms
|
from django import forms
|
||||||
|
|
||||||
from .models import Aufgabe, Bieterfrage
|
from .models import Aufgabe, Bieterfrage, ExternalIssue
|
||||||
|
|
||||||
|
|
||||||
class AufgabeForm(forms.ModelForm):
|
class AufgabeForm(forms.ModelForm):
|
||||||
|
|
@ -63,3 +63,31 @@ class BieterfragenForm(forms.ModelForm):
|
||||||
self.fields['anforderung'].required = False
|
self.fields['anforderung'].required = False
|
||||||
self.fields['dokument'].required = False
|
self.fields['dokument'].required = False
|
||||||
self.fields['verfasser'].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 django.db import models
|
||||||
|
|
||||||
from vergabe_teilnahme.apps.core.models import FlexibleModel
|
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')
|
status = models.CharField(max_length=25, choices=STATUS_CHOICES, default='offen')
|
||||||
prioritaet = models.PositiveSmallIntegerField(choices=PRIORITAET_CHOICES, default=2)
|
prioritaet = models.PositiveSmallIntegerField(choices=PRIORITAET_CHOICES, default=2)
|
||||||
frist = models.DateField(null=True, blank=True)
|
frist = models.DateField(null=True, blank=True)
|
||||||
|
erstellt_am = models.DateTimeField(auto_now_add=True)
|
||||||
verantwortlicher = models.ForeignKey(
|
verantwortlicher = models.ForeignKey(
|
||||||
'accounts.Mitarbeiter', on_delete=models.SET_NULL, null=True, blank=True
|
'accounts.Mitarbeiter', on_delete=models.SET_NULL, null=True, blank=True
|
||||||
)
|
)
|
||||||
|
|
@ -75,11 +78,18 @@ class Aufgabe(FlexibleModel):
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.titel
|
return self.titel
|
||||||
|
|
||||||
|
@property
|
||||||
|
def frist_effektiv(self):
|
||||||
|
if self.frist:
|
||||||
|
return self.frist
|
||||||
|
return (self.erstellt_am + timedelta(days=7)).date()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ist_ueberfaellig(self):
|
def ist_ueberfaellig(self):
|
||||||
if not self.frist:
|
return (
|
||||||
return False
|
self.frist_effektiv < date.today()
|
||||||
return self.frist < date.today() and self.status not in ['erledigt', 'verworfen']
|
and self.status not in ['erledigt', 'verworfen']
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Bieterfrage(FlexibleModel):
|
class Bieterfrage(FlexibleModel):
|
||||||
|
|
@ -122,3 +132,58 @@ class Bieterfrage(FlexibleModel):
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.fragentext[:80]
|
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.ausschreibungen.tests import AusschreibungFactory
|
||||||
from vergabe_teilnahme.apps.lose.tests import AnforderungFactory
|
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):
|
class AufgabeFactory(factory.django.DjangoModelFactory):
|
||||||
|
|
@ -94,3 +94,110 @@ def test_bieterfrage_antwort_speichern(client):
|
||||||
bf.refresh_from_db()
|
bf.refresh_from_db()
|
||||||
assert bf.antwort == 'Die Antwort lautet 42.'
|
assert bf.antwort == 'Die Antwort lautet 42.'
|
||||||
assert bf.status == 'beantwortet'
|
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>/loeschen/', views.aufgabe_loeschen, name='loeschen'),
|
||||||
path('<int:pk>/status/', views.aufgabe_status, name='status'),
|
path('<int:pk>/status/', views.aufgabe_status, name='status'),
|
||||||
path('<int:pk>/ergebnis/', views.aufgabe_ergebnis, name='ergebnis'),
|
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.shortcuts import get_object_or_404, redirect, render
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung
|
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 = [
|
AKTIVE_STATUS = [
|
||||||
'offen', 'in_bearbeitung', 'wartend_intern', 'wartend_sub', 'wartend_ausschreiber',
|
'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 = Aufgabe.objects.select_related('ausschreibung').all()
|
||||||
|
|
||||||
qs.filter(frist__lt=heute, status__in=AKTIVE_STATUS).update(status='ueberfaellig')
|
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')
|
status_filter = request.GET.get('status')
|
||||||
if status_filter:
|
if status_filter:
|
||||||
|
|
@ -99,6 +108,11 @@ def aufgabe_neu(request, ausschreibung_id):
|
||||||
else:
|
else:
|
||||||
form = AufgabeForm(ausschreibung=ausschreibung)
|
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', {
|
return render(request, 'aufgaben/form.html', {
|
||||||
'form': form,
|
'form': form,
|
||||||
'ausschreibung': ausschreibung,
|
'ausschreibung': ausschreibung,
|
||||||
|
|
@ -316,3 +330,138 @@ def bieterfrage_antwort(request, ausschreibung_id, pk):
|
||||||
'show_antwort_form': True,
|
'show_antwort_form': True,
|
||||||
'breadcrumbs': [],
|
'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 "typ" "Typ" %}
|
||||||
{% render_field aufgabe "prioritaet" "Priorität" %}
|
{% render_field aufgabe "prioritaet" "Priorität" %}
|
||||||
{% render_field aufgabe "status" "Status" %}
|
{% 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" %}
|
{% render_field aufgabe "verantwortlicher" "Verantwortlich" %}
|
||||||
{% if aufgabe.beschreibung %}
|
{% if aufgabe.beschreibung %}
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -61,6 +71,28 @@
|
||||||
class="text-sm text-blue-600 hover:underline">{{ aufgabe.los.lostitel }}</a>
|
class="text-sm text-blue-600 hover:underline">{{ aufgabe.los.lostitel }}</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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>
|
||||||
</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
|
id: WP-0015
|
||||||
title: Aufgaben — Verknüpfungen, implizite Fälligkeit, Issue-Facade
|
title: Aufgaben — Verknüpfungen, implizite Fälligkeit, Issue-Facade
|
||||||
status: todo
|
status: done
|
||||||
phase: 15-of-n
|
phase: 15-of-n
|
||||||
created: "2026-05-14"
|
created: "2026-05-14"
|
||||||
depends_on: WP-0014
|
depends_on: WP-0014
|
||||||
|
|
@ -33,7 +33,7 @@ Drei eigenständige Erweiterungen des Aufgaben-Moduls:
|
||||||
```task
|
```task
|
||||||
id: WP-0015-T01
|
id: WP-0015-T01
|
||||||
title: Aufgabe.erstellt_am — Feld + Migration
|
title: Aufgabe.erstellt_am — Feld + Migration
|
||||||
status: todo
|
status: done
|
||||||
|
|
||||||
`apps/aufgaben/models.py` — Feld ergänzen:
|
`apps/aufgaben/models.py` — Feld ergänzen:
|
||||||
|
|
||||||
|
|
@ -55,7 +55,7 @@ für auto_now_add bei ALTER TABLE).
|
||||||
```task
|
```task
|
||||||
id: WP-0015-T02
|
id: WP-0015-T02
|
||||||
title: Implizite Fälligkeit — Property + Überfälligkeitsprüfung
|
title: Implizite Fälligkeit — Property + Überfälligkeitsprüfung
|
||||||
status: todo
|
status: done
|
||||||
|
|
||||||
**Modell** (`apps/aufgaben/models.py`):
|
**Modell** (`apps/aufgaben/models.py`):
|
||||||
|
|
||||||
|
|
@ -125,7 +125,7 @@ angezeigt wird:
|
||||||
```task
|
```task
|
||||||
id: WP-0015-T03
|
id: WP-0015-T03
|
||||||
title: AufgabenVerknuepfung — Modell + Migration + Admin
|
title: AufgabenVerknuepfung — Modell + Migration + Admin
|
||||||
status: todo
|
status: done
|
||||||
|
|
||||||
Neues Modell in `apps/aufgaben/models.py`:
|
Neues Modell in `apps/aufgaben/models.py`:
|
||||||
|
|
||||||
|
|
@ -165,7 +165,7 @@ Preispunkt). Wird in der Form als Auswahlfeld verwendet.
|
||||||
```task
|
```task
|
||||||
id: WP-0015-T04
|
id: WP-0015-T04
|
||||||
title: Verknüpfungen-View — Liste + Hinzufügen (HTMX)
|
title: Verknüpfungen-View — Liste + Hinzufügen (HTMX)
|
||||||
status: todo
|
status: done
|
||||||
|
|
||||||
**URLs** (`apps/aufgaben/urls.py`) ergänzen:
|
**URLs** (`apps/aufgaben/urls.py`) ergänzen:
|
||||||
|
|
||||||
|
|
@ -227,7 +227,7 @@ ermittelbar), Kommentar, Löschen-Button.
|
||||||
```task
|
```task
|
||||||
id: WP-0015-T05
|
id: WP-0015-T05
|
||||||
title: Verknüpfungen-View — Entfernen (HTMX DELETE)
|
title: Verknüpfungen-View — Entfernen (HTMX DELETE)
|
||||||
status: todo
|
status: done
|
||||||
|
|
||||||
**URL** in `apps/aufgaben/urls.py`:
|
**URL** in `apps/aufgaben/urls.py`:
|
||||||
|
|
||||||
|
|
@ -265,7 +265,7 @@ Im `verknuepfung_row.html` Löschen-Button als HTMX-POST mit
|
||||||
```task
|
```task
|
||||||
id: WP-0015-T06
|
id: WP-0015-T06
|
||||||
title: ExternalIssue — Modell + Migration + Service-Interface
|
title: ExternalIssue — Modell + Migration + Service-Interface
|
||||||
status: todo
|
status: done
|
||||||
|
|
||||||
**Modell** in einem neuen Modul `apps/aufgaben/issue_models.py`
|
**Modell** in einem neuen Modul `apps/aufgaben/issue_models.py`
|
||||||
(oder direkt in `models.py`):
|
(oder direkt in `models.py`):
|
||||||
|
|
@ -345,7 +345,7 @@ Migration erstellen und ausführen. Admin registrieren.
|
||||||
```task
|
```task
|
||||||
id: WP-0015-T07
|
id: WP-0015-T07
|
||||||
title: Issue-Facade UI — Panel auf Aufgaben-Detailseite
|
title: Issue-Facade UI — Panel auf Aufgaben-Detailseite
|
||||||
status: todo
|
status: done
|
||||||
|
|
||||||
**URLs** in `apps/aufgaben/urls.py`:
|
**URLs** in `apps/aufgaben/urls.py`:
|
||||||
|
|
||||||
|
|
@ -398,7 +398,7 @@ Key, Sync-Status, Notizen, Buttons "Bearbeiten" und "Entfernen".
|
||||||
```task
|
```task
|
||||||
id: WP-0015-T08
|
id: WP-0015-T08
|
||||||
title: Tests + Smoke-Check
|
title: Tests + Smoke-Check
|
||||||
status: todo
|
status: done
|
||||||
|
|
||||||
Bestehende 68 Tests müssen grün bleiben.
|
Bestehende 68 Tests müssen grün bleiben.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue