refactor part 1
@@ -1 +0,0 @@
|
||||
deploy/app/Dockerfile
|
||||
34
Dockerfile
Normal file
@@ -0,0 +1,34 @@
|
||||
FROM python:3.14-trixie
|
||||
#FROM python:3.13-slim
|
||||
WORKDIR /app
|
||||
|
||||
# Zależności systemowe do OCR, obrazów, tesseract i języka PL
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
tesseract-ocr \
|
||||
tesseract-ocr-pol \
|
||||
libglib2.0-0 \
|
||||
libsm6 \
|
||||
libxrender1 \
|
||||
libxext6 \
|
||||
poppler-utils \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Kopiujemy wymagania
|
||||
COPY requirements.txt requirements.txt
|
||||
|
||||
# Instalujemy zależności
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Kopiujemy resztę aplikacji
|
||||
COPY . .
|
||||
|
||||
# Kopiujemy entrypoint i ustawiamy uprawnienia
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
# Otwieramy port
|
||||
#EXPOSE 8000
|
||||
|
||||
# Ustawiamy entrypoint
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
30
REFACTOR_NOTES.md
Normal file
@@ -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/`.
|
||||
11
shopping_app/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from .app_setup import app, db, socketio, login_manager, APP_PORT, DEBUG_MODE, static_bp
|
||||
from . import models # noqa: F401
|
||||
from . import helpers # noqa: F401
|
||||
app.register_blueprint(static_bp)
|
||||
from . import web # noqa: F401
|
||||
from . import routes_main # noqa: F401
|
||||
from . import routes_secondary # noqa: F401
|
||||
from . import routes_admin # noqa: F401
|
||||
from . import sockets # noqa: F401
|
||||
|
||||
__all__ = ["app", "db", "socketio", "login_manager", "APP_PORT", "DEBUG_MODE"]
|
||||
109
shopping_app/app_setup.py
Normal file
@@ -0,0 +1,109 @@
|
||||
from .deps import *
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(Config)
|
||||
|
||||
csp_policy = (
|
||||
{
|
||||
"default-src": "'self'",
|
||||
"script-src": "'self' 'unsafe-inline'",
|
||||
"style-src": "'self' 'unsafe-inline'",
|
||||
"img-src": "'self' data:",
|
||||
"connect-src": "'self'",
|
||||
}
|
||||
if app.config.get("ENABLE_CSP", True)
|
||||
else None
|
||||
)
|
||||
|
||||
permissions_policy = {"browsing-topics": "()"} if app.config.get("ENABLE_PP") else None
|
||||
|
||||
talisman_kwargs = {
|
||||
"force_https": False,
|
||||
"strict_transport_security": app.config.get("ENABLE_HSTS", True),
|
||||
"frame_options": "DENY" if app.config.get("ENABLE_XFO", True) else None,
|
||||
"permissions_policy": permissions_policy,
|
||||
"content_security_policy": csp_policy,
|
||||
"x_content_type_options": app.config.get("ENABLE_XCTO", True),
|
||||
"strict_transport_security_include_subdomains": False,
|
||||
}
|
||||
|
||||
referrer_policy = app.config.get("REFERRER_POLICY")
|
||||
if referrer_policy:
|
||||
talisman_kwargs["referrer_policy"] = referrer_policy
|
||||
|
||||
effective_headers = {
|
||||
k: v
|
||||
for k, v in talisman_kwargs.items()
|
||||
if k != "referrer_policy" and v not in (None, False)
|
||||
}
|
||||
|
||||
if effective_headers:
|
||||
from flask_talisman import Talisman
|
||||
|
||||
talisman = Talisman(
|
||||
app,
|
||||
session_cookie_secure=app.config.get("SESSION_COOKIE_SECURE", True),
|
||||
**talisman_kwargs,
|
||||
)
|
||||
print("[TALISMAN] Włączony z nagłówkami:", list(effective_headers.keys()))
|
||||
else:
|
||||
print("[TALISMAN] Pominięty — wszystkie nagłówki security wyłączone.")
|
||||
|
||||
register_heif_opener()
|
||||
SQLALCHEMY_ECHO = True
|
||||
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp", "heic", "pdf"}
|
||||
SYSTEM_PASSWORD = app.config.get("SYSTEM_PASSWORD")
|
||||
DEFAULT_ADMIN_USERNAME = app.config.get("DEFAULT_ADMIN_USERNAME")
|
||||
DEFAULT_ADMIN_PASSWORD = app.config.get("DEFAULT_ADMIN_PASSWORD")
|
||||
UPLOAD_FOLDER = app.config.get("UPLOAD_FOLDER")
|
||||
AUTHORIZED_COOKIE_VALUE = app.config.get("AUTHORIZED_COOKIE_VALUE")
|
||||
AUTH_COOKIE_MAX_AGE = app.config.get("AUTH_COOKIE_MAX_AGE")
|
||||
HEALTHCHECK_TOKEN = app.config.get("HEALTHCHECK_TOKEN")
|
||||
SESSION_TIMEOUT_MINUTES = int(app.config.get("SESSION_TIMEOUT_MINUTES"))
|
||||
SESSION_COOKIE_SECURE = app.config.get("SESSION_COOKIE_SECURE")
|
||||
APP_PORT = int(app.config.get("APP_PORT"))
|
||||
app.config["COMPRESS_ALGORITHM"] = ["zstd", "br", "gzip", "deflate"]
|
||||
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(minutes=SESSION_TIMEOUT_MINUTES)
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
|
||||
DEBUG_MODE = app.config.get("DEBUG_MODE", False)
|
||||
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
|
||||
failed_login_attempts = defaultdict(deque)
|
||||
MAX_ATTEMPTS = 10
|
||||
TIME_WINDOW = 60 * 60
|
||||
WEBP_SAVE_PARAMS = {
|
||||
"format": "WEBP",
|
||||
"lossless": False,
|
||||
"method": 6,
|
||||
"quality": 95,
|
||||
}
|
||||
|
||||
def read_commit(filename="version.txt", root_path=None):
|
||||
base = root_path or os.path.dirname(os.path.abspath(__file__))
|
||||
path = os.path.join(base, filename)
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
try:
|
||||
commit = open(path, "r", encoding="utf-8").read().strip()
|
||||
return commit[:12] if commit else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
commit = read_commit("version.txt", root_path=os.path.dirname(os.path.dirname(__file__))) or "dev"
|
||||
APP_VERSION = commit
|
||||
app.config["APP_VERSION"] = APP_VERSION
|
||||
db = SQLAlchemy(app)
|
||||
socketio = SocketIO(app, async_mode="gevent")
|
||||
login_manager = LoginManager(app)
|
||||
login_manager.login_view = "login"
|
||||
app.config["SESSION_TYPE"] = "sqlalchemy"
|
||||
app.config["SESSION_SQLALCHEMY"] = db
|
||||
Session(app)
|
||||
compress = Compress()
|
||||
compress.init_app(app)
|
||||
static_bp = Blueprint("static_bp", __name__)
|
||||
active_users = {}
|
||||
|
||||
def utcnow():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
app_start_time = utcnow()
|
||||
39
shopping_app/deps.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
import mimetypes
|
||||
import sys
|
||||
import platform
|
||||
import psutil
|
||||
import hashlib
|
||||
import re
|
||||
import traceback
|
||||
import bcrypt
|
||||
import colorsys
|
||||
from pillow_heif import register_heif_opener
|
||||
from datetime import datetime, timedelta, UTC, timezone
|
||||
from urllib.parse import urlparse, urlunparse, urlencode
|
||||
from flask import (
|
||||
Flask, render_template, redirect, url_for, request, flash, Blueprint,
|
||||
send_from_directory, abort, session, jsonify, g, render_template_string
|
||||
)
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_login import (
|
||||
LoginManager, UserMixin, login_user, login_required, logout_user, current_user
|
||||
)
|
||||
from flask_compress import Compress
|
||||
from flask_socketio import SocketIO, emit, join_room
|
||||
from config import Config
|
||||
from PIL import Image, ExifTags, ImageFilter, ImageOps
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
from sqlalchemy import func, extract, inspect, or_, case, text, and_, literal
|
||||
from sqlalchemy.orm import joinedload, load_only, aliased
|
||||
from collections import defaultdict, deque
|
||||
from functools import wraps
|
||||
from flask_session import Session
|
||||
from types import SimpleNamespace
|
||||
from pdf2image import convert_from_bytes
|
||||
from typing import Sequence, Any
|
||||
import pytesseract
|
||||
from pytesseract import Output
|
||||
import logging
|
||||
1148
shopping_app/helpers.py
Normal file
155
shopping_app/models.py
Normal file
@@ -0,0 +1,155 @@
|
||||
from .deps import *
|
||||
from .app_setup import db, utcnow
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(150), unique=True, nullable=False)
|
||||
password_hash = db.Column(db.String(512), nullable=False)
|
||||
is_admin = db.Column(db.Boolean, default=False)
|
||||
|
||||
|
||||
# Tabela pośrednia
|
||||
shopping_list_category = db.Table(
|
||||
"shopping_list_category",
|
||||
db.Column(
|
||||
"shopping_list_id",
|
||||
db.Integer,
|
||||
db.ForeignKey("shopping_list.id"),
|
||||
primary_key=True,
|
||||
),
|
||||
db.Column(
|
||||
"category_id", db.Integer, db.ForeignKey("category.id"), primary_key=True
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class Category(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), unique=True, nullable=False)
|
||||
|
||||
|
||||
class ShoppingList(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
title = db.Column(db.String(150), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
owner_id = db.Column(db.Integer, db.ForeignKey("user.id"))
|
||||
owner = db.relationship("User", backref="lists", foreign_keys=[owner_id])
|
||||
|
||||
is_temporary = db.Column(db.Boolean, default=False)
|
||||
share_token = db.Column(db.String(64), unique=True, nullable=True)
|
||||
expires_at = db.Column(db.DateTime(timezone=True), nullable=True)
|
||||
owner = db.relationship("User", backref="lists", lazy=True)
|
||||
is_archived = db.Column(db.Boolean, default=False)
|
||||
is_public = db.Column(db.Boolean, default=False)
|
||||
|
||||
# Relacje
|
||||
items = db.relationship("Item", back_populates="shopping_list", lazy="select")
|
||||
receipts = db.relationship(
|
||||
"Receipt",
|
||||
back_populates="shopping_list",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="select",
|
||||
)
|
||||
expenses = db.relationship("Expense", back_populates="shopping_list", lazy="select")
|
||||
categories = db.relationship(
|
||||
"Category",
|
||||
secondary=shopping_list_category,
|
||||
backref=db.backref("shopping_lists", lazy="dynamic"),
|
||||
)
|
||||
|
||||
|
||||
class Item(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
list_id = db.Column(db.Integer, db.ForeignKey("shopping_list.id"))
|
||||
name = db.Column(db.String(150), nullable=False)
|
||||
# added_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
added_at = db.Column(db.DateTime, default=utcnow)
|
||||
added_by = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
|
||||
added_by_user = db.relationship(
|
||||
"User", backref="added_items", lazy="joined", foreign_keys=[added_by]
|
||||
)
|
||||
|
||||
purchased = db.Column(db.Boolean, default=False)
|
||||
purchased_at = db.Column(db.DateTime, nullable=True)
|
||||
quantity = db.Column(db.Integer, default=1)
|
||||
note = db.Column(db.Text, nullable=True)
|
||||
not_purchased = db.Column(db.Boolean, default=False)
|
||||
not_purchased_reason = db.Column(db.Text, nullable=True)
|
||||
position = db.Column(db.Integer, default=0)
|
||||
|
||||
shopping_list = db.relationship("ShoppingList", back_populates="items")
|
||||
|
||||
|
||||
class SuggestedProduct(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(150), unique=True, nullable=False)
|
||||
usage_count = db.Column(db.Integer, default=0)
|
||||
|
||||
|
||||
class Expense(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
list_id = db.Column(db.Integer, db.ForeignKey("shopping_list.id"))
|
||||
amount = db.Column(db.Float, nullable=False)
|
||||
added_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
receipt_filename = db.Column(db.String(255), nullable=True)
|
||||
|
||||
shopping_list = db.relationship("ShoppingList", back_populates="expenses")
|
||||
|
||||
|
||||
class Receipt(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
list_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey("shopping_list.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
filename = db.Column(db.String(255), nullable=False)
|
||||
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
filesize = db.Column(db.Integer, nullable=True)
|
||||
file_hash = db.Column(db.String(64), nullable=True, unique=True)
|
||||
uploaded_by = db.Column(db.Integer, db.ForeignKey("user.id"))
|
||||
version_token = db.Column(db.String(32), nullable=True)
|
||||
|
||||
shopping_list = db.relationship("ShoppingList", back_populates="receipts")
|
||||
uploaded_by_user = db.relationship("User", backref="uploaded_receipts")
|
||||
|
||||
|
||||
class ListPermission(db.Model):
|
||||
__tablename__ = "list_permission"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
list_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey("shopping_list.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
user_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey("user.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
__table_args__ = (db.UniqueConstraint("list_id", "user_id", name="uq_list_user"),)
|
||||
|
||||
|
||||
ShoppingList.permitted_users = db.relationship(
|
||||
"User",
|
||||
secondary="list_permission",
|
||||
backref=db.backref("permitted_lists", lazy="dynamic"),
|
||||
lazy="dynamic",
|
||||
)
|
||||
|
||||
|
||||
class AppSetting(db.Model):
|
||||
key = db.Column(db.String(64), primary_key=True)
|
||||
value = db.Column(db.Text, nullable=True)
|
||||
|
||||
|
||||
class CategoryColorOverride(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
category_id = db.Column(
|
||||
db.Integer, db.ForeignKey("category.id"), unique=True, nullable=False
|
||||
)
|
||||
color_hex = db.Column(db.String(7), nullable=False) # "#rrggbb"
|
||||
|
||||
|
||||
1247
shopping_app/routes_admin.py
Normal file
747
shopping_app/routes_main.py
Normal 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
shopping_app/routes_secondary.py
Normal file
@@ -0,0 +1,511 @@
|
||||
from .deps import *
|
||||
from .app_setup import *
|
||||
from .models import *
|
||||
from .helpers import *
|
||||
|
||||
@app.route("/expenses")
|
||||
@login_required
|
||||
def expenses():
|
||||
start_date_str = request.args.get("start_date")
|
||||
end_date_str = request.args.get("end_date")
|
||||
category_id = request.args.get("category_id", type=str)
|
||||
show_all = request.args.get("show_all", "true").lower() == "true"
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
visible_clause = visible_lists_clause_for_expenses(
|
||||
user_id=current_user.id, include_shared=show_all, now_dt=now
|
||||
)
|
||||
|
||||
lists_q = ShoppingList.query.filter(*visible_clause)
|
||||
|
||||
if start_date_str and end_date_str:
|
||||
try:
|
||||
start = datetime.strptime(start_date_str, "%Y-%m-%d")
|
||||
end = datetime.strptime(end_date_str, "%Y-%m-%d") + timedelta(days=1)
|
||||
lists_q = lists_q.filter(
|
||||
ShoppingList.created_at >= start,
|
||||
ShoppingList.created_at < end,
|
||||
)
|
||||
except ValueError:
|
||||
flash("Błędny zakres dat", "danger")
|
||||
|
||||
if category_id:
|
||||
if category_id == "none":
|
||||
lists_q = lists_q.filter(~ShoppingList.categories.any())
|
||||
else:
|
||||
try:
|
||||
cid = int(category_id)
|
||||
lists_q = lists_q.join(
|
||||
shopping_list_category,
|
||||
shopping_list_category.c.shopping_list_id == ShoppingList.id,
|
||||
).filter(shopping_list_category.c.category_id == cid)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
lists_filtered = (
|
||||
lists_q.options(
|
||||
joinedload(ShoppingList.owner), joinedload(ShoppingList.categories)
|
||||
)
|
||||
.order_by(ShoppingList.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
list_ids = [l.id for l in lists_filtered] or [-1]
|
||||
|
||||
expenses = (
|
||||
Expense.query.options(
|
||||
joinedload(Expense.shopping_list).joinedload(ShoppingList.owner),
|
||||
joinedload(Expense.shopping_list).joinedload(ShoppingList.categories),
|
||||
)
|
||||
.filter(Expense.list_id.in_(list_ids))
|
||||
.order_by(Expense.added_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
totals_rows = (
|
||||
db.session.query(
|
||||
ShoppingList.id.label("lid"),
|
||||
func.coalesce(func.sum(Expense.amount), 0).label("total_expense"),
|
||||
)
|
||||
.select_from(ShoppingList)
|
||||
.filter(ShoppingList.id.in_(list_ids))
|
||||
.outerjoin(Expense, Expense.list_id == ShoppingList.id)
|
||||
.group_by(ShoppingList.id)
|
||||
.all()
|
||||
)
|
||||
totals_map = {row.lid: float(row.total_expense or 0) for row in totals_rows}
|
||||
|
||||
categories = (
|
||||
Category.query.join(
|
||||
shopping_list_category, shopping_list_category.c.category_id == Category.id
|
||||
)
|
||||
.join(
|
||||
ShoppingList, ShoppingList.id == shopping_list_category.c.shopping_list_id
|
||||
)
|
||||
.filter(ShoppingList.id.in_(list_ids))
|
||||
.distinct()
|
||||
.order_by(Category.name.asc())
|
||||
.all()
|
||||
)
|
||||
categories.append(SimpleNamespace(id="none", name="Bez kategorii"))
|
||||
|
||||
expense_table = [
|
||||
{
|
||||
"title": (e.shopping_list.title if e.shopping_list else "Nieznana"),
|
||||
"amount": e.amount,
|
||||
"added_at": e.added_at,
|
||||
}
|
||||
for e in expenses
|
||||
]
|
||||
|
||||
lists_data = [
|
||||
{
|
||||
"id": l.id,
|
||||
"title": l.title,
|
||||
"created_at": l.created_at,
|
||||
"total_expense": totals_map.get(l.id, 0.0),
|
||||
"owner_username": l.owner.username if l.owner else "?",
|
||||
"categories": [c.id for c in l.categories],
|
||||
}
|
||||
for l in lists_filtered
|
||||
]
|
||||
|
||||
return render_template(
|
||||
"expenses.html",
|
||||
expense_table=expense_table,
|
||||
lists_data=lists_data,
|
||||
categories=categories,
|
||||
selected_category=category_id,
|
||||
show_all=show_all,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/expenses_data")
|
||||
@login_required
|
||||
def expenses_data():
|
||||
range_type = request.args.get("range", "monthly")
|
||||
start_date = request.args.get("start_date")
|
||||
end_date = request.args.get("end_date")
|
||||
show_all = request.args.get("show_all", "true").lower() == "true"
|
||||
category_id = request.args.get("category_id")
|
||||
by_category = request.args.get("by_category", "false").lower() == "true"
|
||||
|
||||
if not start_date or not end_date:
|
||||
sd, ed, bucket = resolve_range(range_type)
|
||||
if sd and ed:
|
||||
start_date = sd
|
||||
end_date = ed
|
||||
range_type = bucket
|
||||
|
||||
if by_category:
|
||||
result = get_total_expenses_grouped_by_category(
|
||||
show_all=show_all,
|
||||
range_type=range_type,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
user_id=current_user.id,
|
||||
category_id=category_id,
|
||||
)
|
||||
else:
|
||||
result = get_total_expenses_grouped_by_list_created_at(
|
||||
user_only=False,
|
||||
admin=False,
|
||||
show_all=show_all,
|
||||
range_type=range_type,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
user_id=current_user.id,
|
||||
category_id=category_id,
|
||||
)
|
||||
|
||||
if "error" in result:
|
||||
return jsonify({"error": result["error"]}), 400
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@app.route("/share/<token>")
|
||||
# @app.route("/guest-list/<int:list_id>")
|
||||
@app.route("/shared/<int:list_id>")
|
||||
def shared_list(token=None, list_id=None):
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
if token:
|
||||
shopping_list = ShoppingList.query.filter_by(share_token=token).first_or_404()
|
||||
|
||||
# jeśli lista wygasła – zablokuj (spójne z resztą aplikacji)
|
||||
if (
|
||||
shopping_list.is_temporary
|
||||
and shopping_list.expires_at
|
||||
and shopping_list.expires_at <= now
|
||||
):
|
||||
flash("Link wygasł.", "warning")
|
||||
return redirect(url_for("main_page"))
|
||||
|
||||
# >>> KLUCZOWE: pozwól wejść nawet, gdy niepubliczna (bez check_list_public)
|
||||
list_id = shopping_list.id
|
||||
|
||||
# >>> Jeśli zalogowany i nie jest właścicielem — auto-przypisz stałe uprawnienie
|
||||
if current_user.is_authenticated and current_user.id != shopping_list.owner_id:
|
||||
# dodaj wpis tylko jeśli go nie ma
|
||||
exists = (
|
||||
db.session.query(ListPermission.id)
|
||||
.filter(
|
||||
ListPermission.list_id == shopping_list.id,
|
||||
ListPermission.user_id == current_user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not exists:
|
||||
db.session.add(
|
||||
ListPermission(list_id=shopping_list.id, user_id=current_user.id)
|
||||
)
|
||||
db.session.commit()
|
||||
else:
|
||||
shopping_list = ShoppingList.query.get_or_404(list_id)
|
||||
|
||||
total_expense = get_total_expense_for_list(list_id)
|
||||
shopping_list, items, receipts, expenses, total_expense = get_list_details(list_id)
|
||||
|
||||
shopping_list.category_badges = [
|
||||
{"name": c.name, "color": category_color_for(c)}
|
||||
for c in shopping_list.categories
|
||||
]
|
||||
|
||||
for item in items:
|
||||
if item.added_by != shopping_list.owner_id:
|
||||
item.added_by_display = (
|
||||
item.added_by_user.username if item.added_by_user else "?"
|
||||
)
|
||||
else:
|
||||
item.added_by_display = None
|
||||
|
||||
return render_template(
|
||||
"list_share.html",
|
||||
list=shopping_list,
|
||||
items=items,
|
||||
receipts=receipts,
|
||||
expenses=expenses,
|
||||
total_expense=total_expense,
|
||||
is_share=True,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/copy/<int:list_id>")
|
||||
@login_required
|
||||
def copy_list(list_id):
|
||||
original = ShoppingList.query.get_or_404(list_id)
|
||||
token = generate_share_token(8)
|
||||
new_list = ShoppingList(
|
||||
title=original.title + " (Kopia)", owner_id=current_user.id, share_token=token
|
||||
)
|
||||
db.session.add(new_list)
|
||||
db.session.commit()
|
||||
original_items = Item.query.filter_by(list_id=original.id).all()
|
||||
for item in original_items:
|
||||
copy_item = Item(list_id=new_list.id, name=item.name)
|
||||
db.session.add(copy_item)
|
||||
db.session.commit()
|
||||
flash("Skopiowano listę", "success")
|
||||
return redirect(url_for("view_list", list_id=new_list.id))
|
||||
|
||||
|
||||
@app.route("/suggest_products")
|
||||
def suggest_products():
|
||||
query = request.args.get("q", "")
|
||||
suggestions = []
|
||||
if query:
|
||||
suggestions = (
|
||||
SuggestedProduct.query.filter(SuggestedProduct.name.ilike(f"%{query}%"))
|
||||
.limit(5)
|
||||
.all()
|
||||
)
|
||||
return {"suggestions": [s.name for s in suggestions]}
|
||||
|
||||
|
||||
@app.route("/all_products")
|
||||
def all_products():
|
||||
sort = request.args.get("sort", "popularity")
|
||||
limit = request.args.get("limit", type=int) or 100
|
||||
offset = request.args.get("offset", type=int) or 0
|
||||
|
||||
products_from_items = db.session.query(
|
||||
func.lower(func.trim(Item.name)).label("normalized_name"),
|
||||
func.min(Item.name).label("display_name"),
|
||||
func.count(func.distinct(Item.list_id)).label("count"),
|
||||
).group_by(func.lower(func.trim(Item.name)))
|
||||
|
||||
products_from_suggested = (
|
||||
db.session.query(
|
||||
func.lower(func.trim(SuggestedProduct.name)).label("normalized_name"),
|
||||
func.min(SuggestedProduct.name).label("display_name"),
|
||||
db.literal(1).label("count"),
|
||||
)
|
||||
.filter(
|
||||
~func.lower(func.trim(SuggestedProduct.name)).in_(
|
||||
db.session.query(func.lower(func.trim(Item.name)))
|
||||
)
|
||||
)
|
||||
.group_by(func.lower(func.trim(SuggestedProduct.name)))
|
||||
)
|
||||
|
||||
union_q = products_from_items.union_all(products_from_suggested).subquery()
|
||||
|
||||
final_q = db.session.query(
|
||||
union_q.c.normalized_name,
|
||||
union_q.c.display_name,
|
||||
func.sum(union_q.c.count).label("count"),
|
||||
).group_by(union_q.c.normalized_name, union_q.c.display_name)
|
||||
|
||||
if sort == "alphabetical":
|
||||
final_q = final_q.order_by(func.lower(union_q.c.display_name).asc())
|
||||
else:
|
||||
final_q = final_q.order_by(
|
||||
func.sum(union_q.c.count).desc(), func.lower(union_q.c.display_name).asc()
|
||||
)
|
||||
|
||||
total_count = (
|
||||
db.session.query(func.count()).select_from(final_q.subquery()).scalar()
|
||||
)
|
||||
products = final_q.offset(offset).limit(limit).all()
|
||||
|
||||
out = [{"name": row.display_name, "count": row.count} for row in products]
|
||||
|
||||
return jsonify({"products": out, "total_count": total_count})
|
||||
|
||||
|
||||
@app.route("/upload_receipt/<int:list_id>", methods=["POST"])
|
||||
@login_required
|
||||
def upload_receipt(list_id):
|
||||
l = db.session.get(ShoppingList, list_id)
|
||||
|
||||
file = request.files.get("receipt")
|
||||
if not file or file.filename == "":
|
||||
return receipt_error("Nie wybrano pliku")
|
||||
|
||||
if not allowed_file(file.filename):
|
||||
return receipt_error("Niedozwolony format pliku")
|
||||
|
||||
file_bytes = file.read()
|
||||
file.seek(0)
|
||||
file_hash = hashlib.sha256(file_bytes).hexdigest()
|
||||
|
||||
existing = Receipt.query.filter_by(file_hash=file_hash).first()
|
||||
if existing:
|
||||
return receipt_error("Taki plik już istnieje")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
timestamp = now.strftime("%Y%m%d_%H%M")
|
||||
random_part = secrets.token_hex(3)
|
||||
webp_filename = f"list_{list_id}_{timestamp}_{random_part}.webp"
|
||||
file_path = os.path.join(app.config["UPLOAD_FOLDER"], webp_filename)
|
||||
|
||||
try:
|
||||
if file.filename.lower().endswith(".pdf"):
|
||||
file.seek(0)
|
||||
save_pdf_as_webp(file, file_path)
|
||||
else:
|
||||
save_resized_image(file, file_path)
|
||||
except ValueError as e:
|
||||
return receipt_error(str(e))
|
||||
|
||||
try:
|
||||
new_receipt = Receipt(
|
||||
list_id=list_id,
|
||||
filename=webp_filename,
|
||||
filesize=os.path.getsize(file_path),
|
||||
uploaded_at=now,
|
||||
file_hash=file_hash,
|
||||
uploaded_by=current_user.id,
|
||||
version_token=generate_version_token(),
|
||||
)
|
||||
db.session.add(new_receipt)
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
return receipt_error(f"Błąd zapisu do bazy: {str(e)}")
|
||||
|
||||
if request.is_json or request.headers.get("X-Requested-With") == "XMLHttpRequest":
|
||||
url = (
|
||||
url_for("uploaded_file", filename=webp_filename)
|
||||
+ f"?v={new_receipt.version_token or '0'}"
|
||||
)
|
||||
socketio.emit("receipt_added", {"url": url}, to=str(list_id))
|
||||
return jsonify({"success": True, "url": url})
|
||||
|
||||
flash("Wgrano paragon", "success")
|
||||
return redirect(request.referrer or url_for("main_page"))
|
||||
|
||||
|
||||
@app.route("/uploads/<filename>")
|
||||
def uploaded_file(filename):
|
||||
response = send_from_directory(app.config["UPLOAD_FOLDER"], filename)
|
||||
response.headers["Cache-Control"] = app.config["UPLOADS_CACHE_CONTROL"]
|
||||
response.headers.pop("Content-Disposition", None)
|
||||
mime, _ = mimetypes.guess_type(filename)
|
||||
if mime:
|
||||
response.headers["Content-Type"] = mime
|
||||
return response
|
||||
|
||||
|
||||
@app.route("/reorder_items", methods=["POST"])
|
||||
@login_required
|
||||
def reorder_items():
|
||||
data = request.get_json()
|
||||
list_id = data.get("list_id")
|
||||
order = data.get("order")
|
||||
|
||||
for index, item_id in enumerate(order):
|
||||
item = db.session.get(Item, item_id)
|
||||
if item and item.list_id == list_id:
|
||||
item.position = index
|
||||
db.session.commit()
|
||||
|
||||
socketio.emit(
|
||||
"items_reordered", {"list_id": list_id, "order": order}, to=str(list_id)
|
||||
)
|
||||
|
||||
return jsonify(success=True)
|
||||
|
||||
|
||||
@app.route("/rotate_receipt/<int:receipt_id>")
|
||||
@login_required
|
||||
def rotate_receipt_user(receipt_id):
|
||||
receipt = Receipt.query.get_or_404(receipt_id)
|
||||
list_obj = ShoppingList.query.get_or_404(receipt.list_id)
|
||||
|
||||
if not (current_user.is_admin or current_user.id == list_obj.owner_id):
|
||||
flash("Brak uprawnień do tej operacji", "danger")
|
||||
return redirect(url_for("main_page"))
|
||||
|
||||
try:
|
||||
rotate_receipt_by_id(receipt_id)
|
||||
recalculate_filesizes(receipt_id)
|
||||
flash("Obrócono paragon", "success")
|
||||
except FileNotFoundError:
|
||||
flash("Plik nie istnieje", "danger")
|
||||
except Exception as e:
|
||||
flash(f"Błąd przy obracaniu: {str(e)}", "danger")
|
||||
|
||||
return redirect(request.referrer or url_for("main_page"))
|
||||
|
||||
|
||||
@app.route("/delete_receipt/<int:receipt_id>")
|
||||
@login_required
|
||||
def delete_receipt_user(receipt_id):
|
||||
receipt = Receipt.query.get_or_404(receipt_id)
|
||||
list_obj = ShoppingList.query.get_or_404(receipt.list_id)
|
||||
|
||||
if not (current_user.is_admin or current_user.id == list_obj.owner_id):
|
||||
flash("Brak uprawnień do tej operacji", "danger")
|
||||
return redirect(url_for("main_page"))
|
||||
|
||||
try:
|
||||
delete_receipt_by_id(receipt_id)
|
||||
flash("Paragon usunięty", "success")
|
||||
except Exception as e:
|
||||
flash(f"Błąd przy usuwaniu pliku: {str(e)}", "danger")
|
||||
|
||||
return redirect(request.referrer or url_for("main_page"))
|
||||
|
||||
|
||||
# OCR
|
||||
@app.route("/lists/<int:list_id>/analyze", methods=["POST"])
|
||||
@login_required
|
||||
def analyze_receipts_for_list(list_id):
|
||||
receipt_objs = Receipt.query.filter_by(list_id=list_id).all()
|
||||
existing_expenses = {
|
||||
e.receipt_filename
|
||||
for e in Expense.query.filter_by(list_id=list_id).all()
|
||||
if e.receipt_filename
|
||||
}
|
||||
|
||||
results = []
|
||||
total = 0.0
|
||||
|
||||
for receipt in receipt_objs:
|
||||
filepath = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename)
|
||||
if not os.path.exists(filepath):
|
||||
continue
|
||||
|
||||
try:
|
||||
raw_image = Image.open(filepath).convert("RGB")
|
||||
image = preprocess_image_for_tesseract(raw_image)
|
||||
value, lines = extract_total_tesseract(image)
|
||||
|
||||
except Exception as e:
|
||||
print(f"OCR error for {receipt.filename}:\n{traceback.format_exc()}")
|
||||
value = 0.0
|
||||
lines = []
|
||||
|
||||
already_added = receipt.filename in existing_expenses
|
||||
|
||||
results.append(
|
||||
{
|
||||
"id": receipt.id,
|
||||
"filename": receipt.filename,
|
||||
"amount": round(value, 2),
|
||||
"debug_text": lines,
|
||||
"already_added": already_added,
|
||||
}
|
||||
)
|
||||
|
||||
# if not already_added:
|
||||
total += value
|
||||
|
||||
return jsonify({"results": results, "total": round(total, 2)})
|
||||
|
||||
|
||||
@app.route("/user_crop_receipt", methods=["POST"])
|
||||
@login_required
|
||||
def crop_receipt_user():
|
||||
receipt_id = request.form.get("receipt_id")
|
||||
file = request.files.get("cropped_image")
|
||||
|
||||
receipt = Receipt.query.get_or_404(receipt_id)
|
||||
list_obj = ShoppingList.query.get_or_404(receipt.list_id)
|
||||
|
||||
if list_obj.owner_id != current_user.id and not current_user.is_admin:
|
||||
return jsonify(success=False, error="Brak dostępu"), 403
|
||||
|
||||
result = handle_crop_receipt(receipt_id, file)
|
||||
return jsonify(result)
|
||||
|
||||
513
shopping_app/sockets.py
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 280 B After Width: | Height: | Size: 280 B |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
@@ -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>
|
||||
|
||||
155
shopping_app/templates/base.html
Normal 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>
|
||||
|
||||
@@ -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
shopping_app/uploads
Symbolic link
@@ -0,0 +1 @@
|
||||
../uploads
|
||||
221
shopping_app/web.py
Normal 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"}
|
||||
@@ -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>
|
||||