first commit
This commit is contained in:
231
app/models.py
Normal file
231
app/models.py
Normal file
@@ -0,0 +1,231 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user