From 3b94f93892fc0e266e83872cde40f5bf80a1421b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Tue, 15 Jul 2025 22:48:25 +0200 Subject: [PATCH 01/13] funckja niekupione --- app.py | 1198 +++++++++++++++++++++++-------------- static/js/functions.js | 85 ++- static/js/live.js | 4 + static/js/sockets.js | 8 + templates/list.html | 13 +- templates/list_share.html | 35 +- 6 files changed, 870 insertions(+), 473 deletions(-) diff --git a/app.py b/app.py index 2403f1a..c9c34d2 100644 --- a/app.py +++ b/app.py @@ -8,10 +8,31 @@ import platform import psutil from datetime import datetime, timedelta -from flask import Flask, render_template, redirect, url_for, request, flash, Blueprint, send_from_directory, request, abort, session, jsonify, make_response +from flask import ( + Flask, + render_template, + redirect, + url_for, + request, + flash, + Blueprint, + send_from_directory, + request, + abort, + session, + jsonify, + make_response, +) from markupsafe import Markup from flask_sqlalchemy import SQLAlchemy -from flask_login import LoginManager, UserMixin, login_user, login_required, logout_user, current_user +from flask_login import ( + LoginManager, + UserMixin, + login_user, + login_required, + logout_user, + current_user, +) from flask_compress import Compress from flask_socketio import SocketIO, emit, join_room from werkzeug.security import generate_password_hash, check_password_hash @@ -25,17 +46,17 @@ from functools import wraps app = Flask(__name__) app.config.from_object(Config) -app.config['COMPRESS_ALGORITHM'] = ['zstd', 'br', 'gzip', 'deflate'] +app.config["COMPRESS_ALGORITHM"] = ["zstd", "br", "gzip", "deflate"] app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) -SYSTEM_PASSWORD = app.config.get('SYSTEM_PASSWORD', 'changeme') -DEFAULT_ADMIN_USERNAME = app.config.get('DEFAULT_ADMIN_USERNAME', 'admin') -DEFAULT_ADMIN_PASSWORD = app.config.get('DEFAULT_ADMIN_PASSWORD', 'admin123') -UPLOAD_FOLDER = app.config.get('UPLOAD_FOLDER', 'uploads') -ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'} -AUTHORIZED_COOKIE_VALUE = app.config.get('AUTHORIZED_COOKIE_VALUE', '80d31cdfe63539c9') -AUTH_COOKIE_MAX_AGE = app.config.get('AUTH_COOKIE_MAX_AGE', 86400) -HEALTHCHECK_TOKEN = app.config.get('HEALTHCHECK_TOKEN', 'alamapsaikota1234') +SYSTEM_PASSWORD = app.config.get("SYSTEM_PASSWORD", "changeme") +DEFAULT_ADMIN_USERNAME = app.config.get("DEFAULT_ADMIN_USERNAME", "admin") +DEFAULT_ADMIN_PASSWORD = app.config.get("DEFAULT_ADMIN_PASSWORD", "admin123") +UPLOAD_FOLDER = app.config.get("UPLOAD_FOLDER", "uploads") +ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"} +AUTHORIZED_COOKIE_VALUE = app.config.get("AUTHORIZED_COOKIE_VALUE", "80d31cdfe63539c9") +AUTH_COOKIE_MAX_AGE = app.config.get("AUTH_COOKIE_MAX_AGE", 86400) +HEALTHCHECK_TOKEN = app.config.get("HEALTHCHECK_TOKEN", "alamapsaikota1234") os.makedirs(UPLOAD_FOLDER, exist_ok=True) @@ -46,65 +67,78 @@ TIME_WINDOW = 60 * 60 db = SQLAlchemy(app) socketio = SocketIO(app, async_mode="eventlet") login_manager = LoginManager(app) -login_manager.login_view = 'login' +login_manager.login_view = "login" # flask-compress compress = Compress() compress.init_app(app) -static_bp = Blueprint('static_bp', __name__) +static_bp = Blueprint("static_bp", __name__) # dla live active_users = {} + class User(UserMixin, db.Model): id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(150), unique=True, nullable=False) password_hash = db.Column(db.String(150), nullable=False) is_admin = db.Column(db.Boolean, default=False) + class ShoppingList(db.Model): id = db.Column(db.Integer, primary_key=True) title = db.Column(db.String(150), nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow) - owner_id = db.Column(db.Integer, db.ForeignKey('user.id')) + owner_id = db.Column(db.Integer, db.ForeignKey("user.id")) is_temporary = db.Column(db.Boolean, default=False) share_token = db.Column(db.String(64), unique=True, nullable=True) expires_at = db.Column(db.DateTime, nullable=True) - owner = db.relationship('User', backref='lists', lazy=True) + owner = db.relationship("User", backref="lists", lazy=True) is_archived = db.Column(db.Boolean, default=False) is_public = db.Column(db.Boolean, default=True) + + class Item(db.Model): id = db.Column(db.Integer, primary_key=True) - list_id = db.Column(db.Integer, db.ForeignKey('shopping_list.id')) + list_id = db.Column(db.Integer, db.ForeignKey("shopping_list.id")) name = db.Column(db.String(150), nullable=False) added_at = db.Column(db.DateTime, default=datetime.utcnow) - added_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True) + added_by = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) purchased = db.Column(db.Boolean, default=False) purchased_at = db.Column(db.DateTime, nullable=True) quantity = db.Column(db.Integer, default=1) note = db.Column(db.Text, nullable=True) + not_purchased = db.Column(db.Boolean, default=False) + not_purchased_reason = db.Column(db.Text, nullable=True) + class SuggestedProduct(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(150), unique=True, nullable=False) usage_count = db.Column(db.Integer, default=0) + + class Expense(db.Model): id = db.Column(db.Integer, primary_key=True) - list_id = db.Column(db.Integer, db.ForeignKey('shopping_list.id')) + list_id = db.Column(db.Integer, db.ForeignKey("shopping_list.id")) amount = db.Column(db.Float, nullable=False) added_at = db.Column(db.DateTime, default=datetime.utcnow) receipt_filename = db.Column(db.String(255), nullable=True) + with app.app_context(): db.create_all() from werkzeug.security import generate_password_hash + admin = User.query.filter_by(is_admin=True).first() - username = app.config.get('DEFAULT_ADMIN_USERNAME', 'admin') - password = app.config.get('DEFAULT_ADMIN_PASSWORD', 'admin123') + username = app.config.get("DEFAULT_ADMIN_USERNAME", "admin") + password = app.config.get("DEFAULT_ADMIN_PASSWORD", "admin123") password_hash = generate_password_hash(password) if admin: - if admin.username != username or not check_password_hash(admin.password_hash, password): + if admin.username != username or not check_password_hash( + admin.password_hash, password + ): admin.username = username admin.password_hash = password_hash db.session.commit() @@ -113,68 +147,78 @@ with app.app_context(): db.session.add(admin) db.session.commit() -@static_bp.route('/static/js/') + +@static_bp.route("/static/js/") def serve_js(filename): - response = send_from_directory('static/js', filename) + response = send_from_directory("static/js", filename) response.cache_control.no_cache = True response.cache_control.no_store = True response.cache_control.must_revalidate = True - #response.expires = 0 - response.pragma = 'no-cache' - response.headers.pop('Content-Disposition', None) - response.headers.pop('Etag', None) + # response.expires = 0 + response.pragma = "no-cache" + response.headers.pop("Content-Disposition", None) + response.headers.pop("Etag", None) return response -@static_bp.route('/static/css/') + +@static_bp.route("/static/css/") def serve_css(filename): - response = send_from_directory('static/css', filename) - response.headers['Cache-Control'] = 'public, max-age=3600' - response.headers.pop('Content-Disposition', None) - response.headers.pop('Etag', None) + response = send_from_directory("static/css", filename) + response.headers["Cache-Control"] = "public, max-age=3600" + response.headers.pop("Content-Disposition", None) + response.headers.pop("Etag", None) return response -@static_bp.route('/static/lib/js/') + +@static_bp.route("/static/lib/js/") def serve_js_lib(filename): - response = send_from_directory('static/lib/js', filename) - response.headers['Cache-Control'] = 'public, max-age=604800' - response.headers.pop('Content-Disposition', None) - response.headers.pop('Etag', None) + response = send_from_directory("static/lib/js", filename) + response.headers["Cache-Control"] = "public, max-age=604800" + response.headers.pop("Content-Disposition", None) + response.headers.pop("Etag", None) return response + # CSS z cache na tydzień -@static_bp.route('/static/lib/css/') +@static_bp.route("/static/lib/css/") def serve_css_lib(filename): - response = send_from_directory('static/lib/css', filename) - response.headers['Cache-Control'] = 'public, max-age=604800' - response.headers.pop('Content-Disposition', None) - response.headers.pop('Etag', None) + response = send_from_directory("static/lib/css", filename) + response.headers["Cache-Control"] = "public, max-age=604800" + response.headers.pop("Content-Disposition", None) + response.headers.pop("Etag", None) return response + app.register_blueprint(static_bp) + def allowed_file(filename): - return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS + return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS + def get_list_details(list_id): shopping_list = ShoppingList.query.get_or_404(list_id) items = Item.query.filter_by(list_id=list_id).all() receipt_pattern = f"list_{list_id}" - all_files = os.listdir(app.config['UPLOAD_FOLDER']) + all_files = os.listdir(app.config["UPLOAD_FOLDER"]) receipt_files = [f for f in all_files if receipt_pattern in f] expenses = Expense.query.filter_by(list_id=list_id).all() total_expense = sum(e.amount for e in expenses) return shopping_list, items, receipt_files, expenses, total_expense + def generate_share_token(length=8): """Generuje token do udostępniania. Parametr `length` to liczba znaków (domyślnie 4).""" return secrets.token_hex(length // 2) + def check_list_public(shopping_list): if not shopping_list.is_public: - flash('Ta lista nie jest publicznie dostępna', 'danger') + flash("Ta lista nie jest publicznie dostępna", "danger") return False return True + def enrich_list_data(l): items = Item.query.filter_by(list_id=l.id).all() l.total_count = len(items) @@ -183,23 +227,30 @@ def enrich_list_data(l): l.total_expense = sum(e.amount for e in expenses) return l + def save_resized_image(file, path: str, max_size=(2000, 2000)): img = Image.open(file) img.thumbnail(max_size) img.save(path) -def redirect_with_flash(message: str, category: str = 'info', endpoint: str = 'main_page'): + +def redirect_with_flash( + message: str, category: str = "info", endpoint: str = "main_page" +): flash(message, category) return redirect(url_for(endpoint)) + def admin_required(f): @wraps(f) def decorated_function(*args, **kwargs): if not current_user.is_authenticated or not current_user.is_admin: - return redirect_with_flash('Brak uprawnień do tej sekcji.', 'danger') + return redirect_with_flash("Brak uprawnień do tej sekcji.", "danger") return f(*args, **kwargs) + return decorated_function + def get_progress(list_id): items = Item.query.filter_by(list_id=list_id).all() total_count = len(items) @@ -207,9 +258,10 @@ def get_progress(list_id): percent = (purchased_count / total_count * 100) if total_count > 0 else 0 return purchased_count, total_count, percent + def delete_receipts_for_list(list_id): receipt_pattern = f"list_{list_id}_" - upload_folder = app.config['UPLOAD_FOLDER'] + upload_folder = app.config["UPLOAD_FOLDER"] for filename in os.listdir(upload_folder): if filename.startswith(receipt_pattern): try: @@ -217,6 +269,7 @@ def delete_receipts_for_list(list_id): except Exception as e: print(f"Nie udało się usunąć pliku {filename}: {e}") + # zabezpieczenie logowani do systemy - błędne hasła def is_ip_blocked(ip): now = time.time() @@ -225,6 +278,7 @@ def is_ip_blocked(ip): attempts.popleft() return len(attempts) >= MAX_ATTEMPTS + def register_failed_attempt(ip): now = time.time() attempts = failed_login_attempts[ip] @@ -232,37 +286,46 @@ def register_failed_attempt(ip): attempts.popleft() attempts.append(now) + def reset_failed_attempts(ip): failed_login_attempts[ip].clear() + def attempts_remaining(ip): attempts = failed_login_attempts[ip] return max(0, MAX_ATTEMPTS - len(attempts)) + + #################################################### + @login_manager.user_loader def load_user(user_id): return User.query.get(int(user_id)) + @app.context_processor def inject_time(): return dict(time=time) + @app.context_processor def inject_has_authorized_cookie(): - return {'has_authorized_cookie': 'authorized' in request.cookies} + return {"has_authorized_cookie": "authorized" in request.cookies} + @app.context_processor def inject_is_blocked(): ip = request.access_route[0] - return {'is_blocked': is_ip_blocked(ip)} + return {"is_blocked": is_ip_blocked(ip)} + @app.before_request def require_system_password(): endpoint = request.endpoint # Wyjątki: lib js/css zawsze przepuszczamy - if endpoint in ('static_bp.serve_js_lib', 'static_bp.serve_css_lib'): + if endpoint in ("static_bp.serve_js_lib", "static_bp.serve_css_lib"): return ip = request.access_route[0] @@ -272,34 +335,39 @@ def require_system_password(): if endpoint is None: return - if endpoint in ('system_auth', 'healthcheck'): + if endpoint in ("system_auth", "healthcheck"): return - if 'authorized' not in request.cookies and not endpoint.startswith('login') and endpoint != 'favicon': + if ( + "authorized" not in request.cookies + and not endpoint.startswith("login") + and endpoint != "favicon" + ): # Dla serve_js przepuszczamy tylko toasts.js - if endpoint == 'static_bp.serve_js': + if endpoint == "static_bp.serve_js": requested_file = request.view_args.get("filename", "") if requested_file == "toasts.js": return if requested_file.endswith(".js"): - return redirect(url_for('system_auth', next=request.url)) + return redirect(url_for("system_auth", next=request.url)) return # Blokujemy pozostałe static_bp - if endpoint.startswith('static_bp.'): + if endpoint.startswith("static_bp."): return - if request.path == '/': - return redirect(url_for('system_auth')) + if request.path == "/": + return redirect(url_for("system_auth")) from urllib.parse import urlparse, urlunparse + parsed = urlparse(request.url) fixed_url = urlunparse(parsed._replace(netloc=request.host)) - return redirect(url_for('system_auth', next=fixed_url)) + return redirect(url_for("system_auth", next=fixed_url)) -@app.template_filter('filemtime') +@app.template_filter("filemtime") def file_mtime_filter(path): try: t = os.path.getmtime(path) @@ -307,11 +375,12 @@ def file_mtime_filter(path): except Exception: return datetime.utcnow() -@app.template_filter('filesizeformat') + +@app.template_filter("filesizeformat") def filesizeformat_filter(path): try: size = os.path.getsize(path) - for unit in ['B', 'KB', 'MB', 'GB']: + for unit in ["B", "KB", "MB", "GB"]: if size < 1024.0: return f"{size:.1f} {unit}" size /= 1024.0 @@ -319,203 +388,260 @@ def filesizeformat_filter(path): except Exception: return "N/A" + @app.errorhandler(404) def page_not_found(e): - return render_template( - 'errors.html', - code=404, - title="Strona nie znaleziona", - message="Ups! Podana strona nie istnieje lub została przeniesiona." - ), 404 + return ( + render_template( + "errors.html", + code=404, + title="Strona nie znaleziona", + message="Ups! Podana strona nie istnieje lub została przeniesiona.", + ), + 404, + ) + @app.errorhandler(403) def forbidden(e): - return render_template( - 'errors.html', - code=403, - title="Brak dostępu", - message="Nie masz uprawnień do wyświetlenia tej strony." - ), 403 + return ( + render_template( + "errors.html", + code=403, + title="Brak dostępu", + message="Nie masz uprawnień do wyświetlenia tej strony.", + ), + 403, + ) -@app.route('/favicon.ico') + +@app.route("/favicon.ico") def favicon_ico(): - return redirect(url_for('static', filename='favicon.svg')) + return redirect(url_for("static", filename="favicon.svg")) -@app.route('/favicon.svg') + +@app.route("/favicon.svg") def favicon(): - svg = ''' + svg = """ 🛒 - ''' - return svg, 200, {'Content-Type': 'image/svg+xml'} + """ + return svg, 200, {"Content-Type": "image/svg+xml"} -@app.route('/') + +@app.route("/") def main_page(): now = datetime.utcnow() if current_user.is_authenticated: - user_lists = ShoppingList.query.filter_by(owner_id=current_user.id, is_archived=False).filter( - (ShoppingList.expires_at == None) | (ShoppingList.expires_at > now) - ).order_by(ShoppingList.created_at.desc()).all() + user_lists = ( + ShoppingList.query.filter_by(owner_id=current_user.id, is_archived=False) + .filter((ShoppingList.expires_at == None) | (ShoppingList.expires_at > now)) + .order_by(ShoppingList.created_at.desc()) + .all() + ) - archived_lists = ShoppingList.query.filter_by(owner_id=current_user.id, is_archived=True).order_by(ShoppingList.created_at.desc()).all() + archived_lists = ( + ShoppingList.query.filter_by(owner_id=current_user.id, is_archived=True) + .order_by(ShoppingList.created_at.desc()) + .all() + ) - public_lists = ShoppingList.query.filter( - ShoppingList.is_public == True, - ShoppingList.owner_id != current_user.id, - ((ShoppingList.expires_at == None) | (ShoppingList.expires_at > now)), - ShoppingList.is_archived == False - ).order_by(ShoppingList.created_at.desc()).all() + public_lists = ( + ShoppingList.query.filter( + ShoppingList.is_public == True, + ShoppingList.owner_id != current_user.id, + ((ShoppingList.expires_at == None) | (ShoppingList.expires_at > now)), + ShoppingList.is_archived == False, + ) + .order_by(ShoppingList.created_at.desc()) + .all() + ) else: user_lists = [] archived_lists = [] - public_lists = ShoppingList.query.filter( - ShoppingList.is_public == True, - ((ShoppingList.expires_at == None) | (ShoppingList.expires_at > now)), - ShoppingList.is_archived == False - ).order_by(ShoppingList.created_at.desc()).all() + public_lists = ( + ShoppingList.query.filter( + ShoppingList.is_public == True, + ((ShoppingList.expires_at == None) | (ShoppingList.expires_at > now)), + ShoppingList.is_archived == False, + ) + .order_by(ShoppingList.created_at.desc()) + .all() + ) for l in user_lists + public_lists + archived_lists: enrich_list_data(l) - return render_template("main.html", user_lists=user_lists, public_lists=public_lists, archived_lists=archived_lists) + return render_template( + "main.html", + user_lists=user_lists, + public_lists=public_lists, + archived_lists=archived_lists, + ) -@app.route('/system-auth', methods=['GET', 'POST']) + +@app.route("/system-auth", methods=["GET", "POST"]) def system_auth(): - if current_user.is_authenticated or request.cookies.get('authorized') == AUTHORIZED_COOKIE_VALUE: - flash('Jesteś już zalogowany lub autoryzowany.', 'info') - return redirect(url_for('main_page')) + if ( + current_user.is_authenticated + or request.cookies.get("authorized") == AUTHORIZED_COOKIE_VALUE + ): + flash("Jesteś już zalogowany lub autoryzowany.", "info") + return redirect(url_for("main_page")) ip = request.access_route[0] - next_page = request.args.get('next') or url_for('main_page') + next_page = request.args.get("next") or url_for("main_page") if is_ip_blocked(ip): - flash('Przekroczono limit prób logowania. Dostęp zablokowany na 1 godzinę.', 'danger') - return render_template('system_auth.html'), 403 + flash( + "Przekroczono limit prób logowania. Dostęp zablokowany na 1 godzinę.", + "danger", + ) + return render_template("system_auth.html"), 403 - if request.method == 'POST': - if request.form['password'] == SYSTEM_PASSWORD: + if request.method == "POST": + if request.form["password"] == SYSTEM_PASSWORD: reset_failed_attempts(ip) resp = redirect(next_page) - max_age = app.config.get('AUTH_COOKIE_MAX_AGE', 86400) - resp.set_cookie('authorized', AUTHORIZED_COOKIE_VALUE, max_age=max_age) + max_age = app.config.get("AUTH_COOKIE_MAX_AGE", 86400) + resp.set_cookie("authorized", AUTHORIZED_COOKIE_VALUE, max_age=max_age) return resp else: register_failed_attempt(ip) if is_ip_blocked(ip): - flash('Przekroczono limit prób logowania. Dostęp zablokowany na 1 godzinę.', 'danger') - return render_template('system_auth.html'), 403 + flash( + "Przekroczono limit prób logowania. Dostęp zablokowany na 1 godzinę.", + "danger", + ) + return render_template("system_auth.html"), 403 remaining = attempts_remaining(ip) - flash(f'Nieprawidłowe hasło. Pozostało {remaining} prób.', 'warning') - return render_template('system_auth.html') + flash(f"Nieprawidłowe hasło. Pozostało {remaining} prób.", "warning") + return render_template("system_auth.html") -@app.route('/toggle_archive_list/') + +@app.route("/toggle_archive_list/") @login_required def toggle_archive_list(list_id): l = ShoppingList.query.get_or_404(list_id) if l.owner_id != current_user.id: - return redirect_with_flash('Nie masz uprawnień do tej listy', 'danger') + return redirect_with_flash("Nie masz uprawnień do tej listy", "danger") - archive = request.args.get('archive', 'true').lower() == 'true' + archive = request.args.get("archive", "true").lower() == "true" if archive: l.is_archived = True - flash(f'Lista „{l.title}” została zarchiwizowana.', 'success') + flash(f"Lista „{l.title}” została zarchiwizowana.", "success") else: l.is_archived = False - flash(f'Lista „{l.title}” została przywrócona.', 'success') + flash(f"Lista „{l.title}” została przywrócona.", "success") db.session.commit() - return redirect(url_for('main_page')) + return redirect(url_for("main_page")) -@app.route('/edit_my_list/', methods=['GET', 'POST']) + +@app.route("/edit_my_list/", methods=["GET", "POST"]) @login_required def edit_my_list(list_id): l = ShoppingList.query.get_or_404(list_id) if l.owner_id != current_user.id: - return redirect_with_flash('Nie masz uprawnień do tej listy', 'danger') + return redirect_with_flash("Nie masz uprawnień do tej listy", "danger") - if request.method == 'POST': - new_title = request.form.get('title') + if request.method == "POST": + new_title = request.form.get("title") if new_title and new_title.strip(): l.title = new_title.strip() db.session.commit() - flash('Zaktualizowano tytuł listy', 'success') - return redirect(url_for('main_page')) + flash("Zaktualizowano tytuł listy", "success") + return redirect(url_for("main_page")) else: - flash('Podaj poprawny tytuł', 'danger') - return render_template('edit_my_list.html', list=l) + flash("Podaj poprawny tytuł", "danger") + return render_template("edit_my_list.html", list=l) -@app.route('/toggle_visibility/', methods=['GET', 'POST']) + +@app.route("/toggle_visibility/", methods=["GET", "POST"]) @login_required def toggle_visibility(list_id): l = ShoppingList.query.get_or_404(list_id) if l.owner_id != current_user.id: - if request.is_json or request.method == 'POST': - return {'error': 'Unauthorized'}, 403 - flash('Nie masz uprawnień do tej listy', 'danger') - return redirect(url_for('main_page')) + if request.is_json or request.method == "POST": + return {"error": "Unauthorized"}, 403 + flash("Nie masz uprawnień do tej listy", "danger") + return redirect(url_for("main_page")) l.is_public = not l.is_public db.session.commit() share_url = f"{request.url_root}share/{l.share_token}" - if request.is_json or request.method == 'POST': - return {'is_public': l.is_public, 'share_url': share_url} + if request.is_json or request.method == "POST": + return {"is_public": l.is_public, "share_url": share_url} if l.is_public: - flash('Lista została udostępniona publicznie', 'success') + flash("Lista została udostępniona publicznie", "success") else: - flash('Lista została ukryta przed gośćmi', 'info') + flash("Lista została ukryta przed gośćmi", "info") + + return redirect(url_for("main_page")) - return redirect(url_for('main_page')) from sqlalchemy import func -@app.route('/login', methods=['GET', 'POST']) -def login(): - if request.method == 'POST': - username_input = request.form['username'].lower() - user = User.query.filter(func.lower(User.username) == username_input).first() - if user and check_password_hash(user.password_hash, request.form['password']): - login_user(user) - flash('Zalogowano pomyślnie', 'success') - return redirect(url_for('main_page')) - flash('Nieprawidłowy login lub hasło', 'danger') - return render_template('login.html') -@app.route('/logout') +@app.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + username_input = request.form["username"].lower() + user = User.query.filter(func.lower(User.username) == username_input).first() + if user and check_password_hash(user.password_hash, request.form["password"]): + login_user(user) + flash("Zalogowano pomyślnie", "success") + return redirect(url_for("main_page")) + flash("Nieprawidłowy login lub hasło", "danger") + return render_template("login.html") + + +@app.route("/logout") @login_required def logout(): logout_user() - flash('Wylogowano pomyślnie', 'success') - return redirect(url_for('main_page')) + flash("Wylogowano pomyślnie", "success") + return redirect(url_for("main_page")) -@app.route('/create', methods=['POST']) + +@app.route("/create", methods=["POST"]) @login_required def create_list(): - title = request.form.get('title') - is_temporary = 'temporary' in request.form + title = request.form.get("title") + is_temporary = "temporary" in request.form token = generate_share_token(8) expires_at = datetime.utcnow() + timedelta(days=7) if is_temporary else None - new_list = ShoppingList(title=title, owner_id=current_user.id, is_temporary=is_temporary, share_token=token, expires_at=expires_at) + new_list = ShoppingList( + title=title, + owner_id=current_user.id, + is_temporary=is_temporary, + share_token=token, + expires_at=expires_at, + ) db.session.add(new_list) db.session.commit() - flash('Utworzono nową listę', 'success') - return redirect(url_for('view_list', list_id=new_list.id)) + flash("Utworzono nową listę", "success") + return redirect(url_for("view_list", list_id=new_list.id)) -@app.route('/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, items, receipt_files, 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 return render_template( - 'list.html', + "list.html", list=shopping_list, items=items, receipt_files=receipt_files, @@ -523,37 +649,43 @@ def view_list(list_id): purchased_count=purchased_count, percent=percent, expenses=expenses, - total_expense=total_expense + total_expense=total_expense, ) -@app.route('/share/') -@app.route('/guest-list/') + +@app.route("/share/") +@app.route("/guest-list/") def shared_list(token=None, list_id=None): if token: shopping_list = ShoppingList.query.filter_by(share_token=token).first_or_404() if not check_list_public(shopping_list): - return redirect(url_for('main_page')) + return redirect(url_for("main_page")) list_id = shopping_list.id - shopping_list, items, receipt_files, expenses, total_expense = get_list_details(list_id) + shopping_list, items, receipt_files, expenses, total_expense = get_list_details( + list_id + ) return render_template( - 'list_share.html', + "list_share.html", list=shopping_list, items=items, receipt_files=receipt_files, expenses=expenses, - total_expense=total_expense + total_expense=total_expense, ) -@app.route('/copy/') + +@app.route("/copy/") @login_required def copy_list(list_id): original = ShoppingList.query.get_or_404(list_id) token = generate_share_token(8) - new_list = ShoppingList(title=original.title + ' (Kopia)', owner_id=current_user.id, share_token=token) + new_list = ShoppingList( + title=original.title + " (Kopia)", owner_id=current_user.id, share_token=token + ) db.session.add(new_list) db.session.commit() original_items = Item.query.filter_by(list_id=original.id).all() @@ -561,27 +693,36 @@ def copy_list(list_id): copy_item = Item(list_id=new_list.id, name=item.name) db.session.add(copy_item) db.session.commit() - flash('Skopiowano listę', 'success') - return redirect(url_for('view_list', list_id=new_list.id)) + flash("Skopiowano listę", "success") + return redirect(url_for("view_list", list_id=new_list.id)) -@app.route('/suggest_products') + +@app.route("/suggest_products") def suggest_products(): - query = request.args.get('q', '') + query = request.args.get("q", "") suggestions = [] if query: - suggestions = SuggestedProduct.query.filter(SuggestedProduct.name.ilike(f'%{query}%')).limit(5).all() - return {'suggestions': [s.name for s in suggestions]} + suggestions = ( + SuggestedProduct.query.filter(SuggestedProduct.name.ilike(f"%{query}%")) + .limit(5) + .all() + ) + return {"suggestions": [s.name for s in suggestions]} -@app.route('/all_products') + +@app.route("/all_products") def all_products(): - query = request.args.get('q', '') + query = request.args.get("q", "") top_products_query = SuggestedProduct.query if query: - top_products_query = top_products_query.filter(SuggestedProduct.name.ilike(f'%{query}%')) + top_products_query = top_products_query.filter( + SuggestedProduct.name.ilike(f"%{query}%") + ) top_products = ( - top_products_query - .order_by(SuggestedProduct.usage_count.desc(), SuggestedProduct.name.asc()) + top_products_query.order_by( + SuggestedProduct.usage_count.desc(), SuggestedProduct.name.asc() + ) .limit(20) .all() ) @@ -589,21 +730,17 @@ def all_products(): 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}%')) + rest_query = rest_query.filter(SuggestedProduct.name.ilike(f"%{query}%")) if top_names: rest_query = rest_query.filter(~SuggestedProduct.name.in_(top_names)) - rest_products = ( - rest_query - .order_by(SuggestedProduct.name.asc()) - .limit(200) - .all() - ) + rest_products = rest_query.order_by(SuggestedProduct.name.asc()).limit(200).all() all_names = top_names + [s.name for s in rest_products] - return {'allproducts': all_names} + return {"allproducts": all_names} + """ @app.route('/upload_receipt/', methods=['POST']) def upload_receipt(list_id): @@ -629,67 +766,78 @@ def upload_receipt(list_id): flash('Niedozwolony format pliku', 'danger') return redirect(request.referrer) """ -@app.route('/upload_receipt/', methods=['POST']) + +@app.route("/upload_receipt/", methods=["POST"]) def upload_receipt(list_id): - if 'receipt' not in request.files: - if request.is_json or request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return jsonify({'success': False, 'message': 'Brak pliku'}), 400 - flash('Brak pliku', 'danger') + if "receipt" not in request.files: + if ( + request.is_json + or request.headers.get("X-Requested-With") == "XMLHttpRequest" + ): + return jsonify({"success": False, "message": "Brak pliku"}), 400 + flash("Brak pliku", "danger") return redirect(request.referrer) - file = request.files['receipt'] + file = request.files["receipt"] - if file.filename == '': - if request.is_json or request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return jsonify({'success': False, 'message': 'Nie wybrano pliku'}), 400 - flash('Nie wybrano pliku', 'danger') + if file.filename == "": + if ( + request.is_json + or request.headers.get("X-Requested-With") == "XMLHttpRequest" + ): + return jsonify({"success": False, "message": "Nie wybrano pliku"}), 400 + flash("Nie wybrano pliku", "danger") return redirect(request.referrer) if file and allowed_file(file.filename): filename = secure_filename(file.filename) full_filename = f"list_{list_id}_{filename}" - file_path = os.path.join(app.config['UPLOAD_FOLDER'], full_filename) + file_path = os.path.join(app.config["UPLOAD_FOLDER"], full_filename) save_resized_image(file, file_path) - if request.is_json or request.headers.get('X-Requested-With') == 'XMLHttpRequest': - url = url_for('uploaded_file', filename=full_filename) + if ( + request.is_json + or request.headers.get("X-Requested-With") == "XMLHttpRequest" + ): + url = url_for("uploaded_file", filename=full_filename) - socketio.emit('receipt_added', {'url': url}, to=str(list_id)) + socketio.emit("receipt_added", {"url": url}, to=str(list_id)) - return jsonify({'success': True, 'url': url}) + return jsonify({"success": True, "url": url}) - flash('Wgrano paragon', 'success') + flash("Wgrano paragon", "success") return redirect(request.referrer) - if request.is_json or request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return jsonify({'success': False, 'message': 'Niedozwolony format pliku'}), 400 - flash('Niedozwolony format pliku', 'danger') + if request.is_json or request.headers.get("X-Requested-With") == "XMLHttpRequest": + return jsonify({"success": False, "message": "Niedozwolony format pliku"}), 400 + flash("Niedozwolony format pliku", "danger") return redirect(request.referrer) -@app.route('/uploads/') +@app.route("/uploads/") def uploaded_file(filename): - response = send_from_directory(app.config['UPLOAD_FOLDER'], filename) - response.headers['Cache-Control'] = 'public, max-age=2592000, immutable' - response.headers.pop('Pragma', None) - response.headers.pop('Content-Disposition', None) + response = send_from_directory(app.config["UPLOAD_FOLDER"], filename) + response.headers["Cache-Control"] = "public, max-age=2592000, immutable" + response.headers.pop("Pragma", None) + response.headers.pop("Content-Disposition", None) mime, _ = mimetypes.guess_type(filename) if mime: - response.headers['Content-Type'] = mime + response.headers["Content-Type"] = mime return response -@app.route('/admin') + +@app.route("/admin") @login_required @admin_required def admin_panel(): - + now = datetime.utcnow() 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_files = os.listdir(app.config["UPLOAD_FOLDER"]) enriched_lists = [] for l in all_lists: @@ -698,22 +846,24 @@ def admin_panel(): total_count = l.total_count purchased_count = l.purchased_count 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() != '']) + comments_count = len([i for i in items if i.note and i.note.strip() != ""]) receipt_pattern = f"list_{l.id}" receipt_files = [f for f in all_files if receipt_pattern in f] - enriched_lists.append({ - 'list': l, - 'total_count': total_count, - 'purchased_count': purchased_count, - 'percent': round(percent), - 'comments_count': comments_count, - 'receipts_count': len(receipt_files), - 'total_expense': l.total_expense - }) + enriched_lists.append( + { + "list": l, + "total_count": total_count, + "purchased_count": purchased_count, + "percent": round(percent), + "comments_count": comments_count, + "receipts_count": len(receipt_files), + "total_expense": l.total_expense, + } + ) top_products = ( - db.session.query(Item.name, func.count(Item.id).label('count')) + db.session.query(Item.name, func.count(Item.id).label("count")) .filter(Item.purchased == True) .group_by(Item.name) .order_by(func.count(Item.id).desc()) @@ -727,23 +877,25 @@ def admin_panel(): current_year = datetime.utcnow().year year_expense_sum = ( db.session.query(func.sum(Expense.amount)) - .filter(extract('year', Expense.added_at) == current_year) - .scalar() or 0 + .filter(extract("year", Expense.added_at) == current_year) + .scalar() + or 0 ) current_month = datetime.utcnow().month 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 + .filter(extract("year", Expense.added_at) == current_year) + .filter(extract("month", Expense.added_at) == current_month) + .scalar() + or 0 ) process = psutil.Process(os.getpid()) app_mem = process.memory_info().rss // (1024 * 1024) # MB return render_template( - 'admin/admin_panel.html', + "admin/admin_panel.html", user_count=user_count, list_count=list_count, item_count=item_count, @@ -760,43 +912,45 @@ def admin_panel(): ) -@app.route('/admin/delete_list/') +@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')) + flash(f"Usunięto listę: {list_to_delete.title}", "success") + return redirect(url_for("admin_panel")) -@app.route('/admin/add_user', methods=['POST']) + +@app.route("/admin/add_user", methods=["POST"]) @login_required @admin_required def add_user(): - username = request.form['username'].lower() - password = request.form['password'] + username = request.form["username"].lower() + password = request.form["password"] if not username or not password: - flash('Wypełnij wszystkie pola', 'danger') - return redirect(url_for('list_users')) + flash("Wypełnij wszystkie pola", "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')) + flash("Użytkownik o takiej nazwie już istnieje", "warning") + return redirect(url_for("list_users")) hashed_password = generate_password_hash(password) new_user = User(username=username, password_hash=hashed_password) db.session.add(new_user) db.session.commit() - flash('Dodano nowego użytkownika', 'success') - return redirect(url_for('list_users')) + flash("Dodano nowego użytkownika", "success") + return redirect(url_for("list_users")) -@app.route('/admin/users') + +@app.route("/admin/users") @login_required @admin_required def list_users(): @@ -805,25 +959,34 @@ def list_users(): list_count = ShoppingList.query.count() item_count = Item.query.count() activity_log = ["Utworzono listę: Zakupy weekendowe", "Dodano produkt: Mleko"] - 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) + 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, + ) -@app.route('/admin/change_password/', methods=['POST']) + +@app.route("/admin/change_password/", methods=["POST"]) @login_required @admin_required def reset_password(user_id): user = User.query.get_or_404(user_id) - new_password = request.form['password'] + new_password = request.form["password"] if not new_password: - flash('Podaj nowe hasło', 'danger') - return redirect(url_for('list_users')) + flash("Podaj nowe hasło", "danger") + return redirect(url_for("list_users")) user.password_hash = generate_password_hash(new_password) db.session.commit() - flash(f'Hasło dla użytkownika {user.username} zostało zaktualizowane', 'success') - return redirect(url_for('list_users')) + flash(f"Hasło dla użytkownika {user.username} zostało zaktualizowane", "success") + return redirect(url_for("list_users")) -@app.route('/admin/delete_user/') + +@app.route("/admin/delete_user/") @login_required @admin_required def delete_user(user_id): @@ -832,19 +995,20 @@ def delete_user(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ąć ostatniego administratora.", "danger") + return redirect(url_for("list_users")) db.session.delete(user) db.session.commit() - flash('Użytkownik usunięty', 'success') - return redirect(url_for('list_users')) + flash("Użytkownik usunięty", "success") + return redirect(url_for("list_users")) -@app.route('/admin/receipts/') + +@app.route("/admin/receipts/") @login_required @admin_required def admin_receipts(id): - all_files = os.listdir(app.config['UPLOAD_FOLDER']) + all_files = os.listdir(app.config["UPLOAD_FOLDER"]) image_files = [f for f in all_files if allowed_file(f)] if id == "all": @@ -856,35 +1020,37 @@ def admin_receipts(id): filtered_files = [f for f in image_files if f.startswith(receipt_prefix)] except ValueError: flash("Nieprawidłowe ID listy.", "danger") - return redirect(url_for('admin_panel')) + return redirect(url_for("admin_panel")) return render_template( - 'admin/receipts.html', + "admin/receipts.html", image_files=filtered_files, - upload_folder=app.config['UPLOAD_FOLDER'] + upload_folder=app.config["UPLOAD_FOLDER"], ) -@app.route('/admin/delete_receipt/') + +@app.route("/admin/delete_receipt/") @login_required @admin_required def delete_receipt(filename): - file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename) + file_path = os.path.join(app.config["UPLOAD_FOLDER"], filename) if os.path.exists(file_path): os.remove(file_path) - flash('Plik usunięty', 'success') + flash("Plik usunięty", "success") else: - flash('Plik nie istnieje', 'danger') - - next_url = request.args.get('next') + flash("Plik nie istnieje", "danger") + + next_url = request.args.get("next") if next_url: return redirect(next_url) - return redirect(url_for('admin_receipts')) + return redirect(url_for("admin_receipts")) -@app.route('/admin/delete_selected_lists', methods=['POST']) + +@app.route("/admin/delete_selected_lists", methods=["POST"]) @login_required @admin_required -def delete_selected_lists(): - ids = request.form.getlist('list_ids') +def delete_selected_lists(): + ids = request.form.getlist("list_ids") for list_id in ids: lst = ShoppingList.query.get(int(list_id)) if lst: @@ -893,19 +1059,21 @@ def delete_selected_lists(): 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("Usunięto wybrane listy", "success") + return redirect(url_for("admin_panel")) -@app.route('/admin/delete_all_items') + +@app.route("/admin/delete_all_items") @login_required @admin_required -def delete_all_items(): +def delete_all_items(): Item.query.delete() db.session.commit() - flash('Usunięto wszystkie produkty', 'success') - return redirect(url_for('admin_panel')) + flash("Usunięto wszystkie produkty", "success") + return redirect(url_for("admin_panel")) -@app.route('/admin/edit_list/', methods=['GET', 'POST']) + +@app.route("/admin/edit_list/", methods=["GET", "POST"]) @login_required @admin_required def edit_list(list_id): @@ -917,18 +1085,18 @@ def edit_list(list_id): # Pobranie listy plików paragonów receipt_pattern = f"list_{list_id}_" - all_files = os.listdir(app.config['UPLOAD_FOLDER']) + all_files = os.listdir(app.config["UPLOAD_FOLDER"]) receipts = [f for f in all_files if f.startswith(receipt_pattern)] - if request.method == 'POST': - action = request.form.get('action') + if request.method == "POST": + action = request.form.get("action") - if action == 'save': - new_title = request.form.get('title', '').strip() - new_amount_str = request.form.get('amount') - is_archived = 'archived' in request.form - is_public = 'public' in request.form - new_owner_id = request.form.get('owner_id') + if action == "save": + new_title = request.form.get("title", "").strip() + new_amount_str = request.form.get("amount") + is_archived = "archived" in request.form + is_public = "public" in request.form + new_owner_id = request.form.get("owner_id") if new_title: l.title = new_title @@ -942,11 +1110,11 @@ def edit_list(list_id): if User.query.get(new_owner_id_int): l.owner_id = new_owner_id_int else: - flash('Wybrany użytkownik nie istnieje', 'danger') - return redirect(url_for('edit_list', list_id=list_id)) + flash("Wybrany użytkownik nie istnieje", "danger") + return redirect(url_for("edit_list", list_id=list_id)) except ValueError: - flash('Niepoprawny ID użytkownika', 'danger') - return redirect(url_for('edit_list', list_id=list_id)) + flash("Niepoprawny ID użytkownika", "danger") + return redirect(url_for("edit_list", list_id=list_id)) if new_amount_str: try: @@ -958,19 +1126,19 @@ def edit_list(list_id): db.session.add(new_expense) db.session.commit() except ValueError: - flash('Niepoprawna kwota', 'danger') - return redirect(url_for('edit_list', list_id=list_id)) + flash("Niepoprawna kwota", "danger") + return redirect(url_for("edit_list", list_id=list_id)) db.session.commit() - flash('Zapisano zmiany listy', 'success') - return redirect(url_for('edit_list', list_id=list_id)) + flash("Zapisano zmiany listy", "success") + return redirect(url_for("edit_list", list_id=list_id)) - elif action == 'add_item': - item_name = request.form.get('item_name', '').strip() - quantity_str = request.form.get('quantity', '1') + elif action == "add_item": + item_name = request.form.get("item_name", "").strip() + quantity_str = request.form.get("quantity", "1") if not item_name: - flash('Podaj nazwę produktu', 'danger') - return redirect(url_for('edit_list', list_id=list_id)) + flash("Podaj nazwę produktu", "danger") + return redirect(url_for("edit_list", list_id=list_id)) try: quantity = int(quantity_str) @@ -979,50 +1147,58 @@ def edit_list(list_id): except ValueError: quantity = 1 - new_item = Item(list_id=list_id, name=item_name, quantity=quantity, added_by=current_user.id) + new_item = Item( + list_id=list_id, + name=item_name, + quantity=quantity, + added_by=current_user.id, + ) db.session.add(new_item) - if not SuggestedProduct.query.filter(func.lower(SuggestedProduct.name) == item_name.lower()).first(): + if not SuggestedProduct.query.filter( + func.lower(SuggestedProduct.name) == item_name.lower() + ).first(): db.session.add(SuggestedProduct(name=item_name)) db.session.commit() - flash('Dodano produkt', 'success') - return redirect(url_for('edit_list', list_id=list_id)) + flash("Dodano produkt", "success") + return redirect(url_for("edit_list", list_id=list_id)) - elif action == 'delete_item': - item_id = request.form.get('item_id') + elif action == "delete_item": + item_id = request.form.get("item_id") item = Item.query.get(item_id) if item and item.list_id == list_id: db.session.delete(item) db.session.commit() - flash('Usunięto produkt', 'success') + flash("Usunięto produkt", "success") else: - flash('Nie znaleziono produktu', 'danger') - return redirect(url_for('edit_list', list_id=list_id)) + flash("Nie znaleziono produktu", "danger") + return redirect(url_for("edit_list", list_id=list_id)) - elif action == 'toggle_purchased': - item_id = request.form.get('item_id') + elif action == "toggle_purchased": + item_id = request.form.get("item_id") item = Item.query.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') + flash("Zmieniono status oznaczenia produktu", "success") else: - flash('Nie znaleziono produktu', 'danger') - return redirect(url_for('edit_list', list_id=list_id)) + flash("Nie znaleziono produktu", "danger") + return redirect(url_for("edit_list", list_id=list_id)) # Przekazanie receipts do szablonu return render_template( - 'admin/edit_list.html', + "admin/edit_list.html", list=l, total_expense=total_expense, users=users, items=items, receipts=receipts, - upload_folder=app.config['UPLOAD_FOLDER'] + upload_folder=app.config["UPLOAD_FOLDER"], ) -@app.route('/admin/products') + +@app.route("/admin/products") @login_required @admin_required def list_products(): @@ -1035,68 +1211,83 @@ def list_products(): suggestions_dict = {s.name.lower(): s for s in suggestions} return render_template( - 'admin/list_products.html', + "admin/list_products.html", items=items, users_dict=users_dict, - suggestions_dict=suggestions_dict + suggestions_dict=suggestions_dict, ) -@app.route('/admin/sync_suggestion/', methods=['POST']) + +@app.route("/admin/sync_suggestion/", methods=["POST"]) @login_required def sync_suggestion_ajax(item_id): if not current_user.is_admin: - return jsonify({'success': False, 'message': 'Brak uprawnień'}), 403 + return jsonify({"success": False, "message": "Brak uprawnień"}), 403 item = Item.query.get_or_404(item_id) - existing = SuggestedProduct.query.filter(func.lower(SuggestedProduct.name) == item.name.lower()).first() + existing = SuggestedProduct.query.filter( + func.lower(SuggestedProduct.name) == item.name.lower() + ).first() if not existing: new_suggestion = SuggestedProduct(name=item.name) db.session.add(new_suggestion) db.session.commit() - return jsonify({'success': True, 'message': f'Utworzono sugestię dla produktu: {item.name}'}) + return jsonify( + { + "success": True, + "message": f"Utworzono sugestię dla produktu: {item.name}", + } + ) else: - return jsonify({'success': True, 'message': f'Sugestia dla produktu „{item.name}” już istnieje.'}) + return jsonify( + { + "success": True, + "message": f"Sugestia dla produktu „{item.name}” już istnieje.", + } + ) -@app.route('/admin/delete_suggestion/', methods=['POST']) + +@app.route("/admin/delete_suggestion/", methods=["POST"]) @login_required def delete_suggestion_ajax(suggestion_id): if not current_user.is_admin: - return jsonify({'success': False, 'message': 'Brak uprawnień'}), 403 + return jsonify({"success": False, "message": "Brak uprawnień"}), 403 suggestion = SuggestedProduct.query.get_or_404(suggestion_id) db.session.delete(suggestion) db.session.commit() - return jsonify({'success': True, 'message': 'Sugestia została usunięta.'}) + return jsonify({"success": True, "message": "Sugestia została usunięta."}) -@app.route('/admin/expenses_data') + +@app.route("/admin/expenses_data") @login_required def admin_expenses_data(): if not current_user.is_admin: - return jsonify({'error': 'Brak uprawnień'}), 403 + return jsonify({"error": "Brak uprawnień"}), 403 - range_type = request.args.get('range', 'monthly') - start_date_str = request.args.get('start_date') - end_date_str = request.args.get('end_date') + range_type = request.args.get("range", "monthly") + start_date_str = request.args.get("start_date") + end_date_str = request.args.get("end_date") now = datetime.utcnow() labels = [] expenses = [] if start_date_str and end_date_str: - start_date = datetime.strptime(start_date_str, '%Y-%m-%d') - end_date = datetime.strptime(end_date_str, '%Y-%m-%d') + start_date = datetime.strptime(start_date_str, "%Y-%m-%d") + end_date = datetime.strptime(end_date_str, "%Y-%m-%d") expenses_query = ( db.session.query( - extract('year', Expense.added_at).label('year'), - extract('month', Expense.added_at).label('month'), - func.sum(Expense.amount).label('total') + extract("year", Expense.added_at).label("year"), + extract("month", Expense.added_at).label("month"), + func.sum(Expense.amount).label("total"), ) .filter(Expense.added_at >= start_date, Expense.added_at <= end_date) - .group_by('year', 'month') - .order_by('year', 'month') + .group_by("year", "month") + .order_by("year", "month") .all() ) @@ -1105,138 +1296,159 @@ def admin_expenses_data(): labels.append(label) expenses.append(round(row.total, 2)) - response = make_response(jsonify({'labels': labels, 'expenses': expenses})) - response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" + response = make_response(jsonify({"labels": labels, "expenses": expenses})) + response.headers["Cache-Control"] = ( + "no-store, no-cache, must-revalidate, max-age=0" + ) return response - if range_type == 'monthly': + if range_type == "monthly": for i in range(11, -1, -1): - year = (now - timedelta(days=i*30)).year - month = (now - timedelta(days=i*30)).month + year = (now - timedelta(days=i * 30)).year + month = (now - timedelta(days=i * 30)).month label = f"{month:02d}/{year}" labels.append(label) month_sum = ( db.session.query(func.sum(Expense.amount)) - .filter(extract('year', Expense.added_at) == year) - .filter(extract('month', Expense.added_at) == month) - .scalar() or 0 + .filter(extract("year", Expense.added_at) == year) + .filter(extract("month", Expense.added_at) == month) + .scalar() + or 0 ) expenses.append(round(month_sum, 2)) - elif range_type == 'quarterly': + elif range_type == "quarterly": for i in range(3, -1, -1): - quarter_start = now - timedelta(days=i*90) + quarter_start = now - timedelta(days=i * 90) year = quarter_start.year quarter = (quarter_start.month - 1) // 3 + 1 label = f"Q{quarter}/{year}" quarter_sum = ( db.session.query(func.sum(Expense.amount)) - .filter(extract('year', Expense.added_at) == year) - .filter((extract('month', Expense.added_at) - 1)//3 + 1 == quarter) - .scalar() or 0 + .filter(extract("year", Expense.added_at) == year) + .filter((extract("month", Expense.added_at) - 1) // 3 + 1 == quarter) + .scalar() + or 0 ) labels.append(label) expenses.append(round(quarter_sum, 2)) - elif range_type == 'halfyearly': + elif range_type == "halfyearly": for i in range(1, -1, -1): - half_start = now - timedelta(days=i*180) + half_start = now - timedelta(days=i * 180) year = half_start.year half = 1 if half_start.month <= 6 else 2 label = f"H{half}/{year}" half_sum = ( db.session.query(func.sum(Expense.amount)) - .filter(extract('year', Expense.added_at) == year) + .filter(extract("year", Expense.added_at) == year) .filter( - (extract('month', Expense.added_at) <= 6) if half == 1 else (extract('month', Expense.added_at) > 6) + (extract("month", Expense.added_at) <= 6) + if half == 1 + else (extract("month", Expense.added_at) > 6) ) - .scalar() or 0 + .scalar() + or 0 ) labels.append(label) expenses.append(round(half_sum, 2)) - elif range_type == 'yearly': + elif range_type == "yearly": for i in range(4, -1, -1): year = now.year - i label = str(year) year_sum = ( db.session.query(func.sum(Expense.amount)) - .filter(extract('year', Expense.added_at) == year) - .scalar() or 0 + .filter(extract("year", Expense.added_at) == year) + .scalar() + or 0 ) labels.append(label) expenses.append(round(year_sum, 2)) - response = make_response(jsonify({'labels': labels, 'expenses': expenses})) + response = make_response(jsonify({"labels": labels, "expenses": expenses})) response.headers["Cache-Control"] = "no-store, no-cache" return response -@app.route('/admin/promote_user/') + +@app.route("/admin/promote_user/") @login_required @admin_required def promote_user(user_id): user = User.query.get_or_404(user_id) user.is_admin = True db.session.commit() - flash(f'Użytkownik {user.username} został ustawiony jako admin.', 'success') - return redirect(url_for('list_users')) + flash(f"Użytkownik {user.username} został ustawiony jako admin.", "success") + return redirect(url_for("list_users")) -@app.route('/admin/demote_user/') + +@app.route("/admin/demote_user/") @login_required @admin_required def demote_user(user_id): user = User.query.get_or_404(user_id) - + if user.id == current_user.id: - flash('Nie możesz zdegradować samego siebie!', 'danger') - return redirect(url_for('list_users')) + flash("Nie możesz zdegradować samego siebie!", "danger") + return redirect(url_for("list_users")) admin_count = User.query.filter_by(is_admin=True).count() if admin_count <= 1 and user.is_admin: - flash('Nie można zdegradować. Musi pozostać co najmniej jeden administrator.', 'danger') - return redirect(url_for('list_users')) + flash( + "Nie można zdegradować. Musi pozostać co najmniej jeden administrator.", + "danger", + ) + return redirect(url_for("list_users")) user.is_admin = False db.session.commit() - flash(f'Użytkownik {user.username} został zdegradowany.', 'success') - return redirect(url_for('list_users')) + flash(f"Użytkownik {user.username} został zdegradowany.", "success") + return redirect(url_for("list_users")) -@app.route('/healthcheck') + +@app.route("/healthcheck") def healthcheck(): header_token = request.headers.get("X-Internal-Check") - correct_token = app.config.get('HEALTHCHECK_TOKEN') + correct_token = app.config.get("HEALTHCHECK_TOKEN") if header_token != correct_token: abort(404) - return 'OK', 200 + return "OK", 200 + # ========================================================================================= # SOCKET.IO # ========================================================================================= -@socketio.on('delete_item') + +@socketio.on("delete_item") def handle_delete_item(data): - item = Item.query.get(data['item_id']) + item = Item.query.get(data["item_id"]) if item: list_id = item.list_id db.session.delete(item) db.session.commit() - emit('item_deleted', {'item_id': item.id}, to=str(item.list_id)) + emit("item_deleted", {"item_id": item.id}, to=str(item.list_id)) purchased_count, total_count, percent = get_progress(list_id) - emit('progress_updated', { - 'purchased_count': purchased_count, - 'total_count': total_count, - 'percent': percent - }, to=str(list_id)) + emit( + "progress_updated", + { + "purchased_count": purchased_count, + "total_count": total_count, + "percent": percent, + }, + to=str(list_id), + ) -@socketio.on('edit_item') + +@socketio.on("edit_item") def handle_edit_item(data): - item = Item.query.get(data['item_id']) - new_name = data['new_name'] - new_quantity = data.get('new_quantity', item.quantity) + item = Item.query.get(data["item_id"]) + new_name = data["new_name"] + new_quantity = data.get("new_quantity", item.quantity) if item and new_name.strip(): item.name = new_name.strip() @@ -1252,45 +1464,48 @@ def handle_edit_item(data): db.session.commit() - emit('item_edited', { - 'item_id': item.id, - 'new_name': item.name, - 'new_quantity': item.quantity - }, to=str(item.list_id)) + emit( + "item_edited", + {"item_id": item.id, "new_name": item.name, "new_quantity": item.quantity}, + to=str(item.list_id), + ) -@socketio.on('join_list') + +@socketio.on("join_list") def handle_join(data): global active_users - room = str(data['room']) - username = data.get('username', 'Gość') + room = str(data["room"]) + username = data.get("username", "Gość") join_room(room) if room not in active_users: active_users[room] = set() active_users[room].add(username) - shopping_list = ShoppingList.query.get(int(data['room'])) + shopping_list = ShoppingList.query.get(int(data["room"])) list_title = shopping_list.title if shopping_list else "Twoja lista" - emit('user_joined', {'username': username}, to=room) - emit('user_list', {'users': list(active_users[room])}, to=room) - emit('joined_confirmation', {'room': room, 'list_title': list_title}) + emit("user_joined", {"username": username}, to=room) + emit("user_list", {"users": list(active_users[room])}, to=room) + emit("joined_confirmation", {"room": room, "list_title": list_title}) -@socketio.on('disconnect') + +@socketio.on("disconnect") def handle_disconnect(sid): global active_users username = current_user.username if current_user.is_authenticated else "Gość" for room, users in active_users.items(): if username in users: users.remove(username) - emit('user_left', {'username': username}, to=room) - emit('user_list', {'users': list(users)}, to=room) + emit("user_left", {"username": username}, to=room) + emit("user_list", {"users": list(users)}, to=room) -@socketio.on('add_item') + +@socketio.on("add_item") def handle_add_item(data): - list_id = data['list_id'] - name = data['name'] - quantity = data.get('quantity', 1) + list_id = data["list_id"] + name = data["name"] + quantity = data.get("quantity", 1) try: quantity = int(quantity) @@ -1303,7 +1518,7 @@ def handle_add_item(data): list_id=list_id, name=name, quantity=quantity, - added_by=current_user.id if current_user.is_authenticated else None + added_by=current_user.id if current_user.is_authenticated else None, ) db.session.add(new_item) @@ -1313,24 +1528,36 @@ def handle_add_item(data): db.session.commit() - emit('item_added', { - 'id': new_item.id, - 'name': new_item.name, - 'quantity': new_item.quantity, - 'added_by': current_user.username if current_user.is_authenticated else 'Gość' - }, to=str(list_id), include_self=True) + emit( + "item_added", + { + "id": new_item.id, + "name": new_item.name, + "quantity": new_item.quantity, + "added_by": ( + current_user.username if current_user.is_authenticated else "Gość" + ), + }, + to=str(list_id), + include_self=True, + ) purchased_count, total_count, percent = get_progress(list_id) - emit('progress_updated', { - 'purchased_count': purchased_count, - 'total_count': total_count, - 'percent': percent - }, to=str(list_id)) + emit( + "progress_updated", + { + "purchased_count": purchased_count, + "total_count": total_count, + "percent": percent, + }, + to=str(list_id), + ) -@socketio.on('check_item') + +@socketio.on("check_item") def handle_check_item(data): - item = Item.query.get(data['item_id']) + item = Item.query.get(data["item_id"]) if item: item.purchased = True item.purchased_at = datetime.utcnow() @@ -1338,16 +1565,21 @@ def handle_check_item(data): purchased_count, total_count, percent = get_progress(item.list_id) - emit('item_checked', {'item_id': item.id}, to=str(item.list_id)) - emit('progress_updated', { - 'purchased_count': purchased_count, - 'total_count': total_count, - 'percent': percent - }, to=str(item.list_id)) + emit("item_checked", {"item_id": item.id}, to=str(item.list_id)) + emit( + "progress_updated", + { + "purchased_count": purchased_count, + "total_count": total_count, + "percent": percent, + }, + to=str(item.list_id), + ) -@socketio.on('uncheck_item') + +@socketio.on("uncheck_item") def handle_uncheck_item(data): - item = Item.query.get(data['item_id']) + item = Item.query.get(data["item_id"]) if item: item.purchased = False item.purchased_at = None @@ -1355,55 +1587,93 @@ def handle_uncheck_item(data): purchased_count, total_count, percent = get_progress(item.list_id) - emit('item_unchecked', {'item_id': item.id}, to=str(item.list_id)) - emit('progress_updated', { - 'purchased_count': purchased_count, - 'total_count': total_count, - 'percent': percent - }, to=str(item.list_id)) + emit("item_unchecked", {"item_id": item.id}, to=str(item.list_id)) + emit( + "progress_updated", + { + "purchased_count": purchased_count, + "total_count": total_count, + "percent": percent, + }, + to=str(item.list_id), + ) -@socketio.on('request_full_list') + +@socketio.on("request_full_list") def handle_request_full_list(data): - list_id = data['list_id'] + list_id = data["list_id"] items = Item.query.filter_by(list_id=list_id).all() items_data = [] for item in items: - items_data.append({ - 'id': item.id, - 'name': item.name, - 'quantity': item.quantity, - 'purchased': item.purchased, - 'note': item.note or '' - }) + items_data.append( + { + "id": item.id, + "name": item.name, + "quantity": item.quantity, + "purchased": item.purchased if not item.not_purchased else False, + "not_purchased": item.not_purchased, + "note": item.note or "", + } + ) - emit('full_list', {'items': items_data}, to=request.sid) + emit("full_list", {"items": items_data}, to=request.sid) -@socketio.on('update_note') + +@socketio.on("update_note") def handle_update_note(data): - item_id = data['item_id'] - note = data['note'] + item_id = data["item_id"] + note = data["note"] item = Item.query.get(item_id) if item: item.note = note - db.session.commit() - emit('note_updated', {'item_id': item_id, 'note': note}, to=str(item.list_id)) + db.session.commit() + emit("note_updated", {"item_id": item_id, "note": note}, to=str(item.list_id)) -@socketio.on('add_expense') + +@socketio.on("add_expense") def handle_add_expense(data): - list_id = data['list_id'] - amount = data['amount'] + list_id = data["list_id"] + amount = data["amount"] new_expense = Expense(list_id=list_id, amount=amount) db.session.add(new_expense) db.session.commit() - total = db.session.query(func.sum(Expense.amount)).filter_by(list_id=list_id).scalar() or 0 + total = ( + db.session.query(func.sum(Expense.amount)).filter_by(list_id=list_id).scalar() + or 0 + ) + + emit("expense_added", {"amount": amount, "total": total}, to=str(list_id)) + + +@socketio.on("mark_not_purchased") +def handle_mark_not_purchased(data): + item = Item.query.get(data["item_id"]) + reason = data.get("reason", "") + if item: + item.not_purchased = True + item.not_purchased_reason = reason + db.session.commit() + emit( + "item_marked_not_purchased", + {"item_id": item.id, "reason": reason}, + to=str(item.list_id), + ) + + +@socketio.on("unmark_not_purchased") +def handle_unmark_not_purchased(data): + item = Item.query.get(data["item_id"]) + if item: + item.not_purchased = False + item.purchased = False + item.purchased_at = None + item.not_purchased_reason = None + db.session.commit() + emit("item_unmarked_not_purchased", {"item_id": item.id}, to=str(item.list_id)) - emit('expense_added', { - 'amount': amount, - 'total': total - }, to=str(list_id)) """ @socketio.on('receipt_uploaded') def handle_receipt_uploaded(data): @@ -1414,10 +1684,12 @@ def handle_receipt_uploaded(data): 'url': url }, to=str(list_id), include_self=False) """ -@app.cli.command('create_db') + +@app.cli.command("create_db") def create_db(): db.create_all() - print('Database created.') + print("Database created.") -if __name__ == '__main__': - socketio.run(app, host='0.0.0.0', port=8000, debug=True) \ No newline at end of file + +if __name__ == "__main__": + socketio.run(app, host="0.0.0.0", port=8000, debug=True) diff --git a/static/js/functions.js b/static/js/functions.js index 45f6811..e88c39a 100644 --- a/static/js/functions.js +++ b/static/js/functions.js @@ -254,6 +254,14 @@ function toggleVisibility(listId) { }); } +function markNotPurchasedModal(e, id) { + e.stopPropagation(); + const reason = prompt("Podaj powód oznaczenia jako niekupione:"); + if (reason !== null) { + socket.emit('mark_not_purchased', { item_id: id, reason: reason }); + } +} + function showToast(message, type = 'primary') { const toastContainer = document.getElementById('toast-container'); const toast = document.createElement('div'); @@ -278,7 +286,7 @@ function isListDifferent(oldItems, newItems) { return false; } -function updateListSmoothly(newItems) { +/* function updateListSmoothly(newItems) { const itemsContainer = document.getElementById('items'); const existingItemsMap = new Map(); @@ -354,6 +362,81 @@ function updateListSmoothly(newItems) { itemsContainer.innerHTML = ''; itemsContainer.appendChild(fragment); + updateProgressBar(); + toggleEmptyPlaceholder(); + applyHidePurchased(); +} */ + +function updateListSmoothly(newItems) { + const itemsContainer = document.getElementById('items'); + const existingItemsMap = new Map(); + + Array.from(itemsContainer.querySelectorAll('li')).forEach(li => { + const id = parseInt(li.id.replace('item-', ''), 10); + existingItemsMap.set(id, li); + }); + + const fragment = document.createDocumentFragment(); + + newItems.forEach(item => { + // 🔥 Logujemy każdy item + console.log('Item:', item.name, 'Purchased:', item.purchased, 'Not purchased:', item.not_purchased); + + let li = existingItemsMap.get(item.id); + let quantityBadge = ''; + if (item.quantity && item.quantity > 1) { + quantityBadge = `x${item.quantity}`; + } + + if (!li) { + li = document.createElement('li'); + li.className = `list-group-item d-flex justify-content-between align-items-center flex-wrap clickable-item`; + li.id = `item-${item.id}`; + } + + // Ustaw klasy tła + li.className = `list-group-item d-flex justify-content-between align-items-center flex-wrap clickable-item ${ + item.purchased ? 'bg-success text-white' : + item.not_purchased ? 'bg-warning text-dark' : 'item-not-checked' + }`; + + // HTML wewnętrzny + li.innerHTML = ` +
+ ${!item.not_purchased ? ` + + ` : ` + 🚫 + `} + ${item.name} ${quantityBadge} + ${item.note ? `[ ${item.note} ]` : ''} +
+
+ ${item.not_purchased ? ` + + ` : ` + + + `} +
+ `; + + fragment.appendChild(li); + }); + + itemsContainer.innerHTML = ''; + itemsContainer.appendChild(fragment); + updateProgressBar(); toggleEmptyPlaceholder(); applyHidePurchased(); diff --git a/static/js/live.js b/static/js/live.js index bed3dbb..4bae47b 100644 --- a/static/js/live.js +++ b/static/js/live.js @@ -225,4 +225,8 @@ function setupList(listId, username) { window.LIST_ID = listId; window.usernameForReconnect = username; +} + +function unmarkNotPurchased(itemId) { + socket.emit('unmark_not_purchased', { item_id: itemId }); } \ No newline at end of file diff --git a/static/js/sockets.js b/static/js/sockets.js index 8cc9027..18f418d 100644 --- a/static/js/sockets.js +++ b/static/js/sockets.js @@ -119,4 +119,12 @@ socket.on('full_list', function (data) { showToast('Lista została zaktualizowana', 'info'); } didReceiveFirstFullList = true; +}); + +socket.on('item_marked_not_purchased', data => { + socket.emit('request_full_list', { list_id: window.LIST_ID }); +}); + +socket.on('item_unmarked_not_purchased', data => { + socket.emit('request_full_list', { list_id: window.LIST_ID }); }); \ No newline at end of file diff --git a/templates/list.html b/templates/list.html index 3562ab7..b0f9838 100644 --- a/templates/list.html +++ b/templates/list.html @@ -87,9 +87,16 @@ Lista: {{ list.title }}
    {% for item in items %} -
  • -
    - +
  • + +
    + + + {{ item.name }} {% if item.quantity and item.quantity > 1 %} diff --git a/templates/list_share.html b/templates/list_share.html index 052356a..2897e47 100644 --- a/templates/list_share.html +++ b/templates/list_share.html @@ -27,8 +27,16 @@
      {% for item in items %} -
    • - +
    • + +
      + + + {{ item.name }} {% if item.quantity and item.quantity > 1 %} @@ -40,10 +48,25 @@ [ {{ item.note }} ] {% endif %}
      - +
      + {% if item.not_purchased %} + + {% else %} + + + {% endif %} +
      + +
    • {% else %}
    • Date: Tue, 15 Jul 2025 23:05:21 +0200 Subject: [PATCH 02/13] funckja niekupione --- alters.txt | 3 +++ app.py | 29 +++++++++++++++++++++++- templates/admin/edit_list.html | 41 +++++++++++++++++++++++++++++----- 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/alters.txt b/alters.txt index fffe528..28fe56e 100644 --- a/alters.txt +++ b/alters.txt @@ -31,3 +31,6 @@ ALTER TABLE item ADD COLUMN quantity INTEGER DEFAULT 1; #licznik najczesciej kupowanych reczy ALTER TABLE suggested_product ADD COLUMN usage_count INTEGER DEFAULT 0; +#funkcja niekupione +ALTER TABLE item ADD COLUMN not_purchased_reason TEXT; +ALTER TABLE item ADD COLUMN not_purchased BOOLEAN DEFAULT 0; diff --git a/app.py b/app.py index c9c34d2..cae872b 100644 --- a/app.py +++ b/app.py @@ -1186,7 +1186,33 @@ def edit_list(list_id): flash("Nie znaleziono produktu", "danger") return redirect(url_for("edit_list", list_id=list_id)) - # Przekazanie receipts do szablonu + elif action == "mark_not_purchased": + item_id = request.form.get("item_id") + item = Item.query.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") + return redirect(url_for("edit_list", list_id=list_id)) + + elif action == "unmark_not_purchased": + item_id = request.form.get("item_id") + item = Item.query.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") + return redirect(url_for("edit_list", list_id=list_id)) + return render_template( "admin/edit_list.html", list=l, @@ -1198,6 +1224,7 @@ def edit_list(list_id): ) + @app.route("/admin/products") @login_required @admin_required diff --git a/templates/admin/edit_list.html b/templates/admin/edit_list.html index 0159ef2..400a832 100644 --- a/templates/admin/edit_list.html +++ b/templates/admin/edit_list.html @@ -88,21 +88,52 @@ {% if item.purchased %} ✔️ Kupiony + {% elif item.not_purchased %} + ⚠️ Nie kupione {% else %} Nieoznaczony {% endif %} -
      + - {% if item.purchased %} - - {% else %} - + {% if not item.not_purchased %} + + + + {% if item.purchased %} + + {% else %} + + {% endif %} +
      {% endif %} + +
      + + + +
      + + {% if item.not_purchased %} +
      + + + +
      + + {% if item.not_purchased_reason %} +
      + Powód: {{ item.not_purchased_reason }} +
      + {% endif %} + {% endif %} + + +
      From 9dcd144b34e2179bc973f2675af5542993a88be6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Tue, 15 Jul 2025 23:27:54 +0200 Subject: [PATCH 03/13] funckja niekupione - poprawki w szablonie i backendzie --- app.py | 12 +++++++++++- templates/list.html | 3 +++ templates/list_share.html | 3 +++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/app.py b/app.py index cae872b..a68b6f7 100644 --- a/app.py +++ b/app.py @@ -723,6 +723,7 @@ def all_products(): top_products_query.order_by( SuggestedProduct.usage_count.desc(), SuggestedProduct.name.asc() ) + .distinct(SuggestedProduct.name) .limit(20) .all() ) @@ -739,7 +740,16 @@ def all_products(): all_names = top_names + [s.name for s in rest_products] - return {"allproducts": all_names} + 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) + + return {"allproducts": unique_names} + """ @app.route('/upload_receipt/', methods=['POST']) diff --git a/templates/list.html b/templates/list.html index b0f9838..382a300 100644 --- a/templates/list.html +++ b/templates/list.html @@ -106,6 +106,9 @@ Lista: {{ list.title }} {% if item.note %} [ {{ item.note }} ] {% endif %} + {% if item.not_purchased_reason %} + [ Powód: {{ item.not_purchased_reason }} ] + {% endif %}
    {% if item.not_purchased %} From 53394469deb1a9cebb461d3e635c9834e9047bbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Tue, 15 Jul 2025 23:55:50 +0200 Subject: [PATCH 04/13] poprawki w js --- app.py | 3 ++ static/js/functions.js | 27 +++++++++---- static/js/notes.js | 8 ++-- templates/list.html | 66 +++++++++++++++++++++----------- templates/list_share.html | 79 +++++++++++++++++++++------------------ 5 files changed, 112 insertions(+), 71 deletions(-) diff --git a/app.py b/app.py index a68b6f7..7d8498b 100644 --- a/app.py +++ b/app.py @@ -650,6 +650,7 @@ def view_list(list_id): percent=percent, expenses=expenses, total_expense=total_expense, + is_share=False ) @@ -675,6 +676,7 @@ def shared_list(token=None, list_id=None): receipt_files=receipt_files, expenses=expenses, total_expense=total_expense, + is_share=True ) @@ -1650,6 +1652,7 @@ def handle_request_full_list(data): "quantity": item.quantity, "purchased": item.purchased if not item.not_purchased else False, "not_purchased": item.not_purchased, + 'not_purchased_reason': item.not_purchased_reason, "note": item.note or "", } ) diff --git a/static/js/functions.js b/static/js/functions.js index e88c39a..8e57518 100644 --- a/static/js/functions.js +++ b/static/js/functions.js @@ -379,9 +379,6 @@ function updateListSmoothly(newItems) { const fragment = document.createDocumentFragment(); newItems.forEach(item => { - // 🔥 Logujemy każdy item - console.log('Item:', item.name, 'Purchased:', item.purchased, 'Not purchased:', item.not_purchased); - let li = existingItemsMap.get(item.id); let quantityBadge = ''; if (item.quantity && item.quantity > 1) { @@ -394,7 +391,7 @@ function updateListSmoothly(newItems) { li.id = `item-${item.id}`; } - // Ustaw klasy tła + // Klasy tła li.className = `list-group-item d-flex justify-content-between align-items-center flex-wrap clickable-item ${ item.purchased ? 'bg-success text-white' : item.not_purchased ? 'bg-warning text-dark' : 'item-not-checked' @@ -411,6 +408,7 @@ function updateListSmoothly(newItems) { `} ${item.name} ${quantityBadge} ${item.note ? `[ ${item.note} ]` : ''} + ${item.not_purchased_reason ? `[ Powód: ${item.not_purchased_reason} ]` : ''}
    ${item.not_purchased ? ` @@ -423,11 +421,23 @@ function updateListSmoothly(newItems) { onclick="markNotPurchasedModal(event, ${item.id})"> ⚠️ - + ${window.IS_SHARE ? ` + + ` : ''} `} + ${!window.IS_SHARE ? ` + + + ` : ''}
    `; @@ -442,6 +452,7 @@ function updateListSmoothly(newItems) { applyHidePurchased(); } + document.addEventListener("DOMContentLoaded", function() { const receiptSection = document.getElementById("receiptSection"); const toggleBtn = document.querySelector('[data-bs-target="#receiptSection"]'); diff --git a/static/js/notes.js b/static/js/notes.js index 3ac767c..5899c09 100644 --- a/static/js/notes.js +++ b/static/js/notes.js @@ -1,13 +1,13 @@ let currentItemId = null; -function openNoteModal(event, itemId) { +window.openNoteModal = function (event, itemId) { event.stopPropagation(); currentItemId = itemId; - const noteEl = document.querySelector(`#item-${itemId} small`); - document.getElementById('noteText').value = noteEl ? noteEl.innerText : ""; + const noteEl = document.querySelector(`#item-${itemId} small.text-danger`); + document.getElementById('noteText').value = noteEl ? noteEl.innerText.replace(/\[|\]|Powód:/g, "").trim() : ""; const modal = new bootstrap.Modal(document.getElementById('noteModal')); modal.show(); -} +}; function submitNote(e) { e.preventDefault(); diff --git a/templates/list.html b/templates/list.html index 382a300..a9583cd 100644 --- a/templates/list.html +++ b/templates/list.html @@ -87,40 +87,58 @@ Lista: {{ list.title }}
      {% for item in items %} -
    • +
    • -
      +
      + {% if list.is_archived or item.not_purchased %}disabled{% endif %}> + + + {{ item.name }} + {% if item.quantity and item.quantity > 1 %} + x{{ item.quantity }} + {% endif %} + - - {{ item.name }} - {% if item.quantity and item.quantity > 1 %} - x{{ item.quantity }} - {% endif %} - {% if item.note %} - [ {{ item.note }} ] + [ {{ item.note }} ] {% endif %} + {% if item.not_purchased_reason %} - [ Powód: {{ item.not_purchased_reason }} ] + [ Powód: {{ item.not_purchased_reason }} ] {% endif %}
      -
      - - + +
      + {% if item.not_purchased %} + + {% else %} + + {% endif %} + + {% if not is_share %} + + + {% endif %}
    • + {% else %}
    • @@ -186,11 +204,15 @@ Lista: {{ list.title }} {% block scripts %} + + {% endblock %} {% endblock %} diff --git a/templates/list_share.html b/templates/list_share.html index 9b3123f..8bb5ae5 100644 --- a/templates/list_share.html +++ b/templates/list_share.html @@ -27,50 +27,52 @@
        {% for item in items %} -
      • -
        +
      • - +
        - - {{ item.name }} - {% if item.quantity and item.quantity > 1 %} - x{{ item.quantity }} + + + + {{ item.name }} + {% if item.quantity and item.quantity > 1 %} + x{{ item.quantity }} + {% endif %} + + + {% if item.note %} + [ {{ item.note }} ] {% endif %} - + {% if item.not_purchased_reason %} + [ Powód: {{ item.not_purchased_reason }} ] + {% endif %} +
        - {% if item.note %} - [ {{ item.note }} ] - {% endif %} - {% if item.not_purchased_reason %} - [ Powód: {{ item.not_purchased_reason }} ] - {% endif %} - -
        - {% if item.not_purchased %} - - {% else %} - - - {% endif %} -
        +
        + {% if item.not_purchased %} + + {% else %} + + + {% endif %} +
        +
      • - {% else %}
      • @@ -160,6 +162,9 @@ {% block scripts %} + From d3e50305a757c0bd748da23ec9c90e7ed43f0d4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Wed, 16 Jul 2025 09:04:01 +0200 Subject: [PATCH 05/13] poprawki w js --- static/css/style.css | 48 ++++-- static/js/clickable_row.js | 2 +- static/js/expenses.js | 84 +++++------ static/js/functions.js | 95 ------------ static/js/live.js | 42 +++--- static/js/notes.js | 1 + static/js/product_suggestion.js | 60 ++++---- static/js/receipt_section.js | 2 +- static/js/sockets.js | 88 +++++------ static/js/toggle_button.js | 4 +- static/js/user_management.js | 2 +- templates/admin/admin_panel.html | 218 ++++++++++++++------------- templates/admin/edit_list.html | 185 ++++++++++++----------- templates/admin/list_products.html | 33 ++-- templates/admin/receipts.html | 48 +++--- templates/admin/user_management.html | 25 ++- templates/base.html | 115 +++++++------- templates/edit_my_list.html | 2 +- templates/errors.html | 2 +- templates/list.html | 165 ++++++++++---------- templates/list_share.html | 171 +++++++++++---------- templates/login.html | 6 +- templates/main.html | 189 +++++++++++------------ templates/system_auth.html | 9 +- 24 files changed, 779 insertions(+), 817 deletions(-) diff --git a/static/css/style.css b/static/css/style.css index 878f7e0..0e32abb 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -3,6 +3,7 @@ width: 1.5em; height: 1.5em; } + .clickable-item { cursor: pointer; } @@ -38,7 +39,8 @@ top: 50%; left: 50%; transform: translate(-50%, -50%); - pointer-events: none; /* klikalne przyciski obok paska nie ucierpią */ + pointer-events: none; + /* klikalne przyciski obok paska nie ucierpią */ white-space: nowrap; } @@ -53,7 +55,7 @@ /* --- Styl przycisku wyboru pliku --- */ input[type="file"]::file-selector-button { - background-color: #225d36; + background-color: #225d36; color: #fff; border: none; padding: 0.5em 1em; @@ -69,16 +71,19 @@ input[type="file"]::file-selector-button { color: #eaffea !important; border-color: #174428 !important; } + .alert-danger { background-color: #7a1f23 !important; color: #ffeaea !important; border-color: #531417 !important; } + .alert-info { background-color: #1d3a4d !important; color: #eaf6ff !important; border-color: #152837 !important; } + .alert-warning { background-color: #665c1e !important; color: #fffbe5 !important; @@ -86,35 +91,50 @@ input[type="file"]::file-selector-button { } /* Badge - kolory pasujące do ciemnych alertów */ -.badge.bg-success, .badge.text-bg-success { +.badge.bg-success, +.badge.text-bg-success { background-color: #225d36 !important; color: #eaffea !important; } -.badge.bg-danger, .badge.text-bg-danger { + +.badge.bg-danger, +.badge.text-bg-danger { background-color: #7a1f23 !important; color: #ffeaea !important; } -.badge.bg-info, .badge.text-bg-info { + +.badge.bg-info, +.badge.text-bg-info { background-color: #1d3a4d !important; color: #eaf6ff !important; } -.badge.bg-warning, .badge.text-bg-warning { + +.badge.bg-warning, +.badge.text-bg-warning { background-color: #665c1e !important; color: #fffbe5 !important; } -.badge.bg-secondary, .badge.text-bg-secondary { + +.badge.bg-secondary, +.badge.text-bg-secondary { background-color: #343a40 !important; color: #e2e3e5 !important; } -.badge.bg-primary, .badge.text-bg-primary { + +.badge.bg-primary, +.badge.text-bg-primary { background-color: #184076 !important; color: #e6f0ff !important; } -.badge.bg-light, .badge.text-bg-light { + +.badge.bg-light, +.badge.text-bg-light { background-color: #444950 !important; color: #f8f9fa !important; } -.badge.bg-dark, .badge.text-bg-dark { + +.badge.bg-dark, +.badge.text-bg-dark { background-color: #181a1b !important; color: #f8f9fa !important; } @@ -157,6 +177,7 @@ input[type="checkbox"].large-checkbox:disabled::before { opacity: 0.5; cursor: not-allowed; } + input[type="checkbox"].large-checkbox:disabled { cursor: not-allowed; } @@ -223,6 +244,7 @@ input.form-control { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); @@ -232,11 +254,13 @@ input.form-control { #mass-add-list li.active { background: #198754 !important; color: #fff !important; - border: 1px solid #000000 !important; + border: 1px solid #000000 !important; } + #mass-add-list li { transition: background 0.2s; } + .quantity-input { width: 60px; background: #343a40; @@ -245,6 +269,7 @@ input.form-control { border-radius: 4px; text-align: center; } + .add-btn { margin-left: 10px; } @@ -256,6 +281,7 @@ input.form-control { justify-content: flex-end; gap: 4px; } + .list-group-item { display: flex; align-items: center; diff --git a/static/js/clickable_row.js b/static/js/clickable_row.js index a928fa5..955c80c 100644 --- a/static/js/clickable_row.js +++ b/static/js/clickable_row.js @@ -1,6 +1,6 @@ document.addEventListener("DOMContentLoaded", () => { document.querySelectorAll('.clickable-item').forEach(item => { - item.addEventListener('click', function(e) { + item.addEventListener('click', function (e) { if (!e.target.closest('button') && e.target.tagName.toLowerCase() !== 'input') { const checkbox = this.querySelector('input[type="checkbox"]'); diff --git a/static/js/expenses.js b/static/js/expenses.js index 7b6b569..9c4b338 100644 --- a/static/js/expenses.js +++ b/static/js/expenses.js @@ -1,4 +1,4 @@ -document.addEventListener("DOMContentLoaded", function() { +document.addEventListener("DOMContentLoaded", function () { let expensesChart = null; const rangeLabel = document.getElementById("chartRangeLabel"); @@ -8,57 +8,57 @@ document.addEventListener("DOMContentLoaded", function() { url += `&start_date=${startDate}&end_date=${endDate}`; } - fetch(url, {cache: "no-store"}) - .then(response => response.json()) - .then(data => { - const ctx = document.getElementById('expensesChart').getContext('2d'); + fetch(url, { cache: "no-store" }) + .then(response => response.json()) + .then(data => { + const ctx = document.getElementById('expensesChart').getContext('2d'); - if (expensesChart) { - expensesChart.destroy(); - } + if (expensesChart) { + expensesChart.destroy(); + } - expensesChart = new Chart(ctx, { - type: 'bar', - data: { - labels: data.labels, - datasets: [{ - label: 'Suma wydatków [PLN]', - data: data.expenses, - backgroundColor: '#0d6efd' - }] - }, - options: { - scales: { - y: { - beginAtZero: true + expensesChart = new Chart(ctx, { + type: 'bar', + data: { + labels: data.labels, + datasets: [{ + label: 'Suma wydatków [PLN]', + data: data.expenses, + backgroundColor: '#0d6efd' + }] + }, + options: { + scales: { + y: { + beginAtZero: true + } } } + }); + + if (startDate && endDate) { + rangeLabel.textContent = `Widok: własny zakres (${startDate} → ${endDate})`; + } else { + let labelText = ""; + if (range === "monthly") labelText = "Widok: miesięczne"; + else if (range === "quarterly") labelText = "Widok: kwartalne"; + else if (range === "halfyearly") labelText = "Widok: półroczne"; + else if (range === "yearly") labelText = "Widok: roczne"; + rangeLabel.textContent = labelText; } + + }) + .catch(error => { + console.error("Błąd pobierania danych:", error); }); - - if (startDate && endDate) { - rangeLabel.textContent = `Widok: własny zakres (${startDate} → ${endDate})`; - } else { - let labelText = ""; - if (range === "monthly") labelText = "Widok: miesięczne"; - else if (range === "quarterly") labelText = "Widok: kwartalne"; - else if (range === "halfyearly") labelText = "Widok: półroczne"; - else if (range === "yearly") labelText = "Widok: roczne"; - rangeLabel.textContent = labelText; - } - - }) - .catch(error => { - console.error("Błąd pobierania danych:", error); - }); } - document.getElementById('loadExpensesBtn').addEventListener('click', function() { + document.getElementById('loadExpensesBtn').addEventListener('click', function () { loadExpenses(); }); document.querySelectorAll('.range-btn').forEach(btn => { - btn.addEventListener('click', function() { + btn.addEventListener('click', function () { document.querySelectorAll('.range-btn').forEach(b => b.classList.remove('active')); this.classList.add('active'); const range = this.getAttribute('data-range'); @@ -66,7 +66,7 @@ document.addEventListener("DOMContentLoaded", function() { }); }); - document.getElementById('customRangeBtn').addEventListener('click', function() { + document.getElementById('customRangeBtn').addEventListener('click', function () { const startDate = document.getElementById('startDate').value; const endDate = document.getElementById('endDate').value; if (startDate && endDate) { @@ -78,7 +78,7 @@ document.addEventListener("DOMContentLoaded", function() { }); }); -document.addEventListener("DOMContentLoaded", function() { +document.addEventListener("DOMContentLoaded", function () { const startDateInput = document.getElementById("startDate"); const endDateInput = document.getElementById("endDate"); diff --git a/static/js/functions.js b/static/js/functions.js index 8e57518..533a179 100644 --- a/static/js/functions.js +++ b/static/js/functions.js @@ -19,20 +19,6 @@ function updateItemState(itemId, isChecked) { applyHidePurchased(); } -/* function updateProgressBar() { - const items = document.querySelectorAll('#items li'); - const total = items.length; - const purchased = Array.from(items).filter(li => li.classList.contains('bg-success')).length; - const percent = total > 0 ? Math.round((purchased / total) * 100) : 0; - - const progressBar = document.getElementById('progress-bar'); - if (progressBar) { - progressBar.style.width = `${percent}%`; - progressBar.setAttribute('aria-valuenow', percent); - progressBar.textContent = `${percent}%`; - } -} */ - function updateProgressBar() { const items = document.querySelectorAll('#items li'); const total = items.length; @@ -286,87 +272,6 @@ function isListDifferent(oldItems, newItems) { return false; } -/* function updateListSmoothly(newItems) { - const itemsContainer = document.getElementById('items'); - const existingItemsMap = new Map(); - - Array.from(itemsContainer.querySelectorAll('li')).forEach(li => { - const id = parseInt(li.id.replace('item-', ''), 10); - existingItemsMap.set(id, li); - }); - - const fragment = document.createDocumentFragment(); - - newItems.forEach(item => { - let li = existingItemsMap.get(item.id); - let quantityBadge = ''; - if (item.quantity && item.quantity > 1) { - quantityBadge = `x${item.quantity}`; - } - - if (li) { - const checkbox = li.querySelector('input[type="checkbox"]'); - if (checkbox) { - checkbox.checked = item.purchased; - checkbox.disabled = false; - } - - li.classList.remove('bg-success', 'text-white', 'item-not-checked', 'opacity-50'); - if (item.purchased) { - li.classList.add('bg-success', 'text-white'); - } else { - li.classList.add('item-not-checked'); - } - - const nameSpan = li.querySelector(`#name-${item.id}`); - const expectedName = `${item.name} ${quantityBadge}`.trim(); - if (nameSpan && nameSpan.innerHTML.trim() !== expectedName) { - nameSpan.innerHTML = expectedName; - } - - let noteEl = li.querySelector('small'); - if (item.note) { - if (!noteEl) { - const newNote = document.createElement('small'); - newNote.className = 'text-danger ms-4'; - newNote.innerHTML = `[ ${item.note} ]`; - nameSpan.insertAdjacentElement('afterend', newNote); - } else { - noteEl.innerHTML = `[ ${item.note} ]`; - } - } else if (noteEl) { - noteEl.remove(); - } - - const sp = li.querySelector('.spinner-border'); - if (sp) sp.remove(); - - } else { - li = document.createElement('li'); - li.className = `list-group-item d-flex justify-content-between align-items-center flex-wrap ${item.purchased ? 'bg-success text-white' : 'item-not-checked'}`; - li.id = `item-${item.id}`; - - li.innerHTML = ` -
        - - ${item.name} ${quantityBadge} - ${item.note ? `[ ${item.note} ]` : ''} -
        - - `; - } - - fragment.appendChild(li); - }); - - itemsContainer.innerHTML = ''; - itemsContainer.appendChild(fragment); - - updateProgressBar(); - toggleEmptyPlaceholder(); - applyHidePurchased(); -} */ - function updateListSmoothly(newItems) { const itemsContainer = document.getElementById('items'); const existingItemsMap = new Map(); diff --git a/static/js/live.js b/static/js/live.js index 4bae47b..d48ae68 100644 --- a/static/js/live.js +++ b/static/js/live.js @@ -7,11 +7,11 @@ function toggleEmptyPlaceholder() { // prawdziwe
      • to te z data‑name lub id="item‑…" const hasRealItems = list.querySelector('li[data-name], li[id^="item-"]') !== null; - const placeholder = document.getElementById('empty-placeholder'); + const placeholder = document.getElementById('empty-placeholder'); if (!hasRealItems && !placeholder) { - const li = document.createElement('li'); - li.id = 'empty-placeholder'; + const li = document.createElement('li'); + li.id = 'empty-placeholder'; li.className = 'list-group-item bg-dark text-secondary text-center w-100'; li.textContent = 'Brak produktów w tej liście.'; list.appendChild(li); @@ -132,30 +132,30 @@ function setupList(listId, username) { const li = document.createElement('li'); li.className = 'list-group-item d-flex justify-content-between align-items-center flex-wrap item-not-checked'; li.id = `item-${data.id}`; - + let quantityBadge = ''; if (data.quantity && data.quantity > 1) { quantityBadge = `x${data.quantity}`; } li.innerHTML = ` -
        - - ${data.name} ${quantityBadge} -
        -
        - - -
        - `; +
        + + ${data.name} ${quantityBadge} +
        +
        + + +
        + `; -// #### WERSJA Z NAPISAMI #### -// -// - - document.getElementById('items').appendChild(li); - updateProgressBar(); - toggleEmptyPlaceholder(); + document.getElementById('items').prepend(li); }); socket.on('item_deleted', data => { @@ -168,7 +168,7 @@ function setupList(listId, username) { toggleEmptyPlaceholder(); }); - socket.on('progress_updated', function(data) { + socket.on('progress_updated', function (data) { const progressBar = document.getElementById('progress-bar'); if (progressBar) { progressBar.style.width = data.percent + '%'; diff --git a/static/js/notes.js b/static/js/notes.js index 5899c09..ed6a4c2 100644 --- a/static/js/notes.js +++ b/static/js/notes.js @@ -20,3 +20,4 @@ function submitNote(e) { modal.hide(); } } + diff --git a/static/js/product_suggestion.js b/static/js/product_suggestion.js index b6bec5f..cde7cf7 100644 --- a/static/js/product_suggestion.js +++ b/static/js/product_suggestion.js @@ -1,4 +1,4 @@ -document.addEventListener("DOMContentLoaded", function() { +document.addEventListener("DOMContentLoaded", function () { // Odśwież eventy document.querySelectorAll('.sync-btn').forEach(btn => { btn.replaceWith(btn.cloneNode(true)); @@ -9,7 +9,7 @@ document.addEventListener("DOMContentLoaded", function() { // Synchronizacja sugestii document.querySelectorAll('.sync-btn').forEach(btn => { - btn.addEventListener('click', function(e) { + btn.addEventListener('click', function (e) { e.preventDefault(); const itemId = this.getAttribute('data-item-id'); @@ -22,28 +22,28 @@ document.addEventListener("DOMContentLoaded", function() { 'X-Requested-With': 'XMLHttpRequest' } }) - .then(response => response.json()) - .then(data => { - showToast(data.message, data.success ? 'success' : 'danger'); + .then(response => response.json()) + .then(data => { + showToast(data.message, data.success ? 'success' : 'danger'); - if (data.success) { - button.innerText = '✅ Zsynchronizowano'; - button.classList.remove('btn-outline-primary'); - button.classList.add('btn-success'); - } else { + if (data.success) { + button.innerText = '✅ Zsynchronizowano'; + button.classList.remove('btn-outline-primary'); + button.classList.add('btn-success'); + } else { + button.disabled = false; + } + }) + .catch(() => { + showToast('Błąd synchronizacji', 'danger'); button.disabled = false; - } - }) - .catch(() => { - showToast('Błąd synchronizacji', 'danger'); - button.disabled = false; - }); + }); }); }); // Usuwanie sugestii document.querySelectorAll('.delete-suggestion-btn').forEach(btn => { - btn.addEventListener('click', function(e) { + btn.addEventListener('click', function (e) { e.preventDefault(); const suggestionId = this.getAttribute('data-suggestion-id'); @@ -56,21 +56,21 @@ document.addEventListener("DOMContentLoaded", function() { 'X-Requested-With': 'XMLHttpRequest' } }) - .then(response => response.json()) - .then(data => { - showToast(data.message, data.success ? 'success' : 'danger'); + .then(response => response.json()) + .then(data => { + showToast(data.message, data.success ? 'success' : 'danger'); - if (data.success) { - const row = button.closest('tr'); - if (row) row.remove(); - } else { + if (data.success) { + const row = button.closest('tr'); + if (row) row.remove(); + } else { + button.disabled = false; + } + }) + .catch(() => { + showToast('Błąd usuwania sugestii', 'danger'); button.disabled = false; - } - }) - .catch(() => { - showToast('Błąd usuwania sugestii', 'danger'); - button.disabled = false; - }); + }); }); }); }); diff --git a/static/js/receipt_section.js b/static/js/receipt_section.js index 6681440..9f474e9 100644 --- a/static/js/receipt_section.js +++ b/static/js/receipt_section.js @@ -1,4 +1,4 @@ -document.addEventListener("DOMContentLoaded", function() { +document.addEventListener("DOMContentLoaded", function () { const receiptSection = document.getElementById("receiptSection"); const toggleBtn = document.querySelector('[data-bs-target="#receiptSection"]'); diff --git a/static/js/sockets.js b/static/js/sockets.js index 18f418d..2a8f9a7 100644 --- a/static/js/sockets.js +++ b/static/js/sockets.js @@ -2,83 +2,83 @@ let didReceiveFirstFullList = false; // --- Automatyczny reconnect po powrocie do karty/przywróceniu internetu --- function reconnectIfNeeded() { - if (!socket.connected) { - socket.connect(); - } + if (!socket.connected) { + socket.connect(); + } } -document.addEventListener("visibilitychange", function() { - if (!document.hidden) { - reconnectIfNeeded(); - } +document.addEventListener("visibilitychange", function () { + if (!document.hidden) { + reconnectIfNeeded(); + } }); -window.addEventListener("focus", function() { - reconnectIfNeeded(); +window.addEventListener("focus", function () { + reconnectIfNeeded(); }); -window.addEventListener("online", function() { - reconnectIfNeeded(); +window.addEventListener("online", function () { + reconnectIfNeeded(); }); // --- Blokowanie checkboxów na czas reconnect --- function disableCheckboxes(disable) { - document.querySelectorAll('#items input[type="checkbox"]').forEach(cb => { - cb.disabled = disable; - }); + document.querySelectorAll('#items input[type="checkbox"]').forEach(cb => { + cb.disabled = disable; + }); } // --- Toasty przy rozłączeniu i połączeniu --- let firstConnect = true; let wasReconnected = false; // flaga do kontrolowania toasta -socket.on('connect', function() { - if (!firstConnect) { - //showToast('Połączono z serwerem!', 'info'); - disableCheckboxes(true); - wasReconnected = true; +socket.on('connect', function () { + if (!firstConnect) { + //showToast('Połączono z serwerem!', 'info'); + disableCheckboxes(true); + wasReconnected = true; - if (window.LIST_ID && window.usernameForReconnect) { - socket.emit('join_list', { room: window.LIST_ID, username: window.usernameForReconnect }); - } + if (window.LIST_ID && window.usernameForReconnect) { + socket.emit('join_list', { room: window.LIST_ID, username: window.usernameForReconnect }); } - firstConnect = false; + } + firstConnect = false; }); -socket.on('disconnect', function(reason) { - showToast('Utracono połączenie z serwerem...', 'warning'); - disableCheckboxes(true); +socket.on('disconnect', function (reason) { + showToast('Utracono połączenie z serwerem...', 'warning'); + disableCheckboxes(true); }); socket.off('joined_confirmation'); -socket.on('joined_confirmation', function(data) { - if (wasReconnected) { - showToast(`Lista: ${data.list_title} – ponownie dołączono.`, 'info'); - wasReconnected = false; - } - if (window.LIST_ID) { - socket.emit('request_full_list', { list_id: window.LIST_ID }); - } +socket.on('joined_confirmation', function (data) { + if (wasReconnected) { + showToast(`Lista: ${data.list_title} – ponownie dołączono.`, 'info'); + wasReconnected = false; + } + if (window.LIST_ID) { + socket.emit('request_full_list', { list_id: window.LIST_ID }); + } }); -socket.on('user_joined', function(data) { - showToast(`${data.username} dołączył do listy`, 'info'); +socket.on('user_joined', function (data) { + showToast(`${data.username} dołączył do listy`, 'info'); }); -socket.on('user_left', function(data) { - showToast(`${data.username} opuścił listę`, 'warning'); +socket.on('user_left', function (data) { + showToast(`${data.username} opuścił listę`, 'warning'); }); -socket.on('user_list', function(data) { - if (data.users.length > 0) { - const userList = data.users.join(', '); - showToast(`Obecni: ${userList}`, 'info'); - } +socket.on('user_list', function (data) { + if (data.users.length > 0) { + const userList = data.users.join(', '); + showToast(`Obecni: ${userList}`, 'info'); + } }); socket.on('receipt_added', function (data) { - + const gallery = document.getElementById("receiptGallery"); if (!gallery) return; diff --git a/static/js/toggle_button.js b/static/js/toggle_button.js index 5216365..d441f64 100644 --- a/static/js/toggle_button.js +++ b/static/js/toggle_button.js @@ -1,4 +1,4 @@ -document.addEventListener("DOMContentLoaded", function() { +document.addEventListener("DOMContentLoaded", function () { const toggleBtn = document.getElementById("tempToggle"); const hiddenInput = document.getElementById("temporaryHidden"); @@ -23,7 +23,7 @@ document.addEventListener("DOMContentLoaded", function() { updateToggle(active); // Obsługa kliknięcia - toggleBtn.addEventListener("click", function() { + toggleBtn.addEventListener("click", function () { active = !active; toggleBtn.setAttribute("data-active", active ? "1" : "0"); hiddenInput.value = active ? "1" : "0"; diff --git a/static/js/user_management.js b/static/js/user_management.js index 8327c93..eee0339 100644 --- a/static/js/user_management.js +++ b/static/js/user_management.js @@ -1,4 +1,4 @@ -document.addEventListener('DOMContentLoaded', function() { +document.addEventListener('DOMContentLoaded', function () { var resetPasswordModal = document.getElementById('resetPasswordModal'); resetPasswordModal.addEventListener('show.bs.modal', function (event) { var button = event.relatedTarget; diff --git a/templates/admin/admin_panel.html b/templates/admin/admin_panel.html index 44624a7..3d85102 100644 --- a/templates/admin/admin_panel.html +++ b/templates/admin/admin_panel.html @@ -10,7 +10,8 @@
      • {% else %} -
      • - Brak produktów w tej liście. -
      • +
      • + Brak produktów w tej liście. +
      • {% endfor %}
      @@ -156,8 +154,10 @@ Lista: {{ list.title }}
      - - + +
      @@ -170,17 +170,18 @@ Lista: {{ list.title }}
      {% if receipt_files %} - {% for file in receipt_files %} -
      - - - -
      - {% endfor %} + {% for file in receipt_files %} +
      + + + +
      + {% endfor %} {% else %} - + {% endif %}
      @@ -205,14 +206,14 @@ Lista: {{ list.title }} {% block scripts %} + - - {% endblock %} -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/templates/list_share.html b/templates/list_share.html index 8bb5ae5..cb0dec7 100644 --- a/templates/list_share.html +++ b/templates/list_share.html @@ -7,16 +7,16 @@ {% if list.is_archived %} - (Archiwalna) + (Archiwalna) {% endif %} {% if total_expense > 0 %} - - 💸 {{ '%.2f'|format(total_expense) }} PLN - + + 💸 {{ '%.2f'|format(total_expense) }} PLN + {% else %} - + {% endif %} @@ -28,106 +28,111 @@
        {% for item in items %} -
      • +
      • -
        +
        - + - - {{ item.name }} - {% if item.quantity and item.quantity > 1 %} - x{{ item.quantity }} - {% endif %} - - - {% if item.note %} - [ {{ item.note }} ] + + {{ item.name }} + {% if item.quantity and item.quantity > 1 %} + x{{ item.quantity }} {% endif %} - {% if item.not_purchased_reason %} - [ Powód: {{ item.not_purchased_reason }} ] - {% endif %} -
        + -
        - {% if item.not_purchased %} - - {% else %} - - - {% endif %} -
        -
      • + {% if item.note %} + [ {{ item.note }} ] + {% endif %} + {% if item.not_purchased_reason %} + [ Powód: {{ item.not_purchased_reason }} ] + {% endif %} + + +
        + {% if item.not_purchased %} + + {% else %} + + + {% endif %} +
        + {% else %} -
      • - Brak produktów w tej liście. -
      • +
      • + Brak produktów w tej liście. +
      • {% endfor %}
      {% if not list.is_archived %} -
      - - - -
      +
      + + + +
      {% endif %} {% if not list.is_archived %} -
      -
      💰 Dodaj wydatek
      -
      - - -
      +
      +
      💰 Dodaj wydatek
      +
      + + +
      {% endif %} -

      💸 Łącznie wydano: {{ '%.2f'|format(total_expense) }} PLN

      +

      💸 Łącznie wydano: {{ '%.2f'|format(total_expense) }} PLN

      -
      -{% set receipt_pattern = 'list_' ~ list.id %} + {% set receipt_pattern = 'list_' ~ list.id %} -
      📸 Paragony dodane do tej listy
      +
      📸 Paragony dodane do tej listy
      -
      - {% if receipt_files %} +
      + {% if receipt_files %} {% for file in receipt_files %} -
      - - - -
      +
      + + + +
      {% endfor %} - {% else %} + {% else %} - {% endif %} -
      + {% endif %} +
      -{% if not list.is_archived %} + {% if not list.is_archived %}
      📤 Dodaj zdjęcie paragonu
      -
      -
      -{% endif %} + {% endif %} @@ -150,7 +155,8 @@
    • + {% endfor %} +
    +{% else %} +

    Nie masz jeszcze żadnych list. Utwórz pierwszą, korzystając + z formularza powyżej!

    +{% endif %} {% endif %}

    Publiczne listy innych użytkowników

    {% if public_lists %} -
      - {% for l in public_lists %} - {% set purchased_count = l.purchased_count %} - {% set total_count = l.total_count %} - {% set percent = (purchased_count / total_count * 100) if total_count > 0 else 0 %} -
    • -
      - {{ l.title }} (Autor: {{ l.owner.username }}) - 📄 Otwórz -
      -
      -
      -
      - + {% for l in public_lists %} + {% set purchased_count = l.purchased_count %} + {% set total_count = l.total_count %} + {% set percent = (purchased_count / total_count * 100) if total_count > 0 else 0 %} +
    • +
      + {{ l.title }} (Autor: {{ l.owner.username }}) + 📄 Otwórz +
      +
      +
      +
      + - Produkty: {{ purchased_count }}/{{ total_count }} ({{ percent|round(0) }}%) - {% if l.total_expense > 0 %} - — 💸 {{ '%.2f'|format(l.total_expense) }} PLN - {% endif %} - -
      -
    • - {% endfor %} -
    + Produkty: {{ purchased_count }}/{{ total_count }} ({{ percent|round(0) }}%) + {% if l.total_expense > 0 %} + — 💸 {{ '%.2f'|format(l.total_expense) }} PLN + {% endif %} + + +
  • + {% endfor %} +
{% else %} -

Brak dostępnych list publicznych do wyświetlenia

+

Brak dostępnych list publicznych do wyświetlenia

{% endif %} -
    +
      {% for item in items %}
    • + {% if item.purchased %}bg-success text-white{% elif item.not_purchased %}bg-warning text-dark{% else %}item-not-checked{% endif %}">
      From 133b91073dc7127dba67fef451eda7d791ccf8c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Wed, 16 Jul 2025 23:07:58 +0200 Subject: [PATCH 07/13] nowe funkcja statystyk i poprawki --- app.py | 85 +++++++++++++++++++++++++++++++- static/js/user_expenses.js | 90 ++++++++++++++++++++++++++++++++++ templates/admin/edit_list.html | 15 +++++- templates/base.html | 15 +++--- templates/user_expenses.html | 84 +++++++++++++++++++++++++++++++ 5 files changed, 278 insertions(+), 11 deletions(-) create mode 100644 static/js/user_expenses.js create mode 100644 templates/user_expenses.html diff --git a/app.py b/app.py index e036b27..385045b 100644 --- a/app.py +++ b/app.py @@ -125,6 +125,7 @@ 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) with app.app_context(): @@ -614,7 +615,7 @@ def logout(): @login_required def create_list(): title = request.form.get("title") - is_temporary = "temporary" in request.form + is_temporary = request.form.get("temporary") == "1" token = generate_share_token(8) expires_at = datetime.utcnow() + timedelta(days=7) if is_temporary else None new_list = ShoppingList( @@ -654,6 +655,76 @@ def view_list(list_id): ) +@app.route("/user_expenses") +@login_required +def user_expenses(): + from sqlalchemy.orm import joinedload + + expenses = ( + Expense.query + .join(ShoppingList, Expense.list_id == ShoppingList.id) + .options(joinedload(Expense.list)) + .filter(ShoppingList.owner_id == current_user.id) + .order_by(Expense.added_at.desc()) + .all() + ) + + rows = [ + { + "title": e.list.title if e.list else "Nieznana", + "amount": e.amount, + "added_at": e.added_at + } + for e in expenses + ] + + return render_template("user_expenses.html", expense_table=rows) + + + +@app.route("/user/expenses_data") +@login_required +def user_expenses_data(): + range_type = request.args.get("range", "monthly") + start_date = request.args.get("start_date") + end_date = request.args.get("end_date") + + query = ( + Expense.query + .join(ShoppingList, Expense.list_id == ShoppingList.id) + .filter(ShoppingList.owner_id == current_user.id) + ) + + if start_date and end_date: + try: + start = datetime.strptime(start_date, "%Y-%m-%d") + end = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1) + query = query.filter(Expense.timestamp >= start, Expense.timestamp < end) + except ValueError: + return jsonify({"error": "Błędne daty"}), 400 + + expenses = query.all() + + grouped = defaultdict(float) + for e in expenses: + ts = e.added_at or datetime.utcnow() + 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] += e.amount + + labels = sorted(grouped) + data = [round(grouped[label], 2) for label in labels] + return jsonify({"labels": labels, "expenses": data}) + + @app.route("/share/") @app.route("/guest-list/") def shared_list(token=None, list_id=None): @@ -1095,7 +1166,6 @@ def edit_list(list_id): users = User.query.all() items = Item.query.filter_by(list_id=list_id).order_by(Item.id.desc()).all() - # Pobranie listy plików paragonów receipt_pattern = f"list_{list_id}_" all_files = os.listdir(app.config["UPLOAD_FOLDER"]) receipts = [f for f in all_files if f.startswith(receipt_pattern)] @@ -1108,6 +1178,8 @@ def edit_list(list_id): new_amount_str = request.form.get("amount") is_archived = "archived" in request.form is_public = "public" in request.form + is_temporary = "temporary" in request.form + expires_at_raw = request.form.get("expires_at") new_owner_id = request.form.get("owner_id") if new_title: @@ -1115,6 +1187,15 @@ def edit_list(list_id): l.is_archived = is_archived l.is_public = is_public + l.is_temporary = is_temporary + + if expires_at_raw: + try: + l.expires_at = datetime.strptime(expires_at_raw, "%Y-%m-%dT%H:%M") + except ValueError: + l.expires_at = None + else: + l.expires_at = None if new_owner_id: try: diff --git a/static/js/user_expenses.js b/static/js/user_expenses.js new file mode 100644 index 0000000..a029e70 --- /dev/null +++ b/static/js/user_expenses.js @@ -0,0 +1,90 @@ +document.addEventListener("DOMContentLoaded", function () { + let expensesChart = null; + const rangeLabel = document.getElementById("chartRangeLabel"); + + function loadExpenses(range = "monthly", startDate = null, endDate = null) { + let url = '/user/expenses_data?range=' + range; + if (startDate && endDate) { + url += `&start_date=${startDate}&end_date=${endDate}`; + } + + fetch(url, { cache: "no-store" }) + .then(response => response.json()) + .then(data => { + const ctx = document.getElementById('expensesChart').getContext('2d'); + + if (expensesChart) { + expensesChart.destroy(); + } + + expensesChart = new Chart(ctx, { + type: 'bar', + data: { + labels: data.labels, + datasets: [{ + label: 'Suma wydatków [PLN]', + data: data.expenses, + backgroundColor: '#0d6efd' + }] + }, + options: { + scales: { + y: { + beginAtZero: true + } + } + } + }); + + if (startDate && endDate) { + rangeLabel.textContent = `Widok: własny zakres (${startDate} → ${endDate})`; + } else { + let labelText = ""; + if (range === "monthly") labelText = "Widok: miesięczne"; + else if (range === "quarterly") labelText = "Widok: kwartalne"; + else if (range === "halfyearly") labelText = "Widok: półroczne"; + else if (range === "yearly") labelText = "Widok: roczne"; + rangeLabel.textContent = labelText; + } + + }) + .catch(error => { + console.error("Błąd pobierania danych:", error); + }); + } + + // Inicjalizacja zakresu dat + const startDateInput = document.getElementById("startDate"); + const endDateInput = document.getElementById("endDate"); + const today = new Date(); + const lastWeek = new Date(today); + lastWeek.setDate(today.getDate() - 7); + const formatDate = (d) => d.toISOString().split('T')[0]; + startDateInput.value = formatDate(lastWeek); + endDateInput.value = formatDate(today); + + // Załaduj początkowy widok + loadExpenses(); + + // Przycisk własnego zakresu + document.getElementById('customRangeBtn').addEventListener('click', function () { + const startDate = startDateInput.value; + const endDate = endDateInput.value; + if (startDate && endDate) { + document.querySelectorAll('.range-btn').forEach(b => b.classList.remove('active')); + loadExpenses('custom', startDate, endDate); + } else { + alert("Proszę wybrać obie daty!"); + } + }); + + // Zakresy predefiniowane + document.querySelectorAll('.range-btn').forEach(btn => { + btn.addEventListener('click', function () { + document.querySelectorAll('.range-btn').forEach(b => b.classList.remove('active')); + this.classList.add('active'); + const range = this.getAttribute('data-range'); + loadExpenses(range); + }); + }); +}); diff --git a/templates/admin/edit_list.html b/templates/admin/edit_list.html index ac59b53..8434638 100644 --- a/templates/admin/edit_list.html +++ b/templates/admin/edit_list.html @@ -46,6 +46,19 @@
      +
      + + +
      + +
      + + +
      + +
      - -
      diff --git a/templates/base.html b/templates/base.html index 0cb3554..146dbd8 100644 --- a/templates/base.html +++ b/templates/base.html @@ -35,21 +35,22 @@ {% endif %} {% endif %} - {% if not is_blocked %} -
      - {% if request.endpoint and request.endpoint != 'system_auth' %} - {% if current_user.is_authenticated and current_user.is_admin %} - ⚙️ Panel admina - {% endif %} + {% if not is_blocked and request.endpoint and request.endpoint != 'system_auth' %} +
      {% if current_user.is_authenticated %} + {% if current_user.is_admin %} + ⚙️ Panel admina + {% endif %} + 📊 Statystyki 🚪 Wyloguj {% else %} 🔑 Zaloguj {% endif %} - {% endif %}
      {% endif %} + +
      diff --git a/templates/user_expenses.html b/templates/user_expenses.html new file mode 100644 index 0000000..d3ebb1a --- /dev/null +++ b/templates/user_expenses.html @@ -0,0 +1,84 @@ +{% extends 'base.html' %} +{% block title %}📊 Twoje wydatki{% endblock %} + +{% block content %} +
      +

      📊 Statystyki wydatków

      + ← Powrót +
      + + + +
      + +
      +
      +
      + {% if expense_table %} +
      + {% for row in expense_table %} +
      +
      +
      +
      {{ row.title }}
      +

      💸 {{ '%.2f'|format(row.amount) }} PLN

      +

      📅 {{ row.added_at.strftime('%Y-%m-%d') }}

      +
      +
      +
      + {% endfor %} +
      + {% else %} +
      Brak wydatków do wyświetlenia.
      + {% endif %} +
      +
      +
      + + +
      +
      +
      +

      Widok: miesięczne

      + +
      +
      + +
      + + + + +
      + +
      +
      + +
      +
      + +
      +
      + +
      +
      +
      +
      +{% endblock %} + +{% block scripts %} + + +{% endblock %} \ No newline at end of file From 377e592f900741d01bee4ad5f9a8a7a6196ab622 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Thu, 17 Jul 2025 13:35:21 +0200 Subject: [PATCH 08/13] nowe funkcje i zmiany ux --- .env.example | 5 +- app.py | 272 +++++++++++++++++++++---------- config.py | 3 +- docker-compose.yml | 1 + templates/admin/admin_panel.html | 10 +- templates/admin/edit_list.html | 16 +- templates/base.html | 8 +- templates/edit_my_list.html | 35 +++- templates/main.html | 16 +- 9 files changed, 255 insertions(+), 111 deletions(-) diff --git a/.env.example b/.env.example index f8da826..ba00eff 100644 --- a/.env.example +++ b/.env.example @@ -20,4 +20,7 @@ AUTHORIZED_COOKIE_VALUE=twoj_wlasny_hash AUTH_COOKIE_MAX_AGE=86400 # dla compose -HEALTHCHECK_TOKEN=alamapsaikota123 \ No newline at end of file +HEALTHCHECK_TOKEN=alamapsaikota123 + +# sesja zalogowanego usera (domyślnie 7 dni) +SESSION_TIMEOUT_MINUTES=10080 \ No newline at end of file diff --git a/app.py b/app.py index 385045b..0a338ed 100644 --- a/app.py +++ b/app.py @@ -7,7 +7,8 @@ import sys import platform import psutil -from datetime import datetime, timedelta +from datetime import datetime, timedelta, UTC, timezone + from flask import ( Flask, render_template, @@ -46,17 +47,22 @@ from functools import wraps app = Flask(__name__) app.config.from_object(Config) -app.config["COMPRESS_ALGORITHM"] = ["zstd", "br", "gzip", "deflate"] -app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) + +ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"} +SQLALCHEMY_ECHO = True SYSTEM_PASSWORD = app.config.get("SYSTEM_PASSWORD", "changeme") DEFAULT_ADMIN_USERNAME = app.config.get("DEFAULT_ADMIN_USERNAME", "admin") DEFAULT_ADMIN_PASSWORD = app.config.get("DEFAULT_ADMIN_PASSWORD", "admin123") UPLOAD_FOLDER = app.config.get("UPLOAD_FOLDER", "uploads") -ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"} AUTHORIZED_COOKIE_VALUE = app.config.get("AUTHORIZED_COOKIE_VALUE", "80d31cdfe63539c9") AUTH_COOKIE_MAX_AGE = app.config.get("AUTH_COOKIE_MAX_AGE", 86400) HEALTHCHECK_TOKEN = app.config.get("HEALTHCHECK_TOKEN", "alamapsaikota1234") +SESSION_TIMEOUT_MINUTES = int(app.config.get("SESSION_TIMEOUT_MINUTES", 10080)) + +app.config["COMPRESS_ALGORITHM"] = ["zstd", "br", "gzip", "deflate"] +app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(minutes=SESSION_TIMEOUT_MINUTES) +app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) os.makedirs(UPLOAD_FOLDER, exist_ok=True) @@ -79,6 +85,10 @@ static_bp = Blueprint("static_bp", __name__) active_users = {} +def utcnow(): + return datetime.now(timezone.utc) + + class User(UserMixin, db.Model): id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(150), unique=True, nullable=False) @@ -93,7 +103,8 @@ class ShoppingList(db.Model): owner_id = db.Column(db.Integer, db.ForeignKey("user.id")) is_temporary = db.Column(db.Boolean, default=False) share_token = db.Column(db.String(64), unique=True, nullable=True) - expires_at = db.Column(db.DateTime, nullable=True) + # expires_at = db.Column(db.DateTime, nullable=True) + expires_at = db.Column(db.DateTime(timezone=True), nullable=True) owner = db.relationship("User", backref="lists", lazy=True) is_archived = db.Column(db.Boolean, default=False) is_public = db.Column(db.Boolean, default=True) @@ -103,7 +114,8 @@ class Item(db.Model): id = db.Column(db.Integer, primary_key=True) list_id = db.Column(db.Integer, db.ForeignKey("shopping_list.id")) name = db.Column(db.String(150), nullable=False) - added_at = db.Column(db.DateTime, default=datetime.utcnow) + # added_at = db.Column(db.DateTime, default=datetime.utcnow) + added_at = db.Column(db.DateTime, default=utcnow) added_by = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) purchased = db.Column(db.Boolean, default=False) purchased_at = db.Column(db.DateTime, nullable=True) @@ -271,7 +283,7 @@ def delete_receipts_for_list(list_id): print(f"Nie udało się usunąć pliku {filename}: {e}") -# zabezpieczenie logowani do systemy - błędne hasła +# zabezpieczenie logowani do systemu - błędne hasła def is_ip_blocked(ip): now = time.time() attempts = failed_login_attempts[ip] @@ -302,7 +314,8 @@ def attempts_remaining(ip): @login_manager.user_loader def load_user(user_id): - return User.query.get(int(user_id)) + # return User.query.get(int(user_id)) + return db.session.get(User, int(user_id)) @app.context_processor @@ -374,7 +387,8 @@ def file_mtime_filter(path): t = os.path.getmtime(path) return datetime.fromtimestamp(t) except Exception: - return datetime.utcnow() + # return datetime.utcnow() + return datetime.now(timezone.utc) @app.template_filter("filesizeformat") @@ -433,7 +447,8 @@ def favicon(): @app.route("/") def main_page(): - now = datetime.utcnow() + # now = datetime.utcnow() + now = datetime.now(timezone.utc) if current_user.is_authenticated: user_lists = ( @@ -525,7 +540,12 @@ def system_auth(): @app.route("/toggle_archive_list/") @login_required def toggle_archive_list(list_id): - l = ShoppingList.query.get_or_404(list_id) + # l = ShoppingList.query.get_or_404(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") @@ -545,26 +565,60 @@ def toggle_archive_list(list_id): @app.route("/edit_my_list/", methods=["GET", "POST"]) @login_required def edit_my_list(list_id): - l = ShoppingList.query.get_or_404(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") if request.method == "POST": - new_title = request.form.get("title") - if new_title and new_title.strip(): - l.title = new_title.strip() - db.session.commit() - flash("Zaktualizowano tytuł listy", "success") - return redirect(url_for("main_page")) - else: + new_title = request.form.get("title", "").strip() + is_public = "is_public" in request.form + is_temporary = "is_temporary" in request.form + is_archived = "is_archived" in request.form + + expires_date = request.form.get("expires_date") + expires_time = request.form.get("expires_time") + + # Walidacja tytułu + if not new_title: flash("Podaj poprawny tytuł", "danger") + return redirect(url_for("edit_my_list", list_id=list_id)) + + l.title = new_title + l.is_public = is_public + l.is_temporary = is_temporary + l.is_archived = is_archived + + # Obsługa daty wygaśnięcia + if expires_date and expires_time: + try: + combined = f"{expires_date} {expires_time}" + expires_dt = datetime.strptime(combined, "%Y-%m-%d %H:%M") + l.expires_at = expires_dt.replace(tzinfo=timezone.utc) + except ValueError: + flash("Błędna data lub godzina wygasania", "danger") + return redirect(url_for("edit_my_list", list_id=list_id)) + else: + l.expires_at = None + + db.session.commit() + flash("Zaktualizowano dane listy", "success") + return redirect(url_for("main_page")) + return render_template("edit_my_list.html", list=l) @app.route("/toggle_visibility/", methods=["GET", "POST"]) @login_required def toggle_visibility(list_id): - l = ShoppingList.query.get_or_404(list_id) + # l = ShoppingList.query.get_or_404(list_id) + + l = db.session.get(ShoppingList, list_id) + if l is None: + abort(404) + if l.owner_id != current_user.id: if request.is_json or request.method == "POST": return {"error": "Unauthorized"}, 403 @@ -587,15 +641,13 @@ def toggle_visibility(list_id): return redirect(url_for("main_page")) -from sqlalchemy import func - - @app.route("/login", methods=["GET", "POST"]) def login(): if request.method == "POST": username_input = request.form["username"].lower() user = User.query.filter(func.lower(User.username) == username_input).first() if user and check_password_hash(user.password_hash, request.form["password"]): + session.permanent = True login_user(user) flash("Zalogowano pomyślnie", "success") return redirect(url_for("main_page")) @@ -617,7 +669,12 @@ def create_list(): title = request.form.get("title") is_temporary = request.form.get("temporary") == "1" token = generate_share_token(8) - expires_at = datetime.utcnow() + timedelta(days=7) if is_temporary else None + + # expires_at = datetime.utcnow() + timedelta(days=7) if is_temporary else None + expires_at = ( + datetime.now(timezone.utc) + timedelta(days=7) if is_temporary else None + ) + new_list = ShoppingList( title=title, owner_id=current_user.id, @@ -651,7 +708,7 @@ def view_list(list_id): percent=percent, expenses=expenses, total_expense=total_expense, - is_share=False + is_share=False, ) @@ -661,8 +718,7 @@ def user_expenses(): from sqlalchemy.orm import joinedload expenses = ( - Expense.query - .join(ShoppingList, Expense.list_id == ShoppingList.id) + Expense.query.join(ShoppingList, Expense.list_id == ShoppingList.id) .options(joinedload(Expense.list)) .filter(ShoppingList.owner_id == current_user.id) .order_by(Expense.added_at.desc()) @@ -673,7 +729,7 @@ def user_expenses(): { "title": e.list.title if e.list else "Nieznana", "amount": e.amount, - "added_at": e.added_at + "added_at": e.added_at, } for e in expenses ] @@ -681,7 +737,6 @@ def user_expenses(): return render_template("user_expenses.html", expense_table=rows) - @app.route("/user/expenses_data") @login_required def user_expenses_data(): @@ -689,10 +744,8 @@ def user_expenses_data(): start_date = request.args.get("start_date") end_date = request.args.get("end_date") - query = ( - Expense.query - .join(ShoppingList, Expense.list_id == ShoppingList.id) - .filter(ShoppingList.owner_id == current_user.id) + query = Expense.query.join(ShoppingList, Expense.list_id == ShoppingList.id).filter( + ShoppingList.owner_id == current_user.id ) if start_date and end_date: @@ -707,7 +760,10 @@ def user_expenses_data(): grouped = defaultdict(float) for e in expenses: - ts = e.added_at or datetime.utcnow() + + # ts = e.added_at or datetime.utcnow() + ts = e.added_at or datetime.now(timezone.utc) + if range_type == "monthly": key = ts.strftime("%Y-%m") elif range_type == "quarterly": @@ -747,7 +803,7 @@ def shared_list(token=None, list_id=None): receipt_files=receipt_files, expenses=expenses, total_expense=total_expense, - is_share=True + is_share=True, ) @@ -824,7 +880,6 @@ def all_products(): return {"allproducts": unique_names} - """ @app.route('/upload_receipt/', methods=['POST']) def upload_receipt(list_id): if 'receipt' not in request.files: @@ -914,8 +969,8 @@ def uploaded_file(filename): @login_required @admin_required def admin_panel(): + now = datetime.now(timezone.utc) - now = datetime.utcnow() user_count = User.query.count() list_count = ShoppingList.query.count() item_count = Item.query.count() @@ -933,6 +988,15 @@ def admin_panel(): receipt_pattern = f"list_{l.id}" receipt_files = [f for f in all_files if receipt_pattern in f] + # obliczenie czy wygasła + if l.is_temporary and l.expires_at: + expires_at = l.expires_at + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + is_expired = expires_at < now + else: + is_expired = False + enriched_lists.append( { "list": l, @@ -942,12 +1006,13 @@ def admin_panel(): "comments_count": comments_count, "receipts_count": len(receipt_files), "total_expense": l.total_expense, + "expired": is_expired, } ) top_products = ( db.session.query(Item.name, func.count(Item.id).label("count")) - .filter(Item.purchased == True) + .filter(Item.purchased.is_(True)) .group_by(Item.name) .order_by(func.count(Item.id).desc()) .limit(5) @@ -957,7 +1022,10 @@ 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 - current_year = datetime.utcnow().year + current_time = datetime.now(timezone.utc) + current_year = current_time.year + current_month = current_time.month + year_expense_sum = ( db.session.query(func.sum(Expense.amount)) .filter(extract("year", Expense.added_at) == current_year) @@ -965,7 +1033,6 @@ def admin_panel(): or 0 ) - current_month = datetime.utcnow().month month_expense_sum = ( db.session.query(func.sum(Expense.amount)) .filter(extract("year", Expense.added_at) == current_year) @@ -1135,7 +1202,10 @@ def delete_receipt(filename): def delete_selected_lists(): ids = request.form.getlist("list_ids") for list_id in ids: - lst = ShoppingList.query.get(int(list_id)) + + # lst = ShoppingList.query.get(int(list_id)) + lst = db.session.get(ShoppingList, int(list_id)) + if lst: delete_receipts_for_list(lst.id) Item.query.filter_by(list_id=lst.id).delete() @@ -1160,11 +1230,16 @@ def delete_all_items(): @login_required @admin_required def edit_list(list_id): - l = ShoppingList.query.get_or_404(list_id) + l = db.session.get(ShoppingList, 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 = Item.query.filter_by(list_id=list_id).order_by(Item.id.desc()).all() + items = ( + db.session.query(Item).filter_by(list_id=list_id).order_by(Item.id.desc()).all() + ) receipt_pattern = f"list_{list_id}_" all_files = os.listdir(app.config["UPLOAD_FOLDER"]) @@ -1179,9 +1254,11 @@ def edit_list(list_id): is_archived = "archived" in request.form is_public = "public" in request.form is_temporary = "temporary" in request.form - expires_at_raw = request.form.get("expires_at") 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 @@ -1189,18 +1266,22 @@ def edit_list(list_id): l.is_public = is_public l.is_temporary = is_temporary - if expires_at_raw: + if expires_date and expires_time: try: - l.expires_at = datetime.strptime(expires_at_raw, "%Y-%m-%dT%H:%M") + 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) except ValueError: - l.expires_at = None + flash("Niepoprawna data lub godzina wygasania", "danger") + return redirect(url_for("edit_list", list_id=list_id)) else: l.expires_at = None if new_owner_id: try: new_owner_id_int = int(new_owner_id) - if User.query.get(new_owner_id_int): + user_obj = db.session.get(User, new_owner_id_int) + if user_obj: l.owner_id = new_owner_id_int else: flash("Wybrany użytkownik nie istnieje", "danger") @@ -1215,13 +1296,12 @@ def edit_list(list_id): for expense in expenses: db.session.delete(expense) db.session.commit() - new_expense = Expense(list_id=list_id, amount=new_amount) - db.session.add(new_expense) - db.session.commit() + db.session.add(Expense(list_id=list_id, amount=new_amount)) except ValueError: flash("Niepoprawna kwota", "danger") return redirect(url_for("edit_list", list_id=list_id)) + db.session.add(l) db.session.commit() flash("Zapisano zmiany listy", "success") return redirect(url_for("edit_list", list_id=list_id)) @@ -1229,28 +1309,32 @@ def edit_list(list_id): elif action == "add_item": item_name = request.form.get("item_name", "").strip() quantity_str = request.form.get("quantity", "1") + if not item_name: flash("Podaj nazwę produktu", "danger") return redirect(url_for("edit_list", list_id=list_id)) try: - quantity = int(quantity_str) - if quantity < 1: - quantity = 1 + quantity = max(1, int(quantity_str)) except ValueError: quantity = 1 - new_item = Item( - list_id=list_id, - name=item_name, - quantity=quantity, - added_by=current_user.id, + db.session.add( + Item( + list_id=list_id, + name=item_name, + quantity=quantity, + added_by=current_user.id, + ) ) - db.session.add(new_item) - if not SuggestedProduct.query.filter( - func.lower(SuggestedProduct.name) == item_name.lower() - ).first(): + exists = ( + db.session.query(SuggestedProduct) + .filter(func.lower(SuggestedProduct.name) == item_name.lower()) + .first() + ) + + if not exists: db.session.add(SuggestedProduct(name=item_name)) db.session.commit() @@ -1258,8 +1342,7 @@ def edit_list(list_id): return redirect(url_for("edit_list", list_id=list_id)) elif action == "delete_item": - item_id = request.form.get("item_id") - item = Item.query.get(item_id) + 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() @@ -1269,8 +1352,7 @@ def edit_list(list_id): return redirect(url_for("edit_list", list_id=list_id)) elif action == "toggle_purchased": - item_id = request.form.get("item_id") - item = Item.query.get(item_id) + 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() @@ -1280,8 +1362,7 @@ def edit_list(list_id): return redirect(url_for("edit_list", list_id=list_id)) elif action == "mark_not_purchased": - item_id = request.form.get("item_id") - item = Item.query.get(item_id) + 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 @@ -1293,8 +1374,7 @@ def edit_list(list_id): return redirect(url_for("edit_list", list_id=list_id)) elif action == "unmark_not_purchased": - item_id = request.form.get("item_id") - item = Item.query.get(item_id) + 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 @@ -1317,13 +1397,13 @@ def edit_list(list_id): ) - @app.route("/admin/products") @login_required @admin_required def list_products(): items = Item.query.order_by(Item.id.desc()).all() - users = User.query.all() + # users = User.query.all() + users = db.session.query(User).all() users_dict = {user.id: user.username for user in users} # Stabilne sortowanie sugestii @@ -1390,7 +1470,9 @@ def admin_expenses_data(): range_type = request.args.get("range", "monthly") start_date_str = request.args.get("start_date") end_date_str = request.args.get("end_date") - now = datetime.utcnow() + + # now = datetime.utcnow() + now = datetime.now(timezone.utc) labels = [] expenses = [] @@ -1544,7 +1626,9 @@ def healthcheck(): @socketio.on("delete_item") def handle_delete_item(data): - item = Item.query.get(data["item_id"]) + # item = Item.query.get(data["item_id"]) + item = db.session.get(Item, data["item_id"]) + if item: list_id = item.list_id db.session.delete(item) @@ -1566,7 +1650,9 @@ def handle_delete_item(data): @socketio.on("edit_item") def handle_edit_item(data): - item = Item.query.get(data["item_id"]) + # item = Item.query.get(data["item_id"]) + item = db.session.get(Item, data["item_id"]) + new_name = data["new_name"] new_quantity = data.get("new_quantity", item.quantity) @@ -1602,7 +1688,9 @@ def handle_join(data): active_users[room] = set() active_users[room].add(username) - shopping_list = ShoppingList.query.get(int(data["room"])) + # shopping_list = ShoppingList.query.get(int(data["room"])) + shopping_list = db.session.get(ShoppingList, int(data["room"])) + list_title = shopping_list.title if shopping_list else "Twoja lista" emit("user_joined", {"username": username}, to=room) @@ -1638,7 +1726,7 @@ def handle_add_item(data): existing_item = Item.query.filter( Item.list_id == list_id, func.lower(Item.name) == name.lower(), - Item.not_purchased == False + Item.not_purchased == False, ).first() if existing_item: @@ -1650,7 +1738,7 @@ def handle_add_item(data): { "item_id": existing_item.id, "new_name": existing_item.name, - "new_quantity": existing_item.quantity + "new_quantity": existing_item.quantity, }, to=str(list_id), ) @@ -1663,7 +1751,9 @@ def handle_add_item(data): ) db.session.add(new_item) - if not SuggestedProduct.query.filter(func.lower(SuggestedProduct.name) == name.lower()).first(): + if not SuggestedProduct.query.filter( + func.lower(SuggestedProduct.name) == name.lower() + ).first(): new_suggestion = SuggestedProduct(name=name) db.session.add(new_suggestion) @@ -1699,10 +1789,14 @@ def handle_add_item(data): @socketio.on("check_item") def handle_check_item(data): - item = Item.query.get(data["item_id"]) + # item = Item.query.get(data["item_id"]) + item = db.session.get(Item, data["item_id"]) + if item: item.purchased = True - item.purchased_at = datetime.utcnow() + # item.purchased_at = datetime.utcnow() + item.purchased_at = datetime.now(UTC) + db.session.commit() purchased_count, total_count, percent = get_progress(item.list_id) @@ -1721,7 +1815,9 @@ def handle_check_item(data): @socketio.on("uncheck_item") def handle_uncheck_item(data): - item = Item.query.get(data["item_id"]) + # item = Item.query.get(data["item_id"]) + item = db.session.get(Item, data["item_id"]) + if item: item.purchased = False item.purchased_at = None @@ -1755,7 +1851,7 @@ def handle_request_full_list(data): "quantity": item.quantity, "purchased": item.purchased if not item.not_purchased else False, "not_purchased": item.not_purchased, - 'not_purchased_reason': item.not_purchased_reason, + "not_purchased_reason": item.not_purchased_reason, "note": item.note or "", } ) @@ -1793,7 +1889,9 @@ def handle_add_expense(data): @socketio.on("mark_not_purchased") def handle_mark_not_purchased(data): - item = Item.query.get(data["item_id"]) + # item = Item.query.get(data["item_id"]) + item = db.session.get(Item, data["item_id"]) + reason = data.get("reason", "") if item: item.not_purchased = True @@ -1808,7 +1906,9 @@ def handle_mark_not_purchased(data): @socketio.on("unmark_not_purchased") def handle_unmark_not_purchased(data): - item = Item.query.get(data["item_id"]) + # item = Item.query.get(data["item_id"]) + item = db.session.get(Item, data["item_id"]) + if item: item.not_purchased = False item.purchased = False diff --git a/config.py b/config.py index 68666d5..cfc93a2 100644 --- a/config.py +++ b/config.py @@ -10,4 +10,5 @@ class Config: UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER', 'uploads') AUTHORIZED_COOKIE_VALUE = os.environ.get('AUTHORIZED_COOKIE_VALUE', 'cookievalue') AUTH_COOKIE_MAX_AGE = int(os.environ.get('AUTH_COOKIE_MAX_AGE', 86400)) - HEALTHCHECK_TOKEN = os.environ.get('HEALTHCHECK_TOKEN', 'alamapsaikota1234') \ No newline at end of file + HEALTHCHECK_TOKEN = os.environ.get('HEALTHCHECK_TOKEN', 'alamapsaikota1234') + SESSION_TIMEOUT_MINUTES = int(os.environ.get('SESSION_TIMEOUT_MINUTES', 10080)) \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index ce6e0ac..bf68436 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,5 +21,6 @@ services: - AUTHORIZED_COOKIE_VALUE=${AUTHORIZED_COOKIE_VALUE} - AUTH_COOKIE_MAX_AGE=${AUTH_COOKIE_MAX_AGE} - HEALTHCHECK_TOKEN=${HEALTHCHECK_TOKEN} + - SESSION_TIMEOUT_MINUTES=${SESSION_TIMEOUT_MINUTES} volumes: - .:/app diff --git a/templates/admin/admin_panel.html b/templates/admin/admin_panel.html index 3d85102..c3b354e 100644 --- a/templates/admin/admin_panel.html +++ b/templates/admin/admin_panel.html @@ -117,11 +117,11 @@ {% if l.is_archived %} Archiwalna - {% elif l.is_temporary and l.expires_at and l.expires_at < now %} Wygasła - {% else %} - Aktywna - {% endif %} + {% elif e.expired %} + Wygasła + {% else %} + Aktywna + {% endif %} {{ l.created_at.strftime('%Y-%m-%d %H:%M') if l.created_at else '-' }} diff --git a/templates/admin/edit_list.html b/templates/admin/edit_list.html index 8434638..1efd3a3 100644 --- a/templates/admin/edit_list.html +++ b/templates/admin/edit_list.html @@ -52,13 +52,21 @@
      -
      - - +
      +
      + + +
      +
      + + +
      +
      {% if current_user.is_authenticated %} {% if current_user.is_admin %} - ⚙️ Panel admina + ⚙️ {% endif %} - 📊 Statystyki - 🚪 Wyloguj + 📊 + 🚪 {% else %} 🔑 Zaloguj {% endif %}
      {% endif %} - -
      diff --git a/templates/edit_my_list.html b/templates/edit_my_list.html index 9732206..128574f 100644 --- a/templates/edit_my_list.html +++ b/templates/edit_my_list.html @@ -4,11 +4,44 @@

      Edytuj listę: {{ list.title }}

      - +
      + +
      + + +
      + +
      + + +
      + +
      +
      + + +
      +
      + + +
      +
      + +
      + + +
      + Anuluj + {% endblock %} \ No newline at end of file diff --git a/templates/main.html b/templates/main.html index f2b6514..561e689 100644 --- a/templates/main.html +++ b/templates/main.html @@ -49,19 +49,19 @@
      {{ l.title }} (Autor: Ty) - +
      From 45290a61476d93d890af5ae47d2e47a4a88459fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Thu, 17 Jul 2025 13:48:46 +0200 Subject: [PATCH 09/13] nowe funkcje i zmiany ux --- templates/edit_my_list.html | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/templates/edit_my_list.html b/templates/edit_my_list.html index 128574f..62892b3 100644 --- a/templates/edit_my_list.html +++ b/templates/edit_my_list.html @@ -1,11 +1,12 @@ {% extends 'base.html' %} {% block content %} -

      Edytuj listę: {{ list.title }}

      +

      Edytuj listę: {{ list.title }}

      - +
      @@ -23,13 +24,13 @@
      - +
      - +
      From 804b80bbf5371383320b0db8f00ed0976f68307e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Fri, 18 Jul 2025 10:45:51 +0200 Subject: [PATCH 10/13] nowa funckcja i male zmiany w js --- app.py | 15 +++++++++++ static/js/confirm_delete.js | 20 +++++++++++++++ static/js/live.js | 13 +++++++++- templates/edit_my_list.html | 49 +++++++++++++++++++++++++++++++++--- templates/user_expenses.html | 2 +- 5 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 static/js/confirm_delete.js diff --git a/app.py b/app.py index 0a338ed..51d8e95 100644 --- a/app.py +++ b/app.py @@ -610,6 +610,21 @@ def edit_my_list(list_id): return render_template("edit_my_list.html", list=l) +@app.route("/delete_user_list/", methods=["POST"]) +@login_required +def delete_user_list(list_id): + l = db.session.get(ShoppingList, list_id) + if l is None or l.owner_id != current_user.id: + abort(403) + delete_receipts_for_list(list_id) + Item.query.filter_by(list_id=list_id).delete() + Expense.query.filter_by(list_id=list_id).delete() + db.session.delete(l) + db.session.commit() + flash("Lista została usunięta", "success") + return redirect(url_for("main_page")) + + @app.route("/toggle_visibility/", methods=["GET", "POST"]) @login_required def toggle_visibility(list_id): diff --git a/static/js/confirm_delete.js b/static/js/confirm_delete.js new file mode 100644 index 0000000..edb0a69 --- /dev/null +++ b/static/js/confirm_delete.js @@ -0,0 +1,20 @@ +document.addEventListener("DOMContentLoaded", function () { + const input = document.getElementById('confirm-delete-input'); + const button = document.getElementById('confirm-delete-btn'); + let timer = null; + + input.addEventListener('input', function () { + button.disabled = true; + if (timer) clearTimeout(timer); + + if (input.value.trim().toLowerCase() === 'usuń') { + timer = setTimeout(() => { + button.disabled = false; + }, 2000); + } + }); + + button.addEventListener('click', function () { + document.getElementById('delete-form').submit(); + }); +}); \ No newline at end of file diff --git a/static/js/live.js b/static/js/live.js index 9cc05c3..40be454 100644 --- a/static/js/live.js +++ b/static/js/live.js @@ -155,8 +155,19 @@ function setupList(listId, username) {
      `; - document.getElementById('items').prepend(li); + // góra listy + //document.getElementById('items').prepend(li); + + // dół listy + document.getElementById('items').appendChild(li); toggleEmptyPlaceholder(); + + setTimeout(() => { + if (window.LIST_ID) { + socket.emit('request_full_list', { list_id: window.LIST_ID }); + } + }, 15000); + }); socket.on('item_deleted', data => { diff --git a/templates/edit_my_list.html b/templates/edit_my_list.html index 62892b3..1adf2b9 100644 --- a/templates/edit_my_list.html +++ b/templates/edit_my_list.html @@ -1,7 +1,8 @@ {% extends 'base.html' %} -{% block content %} +{% block content %}

      Edytuj listę: {{ list.title }}

      +
      @@ -40,9 +41,51 @@
      - - Anuluj +
      + + Anuluj +
      +
      +
      + +
      + +
      + + + +
      + + + +
      +{% endblock %} + +{% block scripts %} + {% endblock %} \ No newline at end of file diff --git a/templates/user_expenses.html b/templates/user_expenses.html index d3ebb1a..ad103fe 100644 --- a/templates/user_expenses.html +++ b/templates/user_expenses.html @@ -3,7 +3,7 @@ {% block content %}
      -

      📊 Statystyki wydatków

      +

      Statystyki wydatków

      ← Powrót
      From 8c9f0f1a6a754e13a926e1f78751d5a10bcc36ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Fri, 18 Jul 2025 12:09:21 +0200 Subject: [PATCH 11/13] =?UTF-8?q?nowa=20funckcja=20zmiana=20kolejnosci=20p?= =?UTF-8?q?rodukt=C3=B3w?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- alters.txt | 7 ++- app.py | 37 +++++++++++++--- static/js/functions.js | 8 ++-- static/js/sockets.js | 15 +++++++ static/js/sort_mode.js | 83 +++++++++++++++++++++++++++++++++++ static/lib/js/Sortable.min.js | 2 + templates/list.html | 15 +++++-- templates/list_share.html | 5 +++ 8 files changed, 159 insertions(+), 13 deletions(-) create mode 100644 static/js/sort_mode.js create mode 100644 static/lib/js/Sortable.min.js diff --git a/alters.txt b/alters.txt index 28fe56e..1731e50 100644 --- a/alters.txt +++ b/alters.txt @@ -28,9 +28,12 @@ ALTER TABLE shopping_list ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT 1; # ilośc produktów ALTER TABLE item ADD COLUMN quantity INTEGER DEFAULT 1; -#licznik najczesciej kupowanych reczy +# licznik najczesciej kupowanych reczy ALTER TABLE suggested_product ADD COLUMN usage_count INTEGER DEFAULT 0; -#funkcja niekupione +# funkcja niekupione ALTER TABLE item ADD COLUMN not_purchased_reason TEXT; ALTER TABLE item ADD COLUMN not_purchased BOOLEAN DEFAULT 0; + +# funkcja sortowania +ALTER TABLE item ADD COLUMN position INTEGER DEFAULT 0; diff --git a/app.py b/app.py index 51d8e95..15fa1ea 100644 --- a/app.py +++ b/app.py @@ -123,6 +123,7 @@ class Item(db.Model): note = db.Column(db.Text, nullable=True) not_purchased = db.Column(db.Boolean, default=False) not_purchased_reason = db.Column(db.Text, nullable=True) + position = db.Column(db.Integer, default=0) class SuggestedProduct(db.Model): @@ -211,7 +212,7 @@ 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).all() + items = Item.query.filter_by(list_id=list_id).order_by(Item.position.asc()).all() receipt_pattern = f"list_{list_id}" all_files = os.listdir(app.config["UPLOAD_FOLDER"]) receipt_files = [f for f in all_files if receipt_pattern in f] @@ -220,6 +221,7 @@ def get_list_details(list_id): return shopping_list, items, receipt_files, expenses, total_expense + def generate_share_token(length=8): """Generuje token do udostępniania. Parametr `length` to liczba znaków (domyślnie 4).""" return secrets.token_hex(length // 2) @@ -265,7 +267,7 @@ def admin_required(f): def get_progress(list_id): - items = Item.query.filter_by(list_id=list_id).all() + 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]) percent = (purchased_count / total_count * 100) if total_count > 0 else 0 @@ -980,6 +982,27 @@ def uploaded_file(filename): return response +@app.route('/reorder_items', methods=['POST']) +@login_required +def reorder_items(): + data = request.get_json() + list_id = data.get('list_id') + order = data.get('order') + + for index, item_id in enumerate(order): + item = db.session.get(Item, item_id) + if item and item.list_id == list_id: + item.position = index + db.session.commit() + + socketio.emit("items_reordered", { + "list_id": list_id, + "order": order + }, to=str(list_id)) + + return jsonify(success=True) + + @app.route("/admin") @login_required @admin_required @@ -1737,7 +1760,6 @@ def handle_add_item(data): except: quantity = 1 - # Szukamy istniejącego itemu w tej liście (ignorując wielkość liter) existing_item = Item.query.filter( Item.list_id == list_id, func.lower(Item.name) == name.lower(), @@ -1758,10 +1780,15 @@ def handle_add_item(data): to=str(list_id), ) else: + max_position = db.session.query(func.max(Item.position)).filter_by(list_id=list_id).scalar() + if max_position is None: + max_position = 0 + new_item = Item( list_id=list_id, name=name, quantity=quantity, + position=max_position + 1, added_by=current_user.id if current_user.is_authenticated else None, ) db.session.add(new_item) @@ -1788,7 +1815,6 @@ def handle_add_item(data): include_self=True, ) - # Aktualizacja postępu purchased_count, total_count, percent = get_progress(list_id) emit( @@ -1802,6 +1828,7 @@ def handle_add_item(data): ) + @socketio.on("check_item") def handle_check_item(data): # item = Item.query.get(data["item_id"]) @@ -1855,7 +1882,7 @@ def handle_uncheck_item(data): @socketio.on("request_full_list") def handle_request_full_list(data): list_id = data["list_id"] - items = Item.query.filter_by(list_id=list_id).all() + items = Item.query.filter_by(list_id=list_id).order_by(Item.position.asc()).all() items_data = [] for item in items: diff --git a/static/js/functions.js b/static/js/functions.js index ac36d16..9d5cd7b 100644 --- a/static/js/functions.js +++ b/static/js/functions.js @@ -179,7 +179,7 @@ function openList(link) { } function applyHidePurchased(isInit = false) { - console.log("applyHidePurchased: wywołana, isInit =", isInit); + //console.log("applyHidePurchased: wywołana, isInit =", isInit); const toggle = document.getElementById('hidePurchasedToggle'); if (!toggle) return; const hide = toggle.checked; @@ -273,6 +273,7 @@ function isListDifferent(oldItems, newItems) { } function updateListSmoothly(newItems) { + const itemsContainer = document.getElementById('items'); const existingItemsMap = new Map(); @@ -292,7 +293,6 @@ function updateListSmoothly(newItems) { if (!li) { li = document.createElement('li'); - li.className = `list-group-item d-flex justify-content-between align-items-center flex-wrap clickable-item`; li.id = `item-${item.id}`; } @@ -301,9 +301,10 @@ function updateListSmoothly(newItems) { item.not_purchased ? 'bg-warning text-dark' : 'item-not-checked' }`; - // HTML wewnętrzny + // Wewnętrzny HTML li.innerHTML = `
      + ${isSorting ? `` : ''} ${!item.not_purchased ? ` @@ -311,6 +312,7 @@ function updateListSmoothly(newItems) { 🚫 `} ${item.name} ${quantityBadge} + ${item.note ? `[ ${item.note} ]` : ''} ${item.not_purchased_reason ? `[ Powód: ${item.not_purchased_reason} ]` : ''}
      diff --git a/static/js/sockets.js b/static/js/sockets.js index 2a8f9a7..2a21f63 100644 --- a/static/js/sockets.js +++ b/static/js/sockets.js @@ -103,6 +103,20 @@ socket.on('receipt_added', function (data) { } }); +socket.on("items_reordered", data => { + if (data.list_id !== window.LIST_ID) return; + + if (window.currentItems) { + window.currentItems = data.order.map(id => + window.currentItems.find(item => item.id === id) + ).filter(Boolean); + + updateListSmoothly(window.currentItems); + //showToast('Kolejność produktów zaktualizowana', 'info'); + } +}); + + socket.on('full_list', function (data) { const itemsContainer = document.getElementById('items'); @@ -112,6 +126,7 @@ socket.on('full_list', function (data) { const isDifferent = isListDifferent(oldItems, data.items); + window.currentItems = data.items; updateListSmoothly(data.items); toggleEmptyPlaceholder(); diff --git a/static/js/sort_mode.js b/static/js/sort_mode.js new file mode 100644 index 0000000..0a91843 --- /dev/null +++ b/static/js/sort_mode.js @@ -0,0 +1,83 @@ +let sortable = null; +let isSorting = false; + +function enableSortMode() { + if (sortable || isSorting) return; + isSorting = true; + localStorage.setItem('sortModeEnabled', 'true'); + + const itemsContainer = document.getElementById('items'); + const listId = window.LIST_ID; + + if (!itemsContainer || !listId) return; + + sortable = Sortable.create(itemsContainer, { + animation: 150, + handle: '.drag-handle', + ghostClass: 'drag-ghost', + filter: 'input, button', + preventOnFilter: false, + onEnd: function () { + const order = Array.from(itemsContainer.children) + .map(li => parseInt(li.id.replace('item-', ''))) + .filter(id => !isNaN(id)); + + fetch('/reorder_items', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ list_id: listId, order }) + }).then(() => { + showToast('Zapisano nową kolejność', 'success'); + + if (window.currentItems) { + window.currentItems = order.map(id => + window.currentItems.find(item => item.id === id) + ); + updateListSmoothly(window.currentItems); + } + }); + } + }); + + const btn = document.getElementById('sort-toggle-btn'); + if (btn) { + btn.textContent = '✔️ Zakończ sortowanie'; + btn.classList.remove('btn-outline-warning'); + btn.classList.add('btn-outline-success'); + } + + if (window.currentItems) { + updateListSmoothly(window.currentItems); + } +} + +function disableSortMode() { + if (sortable) { + sortable.destroy(); + sortable = null; + } + isSorting = false; + localStorage.removeItem('sortModeEnabled'); + + const btn = document.getElementById('sort-toggle-btn'); + if (btn) { + btn.textContent = '✳️ Zmień kolejność'; + btn.classList.remove('btn-outline-success'); + btn.classList.add('btn-outline-warning'); + } + + if (window.currentItems) { + updateListSmoothly(window.currentItems); + } +} + +function toggleSortMode() { + isSorting ? disableSortMode() : enableSortMode(); +} + +document.addEventListener('DOMContentLoaded', () => { + const wasSorting = localStorage.getItem('sortModeEnabled') === 'true'; + if (wasSorting) { + enableSortMode(); + } +}); \ No newline at end of file diff --git a/static/lib/js/Sortable.min.js b/static/lib/js/Sortable.min.js new file mode 100644 index 0000000..95423a6 --- /dev/null +++ b/static/lib/js/Sortable.min.js @@ -0,0 +1,2 @@ +/*! Sortable 1.15.6 - MIT | git://github.com/SortableJS/Sortable.git */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t=t||self).Sortable=e()}(this,function(){"use strict";function e(e,t){var n,o=Object.keys(e);return Object.getOwnPropertySymbols&&(n=Object.getOwnPropertySymbols(e),t&&(n=n.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),o.push.apply(o,n)),o}function I(o){for(var t=1;tt.length)&&(e=t.length);for(var n=0,o=new Array(e);n"===e[0]&&(e=e.substring(1)),t))try{if(t.matches)return t.matches(e);if(t.msMatchesSelector)return t.msMatchesSelector(e);if(t.webkitMatchesSelector)return t.webkitMatchesSelector(e)}catch(t){return}}function g(t){return t.host&&t!==document&&t.host.nodeType?t.host:t.parentNode}function P(t,e,n,o){if(t){n=n||document;do{if(null!=e&&(">"!==e[0]||t.parentNode===n)&&f(t,e)||o&&t===n)return t}while(t!==n&&(t=g(t)))}return null}var m,v=/\s+/g;function k(t,e,n){var o;t&&e&&(t.classList?t.classList[n?"add":"remove"](e):(o=(" "+t.className+" ").replace(v," ").replace(" "+e+" "," "),t.className=(o+(n?" "+e:"")).replace(v," ")))}function R(t,e,n){var o=t&&t.style;if(o){if(void 0===n)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(t,""):t.currentStyle&&(n=t.currentStyle),void 0===e?n:n[e];o[e=!(e in o||-1!==e.indexOf("webkit"))?"-webkit-"+e:e]=n+("string"==typeof n?"":"px")}}function b(t,e){var n="";if("string"==typeof t)n=t;else do{var o=R(t,"transform")}while(o&&"none"!==o&&(n=o+" "+n),!e&&(t=t.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(n)}function D(t,e,n){if(t){var o=t.getElementsByTagName(e),i=0,r=o.length;if(n)for(;i=n.left-e&&i<=n.right+e,e=r>=n.top-e&&r<=n.bottom+e;return o&&e?a=t:void 0}}),a);if(e){var n,o={};for(n in t)t.hasOwnProperty(n)&&(o[n]=t[n]);o.target=o.rootEl=e,o.preventDefault=void 0,o.stopPropagation=void 0,e[K]._onDragOver(o)}}var i,r,a}function Ft(t){Z&&Z.parentNode[K]._isOutsideThisEl(t.target)}function jt(t,e){if(!t||!t.nodeType||1!==t.nodeType)throw"Sortable: `el` must be an HTMLElement, not ".concat({}.toString.call(t));this.el=t,this.options=e=a({},e),t[K]=this;var n,o,i={group:null,sort:!0,disabled:!1,store:null,handle:null,draggable:/^[uo]l$/i.test(t.nodeName)?">li":">*",swapThreshold:1,invertSwap:!1,invertedSwapThreshold:null,removeCloneOnHide:!0,direction:function(){return kt(t,this.options)},ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",ignore:"a, img",filter:null,preventOnFilter:!0,animation:0,easing:null,setData:function(t,e){t.setData("Text",e.textContent)},dropBubble:!1,dragoverBubble:!1,dataIdAttr:"data-id",delay:0,delayOnTouchOnly:!1,touchStartThreshold:(Number.parseInt?Number:window).parseInt(window.devicePixelRatio,10)||1,forceFallback:!1,fallbackClass:"sortable-fallback",fallbackOnBody:!1,fallbackTolerance:0,fallbackOffset:{x:0,y:0},supportPointer:!1!==jt.supportPointer&&"PointerEvent"in window&&(!u||c),emptyInsertThreshold:5};for(n in z.initializePlugins(this,t,i),i)n in e||(e[n]=i[n]);for(o in Rt(e),this)"_"===o.charAt(0)&&"function"==typeof this[o]&&(this[o]=this[o].bind(this));this.nativeDraggable=!e.forceFallback&&It,this.nativeDraggable&&(this.options.touchStartThreshold=1),e.supportPointer?h(t,"pointerdown",this._onTapStart):(h(t,"mousedown",this._onTapStart),h(t,"touchstart",this._onTapStart)),this.nativeDraggable&&(h(t,"dragover",this),h(t,"dragenter",this)),St.push(this.el),e.store&&e.store.get&&this.sort(e.store.get(this)||[]),a(this,A())}function Ht(t,e,n,o,i,r,a,l){var s,c,u=t[K],d=u.options.onMove;return!window.CustomEvent||y||w?(s=document.createEvent("Event")).initEvent("move",!0,!0):s=new CustomEvent("move",{bubbles:!0,cancelable:!0}),s.to=e,s.from=t,s.dragged=n,s.draggedRect=o,s.related=i||e,s.relatedRect=r||X(e),s.willInsertAfter=l,s.originalEvent=a,t.dispatchEvent(s),c=d?d.call(u,s,a):c}function Lt(t){t.draggable=!1}function Kt(){xt=!1}function Wt(t){return setTimeout(t,0)}function zt(t){return clearTimeout(t)}jt.prototype={constructor:jt,_isOutsideThisEl:function(t){this.el.contains(t)||t===this.el||(vt=null)},_getDirection:function(t,e){return"function"==typeof this.options.direction?this.options.direction.call(this,t,e,Z):this.options.direction},_onTapStart:function(e){if(e.cancelable){var n=this,o=this.el,t=this.options,i=t.preventOnFilter,r=e.type,a=e.touches&&e.touches[0]||e.pointerType&&"touch"===e.pointerType&&e,l=(a||e).target,s=e.target.shadowRoot&&(e.path&&e.path[0]||e.composedPath&&e.composedPath()[0])||l,c=t.filter;if(!function(t){Ot.length=0;var e=t.getElementsByTagName("input"),n=e.length;for(;n--;){var o=e[n];o.checked&&Ot.push(o)}}(o),!Z&&!(/mousedown|pointerdown/.test(r)&&0!==e.button||t.disabled)&&!s.isContentEditable&&(this.nativeDraggable||!u||!l||"SELECT"!==l.tagName.toUpperCase())&&!((l=P(l,t.draggable,o,!1))&&l.animated||et===l)){if(it=j(l),at=j(l,t.draggable),"function"==typeof c){if(c.call(this,e,l,this))return V({sortable:n,rootEl:s,name:"filter",targetEl:l,toEl:o,fromEl:o}),U("filter",n,{evt:e}),void(i&&e.preventDefault())}else if(c=c&&c.split(",").some(function(t){if(t=P(s,t.trim(),o,!1))return V({sortable:n,rootEl:t,name:"filter",targetEl:l,fromEl:o,toEl:o}),U("filter",n,{evt:e}),!0}))return void(i&&e.preventDefault());t.handle&&!P(s,t.handle,o,!1)||this._prepareDragStart(e,a,l)}}},_prepareDragStart:function(t,e,n){var o,i=this,r=i.el,a=i.options,l=r.ownerDocument;n&&!Z&&n.parentNode===r&&(o=X(n),J=r,$=(Z=n).parentNode,tt=Z.nextSibling,et=n,st=a.group,ut={target:jt.dragged=Z,clientX:(e||t).clientX,clientY:(e||t).clientY},ft=ut.clientX-o.left,gt=ut.clientY-o.top,this._lastX=(e||t).clientX,this._lastY=(e||t).clientY,Z.style["will-change"]="all",o=function(){U("delayEnded",i,{evt:t}),jt.eventCanceled?i._onDrop():(i._disableDelayedDragEvents(),!s&&i.nativeDraggable&&(Z.draggable=!0),i._triggerDragStart(t,e),V({sortable:i,name:"choose",originalEvent:t}),k(Z,a.chosenClass,!0))},a.ignore.split(",").forEach(function(t){D(Z,t.trim(),Lt)}),h(l,"dragover",Bt),h(l,"mousemove",Bt),h(l,"touchmove",Bt),a.supportPointer?(h(l,"pointerup",i._onDrop),this.nativeDraggable||h(l,"pointercancel",i._onDrop)):(h(l,"mouseup",i._onDrop),h(l,"touchend",i._onDrop),h(l,"touchcancel",i._onDrop)),s&&this.nativeDraggable&&(this.options.touchStartThreshold=4,Z.draggable=!0),U("delayStart",this,{evt:t}),!a.delay||a.delayOnTouchOnly&&!e||this.nativeDraggable&&(w||y)?o():jt.eventCanceled?this._onDrop():(a.supportPointer?(h(l,"pointerup",i._disableDelayedDrag),h(l,"pointercancel",i._disableDelayedDrag)):(h(l,"mouseup",i._disableDelayedDrag),h(l,"touchend",i._disableDelayedDrag),h(l,"touchcancel",i._disableDelayedDrag)),h(l,"mousemove",i._delayedDragTouchMoveHandler),h(l,"touchmove",i._delayedDragTouchMoveHandler),a.supportPointer&&h(l,"pointermove",i._delayedDragTouchMoveHandler),i._dragStartTimer=setTimeout(o,a.delay)))},_delayedDragTouchMoveHandler:function(t){t=t.touches?t.touches[0]:t;Math.max(Math.abs(t.clientX-this._lastX),Math.abs(t.clientY-this._lastY))>=Math.floor(this.options.touchStartThreshold/(this.nativeDraggable&&window.devicePixelRatio||1))&&this._disableDelayedDrag()},_disableDelayedDrag:function(){Z&&Lt(Z),clearTimeout(this._dragStartTimer),this._disableDelayedDragEvents()},_disableDelayedDragEvents:function(){var t=this.el.ownerDocument;p(t,"mouseup",this._disableDelayedDrag),p(t,"touchend",this._disableDelayedDrag),p(t,"touchcancel",this._disableDelayedDrag),p(t,"pointerup",this._disableDelayedDrag),p(t,"pointercancel",this._disableDelayedDrag),p(t,"mousemove",this._delayedDragTouchMoveHandler),p(t,"touchmove",this._delayedDragTouchMoveHandler),p(t,"pointermove",this._delayedDragTouchMoveHandler)},_triggerDragStart:function(t,e){e=e||"touch"==t.pointerType&&t,!this.nativeDraggable||e?this.options.supportPointer?h(document,"pointermove",this._onTouchMove):h(document,e?"touchmove":"mousemove",this._onTouchMove):(h(Z,"dragend",this),h(J,"dragstart",this._onDragStart));try{document.selection?Wt(function(){document.selection.empty()}):window.getSelection().removeAllRanges()}catch(t){}},_dragStarted:function(t,e){var n;Dt=!1,J&&Z?(U("dragStarted",this,{evt:e}),this.nativeDraggable&&h(document,"dragover",Ft),n=this.options,t||k(Z,n.dragClass,!1),k(Z,n.ghostClass,!0),jt.active=this,t&&this._appendGhost(),V({sortable:this,name:"start",originalEvent:e})):this._nulling()},_emulateDragOver:function(){if(dt){this._lastX=dt.clientX,this._lastY=dt.clientY,Xt();for(var t=document.elementFromPoint(dt.clientX,dt.clientY),e=t;t&&t.shadowRoot&&(t=t.shadowRoot.elementFromPoint(dt.clientX,dt.clientY))!==e;)e=t;if(Z.parentNode[K]._isOutsideThisEl(t),e)do{if(e[K])if(e[K]._onDragOver({clientX:dt.clientX,clientY:dt.clientY,target:t,rootEl:e})&&!this.options.dragoverBubble)break}while(e=g(t=e));Yt()}},_onTouchMove:function(t){if(ut){var e=this.options,n=e.fallbackTolerance,o=e.fallbackOffset,i=t.touches?t.touches[0]:t,r=Q&&b(Q,!0),a=Q&&r&&r.a,l=Q&&r&&r.d,e=At&&wt&&E(wt),a=(i.clientX-ut.clientX+o.x)/(a||1)+(e?e[0]-Tt[0]:0)/(a||1),l=(i.clientY-ut.clientY+o.y)/(l||1)+(e?e[1]-Tt[1]:0)/(l||1);if(!jt.active&&!Dt){if(n&&Math.max(Math.abs(i.clientX-this._lastX),Math.abs(i.clientY-this._lastY))E.right+10||S.clientY>x.bottom&&S.clientX>x.left:S.clientY>E.bottom+10||S.clientX>x.right&&S.clientY>x.top)||m.animated)){if(m&&(t=n,e=r,C=X(B((_=this).el,0,_.options,!0)),_=L(_.el,_.options,Q),e?t.clientX<_.left-10||t.clientY {% endif %} -
      - - +
      + +
      + + +
        @@ -205,12 +210,16 @@
      {% block scripts %} + + + diff --git a/templates/list_share.html b/templates/list_share.html index f20b0e0..b7af618 100644 --- a/templates/list_share.html +++ b/templates/list_share.html @@ -170,7 +170,12 @@ + From 69f1b4d1c8c58414ddf6bf57f678df7f5d5a25c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Fri, 18 Jul 2025 12:12:43 +0200 Subject: [PATCH 12/13] dropbny fix --- templates/edit_my_list.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/edit_my_list.html b/templates/edit_my_list.html index 1adf2b9..9b1698e 100644 --- a/templates/edit_my_list.html +++ b/templates/edit_my_list.html @@ -79,7 +79,7 @@
      -
      +
      From 1c88e5c00b7587b22ace2f515176e82e73eaf4fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Fri, 18 Jul 2025 12:30:18 +0200 Subject: [PATCH 13/13] usuniecie funckji masowego usuwania produktow z bazy --- app.py | 31 +++++++++++-------------------- templates/admin/admin_panel.html | 13 ++----------- 2 files changed, 13 insertions(+), 31 deletions(-) diff --git a/app.py b/app.py index 15fa1ea..d4d6ec3 100644 --- a/app.py +++ b/app.py @@ -221,7 +221,6 @@ def get_list_details(list_id): return shopping_list, items, receipt_files, expenses, total_expense - def generate_share_token(length=8): """Generuje token do udostępniania. Parametr `length` to liczba znaków (domyślnie 4).""" return secrets.token_hex(length // 2) @@ -982,12 +981,12 @@ def uploaded_file(filename): return response -@app.route('/reorder_items', methods=['POST']) +@app.route("/reorder_items", methods=["POST"]) @login_required def reorder_items(): data = request.get_json() - list_id = data.get('list_id') - order = data.get('order') + list_id = data.get("list_id") + order = data.get("order") for index, item_id in enumerate(order): item = db.session.get(Item, item_id) @@ -995,10 +994,9 @@ def reorder_items(): item.position = index db.session.commit() - socketio.emit("items_reordered", { - "list_id": list_id, - "order": order - }, to=str(list_id)) + socketio.emit( + "items_reordered", {"list_id": list_id, "order": order}, to=str(list_id) + ) return jsonify(success=True) @@ -1254,16 +1252,6 @@ def delete_selected_lists(): return redirect(url_for("admin_panel")) -@app.route("/admin/delete_all_items") -@login_required -@admin_required -def delete_all_items(): - Item.query.delete() - db.session.commit() - flash("Usunięto wszystkie produkty", "success") - return redirect(url_for("admin_panel")) - - @app.route("/admin/edit_list/", methods=["GET", "POST"]) @login_required @admin_required @@ -1780,7 +1768,11 @@ def handle_add_item(data): to=str(list_id), ) else: - max_position = db.session.query(func.max(Item.position)).filter_by(list_id=list_id).scalar() + max_position = ( + db.session.query(func.max(Item.position)) + .filter_by(list_id=list_id) + .scalar() + ) if max_position is None: max_position = 0 @@ -1828,7 +1820,6 @@ def handle_add_item(data): ) - @socketio.on("check_item") def handle_check_item(data): # item = Item.query.get(data["item_id"]) diff --git a/templates/admin/admin_panel.html b/templates/admin/admin_panel.html index c3b354e..b7564e9 100644 --- a/templates/admin/admin_panel.html +++ b/templates/admin/admin_panel.html @@ -21,19 +21,10 @@ 👥 Zarządzanie użytkownikami
    • -