Established workplans
This commit is contained in:
parent
026f7c0db1
commit
315143a6fc
16 changed files with 5492 additions and 0 deletions
892
wiki/ArchitectureBlueprint.md
Normal file
892
wiki/ArchitectureBlueprint.md
Normal file
|
|
@ -0,0 +1,892 @@
|
|||
# Architecture Blueprint — Vergabe Teilnahme
|
||||
|
||||
**Version:** 1.0
|
||||
**Datum:** 7. Mai 2026
|
||||
**Grundlage:** ProductRequirementsDocument.md
|
||||
|
||||
---
|
||||
|
||||
## 1. Leitprinzipien
|
||||
|
||||
| Prinzip | Konsequenz |
|
||||
|---|---|
|
||||
| **Phasengeführt, nie phasengesperrt** | Die 8 Phasen sind Navigation und Orientierung, keine Zugangssperren. Jedes Element ist jederzeit erreichbar. |
|
||||
| **Kein Zwang zur Vollständigkeit** | Pflichtfelder gibt es nur an echten Systemgrenzen (z. B. Abgabenachweis). Alle anderen Felder können leer bleiben. |
|
||||
| **Anpassbare Datenstruktur** | Jede Entität erlaubt das globale Ausblenden ungenutzter Felder und das Hinzufügen beliebiger Schlüssel-Wert-Attribute. |
|
||||
| **Manuelle Erfassung first** | Keine Automatisierung in v1. Alle Felder werden manuell gepflegt. |
|
||||
| **Server-first, progressive Enhancement** | Seitenrendering auf dem Server. HTMX für partielle Aktualisierungen. Alpine.js für reine UI-Zustände. |
|
||||
|
||||
---
|
||||
|
||||
## 2. Technology Stack
|
||||
|
||||
| Schicht | Technologie | Begründung |
|
||||
|---|---|---|
|
||||
| Sprache | Python 3.12+ | .gitignore-Konvention; erprobtes Ökosystem |
|
||||
| Paketmanager | uv | Geschwindigkeit, lockfile, Python-Versions-Management |
|
||||
| Web-Framework | Django 5.x | ORM, Admin, Auth, ContentType-Framework — alle benötigt |
|
||||
| Template-Rendering | Django Templates | Server-side, kein Build-Step für Templates |
|
||||
| Reaktivität | HTMX 2.x | Partielle DOM-Updates ohne SPA-Overhead |
|
||||
| UI-Zustandsverwaltung | Alpine.js 3.x | Accordions, Dropdowns, Modals, Inline-Toggles |
|
||||
| CSS | Tailwind CSS 4.x | Utility-first; Design-Token-System (s. Abschnitt 8) |
|
||||
| CSS-Build | PostCSS + Vite | Tailwind JIT, Minification |
|
||||
| Datenbank | PostgreSQL 16+ | JSONB, volltextsuche, referentielle Integrität |
|
||||
| Datei-Storage | Django FileField (lokal) | Einfach; abstrakt genug für S3 via django-storages later |
|
||||
| Background-Tasks | Celery + Redis | Fristenprüfung, Benachrichtigungen (optional in v1) |
|
||||
| Testing | pytest-django | Fixture-basiert; kein Mock der DB |
|
||||
|
||||
---
|
||||
|
||||
## 3. Django-App-Struktur
|
||||
|
||||
Das Projekt ist in fachliche Apps aufgeteilt. Technische Querschnittsthemen liegen in `core`.
|
||||
|
||||
```
|
||||
vergabe_teilnahme/ # Django-Projekt-Root
|
||||
├── core/ # Querschnittslogik
|
||||
│ ├── models.py # FlexibleModel-Mixin, CustomAttribute, EntityFieldConfig, Freigabe
|
||||
│ ├── mixins.py # FlexFieldMixin für Views
|
||||
│ ├── templatetags/ # render_field, flex_fields, phase_badge, status_badge
|
||||
│ └── services.py # weighted_average(), deadline_warnings()
|
||||
├── ausschreibungen/ # Ausschreibung, Teilnahmeentscheidung
|
||||
├── lose/ # Los, Anforderung, Auflage
|
||||
├── aufgaben/ # Aufgabe (Offener Punkt), Bieterfrage
|
||||
├── dokumente/ # Dokument, Datei-Upload, Versionierung
|
||||
├── preise/ # Preispunkt, Preismodell, Auswertungen
|
||||
├── partner/ # Subunternehmer, Dienstleistertyp
|
||||
├── bibliothek/ # Nachweis, Referenz, Leistungsblatt, Entscheidungsregel
|
||||
├── marktbegleiter/ # Marktbegleiter, Ausschreibungspassage
|
||||
├── nachbetrachtung/ # Nachbetrachtung (Phase 8)
|
||||
├── feedback/ # Feedbackeintrag
|
||||
└── accounts/ # Mitarbeiter (erweitertes Django-User-Modell)
|
||||
```
|
||||
|
||||
### App-Abhängigkeiten (vereinfacht)
|
||||
|
||||
```
|
||||
core ←── alle Apps
|
||||
accounts ←── ausschreibungen, lose, aufgaben, dokumente, preise, partner
|
||||
ausschreibungen ←── lose, aufgaben, dokumente, preise, marktbegleiter, nachbetrachtung
|
||||
bibliothek ←── lose (Anforderungs-Nachweise), dokumente (Standarddokumente)
|
||||
partner ←── lose, aufgaben, preise
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Flexible Felder — Das Anpassungssystem
|
||||
|
||||
Jede Entität unterstützt zwei Anpassungsmechanismen:
|
||||
|
||||
### 4.1 Felder ausblenden: `EntityFieldConfig`
|
||||
|
||||
Definiert **global** (nicht pro Instanz), welche eingebauten Felder einer Entität ausgeblendet werden. Einmal konfiguriert — wirkt für alle Instanzen dieses Typs.
|
||||
|
||||
```python
|
||||
# core/models.py
|
||||
|
||||
class EntityFieldConfig(Model):
|
||||
entity_type = CharField(max_length=100) # z. B. "subunternehmer", "anforderung"
|
||||
field_name = CharField(max_length=100) # interner Feldname
|
||||
is_hidden = BooleanField(default=False)
|
||||
display_label = CharField(max_length=200, blank=True) # Umbenennungsoption
|
||||
sort_order = PositiveSmallIntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('entity_type', 'field_name')
|
||||
```
|
||||
|
||||
**Template-Tag** `{% render_field obj "mobilnummer" %}` prüft `EntityFieldConfig` und rendert das Feld nur, wenn `is_hidden=False`.
|
||||
|
||||
**Verwaltungsoberfläche:** Unter `/admin/felder/` können Administratoren für jeden Entitätstyp in einer Tabelle alle Felder sehen, ein-/ausblenden und umbenennen. Keine Code-Änderung erforderlich.
|
||||
|
||||
### 4.2 Benutzerdefinierte Schlüssel-Wert-Attribute: `CustomAttribute`
|
||||
|
||||
Für jede Instanz beliebig erweiterbar. Verwendet Django's ContentType-Framework (Generic FK).
|
||||
|
||||
```python
|
||||
# core/models.py
|
||||
|
||||
class CustomAttribute(Model):
|
||||
DATA_TYPES = [
|
||||
('text', 'Text'),
|
||||
('number', 'Zahl'),
|
||||
('date', 'Datum'),
|
||||
('boolean', 'Ja / Nein'),
|
||||
('url', 'Link'),
|
||||
('email', 'E-Mail'),
|
||||
]
|
||||
|
||||
content_type = ForeignKey(ContentType, on_delete=CASCADE)
|
||||
object_id = PositiveIntegerField()
|
||||
content_object = GenericForeignKey('content_type', 'object_id')
|
||||
|
||||
key = CharField(max_length=100) # interner Schlüssel (slug)
|
||||
label = CharField(max_length=200) # Anzeigebezeichnung
|
||||
value = TextField(blank=True)
|
||||
data_type = CharField(max_length=20, choices=DATA_TYPES, default='text')
|
||||
sort_order = PositiveSmallIntegerField(default=0)
|
||||
created_at = DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['sort_order', 'created_at']
|
||||
indexes = [Index(fields=['content_type', 'object_id'])]
|
||||
```
|
||||
|
||||
**UX:** Jede Detailseite zeigt am Ende einen Abschnitt "Weitere Attribute" mit einem "+ Attribut hinzufügen"-Button (HTMX-Inline-Formular). Bestehende Attribute können bearbeitet, umsortiert und gelöscht werden.
|
||||
|
||||
### 4.3 Mixin für alle Entitäts-Modelle
|
||||
|
||||
```python
|
||||
# core/models.py
|
||||
|
||||
class FlexibleModel(Model):
|
||||
"""Abstract base für alle Fachentitäten."""
|
||||
|
||||
custom_attributes = GenericRelation(CustomAttribute)
|
||||
|
||||
def get_visible_fields(self):
|
||||
hidden = EntityFieldConfig.objects.filter(
|
||||
entity_type=self._meta.model_name,
|
||||
is_hidden=True
|
||||
).values_list('field_name', flat=True)
|
||||
return [f for f in self._meta.get_fields() if f.name not in hidden]
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Datenmodelle
|
||||
|
||||
### 5.1 Ausschreibung
|
||||
|
||||
```python
|
||||
class Ausschreibung(FlexibleModel):
|
||||
STATUS_CHOICES = [
|
||||
(1, 'Recherchiert / angelegt'),
|
||||
(2, 'In Erstprüfung'),
|
||||
(3, 'Teilnahme entschieden'),
|
||||
(4, 'Detailanalyse läuft'),
|
||||
(5, 'Klärung / Bieterfragen läuft'),
|
||||
(6, 'Preisgestaltung läuft'),
|
||||
(7, 'Unterlagenfinalisierung läuft'),
|
||||
(8, 'Bereit zur Abgabe'),
|
||||
(9, 'Abgegeben'),
|
||||
(10, 'Zuschlag gewonnen'),
|
||||
(11, 'Zuschlag verloren'),
|
||||
(12, 'Aufgehoben / zurückgezogen'),
|
||||
(13, 'Archiviert'),
|
||||
]
|
||||
TEILNAHME_CHOICES = [
|
||||
('offen', 'Offen'),
|
||||
('teilnahme', 'Teilnahme'),
|
||||
('ablehnung', 'Nichtteilnahme'),
|
||||
('pruefung', 'Weitere Prüfung erforderlich'),
|
||||
]
|
||||
|
||||
titel = CharField(max_length=400)
|
||||
ausschreiber = CharField(max_length=300)
|
||||
plattform = CharField(max_length=200, blank=True)
|
||||
plattform_link = URLField(blank=True)
|
||||
ansprechpartner = CharField(max_length=300, blank=True)
|
||||
hauptverantwortung = ForeignKey('accounts.Mitarbeiter', null=True, blank=True,
|
||||
on_delete=SET_NULL, related_name='verantwortete_ausschreibungen')
|
||||
status = PositiveSmallIntegerField(choices=STATUS_CHOICES, default=1)
|
||||
teilnahmeentscheidung = CharField(max_length=20, choices=TEILNAHME_CHOICES, default='offen')
|
||||
beschreibung = TextField(blank=True)
|
||||
strategische_relevanz = TextField(blank=True)
|
||||
ergebnis = CharField(max_length=20, blank=True) # gewonnen, verloren, aufgehoben, offen
|
||||
archiviert = BooleanField(default=False)
|
||||
|
||||
# Fristen
|
||||
bieterfragen_bis = DateField(null=True, blank=True)
|
||||
abgabe_bis = DateTimeField(null=True, blank=True)
|
||||
zuschlag_bis = DateField(null=True, blank=True)
|
||||
produktiv_bis = DateField(null=True, blank=True)
|
||||
|
||||
erstellt_am = DateTimeField(auto_now_add=True)
|
||||
geaendert_am = DateTimeField(auto_now=True)
|
||||
```
|
||||
|
||||
### 5.2 Los
|
||||
|
||||
```python
|
||||
class Los(FlexibleModel):
|
||||
ausschreibung = ForeignKey(Ausschreibung, on_delete=CASCADE, related_name='lose')
|
||||
losnummer = CharField(max_length=50)
|
||||
lostitel = CharField(max_length=300)
|
||||
beschreibung = TextField(blank=True)
|
||||
abgrenzung = TextField(blank=True)
|
||||
zustaendiger = ForeignKey('accounts.Mitarbeiter', null=True, blank=True, on_delete=SET_NULL)
|
||||
teilnahme = BooleanField(null=True) # None = noch nicht entschieden
|
||||
status = CharField(max_length=50, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['losnummer']
|
||||
```
|
||||
|
||||
### 5.3 Anforderung / Auflage
|
||||
|
||||
```python
|
||||
class Anforderung(FlexibleModel):
|
||||
VERBINDLICHKEIT = [('muss', 'Muss'), ('soll', 'Soll'), ('kann', 'Kann')]
|
||||
ERFUELLUNGSSTATUS = [
|
||||
('offen', 'Offen'),
|
||||
('in_pruefung', 'In Prüfung'),
|
||||
('erfuellbar', 'Erfüllbar'),
|
||||
('mit_subunternehmer', 'Erfüllbar mit Subunternehmer'),
|
||||
('nicht_erfuellbar', 'Nicht erfüllbar'),
|
||||
('klaerung', 'Klärung erforderlich'),
|
||||
]
|
||||
|
||||
ausschreibung = ForeignKey(Ausschreibung, on_delete=CASCADE)
|
||||
los = ForeignKey(Los, null=True, blank=True, on_delete=SET_NULL)
|
||||
titel = CharField(max_length=400)
|
||||
beschreibung = TextField(blank=True)
|
||||
quelle_im_dokument = CharField(max_length=300, blank=True)
|
||||
kategorie = CharField(max_length=100, blank=True)
|
||||
verbindlichkeit = CharField(max_length=10, choices=VERBINDLICHKEIT, default='muss')
|
||||
ausschlusskriterium = BooleanField(default=False)
|
||||
bewertungskriterium = BooleanField(default=False)
|
||||
zustaendiger = ForeignKey('accounts.Mitarbeiter', null=True, blank=True, on_delete=SET_NULL)
|
||||
erfuellungsstatus = CharField(max_length=30, choices=ERFUELLUNGSSTATUS, default='offen')
|
||||
nachweis_erforderlich = BooleanField(default=False)
|
||||
dokumente = ManyToManyField('dokumente.Dokument', blank=True)
|
||||
nachweise = ManyToManyField('bibliothek.Nachweis', blank=True)
|
||||
```
|
||||
|
||||
### 5.4 Aufgabe (Offener Punkt)
|
||||
|
||||
```python
|
||||
class Aufgabe(FlexibleModel):
|
||||
TYP_CHOICES = [
|
||||
('fachlich', 'Fachlich'),
|
||||
('rechtlich', 'Rechtlich'),
|
||||
('kaufmaennisch', 'Kaufmännisch'),
|
||||
('technisch', 'Technisch'),
|
||||
('subunternehmer', 'Subunternehmerklärung'),
|
||||
('dokument', 'Dokumentenaufgabe'),
|
||||
('preis', 'Preisaufgabe'),
|
||||
]
|
||||
STATUS_CHOICES = [
|
||||
('offen', 'Offen'),
|
||||
('in_bearbeitung', 'In Bearbeitung'),
|
||||
('wartend_intern', 'Wartend auf intern'),
|
||||
('wartend_sub', 'Wartend auf Subunternehmer'),
|
||||
('wartend_ausschreiber', 'Wartend auf Ausschreiber'),
|
||||
('erledigt', 'Erledigt'),
|
||||
('verworfen', 'Verworfen'),
|
||||
('ueberfaellig', 'Überfällig'),
|
||||
]
|
||||
|
||||
ausschreibung = ForeignKey(Ausschreibung, on_delete=CASCADE)
|
||||
los = ForeignKey(Los, null=True, blank=True, on_delete=SET_NULL)
|
||||
anforderung = ForeignKey(Anforderung, null=True, blank=True, on_delete=SET_NULL)
|
||||
bieterfrage = ForeignKey('aufgaben.Bieterfrage', null=True, blank=True, on_delete=SET_NULL)
|
||||
dokument = ForeignKey('dokumente.Dokument', null=True, blank=True, on_delete=SET_NULL)
|
||||
|
||||
titel = CharField(max_length=400)
|
||||
beschreibung = TextField(blank=True)
|
||||
typ = CharField(max_length=20, choices=TYP_CHOICES)
|
||||
prioritaet = PositiveSmallIntegerField(default=2) # 1=hoch, 2=mittel, 3=niedrig
|
||||
frist = DateField(null=True, blank=True)
|
||||
verantwortlicher = ForeignKey('accounts.Mitarbeiter', null=True, blank=True, on_delete=SET_NULL)
|
||||
status = CharField(max_length=30, choices=STATUS_CHOICES, default='offen')
|
||||
ergebnis = TextField(blank=True)
|
||||
```
|
||||
|
||||
### 5.5 Bieterfrage
|
||||
|
||||
```python
|
||||
class Bieterfrage(FlexibleModel):
|
||||
STATUS_CHOICES = [
|
||||
('entwurf', 'Entwurf'),
|
||||
('abgestimmt', 'Intern abgestimmt'),
|
||||
('eingereicht','Eingereicht'),
|
||||
('beantwortet','Beantwortet'),
|
||||
('eingearbeitet', 'Eingearbeitet'),
|
||||
]
|
||||
|
||||
ausschreibung = ForeignKey(Ausschreibung, on_delete=CASCADE)
|
||||
anforderung = ForeignKey(Anforderung, null=True, blank=True, on_delete=SET_NULL)
|
||||
dokument = ForeignKey('dokumente.Dokument', null=True, blank=True, on_delete=SET_NULL)
|
||||
frage = TextField()
|
||||
hintergrund = TextField(blank=True)
|
||||
verantwortlicher = ForeignKey('accounts.Mitarbeiter', null=True, blank=True, on_delete=SET_NULL)
|
||||
status = CharField(max_length=20, choices=STATUS_CHOICES, default='entwurf')
|
||||
prioritaet = PositiveSmallIntegerField(default=2)
|
||||
einreichungsdatum = DateField(null=True, blank=True)
|
||||
antwort = TextField(blank=True)
|
||||
auswirkung_angebot = TextField(blank=True)
|
||||
eingearbeitet = BooleanField(default=False)
|
||||
```
|
||||
|
||||
### 5.6 Dokument
|
||||
|
||||
```python
|
||||
class Dokument(FlexibleModel):
|
||||
STATUS_CHOICES = [
|
||||
('hochgeladen', 'Hochgeladen'),
|
||||
('zu_pruefen', 'Zu prüfen'),
|
||||
('in_bearbeitung', 'In Bearbeitung'),
|
||||
('geprueft', 'Geprüft'),
|
||||
('freigegeben', 'Freigegeben'),
|
||||
('final_abgegeben','Final abgegeben'),
|
||||
('ersetzt', 'Ersetzt / veraltet'),
|
||||
('archiviert', 'Archiviert'),
|
||||
]
|
||||
KATEGORIE_CHOICES = [
|
||||
('leistungsverzeichnis', 'Leistungsverzeichnis'),
|
||||
('vertragsunterlagen', 'Vertragsunterlagen'),
|
||||
('preisblatt', 'Preisblatt'),
|
||||
('formblatt', 'Formblatt'),
|
||||
('eignungsnachweis', 'Eignungsnachweis'),
|
||||
('technische_anlage', 'Technische Anlage'),
|
||||
('bieterinformation', 'Bieterinformation'),
|
||||
('sonstiges', 'Sonstiges'),
|
||||
]
|
||||
|
||||
ausschreibung = ForeignKey(Ausschreibung, null=True, blank=True, on_delete=SET_NULL)
|
||||
los = ForeignKey(Los, null=True, blank=True, on_delete=SET_NULL)
|
||||
datei = FileField(upload_to='dokumente/%Y/%m/')
|
||||
dateiname = CharField(max_length=300)
|
||||
kategorie = CharField(max_length=50, choices=KATEGORIE_CHOICES)
|
||||
version = CharField(max_length=50, default='1.0')
|
||||
quelle = CharField(max_length=200, blank=True)
|
||||
status = CharField(max_length=20, choices=STATUS_CHOICES, default='hochgeladen')
|
||||
verantwortlicher = ForeignKey('accounts.Mitarbeiter', null=True, blank=True,
|
||||
on_delete=SET_NULL, related_name='verantwortete_dokumente')
|
||||
pruefer = ForeignKey('accounts.Mitarbeiter', null=True, blank=True,
|
||||
on_delete=SET_NULL, related_name='zu_pruefende_dokumente')
|
||||
finale_abgabeversion = BooleanField(default=False)
|
||||
upload_datum = DateTimeField(auto_now_add=True)
|
||||
```
|
||||
|
||||
### 5.7 Preispunkt
|
||||
|
||||
```python
|
||||
from decimal import Decimal
|
||||
|
||||
class Preispunkt(FlexibleModel):
|
||||
ausschreibung = ForeignKey(Ausschreibung, on_delete=CASCADE)
|
||||
los = ForeignKey(Los, null=True, blank=True, on_delete=SET_NULL)
|
||||
leistungstyp = CharField(max_length=200)
|
||||
konkrete_leistung = CharField(max_length=400)
|
||||
mengeneinheit = CharField(max_length=50, blank=True)
|
||||
menge = DecimalField(max_digits=14, decimal_places=4, null=True, blank=True)
|
||||
einzelpreis = DecimalField(max_digits=14, decimal_places=2, null=True, blank=True)
|
||||
gesamtpreis = DecimalField(max_digits=14, decimal_places=2, null=True, blank=True)
|
||||
waehrung = CharField(max_length=3, default='EUR')
|
||||
preisstand = DateField(null=True, blank=True)
|
||||
wiederkehrend = BooleanField(default=False)
|
||||
laufzeitbezug = CharField(max_length=100, blank=True)
|
||||
subunternehmeranteil = BooleanField(default=False)
|
||||
subunternehmer = ForeignKey('partner.Subunternehmer', null=True, blank=True, on_delete=SET_NULL)
|
||||
|
||||
# Vergleichsgewicht: 0.0 – 2.0, Default 1.0
|
||||
vergleichsgewicht = DecimalField(max_digits=3, decimal_places=1, default=Decimal('1.0'))
|
||||
gewichtungsbegruendung = TextField(blank=True)
|
||||
kommentar = TextField(blank=True)
|
||||
|
||||
# Kontextmarkierungen für Auswertungen
|
||||
ausschreibung_gewonnen = BooleanField(null=True)
|
||||
```
|
||||
|
||||
**Gewichteter Durchschnitt** (Service-Funktion in `preise/services.py`):
|
||||
|
||||
```python
|
||||
from decimal import Decimal
|
||||
|
||||
def gewichteter_durchschnitt(preispunkte, feld='einzelpreis'):
|
||||
relevante = [p for p in preispunkte
|
||||
if getattr(p, feld) is not None and p.vergleichsgewicht > 0]
|
||||
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)
|
||||
return {
|
||||
'wert': summe / summe_gewichte,
|
||||
'summe_gewichte': summe_gewichte,
|
||||
'anzahl': len(relevante),
|
||||
'minimum': min(getattr(p, feld) for p in relevante),
|
||||
'maximum': max(getattr(p, feld) for p in relevante),
|
||||
}
|
||||
```
|
||||
|
||||
### 5.8 Freigabe (generic)
|
||||
|
||||
```python
|
||||
class Freigabe(Model):
|
||||
TYP_CHOICES = [
|
||||
('teilnahme', 'Teilnahmefreigabe'),
|
||||
('ablehnung', 'Nichtteilnahmefreigabe'),
|
||||
('recht', 'Rechts-/Compliance-Freigabe'),
|
||||
('preis', 'Preisfreigabe'),
|
||||
('abgabe', 'Finale Abgabefreigabe'),
|
||||
('standarddokument','Freigabe Standarddokument'),
|
||||
('referenz', 'Freigabe Referenzunterlage'),
|
||||
]
|
||||
STATUS_CHOICES = [
|
||||
('erteilt', 'Erteilt'),
|
||||
('abgelehnt', 'Abgelehnt'),
|
||||
('ausstehend', 'Ausstehend'),
|
||||
]
|
||||
|
||||
content_type = ForeignKey(ContentType, on_delete=CASCADE)
|
||||
object_id = PositiveIntegerField()
|
||||
content_object = GenericForeignKey('content_type', 'object_id')
|
||||
|
||||
freigabe_typ = CharField(max_length=30, choices=TYP_CHOICES)
|
||||
freigebende_person = ForeignKey(settings.AUTH_USER_MODEL, on_delete=PROTECT)
|
||||
status = CharField(max_length=20, choices=STATUS_CHOICES, default='erteilt')
|
||||
kommentar = TextField(blank=True)
|
||||
timestamp = DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-timestamp']
|
||||
```
|
||||
|
||||
### 5.9 Subunternehmer
|
||||
|
||||
```python
|
||||
class Subunternehmer(FlexibleModel):
|
||||
PRAEFERENZ = [('bevorzugt', 'Bevorzugt'), ('zugelassen', 'Zugelassen'), ('gesperrt', 'Gesperrt')]
|
||||
|
||||
name = CharField(max_length=300)
|
||||
dienstleistertyp = ForeignKey('partner.Dienstleistertyp', null=True, blank=True, on_delete=SET_NULL)
|
||||
leistungsbereiche = TextField(blank=True)
|
||||
ansprechpartner = CharField(max_length=200, blank=True)
|
||||
email = EmailField(blank=True)
|
||||
mobilnummer = CharField(max_length=50, blank=True)
|
||||
adresse = TextField(blank=True)
|
||||
webseite = URLField(blank=True)
|
||||
zertifizierungen = TextField(blank=True)
|
||||
praeferenz = CharField(max_length=20, choices=PRAEFERENZ, default='zugelassen')
|
||||
bewertung = TextField(blank=True)
|
||||
typische_preislogik = TextField(blank=True)
|
||||
bemerkungen = TextField(blank=True)
|
||||
```
|
||||
|
||||
### 5.10 Weitere Entitäten (Kurzform)
|
||||
|
||||
| Entität | App | Besonderheiten |
|
||||
|---|---|---|
|
||||
| `Dienstleistertyp` | `partner` | Katalog; kein FK auf Ausschreibung |
|
||||
| `Nachweis` | `bibliothek` | `gueltig_ab`, `gueltig_bis` → Ablaufwarnung |
|
||||
| `Referenz` | `bibliothek` | `whitepaper` FileField; Freigabestatus via `Freigabe` |
|
||||
| `Leistungsblatt` | `bibliothek` | Versioniert; zugehörige Referenzen M2M |
|
||||
| `Entscheidungsregel` | `bibliothek` | Kein Automat; liefert Empfehlung + Score |
|
||||
| `Marktbegleiter` | `marktbegleiter` | Globaler Katalog |
|
||||
| `Ausschreibungspassage` | `marktbegleiter` | FK auf `Ausschreibung`, `Dokument`, `Marktbegleiter`; Verlässlichkeitsscore 0–10 |
|
||||
| `Nachbetrachtung` | `nachbetrachtung` | 1:1 mit Ausschreibung; enthält Verlustgründe als JSON-Array |
|
||||
| `Mitarbeiter` | `accounts` | Erweitert `AbstractUser`; Rolle, Org-Einheit |
|
||||
| `Feedbackeintrag` | `feedback` | Seite/Kontext als CharField; optional FK auf Ausschreibung |
|
||||
|
||||
---
|
||||
|
||||
## 6. Navigationsarchitektur
|
||||
|
||||
### 6.1 Shell-Layout
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ TOPBAR │
|
||||
│ [Logo] [Globale Suche ] [🔔] [Avatar ▾] │
|
||||
├──────────────┬───────────────────────────────────────────────────┤
|
||||
│ │ BREADCRUMB │
|
||||
│ SIDEBAR ├───────────────────────────────────────────────────┤
|
||||
│ (240 px) │ │
|
||||
│ │ HAUPTINHALT │
|
||||
│ Globale Nav │ │
|
||||
│ │ │
|
||||
│ ───────── │ │
|
||||
│ │ │
|
||||
│ Phasen-Nav │ │
|
||||
│ (wenn in │ │
|
||||
│ Ausschrei- │ │
|
||||
│ bung) │ │
|
||||
│ │ │
|
||||
└──────────────┴───────────────────────────────────────────────────┘
|
||||
[💬 Feedback-Button]
|
||||
```
|
||||
|
||||
Der Feedback-Button ist auf jeder Seite unten rechts persistent sichtbar und öffnet ein HTMX-Modal.
|
||||
|
||||
### 6.2 Sidebar — Globale Abschnitte
|
||||
|
||||
```
|
||||
▸ Übersicht (Dashboard)
|
||||
▸ Ausschreibungen
|
||||
└─ + Neue Ausschreibung
|
||||
|
||||
▸ Bibliothek
|
||||
├─ Dokumente
|
||||
├─ Nachweise & Compliance
|
||||
├─ Referenzen
|
||||
├─ Leistungsblätter
|
||||
└─ Entscheidungsregeln
|
||||
|
||||
▸ Partner
|
||||
├─ Subunternehmer
|
||||
└─ Dienstleistertypen
|
||||
|
||||
▸ Marktbegleiter
|
||||
|
||||
▸ Feedback-Backlog
|
||||
|
||||
▸ Administration
|
||||
└─ Felder & Stammdaten ← EntityFieldConfig-Verwaltung
|
||||
```
|
||||
|
||||
### 6.3 Sidebar — Phasen-Navigator (kontextuell)
|
||||
|
||||
Erscheint unterhalb der globalen Nav, wenn der Nutzer in einer Ausschreibung navigiert:
|
||||
|
||||
```
|
||||
▾ [Ausschreibung: Titel]
|
||||
┌─ ① Recherche & Unterlagen (aktuell: Phase 4)
|
||||
├─ ② Teilnahmeentscheidung ✓
|
||||
├─ ③ Detailanalyse ● ← aktive Phase hervorgehoben
|
||||
├─ ④ Bieterfragen & Klärung ●
|
||||
├─ ⑤ Preisgestaltung
|
||||
├─ ⑥ Unterlagen finalisieren
|
||||
├─ ⑦ Abgabe
|
||||
└─ ⑧ Nachbetrachtung
|
||||
```
|
||||
|
||||
Jede Phasennummer ist ein direkter Link. Die Phasenzahl im Status-Feld der Ausschreibung steuert nur die visuelle Hervorhebung — sie sperrt keinen Zugang.
|
||||
|
||||
**Phasenindikator-Zustände:**
|
||||
|
||||
| Symbol | Bedeutung |
|
||||
|---|---|
|
||||
| `○` | Noch nicht begonnen |
|
||||
| `●` | In Bearbeitung |
|
||||
| `✓` | Abgeschlossen |
|
||||
| `⚠` | Aktion erforderlich (überfällige Aufgaben o. Ä.) |
|
||||
|
||||
### 6.4 Dashboard
|
||||
|
||||
Das Dashboard zeigt aggregierte Kacheln und Listen:
|
||||
|
||||
- **Kritische Fristen** — Ausschreibungen mit `abgabe_bis` ≤ 14 Tage
|
||||
- **Ohne Entscheidung** — Status 1–2, älter als 3 Tage
|
||||
- **Überfällige Aufgaben** — Aufgaben mit `frist < heute` und Status ≠ erledigt/verworfen
|
||||
- **Laufende Ausschreibungen** — Status 3–8
|
||||
- **Gewonnen / Verloren** — Letzte 30 Tage
|
||||
- **Ablaufende Nachweise** — Bibliothek-Nachweise mit `gueltig_bis` ≤ 60 Tage
|
||||
|
||||
---
|
||||
|
||||
## 7. URL-Struktur
|
||||
|
||||
```
|
||||
/ # Dashboard
|
||||
|
||||
/ausschreibungen/ # Liste + Filter
|
||||
/ausschreibungen/neu/ # Erstellen
|
||||
/ausschreibungen/<id>/ # Detail / Phase 1 (Stammdaten + Dokumente)
|
||||
/ausschreibungen/<id>/entscheidung/ # Phase 2 (Teilnahmeentscheidung)
|
||||
/ausschreibungen/<id>/lose/ # Phase 3a (Lose)
|
||||
/ausschreibungen/<id>/lose/<lot_id>/ # Los-Detail
|
||||
/ausschreibungen/<id>/anforderungen/ # Phase 3b (Anforderungen)
|
||||
/ausschreibungen/<id>/aufgaben/ # Phase 3c + 4 (Aufgaben)
|
||||
/ausschreibungen/<id>/bieterfragen/ # Phase 4 (Bieterfragen)
|
||||
/ausschreibungen/<id>/preise/ # Phase 5 (Preispunkte + Auswertung)
|
||||
/ausschreibungen/<id>/abgabe/ # Phase 6 + 7 (Checkliste + Nachweis)
|
||||
/ausschreibungen/<id>/nachbetrachtung/ # Phase 8 (Ergebnis + Lessons Learned)
|
||||
/ausschreibungen/<id>/marktbegleiter/ # Passagen dieser Ausschreibung
|
||||
/ausschreibungen/<id>/freigaben/ # Alle Freigaben dieser Ausschreibung
|
||||
|
||||
/bibliothek/dokumente/ # Standarddokumentbibliothek
|
||||
/bibliothek/nachweise/ # Compliance-/QM-Nachweise
|
||||
/bibliothek/nachweise/<id>/ # Nachweis-Detail
|
||||
/bibliothek/referenzen/ # Referenzdatenbank
|
||||
/bibliothek/referenzen/<id>/ # Referenz-Detail
|
||||
/bibliothek/leistungsblaetter/ # Leistungsblätter
|
||||
/bibliothek/entscheidungsregeln/ # Entscheidungsregelkatalog
|
||||
|
||||
/partner/subunternehmer/ # Subunternehmer-Katalog
|
||||
/partner/subunternehmer/<id>/ # Subunternehmer-Detail
|
||||
/partner/dienstleistertypen/ # Dienstleistertypen
|
||||
|
||||
/marktbegleiter/ # Marktbegleiter-Liste
|
||||
/marktbegleiter/<id>/ # Profil + Passagen
|
||||
|
||||
/feedback/ # Feedback-Backlog (Admin-Sicht)
|
||||
|
||||
/admin/felder/ # EntityFieldConfig-Verwaltung
|
||||
/admin/nutzer/ # Mitarbeiterverwaltung
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Tailwind Design System
|
||||
|
||||
### 8.1 Konfiguration (`tailwind.config.js`)
|
||||
|
||||
```js
|
||||
export default {
|
||||
content: ['./templates/**/*.html', './*/templates/**/*.html'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
50: '#f0f4ff',
|
||||
100: '#dce7ff',
|
||||
500: '#3b5bdb', // Primär
|
||||
600: '#2f4ac7',
|
||||
700: '#2541b2',
|
||||
900: '#152d99',
|
||||
},
|
||||
phase: {
|
||||
todo: '#94a3b8', // slate-400
|
||||
active: '#3b82f6', // blue-500
|
||||
done: '#22c55e', // green-500
|
||||
warning: '#f59e0b', // amber-500
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 8.2 Status-Badges
|
||||
|
||||
Alle Status-Badges werden via Template-Tag `{% status_badge value entity_type %}` gerendert. Die Farben:
|
||||
|
||||
| Kategorie | Farbe |
|
||||
|---|---|
|
||||
| offen / ausstehend | `bg-slate-100 text-slate-700` |
|
||||
| in Bearbeitung | `bg-blue-100 text-blue-700` |
|
||||
| erledigt / gewonnen / freigegeben | `bg-green-100 text-green-700` |
|
||||
| überfällig / nicht erfüllbar / verloren | `bg-red-100 text-red-700` |
|
||||
| wartend / weitere Prüfung | `bg-amber-100 text-amber-700` |
|
||||
| archiviert / ersetzt | `bg-gray-100 text-gray-500` |
|
||||
|
||||
### 8.3 Komponenten-Klassen (`@layer components`)
|
||||
|
||||
```css
|
||||
@layer components {
|
||||
.card { @apply bg-white rounded-xl border border-slate-200 shadow-sm p-6; }
|
||||
.btn-primary { @apply bg-brand-500 text-white px-4 py-2 rounded-lg hover:bg-brand-600 transition-colors; }
|
||||
.btn-secondary { @apply bg-white text-slate-700 border border-slate-300 px-4 py-2 rounded-lg hover:bg-slate-50; }
|
||||
.btn-danger { @apply bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700; }
|
||||
.btn-ghost { @apply text-slate-600 px-3 py-2 rounded-lg hover:bg-slate-100; }
|
||||
|
||||
.field-row { @apply grid grid-cols-3 gap-4 py-3 border-b border-slate-100 last:border-0; }
|
||||
.field-label { @apply text-sm font-medium text-slate-500 col-span-1; }
|
||||
.field-value { @apply text-sm text-slate-900 col-span-2; }
|
||||
|
||||
.phase-badge { @apply inline-flex items-center justify-center w-7 h-7 rounded-full text-sm font-bold; }
|
||||
.phase-todo { @apply phase-badge bg-slate-200 text-slate-500; }
|
||||
.phase-active { @apply phase-badge bg-brand-500 text-white; }
|
||||
.phase-done { @apply phase-badge bg-green-500 text-white; }
|
||||
.phase-warn { @apply phase-badge bg-amber-400 text-amber-900; }
|
||||
|
||||
.section-title { @apply text-base font-semibold text-slate-900 mb-4; }
|
||||
.page-title { @apply text-2xl font-bold text-slate-900; }
|
||||
|
||||
.form-input { @apply w-full rounded-lg border border-slate-300 px-3 py-2 text-sm
|
||||
focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent; }
|
||||
.form-label { @apply block text-sm font-medium text-slate-700 mb-1; }
|
||||
|
||||
.table-base { @apply w-full text-sm text-left; }
|
||||
.table-header { @apply bg-slate-50 text-slate-500 font-medium text-xs uppercase tracking-wide; }
|
||||
.table-row { @apply border-t border-slate-100 hover:bg-slate-50 transition-colors; }
|
||||
}
|
||||
```
|
||||
|
||||
### 8.4 Typographie
|
||||
|
||||
- Überschriften: `font-bold` + entsprechende `text-*` Größen (H1 = `text-2xl`, H2 = `text-xl`, H3 = `text-base`)
|
||||
- Beschreibungstexte: `text-sm text-slate-600`
|
||||
- Pflicht-Markierung: roter Stern `<span class="text-red-500">*</span>` via Template-Tag
|
||||
|
||||
---
|
||||
|
||||
## 9. HTMX-Interaktionsmuster
|
||||
|
||||
### 9.1 Inline-Bearbeitung
|
||||
|
||||
Detailfelder rendern als `<span>` mit einem Edit-Icon. Klick → HTMX GET `/…/edit/?field=titel` → Inline-Formular ersetzt den `<span>`. Speichern → HTMX POST → aktualisierter `<span>` wird zurückgegeben.
|
||||
|
||||
```html
|
||||
<div id="field-titel" hx-target="#field-titel" hx-swap="outerHTML">
|
||||
<span class="field-value">{{ obj.titel }}</span>
|
||||
<button hx-get="{% url 'ausschreibung-edit-field' obj.pk %}?field=titel"
|
||||
class="btn-ghost ml-2 opacity-0 group-hover:opacity-100">✎</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 9.2 Listen mit Lazy-Loading
|
||||
|
||||
Lange Listen (Aufgaben, Anforderungen, Preispunkte) laden initial nur die ersten 25 Einträge. Ein "Mehr laden"-Button am Ende nutzt `hx-get` + `hx-swap="beforeend"` für paginiertes Nachladen.
|
||||
|
||||
### 9.3 Status-Wechsel ohne Reload
|
||||
|
||||
Status-Dropdowns in Listenzeilen nutzen `hx-post` + `hx-target="closest tr"` um nur die Zeile neu zu rendern.
|
||||
|
||||
### 9.4 Custom-Attribute-Panel
|
||||
|
||||
Jede Detailseite endet mit:
|
||||
|
||||
```html
|
||||
<section id="custom-attrs"
|
||||
hx-get="{% url 'custom-attributes' content_type_id obj.pk %}"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
Lade...
|
||||
</section>
|
||||
```
|
||||
|
||||
Hinzufügen: Button öffnet Inline-Formular (HTMX). Speichern: Neues Attribut erscheint sofort in der Liste.
|
||||
|
||||
### 9.5 Feedback-Modal
|
||||
|
||||
```html
|
||||
<!-- Auf jeder Seite im base template -->
|
||||
<button id="feedback-trigger"
|
||||
hx-get="{% url 'feedback-modal' %}"
|
||||
hx-target="#modal-container"
|
||||
class="fixed bottom-6 right-6 btn-secondary shadow-lg rounded-full p-3">
|
||||
💬
|
||||
</button>
|
||||
<div id="modal-container"></div>
|
||||
```
|
||||
|
||||
Das Modal schreibt `seite={{ request.path }}` und `ausschreibung={{ current_ausschreibung.id|default:'' }}` als Hidden-Felder automatisch in den Feedbackeintrag.
|
||||
|
||||
---
|
||||
|
||||
## 10. Alpine.js UI-Zustände
|
||||
|
||||
Alpine.js (keine Server-Kommunikation) für:
|
||||
|
||||
- **Sidebar Collapse:** `x-data="{ open: true }" @click="open = !open"`
|
||||
- **Phasen-Accordion:** Aufklappen einzelner Phasen-Abschnitte im Phasen-Navigator
|
||||
- **Bestätigungsdialoge:** `x-show="confirm"` bei Löschen-Aktionen
|
||||
- **Dropdown-Menüs:** Kontextmenüs in Listentabellen
|
||||
- **Feld-Ausblenden im Formular:** Checkbox "Feld ausblenden" toggelt via Alpine das zugehörige Input sofort
|
||||
|
||||
---
|
||||
|
||||
## 11. Dokument-Storage
|
||||
|
||||
### Verzeichnisstruktur
|
||||
|
||||
```
|
||||
media/
|
||||
└── dokumente/
|
||||
└── <ausschreibung_id>/
|
||||
├── leistungsverzeichnis/
|
||||
├── vertragsunterlagen/
|
||||
├── preisblatt/
|
||||
└── ...
|
||||
```
|
||||
|
||||
### Upload-Validierung
|
||||
|
||||
Erlaubte MIME-Typen: `application/pdf`, `application/msword`, `application/vnd.openxmlformats-officedocument.*`, `application/vnd.ms-excel`, `application/zip`, `image/png`, `image/jpeg`.
|
||||
|
||||
Maximale Dateigröße: 50 MB (konfigurierbar via `settings.MAX_UPLOAD_SIZE`).
|
||||
|
||||
### Versionierung
|
||||
|
||||
Neue Version = neues `Dokument`-Objekt. Das alte erhält `status='ersetzt'`. Beide bleiben in der DB und im Dateisystem erhalten. Die Detailansicht zeigt eine Versionshistorie-Liste.
|
||||
|
||||
### Produktions-Abstraktion
|
||||
|
||||
`DEFAULT_FILE_STORAGE` in `settings.py` wechseln auf `storages.backends.s3boto3.S3Boto3Storage` — kein Code in den Apps muss geändert werden.
|
||||
|
||||
---
|
||||
|
||||
## 12. Phasen-Zustandsmaschine
|
||||
|
||||
Der Status der Ausschreibung ist ein Integer 1–13. Übergänge sind nicht erzwungen. Ein Utility gibt den "empfohlenen nächsten Status" zurück:
|
||||
|
||||
```python
|
||||
# ausschreibungen/services.py
|
||||
|
||||
NAECHSTER_STATUS = {
|
||||
1: 2, # Angelegt → In Erstprüfung
|
||||
2: 3, # Erstprüfung → Entscheidung
|
||||
3: 4, # Entschieden → Detailanalyse
|
||||
4: 5, # Detailanalyse → Bieterfragen
|
||||
5: 6, # Bieterfragen → Preisgestaltung
|
||||
6: 7, # Preisgestaltung → Unterlagenfinalisierung
|
||||
7: 8, # Finalisierung → Bereit zur Abgabe
|
||||
8: 9, # Bereit → Abgegeben
|
||||
9: 10, # Abgegeben → (manuell: 10 gewonnen / 11 verloren / 12 aufgehoben)
|
||||
}
|
||||
|
||||
ABSCHLUSS_STATUS = {10, 11, 12, 13}
|
||||
|
||||
def naechster_empfohlener_status(ausschreibung):
|
||||
return NAECHSTER_STATUS.get(ausschreibung.status)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. Sucharchitektur
|
||||
|
||||
Für v1: Django ORM `icontains`-Suche über Titel, Ausschreiber, Beschreibung. Globale Suchleiste gibt einen unified result set zurück:
|
||||
|
||||
```python
|
||||
# search/views.py
|
||||
|
||||
def global_search(request):
|
||||
q = request.GET.get('q', '')
|
||||
return {
|
||||
'ausschreibungen': Ausschreibung.objects.filter(titel__icontains=q)[:5],
|
||||
'aufgaben': Aufgabe.objects.filter(titel__icontains=q)[:5],
|
||||
'dokumente': Dokument.objects.filter(dateiname__icontains=q)[:5],
|
||||
'subunternehmer': Subunternehmer.objects.filter(name__icontains=q)[:5],
|
||||
'marktbegleiter': Marktbegleiter.objects.filter(name__icontains=q)[:5],
|
||||
}
|
||||
```
|
||||
|
||||
PostgreSQL-Volltext (`SearchVector`) kann später ohne URL-Änderung eingebaut werden.
|
||||
|
||||
---
|
||||
|
||||
## 14. Fristenüberwachung
|
||||
|
||||
`core/services.py` stellt `get_deadline_warnings(ausschreibung)` bereit. In v1 wird dies bei jedem Seitenaufruf der Ausschreibungsdetailseite berechnet (kein Background-Job erforderlich):
|
||||
|
||||
```python
|
||||
def get_deadline_warnings(ausschreibung):
|
||||
warnings = []
|
||||
heute = date.today()
|
||||
if ausschreibung.bieterfragen_bis:
|
||||
delta = (ausschreibung.bieterfragen_bis - heute).days
|
||||
if delta <= 3:
|
||||
warnings.append({'typ': 'bieterfragen', 'tage': delta})
|
||||
if ausschreibung.abgabe_bis:
|
||||
delta = (ausschreibung.abgabe_bis.date() - heute).days
|
||||
if delta <= 14:
|
||||
warnings.append({'typ': 'abgabe', 'tage': delta})
|
||||
for aufgabe in ausschreibung.aufgabe_set.filter(status__in=['offen', 'in_bearbeitung']):
|
||||
if aufgabe.frist and aufgabe.frist < heute:
|
||||
warnings.append({'typ': 'aufgabe', 'aufgabe': aufgabe})
|
||||
return warnings
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 15. Erweiterungspunkte (spätere Ausbaustufen)
|
||||
|
||||
| Bereich | Erweiterungsweg |
|
||||
|---|---|
|
||||
| Volltext-Dokumentenanalyse | Apache Tika / pdfplumber auf Upload-Signal; extrahierter Text in `Dokument.volltext` speichern |
|
||||
| KI-Assistenz | Claude API via Celery-Task; gibt strukturierten JSON-Vorschlag zurück; Nutzer bestätigt manuell |
|
||||
| E-Mail-Benachrichtigungen | Django Signals + Celery; Template pro Benachrichtigungstyp |
|
||||
| SharePoint-Integration | `bibliothek`-App kann externe Dokument-URLs speichern (bereits als `quelle`-Feld angelegt) |
|
||||
| Partnerportal | Separates Django-Projekt mit beschränkten API-Endpoints; Authentifizierung via Token |
|
||||
| Mehrsprachigkeit | Django i18n; alle Template-Strings in `{% trans %}` |
|
||||
| Mandantenfähigkeit | `Ausschreibung` bekommt FK auf `Mandant`; alle Querys filtern auf `request.user.mandant` |
|
||||
| SSO / SAML | `django-allauth` oder `python-saml`; kein Auth-Code in den Fachapps |
|
||||
1053
wiki/UseCaseCatalog.md
Normal file
1053
wiki/UseCaseCatalog.md
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue