diff --git a/Dockerfile b/Dockerfile index 7f44721..6fb9036 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,17 @@ FROM python:3.13-slim # Ustawiamy katalog roboczy WORKDIR /app +# Zależności systemowe do OCR, obrazów, tesseract i języka PL +RUN apt-get update && apt-get install -y --no-install-recommends \ + tesseract-ocr \ + tesseract-ocr-pol \ + libglib2.0-0 \ + libsm6 \ + libxrender1 \ + libxext6 \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + # Kopiujemy wymagania COPY requirements.txt requirements.txt diff --git a/app.py b/app.py index 5c731c2..ee97ac9 100644 --- a/app.py +++ b/app.py @@ -6,8 +6,8 @@ import mimetypes import sys import platform import psutil -import secrets import hashlib +import re from pillow_heif import register_heif_opener @@ -42,13 +42,19 @@ from flask_compress import Compress from flask_socketio import SocketIO, emit, join_room from werkzeug.security import generate_password_hash, check_password_hash from config import Config -from PIL import Image, ExifTags +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 from collections import defaultdict, deque from functools import wraps +# OCR +from collections import Counter +import pytesseract +from pytesseract import Output + + app = Flask(__name__) app.config.from_object(Config) register_heif_opener() # pillow_heif dla HEIC @@ -158,7 +164,6 @@ class Receipt(db.Model): 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") @@ -238,7 +243,6 @@ def get_list_details(list_id): 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) @@ -260,36 +264,29 @@ def enrich_list_data(l): def save_resized_image(file, path): try: + # Otwórz i sprawdź poprawność pliku + image = Image.open(file) + image.verify() + file.seek(0) image = Image.open(file) - image.verify() # sprawdzenie poprawności pliku - file.seek(0) # reset do początku - image = Image.open(file) # ponowne otwarcie po verify() except Exception: raise ValueError("Nieprawidłowy plik graficzny") - # Obrót na podstawie EXIF try: - exif = image._getexif() - if exif: - orientation_key = next( - k for k, v in ExifTags.TAGS.items() if v == "Orientation" - ) - orientation = exif.get(orientation_key) - if orientation == 3: - image = image.rotate(180, expand=True) - elif orientation == 6: - image = image.rotate(270, expand=True) - elif orientation == 8: - image = image.rotate(90, expand=True) + # Automatyczna rotacja według EXIF (np. zdjęcia z telefonu) + image = ImageOps.exif_transpose(image) except Exception: - pass # brak lub błędny EXIF + pass # ignorujemy, jeśli EXIF jest uszkodzony lub brak - image.thumbnail((2000, 2000)) - image = image.convert("RGB") - image.info.clear() + try: + image.thumbnail((2000, 2000)) + image = image.convert("RGB") + image.info.clear() - new_path = path.rsplit(".", 1)[0] + ".webp" - image.save(new_path, format="WEBP", quality=85, method=6) + new_path = path.rsplit(".", 1)[0] + ".webp" + image.save(new_path, format="WEBP", quality=100, method=0) + except Exception as e: + raise ValueError(f"Błąd podczas przetwarzania obrazu: {e}") def redirect_with_flash( @@ -335,6 +332,167 @@ def _receipt_error(message): return redirect(request.referrer or url_for("main_page")) +def rotate_receipt_by_id(receipt_id): + receipt = Receipt.query.get_or_404(receipt_id) + old_path = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename) + + if not os.path.exists(old_path): + raise FileNotFoundError("Plik nie istnieje") + + image = Image.open(old_path) + rotated = image.rotate(-90, expand=True) + + new_filename = generate_new_receipt_filename(receipt.list_id) + new_path = os.path.join(app.config["UPLOAD_FOLDER"], new_filename) + rotated.save(new_path, format="WEBP", quality=100) + + os.remove(old_path) + receipt.filename = new_filename + db.session.commit() + + return receipt + + +def delete_receipt_by_id(receipt_id): + receipt = Receipt.query.get_or_404(receipt_id) + filepath = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename) + + if os.path.exists(filepath): + os.remove(filepath) + + db.session.delete(receipt) + db.session.commit() + return receipt + + +def generate_new_receipt_filename(list_id): + timestamp = datetime.now().strftime("%Y%m%d_%H%M") + random_part = secrets.token_hex(3) + return f"list_{list_id}_{timestamp}_{random_part}.webp" + + +############# OCR ########################### + + +def preprocess_image_for_tesseract(image): + image = ImageOps.autocontrast(image) + image = image.point(lambda x: 0 if x < 150 else 255) # mocniejsza binarizacja + image = image.resize( + (image.width * 2, image.height * 2), Image.BICUBIC + ) # większe powiększenie + return 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( + r""" + \b( + [5s]u[mn][aąo0]? | + razem | + zap[łl][aąo0]ty | + do\s+zap[łl][aąo0]ty | + kwota | + płatno[śćs] | + warto[śćs] | + total | + amount + )\b + """, + 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) + for match in matches: + try: + val = float(match.replace(" ", "").replace(",", ".")) + if 0.1 <= val <= 100000: + candidates.append((val, line)) + 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 + + 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()) + ] + + if preferred: + max_val = max(preferred, key=lambda x: x[0])[0] + return round(max_val, 2), lines + + if candidates: + max_val = max([val for val, _ in candidates]) + return round(max_val, 2), lines + + data = pytesseract.image_to_data( + image, lang="pol", config="--psm 4", output_type=Output.DICT + ) + font_candidates = [] + + for i in range(len(data["text"])): + word = data["text"][i].strip() + if not 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 + + if font_candidates: + best = max(font_candidates, key=lambda x: x[1]) + return round(best[0], 2), lines + + return 0.0, lines + + +############# END OCR ####################### + + # zabezpieczenie logowani do systemu - błędne hasła def is_ip_blocked(ip): now = time.time() @@ -476,7 +634,11 @@ def forbidden(e): "errors.html", code=403, title="Brak dostępu", - message="Nie masz uprawnień do wyświetlenia tej strony.", + message=( + e.description + if e.description + else "Nie masz uprawnień do wyświetlenia tej strony." + ), ), 403, ) @@ -617,12 +779,18 @@ def toggle_archive_list(list_id): @app.route("/edit_my_list/", methods=["GET", "POST"]) @login_required def edit_my_list(list_id): + receipts = ( + Receipt.query.filter_by(list_id=list_id) + .order_by(Receipt.uploaded_at.desc()) + .all() + ) + 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") + abort(403, description="Nie jesteś właścicielem tej listy.") if request.method == "POST": new_title = request.form.get("title", "").strip() @@ -659,12 +827,17 @@ def edit_my_list(list_id): flash("Zaktualizowano dane listy", "success") return redirect(url_for("main_page")) - return render_template("edit_my_list.html", list=l) + return render_template("edit_my_list.html", list=l, receipts=receipts) @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, description="Nie jesteś właścicielem tej listy.") + l = db.session.get(ShoppingList, list_id) if l is None or l.owner_id != current_user.id: abort(403) @@ -948,7 +1121,13 @@ def all_products(): @app.route("/upload_receipt/", methods=["POST"]) +@login_required def upload_receipt(list_id): + + l = db.session.get(ShoppingList, list_id) + if l is None or l.owner_id != current_user.id: + return _receipt_error("Nie masz uprawnień do tej listy.") + if "receipt" not in request.files: return _receipt_error("Brak pliku") @@ -957,8 +1136,6 @@ def upload_receipt(list_id): return _receipt_error("Nie wybrano pliku") if file and allowed_file(file.filename): - import hashlib - file_bytes = file.read() file.seek(0) file_hash = hashlib.sha256(file_bytes).hexdigest() @@ -1037,6 +1214,95 @@ def reorder_items(): return jsonify(success=True) +@app.route("/rotate_receipt/") +@login_required +def rotate_receipt_user(receipt_id): + receipt = Receipt.query.get_or_404(receipt_id) + list_obj = ShoppingList.query.get_or_404(receipt.list_id) + + if not (current_user.is_admin or current_user.id == list_obj.owner_id): + flash("Brak uprawnień do tej operacji", "danger") + return redirect(url_for("main_page")) + + try: + rotate_receipt_by_id(receipt_id) + flash("Obrócono paragon", "success") + except FileNotFoundError: + flash("Plik nie istnieje", "danger") + except Exception as e: + flash(f"Błąd przy obracaniu: {str(e)}", "danger") + + return redirect(request.referrer or url_for("main_page")) + + +@app.route("/delete_receipt/") +@login_required +def delete_receipt_user(receipt_id): + receipt = Receipt.query.get_or_404(receipt_id) + list_obj = ShoppingList.query.get_or_404(receipt.list_id) + + if not (current_user.is_admin or current_user.id == list_obj.owner_id): + flash("Brak uprawnień do tej operacji", "danger") + return redirect(url_for("main_page")) + + try: + delete_receipt_by_id(receipt_id) + flash("Paragon usunięty", "success") + except Exception as e: + flash(f"Błąd przy usuwaniu pliku: {str(e)}", "danger") + + return redirect(request.referrer or url_for("main_page")) + + +# OCR +@app.route("/lists//analyze", methods=["POST"]) +@login_required +def analyze_receipts_for_list(list_id): + receipt_objs = Receipt.query.filter_by(list_id=list_id).all() + existing_expenses = { + e.receipt_filename + for e in Expense.query.filter_by(list_id=list_id).all() + if e.receipt_filename + } + + results = [] + total = 0.0 + + for receipt in receipt_objs: + filepath = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename) + if not os.path.exists(filepath): + continue + + try: + raw_image = Image.open(filepath).convert("RGB") + image = preprocess_image_for_tesseract(raw_image) + value, lines = extract_total_tesseract(image) + + except Exception as e: + import traceback + + print(f"OCR error for {receipt.filename}:\n{traceback.format_exc()}") + value = 0.0 + lines = [] + + already_added = receipt.filename in existing_expenses + + results.append( + { + "id": receipt.id, + "filename": receipt.filename, + "amount": round(value, 2), + "debug_text": lines, + "already_added": already_added, + } + ) + + if not already_added: + total += value + + return jsonify({"results": results, "total": round(total, 2)}) + + @app.route("/admin") @login_required @admin_required @@ -1251,24 +1517,30 @@ def admin_receipts(id): @login_required @admin_required def rotate_receipt(receipt_id): - receipt = Receipt.query.get_or_404(receipt_id) - filepath = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename) - - if not os.path.exists(filepath): - flash("Plik nie istnieje", "danger") - return redirect(request.referrer or url_for("admin_receipts", id="all")) - try: - image = Image.open(filepath) - rotated = image.rotate(-90, expand=True) - rotated.save(filepath, format="WEBP", quality=85) + rotate_receipt_by_id(receipt_id) flash("Obrócono paragon", "success") + except FileNotFoundError: + flash("Plik nie istnieje", "danger") except Exception as e: flash(f"Błąd przy obracaniu: {str(e)}", "danger") return redirect(request.referrer or url_for("admin_receipts", id="all")) +@app.route("/admin/delete_receipt/") +@login_required +@admin_required +def delete_receipt(receipt_id): + try: + delete_receipt_by_id(receipt_id) + flash("Paragon usunięty", "success") + except Exception as e: + flash(f"Błąd przy usuwaniu pliku: {str(e)}", "danger") + + return redirect(request.referrer or url_for("admin_receipts", id="all")) + + @app.route("/admin/rename_receipt/") @login_required @admin_required @@ -1280,10 +1552,7 @@ def rename_receipt(receipt_id): flash("Plik nie istnieje", "danger") return redirect(request.referrer) - now = datetime.now() - timestamp = now.strftime("%Y%m%d_%H%M") - random_part = secrets.token_hex(3) - new_filename = f"list_{receipt.list_id}_{timestamp}_{random_part}.webp" + new_filename = generate_new_receipt_filename(receipt.list_id) new_path = os.path.join(app.config["UPLOAD_FOLDER"], new_filename) try: @@ -1297,32 +1566,6 @@ def rename_receipt(receipt_id): return redirect(request.referrer or url_for("admin_receipts", id="all")) -@app.route("/admin/delete_receipt/") -@login_required -@admin_required -def delete_receipt(receipt_id): - receipt = Receipt.query.get(receipt_id) - if not receipt: - flash("Paragon nie istnieje", "danger") - return redirect(request.referrer or url_for("admin_receipts", id="all")) - - file_path = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename) - - # Usuń plik - if os.path.exists(file_path): - try: - os.remove(file_path) - except Exception as e: - flash(f"Błąd przy usuwaniu pliku: {str(e)}", "danger") - - # Usuń rekord z bazy - db.session.delete(receipt) - db.session.commit() - flash("Paragon usunięty", "success") - - return redirect(request.referrer or url_for("admin_receipts", id="all")) - - @app.route("/admin/generate_receipt_hash/") @login_required @admin_required @@ -1337,8 +1580,6 @@ def generate_receipt_hash(receipt_id): flash("Plik nie istnieje", "danger") return redirect(request.referrer) - import hashlib - try: with open(file_path, "rb") as f: file_hash = hashlib.sha256(f.read()).hexdigest() @@ -1756,6 +1997,65 @@ def demote_user(user_id): return redirect(url_for("list_users")) +@app.route("/admin/crop_receipt", methods=["POST"]) +@login_required +@admin_required +def crop_receipt(): + 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)) + + +@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() + flash( + f"Zaktualizowano: {updated}, bez zmian: {unchanged}, brak pliku: {not_found}", + "success", + ) + return redirect(url_for("admin_receipts", id="all")) + + @app.route("/healthcheck") def healthcheck(): header_token = request.headers.get("X-Internal-Check") @@ -2028,8 +2328,18 @@ def handle_update_note(data): def handle_add_expense(data): list_id = data["list_id"] amount = data["amount"] + receipt_filename = data.get("receipt_filename") + + if receipt_filename: + existing = Expense.query.filter_by( + list_id=list_id, receipt_filename=receipt_filename + ).first() + if existing: + return + new_expense = Expense( + list_id=list_id, amount=amount, receipt_filename=receipt_filename + ) - new_expense = Expense(list_id=list_id, amount=amount) db.session.add(new_expense) db.session.commit() diff --git a/config.py b/config.py index 4bbb4bd..feb098e 100644 --- a/config.py +++ b/config.py @@ -10,6 +10,13 @@ class Config: DEFAULT_ADMIN_PASSWORD = os.environ.get("DEFAULT_ADMIN_PASSWORD", "admin123") 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)) + try: + AUTH_COOKIE_MAX_AGE = int(os.environ.get("AUTH_COOKIE_MAX_AGE", "86400") or "86400") + except ValueError: + AUTH_COOKIE_MAX_AGE = 86400 + HEALTHCHECK_TOKEN = os.environ.get("HEALTHCHECK_TOKEN", "alamapsaikota1234") - SESSION_TIMEOUT_MINUTES = int(os.environ.get("SESSION_TIMEOUT_MINUTES", 10080)) + try: + SESSION_TIMEOUT_MINUTES = int(os.environ.get("SESSION_TIMEOUT_MINUTES", "10080") or "10080") + except ValueError: + SESSION_TIMEOUT_MINUTES = 10080 diff --git a/docker-compose.yml b/docker-compose.yml index bf68436..68eeafa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,3 +24,4 @@ services: - SESSION_TIMEOUT_MINUTES=${SESSION_TIMEOUT_MINUTES} volumes: - .:/app + restart: unless-stopped \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 15f3aa5..5957702 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,4 +7,7 @@ eventlet Werkzeug Pillow psutil -pillow-heif \ No newline at end of file +pillow-heif + +pytesseract +opencv-python-headless \ No newline at end of file diff --git a/static/css/style.css b/static/css/style.css index e31d87e..ee72e62 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -193,20 +193,19 @@ input.form-control { } .info-bar-fixed { - position: fixed; - left: 0; - right: 0; - bottom: 0; width: 100%; color: #f8f9fa; + background-color: #212529; border-radius: 12px 12px 0 0; text-align: center; - padding: 10px 8px; + padding: 10px 10px; font-size: 0.95rem; - z-index: 9999; box-sizing: border-box; + margin-top: 2rem; + 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/clickable_row.js b/static/js/clickable_row.js index 955c80c..4ab51af 100644 --- a/static/js/clickable_row.js +++ b/static/js/clickable_row.js @@ -1,31 +1,41 @@ document.addEventListener("DOMContentLoaded", () => { - document.querySelectorAll('.clickable-item').forEach(item => { - item.addEventListener('click', function (e) { - if (!e.target.closest('button') && e.target.tagName.toLowerCase() !== 'input') { - const checkbox = this.querySelector('input[type="checkbox"]'); + const itemsContainer = document.getElementById('items'); + if (!itemsContainer) return; - if (checkbox.disabled) { - return; - } + itemsContainer.addEventListener('click', function (e) { + const row = e.target.closest('.clickable-item'); + if (!row || !itemsContainer.contains(row)) return; - if (checkbox.checked) { - socket.emit('uncheck_item', { item_id: parseInt(this.id.replace('item-', ''), 10) }); - } else { - socket.emit('check_item', { item_id: parseInt(this.id.replace('item-', ''), 10) }); - } + // Ignoruj kliknięcia w przyciski i inputy + if (e.target.closest('button') || e.target.tagName.toLowerCase() === 'input') { + return; + } - checkbox.disabled = true; - this.classList.add('opacity-50'); + const checkbox = row.querySelector('input[type="checkbox"]'); + if (!checkbox || checkbox.disabled) { + return; + } - let existingSpinner = this.querySelector('.spinner-border'); - if (!existingSpinner) { - const spinner = document.createElement('span'); - spinner.className = 'spinner-border spinner-border-sm ms-2'; - spinner.setAttribute('role', 'status'); - spinner.setAttribute('aria-hidden', 'true'); - checkbox.parentElement.appendChild(spinner); - } - } - }); + const itemId = parseInt(row.id.replace('item-', ''), 10); + if (isNaN(itemId)) return; + + if (checkbox.checked) { + socket.emit('uncheck_item', { item_id: itemId }); + } else { + socket.emit('check_item', { item_id: itemId }); + } + + checkbox.disabled = true; + row.classList.add('opacity-50'); + + // Dodaj spinner tylko jeśli nie ma + let existingSpinner = row.querySelector('.spinner-border'); + if (!existingSpinner) { + const spinner = document.createElement('span'); + spinner.className = 'spinner-border spinner-border-sm ms-2'; + spinner.setAttribute('role', 'status'); + spinner.setAttribute('aria-hidden', 'true'); + checkbox.parentElement.appendChild(spinner); + } }); }); diff --git a/static/js/live.js b/static/js/live.js index a7ac78c..e96ebdb 100644 --- a/static/js/live.js +++ b/static/js/live.js @@ -138,12 +138,20 @@ function setupList(listId, username) { quantityBadge = `x${data.quantity}`; } + const countdownId = `countdown-${data.id}`; + const countdownBtn = ` + + `; + li.innerHTML = `
- ${data.name} ${quantityBadge} + + ${data.name} ${quantityBadge} +
+ ${countdownBtn}
`; - // góra listy - //document.getElementById('items').prepend(li); - - // dół listy document.getElementById('items').appendChild(li); toggleEmptyPlaceholder(); + // ⏳ 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); + + // 🔁 Request listy po 15s setTimeout(() => { if (window.LIST_ID) { socket.emit('request_full_list', { list_id: window.LIST_ID }); } }, 15000); - }); + + socket.on('item_deleted', data => { const li = document.getElementById(`item-${data.item_id}`); if (li) { diff --git a/static/js/receipt_analysis.js b/static/js/receipt_analysis.js new file mode 100644 index 0000000..ce24650 --- /dev/null +++ b/static/js/receipt_analysis.js @@ -0,0 +1,99 @@ +document.addEventListener("DOMContentLoaded", () => { + const analyzeBtn = document.getElementById("analyzeBtn"); + if (analyzeBtn) { + analyzeBtn.addEventListener("click", () => analyzeReceipts(LIST_ID)); + } +}); + +async function analyzeReceipts(listId) { + const resultsDiv = document.getElementById("analysisResults"); + resultsDiv.innerHTML = ` +
+
+ Trwa analiza paragonów... +
`; + + const start = performance.now(); + + try { + const res = await fetch(`/lists/${listId}/analyze`, { method: "POST" }); + const data = await res.json(); + const duration = ((performance.now() - start) / 1000).toFixed(2); + + let html = `
`; + html += `

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

`; + html += `

⏱ Czas analizy OCR: ${duration} sek.

`; + + data.results.forEach((r, i) => { + const disabled = r.already_added ? "disabled" : ""; + const inputStyle = "form-control d-inline-block bg-dark text-white border-light rounded"; + const inputField = ``; + + const button = r.already_added + ? `✅ Dodano` + : ``; + + html += ` +
+ ${r.filename} + ${inputField} + ${button} +
`; + }); + + + if (data.results.length > 1) { + html += ``; + } + + html += `
`; + resultsDiv.innerHTML = html; + window._ocr_results = data.results; + + } catch (err) { + resultsDiv.innerHTML = `
❌ Wystąpił błąd podczas analizy.
`; + console.error(err); + } +} + +function emitExpense(i) { + const r = window._ocr_results[i]; + const val = parseFloat(document.getElementById(`amount-${i}`).value); + const btn = document.getElementById(`add-btn-${i}`); + + if (!isNaN(val) && val > 0) { + socket.emit('add_expense', { + list_id: LIST_ID, + amount: val, + receipt_filename: r.filename + }); + + document.getElementById(`amount-${i}`).disabled = true; + if (btn) { + btn.disabled = true; + btn.classList.remove('btn-outline-success'); + btn.classList.add('btn-success'); + btn.textContent = '✅ Dodano'; + } + } +} + +function emitAllExpenses(n) { + const btnAll = document.getElementById('addAllBtn'); + if (btnAll) { + btnAll.disabled = true; + btnAll.innerHTML = `Dodawanie...`; + } + + for (let i = 0; i < n; i++) { + setTimeout(() => emitExpense(i), i * 150); + } + + setTimeout(() => { + if (btnAll) { + btnAll.innerHTML = '✅ Wszystko dodano'; + btnAll.classList.remove('btn-success'); + btnAll.classList.add('btn-outline-success'); + } + }, n * 150 + 300); +} diff --git a/static/js/receipt_crop.js b/static/js/receipt_crop.js new file mode 100644 index 0000000..94bf38d --- /dev/null +++ b/static/js/receipt_crop.js @@ -0,0 +1,63 @@ +let cropper; +let currentReceiptId; + +document.addEventListener("DOMContentLoaded", function () { + const cropModal = document.getElementById("cropModal"); + const cropImage = document.getElementById("cropImage"); + + 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; + + if (cropper) { + cropper.destroy(); + cropper = null; + } + + image.onload = () => { + cropper = new Cropper(image, { + viewMode: 1, + autoCropArea: 1, + responsive: true, + background: false, + zoomable: true, + movable: true, + dragMode: 'move', + minContainerHeight: 400, + minContainerWidth: 400, + }); + }; + }); + + document.getElementById("saveCrop").addEventListener("click", function () { + if (!cropper) return; + + cropper.getCroppedCanvas().toBlob(function (blob) { + const formData = new FormData(); + formData.append("receipt_id", currentReceiptId); + formData.append("cropped_image", blob); + + fetch("/admin/crop_receipt", { + method: "POST", + body: formData, + }) + .then((res) => res.json()) + .then((data) => { + if (data.success) { + showToast("Zapisano przycięty paragon", "success"); + setTimeout(() => location.reload(), 1500); + } else { + showToast("Błąd: " + (data.error || "Nieznany"), "danger"); + } + }) + .catch((err) => { + showToast("Błąd sieci", "danger"); + console.error(err); + }); + }, "image/webp"); + }); +}); diff --git a/static/lib/css/cropper.min.css b/static/lib/css/cropper.min.css new file mode 100644 index 0000000..8e34d75 --- /dev/null +++ b/static/lib/css/cropper.min.css @@ -0,0 +1,9 @@ +/*! + * Cropper.js v1.6.2 + * https://fengyuanchen.github.io/cropperjs + * + * Copyright 2015-present Chen Fengyuan + * Released under the MIT license + * + * Date: 2024-04-21T07:43:02.731Z + */.cropper-container{-webkit-touch-callout:none;direction:ltr;font-size:0;line-height:0;position:relative;-ms-touch-action:none;touch-action:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.cropper-container img{backface-visibility:hidden;display:block;height:100%;image-orientation:0deg;max-height:none!important;max-width:none!important;min-height:0!important;min-width:0!important;width:100%}.cropper-canvas,.cropper-crop-box,.cropper-drag-box,.cropper-modal,.cropper-wrap-box{bottom:0;left:0;position:absolute;right:0;top:0}.cropper-canvas,.cropper-wrap-box{overflow:hidden}.cropper-drag-box{background-color:#fff;opacity:0}.cropper-modal{background-color:#000;opacity:.5}.cropper-view-box{display:block;height:100%;outline:1px solid #39f;outline-color:rgba(51,153,255,.75);overflow:hidden;width:100%}.cropper-dashed{border:0 dashed #eee;display:block;opacity:.5;position:absolute}.cropper-dashed.dashed-h{border-bottom-width:1px;border-top-width:1px;height:33.33333%;left:0;top:33.33333%;width:100%}.cropper-dashed.dashed-v{border-left-width:1px;border-right-width:1px;height:100%;left:33.33333%;top:0;width:33.33333%}.cropper-center{display:block;height:0;left:50%;opacity:.75;position:absolute;top:50%;width:0}.cropper-center:after,.cropper-center:before{background-color:#eee;content:" ";display:block;position:absolute}.cropper-center:before{height:1px;left:-3px;top:0;width:7px}.cropper-center:after{height:7px;left:0;top:-3px;width:1px}.cropper-face,.cropper-line,.cropper-point{display:block;height:100%;opacity:.1;position:absolute;width:100%}.cropper-face{background-color:#fff;left:0;top:0}.cropper-line{background-color:#39f}.cropper-line.line-e{cursor:ew-resize;right:-3px;top:0;width:5px}.cropper-line.line-n{cursor:ns-resize;height:5px;left:0;top:-3px}.cropper-line.line-w{cursor:ew-resize;left:-3px;top:0;width:5px}.cropper-line.line-s{bottom:-3px;cursor:ns-resize;height:5px;left:0}.cropper-point{background-color:#39f;height:5px;opacity:.75;width:5px}.cropper-point.point-e{cursor:ew-resize;margin-top:-3px;right:-3px;top:50%}.cropper-point.point-n{cursor:ns-resize;left:50%;margin-left:-3px;top:-3px}.cropper-point.point-w{cursor:ew-resize;left:-3px;margin-top:-3px;top:50%}.cropper-point.point-s{bottom:-3px;cursor:s-resize;left:50%;margin-left:-3px}.cropper-point.point-ne{cursor:nesw-resize;right:-3px;top:-3px}.cropper-point.point-nw{cursor:nwse-resize;left:-3px;top:-3px}.cropper-point.point-sw{bottom:-3px;cursor:nesw-resize;left:-3px}.cropper-point.point-se{bottom:-3px;cursor:nwse-resize;height:20px;opacity:1;right:-3px;width:20px}@media (min-width:768px){.cropper-point.point-se{height:15px;width:15px}}@media (min-width:992px){.cropper-point.point-se{height:10px;width:10px}}@media (min-width:1200px){.cropper-point.point-se{height:5px;opacity:.75;width:5px}}.cropper-point.point-se:before{background-color:#39f;bottom:-50%;content:" ";display:block;height:200%;opacity:0;position:absolute;right:-50%;width:200%}.cropper-invisible{opacity:0}.cropper-bg{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC")}.cropper-hide{display:block;height:0;position:absolute;width:0}.cropper-hidden{display:none!important}.cropper-move{cursor:move}.cropper-crop{cursor:crosshair}.cropper-disabled .cropper-drag-box,.cropper-disabled .cropper-face,.cropper-disabled .cropper-line,.cropper-disabled .cropper-point{cursor:not-allowed} \ No newline at end of file diff --git a/static/lib/js/cropper.min.js b/static/lib/js/cropper.min.js new file mode 100644 index 0000000..3102cb5 --- /dev/null +++ b/static/lib/js/cropper.min.js @@ -0,0 +1,10 @@ +/*! + * Cropper.js v1.6.2 + * https://fengyuanchen.github.io/cropperjs + * + * Copyright 2015-present Chen Fengyuan + * Released under the MIT license + * + * Date: 2024-04-21T07:43:05.335Z + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Cropper=e()}(this,function(){"use strict";function C(e,t){var i,a=Object.keys(e);return Object.getOwnPropertySymbols&&(i=Object.getOwnPropertySymbols(e),t&&(i=i.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),a.push.apply(a,i)),a}function S(a){for(var t=1;tt.length)&&(e=t.length);for(var i=0,a=new Array(e);it.width?3===i?o=t.height*e:h=t.width/e:3===i?h=t.width/e:o=t.height*e,{aspectRatio:e,naturalWidth:n,naturalHeight:a,width:o,height:h});this.canvasData=e,this.limited=1===i||2===i,this.limitCanvas(!0,!0),e.width=Math.min(Math.max(e.width,e.minWidth),e.maxWidth),e.height=Math.min(Math.max(e.height,e.minHeight),e.maxHeight),e.left=(t.width-e.width)/2,e.top=(t.height-e.height)/2,e.oldLeft=e.left,e.oldTop=e.top,this.initialCanvasData=g({},e)},limitCanvas:function(t,e){var i=this.options,a=this.containerData,n=this.canvasData,o=this.cropBoxData,h=i.viewMode,r=n.aspectRatio,s=this.cropped&&o;t&&(t=Number(i.minCanvasWidth)||0,i=Number(i.minCanvasHeight)||0,1=a.width&&(n.minLeft=Math.min(0,r),n.maxLeft=Math.max(0,r)),n.height>=a.height)&&(n.minTop=Math.min(0,t),n.maxTop=Math.max(0,t))):(n.minLeft=-n.width,n.minTop=-n.height,n.maxLeft=a.width,n.maxTop=a.height))},renderCanvas:function(t,e){var i,a,n,o,h=this.canvasData,r=this.imageData;e&&(e={width:r.naturalWidth*Math.abs(r.scaleX||1),height:r.naturalHeight*Math.abs(r.scaleY||1),degree:r.rotate||0},r=e.width,o=e.height,e=e.degree,i=90==(e=Math.abs(e)%180)?{width:o,height:r}:(a=e%90*Math.PI/180,i=Math.sin(a),n=r*(a=Math.cos(a))+o*i,r=r*i+o*a,90h.maxWidth||h.widthh.maxHeight||h.heighte.width?a.height=a.width/i:a.width=a.height*i),this.cropBoxData=a,this.limitCropBox(!0,!0),a.width=Math.min(Math.max(a.width,a.minWidth),a.maxWidth),a.height=Math.min(Math.max(a.height,a.minHeight),a.maxHeight),a.width=Math.max(a.minWidth,a.width*t),a.height=Math.max(a.minHeight,a.height*t),a.left=e.left+(e.width-a.width)/2,a.top=e.top+(e.height-a.height)/2,a.oldLeft=a.left,a.oldTop=a.top,this.initialCropBoxData=g({},a)},limitCropBox:function(t,e){var i,a,n=this.options,o=this.containerData,h=this.canvasData,r=this.cropBoxData,s=this.limited,c=n.aspectRatio;t&&(t=Number(n.minCropBoxWidth)||0,n=Number(n.minCropBoxHeight)||0,i=s?Math.min(o.width,h.width,h.width+h.left,o.width-h.left):o.width,a=s?Math.min(o.height,h.height,h.height+h.top,o.height-h.top):o.height,t=Math.min(t,o.width),n=Math.min(n,o.height),c&&(t&&n?ti.maxWidth||i.widthi.maxHeight||i.height=e.width&&i.height>=e.height?q:I),f(this.cropBox,g({width:i.width,height:i.height},x({translateX:i.left,translateY:i.top}))),this.cropped&&this.limited&&this.limitCanvas(!0,!0),this.disabled||this.output()},output:function(){this.preview(),y(this.element,tt,this.getData())}},i={initPreview:function(){var t=this.element,i=this.crossOrigin,e=this.options.preview,a=i?this.crossOriginUrl:this.url,n=t.alt||"The image to preview",o=document.createElement("img");i&&(o.crossOrigin=i),o.src=a,o.alt=n,this.viewBox.appendChild(o),this.viewBoxImage=o,e&&("string"==typeof(o=e)?o=t.ownerDocument.querySelectorAll(e):e.querySelector&&(o=[e]),z(this.previews=o,function(t){var e=document.createElement("img");w(t,m,{width:t.offsetWidth,height:t.offsetHeight,html:t.innerHTML}),i&&(e.crossOrigin=i),e.src=a,e.alt=n,e.style.cssText='display:block;width:100%;height:auto;min-width:0!important;min-height:0!important;max-width:none!important;max-height:none!important;image-orientation:0deg!important;"',t.innerHTML="",t.appendChild(e)}))},resetPreview:function(){z(this.previews,function(e){var i=Bt(e,m),i=(f(e,{width:i.width,height:i.height}),e.innerHTML=i.html,e),e=m;if(o(i[e]))try{delete i[e]}catch(t){i[e]=void 0}else if(i.dataset)try{delete i.dataset[e]}catch(t){i.dataset[e]=void 0}else i.removeAttribute("data-".concat(Dt(e)))})},preview:function(){var h=this.imageData,t=this.canvasData,e=this.cropBoxData,r=e.width,s=e.height,c=h.width,d=h.height,l=e.left-t.left-h.left,p=e.top-t.top-h.top;this.cropped&&!this.disabled&&(f(this.viewBoxImage,g({width:c,height:d},x(g({translateX:-l,translateY:-p},h)))),z(this.previews,function(t){var e=Bt(t,m),i=e.width,e=e.height,a=i,n=e,o=1;r&&(n=s*(o=i/r)),s&&eMath.abs(a-1)?i:a)&&(t.restore&&(o=this.getCanvasData(),h=this.getCropBoxData()),this.render(),t.restore)&&(this.setCanvasData(z(o,function(t,e){o[e]=t*n})),this.setCropBoxData(z(h,function(t,e){h[e]=t*n}))))},dblclick:function(){var t,e;this.disabled||this.options.dragMode===_||this.setDragMode((t=this.dragBox,e=Q,(t.classList?t.classList.contains(e):-1y&&(D.x=y-f);break;case k:p+D.xx&&(D.y=x-v)}}var i,a,o,n=this.options,h=this.canvasData,r=this.containerData,s=this.cropBoxData,c=this.pointers,d=this.action,l=n.aspectRatio,p=s.left,m=s.top,u=s.width,g=s.height,f=p+u,v=m+g,w=0,b=0,y=r.width,x=r.height,M=!0,C=(!l&&t.shiftKey&&(l=u&&g?u/g:1),this.limited&&(w=s.minLeft,b=s.minTop,y=w+Math.min(r.width,h.width,h.left+h.width),x=b+Math.min(r.height,h.height,h.top+h.height)),c[Object.keys(c)[0]]),D={x:C.endX-C.startX,y:C.endY-C.startY};switch(d){case I:p+=D.x,m+=D.y;break;case B:0<=D.x&&(y<=f||l&&(m<=b||x<=v))?M=!1:(e(B),(u+=D.x)<0&&(d=k,p-=u=-u),l&&(m+=(s.height-(g=u/l))/2));break;case T:D.y<=0&&(m<=b||l&&(p<=w||y<=f))?M=!1:(e(T),g-=D.y,m+=D.y,g<0&&(d=O,m-=g=-g),l&&(p+=(s.width-(u=g*l))/2));break;case k:D.x<=0&&(p<=w||l&&(m<=b||x<=v))?M=!1:(e(k),u-=D.x,p+=D.x,u<0&&(d=B,p-=u=-u),l&&(m+=(s.height-(g=u/l))/2));break;case O:0<=D.y&&(x<=v||l&&(p<=w||y<=f))?M=!1:(e(O),(g+=D.y)<0&&(d=T,m-=g=-g),l&&(p+=(s.width-(u=g*l))/2));break;case E:if(l){if(D.y<=0&&(m<=b||y<=f)){M=!1;break}e(T),g-=D.y,m+=D.y,u=g*l}else e(T),e(B),!(0<=D.x)||fMath.abs(o)&&(o=i)})}),o),t),M=!1;break;case U:D.x&&D.y?(i=Wt(this.cropper),p=C.startX-i.left,m=C.startY-i.top,u=s.minWidth,g=s.minHeight,0 or element.");this.element=t,this.options=g({},ut,u(e)&&e),this.cropped=!1,this.disabled=!1,this.pointers={},this.ready=!1,this.reloading=!1,this.replaced=!1,this.sized=!1,this.sizing=!1,this.init()}return t=n,i=[{key:"noConflict",value:function(){return window.Cropper=Pt,n}},{key:"setDefaults",value:function(t){g(ut,u(t)&&t)}}],(e=[{key:"init",value:function(){var t,e=this.element,i=e.tagName.toLowerCase();if(!e[c]){if(e[c]=this,"img"===i){if(this.isImg=!0,t=e.getAttribute("src")||"",!(this.originalUrl=t))return;t=e.src}else"canvas"===i&&window.HTMLCanvasElement&&(t=e.toDataURL());this.load(t)}}},{key:"load",value:function(t){var e,i,a,n,o,h,r=this;t&&(this.url=t,this.imageData={},e=this.element,(i=this.options).rotatable||i.scalable||(i.checkOrientation=!1),i.checkOrientation&&window.ArrayBuffer?lt.test(t)?pt.test(t)?this.read((h=(h=t).replace(Xt,""),a=atob(h),h=new ArrayBuffer(a.length),z(n=new Uint8Array(h),function(t,e){n[e]=a.charCodeAt(e)}),h)):this.clone():(o=new XMLHttpRequest,h=this.clone.bind(this),this.reloading=!0,(this.xhr=o).onabort=h,o.onerror=h,o.ontimeout=h,o.onprogress=function(){o.getResponseHeader("content-type")!==ct&&o.abort()},o.onload=function(){r.read(o.response)},o.onloadend=function(){r.reloading=!1,r.xhr=null},i.checkCrossOrigin&&Lt(t)&&e.crossOrigin&&(t=zt(t)),o.open("GET",t,!0),o.responseType="arraybuffer",o.withCredentials="use-credentials"===e.crossOrigin,o.send()):this.clone())}},{key:"read",value:function(t){var e=this.options,i=this.imageData,a=Rt(t),n=0,o=1,h=1;1
',o=(n=n.querySelector(".".concat(c,"-container"))).querySelector(".".concat(c,"-canvas")),h=n.querySelector(".".concat(c,"-drag-box")),s=(r=n.querySelector(".".concat(c,"-crop-box"))).querySelector(".".concat(c,"-face")),this.container=a,this.cropper=n,this.canvas=o,this.dragBox=h,this.cropBox=r,this.viewBox=n.querySelector(".".concat(c,"-view-box")),this.face=s,o.appendChild(i),v(t,L),a.insertBefore(n,t.nextSibling),X(i,Z),this.initPreview(),this.bind(),e.initialAspectRatio=Math.max(0,e.initialAspectRatio)||NaN,e.aspectRatio=Math.max(0,e.aspectRatio)||NaN,e.viewMode=Math.max(0,Math.min(3,Math.round(e.viewMode)))||0,v(r,L),e.guides||v(r.getElementsByClassName("".concat(c,"-dashed")),L),e.center||v(r.getElementsByClassName("".concat(c,"-center")),L),e.background&&v(n,"".concat(c,"-bg")),e.highlight||v(s,G),e.cropBoxMovable&&(v(s,V),w(s,d,I)),e.cropBoxResizable||(v(r.getElementsByClassName("".concat(c,"-line")),L),v(r.getElementsByClassName("".concat(c,"-point")),L)),this.render(),this.ready=!0,this.setDragMode(e.dragMode),e.autoCrop&&this.crop(),this.setData(e.data),l(e.ready)&&b(t,"ready",e.ready,{once:!0}),y(t,"ready"))}},{key:"unbuild",value:function(){var t;this.ready&&(this.ready=!1,this.unbind(),this.resetPreview(),(t=this.cropper.parentNode)&&t.removeChild(this.cropper),X(this.element,L))}},{key:"uncreate",value:function(){this.ready?(this.unbuild(),this.ready=!1,this.cropped=!1):this.sizing?(this.sizingImage.onload=null,this.sizing=!1,this.sized=!1):this.reloading?(this.xhr.onabort=null,this.xhr.abort()):this.image&&this.stop()}}])&&A(t.prototype,e),i&&A(t,i),Object.defineProperty(t,"prototype",{writable:!1}),t;var t,e,i}();return g(It.prototype,t,i,e,St,jt,At),It}); \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index 7bf3c96..71787e7 100644 --- a/templates/base.html +++ b/templates/base.html @@ -11,6 +11,9 @@ {% endif %} + {% if '/admin/' in request.path %} + + {% endif %} @@ -58,6 +61,12 @@
+ + {% if not is_blocked %} + + {% if '/admin/' in request.path %} + + {% endif %} + {% endif %} {% block scripts %}{% endblock %} diff --git a/templates/edit_my_list.html b/templates/edit_my_list.html index 13745f2..b1dea37 100644 --- a/templates/edit_my_list.html +++ b/templates/edit_my_list.html @@ -47,9 +47,44 @@ Anuluj - + {% if receipts %} +
+
Paragony przypisane do tej listy
+ +
+ {% for r in receipts %} +
+
+ + + +
+

{{ r.filename }}

+

Wgrano: {{ r.uploaded_at.strftime('%Y-%m-%d %H:%M') }}

+ {% if r.filesize and r.filesize >= 1024 * 1024 %} +

Rozmiar: {{ (r.filesize / 1024 / 1024) | round(2) }} MB

+ {% elif r.filesize %} +

Rozmiar: {{ (r.filesize / 1024) | round(1) }} kB

+ {% else %} +

Brak danych o rozmiarze

+ {% endif %} + + 🔄 Obróć o 90° + + 🗑️ Usuń +
+
+
+ {% endfor %} +
+ {% endif %} +
diff --git a/templates/list_share.html b/templates/list_share.html index 746b238..495065f 100644 --- a/templates/list_share.html +++ b/templates/list_share.html @@ -5,7 +5,6 @@

🛍️ {{ list.title }} - {% if list.is_archived %} (Archiwalna) {% endif %} @@ -70,8 +69,6 @@ {% endif %}

- - {% else %}
  • Brak produktów w tej liście. @@ -81,10 +78,12 @@ {% if not list.is_archived %}
    - + - + min="1" value="1" style="max-width: 90px;" {% if not current_user.is_authenticated %}disabled{% endif %}> +
    {% endif %} @@ -106,6 +105,25 @@
    {% set receipt_pattern = 'list_' ~ list.id %} + {% if receipt_files %} +
    +
    +
    🧠 Analiza paragonów (OCR)
    +

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

    + + {% if current_user.is_authenticated %} + + {% else %} +
    🔒 Tylko zalogowani użytkownicy mogą zlecać analizę OCR paragonów.
    + {% endif %} + +
    +
    + {% endif %} + +
    📸 Paragony dodane do tej listy
    @@ -125,7 +143,7 @@ {% endif %}
    - {% if not list.is_archived %} + {% if not list.is_archived and current_user.is_authenticated %}
    📤 Dodaj zdjęcie paragonu
    - {% endif %} @@ -192,7 +209,7 @@ - +