From 986ffb200ad1ca325a3f7e0180e07551a399a623 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Fri, 13 Mar 2026 15:17:32 +0100 Subject: [PATCH] first commit --- .dockerignore | 21 + .env.example | 15 + .gitignore | 21 + Caddyfile | 10 + Dockerfile | 29 ++ README.md | 57 +++ app/__init__.py | 163 ++++++++ app/admin/__init__.py | 0 app/admin/routes.py | 193 +++++++++ app/api/__init__.py | 0 app/api/routes.py | 59 +++ app/auth/__init__.py | 0 app/auth/routes.py | 106 +++++ app/cli/__init__.py | 0 app/cli/commands.py | 81 ++++ app/config.py | 40 ++ app/expenses/__init__.py | 0 app/expenses/routes.py | 330 +++++++++++++++ app/extensions.py | 12 + app/forms.py | 107 +++++ app/main/__init__.py | 0 app/main/routes.py | 133 ++++++ app/models.py | 231 +++++++++++ app/services/__init__.py | 0 app/services/analytics.py | 115 ++++++ app/services/assets.py | 18 + app/services/audit.py | 20 + app/services/categorization.py | 25 ++ app/services/export.py | 47 +++ app/services/files.py | 63 +++ app/services/i18n.py | 37 ++ app/services/mail.py | 55 +++ app/services/ocr.py | 52 +++ app/services/reporting.py | 42 ++ app/services/scheduler.py | 32 ++ app/services/settings.py | 21 + app/static/css/app.css | 379 ++++++++++++++++++ app/static/i18n/en.json | 244 +++++++++++ app/static/i18n/pl.json | 244 +++++++++++ app/static/js/app.js | 172 ++++++++ ...5848-8562fe10fb404b7e89678e2af7bf1d46.webp | Bin 0 -> 33588 bytes .../one-a8d5983506994642bb8140141ac8c3f5.webp | Bin 0 -> 84 bytes .../one-f3e272100c2f477eaef5fd2fe767faae.webp | Bin 0 -> 84 bytes .../two-14dfaf6ec4a84f7ca637ab8454ef869c.webp | Bin 0 -> 80 bytes .../two-a21a4ab5a1004647b4581d798a0e2784.webp | Bin 0 -> 80 bytes ...95848-d71c6a1a12b2452cbc8c7d909f34e104.png | Bin 0 -> 53215 bytes .../one-60d229d3507942178d989004a32ba97a.png | Bin 0 -> 97 bytes .../one-bc49d4e12f6b4dd39748c88348f060b5.png | Bin 0 -> 97 bytes .../two-24768a3ece7f49d9adb180414b01539a.png | Bin 0 -> 98 bytes .../two-f78ba5d74e2c4607850c60f42d5872a9.png | Bin 0 -> 98 bytes app/templates/admin/audit.html | 14 + app/templates/admin/categories.html | 23 ++ app/templates/admin/dashboard.html | 32 ++ app/templates/admin/settings.html | 49 +++ app/templates/admin/users.html | 26 ++ app/templates/auth/forgot_password.html | 7 + app/templates/auth/login.html | 28 ++ app/templates/auth/register.html | 11 + app/templates/auth/reset_password.html | 7 + app/templates/base.html | 95 +++++ app/templates/errors/error.html | 15 + app/templates/expenses/budgets.html | 25 ++ app/templates/expenses/create.html | 73 ++++ app/templates/expenses/list.html | 210 ++++++++++ app/templates/mail/expense_report.html | 1 + app/templates/mail/expense_report.txt | 6 + app/templates/mail/layout.html | 1 + app/templates/mail/new_account.html | 1 + app/templates/mail/new_account.txt | 4 + app/templates/mail/password_reset.html | 1 + app/templates/mail/password_reset.txt | 4 + app/templates/main/dashboard.html | 1 + app/templates/main/preferences.html | 35 ++ app/templates/main/statistics.html | 37 ++ app/utils.py | 15 + docker-compose.yml | 23 ++ migrations/README.md | 5 + pyproject.toml | 3 + requirements.txt | 20 + requirements_dev.txt | 19 + run.py | 6 + tests/conftest.py | 49 +++ tests/test_admin.py | 56 +++ tests/test_admin_audit.py | 5 + tests/test_auth.py | 24 ++ tests/test_errors.py | 32 ++ tests/test_expenses.py | 110 +++++ tests/test_integrations.py | 89 ++++ tests/test_main.py | 40 ++ tests/test_premium_features.py | 44 ++ wsgi.py | 3 + 91 files changed, 4423 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Caddyfile create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/admin/__init__.py create mode 100644 app/admin/routes.py create mode 100644 app/api/__init__.py create mode 100644 app/api/routes.py create mode 100644 app/auth/__init__.py create mode 100644 app/auth/routes.py create mode 100644 app/cli/__init__.py create mode 100644 app/cli/commands.py create mode 100644 app/config.py create mode 100644 app/expenses/__init__.py create mode 100644 app/expenses/routes.py create mode 100644 app/extensions.py create mode 100644 app/forms.py create mode 100644 app/main/__init__.py create mode 100644 app/main/routes.py create mode 100644 app/models.py create mode 100644 app/services/__init__.py create mode 100644 app/services/analytics.py create mode 100644 app/services/assets.py create mode 100644 app/services/audit.py create mode 100644 app/services/categorization.py create mode 100644 app/services/export.py create mode 100644 app/services/files.py create mode 100644 app/services/i18n.py create mode 100644 app/services/mail.py create mode 100644 app/services/ocr.py create mode 100644 app/services/reporting.py create mode 100644 app/services/scheduler.py create mode 100644 app/services/settings.py create mode 100644 app/static/css/app.css create mode 100644 app/static/i18n/en.json create mode 100644 app/static/i18n/pl.json create mode 100644 app/static/js/app.js create mode 100644 app/static/previews/Zrzut_ekranu_20251111_195848-8562fe10fb404b7e89678e2af7bf1d46.webp create mode 100644 app/static/previews/one-a8d5983506994642bb8140141ac8c3f5.webp create mode 100644 app/static/previews/one-f3e272100c2f477eaef5fd2fe767faae.webp create mode 100644 app/static/previews/two-14dfaf6ec4a84f7ca637ab8454ef869c.webp create mode 100644 app/static/previews/two-a21a4ab5a1004647b4581d798a0e2784.webp create mode 100644 app/static/uploads/Zrzut_ekranu_20251111_195848-d71c6a1a12b2452cbc8c7d909f34e104.png create mode 100644 app/static/uploads/one-60d229d3507942178d989004a32ba97a.png create mode 100644 app/static/uploads/one-bc49d4e12f6b4dd39748c88348f060b5.png create mode 100644 app/static/uploads/two-24768a3ece7f49d9adb180414b01539a.png create mode 100644 app/static/uploads/two-f78ba5d74e2c4607850c60f42d5872a9.png create mode 100644 app/templates/admin/audit.html create mode 100644 app/templates/admin/categories.html create mode 100644 app/templates/admin/dashboard.html create mode 100644 app/templates/admin/settings.html create mode 100644 app/templates/admin/users.html create mode 100644 app/templates/auth/forgot_password.html create mode 100644 app/templates/auth/login.html create mode 100644 app/templates/auth/register.html create mode 100644 app/templates/auth/reset_password.html create mode 100644 app/templates/base.html create mode 100644 app/templates/errors/error.html create mode 100644 app/templates/expenses/budgets.html create mode 100644 app/templates/expenses/create.html create mode 100644 app/templates/expenses/list.html create mode 100644 app/templates/mail/expense_report.html create mode 100644 app/templates/mail/expense_report.txt create mode 100644 app/templates/mail/layout.html create mode 100644 app/templates/mail/new_account.html create mode 100644 app/templates/mail/new_account.txt create mode 100644 app/templates/mail/password_reset.html create mode 100644 app/templates/mail/password_reset.txt create mode 100644 app/templates/main/dashboard.html create mode 100644 app/templates/main/preferences.html create mode 100644 app/templates/main/statistics.html create mode 100644 app/utils.py create mode 100644 docker-compose.yml create mode 100644 migrations/README.md create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 requirements_dev.txt create mode 100644 run.py create mode 100644 tests/conftest.py create mode 100644 tests/test_admin.py create mode 100644 tests/test_admin_audit.py create mode 100644 tests/test_auth.py create mode 100644 tests/test_errors.py create mode 100644 tests/test_expenses.py create mode 100644 tests/test_integrations.py create mode 100644 tests/test_main.py create mode 100644 tests/test_premium_features.py create mode 100644 wsgi.py 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 0000000000000000000000000000000000000000..3b76a291b31645bb2a7d78fc1b8ae9fa3ea4b5d5 GIT binary patch literal 33588 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 ZA&3xUHl*pDRMP;Qg}O)~U;qFB001XhU1b0O literal 0 HcmV?d00001 diff --git a/app/static/previews/one-a8d5983506994642bb8140141ac8c3f5.webp b/app/static/previews/one-a8d5983506994642bb8140141ac8c3f5.webp new file mode 100644 index 0000000000000000000000000000000000000000..e877f0d464d43a8102a51e1c448240098f75aeb6 GIT binary patch literal 84 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 ID%R-#3!8U|_y7O^ literal 0 HcmV?d00001 diff --git a/app/static/uploads/one-60d229d3507942178d989004a32ba97a.png b/app/static/uploads/one-60d229d3507942178d989004a32ba97a.png new file mode 100644 index 0000000000000000000000000000000000000000..b5e8e02c8700a7edc2c5ff726c17424216c17522 GIT binary patch literal 97 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1SJ1Ryj={WG(24#Ln`LHJ!{Cwz`$|X;K2TL ngM<4*94<%SRQtmYQ~&|_vWt%}+IJ@?TY>nVu6{1-oD!Mr34 literal 0 HcmV?d00001 diff --git a/app/static/uploads/one-bc49d4e12f6b4dd39748c88348f060b5.png b/app/static/uploads/one-bc49d4e12f6b4dd39748c88348f060b5.png new file mode 100644 index 0000000000000000000000000000000000000000..b5e8e02c8700a7edc2c5ff726c17424216c17522 GIT binary patch literal 97 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1SJ1Ryj={WG(24#Ln`LHJ!{Cwz`$|X;K2TL ngM<4*94<%SRQtmYQ~&|_vWt%}+IJ@?TY>nVu6{1-oD!Mr34 literal 0 HcmV?d00001 diff --git a/app/static/uploads/two-24768a3ece7f49d9adb180414b01539a.png b/app/static/uploads/two-24768a3ece7f49d9adb180414b01539a.png new file mode 100644 index 0000000000000000000000000000000000000000..bfa46f4b4df825f06ed1dc83be66498461d0d6dd GIT binary patch literal 98 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1SJ1Ryj={WG(BA$Ln`LHJ!{Cwz`${MLu38< q1g3o<4#6j%Y_8kO3{(LI4Tr@R9%HoMBfs%3i0|p@=d#Wzp$Pz$;~QZB literal 0 HcmV?d00001 diff --git a/app/static/uploads/two-f78ba5d74e2c4607850c60f42d5872a9.png b/app/static/uploads/two-f78ba5d74e2c4607850c60f42d5872a9.png new file mode 100644 index 0000000000000000000000000000000000000000..bfa46f4b4df825f06ed1dc83be66498461d0d6dd GIT binary patch literal 98 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1SJ1Ryj={WG(BA$Ln`LHJ!{Cwz`${MLu38< q1g3o<4#6j%Y_8kO3{(LI4Tr@R9%HoMBfs%3i0|p@=d#Wzp$Pz$;~QZB literal 0 HcmV?d00001 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.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()