--- id: WP-0002 title: Fachmodelle — alle Django-Models, Migrationen, Admin status: done phase: 2-of-12 created: "2026-05-08" depends_on: WP-0001 --- # WP-0002 — Fachmodelle Implementiert alle Django-Modelle gemäß `wiki/ArchitectureBlueprint.md` Abschnitt 5. Alle Modelle erben von `FlexibleModel`. Am Ende: alle Migrationen, Admin-Registrierung und ein Management-Command für Seed-Daten. **Arbeitsverzeichnis:** `/home/worsch/vergabe-teilnahme/` **Apps-Pfad:** `vergabe_teilnahme/apps/` --- ```task id: WP-0002-T01 title: Accounts-App: Mitarbeiter-Modell (AbstractUser) status: done Datei: `vergabe_teilnahme/apps/accounts/models.py` ```python from django.contrib.auth.models import AbstractUser from django.db import models class Mitarbeiter(AbstractUser): ROLLE_CHOICES = [ ('bid_manager', 'Bid Manager'), ('fachexperte', 'Fachverantwortlicher'), ('vertrieb', 'Vertrieb / Account Management'), ('pricing', 'Pricing / Controlling'), ('recht', 'Recht / Compliance'), ('geschaeftsfuehrung', 'Geschäftsführung'), ('projektleitung','Projektleitung Umsetzung'), ('admin', 'Administrator'), ] rolle = CharField(max_length=30, choices=ROLLE_CHOICES, blank=True) mobilnummer = CharField(max_length=50, blank=True) organisationseinheit = CharField(max_length=200, blank=True) def __str__(self): return self.get_full_name() or self.username ``` Registriere in `accounts/admin.py` mit `UserAdmin`. Setze `AUTH_USER_MODEL = 'accounts.Mitarbeiter'` in `settings/base.py` (bereits in T02 von WP-0001 vorbereitet — prüfen). ``` ```task id: WP-0002-T02 title: Core-App: FlexibleModel-Mixin und Basis-Infrastruktur status: done Datei: `vergabe_teilnahme/apps/core/models.py` Implementiere: 1. `FlexibleModel(Model)` — abstract base: - Hat `GenericRelation` auf `CustomAttribute` - Methode `get_hidden_fields()` — liest `EntityFieldConfig` für `self._meta.model_name` - Methode `get_visible_field_names()` — gibt Liste nicht-ausgeblendeter Felder zurück - `class Meta: abstract = True` 2. `EntityFieldConfig(Model)` — globale Feldkonfiguration pro Entitätstyp: - `entity_type` CharField(100) — model_name slug (z. B. 'subunternehmer') - `field_name` CharField(100) - `is_hidden` BooleanField(default=False) - `display_label` CharField(200, blank=True) — Umbenennung - `sort_order` PositiveSmallIntegerField(default=0) - `Meta: unique_together = ('entity_type', 'field_name')` 3. `CustomAttribute(Model)` — generisches Key-Value-Attribut: - `content_type` FK(ContentType, CASCADE) - `object_id` PositiveIntegerField() - `content_object` GenericForeignKey() - `key` CharField(100) — slug - `label` CharField(200) — Anzeigename - `value` TextField(blank=True) - `data_type` CharField(20, choices=[text/number/date/boolean/url/email], default='text') - `sort_order` PositiveSmallIntegerField(default=0) - `created_at` DateTimeField(auto_now_add=True) - `Meta: ordering = ['sort_order', 'created_at']` - `Index` auf `(content_type, object_id)` 4. `Freigabe(Model)` — generische Freigabe: - `content_type` FK(ContentType, CASCADE) - `object_id` PositiveIntegerField() - `content_object` GenericForeignKey() - `freigabe_typ` CharField(30, choices=[teilnahme/ablehnung/recht/preis/abgabe/standarddokument/referenz]) - `freigebende_person` FK(settings.AUTH_USER_MODEL, PROTECT) - `status` CharField(20, choices=[erteilt/abgelehnt/ausstehend], default='erteilt') - `kommentar` TextField(blank=True) - `timestamp` DateTimeField(auto_now_add=True) - `Meta: ordering = ['-timestamp']` Alle vier in `core/admin.py` registrieren. ``` ```task id: WP-0002-T03 title: Core-App: services.py mit Utility-Funktionen status: done Datei: `vergabe_teilnahme/apps/core/services.py` Implementiere folgende Funktionen (ohne Django-Import-Fehler zur Importzeit): ```python from datetime import date from decimal import Decimal def get_deadline_warnings(ausschreibung): """Gibt Liste von Warnungen für nahende Fristen zurück.""" warnings = [] heute = date.today() if ausschreibung.bieterfragen_bis: delta = (ausschreibung.bieterfragen_bis - heute).days if delta <= 3: warnings.append({'typ': 'bieterfragen', 'tage': delta, 'farbe': 'red' if delta <= 1 else 'amber'}) if ausschreibung.abgabe_bis: delta = (ausschreibung.abgabe_bis.date() - heute).days if delta <= 14: warnings.append({'typ': 'abgabe', 'tage': delta, 'farbe': 'red' if delta <= 3 else 'amber'}) return warnings def gewichteter_durchschnitt(preispunkte, feld='einzelpreis'): """Berechnet gewichteten Durchschnitt für Preispunkte. Gibt None zurück wenn keine verwertbaren Punkte.""" relevante = [p for p in preispunkte if getattr(p, feld) is not None and p.vergleichsgewicht > 0] if not relevante: return None summe_gewichte = sum(p.vergleichsgewicht for p in relevante) if summe_gewichte == 0: return None summe = sum(getattr(p, feld) * p.vergleichsgewicht for p in relevante) werte = [getattr(p, feld) for p in relevante] return { 'wert': summe / summe_gewichte, 'summe_gewichte': summe_gewichte, 'anzahl': len(relevante), 'minimum': min(werte), 'maximum': max(werte), 'ungewichtet': sum(werte) / len(werte), } ``` Schreibe Tests in `vergabe_teilnahme/apps/core/tests/test_services.py`: - Test gewichteter_durchschnitt mit Beispiel aus Blueprint (100×1,0 + 80×0,2 + 110×1,2 = 103,33) - Test mit leerem Input → None - Test mit allen Gewichten 0 → None ``` ```task id: WP-0002-T04 title: Ausschreibungen-App: Ausschreibung-Modell status: done Datei: `vergabe_teilnahme/apps/ausschreibungen/models.py` Implementiere `Ausschreibung(FlexibleModel)` gemäß Blueprint Abschnitt 5.1. Import von FlexibleModel: `from vergabe_teilnahme.apps.core.models import FlexibleModel` Felder exakt wie im Blueprint definiert (STATUS_CHOICES 1-13, TEILNAHME_CHOICES, alle Datums-/Fristen-Felder, erstellt_am/geaendert_am auto). Methoden: - `__str__` → `self.titel` - `property ist_aktiv` → status in range(1, 10) - `property naechste_frist` → das nächste nicht-vergangene Datum aus (bieterfragen_bis, abgabe_bis.date()) - `property phase_nummer` → min(8, max(1, self.status)) — Phasennummer für Navigator `Meta: ordering = ['-erstellt_am']` Admin-Registrierung mit list_display=['titel', 'ausschreiber', 'status', 'abgabe_bis']. ``` ```task id: WP-0002-T05 title: Lose-App: Los- und Anforderung-Modell status: done Datei: `vergabe_teilnahme/apps/lose/models.py` `Los(FlexibleModel)`: - `ausschreibung` FK(Ausschreibung, CASCADE, related_name='lose') - `losnummer` CharField(50) - `lostitel` CharField(300) - `beschreibung` TextField(blank=True) - `abgrenzung` TextField(blank=True) - `zustaendiger` FK('accounts.Mitarbeiter', null=True, blank=True, SET_NULL) - `teilnahme` BooleanField(null=True) # None = offen - `status` CharField(50, blank=True) - `Meta: ordering = ['losnummer']` - `__str__` → `f"Los {self.losnummer}: {self.lostitel}"` `Anforderung(FlexibleModel)`: - `ausschreibung` FK(Ausschreibung, CASCADE) - `los` FK(Los, null=True, blank=True, SET_NULL, related_name='anforderungen') - Alle Felder aus Blueprint 5.3 (titel, beschreibung, quelle_im_dokument, kategorie, verbindlichkeit MUSS_CHOICES, ausschlusskriterium, bewertungskriterium, zustaendiger FK, erfuellungsstatus CHOICES, nachweis_erforderlich) - `dokumente` M2M('dokumente.Dokument', blank=True) - `nachweise` M2M('bibliothek.Nachweis', blank=True) - `__str__` → `self.titel` Admin für beide Modelle. ``` ```task id: WP-0002-T06 title: Aufgaben-App: Aufgabe- und Bieterfrage-Modell status: done Datei: `vergabe_teilnahme/apps/aufgaben/models.py` `Aufgabe(FlexibleModel)` mit allen Feldern aus Blueprint 5.4: TYP_CHOICES (fachlich/rechtlich/kaufmaennisch/technisch/subunternehmer/dokument/preis) STATUS_CHOICES (offen/in_bearbeitung/wartend_intern/wartend_sub/wartend_ausschreiber/erledigt/verworfen/ueberfaellig) FKs auf Ausschreibung (CASCADE), Los (null/blank/SET_NULL), Anforderung, Bieterfrage, Dokument. prioritaet PositiveSmallIntegerField(default=2, choices=[(1,'Hoch'),(2,'Mittel'),(3,'Niedrig')]) frist DateField(null=True, blank=True) verantwortlicher FK(Mitarbeiter, null=True, SET_NULL) ergebnis TextField(blank=True) Meta: ordering = ['prioritaet', 'frist'] property `ist_ueberfaellig`: frist < date.today() and status not in ['erledigt', 'verworfen'] `Bieterfrage(FlexibleModel)` mit allen Feldern aus Blueprint 5.5: STATUS_CHOICES (entwurf/abgestimmt/eingereicht/beantwortet/eingearbeitet) FKs auf Ausschreibung (CASCADE), Anforderung (null/blank), Dokument (null/blank) prioritaet, einreichungsdatum, antwort, auswirkung_angebot, eingearbeitet Admin für beide Modelle. ``` ```task id: WP-0002-T07 title: Dokumente-App: Dokument-Modell mit Datei-Upload status: done Datei: `vergabe_teilnahme/apps/dokumente/models.py` `Dokument(FlexibleModel)` mit allen Feldern aus Blueprint 5.6: - `datei` FileField(upload_to='dokumente/%Y/%m/') - `dateiname` CharField(300) — wird beim Upload automatisch aus datei.name befüllt - Alle Status-/Kategorie-Choices - `ausschreibung` FK(null=True, blank=True) - `los` FK(Los, null=True, blank=True) - `verantwortlicher`, `pruefer` FK(Mitarbeiter) - `finale_abgabeversion` BooleanField(default=False) - `upload_datum` DateTimeField(auto_now_add=True) - `GenericRelation(Freigabe)` für direkte Freigaben-Abfrage Signal `pre_save`: falls `finale_abgabeversion` von False auf True gesetzt wird, setze `status='final_abgegeben'`. Datei-Validierung als Modell-Methode `clean()`: Erlaubte MIME-Typen: pdf, docx, xlsx, zip, png, jpg (prüfe gegen Dateiendung, keine MIME-Detection in v1) Maximalgröße: settings.MAX_UPLOAD_SIZE (Default: 50 MB) Admin-Registrierung. ``` ```task id: WP-0002-T08 title: Preise-App: Preispunkt-Modell status: done Datei: `vergabe_teilnahme/apps/preise/models.py` `Preispunkt(FlexibleModel)` gemäß Blueprint 5.7: - `ausschreibung` FK(Ausschreibung, CASCADE) - `los` FK(Los, null=True, blank=True, SET_NULL) - `leistungstyp` CharField(200) — freier Text, z. B. "Schulung", "Lizenz", "Betrieb" - `konkrete_leistung` CharField(400) - `mengeneinheit`, `waehrung` CharFields mit Defaults - `menge`, `einzelpreis`, `gesamtpreis` DecimalFields(14, 2 bzw. 4, null=True, blank=True) - `preisstand` DateField(null=True, blank=True) - `wiederkehrend` BooleanField(default=False) - `laufzeitbezug` CharField(100, blank=True) - `subunternehmeranteil` BooleanField(default=False) - `subunternehmer` FK('partner.Subunternehmer', null=True, blank=True, SET_NULL) - `vergleichsgewicht` DecimalField(3, 1, default=Decimal('1.0')) - `gewichtungsbegruendung` TextField(blank=True) - `kommentar` TextField(blank=True) - `ausschreibung_gewonnen` BooleanField(null=True) Modell-Validierung `clean()`: - `vergleichsgewicht` muss zwischen Decimal('0.0') und Decimal('2.0') liegen, sonst ValidationError("Vergleichsgewicht muss zwischen 0,0 und 2,0 liegen.") `Meta: ordering = ['leistungstyp', 'konkrete_leistung']` Admin mit list_display=['konkrete_leistung', 'leistungstyp', 'einzelpreis', 'vergleichsgewicht']. ``` ```task id: WP-0002-T09 title: Partner-App: Subunternehmer und Dienstleistertyp status: done Datei: `vergabe_teilnahme/apps/partner/models.py` `Dienstleistertyp(FlexibleModel)`: - name CharField(200), beschreibung TextField(blank=True) - typische_leistungen TextField(blank=True), typische_nachweise TextField(blank=True) - relevante_standards TextField(blank=True), typische_preisbestandteile TextField(blank=True) - bemerkungen TextField(blank=True) - `__str__` → name `Subunternehmer(FlexibleModel)`: - Alle Felder aus Blueprint 5.9 - PRAEFERENZ_CHOICES: bevorzugt/zugelassen/gesperrt (default='zugelassen') - `dienstleistertyp` FK(Dienstleistertyp, null=True, blank=True, SET_NULL) - `bisherige_ausschreibungen` M2M(Ausschreibung, blank=True, through='SubunternehmerZuordnung') `SubunternehmerZuordnung(Model)` — Durchgangstabelle (kein FlexibleModel): - `subunternehmer` FK(Subunternehmer, CASCADE) - `ausschreibung` FK(Ausschreibung, CASCADE) - `los` FK(Los, null=True, blank=True, SET_NULL) - `konkrete_leistung` CharField(300, blank=True) - `zusage_vorhanden` BooleanField(default=False) - `nachweis_eingegangen` BooleanField(default=False) - `preis_vorhanden` BooleanField(default=False) - `kommentar` TextField(blank=True) Admin für alle drei Modelle. ``` ```task id: WP-0002-T10 title: Bibliothek-App: Nachweis, Referenz, Leistungsblatt, Entscheidungsregel status: done Datei: `vergabe_teilnahme/apps/bibliothek/models.py` `Nachweis(FlexibleModel)` — Compliance-Nachweise: Alle Felder aus Blueprint 6.10 (titel, kurzbeschreibung, dokumenttyp, kategorie, datei FileField, version, gueltig_ab/bis DateFields, eigentuemer FK(Mitarbeiter), freigabestatus CharField, letzte_pruefung DateField, zugehoerige_standards TextField, vertraulichkeit CharField, sprache CharField default='de', fuer_oeffentliche BooleanField, fuer_privatwirtschaftliche BooleanField) property `ist_abgelaufen`: gueltig_bis < date.today() if gueltig_bis else False `Referenz(FlexibleModel)` — Projekt-Referenzen: Alle Felder aus Blueprint 6.11 (referenztitel, kunde, branche, oeffentlich_oder_privat, leistungsbeschreibung, eingesetzte_produkte, projektzeitraum CharField, vertragsvolumen DecimalField(null=True), ansprechpartner_referenzkunde, freigabestatus_verwendung, vertraulichkeit, whitepaper FileField(null=True), kurzfassung TextField, langfassung TextField, verwendbar_fuer_ausschreibungen BooleanField, einschraenkungen_verwendung TextField) leistungsblaetter M2M('Leistungsblatt', blank=True) `Leistungsblatt(FlexibleModel)`: produktfunktion, beschreibung, leistungsumfang, grenzen_ausschluesse, technische_voraussetzungen, typische_nachweise TextField(blank=True), version CharField, eigentuemer FK(Mitarbeiter, null=True) `Entscheidungsregel(FlexibleModel)`: regelname, beschreibung, kategorie, gewichtung DecimalField(5,2, default=1.0), bewertungslogik TextField, schwellenwert DecimalField(null=True), empfehlung CharField(choices=[teilnehmen/nicht_teilnehmen/pruefen]), begruendung TextField, gueltig_von/bis DateFields(null=True), aktiv BooleanField(default=True), verantwortlicher FK(Mitarbeiter, null=True) Admin für alle vier Modelle. ``` ```task id: WP-0002-T11 title: Marktbegleiter-App: Marktbegleiter und Ausschreibungspassage status: done Datei: `vergabe_teilnahme/apps/marktbegleiter/models.py` `Marktbegleiter(FlexibleModel)` — alle Felder aus Blueprint 6.15: name CharField(300), kurzbeschreibung TextField, produkt_leistungsportfolio TextField(blank=True), relevante_branchen TextField(blank=True), bekannte_staerken TextField(blank=True), bekannte_schwaechen TextField(blank=True), typische_formulierungen TextField(blank=True), typische_leistungsmerkmale TextField(blank=True), bekannte_zertifizierungen TextField(blank=True), bekannte_referenzen TextField(blank=True), quellen_links TextField(blank=True), letzte_aktualisierung DateField(null=True), aktualisierungsstatus CharField(50, blank=True), interne_notizen TextField(blank=True), vertraulichkeit CharField(20, choices=[intern/streng_vertraulich], default='intern') `Ausschreibungspassage(FlexibleModel)` — alle Felder aus Blueprint 6.16: ausschreibung FK(Ausschreibung, CASCADE, related_name='passagen') dokument FK(Dokument, null=True, blank=True, SET_NULL) fundstelle CharField(200, blank=True) passage TextField() kategorie CharField(100, blank=True) marktbegleiter FK(Marktbegleiter, CASCADE, related_name='passagen') begruendung_zuordnung TextField() verlaesslichkeitsscore PositiveSmallIntegerField( validators=[MinValueValidator(1), MaxValueValidator(10)]) auswirkung_entscheidung TextField(blank=True) auswirkung_preisstrategie TextField(blank=True) auswirkung_loesungskonzept TextField(blank=True) erfasst_von FK(Mitarbeiter, null=True, SET_NULL) erfassungsdatum DateField(auto_now_add=True) Admin für beide Modelle. ``` ```task id: WP-0002-T12 title: Nachbetrachtung- und Feedback-Modelle status: done Datei: `vergabe_teilnahme/apps/nachbetrachtung/models.py` `Nachbetrachtung(FlexibleModel)`: - `ausschreibung` OneToOneField(Ausschreibung, CASCADE, related_name='nachbetrachtung') - `ergebnis` CharField(20, choices=[gewonnen/verloren/aufgehoben/offen/zurueckgezogen]) - `zuschlagsdatum` DateField(null=True, blank=True) - `abgegebene_unterlagen` TextField(blank=True) - `abgegebene_preise` TextField(blank=True) - `verlustgruende` JSONField(default=list) # Format: [{"grund": "...", "kategorie": "preis|referenz|...", "verlaesslichkeit": 1-5}] - `ausschlaggebende_zuschlagsmerkmale` TextField(blank=True) - `lessons_learned` TextField(blank=True) - `empfehlungen` TextField(blank=True) - `wiederverwendbare_erkenntnisse_markiert` BooleanField(default=False) - `projektverantwortlicher` FK(Mitarbeiter, null=True, blank=True, SET_NULL) Datei: `vergabe_teilnahme/apps/feedback/models.py` `Feedbackeintrag(FlexibleModel)` — alle Felder aus Blueprint 6.18: titel, beschreibung TextField, seite_kontext CharField(500), ausschreibung FK(Ausschreibung, null=True, blank=True, SET_NULL), kategorie CharField(choices=[fehler/verbesserung/hinweis]), dringlichkeit CharField(choices=[niedrig/mittel/hoch/kritisch], default='mittel'), prioritaet PositiveSmallIntegerField(default=2), status CharField(choices=[neu/in_bearbeitung/umgesetzt/abgelehnt], default='neu'), erfasst_von FK(Mitarbeiter, null=True, SET_NULL), datum DateTimeField(auto_now_add=True), bewertung TextField(blank=True), entscheidung TextField(blank=True), umsetzungshinweis TextField(blank=True) Admin für beide Modelle. ``` ```task id: WP-0002-T13 title: Alle Migrationen generieren und ausführen status: done Führe aus (in dieser Reihenfolge, da Apps voneinander abhängen): ```bash uv run manage.py makemigrations accounts uv run manage.py makemigrations core uv run manage.py makemigrations ausschreibungen uv run manage.py makemigrations lose uv run manage.py makemigrations aufgaben uv run manage.py makemigrations dokumente uv run manage.py makemigrations preise uv run manage.py makemigrations partner uv run manage.py makemigrations bibliothek uv run manage.py makemigrations marktbegleiter uv run manage.py makemigrations nachbetrachtung uv run manage.py makemigrations feedback uv run manage.py migrate ``` Behebe etwaige Zirkulär-Import-Fehler durch String-Referenzen (z. B. `FK('ausschreibungen.Ausschreibung', ...)` statt direktem Import). Prüfe: `uv run manage.py check` → keine Fehler. Prüfe: `uv run manage.py showmigrations` → alle Migrationen als [X] markiert. ``` ```task id: WP-0002-T14 title: Management-Command für Entwicklungs-Seed-Daten status: done Erstelle `vergabe_teilnahme/apps/core/management/commands/seed_dev.py` Der Command erstellt: 1. 3 Mitarbeiter (BM Max Muster, FV Anna Fach, GF Georg Chef) 2. 2 Dienstleistertypen (IT-Dienstleister, Rechtsberatung) 3. 1 Subunternehmer mit Zuordnung zu Dienstleistertyp 4. 2 Nachweise (ISO-27001 gültig, DSGVO-Muster gültig) 5. 1 Marktbegleiter 6. 3 Entscheidungsregeln (Muss-Anforderung nicht erfüllbar, Frist zu kurz, fehlende Referenz) 7. 1 Ausschreibung (Status 4, mit 2 Losen, 3 Anforderungen, 2 Aufgaben, 2 Preispunkten) Nutze `get_or_create` für alle Objekte damit der Command idempotent ist. Ausgabe am Ende: Zusammenfassung der erstellten Objekte. Prüfe: `uv run manage.py seed_dev` läuft fehlerfrei durch. Prüfe anschließend: `uv run manage.py shell -c "from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung; print(Ausschreibung.objects.count())"` → mindestens 1. ```