Compare commits

..

133 Commits

Author SHA1 Message Date
Mateusz Gruszczyński
5db242685b error handling 2026-03-20 11:03:58 +01:00
Mateusz Gruszczyński
be8d4b5a27 error handling 2026-03-20 10:59:51 +01:00
Mateusz Gruszczyński
29b07f6431 error handling 2026-03-20 10:54:01 +01:00
Mateusz Gruszczyński
bbfb3e0887 rozbicie na moduły, poprawki i komendy cli 2026-03-20 10:43:40 +01:00
Mateusz Gruszczyński
c5295fa49c fix healt in compose 2025-12-24 22:55:20 +01:00
Mateusz Gruszczyński
21434588fc naglowek i mod healthchecka 2025-12-24 22:29:31 +01:00
Mateusz Gruszczyński
b3c070fa85 naglowek i mod healthchecka 2025-12-24 22:24:47 +01:00
Mateusz Gruszczyński
50c4c56e72 naglowek i mod healthchecka 2025-12-24 22:22:38 +01:00
Mateusz Gruszczyński
a776e7c51e wizualne 2025-12-12 11:57:34 +01:00
Mateusz Gruszczyński
fe48f589f0 statystyki i optymalizacje 2025-12-12 09:23:34 +01:00
Mateusz Gruszczyński
20910fa898 typo 2025-12-11 23:00:12 +01:00
Mateusz Gruszczyński
ab19453c73 typo 2025-12-11 22:53:07 +01:00
Mateusz Gruszczyński
ff9283fd5f rezerwa ramka inny kolor 2025-12-11 22:44:16 +01:00
Mateusz Gruszczyński
276a0199f3 rezerwa ramka inny kolor 2025-12-11 22:35:54 +01:00
Mateusz Gruszczyński
c75505a79c rezerwa ramka inny kolor 2025-12-11 22:31:49 +01:00
Mateusz Gruszczyński
5db700f3b0 rezerwa bez progressu 2025-12-11 15:24:55 +01:00
Mateusz Gruszczyński
29ca46a5ef funkcje rezerw i przesuniec 2025-12-11 14:55:34 +01:00
Mateusz Gruszczyński
da2c2ca3bd funkcje rezerw i przesuniec 2025-12-11 14:16:14 +01:00
Mateusz Gruszczyński
19f0bbc438 funkcje rezerw i przesuniec 2025-12-11 13:55:49 +01:00
Mateusz Gruszczyński
2af3b7357c funkcje rezerw i przesuniec 2025-12-11 13:53:43 +01:00
Mateusz Gruszczyński
5220b8cf2c funkcje rezerw i przesuniec 2025-12-11 13:47:57 +01:00
Mateusz Gruszczyński
3e4d1ba78c poprawka w logowaniu 2025-12-10 10:05:41 +01:00
Mateusz Gruszczyński
8ee34d931d poprawka w logowaniu 2025-12-10 09:58:53 +01:00
root
562d8117c3 mniej logow z varnisha 2025-12-07 15:45:30 +01:00
root
f2d43e0b04 mniej logow z varnisha 2025-12-07 15:42:15 +01:00
root
d4253dd4a5 mniej logow z varnisha 2025-12-07 15:40:03 +01:00
Mateusz Gruszczyński
cad4f35997 poprwka sql 2025-12-07 14:43:57 +01:00
Mateusz Gruszczyński
e4bd4d1819 poprwka sql 2025-12-07 14:40:35 +01:00
Mateusz Gruszczyński
05e3265e2c poprwka sql 2025-12-07 14:36:12 +01:00
Mateusz Gruszczyński
236dbc8b16 poprwka sql 2025-12-07 14:29:38 +01:00
Mateusz Gruszczyński
ad42e44933 poprwka sql 2025-12-07 14:24:38 +01:00
Mateusz Gruszczyński
7d6fdaf03a poprwka sql 2025-12-07 14:22:03 +01:00
Mateusz Gruszczyński
95ed3180b2 parsowanie kwot 2025-12-07 14:16:17 +01:00
Mateusz Gruszczyński
91d83d91f0 parsowanie kwot 2025-12-07 14:10:33 +01:00
Mateusz Gruszczyński
c48cf7d92e parsowanie kwot 2025-12-07 14:08:16 +01:00
Mateusz Gruszczyński
b88080e5ac parsowanie kwot 2025-12-07 14:03:22 +01:00
gru
2c98d710f3 Update deploy/app/Dockerfile 2025-11-23 22:27:45 +01:00
Mateusz Gruszczyński
3be89fe9d8 ukrywanie akcji 2025-10-10 14:05:23 +02:00
Mateusz Gruszczyński
f6d2c9955c ukrywanie akcji 2025-10-10 14:04:28 +02:00
Mateusz Gruszczyński
15dd9d1fe9 ukrywanie akcji 2025-10-10 14:04:12 +02:00
Mateusz Gruszczyński
e1367f8bf2 ukrywanie akcji 2025-10-10 14:00:40 +02:00
Mateusz Gruszczyński
35a8d5dd8e ukrywanie akcji 2025-10-10 13:58:36 +02:00
Mateusz Gruszczyński
f9406a46cf wylaczenie sposob platnosci i porpawki ux 2025-10-10 13:30:13 +02:00
Mateusz Gruszczyński
1c69295b9b zmiany ux 2025-10-09 22:07:48 +02:00
Mateusz Gruszczyński
a4887f9c73 zmiany ux 2025-10-09 22:02:29 +02:00
Mateusz Gruszczyński
22b4c3d99a zmiany ux 2025-10-09 21:57:20 +02:00
Mateusz Gruszczyński
16ec180822 zmiany ux 2025-10-09 21:54:22 +02:00
Mateusz Gruszczyński
3bad07ce69 zmiany ux 2025-10-09 21:50:29 +02:00
Mateusz Gruszczyński
086784b4ef zmiany ux 2025-10-09 21:47:31 +02:00
Mateusz Gruszczyński
a4fa47e1a2 zmiany ux 2025-10-09 21:44:53 +02:00
Mateusz Gruszczyński
6709681dfd zmiany ux 2025-10-09 21:38:44 +02:00
Mateusz Gruszczyński
1e7f1e3990 zmiany ux 2025-10-09 21:36:11 +02:00
Mateusz Gruszczyński
d844d354fa zmiany ux 2025-10-09 21:33:22 +02:00
Mateusz Gruszczyński
4de192b865 zmiany ux 2025-10-09 21:27:59 +02:00
Mateusz Gruszczyński
90b9c8d462 zmiany ux 2025-10-09 20:32:49 +02:00
gru
ee2f498744 Update docker-compose.yml 2025-10-07 21:23:45 +02:00
gru
4a357ffd57 Update docker-compose.yml 2025-10-07 21:17:32 +02:00
gru
f78fe5369c Update docker-compose.yml 2025-10-07 21:12:55 +02:00
gru
69edf7844a Update docker-compose.yml 2025-10-07 21:09:29 +02:00
gru
1bedab4825 Update docker-compose.yml 2025-10-07 21:05:41 +02:00
gru
609a411824 Update deploy/varnish/default.vcl.template 2025-10-01 21:29:19 +02:00
root
a4fabcf4bd pgsql 18 2025-09-28 11:21:57 +02:00
Mateusz Gruszczyński
d44681ed0c rename script 2025-09-27 22:24:43 +02:00
Mateusz Gruszczyński
1d8a87216d rename script 2025-09-27 22:23:52 +02:00
Mateusz Gruszczyński
2f961db2ac rename script 2025-09-27 22:22:34 +02:00
Mateusz Gruszczyński
e79eebed17 python3.14 2025-09-27 21:31:07 +02:00
Mateusz Gruszczyński
c0daa93fe9 python3.1 2025-09-27 21:28:17 +02:00
Mateusz Gruszczyński
0d09abc4eb dockerfile 2025-09-26 23:36:44 +02:00
Mateusz Gruszczyński
413c80baa2 sitche_form 2025-09-26 23:32:47 +02:00
Mateusz Gruszczyński
0390a59008 sitche_form 2025-09-26 23:30:40 +02:00
Mateusz Gruszczyński
ee05bf74b5 sitche_form 2025-09-26 23:25:13 +02:00
Mateusz Gruszczyński
052439b340 sitche_form 2025-09-26 23:22:13 +02:00
Mateusz Gruszczyński
a673fe99f8 fix w formularzu 2025-09-26 23:19:57 +02:00
Mateusz Gruszczyński
fd46242cf5 fix w formularzu 2025-09-26 23:14:57 +02:00
Mateusz Gruszczyński
95b01665b9 fix w formularzu 2025-09-26 23:07:17 +02:00
Mateusz Gruszczyński
f7a99df93d fix w formularzu 2025-09-26 23:02:59 +02:00
Mateusz Gruszczyński
64ad6b4bbf multidb 2025-09-26 22:53:52 +02:00
Mateusz Gruszczyński
d7abf96080 multidb 2025-09-26 22:52:48 +02:00
Mateusz Gruszczyński
8887ed2c62 multidb 2025-09-26 22:46:23 +02:00
Mateusz Gruszczyński
dfa3028dad multidb 2025-09-26 22:42:10 +02:00
Mateusz Gruszczyński
b679700c6d multidb support 2025-09-26 22:03:09 +02:00
Mateusz Gruszczyński
eb9f11b2d6 alters 2025-09-26 13:59:10 +02:00
Mateusz Gruszczyński
6a1734024a spolszczenie wszystkiego i poprawki 2025-09-26 13:52:06 +02:00
Mateusz Gruszczyński
fdcfaff80e fix w dodawaniu zbiórki 2025-09-25 16:30:36 +02:00
Mateusz Gruszczyński
abb8ee0ae7 varnish reconfig 2025-09-25 10:31:41 +02:00
Mateusz Gruszczyński
37061442d2 varnish config 2025-09-25 09:22:16 +02:00
Mateusz Gruszczyński
113f9e385e varnish config 2025-09-25 09:18:58 +02:00
Mateusz Gruszczyński
34d3f3ae0c varnish throttle 2025-09-24 16:09:35 +02:00
Mateusz Gruszczyński
72b82ae40c varnish throttle 2025-09-24 16:06:57 +02:00
Mateusz Gruszczyński
9a7660fdf7 varnish throttle 2025-09-24 16:02:48 +02:00
Mateusz Gruszczyński
56c02e072d varnish throttle 2025-09-24 15:53:43 +02:00
Mateusz Gruszczyński
b45a259455 varnish add 2025-09-24 13:08:35 +02:00
Mateusz Gruszczyński
d0df1cdee9 varnish add 2025-09-24 13:05:23 +02:00
Mateusz Gruszczyński
71a2b33a7a varnish add 2025-09-24 12:55:50 +02:00
Mateusz Gruszczyński
16beaac932 varnish add 2025-09-24 12:54:01 +02:00
Mateusz Gruszczyński
eec3985c5a varnish add 2025-09-24 12:49:22 +02:00
Mateusz Gruszczyński
b80b92a5df varnish add 2025-09-24 12:46:12 +02:00
Mateusz Gruszczyński
9b2353c82a varnish add 2025-09-24 12:44:40 +02:00
Mateusz Gruszczyński
dec43f324e varnish add 2025-09-24 12:42:16 +02:00
Mateusz Gruszczyński
2b024e2b3a varnish add 2025-09-24 12:40:34 +02:00
Mateusz Gruszczyński
5c54ccfe83 varnish add 2025-09-24 12:37:42 +02:00
Mateusz Gruszczyński
1ff6eac9a2 varnish add 2025-09-24 12:34:38 +02:00
Mateusz Gruszczyński
7495a6baca varnish add 2025-09-24 12:32:48 +02:00
Mateusz Gruszczyński
f4ebcdb5b1 zmiany w ux 2025-09-24 12:20:33 +02:00
Mateusz Gruszczyński
4515a1b391 zmiany w ux 2025-09-24 10:55:04 +02:00
Mateusz Gruszczyński
8b6fb41a68 version_app 2025-09-23 12:52:23 +02:00
Mateusz Gruszczyński
6238cf9e4b version_app 2025-09-23 12:45:35 +02:00
Mateusz Gruszczyński
f2b7c8a64a version_app 2025-09-23 12:42:22 +02:00
Mateusz Gruszczyński
bbe318f995 zmiany ux i kodowe 2025-09-23 10:25:11 +02:00
Mateusz Gruszczyński
1a423a8b92 listy, i inne funkcje 2025-09-22 14:01:57 +02:00
Mateusz Gruszczyński
0b221696d4 opcje wydatkow w zbiorce 2025-09-20 16:19:30 +02:00
Mateusz Gruszczyński
b9e85ab5d4 fix typo 2025-09-15 16:50:25 +02:00
Mateusz Gruszczyński
b3904f25b9 fix typo 2025-09-15 16:46:39 +02:00
Mateusz Gruszczyński
31e65fe8a4 fi w compose 2025-08-28 14:04:14 +02:00
Mateusz Gruszczyński
f30668d987 drobne 2025-08-28 14:03:19 +02:00
Mateusz Gruszczyński
80038d6fb0 drobne 2025-08-28 14:00:43 +02:00
Mateusz Gruszczyński
b122c2b040 drobne 2025-08-28 14:00:33 +02:00
Mateusz Gruszczyński
ee877be74c drobne 2025-08-28 13:58:03 +02:00
Mateusz Gruszczyński
6f85dbf91c drobne 2025-08-28 13:56:00 +02:00
Mateusz Gruszczyński
62e40d5aee zmiany w logice i endpoitach 2025-08-28 12:42:57 +02:00
Mateusz Gruszczyński
d42ce7fcc4 serowwanie staticow fix 2025-08-28 11:55:00 +02:00
Mateusz Gruszczyński
248d1dcd84 pozycja stopki fix 2025-08-28 11:33:42 +02:00
Mateusz Gruszczyński
829142f1cc pozycja stopki fix 2025-08-28 11:31:43 +02:00
Mateusz Gruszczyński
b7ab11d124 logo w navbarze lub tekst 2025-08-28 11:28:22 +02:00
Mateusz Gruszczyński
2186c81264 poprawki w anonimizacji i env 2025-08-28 10:41:01 +02:00
Mateusz Gruszczyński
e9db945bb4 przebudowa systemu 2025-08-28 10:27:06 +02:00
Mateusz Gruszczyński
d71d33cfe0 zmiany w acl 2025-05-13 08:50:50 +02:00
Mateusz Gruszczyński
8d29328103 zmiany w acl 2025-05-13 08:33:10 +02:00
Mateusz Gruszczyński
748b4e47a2 zmiany w szablonach 2025-05-13 08:28:42 +02:00
Mateusz Gruszczyński
6935cefaf7 zmiany w acl 2025-05-13 08:25:23 +02:00
Mateusz Gruszczyński
1a62bbae2a zmiany w acl 2025-05-13 08:21:44 +02:00
Mateusz Gruszczyński
3d54f95a01 zmiany w acl 2025-05-13 07:57:56 +02:00
Mateusz Gruszczyński
9cc6730291 zmiany w acl 2025-05-13 07:41:40 +02:00
73 changed files with 7586 additions and 1177 deletions

67
.env.example Normal file
View File

@@ -0,0 +1,67 @@
# === Podstawowe ===
APP_PORT=8080
# healthcheck na potrzeby dockera
HEALTHCHECK_TOKEN=Bu22SW455TPe92
# SQLAlchemy URI bazy (np. SQLite, Postgres, MySQL).
# Przykłady:
# - SQLite w katalogu instance: sqlite:///instance/baza.db
# - SQLite w bieżącym katalogu: sqlite:///baza.db
# - Postgres: postgresql+psycopg2://user:pass@host:5432/dbname
# - MySQL: mysql+pymysql://user:pass@host:3306/dbname
DATABASE_URL=sqlite:///instance/baza.db
# Klucz sesji Flask (USTAW własną silną wartość w produkcji!)
SECRET_KEY=change_me_strong_secret
# === Rejestracja i admin ===
# Czy pozwalać na rejestrację przez formularz (True/False)
ALLOW_REGISTRATION=False
# Dane głównego admina (tworzonego automatycznie, jeśli brak w bazie)
MAIN_ADMIN_USERNAME=admin
MAIN_ADMIN_PASSWORD=admin
# === Indeksowanie / cache ===
# Blokuj boty (ustawia także X-Robots-Tag) (True/False)
BLOCK_BOTS=True
# Wartość nagłówka Cache-Control dla stron publicznych i elementóœ statycznych
CACHE_CONTROL_HEADER=max-age=600
CACHE_CONTROL_HEADER_STATIC=max-age=72000
USE_ETAGS=True
# Dodatkowe PRAGMA (opcjonalnie, jeśli chcesz dokładać własne)
PRAGMA_HEADER=
# Wartość nagłówka X-Robots-Tag, gdy BLOCK_BOTS=True
ROBOTS_TAG="noindex, nofollow, nosnippet, noarchive"
# Rodzaj bazy: sqlite, pgsql, mysql
# Mozliwe wartosci: sqlite / pgsql / mysql
DB_ENGINE=sqlite
# --- Konfiguracja dla sqlite ---
# Plik bazy bedzie utworzony automatycznie w katalogu ./instance
# Pozostale zmienne sa ignorowane przy DB_ENGINE=sqlite
# --- Konfiguracja dla pgsql ---
# Ustaw DB_ENGINE=pgsql
# Domyslny port PostgreSQL to 5432
# Wymaga dzialajacego serwera PostgreSQL (np. kontener `postgres`)
# --- Konfiguracja dla mysql ---
# Ustaw DB_ENGINE=mysql
# Domyslny port MySQL to 3306
# Wymaga kontenera z MySQL i uzytkownika z dostepem do bazy
# Wspolne zmienne (dla pgsql, mysql)
# DB_HOST = pgsql lub mysql zgodnie z deployem (profil w docker-compose.yml)
DB_HOST=pgsql
DB_PORT=5432
DB_NAME=myapp
DB_USER=user
DB_PASSWORD=pass

6
.gitignore vendored
View File

@@ -2,4 +2,8 @@ __pycache__
data/ data/
instance/ instance/
venv/ venv/
config.py .env
version.txt
deploy/varnish/default.vcl
*.tar.gz
db/*

View File

@@ -1,13 +1,18 @@
FROM python:3.13-slim #FROM python:3.13-slim
FROM python:3.14-slim
WORKDIR /app 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 COPY requirements.txt requirements.txt
RUN apt-get update && apt-get install -y build-essential && \
pip install --upgrade pip && pip install -r requirements.txt RUN pip install --upgrade pip
RUN pip install --no-cache-dir -r requirements.txt
COPY . . COPY . .
RUN mkdir -p /app/instance RUN mkdir -p /app/instance
EXPOSE 8080
CMD ["python", "run_waitress.py"] CMD ["python", "run_waitress.py"]

38
_tools/db/migrate.txt Normal file
View File

@@ -0,0 +1,38 @@
python3 -m venv venv_migrate
source venv_migrate/bin/activate
pip install sqlalchemy psycopg2-binary dotenv
docker compose --profile pgsql up -d --build
PYTHONPATH=. python3 _tools/db/migrate_sqlite_to_pgsql.py
rm -rf venv_migrate
# reset wszystkich sekwencji w pgsql
docker exec -it zbiorka-pgsql-db psql -U zbiorki -d zbiorki
DO $$
DECLARE
r RECORD;
BEGIN
FOR r IN
SELECT
c.relname AS seq_name,
t.relname AS table_name,
a.attname AS column_name
FROM
pg_class c
JOIN
pg_depend d ON d.objid = c.oid
JOIN
pg_class t ON d.refobjid = t.oid
JOIN
pg_attribute a ON a.attrelid = t.oid AND a.attnum = d.refobjsubid
WHERE
c.relkind = 'S'
AND d.deptype = 'a'
LOOP
EXECUTE format(
'SELECT setval(%L, COALESCE((SELECT MAX(%I) FROM %I), 1), true)',
r.seq_name, r.column_name, r.table_name
);
END LOOP;
END$$;

View File

@@ -0,0 +1,68 @@
import sys
import os
from dotenv import load_dotenv
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../")))
load_dotenv()
from sqlalchemy import create_engine, MetaData
from sqlalchemy.orm import sessionmaker
from config import Config
# Źródło: SQLite
sqlite_engine = create_engine("sqlite:///instance/baza.db")
sqlite_meta = MetaData()
sqlite_meta.reflect(bind=sqlite_engine)
# Cel: PostgreSQL
pg_engine = create_engine(Config.SQLALCHEMY_DATABASE_URI)
pg_meta = MetaData()
pg_meta.reflect(bind=pg_engine)
# Sesje
SQLiteSession = sessionmaker(bind=sqlite_engine)
PGSession = sessionmaker(bind=pg_engine)
sqlite_session = SQLiteSession()
pg_session = PGSession()
def migrate_table(table_name):
print("Używana baza docelowa:", Config.SQLALCHEMY_DATABASE_URI)
print(f"Migruję tabelę: {table_name}")
source_table = sqlite_meta.tables.get(table_name)
target_table = pg_meta.tables.get(table_name)
if source_table is None or target_table is None:
print(f"Pominięto: {table_name} (brak w jednej z baz)")
return
rows = sqlite_session.execute(source_table.select()).fetchall()
if not rows:
print("Brak danych do migracji.")
return
insert_data = [dict(row._mapping) for row in rows]
try:
with pg_engine.begin() as conn:
conn.execute(target_table.delete())
conn.execute(target_table.insert(), insert_data)
print(f"Przeniesiono: {len(rows)} rekordów")
except Exception as e:
print(f"Błąd przy migracji {table_name}: {e}")
def main():
tables = ["zbiorka", "przedmiot", "uzytkownik", "wydatek", "ustawienia_globalne", "wplata"]
for table in tables:
migrate_table(table)
print("\nMigracja zakończona pomyślnie.")
if __name__ == "__main__":
main()

View File

@@ -1,3 +1,6 @@
ALTER TABLE global_settings ADD COLUMN allowed_login_hosts TEXT; ALTER TABLE zbiorka ADD COLUMN typ_zbiorki VARCHAR(20) NOT NULL DEFAULT 'standardowa';
CREATE INDEX idx_zbiorka_typ ON zbiorka(typ_zbiorki);
ALTER TABLE zbiorka ADD COLUMN zrealizowana BOOLEAN DEFAULT 0;
ALTER TABLE ustawienia_globalne
ADD COLUMN kolejnosc_rezerwowych VARCHAR(20) NOT NULL DEFAULT 'id';

430
app.py
View File

@@ -1,427 +1,11 @@
from flask import Flask, render_template, request, redirect, url_for, flash from zbiorka_app import create_app
from flask_sqlalchemy import SQLAlchemy from zbiorka_app.extensions import db
from flask_login import LoginManager, login_user, login_required, logout_user, current_user, UserMixin from zbiorka_app.utils import create_admin_account, init_database_with_retry
from werkzeug.security import generate_password_hash, check_password_hash
from datetime import datetime
from markupsafe import Markup
import markdown as md
from flask import request, flash, abort
import os
import re
import socket
app = Flask(__name__) app = create_app()
# Ładujemy konfigurację z pliku config.py
app.config.from_object('config.Config')
db = SQLAlchemy(app) __all__ = ["app", "db", "create_admin_account", "init_database_with_retry"]
login_manager = LoginManager(app)
login_manager.login_view = 'login'
# MODELE if __name__ == "__main__":
init_database_with_retry(app)
class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
password_hash = db.Column(db.String(128), nullable=False)
is_admin = db.Column(db.Boolean, default=False) # Flaga głównego administratora
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_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=False)
numer_telefonu_blik = db.Column(db.String(50), nullable=False)
cel = db.Column(db.Float, nullable=False, default=0.0)
stan = db.Column(db.Float, default=0.0)
ukryta = db.Column(db.Boolean, default=False)
ukryj_kwote = db.Column(db.Boolean, default=False)
wplaty = db.relationship('Wplata', backref='zbiorka', lazy=True, order_by='Wplata.data.desc()')
zrealizowana = 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'), nullable=False)
kwota = db.Column(db.Float, nullable=False)
data = db.Column(db.DateTime, default=datetime.utcnow)
opis = db.Column(db.Text, nullable=True) # Opis wpłaty
class GlobalSettings(db.Model):
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)
allowed_login_hosts = db.Column(db.Text, nullable=True)
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
def get_real_ip():
if "CF-Connecting-IP" in request.headers:
return request.headers.get("CF-Connecting-IP")
elif "X-Real-IP" in request.headers:
return request.headers.get("X-Real-IP")
elif "X-Forwarded-For" in request.headers:
forwarded_for = request.headers.get("X-Forwarded-For").split(",")
return forwarded_for[0].strip()
return request.remote_addr
def is_allowed_ip(remote_ip, allowed_hosts_str):
if os.path.exists("emergency_access.txt"):
return True
allowed_hosts = re.split(r'[\n,]+', allowed_hosts_str.strip())
allowed_ips = set()
for host in allowed_hosts:
host = host.strip()
if not host:
continue
try:
resolved_ip = socket.gethostbyname(host)
allowed_ips.add(resolved_ip)
except Exception:
continue
# Log reverse DNS dla IP odwiedzającego
try:
hostname = socket.gethostbyaddr(remote_ip)[0]
app.logger.info(f"Odwiedzający IP: {remote_ip}, host: {hostname}")
except Exception as e:
app.logger.warning(f"Reverse DNS nieudane dla {remote_ip}: {e}")
return remote_ip in allowed_ips
# Dodaj filtr Markdown pozwala na zagnieżdżanie linków i obrazków w opisie
@app.template_filter('markdown')
def markdown_filter(text):
return Markup(md.markdown(text))
# TRASY PUBLICZNE
@app.route('/')
def index():
zbiorki = Zbiorka.query.filter_by(ukryta=False, zrealizowana=False).all()
return render_template('index.html', zbiorki=zbiorki)
@app.route('/zbiorki_zrealizowane')
def zbiorki_zrealizowane():
zbiorki = Zbiorka.query.filter_by(zrealizowana=True).all()
return render_template('index.html', zbiorki=zbiorki)
@app.errorhandler(404)
def page_not_found(e):
return redirect(url_for('index'))
@app.route('/zbiorka/<int:zbiorka_id>')
def zbiorka(zbiorka_id):
zb = Zbiorka.query.get_or_404(zbiorka_id)
# Jeżeli zbiórka jest ukryta i użytkownik nie jest administratorem, zwróć 404
if zb.ukryta and (not current_user.is_authenticated or not current_user.is_admin):
abort(404)
return render_template('zbiorka.html', zbiorka=zb)
# TRASY LOGOWANIA I REJESTRACJI
@app.route('/login', methods=['GET', 'POST'])
def login():
# Pobierz ustawienia globalne, w tym dozwolone hosty
settings = GlobalSettings.query.first()
allowed_hosts_str = ""
if settings and settings.allowed_login_hosts:
allowed_hosts_str = settings.allowed_login_hosts
# Sprawdzenie, czy adres IP klienta jest dozwolony
client_ip = get_real_ip()
if not is_allowed_ip(client_ip, allowed_hosts_str):
flash('Dostęp do endpointu /login jest zablokowany dla Twojego adresu IP', 'danger')
return redirect(url_for('index'))
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
user = User.query.filter_by(username=username).first()
if user and user.check_password(password):
login_user(user)
flash('Zalogowano pomyślnie', 'success')
next_page = request.args.get('next')
return redirect(next_page) if next_page else redirect(url_for('admin_dashboard'))
else:
flash('Nieprawidłowe dane logowania', 'danger')
return render_template('login.html')
@app.route('/logout')
@login_required
def logout():
logout_user()
flash('Wylogowano', 'success')
return redirect(url_for('login'))
@app.route('/register', methods=['GET', 'POST'])
def register():
if not app.config.get('ALLOW_REGISTRATION', False):
flash('Rejestracja została wyłączona przez administratora', 'danger')
return redirect(url_for('login'))
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
if User.query.filter_by(username=username).first():
flash('Użytkownik już istnieje', 'danger')
return redirect(url_for('register'))
new_user = User(username=username)
new_user.set_password(password)
db.session.add(new_user)
db.session.commit()
flash('Konto utworzone, możesz się zalogować', 'success')
return redirect(url_for('login'))
return render_template('register.html')
# PANEL ADMINISTRACYJNY
@app.route('/admin')
@login_required
def admin_dashboard():
if not current_user.is_admin:
flash('Brak uprawnień do panelu administracyjnego', 'danger')
return redirect(url_for('index'))
active_zbiorki = Zbiorka.query.filter_by(zrealizowana=False).all()
completed_zbiorki = Zbiorka.query.filter_by(zrealizowana=True).all()
return render_template('admin/dashboard.html', active_zbiorki=active_zbiorki,
completed_zbiorki=completed_zbiorki)
@app.route('/admin/zbiorka/dodaj', methods=['GET', 'POST'])
@login_required
def dodaj_zbiorka():
if not current_user.is_admin:
flash('Brak uprawnień', 'danger')
return redirect(url_for('index'))
global_settings = GlobalSettings.query.first() # Pobieramy globalne ustawienia
if request.method == 'POST':
nazwa = request.form['nazwa']
opis = request.form['opis']
# Pozyskujemy numer konta i telefon z formularza (mogą być nadpisane ręcznie)
numer_konta = request.form['numer_konta']
numer_telefonu_blik = request.form['numer_telefonu_blik']
cel = float(request.form['cel'])
ukryj_kwote = 'ukryj_kwote' in request.form
nowa_zbiorka = Zbiorka(
nazwa=nazwa,
opis=opis,
numer_konta=numer_konta,
numer_telefonu_blik=numer_telefonu_blik,
cel=cel,
ukryj_kwote=ukryj_kwote
)
db.session.add(nowa_zbiorka)
db.session.commit()
flash('Zbiórka została dodana', 'success')
return redirect(url_for('admin_dashboard'))
return render_template('admin/add_zbiorka.html', global_settings=global_settings)
@app.route('/admin/zbiorka/edytuj/<int:zbiorka_id>', methods=['GET', 'POST'])
@login_required
def edytuj_zbiorka(zbiorka_id):
if not current_user.is_admin:
flash('Brak uprawnień', 'danger')
return redirect(url_for('index'))
zb = Zbiorka.query.get_or_404(zbiorka_id)
global_settings = GlobalSettings.query.first() # Pobieramy globalne ustawienia
if request.method == 'POST':
zb.nazwa = request.form['nazwa']
zb.opis = request.form['opis']
zb.numer_konta = request.form['numer_konta']
zb.numer_telefonu_blik = request.form['numer_telefonu_blik']
try:
zb.cel = float(request.form['cel'])
except ValueError:
flash('Podano nieprawidłową wartość dla celu zbiórki', 'danger')
return render_template('admin/edit_zbiorka.html', zbiorka=zb, global_settings=global_settings)
zb.ukryj_kwote = 'ukryj_kwote' in request.form
db.session.commit()
flash('Zbiórka została zaktualizowana', 'success')
return redirect(url_for('admin_dashboard'))
return render_template('admin/edit_zbiorka.html', zbiorka=zb, global_settings=global_settings)
# TRASA DODAWANIA WPŁATY Z OPISEM
# TRASA DODAWANIA WPŁATY W PANELU ADMINA
@app.route('/admin/zbiorka/<int:zbiorka_id>/wplata/dodaj', methods=['GET', 'POST'])
@login_required
def admin_dodaj_wplate(zbiorka_id):
if not current_user.is_admin:
flash('Brak uprawnień', 'danger')
return redirect(url_for('index'))
zb = Zbiorka.query.get_or_404(zbiorka_id)
if request.method == 'POST':
kwota = float(request.form['kwota'])
opis = request.form.get('opis', '')
nowa_wplata = Wplata(zbiorka_id=zb.id, kwota=kwota, opis=opis)
zb.stan += kwota # Aktualizacja stanu zbiórki
db.session.add(nowa_wplata)
db.session.commit()
flash('Wpłata została dodana', 'success')
return redirect(url_for('admin_dashboard'))
return render_template('admin/add_wplata.html', zbiorka=zb)
@app.route('/admin/zbiorka/usun/<int:zbiorka_id>', methods=['POST'])
@login_required
def usun_zbiorka(zbiorka_id):
if not current_user.is_admin:
flash('Brak uprawnień', 'danger')
return redirect(url_for('index'))
zb = Zbiorka.query.get_or_404(zbiorka_id)
db.session.delete(zb)
db.session.commit()
flash('Zbiórka została usunięta', 'success')
return redirect(url_for('admin_dashboard'))
@app.route('/admin/zbiorka/edytuj_stan/<int:zbiorka_id>', methods=['GET', 'POST'])
@login_required
def edytuj_stan(zbiorka_id):
if not current_user.is_admin:
flash('Brak uprawnień', 'danger')
return redirect(url_for('index'))
zb = Zbiorka.query.get_or_404(zbiorka_id)
if request.method == 'POST':
try:
nowy_stan = float(request.form['stan'])
except ValueError:
flash('Nieprawidłowa wartość kwoty', 'danger')
return redirect(url_for('edytuj_stan', zbiorka_id=zbiorka_id))
zb.stan = nowy_stan
db.session.commit()
flash('Stan zbiórki został zaktualizowany', 'success')
return redirect(url_for('admin_dashboard'))
return render_template('admin/edytuj_stan.html', zbiorka=zb)
@app.route('/admin/zbiorka/toggle_visibility/<int:zbiorka_id>', methods=['POST'])
@login_required
def toggle_visibility(zbiorka_id):
if not current_user.is_admin:
flash('Brak uprawnień', 'danger')
return redirect(url_for('index'))
zb = Zbiorka.query.get_or_404(zbiorka_id)
zb.ukryta = not zb.ukryta
db.session.commit()
flash('Zbiórka została ' + ('ukryta' if zb.ukryta else 'przywrócona'), 'success')
return redirect(url_for('admin_dashboard'))
def create_admin_account():
admin = User.query.filter_by(is_admin=True).first()
if not admin:
main_admin = User(username=app.config['MAIN_ADMIN_USERNAME'], is_admin=True)
main_admin.set_password(app.config['MAIN_ADMIN_PASSWORD'])
db.session.add(main_admin)
db.session.commit()
@app.after_request
def add_security_headers(response):
if app.config.get("BLOCK_BOTS", False):
cache_control = app.config.get("CACHE_CONTROL_HEADER")
if cache_control:
response.headers["Cache-Control"] = cache_control
# Jeśli Cache-Control jest ustawiony, usuwamy Pragma
response.headers.pop("Pragma", None)
else:
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = app.config.get("PRAGMA_HEADER", "no-cache")
response.headers["X-Robots-Tag"] = app.config.get("ROBOTS_TAG", "noindex, nofollow, nosnippet, noarchive")
return response
@app.route('/admin/settings', methods=['GET', 'POST'])
@login_required
def admin_settings():
if not current_user.is_admin:
flash('Brak uprawnień do panelu administracyjnego', 'danger')
return redirect(url_for('index'))
client_ip = get_real_ip()
settings = GlobalSettings.query.first()
if request.method == 'POST':
numer_konta = request.form.get('numer_konta')
numer_telefonu_blik = request.form.get('numer_telefonu_blik')
allowed_login_hosts = request.form.get('allowed_login_hosts')
if settings is None:
settings = GlobalSettings(
numer_konta=numer_konta,
numer_telefonu_blik=numer_telefonu_blik,
allowed_login_hosts=allowed_login_hosts
)
db.session.add(settings)
else:
settings.numer_konta = numer_konta
settings.numer_telefonu_blik = numer_telefonu_blik
settings.allowed_login_hosts = allowed_login_hosts
db.session.commit()
flash('Ustawienia globalne zostały zaktualizowane', 'success')
return redirect(url_for('admin_dashboard'))
return render_template('admin/settings.html', settings=settings, client_ip=client_ip)
@app.route('/admin/zbiorka/oznacz/<int:zbiorka_id>', methods=['POST'])
@login_required
def oznacz_zbiorka(zbiorka_id):
if not current_user.is_admin:
flash('Brak uprawnień do wykonania tej operacji', 'danger')
return redirect(url_for('index'))
zb = Zbiorka.query.get_or_404(zbiorka_id)
zb.zrealizowana = True
db.session.commit()
flash('Zbiórka została oznaczona jako zrealizowana', 'success')
return redirect(url_for('admin_dashboard'))
@app.route('/robots.txt')
def robots():
if app.config.get("BLOCK_BOTS", False):
# Instrukcje dla robotów blokujemy indeksowanie całej witryny
robots_txt = "User-agent: *\nDisallow: /"
else:
# Jeśli blokowanie botów wyłączone, można zwrócić pusty plik lub inne ustawienia
robots_txt = "User-agent: *\nAllow: /"
return robots_txt, 200, {'Content-Type': 'text/plain'}
@app.route('/debug/headers')
def debug_headers():
ip_sources = {
"CF-Connecting-IP": request.headers.get("CF-Connecting-IP"),
"X-Real-IP": request.headers.get("X-Real-IP"),
"X-Forwarded-For": request.headers.get("X-Forwarded-For"),
"remote_addr": request.remote_addr,
}
all_headers = dict(request.headers)
response_html = "<h2>Nagłówki IP</h2><ul>"
for key, val in ip_sources.items():
response_html += f"<li><strong>{key}:</strong> {val}</li>"
response_html += "</ul><hr><h2>Wszystkie nagłówki</h2><ul>"
for key, val in all_headers.items():
response_html += f"<li><strong>{key}:</strong> {val}</li>"
response_html += "</ul>"
return response_html
if __name__ == '__main__':
with app.app_context():
db.create_all()
# Tworzenie konta głównego admina, jeśli nie istnieje
if not User.query.filter_by(is_admin=True).first():
main_admin = User(username=app.config['MAIN_ADMIN_USERNAME'], is_admin=True)
main_admin.set_password(app.config['MAIN_ADMIN_PASSWORD'])
db.session.add(main_admin)
db.session.commit()
app.run(debug=True) app.run(debug=True)

View File

@@ -1,14 +0,0 @@
# config.py
class Config:
SQLALCHEMY_DATABASE_URI = 'sqlite:///baza.db'
SECRET_KEY = 'tajny_klucz'
# Konfiguracja rejestracji i admina
ALLOW_REGISTRATION = False
MAIN_ADMIN_USERNAME = 'admin'
MAIN_ADMIN_PASSWORD = 'admin'
# Konfiguracja ochrony przed indeksowaniem
BLOCK_BOTS = True
CACHE_CONTROL_HEADER = "max-age=10"
ROBOTS_TAG = "noindex, nofollow, nosnippet, noarchive"

91
config.py Normal file
View File

@@ -0,0 +1,91 @@
import os
basedir = os.path.abspath(os.path.dirname(__file__))
def _get_bool(name: str, default: bool) -> bool:
val = os.environ.get(name)
if val is None:
return default
return str(val).strip().lower() in {"1", "true", "t", "yes", "y", "on"}
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).
Zmiennych szukamy pod nazwami:
- DATABASE_URL
- SECRET_KEY
- ALLOW_REGISTRATION
- MAIN_ADMIN_USERNAME
- MAIN_ADMIN_PASSWORD
- BLOCK_BOTS
- CACHE_CONTROL_HEADER
- PRAGMA_HEADER
- ROBOTS_TAG
"""
#SQLALCHEMY_DATABASE_URI = _get_str("DATABASE_URL", "sqlite:///baza.db")
# Flask
SECRET_KEY = _get_str("SECRET_KEY", "tajny_klucz")
# Rejestracja i konto admina
ALLOW_REGISTRATION = _get_bool("ALLOW_REGISTRATION", False)
MAIN_ADMIN_USERNAME = _get_str("MAIN_ADMIN_USERNAME", "admin")
MAIN_ADMIN_PASSWORD = _get_str("MAIN_ADMIN_PASSWORD", "admin")
# Indeksowanie / cache / robots
BLOCK_BOTS = _get_bool("BLOCK_BOTS", True)
CACHE_CONTROL_HEADER = _get_str("CACHE_CONTROL_HEADER", "max-age=600")
CACHE_CONTROL_HEADER_STATIC = _get_str("CACHE_CONTROL_HEADER_STATIC", "max-age=3600")
USE_ETAGS = _get_bool("USE_ETAGS", True)
PRAGMA_HEADER = _get_str("PRAGMA_HEADER", "")
ROBOTS_TAG = _get_str("ROBOTS_TAG", "noindex, nofollow, nosnippet, noarchive")
# (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
DB_ENGINE = os.environ.get("DB_ENGINE", "sqlite").lower()
if DB_ENGINE == "sqlite":
SQLALCHEMY_DATABASE_URI = f"sqlite:///{os.path.join(basedir, 'db', 'database.db')}"
elif DB_ENGINE == "pgsql":
SQLALCHEMY_DATABASE_URI = (
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.")

18
deploy/app/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
#FROM python:3.13-slim
FROM python:3.14-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
&& apt-get install -y build-essential \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt requirements.txt
RUN pip install --upgrade pip
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p /app/instance
CMD ["python", "run_waitress.py"]

46
deploy/varnish/Dockerfile Normal file
View File

@@ -0,0 +1,46 @@
# --- Stage 1: build varnish + modules ---
FROM debian:trixie-slim AS builder
ARG VARNISH_VERSION=8.0.0
ARG VARNISH_MODULES_VERSION=0.27.0
RUN apt-get update && apt-get install -y \
curl build-essential automake autoconf libtool pkg-config python3-sphinx \
git ca-certificates \
libpcre2-dev libedit-dev \
&& rm -rf /var/lib/apt/lists/*
# build varnish
RUN curl -fsSL https://varnish-cache.org/_downloads/varnish-${VARNISH_VERSION}.tgz -o varnish.tar.gz \
&& tar xzf varnish.tar.gz \
&& cd varnish-${VARNISH_VERSION} \
&& ./configure && make -j$(nproc) && make install \
&& cd .. && rm -rf varnish-${VARNISH_VERSION} varnish.tar.gz
# build varnish-modules
RUN curl -fsSL https://github.com/varnish/varnish-modules/releases/download/${VARNISH_MODULES_VERSION}/varnish-modules-${VARNISH_MODULES_VERSION}.tar.gz -o modules.tar.gz \
&& tar xzf modules.tar.gz \
&& cd varnish-modules-${VARNISH_MODULES_VERSION} \
&& ./configure && make -j$(nproc) && make install \
&& cd .. && rm -rf varnish-modules-${VARNISH_MODULES_VERSION} modules.tar.gz
# --- Stage 2: runtime ---
FROM debian:trixie-slim AS runtime
# tylko to co potrzebne do uruchomienia varnish
RUN apt-get update && apt-get install -y \
libpcre2-8-0 \
libedit2 \
ca-certificates \
curl \
&& rm -rf /var/lib/apt/lists/*
# kopiujemy binaria i moduły z buildera
COPY --from=builder /usr/local /usr/local
WORKDIR /etc/varnish
COPY default.vcl /etc/varnish/
EXPOSE 80
ENTRYPOINT ["varnishd", "-F", "-f", "/etc/varnish/default.vcl", "-s", "malloc,256m"]

View File

@@ -0,0 +1,264 @@
vcl 4.1;
import vsthrottle;
import std;
# ===== Backend =====
backend app {
.host = "app";
.port = "${APP_PORT}";
}
# ===== ACL =====
acl purge {
"127.0.0.1";
"::1";
}
# ===== RECV =====
sub vcl_recv {
# RATE LIMIT: 200 żądań / 10s, blokada 60s
if (vsthrottle.is_denied(client.identity, 200, 10s, 60s)) {
return (synth(429, "Too Many Requests"));
}
# PURGE tylko lokalnie
if (req.method == "PURGE") {
if (!client.ip ~ purge) { return (synth(405, "Not allowed")); }
return (purge);
}
# omijamy cache dla healthchecków / wewnętrznych nagłówków
if (req.url == "/healthcheck" || req.http.X-Internal-Check) { return (pass); }
# Specjalna obsługa WebSocket i socket.io
if (req.http.Upgrade ~ "(?i)websocket" || req.url ~ "^/socket.io/") {
return (pipe);
}
# metody inne niż GET/HEAD bez cache
if (req.method != "GET" && req.method != "HEAD") { return (pass); }
# Żądania z Authorization nie są buforowane
if (req.http.Authorization) { return (pass); }
# ---- Normalizacja Accept-Encoding (kolejność: zstd > br > gzip) ----
if (req.http.Accept-Encoding) {
if (req.http.Accept-Encoding ~ "zstd") {
set req.http.Accept-Encoding = "zstd";
} else if (req.http.Accept-Encoding ~ "br") {
set req.http.Accept-Encoding = "br";
} else if (req.http.Accept-Encoding ~ "gzip") {
set req.http.Accept-Encoding = "gzip";
} else {
set req.http.Accept-Encoding = "identity";
}
}
# ---- (Opcjonalnie) Normalizacja Accept dla obrazów generowanych wariantowo ----
# if (req.url ~ "\.(png|jpe?g|gif|bmp)$") {
# if (req.http.Accept ~ "image/webp") {
# set req.http.X-Accept-Image = "modern"; # webp
# } else {
# set req.http.X-Accept-Image = "legacy"; # jpg/png
# }
# }
# ---- STATYCZNE agresywny cache + ignorujemy sesję ----
if (req.url ~ "^/static/" || req.url ~ "\.(css|js|png|jpe?g|webp|svg|ico|woff2?)$") {
unset req.http.Cookie;
unset req.http.Authorization;
return (hash);
}
if (!req.http.X-Forwarded-Proto) {
set req.http.X-Forwarded-Proto = "https";
}
if (req.url == "/healthcheck" || req.http.X-Internal-Check) {
set req.http.X-Pass-Reason = "internal";
return (pass);
}
if (req.method != "GET" && req.method != "HEAD") {
set req.http.X-Pass-Reason = "method";
return (pass);
}
if (req.http.Authorization) {
set req.http.X-Pass-Reason = "auth";
return (pass);
}
# jeśli chcesz PASS przy cookie:
# if (req.http.Cookie) {
# set req.http.X-Pass-Reason = "cookie";
# return (pass);
# }
return (hash);
}
# ===== PIPE (WebSocket passthrough) =====
sub vcl_pipe {
if (req.http.Upgrade) {
set bereq.http.Upgrade = req.http.Upgrade;
set bereq.http.Connection = req.http.Connection;
}
}
# ===== HASH =====
sub vcl_hash {
hash_data(req.url);
if (req.http.host) { hash_data(req.http.host); } else { hash_data(server.ip); }
# Cookie: zostają dla dynamicznych (dla statyków wyczyszczone wcześniej)
if (req.http.Cookie) { hash_data(req.http.Cookie); }
# Accept-Encoding: już znormalizowany do zstd/br/gzip/identity
if (req.http.Accept-Encoding) { hash_data(req.http.Accept-Encoding); }
# (Opcjonalnie) sygnał obrazów z negocjacją po Accept
if (req.http.X-Accept-Image) { hash_data(req.http.X-Accept-Image); }
}
# ===== BACKEND_RESPONSE =====
sub vcl_backend_response {
# Zakaz cache respektujemy
if (beresp.http.Cache-Control ~ "(?i)no-store|private") {
set beresp.uncacheable = true;
set beresp.ttl = 0s;
set beresp.http.X-Pass-Reason = "no-store";
return (deliver);
}
# NIE cache'uj redirectów do loginu (HTML) z backendu
if (beresp.status >= 300 && beresp.status < 400) {
set beresp.uncacheable = true;
set beresp.ttl = 0s;
set beresp.http.X-Pass-Reason = "redirect";
return (deliver);
}
# Nie cache'uj statyków, jeśli status ≠ 200
if (bereq.url ~ "^/static/" ||
bereq.url ~ "\.(css|js|png|jpe?g|webp|svg|ico|woff2?)($|\?)") {
if (beresp.status != 200) {
set beresp.uncacheable = true;
set beresp.ttl = 0s;
return (deliver);
}
}
# Jeśli pod .js przychodzi text/html — też nie cache'uj (to zwykle redirect/login)
if (bereq.url ~ "\.js(\?.*)?$" && beresp.http.Content-Type ~ "(?i)text/html") {
set beresp.uncacheable = true;
set beresp.ttl = 0s;
return (deliver);
}
# Wymuś poprawny Content-Type dla .js/.css, gdy backend zwróci HTML
if (bereq.url ~ "\.js(\?.*)?$") {
if (!beresp.http.Content-Type || beresp.http.Content-Type ~ "(?i)text/html") {
set beresp.http.Content-Type = "application/javascript; charset=utf-8";
}
}
if (bereq.url ~ "\.css(\?.*)?$") {
if (!beresp.http.Content-Type || beresp.http.Content-Type ~ "(?i)text/html") {
set beresp.http.Content-Type = "text/css; charset=utf-8";
}
}
# ---- STATYCZNE: zdejmij Set-Cookie i Vary: Cookie, zapewnij TTL ----
if (bereq.url ~ "^/static/" || bereq.url ~ "\.(css|js|png|jpe?g|webp|svg|ico|woff2?)$") {
unset beresp.http.Set-Cookie;
# Jeśli backend dodał Vary: Cookie, usuńmy ten element (nie wpływa na statyki)
if (beresp.http.Vary) {
set beresp.http.Vary = regsuball(beresp.http.Vary, "(?i)(^|,)[[:space:]]*Cookie[[:space:]]*(,|$)", "\1");
set beresp.http.Vary = regsuball(beresp.http.Vary, ",[[:space:]]*,", ",");
set beresp.http.Vary = regsub(beresp.http.Vary, "^[[:space:]]*,[[:space:]]*", "");
set beresp.http.Vary = regsub(beresp.http.Vary, "[[:space:]]*,[[:space:]]*$", "");
if (beresp.http.Vary ~ "^[[:space:]]*$") { unset beresp.http.Vary; }
}
# Jeśli brak kontroli czasu życia ustawiamy twarde wartości
if (!(beresp.http.Cache-Control ~ "(?i)(s-maxage|max-age)")) {
set beresp.ttl = 24h;
set beresp.http.Cache-Control = "public, max-age=86400, immutable";
}
set beresp.grace = 1h;
set beresp.keep = 24h;
}
# ---- Ogólne TTL z nagłówków ----
if (beresp.http.Cache-Control ~ "(?i)s-maxage=([0-9]+)") {
set beresp.ttl = std.duration(regsub(beresp.http.Cache-Control, "(?i).*s-maxage=([0-9]+).*", "\1") + "s", 0s);
} else if (beresp.http.Cache-Control ~ "(?i)max-age=([0-9]+)") {
set beresp.ttl = std.duration(regsub(beresp.http.Cache-Control, "(?i).*max-age=([0-9]+).*", "\1") + "s", 0s);
} else if (beresp.http.Expires) {
set beresp.ttl = std.time(beresp.http.Expires, now) - now;
if (beresp.ttl < 0s) { set beresp.ttl = 0s; }
} else {
if (beresp.ttl <= 0s) { set beresp.ttl = 60s; }
}
# Immutable => dłuższe grace/keep
if (beresp.http.Cache-Control ~ "(?i)immutable") {
set beresp.grace = 1h;
set beresp.keep = 24h;
}
# Kompresja po stronie Varnisha wyłącznie dla klientów akceptujących gzip
# i tylko jeśli backend nie dostarczył już Content-Encoding.
if (!beresp.http.Content-Encoding && bereq.http.Accept-Encoding ~ "gzip") {
# Kompresujemy tylko „tekstowe” typy; wykluczamy WASM
if (beresp.http.Content-Type ~ "(?i)text/|application/(javascript|json|xml)") {
set beresp.do_gzip = true;
}
}
# Duże odpowiedzi streamujemy
if (beresp.http.Content-Length && std.integer(beresp.http.Content-Length, 0) > 1048576) {
set beresp.do_stream = true;
}
}
# (Opcjonalnie) Serwuj „stale” przy błędach backendu, jeśli jest obiekt w grace
sub vcl_backend_error {
return (deliver);
}
# ===== DELIVER =====
sub vcl_deliver {
if (obj.uncacheable) {
if (req.http.X-Pass-Reason) {
set resp.http.X-Cache = "PASS:" + req.http.X-Pass-Reason;
} else if (resp.http.X-Pass-Reason) { # z backendu
set resp.http.X-Cache = "PASS:" + resp.http.X-Pass-Reason;
} else {
set resp.http.X-Cache = "PASS";
}
unset resp.http.X-Pass-Reason;
unset resp.http.Age;
} else if (obj.hits > 0) {
set resp.http.X-Cache = "HIT";
} else {
set resp.http.X-Cache = "MISS";
unset resp.http.Age;
}
unset resp.http.Via;
unset resp.http.X-Varnish;
unset resp.http.Server;
}
sub vcl_synth {
set resp.http.X-Cache = "SYNTH";
}
# ===== PURGE HANDLER =====
sub vcl_purge {
return (synth(200, "Purged"));
}

86
deploy_docker.sh Executable file
View File

@@ -0,0 +1,86 @@
#!/usr/bin/env bash
set -euo pipefail
# --- Wczytaj zmienne z .env ---
if [[ -f .env ]]; then
set -a
source .env
set +a
fi
APP_PORT="${APP_PORT:-8080}"
REPO_DIR="${REPO_DIR:-$(pwd)}"
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}"
GIT_REMOTE="${GIT_REMOTE:-origin}"
GIT_BRANCH="${GIT_BRANCH:-$(git -C "$REPO_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || echo main)}"
# Domyślny profil
PROFILE="${1:-sqlite}"
if [[ "$PROFILE" != "pgsql" && "$PROFILE" != "mysql" && "$PROFILE" != "sqlite" ]]; then
echo "Użycie: $0 {pgsql|mysql|sqlite}"
exit 1
fi
SERVICES=()
if [[ $# -gt 1 ]]; then
SERVICES=("${@:2}")
fi
log() { printf "\n==> %s\n" "$*"; }
command -v git >/dev/null || { echo "Brak 'git' w PATH"; exit 1; }
command -v docker >/dev/null || { echo "Brak 'docker' w PATH"; exit 1; }
if ! docker compose version >/dev/null 2>&1; then
echo "Wymagany jest 'docker compose' (plugin), nie stary 'docker-compose'."
exit 1
fi
if [[ ! -f "$REPO_DIR/$COMPOSE_FILE" ]]; then
if [[ -f "$REPO_DIR/compose.yaml" ]]; then
COMPOSE_FILE="compose.yaml"
else
echo "Nie znaleziono pliku Compose w: $REPO_DIR/$COMPOSE_FILE"
exit 1
fi
fi
cd "$REPO_DIR"
log "Aktualizacja repo: git pull --ff-only ($GIT_REMOTE/$GIT_BRANCH)"
git fetch --prune "$GIT_REMOTE"
git checkout "$GIT_BRANCH" >/dev/null 2>&1 || true
git pull --ff-only "$GIT_REMOTE" "$GIT_BRANCH"
log "Zapisywanie hasha commita do version.txt"
git rev-parse --short HEAD > version.txt
log "Docker Compose DOWN"
docker compose --profile "$PROFILE" stop
log "Generowanie default.vcl z APP_PORT=$APP_PORT"
envsubst < deploy/varnish/default.vcl.template > deploy/varnish/default.vcl
# Tworzenie katalogów danych dla baz jeśli brak
if [[ "$PROFILE" == "pgsql" ]]; then
if [[ ! -d "./db/pgsql" ]]; then
log "Tworzę katalog ./db/pgsql dla danych PostgreSQL"
mkdir -p ./db/pgsql
fi
elif [[ "$PROFILE" == "mysql" ]]; then
if [[ ! -d "./db/mysql" ]]; then
log "Tworzę katalog ./db/mysql dla danych MySQL"
mkdir -p ./db/mysql
fi
fi
log "Docker Compose UP (build bez deps) dla profilu: $PROFILE i serwisów: ${SERVICES[*]:-(wszystkie)}"
if [[ ${#SERVICES[@]} -gt 0 ]]; then
DB_ENGINE="$PROFILE" docker compose -f "$COMPOSE_FILE" --profile "$PROFILE" up -d --no-deps --build "${SERVICES[@]}"
else
DB_ENGINE="$PROFILE" docker compose -f "$COMPOSE_FILE" --profile "$PROFILE" up -d --no-deps --build
fi
log "Gotowe ✅ (wersja: $(cat version.txt))"

View File

@@ -1,12 +1,80 @@
version: '3.8'
services: services:
app: app:
build: build: .
context: . container_name: zbiorka-app
dockerfile: Dockerfile #ports:
ports: # - "${APP_PORT:-8080}:${APP_PORT}"
- "8080:8080" expose:
- "${APP_PORT}"
healthcheck:
test:
[
"CMD",
"python",
"-c",
"import urllib.request; import sys; req = urllib.request.Request('http://localhost:${APP_PORT:-8080}/healthcheck', headers={'X-Internal-Check': '${HEALTHCHECK_TOKEN}'}); sys.exit(0) if urllib.request.urlopen(req).getcode() == 200 else sys.exit(1)",
]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
env_file:
- .env
volumes: volumes:
- ./instance:/app/instance - ./instance:/app/instance
networks:
- zbiorki_app_network
restart: unless-stopped restart: unless-stopped
varnish:
#build: ./deploy/varnish
image: varnish:latest
container_name: zbiorka-varnish
depends_on:
app:
condition: service_healthy
ports:
- "${APP_PORT:-8080}:80"
volumes:
- ./deploy/varnish/default.vcl:/etc/varnish/default.vcl:ro
environment:
- VARNISH_SIZE=256m
env_file:
- .env
networks:
- zbiorki_app_network
restart: unless-stopped
mysql:
image: mysql:8
container_name: zbiorka-mysql-db
environment:
MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASSWORD}
MYSQL_ROOT_PASSWORD: 89o38kUX5T4C
volumes:
- ./db/mysql:/var/lib/mysql
restart: unless-stopped
networks:
- zbiorki_app_network
profiles: ["mysql"]
pgsql:
image: postgres:18
container_name: zbiorka-pgsql-db
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
PGDATA: /var/lib/postgresql/
volumes:
- ./db/pgsql:/var/lib/postgresql
networks:
- zbiorki_app_network
restart: unless-stopped
profiles: ["pgsql"]
networks:
zbiorki_app_network:
driver: bridge

3
emergency_access.txt Normal file
View File

@@ -0,0 +1,3 @@
Jeśli ten plik istwnieje w katalogu apliakcji, to wylacza zebzpieczenie logowania do panelu admina z ograniczeniem IP.
Musi miec rozszerzenie .txt

View File

@@ -4,3 +4,6 @@ Flask-Login
Werkzeug Werkzeug
waitress waitress
markdown markdown
psycopg2-binary # pgsql
pymysql # mysql
cryptography # mysql8

View File

@@ -1,8 +1,10 @@
from app import app, db, create_admin_account import os
from waitress import serve from waitress import serve
from app import app, init_database_with_retry
if __name__ == '__main__': if __name__ == '__main__':
with app.app_context(): init_database_with_retry(app, raise_on_failure=False)
db.create_all() port = int(os.environ.get("APP_PORT", 8080))
create_admin_account() serve(app, host="0.0.0.0", port=port)
serve(app, host='0.0.0.0', port=8080)

View File

@@ -1,168 +0,0 @@
/* Import czcionki Roboto */
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap');
/* Globalne */
body {
font-family: 'Roboto', sans-serif;
background-color: #121212;
color: #dcdcdc;
padding-top: 1vh;
margin: 0;
}
/* Nawigacja */
.navbar {
background-color: #1c1c1c;
border-bottom: 1px solid #444;
transition: background-color 0.3s ease;
}
.navbar-brand {
color: #f5f5f5;
font-weight: bold;
transition: color 0.3s ease;
}
.nav-link {
color: #cccccc;
transition: color 0.3s ease;
}
.nav-link:hover {
color: #ffc107;
}
/* Karty */
.card {
background-color: #444242;
border: none;
border-radius: 0.5rem;
box-shadow: 0px 0px 8px rgba(0, 0, 0, 0.6);
margin-bottom: 20px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.card:hover {
transform: translateY(-5px);
box-shadow: 0px 3px 16px rgba(0, 0, 0, 0.8);
}
.card-header {
background-color: #272727;
border-bottom: 1px solid #444;
font-weight: bold;
}
.card-body {
background-color: #444242;
}
/* Przyciski */
.btn {
text-transform: uppercase;
font-weight: bold;
transition: background-color 0.3s ease, transform 0.2s ease;
}
.btn:hover {
transform: translateY(-2px);
}
.btn-primary {
background-color: #2d2c2c;
border-color: #ffeb3b;
color: #ffffff;
}
.btn-primary:hover {
background-color: #1e1e1e;
border-color: #ffc107;
}
/* Linki */
a {
color: #ffc107;
transition: color 0.3s ease;
}
a:hover {
color: #ffeb3b;
}
/* Progress Bar */
.progress {
background-color: #2a2a2a;
border-radius: 0.5rem;
height: 35px;
font-size: 1.2rem;
}
.progress-bar {
background: linear-gradient(90deg, #ffc107, #ffeb3b);
font-weight: bold;
transition: width 0.3s ease;
animation: progressAnimation 1s ease-in-out forwards;
}
@keyframes progressAnimation {
from { width: 0%; }
to { width: var(--progress-width); }
}
/* Alerty (flash messages) */
.alert {
opacity: 0;
animation: fadeIn 0.5s forwards;
margin-bottom: 1rem;
}
@keyframes fadeIn {
to { opacity: 1; }
}
/* Dodatkowe marginesy */
.container {
padding: 0 15px;
}
/* Responsywność */
@media (max-width: 767px) {
.card {
margin-bottom: 1rem;
}
.card-title {
font-size: 1.25rem;
}
.btn {
font-size: 0.9rem;
}
}
h1 { font-size: 2rem; margin-bottom: 1rem; }
h2 { font-size: 1.7rem; margin-bottom: 0.8rem; }
h3 { font-size: 0.9rem; margin-bottom: 0.6rem; }
/* Wspomóż */
.card.wspomoz-card {
border: 1px solid #ffc107 !important;
border-radius: 0.2rem !important;
}
.card.wspomoz-card .card-body,
.card.wspomoz-card .card-title,
.card.wspomoz-card .card-text {
color: #ffffff !important;
font-weight: bold;
font-size: 1.25rem;
}
.btn-primary:focus,
.btn-primary:active,
.btn-primary.focus,
.btn-primary.active {
background-color: #2d2c2c !important;
border-color: #ffeb3b !important;
color: #ffffff !important;
box-shadow: none;
outline: none;
}

View File

@@ -1,24 +0,0 @@
{% extends 'base.html' %}
{% block title %}Dodaj wpłatę{% endblock %}
{% block content %}
<div class="container my-4">
<div class="card shadow-sm">
<div class="card-header bg-warning text-dark">
<h3 class="card-title mb-0">Dodaj wpłatę do zbiórki: {{ zbiorka.nazwa }}</h3>
</div>
<div class="card-body">
<form method="post">
<div class="mb-3">
<label for="kwota" class="form-label">Kwota wpłaty (PLN)</label>
<input type="number" step="0.01" class="form-control" id="kwota" name="kwota" required>
</div>
<div class="mb-3">
<label for="opis" class="form-label">Opis wpłaty (opcjonalnie)</label>
<textarea class="form-control" id="opis" name="opis" rows="3"></textarea>
</div>
<button type="submit" class="btn btn-success">Dodaj wpłatę</button>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,51 +0,0 @@
{% extends 'base.html' %}
{% block title %}Dodaj zbiórkę{% endblock %}
{% block content %}
<div class="container my-4">
<div class="card shadow-sm">
<div class="card-header bg-success text-white">
<h3 class="card-title mb-0">Dodaj nową zbiórkę</h3>
</div>
<div class="card-body">
<form method="post">
<div class="mb-3">
<label for="nazwa" class="form-label">Nazwa zbiórki</label>
<input type="text" class="form-control" id="nazwa" name="nazwa" required>
</div>
<div class="mb-3">
<label for="opis" class="form-label">Opis</label>
<textarea class="form-control" id="opis" name="opis" rows="6" required></textarea>
</div>
<div class="mb-3">
<label for="numer_konta" class="form-label">Numer konta</label>
<input type="text" class="form-control" id="numer_konta" name="numer_konta" value="{{ global_settings.numer_konta if global_settings else '' }}" required>
</div>
<div class="mb-3">
<label for="numer_telefonu_blik" class="form-label">Numer telefonu BLIK</label>
<input type="text" class="form-control" id="numer_telefonu_blik" name="numer_telefonu_blik" value="{{ global_settings.numer_telefonu_blik if global_settings else '' }}" required>
</div>
<div class="mb-3">
<label for="cel" class="form-label">Cel zbiórki (PLN)</label>
<input type="number" step="0.01" class="form-control" id="cel" name="cel" required>
</div>
<div class="form-check mb-3">
<input type="checkbox" class="form-check-input" id="ukryj_kwote" name="ukryj_kwote">
<label class="form-check-label" for="ukryj_kwote">Ukryj kwoty (cel i stan)</label>
</div>
<button type="submit" class="btn btn-success">Dodaj zbiórkę</button>
</form>
</div>
</div>
</div>
<!-- Inicjalizacja edytora Markdown (SimpleMDE) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.css">
<script src="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.js"></script>
<script>
var simplemde = new SimpleMDE({
element: document.getElementById("opis"),
forceSync: true
});
</script>
{% endblock %}

View File

@@ -1,110 +0,0 @@
{% extends 'base.html' %}
{% block title %}Panel Admina{% endblock %}
{% block content %}
<div class="container my-4">
<h2 class="mb-4">Panel Admina</h2>
<div class="mb-3">
<a href="{{ url_for('dodaj_zbiorka') }}" class="btn btn-success">Dodaj zbiórkę</a>
<a href="{{ url_for('admin_settings') }}" class="btn btn-primary">Ustawienia</a>
</div>
<!-- Tabela zbiórek aktywnych -->
<h4>Aktywne zbiórki</h4>
<div class="table-responsive mb-5">
<table class="table table-dark table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Nazwa</th>
<th>Widoczność</th>
<th>Opcje</th>
</tr>
</thead>
<tbody>
{% for z in active_zbiorki %}
<tr>
<td>{{ z.id }}</td>
<td>{{ z.nazwa }}</td>
<td>
{% if z.ukryta %}
<span class="badge bg-secondary">Ukryta</span>
{% else %}
<span class="badge bg-success">Widoczna</span>
{% endif %}
</td>
<td>
<a href="{{ url_for('edytuj_zbiorka', zbiorka_id=z.id) }}" class="btn btn-primary btn-sm">Edytuj</a>
<a href="{{ url_for('admin_dodaj_wplate', zbiorka_id=z.id) }}" class="btn btn-warning btn-sm">Dodaj wpłatę</a>
<a href="{{ url_for('edytuj_stan', zbiorka_id=z.id) }}" class="btn btn-info btn-sm">Edytuj stan</a>
<!-- Przycisk do oznaczenia jako zrealizowana -->
<form action="{{ url_for('oznacz_zbiorka', zbiorka_id=z.id) }}" method="post" style="display: inline;">
<button type="submit" class="btn btn-warning btn-sm">Oznacz jako zrealizowana</button>
</form>
<form action="{{ url_for('toggle_visibility', zbiorka_id=z.id) }}" method="post" style="display: inline;">
<button type="submit" class="btn btn-secondary btn-sm">
{% if z.ukryta %} Pokaż {% else %} Ukryj {% endif %}
</button>
</form>
<form action="{{ url_for('usun_zbiorka', zbiorka_id=z.id) }}" method="post" style="display: inline;">
<button type="submit" class="btn btn-danger btn-sm" onclick="return confirm('Czy na pewno chcesz usunąć tę zbiórkę?');">Usuń</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="4" class="text-center">Brak aktywnych zbiórek</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Tabela zbiórek zrealizowanych -->
<h4>Zrealizowane zbiórki</h4>
<div class="table-responsive">
<table class="table table-dark table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Nazwa</th>
<th>Widoczność</th>
<th>Opcje</th>
</tr>
</thead>
<tbody>
{% for z in completed_zbiorki %}
<tr>
<td>{{ z.id }}</td>
<td>{{ z.nazwa }}</td>
<td>
{% if z.ukryta %}
<span class="badge bg-secondary">Ukryta</span>
{% else %}
<span class="badge bg-success">Widoczna</span>
{% endif %}
</td>
<td>
<a href="{{ url_for('edytuj_zbiorka', zbiorka_id=z.id) }}" class="btn btn-primary btn-sm">Edytuj</a>
<a href="{{ url_for('admin_dodaj_wplate', zbiorka_id=z.id) }}" class="btn btn-warning btn-sm">Dodaj wpłatę</a>
<a href="{{ url_for('edytuj_stan', zbiorka_id=z.id) }}" class="btn btn-info btn-sm">Edytuj stan</a>
<form action="{{ url_for('toggle_visibility', zbiorka_id=z.id) }}" method="post" style="display: inline;">
<button type="submit" class="btn btn-secondary btn-sm">
{% if z.ukryta %} Pokaż {% else %} Ukryj {% endif %}
</button>
</form>
<form action="{{ url_for('usun_zbiorka', zbiorka_id=z.id) }}" method="post" style="display: inline;">
<button type="submit" class="btn btn-danger btn-sm" onclick="return confirm('Czy na pewno chcesz usunąć tę zbiórkę?');">Usuń</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="4" class="text-center">Brak zbiórek zrealizowanych</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}

View File

@@ -1,67 +0,0 @@
{% extends 'base.html' %}
{% block title %}Edytuj zbiórkę{% endblock %}
{% block content %}
<div class="container my-4">
<div class="card shadow-sm">
<div class="card-header bg-success text-white">
<h3 class="card-title mb-0">Edytuj zbiórkę</h3>
</div>
<div class="card-body">
<form method="post">
<div class="mb-3">
<label for="nazwa" class="form-label">Nazwa zbiórki</label>
<input type="text" class="form-control" id="nazwa" name="nazwa" value="{{ zbiorka.nazwa }}" required>
</div>
<div class="mb-3">
<label for="opis" class="form-label">Opis</label>
<textarea class="form-control" id="opis" name="opis" rows="6" required>{{ zbiorka.opis }}</textarea>
</div>
<div class="mb-3">
<label for="numer_konta" class="form-label">Numer konta</label>
<input type="text" class="form-control" id="numer_konta" name="numer_konta"
value="{{ zbiorka.numer_konta if zbiorka.numer_konta else (global_settings.numer_konta if global_settings else '') }}" required>
</div>
<div class="mb-3">
<label for="numer_telefonu_blik" class="form-label">Numer telefonu BLIK</label>
<input type="text" class="form-control" id="numer_telefonu_blik" name="numer_telefonu_blik"
value="{{ zbiorka.numer_telefonu_blik if zbiorka.numer_telefonu_blik else (global_settings.numer_telefonu_blik if global_settings else '') }}" required>
</div>
<div class="mb-3">
<label for="cel" class="form-label">Cel zbiórki (PLN)</label>
<input type="number" step="0.01" class="form-control" id="cel" name="cel" value="{{ zbiorka.cel }}" required>
</div>
<div class="form-check mb-3">
<input type="checkbox" class="form-check-input" id="ukryj_kwote" name="ukryj_kwote" {% if zbiorka.ukryj_kwote %}checked{% endif %}>
<label class="form-check-label" for="ukryj_kwote">Ukryj kwoty (cel i stan)</label>
</div>
<button type="submit" class="btn btn-primary">Zaktualizuj zbiórkę</button>
<button type="button" class="btn btn-primary" id="ustaw-globalne">Ustaw globalne</button>
<form action="{{ url_for('oznacz_zbiorka', zbiorka_id=zbiorka.id) }}" method="post" style="display:inline;">
<button type="submit" class="btn btn-warning">Oznacz jako zrealizowana</button>
</form>
</form>
</div>
</div>
</div>
<!-- Inicjalizacja edytora Markdown (SimpleMDE) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.css">
<script src="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.js"></script>
<script>
var simplemde = new SimpleMDE({
element: document.getElementById("opis"),
forceSync: true
});
</script>
<script>
document.getElementById('ustaw-globalne').addEventListener('click', function() {
{% if global_settings %}
document.getElementById('numer_konta').value = "{{ global_settings.numer_konta }}";
document.getElementById('numer_telefonu_blik').value = "{{ global_settings.numer_telefonu_blik }}";
{% endif %}
});
</script>
{% endblock %}

View File

@@ -1,24 +0,0 @@
{% extends 'base.html' %}
{% block title %}Edytuj stan zbiórki{% endblock %}
{% block content %}
<div class="container my-4">
<div class="card shadow-sm">
<div class="card-header bg-info text-white">
<h3 class="card-title mb-0">Edytuj stan zbiórki: {{ zbiorka.nazwa }}</h3>
</div>
<div class="card-body">
<form method="post">
<div class="mb-3">
<label for="stan" class="form-label">Nowy stan zbiórki (PLN)</label>
<div class="input-group">
<span class="input-group-text">PLN</span>
<input type="number" step="0.01" class="form-control" id="stan" name="stan" value="{{ zbiorka.stan|round(2) }}" required>
</div>
</div>
<button type="submit" class="btn btn-info">Aktualizuj stan</button>
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-secondary">Powrót</a>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,55 +0,0 @@
{% extends 'base.html' %}
{% block title %}Ustawienia globalne{% endblock %}
{% block content %}
<div class="container my-4">
<form method="post">
<!-- Blok ustawień konta -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-primary text-white">
<h3 class="card-title mb-0">Ustawienia konta</h3>
</div>
<div class="card-body">
<div class="mb-3">
<label for="numer_konta" class="form-label">Globalny numer konta</label>
<input type="text" class="form-control" id="numer_konta" name="numer_konta" value="{{ settings.numer_konta if settings else '' }}" required>
</div>
<div class="mb-3">
<label for="numer_telefonu_blik" class="form-label">Globalny numer telefonu BLIK</label>
<input type="text" class="form-control" id="numer_telefonu_blik" name="numer_telefonu_blik" value="{{ settings.numer_telefonu_blik if settings else '' }}" required>
</div>
</div>
</div>
<!-- Blok dozwolonych adresów IP -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-secondary text-white d-flex justify-content-between align-items-center">
<h3 class="card-title mb-0">Dozwolone adresy IP</h3>
</div>
<div class="card-body">
<div class="mb-3">
<label for="allowed_login_hosts" class="form-label">Dozwolone hosty logowania</label>
<textarea class="form-control" id="allowed_login_hosts" name="allowed_login_hosts" rows="4" placeholder="Podaj adresy IP lub nazwy domen oddzielone przecinkami lub nowymi liniami">{{ settings.allowed_login_hosts if settings and settings.allowed_login_hosts else '' }}</textarea>
</div>
<p class="text-muted">Twój aktualny adres IP: <strong>{{ client_ip }}</strong></p>
<button type="button" class="btn btn-sm btn-light text-dark" onclick="dodajMojeIP()">Dodaj moje IP</button>
</div>
</div>
<div class="d-flex justify-content-between">
<button type="submit" class="btn btn-primary">Zapisz ustawienia</button>
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-secondary">Powrót</a>
</div>
</form>
</div>
<script>
function dodajMojeIP() {
const mojeIP = "{{ client_ip }}";
const textarea = document.getElementById("allowed_login_hosts");
if (!textarea.value.includes(mojeIP)) {
const separator = textarea.value.trim() === "" ? "" : "\n";
textarea.value += separator + mojeIP;
}
}
</script>
{% endblock %}

View File

@@ -1,60 +0,0 @@
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>{% block title %}Aplikacja Zbiórek{% endblock %}</title>
<!-- Bootswatch Darkly - atrakcyjny ciemny motyw -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootswatch@5.3.0/dist/darkly/bootstrap.min.css">
<!-- Opcjonalny plik custom.css dla drobnych modyfikacji -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/custom.css') }}">
</head>
<body class="bg-dark text-light">
<nav class="navbar navbar-expand-lg navbar-dark bg-secondary">
<div class="container">
<a class="navbar-brand" href="{{ url_for('index') }}">Zbiórki unitraklub.pl</a>
<div class="collapse navbar-collapse">
<ul class="navbar-nav ms-auto">
<li class="nav-item"><a class="nav-link" href="{{ url_for('index') }}">Aktualne zbiórki</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('zbiorki_zrealizowane') }}">Zrealizowane zbiórki</a></li>
{% if current_user.is_authenticated %}
<li class="nav-item"><a class="nav-link" href="{{ url_for('admin_dashboard') }}">Panel Admina</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('logout') }}">Wyloguj</a></li>
{% else %}
<li class="nav-item"><a class="nav-link" href="{{ url_for('login') }}">Zaloguj</a></li>
{% endif %}
</ul>
</div>
</div>
</nav>
<div class="container mt-4">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
// Funkcja aktualizująca animację paska postępu
function animateProgressBars() {
document.querySelectorAll('.progress-bar').forEach(bar => {
const progressValue = bar.getAttribute('aria-valuenow');
bar.style.setProperty('--progress-width', progressBarWidth(progressBarValue(progressBar)));
});
}
// Funkcja wywoływana przy ładowaniu strony
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.progress-bar').forEach(bar => {
const width = bar.getAttribute('aria-valuenow') + '%';
bar.style.setProperty('--progress-width', width);
});
});
</script>
</body>
</html>

View File

@@ -1,29 +0,0 @@
{% extends 'base.html' %}
{% block title %}{% if request.path == url_for('zbiorki_zrealizowane') %}Zrealizowane zbiórki{% else %}Aktualnie aktywne zbiórki{% endif %}{% endblock %}
{% block content %}
{% if request.path == url_for('zbiorki_zrealizowane') %}
<h2 class="mb-4">Zrealizowane zbiórki</h2>
{% else %}
<h2 class="mb-4">Aktualnie aktywne zbiórki</h2>
{% endif %}
<div class="row">
{% for z in zbiorki %}
<div class="col-sm-12 col-md-6 col-lg-4 mb-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">{{ z.nazwa }}</h5>
{% set progress = (z.stan / z.cel * 100) if z.cel > 0 else 0 %}
<div class="progress mb-3">
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: {{ progress if progress < 100 else 100 }}%;" aria-valuenow="{{ progress }}" aria-valuemin="0" aria-valuemax="100">
{{ progress|round(2) }}%
</div>
</div>
<a href="{{ url_for('zbiorka', zbiorka_id=z.id) }}" class="btn btn-primary btn-sm">Szczegóły</a>
</div>
</div>
</div>
{% else %}
<p>Brak zbiórek</p>
{% endfor %}
</div>
{% endblock %}

View File

@@ -1,19 +0,0 @@
{% extends 'base.html' %}
{% block title %}Logowanie{% endblock %}
{% block content %}
<div class="container my-4">
<h3 class="mb-4">Logowanie</h3>
<form method="post">
<div class="mb-3">
<label for="username" class="form-label">Nazwa użytkownika</label>
<input type="text" class="form-control" id="username" name="username" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Hasło</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<button type="submit" class="btn btn-primary">Zaloguj</button>
</form>
<p class="mt-3">Nie masz konta? <a href="{{ url_for('register') }}">Zarejestruj się</a></p>
</div>
{% endblock %}

View File

@@ -1,18 +0,0 @@
{% extends 'base.html' %}
{% block title %}Rejestracja{% endblock %}
{% block content %}
<div class="container my-4">
<h3 class="mb-4">Rejestracja</h3>
<form method="post">
<div class="mb-3">
<label for="username" class="form-label">Nazwa użytkownika</label>
<input type="text" class="form-control" id="username" name="username" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Hasło</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<button type="submit" class="btn btn-primary">Zarejestruj się</button>
</form>
</div>
{% endblock %}

View File

@@ -1,94 +0,0 @@
{% extends 'base.html' %}
{% block title %}{{ zbiorka.nazwa }}{% endblock %}
{% block content %}
<div class="container my-4">
<!-- Główna karta zbiórki -->
<div class="card mb-4 shadow-sm">
<div class="card-header bg-secondary text-white">
<h3 class="card-title mb-0"></h3>{{ zbiorka.nazwa }}</h3>
</div>
<div class="card-body">
<div class="row">
<!-- Lewa kolumna: opis i postęp -->
<div class="col-md-8">
<h5>Opis:</h5>
<div class="mb-3">
{{ zbiorka.opis | markdown }}
</div>
{% set progress = (zbiorka.stan / zbiorka.cel * 100) if zbiorka.cel > 0 else 0 %}
<h5>Postęp:</h5>
<div class="progress mb-3">
<div class="progress-bar progress-bar-striped progress-bar-animated"
role="progressbar"
style="width: {{ progress if progress < 100 else 100 }}%;"
aria-valuenow="{{ progress }}"
aria-valuemin="0"
aria-valuemax="100">
{{ progress|round(2) }}%
</div>
</div>
</div>
<!-- Prawa kolumna: sekcja "Wspomóż" -->
<div class="col-md-4">
<div class="card wspomoz-card mb-3">
<div class="card-body">
<p class="card-text">
<strong>Numer konta:</strong>
<span class="fs-4">{{ zbiorka.numer_konta }}</span>
</p>
<p class="card-text">
<strong>Telefon BLIK:</strong>
<span class="fs-4">{{ zbiorka.numer_telefonu_blik }}</span>
</p>
{% if not zbiorka.ukryj_kwote %}
<hr>
<p class="card-text">
<strong>Cel zbiórki:</strong>
<span class="fs-4">{{ zbiorka.cel|round(2) }} PLN</span>
</p>
<p class="card-text">
<strong>Stan zbiórki:</strong>
<span class="fs-4">{{ zbiorka.stan|round(2) }} PLN</span>
</p>
{% endif %}
</div>
</div>
</div>
</div>
<div class="d-flex justify-content-between">
{% if current_user.is_authenticated and current_user.is_admin %}
<a href="{{ url_for('admin_dodaj_wplate', zbiorka_id=zbiorka.id) }}" class="btn btn-primary">Dodaj wpłatę</a>
{% endif %}
<a href="{{ url_for('index') }}" class="btn btn-primary">Powrót do listy</a>
</div>
</div>
</div>
<!-- Karta z historią wpłat -->
<div class="card shadow-sm">
<div class="card-header bg-secondary text-white">
<h3 class="card-title mb-0">Historia wpłat</h3>
</div>
<div class="card-body">
{% if zbiorka.wplaty|length > 0 %}
<ul class="list-group">
{% for w in zbiorka.wplaty %}
<li class="list-group-item">
<strong>{{ w.data.strftime('%Y-%m-%d %H:%M:%S') }}</strong> {{ w.kwota|round(2) }} PLN
{% if w.opis %}
<em class="text-muted">({{ w.opis }})</em>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-center">Aktualnie brak wpłat..</p>
{% endif %}
</div>
</div>
</div>
{% endblock %}

43
zbiorka_app/__init__.py Normal file
View File

@@ -0,0 +1,43 @@
from flask import Flask
from config import Config
from .cli import register_cli_commands
from .errors import register_error_handlers
from .extensions import db, login_manager
from .routes import register_routes
from .utils import asset_url, init_version, init_database_with_retry
def create_app():
app = Flask(
__name__,
template_folder="templates",
static_folder="static",
static_url_path="/static",
)
app.config.from_object(Config)
db.init_app(app)
login_manager.init_app(app)
login_manager.login_view = "zaloguj"
init_version(app)
@app.context_processor
def inject_asset_helpers():
return {"asset_url": asset_url}
@app.before_request
def ensure_db_ready_before_request():
if app.extensions.get("database_ready") is True:
return None
init_database_with_retry(app, max_attempts=1, delay=0, raise_on_failure=False)
return None
register_routes(app)
register_error_handlers(app)
register_cli_commands(app)
return app

79
zbiorka_app/cli.py Normal file
View File

@@ -0,0 +1,79 @@
import click
from flask.cli import with_appcontext
from .extensions import db
from .utils import (
ensure_database_ready,
init_database_with_retry,
safe_db_rollback,
set_login_hosts,
set_user_password,
)
def register_cli_commands(app):
@app.cli.command("init-db")
@click.option("--attempts", default=20, type=int, show_default=True)
@click.option("--delay", default=3, type=int, show_default=True)
def init_db_command(attempts: int, delay: int):
"""Inicjalizuje baze i czeka az bedzie gotowa."""
ok = init_database_with_retry(app, max_attempts=attempts, delay=delay, raise_on_failure=False)
if not ok:
raise click.ClickException("Nie udalo sie zainicjalizowac bazy danych.")
click.echo("Baza danych gotowa.")
@app.cli.command("set-admin-password")
@click.option("--username", default="admin", show_default=True)
@click.option("--password", prompt=True, hide_input=True, confirmation_prompt=True)
@with_appcontext
def set_admin_password_command(username: str, password: str):
"""Ustawia haslo administratora."""
try:
ensure_database_ready(create_schema=True, create_admin=True)
user = set_user_password(username=username, password=password, is_admin=True)
click.echo(f"Haslo admina ustawione dla uzytkownika: {user.uzytkownik}")
except Exception as exc:
safe_db_rollback()
raise click.ClickException(str(exc))
@app.cli.command("set-user-password")
@click.argument("username")
@click.option("--password", prompt=True, hide_input=True, confirmation_prompt=True)
@click.option("--admin/--no-admin", default=None)
@with_appcontext
def set_user_password_command(username: str, password: str, admin: bool | None):
"""Tworzy uzytkownika lub zmienia jego haslo."""
try:
ensure_database_ready(create_schema=True, create_admin=True)
user = set_user_password(username=username, password=password, is_admin=admin)
click.echo(f"Haslo ustawione dla uzytkownika: {user.uzytkownik}")
except Exception as exc:
safe_db_rollback()
raise click.ClickException(str(exc))
@app.cli.command("set-login-hosts")
@click.argument("hosts", required=False, default="")
@with_appcontext
def set_login_hosts_command(hosts: str):
"""Ustawia dozwolone hosty lub IP do logowania."""
try:
ensure_database_ready(create_schema=True, create_admin=True)
settings = set_login_hosts(hosts)
click.echo(
"Dozwolone hosty logowania ustawione na: "
f"{settings.dozwolone_hosty_logowania or '(brak - logowanie zablokowane)'}"
)
except Exception as exc:
safe_db_rollback()
raise click.ClickException(str(exc))
@app.cli.command("db-ping")
@with_appcontext
def db_ping_command():
"""Sprawdza polaczenie z baza danych."""
try:
ensure_database_ready(create_schema=False, create_admin=False)
click.echo("OK")
except Exception as exc:
safe_db_rollback()
raise click.ClickException(f"DB ERROR: {exc}")

114
zbiorka_app/errors.py Normal file
View File

@@ -0,0 +1,114 @@
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 .extensions import db
def _safe_rollback() -> None:
try:
db.session.rollback()
except Exception:
pass
def _wants_json_response() -> bool:
if request.path.startswith("/api/"):
return True
best = request.accept_mimetypes.best_match(["application/json", "text/html"])
if not best:
return False
return (
best == "application/json"
and request.accept_mimetypes["application/json"]
>= request.accept_mimetypes["text/html"]
)
def _get_status_phrase(status_code: int) -> str:
try:
return HTTPStatus(status_code).phrase
except ValueError:
return "Blad"
def _get_status_description(status_code: int) -> str:
try:
return HTTPStatus(status_code).description
except ValueError:
return "Wystapil blad podczas przetwarzania zadania."
def _error_headers(status_code: int) -> dict[str, str]:
headers = {}
if status_code >= 500:
headers.update(
{
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0, private",
"Pragma": "no-cache",
"Expires": "0",
"Surrogate-Control": "no-store",
}
)
return headers
def _render_error(status_code: int, message: str | None = None):
phrase = _get_status_phrase(status_code)
description = message or _get_status_description(status_code)
headers = _error_headers(status_code)
payload = {
"status": status_code,
"error": phrase,
"message": description,
}
if _wants_json_response():
return jsonify(payload), status_code, headers
try:
return (
render_template(
"error.html",
error_code=status_code,
error_name=phrase,
error_message=description,
),
status_code,
headers,
)
except TemplateNotFound:
return (
f"{status_code} {phrase}: {description}",
status_code,
headers,
)
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_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_rollback()
app.logger.exception("Nieobsluzony wyjatek: %s", exc)
return _render_error(500, "Wystapil nieoczekiwany blad serwera.")

View File

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

146
zbiorka_app/models.py Normal file
View File

@@ -0,0 +1,146 @@
from datetime import datetime
from flask_login import UserMixin
from sqlalchemy import Numeric
from werkzeug.security import check_password_hash, generate_password_hash
from .extensions import db
class Uzytkownik(UserMixin, db.Model):
__tablename__ = "uzytkownik"
id = db.Column(db.Integer, primary_key=True)
uzytkownik = db.Column(db.String(80), unique=True, nullable=False)
haslo_hash = db.Column(db.String(128), nullable=False)
czy_admin = db.Column(db.Boolean, default=False)
def set_password(self, password):
self.haslo_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.haslo_hash, password)
class Zbiorka(db.Model):
id = db.Column(db.Integer, primary_key=True)
nazwa = db.Column(db.String(100), nullable=False)
opis = db.Column(db.Text, nullable=False)
numer_konta = db.Column(db.String(50), nullable=True)
numer_telefonu_blik = db.Column(db.String(50), nullable=True)
cel = db.Column(Numeric(12, 2), nullable=False, default=0)
stan = db.Column(Numeric(12, 2), default=0)
ukryta = db.Column(db.Boolean, default=False)
ukryj_kwote = db.Column(db.Boolean, default=False)
zrealizowana = db.Column(db.Boolean, default=False)
pokaz_postep_finanse = db.Column(db.Boolean, default=True, nullable=False)
pokaz_postep_pozycje = db.Column(db.Boolean, default=True, nullable=False)
pokaz_postep_kwotowo = db.Column(db.Boolean, default=True, nullable=False)
uzyj_konta = db.Column(db.Boolean, default=True, nullable=False)
uzyj_blik = db.Column(db.Boolean, default=True, nullable=False)
typ_zbiorki = db.Column(db.String(20), default="standardowa", nullable=False)
wplaty = db.relationship(
"Wplata",
back_populates="zbiorka",
lazy=True,
order_by="Wplata.data.desc()",
cascade="all, delete-orphan",
passive_deletes=True,
)
wydatki = db.relationship(
"Wydatek",
backref="zbiorka",
lazy=True,
order_by="Wydatek.data.desc()",
cascade="all, delete-orphan",
passive_deletes=True,
)
przedmioty = db.relationship(
"Przedmiot",
backref="zbiorka",
lazy=True,
order_by="Przedmiot.id.asc()",
cascade="all, delete-orphan",
passive_deletes=True,
)
class Przedmiot(db.Model):
id = db.Column(db.Integer, primary_key=True)
zbiorka_id = db.Column(
db.Integer,
db.ForeignKey("zbiorka.id", ondelete="CASCADE"),
nullable=False,
)
nazwa = db.Column(db.String(120), nullable=False)
link = db.Column(db.String(255), nullable=True)
cena = db.Column(Numeric(12, 2), nullable=True)
kupione = db.Column(db.Boolean, default=False)
class Wplata(db.Model):
id = db.Column(db.Integer, primary_key=True)
zbiorka_id = db.Column(
db.Integer,
db.ForeignKey("zbiorka.id", ondelete="CASCADE"),
nullable=False,
)
kwota = db.Column(Numeric(12, 2), nullable=False)
data = db.Column(db.DateTime, default=datetime.utcnow)
opis = db.Column(db.Text, nullable=True)
zbiorka = db.relationship("Zbiorka", back_populates="wplaty")
ukryta = db.Column(db.Boolean, nullable=False, default=False)
class Wydatek(db.Model):
id = db.Column(db.Integer, primary_key=True)
zbiorka_id = db.Column(
db.Integer,
db.ForeignKey("zbiorka.id", ondelete="CASCADE"),
nullable=False,
)
kwota = db.Column(Numeric(12, 2), nullable=False)
data = db.Column(db.DateTime, default=datetime.utcnow)
opis = db.Column(db.Text, nullable=True)
ukryta = db.Column(db.Boolean, nullable=False, default=False)
class Przesuniecie(db.Model):
id = db.Column(db.Integer, primary_key=True)
zbiorka_zrodlo_id = db.Column(
db.Integer,
db.ForeignKey("zbiorka.id", ondelete="CASCADE"),
nullable=False,
)
zbiorka_cel_id = db.Column(
db.Integer,
db.ForeignKey("zbiorka.id", ondelete="CASCADE"),
nullable=False,
)
kwota = db.Column(Numeric(12, 2), nullable=False)
data = db.Column(db.DateTime, default=datetime.utcnow)
opis = db.Column(db.Text, nullable=True)
ukryta = db.Column(db.Boolean, nullable=False, default=False)
wplata_id = db.Column(
db.Integer,
db.ForeignKey("wplata.id", ondelete="SET NULL"),
nullable=True,
)
zbiorka_zrodlo = db.relationship("Zbiorka", foreign_keys=[zbiorka_zrodlo_id], backref="przesuniecia_wychodzace")
zbiorka_cel = db.relationship("Zbiorka", foreign_keys=[zbiorka_cel_id], backref="przesuniecia_przychodzace")
wplata = db.relationship("Wplata", foreign_keys=[wplata_id], backref="przesuniecia")
class UstawieniaGlobalne(db.Model):
__tablename__ = "ustawienia_globalne"
id = db.Column(db.Integer, primary_key=True)
numer_konta = db.Column(db.String(50), nullable=False)
numer_telefonu_blik = db.Column(db.String(50), nullable=False)
dozwolone_hosty_logowania = db.Column(db.Text, nullable=True)
logo_url = db.Column(db.String(255), nullable=True)
tytul_strony = db.Column(db.String(120), nullable=True)
pokaz_logo_w_navbar = db.Column(db.Boolean, default=False)
typ_navbar = db.Column(db.String(10), default="text")
typ_stopka = db.Column(db.String(10), default="text")
stopka_text = db.Column(db.String(200), nullable=True)
kolejnosc_rezerwowych = db.Column(db.String(20), default="id", nullable=False)

1371
zbiorka_app/routes.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,440 @@
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap');
/* ========= TOKENS ========= */
:root {
color-scheme: dark;
--bg: #121212;
/* główne tło */
--surface-0: #1a1a1a;
/* navbar, header */
--surface-1: #202020;
/* karty */
--surface-2: #2a2a2a;
/* nagłówki kart, ciemniejsze sekcje */
--border: #3a3a3a;
--text: #e4e4e4;
--text-muted: #a8a8a8;
--accent: #f5c84c;
/* żółty/amber akcent */
--accent-600: #e3b23f;
--accent-700: #cfa033;
--accent-300: #ffe083;
--radius: 10px;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, .5);
--shadow-md: 0 4px 12px rgba(0, 0, 0, .45);
--trans: 220ms cubic-bezier(.2, .8, .2, 1);
}
/* ========= BASE ========= */
body {
font-family: 'Roboto', system-ui, -apple-system, Segoe UI, Arial, sans-serif;
background: var(--bg);
color: var(--text);
margin: 0;
padding-top: 1vh;
}
a {
color: var(--accent);
text-decoration: none;
transition: color var(--trans);
}
a:hover {
color: var(--accent-300);
}
/* ========= NAVBAR ========= */
.navbar {
background: var(--surface-0);
border-bottom: 1px solid var(--border);
}
.navbar-brand {
color: var(--text);
font-weight: 700;
transition: color var(--trans);
}
.navbar-brand:hover {
color: var(--accent);
}
.nav-link {
color: var(--text-muted);
transition: color var(--trans);
}
.nav-link:hover,
.nav-link:focus {
color: var(--accent);
}
/* ========= CARDS ========= */
.card {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
margin-bottom: 20px;
transition: transform 160ms ease, box-shadow 160ms ease, border-color var(--trans);
}
.card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
border-color: color-mix(in srgb, var(--accent) 20%, var(--border));
}
.card-header {
background: var(--surface-2);
border-bottom: 1px solid var(--border);
font-weight: 700;
}
.card-body {
background: transparent;
}
/* Wyróżniona karta */
.card.wspomoz-card {
border: 1px solid var(--accent) !important;
border-radius: var(--radius) !important;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent);
}
.card.wspomoz-card .card-body,
.card.wspomoz-card .card-title,
.card.wspomoz-card .card-text {
color: var(--text) !important;
font-weight: 700;
font-size: 1.15rem;
}
/* ========= BUTTONS ========= */
.btn {
font-weight: 700;
border-radius: 8px;
transition: transform 120ms ease, background-color var(--trans), border-color var(--trans), color var(--trans);
}
.btn:hover {
transform: translateY(-1px);
}
.btn:focus-visible {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 40%, transparent);
outline: none;
}
/* Primary = żółty */
.btn-primary {
background-color: var(--accent);
border-color: var(--accent-600);
color: #111;
}
.btn-primary:hover {
background-color: var(--accent-600);
border-color: var(--accent-700);
color: #111;
}
.btn-primary:active,
.btn-primary:focus {
background-color: var(--accent-700) !important;
border-color: var(--accent-700) !important;
color: #111 !important;
}
/* Secondary = ciemna szarość */
.btn-secondary,
.btn-outline-primary {
background: var(--surface-1);
border: 1px solid var(--border);
color: var(--text);
}
.btn-secondary:hover,
.btn-outline-primary:hover {
border-color: var(--accent-600);
color: var(--accent);
}
/* ========= PROGRESS ========= */
.progress {
background: var(--surface-2);
border-radius: 999px;
height: 14px;
overflow: hidden;
border: 1px solid var(--border);
}
.progress-bar {
--progress-width: 0%;
width: var(--progress-width);
background: linear-gradient(90deg, var(--accent-600), var(--accent));
transition: width var(--trans);
}
/* ========= ALERTS ========= */
/* ALERT VARIANTS */
.alert-success {
background: rgba(40, 167, 69, 0.15);
/* lekka zieleń */
border-color: #28a745;
color: #28a745;
}
.alert-danger {
background: rgba(220, 53, 69, 0.15);
/* lekka czerwień */
border-color: #dc3545;
color: #dc3545;
}
.alert-warning {
background: rgba(255, 193, 7, 0.15);
/* lekki bursztyn */
border-color: #ffc107;
color: #ffc107;
}
.alert-info {
background: rgba(23, 162, 184, 0.15);
/* lekki cyjan */
border-color: #17a2b8;
color: #17a2b8;
}
@keyframes fadeIn {
to {
opacity: 1;
}
}
/* ========= TYPO ========= */
h1 {
font-size: 2rem;
margin-bottom: .75rem;
}
h2 {
font-size: 1.6rem;
margin-bottom: .6rem;
}
h3 {
font-size: 1.1rem;
margin-bottom: .5rem;
color: var(--text-muted);
}
small,
.text-muted {
color: var(--text-muted) !important;
}
/* ========= RESPONSIVE ========= */
.container {
padding: 0 15px;
}
@media (max-width: 767px) {
.card {
margin-bottom: 1rem;
}
.card-title {
font-size: 1.1rem;
}
.btn {
font-size: .95rem;
}
}
.table-responsive {
overflow: visible;
}
.dropdown-menu {
z-index: 1080;
}
/* ponad kartą/tabelą */
@media (max-width: 576px) {
.table-responsive {
overflow-x: auto;
}
}
/* ========= FORMS ========= */
input.form-control,
textarea.form-control,
select.form-select {
background-color: var(--surface-1);
border: 1px solid var(--border);
color: var(--text);
}
input.form-control:focus,
textarea.form-control:focus,
select.form-select:focus {
background-color: var(--surface-1);
border-color: var(--accent-600);
color: var(--text);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 50%, transparent);
}
/* pole edycji ciemne */
.CodeMirror {
background-color: #1e1e1e !important;
color: #e0e0e0 !important;
}
/* kursor */
.CodeMirror-cursor {
border-left: 1px solid #e0e0e0 !important;
}
.nav-pills .nav-link.active {
background: var(--accent);
color: #111;
}
.nav-pills .nav-link {
color: inherit;
}
/* sticky tylko od md wzwyż, by na mobile nie przyklejać */
@media (min-width: 768px) {
.sticky-md {
position: sticky;
}
}
:root {
--sticky-offset: 1rem;
}
/* Rząd kopiowania: czytelny, łatwy klik w przycisk */
.copy-row {
display: grid;
grid-template-columns: 1fr auto;
grid-template-rows: auto auto;
gap: .25rem .5rem;
align-items: center;
padding: .5rem .75rem;
border: 1px solid var(--border, rgba(255, 255, 255, .15));
border-radius: .75rem;
background: rgba(255, 255, 255, .02);
}
.copy-row+.copy-row {
margin-top: .5rem;
}
.copy-row__label {
grid-column: 1 / 2;
grid-row: 1 / 2;
font-weight: 600;
font-size: .9rem;
opacity: .85;
}
.copy-row__value {
grid-column: 1 / 2;
grid-row: 2 / 3;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
letter-spacing: .02em;
user-select: text;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.copy-row__btn {
grid-column: 2 / 3;
grid-row: 1 / 3;
height: fit-content;
align-self: center;
}
@media (max-width: 575.98px) {
/* na XS przycisk pod spodem łatwiej trafić kciukiem */
.copy-row {
grid-template-columns: 1fr;
grid-template-rows: auto auto auto;
}
.copy-row__btn {
grid-column: 1 / -1;
grid-row: 3 / 4;
width: 100%;
}
}
.hr-bw {
border: 0;
border-top: 1px dashed rgba(255, 255, 255, 0.2);
margin: 10px 0;
}
/* Tylko ten przycisk */
.btn.btn-outline-light.btn-opis {
color: #fff;
background-color: transparent;
border: 1px solid var(--border);
transition: none;
}
.btn.btn-outline-light.btn-opis:hover,
.btn.btn-outline-light.btn-opis:focus {
color: #fff;
background-color: #161616;
border-color: color-mix(in srgb, var(--accent) 20%, var(--border));
}
.btn.btn-outline-light {
color: #fff;
background-color: transparent;
border: 1px solid rgba(255, 255, 255, 0.9);
}
.btn.btn-outline-light:hover,
.btn.btn-outline-light:focus {
color: #fff;
background-color: #161616;
border-color: color-mix(in srgb, var(--accent) 20%, #ffffff);
}
.btn.btn-outline-light:active {
color: #fff;
background-color: #141414;
border-color: color-mix(in srgb, var(--accent) 24%, #ffffff);
}
#kanalyWarning,
#postepyWarning {
border: 1px solid #ffc107;
background-color: #2c2c2c;
color: #fff;
}
input:disabled,
textarea:disabled,
select:disabled {
background-color: #2b2b2b !important;
color: #bbb !important;
opacity: 1 !important;
cursor: not-allowed;
}

View File

View File

@@ -0,0 +1,21 @@
(function () {
const kwota = document.getElementById('kwota');
const opis = document.getElementById('opis');
const opisCount = document.getElementById('opisCount');
document.querySelectorAll('.btn-kwota').forEach(btn => {
btn.addEventListener('click', () => {
const val = btn.getAttribute('data-amount');
if (val && kwota) {
kwota.value = Number(val).toFixed(2);
kwota.focus();
}
});
});
if (opis && opisCount) {
const updateCount = () => opisCount.textContent = opis.value.length.toString();
opis.addEventListener('input', updateCount);
updateCount();
}
})();

View File

@@ -0,0 +1,5 @@
document.addEventListener('input', (e) => {
if (e.target && e.target.id === 'opis') {
document.getElementById('opisCount').textContent = e.target.value.length;
}
});

View File

@@ -0,0 +1,88 @@
(() => {
// Root kontenera z danymi (dataset.cel)
const root = document.querySelector('[data-module="edit-stan"]');
if (!root) return;
const input = root.querySelector('#stan');
const previewPct = root.querySelector('#previewPct');
const previewBar = root.querySelector('#previewBar');
const previewNote = root.querySelector('#previewNote');
// Cel przekazany jako data atrybut
const cel = Number(root.dataset.cel || 0);
function clamp(n) {
if (Number.isNaN(n)) return 0;
return n < 0 ? 0 : n;
}
function pct(val) {
if (!cel || cel <= 0) return 0;
return (val / cel) * 100;
}
function updatePreview() {
if (!input) return;
const val = clamp(Number(input.value));
const p = Math.max(0, Math.min(100, pct(val)));
if (previewPct) previewPct.textContent = p.toFixed(1);
if (previewBar) {
previewBar.style.width = p + '%';
previewBar.setAttribute('aria-valuenow', p.toFixed(2));
}
if (previewNote) {
if (cel > 0) {
const diff = cel - val;
const isZero = Math.abs(diff) < 0.005; // float-safe
if (diff > 0 && !isZero) {
previewNote.textContent = 'Do celu brakuje: ' + diff.toFixed(2) + ' PLN';
} else if (isZero) {
previewNote.textContent = 'Cel osiągnięty.';
} else {
previewNote.textContent = 'Przekroczono cel o: ' + Math.abs(diff).toFixed(2) + ' PLN';
}
} else {
previewNote.textContent = 'Brak zdefiniowanego celu — procent nie jest wyliczany.';
}
}
}
// Zmiana ręczna
if (input) {
input.addEventListener('input', updatePreview);
input.addEventListener('change', () => {
if (Number(input.value) < 0) input.value = '0.00';
updatePreview();
});
}
// Przyciski +/- delta
root.querySelectorAll('.btn-delta').forEach(btn => {
btn.addEventListener('click', () => {
const d = Number(btn.getAttribute('data-delta') || 0);
const cur = Number(input?.value || 0);
if (!input) return;
input.value = clamp(cur + d).toFixed(2);
updatePreview();
input.focus();
});
});
// Ustaw na konkretną wartość
root.querySelectorAll('.btn-set').forEach(btn => {
btn.addEventListener('click', () => {
const v = Number(btn.getAttribute('data-value') || 0);
if (!input) return;
input.value = clamp(v).toFixed(2);
updatePreview();
input.focus();
});
});
// Inicjalny podgląd
updatePreview();
})();

View File

@@ -0,0 +1,19 @@
document.addEventListener('DOMContentLoaded', function() {
const uzyjKonta = document.getElementById('uzyj_konta');
const kontoField = document.getElementById('konto-field');
const uzyjBlik = document.getElementById('uzyj_blik');
const blikField = document.getElementById('blik-field');
if (uzyjKonta && kontoField) {
uzyjKonta.addEventListener('change', function() {
kontoField.style.display = this.checked ? 'block' : 'none';
});
}
if (uzyjBlik && blikField) {
uzyjBlik.addEventListener('change', function() {
blikField.style.display = this.checked ? 'block' : 'none';
});
}
});

View File

@@ -0,0 +1,63 @@
(function () {
// Licznik znaków opisu
const opis = document.getElementById('opis');
const opisCount = document.getElementById('opisCount');
if (opis && opisCount) {
const updateCount = () => (opisCount.textContent = String(opis.value.length));
opis.addEventListener('input', updateCount);
updateCount();
}
// IBAN: tylko cyfry, auto-grupowanie co 4
const iban = document.getElementById('numer_konta');
if (iban) {
iban.addEventListener('input', () => {
const digits = iban.value.replace(/\D/g, '').slice(0, 26); // 26 cyfr po PL
const chunked = digits.replace(/(.{4})/g, '$1 ').trim();
iban.value = chunked;
});
}
// BLIK telefon: tylko cyfry, format 3-3-3
const tel = document.getElementById('numer_telefonu_blik');
if (tel) {
tel.addEventListener('input', () => {
const digits = tel.value.replace(/\D/g, '').slice(0, 9);
const parts = [];
if (digits.length > 0) parts.push(digits.substring(0, 3));
if (digits.length > 3) parts.push(digits.substring(3, 6));
if (digits.length > 6) parts.push(digits.substring(6, 9));
tel.value = parts.join(' ');
});
}
// „Ustaw globalne” jest tylko w trybie edycji; odpalamy warunkowo
const setGlobalBtn = document.getElementById('ustaw-globalne');
if (setGlobalBtn && iban && tel) {
setGlobalBtn.addEventListener('click', () => {
const gIban = setGlobalBtn.dataset.iban || '';
const gBlik = setGlobalBtn.dataset.blik || '';
if (gIban) {
iban.value = gIban.replace(/\D/g, '').replace(/(.{4})/g, '$1 ').trim();
iban.dispatchEvent(new Event('input'));
}
if (gBlik) {
const d = gBlik.replace(/\D/g, '').slice(0, 9);
const p = [d.slice(0, 3), d.slice(3, 6), d.slice(6, 9)]
.filter(Boolean)
.join(' ');
tel.value = p;
tel.dispatchEvent(new Event('input'));
}
});
}
// Cel: minimalna wartość
const cel = document.getElementById('cel');
if (cel) {
cel.addEventListener('change', () => {
if (cel.value && Number(cel.value) < 0.01) cel.value = '0.01';
});
}
})();

View File

@@ -0,0 +1,153 @@
(function () {
const tbody = document.querySelector('#produkty-body');
const celInput = document.querySelector('#cel');
const box = document.querySelector('#celSyncBox');
const msg = document.querySelector('#celSyncMsg');
const btn = document.querySelector('#btnApplyCelFromSum');
if (!tbody || !celInput || !box || !msg || !btn) return;
const EPS = 0.01; // tolerancja porównania
function parsePrice(raw) {
if (!raw) return NaN;
const s = String(raw).trim().replace(/\s+/g, '').replace(',', '.');
const n = Number(s);
return Number.isFinite(n) && n >= 0 ? n : NaN;
}
function getRows() {
return Array.from(tbody.querySelectorAll('tr'));
}
function computeSum() {
const rows = getRows();
let hasNamed = false;
let sumAll = 0; // suma ze wszystkich wierszy z nazwą i poprawną ceną
let sumToBuy = 0; // suma tylko z wierszy NIE oznaczonych jako "Kupione"
for (const tr of rows) {
const nameInput = tr.querySelector('input[name="item_nazwa[]"]');
const priceInput = tr.querySelector('input[name="item_cena[]"]');
const kupioneSwitch = tr.querySelector('.kupione-switch');
const name = nameInput ? nameInput.value.trim() : '';
if (!name) continue; // ignoruj puste wiersze bez nazwy
hasNamed = true;
const priceVal = priceInput ? parsePrice(priceInput.value) : NaN;
if (Number.isNaN(priceVal)) continue;
// zawsze dolicz do sumy wszystkich
sumAll += priceVal;
// do sumy do-kupienia tylko jeśli nie jest oznaczone jako kupione
if (!(kupioneSwitch && kupioneSwitch.checked)) {
sumToBuy += priceVal;
}
}
return { hasNamed, sumAll, sumToBuy };
}
function readCel() {
const v = parsePrice(celInput.value);
return Number.isNaN(v) ? null : v;
}
function formatPln(n) {
// Nie narzucamy locale prosto 2 miejsca
return n.toFixed(2);
}
function updateUI() {
const { hasNamed, sumAll, sumToBuy } = computeSum();
// Brak produktów (brak nazw) lub obie sumy = 0 → nic nie pokazuj
if (!hasNamed || (sumAll <= 0 && sumToBuy <= 0)) {
box.classList.add('d-none');
btn.classList.add('d-none');
box.classList.remove('alert-success', 'alert-info');
msg.textContent = '';
return;
}
const cel = readCel();
const target = sumToBuy; // porównujemy do kwoty POZOSTAŁE DO KUPIENIA
// Jeśli cel nie ustawiony lub NaN → zaproponuj ustawienie celu = sumToBuy
if (cel === null) {
box.classList.remove('d-none');
box.classList.remove('alert-success');
box.classList.add('alert-info');
// pokazujemy obie sumy w komunikacie
msg.innerHTML = `
<div>Wszystkie: <strong>${formatPln(sumAll)} PLN</strong> ·
Do kupienia: <strong>${formatPln(sumToBuy)} PLN</strong></div>
<div class="mt-1">Możesz ustawić <strong>cel</strong> na kwotę do kupienia.</div>
`;
btn.textContent = `Ustaw cel = ${formatPln(target)} PLN`;
btn.classList.remove('d-none');
return;
}
// Mamy cel — porównanie do sumy do-kupienia
if (Math.abs(cel - target) <= EPS) {
box.classList.remove('d-none');
box.classList.remove('alert-info');
box.classList.add('alert-success');
msg.innerHTML = `
Suma <em>do kupienia</em> (<strong>${formatPln(target)} PLN</strong>) jest równa celowi.
<div class="small text-muted mt-1">Wszystkie: ${formatPln(sumAll)} PLN · Do kupienia: ${formatPln(sumToBuy)} PLN</div>
`;
btn.classList.add('d-none');
} else {
box.classList.remove('d-none');
box.classList.remove('alert-success');
box.classList.add('alert-info');
msg.innerHTML = `
<div>Wszystkie: <strong>${formatPln(sumAll)} PLN</strong> ·
Do kupienia: <strong>${formatPln(sumToBuy)} PLN</strong></div>
<div class="mt-1">Cel: <strong>${formatPln(cel)} PLN</strong></div>
`;
btn.textContent = `Zaktualizuj cel do ${formatPln(target)} PLN`;
btn.classList.remove('d-none');
}
}
btn.addEventListener('click', (e) => {
e.preventDefault();
const { sumToBuy } = computeSum();
if (sumToBuy > 0) {
celInput.value = formatPln(sumToBuy);
celInput.dispatchEvent(new Event('input', { bubbles: true }));
celInput.dispatchEvent(new Event('change', { bubbles: true }));
updateUI();
}
});
// Reaguj na zmiany cen/nazw
tbody.addEventListener('input', (e) => {
const name = e.target.getAttribute('name');
if (name === 'item_nazwa[]' || name === 'item_cena[]') {
updateUI();
}
});
// Reaguj na zmiany celu
celInput.addEventListener('input', updateUI);
celInput.addEventListener('change', updateUI);
// Obserwuj dodawanie/usuwanie wierszy przez inne skrypty
const mo = new MutationObserver(() => updateUI());
mo.observe(tbody, { childList: true, subtree: true });
// Init po załadowaniu
document.addEventListener('DOMContentLoaded', updateUI);
// i jedno wywołanie na starcie (gdy DOMContentLoaded już był)
updateUI();
})();

View File

@@ -0,0 +1,4 @@
var simplemde = new SimpleMDE({
element: document.getElementById("opis"),
forceSync: true
});

View File

@@ -0,0 +1,73 @@
(function () {
const body = document.querySelector('#produkty-body');
const addBtn = document.querySelector('#add-row');
const clearBtn = document.querySelector('#clear-empty');
if (!body) return;
function reindexHidden() {
const rows = [...body.querySelectorAll('tr')];
rows.forEach((tr, idx) => {
const hidden = tr.querySelector('input[type="hidden"][name^="item_kupione_val_"]');
if (hidden) hidden.name = `item_kupione_val_${idx}`;
});
}
function makeRow() {
const tr = document.createElement('tr');
tr.innerHTML = `
<td><input type="text" class="form-control" name="item_nazwa[]" placeholder="np. Karma Brit 10kg" required></td>
<td><input type="url" class="form-control" name="item_link[]" placeholder="https://..."></td>
<td><input type="text" inputmode="decimal" class="form-control text-end" name="item_cena[]" placeholder="0,00"></td>
<td>
<div class="form-check form-switch">
<input class="form-check-input kupione-switch" type="checkbox">
<input type="hidden" name="item_kupione_val_TMP" value="0">
<label class="form-check-label small">Do kupienia</label>
</div>
</td>
<td class="text-end">
<button type="button" class="btn btn-sm btn-outline-light border remove-row" title="Usuń wiersz">✕</button>
</td>`;
return tr;
}
body.addEventListener('change', (e) => {
if (e.target.classList.contains('kupione-switch')) {
const tr = e.target.closest('tr');
const hidden = tr.querySelector('input[type="hidden"][name^="item_kupione_val_"]');
const label = tr.querySelector('.form-check-label');
if (hidden) hidden.value = e.target.checked ? '1' : '0';
if (label) label.textContent = e.target.checked ? 'Kupione' : 'Do kupienia';
}
});
body.addEventListener('click', (e) => {
if (e.target.classList.contains('remove-row')) {
e.preventDefault();
const tr = e.target.closest('tr');
tr.remove();
reindexHidden();
}
});
addBtn?.addEventListener('click', (e) => {
e.preventDefault();
body.appendChild(makeRow());
reindexHidden();
});
clearBtn?.addEventListener('click', (e) => {
e.preventDefault();
[...body.querySelectorAll('tr')].forEach(tr => {
const name = tr.querySelector('input[name="item_nazwa[]"]')?.value.trim();
const link = tr.querySelector('input[name="item_link[]"]')?.value.trim();
const cena = tr.querySelector('input[name="item_cena[]"]')?.value.trim();
if (!name && !link && !cena) tr.remove();
});
reindexHidden();
});
// startowa normalizacja nazw hiddenów (ważne w trybie edycji)
reindexHidden();
})();

View File

@@ -0,0 +1,13 @@
function animateProgressBars() {
document.querySelectorAll('.progress-bar').forEach(bar => {
const progressValue = bar.getAttribute('aria-valuenow');
bar.style.setProperty('--progress-width', progressBarWidth(progressBarValue(progressBar)));
});
}
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.progress-bar').forEach(bar => {
const width = bar.getAttribute('aria-valuenow') + '%';
bar.style.setProperty('--progress-width', width);
});
});

View File

@@ -0,0 +1,74 @@
// static/js/przelaczniki_zabezpieczenie.js
(function () {
'use strict';
function onReady(cb) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', cb);
} else {
cb();
}
}
onReady(function () {
var boxes = Array.prototype.slice.call(
document.querySelectorAll('input.form-check-input[type="checkbox"][data-group="postepy"]')
);
var warning = document.getElementById('postepyWarning');
if (!boxes.length || !warning) {
// Nic do zrobienia, brak elementów
return;
}
function atLeastOneChecked() {
return boxes.some(function (b) { return !!b.checked; });
}
function showWarning(show) {
warning.classList.toggle('d-none', !show);
if (show) {
// dyskretny highlight
warning.classList.add('border', 'border-warning');
warning.style.transition = 'box-shadow 0.2s ease';
warning.style.boxShadow = '0 0 0.25rem rgba(255,193,7,.6)';
setTimeout(function () {
warning.style.boxShadow = '';
}, 300);
}
}
function enforceAtLeastOne(e) {
// Jeżeli po zmianie byłaby 0/3, przywróć zaznaczenie klikniętego i pokaż ostrzeżenie
if (!atLeastOneChecked()) {
e.target.checked = true;
showWarning(true);
e.target.classList.add('is-invalid');
setTimeout(function () { e.target.classList.remove('is-invalid'); }, 400);
return;
}
// Jeśli >=1, ostrzeżenie ukryj
showWarning(false);
}
// Podpinka zdarzeń
boxes.forEach(function (box) {
box.addEventListener('change', enforceAtLeastOne);
});
// Walidacja przy submit (na wszelki wypadek)
var form = boxes[0].closest('form');
if (form) {
form.addEventListener('submit', function (e) {
if (!atLeastOneChecked()) {
e.preventDefault();
showWarning(true);
boxes[0].focus();
}
});
}
// Inicjalny stan (np. po rerenderze z błędem)
showWarning(!atLeastOneChecked());
});
})();

View File

View File

@@ -0,0 +1,88 @@
(function () {
const form = document.getElementById('form-edit-zbiorka') || document.getElementById('form-add-zbiorka') || document.querySelector('form');
const map = [
['uzyj_konta', 'numer_konta'],
['uzyj_blik', 'numer_telefonu_blik']
];
const warnBox = document.getElementById('kanalyWarning');
function showWarn(show) {
if (!warnBox) return;
warnBox.classList.toggle('d-none', !show);
}
function getEl(id) { return document.getElementById(id); }
function toggleField(chkId, inputId) {
const chk = getEl(chkId);
const inp = getEl(inputId);
if (!inp || !chk) return;
const on = chk.checked;
inp.disabled = !on;
if (on) inp.setAttribute('required', '');
else inp.removeAttribute('required');
}
function atLeastOneOn() {
return map.some(([c]) => getEl(c)?.checked);
}
function blinkInvalid(el) {
if (!el) return;
el.classList.add('is-invalid');
setTimeout(() => el.classList.remove('is-invalid'), 400);
}
function preventUncheckLast(e) {
const target = e.target;
if (target.checked) return;
const after = map.map(([c]) => c === target.id ? false : !!getEl(c)?.checked);
if (!after.some(Boolean)) {
e.preventDefault();
target.checked = true;
showWarn(true);
blinkInvalid(target);
} else {
showWarn(false);
}
}
function onToggle(chkId, inputId) {
toggleField(chkId, inputId);
showWarn(!atLeastOneOn());
}
map.forEach(([chkId, inputId]) => {
const chk = getEl(chkId);
if (!chk) return;
chk.addEventListener('click', preventUncheckLast);
chk.addEventListener('change', () => onToggle(chkId, inputId));
toggleField(chkId, inputId);
});
showWarn(!atLeastOneOn());
if (form) {
form.addEventListener('submit', function (e) {
if (!atLeastOneOn()) {
e.preventDefault();
showWarn(true);
blinkInvalid(getEl('uzyj_konta') || getEl('uzyj_blik'));
(getEl('uzyj_konta') || getEl('uzyj_blik'))?.focus();
return;
}
for (const [chkId, inputId] of map) {
const chk = getEl(chkId), inp = getEl(inputId);
if (chk?.checked && inp && !inp.value.trim()) {
e.preventDefault();
showWarn(true);
blinkInvalid(inp);
inp.focus();
return;
}
}
});
}
})();

View File

@@ -0,0 +1,27 @@
document.addEventListener('DOMContentLoaded', function () {
const modalW = new bootstrap.Modal(document.getElementById('modalWplata'));
const modalX = new bootstrap.Modal(document.getElementById('modalWydatek'));
// WPŁATA
document.querySelectorAll('.btn-edit-wplata').forEach(btn => {
btn.addEventListener('click', () => {
const form = document.getElementById('formWplata');
form.action = btn.dataset.action;
document.getElementById('wplataKwota').value = btn.dataset.kwota || '';
document.getElementById('wplataOpis').value = btn.dataset.opis || '';
modalW.show();
});
});
// WYDATEK
document.querySelectorAll('.btn-edit-wydatek').forEach(btn => {
btn.addEventListener('click', () => {
const form = document.getElementById('formWydatek');
form.action = btn.dataset.action;
document.getElementById('wydatekKwota').value = btn.dataset.kwota || '';
document.getElementById('wydatekOpis').value = btn.dataset.opis || '';
modalX.show();
});
});
});

View File

@@ -0,0 +1,92 @@
// static/js/ustawienia.js
document.addEventListener('DOMContentLoaded', () => {
// Formatowanie IBAN (PL)
const iban = document.getElementById('numer_konta');
if (iban) {
iban.addEventListener('input', () => {
const digits = iban.value.replace(/\D/g, '').slice(0, 26);
const chunked = digits.replace(/(.{4})/g, '$1 ').trim();
iban.value = chunked;
});
}
// Telefon BLIK 3-3-3
const tel = document.getElementById('numer_telefonu_blik');
if (tel) {
tel.addEventListener('input', () => {
const digits = tel.value.replace(/\D/g, '').slice(0, 9);
const parts = [];
if (digits.length > 0) parts.push(digits.substring(0, 3));
if (digits.length > 3) parts.push(digits.substring(3, 6));
if (digits.length > 6) parts.push(digits.substring(6, 9));
tel.value = parts.join(' ');
});
}
// Biała lista IP/hostów
const ta = document.getElementById('dozwolone_hosty_logowania');
const count = document.getElementById('hostsCount');
const addBtn = document.getElementById('btn-add-host');
const addMyBtn = document.getElementById('btn-add-my-ip');
const input = document.getElementById('host_input');
const dedupeBtn = document.getElementById('btn-dedupe');
const parseList = (text) =>
text
.split(/[\r\n,;]+/) // \r?\n, przecinek, średnik
.map(s => s.trim())
.filter(Boolean);
const formatList = (arr) => arr.join('\n');
const dedupe = (arr) => {
const seen = new Set();
const out = [];
for (const v of arr) {
const k = v.toLowerCase();
if (!seen.has(k)) { seen.add(k); out.push(v); }
}
return out;
};
const updateCount = () => {
if (!ta || !count) return;
count.textContent = String(parseList(ta.value).length);
};
const addEntry = (val) => {
if (!ta || !val) return;
const list = dedupe([...parseList(ta.value), val]);
ta.value = formatList(list);
updateCount();
};
if (ta) {
ta.addEventListener('input', updateCount);
updateCount(); // inicjalne przeliczenie
}
if (addBtn && input) {
addBtn.addEventListener('click', () => {
const val = (input.value || '').trim();
if (!val) return;
addEntry(val);
input.value = '';
input.focus();
});
}
if (addMyBtn) {
addMyBtn.addEventListener('click', () => {
const ip = addMyBtn.dataset.myIp || '';
if (ip) addEntry(ip);
});
}
if (dedupeBtn && ta) {
dedupeBtn.addEventListener('click', () => {
ta.value = formatList(dedupe(parseList(ta.value)));
updateCount();
});
}
});

View File

@@ -0,0 +1,27 @@
(function () {
const form = document.querySelector('form.needs-validation');
form.addEventListener('submit', function (e) {
if (!form.checkValidity()) {
e.preventDefault();
e.stopPropagation();
}
form.classList.add('was-validated');
}, false);
})();
const pw = document.getElementById("haslo");
const toggle = document.getElementById('togglePw');
toggle.addEventListener('click', () => {
const isText = pw.type === 'text';
pw.type = isText ? "haslo" : 'text';
toggle.textContent = isText ? 'Pokaż' : 'Ukryj';
toggle.setAttribute('aria-pressed', (!isText).toString());
pw.focus();
});
const caps = document.getElementById('capsWarning');
function handleCaps(e) {
const capsOn = e.getModifierState && e.getModifierState('CapsLock');
caps.style.display = capsOn ? 'inline' : 'none';
}
pw.addEventListener('keyup', handleCaps);
pw.addEventListener('keydown', handleCaps);

View File

@@ -0,0 +1,37 @@
(function () {
const form = document.querySelector('form.needs-validation');
form.addEventListener('submit', function (e) {
if (!form.checkValidity()) {
e.preventDefault();
e.stopPropagation();
}
const pw1 = document.getElementById("haslo");
const pw2 = document.getElementById('password2');
if (pw1.value !== pw2.value) {
e.preventDefault();
e.stopPropagation();
pw2.setCustomValidity("Hasła muszą być identyczne.");
pw2.reportValidity();
} else {
pw2.setCustomValidity("");
}
form.classList.add('was-validated');
}, false);
})();
const pw = document.getElementById("haslo");
const toggle = document.getElementById('togglePw');
toggle.addEventListener('click', () => {
const isText = pw.type === 'text';
pw.type = isText ? "haslo" : 'text';
toggle.textContent = isText ? 'Pokaż' : 'Ukryj';
pw.focus();
});
const caps = document.getElementById('capsWarning');
function handleCaps(e) {
const capsOn = e.getModifierState && e.getModifierState('CapsLock');
caps.style.display = capsOn ? 'inline' : 'none';
}
pw.addEventListener('keyup', handleCaps);
pw.addEventListener('keydown', handleCaps);

View File

@@ -0,0 +1,66 @@
(function () {
// --- Formatowanie IBAN ---
const ibanEl = document.getElementById('ibanInput') || document.getElementById('ibanDisplay');
if (ibanEl) {
const raw = (('value' in ibanEl ? ibanEl.value : ibanEl.textContent) || '')
.toString().replace(/\s+/g, '').toUpperCase();
const digits = raw.replace(/^PL/, '').replace(/\D/g, '').slice(0, 26);
if (digits) {
const pretty = 'PL ' + digits.replace(/(.{4})/g, '$1 ').trim();
if ('value' in ibanEl) ibanEl.value = pretty; else ibanEl.textContent = pretty;
}
}
// --- Formatowanie BLIK ---
const blikEl = document.getElementById('blikInput') || document.getElementById('blikDisplay');
if (blikEl) {
const raw = (('value' in blikEl ? blikEl.value : blikEl.textContent) || '')
.toString().replace(/\D/g, '').slice(0, 9);
if (raw) {
const pretty = [raw.slice(0, 3), raw.slice(3, 6), raw.slice(6, 9)]
.filter(Boolean).join(' ');
if ('value' in blikEl) blikEl.value = pretty; else blikEl.textContent = pretty;
}
}
// --- Kopiowanie: wspiera data-copy-input i data-copy-target ---
const buttons = document.querySelectorAll('[data-copy-input], [data-copy-target]');
buttons.forEach(btn => {
btn.addEventListener('click', async () => {
const sel = btn.getAttribute('data-copy-input') || btn.getAttribute('data-copy-target');
const el = sel ? document.querySelector(sel) : null;
if (!el) return;
const textRaw = ('value' in el ? el.value : el.textContent || '')
.toString().replace(/\u00A0/g, ' ').trim();
const copyWithFallback = async (text) => {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// Fallback: tymczasowy textarea
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.top = '-1000px';
ta.setAttribute('readonly', '');
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); } catch { /* ignore */ }
document.body.removeChild(ta);
return true;
}
};
const original = btn.textContent;
const ok = await copyWithFallback(textRaw);
if (ok) {
btn.textContent = 'Skopiowano!';
btn.disabled = true;
setTimeout(() => { btn.textContent = original; btn.disabled = false; }, 1200);
}
});
});
})();

View File

@@ -0,0 +1,373 @@
{% extends 'base.html' %}
{% block title %}Panel Admina{% endblock %}
{% block content %}
<div class="container my-4">
<!-- Nagłówek + akcje globalne -->
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-4">
<div>
<h2 class="mb-1">Panel Admina</h2>
<p class="text-muted mb-0">Zarządzaj zbiórkami i monitoruj finanse</p>
</div>
<div class="d-flex flex-wrap gap-2">
<a href="{{ url_for('admin_statystyki') }}" class="btn btn-info">
<i class="bi bi-graph-up"></i> Statystyki
</a>
<a href="{{ url_for('formularz_zbiorek') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Dodaj zbiórkę
</a>
<a href="{{ url_for('lista_rezerwowych') }}" class="btn btn-outline-light">
<i class="bi bi-wallet2"></i> Listy rezerwowe
</a>
<a href="{{ url_for('admin_ustawienia') }}" class="btn btn-outline-light">
<i class="bi bi-gear"></i> Ustawienia
</a>
</div>
</div>
<!-- Szybkie statystyki (cards) -->
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="card border-primary">
<div class="card-body text-center">
<i class="bi bi-list-check fs-1 text-primary mb-2"></i>
<h3 class="mb-0">{{ active_zbiorki|length }}</h3>
<small class="text-muted">Aktywnych zbiórek</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-success">
<div class="card-body text-center">
<i class="bi bi-check-circle fs-1 text-success mb-2"></i>
<h3 class="mb-0">{{ completed_zbiorki|length }}</h3>
<small class="text-muted">Zrealizowanych</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-info">
<div class="card-body text-center">
<i class="bi bi-bar-chart fs-1 text-info mb-2"></i>
<h3 class="mb-0">{{ active_zbiorki|length + completed_zbiorki|length }}</h3>
<small class="text-muted">Łącznie zbiórek</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-warning">
<div class="card-body text-center">
<a href="{{ url_for('admin_statystyki') }}" class="text-decoration-none">
<i class="bi bi-graph-up-arrow fs-1 text-warning mb-2"></i>
<h5 class="mb-0 text-white">Zobacz pełne</h5>
<small class="text-muted">statystyki</small>
</a>
</div>
</div>
</div>
</div>
<!-- Pigułki: Aktywne / Zrealizowane -->
<ul class="nav nav-pills mb-3" id="adminTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="tab-aktywne" data-bs-toggle="tab" data-bs-target="#pane-aktywne"
type="button" role="tab" aria-controls="pane-aktywne" aria-selected="true">
<i class="bi bi-lightning"></i> Aktywne zbiórki ({{ active_zbiorki|length }})
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-zrealizowane" data-bs-toggle="tab" data-bs-target="#pane-zrealizowane"
type="button" role="tab" aria-controls="pane-zrealizowane" aria-selected="false">
<i class="bi bi-check-circle"></i> Zrealizowane ({{ completed_zbiorki|length }})
</button>
</li>
</ul>
<div class="tab-content">
<!-- PANE: Aktywne -->
<div class="tab-pane fade show active" id="pane-aktywne" role="tabpanel" aria-labelledby="tab-aktywne"
tabindex="0">
{% if active_zbiorki and active_zbiorki|length > 0 %}
<div class="table-responsive mb-5">
<table class="table table-dark table-striped table-hover align-middle">
<thead>
<tr>
<th style="width:72px;">ID</th>
<th>Nazwa</th>
<th style="width:140px;">Widoczność</th>
<th style="width:1%;">Opcje</th>
</tr>
</thead>
<tbody>
{% for z in active_zbiorki %}
<tr>
<td class="text-muted">{{ z.id }}</td>
<td>
<div class="d-flex flex-column">
<a href="{{ url_for('zbiorka', zbiorka_id=z.id) }}" class="fw-semibold text-decoration-none">
{{ z.nazwa }}
</a>
{% if z.cel is defined or z.stan is defined %}
<small class="text-muted">
{% if z.cel is defined and z.cel > 0 %}
Cel: {{ z.cel|round(2) }} PLN
{% endif %}
{% if z.stan is defined %}
· Stan: {{ z.stan|round(2) }} PLN
{% endif %}
{% if z.cel is defined and z.cel > 0 and z.stan is defined %}
· {{ ((z.stan / z.cel) * 100)|round(1) }}%
{% endif %}
</small>
{% endif %}
</div>
</td>
<td>
{% if z.ukryta %}
<span class="badge bg-secondary border"
style="border-color: var(--border);"><i class="bi bi-eye-slash"></i> Ukryta</span>
{% else %}
<span class="badge bg-success"><i class="bi bi-eye"></i> Widoczna</span>
{% endif %}
</td>
<td class="text-end">
<!-- Grupa akcji: główne + rozwijane -->
<div class="btn-group">
<a href="{{ url_for('formularz_zbiorek', zbiorka_id=z.id) }}"
class="btn btn-sm btn-outline-light">
<i class="bi bi-pencil"></i> Edytuj
</a>
<button class="btn btn-sm btn-outline-light dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown" aria-expanded="false" aria-label="Więcej opcji">
<span class="visually-hidden">Więcej</span>
</button>
<ul class="dropdown-menu dropdown-menu-dark dropdown-menu-end shadow">
<li>
<a class="dropdown-item" href="{{ url_for('zbiorka', zbiorka_id=z.id) }}">
<i class="bi bi-eye"></i> Podgląd
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item"
href="{{ url_for('dodaj_wplate', zbiorka_id=z.id) }}">
<i class="bi bi-plus-circle text-success"></i> Dodaj wpłatę
</a>
</li>
<li>
<a class="dropdown-item"
href="{{ url_for('dodaj_wydatek', zbiorka_id=z.id) }}">
<i class="bi bi-dash-circle text-danger"></i> Dodaj wydatek
</a>
</li>
<li>
<a class="dropdown-item"
href="{{ url_for('transakcje_zbiorki', zbiorka_id=z.id) }}">
<i class="bi bi-list-ul"></i> Transakcje
</a>
</li>
<li>
<a class="dropdown-item"
href="{{ url_for('edytuj_stan', zbiorka_id=z.id) }}">
<i class="bi bi-currency-dollar"></i> Edytuj stan
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<form action="{{ url_for('oznacz_zrealizowana', zbiorka_id=z.id) }}"
method="post" class="m-0">
<button type="submit" class="dropdown-item">
<i class="bi bi-check-circle text-success"></i> Oznacz jako zrealizowaną
</button>
</form>
</li>
<li>
<form action="{{ url_for('zmien_widzialnosc', zbiorka_id=z.id) }}"
method="post" class="m-0">
<button type="submit" class="dropdown-item">
{% if z.ukryta %}
<i class="bi bi-eye"></i> Pokaż
{% else %}
<i class="bi bi-eye-slash"></i> Ukryj
{% endif %}
</button>
</form>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<form action="{{ url_for('usun_zbiorka', zbiorka_id=z.id) }}" method="post"
class="m-0"
onsubmit="return confirm('Czy na pewno chcesz usunąć tę zbiórkę?');">
<button type="submit" class="dropdown-item text-danger">
<i class="bi bi-trash"></i> Usuń
</button>
</form>
</li>
</ul>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<!-- Empty state -->
<div class="card">
<div class="card-body text-center py-5">
<i class="bi bi-inbox fs-1 text-muted mb-3"></i>
<h5 class="mb-2">Brak aktywnych zbiórek</h5>
<p class="text-muted mb-4">Wygląda na to, że teraz nic nie zbieramy.</p>
<a href="{{ url_for('formularz_zbiorek') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Utwórz nową zbiórkę
</a>
</div>
</div>
{% endif %}
</div>
<!-- PANEL: Zrealizowane -->
<div class="tab-pane fade" id="pane-zrealizowane" role="tabpanel" aria-labelledby="tab-zrealizowane"
tabindex="0">
{% if completed_zbiorki and completed_zbiorki|length > 0 %}
<div class="table-responsive">
<table class="table table-dark table-striped table-hover align-middle">
<thead>
<tr>
<th style="width:72px;">ID</th>
<th>Nazwa</th>
<th style="width:180px;">Status</th>
<th style="width:1%;">Opcje</th>
</tr>
</thead>
<tbody>
{% for z in completed_zbiorki %}
<tr>
<td class="text-muted">{{ z.id }}</td>
<td>
<div class="d-flex flex-column">
<a href="{{ url_for('zbiorka', zbiorka_id=z.id) }}" class="fw-semibold text-decoration-none">
{{ z.nazwa }}
</a>
{% if z.cel is defined or z.stan is defined %}
<small class="text-muted">
{% if z.cel is defined %} Cel: {{ z.cel|round(2) }} PLN {% endif %}
{% if z.stan is defined %} · Zebrano: {{ z.stan|round(2) }} PLN {% endif %}
</small>
{% endif %}
</div>
</td>
<td>
<div class="d-flex align-items-center gap-2 flex-wrap">
<span class="badge rounded-pill"
style="background: var(--accent); color:#111;">
<i class="bi bi-check-circle"></i> Zrealizowana
</span>
{% if z.ukryta %}
<span class="badge bg-secondary border"
style="border-color: var(--border);"><i class="bi bi-eye-slash"></i> Ukryta</span>
{% else %}
<span class="badge bg-success"><i class="bi bi-eye"></i> Widoczna</span>
{% endif %}
</div>
</td>
<td class="text-end">
<div class="btn-group">
<a href="{{ url_for('formularz_zbiorek', zbiorka_id=z.id) }}"
class="btn btn-sm btn-outline-light">
<i class="bi bi-pencil"></i> Edytuj
</a>
<button class="btn btn-sm btn-outline-light dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown" aria-expanded="false" aria-label="Więcej opcji">
<span class="visually-hidden">Więcej</span>
</button>
<ul class="dropdown-menu dropdown-menu-dark dropdown-menu-end shadow">
<li>
<a class="dropdown-item" href="{{ url_for('zbiorka', zbiorka_id=z.id) }}">
<i class="bi bi-eye"></i> Podgląd
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item"
href="{{ url_for('dodaj_wplate', zbiorka_id=z.id) }}">
<i class="bi bi-plus-circle text-success"></i> Dodaj wpłatę
</a>
</li>
<li>
<a class="dropdown-item"
href="{{ url_for('dodaj_wydatek', zbiorka_id=z.id) }}">
<i class="bi bi-dash-circle text-danger"></i> Dodaj wydatek
</a>
</li>
<li>
<a class="dropdown-item"
href="{{ url_for('transakcje_zbiorki', zbiorka_id=z.id) }}">
<i class="bi bi-list-ul"></i> Transakcje
</a>
</li>
<li>
<a class="dropdown-item"
href="{{ url_for('edytuj_stan', zbiorka_id=z.id) }}">
<i class="bi bi-currency-dollar"></i> Edytuj stan
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<form action="{{ url_for('oznacz_niezrealizowana', zbiorka_id=z.id) }}"
method="post" class="m-0">
<button type="submit" class="dropdown-item">
<i class="bi bi-arrow-counterclockwise"></i> Oznacz jako niezrealizowaną
</button>
</form>
</li>
<li>
<form action="{{ url_for('zmien_widzialnosc', zbiorka_id=z.id) }}"
method="post" class="m-0">
<button type="submit" class="dropdown-item">
{% if z.ukryta %}
<i class="bi bi-eye"></i> Pokaż
{% else %}
<i class="bi bi-eye-slash"></i> Ukryj
{% endif %}
</button>
</form>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<form action="{{ url_for('usun_zbiorka', zbiorka_id=z.id) }}" method="post"
class="m-0"
onsubmit="return confirm('Czy na pewno chcesz usunąć tę zbiórkę?');">
<button type="submit" class="dropdown-item text-danger">
<i class="bi bi-trash"></i> Usuń
</button>
</form>
</li>
</ul>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card">
<div class="card-body text-center py-5">
<i class="bi bi-trophy fs-1 text-muted mb-3"></i>
<h5 class="mb-2">Brak zbiórek zrealizowanych</h5>
<p class="text-muted mb-3">Gdy jakaś zbiórka osiągnie 100%, pojawi się tutaj.</p>
<a href="{{ url_for('formularz_zbiorek') }}" class="btn btn-outline-light">
<i class="bi bi-plus-circle"></i> Utwórz nową zbiórkę
</a>
</div>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,85 @@
{% extends 'base.html' %}
{% block title %}Przesuń środki - {{ zbiorka.nazwa }}{% endblock %}
{% block content %}
<div class="container my-5">
<!-- Nagłówek -->
<div class="mb-4">
<h1 class="mb-2">
<i class="bi bi-arrow-left-right text-primary"></i> Przesuń środki z: {{ zbiorka.nazwa }}
</h1>
<p class="text-muted">Dostępne środki: <strong class="text-success">{{ zbiorka.stan|round(2) }} PLN</strong></p>
</div>
<!-- Formularz -->
<form method="POST">
<div class="card shadow-sm mb-3">
<div class="card-header bg-transparent">
<h5 class="mb-0">
<i class="bi bi-box-arrow-in-down text-success"></i> Cel przesunięcia
</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label for="zbiorka_cel_id" class="form-label fw-semibold">
Docelowa zbiórka <span class="text-danger">*</span>
</label>
<select class="form-select" id="zbiorka_cel_id" name="zbiorka_cel_id" required>
<option value="">-- Wybierz zbiórkę docelową --</option>
{% for zb in dostepne_zbiorki %}
<option value="{{ zb.id }}">
{% if zb.typ_zbiorki == 'rezerwa' %}
{{ zb.nazwa }} (Rezerwa)
{% else %}
{{ zb.nazwa }}
{% endif %}
· Stan: {{ zb.stan|round(2) }} PLN
</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label for="kwota" class="form-label fw-semibold">
Kwota (PLN) <span class="text-danger">*</span>
</label>
<div class="input-group">
<input type="text" class="form-control" id="kwota" name="kwota"
placeholder="np. 100.00" required>
<span class="input-group-text">PLN</span>
</div>
<small class="text-muted">
<i class="bi bi-info-circle"></i> Można użyć przecinka lub kropki
</small>
</div>
<div class="mb-0">
<label for="opis" class="form-label fw-semibold">
Opis <span class="text-muted">(opcjonalny)</span>
</label>
<textarea class="form-control" id="opis" name="opis" rows="3"
placeholder="Dodatkowe informacje o przesunięciu"></textarea>
</div>
</div>
</div>
<!-- Alert informacyjny -->
<div class="alert alert-info d-flex align-items-start mb-3">
<i class="bi bi-lightbulb fs-5 me-2"></i>
<div class="small">
<strong>Jak to działa:</strong> Kwota zostanie odjęta ze źródłowej zbiórki i dodana do docelowej.
W obu zbiórkach pojawi się wpis o przesunięciu w historii transakcji.
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-arrow-left-right"></i> Przesuń środki
</button>
<a href="{{ url_for('transakcje_zbiorki', zbiorka_id=zbiorka.id) }}"
class="btn btn-secondary">Anuluj</a>
</div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,104 @@
{% extends 'base.html' %}
{% block title %}Dodaj wpłatę{% endblock %}
{% block content %}
<div class="container my-4">
<div class="d-flex align-items-center gap-2 mb-3">
<a href="{{ url_for('zbiorka', zbiorka_id=zbiorka.id) }}" class="btn btn-sm btn-outline-light">← Powrót do zbiórki</a>
</div>
<div class="card shadow-sm">
<div class="card-header bg-secondary text-white d-flex flex-wrap align-items-center justify-content-between gap-2">
<h3 class="card-title mb-0">Dodaj wpłatę: <span class="fw-semibold">{{ zbiorka.nazwa }}</span></h3>
<div class="d-flex align-items-center gap-2">
{% if zbiorka.cel and zbiorka.typ_zbiorki != 'rezerwa' %}
<span class="badge bg-dark border" style="border-color: var(--border);">
Cel: {{ zbiorka.cel|round(2) }} PLN
</span>
{% endif %}
<span class="badge bg-dark border" style="border-color: var(--border);">
Stan: {{ zbiorka.stan|round(2) }} PLN
</span>
{% if zbiorka.cel and zbiorka.cel > 0 and zbiorka.typ_zbiorki != 'rezerwa' %}
{% set delta = zbiorka.cel - zbiorka.stan %}
{% if delta > 0 %}
<span class="badge bg-dark border" style="border-color: var(--border);">
Brakuje: {{ delta|round(2) }} PLN
</span>
{% else %}
<span class="badge bg-dark border" style="border-color: var(--border);">
Nadwyżka: {{ (-delta)|round(2) }} PLN
</span>
{% endif %}
{% endif %}
</div>
</div>
{% if zbiorka.typ_zbiorki != 'rezerwa' %}
{% set progress = (zbiorka.stan / zbiorka.cel * 100) if zbiorka.cel and zbiorka.cel > 0 else 0 %}
{% set progress_clamped = 100 if progress > 100 else (0 if progress < 0 else progress) %}
<div class="px-3 pt-3">
<div class="progress" role="progressbar" aria-valuenow="{{ progress_clamped|round(2) }}" aria-valuemin="0"
aria-valuemax="100" aria-label="Postęp zbiórki {{ progress_clamped|round(0) }} procent">
<div class="progress-bar" style="width: {{ progress_clamped }}%;"></div>
</div>
<small class="text-muted d-block mt-1 mb-2">{{ progress|round(1) }}%</small>
</div>
{% endif %}
<div class="card-body {% if zbiorka.typ_zbiorki == 'rezerwa' %}pt-3{% else %}pt-0{% endif %}">
<form method="post" novalidate>
<div class="mb-3">
<label for="kwota" class="form-label">Kwota wpłaty</label>
<div class="input-group">
<span class="input-group-text">PLN</span>
<input type="number" step="0.01" min="0.01" inputmode="decimal" class="form-control" id="kwota" name="kwota"
placeholder="0,00" required aria-describedby="kwotaHelp">
</div>
<div id="kwotaHelp" class="form-text">Podaj kwotę w złotówkach (min. 0,01).</div>
<div class="d-flex flex-wrap gap-2 mt-2">
{% for preset in [5,10,20,25,30,35,40,50,60,100,150,200] %}
<button type="button" class="btn btn-sm btn-outline-light btn-kwota" data-amount="{{ preset }}">
{{ preset }} PLN
</button>
{% endfor %}
{% if zbiorka.cel and zbiorka.cel > 0 and zbiorka.typ_zbiorki != 'rezerwa' %}
{% set brakujace = (zbiorka.cel - zbiorka.stan) if (zbiorka.cel - zbiorka.stan) > 0 else 0 %}
{% if brakujace > 0 %}
<button type="button" class="btn btn-sm btn-outline-light btn-kwota"
data-amount="{{ brakujace|round(2) }}">
Do celu: {{ brakujace|round(2) }} PLN
</button>
{% endif %}
{% endif %}
</div>
</div>
<div class="mb-3">
<label for="opis" class="form-label">Opis (opcjonalnie)</label>
<textarea class="form-control" id="opis" name="opis" rows="3" maxlength="300"
aria-describedby="opisHelp"></textarea>
<div class="d-flex justify-content-between">
<small id="opisHelp" class="form-text text-muted">Krótka notatka do wpłaty (widoczna w systemie).</small>
<small class="text-muted"><span id="opisCount">0</span>/300</small>
</div>
</div>
<div class="d-flex flex-wrap gap-2">
<button type="submit" class="btn btn-success">Dodaj wpłatę</button>
<a href="{{ url_for('zbiorka', zbiorka_id=zbiorka.id) }}" class="btn btn-outline-light">Anuluj</a>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
{{ super() }}
<script src="{{ asset_url('js/dodaj_wplate.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,79 @@
{% extends 'base.html' %}
{% block title %}Dodaj wydatek{% endblock %}
{% block content %}
<div class="container my-4">
<div class="d-flex align-items-center gap-2 mb-3">
<a href="{{ url_for('zbiorka', zbiorka_id=zbiorka.id) }}" class="btn btn-sm btn-outline-light">← Powrót
do zbiórki</a>
</div>
<div class="card shadow-sm">
<div
class="card-header bg-secondary text-white d-flex flex-wrap align-items-center justify-content-between gap-2">
<h3 class="card-title mb-0">Dodaj wydatek: <span class="fw-semibold">{{ zbiorka.nazwa }}</span></h3>
<div class="d-flex align-items-center flex-wrap gap-2">
{% if has_cel %}
<span class="badge bg-dark border" style="border-color: var(--border);">
Cel: {{ zbiorka.cel|round(2) }} PLN
</span>
{% endif %}
<span class="badge bg-dark border" style="border-color: var(--border);">
Obecnie: {{ zbiorka.stan|round(2) }} PLN
</span>
{% if has_cel %}
{% set delta = zbiorka.cel - zbiorka.stan %}
{% if delta > 0 %}
<span class="badge bg-dark border" style="border-color: var(--border);">
Brakuje: {{ delta|round(2) }} PLN
</span>
{% else %}
<span class="badge bg-dark border" style="border-color: var(--border);">
Nadwyżka: {{ (-delta)|round(2) }} PLN
</span>
{% endif %}
{% endif %}
</div>
</div>
<div class="card-body">
<form method="post" novalidate>
<div class="mb-3">
<label for="kwota" class="form-label">Kwota wydatku</label>
<div class="input-group">
<span class="input-group-text">PLN</span>
<input type="number" step="0.01" min="0.01" inputmode="decimal" class="form-control" id="kwota"
name="kwota" placeholder="0,00" required aria-describedby="kwotaHelp">
</div>
<div id="kwotaHelp" class="form-text">Podaj kwotę w złotówkach (min. 0,01).</div>
</div>
<div class="mb-3">
<label for="opis" class="form-label">Opis (opcjonalnie)</label>
<textarea class="form-control" id="opis" name="opis" rows="3" maxlength="300"
aria-describedby="opisHelp"></textarea>
<div class="d-flex justify-content-between">
<small id="opisHelp" class="form-text text-muted">Krótka notatka do wydatku (widoczna w
systemie).</small>
<small class="text-muted"><span id="opisCount">0</span>/300</small>
</div>
</div>
<div class="d-flex flex-wrap gap-2">
<button type="submit" class="btn btn-success">Dodaj wydatek</button>
<a href="{{ url_for('zbiorka', zbiorka_id=zbiorka.id) }}"
class="btn btn-outline-light">Anuluj</a>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
{{ super() }}
<script src="{{ asset_url('js/dodaj_wydatek.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,141 @@
{% extends 'base.html' %}
{% block title %}Edytuj stan zbiórki{% endblock %}
{% block content %}
<div class="container my-4">
<!-- Nawigacja -->
<div class="d-flex align-items-center gap-2 mb-3">
<a href="{{ url_for('zbiorka', zbiorka_id=zbiorka.id) }}" class="btn btn-sm btn-outline-light">← Szczegóły zbiórki</a>
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-sm btn-outline-light">← Panel Admina</a>
</div>
{# Obliczenia wstępne (do inicjalnego podglądu) #}
{% set has_cel = (zbiorka.cel is defined and zbiorka.cel and zbiorka.cel > 0 and zbiorka.typ_zbiorki != 'rezerwa') %}
{% set progress = (zbiorka.stan / zbiorka.cel * 100) if has_cel else 0 %}
{% set progress_clamped = 100 if progress > 100 else (0 if progress < 0 else progress) %}
<div class="card shadow-sm" data-module="edit-stan"
data-cel="{{ (zbiorka.cel|round(2)) if has_cel else 0 }}"
data-typ-zbiorki="{{ zbiorka.typ_zbiorki }}">
<div class="card-header bg-secondary text-white d-flex flex-wrap align-items-center justify-content-between gap-2">
<h3 class="card-title mb-0">Edytuj stan: <span class="fw-semibold">{{ zbiorka.nazwa }}</span></h3>
<div class="d-flex align-items-center flex-wrap gap-2">
{% if has_cel %}
<span class="badge bg-dark border" style="border-color: var(--border);">
Cel: {{ zbiorka.cel|round(2) }} PLN
</span>
{% endif %}
<span class="badge bg-dark border" style="border-color: var(--border);">
Obecnie: {{ zbiorka.stan|round(2) }} PLN
</span>
{% if has_cel %}
{% set delta = zbiorka.cel - zbiorka.stan %}
{% if delta > 0 %}
<span class="badge bg-dark border" style="border-color: var(--border);">
Brakuje: {{ delta|round(2) }} PLN
</span>
{% else %}
<span class="badge bg-dark border" style="border-color: var(--border);">
Nadwyżka: {{ (-delta)|round(2) }} PLN
</span>
{% endif %}
{% endif %}
</div>
</div>
<!-- Mini progress (aktualny) - tylko dla standardowych zbiórek -->
{% if zbiorka.typ_zbiorki != 'rezerwa' %}
<div class="px-3 pt-3">
<div class="progress" role="progressbar" aria-valuenow="{{ progress_clamped|round(2) }}" aria-valuemin="0"
aria-valuemax="100" aria-label="Postęp zbiórki {{ progress_clamped|round(0) }} procent">
<div class="progress-bar" style="width: {{ progress_clamped }}%;"></div>
</div>
<small class="text-muted d-block mt-1 mb-2">Aktualnie: {{ progress|round(1) }}%</small>
</div>
{% endif %}
<div class="card-body {% if zbiorka.typ_zbiorki == 'rezerwa' %}pt-3{% else %}pt-0{% endif %}">
<form method="post" novalidate>
{# {{ form.csrf_token }} #}
<!-- Nowy stan -->
<div class="mb-3">
<label for="stan" class="form-label">Nowy stan zbiórki</label>
<div class="input-group">
<span class="input-group-text">PLN</span>
<input type="number" step="0.01" min="0" class="form-control" id="stan" name="stan"
value="{{ zbiorka.stan|round(2) }}" required aria-describedby="stanHelp">
</div>
<div id="stanHelp" class="form-text">
Wpisz łączną zebraną kwotę po zmianie (nie przyrost). Skorzystaj z szybkich korekt poniżej.
</div>
<!-- Szybkie korekty -->
<div class="d-flex flex-wrap gap-2 mt-2">
{% for delta in [10,50,100,200] %}
<button type="button" class="btn btn-sm btn-outline-light btn-delta" data-delta="{{ delta }}">+{{ delta }} PLN</button>
<button type="button" class="btn btn-sm btn-outline-light btn-delta" data-delta="-{{ delta }}">-{{ delta }} PLN</button>
{% endfor %}
{% if has_cel %}
<button type="button" class="btn btn-sm btn-outline-light btn-set"
data-value="{{ zbiorka.cel|round(2) }}">Ustaw: do celu</button>
{% endif %}
<button type="button" class="btn btn-sm btn-outline-light btn-set" data-value="0">Ustaw: 0</button>
</div>
</div>
<!-- Podgląd po zmianie - tylko dla standardowych zbiórek -->
{% if zbiorka.typ_zbiorki != 'rezerwa' %}
<div class="mb-3">
<div class="card bg-dark border" style="border-color: var(--border);">
<div class="card-body">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
<div>
<div class="small text-muted">Podgląd po zapisaniu</div>
<div class="fw-semibold">
Procent realizacji:
<span id="previewPct">{{ progress|round(1) }}</span>%
</div>
</div>
</div>
<div class="mt-2">
<div class="progress" aria-hidden="true">
<div id="previewBar" class="progress-bar" style="width: {{ progress_clamped }}%;"></div>
</div>
<small class="text-muted d-block mt-1" id="previewNote">
{% if has_cel %}
{% set brakujace = (zbiorka.cel - zbiorka.stan) %}
{% if brakujace > 0 %}
Do celu brakuje: {{ brakujace|round(2) }} PLN
{% elif brakujace == 0 %}
Cel osiągnięty.
{% else %}
Przekroczono cel o: {{ (brakujace * -1)|round(2) }} PLN
{% endif %}
{% else %}
Brak zdefiniowanego celu — procent nie jest wyliczany.
{% endif %}
</small>
</div>
</div>
</div>
</div>
{% endif %}
<!-- CTA -->
<div class="d-flex flex-wrap gap-2">
<button type="submit" class="btn btn-success">Aktualizuj stan</button>
<a href="{{ url_for('zbiorka', zbiorka_id=zbiorka.id) }}" class="btn btn-outline-light">Anuluj</a>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
{{ super() }}
<script src="{{ asset_url('js/edytuj_stan.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,170 @@
{# templates/admin/formularz_rezerwy.html #}
{% extends 'base.html' %}
{% set has_obj = zbiorka is not none %}
{% set is_edit = has_obj and zbiorka.id is not none %}
{% block title %}{{ 'Edytuj listę rezerwową' if is_edit else 'Dodaj listę rezerwową' }}{% endblock %}
{% block content %}
<div class="container my-4">
<!-- Nawigacja / powrót -->
<div class="d-flex align-items-center gap-2 mb-3">
{% if is_edit and zbiorka and zbiorka.id %}
<a href="{{ url_for('zbiorka', zbiorka_id=zbiorka.id) }}" class="btn btn-sm btn-outline-light">← Szczegóły
listy</a>
{% else %}
<a href="{{ url_for('lista_rezerwowych') }}" class="btn btn-sm btn-outline-light">← Listy rezerwowe</a>
{% endif %}
</div>
<div class="card shadow-sm">
<div
class="card-header bg-secondary text-white d-flex flex-wrap align-items-center justify-content-between gap-2">
<h3 class="card-title mb-0">
{{ 'Edytuj listę rezerwową' if is_edit else 'Dodaj nową listę rezerwową' }}
</h3>
{% if is_edit %}
<div class="d-flex flex-wrap align-items-center gap-2">
<span class="badge bg-dark border" style="border-color: var(--border);">
Stan: {{ (zbiorka.stan or 0)|round(2) }} PLN
</span>
<span class="badge bg-info">Lista rezerwowa</span>
</div>
{% else %}
<small class="opacity-75">Utwórz dedykowaną listę do zarządzania środkami</small>
{% endif %}
</div>
<div class="card-body">
<form method="post" novalidate id="{{ 'form-edit-rezerwa' if is_edit else 'form-add-rezerwa' }}">
<!-- ======================================== -->
<!-- PODSTAWOWE DANE -->
<!-- ======================================== -->
<h5 class="mb-3">Podstawowe dane</h5>
<div class="row g-3 mb-4">
<div class="col-md-6">
<label for="nazwa" class="form-label">Nazwa listy <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="nazwa" name="nazwa" required
value="{{ zbiorka.nazwa if is_edit else '' }}"
placeholder="np. Nadpłaty, Środki rezerwowe">
<small class="form-text text-muted">Unikalny identyfikator tej listy rezerwowej</small>
</div>
<div class="col-md-6">
<label for="opis" class="form-label">Opis (opcjonalny)</label>
<textarea class="form-control" id="opis" name="opis" rows="3"
placeholder="Krótki opis przeznaczenia tej listy">{{ zbiorka.opis if is_edit else '' }}</textarea>
<small class="form-text text-muted">Krótki opis, który pomoże w identyfikacji</small>
</div>
</div>
<hr class="my-4">
<!-- ======================================== -->
<!-- KANAŁY PŁATNOŚCI -->
<!-- ======================================== -->
<h5 class="mb-3">Kanały płatności</h5>
<p class="text-muted small mb-3">
Określ, czy ta lista ma akceptować bezpośrednie wpłaty od użytkowników.
</p>
<div class="row g-3 mb-4">
<!-- Przelew -->
<div class="col-md-6">
<div class="border rounded p-3 h-100">
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="uzyj_konta" name="uzyj_konta"
{% if is_edit and zbiorka.uzyj_konta %}checked{% endif %}>
<label class="form-check-label fw-bold" for="uzyj_konta">
Włącz wpłaty przelewem
</label>
</div>
<div id="konto-field" style="display: {% if is_edit and zbiorka.uzyj_konta %}block{% else %}none{% endif %};">
<label for="numer_konta" class="form-label small">Numer konta</label>
<input type="text" class="form-control form-control-sm" id="numer_konta"
name="numer_konta"
value="{{ zbiorka.numer_konta if is_edit else (global_settings.numer_konta if global_settings else '') }}"
placeholder="26 cyfr numeru konta">
<small class="form-text text-muted">
{% if global_settings and global_settings.numer_konta %}
Domyślnie: {{ global_settings.numer_konta }}
{% else %}
Zostaw puste dla globalnego numeru
{% endif %}
</small>
</div>
</div>
</div>
<!-- BLIK -->
<div class="col-md-6">
<div class="border rounded p-3 h-100">
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="uzyj_blik" name="uzyj_blik"
{% if is_edit and zbiorka.uzyj_blik %}checked{% endif %}>
<label class="form-check-label fw-bold" for="uzyj_blik">
Włącz wpłaty przez BLIK
</label>
</div>
<div id="blik-field" style="display: {% if is_edit and zbiorka.uzyj_blik %}block{% else %}none{% endif %};">
<label for="numer_telefonu_blik" class="form-label small">Numer telefonu BLIK</label>
<input type="text" class="form-control form-control-sm" id="numer_telefonu_blik"
name="numer_telefonu_blik"
value="{{ zbiorka.numer_telefonu_blik if is_edit else (global_settings.numer_telefonu_blik if global_settings else '') }}"
placeholder="9 cyfr numeru telefonu">
<small class="form-text text-muted">
{% if global_settings and global_settings.numer_telefonu_blik %}
Domyślnie: {{ global_settings.numer_telefonu_blik }}
{% else %}
Zostaw puste dla globalnego numeru
{% endif %}
</small>
</div>
</div>
</div>
</div>
{% if not is_edit %}
<!-- Alert informacyjny tylko przy tworzeniu -->
<div class="alert alert-info mb-4">
<strong>Wskazówka:</strong> Lista rezerwowa to pomocnicze miejsce do gromadzenia środków,
które mogą być później przesuwane do konkretnych zbiórek. Jest ukryta dla użytkowników
nieadministracyjnych i nie pojawia się na stronie głównej.
</div>
{% endif %}
<hr class="my-4">
<!-- Przyciski akcji -->
<div class="d-flex flex-wrap gap-2 justify-content-between align-items-center">
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
{{ 'Zapisz zmiany' if is_edit else 'Utwórz listę rezerwową' }}
</button>
<a href="{{ url_for('lista_rezerwowych') }}" class="btn btn-outline-light">Anuluj</a>
</div>
{% if is_edit %}
<div>
<a href="{{ url_for('transakcje_zbiorki', zbiorka_id=zbiorka.id) }}"
class="btn btn-outline-light btn-sm">Zobacz transakcje</a>
</div>
{% endif %}
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
{{ super() }}
<script src="{{ asset_url('js/formularz_rezerwy.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,358 @@
{# templates/formularz_zbiorek.html #}
{% extends 'base.html' %}
{% set has_obj = zbiorka is not none %}
{% set is_edit = has_obj and zbiorka.id is not none %}
{% block title %}{{ 'Edytuj zbiórkę' if is_edit else 'Dodaj zbiórkę' }}{% endblock %}
{% block extra_head %}
{{ super() }}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.css">
{% endblock %}
{% block content %}
<div class="container my-4">
<!-- Nawigacja / powrót -->
<div class="d-flex align-items-center gap-2 mb-3">
{% if is_edit and zbiorka and zbiorka.id %}
<a href="{{ url_for('zbiorka', zbiorka_id=zbiorka.id) }}" class="btn btn-sm btn-outline-light">← Szczegóły
zbiórki</a>
{% else %}
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-sm btn-outline-light">← Panel Admina</a>
{% endif %}
</div>
<div class="card shadow-sm">
<div
class="card-header bg-secondary text-white d-flex flex-wrap align-items-center justify-content-between gap-2">
<h3 class="card-title mb-0">
{{ 'Edytuj zbiórkę' if is_edit else 'Dodaj nową zbiórkę' }}
</h3>
{% if is_edit %}
<div class="d-flex flex-wrap align-items-center gap-2">
{% if zbiorka.cel %}
<span class="badge bg-dark border" style="border-color: var(--border);">
Cel: {{ zbiorka.cel|round(2) }} PLN
</span>
{% endif %}
{% if not zbiorka.ukryj_kwote %}
<span class="badge bg-dark border" style="border-color: var(--border);">
Stan: {{ (zbiorka.stan or 0)|round(2) }} PLN
</span>
{% if zbiorka.cel %}
{% set delta = (zbiorka.cel or 0) - (zbiorka.stan or 0) %}
{% if delta > 0 %}
<span class="badge bg-dark border" style="border-color: var(--border);">
Brakuje: {{ delta|round(2) }} PLN
</span>
{% elif delta < 0 %} <span class="badge bg-dark border" style="border-color: var(--border);">
Nadwyżka: {{ (-delta)|round(2) }} PLN
</span>
{% endif %}
{% endif %}
{% endif %}
{% if zbiorka.ukryj_kwote %}
<span class="badge bg-secondary">Kwoty niepubliczne</span>
{% else %}
<span class="badge bg-success">Kwoty widoczne</span>
{% endif %}
</div>
{% else %}
<small class="opacity-75">Uzupełnij podstawowe dane i dane płatności</small>
{% endif %}
</div>
<div class="card-body">
<form method="post" novalidate id="{{ 'form-edit-zbiorka' if is_edit else 'form-add-zbiorka' }}">
{# {{ form.csrf_token }} jeśli używasz Flask-WTF #}
<!-- SEKCJA: Podstawowe -->
<div class="mb-4">
<h6 class="text-muted mb-2">Podstawowe</h6>
<div class="row g-3">
<div class="col-12">
<label for="nazwa" class="form-label">Nazwa zbiórki</label>
<input type="text" class="form-control" id="nazwa" name="nazwa" maxlength="120"
placeholder="{{ 'Krótki, zrozumiały tytuł' if is_edit else 'Np. Wsparcie dla schroniska Azor' }}"
value="{{ (zbiorka.nazwa if zbiorka else request.form.get('nazwa','')) }}" required
aria-describedby="nazwaHelp">
<div id="nazwaHelp" class="form-text">Krótki, zrozumiały tytuł. Max 120 znaków.</div>
</div>
<div class="col-12">
<label for="opis" class="form-label">Opis (Markdown)</label>
<textarea class="form-control" id="opis" name="opis" rows="8" required
aria-describedby="opisHelp">{{ (zbiorka.opis if zbiorka else request.form.get('opis','')) }}</textarea>
<div class="d-flex justify-content-between">
<small id="opisHelp" class="form-text text-muted">
Możesz używać Markdown (nagłówki, listy, linki). W edytorze włącz podgląd 👁️.
</small>
<small class="text-muted"><span id="opisCount">0</span> znaków</small>
</div>
</div>
</div>
</div>
<hr class="my-4" />
<!-- SEKCJA: Lista produktów -->
<div class="mb-4">
<h6 class="text-muted mb-2">Lista produktów / Pozycje / Cele</h6>
<p class="text-muted small mb-3">
Wypunktuj dokładnie produkty do zakupu — podaj nazwę, opcjonalny link do sklepu i cenę.
Status domyślnie <code>Do kupienia</code>; przełącz na <code>Kupione</code> po realizacji.
</p>
<div class="table-responsive">
<table class="table align-middle" id="produkty-table">
<thead>
<tr>
<th style="min-width:220px;">Produkt</th>
<th style="min-width:240px;">Link do sklepu</th>
<th style="width:140px;">Cena [PLN]</th>
<th style="width:160px;">Status</th>
<th style="width:60px;"></th>
</tr>
</thead>
<tbody id="produkty-body">
{% set items = (zbiorka.przedmioty if zbiorka and zbiorka.przedmioty else []) %}
{% if items %}
{% for it in items %}
{% set i = loop.index0 %}
<tr>
<td>
<input type="text" class="form-control" name="item_nazwa[]"
value="{{ it.nazwa }}" placeholder="np. Karma Brit 10kg" required>
</td>
<td>
<input type="url" class="form-control" name="item_link[]"
value="{{ it.link or '' }}" placeholder="https://...">
</td>
<td>
<input type="text" inputmode="decimal" class="form-control text-end"
name="item_cena[]"
value="{{ (it.cena|round(2)) if it.cena is not none else '' }}"
placeholder="0,00">
</td>
<td>
<div class="form-check form-switch">
<input class="form-check-input kupione-switch" type="checkbox" {% if
it.kupione %}checked{% endif %}>
<input type="hidden" name="item_kupione_val_{{ i }}"
value="{{ 1 if it.kupione else 0 }}">
<label class="form-check-label small">{{ 'Kupione' if it.kupione else 'Do
kupienia' }}</label>
</div>
</td>
<td class="text-end">
<button type="button" class="btn btn-sm btn-outline-light remove-row"
title="Usuń wiersz"></button>
</td>
</tr>
{% endfor %}
{% else %}
<!-- pusty wiersz startowy -->
<tr>
<td><input type="text" class="form-control" name="item_nazwa[]"
placeholder="np. Karma Brit 10kg" required></td>
<td><input type="url" class="form-control" name="item_link[]"
placeholder="https://..."></td>
<td><input type="text" inputmode="decimal" class="form-control text-end"
name="item_cena[]" placeholder="0,00"></td>
<td>
<div class="form-check form-switch">
<input class="form-check-input kupione-switch" type="checkbox">
<input type="hidden" name="item_kupione_val_0" value="0">
<label class="form-check-label small">Do kupienia</label>
</div>
</td>
<td class="text-end">
<button type="button" class="btn btn-sm btn-outline-light remove-row"
title="Usuń wiersz"></button>
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-sm btn-outline-light" id="add-row">+ Dodaj pozycję</button>
<button type="button" class="btn btn-sm btn-outline-light" id="clear-empty">Usuń puste
wiersze</button>
</div>
</div>
<hr class="my-4" />
<!-- SEKCJA: Dane płatności -->
<div class="mb-4">
<h6 class="text-muted mb-2">Dane płatności</h6>
<div class="row g-3">
<!-- Przełączniki kanałów -->
<div class="col-12 col-md-6">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="uzyj_konta" name="uzyj_konta" {% if
zbiorka is none or zbiorka.uzyj_konta %}checked{% endif %}>
<label class="form-check-label" for="uzyj_konta">Przelew na konto</label>
</div>
</div>
<div class="col-12 col-md-6">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="uzyj_blik" name="uzyj_blik" {% if
zbiorka is none or zbiorka.uzyj_blik %}checked{% endif %}>
<label class="form-check-label" for="uzyj_blik">BLIK</label>
</div>
</div>
<br>
<div id="kanalyWarning" class="alert alert-warning d-none mt-2" role="alert">
Musi być włączony co najmniej jeden kanał wpłat (konto lub BLIK).
</div>
<!-- IBAN -->
<div class="col-12" id="pole_konto">
<label for="numer_konta" class="form-label">Numer konta (IBAN)</label>
<div class="input-group">
<span class="input-group-text">PL</span>
<input type="text" class="form-control" id="numer_konta" name="numer_konta"
inputmode="numeric" autocomplete="off"
placeholder="12 3456 7890 1234 5678 9012 3456" {% if zbiorka and not
zbiorka.uzyj_konta %}disabled{% endif %} {% if zbiorka is none or zbiorka.uzyj_konta
%}required{% endif %} aria-describedby="ibanHelp"
value="{% if zbiorka and zbiorka.numer_konta %}{{ zbiorka.numer_konta }}{% elif global_settings %}{{ global_settings.numer_konta }}{% else %}{{ request.form.get('numer_konta','') }}{% endif %}">
</div>
<div id="ibanHelp" class="form-text">Wpisz ciąg cyfr; spacje dodadzą się automatycznie dla
czytelności.</div>
</div>
<!-- BLIK -->
<div class="col-12 col-md-6" id="pole_blik">
<label for="numer_telefonu_blik" class="form-label">Numer telefonu BLIK</label>
<div class="input-group">
<span class="input-group-text">+48</span>
<input type="tel" class="form-control" id="numer_telefonu_blik"
name="numer_telefonu_blik" inputmode="tel" pattern="[0-9 ]{9,13}"
placeholder="123 456 789" {% if zbiorka and not zbiorka.uzyj_blik %}disabled{% endif
%} {% if zbiorka is none or zbiorka.uzyj_blik %}required{% endif %}
aria-describedby="blikHelp"
value="{% if zbiorka and zbiorka.numer_telefonu_blik %}{{ zbiorka.numer_telefonu_blik }}{% elif global_settings %}{{ global_settings.numer_telefonu_blik }}{% else %}{{ request.form.get('numer_telefonu_blik','') }}{% endif %}">
</div>
<div id="blikHelp" class="form-text">Dziewięć cyfr telefonu powiązanego z BLIK. Spacje
opcjonalne.</div>
</div>
{% if is_edit %}
<div class="col-12 col-md-12 d-flex align-items-end">
<button type="button" class="btn btn-sm btn-outline-light" id="ustaw-globalne"
title="Wstaw wartości z ustawień globalnych" {% if global_settings %}
data-iban="{{ global_settings.numer_konta }}"
data-blik="{{ global_settings.numer_telefonu_blik }}" {% endif %}>
Wstaw globalne ustawienia
</button>
</div>
{% endif %}
</div>
</div>
<hr class="my-4" />
<!-- SEKCJA: Cel i widoczność -->
<div class="mb-4">
<h6 class="text-muted mb-2">Cel i widoczność</h6>
<div id="celSyncBox" class="alert d-none py-2 px-3 mb-3" role="alert">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2">
<div id="celSyncMsg" class="small"></div>
<button type="button" id="btnApplyCelFromSum"
class="btn btn-sm btn-outline-light d-none"></button>
</div>
</div>
<div class="row g-3">
<div class="col-12 col-md-6">
<label for="cel" class="form-label">Cel zbiórki</label>
<div class="input-group">
<span class="input-group-text">PLN</span>
<input type="text" inputmode="decimal" class="form-control" id="cel" name="cel"
placeholder="0,00" required aria-describedby="celHelp"
value="{% if zbiorka and zbiorka.cel is not none %}{{ zbiorka.cel }}{% else %}{{ request.form.get('cel','') }}{% endif %}">
</div>
<div id="celHelp" class="form-text">Minimalnie 0,01 PLN. Można później edytować.</div>
</div>
<div class="col-12 col-md-12 d-flex align-items-end">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="ukryj_kwote" name="ukryj_kwote" {%
if zbiorka %}{% if zbiorka.ukryj_kwote %}checked{% endif %}{% endif %}>
<label class="form-check-label" for="ukryj_kwote">Ukryj kwoty (cel i stan)</label>
</div>
</div>
</div>
<div class="row g-3 mt-2">
<div class="col-12 col-md-4">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="pokaz_postep_finanse"
name="pokaz_postep_finanse" data-group="postepy" {% if zbiorka %}{% if
zbiorka.pokaz_postep_finanse %}checked{% endif %}{% else %}checked{% endif %}>
<label class="form-check-label" for="pokaz_postep_finanse">Pokaż postęp: Finanse</label>
</div>
</div>
<div class="col-12 col-md-4">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="pokaz_postep_pozycje"
name="pokaz_postep_pozycje" data-group="postepy" {% if zbiorka %}{% if
zbiorka.pokaz_postep_pozycje %}checked{% endif %}{% else %}checked{% endif %}>
<label class="form-check-label" for="pokaz_postep_pozycje">Pokaż postęp: Zakupy
(liczba)</label>
</div>
</div>
<div class="col-12 col-md-4">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="pokaz_postep_kwotowo"
name="pokaz_postep_kwotowo" data-group="postepy" {% if zbiorka %}{% if
zbiorka.pokaz_postep_kwotowo %}checked{% endif %}{% else %}checked{% endif %}>
<label class="form-check-label" for="pokaz_postep_kwotowo">Pokaż postęp: Zakupy
(kwotowo)</label>
</div>
</div>
</div><br>
<div id="postepyWarning" class="alert alert-warning d-none mt-2" role="alert">
Nie można wyłączyć wszystkich wskaźników postępu. Pozostaw przynajmniej jeden włączony.
</div>
</div>
<!-- CTA -->
<div class="d-flex flex-wrap gap-2">
<button type="submit" class="btn btn-success">
{{ ' Zaktualizuj zbiórkę' if is_edit else 'Dodaj zbiórkę' }}
</button>
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-outline-light">Anuluj</a>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
{{ super() }}
<script src="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.js"></script>
<script src="{{ asset_url('js/mde_custom.js') }}"></script>
<script src="{{ asset_url('js/formularz_zbiorek.js') }}"></script>
<script src="{{ asset_url('js/produkty_formularz.js') }}"></script>
<script src="{{ asset_url('js/kwoty_formularz.js') }}"></script>
<script src="{{ asset_url('js/przelaczniki_zabezpieczenie.js') }}"></script>
<script src="{{ asset_url('js/sposoby_wplat.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,199 @@
{% extends 'base.html' %}
{% block title %}Listy rezerwowe{% endblock %}
{% block content %}
<div class="container my-4">
<!-- Nagłówek + akcje globalne -->
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-4">
<div>
<h2 class="mb-1">Listy rezerwowe</h2>
<p class="text-muted mb-0">Zarządzaj środkami rezerwowymi i nadpłatami</p>
</div>
<div class="d-flex flex-wrap gap-2">
<a href="{{ url_for('dodaj_rezerwe') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Dodaj listę
</a>
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-outline-light">
<i class="bi bi-arrow-left"></i> Panel Admina
</a>
</div>
</div>
<!-- Szybkie statystyki dla rezerw -->
{% if rezerwy %}
<div class="row g-3 mb-4">
<div class="col-md-4">
<div class="card border-info">
<div class="card-body text-center">
<i class="bi bi-wallet2 fs-1 text-info mb-2"></i>
<h3 class="mb-0">{{ rezerwy|length }}</h3>
<small class="text-muted">Aktywnych list</small>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-success">
<div class="card-body text-center">
<i class="bi bi-piggy-bank fs-1 text-success mb-2"></i>
<h3 class="mb-0">{{ "%.2f"|format(rezerwy|sum(attribute='stan')) }} zł</h3>
<small class="text-muted">Łączna rezerwa</small>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-warning">
<div class="card-body text-center">
<i class="bi bi-eye fs-1 text-warning mb-2"></i>
<h3 class="mb-0">{{ rezerwy|selectattr('ukryta', 'equalto', False)|list|length }}</h3>
<small class="text-muted">Widocznych publicznie</small>
</div>
</div>
</div>
</div>
{% endif %}
{% if rezerwy %}
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-dark table-striped table-hover align-middle mb-0">
<thead>
<tr>
<th style="width:72px;">ID</th>
<th>Nazwa</th>
<th class="text-end" style="width:150px;">Stan</th>
<th style="width:140px;">Widoczność</th>
<th style="width:1%;">Opcje</th>
</tr>
</thead>
<tbody>
{% for r in rezerwy %}
<tr>
<td class="text-muted">{{ r.id }}</td>
<td>
<div class="d-flex flex-column">
<a href="{{ url_for('zbiorka', zbiorka_id=r.id) }}" class="fw-semibold text-decoration-none">
<i class="bi bi-wallet2"></i> {{ r.nazwa }}
</a>
{% if r.opis %}
<small class="text-muted">
{{ r.opis[:60] }}{% if r.opis|length > 60 %}...{% endif %}
</small>
{% endif %}
</div>
</td>
<td class="text-end">
<strong class="text-success">{{ "%.2f"|format(r.stan) }} zł</strong>
</td>
<td>
{% if r.ukryta %}
<span class="badge bg-secondary border" style="border-color: var(--border);">
<i class="bi bi-eye-slash"></i> Ukryta
</span>
{% else %}
<span class="badge bg-success">
<i class="bi bi-eye"></i> Widoczna
</span>
{% endif %}
</td>
<td class="text-end">
<!-- Grupa akcji: główne + rozwijane -->
<div class="btn-group">
<a href="{{ url_for('edytuj_rezerwe', rezerwa_id=r.id) }}"
class="btn btn-sm btn-outline-light">
<i class="bi bi-pencil"></i> Edytuj
</a>
<button class="btn btn-sm btn-outline-light dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown" aria-expanded="false" aria-label="Więcej opcji">
<span class="visually-hidden">Więcej</span>
</button>
<ul class="dropdown-menu dropdown-menu-dark dropdown-menu-end shadow">
<li>
<a class="dropdown-item"
href="{{ url_for('zbiorka', zbiorka_id=r.id) }}">
<i class="bi bi-eye"></i> Podgląd
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item"
href="{{ url_for('dodaj_wplate', zbiorka_id=r.id) }}">
<i class="bi bi-plus-circle text-success"></i> Dodaj wpłatę
</a>
</li>
<li>
<a class="dropdown-item"
href="{{ url_for('dodaj_wydatek', zbiorka_id=r.id) }}">
<i class="bi bi-dash-circle text-danger"></i> Dodaj wydatek
</a>
</li>
<li>
<a class="dropdown-item"
href="{{ url_for('dodaj_przesuniecie', zbiorka_id=r.id) }}">
<i class="bi bi-arrow-left-right text-info"></i> Przesuń środki
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item"
href="{{ url_for('transakcje_zbiorki', zbiorka_id=r.id) }}">
<i class="bi bi-list-ul"></i> Transakcje
</a>
</li>
<li>
<a class="dropdown-item"
href="{{ url_for('edytuj_stan', zbiorka_id=r.id) }}">
<i class="bi bi-currency-dollar"></i> Edytuj stan
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<form action="{{ url_for('zmien_widzialnosc', zbiorka_id=r.id) }}"
method="post" class="m-0">
<button type="submit" class="dropdown-item">
{% if r.ukryta %}
<i class="bi bi-eye"></i> Pokaż
{% else %}
<i class="bi bi-eye-slash"></i> Ukryj
{% endif %}
</button>
</form>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<form action="{{ url_for('usun_rezerwe', rezerwa_id=r.id) }}" method="post"
class="m-0"
onsubmit="return confirm('Czy na pewno usunąć listę \'{{ r.nazwa }}\'?\n\nUWAGA: Zostaną usunięte wszystkie transakcje powiązane z tą listą!');">
<button type="submit" class="dropdown-item text-danger">
<i class="bi bi-trash"></i> Usuń
</button>
</form>
</li>
</ul>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<!-- Empty state -->
<div class="card">
<div class="card-body text-center py-5">
<i class="bi bi-wallet2 fs-1 text-muted mb-3"></i>
<h5 class="mb-2">Brak list rezerwowych</h5>
<p class="text-muted mb-4">
Nie masz jeszcze żadnych list rezerwowych.<br>
Utwórz pierwszą, aby zarządzać nadpłatami i środkami rezerwowymi.
</p>
<a href="{{ url_for('dodaj_rezerwe') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Dodaj listę rezerwową
</a>
</div>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,119 @@
{% extends 'base.html' %}
{% block title %}Przesuń wpłatę - {{ zbiorka.nazwa }}{% endblock %}
{% block content %}
<div class="container my-5">
<!-- Nagłówek -->
<div class="mb-4">
<h1 class="mb-2">
<i class="bi bi-arrow-left-right text-primary"></i> Przesuń konkretną wpłatę
</h1>
<p class="text-muted">Przenieś wybraną wpłatę do innej zbiórki</p>
</div>
<!-- Szczegóły wpłaty -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-transparent">
<h5 class="mb-0">
<i class="bi bi-receipt text-success"></i> Szczegóły wpłaty
</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<div class="border-start border-primary border-3 ps-3">
<small class="text-muted d-block">Źródło</small>
<strong class="fs-6">{{ zbiorka.nazwa }}</strong>
{% if zbiorka.typ_zbiorki == 'rezerwa' %}
<span class="badge bg-info ms-2">Lista rezerwowa</span>
{% endif %}
</div>
</div>
<div class="col-md-6">
<div class="border-start border-success border-3 ps-3">
<small class="text-muted d-block">Kwota</small>
<strong class="fs-4 text-success">{{ wplata.kwota|round(2) }} PLN</strong>
</div>
</div>
<div class="col-md-6">
<div class="border-start border-secondary border-3 ps-3">
<small class="text-muted d-block">Data wpłaty</small>
<strong>{{ wplata.data|dt("%d.%m.%Y %H:%M") }}</strong>
</div>
</div>
{% if wplata.opis %}
<div class="col-md-6">
<div class="border-start border-secondary border-3 ps-3">
<small class="text-muted d-block">Opis oryginalny</small>
<strong>{{ wplata.opis }}</strong>
</div>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Formularz przesunięcia -->
<form method="POST">
<div class="card shadow-sm mb-3">
<div class="card-header bg-transparent">
<h5 class="mb-0">
<i class="bi bi-box-arrow-right text-warning"></i> Cel przesunięcia
</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label for="zbiorka_cel_id" class="form-label fw-semibold">
Docelowa zbiórka <span class="text-danger">*</span>
</label>
<select class="form-select" id="zbiorka_cel_id" name="zbiorka_cel_id" required>
<option value="">-- Wybierz zbiórkę docelową --</option>
{% for zb in dostepne_zbiorki %}
<option value="{{ zb.id }}">
{% if zb.typ_zbiorki == 'rezerwa' %}
[Rezerwa] {{ zb.nazwa }} · Stan: {{ zb.stan|round(2) }} PLN
{% else %}
{{ zb.nazwa }} · Stan: {{ zb.stan|round(2) }} PLN
{% endif %}
</option>
{% endfor %}
</select>
</div>
<div class="mb-0">
<label for="opis" class="form-label fw-semibold">
Dodatkowy opis przesunięcia <span class="text-muted">(opcjonalny)</span>
</label>
<textarea class="form-control" id="opis" name="opis" rows="3"
placeholder="np. Powód przesunięcia, notatki..."></textarea>
<small class="text-muted">
<i class="bi bi-info-circle"></i> Oryginalny opis wpłaty zostanie zachowany
</small>
</div>
</div>
</div>
<!-- Alert informacyjny -->
<div class="alert alert-info d-flex align-items-start mb-3">
<i class="bi bi-lightbulb fs-5 me-2"></i>
<div class="small">
<strong>Jak to działa:</strong>
<ul class="mb-0 mt-1">
<li>Wpłata zostanie przeniesiona do wybranej zbiórki wraz z całą historią</li>
<li>Zostanie utworzony wpis o przesunięciu w obu zbiórkach</li>
<li>Stany finansowe zostaną automatycznie zaktualizowane</li>
</ul>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-arrow-left-right"></i> Wykonaj przesunięcie
</button>
<a href="{{ url_for('transakcje_zbiorki', zbiorka_id=zbiorka.id) }}"
class="btn btn-secondary">Anuluj</a>
</div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,461 @@
{% extends 'base.html' %}
{% block title %}Statystyki - Panel Admina{% endblock %}
{% block content %}
<div class="container my-4">
<!-- Nagłówek + akcje globalne -->
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-4">
<h2 class="mb-0">Statystyki systemu</h2>
<div class="d-flex flex-wrap gap-2">
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-outline-light">
← Powrót do panelu
</a>
<a href="{{ url_for('admin_ustawienia') }}" class="btn btn-outline-light">
Ustawienia główne
</a>
</div>
</div>
<!-- KARTY PODSUMOWANIA -->
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="card border-success">
<div class="card-body text-center">
<h6 class="text-success mb-2">Suma wpłat</h6>
<h3 class="mb-1">{{ "%.2f"|format(total_wplaty) }} zł</h3>
<small class="text-muted">{{ liczba_wplat }} wpłat</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-danger">
<div class="card-body text-center">
<h6 class="text-danger mb-2">Suma wydatków</h6>
<h3 class="mb-1">{{ "%.2f"|format(total_wydatki) }} zł</h3>
<small class="text-muted">{{ liczba_wydatkow }} wydatków</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-info">
<div class="card-body text-center">
<h6 class="text-info mb-2">Suma przesunięć</h6>
<h3 class="mb-1">{{ "%.2f"|format(total_przesuniec) }} zł</h3>
<small class="text-muted">{{ liczba_przesuniec }} operacji</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card {% if bilans >= 0 %}border-success{% else %}border-danger{% endif %}">
<div class="card-body text-center">
<h6 class="{% if bilans >= 0 %}text-success{% else %}text-danger{% endif %} mb-2">Bilans</h6>
<h3 class="mb-1 {% if bilans >= 0 %}text-success{% else %}text-danger{% endif %}">
{{ "%.2f"|format(bilans) }} zł
</h3>
<small class="text-muted">{{ liczba_zbiorek }} zbiórek</small>
</div>
</div>
</div>
</div>
<!-- Pigułki: Podsumowanie / Aktywność / Miesięczne / Roczne -->
<ul class="nav nav-pills mb-3" id="statsTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="tab-podsumowanie" data-bs-toggle="tab" data-bs-target="#pane-podsumowanie"
type="button" role="tab" aria-controls="pane-podsumowanie" aria-selected="true">
Podsumowanie
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-aktywnosc" data-bs-toggle="tab" data-bs-target="#pane-aktywnosc"
type="button" role="tab" aria-controls="pane-aktywnosc" aria-selected="false">
Aktywność 7/30 dni
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-miesieczne" data-bs-toggle="tab" data-bs-target="#pane-miesieczne"
type="button" role="tab" aria-controls="pane-miesieczne" aria-selected="false">
Miesięczne
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-roczne" data-bs-toggle="tab" data-bs-target="#pane-roczne"
type="button" role="tab" aria-controls="pane-roczne" aria-selected="false">
Roczne
</button>
</li>
</ul>
<div class="tab-content">
<!-- PANE: Podsumowanie -->
<div class="tab-pane fade show active" id="pane-podsumowanie" role="tabpanel" aria-labelledby="tab-podsumowanie" tabindex="0">
<!-- Rekordy -->
<div class="row g-3 mb-4">
<div class="col-md-4">
<div class="card">
<div class="card-header bg-success text-white">
<h6 class="mb-0">Najwyższa wpłata</h6>
</div>
<div class="card-body">
{% if najwyzsza_wplata %}
<h4 class="mb-2">{{ "%.2f"|format(najwyzsza_wplata.kwota) }} zł</h4>
<p class="mb-1"><strong>Opis:</strong> {{ najwyzsza_wplata.opis or "Brak opisu" }}</p>
<p class="mb-1"><strong>Data:</strong> {{ najwyzsza_wplata.data.strftime('%d.%m.%Y') }}</p>
<p class="mb-0"><strong>Zbiórka:</strong>
<a href="{{ url_for('zbiorka', zbiorka_id=najwyzsza_wplata.zbiorka_id) }}" class="text-decoration-none">
{{ najwyzsza_wplata.zbiorka.nazwa }}
</a>
</p>
{% else %}
<p class="text-muted mb-0">Brak wpłat</p>
{% endif %}
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header bg-danger text-white">
<h6 class="mb-0">Najwyższy wydatek</h6>
</div>
<div class="card-body">
{% if najwyzszy_wydatek %}
<h4 class="mb-2">{{ "%.2f"|format(najwyzszy_wydatek.kwota) }} zł</h4>
<p class="mb-1"><strong>Opis:</strong> {{ najwyzszy_wydatek.opis or "Brak opisu" }}</p>
<p class="mb-1"><strong>Data:</strong> {{ najwyzszy_wydatek.data.strftime('%d.%m.%Y') }}</p>
<p class="mb-0"><strong>Zbiórka:</strong>
<a href="{{ url_for('zbiorka', zbiorka_id=najwyzszy_wydatek.zbiorka_id) }}" class="text-decoration-none">
{{ najwyzszy_wydatek.zbiorka.nazwa }}
</a>
</p>
{% else %}
<p class="text-muted mb-0">Brak wydatków</p>
{% endif %}
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header bg-info text-white">
<h6 class="mb-0">Średnie wartości</h6>
</div>
<div class="card-body">
<p class="mb-2"><strong>Średnia wpłata:</strong> {{ "%.2f"|format(srednia_wplata) }} zł</p>
<p class="mb-0"><strong>Średni wydatek:</strong> {{ "%.2f"|format(sredni_wydatek) }} zł</p>
</div>
</div>
</div>
</div>
<!-- Top 10 wpłat -->
<div class="card mb-4">
<div class="card-header" style="background: var(--accent); color: #111;">
<h6 class="mb-0">Top 10 najwyższych wpłat</h6>
</div>
<div class="card-body p-0">
{% if top_10_wplat %}
<div class="table-responsive">
<table class="table table-dark table-striped table-hover align-middle mb-0">
<thead>
<tr>
<th style="width:60px;">#</th>
<th>Kwota</th>
<th>Opis</th>
<th>Data</th>
<th>Zbiórka</th>
</tr>
</thead>
<tbody>
{% for wplata in top_10_wplat %}
<tr>
<td class="text-muted">{{ loop.index }}</td>
<td><strong class="text-success">{{ "%.2f"|format(wplata.kwota) }} zł</strong></td>
<td>{{ wplata.opis or "Brak opisu" }}</td>
<td class="text-muted">{{ wplata.data.strftime('%d.%m.%Y') }}</td>
<td>
<a href="{{ url_for('zbiorka', zbiorka_id=wplata.zbiorka_id) }}" class="text-decoration-none">
{{ wplata.zbiorka.nazwa }}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card-body text-center py-4">
<p class="text-muted mb-0">Brak danych</p>
</div>
{% endif %}
</div>
</div>
<!-- Top 5 zbiórek -->
<div class="card mb-4">
<div class="card-header bg-primary text-white">
<h6 class="mb-0">Top 5 zbiórek (największe wpłaty)</h6>
</div>
<div class="card-body p-0">
{% if top_zbiorki %}
<div class="table-responsive">
<table class="table table-dark table-striped table-hover align-middle mb-0">
<thead>
<tr>
<th style="width:60px;">#</th>
<th>Nazwa zbiórki</th>
<th class="text-end" style="width:180px;">Suma wpłat</th>
</tr>
</thead>
<tbody>
{% for zbiorka, suma in top_zbiorki %}
<tr>
<td class="text-muted">{{ loop.index }}</td>
<td>
<a href="{{ url_for('zbiorka', zbiorka_id=zbiorka.id) }}" class="text-decoration-none">
{{ zbiorka.nazwa }}
</a>
</td>
<td class="text-end"><strong>{{ "%.2f"|format(suma) }} zł</strong></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card-body text-center py-4">
<p class="text-muted mb-0">Brak danych</p>
</div>
{% endif %}
</div>
</div>
<!-- Top 5 źródeł przesunięć -->
<div class="card">
<div class="card-header bg-info text-white">
<h6 class="mb-0">Top 5 źródeł przesunięć</h6>
</div>
<div class="card-body p-0">
{% if top_zrodla_przesuniec %}
<div class="table-responsive">
<table class="table table-dark table-striped table-hover align-middle mb-0">
<thead>
<tr>
<th style="width:60px;">#</th>
<th>Zbiórka źródłowa</th>
<th class="text-end">Liczba przesunięć</th>
<th class="text-end" style="width:180px;">Suma</th>
</tr>
</thead>
<tbody>
{% for nazwa, liczba, suma in top_zrodla_przesuniec %}
<tr>
<td class="text-muted">{{ loop.index }}</td>
<td>{{ nazwa }}</td>
<td class="text-end text-muted">{{ liczba }}</td>
<td class="text-end"><strong>{{ "%.2f"|format(suma) }} zł</strong></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card-body text-center py-4">
<p class="text-muted mb-0">Brak przesunięć</p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- PANE: Aktywność 7/30 dni -->
<div class="tab-pane fade" id="pane-aktywnosc" role="tabpanel" aria-labelledby="tab-aktywnosc" tabindex="0">
<div class="row g-3">
<!-- Ostatnie 7 dni -->
<div class="col-md-6">
<div class="card">
<div class="card-header bg-success text-white">
<h6 class="mb-0">Ostatnie 7 dni</h6>
</div>
<div class="card-body">
<h5 class="mb-3">Wpłaty</h5>
<p class="mb-1"><strong>Liczba:</strong> {{ wplaty_7dni.liczba or 0 }}</p>
<p class="mb-3"><strong>Suma:</strong> {{ "%.2f"|format(wplaty_7dni.suma or 0) }} zł</p>
<h5 class="mb-3">Wydatki</h5>
<p class="mb-1"><strong>Liczba:</strong> {{ wydatki_7dni.liczba or 0 }}</p>
<p class="mb-0"><strong>Suma:</strong> {{ "%.2f"|format(wydatki_7dni.suma or 0) }} zł</p>
</div>
</div>
</div>
<!-- Ostatnie 30 dni -->
<div class="col-md-6">
<div class="card">
<div class="card-header bg-primary text-white">
<h6 class="mb-0">Ostatnie 30 dni</h6>
</div>
<div class="card-body">
<h5 class="mb-3">Wpłaty</h5>
<p class="mb-1"><strong>Liczba:</strong> {{ wplaty_30dni.liczba or 0 }}</p>
<p class="mb-3"><strong>Suma:</strong> {{ "%.2f"|format(wplaty_30dni.suma or 0) }} zł</p>
<h5 class="mb-3">Wydatki</h5>
<p class="mb-1"><strong>Liczba:</strong> {{ wydatki_30dni.liczba or 0 }}</p>
<p class="mb-0"><strong>Suma:</strong> {{ "%.2f"|format(wydatki_30dni.suma or 0) }} zł</p>
</div>
</div>
</div>
</div>
</div>
<!-- PANE: Statystyki miesięczne -->
<div class="tab-pane fade" id="pane-miesieczne" role="tabpanel" aria-labelledby="tab-miesieczne" tabindex="0">
<div class="card">
<div class="card-header" style="background: var(--accent); color: #111;">
<h6 class="mb-0">Ostatnie 12 miesięcy</h6>
</div>
<div class="card-body p-0">
{% if wplaty_miesieczne or wydatki_miesieczne or przesuniecia_miesieczne %}
<div class="table-responsive">
<table class="table table-dark table-striped table-hover align-middle mb-0">
<thead>
<tr>
<th>Miesiąc</th>
<th class="text-end">Wpłaty (suma)</th>
<th class="text-end">Wpłaty (liczba)</th>
<th class="text-end">Wydatki (suma)</th>
<th class="text-end">Wydatki (liczba)</th>
<th class="text-end">Przesunięcia (suma)</th>
<th class="text-end">Bilans</th>
</tr>
</thead>
<tbody>
{% set wplaty_dict = {} %}
{% set wydatki_dict = {} %}
{% set przesuniecia_dict = {} %}
{% for rok, miesiac, suma, liczba in wplaty_miesieczne %}
{% set klucz = "%d-%02d"|format(rok|int, miesiac|int) %}
{% set _ = wplaty_dict.update({klucz: {'suma': suma, 'liczba': liczba}}) %}
{% endfor %}
{% for rok, miesiac, suma, liczba in wydatki_miesieczne %}
{% set klucz = "%d-%02d"|format(rok|int, miesiac|int) %}
{% set _ = wydatki_dict.update({klucz: {'suma': suma, 'liczba': liczba}}) %}
{% endfor %}
{% for rok, miesiac, suma, liczba in przesuniecia_miesieczne %}
{% set klucz = "%d-%02d"|format(rok|int, miesiac|int) %}
{% set _ = przesuniecia_dict.update({klucz: {'suma': suma, 'liczba': liczba}}) %}
{% endfor %}
{% set miesiace = (wplaty_dict.keys() | list + wydatki_dict.keys() | list + przesuniecia_dict.keys() | list) | unique | sort | reverse %}
{% for miesiac_key in miesiace %}
{% set wp = wplaty_dict.get(miesiac_key, {'suma': 0, 'liczba': 0}) %}
{% set wy = wydatki_dict.get(miesiac_key, {'suma': 0, 'liczba': 0}) %}
{% set pr = przesuniecia_dict.get(miesiac_key, {'suma': 0, 'liczba': 0}) %}
{% set bilans_m = wp.suma - wy.suma %}
<tr>
<td><strong>{{ miesiac_key }}</strong></td>
<td class="text-end text-success">{{ "%.2f"|format(wp.suma) }} zł</td>
<td class="text-end text-muted">{{ wp.liczba }}</td>
<td class="text-end text-danger">{{ "%.2f"|format(wy.suma) }} zł</td>
<td class="text-end text-muted">{{ wy.liczba }}</td>
<td class="text-end text-info">{{ "%.2f"|format(pr.suma) }} zł</td>
<td class="text-end {% if bilans_m >= 0 %}text-success{% else %}text-danger{% endif %}">
<strong>{{ "%.2f"|format(bilans_m) }} zł</strong>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card-body text-center py-4">
<p class="text-muted mb-0">Brak danych</p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- PANE: Statystyki roczne -->
<div class="tab-pane fade" id="pane-roczne" role="tabpanel" aria-labelledby="tab-roczne" tabindex="0">
<div class="card">
<div class="card-header bg-secondary text-white">
<h6 class="mb-0">Zestawienie roczne</h6>
</div>
<div class="card-body p-0">
{% if wplaty_roczne or wydatki_roczne %}
<div class="table-responsive">
<table class="table table-dark table-striped table-hover align-middle mb-0">
<thead>
<tr>
<th>Rok</th>
<th class="text-end">Wpłaty (suma)</th>
<th class="text-end">Wpłaty (liczba)</th>
<th class="text-end">Wydatki (suma)</th>
<th class="text-end">Wydatki (liczba)</th>
<th class="text-end">Bilans</th>
</tr>
</thead>
<tbody>
{% set wplaty_dict = {} %}
{% set wydatki_dict = {} %}
{% for rok, suma, liczba in wplaty_roczne %}
{% set _ = wplaty_dict.update({rok|int: {'suma': suma, 'liczba': liczba}}) %}
{% endfor %}
{% for rok, suma, liczba in wydatki_roczne %}
{% set _ = wydatki_dict.update({rok|int: {'suma': suma, 'liczba': liczba}}) %}
{% endfor %}
{% set lata = (wplaty_dict.keys() | list + wydatki_dict.keys() | list) | unique | sort | reverse %}
{% for rok in lata %}
{% set wp = wplaty_dict.get(rok, {'suma': 0, 'liczba': 0}) %}
{% set wy = wydatki_dict.get(rok, {'suma': 0, 'liczba': 0}) %}
{% set bilans_rok = wp.suma - wy.suma %}
<tr>
<td><strong>{{ rok|int }}</strong></td>
<td class="text-end text-success">{{ "%.2f"|format(wp.suma) }} zł</td>
<td class="text-end text-muted">{{ wp.liczba }}</td>
<td class="text-end text-danger">{{ "%.2f"|format(wy.suma) }} zł</td>
<td class="text-end text-muted">{{ wy.liczba }}</td>
<td class="text-end {% if bilans_rok >= 0 %}text-success{% else %}text-danger{% endif %}">
<strong>{{ "%.2f"|format(bilans_rok) }} zł</strong>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card-body text-center py-4">
<p class="text-muted mb-0">Brak danych</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,223 @@
{% extends 'base.html' %}
{% block title %}Transakcje {{ zbiorka.nazwa }}{% endblock %}
{% block content %}
<div class="container my-4">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
<div>
<h3 class="mb-1">Transakcje: {{ zbiorka.nazwa }}</h3>
<div class="d-flex flex-wrap align-items-center gap-2">
{% if zbiorka.typ_zbiorki == 'rezerwa' %}
<span class="badge bg-info">Lista rezerwowa</span>
{% endif %}
{% if zbiorka.cel and zbiorka.typ_zbiorki != 'rezerwa' %}
<span class="badge bg-dark border" style="border-color: var(--border);">
Cel: {{ zbiorka.cel|round(2) }} PLN
</span>
{% endif %}
<span class="badge bg-dark border border-success" style="border-color: var(--border);">
Stan: {{ zbiorka.stan|round(2) }} PLN
</span>
{% if zbiorka.cel and zbiorka.cel > 0 and zbiorka.typ_zbiorki != 'rezerwa' %}
{% set delta = zbiorka.cel - zbiorka.stan %}
{% if delta > 0 %}
<span class="badge bg-dark border border-warning">
Brakuje: {{ delta|round(2) }} PLN
</span>
{% elif delta < 0 %}
<span class="badge bg-dark border" style="border-color: var(--border);">
Nadwyżka: {{ (-delta)|round(2) }} PLN
</span>
{% else %}
<span class="badge rounded-pill" style="background: var(--accent); color:#111;">Zrealizowana</span>
{% endif %}
{% endif %}
</div>
</div>
<div class="btn-group" role="group" aria-label="Akcje zbiórki">
<a class="btn btn-sm btn-outline-light" href="{{ url_for('dodaj_wplate', zbiorka_id=zbiorka.id) }}">
Dodaj wpłatę
</a>
<a class="btn btn-sm btn-outline-light" href="{{ url_for('dodaj_wydatek', zbiorka_id=zbiorka.id) }}">
Dodaj wydatek
</a>
<a class="btn btn-sm btn-outline-light" href="{{ url_for('dodaj_przesuniecie', zbiorka_id=zbiorka.id) }}">
Przesuń środki
</a>
<a class="btn btn-sm btn-outline-light" href="{{ url_for('edytuj_stan', zbiorka_id=zbiorka.id) }}">
Edytuj stan
</a>
<a class="btn btn-sm btn-outline-light" href="{{ url_for('zbiorka', zbiorka_id=zbiorka.id) }}">
Otwórz ↗
</a>
</div>
</div>
<div class="card shadow-sm">
<div class="card-body">
<div class="table-responsive">
<table class="table align-middle">
<thead>
<tr>
<th>Data</th>
<th>Typ</th>
<th>Widoczność</th>
<th class="text-end">Kwota</th>
<th>Opis</th>
<th class="text-end"></th>
</tr>
</thead>
<tbody>
{% for a in aktywnosci %}
<tr data-tx-id="{{ a.id }}" data-tx-typ="{{ a.typ }}">
<td>{{ a.data|dt("%d.%m.%Y %H:%M") }}</td>
<td>
<span class="badge {{ 'bg-success' if a.typ=='wpłata' else 'bg-danger' }}">{{ a.typ }}</span>
</td>
<td>
{% if a.ukryta %}
<span class="badge bg-warning ms-1">ukryta</span>
{% else %}
<span class="badge bg-success ms-1">widoczna</span>
{% endif %}
</td>
<td class="text-end">{{ '%.2f'|format(a.kwota) }} PLN</td>
<td class="text-muted">{{ a.opis or '—' }}</td>
<td class="text-end">
<div class="d-inline-flex flex-nowrap align-items-center gap-2">
{% if a.typ == 'wpłata' %}
<a class="btn btn btn-sm btn-outline-light btn-edit-wplata"
href="{{ url_for('przesun_wplate', zbiorka_id=zbiorka.id, wplata_id=a.id) }}"
title="Przesuń tę wpłatę"> Przesuń
</a>
<button class="btn btn-sm btn-outline-light btn-edit-wplata" data-id="{{ a.id }}"
data-kwota="{{ '%.2f'|format(a.kwota) }}" data-opis="{{ a.opis|e if a.opis }}"
data-action="{{ url_for('zapisz_wplate', wplata_id=a.id) }}">
Edytuj
</button>
{% if a.ukryta %}
<form class="d-inline" method="post"
action="{{ url_for('odkryj_wplate', wplata_id=a.id) }}">
<button class="btn btn-sm btn-outline-success">Odkryj</button>
</form>
{% else %}
<form class="d-inline" method="post"
action="{{ url_for('ukryj_wplate', wplata_id=a.id) }}">
<button class="btn btn-sm btn-outline-warning">Ukryj</button>
</form>
{% endif %}
<form class="d-inline" method="post"
action="{{ url_for('usun_wplate', wplata_id=a.id) }}"
onsubmit="return confirm('Usunąć wpłatę? Cofnie to wpływ na stan.');">
<button class="btn btn-sm btn-outline-danger">Usuń</button>
</form>
{% else %}
<button class="btn btn-sm btn-outline-light btn-edit-wydatek" data-id="{{ a.id }}"
data-kwota="{{ '%.2f'|format(a.kwota) }}" data-opis="{{ a.opis|e if a.opis }}"
data-action="{{ url_for('zapisz_wydatek', wydatek_id=a.id) }}">
Edytuj
</button>
{% if a.ukryta %}
<form class="d-inline" method="post"
action="{{ url_for('odkryj_wydatek', wydatek_id=a.id) }}">
<button class="btn btn-sm btn-outline-success">Odkryj</button>
</form>
{% else %}
<form class="d-inline" method="post"
action="{{ url_for('ukryj_wydatek', wydatek_id=a.id) }}">
<button class="btn btn-sm btn-outline-warning">Ukryj</button>
</form>
{% endif %}
<form class="d-inline" method="post"
action="{{ url_for('usun_wydatek', wydatek_id=a.id) }}"
onsubmit="return confirm('Usunąć wydatek? Cofnie to wpływ na stan.');">
<button class="btn btn-sm btn-outline-danger">Usuń</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="text-center text-muted py-4">Brak transakcji.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{# === MODAL: Edycja wpłaty === #}
<div class="modal fade" id="modalWplata" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<form class="modal-content" method="post" id="formWplata">
<div class="modal-header">
<h5 class="modal-title">Edytuj wpłatę</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Zamknij"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Kwota (PLN)</label>
<input class="form-control text-end" name="kwota" step="0.01" min="0.01" id="wplataKwota"
inputmode="decimal" required>
</div>
<div class="mb-3">
<label class="form-label">Opis</label>
<textarea class="form-control" name="opis" id="wplataOpis" rows="3"></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-success">Zapisz</button>
<button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">Anuluj</button>
</div>
</form>
</div>
</div>
{# === MODAL: Edycja wydatku === #}
<div class="modal fade" id="modalWydatek" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<form class="modal-content" method="post" id="formWydatek">
<div class="modal-header">
<h5 class="modal-title">Edytuj wydatek</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Zamknij"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Kwota (PLN)</label>
<input class="form-control text-end" name="kwota" step="0.01" min="0.01" id="wydatekKwota"
inputmode="decimal" required>
</div>
<div class="mb-3">
<label class="form-label">Opis</label>
<textarea class="form-control" name="opis" id="wydatekOpis" rows="3"></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-success">Zapisz</button>
<button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">Anuluj</button>
</div>
</form>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
{{ super() }}
<script src="{{ asset_url('js/transakcje.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,223 @@
{% extends 'base.html' %}
{% block title %}Ustawienia globalne{% endblock %}
{% block content %}
<div class="container my-4">
<!-- Nagłówek -->
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-4">
<div>
<h2 class="mb-1">Ustawienia globalne</h2>
<p class="text-muted mb-0">Konfiguracja systemu, płatności i wyglądu</p>
</div>
<div class="d-flex flex-wrap gap-2">
<a href="{{ url_for('admin_statystyki') }}" class="btn btn-outline-light">
Statystyki
</a>
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-outline-light">
← Panel Admina
</a>
</div>
</div>
<form method="post" novalidate id="form-global-settings">
<!-- SEKCJA: Dane płatności -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-secondary text-white d-flex align-items-center justify-content-between gap-2">
<h3 class="card-title mb-0">Dane płatności</h3>
<small class="opacity-75">Wartości domyślne dla zbiórek</small>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-12">
<label for="numer_konta" class="form-label">Globalny numer konta (IBAN)</label>
<div class="input-group">
<span class="input-group-text">PL</span>
<input type="text" class="form-control" id="numer_konta" name="numer_konta"
value="{{ settings.numer_konta if settings else '' }}" inputmode="numeric" autocomplete="off"
placeholder="12 3456 7890 1234 5678 9012 3456" required aria-describedby="ibanHelp">
</div>
<div id="ibanHelp" class="form-text">Wpisz ciąg cyfr — spacje dodadzą się automatycznie co 4 znaki</div>
</div>
<div class="col-12 col-md-6">
<label for="numer_telefonu_blik" class="form-label">Globalny numer telefonu BLIK</label>
<div class="input-group">
<span class="input-group-text">+48</span>
<input type="tel" class="form-control" id="numer_telefonu_blik" name="numer_telefonu_blik"
value="{{ settings.numer_telefonu_blik if settings else '' }}" inputmode="tel" pattern="[0-9 ]{9,13}"
placeholder="123 456 789" required aria-describedby="blikHelp">
</div>
<div id="blikHelp" class="form-text">9 cyfr. Format 3-3-3 dla czytelności</div>
</div>
</div>
</div>
</div>
<!-- SEKCJA: Dostępy / biała lista IP -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-secondary text-white d-flex align-items-center justify-content-between gap-2">
<h3 class="card-title mb-0">Kontrola dostępu</h3>
<small class="opacity-75">Biała lista IP/hostów dla logowania</small>
</div>
<div class="card-body">
<!-- Wiersz z inputem i przyciskiem dodawania -->
<div class="row g-3 align-items-end">
<div class="col-12 col-lg-8">
<label for="host_input" class="form-label">Dodaj IP/host</label>
<div class="input-group">
<input type="text" class="form-control" id="host_input"
placeholder="np. 203.0.113.42 lub corp.example.com" aria-describedby="hostAddHelp">
<button type="button" class="btn btn-outline-light" id="btn-add-host">
Dodaj
</button>
</div>
<div id="hostAddHelp" class="form-text">Po wpisaniu kliknij „Dodaj". Duplikaty są pomijane</div>
</div>
<div class="col-12 col-lg-4">
<div class="d-flex flex-wrap gap-2 justify-content-lg-end">
<button type="button" class="btn btn-light text-dark" id="btn-add-my-ip" data-my-ip="{{ client_ip }}">
Dodaj moje IP ({{ client_ip }})
</button>
<button type="button" class="btn btn-outline-light" id="btn-dedupe">
Usuń duplikaty
</button>
</div>
</div>
</div>
<div class="mt-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<label for="dozwolone_hosty_logowania" class="form-label mb-0">
Dozwolone hosty logowania (jeden na linię lub rozdzielone przecinkami)
</label>
<span class="badge text-bg-secondary">Pozycji: <span id="hostsCount">0</span></span>
</div>
<textarea class="form-control" id="dozwolone_hosty_logowania" name="dozwolone_hosty_logowania" rows="6"
placeholder="Adresy IP lub nazwy domen — każdy w osobnej linii lub rozdzielony przecinkiem">{{ settings.dozwolone_hosty_logowania if settings and settings.dozwolone_hosty_logowania else '' }}</textarea>
<small class="text-muted d-block mt-1">
Akceptowane separatory: przecinek (`,`), średnik (`;`) i nowa linia
</small>
</div>
</div>
</div>
<!-- SEKCJA: Kolejność list rezerwowych -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-secondary text-white d-flex align-items-center justify-content-between gap-2">
<h3 class="card-title mb-0">Kolejność wyświetlania</h3>
<small class="opacity-75">Pozycja list rezerwowych na stronie głównej</small>
</div>
<div class="card-body">
<div class="mb-3">
<label for="kolejnosc_rezerwowych" class="form-label fw-semibold">Kolejność list rezerwowych</label>
<select class="form-select" id="kolejnosc_rezerwowych" name="kolejnosc_rezerwowych">
<option value="id" {% if settings and settings.kolejnosc_rezerwowych == 'id' %}selected{% endif %}>
Według ID (domyślnie)
</option>
<option value="first" {% if settings and settings.kolejnosc_rezerwowych == 'first' %}selected{% endif %}>
Jako pierwsze
</option>
<option value="last" {% if settings and settings.kolejnosc_rezerwowych == 'last' %}selected{% endif %}>
Jako ostatnie
</option>
</select>
<small class="text-muted d-block mt-1">Określa, gdzie na stronie głównej będą wyświetlane listy rezerwowe względem standardowych zbiórek</small>
</div>
</div>
</div>
<!-- SEKCJA: Branding -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-secondary text-white d-flex align-items-center justify-content-between gap-2">
<h3 class="card-title mb-0">Branding</h3>
<small class="opacity-75">Logo i tytuły wyświetlane w menu i stopce</small>
</div>
<div class="card-body">
<!-- Wspólne zasoby: logo + tytuł serwisu -->
<div class="row g-3 align-items-end">
<div class="col-md-6">
<label for="logo_url" class="form-label">Logo (URL PNG/SVG)</label>
<input type="text" class="form-control" id="logo_url" name="logo_url"
value="{{ settings.logo_url if settings else '' }}" placeholder="https://example.com/logo.svg">
<div class="form-text">Transparentne, do ~60px wysokości</div>
{% if settings and settings.logo_url %}
<div class="mt-2">
<img src="{{ settings.logo_url }}" alt="Logo preview" style="max-height:50px">
</div>
{% endif %}
</div>
<div class="col-md-6">
<label for="tytul_strony" class="form-label">Tytuł serwisu</label>
<input type="text" class="form-control" id="tytul_strony" name="tytul_strony"
value="{{ settings.tytul_strony if settings else '' }}" placeholder="Np. Zbiórki unitraklub.pl">
</div>
</div>
<hr class="my-4">
<!-- NAVBAR -->
<div class="row g-3">
<div class="col-md-6">
<h6 class="mb-2">Menu (navbar)</h6>
<div class="form-check">
<input class="form-check-input" type="radio" name="typ_navbar" id="navbar_mode_logo" value="logo" {% if
settings and settings.typ_navbar=='logo' or (settings and settings.pokaz_logo_w_navbar) %}checked{%
endif %}>
<label class="form-check-label" for="navbar_mode_logo">Pokaż logo</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="typ_navbar" id="navbar_mode_text" value="text" {% if
not settings or (settings and settings.typ_navbar !='logo' and not settings.pokaz_logo_w_navbar)
%}checked{% endif %}>
<label class="form-check-label" for="navbar_mode_text">Pokaż tekst</label>
</div>
<div class="form-text mt-1">Jeśli wybierzesz logo, użyjemy adresu z pola "Logo URL"</div>
</div>
<!-- STOPKA -->
<div class="col-md-6">
<h6 class="mb-2">Stopka</h6>
<div class="form-check">
<input class="form-check-input" type="radio" name="typ_stopka" id="footer_mode_logo" value="logo" {% if
settings and settings.typ_stopka=='logo' %}checked{% endif %}>
<label class="form-check-label" for="footer_mode_logo">Logo</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="typ_stopka" id="footer_mode_text" value="text" {% if
not settings or (settings and settings.typ_stopka !='logo' ) %}checked{% endif %}>
<label class="form-check-label" for="footer_mode_text">Tekst</label>
</div>
<label for="stopka_text" class="form-label mt-2">Tekst w stopce (gdy wybrano „Tekst")</label>
<input type="text" class="form-control" id="stopka_text" name="stopka_text"
value="{{ settings.stopka_text if settings and settings.stopka_text else '' }}"
placeholder="Np. © {{ now().year if now else '2025' }} Zbiórki">
<div class="form-text">Pozostaw pusty, by użyć domyślnego</div>
</div>
</div>
</div>
</div>
<!-- CTA -->
<div class="d-flex justify-content-between">
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-outline-light">Powrót</a>
<button type="submit" class="btn btn-success">Zapisz ustawienia</button>
</div>
</form>
</div>
{% endblock %}
{% block extra_scripts %}
{{ super() }}
<script src="{{ asset_url('js/ustawienia.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,93 @@
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8" />
<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="{{ asset_url('css/custom.css') }}" />
{% block extra_head %}{% endblock %}
</head>
<body class="d-flex flex-column min-vh-100">
<nav class="navbar navbar-expand-lg">
<div class="container">
<a class="navbar-brand d-flex align-items-center gap-2" href="{{ url_for('index') }}">
{% set nav_mode = (global_settings.typ_navbar if global_settings and
global_settings.typ_navbar else ('logo' if global_settings and
global_settings.pokaz_logo_w_navbar else 'text')) %}
{% if nav_mode == 'logo' and global_settings and global_settings.logo_url %}
<img src="{{ global_settings.logo_url }}" alt="Logo" style="max-height:40px; vertical-align:middle;">
{% else %}
<span>{{ global_settings.tytul_strony if global_settings and global_settings.tytul_strony else "Zbiórki"
}}</span>
{% endif %}
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNavbar"
aria-controls="mainNavbar" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="mainNavbar">
<ul class="navbar-nav ms-auto">
{% set hide_links = request.path == url_for('index') or request.path ==
url_for('zbiorki_zrealizowane') %}
{% if not hide_links %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('index') }}">Aktualne zbiórki</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('zbiorki_zrealizowane') }}">Zrealizowane zbiórki</a>
</li>
{% endif %}
{% if current_user.is_authenticated %}
<li class="nav-item"><a class="nav-link" href="{{ url_for('admin_dashboard') }}">Panel Admina</a>
</li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('wyloguj') }}">Wyloguj</a></li>
{% else %}
{% if is_ip_allowed|default(false) %}
<li class="nav-item"><a class="nav-link" href="{{ url_for('zaloguj') }}">Zaloguj</a></li>
{% endif %}
{% endif %}
</ul>
</div>
</div>
</nav>
<div class="container mt-4">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category|default('secondary') }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<main class="flex-grow-1">
{% block content %}{% endblock %}
</main>
</div>
<!-- stopka -->
<footer class="mt-auto text-center py-3 border-top" style="background: var(--surface-0);">
{% set footer_mode = global_settings.typ_stopka if global_settings and global_settings.typ_stopka
else 'text' %}
{% if footer_mode == 'logo' and global_settings and global_settings.logo_url %}
<img src="{{ global_settings.logo_url }}" alt="Logo" style="max-height:28px;">
{% else %}
{{ global_settings.stopka_text if global_settings and global_settings.stopka_text else "© " ~ (now().year if now
else '2025') ~ " linuxiarz.pl" }}
{% endif %}
<div class="small text-muted">v{{ APP_VERSION }}</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="{{ asset_url('js/progress.js') }}"></script>
{% block extra_scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,152 @@
<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8">
<title>{{ error_code }} {{ error_name }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
{% if asset_url is defined %}
<link rel="stylesheet" href="{{ asset_url('css/style.css') }}">
{% else %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
{% endif %}
<style>
:root {
color-scheme: dark;
--bg: #121212;
--surface-0: #1a1a1a;
--surface-1: #202020;
--surface-2: #2a2a2a;
--border: #3a3a3a;
--text: #e4e4e4;
--text-muted: #a8a8a8;
--accent: #f5c84c;
--accent-600: #e3b23f;
--accent-700: #cfa033;
--accent-300: #ffe083;
--radius: 10px;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, .5);
--shadow-md: 0 4px 12px rgba(0, 0, 0, .45);
--trans: 220ms cubic-bezier(.2, .8, .2, 1);
}
html, body {
min-height: 100%;
}
body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--text);
font-family: 'Roboto', system-ui, -apple-system, Segoe UI, Arial, sans-serif;
}
a {
color: var(--accent);
text-decoration: none;
transition: color var(--trans);
}
a:hover {
color: var(--accent-300);
}
.error-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
box-sizing: border-box;
}
.error-box {
width: 100%;
max-width: 720px;
text-align: center;
padding: 2.5rem 2rem;
border-radius: var(--radius);
background: var(--surface-1);
border: 1px solid var(--border);
box-shadow: var(--shadow-md);
}
.error-code {
font-size: 3rem;
font-weight: 700;
line-height: 1;
margin-bottom: .5rem;
color: var(--accent);
}
.error-name {
font-size: 1.6rem;
margin: 0 0 .75rem;
color: var(--text);
}
.error-message {
margin: 0 0 1.5rem;
color: var(--text-muted);
line-height: 1.5;
}
.error-actions a {
display: inline-block;
padding: .6rem 1.3rem;
border-radius: 8px;
font-weight: 700;
background-color: var(--accent);
border: 1px solid var(--accent-600);
color: #111;
transition: transform 120ms ease, background-color var(--trans), border-color var(--trans), color var(--trans);
}
.error-actions a:hover {
background-color: var(--accent-600);
border-color: var(--accent-700);
color: #111;
transform: translateY(-1px);
}
.error-actions a:active {
background-color: var(--accent-700);
border-color: var(--accent-700);
transform: translateY(0);
}
@media (max-width: 576px) {
.error-box {
padding: 2rem 1.25rem;
}
.error-code {
font-size: 2.4rem;
}
.error-name {
font-size: 1.3rem;
}
}
</style>
</head>
<body>
<main class="error-page">
<section class="error-box">
<div class="error-code">{{ error_code }}</div>
<h1 class="error-name">{{ error_name }}</h1>
<p class="error-message">{{ error_message }}</p>
<div class="error-actions">
<a href="/">Powrot na strone glowna</a>
</div>
</section>
</main>
</body>
</html>

View File

@@ -0,0 +1,143 @@
{% extends 'base.html' %}
{% block title %}{% if request.path == url_for('zbiorki_zrealizowane') %}Zrealizowane zbiórki{% else %}Aktualnie aktywne zbiórki{% endif %}{% endblock %}
{% block content %}
{# Ustal kontekst listy #}
{% set is_completed_view = (request.path == url_for('zbiorki_zrealizowane')) %}
<div class="container my-4">
<!-- Nagłówek z przełącznikiem -->
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
<div>
<h2 class="mb-0">
{% if is_completed_view %}Zrealizowane zbiórki{% else %}Aktywne zbiórki{% endif %}
</h2>
<p class="text-muted mb-0 small">
{% if is_completed_view %}
Ukończone projekty i osiągnięte cele
{% else %}
Trwające zbiórki, które możesz wesprzeć
{% endif %}
</p>
</div>
<ul class="nav nav-pills">
<li class="nav-item">
<a class="nav-link {% if not is_completed_view %}active{% endif %}"
href="{{ url_for('index') }}">
Aktywne
{% if not is_completed_view and zbiorki %}
<span class="badge bg-light text-dark ms-1">{{ zbiorki|length }}</span>
{% endif %}
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if is_completed_view %}active{% endif %}"
href="{{ url_for('zbiorki_zrealizowane') }}">
Zrealizowane
{% if is_completed_view and zbiorki %}
<span class="badge bg-light text-dark ms-1">{{ zbiorki|length }}</span>
{% endif %}
</a>
</li>
</ul>
</div>
{% if zbiorki and zbiorki|length > 0 %}
<div class="row g-3 pb-4">
{% for z in zbiorki %}
{% set progress = (z.stan / z.cel * 100) if z.cel > 0 else 0 %}
{% set progress_clamped = 100 if progress > 100 else (0 if progress < 0 else progress) %}
<div class="col-sm-12 col-md-6 col-lg-4">
<div class="card h-100 position-relative">
<div class="card-body d-flex flex-column">
<div class="d-flex align-items-start justify-content-between gap-2 mb-2">
<h5 class="card-title mb-0">{{ z.nazwa }}</h5>
{% if z.typ_zbiorki == 'rezerwa' %}
<span class="badge bg-info">Rezerwa</span>
{% elif is_completed_view or progress_clamped >= 100 %}
<span class="badge rounded-pill" style="background: var(--accent); color:#111;">Zrealizowana</span>
{% endif %}
</div>
<hr class="hr-bw my-2">
<div class="mb-2 d-flex flex-wrap gap-2 justify-content-center">
{% if not z.ukryj_kwote %}
{% if z.cel > 0 and z.typ_zbiorki != 'rezerwa' %}
<span class="badge bg-dark border" style="border-color: var(--border);">
Cel: {{ z.cel|round(2) }} PLN
</span>
{% endif %}
<span class="badge bg-dark border border-success {% if z.typ_zbiorki == 'rezerwa' %}w-100{% endif %}" style="border-color: var(--border);">
Stan: {{ z.stan|round(2) }} PLN
</span>
{% if z.cel > 0 and z.typ_zbiorki != 'rezerwa' %}
{% set delta = z.cel - z.stan %}
{% if delta > 0 %}
<span class="badge bg-dark border border-warning">
Brakuje: {{ delta|round(2) }} PLN
</span>
{% elif delta < 0 %}
<span class="badge bg-dark border" style="border-color: var(--border);">
Nadwyżka: {{ (-delta)|round(2) }} PLN
</span>
{% endif %}
{% endif %}
{% else %}
<span class="badge bg-secondary">Kwoty niepubliczne</span>
{% endif %}
</div>
{# Progress bar TYLKO dla standardowych zbiórek (nie dla rezerwowych) #}
{% if z.typ_zbiorki != 'rezerwa' %}
<div class="mb-2">
<div class="progress" role="progressbar" aria-valuemin="0" aria-valuemax="100"
aria-valuenow="{{ progress_clamped|round(2) if not z.ukryj_kwote else '' }}"
aria-label="{% if z.ukryj_kwote %}Postęp ukryty{% else %}Postęp zbiórki {{ progress_clamped|round(0) }} procent{% endif %}">
<div class="progress-bar" style="width: {{ progress_clamped }}%;"></div>
</div>
{% if not z.ukryj_kwote %}
<small class="text-muted d-block text-center">{{ progress_clamped|round(1) }}%</small>
{% else %}
<small class="text-muted d-block text-center">Postęp ukryty</small>
{% endif %}
</div>
{% endif %}
<div class="mt-auto pt-1">
<div class="d-grid">
<a href="{{ url_for('zbiorka', zbiorka_id=z.id) }}"
class="btn btn-outline-light btn-sm w-100 btn-opis">
Otwórz
</a>
</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<!-- Empty state -->
<div class="card">
<div class="card-body text-center py-5">
{% if is_completed_view %}
<h5 class="mb-2">Brak zrealizowanych zbiórek</h5>
<p class="text-muted mb-4">Gdy jakaś zbiórka osiągnie 100%, pojawi się tutaj.</p>
<a href="{{ url_for('index') }}" class="btn btn-primary">Zobacz aktywne</a>
{% else %}
<h5 class="mb-2">Brak aktywnych zbiórek</h5>
<p class="text-muted mb-4">Wygląda na to, że teraz nic nie zbieramy.</p>
{% if current_user.is_authenticated and current_user.czy_admin %}
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-primary">Utwórz nową zbiórkę</a>
{% else %}
<a href="{{ url_for('zbiorki_zrealizowane') }}" class="btn btn-outline-light">Zobacz zrealizowane</a>
{% endif %}
{% endif %}
</div>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,55 @@
{% extends 'base.html' %}
{% block title %}Logowanie{% endblock %}
{% block content %}
<div class="container my-5">
<div class="row justify-content-center">
<div class="col-sm-10 col-md-8 col-lg-5">
<div class="card shadow-sm">
<div class="card-header">
<h3 class="card-title mb-0">Logowanie</h3>
</div>
<div class="card-body">
<form method="post" class="needs-validation" novalidate>
{% set next_url = request.args.get('next') %}
{% if next_url %}
<input type="hidden" name="next" value="{{ next_url }}">
{% endif %}
<div class="mb-3">
<label for="uzytkownik" class="form-label">Nazwa użytkownika</label>
<input type="text" class="form-control" id="uzytkownik" name="uzytkownik"
autocomplete="username" autocapitalize="none" spellcheck="false" required autofocus>
<div class="invalid-feedback">Podaj nazwę użytkownika.</div>
</div>
<div class="mb-2">
<label for="haslo" class="form-label d-flex justify-content-between align-items-center">
<span>Hasło</span>
<small id="capsWarning" class="text-muted" style="display:none;">CAPS LOCK
włączony</small>
</label>
<div class="input-group">
<input type="password" class="form-control" id="haslo" name="haslo"
autocomplete="current-password" required minlength="5">
<button type="button" class="btn btn-secondary rounded-end" id="togglePw"
aria-label="Pokaż/ukryj hasło">Pokaż</button>
<div class="invalid-feedback">Wpisz hasło (min. 5 znaków).</div>
</div>
</div>
<button type="submit" class="btn btn-primary w-100">Zaloguj</button>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ asset_url('js/walidacja_logowanie.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,61 @@
{% extends 'base.html' %}
{% block title %}Rejestracja{% endblock %}
{% block content %}
<div class="container my-5">
<div class="row justify-content-center">
<div class="col-sm-10 col-md-8 col-lg-5">
<div class="card shadow-sm">
<div class="card-header">
<h3 class="card-title mb-0">Rejestracja</h3>
</div>
<div class="card-body">
<form method="post" class="needs-validation" novalidate>
<div class="mb-3">
<label for="uzytkownik" class="form-label">Nazwa użytkownika</label>
<input type="text" class="form-control" id="uzytkownik" name="uzytkownik"
autocomplete="username" autocapitalize="none" spellcheck="false" required autofocus>
<div class="invalid-feedback">Podaj nazwę użytkownika.</div>
</div>
<div class="mb-3">
<label for="haslo" class="form-label d-flex justify-content-between align-items-center">
<span>Hasło</span>
<small id="capsWarning" class="text-muted" style="display:none;">CAPS LOCK
włączony</small>
</label>
<div class="input-group">
<input type="password" class="form-control" id="haslo" name="haslo"
autocomplete="new-password" required minlength="6">
<button type="button" class="btn btn-secondary" id="togglePw"
aria-label="Pokaż/ukryj hasło">Pokaż</button>
<div class="invalid-feedback">Hasło musi mieć min. 6 znaków.</div>
</div>
</div>
<div class="mb-4">
<label for="password2" class="form-label">Powtórz hasło</label>
<input type="password" class="form-control" id="password2" name="password2"
autocomplete="new-password" required minlength="6">
<div class="invalid-feedback">Powtórz hasło.</div>
</div>
<button type="submit" class="btn btn-primary w-100">Zarejestruj się</button>
</form>
<p class="mt-3 mb-0 text-center">
Masz już konto?
<a href="{{ url_for('zaloguj') }}">Zaloguj się</a>
</p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ asset_url('js/walidacja_rejestracja.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,414 @@
{% extends 'base.html' %}
{% block title %}{{ zbiorka.nazwa }}{% endblock %}
{% block content %}
<div class="container my-4">
{# Wyliczenia postępu finansowego #}
{% set has_cel = (zbiorka.cel is defined and zbiorka.cel and zbiorka.cel > 0) %}
{% set progress = (zbiorka.stan / zbiorka.cel * 100) if has_cel else 0 %}
{% set progress_clamped = 100 if progress > 100 else (0 if progress < 0 else progress) %}
{% set is_done=(progress_clamped>= 100) %}
<!-- Nagłówek -->
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
<div>
<h2 class="mb-0">
{% if zbiorka.typ_zbiorki == 'rezerwa' %}
<i class="bi bi-wallet2"></i>
{% endif %}
{{ zbiorka.nazwa }}
</h2>
{% if zbiorka.typ_zbiorki == 'rezerwa' %}
<p class="text-muted mb-0 small">Lista rezerwowa środków</p>
{% elif is_done %}
<p class="text-muted mb-0 small">Zbiórka zrealizowana</p>
{% else %}
<p class="text-muted mb-0 small">Aktywna zbiórka</p>
{% endif %}
</div>
<div class="d-flex flex-wrap align-items-center gap-2">
{% if zbiorka.typ_zbiorki == 'rezerwa' %}
<span class="badge bg-info">Lista rezerwowa</span>
{% endif %}
{% if is_done and zbiorka.typ_zbiorki != 'rezerwa' %}
<span class="badge rounded-pill" style="background: var(--accent); color:#111;">Zrealizowana</span>
{% endif %}
{% if zbiorka.ukryj_kwote %}
<span class="badge bg-secondary">Kwoty niepubliczne</span>
{% else %}
<span class="badge bg-success">Kwoty widoczne</span>
{% endif %}
</div>
</div>
<div class="row g-3">
<!-- Kolumna lewa: Opis + (opcjonalnie) Lista zakupów + Postęp -->
<div class="col-md-8">
<!-- Card: Opis -->
<div class="card shadow-sm mb-3">
<div class="card-body">
<h5 class="mb-2">Opis</h5>
<hr class="hr-bw my-2">
<div class="mb-0">
{{ zbiorka.opis | markdown }}
</div>
</div>
</div>
{# Czy są produkty? #}
{% set items = zbiorka.przedmioty or [] %}
{% set has_items = (items|length > 0) %}
<!-- Card: Lista zakupów (tylko gdy są produkty) -->
{% if has_items %}
<div class="card shadow-sm mb-3">
<div class="card-body">
<div class="d-flex align-items-center justify-content-between mb-2">
<h5 class="mb-0">Lista zakupów</h5>
<span class="badge bg-secondary">{{ items|length }} pozycji</span>
</div>
<hr class="hr-bw my-2">
{% set posortowane = items|sort(attribute='kupione') %}
<ul class="list-group list-group-flush">
{% for it in posortowane %}
<li class="list-group-item bg-transparent d-flex flex-wrap justify-content-between align-items-center py-2">
<div class="d-flex align-items-center gap-2">
{% if it.kupione %}
<span class="badge bg-success">Kupione</span>
{% else %}
<span class="badge bg-warning text-dark">Do kupienia</span>
{% endif %}
<span class="fw-semibold">{{ it.nazwa }}</span>
{% if it.link %}
<a href="{{ it.link }}" target="_blank" rel="noopener" class="btn btn-sm btn-outline-light ms-2">Sklep ↗</a>
{% endif %}
</div>
<div>
{% if not zbiorka.ukryj_kwote %}
{% if it.cena is not none %}
<span class="badge bg-dark border" style="border-color: var(--border);">
{{ it.cena|round(2) }} PLN
</span>
{% else %}
<span class="text-muted"></span>
{% endif %}
{% else %}
<span class="text-muted"></span>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
<!-- Card: Postęp (POD listą zakupów) -->
{# Dodatkowe wyliczenia do postępu zakupów #}
{% set total_cnt = items|length %}
{% set kupione_cnt = (items|selectattr('kupione')|list|length) %}
{% set items_pct = (kupione_cnt / total_cnt * 100) if total_cnt > 0 else 0 %}
{% if not zbiorka.ukryj_kwote %}
{% set suma_all = (items|selectattr('cena')|map(attribute='cena')|sum) or 0 %}
{% set suma_kupione = (items|selectattr('kupione')|selectattr('cena')|map(attribute='cena')|sum) or 0 %}
{% set suma_pct = (suma_kupione / suma_all * 100) if suma_all > 0 else 0 %}
{% endif %}
{# Pokazuj sekcję postępu TYLKO dla standardowych zbiórek, NIE dla rezerwowych #}
{% if zbiorka.typ_zbiorki != 'rezerwa' %}
<div class="card shadow-sm">
<div class="card-body">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-2">
<h5 class="mb-0">Postęp</h5>
<div class="d-flex flex-wrap align-items-center gap-2">
{% if has_cel and not zbiorka.ukryj_kwote and zbiorka.pokaz_postep_finanse %}
<span class="badge bg-dark border" style="border-color: var(--border);">
Finanse: {{ zbiorka.stan|round(2) }} / {{ zbiorka.cel|round(2) }} PLN
</span>
{% endif %}
{% if has_items and zbiorka.pokaz_postep_pozycje %}
<span class="badge bg-secondary">Pozycje: {{ kupione_cnt }}/{{ total_cnt }}</span>
{% endif %}
{% if has_items and not zbiorka.ukryj_kwote and (suma_all or 0) > 0 and zbiorka.pokaz_postep_kwotowo %}
<span class="badge bg-secondary">
Zakupy (kwotowo): {{ (suma_kupione or 0)|round(2) }} / {{ (suma_all or 0)|round(2) }} PLN
</span>
{% endif %}
</div>
</div>
<hr class="hr-bw my-2">
{# Pasek: Finanse #}
{% if zbiorka.pokaz_postep_finanse %}
<div class="mb-2">
<small class="text-muted">Finanse</small>
<div class="progress" role="progressbar" aria-valuenow="{{ progress_clamped|round(2) }}" aria-valuemin="0"
aria-valuemax="100">
<div class="progress-bar" style="width: {{ progress_clamped }}%;"></div>
</div>
<small class="text-muted">
{% if zbiorka.ukryj_kwote %}—{% else %}{{ progress|round(1) }}%{% endif %}
</small>
</div>
{% endif %}
{# Pasek: Zakupy sztukami #}
{% if has_items and zbiorka.pokaz_postep_pozycje %}
<div class="mb-2">
<small class="text-muted">Zakupy (liczba pozycji)</small>
<div class="progress" role="progressbar" aria-valuenow="{{ items_pct|round(2) }}" aria-valuemin="0"
aria-valuemax="100">
<div class="progress-bar" style="width: {{ items_pct }}%;"></div>
</div>
<small class="text-muted">{{ items_pct|round(1) }}%</small>
</div>
{% endif %}
{# Pasek: Zakupy kwotowo #}
{% if has_items and not zbiorka.ukryj_kwote and (suma_all or 0) > 0 and zbiorka.pokaz_postep_kwotowo %}
<div>
<small class="text-muted">Zakupy (kwotowo)</small>
<div class="progress" role="progressbar" aria-valuenow="{{ suma_pct|round(2) }}" aria-valuemin="0"
aria-valuemax="100">
<div class="progress-bar" style="width: {{ suma_pct }}%;"></div>
</div>
<small class="text-muted">{{ suma_pct|round(1) }}%</small>
</div>
{% endif %}
</div>
</div>
{% endif %}
{# Koniec warunku dla typu zbiórki #}
</div>
{% set show_iban = zbiorka.uzyj_konta and zbiorka.numer_konta %}
{% set show_blik = zbiorka.uzyj_blik and zbiorka.numer_telefonu_blik %}
<!-- Kolumna prawa: płatności (sticky) -->
<div class="col-md-4">
<div class="card shadow-sm wspomoz-card sticky-md" style="top: var(--sticky-offset, 1rem);">
<div class="card-body d-flex flex-column gap-2">
<div class="d-flex align-items-center justify-content-between">
<h5 class="mb-0">
{% if zbiorka.typ_zbiorki == 'rezerwa' %}
Sposoby wsparcia
{% else %}
Jak wspomóc?
{% endif %}
</h5>
{% if has_cel and not zbiorka.ukryj_kwote and zbiorka.typ_zbiorki != 'rezerwa' %}
{% set brak = (zbiorka.cel - zbiorka.stan) %}
{% if brak > 0 %}
<span class="badge bg-warning text-dark border border-warning">Brakuje: {{ brak|round(2) }} PLN</span>
{% else %}
<span class="badge rounded-pill" style="background: var(--accent); color:#111;">Zrealizowana</span>
{% endif %}
{% endif %}
</div>
{% if show_iban or show_blik %}
{% if show_iban %}
<!-- Numer konta -->
<div>
<label for="ibanInput" class="form-label fw-semibold mb-1">Numer konta</label>
<div class="input-group input-group-sm">
<input id="ibanInput" type="text"
class="form-control bg-transparent text-light border monospace-input text-truncate"
value="{{ zbiorka.numer_konta }}" readonly autocomplete="off" autocorrect="off" autocapitalize="off"
spellcheck="false" inputmode="text" aria-label="Numer konta do wpłaty">
<button class="btn btn-outline-light copy-btn" type="button" data-copy-input="#ibanInput"
aria-label="Kopiuj numer konta">Kopiuj</button>
</div>
</div>
{% endif %}
{% if show_blik %}
<!-- Telefon BLIK -->
<div>
<label for="blikInput" class="form-label fw-semibold mb-1">Telefon / BLIK</label>
<div class="input-group input-group-sm">
<input id="blikInput" type="text"
class="form-control bg-transparent text-light border monospace-input text-truncate"
value="{{ zbiorka.numer_telefonu_blik }}" readonly autocomplete="off" autocorrect="off"
autocapitalize="off" spellcheck="false" inputmode="numeric" aria-label="Telefon BLIK">
<button class="btn btn-outline-light copy-btn" type="button" data-copy-input="#blikInput"
aria-label="Kopiuj numer BLIK">Kopiuj</button>
</div>
</div>
{% endif %}
{% else %}
<div class="alert alert-secondary mb-0">
Kanały płatności są wyłączone dla tej zbiórki.
</div>
{% endif %}
{% if not zbiorka.ukryj_kwote %}
<ul class="list-group list-group-flush small mt-2">
{% if zbiorka.typ_zbiorki != 'rezerwa' %}
{% if has_cel %}
<li class="list-group-item bg-transparent d-flex justify-content-between py-1">
<span>Cel</span>
<span class="fw-semibold">{{ zbiorka.cel|round(2) }} PLN</span>
</li>
{% endif %}
{% endif %}
<li class="list-group-item bg-transparent d-flex justify-content-between py-1">
<span>Stan</span>
<span class="fw-semibold text-success">{{ zbiorka.stan|round(2) }} PLN</span>
</li>
{% if zbiorka.typ_zbiorki != 'rezerwa' and has_cel %}
<li class="list-group-item bg-transparent d-flex justify-content-between py-1">
<span>
{% if brak > 0 %}Brakuje{% elif brak == 0 %}Cel{% else %}Nadwyżka{% endif %}
</span>
<span class="fw-semibold {% if brak > 0 %}text-warning{% elif brak < 0 %}text-success{% endif %}">
{% if brak > 0 %}
{{ brak|round(2) }} PLN
{% elif brak == 0 %}
osiągnięty
{% else %}
{{ (-brak)|round(2) }} PLN
{% endif %}
</span>
</li>
{% endif %}
</ul>
{% endif %}
{% if current_user.is_authenticated and current_user.czy_admin %}
<hr class="my-2">
<div class="d-grid gap-2">
<a href="{{ url_for('dodaj_wplate', zbiorka_id=zbiorka.id) }}" class="btn btn-outline-light btn-sm">Dodaj wpłatę</a>
<a href="{{ url_for('dodaj_wydatek', zbiorka_id=zbiorka.id) }}" class="btn btn-outline-light btn-sm">Dodaj wydatek</a>
<a href="{{ url_for('dodaj_przesuniecie', zbiorka_id=zbiorka.id) }}" class="btn btn-outline-light btn-sm">
<i class="bi bi-arrow-left-right"></i> Przesuń środki
</a>
<a href="{{ url_for('edytuj_stan', zbiorka_id=zbiorka.id) }}" class="btn btn-outline-light btn-sm">Edytuj stan</a>
{% if zbiorka.typ_zbiorki != 'rezerwa' %}
<a href="{{ url_for('formularz_zbiorek', zbiorka_id=zbiorka.id) }}"
class="btn btn-outline-light btn-sm">Edytuj opis</a>
{% else %}
<a href="{{ url_for('edytuj_rezerwe', rezerwa_id=zbiorka.id) }}"
class="btn btn-outline-light btn-sm">Edytuj rezerwę</a>
{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Aktywność -->
<div class="card shadow-sm mt-3">
<div class="card-header bg-transparent d-flex align-items-center justify-content-between py-2">
<h5 class="card-title mb-0">Aktywność / Transakcje</h5>
<div class="d-flex align-items-center gap-2">
{% if aktywnosci and aktywnosci|length > 0 %}
<small class="text-muted">Łącznie pozycji: {{ aktywnosci|length }}</small>
{% endif %}
{% if current_user.is_authenticated and current_user.czy_admin %}
<a href="{{ url_for('transakcje_zbiorki', zbiorka_id=zbiorka.id) }}" class="btn btn-sm btn-outline-light">
Zarządzaj
</a>
{% endif %}
</div>
</div>
<div class="card-body">
{% if aktywnosci and aktywnosci|length > 0 %}
<ul class="list-group list-group-flush">
{% for a in aktywnosci %}
<li class="list-group-item bg-transparent d-flex flex-wrap justify-content-between align-items-start py-2">
<div class="me-3 flex-grow-1">
<strong>{{ a.data|dt("%d.%m.%Y %H:%M") }}</strong>
{% if a.typ == 'wpłata' %}
<span class="badge bg-success ms-2">Wpłata</span>
{% elif a.typ == 'wydatek' %}
<span class="badge bg-danger ms-2">Wydatek</span>
{% elif a.typ == 'przesunięcie_przych' %}
<span class="badge bg-info ms-2">Przesunięcie (↓ przychód)</span>
{% elif a.typ == 'przesunięcie_wych' %}
<span class="badge bg-warning text-dark ms-2">Przesunięcie (↑ wychód)</span>
{% endif %}
{% if a.opis %}
<span class="text-muted">— {{ a.opis }}</span>
{% endif %}
{# Informacja o przesunięciu wpłaty #}
{% if a.typ == 'wpłata' and a.przesuniecie_z %}
<div class="text-muted small mt-1">
<span class="badge bg-secondary">Przesunięto</span>
Źródło:
<a href="{{ url_for('zbiorka', zbiorka_id=a.przesuniecie_z.zbiorka_zrodlo_id) }}"
class="text-decoration-none">
{{ a.przesuniecie_z.zbiorka_zrodlo_nazwa }}
</a>
{% if a.przesuniecie_z.opis %}
<br><span class="text-muted">{{ a.przesuniecie_z.opis }}</span>
{% endif %}
</div>
{% endif %}
{# Link do źródłowej/docelowej zbiórki dla przesunięć ogólnych #}
{% if a.typ in ['przesunięcie_przych', 'przesunięcie_wych'] and a.zbiorka_id %}
<a href="{{ url_for('zbiorka', zbiorka_id=a.zbiorka_id) }}"
class="ms-2 text-decoration-none small">
<i class="bi bi-link-45deg"></i>
{% if a.typ == 'przesunięcie_przych' %}
z: {{ a.zbiorka_nazwa }}
{% else %}
do: {{ a.zbiorka_nazwa }}
{% endif %}
</a>
{% endif %}
</div>
{% if not zbiorka.ukryj_kwote %}
<span class="badge bg-dark border" style="border-color: var(--border);">
{% if a.typ == 'wpłata' or a.typ == 'przesunięcie_przych' %}
+{{ a.kwota|round(2) }} PLN
{% else %}
-{{ a.kwota|round(2) }} PLN
{% endif %}
</span>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<div class="text-center py-4">
<h6 class="mb-1">Brak aktywności</h6>
<p class="text-muted mb-0">Gdy pojawią się pierwsze wpłaty lub wydatki, zobaczysz je tutaj.</p>
</div>
{% endif %}
</div>
</div>
<!-- Akcje dolne -->
<div class="d-flex gap-2 justify-content-between mt-3">
<div></div>
<a href="{{ url_for('index') }}" class="btn btn-outline-light">Powrót do listy</a>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
{{ super() }}
<script src="{{ asset_url('js/zbiorka.js') }}"></script>
<script src="{{ asset_url('js/progress.js') }}"></script>
{% endblock %}

291
zbiorka_app/utils.py Normal file
View File

@@ -0,0 +1,291 @@
import hashlib
import ipaddress
import os
import re
import socket
import threading
import time
from datetime import datetime, timezone
from decimal import Decimal, InvalidOperation
from functools import lru_cache
from pathlib import Path
from flask import current_app, request, url_for
from sqlalchemy import text
from sqlalchemy.exc import OperationalError
from .extensions import db
from .models import UstawieniaGlobalne, Uzytkownik
try:
from zoneinfo import ZoneInfo
except ImportError:
from backports.zoneinfo import ZoneInfo
LOCAL_TZ = ZoneInfo("Europe/Warsaw")
_DB_INIT_LOCK = threading.Lock()
def read_commit_and_date(filename="version.txt", root_path=None):
base = root_path or os.path.dirname(os.path.abspath(__file__))
path = os.path.join(base, filename)
if not os.path.exists(path):
return None, None
try:
commit = open(path, "r", encoding="utf-8").read().strip()
if commit:
commit = commit[:12]
except Exception:
commit = None
try:
ts = os.path.getmtime(path)
date_str = datetime.fromtimestamp(ts).strftime("%Y.%m.%d")
except Exception:
date_str = None
return date_str, commit
def set_sqlite_pragma(dbapi_connection, connection_record):
if dbapi_connection.__class__.__module__.startswith("sqlite3"):
try:
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
except Exception:
pass
def get_real_ip():
headers = request.headers
cf_ip = headers.get("CF-Connecting-IP")
if cf_ip:
return cf_ip.split(",")[0].strip()
xff = headers.get("X-Forwarded-For")
if xff:
return xff.split(",")[0].strip()
x_real_ip = headers.get("X-Real-IP")
if x_real_ip:
return x_real_ip.strip()
return request.remote_addr
def is_allowed_ip(remote_ip, allowed_hosts_str):
if os.path.exists("emergency_access.txt"):
return True
if not allowed_hosts_str or not allowed_hosts_str.strip():
return False
allowed_ips = set()
hosts = re.split(r"[\n,]+", allowed_hosts_str.strip())
for host in hosts:
host = host.strip()
if not host:
continue
try:
allowed_ips.add(ipaddress.ip_address(host))
continue
except ValueError:
pass
try:
infos = socket.getaddrinfo(host, None)
for _, _, _, _, sockaddr in infos:
ip_str = sockaddr[0]
try:
allowed_ips.add(ipaddress.ip_address(ip_str))
except ValueError:
continue
except Exception as exc:
current_app.logger.warning("Nie mozna rozwiazac hosta %s: %s", host, exc)
try:
remote_ip_obj = ipaddress.ip_address(remote_ip)
except ValueError:
current_app.logger.warning("Nieprawidlowe IP klienta: %s", remote_ip)
return False
is_allowed = remote_ip_obj in allowed_ips
current_app.logger.info("is_allowed_ip: %s -> %s (lista: %s)", remote_ip_obj, is_allowed, allowed_ips)
return is_allowed
def to_local(dt):
if dt is None:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(LOCAL_TZ)
def parse_amount(raw: str) -> Decimal:
if not raw or not str(raw).strip():
raise InvalidOperation("empty amount")
norm = str(raw).replace(" ", "").replace("\u00A0", "").replace(",", ".").strip()
d = Decimal(norm)
if d <= 0:
raise InvalidOperation("amount must be > 0")
return d
def safe_db_rollback() -> None:
try:
db.session.rollback()
except Exception:
pass
def create_admin_account(force_reset_password: bool = False):
admin = Uzytkownik.query.filter_by(czy_admin=True).first()
if not admin:
admin = Uzytkownik(
uzytkownik=current_app.config["MAIN_ADMIN_USERNAME"],
czy_admin=True,
)
admin.set_password(current_app.config["MAIN_ADMIN_PASSWORD"])
db.session.add(admin)
db.session.commit()
return admin
if force_reset_password:
admin.set_password(current_app.config["MAIN_ADMIN_PASSWORD"])
db.session.commit()
return admin
def get_or_create_user(username: str, is_admin: bool = False) -> Uzytkownik:
user = Uzytkownik.query.filter_by(uzytkownik=username).first()
if user is None:
user = Uzytkownik(uzytkownik=username, czy_admin=is_admin)
db.session.add(user)
db.session.flush()
return user
def set_user_password(username: str, password: str, is_admin: bool | None = None) -> Uzytkownik:
user = Uzytkownik.query.filter_by(uzytkownik=username).first()
if user is None:
user = Uzytkownik(uzytkownik=username, czy_admin=bool(is_admin))
db.session.add(user)
elif is_admin is not None:
user.czy_admin = bool(is_admin)
user.set_password(password)
db.session.commit()
return user
def set_login_hosts(hosts: str | None) -> UstawieniaGlobalne:
settings = UstawieniaGlobalne.query.first()
if settings is None:
settings = UstawieniaGlobalne(
numer_konta="",
numer_telefonu_blik="",
)
db.session.add(settings)
settings.dozwolone_hosty_logowania = (hosts or "").strip() or None
db.session.commit()
return settings
def init_version(app):
root_path = os.path.dirname(app.root_path)
deploy_date, commit = read_commit_and_date("version.txt", root_path=root_path)
if not deploy_date:
deploy_date = datetime.now().strftime("%Y.%m.%d")
if not commit:
commit = "dev"
app.config["APP_VERSION"] = f"{deploy_date}+{commit}"
@lru_cache(maxsize=512)
def _md5_for_static_file(path_str: str, mtime_ns: int, size: int) -> str:
digest = hashlib.md5()
with open(path_str, "rb") as handle:
for chunk in iter(lambda: handle.read(65536), b""):
digest.update(chunk)
return digest.hexdigest()[:12]
def static_file_hash(filename: str) -> str:
static_root = Path(current_app.static_folder).resolve()
candidate = (static_root / filename).resolve()
try:
candidate.relative_to(static_root)
except ValueError:
return "invalid"
if not candidate.is_file():
return "missing"
stat = candidate.stat()
return _md5_for_static_file(str(candidate), stat.st_mtime_ns, stat.st_size)
def asset_url(filename: str) -> str:
return url_for("static", filename=filename, h=static_file_hash(filename))
def is_database_available() -> bool:
try:
db.session.execute(text("SELECT 1"))
return True
except Exception:
safe_db_rollback()
return False
def ensure_database_ready(create_schema: bool = True, create_admin: bool = True) -> bool:
with _DB_INIT_LOCK:
try:
db.session.execute(text("SELECT 1"))
if create_schema:
db.create_all()
if create_admin:
create_admin_account()
current_app.extensions["database_ready"] = True
return True
except Exception:
safe_db_rollback()
current_app.extensions["database_ready"] = False
raise
def init_database_with_retry(app, max_attempts: int = 20, delay: int = 3, raise_on_failure: bool = True) -> bool:
last_error = None
with app.app_context():
for attempt in range(1, max_attempts + 1):
try:
ensure_database_ready(create_schema=True, create_admin=True)
current_app.logger.info("Baza danych gotowa po probie %s/%s", attempt, max_attempts)
return True
except OperationalError as exc:
last_error = exc
current_app.logger.warning(
"Baza danych niedostepna (proba %s/%s): %s",
attempt,
max_attempts,
exc,
)
except Exception as exc:
last_error = exc
current_app.logger.warning(
"Blad inicjalizacji bazy (proba %s/%s): %s",
attempt,
max_attempts,
exc,
)
if attempt < max_attempts:
time.sleep(delay)
if raise_on_failure and last_error:
raise last_error
return False