diff --git a/CLI_OPIS.txt b/CLI_OPIS.txt index f8ce26f..f25d25a 100644 --- a/CLI_OPIS.txt +++ b/CLI_OPIS.txt @@ -9,22 +9,53 @@ flask admins promote flask admins demote flask admins set-password +Opis: +- list: pokazuje wszystkich uzytkownikow wraz z ID i rola +- create: tworzy konto admina lub zwyklego uzytkownika +- promote: nadaje uprawnienia administratora +- demote: odbiera uprawnienia administratora +- set-password: ustawia nowe haslo dla wskazanego konta + 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" +flask lists move --list-id 12 --when "2026-03-21 09:00" +flask lists move --list-id 12 --when "2026-03-21 09:00" --keep-item-times --keep-expiry +flask lists archive --list-id 12 +flask lists unarchive --list-id 12 +flask lists assign-owner --list-id 12 --owner admin +flask lists create-from-template --template-id 5 --owner admin --when "2026-03-22 08:00" +flask lists create-from-template --template-id 5 --owner admin --title "Weekend" +flask lists delete --list-id 12 +flask lists rename --list-id 12 --title "Nowa nazwa listy" +flask lists duplicate-many --source-list-id 12 --when-list "2026-03-23 08:00,2026-03-24 08:00,2026-03-25 08:00" +flask lists duplicate-many --source-list-id 12 --when-list "2026-03-23 08:00,2026-03-24 08:00" --owner admin --title-prefix "Sklep" Zasady dzialania ---------------- - copy-schedule tworzy nowa liste na podstawie istniejacej -- kopiuje pozycje i przypisane kategorie -- ustawia nowy created_at na wartosc z parametru --when +- copy-schedule kopiuje pozycje i przypisane kategorie +- copy-schedule 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 - +- move przenosi istniejaca liste na wskazany dzien/godzine +- move domyslnie przesuwa rowniez czasy pozycji i expires_at o ten sam offset czasu +- move z opcja --keep-item-times zostawia added_at i purchased_at bez zmian +- move z opcja --keep-expiry zostawia expires_at bez zmian +- archive oznacza liste jako archiwalna +- unarchive przywraca liste z archiwum +- assign-owner zmienia wlasciciela listy +- create-from-template tworzy nowa liste z szablonu dla wskazanego wlasciciela +- create-from-template bez --when ustawia biezacy czas UTC +- delete usuwa liste wraz z powiazanymi pozycjami, historią i paragonami zaleznymi od relacji bazy +- rename zmienia tytul listy +- duplicate-many tworzy wiele kopii tej samej listy dla wielu terminow przekazanych w --when-list +- duplicate-many opcjonalnie pozwala zmienic wlasciciela i nadac prefiks nazwy nowym listom 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. +- Historia zmian listy jest widoczna w widoku listy wlasciciela. +- Szablon mozna utworzyc z panelu admina lub z poziomu listy wlasciciela. +- Admin moze szybko utworzyc liste z szablonu i zduplikowac liste jednym kliknieciem. +- Operacje CLI takie jak copy-schedule, move, archive, unarchive, assign-owner, rename i create-from-template sa zapisywane w historii listy. diff --git a/README.md b/README.md index bfa647a..1e9bb39 100644 --- a/README.md +++ b/README.md @@ -91,4 +91,23 @@ DB_PASSWORD=pass ## CLI -Opis komend administracyjnych znajduje sie w pliku `CLI_OPIS.txt`. +Opis komend administracyjnych znajduje sie w pliku `KOMENDY_CLI.txt`. + +Komendy CLI uruchamiamy wewnatrz kontenera aplikacji. Najwygodniej wejsc do katalogu projektu i wykonac polecenie przez `docker compose exec app`. + +Przykladowe: + +cd /opt/lista_zakupowa_live +docker compose -f docker/compose.yml exec app sh -c 'flask lists copy-schedule --source-list-id 393 --when "2026-03-22 11:30" --owner admin' + +Dodatkowe przyklady: + +docker compose -f docker/compose.yml exec app sh -c 'flask lists move --list-id 393 --when "2026-03-23 08:00"' +docker compose -f docker/compose.yml exec app sh -c 'flask lists rename --list-id 393 --title "Zakupy na poniedzialek"' +docker compose -f docker/compose.yml exec app sh -c 'flask lists create-from-template --template-id 7 --owner admin --when "2026-03-24 09:15" --title "Poranna lista"' + +Uwagi: +- daty przyjmuja format `YYYY-MM-DD` albo `YYYY-MM-DD HH:MM` +- dla samej daty aplikacja ustawia godzine `08:00 UTC` +- identyfikator uzytkownika mozna podac jako login albo ID +- komendy `copy-schedule` i `duplicate-many` kopiują pozycje i przypisane kategorie, ale nie kopiują wydatkow ani paragonow diff --git a/shopping_app/helpers.py b/shopping_app/helpers.py index 642e69b..e061cc0 100644 --- a/shopping_app/helpers.py +++ b/shopping_app/helpers.py @@ -243,6 +243,118 @@ def duplicate_list_for_schedule(source_list: ShoppingList, scheduled_for: dateti db.session.commit() return new_list + + +def shift_datetime_preserving_timezone(value: datetime | None, delta: timedelta) -> datetime | None: + if not value: + return None + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + return value + delta + + +def move_list_schedule(shopping_list: ShoppingList, new_when: datetime, keep_item_times: bool = False, keep_expiry: bool = False): + if shopping_list is None: + raise ValueError('Lista nie istnieje.') + if new_when.tzinfo is None: + new_when = new_when.replace(tzinfo=timezone.utc) + + original_created = shopping_list.created_at or new_when + if original_created.tzinfo is None: + original_created = original_created.replace(tzinfo=timezone.utc) + + delta = new_when - original_created + shopping_list.created_at = new_when + + if not keep_expiry and shopping_list.expires_at: + shopping_list.expires_at = shift_datetime_preserving_timezone(shopping_list.expires_at, delta) + + if not keep_item_times: + for item in shopping_list.items: + if item.added_at: + item.added_at = shift_datetime_preserving_timezone(item.added_at, delta) + if item.purchased_at: + item.purchased_at = shift_datetime_preserving_timezone(item.purchased_at, delta) + + return shopping_list, delta + + +def rename_list(shopping_list: ShoppingList, new_title: str): + normalized = (new_title or '').strip() + if not normalized: + raise ValueError('Podaj nowy tytul listy.') + shopping_list.title = normalized + return shopping_list + + +def set_list_archived(shopping_list: ShoppingList, archived: bool = True): + if shopping_list is None: + raise ValueError('Lista nie istnieje.') + shopping_list.is_archived = bool(archived) + return shopping_list + + +def assign_list_owner(shopping_list: ShoppingList, owner: User): + if shopping_list is None: + raise ValueError('Lista nie istnieje.') + if owner is None: + raise ValueError('Nie znaleziono docelowego wlasciciela.') + shopping_list.owner_id = owner.id + return shopping_list + + +def delete_list_with_relations(shopping_list: ShoppingList): + if shopping_list is None: + raise ValueError('Lista nie istnieje.') + shopping_list.categories.clear() + ListPermission.query.filter_by(list_id=shopping_list.id).delete(synchronize_session=False) + ListActivityLog.query.filter_by(list_id=shopping_list.id).delete(synchronize_session=False) + Expense.query.filter_by(list_id=shopping_list.id).delete(synchronize_session=False) + Receipt.query.filter_by(list_id=shopping_list.id).delete(synchronize_session=False) + Item.query.filter_by(list_id=shopping_list.id).delete(synchronize_session=False) + db.session.delete(shopping_list) + + +def duplicate_list_many(source_list: ShoppingList, schedule_values: list[datetime], owner: User | None = None, title_prefix: str | None = None): + created_lists = [] + base_prefix = (title_prefix or '').strip() + for idx, scheduled_for in enumerate(schedule_values, start=1): + title = None + if base_prefix: + title = f'{base_prefix} #{idx}' + created_lists.append(duplicate_list_for_schedule(source_list, scheduled_for=scheduled_for, owner=owner, title=title)) + return created_lists + + +def create_list_from_template_at_schedule(template: ListTemplate, owner: User, scheduled_for: datetime, title: str | None = None): + if scheduled_for.tzinfo is None: + scheduled_for = scheduled_for.replace(tzinfo=timezone.utc) + + new_list = ShoppingList( + title=(title or template.name).strip(), + owner_id=owner.id, + share_token=generate_share_token(8), + is_temporary=False, + expires_at=None, + created_at=scheduled_for, + ) + db.session.add(new_list) + db.session.flush() + + for idx, item in enumerate(template.items, start=1): + db.session.add(Item( + list_id=new_list.id, + name=item.name, + quantity=item.quantity or 1, + note=item.note, + position=item.position or idx, + added_by=owner.id, + added_at=scheduled_for, + )) + + db.session.commit() + return new_list + def hash_api_token(token: str) -> str: return hashlib.sha256((token or '').encode('utf-8')).hexdigest() @@ -387,6 +499,11 @@ def action_label(action: str) -> str: 'item_unmarked_not_purchased': 'przywrócił produkt', 'expense_added': 'dodał wydatek', 'list_duplicated': 'zduplikował listę', + 'list_moved': 'przeniósł listę', + 'list_archived': 'zarchiwizował listę', + 'list_unarchived': 'przywrócił listę z archiwum', + 'list_owner_changed': 'zmienił właściciela listy', + 'list_renamed': 'zmienił nazwę listy', 'template_created': 'utworzył szablon', }.get(action, action) diff --git a/shopping_app/sockets.py b/shopping_app/sockets.py index 1b96f89..bfa14ba 100644 --- a/shopping_app/sockets.py +++ b/shopping_app/sockets.py @@ -594,6 +594,22 @@ def lists_cli(): """Operacje CLI na listach zakupowych.""" +def _load_list_for_cli(list_id: int): + return ShoppingList.query.options(joinedload(ShoppingList.items), joinedload(ShoppingList.categories), joinedload(ShoppingList.owner)).get(list_id) + + +def _parse_many_when_values(raw_values: str): + values = [] + for part in (raw_values or '').split(','): + normalized = part.strip() + if not normalized: + continue + values.append(parse_cli_datetime(normalized)) + if not values: + raise ValueError('Podaj co najmniej jedna date w --when-list.') + return values + + @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") @@ -601,7 +617,7 @@ def lists_cli(): @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) + source_list = _load_list_for_cli(source_list_id) if not source_list: raise click.ClickException('Nie znaleziono listy zrodlowej.') @@ -617,6 +633,153 @@ def lists_copy_schedule_command(source_list_id, when_value, owner_value, title): raise click.ClickException('Nie znaleziono docelowego wlasciciela.') new_list = duplicate_list_for_schedule(source_list, scheduled_for=scheduled_for, owner=owner, title=title) + log_list_activity(new_list.id, 'list_duplicated', actor_name='CLI', details=f'copy-schedule ze zrodla #{source_list.id}') + db.session.commit() click.echo( f"Utworzono kopie listy: nowa_id={new_list.id}, tytul={new_list.title}, created_at={new_list.created_at.isoformat()}" ) + + +@lists_cli.command("move") +@click.option("--list-id", required=True, type=int, help="ID listy.") +@click.option("--when", "when_value", required=True, help="Nowy termin listy: YYYY-MM-DD lub YYYY-MM-DD HH:MM") +@click.option("--keep-item-times", is_flag=True, help="Nie przesuwaj added_at/purchased_at pozycji.") +@click.option("--keep-expiry", is_flag=True, help="Nie przesuwaj expires_at.") +def lists_move_command(list_id, when_value, keep_item_times, keep_expiry): + with app.app_context(): + shopping_list = _load_list_for_cli(list_id) + if not shopping_list: + raise click.ClickException('Nie znaleziono listy.') + try: + new_when = parse_cli_datetime(when_value) + except ValueError as exc: + raise click.ClickException(str(exc)) + old_created = shopping_list.created_at + move_list_schedule(shopping_list, new_when, keep_item_times=keep_item_times, keep_expiry=keep_expiry) + log_list_activity(shopping_list.id, 'list_moved', actor_name='CLI', details=f'Z {old_created} na {shopping_list.created_at}') + db.session.commit() + click.echo(f'Przeniesiono liste #{shopping_list.id} na {shopping_list.created_at.isoformat()}') + + +@lists_cli.command("archive") +@click.option("--list-id", required=True, type=int, help="ID listy.") +def lists_archive_command(list_id): + with app.app_context(): + shopping_list = _load_list_for_cli(list_id) + if not shopping_list: + raise click.ClickException('Nie znaleziono listy.') + set_list_archived(shopping_list, archived=True) + log_list_activity(shopping_list.id, 'list_archived', actor_name='CLI') + db.session.commit() + click.echo(f'Zarchiwizowano liste #{shopping_list.id}.') + + +@lists_cli.command("unarchive") +@click.option("--list-id", required=True, type=int, help="ID listy.") +def lists_unarchive_command(list_id): + with app.app_context(): + shopping_list = _load_list_for_cli(list_id) + if not shopping_list: + raise click.ClickException('Nie znaleziono listy.') + set_list_archived(shopping_list, archived=False) + log_list_activity(shopping_list.id, 'list_unarchived', actor_name='CLI') + db.session.commit() + click.echo(f'Przywrocono liste #{shopping_list.id} z archiwum.') + + +@lists_cli.command("assign-owner") +@click.option("--list-id", required=True, type=int, help="ID listy.") +@click.option("--owner", "owner_value", required=True, help="Nowy wlasciciel: username albo ID.") +def lists_assign_owner_command(list_id, owner_value): + with app.app_context(): + shopping_list = _load_list_for_cli(list_id) + if not shopping_list: + raise click.ClickException('Nie znaleziono listy.') + owner = resolve_user_identifier(owner_value) + if not owner: + raise click.ClickException('Nie znaleziono docelowego wlasciciela.') + previous_owner = shopping_list.owner.username if shopping_list.owner else shopping_list.owner_id + assign_list_owner(shopping_list, owner) + log_list_activity(shopping_list.id, 'list_owner_changed', actor_name='CLI', details=f'{previous_owner} -> {owner.username}') + db.session.commit() + click.echo(f'Zmieniono wlasciciela listy #{shopping_list.id} na {owner.username}.') + + +@lists_cli.command("create-from-template") +@click.option("--template-id", required=True, type=int, help="ID szablonu.") +@click.option("--owner", "owner_value", required=True, help="Wlasciciel nowej listy: username albo ID.") +@click.option("--when", "when_value", default=None, help="Termin utworzenia: YYYY-MM-DD lub YYYY-MM-DD HH:MM") +@click.option("--title", default=None, help="Tytul nowej listy.") +def lists_create_from_template_command(template_id, owner_value, when_value, title): + with app.app_context(): + template = ListTemplate.query.options(joinedload(ListTemplate.items)).get(template_id) + if not template: + raise click.ClickException('Nie znaleziono szablonu.') + owner = resolve_user_identifier(owner_value) + if not owner: + raise click.ClickException('Nie znaleziono docelowego wlasciciela.') + try: + scheduled_for = parse_cli_datetime(when_value) if when_value else datetime.now(timezone.utc) + except ValueError as exc: + raise click.ClickException(str(exc)) + new_list = create_list_from_template_at_schedule(template, owner=owner, scheduled_for=scheduled_for, title=title) + log_list_activity(new_list.id, 'template_created', actor_name='CLI', details=f'create-from-template z szablonu #{template.id}') + db.session.commit() + click.echo(f'Utworzono liste z szablonu: nowa_id={new_list.id}, tytul={new_list.title}, created_at={new_list.created_at.isoformat()}') + + +@lists_cli.command("delete") +@click.option("--list-id", required=True, type=int, help="ID listy.") +def lists_delete_command(list_id): + with app.app_context(): + shopping_list = _load_list_for_cli(list_id) + if not shopping_list: + raise click.ClickException('Nie znaleziono listy.') + title = shopping_list.title + delete_list_with_relations(shopping_list) + db.session.commit() + click.echo(f'Usunieto liste #{list_id}: {title}') + + +@lists_cli.command("rename") +@click.option("--list-id", required=True, type=int, help="ID listy.") +@click.option("--title", "new_title", required=True, help="Nowy tytul listy.") +def lists_rename_command(list_id, new_title): + with app.app_context(): + shopping_list = _load_list_for_cli(list_id) + if not shopping_list: + raise click.ClickException('Nie znaleziono listy.') + old_title = shopping_list.title + try: + rename_list(shopping_list, new_title) + except ValueError as exc: + raise click.ClickException(str(exc)) + log_list_activity(shopping_list.id, 'list_renamed', actor_name='CLI', details=f'{old_title} -> {shopping_list.title}') + db.session.commit() + click.echo(f'Zmieniono tytul listy #{shopping_list.id} na: {shopping_list.title}') + + +@lists_cli.command("duplicate-many") +@click.option("--source-list-id", required=True, type=int, help="ID listy zrodlowej.") +@click.option("--when-list", required=True, help="Lista terminow rozdzielona przecinkami.") +@click.option("--owner", "owner_value", default=None, help="Nowy wlasciciel: username albo ID.") +@click.option("--title-prefix", default=None, help="Prefiks tytulu dla nowych list.") +def lists_duplicate_many_command(source_list_id, when_list, owner_value, title_prefix): + with app.app_context(): + source_list = _load_list_for_cli(source_list_id) + if not source_list: + raise click.ClickException('Nie znaleziono listy zrodlowej.') + owner = None + if owner_value: + owner = resolve_user_identifier(owner_value) + if not owner: + raise click.ClickException('Nie znaleziono docelowego wlasciciela.') + try: + schedule_values = _parse_many_when_values(when_list) + except ValueError as exc: + raise click.ClickException(str(exc)) + created_lists = duplicate_list_many(source_list, schedule_values=schedule_values, owner=owner, title_prefix=title_prefix) + for new_list in created_lists: + log_list_activity(new_list.id, 'list_duplicated', actor_name='CLI', details=f'duplicate-many ze zrodla #{source_list.id}') + db.session.commit() + click.echo('Utworzono listy: ' + ', '.join([f'#{row.id}@{row.created_at.isoformat()}' for row in created_lists])) diff --git a/shopping_app/templates/list.html b/shopping_app/templates/list.html index 96b7985..7275c67 100644 --- a/shopping_app/templates/list.html +++ b/shopping_app/templates/list.html @@ -455,15 +455,15 @@