From 8ae9068ffa50dbc52c6dabff1aa3d2e439a45604 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Mon, 21 Jul 2025 12:08:01 +0200 Subject: [PATCH 01/17] OCR --- Dockerfile | 11 +++ app.py | 144 ++++++++++++++++++++++++++++++++++ config.py | 11 ++- requirements.txt | 5 +- static/js/receipt_analysis.js | 63 +++++++++++++++ templates/list_share.html | 17 +++- 6 files changed, 247 insertions(+), 4 deletions(-) create mode 100644 static/js/receipt_analysis.js 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..6655c0f 100644 --- a/app.py +++ b/app.py @@ -9,6 +9,9 @@ import psutil import secrets import hashlib +import re +import tempfile + from pillow_heif import register_heif_opener from datetime import datetime, timedelta, UTC, timezone @@ -49,6 +52,11 @@ from sqlalchemy import func, extract from collections import defaultdict, deque from functools import wraps +# OCR +from collections import Counter +import pytesseract + + app = Flask(__name__) app.config.from_object(Config) register_heif_opener() # pillow_heif dla HEIC @@ -335,6 +343,89 @@ def _receipt_error(message): return redirect(request.referrer or url_for("main_page")) +############# OCR ########################### + + +def preprocess_image_for_tesseract(pil_image): + import cv2 + import numpy as np + from PIL import Image + + # Konwersja PIL.Image → NumPy grayscale + image = np.array(pil_image.convert("L")) + + # Zwiększenie skali dla lepszej czytelności OCR + image = cv2.resize(image, None, fx=2.0, fy=2.0, interpolation=cv2.INTER_LINEAR) + + # Adaptacyjne progowanie (lepsze niż THRESH_BINARY przy nierównym tle) + image = cv2.adaptiveThreshold( + image, 255, cv2.ADAPTIVE_THRESH_MEAN_C, cv2.THRESH_BINARY, blockSize=15, C=10 + ) + + # Konwersja z powrotem na PIL.Image (dla pytesseract) + return Image.fromarray(image) + + +def extract_total_tesseract(image): + + text = pytesseract.image_to_string(image, lang="pol", config="--psm 6") + lines = text.splitlines() + candidates = [] + + fuzzy_regex = re.compile(r"[\dOo][.,:;g9zZ][\d]{2}") + + for line in lines: + if not line.strip(): + continue + + matches = re.findall(r"\d{1,4}[.,]\d{2}", line) + for match in matches: + 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 <= 100: + candidates.append((val, line)) + except: + continue + + preferred = [ + val + for val, line in candidates + if re.search(r"sum[aąo]?|razem|zapłaty", line.lower()) + ] + + if preferred: + max_val = round(max(preferred), 2) + return max_val, lines + + if candidates: + max_val = round(max([val for val, _ in candidates]), 2) + return max_val, lines + + return 0.0, lines + + +############# END OCR ####################### + + # zabezpieczenie logowani do systemu - błędne hasła def is_ip_blocked(ip): now = time.time() @@ -1037,6 +1128,59 @@ def reorder_items(): return jsonify(success=True) +# OCR +@app.route("/lists//analyze", methods=["POST"]) +@login_required +def analyze_receipts_for_list(list_id): + list_obj = db.session.get(ShoppingList, list_id) + if not list_obj or list_obj.owner_id != current_user.id: + return jsonify({"error": "Brak dostępu"}), 403 + + receipt_objs = Receipt.query.filter_by(list_id=list_id).all() + 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 + + temp_path = None + + try: + if filepath.lower().endswith(".webp"): + + raw_image = Image.open(filepath).convert("RGB") + image = preprocess_image_for_tesseract(raw_image) + else: + + raw_image = Image.open(filepath).convert("RGB") + image = preprocess_image_for_tesseract(raw_image) + + value, lines = extract_total_tesseract(image) + + except Exception as e: + print(f"OCR error for {receipt.filename}: {e}") + value = 0.0 + lines = [] + + finally: + if temp_path and os.path.exists(temp_path): + os.unlink(temp_path) + + results.append( + { + "id": receipt.id, + "filename": receipt.filename, + "amount": round(value, 2), + "debug_text": lines, + } + ) + total += value + + return jsonify({"results": results, "total": round(total, 2)}) + + @app.route("/admin") @login_required @admin_required 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/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/js/receipt_analysis.js b/static/js/receipt_analysis.js new file mode 100644 index 0000000..6210a5a --- /dev/null +++ b/static/js/receipt_analysis.js @@ -0,0 +1,63 @@ +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(); // ⏱ START + + try { + const res = await fetch(`/lists/${listId}/analyze`, { method: "POST" }); + const data = await res.json(); + + const duration = ((performance.now() - start) / 1000).toFixed(2); // ⏱ STOP + + let html = `

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

`; + html += `

⏱ Czas analizy OCR: ${duration} sek.

`; + + data.results.forEach((r, i) => { + html += ` +
+ ${r.filename}: + + +
`; + }); + + if (data.results.length > 1) { + 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); + if (!isNaN(val) && val > 0) { + socket.emit('add_expense', { + list_id: LIST_ID, + amount: val + + }); + document.getElementById(`amount-${i}`).disabled = true; + } +} + +function emitAllExpenses(n) { + for (let i = 0; i < n; i++) { + emitExpense(i); + } +} diff --git a/templates/list_share.html b/templates/list_share.html index 746b238..6b1e122 100644 --- a/templates/list_share.html +++ b/templates/list_share.html @@ -106,6 +106,21 @@
{% set receipt_pattern = 'list_' ~ list.id %} + {% if receipt_files %} +
+
+
🧠 Analiza paragonów (OCR)
+

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

+ + + +
+
+ {% endif %} + +
📸 Paragony dodane do tej listy
@@ -192,7 +207,7 @@ - + From 955196dd922bbcbc547cfde7105f83ccf3c646b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Mon, 21 Jul 2025 14:12:50 +0200 Subject: [PATCH 02/17] uprawnienia ocr i uploadu --- app.py | 54 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/app.py b/app.py index 6655c0f..904c2d8 100644 --- a/app.py +++ b/app.py @@ -229,6 +229,16 @@ def serve_css_lib(filename): app.register_blueprint(static_bp) +def user_has_list_access(list_obj, user): + if not user.is_authenticated: + return False + if list_obj.owner_id == user.id: + return True + if db.session.query(SharedList).filter_by(list_id=list_obj.id, user_id=user.id).first(): + return True + return False + + def allowed_file(filename): return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS @@ -246,7 +256,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) @@ -1039,7 +1048,12 @@ def all_products(): @app.route("/upload_receipt/", methods=["POST"]) +@login_required def upload_receipt(list_id): + list_obj = db.session.get(ShoppingList, list_id) + if not list_obj or not user_has_list_access(list_obj, current_user): + return _receipt_error("Gość/niezalogowany nie może wgrywać plików") + if "receipt" not in request.files: return _receipt_error("Brak pliku") @@ -1096,6 +1110,7 @@ def upload_receipt(list_id): return _receipt_error("Niedozwolony format pliku") + @app.route("/uploads/") def uploaded_file(filename): response = send_from_directory(app.config["UPLOAD_FOLDER"], filename) @@ -1133,7 +1148,7 @@ def reorder_items(): @login_required def analyze_receipts_for_list(list_id): list_obj = db.session.get(ShoppingList, list_id) - if not list_obj or list_obj.owner_id != current_user.id: + if not list_obj or not user_has_list_access(list_obj, current_user): return jsonify({"error": "Brak dostępu"}), 403 receipt_objs = Receipt.query.filter_by(list_id=list_id).all() @@ -1145,42 +1160,29 @@ def analyze_receipts_for_list(list_id): if not os.path.exists(filepath): continue - temp_path = None - try: - if filepath.lower().endswith(".webp"): - - raw_image = Image.open(filepath).convert("RGB") - image = preprocess_image_for_tesseract(raw_image) - else: - - raw_image = Image.open(filepath).convert("RGB") - image = preprocess_image_for_tesseract(raw_image) - + raw_image = Image.open(filepath).convert("RGB") + image = preprocess_image_for_tesseract(raw_image) value, lines = extract_total_tesseract(image) except Exception as e: - print(f"OCR error for {receipt.filename}: {e}") + import traceback + print(f"OCR error for {receipt.filename}:\n{traceback.format_exc()}") value = 0.0 lines = [] - finally: - if temp_path and os.path.exists(temp_path): - os.unlink(temp_path) - - results.append( - { - "id": receipt.id, - "filename": receipt.filename, - "amount": round(value, 2), - "debug_text": lines, - } - ) + results.append({ + "id": receipt.id, + "filename": receipt.filename, + "amount": round(value, 2), + "debug_text": lines, + }) total += value return jsonify({"results": results, "total": round(total, 2)}) + @app.route("/admin") @login_required @admin_required From 983114575d92c55204b0bcfe493bc8e62863999a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Mon, 21 Jul 2025 15:50:35 +0200 Subject: [PATCH 03/17] uprawnienia ocr i uploadu --- app.py | 61 ++++++++++++++++--------------- static/js/clickable_row.js | 58 +++++++++++++++++------------- static/js/live.js | 32 +++++++++++++---- static/js/receipt_analysis.js | 68 ++++++++++++++++++++++++++--------- templates/list_share.html | 18 +++++----- 5 files changed, 154 insertions(+), 83 deletions(-) diff --git a/app.py b/app.py index 904c2d8..92e07ae 100644 --- a/app.py +++ b/app.py @@ -8,9 +8,8 @@ import platform import psutil import secrets import hashlib - import re -import tempfile + from pillow_heif import register_heif_opener @@ -228,17 +227,6 @@ def serve_css_lib(filename): app.register_blueprint(static_bp) - -def user_has_list_access(list_obj, user): - if not user.is_authenticated: - return False - if list_obj.owner_id == user.id: - return True - if db.session.query(SharedList).filter_by(list_id=list_obj.id, user_id=user.id).first(): - return True - return False - - def allowed_file(filename): return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS @@ -376,7 +364,6 @@ def preprocess_image_for_tesseract(pil_image): def extract_total_tesseract(image): - text = pytesseract.image_to_string(image, lang="pol", config="--psm 6") lines = text.splitlines() candidates = [] @@ -415,23 +402,30 @@ def extract_total_tesseract(image): except: continue + # Rozszerzone słowa kluczowe + keywords = r"sum[aąo]?|razem|zapłat[ay]?|sprzedaż|opodatk|należność|do zapłaty" + preferred = [ val for val, line in candidates - if re.search(r"sum[aąo]?|razem|zapłaty", line.lower()) + if re.search(keywords, line.lower()) ] if preferred: max_val = round(max(preferred), 2) return max_val, lines + # Fallback: wybierz największą wartość jeśli jest sensowna if candidates: max_val = round(max([val for val, _ in candidates]), 2) - return max_val, lines + # Jeśli np. większa niż 10 PLN, zakładamy że to może być suma końcowa + if max_val >= 10: + return max_val, lines return 0.0, lines + ############# END OCR ####################### @@ -1050,9 +1044,6 @@ def all_products(): @app.route("/upload_receipt/", methods=["POST"]) @login_required def upload_receipt(list_id): - list_obj = db.session.get(ShoppingList, list_id) - if not list_obj or not user_has_list_access(list_obj, current_user): - return _receipt_error("Gość/niezalogowany nie może wgrywać plików") if "receipt" not in request.files: return _receipt_error("Brak pliku") @@ -1062,8 +1053,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() @@ -1147,11 +1136,12 @@ def reorder_items(): @app.route("/lists//analyze", methods=["POST"]) @login_required def analyze_receipts_for_list(list_id): - list_obj = db.session.get(ShoppingList, list_id) - if not list_obj or not user_has_list_access(list_obj, current_user): - return jsonify({"error": "Brak dostępu"}), 403 - 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 @@ -1171,13 +1161,18 @@ def analyze_receipts_for_list(list_id): 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 }) - total += value + + if not already_added: + total += value return jsonify({"results": results, "total": round(total, 2)}) @@ -1483,8 +1478,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() @@ -2174,8 +2167,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/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 index 6210a5a..ce24650 100644 --- a/static/js/receipt_analysis.js +++ b/static/js/receipt_analysis.js @@ -7,32 +7,46 @@ document.addEventListener("DOMContentLoaded", () => { async function analyzeReceipts(listId) { const resultsDiv = document.getElementById("analysisResults"); - resultsDiv.innerHTML = `
⏳ Trwa analiza paragonów...
`; + resultsDiv.innerHTML = ` +
+
+ Trwa analiza paragonów... +
`; - const start = performance.now(); // ⏱ START + 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); - const duration = ((performance.now() - start) / 1000).toFixed(2); // ⏱ STOP - - let html = `

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

`; + 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}: - - -
`; +
+ ${r.filename} + ${inputField} + ${button} +
`; }); + if (data.results.length > 1) { - html += ``; + html += ``; } + html += `
`; resultsDiv.innerHTML = html; window._ocr_results = data.results; @@ -42,22 +56,44 @@ async function analyzeReceipts(listId) { } } - 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 - + 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) { - for (let i = 0; i < n; i++) { - emitExpense(i); + 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/templates/list_share.html b/templates/list_share.html index 6b1e122..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 %} @@ -112,9 +111,13 @@
    🧠 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 %}
  • @@ -140,7 +143,7 @@ {% endif %} - {% if not list.is_archived %} + {% if not list.is_archived and current_user.is_authenticated %}
    📤 Dodaj zdjęcie paragonu
    - {% endif %} From a84b13082231035a9edcef07bc5a107f99c3fa19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Mon, 21 Jul 2025 15:50:46 +0200 Subject: [PATCH 04/17] uprawnienia ocr i uploadu --- app.py | 40 +++++++++++++++++++--------------------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/app.py b/app.py index 92e07ae..456c743 100644 --- a/app.py +++ b/app.py @@ -227,6 +227,7 @@ def serve_css_lib(filename): app.register_blueprint(static_bp) + def allowed_file(filename): return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS @@ -405,11 +406,7 @@ def extract_total_tesseract(image): # Rozszerzone słowa kluczowe keywords = r"sum[aąo]?|razem|zapłat[ay]?|sprzedaż|opodatk|należność|do zapłaty" - preferred = [ - val - for val, line in candidates - if re.search(keywords, line.lower()) - ] + preferred = [val for val, line in candidates if re.search(keywords, line.lower())] if preferred: max_val = round(max(preferred), 2) @@ -425,7 +422,6 @@ def extract_total_tesseract(image): return 0.0, lines - ############# END OCR ####################### @@ -1099,7 +1095,6 @@ def upload_receipt(list_id): return _receipt_error("Niedozwolony format pliku") - @app.route("/uploads/") def uploaded_file(filename): response = send_from_directory(app.config["UPLOAD_FOLDER"], filename) @@ -1138,7 +1133,8 @@ def reorder_items(): 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() + e.receipt_filename + for e in Expense.query.filter_by(list_id=list_id).all() if e.receipt_filename } @@ -1157,19 +1153,22 @@ def analyze_receipts_for_list(list_id): 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 - }) + 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 @@ -1177,7 +1176,6 @@ def analyze_receipts_for_list(list_id): return jsonify({"results": results, "total": round(total, 2)}) - @app.route("/admin") @login_required @admin_required @@ -2170,13 +2168,13 @@ def handle_add_expense(data): receipt_filename = data.get("receipt_filename") if receipt_filename: - existing = Expense.query.filter_by(list_id=list_id, receipt_filename=receipt_filename).first() + existing = Expense.query.filter_by( + list_id=list_id, receipt_filename=receipt_filename + ).first() if existing: - return + return new_expense = Expense( - list_id=list_id, - amount=amount, - receipt_filename=receipt_filename + list_id=list_id, amount=amount, receipt_filename=receipt_filename ) db.session.add(new_expense) From aa865baf3b48b83defaa0d0ee73831235d4a8e70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Mon, 21 Jul 2025 15:54:28 +0200 Subject: [PATCH 05/17] restore analiza --- app.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/app.py b/app.py index 456c743..ce92783 100644 --- a/app.py +++ b/app.py @@ -365,6 +365,7 @@ def preprocess_image_for_tesseract(pil_image): def extract_total_tesseract(image): + text = pytesseract.image_to_string(image, lang="pol", config="--psm 6") lines = text.splitlines() candidates = [] @@ -403,21 +404,19 @@ def extract_total_tesseract(image): except: continue - # Rozszerzone słowa kluczowe - keywords = r"sum[aąo]?|razem|zapłat[ay]?|sprzedaż|opodatk|należność|do zapłaty" - - preferred = [val for val, line in candidates if re.search(keywords, line.lower())] + preferred = [ + val + for val, line in candidates + if re.search(r"sum[aąo]?|razem|zapłaty", line.lower()) + ] if preferred: max_val = round(max(preferred), 2) return max_val, lines - # Fallback: wybierz największą wartość jeśli jest sensowna if candidates: max_val = round(max([val for val, _ in candidates]), 2) - # Jeśli np. większa niż 10 PLN, zakładamy że to może być suma końcowa - if max_val >= 10: - return max_val, lines + return max_val, lines return 0.0, lines From a44a61c7184f0c5e2bf96ba07a994e5b4f2cd967 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Tue, 22 Jul 2025 11:23:00 +0200 Subject: [PATCH 06/17] ocr usprawnienia --- app.py | 106 ++++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 71 insertions(+), 35 deletions(-) diff --git a/app.py b/app.py index ce92783..c2dd629 100644 --- a/app.py +++ b/app.py @@ -9,7 +9,7 @@ import psutil import secrets import hashlib import re - +import numpy as np from pillow_heif import register_heif_opener @@ -44,7 +44,7 @@ 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 @@ -54,6 +54,7 @@ from functools import wraps # OCR from collections import Counter import pytesseract +from pytesseract import Output app = Flask(__name__) @@ -295,8 +296,8 @@ def save_resized_image(file, path): image.info.clear() new_path = path.rsplit(".", 1)[0] + ".webp" - image.save(new_path, format="WEBP", quality=85, method=6) - + #image.save(new_path, format="WEBP", quality=85, method=6) + image.save(new_path, format="WEBP", quality=100, method=0) def redirect_with_flash( message: str, category: str = "info", endpoint: str = "main_page" @@ -343,43 +344,57 @@ def _receipt_error(message): ############# OCR ########################### - -def preprocess_image_for_tesseract(pil_image): - import cv2 - import numpy as np - from PIL import Image - - # Konwersja PIL.Image → NumPy grayscale - image = np.array(pil_image.convert("L")) - - # Zwiększenie skali dla lepszej czytelności OCR - image = cv2.resize(image, None, fx=2.0, fy=2.0, interpolation=cv2.INTER_LINEAR) - - # Adaptacyjne progowanie (lepsze niż THRESH_BINARY przy nierównym tle) - image = cv2.adaptiveThreshold( - image, 255, cv2.ADAPTIVE_THRESH_MEAN_C, cv2.THRESH_BINARY, blockSize=15, C=10 - ) - - # Konwersja z powrotem na PIL.Image (dla pytesseract) - return Image.fromarray(image) - +def preprocess_image_for_tesseract(image): + image = ImageOps.autocontrast(image) + image = image.point(lambda x: 0 if x < 160 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 6") + 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}[.,]\d{2}", line) + matches = re.findall(r"\d{1,4}\s?[.,]\d{2}", line) for match in matches: try: - val = float(match.replace(",", ".")) + 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: @@ -399,24 +414,45 @@ def extract_total_tesseract(image): ) try: val = float(cleaned) - if 0.1 <= val <= 100: + if 0.1 <= val <= 100000: candidates.append((val, line)) except: continue preferred = [ - val + (val, line) for val, line in candidates - if re.search(r"sum[aąo]?|razem|zapłaty", line.lower()) + if keyword_pattern.search(line.lower()) ] if preferred: - max_val = round(max(preferred), 2) - return max_val, lines + max_val = max(preferred, key=lambda x: x[0])[0] + return round(max_val, 2), lines if candidates: - max_val = round(max([val for val, _ in candidates]), 2) - return max_val, lines + 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 From db6f70349ee07da2a08542ac7dd771db21b71995 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Tue, 22 Jul 2025 11:28:11 +0200 Subject: [PATCH 07/17] ocr usprawnienia --- app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app.py b/app.py index c2dd629..e779840 100644 --- a/app.py +++ b/app.py @@ -347,7 +347,7 @@ def _receipt_error(message): def preprocess_image_for_tesseract(image): image = ImageOps.autocontrast(image) image = image.point(lambda x: 0 if x < 160 else 255) # mocniejsza binarizacja - image = image.resize((image.width * 2, image.height * 2), Image.BICUBIC) # większe powiększenie + #image = image.resize((image.width * 2, image.height * 2), Image.BICUBIC) # większe powiększenie return image def extract_total_tesseract(image): From cc1dad0d7d8479e3c0c079280f663195c47c6bb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Tue, 22 Jul 2025 11:29:20 +0200 Subject: [PATCH 08/17] ocr usprawnienia --- app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app.py b/app.py index e779840..46b4e65 100644 --- a/app.py +++ b/app.py @@ -346,8 +346,8 @@ def _receipt_error(message): def preprocess_image_for_tesseract(image): image = ImageOps.autocontrast(image) - image = image.point(lambda x: 0 if x < 160 else 255) # mocniejsza binarizacja - #image = image.resize((image.width * 2, image.height * 2), Image.BICUBIC) # większe powiększenie + 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): From 258d111133f4b6a6b11cfa1dc0f64e9f67ddb0c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Tue, 22 Jul 2025 12:35:34 +0200 Subject: [PATCH 09/17] start kontenera z systemem --- docker-compose.yml | 1 + 1 file changed, 1 insertion(+) 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 From 78fcdce32751c1554b456e3cd2035eafc5cdbcdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Tue, 22 Jul 2025 14:19:24 +0200 Subject: [PATCH 10/17] =?UTF-8?q?obracanie=20zdj=C4=99cia=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.py | 55 ++++++++++++++++++++++++++----------------------------- 1 file changed, 26 insertions(+), 29 deletions(-) diff --git a/app.py b/app.py index 46b4e65..0e878ae 100644 --- a/app.py +++ b/app.py @@ -267,37 +267,30 @@ 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=100, method=0) + except Exception as e: + raise ValueError(f"Błąd podczas przetwarzania obrazu: {e}") - new_path = path.rsplit(".", 1)[0] + ".webp" - #image.save(new_path, format="WEBP", quality=85, method=6) - image.save(new_path, format="WEBP", quality=100, method=0) def redirect_with_flash( message: str, category: str = "info", endpoint: str = "main_page" @@ -344,12 +337,16 @@ def _receipt_error(message): ############# 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 + 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() @@ -371,7 +368,7 @@ def extract_total_tesseract(image): amount )\b """, - re.IGNORECASE | re.VERBOSE + re.IGNORECASE | re.VERBOSE, ) for idx, line in enumerate(lines): @@ -420,9 +417,7 @@ def extract_total_tesseract(image): continue preferred = [ - (val, line) - for val, line in candidates - if keyword_pattern.search(line.lower()) + (val, line) for val, line in candidates if keyword_pattern.search(line.lower()) ] if preferred: @@ -433,7 +428,9 @@ def extract_total_tesseract(image): 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) + data = pytesseract.image_to_data( + image, lang="pol", config="--psm 4", output_type=Output.DICT + ) font_candidates = [] for i in range(len(data["text"])): From 22bc8bd01d796c0a58b3d3909d301916401284b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Tue, 22 Jul 2025 14:36:06 +0200 Subject: [PATCH 11/17] user moze edytowac paragony --- app.py | 125 +++++++++++++++++++++++++----------- templates/edit_my_list.html | 37 ++++++++++- 2 files changed, 124 insertions(+), 38 deletions(-) diff --git a/app.py b/app.py index 0e878ae..ee1b220 100644 --- a/app.py +++ b/app.py @@ -335,6 +335,31 @@ 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) + filepath = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename) + + if not os.path.exists(filepath): + raise FileNotFoundError("Plik nie istnieje") + + image = Image.open(filepath) + rotated = image.rotate(-90, expand=True) + rotated.save(filepath, format="WEBP", quality=100) + 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 + + ############# OCR ########################### @@ -739,6 +764,12 @@ 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) @@ -781,7 +812,7 @@ 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"]) @@ -1159,6 +1190,46 @@ 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 @@ -1422,24 +1493,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 @@ -1468,32 +1545,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 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 %} +
    From dea0309cfd32be3c186fb5e728668571e0fcc077 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Tue, 22 Jul 2025 15:15:03 +0200 Subject: [PATCH 12/17] =?UTF-8?q?croper=20do=20paragon=C3=B3w?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.py | 20 +++++++++++ static/js/receipt_crop.js | 63 ++++++++++++++++++++++++++++++++++ static/lib/css/cropper.min.css | 9 +++++ static/lib/js/cropper.min.js | 10 ++++++ templates/admin/receipts.html | 30 ++++++++++++++-- templates/base.html | 8 +++++ 6 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 static/js/receipt_crop.js create mode 100644 static/lib/css/cropper.min.css create mode 100644 static/lib/js/cropper.min.js diff --git a/app.py b/app.py index ee1b220..355bcde 100644 --- a/app.py +++ b/app.py @@ -1975,6 +1975,26 @@ def demote_user(user_id): flash(f"Użytkownik {user.username} został zdegradowany.", "success") 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) + filepath = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename) + + try: + image = Image.open(file).convert("RGB") + image.save(filepath, format="WEBP", quality=100) + return jsonify(success=True) + except Exception as e: + return jsonify(success=False, error=str(e)) + @app.route("/healthcheck") def healthcheck(): diff --git a/static/js/receipt_crop.js b/static/js/receipt_crop.js new file mode 100644 index 0000000..35a291a --- /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, + }); + }; + }); + + // zapisz przycięty + 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) { + location.reload(); + } else { + alert("❌ Błąd: " + data.error || "Nieznany"); + } + }) + .catch((err) => { + alert("❌ Błąd sieci"); + 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/admin/receipts.html b/templates/admin/receipts.html index bcf40f5..0591f3c 100644 --- a/templates/admin/receipts.html +++ b/templates/admin/receipts.html @@ -30,15 +30,18 @@ {% endif %} 🔄 Obróć o 90° + ✂️ Przytnij ✏️ Zmień nazwę {% if not r.file_hash %} 🔐 Generuj hash {% endif %} - 🗑️ + 🗑️ Usuń - ✏️ Edytuj listę #{{ r.list_id }} @@ -55,4 +58,27 @@ {% endif %} + + + +{% block scripts %} + +{% endblock %} + {% endblock %} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index 7bf3c96..a9d6894 100644 --- a/templates/base.html +++ b/templates/base.html @@ -11,6 +11,9 @@ {% endif %} + {% if '/admin/' in request.path %} + + {% endif %} @@ -88,6 +91,11 @@ selector: '.glightbox' }); + + {% if '/admin/' in request.path %} + + {% endif %} + {% endif %} {% block scripts %}{% endblock %} From 1ab1b368118dd86bd0da8ac3830f59778cc1abb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Tue, 22 Jul 2025 21:56:37 +0200 Subject: [PATCH 13/17] usprawnieni i funkcje oraz zabezpieczenia --- app.py | 61 ++++++++++++++++++++++++++--------- static/js/receipt_crop.js | 8 ++--- templates/admin/receipts.html | 5 +-- 3 files changed, 53 insertions(+), 21 deletions(-) diff --git a/app.py b/app.py index 355bcde..41fdf63 100644 --- a/app.py +++ b/app.py @@ -6,10 +6,8 @@ import mimetypes import sys import platform import psutil -import secrets import hashlib import re -import numpy as np from pillow_heif import register_heif_opener @@ -166,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") @@ -337,14 +334,22 @@ def _receipt_error(message): def rotate_receipt_by_id(receipt_id): receipt = Receipt.query.get_or_404(receipt_id) - filepath = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename) + old_path = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename) - if not os.path.exists(filepath): + if not os.path.exists(old_path): raise FileNotFoundError("Plik nie istnieje") - image = Image.open(filepath) + image = Image.open(old_path) rotated = image.rotate(-90, expand=True) - rotated.save(filepath, format="WEBP", quality=100) + + 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 @@ -360,6 +365,12 @@ def delete_receipt_by_id(receipt_id): 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 ########################### @@ -623,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, ) @@ -775,7 +790,7 @@ def edit_my_list(list_id): 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() @@ -818,6 +833,11 @@ def edit_my_list(list_id): @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) @@ -1104,6 +1124,10 @@ def all_products(): @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") @@ -1528,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: @@ -1975,6 +1996,7 @@ def demote_user(user_id): flash(f"Użytkownik {user.username} został zdegradowany.", "success") return redirect(url_for("list_users")) + @app.route("/admin/crop_receipt", methods=["POST"]) @login_required @admin_required @@ -1986,11 +2008,20 @@ def crop_receipt(): return jsonify(success=False, error="Brak danych") receipt = Receipt.query.get_or_404(receipt_id) - filepath = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename) + old_path = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename) try: image = Image.open(file).convert("RGB") - image.save(filepath, format="WEBP", quality=100) + 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)) diff --git a/static/js/receipt_crop.js b/static/js/receipt_crop.js index 35a291a..94bf38d 100644 --- a/static/js/receipt_crop.js +++ b/static/js/receipt_crop.js @@ -33,7 +33,6 @@ document.addEventListener("DOMContentLoaded", function () { }; }); - // zapisz przycięty document.getElementById("saveCrop").addEventListener("click", function () { if (!cropper) return; @@ -49,13 +48,14 @@ document.addEventListener("DOMContentLoaded", function () { .then((res) => res.json()) .then((data) => { if (data.success) { - location.reload(); + showToast("Zapisano przycięty paragon", "success"); + setTimeout(() => location.reload(), 1500); } else { - alert("❌ Błąd: " + data.error || "Nieznany"); + showToast("Błąd: " + (data.error || "Nieznany"), "danger"); } }) .catch((err) => { - alert("❌ Błąd sieci"); + showToast("Błąd sieci", "danger"); console.error(err); }); }, "image/webp"); diff --git a/templates/admin/receipts.html b/templates/admin/receipts.html index 0591f3c..9ad5357 100644 --- a/templates/admin/receipts.html +++ b/templates/admin/receipts.html @@ -66,9 +66,10 @@ -