first commit

This commit is contained in:
Mateusz Gruszczyński
2026-03-13 15:17:32 +01:00
commit 986ffb200a
91 changed files with 4423 additions and 0 deletions

21
.dockerignore Normal file
View File

@@ -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

15
.env.example Normal file
View File

@@ -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

21
.gitignore vendored Normal file
View File

@@ -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

10
Caddyfile Normal file
View File

@@ -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"
}
}

29
Dockerfile Normal file
View File

@@ -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"]

57
README.md Normal file
View File

@@ -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
```

163
app/__init__.py Normal file
View File

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

0
app/admin/__init__.py Normal file
View File

193
app/admin/routes.py Normal file
View File

@@ -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/<int:user_id>/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'))

0
app/api/__init__.py Normal file
View File

59
app/api/routes.py Normal file
View File

@@ -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})

0
app/auth/__init__.py Normal file
View File

106
app/auth/routes.py Normal file
View File

@@ -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/<token>', 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)

0
app/cli/__init__.py Normal file
View File

81
app/cli/commands.py Normal file
View File

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

40
app/config.py Normal file
View File

@@ -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

0
app/expenses/__init__.py Normal file
View File

330
app/expenses/routes.py Normal file
View File

@@ -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('/<int:expense_id>/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('/<int:expense_id>/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)

12
app/extensions.py Normal file
View File

@@ -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'])

107
app/forms.py Normal file
View File

@@ -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')

0
app/main/__init__.py Normal file
View File

133
app/main/routes.py Normal file
View File

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

231
app/models.py Normal file
View File

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

0
app/services/__init__.py Normal file
View File

115
app/services/analytics.py Normal file
View File

@@ -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)]

18
app/services/assets.py Normal file
View File

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

20
app/services/audit.py Normal file
View File

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

View File

@@ -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

47
app/services/export.py Normal file
View File

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

63
app/services/files.py Normal file
View File

@@ -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

37
app/services/i18n.py Normal file
View File

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

55
app/services/mail.py Normal file
View File

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

52
app/services/ocr.py Normal file
View File

@@ -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],
)

42
app/services/reporting.py Normal file
View File

@@ -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

32
app/services/scheduler.py Normal file
View File

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

21
app/services/settings.py Normal file
View File

@@ -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

379
app/static/css/app.css Normal file
View File

@@ -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; }
}

244
app/static/i18n/en.json Normal file
View File

@@ -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"
}

244
app/static/i18n/pl.json Normal file
View File

@@ -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"
}

172
app/static/js/app.js Normal file
View File

@@ -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 => `<div class="card stat-overview-card"><div class="d-flex justify-content-between align-items-center"><div><div class="metric-label">${item.label}</div><div class="metric-value">${item.value}</div></div><span class="metric-icon"><i class="fa-solid ${item.icon}"></i></span></div><div class="small text-body-secondary mt-2">${text.vs_prev || 'Vs previous year'}: ${Number(comparison.percent_change || 0).toFixed(1)}%</div></div>`).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 => `<div class="top-expense-item"><div><strong>${x.title}</strong><div class="small text-body-secondary">${x.date}</div></div><div class="top-expense-amount">${x.amount}</div></div>`).join('')
: `<div class="text-body-secondary">${text.no_data || 'No data'}</div>`;
}
});
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}});
}
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 B

View File

@@ -0,0 +1,14 @@
{% extends 'base.html' %}
{% block content %}
<div class="hero-panel mb-4">
<div class="app-section-title mb-0">
<span class="feature-icon"><i class="fa-solid fa-clipboard-list"></i></span>
<div>
<h1 class="h3 mb-0">{{ t('admin.audit') }}</h1>
<div class="text-body-secondary">{{ t('admin.audit_subtitle') }}</div>
</div>
</div>
</div>
<div class="card"><div class="card-body"><div class="table-responsive"><table class="table align-middle mb-0"><thead><tr><th>{{ t('common.date') }}</th><th>User</th><th>Action</th><th>Target</th><th>Details</th></tr></thead><tbody>{% for log in logs %}<tr><td>{{ log.created_at.strftime('%Y-%m-%d %H:%M') }}</td><td>{{ log.user.email if log.user else '-' }}</td><td>{{ log.action }}</td><td>{{ log.target_type }} #{{ log.target_id }}</td><td class="small text-body-secondary">{{ log.details }}</td></tr>{% else %}<tr><td colspan="5" class="text-body-secondary">{{ t('stats.no_data') }}</td></tr>{% endfor %}</tbody></table></div></div></div>
{% endblock %}

View File

@@ -0,0 +1,23 @@
{% extends 'base.html' %}
{% block content %}
<div class="row g-3">
<div class="col-lg-4">
<div class="card shadow-sm"><div class="card-body">
<h1 class="h4 mb-3">{{ t('admin.categories') }}</h1>
<form method="post">{{ form.hidden_tag() }}
<div class="row g-3">
<div class="col-12">{{ form.key.label(class='form-label') }}{{ form.key(class='form-control') }}</div>
<div class="col-md-6">{{ form.name_pl.label(class='form-label') }}{{ form.name_pl(class='form-control') }}</div>
<div class="col-md-6">{{ form.name_en.label(class='form-label') }}{{ form.name_en(class='form-control') }}</div>
<div class="col-md-6">{{ form.color.label(class='form-label') }}{{ form.color(class='form-select') }}</div>
<div class="col-12">{{ form.submit(class='btn btn-primary w-100') }}</div>
</div>
</form>
</div></div>
</div>
<div class="col-lg-8"><div class="card shadow-sm"><div class="card-body"><div class="table-responsive"><table class="table">
<thead><tr><th>{{ t('common.name') }}</th><th>PL</th><th>EN</th><th>{{ t('common.status') }}</th></tr></thead>
<tbody>{% for category in categories %}<tr><td>{{ category.key }}</td><td>{{ category.name_pl }}</td><td>{{ category.name_en }}</td><td>{{ t('common.active') if category.is_active else t('common.inactive') }}</td></tr>{% endfor %}</tbody>
</table></div></div></div></div>
</div>
{% endblock %}

View File

@@ -0,0 +1,32 @@
{% extends 'base.html' %}
{% block content %}
<div class="hero-panel mb-4">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3">
<div class="app-section-title mb-0">
<span class="feature-icon"><i class="fa-solid fa-shield-halved"></i></span>
<div>
<h1 class="h3 mb-0">{{ t('admin.title') }}</h1>
<div class="text-body-secondary">{{ t('admin.subtitle') }}</div>
</div>
</div>
<div class="d-flex gap-2 flex-wrap">
<form method="post" action="{{ url_for('admin.run_reports') }}"><button class="btn btn-outline-primary"><i class="fa-solid fa-paper-plane me-2"></i>Run reports</button></form>
<a class="btn btn-outline-secondary" href="{{ url_for('admin.users') }}"><i class="fa-solid fa-users me-2"></i>{{ t('admin.users') }}</a>
<a class="btn btn-outline-secondary" href="{{ url_for('admin.categories') }}"><i class="fa-solid fa-tags me-2"></i>{{ t('admin.categories') }}</a>
<a class="btn btn-outline-secondary" href="{{ url_for('admin.settings') }}"><i class="fa-solid fa-gears me-2"></i>{{ t('admin.settings') }}</a>
<a class="btn btn-primary" href="{{ url_for('admin.audit') }}"><i class="fa-solid fa-clipboard-list me-2"></i>{{ t('admin.audit') }}</a>
</div>
</div>
</div>
<div class="quick-stats mb-4">
<div class="card metric-card"><div class="d-flex justify-content-between align-items-center"><div><div class="text-body-secondary">{{ t('admin.users') }}</div><div class="fs-3 fw-bold">{{ stats.users }}</div></div><span class="metric-icon"><i class="fa-solid fa-users"></i></span></div></div>
<div class="card metric-card"><div class="d-flex justify-content-between align-items-center"><div><div class="text-body-secondary">{{ t('admin.categories') }}</div><div class="fs-3 fw-bold">{{ stats.categories }}</div></div><span class="metric-icon"><i class="fa-solid fa-tags"></i></span></div></div>
<div class="card metric-card"><div class="d-flex justify-content-between align-items-center"><div><div class="text-body-secondary">{{ t('admin.audit') }}</div><div class="fs-3 fw-bold">{{ stats.audit_logs }}</div></div><span class="metric-icon"><i class="fa-solid fa-clipboard-list"></i></span></div></div>
<div class="card metric-card"><div class="d-flex justify-content-between align-items-center"><div><div class="text-body-secondary">Admins</div><div class="fs-3 fw-bold">{{ stats.admins }}</div></div><span class="metric-icon"><i class="fa-solid fa-user-shield"></i></span></div></div>
</div>
<div class="row g-3">
<div class="col-lg-6"><div class="card"><div class="card-body"><h2 class="h5 mb-3"><i class="fa-solid fa-server me-2"></i>{{ t('admin.system') }}</h2><div class="small text-body-secondary d-grid gap-2"><div><strong>{{ t('admin.python') }}:</strong> {{ system.python }}</div><div><strong>{{ t('admin.platform') }}:</strong> {{ system.platform }}</div><div><strong>{{ t('admin.environment') }}:</strong> {{ system.flask_env }}</div><div><strong>{{ t('admin.instance_path') }}:</strong> {{ system.instance_path }}</div><div><strong>{{ t('admin.uploads') }}:</strong> {{ system.upload_count }}</div><div><strong>{{ t('admin.previews') }}:</strong> {{ system.preview_count }}</div><div><strong>{{ t('admin.webhook') }}:</strong> {{ t('common.enabled') if system.webhook_enabled else t('common.disabled') }}</div><div><strong>{{ t('admin.scheduler') }}:</strong> {{ t('common.enabled') if system.scheduler_enabled else t('common.disabled') }}</div></div></div></div></div>
<div class="col-lg-6"><div class="card"><div class="card-body"><h2 class="h5 mb-3"><i class="fa-solid fa-database me-2"></i>{{ t('admin.database') }}</h2><div class="small text-body-secondary d-grid gap-2"><div><strong>Engine:</strong> {{ db_info.engine }}</div><div><strong>URL:</strong> {{ db_info.url }}</div><div><strong>Version:</strong> {{ db_version }}</div><div><strong>Max upload MB:</strong> {{ system.max_upload_mb }}</div></div></div></div></div>
<div class="col-12"><div class="card"><div class="card-body"><div class="d-flex justify-content-between align-items-center mb-3"><h2 class="h5 mb-0"><i class="fa-solid fa-clock-rotate-left me-2"></i>{{ t('admin.audit') }}</h2><a href="{{ url_for('admin.audit') }}" class="btn btn-sm btn-outline-secondary">{{ t('common.view_all') }}</a></div><div class="table-responsive"><table class="table align-middle mb-0"><thead><tr><th>{{ t('common.date') }}</th><th>Action</th><th>Target</th><th>Details</th></tr></thead><tbody>{% for log in recent_logs %}<tr><td>{{ log.created_at.strftime('%Y-%m-%d %H:%M') }}</td><td>{{ log.action }}</td><td>{{ log.target_type }} #{{ log.target_id }}</td><td class="small text-body-secondary">{{ log.details }}</td></tr>{% else %}<tr><td colspan="4" class="text-body-secondary">{{ t('stats.no_data') }}</td></tr>{% endfor %}</tbody></table></div></div></div></div>
</div>
{% endblock %}

View File

@@ -0,0 +1,49 @@
{% extends 'base.html' %}
{% block content %}
<div class="mx-auto" style="max-width:1080px">
<div class="app-section-title mb-4">
<span class="feature-icon"><i class="fa-solid fa-gears"></i></span>
<div><h1 class="h3 mb-0">{{ t('admin.settings') }}</h1><div class="text-body-secondary">{{ t('admin.settings_subtitle') }}</div></div>
</div>
<form method="post" class="row g-3 settings-section">
<div class="col-12 col-xl-6"><div class="card h-100"><div class="card-body">
<h2 class="h5 mb-3">{{ t('admin.section_general') }}</h2>
<div class="row g-3">
<div class="col-md-8"><label class="form-label">{{ t('admin.company_name') }}</label><input class="form-control" name="company_name" value="{{ values.get('company_name','') }}"></div>
<div class="col-md-4"><label class="form-label">{{ t('admin.max_upload_mb') }}</label><input class="form-control" name="max_upload_mb" value="{{ values.get('max_upload_mb','10') }}"></div>
<div class="col-12 form-check ms-2"><input class="form-check-input" type="checkbox" name="registration_enabled" {% if values.get('registration_enabled') == 'true' %}checked{% endif %}><label class="form-check-label">{{ t('admin.registration_enabled') }}</label></div>
</div>
</div></div></div>
<div class="col-12 col-xl-6"><div class="card h-100"><div class="card-body">
<h2 class="h5 mb-3">{{ t('admin.smtp_section') }}</h2>
<div class="row g-3">
<div class="col-md-8"><label class="form-label">{{ t('admin.smtp_host') }}</label><input class="form-control" name="smtp_host" value="{{ values.get('smtp_host','') }}"></div>
<div class="col-md-4"><label class="form-label">{{ t('admin.smtp_port') }}</label><input class="form-control" name="smtp_port" value="{{ values.get('smtp_port','465') }}"></div>
<div class="col-md-6"><label class="form-label">{{ t('admin.smtp_security') }}</label><select class="form-select" name="smtp_security"><option value="plain" {% if values.get('smtp_security')=='plain' %}selected{% endif %}>{{ t('admin.smtp_plain') }}</option><option value="starttls" {% if values.get('smtp_security')=='starttls' %}selected{% endif %}>STARTTLS</option><option value="ssl" {% if values.get('smtp_security')=='ssl' %}selected{% endif %}>SSL/TLS</option></select></div>
<div class="col-md-6"><label class="form-label">{{ t('admin.smtp_sender') }}</label><input class="form-control" name="smtp_sender" value="{{ values.get('smtp_sender','') }}"></div>
<div class="col-md-6"><label class="form-label">{{ t('admin.smtp_username') }}</label><input class="form-control" name="smtp_username" value="{{ values.get('smtp_username','') }}"></div>
<div class="col-md-6"><label class="form-label">{{ t('admin.smtp_password') }}</label><input class="form-control" type="password" name="smtp_password" value="{{ values.get('smtp_password','') }}"></div>
</div>
</div></div></div>
<div class="col-12 col-xl-6"><div class="card h-100"><div class="card-body">
<h2 class="h5 mb-3">{{ t('admin.section_reports') }}</h2>
<div class="row g-3">
<div class="col-12 form-check ms-2"><input class="form-check-input" type="checkbox" name="reports_enabled" {% if values.get('reports_enabled','true') == 'true' %}checked{% endif %}><label class="form-check-label">{{ t('admin.reports_enabled') }}</label></div>
<div class="col-12 form-check ms-2"><input class="form-check-input" type="checkbox" name="report_scheduler_enabled" {% if values.get('report_scheduler_enabled') == 'true' %}checked{% endif %}><label class="form-check-label">{{ t('admin.enable_scheduler') }}</label></div>
<div class="col-12"><div class="form-text">{{ t('admin.reports_hint') }}</div></div>
</div>
</div></div></div>
<div class="col-12 col-xl-6"><div class="card h-100"><div class="card-body">
<h2 class="h5 mb-3">{{ t('admin.section_integrations') }}</h2>
<div class="row g-3">
<div class="col-12"><label class="form-label">{{ t('admin.webhook_token') }}</label><input class="form-control" name="webhook_api_token" value="{{ values.get('webhook_api_token','') }}"></div>
</div>
</div></div></div>
<div class="col-12"><button class="btn btn-primary"><i class="fa-solid fa-floppy-disk me-2"></i>{{ t('common.save') }}</button></div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,26 @@
{% extends 'base.html' %}
{% block content %}
<div class="row g-3">
<div class="col-xl-4"><div class="card shadow-sm"><div class="card-body">
<h1 class="h4 mb-3">{{ t('admin.users') }}</h1>
<form method="post">{{ form.hidden_tag() }}
<div class="mb-2"><label class="form-label">{{ t('user.full_name') }}</label>{{ form.full_name(class='form-control') }}</div>
<div class="mb-2"><label class="form-label">{{ t('user.email') }}</label>{{ form.email(class='form-control') }}</div>
<div class="row g-2">
<div class="col-md-6"><label class="form-label">{{ t('common.role') }}</label>{{ form.role(class='form-select') }}</div>
<div class="col-md-6"><label class="form-label">{{ t('preferences.language') }}</label>{{ form.language(class='form-select') }}</div>
<div class="col-md-6"><label class="form-label">{{ t('preferences.reports') }}</label>{{ form.report_frequency(class='form-select') }}</div>
<div class="col-md-6"><label class="form-label">{{ t('preferences.theme') }}</label>{{ form.theme(class='form-select') }}</div>
</div>
<div class="form-check mt-2">{{ form.is_active_user(class='form-check-input') }} <label class="form-check-label">{{ t('user.active') }}</label></div>
<div class="form-check">{{ form.must_change_password(class='form-check-input') }} <label class="form-check-label">{{ t('user.must_change_password') }}</label></div>
<button class="btn btn-primary mt-3">{{ t('common.save') }}</button>
{% if editing_user %}<a class="btn btn-outline-secondary mt-3" href="{{ url_for('admin.users') }}">{{ t('common.cancel') }}</a>{% endif %}
</form>
</div></div></div>
<div class="col-xl-8"><div class="card shadow-sm"><div class="card-body"><div class="table-responsive"><table class="table align-middle">
<thead><tr><th>{{ t('common.name') }}</th><th>{{ t('user.email') }}</th><th>{{ t('common.role') }}</th><th>{{ t('preferences.language') }}</th><th>{{ t('preferences.reports') }}</th><th>{{ t('common.status') }}</th><th></th></tr></thead>
<tbody>{% for user in users %}<tr><td>{{ user.full_name }}</td><td>{{ user.email }}</td><td>{{ user.role }}</td><td>{{ user.language }}</td><td>{{ user.report_frequency }}</td><td>{% if user.is_active_user %}<span class="badge text-bg-success">{{ t('common.active') }}</span>{% else %}<span class="badge text-bg-secondary">{{ t('common.inactive') }}</span>{% endif %}{% if user.must_change_password %}<span class="badge text-bg-warning">{{ t('user.must_change_password_short') }}</span>{% endif %}</td><td class="text-end"><a class="btn btn-sm btn-outline-primary" href="{{ url_for('admin.users', edit=user.id) }}"><i class="fa-solid fa-pen-to-square"></i></a><form method="post" action="{{ url_for('admin.toggle_password_change', user_id=user.id) }}" class="d-inline">{{ csrf_token() if csrf_token else '' }}<button class="btn btn-sm btn-outline-secondary">{{ t('common.toggle') }}</button></form></td></tr>{% endfor %}</tbody>
</table></div></div></div></div>
</div>
{% endblock %}

View File

@@ -0,0 +1,7 @@
{% extends 'base.html' %}
{% block content %}
<div class="row justify-content-center"><div class="col-12 col-md-6 col-xl-4"><div class="card border-0 shadow-sm"><div class="card-body p-4">
<h1 class="h4 mb-3">{{ t('auth.reset_request') }}</h1>
<form method="post">{{ form.hidden_tag() }}<div class="d-none">{{ form.website() }}</div><div class="mb-3">{{ form.email.label(class_='form-label') }}{{ form.email(class_='form-control') }}</div>{{ form.submit(class_='btn btn-primary w-100') }}</form>
</div></div></div></div>
{% endblock %}

View File

@@ -0,0 +1,28 @@
{% extends 'base.html' %}
{% block content %}
<div class="row justify-content-center">
<div class="col-12 col-md-6 col-xl-4">
<div class="card shadow-sm border-0 login-card">
<div class="card-body p-4">
<div class="text-center mb-4">
<div class="brand-icon mb-3"><i class="fa-solid fa-wallet"></i></div>
<h1 class="h3 mb-1">{{ t('auth.login_title') }}</h1>
<p class="text-secondary mb-0">{{ t('auth.login_subtitle') }}</p>
</div>
<form method="post">
{{ form.hidden_tag() }}
<div class="d-none">{{ form.website() }}</div>
<div class="mb-3">{{ form.email.label(class_='form-label') }}{{ form.email(class_='form-control') }}</div>
<div class="mb-3">{{ form.password.label(class_='form-label') }}{{ form.password(class_='form-control') }}</div>
<div class="form-check mb-3">{{ form.remember_me(class_='form-check-input') }} {{ form.remember_me.label(class_='form-check-label') }}</div>
{{ form.submit(class_='btn btn-primary w-100') }}
</form>
<div class="d-flex justify-content-between mt-3 small">
<a href="{{ url_for('auth.forgot_password') }}">{{ t('auth.forgot_password') }}</a>
<a href="{{ url_for('auth.register') }}">{{ t('auth.register') }}</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% extends 'base.html' %}
{% block content %}
<div class="row justify-content-center"><div class="col-12 col-md-7 col-xl-5"><div class="card border-0 shadow-sm"><div class="card-body p-4">
<h1 class="h3 mb-3">{{ t('auth.register') }}</h1>
<form method="post">{{ form.hidden_tag() }}<div class="d-none">{{ form.website() }}</div>
<div class="mb-3">{{ form.full_name.label(class_='form-label') }}{{ form.full_name(class_='form-control') }}</div>
<div class="mb-3">{{ form.email.label(class_='form-label') }}{{ form.email(class_='form-control') }}</div>
<div class="mb-3">{{ form.password.label(class_='form-label') }}{{ form.password(class_='form-control') }}</div>
<div class="mb-3">{{ form.confirm_password.label(class_='form-label') }}{{ form.confirm_password(class_='form-control') }}</div>
{{ form.submit(class_='btn btn-primary w-100') }}</form></div></div></div></div>
{% endblock %}

View File

@@ -0,0 +1,7 @@
{% extends 'base.html' %}
{% block content %}
<div class="row justify-content-center"><div class="col-12 col-md-6 col-xl-4"><div class="card border-0 shadow-sm"><div class="card-body p-4">
<h1 class="h4 mb-3">{{ t('auth.new_password') }}</h1>
<form method="post">{{ form.hidden_tag() }}<div class="mb-3">{{ form.password.label(class_='form-label') }}{{ form.password(class_='form-control') }}</div><div class="mb-3">{{ form.confirm_password.label(class_='form-label') }}{{ form.confirm_password(class_='form-control') }}</div>{{ form.submit(class_='btn btn-primary w-100') }}</form>
</div></div></div></div>
{% endblock %}

95
app/templates/base.html Normal file
View File

@@ -0,0 +1,95 @@
<!doctype html>
<html lang="{{ current_language }}" data-bs-theme="{{ current_user.theme if current_user.is_authenticated else 'light' }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Expense Monitor</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
<link rel="stylesheet" href="{{ asset_url('css/app.css') }}">
<link rel="manifest" href="{{ url_for('manifest') }}">
</head>
<body class="theme-{{ current_user.theme if current_user.is_authenticated else 'light' }}">
{% if current_user.is_authenticated %}
<div class="app-shell">
<aside class="app-sidebar d-none d-lg-flex flex-column">
<a class="sidebar-brand mb-4" href="{{ url_for('main.dashboard') }}">
<span class="brand-mark"><i class="fa-solid fa-wallet"></i></span>
<span>
Expense Monitor
<small>{{ t('brand.subtitle') }}</small>
</span>
</a>
<nav class="sidebar-nav nav flex-column gap-1">
<a class="nav-link" href="{{ url_for('main.dashboard') }}"><i class="fa-solid fa-house"></i><span>{{ t('nav.dashboard') }}</span></a>
<a class="nav-link" href="{{ url_for('expenses.list_expenses') }}"><i class="fa-solid fa-receipt"></i><span>{{ t('nav.expenses') }}</span></a>
<a class="nav-link" href="{{ url_for('main.statistics') }}"><i class="fa-solid fa-chart-line"></i><span>{{ t('nav.statistics') }}</span></a>
<a class="nav-link" href="{{ url_for('expenses.budgets') }}"><i class="fa-solid fa-bullseye"></i><span>{{ t('nav.budgets') }}</span></a>
<a class="nav-link" href="{{ url_for('expenses.create_expense') }}"><i class="fa-solid fa-plus"></i><span>{{ t('nav.add_expense') }}</span></a>
<a class="nav-link" href="{{ url_for('main.preferences') }}"><i class="fa-solid fa-sliders"></i><span>{{ t('nav.preferences') }}</span></a>
{% if current_user.is_admin() %}
<a class="nav-link" href="{{ url_for('admin.dashboard') }}"><i class="fa-solid fa-shield-halved"></i><span>{{ t('nav.admin') }}</span></a>
{% endif %}
</nav>
<div class="sidebar-user mt-auto">
<div class="small text-body-secondary">{{ current_user.full_name }}</div>
<div class="fw-semibold text-truncate">{{ current_user.email }}</div>
<a class="btn btn-outline-secondary w-100 mt-3" href="{{ url_for('auth.logout') }}"><i class="fa-solid fa-right-from-bracket me-2"></i>{{ t('nav.logout') }}</a>
</div>
</aside>
<div class="app-main">
{% endif %}
<nav class="navbar app-navbar navbar-expand-lg sticky-top">
<div class="container-fluid px-3 px-lg-4">
<a class="navbar-brand fw-semibold d-flex align-items-center gap-2" href="{{ url_for('main.dashboard' if current_user.is_authenticated else 'auth.login') }}">
<span class="brand-mark"><i class="fa-solid fa-wallet"></i></span>
<span class="navbar-brand-text">Expense Monitor<small>{{ t('brand.subtitle') }}</small></span>
</a>
<div class="d-flex align-items-center ms-auto gap-2 order-lg-2">
<form method="post" action="{{ url_for('main.set_language') }}" class="language-picker">
{% if csrf_token %}{{ csrf_token() }}{% endif %}
<input type="hidden" name="next" value="{{ request.full_path if request.query_string else request.path }}">
<button class="btn btn-sm btn-light flag-btn {% if current_language=='pl' %}active{% endif %}" name="language" value="pl" type="submit" title="Polski">🇵🇱</button>
<button class="btn btn-sm btn-light flag-btn {% if current_language=='en' %}active{% endif %}" name="language" value="en" type="submit" title="English">🇬🇧</button>
</form>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navMain" aria-controls="navMain" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
</div>
<div class="collapse navbar-collapse order-lg-1" id="navMain">
{% if current_user.is_authenticated %}
<ul class="navbar-nav ms-auto align-items-lg-center gap-lg-2 py-2 py-lg-0 d-lg-none">
<li class="nav-item"><a class="nav-link" href="{{ url_for('main.dashboard') }}"><i class="fa-solid fa-house me-2"></i>{{ t('nav.dashboard') }}</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('expenses.list_expenses') }}"><i class="fa-solid fa-receipt me-2"></i>{{ t('nav.expenses') }}</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('main.statistics') }}"><i class="fa-solid fa-chart-line me-2"></i>{{ t('nav.statistics') }}</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('expenses.budgets') }}"><i class="fa-solid fa-bullseye me-2"></i>{{ t('nav.budgets') }}</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('expenses.create_expense') }}"><i class="fa-solid fa-plus me-2"></i>{{ t('nav.add_expense') }}</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('main.preferences') }}"><i class="fa-solid fa-sliders me-2"></i>{{ t('nav.preferences') }}</a></li>
{% if current_user.is_admin() %}<li class="nav-item"><a class="nav-link" href="{{ url_for('admin.dashboard') }}"><i class="fa-solid fa-shield-halved me-2"></i>{{ t('nav.admin') }}</a></li>{% endif %}
<li class="nav-item ms-lg-2"><a class="btn btn-outline-secondary btn-sm" href="{{ url_for('auth.logout') }}"><i class="fa-solid fa-right-from-bracket me-2"></i>{{ t('nav.logout') }}</a></li>
</ul>
{% endif %}
</div>
</div>
</nav>
<main class="container{% if current_user.is_authenticated %}-fluid{% endif %} py-4 app-content">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show border-0 shadow-sm" role="alert">{{ message }}<button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
{% if current_user.is_authenticated %}
</div>
</div>
{% endif %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.8/dist/chart.umd.min.js"></script>
<script src="{{ asset_url('js/app.js') }}"></script>
<script>if('serviceWorker' in navigator){window.addEventListener('load',()=>navigator.serviceWorker.register('{{ url_for('service_worker') }}').catch(()=>{}));}</script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,15 @@
{% extends 'base.html' %}
{% block content %}
<div class="row justify-content-center">
<div class="col-12 col-md-8 col-xl-6">
<div class="card border-0 shadow-sm">
<div class="card-body p-4 text-center">
<div class="display-4 fw-bold text-primary mb-2">{{ status_code }}</div>
<h1 class="h3">{{ title }}</h1>
<p class="text-secondary mb-4">{{ message }}</p>
<a class="btn btn-primary" href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}">Go back</a>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,25 @@
{% extends 'base.html' %}
{% block content %}
<div class="row g-3">
<div class="col-lg-4">
<div class="card shadow-sm"><div class="card-body">
<h1 class="h4">{{ t('budgets.add') }}</h1>
<form method="post">{{ form.hidden_tag() }}
<div class="mb-2">{{ form.category_id.label(class='form-label') }}{{ form.category_id(class='form-select') }}</div>
<div class="row g-2"><div class="col">{{ form.year.label(class='form-label') }}{{ form.year(class='form-control') }}</div><div class="col">{{ form.month.label(class='form-label') }}{{ form.month(class='form-control') }}</div></div>
<div class="mt-2">{{ form.amount.label(class='form-label') }}{{ form.amount(class='form-control') }}</div>
<div class="mt-2">{{ form.alert_percent.label(class='form-label') }}{{ form.alert_percent(class='form-control') }}</div>
<button class="btn btn-primary mt-3">{{ t('common.save') }}</button>
</form>
</div></div>
</div>
<div class="col-lg-8">
<div class="card shadow-sm"><div class="card-body">
<h2 class="h5">{{ t('budgets.title') }}</h2>
<div class="table-responsive"><table class="table"><thead><tr><th>{{ t('expenses.category') }}</th><th>{{ t('common.year') }}</th><th>{{ t('common.month') }}</th><th>{{ t('expenses.amount') }}</th></tr></thead><tbody>
{% for budget in budgets %}<tr><td>{{ budget.category.localized_name(current_language) }}</td><td>{{ budget.year }}</td><td>{{ budget.month }}</td><td>{{ budget.amount }}</td></tr>{% else %}<tr><td colspan="4" class="text-body-secondary">{{ t('expenses.empty') }}</td></tr>{% endfor %}
</tbody></table></div>
</div></div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,73 @@
{% extends 'base.html' %}
{% block content %}
<div class="card mx-auto" style="max-width: 1080px;">
<div class="card-body p-4 p-lg-5">
<div class="app-section-title">
<span class="feature-icon"><i class="fa-solid fa-file-circle-plus"></i></span>
<div>
<h1 class="h3 mb-0">{{ t('expenses.edit') if expense else t('expenses.new') }}</h1>
<div class="text-body-secondary">{{ t('expenses.form_subtitle') }}</div>
</div>
</div>
<form method="post" enctype="multipart/form-data">
{{ form.hidden_tag() }}
<div class="row g-3">
<div class="col-lg-7">
<div class="row g-3">
<div class="col-md-6">{{ form.amount.label(class='form-label') }}{{ form.amount(class='form-control') }}</div>
<div class="col-md-6">{{ form.purchase_date.label(class='form-label') }}{{ form.purchase_date(class='form-control') }}</div>
<div class="col-md-7">{{ form.category_id.label(class='form-label') }}{{ form.category_id(class='form-select') }}</div>
<div class="col-md-5">{{ form.payment_method.label(class='form-label') }}{{ form.payment_method(class='form-select') }}</div>
<div class="col-md-8">{{ form.title.label(class='form-label') }}{{ form.title(class='form-control', placeholder=t('expenses.placeholder_title')) }}</div>
<div class="col-md-4">{{ form.currency.label(class='form-label') }}{{ form.currency(class='form-select') }}</div>
<div class="col-md-6">{{ form.vendor.label(class='form-label') }}{{ form.vendor(class='form-control', placeholder=t('expenses.placeholder_vendor')) }}</div>
<div class="col-md-6">
{{ form.document.label(class='form-label') }}
<div class="upload-actions d-grid gap-2" id="documentUploadActions">
<button class="btn btn-outline-primary d-none" type="button" id="cameraCaptureButton"><i class="fa-solid fa-camera me-2"></i>{{ t('expenses.take_photo') }}</button>
<button class="btn btn-outline-secondary" type="button" id="filePickerButton"><i class="fa-solid fa-upload me-2"></i>{{ t('expenses.select_files') }}</button>
</div>
<div class="drop-upload-zone mt-2 d-none d-lg-flex" id="dropUploadZone"><i class="fa-solid fa-cloud-arrow-up me-2"></i><span>{{ t('expenses.drop_files_here') }}</span></div><div class="form-text mt-2" id="documentInputHint" data-desktop-hint="{{ t('expenses.upload_hint_desktop') }}" data-mobile-hint="{{ t('expenses.upload_hint_mobile') }}">{{ t('expenses.upload_hint_desktop') }}</div>
{{ form.document(class='d-none', id='documentInput', accept='.jpg,.jpeg,.png,.heic,.pdf,image/*,application/pdf', multiple=true) }}
</div>
<div class="col-12">{{ form.description.label(class='form-label') }}{{ form.description(class='form-control', rows='3', placeholder=t('expenses.placeholder_description')) }}</div>
<div class="col-md-5">{{ form.tags.label(class='form-label') }}{{ form.tags(class='form-control', placeholder=t('expenses.placeholder_tags')) }}</div>
<div class="col-md-3">{{ form.status.label(class='form-label') }}{{ form.status(class='form-select') }}</div>
<div class="col-md-4">{{ form.recurring_period.label(class='form-label') }}{{ form.recurring_period(class='form-select') }}</div>
</div>
</div>
<div class="col-lg-5">
<div class="card h-100 document-editor-card"><div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="h5 mb-0"><i class="fa-solid fa-wand-magic-sparkles me-2"></i>{{ t('expenses.document_tools') }}</h2>
<span class="badge soft-badge">{{ t('expenses.webp_preview') }}</span>
</div>
<div class="document-editor-toolbar mb-3 d-flex flex-wrap gap-2">
<button class="btn btn-sm btn-outline-secondary js-rotate" type="button" data-step="-90"><i class="fa-solid fa-rotate-left me-1"></i>-90°</button>
<button class="btn btn-sm btn-outline-secondary js-rotate" type="button" data-step="90"><i class="fa-solid fa-rotate-right me-1"></i>+90°</button>
<button class="btn btn-sm btn-outline-secondary js-scale" type="button" data-step="-10"><i class="fa-solid fa-magnifying-glass-minus me-1"></i>-10%</button>
<button class="btn btn-sm btn-outline-secondary js-scale" type="button" data-step="10"><i class="fa-solid fa-magnifying-glass-plus me-1"></i>+10%</button>
<button class="btn btn-sm btn-outline-secondary" type="button" id="editorReset"><i class="fa-solid fa-arrows-rotate me-1"></i>{{ t('common.reset') }}</button>
</div>
<div class="editor-meta row g-2 mb-3">
<div class="col-6">{{ form.rotate.label(class='form-label small') }}{{ form.rotate(class='form-control', readonly=true, id='rotateField') }}</div>
<div class="col-6">{{ form.scale_percent.label(class='form-label small') }}{{ form.scale_percent(class='form-control', readonly=true, id='scaleField') }}</div>
</div>
<div class="document-preview-shell">
<div id="documentPreviewStage" class="document-preview-stage">
<img id="documentPreviewImage" alt="preview" class="d-none">
<div id="documentPreviewEmpty" class="document-preview-empty"><i class="fa-regular fa-image"></i><div>{{ t('expenses.upload_to_edit') }}</div></div>
<div id="cropSelection" class="crop-selection d-none"></div>
</div>
</div>
<div class="footer-note mt-3">{{ t('expenses.crop_note') }}</div>
{% if expense and expense.attachments %}<div class="mt-3"><div class="small text-body-secondary mb-2">Existing files</div><div class="d-flex flex-wrap gap-2">{% for item in expense.attachments %}{% if item.preview_filename %}<a class="d-inline-block" target="_blank" href="{{ url_for('static', filename='previews/' ~ item.preview_filename) }}"><img class="expense-row-thumb" src="{{ url_for('static', filename='previews/' ~ item.preview_filename) }}" alt="preview"></a>{% else %}<span class="badge text-bg-secondary">{{ item.original_filename }}</span>{% endif %}{% endfor %}</div></div>{% endif %}
<div class="d-flex gap-3 mt-3 flex-wrap"><div class="form-check">{{ form.is_refund(class='form-check-input') }} {{ form.is_refund.label(class='form-check-label') }}</div><div class="form-check">{{ form.is_business(class='form-check-input') }} {{ form.is_business.label(class='form-check-label') }}</div></div>
</div></div>
</div>
</div>
<button class="btn btn-primary mt-4 px-4"><i class="fa-solid fa-floppy-disk me-2"></i>{{ t('expenses.save') }}</button>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,210 @@
{% extends 'base.html' %}
{% block content %}
<div class="hero-panel mb-4">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3">
<div>
<div class="app-section-title mb-2">
<span class="feature-icon"><i class="fa-solid fa-receipt"></i></span>
<div>
<h1 class="h3 mb-0">{{ t('expenses.list') }}</h1>
<div class="text-body-secondary">{{ selected_year }}-{{ '%02d'|format(selected_month) }}</div>
</div>
</div>
</div>
<div class="d-flex gap-2 flex-wrap">
<a class="btn btn-outline-secondary" href="{{ url_for('expenses.export_csv', **request.args) }}"><i class="fa-solid fa-file-csv me-2"></i>{{ t('expenses.export_csv') }}</a>
<a class="btn btn-outline-secondary" href="{{ url_for('expenses.export_pdf', **request.args) }}"><i class="fa-solid fa-file-pdf me-2"></i>{{ t('expenses.export_pdf') }}</a>
<a class="btn btn-primary" href="{{ url_for('expenses.create_expense') }}"><i class="fa-solid fa-plus me-2"></i>{{ t('nav.add_expense') }}</a>
</div>
</div>
<div class="month-switcher mb-3">
<a class="btn btn-outline-secondary" href="{{ url_for('expenses.list_expenses', year=selected_year if selected_month>1 else selected_year-1, month=selected_month-1 if selected_month>1 else 12, category_id=filters.category_id or None, payment_method=filters.payment_method or None, status=filters.status or None, q=filters.q or None, sort_by=filters.sort_by, sort_dir=filters.sort_dir, group_by=filters.group_by) }}"><i class="fa-solid fa-chevron-left me-2"></i>{{ t('common.previous') }}</a>
<form class="center-panel" method="get">
<i class="fa-regular fa-calendar"></i>
<input class="form-control" style="max-width:130px" type="number" name="year" value="{{ selected_year }}">
<input class="form-control" style="max-width:110px" type="number" name="month" value="{{ selected_month }}" min="1" max="12">
<input type="hidden" name="category_id" value="{{ filters.category_id or 0 }}">
<input type="hidden" name="payment_method" value="{{ filters.payment_method }}">
<input type="hidden" name="status" value="{{ filters.status }}">
<input type="hidden" name="q" value="{{ filters.q }}">
<input type="hidden" name="sort_by" value="{{ filters.sort_by }}">
<input type="hidden" name="sort_dir" value="{{ filters.sort_dir }}">
<input type="hidden" name="group_by" value="{{ filters.group_by }}">
<button class="btn btn-outline-primary"><i class="fa-solid fa-arrow-right me-2"></i>OK</button>
</form>
<a class="btn btn-outline-secondary" href="{{ url_for('expenses.list_expenses', year=selected_year if selected_month<12 else selected_year+1, month=selected_month+1 if selected_month<12 else 1, category_id=filters.category_id or None, payment_method=filters.payment_method or None, status=filters.status or None, q=filters.q or None, sort_by=filters.sort_by, sort_dir=filters.sort_dir, group_by=filters.group_by) }}">{{ t('common.next') }}<i class="fa-solid fa-chevron-right ms-2"></i></a>
</div>
<div class="quick-stats expense-list-stats mb-3">
<div class="metric-card">
<div class="metric-label">{{ t('expenses.filtered_total') }}</div>
<div class="metric-value">{{ '%.2f'|format(month_total) }}</div>
<div class="small text-body-secondary">{{ expenses|length }} {{ t('expenses.results') }}</div>
</div>
<div class="metric-card">
<div class="metric-label">{{ t('expenses.active_sort') }}</div>
<div class="metric-value fs-5">{{ dict(sort_options).get(filters.sort_by, t('expenses.date')) }}</div>
<div class="small text-body-secondary">{{ t('expenses.' ~ filters.sort_dir) if filters.sort_dir in ['asc', 'desc'] else filters.sort_dir }}</div>
</div>
<div class="metric-card">
<div class="metric-label">{{ t('expenses.grouping') }}</div>
<div class="metric-value fs-5">{{ t('expenses.group_' ~ filters.group_by) if filters.group_by in ['category','payment_method','status','none'] else filters.group_by }}</div>
<div class="small text-body-secondary">{{ grouped_expenses|length }} {{ t('expenses.sections') }}</div>
</div>
<div class="metric-card">
<div class="metric-label">{{ t('expenses.categories_count') }}</div>
<div class="metric-value">{{ categories|length }}</div>
<div class="small text-body-secondary">{{ t('expenses.month_view') }}</div>
</div>
</div>
<form method="get" class="expense-filters card card-body border-0 shadow-sm">
<div class="row g-3 align-items-end">
<input type="hidden" name="year" value="{{ selected_year }}">
<input type="hidden" name="month" value="{{ selected_month }}">
<div class="col-lg-4">
<label class="form-label small">{{ t('common.search') }}</label>
<div class="search-input-wrap">
<i class="fa-solid fa-magnifying-glass"></i>
<input class="form-control ps-5" name="q" value="{{ filters.q }}" placeholder="{{ t('expenses.search_placeholder') }}">
</div>
</div>
<div class="col-sm-6 col-lg-2">
<label class="form-label small">{{ t('expenses.category') }}</label>
<select class="form-select" name="category_id">
<option value="0">{{ t('common.all') }}</option>
{% for category in categories %}
<option value="{{ category.id }}" {% if filters.category_id==category.id %}selected{% endif %}>{{ category.localized_name(current_language) }}</option>
{% endfor %}
</select>
</div>
<div class="col-sm-6 col-lg-2">
<label class="form-label small">{{ t('expenses.payment_method') }}</label>
<select class="form-select" name="payment_method">
<option value="">{{ t('common.all') }}</option>
<option value="card" {% if filters.payment_method=='card' %}selected{% endif %}>{{ t('expenses.payment_card') }}</option>
<option value="cash" {% if filters.payment_method=='cash' %}selected{% endif %}>{{ t('expenses.payment_cash') }}</option>
<option value="transfer" {% if filters.payment_method=='transfer' %}selected{% endif %}>{{ t('expenses.payment_transfer') }}</option>
<option value="blik" {% if filters.payment_method=='blik' %}selected{% endif %}>{{ t('expenses.payment_blik') }}</option>
</select>
</div>
<div class="col-sm-6 col-lg-2">
<label class="form-label small">{{ t('expenses.status') }}</label>
<select class="form-select" name="status">
<option value="">{{ t('common.all') }}</option>
<option value="new" {% if filters.status=='new' %}selected{% endif %}>{{ t('expenses.status_new') }}</option>
<option value="needs_review" {% if filters.status=='needs_review' %}selected{% endif %}>{{ t('expenses.status_needs_review') }}</option>
<option value="confirmed" {% if filters.status=='confirmed' %}selected{% endif %}>{{ t('expenses.status_confirmed') }}</option>
</select>
</div>
<div class="col-sm-6 col-lg-2">
<label class="form-label small">{{ t('expenses.sort_by') }}</label>
<select class="form-select" name="sort_by">
{% for value, label in sort_options %}
<option value="{{ value }}" {% if filters.sort_by==value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="col-sm-6 col-lg-2">
<label class="form-label small">{{ t('expenses.sort_direction') }}</label>
<select class="form-select" name="sort_dir">
<option value="desc" {% if filters.sort_dir=='desc' %}selected{% endif %}>{{ t('expenses.desc') }}</option>
<option value="asc" {% if filters.sort_dir=='asc' %}selected{% endif %}>{{ t('expenses.asc') }}</option>
</select>
</div>
<div class="col-sm-6 col-lg-2">
<label class="form-label small">{{ t('expenses.group_by') }}</label>
<select class="form-select" name="group_by">
<option value="category" {% if filters.group_by=='category' %}selected{% endif %}>{{ t('expenses.group_category') }}</option>
<option value="payment_method" {% if filters.group_by=='payment_method' %}selected{% endif %}>{{ t('expenses.group_payment_method') }}</option>
<option value="status" {% if filters.group_by=='status' %}selected{% endif %}>{{ t('expenses.group_status') }}</option>
<option value="none" {% if filters.group_by=='none' %}selected{% endif %}>{{ t('expenses.group_none') }}</option>
</select>
</div>
<div class="col-lg-4 d-flex gap-2">
<button class="btn btn-primary flex-grow-1"><i class="fa-solid fa-filter me-2"></i>{{ t('common.filter') }}</button>
<a class="btn btn-outline-secondary" href="{{ url_for('expenses.list_expenses', year=selected_year, month=selected_month) }}"><i class="fa-solid fa-rotate-left me-2"></i>{{ t('common.reset') }}</a>
</div>
</div>
</form>
</div>
{% if budgets %}<div class="alert alert-info border-0 shadow-sm"><i class="fa-solid fa-bullseye me-2"></i>{{ t('budgets.title') }}: {% for budget in budgets %}{{ budget.category.localized_name(current_language) }} {{ budget.amount }}{% if not loop.last %}, {% endif %}{% endfor %}</div>{% endif %}
{% if expenses %}
<div class="expense-groups d-grid gap-3">
{% for group in grouped_expenses %}
<section class="card expense-group-card">
<div class="card-header expense-group-header">
<div>
<div class="h5 mb-1">{{ group['label'] }}</div>
<div class="small text-body-secondary">{{ group['items']|length }} {{ t('expenses.results') }}</div>
</div>
<div class="text-end">
<div class="small text-body-secondary">{{ t('expenses.filtered_total') }}</div>
<div class="h5 mb-0">{{ '%.2f'|format(group['total']) }}</div>
</div>
</div>
<div class="card-body p-0">
{% for expense in group['items'] %}
<article class="expense-list-item">
<div class="expense-list-main">
<div class="expense-list-thumb-wrap">
{% if expense.preview_filename %}
<img class="expense-row-thumb" src="{{ url_for('static', filename='previews/' ~ expense.preview_filename) }}" alt="preview">
{% else %}
<span class="soft-icon"><i class="fa-solid fa-receipt"></i></span>
{% endif %}
</div>
<div class="expense-list-copy">
<div class="d-flex flex-wrap align-items-center gap-2 mb-1">
<span class="expense-title">{{ expense.title }}</span>
<span class="badge rounded-pill soft-badge">{{ expense.category.localized_name(current_language) if expense.category else t('common.uncategorized') }}</span>
<span class="badge text-bg-light border">{{ expense.purchase_date }}</span>
</div>
<div class="expense-meta-row">
{% if expense.vendor %}<span><i class="fa-solid fa-store me-1"></i>{{ expense.vendor }}</span>{% endif %}
{% if expense.payment_method %}<span><i class="fa-regular fa-credit-card me-1"></i>{{ t('expenses.payment_' ~ expense.payment_method) if expense.payment_method in ['card','cash','transfer','blik'] else expense.payment_method }}</span>{% endif %}
{% if expense.tags %}<span><i class="fa-solid fa-tags me-1"></i>{{ expense.tags }}</span>{% endif %}
{% if expense.status %}<span><i class="fa-solid fa-shield-halved me-1"></i>{{ t('expenses.status_' ~ expense.status) if expense.status in ['new','needs_review','confirmed'] else expense.status }}</span>{% endif %}
</div>
{% if expense.description %}<div class="small text-body-secondary mt-2">{{ expense.description }}</div>{% endif %}
</div>
</div>
<div class="expense-list-side">
<div class="expense-amount">{{ expense.amount }} {{ expense.currency }}</div>
<div class="expense-actions">
{% if expense.all_previews %}
{% for preview_name in expense.all_previews[:3] %}
<button type="button" class="btn btn-sm btn-outline-secondary preview-trigger" data-bs-toggle="modal" data-bs-target="#previewModal" data-preview="{{ url_for('static', filename='previews/' ~ preview_name) }}"><i class="fa-solid fa-image"></i></button>
{% endfor %}
{% endif %}
<a class="btn btn-sm btn-outline-primary" href="{{ url_for('expenses.edit_expense', expense_id=expense.id) }}"><i class="fa-solid fa-pen-to-square me-1"></i>{{ t('expenses.edit') }}</a>
<form method="post" action="{{ url_for('expenses.delete_expense', expense_id=expense.id) }}" class="d-inline">{{ csrf_token() if csrf_token else '' }}<button class="btn btn-sm btn-outline-danger"><i class="fa-solid fa-trash"></i></button></form>
</div>
</div>
</article>
{% endfor %}
</div>
</section>
{% endfor %}
</div>
{% else %}
<div class="card"><div class="empty-state"><i class="fa-solid fa-wallet"></i><div>{{ t('expenses.empty') }}</div></div></div>
{% endif %}
<div class="modal fade" id="previewModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-xl">
<div class="modal-content glass-card">
<div class="modal-header border-0">
<h2 class="h5 mb-0"><i class="fa-solid fa-image me-2"></i>{{ t('expenses.preview') }}</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body text-center">
<img id="previewModalImage" src="" alt="preview" class="img-fluid rounded-4 border">
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1 @@
{% extends "mail/layout.html" %}{% block body %}<h2 style="margin-top:0">Raport wydatków</h2><p>Okres: <strong>{{ period_label }}</strong></p><p>Suma: <strong>{{ total }} {{ currency }}</strong></p><table style="width:100%;border-collapse:collapse;margin-top:16px">{% for expense in expenses %}<tr><td style="padding:8px 0;border-bottom:1px solid #eef2f7">{{ expense.purchase_date }}</td><td style="padding:8px 0;border-bottom:1px solid #eef2f7">{{ expense.title }}</td><td style="padding:8px 0;border-bottom:1px solid #eef2f7;text-align:right">{{ expense.amount }} {{ expense.currency }}</td></tr>{% endfor %}</table>{% endblock %}

View File

@@ -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 %}

View File

@@ -0,0 +1 @@
<!doctype html><html><body style="margin:0;padding:0;background:#f4f7fb;font-family:Arial,sans-serif;color:#162033"><div style="max-width:640px;margin:24px auto;background:#fff;border-radius:20px;overflow:hidden;border:1px solid #e6ebf2"><div style="padding:24px 28px;background:linear-gradient(135deg,#172554,#2563eb);color:#fff"><h1 style="margin:0;font-size:22px">Expense Monitor</h1></div><div style="padding:28px">{% block body %}{% endblock %}</div></div></body></html>

View File

@@ -0,0 +1 @@
{% extends "mail/layout.html" %}{% block body %}<h2 style="margin-top:0">Nowe konto</h2><p>Twoje konto zostało utworzone.</p><p>Login: <strong>{{ user.email }}</strong></p><p>Hasło tymczasowe: <strong>{{ temp_password }}</strong></p><p>Po pierwszym logowaniu zmień hasło.</p>{% endblock %}

View File

@@ -0,0 +1,4 @@
Nowe konto
Login: {{ user.email }}
Hasło tymczasowe: {{ temp_password }}
Po pierwszym logowaniu zmień hasło.

View File

@@ -0,0 +1 @@
{% extends "mail/layout.html" %}{% block body %}<h2 style="margin-top:0">Reset hasła</h2><p>Otrzymaliśmy prośbę o zmianę hasła dla konta {{ user.email }}.</p><p><a href="{{ reset_link }}" style="display:inline-block;background:#2563eb;color:#fff;text-decoration:none;padding:12px 18px;border-radius:12px">Ustaw nowe hasło</a></p><p>Jeśli to nie Ty, zignoruj tę wiadomość.</p>{% endblock %}

View File

@@ -0,0 +1,4 @@
Reset hasła
Użyj linku, aby ustawić nowe hasło:
{{ reset_link }}

View File

@@ -0,0 +1 @@
{% extends 'base.html' %}{% block content %}<div class="hero-panel mb-4"><div class="d-flex flex-wrap justify-content-between align-items-start gap-3"><div><div class="app-section-title mb-2"><span class="feature-icon"><i class="fa-solid fa-chart-pie"></i></span><div><h1 class="h3 mb-0">{{ t('dashboard.title') }}</h1><div class="text-body-secondary">{{ selected_year }}-{{ '%02d'|format(selected_month) }}</div></div></div><div class="footer-note">{{ t('dashboard.latest') }} • {{ t('dashboard.categories') }}</div></div><div class="text-end"><div class="small text-body-secondary">{{ t('dashboard.total') }}</div><div class="display-6 fw-bold">{{ total }}</div></div></div></div>{% if alerts %}<div class="alert alert-warning border-0 shadow-sm"><i class="fa-solid fa-triangle-exclamation me-2"></i><strong>{{ t('dashboard.alerts') }}:</strong> {% for alert in alerts %}{{ alert.category }} {{ '%.0f'|format(alert.ratio) }}%{% if not loop.last %}, {% endif %}{% endfor %}</div>{% endif %}<div class="quick-stats mb-4"><div class="metric-card"><div class="metric-label">{{ t('dashboard.total') }}</div><div class="metric-value">{{ total }}</div></div><div class="metric-card"><div class="metric-label">{{ t('dashboard.latest') }}</div><div class="metric-value">{{ expenses|length }}</div></div><div class="metric-card"><div class="metric-label">{{ t('dashboard.categories') }}</div><div class="metric-value">{{ category_totals|length }}</div></div></div><div class="row g-3"><div class="col-lg-4"><div class="card h-100"><div class="card-body"><div class="app-section-title"><span class="soft-icon"><i class="fa-solid fa-clock-rotate-left"></i></span><h2 class="h5 mb-0">{{ t('dashboard.latest') }}</h2></div>{% if expenses %}<div class="list-group list-group-flush">{% for expense in expenses[:10] %}<div class="list-group-item px-0 d-flex justify-content-between align-items-center gap-3"><div class="d-flex align-items-center gap-3"><span class="soft-icon"><i class="fa-solid fa-receipt"></i></span><div><div class="fw-semibold">{{ expense.title }}</div><div class="small text-body-secondary">{{ expense.purchase_date }} · {{ expense.vendor }}</div></div></div><div class="text-end fw-semibold">{{ expense.amount }} {{ expense.currency }}</div></div>{% endfor %}</div>{% else %}<div class="empty-state"><i class="fa-solid fa-box-open"></i><div>{{ t('dashboard.empty') }}</div></div>{% endif %}</div></div></div><div class="col-lg-4"><div class="card h-100"><div class="card-body"><div class="app-section-title"><span class="soft-icon"><i class="fa-solid fa-chart-pie"></i></span><h2 class="h5 mb-0">{{ t('dashboard.categories') }}</h2></div><div class="chart-card"><canvas id="dashboard-category-chart"></canvas></div></div></div></div><div class="col-lg-4"><div class="card h-100"><div class="card-body"><div class="app-section-title"><span class="soft-icon"><i class="fa-solid fa-credit-card"></i></span><h2 class="h5 mb-0">{{ t('stats.payment_methods') }}</h2></div><div class="chart-card"><canvas id="dashboard-payment-chart"></canvas></div></div></div></div></div>{% endblock %}{% block scripts %}<script>window.dashboardCategoryData={{ chart_categories|tojson }};window.dashboardPaymentData={{ chart_payments|tojson }};</script>{% endblock %}

View File

@@ -0,0 +1,35 @@
{% extends 'base.html' %}
{% block content %}
<div class="row g-3">
<div class="col-xl-6">
<div class="card"><div class="card-body">
<h1 class="h4 mb-3">{{ t('preferences.title') }}</h1>
<form method="post">{{ form.hidden_tag() }}
<div class="row g-3">
<div class="col-md-6"><label class="form-label">{{ t('preferences.language') }}</label>{{ form.language(class='form-select') }}</div>
<div class="col-md-6"><label class="form-label">{{ t('preferences.theme') }}</label>{{ form.theme(class='form-select') }}</div>
<div class="col-md-6"><label class="form-label">{{ t('preferences.reports') }}</label>{{ form.report_frequency(class='form-select', disabled=(not report_options_enabled)) }}</div>
<div class="col-md-6"><label class="form-label">{{ t('preferences.currency') }}</label>{{ form.default_currency(class='form-select') }}</div>
</div>
{% if not report_options_enabled %}<div class="alert alert-secondary mt-3 mb-0">{{ t('preferences.reports_disabled') }}</div>{% endif %}
<button class="btn btn-primary mt-3">{{ t('preferences.save') }}</button>
</form>
</div></div>
</div>
<div class="col-xl-6">
<div class="card"><div class="card-body">
<h2 class="h4 mb-3">{{ t('preferences.my_categories') }}</h2>
<form method="post">{{ category_form.hidden_tag() }}
<div class="row g-3">
<div class="col-md-4"><label class="form-label">{{ t('preferences.category_key') }}</label>{{ category_form.key(class='form-control') }}</div>
<div class="col-md-4"><label class="form-label">{{ t('preferences.category_name_pl') }}</label>{{ category_form.name_pl(class='form-control') }}</div>
<div class="col-md-4"><label class="form-label">{{ t('preferences.category_name_en') }}</label>{{ category_form.name_en(class='form-control') }}</div>
<div class="col-md-6"><label class="form-label">{{ t('preferences.category_color') }}</label>{{ category_form.color(class='form-select') }}</div>
</div>
<button class="btn btn-outline-primary mt-3">{{ t('common.save') }}</button>
</form>
<div class="mt-4 d-flex flex-wrap gap-2">{% for category in my_categories %}<span class="badge text-bg-{{ category.color }}">{{ category.localized_name(current_language) }}</span>{% else %}<span class="text-body-secondary">{{ t('common.no_data') }}</span>{% endfor %}</div>
</div></div>
</div>
</div>
{% endblock %}

View File

@@ -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')] %}
<div class="hero-panel mb-4">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3">
<div class="app-section-title mb-0"><span class="feature-icon"><i class="fa-solid fa-chart-simple"></i></span><div><h1 class="h3 mb-0">{{ t('stats.title') }}</h1><div class="text-body-secondary">{{ t('stats.subtitle') }}</div></div></div>
<form method="get" class="row g-2 align-items-end">
<div class="col-auto"><label class="form-label small">{{ t('common.year') }}</label><input class="form-control" type="number" name="year" value="{{ selected_year }}"></div>
<div class="col-auto"><label class="form-label small">{{ t('common.month') }}</label><select class="form-select" name="month"><option value="0">{{ t('common.all') }}</option>{% for m in range(1,13) %}<option value="{{ m }}" {% if selected_month==m %}selected{% endif %}>{{ month_names[m-1] }}</option>{% endfor %}</select></div>
<div class="col-auto"><label class="form-label small">{{ t('stats.range_from') }}</label><input class="form-control" type="number" name="start_year" value="{{ start_year }}"></div>
<div class="col-auto"><label class="form-label small">{{ t('stats.range_to') }}</label><input class="form-control" type="number" name="end_year" value="{{ end_year }}"></div>
<div class="col-auto"><button class="btn btn-outline-primary"><i class="fa-solid fa-filter me-2"></i>{{ t('common.apply') }}</button></div>
</form>
</div>
</div>
<div class="quick-stats mb-4" id="stats-overview"></div>
<div class="row g-3 mb-3">
<div class="col-xl-8"><div class="card"><div class="card-body"><h2 class="h5">{{ t('stats.monthly_trend') }}</h2><div class="chart-card chart-canvas"><canvas id="chart-monthly"></canvas></div></div></div></div>
<div class="col-xl-4"><div class="card h-100"><div class="card-body"><h2 class="h5 mb-3">{{ t('stats.top_expenses') }}</h2><div id="top-expenses"></div></div></div></div>
</div>
<div class="row g-3">
<div class="col-xl-6"><div class="card"><div class="card-body"><h2 class="h5">{{ t('dashboard.categories') }}</h2><div class="chart-card chart-canvas"><canvas id="chart-categories"></canvas></div></div></div></div>
<div class="col-xl-6"><div class="card"><div class="card-body"><h2 class="h5">{{ t('stats.payment_methods') }}</h2><div class="chart-card chart-canvas"><canvas id="chart-payments"></canvas></div></div></div></div>
<div class="col-xl-6"><div class="card"><div class="card-body"><h2 class="h5">{{ t('stats.long_term') }}</h2><div class="chart-card chart-canvas"><canvas id="chart-range"></canvas></div></div></div></div>
<div class="col-xl-6"><div class="card"><div class="card-body"><h2 class="h5">{{ t('stats.quarterly') }}</h2><div class="chart-card chart-canvas"><canvas id="chart-quarterly"></canvas></div></div></div></div>
<div class="col-12"><div class="card"><div class="card-body"><h2 class="h5">{{ t('stats.weekdays') }}</h2><div class="chart-card chart-canvas"><canvas id="chart-weekdays"></canvas></div></div></div></div>
</div>
{% endblock %}
{% block scripts %}
<script>
window.expenseStatsYear={{ selected_year|tojson }};
window.expenseStatsMonth={{ selected_month|tojson }};
window.expenseStatsStartYear={{ start_year|tojson }};
window.expenseStatsEndYear={{ end_year|tojson }};
window.expenseStatsText={total:{{ t('dashboard.total')|tojson }},count:{{ t('stats.count')|tojson }},average:{{ t('stats.average')|tojson }},refunds:{{ t('stats.refunds')|tojson }},vs_prev:{{ t('stats.vs_prev')|tojson }},no_data:{{ t('common.no_data')|tojson }}};
</script>
{% endblock %}

15
app/utils.py Normal file
View File

@@ -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

23
docker-compose.yml Normal file
View File

@@ -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:

5
migrations/README.md Normal file
View File

@@ -0,0 +1,5 @@
Run these commands after setup:
flask db init
flask db migrate -m "initial"
flask db upgrade

3
pyproject.toml Normal file
View File

@@ -0,0 +1,3 @@
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q"

20
requirements.txt Normal file
View File

@@ -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

19
requirements_dev.txt Normal file
View File

@@ -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

6
run.py Normal file
View File

@@ -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'])

49
tests/conftest.py Normal file
View File

@@ -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

56
tests/test_admin.py Normal file
View File

@@ -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

View File

@@ -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

24
tests/test_auth.py Normal file
View File

@@ -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!')

32
tests/test_errors.py Normal file
View File

@@ -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

110
tests/test_expenses.py Normal file
View File

@@ -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

View File

@@ -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

40
tests/test_main.py Normal file
View File

@@ -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

View File

@@ -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

3
wsgi.py Normal file
View File

@@ -0,0 +1,3 @@
from app import create_app
app = create_app()