diff --git a/Dockerfile b/Dockerfile deleted file mode 120000 index 019363a..0000000 --- a/Dockerfile +++ /dev/null @@ -1 +0,0 @@ -deploy/app/Dockerfile \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9f70744 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +FROM python:3.14-trixie +#FROM python:3.13-slim +WORKDIR /app + +# Zależności systemowe do OCR, obrazów, tesseract i języka PL +RUN apt-get update && apt-get install -y --no-install-recommends \ + tesseract-ocr \ + tesseract-ocr-pol \ + libglib2.0-0 \ + libsm6 \ + libxrender1 \ + libxext6 \ + poppler-utils \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Kopiujemy wymagania +COPY requirements.txt requirements.txt + +# Instalujemy zależności +RUN pip install --no-cache-dir -r requirements.txt + +# Kopiujemy resztę aplikacji +COPY . . + +# Kopiujemy entrypoint i ustawiamy uprawnienia +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# Otwieramy port +#EXPOSE 8000 + +# Ustawiamy entrypoint +ENTRYPOINT ["/entrypoint.sh"] diff --git a/REFACTOR_NOTES.md b/REFACTOR_NOTES.md new file mode 100644 index 0000000..9435149 --- /dev/null +++ b/REFACTOR_NOTES.md @@ -0,0 +1,30 @@ +# Refactor / UX refresh + +## Co zostało zrobione + +### Backend Python +- `app.py` został sprowadzony do lekkiego entrypointu. +- Backend został rozbity na moduły w katalogu `shopping_app/`: + - `app_setup.py` — inicjalizacja Flask / SQLAlchemy / SocketIO / Session / config + - `models.py` — modele bazy danych + - `helpers.py` — funkcje pomocnicze, uploady, OCR, uprawnienia, filtry pomocnicze + - `web.py` — context processory, filtry, błędy, favicon, hooki + - `routes_main.py` — główne trasy użytkownika + - `routes_secondary.py` — wydatki, udostępnianie, paragony usera + - `routes_admin.py` — panel admina i trasy administracyjne + - `sockets.py` — Socket.IO i debug socketów + - `deps.py` — wspólne importy +- Endpointy i nazwy widoków zostały zachowane. +- Docker / compose / deploy / varnish nie były ruszane. + +### Frontend / UX / wygląd +- Przebudowany globalny shell aplikacji w `templates/base.html`. +- Odświeżony, spójny dark UI z mocniejszym mobile-first feel. +- Zachowane istniejące pliki JS i ich selektory. +- Główne zmiany wizualne są w `static/css/style.css` jako nowa warstwa override na końcu pliku. +- Drobnie dopracowane teksty i nagłówki w kluczowych widokach. + +## Ważne +- Rozbicie backendu było celowo wykonane bez zmiany zachowania logiki biznesowej. +- Statyczne assety, Socket.IO i routing powinny działać po staremu, ale kod jest łatwiejszy do dalszej pracy. +- Przy lokalnym starcie bez Dockera pamiętaj o istnieniu katalogów `db/` i `uploads/`. diff --git a/app.py b/app.py index 02c96de..01d37ff 100644 --- a/app.py +++ b/app.py @@ -1,4721 +1,5 @@ -import os -import secrets -import time -import mimetypes - -import sys -import platform -import psutil -import hashlib -import re -import traceback -import bcrypt -import colorsys -from pillow_heif import register_heif_opener -from datetime import datetime, timedelta, UTC, timezone -from urllib.parse import urlparse, urlunparse - -from flask import ( - Flask, - render_template, - redirect, - url_for, - request, - flash, - Blueprint, - send_from_directory, - request, - abort, - session, - jsonify, - g, -) -from flask_sqlalchemy import SQLAlchemy -from flask_login import ( - LoginManager, - UserMixin, - login_user, - login_required, - logout_user, - current_user, -) -from flask_compress import Compress -from flask_socketio import SocketIO, emit, join_room -from config import Config -from PIL import Image, ExifTags, ImageFilter, ImageOps -from werkzeug.middleware.proxy_fix import ProxyFix -from sqlalchemy import func, extract, inspect, or_, case, text, and_, literal -from sqlalchemy.orm import joinedload, load_only, aliased -from collections import defaultdict, deque -from functools import wraps - -# from flask_talisman import Talisman # import niżej pod warunkiem -from flask_session import Session -from types import SimpleNamespace -from pdf2image import convert_from_bytes -from urllib.parse import urlencode -from typing import Sequence, Any - -# OCR -import pytesseract -from pytesseract import Output -import logging - -app = Flask(__name__) -app.config.from_object(Config) - -# Konfiguracja nagłówków bezpieczeństwa z .env -csp_policy = ( - { - "default-src": "'self'", - "script-src": "'self' 'unsafe-inline'", - "style-src": "'self' 'unsafe-inline'", - "img-src": "'self' data:", - "connect-src": "'self'", - } - if app.config.get("ENABLE_CSP", True) - else None -) - -permissions_policy = {"browsing-topics": "()"} if app.config.get("ENABLE_PP") else None - -talisman_kwargs = { - "force_https": False, - "strict_transport_security": app.config.get("ENABLE_HSTS", True), - "frame_options": "DENY" if app.config.get("ENABLE_XFO", True) else None, - "permissions_policy": permissions_policy, - "content_security_policy": csp_policy, - "x_content_type_options": app.config.get("ENABLE_XCTO", True), - "strict_transport_security_include_subdomains": False, -} - -referrer_policy = app.config.get("REFERRER_POLICY") -if referrer_policy: - talisman_kwargs["referrer_policy"] = referrer_policy - -# jak naglowki wylaczone, nie ładuj talisman z pominięciem referrer_policy -effective_headers = { - k: v - for k, v in talisman_kwargs.items() - if k != "referrer_policy" and v not in (None, False) -} - -if effective_headers: - from flask_talisman import Talisman - - talisman = Talisman( - app, - session_cookie_secure=app.config.get("SESSION_COOKIE_SECURE", True), - **talisman_kwargs, - ) - print("[TALISMAN] Włączony z nagłówkami:", list(effective_headers.keys())) -else: - print("[TALISMAN] Pominięty — wszystkie nagłówki security wyłączone.") - - -register_heif_opener() # pillow_heif dla HEIC -SQLALCHEMY_ECHO = True -ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp", "heic", "pdf"} - -SYSTEM_PASSWORD = app.config.get("SYSTEM_PASSWORD") -DEFAULT_ADMIN_USERNAME = app.config.get("DEFAULT_ADMIN_USERNAME") -DEFAULT_ADMIN_PASSWORD = app.config.get("DEFAULT_ADMIN_PASSWORD") -UPLOAD_FOLDER = app.config.get("UPLOAD_FOLDER") -AUTHORIZED_COOKIE_VALUE = app.config.get("AUTHORIZED_COOKIE_VALUE") -AUTH_COOKIE_MAX_AGE = app.config.get("AUTH_COOKIE_MAX_AGE") -HEALTHCHECK_TOKEN = app.config.get("HEALTHCHECK_TOKEN") -SESSION_TIMEOUT_MINUTES = int(app.config.get("SESSION_TIMEOUT_MINUTES")) -SESSION_COOKIE_SECURE = app.config.get("SESSION_COOKIE_SECURE") -APP_PORT = int(app.config.get("APP_PORT")) - -app.config["COMPRESS_ALGORITHM"] = ["zstd", "br", "gzip", "deflate"] -app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(minutes=SESSION_TIMEOUT_MINUTES) - -app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) -DEBUG_MODE = app.config.get("DEBUG_MODE", False) - -os.makedirs(UPLOAD_FOLDER, exist_ok=True) - -failed_login_attempts = defaultdict(deque) -MAX_ATTEMPTS = 10 -TIME_WINDOW = 60 * 60 - -WEBP_SAVE_PARAMS = { - "format": "WEBP", - "lossless": False, # False jeśli chcesz używać quality - "method": 6, - "quality": 95, # tylko jeśli lossless=False -} - -def read_commit(filename="version.txt", root_path=None): - base = root_path or os.path.dirname(os.path.abspath(__file__)) - path = os.path.join(base, filename) - if not os.path.exists(path): - return None - try: - commit = open(path, "r", encoding="utf-8").read().strip() - return commit[:12] if commit else None - except Exception: - return None - -commit = read_commit("version.txt", root_path=os.path.dirname(__file__)) or "dev" -APP_VERSION = commit - -app.config["APP_VERSION"] = APP_VERSION - -db = SQLAlchemy(app) - -# old -#socketio = SocketIO(app, async_mode="eventlet") -# new flask -#socketio = SocketIO(app, async_mode="eventlet", manage_session=False) - -# gevent -socketio = SocketIO(app, async_mode='gevent') - -login_manager = LoginManager(app) -login_manager.login_view = "login" - - -# flask-session -app.config["SESSION_TYPE"] = "sqlalchemy" -app.config["SESSION_SQLALCHEMY"] = db -Session(app) - - -# flask-compress -compress = Compress() -compress.init_app(app) - -static_bp = Blueprint("static_bp", __name__) - -# dla live -active_users = {} - - -def utcnow(): - return datetime.now(timezone.utc) - - -app_start_time = utcnow() - - -class User(UserMixin, db.Model): - id = db.Column(db.Integer, primary_key=True) - username = db.Column(db.String(150), unique=True, nullable=False) - password_hash = db.Column(db.String(512), nullable=False) - is_admin = db.Column(db.Boolean, default=False) - - -# Tabela pośrednia -shopping_list_category = db.Table( - "shopping_list_category", - db.Column( - "shopping_list_id", - db.Integer, - db.ForeignKey("shopping_list.id"), - primary_key=True, - ), - db.Column( - "category_id", db.Integer, db.ForeignKey("category.id"), primary_key=True - ), -) - - -class Category(db.Model): - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(100), unique=True, nullable=False) - - -class ShoppingList(db.Model): - id = db.Column(db.Integer, primary_key=True) - title = db.Column(db.String(150), nullable=False) - created_at = db.Column(db.DateTime, default=datetime.utcnow) - - owner_id = db.Column(db.Integer, db.ForeignKey("user.id")) - owner = db.relationship("User", backref="lists", foreign_keys=[owner_id]) - - is_temporary = db.Column(db.Boolean, default=False) - share_token = db.Column(db.String(64), unique=True, nullable=True) - expires_at = db.Column(db.DateTime(timezone=True), nullable=True) - owner = db.relationship("User", backref="lists", lazy=True) - is_archived = db.Column(db.Boolean, default=False) - is_public = db.Column(db.Boolean, default=False) - - # Relacje - items = db.relationship("Item", back_populates="shopping_list", lazy="select") - receipts = db.relationship( - "Receipt", - back_populates="shopping_list", - cascade="all, delete-orphan", - lazy="select", - ) - expenses = db.relationship("Expense", back_populates="shopping_list", lazy="select") - categories = db.relationship( - "Category", - secondary=shopping_list_category, - backref=db.backref("shopping_lists", lazy="dynamic"), - ) - - -class Item(db.Model): - id = db.Column(db.Integer, primary_key=True) - list_id = db.Column(db.Integer, db.ForeignKey("shopping_list.id")) - name = db.Column(db.String(150), nullable=False) - # added_at = db.Column(db.DateTime, default=datetime.utcnow) - added_at = db.Column(db.DateTime, default=utcnow) - added_by = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) - added_by_user = db.relationship( - "User", backref="added_items", lazy="joined", foreign_keys=[added_by] - ) - - purchased = db.Column(db.Boolean, default=False) - purchased_at = db.Column(db.DateTime, nullable=True) - quantity = db.Column(db.Integer, default=1) - note = db.Column(db.Text, nullable=True) - not_purchased = db.Column(db.Boolean, default=False) - not_purchased_reason = db.Column(db.Text, nullable=True) - position = db.Column(db.Integer, default=0) - - shopping_list = db.relationship("ShoppingList", back_populates="items") - - -class SuggestedProduct(db.Model): - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(150), unique=True, nullable=False) - usage_count = db.Column(db.Integer, default=0) - - -class Expense(db.Model): - id = db.Column(db.Integer, primary_key=True) - list_id = db.Column(db.Integer, db.ForeignKey("shopping_list.id")) - amount = db.Column(db.Float, nullable=False) - added_at = db.Column(db.DateTime, default=datetime.utcnow) - receipt_filename = db.Column(db.String(255), nullable=True) - - shopping_list = db.relationship("ShoppingList", back_populates="expenses") - - -class Receipt(db.Model): - id = db.Column(db.Integer, primary_key=True) - list_id = db.Column( - db.Integer, - db.ForeignKey("shopping_list.id", ondelete="CASCADE"), - nullable=False, - ) - filename = db.Column(db.String(255), nullable=False) - uploaded_at = db.Column(db.DateTime, default=datetime.utcnow) - filesize = db.Column(db.Integer, nullable=True) - file_hash = db.Column(db.String(64), nullable=True, unique=True) - uploaded_by = db.Column(db.Integer, db.ForeignKey("user.id")) - version_token = db.Column(db.String(32), nullable=True) - - shopping_list = db.relationship("ShoppingList", back_populates="receipts") - uploaded_by_user = db.relationship("User", backref="uploaded_receipts") - - -class ListPermission(db.Model): - __tablename__ = "list_permission" - id = db.Column(db.Integer, primary_key=True) - list_id = db.Column( - db.Integer, - db.ForeignKey("shopping_list.id", ondelete="CASCADE"), - nullable=False, - ) - user_id = db.Column( - db.Integer, - db.ForeignKey("user.id", ondelete="CASCADE"), - nullable=False, - ) - created_at = db.Column(db.DateTime, default=datetime.utcnow) - __table_args__ = (db.UniqueConstraint("list_id", "user_id", name="uq_list_user"),) - - -ShoppingList.permitted_users = db.relationship( - "User", - secondary="list_permission", - backref=db.backref("permitted_lists", lazy="dynamic"), - lazy="dynamic", -) - - -class AppSetting(db.Model): - key = db.Column(db.String(64), primary_key=True) - value = db.Column(db.Text, nullable=True) - - -class CategoryColorOverride(db.Model): - id = db.Column(db.Integer, primary_key=True) - category_id = db.Column( - db.Integer, db.ForeignKey("category.id"), unique=True, nullable=False - ) - color_hex = db.Column(db.String(7), nullable=False) # "#rrggbb" - - -def get_setting(key: str, default: str | None = None) -> str | None: - s = db.session.get(AppSetting, key) - return s.value if s else default - - -def set_setting(key: str, value: str | None): - s = db.session.get(AppSetting, key) - if (value or "").strip() == "": - if s: - db.session.delete(s) - else: - if not s: - s = AppSetting(key=key, value=value) - db.session.add(s) - else: - s.value = value - - -def get_ocr_keywords() -> list[str]: - raw = get_setting("ocr_keywords", None) - if raw: - try: - vals = ( - json.loads(raw) - if raw.strip().startswith("[") - else [v.strip() for v in raw.split(",")] - ) - return [v for v in vals if v] - except Exception: - pass - # domyślne – obecne w kodzie OCR - return [ - "razem do zapłaty", - "do zapłaty", - "suma", - "kwota", - "wartość", - "płatność", - "total", - "amount", - ] - - -# 1) nowa funkcja: tylko frazy użytkownika (bez domyślnych) -def get_user_ocr_keywords_only() -> list[str]: - raw = get_setting("ocr_keywords", None) - if not raw: - return [] - try: - if raw.strip().startswith("["): - vals = json.loads(raw) - else: - vals = [v.strip() for v in raw.split(",")] - return [v for v in vals if v] - except Exception: - return [] - - -_BASE_KEYWORDS_BLOCK = r""" - (?: - razem\s*do\s*zap[łl][aąo0]ty | - do\s*zap[łl][aąo0]ty | - suma | - kwota | - warto[śćs] | - płatno[śćs] | - total | - amount - ) -""" - - -def priority_keywords_pattern() -> re.Pattern: - user_terms = get_user_ocr_keywords_only() - if user_terms: - - escaped = [re.escape(t) for t in user_terms] - user_block = " | ".join(escaped) - combined = rf""" - \b( - {_BASE_KEYWORDS_BLOCK} - | {user_block} - )\b - """ - else: - combined = rf"""\b({_BASE_KEYWORDS_BLOCK})\b""" - return re.compile(combined, re.IGNORECASE | re.VERBOSE) - - -def category_color_for(c: Category) -> str: - ov = CategoryColorOverride.query.filter_by(category_id=c.id).first() - return ov.color_hex if ov else category_to_color(c.name) - - -def color_for_category_label(label: str) -> str: - cat = Category.query.filter(func.lower(Category.name) == label.lower()).first() - return category_color_for(cat) if cat else category_to_color(label) - - -def hash_password(password): - pepper = app.config["BCRYPT_PEPPER"] - peppered = (password + pepper).encode("utf-8") - salt = bcrypt.gensalt() - hashed = bcrypt.hashpw(peppered, salt) - return hashed.decode("utf-8") - - -def get_int_setting(key: str, default: int) -> int: - try: - v = get_setting(key, None) - return int(v) if v is not None and str(v).strip() != "" else default - except Exception: - return default - - -def check_password(stored_hash, password_input): - pepper = app.config["BCRYPT_PEPPER"] - peppered = (password_input + pepper).encode("utf-8") - if stored_hash.startswith("$2b$") or stored_hash.startswith("$2a$"): - try: - return bcrypt.checkpw(peppered, stored_hash.encode("utf-8")) - except Exception: - return False - return False - - -def set_authorized_cookie(response): - secure_flag = app.config["SESSION_COOKIE_SECURE"] - max_age = app.config.get("AUTH_COOKIE_MAX_AGE", 86400) - response.set_cookie( - "authorized", - AUTHORIZED_COOKIE_VALUE, - max_age=max_age, - secure=secure_flag, - httponly=True, - ) - return response - - -if app.config["SQLALCHEMY_DATABASE_URI"].startswith("sqlite:///"): - db_path = app.config["SQLALCHEMY_DATABASE_URI"].replace("sqlite:///", "", 1) - db_dir = os.path.dirname(db_path) - if db_dir and not os.path.exists(db_dir): - os.makedirs(db_dir, exist_ok=True) - print(f"Utworzono katalog bazy: {db_dir}") - - -with app.app_context(): - db.create_all() - - # --- Tworzenie admina --- - admin_username = DEFAULT_ADMIN_USERNAME - admin_password = DEFAULT_ADMIN_PASSWORD - password_hash = hash_password(admin_password) - - admin = User.query.filter_by(username=admin_username).first() - if admin: - if not admin.is_admin: - admin.is_admin = True - if not check_password(admin.password_hash, admin_password): - admin.password_hash = password_hash - print(f"[INFO] Zmieniono hasło admina '{admin_username}' z konfiguracji.") - db.session.commit() - else: - db.session.add( - User(username=admin_username, password_hash=password_hash, is_admin=True) - ) - db.session.commit() - - default_categories = app.config["DEFAULT_CATEGORIES"] - existing_names = { - c.name for c in Category.query.filter(Category.name.isnot(None)).all() - } - - existing_names_lower = {name.lower() for name in existing_names} - - missing = [ - cat for cat in default_categories if cat.lower() not in existing_names_lower - ] - - if missing: - db.session.add_all(Category(name=cat) for cat in missing) - db.session.commit() - print(f"[INFO] Dodano brakujące kategorie: {', '.join(missing)}") - # else: - # print("[INFO] Wszystkie domyślne kategorie już istnieją") - - -@static_bp.route("/static/js/") -def serve_js(filename): - response = send_from_directory("static/js", filename) - response.headers["Cache-Control"] = app.config["JS_CACHE_CONTROL"] - response.headers.pop("Content-Disposition", None) - return response - - -@static_bp.route("/static/css/") -def serve_css(filename): - response = send_from_directory("static/css", filename) - response.headers["Cache-Control"] = app.config["CSS_CACHE_CONTROL"] - response.headers.pop("Content-Disposition", None) - return response - - -@static_bp.route("/static/lib/js/") -def serve_js_lib(filename): - response = send_from_directory("static/lib/js", filename) - response.headers["Cache-Control"] = app.config["LIB_JS_CACHE_CONTROL"] - response.headers.pop("Content-Disposition", None) - return response - - -@static_bp.route("/static/lib/css/") -def serve_css_lib(filename): - response = send_from_directory("static/lib/css", filename) - response.headers["Cache-Control"] = app.config["LIB_CSS_CACHE_CONTROL"] - response.headers.pop("Content-Disposition", None) - return response - - -app.register_blueprint(static_bp) - - -def allowed_file(filename): - return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS - - -def generate_version_token(): - return secrets.token_hex(8) - - -def get_list_details(list_id): - shopping_list = ShoppingList.query.options( - joinedload(ShoppingList.items).joinedload(Item.added_by_user), - joinedload(ShoppingList.expenses), - joinedload(ShoppingList.receipts), - ).get_or_404(list_id) - - items = sorted(shopping_list.items, key=lambda i: i.position or 0) - expenses = shopping_list.expenses - total_expense = sum(e.amount for e in expenses) if expenses else 0 - receipts = shopping_list.receipts - - return shopping_list, items, receipts, expenses, total_expense - - -def get_total_expense_for_list(list_id, start_date=None, end_date=None): - query = db.session.query(func.sum(Expense.amount)).filter( - Expense.list_id == list_id - ) - - if start_date and end_date: - query = query.filter( - Expense.added_at >= start_date, Expense.added_at < end_date - ) - - return query.scalar() or 0 - - -def update_list_categories_from_form(shopping_list, form): - raw_vals = form.getlist("categories") - candidate_ids = set() - - for v in raw_vals: - if not v: - continue - v = v.strip() - try: - candidate_ids.add(int(v)) - continue - except ValueError: - pass - - cat = Category.query.filter(func.lower(Category.name) == v.lower()).first() - if cat: - candidate_ids.add(cat.id) - shopping_list.categories.clear() - if candidate_ids: - cats = Category.query.filter(Category.id.in_(candidate_ids)).all() - shopping_list.categories.extend(cats) - - -def generate_share_token(length=8): - return secrets.token_hex(length // 2) - - -def check_list_public(shopping_list): - if not shopping_list.is_public: - flash("Ta lista nie jest publicznie dostępna", "danger") - return False - return True - - -def enrich_list_data(l): - counts = ( - db.session.query( - func.count(Item.id), - func.sum(case((Item.purchased == True, 1), else_=0)), - func.sum(Expense.amount), - ) - .outerjoin(Expense, Expense.list_id == Item.list_id) - .filter(Item.list_id == l.id) - .first() - ) - - l.total_count = counts[0] or 0 - l.purchased_count = counts[1] or 0 - l.total_expense = counts[2] or 0 - - return l - - -def get_total_records(): - total = 0 - inspector = inspect(db.engine) - with db.engine.connect() as conn: - for table_name in inspector.get_table_names(): - count = conn.execute(text(f"SELECT COUNT(*) FROM {table_name}")).scalar() - total += count - return total - - -def save_resized_image(file, path): - try: - image = Image.open(file) - image.verify() - file.seek(0) - image = Image.open(file) - except Exception: - raise ValueError("Nieprawidłowy plik graficzny") - - try: - image = ImageOps.exif_transpose(image) - except Exception: - pass - - try: - image.thumbnail((1500, 1500)) - image = image.convert("RGB") - image.info.clear() - - new_path = path.rsplit(".", 1)[0] + ".webp" - # image.save(new_path, **WEBP_SAVE_PARAMS) - image.save(new_path, format="WEBP", method=6, quality=100) - - except Exception as e: - raise ValueError(f"Błąd podczas przetwarzania obrazu: {e}") - - -def redirect_with_flash( - message: str, category: str = "info", endpoint: str = "main_page" -): - flash(message, category) - return redirect(url_for(endpoint)) - - -def can_view_list(sl: ShoppingList) -> bool: - if current_user.is_authenticated: - if sl.owner_id == current_user.id: - return True - if sl.is_public: - return True - return ( - db.session.query(ListPermission.id) - .filter_by(list_id=sl.id, user_id=current_user.id) - .first() - is not None - ) - return bool(sl.is_public) - - -def db_bucket(col, kind: str = "month"): - name = db.engine.name # 'sqlite', 'mysql', 'mariadb', 'postgresql', ... - kind = (kind or "month").lower() - - if kind == "day": - if name == "sqlite": - return func.strftime("%Y-%m-%d", col) - elif name in ("mysql", "mariadb"): - return func.date_format(col, "%Y-%m-%d") - else: - return func.to_char(col, "YYYY-MM-DD") - - if kind == "week": - if name == "sqlite": - return func.printf( - "%s-W%s", func.strftime("%Y", col), func.strftime("%W", col) - ) - elif name in ("mysql", "mariadb"): - return func.date_format(col, "%x-W%v") - else: - return func.to_char(col, 'IYYY-"W"IW') - - if name == "sqlite": - return func.strftime("%Y-%m", col) - elif name in ("mysql", "mariadb"): - return func.date_format(col, "%Y-%m") - else: - return func.to_char(col, "YYYY-MM") - - -def visible_lists_clause_for_expenses(user_id: int, include_shared: bool, now_dt): - perm_subq = user_permission_subq(user_id) - - base = [ - ShoppingList.is_archived == False, - ((ShoppingList.expires_at == None) | (ShoppingList.expires_at > now_dt)), - ] - - if include_shared: - base.append( - or_( - ShoppingList.owner_id == user_id, - ShoppingList.is_public == True, - ShoppingList.id.in_(perm_subq), - ) - ) - else: - base.append(ShoppingList.owner_id == user_id) - - return base - - -def user_permission_subq(user_id): - return db.session.query(ListPermission.list_id).filter( - ListPermission.user_id == user_id - ) - - -def admin_required(f): - @wraps(f) - def decorated_function(*args, **kwargs): - if not current_user.is_authenticated or not current_user.is_admin: - return redirect_with_flash("Brak uprawnień do tej sekcji.", "danger") - return f(*args, **kwargs) - - return decorated_function - - -def get_progress(list_id: int) -> tuple[int, int, float]: - result = ( - db.session.query( - func.count(Item.id), - func.sum(case((Item.purchased == True, 1), else_=0)), - ) - .filter(Item.list_id == list_id) - .first() - ) - - if result is None: - total_count = 0 - purchased_count = 0 - else: - total_count = result[0] or 0 - purchased_count = result[1] or 0 - - percent = (purchased_count / total_count * 100) if total_count > 0 else 0 - return purchased_count, total_count, percent - - -def delete_receipts_for_list(list_id): - receipt_pattern = f"list_{list_id}_" - upload_folder = app.config["UPLOAD_FOLDER"] - for filename in os.listdir(upload_folder): - if filename.startswith(receipt_pattern): - try: - os.remove(os.path.join(upload_folder, filename)) - except Exception as e: - print(f"Nie udało się usunąć pliku {filename}: {e}") - - -def receipt_error(message): - if request.is_json or request.headers.get("X-Requested-With") == "XMLHttpRequest": - return jsonify({"success": False, "error": message}), 400 - flash(message, "danger") - return redirect(request.referrer or url_for("main_page")) - - -def rotate_receipt_by_id(receipt_id): - receipt = Receipt.query.get_or_404(receipt_id) - path = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename) - - if not os.path.exists(path): - raise FileNotFoundError("Plik nie istnieje") - - try: - image = Image.open(path) - rotated = image.rotate(-90, expand=True) - - rotated = rotated.convert("RGB") - rotated.info.clear() - - rotated.save(path, format="WEBP", method=6, quality=100) - receipt.version_token = generate_version_token() - recalculate_filesizes(receipt.id) - db.session.commit() - - return receipt - except Exception as e: - app.logger.exception("Błąd podczas rotacji pliku") - raise RuntimeError(f"Błąd podczas rotacji pliku: {e}") - - -def delete_receipt_by_id(receipt_id): - receipt = Receipt.query.get_or_404(receipt_id) - filepath = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename) - - if os.path.exists(filepath): - os.remove(filepath) - - db.session.delete(receipt) - db.session.commit() - return receipt - - -def generate_new_receipt_filename(list_id): - timestamp = datetime.now().strftime("%Y%m%d_%H%M") - random_part = secrets.token_hex(3) - return f"list_{list_id}_{timestamp}_{random_part}.webp" - - -def handle_crop_receipt(receipt_id, file): - if not receipt_id or not file: - return {"success": False, "error": "Brak danych"} - - try: - receipt = Receipt.query.get_or_404(receipt_id) - path = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename) - - save_resized_image(file, path) - receipt.version_token = generate_version_token() - recalculate_filesizes(receipt.id) - db.session.commit() - - return {"success": True} - except Exception as e: - app.logger.exception("Błąd podczas przycinania paragonu") - return {"success": False, "error": str(e)} - - -def recalculate_filesizes(receipt_id: int = None): - updated = 0 - not_found = 0 - unchanged = 0 - - if receipt_id is not None: - receipt = db.session.get(Receipt, receipt_id) - receipts = [receipt] if receipt else [] - else: - receipts = db.session.execute(db.select(Receipt)).scalars().all() - - for r in receipts: - if not r: - continue - filepath = os.path.join(app.config["UPLOAD_FOLDER"], r.filename) - if os.path.exists(filepath): - real_size = os.path.getsize(filepath) - if r.filesize != real_size: - r.filesize = real_size - updated += 1 - else: - unchanged += 1 - else: - not_found += 1 - - db.session.commit() - return updated, unchanged, not_found - - -def get_admin_expense_summary(): - now = datetime.now(timezone.utc) - current_year = now.year - current_month = now.month - - def calc_summary(expense_query, list_query): - total = expense_query.scalar() or 0 - year_total = ( - expense_query.filter( - extract("year", ShoppingList.created_at) == current_year - ).scalar() - or 0 - ) - month_total = ( - expense_query.filter( - extract("year", ShoppingList.created_at) == current_year, - extract("month", ShoppingList.created_at) == current_month, - ).scalar() - or 0 - ) - list_count = list_query.count() - avg = round(total / list_count, 2) if list_count else 0 - return { - "total": total, - "year": year_total, - "month": month_total, - "count": list_count, - "avg": avg, - } - - expense_base = db.session.query(func.sum(Expense.amount)).join( - ShoppingList, ShoppingList.id == Expense.list_id - ) - list_base = ShoppingList.query - - all = calc_summary(expense_base, list_base) - - active_condition = and_( - ShoppingList.is_archived == False, - ~( - (ShoppingList.is_temporary == True) - & (ShoppingList.expires_at != None) - & (ShoppingList.expires_at <= now) - ), - ) - active = calc_summary( - expense_base.filter(active_condition), list_base.filter(active_condition) - ) - - archived_condition = ShoppingList.is_archived == True - archived = calc_summary( - expense_base.filter(archived_condition), list_base.filter(archived_condition) - ) - - expired_condition = and_( - ShoppingList.is_archived == False, - ShoppingList.is_temporary == True, - ShoppingList.expires_at != None, - ShoppingList.expires_at <= now, - ) - expired = calc_summary( - expense_base.filter(expired_condition), list_base.filter(expired_condition) - ) - - return { - "all": all, - "active": active, - "archived": archived, - "expired": expired, - } - - -def category_to_color(name: str, min_hue_gap_deg: int = 18) -> str: - # Stabilny hash -> int - hv = int(hashlib.md5(name.encode("utf-8")).hexdigest(), 16) - - # Proste mieszanie bitów, by uniknąć lokalnych skupień - def rotl(x, r, bits=128): - r %= bits - return ((x << r) | (x >> (bits - r))) & ((1 << bits) - 1) - - mix = hv ^ rotl(hv, 37) ^ rotl(hv, 73) ^ rotl(hv, 91) - - # Pełne pokrycie koła barw 0..360 - hue_deg = mix % 360 - - # Odpychanie lokalne po hue, by podobne nazwy nie lądowały zbyt blisko - gap = (rotl(mix, 17) % (2 * min_hue_gap_deg)) - min_hue_gap_deg # [-gap, +gap] - hue_deg = (hue_deg + gap) % 360 - - # DARK profil: niższa jasność i nieco mniejsza saturacja - s = 0.70 - l = 0.45 - - # Wąska wariacja, żeby uniknąć „neonów” i zachować spójność - s_var = ((rotl(mix, 29) % 5) - 2) / 100.0 # ±0.02 - l_var = ((rotl(mix, 53) % 7) - 3) / 100.0 # ±0.03 - s = min(0.76, max(0.62, s + s_var)) - l = min(0.50, max(0.40, l + l_var)) - - # Konwersja HLS->RGB (colorsys: H,L,S w [0..1]) - h = hue_deg / 360.0 - r, g, b = colorsys.hls_to_rgb(h, l, s) - - return f"#{int(round(r*255)):02x}{int(round(g*255)):02x}{int(round(b*255)):02x}" - - -def get_total_expenses_grouped_by_category( - show_all, range_type, start_date, end_date, user_id, category_id=None -): - now = datetime.now(timezone.utc) - lists_q = ShoppingList.query.filter( - ShoppingList.is_archived == False, - ((ShoppingList.expires_at == None) | (ShoppingList.expires_at > now)), - ) - - if show_all: - perm_subq = user_permission_subq(user_id) - lists_q = lists_q.filter( - or_( - ShoppingList.owner_id == user_id, - ShoppingList.is_public == True, - ShoppingList.id.in_(perm_subq), - ) - ) - else: - lists_q = lists_q.filter(ShoppingList.owner_id == user_id) - - if category_id: - if str(category_id) == "none": - lists_q = lists_q.filter(~ShoppingList.categories.any()) - else: - try: - cid = int(category_id) - lists_q = lists_q.join( - shopping_list_category, - shopping_list_category.c.shopping_list_id == ShoppingList.id, - ).filter(shopping_list_category.c.category_id == cid) - except (TypeError, ValueError): - pass - - if start_date and end_date: - try: - dt_start = datetime.strptime(start_date, "%Y-%m-%d") - dt_end = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1) - lists_q = lists_q.filter( - ShoppingList.created_at >= dt_start, ShoppingList.created_at < dt_end - ) - except Exception: - return {"error": "Błędne daty"} - - lists = lists_q.options(joinedload(ShoppingList.categories)).all() - if not lists: - return {"labels": [], "datasets": []} - - list_ids = [l.id for l in lists] - totals = ( - db.session.query( - Expense.list_id, - func.coalesce(func.sum(Expense.amount), 0).label("total_amount"), - ) - .filter(Expense.list_id.in_(list_ids)) - .group_by(Expense.list_id) - .all() - ) - expense_map = {lid: float(total or 0) for lid, total in totals} - - def bucket_from_dt(ts: datetime) -> str: - if range_type == "daily": - return ts.strftime("%Y-%m-%d") - elif range_type == "weekly": - return f"{ts.isocalendar().year}-W{ts.isocalendar().week:02d}" - elif range_type == "quarterly": - return f"{ts.year}-Q{((ts.month - 1)//3 + 1)}" - elif range_type == "halfyearly": - return f"{ts.year}-H{1 if ts.month <= 6 else 2}" - elif range_type == "yearly": - return str(ts.year) - else: - return ts.strftime("%Y-%m") - - data_map = defaultdict(lambda: defaultdict(float)) - all_labels = set() - - for l in lists: - key = bucket_from_dt(l.created_at) - all_labels.add(key) - total_expense = expense_map.get(l.id, 0.0) - - if str(category_id) == "none": - data_map[key]["Bez kategorii"] += total_expense - continue - - if not l.categories: - data_map[key]["Bez kategorii"] += total_expense - else: - for c in l.categories: - if category_id and str(c.id) != str(category_id): - continue - data_map[key][c.name] += total_expense - - labels = sorted(all_labels) - cats = sorted({cat for b in data_map.values() for cat, v in b.items() if v > 0}) - - datasets = [ - { - "label": cat, - "data": [round(data_map[label].get(cat, 0.0), 2) for label in labels], - "backgroundColor": color_for_category_label(cat), - } - for cat in cats - ] - return {"labels": labels, "datasets": datasets} - - -def get_total_expenses_grouped_by_list_created_at( - user_only=False, - admin=False, - show_all=False, - range_type="monthly", - start_date=None, - end_date=None, - user_id=None, - category_id=None, -): - now = datetime.now(timezone.utc) - lists_q = ShoppingList.query.filter( - ShoppingList.is_archived == False, - ((ShoppingList.expires_at == None) | (ShoppingList.expires_at > now)), - ) - - if admin: - pass - elif user_only: - lists_q = lists_q.filter(ShoppingList.owner_id == user_id) - elif show_all: - perm_subq = user_permission_subq(user_id) - lists_q = lists_q.filter( - or_( - ShoppingList.owner_id == user_id, - ShoppingList.is_public == True, - ShoppingList.id.in_(perm_subq), - ) - ) - else: - lists_q = lists_q.filter(ShoppingList.owner_id == user_id) - - # kategorie (bez ucinania „none”) - if category_id: - if str(category_id) == "none": - lists_q = lists_q.filter(~ShoppingList.categories.any()) - else: - try: - cid = int(category_id) - lists_q = lists_q.join( - shopping_list_category, - shopping_list_category.c.shopping_list_id == ShoppingList.id, - ).filter(shopping_list_category.c.category_id == cid) - except (TypeError, ValueError): - pass - - if start_date and end_date: - try: - dt_start = datetime.strptime(start_date, "%Y-%m-%d") - dt_end = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1) - lists_q = lists_q.filter( - ShoppingList.created_at >= dt_start, ShoppingList.created_at < dt_end - ) - except Exception: - return {"error": "Błędne daty"} - - lists = lists_q.options(joinedload(ShoppingList.categories)).all() - if not lists: - return {"labels": [], "expenses": []} - - list_ids = [l.id for l in lists] - totals = ( - db.session.query( - Expense.list_id, - func.coalesce(func.sum(Expense.amount), 0).label("total_amount"), - ) - .filter(Expense.list_id.in_(list_ids)) - .group_by(Expense.list_id) - .all() - ) - expense_map = {lid: float(total or 0) for lid, total in totals} - - def bucket_from_dt(ts: datetime) -> str: - if range_type == "daily": - return ts.strftime("%Y-%m-%d") - elif range_type == "weekly": - return f"{ts.isocalendar().year}-W{ts.isocalendar().week:02d}" - elif range_type == "quarterly": - return f"{ts.year}-Q{((ts.month - 1)//3 + 1)}" - elif range_type == "halfyearly": - return f"{ts.year}-H{1 if ts.month <= 6 else 2}" - elif range_type == "yearly": - return str(ts.year) - else: - return ts.strftime("%Y-%m") - - grouped = defaultdict(float) - for sl in lists: - grouped[bucket_from_dt(sl.created_at)] += expense_map.get(sl.id, 0.0) - - labels = sorted(grouped.keys()) - expenses = [round(grouped[l], 2) for l in labels] - return {"labels": labels, "expenses": expenses} - - -def resolve_range(range_type: str): - now = datetime.now(timezone.utc) - sd = ed = None - bucket = "monthly" - - rt = (range_type or "").lower() - if rt in ("last7days", "last_7_days"): - sd = (now - timedelta(days=7)).date().strftime("%Y-%m-%d") - ed = now.date().strftime("%Y-%m-%d") - bucket = "daily" - elif rt in ("last30days", "last_30_days"): - sd = (now - timedelta(days=30)).date().strftime("%Y-%m-%d") - ed = now.date().strftime("%Y-%m-%d") - bucket = "monthly" - elif rt in ("last90days", "last_90_days"): - sd = (now - timedelta(days=90)).date().strftime("%Y-%m-%d") - ed = now.date().strftime("%Y-%m-%d") - bucket = "monthly" - elif rt in ("thismonth", "this_month"): - first = datetime(now.year, now.month, 1, tzinfo=timezone.utc) - sd = first.date().strftime("%Y-%m-%d") - ed = now.date().strftime("%Y-%m-%d") - bucket = "monthly" - elif rt in ( - "currentmonth", - "thismonth", - "this_month", - "monthtodate", - "month_to_date", - "mtd", - ): - first = datetime(now.year, now.month, 1, tzinfo=timezone.utc) - sd = first.date().strftime("%Y-%m-%d") - ed = now.date().strftime("%Y-%m-%d") - bucket = "monthly" - - return sd, ed, bucket - - -def save_pdf_as_webp(file, path): - try: - images = convert_from_bytes(file.read(), dpi=300) - if not images: - raise ValueError("Nie udało się przekonwertować PDF na obraz.") - - total_height = sum(img.height for img in images) - max_width = max(img.width for img in images) - combined = Image.new("RGB", (max_width, total_height), (255, 255, 255)) - - y_offset = 0 - for img in images: - combined.paste(img, (0, y_offset)) - y_offset += img.height - - new_path = path.rsplit(".", 1)[0] + ".webp" - # combined.save(new_path, **WEBP_SAVE_PARAMS) - combined.save(new_path, format="WEBP") - - except Exception as e: - raise ValueError(f"Błąd podczas przetwarzania PDF: {e}") - - -def get_active_months_query(visible_lists_query=None): - if db.engine.name in ("sqlite",): - - def month_expr(col): - return func.strftime("%Y-%m", col) - - elif db.engine.name in ("mysql", "mariadb"): - - def month_expr(col): - return func.date_format(col, "%Y-%m") - - else: # PostgreSQL - - def month_expr(col): - return func.to_char(col, "YYYY-MM") - - if visible_lists_query is not None: - s = visible_lists_query.subquery() - month_sel = month_expr(s.c.created_at).label("month") - inner = ( - db.session.query(month_sel) - .filter(month_sel.isnot(None)) - .distinct() - .subquery() - ) - else: - month_sel = month_expr(ShoppingList.created_at).label("month") - inner = ( - db.session.query(month_sel) - .filter(ShoppingList.created_at.isnot(None)) - .distinct() - .subquery() - ) - - rows = db.session.query(inner.c.month).order_by(inner.c.month).all() - return [r.month for r in rows] - - -def normalize_name(name): - if not name: - return "" - return re.sub(r"\s+", " ", name).strip().lower() - - -def get_valid_item_or_404(item_id: int, list_id: int) -> Item: - item = db.session.get(Item, item_id) - if not item or item.list_id != list_id: - abort(404, description="Nie znaleziono produktu") - return item - - -def paginate_items( - items: Sequence[Any], page: int, per_page: int -) -> tuple[list, int, int]: - total_items = len(items) - total_pages = (total_items + per_page - 1) // per_page - start = (page - 1) * per_page - end = start + per_page - return items[start:end], total_items, total_pages - - -def get_page_args( - default_per_page: int = 100, max_per_page: int = 300 -) -> tuple[int, int]: - page = request.args.get("page", 1, type=int) - per_page = request.args.get("per_page", default_per_page, type=int) - per_page = max(1, min(per_page, max_per_page)) - return page, per_page - - -############# OCR ########################### - - -def preprocess_image_for_tesseract(image): - # czułość 1..10 (domyślnie 5) - sens = get_int_setting("ocr_sensitivity", 5) - # próg progowy – im wyższa czułość, tym niższy próg (więcej czerni) - base_thresh = 150 - delta = int((sens - 5) * 8) # krok 8 na stopień - thresh = max(90, min(210, base_thresh - delta)) - - image = ImageOps.autocontrast(image) - image = image.point(lambda x: 0 if x < thresh else 255) - image = image.resize((image.width * 2, image.height * 2), Image.BICUBIC) - return image - - -def extract_total_tesseract(image): - - text = pytesseract.image_to_string(image, lang="pol", config="--psm 4") - lines = text.splitlines() - candidates = [] - - blacklist_keywords = re.compile(r"\b(ptu|vat|podatek|stawka)\b", re.IGNORECASE) - - priority_keywords = priority_keywords_pattern() - - for line in lines: - if not line.strip(): - continue - - if blacklist_keywords.search(line): - continue - - is_priority = priority_keywords.search(line) - - matches = re.findall(r"\d{1,4}[.,]\d{2}", line) - for match in matches: - try: - val = float(match.replace(",", ".")) - if 0.1 <= val <= 100000: - candidates.append((val, line, is_priority is not None)) - except: - continue - - if is_priority: - spaced = re.findall(r"\d{1,4}\s\d{2}", line) - for match in spaced: - try: - val = float(match.replace(" ", ".")) - if 0.1 <= val <= 100000: - candidates.append((val, line, True)) - except: - continue - - preferred = [(val, line) for val, line, is_pref in candidates if is_pref] - - if preferred: - best_val = max(preferred, key=lambda x: x[0])[0] - if best_val < 99999: - return round(best_val, 2), lines - - if candidates: - best_val = max(candidates, key=lambda x: x[0])[0] - if best_val < 99999: - return round(best_val, 2), lines - - data = pytesseract.image_to_data( - image, lang="pol", config="--psm 4", output_type=Output.DICT - ) - - font_candidates = [] - for i in range(len(data["text"])): - word = data["text"][i].strip() - if not word or not re.match(r"^\d{1,5}[.,\s]\d{2}$", word): - continue - - try: - val = float(word.replace(",", ".").replace(" ", ".")) - height = data["height"][i] - conf = int(data.get("conf", ["0"] * len(data["text"]))[i]) - if 0.1 <= val <= 100000: - font_candidates.append((val, height, conf)) - except: - continue - - if font_candidates: - best = max(font_candidates, key=lambda x: (x[1], x[2])) - return round(best[0], 2), lines - - return 0.0, lines - - -############# END OCR ####################### - - -# zabezpieczenie logowani do systemu - błędne hasła -def is_ip_blocked(ip): - now = time.time() - attempts = failed_login_attempts[ip] - while attempts and now - attempts[0] > TIME_WINDOW: - attempts.popleft() - max_attempts = get_int_setting("max_login_attempts", 10) - return len(attempts) >= max_attempts - - -def attempts_remaining(ip): - attempts = failed_login_attempts[ip] - max_attempts = get_int_setting("max_login_attempts", 10) - return max(0, max_attempts - len(attempts)) - - -def register_failed_attempt(ip): - now = time.time() - attempts = failed_login_attempts[ip] - while attempts and now - attempts[0] > TIME_WINDOW: - attempts.popleft() - attempts.append(now) - - -def reset_failed_attempts(ip): - failed_login_attempts[ip].clear() - - -#################################################### - - -def get_client_ip(): - for header in ["X-Forwarded-For", "X-Real-IP"]: - if header in request.headers: - ip = request.headers[header].split(",")[0].strip() - if ip: - return ip - return request.remote_addr - - -@login_manager.user_loader -def load_user(user_id): - return db.session.get(User, int(user_id)) - - -@app.context_processor -def inject_version(): - return {"APP_VERSION": app.config["APP_VERSION"]} - - -@app.context_processor -def inject_time(): - return dict(time=time) - - -@app.context_processor -def inject_has_authorized_cookie(): - return {"has_authorized_cookie": "authorized" in request.cookies} - - -@app.context_processor -def inject_is_blocked(): - ip = request.access_route[0] - return {"is_blocked": is_ip_blocked(ip)} - - -@app.before_request -def require_system_password(): - endpoint = request.endpoint - - if endpoint in ( - "static_bp.serve_js", - "static_bp.serve_css", - "static_bp.serve_js_lib", - "static_bp.serve_css_lib", - "favicon", - "favicon_ico", - "uploaded_file", - ): - return - - if endpoint in ("system_auth", "healthcheck", "robots_txt"): - return - - ip = request.access_route[0] - if is_ip_blocked(ip): - abort(403) - - if endpoint is None: - return - - if "authorized" not in request.cookies and not endpoint.startswith("login"): - if request.path == "/": - return redirect(url_for("system_auth")) - - parsed = urlparse(request.url) - fixed_url = urlunparse(parsed._replace(netloc=request.host)) - return redirect(url_for("system_auth", next=fixed_url)) - - -@app.after_request -def apply_headers(response): - # Specjalny endpoint wykresów/API – zawsze no-cache - if request.path == "/expenses_data": - response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate" - response.headers["Pragma"] = "no-cache" - response.headers["Expires"] = "0" - return response - - # --- statyczne pliki (nagłówki z .env) --- - if request.path.startswith(("/static/", "/uploads/")): - response.headers.pop('Vary', None) # fix bug with backslash - response.headers['Vary'] = 'Accept-Encoding' - return response - - # --- healthcheck --- - if request.path == '/healthcheck': - response.headers['Cache-Control'] = 'no-store, no-cache' - response.headers.pop('ETag', None) - response.headers.pop('Vary', None) - return response - - # --- redirecty --- - if response.status_code in (301, 302, 303, 307, 308): - response.headers["Cache-Control"] = "no-store" - response.headers.pop("Vary", None) - return response - - # --- błędy 4xx --- - if 400 <= response.status_code < 500: - response.headers["Cache-Control"] = "no-store" - ct = (response.headers.get("Content-Type") or "").lower() - if "application/json" not in ct: - response.headers["Content-Type"] = "text/html; charset=utf-8" - response.headers.pop("Vary", None) - - # --- błędy 5xx --- - elif 500 <= response.status_code < 600: - response.headers["Cache-Control"] = "no-store" - ct = (response.headers.get("Content-Type") or "").lower() - if "application/json" not in ct: - response.headers["Content-Type"] = "text/html; charset=utf-8" - response.headers["Retry-After"] = "120" - response.headers.pop("Vary", None) - - # --- strony dynamiczne (domyślnie) --- - # Wszystko, co nie jest /static/ ani /uploads/ ma być no-store/no-cache - response.headers.setdefault("Cache-Control", "no-cache, no-store") - - return response - - -@app.before_request -def start_timer(): - g.start_time = time.time() - - -@app.after_request -def log_request(response): - if request.path == "/healthcheck": - return response - - ip = get_client_ip() - method = request.method - path = request.path - status = response.status_code - length = response.content_length or "-" - start = getattr(g, "start_time", None) - duration = round((time.time() - start) * 1000, 2) if start else "-" - agent = request.headers.get("User-Agent", "-") - - if status == 304: - app.logger.info( - f'REVALIDATED: {ip} - "{method} {path}" {status} {length} {duration}ms "{agent}"' - ) - else: - app.logger.info( - f'{ip} - "{method} {path}" {status} {length} {duration}ms "{agent}"' - ) - - app.logger.debug(f"Request headers: {dict(request.headers)}") - app.logger.debug(f"Response headers: {dict(response.headers)}") - return response - - -@app.template_filter("filemtime") -def file_mtime_filter(path): - try: - t = os.path.getmtime(path) - return datetime.fromtimestamp(t) - except Exception: - # return datetime.utcnow() - return datetime.now(timezone.utc) - - -@app.template_filter("todatetime") -def to_datetime_filter(s): - return datetime.strptime(s, "%Y-%m-%d") - - -@app.template_filter("filesizeformat") -def filesizeformat_filter(path): - try: - size = os.path.getsize(path) - for unit in ["B", "KB", "MB", "GB"]: - if size < 1024.0: - return f"{size:.1f} {unit}" - size /= 1024.0 - return f"{size:.1f} TB" - except Exception: - return "N/A" - - -@app.errorhandler(404) -def page_not_found(e): - return ( - render_template( - "errors.html", - code=404, - title="Strona nie znaleziona", - message="Ups! Podana strona nie istnieje lub została przeniesiona.", - ), - 404, - ) - - -@app.errorhandler(403) -def forbidden(e): - return ( - render_template( - "errors.html", - code=403, - title="Brak dostępu", - message=( - e.description - if e.description - else "Nie masz uprawnień do wyświetlenia tej strony." - ), - ), - 403, - ) - - -@app.route("/favicon.ico") -def favicon_ico(): - return redirect(url_for("static", filename="favicon.svg")) - - -@app.route("/favicon.svg") -def favicon(): - svg = """ - - 🛒 - - """ - return svg, 200, {"Content-Type": "image/svg+xml"} - - -@app.route("/") -def main_page(): - perm_subq = ( - user_permission_subq(current_user.id) if current_user.is_authenticated else None - ) - - now = datetime.now(timezone.utc) - - month_param = request.args.get("m", None) - start = end = None - - if month_param in (None, ""): - # domyślnie: bieżący miesiąc - month_str = now.strftime("%Y-%m") - start = datetime(now.year, now.month, 1, tzinfo=timezone.utc) - end = (start + timedelta(days=31)).replace(day=1) - elif month_param == "all": - month_str = "all" - start = end = None - else: - month_str = month_param - try: - year, month = map(int, month_str.split("-")) - start = datetime(year, month, 1, tzinfo=timezone.utc) - end = (start + timedelta(days=31)).replace(day=1) - except ValueError: - # jeśli m ma zły format – pokaż wszystko - month_str = "all" - start = end = None - - def date_filter(query): - if start and end: - query = query.filter( - ShoppingList.created_at >= start, ShoppingList.created_at < end - ) - return query - - if current_user.is_authenticated: - user_lists = ( - date_filter( - ShoppingList.query.filter( - ShoppingList.owner_id == current_user.id, - ShoppingList.is_archived == False, - (ShoppingList.expires_at == None) | (ShoppingList.expires_at > now), - ) - ) - .order_by(ShoppingList.created_at.desc()) - .all() - ) - - archived_lists = ( - ShoppingList.query.filter_by(owner_id=current_user.id, is_archived=True) - .order_by(ShoppingList.created_at.desc()) - .all() - ) - - # publiczne cudze + udzielone mi (po list_permission) - public_lists = ( - date_filter( - ShoppingList.query.filter( - ShoppingList.owner_id != current_user.id, - ShoppingList.is_archived == False, - (ShoppingList.expires_at == None) | (ShoppingList.expires_at > now), - or_( - ShoppingList.is_public == True, - ShoppingList.id.in_(perm_subq), - ), - ) - ) - .order_by(ShoppingList.created_at.desc()) - .all() - ) - accessible_lists = public_lists # alias do szablonu: publiczne + udostępnione - else: - user_lists = [] - archived_lists = [] - public_lists = ( - date_filter( - ShoppingList.query.filter( - ShoppingList.is_public == True, - (ShoppingList.expires_at == None) | (ShoppingList.expires_at > now), - ShoppingList.is_archived == False, - ) - ) - .order_by(ShoppingList.created_at.desc()) - .all() - ) - accessible_lists = public_lists # dla gościa = tylko publiczne - - # Zakres miesięcy do selektora - if current_user.is_authenticated: - visible_lists_query = ShoppingList.query.filter( - or_( - ShoppingList.owner_id == current_user.id, - ShoppingList.is_public == True, - ShoppingList.id.in_(perm_subq), - ) - ) - else: - visible_lists_query = ShoppingList.query.filter(ShoppingList.is_public == True) - - month_options = get_active_months_query(visible_lists_query) - - # Statystyki dla wszystkich widocznych sekcji - all_lists = user_lists + accessible_lists + archived_lists - all_ids = [l.id for l in all_lists] - - if all_ids: - stats = ( - db.session.query( - Item.list_id, - func.count(Item.id).label("total_count"), - func.sum(case((Item.purchased == True, 1), else_=0)).label( - "purchased_count" - ), - func.sum(case((Item.not_purchased == True, 1), else_=0)).label( - "not_purchased_count" - ), - ) - .filter(Item.list_id.in_(all_ids)) - .group_by(Item.list_id) - .all() - ) - stats_map = { - s.list_id: ( - s.total_count or 0, - s.purchased_count or 0, - s.not_purchased_count or 0, - ) - for s in stats - } - - latest_expenses_map = dict( - db.session.query( - Expense.list_id, func.coalesce(func.sum(Expense.amount), 0) - ) - .filter(Expense.list_id.in_(all_ids)) - .group_by(Expense.list_id) - .all() - ) - - for l in all_lists: - total_count, purchased_count, not_purchased_count = stats_map.get( - l.id, (0, 0, 0) - ) - l.total_count = total_count - l.purchased_count = purchased_count - l.not_purchased_count = not_purchased_count - l.total_expense = latest_expenses_map.get(l.id, 0) - l.category_badges = [ - {"name": c.name, "color": category_color_for(c)} for c in l.categories - ] - else: - for l in all_lists: - l.total_count = 0 - l.purchased_count = 0 - l.not_purchased_count = 0 - l.total_expense = 0 - l.category_badges = [] - - return render_template( - "main.html", - user_lists=user_lists, - public_lists=public_lists, - accessible_lists=accessible_lists, - archived_lists=archived_lists, - now=now, - timedelta=timedelta, - month_options=month_options, - selected_month=month_str, - ) - - -@app.route("/system-auth", methods=["GET", "POST"]) -def system_auth(): - if ( - current_user.is_authenticated - or request.cookies.get("authorized") == AUTHORIZED_COOKIE_VALUE - ): - flash("Jesteś już zalogowany lub autoryzowany.", "info") - return redirect(url_for("main_page")) - - ip = request.access_route[0] - next_page = request.args.get("next") or url_for("main_page") - - if is_ip_blocked(ip): - flash( - "Przekroczono limit prób logowania. Dostęp zablokowany na 1 godzinę.", - "danger", - ) - return render_template("system_auth.html"), 403 - - if request.method == "POST": - if request.form["password"] == SYSTEM_PASSWORD: - reset_failed_attempts(ip) - resp = redirect(next_page) - return set_authorized_cookie(resp) - else: - register_failed_attempt(ip) - if is_ip_blocked(ip): - flash( - "Przekroczono limit prób logowania. Dostęp zablokowany na 1 godzinę.", - "danger", - ) - return render_template("system_auth.html"), 403 - remaining = attempts_remaining(ip) - flash(f"Nieprawidłowe hasło. Pozostało {remaining} prób.", "warning") - - return render_template("system_auth.html") - - -@app.route("/edit_my_list/", methods=["GET", "POST"]) -@login_required -def edit_my_list(list_id): - # --- Pobranie listy i weryfikacja właściciela --- - l = db.session.get(ShoppingList, list_id) - if l is None: - abort(404) - if l.owner_id != current_user.id: - abort(403, description="Nie jesteś właścicielem tej listy.") - - # Dane do widoku - receipts = ( - Receipt.query.filter_by(list_id=list_id) - .order_by(Receipt.uploaded_at.desc()) - .all() - ) - categories = Category.query.order_by(Category.name.asc()).all() - selected_categories_ids = {c.id for c in l.categories} - - next_page = request.args.get("next") or request.referrer - wants_json = ( - "application/json" in (request.headers.get("Accept") or "") - or request.headers.get("X-Requested-With") == "fetch" - ) - - if request.method == "POST": - action = request.form.get("action") - - # --- Nadanie dostępu (grant) --- - if action == "grant": - grant_username = (request.form.get("grant_username") or "").strip().lower() - if not grant_username: - if wants_json: - return jsonify(ok=False, error="empty"), 400 - flash("Podaj nazwę użytkownika do nadania dostępu.", "danger") - return redirect(next_page or request.url) - - u = User.query.filter(func.lower(User.username) == grant_username).first() - if not u: - if wants_json: - return jsonify(ok=False, error="not_found"), 404 - flash("Użytkownik nie istnieje.", "danger") - return redirect(next_page or request.url) - if u.id == current_user.id: - if wants_json: - return jsonify(ok=False, error="owner"), 409 - flash("Jesteś właścicielem tej listy.", "info") - return redirect(next_page or request.url) - - exists = ( - db.session.query(ListPermission.id) - .filter( - ListPermission.list_id == l.id, - ListPermission.user_id == u.id, - ) - .first() - ) - if not exists: - db.session.add(ListPermission(list_id=l.id, user_id=u.id)) - db.session.commit() - if wants_json: - return jsonify(ok=True, user={"id": u.id, "username": u.username}) - flash(f"Nadano dostęp użytkownikowi „{u.username}”.", "success") - else: - if wants_json: - return jsonify(ok=False, error="exists"), 409 - flash("Ten użytkownik już ma dostęp.", "info") - return redirect(next_page or request.url) - - # --- Odebranie dostępu (revoke) --- - revoke_user_id = request.form.get("revoke_user_id") - if revoke_user_id: - try: - uid = int(revoke_user_id) - except ValueError: - if wants_json: - return jsonify(ok=False, error="bad_id"), 400 - flash("Błędny identyfikator użytkownika.", "danger") - return redirect(next_page or request.url) - - ListPermission.query.filter_by(list_id=l.id, user_id=uid).delete() - db.session.commit() - if wants_json: - return jsonify(ok=True, removed_user_id=uid) - flash("Odebrano dostęp użytkownikowi.", "success") - return redirect(next_page or request.url) - - # --- Przywracanie z archiwum --- - if "unarchive" in request.form: - l.is_archived = False - db.session.commit() - if wants_json: - return jsonify(ok=True, unarchived=True) - flash(f"Lista „{l.title}” została przywrócona.", "success") - return redirect(next_page or request.url) - - # --- Główny zapis pól formularza --- - move_to_month = request.form.get("move_to_month") - if move_to_month: - try: - year, month = map(int, move_to_month.split("-")) - l.created_at = datetime(year, month, 1, tzinfo=timezone.utc) - if not wants_json: - flash( - f"Zmieniono datę utworzenia listy na {l.created_at.strftime('%Y-%m-%d')}", - "success", - ) - except ValueError: - if not wants_json: - flash( - "Nieprawidłowy format miesiąca — zignorowano zmianę miesiąca.", - "danger", - ) - - new_title = (request.form.get("title") or "").strip() - is_public = "is_public" in request.form - is_temporary = "is_temporary" in request.form - is_archived = "is_archived" in request.form - expires_date = request.form.get("expires_date") - expires_time = request.form.get("expires_time") - - if not new_title: - if wants_json: - return jsonify(ok=False, error="title_empty"), 400 - flash("Podaj poprawny tytuł", "danger") - return redirect(next_page or request.url) - - l.title = new_title - l.is_public = is_public - l.is_temporary = is_temporary - l.is_archived = is_archived - - if expires_date and expires_time: - try: - combined = f"{expires_date} {expires_time}" - expires_dt = datetime.strptime(combined, "%Y-%m-%d %H:%M") - l.expires_at = expires_dt.replace(tzinfo=timezone.utc) - except ValueError: - if wants_json: - return jsonify(ok=False, error="bad_expiry"), 400 - flash("Błędna data lub godzina wygasania", "danger") - return redirect(next_page or request.url) - else: - l.expires_at = None - - # Kategorie (używa Twojej pomocniczej funkcji) - update_list_categories_from_form(l, request.form) - - db.session.commit() - if wants_json: - return jsonify(ok=True, saved=True) - flash("Zaktualizowano dane listy", "success") - return redirect(next_page or request.url) - - # GET: użytkownicy z dostępem - permitted_users = ( - db.session.query(User) - .join(ListPermission, ListPermission.user_id == User.id) - .where(ListPermission.list_id == l.id) - .order_by(User.username.asc()) - .all() - ) - - return render_template( - "edit_my_list.html", - list=l, - receipts=receipts, - categories=categories, - selected_categories=selected_categories_ids, - permitted_users=permitted_users, - ) - - -@app.route("/edit_my_list//suggestions", methods=["GET"]) -@login_required -def edit_my_list_suggestions(list_id: int): - # Weryfikacja listy i właściciela (prywatność) - l = db.session.get(ShoppingList, list_id) - if l is None: - abort(404) - if l.owner_id != current_user.id: - abort(403, description="Nie jesteś właścicielem tej listy.") - - q = (request.args.get("q") or "").strip().lower() - - # Historia nadawań uprawnień przez tego właściciela (po wszystkich jego listach) - subq = ( - db.session.query( - ListPermission.user_id.label("uid"), - func.count(ListPermission.id).label("grant_count"), - func.max(ListPermission.id).label("last_grant_id"), - ) - .join(ShoppingList, ShoppingList.id == ListPermission.list_id) - .filter(ShoppingList.owner_id == current_user.id) - .group_by(ListPermission.user_id) - .subquery() - ) - - query = db.session.query( - User.username, subq.c.grant_count, subq.c.last_grant_id - ).join(subq, subq.c.uid == User.id) - if q: - query = query.filter(func.lower(User.username).like(f"{q}%")) - - rows = ( - query.order_by( - subq.c.grant_count.desc(), - subq.c.last_grant_id.desc(), - func.lower(User.username).asc(), - ) - .limit(20) - .all() - ) - - return jsonify({"users": [r.username for r in rows]}) - - -@app.route("/delete_user_list/", methods=["POST"]) -@login_required -def delete_user_list(list_id): - - l = db.session.get(ShoppingList, list_id) - if l is None or l.owner_id != current_user.id: - abort(403, description="Nie jesteś właścicielem tej listy.") - - l = db.session.get(ShoppingList, list_id) - if l is None or l.owner_id != current_user.id: - abort(403) - delete_receipts_for_list(list_id) - Item.query.filter_by(list_id=list_id).delete() - Expense.query.filter_by(list_id=list_id).delete() - db.session.delete(l) - db.session.commit() - flash("Lista została usunięta", "success") - return redirect(url_for("main_page")) - - -@app.route("/toggle_visibility/", methods=["GET", "POST"]) -@login_required -def toggle_visibility(list_id): - l = db.session.get(ShoppingList, list_id) - if l is None: - abort(404) - - if l.owner_id != current_user.id: - if request.is_json or request.method == "POST": - return {"error": "Unauthorized"}, 403 - flash("Nie masz uprawnień do tej listy", "danger") - return redirect(url_for("main_page")) - - l.is_public = not l.is_public - db.session.commit() - - share_url = f"{request.url_root}share/{l.share_token}" - - if request.is_json or request.method == "POST": - return {"is_public": l.is_public, "share_url": share_url} - - if l.is_public: - flash("Lista została udostępniona publicznie", "success") - else: - flash("Lista została ukryta przed gośćmi", "info") - - return redirect(url_for("main_page")) - - -@app.route("/login", methods=["GET", "POST"]) -def login(): - if request.method == "POST": - username_input = request.form["username"].lower() - user = User.query.filter(func.lower(User.username) == username_input).first() - if user and check_password(user.password_hash, request.form["password"]): - session.permanent = True - login_user(user) - session.modified = True - flash("Zalogowano pomyślnie", "success") - return redirect(url_for("main_page")) - flash("Nieprawidłowy login lub hasło", "danger") - return render_template("login.html") - - -@app.route("/logout") -@login_required -def logout(): - logout_user() - flash("Wylogowano pomyślnie", "success") - return redirect(url_for("main_page")) - - -@app.route("/create", methods=["POST"]) -@login_required -def create_list(): - title = request.form.get("title") - is_temporary = request.form.get("temporary") == "1" - token = generate_share_token(8) - - expires_at = ( - datetime.now(timezone.utc) + timedelta(days=7) if is_temporary else None - ) - - new_list = ShoppingList( - title=title, - owner_id=current_user.id, - is_temporary=is_temporary, - share_token=token, - expires_at=expires_at, - ) - db.session.add(new_list) - db.session.commit() - flash("Utworzono nową listę", "success") - return redirect(url_for("view_list", list_id=new_list.id)) - - -@app.route("/list/") -@login_required -def view_list(list_id): - shopping_list = db.session.get(ShoppingList, list_id) - if not shopping_list: - abort(404) - - is_owner = current_user.id == shopping_list.owner_id - if not is_owner: - flash( - "Nie jesteś właścicielem listy, przekierowano do widoku publicznego.", - "warning", - ) - if current_user.is_admin: - flash( - "W celu modyfikacji listy, przejdź do panelu administracyjnego.", "info" - ) - return redirect(url_for("shared_list", token=shopping_list.share_token)) - - shopping_list, items, receipts, expenses, total_expense = get_list_details(list_id) - total_count = len(items) - purchased_count = len([i for i in items if i.purchased]) - percent = (purchased_count / total_count * 100) if total_count > 0 else 0 - - for item in items: - if item.added_by != shopping_list.owner_id: - item.added_by_display = ( - item.added_by_user.username if item.added_by_user else "?" - ) - else: - item.added_by_display = None - - shopping_list.category_badges = [ - {"name": c.name, "color": category_color_for(c)} - for c in shopping_list.categories - ] - - # Wszystkie kategorie (do selecta) - categories = Category.query.order_by(Category.name.asc()).all() - selected_categories_ids = {c.id for c in shopping_list.categories} - - # Najczęściej używane kategorie właściciela (top N) - popular_categories = ( - db.session.query(Category) - .join( - shopping_list_category, - shopping_list_category.c.category_id == Category.id, - ) - .join( - ShoppingList, - ShoppingList.id == shopping_list_category.c.shopping_list_id, - ) - .filter(ShoppingList.owner_id == current_user.id) - .group_by(Category.id) - .order_by(func.count(ShoppingList.id).desc(), func.lower(Category.name).asc()) - .limit(6) - .all() - ) - - # Użytkownicy z uprawnieniami do listy - permitted_users = ( - db.session.query(User) - .join(ListPermission, ListPermission.user_id == User.id) - .filter(ListPermission.list_id == shopping_list.id) - .order_by(User.username.asc()) - .all() - ) - - return render_template( - "list.html", - list=shopping_list, - items=items, - receipts=receipts, - total_count=total_count, - purchased_count=purchased_count, - percent=percent, - expenses=expenses, - total_expense=total_expense, - is_share=False, - is_owner=is_owner, - categories=categories, - selected_categories=selected_categories_ids, - permitted_users=permitted_users, - popular_categories=popular_categories, - ) - - -@app.route("/list//settings", methods=["POST"]) -@login_required -def list_settings(list_id): - # Uprawnienia: właściciel - l = db.session.get(ShoppingList, list_id) - if l is None: - abort(404) - if l.owner_id != current_user.id: - abort(403, description="Brak uprawnień do ustawień tej listy.") - - next_page = request.form.get("next") or url_for("view_list", list_id=list_id) - wants_json = ( - "application/json" in (request.headers.get("Accept") or "") - or request.headers.get("X-Requested-With") == "fetch" - ) - - action = request.form.get("action") - - # 1) Ustawienie kategorii (pojedynczy wybór z list.html -> modal kategorii) - if action == "set_category": - cid = request.form.get("category_id") - if cid in (None, "", "none"): - # usunięcie kategorii lub brak zmiany – w zależności od Twojej logiki - l.categories = [] - db.session.commit() - if wants_json: - return jsonify(ok=True, saved=True) - flash("Zapisano kategorię.", "success") - return redirect(next_page) - - try: - cid = int(cid) - except (TypeError, ValueError): - if wants_json: - return jsonify(ok=False, error="bad_category"), 400 - flash("Błędna kategoria.", "danger") - return redirect(next_page) - - c = db.session.get(Category, cid) - if not c: - if wants_json: - return jsonify(ok=False, error="bad_category"), 400 - flash("Błędna kategoria.", "danger") - return redirect(next_page) - - # Jeśli jeden wybór – zastąp listę kategorii jedną: - l.categories = [c] - db.session.commit() - if wants_json: - return jsonify(ok=True, saved=True) - flash("Zapisano kategorię.", "success") - return redirect(next_page) - - # 2) Nadanie dostępu (akceptuj 'grant_access' i 'grant') - if action in ("grant_access", "grant"): - grant_username = (request.form.get("grant_username") or "").strip().lower() - - if not grant_username: - if wants_json: - return jsonify(ok=False, error="empty_username"), 400 - flash("Podaj nazwę użytkownika.", "danger") - return redirect(next_page) - - # Szukamy użytkownika po username (case-insensitive) - u = User.query.filter(func.lower(User.username) == grant_username).first() - if not u: - if wants_json: - return jsonify(ok=False, error="not_found"), 404 - flash("Użytkownik nie istnieje.", "danger") - return redirect(next_page) - - # Właściciel już ma dostęp - if u.id == l.owner_id: - if wants_json: - return jsonify(ok=False, error="owner"), 409 - flash("Jesteś właścicielem tej listy.", "info") - return redirect(next_page) - - # Czy już ma dostęp? - exists = ( - db.session.query(ListPermission.id) - .filter(ListPermission.list_id == l.id, ListPermission.user_id == u.id) - .first() - ) - if exists: - if wants_json: - return jsonify(ok=False, error="exists"), 409 - flash("Ten użytkownik już ma dostęp.", "info") - return redirect(next_page) - - # Zapis uprawnienia - db.session.add(ListPermission(list_id=l.id, user_id=u.id)) - db.session.commit() - - if wants_json: - # Zwracamy usera, żeby JS mógł dokleić token bez odświeżania - return jsonify(ok=True, user={"id": u.id, "username": u.username}) - flash(f"Nadano dostęp użytkownikowi „{u.username}”.", "success") - return redirect(next_page) - - # 3) Odebranie dostępu (po polu revoke_user_id, nie po action) - revoke_uid = request.form.get("revoke_user_id") - if revoke_uid: - try: - uid = int(revoke_uid) - except (TypeError, ValueError): - if wants_json: - return jsonify(ok=False, error="bad_user_id"), 400 - flash("Błędny identyfikator użytkownika.", "danger") - return redirect(next_page) - - # Nie pozwalaj usunąć właściciela - if uid == l.owner_id: - if wants_json: - return jsonify(ok=False, error="cannot_revoke_owner"), 400 - flash("Nie można odebrać dostępu właścicielowi.", "danger") - return redirect(next_page) - - ListPermission.query.filter_by(list_id=l.id, user_id=uid).delete() - db.session.commit() - - if wants_json: - return jsonify(ok=True, removed_user_id=uid) - flash("Odebrano dostęp użytkownikowi.", "success") - return redirect(next_page) - - # 4) Nieznana akcja - if wants_json: - return jsonify(ok=False, error="unknown_action"), 400 - flash("Nieznana akcja.", "danger") - return redirect(next_page) - - -@app.route("/expenses") -@login_required -def expenses(): - start_date_str = request.args.get("start_date") - end_date_str = request.args.get("end_date") - category_id = request.args.get("category_id", type=str) - show_all = request.args.get("show_all", "true").lower() == "true" - - now = datetime.now(timezone.utc) - - visible_clause = visible_lists_clause_for_expenses( - user_id=current_user.id, include_shared=show_all, now_dt=now - ) - - lists_q = ShoppingList.query.filter(*visible_clause) - - if start_date_str and end_date_str: - try: - start = datetime.strptime(start_date_str, "%Y-%m-%d") - end = datetime.strptime(end_date_str, "%Y-%m-%d") + timedelta(days=1) - lists_q = lists_q.filter( - ShoppingList.created_at >= start, - ShoppingList.created_at < end, - ) - except ValueError: - flash("Błędny zakres dat", "danger") - - if category_id: - if category_id == "none": - lists_q = lists_q.filter(~ShoppingList.categories.any()) - else: - try: - cid = int(category_id) - lists_q = lists_q.join( - shopping_list_category, - shopping_list_category.c.shopping_list_id == ShoppingList.id, - ).filter(shopping_list_category.c.category_id == cid) - except (TypeError, ValueError): - pass - - lists_filtered = ( - lists_q.options( - joinedload(ShoppingList.owner), joinedload(ShoppingList.categories) - ) - .order_by(ShoppingList.created_at.desc()) - .all() - ) - list_ids = [l.id for l in lists_filtered] or [-1] - - expenses = ( - Expense.query.options( - joinedload(Expense.shopping_list).joinedload(ShoppingList.owner), - joinedload(Expense.shopping_list).joinedload(ShoppingList.categories), - ) - .filter(Expense.list_id.in_(list_ids)) - .order_by(Expense.added_at.desc()) - .all() - ) - - totals_rows = ( - db.session.query( - ShoppingList.id.label("lid"), - func.coalesce(func.sum(Expense.amount), 0).label("total_expense"), - ) - .select_from(ShoppingList) - .filter(ShoppingList.id.in_(list_ids)) - .outerjoin(Expense, Expense.list_id == ShoppingList.id) - .group_by(ShoppingList.id) - .all() - ) - totals_map = {row.lid: float(row.total_expense or 0) for row in totals_rows} - - categories = ( - Category.query.join( - shopping_list_category, shopping_list_category.c.category_id == Category.id - ) - .join( - ShoppingList, ShoppingList.id == shopping_list_category.c.shopping_list_id - ) - .filter(ShoppingList.id.in_(list_ids)) - .distinct() - .order_by(Category.name.asc()) - .all() - ) - categories.append(SimpleNamespace(id="none", name="Bez kategorii")) - - expense_table = [ - { - "title": (e.shopping_list.title if e.shopping_list else "Nieznana"), - "amount": e.amount, - "added_at": e.added_at, - } - for e in expenses - ] - - lists_data = [ - { - "id": l.id, - "title": l.title, - "created_at": l.created_at, - "total_expense": totals_map.get(l.id, 0.0), - "owner_username": l.owner.username if l.owner else "?", - "categories": [c.id for c in l.categories], - } - for l in lists_filtered - ] - - return render_template( - "expenses.html", - expense_table=expense_table, - lists_data=lists_data, - categories=categories, - selected_category=category_id, - show_all=show_all, - ) - - -@app.route("/expenses_data") -@login_required -def expenses_data(): - range_type = request.args.get("range", "monthly") - start_date = request.args.get("start_date") - end_date = request.args.get("end_date") - show_all = request.args.get("show_all", "true").lower() == "true" - category_id = request.args.get("category_id") - by_category = request.args.get("by_category", "false").lower() == "true" - - if not start_date or not end_date: - sd, ed, bucket = resolve_range(range_type) - if sd and ed: - start_date = sd - end_date = ed - range_type = bucket - - if by_category: - result = get_total_expenses_grouped_by_category( - show_all=show_all, - range_type=range_type, - start_date=start_date, - end_date=end_date, - user_id=current_user.id, - category_id=category_id, - ) - else: - result = get_total_expenses_grouped_by_list_created_at( - user_only=False, - admin=False, - show_all=show_all, - range_type=range_type, - start_date=start_date, - end_date=end_date, - user_id=current_user.id, - category_id=category_id, - ) - - if "error" in result: - return jsonify({"error": result["error"]}), 400 - return jsonify(result) - - -@app.route("/share/") -# @app.route("/guest-list/") -@app.route("/shared/") -def shared_list(token=None, list_id=None): - now = datetime.now(timezone.utc) - - if token: - shopping_list = ShoppingList.query.filter_by(share_token=token).first_or_404() - - # jeśli lista wygasła – zablokuj (spójne z resztą aplikacji) - if ( - shopping_list.is_temporary - and shopping_list.expires_at - and shopping_list.expires_at <= now - ): - flash("Link wygasł.", "warning") - return redirect(url_for("main_page")) - - # >>> KLUCZOWE: pozwól wejść nawet, gdy niepubliczna (bez check_list_public) - list_id = shopping_list.id - - # >>> Jeśli zalogowany i nie jest właścicielem — auto-przypisz stałe uprawnienie - if current_user.is_authenticated and current_user.id != shopping_list.owner_id: - # dodaj wpis tylko jeśli go nie ma - exists = ( - db.session.query(ListPermission.id) - .filter( - ListPermission.list_id == shopping_list.id, - ListPermission.user_id == current_user.id, - ) - .first() - ) - if not exists: - db.session.add( - ListPermission(list_id=shopping_list.id, user_id=current_user.id) - ) - db.session.commit() - else: - shopping_list = ShoppingList.query.get_or_404(list_id) - - total_expense = get_total_expense_for_list(list_id) - shopping_list, items, receipts, expenses, total_expense = get_list_details(list_id) - - shopping_list.category_badges = [ - {"name": c.name, "color": category_color_for(c)} - for c in shopping_list.categories - ] - - for item in items: - if item.added_by != shopping_list.owner_id: - item.added_by_display = ( - item.added_by_user.username if item.added_by_user else "?" - ) - else: - item.added_by_display = None - - return render_template( - "list_share.html", - list=shopping_list, - items=items, - receipts=receipts, - expenses=expenses, - total_expense=total_expense, - is_share=True, - ) - - -@app.route("/copy/") -@login_required -def copy_list(list_id): - original = ShoppingList.query.get_or_404(list_id) - token = generate_share_token(8) - new_list = ShoppingList( - title=original.title + " (Kopia)", owner_id=current_user.id, share_token=token - ) - db.session.add(new_list) - db.session.commit() - original_items = Item.query.filter_by(list_id=original.id).all() - for item in original_items: - copy_item = Item(list_id=new_list.id, name=item.name) - db.session.add(copy_item) - db.session.commit() - flash("Skopiowano listę", "success") - return redirect(url_for("view_list", list_id=new_list.id)) - - -@app.route("/suggest_products") -def suggest_products(): - query = request.args.get("q", "") - suggestions = [] - if query: - suggestions = ( - SuggestedProduct.query.filter(SuggestedProduct.name.ilike(f"%{query}%")) - .limit(5) - .all() - ) - return {"suggestions": [s.name for s in suggestions]} - - -@app.route("/all_products") -def all_products(): - sort = request.args.get("sort", "popularity") - limit = request.args.get("limit", type=int) or 100 - offset = request.args.get("offset", type=int) or 0 - - products_from_items = db.session.query( - func.lower(func.trim(Item.name)).label("normalized_name"), - func.min(Item.name).label("display_name"), - func.count(func.distinct(Item.list_id)).label("count"), - ).group_by(func.lower(func.trim(Item.name))) - - products_from_suggested = ( - db.session.query( - func.lower(func.trim(SuggestedProduct.name)).label("normalized_name"), - func.min(SuggestedProduct.name).label("display_name"), - db.literal(1).label("count"), - ) - .filter( - ~func.lower(func.trim(SuggestedProduct.name)).in_( - db.session.query(func.lower(func.trim(Item.name))) - ) - ) - .group_by(func.lower(func.trim(SuggestedProduct.name))) - ) - - union_q = products_from_items.union_all(products_from_suggested).subquery() - - final_q = db.session.query( - union_q.c.normalized_name, - union_q.c.display_name, - func.sum(union_q.c.count).label("count"), - ).group_by(union_q.c.normalized_name, union_q.c.display_name) - - if sort == "alphabetical": - final_q = final_q.order_by(func.lower(union_q.c.display_name).asc()) - else: - final_q = final_q.order_by( - func.sum(union_q.c.count).desc(), func.lower(union_q.c.display_name).asc() - ) - - total_count = ( - db.session.query(func.count()).select_from(final_q.subquery()).scalar() - ) - products = final_q.offset(offset).limit(limit).all() - - out = [{"name": row.display_name, "count": row.count} for row in products] - - return jsonify({"products": out, "total_count": total_count}) - - -@app.route("/upload_receipt/", methods=["POST"]) -@login_required -def upload_receipt(list_id): - l = db.session.get(ShoppingList, list_id) - - file = request.files.get("receipt") - if not file or file.filename == "": - return receipt_error("Nie wybrano pliku") - - if not allowed_file(file.filename): - return receipt_error("Niedozwolony format pliku") - - file_bytes = file.read() - file.seek(0) - file_hash = hashlib.sha256(file_bytes).hexdigest() - - existing = Receipt.query.filter_by(file_hash=file_hash).first() - if existing: - return receipt_error("Taki plik już istnieje") - - now = datetime.now(timezone.utc) - timestamp = now.strftime("%Y%m%d_%H%M") - random_part = secrets.token_hex(3) - webp_filename = f"list_{list_id}_{timestamp}_{random_part}.webp" - file_path = os.path.join(app.config["UPLOAD_FOLDER"], webp_filename) - - try: - if file.filename.lower().endswith(".pdf"): - file.seek(0) - save_pdf_as_webp(file, file_path) - else: - save_resized_image(file, file_path) - except ValueError as e: - return receipt_error(str(e)) - - try: - new_receipt = Receipt( - list_id=list_id, - filename=webp_filename, - filesize=os.path.getsize(file_path), - uploaded_at=now, - file_hash=file_hash, - uploaded_by=current_user.id, - version_token=generate_version_token(), - ) - db.session.add(new_receipt) - db.session.commit() - except Exception as e: - return receipt_error(f"Błąd zapisu do bazy: {str(e)}") - - if request.is_json or request.headers.get("X-Requested-With") == "XMLHttpRequest": - url = ( - url_for("uploaded_file", filename=webp_filename) - + f"?v={new_receipt.version_token or '0'}" - ) - socketio.emit("receipt_added", {"url": url}, to=str(list_id)) - return jsonify({"success": True, "url": url}) - - flash("Wgrano paragon", "success") - return redirect(request.referrer or url_for("main_page")) - - -@app.route("/uploads/") -def uploaded_file(filename): - response = send_from_directory(app.config["UPLOAD_FOLDER"], filename) - response.headers["Cache-Control"] = app.config["UPLOADS_CACHE_CONTROL"] - response.headers.pop("Content-Disposition", None) - mime, _ = mimetypes.guess_type(filename) - if mime: - response.headers["Content-Type"] = mime - return response - - -@app.route("/reorder_items", methods=["POST"]) -@login_required -def reorder_items(): - data = request.get_json() - list_id = data.get("list_id") - order = data.get("order") - - for index, item_id in enumerate(order): - item = db.session.get(Item, item_id) - if item and item.list_id == list_id: - item.position = index - db.session.commit() - - socketio.emit( - "items_reordered", {"list_id": list_id, "order": order}, to=str(list_id) - ) - - return jsonify(success=True) - - -@app.route("/rotate_receipt/") -@login_required -def rotate_receipt_user(receipt_id): - receipt = Receipt.query.get_or_404(receipt_id) - list_obj = ShoppingList.query.get_or_404(receipt.list_id) - - if not (current_user.is_admin or current_user.id == list_obj.owner_id): - flash("Brak uprawnień do tej operacji", "danger") - return redirect(url_for("main_page")) - - try: - rotate_receipt_by_id(receipt_id) - recalculate_filesizes(receipt_id) - flash("Obrócono paragon", "success") - except FileNotFoundError: - flash("Plik nie istnieje", "danger") - except Exception as e: - flash(f"Błąd przy obracaniu: {str(e)}", "danger") - - return redirect(request.referrer or url_for("main_page")) - - -@app.route("/delete_receipt/") -@login_required -def delete_receipt_user(receipt_id): - receipt = Receipt.query.get_or_404(receipt_id) - list_obj = ShoppingList.query.get_or_404(receipt.list_id) - - if not (current_user.is_admin or current_user.id == list_obj.owner_id): - flash("Brak uprawnień do tej operacji", "danger") - return redirect(url_for("main_page")) - - try: - delete_receipt_by_id(receipt_id) - flash("Paragon usunięty", "success") - except Exception as e: - flash(f"Błąd przy usuwaniu pliku: {str(e)}", "danger") - - return redirect(request.referrer or url_for("main_page")) - - -# OCR -@app.route("/lists//analyze", methods=["POST"]) -@login_required -def analyze_receipts_for_list(list_id): - receipt_objs = Receipt.query.filter_by(list_id=list_id).all() - existing_expenses = { - e.receipt_filename - for e in Expense.query.filter_by(list_id=list_id).all() - if e.receipt_filename - } - - results = [] - total = 0.0 - - for receipt in receipt_objs: - filepath = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename) - if not os.path.exists(filepath): - continue - - try: - raw_image = Image.open(filepath).convert("RGB") - image = preprocess_image_for_tesseract(raw_image) - value, lines = extract_total_tesseract(image) - - except Exception as e: - print(f"OCR error for {receipt.filename}:\n{traceback.format_exc()}") - value = 0.0 - lines = [] - - already_added = receipt.filename in existing_expenses - - results.append( - { - "id": receipt.id, - "filename": receipt.filename, - "amount": round(value, 2), - "debug_text": lines, - "already_added": already_added, - } - ) - - # if not already_added: - total += value - - return jsonify({"results": results, "total": round(total, 2)}) - - -@app.route("/user_crop_receipt", methods=["POST"]) -@login_required -def crop_receipt_user(): - receipt_id = request.form.get("receipt_id") - file = request.files.get("cropped_image") - - receipt = Receipt.query.get_or_404(receipt_id) - list_obj = ShoppingList.query.get_or_404(receipt.list_id) - - if list_obj.owner_id != current_user.id and not current_user.is_admin: - return jsonify(success=False, error="Brak dostępu"), 403 - - result = handle_crop_receipt(receipt_id, file) - return jsonify(result) - - -@app.route("/admin") -@login_required -@admin_required -def admin_panel(): - month_str = request.args.get("m") - if not month_str: - month_str = datetime.now(timezone.utc).strftime("%Y-%m") - show_all = month_str == "all" - - if not show_all: - try: - if month_str: - year, month = map(int, month_str.split("-")) - now = datetime(year, month, 1, tzinfo=timezone.utc) - else: - now = datetime.now(timezone.utc) - month_str = now.strftime("%Y-%m") - except Exception: - now = datetime.now(timezone.utc) - month_str = now.strftime("%Y-%m") - start = now - end = (start + timedelta(days=31)).replace(day=1) - else: - now = datetime.now(timezone.utc) - start = end = None - - user_count = User.query.count() - list_count = ShoppingList.query.count() - item_count = Item.query.count() - - base_query = ShoppingList.query.options( - joinedload(ShoppingList.owner), - joinedload(ShoppingList.items), - joinedload(ShoppingList.receipts), - joinedload(ShoppingList.expenses), - joinedload(ShoppingList.categories), - ) - - if not show_all and start and end: - base_query = base_query.filter( - ShoppingList.created_at >= start, ShoppingList.created_at < end - ) - - all_lists = base_query.all() - all_ids = [l.id for l in all_lists] - - stats_map = {} - latest_expenses_map = {} - - if all_ids: - stats = ( - db.session.query( - Item.list_id, - func.count(Item.id).label("total_count"), - func.sum(case((Item.purchased == True, 1), else_=0)).label( - "purchased_count" - ), - ) - .filter(Item.list_id.in_(all_ids)) - .group_by(Item.list_id) - .all() - ) - stats_map = { - s.list_id: (s.total_count or 0, s.purchased_count or 0) for s in stats - } - - latest_expenses_map = dict( - db.session.query( - Expense.list_id, func.coalesce(func.sum(Expense.amount), 0) - ) - .filter(Expense.list_id.in_(all_ids)) - .group_by(Expense.list_id) - .all() - ) - - enriched_lists = [] - for l in all_lists: - total_count, purchased_count = stats_map.get(l.id, (0, 0)) - percent = (purchased_count / total_count * 100) if total_count > 0 else 0 - comments_count = sum(1 for i in l.items if i.note and i.note.strip() != "") - receipts_count = len(l.receipts) - total_expense = latest_expenses_map.get(l.id, 0) - - if l.is_temporary and l.expires_at: - expires_at = l.expires_at - if expires_at.tzinfo is None: - expires_at = expires_at.replace(tzinfo=timezone.utc) - is_expired = expires_at < now - else: - is_expired = False - - enriched_lists.append( - { - "list": l, - "total_count": total_count, - "purchased_count": purchased_count, - "percent": round(percent), - "comments_count": comments_count, - "receipts_count": receipts_count, - "total_expense": total_expense, - "expired": is_expired, - "categories": l.categories, - } - ) - - purchased_items_count = Item.query.filter_by(purchased=True).count() - not_purchased_count = Item.query.filter_by(not_purchased=True).count() - items_with_notes = Item.query.filter(Item.note.isnot(None), Item.note != "").count() - - total_expense = db.session.query(func.sum(Expense.amount)).scalar() or 0 - avg_list_expense = round(total_expense / list_count, 2) if list_count else 0 - - if db.engine.name == "sqlite": - timestamp_diff = func.strftime("%s", Item.purchased_at) - func.strftime( - "%s", Item.added_at - ) - elif db.engine.name in ("postgresql", "postgres"): - timestamp_diff = func.extract("epoch", Item.purchased_at) - func.extract( - "epoch", Item.added_at - ) - elif db.engine.name in ("mysql", "mariadb"): - timestamp_diff = func.timestampdiff( - text("SECOND"), Item.added_at, Item.purchased_at - ) - else: - timestamp_diff = None - - time_to_purchase = ( - db.session.query(func.avg(timestamp_diff)) - .filter( - Item.purchased == True, - Item.purchased_at.isnot(None), - Item.added_at.isnot(None), - ) - .scalar() - if timestamp_diff is not None - else None - ) - - avg_hours_to_purchase = round(time_to_purchase / 3600, 2) if time_to_purchase else 0 - - first_list = db.session.query(func.min(ShoppingList.created_at)).scalar() - last_list = db.session.query(func.max(ShoppingList.created_at)).scalar() - now_dt = datetime.now(timezone.utc) - - if first_list and first_list.tzinfo is None: - first_list = first_list.replace(tzinfo=timezone.utc) - - if last_list and last_list.tzinfo is None: - last_list = last_list.replace(tzinfo=timezone.utc) - - if first_list and last_list: - days_span = max((now_dt - first_list).days, 1) - avg_per_day = list_count / days_span - avg_per_week = round(avg_per_day * 7, 2) - avg_per_month = round(avg_per_day * 30.44, 2) - avg_per_year = round(avg_per_day * 365, 2) - else: - avg_per_week = avg_per_month = avg_per_year = 0 - - top_products = ( - db.session.query(Item.name, func.count(Item.id).label("count")) - .filter(Item.purchased.is_(True)) - .group_by(Item.name) - .order_by(func.count(Item.id).desc()) - .limit(7) - .all() - ) - - expense_summary = get_admin_expense_summary() - process = psutil.Process(os.getpid()) - app_mem = process.memory_info().rss // (1024 * 1024) - - db_engine = db.engine - db_info = { - "engine": db_engine.name, - "version": getattr(db_engine.dialect, "server_version_info", None), - "url": str(db_engine.url).split("?")[0], - } - - inspector = inspect(db_engine) - table_count = len(inspector.get_table_names()) - record_total = get_total_records() - uptime_minutes = int( - (datetime.now(timezone.utc) - app_start_time).total_seconds() // 60 - ) - - month_options = get_active_months_query() - - return render_template( - "admin/admin_panel.html", - user_count=user_count, - list_count=list_count, - item_count=item_count, - purchased_items_count=purchased_items_count, - not_purchased_count=not_purchased_count, - items_with_notes=items_with_notes, - avg_hours_to_purchase=avg_hours_to_purchase, - avg_list_expense=avg_list_expense, - avg_per_week=avg_per_week, - avg_per_month=avg_per_month, - avg_per_year=avg_per_year, - enriched_lists=enriched_lists, - top_products=top_products, - expense_summary=expense_summary, - now=now, - python_version=sys.version, - system_info=platform.platform(), - app_memory=f"{app_mem} MB", - db_info=db_info, - table_count=table_count, - record_total=record_total, - uptime_minutes=uptime_minutes, - timedelta=timedelta, - show_all=show_all, - month_str=month_str, - month_options=month_options, - ) - - -@app.route("/admin/add_user", methods=["POST"]) -@login_required -@admin_required -def add_user(): - username = request.form["username"].lower() - password = request.form["password"] - - if not username or not password: - flash("Wypełnij wszystkie pola", "danger") - return redirect(url_for("list_users")) - - if len(password) < 6: - flash("Hasło musi mieć co najmniej 6 znaków", "danger") - return redirect(url_for("list_users")) - - if User.query.filter(func.lower(User.username) == username).first(): - flash("Użytkownik o takiej nazwie już istnieje", "warning") - return redirect(url_for("list_users")) - - hashed_password = hash_password(password) - new_user = User(username=username, password_hash=hashed_password) - db.session.add(new_user) - db.session.commit() - flash("Dodano nowego użytkownika", "success") - return redirect(url_for("list_users")) - - -@app.route("/admin/users") -@login_required -@admin_required -def list_users(): - users = User.query.order_by(User.id.asc()).all() - - user_data = [] - for user in users: - list_count = ShoppingList.query.filter_by(owner_id=user.id).count() - item_count = Item.query.filter_by(added_by=user.id).count() - receipt_count = Receipt.query.filter_by(uploaded_by=user.id).count() - - user_data.append( - { - "user": user, - "list_count": list_count, - "item_count": item_count, - "receipt_count": receipt_count, - } - ) - - total_users = len(users) - - return render_template( - "admin/user_management.html", - user_data=user_data, - total_users=total_users, - ) - - -@app.route("/admin/change_password/", methods=["POST"]) -@login_required -@admin_required -def reset_password(user_id): - user = User.query.get_or_404(user_id) - new_password = request.form["password"] - - if not new_password: - flash("Podaj nowe hasło", "danger") - return redirect(url_for("list_users")) - - user.password_hash = hash_password(new_password) - db.session.commit() - flash(f"Hasło dla użytkownika {user.username} zostało zaktualizowane", "success") - return redirect(url_for("list_users")) - - -@app.route("/admin/delete_user/") -@login_required -@admin_required -def delete_user(user_id): - user = User.query.get_or_404(user_id) - - if user.is_admin: - flash("Nie można usunąć konta administratora.", "warning") - return redirect(url_for("list_users")) - - admin_user = User.query.filter_by(is_admin=True).first() - if not admin_user: - flash("Brak konta administratora do przeniesienia zawartości.", "danger") - return redirect(url_for("list_users")) - - lists_owned = ShoppingList.query.filter_by(owner_id=user.id).count() - - if lists_owned > 0: - ShoppingList.query.filter_by(owner_id=user.id).update( - {"owner_id": admin_user.id} - ) - Receipt.query.filter_by(uploaded_by=user.id).update( - {"uploaded_by": admin_user.id} - ) - Item.query.filter_by(added_by=user.id).update({"added_by": admin_user.id}) - db.session.commit() - flash( - f"Użytkownik '{user.username}' został usunięty, a jego zawartość przeniesiona na administratora.", - "success", - ) - else: - flash( - f"Użytkownik '{user.username}' został usunięty. Nie posiadał żadnych list zakupowych.", - "info", - ) - - db.session.delete(user) - db.session.commit() - - return redirect(url_for("list_users")) - - -@app.route("/admin/receipts", methods=["GET"]) -@app.route("/admin/receipts/", methods=["GET"]) -@login_required -@admin_required -def admin_receipts(list_id=None): - try: - page, per_page = get_page_args(default_per_page=24, max_per_page=200) - - if list_id is not None: - all_receipts = ( - Receipt.query.options(joinedload(Receipt.uploaded_by_user)) - .filter_by(list_id=list_id) - .order_by(Receipt.uploaded_at.desc()) - .all() - ) - receipts_paginated, total_items, total_pages = paginate_items( - all_receipts, page, per_page - ) - orphan_files = [] - id = list_id - else: - all_filenames = { - r.filename for r in Receipt.query.with_entities(Receipt.filename).all() - } - - pagination = ( - Receipt.query.options(joinedload(Receipt.uploaded_by_user)) - .order_by(Receipt.uploaded_at.desc()) - .paginate(page=page, per_page=per_page, error_out=False) - ) - - receipts_paginated = pagination.items - total_pages = pagination.pages - id = "all" - - upload_folder = app.config["UPLOAD_FOLDER"] - files_on_disk = set(os.listdir(upload_folder)) - orphan_files = [ - f - for f in files_on_disk - if f.endswith(".webp") - and f not in all_filenames - and f.startswith("list_") - ] - - except ValueError: - flash("Nieprawidłowe ID listy.", "danger") - return redirect(url_for("admin_panel")) - - total_filesize = db.session.query(func.sum(Receipt.filesize)).scalar() or 0 - page_filesize = sum(r.filesize or 0 for r in receipts_paginated) - - query_string = urlencode({k: v for k, v in request.args.items() if k != "page"}) - - return render_template( - "admin/receipts.html", - receipts=receipts_paginated, - orphan_files=orphan_files, - orphan_files_count=len(orphan_files), - page=page, - per_page=per_page, - total_pages=total_pages, - id=id, - query_string=query_string, - total_filesize=total_filesize, - page_filesize=page_filesize, - ) - - -@app.route("/admin/rotate_receipt/") -@login_required -@admin_required -def rotate_receipt(receipt_id): - try: - rotate_receipt_by_id(receipt_id) - recalculate_filesizes(receipt_id) - flash("Obrócono paragon", "success") - except FileNotFoundError: - flash("Plik nie istnieje", "danger") - except Exception as e: - flash(f"Błąd przy obracaniu: {str(e)}", "danger") - - return redirect(request.referrer or url_for("admin_receipts", id="all")) - - -@app.route("/admin/delete_receipt/") -@app.route("/admin/delete_receipt/orphan/") -@login_required -@admin_required -def delete_receipt(receipt_id=None, filename=None): - if filename: # tryb orphan - safe_filename = os.path.basename(filename) - if Receipt.query.filter_by(filename=safe_filename).first(): - flash("Nie można usunąć pliku powiązanego z bazą!", "danger") - else: - file_path = os.path.join(app.config["UPLOAD_FOLDER"], safe_filename) - if os.path.exists(file_path): - try: - os.remove(file_path) - flash(f"Usunięto plik: {safe_filename}", "success") - except Exception as e: - flash(f"Błąd przy usuwaniu pliku: {e}", "danger") - else: - flash("Plik już nie istnieje.", "warning") - return redirect(url_for("admin_receipts", id="all")) - - try: - delete_receipt_by_id(receipt_id) - flash("Paragon usunięty", "success") - except Exception as e: - flash(f"Błąd przy usuwaniu pliku: {str(e)}", "danger") - - return redirect(request.referrer or url_for("admin_receipts", id="all")) - - -@app.route("/admin/rename_receipt/") -@login_required -@admin_required -def rename_receipt(receipt_id): - receipt = Receipt.query.get_or_404(receipt_id) - old_path = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename) - - if not os.path.exists(old_path): - flash("Plik nie istnieje", "danger") - return redirect(request.referrer) - - new_filename = generate_new_receipt_filename(receipt.list_id) - new_path = os.path.join(app.config["UPLOAD_FOLDER"], new_filename) - - try: - os.rename(old_path, new_path) - receipt.filename = new_filename - db.session.flush() - recalculate_filesizes(receipt.id) - db.session.commit() - flash("Zmieniono nazwę pliku", "success") - except Exception as e: - flash(f"Błąd przy zmianie nazwy: {str(e)}", "danger") - - return redirect(request.referrer or url_for("admin_receipts", id="all")) - - -@app.route("/admin/generate_receipt_hash/") -@login_required -@admin_required -def generate_receipt_hash(receipt_id): - receipt = Receipt.query.get_or_404(receipt_id) - if receipt.file_hash: - flash("Hash już istnieje", "info") - return redirect(request.referrer) - - file_path = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename) - if not os.path.exists(file_path): - flash("Plik nie istnieje", "danger") - return redirect(request.referrer) - - try: - with open(file_path, "rb") as f: - file_hash = hashlib.sha256(f.read()).hexdigest() - receipt.file_hash = file_hash - db.session.commit() - flash("Hash wygenerowany", "success") - except Exception as e: - flash(f"Błąd przy generowaniu hasha: {e}", "danger") - - return redirect(request.referrer) - - -@app.route("/admin/delete_list", methods=["POST"]) -@login_required -@admin_required -def admin_delete_list(): - ids = request.form.getlist("list_ids") - single_id = request.form.get("single_list_id") - if single_id: - ids.append(single_id) - - for list_id in ids: - lst = db.session.get(ShoppingList, int(list_id)) - if lst: - delete_receipts_for_list(lst.id) - Receipt.query.filter_by(list_id=lst.id).delete() - Item.query.filter_by(list_id=lst.id).delete() - Expense.query.filter_by(list_id=lst.id).delete() - db.session.delete(lst) - - db.session.commit() - flash(f"Usunięto {len(ids)} list(e/y)", "success") - return redirect(request.referrer or url_for("admin_panel")) - - -@app.route("/admin/edit_list/", methods=["GET", "POST"]) -@login_required -@admin_required -def edit_list(list_id): - shopping_list = db.session.get( - ShoppingList, - list_id, - options=[ - joinedload(ShoppingList.expenses), - joinedload(ShoppingList.receipts), - joinedload(ShoppingList.owner), - joinedload(ShoppingList.items), - joinedload(ShoppingList.categories), - ], - ) - permitted_users = ( - db.session.query(User) - .join(ListPermission, ListPermission.user_id == User.id) - .filter(ListPermission.list_id == shopping_list.id) - .order_by(User.username.asc()) - .all() - ) - - if shopping_list is None: - abort(404) - - total_expense = get_total_expense_for_list(shopping_list.id) - categories = Category.query.order_by(Category.name.asc()).all() - selected_categories_ids = {c.id for c in shopping_list.categories} - - if request.method == "POST": - action = request.form.get("action") - - if action == "save": - new_title = request.form.get("title", "").strip() - new_amount_str = request.form.get("amount") - is_archived = "archived" in request.form - is_public = "public" in request.form - is_temporary = "temporary" in request.form - new_owner_id = request.form.get("owner_id") - expires_date = request.form.get("expires_date") - expires_time = request.form.get("expires_time") - - if new_title: - shopping_list.title = new_title - - shopping_list.is_archived = is_archived - shopping_list.is_public = is_public - shopping_list.is_temporary = is_temporary - - if expires_date and expires_time: - try: - combined = f"{expires_date} {expires_time}" - dt = datetime.strptime(combined, "%Y-%m-%d %H:%M") - shopping_list.expires_at = dt.replace(tzinfo=timezone.utc) - except ValueError: - flash("Niepoprawna data lub godzina wygasania", "danger") - return redirect(url_for("edit_list", list_id=list_id)) - else: - shopping_list.expires_at = None - - if new_owner_id: - try: - new_owner_id_int = int(new_owner_id) - user_obj = db.session.get(User, new_owner_id_int) - if user_obj: - shopping_list.owner_id = new_owner_id_int - Item.query.filter_by(list_id=list_id).update( - {"added_by": new_owner_id_int} - ) - Receipt.query.filter_by(list_id=list_id).update( - {"uploaded_by": new_owner_id_int} - ) - else: - flash("Wybrany użytkownik nie istnieje", "danger") - return redirect(url_for("edit_list", list_id=list_id)) - except ValueError: - flash("Niepoprawny ID użytkownika", "danger") - return redirect(url_for("edit_list", list_id=list_id)) - - if new_amount_str: - try: - new_amount = float(new_amount_str) - for expense in shopping_list.expenses: - db.session.delete(expense) - db.session.commit() - db.session.add(Expense(list_id=list_id, amount=new_amount)) - except ValueError: - flash("Niepoprawna kwota", "danger") - return redirect(url_for("edit_list", list_id=list_id)) - - created_month = request.form.get("created_month") - if created_month: - try: - year, month = map(int, created_month.split("-")) - shopping_list.created_at = datetime( - year, month, 1, tzinfo=timezone.utc - ) - except ValueError: - flash("Nieprawidłowy format miesiąca", "danger") - return redirect(url_for("edit_list", list_id=list_id)) - - update_list_categories_from_form(shopping_list, request.form) - db.session.commit() - flash("Zapisano zmiany listy", "success") - return redirect(url_for("edit_list", list_id=list_id)) - - elif action == "add_item": - item_name = request.form.get("item_name", "").strip() - quantity_str = request.form.get("quantity", "1") - - if not item_name: - flash("Podaj nazwę produktu", "danger") - return redirect(url_for("edit_list", list_id=list_id)) - - try: - quantity = max(1, int(quantity_str)) - except ValueError: - quantity = 1 - - db.session.add( - Item( - list_id=list_id, - name=item_name, - quantity=quantity, - added_by=current_user.id, - ) - ) - - exists = ( - db.session.query(SuggestedProduct) - .filter(func.lower(SuggestedProduct.name) == item_name.lower()) - .first() - ) - if not exists: - db.session.add(SuggestedProduct(name=item_name)) - - db.session.commit() - flash("Dodano produkt", "success") - return redirect(url_for("edit_list", list_id=list_id)) - - elif action == "delete_item": - item = get_valid_item_or_404(request.form.get("item_id"), list_id) - db.session.delete(item) - db.session.commit() - flash("Usunięto produkt", "success") - return redirect(url_for("edit_list", list_id=list_id)) - - elif action == "toggle_purchased": - item = get_valid_item_or_404(request.form.get("item_id"), list_id) - item.purchased = not item.purchased - db.session.commit() - flash("Zmieniono status oznaczenia produktu", "success") - return redirect(url_for("edit_list", list_id=list_id)) - - elif action == "mark_not_purchased": - item = get_valid_item_or_404(request.form.get("item_id"), list_id) - item.not_purchased = True - item.purchased = False - item.purchased_at = None - db.session.commit() - flash("Oznaczono produkt jako niekupione", "success") - return redirect(url_for("edit_list", list_id=list_id)) - - elif action == "unmark_not_purchased": - item = get_valid_item_or_404(request.form.get("item_id"), list_id) - item.not_purchased = False - item.not_purchased_reason = None - item.purchased = False - item.purchased_at = None - db.session.commit() - flash("Przywrócono produkt do listy", "success") - return redirect(url_for("edit_list", list_id=list_id)) - - elif action == "edit_quantity": - item = get_valid_item_or_404(request.form.get("item_id"), list_id) - try: - new_quantity = int(request.form.get("quantity")) - if new_quantity > 0: - item.quantity = new_quantity - db.session.commit() - flash("Zmieniono ilość produktu", "success") - except ValueError: - flash("Nieprawidłowa ilość", "danger") - return redirect(url_for("edit_list", list_id=list_id)) - - users = User.query.all() - items = shopping_list.items - receipts = shopping_list.receipts - - return render_template( - "admin/edit_list.html", - list=shopping_list, - total_expense=total_expense, - users=users, - items=items, - receipts=receipts, - categories=categories, - selected_categories=selected_categories_ids, - permitted_users=permitted_users, - ) - - -@app.route("/admin/products") -@login_required -@admin_required -def list_products(): - page, per_page = get_page_args() - - all_items = ( - Item.query.options(joinedload(Item.added_by_user)) - .order_by(Item.id.desc()) - .all() - ) - - seen_names = set() - unique_items = [] - for item in all_items: - key = normalize_name(item.name) - if key not in seen_names: - unique_items.append(item) - seen_names.add(key) - - usage_results = ( - db.session.query( - func.lower(Item.name).label("name"), - func.count(func.distinct(Item.list_id)).label("usage_count"), - ) - .group_by(func.lower(Item.name)) - .all() - ) - usage_counts = {row.name: row.usage_count for row in usage_results} - - items, total_items, total_pages = paginate_items(unique_items, page, per_page) - - user_ids = {item.added_by for item in items if item.added_by} - users = User.query.filter(User.id.in_(user_ids)).all() if user_ids else [] - users_dict = {u.id: u.username for u in users} - - suggestions = SuggestedProduct.query.all() - all_suggestions_dict = { - normalize_name(s.name): s for s in suggestions if s.name and s.name.strip() - } - - used_suggestion_names = {normalize_name(i.name) for i in unique_items} - - suggestions_dict = { - name: all_suggestions_dict[name] - for name in used_suggestion_names - if name in all_suggestions_dict - } - - orphan_suggestions = [ - s - for name, s in all_suggestions_dict.items() - if name not in used_suggestion_names - ] - - query_string = urlencode({k: v for k, v in request.args.items() if k != "page"}) - synced_names = set(suggestions_dict.keys()) - - return render_template( - "admin/list_products.html", - items=items, - users_dict=users_dict, - suggestions_dict=suggestions_dict, - orphan_suggestions=orphan_suggestions, - page=page, - per_page=per_page, - total_pages=total_pages, - query_string=query_string, - total_items=total_items, - usage_counts=usage_counts, - synced_names=synced_names, - ) - - -@app.route("/admin/sync_suggestion/", methods=["POST"]) -@login_required -def sync_suggestion_ajax(item_id): - if not current_user.is_admin: - return jsonify({"success": False, "message": "Brak uprawnień"}), 403 - - item = Item.query.get_or_404(item_id) - - existing = SuggestedProduct.query.filter( - func.lower(SuggestedProduct.name) == item.name.lower() - ).first() - if not existing: - new_suggestion = SuggestedProduct(name=item.name) - db.session.add(new_suggestion) - db.session.commit() - return jsonify( - { - "success": True, - "message": f"Utworzono sugestię dla produktu: {item.name}", - } - ) - else: - return jsonify( - { - "success": True, - "message": f"Sugestia dla produktu „{item.name}” już istnieje.", - } - ) - - -@app.route("/admin/delete_suggestion/", methods=["POST"]) -@login_required -def delete_suggestion_ajax(suggestion_id): - if not current_user.is_admin: - return jsonify({"success": False, "message": "Brak uprawnień"}), 403 - - suggestion = SuggestedProduct.query.get_or_404(suggestion_id) - db.session.delete(suggestion) - db.session.commit() - - return jsonify({"success": True, "message": "Sugestia została usunięta."}) - - -@app.route("/admin/promote_user/") -@login_required -@admin_required -def promote_user(user_id): - user = User.query.get_or_404(user_id) - user.is_admin = True - db.session.commit() - flash(f"Użytkownik {user.username} został ustawiony jako admin.", "success") - return redirect(url_for("list_users")) - - -@app.route("/admin/demote_user/") -@login_required -@admin_required -def demote_user(user_id): - user = User.query.get_or_404(user_id) - - if user.id == current_user.id: - flash("Nie możesz zdegradować samego siebie!", "danger") - return redirect(url_for("list_users")) - - admin_count = User.query.filter_by(is_admin=True).count() - if admin_count <= 1 and user.is_admin: - flash( - "Nie można zdegradować. Musi pozostać co najmniej jeden administrator.", - "danger", - ) - return redirect(url_for("list_users")) - - user.is_admin = False - db.session.commit() - flash(f"Użytkownik {user.username} został zdegradowany.", "success") - return redirect(url_for("list_users")) - - -@app.route("/admin/crop_receipt", methods=["POST"]) -@login_required -@admin_required -def crop_receipt_admin(): - receipt_id = request.form.get("receipt_id") - file = request.files.get("cropped_image") - result = handle_crop_receipt(receipt_id, file) - return jsonify(result) - - -@app.route("/admin/recalculate_filesizes") -@login_required -@admin_required -def recalculate_filesizes_all(): - updated, unchanged, not_found = recalculate_filesizes() - flash( - f"Zaktualizowano: {updated}, bez zmian: {unchanged}, brak pliku: {not_found}", - "success", - ) - return redirect(url_for("admin_receipts", id="all")) - - -@app.route("/admin/edit_categories", methods=["GET", "POST"]) -@login_required -@admin_required -def admin_edit_categories(): - page, per_page = get_page_args(default_per_page=50, max_per_page=200) - - lists_query = ShoppingList.query.options( - joinedload(ShoppingList.categories), - joinedload(ShoppingList.items), - joinedload(ShoppingList.owner), - ).order_by(ShoppingList.created_at.desc()) - - pagination = lists_query.paginate(page=page, per_page=per_page, error_out=False) - lists = pagination.items - - categories = Category.query.order_by(Category.name.asc()).all() - - for l in lists: - l.total_count = len(l.items) - l.owner_name = l.owner.username if l.owner else "?" - l.category_count = len(l.categories) - - if request.method == "POST": - for l in lists: - selected_ids = request.form.getlist(f"categories_{l.id}") - l.categories.clear() - if selected_ids: - cats = Category.query.filter(Category.id.in_(selected_ids)).all() - l.categories.extend(cats) - db.session.commit() - flash("Zaktualizowano kategorie dla wybranych list", "success") - return redirect(url_for("admin_edit_categories", page=page, per_page=per_page)) - - query_string = urlencode({k: v for k, v in request.args.items() if k != "page"}) - - return render_template( - "admin/edit_categories.html", - lists=lists, - categories=categories, - page=page, - per_page=per_page, - total_pages=pagination.pages, - total_items=pagination.total, - query_string=query_string, - ) - - -@app.route("/admin/edit_categories//save", methods=["POST"]) -@login_required -@admin_required -def admin_edit_categories_save(list_id): - l = db.session.get(ShoppingList, list_id) - if not l: - return jsonify(ok=False, error="not_found"), 404 - - data = request.get_json(silent=True) or {} - ids = data.get("category_ids", []) - - try: - ids = [int(x) for x in ids] - except (TypeError, ValueError): - return jsonify(ok=False, error="bad_ids"), 400 - - l.categories.clear() - if ids: - cats = Category.query.filter(Category.id.in_(ids)).all() - l.categories.extend(cats) - - db.session.commit() - return jsonify(ok=True, count=len(l.categories)), 200 - - -@app.route("/admin/list_items/") -@login_required -@admin_required -def admin_list_items_json(list_id): - l = db.session.get(ShoppingList, list_id) - if not l: - return jsonify({"error": "Lista nie istnieje"}), 404 - - items = [ - { - "name": item.name, - "quantity": item.quantity, - "purchased": item.purchased, - "not_purchased": item.not_purchased, - } - for item in l.items - ] - - purchased_count = sum(1 for item in l.items if item.purchased) - total_expense = sum(exp.amount for exp in l.expenses) - - return jsonify( - { - "title": l.title, - "items": items, - "total_count": len(l.items), - "purchased_count": purchased_count, - "total_expense": round(total_expense, 2), - } - ) - - -@app.route("/admin/add_suggestion", methods=["POST"]) -@login_required -@admin_required -def add_suggestion(): - name = request.form.get("suggestion_name", "").strip() - - if not name: - flash("Nazwa nie może być pusta", "warning") - return redirect(url_for("list_products")) - - existing = db.session.query(SuggestedProduct).filter_by(name=name).first() - if existing: - flash("Sugestia już istnieje", "warning") - else: - new_suggestion = SuggestedProduct(name=name) - db.session.add(new_suggestion) - db.session.commit() - flash("Dodano sugestię", "success") - - return redirect(url_for("list_products")) - - -@app.route("/admin/lists-access", methods=["GET", "POST"]) -@app.route("/admin/lists-access/", methods=["GET", "POST"]) -@login_required -@admin_required -def admin_lists_access(list_id=None): - try: - page = int(request.args.get("page", 1)) - except ValueError: - page = 1 - try: - per_page = int(request.args.get("per_page", 25)) - except ValueError: - per_page = 25 - per_page = max(1, min(100, per_page)) - - q = ShoppingList.query.options(db.joinedload(ShoppingList.owner)).order_by( - ShoppingList.created_at.desc() - ) - - if list_id is not None: - target_list = db.session.get(ShoppingList, list_id) - if not target_list: - flash("Lista nie istnieje.", "danger") - return redirect(url_for("admin_lists_access")) - lists = [target_list] - list_ids = [list_id] - pagination = None - else: - pagination = q.paginate(page=page, per_page=per_page, error_out=False) - lists = pagination.items - list_ids = [l.id for l in lists] - - if request.method == "POST": - action = request.form.get("action") - target_list_id = request.form.get("target_list_id", type=int) - - if action == "grant" and target_list_id: - login = (request.form.get("grant_username") or "").strip().lower() - l = db.session.get(ShoppingList, target_list_id) - if not l: - flash("Lista nie istnieje.", "danger") - return redirect(request.url) - u = User.query.filter(func.lower(User.username) == login).first() - if not u: - flash("Użytkownik nie istnieje.", "danger") - return redirect(request.url) - if u.id == l.owner_id: - flash("Nie można nadawać uprawnień właścicielowi listy.", "danger") - return redirect(request.url) - - exists = ( - db.session.query(ListPermission.id) - .filter(ListPermission.list_id == l.id, ListPermission.user_id == u.id) - .first() - ) - if not exists: - db.session.add(ListPermission(list_id=l.id, user_id=u.id)) - db.session.commit() - flash(f"Nadano dostęp „{u.username}” do listy #{l.id}.", "success") - else: - flash("Ten użytkownik już ma dostęp.", "info") - return redirect(request.url) - - if action == "revoke" and target_list_id: - uid = request.form.get("revoke_user_id", type=int) - if uid: - ListPermission.query.filter_by( - list_id=target_list_id, user_id=uid - ).delete() - db.session.commit() - flash("Odebrano dostęp użytkownikowi.", "success") - return redirect(request.url) - - if action == "save_changes": - ids = request.form.getlist("visible_ids", type=int) - if ids: - lists_edit = ShoppingList.query.filter(ShoppingList.id.in_(ids)).all() - posted = request.form - for l in lists_edit: - l.is_public = posted.get(f"is_public_{l.id}") is not None - l.is_temporary = posted.get(f"is_temporary_{l.id}") is not None - l.is_archived = posted.get(f"is_archived_{l.id}") is not None - db.session.commit() - flash("Zapisano zmiany statusów.", "success") - return redirect(request.url) - - perms = ( - db.session.query( - ListPermission.list_id, - User.id.label("uid"), - User.username.label("uname"), - ) - .join(User, User.id == ListPermission.user_id) - .filter(ListPermission.list_id.in_(list_ids)) - .order_by(User.username.asc()) - .all() - ) - - permitted_by_list = {lid: [] for lid in list_ids} - for lid, uid, uname in perms: - permitted_by_list[lid].append({"id": uid, "username": uname}) - - query_string = f"per_page={per_page}" - - return render_template( - "admin/lists_access.html", - lists=lists, - permitted_by_list=permitted_by_list, - page=page, - per_page=per_page, - total_pages=pagination.pages if pagination else 1, - query_string=query_string, - list_id=list_id, - ) - - -@app.route('/healthcheck') -def healthcheck(): - header_token = request.headers.get('X-Internal-Check') - correct_token = app.config.get('HEALTHCHECK_TOKEN') - if header_token != correct_token: - abort(404) - - try: - db.session.execute(text('SELECT 1')) - db.session.commit() - response_data = {"status": "ok"} - except Exception as e: - response_data = { - "status": "waiting", - "message": "waiting for db", - "error": str(e) - } - - return response_data, 200 - - -@app.route("/admin/settings", methods=["GET", "POST"]) -@login_required -@admin_required -def admin_settings(): - categories = Category.query.order_by(Category.name.asc()).all() - - if request.method == "POST": - ocr_raw = (request.form.get("ocr_keywords") or "").strip() - set_setting("ocr_keywords", ocr_raw) - - ocr_sens = (request.form.get("ocr_sensitivity") or "").strip() - set_setting("ocr_sensitivity", ocr_sens) - - max_attempts = (request.form.get("max_login_attempts") or "").strip() - set_setting("max_login_attempts", max_attempts) - - login_window = (request.form.get("login_window_seconds") or "").strip() - if login_window: - set_setting("login_window_seconds", login_window) - - for c in categories: - field = f"color_{c.id}" - vals = request.form.getlist(field) - val = (vals[-1] if vals else "").strip() - - existing = CategoryColorOverride.query.filter_by(category_id=c.id).first() - if val and re.fullmatch(r"^#[0-9A-Fa-f]{6}$", val): - if not existing: - db.session.add(CategoryColorOverride(category_id=c.id, color_hex=val)) - else: - existing.color_hex = val - else: - if existing: - db.session.delete(existing) - - db.session.commit() - flash("Zapisano ustawienia.", "success") - return redirect(url_for("admin_settings")) - - override_rows = CategoryColorOverride.query.filter( - CategoryColorOverride.category_id.in_([c.id for c in categories]) - ).all() - overrides = {row.category_id: row.color_hex for row in override_rows} - auto_colors = {c.id: category_to_color(c.name) for c in categories} - effective_colors = { - c.id: (overrides.get(c.id) or auto_colors[c.id]) for c in categories - } - - current_ocr = get_setting("ocr_keywords", "") - - ocr_sensitivity = get_int_setting("ocr_sensitivity", 5) - max_login_attempts = get_int_setting("max_login_attempts", 10) - login_window_seconds = get_int_setting("login_window_seconds", 3600) - - return render_template( - "admin/settings.html", - categories=categories, - overrides=overrides, - auto_colors=auto_colors, - effective_colors=effective_colors, - current_ocr=current_ocr, - ocr_sensitivity=ocr_sensitivity, - max_login_attempts=max_login_attempts, - login_window_seconds=login_window_seconds, - ) - - -@app.route("/robots.txt") -def robots_txt(): - content = ( - "User-agent: *\nDisallow: /" - if app.config.get("DISABLE_ROBOTS") - else "User-agent: *\nAllow: /" - ) - return content, 200, {"Content-Type": "text/plain"} - - -from flask import render_template_string - -@app.route('/admin/debug-socket') -@login_required -@admin_required -def debug_socket(): - return render_template_string(''' - - - - Socket Debug - - - - -

Socket.IO Debug Tool

- -
Rozlaczony
-
- Transport: - | - Ping: -ms | - SID: - -
- - - - - - -

Logi:

-
- - - - - ''') - - - -# ========================================================================================= -# SOCKET.IO -# ========================================================================================= - - -@socketio.on("delete_item") -def handle_delete_item(data): - # item = Item.query.get(data["item_id"]) - item = db.session.get(Item, data["item_id"]) - - if item: - list_id = item.list_id - db.session.delete(item) - db.session.commit() - emit("item_deleted", {"item_id": item.id}, to=str(item.list_id)) - - purchased_count, total_count, percent = get_progress(list_id) - - emit( - "progress_updated", - { - "purchased_count": purchased_count, - "total_count": total_count, - "percent": percent, - }, - to=str(list_id), - ) - - -@socketio.on("edit_item") -def handle_edit_item(data): - item = db.session.get(Item, data["item_id"]) - - new_name = data["new_name"] - new_quantity = data.get("new_quantity", item.quantity) - - if item and new_name.strip(): - item.name = new_name.strip() - - try: - new_quantity = int(new_quantity) - if new_quantity < 1: - new_quantity = 1 - except: - new_quantity = 1 - - item.quantity = new_quantity - - db.session.commit() - - emit( - "item_edited", - {"item_id": item.id, "new_name": item.name, "new_quantity": item.quantity}, - to=str(item.list_id), - ) - - -@socketio.on("join_list") -def handle_join(data): - global active_users - room = str(data["room"]) - username = data.get("username", "Gość") - join_room(room) - - if room not in active_users: - active_users[room] = set() - active_users[room].add(username) - - shopping_list = db.session.get(ShoppingList, int(data["room"])) - - list_title = shopping_list.title if shopping_list else "Twoja lista" - - emit("user_joined", {"username": username}, to=room) - emit("user_list", {"users": list(active_users[room])}, to=room) - emit("joined_confirmation", {"room": room, "list_title": list_title}) - - -@socketio.on("disconnect") -def handle_disconnect(sid): - global active_users - username = current_user.username if current_user.is_authenticated else "Gość" - for room, users in active_users.items(): - if username in users: - users.remove(username) - emit("user_left", {"username": username}, to=room) - emit("user_list", {"users": list(users)}, to=room) - - -@socketio.on("add_item") -def handle_add_item(data): - list_id = data["list_id"] - name = data["name"].strip() - quantity = data.get("quantity", 1) - - list_obj = db.session.get(ShoppingList, list_id) - if not list_obj: - return - - try: - quantity = int(quantity) - if quantity < 1: - quantity = 1 - except: - quantity = 1 - - existing_item = Item.query.filter( - Item.list_id == list_id, - func.lower(Item.name) == name.lower(), - Item.not_purchased == False, - ).first() - - if existing_item: - existing_item.quantity += quantity - db.session.commit() - - emit( - "item_edited", - { - "item_id": existing_item.id, - "new_name": existing_item.name, - "new_quantity": existing_item.quantity, - }, - to=str(list_id), - ) - else: - max_position = ( - db.session.query(func.max(Item.position)) - .filter_by(list_id=list_id) - .scalar() - ) - if max_position is None: - max_position = 0 - - user_id = current_user.id if current_user.is_authenticated else None - user_name = current_user.username if current_user.is_authenticated else "Gość" - - new_item = Item( - list_id=list_id, - name=name, - quantity=quantity, - position=max_position + 1, - added_by=user_id, - ) - db.session.add(new_item) - - if not SuggestedProduct.query.filter( - func.lower(SuggestedProduct.name) == name.lower() - ).first(): - new_suggestion = SuggestedProduct(name=name) - db.session.add(new_suggestion) - - db.session.commit() - - emit( - "item_added", - { - "id": new_item.id, - "name": new_item.name, - "quantity": new_item.quantity, - "added_by": user_name, - "added_by_id": user_id, - "owner_id": list_obj.owner_id, - }, - to=str(list_id), - include_self=True, - ) - - purchased_count, total_count, percent = get_progress(list_id) - - emit( - "progress_updated", - { - "purchased_count": purchased_count, - "total_count": total_count, - "percent": percent, - }, - to=str(list_id), - ) - - -@socketio.on("check_item") -def handle_check_item(data): - item = db.session.get(Item, data["item_id"]) - - if item: - item.purchased = True - item.purchased_at = datetime.now(UTC) - - db.session.commit() - - purchased_count, total_count, percent = get_progress(item.list_id) - - emit("item_checked", {"item_id": item.id}, to=str(item.list_id)) - emit( - "progress_updated", - { - "purchased_count": purchased_count, - "total_count": total_count, - "percent": percent, - }, - to=str(item.list_id), - ) - - -@socketio.on("uncheck_item") -def handle_uncheck_item(data): - item = db.session.get(Item, data["item_id"]) - - if item: - item.purchased = False - item.purchased_at = None - db.session.commit() - - purchased_count, total_count, percent = get_progress(item.list_id) - - emit("item_unchecked", {"item_id": item.id}, to=str(item.list_id)) - emit( - "progress_updated", - { - "purchased_count": purchased_count, - "total_count": total_count, - "percent": percent, - }, - to=str(item.list_id), - ) - - -@socketio.on("request_full_list") -def handle_request_full_list(data): - list_id = data["list_id"] - - shopping_list = db.session.get(ShoppingList, list_id) - if not shopping_list: - return - - owner_id = shopping_list.owner_id - - items = ( - Item.query.options(joinedload(Item.added_by_user)) - .filter_by(list_id=list_id) - .order_by(Item.position.asc()) - .all() - ) - - items_data = [] - for item in items: - items_data.append( - { - "id": item.id, - "name": item.name, - "quantity": item.quantity, - "purchased": item.purchased if not item.not_purchased else False, - "not_purchased": item.not_purchased, - "not_purchased_reason": item.not_purchased_reason, - "note": item.note or "", - "added_by": item.added_by_user.username if item.added_by_user else None, - "added_by_id": item.added_by_user.id if item.added_by_user else None, - "owner_id": owner_id, - } - ) - - emit("full_list", {"items": items_data}, to=request.sid) - - -@socketio.on("update_note") -def handle_update_note(data): - item_id = data["item_id"] - note = data["note"] - item = Item.query.get(item_id) - if item: - item.note = note - db.session.commit() - emit("note_updated", {"item_id": item_id, "note": note}, to=str(item.list_id)) - - -@socketio.on("add_expense") -def handle_add_expense(data): - list_id = data["list_id"] - amount = data["amount"] - receipt_filename = data.get("receipt_filename") - - if receipt_filename: - existing = Expense.query.filter_by( - list_id=list_id, receipt_filename=receipt_filename - ).first() - if existing: - return - new_expense = Expense( - list_id=list_id, amount=amount, receipt_filename=receipt_filename - ) - - db.session.add(new_expense) - db.session.commit() - - total = ( - db.session.query(func.sum(Expense.amount)).filter_by(list_id=list_id).scalar() - or 0 - ) - - emit("expense_added", {"amount": amount, "total": total}, to=str(list_id)) - - -@socketio.on("mark_not_purchased") -def handle_mark_not_purchased(data): - item = db.session.get(Item, data["item_id"]) - - reason = data.get("reason", "") - if item: - item.not_purchased = True - item.not_purchased_reason = reason - db.session.commit() - emit( - "item_marked_not_purchased", - {"item_id": item.id, "reason": reason}, - to=str(item.list_id), - ) - - -@socketio.on("unmark_not_purchased") -def handle_unmark_not_purchased(data): - item = db.session.get(Item, data["item_id"]) - - if item: - item.not_purchased = False - item.purchased = False - item.purchased_at = None - item.not_purchased_reason = None - db.session.commit() - emit("item_unmarked_not_purchased", {"item_id": item.id}, to=str(item.list_id)) - - -@app.cli.command("db_info") -def create_db(): - with app.app_context(): - inspector = inspect(db.engine) - actual_tables = inspector.get_table_names() - - table_count = len(actual_tables) - record_total = 0 - with db.engine.connect() as conn: - for table in actual_tables: - try: - count = conn.execute(text(f"SELECT COUNT(*) FROM {table}")).scalar() - record_total += count - except Exception: - pass - - print("\nStruktura bazy danych jest poprawna.") - print(f"Silnik: {db.engine.name}") - print(f"Liczba tabel: {table_count}") - print(f"Łączna liczba rekordów: {record_total}") - +from shopping_app import app, socketio, APP_PORT, DEBUG_MODE +from shopping_app.app_setup import logging if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG if DEBUG_MODE else logging.INFO) diff --git a/shopping_app/__init__.py b/shopping_app/__init__.py new file mode 100644 index 0000000..c34af99 --- /dev/null +++ b/shopping_app/__init__.py @@ -0,0 +1,11 @@ +from .app_setup import app, db, socketio, login_manager, APP_PORT, DEBUG_MODE, static_bp +from . import models # noqa: F401 +from . import helpers # noqa: F401 +app.register_blueprint(static_bp) +from . import web # noqa: F401 +from . import routes_main # noqa: F401 +from . import routes_secondary # noqa: F401 +from . import routes_admin # noqa: F401 +from . import sockets # noqa: F401 + +__all__ = ["app", "db", "socketio", "login_manager", "APP_PORT", "DEBUG_MODE"] diff --git a/shopping_app/app_setup.py b/shopping_app/app_setup.py new file mode 100644 index 0000000..bda174c --- /dev/null +++ b/shopping_app/app_setup.py @@ -0,0 +1,109 @@ +from .deps import * + +app = Flask(__name__) +app.config.from_object(Config) + +csp_policy = ( + { + "default-src": "'self'", + "script-src": "'self' 'unsafe-inline'", + "style-src": "'self' 'unsafe-inline'", + "img-src": "'self' data:", + "connect-src": "'self'", + } + if app.config.get("ENABLE_CSP", True) + else None +) + +permissions_policy = {"browsing-topics": "()"} if app.config.get("ENABLE_PP") else None + +talisman_kwargs = { + "force_https": False, + "strict_transport_security": app.config.get("ENABLE_HSTS", True), + "frame_options": "DENY" if app.config.get("ENABLE_XFO", True) else None, + "permissions_policy": permissions_policy, + "content_security_policy": csp_policy, + "x_content_type_options": app.config.get("ENABLE_XCTO", True), + "strict_transport_security_include_subdomains": False, +} + +referrer_policy = app.config.get("REFERRER_POLICY") +if referrer_policy: + talisman_kwargs["referrer_policy"] = referrer_policy + +effective_headers = { + k: v + for k, v in talisman_kwargs.items() + if k != "referrer_policy" and v not in (None, False) +} + +if effective_headers: + from flask_talisman import Talisman + + talisman = Talisman( + app, + session_cookie_secure=app.config.get("SESSION_COOKIE_SECURE", True), + **talisman_kwargs, + ) + print("[TALISMAN] Włączony z nagłówkami:", list(effective_headers.keys())) +else: + print("[TALISMAN] Pominięty — wszystkie nagłówki security wyłączone.") + +register_heif_opener() +SQLALCHEMY_ECHO = True +ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp", "heic", "pdf"} +SYSTEM_PASSWORD = app.config.get("SYSTEM_PASSWORD") +DEFAULT_ADMIN_USERNAME = app.config.get("DEFAULT_ADMIN_USERNAME") +DEFAULT_ADMIN_PASSWORD = app.config.get("DEFAULT_ADMIN_PASSWORD") +UPLOAD_FOLDER = app.config.get("UPLOAD_FOLDER") +AUTHORIZED_COOKIE_VALUE = app.config.get("AUTHORIZED_COOKIE_VALUE") +AUTH_COOKIE_MAX_AGE = app.config.get("AUTH_COOKIE_MAX_AGE") +HEALTHCHECK_TOKEN = app.config.get("HEALTHCHECK_TOKEN") +SESSION_TIMEOUT_MINUTES = int(app.config.get("SESSION_TIMEOUT_MINUTES")) +SESSION_COOKIE_SECURE = app.config.get("SESSION_COOKIE_SECURE") +APP_PORT = int(app.config.get("APP_PORT")) +app.config["COMPRESS_ALGORITHM"] = ["zstd", "br", "gzip", "deflate"] +app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(minutes=SESSION_TIMEOUT_MINUTES) +app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) +DEBUG_MODE = app.config.get("DEBUG_MODE", False) +os.makedirs(UPLOAD_FOLDER, exist_ok=True) +failed_login_attempts = defaultdict(deque) +MAX_ATTEMPTS = 10 +TIME_WINDOW = 60 * 60 +WEBP_SAVE_PARAMS = { + "format": "WEBP", + "lossless": False, + "method": 6, + "quality": 95, +} + +def read_commit(filename="version.txt", root_path=None): + base = root_path or os.path.dirname(os.path.abspath(__file__)) + path = os.path.join(base, filename) + if not os.path.exists(path): + return None + try: + commit = open(path, "r", encoding="utf-8").read().strip() + return commit[:12] if commit else None + except Exception: + return None + +commit = read_commit("version.txt", root_path=os.path.dirname(os.path.dirname(__file__))) or "dev" +APP_VERSION = commit +app.config["APP_VERSION"] = APP_VERSION +db = SQLAlchemy(app) +socketio = SocketIO(app, async_mode="gevent") +login_manager = LoginManager(app) +login_manager.login_view = "login" +app.config["SESSION_TYPE"] = "sqlalchemy" +app.config["SESSION_SQLALCHEMY"] = db +Session(app) +compress = Compress() +compress.init_app(app) +static_bp = Blueprint("static_bp", __name__) +active_users = {} + +def utcnow(): + return datetime.now(timezone.utc) + +app_start_time = utcnow() diff --git a/shopping_app/deps.py b/shopping_app/deps.py new file mode 100644 index 0000000..c080ca3 --- /dev/null +++ b/shopping_app/deps.py @@ -0,0 +1,39 @@ +import os +import secrets +import time +import mimetypes +import sys +import platform +import psutil +import hashlib +import re +import traceback +import bcrypt +import colorsys +from pillow_heif import register_heif_opener +from datetime import datetime, timedelta, UTC, timezone +from urllib.parse import urlparse, urlunparse, urlencode +from flask import ( + Flask, render_template, redirect, url_for, request, flash, Blueprint, + send_from_directory, abort, session, jsonify, g, render_template_string +) +from flask_sqlalchemy import SQLAlchemy +from flask_login import ( + LoginManager, UserMixin, login_user, login_required, logout_user, current_user +) +from flask_compress import Compress +from flask_socketio import SocketIO, emit, join_room +from config import Config +from PIL import Image, ExifTags, ImageFilter, ImageOps +from werkzeug.middleware.proxy_fix import ProxyFix +from sqlalchemy import func, extract, inspect, or_, case, text, and_, literal +from sqlalchemy.orm import joinedload, load_only, aliased +from collections import defaultdict, deque +from functools import wraps +from flask_session import Session +from types import SimpleNamespace +from pdf2image import convert_from_bytes +from typing import Sequence, Any +import pytesseract +from pytesseract import Output +import logging diff --git a/shopping_app/helpers.py b/shopping_app/helpers.py new file mode 100644 index 0000000..2bde3e3 --- /dev/null +++ b/shopping_app/helpers.py @@ -0,0 +1,1148 @@ +from .deps import * +from .app_setup import * +from .models import * + +def get_setting(key: str, default: str | None = None) -> str | None: + s = db.session.get(AppSetting, key) + return s.value if s else default + + +def set_setting(key: str, value: str | None): + s = db.session.get(AppSetting, key) + if (value or "").strip() == "": + if s: + db.session.delete(s) + else: + if not s: + s = AppSetting(key=key, value=value) + db.session.add(s) + else: + s.value = value + + +def get_ocr_keywords() -> list[str]: + raw = get_setting("ocr_keywords", None) + if raw: + try: + vals = ( + json.loads(raw) + if raw.strip().startswith("[") + else [v.strip() for v in raw.split(",")] + ) + return [v for v in vals if v] + except Exception: + pass + # domyślne – obecne w kodzie OCR + return [ + "razem do zapłaty", + "do zapłaty", + "suma", + "kwota", + "wartość", + "płatność", + "total", + "amount", + ] + + +# 1) nowa funkcja: tylko frazy użytkownika (bez domyślnych) +def get_user_ocr_keywords_only() -> list[str]: + raw = get_setting("ocr_keywords", None) + if not raw: + return [] + try: + if raw.strip().startswith("["): + vals = json.loads(raw) + else: + vals = [v.strip() for v in raw.split(",")] + return [v for v in vals if v] + except Exception: + return [] + + +_BASE_KEYWORDS_BLOCK = r""" + (?: + razem\s*do\s*zap[łl][aąo0]ty | + do\s*zap[łl][aąo0]ty | + suma | + kwota | + warto[śćs] | + płatno[śćs] | + total | + amount + ) +""" + + +def priority_keywords_pattern() -> re.Pattern: + user_terms = get_user_ocr_keywords_only() + if user_terms: + + escaped = [re.escape(t) for t in user_terms] + user_block = " | ".join(escaped) + combined = rf""" + \b( + {_BASE_KEYWORDS_BLOCK} + | {user_block} + )\b + """ + else: + combined = rf"""\b({_BASE_KEYWORDS_BLOCK})\b""" + return re.compile(combined, re.IGNORECASE | re.VERBOSE) + + +def category_color_for(c: Category) -> str: + ov = CategoryColorOverride.query.filter_by(category_id=c.id).first() + return ov.color_hex if ov else category_to_color(c.name) + + +def color_for_category_label(label: str) -> str: + cat = Category.query.filter(func.lower(Category.name) == label.lower()).first() + return category_color_for(cat) if cat else category_to_color(label) + + +def hash_password(password): + pepper = app.config["BCRYPT_PEPPER"] + peppered = (password + pepper).encode("utf-8") + salt = bcrypt.gensalt() + hashed = bcrypt.hashpw(peppered, salt) + return hashed.decode("utf-8") + + +def get_int_setting(key: str, default: int) -> int: + try: + v = get_setting(key, None) + return int(v) if v is not None and str(v).strip() != "" else default + except Exception: + return default + + +def check_password(stored_hash, password_input): + pepper = app.config["BCRYPT_PEPPER"] + peppered = (password_input + pepper).encode("utf-8") + if stored_hash.startswith("$2b$") or stored_hash.startswith("$2a$"): + try: + return bcrypt.checkpw(peppered, stored_hash.encode("utf-8")) + except Exception: + return False + return False + + +def set_authorized_cookie(response): + secure_flag = app.config["SESSION_COOKIE_SECURE"] + max_age = app.config.get("AUTH_COOKIE_MAX_AGE", 86400) + response.set_cookie( + "authorized", + AUTHORIZED_COOKIE_VALUE, + max_age=max_age, + secure=secure_flag, + httponly=True, + ) + return response + + +if app.config["SQLALCHEMY_DATABASE_URI"].startswith("sqlite:///"): + db_path = app.config["SQLALCHEMY_DATABASE_URI"].replace("sqlite:///", "", 1) + db_dir = os.path.dirname(db_path) + if db_dir and not os.path.exists(db_dir): + os.makedirs(db_dir, exist_ok=True) + print(f"Utworzono katalog bazy: {db_dir}") + + +with app.app_context(): + db.create_all() + + # --- Tworzenie admina --- + admin_username = DEFAULT_ADMIN_USERNAME + admin_password = DEFAULT_ADMIN_PASSWORD + password_hash = hash_password(admin_password) + + admin = User.query.filter_by(username=admin_username).first() + if admin: + if not admin.is_admin: + admin.is_admin = True + if not check_password(admin.password_hash, admin_password): + admin.password_hash = password_hash + print(f"[INFO] Zmieniono hasło admina '{admin_username}' z konfiguracji.") + db.session.commit() + else: + db.session.add( + User(username=admin_username, password_hash=password_hash, is_admin=True) + ) + db.session.commit() + + default_categories = app.config["DEFAULT_CATEGORIES"] + existing_names = { + c.name for c in Category.query.filter(Category.name.isnot(None)).all() + } + + existing_names_lower = {name.lower() for name in existing_names} + + missing = [ + cat for cat in default_categories if cat.lower() not in existing_names_lower + ] + + if missing: + db.session.add_all(Category(name=cat) for cat in missing) + db.session.commit() + print(f"[INFO] Dodano brakujące kategorie: {', '.join(missing)}") + # else: + # print("[INFO] Wszystkie domyślne kategorie już istnieją") + + +@static_bp.route("/static/js/") +def serve_js(filename): + response = send_from_directory("static/js", filename) + response.headers["Cache-Control"] = app.config["JS_CACHE_CONTROL"] + response.headers.pop("Content-Disposition", None) + return response + + +@static_bp.route("/static/css/") +def serve_css(filename): + response = send_from_directory("static/css", filename) + response.headers["Cache-Control"] = app.config["CSS_CACHE_CONTROL"] + response.headers.pop("Content-Disposition", None) + return response + + +@static_bp.route("/static/lib/js/") +def serve_js_lib(filename): + response = send_from_directory("static/lib/js", filename) + response.headers["Cache-Control"] = app.config["LIB_JS_CACHE_CONTROL"] + response.headers.pop("Content-Disposition", None) + return response + + +@static_bp.route("/static/lib/css/") +def serve_css_lib(filename): + response = send_from_directory("static/lib/css", filename) + response.headers["Cache-Control"] = app.config["LIB_CSS_CACHE_CONTROL"] + response.headers.pop("Content-Disposition", None) + return response + + + + +def allowed_file(filename): + return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS + + +def generate_version_token(): + return secrets.token_hex(8) + + +def get_list_details(list_id): + shopping_list = ShoppingList.query.options( + joinedload(ShoppingList.items).joinedload(Item.added_by_user), + joinedload(ShoppingList.expenses), + joinedload(ShoppingList.receipts), + ).get_or_404(list_id) + + items = sorted(shopping_list.items, key=lambda i: i.position or 0) + expenses = shopping_list.expenses + total_expense = sum(e.amount for e in expenses) if expenses else 0 + receipts = shopping_list.receipts + + return shopping_list, items, receipts, expenses, total_expense + + +def get_total_expense_for_list(list_id, start_date=None, end_date=None): + query = db.session.query(func.sum(Expense.amount)).filter( + Expense.list_id == list_id + ) + + if start_date and end_date: + query = query.filter( + Expense.added_at >= start_date, Expense.added_at < end_date + ) + + return query.scalar() or 0 + + +def update_list_categories_from_form(shopping_list, form): + raw_vals = form.getlist("categories") + candidate_ids = set() + + for v in raw_vals: + if not v: + continue + v = v.strip() + try: + candidate_ids.add(int(v)) + continue + except ValueError: + pass + + cat = Category.query.filter(func.lower(Category.name) == v.lower()).first() + if cat: + candidate_ids.add(cat.id) + shopping_list.categories.clear() + if candidate_ids: + cats = Category.query.filter(Category.id.in_(candidate_ids)).all() + shopping_list.categories.extend(cats) + + +def generate_share_token(length=8): + return secrets.token_hex(length // 2) + + +def check_list_public(shopping_list): + if not shopping_list.is_public: + flash("Ta lista nie jest publicznie dostępna", "danger") + return False + return True + + +def enrich_list_data(l): + counts = ( + db.session.query( + func.count(Item.id), + func.sum(case((Item.purchased == True, 1), else_=0)), + func.sum(Expense.amount), + ) + .outerjoin(Expense, Expense.list_id == Item.list_id) + .filter(Item.list_id == l.id) + .first() + ) + + l.total_count = counts[0] or 0 + l.purchased_count = counts[1] or 0 + l.total_expense = counts[2] or 0 + + return l + + +def get_total_records(): + total = 0 + inspector = inspect(db.engine) + with db.engine.connect() as conn: + for table_name in inspector.get_table_names(): + count = conn.execute(text(f"SELECT COUNT(*) FROM {table_name}")).scalar() + total += count + return total + + +def save_resized_image(file, path): + try: + image = Image.open(file) + image.verify() + file.seek(0) + image = Image.open(file) + except Exception: + raise ValueError("Nieprawidłowy plik graficzny") + + try: + image = ImageOps.exif_transpose(image) + except Exception: + pass + + try: + image.thumbnail((1500, 1500)) + image = image.convert("RGB") + image.info.clear() + + new_path = path.rsplit(".", 1)[0] + ".webp" + # image.save(new_path, **WEBP_SAVE_PARAMS) + image.save(new_path, format="WEBP", method=6, quality=100) + + except Exception as e: + raise ValueError(f"Błąd podczas przetwarzania obrazu: {e}") + + +def redirect_with_flash( + message: str, category: str = "info", endpoint: str = "main_page" +): + flash(message, category) + return redirect(url_for(endpoint)) + + +def can_view_list(sl: ShoppingList) -> bool: + if current_user.is_authenticated: + if sl.owner_id == current_user.id: + return True + if sl.is_public: + return True + return ( + db.session.query(ListPermission.id) + .filter_by(list_id=sl.id, user_id=current_user.id) + .first() + is not None + ) + return bool(sl.is_public) + + +def db_bucket(col, kind: str = "month"): + name = db.engine.name # 'sqlite', 'mysql', 'mariadb', 'postgresql', ... + kind = (kind or "month").lower() + + if kind == "day": + if name == "sqlite": + return func.strftime("%Y-%m-%d", col) + elif name in ("mysql", "mariadb"): + return func.date_format(col, "%Y-%m-%d") + else: + return func.to_char(col, "YYYY-MM-DD") + + if kind == "week": + if name == "sqlite": + return func.printf( + "%s-W%s", func.strftime("%Y", col), func.strftime("%W", col) + ) + elif name in ("mysql", "mariadb"): + return func.date_format(col, "%x-W%v") + else: + return func.to_char(col, 'IYYY-"W"IW') + + if name == "sqlite": + return func.strftime("%Y-%m", col) + elif name in ("mysql", "mariadb"): + return func.date_format(col, "%Y-%m") + else: + return func.to_char(col, "YYYY-MM") + + +def visible_lists_clause_for_expenses(user_id: int, include_shared: bool, now_dt): + perm_subq = user_permission_subq(user_id) + + base = [ + ShoppingList.is_archived == False, + ((ShoppingList.expires_at == None) | (ShoppingList.expires_at > now_dt)), + ] + + if include_shared: + base.append( + or_( + ShoppingList.owner_id == user_id, + ShoppingList.is_public == True, + ShoppingList.id.in_(perm_subq), + ) + ) + else: + base.append(ShoppingList.owner_id == user_id) + + return base + + +def user_permission_subq(user_id): + return db.session.query(ListPermission.list_id).filter( + ListPermission.user_id == user_id + ) + + +def admin_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if not current_user.is_authenticated or not current_user.is_admin: + return redirect_with_flash("Brak uprawnień do tej sekcji.", "danger") + return f(*args, **kwargs) + + return decorated_function + + +def get_progress(list_id: int) -> tuple[int, int, float]: + result = ( + db.session.query( + func.count(Item.id), + func.sum(case((Item.purchased == True, 1), else_=0)), + ) + .filter(Item.list_id == list_id) + .first() + ) + + if result is None: + total_count = 0 + purchased_count = 0 + else: + total_count = result[0] or 0 + purchased_count = result[1] or 0 + + percent = (purchased_count / total_count * 100) if total_count > 0 else 0 + return purchased_count, total_count, percent + + +def delete_receipts_for_list(list_id): + receipt_pattern = f"list_{list_id}_" + upload_folder = app.config["UPLOAD_FOLDER"] + for filename in os.listdir(upload_folder): + if filename.startswith(receipt_pattern): + try: + os.remove(os.path.join(upload_folder, filename)) + except Exception as e: + print(f"Nie udało się usunąć pliku {filename}: {e}") + + +def receipt_error(message): + if request.is_json or request.headers.get("X-Requested-With") == "XMLHttpRequest": + return jsonify({"success": False, "error": message}), 400 + flash(message, "danger") + return redirect(request.referrer or url_for("main_page")) + + +def rotate_receipt_by_id(receipt_id): + receipt = Receipt.query.get_or_404(receipt_id) + path = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename) + + if not os.path.exists(path): + raise FileNotFoundError("Plik nie istnieje") + + try: + image = Image.open(path) + rotated = image.rotate(-90, expand=True) + + rotated = rotated.convert("RGB") + rotated.info.clear() + + rotated.save(path, format="WEBP", method=6, quality=100) + receipt.version_token = generate_version_token() + recalculate_filesizes(receipt.id) + db.session.commit() + + return receipt + except Exception as e: + app.logger.exception("Błąd podczas rotacji pliku") + raise RuntimeError(f"Błąd podczas rotacji pliku: {e}") + + +def delete_receipt_by_id(receipt_id): + receipt = Receipt.query.get_or_404(receipt_id) + filepath = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename) + + if os.path.exists(filepath): + os.remove(filepath) + + db.session.delete(receipt) + db.session.commit() + return receipt + + +def generate_new_receipt_filename(list_id): + timestamp = datetime.now().strftime("%Y%m%d_%H%M") + random_part = secrets.token_hex(3) + return f"list_{list_id}_{timestamp}_{random_part}.webp" + + +def handle_crop_receipt(receipt_id, file): + if not receipt_id or not file: + return {"success": False, "error": "Brak danych"} + + try: + receipt = Receipt.query.get_or_404(receipt_id) + path = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename) + + save_resized_image(file, path) + receipt.version_token = generate_version_token() + recalculate_filesizes(receipt.id) + db.session.commit() + + return {"success": True} + except Exception as e: + app.logger.exception("Błąd podczas przycinania paragonu") + return {"success": False, "error": str(e)} + + +def recalculate_filesizes(receipt_id: int = None): + updated = 0 + not_found = 0 + unchanged = 0 + + if receipt_id is not None: + receipt = db.session.get(Receipt, receipt_id) + receipts = [receipt] if receipt else [] + else: + receipts = db.session.execute(db.select(Receipt)).scalars().all() + + for r in receipts: + if not r: + continue + filepath = os.path.join(app.config["UPLOAD_FOLDER"], r.filename) + if os.path.exists(filepath): + real_size = os.path.getsize(filepath) + if r.filesize != real_size: + r.filesize = real_size + updated += 1 + else: + unchanged += 1 + else: + not_found += 1 + + db.session.commit() + return updated, unchanged, not_found + + +def get_admin_expense_summary(): + now = datetime.now(timezone.utc) + current_year = now.year + current_month = now.month + + def calc_summary(expense_query, list_query): + total = expense_query.scalar() or 0 + year_total = ( + expense_query.filter( + extract("year", ShoppingList.created_at) == current_year + ).scalar() + or 0 + ) + month_total = ( + expense_query.filter( + extract("year", ShoppingList.created_at) == current_year, + extract("month", ShoppingList.created_at) == current_month, + ).scalar() + or 0 + ) + list_count = list_query.count() + avg = round(total / list_count, 2) if list_count else 0 + return { + "total": total, + "year": year_total, + "month": month_total, + "count": list_count, + "avg": avg, + } + + expense_base = db.session.query(func.sum(Expense.amount)).join( + ShoppingList, ShoppingList.id == Expense.list_id + ) + list_base = ShoppingList.query + + all = calc_summary(expense_base, list_base) + + active_condition = and_( + ShoppingList.is_archived == False, + ~( + (ShoppingList.is_temporary == True) + & (ShoppingList.expires_at != None) + & (ShoppingList.expires_at <= now) + ), + ) + active = calc_summary( + expense_base.filter(active_condition), list_base.filter(active_condition) + ) + + archived_condition = ShoppingList.is_archived == True + archived = calc_summary( + expense_base.filter(archived_condition), list_base.filter(archived_condition) + ) + + expired_condition = and_( + ShoppingList.is_archived == False, + ShoppingList.is_temporary == True, + ShoppingList.expires_at != None, + ShoppingList.expires_at <= now, + ) + expired = calc_summary( + expense_base.filter(expired_condition), list_base.filter(expired_condition) + ) + + return { + "all": all, + "active": active, + "archived": archived, + "expired": expired, + } + + +def category_to_color(name: str, min_hue_gap_deg: int = 18) -> str: + # Stabilny hash -> int + hv = int(hashlib.md5(name.encode("utf-8")).hexdigest(), 16) + + # Proste mieszanie bitów, by uniknąć lokalnych skupień + def rotl(x, r, bits=128): + r %= bits + return ((x << r) | (x >> (bits - r))) & ((1 << bits) - 1) + + mix = hv ^ rotl(hv, 37) ^ rotl(hv, 73) ^ rotl(hv, 91) + + # Pełne pokrycie koła barw 0..360 + hue_deg = mix % 360 + + # Odpychanie lokalne po hue, by podobne nazwy nie lądowały zbyt blisko + gap = (rotl(mix, 17) % (2 * min_hue_gap_deg)) - min_hue_gap_deg # [-gap, +gap] + hue_deg = (hue_deg + gap) % 360 + + # DARK profil: niższa jasność i nieco mniejsza saturacja + s = 0.70 + l = 0.45 + + # Wąska wariacja, żeby uniknąć „neonów” i zachować spójność + s_var = ((rotl(mix, 29) % 5) - 2) / 100.0 # ±0.02 + l_var = ((rotl(mix, 53) % 7) - 3) / 100.0 # ±0.03 + s = min(0.76, max(0.62, s + s_var)) + l = min(0.50, max(0.40, l + l_var)) + + # Konwersja HLS->RGB (colorsys: H,L,S w [0..1]) + h = hue_deg / 360.0 + r, g, b = colorsys.hls_to_rgb(h, l, s) + + return f"#{int(round(r*255)):02x}{int(round(g*255)):02x}{int(round(b*255)):02x}" + + +def get_total_expenses_grouped_by_category( + show_all, range_type, start_date, end_date, user_id, category_id=None +): + now = datetime.now(timezone.utc) + lists_q = ShoppingList.query.filter( + ShoppingList.is_archived == False, + ((ShoppingList.expires_at == None) | (ShoppingList.expires_at > now)), + ) + + if show_all: + perm_subq = user_permission_subq(user_id) + lists_q = lists_q.filter( + or_( + ShoppingList.owner_id == user_id, + ShoppingList.is_public == True, + ShoppingList.id.in_(perm_subq), + ) + ) + else: + lists_q = lists_q.filter(ShoppingList.owner_id == user_id) + + if category_id: + if str(category_id) == "none": + lists_q = lists_q.filter(~ShoppingList.categories.any()) + else: + try: + cid = int(category_id) + lists_q = lists_q.join( + shopping_list_category, + shopping_list_category.c.shopping_list_id == ShoppingList.id, + ).filter(shopping_list_category.c.category_id == cid) + except (TypeError, ValueError): + pass + + if start_date and end_date: + try: + dt_start = datetime.strptime(start_date, "%Y-%m-%d") + dt_end = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1) + lists_q = lists_q.filter( + ShoppingList.created_at >= dt_start, ShoppingList.created_at < dt_end + ) + except Exception: + return {"error": "Błędne daty"} + + lists = lists_q.options(joinedload(ShoppingList.categories)).all() + if not lists: + return {"labels": [], "datasets": []} + + list_ids = [l.id for l in lists] + totals = ( + db.session.query( + Expense.list_id, + func.coalesce(func.sum(Expense.amount), 0).label("total_amount"), + ) + .filter(Expense.list_id.in_(list_ids)) + .group_by(Expense.list_id) + .all() + ) + expense_map = {lid: float(total or 0) for lid, total in totals} + + def bucket_from_dt(ts: datetime) -> str: + if range_type == "daily": + return ts.strftime("%Y-%m-%d") + elif range_type == "weekly": + return f"{ts.isocalendar().year}-W{ts.isocalendar().week:02d}" + elif range_type == "quarterly": + return f"{ts.year}-Q{((ts.month - 1)//3 + 1)}" + elif range_type == "halfyearly": + return f"{ts.year}-H{1 if ts.month <= 6 else 2}" + elif range_type == "yearly": + return str(ts.year) + else: + return ts.strftime("%Y-%m") + + data_map = defaultdict(lambda: defaultdict(float)) + all_labels = set() + + for l in lists: + key = bucket_from_dt(l.created_at) + all_labels.add(key) + total_expense = expense_map.get(l.id, 0.0) + + if str(category_id) == "none": + data_map[key]["Bez kategorii"] += total_expense + continue + + if not l.categories: + data_map[key]["Bez kategorii"] += total_expense + else: + for c in l.categories: + if category_id and str(c.id) != str(category_id): + continue + data_map[key][c.name] += total_expense + + labels = sorted(all_labels) + cats = sorted({cat for b in data_map.values() for cat, v in b.items() if v > 0}) + + datasets = [ + { + "label": cat, + "data": [round(data_map[label].get(cat, 0.0), 2) for label in labels], + "backgroundColor": color_for_category_label(cat), + } + for cat in cats + ] + return {"labels": labels, "datasets": datasets} + + +def get_total_expenses_grouped_by_list_created_at( + user_only=False, + admin=False, + show_all=False, + range_type="monthly", + start_date=None, + end_date=None, + user_id=None, + category_id=None, +): + now = datetime.now(timezone.utc) + lists_q = ShoppingList.query.filter( + ShoppingList.is_archived == False, + ((ShoppingList.expires_at == None) | (ShoppingList.expires_at > now)), + ) + + if admin: + pass + elif user_only: + lists_q = lists_q.filter(ShoppingList.owner_id == user_id) + elif show_all: + perm_subq = user_permission_subq(user_id) + lists_q = lists_q.filter( + or_( + ShoppingList.owner_id == user_id, + ShoppingList.is_public == True, + ShoppingList.id.in_(perm_subq), + ) + ) + else: + lists_q = lists_q.filter(ShoppingList.owner_id == user_id) + + # kategorie (bez ucinania „none”) + if category_id: + if str(category_id) == "none": + lists_q = lists_q.filter(~ShoppingList.categories.any()) + else: + try: + cid = int(category_id) + lists_q = lists_q.join( + shopping_list_category, + shopping_list_category.c.shopping_list_id == ShoppingList.id, + ).filter(shopping_list_category.c.category_id == cid) + except (TypeError, ValueError): + pass + + if start_date and end_date: + try: + dt_start = datetime.strptime(start_date, "%Y-%m-%d") + dt_end = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1) + lists_q = lists_q.filter( + ShoppingList.created_at >= dt_start, ShoppingList.created_at < dt_end + ) + except Exception: + return {"error": "Błędne daty"} + + lists = lists_q.options(joinedload(ShoppingList.categories)).all() + if not lists: + return {"labels": [], "expenses": []} + + list_ids = [l.id for l in lists] + totals = ( + db.session.query( + Expense.list_id, + func.coalesce(func.sum(Expense.amount), 0).label("total_amount"), + ) + .filter(Expense.list_id.in_(list_ids)) + .group_by(Expense.list_id) + .all() + ) + expense_map = {lid: float(total or 0) for lid, total in totals} + + def bucket_from_dt(ts: datetime) -> str: + if range_type == "daily": + return ts.strftime("%Y-%m-%d") + elif range_type == "weekly": + return f"{ts.isocalendar().year}-W{ts.isocalendar().week:02d}" + elif range_type == "quarterly": + return f"{ts.year}-Q{((ts.month - 1)//3 + 1)}" + elif range_type == "halfyearly": + return f"{ts.year}-H{1 if ts.month <= 6 else 2}" + elif range_type == "yearly": + return str(ts.year) + else: + return ts.strftime("%Y-%m") + + grouped = defaultdict(float) + for sl in lists: + grouped[bucket_from_dt(sl.created_at)] += expense_map.get(sl.id, 0.0) + + labels = sorted(grouped.keys()) + expenses = [round(grouped[l], 2) for l in labels] + return {"labels": labels, "expenses": expenses} + + +def resolve_range(range_type: str): + now = datetime.now(timezone.utc) + sd = ed = None + bucket = "monthly" + + rt = (range_type or "").lower() + if rt in ("last7days", "last_7_days"): + sd = (now - timedelta(days=7)).date().strftime("%Y-%m-%d") + ed = now.date().strftime("%Y-%m-%d") + bucket = "daily" + elif rt in ("last30days", "last_30_days"): + sd = (now - timedelta(days=30)).date().strftime("%Y-%m-%d") + ed = now.date().strftime("%Y-%m-%d") + bucket = "monthly" + elif rt in ("last90days", "last_90_days"): + sd = (now - timedelta(days=90)).date().strftime("%Y-%m-%d") + ed = now.date().strftime("%Y-%m-%d") + bucket = "monthly" + elif rt in ("thismonth", "this_month"): + first = datetime(now.year, now.month, 1, tzinfo=timezone.utc) + sd = first.date().strftime("%Y-%m-%d") + ed = now.date().strftime("%Y-%m-%d") + bucket = "monthly" + elif rt in ( + "currentmonth", + "thismonth", + "this_month", + "monthtodate", + "month_to_date", + "mtd", + ): + first = datetime(now.year, now.month, 1, tzinfo=timezone.utc) + sd = first.date().strftime("%Y-%m-%d") + ed = now.date().strftime("%Y-%m-%d") + bucket = "monthly" + + return sd, ed, bucket + + +def save_pdf_as_webp(file, path): + try: + images = convert_from_bytes(file.read(), dpi=300) + if not images: + raise ValueError("Nie udało się przekonwertować PDF na obraz.") + + total_height = sum(img.height for img in images) + max_width = max(img.width for img in images) + combined = Image.new("RGB", (max_width, total_height), (255, 255, 255)) + + y_offset = 0 + for img in images: + combined.paste(img, (0, y_offset)) + y_offset += img.height + + new_path = path.rsplit(".", 1)[0] + ".webp" + # combined.save(new_path, **WEBP_SAVE_PARAMS) + combined.save(new_path, format="WEBP") + + except Exception as e: + raise ValueError(f"Błąd podczas przetwarzania PDF: {e}") + + +def get_active_months_query(visible_lists_query=None): + if db.engine.name in ("sqlite",): + + def month_expr(col): + return func.strftime("%Y-%m", col) + + elif db.engine.name in ("mysql", "mariadb"): + + def month_expr(col): + return func.date_format(col, "%Y-%m") + + else: # PostgreSQL + + def month_expr(col): + return func.to_char(col, "YYYY-MM") + + if visible_lists_query is not None: + s = visible_lists_query.subquery() + month_sel = month_expr(s.c.created_at).label("month") + inner = ( + db.session.query(month_sel) + .filter(month_sel.isnot(None)) + .distinct() + .subquery() + ) + else: + month_sel = month_expr(ShoppingList.created_at).label("month") + inner = ( + db.session.query(month_sel) + .filter(ShoppingList.created_at.isnot(None)) + .distinct() + .subquery() + ) + + rows = db.session.query(inner.c.month).order_by(inner.c.month).all() + return [r.month for r in rows] + + +def normalize_name(name): + if not name: + return "" + return re.sub(r"\s+", " ", name).strip().lower() + + +def get_valid_item_or_404(item_id: int, list_id: int) -> Item: + item = db.session.get(Item, item_id) + if not item or item.list_id != list_id: + abort(404, description="Nie znaleziono produktu") + return item + + +def paginate_items( + items: Sequence[Any], page: int, per_page: int +) -> tuple[list, int, int]: + total_items = len(items) + total_pages = (total_items + per_page - 1) // per_page + start = (page - 1) * per_page + end = start + per_page + return items[start:end], total_items, total_pages + + +def get_page_args( + default_per_page: int = 100, max_per_page: int = 300 +) -> tuple[int, int]: + page = request.args.get("page", 1, type=int) + per_page = request.args.get("per_page", default_per_page, type=int) + per_page = max(1, min(per_page, max_per_page)) + return page, per_page + + +############# OCR ########################### + + +def preprocess_image_for_tesseract(image): + # czułość 1..10 (domyślnie 5) + sens = get_int_setting("ocr_sensitivity", 5) + # próg progowy – im wyższa czułość, tym niższy próg (więcej czerni) + base_thresh = 150 + delta = int((sens - 5) * 8) # krok 8 na stopień + thresh = max(90, min(210, base_thresh - delta)) + + image = ImageOps.autocontrast(image) + image = image.point(lambda x: 0 if x < thresh else 255) + image = image.resize((image.width * 2, image.height * 2), Image.BICUBIC) + return image + + +def extract_total_tesseract(image): + + text = pytesseract.image_to_string(image, lang="pol", config="--psm 4") + lines = text.splitlines() + candidates = [] + + blacklist_keywords = re.compile(r"\b(ptu|vat|podatek|stawka)\b", re.IGNORECASE) + + priority_keywords = priority_keywords_pattern() + + for line in lines: + if not line.strip(): + continue + + if blacklist_keywords.search(line): + continue + + is_priority = priority_keywords.search(line) + + matches = re.findall(r"\d{1,4}[.,]\d{2}", line) + for match in matches: + try: + val = float(match.replace(",", ".")) + if 0.1 <= val <= 100000: + candidates.append((val, line, is_priority is not None)) + except: + continue + + if is_priority: + spaced = re.findall(r"\d{1,4}\s\d{2}", line) + for match in spaced: + try: + val = float(match.replace(" ", ".")) + if 0.1 <= val <= 100000: + candidates.append((val, line, True)) + except: + continue + + preferred = [(val, line) for val, line, is_pref in candidates if is_pref] + + if preferred: + best_val = max(preferred, key=lambda x: x[0])[0] + if best_val < 99999: + return round(best_val, 2), lines + + if candidates: + best_val = max(candidates, key=lambda x: x[0])[0] + if best_val < 99999: + return round(best_val, 2), lines + + data = pytesseract.image_to_data( + image, lang="pol", config="--psm 4", output_type=Output.DICT + ) + + font_candidates = [] + for i in range(len(data["text"])): + word = data["text"][i].strip() + if not word or not re.match(r"^\d{1,5}[.,\s]\d{2}$", word): + continue + + try: + val = float(word.replace(",", ".").replace(" ", ".")) + height = data["height"][i] + conf = int(data.get("conf", ["0"] * len(data["text"]))[i]) + if 0.1 <= val <= 100000: + font_candidates.append((val, height, conf)) + except: + continue + + if font_candidates: + best = max(font_candidates, key=lambda x: (x[1], x[2])) + return round(best[0], 2), lines + + return 0.0, lines + + +############# END OCR ####################### + + +# zabezpieczenie logowani do systemu - błędne hasła +def is_ip_blocked(ip): + now = time.time() + attempts = failed_login_attempts[ip] + while attempts and now - attempts[0] > TIME_WINDOW: + attempts.popleft() + max_attempts = get_int_setting("max_login_attempts", 10) + return len(attempts) >= max_attempts + + +def attempts_remaining(ip): + attempts = failed_login_attempts[ip] + max_attempts = get_int_setting("max_login_attempts", 10) + return max(0, max_attempts - len(attempts)) + + +def register_failed_attempt(ip): + now = time.time() + attempts = failed_login_attempts[ip] + while attempts and now - attempts[0] > TIME_WINDOW: + attempts.popleft() + attempts.append(now) + + +def reset_failed_attempts(ip): + failed_login_attempts[ip].clear() + + +#################################################### + + +def get_client_ip(): + for header in ["X-Forwarded-For", "X-Real-IP"]: + if header in request.headers: + ip = request.headers[header].split(",")[0].strip() + if ip: + return ip + return request.remote_addr diff --git a/shopping_app/models.py b/shopping_app/models.py new file mode 100644 index 0000000..3d84f8d --- /dev/null +++ b/shopping_app/models.py @@ -0,0 +1,155 @@ +from .deps import * +from .app_setup import db, utcnow + +class User(UserMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(150), unique=True, nullable=False) + password_hash = db.Column(db.String(512), nullable=False) + is_admin = db.Column(db.Boolean, default=False) + + +# Tabela pośrednia +shopping_list_category = db.Table( + "shopping_list_category", + db.Column( + "shopping_list_id", + db.Integer, + db.ForeignKey("shopping_list.id"), + primary_key=True, + ), + db.Column( + "category_id", db.Integer, db.ForeignKey("category.id"), primary_key=True + ), +) + + +class Category(db.Model): + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100), unique=True, nullable=False) + + +class ShoppingList(db.Model): + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(150), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + owner_id = db.Column(db.Integer, db.ForeignKey("user.id")) + owner = db.relationship("User", backref="lists", foreign_keys=[owner_id]) + + is_temporary = db.Column(db.Boolean, default=False) + share_token = db.Column(db.String(64), unique=True, nullable=True) + expires_at = db.Column(db.DateTime(timezone=True), nullable=True) + owner = db.relationship("User", backref="lists", lazy=True) + is_archived = db.Column(db.Boolean, default=False) + is_public = db.Column(db.Boolean, default=False) + + # Relacje + items = db.relationship("Item", back_populates="shopping_list", lazy="select") + receipts = db.relationship( + "Receipt", + back_populates="shopping_list", + cascade="all, delete-orphan", + lazy="select", + ) + expenses = db.relationship("Expense", back_populates="shopping_list", lazy="select") + categories = db.relationship( + "Category", + secondary=shopping_list_category, + backref=db.backref("shopping_lists", lazy="dynamic"), + ) + + +class Item(db.Model): + id = db.Column(db.Integer, primary_key=True) + list_id = db.Column(db.Integer, db.ForeignKey("shopping_list.id")) + name = db.Column(db.String(150), nullable=False) + # added_at = db.Column(db.DateTime, default=datetime.utcnow) + added_at = db.Column(db.DateTime, default=utcnow) + added_by = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) + added_by_user = db.relationship( + "User", backref="added_items", lazy="joined", foreign_keys=[added_by] + ) + + purchased = db.Column(db.Boolean, default=False) + purchased_at = db.Column(db.DateTime, nullable=True) + quantity = db.Column(db.Integer, default=1) + note = db.Column(db.Text, nullable=True) + not_purchased = db.Column(db.Boolean, default=False) + not_purchased_reason = db.Column(db.Text, nullable=True) + position = db.Column(db.Integer, default=0) + + shopping_list = db.relationship("ShoppingList", back_populates="items") + + +class SuggestedProduct(db.Model): + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(150), unique=True, nullable=False) + usage_count = db.Column(db.Integer, default=0) + + +class Expense(db.Model): + id = db.Column(db.Integer, primary_key=True) + list_id = db.Column(db.Integer, db.ForeignKey("shopping_list.id")) + amount = db.Column(db.Float, nullable=False) + added_at = db.Column(db.DateTime, default=datetime.utcnow) + receipt_filename = db.Column(db.String(255), nullable=True) + + shopping_list = db.relationship("ShoppingList", back_populates="expenses") + + +class Receipt(db.Model): + id = db.Column(db.Integer, primary_key=True) + list_id = db.Column( + db.Integer, + db.ForeignKey("shopping_list.id", ondelete="CASCADE"), + nullable=False, + ) + filename = db.Column(db.String(255), nullable=False) + uploaded_at = db.Column(db.DateTime, default=datetime.utcnow) + filesize = db.Column(db.Integer, nullable=True) + file_hash = db.Column(db.String(64), nullable=True, unique=True) + uploaded_by = db.Column(db.Integer, db.ForeignKey("user.id")) + version_token = db.Column(db.String(32), nullable=True) + + shopping_list = db.relationship("ShoppingList", back_populates="receipts") + uploaded_by_user = db.relationship("User", backref="uploaded_receipts") + + +class ListPermission(db.Model): + __tablename__ = "list_permission" + id = db.Column(db.Integer, primary_key=True) + list_id = db.Column( + db.Integer, + db.ForeignKey("shopping_list.id", ondelete="CASCADE"), + nullable=False, + ) + user_id = db.Column( + db.Integer, + db.ForeignKey("user.id", ondelete="CASCADE"), + nullable=False, + ) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + __table_args__ = (db.UniqueConstraint("list_id", "user_id", name="uq_list_user"),) + + +ShoppingList.permitted_users = db.relationship( + "User", + secondary="list_permission", + backref=db.backref("permitted_lists", lazy="dynamic"), + lazy="dynamic", +) + + +class AppSetting(db.Model): + key = db.Column(db.String(64), primary_key=True) + value = db.Column(db.Text, nullable=True) + + +class CategoryColorOverride(db.Model): + id = db.Column(db.Integer, primary_key=True) + category_id = db.Column( + db.Integer, db.ForeignKey("category.id"), unique=True, nullable=False + ) + color_hex = db.Column(db.String(7), nullable=False) # "#rrggbb" + + diff --git a/shopping_app/routes_admin.py b/shopping_app/routes_admin.py new file mode 100644 index 0000000..eaa08b9 --- /dev/null +++ b/shopping_app/routes_admin.py @@ -0,0 +1,1247 @@ +from .deps import * +from .app_setup import * +from .models import * +from .helpers import * + +@app.route("/admin") +@login_required +@admin_required +def admin_panel(): + month_str = request.args.get("m") + if not month_str: + month_str = datetime.now(timezone.utc).strftime("%Y-%m") + show_all = month_str == "all" + + if not show_all: + try: + if month_str: + year, month = map(int, month_str.split("-")) + now = datetime(year, month, 1, tzinfo=timezone.utc) + else: + now = datetime.now(timezone.utc) + month_str = now.strftime("%Y-%m") + except Exception: + now = datetime.now(timezone.utc) + month_str = now.strftime("%Y-%m") + start = now + end = (start + timedelta(days=31)).replace(day=1) + else: + now = datetime.now(timezone.utc) + start = end = None + + user_count = User.query.count() + list_count = ShoppingList.query.count() + item_count = Item.query.count() + + base_query = ShoppingList.query.options( + joinedload(ShoppingList.owner), + joinedload(ShoppingList.items), + joinedload(ShoppingList.receipts), + joinedload(ShoppingList.expenses), + joinedload(ShoppingList.categories), + ) + + if not show_all and start and end: + base_query = base_query.filter( + ShoppingList.created_at >= start, ShoppingList.created_at < end + ) + + all_lists = base_query.all() + all_ids = [l.id for l in all_lists] + + stats_map = {} + latest_expenses_map = {} + + if all_ids: + stats = ( + db.session.query( + Item.list_id, + func.count(Item.id).label("total_count"), + func.sum(case((Item.purchased == True, 1), else_=0)).label( + "purchased_count" + ), + ) + .filter(Item.list_id.in_(all_ids)) + .group_by(Item.list_id) + .all() + ) + stats_map = { + s.list_id: (s.total_count or 0, s.purchased_count or 0) for s in stats + } + + latest_expenses_map = dict( + db.session.query( + Expense.list_id, func.coalesce(func.sum(Expense.amount), 0) + ) + .filter(Expense.list_id.in_(all_ids)) + .group_by(Expense.list_id) + .all() + ) + + enriched_lists = [] + for l in all_lists: + total_count, purchased_count = stats_map.get(l.id, (0, 0)) + percent = (purchased_count / total_count * 100) if total_count > 0 else 0 + comments_count = sum(1 for i in l.items if i.note and i.note.strip() != "") + receipts_count = len(l.receipts) + total_expense = latest_expenses_map.get(l.id, 0) + + if l.is_temporary and l.expires_at: + expires_at = l.expires_at + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + is_expired = expires_at < now + else: + is_expired = False + + enriched_lists.append( + { + "list": l, + "total_count": total_count, + "purchased_count": purchased_count, + "percent": round(percent), + "comments_count": comments_count, + "receipts_count": receipts_count, + "total_expense": total_expense, + "expired": is_expired, + "categories": l.categories, + } + ) + + purchased_items_count = Item.query.filter_by(purchased=True).count() + not_purchased_count = Item.query.filter_by(not_purchased=True).count() + items_with_notes = Item.query.filter(Item.note.isnot(None), Item.note != "").count() + + total_expense = db.session.query(func.sum(Expense.amount)).scalar() or 0 + avg_list_expense = round(total_expense / list_count, 2) if list_count else 0 + + if db.engine.name == "sqlite": + timestamp_diff = func.strftime("%s", Item.purchased_at) - func.strftime( + "%s", Item.added_at + ) + elif db.engine.name in ("postgresql", "postgres"): + timestamp_diff = func.extract("epoch", Item.purchased_at) - func.extract( + "epoch", Item.added_at + ) + elif db.engine.name in ("mysql", "mariadb"): + timestamp_diff = func.timestampdiff( + text("SECOND"), Item.added_at, Item.purchased_at + ) + else: + timestamp_diff = None + + time_to_purchase = ( + db.session.query(func.avg(timestamp_diff)) + .filter( + Item.purchased == True, + Item.purchased_at.isnot(None), + Item.added_at.isnot(None), + ) + .scalar() + if timestamp_diff is not None + else None + ) + + avg_hours_to_purchase = round(time_to_purchase / 3600, 2) if time_to_purchase else 0 + + first_list = db.session.query(func.min(ShoppingList.created_at)).scalar() + last_list = db.session.query(func.max(ShoppingList.created_at)).scalar() + now_dt = datetime.now(timezone.utc) + + if first_list and first_list.tzinfo is None: + first_list = first_list.replace(tzinfo=timezone.utc) + + if last_list and last_list.tzinfo is None: + last_list = last_list.replace(tzinfo=timezone.utc) + + if first_list and last_list: + days_span = max((now_dt - first_list).days, 1) + avg_per_day = list_count / days_span + avg_per_week = round(avg_per_day * 7, 2) + avg_per_month = round(avg_per_day * 30.44, 2) + avg_per_year = round(avg_per_day * 365, 2) + else: + avg_per_week = avg_per_month = avg_per_year = 0 + + top_products = ( + db.session.query(Item.name, func.count(Item.id).label("count")) + .filter(Item.purchased.is_(True)) + .group_by(Item.name) + .order_by(func.count(Item.id).desc()) + .limit(7) + .all() + ) + + expense_summary = get_admin_expense_summary() + process = psutil.Process(os.getpid()) + app_mem = process.memory_info().rss // (1024 * 1024) + + db_engine = db.engine + db_info = { + "engine": db_engine.name, + "version": getattr(db_engine.dialect, "server_version_info", None), + "url": str(db_engine.url).split("?")[0], + } + + inspector = inspect(db_engine) + table_count = len(inspector.get_table_names()) + record_total = get_total_records() + uptime_minutes = int( + (datetime.now(timezone.utc) - app_start_time).total_seconds() // 60 + ) + + month_options = get_active_months_query() + + return render_template( + "admin/admin_panel.html", + user_count=user_count, + list_count=list_count, + item_count=item_count, + purchased_items_count=purchased_items_count, + not_purchased_count=not_purchased_count, + items_with_notes=items_with_notes, + avg_hours_to_purchase=avg_hours_to_purchase, + avg_list_expense=avg_list_expense, + avg_per_week=avg_per_week, + avg_per_month=avg_per_month, + avg_per_year=avg_per_year, + enriched_lists=enriched_lists, + top_products=top_products, + expense_summary=expense_summary, + now=now, + python_version=sys.version, + system_info=platform.platform(), + app_memory=f"{app_mem} MB", + db_info=db_info, + table_count=table_count, + record_total=record_total, + uptime_minutes=uptime_minutes, + timedelta=timedelta, + show_all=show_all, + month_str=month_str, + month_options=month_options, + ) + + +@app.route("/admin/add_user", methods=["POST"]) +@login_required +@admin_required +def add_user(): + username = request.form["username"].lower() + password = request.form["password"] + + if not username or not password: + flash("Wypełnij wszystkie pola", "danger") + return redirect(url_for("list_users")) + + if len(password) < 6: + flash("Hasło musi mieć co najmniej 6 znaków", "danger") + return redirect(url_for("list_users")) + + if User.query.filter(func.lower(User.username) == username).first(): + flash("Użytkownik o takiej nazwie już istnieje", "warning") + return redirect(url_for("list_users")) + + hashed_password = hash_password(password) + new_user = User(username=username, password_hash=hashed_password) + db.session.add(new_user) + db.session.commit() + flash("Dodano nowego użytkownika", "success") + return redirect(url_for("list_users")) + + +@app.route("/admin/users") +@login_required +@admin_required +def list_users(): + users = User.query.order_by(User.id.asc()).all() + + user_data = [] + for user in users: + list_count = ShoppingList.query.filter_by(owner_id=user.id).count() + item_count = Item.query.filter_by(added_by=user.id).count() + receipt_count = Receipt.query.filter_by(uploaded_by=user.id).count() + + user_data.append( + { + "user": user, + "list_count": list_count, + "item_count": item_count, + "receipt_count": receipt_count, + } + ) + + total_users = len(users) + + return render_template( + "admin/user_management.html", + user_data=user_data, + total_users=total_users, + ) + + +@app.route("/admin/change_password/", methods=["POST"]) +@login_required +@admin_required +def reset_password(user_id): + user = User.query.get_or_404(user_id) + new_password = request.form["password"] + + if not new_password: + flash("Podaj nowe hasło", "danger") + return redirect(url_for("list_users")) + + user.password_hash = hash_password(new_password) + db.session.commit() + flash(f"Hasło dla użytkownika {user.username} zostało zaktualizowane", "success") + return redirect(url_for("list_users")) + + +@app.route("/admin/delete_user/") +@login_required +@admin_required +def delete_user(user_id): + user = User.query.get_or_404(user_id) + + if user.is_admin: + flash("Nie można usunąć konta administratora.", "warning") + return redirect(url_for("list_users")) + + admin_user = User.query.filter_by(is_admin=True).first() + if not admin_user: + flash("Brak konta administratora do przeniesienia zawartości.", "danger") + return redirect(url_for("list_users")) + + lists_owned = ShoppingList.query.filter_by(owner_id=user.id).count() + + if lists_owned > 0: + ShoppingList.query.filter_by(owner_id=user.id).update( + {"owner_id": admin_user.id} + ) + Receipt.query.filter_by(uploaded_by=user.id).update( + {"uploaded_by": admin_user.id} + ) + Item.query.filter_by(added_by=user.id).update({"added_by": admin_user.id}) + db.session.commit() + flash( + f"Użytkownik '{user.username}' został usunięty, a jego zawartość przeniesiona na administratora.", + "success", + ) + else: + flash( + f"Użytkownik '{user.username}' został usunięty. Nie posiadał żadnych list zakupowych.", + "info", + ) + + db.session.delete(user) + db.session.commit() + + return redirect(url_for("list_users")) + + +@app.route("/admin/receipts", methods=["GET"]) +@app.route("/admin/receipts/", methods=["GET"]) +@login_required +@admin_required +def admin_receipts(list_id=None): + try: + page, per_page = get_page_args(default_per_page=24, max_per_page=200) + + if list_id is not None: + all_receipts = ( + Receipt.query.options(joinedload(Receipt.uploaded_by_user)) + .filter_by(list_id=list_id) + .order_by(Receipt.uploaded_at.desc()) + .all() + ) + receipts_paginated, total_items, total_pages = paginate_items( + all_receipts, page, per_page + ) + orphan_files = [] + id = list_id + else: + all_filenames = { + r.filename for r in Receipt.query.with_entities(Receipt.filename).all() + } + + pagination = ( + Receipt.query.options(joinedload(Receipt.uploaded_by_user)) + .order_by(Receipt.uploaded_at.desc()) + .paginate(page=page, per_page=per_page, error_out=False) + ) + + receipts_paginated = pagination.items + total_pages = pagination.pages + id = "all" + + upload_folder = app.config["UPLOAD_FOLDER"] + files_on_disk = set(os.listdir(upload_folder)) + orphan_files = [ + f + for f in files_on_disk + if f.endswith(".webp") + and f not in all_filenames + and f.startswith("list_") + ] + + except ValueError: + flash("Nieprawidłowe ID listy.", "danger") + return redirect(url_for("admin_panel")) + + total_filesize = db.session.query(func.sum(Receipt.filesize)).scalar() or 0 + page_filesize = sum(r.filesize or 0 for r in receipts_paginated) + + query_string = urlencode({k: v for k, v in request.args.items() if k != "page"}) + + return render_template( + "admin/receipts.html", + receipts=receipts_paginated, + orphan_files=orphan_files, + orphan_files_count=len(orphan_files), + page=page, + per_page=per_page, + total_pages=total_pages, + id=id, + query_string=query_string, + total_filesize=total_filesize, + page_filesize=page_filesize, + ) + + +@app.route("/admin/rotate_receipt/") +@login_required +@admin_required +def rotate_receipt(receipt_id): + try: + rotate_receipt_by_id(receipt_id) + recalculate_filesizes(receipt_id) + flash("Obrócono paragon", "success") + except FileNotFoundError: + flash("Plik nie istnieje", "danger") + except Exception as e: + flash(f"Błąd przy obracaniu: {str(e)}", "danger") + + return redirect(request.referrer or url_for("admin_receipts", id="all")) + + +@app.route("/admin/delete_receipt/") +@app.route("/admin/delete_receipt/orphan/") +@login_required +@admin_required +def delete_receipt(receipt_id=None, filename=None): + if filename: # tryb orphan + safe_filename = os.path.basename(filename) + if Receipt.query.filter_by(filename=safe_filename).first(): + flash("Nie można usunąć pliku powiązanego z bazą!", "danger") + else: + file_path = os.path.join(app.config["UPLOAD_FOLDER"], safe_filename) + if os.path.exists(file_path): + try: + os.remove(file_path) + flash(f"Usunięto plik: {safe_filename}", "success") + except Exception as e: + flash(f"Błąd przy usuwaniu pliku: {e}", "danger") + else: + flash("Plik już nie istnieje.", "warning") + return redirect(url_for("admin_receipts", id="all")) + + try: + delete_receipt_by_id(receipt_id) + flash("Paragon usunięty", "success") + except Exception as e: + flash(f"Błąd przy usuwaniu pliku: {str(e)}", "danger") + + return redirect(request.referrer or url_for("admin_receipts", id="all")) + + +@app.route("/admin/rename_receipt/") +@login_required +@admin_required +def rename_receipt(receipt_id): + receipt = Receipt.query.get_or_404(receipt_id) + old_path = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename) + + if not os.path.exists(old_path): + flash("Plik nie istnieje", "danger") + return redirect(request.referrer) + + new_filename = generate_new_receipt_filename(receipt.list_id) + new_path = os.path.join(app.config["UPLOAD_FOLDER"], new_filename) + + try: + os.rename(old_path, new_path) + receipt.filename = new_filename + db.session.flush() + recalculate_filesizes(receipt.id) + db.session.commit() + flash("Zmieniono nazwę pliku", "success") + except Exception as e: + flash(f"Błąd przy zmianie nazwy: {str(e)}", "danger") + + return redirect(request.referrer or url_for("admin_receipts", id="all")) + + +@app.route("/admin/generate_receipt_hash/") +@login_required +@admin_required +def generate_receipt_hash(receipt_id): + receipt = Receipt.query.get_or_404(receipt_id) + if receipt.file_hash: + flash("Hash już istnieje", "info") + return redirect(request.referrer) + + file_path = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename) + if not os.path.exists(file_path): + flash("Plik nie istnieje", "danger") + return redirect(request.referrer) + + try: + with open(file_path, "rb") as f: + file_hash = hashlib.sha256(f.read()).hexdigest() + receipt.file_hash = file_hash + db.session.commit() + flash("Hash wygenerowany", "success") + except Exception as e: + flash(f"Błąd przy generowaniu hasha: {e}", "danger") + + return redirect(request.referrer) + + +@app.route("/admin/delete_list", methods=["POST"]) +@login_required +@admin_required +def admin_delete_list(): + ids = request.form.getlist("list_ids") + single_id = request.form.get("single_list_id") + if single_id: + ids.append(single_id) + + for list_id in ids: + lst = db.session.get(ShoppingList, int(list_id)) + if lst: + delete_receipts_for_list(lst.id) + Receipt.query.filter_by(list_id=lst.id).delete() + Item.query.filter_by(list_id=lst.id).delete() + Expense.query.filter_by(list_id=lst.id).delete() + db.session.delete(lst) + + db.session.commit() + flash(f"Usunięto {len(ids)} list(e/y)", "success") + return redirect(request.referrer or url_for("admin_panel")) + + +@app.route("/admin/edit_list/", methods=["GET", "POST"]) +@login_required +@admin_required +def edit_list(list_id): + shopping_list = db.session.get( + ShoppingList, + list_id, + options=[ + joinedload(ShoppingList.expenses), + joinedload(ShoppingList.receipts), + joinedload(ShoppingList.owner), + joinedload(ShoppingList.items), + joinedload(ShoppingList.categories), + ], + ) + permitted_users = ( + db.session.query(User) + .join(ListPermission, ListPermission.user_id == User.id) + .filter(ListPermission.list_id == shopping_list.id) + .order_by(User.username.asc()) + .all() + ) + + if shopping_list is None: + abort(404) + + total_expense = get_total_expense_for_list(shopping_list.id) + categories = Category.query.order_by(Category.name.asc()).all() + selected_categories_ids = {c.id for c in shopping_list.categories} + + if request.method == "POST": + action = request.form.get("action") + + if action == "save": + new_title = request.form.get("title", "").strip() + new_amount_str = request.form.get("amount") + is_archived = "archived" in request.form + is_public = "public" in request.form + is_temporary = "temporary" in request.form + new_owner_id = request.form.get("owner_id") + expires_date = request.form.get("expires_date") + expires_time = request.form.get("expires_time") + + if new_title: + shopping_list.title = new_title + + shopping_list.is_archived = is_archived + shopping_list.is_public = is_public + shopping_list.is_temporary = is_temporary + + if expires_date and expires_time: + try: + combined = f"{expires_date} {expires_time}" + dt = datetime.strptime(combined, "%Y-%m-%d %H:%M") + shopping_list.expires_at = dt.replace(tzinfo=timezone.utc) + except ValueError: + flash("Niepoprawna data lub godzina wygasania", "danger") + return redirect(url_for("edit_list", list_id=list_id)) + else: + shopping_list.expires_at = None + + if new_owner_id: + try: + new_owner_id_int = int(new_owner_id) + user_obj = db.session.get(User, new_owner_id_int) + if user_obj: + shopping_list.owner_id = new_owner_id_int + Item.query.filter_by(list_id=list_id).update( + {"added_by": new_owner_id_int} + ) + Receipt.query.filter_by(list_id=list_id).update( + {"uploaded_by": new_owner_id_int} + ) + else: + flash("Wybrany użytkownik nie istnieje", "danger") + return redirect(url_for("edit_list", list_id=list_id)) + except ValueError: + flash("Niepoprawny ID użytkownika", "danger") + return redirect(url_for("edit_list", list_id=list_id)) + + if new_amount_str: + try: + new_amount = float(new_amount_str) + for expense in shopping_list.expenses: + db.session.delete(expense) + db.session.commit() + db.session.add(Expense(list_id=list_id, amount=new_amount)) + except ValueError: + flash("Niepoprawna kwota", "danger") + return redirect(url_for("edit_list", list_id=list_id)) + + created_month = request.form.get("created_month") + if created_month: + try: + year, month = map(int, created_month.split("-")) + shopping_list.created_at = datetime( + year, month, 1, tzinfo=timezone.utc + ) + except ValueError: + flash("Nieprawidłowy format miesiąca", "danger") + return redirect(url_for("edit_list", list_id=list_id)) + + update_list_categories_from_form(shopping_list, request.form) + db.session.commit() + flash("Zapisano zmiany listy", "success") + return redirect(url_for("edit_list", list_id=list_id)) + + elif action == "add_item": + item_name = request.form.get("item_name", "").strip() + quantity_str = request.form.get("quantity", "1") + + if not item_name: + flash("Podaj nazwę produktu", "danger") + return redirect(url_for("edit_list", list_id=list_id)) + + try: + quantity = max(1, int(quantity_str)) + except ValueError: + quantity = 1 + + db.session.add( + Item( + list_id=list_id, + name=item_name, + quantity=quantity, + added_by=current_user.id, + ) + ) + + exists = ( + db.session.query(SuggestedProduct) + .filter(func.lower(SuggestedProduct.name) == item_name.lower()) + .first() + ) + if not exists: + db.session.add(SuggestedProduct(name=item_name)) + + db.session.commit() + flash("Dodano produkt", "success") + return redirect(url_for("edit_list", list_id=list_id)) + + elif action == "delete_item": + item = get_valid_item_or_404(request.form.get("item_id"), list_id) + db.session.delete(item) + db.session.commit() + flash("Usunięto produkt", "success") + return redirect(url_for("edit_list", list_id=list_id)) + + elif action == "toggle_purchased": + item = get_valid_item_or_404(request.form.get("item_id"), list_id) + item.purchased = not item.purchased + db.session.commit() + flash("Zmieniono status oznaczenia produktu", "success") + return redirect(url_for("edit_list", list_id=list_id)) + + elif action == "mark_not_purchased": + item = get_valid_item_or_404(request.form.get("item_id"), list_id) + item.not_purchased = True + item.purchased = False + item.purchased_at = None + db.session.commit() + flash("Oznaczono produkt jako niekupione", "success") + return redirect(url_for("edit_list", list_id=list_id)) + + elif action == "unmark_not_purchased": + item = get_valid_item_or_404(request.form.get("item_id"), list_id) + item.not_purchased = False + item.not_purchased_reason = None + item.purchased = False + item.purchased_at = None + db.session.commit() + flash("Przywrócono produkt do listy", "success") + return redirect(url_for("edit_list", list_id=list_id)) + + elif action == "edit_quantity": + item = get_valid_item_or_404(request.form.get("item_id"), list_id) + try: + new_quantity = int(request.form.get("quantity")) + if new_quantity > 0: + item.quantity = new_quantity + db.session.commit() + flash("Zmieniono ilość produktu", "success") + except ValueError: + flash("Nieprawidłowa ilość", "danger") + return redirect(url_for("edit_list", list_id=list_id)) + + users = User.query.all() + items = shopping_list.items + receipts = shopping_list.receipts + + return render_template( + "admin/edit_list.html", + list=shopping_list, + total_expense=total_expense, + users=users, + items=items, + receipts=receipts, + categories=categories, + selected_categories=selected_categories_ids, + permitted_users=permitted_users, + ) + + +@app.route("/admin/products") +@login_required +@admin_required +def list_products(): + page, per_page = get_page_args() + + all_items = ( + Item.query.options(joinedload(Item.added_by_user)) + .order_by(Item.id.desc()) + .all() + ) + + seen_names = set() + unique_items = [] + for item in all_items: + key = normalize_name(item.name) + if key not in seen_names: + unique_items.append(item) + seen_names.add(key) + + usage_results = ( + db.session.query( + func.lower(Item.name).label("name"), + func.count(func.distinct(Item.list_id)).label("usage_count"), + ) + .group_by(func.lower(Item.name)) + .all() + ) + usage_counts = {row.name: row.usage_count for row in usage_results} + + items, total_items, total_pages = paginate_items(unique_items, page, per_page) + + user_ids = {item.added_by for item in items if item.added_by} + users = User.query.filter(User.id.in_(user_ids)).all() if user_ids else [] + users_dict = {u.id: u.username for u in users} + + suggestions = SuggestedProduct.query.all() + all_suggestions_dict = { + normalize_name(s.name): s for s in suggestions if s.name and s.name.strip() + } + + used_suggestion_names = {normalize_name(i.name) for i in unique_items} + + suggestions_dict = { + name: all_suggestions_dict[name] + for name in used_suggestion_names + if name in all_suggestions_dict + } + + orphan_suggestions = [ + s + for name, s in all_suggestions_dict.items() + if name not in used_suggestion_names + ] + + query_string = urlencode({k: v for k, v in request.args.items() if k != "page"}) + synced_names = set(suggestions_dict.keys()) + + return render_template( + "admin/list_products.html", + items=items, + users_dict=users_dict, + suggestions_dict=suggestions_dict, + orphan_suggestions=orphan_suggestions, + page=page, + per_page=per_page, + total_pages=total_pages, + query_string=query_string, + total_items=total_items, + usage_counts=usage_counts, + synced_names=synced_names, + ) + + +@app.route("/admin/sync_suggestion/", methods=["POST"]) +@login_required +def sync_suggestion_ajax(item_id): + if not current_user.is_admin: + return jsonify({"success": False, "message": "Brak uprawnień"}), 403 + + item = Item.query.get_or_404(item_id) + + existing = SuggestedProduct.query.filter( + func.lower(SuggestedProduct.name) == item.name.lower() + ).first() + if not existing: + new_suggestion = SuggestedProduct(name=item.name) + db.session.add(new_suggestion) + db.session.commit() + return jsonify( + { + "success": True, + "message": f"Utworzono sugestię dla produktu: {item.name}", + } + ) + else: + return jsonify( + { + "success": True, + "message": f"Sugestia dla produktu „{item.name}” już istnieje.", + } + ) + + +@app.route("/admin/delete_suggestion/", methods=["POST"]) +@login_required +def delete_suggestion_ajax(suggestion_id): + if not current_user.is_admin: + return jsonify({"success": False, "message": "Brak uprawnień"}), 403 + + suggestion = SuggestedProduct.query.get_or_404(suggestion_id) + db.session.delete(suggestion) + db.session.commit() + + return jsonify({"success": True, "message": "Sugestia została usunięta."}) + + +@app.route("/admin/promote_user/") +@login_required +@admin_required +def promote_user(user_id): + user = User.query.get_or_404(user_id) + user.is_admin = True + db.session.commit() + flash(f"Użytkownik {user.username} został ustawiony jako admin.", "success") + return redirect(url_for("list_users")) + + +@app.route("/admin/demote_user/") +@login_required +@admin_required +def demote_user(user_id): + user = User.query.get_or_404(user_id) + + if user.id == current_user.id: + flash("Nie możesz zdegradować samego siebie!", "danger") + return redirect(url_for("list_users")) + + admin_count = User.query.filter_by(is_admin=True).count() + if admin_count <= 1 and user.is_admin: + flash( + "Nie można zdegradować. Musi pozostać co najmniej jeden administrator.", + "danger", + ) + return redirect(url_for("list_users")) + + user.is_admin = False + db.session.commit() + flash(f"Użytkownik {user.username} został zdegradowany.", "success") + return redirect(url_for("list_users")) + + +@app.route("/admin/crop_receipt", methods=["POST"]) +@login_required +@admin_required +def crop_receipt_admin(): + receipt_id = request.form.get("receipt_id") + file = request.files.get("cropped_image") + result = handle_crop_receipt(receipt_id, file) + return jsonify(result) + + +@app.route("/admin/recalculate_filesizes") +@login_required +@admin_required +def recalculate_filesizes_all(): + updated, unchanged, not_found = recalculate_filesizes() + flash( + f"Zaktualizowano: {updated}, bez zmian: {unchanged}, brak pliku: {not_found}", + "success", + ) + return redirect(url_for("admin_receipts", id="all")) + + +@app.route("/admin/edit_categories", methods=["GET", "POST"]) +@login_required +@admin_required +def admin_edit_categories(): + page, per_page = get_page_args(default_per_page=50, max_per_page=200) + + lists_query = ShoppingList.query.options( + joinedload(ShoppingList.categories), + joinedload(ShoppingList.items), + joinedload(ShoppingList.owner), + ).order_by(ShoppingList.created_at.desc()) + + pagination = lists_query.paginate(page=page, per_page=per_page, error_out=False) + lists = pagination.items + + categories = Category.query.order_by(Category.name.asc()).all() + + for l in lists: + l.total_count = len(l.items) + l.owner_name = l.owner.username if l.owner else "?" + l.category_count = len(l.categories) + + if request.method == "POST": + for l in lists: + selected_ids = request.form.getlist(f"categories_{l.id}") + l.categories.clear() + if selected_ids: + cats = Category.query.filter(Category.id.in_(selected_ids)).all() + l.categories.extend(cats) + db.session.commit() + flash("Zaktualizowano kategorie dla wybranych list", "success") + return redirect(url_for("admin_edit_categories", page=page, per_page=per_page)) + + query_string = urlencode({k: v for k, v in request.args.items() if k != "page"}) + + return render_template( + "admin/edit_categories.html", + lists=lists, + categories=categories, + page=page, + per_page=per_page, + total_pages=pagination.pages, + total_items=pagination.total, + query_string=query_string, + ) + + +@app.route("/admin/edit_categories//save", methods=["POST"]) +@login_required +@admin_required +def admin_edit_categories_save(list_id): + l = db.session.get(ShoppingList, list_id) + if not l: + return jsonify(ok=False, error="not_found"), 404 + + data = request.get_json(silent=True) or {} + ids = data.get("category_ids", []) + + try: + ids = [int(x) for x in ids] + except (TypeError, ValueError): + return jsonify(ok=False, error="bad_ids"), 400 + + l.categories.clear() + if ids: + cats = Category.query.filter(Category.id.in_(ids)).all() + l.categories.extend(cats) + + db.session.commit() + return jsonify(ok=True, count=len(l.categories)), 200 + + +@app.route("/admin/list_items/") +@login_required +@admin_required +def admin_list_items_json(list_id): + l = db.session.get(ShoppingList, list_id) + if not l: + return jsonify({"error": "Lista nie istnieje"}), 404 + + items = [ + { + "name": item.name, + "quantity": item.quantity, + "purchased": item.purchased, + "not_purchased": item.not_purchased, + } + for item in l.items + ] + + purchased_count = sum(1 for item in l.items if item.purchased) + total_expense = sum(exp.amount for exp in l.expenses) + + return jsonify( + { + "title": l.title, + "items": items, + "total_count": len(l.items), + "purchased_count": purchased_count, + "total_expense": round(total_expense, 2), + } + ) + + +@app.route("/admin/add_suggestion", methods=["POST"]) +@login_required +@admin_required +def add_suggestion(): + name = request.form.get("suggestion_name", "").strip() + + if not name: + flash("Nazwa nie może być pusta", "warning") + return redirect(url_for("list_products")) + + existing = db.session.query(SuggestedProduct).filter_by(name=name).first() + if existing: + flash("Sugestia już istnieje", "warning") + else: + new_suggestion = SuggestedProduct(name=name) + db.session.add(new_suggestion) + db.session.commit() + flash("Dodano sugestię", "success") + + return redirect(url_for("list_products")) + + +@app.route("/admin/lists-access", methods=["GET", "POST"]) +@app.route("/admin/lists-access/", methods=["GET", "POST"]) +@login_required +@admin_required +def admin_lists_access(list_id=None): + try: + page = int(request.args.get("page", 1)) + except ValueError: + page = 1 + try: + per_page = int(request.args.get("per_page", 25)) + except ValueError: + per_page = 25 + per_page = max(1, min(100, per_page)) + + q = ShoppingList.query.options(db.joinedload(ShoppingList.owner)).order_by( + ShoppingList.created_at.desc() + ) + + if list_id is not None: + target_list = db.session.get(ShoppingList, list_id) + if not target_list: + flash("Lista nie istnieje.", "danger") + return redirect(url_for("admin_lists_access")) + lists = [target_list] + list_ids = [list_id] + pagination = None + else: + pagination = q.paginate(page=page, per_page=per_page, error_out=False) + lists = pagination.items + list_ids = [l.id for l in lists] + + if request.method == "POST": + action = request.form.get("action") + target_list_id = request.form.get("target_list_id", type=int) + + if action == "grant" and target_list_id: + login = (request.form.get("grant_username") or "").strip().lower() + l = db.session.get(ShoppingList, target_list_id) + if not l: + flash("Lista nie istnieje.", "danger") + return redirect(request.url) + u = User.query.filter(func.lower(User.username) == login).first() + if not u: + flash("Użytkownik nie istnieje.", "danger") + return redirect(request.url) + if u.id == l.owner_id: + flash("Nie można nadawać uprawnień właścicielowi listy.", "danger") + return redirect(request.url) + + exists = ( + db.session.query(ListPermission.id) + .filter(ListPermission.list_id == l.id, ListPermission.user_id == u.id) + .first() + ) + if not exists: + db.session.add(ListPermission(list_id=l.id, user_id=u.id)) + db.session.commit() + flash(f"Nadano dostęp „{u.username}” do listy #{l.id}.", "success") + else: + flash("Ten użytkownik już ma dostęp.", "info") + return redirect(request.url) + + if action == "revoke" and target_list_id: + uid = request.form.get("revoke_user_id", type=int) + if uid: + ListPermission.query.filter_by( + list_id=target_list_id, user_id=uid + ).delete() + db.session.commit() + flash("Odebrano dostęp użytkownikowi.", "success") + return redirect(request.url) + + if action == "save_changes": + ids = request.form.getlist("visible_ids", type=int) + if ids: + lists_edit = ShoppingList.query.filter(ShoppingList.id.in_(ids)).all() + posted = request.form + for l in lists_edit: + l.is_public = posted.get(f"is_public_{l.id}") is not None + l.is_temporary = posted.get(f"is_temporary_{l.id}") is not None + l.is_archived = posted.get(f"is_archived_{l.id}") is not None + db.session.commit() + flash("Zapisano zmiany statusów.", "success") + return redirect(request.url) + + perms = ( + db.session.query( + ListPermission.list_id, + User.id.label("uid"), + User.username.label("uname"), + ) + .join(User, User.id == ListPermission.user_id) + .filter(ListPermission.list_id.in_(list_ids)) + .order_by(User.username.asc()) + .all() + ) + + permitted_by_list = {lid: [] for lid in list_ids} + for lid, uid, uname in perms: + permitted_by_list[lid].append({"id": uid, "username": uname}) + + query_string = f"per_page={per_page}" + + return render_template( + "admin/lists_access.html", + lists=lists, + permitted_by_list=permitted_by_list, + page=page, + per_page=per_page, + total_pages=pagination.pages if pagination else 1, + query_string=query_string, + list_id=list_id, + ) + + +@app.route('/healthcheck') +def healthcheck(): + header_token = request.headers.get('X-Internal-Check') + correct_token = app.config.get('HEALTHCHECK_TOKEN') + if header_token != correct_token: + abort(404) + + try: + db.session.execute(text('SELECT 1')) + db.session.commit() + response_data = {"status": "ok"} + except Exception as e: + response_data = { + "status": "waiting", + "message": "waiting for db", + "error": str(e) + } + + return response_data, 200 + + +@app.route("/admin/settings", methods=["GET", "POST"]) +@login_required +@admin_required +def admin_settings(): + categories = Category.query.order_by(Category.name.asc()).all() + + if request.method == "POST": + ocr_raw = (request.form.get("ocr_keywords") or "").strip() + set_setting("ocr_keywords", ocr_raw) + + ocr_sens = (request.form.get("ocr_sensitivity") or "").strip() + set_setting("ocr_sensitivity", ocr_sens) + + max_attempts = (request.form.get("max_login_attempts") or "").strip() + set_setting("max_login_attempts", max_attempts) + + login_window = (request.form.get("login_window_seconds") or "").strip() + if login_window: + set_setting("login_window_seconds", login_window) + + for c in categories: + field = f"color_{c.id}" + vals = request.form.getlist(field) + val = (vals[-1] if vals else "").strip() + + existing = CategoryColorOverride.query.filter_by(category_id=c.id).first() + if val and re.fullmatch(r"^#[0-9A-Fa-f]{6}$", val): + if not existing: + db.session.add(CategoryColorOverride(category_id=c.id, color_hex=val)) + else: + existing.color_hex = val + else: + if existing: + db.session.delete(existing) + + db.session.commit() + flash("Zapisano ustawienia.", "success") + return redirect(url_for("admin_settings")) + + override_rows = CategoryColorOverride.query.filter( + CategoryColorOverride.category_id.in_([c.id for c in categories]) + ).all() + overrides = {row.category_id: row.color_hex for row in override_rows} + auto_colors = {c.id: category_to_color(c.name) for c in categories} + effective_colors = { + c.id: (overrides.get(c.id) or auto_colors[c.id]) for c in categories + } + + current_ocr = get_setting("ocr_keywords", "") + + ocr_sensitivity = get_int_setting("ocr_sensitivity", 5) + max_login_attempts = get_int_setting("max_login_attempts", 10) + login_window_seconds = get_int_setting("login_window_seconds", 3600) + + return render_template( + "admin/settings.html", + categories=categories, + overrides=overrides, + auto_colors=auto_colors, + effective_colors=effective_colors, + current_ocr=current_ocr, + ocr_sensitivity=ocr_sensitivity, + max_login_attempts=max_login_attempts, + login_window_seconds=login_window_seconds, + ) + + +@app.route("/robots.txt") +def robots_txt(): + content = ( + "User-agent: *\nDisallow: /" + if app.config.get("DISABLE_ROBOTS") + else "User-agent: *\nAllow: /" + ) + return content, 200, {"Content-Type": "text/plain"} diff --git a/shopping_app/routes_main.py b/shopping_app/routes_main.py new file mode 100644 index 0000000..8b4fda4 --- /dev/null +++ b/shopping_app/routes_main.py @@ -0,0 +1,747 @@ +from .deps import * +from .app_setup import * +from .models import * +from .helpers import * + +@app.route("/") +def main_page(): + perm_subq = ( + user_permission_subq(current_user.id) if current_user.is_authenticated else None + ) + + now = datetime.now(timezone.utc) + + month_param = request.args.get("m", None) + start = end = None + + if month_param in (None, ""): + # domyślnie: bieżący miesiąc + month_str = now.strftime("%Y-%m") + start = datetime(now.year, now.month, 1, tzinfo=timezone.utc) + end = (start + timedelta(days=31)).replace(day=1) + elif month_param == "all": + month_str = "all" + start = end = None + else: + month_str = month_param + try: + year, month = map(int, month_str.split("-")) + start = datetime(year, month, 1, tzinfo=timezone.utc) + end = (start + timedelta(days=31)).replace(day=1) + except ValueError: + # jeśli m ma zły format – pokaż wszystko + month_str = "all" + start = end = None + + def date_filter(query): + if start and end: + query = query.filter( + ShoppingList.created_at >= start, ShoppingList.created_at < end + ) + return query + + if current_user.is_authenticated: + user_lists = ( + date_filter( + ShoppingList.query.filter( + ShoppingList.owner_id == current_user.id, + ShoppingList.is_archived == False, + (ShoppingList.expires_at == None) | (ShoppingList.expires_at > now), + ) + ) + .order_by(ShoppingList.created_at.desc()) + .all() + ) + + archived_lists = ( + ShoppingList.query.filter_by(owner_id=current_user.id, is_archived=True) + .order_by(ShoppingList.created_at.desc()) + .all() + ) + + # publiczne cudze + udzielone mi (po list_permission) + public_lists = ( + date_filter( + ShoppingList.query.filter( + ShoppingList.owner_id != current_user.id, + ShoppingList.is_archived == False, + (ShoppingList.expires_at == None) | (ShoppingList.expires_at > now), + or_( + ShoppingList.is_public == True, + ShoppingList.id.in_(perm_subq), + ), + ) + ) + .order_by(ShoppingList.created_at.desc()) + .all() + ) + accessible_lists = public_lists # alias do szablonu: publiczne + udostępnione + else: + user_lists = [] + archived_lists = [] + public_lists = ( + date_filter( + ShoppingList.query.filter( + ShoppingList.is_public == True, + (ShoppingList.expires_at == None) | (ShoppingList.expires_at > now), + ShoppingList.is_archived == False, + ) + ) + .order_by(ShoppingList.created_at.desc()) + .all() + ) + accessible_lists = public_lists # dla gościa = tylko publiczne + + # Zakres miesięcy do selektora + if current_user.is_authenticated: + visible_lists_query = ShoppingList.query.filter( + or_( + ShoppingList.owner_id == current_user.id, + ShoppingList.is_public == True, + ShoppingList.id.in_(perm_subq), + ) + ) + else: + visible_lists_query = ShoppingList.query.filter(ShoppingList.is_public == True) + + month_options = get_active_months_query(visible_lists_query) + + # Statystyki dla wszystkich widocznych sekcji + all_lists = user_lists + accessible_lists + archived_lists + all_ids = [l.id for l in all_lists] + + if all_ids: + stats = ( + db.session.query( + Item.list_id, + func.count(Item.id).label("total_count"), + func.sum(case((Item.purchased == True, 1), else_=0)).label( + "purchased_count" + ), + func.sum(case((Item.not_purchased == True, 1), else_=0)).label( + "not_purchased_count" + ), + ) + .filter(Item.list_id.in_(all_ids)) + .group_by(Item.list_id) + .all() + ) + stats_map = { + s.list_id: ( + s.total_count or 0, + s.purchased_count or 0, + s.not_purchased_count or 0, + ) + for s in stats + } + + latest_expenses_map = dict( + db.session.query( + Expense.list_id, func.coalesce(func.sum(Expense.amount), 0) + ) + .filter(Expense.list_id.in_(all_ids)) + .group_by(Expense.list_id) + .all() + ) + + for l in all_lists: + total_count, purchased_count, not_purchased_count = stats_map.get( + l.id, (0, 0, 0) + ) + l.total_count = total_count + l.purchased_count = purchased_count + l.not_purchased_count = not_purchased_count + l.total_expense = latest_expenses_map.get(l.id, 0) + l.category_badges = [ + {"name": c.name, "color": category_color_for(c)} for c in l.categories + ] + else: + for l in all_lists: + l.total_count = 0 + l.purchased_count = 0 + l.not_purchased_count = 0 + l.total_expense = 0 + l.category_badges = [] + + return render_template( + "main.html", + user_lists=user_lists, + public_lists=public_lists, + accessible_lists=accessible_lists, + archived_lists=archived_lists, + now=now, + timedelta=timedelta, + month_options=month_options, + selected_month=month_str, + ) + + +@app.route("/system-auth", methods=["GET", "POST"]) +def system_auth(): + if ( + current_user.is_authenticated + or request.cookies.get("authorized") == AUTHORIZED_COOKIE_VALUE + ): + flash("Jesteś już zalogowany lub autoryzowany.", "info") + return redirect(url_for("main_page")) + + ip = request.access_route[0] + next_page = request.args.get("next") or url_for("main_page") + + if is_ip_blocked(ip): + flash( + "Przekroczono limit prób logowania. Dostęp zablokowany na 1 godzinę.", + "danger", + ) + return render_template("system_auth.html"), 403 + + if request.method == "POST": + if request.form["password"] == SYSTEM_PASSWORD: + reset_failed_attempts(ip) + resp = redirect(next_page) + return set_authorized_cookie(resp) + else: + register_failed_attempt(ip) + if is_ip_blocked(ip): + flash( + "Przekroczono limit prób logowania. Dostęp zablokowany na 1 godzinę.", + "danger", + ) + return render_template("system_auth.html"), 403 + remaining = attempts_remaining(ip) + flash(f"Nieprawidłowe hasło. Pozostało {remaining} prób.", "warning") + + return render_template("system_auth.html") + + +@app.route("/edit_my_list/", methods=["GET", "POST"]) +@login_required +def edit_my_list(list_id): + # --- Pobranie listy i weryfikacja właściciela --- + l = db.session.get(ShoppingList, list_id) + if l is None: + abort(404) + if l.owner_id != current_user.id: + abort(403, description="Nie jesteś właścicielem tej listy.") + + # Dane do widoku + receipts = ( + Receipt.query.filter_by(list_id=list_id) + .order_by(Receipt.uploaded_at.desc()) + .all() + ) + categories = Category.query.order_by(Category.name.asc()).all() + selected_categories_ids = {c.id for c in l.categories} + + next_page = request.args.get("next") or request.referrer + wants_json = ( + "application/json" in (request.headers.get("Accept") or "") + or request.headers.get("X-Requested-With") == "fetch" + ) + + if request.method == "POST": + action = request.form.get("action") + + # --- Nadanie dostępu (grant) --- + if action == "grant": + grant_username = (request.form.get("grant_username") or "").strip().lower() + if not grant_username: + if wants_json: + return jsonify(ok=False, error="empty"), 400 + flash("Podaj nazwę użytkownika do nadania dostępu.", "danger") + return redirect(next_page or request.url) + + u = User.query.filter(func.lower(User.username) == grant_username).first() + if not u: + if wants_json: + return jsonify(ok=False, error="not_found"), 404 + flash("Użytkownik nie istnieje.", "danger") + return redirect(next_page or request.url) + if u.id == current_user.id: + if wants_json: + return jsonify(ok=False, error="owner"), 409 + flash("Jesteś właścicielem tej listy.", "info") + return redirect(next_page or request.url) + + exists = ( + db.session.query(ListPermission.id) + .filter( + ListPermission.list_id == l.id, + ListPermission.user_id == u.id, + ) + .first() + ) + if not exists: + db.session.add(ListPermission(list_id=l.id, user_id=u.id)) + db.session.commit() + if wants_json: + return jsonify(ok=True, user={"id": u.id, "username": u.username}) + flash(f"Nadano dostęp użytkownikowi „{u.username}”.", "success") + else: + if wants_json: + return jsonify(ok=False, error="exists"), 409 + flash("Ten użytkownik już ma dostęp.", "info") + return redirect(next_page or request.url) + + # --- Odebranie dostępu (revoke) --- + revoke_user_id = request.form.get("revoke_user_id") + if revoke_user_id: + try: + uid = int(revoke_user_id) + except ValueError: + if wants_json: + return jsonify(ok=False, error="bad_id"), 400 + flash("Błędny identyfikator użytkownika.", "danger") + return redirect(next_page or request.url) + + ListPermission.query.filter_by(list_id=l.id, user_id=uid).delete() + db.session.commit() + if wants_json: + return jsonify(ok=True, removed_user_id=uid) + flash("Odebrano dostęp użytkownikowi.", "success") + return redirect(next_page or request.url) + + # --- Przywracanie z archiwum --- + if "unarchive" in request.form: + l.is_archived = False + db.session.commit() + if wants_json: + return jsonify(ok=True, unarchived=True) + flash(f"Lista „{l.title}” została przywrócona.", "success") + return redirect(next_page or request.url) + + # --- Główny zapis pól formularza --- + move_to_month = request.form.get("move_to_month") + if move_to_month: + try: + year, month = map(int, move_to_month.split("-")) + l.created_at = datetime(year, month, 1, tzinfo=timezone.utc) + if not wants_json: + flash( + f"Zmieniono datę utworzenia listy na {l.created_at.strftime('%Y-%m-%d')}", + "success", + ) + except ValueError: + if not wants_json: + flash( + "Nieprawidłowy format miesiąca — zignorowano zmianę miesiąca.", + "danger", + ) + + new_title = (request.form.get("title") or "").strip() + is_public = "is_public" in request.form + is_temporary = "is_temporary" in request.form + is_archived = "is_archived" in request.form + expires_date = request.form.get("expires_date") + expires_time = request.form.get("expires_time") + + if not new_title: + if wants_json: + return jsonify(ok=False, error="title_empty"), 400 + flash("Podaj poprawny tytuł", "danger") + return redirect(next_page or request.url) + + l.title = new_title + l.is_public = is_public + l.is_temporary = is_temporary + l.is_archived = is_archived + + if expires_date and expires_time: + try: + combined = f"{expires_date} {expires_time}" + expires_dt = datetime.strptime(combined, "%Y-%m-%d %H:%M") + l.expires_at = expires_dt.replace(tzinfo=timezone.utc) + except ValueError: + if wants_json: + return jsonify(ok=False, error="bad_expiry"), 400 + flash("Błędna data lub godzina wygasania", "danger") + return redirect(next_page or request.url) + else: + l.expires_at = None + + # Kategorie (używa Twojej pomocniczej funkcji) + update_list_categories_from_form(l, request.form) + + db.session.commit() + if wants_json: + return jsonify(ok=True, saved=True) + flash("Zaktualizowano dane listy", "success") + return redirect(next_page or request.url) + + # GET: użytkownicy z dostępem + permitted_users = ( + db.session.query(User) + .join(ListPermission, ListPermission.user_id == User.id) + .where(ListPermission.list_id == l.id) + .order_by(User.username.asc()) + .all() + ) + + return render_template( + "edit_my_list.html", + list=l, + receipts=receipts, + categories=categories, + selected_categories=selected_categories_ids, + permitted_users=permitted_users, + ) + + +@app.route("/edit_my_list//suggestions", methods=["GET"]) +@login_required +def edit_my_list_suggestions(list_id: int): + # Weryfikacja listy i właściciela (prywatność) + l = db.session.get(ShoppingList, list_id) + if l is None: + abort(404) + if l.owner_id != current_user.id: + abort(403, description="Nie jesteś właścicielem tej listy.") + + q = (request.args.get("q") or "").strip().lower() + + # Historia nadawań uprawnień przez tego właściciela (po wszystkich jego listach) + subq = ( + db.session.query( + ListPermission.user_id.label("uid"), + func.count(ListPermission.id).label("grant_count"), + func.max(ListPermission.id).label("last_grant_id"), + ) + .join(ShoppingList, ShoppingList.id == ListPermission.list_id) + .filter(ShoppingList.owner_id == current_user.id) + .group_by(ListPermission.user_id) + .subquery() + ) + + query = db.session.query( + User.username, subq.c.grant_count, subq.c.last_grant_id + ).join(subq, subq.c.uid == User.id) + if q: + query = query.filter(func.lower(User.username).like(f"{q}%")) + + rows = ( + query.order_by( + subq.c.grant_count.desc(), + subq.c.last_grant_id.desc(), + func.lower(User.username).asc(), + ) + .limit(20) + .all() + ) + + return jsonify({"users": [r.username for r in rows]}) + + +@app.route("/delete_user_list/", methods=["POST"]) +@login_required +def delete_user_list(list_id): + + l = db.session.get(ShoppingList, list_id) + if l is None or l.owner_id != current_user.id: + abort(403, description="Nie jesteś właścicielem tej listy.") + + l = db.session.get(ShoppingList, list_id) + if l is None or l.owner_id != current_user.id: + abort(403) + delete_receipts_for_list(list_id) + Item.query.filter_by(list_id=list_id).delete() + Expense.query.filter_by(list_id=list_id).delete() + db.session.delete(l) + db.session.commit() + flash("Lista została usunięta", "success") + return redirect(url_for("main_page")) + + +@app.route("/toggle_visibility/", methods=["GET", "POST"]) +@login_required +def toggle_visibility(list_id): + l = db.session.get(ShoppingList, list_id) + if l is None: + abort(404) + + if l.owner_id != current_user.id: + if request.is_json or request.method == "POST": + return {"error": "Unauthorized"}, 403 + flash("Nie masz uprawnień do tej listy", "danger") + return redirect(url_for("main_page")) + + l.is_public = not l.is_public + db.session.commit() + + share_url = f"{request.url_root}share/{l.share_token}" + + if request.is_json or request.method == "POST": + return {"is_public": l.is_public, "share_url": share_url} + + if l.is_public: + flash("Lista została udostępniona publicznie", "success") + else: + flash("Lista została ukryta przed gośćmi", "info") + + return redirect(url_for("main_page")) + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + username_input = request.form["username"].lower() + user = User.query.filter(func.lower(User.username) == username_input).first() + if user and check_password(user.password_hash, request.form["password"]): + session.permanent = True + login_user(user) + session.modified = True + flash("Zalogowano pomyślnie", "success") + return redirect(url_for("main_page")) + flash("Nieprawidłowy login lub hasło", "danger") + return render_template("login.html") + + +@app.route("/logout") +@login_required +def logout(): + logout_user() + flash("Wylogowano pomyślnie", "success") + return redirect(url_for("main_page")) + + +@app.route("/create", methods=["POST"]) +@login_required +def create_list(): + title = request.form.get("title") + is_temporary = request.form.get("temporary") == "1" + token = generate_share_token(8) + + expires_at = ( + datetime.now(timezone.utc) + timedelta(days=7) if is_temporary else None + ) + + new_list = ShoppingList( + title=title, + owner_id=current_user.id, + is_temporary=is_temporary, + share_token=token, + expires_at=expires_at, + ) + db.session.add(new_list) + db.session.commit() + flash("Utworzono nową listę", "success") + return redirect(url_for("view_list", list_id=new_list.id)) + + +@app.route("/list/") +@login_required +def view_list(list_id): + shopping_list = db.session.get(ShoppingList, list_id) + if not shopping_list: + abort(404) + + is_owner = current_user.id == shopping_list.owner_id + if not is_owner: + flash( + "Nie jesteś właścicielem listy, przekierowano do widoku publicznego.", + "warning", + ) + if current_user.is_admin: + flash( + "W celu modyfikacji listy, przejdź do panelu administracyjnego.", "info" + ) + return redirect(url_for("shared_list", token=shopping_list.share_token)) + + shopping_list, items, receipts, expenses, total_expense = get_list_details(list_id) + total_count = len(items) + purchased_count = len([i for i in items if i.purchased]) + percent = (purchased_count / total_count * 100) if total_count > 0 else 0 + + for item in items: + if item.added_by != shopping_list.owner_id: + item.added_by_display = ( + item.added_by_user.username if item.added_by_user else "?" + ) + else: + item.added_by_display = None + + shopping_list.category_badges = [ + {"name": c.name, "color": category_color_for(c)} + for c in shopping_list.categories + ] + + # Wszystkie kategorie (do selecta) + categories = Category.query.order_by(Category.name.asc()).all() + selected_categories_ids = {c.id for c in shopping_list.categories} + + # Najczęściej używane kategorie właściciela (top N) + popular_categories = ( + db.session.query(Category) + .join( + shopping_list_category, + shopping_list_category.c.category_id == Category.id, + ) + .join( + ShoppingList, + ShoppingList.id == shopping_list_category.c.shopping_list_id, + ) + .filter(ShoppingList.owner_id == current_user.id) + .group_by(Category.id) + .order_by(func.count(ShoppingList.id).desc(), func.lower(Category.name).asc()) + .limit(6) + .all() + ) + + # Użytkownicy z uprawnieniami do listy + permitted_users = ( + db.session.query(User) + .join(ListPermission, ListPermission.user_id == User.id) + .filter(ListPermission.list_id == shopping_list.id) + .order_by(User.username.asc()) + .all() + ) + + return render_template( + "list.html", + list=shopping_list, + items=items, + receipts=receipts, + total_count=total_count, + purchased_count=purchased_count, + percent=percent, + expenses=expenses, + total_expense=total_expense, + is_share=False, + is_owner=is_owner, + categories=categories, + selected_categories=selected_categories_ids, + permitted_users=permitted_users, + popular_categories=popular_categories, + ) + + +@app.route("/list//settings", methods=["POST"]) +@login_required +def list_settings(list_id): + # Uprawnienia: właściciel + l = db.session.get(ShoppingList, list_id) + if l is None: + abort(404) + if l.owner_id != current_user.id: + abort(403, description="Brak uprawnień do ustawień tej listy.") + + next_page = request.form.get("next") or url_for("view_list", list_id=list_id) + wants_json = ( + "application/json" in (request.headers.get("Accept") or "") + or request.headers.get("X-Requested-With") == "fetch" + ) + + action = request.form.get("action") + + # 1) Ustawienie kategorii (pojedynczy wybór z list.html -> modal kategorii) + if action == "set_category": + cid = request.form.get("category_id") + if cid in (None, "", "none"): + # usunięcie kategorii lub brak zmiany – w zależności od Twojej logiki + l.categories = [] + db.session.commit() + if wants_json: + return jsonify(ok=True, saved=True) + flash("Zapisano kategorię.", "success") + return redirect(next_page) + + try: + cid = int(cid) + except (TypeError, ValueError): + if wants_json: + return jsonify(ok=False, error="bad_category"), 400 + flash("Błędna kategoria.", "danger") + return redirect(next_page) + + c = db.session.get(Category, cid) + if not c: + if wants_json: + return jsonify(ok=False, error="bad_category"), 400 + flash("Błędna kategoria.", "danger") + return redirect(next_page) + + # Jeśli jeden wybór – zastąp listę kategorii jedną: + l.categories = [c] + db.session.commit() + if wants_json: + return jsonify(ok=True, saved=True) + flash("Zapisano kategorię.", "success") + return redirect(next_page) + + # 2) Nadanie dostępu (akceptuj 'grant_access' i 'grant') + if action in ("grant_access", "grant"): + grant_username = (request.form.get("grant_username") or "").strip().lower() + + if not grant_username: + if wants_json: + return jsonify(ok=False, error="empty_username"), 400 + flash("Podaj nazwę użytkownika.", "danger") + return redirect(next_page) + + # Szukamy użytkownika po username (case-insensitive) + u = User.query.filter(func.lower(User.username) == grant_username).first() + if not u: + if wants_json: + return jsonify(ok=False, error="not_found"), 404 + flash("Użytkownik nie istnieje.", "danger") + return redirect(next_page) + + # Właściciel już ma dostęp + if u.id == l.owner_id: + if wants_json: + return jsonify(ok=False, error="owner"), 409 + flash("Jesteś właścicielem tej listy.", "info") + return redirect(next_page) + + # Czy już ma dostęp? + exists = ( + db.session.query(ListPermission.id) + .filter(ListPermission.list_id == l.id, ListPermission.user_id == u.id) + .first() + ) + if exists: + if wants_json: + return jsonify(ok=False, error="exists"), 409 + flash("Ten użytkownik już ma dostęp.", "info") + return redirect(next_page) + + # Zapis uprawnienia + db.session.add(ListPermission(list_id=l.id, user_id=u.id)) + db.session.commit() + + if wants_json: + # Zwracamy usera, żeby JS mógł dokleić token bez odświeżania + return jsonify(ok=True, user={"id": u.id, "username": u.username}) + flash(f"Nadano dostęp użytkownikowi „{u.username}”.", "success") + return redirect(next_page) + + # 3) Odebranie dostępu (po polu revoke_user_id, nie po action) + revoke_uid = request.form.get("revoke_user_id") + if revoke_uid: + try: + uid = int(revoke_uid) + except (TypeError, ValueError): + if wants_json: + return jsonify(ok=False, error="bad_user_id"), 400 + flash("Błędny identyfikator użytkownika.", "danger") + return redirect(next_page) + + # Nie pozwalaj usunąć właściciela + if uid == l.owner_id: + if wants_json: + return jsonify(ok=False, error="cannot_revoke_owner"), 400 + flash("Nie można odebrać dostępu właścicielowi.", "danger") + return redirect(next_page) + + ListPermission.query.filter_by(list_id=l.id, user_id=uid).delete() + db.session.commit() + + if wants_json: + return jsonify(ok=True, removed_user_id=uid) + flash("Odebrano dostęp użytkownikowi.", "success") + return redirect(next_page) + + # 4) Nieznana akcja + if wants_json: + return jsonify(ok=False, error="unknown_action"), 400 + flash("Nieznana akcja.", "danger") + return redirect(next_page) diff --git a/shopping_app/routes_secondary.py b/shopping_app/routes_secondary.py new file mode 100644 index 0000000..0b7cbb7 --- /dev/null +++ b/shopping_app/routes_secondary.py @@ -0,0 +1,511 @@ +from .deps import * +from .app_setup import * +from .models import * +from .helpers import * + +@app.route("/expenses") +@login_required +def expenses(): + start_date_str = request.args.get("start_date") + end_date_str = request.args.get("end_date") + category_id = request.args.get("category_id", type=str) + show_all = request.args.get("show_all", "true").lower() == "true" + + now = datetime.now(timezone.utc) + + visible_clause = visible_lists_clause_for_expenses( + user_id=current_user.id, include_shared=show_all, now_dt=now + ) + + lists_q = ShoppingList.query.filter(*visible_clause) + + if start_date_str and end_date_str: + try: + start = datetime.strptime(start_date_str, "%Y-%m-%d") + end = datetime.strptime(end_date_str, "%Y-%m-%d") + timedelta(days=1) + lists_q = lists_q.filter( + ShoppingList.created_at >= start, + ShoppingList.created_at < end, + ) + except ValueError: + flash("Błędny zakres dat", "danger") + + if category_id: + if category_id == "none": + lists_q = lists_q.filter(~ShoppingList.categories.any()) + else: + try: + cid = int(category_id) + lists_q = lists_q.join( + shopping_list_category, + shopping_list_category.c.shopping_list_id == ShoppingList.id, + ).filter(shopping_list_category.c.category_id == cid) + except (TypeError, ValueError): + pass + + lists_filtered = ( + lists_q.options( + joinedload(ShoppingList.owner), joinedload(ShoppingList.categories) + ) + .order_by(ShoppingList.created_at.desc()) + .all() + ) + list_ids = [l.id for l in lists_filtered] or [-1] + + expenses = ( + Expense.query.options( + joinedload(Expense.shopping_list).joinedload(ShoppingList.owner), + joinedload(Expense.shopping_list).joinedload(ShoppingList.categories), + ) + .filter(Expense.list_id.in_(list_ids)) + .order_by(Expense.added_at.desc()) + .all() + ) + + totals_rows = ( + db.session.query( + ShoppingList.id.label("lid"), + func.coalesce(func.sum(Expense.amount), 0).label("total_expense"), + ) + .select_from(ShoppingList) + .filter(ShoppingList.id.in_(list_ids)) + .outerjoin(Expense, Expense.list_id == ShoppingList.id) + .group_by(ShoppingList.id) + .all() + ) + totals_map = {row.lid: float(row.total_expense or 0) for row in totals_rows} + + categories = ( + Category.query.join( + shopping_list_category, shopping_list_category.c.category_id == Category.id + ) + .join( + ShoppingList, ShoppingList.id == shopping_list_category.c.shopping_list_id + ) + .filter(ShoppingList.id.in_(list_ids)) + .distinct() + .order_by(Category.name.asc()) + .all() + ) + categories.append(SimpleNamespace(id="none", name="Bez kategorii")) + + expense_table = [ + { + "title": (e.shopping_list.title if e.shopping_list else "Nieznana"), + "amount": e.amount, + "added_at": e.added_at, + } + for e in expenses + ] + + lists_data = [ + { + "id": l.id, + "title": l.title, + "created_at": l.created_at, + "total_expense": totals_map.get(l.id, 0.0), + "owner_username": l.owner.username if l.owner else "?", + "categories": [c.id for c in l.categories], + } + for l in lists_filtered + ] + + return render_template( + "expenses.html", + expense_table=expense_table, + lists_data=lists_data, + categories=categories, + selected_category=category_id, + show_all=show_all, + ) + + +@app.route("/expenses_data") +@login_required +def expenses_data(): + range_type = request.args.get("range", "monthly") + start_date = request.args.get("start_date") + end_date = request.args.get("end_date") + show_all = request.args.get("show_all", "true").lower() == "true" + category_id = request.args.get("category_id") + by_category = request.args.get("by_category", "false").lower() == "true" + + if not start_date or not end_date: + sd, ed, bucket = resolve_range(range_type) + if sd and ed: + start_date = sd + end_date = ed + range_type = bucket + + if by_category: + result = get_total_expenses_grouped_by_category( + show_all=show_all, + range_type=range_type, + start_date=start_date, + end_date=end_date, + user_id=current_user.id, + category_id=category_id, + ) + else: + result = get_total_expenses_grouped_by_list_created_at( + user_only=False, + admin=False, + show_all=show_all, + range_type=range_type, + start_date=start_date, + end_date=end_date, + user_id=current_user.id, + category_id=category_id, + ) + + if "error" in result: + return jsonify({"error": result["error"]}), 400 + return jsonify(result) + + +@app.route("/share/") +# @app.route("/guest-list/") +@app.route("/shared/") +def shared_list(token=None, list_id=None): + now = datetime.now(timezone.utc) + + if token: + shopping_list = ShoppingList.query.filter_by(share_token=token).first_or_404() + + # jeśli lista wygasła – zablokuj (spójne z resztą aplikacji) + if ( + shopping_list.is_temporary + and shopping_list.expires_at + and shopping_list.expires_at <= now + ): + flash("Link wygasł.", "warning") + return redirect(url_for("main_page")) + + # >>> KLUCZOWE: pozwól wejść nawet, gdy niepubliczna (bez check_list_public) + list_id = shopping_list.id + + # >>> Jeśli zalogowany i nie jest właścicielem — auto-przypisz stałe uprawnienie + if current_user.is_authenticated and current_user.id != shopping_list.owner_id: + # dodaj wpis tylko jeśli go nie ma + exists = ( + db.session.query(ListPermission.id) + .filter( + ListPermission.list_id == shopping_list.id, + ListPermission.user_id == current_user.id, + ) + .first() + ) + if not exists: + db.session.add( + ListPermission(list_id=shopping_list.id, user_id=current_user.id) + ) + db.session.commit() + else: + shopping_list = ShoppingList.query.get_or_404(list_id) + + total_expense = get_total_expense_for_list(list_id) + shopping_list, items, receipts, expenses, total_expense = get_list_details(list_id) + + shopping_list.category_badges = [ + {"name": c.name, "color": category_color_for(c)} + for c in shopping_list.categories + ] + + for item in items: + if item.added_by != shopping_list.owner_id: + item.added_by_display = ( + item.added_by_user.username if item.added_by_user else "?" + ) + else: + item.added_by_display = None + + return render_template( + "list_share.html", + list=shopping_list, + items=items, + receipts=receipts, + expenses=expenses, + total_expense=total_expense, + is_share=True, + ) + + +@app.route("/copy/") +@login_required +def copy_list(list_id): + original = ShoppingList.query.get_or_404(list_id) + token = generate_share_token(8) + new_list = ShoppingList( + title=original.title + " (Kopia)", owner_id=current_user.id, share_token=token + ) + db.session.add(new_list) + db.session.commit() + original_items = Item.query.filter_by(list_id=original.id).all() + for item in original_items: + copy_item = Item(list_id=new_list.id, name=item.name) + db.session.add(copy_item) + db.session.commit() + flash("Skopiowano listę", "success") + return redirect(url_for("view_list", list_id=new_list.id)) + + +@app.route("/suggest_products") +def suggest_products(): + query = request.args.get("q", "") + suggestions = [] + if query: + suggestions = ( + SuggestedProduct.query.filter(SuggestedProduct.name.ilike(f"%{query}%")) + .limit(5) + .all() + ) + return {"suggestions": [s.name for s in suggestions]} + + +@app.route("/all_products") +def all_products(): + sort = request.args.get("sort", "popularity") + limit = request.args.get("limit", type=int) or 100 + offset = request.args.get("offset", type=int) or 0 + + products_from_items = db.session.query( + func.lower(func.trim(Item.name)).label("normalized_name"), + func.min(Item.name).label("display_name"), + func.count(func.distinct(Item.list_id)).label("count"), + ).group_by(func.lower(func.trim(Item.name))) + + products_from_suggested = ( + db.session.query( + func.lower(func.trim(SuggestedProduct.name)).label("normalized_name"), + func.min(SuggestedProduct.name).label("display_name"), + db.literal(1).label("count"), + ) + .filter( + ~func.lower(func.trim(SuggestedProduct.name)).in_( + db.session.query(func.lower(func.trim(Item.name))) + ) + ) + .group_by(func.lower(func.trim(SuggestedProduct.name))) + ) + + union_q = products_from_items.union_all(products_from_suggested).subquery() + + final_q = db.session.query( + union_q.c.normalized_name, + union_q.c.display_name, + func.sum(union_q.c.count).label("count"), + ).group_by(union_q.c.normalized_name, union_q.c.display_name) + + if sort == "alphabetical": + final_q = final_q.order_by(func.lower(union_q.c.display_name).asc()) + else: + final_q = final_q.order_by( + func.sum(union_q.c.count).desc(), func.lower(union_q.c.display_name).asc() + ) + + total_count = ( + db.session.query(func.count()).select_from(final_q.subquery()).scalar() + ) + products = final_q.offset(offset).limit(limit).all() + + out = [{"name": row.display_name, "count": row.count} for row in products] + + return jsonify({"products": out, "total_count": total_count}) + + +@app.route("/upload_receipt/", methods=["POST"]) +@login_required +def upload_receipt(list_id): + l = db.session.get(ShoppingList, list_id) + + file = request.files.get("receipt") + if not file or file.filename == "": + return receipt_error("Nie wybrano pliku") + + if not allowed_file(file.filename): + return receipt_error("Niedozwolony format pliku") + + file_bytes = file.read() + file.seek(0) + file_hash = hashlib.sha256(file_bytes).hexdigest() + + existing = Receipt.query.filter_by(file_hash=file_hash).first() + if existing: + return receipt_error("Taki plik już istnieje") + + now = datetime.now(timezone.utc) + timestamp = now.strftime("%Y%m%d_%H%M") + random_part = secrets.token_hex(3) + webp_filename = f"list_{list_id}_{timestamp}_{random_part}.webp" + file_path = os.path.join(app.config["UPLOAD_FOLDER"], webp_filename) + + try: + if file.filename.lower().endswith(".pdf"): + file.seek(0) + save_pdf_as_webp(file, file_path) + else: + save_resized_image(file, file_path) + except ValueError as e: + return receipt_error(str(e)) + + try: + new_receipt = Receipt( + list_id=list_id, + filename=webp_filename, + filesize=os.path.getsize(file_path), + uploaded_at=now, + file_hash=file_hash, + uploaded_by=current_user.id, + version_token=generate_version_token(), + ) + db.session.add(new_receipt) + db.session.commit() + except Exception as e: + return receipt_error(f"Błąd zapisu do bazy: {str(e)}") + + if request.is_json or request.headers.get("X-Requested-With") == "XMLHttpRequest": + url = ( + url_for("uploaded_file", filename=webp_filename) + + f"?v={new_receipt.version_token or '0'}" + ) + socketio.emit("receipt_added", {"url": url}, to=str(list_id)) + return jsonify({"success": True, "url": url}) + + flash("Wgrano paragon", "success") + return redirect(request.referrer or url_for("main_page")) + + +@app.route("/uploads/") +def uploaded_file(filename): + response = send_from_directory(app.config["UPLOAD_FOLDER"], filename) + response.headers["Cache-Control"] = app.config["UPLOADS_CACHE_CONTROL"] + response.headers.pop("Content-Disposition", None) + mime, _ = mimetypes.guess_type(filename) + if mime: + response.headers["Content-Type"] = mime + return response + + +@app.route("/reorder_items", methods=["POST"]) +@login_required +def reorder_items(): + data = request.get_json() + list_id = data.get("list_id") + order = data.get("order") + + for index, item_id in enumerate(order): + item = db.session.get(Item, item_id) + if item and item.list_id == list_id: + item.position = index + db.session.commit() + + socketio.emit( + "items_reordered", {"list_id": list_id, "order": order}, to=str(list_id) + ) + + return jsonify(success=True) + + +@app.route("/rotate_receipt/") +@login_required +def rotate_receipt_user(receipt_id): + receipt = Receipt.query.get_or_404(receipt_id) + list_obj = ShoppingList.query.get_or_404(receipt.list_id) + + if not (current_user.is_admin or current_user.id == list_obj.owner_id): + flash("Brak uprawnień do tej operacji", "danger") + return redirect(url_for("main_page")) + + try: + rotate_receipt_by_id(receipt_id) + recalculate_filesizes(receipt_id) + flash("Obrócono paragon", "success") + except FileNotFoundError: + flash("Plik nie istnieje", "danger") + except Exception as e: + flash(f"Błąd przy obracaniu: {str(e)}", "danger") + + return redirect(request.referrer or url_for("main_page")) + + +@app.route("/delete_receipt/") +@login_required +def delete_receipt_user(receipt_id): + receipt = Receipt.query.get_or_404(receipt_id) + list_obj = ShoppingList.query.get_or_404(receipt.list_id) + + if not (current_user.is_admin or current_user.id == list_obj.owner_id): + flash("Brak uprawnień do tej operacji", "danger") + return redirect(url_for("main_page")) + + try: + delete_receipt_by_id(receipt_id) + flash("Paragon usunięty", "success") + except Exception as e: + flash(f"Błąd przy usuwaniu pliku: {str(e)}", "danger") + + return redirect(request.referrer or url_for("main_page")) + + +# OCR +@app.route("/lists//analyze", methods=["POST"]) +@login_required +def analyze_receipts_for_list(list_id): + receipt_objs = Receipt.query.filter_by(list_id=list_id).all() + existing_expenses = { + e.receipt_filename + for e in Expense.query.filter_by(list_id=list_id).all() + if e.receipt_filename + } + + results = [] + total = 0.0 + + for receipt in receipt_objs: + filepath = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename) + if not os.path.exists(filepath): + continue + + try: + raw_image = Image.open(filepath).convert("RGB") + image = preprocess_image_for_tesseract(raw_image) + value, lines = extract_total_tesseract(image) + + except Exception as e: + print(f"OCR error for {receipt.filename}:\n{traceback.format_exc()}") + value = 0.0 + lines = [] + + already_added = receipt.filename in existing_expenses + + results.append( + { + "id": receipt.id, + "filename": receipt.filename, + "amount": round(value, 2), + "debug_text": lines, + "already_added": already_added, + } + ) + + # if not already_added: + total += value + + return jsonify({"results": results, "total": round(total, 2)}) + + +@app.route("/user_crop_receipt", methods=["POST"]) +@login_required +def crop_receipt_user(): + receipt_id = request.form.get("receipt_id") + file = request.files.get("cropped_image") + + receipt = Receipt.query.get_or_404(receipt_id) + list_obj = ShoppingList.query.get_or_404(receipt.list_id) + + if list_obj.owner_id != current_user.id and not current_user.is_admin: + return jsonify(success=False, error="Brak dostępu"), 403 + + result = handle_crop_receipt(receipt_id, file) + return jsonify(result) + diff --git a/shopping_app/sockets.py b/shopping_app/sockets.py new file mode 100644 index 0000000..c5cbf05 --- /dev/null +++ b/shopping_app/sockets.py @@ -0,0 +1,513 @@ +from .deps import * +from .app_setup import * +from .models import * +from .helpers import * + +from flask import render_template_string + +@app.route('/admin/debug-socket') +@login_required +@admin_required +def debug_socket(): + return render_template_string(''' + + + + Socket Debug + + + + +

Socket.IO Debug Tool

+ +
Rozlaczony
+
+ Transport: - | + Ping: -ms | + SID: - +
+ + + + + + +

Logi:

+
+ + + + + ''') + + + +# ========================================================================================= +# SOCKET.IO +# ========================================================================================= + + +@socketio.on("delete_item") +def handle_delete_item(data): + # item = Item.query.get(data["item_id"]) + item = db.session.get(Item, data["item_id"]) + + if item: + list_id = item.list_id + db.session.delete(item) + db.session.commit() + emit("item_deleted", {"item_id": item.id}, to=str(item.list_id)) + + purchased_count, total_count, percent = get_progress(list_id) + + emit( + "progress_updated", + { + "purchased_count": purchased_count, + "total_count": total_count, + "percent": percent, + }, + to=str(list_id), + ) + + +@socketio.on("edit_item") +def handle_edit_item(data): + item = db.session.get(Item, data["item_id"]) + + new_name = data["new_name"] + new_quantity = data.get("new_quantity", item.quantity) + + if item and new_name.strip(): + item.name = new_name.strip() + + try: + new_quantity = int(new_quantity) + if new_quantity < 1: + new_quantity = 1 + except: + new_quantity = 1 + + item.quantity = new_quantity + + db.session.commit() + + emit( + "item_edited", + {"item_id": item.id, "new_name": item.name, "new_quantity": item.quantity}, + to=str(item.list_id), + ) + + +@socketio.on("join_list") +def handle_join(data): + global active_users + room = str(data["room"]) + username = data.get("username", "Gość") + join_room(room) + + if room not in active_users: + active_users[room] = set() + active_users[room].add(username) + + shopping_list = db.session.get(ShoppingList, int(data["room"])) + + list_title = shopping_list.title if shopping_list else "Twoja lista" + + emit("user_joined", {"username": username}, to=room) + emit("user_list", {"users": list(active_users[room])}, to=room) + emit("joined_confirmation", {"room": room, "list_title": list_title}) + + +@socketio.on("disconnect") +def handle_disconnect(sid): + global active_users + username = current_user.username if current_user.is_authenticated else "Gość" + for room, users in active_users.items(): + if username in users: + users.remove(username) + emit("user_left", {"username": username}, to=room) + emit("user_list", {"users": list(users)}, to=room) + + +@socketio.on("add_item") +def handle_add_item(data): + list_id = data["list_id"] + name = data["name"].strip() + quantity = data.get("quantity", 1) + + list_obj = db.session.get(ShoppingList, list_id) + if not list_obj: + return + + try: + quantity = int(quantity) + if quantity < 1: + quantity = 1 + except: + quantity = 1 + + existing_item = Item.query.filter( + Item.list_id == list_id, + func.lower(Item.name) == name.lower(), + Item.not_purchased == False, + ).first() + + if existing_item: + existing_item.quantity += quantity + db.session.commit() + + emit( + "item_edited", + { + "item_id": existing_item.id, + "new_name": existing_item.name, + "new_quantity": existing_item.quantity, + }, + to=str(list_id), + ) + else: + max_position = ( + db.session.query(func.max(Item.position)) + .filter_by(list_id=list_id) + .scalar() + ) + if max_position is None: + max_position = 0 + + user_id = current_user.id if current_user.is_authenticated else None + user_name = current_user.username if current_user.is_authenticated else "Gość" + + new_item = Item( + list_id=list_id, + name=name, + quantity=quantity, + position=max_position + 1, + added_by=user_id, + ) + db.session.add(new_item) + + if not SuggestedProduct.query.filter( + func.lower(SuggestedProduct.name) == name.lower() + ).first(): + new_suggestion = SuggestedProduct(name=name) + db.session.add(new_suggestion) + + db.session.commit() + + emit( + "item_added", + { + "id": new_item.id, + "name": new_item.name, + "quantity": new_item.quantity, + "added_by": user_name, + "added_by_id": user_id, + "owner_id": list_obj.owner_id, + }, + to=str(list_id), + include_self=True, + ) + + purchased_count, total_count, percent = get_progress(list_id) + + emit( + "progress_updated", + { + "purchased_count": purchased_count, + "total_count": total_count, + "percent": percent, + }, + to=str(list_id), + ) + + +@socketio.on("check_item") +def handle_check_item(data): + item = db.session.get(Item, data["item_id"]) + + if item: + item.purchased = True + item.purchased_at = datetime.now(UTC) + + db.session.commit() + + purchased_count, total_count, percent = get_progress(item.list_id) + + emit("item_checked", {"item_id": item.id}, to=str(item.list_id)) + emit( + "progress_updated", + { + "purchased_count": purchased_count, + "total_count": total_count, + "percent": percent, + }, + to=str(item.list_id), + ) + + +@socketio.on("uncheck_item") +def handle_uncheck_item(data): + item = db.session.get(Item, data["item_id"]) + + if item: + item.purchased = False + item.purchased_at = None + db.session.commit() + + purchased_count, total_count, percent = get_progress(item.list_id) + + emit("item_unchecked", {"item_id": item.id}, to=str(item.list_id)) + emit( + "progress_updated", + { + "purchased_count": purchased_count, + "total_count": total_count, + "percent": percent, + }, + to=str(item.list_id), + ) + + +@socketio.on("request_full_list") +def handle_request_full_list(data): + list_id = data["list_id"] + + shopping_list = db.session.get(ShoppingList, list_id) + if not shopping_list: + return + + owner_id = shopping_list.owner_id + + items = ( + Item.query.options(joinedload(Item.added_by_user)) + .filter_by(list_id=list_id) + .order_by(Item.position.asc()) + .all() + ) + + items_data = [] + for item in items: + items_data.append( + { + "id": item.id, + "name": item.name, + "quantity": item.quantity, + "purchased": item.purchased if not item.not_purchased else False, + "not_purchased": item.not_purchased, + "not_purchased_reason": item.not_purchased_reason, + "note": item.note or "", + "added_by": item.added_by_user.username if item.added_by_user else None, + "added_by_id": item.added_by_user.id if item.added_by_user else None, + "owner_id": owner_id, + } + ) + + emit("full_list", {"items": items_data}, to=request.sid) + + +@socketio.on("update_note") +def handle_update_note(data): + item_id = data["item_id"] + note = data["note"] + item = Item.query.get(item_id) + if item: + item.note = note + db.session.commit() + emit("note_updated", {"item_id": item_id, "note": note}, to=str(item.list_id)) + + +@socketio.on("add_expense") +def handle_add_expense(data): + list_id = data["list_id"] + amount = data["amount"] + receipt_filename = data.get("receipt_filename") + + if receipt_filename: + existing = Expense.query.filter_by( + list_id=list_id, receipt_filename=receipt_filename + ).first() + if existing: + return + new_expense = Expense( + list_id=list_id, amount=amount, receipt_filename=receipt_filename + ) + + db.session.add(new_expense) + db.session.commit() + + total = ( + db.session.query(func.sum(Expense.amount)).filter_by(list_id=list_id).scalar() + or 0 + ) + + emit("expense_added", {"amount": amount, "total": total}, to=str(list_id)) + + +@socketio.on("mark_not_purchased") +def handle_mark_not_purchased(data): + item = db.session.get(Item, data["item_id"]) + + reason = data.get("reason", "") + if item: + item.not_purchased = True + item.not_purchased_reason = reason + db.session.commit() + emit( + "item_marked_not_purchased", + {"item_id": item.id, "reason": reason}, + to=str(item.list_id), + ) + + +@socketio.on("unmark_not_purchased") +def handle_unmark_not_purchased(data): + item = db.session.get(Item, data["item_id"]) + + if item: + item.not_purchased = False + item.purchased = False + item.purchased_at = None + item.not_purchased_reason = None + db.session.commit() + emit("item_unmarked_not_purchased", {"item_id": item.id}, to=str(item.list_id)) + + +@app.cli.command("db_info") +def create_db(): + with app.app_context(): + inspector = inspect(db.engine) + actual_tables = inspector.get_table_names() + + table_count = len(actual_tables) + record_total = 0 + with db.engine.connect() as conn: + for table in actual_tables: + try: + count = conn.execute(text(f"SELECT COUNT(*) FROM {table}")).scalar() + record_total += count + except Exception: + pass + + print("\nStruktura bazy danych jest poprawna.") + print(f"Silnik: {db.engine.name}") + print(f"Liczba tabel: {table_count}") + print(f"Łączna liczba rekordów: {record_total}") + + +if __name__ == "__main__": + logging.basicConfig(level=logging.DEBUG if DEBUG_MODE else logging.INFO) + socketio.run(app, host="0.0.0.0", port=APP_PORT, debug=False) diff --git a/static/css/style.css b/shopping_app/static/css/style.css similarity index 62% rename from static/css/style.css rename to shopping_app/static/css/style.css index d53fad6..04bc1cd 100644 --- a/static/css/style.css +++ b/shopping_app/static/css/style.css @@ -1034,3 +1034,794 @@ td select.tom-dark { max-width: 60% !important; } } + +/* ===== 2026 app refresh ===== */ +:root { + --app-bg: #07111f; + --app-bg-soft: #0d1b2d; + --app-surface: rgba(11, 23, 39, 0.88); + --app-surface-strong: rgba(15, 28, 46, 0.98); + --app-surface-muted: rgba(255, 255, 255, 0.04); + --app-border: rgba(255, 255, 255, 0.1); + --app-border-strong: rgba(255, 255, 255, 0.16); + --app-text: #f3f8ff; + --app-text-muted: #9fb0c8; + --app-success: #27d07d; + --app-warning: #f6c453; + --app-danger: #ff6b7a; + --app-shadow: 0 18px 50px rgba(0, 0, 0, 0.28); + --app-radius: 22px; +} + +html, body { + min-height: 100%; + background: + radial-gradient(circle at top left, rgba(39, 208, 125, 0.18), transparent 24%), + radial-gradient(circle at top right, rgba(74, 144, 226, 0.16), transparent 22%), + linear-gradient(180deg, #09111d 0%, #08121f 38%, #060d18 100%); + color: var(--app-text); +} + +body.app-body { + position: relative; + font-feature-settings: "ss01" on, "cv02" on; +} + +.app-backdrop { + position: fixed; + inset: 0; + pointer-events: none; + background: linear-gradient(180deg, rgba(255,255,255,0.02), transparent 28%); +} + +.app-header { + z-index: 1035; + padding: 0.75rem 0 0; + backdrop-filter: blur(12px); +} + +.app-navbar { + background: transparent; +} + +.app-navbar .container-xxl { + border: 1px solid var(--app-border); + background: rgba(6, 15, 27, 0.74); + backdrop-filter: blur(16px); + border-radius: 999px; + min-height: 68px; + box-shadow: var(--app-shadow); +} + +.app-brand { + display: inline-flex; + align-items: center; + gap: 0.85rem; + font-weight: 800; + color: var(--app-text) !important; +} + +.app-brand__icon { + display: inline-grid; + place-items: center; + width: 2.6rem; + height: 2.6rem; + border-radius: 16px; + background: linear-gradient(135deg, rgba(39,208,125,0.22), rgba(74,144,226,0.18)); + box-shadow: inset 0 0 0 1px rgba(255,255,255,0.08); +} + +.app-brand__title { color: #ffffff; } +.app-brand__accent { color: #7ce4a8; margin-left: 0.3rem; } + +.app-navbar__actions, +.app-navbar__meta { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.app-user-chip { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.45rem 0.4rem 0.75rem; + border-radius: 999px; + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.08); +} + +.app-user-chip__label { + font-size: 0.75rem; + color: var(--app-text-muted); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.app-main { + padding: 1rem 0 2.5rem; +} + +.app-content-frame { + padding: 0.25rem 0 0; +} + +.app-footer { + padding: 1rem 0 2rem; +} + +.app-footer__inner { + border-top: 1px solid rgba(255,255,255,0.08); + padding-top: 1.25rem; +} + +h1, h2, h3, h4, h5, h6 { + color: #ffffff; + letter-spacing: -0.02em; +} + +.card, +.modal-content, +.dropdown-menu, +.list-group-item, +.table, +.alert, +.pagination .page-link, +.nav-tabs, +.input-group-text, +.form-control, +.form-select, +.btn, +.progress, +.toast { + border-radius: 18px; +} + +.card, +.modal-content, +.table-responsive, +.alert, +.list-group-item, +.pagination .page-link, +.nav-tabs, +.input-group-text, +.form-control, +.form-select, +.progress, +.toast, +.page-link, +.table, +.btn-group > .btn { + border-color: var(--app-border) !important; +} + +.card, +.modal-content, +.table-responsive, +.alert, +.list-group-item, +.progress, +.toast { + background: var(--app-surface) !important; + box-shadow: var(--app-shadow); + backdrop-filter: blur(10px); +} + +.card-body, +.modal-body, +.modal-header, +.modal-footer { + background: transparent; +} + +.bg-dark, +.table-dark, +.list-group-item.bg-dark, +.modal-content.bg-dark, +.card.bg-dark, +.card.bg-secondary, +.list-group-item.item-not-checked { + background: var(--app-surface) !important; + color: var(--app-text) !important; +} + +.card.bg-secondary.bg-opacity-10, +#share-card { + background: linear-gradient(180deg, rgba(16, 29, 49, 0.96), rgba(10, 20, 36, 0.94)) !important; +} + +.table-dark { + --bs-table-bg: transparent; + --bs-table-striped-bg: rgba(255,255,255,0.03); + --bs-table-hover-bg: rgba(255,255,255,0.05); + --bs-table-color: var(--app-text); + margin-bottom: 0; +} + +.table > :not(caption) > * > * { + padding: 0.9rem 1rem; + border-bottom-color: rgba(255,255,255,0.08); +} + +.list-group { + gap: 0.8rem; +} + +.list-group-item { + margin-bottom: 0; + padding: 1rem 1rem; + color: var(--app-text) !important; +} + +.list-group-item.bg-success { + background: linear-gradient(135deg, rgba(39,208,125,0.92), rgba(22,150,91,0.96)) !important; +} + +.list-group-item.bg-warning { + background: linear-gradient(135deg, rgba(246,196,83,0.96), rgba(224,164,26,0.96)) !important; + color: #1c1b17 !important; +} + +.btn { + border-radius: 14px; + font-weight: 600; + padding: 0.7rem 1rem; + box-shadow: none; +} + +.btn-sm { + padding: 0.55rem 0.85rem; + border-radius: 12px; +} + +.btn-success, +.btn-outline-success:hover { + background: linear-gradient(135deg, #29d17d, #1ea860); + border-color: rgba(41,209,125,0.9); +} + +.btn-outline-light, +.btn-outline-secondary, +.btn-outline-warning, +.btn-outline-primary, +.btn-outline-success { + background: rgba(255,255,255,0.02); +} + +.btn:hover, +.btn:focus { + transform: translateY(-1px); +} + +.form-control, +.form-select, +.input-group-text { + min-height: 48px; + background: rgba(5, 13, 23, 0.86) !important; + color: var(--app-text) !important; + box-shadow: none !important; +} + +.form-control::placeholder { color: rgba(210, 224, 244, 0.45); } +.form-control:focus, +.form-select:focus { + border-color: rgba(39,208,125,0.5) !important; + box-shadow: 0 0 0 0.2rem rgba(39,208,125,0.15) !important; +} + +.nav-tabs { + gap: 0.5rem; + border-bottom: none; + background: rgba(255,255,255,0.03); + padding: 0.4rem; +} + +.nav-tabs .nav-link { + border-radius: 14px; + color: var(--app-text-muted); + border: none; + padding: 0.8rem 1rem; +} + +.nav-tabs .nav-link.active { + background: rgba(39,208,125,0.12); + color: #fff; +} + +.progress { + overflow: hidden; + background: rgba(255,255,255,0.06); + min-height: 1rem; +} + +.badge { + border: 1px solid rgba(255,255,255,0.08); +} + +.pagination .page-link { + background: rgba(255,255,255,0.03); + color: var(--app-text); + margin: 0 0.15rem; +} + +.pagination .page-item.active .page-link { + background: rgba(39,208,125,0.18); + color: #fff; +} + +.modal-content { + overflow: hidden; +} + +.toast-container { z-index: 1200; } + +#items .list-group-item { + border-radius: 18px !important; + padding: 1rem 1rem; +} + +#items .btn-group { + gap: 0.4rem; +} + +#items .btn-group .btn { + border-radius: 12px !important; +} + +.large-checkbox { + width: 1.35rem; + height: 1.35rem; + accent-color: #29d17d; +} + +#share-card .badge, +#total-expense1, +#total-expense2, +#total-expense { + background: rgba(255,255,255,0.08) !important; + color: #dfffea !important; +} + +#share-card, +.card, +.table-responsive, +.alert, +.modal-content, +#expenseChartWrapper, +#categoryChartWrapper { + border-radius: var(--app-radius) !important; +} + +.endpoint-login .app-content-frame, +.endpoint-system_auth .app-content-frame, +.endpoint-page_not_found .app-content-frame, +.endpoint-forbidden .app-content-frame { + max-width: 560px; + margin: 3rem auto 0; +} + +.endpoint-main_page .list-group-item, +.endpoint-expenses .card, +.endpoint-admin_panel .card, +.endpoint-view_list .card, +.endpoint-shared_list .card, +.endpoint-edit_my_list .card, +[class*="endpoint-admin_"] .card { + overflow: hidden; +} + +input[type="checkbox"].form-check-input { + width: 2.9rem; + height: 1.5rem; +} + +hr { + border-color: rgba(255,255,255,0.08); +} + +@media (max-width: 991.98px) { + .app-header { + padding-top: 0.55rem; + } + + .app-navbar .container-xxl { + border-radius: 26px; + padding-top: 0.8rem; + padding-bottom: 0.8rem; + align-items: flex-start; + } + + .app-navbar__actions, + .app-navbar__meta { + width: 100%; + justify-content: flex-start; + } +} + +@media (max-width: 767.98px) { + .app-main { + padding-top: 0.7rem; + } + + .card-body, + .list-group-item, + .modal-body, + .modal-header, + .modal-footer, + .table > :not(caption) > * > * { + padding-left: 0.85rem; + padding-right: 0.85rem; + } + + .btn-group, + .d-flex.gap-2, + .d-flex.gap-3 { + gap: 0.45rem !important; + } + + .btn-group > .btn, + .btn.w-100, + .input-group > .btn { + min-height: 46px; + } + + .input-group { + flex-wrap: wrap; + gap: 0.55rem; + } + + .input-group > .form-control, + .input-group > .form-select, + .input-group > .btn, + .input-group > .input-group-text { + width: 100% !important; + flex: 1 1 100% !important; + border-radius: 14px !important; + max-width: 100% !important; + } + + #items .d-flex.align-items-center.gap-2.flex-grow-1 { + width: 100%; + align-items: flex-start !important; + } + + #items .btn-group { + width: 100%; + margin-top: 0.85rem; + } + + #items .btn-group .btn { + flex: 1 1 0; + } + + .table-responsive { + border-radius: 18px; + } + + .pagination { + flex-wrap: wrap; + gap: 0.25rem; + justify-content: center; + } +} + +/* ========================================================= + Compact minimalist pass +========================================================= */ +:root { + --app-shadow: 0 8px 24px rgba(0, 0, 0, 0.18); + --app-radius: 14px; +} + +body.app-body { + font-size: 0.96rem; + line-height: 1.4; +} + +.app-header { + padding: 0.35rem 0 0; +} + +.app-navbar .container-xxl { + min-height: 54px; + border-radius: 18px; + box-shadow: 0 8px 22px rgba(0,0,0,0.16); +} + +.app-brand { + gap: 0.6rem; + font-size: 0.98rem; +} + +.app-brand__icon { + width: 2.1rem; + height: 2.1rem; + border-radius: 12px; + font-size: 1rem; +} + +.app-brand__title, +.app-brand__accent { + font-size: 0.98rem; +} + +.app-user-chip { + padding: 0.28rem 0.38rem 0.28rem 0.58rem; + gap: 0.4rem; +} + +.app-user-chip__label { + font-size: 0.68rem; + letter-spacing: 0.06em; +} + +.app-main { + padding: 0.65rem 0 1.4rem; +} + +.app-content-frame { + padding-top: 0.1rem; +} + +.app-footer { + padding: 0.5rem 0 1rem; +} + +.app-footer__inner { + padding-top: 0.75rem; +} + +h1, h2, h3, h4, h5, h6 { + letter-spacing: -0.015em; + line-height: 1.15; +} + +h1, .h1 { font-size: clamp(1.45rem, 2vw, 1.9rem); } +h2, .h2 { font-size: clamp(1.2rem, 1.8vw, 1.5rem); } +h3, .h3 { font-size: clamp(1.02rem, 1.5vw, 1.2rem); } + +.card, +.modal-content, +.dropdown-menu, +.list-group-item, +.table, +.alert, +.pagination .page-link, +.nav-tabs, +.input-group-text, +.form-control, +.form-select, +.btn, +.progress, +.toast { + border-radius: 12px; +} + +.card, +.modal-content, +.table-responsive, +.alert, +.list-group-item, +.progress, +.toast { + box-shadow: 0 4px 14px rgba(0,0,0,0.12); + backdrop-filter: blur(8px); +} + +.card-header, +.card-footer, +.card-body, +.modal-header, +.modal-body, +.modal-footer { + padding: 0.75rem 0.85rem; +} + +.table > :not(caption) > * > * { + padding: 0.62rem 0.7rem; +} + +.table-responsive table { + min-width: 860px; +} + +.list-group { + gap: 0.5rem; +} + +.list-group-item { + padding: 0.72rem 0.8rem; +} + +.alert { + padding: 0.7rem 0.85rem; + margin-bottom: 0.8rem; +} + +.badge { + font-weight: 600; + padding: 0.38em 0.58em; +} + +.btn { + border-radius: 10px; + font-weight: 600; + padding: 0.52rem 0.8rem; + min-height: 40px; +} + +.btn-sm { + padding: 0.4rem 0.64rem; + min-height: 34px; + border-radius: 9px; +} + +.form-control, +.form-select, +.input-group-text { + min-height: 40px; + padding: 0.5rem 0.72rem; +} + +textarea.form-control { + min-height: 96px; +} + +.progress { + min-height: 0.8rem; +} + +.progress-label { + font-size: 0.72rem; + font-weight: 600; +} + +.nav-tabs { + gap: 0.35rem; + padding: 0.25rem; +} + +.nav-tabs .nav-link { + padding: 0.55rem 0.7rem; + border-radius: 10px; +} + +#items .list-group-item { + border-radius: 12px !important; + padding: 0.75rem 0.8rem; +} + +#items .btn-group { + gap: 0.25rem; +} + +#items .btn-group .btn { + border-radius: 9px !important; +} + +input[type="checkbox"].form-check-input { + width: 2.5rem; + height: 1.35rem; +} + +.large-checkbox { + width: 1.2rem; + height: 1.2rem; +} + +.toast { + padding: 0; +} + +.endpoint-main_page .card h2, +.endpoint-expenses .card h2, +.endpoint-edit_my_list .card h2, +.endpoint-login .card h2, +.endpoint-system_auth .card h2, +.endpoint-admin_panel .card h2, +[class*="endpoint-admin_"] .card h2 { + margin-bottom: 0.35rem; +} + +.endpoint-main_page .card p, +.endpoint-expenses .card p, +.endpoint-edit_my_list .card p, +.endpoint-login .card p, +.endpoint-system_auth .card p, +.endpoint-admin_panel .card p, +[class*="endpoint-admin_"] .card p { + margin-bottom: 0; +} + +@media (max-width: 991.98px) { + .app-navbar .container-xxl { + border-radius: 16px; + padding-top: 0.55rem; + padding-bottom: 0.55rem; + } +} + +@media (max-width: 767.98px) { + body.app-body { + font-size: 0.93rem; + } + + .app-header { + padding-top: 0.25rem; + } + + .app-main { + padding-top: 0.45rem; + } + + .app-navbar .container-xxl { + min-height: 50px; + border-radius: 14px; + } + + .app-brand { + gap: 0.45rem; + font-size: 0.92rem; + } + + .app-brand__icon { + width: 1.9rem; + height: 1.9rem; + border-radius: 10px; + } + + .app-user-chip { + padding: 0.22rem 0.32rem 0.22rem 0.5rem; + } + + .card-header, + .card-footer, + .card-body, + .modal-header, + .modal-body, + .modal-footer, + .list-group-item, + .table > :not(caption) > * > * { + padding-left: 0.68rem; + padding-right: 0.68rem; + } + + .list-group-item, + #items .list-group-item { + padding-top: 0.62rem; + padding-bottom: 0.62rem; + } + + .btn-group, + .d-flex.gap-2, + .d-flex.gap-3 { + gap: 0.35rem !important; + } + + .btn-group > .btn, + .btn.w-100, + .input-group > .btn, + .btn, + .form-control, + .form-select, + .input-group-text { + min-height: 38px; + } + + .input-group { + gap: 0.4rem; + } + + .table-responsive { + border-radius: 12px; + } + + .progress-label { + font-size: 0.66rem; + } + + .app-footer { + padding-bottom: 0.8rem; + } +} diff --git a/static/css/style_old.css b/shopping_app/static/css/style_old.css similarity index 100% rename from static/css/style_old.css rename to shopping_app/static/css/style_old.css diff --git a/static/js/access_users.js b/shopping_app/static/js/access_users.js similarity index 100% rename from static/js/access_users.js rename to shopping_app/static/js/access_users.js diff --git a/static/js/admin_settings.js b/shopping_app/static/js/admin_settings.js similarity index 100% rename from static/js/admin_settings.js rename to shopping_app/static/js/admin_settings.js diff --git a/static/js/categories_autosave.js b/shopping_app/static/js/categories_autosave.js similarity index 100% rename from static/js/categories_autosave.js rename to shopping_app/static/js/categories_autosave.js diff --git a/static/js/categories_select_admin.js b/shopping_app/static/js/categories_select_admin.js similarity index 100% rename from static/js/categories_select_admin.js rename to shopping_app/static/js/categories_select_admin.js diff --git a/static/js/category_modal.js b/shopping_app/static/js/category_modal.js similarity index 100% rename from static/js/category_modal.js rename to shopping_app/static/js/category_modal.js diff --git a/static/js/chart_controls.js b/shopping_app/static/js/chart_controls.js similarity index 100% rename from static/js/chart_controls.js rename to shopping_app/static/js/chart_controls.js diff --git a/static/js/clickable_row.js b/shopping_app/static/js/clickable_row.js similarity index 100% rename from static/js/clickable_row.js rename to shopping_app/static/js/clickable_row.js diff --git a/static/js/confirm_delete.js b/shopping_app/static/js/confirm_delete.js similarity index 100% rename from static/js/confirm_delete.js rename to shopping_app/static/js/confirm_delete.js diff --git a/static/js/download_chart.js b/shopping_app/static/js/download_chart.js similarity index 100% rename from static/js/download_chart.js rename to shopping_app/static/js/download_chart.js diff --git a/static/js/expense_chart.js b/shopping_app/static/js/expense_chart.js similarity index 100% rename from static/js/expense_chart.js rename to shopping_app/static/js/expense_chart.js diff --git a/static/js/expense_tab.js b/shopping_app/static/js/expense_tab.js similarity index 100% rename from static/js/expense_tab.js rename to shopping_app/static/js/expense_tab.js diff --git a/static/js/expense_table.js b/shopping_app/static/js/expense_table.js similarity index 100% rename from static/js/expense_table.js rename to shopping_app/static/js/expense_table.js diff --git a/static/js/functions.js b/shopping_app/static/js/functions.js similarity index 100% rename from static/js/functions.js rename to shopping_app/static/js/functions.js diff --git a/static/js/lists_access.js b/shopping_app/static/js/lists_access.js similarity index 100% rename from static/js/lists_access.js rename to shopping_app/static/js/lists_access.js diff --git a/static/js/live.js b/shopping_app/static/js/live.js similarity index 100% rename from static/js/live.js rename to shopping_app/static/js/live.js diff --git a/static/js/mass_add.js b/shopping_app/static/js/mass_add.js similarity index 100% rename from static/js/mass_add.js rename to shopping_app/static/js/mass_add.js diff --git a/static/js/modal_chart.js b/shopping_app/static/js/modal_chart.js similarity index 100% rename from static/js/modal_chart.js rename to shopping_app/static/js/modal_chart.js diff --git a/static/js/notes.js b/shopping_app/static/js/notes.js similarity index 100% rename from static/js/notes.js rename to shopping_app/static/js/notes.js diff --git a/static/js/preview_list_modal.js b/shopping_app/static/js/preview_list_modal.js similarity index 100% rename from static/js/preview_list_modal.js rename to shopping_app/static/js/preview_list_modal.js diff --git a/static/js/product_suggestion.js b/shopping_app/static/js/product_suggestion.js similarity index 100% rename from static/js/product_suggestion.js rename to shopping_app/static/js/product_suggestion.js diff --git a/static/js/receipt_analysis.js b/shopping_app/static/js/receipt_analysis.js similarity index 100% rename from static/js/receipt_analysis.js rename to shopping_app/static/js/receipt_analysis.js diff --git a/static/js/receipt_crop.js b/shopping_app/static/js/receipt_crop.js similarity index 100% rename from static/js/receipt_crop.js rename to shopping_app/static/js/receipt_crop.js diff --git a/static/js/receipt_crop_logic.js b/shopping_app/static/js/receipt_crop_logic.js similarity index 100% rename from static/js/receipt_crop_logic.js rename to shopping_app/static/js/receipt_crop_logic.js diff --git a/static/js/receipt_section.js b/shopping_app/static/js/receipt_section.js similarity index 100% rename from static/js/receipt_section.js rename to shopping_app/static/js/receipt_section.js diff --git a/static/js/receipt_upload.js b/shopping_app/static/js/receipt_upload.js similarity index 100% rename from static/js/receipt_upload.js rename to shopping_app/static/js/receipt_upload.js diff --git a/static/js/select.js b/shopping_app/static/js/select.js similarity index 100% rename from static/js/select.js rename to shopping_app/static/js/select.js diff --git a/static/js/select_all_table.js b/shopping_app/static/js/select_all_table.js similarity index 100% rename from static/js/select_all_table.js rename to shopping_app/static/js/select_all_table.js diff --git a/static/js/select_month.js b/shopping_app/static/js/select_month.js similarity index 100% rename from static/js/select_month.js rename to shopping_app/static/js/select_month.js diff --git a/static/js/show_all_expense.js b/shopping_app/static/js/show_all_expense.js similarity index 100% rename from static/js/show_all_expense.js rename to shopping_app/static/js/show_all_expense.js diff --git a/static/js/sockets.js b/shopping_app/static/js/sockets.js similarity index 100% rename from static/js/sockets.js rename to shopping_app/static/js/sockets.js diff --git a/static/js/sort_mode.js b/shopping_app/static/js/sort_mode.js similarity index 100% rename from static/js/sort_mode.js rename to shopping_app/static/js/sort_mode.js diff --git a/static/js/table_search.js b/shopping_app/static/js/table_search.js similarity index 100% rename from static/js/table_search.js rename to shopping_app/static/js/table_search.js diff --git a/static/js/toasts.js b/shopping_app/static/js/toasts.js similarity index 100% rename from static/js/toasts.js rename to shopping_app/static/js/toasts.js diff --git a/static/js/toggle_button.js b/shopping_app/static/js/toggle_button.js similarity index 100% rename from static/js/toggle_button.js rename to shopping_app/static/js/toggle_button.js diff --git a/static/js/user_management.js b/shopping_app/static/js/user_management.js similarity index 100% rename from static/js/user_management.js rename to shopping_app/static/js/user_management.js diff --git a/static/lib/css/bootstrap.min.css b/shopping_app/static/lib/css/bootstrap.min.css similarity index 100% rename from static/lib/css/bootstrap.min.css rename to shopping_app/static/lib/css/bootstrap.min.css diff --git a/static/lib/css/cropper.min.css b/shopping_app/static/lib/css/cropper.min.css similarity index 100% rename from static/lib/css/cropper.min.css rename to shopping_app/static/lib/css/cropper.min.css diff --git a/static/lib/css/glightbox.min.css b/shopping_app/static/lib/css/glightbox.min.css similarity index 100% rename from static/lib/css/glightbox.min.css rename to shopping_app/static/lib/css/glightbox.min.css diff --git a/static/lib/css/sort_table.min.css b/shopping_app/static/lib/css/sort_table.min.css similarity index 100% rename from static/lib/css/sort_table.min.css rename to shopping_app/static/lib/css/sort_table.min.css diff --git a/static/lib/css/tom-select.bootstrap5.min.css b/shopping_app/static/lib/css/tom-select.bootstrap5.min.css similarity index 100% rename from static/lib/css/tom-select.bootstrap5.min.css rename to shopping_app/static/lib/css/tom-select.bootstrap5.min.css diff --git a/static/lib/images/close.png b/shopping_app/static/lib/images/close.png similarity index 100% rename from static/lib/images/close.png rename to shopping_app/static/lib/images/close.png diff --git a/static/lib/images/loading.gif b/shopping_app/static/lib/images/loading.gif similarity index 100% rename from static/lib/images/loading.gif rename to shopping_app/static/lib/images/loading.gif diff --git a/static/lib/images/next.png b/shopping_app/static/lib/images/next.png similarity index 100% rename from static/lib/images/next.png rename to shopping_app/static/lib/images/next.png diff --git a/static/lib/images/prev.png b/shopping_app/static/lib/images/prev.png similarity index 100% rename from static/lib/images/prev.png rename to shopping_app/static/lib/images/prev.png diff --git a/static/lib/js/Sortable.min.js b/shopping_app/static/lib/js/Sortable.min.js similarity index 100% rename from static/lib/js/Sortable.min.js rename to shopping_app/static/lib/js/Sortable.min.js diff --git a/static/lib/js/bootstrap.bundle.min.js b/shopping_app/static/lib/js/bootstrap.bundle.min.js similarity index 100% rename from static/lib/js/bootstrap.bundle.min.js rename to shopping_app/static/lib/js/bootstrap.bundle.min.js diff --git a/static/lib/js/chart.js b/shopping_app/static/lib/js/chart.js similarity index 100% rename from static/lib/js/chart.js rename to shopping_app/static/lib/js/chart.js diff --git a/static/lib/js/cropper.min.js b/shopping_app/static/lib/js/cropper.min.js similarity index 100% rename from static/lib/js/cropper.min.js rename to shopping_app/static/lib/js/cropper.min.js diff --git a/static/lib/js/glightbox.min.js b/shopping_app/static/lib/js/glightbox.min.js similarity index 100% rename from static/lib/js/glightbox.min.js rename to shopping_app/static/lib/js/glightbox.min.js diff --git a/static/lib/js/socket.io.min.js b/shopping_app/static/lib/js/socket.io.min.js similarity index 100% rename from static/lib/js/socket.io.min.js rename to shopping_app/static/lib/js/socket.io.min.js diff --git a/static/lib/js/sort_table.min.js b/shopping_app/static/lib/js/sort_table.min.js similarity index 100% rename from static/lib/js/sort_table.min.js rename to shopping_app/static/lib/js/sort_table.min.js diff --git a/static/lib/js/tom-select.complete.min.js b/shopping_app/static/lib/js/tom-select.complete.min.js similarity index 100% rename from static/lib/js/tom-select.complete.min.js rename to shopping_app/static/lib/js/tom-select.complete.min.js diff --git a/templates/admin/admin_panel.html b/shopping_app/templates/admin/admin_panel.html similarity index 98% rename from templates/admin/admin_panel.html rename to shopping_app/templates/admin/admin_panel.html index 9dbca18..7df7c49 100644 --- a/templates/admin/admin_panel.html +++ b/shopping_app/templates/admin/admin_panel.html @@ -2,8 +2,11 @@ {% block title %}Panel administratora{% endblock %} {% block content %} -
-

⚙️ Panel administratora

+
+
+

⚙️ Panel administratora

+

Wgląd w użytkowników, listy, paragony, wydatki i ustawienia aplikacji.

+
← Powrót do strony głównej
diff --git a/templates/admin/edit_categories.html b/shopping_app/templates/admin/edit_categories.html similarity index 100% rename from templates/admin/edit_categories.html rename to shopping_app/templates/admin/edit_categories.html diff --git a/templates/admin/edit_list.html b/shopping_app/templates/admin/edit_list.html similarity index 100% rename from templates/admin/edit_list.html rename to shopping_app/templates/admin/edit_list.html diff --git a/templates/admin/list_products.html b/shopping_app/templates/admin/list_products.html similarity index 100% rename from templates/admin/list_products.html rename to shopping_app/templates/admin/list_products.html diff --git a/templates/admin/lists_access.html b/shopping_app/templates/admin/lists_access.html similarity index 100% rename from templates/admin/lists_access.html rename to shopping_app/templates/admin/lists_access.html diff --git a/templates/admin/receipts.html b/shopping_app/templates/admin/receipts.html similarity index 100% rename from templates/admin/receipts.html rename to shopping_app/templates/admin/receipts.html diff --git a/templates/admin/settings.html b/shopping_app/templates/admin/settings.html similarity index 100% rename from templates/admin/settings.html rename to shopping_app/templates/admin/settings.html diff --git a/templates/admin/user_management.html b/shopping_app/templates/admin/user_management.html similarity index 100% rename from templates/admin/user_management.html rename to shopping_app/templates/admin/user_management.html diff --git a/shopping_app/templates/base.html b/shopping_app/templates/base.html new file mode 100644 index 0000000..08b6cac --- /dev/null +++ b/shopping_app/templates/base.html @@ -0,0 +1,155 @@ + + + + + + {% block title %}Live Lista Zakupów{% endblock %} + + + + + + {% set exclude_paths = ['/system-auth'] %} + {% if (exclude_paths | select("in", request.path) | list | length == 0) + and has_authorized_cookie + and not is_blocked %} + + + {% endif %} + + {% set substrings_cropper = ['/admin/receipts', '/edit_my_list'] %} + {% if substrings_cropper | select("in", request.path) | list | length > 0 %} + + {% endif %} + + {% set substrings_tomselect = ['/edit_my_list', '/admin/edit_list', '/admin/edit_categories'] %} + {% if substrings_tomselect | select("in", request.path) | list | length > 0 %} + + {% endif %} + + + +
+ +
+ +
+ +
+
+ {% block before_content %}{% endblock %} +
+ {% block content %}{% endblock %} +
+
+
+ +
+ + + + + + {% if not is_blocked %} + + + {% if request.endpoint != 'system_auth' %} + + + + + + + {% endif %} + + + + + {% set substrings = ['/admin/receipts', '/edit_my_list'] %} + {% if substrings | select("in", request.path) | list | length > 0 %} + + {% endif %} + + {% set substrings = ['/edit_my_list', '/admin/edit_list', '/admin/edit_categories'] %} + {% if substrings | select("in", request.path) | list | length > 0 %} + + {% endif %} + {% endif %} + + {% block scripts %}{% endblock %} + + diff --git a/templates/edit_my_list.html b/shopping_app/templates/edit_my_list.html similarity index 97% rename from templates/edit_my_list.html rename to shopping_app/templates/edit_my_list.html index 3bbba0f..c156c2f 100644 --- a/templates/edit_my_list.html +++ b/shopping_app/templates/edit_my_list.html @@ -1,8 +1,11 @@ {% extends 'base.html' %} {% block content %} -
-

Edytuj listę: {{ list.title }}

+
+
+

Edytuj listę: {{ list.title }}

+

Zmień ustawienia, kategorię, ważność i udostępnianie listy.

+
← Powrót
diff --git a/templates/errors.html b/shopping_app/templates/errors.html similarity index 70% rename from templates/errors.html rename to shopping_app/templates/errors.html index 367abfe..fbc9513 100644 --- a/templates/errors.html +++ b/shopping_app/templates/errors.html @@ -2,9 +2,10 @@ {% block title %}Błąd {{ code }}{% endblock %} {% block content %} -
+

{{ code }} — {{ title }}

-
+

Nie udało się wyświetlić żądanej strony.

+
diff --git a/templates/expenses.html b/shopping_app/templates/expenses.html similarity index 97% rename from templates/expenses.html rename to shopping_app/templates/expenses.html index 119dfac..ab9acae 100644 --- a/templates/expenses.html +++ b/shopping_app/templates/expenses.html @@ -2,8 +2,11 @@ {% block title %}Wydatki z Twoich list{% endblock %} {% block content %} -
-

Statystyki wydatków

+
+
+

Statystyki wydatków

+

Analiza kosztów list w czasie, z podziałem na zakresy i kategorie.

+
← Powrót
diff --git a/templates/list.html b/shopping_app/templates/list.html similarity index 100% rename from templates/list.html rename to shopping_app/templates/list.html diff --git a/templates/list_share.html b/shopping_app/templates/list_share.html similarity index 100% rename from templates/list_share.html rename to shopping_app/templates/list_share.html diff --git a/templates/login.html b/shopping_app/templates/login.html similarity index 80% rename from templates/login.html rename to shopping_app/templates/login.html index 5623470..c4ebcbe 100644 --- a/templates/login.html +++ b/shopping_app/templates/login.html @@ -1,9 +1,10 @@ {% extends 'base.html' %} {% block title %}Logowanie{% endblock %} {% block content %} -
+

🔒 Logowanie

-
+

Zaloguj się, aby tworzyć, edytować i współdzielić listy zakupów.

+
diff --git a/templates/main.html b/shopping_app/templates/main.html similarity index 96% rename from templates/main.html rename to shopping_app/templates/main.html index a5261e2..5203e84 100644 --- a/templates/main.html +++ b/shopping_app/templates/main.html @@ -9,11 +9,15 @@ {% endif %} {% if current_user.is_authenticated %} -
-

Stwórz nową listę

-
- -
+
+
+
+
+
+

Twoje centrum list zakupowych

+

Twórz nowe listy, wracaj do aktywnych i zarządzaj archiwum w jednym miejscu.

+
+
@@ -27,8 +31,9 @@
+
-
+
{% endif %} {% set month_names = ["styczeń","luty","marzec","kwiecień","maj","czerwiec","lipiec","sierpień","wrzesień","październik","listopad","grudzień"] %} diff --git a/templates/system_auth.html b/shopping_app/templates/system_auth.html similarity index 81% rename from templates/system_auth.html rename to shopping_app/templates/system_auth.html index 4d55b03..af8b6c2 100644 --- a/templates/system_auth.html +++ b/shopping_app/templates/system_auth.html @@ -2,9 +2,10 @@ {% block title %}Wymagane hasło główne{% endblock %} {% block content %} -
+

🔑 Podaj hasło główne

-
+

Dostęp do aplikacji jest chroniony dodatkowym hasłem wejściowym.

+
diff --git a/shopping_app/uploads b/shopping_app/uploads new file mode 120000 index 0000000..1cca7b2 --- /dev/null +++ b/shopping_app/uploads @@ -0,0 +1 @@ +../uploads \ No newline at end of file diff --git a/shopping_app/web.py b/shopping_app/web.py new file mode 100644 index 0000000..cecbbd9 --- /dev/null +++ b/shopping_app/web.py @@ -0,0 +1,221 @@ +from .deps import * +from .app_setup import * +from .models import * +from .helpers import * + +@login_manager.user_loader +def load_user(user_id): + return db.session.get(User, int(user_id)) + + +@app.context_processor +def inject_version(): + return {"APP_VERSION": app.config["APP_VERSION"]} + + +@app.context_processor +def inject_time(): + return dict(time=time) + + +@app.context_processor +def inject_has_authorized_cookie(): + return {"has_authorized_cookie": "authorized" in request.cookies} + + +@app.context_processor +def inject_is_blocked(): + ip = request.access_route[0] + return {"is_blocked": is_ip_blocked(ip)} + + +@app.before_request +def require_system_password(): + endpoint = request.endpoint + + if endpoint in ( + "static_bp.serve_js", + "static_bp.serve_css", + "static_bp.serve_js_lib", + "static_bp.serve_css_lib", + "favicon", + "favicon_ico", + "uploaded_file", + ): + return + + if endpoint in ("system_auth", "healthcheck", "robots_txt"): + return + + ip = request.access_route[0] + if is_ip_blocked(ip): + abort(403) + + if endpoint is None: + return + + if "authorized" not in request.cookies and not endpoint.startswith("login"): + if request.path == "/": + return redirect(url_for("system_auth")) + + parsed = urlparse(request.url) + fixed_url = urlunparse(parsed._replace(netloc=request.host)) + return redirect(url_for("system_auth", next=fixed_url)) + + +@app.after_request +def apply_headers(response): + # Specjalny endpoint wykresów/API – zawsze no-cache + if request.path == "/expenses_data": + response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + return response + + # --- statyczne pliki (nagłówki z .env) --- + if request.path.startswith(("/static/", "/uploads/")): + response.headers.pop('Vary', None) # fix bug with backslash + response.headers['Vary'] = 'Accept-Encoding' + return response + + # --- healthcheck --- + if request.path == '/healthcheck': + response.headers['Cache-Control'] = 'no-store, no-cache' + response.headers.pop('ETag', None) + response.headers.pop('Vary', None) + return response + + # --- redirecty --- + if response.status_code in (301, 302, 303, 307, 308): + response.headers["Cache-Control"] = "no-store" + response.headers.pop("Vary", None) + return response + + # --- błędy 4xx --- + if 400 <= response.status_code < 500: + response.headers["Cache-Control"] = "no-store" + ct = (response.headers.get("Content-Type") or "").lower() + if "application/json" not in ct: + response.headers["Content-Type"] = "text/html; charset=utf-8" + response.headers.pop("Vary", None) + + # --- błędy 5xx --- + elif 500 <= response.status_code < 600: + response.headers["Cache-Control"] = "no-store" + ct = (response.headers.get("Content-Type") or "").lower() + if "application/json" not in ct: + response.headers["Content-Type"] = "text/html; charset=utf-8" + response.headers["Retry-After"] = "120" + response.headers.pop("Vary", None) + + # --- strony dynamiczne (domyślnie) --- + # Wszystko, co nie jest /static/ ani /uploads/ ma być no-store/no-cache + response.headers.setdefault("Cache-Control", "no-cache, no-store") + + return response + + +@app.before_request +def start_timer(): + g.start_time = time.time() + + +@app.after_request +def log_request(response): + if request.path == "/healthcheck": + return response + + ip = get_client_ip() + method = request.method + path = request.path + status = response.status_code + length = response.content_length or "-" + start = getattr(g, "start_time", None) + duration = round((time.time() - start) * 1000, 2) if start else "-" + agent = request.headers.get("User-Agent", "-") + + if status == 304: + app.logger.info( + f'REVALIDATED: {ip} - "{method} {path}" {status} {length} {duration}ms "{agent}"' + ) + else: + app.logger.info( + f'{ip} - "{method} {path}" {status} {length} {duration}ms "{agent}"' + ) + + app.logger.debug(f"Request headers: {dict(request.headers)}") + app.logger.debug(f"Response headers: {dict(response.headers)}") + return response + + +@app.template_filter("filemtime") +def file_mtime_filter(path): + try: + t = os.path.getmtime(path) + return datetime.fromtimestamp(t) + except Exception: + # return datetime.utcnow() + return datetime.now(timezone.utc) + + +@app.template_filter("todatetime") +def to_datetime_filter(s): + return datetime.strptime(s, "%Y-%m-%d") + + +@app.template_filter("filesizeformat") +def filesizeformat_filter(path): + try: + size = os.path.getsize(path) + for unit in ["B", "KB", "MB", "GB"]: + if size < 1024.0: + return f"{size:.1f} {unit}" + size /= 1024.0 + return f"{size:.1f} TB" + except Exception: + return "N/A" + + +@app.errorhandler(404) +def page_not_found(e): + return ( + render_template( + "errors.html", + code=404, + title="Strona nie znaleziona", + message="Ups! Podana strona nie istnieje lub została przeniesiona.", + ), + 404, + ) + + +@app.errorhandler(403) +def forbidden(e): + return ( + render_template( + "errors.html", + code=403, + title="Brak dostępu", + message=( + e.description + if e.description + else "Nie masz uprawnień do wyświetlenia tej strony." + ), + ), + 403, + ) + + +@app.route("/favicon.ico") +def favicon_ico(): + return redirect(url_for("static", filename="favicon.svg")) + + +@app.route("/favicon.svg") +def favicon(): + svg = """ + + 🛒 + + """ + return svg, 200, {"Content-Type": "image/svg+xml"} diff --git a/templates/base.html b/templates/base.html deleted file mode 100644 index 0b8f0b2..0000000 --- a/templates/base.html +++ /dev/null @@ -1,193 +0,0 @@ - - - - - - {% block title %}Live Lista Zakupów{% endblock %} - - - {# --- Bootstrap i główny css zawsze --- #} - - - - {# --- Style CSS ładowane tylko dla niezablokowanych --- #} - {% set exclude_paths = ['/system-auth'] %} - {% if (exclude_paths | select("in", request.path) | list | length == 0) - and has_authorized_cookie - and not is_blocked %} - - - {% endif %} - - {# --- Cropper CSS tylko dla wybranych podstron --- #} - {% set substrings_cropper = ['/admin/receipts', '/edit_my_list'] %} - {% if substrings_cropper | select("in", request.path) | list | length > 0 %} - - {% endif %} - - {# --- Tom Select CSS tylko dla wybranych podstron --- #} - {% set substrings_tomselect = ['/edit_my_list', '/admin/edit_list', '/admin/edit_categories'] %} - {% if substrings_tomselect | select("in", request.path) | list | length > 0 %} - - {% endif %} - - - - - - -
- {% block content %}{% endblock %} -
- -
- - - - - - {% if not is_blocked %} - - - {% if request.endpoint != 'system_auth' %} - - - - - - - {% endif %} - - - - - {% set substrings = ['/admin/receipts', '/edit_my_list'] %} - {% if substrings | select("in", request.path) | list | length > 0 %} - - {% endif %} - - {% set substrings = ['/edit_my_list', '/admin/edit_list', '/admin/edit_categories'] %} - {% if substrings | select("in", request.path) | list | length > 0 %} - - {% endif %} - - {% endif %} - - {% block scripts %}{% endblock %} - -