diff --git a/app.py b/app.py index 0474504..70fa4e5 100644 --- a/app.py +++ b/app.py @@ -349,6 +349,97 @@ ShoppingList.permitted_users = db.relationship( lazy="dynamic", ) +class AppSetting(db.Model): + key = db.Column(db.String(64), primary_key=True) + value = db.Column(db.Text, nullable=True) + + +class CategoryColorOverride(db.Model): + id = db.Column(db.Integer, primary_key=True) + category_id = db.Column(db.Integer, db.ForeignKey("category.id"), unique=True, nullable=False) + color_hex = db.Column(db.String(7), nullable=False) # "#rrggbb" + +def get_setting(key: str, default: str | None = None) -> str | None: + s = db.session.get(AppSetting, key) + return s.value if s else default + +def set_setting(key: str, value: str | None): + s = db.session.get(AppSetting, key) + if (value or "").strip() == "": + if s: + db.session.delete(s) + else: + if not s: + s = AppSetting(key=key, value=value) + db.session.add(s) + else: + s.value = value + +def get_ocr_keywords() -> list[str]: + raw = get_setting("ocr_keywords", None) + if raw: + try: + vals = json.loads(raw) if raw.strip().startswith("[") else [v.strip() for v in raw.split(",")] + return [v for v in vals if v] + except Exception: + pass + # domyślne – obecne w kodzie OCR + return [ + "razem do zapłaty", "do zapłaty", "suma", "kwota", "wartość", "płatność", + "total", "amount" + ] + +# 1) nowa funkcja: tylko frazy użytkownika (bez domyślnych) +def get_user_ocr_keywords_only() -> list[str]: + raw = get_setting("ocr_keywords", None) + if not raw: + return [] + try: + if raw.strip().startswith("["): + vals = json.loads(raw) + else: + vals = [v.strip() for v in raw.split(",")] + return [v for v in vals if v] + except Exception: + return [] + +_BASE_KEYWORDS_BLOCK = r""" + (?: + razem\s*do\s*zap[łl][aąo0]ty | + do\s*zap[łl][aąo0]ty | + suma | + kwota | + warto[śćs] | + płatno[śćs] | + total | + amount + ) +""" + +def priority_keywords_pattern() -> re.Pattern: + user_terms = get_user_ocr_keywords_only() + if user_terms: + + escaped = [re.escape(t) for t in user_terms] + user_block = " | ".join(escaped) + combined = rf""" + \b( + {_BASE_KEYWORDS_BLOCK} + | {user_block} + )\b + """ + else: + combined = rf"""\b({_BASE_KEYWORDS_BLOCK})\b""" + return re.compile(combined, re.IGNORECASE | re.VERBOSE) + + +def category_color_for(c: Category) -> str: + ov = CategoryColorOverride.query.filter_by(category_id=c.id).first() + return ov.color_hex if ov else category_to_color(c.name) + +def color_for_category_label(label: str) -> str: + cat = Category.query.filter(func.lower(Category.name)==label.lower()).first() + return category_color_for(cat) if cat else category_to_color(label) def hash_password(password): pepper = app.config["BCRYPT_PEPPER"] @@ -357,6 +448,14 @@ def hash_password(password): hashed = bcrypt.hashpw(peppered, salt) return hashed.decode("utf-8") +def get_int_setting(key: str, default: int) -> int: + try: + v = get_setting(key, None) + return int(v) if v is not None and str(v).strip() != "" else default + except Exception: + return default + + def check_password(stored_hash, password_input): pepper = app.config["BCRYPT_PEPPER"] @@ -1021,7 +1120,7 @@ def get_total_expenses_grouped_by_category( { "label": cat, "data": [round(data_map[label].get(cat, 0.0), 2) for label in labels], - "backgroundColor": category_to_color(cat), + "backgroundColor": color_for_category_label(cat), } for cat in cats ] @@ -1259,12 +1358,20 @@ def get_page_args( def preprocess_image_for_tesseract(image): + # czułość 1..10 (domyślnie 5) + sens = get_int_setting("ocr_sensitivity", 5) + # próg progowy – im wyższa czułość, tym niższy próg (więcej czerni) + base_thresh = 150 + delta = int((sens - 5) * 8) # krok 8 na stopień + thresh = max(90, min(210, base_thresh - delta)) + image = ImageOps.autocontrast(image) - image = image.point(lambda x: 0 if x < 150 else 255) + image = image.point(lambda x: 0 if x < thresh else 255) image = image.resize((image.width * 2, image.height * 2), Image.BICUBIC) return image + def extract_total_tesseract(image): text = pytesseract.image_to_string(image, lang="pol", config="--psm 4") @@ -1273,21 +1380,7 @@ def extract_total_tesseract(image): blacklist_keywords = re.compile(r"\b(ptu|vat|podatek|stawka)\b", re.IGNORECASE) - priority_keywords = re.compile( - r""" - \b( - razem\s*do\s*zap[łl][aąo0]ty | - do\s*zap[łl][aąo0]ty | - suma | - kwota | - warto[śćs] | - płatno[śćs] | - total | - amount - )\b - """, - re.IGNORECASE | re.VERBOSE, - ) + priority_keywords = priority_keywords_pattern() for line in lines: if not line.strip(): @@ -1364,7 +1457,14 @@ def is_ip_blocked(ip): attempts = failed_login_attempts[ip] while attempts and now - attempts[0] > TIME_WINDOW: attempts.popleft() - return len(attempts) >= MAX_ATTEMPTS + max_attempts = get_int_setting("max_login_attempts", 10) + return len(attempts) >= max_attempts + +def attempts_remaining(ip): + attempts = failed_login_attempts[ip] + max_attempts = get_int_setting("max_login_attempts", 10) + return max(0, max_attempts - len(attempts)) + def register_failed_attempt(ip): @@ -1379,9 +1479,6 @@ def reset_failed_attempts(ip): failed_login_attempts[ip].clear() -def attempts_remaining(ip): - attempts = failed_login_attempts[ip] - return max(0, MAX_ATTEMPTS - len(attempts)) #################################################### @@ -1756,7 +1853,8 @@ def main_page(): l.not_purchased_count = not_purchased_count l.total_expense = latest_expenses_map.get(l.id, 0) l.category_badges = [ - {"name": c.name, "color": category_to_color(c.name)} + {"name": c.name, "color": category_color_for(c)} + for c in l.categories ] else: @@ -2159,7 +2257,8 @@ def view_list(list_id): item.added_by_display = None shopping_list.category_badges = [ - {"name": c.name, "color": category_to_color(c.name)} + {"name": c.name, "color": category_color_for(c)} + for c in shopping_list.categories ] @@ -2550,7 +2649,8 @@ def shared_list(token=None, list_id=None): shopping_list, items, receipts, expenses, total_expense = get_list_details(list_id) shopping_list.category_badges = [ - {"name": c.name, "color": category_to_color(c.name)} + {"name": c.name, "color": category_color_for(c)} + for c in shopping_list.categories ] @@ -4010,6 +4110,77 @@ def healthcheck(): return "OK", 200 +@app.route("/admin/settings", methods=["GET", "POST"]) +@login_required +@admin_required +def admin_settings(): + categories = Category.query.order_by(Category.name.asc()).all() + + if request.method == "POST": + # OCR + ocr_raw = (request.form.get("ocr_keywords") or "").strip() + set_setting("ocr_keywords", ocr_raw) + + # OCR sensitivity + ocr_sens = (request.form.get("ocr_sensitivity") or "").strip() + set_setting("ocr_sensitivity", ocr_sens) + + # Bezpieczeństwo – limit błędnych prób (system_auth) + max_attempts = (request.form.get("max_login_attempts") or "").strip() + set_setting("max_login_attempts", max_attempts) + + # (opcjonalnie) okno blokady + login_window = (request.form.get("login_window_seconds") or "").strip() + if login_window: # pole możesz ukryć w UI – zostawiam jako opcję + set_setting("login_window_seconds", login_window) + + # Kolory kategorii + for c in categories: + field = f"color_{c.id}" + val = (request.form.get(field) or "").strip() + existing = CategoryColorOverride.query.filter_by(category_id=c.id).first() + if val and re.fullmatch(r"^#[0-9A-Fa-f]{6}$", val): + if not existing: + db.session.add(CategoryColorOverride(category_id=c.id, color_hex=val)) + else: + existing.color_hex = val + else: + if existing: + db.session.delete(existing) + + db.session.commit() + flash("Zapisano ustawienia.", "success") + return redirect(url_for("admin_settings")) + + # --- GET --- + override_rows = CategoryColorOverride.query.filter( + CategoryColorOverride.category_id.in_([c.id for c in categories]) + ).all() + overrides = {row.category_id: row.color_hex for row in override_rows} + auto_colors = {c.id: category_to_color(c.name) for c in categories} + effective_colors = {c.id: (overrides.get(c.id) or auto_colors[c.id]) for c in categories} + + current_ocr = get_setting("ocr_keywords", "") + + # nowe pola do szablonu + ocr_sensitivity = get_int_setting("ocr_sensitivity", 5) + max_login_attempts = get_int_setting("max_login_attempts", 10) + login_window_seconds = get_int_setting("login_window_seconds", 3600) + + return render_template( + "admin/settings.html", + categories=categories, + overrides=overrides, + auto_colors=auto_colors, + effective_colors=effective_colors, + current_ocr=current_ocr, + ocr_sensitivity=ocr_sensitivity, + max_login_attempts=max_login_attempts, + login_window_seconds=login_window_seconds, + ) + + + @app.route("/robots.txt") def robots_txt(): content = ( diff --git a/static,tar.gz b/static,tar.gz new file mode 100644 index 0000000..40f80f4 Binary files /dev/null and b/static,tar.gz differ diff --git a/static/css/style.css b/static/css/style.css index a6b722c..497338f 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -812,4 +812,46 @@ td select.tom-dark { .text-danger { color: var(--danger) !important; -} \ No newline at end of file +} + +/* ========== Kolorowe wskaźniki pod pickerem ========== */ +.color-indicators .indicator { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: .5rem; +} + +.color-indicators .bar { + height: 10px; + border-radius: 6px; + border: 1px solid rgba(255,255,255,.25); + box-shadow: inset 0 0 0 1px rgba(0,0,0,.25); +} + +/* ========== Swatch + zapisy heksowe ========== */ +.swatch { + width: 16px; + height: 16px; + border-radius: 50%; + display: inline-block; + border: 1px solid rgba(0,0,0,.15); +} + +.hex, +.hex-label { + font-variant-numeric: lining-nums; + letter-spacing: .2px; +} + +/* ========== OCR textarea ========== */ +.settings-ocr-textarea { + font: inherit; + line-height: 1.45; +} + +/* ========== Odznaka poziomu czułości ========== */ +.sens-badge { font-weight: 600; } +.sens-low { background: rgba(108,117,125,.25); color: #ced4da; } /* szary */ +.sens-mid { background: rgba(13,110,253,.25); color: #9ec5fe; } /* niebieski */ +.sens-high { background: rgba(220,53,69,.25); color: #f1aeb5; } /* czerwony */ diff --git a/static/js/admin_settings.js b/static/js/admin_settings.js new file mode 100644 index 0000000..2704bf0 --- /dev/null +++ b/static/js/admin_settings.js @@ -0,0 +1,91 @@ +(function () { + const form = document.getElementById("settings-form"); + const resetAllBtn = document.getElementById("reset-all"); + + function ensureHiddenClear(input) { + let hidden = input.parentElement.querySelector(`input[type="hidden"][name="${input.name}"]`); + if (!hidden) { + hidden = document.createElement("input"); + hidden.type = "hidden"; + hidden.name = input.name; + hidden.value = ""; + input.parentElement.appendChild(hidden); + } + } + function removeHiddenClear(input) { + const hidden = input.parentElement.querySelector(`input[type="hidden"][name="${input.name}"]`); + if (hidden) hidden.remove(); + } + + // Podgląd: bary pod pickerem (Efektywny = override || auto) + function updatePreview(input) { + const card = input.closest(".col-12, .col-md-6, .col-lg-4"); + const hexAutoEl = card.querySelector(".hex-auto"); + const hexEffEl = card.querySelector(".hex-effective"); + const barAuto = card.querySelector('.bar[data-kind="auto"]'); + const barEff = card.querySelector('.bar[data-kind="effective"]'); + + const raw = (input.value || "").trim(); + const autoHex = hexAutoEl.textContent.trim(); + const effHex = (raw || autoHex).toUpperCase(); + + if (barEff) barEff.style.backgroundColor = effHex; + if (hexEffEl) hexEffEl.textContent = effHex; + + if (!raw) ensureHiddenClear(input); else removeHiddenClear(input); + } + + // Reset jednego / wszystkich + form.querySelectorAll(".reset-one").forEach(btn => { + btn.addEventListener("click", () => { + const name = btn.getAttribute("data-target"); + const input = form.querySelector(`input[name="${name}"]`); + if (!input) return; + input.value = ""; + updatePreview(input); + }); + }); + resetAllBtn?.addEventListener("click", () => { + form.querySelectorAll('input[type="color"].category-color').forEach(input => { + input.value = ""; + updatePreview(input); + }); + }); + + // Init + live dla pickerów + form.querySelectorAll('input[type="color"].category-color').forEach(input => { + updatePreview(input); + input.addEventListener("input", () => updatePreview(input)); + input.addEventListener("change", () => updatePreview(input)); + }); + + // Live etykiety dla czułości OCR + (function () { + const slider = document.getElementById("ocr_sensitivity"); + const badge = document.getElementById("ocr_sens_badge"); + const value = document.getElementById("ocr_sens_value"); + if (!slider || !badge || !value) return; + + function labelFor(v) { + v = Number(v); + if (v <= 3) return "Niski"; + if (v <= 7) return "Średni"; + return "Wysoki"; + } + function clsFor(v) { + v = Number(v); + if (v <= 3) return "sens-low"; + if (v <= 7) return "sens-mid"; + return "sens-high"; + } + function update() { + value.textContent = `(${slider.value})`; + badge.textContent = labelFor(slider.value); + badge.classList.remove("sens-low","sens-mid","sens-high"); + badge.classList.add(clsFor(slider.value)); + } + slider.addEventListener("input", update); + slider.addEventListener("change", update); + update(); + })(); +})(); diff --git a/templates.tar.gz b/templates.tar.gz new file mode 100644 index 0000000..c7a5f9c Binary files /dev/null and b/templates.tar.gz differ diff --git a/templates/admin/admin_panel.html b/templates/admin/admin_panel.html index 7006cfa..9dbca18 100644 --- a/templates/admin/admin_panel.html +++ b/templates/admin/admin_panel.html @@ -15,6 +15,7 @@ 🛍️ Produkty 🗂 Kategorie 🔐 Uprawnienia + ⚙️ Ustawienia diff --git a/templates/admin/settings.html b/templates/admin/settings.html new file mode 100644 index 0000000..de34efc --- /dev/null +++ b/templates/admin/settings.html @@ -0,0 +1,140 @@ +{% extends "base.html" %} +{% block title %}Ustawienia{% endblock %} + +{% block content %} +
+

⚙️ Ustawienia

+ ← Powrót do panelu +
+ +
+ + +
+
+ 🔎 OCR — słowa kluczowe i czułość +
+
+

+ Dodaj lokalne frazy (CSV lub JSON), np.: summe, gesamtbetrag, importe total +

+ + + + +
+
    +
  • Zalecane: 5–7 (balans dokładności i stabilności).
  • +
  • Niskie (1–3): szybsze, mniejsza wykrywalność trudnych skanów.
  • +
  • Średnie (4–7): dobre na większość paragonów — polecane.
  • +
  • Wysokie (8–10): agresywne binaryzowanie — lepsze dla bladych skanów, + ale większe ryzyko fałszywych trafień i wolniejsze działanie.
  • +
+ Tip: jeśli pojawiają się „dziwne” sumy — obniż o 1–2 poziomy. +
+
+
+ + +
+
+ 🎨 Kolory kategorii + +
+ +
+
+ {% for c in categories %} + {% set hex_override = overrides.get(c.id) %} + {% set hex_auto = auto_colors[c.id] %} + {% set hex_effective = effective_colors[c.id] %} +
+ + +
+ + +
+ + +
+
+ Efektywny + + {{ hex_effective|upper }} +
+
+ Domyślny + + {{ hex_auto|upper }} +
+
+
+ {% endfor %} +
+
+
+ + +
+
+ 🔐 Bezpieczeństwo +
+
+ + +
+ Po przekroczeniu limitu IP zostaje tymczasowo zablokowane. +
+
+
+ + +
+
+ + ❌ Anuluj +
+
+
+{% endblock %} + +{% block scripts %} + + +{% endblock %}