232 lines
9.4 KiB
Python
232 lines
9.4 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import date, datetime, timedelta, timezone
|
|
from secrets import token_urlsafe
|
|
|
|
from flask_login import UserMixin
|
|
from werkzeug.security import check_password_hash, generate_password_hash
|
|
|
|
from .extensions import db, login_manager
|
|
|
|
|
|
class TimestampMixin:
|
|
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
|
|
updated_at = db.Column(
|
|
db.DateTime,
|
|
default=lambda: datetime.now(timezone.utc),
|
|
onupdate=lambda: datetime.now(timezone.utc),
|
|
nullable=False,
|
|
)
|
|
|
|
|
|
class User(UserMixin, TimestampMixin, db.Model):
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
email = db.Column(db.String(255), unique=True, nullable=False, index=True)
|
|
full_name = db.Column(db.String(120), nullable=False)
|
|
password_hash = db.Column(db.String(255), nullable=False)
|
|
role = db.Column(db.String(20), default='user', nullable=False)
|
|
is_active_user = db.Column(db.Boolean, default=True, nullable=False)
|
|
must_change_password = db.Column(db.Boolean, default=True, nullable=False)
|
|
language = db.Column(db.String(5), default='pl', nullable=False)
|
|
theme = db.Column(db.String(20), default='light', nullable=False)
|
|
report_frequency = db.Column(db.String(20), default='off', nullable=False)
|
|
default_currency = db.Column(db.String(10), default='PLN', nullable=False)
|
|
|
|
expenses = db.relationship('Expense', backref='user', lazy=True, cascade='all, delete-orphan')
|
|
budgets = db.relationship('Budget', backref='user', lazy=True, cascade='all, delete-orphan')
|
|
|
|
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)
|
|
|
|
@property
|
|
def is_active(self) -> bool:
|
|
return self.is_active_user
|
|
|
|
def is_admin(self) -> bool:
|
|
return self.role == 'admin'
|
|
|
|
|
|
class Category(TimestampMixin, db.Model):
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True, index=True)
|
|
key = db.Column(db.String(80), unique=True, nullable=False)
|
|
name = db.Column(db.String(120), unique=True, nullable=False)
|
|
name_pl = db.Column(db.String(120), nullable=False)
|
|
name_en = db.Column(db.String(120), nullable=False)
|
|
color = db.Column(db.String(20), default='primary', nullable=False)
|
|
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
|
|
|
owner = db.relationship('User', lazy=True)
|
|
|
|
def localized_name(self, language: str = 'pl') -> str:
|
|
return self.name_en if language == 'en' else self.name_pl
|
|
|
|
|
|
class Expense(TimestampMixin, db.Model):
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
|
category_id = db.Column(db.Integer, db.ForeignKey('category.id'))
|
|
title = db.Column(db.String(255), nullable=False)
|
|
vendor = db.Column(db.String(255), default='')
|
|
description = db.Column(db.Text, default='')
|
|
amount = db.Column(db.Numeric(10, 2), nullable=False)
|
|
currency = db.Column(db.String(10), default='PLN', nullable=False)
|
|
purchase_date = db.Column(db.Date, default=date.today, nullable=False)
|
|
payment_method = db.Column(db.String(20), default='card', nullable=False)
|
|
ocr_status = db.Column(db.String(20), default='manual', nullable=False)
|
|
document_filename = db.Column(db.String(255), default='')
|
|
preview_filename = db.Column(db.String(255), default='')
|
|
is_refund = db.Column(db.Boolean, default=False, nullable=False)
|
|
is_business = db.Column(db.Boolean, default=False, nullable=False)
|
|
tags = db.Column(db.String(255), default='')
|
|
recurring_period = db.Column(db.String(20), default='none', nullable=False)
|
|
status = db.Column(db.String(20), default='confirmed', nullable=False)
|
|
is_deleted = db.Column(db.Boolean, default=False, nullable=False)
|
|
|
|
category = db.relationship('Category', lazy=True)
|
|
attachments = db.relationship('DocumentAttachment', back_populates='expense', lazy=True, cascade='all, delete-orphan', order_by='DocumentAttachment.sort_order')
|
|
|
|
@property
|
|
def all_previews(self):
|
|
previews = [self.preview_filename] if self.preview_filename else []
|
|
previews.extend([item.preview_filename for item in self.attachments if item.preview_filename and item.preview_filename not in previews])
|
|
return previews
|
|
|
|
|
|
|
|
|
|
|
|
class DocumentAttachment(TimestampMixin, db.Model):
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
expense_id = db.Column(db.Integer, db.ForeignKey('expense.id'), nullable=False, index=True)
|
|
original_filename = db.Column(db.String(255), nullable=False)
|
|
stored_filename = db.Column(db.String(255), nullable=False)
|
|
preview_filename = db.Column(db.String(255), default='', nullable=False)
|
|
mime_type = db.Column(db.String(120), default='')
|
|
sort_order = db.Column(db.Integer, default=0, nullable=False)
|
|
|
|
expense = db.relationship('Expense', back_populates='attachments')
|
|
|
|
|
|
class Budget(TimestampMixin, db.Model):
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
|
category_id = db.Column(db.Integer, db.ForeignKey('category.id'))
|
|
year = db.Column(db.Integer, nullable=False)
|
|
month = db.Column(db.Integer, nullable=False)
|
|
amount = db.Column(db.Numeric(10, 2), nullable=False)
|
|
alert_percent = db.Column(db.Integer, default=80, nullable=False)
|
|
|
|
category = db.relationship('Category', lazy=True)
|
|
|
|
|
|
class AppSetting(TimestampMixin, db.Model):
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
key = db.Column(db.String(100), unique=True, nullable=False)
|
|
value = db.Column(db.Text, nullable=False)
|
|
|
|
@classmethod
|
|
def get(cls, key: str, default: str | None = None) -> str | None:
|
|
setting = cls.query.filter_by(key=key).first()
|
|
return setting.value if setting else default
|
|
|
|
@classmethod
|
|
def set(cls, key: str, value: str) -> None:
|
|
setting = cls.query.filter_by(key=key).first()
|
|
if not setting:
|
|
setting = cls(key=key, value=value)
|
|
db.session.add(setting)
|
|
else:
|
|
setting.value = value
|
|
|
|
|
|
class PasswordResetToken(TimestampMixin, db.Model):
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
|
token = db.Column(db.String(255), unique=True, nullable=False, index=True)
|
|
expires_at = db.Column(db.DateTime, nullable=False)
|
|
used_at = db.Column(db.DateTime)
|
|
|
|
user = db.relationship('User', lazy=True)
|
|
|
|
@classmethod
|
|
def issue(cls, user: User, minutes: int = 30) -> 'PasswordResetToken':
|
|
return cls(user=user, token=token_urlsafe(32), expires_at=datetime.now(timezone.utc) + timedelta(minutes=minutes))
|
|
|
|
def is_valid(self) -> bool:
|
|
now = datetime.now(timezone.utc)
|
|
expires_at = self.expires_at if self.expires_at.tzinfo else self.expires_at.replace(tzinfo=timezone.utc)
|
|
return self.used_at is None and expires_at > now
|
|
|
|
|
|
class AuditLog(TimestampMixin, db.Model):
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
|
|
action = db.Column(db.String(120), nullable=False)
|
|
target_type = db.Column(db.String(80), default='')
|
|
target_id = db.Column(db.String(80), default='')
|
|
details = db.Column(db.Text, default='')
|
|
|
|
user = db.relationship('User', lazy=True)
|
|
|
|
|
|
class ReportLog(TimestampMixin, db.Model):
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
|
frequency = db.Column(db.String(20), nullable=False)
|
|
period_label = db.Column(db.String(120), nullable=False)
|
|
|
|
user = db.relationship('User', lazy=True)
|
|
|
|
|
|
@login_manager.user_loader
|
|
def load_user(user_id: str):
|
|
return db.session.get(User, int(user_id))
|
|
|
|
|
|
def seed_categories() -> None:
|
|
default_categories = [
|
|
('groceries', 'Groceries', 'Zakupy spożywcze', 'Groceries', 'success'),
|
|
('transport', 'Transport', 'Transport', 'Transport', 'warning'),
|
|
('health', 'Health', 'Zdrowie', 'Health', 'danger'),
|
|
('bills', 'Bills', 'Rachunki', 'Bills', 'primary'),
|
|
('entertainment', 'Entertainment', 'Rozrywka', 'Entertainment', 'info'),
|
|
('other', 'Other', 'Inne', 'Other', 'secondary'),
|
|
]
|
|
changed = False
|
|
for key, name, name_pl, name_en, color in default_categories:
|
|
category = Category.query.filter_by(key=key).first()
|
|
if not category:
|
|
db.session.add(Category(key=key, name=name, name_pl=name_pl, name_en=name_en, color=color))
|
|
changed = True
|
|
if changed:
|
|
db.session.commit()
|
|
|
|
|
|
def seed_default_settings() -> None:
|
|
defaults = {
|
|
'registration_enabled': 'false',
|
|
'max_upload_mb': '10',
|
|
'smtp_host': '',
|
|
'smtp_port': '465',
|
|
'smtp_username': '',
|
|
'smtp_password': '',
|
|
'smtp_sender': 'no-reply@example.com',
|
|
'smtp_security': 'ssl',
|
|
'company_name': 'Expense Monitor',
|
|
'webhook_api_token': '',
|
|
'reports_enabled': 'true',
|
|
'report_scheduler_enabled': 'false',
|
|
'report_scheduler_interval_minutes': '60',
|
|
}
|
|
changed = False
|
|
for key, value in defaults.items():
|
|
if AppSetting.query.filter_by(key=key).first() is None:
|
|
db.session.add(AppSetting(key=key, value=value))
|
|
changed = True
|
|
if changed:
|
|
db.session.commit()
|