rozbicie na moduły, poprawki i komendy cli
This commit is contained in:
1 parent
c5295fa49c
commit
bbfb3e0887
48 files changed
+2124
-1675
No files matched your search
@@ -1 +0,0 @@
|
||||
deploy/app/Dockerfile
|
||||
+18
@@ -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"]
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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.")
|
||||
@@ -0,0 +1,5 @@
|
||||
from flask_login import LoginManager
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
|
||||
db = SQLAlchemy()
|
||||
login_manager = LoginManager()
|
||||
@@ -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.
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.
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 %}
|
||||
+1
-1
@@ -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 %}
|
||||
+6
-6
@@ -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.
File renamed without changes.
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>
|
||||
@@ -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 %}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user