Compare commits

2 Commits

Author SHA1 Message Date
Mateusz Gruszczyński
43b7b93ffa zakladka ustawien 2025-10-21 11:32:04 +02:00
Mateusz Gruszczyński
cabc2c6a4a zakladka ustawien 2025-10-21 11:30:34 +02:00
7 changed files with 499 additions and 44 deletions

277
app.py
View File

@@ -350,6 +350,118 @@ ShoppingList.permitted_users = db.relationship(
)
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"]
peppered = (password + pepper).encode("utf-8")
@@ -358,6 +470,14 @@ def hash_password(password):
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"]
peppered = (password_input + pepper).encode("utf-8")
@@ -884,8 +1004,6 @@ def get_admin_expense_summary():
}
def category_to_color(name: str, min_hue_gap_deg: int = 18) -> str:
# Stabilny hash -> int
hv = int(hashlib.md5(name.encode("utf-8")).hexdigest(), 16)
@@ -901,7 +1019,7 @@ def category_to_color(name: str, min_hue_gap_deg: int = 18) -> str:
hue_deg = mix % 360
# Odpychanie lokalne po hue, by podobne nazwy nie lądowały zbyt blisko
gap = (rotl(mix, 17) % (2*min_hue_gap_deg)) - min_hue_gap_deg # [-gap, +gap]
gap = (rotl(mix, 17) % (2 * min_hue_gap_deg)) - min_hue_gap_deg # [-gap, +gap]
hue_deg = (hue_deg + gap) % 360
# DARK profil: niższa jasność i nieco mniejsza saturacja
@@ -909,8 +1027,8 @@ def category_to_color(name: str, min_hue_gap_deg: int = 18) -> str:
l = 0.45
# Wąska wariacja, żeby uniknąć „neonów” i zachować spójność
s_var = ((rotl(mix, 29) % 5) - 2) / 100.0 # ±0.02
l_var = ((rotl(mix, 53) % 7) - 3) / 100.0 # ±0.03
s_var = ((rotl(mix, 29) % 5) - 2) / 100.0 # ±0.02
l_var = ((rotl(mix, 53) % 7) - 3) / 100.0 # ±0.03
s = min(0.76, max(0.62, s + s_var))
l = min(0.50, max(0.40, l + l_var))
@@ -920,6 +1038,7 @@ def category_to_color(name: str, min_hue_gap_deg: int = 18) -> str:
return f"#{int(round(r*255)):02x}{int(round(g*255)):02x}{int(round(b*255)):02x}"
def get_total_expenses_grouped_by_category(
show_all, range_type, start_date, end_date, user_id, category_id=None
):
@@ -1021,7 +1140,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,8 +1378,15 @@ 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
@@ -1273,21 +1399,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 +1476,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,11 +1498,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,8 +1870,7 @@ 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)}
for c in l.categories
{"name": c.name, "color": category_color_for(c)} for c in l.categories
]
else:
for l in all_lists:
@@ -1991,7 +2104,6 @@ def edit_my_list(list_id):
)
@app.route("/edit_my_list/<int:list_id>/suggestions", methods=["GET"])
@login_required
def edit_my_list_suggestions(list_id: int):
@@ -2017,10 +2129,9 @@ def edit_my_list_suggestions(list_id: int):
.subquery()
)
query = (
db.session.query(User.username, subq.c.grant_count, subq.c.last_grant_id)
.join(subq, subq.c.uid == User.id)
)
query = db.session.query(
User.username, subq.c.grant_count, subq.c.last_grant_id
).join(subq, subq.c.uid == User.id)
if q:
query = query.filter(func.lower(User.username).like(f"{q}%"))
@@ -2142,9 +2253,14 @@ def view_list(list_id):
is_owner = current_user.id == shopping_list.owner_id
if not is_owner:
flash("Nie jesteś właścicielem listy, przekierowano do widoku publicznego.", "warning")
flash(
"Nie jesteś właścicielem listy, przekierowano do widoku publicznego.",
"warning",
)
if current_user.is_admin:
flash("W celu modyfikacji listy, przejdź do panelu administracyjnego.", "info")
flash(
"W celu modyfikacji listy, przejdź do panelu administracyjnego.", "info"
)
return redirect(url_for("shared_list", token=shopping_list.share_token))
shopping_list, items, receipts, expenses, total_expense = get_list_details(list_id)
@@ -2154,12 +2270,14 @@ def view_list(list_id):
for item in items:
if item.added_by != shopping_list.owner_id:
item.added_by_display = (item.added_by_user.username if item.added_by_user else "?")
item.added_by_display = (
item.added_by_user.username if item.added_by_user else "?"
)
else:
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 +2668,7 @@ 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
]
@@ -3788,9 +3906,7 @@ def admin_edit_categories():
l.categories.extend(cats)
db.session.commit()
flash("Zaktualizowano kategorie dla wybranych list", "success")
return redirect(
url_for("admin_edit_categories", page=page, per_page=per_page)
)
return redirect(url_for("admin_edit_categories", page=page, per_page=per_page))
query_string = urlencode({k: v for k, v in request.args.items() if k != "page"})
@@ -3805,6 +3921,7 @@ def admin_edit_categories():
query_string=query_string,
)
@app.route("/admin/edit_categories/<int:list_id>/save", methods=["POST"])
@login_required
@admin_required
@@ -4010,6 +4127,80 @@ 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 = (

BIN
static,tar.gz Normal file

Binary file not shown.

View File

@@ -812,4 +812,46 @@ td select.tom-dark {
.text-danger {
color: var(--danger) !important;
}
}
/* ========== 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 */

View File

@@ -0,0 +1,87 @@
(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();
}
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);
}
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);
});
});
form.querySelectorAll('input[type="color"].category-color').forEach(input => {
updatePreview(input);
input.addEventListener("input", () => updatePreview(input));
input.addEventListener("change", () => updatePreview(input));
});
(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();
})();
})();

BIN
templates.tar.gz Normal file

Binary file not shown.

View File

@@ -15,6 +15,7 @@
<a href="{{ url_for('list_products') }}" class="btn btn-outline-light btn-sm">🛍️ Produkty</a>
<a href="{{ url_for('admin_edit_categories') }}" class="btn btn-outline-light btn-sm">🗂 Kategorie</a>
<a href="{{ url_for('admin_lists_access') }}" class="btn btn-outline-light btn-sm">🔐 Uprawnienia</a>
<a href="{{ url_for('admin_settings') }}" class="btn btn-outline-light btn-sm">⚙️ Ustawienia</a>
</div>
</div>
</div>

View File

@@ -0,0 +1,134 @@
{% extends "base.html" %}
{% block title %}Ustawienia{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4">
<h2 class="mb-2">⚙️ Ustawienia</h2>
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-secondary">← Powrót do panelu</a>
</div>
<form method="post" id="settings-form">
<div class="card bg-dark text-white mb-4">
<div class="card-header border-0">
<strong>🔎 OCR — słowa kluczowe i czułość</strong>
</div>
<div class="card-body">
<p class="small text-info mb-2">
Dodaj lokalne frazy (CSV lub JSON), np.: <code>summe, gesamtbetrag, importe total</code>
</p>
<textarea
class="form-control settings-ocr-textarea mb-3"
name="ocr_keywords"
rows="3"
placeholder="suma, razem do zapłaty, total"
>{{ current_ocr }}</textarea>
<label for="ocr_sensitivity" class="form-label d-flex align-items-center gap-2">
Poziom czułości OCR
<span id="ocr_sens_badge" class="badge rounded-pill sens-badge">Średni</span>
<span id="ocr_sens_value" class="small">({{ ocr_sensitivity }})</span>
</label>
<input
type="range"
class="form-range"
min="1"
max="10"
step="1"
name="ocr_sensitivity"
id="ocr_sensitivity"
value="{{ ocr_sensitivity }}"
>
<div class="small mt-1">
<ul class="mb-2 ps-3">
<li><strong>Zalecane:</strong> <code>57</code> (balans dokładności i stabilności).</li>
<li><strong>Niskie (13):</strong> szybsze, mniejsza wykrywalność trudnych skanów.</li>
<li><strong>Średnie (47):</strong> dobre na większość paragonów — <em>polecane</em>.</li>
<li><strong>Wysokie (810):</strong> agresywne binaryzowanie — lepsze dla bladych skanów,
ale większe ryzyko fałszywych trafień i wolniejsze działanie.</li>
</ul>
Tip: jeśli pojawiają się „dziwne” sumy — obniż o 12 poziomy.
</div>
</div>
</div>
<div class="card bg-dark text-white mb-4">
<div class="card-header border-0 d-flex align-items-center justify-content-between">
<strong>🎨 Kolory kategorii</strong>
<button type="button" class="btn btn-outline-light btn-sm" id="reset-all">🔄 Wyczyść nadpisania</button>
</div>
<div class="card-body">
<div class="row g-3" id="categories-grid">
{% 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] %}
<div class="col-12 col-md-6 col-lg-4">
<label class="form-label d-block mb-2">{{ c.name }}</label>
<div class="input-group">
<input
type="color"
class="form-control form-control-color category-color"
name="color_{{ c.id }}"
value="{{ hex_override or '' }}"
{% if not hex_override %}data-empty="1"{% endif %}
aria-label="Kolor kategorii {{ c.name }}"
>
<button type="button" class="btn btn-outline-light btn-sm reset-one" data-target="color_{{ c.id }}">
🔄 Reset
</button>
</div>
<div class="color-indicators mt-2">
<div class="indicator">
<span class="badge text-bg-dark me-2">Efektywny</span>
<span class="bar" data-kind="effective" style="background-color: {{ hex_effective }};"></span>
<span class="hex hex-effective ms-2">{{ hex_effective|upper }}</span>
</div>
<div class="indicator mt-1">
<span class="badge text-bg-light me-2">Domyślny</span>
<span class="bar" data-kind="auto" style="background-color: {{ hex_auto }};"></span>
<span class="hex hex-auto ms-2">{{ hex_auto|upper }}</span>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
<div class="card bg-dark text-white mb-4">
<div class="card-header border-0">
<strong>🔐 Bezpieczeństwo</strong>
</div>
<div class="card-body">
<label for="max_login_attempts" class="form-label">Limit błędnych logowań (hasło główne)</label>
<input
type="number"
class="form-control"
name="max_login_attempts"
id="max_login_attempts"
min="1"
max="20"
value="{{ max_login_attempts }}"
>
<div class="form-text text-muted">
Po przekroczeniu limitu IP zostaje tymczasowo zablokowane.
</div>
</div>
</div>
<div class="mt-4 d-flex">
<div class="btn-group" role="group" aria-label="Akcje ustawień">
<button type="submit" class="btn btn-outline-light">💾 Zapisz</button>
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-light">❌ Anuluj</a>
</div>
</div>
</form>
{% endblock %}
{% block scripts %}
<link rel="stylesheet" href="{{ url_for('static_bp.serve_css', filename='admin_settings.css') }}?v={{ APP_VERSION }}">
<script src="{{ url_for('static_bp.serve_js', filename='admin_settings.js') }}?v={{ APP_VERSION }}"></script>
{% endblock %}