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()