rozbicie na moduły, poprawki i komendy cli
This commit is contained in:
@@ -1 +0,0 @@
|
||||
deploy/app/Dockerfile
|
||||
18
Dockerfile
Normal file
18
Dockerfile
Normal 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"]
|
||||
20
config.py
20
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.")
|
||||
@@ -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
43
zbiorka_app/__init__.py
Normal 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
79
zbiorka_app/cli.py
Normal 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
102
zbiorka_app/errors.py
Normal 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
zbiorka_app/extensions.py
Normal file
5
zbiorka_app/extensions.py
Normal 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
146
zbiorka_app/models.py
Normal 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
1371
zbiorka_app/routes.py
Normal file
File diff suppressed because it is too large
Load Diff
0
zbiorka_app/static/js/service-worker.js
Normal file
0
zbiorka_app/static/js/service-worker.js
Normal 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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
zbiorka_app/templates/error.html
Normal file
21
zbiorka_app/templates/error.html
Normal 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 %}
|
||||
@@ -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
zbiorka_app/utils.py
Normal file
291
zbiorka_app/utils.py
Normal 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
|
||||
Reference in New Issue
Block a user