first commit
21
.dockerignore
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
193
app/admin/routes.py
Normal 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
59
app/api/routes.py
Normal 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
106
app/auth/routes.py
Normal 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
81
app/cli/commands.py
Normal 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
@@ -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
330
app/expenses/routes.py
Normal 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
@@ -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
@@ -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
133
app/main/routes.py
Normal 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
@@ -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
115
app/services/analytics.py
Normal 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
@@ -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
@@ -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)
|
||||
25
app/services/categorization.py
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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}});
|
||||
}
|
||||
});
|
||||
|
After Width: | Height: | Size: 33 KiB |
BIN
app/static/previews/one-a8d5983506994642bb8140141ac8c3f5.webp
Normal file
|
After Width: | Height: | Size: 84 B |
BIN
app/static/previews/one-f3e272100c2f477eaef5fd2fe767faae.webp
Normal file
|
After Width: | Height: | Size: 84 B |
BIN
app/static/previews/two-14dfaf6ec4a84f7ca637ab8454ef869c.webp
Normal file
|
After Width: | Height: | Size: 80 B |
BIN
app/static/previews/two-a21a4ab5a1004647b4581d798a0e2784.webp
Normal file
|
After Width: | Height: | Size: 80 B |
|
After Width: | Height: | Size: 52 KiB |
BIN
app/static/uploads/one-60d229d3507942178d989004a32ba97a.png
Normal file
|
After Width: | Height: | Size: 97 B |
BIN
app/static/uploads/one-bc49d4e12f6b4dd39748c88348f060b5.png
Normal file
|
After Width: | Height: | Size: 97 B |
BIN
app/static/uploads/two-24768a3ece7f49d9adb180414b01539a.png
Normal file
|
After Width: | Height: | Size: 98 B |
BIN
app/static/uploads/two-f78ba5d74e2c4607850c60f42d5872a9.png
Normal file
|
After Width: | Height: | Size: 98 B |
14
app/templates/admin/audit.html
Normal 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 %}
|
||||
23
app/templates/admin/categories.html
Normal 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 %}
|
||||
32
app/templates/admin/dashboard.html
Normal 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 %}
|
||||
49
app/templates/admin/settings.html
Normal 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 %}
|
||||
26
app/templates/admin/users.html
Normal 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 %}
|
||||
7
app/templates/auth/forgot_password.html
Normal 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 %}
|
||||
28
app/templates/auth/login.html
Normal 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 %}
|
||||
11
app/templates/auth/register.html
Normal 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 %}
|
||||
7
app/templates/auth/reset_password.html
Normal 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
@@ -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>
|
||||
15
app/templates/errors/error.html
Normal 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 %}
|
||||
25
app/templates/expenses/budgets.html
Normal 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 %}
|
||||
73
app/templates/expenses/create.html
Normal 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 %}
|
||||
210
app/templates/expenses/list.html
Normal 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 %}
|
||||
1
app/templates/mail/expense_report.html
Normal 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 %}
|
||||
6
app/templates/mail/expense_report.txt
Normal 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 %}
|
||||
1
app/templates/mail/layout.html
Normal 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>
|
||||
1
app/templates/mail/new_account.html
Normal 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 %}
|
||||
4
app/templates/mail/new_account.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
Nowe konto
|
||||
Login: {{ user.email }}
|
||||
Hasło tymczasowe: {{ temp_password }}
|
||||
Po pierwszym logowaniu zmień hasło.
|
||||
1
app/templates/mail/password_reset.html
Normal 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 %}
|
||||
4
app/templates/mail/password_reset.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
Reset hasła
|
||||
|
||||
Użyj linku, aby ustawić nowe hasło:
|
||||
{{ reset_link }}
|
||||
1
app/templates/main/dashboard.html
Normal 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 %}
|
||||
35
app/templates/main/preferences.html
Normal 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 %}
|
||||
37
app/templates/main/statistics.html
Normal 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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,3 @@
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
addopts = "-q"
|
||||
20
requirements.txt
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
5
tests/test_admin_audit.py
Normal 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
@@ -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
@@ -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
@@ -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
|
||||
89
tests/test_integrations.py
Normal 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
@@ -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
|
||||
44
tests/test_premium_features.py
Normal 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
|
||||