refactor next push
This commit is contained in:
33
API_OPIS.txt
Normal file
33
API_OPIS.txt
Normal 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
30
CLI_OPIS.txt
Normal 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.
|
||||
@@ -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`.
|
||||
|
||||
@@ -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/`.
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
227
shopping_app/static/js/app_ui.js
Normal file
227
shopping_app/static/js/app_ui.js
Normal 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();
|
||||
}
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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";
|
||||
|
||||
18
shopping_app/templates/admin/_nav.html
Normal file
18
shopping_app/templates/admin/_nav.html
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
161
shopping_app/templates/admin/api_tokens.html
Normal file
161
shopping_app/templates/admin/api_tokens.html
Normal 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/<id>/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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
64
shopping_app/templates/admin/templates.html
Normal file
64
shopping_app/templates/admin/templates.html
Normal 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 Chleb 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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
58
shopping_app/templates/my_templates.html
Normal file
58
shopping_app/templates/my_templates.html
Normal 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 %}
|
||||
@@ -1 +0,0 @@
|
||||
../uploads
|
||||
@@ -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
70
tests/test_refactor.py
Normal 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)
|
||||
Reference in New Issue
Block a user