refactor part 1

This commit is contained in:
Mateusz Gruszczyński
2026-03-13 23:55:17 +01:00
parent e22c7e7dd2
commit a16798553e
86 changed files with 5749 additions and 4930 deletions

View File

@@ -1 +0,0 @@
deploy/app/Dockerfile

34
Dockerfile Normal file
View File

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

30
REFACTOR_NOTES.md Normal file
View File

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

4720
app.py

File diff suppressed because it is too large Load Diff

11
shopping_app/__init__.py Normal file
View File

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

109
shopping_app/app_setup.py Normal file
View File

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

39
shopping_app/deps.py Normal file
View File

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

1148
shopping_app/helpers.py Normal file

File diff suppressed because it is too large Load Diff

155
shopping_app/models.py Normal file
View File

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

1247
shopping_app/routes_admin.py Normal file

File diff suppressed because it is too large Load Diff

747
shopping_app/routes_main.py Normal file
View File

@@ -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/<int:list_id>", 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/<int:list_id>/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/<int:list_id>", 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/<int:list_id>", 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/<int:list_id>")
@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/<int:list_id>/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)

View File

@@ -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/<token>")
# @app.route("/guest-list/<int:list_id>")
@app.route("/shared/<int:list_id>")
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/<int:list_id>")
@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/<int:list_id>", 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/<filename>")
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/<int:receipt_id>")
@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/<int:receipt_id>")
@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/<int:list_id>/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)

513
shopping_app/sockets.py Normal file
View File

@@ -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('''
<!DOCTYPE html>
<html>
<head>
<title>Socket Debug</title>
<script src="{{ url_for('static_bp.serve_js_lib', filename='socket.io.min.js') }}?v={{ APP_VERSION }}"></script>
<style>
body { font-family: monospace; background: #1e1e1e; color: #fff; padding: 20px; }
#log { height: 400px; overflow-y: scroll; background: #2d2d2d; padding: 15px; border-radius: 8px; margin: 10px 0; white-space: pre-wrap; }
button { background: #007bff; color: white; border: none; padding: 10px 20px; margin: 5px; border-radius: 5px; cursor: pointer; }
button:hover { background: #0056b3; }
.status { font-size: 18px; font-weight: bold; margin: 10px 0; }
.connected { color: #28a745; }
.disconnected { color: #dc3545; }
</style>
</head>
<body>
<h1>Socket.IO Debug Tool</h1>
<div id="status" class="status disconnected">Rozlaczony</div>
<div id="info">
Transport: <span id="transport">-</span> |
Ping: <span id="ping">-</span>ms |
SID: <span id="sid">-</span>
</div>
<button onclick="connect()">Polacz</button>
<button onclick="disconnect()">Rozlacz</button>
<button onclick="emitTest()">Emit Test</button>
<button onclick="forcePolling()">Force Polling</button>
<h3>Logi:</h3>
<div id="log"></div>
<script>
let socket;
let logLines = 0;
let isPollingOnly = true;
function log(msg, color = '#fff') {
const logEl = document.getElementById('log');
const time = new Date().toLocaleTimeString();
logEl.innerHTML += `[${time}] ${msg}\n`;
logEl.scrollTop = logEl.scrollHeight;
logLines++;
if (logLines > 200) {
const lines = logEl.innerHTML.split('\\n');
logEl.innerHTML = lines.slice(-200).join('\\n');
logLines = 200;
}
}
function updateStatus(connected) {
const status = document.getElementById('status');
status.textContent = connected ? 'Polaczony' : 'Rozlaczony';
status.className = `status ${connected ? 'connected' : 'disconnected'}`;
}
function connect() {
if (socket) {
socket.disconnect();
socket = null;
}
const transports = isPollingOnly ? ['polling'] : ['polling', 'websocket'];
log(`Polaczenie z: ${transports.join(', ')}`);
socket = io('', {
transports: transports,
timeout: 20000,
autoConnect: false,
forceNew: true
});
socket.on('connect', function() {
log('CONNECTED OK');
updateStatus(true);
try {
const transport = socket.io.engine.transport.name;
document.getElementById('transport').textContent = transport;
document.getElementById('sid').textContent = socket.id.substring(0,8) + '...';
} catch(e) {
log('Transport info error: ' + e.message);
}
socket.emit('requestfulllist', {listid: 1});
});
socket.on('disconnect', function(reason) {
log('DISCONNECTED: ' + reason);
updateStatus(false);
});
socket.on('connect_error', function(err) {
log('CONNECT ERROR: ' + err.message + ' (' + (err.type || 'unknown') + ')');
});
socket.onAny(function(event, ...args) {
log('RECV ' + event + ': ' + JSON.stringify(args).substring(0,100));
});
socket.connect();
}
function disconnect() {
if (socket) {
socket.disconnect();
socket = null;
}
}
function emitTest() {
if (!socket || !socket.connected) {
log('Niepolaczony!');
return;
}
const now = Date.now();
socket.emit('pingtest', now);
log('SENT pingtest ' + now);
}
function forcePolling() {
isPollingOnly = !isPollingOnly;
log('Polling only: ' + isPollingOnly);
connect();
}
// STATUS check co 30s
setInterval(function() {
if (socket && socket.connected) {
const transport = socket.io.engine ? socket.io.engine.transport.name : 'unknown';
log('STATUS OK: ' + transport + ' | SID: ' + (socket.id ? socket.id.substring(0,8) : 'none'));
emitTest();
} else {
log('STATUS: Offline');
}
}, 30000);
// Start
connect();
</script>
</body>
</html>
''')
# =========================================================================================
# 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)

View File

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

View File

Before

Width:  |  Height:  |  Size: 280 B

After

Width:  |  Height:  |  Size: 280 B

View File

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -2,8 +2,11 @@
{% block title %}Panel administratora{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4">
<h2 class="mb-2">⚙️ Panel administratora</h2>
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<h2 class="mb-2">⚙️ Panel administratora</h2>
<p class="text-secondary mb-0">Wgląd w użytkowników, listy, paragony, wydatki i ustawienia aplikacji.</p>
</div>
<a href="{{ url_for('main_page') }}" class="btn btn-outline-secondary">← Powrót do strony głównej</a>
</div>

View File

@@ -0,0 +1,155 @@
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Live Lista Zakupów{% endblock %}</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('favicon') }}">
<link href="{{ url_for('static_bp.serve_css_lib', filename='bootstrap.min.css') }}?v={{ APP_VERSION }}" rel="stylesheet">
<link href="{{ url_for('static_bp.serve_css', filename='style.css') }}?v={{ APP_VERSION }}" rel="stylesheet">
{% set exclude_paths = ['/system-auth'] %}
{% if (exclude_paths | select("in", request.path) | list | length == 0)
and has_authorized_cookie
and not is_blocked %}
<link href="{{ url_for('static_bp.serve_css_lib', filename='glightbox.min.css') }}?v={{ APP_VERSION }}" rel="stylesheet">
<link href="{{ url_for('static_bp.serve_css_lib', filename='sort_table.min.css') }}?v={{ APP_VERSION }}" rel="stylesheet">
{% endif %}
{% set substrings_cropper = ['/admin/receipts', '/edit_my_list'] %}
{% if substrings_cropper | select("in", request.path) | list | length > 0 %}
<link href="{{ url_for('static_bp.serve_css_lib', filename='cropper.min.css') }}?v={{ APP_VERSION }}" rel="stylesheet">
{% endif %}
{% set substrings_tomselect = ['/edit_my_list', '/admin/edit_list', '/admin/edit_categories'] %}
{% if substrings_tomselect | select("in", request.path) | list | length > 0 %}
<link href="{{ url_for('static_bp.serve_css_lib', filename='tom-select.bootstrap5.min.css') }}?v={{ APP_VERSION }}" rel="stylesheet">
{% endif %}
</head>
<body class="app-body endpoint-{{ (request.endpoint or 'unknown')|replace('.', '-') }}{% if current_user.is_authenticated %} is-authenticated{% endif %}">
<div class="app-backdrop"></div>
<header class="app-header sticky-top">
<nav class="navbar navbar-expand-lg app-navbar">
<div class="container-xxl px-3 px-lg-4 gap-2">
<a class="navbar-brand app-brand" href="{{ url_for('main_page') }}">
<span class="app-brand__icon">🛒</span>
<span>
<span class="app-brand__title">Lista</span>
<span class="app-brand__accent">Zakupów</span>
</span>
</a>
<div class="app-navbar__meta order-lg-2 ms-auto ms-lg-0">
{% if has_authorized_cookie and not is_blocked %}
{% if current_user.is_authenticated %}
<div class="app-user-chip">
<span class="app-user-chip__label">Zalogowany</span>
<span class="badge rounded-pill text-bg-success">{{ current_user.username }}</span>
</div>
{% else %}
<div class="app-user-chip app-user-chip--guest">
<span class="app-user-chip__label">Tryb</span>
<span class="badge rounded-pill text-bg-info">gość</span>
</div>
{% endif %}
{% endif %}
</div>
<div class="app-navbar__actions order-lg-3">
{% if not is_blocked and request.endpoint and request.endpoint != 'system_auth' %}
{% if current_user.is_authenticated %}
{% if current_user.is_admin %}
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-light btn-sm">⚙️ <span class="d-none d-sm-inline">Panel</span></a>
{% endif %}
<a href="{{ url_for('expenses') }}" class="btn btn-outline-light btn-sm">📊 <span class="d-none d-sm-inline">Wydatki</span></a>
<a href="{{ url_for('logout') }}" class="btn btn-outline-light btn-sm">🚪 <span class="d-none d-sm-inline">Wyloguj</span></a>
{% else %}
<a href="{{ url_for('login') }}" class="btn btn-success btn-sm">🔑 <span class="d-none d-sm-inline">Zaloguj</span></a>
{% endif %}
{% endif %}
</div>
</div>
</nav>
</header>
<main class="app-main">
<div class="container-xxl px-2 px-md-3 px-xl-4">
{% block before_content %}{% endblock %}
<div class="app-content-frame">
{% block content %}{% endblock %}
</div>
</div>
</main>
<div id="toast-container" class="toast-container position-fixed bottom-0 end-0 p-3"></div>
<footer class="app-footer text-center text-secondary small">
<div class="container-xxl px-3 px-lg-4">
<div class="app-footer__inner">
<p class="mb-1">© 2025 <strong>linuxiarz.pl</strong></p>
<p class="mb-1">
<a href="https://git.linuxiarz.pl/gru/lista_zakupowa_live" target="_blank" class="link-success text-decoration-none">
source code
</a>
</p>
<div class="small">v{{ APP_VERSION }}</div>
</div>
</div>
</footer>
<script src="{{ url_for('static_bp.serve_js_lib', filename='bootstrap.bundle.min.js') }}"></script>
{% if not is_blocked %}
<script>
document.addEventListener('DOMContentLoaded', function () {
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
tooltipTriggerList.forEach(function (el) {
new bootstrap.Tooltip(el);
});
{% with messages = get_flashed_messages(with_categories = true) %}
{% for category, message in messages %}
{% set cat = 'info' if not category else ('danger' if category == 'error' else category) %}
{% if message == 'Please log in to access this page.' %}
showToast("Aby uzyskać dostęp do tej strony, musisz być zalogowany.", "danger");
{% else %}
showToast({{ message|tojson }}, "{{ cat }}");
{% endif %}
{% endfor %}
{% endwith %}
});
</script>
{% if request.endpoint != 'system_auth' %}
<script src="{{ url_for('static_bp.serve_js_lib', filename='glightbox.min.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js_lib', filename='socket.io.min.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js_lib', filename='sort_table.min.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='functions.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='live.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='sockets.js') }}?v={{ APP_VERSION }}"></script>
{% endif %}
<script src="{{ url_for('static_bp.serve_js', filename='toasts.js') }}?v={{ APP_VERSION }}"></script>
<script>
if (typeof GLightbox === 'function') {
let lightbox = GLightbox({ selector: '.glightbox' });
}
</script>
{% set substrings = ['/admin/receipts', '/edit_my_list'] %}
{% if substrings | select("in", request.path) | list | length > 0 %}
<script src="{{ url_for('static_bp.serve_js_lib', filename='cropper.min.js') }}?v={{ APP_VERSION }}"></script>
{% endif %}
{% set substrings = ['/edit_my_list', '/admin/edit_list', '/admin/edit_categories'] %}
{% if substrings | select("in", request.path) | list | length > 0 %}
<script src="{{ url_for('static_bp.serve_js_lib', filename='tom-select.complete.min.js') }}?v={{ APP_VERSION }}"></script>
{% endif %}
{% endif %}
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -1,8 +1,11 @@
{% extends 'base.html' %}
{% block content %}
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4">
<h2>Edytuj listę: <strong>{{ list.title }}</strong></h2>
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<h2 class="mb-2">Edytuj listę: <strong>{{ list.title }}</strong></h2>
<p class="text-secondary mb-0">Zmień ustawienia, kategorię, ważność i udostępnianie listy.</p>
</div>
<a href="{{ url_for('main_page') }}" class="btn btn-outline-secondary">← Powrót</a>
</div>

View File

@@ -2,9 +2,10 @@
{% block title %}Błąd {{ code }}{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4">
<section class="text-center mb-4">
<h2 class="mb-2">{{ code }} — {{ title }}</h2>
</div>
<p class="text-secondary mb-0">Nie udało się wyświetlić żądanej strony.</p>
</section>
<div class="card bg-dark text-white">
<div class="card-body">

View File

@@ -2,8 +2,11 @@
{% block title %}Wydatki z Twoich list{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4">
<h2 class="mb-2">Statystyki wydatków</h2>
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<h2 class="mb-2">Statystyki wydatków</h2>
<p class="text-secondary mb-0">Analiza kosztów list w czasie, z podziałem na zakresy i kategorie.</p>
</div>
<a href="{{ url_for('main_page') }}" class="btn btn-outline-secondary">← Powrót</a>
</div>

View File

@@ -1,9 +1,10 @@
{% extends 'base.html' %}
{% block title %}Logowanie{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4">
<section class="text-center mb-4">
<h2 class="mb-2">🔒 Logowanie</h2>
</div>
<p class="text-secondary mb-0">Zaloguj się, aby tworzyć, edytować i współdzielić listy zakupów.</p>
</section>
<div class="card bg-dark text-white">
<div class="card-body">

View File

@@ -9,11 +9,15 @@
{% endif %}
{% if current_user.is_authenticated %}
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4">
<h2 class="mb-2">Stwórz nową listę</h2>
</div>
<div class="card bg-secondary bg-opacity-10 text-white mb-4">
<section class="mb-4">
<div class="card bg-secondary bg-opacity-10 text-white mb-4">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-3">
<div>
<h2 class="mb-2">Twoje centrum list zakupowych</h2>
<p class="text-secondary mb-0">Twórz nowe listy, wracaj do aktywnych i zarządzaj archiwum w jednym miejscu.</p>
</div>
</div>
<div class="card-body">
<form action="{{ url_for('create_list') }}" method="post">
<div class="input-group mb-3">
@@ -27,8 +31,9 @@
</div>
<button type="submit" class="btn btn-success w-100"> Utwórz nową listę</button>
</form>
</div>
</div>
</div>
</section>
{% endif %}
{% set month_names = ["styczeń","luty","marzec","kwiecień","maj","czerwiec","lipiec","sierpień","wrzesień","październik","listopad","grudzień"] %}

View File

@@ -2,9 +2,10 @@
{% block title %}Wymagane hasło główne{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4">
<section class="text-center mb-4">
<h2 class="mb-2">🔑 Podaj hasło główne</h2>
</div>
<p class="text-secondary mb-0">Dostęp do aplikacji jest chroniony dodatkowym hasłem wejściowym.</p>
</section>
<div class="card bg-dark text-white">
<div class="card-body">

1
shopping_app/uploads Symbolic link
View File

@@ -0,0 +1 @@
../uploads

221
shopping_app/web.py Normal file
View File

@@ -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 = """
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<text y="14" font-size="16">🛒</text>
</svg>
"""
return svg, 200, {"Content-Type": "image/svg+xml"}

View File

@@ -1,193 +0,0 @@
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Live Lista Zakupów{% endblock %}</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('favicon') }}">
{# --- Bootstrap i główny css zawsze --- #}
<link href="{{ url_for('static_bp.serve_css', filename='style.css') }}?v={{ APP_VERSION }}" rel="stylesheet">
<link href="{{ url_for('static_bp.serve_css_lib', filename='bootstrap.min.css') }}?v={{ APP_VERSION }}" rel="stylesheet">
{# --- 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 %}
<link href="{{ url_for('static_bp.serve_css_lib', filename='glightbox.min.css') }}?v={{ APP_VERSION }}" rel="stylesheet">
<link href="{{ url_for('static_bp.serve_css_lib', filename='sort_table.min.css') }}?v={{ APP_VERSION }}" rel="stylesheet">
{% 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 %}
<link href="{{ url_for('static_bp.serve_css_lib', filename='cropper.min.css') }}?v={{ APP_VERSION }}" rel="stylesheet">
{% 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 %}
<link href="{{ url_for('static_bp.serve_css_lib', filename='tom-select.bootstrap5.min.css') }}?v={{ APP_VERSION }}" rel="stylesheet">
{% endif %}
</head>
<body class="bg-dark text-white">
<nav class="navbar navbar-dark bg-dark mb-3">
<div class="container-fluid">
<a class="navbar-brand navbar-brand-compact fw-bold fs-4 text-success" href="/">
🛒 <span class="text-warning navbar-brand-text">Lista</span> <span class="navbar-brand-text">Zakupów</span>
</a>
{% if has_authorized_cookie and not is_blocked %}
{% if current_user.is_authenticated %}
<!-- Desktop/tablet: "Zalogowany:" -->
<div class="d-none d-sm-flex justify-content-center align-items-center text-white small flex-wrap text-center user-info-compact">
<span class="me-1">Zalogowany:</span>
<span class="badge rounded-pill bg-success">{{ current_user.username }}</span>
</div>
<!-- Mobile: 👤 zamiast "Zalogowany:" -->
<div class="d-flex d-sm-none justify-content-center align-items-center text-white small flex-wrap text-center user-info-compact">
<span class="me-1" aria-label="Zalogowany">👤</span>
<span class="badge rounded-pill bg-success">{{ current_user.username }}</span>
</div>
{% else %}
<!-- Desktop/tablet: tekst -->
<div class="d-none d-sm-flex justify-content-center align-items-center text-white small flex-wrap text-center user-info-compact">
<span class="me-1 user-info-label">Przeglądasz jako</span>
<span class="badge rounded-pill bg-info">niezalogowany/a</span>
</div>
<!-- Mobile: ikonka zamiast tekstu -->
<div class="d-flex d-sm-none justify-content-center align-items-center text-white small flex-wrap text-center user-info-compact">
<span class="me-1" aria-label="Niezalogowany">👥</span>
<span class="badge rounded-pill bg-info">gość</span>
</div>
{% endif %}
{% endif %}
{% if not is_blocked and request.endpoint and request.endpoint != 'system_auth' %}
{% if current_user.is_authenticated %}
<!-- Desktop/tablet: bez tooltipów -->
<div class="d-none d-sm-flex align-items-center gap-2 flex-wrap nav-buttons-compact">
{% if current_user.is_admin %}
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-light btn-sm">
⚙️<span class="nav-btn-text ms-1">Panel</span>
</a>
{% endif %}
<a href="{{ url_for('expenses') }}" class="btn btn-outline-light btn-sm">
📊<span class="nav-btn-text ms-1">Wydatki</span>
</a>
<a href="{{ url_for('logout') }}" class="btn btn-outline-light btn-sm">
🚪<span class="nav-btn-text ms-1">Wyloguj</span>
</a>
</div>
<!-- Mobile: tooltipy (bo tekst przycisków znika CSS-em) -->
<div class="d-flex d-sm-none align-items-center gap-2 flex-wrap nav-buttons-compact">
{% if current_user.is_admin %}
<a href="{{ url_for('admin_panel') }}"
class="btn btn-outline-light btn-sm"
data-bs-toggle="tooltip" title="Panel admina">
⚙️<span class="nav-btn-text ms-1">Panel</span>
</a>
{% endif %}
<a href="{{ url_for('expenses') }}"
class="btn btn-outline-light btn-sm"
data-bs-toggle="tooltip" title="Wydatki">
📊<span class="nav-btn-text ms-1">Wydatki</span>
</a>
<a href="{{ url_for('logout') }}"
class="btn btn-outline-light btn-sm"
data-bs-toggle="tooltip" title="Wyloguj">
🚪<span class="nav-btn-text ms-1">Wyloguj</span>
</a>
</div>
{% else %}
<div class="d-flex align-items-center gap-2 flex-wrap nav-buttons-compact">
<a href="{{ url_for('login') }}" class="btn btn-outline-light btn-sm">🔑 Zaloguj</a>
</div>
{% endif %}
{% endif %}
</div>
</nav>
<div class="container px-2">
{% block content %}{% endblock %}
</div>
<div id="toast-container" class="toast-container position-fixed bottom-0 end-0 p-3"></div>
<footer class="text-center text-secondary small mt-5 mb-3">
<hr class="text-secondary">
<p class="mb-0">© 2025 <strong>linuxiarz.pl</strong> ·
<a href="https://git.linuxiarz.pl/gru/lista_zakupowa_live" target="_blank" class="link-success text-decoration-none">
source code
</a>
</p>
<div class="small">v{{ APP_VERSION }}</div>
</footer>
<script src="{{ url_for('static_bp.serve_js_lib', filename='bootstrap.bundle.min.js') }}"></script>
{% if not is_blocked %}
<script>
document.addEventListener('DOMContentLoaded', function () {
// Tooltips tylko na mobile (bo tylko tam dodajemy data-bs-toggle="tooltip")
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
tooltipTriggerList.forEach(function (el) {
new bootstrap.Tooltip(el);
});
{% with messages = get_flashed_messages(with_categories = true) %}
{% for category, message in messages %}
{% set cat = 'info' if not category else ('danger' if category == 'error' else category) %}
{% if message == 'Please log in to access this page.' %}
showToast("Aby uzyskać dostęp do tej strony, musisz być zalogowany.", "danger");
{% else %}
showToast({{ message|tojson }}, "{{ cat }}");
{% endif %}
{% endfor %}
{% endwith %}
});
</script>
{% if request.endpoint != 'system_auth' %}
<script src="{{ url_for('static_bp.serve_js_lib', filename='glightbox.min.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js_lib', filename='socket.io.min.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js_lib', filename='sort_table.min.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='functions.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='live.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='sockets.js') }}?v={{ APP_VERSION }}"></script>
{% endif %}
<script src="{{ url_for('static_bp.serve_js', filename='toasts.js') }}?v={{ APP_VERSION }}"></script>
<script>
let lightbox = GLightbox({ selector: '.glightbox' });
</script>
{% set substrings = ['/admin/receipts', '/edit_my_list'] %}
{% if substrings | select("in", request.path) | list | length > 0 %}
<script src="{{ url_for('static_bp.serve_js_lib', filename='cropper.min.js') }}?v={{ APP_VERSION }}"></script>
{% endif %}
{% set substrings = ['/edit_my_list', '/admin/edit_list', '/admin/edit_categories'] %}
{% if substrings | select("in", request.path) | list | length > 0 %}
<script src="{{ url_for('static_bp.serve_js_lib', filename='tom-select.complete.min.js') }}?v={{ APP_VERSION }}"></script>
{% endif %}
{% endif %}
{% block scripts %}{% endblock %}
</body>
</html>