diff --git a/CHANGELOG.md b/CHANGELOG.md index 42de173..85c6557 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,22 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Fixed +### Added -- Car-type filtering now matches case-insensitively: the live API reports the - free-floating type as `cityflitzer`, not `cityFlitzer`, which made - `cars list` and `autobook` match zero cars. -- Price parsing now understands the live `/prices` schema - (`priceItems[].priceIncl.amount` + `code`); the legacy `gross`/`currency` - shape remains as a fallback. Previously every price came back "unknown". +- `teilauto providers list` — the operators you can log in to (id, abbrev, + name), with the configured `api.prov_id` marked. Deliberately scoped to + `action=CUSTOMER_LOGIN`: the unfiltered provider list contains operators + that are bookable but not login-eligible, and picking one of those makes + login impossible. +- Config key `api.key` (env: `TEILAUTO_API_KEY`) to override the app-level + `x-api-key`. ### Changed +- The default API key is now the "Carsharing Deutschland" umbrella app key, + whose login-eligible set covers every teilauto region and most other German + Cantamen-based operators — so `config set api.prov_id ` (see + `providers list`) is all it takes to use another operator. Provider 118's + settings are byte-identical under the old and new key, so existing + Mitteldeutschland users are unaffected; a fresh `teilauto auth login` after + upgrading is recommended. +- `auth login` now prompts for "Email or customer number" — regional operators + commonly use the customer number (Kundennummer) as the login. - `autobook --for` is now optional and defaults to `1h`. CityFlitzer bills actual usage (first started hour in full, then 15-minute steps, plus kilometers) and the backend rewrites the booking end to the real trip end, so the window is a formality that only affects the availability check. +### Fixed + +- Car-type filtering now matches case-insensitively: the live API reports the + free-floating type as `cityflitzer`, not `cityFlitzer`, which made + `cars list` and `autobook` match zero cars. +- Price parsing now understands the live `/prices` schema + (`priceItems[].priceIncl.amount` + `code`); the legacy `gross`/`currency` + shape remains as a fallback. Previously every price came back "unknown". + ### Removed - `autobook --max-price` and the per-candidate price lookup. The API prices diff --git a/README.md b/README.md index 7ceef8a..b774385 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,7 @@ teilauto notify test | `teilauto cars list` | List cars near a location, closest first. | | `teilauto cars show CAR_ID` | Car detail plus blocked time ranges (next 24h by default). | | `teilauto price CAR_ID` | Price of booking a car for a time window. Login required for real prices; unauthenticated requests return "unknown". For CityFlitzer the API always prices a fixed 48h worst case regardless of the requested window — actual billing follows usage. | +| `teilauto providers list` | Operators you can log in to — pick your `api.prov_id` from here. | | `teilauto bookings list` | Your bookings (upcoming only by default). | | `teilauto bookings show BOOKING_ID` | One booking in detail. | | `teilauto bookings create CAR_ID` | Book a car for a time window (asks for confirmation; skip with `--yes`, required if you also pass `--json`). | @@ -143,7 +144,8 @@ then environment overrides: | `defaults.min_fuel` | `0.0` | Default minimum fuel level (%) filter. | | `defaults.window_min` | `30` | Default booking/search window length in minutes. | | `defaults.interval_sec` | `120` | Default poll interval for `watch`/`autobook`, in seconds. | -| `api.prov_id` | `'118'` | Cantamen provider id (teilauto Mitteldeutschland). | +| `api.prov_id` | `'118'` | Your operator's Cantamen provider id (default: teilauto Mitteldeutschland) — see `teilauto providers list`. | +| `api.key` | (Carsharing Deutschland app key) | App-level `x-api-key` sent with every request. The default covers all teilauto regions and most German Cantamen-based operators; rarely needs changing. | | `notify.channels` | `[]` | Active notification channels: `telegram`, `ntfy`, or both. | | `notify.telegram.chat_id` | `''` | Telegram chat/user id to message. | | `notify.ntfy.topic` | `''` | ntfy.sh topic to publish to. | @@ -166,6 +168,23 @@ config directory with `chmod 600` permissions; environment variables always win over both. Your account password is never stored anywhere — only the session token returned by login. +## Other operators (all teilauto regions, stadtmobil, …) + +The backend is multi-tenant: dozens of German carsharing operators share it, +distinguished by a provider id. The default configuration is teilauto +Mitteldeutschland (`118`); to use another operator, look up its id and save it: + +```bash +teilauto providers list # login-eligible operators (no login needed) +teilauto config set api.prov_id 19 # e.g. teilAuto Neckar-Alb (Tübingen) +teilauto auth login # log in against that operator +``` + +Two things to know: the login is your account email or customer number +(Kundennummer) — regional operators commonly use the Kundennummer — and after +changing `api.prov_id` (or `api.key`) you need a fresh `teilauto auth login`, +since the session token belongs to the operator it was issued for. + ## JSON output & scripting Every data-producing command takes `--json` for machine-readable output. @@ -188,14 +207,17 @@ Exit codes are stable and meant for scripting: ## How it works / API notes teilauto-cli talks to the Cantamen `casirest/v3` backend at -`https://de1.cantamen.de` — the same API the official `buchen.teilauto.net` -booking site uses, reverse-engineered by watching that site's network traffic. -Read-only endpoints (car listings, prices, availability) just need the public -`x-api-key` baked into that site's frontend JavaScript; it identifies the app, -not a user, so it's not treated as a secret here. Member actions (login, -creating/cancelling bookings) use a bearer-style token from `POST /tokens`, -scoped to provider id `118` (teilauto Mitteldeutschland). None of this is an -official, documented, or supported API — see Responsible use below. +`https://de1.cantamen.de` — the same multi-tenant API behind the official +`buchen.teilauto.net` booking site and the teilAuto / Carsharing Deutschland +Android apps, reverse-engineered from their traffic and app bundles. Every +request carries a public `x-api-key` that identifies the *app*, not a user, so +it's not treated as a secret here. The default is the Carsharing Deutschland +umbrella app's key, whose login-eligible operator set covers every teilauto +region and most other German Cantamen-based operators (override with the +`api.key` config). Member actions (login, creating/cancelling bookings) use a +session token from `POST /tokens`, scoped to the operator selected via +`api.prov_id`. None of this is an official, documented, or supported API — see +Responsible use below. ## Responsible use diff --git a/src/teilauto/api/client.py b/src/teilauto/api/client.py index fc4aa06..d7b6427 100644 --- a/src/teilauto/api/client.py +++ b/src/teilauto/api/client.py @@ -13,14 +13,17 @@ Car, Price, Proposal, + Provider, TokenInfo, Unavailability, cars_from_poi, ) -# Public app key baked into the buchen.teilauto.net frontend JS. -# It identifies the app (not a user) and only unlocks read-only data. -API_KEY = "6b4d4759-8fd1-b25a-c099-09c2af9c70ce" +# Public app key of the "Carsharing Deutschland" umbrella app (cantamen GmbH). +# It identifies the app (not a user); its login-eligible operator set is a +# superset covering every teilauto region plus most German Cantamen-based +# operators, so one key serves all users. Override via config `api.key`. +API_KEY = "ada76fb4-0561-40dd-50b4-2b780fb28a5f" BASE_URL = "https://de1.cantamen.de/casirest/v3" PROV_ID = "118" # teilauto/CityFlitzer Mitteldeutschland @@ -150,6 +153,18 @@ def price(self, bookee_id: str, start: datetime, end: datetime) -> Price: data = data[0] if data else {} return Price.from_api(data or {}) + def login_providers(self) -> list[Provider]: + """Operators whose customers may log in with the current app key. + + Deliberately scoped to action=CUSTOMER_LOGIN — the unfiltered + /providersettings list contains many operators that are bookable but + not login-eligible, and picking one of those makes login impossible. + """ + data = self._request( + "GET", "/providers", params={"expand": "remark", "action": "CUSTOMER_LOGIN"} + ) + return [Provider.from_api(item) for item in data or []] + # -- member actions ------------------------------------------------------ def login(self, login: str, credential: str, prov_id: str = PROV_ID) -> TokenInfo: diff --git a/src/teilauto/api/models.py b/src/teilauto/api/models.py index fa07cca..dd3fc8a 100644 --- a/src/teilauto/api/models.py +++ b/src/teilauto/api/models.py @@ -122,6 +122,21 @@ def from_api(cls, data: dict) -> Unavailability: return cls(start=time_range.get("start"), end=time_range.get("end")) +@dataclass(frozen=True) +class Provider: + id: str + abbrev: str | None + name: str | None + + @classmethod + def from_api(cls, data: dict) -> Provider: + return cls( + id=str(data.get("id")), + abbrev=data.get("abbrev"), + name=data.get("name"), + ) + + @dataclass(frozen=True) class TokenInfo: id: str diff --git a/src/teilauto/cli/__init__.py b/src/teilauto/cli/__init__.py index b525c5f..bf9573a 100644 --- a/src/teilauto/cli/__init__.py +++ b/src/teilauto/cli/__init__.py @@ -42,12 +42,18 @@ def settings(self) -> Settings: def client(self) -> CantamenClient: token = self.token - return CantamenClient(token_id=token.id if token else None, verbose=self.verbose) + return CantamenClient( + token_id=token.id if token else None, + api_key=self.settings.api_key, + verbose=self.verbose, + ) def require_auth_client(self) -> CantamenClient: if self.token is None: raise AuthRequiredError() - return CantamenClient(token_id=self.token.id, verbose=self.verbose) + return CantamenClient( + token_id=self.token.id, api_key=self.settings.api_key, verbose=self.verbose + ) @click.group(context_settings=CONTEXT_SETTINGS) @@ -71,6 +77,7 @@ def cli(ctx: click.Context, verbose: bool, quiet: bool) -> None: from teilauto.cli.config_cmd import config_group # noqa: E402 from teilauto.cli.notify_cmd import notify # noqa: E402 from teilauto.cli.price import price # noqa: E402 +from teilauto.cli.providers import providers # noqa: E402 from teilauto.cli.watch import watch_cmd # noqa: E402 cli.add_command(auth) @@ -80,6 +87,7 @@ def cli(ctx: click.Context, verbose: bool, quiet: bool) -> None: cli.add_command(config_group) cli.add_command(notify) cli.add_command(price) +cli.add_command(providers) cli.add_command(watch_cmd) diff --git a/src/teilauto/cli/auth.py b/src/teilauto/cli/auth.py index d477fa6..efe145a 100644 --- a/src/teilauto/cli/auth.py +++ b/src/teilauto/cli/auth.py @@ -16,7 +16,12 @@ def auth() -> None: @auth.command() -@click.option("--email", prompt="Email", help="Your teilauto account email.") +@click.option( + "--email", + "login_name", + prompt="Email or customer number", + help="Your account email or customer number (Kundennummer).", +) @click.option( "--password", prompt="Password", @@ -24,10 +29,10 @@ def auth() -> None: help="Account password (never stored — only the session token is kept).", ) @click.pass_obj -def login(app, email: str, password: str) -> None: +def login(app, login_name: str, password: str) -> None: """Authenticate once; the returned token is valid for about 90 days.""" - with CantamenClient(verbose=app.verbose) as client: - info = client.login(email, password, prov_id=app.settings.prov_id) + with CantamenClient(api_key=app.settings.api_key, verbose=app.verbose) as client: + info = client.login(login_name, password, prov_id=app.settings.prov_id) tokenstore.save_token(StoredToken(id=info.id, expiry=info.expiry, customer_id=info.customer_id)) console.print(f"[green]✓ Logged in.[/green] Token valid until {local_time(info.expiry)}.") diff --git a/src/teilauto/cli/providers.py b/src/teilauto/cli/providers.py new file mode 100644 index 0000000..c3ce71d --- /dev/null +++ b/src/teilauto/cli/providers.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import rich_click as click + +from teilauto.cli.options import json_option +from teilauto.output.json_out import print_json +from teilauto.output.render import console, providers_table + + +@click.group() +def providers() -> None: + """Discover which carsharing operators you can log in to.""" + + +@providers.command("list") +@json_option +@click.pass_obj +def list_providers(app, as_json) -> None: + """Show login-eligible operators (no login required). + + Pick yours and save it with `teilauto config set api.prov_id `, + then run `teilauto auth login`. + """ + with app.client() as client: + items = client.login_providers() + if as_json: + print_json(items) + return + if not items: + console.print("No login-eligible operators for the configured api.key.") + return + console.print(providers_table(items, current=app.settings.prov_id)) diff --git a/src/teilauto/core/config.py b/src/teilauto/core/config.py index ccf3d5c..c79a8d9 100644 --- a/src/teilauto/core/config.py +++ b/src/teilauto/core/config.py @@ -18,7 +18,7 @@ DEFAULTS: dict = { "location": {"lat": 51.3455, "lng": 12.3821, "radius_m": 500}, "defaults": {"min_fuel": 0.0, "window_min": 30, "interval_sec": 120}, - "api": {"prov_id": "118"}, + "api": {"prov_id": "118", "key": "ada76fb4-0561-40dd-50b4-2b780fb28a5f"}, "notify": { "channels": [], "telegram": {"chat_id": ""}, @@ -175,6 +175,7 @@ class Settings: window_min: int interval_sec: int prov_id: str + api_key: str channels: tuple[str, ...] tg_chat_id: str ntfy_topic: str @@ -196,6 +197,7 @@ def load_settings(path: Path | None = None) -> Settings: "defaults.interval_sec", get_value(merged, "defaults.interval_sec"), int ), prov_id=_cast("api.prov_id", get_value(merged, "api.prov_id"), str), + api_key=_cast("api.key", get_value(merged, "api.key"), str), channels=_cast("notify.channels", channels or (), tuple), tg_chat_id=_cast( "notify.telegram.chat_id", get_value(merged, "notify.telegram.chat_id") or "", str diff --git a/src/teilauto/output/render.py b/src/teilauto/output/render.py index 38bc1aa..b6d98ec 100644 --- a/src/teilauto/output/render.py +++ b/src/teilauto/output/render.py @@ -7,7 +7,7 @@ from rich.table import Table from rich.text import Text -from teilauto.api.models import Booking, Car, Unavailability +from teilauto.api.models import Booking, Car, Provider, Unavailability from teilauto.services.availability import CarAvailability console = Console() @@ -83,6 +83,20 @@ def unavailability_table(items: list[Unavailability]) -> Table: return table +def providers_table(providers: list[Provider], current: str | None = None) -> Table: + table = Table( + title="Operators", + header_style="bold", + caption="Set yours with `teilauto config set api.prov_id `, then log in again.", + ) + for column in ("ID", "Abbrev", "Name", "Selected"): + table.add_column(column) + for provider in providers: + selected = Text("✓", style="green") if provider.id == current else Text("") + table.add_row(provider.id, provider.abbrev or "—", provider.name or "—", selected) + return table + + def bookings_table(bookings: list[Booking]) -> Table: table = Table(title="Bookings", header_style="bold") for column in ("ID", "Car", "From", "Until", "State"): diff --git a/tests/conftest.py b/tests/conftest.py index 334bb21..81cd5d7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -41,6 +41,11 @@ def token_json(): return load_fixture("token.json") +@pytest.fixture +def providers_json(): + return load_fixture("providers.json") + + @pytest.fixture def unavailabilities_json(): return load_fixture("unavailabilities.json") diff --git a/tests/fixtures/providers.json b/tests/fixtures/providers.json new file mode 100644 index 0000000..2d37fb6 --- /dev/null +++ b/tests/fixtures/providers.json @@ -0,0 +1,6 @@ +[ + {"id": "1", "abbrev": "KA", "name": "stadtmobil karlsruhe", "country": "DE"}, + {"id": "19", "abbrev": "TÜ", "name": "teilAuto Neckar-Alb", "country": "DE"}, + {"id": "118", "abbrev": "TAM", "name": "teilAuto & cityflitzer Mitteldeutschland", + "publicId": "2016", "country": "DE", "timeZone": "Europe/Berlin"} +] diff --git a/tests/test_cli_providers.py b/tests/test_cli_providers.py new file mode 100644 index 0000000..96d2d9a --- /dev/null +++ b/tests/test_cli_providers.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import json + +import httpx + +from teilauto.api.client import API_KEY, BASE_URL +from teilauto.cli import cli + + +def test_providers_list_no_login_needed(runner, isolated_config, respx_mock, providers_json): + route = respx_mock.get(f"{BASE_URL}/providers").mock( + return_value=httpx.Response(200, json=providers_json) + ) + result = runner.invoke(cli, ["providers", "list"]) + assert result.exit_code == 0, result.output + assert "teilAuto Neckar-Alb" in result.output + params = route.calls.last.request.url.params + assert params["action"] == "CUSTOMER_LOGIN" + assert params["expand"] == "remark" + + +def test_providers_list_marks_configured_provider( + runner, isolated_config, respx_mock, providers_json +): + respx_mock.get(f"{BASE_URL}/providers").mock( + return_value=httpx.Response(200, json=providers_json) + ) + result = runner.invoke(cli, ["providers", "list"]) + assert result.exit_code == 0, result.output + marked = [line for line in result.output.splitlines() if "✓" in line] + assert len(marked) == 1 + assert "118" in marked[0] # the default api.prov_id + + +def test_providers_list_json(runner, isolated_config, respx_mock, providers_json): + respx_mock.get(f"{BASE_URL}/providers").mock( + return_value=httpx.Response(200, json=providers_json) + ) + result = runner.invoke(cli, ["providers", "list", "--json"]) + assert result.exit_code == 0, result.output + ids = [p["id"] for p in json.loads(result.output)] + assert ids == ["1", "19", "118"] + + +def test_providers_list_uses_default_umbrella_key( + runner, isolated_config, respx_mock, providers_json +): + route = respx_mock.get(f"{BASE_URL}/providers").mock( + return_value=httpx.Response(200, json=providers_json) + ) + result = runner.invoke(cli, ["providers", "list"]) + assert result.exit_code == 0, result.output + assert route.calls.last.request.headers["x-api-key"] == API_KEY + + +def test_providers_list_honors_api_key_config( + runner, isolated_config, respx_mock, providers_json, monkeypatch +): + monkeypatch.setenv("TEILAUTO_API_KEY", "custom-key-123") + route = respx_mock.get(f"{BASE_URL}/providers").mock( + return_value=httpx.Response(200, json=providers_json) + ) + result = runner.invoke(cli, ["providers", "list"]) + assert result.exit_code == 0, result.output + assert route.calls.last.request.headers["x-api-key"] == "custom-key-123" + + +def test_providers_list_empty(runner, isolated_config, respx_mock): + respx_mock.get(f"{BASE_URL}/providers").mock(return_value=httpx.Response(200, json=[])) + result = runner.invoke(cli, ["providers", "list"]) + assert result.exit_code == 0, result.output + assert "No login-eligible operators" in result.output diff --git a/tests/test_cli_root.py b/tests/test_cli_root.py index b6c67f8..6cc5897 100644 --- a/tests/test_cli_root.py +++ b/tests/test_cli_root.py @@ -16,6 +16,7 @@ window_min=30, interval_sec=120, prov_id="118", + api_key="test-key", channels=(), tg_chat_id="", ntfy_topic="", diff --git a/tests/test_client.py b/tests/test_client.py index b72a2d9..5ed8fc6 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -119,6 +119,27 @@ def test_exhausted_retries_raise_api_error(respx_mock): client.booking_proposals(lat=1, lng=2, radius_m=3, start=START, end=END) +def test_login_providers_scoped_to_customer_login(respx_mock, providers_json): + route = respx_mock.get(f"{BASE_URL}/providers").mock( + return_value=httpx.Response(200, json=providers_json) + ) + with CantamenClient() as client: + providers = client.login_providers() + assert [p.id for p in providers] == ["1", "19", "118"] + assert providers[2].abbrev == "TAM" + params = route.calls.last.request.url.params + assert params["action"] == "CUSTOMER_LOGIN" + + +def test_custom_api_key_header(respx_mock, providers_json): + route = respx_mock.get(f"{BASE_URL}/providers").mock( + return_value=httpx.Response(200, json=providers_json) + ) + with CantamenClient(api_key="my-key") as client: + client.login_providers() + assert route.calls.last.request.headers["x-api-key"] == "my-key" + + def test_cancel_booking_deletes(respx_mock): route = respx_mock.delete(f"{BASE_URL}/bookings/b-1").mock(return_value=httpx.Response(204)) with CantamenClient(token_id="t") as client: diff --git a/tests/test_config.py b/tests/test_config.py index 40b12bc..a5a214f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -17,6 +17,13 @@ def test_defaults_when_no_file(config_home): assert s.interval_sec == 120 assert s.channels == () assert s.ntfy_server == "https://ntfy.sh" + assert s.prov_id == "118" + assert s.api_key == "ada76fb4-0561-40dd-50b4-2b780fb28a5f" + + +def test_env_override_api_key(config_home, monkeypatch): + monkeypatch.setenv("TEILAUTO_API_KEY", "another-key") + assert cfg.load_settings().api_key == "another-key" def test_file_overrides_defaults(config_home): diff --git a/tests/test_notify.py b/tests/test_notify.py index 1892979..2b908e2 100644 --- a/tests/test_notify.py +++ b/tests/test_notify.py @@ -22,6 +22,7 @@ def _settings(**kw) -> Settings: window_min=30, interval_sec=120, prov_id="118", + api_key="test-key", channels=(), tg_chat_id="", ntfy_topic="",