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:
+168
-1
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user