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:
@@ -7,7 +7,7 @@ from rest_framework.routers import DefaultRouter
|
||||
from rest_framework_simplejwt.views import TokenRefreshView
|
||||
from finance.views import (
|
||||
AccountViewSet, TransactionViewSet, BudgetViewSet,
|
||||
ExpenseViewSet, DeadlineViewSet, ProfileView, RegisterView, LogoutView, ChangePasswordView,
|
||||
ExpenseViewSet, DeadlineViewSet, InsuranceViewSet, PraemienView, PraemienVergleichView, ProfileView, RegisterView, LogoutView, ChangePasswordView,
|
||||
ICalUrlView, ICalFeedView, NotificationsView, SearchView,
|
||||
LoginView, TwoFactorLoginView, TwoFactorSetupView, TwoFactorEnableView, TwoFactorDisableView,
|
||||
TwoFactorRecoverRequestView, TwoFactorRecoverConfirmView,
|
||||
@@ -22,6 +22,7 @@ router.register(r'transactions', TransactionViewSet, basename='transaction')
|
||||
router.register(r'budgets', BudgetViewSet, basename='budget')
|
||||
router.register(r'expenses', ExpenseViewSet, basename='expense')
|
||||
router.register(r'deadlines', DeadlineViewSet, basename='deadline')
|
||||
router.register(r'insurances', InsuranceViewSet, basename='insurance')
|
||||
|
||||
_admin_url = os.environ.get('ADMIN_URL', 'manage/').strip('/')+ '/'
|
||||
|
||||
@@ -50,6 +51,8 @@ urlpatterns = [
|
||||
path('api/notifications/prefs/', NotificationPrefsView.as_view()),
|
||||
path('api/search/', SearchView.as_view()),
|
||||
path('api/notifications/', NotificationsView.as_view()),
|
||||
path('api/praemien/', PraemienView.as_view()),
|
||||
path('api/praemien/vergleich/', PraemienVergleichView.as_view()),
|
||||
path('api/calendar/ical-url/', ICalUrlView.as_view()),
|
||||
path('api/calendar/ical/<int:user_id>/<str:token>/', ICalFeedView.as_view()),
|
||||
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
|
||||
@@ -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
@@ -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")
|
||||
@@ -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
@@ -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