crop dla userów i przeniesienie listy na inny miesiac

This commit is contained in:
Mateusz Gruszczyński
2025-07-28 13:20:23 +02:00
parent 9e3068a722
commit 643757e45e
9 changed files with 334 additions and 111 deletions

145
app.py
View File

@@ -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")

View File

@@ -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");
});
});
});

View File

@@ -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);
});
});
})();

View File

@@ -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,
};
})();

View File

@@ -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);
});
});
})();

View File

@@ -65,6 +65,21 @@
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">Aktualna data utworzenia listy</label>
<p class="form-control-plaintext text-white">
{{ list.created_at.strftime('%Y-%m-%d') }}
</p>
</div>
<div class="col-md-6">
<label for="created_month" class="form-label">Przenieś listę do miesiąca</label>
<input type="month" id="created_month" name="created_month"
class="form-control bg-dark text-white border-secondary rounded">
</div>
</div>
<div class="mb-4">
<label class="form-label">Link do udostępnienia</label>
<input type="text" class="form-control bg-dark text-white border-secondary rounded" readonly

View File

@@ -36,8 +36,10 @@
<a href="{{ url_for('rotate_receipt', receipt_id=r.id) }}"
class="btn btn-sm btn-outline-warning w-100 mb-2">🔄 Obróć o 90°</a>
<a href="#" class="btn btn-sm btn-outline-secondary w-100 mb-2" data-bs-toggle="modal"
data-bs-target="#cropModal" data-img-src="{{ url_for('uploaded_file', filename=r.filename) }}"
data-receipt-id="{{ r.id }}">✂️ Przytnij</a>
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
</a>
<a href="{{ url_for('rename_receipt', receipt_id=r.id) }}" class="btn btn-sm btn-outline-info w-100 mb-2">✏️
Zmień nazwę</a>
{% if not r.file_hash %}
@@ -90,7 +92,7 @@
</div>
{% endif %}
<div class="modal fade" id="cropModal" tabindex="-1" aria-labelledby="cropModalLabel" aria-hidden="true">
<div class="modal fade" id="adminCropModal" tabindex="-1" aria-labelledby="userCropModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content bg-dark text-white">
<div class="modal-header">
@@ -98,14 +100,12 @@
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div style="position: relative; width: 100%; height: 75vh;">
<img id="cropImage" style="max-width: 100%; max-height: 100%; display: block; margin: auto;">
<img id="adminCropImage" style="max-width: 100%; max-height: 100%; display: block; margin: auto;">
</div>
<div class="modal-footer">
<button class="btn btn-secondary" data-bs-dismiss="modal">Anuluj</button>
<button class="btn btn-success" id="saveCrop">Zapisz</button>
<div id="cropLoading" class="position-absolute top-50 start-50 translate-middle text-center d-none"
style="z-index: 1055;">
<button class="btn btn-success" id="adminSaveCrop">Zapisz</button>
<div id="adminCropLoading" class="position-absolute top-50 start-50 translate-middle text-center d-none">
<div class="spinner-border text-light" role="status"></div>
<div class="mt-2 text-light">⏳ Pracuję...</div>
</div>
@@ -114,8 +114,12 @@
</div>
</div>
{% block scripts %}
<script src="{{ url_for('static_bp.serve_js', filename='receipt_crop.js') }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='admin_receipt_crop.js') }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='receipt_crop_logic.js') }}"></script>
{% endblock %}
{% endblock %}

View File

@@ -52,7 +52,6 @@
{% endif %}
</div>
{% endif %}
</div>
</nav>
@@ -100,7 +99,7 @@
});
</script>
{% if '/admin/' in request.path %}
{% if '/admin/receipts' in request.path or '/edit_my_list' in request.path %}
<script src="{{ url_for('static_bp.serve_js_lib', filename='cropper.min.js') }}"></script>
{% endif %}

View File

@@ -37,6 +37,21 @@
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">Aktualna data utworzenia:</label>
<p class="form-control-plaintext text-white">
{{ list.created_at.strftime('%Y-%m-%d') }}
</p>
</div>
<div class="col-md-6">
<label for="move_to_month" class="form-label">Przenieś listę do miesiąca</label>
<input type="month" id="move_to_month" name="move_to_month"
class="form-control bg-dark text-white border-secondary rounded">
</div>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" name="is_archived" id="is_archived" {% if list.is_archived
%}checked{% endif %}>
@@ -75,7 +90,11 @@
<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="#" class="btn btn-sm btn-outline-secondary w-100 mb-2" data-bs-toggle="modal"
data-bs-target="#userCropModal" data-img-src="{{ url_for('uploaded_file', filename=r.filename) }}"
data-receipt-id="{{ r.id }}" data-crop-endpoint="{{ url_for('crop_receipt_user') }}">
✂️ Przytnij
</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>
@@ -116,14 +135,37 @@
</div>
</div>
</div>
<form id="delete-form" method="post" action="{{ url_for('delete_user_list', list_id=list.id) }}"></form>
<!-- Hidden delete form -->
<form id="delete-form" method="post" action="{{ url_for('delete_user_list', list_id=list.id) }}"></form>
<div class="modal fade" id="userCropModal" tabindex="-1" aria-labelledby="userCropModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content bg-dark text-white">
<div class="modal-header">
<h5 class="modal-title">✂️ Przycinanie paragonu</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div style="position: relative; width: 100%; height: 75vh;">
<img id="userCropImage" style="max-width: 100%; max-height: 100%; display: block; margin: auto;">
</div>
<div class="modal-footer">
<button class="btn btn-secondary" data-bs-dismiss="modal">Anuluj</button>
<button class="btn btn-success" id="userSaveCrop">Zapisz</button>
<div id="userCropLoading" class="position-absolute top-50 start-50 translate-middle text-center d-none">
<div class="spinner-border text-light" role="status"></div>
<div class="mt-2 text-light">⏳ Pracuję...</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static_bp.serve_js', filename='confirm_delete.js') }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='user_receipt_crop.js') }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='receipt_crop_logic.js') }}"></script>
{% endblock %}