commit 986ffb200ad1ca325a3f7e0180e07551a399a623 Author: Mateusz Gruszczyński Date: Fri Mar 13 15:17:32 2026 +0100 first commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cd3274e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,21 @@ +.venv/ +venv +__pycache__/ +*.pyc +*.pyo +*.pyd +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ +instance/* +*.db +.env +dist/ +build/ +*.egg-info/ +.pytest_cache + +*.pdf +*.xml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ad986fe --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +FLASK_ENV=development +SECRET_KEY=change-me +DATABASE_URL=sqlite:///expense_monitor.db +APP_HOST=0.0.0.0 +APP_PORT=5000 +MAX_CONTENT_LENGTH_MB=10 +REGISTRATION_ENABLED=false +MAIL_SERVER= +MAIL_PORT=465 +MAIL_USE_TLS=false +MAIL_USE_SSL=true +MAIL_USERNAME= +MAIL_PASSWORD= +MAIL_DEFAULT_SENDER=no-reply@example.com +INSTANCE_PATH=/app/instance diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cd3274e --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +.venv/ +venv +__pycache__/ +*.pyc +*.pyo +*.pyd +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ +instance/* +*.db +.env +dist/ +build/ +*.egg-info/ +.pytest_cache + +*.pdf +*.xml diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..3741e5b --- /dev/null +++ b/Caddyfile @@ -0,0 +1,10 @@ +{$DOMAIN:localhost} { + encode gzip zstd + reverse_proxy app:5000 + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2e14058 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +FROM python:3.12-alpine + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN apk add --no-cache \ + build-base \ + linux-headers \ + postgresql-dev \ + jpeg-dev \ + zlib-dev \ + tiff-dev \ + freetype-dev \ + lcms2-dev \ + openjpeg-dev \ + libwebp-dev \ + tesseract-ocr + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +RUN mkdir -p /app/instance /app/app/static/uploads /app/app/static/previews + +EXPOSE 5000 +CMD ["gunicorn", "-w", "2", "-b", "0.0.0.0:5000", "wsgi:app"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..2507b66 --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# Expense Monitor + +Expanded Flask expense tracker with multilingual UI, admin panel, categories with PL/EN translations, budgets, long-term statistics, CSV/PDF export, audit log, email report command, OCR fallback, document preview generation, and production deployment files. + +## Features +- Flask + SQLAlchemy + Flask-Migrate +- Bootstrap + Font Awesome +- Polish and English UI translations +- SQLite for development, PostgreSQL for Docker/production +- login, logout, registration toggle, password reset, admin panel +- expense create, edit, soft delete, monthly list, dashboard analytics +- budgets and budget alerts +- long-term statistics with charts +- CSV and PDF exports +- audit log and admin system/database info +- HEIC/JPG/PNG/PDF upload, WEBP preview generation +- multiple attachments per expense +- webhook endpoint for external integrations +- PWA manifest + service worker foundation +- optional in-app report scheduler +- rate limiting, honeypot, CSRF, secure headers, 40x/50x pages +- CLI for account management, category seeding, report sending +- Dockerfile, docker-compose, Caddyfile + +## Quick start +```bash +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env +python run.py +``` + +Default admin: +- email: `admin@example.com` +- password: `Admin123!` + +## Tests +```bash +PYTHONPATH=. pytest +``` + +## Docker +```bash +cp .env.example .env +docker compose up --build +``` + +## CLI +```bash +flask --app wsgi:app create-user --email user@example.com --name "Example User" --password "StrongPass123!" +flask --app wsgi:app make-admin --email user@example.com +flask --app wsgi:app reset-password --email user@example.com --password "NewStrongPass123!" +flask --app wsgi:app deactivate-user --email user@example.com +flask --app wsgi:app send-reports +flask --app wsgi:app seed-categories +``` diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..caec2c0 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import logging +from datetime import datetime +from pathlib import Path + +from flask import Flask, jsonify, redirect, render_template, request, session, url_for, Response +from flask_login import current_user +from werkzeug.exceptions import HTTPException, RequestEntityTooLarge + +from .config import Config +from .extensions import db, limiter, login_manager, migrate +from .models import AppSetting, User, seed_categories, seed_default_settings +from .services.assets import asset_url +from .services.i18n import inject_i18n, translate as _ +from .services.settings import get_int_setting +from .services.scheduler import start_scheduler + + +def create_app(config_object=Config): + app = Flask(__name__, instance_relative_config=True) + app.config.from_object(config_object) + Path(app.instance_path).mkdir(parents=True, exist_ok=True) + + configure_logging(app) + + db.init_app(app) + migrate.init_app(app, db) + login_manager.init_app(app) + limiter.init_app(app) + + from .auth.routes import auth_bp + from .main.routes import main_bp + from .expenses.routes import expenses_bp + from .admin.routes import admin_bp + from .api.routes import api_bp + + app.register_blueprint(auth_bp) + app.register_blueprint(main_bp) + app.register_blueprint(expenses_bp, url_prefix='/expenses') + app.register_blueprint(admin_bp, url_prefix='/admin') + app.register_blueprint(api_bp) + + from .cli.commands import register_commands + register_commands(app) + + app.context_processor(inject_i18n) + app.jinja_env.globals['asset_url'] = asset_url + app.jinja_env.globals['now'] = datetime.utcnow + + @app.before_request + def apply_runtime_settings(): + if not current_user.is_authenticated and request.args.get('lang') in app.config['LANGUAGES']: + session['language'] = request.args['lang'] + app.config['MAX_CONTENT_LENGTH'] = get_int_setting('max_upload_mb', app.config['DEFAULT_MAX_UPLOAD_MB']) * 1024 * 1024 + + @app.after_request + def security_headers(response): + response.headers['X-Content-Type-Options'] = 'nosniff' + response.headers['X-Frame-Options'] = 'SAMEORIGIN' + response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin' + response.headers['Content-Security-Policy'] = ( + "default-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com; " + "img-src 'self' data:; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com; " + "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net;" + ) + return response + + register_error_handlers(app) + + + @app.route('/manifest.json') + def manifest(): + return jsonify({ + 'name': 'Expense Monitor', + 'short_name': 'Expenses', + 'start_url': url_for('main.dashboard' if current_user.is_authenticated else 'auth.login'), + 'display': 'standalone', + 'background_color': '#0f172a', + 'theme_color': '#111827', + 'icons': [], + }) + + @app.route('/service-worker.js') + def service_worker(): + content = "self.addEventListener('install',()=>self.skipWaiting());self.addEventListener('fetch',()=>{});" + return Response(content, mimetype='application/javascript') + + @app.route('/health') + def health(): + return {'status': 'ok'} + + with app.app_context(): + db.create_all() + seed_categories() + seed_default_settings() + ensure_admin(app) + start_scheduler(app) + + return app + + +def configure_logging(app: Flask) -> None: + app.logger.setLevel(logging.INFO) + if not app.logger.handlers: + handler = logging.StreamHandler() + handler.setFormatter(logging.Formatter('[%(asctime)s] %(levelname)s in %(module)s: %(message)s')) + app.logger.addHandler(handler) + + +def register_error_handlers(app: Flask) -> None: + def render_error(status_code: int, title: str, message: str): + payload = {'error': title, 'message': message, 'status_code': status_code} + if request.path.startswith('/analytics') or request.accept_mimetypes.best == 'application/json': + return jsonify(payload), status_code + return render_template('errors/error.html', status_code=status_code, title=title, message=message), status_code + + @app.errorhandler(400) + def bad_request(_error): + return render_error(400, _('error.400_title'), _('error.400_message')) + + @app.errorhandler(401) + def unauthorized(_error): + return render_error(401, _('error.401_title'), _('error.401_message')) + + @app.errorhandler(403) + def forbidden(_error): + return render_error(403, _('error.403_title'), _('error.403_message')) + + @app.errorhandler(404) + def not_found(_error): + return render_error(404, _('error.404_title'), _('error.404_message')) + + @app.errorhandler(413) + @app.errorhandler(RequestEntityTooLarge) + def too_large(_error): + return render_error(413, _('error.413_title'), _('error.413_message')) + + @app.errorhandler(429) + def rate_limited(_error): + return render_error(429, _('error.429_title'), _('error.429_message')) + + @app.errorhandler(Exception) + def internal_error(error): + if isinstance(error, HTTPException): + return render_error(error.code or 500, error.name, error.description) + app.logger.exception('Unhandled exception: %s', error) + return render_error(500, _('error.500_title'), _('error.500_message')) + + +def ensure_admin(app: Flask) -> None: + admin_email = 'admin@example.com' + if not User.query.filter_by(email=admin_email).first(): + admin = User( + email=admin_email, + full_name='System Admin', + role='admin', + must_change_password=True, + language=app.config['DEFAULT_LANGUAGE'], + ) + admin.set_password('Admin123!') + db.session.add(admin) + db.session.commit() diff --git a/app/admin/__init__.py b/app/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/admin/routes.py b/app/admin/routes.py new file mode 100644 index 0000000..bb3ddc5 --- /dev/null +++ b/app/admin/routes.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +import platform +from pathlib import Path +from secrets import token_urlsafe +from sqlalchemy import text + +from flask import Blueprint, current_app, flash, redirect, render_template, request, url_for +from flask_login import current_user, login_required + +from ..extensions import db +from ..forms import CategoryForm, UserAdminForm +from ..models import AppSetting, AuditLog, Category, User +from ..services.reporting import send_due_reports +from ..services.audit import log_action +from ..services.i18n import translate as _ +from ..services.mail import MailService +from ..utils import admin_required + +admin_bp = Blueprint('admin', __name__) + + +def _db_info(): + db_info = {'engine': db.engine.name, 'url': str(db.engine.url).replace(db.engine.url.password or '', '***') if db.engine.url.password else str(db.engine.url)} + try: + with db.engine.connect() as conn: + if db.engine.name == 'sqlite': + version = conn.execute(text('select sqlite_version()')).scalar() + else: + version = conn.execute(text('select version()')).scalar() + except Exception: + version = 'unknown' + return db_info, version + + +@admin_bp.route('/') +@login_required +@admin_required +def dashboard(): + db_info, version = _db_info() + stats = {'users': User.query.count(), 'categories': Category.query.count(), 'audit_logs': AuditLog.query.count(), 'admins': User.query.filter_by(role='admin').count()} + upload_dir = Path(current_app.root_path) / 'static' / 'uploads' + preview_dir = Path(current_app.root_path) / 'static' / 'previews' + upload_count = len(list(upload_dir.glob('*'))) if upload_dir.exists() else 0 + preview_count = len(list(preview_dir.glob('*'))) if preview_dir.exists() else 0 + system = {'python': platform.python_version(), 'platform': platform.platform(), 'flask_env': current_app.config['ENV_NAME'], 'instance_path': current_app.instance_path, 'max_upload_mb': current_app.config['MAX_CONTENT_LENGTH'] // 1024 // 1024, 'upload_count': upload_count, 'preview_count': preview_count, 'webhook_enabled': bool(AppSetting.get('webhook_api_token', '')), 'scheduler_enabled': AppSetting.get('report_scheduler_enabled', 'false') == 'true'} + return render_template('admin/dashboard.html', stats=stats, db_info=db_info, db_version=version, system=system, recent_logs=AuditLog.query.order_by(AuditLog.created_at.desc()).limit(20).all()) + + +@admin_bp.route('/audit') +@login_required +@admin_required +def audit(): + logs = AuditLog.query.order_by(AuditLog.created_at.desc()).limit(200).all() + return render_template('admin/audit.html', logs=logs) + + +@admin_bp.route('/categories', methods=['GET', 'POST']) +@login_required +@admin_required +def categories(): + form = CategoryForm() + if form.validate_on_submit(): + existing = Category.query.filter(Category.user_id.is_(None), Category.key == form.key.data.strip().lower()).first() + category = existing or Category(user_id=None, key=form.key.data.strip().lower(), name=form.name_en.data.strip()) + if not existing: + db.session.add(category) + category.key = form.key.data.strip().lower() + category.name = form.name_en.data.strip() + category.name_pl = form.name_pl.data.strip() + category.name_en = form.name_en.data.strip() + category.color = form.color.data + category.is_active = form.is_active.data + db.session.commit() + log_action('category_saved', 'category', category.id, key=category.key) + flash(_('flash.category_saved'), 'success') + return redirect(url_for('admin.categories')) + return render_template('admin/categories.html', form=form, categories=Category.query.filter(Category.user_id.is_(None)).order_by(Category.name_pl).all()) + + +@admin_bp.route('/users', methods=['GET', 'POST']) +@login_required +@admin_required +def users(): + form = UserAdminForm() + form.role.choices = [('user', _('user.role_user')), ('admin', _('user.role_admin'))] + form.language.choices = [('pl', _('language.polish')), ('en', _('language.english'))] + form.report_frequency.choices = [('off', _('report.off')), ('daily', _('report.daily')), ('weekly', _('report.weekly')), ('monthly', _('report.monthly'))] + form.theme.choices = [('light', _('theme.light')), ('dark', _('theme.dark'))] + edit_user_id = request.args.get('edit', type=int) + editing_user = db.session.get(User, edit_user_id) if edit_user_id else None + if request.method == 'GET' and editing_user: + form.full_name.data = editing_user.full_name + form.email.data = editing_user.email + form.role.data = editing_user.role + form.language.data = editing_user.language + form.report_frequency.data = editing_user.report_frequency + form.theme.data = editing_user.theme + form.is_active_user.data = editing_user.is_active_user + form.must_change_password.data = editing_user.must_change_password + if form.validate_on_submit(): + if edit_user_id: + user = db.session.get(User, edit_user_id) + if not user: + flash(_('error.404_title'), 'danger') + return redirect(url_for('admin.users')) + duplicate = User.query.filter(User.email == form.email.data.lower(), User.id != user.id).first() + if duplicate: + flash(_('flash.user_exists'), 'danger') + else: + user.full_name = form.full_name.data + user.email = form.email.data.lower() + user.role = form.role.data + user.language = form.language.data + user.report_frequency = form.report_frequency.data or 'off' + user.theme = form.theme.data or 'light' + user.is_active_user = form.is_active_user.data + user.must_change_password = form.must_change_password.data + db.session.commit() + log_action('user_updated', 'user', user.id, email=user.email) + flash(_('flash.user_updated'), 'success') + return redirect(url_for('admin.users')) + else: + if User.query.filter_by(email=form.email.data.lower()).first(): + flash(_('flash.user_exists'), 'danger') + else: + temp_password = token_urlsafe(8) + user = User(full_name=form.full_name.data, email=form.email.data.lower(), role=form.role.data, language=form.language.data, report_frequency=form.report_frequency.data or 'off', theme=form.theme.data or 'light', is_active_user=form.is_active_user.data, must_change_password=form.must_change_password.data) + user.set_password(temp_password) + db.session.add(user) + db.session.commit() + MailService().send_template(user.email, 'Your new account', 'new_account', user=user, temp_password=temp_password) + log_action('user_created', 'user', user.id, email=user.email) + flash(_('flash.user_created'), 'success') + return redirect(url_for('admin.users')) + users_list = User.query.order_by(User.created_at.desc()).all() + return render_template('admin/users.html', form=form, users=users_list, editing_user=editing_user) + + +@admin_bp.route('/users//toggle-password-change', methods=['POST']) +@login_required +@admin_required +def toggle_password_change(user_id: int): + user = db.session.get(User, user_id) + if user is None: + return redirect(url_for('admin.users')) + user.must_change_password = not user.must_change_password + db.session.commit() + log_action('user_toggle_password_change', 'user', user.id, must_change_password=user.must_change_password) + flash(_('flash.user_flag_updated'), 'success') + return redirect(url_for('admin.users')) + + +@admin_bp.route('/settings', methods=['GET', 'POST']) +@login_required +@admin_required +def settings(): + if request.method == 'POST': + pairs = { + 'registration_enabled': 'true' if request.form.get('registration_enabled') else 'false', + 'max_upload_mb': request.form.get('max_upload_mb', '10'), + 'smtp_host': request.form.get('smtp_host', ''), + 'smtp_port': request.form.get('smtp_port', '465'), + 'smtp_username': request.form.get('smtp_username', ''), + 'smtp_password': request.form.get('smtp_password', ''), + 'smtp_sender': request.form.get('smtp_sender', 'no-reply@example.com'), + 'smtp_security': request.form.get('smtp_security', 'ssl'), + 'company_name': request.form.get('company_name', 'Expense Monitor'), + 'webhook_api_token': request.form.get('webhook_api_token', ''), + 'reports_enabled': 'true' if request.form.get('reports_enabled') else 'false', + 'report_scheduler_enabled': 'true' if request.form.get('report_scheduler_enabled') else 'false', + } + for key, value in pairs.items(): + AppSetting.set(key, value) + db.session.commit() + log_action('settings_saved', 'settings') + flash(_('flash.settings_saved'), 'success') + return redirect(url_for('admin.settings')) + values = {setting.key: setting.value for setting in AppSetting.query.order_by(AppSetting.key).all()} + if 'smtp_security' not in values: + values['smtp_security'] = 'ssl' + values.setdefault('reports_enabled', 'true') + return render_template('admin/settings.html', values=values) + + +@admin_bp.route('/run-reports', methods=['POST']) +@login_required +@admin_required +def run_reports(): + sent = send_due_reports() + log_action('reports_sent_manual', 'reports', 'manual', sent=sent) + flash(f'Queued/sent reports: {sent}', 'success') + return redirect(url_for('admin.dashboard')) diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/routes.py b/app/api/routes.py new file mode 100644 index 0000000..9b1aaa3 --- /dev/null +++ b/app/api/routes.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from datetime import date +from decimal import Decimal + +from flask import Blueprint, abort, jsonify, request + +from ..extensions import db +from ..models import AppSetting, Category, Expense, User +from ..services.audit import log_action + +api_bp = Blueprint('api', __name__, url_prefix='/api') + + +def _require_webhook_token() -> None: + token = (request.headers.get('X-Webhook-Token') or '').strip() + expected = AppSetting.get('webhook_api_token', '') or '' + if not expected or token != expected: + abort(403) + + +@api_bp.route('/webhooks/expenses', methods=['POST']) +def webhook_expenses(): + _require_webhook_token() + payload = request.get_json(silent=True) or {} + if not payload: + abort(400) + email = (payload.get('user_email') or '').strip().lower() + user = User.query.filter_by(email=email, is_active_user=True).first() + if not user: + abort(404) + + category = None + if payload.get('category_key'): + category = Category.query.filter_by(key=str(payload['category_key']).strip().lower(), is_active=True).first() + + amount = Decimal(str(payload.get('amount', '0'))) + expense = Expense( + user_id=user.id, + title=(payload.get('title') or payload.get('vendor') or 'Webhook expense')[:255], + vendor=(payload.get('vendor') or '')[:255], + description=(payload.get('description') or '')[:2000], + amount=amount, + currency=(payload.get('currency') or user.default_currency or 'PLN')[:10], + purchase_date=date.fromisoformat(payload.get('purchase_date') or date.today().isoformat()), + payment_method=(payload.get('payment_method') or 'card')[:20], + tags=(payload.get('tags') or '')[:255], + recurring_period=(payload.get('recurring_period') or 'none')[:20], + status=(payload.get('status') or 'confirmed')[:20], + is_business=bool(payload.get('is_business')), + is_refund=bool(payload.get('is_refund')), + category_id=category.id if category else None, + ocr_status='webhook', + ) + db.session.add(expense) + db.session.commit() + log_action('expense_webhook_created', 'expense', expense.id, user_email=user.email) + db.session.commit() + return jsonify({'status': 'ok', 'expense_id': expense.id}) diff --git a/app/auth/__init__.py b/app/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth/routes.py b/app/auth/routes.py new file mode 100644 index 0000000..48eb7b7 --- /dev/null +++ b/app/auth/routes.py @@ -0,0 +1,106 @@ + +from __future__ import annotations + +from flask import Blueprint, current_app, flash, redirect, render_template, url_for +from flask_login import current_user, login_required, login_user, logout_user + +from ..extensions import db, limiter +from ..forms import LoginForm, PasswordResetForm, RegistrationForm, ResetRequestForm +from ..models import PasswordResetToken, User +from ..services.i18n import translate as _ +from ..services.mail import MailService +from ..services.settings import get_bool_setting + +auth_bp = Blueprint('auth', __name__) + + +@auth_bp.route('/login', methods=['GET', 'POST']) +@limiter.limit('5 per minute') +def login(): + if current_user.is_authenticated: + return redirect(url_for('main.dashboard')) + form = LoginForm() + if form.validate_on_submit(): + if form.website.data: + flash(_('flash.suspicious_request'), 'danger') + return redirect(url_for('auth.login')) + user = User.query.filter_by(email=form.email.data.lower()).first() + if user and user.check_password(form.password.data) and user.is_active_user: + login_user(user, remember=form.remember_me.data) + flash(_('flash.login_success'), 'success') + return redirect(url_for('main.dashboard')) + flash(_('flash.invalid_credentials'), 'danger') + return render_template('auth/login.html', form=form) + + +@auth_bp.route('/register', methods=['GET', 'POST']) +def register(): + if not get_bool_setting('registration_enabled', current_app.config['REGISTRATION_ENABLED']): + flash(_('flash.registration_disabled'), 'warning') + return redirect(url_for('auth.login')) + form = RegistrationForm() + if form.validate_on_submit(): + if form.website.data: + flash(_('flash.suspicious_request'), 'danger') + return redirect(url_for('auth.register')) + if User.query.filter_by(email=form.email.data.lower()).first(): + flash(_('flash.email_exists'), 'danger') + else: + user = User( + email=form.email.data.lower(), + full_name=form.full_name.data, + language=current_app.config['DEFAULT_LANGUAGE'], + must_change_password=False, + ) + user.set_password(form.password.data) + db.session.add(user) + db.session.commit() + flash(_('flash.account_created'), 'success') + return redirect(url_for('auth.login')) + return render_template('auth/register.html', form=form) + + +@auth_bp.route('/logout') +@login_required +def logout(): + logout_user() + flash(_('flash.logged_out'), 'success') + return redirect(url_for('auth.login')) + + +@auth_bp.route('/forgot-password', methods=['GET', 'POST']) +@limiter.limit('3 per minute') +def forgot_password(): + form = ResetRequestForm() + if form.validate_on_submit(): + if form.website.data: + flash(_('flash.suspicious_request'), 'danger') + return redirect(url_for('auth.forgot_password')) + user = User.query.filter_by(email=form.email.data.lower()).first() + if user: + reset = PasswordResetToken.issue(user) + db.session.add(reset) + db.session.commit() + reset_link = url_for('auth.reset_password', token=reset.token, _external=True) + MailService().send_template(user.email, 'Password reset', 'password_reset', reset_link=reset_link, user=user) + current_app.logger.info('Reset link for %s: %s', user.email, reset_link) + flash(_('flash.reset_link_generated'), 'info') + return redirect(url_for('auth.login')) + return render_template('auth/forgot_password.html', form=form) + + +@auth_bp.route('/reset-password/', methods=['GET', 'POST']) +def reset_password(token: str): + reset_entry = PasswordResetToken.query.filter_by(token=token).first_or_404() + if not reset_entry.is_valid(): + flash(_('flash.reset_invalid'), 'danger') + return redirect(url_for('auth.login')) + form = PasswordResetForm() + if form.validate_on_submit(): + reset_entry.user.set_password(form.password.data) + reset_entry.user.must_change_password = False + reset_entry.used_at = db.func.now() + db.session.commit() + flash(_('flash.password_updated'), 'success') + return redirect(url_for('auth.login')) + return render_template('auth/reset_password.html', form=form) diff --git a/app/cli/__init__.py b/app/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/cli/commands.py b/app/cli/commands.py new file mode 100644 index 0000000..e20152c --- /dev/null +++ b/app/cli/commands.py @@ -0,0 +1,81 @@ +import click +from flask.cli import with_appcontext + +from ..extensions import db +from ..models import User, seed_categories +from ..services.reporting import send_due_reports + + +@click.command('create-user') +@click.option('--email', prompt=True) +@click.option('--name', prompt=True) +@click.option('--password', prompt=True, hide_input=True, confirmation_prompt=True) +@click.option('--admin', is_flag=True, default=False) +@with_appcontext +def create_user(email, name, password, admin): + user = User(email=email.lower(), full_name=name, role='admin' if admin else 'user', must_change_password=False) + user.set_password(password) + db.session.add(user) + db.session.commit() + click.echo(f'Created user {email}') + + +@click.command('reset-password') +@click.option('--email', prompt=True) +@click.option('--password', prompt=True, hide_input=True, confirmation_prompt=True) +@with_appcontext +def reset_password(email, password): + user = User.query.filter_by(email=email.lower()).first() + if not user: + raise click.ClickException('User not found') + user.set_password(password) + user.must_change_password = True + db.session.commit() + click.echo(f'Password reset for {email}') + + +@click.command('make-admin') +@click.option('--email', prompt=True) +@with_appcontext +def make_admin(email): + user = User.query.filter_by(email=email.lower()).first() + if not user: + raise click.ClickException('User not found') + user.role = 'admin' + db.session.commit() + click.echo(f'Granted admin to {email}') + + +@click.command('deactivate-user') +@click.option('--email', prompt=True) +@with_appcontext +def deactivate_user(email): + user = User.query.filter_by(email=email.lower()).first() + if not user: + raise click.ClickException('User not found') + user.is_active_user = False + db.session.commit() + click.echo(f'Deactivated {email}') + + +@click.command('send-reports') +@with_appcontext +def send_reports_command(): + count = send_due_reports() + click.echo(f'Sent {count} reports') + + +@click.command('seed-categories') +@with_appcontext +def seed_categories_command(): + seed_categories() + click.echo('Categories seeded') + + +def register_commands(app): + app.cli.add_command(create_user) + app.cli.add_command(reset_password) + app.cli.add_command(make_admin) + app.cli.add_command(deactivate_user) + app.cli.add_command(send_reports_command) + app.cli.add_command(seed_categories_command) diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..f5439ba --- /dev/null +++ b/app/config.py @@ -0,0 +1,40 @@ +import os +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent + + +class Config: + ENV_NAME = os.getenv('FLASK_ENV', 'development') + SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key') + SQLALCHEMY_DATABASE_URI = os.getenv('DATABASE_URL', f"sqlite:///{BASE_DIR / 'instance' / 'expense_monitor.db'}") + SQLALCHEMY_TRACK_MODIFICATIONS = False + APP_HOST = os.getenv('APP_HOST', '127.0.0.1') + APP_PORT = int(os.getenv('APP_PORT', '5000')) + DEFAULT_MAX_UPLOAD_MB = int(os.getenv('MAX_CONTENT_LENGTH_MB', '10')) + MAX_CONTENT_LENGTH = DEFAULT_MAX_UPLOAD_MB * 1024 * 1024 + REGISTRATION_ENABLED = os.getenv('REGISTRATION_ENABLED', 'false').lower() == 'true' + MAIL_SERVER = os.getenv('MAIL_SERVER', '') + MAIL_PORT = int(os.getenv('MAIL_PORT', '465')) + MAIL_USE_TLS = os.getenv('MAIL_USE_TLS', 'false').lower() == 'true' + MAIL_USE_SSL = os.getenv('MAIL_USE_SSL', 'true').lower() == 'true' + MAIL_USERNAME = os.getenv('MAIL_USERNAME', '') + MAIL_PASSWORD = os.getenv('MAIL_PASSWORD', '') + MAIL_DEFAULT_SENDER = os.getenv('MAIL_DEFAULT_SENDER', 'no-reply@example.com') + REMEMBER_COOKIE_HTTPONLY = True + REMEMBER_COOKIE_SAMESITE = 'Lax' + SESSION_COOKIE_HTTPONLY = True + SESSION_COOKIE_SAMESITE = 'Lax' + SESSION_COOKIE_SECURE = os.getenv('FLASK_ENV', 'development') != 'development' + PREFERRED_URL_SCHEME = 'https' if SESSION_COOKIE_SECURE else 'http' + LANGUAGES = ['pl', 'en'] + DEFAULT_LANGUAGE = 'pl' + UPLOAD_EXTENSIONS = {'png', 'jpg', 'jpeg', 'heic', 'pdf'} + + +class TestConfig(Config): + TESTING = True + WTF_CSRF_ENABLED = False + SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:' + SESSION_COOKIE_SECURE = False + LOGIN_DISABLED = False diff --git a/app/expenses/__init__.py b/app/expenses/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/expenses/routes.py b/app/expenses/routes.py new file mode 100644 index 0000000..09e3b3c --- /dev/null +++ b/app/expenses/routes.py @@ -0,0 +1,330 @@ +from __future__ import annotations + +from datetime import date +from decimal import Decimal +from io import BytesIO +from pathlib import Path + +from flask import Blueprint, Response, current_app, flash, redirect, render_template, request, send_file, url_for +from flask_login import current_user, login_required +from sqlalchemy import asc, desc, or_ + +from ..extensions import db +from ..forms import BudgetForm, ExpenseForm +from ..models import Budget, Category, DocumentAttachment, Expense +from ..services.audit import log_action +from ..services.categorization import suggest_category_id +from ..services.export import export_expenses_csv, export_expenses_pdf +from ..services.files import allowed_file, save_document +from ..services.i18n import get_locale, translate as _ +from ..services.ocr import OCRService + +expenses_bp = Blueprint('expenses', __name__) + + +def _category_label(category: Category) -> str: + return category.localized_name(get_locale()) + + +def populate_category_choices(form) -> None: + form.category_id.choices = [(0, '---')] + [ + (category.id, _category_label(category)) + for category in Category.query.filter_by(is_active=True).order_by(Category.name_pl).all() + ] + + +@expenses_bp.route('/') +@login_required +def list_expenses(): + year = request.args.get('year', date.today().year, type=int) + month = request.args.get('month', date.today().month, type=int) + category_id = request.args.get('category_id', type=int) + payment_method = request.args.get('payment_method', '', type=str) + q = (request.args.get('q', '') or '').strip() + status = request.args.get('status', '', type=str) + sort_by = request.args.get('sort_by', 'purchase_date', type=str) + sort_dir = request.args.get('sort_dir', 'desc', type=str) + group_by = request.args.get('group_by', 'category', type=str) + + expenses_query = Expense.query.filter_by(user_id=current_user.id, is_deleted=False).filter( + Expense.purchase_date >= date(year, month, 1), + Expense.purchase_date < (date(year + (month == 12), 1 if month == 12 else month + 1, 1)), + ) + if category_id: + expenses_query = expenses_query.filter(Expense.category_id == category_id) + if payment_method: + expenses_query = expenses_query.filter(Expense.payment_method == payment_method) + if status: + expenses_query = expenses_query.filter(Expense.status == status) + if q: + like = f'%{q}%' + expenses_query = expenses_query.filter(or_(Expense.title.ilike(like), Expense.vendor.ilike(like), Expense.description.ilike(like), Expense.tags.ilike(like))) + + filtered = _apply_expense_sort(expenses_query, sort_by, sort_dir).all() + grouped_expenses = _group_expenses(filtered, group_by) + month_total = sum((expense.amount or Decimal('0')) for expense in filtered) + budgets = Budget.query.filter_by(user_id=current_user.id, year=year, month=month).all() + categories = Category.query.filter_by(is_active=True).order_by(Category.name_pl).all() + filters = { + 'category_id': category_id or 0, + 'payment_method': payment_method, + 'q': q, + 'status': status, + 'sort_by': sort_by, + 'sort_dir': sort_dir, + 'group_by': group_by, + } + sort_options = [ + ('purchase_date', _('expenses.date')), + ('amount', _('expenses.amount')), + ('title', _('expenses.title')), + ('vendor', _('expenses.vendor')), + ('category', _('expenses.category')), + ('payment_method', _('expenses.payment_method')), + ('status', _('expenses.status')), + ('created_at', _('expenses.added')), + ] + return render_template( + 'expenses/list.html', + expenses=filtered, + grouped_expenses=grouped_expenses, + budgets=budgets, + selected_year=year, + selected_month=month, + filters=filters, + categories=categories, + month_total=month_total, + sort_options=sort_options, + ) + + +@expenses_bp.route('/create', methods=['GET', 'POST']) +@login_required +def create_expense(): + form = ExpenseForm() + populate_category_choices(form) + if request.method == 'GET' and not form.purchase_date.data: + form.purchase_date.data = date.today() + form.currency.data = current_user.default_currency + if form.validate_on_submit(): + expense = Expense(user_id=current_user.id) + _fill_expense_from_form(expense, form) + if not expense.title: + expense.title = expense.vendor or 'Expense' + db.session.add(expense) + db.session.flush() + _handle_uploaded_documents(expense, form) + log_action('expense_created', 'expense', expense.id, title=expense.title, amount=str(expense.amount)) + db.session.commit() + flash(_('flash.expense_saved'), 'success') + return redirect(url_for('expenses.list_expenses')) + return render_template('expenses/create.html', form=form, expense=None) + + +@expenses_bp.route('//edit', methods=['GET', 'POST']) +@login_required +def edit_expense(expense_id: int): + expense = Expense.query.filter_by(id=expense_id, user_id=current_user.id, is_deleted=False).first_or_404() + form = ExpenseForm(obj=expense) + populate_category_choices(form) + if request.method == 'GET' and expense.category_id: + form.category_id.data = expense.category_id + if form.validate_on_submit(): + _fill_expense_from_form(expense, form) + db.session.flush() + _handle_uploaded_documents(expense, form) + db.session.commit() + log_action('expense_updated', 'expense', expense.id, title=expense.title, amount=str(expense.amount)) + flash(_('flash.expense_updated'), 'success') + return redirect(url_for('expenses.list_expenses')) + return render_template('expenses/create.html', form=form, expense=expense) + + +@expenses_bp.route('//delete', methods=['POST']) +@login_required +def delete_expense(expense_id: int): + expense = Expense.query.filter_by(id=expense_id, user_id=current_user.id).first_or_404() + expense.is_deleted = True + db.session.commit() + log_action('expense_deleted', 'expense', expense.id) + flash(_('flash.expense_deleted'), 'success') + return redirect(url_for('expenses.list_expenses')) + + +@expenses_bp.route('/budgets', methods=['GET', 'POST']) +@login_required +def budgets(): + form = BudgetForm() + populate_category_choices(form) + if request.method == 'GET': + today = date.today() + form.year.data = today.year + form.month.data = today.month + if form.validate_on_submit(): + budget = Budget.query.filter_by( + user_id=current_user.id, + category_id=form.category_id.data, + year=form.year.data, + month=form.month.data, + ).first() + if not budget: + budget = Budget(user_id=current_user.id, category_id=form.category_id.data, year=form.year.data, month=form.month.data) + db.session.add(budget) + budget.amount = Decimal(str(form.amount.data)) + budget.alert_percent = form.alert_percent.data + db.session.commit() + log_action('budget_saved', 'budget', budget.id, amount=str(budget.amount)) + flash(_('flash.budget_saved'), 'success') + return redirect(url_for('expenses.budgets')) + items = Budget.query.filter_by(user_id=current_user.id).order_by(Budget.year.desc(), Budget.month.desc()).all() + return render_template('expenses/budgets.html', form=form, budgets=items) + + +@expenses_bp.route('/export.csv') +@login_required +def export_csv(): + expenses = _filtered_export_query().order_by(Expense.purchase_date.desc()).all() + content = export_expenses_csv(expenses) + return Response(content, mimetype='text/csv', headers={'Content-Disposition': 'attachment; filename=expenses.csv'}) + + +@expenses_bp.route('/export.pdf') +@login_required +def export_pdf(): + expenses = _filtered_export_query().order_by(Expense.purchase_date.desc()).all() + data = export_expenses_pdf(expenses, title='Expense export') + return send_file(BytesIO(data), mimetype='application/pdf', as_attachment=True, download_name='expenses.pdf') + + + + +def _apply_expense_sort(query, sort_by: str, sort_dir: str): + descending = sort_dir != 'asc' + direction = desc if descending else asc + if sort_by == 'amount': + order = direction(Expense.amount) + elif sort_by == 'title': + order = direction(Expense.title) + elif sort_by == 'vendor': + order = direction(Expense.vendor) + elif sort_by == 'payment_method': + order = direction(Expense.payment_method) + elif sort_by == 'status': + order = direction(Expense.status) + elif sort_by == 'created_at': + order = direction(Expense.created_at) + elif sort_by == 'category': + query = query.outerjoin(Category) + order = direction(Category.name_pl) + else: + order = direction(Expense.purchase_date) + return query.order_by(order, desc(Expense.id)) + + +def _group_expenses(expenses: list[Expense], group_by: str) -> list[dict]: + if group_by == 'none': + return [{'key': 'all', 'label': _('expenses.all_expenses'), 'items': expenses, 'total': sum((expense.amount or Decimal('0')) for expense in expenses)}] + + groups: dict[str, dict] = {} + for expense in expenses: + if group_by == 'payment_method': + key = expense.payment_method or 'other' + label = expense.payment_method.title() if expense.payment_method else _('common.other') + elif group_by == 'status': + key = expense.status or 'unknown' + label = expense.status.replace('_', ' ').title() if expense.status else _('common.other') + else: + key = str(expense.category_id or 0) + label = expense.category.localized_name(get_locale()) if expense.category else _('common.uncategorized') + bucket = groups.setdefault(key, {'key': key, 'label': label, 'items': [], 'total': Decimal('0')}) + bucket['items'].append(expense) + bucket['total'] += expense.amount or Decimal('0') + return sorted(groups.values(), key=lambda item: (-item['total'], item['label'])) + +def _filtered_export_query(): + query = Expense.query.filter_by(user_id=current_user.id, is_deleted=False) + year = request.args.get('year', type=int) + month = request.args.get('month', type=int) + category_id = request.args.get('category_id', type=int) + payment_method = request.args.get('payment_method', '', type=str) + q = (request.args.get('q', '') or '').strip() + status = request.args.get('status', '', type=str) + if year: + query = query.filter(Expense.purchase_date >= date(year, month or 1, 1)) + if month: + query = query.filter(Expense.purchase_date < date(year + (month == 12), 1 if month == 12 else month + 1, 1)) + else: + query = query.filter(Expense.purchase_date < date(year + 1, 1, 1)) + if category_id: + query = query.filter(Expense.category_id == category_id) + if payment_method: + query = query.filter(Expense.payment_method == payment_method) + if status: + query = query.filter(Expense.status == status) + if q: + like = f'%{q}%' + query = query.filter(or_(Expense.title.ilike(like), Expense.vendor.ilike(like), Expense.description.ilike(like), Expense.tags.ilike(like))) + return query + + + +def _fill_expense_from_form(expense: Expense, form: ExpenseForm) -> None: + expense.title = form.title.data or '' + expense.vendor = form.vendor.data or '' + expense.description = form.description.data or '' + expense.amount = Decimal(str(form.amount.data)) + expense.currency = form.currency.data + expense.purchase_date = form.purchase_date.data + expense.payment_method = form.payment_method.data + expense.category_id = form.category_id.data or None + expense.is_refund = form.is_refund.data + expense.is_business = form.is_business.data + expense.tags = form.tags.data or '' + expense.recurring_period = form.recurring_period.data + expense.status = form.status.data + if not expense.category_id: + expense.category_id = suggest_category_id(current_user.id, expense.vendor, expense.title) + + +def _handle_uploaded_documents(expense: Expense, form: ExpenseForm) -> None: + files = [item for item in request.files.getlist(form.document.name) if item and item.filename] + if not files: + return + upload_dir = Path(current_app.root_path) / 'static' / 'uploads' + preview_dir = Path(current_app.root_path) / 'static' / 'previews' + crop_box = None + if all([form.crop_x.data, form.crop_y.data, form.crop_w.data, form.crop_h.data]): + crop_box = tuple(int(float(v)) for v in [form.crop_x.data, form.crop_y.data, form.crop_w.data, form.crop_h.data]) + ocr_service = OCRService() + for index, uploaded in enumerate(files): + if not allowed_file(uploaded.filename, current_app.config['UPLOAD_EXTENSIONS']): + continue + filename, preview = save_document( + uploaded, + upload_dir, + preview_dir, + rotate=form.rotate.data or 0, + crop_box=crop_box, + scale_percent=form.scale_percent.data or 100, + ) + attachment = DocumentAttachment( + expense_id=expense.id, + original_filename=uploaded.filename, + stored_filename=filename, + preview_filename=preview, + mime_type=uploaded.mimetype or '', + sort_order=index, + ) + db.session.add(attachment) + if index == 0: + expense.document_filename = filename + expense.preview_filename = preview + ocr_data = ocr_service.extract(upload_dir / filename) + expense.ocr_status = ocr_data.status + if not (form.title.data or '').strip(): + expense.title = ocr_data.get('title') or expense.title or expense.vendor or 'Expense' + expense.vendor = ocr_data.get('vendor') or expense.vendor + if ocr_data.get('amount'): + expense.amount = Decimal(ocr_data['amount']) + if not expense.category_id: + expense.category_id = suggest_category_id(current_user.id, expense.vendor, expense.title) diff --git a/app/extensions.py b/app/extensions.py new file mode 100644 index 0000000..a3cd101 --- /dev/null +++ b/app/extensions.py @@ -0,0 +1,12 @@ +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address +from flask_login import LoginManager +from flask_migrate import Migrate +from flask_sqlalchemy import SQLAlchemy + + +db = SQLAlchemy() +migrate = Migrate() +login_manager = LoginManager() +login_manager.login_view = 'auth.login' +limiter = Limiter(key_func=get_remote_address, default_limits=['200 per day', '50 per hour']) diff --git a/app/forms.py b/app/forms.py new file mode 100644 index 0000000..c427a9c --- /dev/null +++ b/app/forms.py @@ -0,0 +1,107 @@ +from flask_wtf import FlaskForm +from flask_wtf.file import FileAllowed, FileField +from wtforms import BooleanField, DecimalField, EmailField, HiddenField, IntegerField, PasswordField, SelectField, StringField, SubmitField, TextAreaField +from wtforms.fields import DateField +from wtforms.validators import DataRequired, Email, EqualTo, Length, NumberRange, Optional + + +class HoneypotMixin: + website = StringField('Website', validators=[Optional()]) + + +class LoginForm(HoneypotMixin, FlaskForm): + email = EmailField('Email', validators=[DataRequired(), Email()]) + password = PasswordField('Password', validators=[DataRequired()]) + remember_me = BooleanField('Remember me') + submit = SubmitField('Login') + + +class RegistrationForm(HoneypotMixin, FlaskForm): + full_name = StringField('Full name', validators=[DataRequired(), Length(max=120)]) + email = EmailField('Email', validators=[DataRequired(), Email()]) + password = PasswordField('Password', validators=[DataRequired(), Length(min=8)]) + confirm_password = PasswordField('Confirm password', validators=[DataRequired(), EqualTo('password')]) + submit = SubmitField('Create account') + + +class ExpenseForm(FlaskForm): + title = StringField('Title', validators=[Optional(), Length(max=255)]) + amount = DecimalField('Amount', validators=[DataRequired(), NumberRange(min=0)], places=2) + purchase_date = DateField('Purchase date', validators=[DataRequired()]) + category_id = SelectField('Category', coerce=int, validators=[Optional()]) + payment_method = SelectField('Payment method', choices=[('card', 'Card'), ('cash', 'Cash'), ('transfer', 'Transfer'), ('blik', 'BLIK')], validators=[DataRequired()]) + document = FileField('Document', validators=[Optional(), FileAllowed(['jpg', 'jpeg', 'png', 'heic', 'pdf'])], render_kw={'multiple': True}) + + vendor = StringField('Vendor', validators=[Optional(), Length(max=255)]) + description = TextAreaField('Description', validators=[Optional(), Length(max=2000)]) + currency = SelectField('Currency', choices=[('PLN', 'PLN'), ('EUR', 'EUR'), ('USD', 'USD')], validators=[DataRequired()]) + tags = StringField('Tags', validators=[Optional(), Length(max=255)]) + recurring_period = SelectField('Recurring', choices=[('none', 'None'), ('monthly', 'Monthly'), ('yearly', 'Yearly')], validators=[DataRequired()]) + status = SelectField('Status', choices=[('new', 'New'), ('needs_review', 'Needs review'), ('confirmed', 'Confirmed')], validators=[DataRequired()]) + is_refund = BooleanField('Refund') + is_business = BooleanField('Business') + + rotate = IntegerField('Rotate', validators=[Optional()], default=0) + crop_x = HiddenField('Crop X', default='') + crop_y = HiddenField('Crop Y', default='') + crop_w = HiddenField('Crop W', default='') + crop_h = HiddenField('Crop H', default='') + scale_percent = IntegerField('Scale', validators=[Optional()], default=100) + submit = SubmitField('Save expense') + + +class CategoryForm(FlaskForm): + key = StringField('Key', validators=[DataRequired(), Length(max=80)]) + name_pl = StringField('Polish name', validators=[DataRequired(), Length(max=120)]) + name_en = StringField('English name', validators=[DataRequired(), Length(max=120)]) + color = SelectField('Color', choices=[('primary', 'Primary'), ('secondary', 'Secondary'), ('success', 'Success'), ('danger', 'Danger'), ('warning', 'Warning'), ('info', 'Info')], validators=[DataRequired()]) + is_active = BooleanField('Active') + submit = SubmitField('Save category') + + +class UserAdminForm(FlaskForm): + full_name = StringField('Full name', validators=[DataRequired(), Length(max=120)]) + email = EmailField('Email', validators=[DataRequired(), Email()]) + role = SelectField('Role', choices=[('user', 'User'), ('admin', 'Admin')], validators=[DataRequired()]) + language = SelectField('Language', choices=[('pl', 'Polish'), ('en', 'English')], validators=[DataRequired()]) + report_frequency = SelectField('Reports', choices=[('off', 'Off'), ('daily', 'Daily'), ('weekly', 'Weekly'), ('monthly', 'Monthly')], default='off', validators=[Optional()]) + theme = SelectField('Theme', choices=[('light', 'Light'), ('dark', 'Dark')], default='light', validators=[Optional()]) + is_active_user = BooleanField('Active user', default=True) + must_change_password = BooleanField('Must change password') + submit = SubmitField('Save user') + + +class ResetRequestForm(HoneypotMixin, FlaskForm): + email = EmailField('Email', validators=[DataRequired(), Email()]) + submit = SubmitField('Send reset link') + + +class PasswordResetForm(FlaskForm): + password = PasswordField('Password', validators=[DataRequired(), Length(min=8)]) + confirm_password = PasswordField('Confirm password', validators=[DataRequired(), EqualTo('password')]) + submit = SubmitField('Reset password') + + +class PreferencesForm(FlaskForm): + language = SelectField('Language', choices=[('pl', 'Polski'), ('en', 'English')], validators=[DataRequired()]) + theme = SelectField('Theme', choices=[('light', 'Light'), ('dark', 'Dark')], validators=[DataRequired()]) + report_frequency = SelectField('Reports', choices=[('off', 'Off'), ('daily', 'Daily'), ('weekly', 'Weekly'), ('monthly', 'Monthly')], validators=[DataRequired()]) + default_currency = SelectField('Currency', choices=[('PLN', 'PLN'), ('EUR', 'EUR'), ('USD', 'USD')], validators=[DataRequired()]) + submit = SubmitField('Save preferences') + + +class BudgetForm(FlaskForm): + category_id = SelectField('Category', coerce=int, validators=[DataRequired()]) + year = IntegerField('Year', validators=[DataRequired(), NumberRange(min=2000, max=2200)]) + month = IntegerField('Month', validators=[DataRequired(), NumberRange(min=1, max=12)]) + amount = DecimalField('Amount', validators=[DataRequired(), NumberRange(min=0)], places=2) + alert_percent = IntegerField('Alert percent', validators=[DataRequired(), NumberRange(min=1, max=200)], default=80) + submit = SubmitField('Save budget') + + +class UserCategoryForm(FlaskForm): + key = StringField('Key', validators=[DataRequired(), Length(max=80)]) + name_pl = StringField('Polish name', validators=[DataRequired(), Length(max=120)]) + name_en = StringField('English name', validators=[DataRequired(), Length(max=120)]) + color = SelectField('Color', choices=[('primary', 'Primary'), ('secondary', 'Secondary'), ('success', 'Success'), ('danger', 'Danger'), ('warning', 'Warning'), ('info', 'Info')], validators=[DataRequired()]) + submit = SubmitField('Save category') diff --git a/app/main/__init__.py b/app/main/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/main/routes.py b/app/main/routes.py new file mode 100644 index 0000000..7c44caf --- /dev/null +++ b/app/main/routes.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +from datetime import date + +from flask import Blueprint, jsonify, redirect, render_template, request, session, url_for +from flask_login import current_user, login_required + +from ..extensions import db +from ..forms import PreferencesForm, UserCategoryForm +from ..models import Category +from ..services.analytics import ( + compare_years, + daily_totals, + monthly_summary, + payment_method_totals, + quarterly_totals, + range_totals, + top_expenses, + weekday_totals, + yearly_category_totals, + yearly_overview, + yearly_totals, +) +from ..services.audit import log_action +from ..services.i18n import translate as _ +from ..services.settings import get_bool_setting + + +main_bp = Blueprint('main', __name__) + + +@main_bp.route('/') +def index(): + if current_user.is_authenticated: + return redirect(url_for('main.dashboard')) + return redirect(url_for('auth.login')) + + +@main_bp.post('/language') +def set_language(): + lang = request.form.get('language', 'pl') + if lang not in ['pl', 'en']: + lang = 'pl' + session['language'] = lang + if current_user.is_authenticated: + current_user.language = lang + db.session.commit() + return redirect(request.form.get('next') or request.referrer or url_for('main.index')) + + +@main_bp.route('/dashboard') +@login_required +def dashboard(): + today = date.today() + year = request.args.get('year', today.year, type=int) + month = request.args.get('month', today.month, type=int) + expenses, total, category_totals, alerts = monthly_summary(current_user.id, year, month) + chart_categories = [{'label': k, 'amount': float(v)} for k, v in category_totals.items()] + chart_payments = payment_method_totals(current_user.id, year, month) + return render_template('main/dashboard.html', expenses=expenses, total=total, category_totals=category_totals, alerts=alerts, selected_year=year, selected_month=month, chart_categories=chart_categories, chart_payments=chart_payments) + + +@main_bp.route('/statistics') +@login_required +def statistics(): + year = request.args.get('year', date.today().year, type=int) + month = request.args.get('month', 0, type=int) + start_year = request.args.get('start_year', max(year - 4, 2000), type=int) + end_year = request.args.get('end_year', year, type=int) + if start_year > end_year: + start_year, end_year = end_year, start_year + return render_template('main/statistics.html', selected_year=year, selected_month=month, start_year=start_year, end_year=end_year) + + +@main_bp.route('/analytics/data') +@login_required +def analytics_data(): + year = request.args.get('year', date.today().year, type=int) + month = request.args.get('month', 0, type=int) + month = month or None + start_year = request.args.get('start_year', max(year - 4, 2000), type=int) + end_year = request.args.get('end_year', year, type=int) + if start_year > end_year: + start_year, end_year = end_year, start_year + return jsonify({ + 'yearly_totals': yearly_totals(current_user.id, year, month), + 'daily_totals': daily_totals(current_user.id, year, month), + 'category_totals': yearly_category_totals(current_user.id, year, month), + 'payment_methods': payment_method_totals(current_user.id, year, month), + 'top_expenses': top_expenses(current_user.id, year, month), + 'overview': yearly_overview(current_user.id, year, month), + 'comparison': compare_years(current_user.id, year, month), + 'range_totals': range_totals(current_user.id, start_year, end_year, month), + 'quarterly_totals': quarterly_totals(current_user.id, year, month), + 'weekday_totals': weekday_totals(current_user.id, year, month), + }) + + +@main_bp.route('/preferences', methods=['GET', 'POST']) +@login_required +def preferences(): + form = PreferencesForm(obj=current_user) + form.language.choices = [('pl', _('language.polish')), ('en', _('language.english'))] + form.theme.choices = [('light', _('theme.light')), ('dark', _('theme.dark'))] + form.report_frequency.choices = [('off', _('report.off')), ('daily', _('report.daily')), ('weekly', _('report.weekly')), ('monthly', _('report.monthly'))] + category_form = UserCategoryForm(prefix='cat') + if request.method == 'POST' and 'language' in request.form and form.validate(): + current_user.language = form.language.data + current_user.theme = form.theme.data + current_user.report_frequency = form.report_frequency.data if get_bool_setting('reports_enabled', True) else 'off' + current_user.default_currency = form.default_currency.data + db.session.commit() + flash = __import__('flask').flash + flash(_('flash.settings_saved'), 'success') + return redirect(url_for('main.preferences')) + if request.method == 'POST' and 'cat-key' in request.form and category_form.validate(): + key = f'u{current_user.id}_{category_form.key.data.strip().lower()}' + category = Category.query.filter_by(user_id=current_user.id, key=key).first() + if not category: + category = Category(user_id=current_user.id, key=key, name=category_form.name_en.data.strip(), is_active=True) + db.session.add(category) + category.name = category_form.name_en.data.strip() + category.name_pl = category_form.name_pl.data.strip() + category.name_en = category_form.name_en.data.strip() + category.color = category_form.color.data + db.session.commit() + log_action('user_category_saved', 'category', category.id, owner=current_user.id) + flash = __import__('flask').flash + flash(_('flash.category_saved'), 'success') + return redirect(url_for('main.preferences')) + my_categories = Category.query.filter_by(user_id=current_user.id).order_by(Category.name_pl).all() + report_options_enabled = get_bool_setting('reports_enabled', True) + return render_template('main/preferences.html', form=form, category_form=category_form, my_categories=my_categories, report_options_enabled=report_options_enabled) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..6aabf71 --- /dev/null +++ b/app/models.py @@ -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() diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/analytics.py b/app/services/analytics.py new file mode 100644 index 0000000..e69dc6f --- /dev/null +++ b/app/services/analytics.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +from collections import defaultdict +from decimal import Decimal + +from sqlalchemy import extract, func + +from ..models import Budget, Expense +from .i18n import get_locale + + +def _uncategorized() -> str: + return 'Uncategorized' if get_locale() == 'en' else 'Bez kategorii' + + +def _base_user_query(user_id: int, year: int, month: int | None = None): + query = Expense.query.filter_by(user_id=user_id, is_deleted=False).filter(extract('year', Expense.purchase_date) == year) + if month: + query = query.filter(extract('month', Expense.purchase_date) == month) + return query + + +def monthly_summary(user_id: int, year: int, month: int): + expenses = _base_user_query(user_id, year, month).order_by(Expense.purchase_date.desc(), Expense.id.desc()).all() + total = sum((expense.amount for expense in expenses), Decimal('0.00')) + category_totals = defaultdict(Decimal) + for expense in expenses: + category_name = expense.category.localized_name(get_locale()) if expense.category else _uncategorized() + category_totals[category_name] += expense.amount + budgets = Budget.query.filter_by(user_id=user_id, year=year, month=month).all() + budget_map = {budget.category_id: budget for budget in budgets} + alerts = [] + for expense in expenses: + budget = budget_map.get(expense.category_id) + if budget and expense.category: + spent = category_totals.get(expense.category.localized_name(get_locale()), Decimal('0.00')) + ratio = (spent / budget.amount * 100) if budget.amount else 0 + if ratio >= budget.alert_percent: + alerts.append({'category': expense.category.localized_name(get_locale()), 'ratio': float(ratio), 'budget': float(budget.amount)}) + return expenses, total, category_totals, alerts + + +def yearly_totals(user_id: int, year: int, month: int | None = None): + if month: + return daily_totals(user_id, year, month) + rows = _base_user_query(user_id, year).with_entities(extract('month', Expense.purchase_date).label('month'), func.sum(Expense.amount)).group_by('month').order_by('month').all() + return [{'month': int(month), 'amount': float(amount)} for month, amount in rows] + + +def daily_totals(user_id: int, year: int, month: int | None = None): + rows = _base_user_query(user_id, year, month).with_entities(extract('day', Expense.purchase_date).label('day'), func.sum(Expense.amount)).group_by('day').order_by('day').all() + return [{'month': int(day), 'amount': float(amount)} for day, amount in rows] + + +def yearly_category_totals(user_id: int, year: int, month: int | None = None): + expenses = _base_user_query(user_id, year, month).all() + grouped = defaultdict(Decimal) + for expense in expenses: + name = expense.category.localized_name(get_locale()) if expense.category else _uncategorized() + grouped[name] += expense.amount + return [{'category': name, 'amount': float(amount)} for name, amount in grouped.items()] + + +def payment_method_totals(user_id: int, year: int, month: int | None = None): + rows = _base_user_query(user_id, year, month).with_entities(Expense.payment_method, func.sum(Expense.amount)).group_by(Expense.payment_method).all() + return [{'method': method, 'amount': float(amount)} for method, amount in rows] + + +def top_expenses(user_id: int, year: int, month: int | None = None, limit: int = 10): + rows = _base_user_query(user_id, year, month).order_by(Expense.amount.desc()).limit(limit).all() + return [{'title': row.title, 'amount': float(row.amount), 'date': row.purchase_date.isoformat()} for row in rows] + + +def yearly_overview(user_id: int, year: int, month: int | None = None): + expenses = _base_user_query(user_id, year, month).all() + total = sum((expense.amount for expense in expenses), Decimal('0.00')) + count = len(expenses) + average = (total / count) if count else Decimal('0.00') + refunds = sum((expense.amount for expense in expenses if expense.is_refund), Decimal('0.00')) + business_total = sum((expense.amount for expense in expenses if expense.is_business), Decimal('0.00')) + return {'total': float(total), 'count': count, 'average': float(average), 'refunds': float(refunds), 'business_total': float(business_total)} + + +def compare_years(user_id: int, year: int, month: int | None = None): + current = yearly_overview(user_id, year, month) + previous = yearly_overview(user_id, year - 1, month) + diff = current['total'] - previous['total'] + pct = ((diff / previous['total']) * 100) if previous['total'] else 0 + return {'current_year': year, 'previous_year': year - 1, 'current_total': current['total'], 'previous_total': previous['total'], 'difference': diff, 'percent_change': pct} + + +def range_totals(user_id: int, start_year: int, end_year: int, month: int | None = None): + rows = Expense.query.with_entities(extract('year', Expense.purchase_date).label('year'), func.sum(Expense.amount)).filter_by(user_id=user_id, is_deleted=False).filter(extract('year', Expense.purchase_date) >= start_year, extract('year', Expense.purchase_date) <= end_year) + if month: + rows = rows.filter(extract('month', Expense.purchase_date) == month) + rows = rows.group_by('year').order_by('year').all() + return [{'year': int(year), 'amount': float(amount)} for year, amount in rows] + + +def quarterly_totals(user_id: int, year: int, month: int | None = None): + expenses = _base_user_query(user_id, year, month).all() + quarters = {1: Decimal('0.00'), 2: Decimal('0.00'), 3: Decimal('0.00'), 4: Decimal('0.00')} + for expense in expenses: + quarter = ((expense.purchase_date.month - 1) // 3) + 1 + quarters[quarter] += expense.amount + return [{'quarter': f'Q{quarter}', 'amount': float(amount)} for quarter, amount in quarters.items() if amount > 0] + + +def weekday_totals(user_id: int, year: int, month: int | None = None): + expenses = _base_user_query(user_id, year, month).all() + labels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] if get_locale() == 'en' else ['Pon', 'Wt', 'Śr', 'Czw', 'Pt', 'Sob', 'Niedz'] + totals = [Decimal('0.00') for _ in range(7)] + for expense in expenses: + totals[expense.purchase_date.weekday()] += expense.amount + return [{'day': labels[index], 'amount': float(amount)} for index, amount in enumerate(totals)] diff --git a/app/services/assets.py b/app/services/assets.py new file mode 100644 index 0000000..5836ac2 --- /dev/null +++ b/app/services/assets.py @@ -0,0 +1,18 @@ +import hashlib +from functools import lru_cache +from pathlib import Path + +from flask import url_for + + +@lru_cache(maxsize=128) +def asset_version(file_path: str) -> str: + path = Path(file_path) + if not path.exists(): + return '0' + return hashlib.md5(path.read_bytes()).hexdigest()[:10] + + +def asset_url(relative_path: str) -> str: + base_path = Path(__file__).resolve().parent.parent / 'static' / relative_path + return url_for('static', filename=relative_path, v=asset_version(str(base_path))) diff --git a/app/services/audit.py b/app/services/audit.py new file mode 100644 index 0000000..1ab51fe --- /dev/null +++ b/app/services/audit.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +import json + +from flask_login import current_user + +from ..extensions import db +from ..models import AuditLog + + +def log_action(action: str, target_type: str = '', target_id: str = '', **details) -> None: + user_id = current_user.id if getattr(current_user, 'is_authenticated', False) else None + entry = AuditLog( + user_id=user_id, + action=action, + target_type=target_type, + target_id=str(target_id or ''), + details=json.dumps(details, ensure_ascii=False) if details else '', + ) + db.session.add(entry) diff --git a/app/services/categorization.py b/app/services/categorization.py new file mode 100644 index 0000000..dfa9291 --- /dev/null +++ b/app/services/categorization.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from ..models import Expense + + +def suggest_category_id(user_id: int, vendor: str = '', title: str = '') -> int | None: + vendor = (vendor or '').strip().lower() + title = (title or '').strip().lower() + if not vendor and not title: + return None + recent = ( + Expense.query.filter_by(user_id=user_id, is_deleted=False) + .filter(Expense.category_id.isnot(None)) + .order_by(Expense.id.desc()) + .limit(100) + .all() + ) + for expense in recent: + ev = (expense.vendor or '').strip().lower() + et = (expense.title or '').strip().lower() + if vendor and ev == vendor: + return expense.category_id + if title and et == title: + return expense.category_id + return None diff --git a/app/services/export.py b/app/services/export.py new file mode 100644 index 0000000..0d9cd70 --- /dev/null +++ b/app/services/export.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import csv +from io import BytesIO, StringIO + +from reportlab.lib.pagesizes import A4 +from reportlab.pdfgen import canvas + + +def export_expenses_csv(expenses): + output = StringIO() + writer = csv.writer(output) + writer.writerow(['Date', 'Title', 'Vendor', 'Category', 'Amount', 'Currency', 'Payment method', 'Tags']) + for expense in expenses: + writer.writerow([ + expense.purchase_date.isoformat(), + expense.title, + expense.vendor, + expense.category.name_en if expense.category else '', + str(expense.amount), + expense.currency, + expense.payment_method, + expense.tags, + ]) + return output.getvalue() + + +def export_expenses_pdf(expenses, title: str = 'Expenses'): + buffer = BytesIO() + pdf = canvas.Canvas(buffer, pagesize=A4) + width, height = A4 + y = height - 40 + pdf.setFont('Helvetica-Bold', 14) + pdf.drawString(40, y, title) + y -= 30 + pdf.setFont('Helvetica', 10) + for expense in expenses: + line = f'{expense.purchase_date.isoformat()} | {expense.title} | {expense.amount} {expense.currency}' + pdf.drawString(40, y, line[:100]) + y -= 16 + if y < 60: + pdf.showPage() + y = height - 40 + pdf.setFont('Helvetica', 10) + pdf.save() + buffer.seek(0) + return buffer.read() diff --git a/app/services/files.py b/app/services/files.py new file mode 100644 index 0000000..63bd02a --- /dev/null +++ b/app/services/files.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import re +from pathlib import Path +from uuid import uuid4 + +from PIL import Image, ImageDraw +from pillow_heif import register_heif_opener +from werkzeug.datastructures import FileStorage +from werkzeug.utils import secure_filename + +register_heif_opener() + + +def allowed_file(filename: str, allowed_extensions: set[str]) -> bool: + return '.' in filename and filename.rsplit('.', 1)[1].lower() in allowed_extensions + + +def _apply_image_ops(image: Image.Image, rotate: int = 0, crop_box: tuple[int, int, int, int] | None = None, scale_percent: int = 100) -> Image.Image: + if image.mode not in ('RGB', 'RGBA'): + image = image.convert('RGB') + if rotate: + image = image.rotate(-rotate, expand=True) + if crop_box: + x, y, w, h = crop_box + if w > 0 and h > 0: + image = image.crop((x, y, x + w, y + h)) + if scale_percent and scale_percent != 100: + width = max(1, int(image.width * (scale_percent / 100))) + height = max(1, int(image.height * (scale_percent / 100))) + image = image.resize((width, height)) + return image + + +def _pdf_placeholder(preview_path: Path, original_name: str) -> None: + image = Image.new('RGB', (800, 1100), color='white') + draw = ImageDraw.Draw(image) + draw.text((40, 40), f'PDF preview\n{original_name}', fill='black') + image.save(preview_path, 'WEBP', quality=80) + + +def save_document(file: FileStorage, upload_dir: Path, preview_dir: Path, rotate: int = 0, crop_box: tuple[int, int, int, int] | None = None, scale_percent: int = 100) -> tuple[str, str]: + upload_dir.mkdir(parents=True, exist_ok=True) + preview_dir.mkdir(parents=True, exist_ok=True) + + original_name = secure_filename(file.filename or 'document') + extension = original_name.rsplit('.', 1)[1].lower() if '.' in original_name else 'bin' + stem = re.sub(r'[^a-zA-Z0-9_-]+', '-', Path(original_name).stem).strip('-') or 'document' + unique_name = f'{stem}-{uuid4().hex}.{extension}' + saved_path = upload_dir / unique_name + file.save(saved_path) + + preview_name = f'{stem}-{uuid4().hex}.webp' + preview_path = preview_dir / preview_name + if extension in {'jpg', 'jpeg', 'png', 'heic'}: + image = Image.open(saved_path) + image = _apply_image_ops(image, rotate=rotate, crop_box=crop_box, scale_percent=scale_percent) + image.save(preview_path, 'WEBP', quality=80) + elif extension == 'pdf': + _pdf_placeholder(preview_path, original_name) + else: + preview_name = '' + return unique_name, preview_name diff --git a/app/services/i18n.py b/app/services/i18n.py new file mode 100644 index 0000000..6fb1cff --- /dev/null +++ b/app/services/i18n.py @@ -0,0 +1,37 @@ +import json +from functools import lru_cache +from pathlib import Path + +from flask import current_app, session +from flask_login import current_user + + +@lru_cache(maxsize=8) +def load_language(language: str) -> dict: + base = Path(__file__).resolve().parent.parent / 'static' / 'i18n' + file_path = base / f'{language}.json' + if not file_path.exists(): + file_path = base / 'pl.json' + return json.loads(file_path.read_text(encoding='utf-8')) + + +def get_locale() -> str: + if getattr(current_user, 'is_authenticated', False): + return current_user.language or current_app.config['DEFAULT_LANGUAGE'] + return session.get('language', current_app.config['DEFAULT_LANGUAGE']) + + +def translate(key: str, default: str | None = None) -> str: + language = get_locale() + data = load_language(language) + if key in data: + return data[key] + fallback = load_language(current_app.config.get('DEFAULT_LANGUAGE', 'pl')) + if key in fallback: + return fallback[key] + en = load_language('en') + return en.get(key, default or key) + + +def inject_i18n(): + return {'t': translate, 'current_language': get_locale()} diff --git a/app/services/mail.py b/app/services/mail.py new file mode 100644 index 0000000..cff6429 --- /dev/null +++ b/app/services/mail.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import smtplib +from email.message import EmailMessage + +from flask import current_app, render_template + +from .settings import get_bool_setting, get_int_setting, get_str_setting + + +class MailService: + def _settings(self) -> dict: + security = get_str_setting('smtp_security', '') or ('ssl' if get_bool_setting('smtp_use_ssl', current_app.config.get('MAIL_USE_SSL', True)) else ('starttls' if get_bool_setting('smtp_use_tls', current_app.config.get('MAIL_USE_TLS', False)) else 'plain')) + return { + 'host': get_str_setting('smtp_host', current_app.config.get('MAIL_SERVER', '')), + 'port': get_int_setting('smtp_port', current_app.config.get('MAIL_PORT', 465)), + 'username': get_str_setting('smtp_username', current_app.config.get('MAIL_USERNAME', '')), + 'password': get_str_setting('smtp_password', current_app.config.get('MAIL_PASSWORD', '')), + 'sender': get_str_setting('smtp_sender', current_app.config.get('MAIL_DEFAULT_SENDER', 'no-reply@example.com')), + 'security': security, + } + + def is_configured(self) -> bool: + return bool(self._settings()['host']) + + def send(self, to_email: str, subject: str, body: str, html: str | None = None) -> bool: + cfg = self._settings() + if not cfg['host']: + current_app.logger.info('Mail skipped for %s: %s', to_email, subject) + return False + msg = EmailMessage() + msg['Subject'] = subject + msg['From'] = cfg['sender'] + msg['To'] = to_email + msg.set_content(body) + if html: + msg.add_alternative(html, subtype='html') + if cfg['security'] == 'ssl': + with smtplib.SMTP_SSL(cfg['host'], cfg['port']) as smtp: + if cfg['username']: + smtp.login(cfg['username'], cfg['password']) + smtp.send_message(msg) + else: + with smtplib.SMTP(cfg['host'], cfg['port']) as smtp: + if cfg['security'] == 'starttls': + smtp.starttls() + if cfg['username']: + smtp.login(cfg['username'], cfg['password']) + smtp.send_message(msg) + return True + + def send_template(self, to_email: str, subject: str, template_name: str, **context) -> bool: + html = render_template(f'mail/{template_name}.html', **context) + text = render_template(f'mail/{template_name}.txt', **context) + return self.send(to_email, subject, text, html=html) diff --git a/app/services/ocr.py b/app/services/ocr.py new file mode 100644 index 0000000..50043ee --- /dev/null +++ b/app/services/ocr.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import re +from pathlib import Path + +import pytesseract +from PIL import Image, ImageOps + +AMOUNT_REGEXES = [ + re.compile(r'(?:suma|total|razem)\s*[:]?\s*(\d+[\.,]\d{2})', re.I), + re.compile(r'(\d+[\.,]\d{2})'), +] +DATE_REGEX = re.compile(r'(\d{4}-\d{2}-\d{2}|\d{2}[./-]\d{2}[./-]\d{4})') +NIP_REGEX = re.compile(r'(?:NIP|TIN)\s*[:]?\s*([0-9\- ]{8,20})', re.I) + + +class OCRResult(dict): + @property + def status(self) -> str: + return self.get('status', 'pending') + + +class OCRService: + def extract(self, file_path: Path) -> OCRResult: + if file_path.suffix.lower() not in {'.jpg', '.jpeg', '.png', '.heic', '.webp'}: + return OCRResult(status='pending', title=file_path.stem, vendor='', amount=None, purchase_date=None) + try: + image = Image.open(file_path) + image = ImageOps.exif_transpose(image) + text = pytesseract.image_to_string(image, lang='eng') + except Exception: + return OCRResult(status='pending', title=file_path.stem, vendor='', amount=None, purchase_date=None) + + lines = [line.strip() for line in text.splitlines() if line.strip()] + vendor = lines[0][:255] if lines else '' + amount = None + for pattern in AMOUNT_REGEXES: + match = pattern.search(text) + if match: + amount = match.group(1).replace(',', '.') + break + date_match = DATE_REGEX.search(text) + nip_match = NIP_REGEX.search(text) + return OCRResult( + status='review' if amount or vendor else 'pending', + title=vendor or file_path.stem, + vendor=vendor, + amount=amount, + purchase_date=date_match.group(1) if date_match else None, + tax_id=nip_match.group(1).strip() if nip_match else None, + raw_text=text[:4000], + ) diff --git a/app/services/reporting.py b/app/services/reporting.py new file mode 100644 index 0000000..d153eef --- /dev/null +++ b/app/services/reporting.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from datetime import date, timedelta +from decimal import Decimal + +from ..extensions import db +from ..models import Expense, ReportLog, User +from .mail import MailService +from .settings import get_bool_setting + + +def _range_for_frequency(frequency: str, today: date): + if frequency == 'daily': + start = today - timedelta(days=1) + label = start.isoformat() + elif frequency == 'weekly': + start = today - timedelta(days=7) + label = f'{start.isoformat()}..{today.isoformat()}' + else: + start = date(today.year, today.month, 1) + label = f'{today.year}-{today.month:02d}' + return start, today, label + + +def send_due_reports(today: date | None = None) -> int: + if not get_bool_setting('reports_enabled', True): + return 0 + today = today or date.today() + sent = 0 + users = User.query.filter(User.report_frequency.in_(['daily', 'weekly', 'monthly'])).all() + for user in users: + start, end, label = _range_for_frequency(user.report_frequency, today) + if ReportLog.query.filter_by(user_id=user.id, frequency=user.report_frequency, period_label=label).first(): + continue + q = Expense.query.filter_by(user_id=user.id, is_deleted=False).filter(Expense.purchase_date >= start, Expense.purchase_date <= end) + rows = q.order_by(Expense.purchase_date.desc()).all() + total = sum((expense.amount for expense in rows), Decimal('0.00')) + MailService().send_template(user.email, f'Expense report {label}', 'expense_report', user=user, period_label=label, total=total, currency=user.default_currency, expenses=rows[:10]) + db.session.add(ReportLog(user_id=user.id, frequency=user.report_frequency, period_label=label)) + sent += 1 + db.session.commit() + return sent diff --git a/app/services/scheduler.py b/app/services/scheduler.py new file mode 100644 index 0000000..17107dd --- /dev/null +++ b/app/services/scheduler.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from apscheduler.schedulers.background import BackgroundScheduler + +from .reporting import send_due_reports +from .settings import get_bool_setting + +_scheduler: BackgroundScheduler | None = None + + +def get_scheduler() -> BackgroundScheduler: + global _scheduler + if _scheduler is None: + _scheduler = BackgroundScheduler(timezone='UTC') + return _scheduler + + +def start_scheduler(app) -> None: + if app.config.get('TESTING'): + return + if not get_bool_setting('report_scheduler_enabled', False): + return + scheduler = get_scheduler() + if scheduler.running: + return + + def _job(): + with app.app_context(): + send_due_reports() + + scheduler.add_job(_job, 'interval', minutes=60, id='send_due_reports', replace_existing=True) + scheduler.start() diff --git a/app/services/settings.py b/app/services/settings.py new file mode 100644 index 0000000..edfb882 --- /dev/null +++ b/app/services/settings.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from ..models import AppSetting + + +def get_bool_setting(key: str, default: bool = False) -> bool: + raw = AppSetting.get(key, 'true' if default else 'false') + return str(raw).lower() == 'true' + + +def get_int_setting(key: str, default: int) -> int: + raw = AppSetting.get(key) + try: + return int(raw) if raw is not None else default + except (TypeError, ValueError): + return default + + +def get_str_setting(key: str, default: str = '') -> str: + raw = AppSetting.get(key) + return str(raw) if raw is not None else default diff --git a/app/static/css/app.css b/app/static/css/app.css new file mode 100644 index 0000000..8df48b2 --- /dev/null +++ b/app/static/css/app.css @@ -0,0 +1,379 @@ +:root { + --app-radius: 1.2rem; + --app-radius-sm: .9rem; + --app-shadow: 0 10px 30px rgba(15, 23, 42, 0.08); + --app-shadow-lg: 0 20px 45px rgba(15, 23, 42, 0.12); + --app-border: rgba(148, 163, 184, 0.2); + --app-brand: #2563eb; + --app-brand-2: #7c3aed; + --app-surface: rgba(255,255,255,.75); + --app-surface-strong: rgba(255,255,255,.92); +} + +html[data-bs-theme="dark"] { + --bs-body-bg: #0b1220; + --bs-body-color: #e5eefc; + --bs-secondary-bg: #111827; + --bs-tertiary-bg: #0f172a; + --bs-border-color: rgba(148, 163, 184, 0.22); + --bs-card-bg: rgba(15, 23, 42, 0.9); + --bs-emphasis-color: #f8fafc; + --bs-secondary-color: #94a3b8; + --app-shadow: 0 14px 40px rgba(2, 6, 23, 0.45); + --app-shadow-lg: 0 24px 56px rgba(2, 6, 23, 0.55); + --app-border: rgba(148, 163, 184, 0.16); + --app-surface: rgba(15, 23, 42, .78); + --app-surface-strong: rgba(15, 23, 42, .94); +} + +body { + min-height: 100vh; + background: + radial-gradient(circle at top left, rgba(37,99,235,0.12), transparent 26%), + radial-gradient(circle at top right, rgba(124,58,237,0.12), transparent 20%), + var(--bs-body-bg); +} + +main.container { position: relative; z-index: 1; } + +.navbar.app-navbar { + backdrop-filter: blur(18px); + background: var(--app-surface) !important; + border-bottom: 1px solid var(--app-border) !important; +} + +.brand-mark { + width: 2.45rem; + height: 2.45rem; + border-radius: .95rem; + display: inline-flex; + align-items: center; + justify-content: center; + color: white; + background: linear-gradient(135deg, var(--app-brand), var(--app-brand-2)); + box-shadow: var(--app-shadow); +} + +.navbar-brand-text small { + display: block; + font-size: .72rem; + letter-spacing: .08em; + text-transform: uppercase; + color: var(--bs-secondary-color); +} + +.card, +.glass-card { + border: 1px solid var(--app-border); + border-radius: var(--app-radius); + background: var(--app-surface-strong); + box-shadow: var(--app-shadow); +} + +.card:hover { transition: transform .18s ease, box-shadow .18s ease; } +.card:hover { transform: translateY(-1px); box-shadow: var(--app-shadow-lg); } + +.metric-card .metric-icon, +.feature-icon, +.soft-icon { + width: 2.8rem; + height: 2.8rem; + border-radius: 1rem; + display: inline-flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, rgba(37,99,235,.14), rgba(124,58,237,.14)); + color: var(--app-brand); +} + +html[data-bs-theme="dark"] .metric-card .metric-icon, +html[data-bs-theme="dark"] .feature-icon, +html[data-bs-theme="dark"] .soft-icon { + color: #9ec5ff; + background: linear-gradient(135deg, rgba(59,130,246,.2), rgba(139,92,246,.22)); +} + +.hero-panel { + padding: 1.35rem; + border-radius: calc(var(--app-radius) + .25rem); + background: linear-gradient(135deg, rgba(37,99,235,.14), rgba(124,58,237,.10)); + border: 1px solid var(--app-border); + box-shadow: var(--app-shadow); +} + +html[data-bs-theme="dark"] .hero-panel { + background: linear-gradient(135deg, rgba(30,41,59,.88), rgba(15,23,42,.96)); +} + +.btn { + border-radius: .9rem; + font-weight: 600; +} + +.btn-primary { + background: linear-gradient(135deg, var(--app-brand), var(--app-brand-2)); + border: 0; + box-shadow: 0 12px 24px rgba(37,99,235,.22); +} + +.btn-primary:hover, +.btn-primary:focus { + filter: brightness(1.03); +} + +.btn-outline-secondary, +.btn-outline-primary, +.btn-outline-danger { + border-width: 1px; +} + +.form-control, +.form-select { + min-height: 2.9rem; + border-radius: .9rem; + border-color: var(--app-border); + background-color: rgba(255,255,255,.65); +} + +html[data-bs-theme="dark"] .form-control, +html[data-bs-theme="dark"] .form-select { + background-color: rgba(15,23,42,.82); + color: var(--bs-body-color); +} + +.form-control:focus, +.form-select:focus { + box-shadow: 0 0 0 .25rem rgba(37,99,235,.14); +} + +.table td, .table th { vertical-align: middle; } +.table > :not(caption) > * > * { border-color: var(--app-border); } + +.list-group-item { + border-color: var(--app-border); + background: transparent; +} + +.login-card { overflow: hidden; } +.login-card::before { + content: ""; + display: block; + height: 6px; + background: linear-gradient(135deg, var(--app-brand), var(--app-brand-2)); +} + +.brand-icon { + width: 4.3rem; + height: 4.3rem; + margin-inline: auto; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 1.4rem; + font-size: 1.6rem; + color: white; + background: linear-gradient(135deg, var(--app-brand), var(--app-brand-2)); + box-shadow: var(--app-shadow-lg); +} + +.app-section-title { + display: flex; + align-items: center; + gap: .75rem; + margin-bottom: 1rem; +} + +.expense-row-thumb { + width: 48px; + height: 48px; + border-radius: .9rem; + object-fit: cover; + border: 1px solid var(--app-border); +} + +.month-switcher { + display: grid; + grid-template-columns: auto 1fr auto; + gap: .75rem; + align-items: center; +} + +.month-switcher .center-panel { + display: flex; + gap: .5rem; + align-items: center; + justify-content: center; + flex-wrap: wrap; + padding: .75rem; + border-radius: 1rem; + border: 1px solid var(--app-border); + background: rgba(255,255,255,.48); +} + +html[data-bs-theme="dark"] .month-switcher .center-panel { + background: rgba(15,23,42,.72); +} + +.quick-stats { + display: grid; + gap: 1rem; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.quick-stats .metric-card { padding: 1rem; } +.badge.soft-badge { + background: rgba(37,99,235,.12); + color: var(--app-brand); + border: 1px solid rgba(37,99,235,.12); +} + +html[data-bs-theme="dark"] .badge.soft-badge { + background: rgba(59,130,246,.18); + color: #bfdbfe; +} + +.chart-wrap { position: relative; height: 360px; min-height: 360px; } +.empty-state { + padding: 2rem 1rem; + text-align: center; + color: var(--bs-secondary-color); +} + +.empty-state .fa-solid { + font-size: 2rem; + margin-bottom: .75rem; + opacity: .7; +} + +.footer-note { + color: var(--bs-secondary-color); + font-size: .9rem; +} + +@media (max-width: 992px) { + .quick-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} + +@media (max-width: 768px) { + .container { padding-left: 1rem; padding-right: 1rem; } + .month-switcher { grid-template-columns: 1fr; } + .quick-stats { grid-template-columns: 1fr; } + .hero-panel { padding: 1rem; } +} + + +.app-shell { display:grid; grid-template-columns: 290px 1fr; min-height:100vh; } +.app-sidebar { position:sticky; top:0; height:100vh; padding:1.25rem; background:rgba(255,255,255,.58); border-right:1px solid var(--app-border); backdrop-filter:blur(18px); } +html[data-bs-theme="dark"] .app-sidebar { background:rgba(2,6,23,.78); } +.app-main { min-width:0; } +.sidebar-brand { display:flex; align-items:center; gap:.9rem; text-decoration:none; color:inherit; font-weight:800; } +.sidebar-brand small { display:block; color:var(--bs-secondary-color); font-size:.75rem; font-weight:600; text-transform:uppercase; letter-spacing:.08em; } +.sidebar-nav { display:grid; gap:.35rem; } +.sidebar-nav .nav-link { display:flex; align-items:center; gap:.9rem; padding:.85rem 1rem; border-radius:1rem; color:inherit; } +.sidebar-nav .nav-link:hover { background:rgba(37,99,235,.08); } +.sidebar-user { padding:1rem; border:1px solid var(--app-border); border-radius:1rem; background:var(--app-surface-strong); } +.metric-card { padding:1rem 1.1rem; } +.stat-overview-card { padding:1rem; } +.stat-overview-card .metric-label { color:var(--bs-secondary-color); font-size:.9rem; } +.stat-overview-card .metric-value { font-size:1.65rem; font-weight:800; } +.preview-trigger img { max-width:100%; } +.modal-content.glass-card { background:var(--app-surface-strong); } +@media (max-width: 992px) { .app-shell { display:block; } .app-content { padding-bottom:5rem; } } + + +.app-sidebar {width: 280px; min-height: 100vh; position: sticky; top: 0; padding: 1.2rem; background: rgba(255,255,255,.62); backdrop-filter: blur(16px); border-right: 1px solid var(--app-border);} +html[data-bs-theme="dark"] .app-sidebar {background: rgba(2,6,23,.75);} +.sidebar-brand {display:flex; align-items:center; gap:.9rem; color:inherit; text-decoration:none; font-weight:700;} +.sidebar-brand small {display:block; color:var(--bs-secondary-color); font-size:.75rem;} +.sidebar-nav .nav-link {display:flex; gap:.85rem; align-items:center; border-radius:1rem; padding:.85rem 1rem; color:inherit;} +.sidebar-nav .nav-link:hover {background: rgba(37,99,235,.10);} +.app-shell{display:flex;} .app-main{flex:1; min-width:0;} .app-content{max-width:1500px;} +.soft-badge{background: rgba(37,99,235,.10); color: var(--app-brand); border:1px solid rgba(37,99,235,.12);} +.empty-state{padding:3rem; text-align:center; color:var(--bs-secondary-color); display:grid; gap:.6rem; place-items:center;} +.empty-state i{font-size:2rem;} .footer-note{color:var(--bs-secondary-color); font-size:.92rem;} +.chart-wrap{position:relative; height:360px; min-height:360px;} .metric-label{font-size:.9rem; color:var(--bs-secondary-color);} .metric-value{font-size:1.65rem; font-weight:800;} +.document-editor-card{position:sticky; top:6rem;} .document-preview-shell{border:1px dashed var(--app-border); border-radius:1rem; padding:.75rem; background:rgba(255,255,255,.45);} +html[data-bs-theme="dark"] .document-preview-shell{background:rgba(15,23,42,.52);} +.document-preview-stage{position:relative; min-height:320px; display:grid; place-items:center; overflow:hidden; border-radius:1rem; background:linear-gradient(135deg, rgba(37,99,235,.06), rgba(124,58,237,.04));} +.document-preview-stage img{max-width:100%; max-height:440px; border-radius:1rem; transform-origin:center center; user-select:none;} +.document-preview-empty{display:grid; gap:.6rem; text-align:center; color:var(--bs-secondary-color);} +.document-preview-empty i{font-size:2rem;} .crop-selection{position:absolute; border:2px solid rgba(37,99,235,.75); background:rgba(37,99,235,.16); border-radius:.6rem; pointer-events:none;} +@media (max-width: 991.98px){ .quick-stats{grid-template-columns: repeat(2, minmax(0, 1fr));} .month-switcher{grid-template-columns:1fr;} .document-editor-card{position:static;} } +@media (max-width: 575.98px){ .quick-stats{grid-template-columns: 1fr;} } + +.chart-wrap { position: relative; height: 360px; min-height: 360px; } +.chart-canvas { display:block; width:100% !important; height: calc(100% - 2.5rem) !important; } +@media (max-width: 768px) { .chart-wrap { height: 300px; min-height: 300px; } } + +.upload-actions .btn{justify-content:center;} + + +.expense-list-stats { grid-template-columns: repeat(4, minmax(0, 1fr)); } +.expense-filters .form-label { font-weight: 600; } +.search-input-wrap { position: relative; } +.search-input-wrap i { position: absolute; left: 1rem; top: 50%; transform: translateY(-50%); color: var(--bs-secondary-color); z-index: 2; } +.expense-groups { grid-template-columns: 1fr; } +.expense-group-card { overflow: hidden; } +.expense-group-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + background: linear-gradient(135deg, rgba(37,99,235,.08), rgba(124,58,237,.06)); + border-bottom: 1px solid var(--app-border); +} +html[data-bs-theme="dark"] .expense-group-header { + background: linear-gradient(135deg, rgba(37,99,235,.16), rgba(124,58,237,.12)); +} +.expense-list-item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 1rem; + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--app-border); +} +.expense-list-item:last-child { border-bottom: 0; } +.expense-list-main { display: flex; gap: 1rem; min-width: 0; } +.expense-list-thumb-wrap { flex: 0 0 auto; } +.expense-title { font-size: 1.02rem; font-weight: 700; } +.expense-list-copy { min-width: 0; } +.expense-meta-row { + display: flex; + gap: .8rem; + flex-wrap: wrap; + color: var(--bs-secondary-color); + font-size: .92rem; +} +.expense-list-side { display: flex; flex-direction: column; align-items: end; justify-content: space-between; gap: .85rem; } +.expense-amount { font-size: 1.08rem; font-weight: 800; white-space: nowrap; } +.expense-actions { display: flex; flex-wrap: wrap; justify-content: end; gap: .5rem; } +@media (max-width: 992px) { + .expense-list-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} +@media (max-width: 768px) { + .expense-list-item { grid-template-columns: 1fr; } + .expense-list-side { align-items: stretch; } + .expense-actions { justify-content: start; } +} + +.chart-card{position:relative;height:320px;min-height:320px}.language-picker .flag-btn{border-radius:999px;padding:.25rem .55rem}.language-picker .flag-btn.active{box-shadow:0 0 0 2px rgba(37,99,235,.35)}.drop-upload-zone{align-items:center;justify-content:center;min-height:88px;border:2px dashed rgba(120,130,160,.35);border-radius:16px;background:rgba(99,102,241,.04);color:var(--bs-secondary-color);cursor:pointer}.drop-upload-zone.is-dragover{border-color:var(--bs-primary);background:rgba(59,130,246,.12)}.app-sidebar .nav-link{display:flex;gap:.75rem;align-items:center}.app-sidebar .nav-link span{display:inline-block}.settings-section .card{height:100%} + + +/* layout fixes */ +.app-shell { display:grid !important; grid-template-columns: 280px minmax(0,1fr) !important; min-height:100vh; } +.app-sidebar { width:auto !important; display:flex; flex-direction:column; gap:1rem; } +.app-main { min-width:0; } +.app-content { width:100%; max-width:none; } +.sidebar-nav .nav-link { width:100%; font-weight:600; } +.sidebar-nav .nav-link i { width:1.1rem; text-align:center; } +.navbar.app-navbar { z-index:1030; } +.language-picker { display:flex; align-items:center; gap:.35rem; } +.chart-card { position:relative; height:320px; min-height:320px; overflow:hidden; } +.chart-card canvas { width:100% !important; height:100% !important; } +.top-expense-item { display:flex; align-items:flex-start; justify-content:space-between; gap:1rem; padding:.85rem 0; border-bottom:1px solid var(--app-border); } +.top-expense-item:last-child { border-bottom:0; padding-bottom:0; } +.top-expense-amount { font-weight:800; white-space:nowrap; } +@media (max-width: 991.98px) { + .app-shell { display:block !important; } + .chart-card { height:280px; min-height:280px; } +} diff --git a/app/static/i18n/en.json b/app/static/i18n/en.json new file mode 100644 index 0000000..467cb73 --- /dev/null +++ b/app/static/i18n/en.json @@ -0,0 +1,244 @@ +{ + "nav.dashboard": "Dashboard", + "nav.expenses": "Expenses", + "nav.add_expense": "Add expense", + "nav.preferences": "Preferences", + "nav.admin": "Admin", + "nav.logout": "Logout", + "nav.statistics": "Statistics", + "nav.budgets": "Budgets", + "dashboard.title": "Monthly overview", + "dashboard.total": "Total", + "dashboard.latest": "Recent expenses", + "dashboard.categories": "Categories", + "dashboard.empty": "No expenses for this period.", + "dashboard.alerts": "Budget alerts", + "expenses.list": "Expense list", + "expenses.new": "New expense", + "expenses.edit": "Edit expense", + "expenses.title": "Title", + "expenses.amount": "Amount", + "expenses.category": "Category", + "expenses.date": "Date", + "expenses.vendor": "Vendor", + "expenses.description": "Description", + "expenses.currency": "Currency", + "expenses.payment_method": "Payment method", + "expenses.document": "Document", + "expenses.save": "Save", + "expenses.tags": "Tags", + "expenses.export_csv": "Export CSV", + "expenses.export_pdf": "Export PDF", + "expenses.preview": "Preview", + "expenses.empty": "No items.", + "expenses.deleted": "Deleted", + "preferences.title": "Preferences", + "preferences.language": "Language", + "preferences.theme": "Theme", + "preferences.reports": "Email reports", + "preferences.currency": "Default currency", + "preferences.save": "Save preferences", + "auth.login_title": "Sign in", + "auth.login_subtitle": "Manage expenses and documents in one place.", + "auth.register": "Register", + "auth.forgot_password": "Forgot password", + "auth.reset_request": "Send reset link", + "auth.new_password": "New password", + "admin.title": "Admin panel", + "admin.categories": "Categories", + "admin.users": "Users", + "admin.settings": "Settings", + "admin.system": "System information", + "admin.database": "Database", + "admin.audit": "Audit log", + "stats.title": "Long-term statistics", + "stats.monthly": "Monthly trend", + "stats.categories": "Categories", + "stats.payments": "Payment methods", + "stats.top": "Top expenses", + "budgets.title": "Budgets", + "budgets.add": "Add budget", + "common.actions": "Actions", + "common.save": "Save", + "common.cancel": "Cancel", + "common.uncategorized": "Uncategorized", + "common.previous": "Previous", + "common.next": "Next", + "common.year": "Year", + "common.month": "Month", + "brand.subtitle": "Expense control", + "admin.subtitle": "System overview, security and diagnostics", + "admin.audit_subtitle": "Recent actions from users and administrators", + "stats.subtitle": "Long-term trends and detailed breakdowns", + "stats.range_from": "From year", + "stats.range_to": "To year", + "stats.long_term": "Long-term trend", + "stats.total": "Total", + "stats.count": "Count", + "stats.average": "Average", + "stats.refunds": "Refunds", + "stats.vs_prev": "vs previous year", + "stats.no_data": "No data", + "common.apply": "Apply", + "common.view_all": "View all", + "common.date": "Date", + "expenses.form_subtitle": "Simple mobile-first expense form", + "expenses.placeholder_title": "Groceries, fuel, invoice...", + "expenses.placeholder_vendor": "Store or issuer", + "expenses.placeholder_description": "Optional notes", + "expenses.placeholder_tags": "home, monthly, important", + "expenses.document_tools": "Document tools", + "expenses.webp_preview": "WEBP preview", + "expenses.crop_note": "Crop fields are ready for browser editing and future editor improvements.", + "expenses.tips": "Tips", + "expenses.tip_1": "Start with amount, date and category.", + "expenses.tip_2": "Add a receipt photo only when needed.", + "expenses.tip_3": "Use tags for faster filtering later.", + "flash.suspicious_request": "Suspicious request detected.", + "flash.login_success": "Login successful.", + "flash.invalid_credentials": "Invalid credentials.", + "flash.registration_disabled": "Registration is disabled.", + "flash.email_exists": "Email already exists.", + "flash.account_created": "Account created. You can now log in.", + "flash.logged_out": "Logged out.", + "flash.reset_link_generated": "If the account exists, a reset link was generated.", + "flash.reset_invalid": "Reset token is invalid or expired.", + "flash.password_updated": "Password updated.", + "flash.category_saved": "Category saved.", + "flash.user_exists": "User already exists.", + "flash.user_created": "User created.", + "flash.user_flag_updated": "User flag updated.", + "flash.settings_saved": "Settings saved.", + "flash.expense_saved": "Expense saved.", + "flash.expense_updated": "Expense updated.", + "flash.expense_deleted": "Expense deleted.", + "flash.budget_saved": "Budget saved.", + "error.400_title": "Bad request", + "error.400_message": "The request could not be processed.", + "error.401_title": "Unauthorized", + "error.401_message": "Please sign in to access this page.", + "error.403_title": "Forbidden", + "error.403_message": "You do not have permission to access this resource.", + "error.404_title": "Not found", + "error.404_message": "The requested page does not exist.", + "error.413_title": "File too large", + "error.413_message": "The uploaded file exceeds the allowed size limit.", + "error.429_title": "Too many requests", + "error.429_message": "Please wait a moment before trying again.", + "error.500_title": "Internal server error", + "error.500_message": "Something went wrong on our side.", + "common.search": "Search", + "common.all": "All", + "common.reset": "Reset", + "expenses.search_placeholder": "Search title, vendor, description, tags", + "expenses.upload_to_edit": "Upload an image to rotate, crop and scale before saving.", + "expenses.status": "Status", + "stats.quarterly": "Quarterly", + "stats.weekdays": "Weekdays", + "expenses.take_photo": "Take photo", + "expenses.select_files": "Choose files", + "expenses.upload_hint_desktop": "On desktop you can upload files only.", + "expenses.upload_hint_mobile": "On mobile you can take a photo or choose files.", + "common.filter": "Filter", + "common.other": "Other", + "expenses.filtered_total": "Filtered total", + "expenses.results": "results", + "expenses.active_sort": "Sorting", + "expenses.grouping": "Grouping", + "expenses.sections": "sections", + "expenses.categories_count": "Categories", + "expenses.month_view": "month view", + "expenses.sort_by": "Sort by", + "expenses.sort_direction": "Direction", + "expenses.group_by": "Group by", + "expenses.group_category": "Category", + "expenses.group_payment_method": "Payment method", + "expenses.group_status": "Status", + "expenses.group_none": "No grouping", + "expenses.all_expenses": "All expenses", + "expenses.asc": "Ascending", + "expenses.desc": "Descending", + "expenses.payment_card": "Card", + "expenses.payment_cash": "Cash", + "expenses.payment_transfer": "Transfer", + "expenses.payment_blik": "BLIK", + "expenses.status_new": "New", + "expenses.status_needs_review": "Needs review", + "expenses.status_confirmed": "Confirmed", + "expenses.added": "Added", + "common.name": "Name", + "common.role": "Role", + "common.status": "Status", + "stats.payment_methods": "Payment methods", + "stats.top_expenses": "Top expenses", + "stats.monthly_trend": "Monthly trend", + "admin.settings_subtitle": "Technical and business settings", + "admin.section_general": "General", + "admin.section_reports": "Reports", + "admin.section_integrations": "Integrations", + "admin.company_name": "Company name", + "admin.max_upload_mb": "Upload limit MB", + "admin.registration_enabled": "Registration enabled", + "admin.smtp_security": "SMTP security", + "admin.smtp_sender": "Sender", + "admin.smtp_username": "SMTP username", + "admin.smtp_password": "SMTP password", + "admin.enable_scheduler": "Enable report scheduler", + "admin.scheduler_interval": "Scheduler interval (min)", + "flash.user_updated": "User saved", + "preferences.my_categories": "My categories", + "expenses.drop_files_here": "Drag and drop files here", + "common.active": "Active", + "common.inactive": "Inactive", + "common.enabled": "Enabled", + "common.disabled": "Disabled", + "common.no_data": "No data", + "common.month_1": "January", + "common.month_2": "February", + "common.month_3": "March", + "common.month_4": "April", + "common.month_5": "May", + "common.month_6": "June", + "common.month_7": "July", + "common.month_8": "August", + "common.month_9": "September", + "common.month_10": "October", + "common.month_11": "November", + "common.month_12": "December", + "admin.smtp_section": "SMTP", + "admin.smtp_host": "SMTP host", + "admin.smtp_port": "SMTP port", + "admin.smtp_plain": "SMTP", + "admin.reports_enabled": "Enable email reports", + "admin.reports_hint": "The admin enables or disables the whole reports feature. Users only choose the report type.", + "admin.webhook_token": "Webhook token", + "admin.python": "Python", + "admin.platform": "Platform", + "admin.environment": "Environment", + "admin.instance_path": "Instance path", + "admin.uploads": "Uploads", + "admin.previews": "Previews", + "admin.webhook": "Webhook", + "admin.scheduler": "Scheduler", + "preferences.reports_disabled": "Email reports are currently disabled by the administrator.", + "preferences.category_key": "Category key", + "preferences.category_name_pl": "Name PL", + "preferences.category_name_en": "Name EN", + "preferences.category_color": "Color", + "user.full_name": "Full name", + "user.email": "Email", + "user.active": "Active account", + "user.must_change_password": "Force password change", + "user.must_change_password_short": "must change", + "user.role_user": "User", + "user.role_admin": "Admin", + "language.polish": "Polish", + "language.english": "English", + "theme.light": "Light", + "theme.dark": "Dark", + "report.off": "Off", + "report.daily": "Daily", + "report.weekly": "Weekly", + "report.monthly": "Monthly", + "common.toggle": "Toggle" +} \ No newline at end of file diff --git a/app/static/i18n/pl.json b/app/static/i18n/pl.json new file mode 100644 index 0000000..977ae1b --- /dev/null +++ b/app/static/i18n/pl.json @@ -0,0 +1,244 @@ +{ + "nav.dashboard": "Dashboard", + "nav.expenses": "Wydatki", + "nav.add_expense": "Dodaj wydatek", + "nav.preferences": "Preferencje", + "nav.admin": "Administracja", + "nav.logout": "Wyloguj", + "nav.statistics": "Statystyki", + "nav.budgets": "Budżety", + "dashboard.title": "Podsumowanie miesiąca", + "dashboard.total": "Suma", + "dashboard.latest": "Ostatnie wydatki", + "dashboard.categories": "Kategorie", + "dashboard.empty": "Brak wydatków w tym okresie.", + "dashboard.alerts": "Alerty budżetowe", + "expenses.list": "Lista wydatków", + "expenses.new": "Nowy wydatek", + "expenses.edit": "Edytuj wydatek", + "expenses.title": "Tytuł", + "expenses.amount": "Kwota", + "expenses.category": "Kategoria", + "expenses.date": "Data", + "expenses.vendor": "Sprzedawca", + "expenses.description": "Opis", + "expenses.currency": "Waluta", + "expenses.payment_method": "Metoda płatności", + "expenses.document": "Dokument", + "expenses.save": "Zapisz", + "expenses.tags": "Tagi", + "expenses.export_csv": "Eksport CSV", + "expenses.export_pdf": "Eksport PDF", + "expenses.preview": "Podgląd", + "expenses.empty": "Brak pozycji.", + "expenses.deleted": "Usunięto", + "preferences.title": "Preferencje", + "preferences.language": "Język", + "preferences.theme": "Motyw", + "preferences.reports": "Raporty mailowe", + "preferences.currency": "Waluta domyślna", + "preferences.save": "Zapisz preferencje", + "auth.login_title": "Zaloguj się", + "auth.login_subtitle": "Zarządzaj wydatkami i dokumentami w jednym miejscu.", + "auth.register": "Rejestracja", + "auth.forgot_password": "Nie pamiętam hasła", + "auth.reset_request": "Wyślij link resetu", + "auth.new_password": "Nowe hasło", + "admin.title": "Panel administratora", + "admin.categories": "Kategorie", + "admin.users": "Użytkownicy", + "admin.settings": "Ustawienia", + "admin.system": "Informacje systemowe", + "admin.database": "Baza danych", + "admin.audit": "Log audytowy", + "stats.title": "Statystyki długoterminowe", + "stats.monthly": "Trend miesięczny", + "stats.categories": "Kategorie", + "stats.payments": "Metody płatności", + "stats.top": "Największe wydatki", + "budgets.title": "Budżety", + "budgets.add": "Dodaj budżet", + "common.actions": "Akcje", + "common.save": "Zapisz", + "common.cancel": "Anuluj", + "common.uncategorized": "Bez kategorii", + "common.previous": "Poprzedni", + "common.next": "Następny", + "common.year": "Rok", + "common.month": "Miesiąc", + "brand.subtitle": "Kontrola wydatków", + "admin.subtitle": "Przegląd systemu, bezpieczeństwa i diagnostyki", + "admin.audit_subtitle": "Ostatnie operacje użytkowników i administratorów", + "stats.subtitle": "Długoterminowe trendy i szczegółowe podziały", + "stats.range_from": "Od roku", + "stats.range_to": "Do roku", + "stats.long_term": "Trend wieloletni", + "stats.total": "Suma", + "stats.count": "Liczba", + "stats.average": "Średnia", + "stats.refunds": "Zwroty", + "stats.vs_prev": "vs poprzedni rok", + "stats.no_data": "Brak danych", + "common.apply": "Zastosuj", + "common.view_all": "Zobacz wszystko", + "common.date": "Data", + "expenses.form_subtitle": "Prosty formularz wydatku zoptymalizowany pod telefon", + "expenses.placeholder_title": "Zakupy, paliwo, faktura...", + "expenses.placeholder_vendor": "Sklep lub wystawca", + "expenses.placeholder_description": "Opcjonalne notatki", + "expenses.placeholder_tags": "dom, miesięczne, ważne", + "expenses.document_tools": "Narzędzia dokumentu", + "expenses.webp_preview": "Podgląd WEBP", + "expenses.crop_note": "Pola kadrowania są gotowe pod edycję w przeglądarce i dalszą rozbudowę edytora.", + "expenses.tips": "Wskazówki", + "expenses.tip_1": "Zacznij od kwoty, daty i kategorii.", + "expenses.tip_2": "Dodaj zdjęcie rachunku tylko wtedy, gdy jest potrzebne.", + "expenses.tip_3": "Używaj tagów, aby szybciej filtrować wydatki później.", + "flash.suspicious_request": "Wykryto podejrzane żądanie.", + "flash.login_success": "Logowanie zakończone sukcesem.", + "flash.invalid_credentials": "Nieprawidłowe dane logowania.", + "flash.registration_disabled": "Rejestracja jest wyłączona.", + "flash.email_exists": "Adres e-mail już istnieje.", + "flash.account_created": "Konto zostało utworzone. Możesz się zalogować.", + "flash.logged_out": "Wylogowano.", + "flash.reset_link_generated": "Jeśli konto istnieje, wygenerowano link resetu hasła.", + "flash.reset_invalid": "Link resetu jest nieprawidłowy lub wygasł.", + "flash.password_updated": "Hasło zostało zmienione.", + "flash.category_saved": "Kategoria została zapisana.", + "flash.user_exists": "Użytkownik już istnieje.", + "flash.user_created": "Użytkownik został utworzony.", + "flash.user_flag_updated": "Flaga użytkownika została zaktualizowana.", + "flash.settings_saved": "Ustawienia zostały zapisane.", + "flash.expense_saved": "Wydatek został zapisany.", + "flash.expense_updated": "Wydatek został zaktualizowany.", + "flash.expense_deleted": "Wydatek został usunięty.", + "flash.budget_saved": "Budżet został zapisany.", + "error.400_title": "Błędne żądanie", + "error.400_message": "Nie udało się przetworzyć żądania.", + "error.401_title": "Brak autoryzacji", + "error.401_message": "Zaloguj się, aby uzyskać dostęp do tej strony.", + "error.403_title": "Brak dostępu", + "error.403_message": "Nie masz uprawnień do tego zasobu.", + "error.404_title": "Nie znaleziono", + "error.404_message": "Żądana strona nie istnieje.", + "error.413_title": "Plik jest za duży", + "error.413_message": "Wgrany plik przekracza dozwolony limit rozmiaru.", + "error.429_title": "Zbyt wiele żądań", + "error.429_message": "Odczekaj chwilę przed kolejną próbą.", + "error.500_title": "Błąd serwera", + "error.500_message": "Wystąpił błąd po stronie aplikacji.", + "common.search": "Szukaj", + "common.all": "Wszystkie", + "common.reset": "Reset", + "expenses.search_placeholder": "Szukaj po tytule, sprzedawcy, opisie i tagach", + "expenses.upload_to_edit": "Wgraj obraz, aby obrócić, przyciąć i przeskalować przed zapisem.", + "expenses.status": "Status", + "stats.quarterly": "Kwartalnie", + "stats.weekdays": "Dni tygodnia", + "expenses.take_photo": "Zrób zdjęcie", + "expenses.select_files": "Wybierz pliki", + "expenses.upload_hint_desktop": "Na komputerze możesz tylko wgrać pliki.", + "expenses.upload_hint_mobile": "Na telefonie możesz zrobić zdjęcie lub wybrać pliki.", + "common.filter": "Filtruj", + "common.other": "Inne", + "expenses.filtered_total": "Suma po filtrach", + "expenses.results": "wyników", + "expenses.active_sort": "Sortowanie", + "expenses.grouping": "Grupowanie", + "expenses.sections": "sekcji", + "expenses.categories_count": "Kategorie", + "expenses.month_view": "widok miesiąca", + "expenses.sort_by": "Sortuj po", + "expenses.sort_direction": "Kierunek", + "expenses.group_by": "Grupuj po", + "expenses.group_category": "Kategoria", + "expenses.group_payment_method": "Metoda płatności", + "expenses.group_status": "Status", + "expenses.group_none": "Bez grupowania", + "expenses.all_expenses": "Wszystkie wydatki", + "expenses.asc": "Rosnąco", + "expenses.desc": "Malejąco", + "expenses.payment_card": "Karta", + "expenses.payment_cash": "Gotówka", + "expenses.payment_transfer": "Przelew", + "expenses.payment_blik": "BLIK", + "expenses.status_new": "Nowy", + "expenses.status_needs_review": "Wymaga sprawdzenia", + "expenses.status_confirmed": "Potwierdzony", + "expenses.added": "Dodano", + "common.name": "Nazwa", + "common.role": "Rola", + "common.status": "Status", + "stats.payment_methods": "Metody płatności", + "stats.top_expenses": "Największe wydatki", + "stats.monthly_trend": "Trend miesięczny", + "admin.settings_subtitle": "Ustawienia techniczne i biznesowe", + "admin.section_general": "Ogólne", + "admin.section_reports": "Raporty", + "admin.section_integrations": "Integracje", + "admin.company_name": "Nazwa firmy", + "admin.max_upload_mb": "Limit uploadu MB", + "admin.registration_enabled": "Rejestracja aktywna", + "admin.smtp_security": "Bezpieczeństwo SMTP", + "admin.smtp_sender": "Nadawca", + "admin.smtp_username": "Login SMTP", + "admin.smtp_password": "Hasło SMTP", + "admin.enable_scheduler": "Włącz scheduler raportów", + "admin.scheduler_interval": "Interwał schedulera (min)", + "flash.user_updated": "Użytkownik zapisany", + "preferences.my_categories": "Moje kategorie", + "expenses.drop_files_here": "Przeciągnij i upuść pliki tutaj", + "common.active": "Aktywna", + "common.inactive": "Nieaktywna", + "common.enabled": "Włączone", + "common.disabled": "Wyłączone", + "common.no_data": "Brak danych", + "common.month_1": "Styczeń", + "common.month_2": "Luty", + "common.month_3": "Marzec", + "common.month_4": "Kwiecień", + "common.month_5": "Maj", + "common.month_6": "Czerwiec", + "common.month_7": "Lipiec", + "common.month_8": "Sierpień", + "common.month_9": "Wrzesień", + "common.month_10": "Październik", + "common.month_11": "Listopad", + "common.month_12": "Grudzień", + "admin.smtp_section": "SMTP", + "admin.smtp_host": "Host SMTP", + "admin.smtp_port": "Port SMTP", + "admin.smtp_plain": "SMTP", + "admin.reports_enabled": "Włącz raporty mailowe", + "admin.reports_hint": "Administrator włącza lub wyłącza całą funkcję raportów. Użytkownik wybiera tylko typ raportu.", + "admin.webhook_token": "Token webhooka", + "admin.python": "Python", + "admin.platform": "Platforma", + "admin.environment": "Środowisko", + "admin.instance_path": "Ścieżka instancji", + "admin.uploads": "Pliki", + "admin.previews": "Podglądy", + "admin.webhook": "Webhook", + "admin.scheduler": "Scheduler", + "preferences.reports_disabled": "Raporty mailowe są obecnie wyłączone przez administratora.", + "preferences.category_key": "Klucz kategorii", + "preferences.category_name_pl": "Nazwa PL", + "preferences.category_name_en": "Nazwa EN", + "preferences.category_color": "Kolor", + "user.full_name": "Imię i nazwisko", + "user.email": "Email", + "user.active": "Aktywne konto", + "user.must_change_password": "Wymuś zmianę hasła", + "user.must_change_password_short": "zmień hasło", + "user.role_user": "Użytkownik", + "user.role_admin": "Administrator", + "language.polish": "Polski", + "language.english": "Angielski", + "theme.light": "Jasny", + "theme.dark": "Ciemny", + "report.off": "Wyłączone", + "report.daily": "Dzienne", + "report.weekly": "Tygodniowe", + "report.monthly": "Miesięczne", + "common.toggle": "Przełącz" +} \ No newline at end of file diff --git a/app/static/js/app.js b/app/static/js/app.js new file mode 100644 index 0000000..c42ec94 --- /dev/null +++ b/app/static/js/app.js @@ -0,0 +1,172 @@ + +document.addEventListener('DOMContentLoaded', async () => { + const previewButtons = document.querySelectorAll('.preview-trigger'); + const previewModalImage = document.getElementById('previewModalImage'); + previewButtons.forEach(button => button.addEventListener('click', () => { + if (previewModalImage) previewModalImage.src = button.dataset.preview; + })); + + setupDocumentEditor(); + + if (!window.expenseStatsYear || typeof Chart === 'undefined') return; + + const query = new URLSearchParams({ year: window.expenseStatsYear, month: window.expenseStatsMonth || 0, start_year: window.expenseStatsStartYear || window.expenseStatsYear, end_year: window.expenseStatsEndYear || window.expenseStatsYear }); + const response = await fetch(`/analytics/data?${query.toString()}`); + if (!response.ok) return; + const payload = await response.json(); + const text = window.expenseStatsText || {}; + + const overview = document.getElementById('stats-overview'); + if (overview) { + const comparison = payload.comparison || {}; + overview.innerHTML = [ + { icon: 'fa-wallet', label: text.total || 'Total', value: payload.overview.total.toFixed(2) }, + { icon: 'fa-list-check', label: text.count || 'Count', value: payload.overview.count }, + { icon: 'fa-calculator', label: text.average || 'Average', value: payload.overview.average.toFixed(2) }, + { icon: 'fa-rotate-left', label: text.refunds || 'Refunds', value: payload.overview.refunds.toFixed(2) }, + ].map(item => `
${item.label}
${item.value}
${text.vs_prev || 'Vs previous year'}: ${Number(comparison.percent_change || 0).toFixed(1)}%
`).join(''); + } + + const chartDefaults = { responsive: true, maintainAspectRatio: false, resizeDelay: 150 }; + const buildChart = (id, config) => { + const canvas = document.getElementById(id); + if (!canvas) return; + canvas.style.height = '100%'; + new Chart(canvas, config); + }; + + buildChart('chart-monthly', {type: 'line', data: { labels: payload.yearly_totals.map(x => x.month), datasets: [{ label: text.total || 'Amount', data: payload.yearly_totals.map(x => x.amount), tension: 0.35, fill: false }] }, options: chartDefaults}); + buildChart('chart-categories', {type: 'doughnut', data: { labels: payload.category_totals.map(x => x.category), datasets: [{ data: payload.category_totals.map(x => x.amount) }] }, options: chartDefaults}); + buildChart('chart-payments', {type: 'bar', data: { labels: payload.payment_methods.map(x => x.method), datasets: [{ label: text.total || 'Amount', data: payload.payment_methods.map(x => x.amount) }] }, options: chartDefaults}); + buildChart('chart-range', {type: 'bar', data: { labels: payload.range_totals.map(x => x.year), datasets: [{ label: text.total || 'Amount', data: payload.range_totals.map(x => x.amount) }] }, options: chartDefaults}); + buildChart('chart-quarterly', {type: 'bar', data: { labels: payload.quarterly_totals.map(x => x.quarter), datasets: [{ label: text.total || 'Amount', data: payload.quarterly_totals.map(x => x.amount) }] }, options: chartDefaults}); + buildChart('chart-weekdays', {type: 'line', data: { labels: payload.weekday_totals.map(x => x.day), datasets: [{ label: text.total || 'Amount', data: payload.weekday_totals.map(x => x.amount), tension: 0.35, fill: false }] }, options: chartDefaults}); + + const top = document.getElementById('top-expenses'); + if (top) { + top.innerHTML = payload.top_expenses.length + ? payload.top_expenses.map(x => `
${x.title}
${x.date}
${x.amount}
`).join('') + : `
${text.no_data || 'No data'}
`; + } +}); + +function setupDocumentEditor() { + const fileInput = document.getElementById('documentInput'); + const cameraButton = document.getElementById('cameraCaptureButton'); + const pickerButton = document.getElementById('filePickerButton'); + const uploadHint = document.getElementById('documentInputHint'); + const dropZone = document.getElementById('dropUploadZone'); + const img = document.getElementById('documentPreviewImage'); + const empty = document.getElementById('documentPreviewEmpty'); + const stage = document.getElementById('documentPreviewStage'); + const selection = document.getElementById('cropSelection'); + const rotateField = document.getElementById('rotateField'); + const scaleField = document.getElementById('scaleField'); + const cropX = document.querySelector('input[name="crop_x"]'); + const cropY = document.querySelector('input[name="crop_y"]'); + const cropW = document.querySelector('input[name="crop_w"]'); + const cropH = document.querySelector('input[name="crop_h"]'); + if (!fileInput || !img || !stage) return; + + const isMobile = window.matchMedia('(max-width: 991px)').matches && (navigator.maxTouchPoints > 0 || window.matchMedia('(pointer: coarse)').matches); + const desktopHint = uploadHint?.dataset.desktopHint || uploadHint?.textContent || ''; + const mobileHint = uploadHint?.dataset.mobileHint || desktopHint; + if (cameraButton) cameraButton.classList.toggle('d-none', !isMobile); + if (uploadHint) uploadHint.textContent = isMobile ? mobileHint : desktopHint; + + pickerButton?.addEventListener('click', () => { + fileInput.removeAttribute('capture'); + fileInput.click(); + }); + cameraButton?.addEventListener('click', () => { + fileInput.setAttribute('capture', 'environment'); + fileInput.click(); + }); + + let editorState = { rotate: Number(rotateField?.value || 0), scale: Number(scaleField?.value || 100) }; + let drag = null; + + const renderTransform = () => { + img.style.transform = `rotate(${editorState.rotate}deg) scale(${editorState.scale / 100})`; + if (rotateField) rotateField.value = editorState.rotate; + if (scaleField) scaleField.value = editorState.scale; + }; + + const handleFiles = () => { + const file = fileInput.files?.[0]; + if (!file || !file.type.startsWith('image/')) { + if (empty) empty.classList.remove('d-none'); + img.classList.add('d-none'); + return; + } + const reader = new FileReader(); + reader.onload = e => { + img.src = String(e.target?.result || ''); + img.classList.remove('d-none'); + if (empty) empty.classList.add('d-none'); + renderTransform(); + }; + reader.readAsDataURL(file); + }; + fileInput.addEventListener('change', handleFiles); + if (dropZone) {['dragenter','dragover'].forEach(eventName => dropZone.addEventListener(eventName, event => { event.preventDefault(); dropZone.classList.add('is-dragover'); })); ['dragleave','drop'].forEach(eventName => dropZone.addEventListener(eventName, event => { event.preventDefault(); dropZone.classList.remove('is-dragover'); })); dropZone.addEventListener('drop', event => { const dt = event.dataTransfer; if (!dt?.files?.length) return; fileInput.files = dt.files; handleFiles(); }); dropZone.addEventListener('click', ()=>fileInput.click()); } + + document.querySelectorAll('.js-rotate').forEach(btn => btn.addEventListener('click', () => { + editorState.rotate = (editorState.rotate + Number(btn.dataset.step || 0) + 360) % 360; + renderTransform(); + })); + document.querySelectorAll('.js-scale').forEach(btn => btn.addEventListener('click', () => { + editorState.scale = Math.max(20, Math.min(200, editorState.scale + Number(btn.dataset.step || 0))); + renderTransform(); + })); + document.getElementById('editorReset')?.addEventListener('click', () => { + editorState = { rotate: 0, scale: 100 }; + renderTransform(); + [cropX, cropY, cropW, cropH].forEach(field => { if (field) field.value = ''; }); + selection?.classList.add('d-none'); + selection?.setAttribute('style', ''); + }); + + stage.addEventListener('pointerdown', e => { + const rect = stage.getBoundingClientRect(); + drag = { startX: e.clientX - rect.left, startY: e.clientY - rect.top }; + if (selection) { + selection.classList.remove('d-none'); + selection.style.left = `${drag.startX}px`; + selection.style.top = `${drag.startY}px`; + selection.style.width = '0px'; + selection.style.height = '0px'; + } + }); + + stage.addEventListener('pointermove', e => { + if (!drag || !selection) return; + const rect = stage.getBoundingClientRect(); + const currentX = Math.max(0, Math.min(rect.width, e.clientX - rect.left)); + const currentY = Math.max(0, Math.min(rect.height, e.clientY - rect.top)); + const left = Math.min(drag.startX, currentX); + const top = Math.min(drag.startY, currentY); + const width = Math.abs(currentX - drag.startX); + const height = Math.abs(currentY - drag.startY); + Object.assign(selection.style, { left: `${left}px`, top: `${top}px`, width: `${width}px`, height: `${height}px` }); + if (cropX) cropX.value = Math.round(left); + if (cropY) cropY.value = Math.round(top); + if (cropW) cropW.value = Math.round(width); + if (cropH) cropH.value = Math.round(height); + }); + + const stopDrag = () => { drag = null; }; + stage.addEventListener('pointerup', stopDrag); + stage.addEventListener('pointerleave', stopDrag); + renderTransform(); +} + + +document.addEventListener('DOMContentLoaded', () => { + if (typeof Chart !== 'undefined' && window.dashboardCategoryData) { + const c1 = document.getElementById('dashboard-category-chart'); + if (c1) new Chart(c1, {type:'doughnut', data:{labels: window.dashboardCategoryData.map(x=>x.label), datasets:[{data:window.dashboardCategoryData.map(x=>x.amount)}]}, options:{responsive:true, maintainAspectRatio:false}}); + const c2 = document.getElementById('dashboard-payment-chart'); + if (c2) new Chart(c2, {type:'bar', data:{labels: window.dashboardPaymentData.map(x=>x.method), datasets:[{data:window.dashboardPaymentData.map(x=>x.amount)}]}, options:{responsive:true, maintainAspectRatio:false}}); + } +}); diff --git a/app/static/previews/Zrzut_ekranu_20251111_195848-8562fe10fb404b7e89678e2af7bf1d46.webp b/app/static/previews/Zrzut_ekranu_20251111_195848-8562fe10fb404b7e89678e2af7bf1d46.webp new file mode 100644 index 0000000..3b76a29 Binary files /dev/null and b/app/static/previews/Zrzut_ekranu_20251111_195848-8562fe10fb404b7e89678e2af7bf1d46.webp differ diff --git a/app/static/previews/one-a8d5983506994642bb8140141ac8c3f5.webp b/app/static/previews/one-a8d5983506994642bb8140141ac8c3f5.webp new file mode 100644 index 0000000..e877f0d Binary files /dev/null and b/app/static/previews/one-a8d5983506994642bb8140141ac8c3f5.webp differ diff --git a/app/static/previews/one-f3e272100c2f477eaef5fd2fe767faae.webp b/app/static/previews/one-f3e272100c2f477eaef5fd2fe767faae.webp new file mode 100644 index 0000000..e877f0d Binary files /dev/null and b/app/static/previews/one-f3e272100c2f477eaef5fd2fe767faae.webp differ diff --git a/app/static/previews/two-14dfaf6ec4a84f7ca637ab8454ef869c.webp b/app/static/previews/two-14dfaf6ec4a84f7ca637ab8454ef869c.webp new file mode 100644 index 0000000..c2b921e Binary files /dev/null and b/app/static/previews/two-14dfaf6ec4a84f7ca637ab8454ef869c.webp differ diff --git a/app/static/previews/two-a21a4ab5a1004647b4581d798a0e2784.webp b/app/static/previews/two-a21a4ab5a1004647b4581d798a0e2784.webp new file mode 100644 index 0000000..c2b921e Binary files /dev/null and b/app/static/previews/two-a21a4ab5a1004647b4581d798a0e2784.webp differ diff --git a/app/static/uploads/Zrzut_ekranu_20251111_195848-d71c6a1a12b2452cbc8c7d909f34e104.png b/app/static/uploads/Zrzut_ekranu_20251111_195848-d71c6a1a12b2452cbc8c7d909f34e104.png new file mode 100644 index 0000000..b564f68 Binary files /dev/null and b/app/static/uploads/Zrzut_ekranu_20251111_195848-d71c6a1a12b2452cbc8c7d909f34e104.png differ diff --git a/app/static/uploads/one-60d229d3507942178d989004a32ba97a.png b/app/static/uploads/one-60d229d3507942178d989004a32ba97a.png new file mode 100644 index 0000000..b5e8e02 Binary files /dev/null and b/app/static/uploads/one-60d229d3507942178d989004a32ba97a.png differ diff --git a/app/static/uploads/one-bc49d4e12f6b4dd39748c88348f060b5.png b/app/static/uploads/one-bc49d4e12f6b4dd39748c88348f060b5.png new file mode 100644 index 0000000..b5e8e02 Binary files /dev/null and b/app/static/uploads/one-bc49d4e12f6b4dd39748c88348f060b5.png differ diff --git a/app/static/uploads/two-24768a3ece7f49d9adb180414b01539a.png b/app/static/uploads/two-24768a3ece7f49d9adb180414b01539a.png new file mode 100644 index 0000000..bfa46f4 Binary files /dev/null and b/app/static/uploads/two-24768a3ece7f49d9adb180414b01539a.png differ diff --git a/app/static/uploads/two-f78ba5d74e2c4607850c60f42d5872a9.png b/app/static/uploads/two-f78ba5d74e2c4607850c60f42d5872a9.png new file mode 100644 index 0000000..bfa46f4 Binary files /dev/null and b/app/static/uploads/two-f78ba5d74e2c4607850c60f42d5872a9.png differ diff --git a/app/templates/admin/audit.html b/app/templates/admin/audit.html new file mode 100644 index 0000000..053e358 --- /dev/null +++ b/app/templates/admin/audit.html @@ -0,0 +1,14 @@ + +{% extends 'base.html' %} +{% block content %} +
+
+ +
+

{{ t('admin.audit') }}

+
{{ t('admin.audit_subtitle') }}
+
+
+
+
{% for log in logs %}{% else %}{% endfor %}
{{ t('common.date') }}UserActionTargetDetails
{{ log.created_at.strftime('%Y-%m-%d %H:%M') }}{{ log.user.email if log.user else '-' }}{{ log.action }}{{ log.target_type }} #{{ log.target_id }}{{ log.details }}
{{ t('stats.no_data') }}
+{% endblock %} diff --git a/app/templates/admin/categories.html b/app/templates/admin/categories.html new file mode 100644 index 0000000..019919c --- /dev/null +++ b/app/templates/admin/categories.html @@ -0,0 +1,23 @@ +{% extends 'base.html' %} +{% block content %} +
+
+
+

{{ t('admin.categories') }}

+
{{ form.hidden_tag() }} +
+
{{ form.key.label(class='form-label') }}{{ form.key(class='form-control') }}
+
{{ form.name_pl.label(class='form-label') }}{{ form.name_pl(class='form-control') }}
+
{{ form.name_en.label(class='form-label') }}{{ form.name_en(class='form-control') }}
+
{{ form.color.label(class='form-label') }}{{ form.color(class='form-select') }}
+
{{ form.submit(class='btn btn-primary w-100') }}
+
+
+
+
+
+ + {% for category in categories %}{% endfor %} +
{{ t('common.name') }}PLEN{{ t('common.status') }}
{{ category.key }}{{ category.name_pl }}{{ category.name_en }}{{ t('common.active') if category.is_active else t('common.inactive') }}
+
+{% endblock %} diff --git a/app/templates/admin/dashboard.html b/app/templates/admin/dashboard.html new file mode 100644 index 0000000..e039a34 --- /dev/null +++ b/app/templates/admin/dashboard.html @@ -0,0 +1,32 @@ +{% extends 'base.html' %} +{% block content %} +
+
+
+ +
+

{{ t('admin.title') }}

+
{{ t('admin.subtitle') }}
+
+
+ +
+
+
+
{{ t('admin.users') }}
{{ stats.users }}
+
{{ t('admin.categories') }}
{{ stats.categories }}
+
{{ t('admin.audit') }}
{{ stats.audit_logs }}
+
Admins
{{ stats.admins }}
+
+
+

{{ t('admin.system') }}

{{ t('admin.python') }}: {{ system.python }}
{{ t('admin.platform') }}: {{ system.platform }}
{{ t('admin.environment') }}: {{ system.flask_env }}
{{ t('admin.instance_path') }}: {{ system.instance_path }}
{{ t('admin.uploads') }}: {{ system.upload_count }}
{{ t('admin.previews') }}: {{ system.preview_count }}
{{ t('admin.webhook') }}: {{ t('common.enabled') if system.webhook_enabled else t('common.disabled') }}
{{ t('admin.scheduler') }}: {{ t('common.enabled') if system.scheduler_enabled else t('common.disabled') }}
+

{{ t('admin.database') }}

Engine: {{ db_info.engine }}
URL: {{ db_info.url }}
Version: {{ db_version }}
Max upload MB: {{ system.max_upload_mb }}
+

{{ t('admin.audit') }}

{{ t('common.view_all') }}
{% for log in recent_logs %}{% else %}{% endfor %}
{{ t('common.date') }}ActionTargetDetails
{{ log.created_at.strftime('%Y-%m-%d %H:%M') }}{{ log.action }}{{ log.target_type }} #{{ log.target_id }}{{ log.details }}
{{ t('stats.no_data') }}
+
+{% endblock %} diff --git a/app/templates/admin/settings.html b/app/templates/admin/settings.html new file mode 100644 index 0000000..b74f538 --- /dev/null +++ b/app/templates/admin/settings.html @@ -0,0 +1,49 @@ +{% extends 'base.html' %} +{% block content %} +
+
+ +

{{ t('admin.settings') }}

{{ t('admin.settings_subtitle') }}
+
+
+
+

{{ t('admin.section_general') }}

+
+
+
+
+
+
+ +
+

{{ t('admin.smtp_section') }}

+
+
+
+
+
+
+
+
+
+ +
+

{{ t('admin.section_reports') }}

+
+
+
+
{{ t('admin.reports_hint') }}
+
+
+ +
+

{{ t('admin.section_integrations') }}

+
+
+
+
+ +
+
+
+{% endblock %} diff --git a/app/templates/admin/users.html b/app/templates/admin/users.html new file mode 100644 index 0000000..7c69f25 --- /dev/null +++ b/app/templates/admin/users.html @@ -0,0 +1,26 @@ +{% extends 'base.html' %} +{% block content %} +
+
+

{{ t('admin.users') }}

+
{{ form.hidden_tag() }} +
{{ form.full_name(class='form-control') }}
+
{{ form.email(class='form-control') }}
+
+
{{ form.role(class='form-select') }}
+
{{ form.language(class='form-select') }}
+
{{ form.report_frequency(class='form-select') }}
+
{{ form.theme(class='form-select') }}
+
+
{{ form.is_active_user(class='form-check-input') }}
+
{{ form.must_change_password(class='form-check-input') }}
+ + {% if editing_user %}{{ t('common.cancel') }}{% endif %} +
+
+
+ + {% for user in users %}{% endfor %} +
{{ t('common.name') }}{{ t('user.email') }}{{ t('common.role') }}{{ t('preferences.language') }}{{ t('preferences.reports') }}{{ t('common.status') }}
{{ user.full_name }}{{ user.email }}{{ user.role }}{{ user.language }}{{ user.report_frequency }}{% if user.is_active_user %}{{ t('common.active') }}{% else %}{{ t('common.inactive') }}{% endif %}{% if user.must_change_password %}{{ t('user.must_change_password_short') }}{% endif %}
{{ csrf_token() if csrf_token else '' }}
+
+{% endblock %} diff --git a/app/templates/auth/forgot_password.html b/app/templates/auth/forgot_password.html new file mode 100644 index 0000000..3e3a06e --- /dev/null +++ b/app/templates/auth/forgot_password.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} +{% block content %} +
+

{{ t('auth.reset_request') }}

+
{{ form.hidden_tag() }}
{{ form.website() }}
{{ form.email.label(class_='form-label') }}{{ form.email(class_='form-control') }}
{{ form.submit(class_='btn btn-primary w-100') }}
+
+{% endblock %} diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html new file mode 100644 index 0000000..26ad893 --- /dev/null +++ b/app/templates/auth/login.html @@ -0,0 +1,28 @@ +{% extends 'base.html' %} +{% block content %} +
+
+ +
+
+{% endblock %} diff --git a/app/templates/auth/register.html b/app/templates/auth/register.html new file mode 100644 index 0000000..63372fb --- /dev/null +++ b/app/templates/auth/register.html @@ -0,0 +1,11 @@ +{% extends 'base.html' %} +{% block content %} +
+

{{ t('auth.register') }}

+
{{ form.hidden_tag() }}
{{ form.website() }}
+
{{ form.full_name.label(class_='form-label') }}{{ form.full_name(class_='form-control') }}
+
{{ form.email.label(class_='form-label') }}{{ form.email(class_='form-control') }}
+
{{ form.password.label(class_='form-label') }}{{ form.password(class_='form-control') }}
+
{{ form.confirm_password.label(class_='form-label') }}{{ form.confirm_password(class_='form-control') }}
+{{ form.submit(class_='btn btn-primary w-100') }}
+{% endblock %} diff --git a/app/templates/auth/reset_password.html b/app/templates/auth/reset_password.html new file mode 100644 index 0000000..a568248 --- /dev/null +++ b/app/templates/auth/reset_password.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} +{% block content %} +
+

{{ t('auth.new_password') }}

+
{{ form.hidden_tag() }}
{{ form.password.label(class_='form-label') }}{{ form.password(class_='form-control') }}
{{ form.confirm_password.label(class_='form-label') }}{{ form.confirm_password(class_='form-control') }}
{{ form.submit(class_='btn btn-primary w-100') }}
+
+{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..b34d0eb --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,95 @@ + + + + + + Expense Monitor + + + + + + +{% if current_user.is_authenticated %} +
+ +
+{% endif %} + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+{% if current_user.is_authenticated %} +
+
+{% endif %} + + + + +{% block scripts %}{% endblock %} + + diff --git a/app/templates/errors/error.html b/app/templates/errors/error.html new file mode 100644 index 0000000..21564a9 --- /dev/null +++ b/app/templates/errors/error.html @@ -0,0 +1,15 @@ +{% extends 'base.html' %} +{% block content %} +
+
+
+
+
{{ status_code }}
+

{{ title }}

+

{{ message }}

+ Go back +
+
+
+
+{% endblock %} diff --git a/app/templates/expenses/budgets.html b/app/templates/expenses/budgets.html new file mode 100644 index 0000000..d820360 --- /dev/null +++ b/app/templates/expenses/budgets.html @@ -0,0 +1,25 @@ +{% extends 'base.html' %} +{% block content %} +
+
+
+

{{ t('budgets.add') }}

+
{{ form.hidden_tag() }} +
{{ form.category_id.label(class='form-label') }}{{ form.category_id(class='form-select') }}
+
{{ form.year.label(class='form-label') }}{{ form.year(class='form-control') }}
{{ form.month.label(class='form-label') }}{{ form.month(class='form-control') }}
+
{{ form.amount.label(class='form-label') }}{{ form.amount(class='form-control') }}
+
{{ form.alert_percent.label(class='form-label') }}{{ form.alert_percent(class='form-control') }}
+ +
+
+
+
+
+

{{ t('budgets.title') }}

+
+ {% for budget in budgets %}{% else %}{% endfor %} +
{{ t('expenses.category') }}{{ t('common.year') }}{{ t('common.month') }}{{ t('expenses.amount') }}
{{ budget.category.localized_name(current_language) }}{{ budget.year }}{{ budget.month }}{{ budget.amount }}
{{ t('expenses.empty') }}
+
+
+
+{% endblock %} diff --git a/app/templates/expenses/create.html b/app/templates/expenses/create.html new file mode 100644 index 0000000..03fd1b5 --- /dev/null +++ b/app/templates/expenses/create.html @@ -0,0 +1,73 @@ +{% extends 'base.html' %} +{% block content %} +
+
+
+ +
+

{{ t('expenses.edit') if expense else t('expenses.new') }}

+
{{ t('expenses.form_subtitle') }}
+
+
+
+ {{ form.hidden_tag() }} +
+
+
+
{{ form.amount.label(class='form-label') }}{{ form.amount(class='form-control') }}
+
{{ form.purchase_date.label(class='form-label') }}{{ form.purchase_date(class='form-control') }}
+
{{ form.category_id.label(class='form-label') }}{{ form.category_id(class='form-select') }}
+
{{ form.payment_method.label(class='form-label') }}{{ form.payment_method(class='form-select') }}
+
{{ form.title.label(class='form-label') }}{{ form.title(class='form-control', placeholder=t('expenses.placeholder_title')) }}
+
{{ form.currency.label(class='form-label') }}{{ form.currency(class='form-select') }}
+
{{ form.vendor.label(class='form-label') }}{{ form.vendor(class='form-control', placeholder=t('expenses.placeholder_vendor')) }}
+
+ {{ form.document.label(class='form-label') }} +
+ + +
+
{{ t('expenses.drop_files_here') }}
{{ t('expenses.upload_hint_desktop') }}
+ {{ form.document(class='d-none', id='documentInput', accept='.jpg,.jpeg,.png,.heic,.pdf,image/*,application/pdf', multiple=true) }} +
+
{{ form.description.label(class='form-label') }}{{ form.description(class='form-control', rows='3', placeholder=t('expenses.placeholder_description')) }}
+
{{ form.tags.label(class='form-label') }}{{ form.tags(class='form-control', placeholder=t('expenses.placeholder_tags')) }}
+
{{ form.status.label(class='form-label') }}{{ form.status(class='form-select') }}
+
{{ form.recurring_period.label(class='form-label') }}{{ form.recurring_period(class='form-select') }}
+
+
+
+
+
+

{{ t('expenses.document_tools') }}

+ {{ t('expenses.webp_preview') }} +
+
+ + + + + +
+
+
{{ form.rotate.label(class='form-label small') }}{{ form.rotate(class='form-control', readonly=true, id='rotateField') }}
+
{{ form.scale_percent.label(class='form-label small') }}{{ form.scale_percent(class='form-control', readonly=true, id='scaleField') }}
+
+
+
+ preview +
{{ t('expenses.upload_to_edit') }}
+
+
+
+ + {% if expense and expense.attachments %}
Existing files
{% for item in expense.attachments %}{% if item.preview_filename %}preview{% else %}{{ item.original_filename }}{% endif %}{% endfor %}
{% endif %} +
{{ form.is_refund(class='form-check-input') }} {{ form.is_refund.label(class='form-check-label') }}
{{ form.is_business(class='form-check-input') }} {{ form.is_business.label(class='form-check-label') }}
+
+
+
+ +
+
+
+{% endblock %} diff --git a/app/templates/expenses/list.html b/app/templates/expenses/list.html new file mode 100644 index 0000000..2716b9b --- /dev/null +++ b/app/templates/expenses/list.html @@ -0,0 +1,210 @@ +{% extends 'base.html' %} +{% block content %} +
+
+
+
+ +
+

{{ t('expenses.list') }}

+
{{ selected_year }}-{{ '%02d'|format(selected_month) }}
+
+
+
+ +
+ +
+ {{ t('common.previous') }} +
+ + + + + + + + + + + +
+ {{ t('common.next') }} +
+ +
+
+
{{ t('expenses.filtered_total') }}
+
{{ '%.2f'|format(month_total) }}
+
{{ expenses|length }} {{ t('expenses.results') }}
+
+
+
{{ t('expenses.active_sort') }}
+
{{ dict(sort_options).get(filters.sort_by, t('expenses.date')) }}
+
{{ t('expenses.' ~ filters.sort_dir) if filters.sort_dir in ['asc', 'desc'] else filters.sort_dir }}
+
+
+
{{ t('expenses.grouping') }}
+
{{ t('expenses.group_' ~ filters.group_by) if filters.group_by in ['category','payment_method','status','none'] else filters.group_by }}
+
{{ grouped_expenses|length }} {{ t('expenses.sections') }}
+
+
+
{{ t('expenses.categories_count') }}
+
{{ categories|length }}
+
{{ t('expenses.month_view') }}
+
+
+ +
+
+ + +
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + {{ t('common.reset') }} +
+
+
+
+ +{% if budgets %}
{{ t('budgets.title') }}: {% for budget in budgets %}{{ budget.category.localized_name(current_language) }} {{ budget.amount }}{% if not loop.last %}, {% endif %}{% endfor %}
{% endif %} + +{% if expenses %} +
+ {% for group in grouped_expenses %} +
+
+
+
{{ group['label'] }}
+
{{ group['items']|length }} {{ t('expenses.results') }}
+
+
+
{{ t('expenses.filtered_total') }}
+
{{ '%.2f'|format(group['total']) }}
+
+
+
+ {% for expense in group['items'] %} +
+
+
+ {% if expense.preview_filename %} + preview + {% else %} + + {% endif %} +
+
+
+ {{ expense.title }} + {{ expense.category.localized_name(current_language) if expense.category else t('common.uncategorized') }} + {{ expense.purchase_date }} +
+
+ {% if expense.vendor %}{{ expense.vendor }}{% endif %} + {% if expense.payment_method %}{{ t('expenses.payment_' ~ expense.payment_method) if expense.payment_method in ['card','cash','transfer','blik'] else expense.payment_method }}{% endif %} + {% if expense.tags %}{{ expense.tags }}{% endif %} + {% if expense.status %}{{ t('expenses.status_' ~ expense.status) if expense.status in ['new','needs_review','confirmed'] else expense.status }}{% endif %} +
+ {% if expense.description %}
{{ expense.description }}
{% endif %} +
+
+
+
{{ expense.amount }} {{ expense.currency }}
+
+ {% if expense.all_previews %} + {% for preview_name in expense.all_previews[:3] %} + + {% endfor %} + {% endif %} + {{ t('expenses.edit') }} +
{{ csrf_token() if csrf_token else '' }}
+
+
+
+ {% endfor %} +
+
+ {% endfor %} +
+{% else %} +
{{ t('expenses.empty') }}
+{% endif %} + + +{% endblock %} diff --git a/app/templates/mail/expense_report.html b/app/templates/mail/expense_report.html new file mode 100644 index 0000000..b6fa817 --- /dev/null +++ b/app/templates/mail/expense_report.html @@ -0,0 +1 @@ +{% extends "mail/layout.html" %}{% block body %}

Raport wydatków

Okres: {{ period_label }}

Suma: {{ total }} {{ currency }}

{% for expense in expenses %}{% endfor %}
{{ expense.purchase_date }}{{ expense.title }}{{ expense.amount }} {{ expense.currency }}
{% endblock %} \ No newline at end of file diff --git a/app/templates/mail/expense_report.txt b/app/templates/mail/expense_report.txt new file mode 100644 index 0000000..13ecee5 --- /dev/null +++ b/app/templates/mail/expense_report.txt @@ -0,0 +1,6 @@ +Raport wydatków +Okres: {{ period_label }} +Suma: {{ total }} {{ currency }} + +{% for expense in expenses %}- {{ expense.purchase_date }} | {{ expense.title }} | {{ expense.amount }} {{ expense.currency }} +{% endfor %} \ No newline at end of file diff --git a/app/templates/mail/layout.html b/app/templates/mail/layout.html new file mode 100644 index 0000000..ca655eb --- /dev/null +++ b/app/templates/mail/layout.html @@ -0,0 +1 @@ +

Expense Monitor

{% block body %}{% endblock %}
\ No newline at end of file diff --git a/app/templates/mail/new_account.html b/app/templates/mail/new_account.html new file mode 100644 index 0000000..2872a2f --- /dev/null +++ b/app/templates/mail/new_account.html @@ -0,0 +1 @@ +{% extends "mail/layout.html" %}{% block body %}

Nowe konto

Twoje konto zostało utworzone.

Login: {{ user.email }}

Hasło tymczasowe: {{ temp_password }}

Po pierwszym logowaniu zmień hasło.

{% endblock %} \ No newline at end of file diff --git a/app/templates/mail/new_account.txt b/app/templates/mail/new_account.txt new file mode 100644 index 0000000..c0e1b0a --- /dev/null +++ b/app/templates/mail/new_account.txt @@ -0,0 +1,4 @@ +Nowe konto +Login: {{ user.email }} +Hasło tymczasowe: {{ temp_password }} +Po pierwszym logowaniu zmień hasło. diff --git a/app/templates/mail/password_reset.html b/app/templates/mail/password_reset.html new file mode 100644 index 0000000..f3857fe --- /dev/null +++ b/app/templates/mail/password_reset.html @@ -0,0 +1 @@ +{% extends "mail/layout.html" %}{% block body %}

Reset hasła

Otrzymaliśmy prośbę o zmianę hasła dla konta {{ user.email }}.

Ustaw nowe hasło

Jeśli to nie Ty, zignoruj tę wiadomość.

{% endblock %} \ No newline at end of file diff --git a/app/templates/mail/password_reset.txt b/app/templates/mail/password_reset.txt new file mode 100644 index 0000000..5c17726 --- /dev/null +++ b/app/templates/mail/password_reset.txt @@ -0,0 +1,4 @@ +Reset hasła + +Użyj linku, aby ustawić nowe hasło: +{{ reset_link }} diff --git a/app/templates/main/dashboard.html b/app/templates/main/dashboard.html new file mode 100644 index 0000000..aca9d87 --- /dev/null +++ b/app/templates/main/dashboard.html @@ -0,0 +1 @@ +{% extends 'base.html' %}{% block content %}

{{ t('dashboard.title') }}

{{ selected_year }}-{{ '%02d'|format(selected_month) }}
{{ t('dashboard.total') }}
{{ total }}
{% if alerts %}
{{ t('dashboard.alerts') }}: {% for alert in alerts %}{{ alert.category }} {{ '%.0f'|format(alert.ratio) }}%{% if not loop.last %}, {% endif %}{% endfor %}
{% endif %}
{{ t('dashboard.total') }}
{{ total }}
{{ t('dashboard.latest') }}
{{ expenses|length }}
{{ t('dashboard.categories') }}
{{ category_totals|length }}

{{ t('dashboard.latest') }}

{% if expenses %}
{% for expense in expenses[:10] %}
{{ expense.title }}
{{ expense.purchase_date }} · {{ expense.vendor }}
{{ expense.amount }} {{ expense.currency }}
{% endfor %}
{% else %}
{{ t('dashboard.empty') }}
{% endif %}

{{ t('dashboard.categories') }}

{{ t('stats.payment_methods') }}

{% endblock %}{% block scripts %}{% endblock %} \ No newline at end of file diff --git a/app/templates/main/preferences.html b/app/templates/main/preferences.html new file mode 100644 index 0000000..b953ed0 --- /dev/null +++ b/app/templates/main/preferences.html @@ -0,0 +1,35 @@ +{% extends 'base.html' %} +{% block content %} +
+
+
+

{{ t('preferences.title') }}

+
{{ form.hidden_tag() }} +
+
{{ form.language(class='form-select') }}
+
{{ form.theme(class='form-select') }}
+
{{ form.report_frequency(class='form-select', disabled=(not report_options_enabled)) }}
+
{{ form.default_currency(class='form-select') }}
+
+ {% if not report_options_enabled %}
{{ t('preferences.reports_disabled') }}
{% endif %} + +
+
+
+
+
+

{{ t('preferences.my_categories') }}

+
{{ category_form.hidden_tag() }} +
+
{{ category_form.key(class='form-control') }}
+
{{ category_form.name_pl(class='form-control') }}
+
{{ category_form.name_en(class='form-control') }}
+
{{ category_form.color(class='form-select') }}
+
+ +
+
{% for category in my_categories %}{{ category.localized_name(current_language) }}{% else %}{{ t('common.no_data') }}{% endfor %}
+
+
+
+{% endblock %} diff --git a/app/templates/main/statistics.html b/app/templates/main/statistics.html new file mode 100644 index 0000000..c7f2d75 --- /dev/null +++ b/app/templates/main/statistics.html @@ -0,0 +1,37 @@ +{% extends 'base.html' %} +{% block content %} +{% set month_names = [t('common.month_1'), t('common.month_2'), t('common.month_3'), t('common.month_4'), t('common.month_5'), t('common.month_6'), t('common.month_7'), t('common.month_8'), t('common.month_9'), t('common.month_10'), t('common.month_11'), t('common.month_12')] %} +
+
+

{{ t('stats.title') }}

{{ t('stats.subtitle') }}
+
+
+
+
+
+
+
+
+
+
+
+

{{ t('stats.monthly_trend') }}

+

{{ t('stats.top_expenses') }}

+
+
+

{{ t('dashboard.categories') }}

+

{{ t('stats.payment_methods') }}

+

{{ t('stats.long_term') }}

+

{{ t('stats.quarterly') }}

+

{{ t('stats.weekdays') }}

+
+{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/app/utils.py b/app/utils.py new file mode 100644 index 0000000..bf02bb0 --- /dev/null +++ b/app/utils.py @@ -0,0 +1,15 @@ +from functools import wraps + +from flask import abort +from flask_login import current_user + + + +def admin_required(view): + @wraps(view) + def wrapped(*args, **kwargs): + if not current_user.is_authenticated or not current_user.is_admin(): + abort(403) + return view(*args, **kwargs) + + return wrapped diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..960dfbe --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +services: + app: + build: . + env_file: + - .env + depends_on: + - db + volumes: + - ./instance:/app/instance + - ./app/static/uploads:/app/app/static/uploads + - ./app/static/previews:/app/app/static/previews + ports: + - "${APP_PORT:-5000}:5000" + db: + image: postgres:17-alpine + environment: + POSTGRES_DB: expense_monitor + POSTGRES_USER: expense_user + POSTGRES_PASSWORD: expense_password + volumes: + - pgdata:/var/lib/postgresql/data +volumes: + pgdata: diff --git a/migrations/README.md b/migrations/README.md new file mode 100644 index 0000000..96eaca4 --- /dev/null +++ b/migrations/README.md @@ -0,0 +1,5 @@ +Run these commands after setup: + +flask db init +flask db migrate -m "initial" +flask db upgrade diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0e909a2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..980280d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,20 @@ +Flask==3.1.0 +Flask-SQLAlchemy==3.1.1 +Flask-Login==0.6.3 +Flask-WTF==1.2.2 +Flask-Migrate==4.1.0 +Flask-Limiter==3.9.2 +email-validator==2.2.0 +Pillow==11.1.0 +pillow-heif==0.21.0 +python-dotenv==1.0.1 +gunicorn==23.0.0 +psycopg[binary]==3.2.5 +pytest==8.3.5 +pytest-flask==1.3.0 +pytest-cov==6.0.0 +beautifulsoup4==4.13.3 +pytesseract==0.3.13 +reportlab==4.4.3 + +APScheduler==3.10.4 diff --git a/requirements_dev.txt b/requirements_dev.txt new file mode 100644 index 0000000..31e0683 --- /dev/null +++ b/requirements_dev.txt @@ -0,0 +1,19 @@ +Flask==3.1.0 +Flask-SQLAlchemy==3.1.1 +Flask-Login==0.6.3 +Flask-WTF==1.2.2 +Flask-Migrate==4.1.0 +Flask-Limiter==3.9.2 +email-validator==2.2.0 +Pillow==11.1.0 +python-dotenv==1.0.1 +gunicorn==23.0.0 +psycopg==3.2.5 +pytest==8.3.5 +pytest-flask==1.3.0 +pytest-cov==6.0.0 +beautifulsoup4==4.13.3 +pytesseract==0.3.13 +reportlab==4.4.3 +#pillow-heif==0.21.0 +pi-heif diff --git a/run.py b/run.py new file mode 100644 index 0000000..8693153 --- /dev/null +++ b/run.py @@ -0,0 +1,6 @@ +from app import create_app + +app = create_app() + +if __name__ == '__main__': + app.run(host=app.config['APP_HOST'], port=app.config['APP_PORT']) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..30d8c6b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,49 @@ +import pytest + +from app import create_app +from app.config import TestConfig +from app.extensions import db +from app.models import User, seed_categories, seed_default_settings + + +@pytest.fixture +def app(): + app = create_app(TestConfig) + with app.app_context(): + db.drop_all() + db.create_all() + seed_categories() + seed_default_settings() + admin = User(email='admin@test.com', full_name='Admin', role='admin', must_change_password=False, language='pl') + admin.set_password('Password123!') + user = User(email='user@test.com', full_name='User', role='user', must_change_password=False, language='pl') + user.set_password('Password123!') + db.session.add_all([admin, user]) + db.session.commit() + yield app + + +@pytest.fixture +def client(app): + return app.test_client() + + +@pytest.fixture +def runner(app): + return app.test_cli_runner() + + +def login(client, email='user@test.com', password='Password123!'): + return client.post('/login', data={'email': email, 'password': password}, follow_redirects=True) + + +@pytest.fixture +def logged_user(client): + login(client) + return client + + +@pytest.fixture +def logged_admin(client): + login(client, 'admin@test.com') + return client diff --git a/tests/test_admin.py b/tests/test_admin.py new file mode 100644 index 0000000..e66c0e0 --- /dev/null +++ b/tests/test_admin.py @@ -0,0 +1,56 @@ +from app.models import AppSetting, Category, User + + +def test_admin_can_create_category(logged_admin, app): + response = logged_admin.post('/admin/categories', data={'key': 'pets', 'name_pl': 'Zwierzęta', 'name_en': 'Pets', 'color': 'info', 'is_active': 'y'}, follow_redirects=True) + assert response.status_code == 200 + with app.app_context(): + assert Category.query.filter_by(key='pets').first() is not None + + +def test_non_admin_forbidden(logged_user): + response = logged_user.get('/admin/') + assert response.status_code == 403 + + +def test_admin_can_create_user(logged_admin, app): + response = logged_admin.post('/admin/users', data={ + 'full_name': 'New User', + 'email': 'new@example.com', + 'role': 'user', + 'language': 'en', + 'must_change_password': 'y' + }, follow_redirects=True) + assert response.status_code == 200 + with app.app_context(): + assert User.query.filter_by(email='new@example.com').first() is not None + + +def test_admin_can_update_settings_and_flags(logged_admin, app): + response = logged_admin.post('/admin/settings', data={ + 'registration_enabled': 'on', + 'max_upload_mb': '12', + 'smtp_host': 'smtp.example.com', + 'smtp_port': '587', + 'smtp_username': 'mailer', + 'smtp_password': 'secret', + 'smtp_sender': 'noreply@example.com', + 'smtp_use_tls': 'on', + 'company_name': 'Test Co' + }, follow_redirects=True) + assert response.status_code == 200 + with app.app_context(): + assert AppSetting.get('registration_enabled') == 'true' + user = User.query.filter_by(email='user@test.com').first() + user_id = user.id + response = logged_admin.post(f'/admin/users/{user_id}/toggle-password-change', follow_redirects=True) + assert response.status_code == 200 + with app.app_context(): + user = User.query.filter_by(email='user@test.com').first() + assert user.must_change_password is True + + +def test_admin_dashboard_system_info(logged_admin): + response = logged_admin.get('/admin/') + assert response.status_code == 200 + assert b'Engine:' in response.data diff --git a/tests/test_admin_audit.py b/tests/test_admin_audit.py new file mode 100644 index 0000000..c449f3b --- /dev/null +++ b/tests/test_admin_audit.py @@ -0,0 +1,5 @@ + +def test_admin_audit_page(logged_admin): + response = logged_admin.get('/admin/audit') + assert response.status_code == 200 + assert b'clipboard' in response.data or b'Audit' in response.data or b'Operacje' in response.data diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..fae30b7 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,24 @@ +from app.models import PasswordResetToken, User + + +def test_login_success(client): + response = client.post('/login', data={'email': 'user@test.com', 'password': 'Password123!'}, follow_redirects=True) + assert response.status_code == 200 + assert b'Dashboard' in response.data or b'Panel' in response.data + + +def test_honeypot_blocks_login(client): + response = client.post('/login', data={'email': 'user@test.com', 'password': 'Password123!', 'website': 'spam'}, follow_redirects=True) + assert response.status_code == 200 + + +def test_password_reset_flow(client, app): + client.post('/forgot-password', data={'email': 'user@test.com'}, follow_redirects=True) + with app.app_context(): + token = PasswordResetToken.query.join(User).filter(User.email == 'user@test.com').first() + assert token is not None + response = client.post(f'/reset-password/{token.token}', data={'password': 'NewPassword123!', 'confirm_password': 'NewPassword123!'}, follow_redirects=True) + assert response.status_code == 200 + with app.app_context(): + user = User.query.filter_by(email='user@test.com').first() + assert user.check_password('NewPassword123!') diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..5d729ae --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,32 @@ +from io import BytesIO + +from app.extensions import db +from app.models import AppSetting + + +def test_not_found(client): + response = client.get('/missing-page') + assert response.status_code == 404 + + +def test_json_404(client): + response = client.get('/missing-page', headers={'Accept': 'application/json'}) + assert response.status_code == 404 + assert response.is_json + assert response.get_json()['status_code'] == 404 + + +def test_large_upload_returns_413(logged_user, app): + with app.app_context(): + AppSetting.set('max_upload_mb', '1') + db.session.commit() + data = { + 'title': 'Huge file', + 'amount': '10.00', + 'currency': 'PLN', + 'purchase_date': '2026-03-10', + 'payment_method': 'card', + 'document': (BytesIO(b'x' * (2 * 1024 * 1024)), 'big.jpg'), + } + response = logged_user.post('/expenses/create', data=data, content_type='multipart/form-data') + assert response.status_code == 413 diff --git a/tests/test_expenses.py b/tests/test_expenses.py new file mode 100644 index 0000000..8bbc8c8 --- /dev/null +++ b/tests/test_expenses.py @@ -0,0 +1,110 @@ +from datetime import date + +from app.extensions import db +from app.models import Budget, Category, Expense, User + + +def test_create_manual_expense(logged_user, app): + with app.app_context(): + category = Category.query.first() + category_id = category.id + response = logged_user.post('/expenses/create', data={ + 'title': 'Milk', + 'vendor': 'Store', + 'description': '2L milk', + 'amount': '12.50', + 'currency': 'PLN', + 'purchase_date': '2026-03-10', + 'payment_method': 'card', + 'category_id': str(category_id), + 'recurring_period': 'none', + 'status': 'confirmed', + }, follow_redirects=True) + assert response.status_code == 200 + with app.app_context(): + expense = Expense.query.filter_by(title='Milk').first() + assert expense is not None + assert expense.vendor == 'Store' + + +def test_expense_list_and_exports(logged_user, app): + with app.app_context(): + user = User.query.filter_by(email='user@test.com').first() + expense = Expense(user_id=user.id, title='Bread', amount=4.99, currency='PLN', purchase_date=date(2026, 3, 1), payment_method='cash') + db.session.add(expense) + db.session.commit() + response = logged_user.get('/expenses/?year=2026&month=3') + assert b'Bread' in response.data + csv_response = logged_user.get('/expenses/export.csv') + assert csv_response.status_code == 200 + assert b'Bread' in csv_response.data + pdf_response = logged_user.get('/expenses/export.pdf') + assert pdf_response.status_code == 200 + assert pdf_response.mimetype == 'application/pdf' + + +def test_edit_delete_and_budget(logged_user, app): + with app.app_context(): + user = User.query.filter_by(email='user@test.com').first() + category = Category.query.first() + category_id = category.id + expense = Expense(user_id=user.id, title='Taxi', amount=20, currency='PLN', purchase_date=date(2026, 3, 4), payment_method='cash', category_id=category_id) + db.session.add(expense) + db.session.commit() + expense_id = expense.id + response = logged_user.post(f'/expenses/{expense_id}/edit', data={ + 'title': 'Taxi Updated', + 'vendor': 'Bolt', + 'description': 'Airport ride', + 'amount': '25.00', + 'currency': 'PLN', + 'purchase_date': '2026-03-04', + 'payment_method': 'card', + 'category_id': str(category_id), + 'recurring_period': 'monthly', + 'status': 'confirmed', + }, follow_redirects=True) + assert response.status_code == 200 + with app.app_context(): + updated = db.session.get(Expense, expense_id) + assert updated.title == 'Taxi Updated' + assert updated.vendor == 'Bolt' + response = logged_user.post('/expenses/budgets', data={'category_id': str(category_id), 'year': '2026', 'month': '3', 'amount': '300', 'alert_percent': '80'}, follow_redirects=True) + assert response.status_code == 200 + with app.app_context(): + assert Budget.query.filter_by(year=2026, month=3).first() is not None + response = logged_user.post(f'/expenses/{expense_id}/delete', follow_redirects=True) + assert response.status_code == 200 + with app.app_context(): + assert db.session.get(Expense, expense_id).is_deleted is True + + +def test_expense_list_filters_sort_and_grouping(logged_user, app): + with app.app_context(): + user = User.query.filter_by(email='user@test.com').first() + category = Category.query.first() + other = Category.query.filter(Category.id != category.id).first() + db.session.add_all([ + Expense(user_id=user.id, title='Alpha', vendor='Shop A', amount=10, currency='PLN', purchase_date=date(2026, 3, 5), payment_method='card', category_id=category.id, status='confirmed'), + Expense(user_id=user.id, title='Zulu', vendor='Shop B', amount=99, currency='PLN', purchase_date=date(2026, 3, 6), payment_method='cash', category_id=other.id if other else category.id, status='new'), + ]) + db.session.commit() + response = logged_user.get('/expenses/?year=2026&month=3&q=Zulu&sort_by=amount&sort_dir=desc&group_by=category') + assert response.status_code == 200 + assert b'Zulu' in response.data + assert b'Alpha' not in response.data + assert b'Filtered total' in response.data or 'Suma po filtrach'.encode() in response.data + + +def test_expense_export_respects_status_filter(logged_user, app): + with app.app_context(): + user = User.query.filter_by(email='user@test.com').first() + db.session.add_all([ + Expense(user_id=user.id, title='Confirmed expense', amount=11, currency='PLN', purchase_date=date(2026, 3, 7), payment_method='card', status='confirmed'), + Expense(user_id=user.id, title='Needs review expense', amount=22, currency='PLN', purchase_date=date(2026, 3, 8), payment_method='card', status='needs_review'), + ]) + db.session.commit() + csv_response = logged_user.get('/expenses/export.csv?year=2026&month=3&status=confirmed') + assert csv_response.status_code == 200 + assert b'Confirmed expense' in csv_response.data + assert b'Needs review expense' not in csv_response.data diff --git a/tests/test_integrations.py b/tests/test_integrations.py new file mode 100644 index 0000000..58c54cd --- /dev/null +++ b/tests/test_integrations.py @@ -0,0 +1,89 @@ +from datetime import date +from io import BytesIO + +from PIL import Image + +from app.extensions import db +from app.models import AppSetting, Category, DocumentAttachment, Expense, User +from tests.conftest import login + + +def _png_bytes(color='red'): + image = Image.new('RGB', (32, 32), color=color) + buffer = BytesIO() + image.save(buffer, format='PNG') + buffer.seek(0) + return buffer + + +def test_manifest_and_service_worker(client): + manifest = client.get('/manifest.json') + assert manifest.status_code == 200 + assert manifest.get_json()['display'] == 'standalone' + sw = client.get('/service-worker.js') + assert sw.status_code == 200 + assert b'skipWaiting' in sw.data + + +def test_webhook_creates_expense(client, app): + with app.app_context(): + AppSetting.set('webhook_api_token', 'secret123') + db.session.commit() + category = Category.query.first() + response = client.post( + '/api/webhooks/expenses', + json={ + 'user_email': 'user@test.com', + 'title': 'Webhook Lunch', + 'amount': '25.50', + 'currency': 'PLN', + 'purchase_date': '2026-03-10', + 'category_key': category.key, + }, + headers={'X-Webhook-Token': 'secret123'}, + ) + assert response.status_code == 200 + with app.app_context(): + expense = Expense.query.filter_by(title='Webhook Lunch').first() + assert expense is not None + assert float(expense.amount) == 25.5 + + +def test_multiple_attachments_saved(logged_user, app): + with app.app_context(): + category = Category.query.first() + data = { + 'title': 'Multi doc expense', + 'amount': '12.34', + 'purchase_date': '2026-03-12', + 'category_id': str(category.id), + 'payment_method': 'card', + 'vendor': 'Vendor', + 'description': 'Desc', + 'currency': 'PLN', + 'tags': 'tag1', + 'recurring_period': 'none', + 'status': 'confirmed', + 'rotate': '0', + 'scale_percent': '100', + 'document': [(_png_bytes('red'), 'one.png'), (_png_bytes('blue'), 'two.png')], + } + response = logged_user.post('/expenses/create', data=data, content_type='multipart/form-data', follow_redirects=True) + assert response.status_code == 200 + with app.app_context(): + expense = Expense.query.filter_by(vendor='Vendor').order_by(Expense.id.desc()).first() + assert expense is not None + assert expense.title == 'Multi doc expense' + assert len(expense.attachments) == 2 + assert DocumentAttachment.query.count() >= 2 + + +def test_manual_report_run_from_admin(logged_admin, app): + with app.app_context(): + user = User.query.filter_by(email='user@test.com').first() + user.report_frequency = 'monthly' + db.session.add(Expense(user_id=user.id, title='Report item', amount=9, currency='PLN', purchase_date=date.today(), payment_method='card')) + db.session.commit() + response = logged_admin.post('/admin/run-reports', follow_redirects=True) + assert response.status_code == 200 + assert b'Queued/sent reports' in response.data diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..8a44911 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,40 @@ +from datetime import date + +from app.extensions import db +from app.models import Expense, User + + +def test_dashboard_and_analytics(logged_user, app): + with app.app_context(): + user = User.query.filter_by(email='user@test.com').first() + db.session.add(Expense(user_id=user.id, title='Fuel', amount=100, currency='PLN', purchase_date=date(2026, 2, 5), payment_method='card')) + db.session.add(Expense(user_id=user.id, title='Fuel', amount=50, currency='PLN', purchase_date=date(2026, 3, 5), payment_method='card')) + db.session.commit() + response = logged_user.get('/dashboard?year=2026&month=3') + assert response.status_code == 200 + api = logged_user.get('/analytics/data?year=2026') + assert api.status_code == 200 + data = api.get_json() + assert any(item['month'] == 2 for item in data['yearly_totals']) + assert 'category_totals' in data + + +def test_preferences_language_switch_and_statistics(logged_user, app): + response = logged_user.post('/preferences', data={'language': 'en', 'theme': 'dark', 'report_frequency': 'monthly', 'default_currency': 'EUR'}, follow_redirects=True) + assert response.status_code == 200 + stats = logged_user.get('/statistics?year=2026') + assert stats.status_code == 200 + with app.app_context(): + user = User.query.filter_by(email='user@test.com').first() + assert user.language == 'en' + assert user.theme == 'dark' + assert user.default_currency == 'EUR' + + +def test_extended_statistics_payload(logged_user, app): + response = logged_user.get('/analytics/data?year=2026&start_year=2024&end_year=2026') + assert response.status_code == 200 + data = response.get_json() + assert 'overview' in data + assert 'comparison' in data + assert 'range_totals' in data diff --git a/tests/test_premium_features.py b/tests/test_premium_features.py new file mode 100644 index 0000000..2aef414 --- /dev/null +++ b/tests/test_premium_features.py @@ -0,0 +1,44 @@ +from datetime import date + +from app.extensions import db +from app.models import Category, Expense, User + + +def test_expense_filters_apply(logged_user, app): + with app.app_context(): + user = User.query.filter_by(email='user@test.com').first() + category = Category.query.first() + db.session.add(Expense(user_id=user.id, title='Office Supplies', vendor='Paper Shop', tags='office', amount=20, currency='PLN', purchase_date=date(2026, 3, 12), payment_method='card', category_id=category.id, status='confirmed')) + db.session.add(Expense(user_id=user.id, title='Cinema', vendor='Movie', tags='fun', amount=40, currency='PLN', purchase_date=date(2026, 3, 12), payment_method='cash', status='new')) + db.session.commit() + category_id = category.id + response = logged_user.get(f'/expenses/?year=2026&month=3&q=Paper&category_id={category_id}&payment_method=card') + assert response.status_code == 200 + assert b'Office Supplies' in response.data + assert b'Cinema' not in response.data + + +def test_extended_analytics_payload(logged_user, app): + with app.app_context(): + user = User.query.filter_by(email='user@test.com').first() + db.session.add(Expense(user_id=user.id, title='Q1', amount=10, currency='PLN', purchase_date=date(2026, 1, 10), payment_method='card')) + db.session.add(Expense(user_id=user.id, title='Q2', amount=20, currency='PLN', purchase_date=date(2026, 4, 10), payment_method='card')) + db.session.commit() + response = logged_user.get('/analytics/data?year=2026&start_year=2025&end_year=2026') + data = response.get_json() + assert 'quarterly_totals' in data + assert 'weekday_totals' in data + assert any(item['quarter'] == 'Q1' for item in data['quarterly_totals']) + + +def test_statistics_page_uses_fixed_chart_canvas(logged_user): + response = logged_user.get('/statistics') + assert response.status_code == 200 + assert b'chart-canvas' in response.data + + +def test_create_page_has_mobile_camera_controls(logged_user): + response = logged_user.get('/expenses/create') + assert response.status_code == 200 + assert b'cameraCaptureButton' in response.data + assert b'data-mobile-hint' in response.data diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..0a23b5a --- /dev/null +++ b/wsgi.py @@ -0,0 +1,3 @@ +from app import create_app + +app = create_app()