Compare commits
5 Commits
master
...
a299783a6c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a299783a6c | ||
|
|
14a544c9c4 | ||
|
|
ad5dbcc24b | ||
|
|
3a57f2f1d7 | ||
|
|
a16798553e |
33
API_OPIS.txt
Normal file
@@ -0,0 +1,33 @@
|
||||
API aplikacji Lista Zakupów
|
||||
|
||||
Autoryzacja:
|
||||
- Authorization: Bearer TWOJ_TOKEN
|
||||
- albo X-API-Token: TWOJ_TOKEN
|
||||
|
||||
Token ma jednocześnie dwa ograniczenia:
|
||||
1. zakresy (scopes), np. expenses:read, lists:read, templates:read
|
||||
2. dozwolone endpointy
|
||||
|
||||
Dostępne endpointy:
|
||||
- GET /api/ping
|
||||
Test poprawności tokenu.
|
||||
|
||||
- GET /api/expenses/latest?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD&list_id=ID&owner_id=ID&limit=50
|
||||
Zwraca ostatnie wydatki wraz z metadanymi listy i właściciela.
|
||||
|
||||
- GET /api/expenses/summary?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD&list_id=ID&owner_id=ID
|
||||
Zwraca sumę wydatków, liczbę rekordów i agregację po listach.
|
||||
|
||||
- GET /api/lists?owner_id=ID&limit=50
|
||||
Zwraca listy z podstawowymi metadanymi.
|
||||
|
||||
- GET /api/lists/<id>/expenses?limit=50
|
||||
Zwraca wydatki przypisane do konkretnej listy.
|
||||
|
||||
- GET /api/templates?owner_id=ID
|
||||
Zwraca aktywne szablony.
|
||||
|
||||
Uwagi:
|
||||
- limit odpowiedzi jest przycinany do max_limit ustawionego na tokenie
|
||||
- daty przekazuj w formacie YYYY-MM-DD
|
||||
- endpoint musi być zaznaczony na tokenie, samo posiadanie zakresu nie wystarczy
|
||||
30
CLI_OPIS.txt
Normal file
@@ -0,0 +1,30 @@
|
||||
Komendy CLI
|
||||
===========
|
||||
|
||||
Admini
|
||||
-------
|
||||
flask admins list
|
||||
flask admins create <username> <password> [--admin/--user]
|
||||
flask admins promote <username|id>
|
||||
flask admins demote <username|id>
|
||||
flask admins set-password <username|id> <password>
|
||||
|
||||
Listy
|
||||
-----
|
||||
flask lists copy-schedule --source-list-id 12 --when "2026-03-20 18:30"
|
||||
flask lists copy-schedule --source-list-id 12 --when "2026-03-20 18:30" --owner admin
|
||||
flask lists copy-schedule --source-list-id 12 --when "2026-03-20 18:30" --title "Zakupy piatkowe"
|
||||
|
||||
Zasady dzialania
|
||||
----------------
|
||||
- copy-schedule tworzy nowa liste na podstawie istniejacej
|
||||
- kopiuje pozycje i przypisane kategorie
|
||||
- ustawia nowy created_at na wartosc z parametru --when
|
||||
- gdy lista byla tymczasowa i miala expires_at, termin wygasniecia jest przesuwany o ten sam odstep czasu
|
||||
- wydatki i paragony nie sa kopiowane
|
||||
|
||||
|
||||
SZABLONY I HISTORIA:
|
||||
- Historia zmian listy jest widoczna w widoku listy właściciela.
|
||||
- Szablon można utworzyć z panelu admina lub z poziomu listy właściciela.
|
||||
- Admin może szybko utworzyć listę z szablonu i zduplikować listę jednym kliknięciem.
|
||||
@@ -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"]
|
||||
@@ -10,6 +10,8 @@ Prosta aplikacja webowa do zarządzania listami zakupów z obsługą użytkownik
|
||||
- Archiwizacja i udostępnianie list (publiczne/prywatne)
|
||||
- Statystyki wydatków z podziałem na okresy, statystyki dla użytkowników
|
||||
- Panel administracyjny (statystyki, produkty, paragony, zarządzanie, użytkowmicy)
|
||||
- Tokeny API administratora i endpoint do pobierania ostatnich wydatków
|
||||
- Ujednolicony UI formularzy, tabel i przycisków oraz drobne usprawnienia UX
|
||||
|
||||
## Wymagania
|
||||
|
||||
@@ -85,4 +87,8 @@ DB_PORT=5432
|
||||
DB_NAME=myapp
|
||||
DB_USER=user
|
||||
DB_PASSWORD=pass
|
||||
```
|
||||
```
|
||||
|
||||
## CLI
|
||||
|
||||
Opis komend administracyjnych znajduje sie w pliku `CLI_OPIS.txt`.
|
||||
|
||||
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"]
|
||||
115
shopping_app/app_setup.py
Normal file
@@ -0,0 +1,115 @@
|
||||
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)
|
||||
db_uri = app.config.get("SQLALCHEMY_DATABASE_URI", "")
|
||||
if db_uri.startswith("sqlite:///"):
|
||||
sqlite_path = db_uri.replace("sqlite:///", "", 1)
|
||||
sqlite_dir = os.path.dirname(sqlite_path)
|
||||
if sqlite_dir:
|
||||
os.makedirs(sqlite_dir, 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
|
||||
1525
shopping_app/helpers.py
Normal file
216
shopping_app/models.py
Normal file
@@ -0,0 +1,216 @@
|
||||
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 ApiToken(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(120), nullable=False)
|
||||
token_hash = db.Column(db.String(64), unique=True, nullable=False, index=True)
|
||||
token_prefix = db.Column(db.String(18), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
|
||||
last_used_at = db.Column(db.DateTime, nullable=True)
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
created_by = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
|
||||
scopes = db.Column(db.String(255), nullable=False, default="expenses:read")
|
||||
allowed_endpoints = db.Column(db.String(255), nullable=False, default="/api/expenses/latest")
|
||||
max_limit = db.Column(db.Integer, nullable=False, default=100)
|
||||
|
||||
creator = db.relationship(
|
||||
"User", backref="created_api_tokens", lazy="joined", foreign_keys=[created_by]
|
||||
)
|
||||
|
||||
|
||||
class ListTemplate(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(150), nullable=False)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
|
||||
created_by = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
|
||||
creator = db.relationship("User", backref="list_templates", lazy="joined")
|
||||
items = db.relationship(
|
||||
"ListTemplateItem",
|
||||
back_populates="template",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="select",
|
||||
order_by="ListTemplateItem.position.asc()",
|
||||
)
|
||||
|
||||
|
||||
class ListTemplateItem(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
template_id = db.Column(db.Integer, db.ForeignKey("list_template.id", ondelete="CASCADE"), nullable=False)
|
||||
name = db.Column(db.String(150), nullable=False)
|
||||
quantity = db.Column(db.Integer, default=1)
|
||||
note = db.Column(db.Text, nullable=True)
|
||||
position = db.Column(db.Integer, default=0)
|
||||
|
||||
template = db.relationship("ListTemplate", back_populates="items")
|
||||
|
||||
|
||||
class ListActivityLog(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, index=True)
|
||||
actor_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
|
||||
actor_name = db.Column(db.String(150), nullable=False, default="System")
|
||||
action = db.Column(db.String(64), nullable=False)
|
||||
item_name = db.Column(db.String(150), nullable=True)
|
||||
details = db.Column(db.Text, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=utcnow, nullable=False, index=True)
|
||||
|
||||
shopping_list = db.relationship("ShoppingList", backref=db.backref("activity_logs", lazy="dynamic", cascade="all, delete-orphan"))
|
||||
actor = db.relationship("User", backref="list_activity_logs", lazy="joined")
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
1437
shopping_app/routes_admin.py
Normal file
856
shopping_app/routes_main.py
Normal file
@@ -0,0 +1,856 @@
|
||||
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 = []
|
||||
|
||||
expiring_lists = get_expiring_lists_for_user(current_user.id) if current_user.is_authenticated else []
|
||||
templates = (ListTemplate.query.filter_by(is_active=True, created_by=current_user.id).order_by(ListTemplate.name.asc()).all() if current_user.is_authenticated else [])
|
||||
|
||||
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,
|
||||
expiring_lists=expiring_lists,
|
||||
templates=templates,
|
||||
)
|
||||
|
||||
|
||||
@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()
|
||||
)
|
||||
|
||||
all_usernames = [
|
||||
u.username
|
||||
for u in User.query.filter(User.id != current_user.id)
|
||||
.order_by(func.lower(User.username).asc())
|
||||
.limit(300)
|
||||
.all()
|
||||
]
|
||||
|
||||
return render_template(
|
||||
"edit_my_list.html",
|
||||
list=l,
|
||||
receipts=receipts,
|
||||
categories=categories,
|
||||
selected_categories=selected_categories_ids,
|
||||
permitted_users=permitted_users,
|
||||
all_usernames=all_usernames,
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
.outerjoin(subq, subq.c.uid == User.id)
|
||||
.filter(User.id != current_user.id)
|
||||
)
|
||||
if q:
|
||||
query = query.filter(func.lower(User.username).like(f"{q}%"))
|
||||
|
||||
rows = (
|
||||
query.order_by(
|
||||
func.coalesce(subq.c.grant_count, 0).desc(),
|
||||
func.coalesce(subq.c.last_grant_id, 0).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()
|
||||
log_list_activity(new_list.id, 'list_created', actor=current_user, actor_name=current_user.username, details='Utworzono listę ręcznie')
|
||||
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()
|
||||
)
|
||||
|
||||
activity_logs = (
|
||||
ListActivityLog.query.filter_by(list_id=list_id)
|
||||
.order_by(ListActivityLog.created_at.desc(), ListActivityLog.id.desc())
|
||||
.limit(20)
|
||||
.all()
|
||||
)
|
||||
|
||||
all_usernames = [
|
||||
u.username
|
||||
for u in User.query.filter(User.id != current_user.id)
|
||||
.order_by(func.lower(User.username).asc())
|
||||
.limit(300)
|
||||
.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,
|
||||
activity_logs=activity_logs,
|
||||
action_label=action_label,
|
||||
all_usernames=all_usernames,
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
@app.route('/my-templates', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def my_templates():
|
||||
if request.method == 'POST':
|
||||
action = (request.form.get('action') or 'create_manual').strip()
|
||||
if action == 'create_manual':
|
||||
name = (request.form.get('name') or '').strip()
|
||||
description = (request.form.get('description') or '').strip()
|
||||
raw_items = (request.form.get('items_text') or '').splitlines()
|
||||
if not name:
|
||||
flash('Podaj nazwę szablonu.', 'danger')
|
||||
return redirect(url_for('my_templates'))
|
||||
template = ListTemplate(name=name, description=description, created_by=current_user.id, is_active=True)
|
||||
db.session.add(template)
|
||||
db.session.flush()
|
||||
pos = 1
|
||||
for line in raw_items:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
qty = 1
|
||||
item_name = line
|
||||
match = re.match(r'^(.*?)(?:\s+[xX](\d+))?$', line)
|
||||
if match:
|
||||
item_name = (match.group(1) or '').strip() or line
|
||||
if match.group(2):
|
||||
qty = max(1, int(match.group(2)))
|
||||
db.session.add(ListTemplateItem(template_id=template.id, name=item_name, quantity=qty, position=pos))
|
||||
pos += 1
|
||||
db.session.commit()
|
||||
flash(f'Utworzono szablon „{template.name}”.', 'success')
|
||||
return redirect(url_for('my_templates'))
|
||||
elif action == 'delete':
|
||||
template = ListTemplate.query.options(joinedload(ListTemplate.items)).get_or_404(request.form.get('template_id', type=int))
|
||||
if template.created_by != current_user.id and not current_user.is_admin:
|
||||
abort(403)
|
||||
db.session.delete(template)
|
||||
db.session.commit()
|
||||
flash(f'Usunięto szablon „{template.name}”.', 'warning')
|
||||
return redirect(url_for('my_templates'))
|
||||
|
||||
templates = ListTemplate.query.options(joinedload(ListTemplate.items)).filter_by(created_by=current_user.id, is_active=True).order_by(ListTemplate.created_at.desc(), ListTemplate.id.desc()).all()
|
||||
source_lists = ShoppingList.query.filter_by(owner_id=current_user.id, is_archived=False).order_by(ShoppingList.created_at.desc()).limit(100).all()
|
||||
return render_template('my_templates.html', templates=templates, source_lists=source_lists)
|
||||
|
||||
|
||||
@app.route('/templates/<int:template_id>/instantiate', methods=['POST'])
|
||||
@login_required
|
||||
def instantiate_template(template_id):
|
||||
template = ListTemplate.query.get_or_404(template_id)
|
||||
if not template_is_accessible_to_user(template, current_user):
|
||||
abort(403)
|
||||
title = (request.form.get('title') or '').strip() or None
|
||||
new_list = create_list_from_template(template, owner=current_user, title=title)
|
||||
log_list_activity(new_list.id, 'template_created', actor=current_user, details=f'Utworzono z szablonu: {template.name}')
|
||||
db.session.commit()
|
||||
flash(f'Utworzono listę z szablonu „{template.name}”.', 'success')
|
||||
return redirect(url_for('view_list', list_id=new_list.id))
|
||||
|
||||
|
||||
@app.route('/templates/create-from-list/<int:list_id>', methods=['POST'])
|
||||
@login_required
|
||||
def create_template_from_user_list(list_id):
|
||||
source_list = ShoppingList.query.options(joinedload(ShoppingList.items)).get_or_404(list_id)
|
||||
if source_list.owner_id != current_user.id and not current_user.is_admin:
|
||||
abort(403)
|
||||
name = (request.form.get('template_name') or '').strip() or f'{source_list.title} - szablon'
|
||||
description = (request.form.get('description') or '').strip() or f'Szablon utworzony z listy {source_list.title}'
|
||||
template = create_template_from_list(source_list, created_by=current_user.id, name=name, description=description)
|
||||
flash(f'Utworzono szablon „{template.name}”.', 'success')
|
||||
return redirect(url_for('my_templates'))
|
||||
740
shopping_app/routes_secondary.py
Normal file
@@ -0,0 +1,740 @@
|
||||
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("/api/expenses/latest")
|
||||
@api_token_required
|
||||
@require_api_scope('expenses:read')
|
||||
def api_latest_expenses():
|
||||
start_date_str = (request.args.get("start_date") or "").strip() or None
|
||||
end_date_str = (request.args.get("end_date") or "").strip() or None
|
||||
list_id = request.args.get("list_id", type=int)
|
||||
owner_id = request.args.get("owner_id", type=int)
|
||||
limit = request.args.get("limit", default=50, type=int) or 50
|
||||
token_limit = getattr(g, 'api_token', None).max_limit if getattr(g, 'api_token', None) else 500
|
||||
limit = max(1, min(limit, int(token_limit or 500), 500))
|
||||
|
||||
try:
|
||||
start_date, end_date = parse_api_date_range(start_date_str, end_date_str)
|
||||
except ValueError as exc:
|
||||
return jsonify({"ok": False, "error": "bad_request", "message": str(exc)}), 400
|
||||
|
||||
filter_query = Expense.query.join(ShoppingList, ShoppingList.id == Expense.list_id)
|
||||
|
||||
if start_date:
|
||||
filter_query = filter_query.filter(Expense.added_at >= start_date)
|
||||
if end_date:
|
||||
filter_query = filter_query.filter(Expense.added_at < end_date)
|
||||
if list_id:
|
||||
filter_query = filter_query.filter(Expense.list_id == list_id)
|
||||
if owner_id:
|
||||
filter_query = filter_query.filter(ShoppingList.owner_id == owner_id)
|
||||
|
||||
total_count = filter_query.with_entities(func.count(Expense.id)).scalar() or 0
|
||||
total_amount = float(filter_query.with_entities(func.coalesce(func.sum(Expense.amount), 0)).scalar() or 0)
|
||||
|
||||
expenses = (
|
||||
filter_query.options(
|
||||
joinedload(Expense.shopping_list).joinedload(ShoppingList.owner),
|
||||
joinedload(Expense.shopping_list).joinedload(ShoppingList.categories),
|
||||
)
|
||||
.order_by(Expense.added_at.desc(), Expense.id.desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
items = []
|
||||
for expense in expenses:
|
||||
shopping_list = expense.shopping_list
|
||||
owner = shopping_list.owner if shopping_list else None
|
||||
items.append(
|
||||
{
|
||||
"expense_id": expense.id,
|
||||
"amount": round(float(expense.amount or 0), 2),
|
||||
"added_at": format_dt_for_api(expense.added_at),
|
||||
"receipt_filename": expense.receipt_filename,
|
||||
"list": {
|
||||
"id": shopping_list.id if shopping_list else None,
|
||||
"title": shopping_list.title if shopping_list else None,
|
||||
"created_at": format_dt_for_api(shopping_list.created_at if shopping_list else None),
|
||||
"is_archived": bool(shopping_list.is_archived) if shopping_list else None,
|
||||
"is_public": bool(shopping_list.is_public) if shopping_list else None,
|
||||
"categories": [c.name for c in shopping_list.categories] if shopping_list else [],
|
||||
},
|
||||
"owner": {
|
||||
"id": owner.id if owner else None,
|
||||
"username": owner.username if owner else None,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"ok": True,
|
||||
"filters": {
|
||||
"start_date": start_date_str,
|
||||
"end_date": end_date_str,
|
||||
"list_id": list_id,
|
||||
"owner_id": owner_id,
|
||||
"limit": limit,
|
||||
},
|
||||
"meta": {
|
||||
"returned_count": len(items),
|
||||
"total_count": int(total_count),
|
||||
"total_amount": round(total_amount, 2),
|
||||
"token_name": g.api_token.name,
|
||||
"token_prefix": g.api_token.token_prefix,
|
||||
},
|
||||
"items": items,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/ping")
|
||||
@api_token_required
|
||||
def api_ping():
|
||||
return jsonify({"ok": True, "message": "token accepted", "token_name": g.api_token.name, "token_prefix": g.api_token.token_prefix})
|
||||
|
||||
|
||||
@app.route("/api/expenses/summary")
|
||||
@api_token_required
|
||||
@require_api_scope('expenses:read')
|
||||
def api_expenses_summary():
|
||||
start_date_str = (request.args.get("start_date") or "").strip() or None
|
||||
end_date_str = (request.args.get("end_date") or "").strip() or None
|
||||
list_id = request.args.get("list_id", type=int)
|
||||
owner_id = request.args.get("owner_id", type=int)
|
||||
|
||||
try:
|
||||
start_date, end_date = parse_api_date_range(start_date_str, end_date_str)
|
||||
except ValueError as exc:
|
||||
return jsonify({"ok": False, "error": "bad_request", "message": str(exc)}), 400
|
||||
|
||||
query = Expense.query.join(ShoppingList, ShoppingList.id == Expense.list_id)
|
||||
if start_date:
|
||||
query = query.filter(Expense.added_at >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(Expense.added_at < end_date)
|
||||
if list_id:
|
||||
query = query.filter(Expense.list_id == list_id)
|
||||
if owner_id:
|
||||
query = query.filter(ShoppingList.owner_id == owner_id)
|
||||
|
||||
total_count = int(query.with_entities(func.count(Expense.id)).scalar() or 0)
|
||||
total_amount = float(query.with_entities(func.coalesce(func.sum(Expense.amount), 0)).scalar() or 0)
|
||||
by_list = (
|
||||
query.with_entities(ShoppingList.id, ShoppingList.title, func.count(Expense.id), func.coalesce(func.sum(Expense.amount), 0))
|
||||
.group_by(ShoppingList.id, ShoppingList.title)
|
||||
.order_by(func.coalesce(func.sum(Expense.amount), 0).desc(), ShoppingList.id.desc())
|
||||
.limit(100)
|
||||
.all()
|
||||
)
|
||||
return jsonify({
|
||||
"ok": True,
|
||||
"filters": {"start_date": start_date_str, "end_date": end_date_str, "list_id": list_id, "owner_id": owner_id},
|
||||
"meta": {"total_count": total_count, "total_amount": round(total_amount, 2)},
|
||||
"lists": [{"id": row[0], "title": row[1], "expense_count": int(row[2] or 0), "total_amount": round(float(row[3] or 0), 2)} for row in by_list],
|
||||
})
|
||||
|
||||
|
||||
@app.route("/api/lists")
|
||||
@api_token_required
|
||||
@require_api_scope('lists:read')
|
||||
def api_lists():
|
||||
owner_id = request.args.get("owner_id", type=int)
|
||||
limit = request.args.get("limit", default=50, type=int) or 50
|
||||
token_limit = getattr(g, 'api_token', None).max_limit if getattr(g, 'api_token', None) else 500
|
||||
limit = max(1, min(limit, int(token_limit or 500), 500))
|
||||
|
||||
query = ShoppingList.query.options(joinedload(ShoppingList.owner), joinedload(ShoppingList.categories)).order_by(ShoppingList.created_at.desc(), ShoppingList.id.desc())
|
||||
if owner_id:
|
||||
query = query.filter(ShoppingList.owner_id == owner_id)
|
||||
rows = query.limit(limit).all()
|
||||
return jsonify({
|
||||
"ok": True,
|
||||
"items": [{
|
||||
"id": row.id,
|
||||
"title": row.title,
|
||||
"created_at": format_dt_for_api(row.created_at),
|
||||
"owner": {"id": row.owner.id if row.owner else None, "username": row.owner.username if row.owner else None},
|
||||
"is_temporary": bool(row.is_temporary),
|
||||
"expires_at": format_dt_for_api(row.expires_at),
|
||||
"is_archived": bool(row.is_archived),
|
||||
"is_public": bool(row.is_public),
|
||||
"categories": [c.name for c in row.categories],
|
||||
} for row in rows],
|
||||
})
|
||||
|
||||
|
||||
@app.route("/api/lists/<int:list_id>/expenses")
|
||||
@api_token_required
|
||||
@require_api_scope('lists:read')
|
||||
def api_list_expenses(list_id):
|
||||
limit = request.args.get("limit", default=50, type=int) or 50
|
||||
token_limit = getattr(g, 'api_token', None).max_limit if getattr(g, 'api_token', None) else 500
|
||||
limit = max(1, min(limit, int(token_limit or 500), 500))
|
||||
shopping_list = ShoppingList.query.options(joinedload(ShoppingList.owner), joinedload(ShoppingList.categories)).get_or_404(list_id)
|
||||
rows = Expense.query.filter_by(list_id=list_id).order_by(Expense.added_at.desc(), Expense.id.desc()).limit(limit).all()
|
||||
return jsonify({
|
||||
"ok": True,
|
||||
"list": {
|
||||
"id": shopping_list.id,
|
||||
"title": shopping_list.title,
|
||||
"owner": {"id": shopping_list.owner.id if shopping_list.owner else None, "username": shopping_list.owner.username if shopping_list.owner else None},
|
||||
"categories": [c.name for c in shopping_list.categories],
|
||||
},
|
||||
"items": [{"expense_id": row.id, "amount": round(float(row.amount or 0), 2), "added_at": format_dt_for_api(row.added_at), "receipt_filename": row.receipt_filename} for row in rows],
|
||||
})
|
||||
|
||||
|
||||
@app.route("/api/templates")
|
||||
@api_token_required
|
||||
@require_api_scope('templates:read')
|
||||
def api_templates():
|
||||
query = ListTemplate.query.options(joinedload(ListTemplate.creator), joinedload(ListTemplate.items)).filter_by(is_active=True)
|
||||
owner_id = request.args.get("owner_id", type=int)
|
||||
if owner_id:
|
||||
query = query.filter(ListTemplate.created_by == owner_id)
|
||||
rows = query.order_by(ListTemplate.created_at.desc(), ListTemplate.id.desc()).limit(100).all()
|
||||
return jsonify({
|
||||
"ok": True,
|
||||
"items": [{
|
||||
"id": row.id,
|
||||
"name": row.name,
|
||||
"description": row.description,
|
||||
"created_at": format_dt_for_api(row.created_at),
|
||||
"owner": {"id": row.creator.id if row.creator else None, "username": row.creator.username if row.creator else None},
|
||||
"items_count": len(row.items),
|
||||
"items": [{"name": item.name, "quantity": item.quantity, "note": item.note} for item in row.items],
|
||||
} for row in rows],
|
||||
})
|
||||
|
||||
|
||||
@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()
|
||||
|
||||
expires_at = shopping_list.expires_at
|
||||
if expires_at and expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
# jeśli lista wygasła – zablokuj (spójne z resztą aplikacji)
|
||||
if shopping_list.is_temporary and expires_at and expires_at <= now:
|
||||
flash("Link wygasł.", "warning")
|
||||
return redirect(url_for("main_page"))
|
||||
|
||||
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:
|
||||
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)
|
||||
expires_at = shopping_list.expires_at
|
||||
if expires_at and expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
if shopping_list.is_temporary and expires_at and expires_at <= now:
|
||||
flash("Ta lista wygasła.", "warning")
|
||||
return redirect(url_for("main_page"))
|
||||
|
||||
is_allowed = shopping_list.is_public
|
||||
if current_user.is_authenticated:
|
||||
is_allowed = is_allowed or shopping_list.owner_id == current_user.id or (
|
||||
db.session.query(ListPermission.id)
|
||||
.filter(
|
||||
ListPermission.list_id == shopping_list.id,
|
||||
ListPermission.user_id == current_user.id,
|
||||
)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
|
||||
if not is_allowed:
|
||||
flash("Ta lista nie jest publicznie dostępna.", "warning")
|
||||
return redirect(url_for("main_page"))
|
||||
|
||||
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)
|
||||
|
||||
618
shopping_app/sockets.py
Normal file
@@ -0,0 +1,618 @@
|
||||
import click
|
||||
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
|
||||
log_list_activity(list_id, 'item_deleted', item_name=item.name, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość')
|
||||
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)
|
||||
|
||||
log_list_activity(list_id, 'item_added', item_name=new_item.name, actor=current_user if current_user.is_authenticated else None, actor_name=user_name, details=f'ilość: {new_item.quantity}')
|
||||
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)
|
||||
log_list_activity(item.list_id, 'item_checked', item_name=item.name, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość')
|
||||
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
|
||||
log_list_activity(item.list_id, 'item_unchecked', item_name=item.name, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość')
|
||||
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)
|
||||
log_list_activity(list_id, 'expense_added', item_name=None, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość', details=f'kwota: {float(amount):.2f} PLN')
|
||||
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
|
||||
log_list_activity(item.list_id, 'item_marked_not_purchased', item_name=item.name, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość', details=reason or None)
|
||||
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
|
||||
log_list_activity(item.list_id, 'item_unmarked_not_purchased', item_name=item.name, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość')
|
||||
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)
|
||||
|
||||
|
||||
@app.cli.group("admins")
|
||||
def admins_cli():
|
||||
"""Zarzadzanie kontami administratorow z CLI."""
|
||||
|
||||
|
||||
@admins_cli.command("list")
|
||||
def admins_list_command():
|
||||
with app.app_context():
|
||||
users = User.query.order_by(User.username.asc()).all()
|
||||
if not users:
|
||||
click.echo('Brak uzytkownikow.')
|
||||
return
|
||||
for user in users:
|
||||
role = 'admin' if user.is_admin else 'user'
|
||||
click.echo(f"{user.id} {user.username} {role}")
|
||||
|
||||
|
||||
@admins_cli.command("create")
|
||||
@click.argument("username")
|
||||
@click.argument("password")
|
||||
@click.option("--admin/--user", "make_admin", default=True, show_default=True, help="Utworz konto admina albo zwyklego uzytkownika.")
|
||||
def admins_create_command(username, password, make_admin):
|
||||
with app.app_context():
|
||||
user, created, _ = create_or_update_admin_user(username, password=password, make_admin=make_admin, update_password=False)
|
||||
status = 'Utworzono' if created else 'Istnieje juz'
|
||||
click.echo(f"{status} konto: id={user.id}, username={user.username}, admin={user.is_admin}")
|
||||
|
||||
|
||||
@admins_cli.command("promote")
|
||||
@click.argument("username")
|
||||
def admins_promote_command(username):
|
||||
with app.app_context():
|
||||
user = resolve_user_identifier(username)
|
||||
if not user:
|
||||
raise click.ClickException('Nie znaleziono uzytkownika.')
|
||||
user.is_admin = True
|
||||
db.session.commit()
|
||||
click.echo(f"Uzytkownik {user.username} ma teraz uprawnienia admina.")
|
||||
|
||||
|
||||
@admins_cli.command("demote")
|
||||
@click.argument("username")
|
||||
def admins_demote_command(username):
|
||||
with app.app_context():
|
||||
user = resolve_user_identifier(username)
|
||||
if not user:
|
||||
raise click.ClickException('Nie znaleziono uzytkownika.')
|
||||
user.is_admin = False
|
||||
db.session.commit()
|
||||
click.echo(f"Uzytkownik {user.username} nie jest juz adminem.")
|
||||
|
||||
|
||||
@admins_cli.command("set-password")
|
||||
@click.argument("username")
|
||||
@click.argument("password")
|
||||
def admins_set_password_command(username, password):
|
||||
with app.app_context():
|
||||
user = resolve_user_identifier(username)
|
||||
if not user:
|
||||
raise click.ClickException('Nie znaleziono uzytkownika.')
|
||||
user.password_hash = hash_password(password)
|
||||
db.session.commit()
|
||||
click.echo(f"Zmieniono haslo dla {user.username}.")
|
||||
|
||||
|
||||
@app.cli.group("lists")
|
||||
def lists_cli():
|
||||
"""Operacje CLI na listach zakupowych."""
|
||||
|
||||
|
||||
@lists_cli.command("copy-schedule")
|
||||
@click.option("--source-list-id", required=True, type=int, help="ID listy zrodlowej.")
|
||||
@click.option("--when", "when_value", required=True, help="Nowa data utworzenia listy: YYYY-MM-DD lub YYYY-MM-DD HH:MM")
|
||||
@click.option("--owner", "owner_value", default=None, help="Nowy wlasciciel: username albo ID. Domyslnie wlasciciel oryginalu.")
|
||||
@click.option("--title", default=None, help="Nowy tytul listy. Domyslnie taki sam jak w oryginale.")
|
||||
def lists_copy_schedule_command(source_list_id, when_value, owner_value, title):
|
||||
with app.app_context():
|
||||
source_list = ShoppingList.query.options(joinedload(ShoppingList.items), joinedload(ShoppingList.categories)).get(source_list_id)
|
||||
if not source_list:
|
||||
raise click.ClickException('Nie znaleziono listy zrodlowej.')
|
||||
|
||||
try:
|
||||
scheduled_for = parse_cli_datetime(when_value)
|
||||
except ValueError as exc:
|
||||
raise click.ClickException(str(exc))
|
||||
|
||||
owner = None
|
||||
if owner_value:
|
||||
owner = resolve_user_identifier(owner_value)
|
||||
if not owner:
|
||||
raise click.ClickException('Nie znaleziono docelowego wlasciciela.')
|
||||
|
||||
new_list = duplicate_list_for_schedule(source_list, scheduled_for=scheduled_for, owner=owner, title=title)
|
||||
click.echo(
|
||||
f"Utworzono kopie listy: nowa_id={new_list.id}, tytul={new_list.title}, created_at={new_list.created_at.isoformat()}"
|
||||
)
|
||||
5057
shopping_app/static/css/style.css
Normal file
@@ -29,7 +29,7 @@
|
||||
async function postAction(postUrl, nextPath, params) {
|
||||
const form = new FormData();
|
||||
for (const [k, v] of Object.entries(params)) form.set(k, v);
|
||||
form.set('next', nextPath); // dla trybu HTML fallback
|
||||
form.set('next', nextPath);
|
||||
|
||||
try {
|
||||
const res = await fetch(postUrl, {
|
||||
@@ -61,13 +61,16 @@
|
||||
const suggestUrl = box.dataset.suggestUrl || '';
|
||||
const grantAction = box.dataset.grantAction || 'grant';
|
||||
const revokeField = box.dataset.revokeField || 'revoke_user_id';
|
||||
const listId = box.dataset.listId || '';
|
||||
|
||||
const tokensBox = $('.tokens', box);
|
||||
const input = $('.access-input', box);
|
||||
const addBtn = $('.access-add', box);
|
||||
|
||||
// współdzielony datalist do sugestii
|
||||
let datalist = $('#userHintsGeneric');
|
||||
let datalist = null;
|
||||
const existingListId = input?.getAttribute('list');
|
||||
if (existingListId) datalist = document.getElementById(existingListId);
|
||||
if (!datalist) datalist = $('#userHintsGeneric');
|
||||
if (!datalist) {
|
||||
datalist = document.createElement('datalist');
|
||||
datalist.id = 'userHintsGeneric';
|
||||
@@ -79,25 +82,32 @@
|
||||
const parseUserText = (txt) => unique((txt || '').split(/[\s,;]+/g).map(s => s.trim().replace(/^@/, '').toLowerCase()).filter(Boolean));
|
||||
const debounce = (fn, ms = 200) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; };
|
||||
|
||||
// Sugestie (GET JSON)
|
||||
const renderHints = (users = []) => { datalist.innerHTML = users.slice(0, 20).map(u => `<option value="${u}">@${u}</option>`).join(''); };
|
||||
const initialOptions = Array.from(datalist.querySelectorAll('option')).map(o => o.value).filter(Boolean);
|
||||
const renderHints = (users = []) => {
|
||||
const merged = unique([...(users || []), ...initialOptions]).slice(0, 20);
|
||||
datalist.innerHTML = merged.map(u => `<option value="${u}"></option>`).join('');
|
||||
};
|
||||
renderHints(initialOptions);
|
||||
|
||||
let acCtrl = null;
|
||||
const fetchHints = debounce(async (q) => {
|
||||
if (!suggestUrl) return;
|
||||
try {
|
||||
acCtrl?.abort();
|
||||
acCtrl = new AbortController();
|
||||
const res = await fetch(`${suggestUrl}?q=${encodeURIComponent(q || '')}`, { credentials: 'same-origin', signal: acCtrl.signal });
|
||||
const normalized = String(q || '').trim().replace(/^@/, '');
|
||||
const res = await fetch(`${suggestUrl}?q=${encodeURIComponent(normalized)}`, { credentials: 'same-origin', signal: acCtrl.signal });
|
||||
if (!res.ok) return renderHints([]);
|
||||
const data = await res.json().catch(() => ({ users: [] }));
|
||||
renderHints(data.users || []);
|
||||
} catch { renderHints([]); }
|
||||
} catch {
|
||||
renderHints(initialOptions);
|
||||
}
|
||||
}, 200);
|
||||
|
||||
input?.addEventListener('focus', () => fetchHints(input.value));
|
||||
input?.addEventListener('input', () => fetchHints(input.value));
|
||||
|
||||
// Revoke (klik w token)
|
||||
box.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('.token');
|
||||
if (!btn || !box.contains(btn)) return;
|
||||
@@ -107,7 +117,7 @@
|
||||
if (!userId) return toast('Brak identyfikatora użytkownika.', 'danger');
|
||||
|
||||
btn.disabled = true; btn.classList.add('disabled');
|
||||
const res = await postAction(postUrl, nextPath, { [revokeField]: userId });
|
||||
const res = await postAction(postUrl, nextPath, { action: 'revoke', target_list_id: listId, [revokeField]: userId });
|
||||
|
||||
if (res.ok) {
|
||||
btn.remove();
|
||||
@@ -124,7 +134,6 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Grant (wiele loginów, bez przeładowania strony)
|
||||
async function addUsers() {
|
||||
const users = parseUserText(input?.value);
|
||||
if (!users?.length) return toast('Podaj co najmniej jednego użytkownika', 'warning');
|
||||
@@ -136,10 +145,9 @@
|
||||
let okCount = 0, failCount = 0, appended = 0;
|
||||
|
||||
for (const u of users) {
|
||||
const res = await postAction(postUrl, nextPath, { action: grantAction, grant_username: u });
|
||||
const res = await postAction(postUrl, nextPath, { action: grantAction, target_list_id: listId, grant_username: u });
|
||||
if (res.ok) {
|
||||
okCount++;
|
||||
// jeśli backend odda JSON z userem – dolep token live
|
||||
if (res.data?.user) {
|
||||
appendToken(box, res.data.user);
|
||||
appended++;
|
||||
@@ -156,9 +164,7 @@
|
||||
if (okCount) toast(`Dodano dostęp: ${okCount} użytkownika`, 'success');
|
||||
if (failCount) toast(`Błędy przy dodawaniu: ${failCount}`, 'danger');
|
||||
|
||||
// fallback: jeśli nic nie dolepiliśmy (brak JSON), odśwież, by zobaczyć nowe tokeny
|
||||
if (okCount && appended === 0) {
|
||||
// opóźnij minimalnie, by toast mignął
|
||||
setTimeout(() => location.reload(), 400);
|
||||
}
|
||||
}
|
||||
114
shopping_app/static/js/admin_settings.js
Normal file
@@ -0,0 +1,114 @@
|
||||
(function () {
|
||||
const form = document.getElementById("settings-form");
|
||||
const resetAllBtn = document.getElementById("reset-all");
|
||||
if (!form) return;
|
||||
|
||||
function getCard(input) {
|
||||
return input.closest(".settings-category-card");
|
||||
}
|
||||
|
||||
function getAutoHex(input) {
|
||||
const autoHex = (input.dataset.auto || "").trim();
|
||||
return autoHex ? autoHex.toUpperCase() : "#000000";
|
||||
}
|
||||
|
||||
function setOverrideState(input, enabled) {
|
||||
const card = getCard(input);
|
||||
const flag = card?.querySelector('.override-enabled');
|
||||
const badge = card?.querySelector('[data-role="override-status"]');
|
||||
input.dataset.hasOverride = enabled ? "1" : "0";
|
||||
if (flag) flag.value = enabled ? "1" : "0";
|
||||
if (badge) {
|
||||
badge.textContent = enabled ? "Nadpisany" : "Domyślny";
|
||||
badge.classList.toggle('text-bg-info', enabled);
|
||||
badge.classList.toggle('text-bg-secondary', !enabled);
|
||||
}
|
||||
}
|
||||
|
||||
function updatePreview(input) {
|
||||
const card = getCard(input);
|
||||
if (!card) return;
|
||||
const hexAutoEl = card.querySelector('.hex-auto');
|
||||
const hexEffEl = card.querySelector('.hex-effective');
|
||||
const barAuto = card.querySelector('.bar[data-kind="auto"]');
|
||||
const barEff = card.querySelector('.bar[data-kind="effective"]');
|
||||
const autoHex = getAutoHex(input);
|
||||
const effectiveHex = ((input.value || autoHex).trim() || autoHex).toUpperCase();
|
||||
const hasOverride = input.dataset.hasOverride === '1';
|
||||
|
||||
if (barAuto) barAuto.style.backgroundColor = autoHex;
|
||||
if (hexAutoEl) hexAutoEl.textContent = autoHex;
|
||||
if (barEff) barEff.style.backgroundColor = effectiveHex;
|
||||
if (hexEffEl) hexEffEl.textContent = effectiveHex;
|
||||
setOverrideState(input, hasOverride);
|
||||
}
|
||||
|
||||
function applyDefaultVisual(input, keepOverride) {
|
||||
input.value = getAutoHex(input);
|
||||
setOverrideState(input, !!keepOverride);
|
||||
updatePreview(input);
|
||||
}
|
||||
|
||||
form.querySelectorAll('.use-default').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const input = form.querySelector(`#${btn.dataset.target}`);
|
||||
if (!input) return;
|
||||
applyDefaultVisual(input, true);
|
||||
});
|
||||
});
|
||||
|
||||
form.querySelectorAll('.reset-one').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const input = form.querySelector(`#${btn.dataset.target}`);
|
||||
if (!input) return;
|
||||
applyDefaultVisual(input, false);
|
||||
});
|
||||
});
|
||||
|
||||
resetAllBtn?.addEventListener('click', () => {
|
||||
form.querySelectorAll('input[type="color"].category-color').forEach((input) => {
|
||||
applyDefaultVisual(input, false);
|
||||
});
|
||||
});
|
||||
|
||||
form.querySelectorAll('input[type="color"].category-color').forEach((input) => {
|
||||
updatePreview(input);
|
||||
input.addEventListener('input', () => {
|
||||
setOverrideState(input, true);
|
||||
updatePreview(input);
|
||||
});
|
||||
input.addEventListener('change', () => {
|
||||
setOverrideState(input, true);
|
||||
updatePreview(input);
|
||||
});
|
||||
});
|
||||
|
||||
(function () {
|
||||
const slider = document.getElementById('ocr_sensitivity');
|
||||
const badge = document.getElementById('ocr_sens_badge');
|
||||
const value = document.getElementById('ocr_sens_value');
|
||||
if (!slider || !badge || !value) return;
|
||||
|
||||
function labelFor(v) {
|
||||
v = Number(v);
|
||||
if (v <= 3) return 'Niski';
|
||||
if (v <= 7) return 'Średni';
|
||||
return 'Wysoki';
|
||||
}
|
||||
function clsFor(v) {
|
||||
v = Number(v);
|
||||
if (v <= 3) return 'sens-low';
|
||||
if (v <= 7) return 'sens-mid';
|
||||
return 'sens-high';
|
||||
}
|
||||
function update() {
|
||||
value.textContent = `(${slider.value})`;
|
||||
badge.textContent = labelFor(slider.value);
|
||||
badge.classList.remove('sens-low', 'sens-mid', 'sens-high');
|
||||
badge.classList.add(clsFor(slider.value));
|
||||
}
|
||||
slider.addEventListener('input', update);
|
||||
slider.addEventListener('change', update);
|
||||
update();
|
||||
})();
|
||||
})();
|
||||
227
shopping_app/static/js/app_ui.js
Normal file
@@ -0,0 +1,227 @@
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
enhancePasswordFields();
|
||||
enhanceSearchableTables();
|
||||
wireCopyButtons();
|
||||
wireUnsavedWarnings();
|
||||
enhanceMobileTables();
|
||||
wireAdminNavToggle();
|
||||
initResponsiveCategoryBadges();
|
||||
});
|
||||
|
||||
function enhancePasswordFields() {
|
||||
document.querySelectorAll('input[type="password"]').forEach(function (input) {
|
||||
if (input.dataset.uiPasswordReady === '1') return;
|
||||
if (input.closest('[data-ui-skip-toggle="true"]')) return;
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'ui-password-toggle';
|
||||
btn.setAttribute('aria-label', 'Pokaż lub ukryj hasło');
|
||||
btn.textContent = '👁';
|
||||
|
||||
btn.addEventListener('click', function () {
|
||||
const visible = input.type === 'text';
|
||||
input.type = visible ? 'password' : 'text';
|
||||
btn.textContent = visible ? '👁' : '🙈';
|
||||
btn.classList.toggle('is-active', !visible);
|
||||
});
|
||||
|
||||
if (input.parentElement && input.parentElement.classList.contains('input-group')) {
|
||||
input.parentElement.appendChild(btn);
|
||||
} else {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'input-group ui-password-group';
|
||||
input.parentNode.insertBefore(wrapper, input);
|
||||
wrapper.appendChild(input);
|
||||
wrapper.appendChild(btn);
|
||||
}
|
||||
|
||||
input.dataset.uiPasswordReady = '1';
|
||||
});
|
||||
}
|
||||
|
||||
function enhanceSearchableTables() {
|
||||
if (document.getElementById('search-table')) return;
|
||||
const tables = document.querySelectorAll('table.sortable, table[data-searchable="true"]');
|
||||
|
||||
tables.forEach(function (table, index) {
|
||||
if (table.dataset.uiSearchReady === '1') return;
|
||||
|
||||
const tbody = table.tBodies[0];
|
||||
if (!tbody) return;
|
||||
const rows = Array.from(tbody.querySelectorAll('tr'));
|
||||
if (rows.length < 6) return;
|
||||
|
||||
const toolbar = document.createElement('div');
|
||||
toolbar.className = 'table-toolbar';
|
||||
toolbar.innerHTML = [
|
||||
'<div class="input-group input-group-sm table-toolbar__search">',
|
||||
' <span class="input-group-text">🔎</span>',
|
||||
' <input type="search" class="form-control" placeholder="Filtruj tabelę…" aria-label="Filtruj tabelę">',
|
||||
' <button type="button" class="btn btn-outline-light">Wyczyść</button>',
|
||||
'</div>',
|
||||
'<div class="table-toolbar__meta text-secondary small">',
|
||||
' <span class="table-toolbar__count"></span>',
|
||||
'</div>'
|
||||
].join('');
|
||||
|
||||
const input = toolbar.querySelector('input');
|
||||
const clearBtn = toolbar.querySelector('button');
|
||||
const count = toolbar.querySelector('.table-toolbar__count');
|
||||
|
||||
function updateTableFilter() {
|
||||
const query = (input.value || '').trim().toLowerCase();
|
||||
let visible = 0;
|
||||
rows.forEach(function (row) {
|
||||
const rowText = row.innerText.toLowerCase();
|
||||
const match = !query || rowText.includes(query);
|
||||
row.style.display = match ? '' : 'none';
|
||||
if (match) visible += 1;
|
||||
});
|
||||
count.textContent = 'Widoczne: ' + visible + ' / ' + rows.length;
|
||||
}
|
||||
|
||||
input.addEventListener('input', updateTableFilter);
|
||||
clearBtn.addEventListener('click', function () {
|
||||
input.value = '';
|
||||
updateTableFilter();
|
||||
input.focus();
|
||||
});
|
||||
|
||||
const container = table.closest('.table-responsive') || table;
|
||||
container.parentNode.insertBefore(toolbar, container);
|
||||
updateTableFilter();
|
||||
table.dataset.uiSearchReady = '1';
|
||||
});
|
||||
}
|
||||
|
||||
function wireCopyButtons() {
|
||||
document.querySelectorAll('[data-copy-target]').forEach(function (button) {
|
||||
if (button.dataset.uiCopyReady === '1') return;
|
||||
button.dataset.uiCopyReady = '1';
|
||||
|
||||
button.addEventListener('click', async function () {
|
||||
const target = document.querySelector(button.dataset.copyTarget);
|
||||
if (!target) return;
|
||||
const text = target.value || target.textContent || '';
|
||||
try {
|
||||
await navigator.clipboard.writeText(text.trim());
|
||||
const original = button.textContent;
|
||||
button.textContent = '✅ Skopiowano';
|
||||
setTimeout(function () {
|
||||
button.textContent = original;
|
||||
}, 1800);
|
||||
} catch (err) {
|
||||
console.warn('Copy failed', err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function wireUnsavedWarnings() {
|
||||
const trackedForms = Array.from(document.querySelectorAll('form[data-unsaved-warning="true"]'));
|
||||
if (!trackedForms.length) return;
|
||||
|
||||
trackedForms.forEach(function (form) {
|
||||
if (form.dataset.uiUnsavedReady === '1') return;
|
||||
form.dataset.uiUnsavedReady = '1';
|
||||
form.dataset.uiDirty = '0';
|
||||
|
||||
const markDirty = function () {
|
||||
form.dataset.uiDirty = '1';
|
||||
form.classList.add('is-dirty');
|
||||
};
|
||||
|
||||
form.addEventListener('input', markDirty);
|
||||
form.addEventListener('change', markDirty);
|
||||
form.addEventListener('submit', function () {
|
||||
form.dataset.uiDirty = '0';
|
||||
form.classList.remove('is-dirty');
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', function (event) {
|
||||
const hasDirty = trackedForms.some(function (form) {
|
||||
return form.dataset.uiDirty === '1';
|
||||
});
|
||||
if (!hasDirty) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function enhanceMobileTables() {
|
||||
document.querySelectorAll('table').forEach(function (table) {
|
||||
if (table.dataset.mobileLabelsReady === '1') return;
|
||||
const headers = Array.from(table.querySelectorAll('thead th')).map(function (th) {
|
||||
return (th.innerText || '').trim();
|
||||
});
|
||||
if (!headers.length) return;
|
||||
table.querySelectorAll('tbody tr').forEach(function (row) {
|
||||
Array.from(row.children).forEach(function (cell, index) {
|
||||
if (!cell.dataset.label && headers[index]) {
|
||||
cell.dataset.label = headers[index];
|
||||
}
|
||||
});
|
||||
});
|
||||
table.dataset.mobileLabelsReady = '1';
|
||||
});
|
||||
}
|
||||
|
||||
function wireAdminNavToggle() {
|
||||
const toggle = document.querySelector('[data-admin-nav-toggle]');
|
||||
const nav = document.querySelector('[data-admin-nav-body]');
|
||||
if (!toggle || !nav) return;
|
||||
|
||||
toggle.addEventListener('click', function () {
|
||||
const expanded = toggle.getAttribute('aria-expanded') === 'true';
|
||||
toggle.setAttribute('aria-expanded', expanded ? 'false' : 'true');
|
||||
nav.classList.toggle('is-open', !expanded);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function initResponsiveCategoryBadges() {
|
||||
const headings = Array.from(document.querySelectorAll('[data-mobile-list-heading]'));
|
||||
if (!headings.length) return;
|
||||
|
||||
const update = function () {
|
||||
const isMobile = window.matchMedia('(max-width: 575.98px)').matches;
|
||||
|
||||
headings.forEach(function (heading) {
|
||||
const title = heading.querySelector('[data-mobile-list-title]');
|
||||
const group = heading.querySelector('[data-mobile-category-group]');
|
||||
if (!title || !group) return;
|
||||
|
||||
group.classList.remove('is-compact');
|
||||
if (!isMobile || !group.children.length) return;
|
||||
|
||||
const headingWidth = Math.ceil(heading.getBoundingClientRect().width);
|
||||
if (!headingWidth) return;
|
||||
|
||||
const titleRect = title.getBoundingClientRect();
|
||||
const groupRect = group.getBoundingClientRect();
|
||||
const titleWidth = Math.ceil(titleRect.width);
|
||||
const groupWidth = Math.ceil(group.scrollWidth);
|
||||
const wrapped = groupRect.top - titleRect.top > 4;
|
||||
const needsCompact = wrapped || (titleWidth + groupWidth > headingWidth);
|
||||
group.classList.toggle('is-compact', needsCompact);
|
||||
});
|
||||
};
|
||||
|
||||
let resizeTimer = null;
|
||||
window.addEventListener('resize', function () {
|
||||
window.clearTimeout(resizeTimer);
|
||||
resizeTimer = window.setTimeout(update, 60);
|
||||
});
|
||||
|
||||
if (typeof ResizeObserver === 'function') {
|
||||
const observer = new ResizeObserver(update);
|
||||
headings.forEach(function (heading) {
|
||||
observer.observe(heading);
|
||||
});
|
||||
}
|
||||
|
||||
update();
|
||||
}
|
||||
@@ -25,16 +25,15 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
}
|
||||
|
||||
checkbox.disabled = true;
|
||||
row.classList.add('opacity-50');
|
||||
row.classList.add('opacity-50', 'is-pending');
|
||||
|
||||
// Dodaj spinner tylko jeśli nie ma
|
||||
let existingSpinner = row.querySelector('.spinner-border');
|
||||
let existingSpinner = row.querySelector('.shopping-item-spinner');
|
||||
if (!existingSpinner) {
|
||||
const spinner = document.createElement('span');
|
||||
spinner.className = 'spinner-border spinner-border-sm ms-2';
|
||||
spinner.className = 'shopping-item-spinner spinner-border spinner-border-sm';
|
||||
spinner.setAttribute('role', 'status');
|
||||
spinner.setAttribute('aria-hidden', 'true');
|
||||
checkbox.parentElement.appendChild(spinner);
|
||||
row.appendChild(spinner);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ function updateItemState(itemId, isChecked) {
|
||||
checkbox.checked = isChecked;
|
||||
checkbox.disabled = false;
|
||||
const li = checkbox.closest('li');
|
||||
li.classList.remove('opacity-50', 'bg-light', 'text-dark', 'bg-success', 'text-white');
|
||||
li.classList.remove('opacity-50', 'is-pending', 'bg-light', 'text-dark', 'bg-success', 'text-white', 'bg-warning', 'item-not-checked');
|
||||
|
||||
if (isChecked) {
|
||||
li.classList.add('bg-success', 'text-white');
|
||||
@@ -12,8 +12,7 @@ function updateItemState(itemId, isChecked) {
|
||||
li.classList.add('item-not-checked');
|
||||
}
|
||||
|
||||
const sp = li.querySelector('.spinner-border');
|
||||
if (sp) sp.remove();
|
||||
li.querySelectorAll('.shopping-item-spinner, .spinner-border').forEach(sp => sp.remove());
|
||||
}
|
||||
updateProgressBar();
|
||||
applyHidePurchased();
|
||||
@@ -257,6 +256,17 @@ function showToast(message, type = 'primary') {
|
||||
setTimeout(() => { toast.remove(); }, 1750);
|
||||
}
|
||||
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
|
||||
function isListDifferent(oldItems, newItems) {
|
||||
if (oldItems.length !== newItems.length) return true;
|
||||
|
||||
@@ -271,96 +281,86 @@ function isListDifferent(oldItems, newItems) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
function renderItem(item, isShare = window.IS_SHARE, showEditOnly = false) {
|
||||
const li = document.createElement('li');
|
||||
li.id = `item-${item.id}`;
|
||||
li.dataset.name = item.name.toLowerCase();
|
||||
li.className = `list-group-item d-flex justify-content-between align-items-center flex-wrap clickable-item ${item.purchased ? 'bg-success text-white'
|
||||
li.dataset.name = String(item.name || '').toLowerCase();
|
||||
li.dataset.isShare = isShare ? 'true' : 'false';
|
||||
li.className = `list-group-item shopping-item-row clickable-item ${item.purchased ? 'bg-success text-white'
|
||||
: item.not_purchased ? 'bg-warning text-dark'
|
||||
: 'item-not-checked'
|
||||
}`;
|
||||
|
||||
const isOwner = window.IS_OWNER === true || window.IS_OWNER === 'true';
|
||||
const allowEdit = !isShare || showEditOnly || isOwner;
|
||||
const isArchived = window.IS_ARCHIVED === true || window.IS_ARCHIVED === 'true';
|
||||
const safeName = escapeHtml(item.name || '');
|
||||
const nameForEdit = JSON.stringify(String(item.name || ''));
|
||||
const quantity = Number.isInteger(item.quantity) ? item.quantity : parseInt(item.quantity, 10) || 1;
|
||||
const quantityBadge = quantity > 1
|
||||
? `<span class="badge rounded-pill bg-secondary">x${quantity}</span>`
|
||||
: '';
|
||||
|
||||
let quantityBadge = '';
|
||||
if (item.quantity && item.quantity > 1) {
|
||||
quantityBadge = `<span class="badge bg-secondary">x${item.quantity}</span>`;
|
||||
const canEditListItem = !isShare;
|
||||
const canShowShareActions = isShare && !showEditOnly;
|
||||
const canMarkNotPurchased = !item.not_purchased && !isArchived;
|
||||
const checkboxHtml = `<input id="checkbox-${item.id}" class="large-checkbox" type="checkbox" ${item.purchased ? 'checked' : ''} ${(item.not_purchased || isArchived) ? 'disabled' : ''}>`;
|
||||
|
||||
const infoParts = [];
|
||||
if (item.note) {
|
||||
infoParts.push(`<span class="text-danger">[ <b>${escapeHtml(item.note)}</b> ]</span>`);
|
||||
}
|
||||
if (item.not_purchased_reason) {
|
||||
infoParts.push(`<span class="text-dark">[ <b>Powód: ${escapeHtml(item.not_purchased_reason)}</b> ]</span>`);
|
||||
}
|
||||
const addedByDisplay = item.added_by_display;
|
||||
if (addedByDisplay) {
|
||||
infoParts.push(`<span class="text-info">[ Dodał/a: <b>${escapeHtml(addedByDisplay)}</b> ]</span>`);
|
||||
}
|
||||
const infoHtml = infoParts.length
|
||||
? `<span class="info-line small" id="info-${item.id}">${infoParts.join(' ')}</span>`
|
||||
: '';
|
||||
|
||||
const iconBtn = 'btn btn-outline-light btn-sm shopping-action-btn';
|
||||
const wideBtn = 'btn btn-outline-light btn-sm shopping-action-btn shopping-action-btn--wide';
|
||||
let actionButtons = '';
|
||||
|
||||
if (canEditListItem) {
|
||||
actionButtons += `
|
||||
<button type="button" class="${iconBtn} drag-handle" title="Przesuń produkt" aria-label="Przesuń produkt" ${isArchived ? 'disabled' : ''}>☰</button>
|
||||
<button type="button" class="${iconBtn}" ${isArchived ? 'disabled' : `onclick="editItem(${item.id}, ${nameForEdit}, ${quantity})"`}>✏️</button>
|
||||
<button type="button" class="${iconBtn}" ${isArchived ? 'disabled' : `onclick="deleteItem(${item.id})"`}>🗑️</button>`;
|
||||
}
|
||||
|
||||
let checkboxOrIcon = item.not_purchased
|
||||
? `<span class="ms-1 block-icon">🚫</span>`
|
||||
: `<input id="checkbox-${item.id}" class="large-checkbox" type="checkbox" ${item.purchased ? 'checked' : ''}>`;
|
||||
|
||||
let noteHTML = item.note
|
||||
? `<small class="text-danger ms-4">[ <b>${item.note}</b> ]</small>` : '';
|
||||
|
||||
let reasonHTML = item.not_purchased_reason
|
||||
? `<small class="text-dark ms-4">[ <b>Powód: ${item.not_purchased_reason}</b> ]</small>` : '';
|
||||
|
||||
let dragHandle = window.isSorting ? `<span class="drag-handle me-2 text-danger" style="cursor: grab;">☰</span>` : '';
|
||||
|
||||
let left = `
|
||||
<div class="d-flex align-items-center gap-2 flex-grow-1">
|
||||
${dragHandle}
|
||||
${checkboxOrIcon}
|
||||
<span id="name-${item.id}" class="text-white">${item.name} ${quantityBadge}</span>
|
||||
${noteHTML}
|
||||
${reasonHTML}
|
||||
</div>`;
|
||||
|
||||
let rightButtons = '';
|
||||
|
||||
// ✏️ i 🗑️ — tylko jeśli nie jesteśmy w trybie /share lub jesteśmy w 15s (tymczasowo) lub jesteśmy właścicielem
|
||||
if (allowEdit) {
|
||||
rightButtons += `
|
||||
<button type="button" class="btn btn-outline-light"
|
||||
onclick="editItem(${item.id}, '${item.name.replace(/'/g, "\\'")}', ${item.quantity || 1})">
|
||||
✏️
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-light"
|
||||
onclick="deleteItem(${item.id})">
|
||||
🗑️
|
||||
</button>`;
|
||||
}
|
||||
|
||||
// ✅ Jeśli element jest oznaczony jako niekupiony — pokaż "Przywróć"
|
||||
if (item.not_purchased) {
|
||||
rightButtons += `
|
||||
<button type="button" class="btn btn-outline-light me-auto"
|
||||
onclick="unmarkNotPurchased(${item.id})">
|
||||
✅ Przywróć
|
||||
</button>`;
|
||||
actionButtons += `
|
||||
<button type="button" class="${wideBtn}" ${isArchived ? 'disabled' : `onclick="unmarkNotPurchased(${item.id})"`}>✅ Przywróć</button>`;
|
||||
} else if (!isShare || canShowShareActions || isOwner) {
|
||||
actionButtons += `
|
||||
<button type="button" class="${iconBtn}" ${canMarkNotPurchased ? `onclick="markNotPurchasedModal(event, ${item.id})"` : 'disabled'}>⚠️</button>`;
|
||||
}
|
||||
|
||||
// ⚠️ tylko jeśli NIE jest oznaczony jako niekupiony i nie jesteśmy w 15s
|
||||
if (!item.not_purchased && (isOwner || (isShare && !showEditOnly))) {
|
||||
|
||||
rightButtons += `
|
||||
<button type="button" class="btn btn-outline-light"
|
||||
onclick="markNotPurchasedModal(event, ${item.id})">
|
||||
⚠️
|
||||
</button>`;
|
||||
if (canShowShareActions) {
|
||||
actionButtons += `
|
||||
<button type="button" class="${iconBtn}" ${isArchived ? 'disabled' : `onclick="openNoteModal(event, ${item.id})"`}>📝</button>`;
|
||||
}
|
||||
|
||||
// 📝 tylko jeśli jesteśmy w /share i nie jesteśmy w 15s
|
||||
if (isShare && !showEditOnly && !isOwner) {
|
||||
|
||||
rightButtons += `
|
||||
<button type="button" class="btn btn-outline-light"
|
||||
onclick="openNoteModal(event, ${item.id})">
|
||||
📝
|
||||
</button>`;
|
||||
}
|
||||
|
||||
li.innerHTML = `${left}<div class="btn-group btn-group-sm" role="group">${rightButtons}</div>`;
|
||||
|
||||
if (item.added_by && item.owner_id && item.added_by_id && item.added_by_id !== item.owner_id) {
|
||||
const infoEl = document.createElement('small');
|
||||
infoEl.className = 'text-info ms-4';
|
||||
infoEl.innerHTML = `[Dodał/a: <b>${item.added_by}</b>]`;
|
||||
li.querySelector('.d-flex.align-items-center')?.appendChild(infoEl);
|
||||
}
|
||||
li.innerHTML = `
|
||||
<div class="shopping-item-main">
|
||||
${checkboxHtml}
|
||||
<div class="shopping-item-content">
|
||||
<div class="shopping-item-head">
|
||||
<div class="shopping-item-text">
|
||||
<span id="name-${item.id}" class="shopping-item-name text-white">${safeName}</span>
|
||||
${quantityBadge}
|
||||
${infoHtml}
|
||||
</div>
|
||||
<div class="list-item-actions shopping-item-actions" role="group">
|
||||
${actionButtons}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
return li;
|
||||
}
|
||||
22
shopping_app/static/js/lists_access.js
Normal file
@@ -0,0 +1,22 @@
|
||||
(function () {
|
||||
const $=(s,r=document)=>r.querySelector(s); const $$=(s,r=document)=>Array.from(r.querySelectorAll(s));
|
||||
const filterInput=$('#listFilter'),filterCount=$('#filterCount'),selectAll=$('#selectAll'),bulkTokens=$('#bulkTokens'),bulkInput=$('#bulkUsersInput'),bulkBtn=$('#bulkAddBtn');
|
||||
const unique=arr=>Array.from(new Set(arr));
|
||||
const parseUserText=txt=>unique((txt||'').split(/[\s,;]+/g).map(s=>s.trim().replace(/^@/,'').toLowerCase()).filter(Boolean));
|
||||
const selectedListIds=()=>$$('.row-check:checked').map(ch=>ch.dataset.listId);
|
||||
const visibleRows=()=>$$('#listsTable tbody tr').filter(r=>r.style.display!=='none');
|
||||
function applyFilter(){const q=(filterInput?.value||'').trim().toLowerCase();let shown=0;$$('#listsTable tbody tr').forEach(tr=>{const hay=`${tr.dataset.id||''} ${tr.dataset.title||''} ${tr.dataset.owner||''}`;const ok=!q||hay.includes(q);tr.style.display=ok?'':'none';if(ok) shown++;});if(filterCount) filterCount.textContent=shown?`Widoczne: ${shown}`:'Brak wyników';}
|
||||
filterInput?.addEventListener('input',applyFilter);applyFilter();
|
||||
selectAll?.addEventListener('change',()=>{visibleRows().forEach(tr=>{const cb=tr.querySelector('.row-check'); if(cb) cb.checked=selectAll.checked;});});
|
||||
$$('.copy-share').forEach(btn=>btn.addEventListener('click',async()=>{const url=btn.dataset.url;try{await navigator.clipboard.writeText(url);}catch{const ta=Object.assign(document.createElement('textarea'),{value:url});document.body.appendChild(ta);ta.select();document.execCommand('copy');ta.remove();}showToast('Skopiowano link udostępnienia','success');}));
|
||||
function addGlobalToken(username){if(!username) return;const exists=$(`.user-token[data-user="${username}"]`,bulkTokens);if(exists) return;const token=document.createElement('span');token.className='badge rounded-pill text-bg-secondary user-token';token.dataset.user=username;token.innerHTML=`@${username} <button type="button" class="btn btn-sm btn-link p-0 ms-1 text-white">✕</button>`;token.querySelector('button').addEventListener('click',()=>token.remove());bulkTokens.appendChild(token);}
|
||||
bulkInput?.addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();parseUserText(bulkInput.value).forEach(addGlobalToken);bulkInput.value='';}});
|
||||
bulkInput?.addEventListener('change',()=>{parseUserText(bulkInput.value).forEach(addGlobalToken);bulkInput.value='';});
|
||||
let hintCtrl=null;
|
||||
function renderBulkHints(users){const dl=$('#userHints'); if(!dl) return; dl.innerHTML=(users||[]).slice(0,20).map(u=>`<option value="${u}"></option>`).join('');}
|
||||
async function fetchBulkHints(q=''){const normalized=String(q||'').trim().replace(/^@/,'');try{hintCtrl?.abort();hintCtrl=new AbortController();const res=await fetch(`/admin/user-suggestions?q=${encodeURIComponent(normalized)}`,{credentials:'same-origin',signal:hintCtrl.signal});if(!res.ok) return renderBulkHints([]);const data=await res.json().catch(()=>({users:[]}));renderBulkHints(data.users||[]);}catch(e){renderBulkHints([]);}}
|
||||
bulkInput?.addEventListener('focus',()=>fetchBulkHints(bulkInput.value));
|
||||
bulkInput?.addEventListener('input',()=>fetchBulkHints(bulkInput.value));
|
||||
async function bulkGrant(){const lists=selectedListIds(), users=$$('.user-token',bulkTokens).map(t=>t.dataset.user);if(!lists.length) return showToast('Zaznacz przynajmniej jedną listę','warning');if(!users.length) return showToast('Dodaj przynajmniej jednego użytkownika','warning');bulkBtn.disabled=true;bulkBtn.textContent='Pracuję…';const url=location.pathname+location.search;let ok=0,fail=0;for(const lid of lists){for(const u of users){const form=new FormData();form.set('action','grant');form.set('target_list_id',lid);form.set('grant_username',u);try{const res=await fetch(url,{method:'POST',body:form,credentials:'same-origin',headers:{'Accept':'application/json','X-Requested-With':'fetch'}});if(res.ok) ok++; else fail++;}catch{fail++;}}}bulkBtn.disabled=false;bulkBtn.textContent='➕ Nadaj dostęp';showToast(`Gotowe. Sukcesy: ${ok}${fail?`, błędy: ${fail}`:''}`,fail?'danger':'success');if(ok) location.reload();}
|
||||
bulkBtn?.addEventListener('click',bulkGrant);
|
||||
})();
|
||||
@@ -88,15 +88,15 @@ function setupList(listId, username) {
|
||||
}
|
||||
|
||||
e.target.disabled = true;
|
||||
li.classList.add('opacity-50');
|
||||
li.classList.add('opacity-50', 'is-pending');
|
||||
|
||||
let existingSpinner = li.querySelector('.spinner-border');
|
||||
let existingSpinner = li.querySelector('.shopping-item-spinner');
|
||||
if (!existingSpinner) {
|
||||
const spinner = document.createElement('span');
|
||||
spinner.className = 'spinner-border spinner-border-sm ms-2';
|
||||
spinner.className = 'shopping-item-spinner spinner-border spinner-border-sm';
|
||||
spinner.setAttribute('role', 'status');
|
||||
spinner.setAttribute('aria-hidden', 'true');
|
||||
e.target.parentElement.appendChild(spinner);
|
||||
li.appendChild(spinner);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,7 +139,7 @@ function setupList(listId, username) {
|
||||
note: ''
|
||||
};
|
||||
|
||||
const li = renderItem(item, false, true); // ← tryb 15s
|
||||
const li = renderItem(item, window.IS_SHARE, true);
|
||||
document.getElementById('items').appendChild(li);
|
||||
toggleEmptyPlaceholder();
|
||||
updateProgressBar();
|
||||
@@ -176,7 +176,7 @@ function setupList(listId, username) {
|
||||
setTimeout(() => {
|
||||
const existing = document.getElementById(`item-${data.id}`);
|
||||
if (existing) {
|
||||
const updated = renderItem(item, true);
|
||||
const updated = renderItem(item, window.IS_SHARE);
|
||||
existing.replaceWith(updated);
|
||||
}
|
||||
}, 15000);
|
||||
@@ -5,7 +5,6 @@ function enableSortMode() {
|
||||
if (isSorting) return;
|
||||
isSorting = true;
|
||||
window.isSorting = true;
|
||||
localStorage.setItem('sortModeEnabled', 'true');
|
||||
|
||||
const itemsContainer = document.getElementById('items');
|
||||
const listId = window.LIST_ID;
|
||||
@@ -22,7 +21,7 @@ function enableSortMode() {
|
||||
animation: 150,
|
||||
handle: '.drag-handle',
|
||||
ghostClass: 'drag-ghost',
|
||||
filter: 'input, button',
|
||||
filter: 'input, button:not(.drag-handle)',
|
||||
preventOnFilter: false,
|
||||
onEnd: () => {
|
||||
const order = Array.from(itemsContainer.children)
|
||||
@@ -57,7 +56,6 @@ function disableSortMode() {
|
||||
}
|
||||
|
||||
isSorting = false;
|
||||
localStorage.removeItem('sortModeEnabled');
|
||||
window.isSorting = false;
|
||||
if (window.currentItems) {
|
||||
updateListSmoothly(window.currentItems);
|
||||
@@ -73,6 +71,7 @@ function toggleSortMode() {
|
||||
|
||||
function updateSortButtonUI(active) {
|
||||
const btn = document.getElementById('sort-toggle-btn');
|
||||
document.body.classList.toggle('sorting-active', !!active);
|
||||
if (!btn) return;
|
||||
|
||||
if (active) {
|
||||
@@ -87,8 +86,8 @@ function updateSortButtonUI(active) {
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const wasSorting = localStorage.getItem('sortModeEnabled') === 'true';
|
||||
if (wasSorting) {
|
||||
enableSortMode();
|
||||
}
|
||||
isSorting = false;
|
||||
window.isSorting = false;
|
||||
document.body.classList.remove('sorting-active');
|
||||
updateSortButtonUI(false);
|
||||
});
|
||||
30
shopping_app/static/js/toggle_button.js
Normal file
@@ -0,0 +1,30 @@
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const toggleBtn = document.getElementById("tempToggle");
|
||||
const hiddenInput = document.getElementById("temporaryHidden");
|
||||
if (!toggleBtn || !hiddenInput) return;
|
||||
|
||||
if (typeof bootstrap !== "undefined") {
|
||||
new bootstrap.Tooltip(toggleBtn);
|
||||
}
|
||||
|
||||
function updateToggle(isActive) {
|
||||
toggleBtn.classList.toggle("is-active", isActive);
|
||||
toggleBtn.textContent = isActive ? "Tymczasowa ✔" : "Tymczasowa";
|
||||
toggleBtn.setAttribute("aria-pressed", isActive ? "true" : "false");
|
||||
toggleBtn.setAttribute("title", isActive
|
||||
? "Lista tymczasowa będzie ważna przez 7 dni"
|
||||
: "Po zaznaczeniu lista będzie ważna tylko 7 dni");
|
||||
}
|
||||
|
||||
let active = toggleBtn.getAttribute("data-active") === "1";
|
||||
hiddenInput.value = active ? "1" : "0";
|
||||
updateToggle(active);
|
||||
|
||||
toggleBtn.addEventListener("click", function (event) {
|
||||
event.preventDefault();
|
||||
active = !active;
|
||||
toggleBtn.setAttribute("data-active", active ? "1" : "0");
|
||||
hiddenInput.value = active ? "1" : "0";
|
||||
updateToggle(active);
|
||||
});
|
||||
});
|
||||
|
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 |
18
shopping_app/templates/admin/_nav.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<div class="card bg-secondary bg-opacity-10 text-white mb-4 admin-shortcuts">
|
||||
<div class="card-body p-2">
|
||||
<div class="d-md-none mb-2">
|
||||
<button type="button" class="btn btn-outline-light w-100" data-admin-nav-toggle aria-expanded="false">☰ Menu admina</button>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2" data-admin-nav-body>
|
||||
<a href="{{ url_for('admin_panel') }}" class="btn btn-sm {% if request.endpoint == 'admin_panel' %}btn-success{% else %}btn-outline-light{% endif %}">📊 Dashboard</a>
|
||||
<a href="{{ url_for('list_users') }}" class="btn btn-sm {% if request.endpoint == 'list_users' %}btn-success{% else %}btn-outline-light{% endif %}">👥 Użytkownicy</a>
|
||||
<a href="{{ url_for('admin_receipts') }}" class="btn btn-sm {% if request.endpoint == 'admin_receipts' %}btn-success{% else %}btn-outline-light{% endif %}">📸 Paragony</a>
|
||||
<a href="{{ url_for('list_products') }}" class="btn btn-sm {% if request.endpoint == 'list_products' %}btn-success{% else %}btn-outline-light{% endif %}">🛍️ Produkty</a>
|
||||
<a href="{{ url_for('admin_edit_categories') }}" class="btn btn-sm {% if request.endpoint == 'admin_edit_categories' %}btn-success{% else %}btn-outline-light{% endif %}">🗂 Kategorie</a>
|
||||
<a href="{{ url_for('admin_lists_access') }}" class="btn btn-sm {% if request.endpoint == 'admin_lists_access' %}btn-success{% else %}btn-outline-light{% endif %}">🔐 Uprawnienia</a>
|
||||
<a href="{{ url_for('admin_api_tokens') }}" class="btn btn-sm {% if request.endpoint in ['admin_api_tokens', 'admin_api_docs'] %}btn-success{% else %}btn-outline-light{% endif %}">🔑 Tokeny API</a>
|
||||
<a href="{{ url_for('admin_templates') }}" class="btn btn-sm {% if request.endpoint == 'admin_templates' %}btn-success{% else %}btn-outline-light{% endif %}">🧩 Szablony</a>
|
||||
<a href="{{ url_for('admin_settings') }}" class="btn btn-sm {% if request.endpoint == 'admin_settings' %}btn-success{% else %}btn-outline-light{% endif %}">⚙️ Ustawienia</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2,23 +2,16 @@
|
||||
{% 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="admin-page-head 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>
|
||||
|
||||
<div class="card bg-secondary bg-opacity-10 text-white mb-4">
|
||||
<div class="card-body p-2">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<a href="{{ url_for('list_users') }}" class="btn btn-outline-light btn-sm">👥 Użytkownicy</a>
|
||||
<a href="{{ url_for('admin_receipts') }}" class="btn btn-outline-light btn-sm">📸 Paragony</a>
|
||||
<a href="{{ url_for('list_products') }}" class="btn btn-outline-light btn-sm">🛍️ Produkty</a>
|
||||
<a href="{{ url_for('admin_edit_categories') }}" class="btn btn-outline-light btn-sm">🗂 Kategorie</a>
|
||||
<a href="{{ url_for('admin_lists_access') }}" class="btn btn-outline-light btn-sm">🔐 Uprawnienia</a>
|
||||
<a href="{{ url_for('admin_settings') }}" class="btn btn-outline-light btn-sm">⚙️ Ustawienia</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include 'admin/_nav.html' %}
|
||||
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<!-- Statystyki liczbowe -->
|
||||
@@ -158,7 +151,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-dark text-white mb-5">
|
||||
{% if expiring_lists %}<div class="alert alert-warning mb-4"><div class="fw-semibold mb-2">⏰ Listy tymczasowe wygasające w ciągu 24h</div><ul class="mb-0 ps-3">{% for l in expiring_lists %}<li>#{{ l.id }} {{ l.title }} — {{ l.owner.username if l.owner else '—' }} — {{ l.expires_at.strftime('%Y-%m-%d %H:%M') }}</li>{% endfor %}</ul></div>{% endif %}<div class="card bg-dark text-white mb-5">
|
||||
<div class="card-body">
|
||||
|
||||
{# panel wyboru miesiąca zawsze widoczny #}
|
||||
@@ -243,10 +236,10 @@
|
||||
{% for e in enriched_lists %}
|
||||
{% set l = e.list %}
|
||||
<tr>
|
||||
<td><input type="checkbox" name="list_ids" value="{{ l.id }}"></td>
|
||||
<td><input type="checkbox" name="list_ids" value="{{ l.id }}" class="table-select-checkbox"></td>
|
||||
<td>{{ l.id }}</td>
|
||||
<td class="fw-bold align-middle">
|
||||
<a href="{{ url_for('view_list', list_id=l.id) }}" class="text-white">{{ l.title }}</a>
|
||||
<a href="{{ url_for('view_list', list_id=l.id) }}" class="text-white">{{ l.title }}</a>{% if l.is_temporary and l.expires_at %}<div class="small text-warning mt-1">wygasa: {{ l.expires_at.strftime('%Y-%m-%d %H:%M') }}</div>{% endif %}
|
||||
{% if l.categories %}
|
||||
<span class="ms-1 text-info" data-bs-toggle="tooltip"
|
||||
title="{{ l.categories | map(attribute='name') | join(', ') }}">
|
||||
@@ -295,13 +288,9 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<a href="{{ url_for('edit_list', list_id=l.id) }}" class="btn btn-sm btn-outline-light"
|
||||
title="Edytuj">✏️</a>
|
||||
<button type="button" class="btn btn-sm btn-outline-light preview-btn" data-list-id="{{ l.id }}"
|
||||
title="Podgląd produktów">
|
||||
👁️
|
||||
</button>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<a href="{{ url_for('edit_list', list_id=l.id) }}" class="btn btn-sm btn-outline-light" title="Edytuj">✏️</a>
|
||||
<button type="button" class="btn btn-sm btn-outline-light preview-btn" data-list-id="{{ l.id }}" title="Podgląd produktów">👁️</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
161
shopping_app/templates/admin/api_tokens.html
Normal file
@@ -0,0 +1,161 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Tokeny API{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4 gap-2">
|
||||
<div>
|
||||
<h2 class="mb-2">🔑 Tokeny API</h2>
|
||||
<p class="text-secondary mb-0">Administrator może utworzyć wiele tokenów, ograniczyć ich zakres i endpointy oraz w każdej chwili je wyłączyć albo usunąć.</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<a href="{{ url_for('admin_api_docs') }}" class="btn btn-outline-light" target="_blank">📄 Zobacz opis API</a>
|
||||
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-secondary">← Powrót do panelu</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include 'admin/_nav.html' %}
|
||||
|
||||
{% if latest_plain_token %}
|
||||
<div class="alert alert-success border-success mb-4" role="alert">
|
||||
<div class="d-flex flex-column gap-2">
|
||||
<div><strong>Nowy token:</strong> {{ latest_api_token_name or 'API' }}</div>
|
||||
<div class="input-group">
|
||||
<input type="text" id="latestApiToken" class="form-control" readonly value="{{ latest_plain_token }}">
|
||||
<button type="button" class="btn btn-outline-light" data-copy-target="#latestApiToken">📋 Kopiuj</button>
|
||||
</div>
|
||||
<div class="small text-warning">Pełna wartość jest widoczna tylko teraz. Po odświeżeniu zostanie ukryta.</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card bg-dark text-white">
|
||||
<div class="card-body">
|
||||
<h5 class="mb-3">➕ Utwórz token</h5>
|
||||
<form method="post" data-unsaved-warning="true" class="stack-form">
|
||||
<input type="hidden" name="action" value="create">
|
||||
<div class="row g-4">
|
||||
<div class="col-xl-4">
|
||||
<label for="name" class="form-label">Nazwa tokenu</label>
|
||||
<input type="text" id="name" name="name" class="form-control" placeholder="np. integracja ERP / Power BI" required>
|
||||
<div class="form-text">Nazwij token tak, aby było wiadomo do czego służy.</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-xl-4">
|
||||
<label class="form-label d-block">Zakresy</label>
|
||||
<div class="form-check form-check-spaced"><input class="form-check-input" type="checkbox" id="scope_expenses_read" name="scope_expenses_read" checked><label class="form-check-label" for="scope_expenses_read">Odczyt wydatków</label></div>
|
||||
<div class="form-check form-check-spaced"><input class="form-check-input" type="checkbox" id="scope_lists_read" name="scope_lists_read" checked><label class="form-check-label" for="scope_lists_read">Odczyt list i wydatków list</label></div>
|
||||
<div class="form-check form-check-spaced"><input class="form-check-input" type="checkbox" id="scope_templates_read" name="scope_templates_read"><label class="form-check-label" for="scope_templates_read">Odczyt szablonów</label></div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-xl-4">
|
||||
<label class="form-label d-block">Dozwolone endpointy</label>
|
||||
<div class="form-check form-check-spaced"><input class="form-check-input" type="checkbox" id="allow_ping" name="allow_ping" checked><label class="form-check-label" for="allow_ping">/api/ping</label></div>
|
||||
<div class="form-check form-check-spaced"><input class="form-check-input" type="checkbox" id="allow_latest_expenses" name="allow_latest_expenses" checked><label class="form-check-label" for="allow_latest_expenses">/api/expenses/latest</label></div>
|
||||
<div class="form-check form-check-spaced"><input class="form-check-input" type="checkbox" id="allow_expenses_summary" name="allow_expenses_summary" checked><label class="form-check-label" for="allow_expenses_summary">/api/expenses/summary</label></div>
|
||||
<div class="form-check form-check-spaced"><input class="form-check-input" type="checkbox" id="allow_lists" name="allow_lists" checked><label class="form-check-label" for="allow_lists">/api/lists oraz /api/lists/<id>/expenses</label></div>
|
||||
<div class="form-check form-check-spaced"><input class="form-check-input" type="checkbox" id="allow_templates" name="allow_templates"><label class="form-check-label" for="allow_templates">/api/templates</label></div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-xl-3">
|
||||
<label for="max_limit" class="form-label">Maksymalny limit rekordów</label>
|
||||
<input type="number" id="max_limit" name="max_limit" min="1" max="500" value="100" class="form-control">
|
||||
</div>
|
||||
<div class="col-sm-6 col-xl-3 d-flex align-items-end">
|
||||
<button type="submit" class="btn btn-success w-100">🔑 Wygeneruj token</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-dark text-white mt-4">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
|
||||
<h5 class="mb-0">📘 Dokumentacja API</h5>
|
||||
<a href="{{ url_for('admin_api_docs') }}" class="btn btn-sm btn-outline-light" target="_blank">Otwórz TXT</a>
|
||||
</div>
|
||||
<div class="small text-secondary mb-3">Autoryzacja: <code>Authorization: Bearer TWOJ_TOKEN</code> lub <code>X-API-Token</code>. Endpoint i zakres muszą być jednocześnie dozwolone na tokenie. Parametr <code>limit</code> jest przycinany do wartości ustawionej w tokenie.</div>
|
||||
<div class="table-responsive admin-table-responsive admin-table-responsive--full">
|
||||
<table class="table table-dark align-middle table-sm keep-horizontal">
|
||||
<thead>
|
||||
<tr><th>Metoda</th><th>Endpoint</th><th>Wymagany zakres</th><th>Opis</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in api_examples %}
|
||||
<tr>
|
||||
<td><code>{{ row.method }}</code></td>
|
||||
<td><code class="api-chip api-chip--wrap">{{ row.path }}</code></td>
|
||||
<td><code class="api-chip">{{ row.scope }}</code></td>
|
||||
<td>{{ row.description }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-dark text-white mt-4">
|
||||
<div class="card-body">
|
||||
<div class="admin-page-head mb-3">
|
||||
<h5 class="mb-0">📋 Aktywne i historyczne tokeny</h5>
|
||||
<span class="badge rounded-pill bg-secondary">{{ api_tokens|length }} szt.</span>
|
||||
</div>
|
||||
<div class="table-responsive admin-table-responsive admin-table-responsive--wide">
|
||||
<table class="table table-dark align-middle sortable keep-horizontal" data-searchable="true">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nazwa</th>
|
||||
<th>Prefix</th>
|
||||
<th>Status</th>
|
||||
<th>Zakres</th>
|
||||
<th>Endpointy</th>
|
||||
<th>Max limit</th>
|
||||
<th>Utworzono</th>
|
||||
<th>Ostatnie użycie</th>
|
||||
<th>Akcje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for token in api_tokens %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="fw-semibold text-break">{{ token.name }}</div>
|
||||
<div class="small text-secondary">Autor: {{ token.creator.username if token.creator else '—' }}</div>
|
||||
</td>
|
||||
<td><code class="api-chip">{{ token.token_prefix }}…</code></td>
|
||||
<td>{% if token.is_active %}<span class="badge rounded-pill bg-success">Aktywny</span>{% else %}<span class="badge rounded-pill bg-secondary">Wyłączony</span>{% endif %}</td>
|
||||
<td><code class="api-chip api-chip--wrap">{{ token.scopes or '—' }}</code></td>
|
||||
<td><code class="api-chip api-chip--wrap">{{ token.allowed_endpoints or '—' }}</code></td>
|
||||
<td>{{ token.max_limit or '—' }}</td>
|
||||
<td>{{ token.created_at.strftime('%Y-%m-%d %H:%M') if token.created_at else '—' }}</td>
|
||||
<td>{{ token.last_used_at.strftime('%Y-%m-%d %H:%M') if token.last_used_at else 'Jeszcze nie użyto' }}</td>
|
||||
<td>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
{% if token.is_active %}
|
||||
<form method="post" class="d-inline">
|
||||
<input type="hidden" name="action" value="deactivate">
|
||||
<input type="hidden" name="token_id" value="{{ token.id }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-warning">⏸ Wyłącz</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" class="d-inline">
|
||||
<input type="hidden" name="action" value="activate">
|
||||
<input type="hidden" name="token_id" value="{{ token.id }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-success">▶ Włącz</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="post" class="d-inline" onsubmit="return confirm('Usunąć ten token API?')">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="token_id" value="{{ token.id }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">🗑 Usuń</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="9" class="text-center text-secondary py-4">Brak tokenów API.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -9,6 +9,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include 'admin/_nav.html' %}
|
||||
|
||||
<div class="card bg-dark text-white mb-5">
|
||||
<div class="card-body">
|
||||
<div class="alert alert-warning border-warning text-dark" role="alert">
|
||||
@@ -16,10 +18,10 @@
|
||||
wydatków.
|
||||
</div>
|
||||
|
||||
<form method="post" id="mass-edit-form">
|
||||
<form method="post" id="mass-edit-form" data-unsaved-warning="true">
|
||||
<div class="card bg-dark text-white mb-4">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<div class="table-responsive admin-table-responsive admin-table-responsive--full">
|
||||
<table class="table table-dark align-middle sortable mb-0">
|
||||
<thead class="position-sticky top-0 bg-dark">
|
||||
<tr>
|
||||
@@ -88,8 +90,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Fallback – ukryty przez JS #}
|
||||
<button type="submit" class="btn btn-sm btn-outline-light" id="fallback-save-btn">💾 Zapisz zmiany</button>
|
||||
<div class="d-flex justify-content-end mt-3"><button type="submit" class="btn btn-sm btn-outline-light">💾 Zapisz wszystkie zmiany</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -147,5 +148,4 @@
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='preview_list_modal.js') }}?v={{ APP_VERSION }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='categories_select_admin.js') }}?v={{ APP_VERSION }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='categories_autosave.js') }}?v={{ APP_VERSION }}"></script>
|
||||
{% endblock %}
|
||||
@@ -7,10 +7,12 @@
|
||||
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-secondary">← Powrót do panelu</a>
|
||||
</div>
|
||||
|
||||
{% include 'admin/_nav.html' %}
|
||||
|
||||
<div class="card bg-secondary bg-opacity-10 text-white mb-5">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title">📄 Podstawowe informacje</h4>
|
||||
<form method="post" class="mt-3">
|
||||
<form method="post" class="mt-3" data-unsaved-warning="true">
|
||||
<input type="hidden" name="action" value="save">
|
||||
|
||||
<!-- Nazwa listy -->
|
||||
@@ -43,20 +45,20 @@
|
||||
<!-- Statusy -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label">⚙️ Statusy listy</label>
|
||||
<div class="d-flex flex-wrap gap-3">
|
||||
<div class="form-check form-switch">
|
||||
<div class="switch-grid">
|
||||
<div class="form-check form-switch app-switch">
|
||||
<input class="form-check-input" type="checkbox" id="archived" name="archived" {% if list.is_archived
|
||||
%}checked{% endif %}>
|
||||
<label class="form-check-label" for="archived">📦 Archiwalna</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check form-switch">
|
||||
<div class="form-check form-switch app-switch">
|
||||
<input class="form-check-input" type="checkbox" id="public" name="public" {% if list.is_public %}checked{%
|
||||
endif %}>
|
||||
<label class="form-check-label" for="public">🌐 Publiczna</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check form-switch">
|
||||
<div class="form-check form-switch app-switch">
|
||||
<input class="form-check-input" type="checkbox" id="temporary" name="temporary" {% if list.is_temporary
|
||||
%}checked{% endif %}>
|
||||
<label class="form-check-label" for="temporary">⏳ Tymczasowa (podaj date i godzine wygasania)</label>
|
||||
@@ -7,6 +7,8 @@
|
||||
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-secondary">← Powrót do panelu</a>
|
||||
</div>
|
||||
|
||||
{% include 'admin/_nav.html' %}
|
||||
|
||||
<div class="card bg-secondary bg-opacity-10 text-white mb-4">
|
||||
<div class="card-body">
|
||||
|
||||
@@ -40,7 +42,7 @@
|
||||
<span class="badge rounded-pill bg-info">{{ total_items }} produktów</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-dark align-middle sortable">
|
||||
<table class="table table-dark align-middle sortable keep-horizontal">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
@@ -99,7 +101,7 @@
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% set item_names = items | map(attribute='name') | map('lower') | list %}
|
||||
<table class="table table-dark align-middle sortable">
|
||||
<table class="table table-dark align-middle sortable keep-horizontal">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
@@ -3,8 +3,7 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap mb-3">
|
||||
<h2 class="mb-2">🔐{% if list_id %} Zarządzanie dostępem listy #{{ list_id }}{% else %} Zarządzanie dostępem do list
|
||||
{% endif %}</h2>
|
||||
<h2 class="mb-2">🔐{% if list_id %} Dostęp do listy #{{ list_id }}{% else %} Zarządzanie dostępem do list{% endif %}</h2>
|
||||
<div class="d-flex gap-2">
|
||||
{% if list_id %}
|
||||
<a href="{{ url_for('admin_lists_access') }}" class="btn btn-outline-light">Powrót do wszystkich list</a>
|
||||
@@ -13,12 +12,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include 'admin/_nav.html' %}
|
||||
|
||||
<!-- STICKY ACTION BAR -->
|
||||
<div id="bulkBar" class="position-sticky top-0 z-3 mb-3" style="backdrop-filter: blur(6px);">
|
||||
<div class="card bg-dark border-secondary shadow-sm">
|
||||
<div class="card-body py-2 d-flex flex-wrap align-items-center gap-3">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<input id="selectAll" class="form-check-input" type="checkbox" />
|
||||
<div class="d-flex align-items-center gap-2 w-100">
|
||||
<input id="selectAll" class="form-check-input table-select-checkbox" type="checkbox" />
|
||||
<label for="selectAll" class="form-check-label">Zaznacz wszystko</label>
|
||||
</div>
|
||||
|
||||
@@ -36,7 +37,7 @@
|
||||
<div class="flex-grow-1">
|
||||
<div class="input-group input-group-sm">
|
||||
<input id="bulkUsersInput" class="form-control bg-dark text-white border-secondary"
|
||||
placeholder="Podaj użytkowników (po przecinku lub enterach)" list="userHints">
|
||||
placeholder="Podaj użytkowników (po przecinku lub enterach)" list="userHints" autocomplete="off">
|
||||
<button id="bulkAddBtn" class="btn btn-outline-light" type="button">➕ Nadaj dostęp</button>
|
||||
</div>
|
||||
<div id="bulkTokens" class="d-flex flex-wrap gap-2 mt-2"></div>
|
||||
@@ -47,15 +48,14 @@
|
||||
|
||||
|
||||
<!-- HINTS -->
|
||||
<datalist id="userHints"></datalist>
|
||||
<datalist id="userHints">
|
||||
{% for username in all_usernames %}<option value="{{ username }}"></option>{% endfor %}
|
||||
</datalist>
|
||||
|
||||
<div class="card bg-dark text-white mb-5">
|
||||
<div class="card-body">
|
||||
<form id="statusForm" method="post">
|
||||
<input type="hidden" name="action" value="save_changes">
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark align-middle sortable" id="listsTable">
|
||||
<div class="table-responsive admin-table-responsive admin-table-responsive--wide">
|
||||
<table class="table table-dark align-middle sortable lists-access-table" id="listsTable">
|
||||
<thead class="align-middle">
|
||||
<tr>
|
||||
<th scope="col" style="width:36px;"></th>
|
||||
@@ -63,9 +63,8 @@
|
||||
<th scope="col">Nazwa listy</th>
|
||||
<th scope="col">Właściciel</th>
|
||||
<th scope="col">Utworzono</th>
|
||||
<th scope="col">Statusy</th>
|
||||
<th scope="col">Udostępnianie</th>
|
||||
<th scope="col" style="min-width: 340px;">Uprawnienia</th>
|
||||
<th scope="col" style="min-width: 420px;">Uprawnienia</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -73,8 +72,7 @@
|
||||
<tr data-id="{{ l.id }}" data-title="{{ l.title|lower }}"
|
||||
data-owner="{{ (l.owner.username if l.owner else '-')|lower }}">
|
||||
<td>
|
||||
<input class="row-check form-check-input" type="checkbox" data-list-id="{{ l.id }}">
|
||||
<input type="hidden" name="visible_ids" value="{{ l.id }}">
|
||||
<input class="row-check form-check-input table-select-checkbox" type="checkbox" data-list-id="{{ l.id }}">
|
||||
</td>
|
||||
|
||||
<td class="text-nowrap">{{ l.id }}</td>
|
||||
@@ -92,29 +90,10 @@
|
||||
</td>
|
||||
|
||||
<td class="text-nowrap">{{ l.created_at.strftime('%Y-%m-%d %H:%M') if l.created_at else '-' }}</td>
|
||||
|
||||
<td style="min-width: 230px;">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="pub_{{ l.id }}" name="is_public_{{ l.id }}" {% if
|
||||
l.is_public %}checked{% endif %}>
|
||||
<label class="form-check-label" for="pub_{{ l.id }}">🌐 Publiczna</label>
|
||||
</div>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="tmp_{{ l.id }}" name="is_temporary_{{ l.id }}" {%
|
||||
if l.is_temporary %}checked{% endif %}>
|
||||
<label class="form-check-label" for="tmp_{{ l.id }}">⏳ Tymczasowa</label>
|
||||
</div>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="arc_{{ l.id }}" name="is_archived_{{ l.id }}" {%
|
||||
if l.is_archived %}checked{% endif %}>
|
||||
<label class="form-check-label" for="arc_{{ l.id }}">📦 Archiwalna</label>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td style="min-width: 260px;">
|
||||
{% if l.share_token %}
|
||||
{% set share_url = url_for('shared_list', token=l.share_token, _external=True) %}
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="d-flex align-items-center gap-2 w-100">
|
||||
<div class="flex-grow-1 text-truncate mono small" title="{{ share_url }}">{{ share_url }}</div>
|
||||
<button class="btn btn-sm btn-outline-secondary copy-share" type="button" data-url="{{ share_url }}"
|
||||
aria-label="Kopiuj link">📋</button>
|
||||
@@ -123,12 +102,12 @@
|
||||
{% if l.is_public %}Lista widoczna publicznie{% else %}Dostęp przez link / uprawnienia{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-warning small">Brak tokenu</div>
|
||||
<div class="text-warning small w-100 d-block">Brak tokenu</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<div class="access-editor" data-list-id="{{ l.id }}">
|
||||
<div class="access-editor" data-list-id="{{ l.id }}" data-post-url="{{ request.path }}{{ ('?page=' ~ page ~ "&per_page=" ~ per_page) if not list_id else "" }}" data-suggest-url="{{ url_for('admin_user_suggestions') }}" data-next="{{ request.full_path if request.query_string else request.path }}">
|
||||
<!-- Tokeny z uprawnieniami -->
|
||||
<div class="d-flex flex-wrap gap-2 mb-2 tokens">
|
||||
{% for u in permitted_by_list.get(l.id, []) %}
|
||||
@@ -146,11 +125,11 @@
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text"
|
||||
class="form-control form-control-sm bg-dark text-white border-secondary access-input"
|
||||
placeholder="Dodaj @użytkownika (wiele: przecinki/enter)" list="userHints"
|
||||
placeholder="Dodaj użytkownika (wiele: przecinki/enter)" list="userHints" autocomplete="off"
|
||||
aria-label="Dodaj użytkowników">
|
||||
<button type="button" class="btn btn-sm btn-outline-light access-add">➕ Dodaj</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-light access-add">💾 Zapisz dostęp</button>
|
||||
</div>
|
||||
<div class="text-secondary small mt-1">Kliknij token, aby odebrać dostęp.</div>
|
||||
<div class="text-secondary small mt-1">Kliknij token, aby odebrać dostęp. Zmiana zapisuje się od razu.</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -158,17 +137,13 @@
|
||||
{% endfor %}
|
||||
{% if lists|length == 0 %}
|
||||
<tr>
|
||||
<td colspan="8" class="text-center py-4">Brak list do wyświetlenia</td>
|
||||
<td colspan="7" class="text-center py-4">Brak list do wyświetlenia</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 d-flex justify-content-end">
|
||||
<button type="submit" class="btn btn-sm btn-outline-light">💾 Zapisz zmiany statusów</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -206,6 +181,7 @@
|
||||
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='access_users.js') }}?v={{ APP_VERSION }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='lists_access.js') }}?v={{ APP_VERSION }}"></script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -46,6 +46,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include 'admin/_nav.html' %}
|
||||
|
||||
<div class="card bg-secondary bg-opacity-10 text-white mb-5">
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
@@ -7,7 +7,9 @@
|
||||
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-secondary">← Powrót do panelu</a>
|
||||
</div>
|
||||
|
||||
<form method="post" id="settings-form">
|
||||
{% include 'admin/_nav.html' %}
|
||||
|
||||
<form method="post" id="settings-form" data-unsaved-warning="true">
|
||||
<div class="card bg-dark text-white mb-4">
|
||||
<div class="card-header border-0">
|
||||
<strong>🔎 OCR — słowa kluczowe i czułość</strong>
|
||||
@@ -64,34 +66,44 @@
|
||||
{% set hex_auto = auto_colors[c.id] %}
|
||||
{% set hex_effective = effective_colors[c.id] %}
|
||||
<div class="col-12 col-md-6 col-lg-4">
|
||||
<label class="form-label d-block mb-2">{{ c.name }}</label>
|
||||
<div class="settings-category-card h-100">
|
||||
<div class="settings-category-header mb-2">
|
||||
<label class="form-label d-block mb-0 settings-category-name" for="color_{{ c.id }}">{{ c.name }}</label>
|
||||
<span class="badge settings-override-badge {{ 'text-bg-info' if hex_override else 'text-bg-secondary' }}" data-role="override-status">
|
||||
{{ 'Nadpisany' if hex_override else 'Domyślny' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<input
|
||||
type="color"
|
||||
class="form-control form-control-color category-color"
|
||||
name="color_{{ c.id }}"
|
||||
value="{{ hex_override or '' }}"
|
||||
data-auto="{{ hex_auto }}"
|
||||
{% if not hex_override %}data-empty="1"{% endif %}
|
||||
aria-label="Kolor kategorii {{ c.name }}"
|
||||
>
|
||||
<input type="hidden" name="override_enabled_{{ c.id }}" value="{{ '1' if hex_override else '0' }}" class="override-enabled">
|
||||
|
||||
<div class="btn-group" role="group" aria-label="Akcje koloru">
|
||||
<button type="button"
|
||||
class="btn btn-outline-light btn-sm reset-one"
|
||||
data-target="color_{{ c.id }}">
|
||||
🔄 Reset
|
||||
</button>
|
||||
<button type="button"
|
||||
class="btn btn-outline-light btn-sm use-default"
|
||||
data-target="color_{{ c.id }}">
|
||||
🎯 Przywróć domyślny
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-color-controls">
|
||||
<input
|
||||
type="color"
|
||||
class="form-control form-control-color category-color"
|
||||
id="color_{{ c.id }}"
|
||||
name="color_{{ c.id }}"
|
||||
value="{{ hex_effective }}"
|
||||
data-auto="{{ hex_auto }}"
|
||||
data-effective="{{ hex_effective }}"
|
||||
data-has-override="{{ '1' if hex_override else '0' }}"
|
||||
aria-label="Kolor kategorii {{ c.name }}"
|
||||
>
|
||||
|
||||
<div class="color-indicators mt-2">
|
||||
<div class="settings-color-actions" role="group" aria-label="Akcje koloru">
|
||||
<button type="button"
|
||||
class="btn btn-outline-light btn-sm reset-one"
|
||||
data-target="color_{{ c.id }}">
|
||||
🔄 Wyczyść nadpisanie
|
||||
</button>
|
||||
<button type="button"
|
||||
class="btn btn-outline-light btn-sm use-default"
|
||||
data-target="color_{{ c.id }}">
|
||||
🎯 Ustaw kolor domyślny
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="color-indicators mt-3">
|
||||
<div class="indicator">
|
||||
<span class="badge text-bg-dark me-2">Efektywny</span>
|
||||
<span class="bar" data-kind="effective" style="background-color: {{ hex_effective }};"></span>
|
||||
@@ -102,6 +114,7 @@
|
||||
<span class="bar" data-kind="auto" style="background-color: {{ hex_auto }};"></span>
|
||||
<span class="hex hex-auto ms-2">{{ hex_auto|upper }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
64
shopping_app/templates/admin/templates.html
Normal file
@@ -0,0 +1,64 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Szablony list{% endblock %}
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4 gap-2">
|
||||
<div>
|
||||
<h2 class="mb-2">🧩 Szablony list</h2>
|
||||
<p class="text-secondary mb-0">Szablony są niezależne od zwykłych list i mogą służyć do szybkiego tworzenia nowych list.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% include 'admin/_nav.html' %}
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-5">
|
||||
<div class="card bg-dark text-white mb-4"><div class="card-body">
|
||||
<h5 class="mb-3">➕ Nowy szablon ręcznie</h5>
|
||||
<form method="post" class="stack-form">
|
||||
<input type="hidden" name="action" value="create_manual">
|
||||
<div class="mb-3"><label class="form-label">Nazwa</label><input type="text" name="name" class="form-control" required></div>
|
||||
<div class="mb-3"><label class="form-label">Opis</label><textarea name="description" class="form-control" rows="2"></textarea></div>
|
||||
<div class="mb-3"><label class="form-label">Produkty</label><textarea name="items_text" class="form-control" rows="8" placeholder="Mleko x2 Chleb Jajka x10"></textarea><div class="form-text">Każdy produkt w osobnej linii. Ilość opcjonalnie po „x”.</div></div>
|
||||
<button type="submit" class="btn btn-success w-100">Utwórz szablon</button>
|
||||
</form>
|
||||
</div></div>
|
||||
<div class="card bg-dark text-white"><div class="card-body">
|
||||
<h5 class="mb-3">📋 Utwórz z istniejącej listy</h5>
|
||||
<form method="post" class="stack-form">
|
||||
<input type="hidden" name="action" value="create_from_list">
|
||||
<div class="mb-3"><label class="form-label">Lista źródłowa</label><select name="source_list_id" class="form-select" required>{% for l in source_lists %}<option value="{{ l.id }}">#{{ l.id }} — {{ l.title }}</option>{% endfor %}</select></div>
|
||||
<div class="mb-3"><label class="form-label">Nazwa szablonu</label><input type="text" name="template_name" class="form-control"></div>
|
||||
<div class="mb-3"><label class="form-label">Opis</label><textarea name="description" class="form-control" rows="2"></textarea></div>
|
||||
<button type="submit" class="btn btn-outline-light w-100">Utwórz z listy</button>
|
||||
</form>
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="col-lg-7">
|
||||
<div class="card bg-dark text-white"><div class="card-body">
|
||||
<div class="admin-page-head mb-3"><h5 class="mb-0">Wszystkie szablony użytkowników</h5><span class="badge rounded-pill bg-secondary">{{ templates|length }} szt.</span></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark align-middle keep-horizontal">
|
||||
<thead><tr><th>Nazwa</th><th>Produkty</th><th>Status</th><th>Autor</th><th>Akcje</th></tr></thead>
|
||||
<tbody>
|
||||
{% for template in templates %}
|
||||
<tr>
|
||||
<td><div class="fw-semibold">{{ template.name }}</div><div class="small text-secondary">{{ template.description or 'Bez opisu' }}</div></td>
|
||||
<td>{{ template.items|length }}</td>
|
||||
<td>{% if template.is_active %}<span class="badge rounded-pill bg-success">Aktywny</span>{% else %}<span class="badge rounded-pill bg-secondary">Wyłączony</span>{% endif %}</td>
|
||||
<td>{{ template.creator.username if template.creator else '—' }}</td>
|
||||
<td>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<form method="post"><input type="hidden" name="action" value="instantiate"><input type="hidden" name="template_id" value="{{ template.id }}"><button class="btn btn-sm btn-outline-primary" type="submit">➕ Utwórz listę</button></form>
|
||||
<form method="post"><input type="hidden" name="action" value="toggle"><input type="hidden" name="template_id" value="{{ template.id }}"><button class="btn btn-sm btn-outline-warning" type="submit">{% if template.is_active %}⏸ Wyłącz{% else %}▶ Włącz{% endif %}</button></form>
|
||||
<form method="post" onsubmit="return confirm('Usunąć szablon?')"><input type="hidden" name="action" value="delete"><input type="hidden" name="template_id" value="{{ template.id }}"><button class="btn btn-sm btn-outline-danger" type="submit">🗑 Usuń</button></form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="text-center text-secondary py-4">Brak szablonów użytkowników.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -7,6 +7,8 @@
|
||||
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-secondary">← Powrót do panelu</a>
|
||||
</div>
|
||||
|
||||
{% include 'admin/_nav.html' %}
|
||||
|
||||
<!-- Formularz dodawania nowego użytkownika -->
|
||||
<div class="card bg-secondary bg-opacity-10 text-white mb-4">
|
||||
<div class="card-body">
|
||||
@@ -20,8 +22,10 @@
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="password" class="form-label text-white-50">Hasło</label>
|
||||
<input type="password" id="password" name="password"
|
||||
class="form-control bg-dark text-white border-secondary rounded" placeholder="min. 6 znaków" required>
|
||||
<div class="input-group ui-password-group">
|
||||
<input type="password" id="password" name="password"
|
||||
class="form-control bg-dark text-white border-secondary rounded" placeholder="min. 6 znaków" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 d-grid">
|
||||
<button type="submit" class="btn btn-outline-light">➕ Dodaj użytkownika</button>
|
||||
@@ -34,7 +38,7 @@
|
||||
|
||||
<div class="card bg-dark text-white mb-5">
|
||||
<div class="card-body">
|
||||
<table class="table table-dark align-middle sortable">
|
||||
<table class="table table-dark align-middle sortable keep-horizontal">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
@@ -103,8 +107,10 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p id="resetUsernameLabel">Dla użytkownika: <strong></strong></p>
|
||||
<input type="password" name="password" placeholder="Nowe hasło"
|
||||
class="form-control bg-dark text-white border-secondary rounded" required>
|
||||
<div class="input-group ui-password-group">
|
||||
<input type="password" name="password" placeholder="Nowe hasło"
|
||||
class="form-control bg-dark text-white border-secondary rounded" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer border-0">
|
||||
<button type="submit" class="btn btn-sm btn-outline-light w-100">💾 Zapisz nowe hasło</button>
|
||||
179
shopping_app/templates/base.html
Normal file
@@ -0,0 +1,179 @@
|
||||
<!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 %}{% if request.path.startswith('/admin') %} is-admin-area{% 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="dropdown d-lg-none app-mobile-menu ms-auto">
|
||||
<button class="btn app-navbar-toggler app-mobile-menu__toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false" aria-label="Otwórz menu">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="dropdown-menu dropdown-menu-end app-mobile-menu__panel">
|
||||
{% 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="dropdown-item app-mobile-menu__item">⚙️ <span>Panel</span></a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('expenses') }}" class="dropdown-item app-mobile-menu__item">📊 <span>Wydatki</span></a>
|
||||
<a href="{{ url_for('my_templates') }}" class="dropdown-item app-mobile-menu__item">🧩 <span>Szablony</span></a>
|
||||
<a href="{{ url_for('logout') }}" class="dropdown-item app-mobile-menu__item">🚪 <span>Wyloguj</span></a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('login') }}" class="dropdown-item app-mobile-menu__item">🔑 <span>Zaloguj</span></a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-none d-lg-flex justify-content-end order-lg-3" id="appNavbarMenu">
|
||||
<div class="app-navbar__actions">
|
||||
{% 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 app-nav-action">⚙️ <span>Panel</span></a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('expenses') }}" class="btn btn-outline-light btn-sm app-nav-action">📊 <span>Wydatki</span></a>
|
||||
<a href="{{ url_for('my_templates') }}" class="btn btn-outline-light btn-sm app-nav-action">🧩 <span>Szablony</span></a>
|
||||
<a href="{{ url_for('logout') }}" class="btn btn-outline-light btn-sm app-nav-action">🚪 <span>Wyloguj</span></a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('login') }}" class="btn btn-success btn-sm app-nav-action">🔑 <span>Zaloguj</span></a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</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 src="{{ url_for('static_bp.serve_js', filename='app_ui.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,14 +1,17 @@
|
||||
{% 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>
|
||||
|
||||
<div class="card bg-secondary bg-opacity-10 text-white mb-5">
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
<form method="post" data-unsaved-warning="true" class="stack-form">
|
||||
|
||||
<!-- Nazwa listy -->
|
||||
<div class="mb-3">
|
||||
@@ -20,20 +23,20 @@
|
||||
<!-- Statusy listy -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label">⚙️ Statusy listy</label>
|
||||
<div class="d-flex flex-wrap gap-3">
|
||||
<div class="form-check form-switch">
|
||||
<div class="switch-grid">
|
||||
<div class="form-check form-switch form-check-spaced form-switch-compact app-switch">
|
||||
<input class="form-check-input" type="checkbox" id="public" name="is_public" {% if list.is_public
|
||||
%}checked{% endif %}>
|
||||
<label class="form-check-label" for="public">🌐 Publiczna (czyli mogą zobaczyć goście)</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check form-switch">
|
||||
<div class="form-check form-switch form-check-spaced form-switch-compact app-switch">
|
||||
<input class="form-check-input" type="checkbox" id="temporary" name="is_temporary" {% if list.is_temporary
|
||||
%}checked{% endif %}>
|
||||
<label class="form-check-label" for="temporary">⏳ Tymczasowa (ustaw date wygasania)</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check form-switch">
|
||||
<div class="form-check form-switch form-check-spaced form-switch-compact app-switch">
|
||||
<input class="form-check-input" type="checkbox" id="archived" name="is_archived" {% if list.is_archived
|
||||
%}checked{% endif %}>
|
||||
<label class="form-check-label" for="archived">📦 Archiwalna</label>
|
||||
@@ -95,6 +98,10 @@
|
||||
</div>
|
||||
|
||||
<!-- DOSTĘP DO LISTY -->
|
||||
<datalist id="userHintsOwner">
|
||||
{% for username in all_usernames %}<option value="{{ username }}"></option>{% endfor %}
|
||||
</datalist>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">👥 Użytkownicy z dostępem</label>
|
||||
|
||||
@@ -118,10 +125,10 @@
|
||||
<!-- Dodawanie (wiele: przecinki/enter) + prywatne podpowiedzi -->
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" class="access-input form-control form-control-sm bg-dark text-white border-secondary"
|
||||
placeholder="Dodaj @użytkownika (wiele: przecinki/enter)" aria-label="Dodaj użytkowników">
|
||||
<button type="button" class="access-add btn btn-sm btn-outline-light">➕ Dodaj</button>
|
||||
placeholder="Dodaj użytkownika (wiele: przecinki/enter)" list="userHintsOwner" autocomplete="off" aria-label="Dodaj użytkowników">
|
||||
<button type="button" class="access-add btn btn-sm btn-outline-light">💾 Zapisz dostęp</button>
|
||||
</div>
|
||||
<div class="text-secondary small mt-1">Kliknij token, aby odebrać dostęp.</div>
|
||||
<div class="text-secondary small mt-1">Kliknij token, aby odebrać dostęp. Możesz wpisać kilka loginów oddzielonych przecinkiem.</div>
|
||||
</div>
|
||||
</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,15 +2,18 @@
|
||||
{% 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>
|
||||
|
||||
<div class="card bg-secondary bg-opacity-10 text-white mb-5">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-center mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<div class="form-check form-switch app-switch">
|
||||
<input class="form-check-input" type="checkbox" id="showAllLists" {% if show_all %}checked{% endif %}>
|
||||
<label class="form-check-label ms-2 text-white" for="showAllLists">
|
||||
Uwzględnij listy udostępnione dla mnie i publiczne
|
||||
@@ -94,10 +94,9 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap">
|
||||
<button id="sort-toggle-btn" class="btn btn-sm btn-outline-warning" onclick="toggleSortMode()">✳️ Zmień
|
||||
kolejność</button>
|
||||
<div class="form-check form-switch">
|
||||
<div class="list-toolbar d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||||
<button id="sort-toggle-btn" class="btn btn-sm btn-outline-warning list-toolbar__sort" onclick="toggleSortMode()">✳️ Zmień kolejność</button>
|
||||
<div class="form-check form-switch form-check-spaced app-switch hide-purchased-switch hide-purchased-switch--minimal hide-purchased-switch--right">
|
||||
<input class="form-check-input" type="checkbox" id="hidePurchasedToggle">
|
||||
<label class="form-check-label ms-2" for="hidePurchasedToggle">Ukryj zaznaczone</label>
|
||||
</div>
|
||||
@@ -106,51 +105,47 @@
|
||||
<ul id="items" class="list-group mb-3" data-is-share="{{ 'true' if is_share else 'false' }}">
|
||||
{% for item in items %}
|
||||
<li data-name="{{ item.name|lower }}" id="item-{{ item.id }}"
|
||||
class="list-group-item d-flex justify-content-between align-items-center flex-wrap clickable-item
|
||||
class="list-group-item shopping-item-row clickable-item
|
||||
{% if item.purchased %}bg-success text-white{% elif item.not_purchased %}bg-warning text-dark{% else %}item-not-checked{% endif %}"
|
||||
data-is-share="{{ 'true' if is_share else 'false' }}">
|
||||
|
||||
<div class="d-flex align-items-center gap-2 flex-grow-1">
|
||||
<div class="shopping-item-main">
|
||||
<input id="checkbox-{{ item.id }}" class="large-checkbox" type="checkbox" {% if item.purchased %}checked{% endif
|
||||
%} {% if list.is_archived or item.not_purchased %}disabled{% endif %}>
|
||||
<span id="name-{{ item.id }}" class="text-white">
|
||||
{{ item.name }}
|
||||
{% if item.quantity and item.quantity > 1 %}
|
||||
<span class="badge rounded-pill bg-secondary">x{{ item.quantity }}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
|
||||
<div class="info-line ms-4 small d-flex flex-wrap gap-2" id="info-{{ item.id }}">
|
||||
{% set info_parts = [] %}
|
||||
{% if item.note %}{% set _ = info_parts.append('<span class="text-danger">[ <b>' ~ item.note ~ '</b> ]</span>')
|
||||
%}{% endif %}
|
||||
{% if item.not_purchased_reason %}{% set _ = info_parts.append('<span class="text-dark">[ <b>Powód: ' ~
|
||||
item.not_purchased_reason ~ '</b> ]</span>') %}{% endif %}
|
||||
{% if item.added_by_display %}{% set _ = info_parts.append('<span class="text-info">[ Dodał/a: <b>' ~
|
||||
item.added_by_display ~ '</b> ]</span>') %}{% endif %}
|
||||
{% if info_parts %}
|
||||
<div class="info-line ms-4 small d-flex flex-wrap gap-2" id="info-{{ item.id }}">
|
||||
{{ info_parts | join(' ') | safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<div class="shopping-item-content">
|
||||
<div class="shopping-item-head">
|
||||
<div class="shopping-item-text">
|
||||
<span id="name-{{ item.id }}" class="shopping-item-name text-white">{{ item.name }}</span>
|
||||
{% if item.quantity and item.quantity > 1 %}
|
||||
<span class="badge rounded-pill bg-secondary">x{{ item.quantity }}</span>
|
||||
{% endif %}
|
||||
{% set info_parts = [] %}
|
||||
{% if item.note %}{% set _ = info_parts.append('<span class="text-danger">[ <b>' ~ item.note ~ '</b> ]</span>') %}{% endif %}
|
||||
{% if item.not_purchased_reason %}{% set _ = info_parts.append('<span class="text-dark">[ <b>Powód: ' ~ item.not_purchased_reason ~ '</b> ]</span>') %}{% endif %}
|
||||
{% if item.added_by_display %}{% set _ = info_parts.append('<span class="text-info">[ Dodał/a: <b>' ~ item.added_by_display ~ '</b> ]</span>') %}{% endif %}
|
||||
{% if info_parts %}
|
||||
<span class="info-line small" id="info-{{ item.id }}">{{ info_parts | join(' ') | safe }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="list-item-actions shopping-item-actions" role="group">
|
||||
{% if not is_share %}
|
||||
<button type="button" class="btn btn-outline-light" {% if list.is_archived %}disabled{% else
|
||||
<button type="button" class="btn btn-outline-light btn-sm shopping-action-btn drag-handle" title="Przesuń produkt" aria-label="Przesuń produkt" {% if list.is_archived %}disabled{% endif %}>☰</button>
|
||||
<button type="button" class="btn btn-outline-light btn-sm shopping-action-btn" {% if list.is_archived %}disabled{% else
|
||||
%}onclick="editItem({{ item.id }}, '{{ item.name }}', {{ item.quantity or 1 }})" {% endif %}>✏️</button>
|
||||
<button type="button" class="btn btn-outline-light" {% if list.is_archived %}disabled{% else
|
||||
<button type="button" class="btn btn-outline-light btn-sm shopping-action-btn" {% if list.is_archived %}disabled{% else
|
||||
%}onclick="deleteItem({{ item.id }})" {% endif %}>🗑️</button>
|
||||
{% endif %}
|
||||
|
||||
{% if item.not_purchased %}
|
||||
<button type="button" class="btn btn-outline-light me-auto" {% if list.is_archived %}disabled{% else
|
||||
<button type="button" class="btn btn-outline-light btn-sm shopping-action-btn shopping-action-btn--wide" {% if list.is_archived %}disabled{% else
|
||||
%}onclick="unmarkNotPurchased({{ item.id }})" {% endif %}>✅ Przywróć</button>
|
||||
{% elif not item.not_purchased %}
|
||||
<button type="button" class="btn btn-outline-light" {% if list.is_archived %}disabled{% else
|
||||
<button type="button" class="btn btn-outline-light btn-sm shopping-action-btn" {% if list.is_archived %}disabled{% else
|
||||
%}onclick="markNotPurchasedModal(event, {{ item.id }})" {% endif %}>⚠️</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% else %}
|
||||
@@ -160,20 +155,59 @@
|
||||
</ul>
|
||||
|
||||
{% if not list.is_archived %}
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-12 col-md-2">
|
||||
<button class="btn btn-outline-light w-45 h-45" data-bs-toggle="modal" data-bs-target="#massAddModal">
|
||||
<div class="list-action-block mb-3">
|
||||
<div class="list-action-row mb-2">
|
||||
<button class="btn btn-outline-light btn-sm list-action-row__btn" data-bs-toggle="modal" data-bs-target="#massAddModal">
|
||||
Dodaj produkty masowo
|
||||
</button>
|
||||
<form method="post" action="{{ url_for('create_template_from_user_list', list_id=list.id) }}" class="list-action-row__form">
|
||||
<input type="hidden" name="template_name" value="{{ list.title }} - szablon">
|
||||
<button type="submit" class="btn btn-outline-primary btn-sm list-action-row__btn">🧩 Zapisz jako szablon</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="col-12 col-md-10">
|
||||
<div class="input-group w-100">
|
||||
<input type="text" id="newItem" name="name" class="form-control bg-dark text-white border-secondary"
|
||||
placeholder="Dodaj produkt i ilość" required>
|
||||
<input type="number" id="newQuantity" name="quantity" class="form-control bg-dark text-white border-secondary"
|
||||
placeholder="Ilość" min="1" value="1" style="max-width: 90px;">
|
||||
<button type="button" class="btn btn-outline-success rounded-end" onclick="addItem({{ list.id }})">➕
|
||||
Dodaj</button>
|
||||
|
||||
<div class="input-group mb-2 shopping-compact-input-group shopping-product-input-group">
|
||||
<input type="text" id="newItem" name="name"
|
||||
class="form-control bg-dark text-white border-secondary shopping-product-name-input"
|
||||
placeholder="Dodaj produkt" required>
|
||||
|
||||
<input type="number" id="newQuantity" name="quantity"
|
||||
class="form-control bg-dark text-white border-secondary shopping-qty-input"
|
||||
placeholder="Ilość" min="1" value="1">
|
||||
|
||||
<button type="button"
|
||||
class="btn btn-outline-success share-submit-btn shopping-compact-submit"
|
||||
onclick="addItem({{ list.id }})">
|
||||
<span class="shopping-btn-icon" aria-hidden="true">➕</span>
|
||||
<span class="shopping-btn-label">Dodaj</span>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if activity_logs %}
|
||||
<div class="card bg-dark text-white mb-4">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center gap-2 flex-wrap mb-2">
|
||||
<h5 class="mb-0">🕘 Historia zmian listy</h5>
|
||||
<button class="btn btn-sm btn-outline-light" type="button" data-bs-toggle="collapse" data-bs-target="#activityHistory" aria-expanded="false" aria-controls="activityHistory">Pokaż / ukryj</button>
|
||||
</div>
|
||||
<div class="small text-secondary mb-3">Domyślnie ukryte. Zdarzeń: {{ activity_logs|length }}</div>
|
||||
<div class="collapse" id="activityHistory">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-sm align-middle">
|
||||
<thead><tr><th>Kiedy</th><th>Kto</th><th>Akcja</th><th>Produkt / szczegóły</th></tr></thead>
|
||||
<tbody>
|
||||
{% for log in activity_logs %}
|
||||
<tr>
|
||||
<td>{{ log.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>{{ log.actor_name }}</td>
|
||||
<td>{{ action_label(log.action) }}</td>
|
||||
<td>{{ log.item_name or log.details or '—' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -247,7 +281,7 @@
|
||||
<div class="modal-footer justify-content-end">
|
||||
<div class="btn-group" role="group">
|
||||
<button type="button" class="btn btn-sm btn-outline-light" data-bs-dismiss="modal">❌ Anuluj</button>
|
||||
<button type="submit" class="btn btn-sm btn-outline-light">💾 Zapisz</button>
|
||||
<button type="submit" class="btn btn-sm btn-outline-light"><span class="shopping-btn-icon" aria-hidden="true">💾</span><span class="shopping-btn-label">Zapisz</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -256,6 +290,10 @@
|
||||
</div>
|
||||
|
||||
|
||||
<datalist id="userHintsOwner">
|
||||
{% for username in all_usernames %}<option value="{{ username }}"></option>{% endfor %}
|
||||
</datalist>
|
||||
|
||||
<!-- MODAL: NADAWANIE DOSTĘPU -->
|
||||
<div class="modal fade" id="grantAccessModal" tabindex="-1" aria-labelledby="grantAccessModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
@@ -288,8 +326,8 @@
|
||||
<!-- Dodawanie wielu na raz + podpowiedzi prywatne -->
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" class="access-input form-control form-control-sm bg-dark text-white border-secondary"
|
||||
placeholder="Dodaj @użytkownika (wiele: przecinki/enter)" aria-label="Dodaj użytkowników">
|
||||
<button type="button" class="access-add btn btn-sm btn-outline-light">➕ Dodaj</button>
|
||||
placeholder="Dodaj @użytkownika (wiele: przecinki/enter)" list="userHintsOwner" autocomplete="off" aria-label="Dodaj użytkowników">
|
||||
<button type="button" class="access-add btn btn-sm btn-outline-light"><span class="shopping-btn-icon" aria-hidden="true">➕</span><span class="shopping-btn-label">Dodaj</span></button>
|
||||
</div>
|
||||
<div class="text-secondary small mt-1">Kliknij token, aby odebrać dostęp.</div>
|
||||
</div>
|
||||
@@ -331,6 +369,7 @@
|
||||
const isShare = document.getElementById('items').dataset.isShare === 'true';
|
||||
window.IS_SHARE = isShare;
|
||||
window.LIST_ID = {{ list.id }};
|
||||
window.IS_ARCHIVED = {{ 'true' if list.is_archived else 'false' }};
|
||||
window.IS_OWNER = {{ 'true' if is_owner else 'false' }};
|
||||
</script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='mass_add.js') }}?v={{ APP_VERSION }}"></script>
|
||||
@@ -2,101 +2,89 @@
|
||||
{% block title %}Lista: {{ list.title }}{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<h2 class="mb-2">
|
||||
🛍️ {{ list.title }}
|
||||
<div class="list-header-toolbar d-flex justify-content-between align-items-start mb-3 flex-wrap gap-2">
|
||||
<h2 class="mb-0">
|
||||
🛍️ {{ list.title }}
|
||||
|
||||
{% if list.is_archived %}
|
||||
<span class="badge rounded-pill bg-secondary ms-2">(Archiwalna)</span>
|
||||
{% endif %}
|
||||
{% if list.is_archived %}
|
||||
<span class="badge rounded-pill bg-secondary ms-2">(Archiwalna)</span>
|
||||
{% endif %}
|
||||
|
||||
{% if total_expense > 0 %}
|
||||
<span id="total-expense1" class="badge rounded-pill bg-success ms-2">
|
||||
💸 {{ '%.2f'|format(total_expense) }} PLN
|
||||
</span>
|
||||
{% else %}
|
||||
<span id="total-expense" class="badge rounded-pill bg-secondary ms-2" style="display: none;">
|
||||
💸 0.00 PLN
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if total_expense > 0 %}
|
||||
<span id="total-expense1" class="badge rounded-pill bg-success ms-2">
|
||||
💸 {{ '%.2f'|format(total_expense) }} PLN
|
||||
</span>
|
||||
{% else %}
|
||||
<span id="total-expense" class="badge rounded-pill bg-secondary ms-2" style="display: none;">
|
||||
💸 0.00 PLN
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if list.category_badges %}
|
||||
{% for cat in list.category_badges %}
|
||||
<span class="badge rounded-pill rounded-pill text-dark ms-1" style="background-color: {{ cat.color }};
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.85;">
|
||||
{{ cat.name }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if list.category_badges %}
|
||||
{% for cat in list.category_badges %}
|
||||
<span class="badge rounded-pill rounded-pill text-dark ms-1" style="background-color: {{ cat.color }};
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.85;">
|
||||
{{ cat.name }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</h2>
|
||||
|
||||
</h2>
|
||||
|
||||
|
||||
<div class="form-check form-switch mb-3 d-flex justify-content-end">
|
||||
<input class="form-check-input" type="checkbox" id="hidePurchasedToggle">
|
||||
<label class="form-check-label ms-2" for="hidePurchasedToggle">Ukryj zaznaczone</label>
|
||||
<div class="list-toolbar list-toolbar--share d-flex justify-content-end align-items-center gap-2 share-page-toolbar">
|
||||
<div class="form-check form-switch form-check-spaced app-switch hide-purchased-switch hide-purchased-switch--minimal hide-purchased-switch--right">
|
||||
<input class="form-check-input" type="checkbox" id="hidePurchasedToggle">
|
||||
<label class="form-check-label ms-2" for="hidePurchasedToggle">Ukryj zaznaczone</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul id="items" class="list-group mb-3" data-is-share="{{ 'true' if is_share else 'false' }}">
|
||||
{% for item in items %}
|
||||
|
||||
<li data-name="{{ item.name|lower }}" id="item-{{ item.id }}"
|
||||
class="list-group-item d-flex justify-content-between align-items-center flex-wrap clickable-item
|
||||
class="list-group-item shopping-item-row clickable-item
|
||||
{% if item.purchased %}bg-success text-white{% elif item.not_purchased %}bg-warning text-dark{% else %}item-not-checked{% endif %}">
|
||||
|
||||
<div class="d-flex align-items-center gap-2 flex-grow-1">
|
||||
|
||||
<div class="shopping-item-main">
|
||||
<input id="checkbox-{{ item.id }}" class="large-checkbox" type="checkbox" {% if item.purchased %}checked{% endif
|
||||
%} {% if list.is_archived or item.not_purchased %}disabled{% endif %}>
|
||||
|
||||
<span id="name-{{ item.id }}" class="text-white">
|
||||
{{ item.name }}
|
||||
{% if item.quantity and item.quantity > 1 %}
|
||||
<span class="badge rounded-pill bg-secondary">x{{ item.quantity }}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
|
||||
<div class="info-line ms-4 small d-flex flex-wrap gap-2" id="info-{{ item.id }}">
|
||||
{% set info_parts = [] %}
|
||||
{% if item.note %}
|
||||
{% set _ = info_parts.append('<span class="text-danger">[ <b>' ~ item.note ~ '</b> ]</span>') %}
|
||||
{% endif %}
|
||||
{% if item.not_purchased_reason %}
|
||||
{% set _ = info_parts.append('<span class="text-dark">[ <b>Powód: ' ~ item.not_purchased_reason ~ '</b>
|
||||
]</span>') %}
|
||||
{% endif %}
|
||||
{% if item.added_by_display %}
|
||||
{% set _ = info_parts.append('<span class="text-info">[ Dodał/a: <b>' ~ item.added_by_display ~ '</b> ]</span>')
|
||||
%}
|
||||
{% endif %}
|
||||
|
||||
{% if info_parts %}
|
||||
<div class="info-line ms-4 small d-flex flex-wrap gap-2" id="info-{{ item.id }}">
|
||||
{{ info_parts | join(' ') | safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<div class="shopping-item-content">
|
||||
<div class="shopping-item-head">
|
||||
<div class="shopping-item-text">
|
||||
<span id="name-{{ item.id }}" class="shopping-item-name text-white">{{ item.name }}</span>
|
||||
{% if item.quantity and item.quantity > 1 %}
|
||||
<span class="badge rounded-pill bg-secondary">x{{ item.quantity }}</span>
|
||||
{% endif %}
|
||||
{% set info_parts = [] %}
|
||||
{% if item.note %}{% set _ = info_parts.append('<span class="text-danger">[ <b>' ~ item.note ~ '</b> ]</span>') %}{% endif %}
|
||||
{% if item.not_purchased_reason %}{% set _ = info_parts.append('<span class="text-dark">[ <b>Powód: ' ~ item.not_purchased_reason ~ '</b> ]</span>') %}{% endif %}
|
||||
{% if item.added_by_display %}{% set _ = info_parts.append('<span class="text-info">[ Dodał/a: <b>' ~ item.added_by_display ~ '</b> ]</span>') %}{% endif %}
|
||||
{% if info_parts %}
|
||||
<span class="info-line small" id="info-{{ item.id }}">{{ info_parts | join(' ') | safe }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="list-item-actions shopping-item-actions" role="group">
|
||||
{% if item.not_purchased %}
|
||||
<button type="button" class="btn btn-outline-light me-auto" {% if list.is_archived %}disabled{% else %}
|
||||
<button type="button" class="btn btn-outline-light btn-sm shopping-action-btn shopping-action-btn--wide" {% if list.is_archived %}disabled{% else %}
|
||||
onclick="unmarkNotPurchased({{ item.id }})" {% endif %}>
|
||||
✅ Przywróć
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="button" class="btn btn-outline-light" {% if list.is_archived %}disabled{% else %}
|
||||
<button type="button" class="btn btn-outline-light btn-sm shopping-action-btn" {% if list.is_archived %}disabled{% else %}
|
||||
onclick="markNotPurchasedModal(event, {{ item.id }})" {% endif %}>
|
||||
⚠️
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
<button type="button" class="btn btn-outline-light" {% if list.is_archived %}disabled{% else %}
|
||||
<button type="button" class="btn btn-outline-light btn-sm shopping-action-btn" {% if list.is_archived %}disabled{% else %}
|
||||
onclick="openNoteModal(event, {{ item.id }})" {% endif %}>
|
||||
📝
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</li>
|
||||
{% else %}
|
||||
<li id="empty-placeholder" class="list-group-item bg-dark text-secondary text-center w-100">
|
||||
@@ -106,23 +94,23 @@
|
||||
</ul>
|
||||
|
||||
{% if not list.is_archived %}
|
||||
<div class="input-group mb-2">
|
||||
<input id="newItem" class="form-control bg-dark text-white border-secondary" placeholder="Dodaj produkt i ilość" {% if
|
||||
<div class="input-group mb-2 shopping-compact-input-group shopping-product-input-group">
|
||||
<input id="newItem" class="form-control bg-dark text-white border-secondary shopping-product-name-input" placeholder="Dodaj produkt" {% if
|
||||
not current_user.is_authenticated %}disabled{% endif %}>
|
||||
<input id="newQuantity" type="number" class="form-control bg-dark text-white border-secondary" placeholder="Ilość"
|
||||
min="1" value="1" style="max-width: 90px;" {% if not current_user.is_authenticated %}disabled{% endif %}>
|
||||
<button onclick="addItem({{ list.id }})" class="btn btn-outline-success rounded-end" {% if not
|
||||
current_user.is_authenticated %}disabled{% endif %}>➕ Dodaj</button>
|
||||
<input id="newQuantity" type="number" class="form-control bg-dark text-white border-secondary shopping-qty-input" placeholder="Ilość"
|
||||
min="1" value="1" {% if not current_user.is_authenticated %}disabled{% endif %}>
|
||||
<button onclick="addItem({{ list.id }})" class="btn btn-outline-success share-submit-btn shopping-compact-submit" {% if not
|
||||
current_user.is_authenticated %}disabled{% endif %}><span class="shopping-btn-icon" aria-hidden="true">➕</span><span class="shopping-btn-label">Dodaj</span></button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not list.is_archived %}
|
||||
<hr>
|
||||
<h5>💰 Dodaj wydatek</h5>
|
||||
<div class="input-group mb-2">
|
||||
<input id="expenseAmount" type="number" step="0.01" min="0" class="form-control bg-dark text-white border-secondary"
|
||||
<div class="input-group mb-2 shopping-compact-input-group shopping-expense-input-group">
|
||||
<input id="expenseAmount" type="number" step="0.01" min="0" class="form-control bg-dark text-white border-secondary shopping-expense-amount-input"
|
||||
placeholder="Kwota (PLN)">
|
||||
<button onclick="submitExpense({{ list.id }})" class="btn btn-outline-primary rounded-end">💾 Zapisz</button>
|
||||
<button onclick="submitExpense({{ list.id }})" class="btn btn-outline-primary share-submit-btn shopping-compact-submit"><span class="shopping-btn-icon" aria-hidden="true">💾</span><span class="shopping-btn-label">Zapisz</span></button>
|
||||
</div>{% endif %}
|
||||
<p id="total-expense2"><b>💸 Łącznie wydano:</b> {{ '%.2f'|format(total_expense) }} PLN</p>
|
||||
|
||||
@@ -228,7 +216,7 @@
|
||||
<div class="modal-footer">
|
||||
<div class="btn-group" role="group">
|
||||
<button type="button" class="btn btn-outline-light btn-sm" data-bs-dismiss="modal">❌ Anuluj</button>
|
||||
<button type="submit" class="btn btn-outline-light btn-sm">💾 Zapisz</button>
|
||||
<button type="submit" class="btn btn-outline-light btn-sm"><span class="shopping-btn-icon" aria-hidden="true">💾</span><span class="shopping-btn-label">Zapisz</span></button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -242,6 +230,8 @@
|
||||
const isShare = document.getElementById('items').dataset.isShare === 'true';
|
||||
window.IS_SHARE = isShare;
|
||||
window.LIST_ID = {{ list.id }};
|
||||
window.IS_ARCHIVED = {{ 'true' if list.is_archived else 'false' }};
|
||||
window.IS_OWNER = {{ 'true' if (current_user.is_authenticated and list.user_id == current_user.id) else 'false' }};
|
||||
if (typeof isSorting === 'undefined') {
|
||||
var isSorting = false;
|
||||
}
|
||||
@@ -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">
|
||||
@@ -13,8 +14,10 @@
|
||||
class="form-control bg-dark text-white border-secondary rounded" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<input type="password" name="password" placeholder="Hasło"
|
||||
class="form-control bg-dark text-white border-secondary rounded" required>
|
||||
<div class="input-group ui-password-group">
|
||||
<input type="password" name="password" placeholder="Hasło"
|
||||
class="form-control bg-dark text-white border-secondary rounded" required>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success w-100">🔑 Zaloguj</button>
|
||||
</form>
|
||||
@@ -9,26 +9,41 @@
|
||||
{% 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>
|
||||
{% if expiring_lists %}
|
||||
<div class="alert alert-warning mb-4" role="alert">
|
||||
<div class="fw-semibold mb-2">⏰ Wygasające listy tymczasowe w ciągu 24h</div>
|
||||
<ul class="mb-0 ps-3">
|
||||
{% for l in expiring_lists %}
|
||||
<li><a class="link-dark fw-semibold" href="{{ url_for('view_list', list_id=l.id) }}">{{ l.title }}</a> — {{ l.expires_at.strftime('%Y-%m-%d %H:%M') }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card bg-secondary bg-opacity-10 text-white mb-4">
|
||||
<div class="card-body">
|
||||
<form action="{{ url_for('create_list') }}" method="post">
|
||||
<div class="input-group mb-3">
|
||||
<input type="text" name="title" id="title" placeholder="Wprowadź nazwę nowej listy" required
|
||||
class="form-control bg-dark text-white border-secondary">
|
||||
<button type="button" class="btn btn-outline-secondary rounded-end" id="tempToggle" data-active="0"
|
||||
data-bs-toggle="tooltip" data-bs-placement="top" title="Po zaznaczeniu lista będzie ważna tylko 7 dni">
|
||||
Tymczasowa
|
||||
</button>
|
||||
<input type="hidden" name="temporary" id="temporaryHidden" value="0">
|
||||
{% endif %}
|
||||
<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">Stwórz nową liste</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success w-100">➕ Utwórz nową listę</button>
|
||||
</form>
|
||||
<form action="{{ url_for('create_list') }}" method="post">
|
||||
<div class="input-group mb-3 create-list-input-group">
|
||||
<input type="text" name="title" id="title" placeholder="Wprowadź nazwę nowej listy" required
|
||||
class="form-control bg-dark text-white border-secondary">
|
||||
<button type="button" class="btn btn-outline-secondary create-list-temp-toggle" id="tempToggle" data-active="0"
|
||||
data-bs-toggle="tooltip" data-bs-placement="top" title="Po zaznaczeniu lista będzie ważna tylko 7 dni">
|
||||
Tymczasowa
|
||||
</button>
|
||||
<input type="hidden" name="temporary" id="temporaryHidden" value="0">
|
||||
</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ń"] %}
|
||||
@@ -70,71 +85,44 @@
|
||||
{% set percent = (purchased_count / total_count * 100) if total_count > 0 else 0 %}
|
||||
|
||||
<li class="list-group-item bg-dark text-white">
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap w-100">
|
||||
<span class="fw-bold">
|
||||
<!-- Desktop/tablet: zwykły tekst -->
|
||||
<span class="d-none d-sm-inline">
|
||||
{{ l.title }} (Autor: Ty)
|
||||
</span>
|
||||
|
||||
<!-- Mobile: klikalny tytuł -->
|
||||
<a class="d-inline d-sm-none text-white text-decoration-none"
|
||||
href="{{ url_for('view_list', list_id=l.id) }}">
|
||||
{{ l.title }}
|
||||
</a>
|
||||
|
||||
{% for cat in l.category_badges %}
|
||||
<!-- DESKTOP: nazwa -->
|
||||
<span class="badge rounded-pill text-dark ms-1 d-none d-sm-inline fw-semibold"
|
||||
style="background-color: {{ cat.color }}; font-size: 0.7rem; opacity: 0.9; padding: 0.3em 0.6em;">
|
||||
{{ cat.name }}
|
||||
<div class="main-list-row">
|
||||
<div class="list-main-meta">
|
||||
<div class="fw-bold list-main-title mobile-list-heading" data-mobile-list-heading>
|
||||
{% if l.is_temporary and l.expires_at %}<span class="badge rounded-pill bg-warning text-dark me-2">⏰ {{ l.expires_at.strftime('%Y-%m-%d %H:%M') }}</span>{% endif %}
|
||||
<a class="list-main-title__link text-white text-decoration-none" href="{{ url_for('view_list', list_id=l.id) }}">
|
||||
<span data-mobile-list-title>{{ l.title }}</span>
|
||||
<span class="d-none d-sm-inline"> (Autor: Ty)</span>
|
||||
</a>
|
||||
{% if l.category_badges %}
|
||||
<span class="mobile-category-badges" data-mobile-category-group>
|
||||
{% for cat in l.category_badges %}
|
||||
<span class="badge rounded-pill text-dark ms-1 fw-semibold mobile-category-badge"
|
||||
style="background-color: {{ cat.color }}; font-size: 0.7rem; opacity: 0.9; padding: 0.3em 0.6em;"
|
||||
title="{{ cat.name }}" aria-label="Kategoria: {{ cat.name }}">
|
||||
<span class="mobile-category-badge__text">{{ cat.name }}</span>
|
||||
<span class="mobile-category-badge__dot"></span>
|
||||
</span>
|
||||
{% endfor %}
|
||||
</span>
|
||||
|
||||
<!-- MOBILE -->
|
||||
<span class="ms-1 d-sm-none category-dot-pure"
|
||||
style="background-color: {{ cat.color }};"
|
||||
title="{{ cat.name }}" aria-label="Kategoria: {{ cat.name }}"></span>
|
||||
{% endfor %}
|
||||
</span>
|
||||
|
||||
<div class="btn-group btn-group-compact mt-2 mt-md-0 d-none d-sm-flex" role="group">
|
||||
<a href="{{ url_for('view_list', list_id=l.id) }}" class="btn btn-sm btn-outline-light d-flex align-items-center">
|
||||
📂 <span class="btn-text ms-1">Otwórz</span>
|
||||
</a>
|
||||
<a href="{{ url_for('shared_list', token=l.share_token) }}" class="btn btn-sm btn-outline-light d-flex align-items-center">
|
||||
✏️ <span class="btn-text ms-1">Odznaczaj</span>
|
||||
</a>
|
||||
<a href="{{ url_for('copy_list', list_id=l.id) }}" class="btn btn-sm btn-outline-light d-flex align-items-center">
|
||||
📋 <span class="btn-text ms-1">Kopiuj</span>
|
||||
</a>
|
||||
<a href="{{ url_for('toggle_visibility', list_id=l.id) }}" class="btn btn-sm btn-outline-light d-flex align-items-center">
|
||||
{% if l.is_public %}🙈 <span class="btn-text ms-1">Ukryj</span>{% else %}🐵 <span class="btn-text ms-1">Odkryj</span>{% endif %}
|
||||
</a>
|
||||
<a href="{{ url_for('edit_my_list', list_id=l.id) }}" class="btn btn-sm btn-outline-light d-flex align-items-center">
|
||||
⚙️ <span class="btn-text ms-1">Ustawienia</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-group btn-group-compact mt-2 mt-md-0 d-flex d-sm-none" role="group">
|
||||
<a href="{{ url_for('view_list', list_id=l.id) }}" class="btn btn-sm btn-outline-light d-flex align-items-center"
|
||||
data-bs-toggle="tooltip" title="Otwórz">
|
||||
📂 <span class="btn-text ms-1">Otwórz</span>
|
||||
<div class="btn-group btn-group-compact list-main-actions" role="group">
|
||||
<a href="{{ url_for('view_list', list_id=l.id) }}" class="btn btn-sm btn-outline-light d-flex align-items-center" data-bs-toggle="tooltip" title="Otwórz">
|
||||
📂 <span class="btn-text ms-1 d-none d-sm-inline">Otwórz</span>
|
||||
</a>
|
||||
<a href="{{ url_for('shared_list', token=l.share_token) }}" class="btn btn-sm btn-outline-light d-flex align-items-center"
|
||||
data-bs-toggle="tooltip" title="Odznaczaj">
|
||||
✏️ <span class="btn-text ms-1">Odznaczaj</span>
|
||||
{% if l.share_token %}<a href="{{ url_for('shared_list', token=l.share_token) }}" class="btn btn-sm btn-outline-light d-flex align-items-center" data-bs-toggle="tooltip" title="Odznaczaj">
|
||||
✏️ <span class="btn-text ms-1 d-none d-sm-inline">Odznaczaj</span>
|
||||
</a>{% endif %}
|
||||
<a href="{{ url_for('copy_list', list_id=l.id) }}" class="btn btn-sm btn-outline-light d-flex align-items-center" data-bs-toggle="tooltip" title="Kopiuj">
|
||||
📋 <span class="btn-text ms-1 d-none d-sm-inline">Kopiuj</span>
|
||||
</a>
|
||||
<a href="{{ url_for('copy_list', list_id=l.id) }}" class="btn btn-sm btn-outline-light d-flex align-items-center"
|
||||
data-bs-toggle="tooltip" title="Kopiuj">
|
||||
📋 <span class="btn-text ms-1">Kopiuj</span>
|
||||
<a href="{{ url_for('toggle_visibility', list_id=l.id) }}" class="btn btn-sm btn-outline-light d-flex align-items-center" data-bs-toggle="tooltip" title="{% if l.is_public %}Ukryj{% else %}Odkryj{% endif %}">
|
||||
{% if l.is_public %}🙈 <span class="btn-text ms-1 d-none d-sm-inline">Ukryj</span>{% else %}🐵 <span class="btn-text ms-1 d-none d-sm-inline">Odkryj</span>{% endif %}
|
||||
</a>
|
||||
<a href="{{ url_for('toggle_visibility', list_id=l.id) }}" class="btn btn-sm btn-outline-light d-flex align-items-center"
|
||||
data-bs-toggle="tooltip" title="{% if l.is_public %}Ukryj{% else %}Odkryj{% endif %}">
|
||||
{% if l.is_public %}🙈 <span class="btn-text ms-1">Ukryj</span>{% else %}🐵 <span class="btn-text ms-1">Odkryj</span>{% endif %}
|
||||
</a>
|
||||
<a href="{{ url_for('edit_my_list', list_id=l.id) }}" class="btn btn-sm btn-outline-light d-flex align-items-center"
|
||||
data-bs-toggle="tooltip" title="Ustawienia">
|
||||
⚙️ <span class="btn-text ms-1">Ustawienia</span>
|
||||
<a href="{{ url_for('edit_my_list', list_id=l.id) }}" class="btn btn-sm btn-outline-light d-flex align-items-center" data-bs-toggle="tooltip" title="Ustawienia">
|
||||
⚙️ <span class="btn-text ms-1 d-none d-sm-inline">Ustawienia</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -183,45 +171,32 @@
|
||||
{% set percent = (purchased_count / total_count * 100) if total_count > 0 else 0 %}
|
||||
|
||||
<li class="list-group-item bg-dark text-white">
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap w-100">
|
||||
<span class="fw-bold">
|
||||
<!-- Desktop/tablet: zwykły tekst -->
|
||||
<span class="d-none d-sm-inline">
|
||||
{{ l.title }} (Autor: {{ l.owner.username if l.owner else '—' }})
|
||||
</span>
|
||||
|
||||
<!-- Mobile: klikalny tytuł -> shared_list -->
|
||||
<a class="d-inline d-sm-none fw-bold list-title text-white text-decoration-none"
|
||||
href="{{ url_for('view_list', list_id=l.id) }}">
|
||||
{{ l.title }}
|
||||
</a>
|
||||
|
||||
{% for cat in l.category_badges %}
|
||||
<!-- DESKTOP: nazwa -->
|
||||
<span class="badge rounded-pill text-dark ms-1 d-none d-sm-inline fw-semibold"
|
||||
style="background-color: {{ cat.color }}; font-size: 0.7rem; opacity: 0.9; padding: 0.3em 0.6em;">
|
||||
{{ cat.name }}
|
||||
<div class="main-list-row">
|
||||
<div class="list-main-meta">
|
||||
<div class="fw-bold list-main-title mobile-list-heading" data-mobile-list-heading>
|
||||
{% if l.is_temporary and l.expires_at %}<span class="badge rounded-pill bg-warning text-dark me-2">⏰ {{ l.expires_at.strftime('%Y-%m-%d %H:%M') }}</span>{% endif %}
|
||||
<a class="fw-bold list-main-title__link text-white text-decoration-none" href="{{ url_for('shared_list', list_id=l.id) }}">
|
||||
<span data-mobile-list-title>{{ l.title }}</span>
|
||||
<span class="d-none d-sm-inline"> (Autor: {{ l.owner.username if l.owner else '—' }})</span>
|
||||
</a>
|
||||
{% if l.category_badges %}
|
||||
<span class="mobile-category-badges" data-mobile-category-group>
|
||||
{% for cat in l.category_badges %}
|
||||
<span class="badge rounded-pill text-dark ms-1 fw-semibold mobile-category-badge"
|
||||
style="background-color: {{ cat.color }}; font-size: 0.7rem; opacity: 0.9; padding: 0.3em 0.6em;"
|
||||
title="{{ cat.name }}" aria-label="Kategoria: {{ cat.name }}">
|
||||
<span class="mobile-category-badge__text">{{ cat.name }}</span>
|
||||
<span class="mobile-category-badge__dot"></span>
|
||||
</span>
|
||||
{% endfor %}
|
||||
</span>
|
||||
|
||||
<!-- MOBILE -->
|
||||
<span class="ms-1 d-sm-none category-dot-pure"
|
||||
style="background-color: {{ cat.color }};"
|
||||
title="{{ cat.name }}" aria-label="Kategoria: {{ cat.name }}"></span>
|
||||
{% endfor %}
|
||||
</span>
|
||||
|
||||
<div class="btn-group btn-group-compact mt-2 mt-md-0 d-none d-sm-flex" role="group">
|
||||
<a href="{{ url_for('shared_list', list_id=l.id) }}"
|
||||
class="btn btn-sm btn-outline-light d-flex align-items-center">
|
||||
✏️ <span class="btn-text ms-1">Odznaczaj</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-group btn-group-compact mt-2 mt-md-0 d-flex d-sm-none" role="group">
|
||||
<a href="{{ url_for('shared_list', list_id=l.id) }}"
|
||||
class="btn btn-sm btn-outline-light d-flex align-items-center"
|
||||
data-bs-toggle="tooltip" title="Odznaczaj">
|
||||
✏️ <span class="btn-text ms-1">Odznaczaj</span>
|
||||
<div class="btn-group btn-group-compact list-main-actions" role="group">
|
||||
<a href="{{ url_for('shared_list', list_id=l.id) }}" class="btn btn-sm btn-outline-light d-flex align-items-center" data-bs-toggle="tooltip" title="Odznaczaj">
|
||||
✏️ <span class="btn-text ms-1 d-none d-sm-inline">Odznaczaj</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
58
shopping_app/templates/my_templates.html
Normal file
@@ -0,0 +1,58 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Moje szablony{% endblock %}
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4 gap-2">
|
||||
<div><h2 class="mb-1">🧩 Moje szablony</h2><p class="text-secondary mb-0">Każdy użytkownik zarządza własnymi szablonami niezależnie od panelu admina.</p></div>
|
||||
{% if current_user.is_admin %}<a href="{{ url_for('admin_templates') }}" class="btn btn-outline-secondary">Panel admina</a>{% endif %}
|
||||
</div>
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-5">
|
||||
<div class="card bg-dark text-white mb-4"><div class="card-body">
|
||||
<h5 class="mb-3">➕ Nowy szablon ręcznie</h5>
|
||||
<form method="post" data-unsaved-warning="true" class="stack-form">
|
||||
<input type="hidden" name="action" value="create_manual">
|
||||
<div class="mb-3"><label class="form-label">Nazwa</label><input type="text" name="name" class="form-control" required></div>
|
||||
<div class="mb-3"><label class="form-label">Opis</label><textarea name="description" class="form-control" rows="2"></textarea></div>
|
||||
<div class="mb-3"><label class="form-label">Produkty</label><textarea name="items_text" class="form-control" rows="8" placeholder="Mleko x2
|
||||
Chleb
|
||||
Jajka x10"></textarea><div class="form-text">Każda linia to osobny produkt. Ilość opcjonalnie przez xN.</div></div>
|
||||
<button class="btn btn-success w-100" type="submit">Utwórz szablon</button>
|
||||
</form>
|
||||
</div></div>
|
||||
<div class="card bg-dark text-white"><div class="card-body">
|
||||
<h5 class="mb-3">📋 Utwórz z istniejącej listy</h5>
|
||||
<form method="post" action="{{ url_for('create_template_from_user_list', list_id=0) }}" onsubmit="this.action=this.action.replace('/0','/' + this.querySelector('[name=source_list_id]').value);">
|
||||
<div class="mb-3"><label class="form-label">Lista źródłowa</label><select name="source_list_id" class="form-select" required>{% for l in source_lists %}<option value="{{ l.id }}">#{{ l.id }} — {{ l.title }}</option>{% endfor %}</select></div>
|
||||
<div class="mb-3"><label class="form-label">Nazwa szablonu</label><input type="text" name="template_name" class="form-control"></div>
|
||||
<div class="mb-3"><label class="form-label">Opis</label><textarea name="description" class="form-control" rows="2"></textarea></div>
|
||||
<button class="btn btn-outline-success w-100" type="submit">Zapisz z listy</button>
|
||||
</form>
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="col-lg-7">
|
||||
<div class="card bg-dark text-white"><div class="card-body">
|
||||
<div class="admin-page-head mb-3"><h5 class="mb-0">Aktywne szablony</h5><span class="badge rounded-pill bg-secondary">{{ templates|length }} szt.</span></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark align-middle" data-searchable="true">
|
||||
<thead><tr><th>Nazwa</th><th>Opis</th><th>Produkty</th><th>Akcje</th></tr></thead>
|
||||
<tbody>
|
||||
{% for template in templates %}
|
||||
<tr>
|
||||
<td><div class="fw-semibold">{{ template.name }}</div><div class="small text-secondary">{{ template.created_at.strftime('%Y-%m-%d %H:%M') if template.created_at else '' }}</div></td>
|
||||
<td>{{ template.description or '—' }}</td>
|
||||
<td>{{ template.items|length }}</td>
|
||||
<td><div class="d-flex flex-wrap gap-2">
|
||||
<form method="post" action="{{ url_for('instantiate_template', template_id=template.id) }}" class="d-inline"><button class="btn btn-sm btn-outline-success" type="submit">➕ Utwórz listę</button></form>
|
||||
<form method="post" onsubmit="return confirm('Usunąć szablon?')" class="d-inline"><input type="hidden" name="action" value="delete"><input type="hidden" name="template_id" value="{{ template.id }}"><button class="btn btn-sm btn-outline-danger" type="submit">🗑 Usuń</button></form>
|
||||
</div></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="4" class="text-center text-secondary py-4">Brak własnych szablonów.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -2,16 +2,19 @@
|
||||
{% 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">
|
||||
<form method="post">
|
||||
<div class="mb-3">
|
||||
<input type="password" name="password" placeholder="Hasło"
|
||||
class="form-control bg-dark text-white border-secondary rounded" required>
|
||||
<div class="input-group ui-password-group">
|
||||
<input type="password" name="password" placeholder="Hasło"
|
||||
class="form-control bg-dark text-white border-secondary rounded" required>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success w-100">🔓 Wejdź</button>
|
||||
</form>
|
||||
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 is None:
|
||||
return
|
||||
|
||||
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") or endpoint.startswith("api_"):
|
||||
return
|
||||
|
||||
ip = request.access_route[0]
|
||||
if is_ip_blocked(ip):
|
||||
abort(403)
|
||||
|
||||
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"}
|
||||
1036
static/css/style.css
@@ -1,130 +0,0 @@
|
||||
(function () {
|
||||
const form = document.getElementById("settings-form");
|
||||
const resetAllBtn = document.getElementById("reset-all");
|
||||
|
||||
function ensureHiddenClear(input) {
|
||||
let hidden = input.parentElement.querySelector(`input[type="hidden"][name="${input.name}"]`);
|
||||
if (!hidden) {
|
||||
hidden = document.createElement("input");
|
||||
hidden.type = "hidden";
|
||||
hidden.name = input.name;
|
||||
hidden.value = "";
|
||||
input.parentElement.appendChild(hidden);
|
||||
}
|
||||
}
|
||||
function removeHiddenClear(input) {
|
||||
const hidden = input.parentElement.querySelector(`input[type="hidden"][name="${input.name}"]`);
|
||||
if (hidden) hidden.remove();
|
||||
}
|
||||
|
||||
function updatePreview(input) {
|
||||
const card = input.closest(".col-12, .col-md-6, .col-lg-4");
|
||||
const hexAutoEl = card.querySelector(".hex-auto");
|
||||
const hexEffEl = card.querySelector(".hex-effective");
|
||||
const barAuto = card.querySelector('.bar[data-kind="auto"]');
|
||||
const barEff = card.querySelector('.bar[data-kind="effective"]');
|
||||
|
||||
const raw = (input.value || "").trim();
|
||||
const autoHex = hexAutoEl.textContent.trim();
|
||||
const effHex = (raw || autoHex).toUpperCase();
|
||||
|
||||
if (barEff) barEff.style.backgroundColor = effHex;
|
||||
if (hexEffEl) hexEffEl.textContent = effHex;
|
||||
|
||||
if (!raw) {
|
||||
ensureHiddenClear(input);
|
||||
input.disabled = true;
|
||||
} else {
|
||||
removeHiddenClear(input);
|
||||
input.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
form.querySelectorAll(".use-default").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
const name = btn.getAttribute("data-target");
|
||||
const input = form.querySelector(`input[name="${name}"]`);
|
||||
if (!input) return;
|
||||
input.value = "";
|
||||
updatePreview(input);
|
||||
});
|
||||
});
|
||||
|
||||
form.querySelectorAll(".reset-one").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
const name = btn.getAttribute("data-target");
|
||||
const input = form.querySelector(`input[name="${name}"]`);
|
||||
if (!input) return;
|
||||
input.value = "";
|
||||
updatePreview(input);
|
||||
});
|
||||
});
|
||||
|
||||
resetAllBtn?.addEventListener("click", () => {
|
||||
form.querySelectorAll('input[type="color"].category-color').forEach(input => {
|
||||
input.value = "";
|
||||
updatePreview(input);
|
||||
});
|
||||
});
|
||||
|
||||
form.querySelectorAll('input[type="color"].category-color').forEach(input => {
|
||||
updatePreview(input);
|
||||
input.addEventListener("input", () => updatePreview(input));
|
||||
input.addEventListener("change", () => updatePreview(input));
|
||||
});
|
||||
|
||||
form.addEventListener("submit", () => {
|
||||
form.querySelectorAll('input[type="color"].category-color').forEach(updatePreview);
|
||||
});
|
||||
|
||||
form.querySelectorAll(".use-default").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
const name = btn.getAttribute("data-target");
|
||||
const input = form.querySelector(`input[name="${name}"]`);
|
||||
if (!input) return;
|
||||
|
||||
const card = input.closest(".col-12, .col-md-6, .col-lg-4") || input.closest(".col-12");
|
||||
let autoHex = (input.dataset.auto || "").trim();
|
||||
if (!autoHex && card) {
|
||||
autoHex = (card.querySelector(".hex-auto")?.textContent || "").trim();
|
||||
}
|
||||
if (autoHex && !autoHex.startsWith("#")) autoHex = `#${autoHex}`;
|
||||
|
||||
if (autoHex) {
|
||||
input.disabled = false;
|
||||
removeHiddenClear(input);
|
||||
input.value = autoHex;
|
||||
updatePreview(input);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
(function () {
|
||||
const slider = document.getElementById("ocr_sensitivity");
|
||||
const badge = document.getElementById("ocr_sens_badge");
|
||||
const value = document.getElementById("ocr_sens_value");
|
||||
if (!slider || !badge || !value) return;
|
||||
|
||||
function labelFor(v) {
|
||||
v = Number(v);
|
||||
if (v <= 3) return "Niski";
|
||||
if (v <= 7) return "Średni";
|
||||
return "Wysoki";
|
||||
}
|
||||
function clsFor(v) {
|
||||
v = Number(v);
|
||||
if (v <= 3) return "sens-low";
|
||||
if (v <= 7) return "sens-mid";
|
||||
return "sens-high";
|
||||
}
|
||||
function update() {
|
||||
value.textContent = `(${slider.value})`;
|
||||
badge.textContent = labelFor(slider.value);
|
||||
badge.classList.remove("sens-low","sens-mid","sens-high");
|
||||
badge.classList.add(clsFor(slider.value));
|
||||
}
|
||||
slider.addEventListener("input", update);
|
||||
slider.addEventListener("change", update);
|
||||
update();
|
||||
})();
|
||||
})();
|
||||
@@ -1,254 +0,0 @@
|
||||
(function () {
|
||||
const $ = (s, root = document) => root.querySelector(s);
|
||||
const $$ = (s, root = document) => Array.from(root.querySelectorAll(s));
|
||||
|
||||
const filterInput = $('#listFilter');
|
||||
const filterCount = $('#filterCount');
|
||||
const selectAll = $('#selectAll');
|
||||
const bulkTokens = $('#bulkTokens');
|
||||
const bulkInput = $('#bulkUsersInput');
|
||||
const bulkBtn = $('#bulkAddBtn');
|
||||
const datalist = $('#userHints');
|
||||
|
||||
const unique = (arr) => Array.from(new Set(arr));
|
||||
const parseUserText = (txt) => unique((txt || '')
|
||||
.split(/[\s,;]+/g)
|
||||
.map(s => s.trim().replace(/^@/, '').toLowerCase())
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
const selectedListIds = () =>
|
||||
$$('.row-check:checked').map(ch => ch.dataset.listId);
|
||||
|
||||
const visibleRows = () =>
|
||||
$$('#listsTable tbody tr').filter(r => r.style.display !== 'none');
|
||||
|
||||
// ===== Podpowiedzi (datalist) z DOM-u =====
|
||||
(function buildHints() {
|
||||
const names = new Set();
|
||||
$$('.owner-username').forEach(el => names.add(el.dataset.username));
|
||||
$$('.permitted-username').forEach(el => names.add(el.dataset.username));
|
||||
// również tokeny już wyrenderowane
|
||||
$$('.token[data-username]').forEach(el => names.add(el.dataset.username));
|
||||
datalist.innerHTML = Array.from(names)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map(u => `<option value="${u}">@${u}</option>`)
|
||||
.join('');
|
||||
})();
|
||||
|
||||
// ===== Live filter =====
|
||||
function applyFilter() {
|
||||
const q = (filterInput?.value || '').trim().toLowerCase();
|
||||
let shown = 0;
|
||||
$$('#listsTable tbody tr').forEach(tr => {
|
||||
const hay = `${tr.dataset.id || ''} ${tr.dataset.title || ''} ${tr.dataset.owner || ''}`;
|
||||
const ok = !q || hay.includes(q);
|
||||
tr.style.display = ok ? '' : 'none';
|
||||
if (ok) shown++;
|
||||
});
|
||||
if (filterCount) filterCount.textContent = shown ? `Widoczne: ${shown}` : 'Brak wyników';
|
||||
}
|
||||
filterInput?.addEventListener('input', applyFilter);
|
||||
applyFilter();
|
||||
|
||||
// ===== Select all =====
|
||||
selectAll?.addEventListener('change', () => {
|
||||
visibleRows().forEach(tr => {
|
||||
const cb = tr.querySelector('.row-check');
|
||||
if (cb) cb.checked = selectAll.checked;
|
||||
});
|
||||
});
|
||||
|
||||
// ===== Copy share URL =====
|
||||
$$('.copy-share').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const url = btn.dataset.url;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
showToast('Skopiowano link udostępnienia', 'success');
|
||||
} catch {
|
||||
const ta = Object.assign(document.createElement('textarea'), { value: url });
|
||||
document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove();
|
||||
showToast('Skopiowano link udostępnienia', 'success');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ===== Tokenized users field (global – belka) =====
|
||||
function addGlobalToken(username) {
|
||||
if (!username) return;
|
||||
const exists = $(`.user-token[data-user="${username}"]`, bulkTokens);
|
||||
if (exists) return;
|
||||
const token = document.createElement('span');
|
||||
token.className = 'badge rounded-pill text-bg-secondary user-token';
|
||||
token.dataset.user = username;
|
||||
token.innerHTML = `@${username} <button type="button" class="btn btn-sm btn-link p-0 ms-1 text-white">✕</button>`;
|
||||
token.querySelector('button').addEventListener('click', () => token.remove());
|
||||
bulkTokens.appendChild(token);
|
||||
}
|
||||
bulkInput?.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
parseUserText(bulkInput.value).forEach(addGlobalToken);
|
||||
bulkInput.value = '';
|
||||
}
|
||||
});
|
||||
bulkInput?.addEventListener('change', () => {
|
||||
parseUserText(bulkInput.value).forEach(addGlobalToken);
|
||||
bulkInput.value = '';
|
||||
});
|
||||
|
||||
// ===== Bulk grant (z belki) =====
|
||||
async function bulkGrant() {
|
||||
const lists = selectedListIds();
|
||||
const users = $$('.user-token', bulkTokens).map(t => t.dataset.user);
|
||||
|
||||
if (!lists.length) { showToast('Zaznacz przynajmniej jedną listę', 'warning'); return; }
|
||||
if (!users.length) { showToast('Dodaj przynajmniej jednego użytkownika', 'warning'); return; }
|
||||
|
||||
bulkBtn.disabled = true;
|
||||
bulkBtn.textContent = 'Pracuję…';
|
||||
|
||||
const url = location.pathname + location.search;
|
||||
let ok = 0, fail = 0;
|
||||
|
||||
for (const lid of lists) {
|
||||
for (const u of users) {
|
||||
const form = new FormData();
|
||||
form.set('action', 'grant');
|
||||
form.set('target_list_id', lid);
|
||||
form.set('grant_username', u);
|
||||
|
||||
try {
|
||||
const res = await fetch(url, { method: 'POST', body: form, credentials: 'same-origin' });
|
||||
if (res.ok) ok++; else fail++;
|
||||
} catch { fail++; }
|
||||
}
|
||||
}
|
||||
|
||||
bulkBtn.disabled = false;
|
||||
bulkBtn.textContent = '➕ Nadaj dostęp';
|
||||
|
||||
showToast(`Gotowe. Sukcesy: ${ok}${fail ? `, błędy: ${fail}` : ''}`, fail ? 'danger' : 'success');
|
||||
location.reload();
|
||||
}
|
||||
bulkBtn?.addEventListener('click', bulkGrant);
|
||||
|
||||
// ===== Per-row "Access editor" (tokeny + dodawanie) =====
|
||||
async function postAction(params) {
|
||||
const url = location.pathname + location.search;
|
||||
const form = new FormData();
|
||||
for (const [k, v] of Object.entries(params)) form.set(k, v);
|
||||
const res = await fetch(url, { method: 'POST', body: form, credentials: 'same-origin' });
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
// Delegacja zdarzeń: kliknięcie tokenu = revoke
|
||||
document.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('.access-editor .token');
|
||||
if (!btn) return;
|
||||
|
||||
const wrapper = btn.closest('.access-editor');
|
||||
const listId = wrapper?.dataset.listId;
|
||||
const userId = btn.dataset.userId;
|
||||
const username = btn.dataset.username;
|
||||
|
||||
if (!listId || !userId) return;
|
||||
|
||||
btn.disabled = true;
|
||||
btn.classList.add('disabled');
|
||||
|
||||
const ok = await postAction({
|
||||
action: 'revoke',
|
||||
target_list_id: listId,
|
||||
revoke_user_id: userId
|
||||
});
|
||||
|
||||
if (ok) {
|
||||
btn.remove();
|
||||
const tokens = $$('.token', wrapper);
|
||||
if (!tokens.length) {
|
||||
// pokaż info „brak uprawnień”
|
||||
let empty = $('.no-perms', wrapper);
|
||||
if (!empty) {
|
||||
empty = document.createElement('span');
|
||||
empty.className = 'text-warning small no-perms';
|
||||
empty.textContent = 'Brak dodanych uprawnień.';
|
||||
$('.tokens', wrapper).appendChild(empty);
|
||||
}
|
||||
}
|
||||
showToast(`Odebrano dostęp: @${username}`, 'success');
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('disabled');
|
||||
showToast(`Nie udało się odebrać dostępu @${username}`, 'danger');
|
||||
}
|
||||
});
|
||||
|
||||
// Dodawanie wielu użytkowników per-row
|
||||
document.addEventListener('click', async (e) => {
|
||||
const addBtn = e.target.closest('.access-editor .access-add');
|
||||
if (!addBtn) return;
|
||||
|
||||
const wrapper = addBtn.closest('.access-editor');
|
||||
const listId = wrapper?.dataset.listId;
|
||||
const input = $('.access-input', wrapper);
|
||||
if (!listId || !input) return;
|
||||
|
||||
const users = parseUserText(input.value);
|
||||
if (!users.length) { showToast('Podaj co najmniej jednego użytkownika', 'warning'); return; }
|
||||
|
||||
addBtn.disabled = true;
|
||||
addBtn.textContent = 'Dodaję…';
|
||||
|
||||
let okCount = 0, failCount = 0;
|
||||
|
||||
for (const u of users) {
|
||||
const ok = await postAction({
|
||||
action: 'grant',
|
||||
target_list_id: listId,
|
||||
grant_username: u
|
||||
});
|
||||
if (ok) {
|
||||
okCount++;
|
||||
// usuń info „brak uprawnień”
|
||||
$('.no-perms', wrapper)?.remove();
|
||||
// dodaj token jeśli nie ma
|
||||
const exists = $(`.token[data-username="${u}"]`, wrapper);
|
||||
if (!exists) {
|
||||
const token = document.createElement('button');
|
||||
token.type = 'button';
|
||||
token.className = 'btn btn-sm btn-outline-secondary rounded-pill token';
|
||||
token.dataset.username = u;
|
||||
token.dataset.userId = ''; // nie znamy ID — token nadal klikany, ale bez revoke po ID
|
||||
token.title = '@' + u;
|
||||
token.innerHTML = `@${u} <span aria-hidden="true">×</span>`;
|
||||
$('.tokens', wrapper).appendChild(token);
|
||||
}
|
||||
} else {
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
addBtn.disabled = false;
|
||||
addBtn.textContent = '➕ Dodaj';
|
||||
input.value = '';
|
||||
|
||||
if (okCount) showToast(`Dodano dostęp: ${okCount} użytk.`, 'success');
|
||||
if (failCount) showToast(`Błędy przy dodawaniu: ${failCount}`, 'danger');
|
||||
|
||||
// Odśwież, by mieć poprawne user_id w tokenach (backend wie lepiej)
|
||||
if (okCount) location.reload();
|
||||
});
|
||||
|
||||
// Enter w polu per-row = zadziałaj jak przycisk
|
||||
document.addEventListener('keydown', (e) => {
|
||||
const inp = e.target.closest('.access-editor .access-input');
|
||||
if (inp && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const btn = inp.closest('.access-editor')?.querySelector('.access-add');
|
||||
btn?.click();
|
||||
}
|
||||
});
|
||||
|
||||
})();
|
||||
@@ -1,27 +0,0 @@
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const toggleBtn = document.getElementById("tempToggle");
|
||||
const hiddenInput = document.getElementById("temporaryHidden");
|
||||
const tooltip = new bootstrap.Tooltip(toggleBtn);
|
||||
|
||||
function updateToggle(isActive) {
|
||||
if (isActive) {
|
||||
toggleBtn.classList.remove("btn-outline-secondary");
|
||||
toggleBtn.classList.add("btn-success");
|
||||
toggleBtn.textContent = "Tymczasowa ✔️";
|
||||
} else {
|
||||
toggleBtn.classList.remove("btn-success");
|
||||
toggleBtn.classList.add("btn-outline-secondary");
|
||||
toggleBtn.textContent = "Tymczasowa";
|
||||
}
|
||||
}
|
||||
|
||||
let active = toggleBtn.getAttribute("data-active") === "1";
|
||||
updateToggle(active);
|
||||
|
||||
toggleBtn.addEventListener("click", function () {
|
||||
active = !active;
|
||||
toggleBtn.setAttribute("data-active", active ? "1" : "0");
|
||||
hiddenInput.value = active ? "1" : "0";
|
||||
updateToggle(active);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
70
tests/test_refactor.py
Normal file
@@ -0,0 +1,70 @@
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from shopping_app import app
|
||||
|
||||
|
||||
class RefactorSmokeTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
app.config.update(TESTING=True)
|
||||
cls.client = app.test_client()
|
||||
|
||||
def test_undefined_path_returns_not_500(self):
|
||||
response = self.client.get('/undefined')
|
||||
self.assertNotEqual(response.status_code, 500)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_login_page_renders(self):
|
||||
response = self.client.get('/login')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
html = response.get_data(as_text=True)
|
||||
self.assertIn('name="password"', html)
|
||||
self.assertIn('app_ui.js', html)
|
||||
|
||||
|
||||
class TemplateContractTests(unittest.TestCase):
|
||||
def test_main_template_uses_single_action_group_on_mobile(self):
|
||||
main_html = Path('shopping_app/templates/main.html').read_text(encoding='utf-8')
|
||||
self.assertIn('mobile-list-heading', main_html)
|
||||
self.assertIn('list-main-title__link', main_html)
|
||||
self.assertNotIn('d-flex d-sm-none" role="group"', main_html)
|
||||
|
||||
def test_list_templates_use_compact_mobile_action_layout(self):
|
||||
list_html = Path('shopping_app/templates/list.html').read_text(encoding='utf-8')
|
||||
shared_html = Path('shopping_app/templates/list_share.html').read_text(encoding='utf-8')
|
||||
for html in (list_html, shared_html):
|
||||
self.assertIn('shopping-item-row', html)
|
||||
self.assertIn('shopping-item-actions', html)
|
||||
self.assertIn('shopping-compact-input-group', html)
|
||||
self.assertIn('shopping-item-head', html)
|
||||
|
||||
def test_css_contains_mobile_ux_overrides(self):
|
||||
css = Path('shopping_app/static/css/style.css').read_text(encoding='utf-8')
|
||||
self.assertIn('.shopping-item-actions', css)
|
||||
self.assertIn('.shopping-compact-input-group', css)
|
||||
self.assertIn('.ui-password-group > .ui-password-toggle', css)
|
||||
self.assertIn('.hide-purchased-switch--minimal', css)
|
||||
self.assertIn('.shopping-item-head', css)
|
||||
self.assertIn('UX tweak 2026-03-14 c: hamburger with full labels', css)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
|
||||
class NavbarContractTests(unittest.TestCase):
|
||||
def test_base_template_uses_mobile_collapse_nav(self):
|
||||
base_html = Path('shopping_app/templates/base.html').read_text(encoding='utf-8')
|
||||
self.assertIn('navbar-toggler', base_html)
|
||||
self.assertIn('appNavbarMenu', base_html)
|
||||
|
||||
|
||||
def test_base_template_mobile_nav_has_full_labels(self):
|
||||
base_html = Path('shopping_app/templates/base.html').read_text(encoding='utf-8')
|
||||
self.assertIn('>📊 <span>Wydatki</span><', base_html)
|
||||
self.assertIn('>🚪 <span>Wyloguj</span><', base_html)
|
||||
|
||||
def test_main_template_temp_toggle_is_integrated(self):
|
||||
main_html = Path('shopping_app/templates/main.html').read_text(encoding='utf-8')
|
||||
self.assertIn('create-list-temp-toggle', main_html)
|
||||