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
@@ -0,0 +1,309 @@
"""
Management command: import_praemien
Imports Swiss KVG premium data from two BAG sources:
1. praemienregionen_{year}.xlsx (Priminfo)
PLZ → BFS-Nr, Gemeinde, Kanton, Prämienregion (0-3) + Ø-Monatsrämien
→ PraemienEntry model
2. Prämien_CH.csv (opendata.bagnet.ch / opendata.swiss)
Full per-insurer, per-model, per-franchise granular premiums
→ PraemienPolice model
Usage:
python manage.py import_praemien # import both, latest year
python manage.py import_praemien --year 2025
python manage.py import_praemien --skip-policen # only PLZ/region data
python manage.py import_praemien --skip-regionen # only granular policen data
"""
import csv
import io
import urllib.request
import zipfile
import xml.etree.ElementTree as ET
from decimal import Decimal, InvalidOperation
from django.core.management.base import BaseCommand, CommandError
from finance.models import PraemienEntry, PraemienPolice
NS = {'x': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
REGIONEN_URL = 'https://www.priminfo.admin.ch/downloads/praemienregionen_{year}.xlsx'
POLICEN_URL = 'https://opendata.bagnet.ch/?r=/download&path=L1ByYWVtaWVuL1Byw6RtaWVuX0NILmNzdg%3D%3D'
LATEST_YEAR = 2025 # praemienregionen: update each September when BAG publishes new file
POLICEN_YEAR = 2026 # Prämien_CH.csv always contains the upcoming business year
# BAG insurer ID → display name (stable, regulated by BAG)
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',
}
# Franchise code → CHF value
FRANCHISE_CHF: dict[str, int] = {
'FRA-0': 0,
'FRA-100': 100,
'FRA-200': 200,
'FRA-300': 300,
'FRA-400': 400,
'FRA-500': 500,
'FRA-600': 600,
'FRA-1000': 1000,
'FRA-1500': 1500,
'FRA-2000': 2000,
'FRA-2500': 2500,
}
# ─────────────────────────────────────────────────────────
# XLSX helpers (for praemienregionen)
# ─────────────────────────────────────────────────────────
def _cell_value(cell):
is_el = cell.find('x:is/x:t', NS)
if is_el is not None:
return (is_el.text or '').strip().replace('\n', ' ')
v_el = cell.find('x:v', NS)
if v_el is not None and v_el.text:
return v_el.text.strip()
return None
def _parse_rows(ws):
for row in ws.findall('.//x:row', NS):
yield [_cell_value(c) for c in row.findall('x:c', NS)]
def _parse_regionen_xlsx(data: bytes, year: int) -> list[dict]:
zf = zipfile.ZipFile(io.BytesIO(data))
rels_xml = ET.fromstring(zf.read('xl/_rels/workbook.xml.rels'))
wb_xml = ET.fromstring(zf.read('xl/workbook.xml'))
wb_ns = {'x': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
rid_to_path = {
r.get('Id'): r.get('Target').lstrip('/')
for r in rels_xml if 'worksheet' in r.get('Type', '')
}
rid_to_name = {
s.get('{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id'): s.get('name')
for s in wb_xml.findall('.//x:sheet', wb_ns)
}
name_to_path = {rid_to_name[rid]: path for rid, path in rid_to_path.items() if rid in rid_to_name}
# D_PRIM: BFS-Nr → avg premiums
ws_dprim = ET.fromstring(zf.read(name_to_path['D_PRIM']))
premiums = {}
header_found = False
for row_vals in _parse_rows(ws_dprim):
if not header_found:
flat = ' '.join(str(v) for v in row_vals if v)
if 'BFS-Nr' in flat or 'No OFS' in flat:
header_found = True
continue
if not row_vals or not row_vals[0]:
continue
try:
bfs_nr = int(row_vals[0])
region = int(row_vals[3]) if row_vals[3] is not None else 0
avg_child = Decimal(str(row_vals[4]).replace("'", '')) if row_vals[4] else Decimal('0')
avg_young = Decimal(str(row_vals[5]).replace("'", '')) if row_vals[5] else Decimal('0')
avg_adult = Decimal(str(row_vals[6]).replace("'", '')) if row_vals[6] else Decimal('0')
premiums[bfs_nr] = (region, avg_child, avg_young, avg_adult)
except (ValueError, InvalidOperation, IndexError):
continue
# B_NPA: PLZ → BFS-Nr (PLZ at index 1, flag column at index 0)
ws_bnpa = ET.fromstring(zf.read(name_to_path['B_NPA']))
entries = []
header_found = False
for row_vals in _parse_rows(ws_bnpa):
if not header_found:
flat = ' '.join(str(v) for v in row_vals if v)
if 'PLZ' in flat and 'BFS' in flat:
header_found = True
continue
if len(row_vals) < 6:
continue
plz_raw = str(row_vals[1] or '').replace("'", '').strip()
if not plz_raw.isdigit():
continue
try:
plz = plz_raw.zfill(4)
ort = str(row_vals[2] or '').replace("'", '').strip()
kanton = str(row_vals[3] or '').replace("'", '').strip()
bfs_nr_raw = row_vals[5]
if bfs_nr_raw is None:
continue
bfs_nr = int(str(bfs_nr_raw).replace("'", '').strip())
gemeinde = str(row_vals[6] or '').replace("'", '').strip() if len(row_vals) > 6 else ''
bezirk = str(row_vals[7] or '').replace("'", '').strip() if len(row_vals) > 7 else ''
if bfs_nr not in premiums:
continue
region, avg_child, avg_young, avg_adult = premiums[bfs_nr]
entries.append({
'plz': plz, 'ort': ort, 'kanton': kanton, 'region': region,
'bfs_nr': bfs_nr, 'gemeinde': gemeinde, 'bezirk': bezirk,
'avg_adult': avg_adult, 'avg_young_adult': avg_young,
'avg_child': avg_child, 'data_year': year,
})
except (ValueError, InvalidOperation, IndexError):
continue
return entries
# ─────────────────────────────────────────────────────────
# CSV helpers (for Prämien_CH.csv)
# ─────────────────────────────────────────────────────────
def _parse_policen_csv(data: bytes, data_year: int) -> list[dict]:
"""Parse Prämien_CH.csv → list of PraemienPolice dicts."""
text = data.decode('utf-8-sig')
reader = csv.DictReader(io.StringIO(text))
entries = []
for row in reader:
try:
versicherer_id = int(row['Versicherer'])
kanton = row['Kanton'].strip()
if kanton not in ('AG','AI','AR','BE','BL','BS','FR','GE','GL','GR',
'JU','LU','NE','NW','OW','SG','SH','SO','SZ','TG',
'TI','UR','VD','VS','ZG','ZH'):
continue # skip EU/EFTA rows
region_code = row['Region'].strip() # PR-REG CH0 … CH3
try:
region = int(region_code.split('CH')[1])
except (IndexError, ValueError):
continue
altersklasse = row['Altersklasse'].strip()
unfalleinschluss = row['Unfalleinschluss'].strip()
tariftyp = row['Tariftyp'].strip()
tarifbezeichnung = row['Tarifbezeichnung'].strip()
franchisestufe = row['Franchisestufe'].strip()
franchise_code = row['Franchise'].strip()
franchise_chf = FRANCHISE_CHF.get(franchise_code, 0)
praemie = Decimal(row['Prämie'].strip())
entries.append({
'versicherer_id': versicherer_id,
'kanton': kanton,
'region': region,
'altersklasse': altersklasse,
'unfalleinschluss': unfalleinschluss,
'tariftyp': tariftyp,
'tarifbezeichnung': tarifbezeichnung,
'franchisestufe': franchisestufe,
'franchise_chf': franchise_chf,
'praemie': praemie,
'data_year': data_year,
})
except (ValueError, InvalidOperation, KeyError):
continue
return entries
class Command(BaseCommand):
help = 'Import Swiss KVG premium data from BAG/Priminfo (PLZ regions + granular policen)'
def add_arguments(self, parser):
parser.add_argument('--year', type=int, default=LATEST_YEAR,
help='Year for praemienregionen XLSX (default: %(default)s)')
parser.add_argument('--policen-year', type=int, default=POLICEN_YEAR,
help='Business year in Prämien_CH.csv (default: %(default)s)')
parser.add_argument('--skip-regionen', action='store_true',
help='Skip PLZ/region import (praemienregionen XLSX)')
parser.add_argument('--skip-policen', action='store_true',
help='Skip granular policen import (Prämien_CH.csv)')
def handle(self, *args, **options):
year = options['year']
policen_year = options['policen_year']
# ── 1. PLZ / Prämienregionen ──────────────────────────────────────
if not options['skip_regionen']:
url = REGIONEN_URL.format(year=year)
self.stdout.write(f'[1/2] Downloading praemienregionen {year}: {url}')
try:
with urllib.request.urlopen(url, timeout=30) as resp:
data = resp.read()
except Exception as e:
raise CommandError(f'Download failed: {e}')
self.stdout.write(f' Parsing XLSX ({len(data):,} bytes)…')
entries = _parse_regionen_xlsx(data, year)
self.stdout.write(f' Parsed {len(entries):,} PLZ entries.')
if not entries:
raise CommandError('No PLZ data parsed — check XLSX structure.')
deleted, _ = PraemienEntry.objects.filter(data_year=year).delete()
self.stdout.write(f' Cleared {deleted} old entries.')
objs = [PraemienEntry(**e) for e in entries]
for i in range(0, len(objs), 1000):
PraemienEntry.objects.bulk_create(objs[i:i+1000], ignore_conflicts=True)
self.stdout.write(self.style.SUCCESS(f'{len(objs):,} PLZ entries imported.'))
# ── 2. Granular Prämien_CH.csv ────────────────────────────────────
if not options['skip_policen']:
self.stdout.write(f'[2/2] Downloading Prämien_CH.csv (business year {policen_year})…')
try:
with urllib.request.urlopen(POLICEN_URL, timeout=120) as resp:
data = resp.read()
except Exception as e:
raise CommandError(f'Download failed: {e}')
self.stdout.write(f' Parsing CSV ({len(data):,} bytes)…')
entries = _parse_policen_csv(data, policen_year)
self.stdout.write(f' Parsed {len(entries):,} policen rows.')
if not entries:
raise CommandError('No policen data parsed — check CSV structure.')
deleted, _ = PraemienPolice.objects.filter(data_year=policen_year).delete()
self.stdout.write(f' Cleared {deleted} old entries.')
objs = [PraemienPolice(**e) for e in entries]
created = 0
for i in range(0, len(objs), 2000):
PraemienPolice.objects.bulk_create(objs[i:i+2000], ignore_conflicts=True)
created += min(2000, len(objs) - i)
self.stdout.write(f' {created:,} / {len(objs):,}', ending='\r')
self.stdout.write('')
self.stdout.write(self.style.SUCCESS(f'{len(objs):,} policen rows imported.'))
self.stdout.write(self.style.SUCCESS('Done.'))
@@ -0,0 +1,37 @@
# Generated by Django 6.0.4 on 2026-05-24 10:31
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0020_email_verify_token_expiry'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Insurance',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('insurance_type', models.CharField(choices=[('kvg', 'Krankenkasse Grundversicherung (KVG)'), ('kk_zusatz', 'KK-Zusatzversicherung'), ('nbu', 'Nicht-Berufsunfallversicherung (NBU)'), ('haftpflicht', 'Privathaftpflicht'), ('hausrat', 'Hausrat'), ('mfz', 'MFZ-Haftpflicht'), ('rechtsschutz', 'Rechtsschutz'), ('saule_3a', 'Säule 3a'), ('leben', 'Lebensversicherung'), ('reise', 'Reiseversicherung'), ('other', 'Sonstiges')], max_length=30)),
('insurer', models.CharField(max_length=200)),
('policy_number', models.CharField(blank=True, default='', max_length=100)),
('premium', models.DecimalField(decimal_places=2, max_digits=10)),
('premium_period', models.CharField(choices=[('monthly', 'Monatlich'), ('quarterly', 'Vierteljährlich'), ('semi_annual', 'Halbjährlich'), ('annual', 'Jährlich')], default='monthly', max_length=20)),
('coverage_amount', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
('deductible', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
('valid_from', models.DateField(blank=True, null=True)),
('valid_until', models.DateField(blank=True, null=True)),
('notes', models.TextField(blank=True, default='')),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='insurances', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['insurance_type'],
},
),
]
@@ -0,0 +1,34 @@
# Generated by Django 6.0.4 on 2026-05-24 11:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0021_add_insurance_model'),
]
operations = [
migrations.CreateModel(
name='PraemienEntry',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('plz', models.CharField(db_index=True, max_length=10)),
('ort', models.CharField(max_length=200)),
('kanton', models.CharField(max_length=2)),
('region', models.PositiveSmallIntegerField()),
('bfs_nr', models.PositiveIntegerField(db_index=True)),
('gemeinde', models.CharField(max_length=200)),
('bezirk', models.CharField(blank=True, default='', max_length=200)),
('avg_adult', models.DecimalField(decimal_places=2, max_digits=8)),
('avg_young_adult', models.DecimalField(decimal_places=2, max_digits=8)),
('avg_child', models.DecimalField(decimal_places=2, max_digits=8)),
('data_year', models.PositiveSmallIntegerField(db_index=True)),
],
options={
'ordering': ['kanton', 'ort'],
'unique_together': {('plz', 'ort', 'data_year')},
},
),
]
@@ -0,0 +1,34 @@
# Generated by Django 6.0.4 on 2026-05-24 11:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0022_add_praemien_entry'),
]
operations = [
migrations.CreateModel(
name='PraemienPolice',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('versicherer_id', models.PositiveIntegerField(db_index=True)),
('kanton', models.CharField(max_length=2)),
('region', models.PositiveSmallIntegerField()),
('altersklasse', models.CharField(max_length=10)),
('unfalleinschluss', models.CharField(max_length=10)),
('tariftyp', models.CharField(max_length=10)),
('tarifbezeichnung', models.CharField(max_length=200)),
('franchisestufe', models.CharField(max_length=10)),
('franchise_chf', models.PositiveSmallIntegerField()),
('praemie', models.DecimalField(decimal_places=2, max_digits=8)),
('data_year', models.PositiveSmallIntegerField(db_index=True)),
],
options={
'indexes': [models.Index(fields=['kanton', 'region', 'altersklasse', 'unfalleinschluss', 'tariftyp', 'franchisestufe', 'data_year'], name='finance_pra_kanton_e430cb_idx')],
'unique_together': {('versicherer_id', 'kanton', 'region', 'altersklasse', 'unfalleinschluss', 'tariftyp', 'franchisestufe', 'data_year')},
},
),
]
+109 -1
View File
@@ -223,4 +223,112 @@ class BackupCode(models.Model):
indexes = [models.Index(fields=['user', 'used'])]
def __str__(self):
return f"{self.user} backup {'used' if self.used else 'active'}"
return f"{self.user} backup {'used' if self.used else 'active'}"
class Insurance(models.Model):
INSURANCE_TYPES = [
('kvg', 'Krankenkasse Grundversicherung (KVG)'),
('kk_zusatz', 'KK-Zusatzversicherung'),
('nbu', 'Nicht-Berufsunfallversicherung (NBU)'),
('haftpflicht', 'Privathaftpflicht'),
('hausrat', 'Hausrat'),
('mfz', 'MFZ-Haftpflicht'),
('rechtsschutz', 'Rechtsschutz'),
('saule_3a', 'Säule 3a'),
('leben', 'Lebensversicherung'),
('reise', 'Reiseversicherung'),
('other', 'Sonstiges'),
]
PERIOD_CHOICES = [
('monthly', 'Monatlich'),
('quarterly', 'Vierteljährlich'),
('semi_annual', 'Halbjährlich'),
('annual', 'Jährlich'),
]
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='insurances',
)
insurance_type = models.CharField(max_length=30, choices=INSURANCE_TYPES)
insurer = models.CharField(max_length=200)
policy_number = models.CharField(max_length=100, blank=True, default='')
premium = models.DecimalField(max_digits=10, decimal_places=2)
premium_period = models.CharField(max_length=20, choices=PERIOD_CHOICES, default='monthly')
coverage_amount = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
deductible = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
valid_from = models.DateField(null=True, blank=True)
valid_until = models.DateField(null=True, blank=True)
notes = models.TextField(blank=True, default='')
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['insurance_type']
def __str__(self):
return f"{self.get_insurance_type_display()} {self.insurer}"
class PraemienEntry(models.Model):
"""
Swiss health insurance average premium data from BAG / Priminfo.
Populated via management command: python manage.py import_praemien [year]
Source: https://www.priminfo.admin.ch/downloads/praemienregionen_{year}.xlsx
"""
plz = models.CharField(max_length=10, db_index=True)
ort = models.CharField(max_length=200)
kanton = models.CharField(max_length=2)
region = models.PositiveSmallIntegerField() # Prämienregion 0, 1, 2, or 3
bfs_nr = models.PositiveIntegerField(db_index=True)
gemeinde = models.CharField(max_length=200)
bezirk = models.CharField(max_length=200, blank=True, default='')
avg_adult = models.DecimalField(max_digits=8, decimal_places=2)
avg_young_adult = models.DecimalField(max_digits=8, decimal_places=2)
avg_child = models.DecimalField(max_digits=8, decimal_places=2)
data_year = models.PositiveSmallIntegerField(db_index=True)
class Meta:
unique_together = ['plz', 'ort', 'data_year']
ordering = ['kanton', 'ort']
def __str__(self):
return f"{self.plz} {self.ort} ({self.kanton}) Region {self.region} {self.data_year}"
class PraemienPolice(models.Model):
"""
Granular KVG premium data per insurer, canton, region, age class, model, franchise.
Populated via management command: python manage.py import_praemien [year]
Source: https://opendata.bagnet.ch (Prämien_CH.csv)
~217k rows for a full year.
"""
versicherer_id = models.PositiveIntegerField(db_index=True)
kanton = models.CharField(max_length=2)
region = models.PositiveSmallIntegerField() # 0, 1, 2, 3
altersklasse = models.CharField(max_length=10) # AKL-ERW / AKL-JUG / AKL-KIN
unfalleinschluss = models.CharField(max_length=10) # MIT-UNF / OHN-UNF
tariftyp = models.CharField(max_length=10) # TAR-BASE / TAR-HAM / TAR-HMO / TAR-DIV
tarifbezeichnung = models.CharField(max_length=200)
franchisestufe = models.CharField(max_length=10) # FRAST1 … FRAST7
franchise_chf = models.PositiveSmallIntegerField() # e.g. 300, 500, 1000 …
praemie = models.DecimalField(max_digits=8, decimal_places=2)
data_year = models.PositiveSmallIntegerField(db_index=True)
class Meta:
unique_together = [
'versicherer_id', 'kanton', 'region', 'altersklasse',
'unfalleinschluss', 'tariftyp', 'franchisestufe', 'data_year',
]
indexes = [
models.Index(fields=[
'kanton', 'region', 'altersklasse',
'unfalleinschluss', 'tariftyp', 'franchisestufe', 'data_year',
]),
]
def __str__(self):
return (f"V{self.versicherer_id} {self.kanton} R{self.region} "
f"{self.altersklasse} {self.tariftyp} {self.franchisestufe}{self.praemie} CHF")
+7 -1
View File
@@ -1,6 +1,6 @@
from rest_framework import serializers
from django.contrib.auth import get_user_model
from .models import Account, Transaction, Budget, Expense, Profile, Deadline
from .models import Account, Transaction, Budget, Expense, Profile, Deadline, Insurance
User = get_user_model()
@@ -56,6 +56,12 @@ class DeadlineSerializer(serializers.ModelSerializer):
exclude = ['user']
class InsuranceSerializer(serializers.ModelSerializer):
class Meta:
model = Insurance
exclude = ['user']
class RegisterSerializer(serializers.Serializer):
email = serializers.EmailField()
password = serializers.CharField(min_length=8, write_only=True)
+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)