rozbicie na moduły, poprawki i komendy cli

This commit is contained in:
Mateusz Gruszczyński committed 2026-03-20 10:43:40 +01:00
1 parent c5295fa49c
commit bbfb3e0887
48 files changed
+2124 -1675

No files matched your search

-1
View File
@@ -1 +0,0 @@
deploy/app/Dockerfile
+18
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"]
+6 -1651
View File
File diff suppressed because it is too large. Load diff
+20
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.")
+4 -5
View File
@@ -1,11 +1,10 @@
import os
from app import app, db, create_admin_account
from waitress import serve
if __name__ == '__main__':
with app.app_context():
db.create_all()
create_admin_account()
from app import app, init_database_with_retry
if __name__ == '__main__':
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)
+43
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
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
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.")
+5
View File
@@ -0,0 +1,5 @@
from flask_login import LoginManager
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
login_manager = LoginManager()
+146
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)
File diff suppressed because it is too large. Load diff
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Whitespace-only changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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 %}
@@ -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 %}
@@ -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 %}
@@ -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 %}
@@ -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 %}
File renamed without changes.
@@ -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 %}
@@ -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 %}
@@ -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>
+21
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 %}
File renamed without changes.
@@ -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 %}
@@ -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 %}
@@ -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
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