From bbfb3e08875fe2d55a3e12d85b7587ef02aac080 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Fri, 20 Mar 2026 10:43:40 +0100 Subject: [PATCH] =?UTF-8?q?rozbicie=20na=20modu=C5=82y,=20poprawki=20i=20k?= =?UTF-8?q?omendy=20cli?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 19 +- app.py | 1657 +---------------- config.py | 20 + run_waitress.py | 11 +- zbiorka_app/__init__.py | 43 + zbiorka_app/cli.py | 79 + zbiorka_app/errors.py | 102 + zbiorka_app/extensions.py | 5 + zbiorka_app/models.py | 146 ++ zbiorka_app/routes.py | 1371 ++++++++++++++ {static => zbiorka_app/static}/css/custom.css | 0 .../static}/js/admin_dashboard.js | 0 .../static}/js/dodaj_wplate.js | 0 .../static}/js/dodaj_wydatek.js | 0 .../static}/js/edytuj_stan.js | 0 .../static}/js/formularz_rezerwy.js | 0 .../static}/js/formularz_zbiorek.js | 0 .../static}/js/kwoty_formularz.js | 0 .../static}/js/mde_custom.js | 0 .../static}/js/produkty_formularz.js | 0 {static => zbiorka_app/static}/js/progress.js | 0 .../static}/js/przelaczniki_zabezpieczenie.js | 0 zbiorka_app/static/js/service-worker.js | 0 .../static}/js/sposoby_wplat.js | 0 .../static}/js/transakcje.js | 0 .../static}/js/ustawienia.js | 0 .../static}/js/walidacja_logowanie.js | 0 .../static}/js/walidacja_rejestracja.js | 0 {static => zbiorka_app/static}/js/zbiorka.js | 0 .../templates}/admin/dashboard.html | 0 .../templates}/admin/dodaj_przesuniecie.html | 0 .../templates}/admin/dodaj_wplate.html | 2 +- .../templates}/admin/dodaj_wydatek.html | 2 +- .../templates}/admin/edytuj_stan.html | 2 +- .../templates}/admin/formularz_rezerwy.html | 2 +- .../templates}/admin/formularz_zbiorek.html | 12 +- .../templates}/admin/lista_rezerwowych.html | 0 .../templates}/admin/przesun_wplate.html | 0 .../templates}/admin/statystyki.html | 0 .../templates}/admin/transakcje.html | 2 +- .../templates}/admin/ustawienia.html | 2 +- .../templates}/base.html | 4 +- zbiorka_app/templates/error.html | 21 + .../templates}/index.html | 0 .../templates}/login.html | 2 +- .../templates}/register.html | 2 +- .../templates}/zbiorka.html | 4 +- zbiorka_app/utils.py | 291 +++ 48 files changed, 2125 insertions(+), 1676 deletions(-) mode change 120000 => 100644 Dockerfile create mode 100644 zbiorka_app/__init__.py create mode 100644 zbiorka_app/cli.py create mode 100644 zbiorka_app/errors.py create mode 100644 zbiorka_app/extensions.py create mode 100644 zbiorka_app/models.py create mode 100644 zbiorka_app/routes.py rename {static => zbiorka_app/static}/css/custom.css (100%) rename {static => zbiorka_app/static}/js/admin_dashboard.js (100%) rename {static => zbiorka_app/static}/js/dodaj_wplate.js (100%) rename {static => zbiorka_app/static}/js/dodaj_wydatek.js (100%) rename {static => zbiorka_app/static}/js/edytuj_stan.js (100%) rename {static => zbiorka_app/static}/js/formularz_rezerwy.js (100%) rename {static => zbiorka_app/static}/js/formularz_zbiorek.js (100%) rename {static => zbiorka_app/static}/js/kwoty_formularz.js (100%) rename {static => zbiorka_app/static}/js/mde_custom.js (100%) rename {static => zbiorka_app/static}/js/produkty_formularz.js (100%) rename {static => zbiorka_app/static}/js/progress.js (100%) rename {static => zbiorka_app/static}/js/przelaczniki_zabezpieczenie.js (100%) create mode 100644 zbiorka_app/static/js/service-worker.js rename {static => zbiorka_app/static}/js/sposoby_wplat.js (100%) rename {static => zbiorka_app/static}/js/transakcje.js (100%) rename {static => zbiorka_app/static}/js/ustawienia.js (100%) rename {static => zbiorka_app/static}/js/walidacja_logowanie.js (100%) rename {static => zbiorka_app/static}/js/walidacja_rejestracja.js (100%) rename {static => zbiorka_app/static}/js/zbiorka.js (100%) rename {templates => zbiorka_app/templates}/admin/dashboard.html (100%) rename {templates => zbiorka_app/templates}/admin/dodaj_przesuniecie.html (100%) rename {templates => zbiorka_app/templates}/admin/dodaj_wplate.html (97%) rename {templates => zbiorka_app/templates}/admin/dodaj_wydatek.html (96%) rename {templates => zbiorka_app/templates}/admin/edytuj_stan.html (98%) rename {templates => zbiorka_app/templates}/admin/formularz_rezerwy.html (98%) rename {templates => zbiorka_app/templates}/admin/formularz_zbiorek.html (96%) rename {templates => zbiorka_app/templates}/admin/lista_rezerwowych.html (100%) rename {templates => zbiorka_app/templates}/admin/przesun_wplate.html (100%) rename {templates => zbiorka_app/templates}/admin/statystyki.html (100%) rename {templates => zbiorka_app/templates}/admin/transakcje.html (99%) rename {templates => zbiorka_app/templates}/admin/ustawienia.html (99%) rename {templates => zbiorka_app/templates}/base.html (95%) create mode 100644 zbiorka_app/templates/error.html rename {templates => zbiorka_app/templates}/index.html (100%) rename {templates => zbiorka_app/templates}/login.html (95%) rename {templates => zbiorka_app/templates}/register.html (95%) rename {templates => zbiorka_app/templates}/zbiorka.html (98%) create mode 100644 zbiorka_app/utils.py diff --git a/Dockerfile b/Dockerfile deleted file mode 120000 index 019363a..0000000 --- a/Dockerfile +++ /dev/null @@ -1 +0,0 @@ -deploy/app/Dockerfile \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1f53d79 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +#FROM python:3.13-slim +FROM python:3.14-slim +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + && apt-get install -y build-essential \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt requirements.txt + +RUN pip install --upgrade pip +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . +RUN mkdir -p /app/instance + +CMD ["python", "run_waitress.py"] diff --git a/app.py b/app.py index 35b6140..ee57796 100644 --- a/app.py +++ b/app.py @@ -1,1656 +1,11 @@ -import markdown as md -import hashlib, os, re, socket, ipaddress -import re -import socket -from flask import Flask, render_template, request, redirect, url_for, flash -from flask_sqlalchemy import SQLAlchemy -from flask_login import ( - LoginManager, - login_user, - login_required, - logout_user, - current_user, - UserMixin, -) -from werkzeug.security import generate_password_hash, check_password_hash -from datetime import datetime, timezone -from markupsafe import Markup -from sqlalchemy import event, Numeric, select -from sqlalchemy.engine import Engine -from decimal import Decimal, InvalidOperation -from flask import request, flash, abort +from zbiorka_app import create_app +from zbiorka_app.extensions import db +from zbiorka_app.utils import create_admin_account, init_database_with_retry -try: - from zoneinfo import ZoneInfo # Python 3.9+ -except ImportError: - from backports.zoneinfo import ZoneInfo - - -app = Flask(__name__) -# Ładujemy konfigurację z pliku config.py -app.config.from_object("config.Config") - -db = SQLAlchemy(app) -login_manager = LoginManager(app) -login_manager.login_view = "zaloguj" -LOCAL_TZ = ZoneInfo("Europe/Warsaw") - -def read_commit_and_date(filename="version.txt", root_path=None): - base = root_path or os.path.dirname(os.path.abspath(__file__)) - path = os.path.join(base, filename) - if not os.path.exists(path): - return None, None - - try: - commit = open(path, "r", encoding="utf-8").read().strip() - if commit: - commit = commit[:12] - except Exception: - commit = None - - try: - ts = os.path.getmtime(path) - date_str = datetime.fromtimestamp(ts).strftime("%Y.%m.%d") - except Exception: - date_str = None - - return date_str, commit - -deploy_date, commit = read_commit_and_date("version.txt", root_path=os.path.dirname(__file__)) -if not deploy_date: - deploy_date = datetime.now().strftime("%Y.%m.%d") -if not commit: - commit = "dev" - -APP_VERSION = f"{deploy_date}+{commit}" -app.config["APP_VERSION"] = APP_VERSION - -# MODELE -class Uzytkownik(UserMixin, db.Model): - __tablename__ = "uzytkownik" - - id = db.Column(db.Integer, primary_key=True) - uzytkownik = db.Column(db.String(80), unique=True, nullable=False) - haslo_hash = db.Column(db.String(128), nullable=False) - czy_admin = db.Column(db.Boolean, default=False) - - def set_password(self, password): - self.haslo_hash = generate_password_hash(password) - def check_password(self, password): - return check_password_hash(self.haslo_hash, password) - -class Zbiorka(db.Model): - id = db.Column(db.Integer, primary_key=True) - nazwa = db.Column(db.String(100), nullable=False) - opis = db.Column(db.Text, nullable=False) - numer_konta = db.Column(db.String(50), nullable=True) - numer_telefonu_blik = db.Column(db.String(50), nullable=True) - cel = db.Column(Numeric(12, 2), nullable=False, default=0) - stan = db.Column(Numeric(12, 2), default=0) - ukryta = db.Column(db.Boolean, default=False) - ukryj_kwote = db.Column(db.Boolean, default=False) - zrealizowana = db.Column(db.Boolean, default=False) - pokaz_postep_finanse = db.Column(db.Boolean, default=True, nullable=False) - pokaz_postep_pozycje = db.Column(db.Boolean, default=True, nullable=False) - pokaz_postep_kwotowo = db.Column(db.Boolean, default=True, nullable=False) - uzyj_konta = db.Column(db.Boolean, default=True, nullable=False) - uzyj_blik = db.Column(db.Boolean, default=True, nullable=False) - typ_zbiorki = db.Column(db.String(20), default="standardowa", nullable=False) - - wplaty = db.relationship( - "Wplata", - back_populates="zbiorka", - lazy=True, - order_by="Wplata.data.desc()", - cascade="all, delete-orphan", - passive_deletes=True, - ) - - wydatki = db.relationship( - "Wydatek", - backref="zbiorka", - lazy=True, - order_by="Wydatek.data.desc()", - cascade="all, delete-orphan", - passive_deletes=True, - ) - - przedmioty = db.relationship( - "Przedmiot", - backref="zbiorka", - lazy=True, - order_by="Przedmiot.id.asc()", - cascade="all, delete-orphan", - passive_deletes=True, - ) - -class Przedmiot(db.Model): - id = db.Column(db.Integer, primary_key=True) - zbiorka_id = db.Column( - db.Integer, - db.ForeignKey("zbiorka.id", ondelete="CASCADE"), - nullable=False, - ) - nazwa = db.Column(db.String(120), nullable=False) - link = db.Column(db.String(255), nullable=True) - cena = db.Column(Numeric(12, 2), nullable=True) - kupione = db.Column(db.Boolean, default=False) - -class Wplata(db.Model): - id = db.Column(db.Integer, primary_key=True) - zbiorka_id = db.Column( - db.Integer, - db.ForeignKey("zbiorka.id", ondelete="CASCADE"), - nullable=False, - ) - kwota = db.Column(Numeric(12, 2), nullable=False) - data = db.Column(db.DateTime, default=datetime.utcnow) - opis = db.Column(db.Text, nullable=True) - zbiorka = db.relationship("Zbiorka", back_populates="wplaty") - ukryta = db.Column(db.Boolean, nullable=False, default=False) - -class Wydatek(db.Model): - id = db.Column(db.Integer, primary_key=True) - zbiorka_id = db.Column( - db.Integer, - db.ForeignKey("zbiorka.id", ondelete="CASCADE"), - nullable=False, - ) - kwota = db.Column(Numeric(12, 2), nullable=False) - data = db.Column(db.DateTime, default=datetime.utcnow) - opis = db.Column(db.Text, nullable=True) - ukryta = db.Column(db.Boolean, nullable=False, default=False) - -class Przesuniecie(db.Model): - id = db.Column(db.Integer, primary_key=True) - zbiorka_zrodlo_id = db.Column( - db.Integer, - db.ForeignKey("zbiorka.id", ondelete="CASCADE"), - nullable=False, - ) - zbiorka_cel_id = db.Column( - db.Integer, - db.ForeignKey("zbiorka.id", ondelete="CASCADE"), - nullable=False, - ) - kwota = db.Column(Numeric(12, 2), nullable=False) - data = db.Column(db.DateTime, default=datetime.utcnow) - opis = db.Column(db.Text, nullable=True) - ukryta = db.Column(db.Boolean, nullable=False, default=False) - - wplata_id = db.Column( - db.Integer, - db.ForeignKey("wplata.id", ondelete="SET NULL"), - nullable=True, - ) - - zbiorka_zrodlo = db.relationship("Zbiorka", foreign_keys=[zbiorka_zrodlo_id], backref="przesuniecia_wychodzace") - zbiorka_cel = db.relationship("Zbiorka", foreign_keys=[zbiorka_cel_id], backref="przesuniecia_przychodzace") - wplata = db.relationship("Wplata", foreign_keys=[wplata_id], backref="przesuniecia") - -class UstawieniaGlobalne(db.Model): - __tablename__ = "ustawienia_globalne" - - id = db.Column(db.Integer, primary_key=True) - numer_konta = db.Column(db.String(50), nullable=False) - numer_telefonu_blik = db.Column(db.String(50), nullable=False) - dozwolone_hosty_logowania = db.Column(db.Text, nullable=True) - logo_url = db.Column(db.String(255), nullable=True) - tytul_strony = db.Column(db.String(120), nullable=True) - pokaz_logo_w_navbar = db.Column(db.Boolean, default=False) - typ_navbar = db.Column(db.String(10), default="text") - typ_stopka = db.Column(db.String(10), default="text") - stopka_text = db.Column(db.String(200), nullable=True) - kolejnosc_rezerwowych = db.Column(db.String(20), default="id", nullable=False) - - -@login_manager.user_loader -def load_user(user_id): - return db.session.get(Uzytkownik, int(user_id)) - - -@event.listens_for(Engine, "connect") -def set_sqlite_pragma(dbapi_connection, connection_record): - if dbapi_connection.__class__.__module__.startswith('sqlite3'): - try: - cursor = dbapi_connection.cursor() - cursor.execute("PRAGMA foreign_keys=ON") - cursor.close() - except Exception: - pass - -def get_real_ip(): - headers = request.headers - cf_ip = headers.get("CF-Connecting-IP") - if cf_ip: - return cf_ip.split(",")[0].strip() - xff = headers.get("X-Forwarded-For") - if xff: - return xff.split(",")[0].strip() - x_real_ip = headers.get("X-Real-IP") - if x_real_ip: - return x_real_ip.strip() - return request.remote_addr - - -def is_allowed_ip(remote_ip, allowed_hosts_str): - if os.path.exists("emergency_access.txt"): - return True - - if not allowed_hosts_str or not allowed_hosts_str.strip(): - return False - - allowed_ips = set() - hosts = re.split(r"[\n,]+", allowed_hosts_str.strip()) - - for host in hosts: - host = host.strip() - if not host: - continue - - try: - ip_obj = ipaddress.ip_address(host) - allowed_ips.add(ip_obj) - continue - except ValueError: - pass - - try: - infos = socket.getaddrinfo(host, None) - for family, _, _, _, sockaddr in infos: - ip_str = sockaddr[0] - try: - ip_obj = ipaddress.ip_address(ip_str) - allowed_ips.add(ip_obj) - except ValueError: - continue - except Exception as e: - app.logger.warning(f"Nie można rozwiązać hosta {host}: {e}") - - try: - remote_ip_obj = ipaddress.ip_address(remote_ip) - except ValueError: - app.logger.warning(f"Nieprawidłowe IP klienta: {remote_ip}") - return False - - is_allowed = remote_ip_obj in allowed_ips - app.logger.info(f"is_allowed_ip: {remote_ip_obj} -> {is_allowed} (lista: {allowed_ips})") - return is_allowed - - -def to_local(dt): - if dt is None: - return None - if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) - return dt.astimezone(LOCAL_TZ) - - -def parse_amount(raw: str) -> Decimal: - if not raw or not str(raw).strip(): - raise InvalidOperation("empty amount") - norm = ( - str(raw) - .replace(" ", "") - .replace("\u00A0", "") - .replace(",", ".") - .strip() - ) - d = Decimal(norm) - if d <= 0: - raise InvalidOperation("amount must be > 0") - return d - - -@app.template_filter("dt") -def dt_filter(dt, fmt="%Y-%m-%d %H:%M"): - try: - ldt = to_local(dt) - return ldt.strftime(fmt) if ldt else "" - except Exception: - return "" - - -@app.template_filter("markdown") -def markdown_filter(text): - return Markup(md.markdown(text)) - - -@app.context_processor -def inject_globals(): - settings = UstawieniaGlobalne.query.first() - allowed_hosts_str = ( - settings.dozwolone_hosty_logowania if settings and settings.dozwolone_hosty_logowania else "" - ) - client_ip = get_real_ip() - return { - "is_ip_allowed": is_allowed_ip(client_ip, allowed_hosts_str), - "global_settings": settings, - } - - -@app.context_processor -def inject_version(): - return {'APP_VERSION': app.config['APP_VERSION']} - - -# TRASY PUBLICZNE -@app.route("/") -def index(): - settings = UstawieniaGlobalne.query.first() - kolejnosc = settings.kolejnosc_rezerwowych if settings else "id" - - standardowe = Zbiorka.query.filter_by(ukryta=False, zrealizowana=False).filter( - Zbiorka.typ_zbiorki != 'rezerwa' - ).all() - - rezerwowe = Zbiorka.query.filter_by(ukryta=False, zrealizowana=False, typ_zbiorki='rezerwa').all() - - # Sortuj według ustawienia - if kolejnosc == "first": - zbiorki = rezerwowe + standardowe - elif kolejnosc == "last": - zbiorki = standardowe + rezerwowe - else: # "id" - zbiorki = sorted(standardowe + rezerwowe, key=lambda z: z.id) - - return render_template("index.html", zbiorki=zbiorki) - - -@app.route("/zrealizowane") -def zbiorki_zrealizowane(): - zbiorki = Zbiorka.query.filter_by(zrealizowana=True).all() - return render_template("index.html", zbiorki=zbiorki) - - -@app.errorhandler(404) -def page_not_found(e): - return redirect(url_for("index")) - - -@app.route("/zbiorka/", endpoint='zbiorka') -@app.route("/rezerwa/", endpoint='rezerwa') -def zbiorka(zbiorka_id): - zb = db.session.get(Zbiorka, zbiorka_id) - if zb is None: - abort(404) - - # Zabezpieczenie: sprawdź czy URL pasuje do typu zbiórki - poprawny_endpoint = 'rezerwa' if zb.typ_zbiorki == 'rezerwa' else 'zbiorka' - - if request.endpoint != poprawny_endpoint: - return redirect(url_for(poprawny_endpoint, zbiorka_id=zbiorka_id), code=301) - - if zb.ukryta and (not current_user.is_authenticated or not current_user.czy_admin): - abort(404) - - is_admin = current_user.is_authenticated and current_user.czy_admin - show_hidden = is_admin and (request.args.get("show_hidden") in ("1", "true", "yes")) - - # Stwórz mapę przesunięć wpłat dla tej zbiórki (przychodzące) - przesuniecia_wplat_map = { - p.wplata_id: { - "zbiorka_zrodlo_nazwa": p.zbiorka_zrodlo.nazwa, - "zbiorka_zrodlo_id": p.zbiorka_zrodlo_id, - "opis": p.opis - } - for p in zb.przesuniecia_przychodzace - if p.wplata_id is not None - } - - # Wpłaty z informacją o przesunięciu - wplaty = [ - { - "typ": "wpłata", - "id": w.id, - "kwota": w.kwota, - "opis": w.opis, - "data": w.data, - "ukryta": getattr(w, "ukryta", False), - "przesuniecie_z": przesuniecia_wplat_map.get(w.id) - } - for w in zb.wplaty - if show_hidden or not getattr(w, "ukryta", False) - ] - - # Wydatki - wydatki = [ - { - "typ": "wydatek", - "id": x.id, - "kwota": x.kwota, - "opis": x.opis, - "data": x.data, - "ukryta": getattr(x, "ukryta", False) - } - for x in zb.wydatki - if show_hidden or not getattr(x, "ukryta", False) - ] - - # Przesunięcia przychodzące - TYLKO ogólne (bez konkretnej wpłaty) - przesuniecia_przych = [ - { - "typ": "przesunięcie_przych", - "kwota": p.kwota, - "opis": p.opis or f"Przesunięcie z: {p.zbiorka_zrodlo.nazwa}", - "data": p.data, - "zbiorka_id": p.zbiorka_zrodlo_id, - "zbiorka_nazwa": p.zbiorka_zrodlo.nazwa, - "ukryta": getattr(p, "ukryta", False) - } - for p in zb.przesuniecia_przychodzace - if (show_hidden or not getattr(p, "ukryta", False)) and p.wplata_id is None - ] - - # Przesunięcia wychodzące - TYLKO ogólne (bez konkretnej wpłaty) - przesuniecia_wych = [ - { - "typ": "przesunięcie_wych", - "kwota": p.kwota, - "opis": p.opis or f"Przesunięcie do: {p.zbiorka_cel.nazwa}", - "data": p.data, - "zbiorka_id": p.zbiorka_cel_id, - "zbiorka_nazwa": p.zbiorka_cel.nazwa, - "ukryta": getattr(p, "ukryta", False) - } - for p in zb.przesuniecia_wychodzace - if (show_hidden or not getattr(p, "ukryta", False)) and p.wplata_id is None - ] - - aktywnosci = wplaty + wydatki + przesuniecia_przych + przesuniecia_wych - aktywnosci.sort(key=lambda a: a["data"], reverse=True) - - return render_template("zbiorka.html", zbiorka=zb, aktywnosci=aktywnosci, show_hidden=show_hidden) - - -# TRASY LOGOWANIA I REJESTRACJI - - -@app.route("/zaloguj", methods=["GET", "POST"]) -def zaloguj(): - settings = UstawieniaGlobalne.query.first() - allowed_hosts_str = ( - settings.dozwolone_hosty_logowania - if settings and settings.dozwolone_hosty_logowania - else "" - ) - - client_ip = get_real_ip() - - if not is_allowed_ip(client_ip, allowed_hosts_str): - flash( - f"Dostęp do panelu logowania z adresu IP {client_ip} " - f"jest zablokowany – Twój adres nie znajduje się na liście dozwolonych.", - "danger", - ) - return redirect(url_for("index")) - - if current_user.is_authenticated: - return redirect(url_for("admin_dashboard")) - - if request.method == "POST": - login = request.form.get("uzytkownik", "").strip() - password = request.form.get("haslo", "") - - user = Uzytkownik.query.filter_by(uzytkownik=login).first() - if user and user.check_password(password): - login_user(user) - flash("Zalogowano pomyślnie", "success") - - next_page = request.form.get("next") or request.args.get("next") - return redirect(next_page) if next_page else redirect(url_for("admin_dashboard")) - - flash("Nieprawidłowe dane logowania", "danger") - - return render_template("login.html") - - -@app.route("/wyloguj") -@login_required -def wyloguj(): - logout_user() - flash("Wylogowano", "success") - return redirect(url_for("zaloguj")) - - -@app.route("/zarejestruj", methods=["GET", "POST"]) -def zarejestruj(): - if not app.config.get("ALLOW_REGISTRATION", False): - flash("Rejestracja została wyłączona przez administratora", "danger") - return redirect(url_for("zaloguj")) - if request.method == "POST": - login = request.form["uzytkownik"] - password = request.form["haslo"] - if Uzytkownik.query.filter_by(uzytkownik=login).first(): - flash("Użytkownik już istnieje", "danger") - return redirect(url_for("register")) - new_user = Uzytkownik(uzytkownik=login) - new_user.set_password(password) - db.session.add(new_user) - db.session.commit() - flash("Konto utworzone, możesz się zalogować", "success") - return redirect(url_for("zaloguj")) - return render_template("register.html") - - -# PANEL ADMINISTRACYJNY -@app.route("/admin") -@login_required -def admin_dashboard(): - if not current_user.czy_admin: - flash("Brak uprawnień do panelu administracyjnego", "danger") - return redirect(url_for("index")) - - active_zbiorki = Zbiorka.query.filter_by(zrealizowana=False).filter( - Zbiorka.typ_zbiorki != 'rezerwa' - ).all() - completed_zbiorki = Zbiorka.query.filter_by(zrealizowana=True).filter( - Zbiorka.typ_zbiorki != 'rezerwa' - ).all() - - return render_template( - "admin/dashboard.html", - active_zbiorki=active_zbiorki, - completed_zbiorki=completed_zbiorki, - ) - -@app.route("/admin/zbiorka/dodaj", methods=["GET", "POST"]) -@app.route("/admin/zbiorka/edytuj/", methods=["GET", "POST"]) -@login_required -def formularz_zbiorek(zbiorka_id=None): - if not current_user.czy_admin: - flash("Brak uprawnień", "danger") - return redirect(url_for("index")) - - is_edit = zbiorka_id is not None - zb = db.session.get(Zbiorka, zbiorka_id) if is_edit else None - if is_edit and zb is None: - abort(404) - - global_settings = UstawieniaGlobalne.query.first() - - def _temp_obj(): - t = zb or Zbiorka() - t.nazwa = (request.form.get("nazwa", "") or "").strip() - t.opis = (request.form.get("opis", "") or "").strip() - t.numer_konta = (request.form.get("numer_konta", "") or "").strip() - t.numer_telefonu_blik = (request.form.get("numer_telefonu_blik", "") or "").strip() - t.ukryj_kwote = "ukryj_kwote" in request.form - t.pokaz_postep_finanse = "pokaz_postep_finanse" in request.form - t.pokaz_postep_pozycje = "pokaz_postep_pozycje" in request.form - t.pokaz_postep_kwotowo = "pokaz_postep_kwotowo" in request.form - t.uzyj_konta = "uzyj_konta" in request.form - t.uzyj_blik = "uzyj_blik" in request.form - return t - - if request.method == "POST": - # Pola - nazwa = (request.form.get("nazwa", "") or "").strip() - opis = (request.form.get("opis", "") or "").strip() - numer_konta = (request.form.get("numer_konta", "") or "").strip() - numer_telefonu_blik = (request.form.get("numer_telefonu_blik", "") or "").strip() - - # Przełączniki płatności - uzyj_konta = "uzyj_konta" in request.form - uzyj_blik = "uzyj_blik" in request.form - - # Widoczność/metryki - ukryj_kwote = "ukryj_kwote" in request.form - pokaz_postep_finanse = "pokaz_postep_finanse" in request.form - pokaz_postep_pozycje = "pokaz_postep_pozycje" in request.form - pokaz_postep_kwotowo = "pokaz_postep_kwotowo" in request.form - - # Walidacje - if not nazwa: - flash("Nazwa jest wymagana", "danger") - return render_template("admin/formularz_zbiorek.html", zbiorka=_temp_obj(), global_settings=global_settings) - - if not opis: - flash("Opis jest wymagany", "danger") - return render_template("admin/formularz_zbiorek.html", zbiorka=_temp_obj(), global_settings=global_settings) - - # Co najmniej jeden kanał - if not (uzyj_konta or uzyj_blik): - flash("Włącz co najmniej jeden kanał wpłat (konto lub BLIK).", "danger") - return render_template("admin/formularz_zbiorek.html", zbiorka=_temp_obj(), global_settings=global_settings) - - # Warunkowe wartości - if uzyj_konta and not numer_konta: - flash("Numer konta jest wymagany (kanał przelewu włączony).", "danger") - return render_template("admin/formularz_zbiorek.html", zbiorka=_temp_obj(), global_settings=global_settings) - - if uzyj_blik and not numer_telefonu_blik: - flash("Numer telefonu BLIK jest wymagany (kanał BLIK włączony).", "danger") - return render_template("admin/formularz_zbiorek.html", zbiorka=_temp_obj(), global_settings=global_settings) - - # Cel > 0 - try: - cel = parse_amount(request.form.get("cel")) - if cel <= 0: - raise InvalidOperation - except (InvalidOperation, ValueError): - flash("Podano nieprawidłową wartość dla celu zbiórki", "danger") - return render_template("admin/formularz_zbiorek.html", zbiorka=_temp_obj(), global_settings=global_settings) - - # Produkty - names = request.form.getlist("item_nazwa[]") - links = request.form.getlist("item_link[]") - prices = request.form.getlist("item_cena[]") - - def _read_price(val: str): - try: - return parse_amount(val) - except InvalidOperation: - return None - - # zapis - if is_edit: - zb.nazwa = nazwa - zb.opis = opis - zb.uzyj_konta = uzyj_konta - zb.uzyj_blik = uzyj_blik - zb.numer_konta = numer_konta if uzyj_konta else "" - zb.numer_telefonu_blik = numer_telefonu_blik if uzyj_blik else "" - zb.cel = cel - zb.ukryj_kwote = ukryj_kwote - zb.pokaz_postep_finanse = pokaz_postep_finanse - zb.pokaz_postep_pozycje = pokaz_postep_pozycje - zb.pokaz_postep_kwotowo = pokaz_postep_kwotowo - - istniejace = list(zb.przedmioty) - - # UPDATE pierwsze N produktów - for i in range(min(len(names), len(istniejace))): - name = (names[i] or "").strip() - if not name: - continue - link = (links[i] if i < len(links) else "").strip() or None - cena_val = _read_price(prices[i] if i < len(prices) else "") - kupione_val = request.form.get(f"item_kupione_val_{i}") == "1" - - p = istniejace[i] - p.nazwa = name - p.link = link - p.cena = cena_val - p.kupione = kupione_val - - # DODAJ nowe produkty (więcej niż istnieje) - for i in range(len(istniejace), len(names)): - name = (names[i] or "").strip() - if not name: - continue - link = (links[i] if i < len(links) else "").strip() or None - cena_val = _read_price(prices[i] if i < len(prices) else "") - kupione_val = request.form.get(f"item_kupione_val_{i}") == "1" - - p = Przedmiot( - zbiorka_id=zb.id, - nazwa=name, - link=link, - cena=cena_val, - kupione=kupione_val - ) - db.session.add(p) - zb.przedmioty.append(p) - - # USUŃ nadmiarowe produkty - for i in range(len(names), len(istniejace)): - db.session.delete(istniejace[i]) - - db.session.commit() - flash("Zbiórka została zaktualizowana", "success") - - else: - nowa = Zbiorka( - nazwa=nazwa, - opis=opis, - uzyj_konta=uzyj_konta, - uzyj_blik=uzyj_blik, - numer_konta=(numer_konta if uzyj_konta else ""), - numer_telefonu_blik=(numer_telefonu_blik if uzyj_blik else ""), - cel=cel, - ukryj_kwote=ukryj_kwote, - pokaz_postep_finanse=pokaz_postep_finanse, - pokaz_postep_pozycje=pokaz_postep_pozycje, - pokaz_postep_kwotowo=pokaz_postep_kwotowo, - ) - db.session.add(nowa) - db.session.commit() - - for i, raw_name in enumerate(names): - name = (raw_name or "").strip() - if not name: - continue - link = (links[i] if i < len(links) else "").strip() or None - cena_val = _read_price(prices[i] if i < len(prices) else "") - kupione_val = request.form.get(f"item_kupione_val_{i}") == "1" - - przedmiot = Przedmiot( - nazwa=name, - link=link, - cena=cena_val, - kupione=kupione_val - ) - nowa.przedmioty.append(przedmiot) - - db.session.commit() - flash("Zbiórka została dodana", "success") - - return redirect(url_for("admin_dashboard")) - - - # GET - return render_template( - "admin/formularz_zbiorek.html", - zbiorka=zb, - global_settings=global_settings - ) - - -@app.route("/admin/zbiorka//wplata/dodaj", methods=["GET", "POST"]) -@login_required -def dodaj_wplate(zbiorka_id): - if not current_user.czy_admin: - flash("Brak uprawnień", "danger") - return redirect(url_for("index")) - - zb = db.session.get(Zbiorka, zbiorka_id) - if not zb: - abort(404) - - if request.method == "POST": - try: - kwota = parse_amount(request.form.get("kwota")) - if kwota <= 0: - raise InvalidOperation - except (InvalidOperation, ValueError): - flash("Nieprawidłowa kwota (musi być > 0)", "danger") - return redirect(url_for("dodaj_wplate", zbiorka_id=zbiorka_id)) - - opis = request.form.get("opis", "") - nowa_wplata = Wplata(zbiorka_id=zb.id, kwota=kwota, opis=opis) - zb.stan = (zb.stan or Decimal("0")) + kwota - db.session.add(nowa_wplata) - db.session.commit() - flash("Wpłata została dodana", "success") - - next_url = request.args.get("next") - return redirect(next_url or url_for("transakcje_zbiorki", zbiorka_id=zb.id)) - return render_template("admin/dodaj_wplate.html", zbiorka=zb) - - -@app.route("/admin/zbiorka//wplata//przesun", methods=["GET", "POST"]) -@login_required -def przesun_wplate(zbiorka_id, wplata_id): - if not current_user.czy_admin: - flash("Brak uprawnień", "danger") - return redirect(url_for("index")) - - zb_zrodlo = db.session.get(Zbiorka, zbiorka_id) - if zb_zrodlo is None: - abort(404) - - wplata = db.session.get(Wplata, wplata_id) - if wplata is None or wplata.zbiorka_id != zbiorka_id: - abort(404) - - if request.method == "POST": - zbiorka_cel_id = request.form.get("zbiorka_cel_id") - if not zbiorka_cel_id: - flash("Wybierz docelową zbiórkę", "danger") - return redirect(url_for("przesun_wplate", zbiorka_id=zbiorka_id, wplata_id=wplata_id)) - - zb_cel = db.session.get(Zbiorka, int(zbiorka_cel_id)) - if zb_cel is None: - flash("Docelowa zbiórka nie istnieje", "danger") - return redirect(url_for("przesun_wplate", zbiorka_id=zbiorka_id, wplata_id=wplata_id)) - - if zb_zrodlo.stan < wplata.kwota: - flash("Niewystarczające środki w źródłowej zbiórce", "danger") - return redirect(url_for("przesun_wplate", zbiorka_id=zbiorka_id, wplata_id=wplata_id)) - - opis_dodatkowy = request.form.get("opis", "").strip() - - if opis_dodatkowy: - opis_przesuniecia = f"Przesunięcie wpłaty: {wplata.opis or 'bez opisu'} - {opis_dodatkowy}" - else: - opis_przesuniecia = f"Przesunięcie wpłaty: {wplata.opis or 'bez opisu'}" - - nowe_przesuniecie = Przesuniecie( - zbiorka_zrodlo_id=zb_zrodlo.id, - zbiorka_cel_id=zb_cel.id, - kwota=wplata.kwota, - opis=opis_przesuniecia, - wplata_id=wplata.id - ) - - zb_zrodlo.stan = (zb_zrodlo.stan or Decimal("0")) - wplata.kwota - zb_cel.stan = (zb_cel.stan or Decimal("0")) + wplata.kwota - wplata.zbiorka_id = zb_cel.id - db.session.add(nowe_przesuniecie) - db.session.commit() - - flash(f"Przesunięto wpłatę {wplata.kwota} PLN do zbiórki '{zb_cel.nazwa}'", "success") - - next_url = request.args.get("next") - return redirect(next_url or url_for("transakcje_zbiorki", zbiorka_id=zb_zrodlo.id)) - - dostepne_zbiorki = Zbiorka.query.filter(Zbiorka.id != zbiorka_id).all() - return render_template("admin/przesun_wplate.html", - zbiorka=zb_zrodlo, - wplata=wplata, - dostepne_zbiorki=dostepne_zbiorki) - - - -@app.route("/admin/zbiorka/usun/", methods=["POST"]) -@login_required -def usun_zbiorka(zbiorka_id): - if not current_user.czy_admin: - flash("Brak uprawnień", "danger") - return redirect(url_for("index")) - zb = db.session.get(Zbiorka, zbiorka_id) - if zb is None: - abort(404) - db.session.delete(zb) - db.session.commit() - flash("Zbiórka została usunięta", "success") - return redirect(url_for("admin_dashboard")) - - -@app.route("/admin/zbiorka/edytuj_stan/", methods=["GET", "POST"]) -@login_required -def edytuj_stan(zbiorka_id): - if not current_user.czy_admin: - flash("Brak uprawnień", "danger") - return redirect(url_for("index")) - zb = db.session.get(Zbiorka, zbiorka_id) - if zb is None: - abort(404) - if request.method == "POST": - try: - nowy_stan = parse_amount(request.form.get("stan")) - except (InvalidOperation, ValueError): - flash("Nieprawidłowa wartość kwoty", "danger") - return redirect(url_for("edytuj_stan", zbiorka_id=zbiorka_id)) - zb.stan = nowy_stan - db.session.commit() - flash("Stan zbiórki został zaktualizowany", "success") - return redirect(url_for("admin_dashboard")) - return render_template("admin/edytuj_stan.html", zbiorka=zb) - - -@app.route("/admin/zbiorka/zmien_widzialnosc/", methods=["POST"]) -@login_required -def zmien_widzialnosc(zbiorka_id): - if not current_user.czy_admin: - flash("Brak uprawnień", "danger") - return redirect(url_for("index")) - zb = db.session.get(Zbiorka, zbiorka_id) - if zb is None: - abort(404) - zb.ukryta = not zb.ukryta - db.session.commit() - flash("Zbiórka została " + ("ukryta" if zb.ukryta else "przywrócona"), "success") - return redirect(url_for("admin_dashboard")) - - -def create_admin_account(): - admin = Uzytkownik.query.filter_by(czy_admin=True).first() - if not admin: - main_admin = Uzytkownik( - uzytkownik=app.config["MAIN_ADMIN_USERNAME"], - czy_admin=True - ) - main_admin.set_password(app.config["MAIN_ADMIN_PASSWORD"]) - db.session.add(main_admin) - db.session.commit() - - -@app.after_request -def apply_headers(response): - if request.path.startswith("/static/"): - response.headers.pop("Content-Disposition", None) - response.headers["Vary"] = "Accept-Encoding" - response.headers["Cache-Control"] = app.config.get( - "CACHE_CONTROL_HEADER_STATIC" - ) - if app.config.get("USE_ETAGS", True) and "ETag" not in response.headers: - response.add_etag() - response.make_conditional(request) - return response - - if request.path == '/healthcheck': - response.headers['Cache-Control'] = 'no-store, no-cache' - response.headers.pop('ETag', None) - response.headers.pop('Vary', None) - return response - - path_norm = request.path.lstrip("/") - czy_admin = path_norm.startswith("admin/") or path_norm == "admin" - - if czy_admin: - if (response.mimetype or "").startswith("text/html"): - response.headers["Cache-Control"] = "no-store, no-cache" - response.headers.pop("ETag", None) - - return response - - if response.status_code in (301, 302, 303, 307, 308): - response.headers.pop("Vary", None) - return response - - if 400 <= response.status_code < 500: - response.headers["Cache-Control"] = "no-store" - response.headers["Content-Type"] = "text/html; charset=utf-8" - response.headers.pop("Vary", None) - elif 500 <= response.status_code < 600: - response.headers["Cache-Control"] = "no-store" - response.headers["Content-Type"] = "text/html; charset=utf-8" - response.headers["Retry-After"] = "120" - response.headers.pop("Vary", None) - else: - response.headers["Vary"] = "Cookie, Accept-Encoding" - default_cache = app.config.get("CACHE_CONTROL_HEADER") or "private, no-store" - response.headers["Cache-Control"] = default_cache - - if ( - app.config.get("BLOCK_BOTS", False) - and not czy_admin - and not request.path.startswith("/static/") - ): - cc_override = app.config.get("CACHE_CONTROL_HEADER") - if cc_override: - response.headers["Cache-Control"] = cc_override - response.headers["X-Robots-Tag"] = ( - app.config.get("ROBOTS_TAG") or "noindex, nofollow, nosnippet, noarchive" - ) - - return response - - -@app.route("/admin/ustawienia", methods=["GET", "POST"]) -@login_required -def admin_ustawienia(): - if not current_user.czy_admin: - flash("Brak uprawnień do panelu administracyjnego", "danger") - return redirect(url_for("index")) - - client_ip = get_real_ip() - settings = UstawieniaGlobalne.query.first() - - if request.method == "POST": - numer_konta = request.form.get("numer_konta") - numer_telefonu_blik = request.form.get("numer_telefonu_blik") - dozwolone_hosty_logowania = request.form.get("dozwolone_hosty_logowania") - logo_url = request.form.get("logo_url") - tytul_strony = request.form.get("tytul_strony") - typ_navbar = request.form.get("typ_navbar", "text") - typ_stopka = request.form.get("typ_stopka", "text") - stopka_text = request.form.get("stopka_text") or None - pokaz_logo_w_navbar = (typ_navbar == "logo") - kolejnosc_rezerwowych = request.form.get("kolejnosc_rezerwowych", "id") - - if settings is None: - settings = UstawieniaGlobalne( - numer_konta=numer_konta, - numer_telefonu_blik=numer_telefonu_blik, - dozwolone_hosty_logowania=dozwolone_hosty_logowania, - logo_url=logo_url, - tytul_strony=tytul_strony, - pokaz_logo_w_navbar=pokaz_logo_w_navbar, - typ_navbar=typ_navbar, - typ_stopka=typ_stopka, - stopka_text=stopka_text, - kolejnosc_rezerwowych=kolejnosc_rezerwowych, - ) - db.session.add(settings) - else: - settings.numer_konta = numer_konta - settings.numer_telefonu_blik = numer_telefonu_blik - settings.dozwolone_hosty_logowania = dozwolone_hosty_logowania - settings.logo_url = logo_url - settings.tytul_strony = tytul_strony - settings.pokaz_logo_w_navbar = pokaz_logo_w_navbar - settings.typ_navbar = typ_navbar - settings.typ_stopka = typ_stopka - settings.stopka_text = stopka_text - settings.kolejnosc_rezerwowych = kolejnosc_rezerwowych - - db.session.commit() - flash("Ustawienia globalne zostały zaktualizowane", "success") - return redirect(url_for("admin_dashboard")) - - return render_template("admin/ustawienia.html", settings=settings, client_ip=client_ip) - - -@app.route("/admin/zbiorka//wydatek/dodaj", methods=["GET", "POST"]) -@login_required -def dodaj_wydatek(zbiorka_id): - if not current_user.czy_admin: - flash("Brak uprawnień", "danger") - return redirect(url_for("index")) - - zb = db.session.get(Zbiorka, zbiorka_id) - if zb is None: - abort(404) - - if request.method == "POST": - try: - kwota = parse_amount(request.form.get("kwota")) - if kwota <= 0: - raise InvalidOperation - except (InvalidOperation, ValueError): - flash("Nieprawidłowa kwota (musi być > 0)", "danger") - return redirect(url_for("dodaj_wydatek", zbiorka_id=zbiorka_id)) - - opis = request.form.get("opis", "") - nowy_wydatek = Wydatek(zbiorka_id=zb.id, kwota=kwota, opis=opis) - zb.stan = (zb.stan or Decimal("0")) - kwota - db.session.add(nowy_wydatek) - db.session.commit() - flash("Wydatek został dodany", "success") - - next_url = request.args.get("next") - return redirect(next_url or url_for("transakcje_zbiorki", zbiorka_id=zb.id)) - - return render_template("admin/dodaj_wydatek.html", zbiorka=zb) - - -@app.route( - "/admin/zbiorka/oznacz/niezrealizowana/", - methods=["POST"], - endpoint="oznacz_niezrealizowana", -) -@app.route( - "/admin/zbiorka/oznacz/zrealizowana/", - methods=["POST"], - endpoint="oznacz_zrealizowana", -) -@login_required -def oznacz_zbiorka(zbiorka_id): - if not current_user.czy_admin: - flash("Brak uprawnień do wykonania tej operacji", "danger") - return redirect(url_for("index")) - - zb = db.session.get(Zbiorka, zbiorka_id) - if zb is None: - abort(404) - - if "niezrealizowana" in request.path: - zb.zrealizowana = False - msg = "Zbiórka została oznaczona jako niezrealizowana" - else: - zb.zrealizowana = True - msg = "Zbiórka została oznaczona jako zrealizowana" - - db.session.commit() - flash(msg, "success") - return redirect(url_for("admin_dashboard")) - - -@app.route("/robots.txt") -def robots(): - if app.config.get("BLOCK_BOTS", False): - robots_txt = "User-agent: *\nDisallow: /" - else: - robots_txt = "User-agent: *\nAllow: /" - return robots_txt, 200, {"Content-Type": "text/plain"} - - -@app.route("/admin/zbiorka//transakcje") -@login_required -def transakcje_zbiorki(zbiorka_id): - if not current_user.czy_admin: - flash("Brak uprawnień", "danger"); return redirect(url_for("index")) - - zb = db.session.get(Zbiorka, zbiorka_id) - if zb is None: - abort(404) - - aktywnosci = ( - [ - { - "typ": "wpłata", - "id": w.id, - "kwota": w.kwota, - "opis": w.opis, - "data": w.data, - "ukryta": bool(w.ukryta), - } - for w in zb.wplaty - ] - + - [ - { - "typ": "wydatek", - "id": x.id, - "kwota": x.kwota, - "opis": x.opis, - "data": x.data, - "ukryta": bool(x.ukryta), - } - for x in zb.wydatki - ] - ) - aktywnosci.sort(key=lambda a: a["data"], reverse=True) - return render_template("admin/transakcje.html", zbiorka=zb, aktywnosci=aktywnosci) - - - -@app.route("/admin/wplata//zapisz", methods=["POST"]) -@login_required -def zapisz_wplate(wplata_id): - if not current_user.czy_admin: - flash("Brak uprawnień", "danger"); return redirect(url_for("index")) - w = db.session.get(Wplata, wplata_id) - if w is None: - abort(404) - zb = w.zbiorka - try: - nowa_kwota = parse_amount(request.form.get("kwota")) - if nowa_kwota <= 0: - raise InvalidOperation - except (InvalidOperation, ValueError): - flash("Nieprawidłowa kwota (musi być > 0)", "danger") - return redirect(url_for("transakcje_zbiorki", zbiorka_id=zb.id)) - - delta = nowa_kwota - (w.kwota or Decimal("0")) - w.kwota = nowa_kwota - w.opis = request.form.get("opis", "") - zb.stan = (zb.stan or Decimal("0")) + delta - db.session.commit() - flash("Wpłata zaktualizowana", "success") - return redirect(url_for("transakcje_zbiorki", zbiorka_id=zb.id)) - -@app.post("/wplata//ukryj") -@login_required -def ukryj_wplate(wplata_id): - if not current_user.czy_admin: abort(403) - w = db.session.get(Wplata, wplata_id) - if not w: abort(404) - w.ukryta = True - db.session.commit() - flash("Wpłata ukryta.", "success") - return redirect(request.referrer or url_for("admin_dashboard")) - -@app.post("/wplata//odkryj") -@login_required -def odkryj_wplate(wplata_id): - if not current_user.czy_admin: abort(403) - w = db.session.get(Wplata, wplata_id) - if not w: abort(404) - w.ukryta = False - db.session.commit() - flash("Wpłata odkryta.", "success") - return redirect(request.referrer or url_for("admin_dashboard")) - -@app.post("/wydatek//ukryj") -@login_required -def ukryj_wydatek(wydatek_id): - if not current_user.czy_admin: abort(403) - w = db.session.get(Wydatek, wydatek_id) - if not w: abort(404) - w.ukryta = True - db.session.commit() - flash("Wydatek ukryty.", "success") - return redirect(request.referrer or url_for("admin_dashboard")) - -@app.post("/wydatek//odkryj") -@login_required -def odkryj_wydatek(wydatek_id): - if not current_user.czy_admin: abort(403) - w = db.session.get(Wydatek, wydatek_id) - if not w: abort(404) - w.ukryta = False - db.session.commit() - flash("Wydatek odkryty.", "success") - return redirect(request.referrer or url_for("admin_dashboard")) - - -@app.route("/admin/wplata//usun", methods=["POST"]) -@login_required -def usun_wplate(wplata_id): - if not current_user.czy_admin: - flash("Brak uprawnień", "danger"); return redirect(url_for("index")) - w = db.session.get(Wplata, wplata_id) - if w is None: - abort(404) - zb = w.zbiorka - zb.stan -= w.kwota - db.session.delete(w) - db.session.commit() - flash("Wpłata usunięta", "success") - return redirect(url_for("transakcje_zbiorki", zbiorka_id=zb.id)) - - -@app.route("/admin/wydatek//zapisz", methods=["POST"]) -@login_required -def zapisz_wydatek(wydatek_id): - if not current_user.czy_admin: - flash("Brak uprawnień", "danger"); return redirect(url_for("index")) - x = db.session.get(Wydatek, wydatek_id) - if x is None: - abort(404) - zb = x.zbiorka - try: - nowa_kwota = parse_amount(request.form.get("kwota")) - if nowa_kwota <= 0: - raise InvalidOperation - except (InvalidOperation, ValueError): - flash("Nieprawidłowa kwota (musi być > 0)", "danger") - return redirect(url_for("transakcje_zbiorki", zbiorka_id=zb.id)) - - delta = nowa_kwota - (x.kwota or Decimal("0")) - x.kwota = nowa_kwota - x.opis = request.form.get("opis", "") - zb.stan = (zb.stan or Decimal("0")) - delta - db.session.commit() - flash("Wydatek zaktualizowany", "success") - return redirect(url_for("transakcje_zbiorki", zbiorka_id=zb.id)) - - -@app.route("/admin/wydatek//usun", methods=["POST"]) -@login_required -def usun_wydatek(wydatek_id): - if not current_user.czy_admin: - flash("Brak uprawnień", "danger"); return redirect(url_for("index")) - x = db.session.get(Wydatek, wydatek_id) - if x is None: - abort(404) - zb = x.zbiorka - zb.stan += x.kwota - db.session.delete(x) - db.session.commit() - flash("Wydatek usunięty", "success") - return redirect(url_for("transakcje_zbiorki", zbiorka_id=zb.id)) - - -@app.route("/admin/zbiorka//przesuniecie/dodaj", methods=["GET", "POST"]) -@login_required -def dodaj_przesuniecie(zbiorka_id): - if not current_user.czy_admin: - flash("Brak uprawnień", "danger") - return redirect(url_for("index")) - - zb_zrodlo = db.session.get(Zbiorka, zbiorka_id) - if zb_zrodlo is None: - abort(404) - - if request.method == "POST": - try: - kwota = parse_amount(request.form.get("kwota")) - if kwota <= 0: - raise InvalidOperation - except (InvalidOperation, ValueError): - flash("Nieprawidłowa kwota (musi być > 0)", "danger") - return redirect(url_for("dodaj_przesuniecie", zbiorka_id=zbiorka_id)) - - zbiorka_cel_id = request.form.get("zbiorka_cel_id") - if not zbiorka_cel_id: - flash("Wybierz docelową zbiórkę", "danger") - return redirect(url_for("dodaj_przesuniecie", zbiorka_id=zbiorka_id)) - - zb_cel = db.session.get(Zbiorka, int(zbiorka_cel_id)) - if zb_cel is None: - flash("Docelowa zbiórka nie istnieje", "danger") - return redirect(url_for("dodaj_przesuniecie", zbiorka_id=zbiorka_id)) - - if zb_zrodlo.stan < kwota: - flash("Niewystarczające środki w źródłowej zbiórce", "danger") - return redirect(url_for("dodaj_przesuniecie", zbiorka_id=zbiorka_id)) - - opis = request.form.get("opis", "") - - nowe_przesuniecie = Przesuniecie( - zbiorka_zrodlo_id=zb_zrodlo.id, - zbiorka_cel_id=zb_cel.id, - kwota=kwota, - opis=opis - ) - - zb_zrodlo.stan = (zb_zrodlo.stan or Decimal("0")) - kwota - zb_cel.stan = (zb_cel.stan or Decimal("0")) + kwota - - db.session.add(nowe_przesuniecie) - db.session.commit() - - flash(f"Przesunięto {kwota} PLN do zbiórki '{zb_cel.nazwa}'", "success") - - next_url = request.args.get("next") - return redirect(next_url or url_for("transakcje_zbiorki", zbiorka_id=zb_zrodlo.id)) - - dostepne_zbiorki = Zbiorka.query.filter(Zbiorka.id != zbiorka_id).all() - return render_template("admin/dodaj_przesuniecie.html", zbiorka=zb_zrodlo, dostepne_zbiorki=dostepne_zbiorki) - - -@app.route("/admin/rezerwy") -@login_required -def lista_rezerwowych(): - if not current_user.czy_admin: - flash("Brak uprawnień", "danger") - return redirect(url_for("index")) - - rezerwy = Zbiorka.query.filter_by(typ_zbiorki="rezerwa").all() - return render_template("admin/lista_rezerwowych.html", rezerwy=rezerwy) - - -@app.route("/admin/rezerwa/dodaj", methods=["GET", "POST"]) -@login_required -def dodaj_rezerwe(): - if not current_user.czy_admin: - flash("Brak uprawnień", "danger") - return redirect(url_for("index")) - - if request.method == "POST": - nazwa = request.form.get("nazwa", "").strip() - if not nazwa: - flash("Nazwa jest wymagana", "danger") - global_settings = UstawieniaGlobalne.query.first() - return render_template("admin/formularz_rezerwy.html", zbiorka=None, global_settings=global_settings) - - opis = request.form.get("opis", "").strip() - global_settings = UstawieniaGlobalne.query.first() - - uzyj_konta = "uzyj_konta" in request.form - uzyj_blik = "uzyj_blik" in request.form - numer_konta = request.form.get("numer_konta", "").strip() - numer_telefonu_blik = request.form.get("numer_telefonu_blik", "").strip() - - if uzyj_konta and not numer_konta: - if global_settings and global_settings.numer_konta: - numer_konta = global_settings.numer_konta - - if uzyj_blik and not numer_telefonu_blik: - if global_settings and global_settings.numer_telefonu_blik: - numer_telefonu_blik = global_settings.numer_telefonu_blik - - nowa_rezerwa = Zbiorka( - nazwa=nazwa, - opis=opis, - cel=Decimal("0"), - stan=Decimal("0"), - typ_zbiorki="rezerwa", - ukryta=True, - ukryj_kwote=False, - pokaz_postep_finanse=False, - pokaz_postep_pozycje=False, - pokaz_postep_kwotowo=False, - uzyj_konta=uzyj_konta, - uzyj_blik=uzyj_blik, - numer_konta=numer_konta if uzyj_konta else "", - numer_telefonu_blik=numer_telefonu_blik if uzyj_blik else "" - ) - db.session.add(nowa_rezerwa) - db.session.commit() - - flash(f"Lista rezerwowa '{nazwa}' została utworzona", "success") - return redirect(url_for("lista_rezerwowych")) - - global_settings = UstawieniaGlobalne.query.first() - return render_template("admin/formularz_rezerwy.html", zbiorka=None, global_settings=global_settings) - - -@app.route("/admin/rezerwa/edytuj/", methods=["GET", "POST"]) -@login_required -def edytuj_rezerwe(rezerwa_id): - if not current_user.czy_admin: - flash("Brak uprawnień", "danger") - return redirect(url_for("index")) - - zb = db.session.get(Zbiorka, rezerwa_id) - if zb is None or zb.typ_zbiorki != "rezerwa": - abort(404) - - if request.method == "POST": - nazwa = request.form.get("nazwa", "").strip() - if not nazwa: - flash("Nazwa jest wymagana", "danger") - global_settings = UstawieniaGlobalne.query.first() - return render_template("admin/formularz_rezerwy.html", zbiorka=zb, global_settings=global_settings) - - opis = request.form.get("opis", "").strip() - - uzyj_konta = "uzyj_konta" in request.form - uzyj_blik = "uzyj_blik" in request.form - numer_konta = request.form.get("numer_konta", "").strip() - numer_telefonu_blik = request.form.get("numer_telefonu_blik", "").strip() - - zb.nazwa = nazwa - zb.opis = opis - zb.uzyj_konta = uzyj_konta - zb.uzyj_blik = uzyj_blik - zb.numer_konta = numer_konta if uzyj_konta else "" - zb.numer_telefonu_blik = numer_telefonu_blik if uzyj_blik else "" - - db.session.commit() - - flash(f"Lista rezerwowa '{nazwa}' została zaktualizowana", "success") - return redirect(url_for("lista_rezerwowych")) - - global_settings = UstawieniaGlobalne.query.first() - return render_template("admin/formularz_rezerwy.html", zbiorka=zb, global_settings=global_settings) - -@app.route("/admin/rezerwa/usun/", methods=["POST"]) -@login_required -def usun_rezerwe(rezerwa_id): - if not current_user.czy_admin: - flash("Brak uprawnień", "danger") - return redirect(url_for("index")) - - zb = db.session.get(Zbiorka, rezerwa_id) - if zb is None or zb.typ_zbiorki != "rezerwa": - abort(404) - - nazwa = zb.nazwa - db.session.delete(zb) - db.session.commit() - - flash(f"Lista rezerwowa '{nazwa}' została usunięta", "success") - return redirect(url_for("lista_rezerwowych")) - - -@app.route("/admin/statystyki") -@login_required -def admin_statystyki(): - if not current_user.czy_admin: - abort(403) - - from sqlalchemy import func, extract - from datetime import datetime, timedelta - - # ==================== PODSTAWOWE STATYSTYKI ==================== - total_wplaty = db.session.query(func.sum(Wplata.kwota)).filter(Wplata.ukryta == False).scalar() or 0 - total_wydatki = db.session.query(func.sum(Wydatek.kwota)).filter(Wydatek.ukryta == False).scalar() or 0 - bilans = total_wplaty - total_wydatki - - liczba_wplat = db.session.query(func.count(Wplata.id)).filter(Wplata.ukryta == False).scalar() or 0 - liczba_wydatkow = db.session.query(func.count(Wydatek.id)).filter(Wydatek.ukryta == False).scalar() or 0 - liczba_zbiorek = db.session.query(func.count(Zbiorka.id)).scalar() or 0 - - # Najwyższa wpłata - najwyzsza_wplata = db.session.query(Wplata).filter(Wplata.ukryta == False).order_by(Wplata.kwota.desc()).first() - - # Najwyższy wydatek - najwyzszy_wydatek = db.session.query(Wydatek).filter(Wydatek.ukryta == False).order_by(Wydatek.kwota.desc()).first() - - # Średnia wpłata i wydatek - srednia_wplata = total_wplaty / liczba_wplat if liczba_wplat > 0 else 0 - sredni_wydatek = total_wydatki / liczba_wydatkow if liczba_wydatkow > 0 else 0 - - # ==================== STATYSTYKI PRZESUNIĘĆ ==================== - total_przesuniec = db.session.query(func.sum(Przesuniecie.kwota)).filter(Przesuniecie.ukryta == False).scalar() or 0 - liczba_przesuniec = db.session.query(func.count(Przesuniecie.id)).filter(Przesuniecie.ukryta == False).scalar() or 0 - - # Top 5 źródeł przesunięć (zbiórki które najczęściej przekazują środki) - top_zrodla_przesuniec = db.session.query( - Zbiorka.nazwa, - func.count(Przesuniecie.id).label('liczba'), - func.sum(Przesuniecie.kwota).label('suma') - ).join(Przesuniecie, Przesuniecie.zbiorka_zrodlo_id == Zbiorka.id)\ - .filter(Przesuniecie.ukryta == False)\ - .group_by(Zbiorka.id, Zbiorka.nazwa)\ - .order_by(func.sum(Przesuniecie.kwota).desc())\ - .limit(5).all() - - # ==================== TOP 10 WPŁAT ==================== - top_10_wplat = db.session.query(Wplata)\ - .filter(Wplata.ukryta == False)\ - .order_by(Wplata.kwota.desc())\ - .limit(10).all() - - # ==================== AKTYWNOŚĆ CZASOWA ==================== - teraz = datetime.now() - rok_temu = teraz - timedelta(days=365) - miesiac_temu = teraz - timedelta(days=30) - tydzien_temu = teraz - timedelta(days=7) - - # Aktywność ostatnie 7 dni - wplaty_7dni = db.session.query( - func.count(Wplata.id).label('liczba'), - func.sum(Wplata.kwota).label('suma') - ).filter(Wplata.data >= tydzien_temu, Wplata.ukryta == False).first() - - wydatki_7dni = db.session.query( - func.count(Wydatek.id).label('liczba'), - func.sum(Wydatek.kwota).label('suma') - ).filter(Wydatek.data >= tydzien_temu, Wydatek.ukryta == False).first() - - # Aktywność ostatnie 30 dni - wplaty_30dni = db.session.query( - func.count(Wplata.id).label('liczba'), - func.sum(Wplata.kwota).label('suma') - ).filter(Wplata.data >= miesiac_temu, Wplata.ukryta == False).first() - - wydatki_30dni = db.session.query( - func.count(Wydatek.id).label('liczba'), - func.sum(Wydatek.kwota).label('suma') - ).filter(Wydatek.data >= miesiac_temu, Wydatek.ukryta == False).first() - - # ==================== STATYSTYKI MIESIĘCZNE (ostatnie 12 miesięcy) ==================== - wplaty_miesieczne = db.session.query( - extract('year', Wplata.data).label('rok'), - extract('month', Wplata.data).label('miesiac'), - func.sum(Wplata.kwota).label('suma'), - func.count(Wplata.id).label('liczba') - ).filter( - Wplata.data >= rok_temu, - Wplata.ukryta == False - ).group_by('rok', 'miesiac').order_by('rok', 'miesiac').all() - - wydatki_miesieczne = db.session.query( - extract('year', Wydatek.data).label('rok'), - extract('month', Wydatek.data).label('miesiac'), - func.sum(Wydatek.kwota).label('suma'), - func.count(Wydatek.id).label('liczba') - ).filter( - Wydatek.data >= rok_temu, - Wydatek.ukryta == False - ).group_by('rok', 'miesiac').order_by('rok', 'miesiac').all() - - przesuniecia_miesieczne = db.session.query( - extract('year', Przesuniecie.data).label('rok'), - extract('month', Przesuniecie.data).label('miesiac'), - func.sum(Przesuniecie.kwota).label('suma'), - func.count(Przesuniecie.id).label('liczba') - ).filter( - Przesuniecie.data >= rok_temu, - Przesuniecie.ukryta == False - ).group_by('rok', 'miesiac').order_by('rok', 'miesiac').all() - - # ==================== STATYSTYKI ROCZNE ==================== - wplaty_roczne = db.session.query( - extract('year', Wplata.data).label('rok'), - func.sum(Wplata.kwota).label('suma'), - func.count(Wplata.id).label('liczba') - ).filter( - Wplata.ukryta == False - ).group_by('rok').order_by('rok').all() - - wydatki_roczne = db.session.query( - extract('year', Wydatek.data).label('rok'), - func.sum(Wydatek.kwota).label('suma'), - func.count(Wydatek.id).label('liczba') - ).filter( - Wydatek.ukryta == False - ).group_by('rok').order_by('rok').all() - - # ==================== TOP 5 ZBIÓREK ==================== - top_zbiorki = db.session.query( - Zbiorka, - func.sum(Wplata.kwota).label('suma_wplat') - ).join(Wplata).filter( - Wplata.ukryta == False - ).group_by(Zbiorka.id).order_by(func.sum(Wplata.kwota).desc()).limit(5).all() - - return render_template( - "admin/statystyki.html", - # Podstawowe - total_wplaty=total_wplaty, - total_wydatki=total_wydatki, - bilans=bilans, - liczba_wplat=liczba_wplat, - liczba_wydatkow=liczba_wydatkow, - liczba_zbiorek=liczba_zbiorek, - najwyzsza_wplata=najwyzsza_wplata, - najwyzszy_wydatek=najwyzszy_wydatek, - srednia_wplata=srednia_wplata, - sredni_wydatek=sredni_wydatek, - # Przesunięcia - total_przesuniec=total_przesuniec, - liczba_przesuniec=liczba_przesuniec, - top_zrodla_przesuniec=top_zrodla_przesuniec, - # Top wpłaty - top_10_wplat=top_10_wplat, - # Aktywność czasowa - wplaty_7dni=wplaty_7dni, - wydatki_7dni=wydatki_7dni, - wplaty_30dni=wplaty_30dni, - wydatki_30dni=wydatki_30dni, - # Miesięczne - wplaty_miesieczne=wplaty_miesieczne, - wydatki_miesieczne=wydatki_miesieczne, - przesuniecia_miesieczne=przesuniecia_miesieczne, - # Roczne - wplaty_roczne=wplaty_roczne, - wydatki_roczne=wydatki_roczne, - # Top zbiórki - top_zbiorki=top_zbiorki - ) - - -@app.route("/favicon.ico") -def favicon(): - return "", 204 - - -@app.route("/healthcheck") -def healthcheck(): - header_token = request.headers.get("X-Internal-Check") - correct_token = app.config.get("HEALTHCHECK_TOKEN") - - if header_token != correct_token: - abort(404) - - response_data = { - "status": "OK", - } - - return response_data, 200 +app = create_app() +__all__ = ["app", "db", "create_admin_account", "init_database_with_retry"] if __name__ == "__main__": - with app.app_context(): - db.create_all() - stmt = select(Uzytkownik).filter_by(czy_admin=True) - admin = db.session.execute(stmt).scalars().first() - if not admin: - main_admin = Uzytkownik( - uzytkownik=app.config["MAIN_ADMIN_USERNAME"], - czy_admin=True - ) - main_admin.set_password(app.config["MAIN_ADMIN_PASSWORD"]) - db.session.add(main_admin) - db.session.commit() - + init_database_with_retry(app) app.run(debug=True) diff --git a/config.py b/config.py index ac25c76..4ef38c6 100644 --- a/config.py +++ b/config.py @@ -11,6 +11,13 @@ def _get_bool(name: str, default: bool) -> bool: def _get_str(name: str, default: str) -> str: return os.environ.get(name, default) + +def _get_int(name: str, default: int) -> int: + try: + return int(os.environ.get(name, default)) + except (TypeError, ValueError): + return default + class Config: """ Konfiguracja aplikacji pobierana z ENV (z sensownymi domyślnymi wartościami). @@ -50,6 +57,12 @@ class Config: # (opcjonalnie) wyłącz warningi track_modifications SQLALCHEMY_TRACK_MODIFICATIONS = False + SQLALCHEMY_ENGINE_OPTIONS = { + "pool_pre_ping": True, + "pool_recycle": _get_int("DB_POOL_RECYCLE", 300), + "pool_timeout": _get_int("DB_POOL_TIMEOUT", 30), + } + HEALTHCHECK_TOKEN = _get_str("HEALTHCHECK_TOKEN", "healthcheck") # Baza danych @@ -62,10 +75,17 @@ class Config: f"postgresql://{os.environ['DB_USER']}:{os.environ['DB_PASSWORD']}@" f"{os.environ['DB_HOST']}:{os.environ.get('DB_PORT', 5432)}/{os.environ['DB_NAME']}" ) + SQLALCHEMY_ENGINE_OPTIONS["connect_args"] = { + "connect_timeout": _get_int("DB_CONNECT_TIMEOUT", 5), + "application_name": _get_str("DB_APPLICATION_NAME", "zbiorka-app"), + } elif DB_ENGINE == "mysql": SQLALCHEMY_DATABASE_URI = ( f"mysql+pymysql://{os.environ['DB_USER']}:{os.environ['DB_PASSWORD']}@" f"{os.environ['DB_HOST']}:{os.environ.get('DB_PORT', 3306)}/{os.environ['DB_NAME']}" ) + SQLALCHEMY_ENGINE_OPTIONS["connect_args"] = { + "connect_timeout": _get_int("DB_CONNECT_TIMEOUT", 5), + } else: raise ValueError("Nieobsługiwany typ bazy danych.") \ No newline at end of file diff --git a/run_waitress.py b/run_waitress.py index c34e271..0c05a8b 100644 --- a/run_waitress.py +++ b/run_waitress.py @@ -1,11 +1,10 @@ import os -from app import app, db, create_admin_account + from waitress import serve +from app import app, init_database_with_retry + if __name__ == '__main__': - with app.app_context(): - db.create_all() - create_admin_account() - + init_database_with_retry(app, raise_on_failure=False) port = int(os.environ.get("APP_PORT", 8080)) - serve(app, host="0.0.0.0", port=port) \ No newline at end of file + serve(app, host="0.0.0.0", port=port) diff --git a/zbiorka_app/__init__.py b/zbiorka_app/__init__.py new file mode 100644 index 0000000..7fc8f98 --- /dev/null +++ b/zbiorka_app/__init__.py @@ -0,0 +1,43 @@ +from flask import Flask + +from config import Config + +from .cli import register_cli_commands +from .errors import register_error_handlers +from .extensions import db, login_manager +from .routes import register_routes +from .utils import asset_url, init_version, init_database_with_retry + + +def create_app(): + app = Flask( + __name__, + template_folder="templates", + static_folder="static", + static_url_path="/static", + ) + + app.config.from_object(Config) + + db.init_app(app) + login_manager.init_app(app) + login_manager.login_view = "zaloguj" + + init_version(app) + + @app.context_processor + def inject_asset_helpers(): + return {"asset_url": asset_url} + + @app.before_request + def ensure_db_ready_before_request(): + if app.extensions.get("database_ready") is True: + return None + init_database_with_retry(app, max_attempts=1, delay=0, raise_on_failure=False) + return None + + register_routes(app) + register_error_handlers(app) + register_cli_commands(app) + + return app diff --git a/zbiorka_app/cli.py b/zbiorka_app/cli.py new file mode 100644 index 0000000..e874e45 --- /dev/null +++ b/zbiorka_app/cli.py @@ -0,0 +1,79 @@ +import click +from flask.cli import with_appcontext + +from .extensions import db +from .utils import ( + ensure_database_ready, + init_database_with_retry, + safe_db_rollback, + set_login_hosts, + set_user_password, +) + + +def register_cli_commands(app): + @app.cli.command("init-db") + @click.option("--attempts", default=20, type=int, show_default=True) + @click.option("--delay", default=3, type=int, show_default=True) + def init_db_command(attempts: int, delay: int): + """Inicjalizuje baze i czeka az bedzie gotowa.""" + ok = init_database_with_retry(app, max_attempts=attempts, delay=delay, raise_on_failure=False) + if not ok: + raise click.ClickException("Nie udalo sie zainicjalizowac bazy danych.") + click.echo("Baza danych gotowa.") + + @app.cli.command("set-admin-password") + @click.option("--username", default="admin", show_default=True) + @click.option("--password", prompt=True, hide_input=True, confirmation_prompt=True) + @with_appcontext + def set_admin_password_command(username: str, password: str): + """Ustawia haslo administratora.""" + try: + ensure_database_ready(create_schema=True, create_admin=True) + user = set_user_password(username=username, password=password, is_admin=True) + click.echo(f"Haslo admina ustawione dla uzytkownika: {user.uzytkownik}") + except Exception as exc: + safe_db_rollback() + raise click.ClickException(str(exc)) + + @app.cli.command("set-user-password") + @click.argument("username") + @click.option("--password", prompt=True, hide_input=True, confirmation_prompt=True) + @click.option("--admin/--no-admin", default=None) + @with_appcontext + def set_user_password_command(username: str, password: str, admin: bool | None): + """Tworzy uzytkownika lub zmienia jego haslo.""" + try: + ensure_database_ready(create_schema=True, create_admin=True) + user = set_user_password(username=username, password=password, is_admin=admin) + click.echo(f"Haslo ustawione dla uzytkownika: {user.uzytkownik}") + except Exception as exc: + safe_db_rollback() + raise click.ClickException(str(exc)) + + @app.cli.command("set-login-hosts") + @click.argument("hosts", required=False, default="") + @with_appcontext + def set_login_hosts_command(hosts: str): + """Ustawia dozwolone hosty lub IP do logowania.""" + try: + ensure_database_ready(create_schema=True, create_admin=True) + settings = set_login_hosts(hosts) + click.echo( + "Dozwolone hosty logowania ustawione na: " + f"{settings.dozwolone_hosty_logowania or '(brak - logowanie zablokowane)'}" + ) + except Exception as exc: + safe_db_rollback() + raise click.ClickException(str(exc)) + + @app.cli.command("db-ping") + @with_appcontext + def db_ping_command(): + """Sprawdza polaczenie z baza danych.""" + try: + ensure_database_ready(create_schema=False, create_admin=False) + click.echo("OK") + except Exception as exc: + safe_db_rollback() + raise click.ClickException(f"DB ERROR: {exc}") diff --git a/zbiorka_app/errors.py b/zbiorka_app/errors.py new file mode 100644 index 0000000..d8633b0 --- /dev/null +++ b/zbiorka_app/errors.py @@ -0,0 +1,102 @@ +from html import escape +from http import HTTPStatus + +from flask import jsonify, render_template, request +from jinja2 import TemplateNotFound +from sqlalchemy.exc import OperationalError +from werkzeug.exceptions import HTTPException + +from .utils import safe_db_rollback + + +JSON_MIMETYPES = ["application/json", "text/html"] + + +def _wants_json_response() -> bool: + if request.path.startswith("/api/"): + return True + + if request.is_json: + return True + + best = request.accept_mimetypes.best_match(JSON_MIMETYPES) + if not best: + return False + + return ( + best == "application/json" + and request.accept_mimetypes["application/json"] + >= request.accept_mimetypes["text/html"] + ) + + +def _status_phrase(status_code: int) -> str: + try: + return HTTPStatus(status_code).phrase + except ValueError: + return "Blad" + + +def _status_description(status_code: int) -> str: + try: + return HTTPStatus(status_code).description + except ValueError: + return "Wystapil blad podczas przetwarzania zadania." + + +def _plain_fallback(status_code: int, phrase: str, description: str): + html = f""" + + + + + {status_code} {escape(phrase)} + + +

{status_code} - {escape(phrase)}

+

{escape(description)}

+ +""" + return html, status_code + + +def _render_error(status_code: int, message: str | None = None): + phrase = _status_phrase(status_code) + description = message or _status_description(status_code) + payload = {"status": status_code, "error": phrase, "message": description} + + if _wants_json_response(): + return jsonify(payload), status_code + + try: + return ( + render_template( + "error.html", + error_code=status_code, + error_name=phrase, + error_message=description, + ), + status_code, + ) + except TemplateNotFound: + return _plain_fallback(status_code, phrase, description) + except Exception: + return _plain_fallback(status_code, phrase, description) + + +def register_error_handlers(app): + @app.errorhandler(HTTPException) + def handle_http_exception(exc): + return _render_error(exc.code or 500, exc.description) + + @app.errorhandler(OperationalError) + def handle_operational_error(exc): + safe_db_rollback() + app.logger.exception("Blad polaczenia z baza danych: %s", exc) + return _render_error(503, "Baza danych jest chwilowo niedostepna. Sprobuj ponownie za chwile.") + + @app.errorhandler(Exception) + def handle_unexpected_error(exc): + safe_db_rollback() + app.logger.exception("Nieobsluzony wyjatek: %s", exc) + return _render_error(500, "Wystapil nieoczekiwany blad serwera.") diff --git a/zbiorka_app/extensions.py b/zbiorka_app/extensions.py new file mode 100644 index 0000000..e0663ee --- /dev/null +++ b/zbiorka_app/extensions.py @@ -0,0 +1,5 @@ +from flask_login import LoginManager +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() +login_manager = LoginManager() diff --git a/zbiorka_app/models.py b/zbiorka_app/models.py new file mode 100644 index 0000000..fe4eaa2 --- /dev/null +++ b/zbiorka_app/models.py @@ -0,0 +1,146 @@ +from datetime import datetime + +from flask_login import UserMixin +from sqlalchemy import Numeric +from werkzeug.security import check_password_hash, generate_password_hash + +from .extensions import db + +class Uzytkownik(UserMixin, db.Model): + __tablename__ = "uzytkownik" + + id = db.Column(db.Integer, primary_key=True) + uzytkownik = db.Column(db.String(80), unique=True, nullable=False) + haslo_hash = db.Column(db.String(128), nullable=False) + czy_admin = db.Column(db.Boolean, default=False) + + def set_password(self, password): + self.haslo_hash = generate_password_hash(password) + def check_password(self, password): + return check_password_hash(self.haslo_hash, password) + +class Zbiorka(db.Model): + id = db.Column(db.Integer, primary_key=True) + nazwa = db.Column(db.String(100), nullable=False) + opis = db.Column(db.Text, nullable=False) + numer_konta = db.Column(db.String(50), nullable=True) + numer_telefonu_blik = db.Column(db.String(50), nullable=True) + cel = db.Column(Numeric(12, 2), nullable=False, default=0) + stan = db.Column(Numeric(12, 2), default=0) + ukryta = db.Column(db.Boolean, default=False) + ukryj_kwote = db.Column(db.Boolean, default=False) + zrealizowana = db.Column(db.Boolean, default=False) + pokaz_postep_finanse = db.Column(db.Boolean, default=True, nullable=False) + pokaz_postep_pozycje = db.Column(db.Boolean, default=True, nullable=False) + pokaz_postep_kwotowo = db.Column(db.Boolean, default=True, nullable=False) + uzyj_konta = db.Column(db.Boolean, default=True, nullable=False) + uzyj_blik = db.Column(db.Boolean, default=True, nullable=False) + typ_zbiorki = db.Column(db.String(20), default="standardowa", nullable=False) + + wplaty = db.relationship( + "Wplata", + back_populates="zbiorka", + lazy=True, + order_by="Wplata.data.desc()", + cascade="all, delete-orphan", + passive_deletes=True, + ) + + wydatki = db.relationship( + "Wydatek", + backref="zbiorka", + lazy=True, + order_by="Wydatek.data.desc()", + cascade="all, delete-orphan", + passive_deletes=True, + ) + + przedmioty = db.relationship( + "Przedmiot", + backref="zbiorka", + lazy=True, + order_by="Przedmiot.id.asc()", + cascade="all, delete-orphan", + passive_deletes=True, + ) + +class Przedmiot(db.Model): + id = db.Column(db.Integer, primary_key=True) + zbiorka_id = db.Column( + db.Integer, + db.ForeignKey("zbiorka.id", ondelete="CASCADE"), + nullable=False, + ) + nazwa = db.Column(db.String(120), nullable=False) + link = db.Column(db.String(255), nullable=True) + cena = db.Column(Numeric(12, 2), nullable=True) + kupione = db.Column(db.Boolean, default=False) + +class Wplata(db.Model): + id = db.Column(db.Integer, primary_key=True) + zbiorka_id = db.Column( + db.Integer, + db.ForeignKey("zbiorka.id", ondelete="CASCADE"), + nullable=False, + ) + kwota = db.Column(Numeric(12, 2), nullable=False) + data = db.Column(db.DateTime, default=datetime.utcnow) + opis = db.Column(db.Text, nullable=True) + zbiorka = db.relationship("Zbiorka", back_populates="wplaty") + ukryta = db.Column(db.Boolean, nullable=False, default=False) + +class Wydatek(db.Model): + id = db.Column(db.Integer, primary_key=True) + zbiorka_id = db.Column( + db.Integer, + db.ForeignKey("zbiorka.id", ondelete="CASCADE"), + nullable=False, + ) + kwota = db.Column(Numeric(12, 2), nullable=False) + data = db.Column(db.DateTime, default=datetime.utcnow) + opis = db.Column(db.Text, nullable=True) + ukryta = db.Column(db.Boolean, nullable=False, default=False) + +class Przesuniecie(db.Model): + id = db.Column(db.Integer, primary_key=True) + zbiorka_zrodlo_id = db.Column( + db.Integer, + db.ForeignKey("zbiorka.id", ondelete="CASCADE"), + nullable=False, + ) + zbiorka_cel_id = db.Column( + db.Integer, + db.ForeignKey("zbiorka.id", ondelete="CASCADE"), + nullable=False, + ) + kwota = db.Column(Numeric(12, 2), nullable=False) + data = db.Column(db.DateTime, default=datetime.utcnow) + opis = db.Column(db.Text, nullable=True) + ukryta = db.Column(db.Boolean, nullable=False, default=False) + + wplata_id = db.Column( + db.Integer, + db.ForeignKey("wplata.id", ondelete="SET NULL"), + nullable=True, + ) + + zbiorka_zrodlo = db.relationship("Zbiorka", foreign_keys=[zbiorka_zrodlo_id], backref="przesuniecia_wychodzace") + zbiorka_cel = db.relationship("Zbiorka", foreign_keys=[zbiorka_cel_id], backref="przesuniecia_przychodzace") + wplata = db.relationship("Wplata", foreign_keys=[wplata_id], backref="przesuniecia") + +class UstawieniaGlobalne(db.Model): + __tablename__ = "ustawienia_globalne" + + id = db.Column(db.Integer, primary_key=True) + numer_konta = db.Column(db.String(50), nullable=False) + numer_telefonu_blik = db.Column(db.String(50), nullable=False) + dozwolone_hosty_logowania = db.Column(db.Text, nullable=True) + logo_url = db.Column(db.String(255), nullable=True) + tytul_strony = db.Column(db.String(120), nullable=True) + pokaz_logo_w_navbar = db.Column(db.Boolean, default=False) + typ_navbar = db.Column(db.String(10), default="text") + typ_stopka = db.Column(db.String(10), default="text") + stopka_text = db.Column(db.String(200), nullable=True) + kolejnosc_rezerwowych = db.Column(db.String(20), default="id", nullable=False) + + diff --git a/zbiorka_app/routes.py b/zbiorka_app/routes.py new file mode 100644 index 0000000..ed8fcfe --- /dev/null +++ b/zbiorka_app/routes.py @@ -0,0 +1,1371 @@ +import markdown as md +from datetime import datetime, timedelta +from decimal import InvalidOperation + +from flask import abort, current_app, flash, redirect, render_template, request, url_for +from flask_login import current_user, login_required, login_user, logout_user +from markupsafe import Markup +from sqlalchemy import event +from sqlalchemy.engine import Engine + +from .extensions import db, login_manager +from .models import Przedmiot, Przesuniecie, UstawieniaGlobalne, Uzytkownik, Wplata, Wydatek, Zbiorka +from .utils import get_real_ip, is_allowed_ip, is_database_available, parse_amount, to_local + + +def register_routes(app): + @login_manager.user_loader + def load_user(user_id): + return db.session.get(Uzytkownik, int(user_id)) + + + @event.listens_for(Engine, "connect") + def set_sqlite_pragma(dbapi_connection, connection_record): + if dbapi_connection.__class__.__module__.startswith('sqlite3'): + try: + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + except Exception: + pass + + + @app.template_filter("dt") + def dt_filter(dt, fmt="%Y-%m-%d %H:%M"): + try: + ldt = to_local(dt) + return ldt.strftime(fmt) if ldt else "" + except Exception: + return "" + + + @app.template_filter("markdown") + def markdown_filter(text): + return Markup(md.markdown(text)) + + + @app.context_processor + def inject_globals(): + try: + settings = UstawieniaGlobalne.query.first() + allowed_hosts_str = ( + settings.dozwolone_hosty_logowania if settings and settings.dozwolone_hosty_logowania else "" + ) + client_ip = get_real_ip() + is_ip_ok = is_allowed_ip(client_ip, allowed_hosts_str) + except Exception: + settings = None + is_ip_ok = False + return { + "is_ip_allowed": is_ip_ok, + "global_settings": settings, + } + + + @app.context_processor + def inject_version(): + return {"APP_VERSION": app.config["APP_VERSION"]} + + + # TRASY PUBLICZNE + @app.route("/") + def index(): + settings = UstawieniaGlobalne.query.first() + kolejnosc = settings.kolejnosc_rezerwowych if settings else "id" + + standardowe = Zbiorka.query.filter_by(ukryta=False, zrealizowana=False).filter( + Zbiorka.typ_zbiorki != 'rezerwa' + ).all() + + rezerwowe = Zbiorka.query.filter_by(ukryta=False, zrealizowana=False, typ_zbiorki='rezerwa').all() + + # Sortuj według ustawienia + if kolejnosc == "first": + zbiorki = rezerwowe + standardowe + elif kolejnosc == "last": + zbiorki = standardowe + rezerwowe + else: # "id" + zbiorki = sorted(standardowe + rezerwowe, key=lambda z: z.id) + + return render_template("index.html", zbiorki=zbiorki) + + + @app.route("/zrealizowane") + def zbiorki_zrealizowane(): + zbiorki = Zbiorka.query.filter_by(zrealizowana=True).all() + return render_template("index.html", zbiorki=zbiorki) + + + @app.route("/zbiorka/", endpoint='zbiorka') + @app.route("/rezerwa/", endpoint='rezerwa') + def zbiorka(zbiorka_id): + zb = db.session.get(Zbiorka, zbiorka_id) + if zb is None: + abort(404) + + # Zabezpieczenie: sprawdź czy URL pasuje do typu zbiórki + poprawny_endpoint = 'rezerwa' if zb.typ_zbiorki == 'rezerwa' else 'zbiorka' + + if request.endpoint != poprawny_endpoint: + return redirect(url_for(poprawny_endpoint, zbiorka_id=zbiorka_id), code=301) + + if zb.ukryta and (not current_user.is_authenticated or not current_user.czy_admin): + abort(404) + + is_admin = current_user.is_authenticated and current_user.czy_admin + show_hidden = is_admin and (request.args.get("show_hidden") in ("1", "true", "yes")) + + # Stwórz mapę przesunięć wpłat dla tej zbiórki (przychodzące) + przesuniecia_wplat_map = { + p.wplata_id: { + "zbiorka_zrodlo_nazwa": p.zbiorka_zrodlo.nazwa, + "zbiorka_zrodlo_id": p.zbiorka_zrodlo_id, + "opis": p.opis + } + for p in zb.przesuniecia_przychodzace + if p.wplata_id is not None + } + + # Wpłaty z informacją o przesunięciu + wplaty = [ + { + "typ": "wpłata", + "id": w.id, + "kwota": w.kwota, + "opis": w.opis, + "data": w.data, + "ukryta": getattr(w, "ukryta", False), + "przesuniecie_z": przesuniecia_wplat_map.get(w.id) + } + for w in zb.wplaty + if show_hidden or not getattr(w, "ukryta", False) + ] + + # Wydatki + wydatki = [ + { + "typ": "wydatek", + "id": x.id, + "kwota": x.kwota, + "opis": x.opis, + "data": x.data, + "ukryta": getattr(x, "ukryta", False) + } + for x in zb.wydatki + if show_hidden or not getattr(x, "ukryta", False) + ] + + # Przesunięcia przychodzące - TYLKO ogólne (bez konkretnej wpłaty) + przesuniecia_przych = [ + { + "typ": "przesunięcie_przych", + "kwota": p.kwota, + "opis": p.opis or f"Przesunięcie z: {p.zbiorka_zrodlo.nazwa}", + "data": p.data, + "zbiorka_id": p.zbiorka_zrodlo_id, + "zbiorka_nazwa": p.zbiorka_zrodlo.nazwa, + "ukryta": getattr(p, "ukryta", False) + } + for p in zb.przesuniecia_przychodzace + if (show_hidden or not getattr(p, "ukryta", False)) and p.wplata_id is None + ] + + # Przesunięcia wychodzące - TYLKO ogólne (bez konkretnej wpłaty) + przesuniecia_wych = [ + { + "typ": "przesunięcie_wych", + "kwota": p.kwota, + "opis": p.opis or f"Przesunięcie do: {p.zbiorka_cel.nazwa}", + "data": p.data, + "zbiorka_id": p.zbiorka_cel_id, + "zbiorka_nazwa": p.zbiorka_cel.nazwa, + "ukryta": getattr(p, "ukryta", False) + } + for p in zb.przesuniecia_wychodzace + if (show_hidden or not getattr(p, "ukryta", False)) and p.wplata_id is None + ] + + aktywnosci = wplaty + wydatki + przesuniecia_przych + przesuniecia_wych + aktywnosci.sort(key=lambda a: a["data"], reverse=True) + + return render_template("zbiorka.html", zbiorka=zb, aktywnosci=aktywnosci, show_hidden=show_hidden) + + + # TRASY LOGOWANIA I REJESTRACJI + + + + @app.route("/zaloguj", methods=["GET", "POST"]) + def zaloguj(): + settings = UstawieniaGlobalne.query.first() + allowed_hosts_str = ( + settings.dozwolone_hosty_logowania + if settings and settings.dozwolone_hosty_logowania + else "" + ) + + client_ip = get_real_ip() + + if not is_allowed_ip(client_ip, allowed_hosts_str): + flash( + f"Dostęp do panelu logowania z adresu IP {client_ip} " + f"jest zablokowany – Twój adres nie znajduje się na liście dozwolonych.", + "danger", + ) + return redirect(url_for("index")) + + if current_user.is_authenticated: + return redirect(url_for("admin_dashboard")) + + if request.method == "POST": + login = request.form.get("uzytkownik", "").strip() + password = request.form.get("haslo", "") + + user = Uzytkownik.query.filter_by(uzytkownik=login).first() + if user and user.check_password(password): + login_user(user) + flash("Zalogowano pomyślnie", "success") + + next_page = request.form.get("next") or request.args.get("next") + return redirect(next_page) if next_page else redirect(url_for("admin_dashboard")) + + flash("Nieprawidłowe dane logowania", "danger") + + return render_template("login.html") + + + @app.route("/wyloguj") + @login_required + def wyloguj(): + logout_user() + flash("Wylogowano", "success") + return redirect(url_for("zaloguj")) + + + @app.route("/zarejestruj", methods=["GET", "POST"]) + def zarejestruj(): + if not app.config.get("ALLOW_REGISTRATION", False): + flash("Rejestracja została wyłączona przez administratora", "danger") + return redirect(url_for("zaloguj")) + if request.method == "POST": + login = request.form["uzytkownik"] + password = request.form["haslo"] + if Uzytkownik.query.filter_by(uzytkownik=login).first(): + flash("Użytkownik już istnieje", "danger") + return redirect(url_for("register")) + new_user = Uzytkownik(uzytkownik=login) + new_user.set_password(password) + db.session.add(new_user) + db.session.commit() + flash("Konto utworzone, możesz się zalogować", "success") + return redirect(url_for("zaloguj")) + return render_template("register.html") + + + # PANEL ADMINISTRACYJNY + @app.route("/admin") + @login_required + def admin_dashboard(): + if not current_user.czy_admin: + flash("Brak uprawnień do panelu administracyjnego", "danger") + return redirect(url_for("index")) + + active_zbiorki = Zbiorka.query.filter_by(zrealizowana=False).filter( + Zbiorka.typ_zbiorki != 'rezerwa' + ).all() + completed_zbiorki = Zbiorka.query.filter_by(zrealizowana=True).filter( + Zbiorka.typ_zbiorki != 'rezerwa' + ).all() + + return render_template( + "admin/dashboard.html", + active_zbiorki=active_zbiorki, + completed_zbiorki=completed_zbiorki, + ) + + @app.route("/admin/zbiorka/dodaj", methods=["GET", "POST"]) + @app.route("/admin/zbiorka/edytuj/", methods=["GET", "POST"]) + @login_required + def formularz_zbiorek(zbiorka_id=None): + if not current_user.czy_admin: + flash("Brak uprawnień", "danger") + return redirect(url_for("index")) + + is_edit = zbiorka_id is not None + zb = db.session.get(Zbiorka, zbiorka_id) if is_edit else None + if is_edit and zb is None: + abort(404) + + global_settings = UstawieniaGlobalne.query.first() + + def _temp_obj(): + t = zb or Zbiorka() + t.nazwa = (request.form.get("nazwa", "") or "").strip() + t.opis = (request.form.get("opis", "") or "").strip() + t.numer_konta = (request.form.get("numer_konta", "") or "").strip() + t.numer_telefonu_blik = (request.form.get("numer_telefonu_blik", "") or "").strip() + t.ukryj_kwote = "ukryj_kwote" in request.form + t.pokaz_postep_finanse = "pokaz_postep_finanse" in request.form + t.pokaz_postep_pozycje = "pokaz_postep_pozycje" in request.form + t.pokaz_postep_kwotowo = "pokaz_postep_kwotowo" in request.form + t.uzyj_konta = "uzyj_konta" in request.form + t.uzyj_blik = "uzyj_blik" in request.form + return t + + if request.method == "POST": + # Pola + nazwa = (request.form.get("nazwa", "") or "").strip() + opis = (request.form.get("opis", "") or "").strip() + numer_konta = (request.form.get("numer_konta", "") or "").strip() + numer_telefonu_blik = (request.form.get("numer_telefonu_blik", "") or "").strip() + + # Przełączniki płatności + uzyj_konta = "uzyj_konta" in request.form + uzyj_blik = "uzyj_blik" in request.form + + # Widoczność/metryki + ukryj_kwote = "ukryj_kwote" in request.form + pokaz_postep_finanse = "pokaz_postep_finanse" in request.form + pokaz_postep_pozycje = "pokaz_postep_pozycje" in request.form + pokaz_postep_kwotowo = "pokaz_postep_kwotowo" in request.form + + # Walidacje + if not nazwa: + flash("Nazwa jest wymagana", "danger") + return render_template("admin/formularz_zbiorek.html", zbiorka=_temp_obj(), global_settings=global_settings) + + if not opis: + flash("Opis jest wymagany", "danger") + return render_template("admin/formularz_zbiorek.html", zbiorka=_temp_obj(), global_settings=global_settings) + + # Co najmniej jeden kanał + if not (uzyj_konta or uzyj_blik): + flash("Włącz co najmniej jeden kanał wpłat (konto lub BLIK).", "danger") + return render_template("admin/formularz_zbiorek.html", zbiorka=_temp_obj(), global_settings=global_settings) + + # Warunkowe wartości + if uzyj_konta and not numer_konta: + flash("Numer konta jest wymagany (kanał przelewu włączony).", "danger") + return render_template("admin/formularz_zbiorek.html", zbiorka=_temp_obj(), global_settings=global_settings) + + if uzyj_blik and not numer_telefonu_blik: + flash("Numer telefonu BLIK jest wymagany (kanał BLIK włączony).", "danger") + return render_template("admin/formularz_zbiorek.html", zbiorka=_temp_obj(), global_settings=global_settings) + + # Cel > 0 + try: + cel = parse_amount(request.form.get("cel")) + if cel <= 0: + raise InvalidOperation + except (InvalidOperation, ValueError): + flash("Podano nieprawidłową wartość dla celu zbiórki", "danger") + return render_template("admin/formularz_zbiorek.html", zbiorka=_temp_obj(), global_settings=global_settings) + + # Produkty + names = request.form.getlist("item_nazwa[]") + links = request.form.getlist("item_link[]") + prices = request.form.getlist("item_cena[]") + + def _read_price(val: str): + try: + return parse_amount(val) + except InvalidOperation: + return None + + # zapis + if is_edit: + zb.nazwa = nazwa + zb.opis = opis + zb.uzyj_konta = uzyj_konta + zb.uzyj_blik = uzyj_blik + zb.numer_konta = numer_konta if uzyj_konta else "" + zb.numer_telefonu_blik = numer_telefonu_blik if uzyj_blik else "" + zb.cel = cel + zb.ukryj_kwote = ukryj_kwote + zb.pokaz_postep_finanse = pokaz_postep_finanse + zb.pokaz_postep_pozycje = pokaz_postep_pozycje + zb.pokaz_postep_kwotowo = pokaz_postep_kwotowo + + istniejace = list(zb.przedmioty) + + # UPDATE pierwsze N produktów + for i in range(min(len(names), len(istniejace))): + name = (names[i] or "").strip() + if not name: + continue + link = (links[i] if i < len(links) else "").strip() or None + cena_val = _read_price(prices[i] if i < len(prices) else "") + kupione_val = request.form.get(f"item_kupione_val_{i}") == "1" + + p = istniejace[i] + p.nazwa = name + p.link = link + p.cena = cena_val + p.kupione = kupione_val + + # DODAJ nowe produkty (więcej niż istnieje) + for i in range(len(istniejace), len(names)): + name = (names[i] or "").strip() + if not name: + continue + link = (links[i] if i < len(links) else "").strip() or None + cena_val = _read_price(prices[i] if i < len(prices) else "") + kupione_val = request.form.get(f"item_kupione_val_{i}") == "1" + + p = Przedmiot( + zbiorka_id=zb.id, + nazwa=name, + link=link, + cena=cena_val, + kupione=kupione_val + ) + db.session.add(p) + zb.przedmioty.append(p) + + # USUŃ nadmiarowe produkty + for i in range(len(names), len(istniejace)): + db.session.delete(istniejace[i]) + + db.session.commit() + flash("Zbiórka została zaktualizowana", "success") + + else: + nowa = Zbiorka( + nazwa=nazwa, + opis=opis, + uzyj_konta=uzyj_konta, + uzyj_blik=uzyj_blik, + numer_konta=(numer_konta if uzyj_konta else ""), + numer_telefonu_blik=(numer_telefonu_blik if uzyj_blik else ""), + cel=cel, + ukryj_kwote=ukryj_kwote, + pokaz_postep_finanse=pokaz_postep_finanse, + pokaz_postep_pozycje=pokaz_postep_pozycje, + pokaz_postep_kwotowo=pokaz_postep_kwotowo, + ) + db.session.add(nowa) + db.session.commit() + + for i, raw_name in enumerate(names): + name = (raw_name or "").strip() + if not name: + continue + link = (links[i] if i < len(links) else "").strip() or None + cena_val = _read_price(prices[i] if i < len(prices) else "") + kupione_val = request.form.get(f"item_kupione_val_{i}") == "1" + + przedmiot = Przedmiot( + nazwa=name, + link=link, + cena=cena_val, + kupione=kupione_val + ) + nowa.przedmioty.append(przedmiot) + + db.session.commit() + flash("Zbiórka została dodana", "success") + + return redirect(url_for("admin_dashboard")) + + + # GET + return render_template( + "admin/formularz_zbiorek.html", + zbiorka=zb, + global_settings=global_settings + ) + + + @app.route("/admin/zbiorka//wplata/dodaj", methods=["GET", "POST"]) + @login_required + def dodaj_wplate(zbiorka_id): + if not current_user.czy_admin: + flash("Brak uprawnień", "danger") + return redirect(url_for("index")) + + zb = db.session.get(Zbiorka, zbiorka_id) + if not zb: + abort(404) + + if request.method == "POST": + try: + kwota = parse_amount(request.form.get("kwota")) + if kwota <= 0: + raise InvalidOperation + except (InvalidOperation, ValueError): + flash("Nieprawidłowa kwota (musi być > 0)", "danger") + return redirect(url_for("dodaj_wplate", zbiorka_id=zbiorka_id)) + + opis = request.form.get("opis", "") + nowa_wplata = Wplata(zbiorka_id=zb.id, kwota=kwota, opis=opis) + zb.stan = (zb.stan or Decimal("0")) + kwota + db.session.add(nowa_wplata) + db.session.commit() + flash("Wpłata została dodana", "success") + + next_url = request.args.get("next") + return redirect(next_url or url_for("transakcje_zbiorki", zbiorka_id=zb.id)) + return render_template("admin/dodaj_wplate.html", zbiorka=zb) + + + @app.route("/admin/zbiorka//wplata//przesun", methods=["GET", "POST"]) + @login_required + def przesun_wplate(zbiorka_id, wplata_id): + if not current_user.czy_admin: + flash("Brak uprawnień", "danger") + return redirect(url_for("index")) + + zb_zrodlo = db.session.get(Zbiorka, zbiorka_id) + if zb_zrodlo is None: + abort(404) + + wplata = db.session.get(Wplata, wplata_id) + if wplata is None or wplata.zbiorka_id != zbiorka_id: + abort(404) + + if request.method == "POST": + zbiorka_cel_id = request.form.get("zbiorka_cel_id") + if not zbiorka_cel_id: + flash("Wybierz docelową zbiórkę", "danger") + return redirect(url_for("przesun_wplate", zbiorka_id=zbiorka_id, wplata_id=wplata_id)) + + zb_cel = db.session.get(Zbiorka, int(zbiorka_cel_id)) + if zb_cel is None: + flash("Docelowa zbiórka nie istnieje", "danger") + return redirect(url_for("przesun_wplate", zbiorka_id=zbiorka_id, wplata_id=wplata_id)) + + if zb_zrodlo.stan < wplata.kwota: + flash("Niewystarczające środki w źródłowej zbiórce", "danger") + return redirect(url_for("przesun_wplate", zbiorka_id=zbiorka_id, wplata_id=wplata_id)) + + opis_dodatkowy = request.form.get("opis", "").strip() + + if opis_dodatkowy: + opis_przesuniecia = f"Przesunięcie wpłaty: {wplata.opis or 'bez opisu'} - {opis_dodatkowy}" + else: + opis_przesuniecia = f"Przesunięcie wpłaty: {wplata.opis or 'bez opisu'}" + + nowe_przesuniecie = Przesuniecie( + zbiorka_zrodlo_id=zb_zrodlo.id, + zbiorka_cel_id=zb_cel.id, + kwota=wplata.kwota, + opis=opis_przesuniecia, + wplata_id=wplata.id + ) + + zb_zrodlo.stan = (zb_zrodlo.stan or Decimal("0")) - wplata.kwota + zb_cel.stan = (zb_cel.stan or Decimal("0")) + wplata.kwota + wplata.zbiorka_id = zb_cel.id + db.session.add(nowe_przesuniecie) + db.session.commit() + + flash(f"Przesunięto wpłatę {wplata.kwota} PLN do zbiórki '{zb_cel.nazwa}'", "success") + + next_url = request.args.get("next") + return redirect(next_url or url_for("transakcje_zbiorki", zbiorka_id=zb_zrodlo.id)) + + dostepne_zbiorki = Zbiorka.query.filter(Zbiorka.id != zbiorka_id).all() + return render_template("admin/przesun_wplate.html", + zbiorka=zb_zrodlo, + wplata=wplata, + dostepne_zbiorki=dostepne_zbiorki) + + + + @app.route("/admin/zbiorka/usun/", methods=["POST"]) + @login_required + def usun_zbiorka(zbiorka_id): + if not current_user.czy_admin: + flash("Brak uprawnień", "danger") + return redirect(url_for("index")) + zb = db.session.get(Zbiorka, zbiorka_id) + if zb is None: + abort(404) + db.session.delete(zb) + db.session.commit() + flash("Zbiórka została usunięta", "success") + return redirect(url_for("admin_dashboard")) + + + @app.route("/admin/zbiorka/edytuj_stan/", methods=["GET", "POST"]) + @login_required + def edytuj_stan(zbiorka_id): + if not current_user.czy_admin: + flash("Brak uprawnień", "danger") + return redirect(url_for("index")) + zb = db.session.get(Zbiorka, zbiorka_id) + if zb is None: + abort(404) + if request.method == "POST": + try: + nowy_stan = parse_amount(request.form.get("stan")) + except (InvalidOperation, ValueError): + flash("Nieprawidłowa wartość kwoty", "danger") + return redirect(url_for("edytuj_stan", zbiorka_id=zbiorka_id)) + zb.stan = nowy_stan + db.session.commit() + flash("Stan zbiórki został zaktualizowany", "success") + return redirect(url_for("admin_dashboard")) + return render_template("admin/edytuj_stan.html", zbiorka=zb) + + + @app.route("/admin/zbiorka/zmien_widzialnosc/", methods=["POST"]) + @login_required + def zmien_widzialnosc(zbiorka_id): + if not current_user.czy_admin: + flash("Brak uprawnień", "danger") + return redirect(url_for("index")) + zb = db.session.get(Zbiorka, zbiorka_id) + if zb is None: + abort(404) + zb.ukryta = not zb.ukryta + db.session.commit() + flash("Zbiórka została " + ("ukryta" if zb.ukryta else "przywrócona"), "success") + return redirect(url_for("admin_dashboard")) + + + def create_admin_account(): + admin = Uzytkownik.query.filter_by(czy_admin=True).first() + if not admin: + main_admin = Uzytkownik( + uzytkownik=app.config["MAIN_ADMIN_USERNAME"], + czy_admin=True + ) + main_admin.set_password(app.config["MAIN_ADMIN_PASSWORD"]) + db.session.add(main_admin) + db.session.commit() + + + @app.after_request + def apply_headers(response): + if request.path.startswith("/static/"): + response.headers.pop("Content-Disposition", None) + response.headers["Vary"] = "Accept-Encoding" + response.headers["Cache-Control"] = app.config.get( + "CACHE_CONTROL_HEADER_STATIC" + ) + if app.config.get("USE_ETAGS", True) and "ETag" not in response.headers: + response.add_etag() + response.make_conditional(request) + return response + + if request.path == '/healthcheck': + response.headers['Cache-Control'] = 'no-store, no-cache' + response.headers.pop('ETag', None) + response.headers.pop('Vary', None) + return response + + path_norm = request.path.lstrip("/") + czy_admin = path_norm.startswith("admin/") or path_norm == "admin" + + if czy_admin: + if (response.mimetype or "").startswith("text/html"): + response.headers["Cache-Control"] = "no-store, no-cache" + response.headers.pop("ETag", None) + + return response + + if response.status_code in (301, 302, 303, 307, 308): + response.headers.pop("Vary", None) + return response + + if 400 <= response.status_code < 500: + response.headers["Cache-Control"] = "no-store" + response.headers["Content-Type"] = "text/html; charset=utf-8" + response.headers.pop("Vary", None) + elif 500 <= response.status_code < 600: + response.headers["Cache-Control"] = "no-store" + response.headers["Content-Type"] = "text/html; charset=utf-8" + response.headers["Retry-After"] = "120" + response.headers.pop("Vary", None) + else: + response.headers["Vary"] = "Cookie, Accept-Encoding" + default_cache = app.config.get("CACHE_CONTROL_HEADER") or "private, no-store" + response.headers["Cache-Control"] = default_cache + + if ( + app.config.get("BLOCK_BOTS", False) + and not czy_admin + and not request.path.startswith("/static/") + ): + cc_override = app.config.get("CACHE_CONTROL_HEADER") + if cc_override: + response.headers["Cache-Control"] = cc_override + response.headers["X-Robots-Tag"] = ( + app.config.get("ROBOTS_TAG") or "noindex, nofollow, nosnippet, noarchive" + ) + + return response + + + @app.route("/admin/ustawienia", methods=["GET", "POST"]) + @login_required + def admin_ustawienia(): + if not current_user.czy_admin: + flash("Brak uprawnień do panelu administracyjnego", "danger") + return redirect(url_for("index")) + + client_ip = get_real_ip() + settings = UstawieniaGlobalne.query.first() + + if request.method == "POST": + numer_konta = request.form.get("numer_konta") + numer_telefonu_blik = request.form.get("numer_telefonu_blik") + dozwolone_hosty_logowania = request.form.get("dozwolone_hosty_logowania") + logo_url = request.form.get("logo_url") + tytul_strony = request.form.get("tytul_strony") + typ_navbar = request.form.get("typ_navbar", "text") + typ_stopka = request.form.get("typ_stopka", "text") + stopka_text = request.form.get("stopka_text") or None + pokaz_logo_w_navbar = (typ_navbar == "logo") + kolejnosc_rezerwowych = request.form.get("kolejnosc_rezerwowych", "id") + + if settings is None: + settings = UstawieniaGlobalne( + numer_konta=numer_konta, + numer_telefonu_blik=numer_telefonu_blik, + dozwolone_hosty_logowania=dozwolone_hosty_logowania, + logo_url=logo_url, + tytul_strony=tytul_strony, + pokaz_logo_w_navbar=pokaz_logo_w_navbar, + typ_navbar=typ_navbar, + typ_stopka=typ_stopka, + stopka_text=stopka_text, + kolejnosc_rezerwowych=kolejnosc_rezerwowych, + ) + db.session.add(settings) + else: + settings.numer_konta = numer_konta + settings.numer_telefonu_blik = numer_telefonu_blik + settings.dozwolone_hosty_logowania = dozwolone_hosty_logowania + settings.logo_url = logo_url + settings.tytul_strony = tytul_strony + settings.pokaz_logo_w_navbar = pokaz_logo_w_navbar + settings.typ_navbar = typ_navbar + settings.typ_stopka = typ_stopka + settings.stopka_text = stopka_text + settings.kolejnosc_rezerwowych = kolejnosc_rezerwowych + + db.session.commit() + flash("Ustawienia globalne zostały zaktualizowane", "success") + return redirect(url_for("admin_dashboard")) + + return render_template("admin/ustawienia.html", settings=settings, client_ip=client_ip) + + + @app.route("/admin/zbiorka//wydatek/dodaj", methods=["GET", "POST"]) + @login_required + def dodaj_wydatek(zbiorka_id): + if not current_user.czy_admin: + flash("Brak uprawnień", "danger") + return redirect(url_for("index")) + + zb = db.session.get(Zbiorka, zbiorka_id) + if zb is None: + abort(404) + + if request.method == "POST": + try: + kwota = parse_amount(request.form.get("kwota")) + if kwota <= 0: + raise InvalidOperation + except (InvalidOperation, ValueError): + flash("Nieprawidłowa kwota (musi być > 0)", "danger") + return redirect(url_for("dodaj_wydatek", zbiorka_id=zbiorka_id)) + + opis = request.form.get("opis", "") + nowy_wydatek = Wydatek(zbiorka_id=zb.id, kwota=kwota, opis=opis) + zb.stan = (zb.stan or Decimal("0")) - kwota + db.session.add(nowy_wydatek) + db.session.commit() + flash("Wydatek został dodany", "success") + + next_url = request.args.get("next") + return redirect(next_url or url_for("transakcje_zbiorki", zbiorka_id=zb.id)) + + return render_template("admin/dodaj_wydatek.html", zbiorka=zb) + + + @app.route( + "/admin/zbiorka/oznacz/niezrealizowana/", + methods=["POST"], + endpoint="oznacz_niezrealizowana", + ) + @app.route( + "/admin/zbiorka/oznacz/zrealizowana/", + methods=["POST"], + endpoint="oznacz_zrealizowana", + ) + @login_required + def oznacz_zbiorka(zbiorka_id): + if not current_user.czy_admin: + flash("Brak uprawnień do wykonania tej operacji", "danger") + return redirect(url_for("index")) + + zb = db.session.get(Zbiorka, zbiorka_id) + if zb is None: + abort(404) + + if "niezrealizowana" in request.path: + zb.zrealizowana = False + msg = "Zbiórka została oznaczona jako niezrealizowana" + else: + zb.zrealizowana = True + msg = "Zbiórka została oznaczona jako zrealizowana" + + db.session.commit() + flash(msg, "success") + return redirect(url_for("admin_dashboard")) + + + @app.route("/robots.txt") + def robots(): + if app.config.get("BLOCK_BOTS", False): + robots_txt = "User-agent: *\nDisallow: /" + else: + robots_txt = "User-agent: *\nAllow: /" + return robots_txt, 200, {"Content-Type": "text/plain"} + + + @app.route("/admin/zbiorka//transakcje") + @login_required + def transakcje_zbiorki(zbiorka_id): + if not current_user.czy_admin: + flash("Brak uprawnień", "danger"); return redirect(url_for("index")) + + zb = db.session.get(Zbiorka, zbiorka_id) + if zb is None: + abort(404) + + aktywnosci = ( + [ + { + "typ": "wpłata", + "id": w.id, + "kwota": w.kwota, + "opis": w.opis, + "data": w.data, + "ukryta": bool(w.ukryta), + } + for w in zb.wplaty + ] + + + [ + { + "typ": "wydatek", + "id": x.id, + "kwota": x.kwota, + "opis": x.opis, + "data": x.data, + "ukryta": bool(x.ukryta), + } + for x in zb.wydatki + ] + ) + aktywnosci.sort(key=lambda a: a["data"], reverse=True) + return render_template("admin/transakcje.html", zbiorka=zb, aktywnosci=aktywnosci) + + + + @app.route("/admin/wplata//zapisz", methods=["POST"]) + @login_required + def zapisz_wplate(wplata_id): + if not current_user.czy_admin: + flash("Brak uprawnień", "danger"); return redirect(url_for("index")) + w = db.session.get(Wplata, wplata_id) + if w is None: + abort(404) + zb = w.zbiorka + try: + nowa_kwota = parse_amount(request.form.get("kwota")) + if nowa_kwota <= 0: + raise InvalidOperation + except (InvalidOperation, ValueError): + flash("Nieprawidłowa kwota (musi być > 0)", "danger") + return redirect(url_for("transakcje_zbiorki", zbiorka_id=zb.id)) + + delta = nowa_kwota - (w.kwota or Decimal("0")) + w.kwota = nowa_kwota + w.opis = request.form.get("opis", "") + zb.stan = (zb.stan or Decimal("0")) + delta + db.session.commit() + flash("Wpłata zaktualizowana", "success") + return redirect(url_for("transakcje_zbiorki", zbiorka_id=zb.id)) + + @app.post("/wplata//ukryj") + @login_required + def ukryj_wplate(wplata_id): + if not current_user.czy_admin: abort(403) + w = db.session.get(Wplata, wplata_id) + if not w: abort(404) + w.ukryta = True + db.session.commit() + flash("Wpłata ukryta.", "success") + return redirect(request.referrer or url_for("admin_dashboard")) + + @app.post("/wplata//odkryj") + @login_required + def odkryj_wplate(wplata_id): + if not current_user.czy_admin: abort(403) + w = db.session.get(Wplata, wplata_id) + if not w: abort(404) + w.ukryta = False + db.session.commit() + flash("Wpłata odkryta.", "success") + return redirect(request.referrer or url_for("admin_dashboard")) + + @app.post("/wydatek//ukryj") + @login_required + def ukryj_wydatek(wydatek_id): + if not current_user.czy_admin: abort(403) + w = db.session.get(Wydatek, wydatek_id) + if not w: abort(404) + w.ukryta = True + db.session.commit() + flash("Wydatek ukryty.", "success") + return redirect(request.referrer or url_for("admin_dashboard")) + + @app.post("/wydatek//odkryj") + @login_required + def odkryj_wydatek(wydatek_id): + if not current_user.czy_admin: abort(403) + w = db.session.get(Wydatek, wydatek_id) + if not w: abort(404) + w.ukryta = False + db.session.commit() + flash("Wydatek odkryty.", "success") + return redirect(request.referrer or url_for("admin_dashboard")) + + + @app.route("/admin/wplata//usun", methods=["POST"]) + @login_required + def usun_wplate(wplata_id): + if not current_user.czy_admin: + flash("Brak uprawnień", "danger"); return redirect(url_for("index")) + w = db.session.get(Wplata, wplata_id) + if w is None: + abort(404) + zb = w.zbiorka + zb.stan -= w.kwota + db.session.delete(w) + db.session.commit() + flash("Wpłata usunięta", "success") + return redirect(url_for("transakcje_zbiorki", zbiorka_id=zb.id)) + + + @app.route("/admin/wydatek//zapisz", methods=["POST"]) + @login_required + def zapisz_wydatek(wydatek_id): + if not current_user.czy_admin: + flash("Brak uprawnień", "danger"); return redirect(url_for("index")) + x = db.session.get(Wydatek, wydatek_id) + if x is None: + abort(404) + zb = x.zbiorka + try: + nowa_kwota = parse_amount(request.form.get("kwota")) + if nowa_kwota <= 0: + raise InvalidOperation + except (InvalidOperation, ValueError): + flash("Nieprawidłowa kwota (musi być > 0)", "danger") + return redirect(url_for("transakcje_zbiorki", zbiorka_id=zb.id)) + + delta = nowa_kwota - (x.kwota or Decimal("0")) + x.kwota = nowa_kwota + x.opis = request.form.get("opis", "") + zb.stan = (zb.stan or Decimal("0")) - delta + db.session.commit() + flash("Wydatek zaktualizowany", "success") + return redirect(url_for("transakcje_zbiorki", zbiorka_id=zb.id)) + + + @app.route("/admin/wydatek//usun", methods=["POST"]) + @login_required + def usun_wydatek(wydatek_id): + if not current_user.czy_admin: + flash("Brak uprawnień", "danger"); return redirect(url_for("index")) + x = db.session.get(Wydatek, wydatek_id) + if x is None: + abort(404) + zb = x.zbiorka + zb.stan += x.kwota + db.session.delete(x) + db.session.commit() + flash("Wydatek usunięty", "success") + return redirect(url_for("transakcje_zbiorki", zbiorka_id=zb.id)) + + + @app.route("/admin/zbiorka//przesuniecie/dodaj", methods=["GET", "POST"]) + @login_required + def dodaj_przesuniecie(zbiorka_id): + if not current_user.czy_admin: + flash("Brak uprawnień", "danger") + return redirect(url_for("index")) + + zb_zrodlo = db.session.get(Zbiorka, zbiorka_id) + if zb_zrodlo is None: + abort(404) + + if request.method == "POST": + try: + kwota = parse_amount(request.form.get("kwota")) + if kwota <= 0: + raise InvalidOperation + except (InvalidOperation, ValueError): + flash("Nieprawidłowa kwota (musi być > 0)", "danger") + return redirect(url_for("dodaj_przesuniecie", zbiorka_id=zbiorka_id)) + + zbiorka_cel_id = request.form.get("zbiorka_cel_id") + if not zbiorka_cel_id: + flash("Wybierz docelową zbiórkę", "danger") + return redirect(url_for("dodaj_przesuniecie", zbiorka_id=zbiorka_id)) + + zb_cel = db.session.get(Zbiorka, int(zbiorka_cel_id)) + if zb_cel is None: + flash("Docelowa zbiórka nie istnieje", "danger") + return redirect(url_for("dodaj_przesuniecie", zbiorka_id=zbiorka_id)) + + if zb_zrodlo.stan < kwota: + flash("Niewystarczające środki w źródłowej zbiórce", "danger") + return redirect(url_for("dodaj_przesuniecie", zbiorka_id=zbiorka_id)) + + opis = request.form.get("opis", "") + + nowe_przesuniecie = Przesuniecie( + zbiorka_zrodlo_id=zb_zrodlo.id, + zbiorka_cel_id=zb_cel.id, + kwota=kwota, + opis=opis + ) + + zb_zrodlo.stan = (zb_zrodlo.stan or Decimal("0")) - kwota + zb_cel.stan = (zb_cel.stan or Decimal("0")) + kwota + + db.session.add(nowe_przesuniecie) + db.session.commit() + + flash(f"Przesunięto {kwota} PLN do zbiórki '{zb_cel.nazwa}'", "success") + + next_url = request.args.get("next") + return redirect(next_url or url_for("transakcje_zbiorki", zbiorka_id=zb_zrodlo.id)) + + dostepne_zbiorki = Zbiorka.query.filter(Zbiorka.id != zbiorka_id).all() + return render_template("admin/dodaj_przesuniecie.html", zbiorka=zb_zrodlo, dostepne_zbiorki=dostepne_zbiorki) + + + @app.route("/admin/rezerwy") + @login_required + def lista_rezerwowych(): + if not current_user.czy_admin: + flash("Brak uprawnień", "danger") + return redirect(url_for("index")) + + rezerwy = Zbiorka.query.filter_by(typ_zbiorki="rezerwa").all() + return render_template("admin/lista_rezerwowych.html", rezerwy=rezerwy) + + + @app.route("/admin/rezerwa/dodaj", methods=["GET", "POST"]) + @login_required + def dodaj_rezerwe(): + if not current_user.czy_admin: + flash("Brak uprawnień", "danger") + return redirect(url_for("index")) + + if request.method == "POST": + nazwa = request.form.get("nazwa", "").strip() + if not nazwa: + flash("Nazwa jest wymagana", "danger") + global_settings = UstawieniaGlobalne.query.first() + return render_template("admin/formularz_rezerwy.html", zbiorka=None, global_settings=global_settings) + + opis = request.form.get("opis", "").strip() + global_settings = UstawieniaGlobalne.query.first() + + uzyj_konta = "uzyj_konta" in request.form + uzyj_blik = "uzyj_blik" in request.form + numer_konta = request.form.get("numer_konta", "").strip() + numer_telefonu_blik = request.form.get("numer_telefonu_blik", "").strip() + + if uzyj_konta and not numer_konta: + if global_settings and global_settings.numer_konta: + numer_konta = global_settings.numer_konta + + if uzyj_blik and not numer_telefonu_blik: + if global_settings and global_settings.numer_telefonu_blik: + numer_telefonu_blik = global_settings.numer_telefonu_blik + + nowa_rezerwa = Zbiorka( + nazwa=nazwa, + opis=opis, + cel=Decimal("0"), + stan=Decimal("0"), + typ_zbiorki="rezerwa", + ukryta=True, + ukryj_kwote=False, + pokaz_postep_finanse=False, + pokaz_postep_pozycje=False, + pokaz_postep_kwotowo=False, + uzyj_konta=uzyj_konta, + uzyj_blik=uzyj_blik, + numer_konta=numer_konta if uzyj_konta else "", + numer_telefonu_blik=numer_telefonu_blik if uzyj_blik else "" + ) + db.session.add(nowa_rezerwa) + db.session.commit() + + flash(f"Lista rezerwowa '{nazwa}' została utworzona", "success") + return redirect(url_for("lista_rezerwowych")) + + global_settings = UstawieniaGlobalne.query.first() + return render_template("admin/formularz_rezerwy.html", zbiorka=None, global_settings=global_settings) + + + @app.route("/admin/rezerwa/edytuj/", methods=["GET", "POST"]) + @login_required + def edytuj_rezerwe(rezerwa_id): + if not current_user.czy_admin: + flash("Brak uprawnień", "danger") + return redirect(url_for("index")) + + zb = db.session.get(Zbiorka, rezerwa_id) + if zb is None or zb.typ_zbiorki != "rezerwa": + abort(404) + + if request.method == "POST": + nazwa = request.form.get("nazwa", "").strip() + if not nazwa: + flash("Nazwa jest wymagana", "danger") + global_settings = UstawieniaGlobalne.query.first() + return render_template("admin/formularz_rezerwy.html", zbiorka=zb, global_settings=global_settings) + + opis = request.form.get("opis", "").strip() + + uzyj_konta = "uzyj_konta" in request.form + uzyj_blik = "uzyj_blik" in request.form + numer_konta = request.form.get("numer_konta", "").strip() + numer_telefonu_blik = request.form.get("numer_telefonu_blik", "").strip() + + zb.nazwa = nazwa + zb.opis = opis + zb.uzyj_konta = uzyj_konta + zb.uzyj_blik = uzyj_blik + zb.numer_konta = numer_konta if uzyj_konta else "" + zb.numer_telefonu_blik = numer_telefonu_blik if uzyj_blik else "" + + db.session.commit() + + flash(f"Lista rezerwowa '{nazwa}' została zaktualizowana", "success") + return redirect(url_for("lista_rezerwowych")) + + global_settings = UstawieniaGlobalne.query.first() + return render_template("admin/formularz_rezerwy.html", zbiorka=zb, global_settings=global_settings) + + @app.route("/admin/rezerwa/usun/", methods=["POST"]) + @login_required + def usun_rezerwe(rezerwa_id): + if not current_user.czy_admin: + flash("Brak uprawnień", "danger") + return redirect(url_for("index")) + + zb = db.session.get(Zbiorka, rezerwa_id) + if zb is None or zb.typ_zbiorki != "rezerwa": + abort(404) + + nazwa = zb.nazwa + db.session.delete(zb) + db.session.commit() + + flash(f"Lista rezerwowa '{nazwa}' została usunięta", "success") + return redirect(url_for("lista_rezerwowych")) + + + @app.route("/admin/statystyki") + @login_required + def admin_statystyki(): + if not current_user.czy_admin: + abort(403) + + from sqlalchemy import func, extract + from datetime import datetime, timedelta + + # ==================== PODSTAWOWE STATYSTYKI ==================== + total_wplaty = db.session.query(func.sum(Wplata.kwota)).filter(Wplata.ukryta == False).scalar() or 0 + total_wydatki = db.session.query(func.sum(Wydatek.kwota)).filter(Wydatek.ukryta == False).scalar() or 0 + bilans = total_wplaty - total_wydatki + + liczba_wplat = db.session.query(func.count(Wplata.id)).filter(Wplata.ukryta == False).scalar() or 0 + liczba_wydatkow = db.session.query(func.count(Wydatek.id)).filter(Wydatek.ukryta == False).scalar() or 0 + liczba_zbiorek = db.session.query(func.count(Zbiorka.id)).scalar() or 0 + + # Najwyższa wpłata + najwyzsza_wplata = db.session.query(Wplata).filter(Wplata.ukryta == False).order_by(Wplata.kwota.desc()).first() + + # Najwyższy wydatek + najwyzszy_wydatek = db.session.query(Wydatek).filter(Wydatek.ukryta == False).order_by(Wydatek.kwota.desc()).first() + + # Średnia wpłata i wydatek + srednia_wplata = total_wplaty / liczba_wplat if liczba_wplat > 0 else 0 + sredni_wydatek = total_wydatki / liczba_wydatkow if liczba_wydatkow > 0 else 0 + + # ==================== STATYSTYKI PRZESUNIĘĆ ==================== + total_przesuniec = db.session.query(func.sum(Przesuniecie.kwota)).filter(Przesuniecie.ukryta == False).scalar() or 0 + liczba_przesuniec = db.session.query(func.count(Przesuniecie.id)).filter(Przesuniecie.ukryta == False).scalar() or 0 + + # Top 5 źródeł przesunięć (zbiórki które najczęściej przekazują środki) + top_zrodla_przesuniec = db.session.query( + Zbiorka.nazwa, + func.count(Przesuniecie.id).label('liczba'), + func.sum(Przesuniecie.kwota).label('suma') + ).join(Przesuniecie, Przesuniecie.zbiorka_zrodlo_id == Zbiorka.id)\ + .filter(Przesuniecie.ukryta == False)\ + .group_by(Zbiorka.id, Zbiorka.nazwa)\ + .order_by(func.sum(Przesuniecie.kwota).desc())\ + .limit(5).all() + + # ==================== TOP 10 WPŁAT ==================== + top_10_wplat = db.session.query(Wplata)\ + .filter(Wplata.ukryta == False)\ + .order_by(Wplata.kwota.desc())\ + .limit(10).all() + + # ==================== AKTYWNOŚĆ CZASOWA ==================== + teraz = datetime.now() + rok_temu = teraz - timedelta(days=365) + miesiac_temu = teraz - timedelta(days=30) + tydzien_temu = teraz - timedelta(days=7) + + # Aktywność ostatnie 7 dni + wplaty_7dni = db.session.query( + func.count(Wplata.id).label('liczba'), + func.sum(Wplata.kwota).label('suma') + ).filter(Wplata.data >= tydzien_temu, Wplata.ukryta == False).first() + + wydatki_7dni = db.session.query( + func.count(Wydatek.id).label('liczba'), + func.sum(Wydatek.kwota).label('suma') + ).filter(Wydatek.data >= tydzien_temu, Wydatek.ukryta == False).first() + + # Aktywność ostatnie 30 dni + wplaty_30dni = db.session.query( + func.count(Wplata.id).label('liczba'), + func.sum(Wplata.kwota).label('suma') + ).filter(Wplata.data >= miesiac_temu, Wplata.ukryta == False).first() + + wydatki_30dni = db.session.query( + func.count(Wydatek.id).label('liczba'), + func.sum(Wydatek.kwota).label('suma') + ).filter(Wydatek.data >= miesiac_temu, Wydatek.ukryta == False).first() + + # ==================== STATYSTYKI MIESIĘCZNE (ostatnie 12 miesięcy) ==================== + wplaty_miesieczne = db.session.query( + extract('year', Wplata.data).label('rok'), + extract('month', Wplata.data).label('miesiac'), + func.sum(Wplata.kwota).label('suma'), + func.count(Wplata.id).label('liczba') + ).filter( + Wplata.data >= rok_temu, + Wplata.ukryta == False + ).group_by('rok', 'miesiac').order_by('rok', 'miesiac').all() + + wydatki_miesieczne = db.session.query( + extract('year', Wydatek.data).label('rok'), + extract('month', Wydatek.data).label('miesiac'), + func.sum(Wydatek.kwota).label('suma'), + func.count(Wydatek.id).label('liczba') + ).filter( + Wydatek.data >= rok_temu, + Wydatek.ukryta == False + ).group_by('rok', 'miesiac').order_by('rok', 'miesiac').all() + + przesuniecia_miesieczne = db.session.query( + extract('year', Przesuniecie.data).label('rok'), + extract('month', Przesuniecie.data).label('miesiac'), + func.sum(Przesuniecie.kwota).label('suma'), + func.count(Przesuniecie.id).label('liczba') + ).filter( + Przesuniecie.data >= rok_temu, + Przesuniecie.ukryta == False + ).group_by('rok', 'miesiac').order_by('rok', 'miesiac').all() + + # ==================== STATYSTYKI ROCZNE ==================== + wplaty_roczne = db.session.query( + extract('year', Wplata.data).label('rok'), + func.sum(Wplata.kwota).label('suma'), + func.count(Wplata.id).label('liczba') + ).filter( + Wplata.ukryta == False + ).group_by('rok').order_by('rok').all() + + wydatki_roczne = db.session.query( + extract('year', Wydatek.data).label('rok'), + func.sum(Wydatek.kwota).label('suma'), + func.count(Wydatek.id).label('liczba') + ).filter( + Wydatek.ukryta == False + ).group_by('rok').order_by('rok').all() + + # ==================== TOP 5 ZBIÓREK ==================== + top_zbiorki = db.session.query( + Zbiorka, + func.sum(Wplata.kwota).label('suma_wplat') + ).join(Wplata).filter( + Wplata.ukryta == False + ).group_by(Zbiorka.id).order_by(func.sum(Wplata.kwota).desc()).limit(5).all() + + return render_template( + "admin/statystyki.html", + # Podstawowe + total_wplaty=total_wplaty, + total_wydatki=total_wydatki, + bilans=bilans, + liczba_wplat=liczba_wplat, + liczba_wydatkow=liczba_wydatkow, + liczba_zbiorek=liczba_zbiorek, + najwyzsza_wplata=najwyzsza_wplata, + najwyzszy_wydatek=najwyzszy_wydatek, + srednia_wplata=srednia_wplata, + sredni_wydatek=sredni_wydatek, + # Przesunięcia + total_przesuniec=total_przesuniec, + liczba_przesuniec=liczba_przesuniec, + top_zrodla_przesuniec=top_zrodla_przesuniec, + # Top wpłaty + top_10_wplat=top_10_wplat, + # Aktywność czasowa + wplaty_7dni=wplaty_7dni, + wydatki_7dni=wydatki_7dni, + wplaty_30dni=wplaty_30dni, + wydatki_30dni=wydatki_30dni, + # Miesięczne + wplaty_miesieczne=wplaty_miesieczne, + wydatki_miesieczne=wydatki_miesieczne, + przesuniecia_miesieczne=przesuniecia_miesieczne, + # Roczne + wplaty_roczne=wplaty_roczne, + wydatki_roczne=wydatki_roczne, + # Top zbiórki + top_zbiorki=top_zbiorki + ) + + + @app.route("/favicon.ico") + def favicon(): + return "", 204 + + + @app.route("/healthcheck") + def healthcheck(): + header_token = request.headers.get("X-Internal-Check") + correct_token = app.config.get("HEALTHCHECK_TOKEN") + + if header_token != correct_token: + abort(404) + + db_ok = is_database_available() + response_data = { + "status": "OK" if db_ok else "DEGRADED", + "database": "up" if db_ok else "down", + } + + return response_data, 200 if db_ok else 503 + diff --git a/static/css/custom.css b/zbiorka_app/static/css/custom.css similarity index 100% rename from static/css/custom.css rename to zbiorka_app/static/css/custom.css diff --git a/static/js/admin_dashboard.js b/zbiorka_app/static/js/admin_dashboard.js similarity index 100% rename from static/js/admin_dashboard.js rename to zbiorka_app/static/js/admin_dashboard.js diff --git a/static/js/dodaj_wplate.js b/zbiorka_app/static/js/dodaj_wplate.js similarity index 100% rename from static/js/dodaj_wplate.js rename to zbiorka_app/static/js/dodaj_wplate.js diff --git a/static/js/dodaj_wydatek.js b/zbiorka_app/static/js/dodaj_wydatek.js similarity index 100% rename from static/js/dodaj_wydatek.js rename to zbiorka_app/static/js/dodaj_wydatek.js diff --git a/static/js/edytuj_stan.js b/zbiorka_app/static/js/edytuj_stan.js similarity index 100% rename from static/js/edytuj_stan.js rename to zbiorka_app/static/js/edytuj_stan.js diff --git a/static/js/formularz_rezerwy.js b/zbiorka_app/static/js/formularz_rezerwy.js similarity index 100% rename from static/js/formularz_rezerwy.js rename to zbiorka_app/static/js/formularz_rezerwy.js diff --git a/static/js/formularz_zbiorek.js b/zbiorka_app/static/js/formularz_zbiorek.js similarity index 100% rename from static/js/formularz_zbiorek.js rename to zbiorka_app/static/js/formularz_zbiorek.js diff --git a/static/js/kwoty_formularz.js b/zbiorka_app/static/js/kwoty_formularz.js similarity index 100% rename from static/js/kwoty_formularz.js rename to zbiorka_app/static/js/kwoty_formularz.js diff --git a/static/js/mde_custom.js b/zbiorka_app/static/js/mde_custom.js similarity index 100% rename from static/js/mde_custom.js rename to zbiorka_app/static/js/mde_custom.js diff --git a/static/js/produkty_formularz.js b/zbiorka_app/static/js/produkty_formularz.js similarity index 100% rename from static/js/produkty_formularz.js rename to zbiorka_app/static/js/produkty_formularz.js diff --git a/static/js/progress.js b/zbiorka_app/static/js/progress.js similarity index 100% rename from static/js/progress.js rename to zbiorka_app/static/js/progress.js diff --git a/static/js/przelaczniki_zabezpieczenie.js b/zbiorka_app/static/js/przelaczniki_zabezpieczenie.js similarity index 100% rename from static/js/przelaczniki_zabezpieczenie.js rename to zbiorka_app/static/js/przelaczniki_zabezpieczenie.js diff --git a/zbiorka_app/static/js/service-worker.js b/zbiorka_app/static/js/service-worker.js new file mode 100644 index 0000000..e69de29 diff --git a/static/js/sposoby_wplat.js b/zbiorka_app/static/js/sposoby_wplat.js similarity index 100% rename from static/js/sposoby_wplat.js rename to zbiorka_app/static/js/sposoby_wplat.js diff --git a/static/js/transakcje.js b/zbiorka_app/static/js/transakcje.js similarity index 100% rename from static/js/transakcje.js rename to zbiorka_app/static/js/transakcje.js diff --git a/static/js/ustawienia.js b/zbiorka_app/static/js/ustawienia.js similarity index 100% rename from static/js/ustawienia.js rename to zbiorka_app/static/js/ustawienia.js diff --git a/static/js/walidacja_logowanie.js b/zbiorka_app/static/js/walidacja_logowanie.js similarity index 100% rename from static/js/walidacja_logowanie.js rename to zbiorka_app/static/js/walidacja_logowanie.js diff --git a/static/js/walidacja_rejestracja.js b/zbiorka_app/static/js/walidacja_rejestracja.js similarity index 100% rename from static/js/walidacja_rejestracja.js rename to zbiorka_app/static/js/walidacja_rejestracja.js diff --git a/static/js/zbiorka.js b/zbiorka_app/static/js/zbiorka.js similarity index 100% rename from static/js/zbiorka.js rename to zbiorka_app/static/js/zbiorka.js diff --git a/templates/admin/dashboard.html b/zbiorka_app/templates/admin/dashboard.html similarity index 100% rename from templates/admin/dashboard.html rename to zbiorka_app/templates/admin/dashboard.html diff --git a/templates/admin/dodaj_przesuniecie.html b/zbiorka_app/templates/admin/dodaj_przesuniecie.html similarity index 100% rename from templates/admin/dodaj_przesuniecie.html rename to zbiorka_app/templates/admin/dodaj_przesuniecie.html diff --git a/templates/admin/dodaj_wplate.html b/zbiorka_app/templates/admin/dodaj_wplate.html similarity index 97% rename from templates/admin/dodaj_wplate.html rename to zbiorka_app/templates/admin/dodaj_wplate.html index 91ea8a4..2e9827e 100644 --- a/templates/admin/dodaj_wplate.html +++ b/zbiorka_app/templates/admin/dodaj_wplate.html @@ -100,5 +100,5 @@ {% endblock %} {% block extra_scripts %} {{ super() }} - + {% endblock %} diff --git a/templates/admin/dodaj_wydatek.html b/zbiorka_app/templates/admin/dodaj_wydatek.html similarity index 96% rename from templates/admin/dodaj_wydatek.html rename to zbiorka_app/templates/admin/dodaj_wydatek.html index 2722461..3efc89b 100644 --- a/templates/admin/dodaj_wydatek.html +++ b/zbiorka_app/templates/admin/dodaj_wydatek.html @@ -75,5 +75,5 @@ {% block extra_scripts %} {{ super() }} - + {% endblock %} \ No newline at end of file diff --git a/templates/admin/edytuj_stan.html b/zbiorka_app/templates/admin/edytuj_stan.html similarity index 98% rename from templates/admin/edytuj_stan.html rename to zbiorka_app/templates/admin/edytuj_stan.html index cdab018..365ad21 100644 --- a/templates/admin/edytuj_stan.html +++ b/zbiorka_app/templates/admin/edytuj_stan.html @@ -137,5 +137,5 @@ {% block extra_scripts %} {{ super() }} - + {% endblock %} diff --git a/templates/admin/formularz_rezerwy.html b/zbiorka_app/templates/admin/formularz_rezerwy.html similarity index 98% rename from templates/admin/formularz_rezerwy.html rename to zbiorka_app/templates/admin/formularz_rezerwy.html index 8da50f3..3b9dfc8 100644 --- a/templates/admin/formularz_rezerwy.html +++ b/zbiorka_app/templates/admin/formularz_rezerwy.html @@ -166,5 +166,5 @@ {% endblock %} {% block extra_scripts %} {{ super() }} - + {% endblock %} diff --git a/templates/admin/formularz_zbiorek.html b/zbiorka_app/templates/admin/formularz_zbiorek.html similarity index 96% rename from templates/admin/formularz_zbiorek.html rename to zbiorka_app/templates/admin/formularz_zbiorek.html index cd9830d..391dbee 100644 --- a/templates/admin/formularz_zbiorek.html +++ b/zbiorka_app/templates/admin/formularz_zbiorek.html @@ -349,10 +349,10 @@ {% block extra_scripts %} {{ super() }} - - - - - - + + + + + + {% endblock %} \ No newline at end of file diff --git a/templates/admin/lista_rezerwowych.html b/zbiorka_app/templates/admin/lista_rezerwowych.html similarity index 100% rename from templates/admin/lista_rezerwowych.html rename to zbiorka_app/templates/admin/lista_rezerwowych.html diff --git a/templates/admin/przesun_wplate.html b/zbiorka_app/templates/admin/przesun_wplate.html similarity index 100% rename from templates/admin/przesun_wplate.html rename to zbiorka_app/templates/admin/przesun_wplate.html diff --git a/templates/admin/statystyki.html b/zbiorka_app/templates/admin/statystyki.html similarity index 100% rename from templates/admin/statystyki.html rename to zbiorka_app/templates/admin/statystyki.html diff --git a/templates/admin/transakcje.html b/zbiorka_app/templates/admin/transakcje.html similarity index 99% rename from templates/admin/transakcje.html rename to zbiorka_app/templates/admin/transakcje.html index bb7822e..d115849 100644 --- a/templates/admin/transakcje.html +++ b/zbiorka_app/templates/admin/transakcje.html @@ -219,5 +219,5 @@ {% block extra_scripts %} {{ super() }} - + {% endblock %} diff --git a/templates/admin/ustawienia.html b/zbiorka_app/templates/admin/ustawienia.html similarity index 99% rename from templates/admin/ustawienia.html rename to zbiorka_app/templates/admin/ustawienia.html index 40974ba..f155ce9 100644 --- a/templates/admin/ustawienia.html +++ b/zbiorka_app/templates/admin/ustawienia.html @@ -219,5 +219,5 @@ {% block extra_scripts %} {{ super() }} - + {% endblock %} diff --git a/templates/base.html b/zbiorka_app/templates/base.html similarity index 95% rename from templates/base.html rename to zbiorka_app/templates/base.html index faf5d88..ad0c6d3 100644 --- a/templates/base.html +++ b/zbiorka_app/templates/base.html @@ -6,7 +6,7 @@ {% block title %}Aplikacja Zbiórek{% endblock %} - + {% block extra_head %}{% endblock %} @@ -85,7 +85,7 @@ - + {% block extra_scripts %}{% endblock %} diff --git a/zbiorka_app/templates/error.html b/zbiorka_app/templates/error.html new file mode 100644 index 0000000..8789618 --- /dev/null +++ b/zbiorka_app/templates/error.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} + +{% block title %}{{ error_code }} {{ error_name }}{% endblock %} + +{% block content %} +
+
+
+
+
{{ error_code }}
+

{{ error_name }}

+

{{ error_message }}

+ +
+
+
+
+{% endblock %} diff --git a/templates/index.html b/zbiorka_app/templates/index.html similarity index 100% rename from templates/index.html rename to zbiorka_app/templates/index.html diff --git a/templates/login.html b/zbiorka_app/templates/login.html similarity index 95% rename from templates/login.html rename to zbiorka_app/templates/login.html index 8fddeac..c31779e 100644 --- a/templates/login.html +++ b/zbiorka_app/templates/login.html @@ -51,5 +51,5 @@ {% endblock %} {% block extra_scripts %} - + {% endblock %} \ No newline at end of file diff --git a/templates/register.html b/zbiorka_app/templates/register.html similarity index 95% rename from templates/register.html rename to zbiorka_app/templates/register.html index 645e899..863341e 100644 --- a/templates/register.html +++ b/zbiorka_app/templates/register.html @@ -57,5 +57,5 @@ {% endblock %} {% block extra_scripts %} - + {% endblock %} \ No newline at end of file diff --git a/templates/zbiorka.html b/zbiorka_app/templates/zbiorka.html similarity index 98% rename from templates/zbiorka.html rename to zbiorka_app/templates/zbiorka.html index 8180ca7..4cb9824 100644 --- a/templates/zbiorka.html +++ b/zbiorka_app/templates/zbiorka.html @@ -409,6 +409,6 @@ {% block extra_scripts %} {{ super() }} - - + + {% endblock %} diff --git a/zbiorka_app/utils.py b/zbiorka_app/utils.py new file mode 100644 index 0000000..de7e7dd --- /dev/null +++ b/zbiorka_app/utils.py @@ -0,0 +1,291 @@ +import hashlib +import ipaddress +import os +import re +import socket +import threading +import time +from datetime import datetime, timezone +from decimal import Decimal, InvalidOperation +from functools import lru_cache +from pathlib import Path + +from flask import current_app, request, url_for +from sqlalchemy import text +from sqlalchemy.exc import OperationalError + +from .extensions import db +from .models import UstawieniaGlobalne, Uzytkownik + +try: + from zoneinfo import ZoneInfo +except ImportError: + from backports.zoneinfo import ZoneInfo + +LOCAL_TZ = ZoneInfo("Europe/Warsaw") +_DB_INIT_LOCK = threading.Lock() + + +def read_commit_and_date(filename="version.txt", root_path=None): + base = root_path or os.path.dirname(os.path.abspath(__file__)) + path = os.path.join(base, filename) + if not os.path.exists(path): + return None, None + + try: + commit = open(path, "r", encoding="utf-8").read().strip() + if commit: + commit = commit[:12] + except Exception: + commit = None + + try: + ts = os.path.getmtime(path) + date_str = datetime.fromtimestamp(ts).strftime("%Y.%m.%d") + except Exception: + date_str = None + + return date_str, commit + + +def set_sqlite_pragma(dbapi_connection, connection_record): + if dbapi_connection.__class__.__module__.startswith("sqlite3"): + try: + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + except Exception: + pass + + +def get_real_ip(): + headers = request.headers + cf_ip = headers.get("CF-Connecting-IP") + if cf_ip: + return cf_ip.split(",")[0].strip() + xff = headers.get("X-Forwarded-For") + if xff: + return xff.split(",")[0].strip() + x_real_ip = headers.get("X-Real-IP") + if x_real_ip: + return x_real_ip.strip() + return request.remote_addr + + +def is_allowed_ip(remote_ip, allowed_hosts_str): + if os.path.exists("emergency_access.txt"): + return True + + if not allowed_hosts_str or not allowed_hosts_str.strip(): + return False + + allowed_ips = set() + hosts = re.split(r"[\n,]+", allowed_hosts_str.strip()) + + for host in hosts: + host = host.strip() + if not host: + continue + + try: + allowed_ips.add(ipaddress.ip_address(host)) + continue + except ValueError: + pass + + try: + infos = socket.getaddrinfo(host, None) + for _, _, _, _, sockaddr in infos: + ip_str = sockaddr[0] + try: + allowed_ips.add(ipaddress.ip_address(ip_str)) + except ValueError: + continue + except Exception as exc: + current_app.logger.warning("Nie mozna rozwiazac hosta %s: %s", host, exc) + + try: + remote_ip_obj = ipaddress.ip_address(remote_ip) + except ValueError: + current_app.logger.warning("Nieprawidlowe IP klienta: %s", remote_ip) + return False + + is_allowed = remote_ip_obj in allowed_ips + current_app.logger.info("is_allowed_ip: %s -> %s (lista: %s)", remote_ip_obj, is_allowed, allowed_ips) + return is_allowed + + +def to_local(dt): + if dt is None: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(LOCAL_TZ) + + +def parse_amount(raw: str) -> Decimal: + if not raw or not str(raw).strip(): + raise InvalidOperation("empty amount") + norm = str(raw).replace(" ", "").replace("\u00A0", "").replace(",", ".").strip() + d = Decimal(norm) + if d <= 0: + raise InvalidOperation("amount must be > 0") + return d + + +def safe_db_rollback() -> None: + try: + db.session.rollback() + except Exception: + pass + + +def create_admin_account(force_reset_password: bool = False): + admin = Uzytkownik.query.filter_by(czy_admin=True).first() + if not admin: + admin = Uzytkownik( + uzytkownik=current_app.config["MAIN_ADMIN_USERNAME"], + czy_admin=True, + ) + admin.set_password(current_app.config["MAIN_ADMIN_PASSWORD"]) + db.session.add(admin) + db.session.commit() + return admin + + if force_reset_password: + admin.set_password(current_app.config["MAIN_ADMIN_PASSWORD"]) + db.session.commit() + return admin + + +def get_or_create_user(username: str, is_admin: bool = False) -> Uzytkownik: + user = Uzytkownik.query.filter_by(uzytkownik=username).first() + if user is None: + user = Uzytkownik(uzytkownik=username, czy_admin=is_admin) + db.session.add(user) + db.session.flush() + return user + + +def set_user_password(username: str, password: str, is_admin: bool | None = None) -> Uzytkownik: + user = Uzytkownik.query.filter_by(uzytkownik=username).first() + if user is None: + user = Uzytkownik(uzytkownik=username, czy_admin=bool(is_admin)) + db.session.add(user) + elif is_admin is not None: + user.czy_admin = bool(is_admin) + + user.set_password(password) + db.session.commit() + return user + + +def set_login_hosts(hosts: str | None) -> UstawieniaGlobalne: + settings = UstawieniaGlobalne.query.first() + if settings is None: + settings = UstawieniaGlobalne( + numer_konta="", + numer_telefonu_blik="", + ) + db.session.add(settings) + + settings.dozwolone_hosty_logowania = (hosts or "").strip() or None + db.session.commit() + return settings + + +def init_version(app): + root_path = os.path.dirname(app.root_path) + deploy_date, commit = read_commit_and_date("version.txt", root_path=root_path) + if not deploy_date: + deploy_date = datetime.now().strftime("%Y.%m.%d") + if not commit: + commit = "dev" + app.config["APP_VERSION"] = f"{deploy_date}+{commit}" + + +@lru_cache(maxsize=512) +def _md5_for_static_file(path_str: str, mtime_ns: int, size: int) -> str: + digest = hashlib.md5() + with open(path_str, "rb") as handle: + for chunk in iter(lambda: handle.read(65536), b""): + digest.update(chunk) + return digest.hexdigest()[:12] + + +def static_file_hash(filename: str) -> str: + static_root = Path(current_app.static_folder).resolve() + candidate = (static_root / filename).resolve() + + try: + candidate.relative_to(static_root) + except ValueError: + return "invalid" + + if not candidate.is_file(): + return "missing" + + stat = candidate.stat() + return _md5_for_static_file(str(candidate), stat.st_mtime_ns, stat.st_size) + + +def asset_url(filename: str) -> str: + return url_for("static", filename=filename, h=static_file_hash(filename)) + + +def is_database_available() -> bool: + try: + db.session.execute(text("SELECT 1")) + return True + except Exception: + safe_db_rollback() + return False + + +def ensure_database_ready(create_schema: bool = True, create_admin: bool = True) -> bool: + with _DB_INIT_LOCK: + try: + db.session.execute(text("SELECT 1")) + if create_schema: + db.create_all() + if create_admin: + create_admin_account() + current_app.extensions["database_ready"] = True + return True + except Exception: + safe_db_rollback() + current_app.extensions["database_ready"] = False + raise + + +def init_database_with_retry(app, max_attempts: int = 20, delay: int = 3, raise_on_failure: bool = True) -> bool: + last_error = None + with app.app_context(): + for attempt in range(1, max_attempts + 1): + try: + ensure_database_ready(create_schema=True, create_admin=True) + current_app.logger.info("Baza danych gotowa po probie %s/%s", attempt, max_attempts) + return True + except OperationalError as exc: + last_error = exc + current_app.logger.warning( + "Baza danych niedostepna (proba %s/%s): %s", + attempt, + max_attempts, + exc, + ) + except Exception as exc: + last_error = exc + current_app.logger.warning( + "Blad inicjalizacji bazy (proba %s/%s): %s", + attempt, + max_attempts, + exc, + ) + + if attempt < max_attempts: + time.sleep(delay) + + if raise_on_failure and last_error: + raise last_error + return False