nowe opcje w paragonacch

This commit is contained in:
Mateusz Gruszczyński
2025-07-20 22:08:25 +02:00
parent ae89f55446
commit 62939a9e9a
5 changed files with 190 additions and 79 deletions

View File

@@ -49,3 +49,5 @@ CREATE TABLE receipt (
ALTER TABLE receipt ADD COLUMN filesize INTEGER;
# unikanie identycznych plikow
ALTER TABLE receipt ADD COLUMN file_hash TEXT

219
app.py
View File

@@ -6,6 +6,9 @@ import mimetypes
import sys
import platform
import psutil
import secrets
import hashlib
from pillow_heif import register_heif_opener
from datetime import datetime, timedelta, UTC, timezone
@@ -39,7 +42,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
from PIL import Image, ExifTags
from werkzeug.utils import secure_filename
from werkzeug.middleware.proxy_fix import ProxyFix
from sqlalchemy import func, extract
@@ -150,7 +153,7 @@ class Receipt(db.Model):
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
shopping_list = db.relationship("ShoppingList", backref="receipts", lazy=True)
filesize = db.Column(db.Integer, nullable=True)
file_hash = db.Column(db.String(64), nullable=True, unique=True)
with app.app_context():
db.create_all()
@@ -253,14 +256,38 @@ def enrich_list_data(l):
l.total_expense = sum(e.amount for e in expenses)
return l
def save_resized_image(file, path):
image = Image.open(file)
try:
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)
except Exception:
pass # brak lub błędny EXIF
image.thumbnail((2000, 2000))
image = image.convert("RGB")
image.info.clear()
new_path = path.rsplit(".", 1)[0] + ".webp"
image = image.convert("RGB")
image.save(new_path, format="WEBP", quality=85)
image.save(new_path, format="WEBP", quality=85, method=6)
def redirect_with_flash(
@@ -299,6 +326,13 @@ def delete_receipts_for_list(list_id):
print(f"Nie udało się usunąć pliku {filename}: {e}")
def _receipt_error(message):
if request.is_json or request.headers.get("X-Requested-With") == "XMLHttpRequest":
return jsonify({"success": False, "error": message}), 400
flash(message, "danger")
return redirect(request.referrer or url_for("main_page"))
# zabezpieczenie logowani do systemu - błędne hasła
def is_ip_blocked(ip):
now = time.time()
@@ -911,34 +945,6 @@ def all_products():
return {"allproducts": unique_names}
""" @app.route('/upload_receipt/<int:list_id>', methods=['POST'])
def upload_receipt(list_id):
if 'receipt' not in request.files:
flash('Brak pliku', 'danger')
return redirect(request.referrer)
file = request.files['receipt']
if file.filename == '':
flash('Nie wybrano pliku', 'danger')
return redirect(request.referrer)
if file and allowed_file(file.filename):
filename = secure_filename(file.filename)
file_path = os.path.join(app.config['UPLOAD_FOLDER'], f"list_{list_id}_{filename}")
save_resized_image(file, file_path)
flash('Wgrano paragon', 'success')
return redirect(request.referrer)
flash('Niedozwolony format pliku', 'danger')
return redirect(request.referrer) """
from datetime import datetime
@app.route("/upload_receipt/<int:list_id>", methods=["POST"])
def upload_receipt(list_id):
if "receipt" not in request.files:
@@ -949,39 +955,52 @@ def upload_receipt(list_id):
return _receipt_error("Nie wybrano pliku")
if file and allowed_file(file.filename):
filename = secure_filename(file.filename)
base_name = f"list_{list_id}_{filename.rsplit('.', 1)[0]}"
webp_filename = base_name + ".webp"
import hashlib
file_bytes = file.read()
file.seek(0)
file_hash = hashlib.sha256(file_bytes).hexdigest()
existing = Receipt.query.filter_by(file_hash=file_hash).first()
if existing:
return _receipt_error("Taki plik już istnieje")
now = datetime.now(timezone.utc)
timestamp = now.strftime("%Y%m%d_%H%M")
random_part = secrets.token_hex(3)
webp_filename = f"list_{list_id}_{timestamp}_{random_part}.webp"
file_path = os.path.join(app.config["UPLOAD_FOLDER"], webp_filename)
save_resized_image(file, file_path)
try:
save_resized_image(file, file_path)
except ValueError as e:
return _receipt_error(str(e))
filesize = os.path.getsize(file_path) if os.path.exists(file_path) else None
uploaded_at = datetime.utcnow()
uploaded_at = datetime.now(timezone.utc)
new_receipt = Receipt(
list_id=list_id,
filename=webp_filename,
filesize=filesize,
uploaded_at=uploaded_at,
file_hash=file_hash,
)
db.session.add(new_receipt)
db.session.commit()
if (
request.is_json
or request.headers.get("X-Requested-With") == "XMLHttpRequest"
):
if request.is_json or request.headers.get("X-Requested-With") == "XMLHttpRequest":
url = url_for("uploaded_file", filename=webp_filename)
socketio.emit("receipt_added", {"url": url}, to=str(list_id))
return jsonify({"success": True, "url": url})
flash("Wgrano paragon", "success")
return redirect(request.referrer)
return redirect(request.referrer or url_for("main_page"))
return _receipt_error("Niedozwolony format pliku")
@app.route("/uploads/<filename>")
def uploaded_file(filename):
response = send_from_directory(app.config["UPLOAD_FOLDER"], filename)
@@ -1224,35 +1243,105 @@ def admin_receipts(id):
return render_template("admin/receipts.html", receipts=receipts)
@app.route("/admin/delete_receipt/<filename>")
@app.route("/admin/rotate_receipt/<int:receipt_id>")
@login_required
@admin_required
def delete_receipt(filename):
file_path = os.path.join(app.config["UPLOAD_FOLDER"], filename)
removed_file = False
removed_db = False
def rotate_receipt(receipt_id):
receipt = Receipt.query.get_or_404(receipt_id)
filepath = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename)
# Usuń plik z dysku
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)
flash("Obrócono paragon", "success")
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/rename_receipt/<int:receipt_id>")
@login_required
@admin_required
def rename_receipt(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):
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_path = os.path.join(app.config["UPLOAD_FOLDER"], new_filename)
try:
os.rename(old_path, new_path)
receipt.filename = new_filename
db.session.commit()
flash("Zmieniono nazwę pliku", "success")
except Exception as e:
flash(f"Błąd przy zmianie nazwy: {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):
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):
os.remove(file_path)
removed_file = True
try:
os.remove(file_path)
except Exception as e:
flash(f"Błąd przy usuwaniu pliku: {str(e)}", "danger")
# Usuń rekord z bazy
receipt = Receipt.query.filter_by(filename=filename).first()
if receipt:
db.session.delete(receipt)
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
def generate_receipt_hash(receipt_id):
receipt = Receipt.query.get_or_404(receipt_id)
if receipt.file_hash:
flash("Hash już istnieje", "info")
return redirect(request.referrer)
file_path = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename)
if not os.path.exists(file_path):
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()
receipt.file_hash = file_hash
db.session.commit()
removed_db = True
flash("Hash wygenerowany", "success")
except Exception as e:
flash(f"Błąd przy generowaniu hasha: {e}", "danger")
# Komunikat
if removed_file or removed_db:
flash("Paragon usunięty", "success")
else:
flash("Paragon nie istnieje", "danger")
# Powrót
next_url = request.args.get("next")
return redirect(next_url or url_for("admin_receipts", id="all"))
return redirect(request.referrer)
@app.route("/admin/delete_selected_lists", methods=["POST"])

View File

@@ -61,15 +61,17 @@ if (!window.receiptUploaderInitialized) {
xhr.onreadystatechange = function () {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status === 200) {
try {
const res = JSON.parse(xhr.responseText);
if (res.success && res.url) {
if (xhr.status === 200 && res.success && res.url) {
fetch(window.location.href)
.then(response => response.text())
.then(html => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const newGallery = doc.getElementById("receiptGallery");
if (newGallery) {
gallery.innerHTML = newGallery.innerHTML;
@@ -85,14 +87,16 @@ if (!window.receiptUploaderInitialized) {
}
});
} else {
showToast(res.message || "Błąd podczas wgrywania.", "danger");
const errorMessage = res.error || res.message || "Błąd podczas wgrywania.";
showToast(errorMessage, "danger");
}
} else {
showToast("Błąd serwera. Spróbuj ponownie.", "danger");
} catch (err) {
showToast("Błąd serwera (nieprawidłowa odpowiedź).", "danger");
}
}
};
xhr.send(formData);
}

View File

@@ -195,31 +195,40 @@
{% 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) }}" data-lightbox="receipts"
data-title="{{ r.filename }}" class="glightbox">
<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">Rozmiar nieznany</p>
<p class="small mb-1 text-muted">Brak danych o rozmiarze</p>
{% endif %}
<p class="small mb-1">Wgrano: {{ r.uploaded_at.strftime('%Y-%m-%d %H:%M') }}</p>
<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="{{ 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 %}
<a href="{{ url_for('generate_receipt_hash', receipt_id=r.id) }}"
class="btn btn-sm btn-outline-secondary w-100 mb-2">🔐 Generuj hash</a>
{% endif %}
<a href="{{ url_for('delete_receipt', receipt_id=r.id) }}"
class="btn btn-sm btn-outline-danger w-100 mb-2">🗑️ Usuń</a>
<a href="{{ url_for('delete_receipt', filename=r.filename) }}?next={{ url_for('edit_list', list_id=list.id) }}"
class="btn btn-sm btn-outline-danger w-100">🗑️ Usuń</a>
</div>
</div>
</div>
{% endfor %}
</div>
{% if not receipts %}

View File

@@ -28,12 +28,19 @@
{% else %}
<p class="small mb-1 text-muted">Brak danych o rozmiarze</p>
{% endif %}
<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="{{ 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 %}
<a href="{{ url_for('generate_receipt_hash', receipt_id=r.id) }}"
class="btn btn-sm btn-outline-secondary w-100 mb-2">🔐 Generuj hash</a>
{% endif %}
<a href="{{ url_for('delete_receipt', receipt_id=r.id) }}" class="btn btn-sm btn-outline-danger w-100">🗑️
Usuń</a>
<a href="{{ url_for('edit_list', list_id=r.list_id) }}" class="btn btn-sm btn-outline-light w-100 mb-2">✏️
Edytuj listę #{{ r.list_id }}</a>
<a href="{{ url_for('delete_receipt', filename=r.filename|urlencode) }}?next={{ request.path }}"
class="btn btn-sm btn-outline-danger w-100">🗑️ Usuń</a>
</div>
</div>
</div>