Files
saldo/app/models.py
T
2026-04-21 21:17:36 +02:00

315 lines
13 KiB
Python

from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal
from flask_login import UserMixin
from sqlalchemy import UniqueConstraint
from werkzeug.security import check_password_hash, generate_password_hash
from .extensions import db, login_manager
def utcnow() -> datetime:
return datetime.now(timezone.utc)
class TimestampMixin:
created_at = db.Column(db.DateTime(timezone=True), default=utcnow, nullable=False)
updated_at = db.Column(
db.DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False
)
class User(UserMixin, TimestampMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
display_name = db.Column(db.String(120), nullable=False)
email = db.Column(db.String(255), unique=True, nullable=False)
avatar_url = db.Column(db.String(255), nullable=True)
password_hash = db.Column(db.String(255), nullable=False)
role = db.Column(db.String(20), nullable=False, default="editor")
is_active = db.Column(db.Boolean, nullable=False, default=True)
notification_preference = db.relationship(
"NotificationPreference", back_populates="user", uselist=False
)
push_subscriptions = db.relationship("PushSubscription", back_populates="user")
def set_password(self, password: str) -> None:
self.password_hash = generate_password_hash(password)
def check_password(self, password: str) -> bool:
return check_password_hash(self.password_hash, password)
def is_admin(self) -> bool:
return self.role == "admin"
@property
def ui_name(self) -> str:
return self.display_name
@property
def avatar_initials(self) -> str:
parts = [part for part in self.ui_name.replace("-", " ").replace("_", " ").split() if part]
if not parts:
return "?"
if len(parts) == 1:
return parts[0][:2].upper()
return f"{parts[0][0]}{parts[1][0]}".upper()
@login_manager.user_loader
def load_user(user_id: str) -> User | None:
return db.session.get(User, int(user_id))
class Month(TimestampMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
label = db.Column(db.String(7), unique=True, nullable=False)
year = db.Column(db.Integer, nullable=False)
month = db.Column(db.Integer, nullable=False)
auto_created = db.Column(db.Boolean, nullable=False, default=False)
is_locked = db.Column(db.Boolean, nullable=False, default=False)
notes = db.Column(db.Text, nullable=True)
savings_min_pct = db.Column(db.Numeric(5, 2), nullable=False, default=15)
savings_max_pct = db.Column(db.Numeric(5, 2), nullable=False, default=20)
vacation_min_pct = db.Column(db.Numeric(5, 2), nullable=False, default=5)
vacation_max_pct = db.Column(db.Numeric(5, 2), nullable=False, default=8)
leisure_min_pct = db.Column(db.Numeric(5, 2), nullable=False, default=5)
leisure_max_pct = db.Column(db.Numeric(5, 2), nullable=False, default=10)
personal_split_desi_pct = db.Column(db.Numeric(5, 2), nullable=False, default=50)
entry_values = db.relationship(
"MonthlyEntryValue", back_populates="month", cascade="all, delete-orphan"
)
incomes = db.relationship(
"MonthlyIncome", back_populates="month", cascade="all, delete-orphan"
)
allocations = db.relationship(
"MonthlyAllocation", back_populates="month", cascade="all, delete-orphan"
)
suggestions = db.relationship(
"AllocationSuggestion", back_populates="month", cascade="all, delete-orphan"
)
class Account(TimestampMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(120), nullable=False, unique=True)
slug = db.Column(db.String(120), nullable=False, unique=True)
description = db.Column(db.Text, nullable=True)
sort_order = db.Column(db.Integer, nullable=False, default=0)
is_active = db.Column(db.Boolean, nullable=False, default=True)
categories = db.relationship(
"Category", back_populates="account", cascade="all, delete-orphan"
)
class Category(TimestampMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
account_id = db.Column(db.Integer, db.ForeignKey("account.id"), nullable=False)
community_account_id = db.Column(
db.Integer, db.ForeignKey("community_account.id"), nullable=True
)
name = db.Column(db.String(120), nullable=False)
slug = db.Column(db.String(120), nullable=False)
description = db.Column(db.Text, nullable=True)
sort_order = db.Column(db.Integer, nullable=False, default=0)
is_active = db.Column(db.Boolean, nullable=False, default=True)
account = db.relationship("Account", back_populates="categories")
community_account = db.relationship("CommunityAccount", back_populates="budget_categories")
entries = db.relationship(
"Entry", back_populates="category", cascade="all, delete-orphan"
)
__table_args__ = (UniqueConstraint("account_id", "slug"),)
class Entry(TimestampMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
category_id = db.Column(db.Integer, db.ForeignKey("category.id"), nullable=False)
name = db.Column(db.String(120), nullable=False)
slug = db.Column(db.String(120), nullable=False)
description = db.Column(db.Text, nullable=True)
default_amount = db.Column(db.Numeric(12, 2), nullable=False, default=0)
amount_type = db.Column(db.String(20), nullable=False, default="fixed")
benefit_scope = db.Column(db.String(120), nullable=False, default="all-users")
is_allocation_target = db.Column(db.Boolean, nullable=False, default=False)
is_active = db.Column(db.Boolean, nullable=False, default=True)
sort_order = db.Column(db.Integer, nullable=False, default=0)
category = db.relationship("Category", back_populates="entries")
monthly_values = db.relationship(
"MonthlyEntryValue", back_populates="entry", cascade="all, delete-orphan"
)
share_rules = db.relationship(
"EntryShareRule", back_populates="entry", cascade="all, delete-orphan"
)
__table_args__ = (UniqueConstraint("category_id", "slug"),)
class MonthlyEntryValue(TimestampMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
month_id = db.Column(db.Integer, db.ForeignKey("month.id"), nullable=False)
entry_id = db.Column(db.Integer, db.ForeignKey("entry.id"), nullable=False)
planned_amount = db.Column(db.Numeric(12, 2), nullable=False, default=0)
note = db.Column(db.Text, nullable=True)
created_by = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
updated_by = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
month = db.relationship("Month", back_populates="entry_values")
entry = db.relationship("Entry", back_populates="monthly_values")
__table_args__ = (UniqueConstraint("month_id", "entry_id"),)
class MonthlyIncome(TimestampMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
month_id = db.Column(db.Integer, db.ForeignKey("month.id"), nullable=False)
label = db.Column(db.String(120), nullable=False)
amount = db.Column(db.Numeric(12, 2), nullable=False, default=0)
sort_order = db.Column(db.Integer, nullable=False, default=0)
month = db.relationship("Month", back_populates="incomes")
class MonthlyAllocation(TimestampMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
month_id = db.Column(db.Integer, db.ForeignKey("month.id"), nullable=False)
target_account_id = db.Column(db.Integer, db.ForeignKey("account.id"), nullable=False)
label = db.Column(db.String(120), nullable=False)
amount = db.Column(db.Numeric(12, 2), nullable=False, default=0)
source = db.Column(db.String(30), nullable=False, default="manual")
is_locked = db.Column(db.Boolean, nullable=False, default=False)
sort_order = db.Column(db.Integer, nullable=False, default=0)
month = db.relationship("Month", back_populates="allocations")
target_account = db.relationship("Account")
__table_args__ = (UniqueConstraint("month_id", "target_account_id"),)
class AllocationSuggestion(db.Model):
id = db.Column(db.Integer, primary_key=True)
month_id = db.Column(db.Integer, db.ForeignKey("month.id"), nullable=False)
target_account_id = db.Column(db.Integer, db.ForeignKey("account.id"), nullable=False)
suggested_amount = db.Column(db.Numeric(12, 2), nullable=False, default=0)
reason = db.Column(db.Text, nullable=True)
strategy_key = db.Column(db.String(80), nullable=False)
created_at = db.Column(db.DateTime(timezone=True), default=utcnow, nullable=False)
month = db.relationship("Month", back_populates="suggestions")
target_account = db.relationship("Account")
class CostParticipant(TimestampMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(120), nullable=False, unique=True)
avatar_url = db.Column(db.String(255), nullable=True)
is_app_user = db.Column(db.Boolean, nullable=False, default=False)
linked_user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
is_external = db.Column(db.Boolean, nullable=False, default=False)
is_active = db.Column(db.Boolean, nullable=False, default=True)
linked_user = db.relationship("User")
share_rules = db.relationship(
"EntryShareRule", back_populates="participant", cascade="all, delete-orphan"
)
@property
def display_name(self) -> str:
if self.is_app_user and self.linked_user is not None:
return self.linked_user.ui_name
return self.name
@property
def avatar_initials(self) -> str:
parts = [part for part in self.display_name.replace("-", " ").replace("_", " ").split() if part]
if not parts:
return "?"
if len(parts) == 1:
return parts[0][:2].upper()
return f"{parts[0][0]}{parts[1][0]}".upper()
class EntryShareRule(TimestampMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
entry_id = db.Column(db.Integer, db.ForeignKey("entry.id"), nullable=False)
participant_id = db.Column(
db.Integer, db.ForeignKey("cost_participant.id"), nullable=False
)
share_type = db.Column(db.String(20), nullable=False, default="equal")
share_value = db.Column(db.Numeric(12, 4), nullable=True)
entry = db.relationship("Entry", back_populates="share_rules")
participant = db.relationship("CostParticipant", back_populates="share_rules")
__table_args__ = (UniqueConstraint("entry_id", "participant_id"),)
class PushSubscription(TimestampMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
endpoint = db.Column(db.Text, nullable=False)
p256dh_key = db.Column(db.Text, nullable=False)
auth_key = db.Column(db.Text, nullable=False)
user_agent = db.Column(db.String(255), nullable=True)
user = db.relationship("User", back_populates="push_subscriptions")
class CommunityAccount(TimestampMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(120), nullable=False, unique=True)
slug = db.Column(db.String(120), nullable=False, unique=True)
description = db.Column(db.Text, nullable=True)
account_type = db.Column(db.String(20), nullable=False, default="shared")
linked_account_slug = db.Column(db.String(120), nullable=True)
sort_order = db.Column(db.Integer, nullable=False, default=0)
is_active = db.Column(db.Boolean, nullable=False, default=True)
budget_categories = db.relationship("Category", back_populates="community_account")
class NotificationPreference(TimestampMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False, unique=True)
notify_month_end = db.Column(db.Boolean, nullable=False, default=True)
notify_missing_distribution = db.Column(db.Boolean, nullable=False, default=True)
notify_missing_values = db.Column(db.Boolean, nullable=False, default=True)
user = db.relationship("User", back_populates="notification_preference")
class InAppNotification(db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
type = db.Column(db.String(50), nullable=False)
title = db.Column(db.String(150), nullable=False)
body = db.Column(db.Text, nullable=False)
action_url = db.Column(db.String(255), nullable=True)
is_read = db.Column(db.Boolean, nullable=False, default=False)
created_at = db.Column(db.DateTime(timezone=True), default=utcnow, nullable=False)
class AuditLog(db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
action = db.Column(db.String(120), nullable=False)
entity_type = db.Column(db.String(80), nullable=False)
entity_id = db.Column(db.Integer, nullable=True)
payload_json = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime(timezone=True), default=utcnow, nullable=False)
def to_decimal(value: object) -> Decimal:
if value is None:
return Decimal("0.00")
if isinstance(value, Decimal):
return value.quantize(Decimal("0.01"))
return Decimal(str(value)).quantize(Decimal("0.01"))