release: publish saldo 0.1.0
This commit is contained in:
+314
@@ -0,0 +1,314 @@
|
||||
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"))
|
||||
Reference in New Issue
Block a user