From 3f9b1eebe3fbb4ca161b94d1f5bcd5d91da0419b Mon Sep 17 00:00:00 2001 From: jsem-nerad Date: Mon, 10 Nov 2025 08:27:32 +0100 Subject: [PATCH 1/8] kinda working menu class, need reorganisation of code for easier usage; docs need to be checked, they were made by LLM; --- CHANGELOG.md | 68 +++- MIGRATION_GUIDE.md | 225 +++++++++++ README.md | 110 +++++- examples/example.py | 52 ++- src/strava_cz/__init__.py | 4 +- src/strava_cz/main.py | 801 +++++++++++++++++++++++++++----------- 6 files changed, 997 insertions(+), 263 deletions(-) create mode 100644 MIGRATION_GUIDE.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 341953d..a11ed4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,17 +4,75 @@ Vsechny vyznamne zmeny v tomto projektu budou dokumentovany v tomto souboru. ## [unreleased] + +## [0.2.0] 2025-11-06 ### 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) +- Kazde jidlo nyni obsahuje `orderType` pole s informaci o typu objednavky +- `Menu.fetch()` pro ziskani jidelnicku z API (bez parametru) +- Vice pohledu na menu jako vlastnosti: + - `Menu.all` - default seznam (polevky + hlavni, objednavatelne) + - `Menu.main_only` - pouze hlavni jidla + - `Menu.soup_only` - pouze polevky + - `Menu.complete` - kompletni seznam vcetne volitelnych jidel + - `Menu.restricted` - jidla "CO" (uz nelze objednat) + - `Menu.optional` - jidla "T" (obvykle neobjednavana) +- Ploschy seznamy jidel s datem: + - `Menu.get_meals()` - vsechna jidla jako ploschy seznam + - `Menu.get_main_meals()` - pouze hlavni jidla + - `Menu.get_soup_meals()` - pouze polevky +- `Menu.get_unordered_days()` - vrati seznam dnu bez objednavek +- Parametry `include_restricted` a `include_optional` pro vsechny metody vracejici jidla nebo dny: + - Default False pro seznamove metody (get_meals, get_main_meals, get_soup_meals, filter_by_type, get_unordered_days) + - Default True pro vyhledavaci metody (get_by_id, get_by_date, get_ordered_meals, is_ordered) +- Magic methods pro Menu: `__repr__`, `__str__`, `__iter__`, `__len__`, `__getitem__` +- Automaticke filtrovani podle omezeniObj hodnot: + - "VP" jidla se preskakuji (zadna skola) + - "CO" jidla jdou do `restricted` seznamu (OrderType.RESTRICTED) + - "T" jidla jdou do `optional` seznamu (OrderType.OPTIONAL) + - Prazdny string = objednavatelne jidla (OrderType.NORMAL) +- Inteligentni vyhledavani pres vybrane seznamy podle parametru include_restricted a include_optional +- `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 + +### 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 +- `filter_by_type()` nyni pouziva `MealType` enum misto stringu +- `meal["type"]` je nyni primo `MealType` enum misto stringu +- `find_meal_by_id()` prejmenovano na `get_by_id()` +- `is_meal_ordered()` prejmenovano na `is_ordered()` +- Vyhledavaci metody nyni prohledavaji vsechny seznamy (all, restricted, optional) +- Zmena verze z 0.1.3 na 0.2.0 + +### Removed +- `StravaCZ.get_menu()` - pouzij `strava.menu.fetch()` +- `StravaCZ.print_menu()` - pouzij `strava.menu.print()` +- `StravaCZ.is_ordered()` - pouzij `strava.menu.is_ordered()` +- `StravaCZ.order_meals()` - pouzij `strava.menu.order_meals()` +- `StravaCZ.cancel_meals()` - pouzij `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 (zadna skola) ## [0.1.3] 2025-09-24 ### Added diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md new file mode 100644 index 0000000..8c84d7d --- /dev/null +++ b/MIGRATION_GUIDE.md @@ -0,0 +1,225 @@ +# Migration Guide: Menu Class Refactoring +### Tento text byl vytvoren pomoci LLM / This text was made by an LLM + +## Overview +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`. + +## 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 (Current) +```python +from strava_cz import StravaCZ, MealType + +strava = StravaCZ(username="...", password="...") + +# Fetching menu +strava.menu.fetch(include_soup=True) + +# Printing menu +strava.menu.print() + +# Checking if ordered +is_ordered = strava.menu.is_ordered(meal_id=4) + +# Ordering meals +strava.menu.order_meals(3, 6) + +# Canceling meals +strava.menu.cancel_meals(3, 6) +``` + +## New Features + +### Direct Menu Access +```python +# Get all menu data +all_meals = strava.menu.get_all() + +# Get menu for specific date +today_menu = strava.menu.get_by_date("2025-11-04") + +# Get all ordered meals +ordered = strava.menu.get_ordered_meals() + +# Get specific meal by ID +meal = strava.menu.get_by_id(4) +``` + +### Filtering Capabilities +```python +from strava_cz import MealType, OrderType + +# Filter by meal type (now using enum) +soups = strava.menu.filter_by_type(MealType.SOUP) +main_dishes = strava.menu.filter_by_type(MealType.MAIN) + +# Note: meal["type"] is now a MealType enum, not a string +for meal in soups: + print(meal["type"]) # Output: MealType.SOUP + print(meal["type"].value) # Output: "Polévka" + +# Each meal also has orderType field +for meal in strava.menu.get_meals(): + print(meal["orderType"]) # Output: OrderType.NORMAL, OrderType.RESTRICTED, or OrderType.OPTIONAL + if meal["orderType"] == OrderType.RESTRICTED: + print(f"Meal {meal['id']} can no longer be ordered") +``` + +### Multiple List Views +```python +# Default list (orderable meals: soups + mains) +default = strava.menu.all + +# Only main dishes +mains = strava.menu.main_only + +# Only soups +soups = strava.menu.soup_only + +# Complete list including optional meals +complete = strava.menu.complete + +# Meals that can no longer be ordered ("CO") +restricted = strava.menu.restricted + +# Optional meals not usually ordered ("T") +optional = strava.menu.optional + +# Flat lists with dates +all_meals = strava.menu.get_meals() +main_meals = strava.menu.get_main_meals() +soup_meals = strava.menu.get_soup_meals() +``` + +### Controlling Restricted and Optional Inclusion +```python +# By default, list methods exclude restricted and optional +meals = strava.menu.get_meals() # Only normal meals + +# Include restricted meals +meals = strava.menu.get_meals(include_restricted=True) + +# Include optional meals +meals = strava.menu.get_meals(include_optional=True) + +# Include both +meals = strava.menu.get_meals(include_restricted=True, include_optional=True) + +# Search methods include all by default +meal = strava.menu.get_by_id(123) # Searches all lists +meal = strava.menu.get_by_id(123, include_restricted=False) # Only searches normal lists + +# Get days with no orders +unordered = strava.menu.get_unordered_days() # Excludes restricted/optional +unordered = strava.menu.get_unordered_days(include_restricted=True, include_optional=True) # All days +``` + +### Menu Information +```python +# Get number of days in menu +num_days = len(strava.menu) + +# Get string representation +print(strava.menu) # Output: Menu(days=5, meals=25) + +# Access raw API data +raw_data = strava.menu.raw_data + +# Access processed data +processed = strava.menu.processed_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..475234c 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,13 @@ Ve slozce [notes](https://github.com/jsem-nerad/strava-cz-python/tree/main/notes ## Features - Prihlaseni/odhlaseni -- Vypsani prefiltrovaneho jidelnicku -- Objednavani jidel podle ID jidla +- Vypsani prefiltrovaneho jidelnicku +- Objednavani a odhlasovani jidel podle ID jidla +- Automaticke filtrovani jidel podle typu a objednatelnosti +- Vice pohledu na jidelnicek (vsechny, pouze hlavni, pouze polevky, kompletni, omezene) +- Vyhledavani jidel podle ID nebo data +- Ulozeni raw i zpracovanych dat z API +- Menu objekt se chova jako list pro snadnou iteraci ## Usage @@ -16,10 +21,8 @@ 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 # Vytvoreni objektu strava a prihlaseni uzivatele strava = StravaCZ( @@ -31,30 +34,107 @@ strava = StravaCZ( # Vypsani informaci o uzivateli print(strava.user) -# Ziskani jidelnicku; ulozi list do strava.menu -print(strava.get_menu()) +# Ziskani jidelnicku +strava.menu.fetch() +strava.menu.print() + +# Pristup k ruznym pohledum na menu +print(f"Vsechny jidla: {len(strava.menu)} dni") # Default - vsechna objednavatelna jidla +print(f"Pouze hlavni: {len(strava.menu.main_only)} dni") +print(f"Pouze polevky: {len(strava.menu.soup_only)} dni") +print(f"Kompletni: {len(strava.menu.complete)} dni") # Vcetne volitelnych jidel + +# 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) +strava.menu.order_meals(3, 6) + +# Ziskani vsech objednanych jidel (prohledava vsechny seznamy) +ordered = strava.menu.get_ordered_meals() + +# Ziskani ploschych seznamu jidel s datem +all_meals = strava.menu.get_meals() # Vsechna jidla jako ploschy seznam +main_meals = strava.menu.get_main_meals() # Pouze hlavni jidla +soup_meals = strava.menu.get_soup_meals() # Pouze polevky + +# Ziskani jidelnicku podle konkretniho data (prohledava vsechny seznamy) +today_menu = strava.menu.get_by_date("2025-11-04") # 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 | +|----------------|----------------------|--------------------------------------------------------------------------------| +| `all` | list | Default seznam - vsechna objednavatelna jidla (polevky + hlavni) podle dni | +| `main_only` | list | Pouze hlavni jidla podle dni | +| `soup_only` | list | Pouze polevky podle dni | +| `complete` | list | Kompletni seznam vcetne volitelnych jidel ("T") podle dni | +| `restricted` | list | Jidla, ktera uz nelze objednat ("CO") podle dni | +| `optional` | list | Jidla, ktera obvykle nejsou objednavana ("T") podle dni | + +#### Metody +| funkce | parametry | return type | popis | +|---------------------|-----------------------------------------------------------|-------------|--------------------------------------------------------------------------------------------------------------------| +| `fetch()` | None | Menu | Ziska jidelnicek z API a zpracuje ho do vsech seznamu; vraci sam sebe | +| `print()` | None | None | Vypise zformatovane default menu | +| `get_meals()` | include_restricted=False, include_optional=False | list | Vrati vsechna jidla z prislusnych seznamu jako ploschy seznam s datem | +| `get_main_meals()` | include_restricted=False, include_optional=False | list | Vrati pouze hlavni jidla z prislusnych seznamu jako ploschy seznam s datem | +| `get_soup_meals()` | include_restricted=False, include_optional=False | list | Vrati pouze polevky z prislusnych seznamu jako ploschy seznam s datem | +| `get_by_date()` | date [str], include_restricted=True, include_optional=True | dict/None | Vrati jidla pro konkretni datum (prohledava vybrane seznamy) | +| `get_by_id()` | meal_id [int], include_restricted=True, include_optional=True | dict/None | Vrati konkretni jidlo podle ID (prohledava vybrane seznamy) | +| `get_ordered_meals()` | include_restricted=True, include_optional=True | list | Vrati vsechna objednana jidla z vybranych seznamu | +| `get_unordered_days()` | include_restricted=False, include_optional=False | list | Vrati seznam dnu (datumy), ve kterych neni objednano zadne jidlo | +| `filter_by_type()` | meal_type [MealType], include_restricted=False, include_optional=False | list | Filtruje jidla z vybranych seznamu podle typu | +| `is_ordered()` | meal_id [int], include_restricted=True, include_optional=True | bool | Zjisti, jestli je dane jidlo objednano (prohledava vybrane seznamy) | +| `order_meals()` | *meal_ids [int] | None | Objedna vice jidel podle meal_id | +| `cancel_meals()` | *meal_ids [int] | None | Zrusi objednavky vice jidel podle meal_id | + +Menu objekt podporuje iteraci, indexovani a len() - vse pracuje s default seznamem `all`. + + +### 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 | | `logout()` | None | bool | Odhlasi uzivatele | @@ -90,5 +170,7 @@ 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. diff --git a/examples/example.py b/examples/example.py index 556c9f6..f8de324 100644 --- a/examples/example.py +++ b/examples/example.py @@ -1,4 +1,4 @@ -from strava_cz import StravaCZ +from strava_cz import StravaCZ, MealType, OrderType # Vytvoreni objektu strava a prihlaseni uzivatele strava = StravaCZ( @@ -10,14 +10,56 @@ # Vypsani informaci o uzivateli print(strava.user) -# Ziskani jidelnicku; ulozi list do strava.menu -print(strava.get_menu()) +# Ziskani jidelnicku a vypsani +strava.menu.fetch() +strava.menu.print() + +# Pristup k ruznym seznamum +print(f"Vsechny jidla: {len(strava.menu)} dni") +print(f"Pouze hlavni jidla: {len(strava.menu.main_only)} dni") +print(f"Pouze polevky: {len(strava.menu.soup_only)} dni") +print(f"Kompletni (s volitelymi): {len(strava.menu.complete)} dni") +print(f"Omezene objednavky: {len(strava.menu.restricted)} dni") +print(f"Volitelne objednavky: {len(strava.menu.optional)} dni") + +# Iterace pres menu +for day in strava.menu: + print(f"Datum: {day['date']}, Pocet jidel: {len(day['meals'])}") + +# Priklad: Kontrola typu objednavky +for day in strava.menu.complete: + for meal in day['meals']: + if meal['orderType'] == OrderType.RESTRICTED: + print(f"Jidlo {meal['id']} ({meal['name']}) uz nelze objednat") + elif meal['orderType'] == OrderType.OPTIONAL: + print(f"Jidlo {meal['id']} ({meal['name']}) je volitelne") + elif meal['orderType'] == OrderType.NORMAL: + print(f"Jidlo {meal['id']} ({meal['name']}) lze objednat normalne") # 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) +strava.menu.order_meals(3, 6) + +# Priklad: Ziskani vsech objednanych jidel +ordered_meals = strava.menu.get_ordered_meals() +print(f"Objednana jidla: {len(ordered_meals)}") + +# Priklad: Ziskani neobjednanych dni +unordered_days = strava.menu.get_unordered_days() +print(f"Dny bez objednavky: {unordered_days}") + +# Priklad: Ziskani ploschych seznamu jidel +all_meals = strava.menu.get_meals() +main_meals = strava.menu.get_main_meals() +soup_meals = strava.menu.get_soup_meals() +print(f"Celkem jidel: {len(all_meals)} (hlavni: {len(main_meals)}, polevky: {len(soup_meals)})") + +# Priklad: Zahrnuti omezenych a volitelnych jidel +all_with_restricted = strava.menu.get_meals(include_restricted=True) +all_complete = strava.menu.get_meals(include_restricted=True, include_optional=True) +print(f"S omezenymi: {len(all_with_restricted)}, kompletni: {len(all_complete)}") # Odhlasi uzivatele strava.logout() \ No newline at end of file diff --git a/src/strava_cz/__init__.py b/src/strava_cz/__init__.py index 5803ac9..438a96a 100644 --- a/src/strava_cz/__init__.py +++ b/src/strava_cz/__init__.py @@ -2,9 +2,9 @@ StravaCZ - High level API pro interakci s webovou aplikaci Strava.cz """ -from .main import StravaCZ, AuthenticationError, StravaAPIError, User +from .main import StravaCZ, AuthenticationError, StravaAPIError, User, MealType, OrderType, Menu -__version__ = "0.1.3" +__version__ = "0.2.0" __author__ = "Vojtěch Nerad" __email__ = "ja@jsem-nerad.cz" diff --git a/src/strava_cz/main.py b/src/strava_cz/main.py index ac806b5..58f5ddc 100644 --- a/src/strava_cz/main.py +++ b/src/strava_cz/main.py @@ -1,9 +1,24 @@ """High level API pro interakci s webovou aplikaci Strava.cz""" 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 = "Objednavatelne" # 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.""" @@ -42,6 +57,542 @@ def __repr__(self): ) +class Menu: + """Menu data container and processor""" + + def __init__(self, strava_client: 'StravaCZ'): + """Initialize Menu with reference to StravaCZ client. + + Args: + strava_client: Reference to the parent StravaCZ instance + """ + self.strava = strava_client + self.raw_data: Dict[str, Any] = {} + + # Day-grouped lists (primary storage) + self.all: List[Dict[str, Any]] = [] # Default: soups + mains, orderable + self.main_only: List[Dict[str, Any]] = [] # Only main meals + self.soup_only: List[Dict[str, Any]] = [] # Only soups + self.restricted: List[Dict[str, Any]] = [] # "CO" - too late to order + self.optional: List[Dict[str, Any]] = [] # "T" - not usually ordered + self.complete: List[Dict[str, Any]] = [] # all + optional, sorted + + def fetch(self) -> 'Menu': + """Fetch menu data from API and process it into various lists. + + Returns: + Self for method chaining + + Raises: + AuthenticationError: If user is not logged in + StravaAPIError: If menu retrieval fails + """ + if not self.strava.user.is_logged_in: + raise AuthenticationError("User not logged in") + + payload = { + "cislo": self.strava.user.canteen_number, + "sid": self.strava.user.sid, + "s5url": self.strava.user.s5url, + "lang": "EN", + "konto": self.strava.user.balance, + "podminka": "", + "ignoreCert": False, + } + + response = self.strava._api_request("objednavky", payload) + + if response["status_code"] != 200: + raise StravaAPIError("Failed to fetch menu") + + self.raw_data = response["response"] + self._parse_menu_data() + return self + + def _parse_menu_data(self) -> None: + """Parse raw menu response into structured lists.""" + # Temporary storage categorized by restriction status + all_meals: Dict[str, List[Dict]] = {} # Orderable (empty string) + restricted_meals: Dict[str, List[Dict]] = {} # "CO" - too late + optional_meals: Dict[str, List[Dict]] = {} # "T" - not usually ordered + + # Process all table entries (table0, table1, etc.) + for table_key, meals_list in self.raw_data.items(): + if not table_key.startswith("table"): + continue + + for meal in meals_list: + # 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 = { + "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"]), + } + + # Categorize by restriction status + if order_type == OrderType.RESTRICTED: + if date not in restricted_meals: + restricted_meals[date] = [] + restricted_meals[date].append(meal_filtered) + elif order_type == OrderType.OPTIONAL: + if date not in optional_meals: + optional_meals[date] = [] + optional_meals[date].append(meal_filtered) + else: # NORMAL - orderable + if date not in all_meals: + all_meals[date] = [] + all_meals[date].append(meal_filtered) + + # Convert to day-grouped format and sort by date + self.all = sorted([ + {"date": date, "ordered": any(m["ordered"] for m in meals), "meals": meals} + for date, meals in all_meals.items() + ], key=lambda x: x["date"]) + + self.restricted = sorted([ + {"date": date, "ordered": any(m["ordered"] for m in meals), "meals": meals} + for date, meals in restricted_meals.items() + ], key=lambda x: x["date"]) + + self.optional = sorted([ + {"date": date, "ordered": any(m["ordered"] for m in meals), "meals": meals} + for date, meals in optional_meals.items() + ], key=lambda x: x["date"]) + + # Create filtered views by meal type + self.main_only = self._filter_by_type_internal(self.all, MealType.MAIN) + self.soup_only = self._filter_by_type_internal(self.all, MealType.SOUP) + + # Create complete list (all + optional, sorted) + self.complete = self._merge_sorted_lists(self.all, self.optional) + + def _filter_by_type_internal(self, days: List[Dict[str, Any]], meal_type: MealType) -> List[Dict[str, Any]]: + """Filter days to only include specific meal type.""" + filtered_days = [] + for day in days: + filtered_meals = [m for m in day["meals"] if m["type"] == meal_type] + if filtered_meals: + filtered_days.append({ + "date": day["date"], + "ordered": any(m["ordered"] for m in filtered_meals), + "meals": filtered_meals + }) + return filtered_days + + def _merge_sorted_lists(self, list1: List[Dict[str, Any]], list2: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Merge two sorted day lists, combining meals for same dates.""" + merged = {} + + for day in list1 + list2: + date = day["date"] + if date not in merged: + merged[date] = {"date": date, "meals": [], "ordered": False} + merged[date]["meals"].extend(day["meals"]) + merged[date]["ordered"] = merged[date]["ordered"] or day["ordered"] + + return sorted(merged.values(), key=lambda x: x["date"]) + + def get_by_date( + self, date: str, include_restricted: bool = True, include_optional: bool = True + ) -> Optional[Dict[str, Any]]: + """Get menu items for a specific date. + + Args: + date: Date in YYYY-MM-DD format + include_restricted: Include restricted ("CO") meals in search + include_optional: Include optional ("T") meals in search + + Returns: + Dictionary with date and meals, or None if not found + """ + # Build list of lists to search + lists_to_search = [self.all] + if include_restricted: + lists_to_search.append(self.restricted) + if include_optional: + lists_to_search.append(self.optional) + + for day_list in lists_to_search: + for day in day_list: + if day["date"] == date: + return day + return None + + def get_unordered_days( + self, include_restricted: bool = False, include_optional: bool = False + ) -> List[Dict[str, Any]]: + """Get all days where no meals are ordered. + + Args: + include_restricted: Include restricted ("CO") days + include_optional: Include optional ("T") days + + Returns: + List of days with no ordered meals + """ + lists_to_search = [self.all] + if include_restricted: + lists_to_search.append(self.restricted) + if include_optional: + lists_to_search.append(self.optional) + + unordered_days = [] + for day_list in lists_to_search: + for day in day_list: + if not day["ordered"]: + unordered_days.append(day) + + return sorted(unordered_days, key=lambda x: x["date"]) + + def get_ordered_meals( + self, include_restricted: bool = True, include_optional: bool = True + ) -> List[Dict[str, Any]]: + """Get all ordered meals across all dates. + + Args: + include_restricted: Include restricted ("CO") meals in results + include_optional: Include optional ("T") meals in results + + Returns: + List of ordered meals with their date information + """ + lists_to_search = [self.all] + if include_restricted: + lists_to_search.append(self.restricted) + if include_optional: + lists_to_search.append(self.optional) + + ordered_meals = [] + for day_list in lists_to_search: + for day in day_list: + for meal in day["meals"]: + if meal["ordered"]: + ordered_meals.append({**meal, "date": day["date"]}) + + return sorted(ordered_meals, key=lambda x: x["date"]) + + def filter_by_type( + self, meal_type: MealType, include_restricted: bool = False, include_optional: bool = False + ) -> List[Dict[str, Any]]: + """Filter meals by type. + + Args: + meal_type: Type of meal to filter by (MealType.SOUP or MealType.MAIN) + include_restricted: Include restricted ("CO") meals + include_optional: Include optional ("T") meals + + Returns: + List of meals matching the type with their date information + """ + lists_to_search = [self.all] + if include_restricted: + lists_to_search.append(self.restricted) + if include_optional: + lists_to_search.append(self.optional) + + filtered_meals = [] + for day_list in lists_to_search: + for day in day_list: + for meal in day["meals"]: + if meal["type"] == meal_type: + filtered_meals.append({**meal, "date": day["date"]}) + return sorted(filtered_meals, key=lambda x: x["date"]) + + def get_meals( + self, include_restricted: bool = False, include_optional: bool = False + ) -> List[Dict[str, Any]]: + """Get all meals as flat list with date. + + Args: + include_restricted: Include restricted ("CO") meals + include_optional: Include optional ("T") meals + + Returns: + List of all meals (flattened) with date included in each meal + """ + lists_to_search = [self.all] + if include_restricted: + lists_to_search.append(self.restricted) + if include_optional: + lists_to_search.append(self.optional) + + meals = [] + for day_list in lists_to_search: + for day in day_list: + for meal in day["meals"]: + meals.append({**meal, "date": day["date"]}) + return meals + + def get_main_meals( + self, include_restricted: bool = False, include_optional: bool = False + ) -> List[Dict[str, Any]]: + """Get only main meals as flat list with date. + + Args: + include_restricted: Include restricted ("CO") meals + include_optional: Include optional ("T") meals + + Returns: + List of main meals (flattened) with date included in each meal + """ + lists_to_search = [self.all] + if include_restricted: + lists_to_search.append(self.restricted) + if include_optional: + lists_to_search.append(self.optional) + + meals = [] + for day_list in lists_to_search: + for day in day_list: + for meal in day["meals"]: + if meal["type"] == MealType.MAIN: + meals.append({**meal, "date": day["date"]}) + return meals + + def get_soup_meals( + self, include_restricted: bool = False, include_optional: bool = False + ) -> List[Dict[str, Any]]: + """Get only soup meals as flat list with date. + + Args: + include_restricted: Include restricted ("CO") meals + include_optional: Include optional ("T") meals + + Returns: + List of soup meals (flattened) with date included in each meal + """ + lists_to_search = [self.all] + if include_restricted: + lists_to_search.append(self.restricted) + if include_optional: + lists_to_search.append(self.optional) + + meals = [] + for day_list in lists_to_search: + for day in day_list: + for meal in day["meals"]: + if meal["type"] == MealType.SOUP: + meals.append({**meal, "date": day["date"]}) + return meals + + def get_by_id( + self, meal_id: int, include_restricted: bool = True, include_optional: bool = True + ) -> Optional[Dict[str, Any]]: + """Get a specific meal by its ID. + + Args: + meal_id: Meal identification number + include_restricted: Include restricted ("CO") meals in search + include_optional: Include optional ("T") meals in search + + Returns: + Meal dictionary with date information, or None if not found + """ + lists_to_search = [self.all] + if include_restricted: + lists_to_search.append(self.restricted) + if include_optional: + lists_to_search.append(self.optional) + + for day_list in lists_to_search: + for day in day_list: + for meal in day["meals"]: + if meal["id"] == meal_id: + return {**meal, "date": day["date"]} + return None + + def is_ordered( + self, meal_id: int, include_restricted: bool = True, include_optional: bool = True + ) -> bool: + """Check whether a meal is ordered or not. + + Args: + meal_id: Meal identification number + include_restricted: Include restricted ("CO") meals in search + include_optional: Include optional ("T") meals in search + + Returns: + True if meal is ordered, False otherwise + """ + meal = self.get_by_id(meal_id, include_restricted, include_optional) + 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). + + Args: + meal_id: Meal identification number + ordered: New order status + + Returns: + True if meal order status was changed successfully + + Raises: + AuthenticationError: If user is not logged in + StravaAPIError: If changing meal order status fails + """ + if not self.strava.user.is_logged_in: + raise AuthenticationError("User not logged in") + + if self.is_ordered(meal_id) == ordered: + return True + + payload = { + "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.strava._api_request("pridejJidloS5", payload) + if response["status_code"] != 200: + raise StravaAPIError("Failed to change meal order status") + return True + + def _save_order(self) -> bool: + """Save current order changes. + + Returns: + True if order was saved successfully + + Raises: + AuthenticationError: If user is not logged in + StravaAPIError: If saving order fails + """ + if not self.strava.user.is_logged_in: + raise AuthenticationError("User not logged in") + + payload = { + "cislo": self.strava.user.canteen_number, + "sid": self.strava.user.sid, + "url": self.strava.user.s5url, + "xml": None, + "lang": "EN", + "ignoreCert": "false", + } + + 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: + """Order multiple meals in a single transaction. + + Args: + *meal_ids: Variable number of meal identification numbers + + Raises: + StravaAPIError: If ordering any meal fails + """ + for meal_id in meal_ids: + self._change_meal_order(meal_id, True) + self._save_order() + self.fetch() # Refresh menu data + + for meal_id in meal_ids: + 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: + """Cancel multiple meal orders in a single transaction. + + Args: + *meal_ids: Variable number of meal identification numbers + + Raises: + StravaAPIError: If canceling any meal fails + """ + for meal_id in meal_ids: + self._change_meal_order(meal_id, False) + self._save_order() + self.fetch() # Refresh menu data + + for meal_id in meal_ids: + if self.is_ordered(meal_id): + raise StravaAPIError(f"Failed to cancel meal with ID {meal_id}") + + def print(self) -> None: + """Print the current menu in a readable format.""" + if not self.all: + print("No menu data available") + return + + for day in self.all: + 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" + meal_type_str = meal['type'].value # Get string value from enum + print(f" - {meal['id']} {meal['name']} ({meal_type_str}) - [{status}]") + print() + + def __repr__(self) -> str: + """Return the default processed list representation.""" + return repr(self.all) + + def __str__(self) -> str: + """Return string representation of the default list.""" + return str(self.all) + + def __iter__(self): + """Iterate over the default processed list.""" + return iter(self.all) + + def __len__(self) -> int: + """Return the number of days in default menu.""" + return len(self.all) + + def __getitem__(self, key): + """Access days by index from default list.""" + return self.all[key] + + class StravaCZ: """Strava.cz API client""" @@ -66,7 +617,7 @@ def __init__( self.api_url = f"{self.BASE_URL}/api" self.user = User() # Initialize the user object - self.menu: List[Dict[str, Any]] = [] + self.menu = Menu(self) # Initialize the menu object with reference to self self._setup_headers() self._initialize_session() @@ -177,239 +728,6 @@ def _populate_user_data(self, data: Dict[str, Any]) -> None: 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 - - Returns: - List of menu items grouped by date - - Raises: - AuthenticationError: If user is not logged in - StravaAPIError: If menu retrieval fails - """ - if not self.user.is_logged_in: - raise AuthenticationError("User not logged in") - - payload = { - "cislo": self.user.canteen_number, - "sid": self.user.sid, - "s5url": self.user.s5url, - "lang": "EN", - "konto": self.user.balance, - "podminka": "", - "ignoreCert": False, - } - - response = self._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 - - 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] = {} - - # Process all table entries (table0, table1, etc.) - for table_key, meals_list in menu_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": - continue - - unformated_date = meal["datum"] # Format: "dd-mm.yyyy" - date = f"{unformated_date[6:10]}-{unformated_date[3:5]}-{unformated_date[0:2]}" - - meal_filtered = { - "local_id": meal["id"], - "type": meal["druh_popis"], - "name": meal["nazev"], - "forbiddenAlergens": meal["zakazaneAlergeny"], - "alergens": meal["alergeny"], - "ordered": meal["pocet"] == 1, - "id": int(meal["veta"]), - "price": float(meal["cena"]), - } - - 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() - ] - - def print_menu(self) -> None: - """Print the current menu in a readable format.""" - if not self.menu: - self.get_menu() - - 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() - - def is_ordered(self, meal_id: int) -> bool: - """Check wheather a meal is ordered or not. - - Args: - meal_id: Meal identification number - - Returns: - True if meal is ordered, False otherwise - - Raises: - AuthenticationError: If user is not logged in - """ - if not self.user.is_logged_in: - raise AuthenticationError("User not logged in") - - for day in self.menu: - for meal in day["meals"]: - if meal["id"] == meal_id: - return meal["ordered"] - return False - - def _change_meal_order(self, meal_id: int, ordered: bool) -> bool: - """Change the order status of a meal (without saving). - - Args: - meal_id: Meal identification number - ordered: New order status - - Returns: - True if meal order status was changed successfully - - Raises: - AuthenticationError: If user is not logged in - StravaAPIError: If changing meal order status fails - """ - if not self.user.is_logged_in: - raise AuthenticationError("User not logged in") - - if self.is_ordered(meal_id) == ordered: - return True - - payload = { - "cislo": self.user.canteen_number, - "sid": self.user.sid, - "url": self.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 - - 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 - - Returns: - True if meal was canceled successfully - """ - return self._change_meal_order(meal_id, False) - - def _save_order(self) -> bool: - """Save current order changes. - - Returns: - True if order was saved successfully - - Raises: - AuthenticationError: If user is not logged in - StravaAPIError: If saving order fails - """ - if not self.user.is_logged_in: - raise AuthenticationError("User not logged in") - - payload = { - "cislo": self.user.canteen_number, - "sid": self.user.sid, - "url": self.user.s5url, - "xml": None, - "lang": "EN", - "ignoreCert": "false", - } - - response = self._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: - """Order multiple meals in a single transaction. - - Args: - *meal_ids: Variable number of meal identification numbers - """ - for meal_id in meal_ids: - self._add_meal_to_order(meal_id) - self._save_order() - self.get_menu() - - for meal_id in meal_ids: - 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: - """Cancel multiple meal orders in a single transaction. - - Args: - *meal_ids: Variable number of meal identification numbers - """ - for meal_id in meal_ids: - self._cancel_meal_order(meal_id) - self._save_order() - self.get_menu() - - for meal_id in meal_ids: - if self.is_ordered(meal_id): - raise StravaAPIError(f"Failed to cancel meal with ID {meal_id}") - def logout(self) -> bool: """Log out from Strava.cz account. @@ -434,7 +752,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") @@ -457,9 +775,18 @@ def logout(self) -> bool: ) print(strava.user) - strava.get_menu(include_soup=True) + # Fetch and display menu + strava.menu.fetch() + #strava.menu.print() + + #print(strava.menu.optional) + #print(strava.menu.restricted) + #print(strava.menu.all) + #print(strava.menu.complete) + #ordered = strava.menu.get_ordered_meals() + #print(f"\nOrdered meals: {len(ordered)}") - strava.print_menu() + print(strava.menu) strava.logout() print("Logged out") From ae091370d0eb7c48b05fe6b012e0c82fcfac2d47 Mon Sep 17 00:00:00 2001 From: jsem-nerad Date: Mon, 10 Nov 2025 09:37:28 +0100 Subject: [PATCH 2/8] mnohem lepe citelna a pouzitelna menu trida; docs still require revision; --- CHANGELOG.md | 43 +++-- MIGRATION_GUIDE.md | 139 ++++++++------- README.md | 62 ++++--- examples/example.py | 124 +++++++++---- pyproject.toml | 2 +- src/strava_cz/main.py | 401 +++++++++++++----------------------------- 6 files changed, 346 insertions(+), 425 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a11ed4e..374b6c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,35 +5,27 @@ Vsechny vyznamne zmeny v tomto projektu budou dokumentovany v tomto souboru. ## [unreleased] -## [0.2.0] 2025-11-06 +## [0.2.0] 2025-11-10 ### 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) - Kazde jidlo nyni obsahuje `orderType` pole s informaci o typu objednavky - `Menu.fetch()` pro ziskani jidelnicku z API (bez parametru) -- Vice pohledu na menu jako vlastnosti: - - `Menu.all` - default seznam (polevky + hlavni, objednavatelne) - - `Menu.main_only` - pouze hlavni jidla - - `Menu.soup_only` - pouze polevky - - `Menu.complete` - kompletni seznam vcetne volitelnych jidel - - `Menu.restricted` - jidla "CO" (uz nelze objednat) - - `Menu.optional` - jidla "T" (obvykle neobjednavana) -- Ploschy seznamy jidel s datem: - - `Menu.get_meals()` - vsechna jidla jako ploschy seznam - - `Menu.get_main_meals()` - pouze hlavni jidla - - `Menu.get_soup_meals()` - pouze polevky -- `Menu.get_unordered_days()` - vrati seznam dnu bez objednavek -- Parametry `include_restricted` a `include_optional` pro vsechny metody vracejici jidla nebo dny: - - Default False pro seznamove metody (get_meals, get_main_meals, get_soup_meals, filter_by_type, get_unordered_days) - - Default True pro vyhledavaci metody (get_by_id, get_by_date, get_ordered_meals, is_ordered) +- Dva 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 ploschy seznam +- Flexibilni 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/False/None) - Magic methods pro Menu: `__repr__`, `__str__`, `__iter__`, `__len__`, `__getitem__` - Automaticke filtrovani podle omezeniObj hodnot: - "VP" jidla se preskakuji (zadna skola) - - "CO" jidla jdou do `restricted` seznamu (OrderType.RESTRICTED) - - "T" jidla jdou do `optional` seznamu (OrderType.OPTIONAL) - - Prazdny string = objednavatelne jidla (OrderType.NORMAL) -- Inteligentni vyhledavani pres vybrane seznamy podle parametru include_restricted a include_optional + - "CO" jidla maji OrderType.RESTRICTED + - "T" jidla maji OrderType.OPTIONAL + - 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 @@ -42,11 +34,15 @@ Vsechny vyznamne zmeny v tomto projektu budou dokumentovany v tomto souboru. - 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 -- `filter_by_type()` nyni pouziva `MealType` enum misto stringu - `meal["type"]` je nyni primo `MealType` enum misto stringu +- `meal["orderType"]` indikuje 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 seznamy (all, restricted, optional) +- Vyhledavaci metody nyni prohledavaji vsechny typy objednavek automaticky +- Filtrovani pomoci parametru misto specializovanych metod nebo vlastnosti +- Default behavior: `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" - Zmena verze z 0.1.3 na 0.2.0 ### Removed @@ -55,6 +51,9 @@ Vsechny vyznamne zmeny v tomto projektu budou dokumentovany v tomto souboru. - `StravaCZ.is_ordered()` - pouzij `strava.menu.is_ordered()` - `StravaCZ.order_meals()` - pouzij `strava.menu.order_meals()` - `StravaCZ.cancel_meals()` - pouzij `strava.menu.cancel_meals()` +- `StravaCZ.filter_by_price_range()` - odstraneno filtrovani podle ceny +- Pole `local_id` z jidel - nepouzivane +- `StravaCZ.cancel_meals()` - pouzij `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 diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index 8c84d7d..578ceb0 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -4,6 +4,8 @@ ## Overview 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. + ## Breaking Changes ### Old API (Deprecated) @@ -28,12 +30,12 @@ strava.cancel_meals(3, 6) ### New API (Current) ```python -from strava_cz import StravaCZ, MealType +from strava_cz import StravaCZ, MealType, OrderType strava = StravaCZ(username="...", password="...") -# Fetching menu -strava.menu.fetch(include_soup=True) +# Fetching menu (no parameters needed) +strava.menu.fetch() # Printing menu strava.menu.print() @@ -50,103 +52,106 @@ strava.menu.cancel_meals(3, 6) ## New Features -### Direct Menu Access -```python -# Get all menu data -all_meals = strava.menu.get_all() +### Flexible Filtering System -# Get menu for specific date -today_menu = strava.menu.get_by_date("2025-11-04") +The new API uses two main methods with flexible parameters: -# Get all ordered meals -ordered = strava.menu.get_ordered_meals() +**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 -# Get specific meal by ID -meal = strava.menu.get_by_id(4) -``` +**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:** -### Filtering Capabilities ```python -from strava_cz import MealType, OrderType +from strava_cz import StravaCZ, MealType, OrderType -# Filter by meal type (now using enum) -soups = strava.menu.filter_by_type(MealType.SOUP) -main_dishes = strava.menu.filter_by_type(MealType.MAIN) +strava = StravaCZ(username="...", password="...", canteen_number="...") +strava.menu.fetch() -# Note: meal["type"] is now a MealType enum, not a string -for meal in soups: - print(meal["type"]) # Output: MealType.SOUP - print(meal["type"].value) # Output: "Polévka" +# Get all orderable meals (default - grouped by days) +days = strava.menu.get_days() -# Each meal also has orderType field -for meal in strava.menu.get_meals(): - print(meal["orderType"]) # Output: OrderType.NORMAL, OrderType.RESTRICTED, or OrderType.OPTIONAL - if meal["orderType"] == OrderType.RESTRICTED: - print(f"Meal {meal['id']} can no longer be ordered") -``` +# Get all orderable meals as flat list +meals = strava.menu.get_meals() -### Multiple List Views -```python -# Default list (orderable meals: soups + mains) -default = strava.menu.all +# Get only soups +soups = strava.menu.get_meals(meal_types=[MealType.SOUP]) -# Only main dishes -mains = strava.menu.main_only +# Get only main dishes +mains = strava.menu.get_days(meal_types=[MealType.MAIN]) -# Only soups -soups = strava.menu.soup_only +# Get all meals including restricted and optional +all_meals = strava.menu.get_meals( + order_types=[OrderType.NORMAL, OrderType.RESTRICTED, OrderType.OPTIONAL] +) -# Complete list including optional meals -complete = strava.menu.complete +# Get only ordered meals +ordered = strava.menu.get_meals(ordered=True) -# Meals that can no longer be ordered ("CO") -restricted = strava.menu.restricted +# Get days with no orders +unordered_days = strava.menu.get_days(ordered=False) -# Optional meals not usually ordered ("T") -optional = strava.menu.optional +# Get restricted meals only +restricted = strava.menu.get_days(order_types=[OrderType.RESTRICTED]) -# Flat lists with dates -all_meals = strava.menu.get_meals() -main_meals = strava.menu.get_main_meals() -soup_meals = strava.menu.get_soup_meals() +# 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 +) ``` -### Controlling Restricted and Optional Inclusion +### Direct Menu Access ```python -# By default, list methods exclude restricted and optional -meals = strava.menu.get_meals() # Only normal meals +# Get menu for specific date (searches all order types) +today_menu = strava.menu.get_by_date("2025-11-10") -# Include restricted meals -meals = strava.menu.get_meals(include_restricted=True) - -# Include optional meals -meals = strava.menu.get_meals(include_optional=True) +# Get specific meal by ID (searches all order types) +meal = strava.menu.get_by_id(4) -# Include both -meals = strava.menu.get_meals(include_restricted=True, include_optional=True) +# Check order status (searches all order types) +is_ordered = strava.menu.is_ordered(4) +``` -# Search methods include all by default -meal = strava.menu.get_by_id(123) # Searches all lists -meal = strava.menu.get_by_id(123, include_restricted=False) # Only searches normal lists +### 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" -# Get days with no orders -unordered = strava.menu.get_unordered_days() # Excludes restricted/optional -unordered = strava.menu.get_unordered_days(include_restricted=True, include_optional=True) # All days +# 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 days in menu +# 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 - -# Access processed data -processed = strava.menu.processed_data ``` ## Method Mapping diff --git a/README.md b/README.md index 475234c..e91ff83 100644 --- a/README.md +++ b/README.md @@ -102,39 +102,57 @@ Kazde jidlo v menu obsahuje nasledujici polozky: #### Vlastnosti (Properties) | vlastnost | typ | popis | |----------------|----------------------|--------------------------------------------------------------------------------| -| `all` | list | Default seznam - vsechna objednavatelna jidla (polevky + hlavni) podle dni | -| `main_only` | list | Pouze hlavni jidla podle dni | -| `soup_only` | list | Pouze polevky podle dni | -| `complete` | list | Kompletni seznam vcetne volitelnych jidel ("T") podle dni | -| `restricted` | list | Jidla, ktera uz nelze objednat ("CO") podle dni | -| `optional` | list | Jidla, ktera obvykle nejsou objednavana ("T") podle dni | - -#### Metody +| `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}]` | + +**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 ploschy 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 | |---------------------|-----------------------------------------------------------|-------------|--------------------------------------------------------------------------------------------------------------------| -| `fetch()` | None | Menu | Ziska jidelnicek z API a zpracuje ho do vsech seznamu; vraci sam sebe | -| `print()` | None | None | Vypise zformatovane default menu | -| `get_meals()` | include_restricted=False, include_optional=False | list | Vrati vsechna jidla z prislusnych seznamu jako ploschy seznam s datem | -| `get_main_meals()` | include_restricted=False, include_optional=False | list | Vrati pouze hlavni jidla z prislusnych seznamu jako ploschy seznam s datem | -| `get_soup_meals()` | include_restricted=False, include_optional=False | list | Vrati pouze polevky z prislusnych seznamu jako ploschy seznam s datem | -| `get_by_date()` | date [str], include_restricted=True, include_optional=True | dict/None | Vrati jidla pro konkretni datum (prohledava vybrane seznamy) | -| `get_by_id()` | meal_id [int], include_restricted=True, include_optional=True | dict/None | Vrati konkretni jidlo podle ID (prohledava vybrane seznamy) | -| `get_ordered_meals()` | include_restricted=True, include_optional=True | list | Vrati vsechna objednana jidla z vybranych seznamu | -| `get_unordered_days()` | include_restricted=False, include_optional=False | list | Vrati seznam dnu (datumy), ve kterych neni objednano zadne jidlo | -| `filter_by_type()` | meal_type [MealType], include_restricted=False, include_optional=False | list | Filtruje jidla z vybranych seznamu podle typu | -| `is_ordered()` | meal_id [int], include_restricted=True, include_optional=True | bool | Zjisti, jestli je dane jidlo objednano (prohledava vybrane seznamy) | +| `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) | | `order_meals()` | *meal_ids [int] | None | Objedna vice jidel podle meal_id | | `cancel_meals()` | *meal_ids [int] | None | Zrusi objednavky vice jidel podle meal_id | -Menu objekt podporuje iteraci, indexovani a len() - vse pracuje s default seznamem `all`. +**Poznamka:** Menu objekt podporuje iteraci, indexovani a len() - vse pracuje s defaultnim seznamem objednatelnych jidel. ### 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 | +| `__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 | diff --git a/examples/example.py b/examples/example.py index f8de324..47c34b3 100644 --- a/examples/example.py +++ b/examples/example.py @@ -14,52 +14,104 @@ strava.menu.fetch() strava.menu.print() -# Pristup k ruznym seznamum -print(f"Vsechny jidla: {len(strava.menu)} dni") -print(f"Pouze hlavni jidla: {len(strava.menu.main_only)} dni") -print(f"Pouze polevky: {len(strava.menu.soup_only)} dni") -print(f"Kompletni (s volitelymi): {len(strava.menu.complete)} dni") -print(f"Omezene objednavky: {len(strava.menu.restricted)} dni") -print(f"Volitelne objednavky: {len(strava.menu.optional)} dni") - -# Iterace pres menu +# 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'])}") -# Priklad: Kontrola typu objednavky -for day in strava.menu.complete: - for meal in day['meals']: - if meal['orderType'] == OrderType.RESTRICTED: - print(f"Jidlo {meal['id']} ({meal['name']}) uz nelze objednat") - elif meal['orderType'] == OrderType.OPTIONAL: - print(f"Jidlo {meal['id']} ({meal['name']}) je volitelne") - elif meal['orderType'] == OrderType.NORMAL: - print(f"Jidlo {meal['id']} ({meal['name']}) lze objednat normalne") +# ===== Ziskani jidel podle dni ===== + +# Pouze objednavatelna jidla (default) +normal_days = strava.menu.get_days() +print(f"\nObjednavatelne 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 ploschy 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.menu.is_ordered(4)) +print(f"\nJidlo 4 je objednano: {strava.menu.is_ordered(4)}") -# Objedna jidla s meal_id 3 a 6 -strava.menu.order_meals(3, 6) +# Ziskej jidlo podle ID +meal = strava.menu.get_by_id(4) +if meal: + print(f"Jidlo 4: {meal['name']} ({meal['type'].value})") -# Priklad: Ziskani vsech objednanych jidel -ordered_meals = strava.menu.get_ordered_meals() -print(f"Objednana jidla: {len(ordered_meals)}") +# 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") -# Priklad: Ziskani neobjednanych dni -unordered_days = strava.menu.get_unordered_days() -print(f"Dny bez objednavky: {unordered_days}") +# ===== Objednavani ===== -# Priklad: Ziskani ploschych seznamu jidel -all_meals = strava.menu.get_meals() -main_meals = strava.menu.get_main_meals() -soup_meals = strava.menu.get_soup_meals() -print(f"Celkem jidel: {len(all_meals)} (hlavni: {len(main_meals)}, polevky: {len(soup_meals)})") +# Objedna jidla s meal_id 3 a 6 +strava.menu.order_meals(3, 6) -# Priklad: Zahrnuti omezenych a volitelnych jidel -all_with_restricted = strava.menu.get_meals(include_restricted=True) -all_complete = strava.menu.get_meals(include_restricted=True, include_optional=True) -print(f"S omezenymi: {len(all_with_restricted)}, kompletni: {len(all_complete)}") +# Zrus objednavky +strava.menu.cancel_meals(3, 6) # Odhlasi uzivatele strava.logout() \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index a26439c..cf6f63b 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" diff --git a/src/strava_cz/main.py b/src/strava_cz/main.py index 58f5ddc..5ea71f7 100644 --- a/src/strava_cz/main.py +++ b/src/strava_cz/main.py @@ -110,11 +110,9 @@ def fetch(self) -> 'Menu': return self def _parse_menu_data(self) -> None: - """Parse raw menu response into structured lists.""" - # Temporary storage categorized by restriction status - all_meals: Dict[str, List[Dict]] = {} # Orderable (empty string) - restricted_meals: Dict[str, List[Dict]] = {} # "CO" - too late - optional_meals: Dict[str, List[Dict]] = {} # "T" - not usually ordered + """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 self.raw_data.items(): @@ -169,294 +167,144 @@ def _parse_menu_data(self) -> None: "ordered": meal["pocet"] == 1, "id": int(meal["veta"]), "price": float(meal["cena"]), + "date": date, } - # Categorize by restriction status - if order_type == OrderType.RESTRICTED: - if date not in restricted_meals: - restricted_meals[date] = [] - restricted_meals[date].append(meal_filtered) - elif order_type == OrderType.OPTIONAL: - if date not in optional_meals: - optional_meals[date] = [] - optional_meals[date].append(meal_filtered) - else: # NORMAL - orderable - if date not in all_meals: - all_meals[date] = [] - all_meals[date].append(meal_filtered) + # Store all meals together + if date not in meals_by_date: + meals_by_date[date] = [] + meals_by_date[date].append(meal_filtered) # Convert to day-grouped format and sort by date - self.all = sorted([ + self._all_meals = sorted([ {"date": date, "ordered": any(m["ordered"] for m in meals), "meals": meals} - for date, meals in all_meals.items() + for date, meals in meals_by_date.items() ], key=lambda x: x["date"]) - - self.restricted = sorted([ - {"date": date, "ordered": any(m["ordered"] for m in meals), "meals": meals} - for date, meals in restricted_meals.items() - ], key=lambda x: x["date"]) - - self.optional = sorted([ - {"date": date, "ordered": any(m["ordered"] for m in meals), "meals": meals} - for date, meals in optional_meals.items() - ], key=lambda x: x["date"]) - - # Create filtered views by meal type - self.main_only = self._filter_by_type_internal(self.all, MealType.MAIN) - self.soup_only = self._filter_by_type_internal(self.all, MealType.SOUP) - - # Create complete list (all + optional, sorted) - self.complete = self._merge_sorted_lists(self.all, self.optional) - - def _filter_by_type_internal(self, days: List[Dict[str, Any]], meal_type: MealType) -> List[Dict[str, Any]]: - """Filter days to only include specific meal type.""" - filtered_days = [] - for day in days: - filtered_meals = [m for m in day["meals"] if m["type"] == meal_type] - if filtered_meals: - filtered_days.append({ - "date": day["date"], - "ordered": any(m["ordered"] for m in filtered_meals), - "meals": filtered_meals - }) - return filtered_days - - def _merge_sorted_lists(self, list1: List[Dict[str, Any]], list2: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - """Merge two sorted day lists, combining meals for same dates.""" - merged = {} - - for day in list1 + list2: - date = day["date"] - if date not in merged: - merged[date] = {"date": date, "meals": [], "ordered": False} - merged[date]["meals"].extend(day["meals"]) - merged[date]["ordered"] = merged[date]["ordered"] or day["ordered"] - - return sorted(merged.values(), key=lambda x: x["date"]) - - def get_by_date( - self, date: str, include_restricted: bool = True, include_optional: bool = True - ) -> Optional[Dict[str, Any]]: - """Get menu items for a specific date. - - Args: - date: Date in YYYY-MM-DD format - include_restricted: Include restricted ("CO") meals in search - include_optional: Include optional ("T") meals in search - Returns: - Dictionary with date and meals, or None if not found - """ - # Build list of lists to search - lists_to_search = [self.all] - if include_restricted: - lists_to_search.append(self.restricted) - if include_optional: - lists_to_search.append(self.optional) - - for day_list in lists_to_search: - for day in day_list: - if day["date"] == date: - return day - return None - - def get_unordered_days( - self, include_restricted: bool = False, include_optional: bool = False + 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 all days where no meals are ordered. + """Get menu grouped by days with optional filtering. Args: - include_restricted: Include restricted ("CO") days - include_optional: Include optional ("T") days + 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: - List of days with no ordered meals + List of days with meals: [{"date": "YYYY-MM-DD", "ordered": bool, "meals": [...]}] """ - lists_to_search = [self.all] - if include_restricted: - lists_to_search.append(self.restricted) - if include_optional: - lists_to_search.append(self.optional) - - unordered_days = [] - for day_list in lists_to_search: - for day in day_list: - if not day["ordered"]: - unordered_days.append(day) - - return sorted(unordered_days, key=lambda x: x["date"]) + # Default to NORMAL order type only + if order_types is None: + order_types = [OrderType.NORMAL] - def get_ordered_meals( - self, include_restricted: bool = True, include_optional: bool = True - ) -> List[Dict[str, Any]]: - """Get all ordered meals across all dates. + 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 - Args: - include_restricted: Include restricted ("CO") meals in results - include_optional: Include optional ("T") meals in results + # Check if day has ordered meals + day_has_orders = any(m["ordered"] for m in filtered_meals) - Returns: - List of ordered meals with their date information - """ - lists_to_search = [self.all] - if include_restricted: - lists_to_search.append(self.restricted) - if include_optional: - lists_to_search.append(self.optional) - - ordered_meals = [] - for day_list in lists_to_search: - for day in day_list: - for meal in day["meals"]: - if meal["ordered"]: - ordered_meals.append({**meal, "date": day["date"]}) - - return sorted(ordered_meals, key=lambda x: x["date"]) - - def filter_by_type( - self, meal_type: MealType, include_restricted: bool = False, include_optional: bool = False - ) -> List[Dict[str, Any]]: - """Filter meals by type. + # Apply ordered filter + if ordered is not None: + if ordered and not day_has_orders: + continue + if not ordered and day_has_orders: + continue - Args: - meal_type: Type of meal to filter by (MealType.SOUP or MealType.MAIN) - include_restricted: Include restricted ("CO") meals - include_optional: Include optional ("T") meals + filtered_days.append({ + "date": day["date"], + "ordered": day_has_orders, + "meals": filtered_meals + }) - Returns: - List of meals matching the type with their date information - """ - lists_to_search = [self.all] - if include_restricted: - lists_to_search.append(self.restricted) - if include_optional: - lists_to_search.append(self.optional) - - filtered_meals = [] - for day_list in lists_to_search: - for day in day_list: - for meal in day["meals"]: - if meal["type"] == meal_type: - filtered_meals.append({**meal, "date": day["date"]}) - return sorted(filtered_meals, key=lambda x: x["date"]) + return filtered_days def get_meals( - self, include_restricted: bool = False, include_optional: bool = False + 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 date. + """Get all meals as flat list with optional filtering. Args: - include_restricted: Include restricted ("CO") meals - include_optional: Include optional ("T") meals + 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: - List of all meals (flattened) with date included in each meal + Flat list of meals with date: [{...meal, "date": "YYYY-MM-DD"}] """ - lists_to_search = [self.all] - if include_restricted: - lists_to_search.append(self.restricted) - if include_optional: - lists_to_search.append(self.optional) - - meals = [] - for day_list in lists_to_search: - for day in day_list: - for meal in day["meals"]: - meals.append({**meal, "date": day["date"]}) - return meals + # Default to NORMAL order type only + if order_types is None: + order_types = [OrderType.NORMAL] - def get_main_meals( - self, include_restricted: bool = False, include_optional: bool = False - ) -> List[Dict[str, Any]]: - """Get only main meals as flat list with date. + 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 - Args: - include_restricted: Include restricted ("CO") meals - include_optional: Include optional ("T") meals + meals.append(meal) - Returns: - List of main meals (flattened) with date included in each meal - """ - lists_to_search = [self.all] - if include_restricted: - lists_to_search.append(self.restricted) - if include_optional: - lists_to_search.append(self.optional) - - meals = [] - for day_list in lists_to_search: - for day in day_list: - for meal in day["meals"]: - if meal["type"] == MealType.MAIN: - meals.append({**meal, "date": day["date"]}) return meals - def get_soup_meals( - self, include_restricted: bool = False, include_optional: bool = False - ) -> List[Dict[str, Any]]: - """Get only soup meals as flat list with date. + def get_by_date(self, date: str) -> Optional[Dict[str, Any]]: + """Get menu items for a specific date (searches all order types). Args: - include_restricted: Include restricted ("CO") meals - include_optional: Include optional ("T") meals + date: Date in YYYY-MM-DD format Returns: - List of soup meals (flattened) with date included in each meal + Dictionary with date and meals, or None if not found """ - lists_to_search = [self.all] - if include_restricted: - lists_to_search.append(self.restricted) - if include_optional: - lists_to_search.append(self.optional) - - meals = [] - for day_list in lists_to_search: - for day in day_list: - for meal in day["meals"]: - if meal["type"] == MealType.SOUP: - meals.append({**meal, "date": day["date"]}) - return meals + for day in self._all_meals: + if day["date"] == date: + return day + return None - def get_by_id( - self, meal_id: int, include_restricted: bool = True, include_optional: bool = True - ) -> Optional[Dict[str, Any]]: - """Get a specific meal by its ID. + 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 - include_restricted: Include restricted ("CO") meals in search - include_optional: Include optional ("T") meals in search Returns: - Meal dictionary with date information, or None if not found + Meal dictionary, or None if not found """ - lists_to_search = [self.all] - if include_restricted: - lists_to_search.append(self.restricted) - if include_optional: - lists_to_search.append(self.optional) - - for day_list in lists_to_search: - for day in day_list: - for meal in day["meals"]: - if meal["id"] == meal_id: - return {**meal, "date": day["date"]} + for day in self._all_meals: + for meal in day["meals"]: + if meal["id"] == meal_id: + return meal return None - def is_ordered( - self, meal_id: int, include_restricted: bool = True, include_optional: bool = True - ) -> bool: - """Check whether a meal is ordered or not. + 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 - include_restricted: Include restricted ("CO") meals in search - include_optional: Include optional ("T") meals in search Returns: True if meal is ordered, False otherwise """ - meal = self.get_by_id(meal_id, include_restricted, include_optional) + 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: @@ -559,45 +407,44 @@ def cancel_meals(self, *meal_ids: int) -> None: raise StravaAPIError(f"Failed to cancel meal with ID {meal_id}") def print(self) -> None: - """Print the current menu in a readable format.""" - if not self.all: - print("No menu data available") - return - - for day in self.all: - print(f"Date: {day['date']} - {'Ordered' if day['ordered'] else 'Not ordered'}") + """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" - meal_type_str = meal['type'].value # Get string value from enum - print(f" - {meal['id']} {meal['name']} ({meal_type_str}) - [{status}]") + order_type_info = f" [{meal['orderType'].name}]" if meal["orderType"] != OrderType.NORMAL else "" + print(f" - {meal['id']} {meal['name']} ({meal_type_str}){order_type_info} - [{status}]") print() def __repr__(self) -> str: - """Return the default processed list representation.""" - return repr(self.all) + """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 the default list.""" - return str(self.all) + """Return string representation of menu.""" + return self.__repr__() def __iter__(self): - """Iterate over the default processed list.""" - return iter(self.all) + """Iterate over orderable days.""" + return iter(self.get_days()) def __len__(self) -> int: - """Return the number of days in default menu.""" - return len(self.all) + """Return the number of orderable days.""" + return len(self.get_days()) def __getitem__(self, key): - """Access days by index from default list.""" - return self.all[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" - DEFAULT_CANTEEN_NUMBER = "3753" # Default SSPS canteen number def __init__( self, @@ -610,7 +457,7 @@ def __init__( Args: username: User's login username password: User's login password - canteen_number: Canteen number + canteen_number: Canteen number (required for login) """ self.session = requests.Session() @@ -672,29 +519,31 @@ def _api_request( except requests.RequestException as e: raise StravaAPIError(f"API request failed: {e}") - def login(self, username, password, canteen_number=None): + 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 + 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 or password is empty + 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 or self.DEFAULT_CANTEEN_NUMBER + self.user.canteen_number = canteen_number payload = { "cislo": self.user.canteen_number, @@ -773,20 +622,18 @@ def logout(self) -> bool: password=STRAVA_PASSWORD, canteen_number=STRAVA_CANTEEN_NUMBER, ) - print(strava.user) - # Fetch and display menu + # Ziskani jidelnicku a vypsani strava.menu.fetch() - #strava.menu.print() + + # Vsechna objednavatelna jidla + days = strava.menu.get_days(order_types=[OrderType.NORMAL], ordered=False, meal_types=[MealType.MAIN]) + print(days) - #print(strava.menu.optional) - #print(strava.menu.restricted) - #print(strava.menu.all) - #print(strava.menu.complete) - #ordered = strava.menu.get_ordered_meals() - #print(f"\nOrdered meals: {len(ordered)}") + meal_ids = [] + for day in days: + meal_ids.append(day["meals"][1]["id"]) - print(strava.menu) + strava.menu.order_meals(*meal_ids) strava.logout() - print("Logged out") From 986932ab377a33f3440212a8978f28e2ce59f6e5 Mon Sep 17 00:00:00 2001 From: jsem-nerad Date: Mon, 10 Nov 2025 09:51:13 +0100 Subject: [PATCH 3/8] update tests --- examples/example.py | 2 +- src/strava_cz/main.py | 11 +- tests/test_strava_cz.py | 308 +++++++++++++++++++++++++++++++++++----- 3 files changed, 275 insertions(+), 46 deletions(-) diff --git a/examples/example.py b/examples/example.py index 47c34b3..b4f591e 100644 --- a/examples/example.py +++ b/examples/example.py @@ -26,7 +26,7 @@ # Pouze objednavatelna jidla (default) normal_days = strava.menu.get_days() -print(f"\nObjednavatelne dny: {len(normal_days)}") +print(f"\nObjednatelne dny: {len(normal_days)}") # Pouze polevky soup_days = strava.menu.get_days(meal_types=[MealType.SOUP]) diff --git a/src/strava_cz/main.py b/src/strava_cz/main.py index 5ea71f7..eb92007 100644 --- a/src/strava_cz/main.py +++ b/src/strava_cz/main.py @@ -14,7 +14,7 @@ class MealType(Enum): class OrderType(Enum): """Enum for order restriction types.""" - NORMAL = "Objednavatelne" # Empty string - normal orderable + NORMAL = "Objednatelne" # Empty string - normal orderable RESTRICTED = "Nelze objednat" # "CO" - too late to order OPTIONAL = "Volitelne" # "T" - not usually ordered but can be @@ -68,14 +68,7 @@ def __init__(self, strava_client: 'StravaCZ'): """ self.strava = strava_client self.raw_data: Dict[str, Any] = {} - - # Day-grouped lists (primary storage) - self.all: List[Dict[str, Any]] = [] # Default: soups + mains, orderable - self.main_only: List[Dict[str, Any]] = [] # Only main meals - self.soup_only: List[Dict[str, Any]] = [] # Only soups - self.restricted: List[Dict[str, Any]] = [] # "CO" - too late to order - self.optional: List[Dict[str, Any]] = [] # "T" - not usually ordered - self.complete: List[Dict[str, Any]] = [] # all + optional, sorted + self._all_meals: List[Dict[str, Any]] = [] # Internal storage for all meals def fetch(self) -> 'Menu': """Fetch menu data from API and process it into various lists. diff --git a/tests/test_strava_cz.py b/tests/test_strava_cz.py index 4ba6de5..8384230 100644 --- a/tests/test_strava_cz.py +++ b/tests/test_strava_cz.py @@ -1,6 +1,6 @@ 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 +102,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 +114,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 +128,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 +148,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 +158,276 @@ 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) From fe2553d7d448ca046d31f6719d7c917405633093 Mon Sep 17 00:00:00 2001 From: jsem-nerad Date: Mon, 10 Nov 2025 09:56:28 +0100 Subject: [PATCH 4/8] code formatting --- src/strava_cz/__init__.py | 20 ++++++++++-- src/strava_cz/main.py | 65 ++++++++++++++++++++++++--------------- 2 files changed, 59 insertions(+), 26 deletions(-) diff --git a/src/strava_cz/__init__.py b/src/strava_cz/__init__.py index 438a96a..98fd48c 100644 --- a/src/strava_cz/__init__.py +++ b/src/strava_cz/__init__.py @@ -2,10 +2,26 @@ StravaCZ - High level API pro interakci s webovou aplikaci Strava.cz """ -from .main import StravaCZ, AuthenticationError, StravaAPIError, User, MealType, OrderType, Menu +from .main import ( + StravaCZ, + AuthenticationError, + StravaAPIError, + User, + MealType, + OrderType, + Menu, +) __version__ = "0.2.0" __author__ = "Vojtěch Nerad" __email__ = "ja@jsem-nerad.cz" -__all__ = ["StravaCZ", "AuthenticationError", "StravaAPIError", "User"] +__all__ = [ + "StravaCZ", + "AuthenticationError", + "StravaAPIError", + "User", + "MealType", + "OrderType", + "Menu", +] diff --git a/src/strava_cz/main.py b/src/strava_cz/main.py index eb92007..be0da72 100644 --- a/src/strava_cz/main.py +++ b/src/strava_cz/main.py @@ -7,6 +7,7 @@ class MealType(Enum): """Enum for meal types.""" + SOUP = "Polévka" MAIN = "Hlavní jídlo" UNKNOWN = "Neznámý typ" @@ -14,6 +15,7 @@ class MealType(Enum): 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 @@ -60,9 +62,9 @@ def __repr__(self): class Menu: """Menu data container and processor""" - def __init__(self, strava_client: 'StravaCZ'): + def __init__(self, strava_client: "StravaCZ"): """Initialize Menu with reference to StravaCZ client. - + Args: strava_client: Reference to the parent StravaCZ instance """ @@ -70,7 +72,7 @@ def __init__(self, strava_client: 'StravaCZ'): self.raw_data: Dict[str, Any] = {} self._all_meals: List[Dict[str, Any]] = [] # Internal storage for all meals - def fetch(self) -> 'Menu': + def fetch(self) -> "Menu": """Fetch menu data from API and process it into various lists. Returns: @@ -106,7 +108,7 @@ 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 self.raw_data.items(): if not table_key.startswith("table"): @@ -121,7 +123,7 @@ def _parse_menu_data(self) -> None: # Get restriction status restriction = meal["omezeniObj"]["den"] - + # Skip "VP" (no school) completely if "VP" in restriction: continue @@ -169,10 +171,13 @@ def _parse_menu_data(self) -> None: meals_by_date[date].append(meal_filtered) # 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"]) + 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 get_days( self, @@ -184,11 +189,14 @@ def get_days( 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) + 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: - List of days with meals: [{"date": "YYYY-MM-DD", "ordered": bool, "meals": [...]}] + List of days with meals: + [{"date": "YYYY-MM-DD", "ordered": bool, "meals": [...]}] """ # Default to NORMAL order type only if order_types is None: @@ -198,7 +206,8 @@ def get_days( for day in self._all_meals: # Filter meals by type and order type filtered_meals = [ - meal for meal in day["meals"] + meal + for meal in day["meals"] if (meal_types is None or meal["type"] in meal_types) and (meal["orderType"] in order_types) ] @@ -216,11 +225,9 @@ def get_days( if not ordered and day_has_orders: continue - filtered_days.append({ - "date": day["date"], - "ordered": day_has_orders, - "meals": filtered_meals - }) + filtered_days.append( + {"date": day["date"], "ordered": day_has_orders, "meals": filtered_meals} + ) return filtered_days @@ -234,8 +241,10 @@ def get_meals( 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) + 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: Flat list of meals with date: [{...meal, "date": "YYYY-MM-DD"}] @@ -407,8 +416,14 @@ def print(self) -> None: for meal in day["meals"]: meal_type_str = meal["type"].value status = "Ordered" if meal["ordered"] else "Not ordered" - order_type_info = f" [{meal['orderType'].name}]" if meal["orderType"] != OrderType.NORMAL else "" - print(f" - {meal['id']} {meal['name']} ({meal_type_str}){order_type_info} - [{status}]") + 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: @@ -618,9 +633,11 @@ def logout(self) -> bool: # Ziskani jidelnicku a vypsani strava.menu.fetch() - + # Vsechna objednavatelna jidla - days = strava.menu.get_days(order_types=[OrderType.NORMAL], ordered=False, meal_types=[MealType.MAIN]) + days = strava.menu.get_days( + order_types=[OrderType.NORMAL], ordered=False, meal_types=[MealType.MAIN] + ) print(days) meal_ids = [] From d6ee7a03f2ea643f87dcd30886ac88da8cbdf708 Mon Sep 17 00:00:00 2001 From: jsem-nerad Date: Mon, 10 Nov 2025 14:16:45 +0100 Subject: [PATCH 5/8] order error handeling; --- README.md | 4 +- src/strava_cz/__init__.py | 4 + src/strava_cz/main.py | 204 ++++++++++++++++++++++++++++++++++---- 3 files changed, 191 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index e91ff83..8e84db6 100644 --- a/README.md +++ b/README.md @@ -163,11 +163,11 @@ menu.get_days(order_types=[OrderType.NORMAL, OrderType.RESTRICTED, OrderType.OPT - [x] Lepsi datum format - [x] Moznost detailnejsi filtrace jidelnicku - [x] Kontrola stavu po objednani +- [x] Filtrace dnu, ktere nejdou objednat +- [x] Lepsi testing - [ ] Lepe zdokumentovat pouziti - [ ] Rate limiting - [ ] Balance check pred objednanim -- [ ] Filtrace dnu, ktere nejdou objednat -- [ ] Lepsi testing ## Known bugs diff --git a/src/strava_cz/__init__.py b/src/strava_cz/__init__.py index 98fd48c..ba60d1a 100644 --- a/src/strava_cz/__init__.py +++ b/src/strava_cz/__init__.py @@ -6,6 +6,8 @@ StravaCZ, AuthenticationError, StravaAPIError, + InsufficientBalanceError, + DuplicateMealError, User, MealType, OrderType, @@ -20,6 +22,8 @@ "StravaCZ", "AuthenticationError", "StravaAPIError", + "InsufficientBalanceError", + "DuplicateMealError", "User", "MealType", "OrderType", diff --git a/src/strava_cz/main.py b/src/strava_cz/main.py index be0da72..5c9bc85 100644 --- a/src/strava_cz/main.py +++ b/src/strava_cz/main.py @@ -33,6 +33,18 @@ 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 User: """User data container""" @@ -321,6 +333,7 @@ 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 StravaAPIError: If changing meal order status fails """ if not self.strava.user.is_logged_in: @@ -340,8 +353,29 @@ def _change_meal_order(self, meal_id: int, ordered: bool) -> bool: } response = self.strava._api_request("pridejJidloS5", payload) + if response["status_code"] != 200: - raise StravaAPIError("Failed to change meal order status") + # 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 + return True def _save_order(self) -> bool: @@ -372,41 +406,179 @@ def _save_order(self) -> bool: 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: - StravaAPIError: If ordering any meal fails + InsufficientBalanceError: If insufficient balance (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 = {} + filtered_meal_ids = [] + skipped_meals = [] + for meal_id in meal_ids: - self._change_meal_order(meal_id, True) + 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 = [] + + for meal_id in filtered_meal_ids: + try: + self._change_meal_order(meal_id, True) + except (InsufficientBalanceError, StravaAPIError) as e: + if continue_on_error: + errors.append((meal_id, str(e))) + else: + # Cancel all changes and re-raise + self._cancel_order() + raise + self._save_order() self.fetch() # Refresh menu data - for meal_id in meal_ids: + # Verify orders + for meal_id in filtered_meal_ids: 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: - StravaAPIError: If canceling any meal fails + StravaAPIError: If canceling any meal fails (only if continue_on_error=False) """ + errors = [] + for meal_id in meal_ids: - self._change_meal_order(meal_id, False) + try: + self._change_meal_order(meal_id, False) + except StravaAPIError as e: + if continue_on_error: + errors.append((meal_id, str(e))) + else: + # Cancel all changes and re-raise + self._cancel_order() + raise + self._save_order() self.fetch() # Refresh menu data + # Verify cancellations for meal_id in meal_ids: 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).""" @@ -638,12 +810,6 @@ def logout(self) -> bool: days = strava.menu.get_days( order_types=[OrderType.NORMAL], ordered=False, meal_types=[MealType.MAIN] ) - print(days) - - meal_ids = [] - for day in days: - meal_ids.append(day["meals"][1]["id"]) - - strava.menu.order_meals(*meal_ids) - + + strava.logout() From 5ada706e25de2978c1ca620326a573e6cb833be8 Mon Sep 17 00:00:00 2001 From: jsem-nerad Date: Tue, 11 Nov 2025 14:46:10 +0100 Subject: [PATCH 6/8] lepsi exception handling --- src/strava_cz/__init__.py | 2 ++ src/strava_cz/main.py | 47 ++++++++++++++++++++++++++++++++------- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/src/strava_cz/__init__.py b/src/strava_cz/__init__.py index ba60d1a..da13806 100644 --- a/src/strava_cz/__init__.py +++ b/src/strava_cz/__init__.py @@ -8,6 +8,7 @@ StravaAPIError, InsufficientBalanceError, DuplicateMealError, + InvalidMealTypeError, User, MealType, OrderType, @@ -24,6 +25,7 @@ "StravaAPIError", "InsufficientBalanceError", "DuplicateMealError", + "InvalidMealTypeError", "User", "MealType", "OrderType", diff --git a/src/strava_cz/main.py b/src/strava_cz/main.py index 5c9bc85..8f92779 100644 --- a/src/strava_cz/main.py +++ b/src/strava_cz/main.py @@ -45,6 +45,12 @@ class DuplicateMealError(StravaAPIError): 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""" @@ -334,6 +340,7 @@ 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.strava.user.is_logged_in: @@ -342,6 +349,14 @@ def _change_meal_order(self, meal_id: int, ordered: bool) -> bool: 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.strava.user.canteen_number, "sid": self.strava.user.sid, @@ -357,7 +372,7 @@ def _change_meal_order(self, meal_id: int, ordered: bool) -> bool: 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") @@ -465,6 +480,7 @@ def order_meals( 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) """ @@ -509,13 +525,15 @@ def order_meals( ) 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, StravaAPIError) as e: + 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() @@ -524,8 +542,10 @@ def order_meals( self._save_order() self.fetch() # Refresh menu data - # Verify orders + # 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): error_msg = f"Failed to order meal with ID {meal_id}" if continue_on_error: @@ -548,16 +568,19 @@ def cancel_meals(self, *meal_ids: int, continue_on_error: bool = False) -> None: 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: try: self._change_meal_order(meal_id, False) - except StravaAPIError as e: + 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() @@ -566,8 +589,10 @@ def cancel_meals(self, *meal_ids: int, continue_on_error: bool = False) -> None: self._save_order() self.fetch() # Refresh menu data - # Verify cancellations + # 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): error_msg = f"Failed to cancel meal with ID {meal_id}" if continue_on_error: @@ -808,8 +833,14 @@ def logout(self) -> bool: # Vsechna objednavatelna jidla days = strava.menu.get_days( - order_types=[OrderType.NORMAL], ordered=False, meal_types=[MealType.MAIN] + 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.menu.order_meals(64, *meal_ids[:5], continue_on_error=True, strict_duplicates=False) + strava.logout() From 4c87bf1106e00088e5def071ac12781d493dcf6b Mon Sep 17 00:00:00 2001 From: jsem-nerad Date: Tue, 11 Nov 2025 20:14:46 +0100 Subject: [PATCH 7/8] release verze 0.2.0 na github a pypi --- .flake8 | 11 + CHANGELOG.md | 60 ++-- MIGRATION_GUIDE.md | 129 +++++++- README.md | 112 +++++-- examples/example.py | 62 +++- notes/repo_rules.md | 9 +- src/strava_cz/main.py | 55 ++-- tests/test_strava_cz.py | 642 ++++++++++++++++++++++++++++++++++++++++ 8 files changed, 997 insertions(+), 83 deletions(-) create mode 100644 .flake8 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 374b6c4..dd1dde7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,55 +5,69 @@ Vsechny vyznamne zmeny v tomto projektu budou dokumentovany v tomto souboru. ## [unreleased] -## [0.2.0] 2025-11-10 +## [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) -- Dva hlavni metody pro ziskani dat: +- 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 ploschy seznam -- Flexibilni filtrovani pomoci parametru: + - `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/False/None) -- Magic methods pro Menu: `__repr__`, `__str__`, `__iter__`, `__len__`, `__getitem__` -- Automaticke filtrovani podle omezeniObj hodnot: - - "VP" jidla se preskakuji (zadna skola) - - "CO" jidla maji OrderType.RESTRICTED - - "T" jidla maji OrderType.OPTIONAL + - `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"]` indikuje typ objednavky (NORMAL/RESTRICTED/OPTIONAL) +- `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 -- Default behavior: `order_types=None` vraci pouze `OrderType.NORMAL` (objednavatelne jidla) +- 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" -- Zmena verze z 0.1.3 na 0.2.0 +- **`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()` - pouzij `strava.menu.fetch()` -- `StravaCZ.print_menu()` - pouzij `strava.menu.print()` -- `StravaCZ.is_ordered()` - pouzij `strava.menu.is_ordered()` -- `StravaCZ.order_meals()` - pouzij `strava.menu.order_meals()` -- `StravaCZ.cancel_meals()` - pouzij `strava.menu.cancel_meals()` +- `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()` - pouzij `strava.menu.cancel_meals()` +- `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 @@ -71,7 +85,11 @@ Vsechny vyznamne zmeny v tomto projektu budou dokumentovany v tomto souboru. - Overovani uspesnosti objednani/odhlaseni - Filtrace prazdnych jidel, vcetne svatku a prazdnin - Opravene filtrovani prazdnych jidel -- Automaticke preskoceni "VP" jidel (zadna skola) +- 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 index 578ceb0..2805ba0 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -1,11 +1,20 @@ -# Migration Guide: Menu Class Refactoring +# 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) @@ -28,11 +37,18 @@ strava.order_meals(3, 6) strava.cancel_meals(3, 6) ``` -### New API (Current) +### New API (v0.2.0 - Current) ```python -from strava_cz import StravaCZ, MealType, OrderType +from strava_cz import ( + StravaCZ, + MealType, + OrderType, + InvalidMealTypeError, + DuplicateMealError, + InsufficientBalanceError +) -strava = StravaCZ(username="...", password="...") +strava = StravaCZ(username="...", password="...", canteen_number="...") # Fetching menu (no parameters needed) strava.menu.fetch() @@ -43,14 +59,111 @@ strava.menu.print() # Checking if ordered is_ordered = strava.menu.is_ordered(meal_id=4) -# Ordering meals -strava.menu.order_meals(3, 6) +# 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) +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 ``` -## New Features +## Menu Class Features (v0.2.0) ### Flexible Filtering System diff --git a/README.md b/README.md index 8e84db6..6099a60 100644 --- a/README.md +++ b/README.md @@ -5,14 +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 +- Prihlaseni/odhlaseni systemu +- Vypsani a filtrace jidelnicku - Objednavani a odhlasovani jidel podle ID jidla - Automaticke filtrovani jidel podle typu a objednatelnosti -- Vice pohledu na jidelnicek (vsechny, pouze hlavni, pouze polevky, kompletni, omezene) - Vyhledavani jidel podle ID nebo data - Ulozeni raw i zpracovanych dat z API -- Menu objekt se chova jako list pro snadnou iteraci +- Sledovani zustatku na uctu ## Usage @@ -22,27 +21,36 @@ pip install strava-cz ``` ```python -from strava_cz import StravaCZ, MealType +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 ruznym pohledum na menu -print(f"Vsechny jidla: {len(strava.menu)} dni") # Default - vsechna objednavatelna jidla -print(f"Pouze hlavni: {len(strava.menu.main_only)} dni") -print(f"Pouze polevky: {len(strava.menu.soup_only)} dni") -print(f"Kompletni: {len(strava.menu.complete)} dni") # Vcetne volitelnych jidel +# 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] +) # Iterace pres menu (default seznam) for day in strava.menu: @@ -52,19 +60,25 @@ for day in strava.menu: print(strava.menu.is_ordered(4)) # Objedna jidla s meal_id 3 a 6 +# Automaticky detekuje duplicity a kontroluje typ jidla strava.menu.order_meals(3, 6) -# Ziskani vsech objednanych jidel (prohledava vsechny seznamy) -ordered = strava.menu.get_ordered_meals() +# 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 ploschych seznamu jidel s datem -all_meals = strava.menu.get_meals() # Vsechna jidla jako ploschy seznam -main_meals = strava.menu.get_main_meals() # Pouze hlavni jidla -soup_meals = strava.menu.get_soup_meals() # Pouze polevky +# 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() ``` @@ -111,6 +125,8 @@ Kazde jidlo v menu obsahuje nasledujici polozky: | `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 @@ -122,7 +138,7 @@ Kazde jidlo v menu obsahuje nasledujici polozky: # Vsechna objednavatelna jidla podle dni (default) menu.get_days() -# Vsechna jidla jako ploschy seznam +# Vsechna jidla jako flat seznam menu.get_meals() # Pouze polevky @@ -141,11 +157,52 @@ menu.get_days(order_types=[OrderType.NORMAL, OrderType.RESTRICTED, OrderType.OPT | `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) | -| `order_meals()` | *meal_ids [int] | None | Objedna vice jidel podle meal_id | -| `cancel_meals()` | *meal_ids [int] | None | Zrusi objednavky vice jidel podle meal_id | + +#### 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 @@ -165,9 +222,13 @@ menu.get_days(order_types=[OrderType.NORMAL, OrderType.RESTRICTED, OrderType.OPT - [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 +- [ ] Zrychleni interakce +- [ ] Debug/Log mode ## Known bugs @@ -192,3 +253,12 @@ Udelal jsi sam nejake zlepseni? Jeste lepsi! Kazdy pull request je vitan. 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 b4f591e..5515343 100644 --- a/examples/example.py +++ b/examples/example.py @@ -1,14 +1,23 @@ -from strava_cz import StravaCZ, MealType, OrderType +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 a vypsani strava.menu.fetch() @@ -50,7 +59,7 @@ unordered_days = strava.menu.get_days(ordered=False) print(f"Dny bez objednavek: {len(unordered_days)}") -# ===== Ziskani jidel jako ploschy seznam ===== +# ===== Ziskani jidel jako flat seznam ===== # Vsechna objednavatelna jidla meals = strava.menu.get_meals() @@ -105,13 +114,52 @@ if today_meals: print(f"Jidla na 11.11: {len(today_meals['meals'])} jidel") -# ===== Objednavani ===== +# ===== 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}") -# Objedna jidla s meal_id 3 a 6 -strava.menu.order_meals(3, 6) +# 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 -strava.menu.cancel_meals(3, 6) +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č") + +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/src/strava_cz/main.py b/src/strava_cz/main.py index 8f92779..de4b48e 100644 --- a/src/strava_cz/main.py +++ b/src/strava_cz/main.py @@ -1,5 +1,7 @@ """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 @@ -368,7 +370,7 @@ def _change_meal_order(self, meal_id: int, ordered: bool) -> bool: } response = self.strava._api_request("pridejJidloS5", payload) - + if response["status_code"] != 200: # Check for specific error codes response_data = response.get("response", {}) @@ -377,12 +379,12 @@ def _change_meal_order(self, meal_id: int, ordered: bool) -> bool: 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: @@ -390,7 +392,7 @@ def _change_meal_order(self, meal_id: int, ordered: bool) -> bool: self.strava.user.balance = float(response_data["konto"]) except (ValueError, TypeError): pass # Keep old balance if parsing fails - + return True def _save_order(self) -> bool: @@ -450,7 +452,7 @@ def _cancel_order(self) -> bool: 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: @@ -458,7 +460,7 @@ def _cancel_order(self) -> bool: self.strava.user.balance = float(response_data["konto"]) except (ValueError, TypeError): pass - + return True def order_meals( @@ -480,17 +482,19 @@ def order_meals( 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) + 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 = {} - filtered_meal_ids = [] - skipped_meals = [] - + seen_dates: Dict[str, int] = {} + filtered_meal_ids: List[int] = [] + skipped_meals: List[tuple] = [] + for meal_id in meal_ids: meal = self.get_by_id(meal_id) if not meal: @@ -499,9 +503,9 @@ def order_meals( 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: @@ -512,10 +516,10 @@ def order_meals( 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: @@ -523,10 +527,10 @@ def order_meals( 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) @@ -538,7 +542,7 @@ def order_meals( # Cancel all changes and re-raise self._cancel_order() raise - + self._save_order() self.fetch() # Refresh menu data @@ -552,7 +556,7 @@ def order_meals( 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]) @@ -568,12 +572,13 @@ def cancel_meals(self, *meal_ids: int, continue_on_error: bool = False) -> None: and cancel all changes. Raises: - InvalidMealTypeError: If trying to cancel non-MAIN meal type (only if continue_on_error=False) + 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: try: self._change_meal_order(meal_id, False) @@ -585,7 +590,7 @@ def cancel_meals(self, *meal_ids: int, continue_on_error: bool = False) -> None: # Cancel all changes and re-raise self._cancel_order() raise - + self._save_order() self.fetch() # Refresh menu data @@ -599,7 +604,7 @@ def cancel_meals(self, *meal_ids: int, continue_on_error: bool = False) -> None: 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]) diff --git a/tests/test_strava_cz.py b/tests/test_strava_cz.py index 8384230..0d570e0 100644 --- a/tests/test_strava_cz.py +++ b/tests/test_strava_cz.py @@ -1,3 +1,12 @@ +# !! 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, MealType, OrderType @@ -431,3 +440,636 @@ def test_canteen_number_required(self): 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 From 7713afc39ac4ea1c839997aa2d825c407777510a Mon Sep 17 00:00:00 2001 From: jsem-nerad Date: Tue, 11 Nov 2025 20:24:28 +0100 Subject: [PATCH 8/8] update repo url --- pyproject.toml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index cf6f63b..55f9f31 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"]