c03d2a97ab
- 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
334 lines
13 KiB
Python
334 lines
13 KiB
Python
from django.db import models
|
||
from django.conf import settings
|
||
|
||
class Account(models.Model):
|
||
# Typen basierend auf der Firefly III Logik
|
||
ACCOUNT_TYPES = [
|
||
('asset', 'Asset Account (Bank/Cash)'),
|
||
('expense', 'Expense Account (Laden/Empfänger)'),
|
||
('revenue', 'Revenue Account (Einnahmequelle)'),
|
||
]
|
||
|
||
user = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL,
|
||
on_delete=models.CASCADE,
|
||
related_name='accounts',
|
||
null=True,
|
||
)
|
||
name = models.CharField(max_length=100)
|
||
account_type = models.CharField(max_length=20, choices=ACCOUNT_TYPES, default='asset')
|
||
balance = models.DecimalField(max_digits=12, decimal_places=2, default=0.00)
|
||
active = models.BooleanField(default=True)
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
|
||
def __str__(self):
|
||
return f"{self.name} ({self.get_account_type_display()})"
|
||
|
||
class Transaction(models.Model):
|
||
description = models.CharField(max_length=255)
|
||
amount = models.DecimalField(max_digits=12, decimal_places=2)
|
||
date = models.DateField()
|
||
|
||
# Die Verknüpfung zu den Konten (Double-Entry Prinzip)
|
||
source_account = models.ForeignKey(
|
||
Account,
|
||
on_delete=models.CASCADE,
|
||
related_name='withdrawals'
|
||
)
|
||
destination_account = models.ForeignKey(
|
||
Account,
|
||
on_delete=models.CASCADE,
|
||
related_name='deposits'
|
||
)
|
||
|
||
def __str__(self):
|
||
return f"{self.date}: {self.description} ({self.amount}€)"
|
||
|
||
|
||
class Budget(models.Model):
|
||
MAIN_CATEGORY_CHOICES = [
|
||
('fixed_expenses', 'Fixe Ausgaben'),
|
||
('mobile_internet', 'Mobile & Internet'),
|
||
('subscriptions', 'Abonnements'),
|
||
('leisure', 'Freizeit'),
|
||
('tax_reserves', 'Steuerrücklagen'),
|
||
('insurance', 'Versicherungen'),
|
||
('loans', 'Abzahlungen & Kredite'),
|
||
]
|
||
|
||
name = models.CharField(max_length=100)
|
||
amount = models.DecimalField(max_digits=12, decimal_places=2)
|
||
main_category = models.CharField(max_length=50, choices=MAIN_CATEGORY_CHOICES, default='fixed_expenses')
|
||
account = models.ForeignKey(
|
||
Account,
|
||
on_delete=models.CASCADE,
|
||
related_name='budgets'
|
||
)
|
||
active = models.BooleanField(default=True)
|
||
|
||
def __str__(self):
|
||
return f"{self.name} ({self.amount} CHF)"
|
||
|
||
|
||
class Expense(models.Model):
|
||
CATEGORY_CHOICES = [
|
||
('groceries', 'Groceries'),
|
||
('dining', 'Dining & Restaurants'),
|
||
('transport', 'Transport'),
|
||
('health', 'Health & Medical'),
|
||
('clothing', 'Clothing'),
|
||
('electronics', 'Electronics'),
|
||
('household', 'Household'),
|
||
('entertainment', 'Entertainment'),
|
||
('travel', 'Travel'),
|
||
('other', 'Other'),
|
||
]
|
||
|
||
name = models.CharField(max_length=255)
|
||
amount = models.DecimalField(max_digits=12, decimal_places=2)
|
||
date = models.DateField()
|
||
category = models.CharField(max_length=50, choices=CATEGORY_CHOICES, default='other')
|
||
account = models.ForeignKey(
|
||
Account,
|
||
on_delete=models.CASCADE,
|
||
related_name='expenses'
|
||
)
|
||
notes = models.TextField(blank=True, default='')
|
||
due_date = models.DateField(blank=True, null=True)
|
||
|
||
def __str__(self):
|
||
return f"{self.date}: {self.name} ({self.amount} CHF)"
|
||
|
||
|
||
CANTON_CHOICES = [
|
||
('AG', 'Aargau'), ('AI', 'Appenzell Innerrhoden'), ('AR', 'Appenzell Ausserrhoden'),
|
||
('BE', 'Bern'), ('BL', 'Basel-Landschaft'), ('BS', 'Basel-Stadt'),
|
||
('FR', 'Fribourg'), ('GE', 'Geneva'), ('GL', 'Glarus'),
|
||
('GR', 'Graubünden'), ('JU', 'Jura'), ('LU', 'Lucerne'),
|
||
('NE', 'Neuchâtel'), ('NW', 'Nidwalden'), ('OW', 'Obwalden'),
|
||
('SG', 'St. Gallen'), ('SH', 'Schaffhausen'), ('SO', 'Solothurn'),
|
||
('SZ', 'Schwyz'), ('TG', 'Thurgau'), ('TI', 'Ticino'),
|
||
('UR', 'Uri'), ('VD', 'Vaud'), ('VS', 'Valais'),
|
||
('ZG', 'Zug'), ('ZH', 'Zürich'),
|
||
]
|
||
|
||
|
||
class Deadline(models.Model):
|
||
TYPE_CHOICES = [
|
||
('tax', 'Tax'),
|
||
('insurance', 'Insurance'),
|
||
('invoice', 'Invoice'),
|
||
('personal', 'Personal'),
|
||
('other', 'Other'),
|
||
]
|
||
user = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL,
|
||
on_delete=models.CASCADE,
|
||
related_name='deadlines',
|
||
)
|
||
title = models.CharField(max_length=200)
|
||
date = models.DateField()
|
||
type = models.CharField(max_length=20, choices=TYPE_CHOICES, default='other')
|
||
notes = models.TextField(blank=True, default='')
|
||
|
||
def __str__(self):
|
||
return f"{self.date}: {self.title}"
|
||
|
||
|
||
class Profile(models.Model):
|
||
user = models.OneToOneField(
|
||
settings.AUTH_USER_MODEL,
|
||
on_delete=models.CASCADE,
|
||
related_name='profile',
|
||
null=True,
|
||
)
|
||
first_name = models.CharField(max_length=100, blank=True, default='')
|
||
last_name = models.CharField(max_length=100, blank=True, default='')
|
||
email = models.EmailField(blank=True, default='')
|
||
avatar_color = models.CharField(max_length=7, default='#1A56DB')
|
||
avatar_image = models.ImageField(upload_to='avatars/', blank=True, null=True)
|
||
canton = models.CharField(max_length=2, choices=CANTON_CHOICES, default='ZH')
|
||
language = models.CharField(max_length=2, choices=[('de','Deutsch'),('fr','Français'),('it','Italiano'),('en','English')], default='de')
|
||
totp_secret = models.CharField(max_length=64, blank=True, default='')
|
||
totp_enabled = models.BooleanField(default=False)
|
||
totp_last_used_code = models.CharField(max_length=6, blank=True, default='')
|
||
recovery_email = models.EmailField(blank=True, default='')
|
||
recovery_code_hash = models.CharField(max_length=64, blank=True, default='')
|
||
recovery_code_expires = models.DateTimeField(null=True, blank=True)
|
||
notif_deadlines = models.BooleanField(default=True)
|
||
notif_budget_alerts = models.BooleanField(default=True)
|
||
notif_monthly_summary = models.BooleanField(default=False)
|
||
savings_rate_goal = models.PositiveSmallIntegerField(default=20)
|
||
email_verified = models.BooleanField(default=False)
|
||
email_verify_token = models.CharField(max_length=64, blank=True, default='')
|
||
email_verify_token_expires = models.DateTimeField(null=True, blank=True)
|
||
password_reset_token_hash = models.CharField(max_length=64, blank=True, default='')
|
||
password_reset_token_expires = models.DateTimeField(null=True, blank=True)
|
||
|
||
def __str__(self):
|
||
return f"{self.first_name} {self.last_name}".strip() or 'Profile'
|
||
|
||
|
||
class UserSession(models.Model):
|
||
user = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL,
|
||
on_delete=models.CASCADE,
|
||
related_name='user_sessions',
|
||
)
|
||
session_key = models.CharField(max_length=64, unique=True)
|
||
refresh_jti = models.CharField(max_length=255, blank=True, default='')
|
||
device_name = models.CharField(max_length=200, blank=True, default='')
|
||
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
last_active_at = models.DateTimeField(auto_now_add=True)
|
||
|
||
class Meta:
|
||
ordering = ['-last_active_at']
|
||
|
||
def __str__(self):
|
||
return f"{self.user} – {self.device_name}"
|
||
|
||
|
||
class ReadEvent(models.Model):
|
||
EVENT_TYPES = [
|
||
('deadline', 'Deadline'),
|
||
('expense', 'Expense'),
|
||
]
|
||
user = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL,
|
||
on_delete=models.CASCADE,
|
||
related_name='read_events',
|
||
)
|
||
event_type = models.CharField(max_length=20, choices=EVENT_TYPES)
|
||
event_id = models.PositiveIntegerField()
|
||
|
||
class Meta:
|
||
unique_together = ['user', 'event_type', 'event_id']
|
||
|
||
def __str__(self):
|
||
return f"{self.user} – {self.event_type} {self.event_id}"
|
||
|
||
|
||
class BackupCode(models.Model):
|
||
user = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL,
|
||
on_delete=models.CASCADE,
|
||
related_name='backup_codes',
|
||
)
|
||
code_hash = models.CharField(max_length=64)
|
||
used = models.BooleanField(default=False)
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
|
||
class Meta:
|
||
indexes = [models.Index(fields=['user', 'used'])]
|
||
|
||
def __str__(self):
|
||
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") |