diff --git a/app.py b/app.py index d3e57a6..9a89b35 100644 --- a/app.py +++ b/app.py @@ -8,10 +8,11 @@ import platform import psutil import hashlib import re +import traceback from pillow_heif import register_heif_opener - from datetime import datetime, timedelta, UTC, timezone +from urllib.parse import urlparse, urlunparse from flask import ( Flask, @@ -45,18 +46,55 @@ from config import Config from PIL import Image, ExifTags, ImageFilter, ImageOps from werkzeug.utils import secure_filename from werkzeug.middleware.proxy_fix import ProxyFix -from sqlalchemy import func, extract, inspect +from sqlalchemy import func, extract, inspect, or_ +from sqlalchemy.orm import joinedload from collections import defaultdict, deque from functools import wraps +from flask_talisman import Talisman # OCR -from collections import Counter import pytesseract +from collections import Counter from pytesseract import Output +import logging + app = Flask(__name__) app.config.from_object(Config) + +# Konfiguracja nagłówków bezpieczeństwa z .env +csp_policy = ( + { + "default-src": "'self'", + "script-src": "'self' 'unsafe-inline'", + "style-src": "'self' 'unsafe-inline'", + "img-src": "'self' data:", + "connect-src": "'self'", + } + if app.config.get("ENABLE_CSP", True) + else None +) + +permissions_policy = {"browsing-topics": "()"} if app.config.get("ENABLE_PP") else None + +talisman_kwargs = { + "force_https": False, + "strict_transport_security": app.config.get("ENABLE_HSTS", True), + "frame_options": "DENY" if app.config.get("ENABLE_XFO", True) else None, + "permissions_policy": permissions_policy, + "content_security_policy": csp_policy, + "x_content_type_options": app.config.get("ENABLE_XCTO", True), + "strict_transport_security_include_subdomains": False, + "session_cookie_secure": app.config.get("SESSION_COOKIE_SECURE", False), +} + +referrer_policy = app.config.get("REFERRER_POLICY") +if referrer_policy: + talisman_kwargs["referrer_policy"] = referrer_policy + +talisman = Talisman(app, **talisman_kwargs) + register_heif_opener() # pillow_heif dla HEIC ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp", "heic"} @@ -74,6 +112,7 @@ 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) +DEBUG_MODE = app.config.get("DEBUG_MODE", False) os.makedirs(UPLOAD_FOLDER, exist_ok=True) @@ -81,6 +120,14 @@ failed_login_attempts = defaultdict(deque) MAX_ATTEMPTS = 10 TIME_WINDOW = 60 * 60 +WEBP_SAVE_PARAMS = { + "format": "WEBP", + "lossless": True, # lub False jeśli chcesz używać quality + "method": 6, + # "quality": 95, # tylko jeśli lossless=False +} + + db = SQLAlchemy(app) socketio = SocketIO(app, async_mode="eventlet") login_manager = LoginManager(app) @@ -114,7 +161,10 @@ class ShoppingList(db.Model): id = db.Column(db.Integer, primary_key=True) title = db.Column(db.String(150), nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow) + owner_id = db.Column(db.Integer, db.ForeignKey("user.id")) + owner = db.relationship("User", backref="lists", foreign_keys=[owner_id]) + is_temporary = db.Column(db.Boolean, default=False) share_token = db.Column(db.String(64), unique=True, nullable=True) # expires_at = db.Column(db.DateTime, nullable=True) @@ -131,6 +181,10 @@ class Item(db.Model): # added_at = db.Column(db.DateTime, default=datetime.utcnow) added_at = db.Column(db.DateTime, default=utcnow) added_by = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) + added_by_user = db.relationship( + "User", backref="added_items", lazy="joined", foreign_keys=[added_by] + ) + purchased = db.Column(db.Boolean, default=False) purchased_at = db.Column(db.DateTime, nullable=True) quantity = db.Column(db.Integer, default=1) @@ -188,11 +242,10 @@ with app.app_context(): @static_bp.route("/static/js/") def serve_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.cache_control.no_cache = True + # response.cache_control.no_store = True + # response.cache_control.must_revalidate = True + response.headers["Cache-Control"] = app.config["JS_CACHE_CONTROL"] response.headers.pop("Content-Disposition", None) response.headers.pop("Etag", None) return response @@ -201,7 +254,7 @@ def serve_js(filename): @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["Cache-Control"] = app.config["CSS_CACHE_CONTROL"] response.headers.pop("Content-Disposition", None) response.headers.pop("Etag", None) return response @@ -210,7 +263,7 @@ def serve_css(filename): @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["Cache-Control"] = app.config["LIB_JS_CACHE_CONTROL"] response.headers.pop("Content-Disposition", None) response.headers.pop("Etag", None) return response @@ -220,7 +273,7 @@ def serve_js_lib(filename): @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["Cache-Control"] = app.config["LIB_CSS_CACHE_CONTROL"] response.headers.pop("Content-Disposition", None) response.headers.pop("Etag", None) return response @@ -287,7 +340,8 @@ def save_resized_image(file, path): image.info.clear() new_path = path.rsplit(".", 1)[0] + ".webp" - image.save(new_path, format="WEBP", quality=100, method=0) + image.save(new_path, **WEBP_SAVE_PARAMS) + except Exception as e: raise ValueError(f"Błąd podczas przetwarzania obrazu: {e}") @@ -347,7 +401,7 @@ def rotate_receipt_by_id(receipt_id): new_filename = generate_new_receipt_filename(receipt.list_id) new_path = os.path.join(app.config["UPLOAD_FOLDER"], new_filename) - rotated.save(new_path, format="WEBP", quality=100) + rotated.save(new_path, **WEBP_SAVE_PARAMS) os.remove(old_path) receipt.filename = new_filename @@ -374,6 +428,131 @@ def generate_new_receipt_filename(list_id): return f"list_{list_id}_{timestamp}_{random_part}.webp" +def handle_crop_receipt(receipt_id, file): + if not receipt_id or not file: + return {"success": False, "error": "Brak danych"} + + receipt = Receipt.query.get_or_404(receipt_id) + old_path = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename) + + try: + new_filename = generate_new_receipt_filename(receipt.list_id) + new_path = os.path.join(app.config["UPLOAD_FOLDER"], new_filename) + + save_resized_image(file, new_path) + + if os.path.exists(old_path): + os.remove(old_path) + + receipt.filename = os.path.basename(new_path) + db.session.commit() + recalculate_filesizes(receipt.id) + return {"success": True} + except Exception as e: + return {"success": False, "error": str(e)} + + +def get_expenses_aggregated_by_list_created_at( + user_only=False, + admin=False, + show_all=False, + range_type="monthly", + start_date=None, + end_date=None, + user_id=None, +): + """ + Wspólna logika: sumujemy najnowszy wydatek z każdej listy, + ale do agregacji/filtra bierzemy ShoppingList.created_at! + """ + lists_query = ShoppingList.query + # Uprawnienia + if admin: + # admin widzi wszystko, ewentualnie: dodać filtrowanie wg potrzeb + pass + elif show_all: + lists_query = lists_query.filter( + or_( + ShoppingList.owner_id == user_id, + ShoppingList.is_public == True, + ) + ) + else: + lists_query = lists_query.filter(ShoppingList.owner_id == user_id) + + # Filtrowanie po created_at listy + if start_date and end_date: + try: + dt_start = datetime.strptime(start_date, "%Y-%m-%d") + dt_end = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1) + except Exception: + return {"error": "Błędne daty", "labels": [], "expenses": []} + lists_query = lists_query.filter( + ShoppingList.created_at >= dt_start, ShoppingList.created_at < dt_end + ) + lists = lists_query.all() + + # Najnowszy wydatek każdej listy + data = [] + for sl in lists: + latest_exp = ( + Expense.query.filter_by(list_id=sl.id) + .order_by(Expense.added_at.desc()) + .first() + ) + if latest_exp: + data.append({"created_at": sl.created_at, "amount": latest_exp.amount}) + + # Grupowanie po wybranym zakresie wg utworzenia listy + grouped = defaultdict(float) + for rec in data: + ts = rec["created_at"] or datetime.now(timezone.utc) + if range_type == "monthly": + key = ts.strftime("%Y-%m") + elif range_type == "quarterly": + key = f"{ts.year}-Q{((ts.month - 1) // 3 + 1)}" + elif range_type == "halfyearly": + key = f"{ts.year}-H{1 if ts.month <= 6 else 2}" + elif range_type == "yearly": + key = str(ts.year) + else: + key = ts.strftime("%Y-%m-%d") + grouped[key] += rec["amount"] + + labels = sorted(grouped) + expenses = [round(grouped[l], 2) for l in labels] + return {"labels": labels, "expenses": expenses} + + +def recalculate_filesizes(receipt_id: int = None): + updated = 0 + not_found = 0 + unchanged = 0 + + if receipt_id is not None: + receipt = db.session.get(Receipt, receipt_id) + receipts = [receipt] if receipt else [] + else: + receipts = db.session.execute(db.select(Receipt)).scalars().all() + + for r in receipts: + if not r: + continue + filepath = os.path.join(app.config["UPLOAD_FOLDER"], r.filename) + if os.path.exists(filepath): + real_size = os.path.getsize(filepath) + if r.filesize != real_size: + r.filesize = real_size + updated += 1 + else: + unchanged += 1 + else: + not_found += 1 + + db.session.commit() + return updated, unchanged, not_found + + ############# OCR ########################### @@ -387,22 +566,22 @@ def preprocess_image_for_tesseract(image): def extract_total_tesseract(image): + text = pytesseract.image_to_string(image, lang="pol", config="--psm 4") lines = text.splitlines() candidates = [] - keyword_lines_debug = [] - fuzzy_regex = re.compile(r"[\dOo][.,:;g9zZ][\d]{2}") - keyword_pattern = re.compile( + blacklist_keywords = re.compile(r"\b(ptu|vat|podatek|stawka)\b", re.IGNORECASE) + + priority_keywords = re.compile( r""" \b( - [5s]u[mn][aąo0]? | - razem | - zap[łl][aąo0]ty | - do\s+zap[łl][aąo0]ty | + razem\s*do\s*zap[łl][aąo0]ty | + do\s*zap[łl][aąo0]ty | + suma | kwota | - płatno[śćs] | warto[śćs] | + płatno[śćs] | total | amount )\b @@ -410,84 +589,71 @@ def extract_total_tesseract(image): re.IGNORECASE | re.VERBOSE, ) - for idx, line in enumerate(lines): - if keyword_pattern.search(line[:30]): - keyword_lines_debug.append((idx, line)) - for line in lines: if not line.strip(): continue - matches = re.findall(r"\d{1,4}\s?[.,]\d{2}", line) + if blacklist_keywords.search(line): + continue + + is_priority = priority_keywords.search(line) + + matches = re.findall(r"\d{1,4}[.,]\d{2}", line) for match in matches: try: - val = float(match.replace(" ", "").replace(",", ".")) + val = float(match.replace(",", ".")) if 0.1 <= val <= 100000: - candidates.append((val, line)) + candidates.append((val, line, is_priority is not None)) except: continue - spaced = re.findall(r"\d{1,4}\s\d{2}", line) - for match in spaced: - try: - val = float(match.replace(" ", ".")) - if 0.1 <= val <= 100000: - candidates.append((val, line)) - except: - continue + # Tylko w liniach priorytetowych: sprawdzamy spaced fallback + if is_priority: + spaced = re.findall(r"\d{1,4}\s\d{2}", line) + for match in spaced: + try: + val = float(match.replace(" ", ".")) + if 0.1 <= val <= 100000: + candidates.append((val, line, True)) + except: + continue - fuzzy_matches = fuzzy_regex.findall(line) - for match in fuzzy_matches: - cleaned = ( - match.replace("O", "0") - .replace("o", "0") - .replace(":", ".") - .replace(";", ".") - .replace(",", ".") - .replace("g", "9") - .replace("z", "9") - .replace("Z", "9") - ) - try: - val = float(cleaned) - if 0.1 <= val <= 100000: - candidates.append((val, line)) - except: - continue - - preferred = [ - (val, line) for val, line in candidates if keyword_pattern.search(line.lower()) - ] + # Preferujemy linie priorytetowe + preferred = [(val, line) for val, line, is_pref in candidates if is_pref] if preferred: - max_val = max(preferred, key=lambda x: x[0])[0] - return round(max_val, 2), lines + best_val = max(preferred, key=lambda x: x[0])[0] + if best_val < 99999: + return round(best_val, 2), lines if candidates: - max_val = max([val for val, _ in candidates]) - return round(max_val, 2), lines + best_val = max(candidates, key=lambda x: x[0])[0] + if best_val < 99999: + return round(best_val, 2), lines + # Fallback: największy font + bold data = pytesseract.image_to_data( image, lang="pol", config="--psm 4", output_type=Output.DICT ) - font_candidates = [] + font_candidates = [] for i in range(len(data["text"])): word = data["text"][i].strip() - if not word: + if not word or not re.match(r"^\d{1,5}[.,\s]\d{2}$", word): continue - if re.match(r"^\d{1,5}[.,\s]\d{2}$", word): - try: - val = float(word.replace(",", ".").replace(" ", ".")) - height = data["height"][i] - if 0.1 <= val <= 10000: - font_candidates.append((val, height, word)) - except: - continue + try: + val = float(word.replace(",", ".").replace(" ", ".")) + height = data["height"][i] + conf = int(data.get("conf", ["0"] * len(data["text"]))[i]) + if 0.1 <= val <= 100000: + font_candidates.append((val, height, conf)) + except: + continue if font_candidates: - best = max(font_candidates, key=lambda x: x[1]) + # Preferuj najwyższy font z sensownym confidence + best = max(font_candidates, key=lambda x: (x[1], x[2])) return round(best[0], 2), lines return 0.0, lines @@ -525,6 +691,17 @@ def attempts_remaining(ip): #################################################### +def get_client_ip(): + # Obsługuje: X-Forwarded-For, X-Real-IP, fallback na remote_addr + for header in ["X-Forwarded-For", "X-Real-IP"]: + if header in request.headers: + # Pierwszy IP w X-Forwarded-For jest najczęściej klientem + ip = request.headers[header].split(",")[0].strip() + if ip: + return ip + return request.remote_addr + + @login_manager.user_loader def load_user(user_id): # return User.query.get(int(user_id)) @@ -587,13 +764,35 @@ def require_system_password(): 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)) +@app.before_request +def start_timer(): + request._start_time = time.time() + + +@app.after_request +def log_request(response): + if request.path == "/healthcheck": + return response + + ip = get_client_ip() + method = request.method + path = request.path + status = response.status_code + length = response.content_length or "-" + start = getattr(request, "_start_time", None) + duration = round((time.time() - start) * 1000, 2) if start else "-" + agent = request.headers.get("User-Agent", "-") + + log_msg = f'{ip} - "{method} {path}" {status} {length} {duration}ms "{agent}"' + app.logger.info(log_msg) + return response + + @app.template_filter("filemtime") def file_mtime_filter(path): try: @@ -664,13 +863,34 @@ def favicon(): @app.route("/") def main_page(): - # now = datetime.utcnow() now = datetime.now(timezone.utc) + month_str = request.args.get("month") + start = end = None + + if month_str: + try: + year, month = map(int, month_str.split("-")) + start = datetime(year, month, 1, tzinfo=timezone.utc) + end = (start + timedelta(days=31)).replace(day=1) + except: + start = end = None + + def date_filter(query): + if start and end: + query = query.filter( + ShoppingList.created_at >= start, ShoppingList.created_at < end + ) + return query if current_user.is_authenticated: user_lists = ( - ShoppingList.query.filter_by(owner_id=current_user.id, is_archived=False) - .filter((ShoppingList.expires_at == None) | (ShoppingList.expires_at > now)) + date_filter( + 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() ) @@ -682,11 +902,16 @@ def main_page(): ) 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, + date_filter( + 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() @@ -695,10 +920,15 @@ def main_page(): 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, + date_filter( + ShoppingList.query.filter( + ShoppingList.is_public == True, + ( + (ShoppingList.expires_at == None) + | (ShoppingList.expires_at > now) + ), + ShoppingList.is_archived == False, + ) ) .order_by(ShoppingList.created_at.desc()) .all() @@ -712,6 +942,8 @@ def main_page(): user_lists=user_lists, public_lists=public_lists, archived_lists=archived_lists, + now=now, + timedelta=timedelta, ) @@ -739,7 +971,12 @@ def system_auth(): 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) + resp.set_cookie( + "authorized", + AUTHORIZED_COOKIE_VALUE, + max_age=max_age, + secure=request.is_secure, + ) return resp else: register_failed_attempt(ip) @@ -796,15 +1033,31 @@ def edit_my_list(list_id): abort(403, description="Nie jesteś właścicielem tej listy.") if request.method == "POST": + # Obsługa zmiany miesiąca utworzenia listy + move_to_month = request.form.get("move_to_month") + if move_to_month: + try: + year, month = map(int, move_to_month.split("-")) + new_created_at = datetime(year, month, 1, tzinfo=timezone.utc) + l.created_at = new_created_at + db.session.commit() + flash( + f"Zmieniono datę utworzenia listy na {new_created_at.strftime('%Y-%m-%d')}", + "success", + ) + return redirect(url_for("edit_my_list", list_id=list_id)) + except ValueError: + flash("Nieprawidłowy format miesiąca", "danger") + return redirect(url_for("edit_my_list", list_id=list_id)) + + # Pozostała aktualizacja pól 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)) @@ -814,7 +1067,6 @@ def edit_my_list(list_id): 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}" @@ -892,6 +1144,7 @@ def login(): if user and check_password_hash(user.password_hash, request.form["password"]): session.permanent = True login_user(user) + # session["logged"] = True flash("Zalogowano pomyślnie", "success") return redirect(url_for("main_page")) flash("Nieprawidłowy login lub hasło", "danger") @@ -940,6 +1193,13 @@ def view_list(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 + is_owner = current_user.id == shopping_list.owner_id + + for item in items: + if item.added_by != shopping_list.owner_id: + item.added_by_display = item.added_by_user.username if item.added_by_user else "?" + else: + item.added_by_display = None return render_template( "list.html", @@ -952,23 +1212,54 @@ def view_list(list_id): expenses=expenses, total_expense=total_expense, is_share=False, + is_owner=is_owner, ) @app.route("/user_expenses") @login_required def user_expenses(): - from sqlalchemy.orm import joinedload + start_date_str = request.args.get("start_date") + end_date_str = request.args.get("end_date") + show_all = request.args.get("show_all", "false").lower() == "true" - 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()) + start = None + end = None + + expenses_query = Expense.query.join( + ShoppingList, Expense.list_id == ShoppingList.id + ).options(joinedload(Expense.list)) + + # Jeśli show_all to False, filtruj tylko po bieżącym użytkowniku + if not show_all: + expenses_query = expenses_query.filter(ShoppingList.owner_id == current_user.id) + else: + expenses_query = expenses_query.filter( + or_( + ShoppingList.owner_id == current_user.id, ShoppingList.is_public == True + ) + ) + + if start_date_str and end_date_str: + try: + start = datetime.strptime(start_date_str, "%Y-%m-%d") + end = datetime.strptime(end_date_str, "%Y-%m-%d") + timedelta(days=1) + expenses_query = expenses_query.filter( + Expense.added_at >= start, Expense.added_at < end + ) + except ValueError: + flash("Błędny zakres dat", "danger") + + expenses = expenses_query.order_by(Expense.added_at.desc()).all() + + list_ids = {e.list_id for e in expenses} + lists = ( + ShoppingList.query.filter(ShoppingList.id.in_(list_ids)) + .order_by(ShoppingList.created_at.desc()) .all() ) - rows = [ + expense_table = [ { "title": e.list.title if e.list else "Nieznana", "amount": e.amount, @@ -977,51 +1268,49 @@ def user_expenses(): for e in expenses ] - return render_template("user_expenses.html", expense_table=rows) + lists_data = [ + { + "id": l.id, + "title": l.title, + "created_at": l.created_at, + "total_expense": sum( + e.amount + for e in l.expenses + if (not start or not end) or (e.added_at >= start and e.added_at < end) + ), + "owner_username": l.owner.username if l.owner else "?", + } + for l in lists + ] + + return render_template( + "user_expenses.html", + expense_table=expense_table, + lists_data=lists_data, + show_all=show_all, + ) -@app.route("/user/expenses_data") +@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") + show_all = request.args.get("show_all", "false").lower() == "true" - query = Expense.query.join(ShoppingList, Expense.list_id == ShoppingList.id).filter( - ShoppingList.owner_id == current_user.id + result = get_expenses_aggregated_by_list_created_at( + user_only=True, + admin=False, + show_all=show_all, + range_type=range_type, + start_date=start_date, + end_date=end_date, + user_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() - ts = e.added_at or datetime.now(timezone.utc) - - if range_type == "monthly": - key = ts.strftime("%Y-%m") - elif range_type == "quarterly": - key = f"{ts.year}-Q{((ts.month - 1) // 3) + 1}" - elif range_type == "halfyearly": - key = f"{ts.year}-H{1 if ts.month <= 6 else 2}" - elif range_type == "yearly": - key = str(ts.year) - else: - key = ts.strftime("%Y-%m-%d") - grouped[key] += e.amount - - labels = sorted(grouped) - data = [round(grouped[label], 2) for label in labels] - return jsonify({"labels": labels, "expenses": data}) + if "error" in result: + return jsonify({"error": result["error"]}), 400 + return jsonify(result) @app.route("/share/") @@ -1039,6 +1328,12 @@ def shared_list(token=None, list_id=None): list_id ) + for item in items: + if item.added_by != shopping_list.owner_id: + item.added_by_display = item.added_by_user.username if item.added_by_user else "?" + else: + item.added_by_display = None + return render_template( "list_share.html", list=shopping_list, @@ -1191,7 +1486,7 @@ def upload_receipt(list_id): @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["Cache-Control"] = app.config["UPLOADS_CACHE_CONTROL"] response.headers.pop("Pragma", None) response.headers.pop("Content-Disposition", None) mime, _ = mimetypes.guess_type(filename) @@ -1232,6 +1527,7 @@ def rotate_receipt_user(receipt_id): try: rotate_receipt_by_id(receipt_id) + recalculate_filesizes(receipt_id) flash("Obrócono paragon", "success") except FileNotFoundError: flash("Plik nie istnieje", "danger") @@ -1285,8 +1581,6 @@ def analyze_receipts_for_list(list_id): value, lines = extract_total_tesseract(image) except Exception as e: - import traceback - print(f"OCR error for {receipt.filename}:\n{traceback.format_exc()}") value = 0.0 lines = [] @@ -1309,6 +1603,22 @@ def analyze_receipts_for_list(list_id): return jsonify({"results": results, "total": round(total, 2)}) +@app.route("/user_crop_receipt", methods=["POST"]) +@login_required +def crop_receipt_user(): + receipt_id = request.form.get("receipt_id") + file = request.files.get("cropped_image") + + receipt = Receipt.query.get_or_404(receipt_id) + list_obj = ShoppingList.query.get_or_404(receipt.list_id) + + if list_obj.owner_id != current_user.id and not current_user.is_admin: + return jsonify(success=False, error="Brak dostępu"), 403 + + result = handle_crop_receipt(receipt_id, file) + return jsonify(result) + + @app.route("/admin") @login_required @admin_required @@ -1329,10 +1639,8 @@ def admin_panel(): 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() != ""]) - receipt_pattern = f"list_{l.id}" - receipt_files = [f for f in all_files if receipt_pattern in f] + receipts_count = Receipt.query.filter_by(list_id=l.id).count() - # obliczenie czy wygasła if l.is_temporary and l.expires_at: expires_at = l.expires_at if expires_at.tzinfo is None: @@ -1348,7 +1656,7 @@ def admin_panel(): "purchased_count": purchased_count, "percent": round(percent), "comments_count": comments_count, - "receipts_count": len(receipt_files), + "receipts_count": receipts_count, "total_expense": l.total_expense, "expired": is_expired, } @@ -1535,6 +1843,18 @@ def admin_receipts(id): try: if id == "all": receipts = Receipt.query.order_by(Receipt.uploaded_at.desc()).all() + + # Szukaj sierot tylko dla "all" + upload_folder = app.config["UPLOAD_FOLDER"] + all_db_filenames = set(r.filename for r in receipts) + files_on_disk = set(os.listdir(upload_folder)) + stale_files = [ + f + for f in files_on_disk + if f.endswith(".webp") + and f not in all_db_filenames + and f.startswith("list_") + ] else: list_id = int(id) receipts = ( @@ -1542,11 +1862,17 @@ def admin_receipts(id): .order_by(Receipt.uploaded_at.desc()) .all() ) + stale_files = [] # brak sierot except ValueError: flash("Nieprawidłowe ID listy.", "danger") return redirect(url_for("admin_panel")) - return render_template("admin/receipts.html", receipts=receipts) + return render_template( + "admin/receipts.html", + receipts=receipts, + orphan_files=stale_files, + orphan_files_count=len(stale_files), + ) @app.route("/admin/rotate_receipt/") @@ -1555,6 +1881,7 @@ def admin_receipts(id): def rotate_receipt(receipt_id): try: rotate_receipt_by_id(receipt_id) + recalculate_filesizes(receipt_id) flash("Obrócono paragon", "success") except FileNotFoundError: flash("Plik nie istnieje", "danger") @@ -1565,9 +1892,27 @@ def rotate_receipt(receipt_id): @app.route("/admin/delete_receipt/") +@app.route("/admin/delete_receipt/orphan/") @login_required @admin_required -def delete_receipt(receipt_id): +def delete_receipt(receipt_id=None, filename=None): + if filename: # tryb orphan + safe_filename = os.path.basename(filename) + if Receipt.query.filter_by(filename=safe_filename).first(): + flash("Nie można usunąć pliku powiązanego z bazą!", "danger") + else: + file_path = os.path.join(app.config["UPLOAD_FOLDER"], safe_filename) + if os.path.exists(file_path): + try: + os.remove(file_path) + flash(f"Usunięto plik: {safe_filename}", "success") + except Exception as e: + flash(f"Błąd przy usuwaniu pliku: {e}", "danger") + else: + flash("Plik już nie istnieje.", "warning") + return redirect(url_for("admin_receipts", id="all")) + + # tryb z rekordem w bazie try: delete_receipt_by_id(receipt_id) flash("Paragon usunięty", "success") @@ -1594,6 +1939,8 @@ def rename_receipt(receipt_id): try: os.rename(old_path, new_path) receipt.filename = new_filename + db.session.flush() + recalculate_filesizes(receipt.id) db.session.commit() flash("Zmieniono nazwę pliku", "success") except Exception as e: @@ -1725,6 +2072,18 @@ def edit_list(list_id): flash("Niepoprawna kwota", "danger") return redirect(url_for("edit_list", list_id=list_id)) + created_month = request.form.get("created_month") + if created_month: + try: + year, month = map(int, created_month.split("-")) + l.created_at = datetime(year, month, 1, tzinfo=timezone.utc) + except ValueError: + flash( + "Nieprawidłowy format miesiąca (przeniesienie daty utworzenia)", + "danger", + ) + return redirect(url_for("edit_list", list_id=list_id)) + db.session.add(l) db.session.commit() flash("Zapisano zmiany listy", "success") @@ -1907,110 +2266,21 @@ def admin_expenses_data(): 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") + start_date = request.args.get("start_date") + end_date = request.args.get("end_date") - # now = datetime.utcnow() - now = datetime.now(timezone.utc) - - 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") - - 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"), - ) - .filter(Expense.added_at >= start_date, Expense.added_at <= end_date) - .group_by("year", "month") - .order_by("year", "month") - .all() - ) - - for row in expenses_query: - label = f"{int(row.month):02d}/{int(row.year)}" - 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" - ) - return response - - 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 - 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 - ) - expenses.append(round(month_sum, 2)) - - elif range_type == "quarterly": - for i in range(3, -1, -1): - 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 - ) - labels.append(label) - expenses.append(round(quarter_sum, 2)) - - elif range_type == "halfyearly": - for i in range(1, -1, -1): - 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("month", Expense.added_at) <= 6) - if half == 1 - else (extract("month", Expense.added_at) > 6) - ) - .scalar() - or 0 - ) - labels.append(label) - expenses.append(round(half_sum, 2)) - - 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 - ) - labels.append(label) - expenses.append(round(year_sum, 2)) - - response = make_response(jsonify({"labels": labels, "expenses": expenses})) - response.headers["Cache-Control"] = "no-store, no-cache" - return response + result = get_expenses_aggregated_by_list_created_at( + user_only=False, + admin=True, + show_all=True, + range_type=range_type, + start_date=start_date, + end_date=end_date, + user_id=None, + ) + if "error" in result: + return jsonify({"error": result["error"]}), 400 + return jsonify(result) @app.route("/admin/promote_user/") @@ -2051,55 +2321,18 @@ def demote_user(user_id): @app.route("/admin/crop_receipt", methods=["POST"]) @login_required @admin_required -def crop_receipt(): +def crop_receipt_admin(): receipt_id = request.form.get("receipt_id") file = request.files.get("cropped_image") - - if not receipt_id or not file: - return jsonify(success=False, error="Brak danych") - - receipt = Receipt.query.get_or_404(receipt_id) - old_path = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename) - - try: - image = Image.open(file).convert("RGB") - new_filename = generate_new_receipt_filename(receipt.list_id) - new_path = os.path.join(app.config["UPLOAD_FOLDER"], new_filename) - image.save(new_path, format="WEBP", quality=100) - - if os.path.exists(old_path): - os.remove(old_path) - - receipt.filename = new_filename - db.session.commit() - - return jsonify(success=True) - except Exception as e: - return jsonify(success=False, error=str(e)) + result = handle_crop_receipt(receipt_id, file) + return jsonify(result) @app.route("/admin/recalculate_filesizes") @login_required @admin_required -def recalculate_filesizes(): - updated = 0 - not_found = 0 - unchanged = 0 - - receipts = Receipt.query.all() - for r in receipts: - filepath = os.path.join(app.config["UPLOAD_FOLDER"], r.filename) - if os.path.exists(filepath): - real_size = os.path.getsize(filepath) - if r.filesize != real_size: - r.filesize = real_size - updated += 1 - else: - unchanged += 1 - else: - not_found += 1 - - db.session.commit() +def recalculate_filesizes_all(): + updated, unchanged, not_found = recalculate_filesizes() flash( f"Zaktualizowano: {updated}, bez zmian: {unchanged}, brak pliku: {not_found}", "success", @@ -2117,6 +2350,13 @@ def healthcheck(): return "OK", 200 +@app.route("/robots.txt") +def robots_txt(): + if app.config.get("DISABLE_ROBOTS", False): + return "User-agent: *\nDisallow: /", 200, {"Content-Type": "text/plain"} + return "User-agent: *\nAllow: /", 200, {"Content-Type": "text/plain"} + + # ========================================================================================= # SOCKET.IO # ========================================================================================= @@ -2213,6 +2453,10 @@ def handle_add_item(data): name = data["name"].strip() quantity = data.get("quantity", 1) + list_obj = db.session.get(ShoppingList, list_id) + if not list_obj: + return + try: quantity = int(quantity) if quantity < 1: @@ -2248,12 +2492,15 @@ def handle_add_item(data): if max_position is None: max_position = 0 + user_id = current_user.id if current_user.is_authenticated else None + user_name = current_user.username if current_user.is_authenticated else "Gość" + new_item = Item( list_id=list_id, name=name, quantity=quantity, position=max_position + 1, - added_by=current_user.id if current_user.is_authenticated else None, + added_by=user_id, ) db.session.add(new_item) @@ -2271,9 +2518,9 @@ def handle_add_item(data): "id": new_item.id, "name": new_item.name, "quantity": new_item.quantity, - "added_by": ( - current_user.username if current_user.is_authenticated else "Gość" - ), + "added_by": user_name, + "added_by_id": user_id, + "owner_id": list_obj.owner_id, }, to=str(list_id), include_self=True, @@ -2345,7 +2592,19 @@ 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).order_by(Item.position.asc()).all() + + shopping_list = db.session.get(ShoppingList, list_id) + if not shopping_list: + return + + owner_id = shopping_list.owner_id + + items = ( + Item.query.options(joinedload(Item.added_by_user)) + .filter_by(list_id=list_id) + .order_by(Item.position.asc()) + .all() + ) items_data = [] for item in items: @@ -2358,6 +2617,9 @@ def handle_request_full_list(data): "not_purchased": item.not_purchased, "not_purchased_reason": item.not_purchased_reason, "note": item.note or "", + "added_by": item.added_by_user.username if item.added_by_user else None, + "added_by_id": item.added_by_user.id if item.added_by_user else None, + "owner_id": owner_id, } ) @@ -2433,21 +2695,50 @@ def handle_unmark_not_purchased(data): emit("item_unmarked_not_purchased", {"item_id": item.id}, to=str(item.list_id)) -""" @socketio.on('receipt_uploaded') -def handle_receipt_uploaded(data): - list_id = data['list_id'] - url = data['url'] - - emit('receipt_added', { - 'url': url - }, to=str(list_id), include_self=False) """ - - @app.cli.command("create_db") def create_db(): - db.create_all() - print("Database created.") + inspector = inspect(db.engine) + expected_tables = set(db.Model.metadata.tables.keys()) + actual_tables = set(inspector.get_table_names()) + missing_tables = expected_tables - actual_tables + extra_tables = actual_tables - expected_tables + + if missing_tables: + print(f"Brakuje tabel: {', '.join(sorted(missing_tables))}") + + if extra_tables: + print(f"Dodatkowe tabele w bazie: {', '.join(sorted(extra_tables))}") + + critical_error = False + + for table in expected_tables & actual_tables: + expected_columns = set(c.name for c in db.Model.metadata.tables[table].columns) + actual_columns = set(c["name"] for c in inspector.get_columns(table)) + missing_cols = expected_columns - actual_columns + extra_cols = actual_columns - expected_columns + + if missing_cols: + print( + f"Brakuje kolumn w tabeli '{table}': {', '.join(sorted(missing_cols))}" + ) + critical_error = True + + if extra_cols: + print( + f"Dodatkowe kolumny w tabeli '{table}': {', '.join(sorted(extra_cols))}" + ) + + if missing_tables or critical_error: + print("Struktura bazy jest niekompletna lub niezgodna. Przerwano.") + return + + if not actual_tables: + db.create_all() + print("Utworzono strukturę bazy danych.") + else: + print("Struktura bazy danych jest poprawna.") if __name__ == "__main__": - socketio.run(app, host="0.0.0.0", port=8000, debug=True) + logging.basicConfig(level=logging.DEBUG if DEBUG_MODE else logging.INFO) + socketio.run(app, host="0.0.0.0", port=8000, debug=False) diff --git a/config.py b/config.py index 7fdf671..f5349f5 100644 --- a/config.py +++ b/config.py @@ -30,3 +30,19 @@ class Config: SESSION_TIMEOUT_MINUTES = int(os.environ.get("SESSION_TIMEOUT_MINUTES", "10080") or "10080") except ValueError: SESSION_TIMEOUT_MINUTES = 10080 + SESSION_COOKIE_SECURE = os.environ.get("SESSION_COOKIE_SECURE", "0") == "1" + + ENABLE_HSTS = os.environ.get("ENABLE_HSTS", "0") == "1" + ENABLE_XFO = os.environ.get("ENABLE_XFO", "0") == "1" + ENABLE_XCTO = os.environ.get("ENABLE_XCTO", "0") == "1" + ENABLE_CSP = os.environ.get("ENABLE_CSP", "0") == "1" + ENABLE_PP = os.environ.get("ENABLE_PP", "0") == "1" + REFERRER_POLICY = os.environ.get("REFERRER_POLICY") or None + + DEBUG_MODE = os.environ.get("DEBUG_MODE", "1") == "1" + DISABLE_ROBOTS = os.environ.get("DISABLE_ROBOTS", "0") == "1" + JS_CACHE_CONTROL = os.environ.get("JS_CACHE_CONTROL", "no-cache, no-store, must-revalidate") + CSS_CACHE_CONTROL = os.environ.get("CSS_CACHE_CONTROL", "public, max-age=3600") + LIB_JS_CACHE_CONTROL = os.environ.get("LIB_JS_CACHE_CONTROL", "public, max-age=604800") + LIB_CSS_CACHE_CONTROL = os.environ.get("LIB_CSS_CACHE_CONTROL", "public, max-age=604800") + UPLOADS_CACHE_CONTROL = os.environ.get("UPLOADS_CACHE_CONTROL", "public, max-age=2592000, immutable") \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index d5417f9..1d18911 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,3 +14,4 @@ opencv-python-headless psycopg2-binary # pgsql pymysql # mysql cryptography # mysql8 +flask-talisman # nagłówki \ No newline at end of file diff --git a/static/css/style.css b/static/css/style.css index ee72e62..0174ec6 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -205,7 +205,6 @@ input.form-control { box-shadow: 0 -1px 4px rgba(0, 0, 0, 0.25); } - @media (max-width: 768px) { .info-bar-fixed { position: static; diff --git a/static/js/receipt_crop.js b/static/js/_unused_receipt_crop.js similarity index 51% rename from static/js/receipt_crop.js rename to static/js/_unused_receipt_crop.js index 94bf38d..33754a0 100644 --- a/static/js/receipt_crop.js +++ b/static/js/_unused_receipt_crop.js @@ -4,22 +4,22 @@ let currentReceiptId; document.addEventListener("DOMContentLoaded", function () { const cropModal = document.getElementById("cropModal"); const cropImage = document.getElementById("cropImage"); + const spinner = document.getElementById("cropLoading"); cropModal.addEventListener("shown.bs.modal", function (event) { const button = event.relatedTarget; const imgSrc = button.getAttribute("data-img-src"); currentReceiptId = button.getAttribute("data-receipt-id"); - const image = document.getElementById("cropImage"); - image.src = imgSrc; + cropImage.src = imgSrc; if (cropper) { cropper.destroy(); cropper = null; } - image.onload = () => { - cropper = new Cropper(image, { + cropImage.onload = () => { + cropper = new Cropper(cropImage, { viewMode: 1, autoCropArea: 1, responsive: true, @@ -36,7 +36,51 @@ document.addEventListener("DOMContentLoaded", function () { document.getElementById("saveCrop").addEventListener("click", function () { if (!cropper) return; - cropper.getCroppedCanvas().toBlob(function (blob) { + spinner.classList.remove("d-none"); + + const cropData = cropper.getData(); + const imageData = cropper.getImageData(); + + const scaleX = imageData.naturalWidth / imageData.width; + const scaleY = imageData.naturalHeight / imageData.height; + + const width = cropData.width * scaleX; + const height = cropData.height * scaleY; + + if (width < 1 || height < 1) { + spinner.classList.add("d-none"); + showToast("Obszar przycięcia jest zbyt mały lub pusty", "danger"); + return; + } + + // Ogranicz do 2000x2000 w proporcji + const maxDim = 2000; + const scale = Math.min(1, maxDim / Math.max(width, height)); + + const finalWidth = Math.round(width * scale); + const finalHeight = Math.round(height * scale); + + const croppedCanvas = cropper.getCroppedCanvas({ + width: finalWidth, + height: finalHeight, + imageSmoothingEnabled: true, + imageSmoothingQuality: 'high', + }); + + + if (!croppedCanvas) { + spinner.classList.add("d-none"); + showToast("Nie można uzyskać obrazu przycięcia", "danger"); + return; + } + + croppedCanvas.toBlob(function (blob) { + if (!blob) { + spinner.classList.add("d-none"); + showToast("Nie udało się zapisać obrazu", "danger"); + return; + } + const formData = new FormData(); formData.append("receipt_id", currentReceiptId); formData.append("cropped_image", blob); @@ -47,6 +91,7 @@ document.addEventListener("DOMContentLoaded", function () { }) .then((res) => res.json()) .then((data) => { + spinner.classList.add("d-none"); if (data.success) { showToast("Zapisano przycięty paragon", "success"); setTimeout(() => location.reload(), 1500); @@ -55,9 +100,10 @@ document.addEventListener("DOMContentLoaded", function () { } }) .catch((err) => { + spinner.classList.add("d-none"); showToast("Błąd sieci", "danger"); console.error(err); }); - }, "image/webp"); + }, "image/webp", 1.0); }); }); diff --git a/static/js/admin_receipt_crop.js b/static/js/admin_receipt_crop.js new file mode 100644 index 0000000..4a8335b --- /dev/null +++ b/static/js/admin_receipt_crop.js @@ -0,0 +1,39 @@ +(function () { + document.addEventListener("DOMContentLoaded", function () { + const cropModal = document.getElementById("adminCropModal"); + const cropImage = document.getElementById("adminCropImage"); + const spinner = document.getElementById("adminCropLoading"); + const saveButton = document.getElementById("adminSaveCrop"); + + if (!cropModal || !cropImage || !spinner || !saveButton) return; + + let cropper; + let currentReceiptId; + const currentEndpoint = "/admin/crop_receipt"; + + cropModal.addEventListener("shown.bs.modal", function (event) { + const button = event.relatedTarget; + const imgSrc = button.getAttribute("data-img-src"); + currentReceiptId = button.getAttribute("data-receipt-id"); + cropImage.src = imgSrc; + + document.querySelectorAll('.cropper-container').forEach(e => e.remove()); + + if (cropper) cropper.destroy(); + cropImage.onload = () => { + cropper = cropUtils.initCropper(cropImage); + }; + }); + + cropModal.addEventListener("hidden.bs.modal", function () { + cropUtils.cleanUpCropper(cropImage, cropper); + cropper = null; + }); + + saveButton.addEventListener("click", function () { + if (!cropper) return; + spinner.classList.remove("d-none"); + cropUtils.handleCrop(currentEndpoint, currentReceiptId, cropper, spinner); + }); + }); +})(); diff --git a/static/js/functions.js b/static/js/functions.js index 6df9da8..e344aaf 100644 --- a/static/js/functions.js +++ b/static/js/functions.js @@ -272,8 +272,101 @@ function isListDifferent(oldItems, newItems) { return false; } -function updateListSmoothly(newItems) { +function renderItem(item, isShare = window.IS_SHARE, showEditOnly = false) { + const li = document.createElement('li'); + li.id = `item-${item.id}`; + li.dataset.name = item.name.toLowerCase(); + li.className = `list-group-item d-flex justify-content-between align-items-center flex-wrap clickable-item ${item.purchased ? 'bg-success text-white' + : item.not_purchased ? 'bg-warning text-dark' + : 'item-not-checked' + }`; + const isOwner = window.IS_OWNER === true || window.IS_OWNER === 'true'; + const allowEdit = !isShare || showEditOnly || isOwner; + + let quantityBadge = ''; + if (item.quantity && item.quantity > 1) { + quantityBadge = `x${item.quantity}`; + } + + let checkboxOrIcon = item.not_purchased + ? `🚫` + : ``; + + let noteHTML = item.note + ? `[ ${item.note} ]` : ''; + + let reasonHTML = item.not_purchased_reason + ? `[ Powód: ${item.not_purchased_reason} ]` : ''; + + let dragHandle = window.isSorting ? `` : ''; + + let left = ` +
+ ${dragHandle} + ${checkboxOrIcon} + ${item.name} ${quantityBadge} + ${noteHTML} + ${reasonHTML} +
`; + + let rightButtons = ''; + + // ✏️ i 🗑️ — tylko jeśli nie jesteśmy w trybie /share lub jesteśmy w 15s (tymczasowo) lub jesteśmy właścicielem + if (allowEdit) { + rightButtons += ` + + `; + } + + // ✅ Jeśli element jest oznaczony jako niekupiony — pokaż "Przywróć" + if (item.not_purchased) { + rightButtons += ` + `; + } + + // ⚠️ tylko jeśli NIE jest oznaczony jako niekupiony i nie jesteśmy w 15s + if (!item.not_purchased && (isOwner || (isShare && !showEditOnly))) { + + rightButtons += ` + `; + } + + // 📝 tylko jeśli jesteśmy w /share i nie jesteśmy w 15s + if (isShare && !showEditOnly && !isOwner) { + + rightButtons += ` + `; + } + + li.innerHTML = `${left}
${rightButtons}
`; + + if (item.added_by && item.owner_id && item.added_by_id && item.added_by_id !== item.owner_id) { + const infoEl = document.createElement('small'); + infoEl.className = 'text-info ms-4'; + infoEl.innerHTML = `[Dodał/a: ${item.added_by}]`; + li.querySelector('.d-flex.align-items-center')?.appendChild(infoEl); + } + + return li; +} + +function updateListSmoothly(newItems) { const itemsContainer = document.getElementById('items'); const existingItemsMap = new Map(); @@ -285,68 +378,7 @@ function updateListSmoothly(newItems) { 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) { - li = document.createElement('li'); - li.id = `item-${item.id}`; - } - - // 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' - }`; - - // Wewnętrzny HTML - li.innerHTML = ` -
- ${isSorting ? `` : ''} - ${!item.not_purchased ? ` - - ` : ` - 🚫 - `} - ${item.name} ${quantityBadge} - - ${item.note ? `[ ${item.note} ]` : ''} - ${item.not_purchased_reason ? `[ Powód: ${item.not_purchased_reason} ]` : ''} -
-
- ${item.not_purchased ? ` - - ` : ` - - ${window.IS_SHARE ? ` - - ` : ''} - `} - ${!window.IS_SHARE ? ` - - - ` : ''} -
- `; - + const li = renderItem(item); fragment.appendChild(li); }); diff --git a/static/js/live.js b/static/js/live.js index e96ebdb..ee4654b 100644 --- a/static/js/live.js +++ b/static/js/live.js @@ -127,69 +127,59 @@ function setupList(listId, username) { showToast(`Dodano wydatek: ${data.amount.toFixed(2)} PLN`, 'info'); }); + socket.on('item_added', data => { showToast(`${data.added_by} dodał: ${data.name}`, 'info'); - 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}`; - } - - const countdownId = `countdown-${data.id}`; - const countdownBtn = ` - - `; - - li.innerHTML = ` -
- - - ${data.name} ${quantityBadge} - -
-
- ${countdownBtn} - - -
- `; + const item = { + ...data, + purchased: false, + not_purchased: false, + not_purchased_reason: '', + note: '' + }; + const li = renderItem(item, false, true); // ← tryb 15s document.getElementById('items').appendChild(li); toggleEmptyPlaceholder(); + updateProgressBar(); - // ⏳ Licznik odliczania - let seconds = 15; - const countdownEl = document.getElementById(countdownId); - const intervalId = setInterval(() => { - seconds--; - if (countdownEl) { - countdownEl.textContent = `${seconds}s`; - } - if (seconds <= 0) { - clearInterval(intervalId); - if (countdownEl) countdownEl.remove(); - } - }, 1000); + if (window.IS_SHARE) { + const countdownId = `countdown-${data.id}`; + const countdownBtn = document.createElement('button'); + countdownBtn.type = 'button'; + countdownBtn.className = 'btn btn-outline-warning'; + countdownBtn.id = countdownId; + countdownBtn.disabled = true; + countdownBtn.textContent = '15s'; - // 🔁 Request listy po 15s - setTimeout(() => { - if (window.LIST_ID) { - socket.emit('request_full_list', { list_id: window.LIST_ID }); - } - }, 15000); + li.querySelector('.btn-group')?.prepend(countdownBtn); + + let seconds = 15; + const intervalId = setInterval(() => { + const el = document.getElementById(countdownId); + if (el) { + seconds--; + el.textContent = `${seconds}s`; + if (seconds <= 0) { + el.remove(); + clearInterval(intervalId); + } + } else { + clearInterval(intervalId); + } + }, 1000); + + setTimeout(() => { + const existing = document.getElementById(`item-${data.id}`); + if (existing) { + const updated = renderItem(item, true); + existing.replaceWith(updated); + } + }, 15000); + } }); - - socket.on('item_deleted', data => { const li = document.getElementById(`item-${data.item_id}`); if (li) { @@ -215,43 +205,39 @@ function setupList(listId, username) { }); socket.on('note_updated', data => { - const itemEl = document.getElementById(`item-${data.item_id}`); - if (itemEl) { - let noteEl = itemEl.querySelector('small'); - if (noteEl) { - //noteEl.innerHTML = `[ Notatka: ${data.note} ]`; - noteEl.innerHTML = `[ ${data.note} ]`; - } else { - const newNote = document.createElement('small'); - newNote.className = 'text-danger ms-4'; - //newNote.innerHTML = `[ Notatka: ${data.note} ]`; - newNote.innerHTML = `[ ${data.note} ]`; + const idx = window.currentItems.findIndex(i => i.id === data.item_id); + if (idx !== -1) { + window.currentItems[idx].note = data.note; - const flexColumn = itemEl.querySelector('.d-flex.flex-column'); - if (flexColumn) { - flexColumn.appendChild(newNote); - } else { - itemEl.appendChild(newNote); - } + const newItem = renderItem(window.currentItems[idx], true); + const oldItem = document.getElementById(`item-${data.item_id}`); + if (oldItem && newItem) { + oldItem.replaceWith(newItem); } } + showToast('Notatka dodana/zaktualizowana', 'success'); }); - socket.on('item_edited', data => { - const nameSpan = document.getElementById(`name-${data.item_id}`); - if (nameSpan) { - let quantityBadge = ''; - if (data.new_quantity && data.new_quantity > 1) { - quantityBadge = ` x${data.new_quantity}`; - } - nameSpan.innerHTML = `${data.new_name}${quantityBadge}`; - } - showToast(`Zaktualizowano produkt: ${data.new_name} (x${data.new_quantity})`, 'success'); - }); - updateProgressBar(); - toggleEmptyPlaceholder(); + socket.on('item_edited', data => { + const idx = window.currentItems.findIndex(i => i.id === data.item_id); + if (idx !== -1) { + window.currentItems[idx].name = data.new_name; + window.currentItems[idx].quantity = data.new_quantity; + + const newItem = renderItem(window.currentItems[idx], true); + const oldItem = document.getElementById(`item-${data.item_id}`); + if (oldItem && newItem) { + oldItem.replaceWith(newItem); + } + } + + showToast(`Zaktualizowano produkt: ${data.new_name} (x${data.new_quantity})`, 'success'); + + updateProgressBar(); + toggleEmptyPlaceholder(); + }); // --- WAŻNE: zapisz dane do reconnect --- window.LIST_ID = listId; diff --git a/static/js/receipt_crop_logic.js b/static/js/receipt_crop_logic.js new file mode 100644 index 0000000..c36af67 --- /dev/null +++ b/static/js/receipt_crop_logic.js @@ -0,0 +1,96 @@ +(function () { + function initCropper(imgEl) { + return new Cropper(imgEl, { + viewMode: 1, + autoCropArea: 1, + responsive: true, + background: false, + zoomable: true, + movable: true, + dragMode: 'move', + minContainerHeight: 400, + minContainerWidth: 400, + }); + } + + function cleanUpCropper(imgEl, cropperInstance) { + if (cropperInstance) { + cropperInstance.destroy(); + } + if (imgEl) imgEl.src = ""; + } + + function handleCrop(endpoint, receiptId, cropper, spinner) { + const cropData = cropper.getData(); + const imageData = cropper.getImageData(); + + const scaleX = imageData.naturalWidth / imageData.width; + const scaleY = imageData.naturalHeight / imageData.height; + + const width = cropData.width * scaleX; + const height = cropData.height * scaleY; + + if (width < 1 || height < 1) { + spinner.classList.add("d-none"); + showToast("Obszar przycięcia jest zbyt mały lub pusty", "danger"); + return; + } + + const maxDim = 2000; + const scale = Math.min(1, maxDim / Math.max(width, height)); + + const finalWidth = Math.round(width * scale); + const finalHeight = Math.round(height * scale); + + const croppedCanvas = cropper.getCroppedCanvas({ + width: finalWidth, + height: finalHeight, + imageSmoothingEnabled: true, + imageSmoothingQuality: 'high', + }); + + if (!croppedCanvas) { + spinner.classList.add("d-none"); + showToast("Nie można uzyskać obrazu przycięcia", "danger"); + return; + } + + croppedCanvas.toBlob(function (blob) { + if (!blob) { + spinner.classList.add("d-none"); + showToast("Nie udało się zapisać obrazu", "danger"); + return; + } + + const formData = new FormData(); + formData.append("receipt_id", receiptId); + formData.append("cropped_image", blob); + + fetch(endpoint, { + method: "POST", + body: formData, + }) + .then((res) => res.json()) + .then((data) => { + spinner.classList.add("d-none"); + if (data.success) { + showToast("Zapisano przycięty paragon", "success"); + setTimeout(() => location.reload(), 1500); + } else { + showToast("Błąd: " + (data.error || "Nieznany"), "danger"); + } + }) + .catch((err) => { + spinner.classList.add("d-none"); + showToast("Błąd sieci", "danger"); + console.error(err); + }); + }, "image/webp", 1.0); + } + + window.cropUtils = { + initCropper, + cleanUpCropper, + handleCrop, + }; +})(); diff --git a/static/js/receipt_section.js b/static/js/receipt_section.js index 9f474e9..6a8b271 100644 --- a/static/js/receipt_section.js +++ b/static/js/receipt_section.js @@ -16,3 +16,24 @@ document.addEventListener("DOMContentLoaded", function () { localStorage.setItem("receiptSectionOpen", "false"); }); }); + +document.addEventListener("DOMContentLoaded", function () { + const btn = document.getElementById("toggleReceiptBtn"); + const target = document.querySelector(btn.getAttribute("data-bs-target")); + + function updateUI() { + const isShown = target.classList.contains("show"); + btn.innerHTML = isShown + ? "📄 Ukryj sekcję paragonów" + : "📄 Pokaż sekcję paragonów"; + + btn.classList.toggle("active", isShown); + btn.classList.toggle("btn-outline-light", !isShown); + btn.classList.toggle("btn-secondary", isShown); + } + + target.addEventListener("shown.bs.collapse", updateUI); + target.addEventListener("hidden.bs.collapse", updateUI); + + updateUI(); +}); \ No newline at end of file diff --git a/static/js/receipt_upload.js b/static/js/receipt_upload.js index 7481ae1..38c4f2b 100644 --- a/static/js/receipt_upload.js +++ b/static/js/receipt_upload.js @@ -16,7 +16,6 @@ if (!window.receiptUploaderInitialized) { const isDesktop = window.matchMedia("(pointer: fine)").matches; - // 🧼 Jedno miejsce, pełna logika desktopowa if (isDesktop) { if (cameraBtn) cameraBtn.remove(); // całkowicie usuń przycisk if (inputCamera) inputCamera.remove(); // oraz input @@ -80,6 +79,12 @@ if (!window.receiptUploaderInitialized) { } lightbox = GLightbox({ selector: ".glightbox" }); + // Pokaż sekcję OCR jeśli była ukryta + const analysisBlock = document.getElementById("receiptAnalysisBlock"); + if (analysisBlock) { + analysisBlock.classList.remove("d-none"); + } + if (!window.receiptToastShown) { showToast("Wgrano paragon", "success"); window.receiptToastShown = true; @@ -96,7 +101,6 @@ if (!window.receiptUploaderInitialized) { } }; - xhr.send(formData); } diff --git a/static/js/select_month.js b/static/js/select_month.js new file mode 100644 index 0000000..8a7f958 --- /dev/null +++ b/static/js/select_month.js @@ -0,0 +1,14 @@ +document.addEventListener("DOMContentLoaded", () => { + const select = document.getElementById("monthSelect"); + if (!select) return; + select.addEventListener("change", () => { + const month = select.value; + const url = new URL(window.location.href); + if (month) { + url.searchParams.set("month", month); + } else { + url.searchParams.delete("month"); + } + window.location.href = url.toString(); + }); +}); \ No newline at end of file diff --git a/static/js/sort_mode.js b/static/js/sort_mode.js index 0a91843..fad6e84 100644 --- a/static/js/sort_mode.js +++ b/static/js/sort_mode.js @@ -2,53 +2,54 @@ let sortable = null; let isSorting = false; function enableSortMode() { - if (sortable || isSorting) return; + if (isSorting) return; isSorting = true; + window.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'); - } - + // Odśwież widok listy z uchwytami (☰) if (window.currentItems) { updateListSmoothly(window.currentItems); } + + // Poczekaj na DOM po odświeżeniu listy + setTimeout(() => { + if (sortable) sortable.destroy(); + + sortable = Sortable.create(itemsContainer, { + animation: 150, + handle: '.drag-handle', + ghostClass: 'drag-ghost', + filter: 'input, button', + preventOnFilter: false, + onEnd: () => { + 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); + } + }); + } + }); + + updateSortButtonUI(true); + }, 50); } function disableSortMode() { @@ -56,28 +57,40 @@ function disableSortMode() { 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'); - } - + window.isSorting = false; if (window.currentItems) { updateListSmoothly(window.currentItems); } + + updateSortButtonUI(false); + } function toggleSortMode() { isSorting ? disableSortMode() : enableSortMode(); } +function updateSortButtonUI(active) { + const btn = document.getElementById('sort-toggle-btn'); + if (!btn) return; + + if (active) { + btn.textContent = '✔️ Zakończ sortowanie'; + btn.classList.remove('btn-outline-warning'); + btn.classList.add('btn-outline-success'); + } else { + btn.textContent = '✳️ Zmień kolejność'; + btn.classList.remove('btn-outline-success'); + btn.classList.add('btn-outline-warning'); + } +} + document.addEventListener('DOMContentLoaded', () => { const wasSorting = localStorage.getItem('sortModeEnabled') === 'true'; if (wasSorting) { enableSortMode(); } -}); \ No newline at end of file +}); diff --git a/static/js/user_expense_lists.js b/static/js/user_expense_lists.js new file mode 100644 index 0000000..f7ef99d --- /dev/null +++ b/static/js/user_expense_lists.js @@ -0,0 +1,160 @@ +document.addEventListener('DOMContentLoaded', () => { + const checkboxes = document.querySelectorAll('.list-checkbox'); + const totalEl = document.getElementById('listsTotal'); + const filterButtons = document.querySelectorAll('.range-btn'); + const rows = document.querySelectorAll('#listsTableBody tr'); + + const onlyWith = document.getElementById('onlyWithExpenses'); + const customStart = document.getElementById('customStart'); + const customEnd = document.getElementById('customEnd'); + + // Przywróć zapisane daty + if (localStorage.getItem('customStart')) { + customStart.value = localStorage.getItem('customStart'); + } + if (localStorage.getItem('customEnd')) { + customEnd.value = localStorage.getItem('customEnd'); + } + + function updateTotal() { + let total = 0; + checkboxes.forEach(cb => { + const row = cb.closest('tr'); + if (cb.checked && row.style.display !== 'none') { + total += parseFloat(cb.dataset.amount); + } + }); + + totalEl.textContent = total.toFixed(2) + ' PLN'; + totalEl.parentElement.classList.add('animate__animated', 'animate__fadeIn'); + setTimeout(() => { + totalEl.parentElement.classList.remove('animate__animated', 'animate__fadeIn'); + }, 400); + } + + checkboxes.forEach(cb => cb.addEventListener('change', updateTotal)); + + filterButtons.forEach(btn => { + btn.addEventListener('click', () => { + filterButtons.forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + const range = btn.dataset.range; + + // Czyść lokalne daty przy kliknięciu zakresu + localStorage.removeItem('customStart'); + localStorage.removeItem('customEnd'); + + const now = new Date(); + const todayStr = now.toISOString().slice(0, 10); + const year = now.getFullYear(); + const month = now.toISOString().slice(0, 7); + const week = `${year}-${String(getISOWeek(now)).padStart(2, '0')}`; + + rows.forEach(row => { + const rDate = row.dataset.date; + const rMonth = row.dataset.month; + const rWeek = row.dataset.week; + const rYear = row.dataset.year; + + let show = true; + if (range === 'day') show = rDate === todayStr; + if (range === 'month') show = rMonth === month; + if (range === 'week') show = rWeek === week; + if (range === 'year') show = rYear === String(year); + + row.style.display = show ? '' : 'none'; + }); + + applyExpenseFilter(); + updateTotal(); + }); + }); + + function getISOWeek(date) { + const target = new Date(date.valueOf()); + const dayNr = (date.getDay() + 6) % 7; + target.setDate(target.getDate() - dayNr + 3); + const firstThursday = new Date(target.getFullYear(), 0, 4); + const dayDiff = (target - firstThursday) / 86400000; + return 1 + Math.floor(dayDiff / 7); + } + + document.getElementById('applyCustomRange').addEventListener('click', () => { + const start = customStart.value; + const end = customEnd.value; + + // Zapamiętaj daty + localStorage.setItem('customStart', start); + localStorage.setItem('customEnd', end); + + filterButtons.forEach(b => b.classList.remove('active')); + + rows.forEach(row => { + const date = row.dataset.date; + const show = (!start || date >= start) && (!end || date <= end); + row.style.display = show ? '' : 'none'; + }); + + applyExpenseFilter(); + updateTotal(); + }); + + if (onlyWith) { + onlyWith.addEventListener('change', () => { + applyExpenseFilter(); + updateTotal(); + }); + } + + function applyExpenseFilter() { + if (!onlyWith || !onlyWith.checked) return; + rows.forEach(row => { + const amt = parseFloat(row.querySelector('.list-checkbox').dataset.amount || 0); + if (amt <= 0) row.style.display = 'none'; + }); + } + + // Domyślnie kliknij „Miesiąc” + const defaultBtn = document.querySelector('.range-btn[data-range="month"]'); + if (defaultBtn && !customStart.value && !customEnd.value) { + defaultBtn.click(); + } +}); + +document.addEventListener("DOMContentLoaded", function () { + const toggleBtn = document.getElementById("toggleAllCheckboxes"); + let allChecked = false; + + toggleBtn?.addEventListener("click", () => { + const checkboxes = document.querySelectorAll(".list-checkbox"); + allChecked = !allChecked; + + checkboxes.forEach(cb => { + cb.checked = allChecked; + }); + + toggleBtn.textContent = allChecked ? "🚫 Odznacz wszystkie" : "✅ Zaznacz wszystkie"; + const updateTotalEvent = new Event('change'); + checkboxes.forEach(cb => cb.dispatchEvent(updateTotalEvent)); + }); +}); + +document.getElementById("applyCustomRange")?.addEventListener("click", () => { + const start = document.getElementById("customStart")?.value; + const end = document.getElementById("customEnd")?.value; + if (start && end) { + const url = `/user_expenses?start_date=${start}&end_date=${end}`; + window.location.href = url; + } +}); + +document.getElementById("showAllLists").addEventListener("change", function () { + const checked = this.checked; + const url = new URL(window.location.href); + if (checked) { + url.searchParams.set("show_all", "true"); + } else { + url.searchParams.delete("show_all"); + } + window.location.href = url.toString(); +}); diff --git a/static/js/user_expenses.js b/static/js/user_expenses.js index a029e70..437dcfa 100644 --- a/static/js/user_expenses.js +++ b/static/js/user_expenses.js @@ -3,7 +3,11 @@ document.addEventListener("DOMContentLoaded", function () { const rangeLabel = document.getElementById("chartRangeLabel"); function loadExpenses(range = "monthly", startDate = null, endDate = null) { - let url = '/user/expenses_data?range=' + range; + let url = '/user_expenses_data?range=' + range; + const showAllCheckbox = document.getElementById("showAllLists"); + if (showAllCheckbox && showAllCheckbox.checked) { + url += '&show_all=true'; + } if (startDate && endDate) { url += `&start_date=${startDate}&end_date=${endDate}`; } diff --git a/static/js/user_receipt_crop.js b/static/js/user_receipt_crop.js new file mode 100644 index 0000000..86954f9 --- /dev/null +++ b/static/js/user_receipt_crop.js @@ -0,0 +1,39 @@ +(function () { + document.addEventListener("DOMContentLoaded", function () { + const cropModal = document.getElementById("userCropModal"); + const cropImage = document.getElementById("userCropImage"); + const spinner = document.getElementById("userCropLoading"); + const saveButton = document.getElementById("userSaveCrop"); + + if (!cropModal || !cropImage || !spinner || !saveButton) return; + + let cropper; + let currentReceiptId; + const currentEndpoint = "/user_crop_receipt"; + + cropModal.addEventListener("shown.bs.modal", function (event) { + const button = event.relatedTarget; + const imgSrc = button.getAttribute("data-img-src"); + currentReceiptId = button.getAttribute("data-receipt-id"); + cropImage.src = imgSrc; + + document.querySelectorAll('.cropper-container').forEach(e => e.remove()); + + if (cropper) cropper.destroy(); + cropImage.onload = () => { + cropper = cropUtils.initCropper(cropImage); + }; + }); + + cropModal.addEventListener("hidden.bs.modal", function () { + cropUtils.cleanUpCropper(cropImage, cropper); + cropper = null; + }); + + saveButton.addEventListener("click", function () { + if (!cropper) return; + spinner.classList.remove("d-none"); + cropUtils.handleCrop(currentEndpoint, currentReceiptId, cropper, spinner); + }); + }); +})(); diff --git a/static/lib/css/sort_table.min.css b/static/lib/css/sort_table.min.css new file mode 100644 index 0000000..96e8814 --- /dev/null +++ b/static/lib/css/sort_table.min.css @@ -0,0 +1 @@ +.sortable thead th:not(.no-sort){cursor:pointer}.sortable thead th:not(.no-sort)::after,.sortable thead th:not(.no-sort)::before{transition:color .1s ease-in-out;font-size:1.2em;color:rgba(0,0,0,0)}.sortable thead th:not(.no-sort)::after{margin-left:3px;content:"▸"}.sortable thead th:not(.no-sort):hover::after{color:inherit}.sortable thead th:not(.no-sort)[aria-sort=descending]::after{color:inherit;content:"▾"}.sortable thead th:not(.no-sort)[aria-sort=ascending]::after{color:inherit;content:"▴"}.sortable thead th:not(.no-sort).indicator-left::after{content:""}.sortable thead th:not(.no-sort).indicator-left::before{margin-right:3px;content:"▸"}.sortable thead th:not(.no-sort).indicator-left:hover::before{color:inherit}.sortable thead th:not(.no-sort).indicator-left[aria-sort=descending]::before{color:inherit;content:"▾"}.sortable thead th:not(.no-sort).indicator-left[aria-sort=ascending]::before{color:inherit;content:"▴"} \ No newline at end of file diff --git a/static/lib/js/sort_table.min.js b/static/lib/js/sort_table.min.js new file mode 100644 index 0000000..65070c1 --- /dev/null +++ b/static/lib/js/sort_table.min.js @@ -0,0 +1,4 @@ +document.addEventListener("click",function(d){try{var A=d.shiftKey||d.altKey,f=function k(a,l){return a.nodeName===l?a:k(a.parentNode,l)}(d.target,"TH"),v=f.parentNode,w=v.parentNode,g=w.parentNode;if("THEAD"===w.nodeName&&g.classList.contains("sortable")&&!f.classList.contains("no-sort")){var h=v.cells;for(d=0;d📄 Wszystkie listy zakupowe
- +
@@ -195,6 +195,7 @@ }); + {% endblock %}
diff --git a/templates/admin/edit_list.html b/templates/admin/edit_list.html index 9666185..5f78e19 100644 --- a/templates/admin/edit_list.html +++ b/templates/admin/edit_list.html @@ -65,6 +65,21 @@
+
+
+ +

+ {{ list.created_at.strftime('%Y-%m-%d') }} +

+
+
+ + +
+
+ +

📸 Wszystkie paragony

@@ -36,16 +36,18 @@ 🔄 Obróć o 90° ✂️ Przytnij + data-bs-target="#adminCropModal" data-img-src="{{ url_for('uploaded_file', filename=r.filename) }}" + data-receipt-id="{{ r.id }}" data-crop-endpoint="{{ url_for('crop_receipt_admin') }}"> + ✂️ Przytnij + ✏️ Zmień nazwę {% if not r.file_hash %} 🔐 Generuj hash {% endif %} - 🗑️ + 🗑️ Usuń ✏️ Edytuj listę #{{ r.list_id }} @@ -64,7 +66,33 @@
- {% endif %} - @@ -87,6 +87,7 @@ {% if request.endpoint != 'system_auth' %} + @@ -98,7 +99,7 @@ }); - {% if '/admin/' in request.path %} + {% if '/admin/receipts' in request.path or '/edit_my_list' in request.path %} {% endif %} diff --git a/templates/edit_my_list.html b/templates/edit_my_list.html index b1dea37..8444f9f 100644 --- a/templates/edit_my_list.html +++ b/templates/edit_my_list.html @@ -37,6 +37,21 @@ + +
+
+ +

+ {{ list.created_at.strftime('%Y-%m-%d') }} +

+
+
+ + +
+
+ @@ -116,14 +136,35 @@ - - - - + + + {% endblock %} {% block scripts %} + + {% endblock %} \ No newline at end of file diff --git a/templates/list.html b/templates/list.html index 7a69337..3ddae8a 100644 --- a/templates/list.html +++ b/templates/list.html @@ -55,7 +55,7 @@ 📊 Postęp listy — {{ purchased_count }}/ {{ total_count }} kupionych - ({{ percent|round(0) }}%) + ({{ percent|int }}%)
@@ -108,36 +108,49 @@ {% endif %} - {% if item.note %} - [ {{ item.note }} ] - {% endif %} +
+ {% set info_parts = [] %} + {% if item.note %} + {% set _ = info_parts.append('[ ' ~ item.note ~ ' ]') %} + {% endif %} + {% if item.not_purchased_reason %} + {% set _ = info_parts.append('[ Powód: ' ~ item.not_purchased_reason ~ ' + ]') %} + {% endif %} + {% if item.added_by_display %} + {% set _ = info_parts.append('[ Dodał/a: ' ~ item.added_by_display ~ ' ]') + %} + {% endif %} - {% if item.not_purchased_reason %} - [ Powód: {{ item.not_purchased_reason }} ] - {% endif %} + {% if info_parts %} +
+ {{ info_parts | join(' ') | safe }} +
+ {% endif %} +
- {% if item.not_purchased %} - - {% else %} - {% endif %} - {% if not is_share %} - - {% endif %}
@@ -215,6 +228,8 @@ const isShare = document.getElementById('items').dataset.isShare === 'true'; window.IS_SHARE = isShare; window.LIST_ID = {{ list.id }}; + window.IS_OWNER = {{ 'true' if is_owner else 'false' }}; + diff --git a/templates/list_share.html b/templates/list_share.html index 495065f..1675385 100644 --- a/templates/list_share.html +++ b/templates/list_share.html @@ -43,31 +43,47 @@ {% endif %} - {% if item.note %} - [ {{ item.note }} ] - {% endif %} - {% if item.not_purchased_reason %} - [ Powód: {{ item.not_purchased_reason }} ] - {% endif %} +
+ {% set info_parts = [] %} + {% if item.note %} + {% set _ = info_parts.append('[ ' ~ item.note ~ ' ]') %} + {% endif %} + {% if item.not_purchased_reason %} + {% set _ = info_parts.append('[ Powód: ' ~ item.not_purchased_reason ~ ' + ]') %} + {% endif %} + {% if item.added_by_display %} + {% set _ = info_parts.append('[ Dodał/a: ' ~ item.added_by_display ~ ' ]') + %} + {% endif %} + + {% if info_parts %} +
+ {{ info_parts | join(' ') | safe }} +
+ {% endif %} +
{% if item.not_purchased %} - {% else %} - - - {% endif %}
+ {% else %}
  • @@ -98,16 +114,15 @@ {% endif %}

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

    - -
    +
    {% set receipt_pattern = 'list_' ~ list.id %} - {% if receipt_files %} -
    -
    +
    🧠 Analiza paragonów (OCR)

    System spróbuje automatycznie rozpoznać kwoty z dodanych paragonów.

    @@ -118,14 +133,10 @@ {% else %}
    🔒 Tylko zalogowani użytkownicy mogą zlecać analizę OCR paragonów.
    {% endif %} -
    - {% endif %} -
    📸 Paragony dodane do tej listy
    -
    {% if receipt_files %} {% for file in receipt_files %} @@ -163,14 +174,15 @@ - diff --git a/templates/main.html b/templates/main.html index 1891bc7..1cce537 100644 --- a/templates/main.html +++ b/templates/main.html @@ -31,6 +31,33 @@
    {% endif %} +{% set month_names = ["styczeń", "luty", "marzec", "kwiecień", "maj", "czerwiec", "lipiec", "sierpień", "wrzesień", +"październik", "listopad", "grudzień"] %} +{% set selected_month = request.args.get('month') or now.strftime('%Y-%m') %} + + +
    + + +
    + + +
    + +
    + {% if current_user.is_authenticated %}

    Twoje listy @@ -78,8 +105,7 @@ {% endfor %} {% else %} -

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

    +

    Nie utworzono żadnej listy

    {% endif %} {% endif %} @@ -114,7 +140,6 @@

    Brak dostępnych list publicznych do wyświetlenia

    {% endif %} - - +
    + + +
    - -
    + + +
    - {% 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 %} + +
    + + +
    + + +
    + Od + + Do + + +
    + +
    + +
    +
    +

  • + + + + + + + + + + {% for list in lists_data %} + + + + + + + {% endfor %} + +
    Nazwa listyDataWydatki (PLN)
    + + + {{ list.title }} +
    👤 {{ list.owner_username or '?' }} + +
    {{ list.created_at.strftime('%Y-%m-%d') }}{{ '%.2f'|format(list.total_expense) }}
    +
    + +
    +
    💰 Suma zaznaczonych: 0.00 PLN
    - + +
    @@ -59,31 +113,33 @@
    -
    + +
    - +
    -
    -
    - -
    -
    - -
    -
    - -
    + +
    + Od + + Do + +
    +
    +
    + {% endblock %} {% block scripts %} + {% endblock %} \ No newline at end of file