feat: insurance section — overview, documents, analysis, KVG premium comparison

- Insurance overview page (/insurance): current policies table with type,
  provider, premium, franchise, coverage, and document links
- Documents page: upload and manage insurance documents
- Analysis page: coverage gap analysis per insurance type
- Priminfo integration (/insurance/priminfo): KVG premium comparison by
  insurer, model (TAR/HMO/etc.), franchise level, and accident coverage
  via embedded Priminfo iframe (no public API available)
- Backend: Insurance, PraemienEntry, PraemienPolice models with migrations
- Sidebar: insurance nav group with flyout and dropdown
- i18n: all keys in DE/EN/FR/IT
This commit is contained in:
Daniel Krähenbühl
2026-05-25 22:05:37 +02:00
parent 1a7ef09805
commit c03d2a97ab
26 changed files with 2456 additions and 44 deletions
+168 -1
View File
@@ -21,10 +21,11 @@ from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.throttling import AnonRateThrottle
from rest_framework_simplejwt.tokens import RefreshToken
from rest_framework_simplejwt.exceptions import TokenError
from .models import Account, Transaction, Budget, Expense, Profile, Deadline, ReadEvent, BackupCode, UserSession
from .models import Account, Transaction, Budget, Expense, Profile, Deadline, ReadEvent, BackupCode, UserSession, Insurance, PraemienEntry, PraemienPolice
from .serializers import (
AccountSerializer, TransactionSerializer, BudgetSerializer,
ExpenseSerializer, ProfileSerializer, DeadlineSerializer, RegisterSerializer,
InsuranceSerializer,
)
@@ -111,6 +112,172 @@ class DeadlineViewSet(viewsets.ModelViewSet):
serializer.save(user=self.request.user)
class InsuranceViewSet(viewsets.ModelViewSet):
serializer_class = InsuranceSerializer
def get_queryset(self):
return Insurance.objects.filter(user=self.request.user)
def perform_create(self, serializer):
serializer.save(user=self.request.user)
INSURER_NAMES: dict[int, str] = {
8: 'Helsana AG',
32: 'KPT/CPT',
134: 'CSS Versicherung AG',
194: 'Concordia',
246: 'Groupe Mutuel',
290: 'Sanitas Krankenversicherung',
312: 'SWICA Krankenversicherung',
343: 'Visana AG',
360: 'Atupri Krankenkasse',
376: 'Kolping Krankenkasse',
455: 'EGK-Gesundheitskasse',
509: 'Galenos AG',
780: 'Luzerner Hinterland Krankenkasse (LHK)',
820: 'Krankenkasse Steffisburg',
881: 'sodalis gesundheitsgruppe',
923: 'Vivao Sympany AG',
941: 'Birchmeier Krankenkasse',
966: 'Krankenkasse Wädenswil',
1040: 'ÖKK',
1113: 'Agrisano Krankenkasse',
1318: 'Mutuel Assurance',
1322: 'Provita Gesundheitsversicherung AG',
1384: 'Sanagate AG',
1386: 'Aquilana Versicherungen',
1401: 'Easy Sana Assurance Maladie SA',
1479: 'Caisse-maladie Philos',
1507: 'Scheidegg Krankenkasse',
1509: 'Sana24 AG',
1535: 'rhenusana',
1542: 'Caisse-maladie de la Vallée SA',
1555: 'KLuG Krankenkasse',
1560: 'Krankenkasse Institut Ingenbohl',
1562: 'Sumiswalder Krankenkasse',
1568: 'avanto health AG',
}
def _age_class(geburtsjahr: int) -> str:
from datetime import date
age = date.today().year - geburtsjahr
if age <= 18:
return 'AKL-KIN'
if age <= 25:
return 'AKL-JUG'
return 'AKL-ERW'
class PraemienView(views.APIView):
"""
GET /api/praemien/?plz=8001
→ Ø-Prämien aus D_PRIM (alle Versicherer, alle Modelle), 3 Alterskategorien.
GET /api/praemien/vergleich/?plz=8001&geburtsjahr=1990&tariftyp=TAR-BASE&franchisestufe=FRAST1&unfall=OHN-UNF
→ Granularer Prämienvergleich pro Versicherer aus Prämien_CH.csv.
"""
permission_classes = [IsAuthenticated]
def get(self, request):
plz = request.query_params.get('plz', '').strip().zfill(4)
if not plz or not plz.isdigit() or len(plz) != 4:
return Response({'error': 'Invalid PLZ'}, status=status.HTTP_400_BAD_REQUEST)
latest_year = (PraemienEntry.objects
.values_list('data_year', flat=True)
.order_by('-data_year').first())
if not latest_year:
return Response({'error': 'No data imported yet.'}, status=status.HTTP_404_NOT_FOUND)
entries = list(PraemienEntry.objects.filter(plz=plz, data_year=latest_year).values(
'plz', 'ort', 'kanton', 'region', 'gemeinde', 'bezirk',
'avg_adult', 'avg_young_adult', 'avg_child', 'data_year',
))
if not entries:
return Response({'error': f'No data for PLZ {plz}'}, status=status.HTTP_404_NOT_FOUND)
return Response({'data_year': latest_year, 'results': entries})
class PraemienVergleichView(views.APIView):
"""
GET /api/praemien/vergleich/?plz=8001&geburtsjahr=1990&tariftyp=TAR-BASE&franchisestufe=FRAST1&unfall=OHN-UNF
Returns all available insurers for the given filters, sorted by premium ascending.
"""
permission_classes = [IsAuthenticated]
def get(self, request):
plz = request.query_params.get('plz', '').strip().zfill(4)
geburtsjahr_raw = request.query_params.get('geburtsjahr', '').strip()
tariftyp = request.query_params.get('tariftyp', 'TAR-BASE').strip()
franchisestufe = request.query_params.get('franchisestufe', 'FRAST1').strip()
unfall = request.query_params.get('unfall', 'OHN-UNF').strip()
if not plz or not plz.isdigit() or len(plz) != 4:
return Response({'error': 'Invalid PLZ'}, status=status.HTTP_400_BAD_REQUEST)
if not geburtsjahr_raw.isdigit():
return Response({'error': 'Invalid geburtsjahr'}, status=status.HTTP_400_BAD_REQUEST)
geburtsjahr = int(geburtsjahr_raw)
altersklasse = _age_class(geburtsjahr)
# Resolve PLZ → Kanton + Region
entry = (PraemienEntry.objects
.filter(plz=plz)
.order_by('-data_year')
.values('kanton', 'region', 'ort', 'gemeinde', 'data_year')
.first())
if not entry:
return Response({'error': f'PLZ {plz} not found'}, status=status.HTTP_404_NOT_FOUND)
kanton = entry['kanton']
region = entry['region']
# Latest policen year
policen_year = (PraemienPolice.objects
.values_list('data_year', flat=True)
.order_by('-data_year').first())
if not policen_year:
return Response({'error': 'No policen data imported yet.'}, status=status.HTTP_404_NOT_FOUND)
policen = list(PraemienPolice.objects.filter(
kanton=kanton,
region=region,
altersklasse=altersklasse,
unfalleinschluss=unfall,
tariftyp=tariftyp,
franchisestufe=franchisestufe,
data_year=policen_year,
).values('versicherer_id', 'tarifbezeichnung', 'franchise_chf', 'praemie')
.order_by('praemie'))
results = []
for p in policen:
vid = p['versicherer_id']
results.append({
'versicherer_id': vid,
'versicherer_name': INSURER_NAMES.get(vid, f'Versicherer {vid}'),
'tarifbezeichnung': p['tarifbezeichnung'],
'franchise_chf': p['franchise_chf'],
'praemie': str(p['praemie']),
})
return Response({
'kanton': kanton,
'region': region,
'ort': entry['ort'],
'gemeinde': entry['gemeinde'],
'altersklasse': altersklasse,
'tariftyp': tariftyp,
'franchisestufe': franchisestufe,
'unfall': unfall,
'data_year': policen_year,
'results': results,
})
class ProfileView(views.APIView):
def get(self, request):
profile, _ = Profile.objects.get_or_create(user=request.user)