Compare commits
18 Commits
0d9e56dfa1
...
48f1841649
Author | SHA1 | Date | |
---|---|---|---|
48f1841649 | |||
![]() |
d899672a2b | ||
![]() |
03d4370c8a | ||
![]() |
f30cd0f2fe | ||
![]() |
4ec33569a0 | ||
![]() |
1ab1b36811 | ||
![]() |
dea0309cfd | ||
![]() |
22bc8bd01d | ||
![]() |
78fcdce327 | ||
![]() |
258d111133 | ||
![]() |
cc1dad0d7d | ||
![]() |
db6f70349e | ||
![]() |
a44a61c718 | ||
![]() |
aa865baf3b | ||
![]() |
a84b130822 | ||
![]() |
983114575d | ||
![]() |
955196dd92 | ||
![]() |
8ae9068ffa |
11
Dockerfile
11
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
|
||||
|
||||
|
458
app.py
458
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/<int:list_id>", 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/<int:list_id>", 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/<int:list_id>", 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/<int:receipt_id>")
|
||||
@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/<int:receipt_id>")
|
||||
@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/<int:list_id>/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/<int:receipt_id>")
|
||||
@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/<int:receipt_id>")
|
||||
@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/<int:receipt_id>")
|
||||
@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/<int:receipt_id>")
|
||||
@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()
|
||||
|
||||
|
11
config.py
11
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
|
||||
|
@@ -24,3 +24,4 @@ services:
|
||||
- SESSION_TIMEOUT_MINUTES=${SESSION_TIMEOUT_MINUTES}
|
||||
volumes:
|
||||
- .:/app
|
||||
restart: unless-stopped
|
@@ -7,4 +7,7 @@ eventlet
|
||||
Werkzeug
|
||||
Pillow
|
||||
psutil
|
||||
pillow-heif
|
||||
pillow-heif
|
||||
|
||||
pytesseract
|
||||
opencv-python-headless
|
@@ -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;
|
||||
|
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
@@ -138,12 +138,20 @@ function setupList(listId, username) {
|
||||
quantityBadge = `<span class="badge bg-secondary">x${data.quantity}</span>`;
|
||||
}
|
||||
|
||||
const countdownId = `countdown-${data.id}`;
|
||||
const countdownBtn = `
|
||||
<button type="button" class="btn btn-outline-warning" id="${countdownId}" disabled>15s</button>
|
||||
`;
|
||||
|
||||
li.innerHTML = `
|
||||
<div class="d-flex align-items-center flex-wrap gap-2 flex-grow-1">
|
||||
<input class="large-checkbox" type="checkbox">
|
||||
<span id="name-${data.id}" class="text-white">${data.name} ${quantityBadge}</span>
|
||||
<span id="name-${data.id}" class="text-white">
|
||||
${data.name} ${quantityBadge}
|
||||
</span>
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
${countdownBtn}
|
||||
<button type="button" class="btn btn-outline-light"
|
||||
onclick="editItem(${data.id}, '${data.name.replace(/'/g, "\\'")}', ${data.quantity || 1})">
|
||||
✏️
|
||||
@@ -155,21 +163,33 @@ function setupList(listId, username) {
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 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) {
|
||||
|
99
static/js/receipt_analysis.js
Normal file
99
static/js/receipt_analysis.js
Normal file
@@ -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 = `
|
||||
<div class="text-info d-flex align-items-center gap-2">
|
||||
<div class="spinner-border spinner-border-sm text-info" role="status"></div>
|
||||
<span>Trwa analiza paragonów...</span>
|
||||
</div>`;
|
||||
|
||||
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 = `<div class="card bg-dark text-white border-secondary p-3">`;
|
||||
html += `<p><b>📊 Łącznie wykryto:</b> ${data.total.toFixed(2)} PLN</p>`;
|
||||
html += `<p class="text-secondary"><small>⏱ Czas analizy OCR: ${duration} sek.</small></p>`;
|
||||
|
||||
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 = `<input type="number" id="amount-${i}" value="${r.amount}" step="0.01" class="${inputStyle}" style="width: 120px;" ${disabled}>`;
|
||||
|
||||
const button = r.already_added
|
||||
? `<span class="badge bg-primary ms-2">✅ Dodano</span>`
|
||||
: `<button id="add-btn-${i}" onclick="emitExpense(${i})" class="btn btn-sm btn-outline-success ms-2">➕ Dodaj</button>`;
|
||||
|
||||
html += `
|
||||
<div class="mb-2 d-flex align-items-center gap-2 flex-wrap">
|
||||
<span class="text-light flex-grow-1">${r.filename}</span>
|
||||
${inputField}
|
||||
${button}
|
||||
</div>`;
|
||||
});
|
||||
|
||||
|
||||
if (data.results.length > 1) {
|
||||
html += `<button id="addAllBtn" onclick="emitAllExpenses(${data.results.length})" class="btn btn-success mt-3 w-100">➕ Dodaj wszystkie</button>`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
resultsDiv.innerHTML = html;
|
||||
window._ocr_results = data.results;
|
||||
|
||||
} catch (err) {
|
||||
resultsDiv.innerHTML = `<div class="text-danger">❌ Wystąpił błąd podczas analizy.</div>`;
|
||||
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 = `<span class="spinner-border spinner-border-sm me-2" role="status"></span>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);
|
||||
}
|
63
static/js/receipt_crop.js
Normal file
63
static/js/receipt_crop.js
Normal file
@@ -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");
|
||||
});
|
||||
});
|
9
static/lib/css/cropper.min.css
vendored
Normal file
9
static/lib/css/cropper.min.css
vendored
Normal file
@@ -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}
|
10
static/lib/js/cropper.min.js
vendored
Normal file
10
static/lib/js/cropper.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -11,6 +11,9 @@
|
||||
<link href="{{ url_for('static_bp.serve_css_lib', filename='glightbox.min.css') }}" rel="stylesheet">
|
||||
{% endif %}
|
||||
<link href="{{ url_for('static_bp.serve_css_lib', filename='bootstrap.min.css') }}" rel="stylesheet">
|
||||
{% if '/admin/' in request.path %}
|
||||
<link href="{{ url_for('static_bp.serve_css_lib', filename='cropper.min.css') }}" rel="stylesheet">
|
||||
{% endif %}
|
||||
</head>
|
||||
|
||||
<body class="bg-dark text-white">
|
||||
@@ -58,6 +61,12 @@
|
||||
|
||||
<div id="toast-container" class="toast-container position-fixed bottom-0 end-0 p-3"></div>
|
||||
|
||||
<footer class="text-center text-secondary small mt-5 mb-3">
|
||||
<hr class="text-secondary">
|
||||
<p class="mb-0">© 2025 <strong>linuxiarz.pl</strong> · <a href="https://gitea.linuxiarz.pl/gru/lista_zakupowa_live"
|
||||
target="_blank" class="link-success text-decoration-none"> source code</a>
|
||||
</footer>
|
||||
|
||||
<script src="{{ url_for('static_bp.serve_js_lib', filename='bootstrap.bundle.min.js') }}"></script>
|
||||
{% if not is_blocked %}
|
||||
<script>
|
||||
@@ -88,6 +97,11 @@
|
||||
selector: '.glightbox'
|
||||
});
|
||||
</script>
|
||||
|
||||
{% if '/admin/' in request.path %}
|
||||
<script src="{{ url_for('static_bp.serve_js_lib', filename='cropper.min.js') }}"></script>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
|
@@ -47,9 +47,44 @@
|
||||
<button type="submit" class="btn btn-outline-success">Zapisz</button>
|
||||
<a href="{{ url_for('main_page') }}" class="btn btn-outline-light">Anuluj</a>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
{% if receipts %}
|
||||
<hr class="my-4">
|
||||
<h5>Paragony przypisane do tej listy</h5>
|
||||
|
||||
<div class="row">
|
||||
{% for r in receipts %}
|
||||
<div class="col-6 col-md-4 col-lg-3">
|
||||
<div class="card bg-dark text-white h-100">
|
||||
<a href="{{ url_for('uploaded_file', filename=r.filename) }}" class="glightbox" data-gallery="receipts"
|
||||
data-title="{{ r.filename }}">
|
||||
<img src="{{ url_for('uploaded_file', filename=r.filename) }}" class="card-img-top"
|
||||
style="object-fit: cover; height: 200px;">
|
||||
</a>
|
||||
<div class="card-body text-center">
|
||||
<p class="small text-truncate mb-1">{{ r.filename }}</p>
|
||||
<p class="small mb-1">Wgrano: {{ r.uploaded_at.strftime('%Y-%m-%d %H:%M') }}</p>
|
||||
{% if r.filesize and r.filesize >= 1024 * 1024 %}
|
||||
<p class="small mb-1">Rozmiar: {{ (r.filesize / 1024 / 1024) | round(2) }} MB</p>
|
||||
{% elif r.filesize %}
|
||||
<p class="small mb-1">Rozmiar: {{ (r.filesize / 1024) | round(1) }} kB</p>
|
||||
{% else %}
|
||||
<p class="small mb-1 text-muted">Brak danych o rozmiarze</p>
|
||||
{% endif %}
|
||||
|
||||
<a href="{{ url_for('rotate_receipt_user', receipt_id=r.id) }}"
|
||||
class="btn btn-sm btn-outline-warning w-100 mb-2">🔄 Obróć o 90°</a>
|
||||
|
||||
<a href="{{ url_for('delete_receipt_user', receipt_id=r.id) }}" class="btn btn-sm btn-outline-danger w-100"
|
||||
onclick="return confirm('Na pewno usunąć ten paragon?')">🗑️ Usuń</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<hr class="my-3">
|
||||
<!-- Trigger przycisk -->
|
||||
<div class="btn-group mt-4" role="group">
|
||||
|
@@ -5,7 +5,6 @@
|
||||
<h2 class="mb-2">
|
||||
🛍️ {{ list.title }}
|
||||
|
||||
|
||||
{% if list.is_archived %}
|
||||
<span class="badge bg-secondary ms-2">(Archiwalna)</span>
|
||||
{% endif %}
|
||||
@@ -70,8 +69,6 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</li>
|
||||
|
||||
|
||||
{% else %}
|
||||
<li id="empty-placeholder" class="list-group-item bg-dark text-secondary text-center w-100">
|
||||
Brak produktów w tej liście.
|
||||
@@ -81,10 +78,12 @@
|
||||
|
||||
{% if not list.is_archived %}
|
||||
<div class="input-group mb-2">
|
||||
<input id="newItem" class="form-control bg-dark text-white border-secondary" placeholder="Dodaj produkt i ilość">
|
||||
<input id="newItem" class="form-control bg-dark text-white border-secondary" placeholder="Dodaj produkt i ilość" {% if
|
||||
not current_user.is_authenticated %}disabled{% endif %}>
|
||||
<input id="newQuantity" type="number" class="form-control bg-dark text-white border-secondary" placeholder="Ilość"
|
||||
min="1" value="1" style="max-width: 90px;">
|
||||
<button onclick="addItem({{ list.id }})" class="btn btn-success rounded-end">➕ Dodaj</button>
|
||||
min="1" value="1" style="max-width: 90px;" {% if not current_user.is_authenticated %}disabled{% endif %}>
|
||||
<button onclick="addItem({{ list.id }})" class="btn btn-success rounded-end" {% if not current_user.is_authenticated
|
||||
%}disabled{% endif %}>➕ Dodaj</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -106,6 +105,25 @@
|
||||
<div class="collapse" id="receiptSection">
|
||||
{% set receipt_pattern = 'list_' ~ list.id %}
|
||||
|
||||
{% if receipt_files %}
|
||||
<hr>
|
||||
<div class="mt-3 p-3 border border-secondary rounded bg-dark text-white" id="receiptAnalysisBlock">
|
||||
<h5>🧠 Analiza paragonów (OCR)</h5>
|
||||
<p class="text-small">System spróbuje automatycznie rozpoznać kwoty z dodanych paragonów.</p>
|
||||
|
||||
{% if current_user.is_authenticated %}
|
||||
<button id="analyzeBtn" class="btn btn-outline-info mb-3">
|
||||
🔍 Zleć analizę OCR
|
||||
</button>
|
||||
{% else %}
|
||||
<div class="alert alert-warning">🔒 Tylko zalogowani użytkownicy mogą zlecać analizę OCR paragonów.</div>
|
||||
{% endif %}
|
||||
|
||||
<div id="analysisResults" class="mt-2"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<h5 class="mt-4">📸 Paragony dodane do tej listy</h5>
|
||||
|
||||
<div class="row g-3 mt-2" id="receiptGallery">
|
||||
@@ -125,7 +143,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if not list.is_archived %}
|
||||
{% if not list.is_archived and current_user.is_authenticated %}
|
||||
<hr>
|
||||
<h5>📤 Dodaj zdjęcie paragonu</h5>
|
||||
<form id="receiptForm" action="{{ url_for('upload_receipt', list_id=list.id) }}" method="post"
|
||||
@@ -151,7 +169,6 @@
|
||||
|
||||
<div id="receiptGallery" class="mt-3"></div>
|
||||
</form>
|
||||
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
@@ -192,7 +209,7 @@
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='clickable_row.js') }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='receipt_section.js') }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='receipt_upload.js') }}"></script>
|
||||
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='receipt_analysis.js') }}"></script>
|
||||
<script>
|
||||
setupList({{ list.id }}, '{{ current_user.username if current_user.is_authenticated else 'Gość' }}');
|
||||
</script>
|
||||
|
Reference in New Issue
Block a user