rozbicie na moduły, poprawki i komendy cli

This commit is contained in:
Mateusz Gruszczyński
2026-03-20 10:43:40 +01:00
parent c5295fa49c
commit bbfb3e0887
48 changed files with 2125 additions and 1676 deletions

View File

@@ -1 +0,0 @@
deploy/app/Dockerfile

18
Dockerfile Normal file
View File

@@ -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"]

1657
app.py

File diff suppressed because it is too large Load Diff

View File

@@ -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.")

View File

@@ -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)
serve(app, host="0.0.0.0", port=port)

43
zbiorka_app/__init__.py Normal file
View File

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

79
zbiorka_app/cli.py Normal file
View File

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

102
zbiorka_app/errors.py Normal file
View File

@@ -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"""<!doctype html>
<html lang=\"pl\">
<head>
<meta charset=\"utf-8\">
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
<title>{status_code} {escape(phrase)}</title>
</head>
<body>
<h1>{status_code} - {escape(phrase)}</h1>
<p>{escape(description)}</p>
</body>
</html>"""
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.")

View File

@@ -0,0 +1,5 @@
from flask_login import LoginManager
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
login_manager = LoginManager()

146
zbiorka_app/models.py Normal file
View File

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

1371
zbiorka_app/routes.py Normal file

File diff suppressed because it is too large Load Diff

View File

View File

@@ -100,5 +100,5 @@
{% endblock %}
{% block extra_scripts %}
{{ super() }}
<script src="{{ url_for('static', filename='js/dodaj_wplate.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ asset_url('js/dodaj_wplate.js') }}"></script>
{% endblock %}

View File

@@ -75,5 +75,5 @@
{% block extra_scripts %}
{{ super() }}
<script src="{{ url_for('static', filename='js/dodaj_wydatek.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ asset_url('js/dodaj_wydatek.js') }}"></script>
{% endblock %}

View File

@@ -137,5 +137,5 @@
{% block extra_scripts %}
{{ super() }}
<script src="{{ url_for('static', filename='js/edytuj_stan.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ asset_url('js/edytuj_stan.js') }}"></script>
{% endblock %}

View File

@@ -166,5 +166,5 @@
{% endblock %}
{% block extra_scripts %}
{{ super() }}
<script src="{{ url_for('static', filename='js/formularz_rezerwy.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ asset_url('js/formularz_rezerwy.js') }}"></script>
{% endblock %}

View File

@@ -349,10 +349,10 @@
{% block extra_scripts %}
{{ super() }}
<script src="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.js"></script>
<script src="{{ url_for('static', filename='js/mde_custom.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static', filename='js/formularz_zbiorek.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static', filename='js/produkty_formularz.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static', filename='js/kwoty_formularz.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static', filename='js/przelaczniki_zabezpieczenie.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static', filename='js/sposoby_wplat.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ asset_url('js/mde_custom.js') }}"></script>
<script src="{{ asset_url('js/formularz_zbiorek.js') }}"></script>
<script src="{{ asset_url('js/produkty_formularz.js') }}"></script>
<script src="{{ asset_url('js/kwoty_formularz.js') }}"></script>
<script src="{{ asset_url('js/przelaczniki_zabezpieczenie.js') }}"></script>
<script src="{{ asset_url('js/sposoby_wplat.js') }}"></script>
{% endblock %}

View File

@@ -219,5 +219,5 @@
{% block extra_scripts %}
{{ super() }}
<script src="{{ url_for('static', filename='js/transakcje.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ asset_url('js/transakcje.js') }}"></script>
{% endblock %}

View File

@@ -219,5 +219,5 @@
{% block extra_scripts %}
{{ super() }}
<script src="{{ url_for('static', filename='js/ustawienia.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ asset_url('js/ustawienia.js') }}"></script>
{% endblock %}

View File

@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<title>{% block title %}Aplikacja Zbiórek{% endblock %}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootswatch@5.3.0/dist/darkly/bootstrap.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/custom.css') }}?v={{ APP_VERSION }}" />
<link rel="stylesheet" href="{{ asset_url('css/custom.css') }}" />
{% block extra_head %}{% endblock %}
</head>
@@ -85,7 +85,7 @@
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="{{ url_for('static', filename='js/progress.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ asset_url('js/progress.js') }}"></script>
{% block extra_scripts %}{% endblock %}
</body>

View File

@@ -0,0 +1,21 @@
{% extends "base.html" %}
{% block title %}{{ error_code }} {{ error_name }}{% endblock %}
{% block content %}
<div class="row justify-content-center py-5">
<div class="col-12 col-md-10 col-lg-8">
<div class="card border-warning shadow-sm">
<div class="card-body p-4 p-md-5 text-center">
<div class="display-4 fw-bold mb-3">{{ error_code }}</div>
<h1 class="h3 mb-3">{{ error_name }}</h1>
<p class="lead mb-4">{{ error_message }}</p>
<div class="d-flex gap-2 justify-content-center flex-wrap">
<a href="{{ url_for('index') }}" class="btn btn-primary">Wroc na strone glowna</a>
<a href="javascript:history.back()" class="btn btn-outline-light">Wroc</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -51,5 +51,5 @@
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('static', filename='js/walidacja_logowanie.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ asset_url('js/walidacja_logowanie.js') }}"></script>
{% endblock %}

View File

@@ -57,5 +57,5 @@
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('static', filename='js/walidacja_rejestracja.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ asset_url('js/walidacja_rejestracja.js') }}"></script>
{% endblock %}

View File

@@ -409,6 +409,6 @@
{% block extra_scripts %}
{{ super() }}
<script src="{{ url_for('static', filename='js/zbiorka.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static', filename='js/progress.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ asset_url('js/zbiorka.js') }}"></script>
<script src="{{ asset_url('js/progress.js') }}"></script>
{% endblock %}

291
zbiorka_app/utils.py Normal file
View File

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