From 643757e45e9563a47f05545fe9e386df5cd1d63a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Mon, 28 Jul 2025 13:20:23 +0200 Subject: [PATCH] =?UTF-8?q?crop=20dla=20user=C3=B3w=20i=20przeniesienie=20?= =?UTF-8?q?listy=20na=20inny=20miesiac?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.py | 145 ++++++++++++------ ...eceipt_crop.js => _unused_receipt_crop.js} | 60 +------- static/js/admin_receipt_crop.js | 39 +++++ static/js/receipt_crop_logic.js | 74 +++++++++ static/js/user_receipt_crop.js | 39 +++++ templates/admin/edit_list.html | 15 ++ templates/admin/receipts.html | 20 ++- templates/base.html | 3 +- templates/edit_my_list.html | 50 +++++- 9 files changed, 334 insertions(+), 111 deletions(-) rename static/js/{receipt_crop.js => _unused_receipt_crop.js} (51%) create mode 100644 static/js/admin_receipt_crop.js create mode 100644 static/js/receipt_crop_logic.js create mode 100644 static/js/user_receipt_crop.js diff --git a/app.py b/app.py index 2862eee..b12f77c 100644 --- a/app.py +++ b/app.py @@ -64,13 +64,17 @@ app = Flask(__name__) app.config.from_object(Config) # Konfiguracja nagłówków bezpieczeństwa z .env -csp_policy = { - "default-src": "'self'", - "script-src": "'self' 'unsafe-inline'", - "style-src": "'self' 'unsafe-inline'", - "img-src": "'self' data:", - "connect-src": "'self'", -} if app.config.get("ENABLE_CSP", True) else None +csp_policy = ( + { + "default-src": "'self'", + "script-src": "'self' 'unsafe-inline'", + "style-src": "'self' 'unsafe-inline'", + "img-src": "'self' data:", + "connect-src": "'self'", + } + if app.config.get("ENABLE_CSP", True) + else None +) permissions_policy = {"browsing-topics": "()"} if app.config.get("ENABLE_PP") else None @@ -424,9 +428,38 @@ def generate_new_receipt_filename(list_id): return f"list_{list_id}_{timestamp}_{random_part}.webp" +def handle_crop_receipt(receipt_id, file): + if not receipt_id or not file: + return {"success": False, "error": "Brak danych"} + + receipt = Receipt.query.get_or_404(receipt_id) + old_path = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename) + + try: + new_filename = generate_new_receipt_filename(receipt.list_id) + new_path = os.path.join(app.config["UPLOAD_FOLDER"], new_filename) + + save_resized_image(file, new_path) + + if os.path.exists(old_path): + os.remove(old_path) + + receipt.filename = os.path.basename(new_path) + db.session.commit() + recalculate_filesizes(receipt.id) + return {"success": True} + except Exception as e: + return {"success": False, "error": str(e)} + + def get_expenses_aggregated_by_list_created_at( - user_only=False, admin=False, show_all=False, - range_type="monthly", start_date=None, end_date=None, user_id=None + user_only=False, + admin=False, + show_all=False, + range_type="monthly", + start_date=None, + end_date=None, + user_id=None, ): """ Wspólna logika: sumujemy najnowszy wydatek z każdej listy, @@ -455,8 +488,7 @@ def get_expenses_aggregated_by_list_created_at( except Exception: return {"error": "Błędne daty", "labels": [], "expenses": []} lists_query = lists_query.filter( - ShoppingList.created_at >= dt_start, - ShoppingList.created_at < dt_end + ShoppingList.created_at >= dt_start, ShoppingList.created_at < dt_end ) lists = lists_query.all() @@ -464,8 +496,7 @@ def get_expenses_aggregated_by_list_created_at( data = [] for sl in lists: latest_exp = ( - Expense.query - .filter_by(list_id=sl.id) + Expense.query.filter_by(list_id=sl.id) .order_by(Expense.added_at.desc()) .first() ) @@ -1002,15 +1033,31 @@ def edit_my_list(list_id): abort(403, description="Nie jesteś właścicielem tej listy.") if request.method == "POST": + # Obsługa zmiany miesiąca utworzenia listy + move_to_month = request.form.get("move_to_month") + if move_to_month: + try: + year, month = map(int, move_to_month.split("-")) + new_created_at = datetime(year, month, 1, tzinfo=timezone.utc) + l.created_at = new_created_at + db.session.commit() + flash( + f"Zmieniono datę utworzenia listy na {new_created_at.strftime('%Y-%m-%d')}", + "success", + ) + return redirect(url_for("edit_my_list", list_id=list_id)) + except ValueError: + flash("Nieprawidłowy format miesiąca", "danger") + return redirect(url_for("edit_my_list", list_id=list_id)) + + # Pozostała aktualizacja pól new_title = request.form.get("title", "").strip() is_public = "is_public" in request.form is_temporary = "is_temporary" in request.form is_archived = "is_archived" in request.form - expires_date = request.form.get("expires_date") expires_time = request.form.get("expires_time") - # Walidacja tytułu if not new_title: flash("Podaj poprawny tytuł", "danger") return redirect(url_for("edit_my_list", list_id=list_id)) @@ -1020,7 +1067,6 @@ def edit_my_list(list_id): l.is_temporary = is_temporary l.is_archived = is_archived - # Obsługa daty wygaśnięcia if expires_date and expires_time: try: combined = f"{expires_date} {expires_time}" @@ -1160,7 +1206,7 @@ def view_list(list_id): expenses=expenses, total_expense=total_expense, is_share=False, - is_owner=is_owner + is_owner=is_owner, ) @@ -1254,7 +1300,7 @@ def user_expenses_data(): range_type=range_type, start_date=start_date, end_date=end_date, - user_id=current_user.id + user_id=current_user.id, ) if "error" in result: return jsonify({"error": result["error"]}), 400 @@ -1545,6 +1591,22 @@ def analyze_receipts_for_list(list_id): return jsonify({"results": results, "total": round(total, 2)}) +@app.route("/user_crop_receipt", methods=["POST"]) +@login_required +def crop_receipt_user(): + receipt_id = request.form.get("receipt_id") + file = request.files.get("cropped_image") + + receipt = Receipt.query.get_or_404(receipt_id) + list_obj = ShoppingList.query.get_or_404(receipt.list_id) + + if list_obj.owner_id != current_user.id and not current_user.is_admin: + return jsonify(success=False, error="Brak dostępu"), 403 + + result = handle_crop_receipt(receipt_id, file) + return jsonify(result) + + @app.route("/admin") @login_required @admin_required @@ -1775,8 +1837,11 @@ def admin_receipts(id): all_db_filenames = set(r.filename for r in receipts) files_on_disk = set(os.listdir(upload_folder)) stale_files = [ - f for f in files_on_disk - if f.endswith(".webp") and f not in all_db_filenames and f.startswith("list_") + f + for f in files_on_disk + if f.endswith(".webp") + and f not in all_db_filenames + and f.startswith("list_") ] else: list_id = int(id) @@ -1794,7 +1859,7 @@ def admin_receipts(id): "admin/receipts.html", receipts=receipts, orphan_files=stale_files, - orphan_files_count=len(stale_files) + orphan_files_count=len(stale_files), ) @@ -1995,6 +2060,18 @@ def edit_list(list_id): flash("Niepoprawna kwota", "danger") return redirect(url_for("edit_list", list_id=list_id)) + created_month = request.form.get("created_month") + if created_month: + try: + year, month = map(int, created_month.split("-")) + l.created_at = datetime(year, month, 1, tzinfo=timezone.utc) + except ValueError: + flash( + "Nieprawidłowy format miesiąca (przeniesienie daty utworzenia)", + "danger", + ) + return redirect(url_for("edit_list", list_id=list_id)) + db.session.add(l) db.session.commit() flash("Zapisano zmiany listy", "success") @@ -2232,31 +2309,11 @@ def demote_user(user_id): @app.route("/admin/crop_receipt", methods=["POST"]) @login_required @admin_required -def crop_receipt(): +def crop_receipt_admin(): receipt_id = request.form.get("receipt_id") file = request.files.get("cropped_image") - - if not receipt_id or not file: - return jsonify(success=False, error="Brak danych") - - receipt = Receipt.query.get_or_404(receipt_id) - old_path = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename) - - try: - new_filename = generate_new_receipt_filename(receipt.list_id) - new_path = os.path.join(app.config["UPLOAD_FOLDER"], new_filename) - - save_resized_image(file, new_path) - - if os.path.exists(old_path): - os.remove(old_path) - - receipt.filename = os.path.basename(new_path) - db.session.commit() - recalculate_filesizes(receipt.id) - return jsonify(success=True) - except Exception as e: - return jsonify(success=False, error=str(e)) + result = handle_crop_receipt(receipt_id, file) + return jsonify(result) @app.route("/admin/recalculate_filesizes") diff --git a/static/js/receipt_crop.js b/static/js/_unused_receipt_crop.js similarity index 51% rename from static/js/receipt_crop.js rename to static/js/_unused_receipt_crop.js index 33754a0..d39765b 100644 --- a/static/js/receipt_crop.js +++ b/static/js/_unused_receipt_crop.js @@ -4,22 +4,22 @@ let currentReceiptId; document.addEventListener("DOMContentLoaded", function () { const cropModal = document.getElementById("cropModal"); const cropImage = document.getElementById("cropImage"); - const spinner = document.getElementById("cropLoading"); cropModal.addEventListener("shown.bs.modal", function (event) { const button = event.relatedTarget; const imgSrc = button.getAttribute("data-img-src"); currentReceiptId = button.getAttribute("data-receipt-id"); - cropImage.src = imgSrc; + const image = document.getElementById("cropImage"); + image.src = imgSrc; if (cropper) { cropper.destroy(); cropper = null; } - cropImage.onload = () => { - cropper = new Cropper(cropImage, { + image.onload = () => { + cropper = new Cropper(image, { viewMode: 1, autoCropArea: 1, responsive: true, @@ -36,51 +36,7 @@ document.addEventListener("DOMContentLoaded", function () { document.getElementById("saveCrop").addEventListener("click", function () { if (!cropper) return; - spinner.classList.remove("d-none"); - - const cropData = cropper.getData(); - const imageData = cropper.getImageData(); - - const scaleX = imageData.naturalWidth / imageData.width; - const scaleY = imageData.naturalHeight / imageData.height; - - const width = cropData.width * scaleX; - const height = cropData.height * scaleY; - - if (width < 1 || height < 1) { - spinner.classList.add("d-none"); - showToast("Obszar przycięcia jest zbyt mały lub pusty", "danger"); - return; - } - - // Ogranicz do 2000x2000 w proporcji - const maxDim = 2000; - const scale = Math.min(1, maxDim / Math.max(width, height)); - - const finalWidth = Math.round(width * scale); - const finalHeight = Math.round(height * scale); - - const croppedCanvas = cropper.getCroppedCanvas({ - width: finalWidth, - height: finalHeight, - imageSmoothingEnabled: true, - imageSmoothingQuality: 'high', - }); - - - if (!croppedCanvas) { - spinner.classList.add("d-none"); - showToast("Nie można uzyskać obrazu przycięcia", "danger"); - return; - } - - croppedCanvas.toBlob(function (blob) { - if (!blob) { - spinner.classList.add("d-none"); - showToast("Nie udało się zapisać obrazu", "danger"); - return; - } - + cropper.getCroppedCanvas().toBlob(function (blob) { const formData = new FormData(); formData.append("receipt_id", currentReceiptId); formData.append("cropped_image", blob); @@ -91,7 +47,6 @@ document.addEventListener("DOMContentLoaded", function () { }) .then((res) => res.json()) .then((data) => { - spinner.classList.add("d-none"); if (data.success) { showToast("Zapisano przycięty paragon", "success"); setTimeout(() => location.reload(), 1500); @@ -100,10 +55,9 @@ document.addEventListener("DOMContentLoaded", function () { } }) .catch((err) => { - spinner.classList.add("d-none"); showToast("Błąd sieci", "danger"); console.error(err); }); - }, "image/webp", 1.0); + }, "image/webp"); }); -}); +}); \ No newline at end of file diff --git a/static/js/admin_receipt_crop.js b/static/js/admin_receipt_crop.js new file mode 100644 index 0000000..4a8335b --- /dev/null +++ b/static/js/admin_receipt_crop.js @@ -0,0 +1,39 @@ +(function () { + document.addEventListener("DOMContentLoaded", function () { + const cropModal = document.getElementById("adminCropModal"); + const cropImage = document.getElementById("adminCropImage"); + const spinner = document.getElementById("adminCropLoading"); + const saveButton = document.getElementById("adminSaveCrop"); + + if (!cropModal || !cropImage || !spinner || !saveButton) return; + + let cropper; + let currentReceiptId; + const currentEndpoint = "/admin/crop_receipt"; + + cropModal.addEventListener("shown.bs.modal", function (event) { + const button = event.relatedTarget; + const imgSrc = button.getAttribute("data-img-src"); + currentReceiptId = button.getAttribute("data-receipt-id"); + cropImage.src = imgSrc; + + document.querySelectorAll('.cropper-container').forEach(e => e.remove()); + + if (cropper) cropper.destroy(); + cropImage.onload = () => { + cropper = cropUtils.initCropper(cropImage); + }; + }); + + cropModal.addEventListener("hidden.bs.modal", function () { + cropUtils.cleanUpCropper(cropImage, cropper); + cropper = null; + }); + + saveButton.addEventListener("click", function () { + if (!cropper) return; + spinner.classList.remove("d-none"); + cropUtils.handleCrop(currentEndpoint, currentReceiptId, cropper, spinner); + }); + }); +})(); diff --git a/static/js/receipt_crop_logic.js b/static/js/receipt_crop_logic.js new file mode 100644 index 0000000..ef4dfe9 --- /dev/null +++ b/static/js/receipt_crop_logic.js @@ -0,0 +1,74 @@ +// receipt_crop_logic.js +(function () { + function initCropper(imgEl) { + return new Cropper(imgEl, { + viewMode: 1, + autoCropArea: 1, + responsive: true, + background: false, + zoomable: true, + movable: true, + dragMode: 'move', + minContainerHeight: 400, + minContainerWidth: 400, + }); + } + + function cleanUpCropper(imgEl, cropperInstance) { + if (cropperInstance) { + cropperInstance.destroy(); + } + if (imgEl) imgEl.src = ""; + } + + function handleCrop(endpoint, receiptId, cropper, spinner) { + const croppedCanvas = cropper.getCroppedCanvas({ + imageSmoothingEnabled: false, + imageSmoothingQuality: "high", + }); + if (!croppedCanvas) { + spinner.classList.add("d-none"); + showToast("Nie można uzyskać obrazu przycięcia", "danger"); + return; + } + + croppedCanvas.toBlob(function (blob) { + if (!blob) { + spinner.classList.add("d-none"); + showToast("Nie udało się zapisać obrazu", "danger"); + return; + } + + const formData = new FormData(); + formData.append("receipt_id", receiptId); + formData.append("cropped_image", blob); + + fetch(endpoint, { + method: "POST", + body: formData, + }) + .then((res) => res.json()) + .then((data) => { + spinner.classList.add("d-none"); + if (data.success) { + showToast("Zapisano przycięty paragon", "success"); + setTimeout(() => location.reload(), 1500); + } else { + showToast("Błąd: " + (data.error || "Nieznany"), "danger"); + } + }) + .catch((err) => { + spinner.classList.add("d-none"); + showToast("Błąd sieci", "danger"); + console.error(err); + }); + }, "image/webp", 1.0); + } + + + window.cropUtils = { + initCropper, + cleanUpCropper, + handleCrop, + }; +})(); diff --git a/static/js/user_receipt_crop.js b/static/js/user_receipt_crop.js new file mode 100644 index 0000000..86954f9 --- /dev/null +++ b/static/js/user_receipt_crop.js @@ -0,0 +1,39 @@ +(function () { + document.addEventListener("DOMContentLoaded", function () { + const cropModal = document.getElementById("userCropModal"); + const cropImage = document.getElementById("userCropImage"); + const spinner = document.getElementById("userCropLoading"); + const saveButton = document.getElementById("userSaveCrop"); + + if (!cropModal || !cropImage || !spinner || !saveButton) return; + + let cropper; + let currentReceiptId; + const currentEndpoint = "/user_crop_receipt"; + + cropModal.addEventListener("shown.bs.modal", function (event) { + const button = event.relatedTarget; + const imgSrc = button.getAttribute("data-img-src"); + currentReceiptId = button.getAttribute("data-receipt-id"); + cropImage.src = imgSrc; + + document.querySelectorAll('.cropper-container').forEach(e => e.remove()); + + if (cropper) cropper.destroy(); + cropImage.onload = () => { + cropper = cropUtils.initCropper(cropImage); + }; + }); + + cropModal.addEventListener("hidden.bs.modal", function () { + cropUtils.cleanUpCropper(cropImage, cropper); + cropper = null; + }); + + saveButton.addEventListener("click", function () { + if (!cropper) return; + spinner.classList.remove("d-none"); + cropUtils.handleCrop(currentEndpoint, currentReceiptId, cropper, spinner); + }); + }); +})(); diff --git a/templates/admin/edit_list.html b/templates/admin/edit_list.html index 9666185..5f78e19 100644 --- a/templates/admin/edit_list.html +++ b/templates/admin/edit_list.html @@ -65,6 +65,21 @@ +
+
+ +

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

+
+
+ + +
+
+ +
🔄 Obróć o 90° ✂️ Przytnij + data-bs-target="#adminCropModal" data-img-src="{{ url_for('uploaded_file', filename=r.filename) }}" + data-receipt-id="{{ r.id }}" data-crop-endpoint="{{ url_for('crop_receipt_admin') }}"> + ✂️ Przytnij + ✏️ Zmień nazwę {% if not r.file_hash %} @@ -90,7 +92,7 @@
{% endif %} - + +
+
+ +

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

+
+
+ + +
+
+
@@ -75,7 +90,11 @@ 🔄 Obróć o 90° - + + ✂️ Przytnij + 🗑️ Usuń
@@ -116,14 +135,37 @@ -
- -
+ + + + {% endblock %} {% block scripts %} + + {% endblock %} \ No newline at end of file