Prototype implementation
This commit is contained in:
parent
315143a6fc
commit
14b0bc6d01
160 changed files with 5731 additions and 42 deletions
0
vergabe_teilnahme/apps/core/__init__.py
Normal file
0
vergabe_teilnahme/apps/core/__init__.py
Normal file
22
vergabe_teilnahme/apps/core/admin.py
Normal file
22
vergabe_teilnahme/apps/core/admin.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
from django.contrib import admin
|
||||
|
||||
from .models import CustomAttribute, EntityFieldConfig, Freigabe
|
||||
|
||||
|
||||
@admin.register(EntityFieldConfig)
|
||||
class EntityFieldConfigAdmin(admin.ModelAdmin):
|
||||
list_display = ['entity_type', 'field_name', 'is_hidden', 'display_label', 'sort_order']
|
||||
list_filter = ['entity_type', 'is_hidden']
|
||||
search_fields = ['entity_type', 'field_name']
|
||||
|
||||
|
||||
@admin.register(CustomAttribute)
|
||||
class CustomAttributeAdmin(admin.ModelAdmin):
|
||||
list_display = ['label', 'key', 'data_type', 'value', 'content_type', 'object_id']
|
||||
list_filter = ['data_type', 'content_type']
|
||||
|
||||
|
||||
@admin.register(Freigabe)
|
||||
class FreigabeAdmin(admin.ModelAdmin):
|
||||
list_display = ['freigabe_typ', 'status', 'freigebende_person', 'timestamp']
|
||||
list_filter = ['freigabe_typ', 'status']
|
||||
5
vergabe_teilnahme/apps/core/apps.py
Normal file
5
vergabe_teilnahme/apps/core/apps.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
name = 'vergabe_teilnahme.apps.core'
|
||||
13
vergabe_teilnahme/apps/core/context_processors.py
Normal file
13
vergabe_teilnahme/apps/core/context_processors.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
def vergabe_context(request):
|
||||
context = {}
|
||||
ausschreibung_id = None
|
||||
if hasattr(request, 'resolver_match') and request.resolver_match:
|
||||
kwargs = request.resolver_match.kwargs
|
||||
ausschreibung_id = kwargs.get('ausschreibung_id') or kwargs.get('pk')
|
||||
if ausschreibung_id:
|
||||
try:
|
||||
from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung
|
||||
context['current_ausschreibung'] = Ausschreibung.objects.get(pk=ausschreibung_id)
|
||||
except (Ausschreibung.DoesNotExist, ValueError):
|
||||
pass
|
||||
return context
|
||||
0
vergabe_teilnahme/apps/core/management/__init__.py
Normal file
0
vergabe_teilnahme/apps/core/management/__init__.py
Normal file
239
vergabe_teilnahme/apps/core/management/commands/seed_dev.py
Normal file
239
vergabe_teilnahme/apps/core/management/commands/seed_dev.py
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
from datetime import date, timedelta
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Erstellt Entwicklungs-Seed-Daten (idempotent)'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from vergabe_teilnahme.apps.accounts.models import Mitarbeiter
|
||||
from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung
|
||||
from vergabe_teilnahme.apps.aufgaben.models import Aufgabe
|
||||
from vergabe_teilnahme.apps.bibliothek.models import Entscheidungsregel, Nachweis
|
||||
from vergabe_teilnahme.apps.lose.models import Anforderung, Los
|
||||
from vergabe_teilnahme.apps.marktbegleiter.models import Marktbegleiter
|
||||
from vergabe_teilnahme.apps.partner.models import Dienstleistertyp, Subunternehmer
|
||||
from vergabe_teilnahme.apps.preise.models import Preispunkt
|
||||
|
||||
self.stdout.write('Erstelle Seed-Daten...')
|
||||
|
||||
# 1. Mitarbeiter
|
||||
bm, _ = Mitarbeiter.objects.get_or_create(
|
||||
username='max.muster',
|
||||
defaults={
|
||||
'first_name': 'Max', 'last_name': 'Muster',
|
||||
'rolle': 'bid_manager', 'organisationseinheit': 'Vertrieb',
|
||||
'email': 'max.muster@example.com',
|
||||
}
|
||||
)
|
||||
bm.set_password('testpass123')
|
||||
bm.save(update_fields=['password'])
|
||||
|
||||
fach, _ = Mitarbeiter.objects.get_or_create(
|
||||
username='anna.fach',
|
||||
defaults={
|
||||
'first_name': 'Anna', 'last_name': 'Fach',
|
||||
'rolle': 'fachexperte', 'organisationseinheit': 'Technik',
|
||||
'email': 'anna.fach@example.com',
|
||||
}
|
||||
)
|
||||
fach.set_password('testpass123')
|
||||
fach.save(update_fields=['password'])
|
||||
|
||||
gf, _ = Mitarbeiter.objects.get_or_create(
|
||||
username='georg.chef',
|
||||
defaults={
|
||||
'first_name': 'Georg', 'last_name': 'Chef',
|
||||
'rolle': 'geschaeftsfuehrung', 'organisationseinheit': 'GF',
|
||||
'email': 'georg.chef@example.com', 'is_staff': True,
|
||||
}
|
||||
)
|
||||
gf.set_password('testpass123')
|
||||
gf.save(update_fields=['password'])
|
||||
|
||||
self.stdout.write(f' ✓ 3 Mitarbeiter')
|
||||
|
||||
# 2. Dienstleistertypen
|
||||
dt_it, _ = Dienstleistertyp.objects.get_or_create(
|
||||
name='IT-Dienstleister',
|
||||
defaults={'beschreibung': 'Software- und IT-Infrastruktur-Dienstleister'}
|
||||
)
|
||||
dt_recht, _ = Dienstleistertyp.objects.get_or_create(
|
||||
name='Rechtsberatung',
|
||||
defaults={'beschreibung': 'Anwaltskanzleien und Rechtsberatungsunternehmen'}
|
||||
)
|
||||
self.stdout.write(f' ✓ 2 Dienstleistertypen')
|
||||
|
||||
# 3. Subunternehmer
|
||||
sub, _ = Subunternehmer.objects.get_or_create(
|
||||
name='TechPartner GmbH',
|
||||
defaults={
|
||||
'dienstleistertyp': dt_it,
|
||||
'praeferenz': 'bevorzugt',
|
||||
'ort': 'München',
|
||||
'email': 'kontakt@techpartner.example.com',
|
||||
'leistungsprofil': 'Cloud-Infrastruktur, DevOps, Security',
|
||||
}
|
||||
)
|
||||
self.stdout.write(f' ✓ 1 Subunternehmer')
|
||||
|
||||
# 4. Nachweise
|
||||
nachweis_iso, _ = Nachweis.objects.get_or_create(
|
||||
titel='ISO 27001 Zertifikat',
|
||||
defaults={
|
||||
'kategorie': 'Zertifizierung',
|
||||
'version': '2.0',
|
||||
'gueltig_ab': date.today() - timedelta(days=365),
|
||||
'gueltig_bis': date.today() + timedelta(days=365),
|
||||
'eigentuemer': bm,
|
||||
'freigabestatus': 'intern_freigegeben',
|
||||
}
|
||||
)
|
||||
nachweis_dsgvo, _ = Nachweis.objects.get_or_create(
|
||||
titel='DSGVO-Datenschutzerklärung',
|
||||
defaults={
|
||||
'kategorie': 'Rechtlich',
|
||||
'version': '1.2',
|
||||
'gueltig_bis': date.today() + timedelta(days=730),
|
||||
'eigentuemer': bm,
|
||||
'freigabestatus': 'intern_freigegeben',
|
||||
}
|
||||
)
|
||||
self.stdout.write(f' ✓ 2 Nachweise')
|
||||
|
||||
# 5. Marktbegleiter
|
||||
mb, _ = Marktbegleiter.objects.get_or_create(
|
||||
name='Konkurrent AG',
|
||||
defaults={
|
||||
'kurzbeschreibung': 'Größter Wettbewerber im IT-Bereich',
|
||||
'relevante_branchen': 'IT, Behörden, Finanzwesen',
|
||||
'bekannte_staerken': 'Große Kundenbasis, günstige Preise',
|
||||
'bekannte_schwaechen': 'Langsamer Support, wenig Flexibilität',
|
||||
}
|
||||
)
|
||||
self.stdout.write(f' ✓ 1 Marktbegleiter')
|
||||
|
||||
# 6. Entscheidungsregeln
|
||||
Entscheidungsregel.objects.get_or_create(
|
||||
regelname='Muss-Anforderung nicht erfüllbar',
|
||||
defaults={
|
||||
'kategorie': 'ausschlusskriterium',
|
||||
'empfehlung': 'nicht_teilnehmen',
|
||||
'begruendung': 'Falls eine Muss-Anforderung nicht erfüllt werden kann.',
|
||||
'aktiv': True,
|
||||
}
|
||||
)
|
||||
Entscheidungsregel.objects.get_or_create(
|
||||
regelname='Abgabefrist zu kurz',
|
||||
defaults={
|
||||
'kategorie': 'frist',
|
||||
'empfehlung': 'pruefen',
|
||||
'begruendung': 'Weniger als 2 Wochen bis zur Abgabe.',
|
||||
'aktiv': True,
|
||||
}
|
||||
)
|
||||
Entscheidungsregel.objects.get_or_create(
|
||||
regelname='Fehlende Referenz',
|
||||
defaults={
|
||||
'kategorie': 'referenz',
|
||||
'empfehlung': 'nicht_teilnehmen',
|
||||
'begruendung': 'Keine passende Referenz für die geforderte Branche vorhanden.',
|
||||
'aktiv': True,
|
||||
}
|
||||
)
|
||||
self.stdout.write(f' ✓ 3 Entscheidungsregeln')
|
||||
|
||||
# 7. Ausschreibung mit Losen, Anforderungen, Aufgaben, Preispunkten
|
||||
ausschreibung, _ = Ausschreibung.objects.get_or_create(
|
||||
vergabenummer='DEV-2026-001',
|
||||
defaults={
|
||||
'titel': 'Digitalisierung Verwaltungsportal Phase 2',
|
||||
'ausschreiber': 'Beispiel-Behörde GmbH',
|
||||
'vergabeplattform': 'DTVP',
|
||||
'status': 4,
|
||||
'bieterfragen_bis': date.today() + timedelta(days=5),
|
||||
'abgabe_bis': None,
|
||||
'bid_manager': bm,
|
||||
'branche': 'Öffentliche Verwaltung',
|
||||
'leistungsbeschreibung': 'Entwicklung und Betrieb eines digitalen Verwaltungsportals.',
|
||||
}
|
||||
)
|
||||
|
||||
los1, _ = Los.objects.get_or_create(
|
||||
ausschreibung=ausschreibung, losnummer='1',
|
||||
defaults={'lostitel': 'Softwareentwicklung', 'zustaendiger': fach}
|
||||
)
|
||||
los2, _ = Los.objects.get_or_create(
|
||||
ausschreibung=ausschreibung, losnummer='2',
|
||||
defaults={'lostitel': 'Hosting & Betrieb', 'zustaendiger': bm}
|
||||
)
|
||||
|
||||
anf1, _ = Anforderung.objects.get_or_create(
|
||||
ausschreibung=ausschreibung,
|
||||
titel='ISO 27001 Zertifizierung nachweisen',
|
||||
defaults={
|
||||
'verbindlichkeit': 'muss', 'ausschlusskriterium': True,
|
||||
'kategorie': 'zertifizierung', 'los': los1,
|
||||
}
|
||||
)
|
||||
anf2, _ = Anforderung.objects.get_or_create(
|
||||
ausschreibung=ausschreibung,
|
||||
titel='Mindestens 3 Referenzprojekte in der öffentlichen Verwaltung',
|
||||
defaults={
|
||||
'verbindlichkeit': 'muss', 'kategorie': 'referenz', 'los': los1,
|
||||
}
|
||||
)
|
||||
anf3, _ = Anforderung.objects.get_or_create(
|
||||
ausschreibung=ausschreibung,
|
||||
titel='SLA 99,5% Verfügbarkeit',
|
||||
defaults={
|
||||
'verbindlichkeit': 'soll', 'kategorie': 'technisch', 'los': los2,
|
||||
}
|
||||
)
|
||||
|
||||
Aufgabe.objects.get_or_create(
|
||||
ausschreibung=ausschreibung,
|
||||
titel='ISO-Zertifikat aktualisieren und einreichen',
|
||||
defaults={
|
||||
'typ': 'dokument', 'prioritaet': 1,
|
||||
'verantwortlicher': bm, 'anforderung': anf1,
|
||||
}
|
||||
)
|
||||
Aufgabe.objects.get_or_create(
|
||||
ausschreibung=ausschreibung,
|
||||
titel='Referenzliste zusammenstellen',
|
||||
defaults={
|
||||
'typ': 'fachlich', 'prioritaet': 2,
|
||||
'verantwortlicher': fach, 'anforderung': anf2,
|
||||
}
|
||||
)
|
||||
|
||||
from decimal import Decimal
|
||||
Preispunkt.objects.get_or_create(
|
||||
ausschreibung=ausschreibung,
|
||||
konkrete_leistung='Entwicklung Frontend',
|
||||
defaults={
|
||||
'leistungstyp': 'Entwicklung',
|
||||
'menge': Decimal('1'),
|
||||
'mengeneinheit': 'Pauschal',
|
||||
'einzelpreis': Decimal('85000.00'),
|
||||
'los': los1,
|
||||
}
|
||||
)
|
||||
Preispunkt.objects.get_or_create(
|
||||
ausschreibung=ausschreibung,
|
||||
konkrete_leistung='Hosting p.a.',
|
||||
defaults={
|
||||
'leistungstyp': 'Betrieb',
|
||||
'menge': Decimal('1'),
|
||||
'mengeneinheit': 'Jahr',
|
||||
'einzelpreis': Decimal('24000.00'),
|
||||
'wiederkehrend': True,
|
||||
'los': los2,
|
||||
'vergleichsgewicht': Decimal('0.8'),
|
||||
}
|
||||
)
|
||||
|
||||
self.stdout.write(f' ✓ 1 Ausschreibung mit 2 Losen, 3 Anforderungen, 2 Aufgaben, 2 Preispunkten')
|
||||
self.stdout.write(self.style.SUCCESS('\nSeed-Daten erfolgreich erstellt.'))
|
||||
70
vergabe_teilnahme/apps/core/migrations/0001_initial.py
Normal file
70
vergabe_teilnahme/apps/core/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# Generated by Django 6.0.5 on 2026-05-08 10:32
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='EntityFieldConfig',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('entity_type', models.CharField(max_length=100)),
|
||||
('field_name', models.CharField(max_length=100)),
|
||||
('is_hidden', models.BooleanField(default=False)),
|
||||
('display_label', models.CharField(blank=True, max_length=200)),
|
||||
('sort_order', models.PositiveSmallIntegerField(default=0)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Feldkonfiguration',
|
||||
'verbose_name_plural': 'Feldkonfigurationen',
|
||||
'unique_together': {('entity_type', 'field_name')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Freigabe',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('object_id', models.PositiveIntegerField()),
|
||||
('freigabe_typ', models.CharField(choices=[('teilnahme', 'Teilnahme-Freigabe'), ('ablehnung', 'Ablehnungs-Freigabe'), ('recht', 'Rechtliche Freigabe'), ('preis', 'Preis-Freigabe'), ('abgabe', 'Abgabe-Freigabe'), ('standarddokument', 'Standarddokument-Freigabe'), ('referenz', 'Referenz-Freigabe')], max_length=30)),
|
||||
('status', models.CharField(choices=[('erteilt', 'Erteilt'), ('abgelehnt', 'Abgelehnt'), ('ausstehend', 'Ausstehend')], default='erteilt', max_length=20)),
|
||||
('kommentar', models.TextField(blank=True)),
|
||||
('timestamp', models.DateTimeField(auto_now_add=True)),
|
||||
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
|
||||
('freigebende_person', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='erteilte_freigaben', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Freigabe',
|
||||
'verbose_name_plural': 'Freigaben',
|
||||
'ordering': ['-timestamp'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CustomAttribute',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('object_id', models.PositiveIntegerField()),
|
||||
('key', models.CharField(max_length=100)),
|
||||
('label', models.CharField(max_length=200)),
|
||||
('value', models.TextField(blank=True)),
|
||||
('data_type', models.CharField(choices=[('text', 'Text'), ('number', 'Zahl'), ('date', 'Datum'), ('boolean', 'Ja/Nein'), ('url', 'URL'), ('email', 'E-Mail')], default='text', max_length=20)),
|
||||
('sort_order', models.PositiveSmallIntegerField(default=0)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['sort_order', 'created_at'],
|
||||
'indexes': [models.Index(fields=['content_type', 'object_id'], name='core_custom_content_b275a1_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
vergabe_teilnahme/apps/core/migrations/__init__.py
Normal file
0
vergabe_teilnahme/apps/core/migrations/__init__.py
Normal file
106
vergabe_teilnahme/apps/core/models.py
Normal file
106
vergabe_teilnahme/apps/core/models.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
from django.conf import settings
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import models
|
||||
|
||||
|
||||
class EntityFieldConfig(models.Model):
|
||||
entity_type = models.CharField(max_length=100)
|
||||
field_name = models.CharField(max_length=100)
|
||||
is_hidden = models.BooleanField(default=False)
|
||||
display_label = models.CharField(max_length=200, blank=True)
|
||||
sort_order = models.PositiveSmallIntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('entity_type', 'field_name')
|
||||
verbose_name = 'Feldkonfiguration'
|
||||
verbose_name_plural = 'Feldkonfigurationen'
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.entity_type}.{self.field_name}'
|
||||
|
||||
|
||||
class CustomAttribute(models.Model):
|
||||
DATA_TYPE_CHOICES = [
|
||||
('text', 'Text'),
|
||||
('number', 'Zahl'),
|
||||
('date', 'Datum'),
|
||||
('boolean', 'Ja/Nein'),
|
||||
('url', 'URL'),
|
||||
('email', 'E-Mail'),
|
||||
]
|
||||
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
||||
object_id = models.PositiveIntegerField()
|
||||
content_object = GenericForeignKey('content_type', 'object_id')
|
||||
key = models.CharField(max_length=100)
|
||||
label = models.CharField(max_length=200)
|
||||
value = models.TextField(blank=True)
|
||||
data_type = models.CharField(max_length=20, choices=DATA_TYPE_CHOICES, default='text')
|
||||
sort_order = models.PositiveSmallIntegerField(default=0)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['sort_order', 'created_at']
|
||||
indexes = [models.Index(fields=['content_type', 'object_id'])]
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.label}: {self.value}'
|
||||
|
||||
|
||||
class Freigabe(models.Model):
|
||||
TYP_CHOICES = [
|
||||
('teilnahme', 'Teilnahme-Freigabe'),
|
||||
('ablehnung', 'Ablehnungs-Freigabe'),
|
||||
('recht', 'Rechtliche Freigabe'),
|
||||
('preis', 'Preis-Freigabe'),
|
||||
('abgabe', 'Abgabe-Freigabe'),
|
||||
('standarddokument', 'Standarddokument-Freigabe'),
|
||||
('referenz', 'Referenz-Freigabe'),
|
||||
]
|
||||
STATUS_CHOICES = [
|
||||
('erteilt', 'Erteilt'),
|
||||
('abgelehnt', 'Abgelehnt'),
|
||||
('ausstehend', 'Ausstehend'),
|
||||
]
|
||||
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
||||
object_id = models.PositiveIntegerField()
|
||||
content_object = GenericForeignKey('content_type', 'object_id')
|
||||
freigabe_typ = models.CharField(max_length=30, choices=TYP_CHOICES)
|
||||
freigebende_person = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='erteilte_freigaben'
|
||||
)
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='erteilt')
|
||||
kommentar = models.TextField(blank=True)
|
||||
timestamp = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-timestamp']
|
||||
verbose_name = 'Freigabe'
|
||||
verbose_name_plural = 'Freigaben'
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.get_freigabe_typ_display()} — {self.status}'
|
||||
|
||||
|
||||
class FlexibleModel(models.Model):
|
||||
custom_attributes = GenericRelation(CustomAttribute)
|
||||
freigaben = GenericRelation(Freigabe)
|
||||
|
||||
def get_hidden_fields(self):
|
||||
model_name = self._meta.model_name
|
||||
return set(
|
||||
EntityFieldConfig.objects.filter(
|
||||
entity_type=model_name, is_hidden=True
|
||||
).values_list('field_name', flat=True)
|
||||
)
|
||||
|
||||
def get_visible_field_names(self):
|
||||
hidden = self.get_hidden_fields()
|
||||
return [
|
||||
f.name
|
||||
for f in self._meta.get_fields()
|
||||
if hasattr(f, 'column') and not f.name.startswith('_') and f.name not in hidden
|
||||
]
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
106
vergabe_teilnahme/apps/core/services.py
Normal file
106
vergabe_teilnahme/apps/core/services.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
PHASEN = [
|
||||
(1, 'Recherche & Unterlagen'),
|
||||
(2, 'Teilnahmeentscheidung'),
|
||||
(3, 'Detaillierte Durchsicht'),
|
||||
(4, 'Bieterfragen & Klärung'),
|
||||
(5, 'Preismodell'),
|
||||
(6, 'Unterlagen finalisieren'),
|
||||
(7, 'Abgabe'),
|
||||
(8, 'Zuschlag / Nachbetrachtung'),
|
||||
]
|
||||
|
||||
# Maps Ausschreibung.status integer to phase number
|
||||
STATUS_TO_PHASE = {
|
||||
1: 1, 2: 1, # Recherche
|
||||
3: 2, # Teilnahmeentscheidung
|
||||
4: 3, 5: 3, # Durchsicht
|
||||
6: 4, 7: 4, # Bieterfragen
|
||||
8: 5, # Preise
|
||||
9: 6, # Finalisierung
|
||||
10: 7, 11: 7, # Abgabe
|
||||
12: 8, 13: 8, # Nachbetrachtung
|
||||
}
|
||||
|
||||
|
||||
def build_phase_nav(ausschreibung, current_url=''):
|
||||
aktuelle_phase = STATUS_TO_PHASE.get(ausschreibung.status, 1)
|
||||
base = f'/ausschreibungen/{ausschreibung.pk}'
|
||||
phase_urls = {
|
||||
1: f'{base}/',
|
||||
2: f'{base}/teilnahmeentscheidung/',
|
||||
3: f'{base}/anforderungen/',
|
||||
4: f'{base}/bieterfragen/',
|
||||
5: f'{base}/preise/',
|
||||
6: f'{base}/dokumente/',
|
||||
7: f'{base}/abgabe/',
|
||||
8: f'{base}/nachbetrachtung/',
|
||||
}
|
||||
return [
|
||||
{
|
||||
'nummer': num,
|
||||
'name': name,
|
||||
'url': phase_urls[num],
|
||||
'aktiv': num == aktuelle_phase,
|
||||
'erledigt': num < aktuelle_phase,
|
||||
'warnung': False,
|
||||
}
|
||||
for num, name in PHASEN
|
||||
]
|
||||
|
||||
|
||||
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:
|
||||
abgabe_date = (
|
||||
ausschreibung.abgabe_bis.date()
|
||||
if hasattr(ausschreibung.abgabe_bis, 'date')
|
||||
else ausschreibung.abgabe_bis
|
||||
)
|
||||
delta = (abgabe_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.
|
||||
|
||||
Punkte mit Gewicht 0,0 werden ausgeschlossen.
|
||||
Gibt None zurück wenn keine verwertbaren Punkte vorhanden.
|
||||
"""
|
||||
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),
|
||||
}
|
||||
0
vergabe_teilnahme/apps/core/templatetags/__init__.py
Normal file
0
vergabe_teilnahme/apps/core/templatetags/__init__.py
Normal file
71
vergabe_teilnahme/apps/core/templatetags/vergabe_tags.py
Normal file
71
vergabe_teilnahme/apps/core/templatetags/vergabe_tags.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
from django import template
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
register = template.Library()
|
||||
|
||||
STATUS_COLORS = {
|
||||
'offen': 'bg-slate-100 text-slate-700',
|
||||
'in_bearbeitung': 'bg-blue-100 text-blue-700',
|
||||
'erledigt': 'bg-green-100 text-green-700',
|
||||
'freigegeben': 'bg-green-100 text-green-700',
|
||||
'erteilt': 'bg-green-100 text-green-700',
|
||||
'gewonnen': 'bg-green-100 text-green-700',
|
||||
'intern_freigegeben': 'bg-green-100 text-green-700',
|
||||
'ueberfaellig': 'bg-red-100 text-red-700',
|
||||
'nicht_erfuellbar': 'bg-red-100 text-red-700',
|
||||
'verloren': 'bg-red-100 text-red-700',
|
||||
'abgelehnt': 'bg-red-100 text-red-700',
|
||||
'ausstehend': 'bg-amber-100 text-amber-700',
|
||||
'in_pruefung': 'bg-amber-100 text-amber-700',
|
||||
'wartend_intern': 'bg-amber-100 text-amber-700',
|
||||
'wartend_sub': 'bg-amber-100 text-amber-700',
|
||||
'wartend_ausschreiber': 'bg-amber-100 text-amber-700',
|
||||
'archiviert': 'bg-gray-100 text-gray-500',
|
||||
'ersetzt': 'bg-gray-100 text-gray-500',
|
||||
'verworfen': 'bg-gray-100 text-gray-500',
|
||||
}
|
||||
|
||||
|
||||
@register.inclusion_tag('partials/status_badge.html')
|
||||
def status_badge(value, display_label=None):
|
||||
css = STATUS_COLORS.get(str(value), 'bg-slate-100 text-slate-700')
|
||||
label = display_label or str(value).replace('_', ' ').capitalize()
|
||||
return {'css': css, 'label': label}
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def phase_badge(nummer, zustand='todo'):
|
||||
css_map = {
|
||||
'todo': 'phase-todo',
|
||||
'active': 'phase-active',
|
||||
'done': 'phase-done',
|
||||
'warn': 'phase-warn',
|
||||
}
|
||||
css = css_map.get(zustand, 'phase-todo')
|
||||
return mark_safe(f'<span class="{css}">{nummer}</span>')
|
||||
|
||||
|
||||
def _is_field_hidden(entity_type, field_name):
|
||||
from vergabe_teilnahme.apps.core.models import EntityFieldConfig
|
||||
return EntityFieldConfig.objects.filter(
|
||||
entity_type=entity_type, field_name=field_name, is_hidden=True
|
||||
).exists()
|
||||
|
||||
|
||||
def _get_field_label(entity_type, field_name, default_label):
|
||||
from vergabe_teilnahme.apps.core.models import EntityFieldConfig
|
||||
try:
|
||||
cfg = EntityFieldConfig.objects.get(entity_type=entity_type, field_name=field_name)
|
||||
return cfg.display_label or default_label
|
||||
except EntityFieldConfig.DoesNotExist:
|
||||
return default_label
|
||||
|
||||
|
||||
@register.inclusion_tag('partials/field_row.html')
|
||||
def render_field(obj, field_name, label=None, force_show=False):
|
||||
entity_type = obj._meta.model_name
|
||||
if not force_show and _is_field_hidden(entity_type, field_name):
|
||||
return {'hidden': True}
|
||||
value = getattr(obj, field_name, None)
|
||||
display_label = _get_field_label(entity_type, field_name, label or field_name)
|
||||
return {'hidden': False, 'label': display_label, 'value': value, 'field_name': field_name}
|
||||
3
vergabe_teilnahme/apps/core/tests.py
Normal file
3
vergabe_teilnahme/apps/core/tests.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
0
vergabe_teilnahme/apps/core/tests/__init__.py
Normal file
0
vergabe_teilnahme/apps/core/tests/__init__.py
Normal file
41
vergabe_teilnahme/apps/core/tests/test_services.py
Normal file
41
vergabe_teilnahme/apps/core/tests/test_services.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from vergabe_teilnahme.apps.core.services import gewichteter_durchschnitt
|
||||
|
||||
|
||||
class FakePreispunkt:
|
||||
def __init__(self, einzelpreis, vergleichsgewicht):
|
||||
self.einzelpreis = Decimal(str(einzelpreis)) if einzelpreis is not None else None
|
||||
self.vergleichsgewicht = Decimal(str(vergleichsgewicht))
|
||||
|
||||
|
||||
class TestGewichteterDurchschnitt:
|
||||
def test_blueprint_beispiel(self):
|
||||
# 100×1,0 + 80×0,2 + 110×1,2 = 100+16+132=248 / (1,0+0,2+1,2)=2,4 = 103,333...
|
||||
punkte = [
|
||||
FakePreispunkt(100, '1.0'),
|
||||
FakePreispunkt(80, '0.2'),
|
||||
FakePreispunkt(110, '1.2'),
|
||||
]
|
||||
result = gewichteter_durchschnitt(punkte)
|
||||
assert result is not None
|
||||
assert abs(result['wert'] - Decimal('103.333')) < Decimal('0.001')
|
||||
assert result['anzahl'] == 3
|
||||
assert result['minimum'] == Decimal('80')
|
||||
assert result['maximum'] == Decimal('110')
|
||||
|
||||
def test_leerer_input(self):
|
||||
assert gewichteter_durchschnitt([]) is None
|
||||
|
||||
def test_alle_gewichte_null(self):
|
||||
punkte = [FakePreispunkt(100, '0.0'), FakePreispunkt(200, '0.0')]
|
||||
assert gewichteter_durchschnitt(punkte) is None
|
||||
|
||||
def test_einzelpreis_none_wird_ausgeschlossen(self):
|
||||
punkte = [FakePreispunkt(None, '1.0'), FakePreispunkt(100, '1.0')]
|
||||
result = gewichteter_durchschnitt(punkte)
|
||||
assert result is not None
|
||||
assert result['anzahl'] == 1
|
||||
assert result['wert'] == Decimal('100')
|
||||
14
vergabe_teilnahme/apps/core/view_helpers.py
Normal file
14
vergabe_teilnahme/apps/core/view_helpers.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
def make_breadcrumbs(*args):
|
||||
"""Build a breadcrumbs list from alternating label/url pairs or (label, url) tuples.
|
||||
|
||||
Usage:
|
||||
make_breadcrumbs(('Ausschreibungen', '/ausschreibungen/'), ('Mein Tender', None))
|
||||
"""
|
||||
crumbs = []
|
||||
for item in args:
|
||||
if isinstance(item, (list, tuple)) and len(item) == 2:
|
||||
label, url = item
|
||||
else:
|
||||
label, url = item, None
|
||||
crumbs.append({'label': label, 'url': url})
|
||||
return crumbs
|
||||
29
vergabe_teilnahme/apps/core/views.py
Normal file
29
vergabe_teilnahme/apps/core/views.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
from django.http import HttpResponse
|
||||
from django.shortcuts import render
|
||||
from django.views.decorators.http import require_GET
|
||||
|
||||
|
||||
def custom_404(request, exception=None):
|
||||
return render(request, 'errors/404.html', status=404)
|
||||
|
||||
|
||||
def custom_500(request):
|
||||
return render(request, 'errors/500.html', status=500)
|
||||
|
||||
|
||||
@require_GET
|
||||
def suche(request):
|
||||
q = request.GET.get('q', '').strip()
|
||||
if not q or len(q) < 2:
|
||||
return HttpResponse('')
|
||||
from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung
|
||||
ausschreibungen = Ausschreibung.objects.filter(titel__icontains=q)[:5]
|
||||
if not ausschreibungen:
|
||||
return HttpResponse('')
|
||||
items = ''.join(
|
||||
f'<a href="/ausschreibungen/{a.pk}/" '
|
||||
f'class="block px-4 py-2 text-sm text-slate-700 hover:bg-slate-50">'
|
||||
f'{a.titel}</a>'
|
||||
for a in ausschreibungen
|
||||
)
|
||||
return HttpResponse(items)
|
||||
Loading…
Add table
Add a link
Reference in a new issue