rewrite_code #12

Merged
gru merged 17 commits from rewrite_code into master 2026-03-26 12:00:47 +01:00
86 changed files with 5749 additions and 4930 deletions
Showing only changes of commit a16798553e - Show all commits

No files matched your search

-1
View File
@@ -1 +0,0 @@
deploy/app/Dockerfile
+34
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
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/`.
+2 -4718
View File
File diff suppressed because it is too large. Load diff
+11
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
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
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
File diff suppressed because it is too large. Load diff
+155
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"
File diff suppressed because it is too large. Load diff
+747
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)
+511
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
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)
@@ -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;
}
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.

Before

Width:  |  Height:  |  Size: 280 B

After

Width:  |  Height:  |  Size: 280 B

File renamed without changes.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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>
File renamed without changes.
File renamed without changes.
File renamed without changes.
+155
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>
@@ -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>
@@ -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">
@@ -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>
File renamed without changes.
File renamed without changes.
@@ -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">
@@ -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ń"] %}
@@ -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
View File
@@ -0,0 +1 @@
../uploads
+221
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"}
-193
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>