uprawnienia ocr i uploadu

This commit is contained in:
Mateusz Gruszczyński
2025-07-21 15:50:35 +02:00
parent 955196dd92
commit 983114575d
5 changed files with 154 additions and 83 deletions

61
app.py
View File

@@ -8,9 +8,8 @@ import platform
import psutil
import secrets
import hashlib
import re
import tempfile
from pillow_heif import register_heif_opener
@@ -228,17 +227,6 @@ def serve_css_lib(filename):
app.register_blueprint(static_bp)
def user_has_list_access(list_obj, user):
if not user.is_authenticated:
return False
if list_obj.owner_id == user.id:
return True
if db.session.query(SharedList).filter_by(list_id=list_obj.id, user_id=user.id).first():
return True
return False
def allowed_file(filename):
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
@@ -376,7 +364,6 @@ def preprocess_image_for_tesseract(pil_image):
def extract_total_tesseract(image):
text = pytesseract.image_to_string(image, lang="pol", config="--psm 6")
lines = text.splitlines()
candidates = []
@@ -415,23 +402,30 @@ def extract_total_tesseract(image):
except:
continue
# Rozszerzone słowa kluczowe
keywords = r"sum[aąo]?|razem|zapłat[ay]?|sprzedaż|opodatk|należność|do zapłaty"
preferred = [
val
for val, line in candidates
if re.search(r"sum[aąo]?|razem|zapłaty", line.lower())
if re.search(keywords, line.lower())
]
if preferred:
max_val = round(max(preferred), 2)
return max_val, lines
# Fallback: wybierz największą wartość jeśli jest sensowna
if candidates:
max_val = round(max([val for val, _ in candidates]), 2)
return max_val, lines
# Jeśli np. większa niż 10 PLN, zakładamy że to może być suma końcowa
if max_val >= 10:
return max_val, lines
return 0.0, lines
############# END OCR #######################
@@ -1050,9 +1044,6 @@ def all_products():
@app.route("/upload_receipt/<int:list_id>", methods=["POST"])
@login_required
def upload_receipt(list_id):
list_obj = db.session.get(ShoppingList, list_id)
if not list_obj or not user_has_list_access(list_obj, current_user):
return _receipt_error("Gość/niezalogowany nie może wgrywać plików")
if "receipt" not in request.files:
return _receipt_error("Brak pliku")
@@ -1062,8 +1053,6 @@ def upload_receipt(list_id):
return _receipt_error("Nie wybrano pliku")
if file and allowed_file(file.filename):
import hashlib
file_bytes = file.read()
file.seek(0)
file_hash = hashlib.sha256(file_bytes).hexdigest()
@@ -1147,11 +1136,12 @@ def reorder_items():
@app.route("/lists/<int:list_id>/analyze", methods=["POST"])
@login_required
def analyze_receipts_for_list(list_id):
list_obj = db.session.get(ShoppingList, list_id)
if not list_obj or not user_has_list_access(list_obj, current_user):
return jsonify({"error": "Brak dostępu"}), 403
receipt_objs = Receipt.query.filter_by(list_id=list_id).all()
existing_expenses = {
e.receipt_filename for e in Expense.query.filter_by(list_id=list_id).all()
if e.receipt_filename
}
results = []
total = 0.0
@@ -1171,13 +1161,18 @@ def analyze_receipts_for_list(list_id):
value = 0.0
lines = []
already_added = receipt.filename in existing_expenses
results.append({
"id": receipt.id,
"filename": receipt.filename,
"amount": round(value, 2),
"debug_text": lines,
"already_added": already_added
})
total += value
if not already_added:
total += value
return jsonify({"results": results, "total": round(total, 2)})
@@ -1483,8 +1478,6 @@ def generate_receipt_hash(receipt_id):
flash("Plik nie istnieje", "danger")
return redirect(request.referrer)
import hashlib
try:
with open(file_path, "rb") as f:
file_hash = hashlib.sha256(f.read()).hexdigest()
@@ -2174,8 +2167,18 @@ def handle_update_note(data):
def handle_add_expense(data):
list_id = data["list_id"]
amount = data["amount"]
receipt_filename = data.get("receipt_filename")
if receipt_filename:
existing = Expense.query.filter_by(list_id=list_id, receipt_filename=receipt_filename).first()
if existing:
return
new_expense = Expense(
list_id=list_id,
amount=amount,
receipt_filename=receipt_filename
)
new_expense = Expense(list_id=list_id, amount=amount)
db.session.add(new_expense)
db.session.commit()

View File

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

View File

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

View File

@@ -7,32 +7,46 @@ document.addEventListener("DOMContentLoaded", () => {
async function analyzeReceipts(listId) {
const resultsDiv = document.getElementById("analysisResults");
resultsDiv.innerHTML = `<div class="text-info">⏳ Trwa analiza paragonów...</div>`;
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(); // ⏱ START
const start = performance.now();
try {
const res = await fetch(`/lists/${listId}/analyze`, { method: "POST" });
const data = await res.json();
const duration = ((performance.now() - start) / 1000).toFixed(2);
const duration = ((performance.now() - start) / 1000).toFixed(2); // ⏱ STOP
let html = `<p><b>📊 Łącznie wykryto:</b> ${data.total.toFixed(2)} PLN</p>`;
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">
<span class="text-light">${r.filename}</span>:
<input type="number" id="amount-${i}" value="${r.amount}" step="0.01" class="form-control d-inline-block bg-dark text-white border-light rounded" style="width: 120px;">
<button onclick="emitExpense(${i})" class="btn btn-sm btn-outline-success ms-2"> Dodaj</button>
</div>`;
<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 onclick="emitAllExpenses(${data.results.length})" class="btn btn-success mt-3"> Dodaj wszystkie</button>`;
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;
@@ -42,22 +56,44 @@ async function analyzeReceipts(listId) {
}
}
function emitExpense(i) {
const r = window._ocr_results[i];
const val = parseFloat(document.getElementById(`amount-${i}`).value);
const btn = document.getElementById(`add-btn-${i}`);
if (!isNaN(val) && val > 0) {
socket.emit('add_expense', {
list_id: LIST_ID,
amount: val
amount: val,
receipt_filename: r.filename
});
document.getElementById(`amount-${i}`).disabled = true;
if (btn) {
btn.disabled = true;
btn.classList.remove('btn-outline-success');
btn.classList.add('btn-success');
btn.textContent = '✅ Dodano';
}
}
}
function emitAllExpenses(n) {
for (let i = 0; i < n; i++) {
emitExpense(i);
const btnAll = document.getElementById('addAllBtn');
if (btnAll) {
btnAll.disabled = true;
btnAll.innerHTML = `<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);
}

View File

@@ -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 %}
@@ -112,9 +111,13 @@
<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>
@@ -140,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"
@@ -166,7 +169,6 @@
<div id="receiptGallery" class="mt-3"></div>
</form>
{% endif %}
</div>