From 35396afecbcdd5cd43b800a6a7d09b67da5fdd3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Tue, 29 Jul 2025 11:40:28 +0200 Subject: [PATCH] app.py - optymalizacje --- .gitignore | 1 + app.py | 357 +++++++++++++++++++++++++++++++++++++---------------- config.py | 2 +- 3 files changed, 250 insertions(+), 110 deletions(-) diff --git a/.gitignore b/.gitignore index 62bde8f..4ba6499 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ env *.db __pycache__ instance/ +database/ uploads/ .DS_Store db/* diff --git a/app.py b/app.py index 9a89b35..97b170c 100644 --- a/app.py +++ b/app.py @@ -27,9 +27,7 @@ from flask import ( abort, session, jsonify, - make_response, ) -from markupsafe import Markup from flask_sqlalchemy import SQLAlchemy from flask_login import ( LoginManager, @@ -46,7 +44,7 @@ from config import Config from PIL import Image, ExifTags, ImageFilter, ImageOps from werkzeug.utils import secure_filename from werkzeug.middleware.proxy_fix import ProxyFix -from sqlalchemy import func, extract, inspect, or_ +from sqlalchemy import func, extract, inspect, or_, case from sqlalchemy.orm import joinedload from collections import defaultdict, deque from functools import wraps @@ -54,12 +52,9 @@ from flask_talisman import Talisman # OCR import pytesseract -from collections import Counter from pytesseract import Output - import logging - app = Flask(__name__) app.config.from_object(Config) @@ -173,6 +168,11 @@ class ShoppingList(db.Model): is_archived = db.Column(db.Boolean, default=False) is_public = db.Column(db.Boolean, default=True) + # Relacje + items = db.relationship("Item", back_populates="shopping_list", lazy="select") + receipts = db.relationship("Receipt", back_populates="shopping_list", lazy="select") + expenses = db.relationship("Expense", back_populates="shopping_list", lazy="select") + class Item(db.Model): id = db.Column(db.Integer, primary_key=True) @@ -193,6 +193,8 @@ class Item(db.Model): 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) @@ -206,7 +208,8 @@ class Expense(db.Model): 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) - list = db.relationship("ShoppingList", backref="expenses", lazy=True) + + shopping_list = db.relationship("ShoppingList", back_populates="expenses") class Receipt(db.Model): @@ -214,10 +217,18 @@ class Receipt(db.Model): list_id = db.Column(db.Integer, db.ForeignKey("shopping_list.id"), nullable=False) filename = db.Column(db.String(255), nullable=False) uploaded_at = db.Column(db.DateTime, default=datetime.utcnow) - shopping_list = db.relationship("ShoppingList", backref="receipts", lazy=True) filesize = db.Column(db.Integer, nullable=True) file_hash = db.Column(db.String(64), nullable=True, unique=True) + shopping_list = db.relationship("ShoppingList", back_populates="receipts") + + +if app.config["SQLALCHEMY_DATABASE_URI"].startswith("sqlite:///"): + db_path = app.config["SQLALCHEMY_DATABASE_URI"].replace("sqlite:///", "", 1) + db_dir = os.path.dirname(db_path) + if db_dir and not os.path.exists(db_dir): + os.makedirs(db_dir, exist_ok=True) + print(f"Utworzono katalog bazy: {db_dir}") with app.app_context(): db.create_all() @@ -287,17 +298,33 @@ def allowed_file(filename): def get_list_details(list_id): - shopping_list = ShoppingList.query.get_or_404(list_id) - items = Item.query.filter_by(list_id=list_id).order_by(Item.position.asc()).all() - expenses = Expense.query.filter_by(list_id=list_id).all() - total_expense = sum(e.amount for e in expenses) + shopping_list = ShoppingList.query.options( + joinedload(ShoppingList.items).joinedload(Item.added_by_user), + joinedload(ShoppingList.expenses), + joinedload(ShoppingList.receipts), + ).get_or_404(list_id) - receipts = Receipt.query.filter_by(list_id=list_id).all() - receipt_files = [r.filename for r in receipts] + 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] return shopping_list, items, receipt_files, expenses, total_expense +def get_total_expense_for_list(list_id, start_date=None, end_date=None): + query = db.session.query(func.sum(Expense.amount)).filter( + Expense.list_id == list_id + ) + + if start_date and end_date: + query = query.filter( + Expense.added_at >= start_date, Expense.added_at < end_date + ) + + return query.scalar() or 0 + + def generate_share_token(length=8): return secrets.token_hex(length // 2) @@ -310,17 +337,26 @@ def check_list_public(shopping_list): def enrich_list_data(l): - items = Item.query.filter_by(list_id=l.id).all() - l.total_count = len(items) - l.purchased_count = len([i for i in items if i.purchased]) - expenses = Expense.query.filter_by(list_id=l.id).all() - l.total_expense = sum(e.amount for e in expenses) + counts = ( + db.session.query( + func.count(Item.id), + func.sum(case((Item.purchased == True, 1), else_=0)), + func.sum(Expense.amount), + ) + .outerjoin(Expense, Expense.list_id == Item.list_id) + .filter(Item.list_id == l.id) + .first() + ) + + l.total_count = counts[0] or 0 + l.purchased_count = counts[1] or 0 + l.total_expense = counts[2] or 0 + return l def save_resized_image(file, path): try: - # Otwórz i sprawdź poprawność pliku image = Image.open(file) image.verify() file.seek(0) @@ -364,9 +400,17 @@ def admin_required(f): def get_progress(list_id): - items = Item.query.filter_by(list_id=list_id).order_by(Item.position.asc()).all() - total_count = len(items) - purchased_count = len([i for i in items if i.purchased]) + total_count, purchased_count = ( + db.session.query( + 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 + percent = (purchased_count / total_count * 100) if total_count > 0 else 0 return purchased_count, total_count, percent @@ -452,7 +496,7 @@ def handle_crop_receipt(receipt_id, file): return {"success": False, "error": str(e)} -def get_expenses_aggregated_by_list_created_at( +def get_total_expenses_grouped_by_list_created_at( user_only=False, admin=False, show_all=False, @@ -461,14 +505,10 @@ def get_expenses_aggregated_by_list_created_at( end_date=None, user_id=None, ): - """ - Wspólna logika: sumujemy najnowszy wydatek z każdej listy, - ale do agregacji/filtra bierzemy ShoppingList.created_at! - """ lists_query = ShoppingList.query + # Uprawnienia if admin: - # admin widzi wszystko, ewentualnie: dodać filtrowanie wg potrzeb pass elif show_all: lists_query = lists_query.filter( @@ -480,7 +520,7 @@ def get_expenses_aggregated_by_list_created_at( else: lists_query = lists_query.filter(ShoppingList.owner_id == user_id) - # Filtrowanie po created_at listy + # Filtr daty utworzenia listy if start_date and end_date: try: dt_start = datetime.strptime(start_date, "%Y-%m-%d") @@ -490,34 +530,40 @@ def get_expenses_aggregated_by_list_created_at( lists_query = lists_query.filter( ShoppingList.created_at >= dt_start, ShoppingList.created_at < dt_end ) + lists = lists_query.all() + if not lists: + return {"labels": [], "expenses": []} - # Najnowszy wydatek każdej listy - data = [] - for sl in lists: - latest_exp = ( - Expense.query.filter_by(list_id=sl.id) - .order_by(Expense.added_at.desc()) - .first() + list_ids = [l.id for l in lists] + + # Suma wszystkich wydatków dla każdej listy + total_expenses = ( + db.session.query( + Expense.list_id, func.sum(Expense.amount).label("total_amount") ) - if latest_exp: - data.append({"created_at": sl.created_at, "amount": latest_exp.amount}) + .filter(Expense.list_id.in_(list_ids)) + .group_by(Expense.list_id) + .all() + ) + + expense_map = {lid: amt for lid, amt in total_expenses} - # Grupowanie po wybranym zakresie wg utworzenia listy grouped = defaultdict(float) - for rec in data: - ts = rec["created_at"] or datetime.now(timezone.utc) - if range_type == "monthly": - key = ts.strftime("%Y-%m") - elif range_type == "quarterly": - key = f"{ts.year}-Q{((ts.month - 1) // 3 + 1)}" - elif range_type == "halfyearly": - key = f"{ts.year}-H{1 if ts.month <= 6 else 2}" - elif range_type == "yearly": - key = str(ts.year) - else: - key = ts.strftime("%Y-%m-%d") - grouped[key] += rec["amount"] + for sl in lists: + if sl.id in expense_map: + ts = sl.created_at or datetime.now(timezone.utc) + if range_type == "monthly": + key = ts.strftime("%Y-%m") + elif range_type == "quarterly": + key = f"{ts.year}-Q{((ts.month - 1) // 3 + 1)}" + elif range_type == "halfyearly": + key = f"{ts.year}-H{1 if ts.month <= 6 else 2}" + elif range_type == "yearly": + key = str(ts.year) + else: + key = ts.strftime("%Y-%m-%d") + grouped[key] += expense_map[sl.id] labels = sorted(grouped) expenses = [round(grouped[l], 2) for l in labels] @@ -882,6 +928,7 @@ def main_page(): ) return query + # Pobranie list if current_user.is_authenticated: user_lists = ( date_filter( @@ -934,8 +981,47 @@ def main_page(): .all() ) - for l in user_lists + public_lists + archived_lists: - enrich_list_data(l) + all_lists = user_lists + public_lists + archived_lists + all_ids = [l.id for l in all_lists] + + if all_ids: + # statystyki produktów + 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" + ), + ) + .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) for s in stats + } + + # ostatnia kwota (w tym przypadku max = suma z ostatniego zapisu) + latest_expenses_map = dict( + db.session.query( + Expense.list_id, func.coalesce(func.max(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 = stats_map.get(l.id, (0, 0)) + l.total_count = total_count + l.purchased_count = purchased_count + l.total_expense = latest_expenses_map.get(l.id, 0) + else: + for l in all_lists: + l.total_count = 0 + l.purchased_count = 0 + l.total_expense = 0 return render_template( "main.html", @@ -1197,7 +1283,9 @@ def view_list(list_id): 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 "?" + item.added_by_display = ( + item.added_by_user.username if item.added_by_user else "?" + ) else: item.added_by_display = None @@ -1226,11 +1314,12 @@ def user_expenses(): start = None end = None - expenses_query = Expense.query.join( - ShoppingList, Expense.list_id == ShoppingList.id - ).options(joinedload(Expense.list)) + expenses_query = Expense.query.options( + joinedload(Expense.shopping_list).joinedload(ShoppingList.owner), + joinedload(Expense.shopping_list).joinedload(ShoppingList.expenses), + ).join(ShoppingList, Expense.list_id == ShoppingList.id) - # Jeśli show_all to False, filtruj tylko po bieżącym użytkowniku + # Filtry dostępu if not show_all: expenses_query = expenses_query.filter(ShoppingList.owner_id == current_user.id) else: @@ -1240,6 +1329,7 @@ def user_expenses(): ) ) + # Filtr daty if start_date_str and end_date_str: try: start = datetime.strptime(start_date_str, "%Y-%m-%d") @@ -1250,37 +1340,43 @@ def user_expenses(): except ValueError: flash("Błędny zakres dat", "danger") + # Pobranie wszystkich wydatków z powiązanymi listami expenses = expenses_query.order_by(Expense.added_at.desc()).all() + # Zbiorcze sumowanie wydatków per lista w SQL list_ids = {e.list_id for e in expenses} - lists = ( - ShoppingList.query.filter(ShoppingList.id.in_(list_ids)) - .order_by(ShoppingList.created_at.desc()) - .all() - ) + totals_map = {} + if list_ids: + totals = ( + db.session.query( + Expense.list_id, func.sum(Expense.amount).label("total_expense") + ) + .filter(Expense.list_id.in_(list_ids)) + .group_by(Expense.list_id) + .all() + ) + totals_map = {t.list_id: t.total_expense or 0 for t in totals} + # Tabela wydatków expense_table = [ { - "title": e.list.title if e.list else "Nieznana", + "title": e.shopping_list.title if e.shopping_list else "Nieznana", "amount": e.amount, "added_at": e.added_at, } for e in expenses ] + # Lista z danymi i sumami lists_data = [ { "id": l.id, "title": l.title, "created_at": l.created_at, - "total_expense": sum( - e.amount - for e in l.expenses - if (not start or not end) or (e.added_at >= start and e.added_at < end) - ), + "total_expense": totals_map.get(l.id, 0), "owner_username": l.owner.username if l.owner else "?", } - for l in lists + for l in {e.shopping_list for e in expenses if e.shopping_list} ] return render_template( @@ -1299,7 +1395,7 @@ def user_expenses_data(): end_date = request.args.get("end_date") show_all = request.args.get("show_all", "false").lower() == "true" - result = get_expenses_aggregated_by_list_created_at( + result = get_total_expenses_grouped_by_list_created_at( user_only=True, admin=False, show_all=show_all, @@ -1324,13 +1420,16 @@ 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 ) 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 "?" + item.added_by_display = ( + item.added_by_user.username if item.added_by_user else "?" + ) else: item.added_by_display = None @@ -1456,7 +1555,7 @@ def upload_receipt(list_id): except ValueError as e: return _receipt_error(str(e)) - filesize = os.path.getsize(file_path) if os.path.exists(file_path) else None + filesize = os.path.getsize(file_path) uploaded_at = datetime.now(timezone.utc) new_receipt = Receipt( @@ -1625,21 +1724,59 @@ def crop_receipt_user(): def admin_panel(): now = datetime.now(timezone.utc) + # Liczniki globalne user_count = User.query.count() list_count = ShoppingList.query.count() item_count = Item.query.count() - all_lists = ShoppingList.query.options(db.joinedload(ShoppingList.owner)).all() - all_files = os.listdir(app.config["UPLOAD_FOLDER"]) + + all_lists = ShoppingList.query.options( + joinedload(ShoppingList.owner), + joinedload(ShoppingList.items), + joinedload(ShoppingList.receipts), + joinedload(ShoppingList.expenses), + ).all() + + 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, + func.count(Item.id).label("total_count"), + func.sum(case((Item.purchased == True, 1), else_=0)).label( + "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) for s in stats + } + + # Pobranie ostatnich kwot dla wszystkich list w jednym zapytaniu + latest_expenses_map = dict( + db.session.query( + Expense.list_id, func.coalesce(func.max(Expense.amount), 0) + ) + .filter(Expense.list_id.in_(all_ids)) + .group_by(Expense.list_id) + .all() + ) enriched_lists = [] for l in all_lists: - enrich_list_data(l) - items = Item.query.filter_by(list_id=l.id).all() - total_count = l.total_count - purchased_count = l.purchased_count + total_count, purchased_count = stats_map.get(l.id, (0, 0)) percent = (purchased_count / total_count * 100) if total_count > 0 else 0 - comments_count = len([i for i in items if i.note and i.note.strip() != ""]) - receipts_count = Receipt.query.filter_by(list_id=l.id).count() + comments_count = sum(1 for i in l.items if i.note and i.note.strip() != "") + receipts_count = len(l.receipts) + total_expense = latest_expenses_map.get(l.id, 0) if l.is_temporary and l.expires_at: expires_at = l.expires_at @@ -1657,11 +1794,12 @@ def admin_panel(): "percent": round(percent), "comments_count": comments_count, "receipts_count": receipts_count, - "total_expense": l.total_expense, + "total_expense": total_expense, "expired": is_expired, } ) + # Top produkty top_products = ( db.session.query(Item.name, func.count(Item.id).label("count")) .filter(Item.purchased.is_(True)) @@ -1672,8 +1810,9 @@ def admin_panel(): ) purchased_items_count = Item.query.filter_by(purchased=True).count() - total_expense_sum = db.session.query(func.sum(Expense.amount)).scalar() or 0 + # Podsumowania wydatków globalnych + total_expense_sum = db.session.query(func.sum(Expense.amount)).scalar() or 0 current_time = datetime.now(timezone.utc) current_year = current_time.year current_month = current_time.month @@ -1682,21 +1821,19 @@ def admin_panel(): db.session.query(func.sum(Expense.amount)) .filter(extract("year", Expense.added_at) == current_year) .scalar() - or 0 - ) + ) or 0 month_expense_sum = ( db.session.query(func.sum(Expense.amount)) .filter(extract("year", Expense.added_at) == current_year) .filter(extract("month", Expense.added_at) == current_month) .scalar() - or 0 - ) + ) or 0 + # Statystyki systemowe process = psutil.Process(os.getpid()) app_mem = process.memory_info().rss // (1024 * 1024) # MB - # Engine info db_engine = db.engine db_info = { "engine": db_engine.name, @@ -1704,11 +1841,9 @@ def admin_panel(): "url": str(db_engine.url).split("?")[0], } - # Tabele inspector = inspect(db_engine) table_count = len(inspector.get_table_names()) - # Rekordy (szybkie zliczenie) record_total = ( db.session.query(func.count(User.id)).scalar() + db.session.query(func.count(ShoppingList.id)).scalar() @@ -1717,7 +1852,6 @@ def admin_panel(): + db.session.query(func.count(Expense.id)).scalar() ) - # Uptime uptime_minutes = int( (datetime.now(timezone.utc) - app_start_time).total_seconds() // 60 ) @@ -1999,22 +2133,23 @@ def delete_selected_lists(): @login_required @admin_required def edit_list(list_id): - l = db.session.get(ShoppingList, list_id) + # Pobieramy listę z powiązanymi danymi jednym zapytaniem + l = ( + db.session.query(ShoppingList) + .options( + joinedload(ShoppingList.expenses), + joinedload(ShoppingList.receipts), + joinedload(ShoppingList.owner), + joinedload(ShoppingList.items), + ) + .get(list_id) + ) + if l is None: abort(404) - expenses = Expense.query.filter_by(list_id=list_id).all() - total_expense = sum(e.amount for e in expenses) - users = User.query.all() - items = ( - db.session.query(Item).filter_by(list_id=list_id).order_by(Item.id.desc()).all() - ) - - receipts = ( - Receipt.query.filter_by(list_id=list_id) - .order_by(Receipt.uploaded_at.desc()) - .all() - ) + # Suma wydatków z listy + total_expense = get_total_expense_for_list(l.id) if request.method == "POST": action = request.form.get("action") @@ -2064,7 +2199,7 @@ def edit_list(list_id): if new_amount_str: try: new_amount = float(new_amount_str) - for expense in expenses: + for expense in l.expenses: db.session.delete(expense) db.session.commit() db.session.add(Expense(list_id=list_id, amount=new_amount)) @@ -2184,6 +2319,11 @@ def edit_list(list_id): flash("Nie znaleziono produktu", "danger") return redirect(url_for("edit_list", list_id=list_id)) + # Dane do widoku + users = User.query.all() + items = l.items + receipts = l.receipts + return render_template( "admin/edit_list.html", list=l, @@ -2191,7 +2331,6 @@ def edit_list(list_id): users=users, items=items, receipts=receipts, - upload_folder=app.config["UPLOAD_FOLDER"], ) @@ -2269,7 +2408,7 @@ def admin_expenses_data(): start_date = request.args.get("start_date") end_date = request.args.get("end_date") - result = get_expenses_aggregated_by_list_created_at( + result = get_total_expenses_grouped_by_list_created_at( user_only=False, admin=True, show_all=True, diff --git a/config.py b/config.py index f5349f5..8866df9 100644 --- a/config.py +++ b/config.py @@ -6,7 +6,7 @@ class Config: DB_ENGINE = os.environ.get("DB_ENGINE", "sqlite").lower() if DB_ENGINE == "sqlite": - SQLALCHEMY_DATABASE_URI = f"sqlite:///{os.path.join(basedir, 'instance', 'shopping.db')}" + SQLALCHEMY_DATABASE_URI = f"sqlite:///{os.path.join(basedir, 'database', 'shopping.db')}" elif DB_ENGINE == "pgsql": SQLALCHEMY_DATABASE_URI = f"postgresql://{os.environ['DB_USER']}:{os.environ['DB_PASSWORD']}@{os.environ['DB_HOST']}:{os.environ.get('DB_PORT', 5432)}/{os.environ['DB_NAME']}" elif DB_ENGINE == "mysql":