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:
|
def _get_str(name: str, default: str) -> str:
|
||||||
return os.environ.get(name, default)
|
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:
|
class Config:
|
||||||
"""
|
"""
|
||||||
Konfiguracja aplikacji pobierana z ENV (z sensownymi domyślnymi wartościami).
|
Konfiguracja aplikacji pobierana z ENV (z sensownymi domyślnymi wartościami).
|
||||||
@@ -50,6 +57,12 @@ class Config:
|
|||||||
# (opcjonalnie) wyłącz warningi track_modifications
|
# (opcjonalnie) wyłącz warningi track_modifications
|
||||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
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")
|
HEALTHCHECK_TOKEN = _get_str("HEALTHCHECK_TOKEN", "healthcheck")
|
||||||
|
|
||||||
# Baza danych
|
# Baza danych
|
||||||
@@ -62,10 +75,17 @@ class Config:
|
|||||||
f"postgresql://{os.environ['DB_USER']}:{os.environ['DB_PASSWORD']}@"
|
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']}"
|
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":
|
elif DB_ENGINE == "mysql":
|
||||||
SQLALCHEMY_DATABASE_URI = (
|
SQLALCHEMY_DATABASE_URI = (
|
||||||
f"mysql+pymysql://{os.environ['DB_USER']}:{os.environ['DB_PASSWORD']}@"
|
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']}"
|
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:
|
else:
|
||||||
raise ValueError("Nieobsługiwany typ bazy danych.")
|
raise ValueError("Nieobsługiwany typ bazy danych.")
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import os
|
import os
|
||||||
from app import app, db, create_admin_account
|
|
||||||
from waitress import serve
|
from waitress import serve
|
||||||
|
|
||||||
if __name__ == '__main__':
|
from app import app, init_database_with_retry
|
||||||
with app.app_context():
|
|
||||||
db.create_all()
|
|
||||||
create_admin_account()
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
init_database_with_retry(app, raise_on_failure=False)
|
||||||
port = int(os.environ.get("APP_PORT", 8080))
|
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 %}
|
{% endblock %}
|
||||||
{% block extra_scripts %}
|
{% block extra_scripts %}
|
||||||
{{ super() }}
|
{{ 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 %}
|
{% endblock %}
|
||||||
@@ -75,5 +75,5 @@
|
|||||||
|
|
||||||
{% block extra_scripts %}
|
{% block extra_scripts %}
|
||||||
{{ super() }}
|
{{ 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 %}
|
{% endblock %}
|
||||||
@@ -137,5 +137,5 @@
|
|||||||
|
|
||||||
{% block extra_scripts %}
|
{% block extra_scripts %}
|
||||||
{{ super() }}
|
{{ 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 %}
|
{% endblock %}
|
||||||
@@ -166,5 +166,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block extra_scripts %}
|
{% block extra_scripts %}
|
||||||
{{ super() }}
|
{{ 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 %}
|
{% endblock %}
|
||||||
@@ -349,10 +349,10 @@
|
|||||||
{% block extra_scripts %}
|
{% block extra_scripts %}
|
||||||
{{ super() }}
|
{{ super() }}
|
||||||
<script src="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.js"></script>
|
<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="{{ asset_url('js/mde_custom.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/formularz_zbiorek.js') }}?v={{ APP_VERSION }}"></script>
|
<script src="{{ asset_url('js/formularz_zbiorek.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/produkty_formularz.js') }}?v={{ APP_VERSION }}"></script>
|
<script src="{{ asset_url('js/produkty_formularz.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/kwoty_formularz.js') }}?v={{ APP_VERSION }}"></script>
|
<script src="{{ asset_url('js/kwoty_formularz.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/przelaczniki_zabezpieczenie.js') }}?v={{ APP_VERSION }}"></script>
|
<script src="{{ asset_url('js/przelaczniki_zabezpieczenie.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/sposoby_wplat.js') }}?v={{ APP_VERSION }}"></script>
|
<script src="{{ asset_url('js/sposoby_wplat.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -219,5 +219,5 @@
|
|||||||
|
|
||||||
{% block extra_scripts %}
|
{% block extra_scripts %}
|
||||||
{{ super() }}
|
{{ super() }}
|
||||||
<script src="{{ url_for('static', filename='js/transakcje.js') }}?v={{ APP_VERSION }}"></script>
|
<script src="{{ asset_url('js/transakcje.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -219,5 +219,5 @@
|
|||||||
|
|
||||||
{% block extra_scripts %}
|
{% block extra_scripts %}
|
||||||
{{ super() }}
|
{{ super() }}
|
||||||
<script src="{{ url_for('static', filename='js/ustawienia.js') }}?v={{ APP_VERSION }}"></script>
|
<script src="{{ asset_url('js/ustawienia.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
|
||||||
<title>{% block title %}Aplikacja Zbiórek{% endblock %}</title>
|
<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="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 %}
|
{% block extra_head %}{% endblock %}
|
||||||
</head>
|
</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="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 %}
|
{% block extra_scripts %}{% endblock %}
|
||||||
</body>
|
</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 %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_scripts %}
|
{% 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 %}
|
{% endblock %}
|
||||||
@@ -57,5 +57,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_scripts %}
|
{% 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 %}
|
{% endblock %}
|
||||||
@@ -409,6 +409,6 @@
|
|||||||
|
|
||||||
{% block extra_scripts %}
|
{% block extra_scripts %}
|
||||||
{{ super() }}
|
{{ super() }}
|
||||||
<script src="{{ url_for('static', filename='js/zbiorka.js') }}?v={{ APP_VERSION }}"></script>
|
<script src="{{ asset_url('js/zbiorka.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/progress.js') }}?v={{ APP_VERSION }}"></script>
|
<script src="{{ asset_url('js/progress.js') }}"></script>
|
||||||
{% endblock %}
|
{% 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