diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..9750cf0 --- /dev/null +++ b/.flake8 @@ -0,0 +1,11 @@ +[flake8] +max-line-length = 100 +extend-ignore = E203, W503 +exclude = + .git, + __pycache__, + .venv, + venv, + build, + dist, + *.egg-info diff --git a/CHANGELOG.md b/CHANGELOG.md index 341953d..dd1dde7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,17 +4,92 @@ Vsechny vyznamne zmeny v tomto projektu budou dokumentovany v tomto souboru. ## [unreleased] + +## [0.2.0] 2025-11-11 ### Added +- `Menu` class jako samostatna trida pro praci s jidelnickem +- `MealType` enum pro typy jidel (SOUP, MAIN, UNKNOWN) +- `OrderType` enum pro typy objednavek (NORMAL, RESTRICTED, OPTIONAL) +- `InvalidMealTypeError` exception pro pokusy o objednani/zruseni jidel, ktera nelze modifikovat (polevky) +- `DuplicateMealError` exception pro pokusy o objednani vice jidel ze stejneho dne ve strict modu +- `InsufficientBalanceError` exception pro nedostatecny zustatek na uctu +- Kazde jidlo nyni obsahuje `orderType` pole s informaci o typu objednavky +- `Menu.fetch()` pro ziskani jidelnicku z API (bez parametru) +- Dve hlavni metody pro ziskani dat: + - `Menu.get_days(meal_types, order_types, ordered)` - jidla seskupena podle dni + - `Menu.get_meals(meal_types, order_types, ordered)` - vsechna jidla jako flat seznam +- Filtrovani pomoci parametru: + - `meal_types` - seznam typu jidel k ziskani (napr. `[MealType.SOUP, MealType.MAIN]`) + - `order_types` - seznam typu objednavek (napr. `[OrderType.NORMAL, OrderType.RESTRICTED]`) + - `ordered` - filtrovani podle stavu objednavky (True = objednano / False = neobjednano / None = oboji) +- Magic metody pro Menu: `__repr__`, `__str__`, `__iter__`, `__len__`, `__getitem__` +- Automaticke filtrovani podle omezeniObj hodnot na typ moznosti objednani: + - "VP" jidla se preskakuji (zadna skola tento den) + - "CO" jidla maji OrderType.RESTRICTED (jiz nejde zmenit) + - "T" jidla maji OrderType.OPTIONAL (normalne neobjednavano) + - Prazdny string = OrderType.NORMAL (objednavatelne) +- Pomocne metody: `get_by_id()`, `get_by_date()`, `is_ordered()` (prohledavaji vsechny typy automaticky) +- `Menu.order_meals()` a `Menu.cancel_meals()` primo v Menu objektu +- Export `MealType`, `OrderType` a `Menu` z hlavniho modulu +- MIGRATION_GUIDE.md s detailnim navodem pro prechod na novou verzi +- Automaticka detekce duplicitnich jidel ze stejneho dne pri objednavani +- Parametr `strict_duplicates` v `order_meals()` - kontroluje duplikaty (defaultne: False = pouze varuje) +- Parametr `continue_on_error` v `order_meals()` a `cancel_meals()` - pokracuje, i kdyz se naskytne chyba (default: False = zastavi po chybe a zrusi objednavani) +- Automaticka kontrola typu jidla - pouze hlavni jidla (MAIN) lze objednavat/rusit +- Automaticke sledovani zustatku na uctu po kazde operaci +- Automaticke ruseni zmen pri chybe pri objednavani (rollback pomoci `nactiVlastnostiPA` endpointu) +- Prevence duplicitnich chybovych hlaseni - kazde jidlo je ohlaseno pouze jednou +- Kompletni testovaci pokryti pro vsechny nove funkce (16 testu, 75% coverage, !! TESTY BYLY VYTVORENY POMOCI LLM !!) + +### Changed +- Menu je nyni samostatny objekt pristupny pres `strava.menu` +- Uzivatel nyni interaguje primo s Menu objektem misto volani metod na StravaCZ +- `fetch()` uz nema parametry include_soup a include_empty - vse se zpracovava automaticky +- `meal["type"]` je nyni primo `MealType` enum misto stringu +- `meal["orderType"]` je enum typ objednavky (NORMAL/RESTRICTED/OPTIONAL) +- `find_meal_by_id()` prejmenovano na `get_by_id()` +- `is_meal_ordered()` prejmenovano na `is_ordered()` +- Vyhledavaci metody nyni prohledavaji vsechny typy objednavek automaticky +- Filtrovani pomoci parametru misto specializovanych metod nebo vlastnosti +- Defaultni chovani: `order_types=None` vraci pouze `OrderType.NORMAL` (objednavatelne jidla) +- Magic metoda `__str__` vraci format `Menu(days=X, meals=Y)` +- **`canteen_number` je nyni povinny parametr** - odstranena default hodnota "3753" +- `order_meals()` nyni automaticky detekuje a hlasi duplicitni jidla ze stejneho dne +- Default chovani pri duplicitach: objedna prvni jidlo, varuje o dalsich +- Zpracovani chyb: defaultne zastavi pri prvni chybe a zrusi vsechny zmeny +- Balance tracking: `user.balance` se aktualizuje po kazdé operaci z API odpovedi + +### Removed +- `StravaCZ.get_menu()` - nahrazeno `strava.menu.fetch()` +- `StravaCZ.print_menu()` - nahrazeno `strava.menu.print()` +- `StravaCZ.is_ordered()` - nahrazeno `strava.menu.is_ordered()` +- `StravaCZ.order_meals()` - nahrazeno `strava.menu.order_meals()` +- `StravaCZ.cancel_meals()` - nahrazeno `strava.menu.cancel_meals()` +- `StravaCZ.filter_by_price_range()` - odstraneno filtrovani podle ceny +- Pole `local_id` z jidel - nepouzivane +- `StravaCZ.cancel_meals()` - nahrazeno `strava.menu.cancel_meals()` +- `StravaCZ._change_meal_order()` - presunuto do Menu class +- `StravaCZ._add_meal_to_order()` - presunuto do Menu class +- `StravaCZ._cancel_meal_order()` - presunuto do Menu class +- `StravaCZ._save_order()` - presunuto do Menu class +- `StravaCZ._parse_menu_response()` - presunuto do Menu class jako `_parse_menu_data()` +- `Menu.filter_by_price_range()` - odstranena funkce pro filtrovani podle ceny +- `Menu.get_all()` - nahrazeno vlastnosti `Menu.all` +- `Menu.processed_data` - nahrazeno vlastnostmi `all`, `main_only`, atd. +- `local_id` polozka z meal dat - odstranena nepotrebna hodnota +- Parametry `include_soup` a `include_empty` z `fetch()` - nyni se vse zpracovava automaticky + +### Fixed - Hodnota `ordered` u kazdeho dne - Hodnota `price` u kazdeho jidla -- `cancel_meals` funkce - Overovani uspesnosti objednani/odhlaseni - -### Fixed - Filtrace prazdnych jidel, vcetne svatku a prazdnin - -### Fixed - Opravene filtrovani prazdnych jidel +- Automaticke preskoceni "VP" jidel +- Duplicitni chybove zpravy pri neuspesnem objednani +- Chybejici validace typu jidla (zabraneno objednavani polevek) +- Chybejici kontrola zustatku pred objednanim +- Chybejici rollback mechanismus pri chybach ## [0.1.3] 2025-09-24 ### Added diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md new file mode 100644 index 0000000..2805ba0 --- /dev/null +++ b/MIGRATION_GUIDE.md @@ -0,0 +1,343 @@ +# Migration Guide: Menu Class Refactoring & Advanced Error Handling +### Tento text byl vytvoren pomoci LLM / This text was made by an LLM + +## Overview + +### Version 0.2.0 (Current) +The menu functionality has been refactored into an independent `Menu` class that maintains a reference to the `StravaCZ` client. Users now interact directly with the `Menu` class instead of calling menu methods on `StravaCZ`. + +The new API provides two main methods with flexible filtering parameters instead of multiple specialized methods. + +**New in 0.2.0:** +- Advanced error handling with `continue_on_error` parameter +- Automatic duplicate meal detection with `strict_duplicates` parameter +- Meal type validation (only MAIN meals can be ordered) +- Balance tracking and insufficient balance detection +- New exceptions: `InvalidMealTypeError`, `DuplicateMealError`, `InsufficientBalanceError` + +## Breaking Changes + +### Old API (Deprecated) +```python +strava = StravaCZ(username="...", password="...") + +# Fetching menu +menu_data = strava.get_menu(include_soup=True) + +# Printing menu +strava.print_menu() + +# Checking if ordered +is_ordered = strava.is_ordered(meal_id=4) + +# Ordering meals +strava.order_meals(3, 6) + +# Canceling meals +strava.cancel_meals(3, 6) +``` + +### New API (v0.2.0 - Current) +```python +from strava_cz import ( + StravaCZ, + MealType, + OrderType, + InvalidMealTypeError, + DuplicateMealError, + InsufficientBalanceError +) + +strava = StravaCZ(username="...", password="...", canteen_number="...") + +# Fetching menu (no parameters needed) +strava.menu.fetch() + +# Printing menu +strava.menu.print() + +# Checking if ordered +is_ordered = strava.menu.is_ordered(meal_id=4) + +# Ordering meals with advanced error handling +try: + strava.menu.order_meals( + 3, 6, + continue_on_error=False, # Stop on first error (default) + strict_duplicates=False # Warn about duplicates (default) + ) +except InvalidMealTypeError as e: + print(f"Invalid meal type: {e}") +except DuplicateMealError as e: + print(f"Duplicate meals: {e}") +except InsufficientBalanceError as e: + print(f"Insufficient balance: {e}") + +# Canceling meals +strava.menu.cancel_meals(3, 6, continue_on_error=False) + +# Check balance +print(f"Balance: {strava.user.balance} Kč") +``` + +## New Features in v0.2.0 + +### 1. Duplicate Meal Detection + +The system now automatically detects when you try to order multiple meals from the same day: + +```python +# Default behavior: warn and order only the first meal +strava.menu.order_meals(meal_1, meal_2_same_day) +# Warning: Skipping meal X from 2025-11-11 because meal Y from the same day is already being ordered + +# Strict mode: throw error +try: + strava.menu.order_meals(meal_1, meal_2_same_day, strict_duplicates=True) +except DuplicateMealError as e: + print(f"Error: {e}") +``` + +### 2. Meal Type Validation + +Only MAIN meals can be ordered or canceled. Soups are automatically served with main dishes: + +```python +try: + strava.menu.order_meals(soup_id) # Will raise InvalidMealTypeError +except InvalidMealTypeError as e: + print(f"Cannot order soups: {e}") +``` + +### 3. Balance Tracking + +User balance is automatically updated after each operation: + +```python +print(f"Before: {strava.user.balance} Kč") +strava.menu.order_meals(meal_id) +print(f"After: {strava.user.balance} Kč") # Updated automatically + +try: + strava.menu.order_meals(expensive_meal) +except InsufficientBalanceError as e: + print(f"Not enough money: {e}") + print(f"Current balance: {strava.user.balance} Kč") +``` + +### 4. Advanced Error Handling + +Control how errors are handled with `continue_on_error`: + +```python +# Default: stop on first error and rollback all changes +try: + strava.menu.order_meals(soup, main, another_main) +except InvalidMealTypeError: + pass # Nothing was ordered, all changes rolled back + +# Continue on error: collect all errors and report at the end +from strava_cz import StravaAPIError + +try: + strava.menu.order_meals( + soup, main, another_main, + continue_on_error=True + ) +except StravaAPIError as e: + # e contains all errors that occurred + # main and another_main were still ordered successfully + print(f"Some errors occurred: {e}") +``` + +### 5. Automatic Rollback + +When an error occurs (and `continue_on_error=False`), all changes are automatically rolled back: + +```python +try: + strava.menu.order_meals(meal_1, meal_2, invalid_meal) +except InvalidMealTypeError: + # meal_1 and meal_2 were NOT ordered + # all changes were automatically rolled back + pass +``` + +## Menu Class Features (v0.2.0) + +### Flexible Filtering System + +The new API uses two main methods with flexible parameters: + +**1. `get_days(meal_types=None, order_types=None, ordered=None)`** - Returns meals grouped by days +**2. `get_meals(meal_types=None, order_types=None, ordered=None)`** - Returns flat list of meals + +**Parameters:** +- `meal_types`: List of `MealType` values (e.g., `[MealType.SOUP, MealType.MAIN]`) + - `None` = all types +- `order_types`: List of `OrderType` values (e.g., `[OrderType.NORMAL, OrderType.RESTRICTED]`) + - `None` = defaults to `[OrderType.NORMAL]` (orderable meals only) +- `ordered`: Boolean filter for order status + - `True` = only ordered meals + - `False` = only unordered meals + - `None` = all meals + +**Examples:** + +```python +from strava_cz import StravaCZ, MealType, OrderType + +strava = StravaCZ(username="...", password="...", canteen_number="...") +strava.menu.fetch() + +# Get all orderable meals (default - grouped by days) +days = strava.menu.get_days() + +# Get all orderable meals as flat list +meals = strava.menu.get_meals() + +# Get only soups +soups = strava.menu.get_meals(meal_types=[MealType.SOUP]) + +# Get only main dishes +mains = strava.menu.get_days(meal_types=[MealType.MAIN]) + +# Get all meals including restricted and optional +all_meals = strava.menu.get_meals( + order_types=[OrderType.NORMAL, OrderType.RESTRICTED, OrderType.OPTIONAL] +) + +# Get only ordered meals +ordered = strava.menu.get_meals(ordered=True) + +# Get days with no orders +unordered_days = strava.menu.get_days(ordered=False) + +# Get restricted meals only +restricted = strava.menu.get_days(order_types=[OrderType.RESTRICTED]) + +# Complex filtering: ordered main dishes including optional ones +ordered_mains = strava.menu.get_meals( + meal_types=[MealType.MAIN], + order_types=[OrderType.NORMAL, OrderType.OPTIONAL], + ordered=True +) +``` + +### Direct Menu Access +```python +# Get menu for specific date (searches all order types) +today_menu = strava.menu.get_by_date("2025-11-10") + +# Get specific meal by ID (searches all order types) +meal = strava.menu.get_by_id(4) + +# Check order status (searches all order types) +is_ordered = strava.menu.is_ordered(4) +``` + +### Type Safety with Enums +```python +# meal["type"] is a MealType enum +for meal in strava.menu.get_meals(): + print(meal["type"]) # Output: MealType.SOUP or MealType.MAIN + print(meal["type"].value) # Output: "Polévka" or "Oběd" + +# meal["orderType"] indicates order restriction +for meal in strava.menu.get_meals(order_types=[OrderType.NORMAL, OrderType.RESTRICTED, OrderType.OPTIONAL]): + if meal["orderType"] == OrderType.RESTRICTED: + print(f"Meal {meal['id']} can no longer be ordered") + elif meal["orderType"] == OrderType.OPTIONAL: + print(f"Meal {meal['id']} is optional") +``` + +### Menu Information +```python +# Get number of orderable days in menu +num_days = len(strava.menu) + +# Get string representation +print(strava.menu) # Output: Menu(days=5, meals=25) + +# Iterate over orderable days +for day in strava.menu: + print(f"{day['date']}: {len(day['meals'])} meals") + +# Access raw API data +raw_data = strava.menu.raw_data +``` + +## Method Mapping + +| Old Method | New Method | Notes | +|------------|------------|-------| +| `strava.get_menu()` | `strava.menu.fetch()` | Returns Menu object for chaining | +| `strava.print_menu()` | `strava.menu.print()` | Renamed for brevity | +| `strava.is_ordered(id)` | `strava.menu.is_ordered(id)` | Moved to Menu class | +| `strava.order_meals(*ids)` | `strava.menu.order_meals(*ids)` | Moved to Menu class | +| `strava.cancel_meals(*ids)` | `strava.menu.cancel_meals(*ids)` | Moved to Menu class | + +## Benefits + +1. **Separation of Concerns**: Menu logic is isolated in its own class +2. **Self-Contained**: Menu can manage its own data and API calls +3. **Extensible**: Easy to add new filtering and processing methods +4. **Raw Data Access**: Both raw and processed data are preserved +5. **Method Chaining**: `fetch()` returns self for chaining +6. **Better Organization**: Related functionality grouped together + +## Complete Example + +```python +from strava_cz import StravaCZ, MealType + +# Login +strava = StravaCZ( + username="your.username", + password="YourPassword123", + canteen_number="3753" +) + +# Fetch and display menu +strava.menu.fetch(include_soup=True, include_empty=False) +strava.menu.print() + +# Work with menu data +print(f"Menu contains {len(strava.menu)} days") + +# Get ordered meals +ordered = strava.menu.get_ordered_meals() +print(f"You have {len(ordered)} meals ordered") + +# Filter by type +soups = strava.menu.filter_by_type(MealType.SOUP) +print(f"Found {len(soups)} soups") + +# Order new meals +strava.menu.order_meals(10, 15, 20) + +# Check if specific meal is ordered +if strava.menu.is_ordered(10): + print("Meal 10 is now ordered!") + +# Logout +strava.logout() +``` + +## Architecture + +The `Menu` class now holds a reference to its parent `StravaCZ` instance: + +```python +class Menu: + def __init__(self, strava_client: 'StravaCZ'): + self.strava = strava_client + self.raw_data = {} + self.processed_data = [] +``` + +This allows the Menu to: +- Access user credentials for API calls +- Call `_api_request()` method directly +- Refresh its own data after ordering/canceling meals +- Operate independently without external data passing diff --git a/README.md b/README.md index beb9d92..6099a60 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,13 @@ High level API pro interakci s webovou aplikaci Strava.cz udelane v Pythonu cist Ve slozce [notes](https://github.com/jsem-nerad/strava-cz-python/tree/main/notes) muzete najit veskere moje poznatky, ktere jsem zjistil o internim fungovani aplikace Strava.cz. ## Features -- Prihlaseni/odhlaseni -- Vypsani prefiltrovaneho jidelnicku -- Objednavani jidel podle ID jidla +- Prihlaseni/odhlaseni systemu +- Vypsani a filtrace jidelnicku +- Objednavani a odhlasovani jidel podle ID jidla +- Automaticke filtrovani jidel podle typu a objednatelnosti +- Vyhledavani jidel podle ID nebo data +- Ulozeni raw i zpracovanych dat z API +- Sledovani zustatku na uctu ## Usage @@ -16,45 +20,196 @@ Ve slozce [notes](https://github.com/jsem-nerad/strava-cz-python/tree/main/notes pip install strava-cz ``` - - ```python -from strava_cz import StravaCZ +from strava_cz import StravaCZ, MealType, OrderType # Vytvoreni objektu strava a prihlaseni uzivatele strava = StravaCZ( username="your.username", password="YourPassword123", - canteen_number="your canteen number" + canteen_number="your canteen number" # POVINNY parametr ) # Vypsani informaci o uzivateli print(strava.user) +print(f"Zustatok: {strava.user.balance} Kč") + +# Ziskani jidelnicku +strava.menu.fetch() +strava.menu.print() + +# Pristup k jidelnicku podle dni +days = strava.menu.get_days() # Default - vsechna objednavatelna jidla +print(f"Vsechny jidla: {len(days)} dni") + +# Pristup k jidlum jako ploschy seznam +meals = strava.menu.get_meals() # Vsechna jidla jako ploschy seznam +main_meals = strava.menu.get_meals(meal_types=[MealType.MAIN]) # Pouze hlavni jidla +soup_meals = strava.menu.get_meals(meal_types=[MealType.SOUP]) # Pouze polevky + +# Vcetne volitelnych jidel +complete = strava.menu.get_days( + order_types=[OrderType.NORMAL, OrderType.OPTIONAL] +) -# Ziskani jidelnicku; ulozi list do strava.menu -print(strava.get_menu()) +# Iterace pres menu (default seznam) +for day in strava.menu: + print(f"Datum: {day['date']}, Pocet jidel: {len(day['meals'])}") # Zjisti, jestli je jidlo s meal_id 4 objednano (True/False) -print(strava.is_ordered(4)) +print(strava.menu.is_ordered(4)) # Objedna jidla s meal_id 3 a 6 -strava.order_meals(3, 6) +# Automaticky detekuje duplicity a kontroluje typ jidla +strava.menu.order_meals(3, 6) + +# Objednani s pokrocilymi parametry +strava.menu.order_meals( + 3, 6, + continue_on_error=True, # Pokracuj i pri chybach + strict_duplicates=False # Pouze varuj pri duplikatech +) + +# Ziskani vsech objednanych jidel +ordered = strava.menu.get_meals(ordered=True) + +# Ziskani jidelnicku podle konkretniho data (prohledava vsechny seznamy) +today_menu = strava.menu.get_by_date("2025-11-04") + +# Zrus objednavky +strava.menu.cancel_meals(3, 6) # Odhlasi uzivatele strava.logout() ``` -> meal_id je unikatni identifikacni cislo jidla v celem jidelnicku. neni ovsem stale vazane na konkretni jidlo a meni se se zmenami jidelnicku +> meal_id je unikatni identifikacni cislo jidla v celem jidelnicku. neni ovsem stale vazane na konkretni jidlo a meni se se zmenami jidelnicku kazdy den + +> **Pozor!** Verze 0.2.0 obsahuje breaking changes. Prosim precti si [migration guide](MIGRATION_GUIDE.md) pro vice informaci o pruchodu na novou verzi. + +### Struktura dat jidla + +Kazde jidlo v menu obsahuje nasledujici polozky: +- `id` [int] - Unikatni identifikacni cislo jidla +- `type` [MealType] - Typ jidla (MealType.SOUP nebo MealType.MAIN) +- `orderType` [OrderType] - Typ objednavky (OrderType.NORMAL, OrderType.RESTRICTED, OrderType.OPTIONAL) +- `name` [str] - Nazev jidla +- `price` [float] - Cena jidla +- `ordered` [bool] - Zda je jidlo objednano +- `alergens` [str] - Alergeny +- `forbiddenAlergens` [str] - Zakazane alergeny + +### Enumy + +**MealType** - Typy jidel: +- `SOUP` - Polevka +- `MAIN` - Hlavni jidlo + +**OrderType** - Typy objednavek: +- `NORMAL` - Normalni objednavatelne jidlo +- `RESTRICTED` - Jidlo, ktere uz nelze objednat (prilis pozde - "CO") +- `OPTIONAL` - Jidlo, ktere obvykle neni objednavano, ale muze byt ("T") + + +### Menu class + +#### Vlastnosti (Properties) +| vlastnost | typ | popis | +|----------------|----------------------|--------------------------------------------------------------------------------| +| `raw_data` | dict | Surova odpoved z API bez zpracovani | + +#### Hlavni Metody +| funkce | parametry | return type | popis | +|---------------------|-----------------------------------------------------------|-------------|--------------------------------------------------------------------------------------------------------------------| +| `fetch()` | None | Menu | Ziska jidelnicek z API a zpracuje ho; vraci sam sebe | +| `print()` | None | None | Vypise zformatovane menu (default: pouze objednavatelna jidla) | +| `get_days()` | meal_types=None, order_types=None, ordered=None | list | Vrati jidla seskupena podle dni: `[{date, ordered, meals: [...]}]` | +| `get_meals()` | meal_types=None, order_types=None, ordered=None | list | Vrati vsechna jidla jako ploschy seznam: `[{...meal}]` | +| `order_meals()` | *meal_ids, continue_on_error=False, strict_duplicates=False | None | Objedna vice jidel; detekuje duplicity a kontroluje typy | +| `cancel_meals()` | *meal_ids, continue_on_error=False | None | Zrusi objednavky vice jidel | + +**Parametry filtrovani:** +- `meal_types` - Seznam typu jidel k ziskani (napr. `[MealType.SOUP, MealType.MAIN]`). None = vsechny typy +- `order_types` - Seznam typu objednavek k ziskani (napr. `[OrderType.NORMAL, OrderType.OPTIONAL]`). None = pouze `[OrderType.NORMAL]` +- `ordered` - Filtrovani podle stavu objednavky: `True` = pouze objednane, `False` = pouze neobjednane, `None` = vse + +**Priklady:** +```python +# Vsechna objednavatelna jidla podle dni (default) +menu.get_days() + +# Vsechna jidla jako flat seznam +menu.get_meals() + +# Pouze polevky +menu.get_meals(meal_types=[MealType.SOUP]) + +# Pouze objednana jidla +menu.get_meals(ordered=True) + +# Vcetne omezenych a volitelnych jidel +menu.get_days(order_types=[OrderType.NORMAL, OrderType.RESTRICTED, OrderType.OPTIONAL]) +``` + +#### Pomocne Metody +| funkce | parametry | return type | popis | +|---------------------|-----------------------------------------------------------|-------------|--------------------------------------------------------------------------------------------------------------------| +| `get_by_date()` | date [str] | dict/None | Vrati jidla pro konkretni datum (prohledava vsechny typy objednavek) | +| `get_by_id()` | meal_id [int] | dict/None | Vrati konkretni jidlo podle ID (prohledava vsechny typy objednavek) | +| `is_ordered()` | meal_id [int] | bool | Zjisti, jestli je dane jidlo objednano (prohledava vsechny typy objednavek) | + +#### Parametry objednavani +- `continue_on_error` - Pokud True, pokracuje pri chybach a sbirá je; pokud False (default), zastavi pri prvni chybe +- `strict_duplicates` - Pokud True, vyhodit chybu pri vice jidlech ze stejneho dne; pokud False (default), pouze varuje a objedna prvni +**Poznamka:** Menu objekt podporuje iteraci, indexovani a len() - vse pracuje s defaultnim seznamem objednatelnych jidel. + +### Exceptions + +Knihovna nabizi specialni vyjimky pro ruzne chybove stavy: + +| Exception | Popis | +|------------------------------|----------------------------------------------------------------| +| `StravaAPIError` | Zakladni vyjimka pro vsechny API chyby | +| `AuthenticationError` | Chyba pri prihlaseni nebo pokud uzivatel neni prihlasen | +| `InsufficientBalanceError` | Nedostatecny zustatek na uctu pro objednani jidla | +| `InvalidMealTypeError` | Pokus o objednani/zruseni jidla, ktere nelze modifikovat (polevka) | +| `DuplicateMealError` | Pokus o objednani vice jidel ze stejneho dne (strict mode) | + +**Priklad pouziti:** +```python +from strava_cz import StravaCZ, InvalidMealTypeError, InsufficientBalanceError, DuplicateMealError + +strava = StravaCZ("user", "pass", "1234") +strava.menu.fetch() + +try: + # Pokus o objednani polevky (vyhodit InvalidMealTypeError) + strava.menu.order_meals(75) # ID polevky +except InvalidMealTypeError as e: + print(f"Chyba: {e}") + +try: + # Pokus o objednani vice jidel ze stejneho dne + strava.menu.order_meals(1, 2, strict_duplicates=True) +except DuplicateMealError as e: + print(f"Duplicita: {e}") + +try: + # Pokus o objednani draheho jidla + strava.menu.order_meals(100) +except InsufficientBalanceError as e: + print(f"Nedostatecny zustatek: {e}") + print(f"Aktualni zustatek: {strava.user.balance} Kč") +``` + + +### StravaCZ class | funkce | parametry | return type | popis | |---------------------|-----------------------------------------------------------|-------------|--------------------------------------------------------------------------------------------------------------------| -| `__init__()` (=`StravaCZ()`) | username=None, password=None, canteen_number=None | None | Inicializuje objekt StravaCZ a automaticky prihlasi uzivatele, pokud jsou vyplnene parametry username a password | -| `login()` | username [str], password [str], canteen_number=None [str] | User | Prihlasi uzivatele pomoci uzivatelskeho jmena a hesla; pokud neni vyplnene cislo jidelny, automaticky pouzije 3753 | -| `get_menu()` | None | list | Vrati jidelnicek jako seznam podle dni; zaroven ho ulozi do promenne menu | -| `print_menu()` | include_soup [bool], include_empty [bool] | None | Vypise zformatovane menu | -| `is_ordered()` | meal_id [int] | bool | Zjisti, jestli je dane jidlo objednano | -| `order_meals()` | *meal_ids [int] | None | Objedna vice jidel podle meal_id | +| `__init__()` (=`StravaCZ()`) | username=None, password=None, canteen_number=None | None | Inicializuje objekt StravaCZ a automaticky prihlasi uzivatele, pokud jsou vyplnene vsechny tri parametry | +| `login()` | username [str], password [str], canteen_number [str] | User | Prihlasi uzivatele pomoci uzivatelskeho jmena, hesla a cisla jidelny (vsechny parametry jsou povinne) | | `logout()` | None | bool | Odhlasi uzivatele | @@ -65,11 +220,15 @@ strava.logout() - [x] Lepsi datum format - [x] Moznost detailnejsi filtrace jidelnicku - [x] Kontrola stavu po objednani +- [x] Filtrace dnu, ktere nejdou objednat +- [x] Lepsi testing +- [x] Balance check pred objednanim +- [x] Detekce a prevence duplicitnich objednavek +- [x] Kontrola typu jidla pri objednavani - [ ] Lepe zdokumentovat pouziti - [ ] Rate limiting -- [ ] Balance check pred objednanim -- [ ] Filtrace dnu, ktere nejdou objednat -- [ ] Lepsi testing +- [ ] Zrychleni interakce +- [ ] Debug/Log mode ## Known bugs @@ -90,5 +249,16 @@ Nasel jsi chybu nebo mas navrh na zlepseni? Skvele! Vytvor prosim [bug report](h Udelal jsi sam nejake zlepseni? Jeste lepsi! Kazdy pull request je vitan. +### Pouziti AI + +Na tento projekt byly do jiste miry vyuzity modely LLM, primarne na formatovani a dokumentaci kodu. V projektu nebyl ani nebude tolerovan cisty vibecoding. + +Zaznamy konkretniho pouziti: +- Kontrola syntaxe kodu a repetetivni upravy detailu +- Vytvoreni testu +- Vytvoreni example.py +- Uprava a ladeni README +- Obcasny zapis do CHANGELOGu +- Vytvoreni MIGRATION GUIDE diff --git a/examples/example.py b/examples/example.py index 556c9f6..5515343 100644 --- a/examples/example.py +++ b/examples/example.py @@ -1,23 +1,165 @@ -from strava_cz import StravaCZ +from strava_cz import ( + StravaCZ, + MealType, + OrderType, + InvalidMealTypeError, + DuplicateMealError, + InsufficientBalanceError, + StravaAPIError +) # Vytvoreni objektu strava a prihlaseni uzivatele strava = StravaCZ( username="your.username", password="YourPassword123", - canteen_number="your canteen number" + canteen_number="your canteen number" # POVINNY parametr! ) # Vypsani informaci o uzivateli print(strava.user) +print(f"Zustatok: {strava.user.balance} Kč") -# Ziskani jidelnicku; ulozi list do strava.menu -print(strava.get_menu()) +# Ziskani jidelnicku a vypsani +strava.menu.fetch() +strava.menu.print() + +# Informace o menu +print(f"\nMenu: {strava.menu}") +print(f"Pocet objednatelnych dni: {len(strava.menu)}") + +# Iterace pres objednavatelna jidla (default) +for day in strava.menu: + print(f"Datum: {day['date']}, Pocet jidel: {len(day['meals'])}") + +# ===== Ziskani jidel podle dni ===== + +# Pouze objednavatelna jidla (default) +normal_days = strava.menu.get_days() +print(f"\nObjednatelne dny: {len(normal_days)}") + +# Pouze polevky +soup_days = strava.menu.get_days(meal_types=[MealType.SOUP]) +print(f"Dny s polevkami: {len(soup_days)}") + +# Pouze hlavni jidla +main_days = strava.menu.get_days(meal_types=[MealType.MAIN]) +print(f"Dny s hlavnimi jidly: {len(main_days)}") + +# Vsechna jidla (vcetne omezenych a volitelnych) +all_days = strava.menu.get_days( + order_types=[OrderType.NORMAL, OrderType.RESTRICTED, OrderType.OPTIONAL] +) +print(f"Vsechny dny: {len(all_days)}") + +# Pouze dny s objednavkami +ordered_days = strava.menu.get_days(ordered=True) +print(f"Dny s objednavkami: {len(ordered_days)}") + +# Pouze dny bez objednavek +unordered_days = strava.menu.get_days(ordered=False) +print(f"Dny bez objednavek: {len(unordered_days)}") + +# ===== Ziskani jidel jako flat seznam ===== + +# Vsechna objednavatelna jidla +meals = strava.menu.get_meals() +print(f"\nCelkem objednatelnych jidel: {len(meals)}") + +# Pouze polevky +soups = strava.menu.get_meals(meal_types=[MealType.SOUP]) +print(f"Polevky: {len(soups)}") + +# Pouze hlavni jidla +mains = strava.menu.get_meals(meal_types=[MealType.MAIN]) +print(f"Hlavni jidla: {len(mains)}") + +# Pouze objednana jidla +ordered = strava.menu.get_meals(ordered=True) +print(f"Objednana jidla: {len(ordered)}") + +# Vcetne omezenych jidel +with_restricted = strava.menu.get_meals( + order_types=[OrderType.NORMAL, OrderType.RESTRICTED] +) +print(f"S omezenymi: {len(with_restricted)}") + +# Vsechna jidla (vcetne omezenych a volitelnych) +all_meals = strava.menu.get_meals( + order_types=[OrderType.NORMAL, OrderType.RESTRICTED, OrderType.OPTIONAL] +) +print(f"Vsechna jidla: {len(all_meals)}") + +# ===== Priklady kontroly typu objednavky ===== + +for meal in all_meals[:5]: # Prvnich 5 jidel + if meal['orderType'] == OrderType.RESTRICTED: + print(f" {meal['name']} - NELZE OBJEDNAT") + elif meal['orderType'] == OrderType.OPTIONAL: + print(f" {meal['name']} - VOLITELNE") + else: + print(f" {meal['name']} - NORMALNI") + +# ===== Vyhledavani ===== # Zjisti, jestli je jidlo s meal_id 4 objednano (True/False) -print(strava.is_ordered(4)) +print(f"\nJidlo 4 je objednano: {strava.menu.is_ordered(4)}") + +# Ziskej jidlo podle ID +meal = strava.menu.get_by_id(4) +if meal: + print(f"Jidlo 4: {meal['name']} ({meal['type'].value})") + +# Ziskej jidla pro konkretni datum +today_meals = strava.menu.get_by_date("2025-11-11") +if today_meals: + print(f"Jidla na 11.11: {len(today_meals['meals'])} jidel") + +# ===== Objednavani s pokrocilym zpracovanim chyb ===== + +# Zakladni objednavka +try: + print(f"\nZustatok pred objednanim: {strava.user.balance} Kč") + strava.menu.order_meals(3, 6) + print(f"Zustatok po objednani: {strava.user.balance} Kč") +except InvalidMealTypeError as e: + print(f"Neplatny typ jidla: {e}") +except InsufficientBalanceError as e: + print(f"Nedostatecny zustatok: {e}") +except DuplicateMealError as e: + print(f"Duplicitni jidla: {e}") + +# Objednavka s parametry +try: + strava.menu.order_meals( + 3, 6, 7, + continue_on_error=True, # Pokracuj i pri chybach + strict_duplicates=False # Pouze varuj pri duplicitach + ) +except StravaAPIError as e: + print(f"Nektera jidla selhala: {e}") + +# Strict mode - vyhodi chybu pri duplikatech +try: + strava.menu.order_meals(3, 4, strict_duplicates=True) # Pokud jsou ze stejneho dne +except DuplicateMealError as e: + print(f"Chyba duplicity: {e}") + +# Zrus objednavky +try: + strava.menu.cancel_meals(3, 6, continue_on_error=False) + print("Objednavky zruseny") +except StravaAPIError as e: + print(f"Chyba pri ruseni: {e}") + +# ===== Overeni stavu ===== + +# Zkontroluj, co je objednano +ordered_meals = strava.menu.get_meals(ordered=True) +print(f"\nCelkem objednano: {len(ordered_meals)} jidel") +for meal in ordered_meals: + print(f" - {meal['name']} ({meal['date']}): {meal['price']} Kč") -# Objedna jidla s meal_id 3 a 6 -strava.order_meals(3, 6) +print(f"\nKonecny zustatok: {strava.user.balance} Kč") # Odhlasi uzivatele strava.logout() \ No newline at end of file diff --git a/notes/repo_rules.md b/notes/repo_rules.md index 0d8a220..7917cdf 100644 --- a/notes/repo_rules.md +++ b/notes/repo_rules.md @@ -96,4 +96,11 @@ git branch -d feature/nazev-planovanych-funkci 3. Vytvoreni git tagu: `git tag v0.1.0` -4. Push na GitHub s tagem: `git push origin v0.1.0` \ No newline at end of file +4. Push na GitHub s tagem: `git push origin v0.1.0` + + +## Pouzivani LLM + +Silne nepodporuji vibecoding v tomto projektu, ale pro jeho mene podstatne a mene rizikove casti jako je dokumentace, vytvareni komentaru, hledani chyb, tvorba testu k novym funkcim atd. je vporadku LLM pouzit, ale pouze pod podminkou manualni kontroly uprav. + +Dekuji za pochopeni \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index a26439c..55f9f31 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "strava-cz" -version = "0.1.3" +version = "0.2.0" description = "High level API pro interakci s webovou aplikaci Strava.cz" readme = "README.md" license = "GPL-3.0-or-later" @@ -41,10 +41,10 @@ dev = [ ] [project.urls] -Homepage = "https://github.com/jsem-nerad/strava-cz" -Documentation = "https://github.com/jsem-nerad/strava-cz#readme" -Repository = "https://github.com/jsem-nerad/strava-cz" -Issues = "https://github.com/jsem-nerad/strava-cz/issues" +Homepage = "https://github.com/jsem-nerad/strava-cz-python" +Documentation = "https://github.com/jsem-nerad/strava-cz-python#readme" +Repository = "https://github.com/jsem-nerad/strava-cz-python" +Issues = "https://github.com/jsem-nerad/strava-cz-python/issues" [tool.setuptools.packages.find] where = ["src"] diff --git a/src/strava_cz/__init__.py b/src/strava_cz/__init__.py index 5803ac9..da13806 100644 --- a/src/strava_cz/__init__.py +++ b/src/strava_cz/__init__.py @@ -2,10 +2,32 @@ StravaCZ - High level API pro interakci s webovou aplikaci Strava.cz """ -from .main import StravaCZ, AuthenticationError, StravaAPIError, User +from .main import ( + StravaCZ, + AuthenticationError, + StravaAPIError, + InsufficientBalanceError, + DuplicateMealError, + InvalidMealTypeError, + User, + MealType, + OrderType, + Menu, +) -__version__ = "0.1.3" +__version__ = "0.2.0" __author__ = "Vojtěch Nerad" __email__ = "ja@jsem-nerad.cz" -__all__ = ["StravaCZ", "AuthenticationError", "StravaAPIError", "User"] +__all__ = [ + "StravaCZ", + "AuthenticationError", + "StravaAPIError", + "InsufficientBalanceError", + "DuplicateMealError", + "InvalidMealTypeError", + "User", + "MealType", + "OrderType", + "Menu", +] diff --git a/src/strava_cz/main.py b/src/strava_cz/main.py index ac806b5..de4b48e 100644 --- a/src/strava_cz/main.py +++ b/src/strava_cz/main.py @@ -1,9 +1,28 @@ """High level API pro interakci s webovou aplikaci Strava.cz""" +# Komentare v tomto kodu byly doplnene pomoci LLM + from typing import Dict, List, Optional, Any +from enum import Enum import requests +class MealType(Enum): + """Enum for meal types.""" + + SOUP = "Polévka" + MAIN = "Hlavní jídlo" + UNKNOWN = "Neznámý typ" + + +class OrderType(Enum): + """Enum for order restriction types.""" + + NORMAL = "Objednatelne" # Empty string - normal orderable + RESTRICTED = "Nelze objednat" # "CO" - too late to order + OPTIONAL = "Volitelne" # "T" - not usually ordered but can be + + class StravaAPIError(Exception): """Custom exception for Strava API errors.""" @@ -16,6 +35,24 @@ class AuthenticationError(StravaAPIError): pass +class InsufficientBalanceError(StravaAPIError): + """Raised when user has insufficient balance to order a meal.""" + + pass + + +class DuplicateMealError(StravaAPIError): + """Raised when trying to order multiple meals from the same day.""" + + pass + + +class InvalidMealTypeError(StravaAPIError): + """Raised when trying to order or cancel a meal type that cannot be modified (e.g., soup).""" + + pass + + class User: """User data container""" @@ -42,258 +79,255 @@ def __repr__(self): ) -class StravaCZ: - """Strava.cz API client""" - - BASE_URL = "https://app.strava.cz" - DEFAULT_CANTEEN_NUMBER = "3753" # Default SSPS canteen number - - def __init__( - self, - username: Optional[str] = None, - password: Optional[str] = None, - canteen_number: Optional[str] = None, - ): - """Initialize Strava.cz API client. - - Args: - username: User's login username - password: User's login password - canteen_number: Canteen number - """ - - self.session = requests.Session() - self.api_url = f"{self.BASE_URL}/api" - - self.user = User() # Initialize the user object - self.menu: List[Dict[str, Any]] = [] - - self._setup_headers() - self._initialize_session() - - # Auto-login if credentials are provided - if username and password: - self.login(username=username, password=password, canteen_number=canteen_number) - elif username == "" or password == "": - raise AuthenticationError("Both username and password are required for login") - - def _setup_headers(self) -> None: - """Set up default headers for API requests.""" - self.headers = { - "User-Agent": ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " - "(KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36" - ), - "Accept": "*/*", - "Accept-Language": "en-US,en;q=0.9,de-DE;q=0.8,de;q=0.7,cs;q=0.6", - "Accept-Encoding": "gzip, deflate, br, zstd", - "Content-Type": "text/plain;charset=UTF-8", - "Origin": self.BASE_URL, - "Referer": f"{self.BASE_URL}/en/prihlasit-se?jidelna", - "sec-fetch-dest": "empty", - "sec-fetch-mode": "cors", - "sec-fetch-site": "same-origin", - } - - def _initialize_session(self) -> None: - """Initialize session with initial GET request.""" - self.session.get(f"{self.BASE_URL}/en/prihlasit-se?jidelna") +class Menu: + """Menu data container and processor""" - def _api_request( - self, endpoint: str, payload: Optional[Dict[str, Any]] = None - ) -> Dict[str, Any]: - """Make API request to Strava.cz endpoint. + def __init__(self, strava_client: "StravaCZ"): + """Initialize Menu with reference to StravaCZ client. Args: - endpoint: API endpoint path - payload: Request payload data - - Returns: - Dictionary containing status code and response data - - Raises: - StravaAPIError: If API request fails - """ - url = f"{self.api_url}/{endpoint}" - try: - response = self.session.post(url, json=payload, headers=self.headers) - return {"status_code": response.status_code, "response": response.json()} - except requests.RequestException as e: - raise StravaAPIError(f"API request failed: {e}") - - def login(self, username, password, canteen_number=None): - """Log in to Strava.cz account. - - Args: - username: User's login username - password: User's login password - canteen_number: Canteen number - - Returns: - User object with populated account information - - Raises: - AuthenticationError: If user is already logged in or login fails - ValueError: If username or password is empty + strava_client: Reference to the parent StravaCZ instance """ - if self.user.is_logged_in: - raise AuthenticationError("User already logged in") - if not username or not password: - raise ValueError("Username and password are required for login") - - self.user.username = username - self.user.password = password - self.user.canteen_number = canteen_number or self.DEFAULT_CANTEEN_NUMBER - - payload = { - "cislo": self.user.canteen_number, - "jmeno": self.user.username, - "heslo": self.user.password, - "zustatPrihlasen": True, - "environment": "W", - "lang": "EN", - } + self.strava = strava_client + self.raw_data: Dict[str, Any] = {} + self._all_meals: List[Dict[str, Any]] = [] # Internal storage for all meals - response = self._api_request("login", payload) - - if response["status_code"] == 200: - self._populate_user_data(response["response"]) - self.user.is_logged_in = True - return self.user - else: - error_message = response["response"].get("message", "Unknown error") - raise AuthenticationError(f"Login failed: {error_message}") - - def _populate_user_data(self, data: Dict[str, Any]) -> None: - """Populate user object with login response data.""" - user_data = data.get("uzivatel", {}) - - self.user.sid = data.get("sid", "") - self.user.s5url = data.get("s5url", "") - self.user.full_name = user_data.get("jmeno", "") - self.user.email = user_data.get("email", "") - self.user.balance = user_data.get("konto", 0.0) - self.user.id = user_data.get("id", 0) - self.user.currency = user_data.get("mena", "Kč") - self.user.canteen_name = user_data.get("nazevJidelny", "") - - def get_menu( - self, include_soup: bool = False, include_empty: bool = False - ) -> List[Dict[str, Any]]: - """Retrieve and parse user's menu list from API. - - Args: - include_soup: Whether to include soups in the menu - include_empty: Whether to include empty meals or meals not named yet + def fetch(self) -> "Menu": + """Fetch menu data from API and process it into various lists. Returns: - List of menu items grouped by date + Self for method chaining Raises: AuthenticationError: If user is not logged in StravaAPIError: If menu retrieval fails """ - if not self.user.is_logged_in: + if not self.strava.user.is_logged_in: raise AuthenticationError("User not logged in") payload = { - "cislo": self.user.canteen_number, - "sid": self.user.sid, - "s5url": self.user.s5url, + "cislo": self.strava.user.canteen_number, + "sid": self.strava.user.sid, + "s5url": self.strava.user.s5url, "lang": "EN", - "konto": self.user.balance, + "konto": self.strava.user.balance, "podminka": "", "ignoreCert": False, } - response = self._api_request("objednavky", payload) + response = self.strava._api_request("objednavky", payload) if response["status_code"] != 200: raise StravaAPIError("Failed to fetch menu") - self.menu = self._parse_menu_response(response["response"], include_soup, include_empty) - return self.menu + self.raw_data = response["response"] + self._parse_menu_data() + return self - def _parse_menu_response( - self, menu_data: Dict[str, Any], include_soup: bool = False, include_empty: bool = False - ) -> List[Dict[str, Any]]: - """Parse raw menu response into structured format.""" - meals_by_date: Dict[str, Any] = {} + def _parse_menu_data(self) -> None: + """Parse raw menu response into internal storage.""" + # Single storage for all meals grouped by date + meals_by_date: Dict[str, List[Dict]] = {} # Process all table entries (table0, table1, etc.) - for table_key, meals_list in menu_data.items(): + for table_key, meals_list in self.raw_data.items(): if not table_key.startswith("table"): continue for meal in meals_list: - if not include_empty: - has_no_description = not meal["delsiPopis"] and not meal["alergeny"] - is_unnamed_meal = meal["nazev"] == meal["druh_popis"] - cant_be_ordered = ( - "CO" in meal["omezeniObj"]["den"] or "VP" in meal["omezeniObj"]["den"] - ) # Hardcoded values for meals that can't be ordered, there may be more - if has_no_description or is_unnamed_meal or cant_be_ordered: - continue - - if not include_soup and meal["druh_popis"] == "Polévka": + # Skip empty meals + has_no_description = not meal["delsiPopis"] and not meal["alergeny"] + is_unnamed_meal = meal["nazev"] == meal["druh_popis"] + if has_no_description or is_unnamed_meal: + continue + + # Get restriction status + restriction = meal["omezeniObj"]["den"] + + # Skip "VP" (no school) completely + if "VP" in restriction: continue + # Parse date unformated_date = meal["datum"] # Format: "dd-mm.yyyy" date = f"{unformated_date[6:10]}-{unformated_date[3:5]}-{unformated_date[0:2]}" + # Convert string type to MealType enum + meal_type_str = meal["druh_popis"] + if meal_type_str == "Polévka": + meal_type = MealType.SOUP + elif "Oběd" in meal_type_str: + meal_type = MealType.MAIN + else: + meal_type = MealType.UNKNOWN + + # Skip unknown types + if meal_type == MealType.UNKNOWN: + continue + + # Determine order type + if "CO" in restriction: + order_type = OrderType.RESTRICTED + elif "T" in restriction: + order_type = OrderType.OPTIONAL + else: # Empty string - orderable + order_type = OrderType.NORMAL + meal_filtered = { - "local_id": meal["id"], - "type": meal["druh_popis"], + "type": meal_type, + "orderType": order_type, "name": meal["nazev"], "forbiddenAlergens": meal["zakazaneAlergeny"], "alergens": meal["alergeny"], "ordered": meal["pocet"] == 1, "id": int(meal["veta"]), "price": float(meal["cena"]), + "date": date, } + # Store all meals together if date not in meals_by_date: meals_by_date[date] = [] meals_by_date[date].append(meal_filtered) - # Convert to final format - return [ - {"date": date, "ordered": any(meal["ordered"] for meal in meals), "meals": meals} - for date, meals in meals_by_date.items() - ] + # Convert to day-grouped format and sort by date + self._all_meals = sorted( + [ + {"date": date, "ordered": any(m["ordered"] for m in meals), "meals": meals} + for date, meals in meals_by_date.items() + ], + key=lambda x: x["date"], + ) - def print_menu(self) -> None: - """Print the current menu in a readable format.""" - if not self.menu: - self.get_menu() + def get_days( + self, + meal_types: Optional[List[MealType]] = None, + order_types: Optional[List[OrderType]] = None, + ordered: Optional[bool] = None, + ) -> List[Dict[str, Any]]: + """Get menu grouped by days with optional filtering. - for day in self.menu: - print(f"Date: {day['date']} - {'Ordered' if day['ordered'] else 'Not ordered'}") - for meal in day["meals"]: - status = "Ordered" if meal["ordered"] else "Not ordered" - print(f" - {meal['id']} {meal['name']} ({meal['type']}) - [{status}]") - print() + Args: + meal_types: List of meal types to include (None = all types) + order_types: List of order types to include + (None = [OrderType.NORMAL] only) + ordered: Filter by order status + (True = ordered only, False = unordered only, None = all) - def is_ordered(self, meal_id: int) -> bool: - """Check wheather a meal is ordered or not. + Returns: + List of days with meals: + [{"date": "YYYY-MM-DD", "ordered": bool, "meals": [...]}] + """ + # Default to NORMAL order type only + if order_types is None: + order_types = [OrderType.NORMAL] + + filtered_days = [] + for day in self._all_meals: + # Filter meals by type and order type + filtered_meals = [ + meal + for meal in day["meals"] + if (meal_types is None or meal["type"] in meal_types) + and (meal["orderType"] in order_types) + ] + + if not filtered_meals: + continue + + # Check if day has ordered meals + day_has_orders = any(m["ordered"] for m in filtered_meals) + + # Apply ordered filter + if ordered is not None: + if ordered and not day_has_orders: + continue + if not ordered and day_has_orders: + continue + + filtered_days.append( + {"date": day["date"], "ordered": day_has_orders, "meals": filtered_meals} + ) + + return filtered_days + + def get_meals( + self, + meal_types: Optional[List[MealType]] = None, + order_types: Optional[List[OrderType]] = None, + ordered: Optional[bool] = None, + ) -> List[Dict[str, Any]]: + """Get all meals as flat list with optional filtering. Args: - meal_id: Meal identification number + meal_types: List of meal types to include (None = all types) + order_types: List of order types to include + (None = [OrderType.NORMAL] only) + ordered: Filter by order status + (True = ordered only, False = unordered only, None = all) Returns: - True if meal is ordered, False otherwise + Flat list of meals with date: [{...meal, "date": "YYYY-MM-DD"}] + """ + # Default to NORMAL order type only + if order_types is None: + order_types = [OrderType.NORMAL] - Raises: - AuthenticationError: If user is not logged in + meals = [] + for day in self._all_meals: + for meal in day["meals"]: + # Apply filters + if meal_types is not None and meal["type"] not in meal_types: + continue + if meal["orderType"] not in order_types: + continue + if ordered is not None and meal["ordered"] != ordered: + continue + + meals.append(meal) + + return meals + + def get_by_date(self, date: str) -> Optional[Dict[str, Any]]: + """Get menu items for a specific date (searches all order types). + + Args: + date: Date in YYYY-MM-DD format + + Returns: + Dictionary with date and meals, or None if not found """ - if not self.user.is_logged_in: - raise AuthenticationError("User not logged in") + for day in self._all_meals: + if day["date"] == date: + return day + return None + + def get_by_id(self, meal_id: int) -> Optional[Dict[str, Any]]: + """Get a specific meal by its ID (searches all order types). + + Args: + meal_id: Meal identification number - for day in self.menu: + Returns: + Meal dictionary, or None if not found + """ + for day in self._all_meals: for meal in day["meals"]: if meal["id"] == meal_id: - return meal["ordered"] - return False + return meal + return None + + def is_ordered(self, meal_id: int) -> bool: + """Check whether a meal is ordered or not (searches all order types). + + Args: + meal_id: Meal identification number + + Returns: + True if meal is ordered, False otherwise + """ + meal = self.get_by_id(meal_id) + return meal["ordered"] if meal else False def _change_meal_order(self, meal_id: int, ordered: bool) -> bool: """Change the order status of a meal (without saving). @@ -307,50 +341,59 @@ def _change_meal_order(self, meal_id: int, ordered: bool) -> bool: Raises: AuthenticationError: If user is not logged in + InsufficientBalanceError: If insufficient balance to order meal + InvalidMealTypeError: If trying to order/cancel non-MAIN meal type StravaAPIError: If changing meal order status fails """ - if not self.user.is_logged_in: + if not self.strava.user.is_logged_in: raise AuthenticationError("User not logged in") if self.is_ordered(meal_id) == ordered: return True + # Check meal type - only MAIN meals can be ordered/canceled + meal = self.get_by_id(meal_id) + if meal and meal["type"] != MealType.MAIN: + raise InvalidMealTypeError( + f"Cannot order or cancel {meal['type'].value} meals. " + f"Only main dishes (MAIN) can be ordered or canceled." + ) + payload = { - "cislo": self.user.canteen_number, - "sid": self.user.sid, - "url": self.user.s5url, + "cislo": self.strava.user.canteen_number, + "sid": self.strava.user.sid, + "url": self.strava.user.s5url, "veta": str(meal_id), "pocet": "1" if ordered else "0", "lang": "EN", "ignoreCert": "false", } - response = self._api_request("pridejJidloS5", payload) - if response["status_code"] != 200: - raise StravaAPIError("Failed to change meal order status") - return True - - def _add_meal_to_order(self, meal_id: int) -> bool: - """Add a meal to the order (without saving). - - Args: - meal_id: Meal identification number + response = self.strava._api_request("pridejJidloS5", payload) - Returns: - True if meal was added successfully - """ - return self._change_meal_order(meal_id, True) - - def _cancel_meal_order(self, meal_id: int) -> bool: - """Cancel a meal order (without saving). - - Args: - meal_id: Meal identification number + if response["status_code"] != 200: + # Check for specific error codes + response_data = response.get("response", {}) + + # Error code 35 = insufficient balance + if response_data.get("number") == 35: + error_msg = response_data.get("message", "Insufficient balance") + raise InsufficientBalanceError(error_msg) + + raise StravaAPIError( + f"Failed to change meal order status: " + f"{response_data.get('message', 'Unknown error')}" + ) + + # Update balance from response + response_data = response.get("response", {}) + if "konto" in response_data: + try: + self.strava.user.balance = float(response_data["konto"]) + except (ValueError, TypeError): + pass # Keep old balance if parsing fails - Returns: - True if meal was canceled successfully - """ - return self._change_meal_order(meal_id, False) + return True def _save_order(self) -> bool: """Save current order changes. @@ -362,53 +405,387 @@ def _save_order(self) -> bool: AuthenticationError: If user is not logged in StravaAPIError: If saving order fails """ - if not self.user.is_logged_in: + if not self.strava.user.is_logged_in: raise AuthenticationError("User not logged in") payload = { - "cislo": self.user.canteen_number, - "sid": self.user.sid, - "url": self.user.s5url, + "cislo": self.strava.user.canteen_number, + "sid": self.strava.user.sid, + "url": self.strava.user.s5url, "xml": None, "lang": "EN", "ignoreCert": "false", } - response = self._api_request("saveOrders", payload) + response = self.strava._api_request("saveOrders", payload) if response["status_code"] != 200: raise StravaAPIError("Failed to save order") return True - def order_meals(self, *meal_ids: int) -> None: + def _cancel_order(self) -> bool: + """Cancel current order changes (revert to previous state). + + Returns: + True if order was canceled successfully + + Raises: + AuthenticationError: If user is not logged in + StravaAPIError: If canceling order fails + """ + if not self.strava.user.is_logged_in: + raise AuthenticationError("User not logged in") + + payload = { + "sid": self.strava.user.sid, + "url": self.strava.user.s5url, + "cislo": self.strava.user.canteen_number, + "ignoreCert": "false", + "lang": "EN", + "getText": True, + "checkVersion": True, + "resetTables": True, + "frontendFunction": "refreshInformations", + } + + response = self.strava._api_request("nactiVlastnostiPA", payload) + + if response["status_code"] != 200: + raise StravaAPIError("Failed to cancel order changes") + + # Update balance from response + response_data = response.get("response", {}) + if "konto" in response_data: + try: + self.strava.user.balance = float(response_data["konto"]) + except (ValueError, TypeError): + pass + + return True + + def order_meals( + self, + *meal_ids: int, + continue_on_error: bool = False, + strict_duplicates: bool = False, + ) -> None: """Order multiple meals in a single transaction. Args: *meal_ids: Variable number of meal identification numbers + continue_on_error: If True, continue ordering other meals if one fails + and collect errors. If False (default), stop on first error + and cancel all changes. + strict_duplicates: If True, raise DuplicateMealError when multiple + meals from the same day are being ordered. If False (default), + only order the first meal from each day and warn about skipped duplicates. + + Raises: + InsufficientBalanceError: If insufficient balance (only if continue_on_error=False) + InvalidMealTypeError: If trying to order non-MAIN meal type + (only if continue_on_error=False) + DuplicateMealError: If ordering multiple meals from same day + (only if strict_duplicates=True) + StravaAPIError: If ordering any meal fails (only if continue_on_error=False) """ + import warnings + + # Detect duplicate days + seen_dates: Dict[str, int] = {} + filtered_meal_ids: List[int] = [] + skipped_meals: List[tuple] = [] + for meal_id in meal_ids: - self._add_meal_to_order(meal_id) + meal = self.get_by_id(meal_id) + if not meal: + if continue_on_error: + warnings.warn(f"Meal with ID {meal_id} not found, skipping") + continue + else: + raise StravaAPIError(f"Meal with ID {meal_id} not found") + + meal_date = meal["date"] + + if meal_date in seen_dates: + # Duplicate day detected + if strict_duplicates: + raise DuplicateMealError( + f"Cannot order multiple meals from the same day ({meal_date}). " + f"Meal IDs {seen_dates[meal_date]} and {meal_id} are from the same day." + ) + else: + skipped_meals.append((meal_id, meal_date, seen_dates[meal_date])) + continue + + seen_dates[meal_date] = meal_id + filtered_meal_ids.append(meal_id) + + # Warn about skipped duplicates + if skipped_meals and not strict_duplicates: + for meal_id, meal_date, first_meal_id in skipped_meals: + warnings.warn( + f"Skipping meal {meal_id} from {meal_date} because meal {first_meal_id} " + f"from the same day is already being ordered" + ) + + errors = [] + failed_meal_ids = set() # Track meals that already failed + + for meal_id in filtered_meal_ids: + try: + self._change_meal_order(meal_id, True) + except (InsufficientBalanceError, InvalidMealTypeError, StravaAPIError) as e: + if continue_on_error: + errors.append((meal_id, str(e))) + failed_meal_ids.add(meal_id) # Mark as failed + else: + # Cancel all changes and re-raise + self._cancel_order() + raise + self._save_order() - self.get_menu() + self.fetch() # Refresh menu data - for meal_id in meal_ids: + # Verify orders (skip meals that already failed) + for meal_id in filtered_meal_ids: + if meal_id in failed_meal_ids: + continue # Skip verification for meals that already had errors if not self.is_ordered(meal_id): - raise StravaAPIError(f"Failed to order meal with ID {meal_id}") - - def cancel_meals(self, *meal_ids: int) -> None: + error_msg = f"Failed to order meal with ID {meal_id}" + if continue_on_error: + errors.append((meal_id, error_msg)) + else: + raise StravaAPIError(error_msg) + + # If there were errors and continue_on_error is True, report them + if errors and continue_on_error: + error_details = "; ".join([f"Meal {mid}: {err}" for mid, err in errors]) + raise StravaAPIError(f"Some meals failed to order: {error_details}") + + def cancel_meals(self, *meal_ids: int, continue_on_error: bool = False) -> None: """Cancel multiple meal orders in a single transaction. Args: *meal_ids: Variable number of meal identification numbers + continue_on_error: If True, continue canceling other meals if one fails + and collect errors. If False (default), stop on first error + and cancel all changes. + + Raises: + InvalidMealTypeError: If trying to cancel non-MAIN meal type + (only if continue_on_error=False) + StravaAPIError: If canceling any meal fails (only if continue_on_error=False) """ + errors = [] + failed_meal_ids = set() # Track meals that already failed + for meal_id in meal_ids: - self._cancel_meal_order(meal_id) + try: + self._change_meal_order(meal_id, False) + except (InvalidMealTypeError, StravaAPIError) as e: + if continue_on_error: + errors.append((meal_id, str(e))) + failed_meal_ids.add(meal_id) # Mark as failed + else: + # Cancel all changes and re-raise + self._cancel_order() + raise + self._save_order() - self.get_menu() + self.fetch() # Refresh menu data + # Verify cancellations (skip meals that already failed) for meal_id in meal_ids: + if meal_id in failed_meal_ids: + continue # Skip verification for meals that already had errors if self.is_ordered(meal_id): - raise StravaAPIError(f"Failed to cancel meal with ID {meal_id}") + error_msg = f"Failed to cancel meal with ID {meal_id}" + if continue_on_error: + errors.append((meal_id, error_msg)) + else: + raise StravaAPIError(error_msg) + + # If there were errors and continue_on_error is True, report them + if errors and continue_on_error: + error_details = "; ".join([f"Meal {mid}: {err}" for mid, err in errors]) + raise StravaAPIError(f"Some meals failed to cancel: {error_details}") + + def print(self) -> None: + """Print formatted menu (default: orderable meals only).""" + days = self.get_days() + for day in days: + print(f"{day['date']}:") + for meal in day["meals"]: + meal_type_str = meal["type"].value + status = "Ordered" if meal["ordered"] else "Not ordered" + order_type_info = "" + if meal["orderType"] != OrderType.NORMAL: + order_type_info = f" [{meal['orderType'].name}]" + meal_info = ( + f" - {meal['id']} {meal['name']} " + f"({meal_type_str}){order_type_info} - [{status}]" + ) + print(meal_info) + print() + + def __repr__(self) -> str: + """Return representation of menu.""" + days = self.get_days() + total_meals = sum(len(day["meals"]) for day in days) + return f"Menu(days={len(days)}, meals={total_meals})" + + def __str__(self) -> str: + """Return string representation of menu.""" + return self.__repr__() + + def __iter__(self): + """Iterate over orderable days.""" + return iter(self.get_days()) + + def __len__(self) -> int: + """Return the number of orderable days.""" + return len(self.get_days()) + + def __getitem__(self, key): + """Access days by index from orderable days.""" + return self.get_days()[key] + + +class StravaCZ: + """Strava.cz API client""" + + BASE_URL = "https://app.strava.cz" + + def __init__( + self, + username: Optional[str] = None, + password: Optional[str] = None, + canteen_number: Optional[str] = None, + ): + """Initialize Strava.cz API client. + + Args: + username: User's login username + password: User's login password + canteen_number: Canteen number (required for login) + """ + + self.session = requests.Session() + self.api_url = f"{self.BASE_URL}/api" + + self.user = User() # Initialize the user object + self.menu = Menu(self) # Initialize the menu object with reference to self + + self._setup_headers() + self._initialize_session() + + # Auto-login if credentials are provided + if username and password: + self.login(username=username, password=password, canteen_number=canteen_number) + elif username == "" or password == "": + raise AuthenticationError("Both username and password are required for login") + + def _setup_headers(self) -> None: + """Set up default headers for API requests.""" + self.headers = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36" + ), + "Accept": "*/*", + "Accept-Language": "en-US,en;q=0.9,de-DE;q=0.8,de;q=0.7,cs;q=0.6", + "Accept-Encoding": "gzip, deflate, br, zstd", + "Content-Type": "text/plain;charset=UTF-8", + "Origin": self.BASE_URL, + "Referer": f"{self.BASE_URL}/en/prihlasit-se?jidelna", + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", + } + + def _initialize_session(self) -> None: + """Initialize session with initial GET request.""" + self.session.get(f"{self.BASE_URL}/en/prihlasit-se?jidelna") + + def _api_request( + self, endpoint: str, payload: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """Make API request to Strava.cz endpoint. + + Args: + endpoint: API endpoint path + payload: Request payload data + + Returns: + Dictionary containing status code and response data + + Raises: + StravaAPIError: If API request fails + """ + url = f"{self.api_url}/{endpoint}" + try: + response = self.session.post(url, json=payload, headers=self.headers) + return {"status_code": response.status_code, "response": response.json()} + except requests.RequestException as e: + raise StravaAPIError(f"API request failed: {e}") + + def login(self, username, password, canteen_number): + """Log in to Strava.cz account. + + Args: + username: User's login username + password: User's login password + canteen_number: Canteen number (required) + + Returns: + User object with populated account information + + Raises: + AuthenticationError: If user is already logged in or login fails + ValueError: If username, password, or canteen_number is missing + """ + if self.user.is_logged_in: + raise AuthenticationError("User already logged in") + if not username or not password: + raise ValueError("Username and password are required for login") + if not canteen_number: + raise ValueError("Canteen number is required for login") + + self.user.username = username + self.user.password = password + self.user.canteen_number = canteen_number + + payload = { + "cislo": self.user.canteen_number, + "jmeno": self.user.username, + "heslo": self.user.password, + "zustatPrihlasen": True, + "environment": "W", + "lang": "EN", + } + + response = self._api_request("login", payload) + + if response["status_code"] == 200: + self._populate_user_data(response["response"]) + self.user.is_logged_in = True + return self.user + else: + error_message = response["response"].get("message", "Unknown error") + raise AuthenticationError(f"Login failed: {error_message}") + + def _populate_user_data(self, data: Dict[str, Any]) -> None: + """Populate user object with login response data.""" + user_data = data.get("uzivatel", {}) + + self.user.sid = data.get("sid", "") + self.user.s5url = data.get("s5url", "") + self.user.full_name = user_data.get("jmeno", "") + self.user.email = user_data.get("email", "") + self.user.balance = user_data.get("konto", 0.0) + self.user.id = user_data.get("id", 0) + self.user.currency = user_data.get("mena", "Kč") + self.user.canteen_name = user_data.get("nazevJidelny", "") def logout(self) -> bool: """Log out from Strava.cz account. @@ -434,7 +811,7 @@ def logout(self) -> bool: if response["status_code"] == 200: self.user = User() # Reset user - self.menu = [] # Clear menu + self.menu = Menu(self) # Clear menu return True else: raise StravaAPIError("Failed to logout") @@ -455,11 +832,20 @@ def logout(self) -> bool: password=STRAVA_PASSWORD, canteen_number=STRAVA_CANTEEN_NUMBER, ) - print(strava.user) - strava.get_menu(include_soup=True) + # Ziskani jidelnicku a vypsani + strava.menu.fetch() + + # Vsechna objednavatelna jidla + days = strava.menu.get_days( + order_types=[OrderType.NORMAL], ordered=False, meal_types=[MealType.SOUP] + ) + print("".join(f"{day['date']}\n" for day in days)) + + meal_ids = [] + for day in days: + meal_ids.append(day["meals"][0]["id"]) - strava.print_menu() + strava.menu.order_meals(64, *meal_ids[:5], continue_on_error=True, strict_duplicates=False) strava.logout() - print("Logged out") diff --git a/tests/test_strava_cz.py b/tests/test_strava_cz.py index 4ba6de5..0d570e0 100644 --- a/tests/test_strava_cz.py +++ b/tests/test_strava_cz.py @@ -1,6 +1,15 @@ +# !! NOTICE / UPOZORNENI !! +# =========================== +# These tests were made mostly by an LLM, +# but they have been reviewed by me. +# - - - - - - - - - - - - - +# Tyto testy byly vytvořeny převážně pomociLLM, +# ale byly mnou zkontrolovany. +# =========================== + import pytest from unittest.mock import patch, MagicMock -from strava_cz import StravaCZ, AuthenticationError +from strava_cz import StravaCZ, AuthenticationError, MealType, OrderType class TestStravaCZ: """Test StravaCZ without real credentials using mocks.""" @@ -102,8 +111,8 @@ def test_login_failure(self, mock_post): StravaCZ("bad_user", "bad_pass", "1234") @patch('strava_cz.main.requests.Session') - def test_get_menu(self, mock_Session): - """Test get_menu returns correctly formatted data without real HTTP calls.""" + def test_menu_fetch_and_filtering(self, mock_Session): + """Test menu.fetch() and filtering methods work correctly.""" # Arrange: fake session and responses fake_session = MagicMock() mock_Session.return_value = fake_session @@ -114,7 +123,7 @@ def test_get_menu(self, mock_Session): login_response.json.return_value = { "sid": "SID123", "s5url": "https://fake.s5url", - "cislo": "3753", + "cislo": "1234", "jmeno": "user", "uzivatel": { "id": "user", @@ -128,14 +137,14 @@ def test_get_menu(self, mock_Session): "zustatPrihlasen": False } - # 2) menu response + # 2) menu response with different meal types and order types menu_response = MagicMock() menu_response.status_code = 200 menu_response.json.return_value = { "table0": [ { "id": 0, - "datum": "15.09.2025", + "datum": "15-09.2025", "druh_popis": "Polévka", "delsiPopis": "zelnacka", "nazev": "Vývar", @@ -148,7 +157,7 @@ def test_get_menu(self, mock_Session): }, { "id": 1, - "datum": "15.09.2025", + "datum": "15-09.2025", "druh_popis": "Oběd1", "delsiPopis": "Rajská omáčka s těstovinami", "nazev": "Rajská omáčka s těstovinami", @@ -158,40 +167,909 @@ def test_get_menu(self, mock_Session): "pocet": 1, "veta": "1", "cena": "40.00" + }, + { + "id": 2, + "datum": "16-09.2025", + "druh_popis": "Oběd1", + "delsiPopis": "Restricted meal", + "nazev": "Restricted meal", + "zakazaneAlergeny": None, + "alergeny": [], + "omezeniObj": {"den": "CO"}, + "pocet": 0, + "veta": "2", + "cena": "50.00" + }, + { + "id": 3, + "datum": "16-09.2025", + "druh_popis": "Oběd2", + "delsiPopis": "Optional meal", + "nazev": "Optional meal", + "zakazaneAlergeny": None, + "alergeny": [], + "omezeniObj": {"den": "T"}, + "pocet": 0, + "veta": "3", + "cena": "45.00" } ] } - # Configure post side_effect: first call is login, second is menu list + # Configure post side_effect: first call is login, second is menu fake_session.post.side_effect = [login_response, menu_response] # Act: initialize (logs in) and fetch menu - s = StravaCZ("user", "pass", "3753") - menu = s.get_menu(include_soup=True) - - # Assert structure - assert isinstance(menu, list) - assert len(menu) == 1 - day = menu[0] - assert day["date"] == "2025-09-15" - meals = day["meals"] - assert len(meals) == 2 - - # First meal: not ordered - first = meals[0] - assert first["local_id"] == 0 - assert first["type"] == "Polévka" - assert first["name"] == "Vývar" - assert first["ordered"] is False - assert first["id"] == 75 - - # Second meal: ordered - second = meals[1] - assert second["local_id"] == 1 - assert second["type"] == "Oběd1" - assert second["name"] == "Rajská omáčka s těstovinami" - assert second["ordered"] is True - assert second["id"] == 1 - - # Verify two POST calls occurred + s = StravaCZ("user", "pass", "1234") + s.menu.fetch() + + # Test get_days() - default should return only NORMAL order types + normal_days = s.menu.get_days() + assert len(normal_days) == 1 + assert normal_days[0]["date"] == "2025-09-15" + assert len(normal_days[0]["meals"]) == 2 # Soup and main + + # Test get_days with all order types + all_days = s.menu.get_days( + order_types=[OrderType.NORMAL, OrderType.RESTRICTED, OrderType.OPTIONAL] + ) + assert len(all_days) == 2 + + # Test get_meals() - flat list + normal_meals = s.menu.get_meals() + assert len(normal_meals) == 2 + + # Test filtering by meal type + soups = s.menu.get_meals(meal_types=[MealType.SOUP]) + assert len(soups) == 1 + assert soups[0]["type"] == MealType.SOUP + assert soups[0]["name"] == "Vývar" + + mains = s.menu.get_meals(meal_types=[MealType.MAIN]) + assert len(mains) == 1 + assert mains[0]["type"] == MealType.MAIN + + # Test filtering by order status + ordered_meals = s.menu.get_meals(ordered=True) + assert len(ordered_meals) == 1 + assert ordered_meals[0]["ordered"] is True + assert ordered_meals[0]["name"] == "Rajská omáčka s těstovinami" + + # Test get_by_id + meal = s.menu.get_by_id(75) + assert meal is not None + assert meal["name"] == "Vývar" + assert meal["type"] == MealType.SOUP + + # Test get_by_date + day = s.menu.get_by_date("2025-09-15") + assert day is not None + assert len(day["meals"]) == 2 + + # Test is_ordered + assert s.menu.is_ordered(1) is True + assert s.menu.is_ordered(75) is False + + # Verify two POST calls occurred (login + fetch) assert fake_session.post.call_count == 2 + + @patch('strava_cz.main.requests.Session') + def test_menu_order_types(self, mock_Session): + """Test that order types are correctly assigned and filtered.""" + fake_session = MagicMock() + mock_Session.return_value = fake_session + + login_response = MagicMock() + login_response.status_code = 200 + login_response.json.return_value = { + "sid": "SID123", + "s5url": "https://fake.s5url", + "cislo": "1234", + "jmeno": "user", + "uzivatel": { + "id": "user", + "email": "u@e.cz", + "konto": "10.00", + "mena": "Kč", + "nazevJidelny": "Test Canteen" + }, + "betatest": False, + "ignoreCert": False, + "zustatPrihlasen": False + } + + menu_response = MagicMock() + menu_response.status_code = 200 + menu_response.json.return_value = { + "table0": [ + { + "id": 0, + "datum": "15-09.2025", + "druh_popis": "Oběd1", + "delsiPopis": "Normal meal", + "nazev": "Normal meal", + "zakazaneAlergeny": None, + "alergeny": [], + "omezeniObj": {"den": ""}, + "pocet": 0, + "veta": "1", + "cena": "40.00" + }, + { + "id": 1, + "datum": "15-09.2025", + "druh_popis": "Oběd2", + "delsiPopis": "Restricted meal", + "nazev": "Restricted meal", + "zakazaneAlergeny": None, + "alergeny": [], + "omezeniObj": {"den": "CO"}, + "pocet": 0, + "veta": "2", + "cena": "40.00" + }, + { + "id": 2, + "datum": "15-09.2025", + "druh_popis": "Oběd3", + "delsiPopis": "Optional meal", + "nazev": "Optional meal", + "zakazaneAlergeny": None, + "alergeny": [], + "omezeniObj": {"den": "T"}, + "pocet": 0, + "veta": "3", + "cena": "40.00" + } + ] + } + + fake_session.post.side_effect = [login_response, menu_response] + + s = StravaCZ("user", "pass", "1234") + s.menu.fetch() + + # Test that meals have correct orderType + all_meals = s.menu.get_meals( + order_types=[OrderType.NORMAL, OrderType.RESTRICTED, OrderType.OPTIONAL] + ) + assert len(all_meals) == 3 + assert all_meals[0]["orderType"] == OrderType.NORMAL + assert all_meals[1]["orderType"] == OrderType.RESTRICTED + assert all_meals[2]["orderType"] == OrderType.OPTIONAL + + # Test filtering by specific order types + restricted_only = s.menu.get_meals(order_types=[OrderType.RESTRICTED]) + assert len(restricted_only) == 1 + assert restricted_only[0]["name"] == "Restricted meal" + + optional_only = s.menu.get_meals(order_types=[OrderType.OPTIONAL]) + assert len(optional_only) == 1 + assert optional_only[0]["name"] == "Optional meal" + + # Test default behavior (only NORMAL) + normal_only = s.menu.get_meals() + assert len(normal_only) == 1 + assert normal_only[0]["name"] == "Normal meal" + + @patch('strava_cz.main.requests.Session') + def test_menu_iteration(self, mock_Session): + """Test that menu supports iteration and len().""" + fake_session = MagicMock() + mock_Session.return_value = fake_session + + login_response = MagicMock() + login_response.status_code = 200 + login_response.json.return_value = { + "sid": "SID123", + "s5url": "https://fake.s5url", + "cislo": "1234", + "jmeno": "user", + "uzivatel": { + "id": "user", + "email": "u@e.cz", + "konto": "10.00", + "mena": "Kč", + "nazevJidelny": "Test Canteen" + }, + "betatest": False, + "ignoreCert": False, + "zustatPrihlasen": False + } + + menu_response = MagicMock() + menu_response.status_code = 200 + menu_response.json.return_value = { + "table0": [ + { + "id": 0, + "datum": "15-09.2025", + "druh_popis": "Oběd1", + "delsiPopis": "Meal 1", + "nazev": "Meal 1", + "zakazaneAlergeny": None, + "alergeny": [], + "omezeniObj": {"den": ""}, + "pocet": 0, + "veta": "1", + "cena": "40.00" + }, + { + "id": 1, + "datum": "16-09.2025", + "druh_popis": "Oběd1", + "delsiPopis": "Meal 2", + "nazev": "Meal 2", + "zakazaneAlergeny": None, + "alergeny": [], + "omezeniObj": {"den": ""}, + "pocet": 0, + "veta": "2", + "cena": "40.00" + } + ] + } + + fake_session.post.side_effect = [login_response, menu_response] + + s = StravaCZ("user", "pass", "1234") + s.menu.fetch() + + # Test len() + assert len(s.menu) == 2 + + # Test iteration + days = list(s.menu) + assert len(days) == 2 + assert days[0]["date"] == "2025-09-15" + assert days[1]["date"] == "2025-09-16" + + # Test indexing + assert s.menu[0]["date"] == "2025-09-15" + assert s.menu[1]["date"] == "2025-09-16" + + # Test __str__ + str_repr = str(s.menu) + assert "Menu" in str_repr + assert "days=" in str_repr + assert "meals=" in str_repr + + def test_canteen_number_required(self): + """Test that canteen_number is now required.""" + with pytest.raises(ValueError): + s = StravaCZ("user", "pass") + s.login("user", "pass", None) + + @patch('strava_cz.main.requests.Session') + def test_invalid_meal_type_order_soup(self, mock_Session): + """Test that ordering a soup raises InvalidMealTypeError.""" + from strava_cz import InvalidMealTypeError + + fake_session = MagicMock() + mock_Session.return_value = fake_session + + login_response = MagicMock() + login_response.status_code = 200 + login_response.json.return_value = { + "sid": "SID123", + "s5url": "https://fake.s5url", + "cislo": "1234", + "jmeno": "user", + "uzivatel": { + "id": "user", + "email": "u@e.cz", + "konto": "10.00", + "mena": "Kč", + "nazevJidelny": "Test Canteen" + }, + "betatest": False, + "ignoreCert": False, + "zustatPrihlasen": False + } + + menu_response = MagicMock() + menu_response.status_code = 200 + menu_response.json.return_value = { + "table0": [ + { + "id": 0, + "datum": "15-09.2025", + "druh_popis": "Polévka", + "delsiPopis": "Soup", + "nazev": "Test Soup", + "zakazaneAlergeny": None, + "alergeny": [], + "omezeniObj": {"den": ""}, + "pocet": 0, + "veta": "75", + "cena": "20.00" + } + ] + } + + # Add response for cancel_order (nactiVlastnostiPA) + cancel_response = MagicMock() + cancel_response.status_code = 200 + cancel_response.json.return_value = {"konto": "10.00"} + + fake_session.post.side_effect = [login_response, menu_response, cancel_response] + + s = StravaCZ("user", "pass", "1234") + s.menu.fetch() + + # Test ordering a soup raises InvalidMealTypeError + with pytest.raises(InvalidMealTypeError) as exc_info: + s.menu.order_meals(75) + + assert "Polévka" in str(exc_info.value) + assert "MAIN" in str(exc_info.value) + + @patch('strava_cz.main.requests.Session') + def test_invalid_meal_type_cancel_soup(self, mock_Session): + """Test that canceling a soup raises InvalidMealTypeError.""" + from strava_cz import InvalidMealTypeError + + fake_session = MagicMock() + mock_Session.return_value = fake_session + + login_response = MagicMock() + login_response.status_code = 200 + login_response.json.return_value = { + "sid": "SID123", + "s5url": "https://fake.s5url", + "cislo": "1234", + "jmeno": "user", + "uzivatel": { + "id": "user", + "email": "u@e.cz", + "konto": "10.00", + "mena": "Kč", + "nazevJidelny": "Test Canteen" + }, + "betatest": False, + "ignoreCert": False, + "zustatPrihlasen": False + } + + menu_response = MagicMock() + menu_response.status_code = 200 + menu_response.json.return_value = { + "table0": [ + { + "id": 0, + "datum": "15-09.2025", + "druh_popis": "Polévka", + "delsiPopis": "Soup", + "nazev": "Test Soup", + "zakazaneAlergeny": None, + "alergeny": [], + "omezeniObj": {"den": ""}, + "pocet": 1, # Already ordered + "veta": "75", + "cena": "20.00" + } + ] + } + + # Add response for cancel_order (nactiVlastnostiPA) + cancel_response = MagicMock() + cancel_response.status_code = 200 + cancel_response.json.return_value = {"konto": "10.00"} + + fake_session.post.side_effect = [login_response, menu_response, cancel_response] + + s = StravaCZ("user", "pass", "1234") + s.menu.fetch() + + # Test canceling a soup raises InvalidMealTypeError + with pytest.raises(InvalidMealTypeError) as exc_info: + s.menu.cancel_meals(75) + + assert "Polévka" in str(exc_info.value) + + @patch('strava_cz.main.requests.Session') + def test_duplicate_meal_error_strict_mode(self, mock_Session): + """Test that ordering multiple meals from same day raises DuplicateMealError in strict mode.""" + from strava_cz import DuplicateMealError + + fake_session = MagicMock() + mock_Session.return_value = fake_session + + login_response = MagicMock() + login_response.status_code = 200 + login_response.json.return_value = { + "sid": "SID123", + "s5url": "https://fake.s5url", + "cislo": "1234", + "jmeno": "user", + "uzivatel": { + "id": "user", + "email": "u@e.cz", + "konto": "100.00", + "mena": "Kč", + "nazevJidelny": "Test Canteen" + }, + "betatest": False, + "ignoreCert": False, + "zustatPrihlasen": False + } + + menu_response = MagicMock() + menu_response.status_code = 200 + menu_response.json.return_value = { + "table0": [ + { + "id": 0, + "datum": "15-09.2025", + "druh_popis": "Oběd1", + "delsiPopis": "Meal 1", + "nazev": "Meal 1", + "zakazaneAlergeny": None, + "alergeny": [], + "omezeniObj": {"den": ""}, + "pocet": 0, + "veta": "1", + "cena": "40.00" + }, + { + "id": 1, + "datum": "15-09.2025", + "druh_popis": "Oběd2", + "delsiPopis": "Meal 2", + "nazev": "Meal 2", + "zakazaneAlergeny": None, + "alergeny": [], + "omezeniObj": {"den": ""}, + "pocet": 0, + "veta": "2", + "cena": "45.00" + } + ] + } + + fake_session.post.side_effect = [login_response, menu_response] + + s = StravaCZ("user", "pass", "1234") + s.menu.fetch() + + # Test ordering multiple meals from same day with strict_duplicates=True + with pytest.raises(DuplicateMealError) as exc_info: + s.menu.order_meals(1, 2, strict_duplicates=True) + + assert "2025-09-15" in str(exc_info.value) + assert "same day" in str(exc_info.value).lower() + + @patch('strava_cz.main.requests.Session') + def test_duplicate_meal_warning_default_mode(self, mock_Session): + """Test that ordering multiple meals from same day only orders first one and warns.""" + fake_session = MagicMock() + mock_Session.return_value = fake_session + + login_response = MagicMock() + login_response.status_code = 200 + login_response.json.return_value = { + "sid": "SID123", + "s5url": "https://fake.s5url", + "cislo": "1234", + "jmeno": "user", + "uzivatel": { + "id": "user", + "email": "u@e.cz", + "konto": "100.00", + "mena": "Kč", + "nazevJidelny": "Test Canteen" + }, + "betatest": False, + "ignoreCert": False, + "zustatPrihlasen": False + } + + menu_response = MagicMock() + menu_response.status_code = 200 + menu_response.json.return_value = { + "table0": [ + { + "id": 0, + "datum": "15-09.2025", + "druh_popis": "Oběd1", + "delsiPopis": "Meal 1", + "nazev": "Meal 1", + "zakazaneAlergeny": None, + "alergeny": [], + "omezeniObj": {"den": ""}, + "pocet": 0, + "veta": "1", + "cena": "40.00" + }, + { + "id": 1, + "datum": "15-09.2025", + "druh_popis": "Oběd2", + "delsiPopis": "Meal 2", + "nazev": "Meal 2", + "zakazaneAlergeny": None, + "alergeny": [], + "omezeniObj": {"den": ""}, + "pocet": 0, + "veta": "2", + "cena": "45.00" + } + ] + } + + order_response = MagicMock() + order_response.status_code = 200 + order_response.json.return_value = {"konto": "60.00"} + + save_response = MagicMock() + save_response.status_code = 200 + save_response.json.return_value = {} + + # After save, fetch again to get updated menu + menu_response_after = MagicMock() + menu_response_after.status_code = 200 + menu_response_after.json.return_value = { + "table0": [ + { + "id": 0, + "datum": "15-09.2025", + "druh_popis": "Oběd1", + "delsiPopis": "Meal 1", + "nazev": "Meal 1", + "zakazaneAlergeny": None, + "alergeny": [], + "omezeniObj": {"den": ""}, + "pocet": 1, # Now ordered + "veta": "1", + "cena": "40.00" + }, + { + "id": 1, + "datum": "15-09.2025", + "druh_popis": "Oběd2", + "delsiPopis": "Meal 2", + "nazev": "Meal 2", + "zakazaneAlergeny": None, + "alergeny": [], + "omezeniObj": {"den": ""}, + "pocet": 0, # Not ordered + "veta": "2", + "cena": "45.00" + } + ] + } + + fake_session.post.side_effect = [ + login_response, + menu_response, + order_response, # pridejJidloS5 for meal 1 + save_response, # saveOrders + menu_response_after # fetch after save + ] + + s = StravaCZ("user", "pass", "1234") + s.menu.fetch() + + # Test ordering multiple meals from same day without strict_duplicates + # Should only order first meal and warn about second + with pytest.warns(UserWarning, match="Skipping meal 2"): + s.menu.order_meals(1, 2, strict_duplicates=False) + + # Verify only meal 1 was ordered + assert s.menu.is_ordered(1) is True + assert s.menu.is_ordered(2) is False + + @patch('strava_cz.main.requests.Session') + def test_insufficient_balance_error(self, mock_Session): + """Test that insufficient balance raises InsufficientBalanceError.""" + from strava_cz import InsufficientBalanceError + + fake_session = MagicMock() + mock_Session.return_value = fake_session + + login_response = MagicMock() + login_response.status_code = 200 + login_response.json.return_value = { + "sid": "SID123", + "s5url": "https://fake.s5url", + "cislo": "1234", + "jmeno": "user", + "uzivatel": { + "id": "user", + "email": "u@e.cz", + "konto": "5.00", # Low balance + "mena": "Kč", + "nazevJidelny": "Test Canteen" + }, + "betatest": False, + "ignoreCert": False, + "zustatPrihlasen": False + } + + menu_response = MagicMock() + menu_response.status_code = 200 + menu_response.json.return_value = { + "table0": [ + { + "id": 0, + "datum": "15-09.2025", + "druh_popis": "Oběd1", + "delsiPopis": "Expensive meal", + "nazev": "Expensive meal", + "zakazaneAlergeny": None, + "alergeny": [], + "omezeniObj": {"den": ""}, + "pocet": 0, + "veta": "1", + "cena": "100.00" + } + ] + } + + # API returns error code 35 for insufficient balance + order_response = MagicMock() + order_response.status_code = 400 + order_response.json.return_value = { + "number": 35, + "message": "Insufficient balance to order this meal" + } + + cancel_response = MagicMock() + cancel_response.status_code = 200 + cancel_response.json.return_value = {"konto": "5.00"} + + fake_session.post.side_effect = [ + login_response, + menu_response, + order_response, # pridejJidloS5 fails + cancel_response # nactiVlastnostiPA to cancel + ] + + s = StravaCZ("user", "pass", "1234") + s.menu.fetch() + + # Test insufficient balance raises InsufficientBalanceError + with pytest.raises(InsufficientBalanceError) as exc_info: + s.menu.order_meals(1) + + assert "balance" in str(exc_info.value).lower() + + @patch('strava_cz.main.requests.Session') + def test_continue_on_error_collects_errors(self, mock_Session): + """Test that continue_on_error=True collects all errors.""" + from strava_cz import StravaAPIError + + fake_session = MagicMock() + mock_Session.return_value = fake_session + + login_response = MagicMock() + login_response.status_code = 200 + login_response.json.return_value = { + "sid": "SID123", + "s5url": "https://fake.s5url", + "cislo": "1234", + "jmeno": "user", + "uzivatel": { + "id": "user", + "email": "u@e.cz", + "konto": "100.00", + "mena": "Kč", + "nazevJidelny": "Test Canteen" + }, + "betatest": False, + "ignoreCert": False, + "zustatPrihlasen": False + } + + menu_response = MagicMock() + menu_response.status_code = 200 + menu_response.json.return_value = { + "table0": [ + { + "id": 0, + "datum": "14-09.2025", + "druh_popis": "Polévka", + "delsiPopis": "Soup", + "nazev": "Soup", + "zakazaneAlergeny": None, + "alergeny": [], + "omezeniObj": {"den": ""}, + "pocet": 0, + "veta": "75", + "cena": "20.00" + }, + { + "id": 1, + "datum": "15-09.2025", + "druh_popis": "Oběd1", + "delsiPopis": "Main meal", + "nazev": "Main meal", + "zakazaneAlergeny": None, + "alergeny": [], + "omezeniObj": {"den": ""}, + "pocet": 0, + "veta": "1", + "cena": "40.00" + }, + { + "id": 2, + "datum": "16-09.2025", + "druh_popis": "Polévka", + "delsiPopis": "Another soup", + "nazev": "Another soup", + "zakazaneAlergeny": None, + "alergeny": [], + "omezeniObj": {"den": ""}, + "pocet": 0, + "veta": "76", + "cena": "20.00" + } + ] + } + + order_response = MagicMock() + order_response.status_code = 200 + order_response.json.return_value = {"konto": "60.00"} + + save_response = MagicMock() + save_response.status_code = 200 + save_response.json.return_value = {} + + menu_response_after = MagicMock() + menu_response_after.status_code = 200 + menu_response_after.json.return_value = { + "table0": [ + { + "id": 0, + "datum": "14-09.2025", + "druh_popis": "Polévka", + "delsiPopis": "Soup", + "nazev": "Soup", + "zakazaneAlergeny": None, + "alergeny": [], + "omezeniObj": {"den": ""}, + "pocet": 0, + "veta": "75", + "cena": "20.00" + }, + { + "id": 1, + "datum": "15-09.2025", + "druh_popis": "Oběd1", + "delsiPopis": "Main meal", + "nazev": "Main meal", + "zakazaneAlergeny": None, + "alergeny": [], + "omezeniObj": {"den": ""}, + "pocet": 1, # Ordered + "veta": "1", + "cena": "40.00" + }, + { + "id": 2, + "datum": "16-09.2025", + "druh_popis": "Polévka", + "delsiPopis": "Another soup", + "nazev": "Another soup", + "zakazaneAlergeny": None, + "alergeny": [], + "omezeniObj": {"den": ""}, + "pocet": 0, + "veta": "76", + "cena": "20.00" + } + ] + } + + fake_session.post.side_effect = [ + login_response, + menu_response, + order_response, # pridejJidloS5 for meal 1 (main) + save_response, + menu_response_after + ] + + s = StravaCZ("user", "pass", "1234") + s.menu.fetch() + + # Test that continue_on_error=True collects errors for soups + with pytest.raises(StravaAPIError) as exc_info: + s.menu.order_meals(75, 1, 76, continue_on_error=True) + + error_msg = str(exc_info.value) + assert "75" in error_msg # First soup + assert "76" in error_msg # Second soup + assert "Polévka" in error_msg + # Main meal should have been ordered successfully + assert s.menu.is_ordered(1) is True + + @patch('strava_cz.main.requests.Session') + def test_balance_update_after_order(self, mock_Session): + """Test that user balance is updated after ordering.""" + fake_session = MagicMock() + mock_Session.return_value = fake_session + + login_response = MagicMock() + login_response.status_code = 200 + login_response.json.return_value = { + "sid": "SID123", + "s5url": "https://fake.s5url", + "cislo": "1234", + "jmeno": "user", + "uzivatel": { + "id": "user", + "email": "u@e.cz", + "konto": "100.00", + "mena": "Kč", + "nazevJidelny": "Test Canteen" + }, + "betatest": False, + "ignoreCert": False, + "zustatPrihlasen": False + } + + menu_response = MagicMock() + menu_response.status_code = 200 + menu_response.json.return_value = { + "table0": [ + { + "id": 0, + "datum": "15-09.2025", + "druh_popis": "Oběd1", + "delsiPopis": "Meal", + "nazev": "Meal", + "zakazaneAlergeny": None, + "alergeny": [], + "omezeniObj": {"den": ""}, + "pocet": 0, + "veta": "1", + "cena": "40.00" + } + ] + } + + order_response = MagicMock() + order_response.status_code = 200 + order_response.json.return_value = {"konto": "60.00"} # Balance after ordering + + save_response = MagicMock() + save_response.status_code = 200 + save_response.json.return_value = {} + + menu_response_after = MagicMock() + menu_response_after.status_code = 200 + menu_response_after.json.return_value = { + "table0": [ + { + "id": 0, + "datum": "15-09.2025", + "druh_popis": "Oběd1", + "delsiPopis": "Meal", + "nazev": "Meal", + "zakazaneAlergeny": None, + "alergeny": [], + "omezeniObj": {"den": ""}, + "pocet": 1, + "veta": "1", + "cena": "40.00" + } + ] + } + + fake_session.post.side_effect = [ + login_response, + menu_response, + order_response, + save_response, + menu_response_after + ] + + s = StravaCZ("user", "pass", "1234") + assert s.user.balance == "100.00" + + s.menu.fetch() + s.menu.order_meals(1) + + # Balance should be updated to 60.00 + assert s.user.balance == 60.00 # Now it's a float after update