refactor next push

This commit is contained in:
Mateusz Gruszczyński
2026-03-14 23:17:05 +01:00
parent a16798553e
commit 3a57f2f1d7
37 changed files with 4012 additions and 658 deletions

33
API_OPIS.txt Normal file
View File

@@ -0,0 +1,33 @@
API aplikacji Lista Zakupów
Autoryzacja:
- Authorization: Bearer TWOJ_TOKEN
- albo X-API-Token: TWOJ_TOKEN
Token ma jednocześnie dwa ograniczenia:
1. zakresy (scopes), np. expenses:read, lists:read, templates:read
2. dozwolone endpointy
Dostępne endpointy:
- GET /api/ping
Test poprawności tokenu.
- GET /api/expenses/latest?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD&list_id=ID&owner_id=ID&limit=50
Zwraca ostatnie wydatki wraz z metadanymi listy i właściciela.
- GET /api/expenses/summary?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD&list_id=ID&owner_id=ID
Zwraca sumę wydatków, liczbę rekordów i agregację po listach.
- GET /api/lists?owner_id=ID&limit=50
Zwraca listy z podstawowymi metadanymi.
- GET /api/lists/<id>/expenses?limit=50
Zwraca wydatki przypisane do konkretnej listy.
- GET /api/templates?owner_id=ID
Zwraca aktywne szablony.
Uwagi:
- limit odpowiedzi jest przycinany do max_limit ustawionego na tokenie
- daty przekazuj w formacie YYYY-MM-DD
- endpoint musi być zaznaczony na tokenie, samo posiadanie zakresu nie wystarczy

30
CLI_OPIS.txt Normal file
View File

@@ -0,0 +1,30 @@
Komendy CLI
===========
Admini
-------
flask admins list
flask admins create <username> <password> [--admin/--user]
flask admins promote <username|id>
flask admins demote <username|id>
flask admins set-password <username|id> <password>
Listy
-----
flask lists copy-schedule --source-list-id 12 --when "2026-03-20 18:30"
flask lists copy-schedule --source-list-id 12 --when "2026-03-20 18:30" --owner admin
flask lists copy-schedule --source-list-id 12 --when "2026-03-20 18:30" --title "Zakupy piatkowe"
Zasady dzialania
----------------
- copy-schedule tworzy nowa liste na podstawie istniejacej
- kopiuje pozycje i przypisane kategorie
- ustawia nowy created_at na wartosc z parametru --when
- gdy lista byla tymczasowa i miala expires_at, termin wygasniecia jest przesuwany o ten sam odstep czasu
- wydatki i paragony nie sa kopiowane
SZABLONY I HISTORIA:
- Historia zmian listy jest widoczna w widoku listy właściciela.
- Szablon można utworzyć z panelu admina lub z poziomu listy właściciela.
- Admin może szybko utworzyć listę z szablonu i zduplikować listę jednym kliknięciem.

View File

@@ -10,6 +10,8 @@ Prosta aplikacja webowa do zarządzania listami zakupów z obsługą użytkownik
- Archiwizacja i udostępnianie list (publiczne/prywatne)
- Statystyki wydatków z podziałem na okresy, statystyki dla użytkowników
- Panel administracyjny (statystyki, produkty, paragony, zarządzanie, użytkowmicy)
- Tokeny API administratora i endpoint do pobierania ostatnich wydatków
- Ujednolicony UI formularzy, tabel i przycisków oraz drobne usprawnienia UX
## Wymagania
@@ -85,4 +87,8 @@ DB_PORT=5432
DB_NAME=myapp
DB_USER=user
DB_PASSWORD=pass
```
```
## CLI
Opis komend administracyjnych znajduje sie w pliku `CLI_OPIS.txt`.

View File

@@ -1,30 +0,0 @@
# Refactor / UX refresh
## Co zostało zrobione
### Backend Python
- `app.py` został sprowadzony do lekkiego entrypointu.
- Backend został rozbity na moduły w katalogu `shopping_app/`:
- `app_setup.py` — inicjalizacja Flask / SQLAlchemy / SocketIO / Session / config
- `models.py` — modele bazy danych
- `helpers.py` — funkcje pomocnicze, uploady, OCR, uprawnienia, filtry pomocnicze
- `web.py` — context processory, filtry, błędy, favicon, hooki
- `routes_main.py` — główne trasy użytkownika
- `routes_secondary.py` — wydatki, udostępnianie, paragony usera
- `routes_admin.py` — panel admina i trasy administracyjne
- `sockets.py` — Socket.IO i debug socketów
- `deps.py` — wspólne importy
- Endpointy i nazwy widoków zostały zachowane.
- Docker / compose / deploy / varnish nie były ruszane.
### Frontend / UX / wygląd
- Przebudowany globalny shell aplikacji w `templates/base.html`.
- Odświeżony, spójny dark UI z mocniejszym mobile-first feel.
- Zachowane istniejące pliki JS i ich selektory.
- Główne zmiany wizualne są w `static/css/style.css` jako nowa warstwa override na końcu pliku.
- Drobnie dopracowane teksty i nagłówki w kluczowych widokach.
## Ważne
- Rozbicie backendu było celowo wykonane bez zmiany zachowania logiki biznesowej.
- Statyczne assety, Socket.IO i routing powinny działać po staremu, ale kod jest łatwiejszy do dalszej pracy.
- Przy lokalnym starcie bez Dockera pamiętaj o istnieniu katalogów `db/` i `uploads/`.

View File

@@ -67,6 +67,12 @@ app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(minutes=SESSION_TIMEOUT_MIN
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
DEBUG_MODE = app.config.get("DEBUG_MODE", False)
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
db_uri = app.config.get("SQLALCHEMY_DATABASE_URI", "")
if db_uri.startswith("sqlite:///"):
sqlite_path = db_uri.replace("sqlite:///", "", 1)
sqlite_dir = os.path.dirname(sqlite_path)
if sqlite_dir:
os.makedirs(sqlite_dir, exist_ok=True)
failed_login_attempts = defaultdict(deque)
MAX_ATTEMPTS = 10
TIME_WINDOW = 60 * 60

View File

@@ -128,6 +128,383 @@ def check_password(stored_hash, password_input):
return False
def resolve_user_identifier(identifier):
if identifier is None:
return None
raw = str(identifier).strip()
if not raw:
return None
if raw.isdigit():
return db.session.get(User, int(raw))
return User.query.filter(func.lower(User.username) == raw.lower()).first()
def create_or_update_admin_user(username: str, password: str | None = None, make_admin: bool = True, update_password: bool = False):
normalized = (username or '').strip().lower()
if not normalized:
raise ValueError('Username nie moze byc pusty.')
user = User.query.filter(func.lower(User.username) == normalized).first()
created = False
password_changed = False
if user is None:
if not password:
raise ValueError('Haslo jest wymagane przy tworzeniu nowego uzytkownika.')
user = User(
username=normalized,
password_hash=hash_password(password),
is_admin=bool(make_admin),
)
db.session.add(user)
created = True
else:
user.username = normalized
if make_admin and not user.is_admin:
user.is_admin = True
elif not make_admin and user.is_admin:
user.is_admin = False
if password and update_password:
user.password_hash = hash_password(password)
password_changed = True
db.session.commit()
return user, created, password_changed
def parse_cli_datetime(value: str) -> datetime:
raw = (value or '').strip()
if not raw:
raise ValueError('Podaj date i godzine.')
normalized = raw.replace('T', ' ')
for fmt in ('%Y-%m-%d %H:%M', '%Y-%m-%d %H:%M:%S', '%Y-%m-%d'):
try:
parsed = datetime.strptime(normalized, fmt)
if fmt == '%Y-%m-%d':
parsed = parsed.replace(hour=8, minute=0, second=0)
return parsed.replace(tzinfo=timezone.utc)
except ValueError:
continue
raise ValueError('Niepoprawny format daty. Uzyj YYYY-MM-DD lub YYYY-MM-DD HH:MM.')
def duplicate_list_for_schedule(source_list: ShoppingList, scheduled_for: datetime, owner: User | None = None, title: str | None = None):
if source_list is None:
raise ValueError('Lista zrodlowa nie istnieje.')
if scheduled_for.tzinfo is None:
scheduled_for = scheduled_for.replace(tzinfo=timezone.utc)
owner_id = owner.id if owner else source_list.owner_id
base_title = (title or source_list.title or 'Lista').strip()
new_list = ShoppingList(
title=base_title,
owner_id=owner_id,
is_temporary=bool(source_list.is_temporary),
share_token=generate_share_token(8),
created_at=scheduled_for,
is_archived=bool(source_list.is_archived),
is_public=bool(source_list.is_public),
)
if source_list.expires_at:
original_created = source_list.created_at or scheduled_for
if original_created.tzinfo is None:
original_created = original_created.replace(tzinfo=timezone.utc)
expires_at = source_list.expires_at
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
delta = expires_at - original_created
if delta.total_seconds() > 0:
new_list.expires_at = scheduled_for + delta
db.session.add(new_list)
db.session.flush()
for item in source_list.items:
db.session.add(
Item(
list_id=new_list.id,
name=item.name,
quantity=item.quantity or 1,
note=item.note,
position=item.position or 0,
added_at=scheduled_for,
added_by=owner_id,
)
)
for category in source_list.categories:
new_list.categories.append(category)
db.session.commit()
return new_list
def hash_api_token(token: str) -> str:
return hashlib.sha256((token or '').encode('utf-8')).hexdigest()
def generate_api_token_value() -> str:
return f"sz_{secrets.token_urlsafe(24)}"
def mask_token_prefix(token_value: str, visible: int = 12) -> str:
return (token_value or '')[:visible]
def create_api_token_record(name: str, created_by: int | None = None, scopes: str = 'expenses:read', allowed_endpoints: str = '/api/expenses/latest,/api/expenses/summary,/api/lists,/api/lists/<id>/expenses,/api/templates,/api/ping', max_limit: int = 100):
token_value = generate_api_token_value()
record = ApiToken(
name=name.strip(),
token_hash=hash_api_token(token_value),
token_prefix=mask_token_prefix(token_value),
created_by=created_by,
scopes=scopes or 'expenses:read',
allowed_endpoints=allowed_endpoints or '/api/expenses/latest,/api/expenses/summary,/api/lists,/api/lists/<id>/expenses,/api/templates,/api/ping',
max_limit=max(1, min(int(max_limit or 100), 500)),
)
db.session.add(record)
db.session.commit()
return record, token_value
def extract_api_token_from_request() -> str | None:
auth_header = (request.headers.get('Authorization') or '').strip()
if auth_header.lower().startswith('bearer '):
token_value = auth_header[7:].strip()
if token_value:
return token_value
header_token = (request.headers.get('X-API-Token') or '').strip()
if header_token:
return header_token
query_token = (request.args.get('api_token') or '').strip()
if query_token:
return query_token
return None
def authenticate_api_token(raw_token: str | None = None, touch: bool = True) -> ApiToken | None:
token_value = (raw_token or extract_api_token_from_request() or '').strip()
if not token_value:
return None
token_hash = hash_api_token(token_value)
token_record = ApiToken.query.filter_by(token_hash=token_hash, is_active=True).first()
if token_record and touch:
token_record.last_used_at = utcnow()
db.session.commit()
return token_record
def api_token_required(view_func):
@wraps(view_func)
def wrapped(*args, **kwargs):
token_record = authenticate_api_token()
if not token_record:
return (
jsonify(
{
'ok': False,
'error': 'unauthorized',
'message': 'Brak poprawnego tokenu API. Użyj nagłówka Authorization: Bearer <token> albo X-API-Token.',
}
),
401,
)
g.api_token = token_record
return view_func(*args, **kwargs)
return wrapped
def api_token_has_scope(token_record: ApiToken | None, required_scope: str) -> bool:
if not token_record or not required_scope:
return False
scopes = {s.strip() for s in (token_record.scopes or '').split(',') if s.strip()}
return required_scope in scopes or '*' in scopes
def api_token_allows_endpoint(token_record: ApiToken | None, endpoint_path: str) -> bool:
if not token_record:
return False
allowed = {s.strip() for s in (token_record.allowed_endpoints or '').split(',') if s.strip()}
if not allowed:
return False
if '*' in allowed or endpoint_path in allowed:
return True
for pattern in allowed:
if '<id>' in pattern:
regex = '^' + re.escape(pattern).replace(re.escape('<id>'), r'\d+') + '$'
if re.match(regex, endpoint_path):
return True
return False
def require_api_scope(required_scope: str):
def decorator(view_func):
@wraps(view_func)
def wrapped(*args, **kwargs):
token_record = getattr(g, 'api_token', None)
if not token_record:
return jsonify({'ok': False, 'error': 'unauthorized'}), 401
if not api_token_has_scope(token_record, required_scope):
return jsonify({'ok': False, 'error': 'forbidden', 'message': 'Token nie ma wymaganego zakresu.'}), 403
if not api_token_allows_endpoint(token_record, request.path):
return jsonify({'ok': False, 'error': 'forbidden', 'message': 'Token nie ma dostepu do tego endpointu.'}), 403
return view_func(*args, **kwargs)
return wrapped
return decorator
def log_list_activity(list_id: int, action: str, item_name: str | None = None, actor: User | None = None, actor_name: str | None = None, details: str | None = None):
resolved_name = actor_name or (actor.username if actor else None) or 'Gość'
db.session.add(ListActivityLog(
list_id=list_id,
actor_id=actor.id if actor else None,
actor_name=resolved_name,
action=action,
item_name=item_name,
details=details,
))
def action_label(action: str) -> str:
return {
'item_added': 'dodał produkt',
'item_deleted': 'usunął produkt',
'item_checked': 'oznaczył jako kupione',
'item_unchecked': 'odznaczył produkt',
'item_marked_not_purchased': 'oznaczył jako niekupione',
'item_unmarked_not_purchased': 'przywrócił produkt',
'expense_added': 'dodał wydatek',
'list_duplicated': 'zduplikował listę',
'template_created': 'utworzył szablon',
}.get(action, action)
def get_expiring_lists_for_user(user_id: int, within_hours: int = 24):
now_dt = datetime.now(timezone.utc)
until_dt = now_dt + timedelta(hours=within_hours)
return (
ShoppingList.query.filter(
ShoppingList.owner_id == user_id,
ShoppingList.is_temporary == True,
ShoppingList.is_archived == False,
ShoppingList.expires_at.isnot(None),
ShoppingList.expires_at > now_dt,
ShoppingList.expires_at <= until_dt,
)
.order_by(ShoppingList.expires_at.asc())
.all()
)
def get_admin_expiring_lists(within_hours: int = 24):
now_dt = datetime.now(timezone.utc)
until_dt = now_dt + timedelta(hours=within_hours)
return (
ShoppingList.query.options(joinedload(ShoppingList.owner))
.filter(
ShoppingList.is_temporary == True,
ShoppingList.is_archived == False,
ShoppingList.expires_at.isnot(None),
ShoppingList.expires_at > now_dt,
ShoppingList.expires_at <= until_dt,
)
.order_by(ShoppingList.expires_at.asc())
.all()
)
def create_template_from_list(source_list: ShoppingList, created_by: int | None = None, name: str | None = None, description: str | None = None):
template = ListTemplate(
name=(name or source_list.title).strip(),
description=(description or f'Szablon utworzony z listy #{source_list.id}').strip(),
created_by=created_by,
)
db.session.add(template)
db.session.flush()
for idx, item in enumerate(sorted(source_list.items, key=lambda x: (x.position or 0, x.id))):
db.session.add(ListTemplateItem(
template_id=template.id,
name=item.name,
quantity=item.quantity or 1,
note=item.note,
position=idx + 1,
))
db.session.commit()
return template
def template_is_accessible_to_user(template: ListTemplate, user: User | None) -> bool:
if not template or not template.is_active or not user:
return False
if user.is_admin:
return True
return bool(template.created_by == user.id)
def create_list_from_template(template: ListTemplate, owner: User, title: str | None = None):
token = generate_share_token(8)
new_list = ShoppingList(
title=(title or template.name).strip(),
owner_id=owner.id,
share_token=token,
is_temporary=False,
expires_at=None,
)
db.session.add(new_list)
db.session.flush()
for idx, item in enumerate(template.items):
db.session.add(Item(
list_id=new_list.id,
name=item.name,
quantity=item.quantity or 1,
note=item.note,
position=idx + 1,
added_by=owner.id,
))
db.session.commit()
return new_list
def format_dt_for_api(dt: datetime | None) -> str | None:
if not dt:
return None
if dt.tzinfo is None:
return dt.isoformat() + 'Z'
return dt.astimezone(timezone.utc).isoformat().replace('+00:00', 'Z')
def parse_api_date_range(start_date_str: str | None, end_date_str: str | None):
start_date = None
end_date = None
if start_date_str:
start_date = datetime.strptime(start_date_str, '%Y-%m-%d')
if end_date_str:
end_date = datetime.strptime(end_date_str, '%Y-%m-%d') + timedelta(days=1)
if start_date and end_date and start_date >= end_date:
raise ValueError('Data początkowa musi być wcześniejsza niż końcowa.')
if not start_date and not end_date:
end_date = datetime.utcnow() + timedelta(days=1)
start_date = end_date - timedelta(days=30)
return start_date, end_date
def set_authorized_cookie(response):
secure_flag = app.config["SESSION_COOKIE_SECURE"]
max_age = app.config.get("AUTH_COOKIE_MAX_AGE", 86400)

View File

@@ -145,6 +145,67 @@ class AppSetting(db.Model):
value = db.Column(db.Text, nullable=True)
class ApiToken(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(120), nullable=False)
token_hash = db.Column(db.String(64), unique=True, nullable=False, index=True)
token_prefix = db.Column(db.String(18), nullable=False)
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
last_used_at = db.Column(db.DateTime, nullable=True)
is_active = db.Column(db.Boolean, default=True, nullable=False)
created_by = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
scopes = db.Column(db.String(255), nullable=False, default="expenses:read")
allowed_endpoints = db.Column(db.String(255), nullable=False, default="/api/expenses/latest")
max_limit = db.Column(db.Integer, nullable=False, default=100)
creator = db.relationship(
"User", backref="created_api_tokens", lazy="joined", foreign_keys=[created_by]
)
class ListTemplate(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(150), nullable=False)
description = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
created_by = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
is_active = db.Column(db.Boolean, default=True, nullable=False)
creator = db.relationship("User", backref="list_templates", lazy="joined")
items = db.relationship(
"ListTemplateItem",
back_populates="template",
cascade="all, delete-orphan",
lazy="select",
order_by="ListTemplateItem.position.asc()",
)
class ListTemplateItem(db.Model):
id = db.Column(db.Integer, primary_key=True)
template_id = db.Column(db.Integer, db.ForeignKey("list_template.id", ondelete="CASCADE"), nullable=False)
name = db.Column(db.String(150), nullable=False)
quantity = db.Column(db.Integer, default=1)
note = db.Column(db.Text, nullable=True)
position = db.Column(db.Integer, default=0)
template = db.relationship("ListTemplate", back_populates="items")
class ListActivityLog(db.Model):
id = db.Column(db.Integer, primary_key=True)
list_id = db.Column(db.Integer, db.ForeignKey("shopping_list.id", ondelete="CASCADE"), nullable=False, index=True)
actor_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
actor_name = db.Column(db.String(150), nullable=False, default="System")
action = db.Column(db.String(64), nullable=False)
item_name = db.Column(db.String(150), nullable=True)
details = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=utcnow, nullable=False, index=True)
shopping_list = db.relationship("ShoppingList", backref=db.backref("activity_logs", lazy="dynamic", cascade="all, delete-orphan"))
actor = db.relationship("User", backref="list_activity_logs", lazy="joined")
class CategoryColorOverride(db.Model):
id = db.Column(db.Integer, primary_key=True)
category_id = db.Column(

View File

@@ -173,6 +173,7 @@ def admin_panel():
)
expense_summary = get_admin_expense_summary()
expiring_lists = get_admin_expiring_lists()
process = psutil.Process(os.getpid())
app_mem = process.memory_info().rss // (1024 * 1024)
@@ -1033,6 +1034,18 @@ def add_suggestion():
return redirect(url_for("list_products"))
@app.route("/admin/user-suggestions", methods=["GET"])
@login_required
@admin_required
def admin_user_suggestions():
q = (request.args.get("q") or "").strip().lower().lstrip('@')
query = User.query.order_by(func.lower(User.username).asc())
if q:
query = query.filter(func.lower(User.username).like(f"{q}%"))
rows = query.limit(20).all()
return jsonify({"users": [u.username for u in rows]})
@app.route("/admin/lists-access", methods=["GET", "POST"])
@app.route("/admin/lists-access/<int:list_id>", methods=["GET", "POST"])
@login_required
@@ -1065,21 +1078,32 @@ def admin_lists_access(list_id=None):
lists = pagination.items
list_ids = [l.id for l in lists]
wants_json = (
"application/json" in (request.headers.get("Accept") or "")
or request.headers.get("X-Requested-With") == "fetch"
)
if request.method == "POST":
action = request.form.get("action")
target_list_id = request.form.get("target_list_id", type=int)
target_list_id = request.form.get("target_list_id", type=int) or list_id
if action == "grant" and target_list_id:
login = (request.form.get("grant_username") or "").strip().lower()
login = (request.form.get("grant_username") or "").strip().lower().lstrip('@')
l = db.session.get(ShoppingList, target_list_id)
if not l:
if wants_json:
return jsonify(ok=False, error="list_not_found"), 404
flash("Lista nie istnieje.", "danger")
return redirect(request.url)
u = User.query.filter(func.lower(User.username) == login).first()
if not u:
if wants_json:
return jsonify(ok=False, error="user_not_found"), 404
flash("Użytkownik nie istnieje.", "danger")
return redirect(request.url)
if u.id == l.owner_id:
if wants_json:
return jsonify(ok=False, error="owner"), 409
flash("Nie można nadawać uprawnień właścicielowi listy.", "danger")
return redirect(request.url)
@@ -1088,36 +1112,29 @@ def admin_lists_access(list_id=None):
.filter(ListPermission.list_id == l.id, ListPermission.user_id == u.id)
.first()
)
if not exists:
db.session.add(ListPermission(list_id=l.id, user_id=u.id))
db.session.commit()
flash(f"Nadano dostęp „{u.username}” do listy #{l.id}.", "success")
else:
if exists:
if wants_json:
return jsonify(ok=False, error="exists"), 409
flash("Ten użytkownik już ma dostęp.", "info")
return redirect(request.url)
db.session.add(ListPermission(list_id=l.id, user_id=u.id))
db.session.commit()
if wants_json:
return jsonify(ok=True, user={"id": u.id, "username": u.username})
flash(f"Nadano dostęp „{u.username}” do listy #{l.id}.", "success")
return redirect(request.url)
if action == "revoke" and target_list_id:
uid = request.form.get("revoke_user_id", type=int)
if uid:
ListPermission.query.filter_by(
list_id=target_list_id, user_id=uid
).delete()
ListPermission.query.filter_by(list_id=target_list_id, user_id=uid).delete()
db.session.commit()
if wants_json:
return jsonify(ok=True, removed_user_id=uid)
flash("Odebrano dostęp użytkownikowi.", "success")
return redirect(request.url)
if action == "save_changes":
ids = request.form.getlist("visible_ids", type=int)
if ids:
lists_edit = ShoppingList.query.filter(ShoppingList.id.in_(ids)).all()
posted = request.form
for l in lists_edit:
l.is_public = posted.get(f"is_public_{l.id}") is not None
l.is_temporary = posted.get(f"is_temporary_{l.id}") is not None
l.is_archived = posted.get(f"is_archived_{l.id}") is not None
db.session.commit()
flash("Zapisano zmiany statusów.", "success")
return redirect(request.url)
perms = (
db.session.query(
@@ -1135,6 +1152,7 @@ def admin_lists_access(list_id=None):
for lid, uid, uname in perms:
permitted_by_list[lid].append({"id": uid, "username": uname})
all_usernames = [u.username for u in User.query.order_by(func.lower(User.username).asc()).limit(300).all()]
query_string = f"per_page={per_page}"
return render_template(
@@ -1146,6 +1164,7 @@ def admin_lists_access(list_id=None):
total_pages=pagination.pages if pagination else 1,
query_string=query_string,
list_id=list_id,
all_usernames=all_usernames,
)
@@ -1170,6 +1189,100 @@ def healthcheck():
return response_data, 200
@app.route("/admin/api-tokens", methods=["GET", "POST"])
@login_required
@admin_required
def admin_api_tokens():
if request.method == "POST":
action = (request.form.get("action") or "create").strip()
if action == "create":
name = (request.form.get("name") or "").strip()
if not name:
flash("Podaj nazwę tokenu API.", "danger")
return redirect(url_for("admin_api_tokens"))
scopes = []
if request.form.get('scope_expenses_read'):
scopes.append('expenses:read')
if request.form.get('scope_lists_read'):
scopes.append('lists:read')
if request.form.get('scope_templates_read'):
scopes.append('templates:read')
scopes = ','.join(scopes)
allowed = []
if request.form.get('allow_ping'):
allowed.append('/api/ping')
if request.form.get('allow_latest_expenses'):
allowed.append('/api/expenses/latest')
if request.form.get('allow_expenses_summary'):
allowed.append('/api/expenses/summary')
if request.form.get('allow_lists'):
allowed.extend(['/api/lists', '/api/lists/<id>/expenses'])
if request.form.get('allow_templates'):
allowed.append('/api/templates')
allowed_endpoints = ','.join(dict.fromkeys(allowed))
max_limit = request.form.get('max_limit', type=int) or 100
_, plain_token = create_api_token_record(name=name, created_by=current_user.id, scopes=scopes, allowed_endpoints=allowed_endpoints, max_limit=max_limit)
session["latest_api_token_plain"] = plain_token
session["latest_api_token_name"] = name
flash("Wygenerowano nowy token API. Skopiuj go teraz — później nie będzie widoczny w całości.", "success")
return redirect(url_for("admin_api_tokens"))
token_id = request.form.get("token_id", type=int)
token_row = ApiToken.query.get_or_404(token_id)
if action == "deactivate":
token_row.is_active = False
db.session.commit()
flash(f"Token „{token_row.name}” został wyłączony.", "warning")
elif action == "activate":
token_row.is_active = True
db.session.commit()
flash(f"Token „{token_row.name}” został ponownie aktywowany.", "success")
elif action == "delete":
db.session.delete(token_row)
db.session.commit()
flash(f"Token „{token_row.name}” został usunięty.", "info")
else:
flash("Nieznana akcja dla tokenu API.", "danger")
return redirect(url_for("admin_api_tokens"))
latest_plain_token = session.pop("latest_api_token_plain", None)
latest_api_token_name = session.pop("latest_api_token_name", None)
api_tokens = ApiToken.query.options(joinedload(ApiToken.creator)).order_by(ApiToken.created_at.desc(), ApiToken.id.desc()).all()
api_examples = [
{'method': 'GET', 'path': '/api/ping', 'scope': 'dowolny aktywny token', 'description': 'szybki test poprawności tokenu'},
{'method': 'GET', 'path': '/api/expenses/latest', 'scope': 'expenses:read', 'description': 'ostatnie wydatki z filtrem po datach, liście i właścicielu'},
{'method': 'GET', 'path': '/api/expenses/summary', 'scope': 'expenses:read', 'description': 'sumy wydatków i liczba rekordów dla zakresu'},
{'method': 'GET', 'path': '/api/lists', 'scope': 'lists:read', 'description': 'lista list z podstawowymi metadanymi'},
{'method': 'GET', 'path': '/api/lists/<id>/expenses', 'scope': 'lists:read', 'description': 'wydatki dla konkretnej listy'},
{'method': 'GET', 'path': '/api/templates', 'scope': 'templates:read', 'description': 'szablony przypisane do użytkownika tokenu lub wszystkie dla admina'},
]
return render_template(
"admin/api_tokens.html",
api_tokens=api_tokens,
latest_plain_token=latest_plain_token,
latest_api_token_name=latest_api_token_name,
api_examples=api_examples,
)
@app.route("/admin/api-docs.txt")
@login_required
@admin_required
def admin_api_docs():
return send_from_directory(
os.path.dirname(app.root_path),
"API_OPIS.txt",
mimetype="text/plain; charset=utf-8",
as_attachment=False,
)
@app.route("/admin/settings", methods=["GET", "POST"])
@login_required
@admin_required
@@ -1245,3 +1358,79 @@ def robots_txt():
else "User-agent: *\nAllow: /"
)
return content, 200, {"Content-Type": "text/plain"}
@app.route('/admin/list/<int:list_id>/duplicate', methods=['POST'])
@login_required
@admin_required
def admin_duplicate_list(list_id):
source_list = ShoppingList.query.options(joinedload(ShoppingList.items), joinedload(ShoppingList.categories)).get_or_404(list_id)
owner = source_list.owner or current_user
new_list = duplicate_list_for_schedule(source_list, scheduled_for=datetime.now(timezone.utc), owner=owner, title=f'{source_list.title} (Kopia)')
log_list_activity(new_list.id, 'list_duplicated', actor=current_user, details=f'Źródło #{source_list.id}')
db.session.commit()
flash(f'Zduplikowano listę #{source_list.id} do nowej listy #{new_list.id}.', 'success')
return redirect(url_for('admin_panel'))
@app.route('/admin/templates', methods=['GET', 'POST'])
@login_required
@admin_required
def admin_templates():
if request.method == 'POST':
action = (request.form.get('action') or 'create_manual').strip()
if action == 'create_manual':
name = (request.form.get('name') or '').strip()
description = (request.form.get('description') or '').strip()
raw_items = (request.form.get('items_text') or '').splitlines()
if not name:
flash('Podaj nazwę szablonu.', 'danger')
return redirect(url_for('admin_templates'))
template = ListTemplate(name=name, description=description, created_by=current_user.id, is_active=True)
db.session.add(template)
db.session.flush()
pos = 1
for line in raw_items:
line = line.strip()
if not line:
continue
qty = 1
item_name = line
match = re.match(r'^(.*?)(?:\s+[xX](\d+))?$', line)
if match:
item_name = (match.group(1) or '').strip() or line
if match.group(2):
qty = max(1, int(match.group(2)))
db.session.add(ListTemplateItem(template_id=template.id, name=item_name, quantity=qty, position=pos))
pos += 1
db.session.commit()
flash(f'Utworzono szablon „{template.name}”.', 'success')
return redirect(url_for('admin_templates'))
if action == 'create_from_list':
list_id = request.form.get('source_list_id', type=int)
source_list = ShoppingList.query.options(joinedload(ShoppingList.items)).get_or_404(list_id)
template = create_template_from_list(source_list, created_by=current_user.id, name=(request.form.get('template_name') or '').strip() or None, description=(request.form.get('description') or '').strip() or None)
flash(f'Utworzono szablon z listy „{source_list.title}”.', 'success')
return redirect(url_for('admin_templates'))
if action in {'toggle', 'delete', 'instantiate'}:
template = ListTemplate.query.options(joinedload(ListTemplate.items)).get_or_404(request.form.get('template_id', type=int))
if action == 'toggle':
template.is_active = not template.is_active
db.session.commit()
flash(f'Zmieniono status szablonu „{template.name}”.', 'info')
elif action == 'delete':
db.session.delete(template)
db.session.commit()
flash(f'Usunięto szablon „{template.name}”.', 'warning')
elif action == 'instantiate':
owner = User.query.get(request.form.get('owner_id', type=int) or current_user.id) or current_user
new_list = create_list_from_template(template, owner=owner, title=(request.form.get('title') or '').strip() or None)
log_list_activity(new_list.id, 'template_created', actor=current_user, details=f'Admin utworzył z szablonu: {template.name}')
db.session.commit()
flash(f'Utworzono listę #{new_list.id} z szablonu.', 'success')
return redirect(url_for('admin_templates'))
templates = ListTemplate.query.options(joinedload(ListTemplate.creator), joinedload(ListTemplate.items)).order_by(ListTemplate.created_at.desc(), ListTemplate.id.desc()).all()
source_lists = ShoppingList.query.order_by(ShoppingList.created_at.desc()).limit(100).all()
users = User.query.order_by(User.username.asc()).all()
return render_template('admin/templates.html', templates=templates, source_lists=source_lists, users=users)

View File

@@ -163,6 +163,9 @@ def main_page():
l.total_expense = 0
l.category_badges = []
expiring_lists = get_expiring_lists_for_user(current_user.id) if current_user.is_authenticated else []
templates = (ListTemplate.query.filter_by(is_active=True, created_by=current_user.id).order_by(ListTemplate.name.asc()).all() if current_user.is_authenticated else [])
return render_template(
"main.html",
user_lists=user_lists,
@@ -173,6 +176,8 @@ def main_page():
timedelta=timedelta,
month_options=month_options,
selected_month=month_str,
expiring_lists=expiring_lists,
templates=templates,
)
@@ -377,6 +382,14 @@ def edit_my_list(list_id):
.all()
)
all_usernames = [
u.username
for u in User.query.filter(User.id != current_user.id)
.order_by(func.lower(User.username).asc())
.limit(300)
.all()
]
return render_template(
"edit_my_list.html",
list=l,
@@ -384,6 +397,7 @@ def edit_my_list(list_id):
categories=categories,
selected_categories=selected_categories_ids,
permitted_users=permitted_users,
all_usernames=all_usernames,
)
@@ -412,16 +426,18 @@ def edit_my_list_suggestions(list_id: int):
.subquery()
)
query = db.session.query(
User.username, subq.c.grant_count, subq.c.last_grant_id
).join(subq, subq.c.uid == User.id)
query = (
db.session.query(User.username, subq.c.grant_count, subq.c.last_grant_id)
.outerjoin(subq, subq.c.uid == User.id)
.filter(User.id != current_user.id)
)
if q:
query = query.filter(func.lower(User.username).like(f"{q}%"))
rows = (
query.order_by(
subq.c.grant_count.desc(),
subq.c.last_grant_id.desc(),
func.coalesce(subq.c.grant_count, 0).desc(),
func.coalesce(subq.c.last_grant_id, 0).desc(),
func.lower(User.username).asc(),
)
.limit(20)
@@ -523,6 +539,8 @@ def create_list():
)
db.session.add(new_list)
db.session.commit()
log_list_activity(new_list.id, 'list_created', actor=current_user, actor_name=current_user.username, details='Utworzono listę ręcznie')
db.session.commit()
flash("Utworzono nową listę", "success")
return redirect(url_for("view_list", list_id=new_list.id))
@@ -595,6 +613,21 @@ def view_list(list_id):
.all()
)
activity_logs = (
ListActivityLog.query.filter_by(list_id=list_id)
.order_by(ListActivityLog.created_at.desc(), ListActivityLog.id.desc())
.limit(20)
.all()
)
all_usernames = [
u.username
for u in User.query.filter(User.id != current_user.id)
.order_by(func.lower(User.username).asc())
.limit(300)
.all()
]
return render_template(
"list.html",
list=shopping_list,
@@ -611,6 +644,9 @@ def view_list(list_id):
selected_categories=selected_categories_ids,
permitted_users=permitted_users,
popular_categories=popular_categories,
activity_logs=activity_logs,
action_label=action_label,
all_usernames=all_usernames,
)
@@ -745,3 +781,76 @@ def list_settings(list_id):
return jsonify(ok=False, error="unknown_action"), 400
flash("Nieznana akcja.", "danger")
return redirect(next_page)
@app.route('/my-templates', methods=['GET', 'POST'])
@login_required
def my_templates():
if request.method == 'POST':
action = (request.form.get('action') or 'create_manual').strip()
if action == 'create_manual':
name = (request.form.get('name') or '').strip()
description = (request.form.get('description') or '').strip()
raw_items = (request.form.get('items_text') or '').splitlines()
if not name:
flash('Podaj nazwę szablonu.', 'danger')
return redirect(url_for('my_templates'))
template = ListTemplate(name=name, description=description, created_by=current_user.id, is_active=True)
db.session.add(template)
db.session.flush()
pos = 1
for line in raw_items:
line = line.strip()
if not line:
continue
qty = 1
item_name = line
match = re.match(r'^(.*?)(?:\s+[xX](\d+))?$', line)
if match:
item_name = (match.group(1) or '').strip() or line
if match.group(2):
qty = max(1, int(match.group(2)))
db.session.add(ListTemplateItem(template_id=template.id, name=item_name, quantity=qty, position=pos))
pos += 1
db.session.commit()
flash(f'Utworzono szablon „{template.name}”.', 'success')
return redirect(url_for('my_templates'))
elif action == 'delete':
template = ListTemplate.query.options(joinedload(ListTemplate.items)).get_or_404(request.form.get('template_id', type=int))
if template.created_by != current_user.id and not current_user.is_admin:
abort(403)
db.session.delete(template)
db.session.commit()
flash(f'Usunięto szablon „{template.name}”.', 'warning')
return redirect(url_for('my_templates'))
templates = ListTemplate.query.options(joinedload(ListTemplate.items)).filter_by(created_by=current_user.id, is_active=True).order_by(ListTemplate.created_at.desc(), ListTemplate.id.desc()).all()
source_lists = ShoppingList.query.filter_by(owner_id=current_user.id, is_archived=False).order_by(ShoppingList.created_at.desc()).limit(100).all()
return render_template('my_templates.html', templates=templates, source_lists=source_lists)
@app.route('/templates/<int:template_id>/instantiate', methods=['POST'])
@login_required
def instantiate_template(template_id):
template = ListTemplate.query.get_or_404(template_id)
if not template_is_accessible_to_user(template, current_user):
abort(403)
title = (request.form.get('title') or '').strip() or None
new_list = create_list_from_template(template, owner=current_user, title=title)
log_list_activity(new_list.id, 'template_created', actor=current_user, details=f'Utworzono z szablonu: {template.name}')
db.session.commit()
flash(f'Utworzono listę z szablonu „{template.name}”.', 'success')
return redirect(url_for('view_list', list_id=new_list.id))
@app.route('/templates/create-from-list/<int:list_id>', methods=['POST'])
@login_required
def create_template_from_user_list(list_id):
source_list = ShoppingList.query.options(joinedload(ShoppingList.items)).get_or_404(list_id)
if source_list.owner_id != current_user.id and not current_user.is_admin:
abort(403)
name = (request.form.get('template_name') or '').strip() or f'{source_list.title} - szablon'
description = (request.form.get('description') or '').strip() or f'Szablon utworzony z listy {source_list.title}'
template = create_template_from_list(source_list, created_by=current_user.id, name=name, description=description)
flash(f'Utworzono szablon „{template.name}”.', 'success')
return redirect(url_for('my_templates'))

View File

@@ -163,6 +163,214 @@ def expenses_data():
return jsonify(result)
@app.route("/api/expenses/latest")
@api_token_required
@require_api_scope('expenses:read')
def api_latest_expenses():
start_date_str = (request.args.get("start_date") or "").strip() or None
end_date_str = (request.args.get("end_date") or "").strip() or None
list_id = request.args.get("list_id", type=int)
owner_id = request.args.get("owner_id", type=int)
limit = request.args.get("limit", default=50, type=int) or 50
token_limit = getattr(g, 'api_token', None).max_limit if getattr(g, 'api_token', None) else 500
limit = max(1, min(limit, int(token_limit or 500), 500))
try:
start_date, end_date = parse_api_date_range(start_date_str, end_date_str)
except ValueError as exc:
return jsonify({"ok": False, "error": "bad_request", "message": str(exc)}), 400
filter_query = Expense.query.join(ShoppingList, ShoppingList.id == Expense.list_id)
if start_date:
filter_query = filter_query.filter(Expense.added_at >= start_date)
if end_date:
filter_query = filter_query.filter(Expense.added_at < end_date)
if list_id:
filter_query = filter_query.filter(Expense.list_id == list_id)
if owner_id:
filter_query = filter_query.filter(ShoppingList.owner_id == owner_id)
total_count = filter_query.with_entities(func.count(Expense.id)).scalar() or 0
total_amount = float(filter_query.with_entities(func.coalesce(func.sum(Expense.amount), 0)).scalar() or 0)
expenses = (
filter_query.options(
joinedload(Expense.shopping_list).joinedload(ShoppingList.owner),
joinedload(Expense.shopping_list).joinedload(ShoppingList.categories),
)
.order_by(Expense.added_at.desc(), Expense.id.desc())
.limit(limit)
.all()
)
items = []
for expense in expenses:
shopping_list = expense.shopping_list
owner = shopping_list.owner if shopping_list else None
items.append(
{
"expense_id": expense.id,
"amount": round(float(expense.amount or 0), 2),
"added_at": format_dt_for_api(expense.added_at),
"receipt_filename": expense.receipt_filename,
"list": {
"id": shopping_list.id if shopping_list else None,
"title": shopping_list.title if shopping_list else None,
"created_at": format_dt_for_api(shopping_list.created_at if shopping_list else None),
"is_archived": bool(shopping_list.is_archived) if shopping_list else None,
"is_public": bool(shopping_list.is_public) if shopping_list else None,
"categories": [c.name for c in shopping_list.categories] if shopping_list else [],
},
"owner": {
"id": owner.id if owner else None,
"username": owner.username if owner else None,
},
}
)
return jsonify(
{
"ok": True,
"filters": {
"start_date": start_date_str,
"end_date": end_date_str,
"list_id": list_id,
"owner_id": owner_id,
"limit": limit,
},
"meta": {
"returned_count": len(items),
"total_count": int(total_count),
"total_amount": round(total_amount, 2),
"token_name": g.api_token.name,
"token_prefix": g.api_token.token_prefix,
},
"items": items,
}
)
@app.route("/api/ping")
@api_token_required
def api_ping():
return jsonify({"ok": True, "message": "token accepted", "token_name": g.api_token.name, "token_prefix": g.api_token.token_prefix})
@app.route("/api/expenses/summary")
@api_token_required
@require_api_scope('expenses:read')
def api_expenses_summary():
start_date_str = (request.args.get("start_date") or "").strip() or None
end_date_str = (request.args.get("end_date") or "").strip() or None
list_id = request.args.get("list_id", type=int)
owner_id = request.args.get("owner_id", type=int)
try:
start_date, end_date = parse_api_date_range(start_date_str, end_date_str)
except ValueError as exc:
return jsonify({"ok": False, "error": "bad_request", "message": str(exc)}), 400
query = Expense.query.join(ShoppingList, ShoppingList.id == Expense.list_id)
if start_date:
query = query.filter(Expense.added_at >= start_date)
if end_date:
query = query.filter(Expense.added_at < end_date)
if list_id:
query = query.filter(Expense.list_id == list_id)
if owner_id:
query = query.filter(ShoppingList.owner_id == owner_id)
total_count = int(query.with_entities(func.count(Expense.id)).scalar() or 0)
total_amount = float(query.with_entities(func.coalesce(func.sum(Expense.amount), 0)).scalar() or 0)
by_list = (
query.with_entities(ShoppingList.id, ShoppingList.title, func.count(Expense.id), func.coalesce(func.sum(Expense.amount), 0))
.group_by(ShoppingList.id, ShoppingList.title)
.order_by(func.coalesce(func.sum(Expense.amount), 0).desc(), ShoppingList.id.desc())
.limit(100)
.all()
)
return jsonify({
"ok": True,
"filters": {"start_date": start_date_str, "end_date": end_date_str, "list_id": list_id, "owner_id": owner_id},
"meta": {"total_count": total_count, "total_amount": round(total_amount, 2)},
"lists": [{"id": row[0], "title": row[1], "expense_count": int(row[2] or 0), "total_amount": round(float(row[3] or 0), 2)} for row in by_list],
})
@app.route("/api/lists")
@api_token_required
@require_api_scope('lists:read')
def api_lists():
owner_id = request.args.get("owner_id", type=int)
limit = request.args.get("limit", default=50, type=int) or 50
token_limit = getattr(g, 'api_token', None).max_limit if getattr(g, 'api_token', None) else 500
limit = max(1, min(limit, int(token_limit or 500), 500))
query = ShoppingList.query.options(joinedload(ShoppingList.owner), joinedload(ShoppingList.categories)).order_by(ShoppingList.created_at.desc(), ShoppingList.id.desc())
if owner_id:
query = query.filter(ShoppingList.owner_id == owner_id)
rows = query.limit(limit).all()
return jsonify({
"ok": True,
"items": [{
"id": row.id,
"title": row.title,
"created_at": format_dt_for_api(row.created_at),
"owner": {"id": row.owner.id if row.owner else None, "username": row.owner.username if row.owner else None},
"is_temporary": bool(row.is_temporary),
"expires_at": format_dt_for_api(row.expires_at),
"is_archived": bool(row.is_archived),
"is_public": bool(row.is_public),
"categories": [c.name for c in row.categories],
} for row in rows],
})
@app.route("/api/lists/<int:list_id>/expenses")
@api_token_required
@require_api_scope('lists:read')
def api_list_expenses(list_id):
limit = request.args.get("limit", default=50, type=int) or 50
token_limit = getattr(g, 'api_token', None).max_limit if getattr(g, 'api_token', None) else 500
limit = max(1, min(limit, int(token_limit or 500), 500))
shopping_list = ShoppingList.query.options(joinedload(ShoppingList.owner), joinedload(ShoppingList.categories)).get_or_404(list_id)
rows = Expense.query.filter_by(list_id=list_id).order_by(Expense.added_at.desc(), Expense.id.desc()).limit(limit).all()
return jsonify({
"ok": True,
"list": {
"id": shopping_list.id,
"title": shopping_list.title,
"owner": {"id": shopping_list.owner.id if shopping_list.owner else None, "username": shopping_list.owner.username if shopping_list.owner else None},
"categories": [c.name for c in shopping_list.categories],
},
"items": [{"expense_id": row.id, "amount": round(float(row.amount or 0), 2), "added_at": format_dt_for_api(row.added_at), "receipt_filename": row.receipt_filename} for row in rows],
})
@app.route("/api/templates")
@api_token_required
@require_api_scope('templates:read')
def api_templates():
query = ListTemplate.query.options(joinedload(ListTemplate.creator), joinedload(ListTemplate.items)).filter_by(is_active=True)
owner_id = request.args.get("owner_id", type=int)
if owner_id:
query = query.filter(ListTemplate.created_by == owner_id)
rows = query.order_by(ListTemplate.created_at.desc(), ListTemplate.id.desc()).limit(100).all()
return jsonify({
"ok": True,
"items": [{
"id": row.id,
"name": row.name,
"description": row.description,
"created_at": format_dt_for_api(row.created_at),
"owner": {"id": row.creator.id if row.creator else None, "username": row.creator.username if row.creator else None},
"items_count": len(row.items),
"items": [{"name": item.name, "quantity": item.quantity, "note": item.note} for item in row.items],
} for row in rows],
})
@app.route("/share/<token>")
# @app.route("/guest-list/<int:list_id>")
@app.route("/shared/<int:list_id>")
@@ -172,21 +380,19 @@ def shared_list(token=None, list_id=None):
if token:
shopping_list = ShoppingList.query.filter_by(share_token=token).first_or_404()
expires_at = shopping_list.expires_at
if expires_at and expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
# jeśli lista wygasła zablokuj (spójne z resztą aplikacji)
if (
shopping_list.is_temporary
and shopping_list.expires_at
and shopping_list.expires_at <= now
):
if shopping_list.is_temporary and expires_at and expires_at <= now:
flash("Link wygasł.", "warning")
return redirect(url_for("main_page"))
# >>> KLUCZOWE: pozwól wejść nawet, gdy niepubliczna (bez check_list_public)
list_id = shopping_list.id
# >>> Jeśli zalogowany i nie jest właścicielem — auto-przypisz stałe uprawnienie
# jeśli zalogowany i nie jest właścicielem — auto-przypisz stałe uprawnienie
if current_user.is_authenticated and current_user.id != shopping_list.owner_id:
# dodaj wpis tylko jeśli go nie ma
exists = (
db.session.query(ListPermission.id)
.filter(
@@ -202,6 +408,29 @@ def shared_list(token=None, list_id=None):
db.session.commit()
else:
shopping_list = ShoppingList.query.get_or_404(list_id)
expires_at = shopping_list.expires_at
if expires_at and expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
if shopping_list.is_temporary and expires_at and expires_at <= now:
flash("Ta lista wygasła.", "warning")
return redirect(url_for("main_page"))
is_allowed = shopping_list.is_public
if current_user.is_authenticated:
is_allowed = is_allowed or shopping_list.owner_id == current_user.id or (
db.session.query(ListPermission.id)
.filter(
ListPermission.list_id == shopping_list.id,
ListPermission.user_id == current_user.id,
)
.first()
is not None
)
if not is_allowed:
flash("Ta lista nie jest publicznie dostępna.", "warning")
return redirect(url_for("main_page"))
total_expense = get_total_expense_for_list(list_id)
shopping_list, items, receipts, expenses, total_expense = get_list_details(list_id)

View File

@@ -1,3 +1,4 @@
import click
from .deps import *
from .app_setup import *
from .models import *
@@ -167,6 +168,7 @@ def handle_delete_item(data):
if item:
list_id = item.list_id
log_list_activity(list_id, 'item_deleted', item_name=item.name, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość')
db.session.delete(item)
db.session.commit()
emit("item_deleted", {"item_id": item.id}, to=str(item.list_id))
@@ -306,6 +308,7 @@ def handle_add_item(data):
new_suggestion = SuggestedProduct(name=name)
db.session.add(new_suggestion)
log_list_activity(list_id, 'item_added', item_name=new_item.name, actor=current_user if current_user.is_authenticated else None, actor_name=user_name, details=f'ilość: {new_item.quantity}')
db.session.commit()
emit(
@@ -342,7 +345,7 @@ def handle_check_item(data):
if item:
item.purchased = True
item.purchased_at = datetime.now(UTC)
log_list_activity(item.list_id, 'item_checked', item_name=item.name, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość')
db.session.commit()
purchased_count, total_count, percent = get_progress(item.list_id)
@@ -366,6 +369,7 @@ def handle_uncheck_item(data):
if item:
item.purchased = False
item.purchased_at = None
log_list_activity(item.list_id, 'item_unchecked', item_name=item.name, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość')
db.session.commit()
purchased_count, total_count, percent = get_progress(item.list_id)
@@ -447,6 +451,7 @@ def handle_add_expense(data):
)
db.session.add(new_expense)
log_list_activity(list_id, 'expense_added', item_name=None, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość', details=f'kwota: {float(amount):.2f} PLN')
db.session.commit()
total = (
@@ -465,6 +470,7 @@ def handle_mark_not_purchased(data):
if item:
item.not_purchased = True
item.not_purchased_reason = reason
log_list_activity(item.list_id, 'item_marked_not_purchased', item_name=item.name, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość', details=reason or None)
db.session.commit()
emit(
"item_marked_not_purchased",
@@ -482,6 +488,7 @@ def handle_unmark_not_purchased(data):
item.purchased = False
item.purchased_at = None
item.not_purchased_reason = None
log_list_activity(item.list_id, 'item_unmarked_not_purchased', item_name=item.name, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość')
db.session.commit()
emit("item_unmarked_not_purchased", {"item_id": item.id}, to=str(item.list_id))
@@ -511,3 +518,101 @@ def create_db():
if __name__ == "__main__":
logging.basicConfig(level=logging.DEBUG if DEBUG_MODE else logging.INFO)
socketio.run(app, host="0.0.0.0", port=APP_PORT, debug=False)
@app.cli.group("admins")
def admins_cli():
"""Zarzadzanie kontami administratorow z CLI."""
@admins_cli.command("list")
def admins_list_command():
with app.app_context():
users = User.query.order_by(User.username.asc()).all()
if not users:
click.echo('Brak uzytkownikow.')
return
for user in users:
role = 'admin' if user.is_admin else 'user'
click.echo(f"{user.id} {user.username} {role}")
@admins_cli.command("create")
@click.argument("username")
@click.argument("password")
@click.option("--admin/--user", "make_admin", default=True, show_default=True, help="Utworz konto admina albo zwyklego uzytkownika.")
def admins_create_command(username, password, make_admin):
with app.app_context():
user, created, _ = create_or_update_admin_user(username, password=password, make_admin=make_admin, update_password=False)
status = 'Utworzono' if created else 'Istnieje juz'
click.echo(f"{status} konto: id={user.id}, username={user.username}, admin={user.is_admin}")
@admins_cli.command("promote")
@click.argument("username")
def admins_promote_command(username):
with app.app_context():
user = resolve_user_identifier(username)
if not user:
raise click.ClickException('Nie znaleziono uzytkownika.')
user.is_admin = True
db.session.commit()
click.echo(f"Uzytkownik {user.username} ma teraz uprawnienia admina.")
@admins_cli.command("demote")
@click.argument("username")
def admins_demote_command(username):
with app.app_context():
user = resolve_user_identifier(username)
if not user:
raise click.ClickException('Nie znaleziono uzytkownika.')
user.is_admin = False
db.session.commit()
click.echo(f"Uzytkownik {user.username} nie jest juz adminem.")
@admins_cli.command("set-password")
@click.argument("username")
@click.argument("password")
def admins_set_password_command(username, password):
with app.app_context():
user = resolve_user_identifier(username)
if not user:
raise click.ClickException('Nie znaleziono uzytkownika.')
user.password_hash = hash_password(password)
db.session.commit()
click.echo(f"Zmieniono haslo dla {user.username}.")
@app.cli.group("lists")
def lists_cli():
"""Operacje CLI na listach zakupowych."""
@lists_cli.command("copy-schedule")
@click.option("--source-list-id", required=True, type=int, help="ID listy zrodlowej.")
@click.option("--when", "when_value", required=True, help="Nowa data utworzenia listy: YYYY-MM-DD lub YYYY-MM-DD HH:MM")
@click.option("--owner", "owner_value", default=None, help="Nowy wlasciciel: username albo ID. Domyslnie wlasciciel oryginalu.")
@click.option("--title", default=None, help="Nowy tytul listy. Domyslnie taki sam jak w oryginale.")
def lists_copy_schedule_command(source_list_id, when_value, owner_value, title):
with app.app_context():
source_list = ShoppingList.query.options(joinedload(ShoppingList.items), joinedload(ShoppingList.categories)).get(source_list_id)
if not source_list:
raise click.ClickException('Nie znaleziono listy zrodlowej.')
try:
scheduled_for = parse_cli_datetime(when_value)
except ValueError as exc:
raise click.ClickException(str(exc))
owner = None
if owner_value:
owner = resolve_user_identifier(owner_value)
if not owner:
raise click.ClickException('Nie znaleziono docelowego wlasciciela.')
new_list = duplicate_list_for_schedule(source_list, scheduled_for=scheduled_for, owner=owner, title=title)
click.echo(
f"Utworzono kopie listy: nowa_id={new_list.id}, tytul={new_list.title}, created_at={new_list.created_at.isoformat()}"
)

File diff suppressed because it is too large Load Diff

View File

@@ -29,7 +29,7 @@
async function postAction(postUrl, nextPath, params) {
const form = new FormData();
for (const [k, v] of Object.entries(params)) form.set(k, v);
form.set('next', nextPath); // dla trybu HTML fallback
form.set('next', nextPath);
try {
const res = await fetch(postUrl, {
@@ -61,13 +61,16 @@
const suggestUrl = box.dataset.suggestUrl || '';
const grantAction = box.dataset.grantAction || 'grant';
const revokeField = box.dataset.revokeField || 'revoke_user_id';
const listId = box.dataset.listId || '';
const tokensBox = $('.tokens', box);
const input = $('.access-input', box);
const addBtn = $('.access-add', box);
// współdzielony datalist do sugestii
let datalist = $('#userHintsGeneric');
let datalist = null;
const existingListId = input?.getAttribute('list');
if (existingListId) datalist = document.getElementById(existingListId);
if (!datalist) datalist = $('#userHintsGeneric');
if (!datalist) {
datalist = document.createElement('datalist');
datalist.id = 'userHintsGeneric';
@@ -79,25 +82,32 @@
const parseUserText = (txt) => unique((txt || '').split(/[\s,;]+/g).map(s => s.trim().replace(/^@/, '').toLowerCase()).filter(Boolean));
const debounce = (fn, ms = 200) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; };
// Sugestie (GET JSON)
const renderHints = (users = []) => { datalist.innerHTML = users.slice(0, 20).map(u => `<option value="${u}">@${u}</option>`).join(''); };
const initialOptions = Array.from(datalist.querySelectorAll('option')).map(o => o.value).filter(Boolean);
const renderHints = (users = []) => {
const merged = unique([...(users || []), ...initialOptions]).slice(0, 20);
datalist.innerHTML = merged.map(u => `<option value="${u}"></option>`).join('');
};
renderHints(initialOptions);
let acCtrl = null;
const fetchHints = debounce(async (q) => {
if (!suggestUrl) return;
try {
acCtrl?.abort();
acCtrl = new AbortController();
const res = await fetch(`${suggestUrl}?q=${encodeURIComponent(q || '')}`, { credentials: 'same-origin', signal: acCtrl.signal });
const normalized = String(q || '').trim().replace(/^@/, '');
const res = await fetch(`${suggestUrl}?q=${encodeURIComponent(normalized)}`, { credentials: 'same-origin', signal: acCtrl.signal });
if (!res.ok) return renderHints([]);
const data = await res.json().catch(() => ({ users: [] }));
renderHints(data.users || []);
} catch { renderHints([]); }
} catch {
renderHints(initialOptions);
}
}, 200);
input?.addEventListener('focus', () => fetchHints(input.value));
input?.addEventListener('input', () => fetchHints(input.value));
// Revoke (klik w token)
box.addEventListener('click', async (e) => {
const btn = e.target.closest('.token');
if (!btn || !box.contains(btn)) return;
@@ -107,7 +117,7 @@
if (!userId) return toast('Brak identyfikatora użytkownika.', 'danger');
btn.disabled = true; btn.classList.add('disabled');
const res = await postAction(postUrl, nextPath, { [revokeField]: userId });
const res = await postAction(postUrl, nextPath, { action: 'revoke', target_list_id: listId, [revokeField]: userId });
if (res.ok) {
btn.remove();
@@ -124,7 +134,6 @@
}
});
// Grant (wiele loginów, bez przeładowania strony)
async function addUsers() {
const users = parseUserText(input?.value);
if (!users?.length) return toast('Podaj co najmniej jednego użytkownika', 'warning');
@@ -136,10 +145,9 @@
let okCount = 0, failCount = 0, appended = 0;
for (const u of users) {
const res = await postAction(postUrl, nextPath, { action: grantAction, grant_username: u });
const res = await postAction(postUrl, nextPath, { action: grantAction, target_list_id: listId, grant_username: u });
if (res.ok) {
okCount++;
// jeśli backend odda JSON z userem dolep token live
if (res.data?.user) {
appendToken(box, res.data.user);
appended++;
@@ -156,9 +164,7 @@
if (okCount) toast(`Dodano dostęp: ${okCount} użytkownika`, 'success');
if (failCount) toast(`Błędy przy dodawaniu: ${failCount}`, 'danger');
// fallback: jeśli nic nie dolepiliśmy (brak JSON), odśwież, by zobaczyć nowe tokeny
if (okCount && appended === 0) {
// opóźnij minimalnie, by toast mignął
setTimeout(() => location.reload(), 400);
}
}

View File

@@ -0,0 +1,227 @@
document.addEventListener('DOMContentLoaded', function () {
enhancePasswordFields();
enhanceSearchableTables();
wireCopyButtons();
wireUnsavedWarnings();
enhanceMobileTables();
wireAdminNavToggle();
initResponsiveCategoryBadges();
});
function enhancePasswordFields() {
document.querySelectorAll('input[type="password"]').forEach(function (input) {
if (input.dataset.uiPasswordReady === '1') return;
if (input.closest('[data-ui-skip-toggle="true"]')) return;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-outline-light ui-password-toggle';
btn.setAttribute('aria-label', 'Pokaż lub ukryj hasło');
btn.textContent = '👁';
btn.addEventListener('click', function () {
const visible = input.type === 'text';
input.type = visible ? 'password' : 'text';
btn.textContent = visible ? '👁' : '🙈';
btn.classList.toggle('is-active', !visible);
});
if (input.parentElement && input.parentElement.classList.contains('input-group')) {
input.parentElement.appendChild(btn);
} else {
const wrapper = document.createElement('div');
wrapper.className = 'input-group ui-password-group';
input.parentNode.insertBefore(wrapper, input);
wrapper.appendChild(input);
wrapper.appendChild(btn);
}
input.dataset.uiPasswordReady = '1';
});
}
function enhanceSearchableTables() {
if (document.getElementById('search-table')) return;
const tables = document.querySelectorAll('table.sortable, table[data-searchable="true"]');
tables.forEach(function (table, index) {
if (table.dataset.uiSearchReady === '1') return;
const tbody = table.tBodies[0];
if (!tbody) return;
const rows = Array.from(tbody.querySelectorAll('tr'));
if (rows.length < 6) return;
const toolbar = document.createElement('div');
toolbar.className = 'table-toolbar';
toolbar.innerHTML = [
'<div class="input-group input-group-sm table-toolbar__search">',
' <span class="input-group-text">🔎</span>',
' <input type="search" class="form-control" placeholder="Filtruj tabelę…" aria-label="Filtruj tabelę">',
' <button type="button" class="btn btn-outline-light">Wyczyść</button>',
'</div>',
'<div class="table-toolbar__meta text-secondary small">',
' <span class="table-toolbar__count"></span>',
'</div>'
].join('');
const input = toolbar.querySelector('input');
const clearBtn = toolbar.querySelector('button');
const count = toolbar.querySelector('.table-toolbar__count');
function updateTableFilter() {
const query = (input.value || '').trim().toLowerCase();
let visible = 0;
rows.forEach(function (row) {
const rowText = row.innerText.toLowerCase();
const match = !query || rowText.includes(query);
row.style.display = match ? '' : 'none';
if (match) visible += 1;
});
count.textContent = 'Widoczne: ' + visible + ' / ' + rows.length;
}
input.addEventListener('input', updateTableFilter);
clearBtn.addEventListener('click', function () {
input.value = '';
updateTableFilter();
input.focus();
});
const container = table.closest('.table-responsive') || table;
container.parentNode.insertBefore(toolbar, container);
updateTableFilter();
table.dataset.uiSearchReady = '1';
});
}
function wireCopyButtons() {
document.querySelectorAll('[data-copy-target]').forEach(function (button) {
if (button.dataset.uiCopyReady === '1') return;
button.dataset.uiCopyReady = '1';
button.addEventListener('click', async function () {
const target = document.querySelector(button.dataset.copyTarget);
if (!target) return;
const text = target.value || target.textContent || '';
try {
await navigator.clipboard.writeText(text.trim());
const original = button.textContent;
button.textContent = '✅ Skopiowano';
setTimeout(function () {
button.textContent = original;
}, 1800);
} catch (err) {
console.warn('Copy failed', err);
}
});
});
}
function wireUnsavedWarnings() {
const trackedForms = Array.from(document.querySelectorAll('form[data-unsaved-warning="true"]'));
if (!trackedForms.length) return;
trackedForms.forEach(function (form) {
if (form.dataset.uiUnsavedReady === '1') return;
form.dataset.uiUnsavedReady = '1';
form.dataset.uiDirty = '0';
const markDirty = function () {
form.dataset.uiDirty = '1';
form.classList.add('is-dirty');
};
form.addEventListener('input', markDirty);
form.addEventListener('change', markDirty);
form.addEventListener('submit', function () {
form.dataset.uiDirty = '0';
form.classList.remove('is-dirty');
});
});
window.addEventListener('beforeunload', function (event) {
const hasDirty = trackedForms.some(function (form) {
return form.dataset.uiDirty === '1';
});
if (!hasDirty) return;
event.preventDefault();
event.returnValue = '';
});
}
function enhanceMobileTables() {
document.querySelectorAll('table').forEach(function (table) {
if (table.dataset.mobileLabelsReady === '1') return;
const headers = Array.from(table.querySelectorAll('thead th')).map(function (th) {
return (th.innerText || '').trim();
});
if (!headers.length) return;
table.querySelectorAll('tbody tr').forEach(function (row) {
Array.from(row.children).forEach(function (cell, index) {
if (!cell.dataset.label && headers[index]) {
cell.dataset.label = headers[index];
}
});
});
table.dataset.mobileLabelsReady = '1';
});
}
function wireAdminNavToggle() {
const toggle = document.querySelector('[data-admin-nav-toggle]');
const nav = document.querySelector('[data-admin-nav-body]');
if (!toggle || !nav) return;
toggle.addEventListener('click', function () {
const expanded = toggle.getAttribute('aria-expanded') === 'true';
toggle.setAttribute('aria-expanded', expanded ? 'false' : 'true');
nav.classList.toggle('is-open', !expanded);
});
}
function initResponsiveCategoryBadges() {
const headings = Array.from(document.querySelectorAll('[data-mobile-list-heading]'));
if (!headings.length) return;
const update = function () {
const isMobile = window.matchMedia('(max-width: 575.98px)').matches;
headings.forEach(function (heading) {
const title = heading.querySelector('[data-mobile-list-title]');
const group = heading.querySelector('[data-mobile-category-group]');
if (!title || !group) return;
group.classList.remove('is-compact');
if (!isMobile || !group.children.length) return;
const headingWidth = Math.ceil(heading.getBoundingClientRect().width);
if (!headingWidth) return;
const titleRect = title.getBoundingClientRect();
const groupRect = group.getBoundingClientRect();
const titleWidth = Math.ceil(titleRect.width);
const groupWidth = Math.ceil(group.scrollWidth);
const wrapped = groupRect.top - titleRect.top > 4;
const needsCompact = wrapped || (titleWidth + groupWidth > headingWidth);
group.classList.toggle('is-compact', needsCompact);
});
};
let resizeTimer = null;
window.addEventListener('resize', function () {
window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(update, 60);
});
if (typeof ResizeObserver === 'function') {
const observer = new ResizeObserver(update);
headings.forEach(function (heading) {
observer.observe(heading);
});
}
update();
}

View File

@@ -1,254 +1,22 @@
(function () {
const $ = (s, root = document) => root.querySelector(s);
const $$ = (s, root = document) => Array.from(root.querySelectorAll(s));
const filterInput = $('#listFilter');
const filterCount = $('#filterCount');
const selectAll = $('#selectAll');
const bulkTokens = $('#bulkTokens');
const bulkInput = $('#bulkUsersInput');
const bulkBtn = $('#bulkAddBtn');
const datalist = $('#userHints');
const unique = (arr) => Array.from(new Set(arr));
const parseUserText = (txt) => unique((txt || '')
.split(/[\s,;]+/g)
.map(s => s.trim().replace(/^@/, '').toLowerCase())
.filter(Boolean)
);
const selectedListIds = () =>
$$('.row-check:checked').map(ch => ch.dataset.listId);
const visibleRows = () =>
$$('#listsTable tbody tr').filter(r => r.style.display !== 'none');
// ===== Podpowiedzi (datalist) z DOM-u =====
(function buildHints() {
const names = new Set();
$$('.owner-username').forEach(el => names.add(el.dataset.username));
$$('.permitted-username').forEach(el => names.add(el.dataset.username));
// również tokeny już wyrenderowane
$$('.token[data-username]').forEach(el => names.add(el.dataset.username));
datalist.innerHTML = Array.from(names)
.sort((a, b) => a.localeCompare(b))
.map(u => `<option value="${u}">@${u}</option>`)
.join('');
})();
// ===== Live filter =====
function applyFilter() {
const q = (filterInput?.value || '').trim().toLowerCase();
let shown = 0;
$$('#listsTable tbody tr').forEach(tr => {
const hay = `${tr.dataset.id || ''} ${tr.dataset.title || ''} ${tr.dataset.owner || ''}`;
const ok = !q || hay.includes(q);
tr.style.display = ok ? '' : 'none';
if (ok) shown++;
});
if (filterCount) filterCount.textContent = shown ? `Widoczne: ${shown}` : 'Brak wyników';
}
filterInput?.addEventListener('input', applyFilter);
applyFilter();
// ===== Select all =====
selectAll?.addEventListener('change', () => {
visibleRows().forEach(tr => {
const cb = tr.querySelector('.row-check');
if (cb) cb.checked = selectAll.checked;
});
});
// ===== Copy share URL =====
$$('.copy-share').forEach(btn => {
btn.addEventListener('click', async () => {
const url = btn.dataset.url;
try {
await navigator.clipboard.writeText(url);
showToast('Skopiowano link udostępnienia', 'success');
} catch {
const ta = Object.assign(document.createElement('textarea'), { value: url });
document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove();
showToast('Skopiowano link udostępnienia', 'success');
}
});
});
// ===== Tokenized users field (global belka) =====
function addGlobalToken(username) {
if (!username) return;
const exists = $(`.user-token[data-user="${username}"]`, bulkTokens);
if (exists) return;
const token = document.createElement('span');
token.className = 'badge rounded-pill text-bg-secondary user-token';
token.dataset.user = username;
token.innerHTML = `@${username} <button type="button" class="btn btn-sm btn-link p-0 ms-1 text-white">✕</button>`;
token.querySelector('button').addEventListener('click', () => token.remove());
bulkTokens.appendChild(token);
}
bulkInput?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
parseUserText(bulkInput.value).forEach(addGlobalToken);
bulkInput.value = '';
}
});
bulkInput?.addEventListener('change', () => {
parseUserText(bulkInput.value).forEach(addGlobalToken);
bulkInput.value = '';
});
// ===== Bulk grant (z belki) =====
async function bulkGrant() {
const lists = selectedListIds();
const users = $$('.user-token', bulkTokens).map(t => t.dataset.user);
if (!lists.length) { showToast('Zaznacz przynajmniej jedną listę', 'warning'); return; }
if (!users.length) { showToast('Dodaj przynajmniej jednego użytkownika', 'warning'); return; }
bulkBtn.disabled = true;
bulkBtn.textContent = 'Pracuję…';
const url = location.pathname + location.search;
let ok = 0, fail = 0;
for (const lid of lists) {
for (const u of users) {
const form = new FormData();
form.set('action', 'grant');
form.set('target_list_id', lid);
form.set('grant_username', u);
try {
const res = await fetch(url, { method: 'POST', body: form, credentials: 'same-origin' });
if (res.ok) ok++; else fail++;
} catch { fail++; }
}
}
bulkBtn.disabled = false;
bulkBtn.textContent = ' Nadaj dostęp';
showToast(`Gotowe. Sukcesy: ${ok}${fail ? `, błędy: ${fail}` : ''}`, fail ? 'danger' : 'success');
location.reload();
}
bulkBtn?.addEventListener('click', bulkGrant);
// ===== Per-row "Access editor" (tokeny + dodawanie) =====
async function postAction(params) {
const url = location.pathname + location.search;
const form = new FormData();
for (const [k, v] of Object.entries(params)) form.set(k, v);
const res = await fetch(url, { method: 'POST', body: form, credentials: 'same-origin' });
return res.ok;
}
// Delegacja zdarzeń: kliknięcie tokenu = revoke
document.addEventListener('click', async (e) => {
const btn = e.target.closest('.access-editor .token');
if (!btn) return;
const wrapper = btn.closest('.access-editor');
const listId = wrapper?.dataset.listId;
const userId = btn.dataset.userId;
const username = btn.dataset.username;
if (!listId || !userId) return;
btn.disabled = true;
btn.classList.add('disabled');
const ok = await postAction({
action: 'revoke',
target_list_id: listId,
revoke_user_id: userId
});
if (ok) {
btn.remove();
const tokens = $$('.token', wrapper);
if (!tokens.length) {
// pokaż info „brak uprawnień”
let empty = $('.no-perms', wrapper);
if (!empty) {
empty = document.createElement('span');
empty.className = 'text-warning small no-perms';
empty.textContent = 'Brak dodanych uprawnień.';
$('.tokens', wrapper).appendChild(empty);
}
}
showToast(`Odebrano dostęp: @${username}`, 'success');
} else {
btn.disabled = false;
btn.classList.remove('disabled');
showToast(`Nie udało się odebrać dostępu @${username}`, 'danger');
}
});
// Dodawanie wielu użytkowników per-row
document.addEventListener('click', async (e) => {
const addBtn = e.target.closest('.access-editor .access-add');
if (!addBtn) return;
const wrapper = addBtn.closest('.access-editor');
const listId = wrapper?.dataset.listId;
const input = $('.access-input', wrapper);
if (!listId || !input) return;
const users = parseUserText(input.value);
if (!users.length) { showToast('Podaj co najmniej jednego użytkownika', 'warning'); return; }
addBtn.disabled = true;
addBtn.textContent = 'Dodaję…';
let okCount = 0, failCount = 0;
for (const u of users) {
const ok = await postAction({
action: 'grant',
target_list_id: listId,
grant_username: u
});
if (ok) {
okCount++;
// usuń info „brak uprawnień”
$('.no-perms', wrapper)?.remove();
// dodaj token jeśli nie ma
const exists = $(`.token[data-username="${u}"]`, wrapper);
if (!exists) {
const token = document.createElement('button');
token.type = 'button';
token.className = 'btn btn-sm btn-outline-secondary rounded-pill token';
token.dataset.username = u;
token.dataset.userId = ''; // nie znamy ID — token nadal klikany, ale bez revoke po ID
token.title = '@' + u;
token.innerHTML = `@${u} <span aria-hidden="true">×</span>`;
$('.tokens', wrapper).appendChild(token);
}
} else {
failCount++;
}
}
addBtn.disabled = false;
addBtn.textContent = ' Dodaj';
input.value = '';
if (okCount) showToast(`Dodano dostęp: ${okCount} użytk.`, 'success');
if (failCount) showToast(`Błędy przy dodawaniu: ${failCount}`, 'danger');
// Odśwież, by mieć poprawne user_id w tokenach (backend wie lepiej)
if (okCount) location.reload();
});
// Enter w polu per-row = zadziałaj jak przycisk
document.addEventListener('keydown', (e) => {
const inp = e.target.closest('.access-editor .access-input');
if (inp && e.key === 'Enter') {
e.preventDefault();
const btn = inp.closest('.access-editor')?.querySelector('.access-add');
btn?.click();
}
});
})();
const $=(s,r=document)=>r.querySelector(s); const $$=(s,r=document)=>Array.from(r.querySelectorAll(s));
const filterInput=$('#listFilter'),filterCount=$('#filterCount'),selectAll=$('#selectAll'),bulkTokens=$('#bulkTokens'),bulkInput=$('#bulkUsersInput'),bulkBtn=$('#bulkAddBtn');
const unique=arr=>Array.from(new Set(arr));
const parseUserText=txt=>unique((txt||'').split(/[\s,;]+/g).map(s=>s.trim().replace(/^@/,'').toLowerCase()).filter(Boolean));
const selectedListIds=()=>$$('.row-check:checked').map(ch=>ch.dataset.listId);
const visibleRows=()=>$$('#listsTable tbody tr').filter(r=>r.style.display!=='none');
function applyFilter(){const q=(filterInput?.value||'').trim().toLowerCase();let shown=0;$$('#listsTable tbody tr').forEach(tr=>{const hay=`${tr.dataset.id||''} ${tr.dataset.title||''} ${tr.dataset.owner||''}`;const ok=!q||hay.includes(q);tr.style.display=ok?'':'none';if(ok) shown++;});if(filterCount) filterCount.textContent=shown?`Widoczne: ${shown}`:'Brak wyników';}
filterInput?.addEventListener('input',applyFilter);applyFilter();
selectAll?.addEventListener('change',()=>{visibleRows().forEach(tr=>{const cb=tr.querySelector('.row-check'); if(cb) cb.checked=selectAll.checked;});});
$$('.copy-share').forEach(btn=>btn.addEventListener('click',async()=>{const url=btn.dataset.url;try{await navigator.clipboard.writeText(url);}catch{const ta=Object.assign(document.createElement('textarea'),{value:url});document.body.appendChild(ta);ta.select();document.execCommand('copy');ta.remove();}showToast('Skopiowano link udostępnienia','success');}));
function addGlobalToken(username){if(!username) return;const exists=$(`.user-token[data-user="${username}"]`,bulkTokens);if(exists) return;const token=document.createElement('span');token.className='badge rounded-pill text-bg-secondary user-token';token.dataset.user=username;token.innerHTML=`@${username} <button type="button" class="btn btn-sm btn-link p-0 ms-1 text-white">✕</button>`;token.querySelector('button').addEventListener('click',()=>token.remove());bulkTokens.appendChild(token);}
bulkInput?.addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();parseUserText(bulkInput.value).forEach(addGlobalToken);bulkInput.value='';}});
bulkInput?.addEventListener('change',()=>{parseUserText(bulkInput.value).forEach(addGlobalToken);bulkInput.value='';});
let hintCtrl=null;
function renderBulkHints(users){const dl=$('#userHints'); if(!dl) return; dl.innerHTML=(users||[]).slice(0,20).map(u=>`<option value="${u}"></option>`).join('');}
async function fetchBulkHints(q=''){const normalized=String(q||'').trim().replace(/^@/,'');try{hintCtrl?.abort();hintCtrl=new AbortController();const res=await fetch(`/admin/user-suggestions?q=${encodeURIComponent(normalized)}`,{credentials:'same-origin',signal:hintCtrl.signal});if(!res.ok) return renderBulkHints([]);const data=await res.json().catch(()=>({users:[]}));renderBulkHints(data.users||[]);}catch(e){renderBulkHints([]);}}
bulkInput?.addEventListener('focus',()=>fetchBulkHints(bulkInput.value));
bulkInput?.addEventListener('input',()=>fetchBulkHints(bulkInput.value));
async function bulkGrant(){const lists=selectedListIds(), users=$$('.user-token',bulkTokens).map(t=>t.dataset.user);if(!lists.length) return showToast('Zaznacz przynajmniej jedną listę','warning');if(!users.length) return showToast('Dodaj przynajmniej jednego użytkownika','warning');bulkBtn.disabled=true;bulkBtn.textContent='Pracuję…';const url=location.pathname+location.search;let ok=0,fail=0;for(const lid of lists){for(const u of users){const form=new FormData();form.set('action','grant');form.set('target_list_id',lid);form.set('grant_username',u);try{const res=await fetch(url,{method:'POST',body:form,credentials:'same-origin',headers:{'Accept':'application/json','X-Requested-With':'fetch'}});if(res.ok) ok++; else fail++;}catch{fail++;}}}bulkBtn.disabled=false;bulkBtn.textContent=' Nadaj dostęp';showToast(`Gotowe. Sukcesy: ${ok}${fail?`, błędy: ${fail}`:''}`,fail?'danger':'success');if(ok) location.reload();}
bulkBtn?.addEventListener('click',bulkGrant);
})();

View File

@@ -1,7 +1,11 @@
document.addEventListener("DOMContentLoaded", function () {
const toggleBtn = document.getElementById("tempToggle");
const hiddenInput = document.getElementById("temporaryHidden");
const tooltip = new bootstrap.Tooltip(toggleBtn);
if (!toggleBtn || !hiddenInput) return;
if (typeof bootstrap !== "undefined") {
new bootstrap.Tooltip(toggleBtn);
}
function updateToggle(isActive) {
if (isActive) {
@@ -13,12 +17,14 @@ document.addEventListener("DOMContentLoaded", function () {
toggleBtn.classList.add("btn-outline-secondary");
toggleBtn.textContent = "Tymczasowa";
}
toggleBtn.setAttribute("aria-pressed", isActive ? "true" : "false");
}
let active = toggleBtn.getAttribute("data-active") === "1";
updateToggle(active);
toggleBtn.addEventListener("click", function () {
toggleBtn.addEventListener("click", function (event) {
event.preventDefault();
active = !active;
toggleBtn.setAttribute("data-active", active ? "1" : "0");
hiddenInput.value = active ? "1" : "0";

View File

@@ -0,0 +1,18 @@
<div class="card bg-secondary bg-opacity-10 text-white mb-4 admin-shortcuts">
<div class="card-body p-2">
<div class="d-md-none mb-2">
<button type="button" class="btn btn-outline-light w-100" data-admin-nav-toggle aria-expanded="false">☰ Menu admina</button>
</div>
<div class="d-flex flex-wrap gap-2" data-admin-nav-body>
<a href="{{ url_for('admin_panel') }}" class="btn btn-sm {% if request.endpoint == 'admin_panel' %}btn-success{% else %}btn-outline-light{% endif %}">📊 Dashboard</a>
<a href="{{ url_for('list_users') }}" class="btn btn-sm {% if request.endpoint == 'list_users' %}btn-success{% else %}btn-outline-light{% endif %}">👥 Użytkownicy</a>
<a href="{{ url_for('admin_receipts') }}" class="btn btn-sm {% if request.endpoint == 'admin_receipts' %}btn-success{% else %}btn-outline-light{% endif %}">📸 Paragony</a>
<a href="{{ url_for('list_products') }}" class="btn btn-sm {% if request.endpoint == 'list_products' %}btn-success{% else %}btn-outline-light{% endif %}">🛍️ Produkty</a>
<a href="{{ url_for('admin_edit_categories') }}" class="btn btn-sm {% if request.endpoint == 'admin_edit_categories' %}btn-success{% else %}btn-outline-light{% endif %}">🗂 Kategorie</a>
<a href="{{ url_for('admin_lists_access') }}" class="btn btn-sm {% if request.endpoint == 'admin_lists_access' %}btn-success{% else %}btn-outline-light{% endif %}">🔐 Uprawnienia</a>
<a href="{{ url_for('admin_api_tokens') }}" class="btn btn-sm {% if request.endpoint in ['admin_api_tokens', 'admin_api_docs'] %}btn-success{% else %}btn-outline-light{% endif %}">🔑 Tokeny API</a>
<a href="{{ url_for('admin_templates') }}" class="btn btn-sm {% if request.endpoint == 'admin_templates' %}btn-success{% else %}btn-outline-light{% endif %}">🧩 Szablony</a>
<a href="{{ url_for('admin_settings') }}" class="btn btn-sm {% if request.endpoint == 'admin_settings' %}btn-success{% else %}btn-outline-light{% endif %}">⚙️ Ustawienia</a>
</div>
</div>
</div>

View File

@@ -2,7 +2,7 @@
{% block title %}Panel administratora{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div class="admin-page-head mb-4">
<div>
<h2 class="mb-2">⚙️ Panel administratora</h2>
<p class="text-secondary mb-0">Wgląd w użytkowników, listy, paragony, wydatki i ustawienia aplikacji.</p>
@@ -10,18 +10,8 @@
<a href="{{ url_for('main_page') }}" class="btn btn-outline-secondary">← Powrót do strony głównej</a>
</div>
<div class="card bg-secondary bg-opacity-10 text-white mb-4">
<div class="card-body p-2">
<div class="d-flex flex-wrap gap-2">
<a href="{{ url_for('list_users') }}" class="btn btn-outline-light btn-sm">👥 Użytkownicy</a>
<a href="{{ url_for('admin_receipts') }}" class="btn btn-outline-light btn-sm">📸 Paragony</a>
<a href="{{ url_for('list_products') }}" class="btn btn-outline-light btn-sm">🛍️ Produkty</a>
<a href="{{ url_for('admin_edit_categories') }}" class="btn btn-outline-light btn-sm">🗂 Kategorie</a>
<a href="{{ url_for('admin_lists_access') }}" class="btn btn-outline-light btn-sm">🔐 Uprawnienia</a>
<a href="{{ url_for('admin_settings') }}" class="btn btn-outline-light btn-sm">⚙️ Ustawienia</a>
</div>
</div>
</div>
{% include 'admin/_nav.html' %}
<div class="row g-3 mb-4">
<!-- Statystyki liczbowe -->
@@ -161,7 +151,7 @@
</div>
</div>
<div class="card bg-dark text-white mb-5">
{% if expiring_lists %}<div class="alert alert-warning mb-4"><div class="fw-semibold mb-2">⏰ Listy tymczasowe wygasające w ciągu 24h</div><ul class="mb-0 ps-3">{% for l in expiring_lists %}<li>#{{ l.id }} {{ l.title }} — {{ l.owner.username if l.owner else '—' }} — {{ l.expires_at.strftime('%Y-%m-%d %H:%M') }}</li>{% endfor %}</ul></div>{% endif %}<div class="card bg-dark text-white mb-5">
<div class="card-body">
{# panel wyboru miesiąca zawsze widoczny #}
@@ -246,10 +236,10 @@
{% for e in enriched_lists %}
{% set l = e.list %}
<tr>
<td><input type="checkbox" name="list_ids" value="{{ l.id }}"></td>
<td><input type="checkbox" name="list_ids" value="{{ l.id }}" class="table-select-checkbox"></td>
<td>{{ l.id }}</td>
<td class="fw-bold align-middle">
<a href="{{ url_for('view_list', list_id=l.id) }}" class="text-white">{{ l.title }}</a>
<a href="{{ url_for('view_list', list_id=l.id) }}" class="text-white">{{ l.title }}</a>{% if l.is_temporary and l.expires_at %}<div class="small text-warning mt-1">wygasa: {{ l.expires_at.strftime('%Y-%m-%d %H:%M') }}</div>{% endif %}
{% if l.categories %}
<span class="ms-1 text-info" data-bs-toggle="tooltip"
title="{{ l.categories | map(attribute='name') | join(', ') }}">
@@ -298,13 +288,9 @@
{% endif %}
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<a href="{{ url_for('edit_list', list_id=l.id) }}" class="btn btn-sm btn-outline-light"
title="Edytuj"></a>
<button type="button" class="btn btn-sm btn-outline-light preview-btn" data-list-id="{{ l.id }}"
title="Podgląd produktów">
👁️
</button>
<div class="d-flex flex-wrap gap-2">
<a href="{{ url_for('edit_list', list_id=l.id) }}" class="btn btn-sm btn-outline-light" title="Edytuj">✏️</a>
<button type="button" class="btn btn-sm btn-outline-light preview-btn" data-list-id="{{ l.id }}" title="Podgląd produktów">👁</button>
</div>
</td>
</tr>

View File

@@ -0,0 +1,161 @@
{% extends 'base.html' %}
{% block title %}Tokeny API{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4 gap-2">
<div>
<h2 class="mb-2">🔑 Tokeny API</h2>
<p class="text-secondary mb-0">Administrator może utworzyć wiele tokenów, ograniczyć ich zakres i endpointy oraz w każdej chwili je wyłączyć albo usunąć.</p>
</div>
<div class="d-flex gap-2 flex-wrap">
<a href="{{ url_for('admin_api_docs') }}" class="btn btn-outline-light" target="_blank">📄 Zobacz opis API</a>
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-secondary">← Powrót do panelu</a>
</div>
</div>
{% include 'admin/_nav.html' %}
{% if latest_plain_token %}
<div class="alert alert-success border-success mb-4" role="alert">
<div class="d-flex flex-column gap-2">
<div><strong>Nowy token:</strong> {{ latest_api_token_name or 'API' }}</div>
<div class="input-group">
<input type="text" id="latestApiToken" class="form-control" readonly value="{{ latest_plain_token }}">
<button type="button" class="btn btn-outline-light" data-copy-target="#latestApiToken">📋 Kopiuj</button>
</div>
<div class="small text-warning">Pełna wartość jest widoczna tylko teraz. Po odświeżeniu zostanie ukryta.</div>
</div>
</div>
{% endif %}
<div class="card bg-dark text-white">
<div class="card-body">
<h5 class="mb-3"> Utwórz token</h5>
<form method="post" data-unsaved-warning="true" class="stack-form">
<input type="hidden" name="action" value="create">
<div class="row g-4">
<div class="col-xl-4">
<label for="name" class="form-label">Nazwa tokenu</label>
<input type="text" id="name" name="name" class="form-control" placeholder="np. integracja ERP / Power BI" required>
<div class="form-text">Nazwij token tak, aby było wiadomo do czego służy.</div>
</div>
<div class="col-sm-6 col-xl-4">
<label class="form-label d-block">Zakresy</label>
<div class="form-check form-check-spaced"><input class="form-check-input" type="checkbox" id="scope_expenses_read" name="scope_expenses_read" checked><label class="form-check-label" for="scope_expenses_read">Odczyt wydatków</label></div>
<div class="form-check form-check-spaced"><input class="form-check-input" type="checkbox" id="scope_lists_read" name="scope_lists_read" checked><label class="form-check-label" for="scope_lists_read">Odczyt list i wydatków list</label></div>
<div class="form-check form-check-spaced"><input class="form-check-input" type="checkbox" id="scope_templates_read" name="scope_templates_read"><label class="form-check-label" for="scope_templates_read">Odczyt szablonów</label></div>
</div>
<div class="col-sm-6 col-xl-4">
<label class="form-label d-block">Dozwolone endpointy</label>
<div class="form-check form-check-spaced"><input class="form-check-input" type="checkbox" id="allow_ping" name="allow_ping" checked><label class="form-check-label" for="allow_ping">/api/ping</label></div>
<div class="form-check form-check-spaced"><input class="form-check-input" type="checkbox" id="allow_latest_expenses" name="allow_latest_expenses" checked><label class="form-check-label" for="allow_latest_expenses">/api/expenses/latest</label></div>
<div class="form-check form-check-spaced"><input class="form-check-input" type="checkbox" id="allow_expenses_summary" name="allow_expenses_summary" checked><label class="form-check-label" for="allow_expenses_summary">/api/expenses/summary</label></div>
<div class="form-check form-check-spaced"><input class="form-check-input" type="checkbox" id="allow_lists" name="allow_lists" checked><label class="form-check-label" for="allow_lists">/api/lists oraz /api/lists/&lt;id&gt;/expenses</label></div>
<div class="form-check form-check-spaced"><input class="form-check-input" type="checkbox" id="allow_templates" name="allow_templates"><label class="form-check-label" for="allow_templates">/api/templates</label></div>
</div>
<div class="col-sm-6 col-xl-3">
<label for="max_limit" class="form-label">Maksymalny limit rekordów</label>
<input type="number" id="max_limit" name="max_limit" min="1" max="500" value="100" class="form-control">
</div>
<div class="col-sm-6 col-xl-3 d-flex align-items-end">
<button type="submit" class="btn btn-success w-100">🔑 Wygeneruj token</button>
</div>
</div>
</form>
</div>
</div>
<div class="card bg-dark text-white mt-4">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
<h5 class="mb-0">📘 Dokumentacja API</h5>
<a href="{{ url_for('admin_api_docs') }}" class="btn btn-sm btn-outline-light" target="_blank">Otwórz TXT</a>
</div>
<div class="small text-secondary mb-3">Autoryzacja: <code>Authorization: Bearer TWOJ_TOKEN</code> lub <code>X-API-Token</code>. Endpoint i zakres muszą być jednocześnie dozwolone na tokenie. Parametr <code>limit</code> jest przycinany do wartości ustawionej w tokenie.</div>
<div class="table-responsive admin-table-responsive admin-table-responsive--full">
<table class="table table-dark align-middle table-sm keep-horizontal">
<thead>
<tr><th>Metoda</th><th>Endpoint</th><th>Wymagany zakres</th><th>Opis</th></tr>
</thead>
<tbody>
{% for row in api_examples %}
<tr>
<td><code>{{ row.method }}</code></td>
<td><code class="api-chip api-chip--wrap">{{ row.path }}</code></td>
<td><code class="api-chip">{{ row.scope }}</code></td>
<td>{{ row.description }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="card bg-dark text-white mt-4">
<div class="card-body">
<div class="admin-page-head mb-3">
<h5 class="mb-0">📋 Aktywne i historyczne tokeny</h5>
<span class="badge rounded-pill bg-secondary">{{ api_tokens|length }} szt.</span>
</div>
<div class="table-responsive admin-table-responsive admin-table-responsive--wide">
<table class="table table-dark align-middle sortable keep-horizontal" data-searchable="true">
<thead>
<tr>
<th>Nazwa</th>
<th>Prefix</th>
<th>Status</th>
<th>Zakres</th>
<th>Endpointy</th>
<th>Max limit</th>
<th>Utworzono</th>
<th>Ostatnie użycie</th>
<th>Akcje</th>
</tr>
</thead>
<tbody>
{% for token in api_tokens %}
<tr>
<td>
<div class="fw-semibold text-break">{{ token.name }}</div>
<div class="small text-secondary">Autor: {{ token.creator.username if token.creator else '—' }}</div>
</td>
<td><code class="api-chip">{{ token.token_prefix }}…</code></td>
<td>{% if token.is_active %}<span class="badge rounded-pill bg-success">Aktywny</span>{% else %}<span class="badge rounded-pill bg-secondary">Wyłączony</span>{% endif %}</td>
<td><code class="api-chip api-chip--wrap">{{ token.scopes or '—' }}</code></td>
<td><code class="api-chip api-chip--wrap">{{ token.allowed_endpoints or '—' }}</code></td>
<td>{{ token.max_limit or '—' }}</td>
<td>{{ token.created_at.strftime('%Y-%m-%d %H:%M') if token.created_at else '—' }}</td>
<td>{{ token.last_used_at.strftime('%Y-%m-%d %H:%M') if token.last_used_at else 'Jeszcze nie użyto' }}</td>
<td>
<div class="d-flex flex-wrap gap-2">
{% if token.is_active %}
<form method="post" class="d-inline">
<input type="hidden" name="action" value="deactivate">
<input type="hidden" name="token_id" value="{{ token.id }}">
<button type="submit" class="btn btn-sm btn-outline-warning">⏸ Wyłącz</button>
</form>
{% else %}
<form method="post" class="d-inline">
<input type="hidden" name="action" value="activate">
<input type="hidden" name="token_id" value="{{ token.id }}">
<button type="submit" class="btn btn-sm btn-outline-success">▶ Włącz</button>
</form>
{% endif %}
<form method="post" class="d-inline" onsubmit="return confirm('Usunąć ten token API?')">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="token_id" value="{{ token.id }}">
<button type="submit" class="btn btn-sm btn-outline-danger">🗑 Usuń</button>
</form>
</div>
</td>
</tr>
{% else %}
<tr><td colspan="9" class="text-center text-secondary py-4">Brak tokenów API.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@@ -9,6 +9,8 @@
</div>
</div>
{% include 'admin/_nav.html' %}
<div class="card bg-dark text-white mb-5">
<div class="card-body">
<div class="alert alert-warning border-warning text-dark" role="alert">
@@ -16,10 +18,10 @@
wydatków.
</div>
<form method="post" id="mass-edit-form">
<form method="post" id="mass-edit-form" data-unsaved-warning="true">
<div class="card bg-dark text-white mb-4">
<div class="card-body p-0">
<div class="table-responsive">
<div class="table-responsive admin-table-responsive admin-table-responsive--full">
<table class="table table-dark align-middle sortable mb-0">
<thead class="position-sticky top-0 bg-dark">
<tr>
@@ -88,8 +90,7 @@
</div>
</div>
{# Fallback ukryty przez JS #}
<button type="submit" class="btn btn-sm btn-outline-light" id="fallback-save-btn">💾 Zapisz zmiany</button>
<div class="d-flex justify-content-end mt-3"><button type="submit" class="btn btn-sm btn-outline-light">💾 Zapisz wszystkie zmiany</button></div>
</form>
</div>
</div>
@@ -147,5 +148,4 @@
{% block scripts %}
<script src="{{ url_for('static_bp.serve_js', filename='preview_list_modal.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='categories_select_admin.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='categories_autosave.js') }}?v={{ APP_VERSION }}"></script>
{% endblock %}

View File

@@ -7,10 +7,12 @@
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-secondary">← Powrót do panelu</a>
</div>
{% include 'admin/_nav.html' %}
<div class="card bg-secondary bg-opacity-10 text-white mb-5">
<div class="card-body">
<h4 class="card-title">📄 Podstawowe informacje</h4>
<form method="post" class="mt-3">
<form method="post" class="mt-3" data-unsaved-warning="true">
<input type="hidden" name="action" value="save">
<!-- Nazwa listy -->
@@ -43,20 +45,20 @@
<!-- Statusy -->
<div class="mb-4">
<label class="form-label">⚙️ Statusy listy</label>
<div class="d-flex flex-wrap gap-3">
<div class="form-check form-switch">
<div class="switch-grid">
<div class="form-check form-switch app-switch">
<input class="form-check-input" type="checkbox" id="archived" name="archived" {% if list.is_archived
%}checked{% endif %}>
<label class="form-check-label" for="archived">📦 Archiwalna</label>
</div>
<div class="form-check form-switch">
<div class="form-check form-switch app-switch">
<input class="form-check-input" type="checkbox" id="public" name="public" {% if list.is_public %}checked{%
endif %}>
<label class="form-check-label" for="public">🌐 Publiczna</label>
</div>
<div class="form-check form-switch">
<div class="form-check form-switch app-switch">
<input class="form-check-input" type="checkbox" id="temporary" name="temporary" {% if list.is_temporary
%}checked{% endif %}>
<label class="form-check-label" for="temporary">⏳ Tymczasowa (podaj date i godzine wygasania)</label>

View File

@@ -7,6 +7,8 @@
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-secondary">← Powrót do panelu</a>
</div>
{% include 'admin/_nav.html' %}
<div class="card bg-secondary bg-opacity-10 text-white mb-4">
<div class="card-body">
@@ -40,7 +42,7 @@
<span class="badge rounded-pill bg-info">{{ total_items }} produktów</span>
</div>
<div class="card-body p-0">
<table class="table table-dark align-middle sortable">
<table class="table table-dark align-middle sortable keep-horizontal">
<thead>
<tr>
<th>ID</th>
@@ -99,7 +101,7 @@
</div>
<div class="card-body p-0">
{% set item_names = items | map(attribute='name') | map('lower') | list %}
<table class="table table-dark align-middle sortable">
<table class="table table-dark align-middle sortable keep-horizontal">
<thead>
<tr>
<th>ID</th>

View File

@@ -3,8 +3,7 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center flex-wrap mb-3">
<h2 class="mb-2">🔐{% if list_id %} Zarządzanie dostępem listy #{{ list_id }}{% else %} Zarządzanie dostępem do list
{% endif %}</h2>
<h2 class="mb-2">🔐{% if list_id %} Dostęp do listy #{{ list_id }}{% else %} Zarządzanie dostępem do list{% endif %}</h2>
<div class="d-flex gap-2">
{% if list_id %}
<a href="{{ url_for('admin_lists_access') }}" class="btn btn-outline-light">Powrót do wszystkich list</a>
@@ -13,12 +12,14 @@
</div>
</div>
{% include 'admin/_nav.html' %}
<!-- STICKY ACTION BAR -->
<div id="bulkBar" class="position-sticky top-0 z-3 mb-3" style="backdrop-filter: blur(6px);">
<div class="card bg-dark border-secondary shadow-sm">
<div class="card-body py-2 d-flex flex-wrap align-items-center gap-3">
<div class="d-flex align-items-center gap-2">
<input id="selectAll" class="form-check-input" type="checkbox" />
<div class="d-flex align-items-center gap-2 w-100">
<input id="selectAll" class="form-check-input table-select-checkbox" type="checkbox" />
<label for="selectAll" class="form-check-label">Zaznacz wszystko</label>
</div>
@@ -36,7 +37,7 @@
<div class="flex-grow-1">
<div class="input-group input-group-sm">
<input id="bulkUsersInput" class="form-control bg-dark text-white border-secondary"
placeholder="Podaj użytkowników (po przecinku lub enterach)" list="userHints">
placeholder="Podaj użytkowników (po przecinku lub enterach)" list="userHints" autocomplete="off">
<button id="bulkAddBtn" class="btn btn-outline-light" type="button"> Nadaj dostęp</button>
</div>
<div id="bulkTokens" class="d-flex flex-wrap gap-2 mt-2"></div>
@@ -47,15 +48,14 @@
<!-- HINTS -->
<datalist id="userHints"></datalist>
<datalist id="userHints">
{% for username in all_usernames %}<option value="{{ username }}"></option>{% endfor %}
</datalist>
<div class="card bg-dark text-white mb-5">
<div class="card-body">
<form id="statusForm" method="post">
<input type="hidden" name="action" value="save_changes">
<div class="table-responsive">
<table class="table table-dark align-middle sortable" id="listsTable">
<div class="table-responsive admin-table-responsive admin-table-responsive--wide">
<table class="table table-dark align-middle sortable lists-access-table" id="listsTable">
<thead class="align-middle">
<tr>
<th scope="col" style="width:36px;"></th>
@@ -63,9 +63,8 @@
<th scope="col">Nazwa listy</th>
<th scope="col">Właściciel</th>
<th scope="col">Utworzono</th>
<th scope="col">Statusy</th>
<th scope="col">Udostępnianie</th>
<th scope="col" style="min-width: 340px;">Uprawnienia</th>
<th scope="col" style="min-width: 420px;">Uprawnienia</th>
</tr>
</thead>
<tbody>
@@ -73,8 +72,7 @@
<tr data-id="{{ l.id }}" data-title="{{ l.title|lower }}"
data-owner="{{ (l.owner.username if l.owner else '-')|lower }}">
<td>
<input class="row-check form-check-input" type="checkbox" data-list-id="{{ l.id }}">
<input type="hidden" name="visible_ids" value="{{ l.id }}">
<input class="row-check form-check-input table-select-checkbox" type="checkbox" data-list-id="{{ l.id }}">
</td>
<td class="text-nowrap">{{ l.id }}</td>
@@ -92,29 +90,10 @@
</td>
<td class="text-nowrap">{{ l.created_at.strftime('%Y-%m-%d %H:%M') if l.created_at else '-' }}</td>
<td style="min-width: 230px;">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="pub_{{ l.id }}" name="is_public_{{ l.id }}" {% if
l.is_public %}checked{% endif %}>
<label class="form-check-label" for="pub_{{ l.id }}">🌐 Publiczna</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="tmp_{{ l.id }}" name="is_temporary_{{ l.id }}" {%
if l.is_temporary %}checked{% endif %}>
<label class="form-check-label" for="tmp_{{ l.id }}">⏳ Tymczasowa</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="arc_{{ l.id }}" name="is_archived_{{ l.id }}" {%
if l.is_archived %}checked{% endif %}>
<label class="form-check-label" for="arc_{{ l.id }}">📦 Archiwalna</label>
</div>
</td>
<td style="min-width: 260px;">
{% if l.share_token %}
{% set share_url = url_for('shared_list', token=l.share_token, _external=True) %}
<div class="d-flex align-items-center gap-2">
<div class="d-flex align-items-center gap-2 w-100">
<div class="flex-grow-1 text-truncate mono small" title="{{ share_url }}">{{ share_url }}</div>
<button class="btn btn-sm btn-outline-secondary copy-share" type="button" data-url="{{ share_url }}"
aria-label="Kopiuj link">📋</button>
@@ -123,12 +102,12 @@
{% if l.is_public %}Lista widoczna publicznie{% else %}Dostęp przez link / uprawnienia{% endif %}
</div>
{% else %}
<div class="text-warning small">Brak tokenu</div>
<div class="text-warning small w-100 d-block">Brak tokenu</div>
{% endif %}
</td>
<td>
<div class="access-editor" data-list-id="{{ l.id }}">
<div class="access-editor" data-list-id="{{ l.id }}" data-post-url="{{ request.path }}{{ ('?page=' ~ page ~ "&per_page=" ~ per_page) if not list_id else "" }}" data-suggest-url="{{ url_for('admin_user_suggestions') }}" data-next="{{ request.full_path if request.query_string else request.path }}">
<!-- Tokeny z uprawnieniami -->
<div class="d-flex flex-wrap gap-2 mb-2 tokens">
{% for u in permitted_by_list.get(l.id, []) %}
@@ -146,11 +125,11 @@
<div class="input-group input-group-sm">
<input type="text"
class="form-control form-control-sm bg-dark text-white border-secondary access-input"
placeholder="Dodaj @użytkownika (wiele: przecinki/enter)" list="userHints"
placeholder="Dodaj użytkownika (wiele: przecinki/enter)" list="userHints" autocomplete="off"
aria-label="Dodaj użytkowników">
<button type="button" class="btn btn-sm btn-outline-light access-add"> Dodaj</button>
<button type="button" class="btn btn-sm btn-outline-light access-add">💾 Zapisz dostęp</button>
</div>
<div class="text-secondary small mt-1">Kliknij token, aby odebrać dostęp.</div>
<div class="text-secondary small mt-1">Kliknij token, aby odebrać dostęp. Zmiana zapisuje się od razu.</div>
</div>
</td>
@@ -158,17 +137,13 @@
{% endfor %}
{% if lists|length == 0 %}
<tr>
<td colspan="8" class="text-center py-4">Brak list do wyświetlenia</td>
<td colspan="7" class="text-center py-4">Brak list do wyświetlenia</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
<div class="mt-3 d-flex justify-content-end">
<button type="submit" class="btn btn-sm btn-outline-light">💾 Zapisz zmiany statusów</button>
</div>
</form>
</div>
</div>
@@ -206,6 +181,7 @@
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static_bp.serve_js', filename='access_users.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='lists_access.js') }}?v={{ APP_VERSION }}"></script>
{% endblock %}

View File

@@ -46,6 +46,8 @@
</div>
</div>
{% include 'admin/_nav.html' %}
<div class="card bg-secondary bg-opacity-10 text-white mb-5">
<div class="card-body">
<div class="row g-3">

View File

@@ -7,7 +7,9 @@
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-secondary">← Powrót do panelu</a>
</div>
<form method="post" id="settings-form">
{% include 'admin/_nav.html' %}
<form method="post" id="settings-form" data-unsaved-warning="true">
<div class="card bg-dark text-white mb-4">
<div class="card-header border-0">
<strong>🔎 OCR — słowa kluczowe i czułość</strong>

View File

@@ -0,0 +1,64 @@
{% extends 'base.html' %}
{% block title %}Szablony list{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4 gap-2">
<div>
<h2 class="mb-2">🧩 Szablony list</h2>
<p class="text-secondary mb-0">Szablony są niezależne od zwykłych list i mogą służyć do szybkiego tworzenia nowych list.</p>
</div>
</div>
{% include 'admin/_nav.html' %}
<div class="row g-4">
<div class="col-lg-5">
<div class="card bg-dark text-white mb-4"><div class="card-body">
<h5 class="mb-3"> Nowy szablon ręcznie</h5>
<form method="post" class="stack-form">
<input type="hidden" name="action" value="create_manual">
<div class="mb-3"><label class="form-label">Nazwa</label><input type="text" name="name" class="form-control" required></div>
<div class="mb-3"><label class="form-label">Opis</label><textarea name="description" class="form-control" rows="2"></textarea></div>
<div class="mb-3"><label class="form-label">Produkty</label><textarea name="items_text" class="form-control" rows="8" placeholder="Mleko x2&#10;Chleb&#10;Jajka x10"></textarea><div class="form-text">Każdy produkt w osobnej linii. Ilość opcjonalnie po „x”.</div></div>
<button type="submit" class="btn btn-success w-100">Utwórz szablon</button>
</form>
</div></div>
<div class="card bg-dark text-white"><div class="card-body">
<h5 class="mb-3">📋 Utwórz z istniejącej listy</h5>
<form method="post" class="stack-form">
<input type="hidden" name="action" value="create_from_list">
<div class="mb-3"><label class="form-label">Lista źródłowa</label><select name="source_list_id" class="form-select" required>{% for l in source_lists %}<option value="{{ l.id }}">#{{ l.id }} — {{ l.title }}</option>{% endfor %}</select></div>
<div class="mb-3"><label class="form-label">Nazwa szablonu</label><input type="text" name="template_name" class="form-control"></div>
<div class="mb-3"><label class="form-label">Opis</label><textarea name="description" class="form-control" rows="2"></textarea></div>
<button type="submit" class="btn btn-outline-light w-100">Utwórz z listy</button>
</form>
</div></div>
</div>
<div class="col-lg-7">
<div class="card bg-dark text-white"><div class="card-body">
<div class="admin-page-head mb-3"><h5 class="mb-0">Wszystkie szablony użytkowników</h5><span class="badge rounded-pill bg-secondary">{{ templates|length }} szt.</span></div>
<div class="table-responsive">
<table class="table table-dark align-middle keep-horizontal">
<thead><tr><th>Nazwa</th><th>Produkty</th><th>Status</th><th>Autor</th><th>Akcje</th></tr></thead>
<tbody>
{% for template in templates %}
<tr>
<td><div class="fw-semibold">{{ template.name }}</div><div class="small text-secondary">{{ template.description or 'Bez opisu' }}</div></td>
<td>{{ template.items|length }}</td>
<td>{% if template.is_active %}<span class="badge rounded-pill bg-success">Aktywny</span>{% else %}<span class="badge rounded-pill bg-secondary">Wyłączony</span>{% endif %}</td>
<td>{{ template.creator.username if template.creator else '—' }}</td>
<td>
<div class="d-flex flex-wrap gap-2">
<form method="post"><input type="hidden" name="action" value="instantiate"><input type="hidden" name="template_id" value="{{ template.id }}"><button class="btn btn-sm btn-outline-primary" type="submit"> Utwórz listę</button></form>
<form method="post"><input type="hidden" name="action" value="toggle"><input type="hidden" name="template_id" value="{{ template.id }}"><button class="btn btn-sm btn-outline-warning" type="submit">{% if template.is_active %}⏸ Wyłącz{% else %}▶ Włącz{% endif %}</button></form>
<form method="post" onsubmit="return confirm('Usunąć szablon?')"><input type="hidden" name="action" value="delete"><input type="hidden" name="template_id" value="{{ template.id }}"><button class="btn btn-sm btn-outline-danger" type="submit">🗑 Usuń</button></form>
</div>
</td>
</tr>
{% else %}
<tr><td colspan="5" class="text-center text-secondary py-4">Brak szablonów użytkowników.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div></div>
</div>
</div>
{% endblock %}

View File

@@ -7,6 +7,8 @@
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-secondary">← Powrót do panelu</a>
</div>
{% include 'admin/_nav.html' %}
<!-- Formularz dodawania nowego użytkownika -->
<div class="card bg-secondary bg-opacity-10 text-white mb-4">
<div class="card-body">
@@ -34,7 +36,7 @@
<div class="card bg-dark text-white mb-5">
<div class="card-body">
<table class="table table-dark align-middle sortable">
<table class="table table-dark align-middle sortable keep-horizontal">
<thead>
<tr>
<th>ID</th>

View File

@@ -28,7 +28,7 @@
{% endif %}
</head>
<body class="app-body endpoint-{{ (request.endpoint or 'unknown')|replace('.', '-') }}{% if current_user.is_authenticated %} is-authenticated{% endif %}">
<body class="app-body endpoint-{{ (request.endpoint or 'unknown')|replace('.', '-') }}{% if current_user.is_authenticated %} is-authenticated{% endif %}{% if request.path.startswith('/admin') %} is-admin-area{% endif %}">
<div class="app-backdrop"></div>
<header class="app-header sticky-top">
@@ -58,18 +58,25 @@
{% endif %}
</div>
<div class="app-navbar__actions order-lg-3">
{% if not is_blocked and request.endpoint and request.endpoint != 'system_auth' %}
{% if current_user.is_authenticated %}
{% if current_user.is_admin %}
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-light btn-sm">⚙️ <span class="d-none d-sm-inline">Panel</span></a>
<button class="navbar-toggler app-navbar-toggler d-lg-none" type="button" data-bs-toggle="collapse" data-bs-target="#appNavbarMenu" aria-controls="appNavbarMenu" aria-expanded="false" aria-label="Przełącz nawigację">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse justify-content-end order-4 order-lg-3" id="appNavbarMenu">
<div class="app-navbar__actions">
{% if not is_blocked and request.endpoint and request.endpoint != 'system_auth' %}
{% if current_user.is_authenticated %}
{% if current_user.is_admin %}
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-light btn-sm app-nav-action">⚙️ <span>Panel</span></a>
{% endif %}
<a href="{{ url_for('expenses') }}" class="btn btn-outline-light btn-sm app-nav-action">📊 <span>Wydatki</span></a>
<a href="{{ url_for('my_templates') }}" class="btn btn-outline-light btn-sm app-nav-action">🧩 <span>Szablony</span></a>
<a href="{{ url_for('logout') }}" class="btn btn-outline-light btn-sm app-nav-action">🚪 <span>Wyloguj</span></a>
{% else %}
<a href="{{ url_for('login') }}" class="btn btn-success btn-sm app-nav-action">🔑 <span>Zaloguj</span></a>
{% endif %}
<a href="{{ url_for('expenses') }}" class="btn btn-outline-light btn-sm">📊 <span class="d-none d-sm-inline">Wydatki</span></a>
<a href="{{ url_for('logout') }}" class="btn btn-outline-light btn-sm">🚪 <span class="d-none d-sm-inline">Wyloguj</span></a>
{% else %}
<a href="{{ url_for('login') }}" class="btn btn-success btn-sm">🔑 <span class="d-none d-sm-inline">Zaloguj</span></a>
{% endif %}
{% endif %}
</div>
</div>
</div>
</nav>
@@ -133,6 +140,7 @@
{% endif %}
<script src="{{ url_for('static_bp.serve_js', filename='toasts.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='app_ui.js') }}?v={{ APP_VERSION }}"></script>
<script>
if (typeof GLightbox === 'function') {
let lightbox = GLightbox({ selector: '.glightbox' });

View File

@@ -11,7 +11,7 @@
<div class="card bg-secondary bg-opacity-10 text-white mb-5">
<div class="card-body">
<form method="post">
<form method="post" data-unsaved-warning="true" class="stack-form">
<!-- Nazwa listy -->
<div class="mb-3">
@@ -23,20 +23,20 @@
<!-- Statusy listy -->
<div class="mb-4">
<label class="form-label">⚙️ Statusy listy</label>
<div class="d-flex flex-wrap gap-3">
<div class="form-check form-switch">
<div class="switch-grid">
<div class="form-check form-switch form-check-spaced form-switch-compact app-switch">
<input class="form-check-input" type="checkbox" id="public" name="is_public" {% if list.is_public
%}checked{% endif %}>
<label class="form-check-label" for="public">🌐 Publiczna (czyli mogą zobaczyć goście)</label>
</div>
<div class="form-check form-switch">
<div class="form-check form-switch form-check-spaced form-switch-compact app-switch">
<input class="form-check-input" type="checkbox" id="temporary" name="is_temporary" {% if list.is_temporary
%}checked{% endif %}>
<label class="form-check-label" for="temporary">⏳ Tymczasowa (ustaw date wygasania)</label>
</div>
<div class="form-check form-switch">
<div class="form-check form-switch form-check-spaced form-switch-compact app-switch">
<input class="form-check-input" type="checkbox" id="archived" name="is_archived" {% if list.is_archived
%}checked{% endif %}>
<label class="form-check-label" for="archived">📦 Archiwalna</label>
@@ -98,6 +98,10 @@
</div>
<!-- DOSTĘP DO LISTY -->
<datalist id="userHintsOwner">
{% for username in all_usernames %}<option value="{{ username }}"></option>{% endfor %}
</datalist>
<div class="mb-3">
<label class="form-label">👥 Użytkownicy z dostępem</label>
@@ -121,10 +125,10 @@
<!-- Dodawanie (wiele: przecinki/enter) + prywatne podpowiedzi -->
<div class="input-group input-group-sm">
<input type="text" class="access-input form-control form-control-sm bg-dark text-white border-secondary"
placeholder="Dodaj @użytkownika (wiele: przecinki/enter)" aria-label="Dodaj użytkowników">
<button type="button" class="access-add btn btn-sm btn-outline-light"> Dodaj</button>
placeholder="Dodaj użytkownika (wiele: przecinki/enter)" list="userHintsOwner" autocomplete="off" aria-label="Dodaj użytkowników">
<button type="button" class="access-add btn btn-sm btn-outline-light">💾 Zapisz dostęp</button>
</div>
<div class="text-secondary small mt-1">Kliknij token, aby odebrać dostęp.</div>
<div class="text-secondary small mt-1">Kliknij token, aby odebrać dostęp. Możesz wpisać kilka loginów oddzielonych przecinkiem.</div>
</div>
</div>

View File

@@ -13,7 +13,7 @@
<div class="card bg-secondary bg-opacity-10 text-white mb-5">
<div class="card-body">
<div class="d-flex justify-content-center mb-3">
<div class="form-check form-switch">
<div class="form-check form-switch app-switch">
<input class="form-check-input" type="checkbox" id="showAllLists" {% if show_all %}checked{% endif %}>
<label class="form-check-label ms-2 text-white" for="showAllLists">
Uwzględnij listy udostępnione dla mnie i publiczne

View File

@@ -94,10 +94,9 @@
</div>
{% endif %}
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap">
<button id="sort-toggle-btn" class="btn btn-sm btn-outline-warning" onclick="toggleSortMode()">✳️ Zmień
kolejność</button>
<div class="form-check form-switch">
<div class="list-toolbar d-flex justify-content-between align-items-start mb-3 flex-wrap gap-2">
<button id="sort-toggle-btn" class="btn btn-sm btn-outline-warning list-toolbar__sort" onclick="toggleSortMode()">✳️ Zmień kolejność</button>
<div class="form-check form-switch form-check-spaced app-switch hide-purchased-switch hide-purchased-switch--minimal">
<input class="form-check-input" type="checkbox" id="hidePurchasedToggle">
<label class="form-check-label ms-2" for="hidePurchasedToggle">Ukryj zaznaczone</label>
</div>
@@ -106,37 +105,29 @@
<ul id="items" class="list-group mb-3" data-is-share="{{ 'true' if is_share else 'false' }}">
{% for item in items %}
<li data-name="{{ item.name|lower }}" id="item-{{ item.id }}"
class="list-group-item d-flex justify-content-between align-items-center flex-wrap clickable-item
class="list-group-item shopping-item-row clickable-item
{% if item.purchased %}bg-success text-white{% elif item.not_purchased %}bg-warning text-dark{% else %}item-not-checked{% endif %}"
data-is-share="{{ 'true' if is_share else 'false' }}">
<div class="d-flex align-items-center gap-2 flex-grow-1">
<div class="shopping-item-main">
<input id="checkbox-{{ item.id }}" class="large-checkbox" type="checkbox" {% if item.purchased %}checked{% endif
%} {% if list.is_archived or item.not_purchased %}disabled{% endif %}>
<span id="name-{{ item.id }}" class="text-white">
{{ item.name }}
{% if item.quantity and item.quantity > 1 %}
<span class="badge rounded-pill bg-secondary">x{{ item.quantity }}</span>
{% endif %}
</span>
<div class="info-line ms-4 small d-flex flex-wrap gap-2" id="info-{{ item.id }}">
{% set info_parts = [] %}
{% if item.note %}{% set _ = info_parts.append('<span class="text-danger">[ <b>' ~ item.note ~ '</b> ]</span>')
%}{% endif %}
{% if item.not_purchased_reason %}{% set _ = info_parts.append('<span class="text-dark">[ <b>Powód: ' ~
item.not_purchased_reason ~ '</b> ]</span>') %}{% endif %}
{% if item.added_by_display %}{% set _ = info_parts.append('<span class="text-info">[ Dodał/a: <b>' ~
item.added_by_display ~ '</b> ]</span>') %}{% endif %}
{% if info_parts %}
<div class="info-line ms-4 small d-flex flex-wrap gap-2" id="info-{{ item.id }}">
{{ info_parts | join(' ') | safe }}
</div>
{% endif %}
</div>
</div>
<div class="btn-group btn-group-sm" role="group">
<div class="shopping-item-content">
<div class="shopping-item-head">
<div class="shopping-item-text">
<span id="name-{{ item.id }}" class="shopping-item-name text-white">{{ item.name }}</span>
{% if item.quantity and item.quantity > 1 %}
<span class="badge rounded-pill bg-secondary">x{{ item.quantity }}</span>
{% endif %}
{% set info_parts = [] %}
{% if item.note %}{% set _ = info_parts.append('<span class="text-danger">[ <b>' ~ item.note ~ '</b> ]</span>') %}{% endif %}
{% if item.not_purchased_reason %}{% set _ = info_parts.append('<span class="text-dark">[ <b>Powód: ' ~ item.not_purchased_reason ~ '</b> ]</span>') %}{% endif %}
{% if item.added_by_display %}{% set _ = info_parts.append('<span class="text-info">[ Dodał/a: <b>' ~ item.added_by_display ~ '</b> ]</span>') %}{% endif %}
{% if info_parts %}
<div class="info-line small d-flex flex-wrap gap-2" id="info-{{ item.id }}">{{ info_parts | join(' ') | safe }}</div>
{% endif %}
</div>
<div class="btn-group btn-group-sm list-item-actions shopping-item-actions" role="group">
{% if not is_share %}
<button type="button" class="btn btn-outline-light" {% if list.is_archived %}disabled{% else
%}onclick="editItem({{ item.id }}, '{{ item.name }}', {{ item.quantity or 1 }})" {% endif %}>✏️</button>
@@ -145,12 +136,15 @@
{% endif %}
{% if item.not_purchased %}
<button type="button" class="btn btn-outline-light me-auto" {% if list.is_archived %}disabled{% else
<button type="button" class="btn btn-outline-light" {% if list.is_archived %}disabled{% else
%}onclick="unmarkNotPurchased({{ item.id }})" {% endif %}>✅ Przywróć</button>
{% elif not item.not_purchased %}
<button type="button" class="btn btn-outline-light" {% if list.is_archived %}disabled{% else
%}onclick="markNotPurchasedModal(event, {{ item.id }})" {% endif %}>⚠️</button>
{% endif %}
</div>
</div>
</div>
</div>
</li>
{% else %}
@@ -160,20 +154,55 @@
</ul>
{% if not list.is_archived %}
<div class="list-action-block mb-3">
<div class="d-flex flex-wrap gap-2 mb-2">
<form method="post" action="{{ url_for('create_template_from_user_list', list_id=list.id) }}" class="d-inline">
<input type="hidden" name="template_name" value="{{ list.title }} - szablon">
<button type="submit" class="btn btn-outline-primary btn-sm">🧩 Zapisz jako szablon</button>
</form>
</div>
<div class="row g-2 mb-3">
<div class="col-12 col-md-2">
<button class="btn btn-outline-light w-45 h-45" data-bs-toggle="modal" data-bs-target="#massAddModal">
<div class="col-12 col-md-3 col-lg-2">
<button class="btn btn-outline-light w-100" data-bs-toggle="modal" data-bs-target="#massAddModal">
Dodaj produkty masowo
</button>
</div>
<div class="col-12 col-md-10">
<div class="input-group w-100">
<input type="text" id="newItem" name="name" class="form-control bg-dark text-white border-secondary"
placeholder="Dodaj produkt i ilość" required>
<input type="number" id="newQuantity" name="quantity" class="form-control bg-dark text-white border-secondary"
placeholder="Ilość" min="1" value="1" style="max-width: 90px;">
<button type="button" class="btn btn-outline-success rounded-end" onclick="addItem({{ list.id }})">
Dodaj</button>
<div class="col-12 col-md-9 col-lg-10">
<div class="input-group w-100 shopping-compact-input-group shopping-product-input-group">
<input type="text" id="newItem" name="name" class="form-control bg-dark text-white border-secondary shopping-product-name-input"
placeholder="Dodaj produkt" required>
<input type="number" id="newQuantity" name="quantity" class="form-control bg-dark text-white border-secondary shopping-qty-input"
placeholder="Ilość" min="1" value="1">
<button type="button" class="btn btn-outline-success shopping-compact-submit" onclick="addItem({{ list.id }})"> Dodaj</button>
</div>
</div>
</div>
{% endif %}
{% if activity_logs %}
<div class="card bg-dark text-white mb-4">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center gap-2 flex-wrap mb-2">
<h5 class="mb-0">🕘 Historia zmian listy</h5>
<button class="btn btn-sm btn-outline-light" type="button" data-bs-toggle="collapse" data-bs-target="#activityHistory" aria-expanded="false" aria-controls="activityHistory">Pokaż / ukryj</button>
</div>
<div class="small text-secondary mb-3">Domyślnie ukryte. Zdarzeń: {{ activity_logs|length }}</div>
<div class="collapse" id="activityHistory">
<div class="table-responsive">
<table class="table table-dark table-sm align-middle">
<thead><tr><th>Kiedy</th><th>Kto</th><th>Akcja</th><th>Produkt / szczegóły</th></tr></thead>
<tbody>
{% for log in activity_logs %}
<tr>
<td>{{ log.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td>{{ log.actor_name }}</td>
<td>{{ action_label(log.action) }}</td>
<td>{{ log.item_name or log.details or '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
@@ -256,6 +285,10 @@
</div>
<datalist id="userHintsOwner">
{% for username in all_usernames %}<option value="{{ username }}"></option>{% endfor %}
</datalist>
<!-- MODAL: NADAWANIE DOSTĘPU -->
<div class="modal fade" id="grantAccessModal" tabindex="-1" aria-labelledby="grantAccessModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
@@ -288,7 +321,7 @@
<!-- Dodawanie wielu na raz + podpowiedzi prywatne -->
<div class="input-group input-group-sm">
<input type="text" class="access-input form-control form-control-sm bg-dark text-white border-secondary"
placeholder="Dodaj @użytkownika (wiele: przecinki/enter)" aria-label="Dodaj użytkowników">
placeholder="Dodaj @użytkownika (wiele: przecinki/enter)" list="userHintsOwner" autocomplete="off" aria-label="Dodaj użytkowników">
<button type="button" class="access-add btn btn-sm btn-outline-light"> Dodaj</button>
</div>
<div class="text-secondary small mt-1">Kliknij token, aby odebrać dostęp.</div>

View File

@@ -32,71 +32,59 @@
</h2>
<div class="form-check form-switch mb-3 d-flex justify-content-end">
<input class="form-check-input" type="checkbox" id="hidePurchasedToggle">
<label class="form-check-label ms-2" for="hidePurchasedToggle">Ukryj zaznaczone</label>
<div class="list-toolbar list-toolbar--share d-flex justify-content-end align-items-start mb-3 gap-2 share-page-toolbar">
<div class="form-check form-switch form-check-spaced app-switch hide-purchased-switch hide-purchased-switch--minimal">
<input class="form-check-input" type="checkbox" id="hidePurchasedToggle">
<label class="form-check-label ms-2" for="hidePurchasedToggle">Ukryj zaznaczone</label>
</div>
</div>
<ul id="items" class="list-group mb-3" data-is-share="{{ 'true' if is_share else 'false' }}">
{% for item in items %}
<li data-name="{{ item.name|lower }}" id="item-{{ item.id }}"
class="list-group-item d-flex justify-content-between align-items-center flex-wrap clickable-item
class="list-group-item shopping-item-row clickable-item
{% if item.purchased %}bg-success text-white{% elif item.not_purchased %}bg-warning text-dark{% else %}item-not-checked{% endif %}">
<div class="d-flex align-items-center gap-2 flex-grow-1">
<div class="shopping-item-main">
<input id="checkbox-{{ item.id }}" class="large-checkbox" type="checkbox" {% if item.purchased %}checked{% endif
%} {% if list.is_archived or item.not_purchased %}disabled{% endif %}>
<span id="name-{{ item.id }}" class="text-white">
{{ item.name }}
{% if item.quantity and item.quantity > 1 %}
<span class="badge rounded-pill bg-secondary">x{{ item.quantity }}</span>
{% endif %}
</span>
<div class="info-line ms-4 small d-flex flex-wrap gap-2" id="info-{{ item.id }}">
{% set info_parts = [] %}
{% if item.note %}
{% set _ = info_parts.append('<span class="text-danger">[ <b>' ~ item.note ~ '</b> ]</span>') %}
{% endif %}
{% if item.not_purchased_reason %}
{% set _ = info_parts.append('<span class="text-dark">[ <b>Powód: ' ~ item.not_purchased_reason ~ '</b>
]</span>') %}
{% endif %}
{% if item.added_by_display %}
{% set _ = info_parts.append('<span class="text-info">[ Dodał/a: <b>' ~ item.added_by_display ~ '</b> ]</span>')
%}
{% endif %}
{% if info_parts %}
<div class="info-line ms-4 small d-flex flex-wrap gap-2" id="info-{{ item.id }}">
{{ info_parts | join(' ') | safe }}
</div>
{% endif %}
</div>
</div>
<div class="btn-group btn-group-sm" role="group">
<div class="shopping-item-content">
<div class="shopping-item-head">
<div class="shopping-item-text">
<span id="name-{{ item.id }}" class="shopping-item-name text-white">{{ item.name }}</span>
{% if item.quantity and item.quantity > 1 %}
<span class="badge rounded-pill bg-secondary">x{{ item.quantity }}</span>
{% endif %}
{% set info_parts = [] %}
{% if item.note %}{% set _ = info_parts.append('<span class="text-danger">[ <b>' ~ item.note ~ '</b> ]</span>') %}{% endif %}
{% if item.not_purchased_reason %}{% set _ = info_parts.append('<span class="text-dark">[ <b>Powód: ' ~ item.not_purchased_reason ~ '</b> ]</span>') %}{% endif %}
{% if item.added_by_display %}{% set _ = info_parts.append('<span class="text-info">[ Dodał/a: <b>' ~ item.added_by_display ~ '</b> ]</span>') %}{% endif %}
{% if info_parts %}
<div class="info-line small d-flex flex-wrap gap-2" id="info-{{ item.id }}">{{ info_parts | join(' ') | safe }}</div>
{% endif %}
</div>
<div class="d-flex align-items-center list-item-actions shopping-item-actions" role="group">
{% if item.not_purchased %}
<button type="button" class="btn btn-outline-light me-auto" {% if list.is_archived %}disabled{% else %}
<button type="button" class="btn btn-outline-light btn-sm" {% if list.is_archived %}disabled{% else %}
onclick="unmarkNotPurchased({{ item.id }})" {% endif %}>
✅ Przywróć
</button>
{% else %}
<button type="button" class="btn btn-outline-light" {% if list.is_archived %}disabled{% else %}
<button type="button" class="btn btn-outline-light btn-sm" {% if list.is_archived %}disabled{% else %}
onclick="markNotPurchasedModal(event, {{ item.id }})" {% endif %}>
⚠️
</button>
{% endif %}
<button type="button" class="btn btn-outline-light" {% if list.is_archived %}disabled{% else %}
<button type="button" class="btn btn-outline-light btn-sm" {% if list.is_archived %}disabled{% else %}
onclick="openNoteModal(event, {{ item.id }})" {% endif %}>
📝
</button>
</div>
</div>
</div>
</div>
</li>
{% else %}
<li id="empty-placeholder" class="list-group-item bg-dark text-secondary text-center w-100">
@@ -106,12 +94,12 @@
</ul>
{% if not list.is_archived %}
<div class="input-group mb-2">
<input id="newItem" class="form-control bg-dark text-white border-secondary" placeholder="Dodaj produkt i ilość" {% if
<div class="input-group mb-2 shopping-compact-input-group shopping-product-input-group">
<input id="newItem" class="form-control bg-dark text-white border-secondary shopping-product-name-input" placeholder="Dodaj produkt" {% if
not current_user.is_authenticated %}disabled{% endif %}>
<input id="newQuantity" type="number" class="form-control bg-dark text-white border-secondary" placeholder="Ilość"
min="1" value="1" style="max-width: 90px;" {% if not current_user.is_authenticated %}disabled{% endif %}>
<button onclick="addItem({{ list.id }})" class="btn btn-outline-success rounded-end" {% if not
<input id="newQuantity" type="number" class="form-control bg-dark text-white border-secondary shopping-qty-input" placeholder="Ilość"
min="1" value="1" {% if not current_user.is_authenticated %}disabled{% endif %}>
<button onclick="addItem({{ list.id }})" class="btn btn-outline-success share-submit-btn shopping-compact-submit" {% if not
current_user.is_authenticated %}disabled{% endif %}> Dodaj</button>
</div>
{% endif %}
@@ -119,10 +107,10 @@
{% if not list.is_archived %}
<hr>
<h5>💰 Dodaj wydatek</h5>
<div class="input-group mb-2">
<input id="expenseAmount" type="number" step="0.01" min="0" class="form-control bg-dark text-white border-secondary"
<div class="input-group mb-2 shopping-compact-input-group shopping-expense-input-group">
<input id="expenseAmount" type="number" step="0.01" min="0" class="form-control bg-dark text-white border-secondary shopping-expense-amount-input"
placeholder="Kwota (PLN)">
<button onclick="submitExpense({{ list.id }})" class="btn btn-outline-primary rounded-end">💾 Zapisz</button>
<button onclick="submitExpense({{ list.id }})" class="btn btn-outline-primary share-submit-btn shopping-compact-submit">💾 Zapisz</button>
</div>{% endif %}
<p id="total-expense2"><b>💸 Łącznie wydano:</b> {{ '%.2f'|format(total_expense) }} PLN</p>

View File

@@ -9,31 +9,41 @@
{% endif %}
{% if current_user.is_authenticated %}
{% if expiring_lists %}
<div class="alert alert-warning mb-4" role="alert">
<div class="fw-semibold mb-2">⏰ Wygasające listy tymczasowe w ciągu 24h</div>
<ul class="mb-0 ps-3">
{% for l in expiring_lists %}
<li><a class="link-dark fw-semibold" href="{{ url_for('view_list', list_id=l.id) }}">{{ l.title }}</a> — {{ l.expires_at.strftime('%Y-%m-%d %H:%M') }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
<section class="mb-4">
<div class="card bg-secondary bg-opacity-10 text-white mb-4">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-3">
<div>
<h2 class="mb-2">Twoje centrum list zakupowych</h2>
<p class="text-secondary mb-0">Twórz nowe listy, wracaj do aktywnych i zarządzaj archiwum w jednym miejscu.</p>
<p class="text-secondary mb-0">Stwórz nową liste</p>
</div>
</div>
<div class="card-body">
<form action="{{ url_for('create_list') }}" method="post">
<div class="input-group mb-3">
<input type="text" name="title" id="title" placeholder="Wprowadź nazwę nowej listy" required
class="form-control bg-dark text-white border-secondary">
<button type="button" class="btn btn-outline-secondary rounded-end" id="tempToggle" data-active="0"
data-bs-toggle="tooltip" data-bs-placement="top" title="Po zaznaczeniu lista będzie ważna tylko 7 dni">
Tymczasowa
</button>
<input type="hidden" name="temporary" id="temporaryHidden" value="0">
</div>
<button type="submit" class="btn btn-success w-100"> Utwórz nową listę</button>
</form>
<form action="{{ url_for('create_list') }}" method="post">
<div class="input-group mb-3 create-list-input-group">
<input type="text" name="title" id="title" placeholder="Wprowadź nazwę nowej listy" required
class="form-control bg-dark text-white border-secondary">
<button type="button" class="btn btn-outline-secondary create-list-temp-toggle" id="tempToggle" data-active="0"
data-bs-toggle="tooltip" data-bs-placement="top" title="Po zaznaczeniu lista będzie ważna tylko 7 dni">
Tymczasowa
</button>
<input type="hidden" name="temporary" id="temporaryHidden" value="0">
</div>
<button type="submit" class="btn btn-success w-100"> Utwórz nową listę</button>
</form>
</div>
</div>
</section>
{% endif %}
{% set month_names = ["styczeń","luty","marzec","kwiecień","maj","czerwiec","lipiec","sierpień","wrzesień","październik","listopad","grudzień"] %}
@@ -75,71 +85,44 @@
{% set percent = (purchased_count / total_count * 100) if total_count > 0 else 0 %}
<li class="list-group-item bg-dark text-white">
<div class="d-flex justify-content-between align-items-center flex-wrap w-100">
<span class="fw-bold">
<!-- Desktop/tablet: zwykły tekst -->
<span class="d-none d-sm-inline">
{{ l.title }} (Autor: Ty)
</span>
<!-- Mobile: klikalny tytuł -->
<a class="d-inline d-sm-none text-white text-decoration-none"
href="{{ url_for('view_list', list_id=l.id) }}">
{{ l.title }}
</a>
{% for cat in l.category_badges %}
<!-- DESKTOP: nazwa -->
<span class="badge rounded-pill text-dark ms-1 d-none d-sm-inline fw-semibold"
style="background-color: {{ cat.color }}; font-size: 0.7rem; opacity: 0.9; padding: 0.3em 0.6em;">
{{ cat.name }}
<div class="main-list-row">
<div class="list-main-meta">
<div class="fw-bold list-main-title mobile-list-heading" data-mobile-list-heading>
{% if l.is_temporary and l.expires_at %}<span class="badge rounded-pill bg-warning text-dark me-2">⏰ {{ l.expires_at.strftime('%Y-%m-%d %H:%M') }}</span>{% endif %}
<a class="list-main-title__link text-white text-decoration-none" href="{{ url_for('view_list', list_id=l.id) }}">
<span data-mobile-list-title>{{ l.title }}</span>
<span class="d-none d-sm-inline"> (Autor: Ty)</span>
</a>
{% if l.category_badges %}
<span class="mobile-category-badges" data-mobile-category-group>
{% for cat in l.category_badges %}
<span class="badge rounded-pill text-dark ms-1 fw-semibold mobile-category-badge"
style="background-color: {{ cat.color }}; font-size: 0.7rem; opacity: 0.9; padding: 0.3em 0.6em;"
title="{{ cat.name }}" aria-label="Kategoria: {{ cat.name }}">
<span class="mobile-category-badge__text">{{ cat.name }}</span>
<span class="mobile-category-badge__dot"></span>
</span>
{% endfor %}
</span>
<!-- MOBILE -->
<span class="ms-1 d-sm-none category-dot-pure"
style="background-color: {{ cat.color }};"
title="{{ cat.name }}" aria-label="Kategoria: {{ cat.name }}"></span>
{% endfor %}
</span>
<div class="btn-group btn-group-compact mt-2 mt-md-0 d-none d-sm-flex" role="group">
<a href="{{ url_for('view_list', list_id=l.id) }}" class="btn btn-sm btn-outline-light d-flex align-items-center">
📂 <span class="btn-text ms-1">Otwórz</span>
</a>
<a href="{{ url_for('shared_list', token=l.share_token) }}" class="btn btn-sm btn-outline-light d-flex align-items-center">
✏️ <span class="btn-text ms-1">Odznaczaj</span>
</a>
<a href="{{ url_for('copy_list', list_id=l.id) }}" class="btn btn-sm btn-outline-light d-flex align-items-center">
📋 <span class="btn-text ms-1">Kopiuj</span>
</a>
<a href="{{ url_for('toggle_visibility', list_id=l.id) }}" class="btn btn-sm btn-outline-light d-flex align-items-center">
{% if l.is_public %}🙈 <span class="btn-text ms-1">Ukryj</span>{% else %}🐵 <span class="btn-text ms-1">Odkryj</span>{% endif %}
</a>
<a href="{{ url_for('edit_my_list', list_id=l.id) }}" class="btn btn-sm btn-outline-light d-flex align-items-center">
⚙️ <span class="btn-text ms-1">Ustawienia</span>
</a>
{% endif %}
</div>
</div>
<div class="btn-group btn-group-compact mt-2 mt-md-0 d-flex d-sm-none" role="group">
<a href="{{ url_for('view_list', list_id=l.id) }}" class="btn btn-sm btn-outline-light d-flex align-items-center"
data-bs-toggle="tooltip" title="Otwórz">
📂 <span class="btn-text ms-1">Otwórz</span>
<div class="btn-group btn-group-compact list-main-actions" role="group">
<a href="{{ url_for('view_list', list_id=l.id) }}" class="btn btn-sm btn-outline-light d-flex align-items-center" data-bs-toggle="tooltip" title="Otwórz">
📂 <span class="btn-text ms-1 d-none d-sm-inline">Otwórz</span>
</a>
<a href="{{ url_for('shared_list', token=l.share_token) }}" class="btn btn-sm btn-outline-light d-flex align-items-center"
data-bs-toggle="tooltip" title="Odznaczaj">
✏️ <span class="btn-text ms-1">Odznaczaj</span>
{% if l.share_token %}<a href="{{ url_for('shared_list', token=l.share_token) }}" class="btn btn-sm btn-outline-light d-flex align-items-center" data-bs-toggle="tooltip" title="Odznaczaj">
✏️ <span class="btn-text ms-1 d-none d-sm-inline">Odznaczaj</span>
</a>{% endif %}
<a href="{{ url_for('copy_list', list_id=l.id) }}" class="btn btn-sm btn-outline-light d-flex align-items-center" data-bs-toggle="tooltip" title="Kopiuj">
📋 <span class="btn-text ms-1 d-none d-sm-inline">Kopiuj</span>
</a>
<a href="{{ url_for('copy_list', list_id=l.id) }}" class="btn btn-sm btn-outline-light d-flex align-items-center"
data-bs-toggle="tooltip" title="Kopiuj">
📋 <span class="btn-text ms-1">Kopiuj</span>
<a href="{{ url_for('toggle_visibility', list_id=l.id) }}" class="btn btn-sm btn-outline-light d-flex align-items-center" data-bs-toggle="tooltip" title="{% if l.is_public %}Ukryj{% else %}Odkryj{% endif %}">
{% if l.is_public %}🙈 <span class="btn-text ms-1 d-none d-sm-inline">Ukryj</span>{% else %}🐵 <span class="btn-text ms-1 d-none d-sm-inline">Odkryj</span>{% endif %}
</a>
<a href="{{ url_for('toggle_visibility', list_id=l.id) }}" class="btn btn-sm btn-outline-light d-flex align-items-center"
data-bs-toggle="tooltip" title="{% if l.is_public %}Ukryj{% else %}Odkryj{% endif %}">
{% if l.is_public %}🙈 <span class="btn-text ms-1">Ukryj</span>{% else %}🐵 <span class="btn-text ms-1">Odkryj</span>{% endif %}
</a>
<a href="{{ url_for('edit_my_list', list_id=l.id) }}" class="btn btn-sm btn-outline-light d-flex align-items-center"
data-bs-toggle="tooltip" title="Ustawienia">
⚙️ <span class="btn-text ms-1">Ustawienia</span>
<a href="{{ url_for('edit_my_list', list_id=l.id) }}" class="btn btn-sm btn-outline-light d-flex align-items-center" data-bs-toggle="tooltip" title="Ustawienia">
⚙️ <span class="btn-text ms-1 d-none d-sm-inline">Ustawienia</span>
</a>
</div>
</div>
@@ -188,45 +171,32 @@
{% set percent = (purchased_count / total_count * 100) if total_count > 0 else 0 %}
<li class="list-group-item bg-dark text-white">
<div class="d-flex justify-content-between align-items-center flex-wrap w-100">
<span class="fw-bold">
<!-- Desktop/tablet: zwykły tekst -->
<span class="d-none d-sm-inline">
{{ l.title }} (Autor: {{ l.owner.username if l.owner else '—' }})
</span>
<!-- Mobile: klikalny tytuł -> shared_list -->
<a class="d-inline d-sm-none fw-bold list-title text-white text-decoration-none"
href="{{ url_for('view_list', list_id=l.id) }}">
{{ l.title }}
</a>
{% for cat in l.category_badges %}
<!-- DESKTOP: nazwa -->
<span class="badge rounded-pill text-dark ms-1 d-none d-sm-inline fw-semibold"
style="background-color: {{ cat.color }}; font-size: 0.7rem; opacity: 0.9; padding: 0.3em 0.6em;">
{{ cat.name }}
<div class="main-list-row">
<div class="list-main-meta">
<div class="fw-bold list-main-title mobile-list-heading" data-mobile-list-heading>
{% if l.is_temporary and l.expires_at %}<span class="badge rounded-pill bg-warning text-dark me-2">⏰ {{ l.expires_at.strftime('%Y-%m-%d %H:%M') }}</span>{% endif %}
<a class="fw-bold list-main-title__link text-white text-decoration-none" href="{{ url_for('shared_list', list_id=l.id) }}">
<span data-mobile-list-title>{{ l.title }}</span>
<span class="d-none d-sm-inline"> (Autor: {{ l.owner.username if l.owner else '—' }})</span>
</a>
{% if l.category_badges %}
<span class="mobile-category-badges" data-mobile-category-group>
{% for cat in l.category_badges %}
<span class="badge rounded-pill text-dark ms-1 fw-semibold mobile-category-badge"
style="background-color: {{ cat.color }}; font-size: 0.7rem; opacity: 0.9; padding: 0.3em 0.6em;"
title="{{ cat.name }}" aria-label="Kategoria: {{ cat.name }}">
<span class="mobile-category-badge__text">{{ cat.name }}</span>
<span class="mobile-category-badge__dot"></span>
</span>
{% endfor %}
</span>
<!-- MOBILE -->
<span class="ms-1 d-sm-none category-dot-pure"
style="background-color: {{ cat.color }};"
title="{{ cat.name }}" aria-label="Kategoria: {{ cat.name }}"></span>
{% endfor %}
</span>
<div class="btn-group btn-group-compact mt-2 mt-md-0 d-none d-sm-flex" role="group">
<a href="{{ url_for('shared_list', list_id=l.id) }}"
class="btn btn-sm btn-outline-light d-flex align-items-center">
✏️ <span class="btn-text ms-1">Odznaczaj</span>
</a>
{% endif %}
</div>
</div>
<div class="btn-group btn-group-compact mt-2 mt-md-0 d-flex d-sm-none" role="group">
<a href="{{ url_for('shared_list', list_id=l.id) }}"
class="btn btn-sm btn-outline-light d-flex align-items-center"
data-bs-toggle="tooltip" title="Odznaczaj">
✏️ <span class="btn-text ms-1">Odznaczaj</span>
<div class="btn-group btn-group-compact list-main-actions" role="group">
<a href="{{ url_for('shared_list', list_id=l.id) }}" class="btn btn-sm btn-outline-light d-flex align-items-center" data-bs-toggle="tooltip" title="Odznaczaj">
✏️ <span class="btn-text ms-1 d-none d-sm-inline">Odznaczaj</span>
</a>
</div>
</div>

View File

@@ -0,0 +1,58 @@
{% extends 'base.html' %}
{% block title %}Moje szablony{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4 gap-2">
<div><h2 class="mb-1">🧩 Moje szablony</h2><p class="text-secondary mb-0">Każdy użytkownik zarządza własnymi szablonami niezależnie od panelu admina.</p></div>
{% if current_user.is_admin %}<a href="{{ url_for('admin_templates') }}" class="btn btn-outline-secondary">Panel admina</a>{% endif %}
</div>
<div class="row g-4">
<div class="col-lg-5">
<div class="card bg-dark text-white mb-4"><div class="card-body">
<h5 class="mb-3"> Nowy szablon ręcznie</h5>
<form method="post" data-unsaved-warning="true" class="stack-form">
<input type="hidden" name="action" value="create_manual">
<div class="mb-3"><label class="form-label">Nazwa</label><input type="text" name="name" class="form-control" required></div>
<div class="mb-3"><label class="form-label">Opis</label><textarea name="description" class="form-control" rows="2"></textarea></div>
<div class="mb-3"><label class="form-label">Produkty</label><textarea name="items_text" class="form-control" rows="8" placeholder="Mleko x2
Chleb
Jajka x10"></textarea><div class="form-text">Każda linia to osobny produkt. Ilość opcjonalnie przez xN.</div></div>
<button class="btn btn-success w-100" type="submit">Utwórz szablon</button>
</form>
</div></div>
<div class="card bg-dark text-white"><div class="card-body">
<h5 class="mb-3">📋 Utwórz z istniejącej listy</h5>
<form method="post" action="{{ url_for('create_template_from_user_list', list_id=0) }}" onsubmit="this.action=this.action.replace('/0','/' + this.querySelector('[name=source_list_id]').value);">
<div class="mb-3"><label class="form-label">Lista źródłowa</label><select name="source_list_id" class="form-select" required>{% for l in source_lists %}<option value="{{ l.id }}">#{{ l.id }} — {{ l.title }}</option>{% endfor %}</select></div>
<div class="mb-3"><label class="form-label">Nazwa szablonu</label><input type="text" name="template_name" class="form-control"></div>
<div class="mb-3"><label class="form-label">Opis</label><textarea name="description" class="form-control" rows="2"></textarea></div>
<button class="btn btn-outline-success w-100" type="submit">Zapisz z listy</button>
</form>
</div></div>
</div>
<div class="col-lg-7">
<div class="card bg-dark text-white"><div class="card-body">
<div class="admin-page-head mb-3"><h5 class="mb-0">Aktywne szablony</h5><span class="badge rounded-pill bg-secondary">{{ templates|length }} szt.</span></div>
<div class="table-responsive">
<table class="table table-dark align-middle" data-searchable="true">
<thead><tr><th>Nazwa</th><th>Opis</th><th>Produkty</th><th>Akcje</th></tr></thead>
<tbody>
{% for template in templates %}
<tr>
<td><div class="fw-semibold">{{ template.name }}</div><div class="small text-secondary">{{ template.created_at.strftime('%Y-%m-%d %H:%M') if template.created_at else '' }}</div></td>
<td>{{ template.description or '—' }}</td>
<td>{{ template.items|length }}</td>
<td><div class="d-flex flex-wrap gap-2">
<form method="post" action="{{ url_for('instantiate_template', template_id=template.id) }}" class="d-inline"><button class="btn btn-sm btn-outline-success" type="submit"> Utwórz listę</button></form>
<form method="post" onsubmit="return confirm('Usunąć szablon?')" class="d-inline"><input type="hidden" name="action" value="delete"><input type="hidden" name="template_id" value="{{ template.id }}"><button class="btn btn-sm btn-outline-danger" type="submit">🗑 Usuń</button></form>
</div></td>
</tr>
{% else %}
<tr><td colspan="4" class="text-center text-secondary py-4">Brak własnych szablonów.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div></div>
</div>
</div>
{% endblock %}

View File

@@ -1 +0,0 @@
../uploads

View File

@@ -33,6 +33,9 @@ def inject_is_blocked():
def require_system_password():
endpoint = request.endpoint
if endpoint is None:
return
if endpoint in (
"static_bp.serve_js",
"static_bp.serve_css",
@@ -44,16 +47,13 @@ def require_system_password():
):
return
if endpoint in ("system_auth", "healthcheck", "robots_txt"):
if endpoint in ("system_auth", "healthcheck", "robots_txt") or endpoint.startswith("api_"):
return
ip = request.access_route[0]
if is_ip_blocked(ip):
abort(403)
if endpoint is None:
return
if "authorized" not in request.cookies and not endpoint.startswith("login"):
if request.path == "/":
return redirect(url_for("system_auth"))

70
tests/test_refactor.py Normal file
View File

@@ -0,0 +1,70 @@
import unittest
from pathlib import Path
from shopping_app import app
class RefactorSmokeTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
app.config.update(TESTING=True)
cls.client = app.test_client()
def test_undefined_path_returns_not_500(self):
response = self.client.get('/undefined')
self.assertNotEqual(response.status_code, 500)
self.assertEqual(response.status_code, 404)
def test_login_page_renders(self):
response = self.client.get('/login')
self.assertEqual(response.status_code, 200)
html = response.get_data(as_text=True)
self.assertIn('name="password"', html)
self.assertIn('app_ui.js', html)
class TemplateContractTests(unittest.TestCase):
def test_main_template_uses_single_action_group_on_mobile(self):
main_html = Path('shopping_app/templates/main.html').read_text(encoding='utf-8')
self.assertIn('mobile-list-heading', main_html)
self.assertIn('list-main-title__link', main_html)
self.assertNotIn('d-flex d-sm-none" role="group"', main_html)
def test_list_templates_use_compact_mobile_action_layout(self):
list_html = Path('shopping_app/templates/list.html').read_text(encoding='utf-8')
shared_html = Path('shopping_app/templates/list_share.html').read_text(encoding='utf-8')
for html in (list_html, shared_html):
self.assertIn('shopping-item-row', html)
self.assertIn('shopping-item-actions', html)
self.assertIn('shopping-compact-input-group', html)
self.assertIn('shopping-item-head', html)
def test_css_contains_mobile_ux_overrides(self):
css = Path('shopping_app/static/css/style.css').read_text(encoding='utf-8')
self.assertIn('.shopping-item-actions', css)
self.assertIn('.shopping-compact-input-group', css)
self.assertIn('.ui-password-group > .ui-password-toggle', css)
self.assertIn('.hide-purchased-switch--minimal', css)
self.assertIn('.shopping-item-head', css)
self.assertIn('UX tweak 2026-03-14 c: hamburger with full labels', css)
if __name__ == '__main__':
unittest.main()
class NavbarContractTests(unittest.TestCase):
def test_base_template_uses_mobile_collapse_nav(self):
base_html = Path('shopping_app/templates/base.html').read_text(encoding='utf-8')
self.assertIn('navbar-toggler', base_html)
self.assertIn('appNavbarMenu', base_html)
def test_base_template_mobile_nav_has_full_labels(self):
base_html = Path('shopping_app/templates/base.html').read_text(encoding='utf-8')
self.assertIn('>📊 <span>Wydatki</span><', base_html)
self.assertIn('>🚪 <span>Wyloguj</span><', base_html)
def test_main_template_temp_toggle_is_integrated(self):
main_html = Path('shopping_app/templates/main.html').read_text(encoding='utf-8')
self.assertIn('create-list-temp-toggle', main_html)