Compare commits
60 Commits
9b2353c82a
...
master
Author | SHA1 | Date | |
---|---|---|---|
![]() |
3be89fe9d8 | ||
![]() |
f6d2c9955c | ||
![]() |
15dd9d1fe9 | ||
![]() |
e1367f8bf2 | ||
![]() |
35a8d5dd8e | ||
![]() |
f9406a46cf | ||
![]() |
1c69295b9b | ||
![]() |
a4887f9c73 | ||
![]() |
22b4c3d99a | ||
![]() |
16ec180822 | ||
![]() |
3bad07ce69 | ||
![]() |
086784b4ef | ||
![]() |
a4fa47e1a2 | ||
![]() |
6709681dfd | ||
![]() |
1e7f1e3990 | ||
![]() |
d844d354fa | ||
![]() |
4de192b865 | ||
![]() |
90b9c8d462 | ||
ee2f498744 | |||
4a357ffd57 | |||
f78fe5369c | |||
69edf7844a | |||
1bedab4825 | |||
609a411824 | |||
![]() |
a4fabcf4bd | ||
![]() |
d44681ed0c | ||
![]() |
1d8a87216d | ||
![]() |
2f961db2ac | ||
![]() |
e79eebed17 | ||
![]() |
c0daa93fe9 | ||
![]() |
0d09abc4eb | ||
![]() |
413c80baa2 | ||
![]() |
0390a59008 | ||
![]() |
ee05bf74b5 | ||
![]() |
052439b340 | ||
![]() |
a673fe99f8 | ||
![]() |
fd46242cf5 | ||
![]() |
95b01665b9 | ||
![]() |
f7a99df93d | ||
![]() |
64ad6b4bbf | ||
![]() |
d7abf96080 | ||
![]() |
8887ed2c62 | ||
![]() |
dfa3028dad | ||
![]() |
b679700c6d | ||
![]() |
eb9f11b2d6 | ||
![]() |
6a1734024a | ||
![]() |
fdcfaff80e | ||
![]() |
abb8ee0ae7 | ||
![]() |
37061442d2 | ||
![]() |
113f9e385e | ||
![]() |
34d3f3ae0c | ||
![]() |
72b82ae40c | ||
![]() |
9a7660fdf7 | ||
![]() |
56c02e072d | ||
![]() |
b45a259455 | ||
![]() |
d0df1cdee9 | ||
![]() |
71a2b33a7a | ||
![]() |
16beaac932 | ||
![]() |
eec3985c5a | ||
![]() |
b80b92a5df |
30
.env.example
30
.env.example
@@ -36,4 +36,32 @@ USE_ETAGS=True
|
||||
PRAGMA_HEADER=
|
||||
|
||||
# Wartość nagłówka X-Robots-Tag, gdy BLOCK_BOTS=True
|
||||
ROBOTS_TAG=noindex, nofollow, nosnippet, noarchive
|
||||
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
|
||||
|
5
.gitignore
vendored
5
.gitignore
vendored
@@ -3,4 +3,7 @@ data/
|
||||
instance/
|
||||
venv/
|
||||
.env
|
||||
version.txt
|
||||
version.txt
|
||||
deploy/varnish/default.vcl
|
||||
*.tar.gz
|
||||
db/*
|
19
Dockerfile
19
Dockerfile
@@ -1,19 +0,0 @@
|
||||
FROM python:3.13-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
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["python", "run_waitress.py"]
|
1
Dockerfile
Symbolic link
1
Dockerfile
Symbolic link
@@ -0,0 +1 @@
|
||||
deploy/app/Dockerfile
|
38
_tools/db/migrate.txt
Normal file
38
_tools/db/migrate.txt
Normal 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$$;
|
68
_tools/db/migrate_sqlite_to_pgsql.py
Normal file
68
_tools/db/migrate_sqlite_to_pgsql.py
Normal 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()
|
75
alters.txt
75
alters.txt
@@ -1,68 +1,17 @@
|
||||
-- WŁĄCZ/wyłącz FK zależnie od etapu migracji
|
||||
PRAGMA foreign_keys = OFF;
|
||||
ALTER TABLE zbiorka ALTER COLUMN numer_konta DROP NOT NULL;
|
||||
ALTER TABLE zbiorka ALTER COLUMN numer_telefonu_blik DROP NOT NULL;
|
||||
|
||||
BEGIN TRANSACTION;
|
||||
_______________________________
|
||||
|
||||
-- 1) Nowa tabela z właściwym FK (ON DELETE CASCADE)
|
||||
CREATE TABLE wplata_new (
|
||||
id INTEGER PRIMARY KEY,
|
||||
zbiorka_id INTEGER NOT NULL,
|
||||
kwota REAL NOT NULL,
|
||||
data DATETIME,
|
||||
opis TEXT,
|
||||
FOREIGN KEY(zbiorka_id) REFERENCES zbiorka(id) ON DELETE CASCADE
|
||||
);
|
||||
PGSQL
|
||||
ALTER TABLE wplata ADD COLUMN ukryta boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE wydatek ADD COLUMN ukryta boolean NOT NULL DEFAULT false;
|
||||
|
||||
-- 2) (opcjonalnie) upewnij się, że nie ma „sierotek”
|
||||
-- SELECT w.* FROM wplata w LEFT JOIN zbiorka z ON z.id = w.zbiorka_id WHERE z.id IS NULL;
|
||||
|
||||
-- 3) Kopiowanie danych
|
||||
INSERT INTO wplata_new (id, zbiorka_id, kwota, data, opis)
|
||||
SELECT id, zbiorka_id, kwota, data, opis
|
||||
FROM wplata;
|
||||
|
||||
-- 4) Usunięcie starej tabeli
|
||||
DROP TABLE wplata;
|
||||
|
||||
-- 5) Zmiana nazwy nowej tabeli na właściwą
|
||||
ALTER TABLE wplata_new RENAME TO wplata;
|
||||
|
||||
-- 6) Odtwórz indeksy/trigger-y jeśli jakieś były (przykład indeksu po FK)
|
||||
-- CREATE INDEX idx_wplata_zbiorka_id ON wplata(zbiorka_id);
|
||||
|
||||
COMMIT;
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
-- po migracji można zdjąć DEFAULT (opcjonalnie)
|
||||
ALTER TABLE wplata ALTER COLUMN ukryta DROP DEFAULT;
|
||||
ALTER TABLE wydatek ALTER COLUMN ukryta DROP DEFAULT;
|
||||
|
||||
|
||||
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
PRAGMA foreign_keys=OFF;
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
ALTER TABLE global_settings ADD COLUMN logo_url TEXT DEFAULT '';
|
||||
ALTER TABLE global_settings ADD COLUMN site_title TEXT DEFAULT '';
|
||||
ALTER TABLE global_settings ADD COLUMN show_logo_in_navbar BOOLEAN DEFAULT 0;
|
||||
|
||||
|
||||
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys=ON;
|
||||
|
||||
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
|
||||
-- 1) Dodajemy nowe kolumny (SQLite pozwala tylko na ADD COLUMN)
|
||||
ALTER TABLE global_settings ADD COLUMN navbar_brand_mode TEXT DEFAULT 'text';
|
||||
ALTER TABLE global_settings ADD COLUMN footer_brand_mode TEXT DEFAULT 'text';
|
||||
ALTER TABLE global_settings ADD COLUMN footer_text TEXT;
|
||||
|
||||
-- 2) Backfill: zgodność wsteczna z show_logo_in_navbar
|
||||
UPDATE global_settings
|
||||
SET navbar_brand_mode = 'logo'
|
||||
WHERE COALESCE(show_logo_in_navbar, 0) = 1;
|
||||
|
||||
-- 3) Upewnij się, że wartości są ustawione (na wypadek NULL-i)
|
||||
UPDATE global_settings
|
||||
SET navbar_brand_mode = COALESCE(navbar_brand_mode, 'text'),
|
||||
footer_brand_mode = COALESCE(footer_brand_mode, 'text');
|
||||
SQLite
|
||||
ALTER TABLE wplata ADD COLUMN ukryta INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE wydatek ADD COLUMN ukryta INTEGER NOT NULL DEFAULT 0;
|
440
app.py
440
app.py
@@ -65,30 +65,35 @@ APP_VERSION = f"{deploy_date}+{commit}"
|
||||
app.config["APP_VERSION"] = APP_VERSION
|
||||
|
||||
# MODELE
|
||||
class User(UserMixin, db.Model):
|
||||
class Uzytkownik(UserMixin, db.Model):
|
||||
__tablename__ = "uzytkownik"
|
||||
|
||||
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
|
||||
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.password_hash = generate_password_hash(password)
|
||||
|
||||
self.haslo_hash = generate_password_hash(password)
|
||||
def check_password(self, password):
|
||||
return check_password_hash(self.password_hash, 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=False)
|
||||
numer_telefonu_blik = db.Column(db.String(50), 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)
|
||||
|
||||
wplaty = db.relationship(
|
||||
"Wplata",
|
||||
@@ -139,9 +144,8 @@ class Wplata(db.Model):
|
||||
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)
|
||||
@@ -153,35 +157,37 @@ class Wydatek(db.Model):
|
||||
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 UstawieniaGlobalne(db.Model):
|
||||
__tablename__ = "ustawienia_globalne"
|
||||
|
||||
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)
|
||||
dozwolone_hosty_logowania = db.Column(db.Text, nullable=True)
|
||||
logo_url = db.Column(db.String(255), nullable=True)
|
||||
site_title = db.Column(db.String(120), nullable=True)
|
||||
show_logo_in_navbar = db.Column(db.Boolean, default=False)
|
||||
navbar_brand_mode = db.Column(db.String(10), default="text")
|
||||
footer_brand_mode = db.Column(db.String(10), default="text")
|
||||
footer_text = db.Column(db.String(200), 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)
|
||||
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
return db.session.get(User, int(user_id))
|
||||
return db.session.get(Uzytkownik, int(user_id))
|
||||
|
||||
|
||||
@event.listens_for(Engine, "connect")
|
||||
def set_sqlite_pragma(dbapi_connection, connection_record):
|
||||
try:
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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
|
||||
@@ -249,11 +255,9 @@ def markdown_filter(text):
|
||||
|
||||
@app.context_processor
|
||||
def inject_globals():
|
||||
settings = GlobalSettings.query.first()
|
||||
settings = UstawieniaGlobalne.query.first()
|
||||
allowed_hosts_str = (
|
||||
settings.allowed_login_hosts
|
||||
if settings and settings.allowed_login_hosts
|
||||
else ""
|
||||
settings.dozwolone_hosty_logowania if settings and settings.dozwolone_hosty_logowania else ""
|
||||
)
|
||||
client_ip = get_real_ip()
|
||||
return {
|
||||
@@ -290,20 +294,28 @@ def zbiorka(zbiorka_id):
|
||||
zb = db.session.get(Zbiorka, zbiorka_id)
|
||||
if zb is None:
|
||||
abort(404)
|
||||
if zb.ukryta and (not current_user.is_authenticated or not current_user.is_admin):
|
||||
if zb.ukryta and (not current_user.is_authenticated or not current_user.czy_admin):
|
||||
abort(404)
|
||||
|
||||
# scalona oś czasu: wpłaty + wydatki
|
||||
aktywnosci = [
|
||||
{"typ": "wpłata", "kwota": w.kwota, "opis": w.opis, "data": w.data}
|
||||
is_admin = current_user.is_authenticated and current_user.czy_admin
|
||||
show_hidden = is_admin and (request.args.get("show_hidden") in ("1", "true", "yes"))
|
||||
|
||||
# wpłaty / wydatki z filtrem ukrycia
|
||||
wplaty = [
|
||||
{"typ": "wpłata", "kwota": w.kwota, "opis": w.opis, "data": w.data, "ukryta": getattr(w, "ukryta", False)}
|
||||
for w in zb.wplaty
|
||||
] + [
|
||||
{"typ": "wydatek", "kwota": x.kwota, "opis": x.opis, "data": x.data}
|
||||
for x in zb.wydatki
|
||||
if show_hidden or not getattr(w, "ukryta", False)
|
||||
]
|
||||
wydatki = [
|
||||
{"typ": "wydatek", "kwota": x.kwota, "opis": x.opis, "data": x.data, "ukryta": getattr(x, "ukryta", False)}
|
||||
for x in zb.wydatki
|
||||
if show_hidden or not getattr(x, "ukryta", False)
|
||||
]
|
||||
|
||||
aktywnosci = wplaty + wydatki
|
||||
aktywnosci.sort(key=lambda a: a["data"], reverse=True)
|
||||
|
||||
return render_template("zbiorka.html", zbiorka=zb, aktywnosci=aktywnosci)
|
||||
return render_template("zbiorka.html", zbiorka=zb, aktywnosci=aktywnosci, show_hidden=show_hidden)
|
||||
|
||||
|
||||
# TRASY LOGOWANIA I REJESTRACJI
|
||||
@@ -311,34 +323,22 @@ def zbiorka(zbiorka_id):
|
||||
|
||||
@app.route("/zaloguj", methods=["GET", "POST"])
|
||||
def zaloguj():
|
||||
# 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
|
||||
settings = UstawieniaGlobalne.query.first()
|
||||
allowed_hosts_str = settings.dozwolone_hosty_logowania or "" if settings else ""
|
||||
client_ip = get_real_ip()
|
||||
if not is_allowed_ip(client_ip, allowed_hosts_str):
|
||||
flash(
|
||||
"Dostęp do tego systemu jest zablokowany dla Twojego adresu IP",
|
||||
"danger",
|
||||
)
|
||||
flash("Dostęp do tego systemu 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()
|
||||
login = request.form["uzytkownik"]
|
||||
password = request.form["haslo"]
|
||||
user = Uzytkownik.query.filter_by(uzytkownik=login).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"))
|
||||
)
|
||||
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")
|
||||
@@ -358,12 +358,12 @@ def zarejestruj():
|
||||
flash("Rejestracja została wyłączona przez administratora", "danger")
|
||||
return redirect(url_for("zaloguj"))
|
||||
if request.method == "POST":
|
||||
username = request.form["username"]
|
||||
password = request.form["password"]
|
||||
if User.query.filter_by(username=username).first():
|
||||
login = request.form["uzytkownik"]
|
||||
password = request.form["haslo"]
|
||||
if Uzytkownik.query.filter_by(uzytkownik=login).first():
|
||||
flash("Użytkownik już istnieje", "danger")
|
||||
return redirect(url_for("register"))
|
||||
new_user = User(username=username)
|
||||
new_user = Uzytkownik(uzytkownik=login)
|
||||
new_user.set_password(password)
|
||||
db.session.add(new_user)
|
||||
db.session.commit()
|
||||
@@ -376,7 +376,7 @@ def zarejestruj():
|
||||
@app.route("/admin")
|
||||
@login_required
|
||||
def admin_dashboard():
|
||||
if not current_user.is_admin:
|
||||
if not current_user.czy_admin:
|
||||
flash("Brak uprawnień do panelu administracyjnego", "danger")
|
||||
return redirect(url_for("index"))
|
||||
active_zbiorki = Zbiorka.query.filter_by(zrealizowana=False).all()
|
||||
@@ -392,81 +392,118 @@ def admin_dashboard():
|
||||
@app.route("/admin/zbiorka/edytuj/<int:zbiorka_id>", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def formularz_zbiorek(zbiorka_id=None):
|
||||
if not current_user.is_admin:
|
||||
if not current_user.czy_admin:
|
||||
flash("Brak uprawnień", "danger")
|
||||
return redirect(url_for("index"))
|
||||
|
||||
# Tryb
|
||||
is_edit = zbiorka_id is not None
|
||||
zb = db.session.get(Zbiorka, zbiorka_id)
|
||||
if zb is None:
|
||||
zb = db.session.get(Zbiorka, zbiorka_id) if is_edit else None
|
||||
if is_edit and zb is None:
|
||||
abort(404)
|
||||
global_settings = GlobalSettings.query.first()
|
||||
|
||||
global_settings = UstawieniaGlobalne.query.first()
|
||||
|
||||
def _temp_obj():
|
||||
t = zb or Zbiorka()
|
||||
t.nazwa = (request.form.get("nazwa", "") or "").strip()
|
||||
t.opis = (request.form.get("opis", "") or "").strip()
|
||||
t.numer_konta = (request.form.get("numer_konta", "") or "").strip()
|
||||
t.numer_telefonu_blik = (request.form.get("numer_telefonu_blik", "") or "").strip()
|
||||
t.ukryj_kwote = "ukryj_kwote" in request.form
|
||||
t.pokaz_postep_finanse = "pokaz_postep_finanse" in request.form
|
||||
t.pokaz_postep_pozycje = "pokaz_postep_pozycje" in request.form
|
||||
t.pokaz_postep_kwotowo = "pokaz_postep_kwotowo" in request.form
|
||||
t.uzyj_konta = "uzyj_konta" in request.form
|
||||
t.uzyj_blik = "uzyj_blik" in request.form
|
||||
return t
|
||||
|
||||
if request.method == "POST":
|
||||
# Pola wspólne
|
||||
nazwa = request.form.get("nazwa", "").strip()
|
||||
opis = request.form.get("opis", "").strip()
|
||||
# Pola
|
||||
nazwa = (request.form.get("nazwa", "") or "").strip()
|
||||
opis = (request.form.get("opis", "") or "").strip()
|
||||
numer_konta = (request.form.get("numer_konta", "") or "").strip()
|
||||
numer_telefonu_blik = (request.form.get("numer_telefonu_blik", "") or "").strip()
|
||||
|
||||
# IBAN/telefon — oczyść z nadmiarowych znaków odstępu (zostaw spacje w prezentacji frontu)
|
||||
numer_konta = request.form.get("numer_konta", "").strip()
|
||||
numer_telefonu_blik = request.form.get("numer_telefonu_blik", "").strip()
|
||||
# Przełączniki płatności
|
||||
uzyj_konta = "uzyj_konta" in request.form
|
||||
uzyj_blik = "uzyj_blik" in request.form
|
||||
|
||||
# Cel — walidacja liczby (Decimal, nie float)
|
||||
# Widoczność/metryki
|
||||
ukryj_kwote = "ukryj_kwote" in request.form
|
||||
pokaz_postep_finanse = "pokaz_postep_finanse" in request.form
|
||||
pokaz_postep_pozycje = "pokaz_postep_pozycje" in request.form
|
||||
pokaz_postep_kwotowo = "pokaz_postep_kwotowo" in request.form
|
||||
|
||||
# Walidacje
|
||||
if not nazwa:
|
||||
flash("Nazwa jest wymagana", "danger")
|
||||
return render_template("admin/formularz_zbiorek.html", zbiorka=_temp_obj(), global_settings=global_settings)
|
||||
|
||||
if not opis:
|
||||
flash("Opis jest wymagany", "danger")
|
||||
return render_template("admin/formularz_zbiorek.html", zbiorka=_temp_obj(), global_settings=global_settings)
|
||||
|
||||
# Co najmniej jeden kanał
|
||||
if not (uzyj_konta or uzyj_blik):
|
||||
flash("Włącz co najmniej jeden kanał wpłat (konto lub BLIK).", "danger")
|
||||
return render_template("admin/formularz_zbiorek.html", zbiorka=_temp_obj(), global_settings=global_settings)
|
||||
|
||||
# Warunkowe wartości
|
||||
if uzyj_konta and not numer_konta:
|
||||
flash("Numer konta jest wymagany (kanał przelewu włączony).", "danger")
|
||||
return render_template("admin/formularz_zbiorek.html", zbiorka=_temp_obj(), global_settings=global_settings)
|
||||
|
||||
if uzyj_blik and not numer_telefonu_blik:
|
||||
flash("Numer telefonu BLIK jest wymagany (kanał BLIK włączony).", "danger")
|
||||
return render_template("admin/formularz_zbiorek.html", zbiorka=_temp_obj(), global_settings=global_settings)
|
||||
|
||||
# Cel > 0
|
||||
cel_raw = (request.form.get("cel", "") or "")
|
||||
cel_norm = cel_raw.replace(" ", "").replace("\u00A0", "").replace(",", ".").strip()
|
||||
try:
|
||||
cel_str = request.form.get("cel", "").replace(",", ".").strip()
|
||||
cel = Decimal(cel_str)
|
||||
if not cel_norm:
|
||||
raise InvalidOperation
|
||||
cel = Decimal(cel_norm)
|
||||
if cel <= Decimal("0"):
|
||||
raise InvalidOperation
|
||||
except (InvalidOperation, ValueError):
|
||||
flash("Podano nieprawidłową wartość dla celu zbiórki", "danger")
|
||||
# render z dotychczasowo wpisanymi danymi (w trybie dodawania tworzymy tymczasowy obiekt)
|
||||
temp_zb = zb or Zbiorka(
|
||||
nazwa=nazwa,
|
||||
opis=opis,
|
||||
numer_konta=numer_konta,
|
||||
numer_telefonu_blik=numer_telefonu_blik,
|
||||
cel=None,
|
||||
ukryj_kwote=("ukryj_kwote" in request.form),
|
||||
)
|
||||
return render_template(
|
||||
"admin/formularz_zbiorek.html",
|
||||
zbiorka=temp_zb if is_edit else None if zb is None else temp_zb,
|
||||
global_settings=global_settings,
|
||||
)
|
||||
|
||||
ukryj_kwote = "ukryj_kwote" in request.form
|
||||
return render_template("admin/formularz_zbiorek.html", zbiorka=_temp_obj(), global_settings=global_settings)
|
||||
|
||||
# Produkty
|
||||
names = request.form.getlist("item_nazwa[]")
|
||||
links = request.form.getlist("item_link[]")
|
||||
prices = request.form.getlist("item_cena[]")
|
||||
|
||||
def _read_price(val):
|
||||
"""Zwraca Decimal(>=0) albo None; akceptuje przecinek jako separator dziesiętny."""
|
||||
def _read_price(val: str):
|
||||
if not val or not val.strip():
|
||||
return None
|
||||
try:
|
||||
d = Decimal(val.replace(",", "."))
|
||||
if d < 0:
|
||||
return None
|
||||
return d
|
||||
return d if d >= 0 else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# --- ZAPIS ZBIÓRKI + PRODUKTÓW ---
|
||||
# Zapis
|
||||
if is_edit:
|
||||
# Aktualizacja istniejącej zbiórki
|
||||
zb.nazwa = nazwa
|
||||
zb.opis = opis
|
||||
zb.numer_konta = numer_konta
|
||||
zb.numer_telefonu_blik = numer_telefonu_blik
|
||||
zb.cel = cel # ❗ bez float(cel) — zostaje Decimal
|
||||
|
||||
# NOT NULL-safe: puste stringi gdy wyłączone
|
||||
zb.uzyj_konta = uzyj_konta
|
||||
zb.uzyj_blik = uzyj_blik
|
||||
zb.numer_konta = numer_konta if uzyj_konta else ""
|
||||
zb.numer_telefonu_blik = numer_telefonu_blik if uzyj_blik else ""
|
||||
|
||||
zb.cel = cel
|
||||
zb.ukryj_kwote = ukryj_kwote
|
||||
db.session.commit() # najpierw zapisz bazowe pola
|
||||
zb.pokaz_postep_finanse = pokaz_postep_finanse
|
||||
zb.pokaz_postep_pozycje = pokaz_postep_pozycje
|
||||
zb.pokaz_postep_kwotowo = pokaz_postep_kwotowo
|
||||
db.session.commit()
|
||||
|
||||
# Nadpisz listę produktów (czyść i dodaj od nowa dla prostoty)
|
||||
# Nadpisz pozycje
|
||||
zb.przedmioty.clear()
|
||||
|
||||
for i, raw_name in enumerate(names):
|
||||
name = (raw_name or "").strip()
|
||||
if not name:
|
||||
@@ -474,32 +511,33 @@ def formularz_zbiorek(zbiorka_id=None):
|
||||
link = (links[i] if i < len(links) else "").strip() or None
|
||||
cena_val = _read_price(prices[i] if i < len(prices) else "")
|
||||
kupione_val = request.form.get(f"item_kupione_val_{i}") == "1"
|
||||
|
||||
db.session.add(Przedmiot(
|
||||
zbiorka_id=zb.id,
|
||||
nazwa=name,
|
||||
link=link,
|
||||
cena=cena_val, # Decimal albo None
|
||||
cena=cena_val,
|
||||
kupione=kupione_val
|
||||
))
|
||||
|
||||
db.session.commit()
|
||||
flash("Zbiórka została zaktualizowana", "success")
|
||||
|
||||
else:
|
||||
# Utworzenie nowej zbiórki
|
||||
nowa = Zbiorka(
|
||||
nazwa=nazwa,
|
||||
opis=opis,
|
||||
numer_konta=numer_konta,
|
||||
numer_telefonu_blik=numer_telefonu_blik,
|
||||
cel=cel, # ❗ Decimal
|
||||
uzyj_konta=uzyj_konta,
|
||||
uzyj_blik=uzyj_blik,
|
||||
numer_konta=(numer_konta if uzyj_konta else ""),
|
||||
numer_telefonu_blik=(numer_telefonu_blik if uzyj_blik else ""),
|
||||
cel=cel,
|
||||
ukryj_kwote=ukryj_kwote,
|
||||
pokaz_postep_finanse=pokaz_postep_finanse,
|
||||
pokaz_postep_pozycje=pokaz_postep_pozycje,
|
||||
pokaz_postep_kwotowo=pokaz_postep_kwotowo,
|
||||
)
|
||||
db.session.add(nowa)
|
||||
db.session.commit() # potrzebujemy ID nowej zbiórki
|
||||
db.session.commit() # potrzebne ID
|
||||
|
||||
# Dodaj produkty do nowej zbiórki
|
||||
for i, raw_name in enumerate(names):
|
||||
name = (raw_name or "").strip()
|
||||
if not name:
|
||||
@@ -507,15 +545,13 @@ def formularz_zbiorek(zbiorka_id=None):
|
||||
link = (links[i] if i < len(links) else "").strip() or None
|
||||
cena_val = _read_price(prices[i] if i < len(prices) else "")
|
||||
kupione_val = request.form.get(f"item_kupione_val_{i}") == "1"
|
||||
|
||||
db.session.add(Przedmiot(
|
||||
zbiorka_id=nowa.id,
|
||||
nazwa=name,
|
||||
link=link,
|
||||
cena=cena_val, # Decimal albo None
|
||||
cena=cena_val,
|
||||
kupione=kupione_val
|
||||
))
|
||||
|
||||
db.session.commit()
|
||||
flash("Zbiórka została dodana", "success")
|
||||
|
||||
@@ -523,14 +559,16 @@ def formularz_zbiorek(zbiorka_id=None):
|
||||
|
||||
# GET
|
||||
return render_template(
|
||||
"admin/formularz_zbiorek.html", zbiorka=zb, global_settings=global_settings
|
||||
"admin/formularz_zbiorek.html",
|
||||
zbiorka=zb,
|
||||
global_settings=global_settings
|
||||
)
|
||||
|
||||
|
||||
@app.route("/admin/zbiorka/<int:zbiorka_id>/wplata/dodaj", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def dodaj_wplate(zbiorka_id):
|
||||
if not current_user.is_admin:
|
||||
if not current_user.czy_admin:
|
||||
flash("Brak uprawnień", "danger")
|
||||
return redirect(url_for("index"))
|
||||
|
||||
@@ -562,7 +600,7 @@ def dodaj_wplate(zbiorka_id):
|
||||
@app.route("/admin/zbiorka/usun/<int:zbiorka_id>", methods=["POST"])
|
||||
@login_required
|
||||
def usun_zbiorka(zbiorka_id):
|
||||
if not current_user.is_admin:
|
||||
if not current_user.czy_admin:
|
||||
flash("Brak uprawnień", "danger")
|
||||
return redirect(url_for("index"))
|
||||
zb = db.session.get(Zbiorka, zbiorka_id)
|
||||
@@ -577,7 +615,7 @@ def usun_zbiorka(zbiorka_id):
|
||||
@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:
|
||||
if not current_user.czy_admin:
|
||||
flash("Brak uprawnień", "danger")
|
||||
return redirect(url_for("index"))
|
||||
zb = db.session.get(Zbiorka, zbiorka_id)
|
||||
@@ -599,7 +637,7 @@ def edytuj_stan(zbiorka_id):
|
||||
@app.route("/admin/zbiorka/zmien_widzialnosc/<int:zbiorka_id>", methods=["POST"])
|
||||
@login_required
|
||||
def zmien_widzialnosc(zbiorka_id):
|
||||
if not current_user.is_admin:
|
||||
if not current_user.czy_admin:
|
||||
flash("Brak uprawnień", "danger")
|
||||
return redirect(url_for("index"))
|
||||
zb = db.session.get(Zbiorka, zbiorka_id)
|
||||
@@ -612,15 +650,17 @@ def zmien_widzialnosc(zbiorka_id):
|
||||
|
||||
|
||||
def create_admin_account():
|
||||
admin = User.query.filter_by(is_admin=True).first()
|
||||
admin = Uzytkownik.query.filter_by(czy_admin=True).first()
|
||||
if not admin:
|
||||
main_admin = User(username=app.config["MAIN_ADMIN_USERNAME"], is_admin=True)
|
||||
main_admin = Uzytkownik(
|
||||
uzytkownik=app.config["MAIN_ADMIN_USERNAME"],
|
||||
czy_admin=True
|
||||
)
|
||||
main_admin.set_password(app.config["MAIN_ADMIN_PASSWORD"])
|
||||
db.session.add(main_admin)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
|
||||
@app.after_request
|
||||
def apply_headers(response):
|
||||
if request.path.startswith("/static/"):
|
||||
@@ -635,9 +675,9 @@ def apply_headers(response):
|
||||
return response
|
||||
|
||||
path_norm = request.path.lstrip("/")
|
||||
is_admin = path_norm.startswith("admin/") or path_norm == "admin"
|
||||
czy_admin = path_norm.startswith("admin/") or path_norm == "admin"
|
||||
|
||||
if is_admin:
|
||||
if czy_admin:
|
||||
if (response.mimetype or "").startswith("text/html"):
|
||||
response.headers["Cache-Control"] = "no-store, no-cache"
|
||||
response.headers.pop("ETag", None)
|
||||
@@ -664,7 +704,7 @@ def apply_headers(response):
|
||||
|
||||
if (
|
||||
app.config.get("BLOCK_BOTS", False)
|
||||
and not is_admin
|
||||
and not czy_admin
|
||||
and not request.path.startswith("/static/")
|
||||
):
|
||||
cc_override = app.config.get("CACHE_CONTROL_HEADER")
|
||||
@@ -680,60 +720,58 @@ def apply_headers(response):
|
||||
@app.route("/admin/ustawienia", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def admin_ustawienia():
|
||||
if not current_user.is_admin:
|
||||
if not current_user.czy_admin:
|
||||
flash("Brak uprawnień do panelu administracyjnego", "danger")
|
||||
return redirect(url_for("index"))
|
||||
|
||||
client_ip = get_real_ip()
|
||||
settings = GlobalSettings.query.first()
|
||||
settings = UstawieniaGlobalne.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")
|
||||
dozwolone_hosty_logowania = request.form.get("dozwolone_hosty_logowania")
|
||||
logo_url = request.form.get("logo_url")
|
||||
site_title = request.form.get("site_title")
|
||||
navbar_brand_mode = request.form.get("navbar_brand_mode", "text")
|
||||
footer_brand_mode = request.form.get("footer_brand_mode", "text")
|
||||
footer_text = request.form.get("footer_text") or None
|
||||
show_logo_in_navbar = navbar_brand_mode == "logo"
|
||||
tytul_strony = request.form.get("tytul_strony")
|
||||
typ_navbar = request.form.get("typ_navbar", "text")
|
||||
typ_stopka = request.form.get("typ_stopka", "text")
|
||||
stopka_text = request.form.get("stopka_text") or None
|
||||
pokaz_logo_w_navbar = (typ_navbar == "logo")
|
||||
|
||||
if settings is None:
|
||||
settings = GlobalSettings(
|
||||
settings = UstawieniaGlobalne(
|
||||
numer_konta=numer_konta,
|
||||
numer_telefonu_blik=numer_telefonu_blik,
|
||||
allowed_login_hosts=allowed_login_hosts,
|
||||
dozwolone_hosty_logowania=dozwolone_hosty_logowania,
|
||||
logo_url=logo_url,
|
||||
site_title=site_title,
|
||||
show_logo_in_navbar=show_logo_in_navbar,
|
||||
navbar_brand_mode=navbar_brand_mode,
|
||||
footer_brand_mode=footer_brand_mode,
|
||||
footer_text=footer_text,
|
||||
tytul_strony=tytul_strony,
|
||||
pokaz_logo_w_navbar=pokaz_logo_w_navbar,
|
||||
typ_navbar=typ_navbar,
|
||||
typ_stopka=typ_stopka,
|
||||
stopka_text=stopka_text,
|
||||
)
|
||||
db.session.add(settings)
|
||||
else:
|
||||
settings.numer_konta = numer_konta
|
||||
settings.numer_telefonu_blik = numer_telefonu_blik
|
||||
settings.allowed_login_hosts = allowed_login_hosts
|
||||
settings.dozwolone_hosty_logowania = dozwolone_hosty_logowania
|
||||
settings.logo_url = logo_url
|
||||
settings.site_title = site_title
|
||||
settings.show_logo_in_navbar = show_logo_in_navbar
|
||||
settings.navbar_brand_mode = navbar_brand_mode
|
||||
settings.footer_brand_mode = footer_brand_mode
|
||||
settings.footer_text = footer_text
|
||||
settings.tytul_strony = tytul_strony
|
||||
settings.pokaz_logo_w_navbar = pokaz_logo_w_navbar
|
||||
settings.typ_navbar = typ_navbar
|
||||
settings.typ_stopka = typ_stopka
|
||||
settings.stopka_text = stopka_text
|
||||
|
||||
db.session.commit()
|
||||
flash("Ustawienia globalne zostały zaktualizowane", "success")
|
||||
return redirect(url_for("admin_dashboard"))
|
||||
|
||||
return render_template(
|
||||
"admin/ustawienia.html", settings=settings, client_ip=client_ip
|
||||
)
|
||||
return render_template("admin/ustawienia.html", settings=settings, client_ip=client_ip)
|
||||
|
||||
|
||||
@app.route("/admin/zbiorka/<int:zbiorka_id>/wydatek/dodaj", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def dodaj_wydatek(zbiorka_id):
|
||||
if not current_user.is_admin:
|
||||
if not current_user.czy_admin:
|
||||
flash("Brak uprawnień", "danger")
|
||||
return redirect(url_for("index"))
|
||||
|
||||
@@ -775,7 +813,7 @@ def dodaj_wydatek(zbiorka_id):
|
||||
)
|
||||
@login_required
|
||||
def oznacz_zbiorka(zbiorka_id):
|
||||
if not current_user.is_admin:
|
||||
if not current_user.czy_admin:
|
||||
flash("Brak uprawnień do wykonania tej operacji", "danger")
|
||||
return redirect(url_for("index"))
|
||||
|
||||
@@ -807,23 +845,47 @@ def robots():
|
||||
@app.route("/admin/zbiorka/<int:zbiorka_id>/transakcje")
|
||||
@login_required
|
||||
def transakcje_zbiorki(zbiorka_id):
|
||||
if not current_user.is_admin:
|
||||
if not current_user.czy_admin:
|
||||
flash("Brak uprawnień", "danger"); return redirect(url_for("index"))
|
||||
|
||||
zb = db.session.get(Zbiorka, zbiorka_id)
|
||||
if zb is None:
|
||||
abort(404)
|
||||
|
||||
aktywnosci = (
|
||||
[{"typ": "wpłata", "id": w.id, "kwota": w.kwota, "opis": w.opis, "data": w.data} for w in zb.wplaty] +
|
||||
[{"typ": "wydatek","id": x.id, "kwota": x.kwota,"opis": x.opis,"data": x.data} for x in zb.wydatki]
|
||||
[
|
||||
{
|
||||
"typ": "wpłata",
|
||||
"id": w.id,
|
||||
"kwota": w.kwota,
|
||||
"opis": w.opis,
|
||||
"data": w.data,
|
||||
"ukryta": bool(w.ukryta),
|
||||
}
|
||||
for w in zb.wplaty
|
||||
]
|
||||
+
|
||||
[
|
||||
{
|
||||
"typ": "wydatek",
|
||||
"id": x.id,
|
||||
"kwota": x.kwota,
|
||||
"opis": x.opis,
|
||||
"data": x.data,
|
||||
"ukryta": bool(x.ukryta),
|
||||
}
|
||||
for x in zb.wydatki
|
||||
]
|
||||
)
|
||||
aktywnosci.sort(key=lambda a: a["data"], reverse=True)
|
||||
return render_template("admin/transakcje.html", zbiorka=zb, aktywnosci=aktywnosci)
|
||||
|
||||
|
||||
|
||||
@app.route("/admin/wplata/<int:wplata_id>/zapisz", methods=["POST"])
|
||||
@login_required
|
||||
def zapisz_wplate(wplata_id):
|
||||
if not current_user.is_admin:
|
||||
if not current_user.czy_admin:
|
||||
flash("Brak uprawnień", "danger"); return redirect(url_for("index"))
|
||||
w = db.session.get(Wplata, wplata_id)
|
||||
if w is None:
|
||||
@@ -845,11 +907,55 @@ def zapisz_wplate(wplata_id):
|
||||
flash("Wpłata zaktualizowana", "success")
|
||||
return redirect(url_for("transakcje_zbiorki", zbiorka_id=zb.id))
|
||||
|
||||
@app.post("/wplata/<int:wplata_id>/ukryj")
|
||||
@login_required
|
||||
def ukryj_wplate(wplata_id):
|
||||
if not current_user.czy_admin: abort(403)
|
||||
w = db.session.get(Wplata, wplata_id)
|
||||
if not w: abort(404)
|
||||
w.ukryta = True
|
||||
db.session.commit()
|
||||
flash("Wpłata ukryta.", "success")
|
||||
return redirect(request.referrer or url_for("admin_dashboard"))
|
||||
|
||||
@app.post("/wplata/<int:wplata_id>/odkryj")
|
||||
@login_required
|
||||
def odkryj_wplate(wplata_id):
|
||||
if not current_user.czy_admin: abort(403)
|
||||
w = db.session.get(Wplata, wplata_id)
|
||||
if not w: abort(404)
|
||||
w.ukryta = False
|
||||
db.session.commit()
|
||||
flash("Wpłata odkryta.", "success")
|
||||
return redirect(request.referrer or url_for("admin_dashboard"))
|
||||
|
||||
@app.post("/wydatek/<int:wydatek_id>/ukryj")
|
||||
@login_required
|
||||
def ukryj_wydatek(wydatek_id):
|
||||
if not current_user.czy_admin: abort(403)
|
||||
w = db.session.get(Wydatek, wydatek_id)
|
||||
if not w: abort(404)
|
||||
w.ukryta = True
|
||||
db.session.commit()
|
||||
flash("Wydatek ukryty.", "success")
|
||||
return redirect(request.referrer or url_for("admin_dashboard"))
|
||||
|
||||
@app.post("/wydatek/<int:wydatek_id>/odkryj")
|
||||
@login_required
|
||||
def odkryj_wydatek(wydatek_id):
|
||||
if not current_user.czy_admin: abort(403)
|
||||
w = db.session.get(Wydatek, wydatek_id)
|
||||
if not w: abort(404)
|
||||
w.ukryta = False
|
||||
db.session.commit()
|
||||
flash("Wydatek odkryty.", "success")
|
||||
return redirect(request.referrer or url_for("admin_dashboard"))
|
||||
|
||||
|
||||
@app.route("/admin/wplata/<int:wplata_id>/usun", methods=["POST"])
|
||||
@login_required
|
||||
def usun_wplate(wplata_id):
|
||||
if not current_user.is_admin:
|
||||
if not current_user.czy_admin:
|
||||
flash("Brak uprawnień", "danger"); return redirect(url_for("index"))
|
||||
w = db.session.get(Wplata, wplata_id)
|
||||
if w is None:
|
||||
@@ -865,7 +971,7 @@ def usun_wplate(wplata_id):
|
||||
@app.route("/admin/wydatek/<int:wydatek_id>/zapisz", methods=["POST"])
|
||||
@login_required
|
||||
def zapisz_wydatek(wydatek_id):
|
||||
if not current_user.is_admin:
|
||||
if not current_user.czy_admin:
|
||||
flash("Brak uprawnień", "danger"); return redirect(url_for("index"))
|
||||
x = db.session.get(Wydatek, wydatek_id)
|
||||
if x is None:
|
||||
@@ -892,7 +998,7 @@ def zapisz_wydatek(wydatek_id):
|
||||
@app.route("/admin/wydatek/<int:wydatek_id>/usun", methods=["POST"])
|
||||
@login_required
|
||||
def usun_wydatek(wydatek_id):
|
||||
if not current_user.is_admin:
|
||||
if not current_user.czy_admin:
|
||||
flash("Brak uprawnień", "danger"); return redirect(url_for("index"))
|
||||
x = db.session.get(Wydatek, wydatek_id)
|
||||
if x is None:
|
||||
@@ -923,14 +1029,12 @@ def healthcheck():
|
||||
if __name__ == "__main__":
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
# Tworzenie konta głównego admina, jeśli nie istnieje
|
||||
stmt = select(User).filter_by(is_admin=True)
|
||||
stmt = select(Uzytkownik).filter_by(czy_admin=True)
|
||||
admin = db.session.execute(stmt).scalars().first()
|
||||
|
||||
if not admin:
|
||||
main_admin = User(
|
||||
username=app.config["MAIN_ADMIN_USERNAME"],
|
||||
is_admin=True
|
||||
main_admin = Uzytkownik(
|
||||
uzytkownik=app.config["MAIN_ADMIN_USERNAME"],
|
||||
czy_admin=True
|
||||
)
|
||||
main_admin.set_password(app.config["MAIN_ADMIN_PASSWORD"])
|
||||
db.session.add(main_admin)
|
||||
|
26
config.py
26
config.py
@@ -1,5 +1,7 @@
|
||||
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:
|
||||
@@ -24,8 +26,8 @@ class Config:
|
||||
- ROBOTS_TAG
|
||||
"""
|
||||
|
||||
# Baza danych
|
||||
SQLALCHEMY_DATABASE_URI = _get_str("DATABASE_URL", "sqlite:///baza.db")
|
||||
|
||||
#SQLALCHEMY_DATABASE_URI = _get_str("DATABASE_URL", "sqlite:///baza.db")
|
||||
|
||||
# Flask
|
||||
SECRET_KEY = _get_str("SECRET_KEY", "tajny_klucz")
|
||||
@@ -48,4 +50,22 @@ class Config:
|
||||
# (opcjonalnie) wyłącz warningi track_modifications
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
|
||||
HEALTHCHECK_TOKEN = _get_str("HEALTHCHECK_TOKEN", "healthcheck")
|
||||
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']}"
|
||||
)
|
||||
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']}"
|
||||
)
|
||||
else:
|
||||
raise ValueError("Nieobsługiwany typ bazy danych.")
|
59
deploy.sh
59
deploy.sh
@@ -1,59 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# === Konfiguracja (możesz nadpisać zmiennymi środowiskowymi) ===
|
||||
REPO_DIR="${REPO_DIR:-$(pwd)}"
|
||||
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}" # albo compose.yaml
|
||||
GIT_REMOTE="${GIT_REMOTE:-origin}"
|
||||
GIT_BRANCH="${GIT_BRANCH:-$(git -C "$REPO_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || echo main)}"
|
||||
|
||||
# Jeśli chcesz uruchomić tylko wybrane serwisy, podaj je po komendzie,
|
||||
# np.: ./deploy.sh web api
|
||||
SERVICES=("$@")
|
||||
|
||||
log() { printf "\n==> %s\n" "$*"; }
|
||||
|
||||
# --- Kontrole wstępne ---
|
||||
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
|
||||
|
||||
# --- Praca w katalogu repo ---
|
||||
cd "$REPO_DIR"
|
||||
|
||||
# --- Aktualizacja kodu ---
|
||||
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"
|
||||
|
||||
# --- Zapisanie wersji do pliku ---
|
||||
log "Zapisywanie hasha commita do version.txt"
|
||||
git rev-parse --short HEAD > version.txt
|
||||
|
||||
# --- Zatrzymanie i usunięcie bieżącego stacka ---
|
||||
log "Docker Compose DOWN (usuwanie kontenerów i osieroconych usług)"
|
||||
docker compose -f "$COMPOSE_FILE" down --remove-orphans
|
||||
|
||||
# --- Budowanie i uruchamianie bez restartu zależności ---
|
||||
log "Docker Compose UP (build bez deps) dla: ${SERVICES[*]:-(wszystkie)}"
|
||||
if [[ ${#SERVICES[@]} -gt 0 ]]; then
|
||||
docker compose -f "$COMPOSE_FILE" up -d --no-deps --build "${SERVICES[@]}"
|
||||
else
|
||||
docker compose -f "$COMPOSE_FILE" up -d --no-deps --build
|
||||
fi
|
||||
|
||||
log "Gotowe ✅ (wersja: $(cat version.txt))"
|
18
deploy/app/Dockerfile
Normal file
18
deploy/app/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
||||
#FROM python:3.13-slim
|
||||
FROM python:3.14-rc-trixie
|
||||
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"]
|
@@ -1,30 +1,43 @@
|
||||
FROM debian:trixie-slim
|
||||
# --- Stage 1: build varnish + modules ---
|
||||
FROM debian:trixie-slim AS builder
|
||||
|
||||
ARG VARNISH_VERSION=8.0.0
|
||||
ARG VARNISH_MODULES_VERSION=0.27.0
|
||||
|
||||
# potrzebne narzędzia do kompilacji
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl build-essential automake autoconf libtool pkg-config python3-sphinx \
|
||||
git ca-certificates \
|
||||
libpcre2-dev \
|
||||
libpcre2-dev libedit-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# instalacja varnish
|
||||
# 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
|
||||
|
||||
# instalacja varnish-modules (w tym vsthrottle)
|
||||
# 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
|
||||
|
||||
# katalog na konfigurację
|
||||
# --- 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/
|
||||
|
||||
|
@@ -1,58 +0,0 @@
|
||||
vcl 4.1;
|
||||
|
||||
import vsthrottle;
|
||||
|
||||
backend app {
|
||||
.host = "app";
|
||||
.port = "8080";
|
||||
}
|
||||
|
||||
acl purge { "localhost"; "127.0.0.1"; }
|
||||
|
||||
sub vcl_recv {
|
||||
# RATE LIMIT
|
||||
if (!vsthrottle.is_allowed(client.ip, 10, 10s)) {
|
||||
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. nagłówka
|
||||
if (req.url == "/healthcheck" || req.http.X-Internal-Check) { return (pass); }
|
||||
|
||||
# metody inne niż GET/HEAD bez cache
|
||||
if (req.method != "GET" && req.method != "HEAD") { return (pass); }
|
||||
|
||||
# static – agresywnie cache’ujemy
|
||||
if (req.url ~ "^/static/" || req.url ~ "\.(css|js|png|jpg|svg|ico|woff2?)$") { return (hash); }
|
||||
|
||||
return (hash);
|
||||
}
|
||||
|
||||
sub vcl_backend_response {
|
||||
if (bereq.url ~ "^/static/" || bereq.url ~ "\.(css|js|png|jpg|svg|ico|woff2?)$") {
|
||||
set beresp.ttl = 24h;
|
||||
} else {
|
||||
if (beresp.http.Cache-Control ~ "no-cache|no-store|private") {
|
||||
set beresp.uncacheable = true;
|
||||
set beresp.ttl = 0s;
|
||||
} else {
|
||||
set beresp.ttl = 60s; # domyślny TTL dla HTML/API
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sub vcl_deliver {
|
||||
if (obj.hits > 0) {
|
||||
set resp.http.X-Cache = "HIT";
|
||||
} else {
|
||||
set resp.http.X-Cache = "MISS";
|
||||
}
|
||||
|
||||
set resp.http.X-RateLimit-Limit = "10";
|
||||
set resp.http.X-RateLimit-Window = "10s";
|
||||
}
|
264
deploy/varnish/default.vcl.template
Normal file
264
deploy/varnish/default.vcl.template
Normal 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
86
deploy_docker.sh
Executable 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))"
|
@@ -16,10 +16,13 @@ services:
|
||||
- .env
|
||||
volumes:
|
||||
- ./instance:/app/instance
|
||||
networks:
|
||||
- zbiorki_app_network
|
||||
restart: unless-stopped
|
||||
|
||||
varnish:
|
||||
build: ./deploy/varnish
|
||||
#build: ./deploy/varnish
|
||||
image: varnish:latest
|
||||
container_name: zbiorka-varnish
|
||||
depends_on:
|
||||
app:
|
||||
@@ -30,11 +33,42 @@ services:
|
||||
- ./deploy/varnish/default.vcl:/etc/varnish/default.vcl:ro
|
||||
environment:
|
||||
- VARNISH_SIZE=256m
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "curl -fsS -H 'X-Internal-Check=${HEALTHCHECK_TOKEN}' http://localhost/healthcheck | grep -q OK" ]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
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,4 +3,7 @@ Flask-SQLAlchemy
|
||||
Flask-Login
|
||||
Werkzeug
|
||||
waitress
|
||||
markdown
|
||||
markdown
|
||||
psycopg2-binary # pgsql
|
||||
pymysql # mysql
|
||||
cryptography # mysql8
|
@@ -387,4 +387,54 @@ select.form-select:focus {
|
||||
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;
|
||||
}
|
74
static/js/przelaczniki_zabezpieczenie.js
Normal file
74
static/js/przelaczniki_zabezpieczenie.js
Normal 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());
|
||||
});
|
||||
})();
|
88
static/js/sposoby_wplat.js
Normal file
88
static/js/sposoby_wplat.js
Normal 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
@@ -23,4 +23,5 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
modalX.show();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
@@ -1,19 +1,20 @@
|
||||
(function () {
|
||||
// IBAN: tylko cyfry, auto-grupowanie co 4 (po prefiksie PL)
|
||||
// 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); // 26 cyfr po "PL"
|
||||
const digits = iban.value.replace(/\D/g, '').slice(0, 26);
|
||||
const chunked = digits.replace(/(.{4})/g, '$1 ').trim();
|
||||
iban.value = chunked;
|
||||
});
|
||||
}
|
||||
|
||||
// Telefon BLIK: tylko cyfry, format 3-3-3
|
||||
// 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 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));
|
||||
@@ -22,24 +23,23 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Biała lista IP/hostów — helpery
|
||||
const ta = document.getElementById('allowed_login_hosts');
|
||||
// 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');
|
||||
|
||||
function parseList(text) {
|
||||
// akceptuj przecinki, średniki i nowe linie; trimuj; usuń puste
|
||||
return text
|
||||
.split(/[\\n,;]+/)
|
||||
const parseList = (text) =>
|
||||
text
|
||||
.split(/[\r\n,;]+/) // \r?\n, przecinek, średnik
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
function formatList(arr) {
|
||||
return arr.join('\\n');
|
||||
}
|
||||
function dedupe(arr) {
|
||||
|
||||
const formatList = (arr) => arr.join('\n');
|
||||
|
||||
const dedupe = (arr) => {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const v of arr) {
|
||||
@@ -47,22 +47,23 @@
|
||||
if (!seen.has(k)) { seen.add(k); out.push(v); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function updateCount() {
|
||||
};
|
||||
|
||||
const updateCount = () => {
|
||||
if (!ta || !count) return;
|
||||
count.textContent = parseList(ta.value).length.toString();
|
||||
}
|
||||
function addEntry(val) {
|
||||
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);
|
||||
// inicjalny przelicznik
|
||||
updateCount();
|
||||
updateCount(); // inicjalne przeliczenie
|
||||
}
|
||||
|
||||
if (addBtn && input) {
|
||||
@@ -82,11 +83,10 @@
|
||||
});
|
||||
}
|
||||
|
||||
const dedupeBtn = document.getElementById('btn-dedupe');
|
||||
if (dedupeBtn && ta) {
|
||||
dedupeBtn.addEventListener('click', () => {
|
||||
ta.value = formatList(dedupe(parseList(ta.value)));
|
||||
updateCount();
|
||||
});
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
@@ -9,11 +9,11 @@
|
||||
}, false);
|
||||
})();
|
||||
|
||||
const pw = document.getElementById('password');
|
||||
const pw = document.getElementById("haslo");
|
||||
const toggle = document.getElementById('togglePw');
|
||||
toggle.addEventListener('click', () => {
|
||||
const isText = pw.type === 'text';
|
||||
pw.type = isText ? 'password' : 'text';
|
||||
pw.type = isText ? "haslo" : 'text';
|
||||
toggle.textContent = isText ? 'Pokaż' : 'Ukryj';
|
||||
toggle.setAttribute('aria-pressed', (!isText).toString());
|
||||
pw.focus();
|
||||
|
@@ -5,7 +5,7 @@
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
const pw1 = document.getElementById('password');
|
||||
const pw1 = document.getElementById("haslo");
|
||||
const pw2 = document.getElementById('password2');
|
||||
if (pw1.value !== pw2.value) {
|
||||
e.preventDefault();
|
||||
@@ -19,11 +19,11 @@
|
||||
}, false);
|
||||
})();
|
||||
|
||||
const pw = document.getElementById('password');
|
||||
const pw = document.getElementById("haslo");
|
||||
const toggle = document.getElementById('togglePw');
|
||||
toggle.addEventListener('click', () => {
|
||||
const isText = pw.type === 'text';
|
||||
pw.type = isText ? 'password' : 'text';
|
||||
pw.type = isText ? "haslo" : 'text';
|
||||
toggle.textContent = isText ? 'Pokaż' : 'Ukryj';
|
||||
pw.focus();
|
||||
});
|
||||
|
@@ -11,8 +11,8 @@
|
||||
<a href="{{ url_for('formularz_zbiorek') }}" class="btn btn-primary">
|
||||
➕ Dodaj zbiórkę
|
||||
</a>
|
||||
<a href="{{ url_for('admin_ustawienia') }}" class="btn btn-outline-light border">
|
||||
⚙️ Ustawienia
|
||||
<a href="{{ url_for('admin_ustawienia') }}" class="btn btn-outline-light">
|
||||
Ustawienia główne
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -71,8 +71,7 @@
|
||||
<span class="badge bg-secondary border"
|
||||
style="border-color: var(--border);">Ukryta</span>
|
||||
{% else %}
|
||||
<span class="badge rounded-pill"
|
||||
style="background: var(--accent); color:#111;">Widoczna</span>
|
||||
<span class="badge bg-success">Widoczna</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
@@ -261,7 +260,7 @@
|
||||
<div class="card-body text-center py-5">
|
||||
<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 border">Utwórz nową
|
||||
<a href="{{ url_for('formularz_zbiorek') }}" class="btn btn-outline-light">Utwórz nową
|
||||
zbiórkę</a>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -5,7 +5,7 @@
|
||||
<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 border">← Powrót do
|
||||
<a href="{{ url_for('zbiorka', zbiorka_id=zbiorka.id) }}" class="btn btn-sm btn-outline-light">← Powrót do
|
||||
zbiórki</a>
|
||||
</div>
|
||||
|
||||
@@ -60,14 +60,14 @@
|
||||
<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 [10,25,50,100,200] %}
|
||||
<button type="button" class="btn btn-sm btn-outline-light border btn-kwota" data-amount="{{ preset }}">
|
||||
{% for preset in [5,10,20,25,30,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 %}
|
||||
{% set brakujace = (zbiorka.cel - zbiorka.stan) if (zbiorka.cel - zbiorka.stan) > 0 else 0 %}
|
||||
<button type="button" class="btn btn-sm btn-outline-light border btn-kwota"
|
||||
<button type="button" class="btn btn-sm btn-outline-light btn-kwota"
|
||||
data-amount="{{ brakujace|round(2) }}">
|
||||
Do celu: {{ brakujace|round(2) }} PLN
|
||||
</button>
|
||||
@@ -87,7 +87,7 @@
|
||||
|
||||
<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 border">Anuluj</a>
|
||||
<a href="{{ url_for('zbiorka', zbiorka_id=zbiorka.id) }}" class="btn btn-outline-light">Anuluj</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
@@ -5,7 +5,7 @@
|
||||
<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 border">← Powrót
|
||||
<a href="{{ url_for('zbiorka', zbiorka_id=zbiorka.id) }}" class="btn btn-sm btn-outline-light">← Powrót
|
||||
do zbiórki</a>
|
||||
</div>
|
||||
|
||||
@@ -63,9 +63,9 @@
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<button type="submit" class="btn btn-danger">Dodaj wydatek</button>
|
||||
<button type="submit" class="btn btn-success">Dodaj wydatek</button>
|
||||
<a href="{{ url_for('zbiorka', zbiorka_id=zbiorka.id) }}"
|
||||
class="btn btn-outline-light border">Anuluj</a>
|
||||
class="btn btn-outline-light">Anuluj</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
@@ -6,9 +6,9 @@
|
||||
|
||||
<!-- 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 border">← Szczegóły
|
||||
<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 border">← Panel Admina</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) #}
|
||||
@@ -72,18 +72,18 @@
|
||||
<!-- 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 border btn-delta" data-delta="{{ delta }}">+{{
|
||||
<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 border btn-delta" data-delta="-{{ delta }}">-{{
|
||||
<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 border btn-set"
|
||||
<button type="button" class="btn btn-sm btn-outline-light btn-set"
|
||||
data-value="{{ zbiorka.cel|round(2) }}">Ustaw: do celu</button>
|
||||
{% set brakujace = (zbiorka.cel - zbiorka.stan) if (zbiorka.cel - zbiorka.stan) > 0 else 0 %}
|
||||
|
||||
{% endif %}
|
||||
<button type="button" class="btn btn-sm btn-outline-light border btn-set" data-value="0">Ustaw: 0</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-light btn-set" data-value="0">Ustaw: 0</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -125,7 +125,7 @@
|
||||
<!-- 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 border">Anuluj</a>
|
||||
<a href="{{ url_for('zbiorka', zbiorka_id=zbiorka.id) }}" class="btn btn-outline-light">Anuluj</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
@@ -1,7 +1,8 @@
|
||||
{# templates/zbiorka_form.html #}
|
||||
{# templates/formularz_zbiorek.html #}
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% set is_edit = zbiorka is not none %}
|
||||
{% 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 %}
|
||||
|
||||
@@ -15,11 +16,11 @@
|
||||
|
||||
<!-- Nawigacja / powrót -->
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
{% if is_edit %}
|
||||
<a href="{{ url_for('zbiorka', zbiorka_id=zbiorka.id) }}" class="btn btn-sm btn-outline-light border">←
|
||||
Szczegóły zbiórki</a>
|
||||
{% 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 border">← Panel Admina</a>
|
||||
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-sm btn-outline-light">← Panel Admina</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -40,11 +41,11 @@
|
||||
|
||||
{% if not zbiorka.ukryj_kwote %}
|
||||
<span class="badge bg-dark border" style="border-color: var(--border);">
|
||||
Stan: {{ zbiorka.stan|round(2) }} PLN
|
||||
Stan: {{ (zbiorka.stan or 0)|round(2) }} PLN
|
||||
</span>
|
||||
|
||||
{% if zbiorka.cel %}
|
||||
{% set delta = zbiorka.cel - zbiorka.stan %}
|
||||
{% 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
|
||||
@@ -65,7 +66,6 @@
|
||||
{% else %}
|
||||
<small class="opacity-75">Uzupełnij podstawowe dane i dane płatności</small>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
@@ -80,17 +80,18 @@
|
||||
<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 is_edit else '' }}" required aria-describedby="nazwaHelp">
|
||||
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 is_edit else '' }}</textarea>
|
||||
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 👁️.
|
||||
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>
|
||||
@@ -120,8 +121,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="produkty-body">
|
||||
{% set items = zbiorka.przedmioty if is_edit and zbiorka and zbiorka.przedmioty else []
|
||||
%}
|
||||
{% set items = (zbiorka.przedmioty if zbiorka and zbiorka.przedmioty else []) %}
|
||||
{% if items %}
|
||||
{% for it in items %}
|
||||
{% set i = loop.index0 %}
|
||||
@@ -151,7 +151,7 @@
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<button type="button" class="btn btn-sm btn-outline-light border remove-row"
|
||||
<button type="button" class="btn btn-sm btn-outline-light remove-row"
|
||||
title="Usuń wiersz">✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -173,7 +173,7 @@
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<button type="button" class="btn btn-sm btn-outline-light border remove-row"
|
||||
<button type="button" class="btn btn-sm btn-outline-light remove-row"
|
||||
title="Usuń wiersz">✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -183,9 +183,8 @@
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-light border" id="add-row">+ Dodaj
|
||||
pozycję</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-light border" id="clear-empty">Usuń puste
|
||||
<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>
|
||||
@@ -195,36 +194,65 @@
|
||||
<!-- 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">
|
||||
<div class="col-12">
|
||||
<!-- 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" required aria-describedby="ibanHelp"
|
||||
value="{% if is_edit and zbiorka.numer_konta %}{{ zbiorka.numer_konta }}{% elif global_settings %}{{ global_settings.numer_konta }}{% else %}{% endif %}">
|
||||
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>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<!-- 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" required aria-describedby="blikHelp"
|
||||
value="{% if is_edit and zbiorka.numer_telefonu_blik %}{{ zbiorka.numer_telefonu_blik }}{% elif global_settings %}{{ global_settings.numer_telefonu_blik }}{% else %}{% endif %}">
|
||||
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 border" id="ustaw-globalne"
|
||||
<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 %}>
|
||||
@@ -245,7 +273,7 @@
|
||||
<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 border d-none"></button>
|
||||
class="btn btn-sm btn-outline-light d-none"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -254,21 +282,55 @@
|
||||
<label for="cel" class="form-label">Cel zbiórki</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">PLN</span>
|
||||
<input type="number" class="form-control" id="cel" name="cel" step="0.01" min="0.01"
|
||||
<input type="text" inputmode="decimal" class="form-control" id="cel" name="cel"
|
||||
placeholder="0,00" required aria-describedby="celHelp"
|
||||
value="{{ zbiorka.cel if is_edit else '' }}">
|
||||
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żesz to później edytować.</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 is_edit and zbiorka.ukryj_kwote %}checked{% endif %}>
|
||||
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 -->
|
||||
@@ -276,7 +338,7 @@
|
||||
<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 border">Anuluj</a>
|
||||
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-outline-light">Anuluj</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -291,4 +353,6 @@
|
||||
<script src="{{ url_for('static', filename='js/formularz_zbiorek.js') }}?v={{ APP_VERSION }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/produkty_formularz.js') }}?v={{ APP_VERSION }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/kwoty_formularz.js') }}?v={{ APP_VERSION }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/przelaczniki_zabezpieczenie.js') }}?v={{ APP_VERSION }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/sposoby_wplat.js') }}?v={{ APP_VERSION }}"></script>
|
||||
{% endblock %}
|
@@ -6,13 +6,20 @@
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h3 class="mb-0">Transakcje: {{ zbiorka.nazwa }}</h3>
|
||||
<div class="btn-group">
|
||||
<a class="btn btn-sm btn-outline-light border" href="{{ url_for('dodaj_wplate', zbiorka_id=zbiorka.id) }}">+
|
||||
Wpłata</a>
|
||||
<a class="btn btn-sm btn-outline-light border"
|
||||
href="{{ url_for('dodaj_wydatek', zbiorka_id=zbiorka.id) }}">+ Wydatek</a>
|
||||
<a class="btn btn-sm btn-outline-light border"
|
||||
href="{{ url_for('zbiorka', zbiorka_id=zbiorka.id) }}">Szczegóły zbiórki</a>
|
||||
|
||||
<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) }}">
|
||||
<i class="fas fa-plus-circle"></i> 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('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>
|
||||
|
||||
@@ -24,6 +31,7 @@
|
||||
<tr>
|
||||
<th>Data</th>
|
||||
<th>Typ</th>
|
||||
<th>Widoczność</th>
|
||||
<th class="text-end">Kwota</th>
|
||||
<th>Opis</th>
|
||||
<th class="text-end"></th>
|
||||
@@ -31,39 +39,77 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for a in aktywnosci %}
|
||||
<tr>
|
||||
<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">
|
||||
{% if a.typ == 'wpłata' %}
|
||||
<button class="btn btn-sm btn-outline-light border 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>
|
||||
<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 border 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>
|
||||
<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 class="d-inline-flex flex-nowrap align-items-center gap-2">
|
||||
{% if a.typ == 'wpłata' %}
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{% 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-secondary">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-secondary">Ukryj</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% 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>
|
||||
<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>
|
||||
|
||||
{% 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-secondary">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-secondary">Ukryj</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
@@ -103,7 +149,7 @@
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-success">Zapisz</button>
|
||||
<button type="button" class="btn btn-outline-light border" data-bs-dismiss="modal">Anuluj</button>
|
||||
<button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">Anuluj</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -130,7 +176,7 @@
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-success">Zapisz</button>
|
||||
<button type="button" class="btn btn-outline-light border" data-bs-dismiss="modal">Anuluj</button>
|
||||
<button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">Anuluj</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
@@ -44,38 +44,55 @@
|
||||
<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">Dostęp — dozwolone adresy IP / hosty</h3>
|
||||
<small class="opacity-75">Zależnie od konfiguracji, logowanie może wymagać dopasowania do białej listy</small>
|
||||
<small class="opacity-75">Zależnie od konfiguracji logowanie może wymagać dopasowania do białej listy</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-md-6">
|
||||
<label for="host_input" class="form-label">Dodaj pojedynczy IP/host</label>
|
||||
<input type="text" class="form-control" id="host_input" placeholder="np. 203.0.113.42 lub corp.example.com"
|
||||
aria-describedby="hostAddHelp">
|
||||
<div id="hostAddHelp" class="form-text">Po wpisaniu kliknij „Dodaj do listy”. Duplikaty są pomijane.</div>
|
||||
<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-md-6 d-flex gap-2">
|
||||
<button type="button" class="btn btn-outline-light border" id="btn-add-host">Dodaj do listy</button>
|
||||
<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 border" id="btn-dedupe">Usuń duplikaty</button>
|
||||
|
||||
<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 }}">
|
||||
<i class="fas fa-location-arrow"></i> ➕ 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">
|
||||
<label for="allowed_login_hosts" class="form-label">Dozwolone hosty logowania (jeden na linię lub rozdzielone
|
||||
przecinkami)</label>
|
||||
<textarea class="form-control" id="allowed_login_hosts" name="allowed_login_hosts" rows="6"
|
||||
placeholder="Adresy IP lub nazwy domen — każdy w osobnej linii lub rozdzielony przecinkiem">{{ settings.allowed_login_hosts if settings and settings.allowed_login_hosts else '' }}</textarea>
|
||||
<div class="d-flex justify-content-between mt-1">
|
||||
<small class="text-muted">Akceptowane separatory: przecinek (`,`), średnik (`;`) i nowa linia.</small>
|
||||
<small class="text-muted">Pozycji na liście: <span id="hostsCount">0</span></small>
|
||||
<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: 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">
|
||||
@@ -99,9 +116,9 @@
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label for="site_title" class="form-label">Tytuł serwisu</label>
|
||||
<input type="text" class="form-control" id="site_title" name="site_title"
|
||||
value="{{ settings.site_title if settings else '' }}" placeholder="Np. Zbiórki unitraklub.pl">
|
||||
<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>
|
||||
|
||||
@@ -112,15 +129,15 @@
|
||||
<div class="col-md-6">
|
||||
<h6 class="mb-2">Menu (navbar)</h6>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="navbar_brand_mode" id="navbar_mode_logo" value="logo"
|
||||
{% if settings and settings.navbar_brand_mode=='logo' or (settings and settings.show_logo_in_navbar)
|
||||
%}checked{% endif %}>
|
||||
<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="navbar_brand_mode" id="navbar_mode_text" value="text"
|
||||
{% if not settings or (settings and settings.navbar_brand_mode !='logo' and not
|
||||
settings.show_logo_in_navbar) %}checked{% endif %}>
|
||||
<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 "Tytuł serwisu".</div>
|
||||
@@ -130,19 +147,19 @@
|
||||
<div class="col-md-6">
|
||||
<h6 class="mb-2">Stopka</h6>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="footer_brand_mode" id="footer_mode_logo" value="logo"
|
||||
{% if settings and settings.footer_brand_mode=='logo' %}checked{% endif %}>
|
||||
<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="footer_brand_mode" id="footer_mode_text" value="text"
|
||||
{% if not settings or (settings and settings.footer_brand_mode !='logo' ) %}checked{% endif %}>
|
||||
<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="footer_text" class="form-label mt-2">Tekst w stopce (gdy wybrano „Tekst”)</label>
|
||||
<input type="text" class="form-control" id="footer_text" name="footer_text"
|
||||
value="{{ settings.footer_text if settings and settings.footer_text else '' }}"
|
||||
<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>
|
||||
@@ -153,8 +170,8 @@
|
||||
|
||||
<!-- CTA -->
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-outline-light border">Powrót</a>
|
||||
<button type="submit" class="btn btn-primary">Zapisz ustawienia</button>
|
||||
<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>
|
||||
|
@@ -14,13 +14,13 @@
|
||||
<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.navbar_brand_mode if global_settings and
|
||||
global_settings.navbar_brand_mode else ('logo' if global_settings and
|
||||
global_settings.show_logo_in_navbar else 'text')) %}
|
||||
{% 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.site_title if global_settings and global_settings.site_title else "Zbiórki"
|
||||
<span>{{ global_settings.tytul_strony if global_settings and global_settings.tytul_strony else "Zbiórki"
|
||||
}}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
@@ -72,12 +72,12 @@
|
||||
|
||||
<!-- stopka -->
|
||||
<footer class="mt-auto text-center py-3 border-top" style="background: var(--surface-0);">
|
||||
{% set footer_mode = global_settings.footer_brand_mode if global_settings and global_settings.footer_brand_mode
|
||||
{% 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.footer_text if global_settings and global_settings.footer_text else "© " ~ (now().year if now
|
||||
{{ 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>
|
||||
|
@@ -24,7 +24,7 @@ zbiórki{% endif %}{% endblock %}
|
||||
</div>
|
||||
|
||||
{% if zbiorki and zbiorki|length > 0 %}
|
||||
<div class="row g-4">
|
||||
<div class="row g-4 pb-5">
|
||||
{% 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
|
||||
@@ -54,7 +54,7 @@ zbiórki{% endif %}{% endblock %}
|
||||
{% if z.cel > 0 %}
|
||||
{% set delta = z.cel - z.stan %}
|
||||
{% if delta > 0 %}
|
||||
{# CHANGE: mocniejszy badge „Brakuje” #}
|
||||
|
||||
<span class="badge bg-dark border border-warning">
|
||||
Brakuje: {{ delta|round(2) }} PLN
|
||||
</span>
|
||||
@@ -83,10 +83,14 @@ zbiórki{% endif %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<div class="mt-auto pt-2">
|
||||
<a href="{{ url_for('zbiorka', zbiorka_id=z.id) }}" class="stretched-link"></a>
|
||||
|
||||
{# TO POWODUJE ZE BLOK JEST KLIKALNY #}
|
||||
<!-- <a href="{{ url_for('zbiorka', zbiorka_id=z.id) }}" class="stretched-link"></a> -->
|
||||
|
||||
<div class="d-grid">
|
||||
<a href="{{ url_for('zbiorka', zbiorka_id=z.id) }}" class="btn btn-outline-light btn-sm w-100">
|
||||
Szczegóły
|
||||
<a href="{{ url_for('zbiorka', zbiorka_id=z.id) }}"
|
||||
class="btn btn-outline-light btn-sm w-100 btn-opis">
|
||||
Otwórz opis
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -105,7 +109,7 @@ zbiórki{% endif %}{% endblock %}
|
||||
{% 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.is_admin %}
|
||||
{% 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-primary">Zobacz zrealizowane</a>
|
||||
|
@@ -18,21 +18,21 @@
|
||||
{% endif %}
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Nazwa użytkownika</label>
|
||||
<input type="text" class="form-control" id="username" name="username"
|
||||
<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="password" class="form-label d-flex justify-content-between align-items-center">
|
||||
<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="password" name="password"
|
||||
<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>
|
||||
|
@@ -14,20 +14,20 @@
|
||||
<form method="post" class="needs-validation" novalidate>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Nazwa użytkownika</label>
|
||||
<input type="text" class="form-control" id="username" name="username"
|
||||
<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="password" class="form-label d-flex justify-content-between align-items-center">
|
||||
<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="password" name="password"
|
||||
<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>
|
||||
|
@@ -62,8 +62,8 @@
|
||||
{% 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 border ms-2">Sklep ↗</a>
|
||||
<a href="{{ it.link }}" target="_blank" rel="noopener" class="btn btn-sm btn-outline-light ms-2">Sklep
|
||||
↗</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
@@ -103,23 +103,28 @@
|
||||
<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 %}
|
||||
{% 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 %}
|
||||
|
||||
{% if has_items and zbiorka.pokaz_postep_pozycje %}
|
||||
<span class="badge bg-secondary">Pozycje: {{ kupione_cnt }}/{{ total_cnt }}</span>
|
||||
{% if not zbiorka.ukryj_kwote and (suma_all or 0) > 0 %}
|
||||
<span class="badge bg-secondary">Zakupy (kwotowo):
|
||||
{{ (suma_kupione or 0)|round(2) }} / {{ (suma_all or 0)|round(2) }} PLN
|
||||
</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">
|
||||
<!-- Pasek: Finanse (zawsze) -->
|
||||
|
||||
{# Pasek: Finanse #}
|
||||
{% if zbiorka.pokaz_postep_finanse %}
|
||||
<div class="mb-3">
|
||||
<small class="text-muted">Finanse</small>
|
||||
<div class="progress" role="progressbar" aria-valuenow="{{ progress_clamped|round(2) }}" aria-valuemin="0"
|
||||
@@ -130,9 +135,10 @@
|
||||
{% if zbiorka.ukryj_kwote %}—{% else %}{{ progress|round(1) }}%{% endif %}
|
||||
</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if has_items %}
|
||||
<!-- Pasek: Zakupy sztukami -->
|
||||
{# Pasek: Zakupy sztukami #}
|
||||
{% if has_items and zbiorka.pokaz_postep_pozycje %}
|
||||
<div class="mb-3">
|
||||
<small class="text-muted">Zakupy (liczba pozycji)</small>
|
||||
<div class="progress" role="progressbar" aria-valuenow="{{ items_pct|round(2) }}" aria-valuemin="0"
|
||||
@@ -141,9 +147,10 @@
|
||||
</div>
|
||||
<small class="text-muted">{{ items_pct|round(1) }}%</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not zbiorka.ukryj_kwote and (suma_all or 0) > 0 %}
|
||||
<!-- Pasek: Zakupy kwotowo -->
|
||||
{# 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"
|
||||
@@ -153,12 +160,14 @@
|
||||
<small class="text-muted">{{ suma_pct|round(1) }}%</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</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);">
|
||||
@@ -169,17 +178,15 @@
|
||||
{% if has_cel and not zbiorka.ukryj_kwote %}
|
||||
{% 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>
|
||||
<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>
|
||||
<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>
|
||||
@@ -188,11 +195,13 @@
|
||||
class="form-control form-control-sm 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-sm btn-outline-light border copy-btn" type="button" data-copy-input="#ibanInput"
|
||||
<button class="btn btn-sm 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>
|
||||
@@ -201,10 +210,16 @@
|
||||
class="form-control form-control-sm 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-sm btn-outline-light border copy-btn" type="button" data-copy-input="#blikInput"
|
||||
<button class="btn btn-sm 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">
|
||||
@@ -237,8 +252,7 @@
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if current_user.is_authenticated and current_user.is_admin %}
|
||||
{% if current_user.is_authenticated and current_user.czy_admin %}
|
||||
<hr>
|
||||
<div class="d-grid gap-2 mt-2">
|
||||
<a href="{{ url_for('dodaj_wplate', zbiorka_id=zbiorka.id) }}" class="btn btn-outline-light btn-sm">Dodaj
|
||||
@@ -264,9 +278,8 @@
|
||||
{% if aktywnosci and aktywnosci|length > 0 %}
|
||||
<small class="text-muted">Łącznie pozycji: {{ aktywnosci|length }}</small>
|
||||
{% endif %}
|
||||
{% if current_user.is_authenticated and current_user.is_admin %}
|
||||
<a href="{{ url_for('transakcje_zbiorki', zbiorka_id=zbiorka.id) }}"
|
||||
class="btn btn-sm btn-outline-light border">
|
||||
{% 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 %}
|
||||
@@ -308,7 +321,7 @@
|
||||
<!-- 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 border">Powrót do listy</a>
|
||||
<a href="{{ url_for('index') }}" class="btn btn-outline-light">Powrót do listy</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -318,5 +331,4 @@
|
||||
{{ super() }}
|
||||
<script src="{{ url_for('static', filename='js/zbiorka.js') }}?v={{ APP_VERSION }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/progress.js') }}?v={{ APP_VERSION }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/progress.js') }}?v={{ APP_VERSION }}"></script>
|
||||
{% endblock %}
|
Reference in New Issue
Block a user