diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8b5e4f9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +*.py text working-tree-encoding=UTF-8 +*.env.example text working-tree-encoding=UTF-8 +.env text working-tree-encoding=UTF-8 diff --git a/.gitignore b/.gitignore index 4ba6499..00d8011 100644 --- a/.gitignore +++ b/.gitignore @@ -3,9 +3,9 @@ venv env *.db __pycache__ -instance/ -database/ uploads/ .DS_Store -db/* +db/mysql/* +db/pgsql/* +db/shopping.db *.swp \ No newline at end of file diff --git a/app.py b/app.py index fa12574..ae84c49 100644 --- a/app.py +++ b/app.py @@ -29,6 +29,7 @@ from flask import ( abort, session, jsonify, + g, ) from flask_sqlalchemy import SQLAlchemy from flask_login import ( @@ -44,8 +45,8 @@ 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 -from sqlalchemy.orm import joinedload, load_only +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 @@ -54,14 +55,13 @@ from flask_session import Session from types import SimpleNamespace from pdf2image import convert_from_bytes from urllib.parse import urlencode +from typing import Sequence, Any # OCR import pytesseract from pytesseract import Output import logging -from types import SimpleNamespace - app = Flask(__name__) app.config.from_object(Config) @@ -142,9 +142,9 @@ TIME_WINDOW = 60 * 60 WEBP_SAVE_PARAMS = { "format": "WEBP", - "lossless": True, # lub False jeśli chcesz używać quality + "lossless": False, # False jeśli chcesz używać quality "method": 6, - # "quality": 95, # tylko jeśli lossless=False + "quality": 95, # tylko jeśli lossless=False } db = SQLAlchemy(app) @@ -220,10 +220,13 @@ class ShoppingList(db.Model): # Relacje items = db.relationship("Item", back_populates="shopping_list", lazy="select") - receipts = db.relationship("Receipt", 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") - - # Nowa relacja wiele-do-wielu categories = db.relationship( "Category", secondary=shopping_list_category, @@ -271,13 +274,20 @@ class Expense(db.Model): class Receipt(db.Model): id = db.Column(db.Integer, primary_key=True) - list_id = db.Column(db.Integer, db.ForeignKey("shopping_list.id"), nullable=False) + 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") def hash_password(password): @@ -400,6 +410,10 @@ def allowed_file(filename): return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS +def generate_version_token(): + return secrets.token_hex(8) + + def get_list_details(list_id): shopping_list = ShoppingList.query.options( joinedload(ShoppingList.items).joinedload(Item.added_by_user), @@ -410,9 +424,9 @@ def get_list_details(list_id): items = sorted(shopping_list.items, key=lambda i: i.position or 0) expenses = shopping_list.expenses total_expense = sum(e.amount for e in expenses) if expenses else 0 - receipt_files = [r.filename for r in shopping_list.receipts] + receipts = shopping_list.receipts - return shopping_list, items, receipt_files, expenses, total_expense + return shopping_list, items, receipts, expenses, total_expense def get_total_expense_for_list(list_id, start_date=None, end_date=None): @@ -506,12 +520,13 @@ def save_resized_image(file, path): pass try: - image.thumbnail((2000, 2000)) + image.thumbnail((1500, 1500)) image = image.convert("RGB") image.info.clear() new_path = path.rsplit(".", 1)[0] + ".webp" - image.save(new_path, **WEBP_SAVE_PARAMS) + # image.save(new_path, **WEBP_SAVE_PARAMS) + image.save(new_path, format="WEBP", method=6, quality=100) except Exception as e: raise ValueError(f"Błąd podczas przetwarzania obrazu: {e}") @@ -534,17 +549,22 @@ def admin_required(f): return decorated_function -def get_progress(list_id): - total_count, purchased_count = ( +def get_progress(list_id: int) -> tuple[int, int, float]: + result = ( db.session.query( - func.count(Item.id), func.sum(case((Item.purchased == True, 1), else_=0)) + func.count(Item.id), + func.sum(case((Item.purchased == True, 1), else_=0)), ) .filter(Item.list_id == list_id) .first() ) - total_count = total_count or 0 - purchased_count = purchased_count or 0 + if result is None: + total_count = 0 + purchased_count = 0 + else: + total_count = result[0] or 0 + purchased_count = result[1] or 0 percent = (purchased_count / total_count * 100) if total_count > 0 else 0 return purchased_count, total_count, percent @@ -570,23 +590,27 @@ def receipt_error(message): def rotate_receipt_by_id(receipt_id): receipt = Receipt.query.get_or_404(receipt_id) - old_path = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename) + path = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename) - if not os.path.exists(old_path): + if not os.path.exists(path): raise FileNotFoundError("Plik nie istnieje") - image = Image.open(old_path) - rotated = image.rotate(-90, expand=True) + try: + image = Image.open(path) + rotated = image.rotate(-90, expand=True) - new_filename = generate_new_receipt_filename(receipt.list_id) - new_path = os.path.join(app.config["UPLOAD_FOLDER"], new_filename) - rotated.save(new_path, **WEBP_SAVE_PARAMS) + rotated = rotated.convert("RGB") + rotated.info.clear() - os.remove(old_path) - receipt.filename = new_filename - db.session.commit() + rotated.save(path, format="WEBP", method=6, quality=100) + receipt.version_token = generate_version_token() + recalculate_filesizes(receipt.id) + db.session.commit() - return receipt + return receipt + except Exception as e: + app.logger.exception("Błąd podczas rotacji pliku") + raise RuntimeError(f"Błąd podczas rotacji pliku: {e}") def delete_receipt_by_id(receipt_id): @@ -611,23 +635,18 @@ def handle_crop_receipt(receipt_id, file): if not receipt_id or not file: return {"success": False, "error": "Brak danych"} - receipt = Receipt.query.get_or_404(receipt_id) - old_path = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename) - try: - new_filename = generate_new_receipt_filename(receipt.list_id) - new_path = os.path.join(app.config["UPLOAD_FOLDER"], new_filename) + receipt = Receipt.query.get_or_404(receipt_id) + path = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename) - save_resized_image(file, new_path) - - if os.path.exists(old_path): - os.remove(old_path) - - receipt.filename = os.path.basename(new_path) - db.session.commit() + save_resized_image(file, path) + receipt.version_token = generate_version_token() recalculate_filesizes(receipt.id) + db.session.commit() + return {"success": True} except Exception as e: + app.logger.exception("Błąd podczas przycinania paragonu") return {"success": False, "error": str(e)} @@ -772,55 +791,70 @@ def get_admin_expense_summary(): current_year = now.year current_month = now.month - def calc_sum(base_query): - total = base_query.scalar() or 0 + def calc_summary(expense_query, list_query): + total = expense_query.scalar() or 0 year_total = ( - base_query.filter( + expense_query.filter( extract("year", ShoppingList.created_at) == current_year ).scalar() or 0 ) month_total = ( - base_query.filter(extract("year", ShoppingList.created_at) == current_year) - .filter(extract("month", ShoppingList.created_at) == current_month) - .scalar() + expense_query.filter( + extract("year", ShoppingList.created_at) == current_year, + extract("month", ShoppingList.created_at) == current_month, + ).scalar() or 0 ) - return {"total": total, "year": year_total, "month": month_total} + list_count = list_query.count() + avg = round(total / list_count, 2) if list_count else 0 + return { + "total": total, + "year": year_total, + "month": month_total, + "count": list_count, + "avg": avg, + } - base = db.session.query(func.sum(Expense.amount)).join( + expense_base = db.session.query(func.sum(Expense.amount)).join( ShoppingList, ShoppingList.id == Expense.list_id ) + list_base = ShoppingList.query - all_lists = calc_sum(base) + all = calc_summary(expense_base, list_base) - active_lists = calc_sum( - base.filter( - ShoppingList.is_archived == False, - ~( - (ShoppingList.is_temporary == True) - & (ShoppingList.expires_at != None) - & (ShoppingList.expires_at <= now) - ), - ) + active_condition = and_( + ShoppingList.is_archived == False, + ~( + (ShoppingList.is_temporary == True) + & (ShoppingList.expires_at != None) + & (ShoppingList.expires_at <= now) + ), + ) + active = calc_summary( + expense_base.filter(active_condition), list_base.filter(active_condition) ) - archived_lists = calc_sum(base.filter(ShoppingList.is_archived == True)) + archived_condition = ShoppingList.is_archived == True + archived = calc_summary( + expense_base.filter(archived_condition), list_base.filter(archived_condition) + ) - expired_lists = calc_sum( - base.filter( - ShoppingList.is_archived == False, - (ShoppingList.is_temporary == True), - (ShoppingList.expires_at != None), - (ShoppingList.expires_at <= now), - ) + expired_condition = and_( + ShoppingList.is_archived == False, + ShoppingList.is_temporary == True, + ShoppingList.expires_at != None, + ShoppingList.expires_at <= now, + ) + expired = calc_summary( + expense_base.filter(expired_condition), list_base.filter(expired_condition) ) return { - "all": all_lists, - "active": active_lists, - "archived": archived_lists, - "expired": expired_lists, + "all": all, + "active": active, + "archived": archived, + "expired": expired, } @@ -859,6 +893,19 @@ def get_total_expenses_grouped_by_category( shopping_list_category.c.shopping_list_id == ShoppingList.id, ).filter(shopping_list_category.c.category_id == cat_id_int) + if not start_date and not end_date: + today = datetime.now(timezone.utc).date() + if range_type == "last30days": + dt_start = today - timedelta(days=29) + dt_end = today + timedelta(days=1) + start_date = dt_start.strftime("%Y-%m-%d") + end_date = dt_end.strftime("%Y-%m-%d") + elif range_type == "currentmonth": + dt_start = today.replace(day=1) + dt_end = today + timedelta(days=1) + start_date = dt_start.strftime("%Y-%m-%d") + end_date = dt_end.strftime("%Y-%m-%d") + if start_date and end_date: try: dt_start = datetime.strptime(start_date, "%Y-%m-%d") @@ -951,18 +998,20 @@ def save_pdf_as_webp(file, path): combined.paste(img, (0, y_offset)) y_offset += img.height - combined.thumbnail((2000, 20000)) new_path = path.rsplit(".", 1)[0] + ".webp" - combined.save(new_path, **WEBP_SAVE_PARAMS) + # combined.save(new_path, **WEBP_SAVE_PARAMS) + combined.save(new_path, format="WEBP") except Exception as e: raise ValueError(f"Błąd podczas przetwarzania PDF: {e}") def get_active_months_query(visible_lists_query=None): - if db.engine.name == "sqlite": + if db.engine.name in ("sqlite",): month_col = func.strftime("%Y-%m", ShoppingList.created_at) - else: + elif db.engine.name in ("mysql", "mariadb"): + month_col = func.date_format(ShoppingList.created_at, "%Y-%m") + else: # PostgreSQL i inne wspierające to_char month_col = func.to_char(ShoppingList.created_at, "YYYY-MM") query = db.session.query(month_col.label("month")) @@ -978,7 +1027,33 @@ def get_active_months_query(visible_lists_query=None): def normalize_name(name): if not name: return "" - return re.sub(r'\s+', ' ', name).strip().lower() + return re.sub(r"\s+", " ", name).strip().lower() + + +def get_valid_item_or_404(item_id: int, list_id: int) -> Item: + item = db.session.get(Item, item_id) + if not item or item.list_id != list_id: + abort(404, description="Nie znaleziono produktu") + return item + + +def paginate_items( + items: Sequence[Any], page: int, per_page: int +) -> tuple[list, int, int]: + total_items = len(items) + total_pages = (total_items + per_page - 1) // per_page + start = (page - 1) * per_page + end = start + per_page + return items[start:end], total_items, total_pages + + +def get_page_args( + default_per_page: int = 100, max_per_page: int = 300 +) -> tuple[int, int]: + page = request.args.get("page", 1, type=int) + per_page = request.args.get("per_page", default_per_page, type=int) + per_page = max(1, min(per_page, max_per_page)) + return page, per_page ############# OCR ########################### @@ -1184,9 +1259,34 @@ def require_system_password(): return redirect(url_for("system_auth", next=fixed_url)) +@app.after_request +def apply_headers(response): + if request.path.startswith(("/static/", "/uploads/")): + response.headers["Vary"] = "Accept-Encoding" + return response + + if response.status_code in (301, 302, 303, 307, 308): + response.headers["Cache-Control"] = "no-store" + response.headers.pop("Vary", None) + return response + + if 400 <= response.status_code < 500: + response.headers["Cache-Control"] = "no-store" + response.headers["Content-Type"] = "text/html; charset=utf-8" + response.headers.pop("Vary", None) + + elif 500 <= response.status_code < 600: + response.headers["Cache-Control"] = "no-store" + response.headers["Content-Type"] = "text/html; charset=utf-8" + response.headers["Retry-After"] = "120" + response.headers.pop("Vary", None) + + return response + + @app.before_request def start_timer(): - request._start_time = time.time() + g.start_time = time.time() @app.after_request @@ -1199,9 +1299,10 @@ def log_request(response): path = request.path status = response.status_code length = response.content_length or "-" - start = getattr(request, "_start_time", None) + 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}"' @@ -1210,6 +1311,7 @@ def log_request(response): 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 @@ -1503,29 +1605,6 @@ def system_auth(): return render_template("system_auth.html") -@app.route("/toggle_archive_list/") -@login_required -def toggle_archive_list(list_id): - l = db.session.get(ShoppingList, list_id) - if l is None: - abort(404) - - if l.owner_id != current_user.id: - return redirect_with_flash("Nie masz uprawnień do tej listy", "danger") - - archive = request.args.get("archive", "true").lower() == "true" - - if archive: - l.is_archived = True - flash(f"Lista „{l.title}” została zarchiwizowana.", "success") - else: - l.is_archived = False - flash(f"Lista „{l.title}” została przywrócona.", "success") - - db.session.commit() - return redirect(url_for("main_page")) - - @app.route("/edit_my_list/", methods=["GET", "POST"]) @login_required def edit_my_list(list_id): @@ -1548,6 +1627,13 @@ def edit_my_list(list_id): next_page = request.args.get("next") or request.referrer if request.method == "POST": + if "unarchive" in request.form: + l.is_archived = False + db.session.commit() + flash(f"Lista „{l.title}” została przywrócona.", "success") + return redirect(next_page or url_for("main_page")) + + # Pełna edycja formularza move_to_month = request.form.get("move_to_month") if move_to_month: try: @@ -1704,9 +1790,24 @@ def create_list(): @app.route("/list/") @login_required def view_list(list_id): - shopping_list, items, receipt_files, expenses, total_expense = get_list_details( - 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 @@ -1729,7 +1830,7 @@ def view_list(list_id): "list.html", list=shopping_list, items=items, - receipt_files=receipt_files, + receipts=receipts, total_count=total_count, purchased_count=purchased_count, percent=percent, @@ -1904,9 +2005,7 @@ def shared_list(token=None, list_id=None): list_id = shopping_list.id total_expense = get_total_expense_for_list(list_id) - shopping_list, items, receipt_files, expenses, total_expense = get_list_details( - list_id - ) + shopping_list, items, receipts, expenses, total_expense = get_list_details(list_id) shopping_list.category_badges = [ {"name": c.name, "color": category_to_color(c.name)} @@ -1925,7 +2024,7 @@ def shared_list(token=None, list_id=None): "list_share.html", list=shopping_list, items=items, - receipt_files=receipt_files, + receipts=receipts, expenses=expenses, total_expense=total_expense, is_share=True, @@ -1966,118 +2065,121 @@ def suggest_products(): @app.route("/all_products") def all_products(): - query = request.args.get("q", "") + sort = request.args.get("sort", "popularity") + limit = request.args.get("limit", type=int) or 100 + offset = request.args.get("offset", type=int) or 0 - top_products_query = SuggestedProduct.query - if query: - top_products_query = top_products_query.filter( - SuggestedProduct.name.ilike(f"%{query}%") - ) + 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))) - top_products = ( + products_from_suggested = ( db.session.query( - func.lower(Item.name).label("name"), func.sum(Item.quantity).label("count") + func.lower(func.trim(SuggestedProduct.name)).label("normalized_name"), + func.min(SuggestedProduct.name).label("display_name"), + db.literal(1).label("count"), ) - .join(ShoppingList, ShoppingList.id == Item.list_id) - .filter(Item.purchased.is_(True)) - .group_by(func.lower(Item.name)) - .order_by(func.sum(Item.quantity).desc()) - .limit(5) - .all() + .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))) ) - top_names = [s.name for s in top_products] - rest_query = SuggestedProduct.query - if query: - rest_query = rest_query.filter(SuggestedProduct.name.ilike(f"%{query}%")) + union_q = products_from_items.union_all(products_from_suggested).subquery() - if top_names: - rest_query = rest_query.filter(~SuggestedProduct.name.in_(top_names)) + 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) - rest_products = rest_query.order_by(SuggestedProduct.name.asc()).limit(200).all() + 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() + ) - all_names = top_names + [s.name for s in rest_products] + total_count = ( + db.session.query(func.count()).select_from(final_q.subquery()).scalar() + ) + products = final_q.offset(offset).limit(limit).all() - seen = set() - unique_names = [] - for name in all_names: - name_lower = name.strip().lower() - if name_lower not in seen: - unique_names.append(name) - seen.add(name_lower) + out = [{"name": row.display_name, "count": row.count} for row in products] - return {"allproducts": unique_names} + return jsonify({"products": out, "total_count": total_count}) @app.route("/upload_receipt/", methods=["POST"]) @login_required def upload_receipt(list_id): - l = db.session.get(ShoppingList, list_id) - if "receipt" not in request.files: - return receipt_error("Brak pliku") - - file = request.files["receipt"] - if file.filename == "": + file = request.files.get("receipt") + if not file or file.filename == "": return receipt_error("Nie wybrano pliku") - if file and allowed_file(file.filename): - file_bytes = file.read() - file.seek(0) - file_hash = hashlib.sha256(file_bytes).hexdigest() + if not allowed_file(file.filename): + return receipt_error("Niedozwolony format pliku") - existing = Receipt.query.filter_by(file_hash=file_hash).first() - if existing: - return receipt_error("Taki plik już istnieje") + file_bytes = file.read() + file.seek(0) + file_hash = hashlib.sha256(file_bytes).hexdigest() - 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) + existing = Receipt.query.filter_by(file_hash=file_hash).first() + if existing: + return receipt_error("Taki plik już istnieje") - 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)) + 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) - filesize = os.path.getsize(file_path) - uploaded_at = datetime.now(timezone.utc) + 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=filesize, - uploaded_at=uploaded_at, + 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) - socketio.emit("receipt_added", {"url": url}, to=str(list_id)) - return jsonify({"success": True, "url": url}) + 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")) - - return receipt_error("Niedozwolony format pliku") + flash("Wgrano paragon", "success") + return redirect(request.referrer or url_for("main_page")) @app.route("/uploads/") def uploaded_file(filename): response = send_from_directory(app.config["UPLOAD_FOLDER"], filename) response.headers["Cache-Control"] = app.config["UPLOADS_CACHE_CONTROL"] - response.headers.pop("Pragma", None) response.headers.pop("Content-Disposition", None) mime, _ = mimetypes.guess_type(filename) if mime: @@ -2187,8 +2289,8 @@ def analyze_receipts_for_list(list_id): } ) - if not already_added: - total += value + # if not already_added: + total += value return jsonify({"results": results, "total": round(total, 2)}) @@ -2235,7 +2337,6 @@ def admin_panel(): now = datetime.now(timezone.utc) start = end = None - # Liczniki globalne user_count = User.query.count() list_count = ShoppingList.query.count() item_count = Item.query.count() @@ -2254,17 +2355,12 @@ def admin_panel(): ) all_lists = base_query.all() - - # tylko listy z danych miesięcy - month_options = get_active_months_query() - all_ids = [l.id for l in all_lists] stats_map = {} latest_expenses_map = {} if all_ids: - # Statystyki produktów stats = ( db.session.query( Item.list_id, @@ -2320,17 +2416,70 @@ def admin_panel(): } ) + purchased_items_count = Item.query.filter_by(purchased=True).count() + not_purchased_count = Item.query.filter_by(not_purchased=True).count() + items_with_notes = Item.query.filter(Item.note.isnot(None), Item.note != "").count() + + total_expense = db.session.query(func.sum(Expense.amount)).scalar() or 0 + avg_list_expense = round(total_expense / list_count, 2) if list_count else 0 + + if db.engine.name == "sqlite": + timestamp_diff = func.strftime("%s", Item.purchased_at) - func.strftime( + "%s", Item.added_at + ) + elif db.engine.name in ("postgresql", "postgres"): + timestamp_diff = func.extract("epoch", Item.purchased_at) - func.extract( + "epoch", Item.added_at + ) + elif db.engine.name in ("mysql", "mariadb"): + timestamp_diff = func.timestampdiff( + text("SECOND"), Item.added_at, Item.purchased_at + ) + else: + timestamp_diff = None + + time_to_purchase = ( + db.session.query(func.avg(timestamp_diff)) + .filter( + Item.purchased == True, + Item.purchased_at.isnot(None), + Item.added_at.isnot(None), + ) + .scalar() + if timestamp_diff is not None + else None + ) + + avg_hours_to_purchase = round(time_to_purchase / 3600, 2) if time_to_purchase else 0 + + first_list = db.session.query(func.min(ShoppingList.created_at)).scalar() + last_list = db.session.query(func.max(ShoppingList.created_at)).scalar() + now_dt = datetime.now(timezone.utc) + + if first_list and first_list.tzinfo is None: + first_list = first_list.replace(tzinfo=timezone.utc) + + if last_list and last_list.tzinfo is None: + last_list = last_list.replace(tzinfo=timezone.utc) + + if first_list and last_list: + days_span = max((now_dt - first_list).days, 1) + avg_per_day = list_count / days_span + avg_per_week = round(avg_per_day * 7, 2) + avg_per_month = round(avg_per_day * 30.44, 2) + avg_per_year = round(avg_per_day * 365, 2) + else: + avg_per_week = avg_per_month = avg_per_year = 0 + top_products = ( db.session.query(Item.name, func.count(Item.id).label("count")) .filter(Item.purchased.is_(True)) .group_by(Item.name) .order_by(func.count(Item.id).desc()) - .limit(5) + .limit(7) .all() ) - purchased_items_count = Item.query.filter_by(purchased=True).count() - expense_summary = get_admin_expense_summary() process = psutil.Process(os.getpid()) app_mem = process.memory_info().rss // (1024 * 1024) @@ -2349,12 +2498,21 @@ def admin_panel(): (datetime.now(timezone.utc) - app_start_time).total_seconds() // 60 ) + month_options = get_active_months_query() + return render_template( "admin/admin_panel.html", user_count=user_count, list_count=list_count, item_count=item_count, purchased_items_count=purchased_items_count, + not_purchased_count=not_purchased_count, + items_with_notes=items_with_notes, + avg_hours_to_purchase=avg_hours_to_purchase, + avg_list_expense=avg_list_expense, + avg_per_week=avg_per_week, + avg_per_month=avg_per_month, + avg_per_year=avg_per_year, enriched_lists=enriched_lists, top_products=top_products, expense_summary=expense_summary, @@ -2373,21 +2531,6 @@ def admin_panel(): ) -@app.route("/admin/delete_list/") -@login_required -@admin_required -def delete_list(list_id): - - delete_receipts_for_list(list_id) - list_to_delete = ShoppingList.query.get_or_404(list_id) - Item.query.filter_by(list_id=list_to_delete.id).delete() - Expense.query.filter_by(list_id=list_to_delete.id).delete() - db.session.delete(list_to_delete) - db.session.commit() - flash(f"Usunięto listę: {list_to_delete.title}", "success") - return redirect(url_for("admin_panel")) - - @app.route("/admin/add_user", methods=["POST"]) @login_required @admin_required @@ -2399,6 +2542,10 @@ def add_user(): flash("Wypełnij wszystkie pola", "danger") return redirect(url_for("list_users")) + if len(password) < 6: + flash("Hasło musi mieć co najmniej 6 znaków", "danger") + return redirect(url_for("list_users")) + if User.query.filter(func.lower(User.username) == username).first(): flash("Użytkownik o takiej nazwie już istnieje", "warning") return redirect(url_for("list_users")) @@ -2415,18 +2562,29 @@ def add_user(): @login_required @admin_required def list_users(): - users = User.query.all() - user_count = User.query.count() - list_count = ShoppingList.query.count() - item_count = Item.query.count() - activity_log = ["Utworzono listę: Zakupy weekendowe", "Dodano produkt: Mleko"] + users = User.query.order_by(User.id.asc()).all() + + user_data = [] + for user in users: + list_count = ShoppingList.query.filter_by(owner_id=user.id).count() + item_count = Item.query.filter_by(added_by=user.id).count() + receipt_count = Receipt.query.filter_by(uploaded_by=user.id).count() + + user_data.append( + { + "user": user, + "list_count": list_count, + "item_count": item_count, + "receipt_count": receipt_count, + } + ) + + total_users = len(users) + return render_template( "admin/user_management.html", - users=users, - user_count=user_count, - list_count=list_count, - item_count=item_count, - activity_log=activity_log, + user_data=user_data, + total_users=total_users, ) @@ -2454,14 +2612,38 @@ def delete_user(user_id): user = User.query.get_or_404(user_id) if user.is_admin: - admin_count = User.query.filter_by(is_admin=True).count() - if admin_count <= 1: - flash("Nie można usunąć ostatniego administratora.", "danger") - return redirect(url_for("list_users")) + flash("Nie można usunąć konta administratora.", "warning") + return redirect(url_for("list_users")) + + admin_user = User.query.filter_by(is_admin=True).first() + if not admin_user: + flash("Brak konta administratora do przeniesienia zawartości.", "danger") + return redirect(url_for("list_users")) + + lists_owned = ShoppingList.query.filter_by(owner_id=user.id).count() + + if lists_owned > 0: + ShoppingList.query.filter_by(owner_id=user.id).update( + {"owner_id": admin_user.id} + ) + Receipt.query.filter_by(uploaded_by=user.id).update( + {"uploaded_by": admin_user.id} + ) + Item.query.filter_by(added_by=user.id).update({"added_by": admin_user.id}) + db.session.commit() + flash( + f"Użytkownik '{user.username}' został usunięty, a jego zawartość przeniesiona na administratora.", + "success", + ) + else: + flash( + f"Użytkownik '{user.username}' został usunięty. Nie posiadał żadnych list zakupowych.", + "info", + ) db.session.delete(user) db.session.commit() - flash("Użytkownik usunięty", "success") + return redirect(url_for("list_users")) @@ -2470,15 +2652,17 @@ def delete_user(user_id): @admin_required def admin_receipts(id): try: - page = request.args.get("page", 1, type=int) - per_page = request.args.get("per_page", 24, type=int) - per_page = max(1, min(per_page, 200)) # sanity check + page, per_page = get_page_args(default_per_page=24, max_per_page=200) if id == "all": - all_filenames = {r.filename for r in Receipt.query.all()} + all_filenames = { + r.filename for r in Receipt.query.with_entities(Receipt.filename).all() + } - pagination = Receipt.query.order_by(Receipt.uploaded_at.desc()).paginate( - page=page, per_page=per_page, error_out=False + pagination = ( + Receipt.query.options(joinedload(Receipt.uploaded_by_user)) + .order_by(Receipt.uploaded_at.desc()) + .paginate(page=page, per_page=per_page, error_out=False) ) receipts_paginated = pagination.items @@ -2495,19 +2679,25 @@ def admin_receipts(id): ] else: list_id = int(id) - receipts_paginated = ( - Receipt.query.filter_by(list_id=list_id) + all_receipts = ( + Receipt.query.options(joinedload(Receipt.uploaded_by_user)) + .filter_by(list_id=list_id) .order_by(Receipt.uploaded_at.desc()) .all() ) + receipts_paginated, total_items, total_pages = paginate_items( + all_receipts, page, per_page + ) orphan_files = [] - page = 1 - total_pages = 1 - per_page = len(receipts_paginated) or 1 + except ValueError: flash("Nieprawidłowe ID listy.", "danger") return redirect(url_for("admin_panel")) + total_filesize = db.session.query(func.sum(Receipt.filesize)).scalar() or 0 + + page_filesize = sum(r.filesize or 0 for r in receipts_paginated) + query_string = urlencode({k: v for k, v in request.args.items() if k != "page"}) return render_template( @@ -2520,6 +2710,8 @@ def admin_receipts(id): total_pages=total_pages, id=id, query_string=query_string, + total_filesize=total_filesize, + page_filesize=page_filesize, ) @@ -2622,30 +2814,34 @@ def generate_receipt_hash(receipt_id): return redirect(request.referrer) -@app.route("/admin/delete_selected_lists", methods=["POST"]) +@app.route("/admin/delete_list", methods=["POST"]) @login_required @admin_required -def delete_selected_lists(): +def admin_delete_list(): ids = request.form.getlist("list_ids") + single_id = request.form.get("single_list_id") + if single_id: + ids.append(single_id) + for list_id in ids: - lst = db.session.get(ShoppingList, int(list_id)) - if lst: delete_receipts_for_list(lst.id) + Receipt.query.filter_by(list_id=lst.id).delete() Item.query.filter_by(list_id=lst.id).delete() Expense.query.filter_by(list_id=lst.id).delete() db.session.delete(lst) + db.session.commit() - flash("Usunięto wybrane listy", "success") - return redirect(url_for("admin_panel")) + flash(f"Usunięto {len(ids)} list(y)", "success") + return redirect(request.referrer or url_for("admin_panel")) @app.route("/admin/edit_list/", methods=["GET", "POST"]) @login_required @admin_required def edit_list(list_id): - l = db.session.get( + shopping_list = db.session.get( ShoppingList, list_id, options=[ @@ -2657,13 +2853,12 @@ def edit_list(list_id): ], ) - if l is None: + if shopping_list is None: abort(404) - total_expense = get_total_expense_for_list(l.id) - + total_expense = get_total_expense_for_list(shopping_list.id) categories = Category.query.order_by(Category.name.asc()).all() - selected_categories_ids = {c.id for c in l.categories} + selected_categories_ids = {c.id for c in shopping_list.categories} if request.method == "POST": action = request.form.get("action") @@ -2675,34 +2870,39 @@ def edit_list(list_id): is_public = "public" in request.form is_temporary = "temporary" in request.form new_owner_id = request.form.get("owner_id") - expires_date = request.form.get("expires_date") expires_time = request.form.get("expires_time") if new_title: - l.title = new_title + shopping_list.title = new_title - l.is_archived = is_archived - l.is_public = is_public - l.is_temporary = is_temporary + shopping_list.is_archived = is_archived + shopping_list.is_public = is_public + shopping_list.is_temporary = is_temporary if expires_date and expires_time: try: - combined_str = f"{expires_date} {expires_time}" - dt = datetime.strptime(combined_str, "%Y-%m-%d %H:%M") - l.expires_at = dt.replace(tzinfo=timezone.utc) + combined = f"{expires_date} {expires_time}" + dt = datetime.strptime(combined, "%Y-%m-%d %H:%M") + shopping_list.expires_at = dt.replace(tzinfo=timezone.utc) except ValueError: flash("Niepoprawna data lub godzina wygasania", "danger") return redirect(url_for("edit_list", list_id=list_id)) else: - l.expires_at = None + shopping_list.expires_at = None if new_owner_id: try: new_owner_id_int = int(new_owner_id) user_obj = db.session.get(User, new_owner_id_int) if user_obj: - l.owner_id = new_owner_id_int + shopping_list.owner_id = new_owner_id_int + Item.query.filter_by(list_id=list_id).update( + {"added_by": new_owner_id_int} + ) + Receipt.query.filter_by(list_id=list_id).update( + {"uploaded_by": new_owner_id_int} + ) else: flash("Wybrany użytkownik nie istnieje", "danger") return redirect(url_for("edit_list", list_id=list_id)) @@ -2713,7 +2913,7 @@ def edit_list(list_id): if new_amount_str: try: new_amount = float(new_amount_str) - for expense in l.expenses: + for expense in shopping_list.expenses: db.session.delete(expense) db.session.commit() db.session.add(Expense(list_id=list_id, amount=new_amount)) @@ -2725,17 +2925,14 @@ def edit_list(list_id): if created_month: try: year, month = map(int, created_month.split("-")) - l.created_at = datetime(year, month, 1, tzinfo=timezone.utc) - except ValueError: - flash( - "Nieprawidłowy format miesiąca (przeniesienie daty utworzenia)", - "danger", + shopping_list.created_at = datetime( + year, month, 1, tzinfo=timezone.utc ) + except ValueError: + flash("Nieprawidłowy format miesiąca", "danger") return redirect(url_for("edit_list", list_id=list_id)) - update_list_categories_from_form(l, request.form) - - db.session.add(l) + update_list_categories_from_form(shopping_list, request.form) db.session.commit() flash("Zapisano zmiany listy", "success") return redirect(url_for("edit_list", list_id=list_id)) @@ -2767,7 +2964,6 @@ def edit_list(list_id): .filter(func.lower(SuggestedProduct.name) == item_name.lower()) .first() ) - if not exists: db.session.add(SuggestedProduct(name=item_name)) @@ -2776,72 +2972,57 @@ def edit_list(list_id): return redirect(url_for("edit_list", list_id=list_id)) elif action == "delete_item": - item = db.session.get(Item, request.form.get("item_id")) - if item and item.list_id == list_id: - db.session.delete(item) - db.session.commit() - flash("Usunięto produkt", "success") - else: - flash("Nie znaleziono produktu", "danger") + item = get_valid_item_or_404(request.form.get("item_id"), list_id) + db.session.delete(item) + db.session.commit() + flash("Usunięto produkt", "success") return redirect(url_for("edit_list", list_id=list_id)) elif action == "toggle_purchased": - item = db.session.get(Item, request.form.get("item_id")) - if item and item.list_id == list_id: - item.purchased = not item.purchased - db.session.commit() - flash("Zmieniono status oznaczenia produktu", "success") - else: - flash("Nie znaleziono produktu", "danger") + item = get_valid_item_or_404(request.form.get("item_id"), list_id) + item.purchased = not item.purchased + db.session.commit() + flash("Zmieniono status oznaczenia produktu", "success") return redirect(url_for("edit_list", list_id=list_id)) elif action == "mark_not_purchased": - item = db.session.get(Item, request.form.get("item_id")) - if item and item.list_id == list_id: - item.not_purchased = True - item.purchased = False - item.purchased_at = None - db.session.commit() - flash("Oznaczono produkt jako niekupione", "success") - else: - flash("Nie znaleziono produktu", "danger") + item = get_valid_item_or_404(request.form.get("item_id"), list_id) + item.not_purchased = True + item.purchased = False + item.purchased_at = None + db.session.commit() + flash("Oznaczono produkt jako niekupione", "success") return redirect(url_for("edit_list", list_id=list_id)) elif action == "unmark_not_purchased": - item = db.session.get(Item, request.form.get("item_id")) - if item and item.list_id == list_id: - item.not_purchased = False - item.not_purchased_reason = None - item.purchased = False - item.purchased_at = None - db.session.commit() - flash("Przywrócono produkt do listy", "success") - else: - flash("Nie znaleziono produktu", "danger") + item = get_valid_item_or_404(request.form.get("item_id"), list_id) + item.not_purchased = False + item.not_purchased_reason = None + item.purchased = False + item.purchased_at = None + db.session.commit() + flash("Przywrócono produkt do listy", "success") return redirect(url_for("edit_list", list_id=list_id)) elif action == "edit_quantity": - item = db.session.get(Item, request.form.get("item_id")) - if item and item.list_id == list_id: - try: - new_quantity = int(request.form.get("quantity")) - if new_quantity > 0: - item.quantity = new_quantity - db.session.commit() - flash("Zmieniono ilość produktu", "success") - except ValueError: - flash("Nieprawidłowa ilość", "danger") - else: - flash("Nie znaleziono produktu", "danger") + item = get_valid_item_or_404(request.form.get("item_id"), list_id) + try: + new_quantity = int(request.form.get("quantity")) + if new_quantity > 0: + item.quantity = new_quantity + db.session.commit() + flash("Zmieniono ilość produktu", "success") + except ValueError: + flash("Nieprawidłowa ilość", "danger") return redirect(url_for("edit_list", list_id=list_id)) users = User.query.all() - items = l.items - receipts = l.receipts + items = shopping_list.items + receipts = shopping_list.receipts return render_template( "admin/edit_list.html", - list=l, + list=shopping_list, total_expense=total_expense, users=users, items=items, @@ -2855,14 +3036,10 @@ def edit_list(list_id): @login_required @admin_required def list_products(): - page = request.args.get("page", 1, type=int) - per_page = request.args.get("per_page", 100, type=int) - per_page = max(1, min(per_page, 300)) + page, per_page = get_page_args() all_items = ( - Item.query.options( - joinedload(Item.added_by_user), - ) + Item.query.options(joinedload(Item.added_by_user)) .order_by(Item.id.desc()) .all() ) @@ -2875,32 +3052,27 @@ def list_products(): unique_items.append(item) seen_names.add(key) - usage_counts = dict( + usage_results = ( db.session.query( - func.lower(Item.name), - func.coalesce(func.sum(Item.quantity), 0) + func.lower(Item.name).label("name"), + func.count(func.distinct(Item.list_id)).label("usage_count"), ) .group_by(func.lower(Item.name)) .all() ) + usage_counts = {row.name: row.usage_count for row in usage_results} - total_items = len(unique_items) - total_pages = (total_items + per_page - 1) // per_page - start = (page - 1) * per_page - end = start + per_page - items = unique_items[start:end] + items, total_items, total_pages = paginate_items(unique_items, page, per_page) user_ids = {item.added_by for item in items if item.added_by} users = User.query.filter(User.id.in_(user_ids)).all() if user_ids else [] users_dict = {u.id: u.username for u in users} suggestions = SuggestedProduct.query.all() - all_suggestions_dict = { - normalize_name(s.name): s - for s in suggestions - if s.name and s.name.strip() + normalize_name(s.name): s for s in suggestions if s.name and s.name.strip() } + used_suggestion_names = {normalize_name(i.name) for i in unique_items} suggestions_dict = { @@ -2908,12 +3080,13 @@ def list_products(): for name in used_suggestion_names if name in all_suggestions_dict } + orphan_suggestions = [ - s for name, s in all_suggestions_dict.items() + s + for name, s in all_suggestions_dict.items() if name not in used_suggestion_names ] - query_string = urlencode({k: v for k, v in request.args.items() if k != "page"}) synced_names = set(suggestions_dict.keys()) @@ -2929,11 +3102,10 @@ def list_products(): query_string=query_string, total_items=total_items, usage_counts=usage_counts, - synced_names=synced_names + synced_names=synced_names, ) - @app.route("/admin/sync_suggestion/", methods=["POST"]) @login_required def sync_suggestion_ajax(item_id): @@ -3038,9 +3210,7 @@ def recalculate_filesizes_all(): @login_required @admin_required def admin_mass_edit_categories(): - page = request.args.get("page", 1, type=int) - per_page = request.args.get("per_page", 50, type=int) - per_page = max(1, min(per_page, 200)) # ogranicz do sensownych wartości + page, per_page = get_page_args(default_per_page=50, max_per_page=200) lists_query = ShoppingList.query.options( joinedload(ShoppingList.categories), @@ -3117,6 +3287,28 @@ def admin_list_items_json(list_id): ) +@app.route("/admin/add_suggestion", methods=["POST"]) +@login_required +@admin_required +def add_suggestion(): + name = request.form.get("suggestion_name", "").strip() + + if not name: + flash("Nazwa nie może być pusta", "warning") + return redirect(url_for("list_products")) + + existing = db.session.query(SuggestedProduct).filter_by(name=name).first() + if existing: + flash("Sugestia już istnieje", "warning") + else: + new_suggestion = SuggestedProduct(name=name) + db.session.add(new_suggestion) + db.session.commit() + flash("Dodano sugestię", "success") + + return redirect(url_for("list_products")) + + @app.route("/healthcheck") def healthcheck(): header_token = request.headers.get("X-Internal-Check") diff --git a/static/js/expense_chart.js b/static/js/expense_chart.js index 77f830d..7dc3599 100644 --- a/static/js/expense_chart.js +++ b/static/js/expense_chart.js @@ -7,12 +7,16 @@ document.addEventListener("DOMContentLoaded", function () { window.selectedCategoryId = ""; } - function loadExpenses(range = "last30days", startDate = null, endDate = null) { + function loadExpenses(range = "currentmonth", startDate = null, endDate = null) { let url = '/expenses_data?range=' + range; + const showAllCheckbox = document.getElementById("showAllLists"); - if (showAllCheckbox && showAllCheckbox.checked) { + if (showAllCheckbox) { + url += showAllCheckbox.checked ? '&show_all=true' : '&show_all=false'; + } else { url += '&show_all=true'; } + if (startDate && endDate) { url += `&start_date=${startDate}&end_date=${endDate}`; } @@ -165,6 +169,6 @@ document.addEventListener("DOMContentLoaded", function () { // Jeśli jesteśmy od razu na zakładce Wykres if (document.getElementById('chart-tab').classList.contains('active')) { - loadExpenses("last30days"); + loadExpenses("currentmonth"); } }); diff --git a/static/js/expense_table.js b/static/js/expense_table.js index c8971cc..47147d1 100644 --- a/static/js/expense_table.js +++ b/static/js/expense_table.js @@ -4,10 +4,21 @@ document.addEventListener('DOMContentLoaded', () => { const filterButtons = document.querySelectorAll('.range-btn'); const rows = document.querySelectorAll('#listsTableBody tr'); const categoryButtons = document.querySelectorAll('.category-filter'); - const onlyWith = document.getElementById('onlyWithExpenses'); + const applyCustomBtn = document.getElementById('applyCustomRange'); + const customStartInput = document.getElementById('customStart'); + const customEndInput = document.getElementById('customEnd'); + + if (customStartInput && customEndInput) { + const now = new Date(); + const y = now.getFullYear(); + const m = String(now.getMonth() + 1).padStart(2, '0'); + const d = String(now.getDate()).padStart(2, '0'); + customStartInput.value = `${y}-${m}-01`; + customEndInput.value = `${y}-${m}-${d}`; + } window.selectedCategoryId = ""; - let initialLoad = true; // flaga - true tylko przy pierwszym wejściu + let initialLoad = true; function updateTotal() { let total = 0; @@ -35,10 +46,8 @@ document.addEventListener('DOMContentLoaded', () => { const year = now.getFullYear(); const month = now.toISOString().slice(0, 7); const week = `${year}-${String(getISOWeek(now)).padStart(2, '0')}`; - let startDate = null; let endDate = null; - if (range === 'last30days') { endDate = now; startDate = new Date(); @@ -48,14 +57,12 @@ document.addEventListener('DOMContentLoaded', () => { startDate = new Date(year, now.getMonth(), 1); endDate = now; } - rows.forEach(row => { const rDate = row.dataset.date; const rMonth = row.dataset.month; const rWeek = row.dataset.week; const rYear = row.dataset.year; const rowDateObj = new Date(rDate); - let show = true; if (range === 'day') show = rDate === todayStr; else if (range === 'month') show = rMonth === month; @@ -64,7 +71,6 @@ document.addEventListener('DOMContentLoaded', () => { else if (range === 'all') show = true; else if (range === 'last30days') show = rowDateObj >= startDate && rowDateObj <= endDate; else if (range === 'currentmonth') show = rowDateObj >= startDate && rowDateObj <= endDate; - row.style.display = show ? '' : 'none'; }); } @@ -74,7 +80,6 @@ document.addEventListener('DOMContentLoaded', () => { } function applyExpenseFilter() { - if (!onlyWith || !onlyWith.checked) return; rows.forEach(row => { const amt = parseFloat(row.querySelector('.list-checkbox').dataset.amount || 0); if (amt <= 0) row.style.display = 'none'; @@ -83,36 +88,36 @@ document.addEventListener('DOMContentLoaded', () => { function applyCategoryFilter() { if (!window.selectedCategoryId) return; - rows.forEach(row => { const categoriesStr = row.dataset.categories || ""; const categories = categoriesStr ? categoriesStr.split(",") : []; - if (window.selectedCategoryId === "none") { - // Bez kategorii - if (categoriesStr.trim() !== "") { - row.style.display = 'none'; - } + if (categoriesStr.trim() !== "") row.style.display = 'none'; } else { - // Normalne filtrowanie po ID kategorii - if (!categories.includes(String(window.selectedCategoryId))) { - row.style.display = 'none'; - } + if (!categories.includes(String(window.selectedCategoryId))) row.style.display = 'none'; } }); } - // Obsługa checkboxów wierszy + function filterByCustomRange(startStr, endStr) { + const start = new Date(startStr); + const end = new Date(endStr); + if (isNaN(start) || isNaN(end)) return; + end.setHours(23, 59, 59, 999); + rows.forEach(row => { + const rowDateObj = new Date(row.dataset.date); + const show = rowDateObj >= start && rowDateObj <= end; + row.style.display = show ? '' : 'none'; + }); + } + checkboxes.forEach(cb => cb.addEventListener('change', updateTotal)); - // Obsługa przycisków zakresu filterButtons.forEach(btn => { btn.addEventListener('click', () => { - initialLoad = false; // po kliknięciu wyłączamy tryb startowy - + initialLoad = false; filterButtons.forEach(b => b.classList.remove('active')); btn.classList.add('active'); - const range = btn.dataset.range; filterByRange(range); applyExpenseFilter(); @@ -121,46 +126,22 @@ document.addEventListener('DOMContentLoaded', () => { }); }); - // Checkbox "tylko z wydatkami" - if (onlyWith) { - onlyWith.addEventListener('change', () => { - if (initialLoad) { - filterByLast30Days(); - } else { - const activeRange = document.querySelector('.range-btn.active'); - if (activeRange) { - filterByRange(activeRange.dataset.range); - } - } - applyExpenseFilter(); - applyCategoryFilter(); - updateTotal(); - }); - } - - // Obsługa kliknięcia w kategorię categoryButtons.forEach(btn => { btn.addEventListener('click', () => { categoryButtons.forEach(b => b.classList.remove('btn-success', 'active')); categoryButtons.forEach(b => b.classList.add('btn-outline-light')); btn.classList.remove('btn-outline-light'); btn.classList.add('btn-success', 'active'); - window.selectedCategoryId = btn.dataset.categoryId || ""; - if (initialLoad) { filterByLast30Days(); } else { const activeRange = document.querySelector('.range-btn.active'); - if (activeRange) { - filterByRange(activeRange.dataset.range); - } + if (activeRange) filterByRange(activeRange.dataset.range); } - applyExpenseFilter(); applyCategoryFilter(); updateTotal(); - const chartTab = document.querySelector('#chart-tab'); if (chartTab && chartTab.classList.contains('active') && typeof window.loadExpenses === 'function') { window.loadExpenses(); @@ -168,7 +149,23 @@ document.addEventListener('DOMContentLoaded', () => { }); }); - // Start – domyślnie ostatnie 30 dni + if (applyCustomBtn) { + applyCustomBtn.addEventListener('click', () => { + const startStr = customStartInput?.value; + const endStr = customEndInput?.value; + if (!startStr || !endStr) { + alert('Proszę wybrać obie daty!'); + return; + } + initialLoad = false; + document.querySelectorAll('.range-btn').forEach(b => b.classList.remove('active')); + filterByCustomRange(startStr, endStr); + applyExpenseFilter(); + applyCategoryFilter(); + updateTotal(); + }); + } + filterByLast30Days(); applyExpenseFilter(); applyCategoryFilter(); diff --git a/static/js/functions.js b/static/js/functions.js index 43ab3c0..244499e 100644 --- a/static/js/functions.js +++ b/static/js/functions.js @@ -231,7 +231,7 @@ function toggleVisibility(listId) { copyBtn.disabled = false; toggleBtn.innerHTML = '🙈 Ukryj listę'; } else { - shareHeader.textContent = '🙈 Lista jest ukryta przed gośćmi'; + shareHeader.textContent = '🙈 Lista jest ukryta. Link udostępniania nie zadziała!'; shareUrlSpan.style.display = 'none'; copyBtn.disabled = true; toggleBtn.innerHTML = '👁️ Udostępnij ponownie'; diff --git a/static/js/mass_add.js b/static/js/mass_add.js index b573fb1..4ce906e 100644 --- a/static/js/mass_add.js +++ b/static/js/mass_add.js @@ -1,116 +1,225 @@ document.addEventListener('DOMContentLoaded', function () { const modal = document.getElementById('massAddModal'); const productList = document.getElementById('mass-add-list'); + const sortBar = document.getElementById('sort-bar'); + const productCountDisplay = document.getElementById('product-count'); + const modalBody = modal?.querySelector('.modal-body'); - // Funkcja normalizacji (usuwa diakrytyki i zamienia na lowercase) function normalize(str) { - return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase(); + return str?.trim().toLowerCase() || ''; } - modal.addEventListener('show.bs.modal', async function () { - let addedProducts = new Set(); - document.querySelectorAll('#items li').forEach(li => { - if (li.dataset.name) { - addedProducts.add(normalize(li.dataset.name)); + let sortMode = 'popularity'; + let limit = 25; + let offset = 0; + let loading = false; + let reachedEnd = false; + let allProducts = []; + let addedProducts = new Set(); + + function renderSortBar() { + if (!sortBar) return; + sortBar.innerHTML = ` + Sortuj: Popularność | + Alfabetycznie + `; + document.getElementById('sort-popularity').onclick = (e) => { + e.preventDefault(); + if (sortMode !== 'popularity') { + sortMode = 'popularity'; + resetAndFetchProducts(); } - }); + }; + document.getElementById('sort-alphabetical').onclick = (e) => { + e.preventDefault(); + if (sortMode !== 'alphabetical') { + sortMode = 'alphabetical'; + resetAndFetchProducts(); + } + }; + } + + function resetAndFetchProducts() { + offset = 0; + reachedEnd = false; + allProducts = []; + productList.innerHTML = ''; + fetchProducts(true); + renderSortBar(); + if (productCountDisplay) productCountDisplay.textContent = ''; + } + + async function fetchProducts(reset = false) { + if (loading || reachedEnd) return; + loading = true; + + if (!reset) { + const loadingLi = document.createElement('li'); + loadingLi.className = 'list-group-item bg-dark text-light loading'; + loadingLi.textContent = 'Ładowanie...'; + productList.appendChild(loadingLi); + } - productList.innerHTML = '
  • Ładowanie...
  • '; try { - const res = await fetch('/all_products'); + const res = await fetch(`/all_products?sort=${sortMode}&limit=${limit}&offset=${offset}`); const data = await res.json(); - const allproducts = data.allproducts; - productList.innerHTML = ''; - allproducts.forEach(name => { - const li = document.createElement('li'); - li.className = 'list-group-item d-flex justify-content-between align-items-center bg-dark text-light'; + const products = data.products || []; - if (addedProducts.has(normalize(name))) { - const nameSpan = document.createElement('span'); - nameSpan.textContent = name; - li.appendChild(nameSpan); + if (products.length < limit) reachedEnd = true; + allProducts = reset ? products : allProducts.concat(products); - li.classList.add('opacity-50'); - const badge = document.createElement('span'); - badge.className = 'badge bg-success ms-auto'; - badge.textContent = 'Dodano'; - li.appendChild(badge); - } else { - const nameSpan = document.createElement('span'); - nameSpan.textContent = name; - nameSpan.style.flex = '1 1 auto'; - li.appendChild(nameSpan); + const loadingEl = productList.querySelector('.loading'); + if (loadingEl) loadingEl.remove(); - const qtyWrapper = document.createElement('div'); - qtyWrapper.className = 'd-flex align-items-center ms-2 quantity-controls'; + if (reset && products.length === 0) { + const emptyLi = document.createElement('li'); + emptyLi.className = 'list-group-item text-muted bg-dark'; + emptyLi.textContent = 'Brak produktów do wyświetlenia.'; + productList.appendChild(emptyLi); + } else { + renderProducts(products); + } - const minusBtn = document.createElement('button'); - minusBtn.type = 'button'; - minusBtn.className = 'btn btn-outline-light btn-sm px-2'; - minusBtn.textContent = '−'; - minusBtn.onclick = () => { - qty.value = Math.max(1, parseInt(qty.value) - 1); - }; + offset += limit; - const qty = document.createElement('input'); - qty.type = 'number'; - qty.min = 1; - qty.value = 1; - qty.className = 'form-control text-center p-1 rounded'; - qty.style.width = '50px'; - qty.style.margin = '0 2px'; - qty.title = 'Ilość'; + if (productCountDisplay) { + productCountDisplay.textContent = `Wyświetlono ${allProducts.length} z ${data.total_count} pozycji`; + } - const plusBtn = document.createElement('button'); - plusBtn.type = 'button'; - plusBtn.className = 'btn btn-outline-light btn-sm px-2'; - plusBtn.textContent = '+'; - plusBtn.onclick = () => { - qty.value = parseInt(qty.value) + 1; - }; + const statsEl = document.getElementById('massAddProductStats'); + if (statsEl) { + statsEl.textContent = `(${allProducts.length} z ${data.total_count})`; + } - qtyWrapper.appendChild(minusBtn); - qtyWrapper.appendChild(qty); - qtyWrapper.appendChild(plusBtn); - - const btn = document.createElement('button'); - btn.className = 'btn btn-sm btn-primary ms-4'; - btn.textContent = '+'; - - btn.onclick = () => { - const quantity = parseInt(qty.value) || 1; - socket.emit('add_item', { list_id: LIST_ID, name: name, quantity: quantity }); - }; - - li.appendChild(qtyWrapper); - li.appendChild(btn); - } - productList.appendChild(li); - }); } catch (err) { - productList.innerHTML = '
  • Błąd ładowania danych
  • '; + const loadingEl = productList.querySelector('.loading'); + if (loadingEl) loadingEl.remove(); + const errorLi = document.createElement('li'); + errorLi.className = 'list-group-item text-danger bg-dark'; + errorLi.textContent = 'Błąd ładowania danych'; + productList.appendChild(errorLi); } + + loading = false; + } + + function getAlreadyAddedProducts() { + const set = new Set(); + document.querySelectorAll('#items li').forEach(li => { + if (li.dataset.name) { + set.add(normalize(li.dataset.name)); + } + }); + return set; + } + + function renderProducts(products) { + addedProducts = getAlreadyAddedProducts(); + + const existingNames = new Set(); + document.querySelectorAll('#mass-add-list li').forEach(li => { + const name = li.querySelector('span')?.textContent; + if (name) existingNames.add(normalize(name)); + }); + + products.forEach(product => { + const name = typeof product === "object" ? product.name : product; + const normName = normalize(name); + if (existingNames.has(normName)) return; + existingNames.add(normName); + + const li = document.createElement('li'); + li.className = 'list-group-item d-flex justify-content-between align-items-center bg-dark text-light'; + + if (addedProducts.has(normName)) { + const nameSpan = document.createElement('span'); + nameSpan.textContent = name; + li.appendChild(nameSpan); + li.classList.add('opacity-50'); + const badge = document.createElement('span'); + badge.className = 'badge bg-success ms-auto'; + badge.textContent = 'Dodano'; + li.appendChild(badge); + } else { + const nameSpan = document.createElement('span'); + nameSpan.textContent = name; + nameSpan.style.flex = '1 1 auto'; + li.appendChild(nameSpan); + + const qtyWrapper = document.createElement('div'); + qtyWrapper.className = 'd-flex align-items-center ms-2 quantity-controls'; + + const minusBtn = document.createElement('button'); + minusBtn.type = 'button'; + minusBtn.className = 'btn btn-outline-light btn-sm px-2'; + minusBtn.textContent = '−'; + + const qty = document.createElement('input'); + qty.type = 'number'; + qty.min = 1; + qty.value = 1; + qty.className = 'form-control text-center p-1 rounded'; + qty.style.width = '50px'; + qty.style.margin = '0 2px'; + qty.title = 'Ilość'; + + const plusBtn = document.createElement('button'); + plusBtn.type = 'button'; + plusBtn.className = 'btn btn-outline-light btn-sm px-2'; + plusBtn.textContent = '+'; + + minusBtn.onclick = () => { + qty.value = Math.max(1, parseInt(qty.value) - 1); + }; + plusBtn.onclick = () => { + qty.value = parseInt(qty.value) + 1; + }; + + qtyWrapper.append(minusBtn, qty, plusBtn); + + const btn = document.createElement('button'); + btn.className = 'btn btn-sm btn-primary ms-4'; + btn.textContent = '+'; + btn.onclick = () => { + const quantity = parseInt(qty.value) || 1; + socket.emit('add_item', { list_id: LIST_ID, name: name, quantity: quantity }); + }; + + li.append(qtyWrapper, btn); + } + + productList.appendChild(li); + }); + } + + if (modalBody) { + modalBody.addEventListener('scroll', function () { + if (!loading && !reachedEnd && (modalBody.scrollTop + modalBody.clientHeight > modalBody.scrollHeight - 80)) { + fetchProducts(false); + } + }); + } + + modal.addEventListener('show.bs.modal', function () { + resetAndFetchProducts(); }); + renderSortBar(); + socket.on('item_added', data => { document.querySelectorAll('#mass-add-list li').forEach(li => { const itemName = li.firstChild?.textContent.trim(); - if (normalize(itemName) === normalize(data.name) && !li.classList.contains('opacity-50')) { li.classList.add('opacity-50'); - - // Usuń poprzednie przyciski li.querySelectorAll('button').forEach(btn => btn.remove()); const quantityControls = li.querySelector('.quantity-controls'); if (quantityControls) quantityControls.remove(); - // Badge "Dodano" const badge = document.createElement('span'); badge.className = 'badge bg-success'; badge.textContent = 'Dodano'; - // Grupowanie przycisku + licznika const btnGroup = document.createElement('div'); btnGroup.className = 'btn-group btn-group-sm me-2'; btnGroup.role = 'group'; @@ -124,17 +233,13 @@ document.addEventListener('DOMContentLoaded', function () { let secondsLeft = 15; timerBtn.textContent = `${secondsLeft}s`; - btnGroup.appendChild(undoBtn); - btnGroup.appendChild(timerBtn); + btnGroup.append(undoBtn, timerBtn); - // Kontener na prawą stronę const rightWrapper = document.createElement('div'); rightWrapper.className = 'd-flex align-items-center gap-2 ms-auto'; - rightWrapper.appendChild(btnGroup); - rightWrapper.appendChild(badge); + rightWrapper.append(btnGroup, badge); li.appendChild(rightWrapper); - // Odliczanie const intervalId = setInterval(() => { secondsLeft--; if (secondsLeft > 0) { @@ -145,14 +250,12 @@ document.addEventListener('DOMContentLoaded', function () { } }, 1000); - // Obsługa cofnięcia undoBtn.onclick = () => { clearInterval(intervalId); btnGroup.remove(); badge.remove(); li.classList.remove('opacity-50'); - // Przywróć kontrolki ilości const qtyWrapper = document.createElement('div'); qtyWrapper.className = 'd-flex align-items-center ms-2 quantity-controls'; @@ -185,7 +288,6 @@ document.addEventListener('DOMContentLoaded', function () { qtyWrapper.append(minusBtn, qty, plusBtn); li.appendChild(qtyWrapper); - // Dodaj przycisk dodawania const addBtn = document.createElement('button'); addBtn.className = 'btn btn-sm btn-primary ms-4'; addBtn.textContent = '+'; @@ -199,13 +301,9 @@ document.addEventListener('DOMContentLoaded', function () { }; li.appendChild(addBtn); - // Usuń z listy socket.emit('delete_item', { item_id: data.id }); }; } }); }); - - }); - diff --git a/static/js/product_suggestion.js b/static/js/product_suggestion.js index 0c1af4c..4d3b0c8 100644 --- a/static/js/product_suggestion.js +++ b/static/js/product_suggestion.js @@ -66,7 +66,7 @@ function bindDeleteButton(button) { const syncBtn = cell.querySelector('.sync-btn'); if (syncBtn) bindSyncButton(syncBtn); } else { - cell.innerHTML = 'Usunięto synchronizacje'; + cell.innerHTML = 'Usunięto z bazy danych'; } }) .catch(() => { diff --git a/static/js/receipt_analysis.js b/static/js/receipt_analysis.js index ce24650..3717ebe 100644 --- a/static/js/receipt_analysis.js +++ b/static/js/receipt_analysis.js @@ -21,8 +21,8 @@ async function analyzeReceipts(listId) { const duration = ((performance.now() - start) / 1000).toFixed(2); let html = `
    `; - html += `

    📊 Łącznie wykryto: ${data.total.toFixed(2)} PLN

    `; html += `

    ⏱ Czas analizy OCR: ${duration} sek.

    `; + html += `

    📊 Łącznie wykryto: ${data.total.toFixed(2)} PLN

    `; data.results.forEach((r, i) => { const disabled = r.already_added ? "disabled" : ""; @@ -30,8 +30,8 @@ async function analyzeReceipts(listId) { const inputField = ``; const button = r.already_added - ? `✅ Dodano` - : ``; + ? `Dodano` + : ``; html += `
    @@ -43,7 +43,7 @@ async function analyzeReceipts(listId) { if (data.results.length > 1) { - html += ``; + html += ``; } html += `
    `; diff --git a/static/js/show_all_expense.js b/static/js/show_all_expense.js new file mode 100644 index 0000000..eba67b6 --- /dev/null +++ b/static/js/show_all_expense.js @@ -0,0 +1,17 @@ +document.addEventListener('DOMContentLoaded', function () { + const showAllCheckbox = document.getElementById('showAllLists'); + if (!showAllCheckbox) return; + + const params = new URLSearchParams(window.location.search); + if (!params.has('show_all')) { + params.set('show_all', 'true'); + window.history.replaceState({}, '', `${window.location.pathname}?${params.toString()}`); + } + showAllCheckbox.checked = params.get('show_all') === 'true'; + + showAllCheckbox.addEventListener('change', function () { + const urlParams = new URLSearchParams(window.location.search); + urlParams.set('show_all', showAllCheckbox.checked ? 'true' : 'false'); + window.location.search = urlParams.toString(); + }); +}); diff --git a/static/js/table_search.js b/static/js/table_search.js new file mode 100644 index 0000000..2958ba2 --- /dev/null +++ b/static/js/table_search.js @@ -0,0 +1,28 @@ +document.addEventListener("DOMContentLoaded", function () { + const searchInput = document.getElementById("search-table"); + const clearButton = document.getElementById("clear-search"); + const rows = document.querySelectorAll("table tbody tr"); + + if (!searchInput || !rows.length) return; + + function filterTable(query) { + const q = query.toLowerCase(); + + rows.forEach(row => { + const rowText = row.textContent.toLowerCase(); + row.style.display = rowText.includes(q) ? "" : "none"; + }); + } + + searchInput.addEventListener("input", function () { + filterTable(this.value); + }); + + if (clearButton) { + clearButton.addEventListener("click", function () { + searchInput.value = ""; + filterTable(""); // Pokaż wszystko + searchInput.focus(); + }); + } +}); \ No newline at end of file diff --git a/templates/admin/admin_panel.html b/templates/admin/admin_panel.html index 9b65e2c..30791e7 100644 --- a/templates/admin/admin_panel.html +++ b/templates/admin/admin_panel.html @@ -4,10 +4,10 @@ -
    +
    👥 Użytkownicy @@ -35,19 +35,43 @@ {{ list_count }} - 🛒 Produkty + 🛒 Produkty na listach {{ item_count }} ✅ Zakupione {{ purchased_items_count }} + + 🚫 Nieoznaczone jako kupione + {{ not_purchased_count }} + + + ✍️ Produkty z notatkami + {{ items_with_notes }} + + + 🕓 Śr. czas do zakupu (h) + {{ avg_hours_to_purchase }} + + + 💸 Średnia kwota na listę + {{ avg_list_expense }} zł + +
    +
    📈 Średnie tempo tworzenia list:
    +
      +
    • 📆 Tygodniowo: {{ avg_per_week }}
    • +
    • 🗓️ Miesięcznie: {{ avg_per_month }}
    • + +
    +
    @@ -59,7 +83,7 @@
    {{ name }} - {{ count }}× + {{ count }}x
    {% endfor %} {% else %} - Brak danych

    - {% endif %} +
    +

    Brak danych

    +
    + {% endif %}
    -
    +
    -
    💸 Podsumowanie wydatków:
    +
    💸 Podsumowanie wydatków
    - - +
    + - - - - + + + + + @@ -96,225 +123,238 @@ + + + +
    Typ listyMiesiącRokCałkowiteTyp listyMiesiącRokCałkowite
    {{ '%.2f'|format(expense_summary.all.month) }} PLN {{ '%.2f'|format(expense_summary.all.year) }} PLN {{ '%.2f'|format(expense_summary.all.total) }} PLN
    Aktywne {{ '%.2f'|format(expense_summary.active.month) }} PLN {{ '%.2f'|format(expense_summary.active.year) }} PLN {{ '%.2f'|format(expense_summary.active.total) }} PLN
    Archiwalne {{ '%.2f'|format(expense_summary.archived.month) }} PLN {{ '%.2f'|format(expense_summary.archived.year) }} PLN {{ '%.2f'|format(expense_summary.archived.total) }} PLN
    Wygasłe {{ '%.2f'|format(expense_summary.expired.month) }} PLN {{ '%.2f'|format(expense_summary.expired.year) }} PLN {{ '%.2f'|format(expense_summary.expired.total) }} PLN
    - + 📊 Pokaż wykres wydatków -
    -
    -{# panel wyboru miesiąca zawsze widoczny #} -
    +
    +
    - {# LEWA STRONA — przyciski ← → TYLKO gdy nie show_all #} -
    - {% if not show_all %} - {% set current_date = now.replace(day=1) %} - {% set prev_month = (current_date - timedelta(days=1)).strftime('%Y-%m') %} - {% set next_month = (current_date + timedelta(days=31)).replace(day=1).strftime('%Y-%m') %} + {# panel wyboru miesiąca zawsze widoczny #} +
    - {% if prev_month in month_options %} - - ← {{ prev_month }} - - {% else %} - - {% endif %} + {# LEWA STRONA — przyciski ← → TYLKO gdy nie show_all #} +
    + {% if not show_all %} + {% set current_date = now.replace(day=1) %} + {% set prev_month = (current_date - timedelta(days=1)).strftime('%Y-%m') %} + {% set next_month = (current_date + timedelta(days=31)).replace(day=1).strftime('%Y-%m') %} - {% if next_month in month_options %} - - {{ next_month }} → - - {% else %} - - {% endif %} - {% else %} - {# Tryb wszystkie miesiące — możemy pokazać skrót do bieżącego miesiąca #} - - 📅 Przejdź do bieżącego miesiąca - - {% endif %} -
    + {% if prev_month in month_options %} + + ← {{ prev_month }} + + {% else %} + + {% endif %} - {# PRAWA STRONA — picker miesięcy zawsze widoczny #} -
    -
    - 📅 - -
    -
    -
    + {% if next_month in month_options %} + + {{ next_month }} → + + {% else %} + + {% endif %} + {% else %} + {# Tryb wszystkie miesiące — możemy pokazać skrót do bieżącego miesiąca #} + + 📅 Przejdź do bieżącego miesiąca + + {% endif %} +
    -
    -
    -

    - 📄 Listy zakupowe - {% if show_all %} - — wszystkie miesiące - {% else %} - — {{ month_str|replace('-', ' / ') }} - {% endif %} -

    -
    -
    - - - - - - - - - - - - - - - - - - - {% for e in enriched_lists %} - {% set l = e.list %} - - - - + {# PRAWA STRONA — picker miesięcy zawsze widoczny #} + +
    + 📅 + +
    + + - - - - - + + + {% endfor %} + {% if enriched_lists|length == 0 %} + + + + {% endif %} + +
    IDTytułStatusUtworzonoWłaścicielProduktyProgressKoment.ParagonyWydatkiAkcje
    {{ l.id }} - {{ l.title }} - {% if l.categories %} - - 🏷 - - {% endif %} - - {% if l.is_archived %} - Archiwalna - {% elif e.expired %} - Wygasła - {% else %} - Aktywna - {% endif %} - {{ l.created_at.strftime('%Y-%m-%d %H:%M') if l.created_at else '-' }} - {% if l.owner %} - 👤 {{ l.owner.username }} ({{ l.owner.id }}) - {% else %} - - - {% endif %} - {{ e.total_count }} -
    -
    + 📄 Listy zakupowe + {% if show_all %} + — wszystkie miesiące + {% else %} + — {{ month_str|replace('-', ' / ') }} + {% endif %} + +
    +
    + + + + + + + + + + + + + + + + + + + {% for e in enriched_lists %} + {% set l = e.list %} + + + + + + + + + + - - - + + - - - {% endfor %} - -
    IDTytułStatusUtworzonoWłaścicielProduktyProgressKoment.ParagonyWydatkiAkcje
    {{ l.id }} + {{ l.title }} + {% if l.categories %} + + 🏷 + + {% endif %} + + {% if l.is_archived %} + Archiwalna + {% elif e.expired %} + Wygasła + {% else %} + Aktywna + {% endif %} + {{ l.created_at.strftime('%Y-%m-%d %H:%M') if l.created_at else '-' }} + {% if l.owner %} + 👤 {{ l.owner.username }} ({{ l.owner.id }}) + {% else %} + - + {% endif %} + {{ e.total_count }} +
    +
    - {{ e.purchased_count }}/{{ e.total_count }} + {{ e.purchased_count }}/{{ e.total_count }} +
    - -
    {{ e.comments_count }}{{ e.receipts_count }}{{ e.comments_count }}{{ e.receipts_count }} - {% if e.total_expense > 0 %} - {{ '%.2f'|format(e.total_expense) }} PLN - {% else %} - - - {% endif %} - -
    - ✏️ - - 🗑️ -
    -
    -
    -
    - -
    -
    + {% if e.total_expense > 0 %} + {{ '%.2f'|format(e.total_expense) }} PLN + {% else %} + - + {% endif %} +
    +
    + ✏️ + +
    + + +
    +
    +
    + Brak list zakupowych do wyświetlenia +
    +
    +
    + +
    + +
    -
    -
    - Python: {{ python_version.split()[0] }} | {{ system_info }} | RAM app: {{ app_memory }} | - DB: {{ db_info.engine|upper }}{% if db_info.version %} v{{ db_info.version[0] }}{% endif %} | - Tabele: {{ table_count }} | Rekordy: {{ record_total }} | - Uptime: {{ uptime_minutes }} min -
    +
    + Python: {{ python_version.split()[0] }} | {{ system_info }} | RAM app: {{ app_memory }} | + DB: {{ db_info.engine|upper }}{% if db_info.version %} v{{ db_info.version[0] }}{% endif %} | + Tabele: {{ table_count }} | Rekordy: {{ record_total }} | + Uptime: {{ uptime_minutes }} min +
    - -