Prototype implementation

This commit is contained in:
tegwick 2026-05-08 14:26:48 +02:00
parent 315143a6fc
commit 14b0bc6d01
160 changed files with 5731 additions and 42 deletions

View file

View 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']

View file

@ -0,0 +1,5 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
name = 'vergabe_teilnahme.apps.core'

View 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

View 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.'))

View 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')],
},
),
]

View 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

View 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),
}

View 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}

View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View 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')

View 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

View 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)