diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..3c49d85 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,18 @@ +{ + "permissions": { + "allow": [ + "Bash(python3 *)", + "Bash(find *)", + "Bash(make typecheck *)", + "Bash(make test *)", + "Bash(make check *)", + "Bash(.venv/bin/ruff check *)", + "Bash(.venv/bin/ruff format *)", + "Bash(.venv/bin/pytest tests/ -q)", + "Bash(python *)", + "Bash(git stash *)", + "Bash(poetry run *)", + "Bash(make lint *)" + ] + } +} diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 1519028..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,53 +0,0 @@ -# Repository Guidelines - -## Project Structure & Module Organization - -The package lives in `avito/`. The repository already contains a domain-oriented SDK layout: - -- `avito/client/` contains the top-level SDK entry point. -- `avito/core/` contains shared transport, retry, exception, pagination, and shared type abstractions. -- `avito/auth/` contains auth settings, token clients, and auth provider logic. -- `avito//` packages such as `accounts/`, `ads/`, `messenger/`, `orders/`, `jobs/`, `cpa/`, `autoteka/`, `realty/`, `ratings/`, `tariffs/`, and `promotion/` contain domain models, mappers, enums, and domain clients. -- `docs/` stores Avito API reference payloads and examples in JSON/Markdown. -- `tests/` contains regression and release-gate coverage. - -Keep new code inside the `avito/` package and group it by API area (`ads/`, `messenger/`, `orders/`) as described in `STYLEGUIDE.md`. Avoid adding raw integration logic to `__init__.py`. - -## Build, Test, and Development Commands - -- `poetry install` installs runtime and developer dependencies. -- `poetry run python -m avito` runs the package entry point if you add CLI behavior. -- `make check` runs the repository quality gate: tests, mypy, ruff, and build. -- `make fmt` formats the code with Ruff. -- `poetry build` builds the wheel and source distribution. -- `make release` publishes the package after the quality gate passes. - -There is no dedicated local server or demo app in this repository. Treat `make check` as the minimum release validation step. - -## Coding Style & Naming Conventions - -Target Python is `3.14` and dependency management is handled by Poetry. Follow `STYLEGUIDE.md` for architectural rules: - -- Use 4-space indentation and full type annotations on public code. -- Prefer clear domain models over loose `dict` payloads. -- Keep HTTP, auth, mapping, and settings concerns separated. -- Use `snake_case` for functions/modules, `PascalCase` for classes, and uppercase names for environment variables such as `AVITO_CLIENT_ID`. - -`STYLEGUIDE.md` prefers dataclasses for domain models; use `pydantic-settings` only at configuration boundaries. - -## Testing Guidelines - -Automated tests are already set up under `tests/`. Add new tests with `test_*.py` names and mirror the package structure when possible, for example `tests/client/test_client.py`. - -When adding behavior, include at least one regression test and run `make check`. For API-facing changes, cover auth failures, retry behavior, and response mapping. - -## Commit & Pull Request Guidelines - -Recent commits use short, imperative summaries in Russian, for example `Подготовка к разработке`. Keep commit subjects concise, specific, and focused on one change. - -Pull requests should include: - -- a short description of the behavioral change; -- linked issue or task when available; -- notes about new environment variables, endpoints, or publishing impact; -- sample request/response snippets when API behavior changes. diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 0000000..681311e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 07de466..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,28 +0,0 @@ -# Changelog - -Все заметные изменения SDK фиксируются в этом файле. - -Формат записей: - -- `Добавлено` — новые публичные методы, модели и доменные блоки. -- `Изменено` — совместимые изменения поведения и документации. -- `Исправлено` — багфиксы, корректировки mapping и transport. -- `Удалено` — убранные или больше не поддерживаемые части API. - -## [Unreleased] - -### Добавлено - -- Политика релизного процесса в `docs/release.md`. -- Проверки качества `pytest`, `mypy`, `ruff` и `poetry build` в `README.md`. -- Безопасный debug hook `AvitoClient.debug_info()` для диагностики transport-конфигурации. -- GitHub Actions workflows `CI` и `Release` с обязательной валидацией проекта на Python `3.14`. -- Автоматическая публикация релиза по тегу `v*` с проверкой соответствия версии из `pyproject.toml`. -- Единый quality gate `make check` для тестов, типизации, линтинга и сборки. - -### Изменено - -- `README.md` приведён к объектному API SDK и дополнен сценарными примерами по доменам. -- `pyproject.toml` дополнен strict-конфигурацией `mypy`, правилами `ruff` и современным build backend `poetry-core`. -- `Makefile` разделён на `fmt`, `lint`, `typecheck`, `test`, `build` и `check`, чтобы release не зависел от автоформатирования. -- `AGENTS.md` синхронизирован с реальной структурой SDK, наличием тестов и Python `3.14` quality gate. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..82c154e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,86 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +make test # run all tests +make typecheck # mypy strict check on avito/ +make lint # ruff check +make fmt # ruff format +make check # test → typecheck → lint → build (full gate) +make build # poetry build + +# single test +poetry run pytest tests/test_facade.py::test_name +``` + +## Architecture + +**Entry point**: `avito/client.py` — `AvitoClient` is the single public facade. It exposes factory methods (`account()`, `ad()`, `chat()`, etc.) that return domain objects. + +**Layers** (strict separation, no mixing): + +| Layer | Location | Responsibility | +|---|---|---| +| `AvitoClient` | `avito/client.py` | Public facade, factory methods | +| `SectionClient` | `avito//client.py` | HTTP calls for one API section | +| `Transport` | `avito/core/transport.py` | httpx, retries, error mapping, token injection | +| `AuthProvider` | `avito/auth/provider.py` | Token cache, refresh, 401 handling | +| `Mapper` | `avito//mappers.py` | JSON → typed dataclass | +| Config | `avito/config.py`, `avito/auth/settings.py` | `AvitoSettings`, `AuthSettings` | + +**Domain packages** follow a uniform structure: `__init__.py`, `domain.py` (DomainObject subclass), `client.py` (SectionClient), `models.py` (frozen dataclasses), `mappers.py`, optional `enums.py`. + +**Public models** are `@dataclass(slots=True, frozen=True)`, inherit `SerializableModel` (provides `to_dict()` / `model_dump()`), and never expose transport fields. + +**Exceptions** live in `avito/core/exceptions.py`. `AvitoError` is the base. HTTP codes map to specific types: 401→`AuthenticationError`, 403→`AuthorizationError`, 429→`RateLimitError`, etc. These two are siblings, not parent/child. + +**Pagination**: `PaginatedList[T]` is lazy. First page loads on creation; subsequent pages load on iteration. `materialize()` loads all pages. + +**Testing**: `tests/fake_transport.py` provides `FakeTransport` — inject it instead of real HTTP. Tests are Arrange/Act/Assert, one scenario per test. Test names describe behavior, not the method under test. + +## API coverage and inventory + +`docs/` contains Swagger/OpenAPI specs (23 documents, 204 operations) — the authoritative source of truth for all API contracts. + +`docs/inventory.md` is the canonical mapping of every API operation to its SDK domain object and public method. Before implementing any new method, check the inventory to find: +- which `пакет_sdk` and `доменный_объект` it belongs to +- the expected `публичный_метод_sdk`, request/response type names +- whether the operation is deprecated (`deprecated: да` → wrap in a legacy domain object) + +**When adding a new API method**: add it to the `## Операции` table in `docs/inventory.md` (between the `operations-table:start/end` markers) following the existing format. + +All 204 operations from the specs must be covered. A missing method is a defect. + +## STYLEGUIDE.md — strict compliance is mandatory + +`STYLEGUIDE.md` is a normative document. All code changes **must** comply with it. When there is a conflict between any consideration and the STYLEGUIDE, the STYLEGUIDE takes priority. + +The most critical prohibitions that must never be violated: + +- Mixing layers: transport/auth/parsing/domain logic in one class. +- Returning `dict` or `Any` from public methods. +- Using `resource_id` instead of concrete names (`item_id`, `order_id`). +- Annotating `list[T]` where `PaginatedList[T]` is returned at runtime. +- Making `AuthenticationError` a subclass of `AuthorizationError` (or vice versa). +- Writing error messages in mixed languages (Russian only). +- Injecting methods via `setattr`/`globals()` at runtime. +- Duplicating behavior through two different public methods without deprecation. +- Leaking internal-layer request-DTOs into public signatures. +- Adding dead code: unused imports, type aliases, TypeVars. + +## Key conventions (from STYLEGUIDE.md) + +- All public methods return typed SDK models, never raw `dict`. +- Field names are concrete: `item_id`, `user_id` — never `resource_id`. +- Public method arguments are primitives or domain models — internal request-DTOs must not leak out. +- Write-operations that accept `dry_run: bool = False` must build the same payload in both modes; with `dry_run=True` transport must not be called. +- `Any` is forbidden except at JSON boundary layers (with a local comment). +- Error messages are written in Russian only — no mixed languages. +- No dynamic method injection (`setattr`, `globals()` patching). +- `PaginatedList[T]` annotation must match runtime — never annotate as `list[T]` if you return `PaginatedList[T]`. +- `AuthenticationError` (401) and `AuthorizationError` (403) must not be in an inheritance relation. +- Dead code (unused imports, aliases, TypeVars) must be removed. +- Request-objects of the internal layer must not appear in public domain-method signatures. diff --git a/README.md b/README.md index f140311..9ec1589 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ - дать единый вход в доменные сценарии вида `avito.ad(...).get()` и `avito.chat(...).send_message(...)`; - покрыть все swagger-документы из каталога [docs](docs). +Каталог [docs](docs) рассматривается как upstream API contract. Эти файлы не редактируются вручную при развитии SDK: публичные модели, мапперы и тесты должны подстраиваться под documented shape из `docs/*`. + ## Установка ```bash @@ -27,6 +29,8 @@ pip install avito-py ## Быстрый старт +Получение ключей - https://www.avito.ru/professionals/api + ```python from avito import AvitoClient @@ -40,12 +44,51 @@ print(ad.title) По умолчанию настройки читаются из переменных окружения с префиксом `AVITO_`. -Базовые переменные: +Официальный способ конфигурации SDK: + +```python +from avito import AuthSettings, AvitoClient, AvitoSettings + +settings = AvitoSettings( + base_url="https://api.avito.ru", + user_id=123, + auth=AuthSettings( + client_id="client-id", + client_secret="client-secret", + ), +) +client = AvitoClient(settings) +``` + +Инициализация из окружения и `.env`: + +```python +from avito import AvitoClient, AvitoSettings + +settings = AvitoSettings.from_env() +client = AvitoClient.from_env() +``` + +Поддерживаемые env-переменные и alias-имена: -- `AVITO_AUTH__CLIENT_ID` -- `AVITO_AUTH__CLIENT_SECRET` -- `AVITO_AUTH__REFRESH_TOKEN` - `AVITO_BASE_URL` +- `AVITO_USER_ID` +- `AVITO_AUTH__CLIENT_ID`, alias: `AVITO_CLIENT_ID` +- `AVITO_AUTH__CLIENT_SECRET`, alias: `AVITO_CLIENT_SECRET` +- `AVITO_AUTH__REFRESH_TOKEN`, alias: `AVITO_REFRESH_TOKEN` +- `AVITO_AUTH__SCOPE`, alias: `AVITO_SCOPE` +- `AVITO_AUTH__TOKEN_URL`, alias: `AVITO_TOKEN_URL` +- `AVITO_AUTH__ALTERNATE_TOKEN_URL`, alias: `AVITO_ALTERNATE_TOKEN_URL` +- `AVITO_AUTH__AUTOTEKA_TOKEN_URL`, alias: `AVITO_AUTOTEKA_TOKEN_URL` +- `AVITO_AUTH__AUTOTEKA_CLIENT_ID`, alias: `AVITO_AUTOTEKA_CLIENT_ID` +- `AVITO_AUTH__AUTOTEKA_CLIENT_SECRET`, alias: `AVITO_AUTOTEKA_CLIENT_SECRET` +- `AVITO_AUTH__AUTOTEKA_SCOPE`, alias: `AVITO_AUTOTEKA_SCOPE` + +Правила resolution: + +- значения из process environment имеют приоритет над `.env`; +- `AvitoSettings.from_env()` и `AvitoClient.from_env()` детерминированно читают `.env` из текущей рабочей директории или из переданного `env_file`; +- при отсутствии `client_id` или `client_secret` SDK завершает инициализацию с typed-ошибкой `ConfigurationError`. ## Примеры по доменам @@ -75,10 +118,23 @@ with AvitoClient() as avito: ```python from avito import AvitoClient +from avito.messenger import UploadImageFile with AvitoClient() as avito: chats = avito.chat(user_id=123).list() - message = avito.chat(chat_id="chat-1", user_id=123).send_message(message="Здравствуйте") + message = avito.chat_message(chat_id="chat-1", user_id=123).send_message( + message="Здравствуйте" + ) + uploaded = avito.chat_media(user_id=123).upload_images( + files=[ + UploadImageFile( + field_name="image", + filename="photo.jpg", + content=b"...", + content_type="image/jpeg", + ) + ] + ) subscriptions = avito.chat_webhook().list() ``` @@ -86,32 +142,56 @@ with AvitoClient() as avito: ```python from avito import AvitoClient +from datetime import datetime with AvitoClient() as avito: services = avito.promotion_order().list_orders() forecast = avito.bbip_promotion(item_id=42).get_forecasts(items=[]) + budget = avito.autostrategy_campaign().create_budget( + campaign_type="AS", + start_time="2026-04-20T00:00:00Z", + finish_time="2026-04-27T00:00:00Z", + items=[42, 43], + ) campaign = avito.autostrategy_campaign(campaign_id=15).get() + campaigns = avito.autostrategy_campaign().list( + limit=50, + status_id=[1, 2], + order_by=[("startTime", "asc")], + updated_from=datetime(2026, 4, 1), + updated_to=datetime(2026, 4, 30), + ) + +print(budget.calc_id) +print(campaign.campaign.title if campaign.campaign else None) +print(campaigns.total_count) ``` ### Заказы и доставка ```python from avito import AvitoClient +from avito.orders import OrderLabelsRequest, StockInfoRequest with AvitoClient() as avito: - order = avito.order(order_id=100500).get() - label = avito.order_label(task_id="task-1").download() - sandbox = avito.sandbox_delivery(task_id="task-1").get() + orders = avito.order().list() + label_task = avito.order_label().create(request=OrderLabelsRequest(order_ids=["100500"])) + label_pdf = avito.order_label(task_id=label_task.task_id).download() + stock_info = avito.stock().get(request=StockInfoRequest(item_ids=[100500])) ``` ### Работа ```python from avito import AvitoClient +from avito.jobs import ApplicationIdsQuery, ResumeSearchQuery with AvitoClient() as avito: vacancies = avito.vacancy().list() - applications = avito.application().list() + applications = avito.application().list( + query=ApplicationIdsQuery(updated_at_from="2026-04-18") + ) + resumes = avito.resume().list(query=ResumeSearchQuery(query="оператор")) webhooks = avito.job_webhook().list() ``` @@ -119,30 +199,80 @@ with AvitoClient() as avito: ```python from avito import AvitoClient +from avito.cpa import CpaCallsByTimeRequest with AvitoClient() as avito: - calls = avito.cpa_call().list() - records = avito.call_tracking_call(call_id=10).download() + calls = avito.cpa_call().list( + request=CpaCallsByTimeRequest( + date_time_from="2026-04-18T00:00:00Z", + date_time_to="2026-04-19T00:00:00Z", + ) + ) + calltracking = avito.call_tracking_call(10).get() + records = avito.call_tracking_call(10).download() +``` + +## Пагинация + +Публичные list-операции, которые поддерживают lazy pagination, возвращают обычные SDK-результаты, а поле `items` в них ведет себя как list-like коллекция `PaginatedList`. + +Текущий стабильный контракт: + +- первая страница загружается сразу, остальные страницы подгружаются только при чтении элементов за ее пределами; +- доступ к уже загруженным элементам не делает повторных запросов; +- частичная итерация и slicing загружают только необходимые страницы; +- явная полная материализация выполняется через `items.materialize()`. + +Пример: + +```python +from avito import AvitoClient + +with AvitoClient() as avito: + result = avito.ad(user_id=123).list(status="active", limit=50) + + first = result.items[0] + preview = result.items[:10] + all_items = result.items.materialize() ``` ### Автотека ```python from avito import AvitoClient +from avito.autoteka import CatalogResolveRequest, PreviewReportRequest, VinRequest with AvitoClient() as avito: - preview = avito.autoteka_vehicle().create_preview_by_vin(payload={"vin": "XTA00000000000000"}) - report = avito.autoteka_report(report_id=preview.preview_id).get_report() + catalog = avito.autoteka_vehicle().resolve_catalog( + request=CatalogResolveRequest(brand_id=1) + ) + preview = avito.autoteka_vehicle().create_preview_by_vin( + request=VinRequest(vin="XTA00000000000000") + ) + report = avito.autoteka_report().create_report( + request=PreviewReportRequest(preview_id=int(preview.preview_id or 0)) + ) + reports = avito.autoteka_report().list_reports() ``` ### Недвижимость, отзывы и тарифы ```python from avito import AvitoClient +from avito.realty import RealtyBookingsUpdateRequest, RealtyPricePeriod, RealtyPricesUpdateRequest with AvitoClient() as avito: - bookings = avito.realty_booking().list() - reviews = avito.review().list_reviews_v1() + booking = avito.realty_booking(20, user_id=10) + booking.update_bookings_info( + request=RealtyBookingsUpdateRequest(blocked_dates=["2026-05-01"]) + ) + bookings = booking.list_realty_bookings(date_start="2026-05-01", date_end="2026-05-05") + avito.realty_pricing(20, user_id=10).update_realty_prices( + request=RealtyPricesUpdateRequest( + periods=[RealtyPricePeriod(date_from="2026-05-01", price=5000)] + ) + ) + reviews = avito.review().list() tariff = avito.tariff().get_tariff_info() ``` @@ -157,11 +287,12 @@ client = AvitoClient() info = client.debug_info() print(info.base_url) +print(info.user_id) print(info.retry_max_attempts) client.close() ``` -`debug_info()` подходит для smoke-проверок окружения и диагностики конфигурации. Access token, `client_secret` и `Authorization` header в этот снимок не попадают. +`debug_info()` подходит для smoke-проверок окружения и диагностики конфигурации. Стабильный контракт включает `base_url`, `user_id`, флаг `requires_auth`, таймауты и retry-настройки. Access token, `client_secret` и `Authorization` header в этот снимок не попадают. ## Проверки качества @@ -201,7 +332,5 @@ git push origin v1.0.2 ## Документация репозитория -- [STYLEGUIDE.md](STYLEGUIDE.md) — нормативные архитектурные правила. -- [TODO.md](TODO.md) — этапы реализации и релизный gate. -- [docs/inventory.md](docs/inventory.md) — матрица соответствия swagger-операций и публичного API SDK. -- [docs/release.md](docs/release.md) — политика changelog и checklist релиза. +- [STYLEGUIDE.md](STYLEGUIDE.md) — нормативные архитектурные правила +- [docs/inventory.md](docs/inventory.md) — матрица соответствия swagger-операций и публичного API SDK diff --git a/STYLEGUIDE.md b/STYLEGUIDE.md index 76037ec..7ec2b11 100644 --- a/STYLEGUIDE.md +++ b/STYLEGUIDE.md @@ -1,30 +1,37 @@ # STYLEGUIDE -## Цель +## Purpose -Этот документ задает единый стиль разработки Python SDK для Avito API. -Цель библиотеки: +This document defines the unified development style for the Avito API Python SDK. +The library's goals: -- дать понятный и прозрачный публичный API; -- скрыть технические детали авторизации, ретраев и обогащения данных; -- возвращать строго типизированные объекты собственных классов; -- сохранить чистую пакетную архитектуру по разделам Avito API; -- обеспечить предсказуемое поведение при нестабильном соединении. +- provide a clear and transparent public API; +- hide the technical details of authorization, retries, and data enrichment; +- return strictly typed objects of its own classes; +- maintain a clean package architecture organized by Avito API sections; +- ensure predictable behavior under unstable network conditions. -Документ нормативный. Новые модули и рефакторинг существующего кода должны соответствовать этим правилам. +This document is normative. New modules and refactoring of existing code must comply with these rules. -## Базовые принципы +## Core Principles -- Код должен быть читаемым раньше, чем компактным. -- Публичный API библиотеки должен быть простым, внутренние детали должны быть инкапсулированы. -- Каждый слой отвечает только за свою задачу: transport, auth, API clients, domain models, mapping, errors. -- Внешний код не должен работать с сырыми `dict[str, Any]`, если можно вернуть типизированный объект. -- Исключения должны быть явными и доменными, без `assert False` для управления потоком. -- Любое сетевое взаимодействие считается потенциально нестабильным. +Principles are listed in descending priority order when they conflict. -## Целевая архитектура пакетов +- Code must be readable before it is compact. +- Explicit over implicit: every public contract is readable without knowledge of implementation details. +- Simple over complex: add abstraction only when there is no way around it. +- For each task there must be one obvious way — not two, not three. +- Errors must not pass silently: invalid state is detected as early as possible. +- The public API of the library must be simple; internal details must be encapsulated. +- Each layer is responsible for its own task only: transport, auth, API clients, domain models, mapping, errors. +- External code must not work with raw `dict[str, Any]` when a typed object can be returned instead. +- Exceptions must be explicit and domain-specific; no `assert False` for flow control. +- All network interaction is considered potentially unstable. +- Public SDK contracts are fixed explicitly and changed only deliberately. -Разделы Avito API оформляются пакетами. Рекомендуемая структура: +## Target Package Architecture + +Avito API sections are organized as packages. Recommended structure: ```text avito/ @@ -35,6 +42,7 @@ avito/ __init__.py models.py provider.py + settings.py core/ __init__.py transport.py @@ -42,12 +50,23 @@ avito/ exceptions.py types.py pagination.py + accounts/ + __init__.py + client.py + models.py + mappers.py ads/ __init__.py client.py models.py enums.py mappers.py + promotion/ + __init__.py + client.py + models.py + enums.py + mappers.py messenger/ __init__.py client.py @@ -62,62 +81,123 @@ avito/ mappers.py ``` -Правила: +Rules: -- `core/` содержит только общую инфраструктуру, без логики конкретного API-раздела. -- Каждый раздел API живет в отдельном пакете: `ads`, `messenger`, `orders`, `autoload` и т.д. -- В каждом разделе допускаются только модули, относящиеся к этому разделу. -- `avito/client.py` или `avito/__init__.py` содержит только высокоуровневую точку входа. +- `core/` contains only shared infrastructure, with no logic specific to any API section. +- Each API section lives in its own package: `ads`, `messenger`, `orders`, `autoload`, etc. +- Only modules belonging to that section are allowed inside each section package. +- `avito/client.py` and `avito/__init__.py` contain only the high-level entry point and public exports. -## Публичный API библиотеки +## Public API -Публичный API должен быть объектным и очевидным: +The public API must be object-oriented and obvious: ```python client = AvitoClient(settings) -ad = client.ads.get(ad_id) -messages = client.messenger.list_chats() -stats = client.analytics.get_item_stats(...) +profile = client.account().get_self() +listing = client.ad(item_id=42, user_id=123).get() +stats = client.ad_stats(user_id=123).get_item_stats(item_ids=[42]) +``` + +Rules: + +- Methods must reflect domain actions, not HTTP details. +- `headers`, `token refresh`, `raw request payload` must not be exposed in the public API unless there is an explicit need. +- Public methods return domain models, collections of domain models, or typed result objects. +- Raw API responses are acceptable only in internal layers or in explicitly designated low-level methods. + +### One Path Per Operation + +For each operation in the public API there must be exactly one obvious way to perform it. If two different objects do the same thing, that is a design error. + +- Duplicating behavior through different facades is forbidden: `ad().get_stats()` and `ad_stats().get_item_stats()` for the same dataset cannot coexist. +- If one method covers a specific case and another covers the general case, the specific one must be a wrapper around the general one, not an independent implementation. +- Type aliases (`Listing = AdItem`) without an explicit deprecation marker are forbidden: each public type must have one canonical name. + +### What Constitutes the Public SDK Contract + +The following are normatively part of the public contract: + +- the `avito` package and its exports `AvitoClient`, `AvitoSettings`, `AuthSettings`; +- resource factory methods on `AvitoClient`, e.g. `account()`, `ad()`, `ad_stats()`, `promotion_order()`; +- public models from `avito..models`; +- typed exceptions from `avito.core.exceptions`; +- the lazy pagination contract `PaginatedList`; +- stable serialization of public models via `to_dict()` and `model_dump()`; +- the safe diagnostic contract of `debug_info()`. + +The following are normatively not part of the public contract: + +- transport request/response shapes; +- internal mapper objects; +- `raw_payload`, transport-layer service dataclasses, and internal DTOs; +- the shape of the raw Avito API JSON response. + +Internal changes are acceptable as long as public signatures, returned models, serialization, and exception types remain stable. + +## Client Initialization + +The user must have a simple path to the first working call. + +Normatively supported ways to create a client (from simplest to most explicit): + +```python +# 1. From environment variables +with AvitoClient.from_env() as avito: + ... + +# 2. Explicit credentials — required shortcut +with AvitoClient(client_id="...", client_secret="...") as avito: + ... + +# 3. Full configuration via settings +settings = AvitoSettings(auth=AuthSettings(client_id="...", client_secret="...")) +with AvitoClient(settings) as avito: + ... ``` -Правила: +Rules: -- Методы должны отражать действие предметной области, а не детали HTTP. -- Нельзя выносить в публичный API детали `headers`, `token refresh`, `raw request payload`, если в этом нет явной необходимости. -- Публичные методы возвращают доменные модели, коллекции доменных моделей или типизированные result-объекты. -- Сырые ответы API допустимы только во внутренних слоях или в явно обозначенных low-level методах. +- `AvitoClient` must accept `client_id` and `client_secret` directly without requiring an intermediate `AuthSettings` object. +- `AvitoClient.from_env()` is the official factory method for initializing from the environment. +- The nested `AvitoSettings → AuthSettings` path is acceptable as an explicit option but must not be the only one. -## Классы и ответственность +## Classes and Responsibilities -Обязательное разделение: +Required separation: -- `AvitoClient` — корневой фасад SDK. -- `SectionClient` классы — клиенты конкретных API-разделов. -- `Transport` — выполнение HTTP-запросов. -- `AuthProvider` — получение и обновление токена. -- `Mapper` — преобразование JSON в доменные модели. -- `Settings`/`Config` — конфигурация SDK. +- `AvitoClient` — the root SDK facade. +- `SectionClient` classes — clients for specific API sections. +- `Transport` — HTTP request execution. +- `AuthProvider` — token acquisition and refresh. +- `Mapper` — JSON to domain model conversion. +- `Settings`/`Config` — SDK configuration. -Правила: +Rules: -- Один класс — одна явная зона ответственности. -- Классы не должны одновременно заниматься HTTP, авторизацией, логированием и преобразованием моделей. -- Запрещены "god object" классы с логикой всех API-разделов сразу. +- One class, one explicit area of responsibility. +- Classes must not simultaneously handle HTTP, authorization, logging, and model transformation. +- "God object" classes containing logic for all API sections are forbidden. -## Dataclass и модели +## Dataclasses and Models -Основной формат моделей SDK — `dataclass`. +The primary model format for the SDK is `dataclass`. -Правила: +Rules: -- Доменные сущности и объекты ответов описываются через `@dataclass(slots=True, frozen=True)` по умолчанию. -- Если модель должна быть изменяемой, это должно быть осознанным исключением и явно документироваться. -- Для списков использовать конкретные контейнеры: `list[Message]`, а не просто `list`. -- Для необязательных полей использовать `T | None`, а не неявные значения. -- Вложенные структуры тоже должны иметь собственные типизированные dataclass-модели. -- Не использовать `dict` как substitute для модели предметной области. +- Domain entities and response objects are described with `@dataclass(slots=True, frozen=True)` by default. +- If a model must be mutable, that must be a conscious exception and explicitly documented. +- Use concrete containers for lists: `list[Message]`, not just `list`. +- Use `T | None` for optional fields, not implicit defaults. +- Nested structures must also have their own typed dataclass models. +- Do not use `dict` as a substitute for a domain model. +- All public read/write methods return only normalized SDK models, not transport-layer objects. +- For stable public models, required and nullable fields must be explicitly defined. +- Each public model must provide uniform serialization via `to_dict()` and `model_dump()`. +- Serialization of public models must be JSON-compatible and recursive for nested SDK models. +- Transport/internal implementation fields are forbidden in public models. -Пример: +Example: ```python from dataclasses import dataclass @@ -132,35 +212,92 @@ class Message: created_at: datetime ``` -## Pydantic и валидация +## Domain Object Field Naming + +Field names must precisely reflect what they store, without generalizations. + +Rules: + +- The abstract name `resource_id` is forbidden in domain objects. Use a concrete field name instead: `item_id`, `user_id`, `report_id`, `order_id`, etc. +- If a domain object takes multiple identifiers, each is declared as an explicit field with a domain-specific name. +- Field names in public models must not reflect HTTP details or upstream API JSON field names. + +```python +# Correct +@dataclass(slots=True, frozen=True) +class Ad(DomainObject): + item_id: int | None = None + user_id: int | None = None + +# Wrong +@dataclass(slots=True, frozen=True) +class Ad(DomainObject): + resource_id: int | str | None = None # unclear what is stored + user_id: int | str | None = None +``` + +## Public Method Parameters + +A public method must not require the user to construct internal SDK objects. + +Rules: + +- Public method arguments must be primitive types (`int`, `str`, `bool`, `float`) or well-known domain result models (not request objects). +- Request-DTOs used inside section clients must not appear in public domain method signatures. +- If a method requires a complex input object, it must accept its fields directly as keyword-only arguments. + +```python +# Correct: primitives and keyword-only +def create_order(self, *, item_id: int, duration: int, price: int) -> PromotionActionResult: + ... + +# Wrong: internal request object leaks out +def create_order(self, *, items: list[BbipOrderItem]) -> PromotionActionResult: + ... +``` + +## Fail-Fast and State Validation + +Invalid object state must be detected as early as possible. + +Rules: + +- If a domain object cannot perform any operation without a specific identifier, that identifier must be validated at object creation time, not at the first method call. +- A factory method that creates an object in a knowingly incomplete state must return an object with a restricted interface (only the methods available without the ID), not an object with methods that fail at runtime. +- A configuration error (`ConfigurationError`) must be raised before the first HTTP request. +- Dates passed as parameters must accept `datetime` or a validated string format — a bare `str` without validation is not acceptable when the format matters. + +## Pydantic and Validation -Для этого проекта `dataclass` — стандарт представления доменных объектов. `pydantic` не должен быть базовым строительным блоком всей модели SDK. +For this project `dataclass` is the standard for representing domain objects. `pydantic` must not be the foundational building block of the entire SDK model. -Допустимое использование `pydantic`: +Acceptable uses of `pydantic`: -- чтение конфигурации из environment; -- валидация сложного внешнего payload на границе системы, если это действительно упрощает код; -- прототипирование до появления финальной dataclass-модели. +- reading configuration from the environment; +- validating complex external payloads at the system boundary, if it genuinely simplifies the code; +- prototyping before the final dataclass model exists. -Недопустимое использование: +Unacceptable uses: -- смешивать `pydantic.BaseModel` и `dataclass` без четкого слоя ответственности; -- возвращать `BaseModel` как основной публичный формат SDK, если доменная dataclass уже существует. +- mixing `pydantic.BaseModel` and `dataclass` without a clear layer of responsibility; +- returning `BaseModel` as the primary public SDK format when a domain dataclass already exists. -## Типизация и mypy +## Typing and mypy -Строгая типизация обязательна. +Strict typing is mandatory. -Правила: +Rules: -- Все функции, методы, атрибуты классов и возвращаемые значения должны быть аннотированы. -- `Any` запрещен, кроме узких boundary-layer мест с локальным объяснением. -- Использовать `mypy` в строгом режиме или максимально близком к нему. -- Использовать `Protocol`, `TypeAlias`, `TypedDict` для границ, где dataclass еще не применим. -- JSON от внешнего API сначала трактуется как boundary-тип, затем маппится в dataclass. -- Не возвращать объединения слишком широких типов вроде `dict | list | str | None`. +- All functions, methods, class attributes, and return values must be annotated. +- `Any` is forbidden except in narrow boundary-layer locations with a local explanation. +- Use `mypy` in strict mode or as close to it as possible. +- Use `Protocol`, `TypeAlias`, `TypedDict` at boundaries where a dataclass is not yet applicable. +- JSON from an external API is first treated as a boundary type, then mapped to a dataclass. +- Do not return overly wide type unions such as `dict | list | str | None`. +- The return type annotation must precisely match the type of the value at runtime. If a method returns `PaginatedList`, the annotation must contain `PaginatedList`, not `list`. +- Dead code is not allowed: unused `TypeVar`s, imports, and aliases must be removed. -Минимальный целевой профиль `mypy`: +Minimum target `mypy` profile: ```toml [tool.mypy] @@ -173,184 +310,455 @@ disallow_any_generics = true no_implicit_optional = true ``` -## HTTP и transport layer +## HTTP and Transport Layer -Весь HTTP должен проходить через единый transport layer. +All HTTP must go through a single transport layer. -Правила: +Rules: -- Прямые вызовы `httpx.get()`/`httpx.post()` внутри section clients запрещены. -- Использовать `httpx.Client` или `httpx.AsyncClient` как внутреннюю зависимость transport-слоя. -- Таймауты задаются явно. -- Заголовки авторизации подставляются transport/auth слоем, а не бизнес-методами. -- Формирование URL, обработка ошибок, retry и логирование концентрируются в transport. +- Direct calls to `httpx.get()`/`httpx.post()` inside section clients are forbidden. +- Use `httpx.Client` or `httpx.AsyncClient` as an internal dependency of the transport layer. +- Timeouts are set explicitly. +- Authorization headers are injected by the transport/auth layer, not by business methods. +- URL construction, error handling, retries, and logging are concentrated in the transport. +- Transport details must not be part of public signatures, docstrings, or serialization. -Рекомендация: +Recommendation: -- Сначала сделать качественный sync SDK. -- Async-версию добавлять отдельным слоем, а не смешивать sync/async в одних и тех же классах. +- Build a high-quality sync SDK first. +- The SDK is synchronous — this must be explicitly documented in the README and public API. +- An async version should be added as a separate layer, not mixed with sync in the same classes. -## Авторизация +## Authorization -Авторизация должна быть полностью абстрагирована от API-методов. +Authorization must be fully abstracted away from API methods. -Правила: +Rules: -- API-методы не должны сами получать токен. -- Должен существовать отдельный `AuthProvider`, отвечающий за кэш токена, refresh и срок жизни. -- При `401 Unauthorized` transport должен инициировать controlled refresh, а не ломать контракт случайным образом. -- Конфигурация авторизации хранится в `Settings`, а не размазывается по коду. +- API methods must not fetch tokens themselves. +- A separate `AuthProvider` must exist, responsible for token caching, refresh, and lifetime. +- On `401 Unauthorized`, the transport must initiate a controlled refresh rather than breaking the contract unpredictably. +- Authorization configuration is stored in `Settings`, not scattered throughout the code. -## Работа с нестабильным соединением +## Handling Unstable Connections -Сеть нестабильна по умолчанию. Это нужно считать частью дизайна. +The network is unstable by default. This must be treated as part of the design. -Правила: +Rules: -- На transport-уровне должны быть retries с ограничением числа попыток. -- Retry применяется только к безопасным сценариям: timeout, connection errors, временные `5xx`, rate limit при понятной политике. -- Политика retry должна быть централизована и конфигурируема. -- Для всех запросов задаются разумные timeout'ы на connect/read/write. -- Ошибки после исчерпания retry не скрываются, а поднимаются как доменные исключения. -- Логирование retry должно быть информативным, но без утечки секретов. +- The transport layer must have retries with a bounded number of attempts. +- Retries apply only to safe scenarios: timeout, connection errors, transient `5xx`, rate limiting under a clear policy. +- The retry policy must be centralized and configurable. +- Reasonable timeouts for connect/read/write are set on all requests. +- Errors after retry exhaustion are not suppressed; they are raised as domain exceptions. +- Retry logging must be informative but must not leak secrets. -Минимально ожидаемые сущности: +Minimum expected entities: - `RetryPolicy` - `ApiTimeouts` - `TransportError` - `RateLimitError` -- `AuthenticationError` -- `ServerError` +- `AuthorizationError` +- `UpstreamApiError` -## Ошибки и исключения +## Errors and Exceptions -`assert` не используется для обработки ошибок API. +`assert` is not used to handle API errors. -Правила: +Rules: -- Для ошибок SDK создается иерархия собственных исключений в `core/exceptions.py`. -- Ошибка должна содержать минимум: HTTP status, код ошибки Avito при наличии, человекочитаемое сообщение, исходный response payload при безопасной необходимости. -- Ошибки 4xx и 5xx должны различаться типами. -- Ошибки парсинга и ошибки transport должны различаться. +- A hierarchy of custom exceptions is created in `core/exceptions.py` for SDK errors. +- An error must contain at minimum: `operation`, HTTP status, Avito error code if present, a human-readable message, and safe metadata. +- `4xx` and `5xx` errors must be distinguished by type. +- Parsing errors and transport errors must be distinguished. +- Mapping of transport/HTTP/API errors to public SDK errors must be centralized. +- Secrets, tokens, and sensitive headers must be automatically sanitized in the message and metadata. +- An unknown upstream error must not leak out as a raw transport exception. +- All error messages are written in a single language — Russian. Mixing languages in error messages is forbidden. -Пример иерархии: +Example hierarchy: ```python class AvitoError(Exception): ... class TransportError(AvitoError): ... -class AuthenticationError(AvitoError): ... -class PermissionDeniedError(AvitoError): ... -class NotFoundError(AvitoError): ... class ValidationError(AvitoError): ... +class AuthorizationError(AvitoError): ... # 403: insufficient permissions +class AuthenticationError(AvitoError): ... # 401: invalid credentials / token class RateLimitError(AvitoError): ... -class ServerError(AvitoError): ... +class ConflictError(AvitoError): ... +class UnsupportedOperationError(AvitoError): ... +class UpstreamApiError(AvitoError): ... class ResponseMappingError(AvitoError): ... ``` -## Mapping и преобразование данных +`AuthenticationError` (401) and `AuthorizationError` (403) are semantically different errors; they must not be in an inheritance relationship. A user catching `AuthorizationError` must not unexpectedly receive authentication errors. + +Normative mapping: + +- `400` and `422` map to `ValidationError` when that matches the operation's contract; +- `401` maps to `AuthenticationError`; +- `403` maps to `AuthorizationError`; +- `409` maps to `ConflictError`; +- `429` maps to `RateLimitError`; +- an unsupported operation results in `UnsupportedOperationError`; +- all other unknown upstream errors map to `UpstreamApiError`. + +## Mapping and Data Transformation + +JSON from Avito is an external contract, not an internal application model. + +Rules: + +- Raw JSON responses are mapped in a dedicated layer. +- Data enrichment logic executes after transport but before returning the object to the user. +- Enrichment must be deterministic and must not break the original method contract. +- If enrichment is expensive or requires additional requests, it must be explicitly indicated in the API. +- Transformation of transport responses into public SDK models must be centralized. +- The same resource must always map to the same public type, regardless of upstream payload variations within the allowed range. +- Public docstrings and signatures must not require knowledge of the upstream JSON shape. + +Recommendation: -JSON от Avito — это внешний контракт, а не внутренняя модель приложения. +- Use `mappers.py` inside each API section. +- Do not mix mapping with the HTTP call in the same method. -Правила: +## Public Read Contracts -- Сырые JSON-ответы маппятся в отдельном слое. -- Логика "обогащения" данных выполняется после transport, но до возврата объекта пользователю. -- Обогащение должно быть детерминированным и не ломать исходный контракт метода. -- Если обогащение дорогое или требует дополнительных запросов, оно должно быть явно обозначено в API. +Read operations must be aligned in result shape, nullable behavior, and field naming. + +Rules: -Рекомендация: +- `account().get_self()` returns `AccountProfile`; +- `ad().get(...)` returns `Listing`; +- `ad().list(...)` returns a collection or paginated result of `Listing`; +- `ad_stats().get_item_stats(...)` returns a collection of `ListingStats`; +- `ad_stats().get_calls_stats(...)` returns a collection of `CallStats`; +- `ad_stats().get_account_spendings(...)` returns `AccountSpendings` or another model fixed by the SDK contract; +- an empty or partially populated upstream payload must not break the read contract if the model allows `None` for missing values; +- consumer code must not need to know the raw Avito response structure to use read methods. -- Использовать `mappers.py` внутри раздела API. -- Не смешивать mapping с HTTP-вызовом в одном методе. +The following canonical types are normatively fixed for stable public read/write results: -## Нейминг +- `AccountProfile` +- `Listing` +- `ListingStats` +- `CallStats` +- `AccountSpendings` +- `PromotionService` +- `PromotionOrder` +- `PromotionForecast` +- `PromotionActionResult` -Правила: +## Promotion Write Contract -- Имена пакетов и модулей: lowercase, короткие и предметные. -- Имена классов: `PascalCase`. -- Имена функций и методов: `snake_case`. -- Имена публичных методов должны описывать бизнес-действие: `get_item`, `list_messages`, `create_discount_campaign`. -- Избегать абстрактных имен вроде `utils`, `helpers`, `common2`, `manager2`. +Officially supported promotion write operations must have a unified public contract. -## Конфигурация +Rules: -Правила: +- Promotion write operations accept `dry_run: bool = False`; +- with `dry_run=True` the method must validate inputs, build the official request payload, not execute the write request, and return `PromotionActionResult` with status `preview` or `validated`; +- with `dry_run=False` the method must use the same payload builder, execute the write request, and return the same type `PromotionActionResult`; +- invalid input parameters must result in `ValidationError` before transport is called; +- `request_payload` in the result must correspond to the actual payload of the write call; +- identical inputs in `dry_run=True` and `dry_run=False` must produce the same payload. -- Конфигурация SDK выделяется в отдельный модуль: `config.py` или `settings.py`. -- Переменные окружения читаются в одном месте. -- Публичные классы не должны напрямую зависеть от чтения environment. -- Пользователь SDK должен иметь возможность передать конфигурацию явно через объект настроек. +Stable `PromotionActionResult` contract: -Пример: +- `action` +- `target` +- `status` +- `applied` +- `request_payload` +- `warnings` +- `upstream_reference` +- `details` + +At minimum the following operations must follow this contract: + +- `bbip_promotion().create_order(...)` +- `ad_promotion().apply_vas(...)` +- `ad_promotion().apply_vas_package(...)` +- `ad_promotion().apply_vas_direct(...)` +- `trx_promotion().apply(...)` +- `trx_promotion().delete(...)` +- `target_action_pricing().update_manual(...)` +- `target_action_pricing().update_auto(...)` +- `target_action_pricing().delete(...)` + +## Promotion Read Contract + +Promotion surface read operations must return only stable public SDK models. + +Rules: + +- `promotion_order().list_services(...)` returns a collection of `PromotionService`; +- `promotion_order().list_orders(...)` returns a collection of `PromotionOrder`; +- `promotion_order().get_order_status(...)` returns a result per the fixed SDK contract; +- `bbip_promotion().get_suggests(...)` and `bbip_promotion().get_forecasts(...)` return stable SDK models, not transport shapes; +- `target_action_pricing().get_bids(...)` and `target_action_pricing().get_promotions_by_item_ids(...)` return stable SDK models; +- an empty upstream list is correctly returned as an empty SDK model collection; +- a partial upstream payload is correctly mapped into nullable fields of the public model. + +## Naming + +Rules: + +- Package and module names: lowercase, short, and domain-specific. +- Class names: `PascalCase`. +- Function and method names: `snake_case`. +- Public method names must describe the business action: `get_item`, `list_messages`, `create_discount_campaign`. +- Use canonical domain names for public models, not internal transport aliases. +- Avoid abstract names like `utils`, `helpers`, `common2`, `manager2`. +- Generic identifier names are forbidden: `resource_id`, `entity_id`, `obj_id`. Use concrete names: `item_id`, `order_id`, `user_id`. + +## Configuration + +Rules: + +- SDK configuration is isolated in a dedicated module: `config.py` or `settings.py`. +- `AvitoSettings` and `AuthSettings` are the official way to configure the SDK. +- SDK users must be able to pass `client_id` and `client_secret` directly to `AvitoClient` without creating intermediate objects. +- Environment variables are read in one place via `AvitoSettings.from_env()` and `AuthSettings.from_env()`. +- `AvitoClient.from_env()` is the official factory method for initializing the client from the environment. +- Resolution of process environment and `.env` must be deterministic and identical for all entry points. +- Process environment values take priority over `.env`. +- Supported environment variables and alias names must be documented and considered part of the stable config contract. +- Missing required configuration fields must be validated before the first HTTP request, via typed exceptions with clear messages. +- Error messages and metadata for configuration errors must not contain secret values. +- The number of allowed environment variable synonyms for a single field must be minimal. Generic names like `SECRET` or `TOKEN` must not be official aliases. + +Example: ```python @dataclass(slots=True, frozen=True) -class AvitoSettings: +class AuthSettings: client_id: str client_secret: str + refresh_token: str | None = None + + +@dataclass(slots=True, frozen=True) +class AvitoSettings: + auth: AuthSettings base_url: str = "https://api.avito.ru" + user_id: int | None = None timeout_seconds: float = 10.0 ``` -## Логирование +Minimum expected config contract capabilities: + +- `AvitoSettings.from_env()`; +- `AuthSettings.from_env()`; +- `AvitoClient.from_env()`; +- `AvitoClient(client_id=..., client_secret=...)`; +- explicit validation of required auth fields; +- safe `debug_info()` contract with no leakage of `client_secret`, access token, refresh token, or `Authorization` header. + +## Pagination + +The public behavior of lazy pagination must be fixed as part of the SDK contract. + +Rules: + +- list methods using lazy pagination return a result with a list-like `PaginatedList` collection in the `items` field; +- the type annotation for the `items` field must be `PaginatedList[T]`, not `list[T]` — the annotation must match the runtime; +- the first page may already be loaded at the time the result is obtained; +- reading the first `N` elements must not load all pages at once; +- iterating over the first `N` elements must execute only the necessary number of page requests; +- full materialization must be performed by an explicit call, e.g. `items.materialize()`; +- an empty collection must work without extra requests; +- an error on a subsequent page must propagate at the time that page is read; +- repeated access to already-loaded elements must not trigger a re-fetch if caching is declared part of the contract. + +If additional utilities are needed on top of pagination, they must be part of the public SDK contract, not external helper functions. + +## Serialization + +Public SDK models must serialize safely and uniformly without external helpers. + +Rules: + +- each public model serializes via a standard SDK method; +- the serialization result must be JSON-compatible; +- nested public models must serialize recursively; +- nullable and optional fields serialize per the fixed contract rules; +- serialization must not expose transport objects, service references, or internal mapper fields; +- `to_dict()` and `model_dump()` must be explicitly declared in the class or inherited from an explicit mixin — dynamic method injection via `globals()` or `setattr` at runtime is forbidden; +- the presence of serialization methods must be visible in the class definition without tracking side-effect calls during module import. + +## Logging + +Rules: + +- Logging must be structured and useful for diagnostics. +- `client_secret`, access token, refresh token, the full authorization header, and other secrets must not be logged. +- At info/debug level it is acceptable to log endpoint, attempt number, latency, status code, and operation name. +- SDK users must be able to disable or redirect logging. +- Diagnostic snapshots such as `debug_info()` must be safe by default. + +## Docstrings and Comments + +Rules: + +- Public classes and methods must have short docstrings describing the contract. +- A public method docstring must describe the returned SDK model and behavior on nullable/empty cases. +- Comments are used only where the intent cannot be expressed in code. +- Comments must not duplicate what is obvious. + +## Testing + +### What to Test + +A test exists to fix a technical decision or contract. A test is justified if without it, behavior that matters to the user or to the system can be broken unnoticed. + +What is tested: + +- **Public SDK contract**: factory method signatures, return types, behavior on empty and partial upstream payloads. +- **Error mapping**: each significant HTTP status must result in a strictly defined SDK exception type; secrets must not leak into metadata. +- **Auth flow**: token acquisition, refresh after 401, use of separate credentials for specialized endpoints. +- **Retry logic**: retries fire on allowed scenarios (timeout, 5xx, rate limit) and do not fire on disallowed ones (non-idempotent methods without explicit permission). +- **Pagination**: lazy loading reads only the necessary pages; an error on a subsequent page propagates at read time; an empty collection triggers no extra requests; full materialization loads all pages exactly once. +- **Serialization**: `to_dict()` / `model_dump()` returns a JSON-compatible structure; transport fields do not appear in the result; nested models serialize recursively. +- **Dry-run contract**: with `dry_run=True` transport is not called; the payload built in `dry_run` and in a real call is identical given the same inputs. +- **Configuration**: required fields are validated before the first HTTP request; process environment priority over `.env` is deterministic; secrets do not appear in `debug_info()`. +- **Data security**: secret values (tokens, `client_secret`, `Authorization` header) do not appear in error messages, metadata, or serialization output. + +What is not tested: + +- That a constructor accepts arguments and stores them in fields. +- That a dataclass contains a field of a certain type. +- That a function returns `None` when the input is `None`. +- That importing a module does not raise an exception. +- Logic fully implemented by a third-party library without customization. +- Code-to-documentation consistency: a test must not verify that a README, inventory, docstring, or comment describes the current behavior. Documentation is not a contract — it describes code, not the other way around. If documentation is outdated, update it; do not write a test to track it. +- The presence of a specific method or attribute via `hasattr`. That is a syntax check, not a behavior check. If a method is renamed, the calling code will break, not a `hasattr` test. + +Criterion: if a test cannot be broken without violating a public contract or technical decision, the test is not needed. + +### Test Architecture + +Tests are divided by what they verify, not by which module they cover. + +**Test levels:** + +- **Contract tests** — verify that the public API returns expected types and structures given correct upstream payloads. Use fake transport. Do not depend on the network. +- **Error mapping tests** — verify that each HTTP status and upstream error shape results in the correct SDK exception type with the expected fields. +- **Integration-style tests** — verify end-to-end technical decisions: retry, auth refresh, pagination. Use a controlled fake transport with specified scenarios. +- **Security tests** — verify that secrets do not leak through any public path: errors, serialization, debug_info. + +### Isolation + +Tests do not make network calls. All HTTP is replaced by a controlled fake transport that: + +- accepts a specified status and payload for each request; +- allows verifying whether a call was made, how many times, with which method and body; +- is used uniformly across all tests that verify the public API. + +Section clients, domain objects, and transport are tested in isolation from each other. + +### Test Structure + +Each test verifies one aspect of behavior. Structure: Arrange / Act / Assert with no nested conditionals. + +```python +def test_transport_retries_on_server_error_and_raises_after_exhaustion(): + # Arrange + transport = FakeTransport(responses=[ + FakeResponse(status=500), + FakeResponse(status=500), + FakeResponse(status=500), + ]) + + # Act / Assert + with pytest.raises(ServerError): + transport.request_json("GET", "/some/path", context=ctx) + + assert transport.call_count == 3 +``` + +Rules: + +- Test names describe behavior, not the method under test: `test_transport_retries_on_server_error_and_raises_after_exhaustion`, not `test_transport_request`. +- One test, one scenario. Multiple `assert` statements are acceptable when they verify the same behavior from different angles. +- Parametrization is used for sets of equivalent inputs: different HTTP statuses, different error shapes, different upstream payload variants. +- Fixtures create only infrastructure (fake transport, settings); they must not hide test logic. + +### Coverage of Mandatory Scenarios + +**Error mapping** — must cover: -Правила: +- 400, 401, 403, 404, 409, 422, 429, 5xx → the corresponding SDK exception type; +- secrets in `metadata` and error `headers` are replaced with `***`; +- an unknown status maps to `UpstreamApiError`, not a generic `Exception`. -- Логирование должно быть структурным и полезным для диагностики. -- Нельзя логировать `client_secret`, access token, полный authorization header. -- На уровне info/debug можно логировать endpoint, attempt number, latency, status code. -- Пользователь SDK должен иметь возможность отключить или перенаправить логирование. +**Auth flow** — must cover: -## Докстринги и комментарии +- successful token acquisition via `client_credentials`; +- automatic refresh after 401 exactly once; +- `AuthenticationError` after a failed refresh (second 401); +- credential isolation for separate token endpoints. -Правила: +**Pagination** — must cover: -- Публичные классы и методы должны иметь короткие docstring с описанием контракта. -- Комментарии используются только там, где нельзя выразить намерение кодом. -- Комментарии не должны дублировать очевидное. +- partial iteration loads only the necessary pages; +- full materialization via `materialize()` loads everything exactly once; +- an empty first page triggers no additional requests; +- an error on a subsequent page propagates at read time, not at object creation. -## Тестируемость +**Dry-run** — must cover for each write method with `dry_run`: -Style guide ориентирован на код, который легко тестировать. +- with `dry_run=True` transport receives no calls; +- the payload in `dry_run=True` and `dry_run=False` is identical given the same inputs; +- input validation in `dry_run=True` works the same as in `dry_run=False`. -Правила: +**Serialization** — must cover: -- Внешние зависимости передаются через конструктор. -- Нельзя захардкодить сетевые вызовы так, чтобы их нельзя было подменить в тестах. -- Transport, auth provider и section clients должны тестироваться отдельно. -- Mapping должен покрываться unit-тестами на реальных примерах Avito payload. +- `to_dict()` returns only public fields with no transport objects; +- nested models serialize recursively; +- the result passes `json.dumps()` without exceptions. -## Импорты и зависимости +## API Documentation and Contract Coverage -Правила: +Avito API specifications are stored in the `docs/` directory as Swagger/OpenAPI files. This is the authoritative source of truth for all API contracts. -- Использовать абсолютные импорты внутри пакета. -- Избегать циклических зависимостей между пакетами API-разделов. -- Зависимости должны быть минимально необходимыми. -- Если стандартная библиотека решает задачу без потери качества, сторонняя библиотека не нужна. +Rules: -## Чего в проекте быть не должно +- Before implementing any new method or model, consult the specification in `docs/`. +- The SDK must cover **all** API methods described in `docs/`. A method absent from the SDK but present in the specification is a defect. +- Public method signatures, model field names and types, allowed enum values, and nullable behavior must exactly match the contract in `docs/`. +- When there is a discrepancy between code and the specification in `docs/`, the specification takes priority. +- If the upstream API adds a new endpoint or changes an existing one, a corresponding SDK change is mandatory. +- Fields marked as required in the specification cannot be `T | None` in the public model without explicit justification. +- Enum values in the SDK must match the allowed values from the specification — arbitrary extension is forbidden. -- Глобального состояния токена без контролируемого владельца. -- Методов, возвращающих неструктурированный JSON в основном API. -- Смешения transport, auth, parsing и domain logic в одном классе. -- Неаннотированных публичных методов. -- Широкого использования `Any`. -- Обработки ошибок через `assert`. -- Скрытых сетевых побочных эффектов в свойствах и dataclass. +## Imports and Dependencies -## Практический вывод для текущего репозитория +Rules: -При дальнейшем рефакторинге проекта нужно двигаться в сторону следующей модели: +- Use absolute imports within the package. +- Avoid circular dependencies between API section packages. +- Dependencies must be minimal. +- If the standard library solves the problem without loss of quality, a third-party library is not needed. -- заменить текущие `BaseModel` доменные сущности на `dataclass`; -- вынести HTTP и retry в `core/transport.py`; -- вынести авторизацию в отдельный пакет `auth/`; -- разбить API по предметным пакетам вместо одной общей клиентской реализации; -- ввести строгую конфигурацию `mypy`; -- заменить сырые словари ответа на собственные типизированные модели; -- заменить `assert` на иерархию исключений SDK. +## What Must Not Exist in the Project -Этот документ является базовым стандартом для всех следующих изменений в проекте. +- Global token state without a controlled owner. +- Methods returning unstructured JSON in the main API. +- Mixing of transport, auth, parsing, and domain logic in one class. +- Unannotated public methods. +- Widespread use of `Any`. +- Error handling via `assert`. +- Hidden network side effects in properties and dataclasses. +- Leakage of transport-layer shapes and mapper details into public signatures and models. +- Implicit or undocumented config resolution through the environment. +- Abstract field names (`resource_id`) where a domain-specific name is known and unambiguous. +- Dynamic method injection into classes via `setattr`, patching via `globals()`, or other runtime magic. +- Two public methods doing the same thing without one of them being explicitly marked as deprecated. +- Type aliases without explicit deprecation. +- `list[T]` annotation where `PaginatedList[T]` is returned at runtime. +- `AuthenticationError` as a subclass of `AuthorizationError`: 401 and 403 are different errors. +- Error messages in mixed languages: all user-facing error text must be in one language. +- Generic environment aliases (`SECRET`, `TOKEN`) in the official config contract. +- Dead code: unused symbols, aliases, and imports. +- Internal-layer request objects in public domain method signatures. diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 186a126..0000000 --- a/TODO.md +++ /dev/null @@ -1,609 +0,0 @@ -# TODO - -## Цель - -Полностью реализовать SDK для всех методов, описанных в `docs/*.json`, с архитектурой, строго соответствующей `STYLEGUIDE.md`: - -- публичный API только через объектный фасад `AvitoClient`; -- один публичный клиент без дерева section clients в пользовательском API; -- отдельные слои `auth`, `core`, `domain objects`, `mappers`, `errors`; -- публичные ответы только как `@dataclass(slots=True, frozen=True)`; -- единый transport на `httpx.Client`; -- централизованные retries, timeouts, обработка `401`, `4xx`, `5xx`, rate limit; -- отсутствие `assert` и сырых `dict[str, Any]` в публичном API. -- пользовательская документация репозитория и docstring публичных сущностей должны быть на русском языке. - -Целевая форма пользовательского API: - -```python -avito = AvitoClient(...) -ad = avito.ad(item_id).get() -avito.account(user_id).get_balance() -avito.chat(chat_id, user_id=user_id).send_message(...) -``` - -Доменные объекты создаются через один клиент и инкапсулируют операции своей предметной области. В публичном API не должно быть сценариев вида `client.ads.get(...)`, `client.orders.list(...)` или `client.messenger.send(...)`. - -## Полный охват документации - -Нужно покрыть все разделы из `docs/`: - -- `auth`: Авторизация, а также отдельный токен в `Автотека`. -- `accounts`: Информация о пользователе, Иерархия аккаунтов. -- `ads`: Объявления, Автозагрузка. -- `messenger`: Мессенджер, Рассылка скидок и спецпредложений. -- `promotion`: Продвижение, TrxPromo, CPA-аукцион, Настройка цены целевого действия, Автостратегия. -- `orders`: Управление заказами, Доставка, Управление остатками. -- `jobs`: Авито.Работа. -- `cpa`: CPA Авито, CallTracking. -- `autoteka`: все операции по превью, отчетам, скорингу, мониторингу, тизерам, оценке. -- `realty`: Краткосрочная аренда, Аналитика по недвижимости. -- `ratings`: Рейтинги и отзывы. -- `tariffs`: Тарифы. - -Deprecated-методы из swagger не пропускать: реализовать как отдельные legacy-методы с явной пометкой в docstring и тестах. - -## Явная матрица покрытия docs -> SDK - -Ниже зафиксировано соответствие каждого документа и его endpoint-групп будущим пакетам SDK. Это обязательная часть плана, а не задача "на потом". - -| Документ | Операции | Пакет SDK | Доменный объект | Этап | -| --- | ---: | --- | --- | ---: | -| `docs/Авторизация.json` | 3 | `auth` | `AvitoClient.auth()` и внутренние token flow-объекты | 3 | -| `docs/Информацияопользователе.json` | 3 | `accounts` | `Account` | 4 | -| `docs/ИерархияАккаунтов.json` | 5 | `accounts` | `AccountHierarchy` | 4 | -| `docs/Объявления.json` | 11 | `ads` | `Ad`, `AdStats`, `AdPromotion` | 4 | -| `docs/Автозагрузка.json` | 17 | `ads` | `AutoloadProfile`, `AutoloadReport`, `AutoloadLegacy` | 4 | -| `docs/Мессенджер.json` | 13 | `messenger` | `Chat`, `ChatMessage`, `ChatWebhook`, `ChatMedia` | 5 | -| `docs/Рассылкаскидокиспецпредложенийвмессенджере.json` | 5 | `messenger` | `SpecialOfferCampaign` | 5 | -| `docs/Продвижение.json` | 7 | `promotion` | `PromotionOrder`, `BbipPromotion` | 6 | -| `docs/TrxPromo.json` | 3 | `promotion` | `TrxPromotion` | 6 | -| `docs/CPA-аукцион.json` | 2 | `promotion` | `CpaAuction` | 6 | -| `docs/Настройкаценыцелевогодействия.json` | 5 | `promotion` | `TargetActionPricing` | 6 | -| `docs/Автостратегия.json` | 7 | `promotion` | `AutostrategyCampaign` | 6 | -| `docs/Управлениезаказами.json` | 12 | `orders` | `Order`, `OrderLabel` | 7 | -| `docs/Доставка.json` | 31 | `orders` | `DeliveryOrder`, `SandboxDelivery`, `DeliveryTask` | 7 | -| `docs/Управлениеостатками.json` | 2 | `orders` | `Stock` | 7 | -| `docs/АвитоРабота.json` | 25 | `jobs` | `Vacancy`, `Application`, `Resume`, `JobWebhook`, `JobDictionary` | 8 | -| `docs/CPAАвито.json` | 11 | `cpa` | `CpaLead`, `CpaChat`, `CpaCall`, `CpaLegacy` | 9 | -| `docs/CallTracking[КТ].json` | 3 | `cpa` | `CallTrackingCall` | 9 | -| `docs/Автотека.json` | 27 | `autoteka` | `AutotekaVehicle`, `AutotekaReport`, `AutotekaMonitoring`, `AutotekaScoring`, `AutotekaValuation` | 10 | -| `docs/Краткосрочнаяаренда.json` | 5 | `realty` | `RealtyListing`, `RealtyBooking`, `RealtyPricing` | 11 | -| `docs/Аналитикапонедвижимости.json` | 2 | `realty` | `RealtyAnalyticsReport` | 11 | -| `docs/Рейтингииотзывы.json` | 4 | `ratings` | `Review`, `ReviewAnswer`, `RatingProfile` | 11 | -| `docs/Тарифы.json` | 1 | `tariffs` | `Tariff` | 11 | - -Правила полноты: - -- Для каждого endpoint из таблицы в `docs/inventory.md` должны быть зафиксированы: - - HTTP method; - - path; - - swagger summary; - - target package; - - target domain object; - - публичный метод SDK; - - признак `legacy/deprecated`; - - тип request/response модели; - - тесты, покрывающие endpoint. -- Этап считается закрытым только если все endpoints из соответствующих строк таблицы получили конкретные методы SDK и тесты. -- Любой endpoint из `docs/*.json`, который не попал в таблицу или в inventory, считается пропуском плана. - -## Проверка соответствия swagger - -Покрытие `docs/*.json` должно не только существовать в inventory, но и регулярно проверяться на консистентность. - -- Для каждого swagger-документа нужно зафиксировать число операций и их соответствие inventory. -- Для каждого endpoint нужно проверять совпадение: - - HTTP method; - - path; - - deprecated-статуса; - - основных request/response схем на уровне, достаточном для выявления расхождений SDK с документацией. -- Любое расхождение между swagger и `docs/inventory.md` считается дефектом плана и должно исправляться до расширения SDK. -- Если swagger содержит неоднозначность или явную ошибку, это должно быть отдельно отмечено в inventory с пояснением принятой нормализации. -- Для inventory и матрицы покрытия нужна отдельная проверка в тестах или validation script, которую можно запускать перед релизом. - -## Целевая структура пакетов - -```text -avito/ - __init__.py - client.py - config.py - auth/ - core/ - accounts/ - ads/ - messenger/ - promotion/ - orders/ - jobs/ - cpa/ - autoteka/ - realty/ - ratings/ - tariffs/ -``` - -В каждом доменном пакете: - -- `domain.py` с доменными объектами и их операциями; -- `models.py` с dataclass-моделями; -- `enums.py` для строковых констант API; -- `mappers.py` для JSON -> dataclass; -- при необходимости `requests.py` или `filters.py` для typed request-моделей. - -## Общие правила реализации - -- Все JSON-ответы сначала описывать как boundary-типы (`TypedDict`), затем маппить в dataclass. -- Для списков, пагинации, batch-операций и task-based API делать отдельные result-модели. -- Для бинарных ответов выделить типизированные low-level сущности: - - аудиозаписи звонков; - - PDF этикеток; - - загрузка изображений и файлов. -- Для webhook-операций сделать отдельные request/result модели и контрактные тесты сериализации. -- Для sandbox-методов `delivery` сохранить отдельный `SandboxDeliveryClient`, не смешивая с production API. -- Для `x-gateway`, rate-limiter и нестандартных заголовков добавить расширяемую конфигурацию transport, но скрыть детали от публичного API. -- Сохранять обратную совместимость с текущим кодом не требуется: при конфликте старой структуры с новой архитектурой приоритет всегда у новой архитектуры из `STYLEGUIDE.md`. -- Каждый публичный и внутренний класс должен иметь короткий обязательный docstring с описанием ответственности и контракта. -- Пользовательская документация репозитория, `docs/*.md`, README и docstring публичных сущностей ведутся на русском языке. -- Аномалии в swagger нормализовать в inventory: - - дубли `/token` с невидимыми символами; - - deprecated-версии; - - неоднородные path/query/body схемы; - - операции, возвращающие task id для последующего polling. - -## Этап 1. Инвентаризация swagger и каркас SDK - -Что сделать: - -- Создать `docs/inventory.md` с перечнем всех операций: section, HTTP method, path, swagger summary, статус `deprecated`, пакет SDK, domain object, публичный метод SDK, тип request, тип response, тип теста. -- Зафиксировать единый mapping `docs/*.json -> package/module`. -- Удалить текущую смесь логики из `avito/client/client.py` и заменить на один минимальный клиент, совместимый с новой архитектурой. -- Создать пакеты `auth`, `core` и доменные пакеты-заготовки. -- Ввести единый стиль именования фабрик и методов доменных объектов: - - `avito.account(...)`, `avito.ad(...)`, `avito.chat(...)`, `avito.order(...)`, `avito.tariff(...)`; - - `get_*`, `list_*`, `create_*`, `update_*`, `delete_*`, `apply_*`, `download_*`, `get_*_by_*`. - -Тесты: - -- Smoke-тест импорта `AvitoClient`. -- Тест структуры единого клиента: `avito.account(...)`, `avito.ad(...)`, `avito.chat(...)`, `avito.order(...)` и другие доменные фабрики доступны как методы одного клиента. -- Тест проверки соответствия `docs/inventory.md` исходным swagger-документам. - -Критерии готовности: - -- В репозитории есть полный inventory по всем swagger-операциям. -- Inventory доказывает соответствие `каждый endpoint -> конкретный метод SDK -> конкретный тест`. -- Новая пакетная структура создана. -- Старый god-object больше не является точкой дальнейшей разработки, а новый единый клиент является единственной публичной точкой входа. - -## Этап 2. Core: config, exceptions, transport, retries, pagination - -Что сделать: - -- Вынести настройки в `config.py` и `auth/settings.py` на `pydantic-settings`. -- Реализовать: - - `ApiTimeouts`; - - `RetryPolicy`; - - `Transport`; - - `RequestContext`; - - `Paginator` или page/result abstractions; - - иерархию исключений из `STYLEGUIDE.md`. -- Добавить нормализацию URL и кодирование path/query параметров. -- Реализовать базовую обработку: - - `401 -> controlled token refresh`; - - `429 -> RateLimitError` или retry по политике; - - `5xx -> retry + ServerError`; - - mapping errors -> `ResponseMappingError`. -- Поддержать JSON, multipart upload, binary download. - -Тесты: - -- Unit-тесты таймаутов, retry-решений и классификации ошибок. -- Тест refresh токена после `401` с повтором запроса. -- Тест, что неидемпотентные запросы не ретраятся без явного разрешения. -- Тест бинарного ответа и multipart upload. -- Тест пагинации и сборки типизированного page result. - -Критерии готовности: - -- Ни один доменный объект не делает прямой вызов `httpx` или `requests`. -- Все сетевые ошибки поднимаются как доменные исключения. -- Transport покрыт regression-тестами на основные ветки поведения. - -## Этап 3. Auth provider и аутентификация - -Что сделать: - -- Реализовать `AuthProvider` с кешем access token и временем жизни. -- Поддержать: - - client credentials; - - refresh token flow; - - отдельный auth flow для `Автотека`, если он отличается по контракту. -- Нормализовать дубли `/token` из swagger в один публичный API с legacy-alias при необходимости. -- Добавить low-level модели токенов и auth errors. - -Тесты: - -- Успешное получение access token. -- Успешный refresh token. -- Ошибка авторизации маппится в `AuthenticationError`. -- Дублирующиеся swagger-path не ломают публичный интерфейс. - -Критерии готовности: - -- Ни один API-метод не получает токен самостоятельно. -- Обновление токена происходит централизованно и прозрачно для доменных объектов. - -## Этап 4. Базовые account- и ads-разделы - -Покрыть разделы: - -- `Информация о пользователе` -- `Иерархия Аккаунтов` -- `Объявления` -- `Автозагрузка` - -Что сделать: - -- Реализовать доменные объекты `Account`, `AccountHierarchy`, `Ad`, `AdStats`, `AdPromotion`, `AutoloadProfile`, `AutoloadReport`. -- В `accounts` покрыть: - - `self`; - - `balance`; - - `operations_history`; - - статус пользователя в ИА; - - сотрудников; - - телефоны компании; - - связь объявлений и сотрудников; - - список объявлений сотрудника. -- В `ads` покрыть: - - получение одного объявления и списка объявлений; - - статистику по объявлениям и расходам; - - call stats; - - обновление цены; - - применение VAS и VAS packages; - - цены на услуги продвижения; - - все отчеты и profile API автозагрузки; - - `ad_ids`, `avito_ids`, tree/fields, upload by URL. -- Для deprecated операций автозагрузки сделать `legacy`-объект или `legacy`-методы с явным именованием. - -Тесты: - -- Mapping-тесты на все основные модели объявлений, статистики, spendings и autoload reports. -- Тесты query/body/path параметров для фильтров и batch-операций. -- Тесты deprecated wrappers, что они помечены и вызывают правильный endpoint. -- Тесты upload/profile/report flows автозагрузки. - -Критерии готовности: - -- Пользователь может пройти сценарий `avito.account(user_id).get_self()`, `avito.ad(item_id).get()`, `avito.ad(item_id).get_stats(...)`, `avito.autoload_report(report_id).get()`. -- Все ответы этого блока возвращают dataclass-модели, а не `dict`. - -## Этап 5. Messenger и messaging-adjacent API - -Покрыть разделы: - -- `Мессенджер` -- `Рассылка скидок и спецпредложений в мессенджере` - -Что сделать: - -- Реализовать `MessengerClient` и вложенный `SpecialOffersClient`. -- Покрыть: - - список чатов; - - чат по ID; - - сообщения V3; - - отправку текста; - - отправку изображения; - - удаление сообщения; - - отметку чата как прочитанного; - - voice files; - - upload images; - - blacklist; - - webhook subscribe/unsubscribe/list; - - available offers, draft create, confirm, stats, tariff info. -- Отдельно спроектировать модели для webhook subscription и media upload results. - -Тесты: - -- Контрактные тесты сериализации webhook payload и subscribe/unsubscribe flows. -- Тесты multipart/image upload. -- Mapping-тесты чатов, сообщений и голосовых файлов. -- Интеграционные мок-тесты цепочки `upload image -> send image message`. -- Тесты offer campaign flow: `available -> multiCreate -> multiConfirm`. - -Критерии готовности: - -- Пользователь может пройти end-to-end сценарий переписки и рассылки без обращения к raw HTTP. -- Все webhook-методы имеют стабильные typed request/result модели. - -## Этап 6. Promotion stack - -Покрыть разделы: - -- `Продвижение` -- `TrxPromo` -- `CPA-аукцион` -- `Настройка цены целевого действия` -- `Автостратегия` - -Что сделать: - -- Реализовать `PromotionClient` и при необходимости вложенные клиенты: - - `BbipClient`; - - `TrxPromoClient`; - - `CpaAuctionClient`; - - `TargetActionPriceClient`; - - `AutostrategyClient`. -- Покрыть: - - словари услуг, список услуг, заявки, статусы заявок; - - прогнозы, budget suggests, подключение услуг; - - apply/cancel/commissions для TrxPromo; - - get/save bids для аукциона; - - get bids, promotions by item ids, remove, set auto/manual; - - budget calculation, create/edit/info/stop/list/stat кампаний. -- Выделить общие модели бюджетов, ставок, кампаний и заявок. - -Тесты: - -- Mapping-тесты бюджетов, ставок, кампаний и статусов заявок. -- Тесты на идемпотентность и корректный HTTP method (`PUT` против `POST`) для подключения услуг. -- Тесты batch-операций по нескольким объявлениям. -- Тесты legacy/deprecated, если в ответах есть старые формы. - -Критерии готовности: - -- Все promotion API доступны из одного доменного блока без дублирования transport-логики. -- Публичный API отражает предметные действия, а не названия raw endpoints. - -## Этап 7. Orders, delivery, stock management - -Покрыть разделы: - -- `Управление заказами` -- `Доставка` -- `Управление остатками` - -Что сделать: - -- Реализовать `OrdersClient`, `DeliveryClient`, `SandboxDeliveryClient`, `StockClient`. -- В `orders` покрыть: - - получение заказов; - - transitions; - - confirmation code; - - cnc details; - - courier ranges; - - tracking number; - - return order; - - markings; - - labels tasks; - - labels PDF download. -- В `delivery` покрыть production и sandbox сценарии: - - announcement create/cancel/track; - - parcel create/cancel/change/info; - - tariff/terminals/areas/sorting centers; - - prohibit acceptance; - - custom schedule; - - tasks polling; - - change result callbacks. -- В `stock` покрыть info и update stocks. - -Тесты: - -- Тесты бинарной загрузки PDF-этикеток. -- Тесты polling long-running tasks по `task_id`. -- Тесты sandbox/prod route separation. -- Mapping-тесты заказов, маркировок, диапазонов доставки, посылок, тарифов и остатков. -- Тесты optimistic retry only for safe polling requests. - -Критерии готовности: - -- Production и sandbox API доставки не смешаны в одном наборе методов. -- Пользователь может получить PDF-этикетку и обработать lifecycle заказа через typed API. - -## Этап 8. Jobs API - -Покрыть раздел: - -- `Авито.Работа` - -Что сделать: - -- Реализовать `JobsClient` с подразделами: - - `VacanciesClient`; - - `ApplicationsClient`; - - `ResumesClient`; - - `WebhookClient`; - - `DictionariesClient`. -- Покрыть обе версии вакансий и резюме: - - publish/edit/archive/prolongate; - - v2 create/update/statuses/batch/get/list/auto_renewal; - - applications ids/by_ids/states/apply_actions/set_is_viewed; - - webhook CRUD/list; - - resumes search/get/get contacts; - - vacancy dictionaries. -- Развести `v1` и `v2` модели там, где контракт реально отличается. - -Тесты: - -- Mapping-тесты вакансий, резюме, откликов, словарей и статусов публикации. -- Тесты batch vacancy flows. -- Тесты webhook CRUD. -- Тесты, что `v1` и `v2` не смешивают несовместимые поля. - -Критерии готовности: - -- Полный lifecycle вакансии и отклика покрыт публичным API. -- Версионные различия инкапсулированы в моделях и клиентах, а не размазаны по условным `dict`. - -## Этап 9. CPA и CallTracking - -Покрыть разделы: - -- `CPA Авито` -- `CallTracking[КТ]` - -Что сделать: - -- Реализовать `CpaClient` и `CallTrackingClient`. -- Покрыть: - - chat by action id; - - chats by time `v1` deprecated и `v2`; - - call by id `v1` deprecated и `v2`; - - calls by time; - - complaints; - - phones info from chats; - - balance info `v2` deprecated и `v3`; - - calltracking get call, get calls, get record. -- Для аудиофайлов и записей звонков ввести typed binary result. - -Тесты: - -- Mapping-тесты звонков, чатов, балансов и complaint responses. -- Тесты legacy wrappers на deprecated endpoints. -- Тесты бинарной выдачи аудиозаписи звонка. -- Тесты временных фильтров и batch phone info requests. - -Критерии готовности: - -- Все CPA и call tracking методы доступны из отдельных логически чистых клиентов. -- Deprecated API поддержаны без загрязнения основного современного интерфейса. - -## Этап 10. Autoteka - -Покрыть раздел: - -- `Автотека` - -Что сделать: - -- Реализовать `AutotekaClient` с подразделами: - - `CatalogClient`; - - `PreviewClient`; - - `ReportClient`; - - `ScoringClient`; - - `SpecificationsClient`; - - `MonitoringClient`; - - `TeaserClient`; - - `ValuationClient`; - - `PackageClient`. -- Покрыть: - - resolve catalogs; - - active package; - - all preview creation variants; - - previews by id; - - reports, reports by vehicle id, report list, report by id; - - sync reports by regnumber/vin; - - scoring create/get; - - specification create/get; - - monitoring add/remove/delete/get events; - - teasers create/get; - - valuation; - - leads/signal events. -- Отдельно оформить auth-требования, если токен автотеки живет отдельно. - -Тесты: - -- Mapping-тесты всех крупных сущностей: preview, report, scoring, specification, teaser, package balance, monitoring event. -- Тесты нескольких альтернативных request-моделей для одного бизнес-действия. -- Тесты polling/report retrieval после create. -- Тесты auth boundary для отдельного токена автотеки. - -Критерии готовности: - -- Сложный большой API разбит на малые доменные объекты и mappers. -- Пользователь может последовательно пройти сценарий `preview -> report -> scoring/specification`. - -## Этап 11. Realty, ratings, tariffs - -Покрыть разделы: - -- `Краткосрочная аренда` -- `Аналитика по недвижимости` -- `Рейтинги и отзывы` -- `Тарифы` - -Что сделать: - -- Реализовать `RealtyClient`, `RatingsClient`, `TariffsClient`. -- В `realty` покрыть: - - bookings get/fill; - - prices update for periods; - - intervals fill; - - base params set; - - market price correspondence; - - analytics report create. -- В `ratings` покрыть rating info, reviews pagination, create/delete answer. -- В `tariffs` покрыть transport tariff info. - -Тесты: - -- Mapping-тесты броней, периодов, аналитических отчетов, отзывов, рейтинга, tariff info. -- Тест пагинации отзывов. -- Тесты mutation-сценариев answer create/delete и booking update. -- Тесты path params для itemId/price/report create. - -Критерии готовности: - -- Все remaining sections из `docs/` реализованы. -- Нет разделов swagger без соответствующего доменного объекта. - -## Этап 12. Сквозная стабилизация, документация и релизный gate - -Что сделать: - -- Добавить `pytest`, `respx` или аналог для HTTP mocking, `mypy`, `ruff`. -- Зафиксировать `mypy` в strict-режиме или профиле, эквивалентном требованиям `STYLEGUIDE.md`. -- Довести покрытие unit + contract tests до уровня, достаточного для безопасного рефакторинга. -- Добавить обязательные примеры использования публичных методов: - - короткие примеры в docstring или рядом с методом там, где это уместно; - - сценарные примеры в `README.md` по всем основным доменам; - - не менее одного end-to-end примера на каждый крупный пакет SDK. -- Добавить changelog-политику и release checklist. -- Подготовить минимальные low-level debugging hooks, не нарушающие публичный API. -- Запустить финальную ревизию имен методов и моделей, чтобы в API не осталось HTTP-терминов без необходимости. - -Тесты: - -- `poetry run pytest` -- `poetry run mypy avito` -- `poetry run ruff check .` -- `poetry build` -- Проверка, что конфиг `mypy` соответствует минимальному strict-профилю из `STYLEGUIDE.md`: `strict = true`, `warn_unused_ignores = true`, `warn_redundant_casts = true`, `warn_return_any = true`, `disallow_any_generics = true`, `no_implicit_optional = true`. - -Критерии готовности: - -- Все этапы выше закрыты. -- Все swagger-разделы имеют реализованные доменные объекты, модели, мапперы и тесты. -- `mypy` работает в strict-режиме или эквивалентном профиле, соответствующем `STYLEGUIDE.md`. -- Сборка пакета проходит стабильно. -- Все классы задокументированы обязательными docstring. -- README показывает объектный API, соответствующий `STYLEGUIDE.md`, и содержит примеры использования ключевых методов. - -## Матрица покрытия по пакетам - -- `auth`: Авторизация, Autoteka token. -- `accounts`: Информация о пользователе, Иерархия аккаунтов. -- `ads`: Объявления, Автозагрузка. -- `messenger`: Мессенджер, Special offers. -- `promotion`: Продвижение, TrxPromo, CPA-аукцион, Целевое действие, Автостратегия. -- `orders`: Управление заказами, Доставка, Управление остатками. -- `jobs`: Авито.Работа. -- `cpa`: CPA Авито, CallTracking. -- `autoteka`: Автотека. -- `realty`: Краткосрочная аренда, Аналитика по недвижимости. -- `ratings`: Рейтинги и отзывы. -- `tariffs`: Тарифы. - -## Определение готовности проекта - -Проект считается завершенным только если одновременно выполнено все ниже: - -- каждый endpoint из `docs/*.json` сопоставлен публичному или legacy-методу SDK; -- это соответствие зафиксировано в `docs/inventory.md` и проверяемо по тестам; -- ни один публичный метод не возвращает сырой JSON; -- transport/auth/errors/retries изолированы от доменных клиентов; -- для каждого этапа есть regression-тесты; -- deprecated API явно отделены и задокументированы; -- строгая типизация подтверждена `mypy` strict или эквивалентным профилем из `STYLEGUIDE.md`; -- `poetry build` и полный тестовый набор проходят без ручных правок. diff --git a/avito/__init__.py b/avito/__init__.py index 61b0a07..d4de0b3 100644 --- a/avito/__init__.py +++ b/avito/__init__.py @@ -1,6 +1,7 @@ """Публичные экспорты пакета SDK для Avito.""" +from avito.auth.settings import AuthSettings from avito.client import AvitoClient from avito.config import AvitoSettings -__all__ = ("AvitoClient", "AvitoSettings") +__all__ = ("AuthSettings", "AvitoClient", "AvitoSettings") diff --git a/avito/_env.py b/avito/_env.py new file mode 100644 index 0000000..dc05e37 --- /dev/null +++ b/avito/_env.py @@ -0,0 +1,127 @@ +"""Внутренние утилиты детерминированного чтения env и `.env`.""" + +from __future__ import annotations + +import os +from collections.abc import Mapping +from json import JSONDecodeError, loads +from pathlib import Path + +from avito.core.exceptions import ConfigurationError + + +def read_dotenv(env_file: str | Path | None) -> dict[str, str]: + """Читает простой `.env` файл без побочных эффектов.""" + + if env_file is None: + return {} + + path = Path(env_file) + if not path.exists(): + return {} + + values: dict[str, str] = {} + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if line.startswith("export "): + line = line.removeprefix("export ").strip() + if "=" not in line: + continue + key, value = line.split("=", 1) + cleaned_value = value.strip() + if len(cleaned_value) >= 2 and cleaned_value[0] == cleaned_value[-1]: + if cleaned_value[0] in {"'", '"'}: + cleaned_value = cleaned_value[1:-1] + values[key.strip()] = cleaned_value + return values + + +def resolve_env_aliases( + aliases_by_field: Mapping[str, tuple[str, ...]], + *, + env_file: str | Path | None, +) -> dict[str, str]: + """Разрешает env alias-имена с приоритетом process environment над `.env`.""" + + file_values = read_dotenv(env_file) + resolved: dict[str, str] = {} + + for field_name, aliases in aliases_by_field.items(): + for source in (os.environ, file_values): + value = _first_present(source, aliases) + if value is not None: + resolved[field_name] = value + break + + return resolved + + +def _first_present(source: Mapping[str, str], aliases: tuple[str, ...]) -> str | None: + for alias in aliases: + value = source.get(alias) + if value is not None and value != "": + return value + return None + + +def parse_env_int(value: str, *, field_name: str) -> int: + """Преобразует env-значение в `int` с typed-ошибкой.""" + + try: + return int(value) + except ValueError as exc: + raise ConfigurationError( + f"Некорректное значение `{field_name}`: ожидается int, получено {value!r}." + ) from exc + + +def parse_env_float(value: str, *, field_name: str) -> float: + """Преобразует env-значение в `float` с typed-ошибкой.""" + + try: + return float(value) + except ValueError as exc: + raise ConfigurationError( + f"Некорректное значение `{field_name}`: ожидается float, получено {value!r}." + ) from exc + + +def parse_env_bool(value: str, *, field_name: str) -> bool: + """Преобразует env-значение в `bool` с typed-ошибкой.""" + + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off"}: + return False + raise ConfigurationError( + f"Некорректное значение `{field_name}`: ожидается bool, получено {value!r}." + ) + + +def parse_env_str_tuple(value: str, *, field_name: str) -> tuple[str, ...]: + """Преобразует env-значение в кортеж строк.""" + + stripped = value.strip() + if not stripped: + return () + if stripped.startswith("["): + try: + parsed = loads(stripped) + except JSONDecodeError as exc: + raise ConfigurationError( + f"Некорректное значение `{field_name}`: ожидается JSON-массив строк." + ) from exc + if not isinstance(parsed, list) or not all(isinstance(item, str) for item in parsed): + raise ConfigurationError( + f"Некорректное значение `{field_name}`: ожидается список строк." + ) + return tuple(parsed) + parts = tuple(part.strip() for part in stripped.split(",") if part.strip()) + if not parts: + raise ConfigurationError( + f"Некорректное значение `{field_name}`: ожидается непустой список строк." + ) + return parts diff --git a/avito/accounts/__init__.py b/avito/accounts/__init__.py index eca83f9..e398550 100644 --- a/avito/accounts/__init__.py +++ b/avito/accounts/__init__.py @@ -1,35 +1,30 @@ """Пакет accounts.""" -from avito.accounts.domain import Account, AccountHierarchy, DomainObject +from avito.accounts.domain import Account, AccountHierarchy from avito.accounts.models import ( + AccountActionResult, AccountBalance, AccountProfile, - ActionResult, AhUserStatus, CompanyPhone, CompanyPhonesResult, Employee, EmployeeItem, - EmployeeItemsResult, EmployeesResult, OperationRecord, - OperationsHistoryResult, ) __all__ = ( "Account", + "AccountActionResult", "AccountBalance", "AccountHierarchy", "AccountProfile", - "ActionResult", "AhUserStatus", "CompanyPhone", "CompanyPhonesResult", - "DomainObject", "Employee", "EmployeeItem", - "EmployeeItemsResult", "EmployeesResult", "OperationRecord", - "OperationsHistoryResult", ) diff --git a/avito/accounts/client.py b/avito/accounts/client.py index 905d841..d1f72e7 100644 --- a/avito/accounts/client.py +++ b/avito/accounts/client.py @@ -15,19 +15,20 @@ map_operations_history, ) from avito.accounts.models import ( + AccountActionResult, AccountBalance, AccountProfile, - ActionResult, AhUserStatus, CompanyPhonesResult, + EmployeeItem, EmployeeItemLinkRequest, EmployeeItemsRequest, - EmployeeItemsResult, EmployeesResult, + OperationRecord, OperationsHistoryRequest, - OperationsHistoryResult, ) -from avito.core import RequestContext, Transport +from avito.core import JsonPage, PaginatedList, Paginator, RequestContext, Transport +from avito.core.mapping import request_public_model @dataclass(slots=True) @@ -39,33 +40,56 @@ class AccountsClient: def get_self(self) -> AccountProfile: """Получает профиль авторизованного пользователя.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", "/core/v1/accounts/self", context=RequestContext("accounts.get_self"), + mapper=map_account_profile, ) - return map_account_profile(payload) def get_balance(self, *, user_id: int) -> AccountBalance: """Получает баланс аккаунта.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", f"/core/v1/accounts/{user_id}/balance/", context=RequestContext("accounts.get_balance"), + mapper=map_account_balance, ) - return map_account_balance(payload) - def get_operations_history(self, request: OperationsHistoryRequest) -> OperationsHistoryResult: + def get_operations_history(self, request: OperationsHistoryRequest) -> PaginatedList[OperationRecord]: """Получает историю операций пользователя.""" - payload = self.transport.request_json( - "POST", - "/core/v1/accounts/operations_history/", - context=RequestContext("accounts.get_operations_history", allow_retry=True), - json_body=request.to_payload(), - ) - return map_operations_history(payload) + page_size = request.limit or 25 + base_offset = request.offset or 0 + + def fetch_page(page: int | None, _cursor: str | None) -> JsonPage[OperationRecord]: + current_page = page or 1 + current_offset = base_offset + (current_page - 1) * page_size + paged_request = OperationsHistoryRequest( + date_from=request.date_from, + date_to=request.date_to, + limit=page_size, + offset=current_offset, + ) + result = request_public_model( + self.transport, + "POST", + "/core/v1/accounts/operations_history/", + context=RequestContext("accounts.get_operations_history", allow_retry=True), + mapper=map_operations_history, + json_body=paged_request.to_payload(), + ) + return JsonPage( + items=result.operations, + total=result.total, + page=current_page, + per_page=page_size, + ) + + return Paginator(fetch_page).as_list(first_page=fetch_page(1, None)) @dataclass(slots=True) @@ -77,54 +101,79 @@ class HierarchyClient: def get_status(self) -> AhUserStatus: """Получает статус пользователя в ИА.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", "/checkAhUserV1", context=RequestContext("accounts.hierarchy.get_status"), + mapper=map_ah_user_status, ) - return map_ah_user_status(payload) def list_employees(self) -> EmployeesResult: """Получает список сотрудников иерархии.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", "/getEmployeesV1", context=RequestContext("accounts.hierarchy.list_employees"), + mapper=map_employees, ) - return map_employees(payload) def list_company_phones(self) -> CompanyPhonesResult: """Получает список телефонов компании.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", "/listCompanyPhonesV1", context=RequestContext("accounts.hierarchy.list_company_phones"), + mapper=map_company_phones, ) - return map_company_phones(payload) - def link_items(self, request: EmployeeItemLinkRequest) -> ActionResult: + def link_items(self, request: EmployeeItemLinkRequest) -> AccountActionResult: """Прикрепляет объявления к сотруднику.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", "/linkItemsV1", context=RequestContext("accounts.hierarchy.link_items", allow_retry=True), + mapper=map_action_result, json_body=request.to_payload(), ) - return map_action_result(payload) - def list_items_by_employee(self, request: EmployeeItemsRequest) -> EmployeeItemsResult: + def list_items_by_employee(self, request: EmployeeItemsRequest) -> PaginatedList[EmployeeItem]: """Получает список объявлений по сотруднику.""" - payload = self.transport.request_json( - "POST", - "/listItemsByEmployeeIdV1", - context=RequestContext("accounts.hierarchy.list_items_by_employee", allow_retry=True), - json_body=request.to_payload(), - ) - return map_employee_items(payload) + page_size = request.limit or 25 + + def fetch_page(page: int | None, _cursor: str | None) -> JsonPage[EmployeeItem]: + current_page = page or 1 + current_offset = (request.offset or 0) + (current_page - 1) * page_size + paged_request = EmployeeItemsRequest( + employee_id=request.employee_id, + limit=page_size, + offset=current_offset, + ) + result = request_public_model( + self.transport, + "POST", + "/listItemsByEmployeeIdV1", + context=RequestContext( + "accounts.hierarchy.list_items_by_employee", allow_retry=True + ), + mapper=map_employee_items, + json_body=paged_request.to_payload(), + ) + return JsonPage( + items=result.items, + total=result.total, + page=current_page, + per_page=page_size, + ) + + return Paginator(fetch_page).as_list(first_page=fetch_page(1, None)) __all__ = ("AccountsClient", "HierarchyClient") diff --git a/avito/accounts/domain.py b/avito/accounts/domain.py index b51fe83..74abbc4 100644 --- a/avito/accounts/domain.py +++ b/avito/accounts/domain.py @@ -4,36 +4,34 @@ from collections.abc import Sequence from dataclasses import dataclass +from datetime import datetime from avito.accounts.client import AccountsClient, HierarchyClient from avito.accounts.models import ( + AccountActionResult, AccountBalance, AccountProfile, - ActionResult, AhUserStatus, CompanyPhonesResult, + EmployeeItem, EmployeeItemLinkRequest, EmployeeItemsRequest, - EmployeeItemsResult, EmployeesResult, + OperationRecord, OperationsHistoryRequest, - OperationsHistoryResult, ) -from avito.core import Transport +from avito.core import PaginatedList, ValidationError +from avito.core.domain import DomainObject -@dataclass(slots=True, frozen=True) -class DomainObject: - """Базовый доменный объект раздела accounts.""" - - transport: Transport +def _serialize_datetime(value: datetime | None) -> str | None: + return value.isoformat() if value is not None else None @dataclass(slots=True, frozen=True) class Account(DomainObject): """Доменный объект операций аккаунта.""" - resource_id: int | str | None = None user_id: int | str | None = None def get_self(self) -> AccountProfile: @@ -46,23 +44,23 @@ def get_balance(self, user_id: int | None = None) -> AccountBalance: resolved_user_id = user_id or (int(self.user_id) if self.user_id is not None else None) if resolved_user_id is None: - raise ValueError("Для получения баланса требуется `user_id`.") + raise ValidationError("Для операции требуется `user_id`.") return AccountsClient(self.transport).get_balance(user_id=resolved_user_id) def get_operations_history( self, *, - date_from: str | None = None, - date_to: str | None = None, + date_from: datetime | None = None, + date_to: datetime | None = None, limit: int | None = None, offset: int | None = None, - ) -> OperationsHistoryResult: + ) -> PaginatedList[OperationRecord]: """Получает историю операций пользователя.""" return AccountsClient(self.transport).get_operations_history( OperationsHistoryRequest( - date_from=date_from, - date_to=date_to, + date_from=_serialize_datetime(date_from), + date_to=_serialize_datetime(date_to), limit=limit, offset=offset, ) @@ -73,7 +71,6 @@ def get_operations_history( class AccountHierarchy(DomainObject): """Доменный объект иерархии аккаунтов.""" - resource_id: int | str | None = None user_id: int | str | None = None def get_status(self) -> AhUserStatus: @@ -97,7 +94,7 @@ def link_items( employee_id: int, item_ids: Sequence[int], source_employee_id: int | None = None, - ) -> ActionResult: + ) -> AccountActionResult: """Прикрепляет объявления к сотруднику.""" return HierarchyClient(self.transport).link_items( @@ -114,7 +111,7 @@ def list_items_by_employee( employee_id: int, limit: int | None = None, offset: int | None = None, - ) -> EmployeeItemsResult: + ) -> PaginatedList[EmployeeItem]: """Получает список объявлений сотрудника.""" return HierarchyClient(self.transport).list_items_by_employee( @@ -122,4 +119,4 @@ def list_items_by_employee( ) -__all__ = ("DomainObject", "Account", "AccountHierarchy") +__all__ = ("Account", "AccountHierarchy") diff --git a/avito/accounts/enums.py b/avito/accounts/enums.py deleted file mode 100644 index 6990484..0000000 --- a/avito/accounts/enums.py +++ /dev/null @@ -1 +0,0 @@ -"""Строковые enum и константы пакета accounts.""" diff --git a/avito/accounts/mappers.py b/avito/accounts/mappers.py index 11d8882..418495a 100644 --- a/avito/accounts/mappers.py +++ b/avito/accounts/mappers.py @@ -3,12 +3,13 @@ from __future__ import annotations from collections.abc import Mapping +from datetime import datetime from typing import cast from avito.accounts.models import ( + AccountActionResult, AccountBalance, AccountProfile, - ActionResult, AhUserStatus, CompanyPhone, CompanyPhonesResult, @@ -46,6 +47,18 @@ def _as_str(payload: Payload, *keys: str) -> str | None: return None +def _as_datetime(payload: Payload, *keys: str) -> datetime | None: + for key in keys: + value = payload.get(key) + if isinstance(value, str): + normalized = value.replace("Z", "+00:00") + try: + return datetime.fromisoformat(normalized) + except ValueError: + continue + return None + + def _as_int(payload: Payload, *keys: str) -> int | None: for key in keys: value = payload.get(key) @@ -79,11 +92,10 @@ def map_account_profile(payload: object) -> AccountProfile: data = _expect_mapping(payload) return AccountProfile( - id=_as_int(data, "id", "user_id"), + user_id=_as_int(data, "id", "user_id"), name=_as_str(data, "name", "title"), email=_as_str(data, "email"), phone=_as_str(data, "phone"), - raw_payload=data, ) @@ -105,7 +117,6 @@ def map_account_balance(payload: object) -> AccountBalance: bonus=bonus, total=total, currency=_as_str(wallet_data, "currency"), - raw_payload=data, ) @@ -116,19 +127,17 @@ def map_operations_history(payload: object) -> OperationsHistoryResult: operations = [ OperationRecord( id=_as_str(item, "id", "operation_id"), - created_at=_as_str(item, "created_at", "createdAt", "date"), + created_at=_as_datetime(item, "created_at", "createdAt", "date"), amount=_as_float(item, "amount", "price", "sum"), operation_type=_as_str(item, "type", "operation_type", "operationType"), status=_as_str(item, "status"), description=_as_str(item, "description", "title"), - raw_payload=item, ) for item in _as_list(data, "operations", "items", "result") ] return OperationsHistoryResult( operations=operations, total=_as_int(data, "total", "count"), - raw_payload=data, ) @@ -140,7 +149,6 @@ def map_ah_user_status(payload: object) -> AhUserStatus: user_id=_as_int(data, "user_id", "userId", "id"), is_active=_as_bool(data, "is_active", "isActive", "active"), role=_as_str(data, "role", "status"), - raw_payload=data, ) @@ -155,11 +163,10 @@ def map_employees(payload: object) -> EmployeesResult: name=_as_str(item, "name", "title"), phone=_as_str(item, "phone"), email=_as_str(item, "email"), - raw_payload=item, ) for item in _as_list(data, "employees", "items", "result") ] - return EmployeesResult(items=items, total=_as_int(data, "total", "count"), raw_payload=data) + return EmployeesResult(items=items, total=_as_int(data, "total", "count")) def map_company_phones(payload: object) -> CompanyPhonesResult: @@ -168,14 +175,13 @@ def map_company_phones(payload: object) -> CompanyPhonesResult: data = _expect_mapping(payload) items = [ CompanyPhone( - id=_as_int(item, "id", "phone_id", "phoneId"), + phone_id=_as_int(item, "id", "phone_id", "phoneId"), phone=_as_str(item, "phone", "value"), comment=_as_str(item, "comment", "description"), - raw_payload=item, ) for item in _as_list(data, "phones", "items", "result") ] - return CompanyPhonesResult(items=items, raw_payload=data) + return CompanyPhonesResult(items=items) def map_employee_items(payload: object) -> EmployeeItemsResult: @@ -188,22 +194,21 @@ def map_employee_items(payload: object) -> EmployeeItemsResult: title=_as_str(item, "title"), status=_as_str(item, "status"), price=_as_float(item, "price"), - raw_payload=item, ) for item in _as_list(data, "items", "result") ] - return EmployeeItemsResult(items=items, total=_as_int(data, "total", "count"), raw_payload=data) + return EmployeeItemsResult(items=items, total=_as_int(data, "total", "count")) -def map_action_result(payload: object) -> ActionResult: +def map_action_result(payload: object) -> AccountActionResult: """Преобразует ответ мутационной операции в dataclass.""" if isinstance(payload, Mapping): data = cast(Payload, payload) success = bool(data.get("success", True)) message = _as_str(data, "message", "status") - return ActionResult(success=success, message=message, raw_payload=data) - return ActionResult(success=True, raw_payload={}) + return AccountActionResult(success=success, message=message) + return AccountActionResult(success=True) __all__ = ( diff --git a/avito/accounts/models.py b/avito/accounts/models.py index 0383da7..8bf83e3 100644 --- a/avito/accounts/models.py +++ b/avito/accounts/models.py @@ -2,23 +2,24 @@ from __future__ import annotations -from collections.abc import Mapping -from dataclasses import dataclass, field +from dataclasses import dataclass +from datetime import datetime + +from avito.core.serialization import SerializableModel @dataclass(slots=True, frozen=True) -class AccountProfile: +class AccountProfile(SerializableModel): """Профиль авторизованного пользователя.""" - id: int | None + user_id: int | None name: str | None email: str | None phone: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class AccountBalance: +class AccountBalance(SerializableModel): """Баланс кошелька пользователя.""" user_id: int | None @@ -26,20 +27,18 @@ class AccountBalance: bonus: float | None total: float | None currency: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class OperationRecord: +class OperationRecord(SerializableModel): """Операция по аккаунту.""" id: str | None - created_at: str | None + created_at: datetime | None amount: float | None operation_type: str | None status: str | None description: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -67,26 +66,24 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class OperationsHistoryResult: +class OperationsHistoryResult(SerializableModel): """История операций пользователя.""" operations: list[OperationRecord] total: int | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class AhUserStatus: +class AhUserStatus(SerializableModel): """Статус пользователя в иерархии аккаунтов.""" user_id: int | None is_active: bool | None role: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class Employee: +class Employee(SerializableModel): """Сотрудник иерархии аккаунтов.""" employee_id: int | None @@ -94,34 +91,30 @@ class Employee: name: str | None phone: str | None email: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class EmployeesResult: +class EmployeesResult(SerializableModel): """Список сотрудников иерархии.""" items: list[Employee] total: int | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class CompanyPhone: +class CompanyPhone(SerializableModel): """Телефон компании.""" - id: int | None + phone_id: int | None phone: str | None comment: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class CompanyPhonesResult: +class CompanyPhonesResult(SerializableModel): """Список телефонов компании.""" items: list[CompanyPhone] - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -169,38 +162,35 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class EmployeeItem: +class EmployeeItem(SerializableModel): """Объявление сотрудника в иерархии.""" item_id: int | None title: str | None status: str | None price: float | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class EmployeeItemsResult: +class EmployeeItemsResult(SerializableModel): """Список объявлений сотрудника.""" items: list[EmployeeItem] total: int | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class ActionResult: +class AccountActionResult(SerializableModel): """Результат мутационной операции accounts.""" success: bool message: str | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) __all__ = ( + "AccountActionResult", "AccountBalance", "AccountProfile", - "ActionResult", "AhUserStatus", "CompanyPhone", "CompanyPhonesResult", diff --git a/avito/ads/__init__.py b/avito/ads/__init__.py index 68f3e29..3375431 100644 --- a/avito/ads/__init__.py +++ b/avito/ads/__init__.py @@ -4,14 +4,13 @@ Ad, AdPromotion, AdStats, - AutoloadLegacy, + AutoloadArchive, AutoloadProfile, AutoloadReport, - DomainObject, ) from avito.ads.models import ( - ActionResult, - AdItem, + AccountSpendings, + AdsActionResult, AdsListResult, AutoloadFee, AutoloadFeesResult, @@ -26,11 +25,13 @@ AutoloadTreeNode, AutoloadTreeResult, CallsStatsResult, - CallStat, + CallStats, ItemAnalyticsResult, ItemStatsResult, LegacyAutoloadReport, - SpendingsResult, + Listing, + ListingStats, + SpendingRecord, UpdatePriceResult, UploadResult, VasApplyResult, @@ -38,17 +39,17 @@ ) __all__ = ( - "ActionResult", + "AccountSpendings", "Ad", - "AdItem", + "AdsActionResult", + "AdsListResult", "AdPromotion", "AdStats", - "AdsListResult", + "AutoloadArchive", "AutoloadFee", "AutoloadFeesResult", "AutoloadField", "AutoloadFieldsResult", - "AutoloadLegacy", "AutoloadProfile", "AutoloadProfileSettings", "AutoloadReport", @@ -59,13 +60,14 @@ "AutoloadReportsResult", "AutoloadTreeNode", "AutoloadTreeResult", - "CallStat", + "CallStats", "CallsStatsResult", - "DomainObject", "ItemAnalyticsResult", "ItemStatsResult", "LegacyAutoloadReport", - "SpendingsResult", + "Listing", + "ListingStats", + "SpendingRecord", "UpdatePriceResult", "UploadResult", "VasApplyResult", diff --git a/avito/ads/client.py b/avito/ads/client.py index a2f6b05..2991767 100644 --- a/avito/ads/client.py +++ b/avito/ads/client.py @@ -23,13 +23,11 @@ map_spendings, map_update_price_result, map_upload_result, - map_vas_apply_result, map_vas_prices, ) from avito.ads.models import ( - ActionResult, - AdItem, - AdsListResult, + AccountSpendings, + AdsActionResult, ApplyVasPackageRequest, ApplyVasRequest, AutoloadFeesResult, @@ -38,7 +36,7 @@ AutoloadProfileUpdateRequest, AutoloadReportDetails, AutoloadReportItemsResult, - AutoloadReportsResult, + AutoloadReportSummary, AutoloadTreeResult, CallsStatsRequest, CallsStatsResult, @@ -47,16 +45,25 @@ ItemStatsRequest, ItemStatsResult, LegacyAutoloadReport, - SpendingsResult, + Listing, UpdatePriceRequest, UpdatePriceResult, UploadByUrlRequest, UploadResult, - VasApplyResult, VasPricesRequest, VasPricesResult, ) -from avito.core import JsonPage, Paginator, RequestContext, Transport +from avito.core import ( + JsonPage, + PaginatedList, + Paginator, + RequestContext, + Transport, + ValidationError, +) +from avito.core.mapping import request_public_model +from avito.promotion.mappers import map_promotion_action +from avito.promotion.models import PromotionActionResult @dataclass(slots=True) @@ -65,15 +72,16 @@ class AdsClient: transport: Transport - def get_item(self, *, user_id: int, item_id: int) -> AdItem: + def get_item(self, *, user_id: int, item_id: int) -> Listing: """Получает одно объявление.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", f"/core/v1/accounts/{user_id}/items/{item_id}/", context=RequestContext("ads.get_item"), + mapper=map_ad_item, ) - return map_ad_item(payload) def list_items( self, @@ -82,14 +90,16 @@ def list_items( status: str | None = None, limit: int | None = None, offset: int | None = None, - ) -> AdsListResult: + ) -> PaginatedList[Listing]: """Получает список объявлений пользователя.""" start_offset = offset if offset is not None else 0 if limit is not None else None - payload = self.transport.request_json( + result = request_public_model( + self.transport, "GET", "/core/v1/items", context=RequestContext("ads.list_items"), + mapper=map_ads_list, params={ "user_id": user_id, "status": status, @@ -97,32 +107,23 @@ def list_items( "offset": start_offset, }, ) - result = map_ads_list(payload) page_size = limit if limit and limit > 0 else len(result.items) resolved_offset = start_offset or 0 - - if result.total is None or page_size <= 0 or resolved_offset + len(result.items) >= result.total: - return result - - start_page = resolved_offset // page_size + 1 - paginator = Paginator( + start_page = resolved_offset // page_size + 1 if page_size > 0 else 1 + first_page = JsonPage( + items=list(result.items), + total=result.total, + page=start_page, + per_page=page_size if page_size > 0 else None, + ) + return Paginator( lambda page, cursor: self._fetch_ads_page( page=page, user_id=user_id, status=status, page_size=page_size, ) - ) - paginated_items = paginator.as_list( - start_page=start_page, - first_page=JsonPage( - items=list(result.items), - total=result.total, - page=start_page, - per_page=page_size, - ), - ) - return AdsListResult(items=paginated_items, total=result.total, raw_payload=result.raw_payload) + ).as_list(start_page=start_page, first_page=first_page) def _fetch_ads_page( self, @@ -131,15 +132,17 @@ def _fetch_ads_page( user_id: int | None, status: str | None, page_size: int, - ) -> JsonPage[AdItem]: + ) -> JsonPage[Listing]: if page is None: - raise ValueError("Постраничная загрузка объявлений требует номера страницы.") + raise ValidationError("Для операции требуется `page`.") offset = (page - 1) * page_size - payload = self.transport.request_json( + result = request_public_model( + self.transport, "GET", "/core/v1/items", context=RequestContext("ads.list_items"), + mapper=map_ads_list, params={ "user_id": user_id, "status": status, @@ -147,7 +150,6 @@ def _fetch_ads_page( "offset": offset, }, ) - result = map_ads_list(payload) return JsonPage( items=list(result.items), total=result.total, @@ -158,13 +160,14 @@ def _fetch_ads_page( def update_price(self, *, item_id: int, price: UpdatePriceRequest) -> UpdatePriceResult: """Обновляет цену объявления.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", f"/core/v1/items/{item_id}/update_price", context=RequestContext("ads.update_price", allow_retry=True), + mapper=map_update_price_result, json_body=price.to_payload(), ) - return map_update_price_result(payload) @dataclass(slots=True) @@ -176,46 +179,50 @@ class StatsClient: def get_calls_stats(self, *, user_id: int, request: CallsStatsRequest) -> CallsStatsResult: """Получает статистику звонков.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", f"/core/v1/accounts/{user_id}/calls/stats/", context=RequestContext("ads.stats.calls", allow_retry=True), + mapper=map_calls_stats, json_body=request.to_payload(), ) - return map_calls_stats(payload) def get_item_stats(self, *, user_id: int, request: ItemStatsRequest) -> ItemStatsResult: """Получает статистику по списку объявлений.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", f"/stats/v1/accounts/{user_id}/items", context=RequestContext("ads.stats.items", allow_retry=True), + mapper=map_item_stats, json_body=request.to_payload(), ) - return map_item_stats(payload) def get_item_analytics(self, *, user_id: int, request: ItemStatsRequest) -> ItemAnalyticsResult: """Получает аналитику по профилю.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", f"/stats/v2/accounts/{user_id}/items", context=RequestContext("ads.stats.analytics", allow_retry=True), + mapper=map_item_analytics, json_body=request.to_payload(), ) - return map_item_analytics(payload) - def get_account_spendings(self, *, user_id: int, request: ItemStatsRequest) -> SpendingsResult: + def get_account_spendings(self, *, user_id: int, request: ItemStatsRequest) -> AccountSpendings: """Получает статистику расходов профиля.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", f"/stats/v2/accounts/{user_id}/spendings", context=RequestContext("ads.stats.spendings", allow_retry=True), + mapper=map_spendings, json_body=request.to_payload(), ) - return map_spendings(payload) @dataclass(slots=True) @@ -227,50 +234,82 @@ class VasClient: def get_prices(self, *, user_id: int, request: VasPricesRequest) -> VasPricesResult: """Получает цены VAS и доступные услуги продвижения.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", f"/core/v1/accounts/{user_id}/vas/prices", context=RequestContext("ads.vas.prices", allow_retry=True), + mapper=map_vas_prices, json_body=request.to_payload(), ) - return map_vas_prices(payload) def apply_item_vas( - self, *, user_id: int, item_id: int, request: ApplyVasRequest - ) -> ActionResult: + self, + *, + user_id: int, + item_id: int, + request: ApplyVasRequest, + ) -> PromotionActionResult: """Применяет дополнительные услуги к объявлению.""" + payload_to_send = request.to_payload() payload = self.transport.request_json( "PUT", f"/core/v1/accounts/{user_id}/items/{item_id}/vas", context=RequestContext("ads.vas.apply_item_vas", allow_retry=True), - json_body=request.to_payload(), + json_body=payload_to_send, + ) + return map_promotion_action( + payload, + action="apply_vas", + target={"item_id": item_id, "user_id": user_id}, + request_payload=payload_to_send, ) - return map_action_result(payload) def apply_item_vas_package( - self, *, user_id: int, item_id: int, request: ApplyVasPackageRequest - ) -> ActionResult: + self, + *, + user_id: int, + item_id: int, + request: ApplyVasPackageRequest, + ) -> PromotionActionResult: """Применяет пакет дополнительных услуг.""" + payload_to_send = request.to_payload() payload = self.transport.request_json( "PUT", f"/core/v2/accounts/{user_id}/items/{item_id}/vas_packages", context=RequestContext("ads.vas.apply_item_vas_package", allow_retry=True), - json_body=request.to_payload(), + json_body=payload_to_send, + ) + return map_promotion_action( + payload, + action="apply_vas_package", + target={"item_id": item_id, "user_id": user_id}, + request_payload=payload_to_send, ) - return map_action_result(payload) - def apply_vas_v2(self, *, item_id: int, request: ApplyVasRequest) -> VasApplyResult: + def apply_vas_direct( + self, + *, + item_id: int, + request: ApplyVasRequest, + ) -> PromotionActionResult: """Применяет услуги продвижения через v2 endpoint.""" + payload_to_send = request.to_payload() payload = self.transport.request_json( "PUT", f"/core/v2/items/{item_id}/vas/", - context=RequestContext("ads.vas.apply_v2", allow_retry=True), - json_body=request.to_payload(), + context=RequestContext("ads.vas.apply_direct", allow_retry=True), + json_body=payload_to_send, + ) + return map_promotion_action( + payload, + action="apply_vas_direct", + target={"item_id": item_id}, + request_payload=payload_to_send, ) - return map_vas_apply_result(payload) @dataclass(slots=True) @@ -282,188 +321,219 @@ class AutoloadClient: def get_profile(self) -> AutoloadProfileSettings: """Получает профиль пользователя автозагрузки.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", "/autoload/v2/profile", context=RequestContext("ads.autoload.get_profile"), + mapper=map_autoload_profile, ) - return map_autoload_profile(payload) - def save_profile(self, request: AutoloadProfileUpdateRequest) -> ActionResult: + def save_profile(self, request: AutoloadProfileUpdateRequest) -> AdsActionResult: """Создает или редактирует профиль автозагрузки.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", "/autoload/v2/profile", context=RequestContext("ads.autoload.save_profile", allow_retry=True), + mapper=map_action_result, json_body=request.to_payload(), ) - return map_action_result(payload) def upload_by_url(self, request: UploadByUrlRequest) -> UploadResult: """Запускает загрузку файла по ссылке.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", "/autoload/v1/upload", context=RequestContext("ads.autoload.upload_by_url", allow_retry=True), + mapper=map_upload_result, json_body=request.to_payload(), ) - return map_upload_result(payload) def get_node_fields(self, *, node_slug: str) -> AutoloadFieldsResult: """Получает поля категории по slug.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", f"/autoload/v1/user-docs/node/{node_slug}/fields", context=RequestContext("ads.autoload.get_node_fields"), + mapper=map_autoload_fields, ) - return map_autoload_fields(payload) def get_tree(self) -> AutoloadTreeResult: """Получает дерево категорий автозагрузки.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", "/autoload/v1/user-docs/tree", context=RequestContext("ads.autoload.get_tree"), + mapper=map_autoload_tree, ) - return map_autoload_tree(payload) def get_ad_ids_by_avito_ids(self, *, avito_ids: list[int]) -> IdMappingResult: """Получает ad ids по avito ids.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", "/autoload/v2/items/ad_ids", context=RequestContext("ads.autoload.get_ad_ids_by_avito_ids"), + mapper=map_id_mapping, params={"avito_ids": ",".join(str(item) for item in avito_ids)}, ) - return map_id_mapping(payload) def get_avito_ids_by_ad_ids(self, *, ad_ids: list[int]) -> IdMappingResult: """Получает avito ids по ad ids.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", "/autoload/v2/items/avito_ids", context=RequestContext("ads.autoload.get_avito_ids_by_ad_ids"), + mapper=map_id_mapping, params={"ad_ids": ",".join(str(item) for item in ad_ids)}, ) - return map_id_mapping(payload) def list_reports( self, *, limit: int | None = None, offset: int | None = None - ) -> AutoloadReportsResult: + ) -> PaginatedList[AutoloadReportSummary]: """Получает список отчетов автозагрузки.""" - payload = self.transport.request_json( - "GET", - "/autoload/v2/reports", - context=RequestContext("ads.autoload.list_reports"), - params={"limit": limit, "offset": offset}, - ) - return map_autoload_reports(payload) + page_size = limit or 25 + base_offset = offset or 0 + + def fetch_page(page: int | None, _cursor: str | None) -> JsonPage[AutoloadReportSummary]: + current_page = page or 1 + current_offset = base_offset + (current_page - 1) * page_size + result = request_public_model( + self.transport, + "GET", + "/autoload/v2/reports", + context=RequestContext("ads.autoload.list_reports"), + mapper=map_autoload_reports, + params={"limit": page_size, "offset": current_offset}, + ) + return JsonPage( + items=result.items, + total=result.total, + page=current_page, + per_page=page_size, + ) + + return Paginator(fetch_page).as_list(first_page=fetch_page(1, None)) def get_items_info(self, *, item_ids: list[int]) -> AutoloadReportItemsResult: """Получает объявления автозагрузки по ID.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", "/autoload/v2/reports/items", context=RequestContext("ads.autoload.get_items_info"), + mapper=map_autoload_report_items, params={"item_ids": ",".join(str(item) for item in item_ids)}, ) - return map_autoload_report_items(payload) def get_report(self, *, report_id: int) -> AutoloadReportDetails: """Получает статистику по конкретной выгрузке v3.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", f"/autoload/v3/reports/{report_id}", context=RequestContext("ads.autoload.get_report"), + mapper=map_autoload_report_details, ) - return map_autoload_report_details(payload) def get_last_completed_report(self) -> AutoloadReportDetails: """Получает статистику по последней выгрузке v3.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", "/autoload/v3/reports/last_completed_report", context=RequestContext("ads.autoload.get_last_completed_report"), + mapper=map_autoload_report_details, ) - return map_autoload_report_details(payload) def get_report_items(self, *, report_id: int) -> AutoloadReportItemsResult: """Получает все объявления из конкретной выгрузки.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", f"/autoload/v2/reports/{report_id}/items", context=RequestContext("ads.autoload.get_report_items"), + mapper=map_autoload_report_items, ) - return map_autoload_report_items(payload) def get_report_fees(self, *, report_id: int) -> AutoloadFeesResult: """Получает списания по объявлениям выгрузки.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", f"/autoload/v2/reports/{report_id}/items/fees", context=RequestContext("ads.autoload.get_report_fees"), + mapper=map_autoload_fees, ) - return map_autoload_fees(payload) @dataclass(slots=True) -class AutoloadLegacyClient: - """Выполняет legacy HTTP-операции автозагрузки.""" +class AutoloadArchiveClient: + """Выполняет архивные HTTP-операции автозагрузки.""" transport: Transport def get_profile(self) -> AutoloadProfileSettings: - """Получает legacy профиль автозагрузки.""" + """Получает архивный профиль автозагрузки.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", "/autoload/v1/profile", - context=RequestContext("ads.autoload_legacy.get_profile"), + context=RequestContext("ads.autoload_archive.get_profile"), + mapper=map_autoload_profile, ) - return map_autoload_profile(payload) - def save_profile(self, request: AutoloadProfileUpdateRequest) -> ActionResult: - """Создает или редактирует legacy профиль автозагрузки.""" + def save_profile(self, request: AutoloadProfileUpdateRequest) -> AdsActionResult: + """Создает или редактирует архивный профиль автозагрузки.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", "/autoload/v1/profile", - context=RequestContext("ads.autoload_legacy.save_profile", allow_retry=True), + context=RequestContext("ads.autoload_archive.save_profile", allow_retry=True), + mapper=map_action_result, json_body=request.to_payload(), ) - return map_action_result(payload) def get_last_completed_report(self) -> LegacyAutoloadReport: - """Получает статистику по последней выгрузке legacy v2.""" + """Получает статистику по последней архивной выгрузке.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", "/autoload/v2/reports/last_completed_report", - context=RequestContext("ads.autoload_legacy.get_last_completed_report"), + context=RequestContext("ads.autoload_archive.get_last_completed_report"), + mapper=map_legacy_autoload_report, ) - return map_legacy_autoload_report(payload) def get_report(self, *, report_id: int) -> LegacyAutoloadReport: - """Получает статистику по конкретной выгрузке legacy v2.""" + """Получает статистику по конкретной архивной выгрузке.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", f"/autoload/v2/reports/{report_id}", - context=RequestContext("ads.autoload_legacy.get_report"), + context=RequestContext("ads.autoload_archive.get_report"), + mapper=map_legacy_autoload_report, ) - return map_legacy_autoload_report(payload) -__all__ = ("AdsClient", "AutoloadClient", "AutoloadLegacyClient", "StatsClient", "VasClient") +__all__ = ("AdsClient", "AutoloadArchiveClient", "AutoloadClient", "StatsClient", "VasClient") diff --git a/avito/ads/domain.py b/avito/ads/domain.py index 9a9011b..837f65f 100644 --- a/avito/ads/domain.py +++ b/avito/ads/domain.py @@ -4,12 +4,18 @@ from collections.abc import Sequence from dataclasses import dataclass - -from avito.ads.client import AdsClient, AutoloadClient, AutoloadLegacyClient, StatsClient, VasClient +from datetime import datetime + +from avito.ads.client import ( + AdsClient, + AutoloadArchiveClient, + AutoloadClient, + StatsClient, + VasClient, +) from avito.ads.models import ( - ActionResult, - AdItem, - AdsListResult, + AccountSpendings, + AdsActionResult, ApplyVasPackageRequest, ApplyVasRequest, AutoloadFeesResult, @@ -18,7 +24,7 @@ AutoloadProfileUpdateRequest, AutoloadReportDetails, AutoloadReportItemsResult, - AutoloadReportsResult, + AutoloadReportSummary, AutoloadTreeResult, CallsStatsRequest, CallsStatsResult, @@ -27,33 +33,51 @@ ItemStatsRequest, ItemStatsResult, LegacyAutoloadReport, - SpendingsResult, + Listing, UpdatePriceRequest, UpdatePriceResult, UploadByUrlRequest, UploadResult, - VasApplyResult, VasPricesRequest, VasPricesResult, ) -from avito.core import Transport +from avito.core import PaginatedList, ValidationError +from avito.core.domain import DomainObject +from avito.core.validation import ( + validate_non_empty_string, + validate_string_items, +) +from avito.promotion.models import PromotionActionResult -@dataclass(slots=True, frozen=True) -class DomainObject: - """Базовый доменный объект раздела ads.""" +def _preview_result( + *, + action: str, + target: dict[str, object], + request_payload: dict[str, object], +) -> PromotionActionResult: + return PromotionActionResult( + action=action, + target=target, + status="preview", + applied=False, + request_payload=request_payload, + details={"validated": True}, + ) - transport: Transport + +def _serialize_datetime(value: datetime | None) -> str | None: + return value.isoformat() if value is not None else None @dataclass(slots=True, frozen=True) class Ad(DomainObject): """Доменный объект объявления.""" - resource_id: int | str | None = None + item_id: int | str | None = None user_id: int | str | None = None - def get(self) -> AdItem: + def get(self) -> Listing: """Получает объявление по `item_id`.""" item_id, user_id = self._require_ids() @@ -61,7 +85,7 @@ def get(self) -> AdItem: def list( self, *, status: str | None = None, limit: int | None = None, offset: int | None = None - ) -> AdsListResult: + ) -> PaginatedList[Listing]: """Получает список объявлений.""" user_id = int(self.user_id) if self.user_id is not None else None @@ -77,61 +101,43 @@ def update_price(self, *, price: int | float) -> UpdatePriceResult: item_id=item_id, price=UpdatePriceRequest(price=price) ) - def get_stats( - self, - *, - date_from: str | None = None, - date_to: str | None = None, - fields: Sequence[str] | None = None, - ) -> ItemStatsResult: - """Получает статистику текущего объявления.""" - - item_id, user_id = self._require_ids() - return StatsClient(self.transport).get_item_stats( - user_id=user_id, - request=ItemStatsRequest( - item_ids=[item_id], - date_from=date_from, - date_to=date_to, - fields=list(fields or []), - ), - ) - def _require_item_id(self) -> int: - if self.resource_id is None: - raise ValueError("Для операции требуется `item_id`.") - return int(self.resource_id) + if self.item_id is None: + raise ValidationError("Для операции требуется `item_id`.") + return int(self.item_id) def _require_ids(self) -> tuple[int, int]: - if self.resource_id is None or self.user_id is None: - raise ValueError("Для операции требуются `item_id` и `user_id`.") - return int(self.resource_id), int(self.user_id) + if self.item_id is None or self.user_id is None: + raise ValidationError("Для операции требуются `item_id` и `user_id`.") + return int(self.item_id), int(self.user_id) @dataclass(slots=True, frozen=True) class AdStats(DomainObject): """Доменный объект статистики объявлений.""" - resource_id: int | str | None = None + item_id: int | str | None = None user_id: int | str | None = None def get_calls_stats( self, *, item_ids: list[int] | None = None, - date_from: str | None = None, - date_to: str | None = None, + date_from: datetime | None = None, + date_to: datetime | None = None, ) -> CallsStatsResult: """Получает статистику звонков.""" user_id = self._require_user_id() resolved_item_ids = item_ids or ( - [int(self.resource_id)] if self.resource_id is not None else [] + [int(self.item_id)] if self.item_id is not None else [] ) return StatsClient(self.transport).get_calls_stats( user_id=user_id, request=CallsStatsRequest( - item_ids=resolved_item_ids, date_from=date_from, date_to=date_to + item_ids=resolved_item_ids, + date_from=_serialize_datetime(date_from), + date_to=_serialize_datetime(date_to), ), ) @@ -139,22 +145,22 @@ def get_item_stats( self, *, item_ids: list[int] | None = None, - date_from: str | None = None, - date_to: str | None = None, + date_from: datetime | None = None, + date_to: datetime | None = None, fields: list[str] | None = None, ) -> ItemStatsResult: """Получает статистику по списку объявлений.""" user_id = self._require_user_id() resolved_item_ids = item_ids or ( - [int(self.resource_id)] if self.resource_id is not None else [] + [int(self.item_id)] if self.item_id is not None else [] ) return StatsClient(self.transport).get_item_stats( user_id=user_id, request=ItemStatsRequest( item_ids=resolved_item_ids, - date_from=date_from, - date_to=date_to, + date_from=_serialize_datetime(date_from), + date_to=_serialize_datetime(date_to), fields=fields or [], ), ) @@ -163,22 +169,22 @@ def get_item_analytics( self, *, item_ids: list[int] | None = None, - date_from: str | None = None, - date_to: str | None = None, + date_from: datetime | None = None, + date_to: datetime | None = None, fields: list[str] | None = None, ) -> ItemAnalyticsResult: """Получает аналитику по профилю.""" user_id = self._require_user_id() resolved_item_ids = item_ids or ( - [int(self.resource_id)] if self.resource_id is not None else [] + [int(self.item_id)] if self.item_id is not None else [] ) return StatsClient(self.transport).get_item_analytics( user_id=user_id, request=ItemStatsRequest( item_ids=resolved_item_ids, - date_from=date_from, - date_to=date_to, + date_from=_serialize_datetime(date_from), + date_to=_serialize_datetime(date_to), fields=fields or [], ), ) @@ -187,29 +193,29 @@ def get_account_spendings( self, *, item_ids: list[int] | None = None, - date_from: str | None = None, - date_to: str | None = None, + date_from: datetime | None = None, + date_to: datetime | None = None, fields: list[str] | None = None, - ) -> SpendingsResult: + ) -> AccountSpendings: """Получает статистику расходов профиля.""" user_id = self._require_user_id() resolved_item_ids = item_ids or ( - [int(self.resource_id)] if self.resource_id is not None else [] + [int(self.item_id)] if self.item_id is not None else [] ) return StatsClient(self.transport).get_account_spendings( user_id=user_id, request=ItemStatsRequest( item_ids=resolved_item_ids, - date_from=date_from, - date_to=date_to, + date_from=_serialize_datetime(date_from), + date_to=_serialize_datetime(date_to), fields=fields or [], ), ) def _require_user_id(self) -> int: if self.user_id is None: - raise ValueError("Для статистики требуется `user_id`.") + raise ValidationError("Для операции требуется `user_id`.") return int(self.user_id) @@ -217,7 +223,7 @@ def _require_user_id(self) -> int: class AdPromotion(DomainObject): """Доменный объект продвижения объявления.""" - resource_id: int | str | None = None + item_id: int | str | None = None user_id: int | str | None = None def get_vas_prices( @@ -231,42 +237,88 @@ def get_vas_prices( request=VasPricesRequest(item_ids=item_ids, location_id=location_id), ) - def apply_vas(self, *, codes: list[str]) -> ActionResult: + def apply_vas( + self, + *, + codes: list[str], + dry_run: bool = False, + ) -> PromotionActionResult: """Применяет дополнительные услуги к объявлению.""" item_id, user_id = self._require_ids() + validate_string_items("codes", codes) + request = ApplyVasRequest(codes=codes) + request_payload = request.to_payload() + target: dict[str, object] = {"item_id": item_id, "user_id": user_id} + if dry_run: + return _preview_result( + action="apply_vas", + target=target, + request_payload=request_payload, + ) return VasClient(self.transport).apply_item_vas( user_id=user_id, item_id=item_id, - request=ApplyVasRequest(codes=codes), + request=request, ) - def apply_vas_package(self, *, package_code: str) -> ActionResult: + def apply_vas_package( + self, + *, + package_code: str, + dry_run: bool = False, + ) -> PromotionActionResult: """Применяет пакет дополнительных услуг.""" item_id, user_id = self._require_ids() + validate_non_empty_string("package_code", package_code) + request = ApplyVasPackageRequest(package_code=package_code) + request_payload = request.to_payload() + target: dict[str, object] = {"item_id": item_id, "user_id": user_id} + if dry_run: + return _preview_result( + action="apply_vas_package", + target=target, + request_payload=request_payload, + ) return VasClient(self.transport).apply_item_vas_package( user_id=user_id, item_id=item_id, - request=ApplyVasPackageRequest(package_code=package_code), + request=request, ) - def apply_vas_v2(self, *, codes: list[str]) -> VasApplyResult: - """Применяет услуги продвижения через v2 endpoint.""" + def apply_vas_direct( + self, + *, + codes: list[str], + dry_run: bool = False, + ) -> PromotionActionResult: + """Применяет услуги продвижения через прямой v2 endpoint.""" item_id = self._require_item_id() - return VasClient(self.transport).apply_vas_v2( - item_id=item_id, request=ApplyVasRequest(codes=codes) + validate_string_items("codes", codes) + request = ApplyVasRequest(codes=codes) + request_payload = request.to_payload() + target: dict[str, object] = {"item_id": item_id} + if dry_run: + return _preview_result( + action="apply_vas_direct", + target=target, + request_payload=request_payload, + ) + return VasClient(self.transport).apply_vas_direct( + item_id=item_id, + request=request, ) def _require_item_id(self) -> int: - if self.resource_id is None: - raise ValueError("Для операции требуется `item_id`.") - return int(self.resource_id) + if self.item_id is None: + raise ValidationError("Для операции требуется `item_id`.") + return int(self.item_id) def _require_user_id(self) -> int: if self.user_id is None: - raise ValueError("Для операции требуется `user_id`.") + raise ValidationError("Для операции требуется `user_id`.") return int(self.user_id) def _require_ids(self) -> tuple[int, int]: @@ -277,7 +329,6 @@ def _require_ids(self) -> tuple[int, int]: class AutoloadProfile(DomainObject): """Доменный объект профиля автозагрузки.""" - resource_id: int | str | None = None user_id: int | str | None = None def get(self) -> AutoloadProfileSettings: @@ -291,7 +342,7 @@ def save( is_enabled: bool | None = None, email: str | None = None, callback_url: str | None = None, - ) -> ActionResult: + ) -> AdsActionResult: """Сохраняет профиль автозагрузки.""" return AutoloadClient(self.transport).save_profile( @@ -320,8 +371,7 @@ def get_node_fields(self, *, node_slug: str) -> AutoloadFieldsResult: class AutoloadReport(DomainObject): """Доменный объект отчета автозагрузки.""" - resource_id: int | str | None = None - user_id: int | str | None = None + report_id: int | str | None = None def get(self) -> AutoloadReportDetails: """Получает конкретный отчет v3.""" @@ -329,7 +379,9 @@ def get(self) -> AutoloadReportDetails: report_id = self._require_report_id() return AutoloadClient(self.transport).get_report(report_id=report_id) - def list(self, *, limit: int | None = None, offset: int | None = None) -> AutoloadReportsResult: + def list( + self, *, limit: int | None = None, offset: int | None = None + ) -> PaginatedList[AutoloadReportSummary]: """Получает список отчетов автозагрузки.""" return AutoloadClient(self.transport).list_reports(limit=limit, offset=offset) @@ -367,22 +419,21 @@ def get_items_info(self, *, item_ids: Sequence[int]) -> AutoloadReportItemsResul return AutoloadClient(self.transport).get_items_info(item_ids=list(item_ids)) def _require_report_id(self) -> int: - if self.resource_id is None: - raise ValueError("Для операции требуется `report_id`.") - return int(self.resource_id) + if self.report_id is None: + raise ValidationError("Для операции требуется `report_id`.") + return int(self.report_id) @dataclass(slots=True, frozen=True) -class AutoloadLegacy(DomainObject): - """Доменный объект legacy-операций автозагрузки.""" +class AutoloadArchive(DomainObject): + """Доменный объект архивных операций автозагрузки.""" - resource_id: int | str | None = None - user_id: int | str | None = None + report_id: int | str | None = None def get_profile(self) -> AutoloadProfileSettings: - """Получает legacy профиль автозагрузки.""" + """Получает архивный профиль автозагрузки.""" - return AutoloadLegacyClient(self.transport).get_profile() + return AutoloadArchiveClient(self.transport).get_profile() def save_profile( self, @@ -390,38 +441,37 @@ def save_profile( is_enabled: bool | None = None, email: str | None = None, callback_url: str | None = None, - ) -> ActionResult: - """Сохраняет legacy профиль автозагрузки.""" + ) -> AdsActionResult: + """Сохраняет архивный профиль автозагрузки.""" - return AutoloadLegacyClient(self.transport).save_profile( + return AutoloadArchiveClient(self.transport).save_profile( AutoloadProfileUpdateRequest( is_enabled=is_enabled, email=email, callback_url=callback_url ) ) def get_last_completed_report(self) -> LegacyAutoloadReport: - """Получает legacy статистику по последней выгрузке.""" + """Получает архивную статистику по последней выгрузке.""" - return AutoloadLegacyClient(self.transport).get_last_completed_report() + return AutoloadArchiveClient(self.transport).get_last_completed_report() def get_report(self) -> LegacyAutoloadReport: - """Получает legacy статистику по конкретной выгрузке.""" + """Получает архивную статистику по конкретной выгрузке.""" report_id = self._require_report_id() - return AutoloadLegacyClient(self.transport).get_report(report_id=report_id) + return AutoloadArchiveClient(self.transport).get_report(report_id=report_id) def _require_report_id(self) -> int: - if self.resource_id is None: - raise ValueError("Для операции требуется `report_id`.") - return int(self.resource_id) + if self.report_id is None: + raise ValidationError("Для операции требуется `report_id`.") + return int(self.report_id) __all__ = ( - "DomainObject", "Ad", - "AdStats", "AdPromotion", + "AdStats", + "AutoloadArchive", "AutoloadProfile", "AutoloadReport", - "AutoloadLegacy", ) diff --git a/avito/ads/enums.py b/avito/ads/enums.py deleted file mode 100644 index 07f3b0d..0000000 --- a/avito/ads/enums.py +++ /dev/null @@ -1 +0,0 @@ -"""Строковые enum и константы пакета ads.""" diff --git a/avito/ads/mappers.py b/avito/ads/mappers.py index 6787f26..e3819b4 100644 --- a/avito/ads/mappers.py +++ b/avito/ads/mappers.py @@ -3,11 +3,12 @@ from __future__ import annotations from collections.abc import Mapping +from datetime import datetime from typing import cast from avito.ads.models import ( - ActionResult, - AdItem, + AccountSpendings, + AdsActionResult, AdsListResult, AutoloadFee, AutoloadFeesResult, @@ -22,14 +23,14 @@ AutoloadTreeNode, AutoloadTreeResult, CallsStatsResult, - CallStat, + CallStats, IdMappingResult, ItemAnalyticsResult, - ItemStatsRecord, ItemStatsResult, LegacyAutoloadReport, + Listing, + ListingStats, SpendingRecord, - SpendingsResult, UpdatePriceResult, UploadResult, VasApplyResult, @@ -63,6 +64,18 @@ def _str(payload: Payload, *keys: str) -> str | None: return None +def _datetime(payload: Payload, *keys: str) -> datetime | None: + for key in keys: + value = payload.get(key) + if isinstance(value, str): + normalized = value.replace("Z", "+00:00") + try: + return datetime.fromisoformat(normalized) + except ValueError: + continue + return None + + def _int(payload: Payload, *keys: str) -> int | None: for key in keys: value = payload.get(key) @@ -91,19 +104,18 @@ def _bool(payload: Payload, *keys: str) -> bool | None: return None -def map_ad_item(payload: object) -> AdItem: +def map_ad_item(payload: object) -> Listing: """Преобразует объявление в dataclass.""" data = _expect_mapping(payload) - return AdItem( - id=_int(data, "id", "item_id", "itemId"), + return Listing( + item_id=_int(data, "id", "item_id", "itemId"), user_id=_int(data, "user_id", "userId"), title=_str(data, "title"), description=_str(data, "description"), status=_str(data, "status"), price=_float(data, "price"), url=_str(data, "url", "link"), - raw_payload=data, ) @@ -112,7 +124,7 @@ def map_ads_list(payload: object) -> AdsListResult: data = _expect_mapping(payload) items = [map_ad_item(item) for item in _list(data, "items", "result", "resources")] - return AdsListResult(items=items, total=_int(data, "total", "count"), raw_payload=data) + return AdsListResult(items=items, total=_int(data, "total", "count")) def map_update_price_result(payload: object) -> UpdatePriceResult: @@ -123,7 +135,6 @@ def map_update_price_result(payload: object) -> UpdatePriceResult: item_id=_int(data, "item_id", "itemId", "id"), price=_float(data, "price"), status=_str(data, "status", "result"), - raw_payload=data, ) @@ -132,25 +143,23 @@ def map_calls_stats(payload: object) -> CallsStatsResult: data = _expect_mapping(payload) items = [ - CallStat( + CallStats( item_id=_int(item, "item_id", "itemId", "id"), calls=_int(item, "calls", "total"), answered_calls=_int(item, "answered_calls", "answeredCalls"), missed_calls=_int(item, "missed_calls", "missedCalls"), - raw_payload=item, ) for item in _list(data, "items", "result", "stats") ] - return CallsStatsResult(items=items, raw_payload=data) + return CallsStatsResult(items=items) -def _map_item_stat(item: Payload) -> ItemStatsRecord: - return ItemStatsRecord( +def _map_item_stat(item: Payload) -> ListingStats: + return ListingStats( item_id=_int(item, "item_id", "itemId", "id"), views=_int(item, "views", "impressions"), contacts=_int(item, "contacts", "contacts_total", "contactsTotal"), favorites=_int(item, "favorites", "favorites_total", "favoritesTotal"), - raw_payload=item, ) @@ -160,7 +169,6 @@ def map_item_stats(payload: object) -> ItemStatsResult: data = _expect_mapping(payload) return ItemStatsResult( items=[_map_item_stat(item) for item in _list(data, "items", "result", "stats")], - raw_payload=data, ) @@ -171,11 +179,10 @@ def map_item_analytics(payload: object) -> ItemAnalyticsResult: return ItemAnalyticsResult( items=[_map_item_stat(item) for item in _list(data, "items", "result", "stats")], period=_str(data, "period"), - raw_payload=data, ) -def map_spendings(payload: object) -> SpendingsResult: +def map_spendings(payload: object) -> AccountSpendings: """Преобразует статистику расходов.""" data = _expect_mapping(payload) @@ -184,14 +191,13 @@ def map_spendings(payload: object) -> SpendingsResult: item_id=_int(item, "item_id", "itemId", "id"), amount=_float(item, "amount", "price", "cost"), service=_str(item, "service", "serviceType", "type"), - raw_payload=item, ) for item in _list(data, "items", "result", "spendings") ] total = _float(data, "total") if total is None: total = sum(item.amount for item in items if item.amount is not None) or None - return SpendingsResult(items=items, total=total, raw_payload=data) + return AccountSpendings(items=items, total=total) def map_vas_prices(payload: object) -> VasPricesResult: @@ -204,11 +210,10 @@ def map_vas_prices(payload: object) -> VasPricesResult: title=_str(item, "title", "name"), price=_float(item, "price", "amount"), is_available=_bool(item, "is_available", "isAvailable", "available"), - raw_payload=item, ) for item in _list(data, "items", "services", "result") ] - return VasPricesResult(items=items, raw_payload=data) + return VasPricesResult(items=items) def map_vas_apply_result(payload: object) -> VasApplyResult: @@ -218,7 +223,6 @@ def map_vas_apply_result(payload: object) -> VasApplyResult: return VasApplyResult( success=bool(data.get("success", True)), status=_str(data, "status", "result", "message"), - raw_payload=data, ) @@ -230,7 +234,6 @@ def map_autoload_profile(payload: object) -> AutoloadProfileSettings: user_id=_int(data, "user_id", "userId", "id"), is_enabled=_bool(data, "is_enabled", "isEnabled", "enabled"), upload_url=_str(data, "upload_url", "uploadUrl", "url"), - raw_payload=data, ) @@ -241,7 +244,6 @@ def map_upload_result(payload: object) -> UploadResult: return UploadResult( success=bool(data.get("success", True)), report_id=_int(data, "report_id", "reportId", "id"), - raw_payload=data, ) @@ -255,11 +257,10 @@ def map_autoload_fields(payload: object) -> AutoloadFieldsResult: title=_str(item, "title", "name"), type=_str(item, "type"), required=_bool(item, "required", "is_required", "isRequired"), - raw_payload=item, ) for item in _list(data, "fields", "items", "result") ] - return AutoloadFieldsResult(items=items, raw_payload=data) + return AutoloadFieldsResult(items=items) def _map_tree_node(payload: Payload) -> AutoloadTreeNode: @@ -267,7 +268,6 @@ def _map_tree_node(payload: Payload) -> AutoloadTreeNode: slug=_str(payload, "slug", "code", "id"), title=_str(payload, "title", "name"), children=[_map_tree_node(item) for item in _list(payload, "children", "items")], - raw_payload=payload, ) @@ -276,7 +276,7 @@ def map_autoload_tree(payload: object) -> AutoloadTreeResult: data = _expect_mapping(payload) items = [_map_tree_node(item) for item in _list(data, "tree", "items", "result")] - return AutoloadTreeResult(items=items, raw_payload=data) + return AutoloadTreeResult(items=items) def map_id_mapping(payload: object) -> IdMappingResult: @@ -286,17 +286,16 @@ def map_id_mapping(payload: object) -> IdMappingResult: mappings: list[tuple[int | None, int | None]] = [] for item in _list(data, "items", "result", "mappings"): mappings.append((_int(item, "ad_id", "adId"), _int(item, "avito_id", "avitoId"))) - return IdMappingResult(mappings=mappings, raw_payload=data) + return IdMappingResult(mappings=mappings) def _map_report_summary(item: Payload) -> AutoloadReportSummary: return AutoloadReportSummary( report_id=_int(item, "report_id", "reportId", "id"), status=_str(item, "status"), - created_at=_str(item, "created_at", "createdAt"), - finished_at=_str(item, "finished_at", "finishedAt"), + created_at=_datetime(item, "created_at", "createdAt"), + finished_at=_datetime(item, "finished_at", "finishedAt"), processed_items=_int(item, "processed_items", "processedItems", "items"), - raw_payload=item, ) @@ -307,7 +306,6 @@ def map_autoload_reports(payload: object) -> AutoloadReportsResult: return AutoloadReportsResult( items=[_map_report_summary(item) for item in _list(data, "reports", "items", "result")], total=_int(data, "total", "count"), - raw_payload=data, ) @@ -318,11 +316,10 @@ def map_autoload_report_details(payload: object) -> AutoloadReportDetails: return AutoloadReportDetails( report_id=_int(data, "report_id", "reportId", "id"), status=_str(data, "status"), - created_at=_str(data, "created_at", "createdAt"), - finished_at=_str(data, "finished_at", "finishedAt"), + created_at=_datetime(data, "created_at", "createdAt"), + finished_at=_datetime(data, "finished_at", "finishedAt"), errors_count=_int(data, "errors_count", "errorsCount"), warnings_count=_int(data, "warnings_count", "warningsCount"), - raw_payload=data, ) @@ -333,7 +330,6 @@ def map_legacy_autoload_report(payload: object) -> LegacyAutoloadReport: return LegacyAutoloadReport( report_id=_int(data, "report_id", "reportId", "id"), status=_str(data, "status"), - raw_payload=data, ) @@ -347,13 +343,10 @@ def map_autoload_report_items(payload: object) -> AutoloadReportItemsResult: avito_id=_int(item, "avito_id", "avitoId"), status=_str(item, "status"), title=_str(item, "title"), - raw_payload=item, ) for item in _list(data, "items", "result") ] - return AutoloadReportItemsResult( - items=items, total=_int(data, "total", "count"), raw_payload=data - ) + return AutoloadReportItemsResult(items=items, total=_int(data, "total", "count")) def map_autoload_fees(payload: object) -> AutoloadFeesResult: @@ -365,27 +358,25 @@ def map_autoload_fees(payload: object) -> AutoloadFeesResult: item_id=_int(item, "item_id", "itemId", "id"), amount=_float(item, "amount", "price", "cost"), service=_str(item, "service", "serviceType", "type"), - raw_payload=item, ) for item in _list(data, "items", "result", "fees") ] total = _float(data, "total") if total is None: total = sum(item.amount for item in items if item.amount is not None) or None - return AutoloadFeesResult(items=items, total=total, raw_payload=data) + return AutoloadFeesResult(items=items, total=total) -def map_action_result(payload: object) -> ActionResult: - """Преобразует ответ мутационной операции.""" +def map_action_result(payload: object) -> AdsActionResult: + """Преобразует ответ мутационной операции ads.""" if isinstance(payload, Mapping): data = cast(Payload, payload) - return ActionResult( + return AdsActionResult( success=bool(data.get("success", True)), message=_str(data, "message", "status"), - raw_payload=data, ) - return ActionResult(success=True, raw_payload={}) + return AdsActionResult(success=True) __all__ = ( diff --git a/avito/ads/models.py b/avito/ads/models.py index 13d803e..57fa979 100644 --- a/avito/ads/models.py +++ b/avito/ads/models.py @@ -2,31 +2,31 @@ from __future__ import annotations -from collections.abc import Mapping from dataclasses import dataclass, field +from datetime import datetime + +from avito.core.serialization import SerializableModel @dataclass(slots=True, frozen=True) -class AdItem: +class Listing(SerializableModel): """Объявление пользователя.""" - id: int | None + item_id: int | None user_id: int | None title: str | None description: str | None status: str | None price: float | None url: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class AdsListResult: +class AdsListResult(SerializableModel): """Результат списка объявлений.""" - items: list[AdItem] + items: list[Listing] total: int | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -42,13 +42,12 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class UpdatePriceResult: +class UpdatePriceResult(SerializableModel): """Результат обновления цены объявления.""" item_id: int | None price: float | None status: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -74,22 +73,20 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class CallStat: +class CallStats(SerializableModel): """Статистика звонков по объявлению.""" item_id: int | None calls: int | None answered_calls: int | None missed_calls: int | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class CallsStatsResult: +class CallsStatsResult(SerializableModel): """Статистика звонков по набору объявлений.""" - items: list[CallStat] - raw_payload: Mapping[str, object] = field(default_factory=dict) + items: list[CallStats] @dataclass(slots=True, frozen=True) @@ -117,61 +114,55 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class ItemStatsRecord: +class ListingStats(SerializableModel): """Статистические показатели объявления.""" item_id: int | None views: int | None contacts: int | None favorites: int | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class ItemStatsResult: +class ItemStatsResult(SerializableModel): """Статистика по списку объявлений.""" - items: list[ItemStatsRecord] - raw_payload: Mapping[str, object] = field(default_factory=dict) + items: list[ListingStats] @dataclass(slots=True, frozen=True) -class ItemAnalyticsResult: +class ItemAnalyticsResult(SerializableModel): """Аналитика по профилю или объявлениям.""" - items: list[ItemStatsRecord] + items: list[ListingStats] period: str | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class SpendingRecord: - """Запись статистики расходов.""" +class SpendingRecord(SerializableModel): + """Запись статистики расходов по объявлению.""" item_id: int | None amount: float | None service: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class SpendingsResult: +class AccountSpendings(SerializableModel): """Статистика расходов профиля.""" items: list[SpendingRecord] total: float | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class VasPrice: +class VasPrice(SerializableModel): """Цена и доступность услуги продвижения.""" code: str | None title: str | None price: float | None is_available: bool | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -195,20 +186,18 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class VasPricesResult: +class VasPricesResult(SerializableModel): """Список цен и доступных услуг продвижения.""" items: list[VasPrice] - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class VasApplyResult: +class VasApplyResult(SerializableModel): """Результат применения услуг продвижения.""" success: bool status: str | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -236,13 +225,12 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class AutoloadProfileSettings: +class AutoloadProfileSettings(SerializableModel): """Профиль пользователя автозагрузки.""" user_id: int | None is_enabled: bool | None upload_url: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -280,153 +268,138 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class UploadResult: +class UploadResult(SerializableModel): """Результат запуска загрузки файла.""" success: bool report_id: int | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class AutoloadField: +class AutoloadField(SerializableModel): """Поле категории автозагрузки.""" slug: str | None title: str | None type: str | None required: bool | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class AutoloadFieldsResult: +class AutoloadFieldsResult(SerializableModel): """Список полей категории автозагрузки.""" items: list[AutoloadField] - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class AutoloadTreeNode: +class AutoloadTreeNode(SerializableModel): """Узел дерева категорий автозагрузки.""" slug: str | None title: str | None children: list[AutoloadTreeNode] = field(default_factory=list) - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class AutoloadTreeResult: +class AutoloadTreeResult(SerializableModel): """Дерево категорий автозагрузки.""" items: list[AutoloadTreeNode] - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class IdMappingResult: +class IdMappingResult(SerializableModel): """Сопоставление идентификаторов объявлений.""" mappings: list[tuple[int | None, int | None]] - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class AutoloadReportSummary: +class AutoloadReportSummary(SerializableModel): """Краткая информация по отчету автозагрузки.""" report_id: int | None status: str | None - created_at: str | None - finished_at: str | None + created_at: datetime | None + finished_at: datetime | None processed_items: int | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class AutoloadReportsResult: +class AutoloadReportsResult(SerializableModel): """Список отчетов автозагрузки.""" items: list[AutoloadReportSummary] total: int | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class AutoloadReportItem: +class AutoloadReportItem(SerializableModel): """Объявление внутри отчета автозагрузки.""" item_id: int | None avito_id: int | None status: str | None title: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class AutoloadReportItemsResult: +class AutoloadReportItemsResult(SerializableModel): """Список объявлений из отчета автозагрузки.""" items: list[AutoloadReportItem] total: int | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class AutoloadFee: +class AutoloadFee(SerializableModel): """Списание по объявлению в отчете автозагрузки.""" item_id: int | None amount: float | None service: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class AutoloadFeesResult: +class AutoloadFeesResult(SerializableModel): """Списания по объявлениям отчета.""" items: list[AutoloadFee] total: float | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class AutoloadReportDetails: +class AutoloadReportDetails(SerializableModel): """Детальная информация по отчету автозагрузки.""" report_id: int | None status: str | None - created_at: str | None - finished_at: str | None + created_at: datetime | None + finished_at: datetime | None errors_count: int | None warnings_count: int | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class LegacyAutoloadReport: +class LegacyAutoloadReport(SerializableModel): """Legacy-ответ автозагрузки.""" report_id: int | None status: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class ActionResult: - """Универсальный результат мутационной операции ads.""" +class AdsActionResult(SerializableModel): + """Результат мутационной операции ads.""" success: bool message: str | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) __all__ = ( - "ActionResult", - "AdItem", + "AccountSpendings", + "AdsActionResult", "AdsListResult", "ApplyVasPackageRequest", "ApplyVasRequest", @@ -443,17 +416,17 @@ class ActionResult: "AutoloadReportsResult", "AutoloadTreeNode", "AutoloadTreeResult", - "CallStat", + "CallStats", "CallsStatsRequest", "CallsStatsResult", "IdMappingResult", "ItemAnalyticsResult", - "ItemStatsRecord", "ItemStatsRequest", "ItemStatsResult", "LegacyAutoloadReport", + "Listing", + "ListingStats", "SpendingRecord", - "SpendingsResult", "UpdatePriceRequest", "UpdatePriceResult", "UploadByUrlRequest", diff --git a/avito/auth/__init__.py b/avito/auth/__init__.py index f5c1f53..03d66d3 100644 --- a/avito/auth/__init__.py +++ b/avito/auth/__init__.py @@ -1,21 +1,40 @@ """Пакет аутентификации.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + from avito.auth.models import ( AccessToken, ClientCredentialsRequest, RefreshTokenRequest, TokenResponse, ) -from avito.auth.provider import AuthProvider, LegacyTokenClient, TokenClient from avito.auth.settings import AuthSettings +if TYPE_CHECKING: + from avito.auth.provider import AlternateTokenClient, AuthProvider, TokenClient + __all__ = ( "AccessToken", + "AlternateTokenClient", "AuthProvider", "AuthSettings", "ClientCredentialsRequest", - "LegacyTokenClient", "RefreshTokenRequest", "TokenClient", "TokenResponse", ) + + +def __getattr__(name: str) -> object: + if name in {"AlternateTokenClient", "AuthProvider", "TokenClient"}: + from avito.auth.provider import AlternateTokenClient, AuthProvider, TokenClient + + exports = { + "AlternateTokenClient": AlternateTokenClient, + "AuthProvider": AuthProvider, + "TokenClient": TokenClient, + } + return exports[name] + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/avito/auth/mappers.py b/avito/auth/mappers.py index 00836dd..fbd034e 100644 --- a/avito/auth/mappers.py +++ b/avito/auth/mappers.py @@ -39,7 +39,6 @@ def map_token_response(payload: object, *, now: datetime | None = None) -> Token ), refresh_token=refresh_token, scope=payload.get("scope") if isinstance(payload.get("scope"), str) else None, - raw_payload=payload, ) diff --git a/avito/auth/models.py b/avito/auth/models.py index c0fa8cc..5570c56 100644 --- a/avito/auth/models.py +++ b/avito/auth/models.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Mapping from dataclasses import dataclass from datetime import datetime, timedelta @@ -28,7 +27,6 @@ class TokenResponse: access_token: AccessToken refresh_token: str | None = None scope: str | None = None - raw_payload: Mapping[str, object] | None = None @dataclass(slots=True, frozen=True) diff --git a/avito/auth/provider.py b/avito/auth/provider.py index b08b866..816d539 100644 --- a/avito/auth/provider.py +++ b/avito/auth/provider.py @@ -4,7 +4,7 @@ from dataclasses import dataclass, field from datetime import UTC, datetime -from typing import TYPE_CHECKING, Protocol +from typing import Protocol import httpx @@ -19,9 +19,6 @@ from avito.auth.settings import AuthSettings from avito.core.exceptions import AuthenticationError -if TYPE_CHECKING: - pass - class TokenFetcher(Protocol): """Контракт получения нового access token из внешнего источника.""" @@ -35,7 +32,7 @@ class AuthProvider: settings: AuthSettings token_client: TokenClient | None = None - legacy_token_client: LegacyTokenClient | None = None + alternate_token_client: AlternateTokenClient | None = None autoteka_token_client: TokenClient | None = None token_fetcher: TokenFetcher | None = None _access_token: AccessToken | None = field(default=None, init=False, repr=False) @@ -66,7 +63,7 @@ def invalidate_token(self) -> None: def close(self) -> None: """Закрывает внутренние HTTP-клиенты provider-а.""" - for client in (self.token_client, self.legacy_token_client, self.autoteka_token_client): + for client in (self.token_client, self.alternate_token_client, self.autoteka_token_client): if client is not None: client.close() @@ -94,10 +91,10 @@ def token_flow(self) -> TokenClient: return self._get_token_client() - def legacy_token_flow(self) -> LegacyTokenClient: - """Возвращает legacy alias token client без отдельного публичного API метода.""" + def alternate_token_flow(self) -> AlternateTokenClient: + """Возвращает дополнительный token client для альтернативного `/token` flow.""" - return self._get_legacy_token_client() + return self._get_alternate_token_client() def _fetch_token_response(self) -> TokenResponse: if self.token_fetcher is not None: @@ -140,10 +137,10 @@ def _get_token_client(self) -> TokenClient: self.token_client = TokenClient(self.settings) return self.token_client - def _get_legacy_token_client(self) -> LegacyTokenClient: - if self.legacy_token_client is None: - self.legacy_token_client = LegacyTokenClient(self.settings) - return self.legacy_token_client + def _get_alternate_token_client(self) -> AlternateTokenClient: + if self.alternate_token_client is None: + self.alternate_token_client = AlternateTokenClient(self.settings) + return self.alternate_token_client def _get_autoteka_token_client(self) -> TokenClient: if self.autoteka_token_client is None: @@ -265,14 +262,14 @@ def _extract_error_code(self, response: httpx.Response) -> str | None: @dataclass(slots=True) -class LegacyTokenClient: - """Служебный клиент для legacy alias token endpoint из swagger.""" +class AlternateTokenClient: + """Служебный клиент для альтернативного token endpoint из swagger.""" settings: AuthSettings client: httpx.Client | None = None def close(self) -> None: - """Закрывает выделенный HTTP-клиент legacy token flow.""" + """Закрывает выделенный HTTP-клиент альтернативного token flow.""" if self.client is not None: self.client.close() @@ -281,22 +278,22 @@ def request_client_credentials_token( self, request: ClientCredentialsRequest, ) -> TokenResponse: - """Запрашивает токен через legacy alias canonical `/token`.""" + """Запрашивает токен через альтернативный canonical `/token`.""" return TokenClient( self.settings, - token_url=self.settings.legacy_token_url, + token_url=self.settings.alternate_token_url, client=self.client, ).request_client_credentials_token(request) def request_refresh_token(self, request: RefreshTokenRequest) -> TokenResponse: - """Обновляет токен через legacy alias canonical `/token`.""" + """Обновляет токен через альтернативный canonical `/token`.""" return TokenClient( self.settings, - token_url=self.settings.legacy_token_url, + token_url=self.settings.alternate_token_url, client=self.client, ).request_refresh_token(request) -__all__ = ("AuthProvider", "LegacyTokenClient", "TokenClient", "TokenFetcher") +__all__ = ("AlternateTokenClient", "AuthProvider", "TokenClient", "TokenFetcher") diff --git a/avito/auth/settings.py b/avito/auth/settings.py index 221d48e..6d25f51 100644 --- a/avito/auth/settings.py +++ b/avito/auth/settings.py @@ -2,59 +2,91 @@ from __future__ import annotations -from pydantic import AliasChoices, Field -from pydantic_settings import BaseSettings, SettingsConfigDict - - -class AuthSettings(BaseSettings): - """Настройки OAuth и служебных токенов для transport-слоя.""" - - model_config = SettingsConfigDict( - env_prefix="AVITO_", - env_file=".env", - extra="ignore", - populate_by_name=True, - ) - - client_id: str | None = Field( - default=None, - validation_alias=AliasChoices("CLIENT_ID", "AVITO_CLIENT_ID"), - ) - client_secret: str | None = Field( - default=None, - validation_alias=AliasChoices( - "CLIENT_SECRET", "SECRET", "AVITO_CLIENT_SECRET", "AVITO_SECRET" +from dataclasses import dataclass +from pathlib import Path +from typing import ClassVar + +from avito._env import resolve_env_aliases +from avito.core.exceptions import ConfigurationError + + +@dataclass(slots=True, frozen=True) +class AuthSettings: + """Единственный публичный контракт OAuth-конфигурации SDK.""" + + ENV_ALIASES: ClassVar[dict[str, tuple[str, ...]]] = { + "client_id": ("AVITO_AUTH__CLIENT_ID", "AVITO_CLIENT_ID"), + "client_secret": ( + "AVITO_AUTH__CLIENT_SECRET", + "AVITO_CLIENT_SECRET", + ), + "scope": ("AVITO_AUTH__SCOPE", "AVITO_SCOPE"), + "refresh_token": ( + "AVITO_AUTH__REFRESH_TOKEN", + "AVITO_REFRESH_TOKEN", + ), + "token_url": ("AVITO_AUTH__TOKEN_URL", "AVITO_TOKEN_URL"), + "alternate_token_url": ( + "AVITO_AUTH__ALTERNATE_TOKEN_URL", + "AVITO_ALTERNATE_TOKEN_URL", + ), + "autoteka_token_url": ( + "AVITO_AUTH__AUTOTEKA_TOKEN_URL", + "AVITO_AUTOTEKA_TOKEN_URL", + ), + "autoteka_client_id": ( + "AVITO_AUTH__AUTOTEKA_CLIENT_ID", + "AVITO_AUTOTEKA_CLIENT_ID", + ), + "autoteka_client_secret": ( + "AVITO_AUTH__AUTOTEKA_CLIENT_SECRET", + "AVITO_AUTOTEKA_CLIENT_SECRET", ), - ) - scope: str | None = Field(default=None, validation_alias=AliasChoices("SCOPE", "AVITO_SCOPE")) - refresh_token: str | None = Field( - default=None, - validation_alias=AliasChoices("REFRESH_TOKEN", "AVITO_REFRESH_TOKEN"), - ) - token_url: str = Field( - default="/token", - validation_alias=AliasChoices("TOKEN_URL", "AVITO_TOKEN_URL"), - ) - legacy_token_url: str = Field( - default="/token", - validation_alias=AliasChoices("LEGACY_TOKEN_URL", "AVITO_LEGACY_TOKEN_URL"), - ) - autoteka_token_url: str = Field( - default="/autoteka/token", - validation_alias=AliasChoices("AUTOTEKA_TOKEN_URL", "AVITO_AUTOTEKA_TOKEN_URL"), - ) - autoteka_client_id: str | None = Field( - default=None, - validation_alias=AliasChoices("AUTOTEKA_CLIENT_ID", "AVITO_AUTOTEKA_CLIENT_ID"), - ) - autoteka_client_secret: str | None = Field( - default=None, - validation_alias=AliasChoices("AUTOTEKA_CLIENT_SECRET", "AVITO_AUTOTEKA_CLIENT_SECRET"), - ) - autoteka_scope: str | None = Field( - default=None, - validation_alias=AliasChoices("AUTOTEKA_SCOPE", "AVITO_AUTOTEKA_SCOPE"), - ) + "autoteka_scope": ( + "AVITO_AUTH__AUTOTEKA_SCOPE", + "AVITO_AUTOTEKA_SCOPE", + ), + } + + client_id: str | None = None + client_secret: str | None = None + scope: str | None = None + refresh_token: str | None = None + token_url: str = "/token" + alternate_token_url: str = "/token" + autoteka_token_url: str = "/autoteka/token" + autoteka_client_id: str | None = None + autoteka_client_secret: str | None = None + autoteka_scope: str | None = None + + @classmethod + def from_env(cls, *, env_file: str | Path | None = ".env") -> AuthSettings: + """Загружает auth-настройки из процесса и optional `.env` файла.""" + + resolved_values = resolve_env_aliases(cls.ENV_ALIASES, env_file=env_file) + return cls(**resolved_values).validate_required() + + @classmethod + def supported_env_vars(cls) -> dict[str, tuple[str, ...]]: + """Возвращает документированный набор env-переменных и alias-имен.""" + + return dict(cls.ENV_ALIASES) + + def validate_required(self) -> AuthSettings: + """Проверяет обязательные поля OAuth-конфигурации.""" + + missing_fields: list[str] = [] + if not self.client_id: + missing_fields.append("client_id: " + ", ".join(self.ENV_ALIASES["client_id"])) + if not self.client_secret: + missing_fields.append("client_secret: " + ", ".join(self.ENV_ALIASES["client_secret"])) + if missing_fields: + raise ConfigurationError( + "Не заданы обязательные настройки OAuth. Ожидаются " + + "; ".join(missing_fields) + + "." + ) + return self __all__ = ("AuthSettings",) diff --git a/avito/autoteka/__init__.py b/avito/autoteka/__init__.py index c8ec887..786fd3c 100644 --- a/avito/autoteka/__init__.py +++ b/avito/autoteka/__init__.py @@ -6,7 +6,6 @@ AutotekaScoring, AutotekaValuation, AutotekaVehicle, - DomainObject, ) from avito.autoteka.models import ( AutotekaLeadEvent, @@ -21,11 +20,24 @@ AutotekaValuationInfo, CatalogField, CatalogFieldValue, + CatalogResolveRequest, CatalogResolveResult, + ExternalItemPreviewRequest, + ItemIdRequest, + LeadsRequest, + MonitoringBucketRequest, MonitoringBucketResult, MonitoringEvent, + MonitoringEventsQuery, MonitoringEventsResult, MonitoringInvalidVehicle, + PlateNumberRequest, + PreviewReportRequest, + RegNumberRequest, + TeaserCreateRequest, + ValuationBySpecificationRequest, + VehicleIdRequest, + VinRequest, ) __all__ = ( @@ -47,9 +59,21 @@ "CatalogField", "CatalogFieldValue", "CatalogResolveResult", - "DomainObject", + "CatalogResolveRequest", + "ExternalItemPreviewRequest", + "ItemIdRequest", + "LeadsRequest", + "MonitoringBucketRequest", "MonitoringBucketResult", "MonitoringEvent", + "MonitoringEventsQuery", "MonitoringEventsResult", "MonitoringInvalidVehicle", + "PlateNumberRequest", + "PreviewReportRequest", + "RegNumberRequest", + "TeaserCreateRequest", + "ValuationBySpecificationRequest", + "VehicleIdRequest", + "VinRequest", ) diff --git a/avito/autoteka/client.py b/avito/autoteka/client.py index 9e9b891..9968ace 100644 --- a/avito/autoteka/client.py +++ b/avito/autoteka/client.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Mapping from dataclasses import dataclass from avito.autoteka.mappers import ( @@ -29,10 +28,22 @@ AutotekaSpecificationInfo, AutotekaTeaserInfo, AutotekaValuationInfo, + CatalogResolveRequest, CatalogResolveResult, - JsonRequest, + ExternalItemPreviewRequest, + ItemIdRequest, + LeadsRequest, + MonitoringBucketRequest, MonitoringBucketResult, + MonitoringEventsQuery, MonitoringEventsResult, + PlateNumberRequest, + PreviewReportRequest, + RegNumberRequest, + TeaserCreateRequest, + ValuationBySpecificationRequest, + VehicleIdRequest, + VinRequest, ) from avito.core import RequestContext, Transport @@ -60,7 +71,7 @@ def _context(self, operation_name: str, *, allow_retry: bool = False) -> Request class CatalogClient(AutotekaBaseClient): """Выполняет HTTP-операции автокаталога.""" - def get_catalogs_resolve(self, request: JsonRequest) -> CatalogResolveResult: + def resolve_catalog(self, request: CatalogResolveRequest) -> CatalogResolveResult: payload = self.transport.request_json( "POST", "/autoteka/v1/catalogs/resolve", @@ -74,7 +85,7 @@ def get_catalogs_resolve(self, request: JsonRequest) -> CatalogResolveResult: class LeadsClient(AutotekaBaseClient): """Выполняет HTTP-операции сервиса Сигнал.""" - def get_leads(self, request: JsonRequest) -> AutotekaLeadsResult: + def get_leads(self, request: LeadsRequest) -> AutotekaLeadsResult: payload = self.transport.request_json( "POST", "/autoteka/v1/get-leads/", @@ -88,26 +99,26 @@ def get_leads(self, request: JsonRequest) -> AutotekaLeadsResult: class PreviewClient(AutotekaBaseClient): """Выполняет HTTP-операции превью автомобиля.""" - def create_by_vin(self, request: JsonRequest) -> AutotekaPreviewInfo: + def create_by_vin(self, request: VinRequest) -> AutotekaPreviewInfo: return self._post_preview( "/autoteka/v1/previews", "autoteka.preview.create_by_vin", request ) - def create_by_external_item(self, request: JsonRequest) -> AutotekaPreviewInfo: + def create_by_external_item(self, request: ExternalItemPreviewRequest) -> AutotekaPreviewInfo: return self._post_preview( "/autoteka/v1/request-preview-by-external-item", "autoteka.preview.create_by_external_item", request, ) - def create_by_item_id(self, request: JsonRequest) -> AutotekaPreviewInfo: + def create_by_item_id(self, request: ItemIdRequest) -> AutotekaPreviewInfo: return self._post_preview( "/autoteka/v1/request-preview-by-item-id", "autoteka.preview.create_by_item_id", request, ) - def create_by_reg_number(self, request: JsonRequest) -> AutotekaPreviewInfo: + def create_by_reg_number(self, request: RegNumberRequest) -> AutotekaPreviewInfo: return self._post_preview( "/autoteka/v1/request-preview-by-regnumber", "autoteka.preview.create_by_reg_number", @@ -122,7 +133,12 @@ def get_preview(self, *, preview_id: int | str) -> AutotekaPreviewInfo: ) return map_preview(payload) - def _post_preview(self, path: str, operation: str, request: JsonRequest) -> AutotekaPreviewInfo: + def _post_preview( + self, + path: str, + operation: str, + request: VinRequest | ExternalItemPreviewRequest | ItemIdRequest | RegNumberRequest, + ) -> AutotekaPreviewInfo: payload = self.transport.request_json( "POST", path, @@ -144,10 +160,10 @@ def get_active_package(self) -> AutotekaPackageInfo: ) return map_package(payload) - def create_report(self, request: JsonRequest) -> AutotekaReportInfo: + def create_report(self, request: PreviewReportRequest) -> AutotekaReportInfo: return self._post_report("/autoteka/v1/reports", "autoteka.report.create", request) - def create_report_by_vehicle_id(self, request: JsonRequest) -> AutotekaReportInfo: + def create_report_by_vehicle_id(self, request: VehicleIdRequest) -> AutotekaReportInfo: return self._post_report( "/autoteka/v1/reports-by-vehicle-id", "autoteka.report.create_by_vehicle_id", @@ -170,21 +186,26 @@ def get_report(self, *, report_id: int | str) -> AutotekaReportInfo: ) return map_report(payload) - def create_sync_report_by_reg_number(self, request: JsonRequest) -> AutotekaReportInfo: + def create_sync_report_by_reg_number(self, request: RegNumberRequest) -> AutotekaReportInfo: return self._post_report( "/autoteka/v1/sync/create-by-regnumber", "autoteka.report.create_sync_by_reg_number", request, ) - def create_sync_report_by_vin(self, request: JsonRequest) -> AutotekaReportInfo: + def create_sync_report_by_vin(self, request: VinRequest) -> AutotekaReportInfo: return self._post_report( "/autoteka/v1/sync/create-by-vin", "autoteka.report.create_sync_by_vin", request, ) - def _post_report(self, path: str, operation: str, request: JsonRequest) -> AutotekaReportInfo: + def _post_report( + self, + path: str, + operation: str, + request: PreviewReportRequest | VehicleIdRequest | RegNumberRequest | VinRequest, + ) -> AutotekaReportInfo: payload = self.transport.request_json( "POST", path, @@ -198,7 +219,7 @@ def _post_report(self, path: str, operation: str, request: JsonRequest) -> Autot class MonitoringClient(AutotekaBaseClient): """Выполняет HTTP-операции мониторинга.""" - def add_bucket(self, request: JsonRequest) -> MonitoringBucketResult: + def add_bucket(self, request: MonitoringBucketRequest) -> MonitoringBucketResult: return self._post_bucket( "/autoteka/v1/monitoring/bucket/add", "autoteka.monitoring.bucket_add", @@ -213,7 +234,7 @@ def delete_bucket(self) -> MonitoringBucketResult: ) return map_monitoring_bucket(payload) - def remove_bucket(self, request: JsonRequest) -> MonitoringBucketResult: + def remove_bucket(self, request: MonitoringBucketRequest) -> MonitoringBucketResult: return self._post_bucket( "/autoteka/v1/monitoring/bucket/remove", "autoteka.monitoring.bucket_remove", @@ -221,18 +242,21 @@ def remove_bucket(self, request: JsonRequest) -> MonitoringBucketResult: ) def get_reg_actions( - self, *, params: Mapping[str, object] | None = None + self, *, query: MonitoringEventsQuery | None = None ) -> MonitoringEventsResult: payload = self.transport.request_json( "GET", "/autoteka/v1/monitoring/get-reg-actions/", context=self._context("autoteka.monitoring.get_reg_actions"), - params=params, + params=query.to_params() if query is not None else None, ) return map_monitoring_events(payload) def _post_bucket( - self, path: str, operation: str, request: JsonRequest + self, + path: str, + operation: str, + request: MonitoringBucketRequest, ) -> MonitoringBucketResult: payload = self.transport.request_json( "POST", @@ -247,7 +271,7 @@ def _post_bucket( class ScoringClient(AutotekaBaseClient): """Выполняет HTTP-операции скоринга рисков.""" - def create_by_vehicle_id(self, request: JsonRequest) -> AutotekaScoringInfo: + def create_by_vehicle_id(self, request: VehicleIdRequest) -> AutotekaScoringInfo: payload = self.transport.request_json( "POST", "/autoteka/v1/scoring/by-vehicle-id", @@ -269,14 +293,14 @@ def get_by_id(self, *, scoring_id: int | str) -> AutotekaScoringInfo: class SpecificationsClient(AutotekaBaseClient): """Выполняет HTTP-операции спецификаций автомобиля.""" - def create_by_plate_number(self, request: JsonRequest) -> AutotekaSpecificationInfo: + def create_by_plate_number(self, request: PlateNumberRequest) -> AutotekaSpecificationInfo: return self._post_specification( "/autoteka/v1/specifications/by-plate-number", "autoteka.specification.create_by_plate_number", request, ) - def create_by_vehicle_id(self, request: JsonRequest) -> AutotekaSpecificationInfo: + def create_by_vehicle_id(self, request: VehicleIdRequest) -> AutotekaSpecificationInfo: return self._post_specification( "/autoteka/v1/specifications/by-vehicle-id", "autoteka.specification.create_by_vehicle_id", @@ -295,7 +319,7 @@ def _post_specification( self, path: str, operation: str, - request: JsonRequest, + request: PlateNumberRequest | VehicleIdRequest, ) -> AutotekaSpecificationInfo: payload = self.transport.request_json( "POST", @@ -310,7 +334,7 @@ def _post_specification( class TeaserClient(AutotekaBaseClient): """Выполняет HTTP-операции тизеров.""" - def create(self, request: JsonRequest) -> AutotekaTeaserInfo: + def create(self, request: TeaserCreateRequest) -> AutotekaTeaserInfo: payload = self.transport.request_json( "POST", "/autoteka/v1/teasers", @@ -332,7 +356,9 @@ def get(self, *, teaser_id: int | str) -> AutotekaTeaserInfo: class ValuationClient(AutotekaBaseClient): """Выполняет HTTP-операции оценки стоимости.""" - def get_by_specification(self, request: JsonRequest) -> AutotekaValuationInfo: + def get_by_specification( + self, request: ValuationBySpecificationRequest + ) -> AutotekaValuationInfo: payload = self.transport.request_json( "POST", "/autoteka/v1/valuation/by-specification", diff --git a/avito/autoteka/domain.py b/avito/autoteka/domain.py index 2af7b36..e2fb0e8 100644 --- a/avito/autoteka/domain.py +++ b/avito/autoteka/domain.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Mapping from dataclasses import dataclass from avito.autoteka.client import ( @@ -26,186 +25,208 @@ AutotekaSpecificationInfo, AutotekaTeaserInfo, AutotekaValuationInfo, + CatalogResolveRequest, CatalogResolveResult, - JsonRequest, + ExternalItemPreviewRequest, + ItemIdRequest, + LeadsRequest, + MonitoringBucketRequest, MonitoringBucketResult, + MonitoringEventsQuery, MonitoringEventsResult, + PlateNumberRequest, + PreviewReportRequest, + RegNumberRequest, + TeaserCreateRequest, + ValuationBySpecificationRequest, + VehicleIdRequest, + VinRequest, ) -from avito.core import Transport - - -@dataclass(slots=True, frozen=True) -class DomainObject: - """Базовый доменный объект раздела autoteka.""" - - transport: Transport +from avito.core import ValidationError +from avito.core.domain import DomainObject @dataclass(slots=True, frozen=True) class AutotekaVehicle(DomainObject): """Доменный объект превью, спецификаций, тизеров и каталога.""" - resource_id: int | str | None = None + vehicle_id: int | str | None = None user_id: int | str | None = None - def get_catalogs_resolve(self, *, payload: Mapping[str, object]) -> CatalogResolveResult: - return CatalogClient(self.transport).get_catalogs_resolve(JsonRequest(payload)) + def resolve_catalog(self, *, brand_id: int) -> CatalogResolveResult: + """Актуализирует параметры автокаталога.""" + + return CatalogClient(self.transport).resolve_catalog(CatalogResolveRequest(brand_id=brand_id)) - def get_leads(self, *, payload: Mapping[str, object]) -> AutotekaLeadsResult: - return LeadsClient(self.transport).get_leads(JsonRequest(payload)) + def get_leads(self, *, limit: int) -> AutotekaLeadsResult: + return LeadsClient(self.transport).get_leads(LeadsRequest(limit=limit)) - def create_preview_by_vin(self, *, payload: Mapping[str, object]) -> AutotekaPreviewInfo: - return PreviewClient(self.transport).create_by_vin(JsonRequest(payload)) + def create_preview_by_vin(self, *, vin: str) -> AutotekaPreviewInfo: + return PreviewClient(self.transport).create_by_vin(VinRequest(vin=vin)) def get_preview(self, *, preview_id: int | str | None = None) -> AutotekaPreviewInfo: return PreviewClient(self.transport).get_preview( - preview_id=preview_id or self._require_resource_id("preview_id") + preview_id=preview_id or self._require_vehicle_id("preview_id") ) - def create_preview_by_external_item( - self, *, payload: Mapping[str, object] - ) -> AutotekaPreviewInfo: - return PreviewClient(self.transport).create_by_external_item(JsonRequest(payload)) + def create_preview_by_external_item(self, *, item_id: str, site: str) -> AutotekaPreviewInfo: + return PreviewClient(self.transport).create_by_external_item( + ExternalItemPreviewRequest(item_id=item_id, site=site) + ) - def create_preview_by_item_id(self, *, payload: Mapping[str, object]) -> AutotekaPreviewInfo: - return PreviewClient(self.transport).create_by_item_id(JsonRequest(payload)) + def create_preview_by_item_id(self, *, item_id: int) -> AutotekaPreviewInfo: + return PreviewClient(self.transport).create_by_item_id(ItemIdRequest(item_id=item_id)) - def create_preview_by_reg_number(self, *, payload: Mapping[str, object]) -> AutotekaPreviewInfo: - return PreviewClient(self.transport).create_by_reg_number(JsonRequest(payload)) + def create_preview_by_reg_number(self, *, reg_number: str) -> AutotekaPreviewInfo: + return PreviewClient(self.transport).create_by_reg_number( + RegNumberRequest(reg_number=reg_number) + ) def create_specification_by_plate_number( - self, *, payload: Mapping[str, object] + self, *, plate_number: str ) -> AutotekaSpecificationInfo: - return SpecificationsClient(self.transport).create_by_plate_number(JsonRequest(payload)) + return SpecificationsClient(self.transport).create_by_plate_number( + PlateNumberRequest(plate_number=plate_number) + ) def create_specification_by_vehicle_id( - self, *, payload: Mapping[str, object] + self, *, vehicle_id: str ) -> AutotekaSpecificationInfo: - return SpecificationsClient(self.transport).create_by_vehicle_id(JsonRequest(payload)) + return SpecificationsClient(self.transport).create_by_vehicle_id( + VehicleIdRequest(vehicle_id=vehicle_id) + ) - def get_specification_get_by_id( + def get_specification_by_id( self, *, specification_id: int | str | None = None, ) -> AutotekaSpecificationInfo: return SpecificationsClient(self.transport).get_by_id( - specification_id=specification_id or self._require_resource_id("specification_id") + specification_id=specification_id or self._require_vehicle_id("specification_id") ) - def create_teaser(self, *, payload: Mapping[str, object]) -> AutotekaTeaserInfo: - return TeaserClient(self.transport).create(JsonRequest(payload)) + def create_teaser(self, *, vehicle_id: str) -> AutotekaTeaserInfo: + return TeaserClient(self.transport).create(TeaserCreateRequest(vehicle_id=vehicle_id)) def get_teaser(self, *, teaser_id: int | str | None = None) -> AutotekaTeaserInfo: return TeaserClient(self.transport).get( - teaser_id=teaser_id or self._require_resource_id("teaser_id") + teaser_id=teaser_id or self._require_vehicle_id("teaser_id") ) - def _require_resource_id(self, field_name: str) -> str: - if self.resource_id is None: - raise ValueError(f"Для операции требуется `{field_name}`.") - return str(self.resource_id) + def _require_vehicle_id(self, field_name: str) -> str: + if self.vehicle_id is None: + raise ValidationError(f"Для операции требуется `{field_name}`.") + return str(self.vehicle_id) @dataclass(slots=True, frozen=True) class AutotekaReport(DomainObject): """Доменный объект отчетов и пакетов Автотеки.""" - resource_id: int | str | None = None + report_id: int | str | None = None user_id: int | str | None = None def get_active_package(self) -> AutotekaPackageInfo: return ReportClient(self.transport).get_active_package() - def create_report(self, *, payload: Mapping[str, object]) -> AutotekaReportInfo: - return ReportClient(self.transport).create_report(JsonRequest(payload)) + def create_report(self, *, preview_id: int) -> AutotekaReportInfo: + return ReportClient(self.transport).create_report(PreviewReportRequest(preview_id=preview_id)) - def create_report_by_vehicle_id(self, *, payload: Mapping[str, object]) -> AutotekaReportInfo: - return ReportClient(self.transport).create_report_by_vehicle_id(JsonRequest(payload)) + def create_report_by_vehicle_id(self, *, vehicle_id: str) -> AutotekaReportInfo: + return ReportClient(self.transport).create_report_by_vehicle_id( + VehicleIdRequest(vehicle_id=vehicle_id) + ) + + def list_reports(self) -> AutotekaReportsResult: + """Получает список отчетов Автотеки.""" - def list_report_list(self) -> AutotekaReportsResult: return ReportClient(self.transport).list_reports() def get_report(self, *, report_id: int | str | None = None) -> AutotekaReportInfo: return ReportClient(self.transport).get_report( - report_id=report_id or self._require_resource_id() + report_id=report_id or self._require_report_id() ) - def create_sync_create_report_by_reg_number( - self, *, payload: Mapping[str, object] - ) -> AutotekaReportInfo: - return ReportClient(self.transport).create_sync_report_by_reg_number(JsonRequest(payload)) + def create_sync_report_by_reg_number(self, *, reg_number: str) -> AutotekaReportInfo: + return ReportClient(self.transport).create_sync_report_by_reg_number( + RegNumberRequest(reg_number=reg_number) + ) - def create_sync_create_report_by_vin( - self, *, payload: Mapping[str, object] - ) -> AutotekaReportInfo: - return ReportClient(self.transport).create_sync_report_by_vin(JsonRequest(payload)) + def create_sync_report_by_vin(self, *, vin: str) -> AutotekaReportInfo: + return ReportClient(self.transport).create_sync_report_by_vin(VinRequest(vin=vin)) - def _require_resource_id(self) -> str: - if self.resource_id is None: - raise ValueError("Для операции требуется `report_id`.") - return str(self.resource_id) + def _require_report_id(self) -> str: + if self.report_id is None: + raise ValidationError("Для операции требуется `report_id`.") + return str(self.report_id) @dataclass(slots=True, frozen=True) class AutotekaMonitoring(DomainObject): """Доменный объект мониторинга Автотеки.""" - resource_id: int | str | None = None user_id: int | str | None = None - def create_monitoring_bucket_add( - self, *, payload: Mapping[str, object] - ) -> MonitoringBucketResult: - return MonitoringClient(self.transport).add_bucket(JsonRequest(payload)) + def create_monitoring_bucket_add(self, *, vehicles: list[str]) -> MonitoringBucketResult: + return MonitoringClient(self.transport).add_bucket( + MonitoringBucketRequest(vehicles=vehicles) + ) + + def delete_bucket(self) -> MonitoringBucketResult: + """Очищает bucket мониторинга.""" - def list_monitoring_bucket_delete(self) -> MonitoringBucketResult: return MonitoringClient(self.transport).delete_bucket() - def delete_monitoring_bucket_remove( - self, *, payload: Mapping[str, object] - ) -> MonitoringBucketResult: - return MonitoringClient(self.transport).remove_bucket(JsonRequest(payload)) + def remove_bucket(self, *, vehicles: list[str]) -> MonitoringBucketResult: + """Удаляет автомобили из bucket мониторинга.""" - def get_monitoring_get_reg_actions( + return MonitoringClient(self.transport).remove_bucket( + MonitoringBucketRequest(vehicles=vehicles) + ) + + def get_monitoring_reg_actions( self, *, - params: Mapping[str, object] | None = None, + query: MonitoringEventsQuery | None = None, ) -> MonitoringEventsResult: - return MonitoringClient(self.transport).get_reg_actions(params=params) + return MonitoringClient(self.transport).get_reg_actions(query=query) @dataclass(slots=True, frozen=True) class AutotekaScoring(DomainObject): """Доменный объект скоринга рисков.""" - resource_id: int | str | None = None + scoring_id: int | str | None = None user_id: int | str | None = None - def create_scoring_by_vehicle_id(self, *, payload: Mapping[str, object]) -> AutotekaScoringInfo: - return ScoringClient(self.transport).create_by_vehicle_id(JsonRequest(payload)) + def create_scoring_by_vehicle_id(self, *, vehicle_id: str) -> AutotekaScoringInfo: + return ScoringClient(self.transport).create_by_vehicle_id( + VehicleIdRequest(vehicle_id=vehicle_id) + ) - def get_scoring_get_by_id(self, *, scoring_id: int | str | None = None) -> AutotekaScoringInfo: + def get_scoring_by_id(self, *, scoring_id: int | str | None = None) -> AutotekaScoringInfo: return ScoringClient(self.transport).get_by_id( - scoring_id=scoring_id or self._require_resource_id() + scoring_id=scoring_id or self._require_scoring_id() ) - def _require_resource_id(self) -> str: - if self.resource_id is None: - raise ValueError("Для операции требуется `scoring_id`.") - return str(self.resource_id) + def _require_scoring_id(self) -> str: + if self.scoring_id is None: + raise ValidationError("Для операции требуется `scoring_id`.") + return str(self.scoring_id) @dataclass(slots=True, frozen=True) class AutotekaValuation(DomainObject): """Доменный объект оценки автомобиля.""" - resource_id: int | str | None = None user_id: int | str | None = None def get_valuation_by_specification( - self, *, payload: Mapping[str, object] + self, *, specification_id: int, mileage: int ) -> AutotekaValuationInfo: - return ValuationClient(self.transport).get_by_specification(JsonRequest(payload)) + return ValuationClient(self.transport).get_by_specification( + ValuationBySpecificationRequest(specification_id=specification_id, mileage=mileage) + ) __all__ = ( @@ -214,5 +235,4 @@ def get_valuation_by_specification( "AutotekaScoring", "AutotekaValuation", "AutotekaVehicle", - "DomainObject", ) diff --git a/avito/autoteka/enums.py b/avito/autoteka/enums.py deleted file mode 100644 index c465198..0000000 --- a/avito/autoteka/enums.py +++ /dev/null @@ -1 +0,0 @@ -"""Строковые enum и константы пакета autoteka.""" diff --git a/avito/autoteka/mappers.py b/avito/autoteka/mappers.py index 45c3ed8..6f1c90d 100644 --- a/avito/autoteka/mappers.py +++ b/avito/autoteka/mappers.py @@ -94,15 +94,12 @@ def map_catalogs_resolve(payload: object) -> CatalogResolveResult: CatalogFieldValue( value_id=_str(value, "valueId", "id"), label=_str(value, "label", "value"), - raw_payload=value, ) for value in _list(item, "values", "items") ], - raw_payload=item, ) for item in _list(result, "fields", "items") ], - raw_payload=data, ) @@ -125,10 +122,9 @@ def map_leads(payload: object) -> AutotekaLeadsResult: price=_int(event_payload, "price"), created_at=_str(event_payload, "itemCreatedAt"), url=_str(event_payload, "url"), - raw_payload=item, ) ) - return AutotekaLeadsResult(items=items, last_id=_int(pagination, "lastId"), raw_payload=data) + return AutotekaLeadsResult(items=items, last_id=_int(pagination, "lastId")) def map_monitoring_bucket(payload: object) -> MonitoringBucketResult: @@ -142,11 +138,9 @@ def map_monitoring_bucket(payload: object) -> MonitoringBucketResult: MonitoringInvalidVehicle( vehicle_id=_str(item, "vehicleID", "vehicleId"), description=_str(item, "description"), - raw_payload=item, ) for item in _list(result, "invalidVehicles", "items") ], - raw_payload=data, ) @@ -167,14 +161,12 @@ def map_monitoring_events(payload: object) -> MonitoringEventsResult: operation_date_to=_str(item, "operationDateTo"), owner_code=_int(item, "ownerCode"), actual_at=_int(item, "actualAt"), - raw_payload=item, ) for item in _list(data, "data", "items") ], has_next=_bool(pagination, "hasNext"), next_cursor=_str(pagination, "nextCursor"), next_link=_str(pagination, "nextLink"), - raw_payload=data, ) @@ -189,7 +181,6 @@ def map_package(payload: object) -> AutotekaPackageInfo: reports_remaining=_int(package, "reportsCntRemain"), created_at=_str(package, "createdTime"), expires_at=_str(package, "expireTime"), - raw_payload=data, ) @@ -199,7 +190,6 @@ def _map_preview_source(source: Payload) -> AutotekaPreviewInfo: status=_str(source, "status"), vehicle_id=_str(source, "vin", "vehicleId"), reg_number=_str(source, "regNumber", "plateNumber"), - raw_payload=source, ) @@ -222,7 +212,6 @@ def _map_report_source(source: Payload) -> AutotekaReportInfo: created_at=_str(source, "createdAt") or _str(data, "createdAt"), web_link=_str(source, "webLink"), pdf_link=_str(source, "pdfLink"), - raw_payload=source, ) @@ -242,7 +231,6 @@ def map_reports(payload: object) -> AutotekaReportsResult: data = _expect_mapping(payload) return AutotekaReportsResult( items=[_map_report_source(item) for item in _list(data, "result", "items")], - raw_payload=data, ) @@ -257,7 +245,6 @@ def map_scoring(payload: object) -> AutotekaScoringInfo: scoring_id=_str(source, "scoringId"), is_completed=_bool(source, "isCompleted"), created_at=_int(source, "createdAt"), - raw_payload=source, ) @@ -273,7 +260,6 @@ def map_specification(payload: object) -> AutotekaSpecificationInfo: status=_str(source, "status"), vehicle_id=_str(source, "vehicleId"), plate_number=_str(source, "plateNumber"), - raw_payload=source, ) @@ -291,7 +277,6 @@ def map_teaser(payload: object) -> AutotekaTeaserInfo: brand=_str(teaser_data, "brand") if teaser_data else _str(source, "brand"), model=_str(teaser_data, "model") if teaser_data else _str(source, "model"), year=_int(teaser_data, "year") if teaser_data else _int(source, "year"), - raw_payload=data, ) @@ -312,5 +297,4 @@ def map_valuation(payload: object) -> AutotekaValuationInfo: mileage=_int(source, "mileage"), avg_price_with_condition=_int(valuation, "avgPriceWithCondition"), avg_market_price=_int(valuation, "avgMarketPrice"), - raw_payload=data, ) diff --git a/avito/autoteka/models.py b/avito/autoteka/models.py index eaab155..483b957 100644 --- a/avito/autoteka/models.py +++ b/avito/autoteka/models.py @@ -2,52 +2,199 @@ from __future__ import annotations -from collections.abc import Mapping -from dataclasses import dataclass, field +from dataclasses import dataclass + +from avito.core.serialization import SerializableModel + + +@dataclass(slots=True, frozen=True) +class CatalogResolveRequest: + """Запрос актуализации параметров автокаталога.""" + + brand_id: int + + def to_payload(self) -> dict[str, object]: + """Сериализует запрос автокаталога.""" + + return {"brandId": self.brand_id} + + +@dataclass(slots=True, frozen=True) +class LeadsRequest: + """Запрос событий сервиса Сигнал.""" + + limit: int + + def to_payload(self) -> dict[str, object]: + """Сериализует запрос событий Сигнал.""" + + return {"limit": self.limit} + + +@dataclass(slots=True, frozen=True) +class VinRequest: + """Запрос по VIN.""" + + vin: str + + def to_payload(self) -> dict[str, object]: + """Сериализует VIN-запрос.""" + + return {"vin": self.vin} + + +@dataclass(slots=True, frozen=True) +class VehicleIdRequest: + """Запрос по идентификатору автомобиля.""" + + vehicle_id: str + + def to_payload(self) -> dict[str, object]: + """Сериализует запрос по vehicle id.""" + + return {"vehicleId": self.vehicle_id} + + +@dataclass(slots=True, frozen=True) +class ItemIdRequest: + """Запрос по идентификатору объявления.""" + + item_id: int + + def to_payload(self) -> dict[str, object]: + """Сериализует запрос по item id.""" + + return {"itemId": self.item_id} + + +@dataclass(slots=True, frozen=True) +class ExternalItemPreviewRequest: + """Запрос превью по внешнему объявлению.""" + + item_id: str + site: str + + def to_payload(self) -> dict[str, object]: + """Сериализует запрос внешнего объявления.""" + + return {"itemId": self.item_id, "site": self.site} + + +@dataclass(slots=True, frozen=True) +class RegNumberRequest: + """Запрос по государственному номеру.""" + + reg_number: str + + def to_payload(self) -> dict[str, object]: + """Сериализует запрос по госномеру.""" + + return {"regNumber": self.reg_number} + + +@dataclass(slots=True, frozen=True) +class PlateNumberRequest: + """Запрос по номерному знаку.""" + + plate_number: str + + def to_payload(self) -> dict[str, object]: + """Сериализует запрос по номерному знаку.""" + + return {"plateNumber": self.plate_number} + + +@dataclass(slots=True, frozen=True) +class PreviewReportRequest: + """Запрос отчета по preview id.""" + + preview_id: int + + def to_payload(self) -> dict[str, object]: + """Сериализует запрос отчета по preview id.""" + + return {"previewId": self.preview_id} + + +@dataclass(slots=True, frozen=True) +class MonitoringBucketRequest: + """Запрос изменения списка мониторинга.""" + + vehicles: list[str] + + def to_payload(self) -> dict[str, object]: + """Сериализует запрос изменения списка мониторинга.""" + + return {"vehicles": list(self.vehicles)} + + +@dataclass(slots=True, frozen=True) +class MonitoringEventsQuery: + """Query событий мониторинга.""" + + limit: int | None = None + + def to_params(self) -> dict[str, object]: + """Сериализует query событий мониторинга.""" + + params: dict[str, object] = {} + if self.limit is not None: + params["limit"] = self.limit + return params + + +@dataclass(slots=True, frozen=True) +class TeaserCreateRequest: + """Запрос создания тизера.""" + + vehicle_id: str + + def to_payload(self) -> dict[str, object]: + """Сериализует запрос создания тизера.""" + + return {"vehicleId": self.vehicle_id} @dataclass(slots=True, frozen=True) -class JsonRequest: - """Типизированная обертка над JSON payload запроса.""" +class ValuationBySpecificationRequest: + """Запрос оценки автомобиля по specification id.""" - payload: Mapping[str, object] + specification_id: int + mileage: int def to_payload(self) -> dict[str, object]: - """Сериализует payload запроса.""" + """Сериализует запрос оценки автомобиля.""" - return dict(self.payload) + return {"specificationId": self.specification_id, "mileage": self.mileage} @dataclass(slots=True, frozen=True) -class CatalogFieldValue: +class CatalogFieldValue(SerializableModel): """Значение параметра автокаталога.""" value_id: str | None label: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class CatalogField: +class CatalogField(SerializableModel): """Параметр автокаталога.""" field_id: str | None label: str | None data_type: str | None values: list[CatalogFieldValue] - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class CatalogResolveResult: +class CatalogResolveResult(SerializableModel): """Результат актуализации параметров автокаталога.""" items: list[CatalogField] - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class AutotekaLeadEvent: +class AutotekaLeadEvent(SerializableModel): """Событие сервиса Сигнал.""" event_id: str | None @@ -59,38 +206,34 @@ class AutotekaLeadEvent: price: int | None created_at: str | None url: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class AutotekaLeadsResult: +class AutotekaLeadsResult(SerializableModel): """Список событий сервиса Сигнал.""" items: list[AutotekaLeadEvent] last_id: int | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class MonitoringInvalidVehicle: +class MonitoringInvalidVehicle(SerializableModel): """Невалидный идентификатор авто в запросах мониторинга.""" vehicle_id: str | None description: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class MonitoringBucketResult: +class MonitoringBucketResult(SerializableModel): """Результат изменения списка мониторинга.""" success: bool invalid_vehicles: list[MonitoringInvalidVehicle] - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class MonitoringEvent: +class MonitoringEvent(SerializableModel): """Событие мониторинга регистрационных действий.""" vehicle_id: str | None @@ -102,44 +245,40 @@ class MonitoringEvent: operation_date_to: str | None owner_code: int | None actual_at: int | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class MonitoringEventsResult: +class MonitoringEventsResult(SerializableModel): """Список событий мониторинга.""" items: list[MonitoringEvent] has_next: bool | None = None next_cursor: str | None = None next_link: str | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class AutotekaPackageInfo: +class AutotekaPackageInfo(SerializableModel): """Информация о текущем пакете отчетов Автотеки.""" reports_total: int | None reports_remaining: int | None created_at: str | None expires_at: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class AutotekaPreviewInfo: +class AutotekaPreviewInfo(SerializableModel): """Информация о превью автомобиля.""" preview_id: str | None status: str | None vehicle_id: str | None reg_number: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class AutotekaReportInfo: +class AutotekaReportInfo(SerializableModel): """Информация об отчете Автотеки.""" report_id: str | None @@ -148,40 +287,36 @@ class AutotekaReportInfo: created_at: str | None web_link: str | None pdf_link: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class AutotekaReportsResult: +class AutotekaReportsResult(SerializableModel): """Список отчетов Автотеки.""" items: list[AutotekaReportInfo] - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class AutotekaScoringInfo: +class AutotekaScoringInfo(SerializableModel): """Информация о скоринге рисков.""" scoring_id: str | None is_completed: bool | None created_at: int | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class AutotekaSpecificationInfo: +class AutotekaSpecificationInfo(SerializableModel): """Информация о запросе спецификации автомобиля.""" specification_id: str | None status: str | None vehicle_id: str | None plate_number: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class AutotekaTeaserInfo: +class AutotekaTeaserInfo(SerializableModel): """Информация о тизере Автотеки.""" teaser_id: str | None @@ -189,11 +324,10 @@ class AutotekaTeaserInfo: brand: str | None = None model: str | None = None year: int | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class AutotekaValuationInfo: +class AutotekaValuationInfo(SerializableModel): """Оценка стоимости автомобиля.""" status: str | None @@ -205,4 +339,3 @@ class AutotekaValuationInfo: mileage: int | None avg_price_with_condition: int | None avg_market_price: int | None - raw_payload: Mapping[str, object] = field(default_factory=dict) diff --git a/avito/client/client.py b/avito/client.py similarity index 60% rename from avito/client/client.py rename to avito/client.py index cdee040..4807762 100644 --- a/avito/client/client.py +++ b/avito/client.py @@ -2,11 +2,14 @@ from __future__ import annotations +from pathlib import Path +from types import TracebackType + import httpx from avito.accounts import Account, AccountHierarchy -from avito.ads import Ad, AdPromotion, AdStats, AutoloadLegacy, AutoloadProfile, AutoloadReport -from avito.auth import AuthProvider, LegacyTokenClient, TokenClient +from avito.ads import Ad, AdPromotion, AdStats, AutoloadArchive, AutoloadProfile, AutoloadReport +from avito.auth import AlternateTokenClient, AuthProvider, TokenClient from avito.autoteka import ( AutotekaMonitoring, AutotekaReport, @@ -16,7 +19,8 @@ ) from avito.config import AvitoSettings from avito.core import Transport, TransportDebugInfo -from avito.cpa import CallTrackingCall, CpaCall, CpaChat, CpaLead, CpaLegacy +from avito.core.transport import build_httpx_timeout +from avito.cpa import CallTrackingCall, CpaArchive, CpaCall, CpaChat, CpaLead from avito.jobs import Application, JobDictionary, JobWebhook, Resume, Vacancy from avito.messenger import Chat, ChatMedia, ChatMessage, ChatWebhook, SpecialOfferCampaign from avito.orders import DeliveryOrder, DeliveryTask, Order, OrderLabel, SandboxDelivery, Stock @@ -46,11 +50,28 @@ class AvitoClient: ``` """ - def __init__(self, settings: AvitoSettings | None = None) -> None: - self.settings = settings or AvitoSettings.from_env() + def __init__( + self, + settings: AvitoSettings | None = None, + *, + client_id: str | None = None, + client_secret: str | None = None, + ) -> None: + if client_id is not None or client_secret is not None: + from avito.auth.settings import AuthSettings + + auth = AuthSettings(client_id=client_id, client_secret=client_secret) + settings = AvitoSettings(auth=auth) + self.settings = (settings or AvitoSettings.from_env()).validate_required() self.auth_provider = self._build_auth_provider() self.transport = Transport(self.settings, auth_provider=self.auth_provider) + @classmethod + def from_env(cls, *, env_file: str | Path | None = ".env") -> AvitoClient: + """Создает клиент из переменных окружения и optional `.env` файла.""" + + return cls(AvitoSettings.from_env(env_file=env_file)) + def auth(self) -> AuthProvider: """Возвращает объект аутентификации и token-flow операций.""" @@ -72,19 +93,36 @@ def __enter__(self) -> AvitoClient: return self - def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: """Закрывает клиент при выходе из контекстного менеджера.""" self.close() def _build_auth_provider(self) -> AuthProvider: - token_http_client = httpx.Client(base_url=self.settings.base_url.rstrip("/")) - legacy_http_client = httpx.Client(base_url=self.settings.base_url.rstrip("/")) - autoteka_http_client = httpx.Client(base_url=self.settings.base_url.rstrip("/")) + timeout = build_httpx_timeout(self.settings.timeouts) + token_http_client = httpx.Client( + base_url=self.settings.base_url.rstrip("/"), + timeout=timeout, + ) + alternate_http_client = httpx.Client( + base_url=self.settings.base_url.rstrip("/"), + timeout=timeout, + ) + autoteka_http_client = httpx.Client( + base_url=self.settings.base_url.rstrip("/"), + timeout=timeout, + ) return AuthProvider( self.settings.auth, token_client=TokenClient(self.settings.auth, client=token_http_client), - legacy_token_client=LegacyTokenClient(self.settings.auth, client=legacy_http_client), + alternate_token_client=AlternateTokenClient( + self.settings.auth, client=alternate_http_client + ), autoteka_token_client=TokenClient( self.settings.auth, token_url=self.settings.auth.autoteka_token_url, @@ -95,51 +133,51 @@ def _build_auth_provider(self) -> AuthProvider: def account(self, user_id: int | str | None = None) -> Account: """Создает доменный объект аккаунта.""" - return Account(self.transport, resource_id=user_id, user_id=user_id) + return Account(self.transport, user_id=user_id) def account_hierarchy(self, user_id: int | str | None = None) -> AccountHierarchy: """Создает доменный объект иерархии аккаунта.""" - return AccountHierarchy(self.transport, resource_id=user_id, user_id=user_id) + return AccountHierarchy(self.transport, user_id=user_id) def ad(self, item_id: int | str | None = None, user_id: int | str | None = None) -> Ad: """Создает доменный объект объявления.""" - return Ad(self.transport, resource_id=item_id, user_id=user_id) + return Ad(self.transport, item_id=item_id, user_id=user_id) def ad_stats( self, item_id: int | str | None = None, user_id: int | str | None = None ) -> AdStats: """Создает доменный объект статистики объявления.""" - return AdStats(self.transport, resource_id=item_id, user_id=user_id) + return AdStats(self.transport, item_id=item_id, user_id=user_id) def ad_promotion( self, item_id: int | str | None = None, user_id: int | str | None = None ) -> AdPromotion: """Создает доменный объект продвижения объявления.""" - return AdPromotion(self.transport, resource_id=item_id, user_id=user_id) + return AdPromotion(self.transport, item_id=item_id, user_id=user_id) def autoload_profile(self, user_id: int | str | None = None) -> AutoloadProfile: """Создает доменный объект профиля автозагрузки.""" - return AutoloadProfile(self.transport, resource_id=user_id, user_id=user_id) + return AutoloadProfile(self.transport, user_id=user_id) def autoload_report(self, report_id: int | str | None = None) -> AutoloadReport: """Создает доменный объект отчета автозагрузки.""" - return AutoloadReport(self.transport, resource_id=report_id) + return AutoloadReport(self.transport, report_id=report_id) - def autoload_legacy(self, report_id: int | str | None = None) -> AutoloadLegacy: - """Создает доменный объект legacy-операций автозагрузки.""" + def autoload_archive(self, report_id: int | str | None = None) -> AutoloadArchive: + """Создает доменный объект архивных операций автозагрузки.""" - return AutoloadLegacy(self.transport, resource_id=report_id) + return AutoloadArchive(self.transport, report_id=report_id) def chat(self, chat_id: int | str | None = None, *, user_id: int | str | None = None) -> Chat: """Создает доменный объект чата.""" - return Chat(self.transport, resource_id=chat_id, user_id=user_id) + return Chat(self.transport, chat_id=chat_id, user_id=user_id) def chat_message( self, @@ -150,100 +188,97 @@ def chat_message( ) -> ChatMessage: """Создает доменный объект сообщения чата.""" - resource_id = message_id if message_id is not None else chat_id - return ChatMessage(self.transport, resource_id=resource_id, user_id=user_id) + return ChatMessage(self.transport, chat_id=chat_id, message_id=message_id, user_id=user_id) def chat_webhook(self) -> ChatWebhook: """Создает доменный объект webhook мессенджера.""" return ChatWebhook(self.transport) - def chat_media( - self, media_id: int | str | None = None, *, user_id: int | str | None = None - ) -> ChatMedia: + def chat_media(self, *, user_id: int | str | None = None) -> ChatMedia: """Создает доменный объект медиа мессенджера.""" - return ChatMedia(self.transport, resource_id=media_id, user_id=user_id) + return ChatMedia(self.transport, user_id=user_id) def special_offer_campaign(self, campaign_id: int | str | None = None) -> SpecialOfferCampaign: """Создает доменный объект рассылки спецпредложений.""" - return SpecialOfferCampaign(self.transport, resource_id=campaign_id) + return SpecialOfferCampaign(self.transport, campaign_id=campaign_id) def promotion_order(self, order_id: int | str | None = None) -> PromotionOrder: """Создает доменный объект заявки на продвижение.""" - return PromotionOrder(self.transport, resource_id=order_id) + return PromotionOrder(self.transport, order_id=order_id) def bbip_promotion(self, item_id: int | str | None = None) -> BbipPromotion: """Создает доменный объект BBIP-продвижения.""" - return BbipPromotion(self.transport, resource_id=item_id) + return BbipPromotion(self.transport, item_id=item_id) def trx_promotion(self, item_id: int | str | None = None) -> TrxPromotion: """Создает доменный объект TrxPromo.""" - return TrxPromotion(self.transport, resource_id=item_id) + return TrxPromotion(self.transport, item_id=item_id) def cpa_auction(self, item_id: int | str | None = None) -> CpaAuction: """Создает доменный объект CPA-аукциона.""" - return CpaAuction(self.transport, resource_id=item_id) + return CpaAuction(self.transport, item_id=item_id) def target_action_pricing(self, item_id: int | str | None = None) -> TargetActionPricing: """Создает доменный объект цены целевого действия.""" - return TargetActionPricing(self.transport, resource_id=item_id) + return TargetActionPricing(self.transport, item_id=item_id) def autostrategy_campaign(self, campaign_id: int | str | None = None) -> AutostrategyCampaign: """Создает доменный объект автостратегии.""" - return AutostrategyCampaign(self.transport, resource_id=campaign_id) + return AutostrategyCampaign(self.transport, campaign_id=campaign_id) - def order(self, order_id: int | str | None = None) -> Order: + def order(self) -> Order: """Создает доменный объект заказа.""" - return Order(self.transport, resource_id=order_id) + return Order(self.transport) def order_label(self, task_id: int | str | None = None) -> OrderLabel: """Создает доменный объект этикетки заказа.""" - return OrderLabel(self.transport, resource_id=task_id) + return OrderLabel(self.transport, task_id=task_id) - def delivery_order(self, order_id: int | str | None = None) -> DeliveryOrder: + def delivery_order(self) -> DeliveryOrder: """Создает доменный объект доставки.""" - return DeliveryOrder(self.transport, resource_id=order_id) + return DeliveryOrder(self.transport) - def sandbox_delivery(self, task_id: int | str | None = None) -> SandboxDelivery: + def sandbox_delivery(self) -> SandboxDelivery: """Создает доменный объект песочницы доставки.""" - return SandboxDelivery(self.transport, resource_id=task_id) + return SandboxDelivery(self.transport) def delivery_task(self, task_id: int | str | None = None) -> DeliveryTask: """Создает доменный объект задачи доставки.""" - return DeliveryTask(self.transport, resource_id=task_id) + return DeliveryTask(self.transport, task_id=task_id) - def stock(self, stock_id: int | str | None = None) -> Stock: + def stock(self) -> Stock: """Создает доменный объект остатков.""" - return Stock(self.transport, resource_id=stock_id) + return Stock(self.transport) def vacancy(self, vacancy_id: int | str | None = None) -> Vacancy: """Создает доменный объект вакансии.""" - return Vacancy(self.transport, resource_id=vacancy_id) + return Vacancy(self.transport, vacancy_id=vacancy_id) - def application(self, application_id: int | str | None = None) -> Application: + def application(self) -> Application: """Создает доменный объект отклика.""" - return Application(self.transport, resource_id=application_id) + return Application(self.transport) def resume(self, resume_id: int | str | None = None) -> Resume: """Создает доменный объект резюме.""" - return Resume(self.transport, resource_id=resume_id) + return Resume(self.transport, resume_id=resume_id) def job_webhook(self) -> JobWebhook: """Создает доменный объект webhook раздела Работа.""" @@ -253,97 +288,117 @@ def job_webhook(self) -> JobWebhook: def job_dictionary(self, dictionary_id: int | str | None = None) -> JobDictionary: """Создает доменный объект словаря Работа.""" - return JobDictionary(self.transport, resource_id=dictionary_id) + return JobDictionary(self.transport, dictionary_id=dictionary_id) - def cpa_lead(self, lead_id: int | str | None = None) -> CpaLead: + def cpa_lead(self) -> CpaLead: """Создает доменный объект CPA-лида.""" - return CpaLead(self.transport, resource_id=lead_id) + return CpaLead(self.transport) def cpa_chat(self, chat_id: int | str | None = None) -> CpaChat: """Создает доменный объект CPA-чата.""" - return CpaChat(self.transport, resource_id=chat_id) + return CpaChat(self.transport, action_id=chat_id) - def cpa_call(self, call_id: int | str | None = None) -> CpaCall: + def cpa_call(self) -> CpaCall: """Создает доменный объект CPA-звонка.""" - return CpaCall(self.transport, resource_id=call_id) + return CpaCall(self.transport) - def cpa_legacy(self, legacy_id: int | str | None = None) -> CpaLegacy: - """Создает доменный объект legacy-операций CPA.""" + def cpa_archive(self, call_id: int | str | None = None) -> CpaArchive: + """Создает доменный объект архивных операций CPA.""" - return CpaLegacy(self.transport, resource_id=legacy_id) + return CpaArchive(self.transport, call_id=call_id) def call_tracking_call(self, call_id: int | str | None = None) -> CallTrackingCall: """Создает доменный объект CallTracking.""" - return CallTrackingCall(self.transport, resource_id=call_id) + return CallTrackingCall(self.transport, call_id=call_id) def autoteka_vehicle(self, vehicle_id: int | str | None = None) -> AutotekaVehicle: """Создает доменный объект транспортного средства Автотеки.""" - return AutotekaVehicle(self.transport, resource_id=vehicle_id) + return AutotekaVehicle(self.transport, vehicle_id=vehicle_id) def autoteka_report(self, report_id: int | str | None = None) -> AutotekaReport: """Создает доменный объект отчета Автотеки.""" - return AutotekaReport(self.transport, resource_id=report_id) + return AutotekaReport(self.transport, report_id=report_id) - def autoteka_monitoring(self, monitoring_id: int | str | None = None) -> AutotekaMonitoring: + def autoteka_monitoring(self) -> AutotekaMonitoring: """Создает доменный объект мониторинга Автотеки.""" - return AutotekaMonitoring(self.transport, resource_id=monitoring_id) + return AutotekaMonitoring(self.transport) def autoteka_scoring(self, scoring_id: int | str | None = None) -> AutotekaScoring: """Создает доменный объект скоринга Автотеки.""" - return AutotekaScoring(self.transport, resource_id=scoring_id) + return AutotekaScoring(self.transport, scoring_id=scoring_id) - def autoteka_valuation(self, valuation_id: int | str | None = None) -> AutotekaValuation: + def autoteka_valuation(self) -> AutotekaValuation: """Создает доменный объект оценки Автотеки.""" - return AutotekaValuation(self.transport, resource_id=valuation_id) + return AutotekaValuation(self.transport) - def realty_listing(self, item_id: int | str | None = None) -> RealtyListing: + def realty_listing( + self, + item_id: int | str | None = None, + *, + user_id: int | str | None = None, + ) -> RealtyListing: """Создает доменный объект объявления недвижимости.""" - return RealtyListing(self.transport, resource_id=item_id) + return RealtyListing(self.transport, item_id=item_id, user_id=user_id) - def realty_booking(self, booking_id: int | str | None = None) -> RealtyBooking: + def realty_booking( + self, + item_id: int | str | None = None, + *, + user_id: int | str | None = None, + ) -> RealtyBooking: """Создает доменный объект бронирования недвижимости.""" - return RealtyBooking(self.transport, resource_id=booking_id) + return RealtyBooking(self.transport, item_id=item_id, user_id=user_id) - def realty_pricing(self, item_id: int | str | None = None) -> RealtyPricing: + def realty_pricing( + self, + item_id: int | str | None = None, + *, + user_id: int | str | None = None, + ) -> RealtyPricing: """Создает доменный объект цен недвижимости.""" - return RealtyPricing(self.transport, resource_id=item_id) + return RealtyPricing(self.transport, item_id=item_id, user_id=user_id) - def realty_analytics_report(self, report_id: int | str | None = None) -> RealtyAnalyticsReport: + def realty_analytics_report( + self, + item_id: int | str | None = None, + *, + user_id: int | str | None = None, + ) -> RealtyAnalyticsReport: """Создает доменный объект аналитического отчета недвижимости.""" - return RealtyAnalyticsReport(self.transport, resource_id=report_id) + return RealtyAnalyticsReport(self.transport, item_id=item_id, user_id=user_id) - def review(self, review_id: int | str | None = None) -> Review: + def review(self) -> Review: """Создает доменный объект отзыва.""" - return Review(self.transport, resource_id=review_id) + return Review(self.transport) def review_answer(self, answer_id: int | str | None = None) -> ReviewAnswer: """Создает доменный объект ответа на отзыв.""" - return ReviewAnswer(self.transport, resource_id=answer_id) + return ReviewAnswer(self.transport, answer_id=answer_id) - def rating_profile(self, profile_id: int | str | None = None) -> RatingProfile: + def rating_profile(self) -> RatingProfile: """Создает доменный объект рейтингового профиля.""" - return RatingProfile(self.transport, resource_id=profile_id) + return RatingProfile(self.transport) def tariff(self, tariff_id: int | str | None = None) -> Tariff: """Создает доменный объект тарифа.""" - return Tariff(self.transport, resource_id=tariff_id) + return Tariff(self.transport, tariff_id=tariff_id) __all__ = ("AvitoClient",) diff --git a/avito/client/__init__.py b/avito/client/__init__.py deleted file mode 100644 index be13f2c..0000000 --- a/avito/client/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Публичные экспорты клиентского пакета.""" - -from avito.client.client import AvitoClient - -__all__ = ("AvitoClient",) diff --git a/avito/config.py b/avito/config.py index c0203d8..551b2ae 100644 --- a/avito/config.py +++ b/avito/config.py @@ -2,47 +2,64 @@ from __future__ import annotations -from pydantic import Field -from pydantic_settings import BaseSettings, SettingsConfigDict +from dataclasses import dataclass, field +from pathlib import Path +from typing import ClassVar +from avito._env import parse_env_int, resolve_env_aliases from avito.auth.settings import AuthSettings from avito.core.retries import RetryPolicy from avito.core.types import ApiTimeouts -class AvitoSettings(BaseSettings): - """Корневая конфигурация SDK с настройками transport и авторизации.""" +@dataclass(slots=True, frozen=True) +class AvitoSettings: + """Единственный публичный контракт конфигурации SDK.""" - model_config = SettingsConfigDict( - env_prefix="AVITO_", - env_file=".env", - env_nested_delimiter="__", - extra="ignore", - ) + ENV_ALIASES: ClassVar[dict[str, tuple[str, ...]]] = { + "base_url": ("AVITO_BASE_URL",), + "user_id": ("AVITO_USER_ID",), + } base_url: str = "https://api.avito.ru" user_id: int | None = None - auth: AuthSettings = Field(default_factory=AuthSettings) - timeouts: ApiTimeouts = Field(default_factory=ApiTimeouts) - retry_policy: RetryPolicy = Field(default_factory=RetryPolicy) + auth: AuthSettings = field(default_factory=AuthSettings) + timeouts: ApiTimeouts = field(default_factory=ApiTimeouts) + retry_policy: RetryPolicy = field(default_factory=RetryPolicy) - @property - def client_id(self) -> str | None: - """Возвращает client id для совместимости с ранними версиями SDK.""" + @classmethod + def from_env(cls, *, env_file: str | Path | None = ".env") -> AvitoSettings: + """Загружает конфигурацию из окружения и optional `.env` файла.""" - return self.auth.client_id + resolved_values = resolve_env_aliases(cls.ENV_ALIASES, env_file=env_file) + user_id = resolved_values.get("user_id") + auth_settings = AuthSettings.from_env(env_file=env_file) + return cls( + base_url=resolved_values.get("base_url", "https://api.avito.ru"), + user_id=parse_env_int(user_id, field_name="user_id") if user_id is not None else None, + auth=auth_settings, + timeouts=ApiTimeouts.from_env(env_file=env_file), + retry_policy=RetryPolicy.from_env(env_file=env_file), + ).validate_required() - @property - def client_secret(self) -> str | None: - """Возвращает client secret для совместимости с ранними версиями SDK.""" + @classmethod + def supported_env_vars(cls) -> dict[str, tuple[str, ...]]: + """Возвращает документированный набор env-переменных SDK.""" - return self.auth.client_secret + env_vars = dict(cls.ENV_ALIASES) + env_vars.update( + { + f"auth.{field_name}": aliases + for field_name, aliases in AuthSettings.supported_env_vars().items() + } + ) + return env_vars - @classmethod - def from_env(cls) -> AvitoSettings: - """Загружает конфигурацию SDK из переменных окружения.""" + def validate_required(self) -> AvitoSettings: + """Проверяет обязательные части публичной конфигурации SDK.""" - return cls() + self.auth.validate_required() + return self __all__ = ("AvitoSettings",) diff --git a/avito/core/__init__.py b/avito/core/__init__.py index 203f496..c0f7b94 100644 --- a/avito/core/__init__.py +++ b/avito/core/__init__.py @@ -1,47 +1,102 @@ """Пакет общей инфраструктуры SDK.""" -from avito.core.exceptions import ( - AuthenticationError, - AvitoError, - ClientError, - NotFoundError, - PermissionDeniedError, - RateLimitError, - ResponseMappingError, - ServerError, - TransportError, - ValidationError, -) -from avito.core.pagination import PaginatedList, Paginator -from avito.core.retries import RetryDecision, RetryPolicy -from avito.core.transport import Transport -from avito.core.types import ( - ApiTimeouts, - BinaryResponse, - JsonPage, - RequestContext, - TransportDebugInfo, -) +from __future__ import annotations + +from importlib import import_module +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from avito.core.domain import DomainObject + from avito.core.exceptions import ( + AuthenticationError, + AuthorizationError, + AvitoError, + ClientError, + ConfigurationError, + ConflictError, + NotFoundError, + RateLimitError, + ResponseMappingError, + ServerError, + TransportError, + UnsupportedOperationError, + UpstreamApiError, + ValidationError, + ) + from avito.core.pagination import PaginatedList, Paginator + from avito.core.retries import RetryDecision, RetryPolicy + from avito.core.serialization import SerializableModel + from avito.core.transport import Transport + from avito.core.types import ( + ApiTimeouts, + BinaryResponse, + JsonPage, + RequestContext, + TransportDebugInfo, + ) __all__ = ( "ApiTimeouts", "AuthenticationError", + "AuthorizationError", "AvitoError", "BinaryResponse", "ClientError", + "ConfigurationError", + "ConflictError", + "DomainObject", "JsonPage", "NotFoundError", "PaginatedList", "Paginator", - "PermissionDeniedError", "RateLimitError", "RequestContext", "ResponseMappingError", "RetryDecision", "RetryPolicy", + "SerializableModel", "ServerError", "Transport", "TransportDebugInfo", "TransportError", + "UnsupportedOperationError", + "UpstreamApiError", "ValidationError", ) + +_EXPORT_MODULES = { + "ApiTimeouts": "avito.core.types", + "AuthenticationError": "avito.core.exceptions", + "AuthorizationError": "avito.core.exceptions", + "AvitoError": "avito.core.exceptions", + "BinaryResponse": "avito.core.types", + "ClientError": "avito.core.exceptions", + "ConfigurationError": "avito.core.exceptions", + "ConflictError": "avito.core.exceptions", + "DomainObject": "avito.core.domain", + "JsonPage": "avito.core.types", + "NotFoundError": "avito.core.exceptions", + "PaginatedList": "avito.core.pagination", + "Paginator": "avito.core.pagination", + "RateLimitError": "avito.core.exceptions", + "RequestContext": "avito.core.types", + "ResponseMappingError": "avito.core.exceptions", + "RetryDecision": "avito.core.retries", + "RetryPolicy": "avito.core.retries", + "SerializableModel": "avito.core.serialization", + "ServerError": "avito.core.exceptions", + "Transport": "avito.core.transport", + "TransportDebugInfo": "avito.core.types", + "TransportError": "avito.core.exceptions", + "UnsupportedOperationError": "avito.core.exceptions", + "UpstreamApiError": "avito.core.exceptions", + "ValidationError": "avito.core.exceptions", +} + + +def __getattr__(name: str) -> object: + module_name = _EXPORT_MODULES.get(name) + if module_name is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + module = import_module(module_name) + return getattr(module, name) diff --git a/avito/core/domain.py b/avito/core/domain.py new file mode 100644 index 0000000..92a8ab1 --- /dev/null +++ b/avito/core/domain.py @@ -0,0 +1,17 @@ +"""Базовый доменный объект SDK.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from avito.core.transport import Transport + + +@dataclass(slots=True, frozen=True) +class DomainObject: + """Базовый доменный объект с доступом к transport-слою.""" + + transport: Transport + + +__all__ = ("DomainObject",) diff --git a/avito/core/exceptions.py b/avito/core/exceptions.py index 19e2e99..e03848c 100644 --- a/avito/core/exceptions.py +++ b/avito/core/exceptions.py @@ -3,21 +3,85 @@ from __future__ import annotations from collections.abc import Mapping -from dataclasses import dataclass +from dataclasses import FrozenInstanceError, dataclass, field + +_SECRET_KEYS = ( + "authorization", + "access_token", + "refresh_token", + "token", + "client_secret", + "secret", + "password", +) + + +def _is_secret_key(key: object) -> bool: + return isinstance(key, str) and any(secret in key.lower() for secret in _SECRET_KEYS) + + +def sanitize_metadata(value: object) -> object: + """Удаляет секреты из диагностических метаданных исключения.""" + + if isinstance(value, Mapping): + sanitized: dict[str, object] = {} + for key, item in value.items(): + if _is_secret_key(key): + sanitized[str(key)] = "***" + else: + sanitized[str(key)] = sanitize_metadata(item) + return sanitized + if isinstance(value, list): + return [sanitize_metadata(item) for item in value] + if isinstance(value, tuple): + return tuple(sanitize_metadata(item) for item in value) + if isinstance(value, str) and any(secret in value.lower() for secret in _SECRET_KEYS): + return "***" + return value @dataclass(slots=True) class AvitoError(Exception): - """Базовое исключение SDK с метаданными HTTP-ответа.""" + """Базовое исключение SDK с безопасными диагностическими метаданными.""" message: str status_code: int | None = None error_code: str | None = None + operation: str | None = None + metadata: Mapping[str, object] = field(default_factory=dict) payload: object | None = None headers: Mapping[str, str] | None = None + _is_initialized: bool = field(init=False, default=False, repr=False, compare=False) + + def __setattr__(self, name: str, value: object) -> None: + if name == "_is_initialized": + object.__setattr__(self, name, value) + return + + if not getattr(self, "_is_initialized", False): + object.__setattr__(self, name, value) + return + + if name in self.__dataclass_fields__: + raise FrozenInstanceError(f"нельзя присвоить значение полю {name!r}") + + object.__setattr__(self, name, value) + + def __post_init__(self) -> None: + sanitized_payload = sanitize_metadata(self.payload) + sanitized_headers = ( + sanitize_metadata(dict(self.headers)) if self.headers is not None else None + ) + sanitized_metadata = sanitize_metadata(dict(self.metadata)) + object.__setattr__(self, "payload", sanitized_payload) + object.__setattr__(self, "headers", sanitized_headers) + object.__setattr__(self, "metadata", sanitized_metadata) + object.__setattr__(self, "_is_initialized", True) def __str__(self) -> str: details: list[str] = [self.message] + if self.operation is not None: + details.append(f"operation={self.operation}") if self.status_code is not None: details.append(f"status={self.status_code}") if self.error_code is not None: @@ -30,31 +94,47 @@ class TransportError(AvitoError): class AuthenticationError(AvitoError): - """Ошибка аутентификации или получения access token.""" + """Ошибка аутентификации: неверные credentials или истёкший токен (HTTP 401).""" -class PermissionDeniedError(AvitoError): - """Недостаточно прав для выполнения операции.""" +class AuthorizationError(AvitoError): + """Ошибка авторизации: недостаточно прав для операции (HTTP 403).""" -class NotFoundError(AvitoError): - """Запрошенный ресурс не найден.""" +class ValidationError(AvitoError): + """API отклонил запрос из-за некорректных параметров (HTTP 400, 422).""" -class ValidationError(AvitoError): - """API отклонил запрос из-за некорректных параметров.""" +class ConfigurationError(AvitoError): + """SDK сконфигурирован некорректно — ошибка обнаружена до выполнения HTTP-запроса.""" class RateLimitError(AvitoError): - """Превышен лимит запросов API.""" + """Превышен лимит запросов API (HTTP 429).""" + + +class ConflictError(AvitoError): + """Операция конфликтует с текущим состоянием upstream-ресурса (HTTP 409).""" + + +class UnsupportedOperationError(AvitoError): + """Операция не поддерживается публичным Avito API или данным endpoint (HTTP 405, 501).""" + + +class UpstreamApiError(AvitoError): + """Неизвестная ошибка upstream API вне специализированных типов SDK.""" + + +class NotFoundError(UpstreamApiError): + """Запрошенный ресурс не найден (HTTP 404).""" -class ClientError(AvitoError): - """Прочая клиентская ошибка диапазона `4xx`.""" +class ClientError(UpstreamApiError): + """Прочая клиентская ошибка диапазона 4xx без более конкретного типа.""" -class ServerError(AvitoError): - """Серверная ошибка диапазона `5xx`.""" +class ServerError(UpstreamApiError): + """Серверная ошибка диапазона 5xx.""" class ResponseMappingError(AvitoError): @@ -63,13 +143,18 @@ class ResponseMappingError(AvitoError): __all__ = ( "AuthenticationError", + "AuthorizationError", "AvitoError", "ClientError", + "ConfigurationError", + "ConflictError", "NotFoundError", - "PermissionDeniedError", "RateLimitError", "ResponseMappingError", "ServerError", "TransportError", + "UnsupportedOperationError", + "UpstreamApiError", "ValidationError", + "sanitize_metadata", ) diff --git a/avito/core/mapping.py b/avito/core/mapping.py new file mode 100644 index 0000000..2a6ea2c --- /dev/null +++ b/avito/core/mapping.py @@ -0,0 +1,33 @@ +"""Внутренние helper-ы для преобразования transport payload в публичные SDK-модели.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping + +from avito.core.transport import Transport +from avito.core.types import HttpMethod, RequestContext + + +def request_public_model[ModelT]( + transport: Transport, + method: HttpMethod, + path: str, + *, + context: RequestContext, + mapper: Callable[[object], ModelT], + params: Mapping[str, object] | None = None, + json_body: Mapping[str, object] | None = None, +) -> ModelT: + """Выполняет HTTP-запрос и маппит JSON в публичную модель SDK.""" + + payload = transport.request_json( + method, + path, + context=context, + params=params, + json_body=json_body, + ) + return mapper(payload) + + +__all__ = ("request_public_model",) diff --git a/avito/core/pagination.py b/avito/core/pagination.py index 235f176..0562e9c 100644 --- a/avito/core/pagination.py +++ b/avito/core/pagination.py @@ -11,7 +11,14 @@ class PaginatedList[ItemT](list[ItemT]): - """Ленивый list-like контейнер для элементов из последовательности страниц.""" + """Ленивый list-like контейнер для элементов из последовательности страниц. + + Контракт: + + - уже загруженные элементы читаются без повторных запросов; + - чтение по индексу, slice и частичная итерация подгружают только нужные страницы; + - `materialize()` выполняет явную полную загрузку всех оставшихся страниц. + """ def __init__( self, @@ -79,6 +86,24 @@ def __eq__(self, other: object) -> bool: self._ensure_all_loaded() return super().__eq__(other) + @property + def loaded_count(self) -> int: + """Количество элементов, уже загруженных локально.""" + + return super().__len__() + + @property + def is_materialized(self) -> bool: + """Показывает, загружены ли все страницы коллекции.""" + + return self._exhausted + + def materialize(self) -> list[ItemT]: + """Явно загружает все страницы и возвращает snapshot-список.""" + + self._ensure_all_loaded() + return list(super().__iter__()) + def _ensure_slice_loaded(self, slice_index: slice) -> None: if ( slice_index.step is not None diff --git a/avito/core/retries.py b/avito/core/retries.py index 96d47c6..11e173d 100644 --- a/avito/core/retries.py +++ b/avito/core/retries.py @@ -3,33 +3,86 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Literal +from pathlib import Path +from typing import ClassVar, Literal -from pydantic import Field -from pydantic_settings import BaseSettings, SettingsConfigDict +from avito._env import ( + parse_env_bool, + parse_env_float, + parse_env_int, + parse_env_str_tuple, + resolve_env_aliases, +) RetryReason = Literal[ "transport_error", "timeout", "rate_limit", "server_error", "unauthorized_refresh" ] -class RetryPolicy(BaseSettings): +@dataclass(slots=True, frozen=True) +class RetryPolicy: """Конфигурация повторных попыток для transport-слоя.""" - model_config = SettingsConfigDict( - env_prefix="AVITO_RETRY_", - env_file=".env", - extra="ignore", - ) + ENV_ALIASES: ClassVar[dict[str, tuple[str, ...]]] = { + "max_attempts": ("AVITO_RETRY_MAX_ATTEMPTS",), + "backoff_factor": ("AVITO_RETRY_BACKOFF_FACTOR",), + "retryable_methods": ("AVITO_RETRY_RETRYABLE_METHODS",), + "retry_on_rate_limit": ("AVITO_RETRY_RETRY_ON_RATE_LIMIT",), + "retry_on_server_error": ("AVITO_RETRY_RETRY_ON_SERVER_ERROR",), + "retry_on_transport_error": ("AVITO_RETRY_RETRY_ON_TRANSPORT_ERROR",), + "max_rate_limit_wait_seconds": ("AVITO_RETRY_MAX_RATE_LIMIT_WAIT_SECONDS",), + } max_attempts: int = 3 backoff_factor: float = 0.5 - retryable_methods: tuple[str, ...] = Field(default=("GET", "HEAD", "OPTIONS", "PUT", "DELETE")) + retryable_methods: tuple[str, ...] = ("GET", "HEAD", "OPTIONS", "PUT", "DELETE") retry_on_rate_limit: bool = True retry_on_server_error: bool = True retry_on_transport_error: bool = True max_rate_limit_wait_seconds: float = 30.0 + @classmethod + def from_env(cls, *, env_file: str | Path | None = ".env") -> RetryPolicy: + """Загружает retry-политику из process environment и optional `.env` файла.""" + + resolved_values = resolve_env_aliases(cls.ENV_ALIASES, env_file=env_file) + defaults = cls() + max_attempts = defaults.max_attempts + backoff_factor = defaults.backoff_factor + retryable_methods = defaults.retryable_methods + retry_on_rate_limit = defaults.retry_on_rate_limit + retry_on_server_error = defaults.retry_on_server_error + retry_on_transport_error = defaults.retry_on_transport_error + max_rate_limit_wait_seconds = defaults.max_rate_limit_wait_seconds + for field_name, value in resolved_values.items(): + if field_name == "max_attempts": + max_attempts = parse_env_int(value, field_name=field_name) + elif field_name in {"backoff_factor", "max_rate_limit_wait_seconds"}: + parsed_float = parse_env_float(value, field_name=field_name) + if field_name == "backoff_factor": + backoff_factor = parsed_float + else: + max_rate_limit_wait_seconds = parsed_float + elif field_name == "retryable_methods": + retryable_methods = parse_env_str_tuple(value, field_name=field_name) + else: + parsed_bool = parse_env_bool(value, field_name=field_name) + if field_name == "retry_on_rate_limit": + retry_on_rate_limit = parsed_bool + elif field_name == "retry_on_server_error": + retry_on_server_error = parsed_bool + else: + retry_on_transport_error = parsed_bool + return cls( + max_attempts=max_attempts, + backoff_factor=backoff_factor, + retryable_methods=retryable_methods, + retry_on_rate_limit=retry_on_rate_limit, + retry_on_server_error=retry_on_server_error, + retry_on_transport_error=retry_on_transport_error, + max_rate_limit_wait_seconds=max_rate_limit_wait_seconds, + ) + def is_retryable_method(self, method: str, *, explicit_retry: bool = False) -> bool: """Определяет, можно ли повторять запрос указанного HTTP-метода.""" diff --git a/avito/core/serialization.py b/avito/core/serialization.py new file mode 100644 index 0000000..15856ee --- /dev/null +++ b/avito/core/serialization.py @@ -0,0 +1,53 @@ +"""Публичная сериализация SDK-моделей без transport-деталей.""" + +from __future__ import annotations + +from base64 import b64encode +from collections.abc import Mapping, Sequence +from dataclasses import fields, is_dataclass +from datetime import date, datetime + + +def _is_public_field(name: str) -> bool: + return not name.startswith("_") and name != "raw_payload" + + +def _serialize_value(value: object) -> object: + if isinstance(value, SerializableModel): + return value.to_dict() + if isinstance(value, datetime | date): + return value.isoformat() + if isinstance(value, bytes | bytearray): + return b64encode(bytes(value)).decode("ascii") + if is_dataclass(value): + return { + field.name: _serialize_value(getattr(value, field.name)) + for field in fields(value) + if _is_public_field(field.name) + } + if isinstance(value, Mapping): + return {str(key): _serialize_value(item) for key, item in value.items()} + if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray): + return [_serialize_value(item) for item in value] + return value + + +class SerializableModel: + """Mixin для стабильной JSON-compatible сериализации публичных моделей.""" + + def to_dict(self) -> dict[str, object]: + if not is_dataclass(self): + raise TypeError("SerializableModel supports dataclass instances only.") + return { + field.name: _serialize_value(getattr(self, field.name)) + for field in fields(self) + if _is_public_field(field.name) + } + + def model_dump(self) -> dict[str, object]: + """Совместимый alias для pydantic-подобного публичного контракта.""" + + return self.to_dict() + + +__all__ = ("SerializableModel",) diff --git a/avito/core/transport.py b/avito/core/transport.py index 284d302..c6a109e 100644 --- a/avito/core/transport.py +++ b/avito/core/transport.py @@ -15,13 +15,15 @@ from avito.auth.provider import AuthProvider from avito.core.exceptions import ( AuthenticationError, - ClientError, + AuthorizationError, + ConflictError, NotFoundError, - PermissionDeniedError, RateLimitError, ResponseMappingError, ServerError, TransportError, + UnsupportedOperationError, + UpstreamApiError, ValidationError, ) from avito.core.retries import RetryDecision @@ -50,6 +52,17 @@ RequestFiles = Mapping[str, FileValue] +def build_httpx_timeout(timeouts: ApiTimeouts) -> httpx.Timeout: + """Преобразует SDK-конфигурацию таймаутов в `httpx.Timeout`.""" + + return httpx.Timeout( + connect=timeouts.connect, + read=timeouts.read, + write=timeouts.write, + pool=timeouts.pool, + ) + + class Transport: """Выполняет HTTP-запросы, применяет retry и маппит ошибки API.""" @@ -66,7 +79,7 @@ def __init__( self._retry_policy = settings.retry_policy self._client = client or httpx.Client( base_url=settings.base_url.rstrip("/"), - timeout=self._build_timeout(settings.timeouts), + timeout=build_httpx_timeout(settings.timeouts), ) self._sleep = sleep @@ -75,6 +88,7 @@ def debug_info(self) -> TransportDebugInfo: return TransportDebugInfo( base_url=str(self._client.base_url), + user_id=self._settings.user_id, requires_auth=self._auth_provider is not None, timeout_connect=self._settings.timeouts.connect, timeout_read=self._settings.timeouts.read, @@ -106,7 +120,7 @@ def request( normalized_path = self._normalize_path(path) request_headers = self._merge_headers(context=context, headers=headers) - timeout = self._build_timeout(context.timeout or self._settings.timeouts) + timeout = build_httpx_timeout(context.timeout or self._settings.timeouts) attempt = 0 unauthorized_refresh_used = False @@ -134,7 +148,11 @@ def request( if decision.should_retry: self._sleep(decision.delay_seconds) continue - raise TransportError(str(exc)) from exc + raise TransportError( + str(exc), + operation=context.operation_name, + metadata={"timeout": isinstance(exc, httpx.TimeoutException)}, + ) from exc if ( response.status_code == 401 @@ -142,7 +160,7 @@ def request( and self._auth_provider is not None ): if unauthorized_refresh_used: - raise self._map_http_error(response) + raise self._map_http_error(response, operation=context.operation_name) unauthorized_refresh_used = True self._auth_provider.invalidate_token() refreshed_headers = dict(request_headers) @@ -162,7 +180,7 @@ def request( if decision.should_retry: self._sleep(decision.delay_seconds) continue - raise self._map_http_error(response) + raise self._map_http_error(response, operation=context.operation_name) if 500 <= response.status_code < 600: decision = self._decide_http_retry( @@ -174,10 +192,10 @@ def request( if decision.should_retry: self._sleep(decision.delay_seconds) continue - raise self._map_http_error(response) + raise self._map_http_error(response, operation=context.operation_name) if response.is_error: - raise self._map_http_error(response) + raise self._map_http_error(response, operation=context.operation_name) return response @@ -211,6 +229,8 @@ def request_json( raise ResponseMappingError( "Ответ API не является корректным JSON.", status_code=response.status_code, + operation=context.operation_name, + metadata={"content_type": response.headers.get("content-type")}, payload=response.text, headers=dict(response.headers), ) from exc @@ -346,44 +366,104 @@ def _decide_http_retry( ) return RetryDecision(False) - def _map_http_error(self, response: httpx.Response) -> Exception: + def _map_http_error( + self, response: httpx.Response, *, operation: str | None = None + ) -> Exception: payload = self._safe_payload(response) message = self._extract_message(payload) or f"HTTP {response.status_code}" error_code = self._extract_error_code(payload) headers = dict(response.headers) + metadata = { + "method": response.request.method, + "path": response.request.url.path, + } if response.status_code == 401: return AuthenticationError( - message, status_code=401, error_code=error_code, payload=payload, headers=headers + message, + status_code=401, + error_code=error_code, + operation=operation, + metadata=metadata, + payload=payload, + headers=headers, ) if response.status_code == 403: - return PermissionDeniedError( - message, status_code=403, error_code=error_code, payload=payload, headers=headers + return AuthorizationError( + message, + status_code=403, + error_code=error_code, + operation=operation, + metadata=metadata, + payload=payload, + headers=headers, ) if response.status_code == 404: return NotFoundError( - message, status_code=404, error_code=error_code, payload=payload, headers=headers + message, + status_code=404, + error_code=error_code, + operation=operation, + metadata=metadata, + payload=payload, + headers=headers, ) - if response.status_code == 422: + if response.status_code in {400, 422}: return ValidationError( - message, status_code=422, error_code=error_code, payload=payload, headers=headers + message, + status_code=response.status_code, + error_code=error_code, + operation=operation, + metadata=metadata, + payload=payload, + headers=headers, + ) + if response.status_code == 409: + return ConflictError( + message, + status_code=409, + error_code=error_code, + operation=operation, + metadata=metadata, + payload=payload, + headers=headers, ) if response.status_code == 429: return RateLimitError( - message, status_code=429, error_code=error_code, payload=payload, headers=headers + message, + status_code=429, + error_code=error_code, + operation=operation, + metadata=metadata, + payload=payload, + headers=headers, ) - if 400 <= response.status_code < 500: - return ClientError( + if response.status_code in {405, 501}: + return UnsupportedOperationError( message, status_code=response.status_code, error_code=error_code, + operation=operation, + metadata=metadata, payload=payload, headers=headers, ) - return ServerError( + if response.status_code >= 500: + return ServerError( + message, + status_code=response.status_code, + error_code=error_code, + operation=operation, + metadata=metadata, + payload=payload, + headers=headers, + ) + return UpstreamApiError( message, status_code=response.status_code, error_code=error_code, + operation=operation, + metadata=metadata, payload=payload, headers=headers, ) @@ -433,13 +513,4 @@ def _extract_filename(self, content_disposition: str | None) -> str | None: return decoded_value return filename - def _build_timeout(self, timeouts: ApiTimeouts) -> httpx.Timeout: - return httpx.Timeout( - connect=timeouts.connect, - read=timeouts.read, - write=timeouts.write, - pool=timeouts.pool, - ) - - -__all__ = ("Transport",) +__all__ = ("Transport", "build_httpx_timeout") diff --git a/avito/core/types.py b/avito/core/types.py index 6b0bdfe..58afb6f 100644 --- a/avito/core/types.py +++ b/avito/core/types.py @@ -4,27 +4,41 @@ from collections.abc import Mapping from dataclasses import dataclass, field -from typing import Literal +from pathlib import Path +from typing import ClassVar, Literal -from pydantic_settings import BaseSettings, SettingsConfigDict +from avito._env import parse_env_float, resolve_env_aliases HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] -class ApiTimeouts(BaseSettings): +@dataclass(slots=True, frozen=True) +class ApiTimeouts: """Явные таймауты для HTTP-запросов SDK.""" - model_config = SettingsConfigDict( - env_prefix="AVITO_TIMEOUT_", - env_file=".env", - extra="ignore", - ) + ENV_ALIASES: ClassVar[dict[str, tuple[str, ...]]] = { + "connect": ("AVITO_TIMEOUT_CONNECT",), + "read": ("AVITO_TIMEOUT_READ",), + "write": ("AVITO_TIMEOUT_WRITE",), + "pool": ("AVITO_TIMEOUT_POOL",), + } connect: float = 5.0 read: float = 15.0 write: float = 15.0 pool: float = 5.0 + @classmethod + def from_env(cls, *, env_file: str | Path | None = ".env") -> ApiTimeouts: + """Загружает таймауты из process environment и optional `.env` файла.""" + + resolved_values = resolve_env_aliases(cls.ENV_ALIASES, env_file=env_file) + parsed_values: dict[str, float] = { + field_name: parse_env_float(value, field_name=field_name) + for field_name, value in resolved_values.items() + } + return cls(**parsed_values) + @dataclass(slots=True, frozen=True) class RequestContext: @@ -53,6 +67,7 @@ class TransportDebugInfo: """Безопасный снимок transport-конфигурации для диагностики интеграции.""" base_url: str + user_id: int | None requires_auth: bool timeout_connect: float timeout_read: float diff --git a/avito/core/validation.py b/avito/core/validation.py new file mode 100644 index 0000000..a1be54d --- /dev/null +++ b/avito/core/validation.py @@ -0,0 +1,40 @@ +"""Внутренние валидаторы входных данных доменного слоя.""" + +from __future__ import annotations + +from collections.abc import Sequence + +from avito.core.exceptions import ValidationError + + +def validate_non_empty(name: str, items: Sequence[object]) -> None: + """Проверяет, что последовательность содержит хотя бы один элемент.""" + if not items: + raise ValidationError(f"`{name}` должен содержать хотя бы один элемент.") + + +def validate_positive_int(name: str, value: int) -> None: + """Проверяет, что значение является положительным целым числом.""" + if value <= 0: + raise ValidationError(f"`{name}` должен быть положительным целым числом.") + + +def validate_non_empty_string(name: str, value: str) -> None: + """Проверяет, что строка не является пустой или состоящей из пробелов.""" + if not value.strip(): + raise ValidationError(f"`{name}` не может быть пустой строкой.") + + +def validate_string_items(name: str, values: Sequence[str]) -> None: + """Проверяет, что список строк непустой и каждая строка непустая.""" + validate_non_empty(name, values) + for index, value in enumerate(values): + validate_non_empty_string(f"{name}[{index}]", value) + + +__all__ = ( + "validate_non_empty", + "validate_non_empty_string", + "validate_positive_int", + "validate_string_items", +) diff --git a/avito/cpa/__init__.py b/avito/cpa/__init__.py index 98d556e..0eed32e 100644 --- a/avito/cpa/__init__.py +++ b/avito/cpa/__init__.py @@ -1,40 +1,57 @@ """Пакет cpa.""" -from avito.cpa.domain import CallTrackingCall, CpaCall, CpaChat, CpaLead, CpaLegacy, DomainObject +from avito.cpa.domain import CallTrackingCall, CpaArchive, CpaCall, CpaChat, CpaLead from avito.cpa.models import ( CallTrackingCallInfo, + CallTrackingCallResponse, + CallTrackingCallsRequest, CallTrackingCallsResult, + CallTrackingGetCallByIdRequest, CallTrackingRecord, CpaActionResult, CpaAudioRecord, CpaBalanceInfo, + CpaCallByIdRequest, + CpaCallComplaintRequest, CpaCallInfo, + CpaCallsByTimeRequest, CpaCallsResult, CpaChatInfo, + CpaChatsByTimeRequest, CpaChatsResult, CpaErrorInfo, + CpaLeadComplaintRequest, CpaPhoneInfo, + CpaPhonesFromChatsRequest, CpaPhonesResult, ) __all__ = ( "CallTrackingCall", "CallTrackingCallInfo", + "CallTrackingCallResponse", + "CallTrackingCallsRequest", "CallTrackingCallsResult", + "CallTrackingGetCallByIdRequest", "CallTrackingRecord", "CpaActionResult", "CpaAudioRecord", "CpaBalanceInfo", + "CpaArchive", "CpaCall", + "CpaCallByIdRequest", + "CpaCallComplaintRequest", "CpaCallInfo", + "CpaCallsByTimeRequest", "CpaCallsResult", "CpaChat", "CpaChatInfo", + "CpaChatsByTimeRequest", "CpaChatsResult", "CpaErrorInfo", "CpaLead", - "CpaLegacy", + "CpaLeadComplaintRequest", "CpaPhoneInfo", + "CpaPhonesFromChatsRequest", "CpaPhonesResult", - "DomainObject", ) diff --git a/avito/cpa/client.py b/avito/cpa/client.py index 58dc799..2df360c 100644 --- a/avito/cpa/client.py +++ b/avito/cpa/client.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from avito.core import RequestContext, Transport +from avito.core.mapping import request_public_model from avito.cpa.mappers import ( map_balance, map_call_item, @@ -17,18 +18,25 @@ map_phones, ) from avito.cpa.models import ( - CallTrackingCallInfo, + CallTrackingCallResponse, + CallTrackingCallsRequest, CallTrackingCallsResult, + CallTrackingGetCallByIdRequest, CallTrackingRecord, CpaActionResult, CpaAudioRecord, CpaBalanceInfo, + CpaCallByIdRequest, + CpaCallComplaintRequest, CpaCallInfo, + CpaCallsByTimeRequest, CpaCallsResult, CpaChatInfo, + CpaChatsByTimeRequest, CpaChatsResult, + CpaLeadComplaintRequest, + CpaPhonesFromChatsRequest, CpaPhonesResult, - JsonRequest, ) @@ -39,39 +47,43 @@ class CpaChatsClient: transport: Transport def get_by_action_id(self, *, action_id: int | str) -> CpaChatInfo: - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", f"/cpa/v1/chatByActionId/{action_id}", context=RequestContext("cpa.chats.get_by_action_id"), + mapper=map_chat_item, ) - return map_chat_item(payload) - def list_by_time_v1(self, request: JsonRequest) -> CpaChatsResult: - payload = self.transport.request_json( + def list_by_time_classic(self, request: CpaChatsByTimeRequest) -> CpaChatsResult: + return request_public_model( + self.transport, "POST", "/cpa/v1/chatsByTime", - context=RequestContext("cpa.chats.list_by_time_v1", allow_retry=True), + context=RequestContext("cpa.chats.list_by_time_classic", allow_retry=True), + mapper=map_chats, json_body=request.to_payload(), ) - return map_chats(payload) - def list_by_time_v2(self, request: JsonRequest) -> CpaChatsResult: - payload = self.transport.request_json( + def list_by_time(self, request: CpaChatsByTimeRequest) -> CpaChatsResult: + return request_public_model( + self.transport, "POST", "/cpa/v2/chatsByTime", - context=RequestContext("cpa.chats.list_by_time_v2", allow_retry=True), + context=RequestContext("cpa.chats.list_by_time", allow_retry=True), + mapper=map_chats, json_body=request.to_payload(), ) - return map_chats(payload) - def get_phones_info(self, request: JsonRequest) -> CpaPhonesResult: - payload = self.transport.request_json( + def get_phones_info(self, request: CpaPhonesFromChatsRequest) -> CpaPhonesResult: + return request_public_model( + self.transport, "POST", "/cpa/v1/phonesInfoFromChats", context=RequestContext("cpa.chats.get_phones_info", allow_retry=True), + mapper=map_phones, json_body=request.to_payload(), ) - return map_phones(payload) @dataclass(slots=True) @@ -80,23 +92,25 @@ class CpaCallsClient: transport: Transport - def list_by_time_v2(self, request: JsonRequest) -> CpaCallsResult: - payload = self.transport.request_json( + def list_by_time(self, request: CpaCallsByTimeRequest) -> CpaCallsResult: + return request_public_model( + self.transport, "POST", "/cpa/v2/callsByTime", - context=RequestContext("cpa.calls.list_by_time_v2", allow_retry=True), + context=RequestContext("cpa.calls.list_by_time", allow_retry=True), + mapper=map_calls, json_body=request.to_payload(), ) - return map_calls(payload) - def create_complaint(self, request: JsonRequest) -> CpaActionResult: - payload = self.transport.request_json( + def create_complaint(self, request: CpaCallComplaintRequest) -> CpaActionResult: + return request_public_model( + self.transport, "POST", "/cpa/v1/createComplaint", context=RequestContext("cpa.calls.create_complaint", allow_retry=True), + mapper=map_cpa_action, json_body=request.to_payload(), ) - return map_cpa_action(payload) @dataclass(slots=True) @@ -105,55 +119,59 @@ class CpaLeadsClient: transport: Transport - def create_complaint_by_action_id(self, request: JsonRequest) -> CpaActionResult: - payload = self.transport.request_json( + def create_complaint_by_action_id(self, request: CpaLeadComplaintRequest) -> CpaActionResult: + return request_public_model( + self.transport, "POST", "/cpa/v1/createComplaintByActionId", context=RequestContext("cpa.leads.create_complaint_by_action_id", allow_retry=True), + mapper=map_cpa_action, json_body=request.to_payload(), ) - return map_cpa_action(payload) - def get_balance_info_v3(self, request: JsonRequest) -> CpaBalanceInfo: - payload = self.transport.request_json( + def get_balance_info(self) -> CpaBalanceInfo: + return request_public_model( + self.transport, "POST", "/cpa/v3/balanceInfo", - context=RequestContext("cpa.leads.get_balance_info_v3", allow_retry=True), - json_body=request.to_payload(), + context=RequestContext("cpa.leads.get_balance_info", allow_retry=True), + mapper=map_balance, + json_body={}, ) - return map_balance(payload) @dataclass(slots=True) -class CpaLegacyClient: - """Выполняет legacy HTTP-операции CPA.""" +class CpaArchiveClient: + """Выполняет архивные HTTP-операции CPA.""" transport: Transport def get_record(self, *, call_id: int | str) -> CpaAudioRecord: binary = self.transport.download_binary( f"/cpa/v1/call/{call_id}", - context=RequestContext("cpa.legacy.get_record"), + context=RequestContext("cpa.archive.get_record"), ) return CpaAudioRecord(binary) - def get_balance_info_v2(self, request: JsonRequest) -> CpaBalanceInfo: - payload = self.transport.request_json( + def get_balance_info(self) -> CpaBalanceInfo: + return request_public_model( + self.transport, "POST", "/cpa/v2/balanceInfo", - context=RequestContext("cpa.legacy.get_balance_info_v2", allow_retry=True), - json_body=request.to_payload(), + context=RequestContext("cpa.archive.get_balance_info", allow_retry=True), + mapper=map_balance, + json_body={}, ) - return map_balance(payload) - def get_call_by_id_v2(self, request: JsonRequest) -> CpaCallInfo: - payload = self.transport.request_json( + def get_call_by_id(self, request: CpaCallByIdRequest) -> CpaCallInfo: + return request_public_model( + self.transport, "POST", "/cpa/v2/callById", - context=RequestContext("cpa.legacy.get_call_by_id_v2", allow_retry=True), + context=RequestContext("cpa.archive.get_call_by_id", allow_retry=True), + mapper=map_call_item, json_body=request.to_payload(), ) - return map_call_item(payload) @dataclass(slots=True) @@ -162,23 +180,25 @@ class CallTrackingClient: transport: Transport - def get_call_by_id(self, request: JsonRequest) -> CallTrackingCallInfo: - payload = self.transport.request_json( + def get_call_by_id(self, request: CallTrackingGetCallByIdRequest) -> CallTrackingCallResponse: + return request_public_model( + self.transport, "POST", "/calltracking/v1/getCallById/", context=RequestContext("cpa.calltracking.get_call_by_id", allow_retry=True), + mapper=map_call_tracking_call_item, json_body=request.to_payload(), ) - return map_call_tracking_call_item(payload) - def get_calls(self, request: JsonRequest) -> CallTrackingCallsResult: - payload = self.transport.request_json( + def get_calls(self, request: CallTrackingCallsRequest) -> CallTrackingCallsResult: + return request_public_model( + self.transport, "POST", "/calltracking/v1/getCalls/", context=RequestContext("cpa.calltracking.get_calls", allow_retry=True), + mapper=map_call_tracking_calls, json_body=request.to_payload(), ) - return map_call_tracking_calls(payload) def get_record_by_call_id(self, *, call_id: int | str) -> CallTrackingRecord: binary = self.transport.download_binary( diff --git a/avito/cpa/domain.py b/avito/cpa/domain.py index 93ff7bc..f01c643 100644 --- a/avito/cpa/domain.py +++ b/avito/cpa/domain.py @@ -2,156 +2,182 @@ from __future__ import annotations -from collections.abc import Mapping from dataclasses import dataclass -from avito.core import Transport +from avito.core import ValidationError +from avito.core.domain import DomainObject from avito.cpa.client import ( CallTrackingClient, + CpaArchiveClient, CpaCallsClient, CpaChatsClient, CpaLeadsClient, - CpaLegacyClient, ) from avito.cpa.models import ( - CallTrackingCallInfo, + CallTrackingCallResponse, + CallTrackingCallsRequest, CallTrackingCallsResult, + CallTrackingGetCallByIdRequest, CallTrackingRecord, CpaActionResult, CpaAudioRecord, CpaBalanceInfo, + CpaCallByIdRequest, + CpaCallComplaintRequest, CpaCallInfo, + CpaCallsByTimeRequest, CpaCallsResult, CpaChatInfo, + CpaChatsByTimeRequest, CpaChatsResult, + CpaLeadComplaintRequest, + CpaPhonesFromChatsRequest, CpaPhonesResult, - JsonRequest, ) -@dataclass(slots=True, frozen=True) -class DomainObject: - """Базовый доменный объект раздела cpa.""" - - transport: Transport - - @dataclass(slots=True, frozen=True) class CpaLead(DomainObject): """Доменный объект CPA-лида и связанных lead-операций.""" - resource_id: int | str | None = None user_id: int | str | None = None - def create_complaint_by_action_id(self, *, payload: Mapping[str, object]) -> CpaActionResult: - return CpaLeadsClient(self.transport).create_complaint_by_action_id(JsonRequest(payload)) + def create_complaint_by_action_id( + self, + *, + request: CpaLeadComplaintRequest, + ) -> CpaActionResult: + return CpaLeadsClient(self.transport).create_complaint_by_action_id(request) - def create_balance_info_v3( - self, *, payload: Mapping[str, object] | None = None - ) -> CpaBalanceInfo: - return CpaLeadsClient(self.transport).get_balance_info_v3(JsonRequest(payload or {})) + def get_balance_info(self) -> CpaBalanceInfo: + return CpaLeadsClient(self.transport).get_balance_info() @dataclass(slots=True, frozen=True) class CpaChat(DomainObject): """Доменный объект CPA-чата.""" - resource_id: int | str | None = None + action_id: int | str | None = None user_id: int | str | None = None def get(self, *, action_id: int | str | None = None) -> CpaChatInfo: return CpaChatsClient(self.transport).get_by_action_id( - action_id=action_id or self._require_resource_id() + action_id=action_id or self._require_action_id() ) def list( self, *, - payload: Mapping[str, object], + request: CpaChatsByTimeRequest, version: int = 2, ) -> CpaChatsResult: client = CpaChatsClient(self.transport) - request = JsonRequest(payload) if version == 1: - return client.list_by_time_v1(request) - return client.list_by_time_v2(request) + return client.list_by_time_classic(request) + return client.list_by_time(request) - def get_phones_info_from_chats(self, *, payload: Mapping[str, object]) -> CpaPhonesResult: - return CpaChatsClient(self.transport).get_phones_info(JsonRequest(payload)) + def get_phones_info_from_chats( + self, + *, + request: CpaPhonesFromChatsRequest, + ) -> CpaPhonesResult: + return CpaChatsClient(self.transport).get_phones_info(request) - def _require_resource_id(self) -> str: - if self.resource_id is None: - raise ValueError("Для операции требуется `action_id` или `chat_id`.") - return str(self.resource_id) + def _require_action_id(self) -> str: + if self.action_id is None: + raise ValidationError("Для операции требуется `action_id`.") + return str(self.action_id) @dataclass(slots=True, frozen=True) class CpaCall(DomainObject): """Доменный объект CPA-звонка.""" - resource_id: int | str | None = None user_id: int | str | None = None - def list(self, *, payload: Mapping[str, object]) -> CpaCallsResult: - return CpaCallsClient(self.transport).list_by_time_v2(JsonRequest(payload)) + def list(self, *, date_time_from: str, date_time_to: str) -> CpaCallsResult: + return CpaCallsClient(self.transport).list_by_time( + CpaCallsByTimeRequest( + date_time_from=date_time_from, + date_time_to=date_time_to, + ) + ) - def create_create_complaint(self, *, payload: Mapping[str, object]) -> CpaActionResult: - return CpaCallsClient(self.transport).create_complaint(JsonRequest(payload)) + def create_complaint(self, *, call_id: int, reason: str) -> CpaActionResult: + return CpaCallsClient(self.transport).create_complaint( + CpaCallComplaintRequest(call_id=call_id, reason=reason) + ) @dataclass(slots=True, frozen=True) -class CpaLegacy(DomainObject): - """Доменный объект legacy-операций CPA.""" +class CpaArchive(DomainObject): + """Доменный объект архивных операций CPA.""" - resource_id: int | str | None = None + call_id: int | str | None = None user_id: int | str | None = None - def legacy_get_call(self, *, call_id: int | str | None = None) -> CpaAudioRecord: - return CpaLegacyClient(self.transport).get_record( - call_id=call_id or self._require_resource_id() + def get_call(self, *, call_id: int | str | None = None) -> CpaAudioRecord: + return CpaArchiveClient(self.transport).get_record( + call_id=call_id or self._require_call_id() ) - def legacy_create_balance_info_v2( - self, - *, - payload: Mapping[str, object] | None = None, - ) -> CpaBalanceInfo: - return CpaLegacyClient(self.transport).get_balance_info_v2(JsonRequest(payload or {})) + def get_balance_info(self) -> CpaBalanceInfo: + return CpaArchiveClient(self.transport).get_balance_info() - def legacy_create_call_by_id_v2(self, *, payload: Mapping[str, object]) -> CpaCallInfo: - return CpaLegacyClient(self.transport).get_call_by_id_v2(JsonRequest(payload)) + def get_call_by_id(self, *, call_id: int) -> CpaCallInfo: + return CpaArchiveClient(self.transport).get_call_by_id( + CpaCallByIdRequest(call_id=call_id) + ) - def _require_resource_id(self) -> str: - if self.resource_id is None: - raise ValueError("Для операции требуется `call_id`.") - return str(self.resource_id) + def _require_call_id(self) -> str: + if self.call_id is None: + raise ValidationError("Для операции требуется `call_id`.") + return str(self.call_id) @dataclass(slots=True, frozen=True) class CallTrackingCall(DomainObject): """Доменный объект CallTracking.""" - resource_id: int | str | None = None + call_id: int | str | None = None user_id: int | str | None = None - def get(self, *, payload: Mapping[str, object] | None = None) -> CallTrackingCallInfo: - request_payload = dict(payload or {}) - if "callId" not in request_payload and self.resource_id is not None: - request_payload["callId"] = self.resource_id - return CallTrackingClient(self.transport).get_call_by_id(JsonRequest(request_payload)) + def get(self, *, call_id: int | None = None) -> CallTrackingCallResponse: + resolved_call_id = call_id or ( + int(self.call_id) if self.call_id is not None else None + ) + if resolved_call_id is None: + raise ValidationError("Для операции требуется `call_id`.") + return CallTrackingClient(self.transport).get_call_by_id( + CallTrackingGetCallByIdRequest(call_id=resolved_call_id) + ) - def list(self, *, payload: Mapping[str, object]) -> CallTrackingCallsResult: - return CallTrackingClient(self.transport).get_calls(JsonRequest(payload)) + def list( + self, + *, + date_time_from: str, + date_time_to: str, + limit: int | None = None, + offset: int | None = None, + ) -> CallTrackingCallsResult: + return CallTrackingClient(self.transport).get_calls( + CallTrackingCallsRequest( + date_time_from=date_time_from, + date_time_to=date_time_to, + limit=limit, + offset=offset, + ) + ) def download(self, *, call_id: int | str | None = None) -> CallTrackingRecord: return CallTrackingClient(self.transport).get_record_by_call_id( - call_id=call_id or self._require_resource_id() + call_id=call_id or self._require_call_id() ) - def _require_resource_id(self) -> str: - if self.resource_id is None: - raise ValueError("Для операции требуется `call_id`.") - return str(self.resource_id) + def _require_call_id(self) -> str: + if self.call_id is None: + raise ValidationError("Для операции требуется `call_id`.") + return str(self.call_id) -__all__ = ("CallTrackingCall", "CpaCall", "CpaChat", "CpaLead", "CpaLegacy", "DomainObject") +__all__ = ("CallTrackingCall", "CpaArchive", "CpaCall", "CpaChat", "CpaLead") diff --git a/avito/cpa/enums.py b/avito/cpa/enums.py deleted file mode 100644 index acc4731..0000000 --- a/avito/cpa/enums.py +++ /dev/null @@ -1 +0,0 @@ -"""Строковые enum и константы пакета cpa.""" diff --git a/avito/cpa/mappers.py b/avito/cpa/mappers.py index b767245..3547d68 100644 --- a/avito/cpa/mappers.py +++ b/avito/cpa/mappers.py @@ -8,6 +8,7 @@ from avito.core.exceptions import ResponseMappingError from avito.cpa.models import ( CallTrackingCallInfo, + CallTrackingCallResponse, CallTrackingCallsResult, CpaActionResult, CpaBalanceInfo, @@ -94,7 +95,6 @@ def map_cpa_error(payload: object | None) -> CpaErrorInfo | None: return CpaErrorInfo( code=_int(data, "code"), message=_str(data, "message", "error"), - raw_payload=data, ) @@ -105,7 +105,6 @@ def map_cpa_action(payload: object) -> CpaActionResult: return CpaActionResult( success=bool(data.get("success", False)), error=map_cpa_error(data.get("error")), - raw_payload=data, ) @@ -118,7 +117,6 @@ def map_balance(payload: object) -> CpaBalanceInfo: advance=_int(data, "advance"), debt=_int(data, "debt"), error=map_cpa_error(data.get("error")), - raw_payload=data, ) @@ -138,7 +136,6 @@ def _map_cpa_call(item: Payload) -> CpaCallInfo: group_title=_str(item, "groupTitle"), record_url=_str(item, "recordUrl"), is_arbitrage_available=_bool(item, "isArbitrageAvailable"), - raw_payload=item, ) @@ -158,7 +155,6 @@ def map_calls(payload: object) -> CpaCallsResult: return CpaCallsResult( items=[_map_cpa_call(item) for item in _list(data, "calls", "items", "results")], error=map_cpa_error(data.get("error")), - raw_payload=data, ) @@ -177,7 +173,6 @@ def _map_cpa_chat(item: Payload) -> CpaChatInfo: created_at=_str(source, "createdAt", "created_at"), updated_at=_str(source, "updatedAt", "updated_at"), is_arbitrage_available=_bool(item, "isArbitrageAvailable"), - raw_payload=item, ) @@ -197,7 +192,6 @@ def map_chats(payload: object) -> CpaChatsResult: data = _expect_mapping(payload) return CpaChatsResult( items=[_map_cpa_chat(item) for item in _list(data, "chats", "items", "results")], - raw_payload=data, ) @@ -214,12 +208,10 @@ def map_phones(payload: object) -> CpaPhonesResult: price=_int(item, "pricePenny", "price"), group=_str(item, "group"), preview_url=_str(item, "url", "previewUrl"), - raw_payload=item, ) for item in _list(data, "results", "items") ], total=_int(data, "total"), - raw_payload=data, ) @@ -233,17 +225,21 @@ def _map_call_tracking_call(item: Payload) -> CallTrackingCallInfo: call_time=_str(item, "callTime", "createTime"), talk_duration=_int(item, "talkDuration", "duration"), waiting_duration=_float(item, "waitingDuration"), - raw_payload=item, ) -def map_call_tracking_call_item(payload: object) -> CallTrackingCallInfo: +def map_call_tracking_call_item(payload: object) -> CallTrackingCallResponse: """Преобразует один звонок CallTracking.""" data = _expect_mapping(payload) call = _mapping(data, "call") - source = call or data - return _map_call_tracking_call(source) + error = map_cpa_error(data.get("error")) + if not call or error is None: + raise ResponseMappingError( + "Ответ CallTracking getCallById должен содержать `call` и `error`.", + payload=payload, + ) + return CallTrackingCallResponse(call=_map_call_tracking_call(call), error=error) def map_call_tracking_calls(payload: object) -> CallTrackingCallsResult: @@ -253,5 +249,4 @@ def map_call_tracking_calls(payload: object) -> CallTrackingCallsResult: return CallTrackingCallsResult( items=[_map_call_tracking_call(item) for item in _list(data, "calls", "items", "results")], error=map_cpa_error(data.get("error")), - raw_payload=data, ) diff --git a/avito/cpa/models.py b/avito/cpa/models.py index fee2c91..930f577 100644 --- a/avito/cpa/models.py +++ b/avito/cpa/models.py @@ -2,55 +2,132 @@ from __future__ import annotations -from collections.abc import Mapping -from dataclasses import dataclass, field +from base64 import b64encode +from dataclasses import dataclass from avito.core import BinaryResponse +from avito.core.serialization import SerializableModel @dataclass(slots=True, frozen=True) -class JsonRequest: - """Типизированная обертка над JSON payload запроса.""" +class CpaChatsByTimeRequest: + """Запрос списка CPA-чатов по времени.""" - payload: Mapping[str, object] + created_at_from: str + limit: int | None = None def to_payload(self) -> dict[str, object]: - """Сериализует payload запроса.""" + payload: dict[str, object] = {"createdAtFrom": self.created_at_from} + if self.limit is not None: + payload["limit"] = self.limit + return payload - return dict(self.payload) + +@dataclass(slots=True, frozen=True) +class CpaPhonesFromChatsRequest: + """Запрос телефонов из целевых чатов.""" + + action_ids: list[str] + + def to_payload(self) -> dict[str, object]: + return {"actionIds": list(self.action_ids)} + + +@dataclass(slots=True, frozen=True) +class CpaCallsByTimeRequest: + """Запрос списка CPA-звонков по времени.""" + + date_time_from: str + date_time_to: str + + def to_payload(self) -> dict[str, object]: + return { + "dateTimeFrom": self.date_time_from, + "dateTimeTo": self.date_time_to, + } + + +@dataclass(slots=True, frozen=True) +class CpaCallComplaintRequest: + """Запрос жалобы на CPA-звонок.""" + + call_id: int + reason: str + + def to_payload(self) -> dict[str, object]: + return {"callId": self.call_id, "reason": self.reason} + + +@dataclass(slots=True, frozen=True) +class CpaLeadComplaintRequest: + """Запрос жалобы по action id.""" + + action_id: str + reason: str + + def to_payload(self) -> dict[str, object]: + return {"actionId": self.action_id, "reason": self.reason} + + +@dataclass(slots=True, frozen=True) +class CpaCallByIdRequest: + """Запрос получения CPA-звонка по идентификатору.""" + + call_id: int + + def to_payload(self) -> dict[str, int]: + return {"callId": self.call_id} @dataclass(slots=True, frozen=True) -class CpaErrorInfo: +class CallTrackingCallsRequest: + """Запрос списка звонков CallTracking.""" + + date_time_from: str + date_time_to: str + limit: int | None = None + offset: int | None = None + + def to_payload(self) -> dict[str, object]: + payload: dict[str, object] = { + "dateTimeFrom": self.date_time_from, + "dateTimeTo": self.date_time_to, + } + if self.limit is not None: + payload["limit"] = self.limit + if self.offset is not None: + payload["offset"] = self.offset + return payload + + +@dataclass(slots=True, frozen=True) +class CpaErrorInfo(SerializableModel): """Информация об ошибке CPA API.""" code: int | None message: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class CpaActionResult: +class CpaActionResult(SerializableModel): """Результат mutation-операции CPA.""" success: bool error: CpaErrorInfo | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class CpaBalanceInfo: +class CpaBalanceInfo(SerializableModel): """Информация о CPA-балансе пользователя.""" balance: int | None advance: int | None = None debt: int | None = None error: CpaErrorInfo | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class CpaCallInfo: +class CpaCallInfo(SerializableModel): """Информация о звонке CPA.""" call_id: str | None @@ -67,20 +144,18 @@ class CpaCallInfo: group_title: str | None record_url: str | None is_arbitrage_available: bool | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class CpaCallsResult: +class CpaCallsResult(SerializableModel): """Список звонков CPA.""" items: list[CpaCallInfo] error: CpaErrorInfo | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class CpaChatInfo: +class CpaChatInfo(SerializableModel): """Информация о CPA-чате.""" chat_id: str | None @@ -92,19 +167,17 @@ class CpaChatInfo: created_at: str | None updated_at: str | None is_arbitrage_available: bool | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class CpaChatsResult: +class CpaChatsResult(SerializableModel): """Список чатов CPA.""" items: list[CpaChatInfo] - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class CpaPhoneInfo: +class CpaPhoneInfo(SerializableModel): """Информация по телефону, найденному в целевом чате.""" action_id: str | None @@ -113,16 +186,14 @@ class CpaPhoneInfo: price: int | None group: str | None preview_url: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class CpaPhonesResult: +class CpaPhonesResult(SerializableModel): """Список телефонных номеров из целевых чатов.""" items: list[CpaPhoneInfo] total: int | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -137,9 +208,21 @@ def filename(self) -> str | None: return self.binary.filename + def to_dict(self) -> dict[str, object]: + """Сериализует бинарную запись без transport-объекта.""" + + return { + "filename": self.binary.filename, + "content_type": self.binary.content_type, + "content_base64": b64encode(self.binary.content).decode("ascii"), + } + + def model_dump(self) -> dict[str, object]: + return self.to_dict() + @dataclass(slots=True, frozen=True) -class CallTrackingCallInfo: +class CallTrackingCallInfo(SerializableModel): """Информация о звонке CallTracking.""" call_id: str | None @@ -150,16 +233,34 @@ class CallTrackingCallInfo: call_time: str | None talk_duration: int | None waiting_duration: float | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class CallTrackingCallsResult: +class CallTrackingCallsResult(SerializableModel): """Список звонков CallTracking.""" items: list[CallTrackingCallInfo] error: CpaErrorInfo | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) + + +@dataclass(slots=True, frozen=True) +class CallTrackingGetCallByIdRequest: + """Запрос получения звонка CallTracking по идентификатору.""" + + call_id: int + + def to_payload(self) -> dict[str, int]: + """Сериализует запрос звонка CallTracking.""" + + return {"callId": self.call_id} + + +@dataclass(slots=True, frozen=True) +class CallTrackingCallResponse(SerializableModel): + """Ответ CallTracking get_call_by_id с объектом звонка и ошибкой.""" + + call: CallTrackingCallInfo + error: CpaErrorInfo @dataclass(slots=True, frozen=True) @@ -173,3 +274,17 @@ def filename(self) -> str | None: """Имя файла записи звонка.""" return self.binary.filename + + def to_dict(self) -> dict[str, object]: + """Сериализует бинарную запись без transport-объекта.""" + + return { + "filename": self.binary.filename, + "content_type": self.binary.content_type, + "content_base64": b64encode(self.binary.content).decode("ascii"), + } + + def model_dump(self) -> dict[str, object]: + return self.to_dict() + + diff --git a/avito/jobs/__init__.py b/avito/jobs/__init__.py index 62f48cc..f5b1e52 100644 --- a/avito/jobs/__init__.py +++ b/avito/jobs/__init__.py @@ -1,42 +1,69 @@ """Пакет jobs.""" -from avito.jobs.domain import Application, DomainObject, JobDictionary, JobWebhook, Resume, Vacancy +from avito.jobs.domain import Application, JobDictionary, JobWebhook, Resume, Vacancy from avito.jobs.models import ( + ApplicationActionRequest, + ApplicationIdsQuery, + ApplicationIdsRequest, ApplicationIdsResult, ApplicationsResult, ApplicationStatesResult, + ApplicationViewedItem, + ApplicationViewedRequest, JobActionResult, JobDictionariesResult, JobDictionaryValuesResult, JobWebhookInfo, JobWebhooksResult, + JobWebhookUpdateRequest, ResumeContactInfo, ResumeInfo, + ResumeSearchQuery, ResumesResult, + VacanciesQuery, VacanciesResult, + VacancyArchiveRequest, + VacancyAutoRenewalRequest, + VacancyCreateRequest, + VacancyIdsRequest, VacancyInfo, + VacancyProlongateRequest, VacancyStatusesResult, + VacancyUpdateRequest, ) __all__ = ( "Application", + "ApplicationActionRequest", "ApplicationIdsResult", + "ApplicationIdsQuery", + "ApplicationIdsRequest", "ApplicationsResult", "ApplicationStatesResult", - "DomainObject", + "ApplicationViewedItem", + "ApplicationViewedRequest", "JobActionResult", "JobDictionariesResult", "JobDictionary", "JobDictionaryValuesResult", "JobWebhook", "JobWebhookInfo", + "JobWebhookUpdateRequest", "JobWebhooksResult", "Resume", "ResumeContactInfo", "ResumeInfo", + "ResumeSearchQuery", "ResumesResult", "VacanciesResult", "Vacancy", + "VacancyArchiveRequest", + "VacancyAutoRenewalRequest", + "VacancyCreateRequest", "VacancyInfo", + "VacancyIdsRequest", + "VacancyProlongateRequest", "VacancyStatusesResult", + "VacanciesQuery", + "VacancyUpdateRequest", ) diff --git a/avito/jobs/client.py b/avito/jobs/client.py index 8512170..e8fdba5 100644 --- a/avito/jobs/client.py +++ b/avito/jobs/client.py @@ -2,10 +2,10 @@ from __future__ import annotations -from collections.abc import Mapping from dataclasses import dataclass from avito.core import RequestContext, Transport +from avito.core.mapping import request_public_model from avito.jobs.mappers import ( map_application_ids, map_application_states, @@ -23,21 +23,33 @@ map_vacancy_statuses, ) from avito.jobs.models import ( + ApplicationActionRequest, + ApplicationIdsQuery, + ApplicationIdsRequest, ApplicationIdsResult, ApplicationsResult, ApplicationStatesResult, + ApplicationViewedRequest, JobActionResult, JobDictionariesResult, JobDictionaryValuesResult, JobWebhookInfo, JobWebhooksResult, - JsonRequest, + JobWebhookUpdateRequest, ResumeContactInfo, ResumeInfo, + ResumeSearchQuery, ResumesResult, + VacanciesQuery, VacanciesResult, + VacancyArchiveRequest, + VacancyAutoRenewalRequest, + VacancyCreateRequest, + VacancyIdsRequest, VacancyInfo, + VacancyProlongateRequest, VacancyStatusesResult, + VacancyUpdateRequest, ) @@ -47,50 +59,59 @@ class ApplicationsClient: transport: Transport - def apply_actions(self, request: JsonRequest) -> JobActionResult: + def apply_actions(self, request: ApplicationActionRequest) -> JobActionResult: return self._post_action( "/job/v1/applications/apply_actions", "jobs.applications.apply_actions", request ) - def get_by_ids(self, request: JsonRequest) -> ApplicationsResult: - payload = self.transport.request_json( + def get_by_ids(self, request: ApplicationIdsRequest) -> ApplicationsResult: + return request_public_model( + self.transport, "POST", "/job/v1/applications/get_by_ids", context=RequestContext("jobs.applications.get_by_ids", allow_retry=True), + mapper=map_applications, json_body=request.to_payload(), ) - return map_applications(payload) - def get_ids(self, *, params: Mapping[str, object]) -> ApplicationIdsResult: - payload = self.transport.request_json( + def get_ids(self, *, query: ApplicationIdsQuery) -> ApplicationIdsResult: + return request_public_model( + self.transport, "GET", "/job/v1/applications/get_ids", context=RequestContext("jobs.applications.get_ids"), - params=params, + mapper=map_application_ids, + params=query.to_params(), ) - return map_application_ids(payload) def get_states(self) -> ApplicationStatesResult: - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", "/job/v1/applications/get_states", context=RequestContext("jobs.applications.get_states"), + mapper=map_application_states, ) - return map_application_states(payload) - def set_is_viewed(self, request: JsonRequest) -> JobActionResult: + def set_is_viewed(self, request: ApplicationViewedRequest) -> JobActionResult: return self._post_action( "/job/v1/applications/set_is_viewed", "jobs.applications.set_is_viewed", request ) - def _post_action(self, path: str, operation: str, request: JsonRequest) -> JobActionResult: - payload = self.transport.request_json( + def _post_action( + self, + path: str, + operation: str, + request: ApplicationActionRequest | ApplicationViewedRequest, + ) -> JobActionResult: + return request_public_model( + self.transport, "POST", path, context=RequestContext(operation, allow_retry=True), + mapper=map_job_action, json_body=request.to_payload(), ) - return map_job_action(payload) @dataclass(slots=True) @@ -100,38 +121,42 @@ class WebhookClient: transport: Transport def get_webhook(self) -> JobWebhookInfo: - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", "/job/v1/applications/webhook", context=RequestContext("jobs.webhook.get"), + mapper=map_job_webhook, ) - return map_job_webhook(payload) - def put_webhook(self, request: JsonRequest) -> JobWebhookInfo: - payload = self.transport.request_json( + def put_webhook(self, request: JobWebhookUpdateRequest) -> JobWebhookInfo: + return request_public_model( + self.transport, "PUT", "/job/v1/applications/webhook", context=RequestContext("jobs.webhook.put", allow_retry=True), + mapper=map_job_webhook, json_body=request.to_payload(), ) - return map_job_webhook(payload) def delete_webhook(self, *, url: str | None = None) -> JobActionResult: - payload = self.transport.request_json( + return request_public_model( + self.transport, "DELETE", "/job/v1/applications/webhook", context=RequestContext("jobs.webhook.delete", allow_retry=True), + mapper=map_job_action, params={"url": url}, ) - return map_job_action(payload) def list_webhooks(self) -> JobWebhooksResult: - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", "/job/v1/applications/webhooks", context=RequestContext("jobs.webhook.list"), + mapper=map_job_webhooks, ) - return map_job_webhooks(payload) @dataclass(slots=True) @@ -140,30 +165,33 @@ class ResumeClient: transport: Transport - def search(self, *, params: Mapping[str, object] | None = None) -> ResumesResult: - payload = self.transport.request_json( + def search(self, *, query: ResumeSearchQuery | None = None) -> ResumesResult: + return request_public_model( + self.transport, "GET", "/job/v1/resumes/", context=RequestContext("jobs.resumes.search"), - params=params, + mapper=map_resumes, + params=query.to_params() if query is not None else None, ) - return map_resumes(payload) def get_contacts(self, *, resume_id: str) -> ResumeContactInfo: - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", f"/job/v1/resumes/{resume_id}/contacts/", context=RequestContext("jobs.resumes.get_contacts"), + mapper=map_resume_contacts, ) - return map_resume_contacts(payload) def get_item(self, *, resume_id: str) -> ResumeInfo: - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", f"/job/v2/resumes/{resume_id}", context=RequestContext("jobs.resumes.get_item"), + mapper=map_resume_item, ) - return map_resume_item(payload) @dataclass(slots=True) @@ -172,106 +200,137 @@ class VacanciesClient: transport: Transport - def create_v1(self, request: JsonRequest) -> JobActionResult: - payload = self.transport.request_json( + def create_classic(self, request: VacancyCreateRequest) -> JobActionResult: + return request_public_model( + self.transport, "POST", "/job/v1/vacancies", - context=RequestContext("jobs.vacancies.create_v1", allow_retry=True), + context=RequestContext("jobs.vacancies.create_classic", allow_retry=True), + mapper=map_job_action, json_body=request.to_payload(), ) - return map_job_action(payload) - def archive_v1(self, *, vacancy_id: int | str, request: JsonRequest) -> JobActionResult: - payload = self.transport.request_json( + def archive( + self, + *, + vacancy_id: int | str, + request: VacancyArchiveRequest, + ) -> JobActionResult: + return request_public_model( + self.transport, "PUT", f"/job/v1/vacancies/archived/{vacancy_id}", - context=RequestContext("jobs.vacancies.archive_v1", allow_retry=True), + context=RequestContext("jobs.vacancies.archive", allow_retry=True), + mapper=map_job_action, json_body=request.to_payload(), ) - return map_job_action(payload) - def update_v1(self, *, vacancy_id: int | str, request: JsonRequest) -> JobActionResult: - payload = self.transport.request_json( + def update_classic( + self, + *, + vacancy_id: int | str, + request: VacancyUpdateRequest, + ) -> JobActionResult: + return request_public_model( + self.transport, "PUT", f"/job/v1/vacancies/{vacancy_id}", - context=RequestContext("jobs.vacancies.update_v1", allow_retry=True), + context=RequestContext("jobs.vacancies.update_classic", allow_retry=True), + mapper=map_job_action, json_body=request.to_payload(), ) - return map_job_action(payload) - def prolongate_v1(self, *, vacancy_id: int | str, request: JsonRequest) -> JobActionResult: - payload = self.transport.request_json( + def prolongate( + self, + *, + vacancy_id: int | str, + request: VacancyProlongateRequest, + ) -> JobActionResult: + return request_public_model( + self.transport, "POST", f"/job/v1/vacancies/{vacancy_id}/prolongate", - context=RequestContext("jobs.vacancies.prolongate_v1", allow_retry=True), + context=RequestContext("jobs.vacancies.prolongate", allow_retry=True), + mapper=map_job_action, json_body=request.to_payload(), ) - return map_job_action(payload) - def list_v2(self, *, params: Mapping[str, object] | None = None) -> VacanciesResult: - payload = self.transport.request_json( + def list(self, *, query: VacanciesQuery | None = None) -> VacanciesResult: + return request_public_model( + self.transport, "GET", "/job/v2/vacancies", - context=RequestContext("jobs.vacancies.list_v2"), - params=params, + context=RequestContext("jobs.vacancies.list"), + mapper=map_vacancies, + params=query.to_params() if query is not None else None, ) - return map_vacancies(payload) - def create_v2(self, request: JsonRequest) -> JobActionResult: - payload = self.transport.request_json( + def create(self, request: VacancyCreateRequest) -> JobActionResult: + return request_public_model( + self.transport, "POST", "/job/v2/vacancies", - context=RequestContext("jobs.vacancies.create_v2", allow_retry=True), + context=RequestContext("jobs.vacancies.create", allow_retry=True), + mapper=map_job_action, json_body=request.to_payload(), ) - return map_job_action(payload) - def get_by_ids_v2(self, request: JsonRequest) -> VacanciesResult: - payload = self.transport.request_json( + def get_by_ids(self, request: VacancyIdsRequest) -> VacanciesResult: + return request_public_model( + self.transport, "POST", "/job/v2/vacancies/batch", - context=RequestContext("jobs.vacancies.get_by_ids_v2", allow_retry=True), + context=RequestContext("jobs.vacancies.get_by_ids", allow_retry=True), + mapper=map_vacancies, json_body=request.to_payload(), ) - return map_vacancies(payload) - def get_statuses_v2(self, request: JsonRequest) -> VacancyStatusesResult: - payload = self.transport.request_json( + def get_statuses(self, request: VacancyIdsRequest) -> VacancyStatusesResult: + return request_public_model( + self.transport, "POST", "/job/v2/vacancies/statuses", - context=RequestContext("jobs.vacancies.get_statuses_v2", allow_retry=True), + context=RequestContext("jobs.vacancies.get_statuses", allow_retry=True), + mapper=map_vacancy_statuses, json_body=request.to_payload(), ) - return map_vacancy_statuses(payload) - def update_v2(self, *, vacancy_uuid: str, request: JsonRequest) -> JobActionResult: - payload = self.transport.request_json( + def update(self, *, vacancy_uuid: str, request: VacancyUpdateRequest) -> JobActionResult: + return request_public_model( + self.transport, "POST", f"/job/v2/vacancies/update/{vacancy_uuid}", - context=RequestContext("jobs.vacancies.update_v2", allow_retry=True), + context=RequestContext("jobs.vacancies.update", allow_retry=True), + mapper=map_job_action, json_body=request.to_payload(), ) - return map_job_action(payload) - def get_item_v2( - self, *, vacancy_id: int | str, params: Mapping[str, object] | None = None + def get_item( + self, *, vacancy_id: int | str, query: VacanciesQuery | None = None ) -> VacancyInfo: - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", f"/job/v2/vacancies/{vacancy_id}", - context=RequestContext("jobs.vacancies.get_item_v2"), - params=params, + context=RequestContext("jobs.vacancies.get_item"), + mapper=map_vacancy_item, + params=query.to_params() if query is not None else None, ) - return map_vacancy_item(payload) - def auto_renewal_v2(self, *, vacancy_uuid: str, request: JsonRequest) -> JobActionResult: - payload = self.transport.request_json( + def update_auto_renewal( + self, + *, + vacancy_uuid: str, + request: VacancyAutoRenewalRequest, + ) -> JobActionResult: + return request_public_model( + self.transport, "PUT", f"/job/v2/vacancies/{vacancy_uuid}/auto_renewal", - context=RequestContext("jobs.vacancies.auto_renewal_v2", allow_retry=True), + context=RequestContext("jobs.vacancies.update_auto_renewal", allow_retry=True), + mapper=map_job_action, json_body=request.to_payload(), ) - return map_job_action(payload) @dataclass(slots=True) @@ -281,20 +340,22 @@ class DictionariesClient: transport: Transport def list_dicts(self) -> JobDictionariesResult: - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", "/job/v2/vacancy/dict", context=RequestContext("jobs.dictionaries.list_dicts"), + mapper=map_job_dictionaries, ) - return map_job_dictionaries(payload) def get_dict_by_id(self, *, dictionary_id: str) -> JobDictionaryValuesResult: - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", f"/job/v2/vacancy/dict/{dictionary_id}", context=RequestContext("jobs.dictionaries.get_dict_by_id"), + mapper=map_job_dictionary_values, ) - return map_job_dictionary_values(payload) __all__ = ( diff --git a/avito/jobs/domain.py b/avito/jobs/domain.py index 099e96b..ee98fa0 100644 --- a/avito/jobs/domain.py +++ b/avito/jobs/domain.py @@ -2,10 +2,11 @@ from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Sequence from dataclasses import dataclass -from avito.core import Transport +from avito.core import ValidationError +from avito.core.domain import DomainObject from avito.jobs.client import ( ApplicationsClient, DictionariesClient, @@ -14,167 +15,178 @@ WebhookClient, ) from avito.jobs.models import ( + ApplicationActionRequest, + ApplicationIdsQuery, + ApplicationIdsRequest, + ApplicationIdsResult, + ApplicationsResult, ApplicationStatesResult, + ApplicationViewedItem, + ApplicationViewedRequest, JobActionResult, JobDictionariesResult, JobDictionaryValuesResult, JobWebhookInfo, JobWebhooksResult, - JsonRequest, + JobWebhookUpdateRequest, ResumeContactInfo, ResumeInfo, + ResumeSearchQuery, ResumesResult, + VacanciesQuery, VacanciesResult, + VacancyArchiveRequest, + VacancyAutoRenewalRequest, + VacancyCreateRequest, + VacancyIdsRequest, VacancyInfo, + VacancyProlongateRequest, VacancyStatusesResult, + VacancyUpdateRequest, ) -@dataclass(slots=True, frozen=True) -class DomainObject: - """Базовый доменный объект раздела jobs.""" - - transport: Transport - - @dataclass(slots=True, frozen=True) class Vacancy(DomainObject): """Доменный объект вакансий.""" - resource_id: int | str | None = None + vacancy_id: int | str | None = None user_id: int | str | None = None - def create(self, *, payload: Mapping[str, object], version: int = 2) -> JobActionResult: + def create(self, *, title: str, version: int = 2) -> JobActionResult: client = VacanciesClient(self.transport) - request = JsonRequest(payload) + request = VacancyCreateRequest(title=title) if version == 1: - return client.create_v1(request) - return client.create_v2(request) + return client.create_classic(request) + return client.create(request) def update( self, *, - payload: Mapping[str, object], + request: VacancyUpdateRequest, vacancy_id: int | str | None = None, vacancy_uuid: str | None = None, version: int = 2, ) -> JobActionResult: client = VacanciesClient(self.transport) - request = JsonRequest(payload) if version == 1: - return client.update_v1( - vacancy_id=vacancy_id or self._require_resource_id(), request=request + return client.update_classic( + vacancy_id=vacancy_id or self._require_vacancy_id(), request=request ) - return client.update_v2( - vacancy_uuid=vacancy_uuid or self._require_resource_id(), request=request + return client.update( + vacancy_uuid=vacancy_uuid or self._require_vacancy_id(), request=request ) def delete( - self, *, payload: Mapping[str, object], vacancy_id: int | str | None = None + self, *, request: VacancyArchiveRequest, vacancy_id: int | str | None = None ) -> JobActionResult: - return VacanciesClient(self.transport).archive_v1( - vacancy_id=vacancy_id or self._require_resource_id(), - request=JsonRequest(payload), + return VacanciesClient(self.transport).archive( + vacancy_id=vacancy_id or self._require_vacancy_id(), + request=request, ) def prolongate( - self, *, payload: Mapping[str, object], vacancy_id: int | str | None = None + self, *, request: VacancyProlongateRequest, vacancy_id: int | str | None = None ) -> JobActionResult: - return VacanciesClient(self.transport).prolongate_v1( - vacancy_id=vacancy_id or self._require_resource_id(), - request=JsonRequest(payload), + return VacanciesClient(self.transport).prolongate( + vacancy_id=vacancy_id or self._require_vacancy_id(), + request=request, ) - def list(self, *, params: Mapping[str, object] | None = None) -> VacanciesResult: - return VacanciesClient(self.transport).list_v2(params=params) + def list(self, *, query: VacanciesQuery | None = None) -> VacanciesResult: + return VacanciesClient(self.transport).list(query=query) def get( - self, *, vacancy_id: int | str | None = None, params: Mapping[str, object] | None = None + self, *, vacancy_id: int | str | None = None, query: VacanciesQuery | None = None ) -> VacancyInfo: - return VacanciesClient(self.transport).get_item_v2( - vacancy_id=vacancy_id or self._require_resource_id(), - params=params, + return VacanciesClient(self.transport).get_item( + vacancy_id=vacancy_id or self._require_vacancy_id(), + query=query, ) - def get_by_ids(self, *, payload: Mapping[str, object]) -> VacanciesResult: - return VacanciesClient(self.transport).get_by_ids_v2(JsonRequest(payload)) + def get_by_ids(self, *, ids: Sequence[int]) -> VacanciesResult: + return VacanciesClient(self.transport).get_by_ids(VacancyIdsRequest(ids=list(ids))) - def get_statuses(self, *, payload: Mapping[str, object]) -> VacancyStatusesResult: - return VacanciesClient(self.transport).get_statuses_v2(JsonRequest(payload)) + def get_statuses(self, *, ids: Sequence[int]) -> VacancyStatusesResult: + return VacanciesClient(self.transport).get_statuses(VacancyIdsRequest(ids=list(ids))) def update_auto_renewal( - self, *, payload: Mapping[str, object], vacancy_uuid: str | None = None + self, *, request: VacancyAutoRenewalRequest, vacancy_uuid: str | None = None ) -> JobActionResult: - return VacanciesClient(self.transport).auto_renewal_v2( - vacancy_uuid=vacancy_uuid or self._require_resource_id(), - request=JsonRequest(payload), + return VacanciesClient(self.transport).update_auto_renewal( + vacancy_uuid=vacancy_uuid or self._require_vacancy_id(), + request=request, ) - def _require_resource_id(self) -> str: - if self.resource_id is None: - raise ValueError("Для операции требуется идентификатор вакансии.") - return str(self.resource_id) + def _require_vacancy_id(self) -> str: + if self.vacancy_id is None: + raise ValidationError("Для операции требуется идентификатор вакансии.") + return str(self.vacancy_id) @dataclass(slots=True, frozen=True) class Application(DomainObject): """Доменный объект откликов.""" - resource_id: int | str | None = None user_id: int | str | None = None - def apply(self, *, payload: Mapping[str, object]) -> JobActionResult: - return ApplicationsClient(self.transport).apply_actions(JsonRequest(payload)) + def apply(self, *, ids: Sequence[str], action: str) -> JobActionResult: + return ApplicationsClient(self.transport).apply_actions( + ApplicationActionRequest(ids=list(ids), action=action) + ) def list( self, *, - payload: Mapping[str, object] | None = None, - params: Mapping[str, object] | None = None, - ) -> object: + request: ApplicationIdsRequest | None = None, + query: ApplicationIdsQuery | None = None, + ) -> ApplicationsResult | ApplicationIdsResult: client = ApplicationsClient(self.transport) - if payload is not None: - return client.get_by_ids(JsonRequest(payload)) - return client.get_ids(params=params or {}) + if request is not None: + return client.get_by_ids(request) + if query is None: + raise ValidationError("Для операции требуется `query` или `request`.") + return client.get_ids(query=query) def get_states(self) -> ApplicationStatesResult: return ApplicationsClient(self.transport).get_states() - def update(self, *, payload: Mapping[str, object]) -> JobActionResult: - return ApplicationsClient(self.transport).set_is_viewed(JsonRequest(payload)) + def update(self, *, applies: Sequence[ApplicationViewedItem]) -> JobActionResult: + return ApplicationsClient(self.transport).set_is_viewed( + ApplicationViewedRequest(applies=list(applies)) + ) @dataclass(slots=True, frozen=True) class Resume(DomainObject): """Доменный объект резюме.""" - resource_id: int | str | None = None + resume_id: int | str | None = None user_id: int | str | None = None - def list(self, *, params: Mapping[str, object] | None = None) -> ResumesResult: - return ResumeClient(self.transport).search(params=params) + def list(self, *, query: ResumeSearchQuery | None = None) -> ResumesResult: + return ResumeClient(self.transport).search(query=query) def get(self, *, resume_id: int | str | None = None) -> ResumeInfo: return ResumeClient(self.transport).get_item( - resume_id=str(resume_id or self._require_resource_id()) + resume_id=str(resume_id or self._require_resume_id()) ) def get_contacts(self, *, resume_id: int | str | None = None) -> ResumeContactInfo: return ResumeClient(self.transport).get_contacts( - resume_id=str(resume_id or self._require_resource_id()) + resume_id=str(resume_id or self._require_resume_id()) ) - def _require_resource_id(self) -> str: - if self.resource_id is None: - raise ValueError("Для операции требуется `resume_id`.") - return str(self.resource_id) + def _require_resume_id(self) -> str: + if self.resume_id is None: + raise ValidationError("Для операции требуется `resume_id`.") + return str(self.resume_id) @dataclass(slots=True, frozen=True) class JobWebhook(DomainObject): """Доменный объект webhook откликов.""" - resource_id: int | str | None = None user_id: int | str | None = None def get(self) -> JobWebhookInfo: @@ -183,8 +195,8 @@ def get(self) -> JobWebhookInfo: def list(self) -> JobWebhooksResult: return WebhookClient(self.transport).list_webhooks() - def update(self, *, payload: Mapping[str, object]) -> JobWebhookInfo: - return WebhookClient(self.transport).put_webhook(JsonRequest(payload)) + def update(self, *, url: str) -> JobWebhookInfo: + return WebhookClient(self.transport).put_webhook(JobWebhookUpdateRequest(url=url)) def delete(self, *, url: str | None = None) -> JobActionResult: return WebhookClient(self.transport).delete_webhook(url=url) @@ -194,7 +206,7 @@ def delete(self, *, url: str | None = None) -> JobActionResult: class JobDictionary(DomainObject): """Доменный объект словарей вакансий.""" - resource_id: int | str | None = None + dictionary_id: int | str | None = None user_id: int | str | None = None def list(self) -> JobDictionariesResult: @@ -202,13 +214,13 @@ def list(self) -> JobDictionariesResult: def get(self, *, dictionary_id: str | None = None) -> JobDictionaryValuesResult: return DictionariesClient(self.transport).get_dict_by_id( - dictionary_id=dictionary_id or self._require_resource_id() + dictionary_id=dictionary_id or self._require_dictionary_id() ) - def _require_resource_id(self) -> str: - if self.resource_id is None: - raise ValueError("Для операции требуется `dictionary_id`.") - return str(self.resource_id) + def _require_dictionary_id(self) -> str: + if self.dictionary_id is None: + raise ValidationError("Для операции требуется `dictionary_id`.") + return str(self.dictionary_id) -__all__ = ("Application", "DomainObject", "JobDictionary", "JobWebhook", "Resume", "Vacancy") +__all__ = ("Application", "JobDictionary", "JobWebhook", "Resume", "Vacancy") diff --git a/avito/jobs/enums.py b/avito/jobs/enums.py deleted file mode 100644 index 2b491e3..0000000 --- a/avito/jobs/enums.py +++ /dev/null @@ -1 +0,0 @@ -"""Строковые enum и константы пакета jobs.""" diff --git a/avito/jobs/mappers.py b/avito/jobs/mappers.py index bfc8704..1666278 100644 --- a/avito/jobs/mappers.py +++ b/avito/jobs/mappers.py @@ -99,7 +99,6 @@ def map_job_action(payload: object) -> JobActionResult: id=identifier or (str(numeric_id) if numeric_id is not None else None), status=_str(source, "status", "state"), message=_str(source, "message"), - raw_payload=data, ) @@ -111,7 +110,6 @@ def map_application(payload: Payload) -> ApplicationInfo: state=_str(payload, "state", "status"), is_viewed=_bool(payload, "is_viewed", "isViewed"), applicant_name=_str(_mapping(payload, "applicant"), "name", "fullName"), - raw_payload=payload, ) @@ -124,7 +122,6 @@ def map_applications(payload: object) -> ApplicationsResult: map_application(item) for item in _list(data, "applies", "applications", "items", "result") ], - raw_payload=data, ) @@ -137,12 +134,10 @@ def map_application_ids(payload: object) -> ApplicationIdsResult: ApplicationIdItem( id=_str(item, "id"), updated_at=_str(item, "updatedAt", "updated_at"), - raw_payload=item, ) for item in _list(data, "items", "applies", "result") ], cursor=_str(_mapping(data, "meta"), "cursor") or _str(data, "cursor"), - raw_payload=data, ) @@ -155,11 +150,9 @@ def map_application_states(payload: object) -> ApplicationStatesResult: ApplicationState( slug=_str(item, "slug", "id"), description=_str(item, "description", "name"), - raw_payload=item, ) for item in _list(data, "states", "items", "result") ], - raw_payload=data, ) @@ -172,7 +165,6 @@ def map_resume(payload: Payload) -> ResumeInfo: location=_str(payload, "location") or _str(_mapping(payload, "address_details"), "location"), salary=_int(payload, "salary") or _int(salary_payload, "value", "from"), - raw_payload=payload, ) @@ -185,7 +177,6 @@ def map_resumes(payload: object) -> ResumesResult: items=[map_resume(item) for item in _list(data, "resumes", "items", "result")], cursor=_str(meta, "cursor"), total=_int(meta, "total"), - raw_payload=data, ) @@ -204,7 +195,6 @@ def map_resume_contacts(payload: object) -> ResumeContactInfo: name=_str(data, "name", "fullName"), phone=_str(data, "phone", "phoneNumber"), email=_str(data, "email"), - raw_payload=data, ) @@ -220,7 +210,6 @@ def map_vacancy(payload: Payload) -> VacancyInfo: title=_str(payload, "title", "name"), status=_str(payload, "status", "state"), url=_str(payload, "url"), - raw_payload=payload, ) @@ -242,7 +231,6 @@ def map_vacancies(payload: object) -> VacanciesResult: return VacanciesResult( items=[map_vacancy(item) for item in items], total=_int(meta, "total") or _int(data, "total"), - raw_payload=data, ) @@ -261,11 +249,9 @@ def map_vacancy_statuses(payload: object) -> VacancyStatusesResult: ), uuid=_str(item, "uuid", "vacancy_uuid"), status=_str(item, "status", "state"), - raw_payload=item, ) for item in _list(data, "items", "statuses", "vacancies", "result") ], - raw_payload=data, ) @@ -277,7 +263,6 @@ def map_job_webhook(payload: object) -> JobWebhookInfo: url=_str(data, "url"), is_active=_bool(data, "is_active", "isActive", "active"), version=_str(data, "version"), - raw_payload=data, ) @@ -286,14 +271,11 @@ def map_job_webhooks(payload: object) -> JobWebhooksResult: if isinstance(payload, list): items_payload = _expect_list(payload) - return JobWebhooksResult( - items=[map_job_webhook(item) for item in items_payload], raw_payload={} - ) + return JobWebhooksResult(items=[map_job_webhook(item) for item in items_payload]) data = _expect_mapping(payload) return JobWebhooksResult( items=[map_job_webhook(item) for item in _list(data, "items", "webhooks", "result")], - raw_payload=data, ) @@ -310,11 +292,9 @@ def map_job_dictionaries(payload: object) -> JobDictionariesResult: JobDictionaryInfo( id=_str(item, "id"), description=_str(item, "description"), - raw_payload=item, ) for item in items_payload ], - raw_payload={} if isinstance(payload, list) else _expect_mapping(payload), ) @@ -332,9 +312,7 @@ def map_job_dictionary_values(payload: object) -> JobDictionaryValuesResult: id=_int(item, "id") if _int(item, "id") is not None else _str(item, "id"), name=_str(item, "name", "description"), deprecated=_bool(item, "deprecated"), - raw_payload=item, ) for item in items_payload ], - raw_payload={} if isinstance(payload, list) else _expect_mapping(payload), ) diff --git a/avito/jobs/models.py b/avito/jobs/models.py index 2901974..8e97adf 100644 --- a/avito/jobs/models.py +++ b/avito/jobs/models.py @@ -2,35 +2,196 @@ from __future__ import annotations -from collections.abc import Mapping -from dataclasses import dataclass, field +from dataclasses import dataclass + +from avito.core.serialization import SerializableModel + + +@dataclass(slots=True, frozen=True) +class ApplicationIdsQuery: + """Query списка идентификаторов откликов.""" + + updated_at_from: str + + def to_params(self) -> dict[str, str]: + """Сериализует query-параметры идентификаторов откликов.""" + + return {"updatedAtFrom": self.updated_at_from} + + +@dataclass(slots=True, frozen=True) +class ApplicationIdsRequest: + """Запрос получения откликов по идентификаторам.""" + + ids: list[str] + + def to_payload(self) -> dict[str, object]: + """Сериализует идентификаторы откликов.""" + + return {"ids": list(self.ids)} + + +@dataclass(slots=True, frozen=True) +class ApplicationActionRequest: + """Запрос действия над откликами.""" + + ids: list[str] + action: str + + def to_payload(self) -> dict[str, object]: + """Сериализует действие над откликами.""" + + return {"ids": list(self.ids), "action": self.action} + + +@dataclass(slots=True, frozen=True) +class ApplicationViewedItem: + """Флаг просмотра для отклика.""" + + id: str + is_viewed: bool + + def to_payload(self) -> dict[str, object]: + """Сериализует флаг просмотра отклика.""" + + return {"id": self.id, "is_viewed": self.is_viewed} + + +@dataclass(slots=True, frozen=True) +class ApplicationViewedRequest: + """Запрос обновления флага просмотра откликов.""" + + applies: list[ApplicationViewedItem] + + def to_payload(self) -> dict[str, object]: + """Сериализует запрос обновления просмотра откликов.""" + + return {"applies": [item.to_payload() for item in self.applies]} + + +@dataclass(slots=True, frozen=True) +class JobWebhookUpdateRequest: + """Запрос обновления webhook откликов.""" + + url: str + + def to_payload(self) -> dict[str, object]: + """Сериализует webhook откликов.""" + + return {"url": self.url} + + +@dataclass(slots=True, frozen=True) +class ResumeSearchQuery: + """Query поиска резюме.""" + + query: str + + def to_params(self) -> dict[str, str]: + """Сериализует query поиска резюме.""" + + return {"query": self.query} + + +@dataclass(slots=True, frozen=True) +class VacanciesQuery: + """Query списка или карточки вакансий.""" + + query: str | None = None + + def to_params(self) -> dict[str, str]: + """Сериализует query вакансий.""" + + params: dict[str, str] = {} + if self.query is not None: + params["query"] = self.query + return params + + +@dataclass(slots=True, frozen=True) +class VacancyCreateRequest: + """Запрос создания вакансии.""" + + title: str + + def to_payload(self) -> dict[str, object]: + """Сериализует создание вакансии.""" + + return {"title": self.title} + + +@dataclass(slots=True, frozen=True) +class VacancyUpdateRequest: + """Запрос обновления вакансии.""" + + title: str + + def to_payload(self) -> dict[str, object]: + """Сериализует обновление вакансии.""" + + return {"title": self.title} + + +@dataclass(slots=True, frozen=True) +class VacancyArchiveRequest: + """Запрос архивации вакансии v1.""" + + employee_id: int + + def to_payload(self) -> dict[str, object]: + """Сериализует архивацию вакансии.""" + + return {"employee_id": self.employee_id} + + +@dataclass(slots=True, frozen=True) +class VacancyProlongateRequest: + """Запрос продления вакансии v1.""" + + billing_type: str + + def to_payload(self) -> dict[str, object]: + """Сериализует продление вакансии.""" + + return {"billing_type": self.billing_type} + + +@dataclass(slots=True, frozen=True) +class VacancyIdsRequest: + """Запрос списка вакансий по идентификаторам.""" + + ids: list[int] + + def to_payload(self) -> dict[str, object]: + """Сериализует идентификаторы вакансий.""" + + return {"ids": list(self.ids)} @dataclass(slots=True, frozen=True) -class JsonRequest: - """Типизированная обертка над JSON payload запроса.""" +class VacancyAutoRenewalRequest: + """Запрос обновления автообновления вакансии.""" - payload: Mapping[str, object] + auto_renewal: bool def to_payload(self) -> dict[str, object]: - """Сериализует JSON payload запроса.""" + """Сериализует флаг автообновления.""" - return dict(self.payload) + return {"auto_renewal": self.auto_renewal} @dataclass(slots=True, frozen=True) -class JobActionResult: +class JobActionResult(SerializableModel): """Результат mutation-операции Jobs API.""" success: bool id: str | None = None status: str | None = None message: str | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class ApplicationInfo: +class ApplicationInfo(SerializableModel): """Информация об отклике.""" id: str | None @@ -39,54 +200,48 @@ class ApplicationInfo: state: str | None is_viewed: bool | None applicant_name: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class ApplicationsResult: +class ApplicationsResult(SerializableModel): """Список откликов.""" items: list[ApplicationInfo] - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class ApplicationIdItem: +class ApplicationIdItem(SerializableModel): """Идентификатор отклика.""" id: str | None updated_at: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class ApplicationIdsResult: +class ApplicationIdsResult(SerializableModel): """Постраничный список идентификаторов откликов.""" items: list[ApplicationIdItem] cursor: str | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class ApplicationState: +class ApplicationState(SerializableModel): """Статус отклика.""" slug: str | None description: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class ApplicationStatesResult: +class ApplicationStatesResult(SerializableModel): """Список возможных статусов откликов.""" items: list[ApplicationState] - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class ResumeInfo: +class ResumeInfo(SerializableModel): """Краткая или полная информация о резюме.""" id: str | None @@ -94,31 +249,28 @@ class ResumeInfo: candidate_name: str | None location: str | None salary: int | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class ResumesResult: +class ResumesResult(SerializableModel): """Результат поиска резюме.""" items: list[ResumeInfo] cursor: str | None = None total: int | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class ResumeContactInfo: +class ResumeContactInfo(SerializableModel): """Контакты соискателя.""" name: str | None phone: str | None email: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class VacancyInfo: +class VacancyInfo(SerializableModel): """Информация о вакансии.""" id: str | None @@ -126,84 +278,74 @@ class VacancyInfo: title: str | None status: str | None url: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class VacanciesResult: +class VacanciesResult(SerializableModel): """Список вакансий.""" items: list[VacancyInfo] total: int | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class VacancyStatusInfo: +class VacancyStatusInfo(SerializableModel): """Статус публикации вакансии v2.""" id: str | None uuid: str | None status: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class VacancyStatusesResult: +class VacancyStatusesResult(SerializableModel): """Список статусов вакансий.""" items: list[VacancyStatusInfo] - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class JobWebhookInfo: +class JobWebhookInfo(SerializableModel): """Подписка webhook раздела Работа.""" url: str | None is_active: bool | None version: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class JobWebhooksResult: +class JobWebhooksResult(SerializableModel): """Список webhook-подписок.""" items: list[JobWebhookInfo] - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class JobDictionaryInfo: +class JobDictionaryInfo(SerializableModel): """Справочник вакансий.""" id: str | None description: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class JobDictionariesResult: +class JobDictionariesResult(SerializableModel): """Список доступных словарей.""" items: list[JobDictionaryInfo] - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class JobDictionaryValue: +class JobDictionaryValue(SerializableModel): """Значение словаря вакансий.""" id: int | str | None name: str | None deprecated: bool | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class JobDictionaryValuesResult: +class JobDictionaryValuesResult(SerializableModel): """Список значений словаря.""" items: list[JobDictionaryValue] - raw_payload: Mapping[str, object] = field(default_factory=dict) diff --git a/avito/messages/__init__.py b/avito/messages/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/avito/messages/message.py b/avito/messages/message.py deleted file mode 100644 index 68c5f23..0000000 --- a/avito/messages/message.py +++ /dev/null @@ -1 +0,0 @@ -class Message: ... diff --git a/avito/messenger/__init__.py b/avito/messenger/__init__.py index 5db1875..29c7422 100644 --- a/avito/messenger/__init__.py +++ b/avito/messenger/__init__.py @@ -5,7 +5,6 @@ ChatMedia, ChatMessage, ChatWebhook, - DomainObject, SpecialOfferCampaign, ) from avito.messenger.models import ( @@ -19,6 +18,8 @@ SpecialOfferStatsResult, SubscriptionsResult, TariffInfo, + UploadImageFile, + UploadImagesRequest, UploadImagesResult, VoiceFilesResult, WebhookActionResult, @@ -31,7 +32,6 @@ "ChatMessage", "ChatWebhook", "ChatsResult", - "DomainObject", "MessageActionResult", "MessageInfo", "MessagesResult", @@ -41,6 +41,8 @@ "SpecialOfferStatsResult", "SubscriptionsResult", "TariffInfo", + "UploadImageFile", + "UploadImagesRequest", "UploadImagesResult", "VoiceFilesResult", "WebhookActionResult", diff --git a/avito/messenger/client.py b/avito/messenger/client.py index 0aed527..bf6c8ed 100644 --- a/avito/messenger/client.py +++ b/avito/messenger/client.py @@ -38,6 +38,7 @@ TariffInfo, UnsubscribeWebhookRequest, UpdateWebhookRequest, + UploadImagesRequest, UploadImagesResult, VoiceFilesResult, WebhookActionResult, @@ -197,7 +198,7 @@ def upload_images( self, *, user_id: int, - files: dict[str, object], + request: UploadImagesRequest, ) -> UploadImagesResult: """Загружает изображения для сообщений.""" @@ -205,7 +206,7 @@ def upload_images( "POST", f"/messenger/v1/accounts/{user_id}/uploadImages", context=RequestContext("messenger.media.upload_images", allow_retry=True), - files=files, + files=request.to_files(), ) return map_upload_images(payload) diff --git a/avito/messenger/domain.py b/avito/messenger/domain.py index c16df43..80211ae 100644 --- a/avito/messenger/domain.py +++ b/avito/messenger/domain.py @@ -4,7 +4,8 @@ from dataclasses import dataclass -from avito.core import Transport +from avito.core import ValidationError +from avito.core.domain import DomainObject from avito.messenger.client import MediaClient, MessengerClient, SpecialOffersClient, WebhookClient from avito.messenger.models import ( BlacklistRequest, @@ -25,24 +26,19 @@ TariffInfo, UnsubscribeWebhookRequest, UpdateWebhookRequest, + UploadImageFile, + UploadImagesRequest, UploadImagesResult, VoiceFilesResult, WebhookActionResult, ) -@dataclass(slots=True, frozen=True) -class DomainObject: - """Базовый доменный объект раздела messenger.""" - - transport: Transport - - @dataclass(slots=True, frozen=True) class Chat(DomainObject): """Доменный объект чата.""" - resource_id: int | str | None = None + chat_id: int | str | None = None user_id: int | str | None = None def get(self) -> ChatInfo: @@ -76,20 +72,21 @@ def blacklist(self, *, blacklisted_user_id: int) -> MessageActionResult: def _require_user_id(self) -> int: if self.user_id is None: - raise ValueError("Для операции требуется `user_id`.") + raise ValidationError("Для операции требуется `user_id`.") return int(self.user_id) def _require_chat_id(self) -> str: - if self.resource_id is None: - raise ValueError("Для операции требуется `chat_id`.") - return str(self.resource_id) + if self.chat_id is None: + raise ValidationError("Для операции требуется `chat_id`.") + return str(self.chat_id) @dataclass(slots=True, frozen=True) class ChatMessage(DomainObject): """Доменный объект сообщения чата.""" - resource_id: int | str | None = None + chat_id: int | str | None = None + message_id: int | str | None = None user_id: int | str | None = None def list(self, *, chat_id: str | None = None) -> MessagesResult: @@ -134,25 +131,24 @@ def delete( def _require_user_id(self) -> int: if self.user_id is None: - raise ValueError("Для операции требуется `user_id`.") + raise ValidationError("Для операции требуется `user_id`.") return int(self.user_id) def _require_chat_id(self) -> str: - if self.resource_id is None: - raise ValueError("Для операции требуется `chat_id`.") - return str(self.resource_id) + if self.chat_id is None: + raise ValidationError("Для операции требуется `chat_id`.") + return str(self.chat_id) def _require_message_id(self) -> str: - if self.resource_id is None: - raise ValueError("Для операции требуется `message_id`.") - return str(self.resource_id) + if self.message_id is None: + raise ValidationError("Для операции требуется `message_id`.") + return str(self.message_id) @dataclass(slots=True, frozen=True) class ChatWebhook(DomainObject): """Доменный объект webhook мессенджера.""" - resource_id: int | str | None = None user_id: int | str | None = None def list(self) -> SubscriptionsResult: @@ -175,7 +171,6 @@ def subscribe(self, *, url: str, secret: str | None = None) -> WebhookActionResu class ChatMedia(DomainObject): """Доменный объект media-функций мессенджера.""" - resource_id: int | str | None = None user_id: int | str | None = None def get_voice_files(self) -> VoiceFilesResult: @@ -183,16 +178,17 @@ def get_voice_files(self) -> VoiceFilesResult: return MediaClient(self.transport).get_voice_files(user_id=self._require_user_id()) - def upload_images(self, *, files: dict[str, object]) -> UploadImagesResult: + def upload_images(self, *, files: list[UploadImageFile]) -> UploadImagesResult: """Загружает изображения для сообщений.""" return MediaClient(self.transport).upload_images( - user_id=self._require_user_id(), files=files + user_id=self._require_user_id(), + request=UploadImagesRequest(files=files), ) def _require_user_id(self) -> int: if self.user_id is None: - raise ValueError("Для операции требуется `user_id`.") + raise ValidationError("Для операции требуется `user_id`.") return int(self.user_id) @@ -200,7 +196,7 @@ def _require_user_id(self) -> int: class SpecialOfferCampaign(DomainObject): """Доменный объект рассылки скидок и спецпредложений.""" - resource_id: int | str | None = None + campaign_id: int | str | None = None user_id: int | str | None = None def get_available(self, *, item_ids: list[int]) -> SpecialOfferAvailableResult: @@ -243,9 +239,9 @@ def get_tariff_info(self) -> TariffInfo: return SpecialOffersClient(self.transport).get_tariff_info() def _require_campaign_id(self) -> str: - if self.resource_id is None: - raise ValueError("Для операции требуется `campaign_id`.") - return str(self.resource_id) + if self.campaign_id is None: + raise ValidationError("Для операции требуется `campaign_id`.") + return str(self.campaign_id) __all__ = ( @@ -253,6 +249,5 @@ def _require_campaign_id(self) -> str: "ChatMedia", "ChatMessage", "ChatWebhook", - "DomainObject", "SpecialOfferCampaign", ) diff --git a/avito/messenger/enums.py b/avito/messenger/enums.py deleted file mode 100644 index 4c4d4a2..0000000 --- a/avito/messenger/enums.py +++ /dev/null @@ -1 +0,0 @@ -"""Строковые enum и константы пакета messenger.""" diff --git a/avito/messenger/mappers.py b/avito/messenger/mappers.py index 69467d7..b8ad350 100644 --- a/avito/messenger/mappers.py +++ b/avito/messenger/mappers.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +from datetime import datetime from typing import cast from avito.core.exceptions import ResponseMappingError @@ -51,6 +52,18 @@ def _str(payload: Payload, *keys: str) -> str | None: return None +def _datetime(payload: Payload, *keys: str) -> datetime | None: + for key in keys: + value = payload.get(key) + if isinstance(value, str): + normalized = value.replace("Z", "+00:00") + try: + return datetime.fromisoformat(normalized) + except ValueError: + continue + return None + + def _int(payload: Payload, *keys: str) -> int | None: for key in keys: value = payload.get(key) @@ -86,12 +99,11 @@ def map_chat(payload: object) -> ChatInfo: last_message = data.get("last_message") last_message_data = cast(Payload, last_message) if isinstance(last_message, Mapping) else {} return ChatInfo( - id=_str(data, "id", "chat_id", "chatId"), + chat_id=_str(data, "id", "chat_id", "chatId"), user_id=_int(data, "user_id", "userId"), title=_str(data, "title", "name"), unread_count=_int(data, "unread_count", "unreadCount"), last_message_text=_str(last_message_data, "text", "message"), - raw_payload=data, ) @@ -102,7 +114,6 @@ def map_chats(payload: object) -> ChatsResult: return ChatsResult( items=[map_chat(item) for item in _list(data, "chats", "items", "result")], total=_int(data, "total", "count"), - raw_payload=data, ) @@ -111,14 +122,13 @@ def map_message(payload: object) -> MessageInfo: data = _expect_mapping(payload) return MessageInfo( - id=_str(data, "id", "message_id", "messageId"), + message_id=_str(data, "id", "message_id", "messageId"), chat_id=_str(data, "chat_id", "chatId"), author_id=_int(data, "author_id", "authorId", "user_id", "userId"), text=_str(data, "text", "message"), - created_at=_str(data, "created_at", "createdAt"), + created_at=_datetime(data, "created_at", "createdAt"), direction=_str(data, "direction"), type=_str(data, "type"), - raw_payload=data, ) @@ -129,7 +139,6 @@ def map_messages(payload: object) -> MessagesResult: return MessagesResult( items=[map_message(item) for item in _list(data, "messages", "items", "result")], total=_int(data, "total", "count"), - raw_payload=data, ) @@ -141,7 +150,6 @@ def map_message_action(payload: object) -> MessageActionResult: success=bool(data.get("success", True)), message_id=_str(data, "message_id", "messageId", "id"), status=_str(data, "status", "message"), - raw_payload=data, ) @@ -156,11 +164,9 @@ def map_voice_files(payload: object) -> VoiceFilesResult: url=_str(item, "url"), duration=_int(item, "duration"), transcript=_str(item, "transcript", "text"), - raw_payload=item, ) for item in _list(data, "voice_files", "items", "result") ], - raw_payload=data, ) @@ -173,11 +179,9 @@ def map_upload_images(payload: object) -> UploadImagesResult: UploadImageResult( image_id=_str(item, "image_id", "imageId", "id"), url=_str(item, "url"), - raw_payload=item, ) for item in _list(data, "images", "items", "result") ], - raw_payload=data, ) @@ -191,11 +195,9 @@ def map_subscriptions(payload: object) -> SubscriptionsResult: url=_str(item, "url"), version=_str(item, "version"), status=_str(item, "status"), - raw_payload=item, ) for item in _list(data, "subscriptions", "items", "result") ], - raw_payload=data, ) @@ -206,7 +208,6 @@ def map_webhook_action(payload: object) -> WebhookActionResult: return WebhookActionResult( success=bool(data.get("success", True)), status=_str(data, "status", "message"), - raw_payload=data, ) @@ -220,11 +221,9 @@ def map_available_special_offers(payload: object) -> SpecialOfferAvailableResult item_id=_int(item, "item_id", "itemId", "id"), title=_str(item, "title"), is_available=_bool(item, "is_available", "isAvailable", "available"), - raw_payload=item, ) for item in _list(data, "items", "result") ], - raw_payload=data, ) @@ -235,7 +234,6 @@ def map_multi_create_result(payload: object) -> MultiCreateSpecialOfferResult: return MultiCreateSpecialOfferResult( campaign_id=_str(data, "campaign_id", "campaignId", "id"), status=_str(data, "status"), - raw_payload=data, ) @@ -248,7 +246,6 @@ def map_special_offer_stats(payload: object) -> SpecialOfferStatsResult: sent_count=_int(data, "sent_count", "sentCount"), delivered_count=_int(data, "delivered_count", "deliveredCount"), read_count=_int(data, "read_count", "readCount"), - raw_payload=data, ) @@ -260,7 +257,6 @@ def map_tariff_info(payload: object) -> TariffInfo: price=_float(data, "price", "amount"), currency=_str(data, "currency"), daily_limit=_int(data, "daily_limit", "dailyLimit", "limit"), - raw_payload=data, ) diff --git a/avito/messenger/models.py b/avito/messenger/models.py index 6e4e51d..2464512 100644 --- a/avito/messenger/models.py +++ b/avito/messenger/models.py @@ -2,29 +2,30 @@ from __future__ import annotations -from collections.abc import Mapping -from dataclasses import dataclass, field +from dataclasses import dataclass +from datetime import datetime +from typing import BinaryIO + +from avito.core.serialization import SerializableModel @dataclass(slots=True, frozen=True) -class ChatInfo: +class ChatInfo(SerializableModel): """Информация о чате.""" - id: str | None + chat_id: str | None user_id: int | None title: str | None unread_count: int | None last_message_text: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class ChatsResult: +class ChatsResult(SerializableModel): """Список чатов.""" items: list[ChatInfo] total: int | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -62,90 +63,105 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class MessageInfo: +class MessageInfo(SerializableModel): """Информация о сообщении чата.""" - id: str | None + message_id: str | None chat_id: str | None author_id: int | None text: str | None - created_at: str | None + created_at: datetime | None direction: str | None type: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class MessagesResult: +class MessagesResult(SerializableModel): """Список сообщений чата.""" items: list[MessageInfo] total: int | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class MessageActionResult: +class MessageActionResult(SerializableModel): """Результат операции с сообщением или чатом.""" success: bool message_id: str | None = None status: str | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class VoiceFile: +class VoiceFile(SerializableModel): """Голосовое сообщение.""" id: str | None url: str | None duration: int | None transcript: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class VoiceFilesResult: +class VoiceFilesResult(SerializableModel): """Список голосовых сообщений.""" items: list[VoiceFile] - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class UploadImageResult: +class UploadImageResult(SerializableModel): """Результат загрузки изображения.""" image_id: str | None url: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class UploadImagesResult: +class UploadImagesResult(SerializableModel): """Список загруженных изображений.""" items: list[UploadImageResult] - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class SubscriptionInfo: +class UploadImageFile: + """Файл изображения для загрузки в мессенджер.""" + + field_name: str + filename: str + content: bytes | BinaryIO + content_type: str + + +@dataclass(slots=True, frozen=True) +class UploadImagesRequest: + """Запрос загрузки изображений для сообщений.""" + + files: list[UploadImageFile] + + def to_files(self) -> dict[str, object]: + """Сериализует multipart-структуру для transport.""" + + return { + file.field_name: (file.filename, file.content, file.content_type) for file in self.files + } + + +@dataclass(slots=True, frozen=True) +class SubscriptionInfo(SerializableModel): """Подписка webhook мессенджера.""" url: str | None version: str | None status: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class SubscriptionsResult: +class SubscriptionsResult(SerializableModel): """Список webhook-подписок.""" items: list[SubscriptionInfo] - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -178,12 +194,11 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class WebhookActionResult: +class WebhookActionResult(SerializableModel): """Результат операции с webhook.""" success: bool status: str | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -211,21 +226,19 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class SpecialOfferAvailableItem: +class SpecialOfferAvailableItem(SerializableModel): """Доступное объявление для рассылки спецпредложений.""" item_id: int | None title: str | None is_available: bool | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class SpecialOfferAvailableResult: +class SpecialOfferAvailableResult(SerializableModel): """Результат получения доступных объявлений.""" items: list[SpecialOfferAvailableItem] - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -251,12 +264,11 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class MultiCreateSpecialOfferResult: +class MultiCreateSpecialOfferResult(SerializableModel): """Результат создания рассылки.""" campaign_id: str | None status: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -284,24 +296,22 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class SpecialOfferStatsResult: +class SpecialOfferStatsResult(SerializableModel): """Статистика рассылки.""" campaign_id: str | None sent_count: int | None delivered_count: int | None read_count: int | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class TariffInfo: +class TariffInfo(SerializableModel): """Информация о тарифе рассылок.""" price: float | None currency: str | None daily_limit: int | None - raw_payload: Mapping[str, object] = field(default_factory=dict) __all__ = ( @@ -326,6 +336,8 @@ class TariffInfo: "TariffInfo", "UnsubscribeWebhookRequest", "UpdateWebhookRequest", + "UploadImageFile", + "UploadImagesRequest", "UploadImageResult", "UploadImagesResult", "VoiceFile", diff --git a/avito/orders/__init__.py b/avito/orders/__init__.py index ce6e532..e1a304e 100644 --- a/avito/orders/__init__.py +++ b/avito/orders/__init__.py @@ -3,41 +3,163 @@ from avito.orders.domain import ( DeliveryOrder, DeliveryTask, - DomainObject, Order, OrderLabel, SandboxDelivery, Stock, ) from avito.orders.models import ( + AddSortingCentersRequest, + AddTariffV2Request, + AddTerminalsRequest, + CancelParcelRequest, + CancelSandboxParcelRequest, + ChangeParcelApplication, + ChangeParcelOptions, + ChangeParcelRequest, CourierRangesResult, + CustomAreaScheduleEntry, + CustomAreaScheduleRequest, + DeliveryAddress, + DeliveryAnnouncementRequest, + DeliveryDateInterval, + DeliveryDirection, + DeliveryDirectionZone, DeliveryEntityResult, + DeliveryParcelIdsRequest, + DeliveryParcelRequest, + DeliveryParcelResultRequest, + DeliveryRestriction, DeliverySortingCentersResult, + DeliveryTariffItem, + DeliveryTariffValue, + DeliveryTariffZone, DeliveryTaskInfo, + DeliveryTerms, + DeliveryTermsZone, + DeliveryTrackingOptions, + DeliveryTrackingRequest, + GetChangeParcelInfoRequest, + GetRegisteredParcelIdRequest, + GetSandboxParcelInfoRequest, LabelPdfResult, LabelTaskResult, + OrderAcceptReturnRequest, OrderActionResult, + OrderApplyTransitionRequest, + OrderCncDetailsRequest, + OrderConfirmationCodeRequest, + OrderCourierRangeRequest, + OrderDeliveryProperties, + OrderLabelsRequest, + OrderMarkingsRequest, OrdersResult, + OrderTrackingNumberRequest, + ProhibitOrderAcceptanceRequest, + RealAddress, + SandboxAnnouncementDelivery, + SandboxAnnouncementPackage, + SandboxAnnouncementParticipant, + SandboxArea, + SandboxAreasRequest, + SandboxCancelAnnouncementOptions, + SandboxCancelAnnouncementRequest, + SandboxConfirmationCodeRequest, + SandboxCreateAnnouncementOptions, + SandboxCreateAnnouncementRequest, + SandboxDeliveryPoint, + SandboxGetAnnouncementEventRequest, + SetOrderPropertiesRequest, + SetOrderRealAddressRequest, + StockInfoRequest, StockInfoResult, + StockUpdateEntry, + StockUpdateRequest, StockUpdateResult, + TaggedSortingCenter, + TaggedSortingCentersRequest, + TerminalUpload, + UpdateTermsRequest, + WeeklySchedule, ) __all__ = ( "CourierRangesResult", + "CustomAreaScheduleEntry", + "CustomAreaScheduleRequest", + "DeliveryAddress", + "DeliveryAnnouncementRequest", + "DeliveryDateInterval", "DeliveryEntityResult", "DeliveryOrder", + "DeliveryParcelIdsRequest", + "DeliveryParcelRequest", + "DeliveryParcelResultRequest", "DeliverySortingCentersResult", "DeliveryTask", "DeliveryTaskInfo", - "DomainObject", + "DeliveryDirection", + "DeliveryDirectionZone", "LabelPdfResult", "LabelTaskResult", "Order", + "OrderDeliveryProperties", + "OrderAcceptReturnRequest", "OrderActionResult", + "OrderApplyTransitionRequest", + "OrderCncDetailsRequest", + "OrderConfirmationCodeRequest", + "OrderCourierRangeRequest", + "OrderLabelsRequest", "OrderLabel", + "OrderMarkingsRequest", + "OrderTrackingNumberRequest", "OrdersResult", + "ProhibitOrderAcceptanceRequest", + "RealAddress", + "SandboxArea", + "SandboxAreasRequest", "SandboxDelivery", + "SandboxConfirmationCodeRequest", "Stock", + "StockInfoRequest", "StockInfoResult", + "StockUpdateEntry", + "StockUpdateRequest", "StockUpdateResult", + "SetOrderPropertiesRequest", + "SetOrderRealAddressRequest", + "WeeklySchedule", + "DeliveryRestriction", + "AddSortingCentersRequest", + "TaggedSortingCenter", + "TaggedSortingCentersRequest", + "TerminalUpload", + "AddTerminalsRequest", + "DeliveryTermsZone", + "UpdateTermsRequest", + "DeliveryTrackingOptions", + "DeliveryTrackingRequest", + "DeliveryTerms", + "CancelParcelRequest", + "AddTariffV2Request", + "DeliveryTariffValue", + "DeliveryTariffItem", + "DeliveryTariffZone", + "SandboxCancelAnnouncementOptions", + "SandboxCancelAnnouncementRequest", + "CancelSandboxParcelRequest", + "ChangeParcelApplication", + "ChangeParcelOptions", + "ChangeParcelRequest", + "SandboxCreateAnnouncementOptions", + "SandboxDeliveryPoint", + "SandboxAnnouncementDelivery", + "SandboxAnnouncementParticipant", + "SandboxAnnouncementPackage", + "SandboxCreateAnnouncementRequest", + "SandboxGetAnnouncementEventRequest", + "GetChangeParcelInfoRequest", + "GetSandboxParcelInfoRequest", + "GetRegisteredParcelIdRequest", ) diff --git a/avito/orders/client.py b/avito/orders/client.py index 63bf6e5..06d3301 100644 --- a/avito/orders/client.py +++ b/avito/orders/client.py @@ -17,17 +17,51 @@ map_stock_update, ) from avito.orders.models import ( + AddSortingCentersRequest, + AddTariffV2Request, + AddTerminalsRequest, + CancelParcelRequest, + CancelSandboxParcelRequest, + ChangeParcelRequest, CourierRangesResult, + CustomAreaScheduleRequest, + DeliveryAnnouncementRequest, DeliveryEntityResult, + DeliveryParcelIdsRequest, + DeliveryParcelRequest, + DeliveryParcelResultRequest, DeliverySortingCentersResult, DeliveryTaskInfo, - JsonRequest, + DeliveryTrackingRequest, + GetChangeParcelInfoRequest, + GetRegisteredParcelIdRequest, + GetSandboxParcelInfoRequest, LabelPdfResult, LabelTaskResult, + OrderAcceptReturnRequest, OrderActionResult, + OrderApplyTransitionRequest, + OrderCncDetailsRequest, + OrderConfirmationCodeRequest, + OrderCourierRangeRequest, + OrderLabelsRequest, + OrderMarkingsRequest, OrdersResult, + OrderTrackingNumberRequest, + ProhibitOrderAcceptanceRequest, + SandboxAreasRequest, + SandboxCancelAnnouncementRequest, + SandboxConfirmationCodeRequest, + SandboxCreateAnnouncementRequest, + SandboxGetAnnouncementEventRequest, + SetOrderPropertiesRequest, + SetOrderRealAddressRequest, + StockInfoRequest, StockInfoResult, + StockUpdateRequest, StockUpdateResult, + TaggedSortingCentersRequest, + UpdateTermsRequest, ) @@ -45,31 +79,31 @@ def list_orders(self) -> OrdersResult: ) return map_orders(payload) - def update_markings(self, request: JsonRequest) -> OrderActionResult: + def update_markings(self, request: OrderMarkingsRequest) -> OrderActionResult: return self._post_action("/order-management/1/markings", "orders.update_markings", request) - def accept_return_order(self, request: JsonRequest) -> OrderActionResult: + def accept_return_order(self, request: OrderAcceptReturnRequest) -> OrderActionResult: return self._post_action( "/order-management/1/order/acceptReturnOrder", "orders.accept_return_order", request, ) - def apply_transition(self, request: JsonRequest) -> OrderActionResult: + def apply_transition(self, request: OrderApplyTransitionRequest) -> OrderActionResult: return self._post_action( "/order-management/1/order/applyTransition", "orders.apply_transition", request, ) - def check_confirmation_code(self, request: JsonRequest) -> OrderActionResult: + def check_confirmation_code(self, request: OrderConfirmationCodeRequest) -> OrderActionResult: return self._post_action( "/order-management/1/order/checkConfirmationCode", "orders.check_confirmation_code", request, ) - def set_cnc_details(self, request: JsonRequest) -> OrderActionResult: + def set_cnc_details(self, request: OrderCncDetailsRequest) -> OrderActionResult: return self._post_action( "/order-management/1/order/cncSetDetails", "orders.set_cnc_details", @@ -84,21 +118,32 @@ def get_courier_delivery_range(self) -> CourierRangesResult: ) return map_courier_ranges(payload) - def set_courier_delivery_range(self, request: JsonRequest) -> OrderActionResult: + def set_courier_delivery_range(self, request: OrderCourierRangeRequest) -> OrderActionResult: return self._post_action( "/order-management/1/order/setCourierDeliveryRange", "orders.set_courier_delivery_range", request, ) - def set_tracking_number(self, request: JsonRequest) -> OrderActionResult: + def set_tracking_number(self, request: OrderTrackingNumberRequest) -> OrderActionResult: return self._post_action( "/order-management/1/order/setTrackingNumber", "orders.set_tracking_number", request, ) - def _post_action(self, path: str, operation: str, request: JsonRequest) -> OrderActionResult: + def _post_action( + self, + path: str, + operation: str, + request: OrderMarkingsRequest + | OrderAcceptReturnRequest + | OrderApplyTransitionRequest + | OrderConfirmationCodeRequest + | OrderCncDetailsRequest + | OrderCourierRangeRequest + | OrderTrackingNumberRequest, + ) -> OrderActionResult: payload = self.transport.request_json( "POST", path, @@ -114,10 +159,10 @@ class LabelsClient: transport: Transport - def create_generate_labels(self, request: JsonRequest) -> LabelTaskResult: + def create_generate_labels(self, request: OrderLabelsRequest) -> LabelTaskResult: return self._create("/order-management/1/orders/labels", "orders.labels.create", request) - def create_generate_labels_extended(self, request: JsonRequest) -> LabelTaskResult: + def create_generate_labels_extended(self, request: OrderLabelsRequest) -> LabelTaskResult: return self._create( "/order-management/1/orders/labels/extended", "orders.labels.create_extended", @@ -131,7 +176,7 @@ def get_download_label(self, *, task_id: str) -> LabelPdfResult: ) return LabelPdfResult(binary=binary) - def _create(self, path: str, operation: str, request: JsonRequest) -> LabelTaskResult: + def _create(self, path: str, operation: str, request: OrderLabelsRequest) -> LabelTaskResult: payload = self.transport.request_json( "POST", path, @@ -147,28 +192,36 @@ class DeliveryClient: transport: Transport - def create_announcement(self, request: JsonRequest) -> DeliveryEntityResult: + def create_announcement(self, request: DeliveryAnnouncementRequest) -> DeliveryEntityResult: return self._post("/createAnnouncement", "orders.delivery.create_announcement", request) - def cancel_announcement(self, request: JsonRequest) -> DeliveryEntityResult: + def cancel_announcement(self, request: DeliveryAnnouncementRequest) -> DeliveryEntityResult: return self._post("/cancelAnnouncement", "orders.delivery.cancel_announcement", request) - def create_parcel(self, request: JsonRequest) -> DeliveryEntityResult: + def create_parcel(self, request: DeliveryParcelRequest) -> DeliveryEntityResult: return self._post("/createParcel", "orders.delivery.create_parcel", request) - def change_parcel_result(self, request: JsonRequest) -> DeliveryEntityResult: + def change_parcel_result(self, request: DeliveryParcelResultRequest) -> DeliveryEntityResult: return self._post( "/delivery/order/changeParcelResult", "orders.delivery.change_parcel_result", request, ) - def update_change_parcels(self, request: JsonRequest) -> DeliveryEntityResult: + def update_change_parcels(self, request: DeliveryParcelIdsRequest) -> DeliveryEntityResult: return self._post( "/sandbox/changeParcels", "orders.delivery.update_change_parcels", request ) - def _post(self, path: str, operation: str, request: JsonRequest) -> DeliveryEntityResult: + def _post( + self, + path: str, + operation: str, + request: DeliveryAnnouncementRequest + | DeliveryParcelRequest + | DeliveryParcelResultRequest + | DeliveryParcelIdsRequest, + ) -> DeliveryEntityResult: payload = self.transport.request_json( "POST", path, @@ -184,51 +237,57 @@ class SandboxDeliveryClient: transport: Transport - def create_announcement(self, request: JsonRequest) -> DeliveryEntityResult: + def create_announcement(self, request: DeliveryAnnouncementRequest) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/announcements/create", "orders.sandbox.create_announcement", request ) - def track_announcement(self, request: JsonRequest) -> DeliveryEntityResult: + def track_announcement(self, request: DeliveryAnnouncementRequest) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/announcements/track", "orders.sandbox.track_announcement", request ) - def update_custom_area_schedule(self, request: JsonRequest) -> DeliveryEntityResult: + def update_custom_area_schedule( + self, request: CustomAreaScheduleRequest + ) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/areas/custom-schedule", "orders.sandbox.update_custom_area_schedule", request, ) - def cancel_parcel(self, request: JsonRequest) -> DeliveryEntityResult: + def cancel_parcel(self, request: CancelParcelRequest) -> DeliveryEntityResult: return self._post("/delivery-sandbox/cancelParcel", "orders.sandbox.cancel_parcel", request) - def check_confirmation_code(self, request: JsonRequest) -> DeliveryEntityResult: + def check_confirmation_code( + self, request: SandboxConfirmationCodeRequest + ) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/order/checkConfirmationCode", "orders.sandbox.check_confirmation_code", request, ) - def set_order_properties(self, request: JsonRequest) -> DeliveryEntityResult: + def set_order_properties(self, request: SetOrderPropertiesRequest) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/order/properties", "orders.sandbox.set_order_properties", request, ) - def set_order_real_address(self, request: JsonRequest) -> DeliveryEntityResult: + def set_order_real_address(self, request: SetOrderRealAddressRequest) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/order/realAddress", "orders.sandbox.set_order_real_address", request, ) - def tracking(self, request: JsonRequest) -> DeliveryEntityResult: + def tracking(self, request: DeliveryTrackingRequest) -> DeliveryEntityResult: return self._post("/delivery-sandbox/order/tracking", "orders.sandbox.tracking", request) - def prohibit_order_acceptance(self, request: JsonRequest) -> DeliveryEntityResult: + def prohibit_order_acceptance( + self, request: ProhibitOrderAcceptanceRequest + ) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/prohibitOrderAcceptance", "orders.sandbox.prohibit_order_acceptance", @@ -243,14 +302,14 @@ def list_sorting_center(self) -> DeliverySortingCentersResult: ) return map_sorting_centers(payload) - def add_sorting_center(self, request: JsonRequest) -> DeliveryEntityResult: + def add_sorting_center(self, request: AddSortingCentersRequest) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/tariffs/sorting-center", "orders.sandbox.add_sorting_center", request, ) - def add_areas(self, *, tariff_id: str, request: JsonRequest) -> DeliveryEntityResult: + def add_areas(self, *, tariff_id: str, request: SandboxAreasRequest) -> DeliveryEntityResult: return self._post( f"/delivery-sandbox/tariffs/{tariff_id}/areas", "orders.sandbox.add_areas", @@ -258,7 +317,7 @@ def add_areas(self, *, tariff_id: str, request: JsonRequest) -> DeliveryEntityRe ) def add_tags_to_sorting_center( - self, *, tariff_id: str, request: JsonRequest + self, *, tariff_id: str, request: TaggedSortingCentersRequest ) -> DeliveryEntityResult: return self._post( f"/delivery-sandbox/tariffs/{tariff_id}/tagged-sorting-centers", @@ -266,81 +325,122 @@ def add_tags_to_sorting_center( request, ) - def add_terminals(self, *, tariff_id: str, request: JsonRequest) -> DeliveryEntityResult: + def add_terminals( + self, *, tariff_id: str, request: AddTerminalsRequest + ) -> DeliveryEntityResult: return self._post( f"/delivery-sandbox/tariffs/{tariff_id}/terminals", "orders.sandbox.add_terminals", request, ) - def update_terms(self, *, tariff_id: str, request: JsonRequest) -> DeliveryEntityResult: + def update_terms(self, *, tariff_id: str, request: UpdateTermsRequest) -> DeliveryEntityResult: return self._post( f"/delivery-sandbox/tariffs/{tariff_id}/terms", "orders.sandbox.update_terms", request, ) - def add_tariff_v2(self, request: JsonRequest) -> DeliveryEntityResult: - return self._post("/delivery-sandbox/tariffsV2", "orders.sandbox.add_tariff_v2", request) + def add_tariff(self, request: AddTariffV2Request) -> DeliveryEntityResult: + return self._post("/delivery-sandbox/tariffsV2", "orders.sandbox.add_tariff", request) - def v1_cancel_announcement(self, request: JsonRequest) -> DeliveryEntityResult: + def cancel_sandbox_announcement( + self, request: SandboxCancelAnnouncementRequest + ) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/v1/cancelAnnouncement", - "orders.sandbox.v1_cancel_announcement", + "orders.sandbox.cancel_sandbox_announcement", request, ) - def v1_cancel_parcel(self, request: JsonRequest) -> DeliveryEntityResult: + def cancel_sandbox_parcel(self, request: CancelSandboxParcelRequest) -> DeliveryEntityResult: return self._post( - "/delivery-sandbox/v1/cancelParcel", "orders.sandbox.v1_cancel_parcel", request + "/delivery-sandbox/v1/cancelParcel", "orders.sandbox.cancel_sandbox_parcel", request ) - def v1_change_parcel(self, request: JsonRequest) -> DeliveryEntityResult: + def change_sandbox_parcel(self, request: ChangeParcelRequest) -> DeliveryEntityResult: return self._post( - "/delivery-sandbox/v1/changeParcel", "orders.sandbox.v1_change_parcel", request + "/delivery-sandbox/v1/changeParcel", "orders.sandbox.change_sandbox_parcel", request ) - def v1_create_announcement(self, request: JsonRequest) -> DeliveryEntityResult: + def create_sandbox_announcement( + self, request: SandboxCreateAnnouncementRequest + ) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/v1/createAnnouncement", - "orders.sandbox.v1_create_announcement", + "orders.sandbox.create_sandbox_announcement", request, ) - def v1_get_announcement_event(self, request: JsonRequest) -> DeliveryEntityResult: + def get_sandbox_announcement_event( + self, request: SandboxGetAnnouncementEventRequest + ) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/v1/getAnnouncementEvent", - "orders.sandbox.v1_get_announcement_event", + "orders.sandbox.get_sandbox_announcement_event", request, ) - def v1_get_change_parcel_info(self, request: JsonRequest) -> DeliveryEntityResult: + def get_sandbox_change_parcel_info( + self, request: GetChangeParcelInfoRequest + ) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/v1/getChangeParcelInfo", - "orders.sandbox.v1_get_change_parcel_info", + "orders.sandbox.get_sandbox_change_parcel_info", request, ) - def v1_get_parcel_info(self, request: JsonRequest) -> DeliveryEntityResult: + def get_sandbox_parcel_info( + self, request: GetSandboxParcelInfoRequest + ) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/v1/getParcelInfo", - "orders.sandbox.v1_get_parcel_info", + "orders.sandbox.get_sandbox_parcel_info", request, ) - def v1_get_registered_parcel_id(self, request: JsonRequest) -> DeliveryEntityResult: + def get_sandbox_registered_parcel_id( + self, request: GetRegisteredParcelIdRequest + ) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/v1/getRegisteredParcelID", - "orders.sandbox.v1_get_registered_parcel_id", + "orders.sandbox.get_sandbox_registered_parcel_id", request, ) - def create_parcel_v2(self, request: JsonRequest) -> DeliveryEntityResult: + def create_parcel(self, request: DeliveryParcelRequest) -> DeliveryEntityResult: return self._post( - "/delivery-sandbox/v2/createParcel", "orders.sandbox.create_parcel_v2", request - ) - - def _post(self, path: str, operation: str, request: JsonRequest) -> DeliveryEntityResult: + "/delivery-sandbox/v2/createParcel", "orders.sandbox.create_parcel", request + ) + + def _post( + self, + path: str, + operation: str, + request: CustomAreaScheduleRequest + | CancelParcelRequest + | SandboxConfirmationCodeRequest + | SetOrderPropertiesRequest + | SetOrderRealAddressRequest + | DeliveryTrackingRequest + | ProhibitOrderAcceptanceRequest + | AddSortingCentersRequest + | DeliveryAnnouncementRequest + | SandboxAreasRequest + | TaggedSortingCentersRequest + | AddTerminalsRequest + | UpdateTermsRequest + | AddTariffV2Request + | SandboxCancelAnnouncementRequest + | CancelSandboxParcelRequest + | ChangeParcelRequest + | SandboxCreateAnnouncementRequest + | SandboxGetAnnouncementEventRequest + | GetChangeParcelInfoRequest + | GetSandboxParcelInfoRequest + | GetRegisteredParcelIdRequest + | DeliveryParcelRequest, + ) -> DeliveryEntityResult: payload = self.transport.request_json( "POST", path, @@ -371,7 +471,7 @@ class StockManagementClient: transport: Transport - def get_info(self, request: JsonRequest) -> StockInfoResult: + def get_info(self, request: StockInfoRequest) -> StockInfoResult: payload = self.transport.request_json( "POST", "/stock-management/1/info", @@ -380,7 +480,7 @@ def get_info(self, request: JsonRequest) -> StockInfoResult: ) return map_stock_info(payload) - def update_stocks(self, request: JsonRequest) -> StockUpdateResult: + def update_stocks(self, request: StockUpdateRequest) -> StockUpdateResult: payload = self.transport.request_json( "PUT", "/stock-management/1/stocks", diff --git a/avito/orders/domain.py b/avito/orders/domain.py index eeac526..5f0e50c 100644 --- a/avito/orders/domain.py +++ b/avito/orders/domain.py @@ -2,10 +2,11 @@ from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Sequence from dataclasses import dataclass -from avito.core import Transport +from avito.core import ValidationError +from avito.core.domain import DomainObject from avito.orders.client import ( DeliveryClient, DeliveryTasksClient, @@ -15,72 +16,131 @@ StockManagementClient, ) from avito.orders.models import ( + AddSortingCentersRequest, + AddTariffV2Request, + AddTerminalsRequest, + CancelParcelRequest, + CancelSandboxParcelOptions, + CancelSandboxParcelRequest, + ChangeParcelApplication, + ChangeParcelOptions, + ChangeParcelRequest, CourierRangesResult, + CustomAreaScheduleEntry, + CustomAreaScheduleRequest, + DeliveryAnnouncementRequest, + DeliveryDirection, DeliveryEntityResult, + DeliveryParcelIdsRequest, + DeliveryParcelRequest, + DeliveryParcelResultRequest, DeliverySortingCentersResult, + DeliveryTariffZone, DeliveryTaskInfo, - JsonRequest, + DeliveryTermsZone, + DeliveryTrackingOptions, + DeliveryTrackingRequest, + GetChangeParcelInfoRequest, + GetRegisteredParcelIdRequest, + GetSandboxParcelInfoRequest, LabelPdfResult, LabelTaskResult, + OrderAcceptReturnRequest, OrderActionResult, + OrderApplyTransitionRequest, + OrderCncDetailsRequest, + OrderConfirmationCodeRequest, + OrderCourierRangeRequest, + OrderDeliveryProperties, + OrderLabelsRequest, + OrderMarkingsRequest, OrdersResult, + OrderTrackingNumberRequest, + ProhibitOrderAcceptanceRequest, + RealAddress, + SandboxAnnouncementPackage, + SandboxAnnouncementParticipant, + SandboxArea, + SandboxAreasRequest, + SandboxCancelAnnouncementOptions, + SandboxCancelAnnouncementRequest, + SandboxConfirmationCodeRequest, + SandboxCreateAnnouncementOptions, + SandboxCreateAnnouncementRequest, + SandboxGetAnnouncementEventRequest, + SetOrderPropertiesRequest, + SetOrderRealAddressRequest, + SortingCenterUpload, + StockInfoRequest, StockInfoResult, + StockUpdateEntry, + StockUpdateRequest, StockUpdateResult, + TaggedSortingCenter, + TaggedSortingCentersRequest, + TerminalUpload, + UpdateTermsRequest, ) -@dataclass(slots=True, frozen=True) -class DomainObject: - """Базовый доменный объект раздела orders.""" - - transport: Transport - - @dataclass(slots=True, frozen=True) class Order(DomainObject): """Доменный объект заказа.""" - resource_id: int | str | None = None user_id: int | str | None = None def list(self) -> OrdersResult: return OrdersClient(self.transport).list_orders() - def update_markings(self, *, payload: Mapping[str, object]) -> OrderActionResult: - return OrdersClient(self.transport).update_markings(JsonRequest(payload)) + def update_markings(self, *, order_id: str, codes: Sequence[str]) -> OrderActionResult: + return OrdersClient(self.transport).update_markings( + OrderMarkingsRequest(order_id=order_id, codes=list(codes)) + ) - def accept_return_order(self, *, payload: Mapping[str, object]) -> OrderActionResult: - return OrdersClient(self.transport).accept_return_order(JsonRequest(payload)) + def accept_return_order(self, *, order_id: str, postal_office_id: str) -> OrderActionResult: + return OrdersClient(self.transport).accept_return_order( + OrderAcceptReturnRequest(order_id=order_id, postal_office_id=postal_office_id) + ) - def apply(self, *, payload: Mapping[str, object]) -> OrderActionResult: - return OrdersClient(self.transport).apply_transition(JsonRequest(payload)) + def apply(self, *, order_id: str, transition: str) -> OrderActionResult: + return OrdersClient(self.transport).apply_transition( + OrderApplyTransitionRequest(order_id=order_id, transition=transition) + ) - def check_confirmation_code(self, *, payload: Mapping[str, object]) -> OrderActionResult: - return OrdersClient(self.transport).check_confirmation_code(JsonRequest(payload)) + def check_confirmation_code(self, *, order_id: str, code: str) -> OrderActionResult: + return OrdersClient(self.transport).check_confirmation_code( + OrderConfirmationCodeRequest(order_id=order_id, code=code) + ) - def set_cnc_details(self, *, payload: Mapping[str, object]) -> OrderActionResult: - return OrdersClient(self.transport).set_cnc_details(JsonRequest(payload)) + def set_cnc_details(self, *, order_id: str, pickup_point_id: str) -> OrderActionResult: + return OrdersClient(self.transport).set_cnc_details( + OrderCncDetailsRequest(order_id=order_id, pickup_point_id=pickup_point_id) + ) def get_courier_delivery_range(self) -> CourierRangesResult: return OrdersClient(self.transport).get_courier_delivery_range() - def set_courier_delivery_range(self, *, payload: Mapping[str, object]) -> OrderActionResult: - return OrdersClient(self.transport).set_courier_delivery_range(JsonRequest(payload)) + def set_courier_delivery_range(self, *, order_id: str, interval_id: str) -> OrderActionResult: + return OrdersClient(self.transport).set_courier_delivery_range( + OrderCourierRangeRequest(order_id=order_id, interval_id=interval_id) + ) - def update_tracking_number(self, *, payload: Mapping[str, object]) -> OrderActionResult: - return OrdersClient(self.transport).set_tracking_number(JsonRequest(payload)) + def update_tracking_number(self, *, order_id: str, tracking_number: str) -> OrderActionResult: + return OrdersClient(self.transport).set_tracking_number( + OrderTrackingNumberRequest(order_id=order_id, tracking_number=tracking_number) + ) @dataclass(slots=True, frozen=True) class OrderLabel(DomainObject): """Доменный объект генерации и загрузки этикеток.""" - resource_id: int | str | None = None + task_id: int | str | None = None user_id: int | str | None = None - def create(self, *, payload: Mapping[str, object], extended: bool = False) -> LabelTaskResult: + def create(self, *, order_ids: Sequence[str], extended: bool = False) -> LabelTaskResult: client = LabelsClient(self.transport) - request = JsonRequest(payload) + request = OrderLabelsRequest(order_ids=list(order_ids)) if extended: return client.create_generate_labels_extended(request) return client.create_generate_labels(request) @@ -90,139 +150,265 @@ def download(self, *, task_id: str | None = None) -> LabelPdfResult: return LabelsClient(self.transport).get_download_label(task_id=resolved_task_id) def _require_task_id(self) -> str: - if self.resource_id is None: - raise ValueError("Для операции требуется `task_id`.") - return str(self.resource_id) + if self.task_id is None: + raise ValidationError("Для операции требуется `task_id`.") + return str(self.task_id) @dataclass(slots=True, frozen=True) class DeliveryOrder(DomainObject): """Доменный объект production API доставки.""" - resource_id: int | str | None = None user_id: int | str | None = None - def create_announcement(self, *, payload: Mapping[str, object]) -> DeliveryEntityResult: - return DeliveryClient(self.transport).create_announcement(JsonRequest(payload)) + def create_announcement(self, *, order_id: str) -> DeliveryEntityResult: + return DeliveryClient(self.transport).create_announcement( + DeliveryAnnouncementRequest(order_id=order_id) + ) - def delete(self, *, payload: Mapping[str, object]) -> DeliveryEntityResult: - return DeliveryClient(self.transport).cancel_announcement(JsonRequest(payload)) + def delete(self, *, order_id: str) -> DeliveryEntityResult: + return DeliveryClient(self.transport).cancel_announcement( + DeliveryAnnouncementRequest(order_id=order_id) + ) - def create(self, *, payload: Mapping[str, object]) -> DeliveryEntityResult: - return DeliveryClient(self.transport).create_parcel(JsonRequest(payload)) + def create(self, *, order_id: str, parcel_id: str) -> DeliveryEntityResult: + return DeliveryClient(self.transport).create_parcel( + DeliveryParcelRequest(order_id=order_id, parcel_id=parcel_id) + ) - def update_change_parcels(self, *, payload: Mapping[str, object]) -> DeliveryEntityResult: - return DeliveryClient(self.transport).update_change_parcels(JsonRequest(payload)) + def update_change_parcels(self, *, parcel_ids: Sequence[str]) -> DeliveryEntityResult: + return DeliveryClient(self.transport).update_change_parcels( + DeliveryParcelIdsRequest(parcel_ids=list(parcel_ids)) + ) - def create_change_parcel_result(self, *, payload: Mapping[str, object]) -> DeliveryEntityResult: - return DeliveryClient(self.transport).change_parcel_result(JsonRequest(payload)) + def create_change_parcel_result(self, *, parcel_id: str, result: str) -> DeliveryEntityResult: + return DeliveryClient(self.transport).change_parcel_result( + DeliveryParcelResultRequest(parcel_id=parcel_id, result=result) + ) @dataclass(slots=True, frozen=True) class SandboxDelivery(DomainObject): """Доменный объект sandbox API доставки.""" - resource_id: int | str | None = None user_id: int | str | None = None - def create_announcement(self, *, payload: Mapping[str, object]) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).create_announcement(JsonRequest(payload)) + def create_announcement(self, *, order_id: str) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).create_announcement( + DeliveryAnnouncementRequest(order_id=order_id) + ) - def track_announcement(self, *, payload: Mapping[str, object]) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).track_announcement(JsonRequest(payload)) + def track_announcement(self, *, order_id: str) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).track_announcement( + DeliveryAnnouncementRequest(order_id=order_id) + ) - def update_custom_area_schedule(self, *, payload: Mapping[str, object]) -> DeliveryEntityResult: + def update_custom_area_schedule( + self, *, items: Sequence[CustomAreaScheduleEntry] + ) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).update_custom_area_schedule( - JsonRequest(payload) + CustomAreaScheduleRequest(items=list(items)) ) - def cancel_parcel(self, *, payload: Mapping[str, object]) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).cancel_parcel(JsonRequest(payload)) + def cancel_parcel(self, *, parcel_id: str, actor: str) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).cancel_parcel( + CancelParcelRequest(parcel_id=parcel_id, actor=actor) + ) - def check_confirmation_code(self, *, payload: Mapping[str, object]) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).check_confirmation_code(JsonRequest(payload)) + def check_confirmation_code(self, *, parcel_id: str, confirm_code: str) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).check_confirmation_code( + SandboxConfirmationCodeRequest(parcel_id=parcel_id, confirm_code=confirm_code) + ) - def set_order_properties(self, *, payload: Mapping[str, object]) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).set_order_properties(JsonRequest(payload)) + def set_order_properties( + self, *, order_id: str, properties: OrderDeliveryProperties + ) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).set_order_properties( + SetOrderPropertiesRequest(order_id=order_id, properties=properties) + ) - def set_order_real_address(self, *, payload: Mapping[str, object]) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).set_order_real_address(JsonRequest(payload)) + def set_order_real_address(self, *, order_id: str, address: RealAddress) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).set_order_real_address( + SetOrderRealAddressRequest(order_id=order_id, address=address) + ) - def tracking(self, *, payload: Mapping[str, object]) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).tracking(JsonRequest(payload)) + def tracking( + self, + *, + order_id: str, + avito_status: str, + avito_event_type: str, + provider_event_code: str, + date: str, + location: str, + comment: str | None = None, + options: DeliveryTrackingOptions | None = None, + ) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).tracking( + DeliveryTrackingRequest( + order_id=order_id, + avito_status=avito_status, + avito_event_type=avito_event_type, + provider_event_code=provider_event_code, + date=date, + location=location, + comment=comment, + options=options, + ) + ) - def prohibit_order_acceptance(self, *, payload: Mapping[str, object]) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).prohibit_order_acceptance(JsonRequest(payload)) + def prohibit_order_acceptance(self, *, order_id: str) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).prohibit_order_acceptance( + ProhibitOrderAcceptanceRequest(order_id=order_id) + ) def list_sorting_center(self) -> DeliverySortingCentersResult: return SandboxDeliveryClient(self.transport).list_sorting_center() - def add_sorting_center(self, *, payload: Mapping[str, object]) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).add_sorting_center(JsonRequest(payload)) + def add_sorting_center(self, *, items: Sequence[SortingCenterUpload]) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).add_sorting_center( + AddSortingCentersRequest(items=list(items)) + ) - def add_areas(self, *, tariff_id: str, payload: Mapping[str, object]) -> DeliveryEntityResult: + def add_areas(self, *, tariff_id: str, areas: Sequence[SandboxArea]) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).add_areas( - tariff_id=tariff_id, request=JsonRequest(payload) + tariff_id=tariff_id, + request=SandboxAreasRequest(areas=list(areas)), ) def add_tags_to_sorting_center( - self, *, tariff_id: str, payload: Mapping[str, object] + self, *, tariff_id: str, items: Sequence[TaggedSortingCenter] ) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).add_tags_to_sorting_center( tariff_id=tariff_id, - request=JsonRequest(payload), + request=TaggedSortingCentersRequest(items=list(items)), ) def add_terminals( - self, *, tariff_id: str, payload: Mapping[str, object] + self, *, tariff_id: str, items: Sequence[TerminalUpload] ) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).add_terminals( - tariff_id=tariff_id, request=JsonRequest(payload) + tariff_id=tariff_id, + request=AddTerminalsRequest(items=list(items)), ) - def update_terms( - self, *, tariff_id: str, payload: Mapping[str, object] - ) -> DeliveryEntityResult: + def update_terms(self, *, tariff_id: str, items: Sequence[DeliveryTermsZone]) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).update_terms( - tariff_id=tariff_id, request=JsonRequest(payload) + tariff_id=tariff_id, + request=UpdateTermsRequest(items=list(items)), ) - def add_tariff_v2(self, *, payload: Mapping[str, object]) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).add_tariff_v2(JsonRequest(payload)) - - def create_parcel(self, *, payload: Mapping[str, object]) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).create_parcel_v2(JsonRequest(payload)) - - def legacy_cancel_announcement(self, *, payload: Mapping[str, object]) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).v1_cancel_announcement(JsonRequest(payload)) + def add_tariff( + self, + *, + name: str, + delivery_provider_tariff_id: str, + directions: Sequence[DeliveryDirection], + tariff_zones: Sequence[DeliveryTariffZone], + terms_zones: Sequence[DeliveryTermsZone], + tariff_type: str | None = None, + ) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).add_tariff( + AddTariffV2Request( + name=name, + delivery_provider_tariff_id=delivery_provider_tariff_id, + directions=list(directions), + tariff_zones=list(tariff_zones), + terms_zones=list(terms_zones), + tariff_type=tariff_type, + ) + ) - def legacy_cancel_parcel(self, *, payload: Mapping[str, object]) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).v1_cancel_parcel(JsonRequest(payload)) + def create_parcel(self, *, order_id: str, parcel_id: str) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).create_parcel( + DeliveryParcelRequest(order_id=order_id, parcel_id=parcel_id) + ) - def legacy_change_parcel(self, *, payload: Mapping[str, object]) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).v1_change_parcel(JsonRequest(payload)) + def cancel_sandbox_announcement( + self, + *, + announcement_id: str, + date: str, + options: SandboxCancelAnnouncementOptions, + ) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).cancel_sandbox_announcement( + SandboxCancelAnnouncementRequest( + announcement_id=announcement_id, + date=date, + options=options, + ) + ) - def legacy_create_announcement(self, *, payload: Mapping[str, object]) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).v1_create_announcement(JsonRequest(payload)) + def cancel_sandbox_parcel( + self, + *, + parcel_id: str, + options: CancelSandboxParcelOptions | None = None, + ) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).cancel_sandbox_parcel( + CancelSandboxParcelRequest(parcel_id=parcel_id, options=options) + ) - def legacy_get_announcement_event( - self, *, payload: Mapping[str, object] + def change_sandbox_parcel( + self, + *, + type: str, + parcel_id: str, + application: ChangeParcelApplication | None = None, + options: ChangeParcelOptions | None = None, ) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).v1_get_announcement_event(JsonRequest(payload)) + return SandboxDeliveryClient(self.transport).change_sandbox_parcel( + ChangeParcelRequest( + type=type, + parcel_id=parcel_id, + application=application, + options=options, + ) + ) - def legacy_get_change_parcel_info( - self, *, payload: Mapping[str, object] + def create_sandbox_announcement( + self, + *, + announcement_id: str, + barcode: str, + sender: SandboxAnnouncementParticipant, + receiver: SandboxAnnouncementParticipant, + announcement_type: str, + date: str, + packages: Sequence[SandboxAnnouncementPackage], + options: SandboxCreateAnnouncementOptions, ) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).v1_get_change_parcel_info(JsonRequest(payload)) + return SandboxDeliveryClient(self.transport).create_sandbox_announcement( + SandboxCreateAnnouncementRequest( + announcement_id=announcement_id, + barcode=barcode, + sender=sender, + receiver=receiver, + announcement_type=announcement_type, + date=date, + packages=list(packages), + options=options, + ) + ) - def legacy_get_parcel_info(self, *, payload: Mapping[str, object]) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).v1_get_parcel_info(JsonRequest(payload)) + def get_sandbox_announcement_event(self, *, announcement_id: str) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).get_sandbox_announcement_event( + SandboxGetAnnouncementEventRequest(announcement_id=announcement_id) + ) - def legacy_get_registered_parcel_id( - self, *, payload: Mapping[str, object] - ) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).v1_get_registered_parcel_id( - JsonRequest(payload) + def get_sandbox_change_parcel_info(self, *, application_id: str) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).get_sandbox_change_parcel_info( + GetChangeParcelInfoRequest(application_id=application_id) + ) + + def get_sandbox_parcel_info(self, *, parcel_id: str) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).get_sandbox_parcel_info( + GetSandboxParcelInfoRequest(parcel_id=parcel_id) + ) + + def get_sandbox_registered_parcel_id(self, *, order_id: str) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).get_sandbox_registered_parcel_id( + GetRegisteredParcelIdRequest(order_id=order_id) ) @@ -230,7 +416,7 @@ def legacy_get_registered_parcel_id( class DeliveryTask(DomainObject): """Доменный объект задачи доставки.""" - resource_id: int | str | None = None + task_id: int | str | None = None user_id: int | str | None = None def get(self, *, task_id: str | None = None) -> DeliveryTaskInfo: @@ -238,29 +424,31 @@ def get(self, *, task_id: str | None = None) -> DeliveryTaskInfo: return DeliveryTasksClient(self.transport).get_task(task_id=resolved_task_id) def _require_task_id(self) -> str: - if self.resource_id is None: - raise ValueError("Для операции требуется `task_id`.") - return str(self.resource_id) + if self.task_id is None: + raise ValidationError("Для операции требуется `task_id`.") + return str(self.task_id) @dataclass(slots=True, frozen=True) class Stock(DomainObject): """Доменный объект управления остатками.""" - resource_id: int | str | None = None user_id: int | str | None = None - def get(self, *, payload: Mapping[str, object]) -> StockInfoResult: - return StockManagementClient(self.transport).get_info(JsonRequest(payload)) + def get(self, *, item_ids: Sequence[int]) -> StockInfoResult: + return StockManagementClient(self.transport).get_info( + StockInfoRequest(item_ids=list(item_ids)) + ) - def update(self, *, payload: Mapping[str, object]) -> StockUpdateResult: - return StockManagementClient(self.transport).update_stocks(JsonRequest(payload)) + def update(self, *, stocks: Sequence[StockUpdateEntry]) -> StockUpdateResult: + return StockManagementClient(self.transport).update_stocks( + StockUpdateRequest(stocks=list(stocks)) + ) __all__ = ( "DeliveryOrder", "DeliveryTask", - "DomainObject", "Order", "OrderLabel", "SandboxDelivery", diff --git a/avito/orders/enums.py b/avito/orders/enums.py deleted file mode 100644 index 424e4e0..0000000 --- a/avito/orders/enums.py +++ /dev/null @@ -1 +0,0 @@ -"""Строковые enum и константы пакета orders.""" diff --git a/avito/orders/mappers.py b/avito/orders/mappers.py index ada296e..e2bfed7 100644 --- a/avito/orders/mappers.py +++ b/avito/orders/mappers.py @@ -86,12 +86,10 @@ def map_orders(payload: object) -> OrdersResult: created_at=_str(item, "created", "created_at", "createdAt"), buyer_name=_str(_mapping(item, "buyerInfo"), "fullName"), total_price=_int(item, "totalPrice", "price"), - raw_payload=item, ) for item in _list(data, "orders", "items", "result") ], total=_int(data, "total", "count"), - raw_payload=data, ) @@ -106,7 +104,6 @@ def map_order_action(payload: object) -> OrderActionResult: order_id=_str(source, "orderId", "order_id", "id"), status=_str(source, "status"), message=_str(source, "message"), - raw_payload=data, ) @@ -123,12 +120,10 @@ def map_courier_ranges(payload: object) -> CourierRangesResult: date=_str(item, "date"), start_at=_str(item, "startAt", "startDate"), end_at=_str(item, "endAt", "endDate"), - raw_payload=item, ) for item in _list(source, "timeIntervals", "intervals", "items", "result") ], address=_str(source, "address"), - raw_payload=data, ) @@ -143,7 +138,6 @@ def map_label_task(payload: object) -> LabelTaskResult: return LabelTaskResult( task_id=task_id or (str(task_int) if task_int is not None else None), status=_str(source, "status"), - raw_payload=data, ) @@ -162,7 +156,6 @@ def map_delivery_entity(payload: object) -> DeliveryEntityResult: parcel_id=_str(source, "parcelId", "parcelID"), status=_str(source, "status"), message=_str(_mapping(data, "error"), "message") or _str(source, "message"), - raw_payload=data, ) @@ -178,11 +171,9 @@ def map_sorting_centers(payload: object) -> DeliverySortingCentersResult: sorting_center_id=_str(item, "id", "sortingCenterId", "sorting_center_id"), name=_str(item, "name"), city=_str(item, "city"), - raw_payload=item, ) for item in _list(source, "sortingCenters", "items", "result") ], - raw_payload=data, ) @@ -198,7 +189,6 @@ def map_delivery_task(payload: object) -> DeliveryTaskInfo: task_id=task_id or (str(task_int) if task_int is not None else None), status=_str(source, "status"), error=_str(_mapping(data, "error"), "message") or _str(source, "error"), - raw_payload=data, ) @@ -214,11 +204,9 @@ def map_stock_info(payload: object) -> StockInfoResult: is_multiple=_bool(item, "is_multiple", "isMultiple"), is_unlimited=_bool(item, "is_unlimited", "isUnlimited"), is_out_of_stock=_bool(item, "is_out_of_stock", "isOutOfStock"), - raw_payload=item, ) for item in _list(data, "stocks", "items", "result") ], - raw_payload=data, ) @@ -233,11 +221,9 @@ def map_stock_update(payload: object) -> StockUpdateResult: external_id=_str(item, "external_id", "externalId"), success=bool(item.get("success", True)), errors=_extract_errors(item), - raw_payload=item, ) for item in _list(data, "stocks", "items", "result") ], - raw_payload=data, ) diff --git a/avito/orders/models.py b/avito/orders/models.py index 32c567e..c9cad74 100644 --- a/avito/orders/models.py +++ b/avito/orders/models.py @@ -2,26 +2,1021 @@ from __future__ import annotations -from collections.abc import Mapping -from dataclasses import dataclass, field +from base64 import b64encode +from dataclasses import dataclass from avito.core import BinaryResponse +from avito.core.serialization import SerializableModel @dataclass(slots=True, frozen=True) -class JsonRequest: - """Типизированная обертка над JSON payload запроса.""" +class DeliveryDateInterval: + """Интервалы доставки/забора для конкретной даты.""" - payload: Mapping[str, object] + date: str + intervals: list[str] def to_payload(self) -> dict[str, object]: - """Сериализует payload запроса.""" + return {"date": self.date, "intervals": list(self.intervals)} - return dict(self.payload) + +@dataclass(slots=True, frozen=True) +class CustomAreaScheduleEntry: + """Кастомное расписание для списка областей доставки.""" + + provider_area_numbers: list[str] + services: list[str] + custom_schedule: list[DeliveryDateInterval] + use_all_areas: bool | None = None + + def to_payload(self) -> dict[str, object]: + payload: dict[str, object] = { + "providerAreaNumber": list(self.provider_area_numbers), + "services": list(self.services), + "customSchedule": [entry.to_payload() for entry in self.custom_schedule], + } + if self.use_all_areas is not None: + payload["useAllAreas"] = self.use_all_areas + return payload + + +@dataclass(slots=True, frozen=True) +class CustomAreaScheduleRequest: + """Запрос установки кастомного расписания областей.""" + + items: list[CustomAreaScheduleEntry] + + def to_payload(self) -> list[dict[str, object]]: + return [item.to_payload() for item in self.items] + + +@dataclass(slots=True, frozen=True) +class OrderMarkingsRequest: + """Запрос обновления маркировок заказа.""" + + order_id: str + codes: list[str] + + def to_payload(self) -> dict[str, object]: + return {"orderId": self.order_id, "codes": list(self.codes)} + + +@dataclass(slots=True, frozen=True) +class OrderAcceptReturnRequest: + """Запрос подтверждения возврата заказа.""" + + order_id: str + postal_office_id: str + + def to_payload(self) -> dict[str, object]: + return {"orderId": self.order_id, "postalOfficeId": self.postal_office_id} + + +@dataclass(slots=True, frozen=True) +class OrderApplyTransitionRequest: + """Запрос перехода заказа в другой статус.""" + + order_id: str + transition: str + + def to_payload(self) -> dict[str, object]: + return {"orderId": self.order_id, "transition": self.transition} + + +@dataclass(slots=True, frozen=True) +class OrderConfirmationCodeRequest: + """Запрос проверки кода подтверждения заказа.""" + + order_id: str + code: str + + def to_payload(self) -> dict[str, object]: + return {"orderId": self.order_id, "code": self.code} + + +@dataclass(slots=True, frozen=True) +class OrderCncDetailsRequest: + """Запрос установки деталей cnc-заказа.""" + + order_id: str + pickup_point_id: str + + def to_payload(self) -> dict[str, object]: + return {"orderId": self.order_id, "pickupPointId": self.pickup_point_id} + + +@dataclass(slots=True, frozen=True) +class OrderCourierRangeRequest: + """Запрос установки интервала курьерской доставки.""" + + order_id: str + interval_id: str + + def to_payload(self) -> dict[str, object]: + return {"orderId": self.order_id, "intervalId": self.interval_id} + + +@dataclass(slots=True, frozen=True) +class OrderTrackingNumberRequest: + """Запрос установки трек-номера.""" + + order_id: str + tracking_number: str + + def to_payload(self) -> dict[str, object]: + return {"orderId": self.order_id, "trackingNumber": self.tracking_number} + + +@dataclass(slots=True, frozen=True) +class OrderLabelsRequest: + """Запрос генерации этикеток.""" + + order_ids: list[str] + + def to_payload(self) -> dict[str, object]: + return {"orderIds": list(self.order_ids)} + + +@dataclass(slots=True, frozen=True) +class DeliveryAnnouncementRequest: + """Запрос создания или отмены анонса доставки.""" + + order_id: str + + def to_payload(self) -> dict[str, object]: + return {"orderId": self.order_id} + + +@dataclass(slots=True, frozen=True) +class DeliveryParcelRequest: + """Запрос создания посылки.""" + + order_id: str + parcel_id: str + + def to_payload(self) -> dict[str, object]: + return {"orderId": self.order_id, "parcelId": self.parcel_id} + + +@dataclass(slots=True, frozen=True) +class DeliveryParcelResultRequest: + """Запрос передачи результата по посылке.""" + + parcel_id: str + result: str + + def to_payload(self) -> dict[str, object]: + return {"parcelId": self.parcel_id, "result": self.result} + + +@dataclass(slots=True, frozen=True) +class CancelParcelRequest: + """Запрос отмены sandbox-посылки.""" + + parcel_id: str + actor: str + + def to_payload(self) -> dict[str, object]: + return {"parcelID": self.parcel_id, "actor": self.actor} + + +@dataclass(slots=True, frozen=True) +class SandboxConfirmationCodeRequest: + """Запрос проверки кода подтверждения sandbox-заказа.""" + + parcel_id: str + confirm_code: str + + def to_payload(self) -> dict[str, object]: + return {"parcelID": self.parcel_id, "confirmCode": self.confirm_code} + + +@dataclass(slots=True, frozen=True) +class DeliveryTerms: + """Параметры условий доставки заказа.""" + + cost: int | None = None + direct_control_date: str | None = None + receiver_terminal_code: str | None = None + return_control_date: str | None = None + sender_receive_terminal_code: str | None = None + tough_wrap: bool | None = None + + def to_payload(self) -> dict[str, object]: + payload: dict[str, object] = {} + if self.cost is not None: + payload["cost"] = self.cost + if self.direct_control_date is not None: + payload["directControlDate"] = self.direct_control_date + if self.receiver_terminal_code is not None: + payload["receiverTerminalCode"] = self.receiver_terminal_code + if self.return_control_date is not None: + payload["returnControlDate"] = self.return_control_date + if self.sender_receive_terminal_code is not None: + payload["senderReceiveTerminalCode"] = self.sender_receive_terminal_code + if self.tough_wrap is not None: + payload["toughWrap"] = self.tough_wrap + return payload + + +@dataclass(slots=True, frozen=True) +class OrderDeliveryProperties: + """Набор параметров доставки sandbox-заказа.""" + + delivery: DeliveryTerms | None = None + dimensions: list[int] | None = None + weight: int | None = None + + def to_payload(self) -> dict[str, object]: + payload: dict[str, object] = {} + if self.delivery is not None: + payload["delivery"] = self.delivery.to_payload() + if self.dimensions is not None: + payload["dimensions"] = list(self.dimensions) + if self.weight is not None: + payload["weight"] = self.weight + return payload + + +@dataclass(slots=True, frozen=True) +class SetOrderPropertiesRequest: + """Запрос установки параметров доставки sandbox-заказа.""" + + order_id: str + properties: OrderDeliveryProperties + + def to_payload(self) -> dict[str, object]: + return {"orderId": self.order_id, "properties": self.properties.to_payload()} + + +@dataclass(slots=True, frozen=True) +class RealAddress: + """Фактический адрес приема или возврата.""" + + address_type: str + terminal_number: str + + def to_payload(self) -> dict[str, object]: + return { + "addressType": self.address_type, + "terminalNumber": self.terminal_number, + } + + +@dataclass(slots=True, frozen=True) +class SetOrderRealAddressRequest: + """Запрос передачи фактического адреса sandbox-заказа.""" + + order_id: str + address: RealAddress + + def to_payload(self) -> dict[str, object]: + return {"orderId": self.order_id, "address": self.address.to_payload()} + + +@dataclass(slots=True, frozen=True) +class DeliveryTrackingOptions: + """Дополнительные поля tracking-события.""" + + barcode: str | None = None + return_barcode: str | None = None + return_dispatch_number: str | None = None + return_tracking_number: str | None = None + + def to_payload(self) -> dict[str, object]: + payload: dict[str, object] = {} + if self.barcode is not None: + payload["barcode"] = self.barcode + if self.return_barcode is not None: + payload["returnBarcode"] = self.return_barcode + if self.return_dispatch_number is not None: + payload["returnDispatchNumber"] = self.return_dispatch_number + if self.return_tracking_number is not None: + payload["returnTrackingNumber"] = self.return_tracking_number + return payload + + +@dataclass(slots=True, frozen=True) +class DeliveryTrackingRequest: + """Запрос передачи tracking-события sandbox-заказа.""" + + order_id: str + avito_status: str + avito_event_type: str + provider_event_code: str + date: str + location: str + comment: str | None = None + options: DeliveryTrackingOptions | None = None + + def to_payload(self) -> dict[str, object]: + payload: dict[str, object] = { + "orderId": self.order_id, + "avitoStatus": self.avito_status, + "avitoEventType": self.avito_event_type, + "providerEventCode": self.provider_event_code, + "date": self.date, + "location": self.location, + } + if self.comment is not None: + payload["comment"] = self.comment + if self.options is not None: + payload["options"] = self.options.to_payload() + return payload + + +@dataclass(slots=True, frozen=True) +class ProhibitOrderAcceptanceRequest: + """Запрос запрета приема sandbox-посылки.""" + + order_id: str + + def to_payload(self) -> dict[str, object]: + return {"orderId": self.order_id} + + +@dataclass(slots=True, frozen=True) +class DeliveryParcelIdsRequest: + """Запрос пакетной операции по посылкам.""" + + parcel_ids: list[str] + + def to_payload(self) -> dict[str, object]: + return {"parcelIds": list(self.parcel_ids)} + + +@dataclass(slots=True, frozen=True) +class SandboxArea: + """Зона sandbox-доставки.""" + + city: str + + def to_payload(self) -> dict[str, object]: + return {"city": self.city} + + +@dataclass(slots=True, frozen=True) +class DeliveryAddress: + """Адрес сортировочного центра или терминала.""" + + country: str + region: str + locality: str + fias: str + zip_code: str + lat: float + lng: float + address_row: str | None = None + building: str | None = None + floor: int | None = None + house: str | None = None + housing: str | None = None + locality_type: str | None = None + porch: str | None = None + room: str | None = None + street: str | None = None + sub_region: str | None = None + sub_region_type: str | None = None + + def to_payload(self) -> dict[str, object]: + payload: dict[str, object] = { + "country": self.country, + "region": self.region, + "locality": self.locality, + "fias": self.fias, + "zipCode": self.zip_code, + "lat": self.lat, + "lng": self.lng, + } + if self.address_row is not None: + payload["addressRow"] = self.address_row + if self.building is not None: + payload["building"] = self.building + if self.floor is not None: + payload["floor"] = self.floor + if self.house is not None: + payload["house"] = self.house + if self.housing is not None: + payload["housing"] = self.housing + if self.locality_type is not None: + payload["localityType"] = self.locality_type + if self.porch is not None: + payload["porch"] = self.porch + if self.room is not None: + payload["room"] = self.room + if self.street is not None: + payload["street"] = self.street + if self.sub_region is not None: + payload["subRegion"] = self.sub_region + if self.sub_region_type is not None: + payload["subRegionType"] = self.sub_region_type + return payload + + +@dataclass(slots=True, frozen=True) +class DeliveryRestriction: + """Ограничения терминала или сортировочного центра.""" + + max_weight: int + max_dimensions: list[int] + max_declared_cost: int + dimensional_factor: int | None = None + max_dimensional_weight: int | None = None + + def to_payload(self) -> dict[str, object]: + payload: dict[str, object] = { + "maxWeight": self.max_weight, + "maxDimensions": list(self.max_dimensions), + "maxDeclaredCost": self.max_declared_cost, + } + if self.dimensional_factor is not None: + payload["dimensionalFactor"] = self.dimensional_factor + if self.max_dimensional_weight is not None: + payload["maxDimensionalWeight"] = self.max_dimensional_weight + return payload + + +@dataclass(slots=True, frozen=True) +class WeeklySchedule: + """Недельное расписание работы.""" + + mon: list[str] + tue: list[str] + wed: list[str] + thu: list[str] + fri: list[str] + sat: list[str] + sun: list[str] + + def to_payload(self) -> dict[str, object]: + return { + "mon": list(self.mon), + "tue": list(self.tue), + "wed": list(self.wed), + "thu": list(self.thu), + "fri": list(self.fri), + "sat": list(self.sat), + "sun": list(self.sun), + } + + +@dataclass(slots=True, frozen=True) +class SortingCenterUpload: + """Сортировочный центр для загрузки в sandbox delivery API.""" + + delivery_provider_id: str + name: str + address: DeliveryAddress + phones: list[str] + itinerary: str + photos: list[str] + schedule: WeeklySchedule + restriction: DeliveryRestriction + direction_tag: str | None = None + + def to_payload(self) -> dict[str, object]: + payload: dict[str, object] = { + "deliveryProviderId": self.delivery_provider_id, + "name": self.name, + "address": self.address.to_payload(), + "phones": list(self.phones), + "itinerary": self.itinerary, + "photos": list(self.photos), + "schedule": self.schedule.to_payload(), + "restriction": self.restriction.to_payload(), + } + if self.direction_tag is not None: + payload["directionTag"] = self.direction_tag + return payload + + +@dataclass(slots=True, frozen=True) +class AddSortingCentersRequest: + """Запрос загрузки сортировочных центров.""" + + items: list[SortingCenterUpload] + + def to_payload(self) -> list[dict[str, object]]: + return [item.to_payload() for item in self.items] + + +@dataclass(slots=True, frozen=True) +class TaggedSortingCenter: + """Тэг для сортировочного центра.""" + + delivery_provider_id: str + direction_tag: str + + def to_payload(self) -> dict[str, object]: + return { + "deliveryProviderId": self.delivery_provider_id, + "directionTag": self.direction_tag, + } + + +@dataclass(slots=True, frozen=True) +class TaggedSortingCentersRequest: + """Запрос установки тэгов сортировочным центрам.""" + + items: list[TaggedSortingCenter] + + def to_payload(self) -> list[dict[str, object]]: + return [item.to_payload() for item in self.items] @dataclass(slots=True, frozen=True) -class OrderSummary: +class TerminalUpload: + """Терминал для загрузки в sandbox delivery API.""" + + delivery_provider_id: str + name: str + address: DeliveryAddress + phones: list[str] + itinerary: str + photos: list[str] + direction_tag: str + services: list[str] + schedule: WeeklySchedule + restriction: DeliveryRestriction + display_name: str | None = None + options: list[str] | None = None + terminal_type: str | None = None + + def to_payload(self) -> dict[str, object]: + payload: dict[str, object] = { + "deliveryProviderId": self.delivery_provider_id, + "name": self.name, + "address": self.address.to_payload(), + "phones": list(self.phones), + "itinerary": self.itinerary, + "photos": list(self.photos), + "directionTag": self.direction_tag, + "services": list(self.services), + "schedule": self.schedule.to_payload(), + "restriction": self.restriction.to_payload(), + } + if self.display_name is not None: + payload["displayName"] = self.display_name + if self.options is not None: + payload["options"] = list(self.options) + if self.terminal_type is not None: + payload["type"] = self.terminal_type + return payload + + +@dataclass(slots=True, frozen=True) +class AddTerminalsRequest: + """Запрос загрузки терминалов.""" + + items: list[TerminalUpload] + + def to_payload(self) -> list[dict[str, object]]: + return [item.to_payload() for item in self.items] + + +@dataclass(slots=True, frozen=True) +class DeliveryTermsZone: + """Зона сроков доставки.""" + + delivery_provider_zone_id: str | None = None + min_term: int | None = None + max_term: int | None = None + name: str | None = None + + def to_payload(self) -> dict[str, object]: + payload: dict[str, object] = {} + if self.delivery_provider_zone_id is not None: + payload["deliveryProviderZoneId"] = self.delivery_provider_zone_id + if self.min_term is not None: + payload["minTerm"] = self.min_term + if self.max_term is not None: + payload["maxTerm"] = self.max_term + if self.name is not None: + payload["name"] = self.name + return payload + + +@dataclass(slots=True, frozen=True) +class UpdateTermsRequest: + """Запрос обновления сроков по тарифу.""" + + items: list[DeliveryTermsZone] + + def to_payload(self) -> list[dict[str, object]]: + return [item.to_payload() for item in self.items] + + +@dataclass(slots=True, frozen=True) +class DeliveryDirectionZone: + """Условия доставки внутри направления тарифа.""" + + tariff_zone_id: str | None = None + terms_zone_id: str | None = None + type: str | None = None + + def to_payload(self) -> dict[str, object]: + payload: dict[str, object] = {} + if self.tariff_zone_id is not None: + payload["tariffZoneId"] = self.tariff_zone_id + if self.terms_zone_id is not None: + payload["termsZoneId"] = self.terms_zone_id + if self.type is not None: + payload["type"] = self.type + return payload + + +@dataclass(slots=True, frozen=True) +class DeliveryDirection: + """Направление доставки в тарифе.""" + + provider_direction_id: str + tag_from: str + tag_to: str + zones: list[DeliveryDirectionZone] + + def to_payload(self) -> dict[str, object]: + return { + "providerDirectionId": self.provider_direction_id, + "tagFrom": self.tag_from, + "tagTo": self.tag_to, + "zones": [zone.to_payload() for zone in self.zones], + } + + +@dataclass(slots=True, frozen=True) +class DeliveryTariffValue: + """Значение внутри модели расчета тарифной зоны.""" + + cost: int | None = None + max_weight: int | None = None + dimensional_factor: int | None = None + max_declared_cost: int | None = None + percent: float | None = None + min_cost: int | None = None + + def to_payload(self) -> dict[str, object]: + payload: dict[str, object] = {} + if self.cost is not None: + payload["cost"] = self.cost + if self.max_weight is not None: + payload["maxWeight"] = self.max_weight + if self.dimensional_factor is not None: + payload["dimensionalFactor"] = self.dimensional_factor + if self.max_declared_cost is not None: + payload["maxDeclaredCost"] = self.max_declared_cost + if self.percent is not None: + payload["percent"] = self.percent + if self.min_cost is not None: + payload["minCost"] = self.min_cost + return payload + + +@dataclass(slots=True, frozen=True) +class DeliveryTariffItem: + """Модель расчета стоимости услуги в тарифной зоне.""" + + calculation_mechanic: str + chargeable_parameter: str + service_name: str + values: list[DeliveryTariffValue] + + def to_payload(self) -> dict[str, object]: + return { + "calculationMechanic": self.calculation_mechanic, + "chargeableParameter": self.chargeable_parameter, + "serviceName": self.service_name, + "values": [value.to_payload() for value in self.values], + } + + +@dataclass(slots=True, frozen=True) +class DeliveryTariffZone: + """Тарифная зона доставки.""" + + name: str + delivery_provider_zone_id: str + items: list[DeliveryTariffItem] + + def to_payload(self) -> dict[str, object]: + return { + "name": self.name, + "deliveryProviderZoneId": self.delivery_provider_zone_id, + "items": [item.to_payload() for item in self.items], + } + + +@dataclass(slots=True, frozen=True) +class AddTariffV2Request: + """Запрос загрузки тарифа sandbox delivery API.""" + + name: str + delivery_provider_tariff_id: str + directions: list[DeliveryDirection] + tariff_zones: list[DeliveryTariffZone] + terms_zones: list[DeliveryTermsZone] + tariff_type: str | None = None + + def to_payload(self) -> dict[str, object]: + payload: dict[str, object] = { + "name": self.name, + "deliveryProviderTariffId": self.delivery_provider_tariff_id, + "directions": [direction.to_payload() for direction in self.directions], + "tariffZones": [zone.to_payload() for zone in self.tariff_zones], + "termsZones": [zone.to_payload() for zone in self.terms_zones], + } + if self.tariff_type is not None: + payload["tariffType"] = self.tariff_type + return payload + + +@dataclass(slots=True, frozen=True) +class SandboxCancelAnnouncementOptions: + """Опции отмены тестового анонса.""" + + url_to_cancel_announcement: str + + def to_payload(self) -> dict[str, object]: + return {"urlToCancelAnnouncement": self.url_to_cancel_announcement} + + +@dataclass(slots=True, frozen=True) +class SandboxCancelAnnouncementRequest: + """Запрос отмены тестового анонса.""" + + announcement_id: str + date: str + options: SandboxCancelAnnouncementOptions + + def to_payload(self) -> dict[str, object]: + return { + "announcementID": self.announcement_id, + "date": self.date, + "options": self.options.to_payload(), + } + + +@dataclass(slots=True, frozen=True) +class CancelSandboxParcelOptions: + """Опции отмены тестовой посылки.""" + + cancelation_url: str + + def to_payload(self) -> dict[str, object]: + return {"cancelationUrl": self.cancelation_url} + + +@dataclass(slots=True, frozen=True) +class CancelSandboxParcelRequest: + """Запрос отмены тестовой посылки.""" + + parcel_id: str + options: CancelSandboxParcelOptions | None = None + + def to_payload(self) -> dict[str, object]: + payload: dict[str, object] = {"parcelID": self.parcel_id} + if self.options is not None: + payload["options"] = self.options.to_payload() + return payload + + +@dataclass(slots=True, frozen=True) +class ChangeParcelApplication: + """Изменяемые данные по посылке.""" + + kind: str | None = None + name: str | None = None + phones: list[str] | None = None + + def to_payload(self) -> dict[str, object]: + payload: dict[str, object] = {} + if self.kind is not None: + payload["kind"] = self.kind + if self.name is not None: + payload["name"] = self.name + if self.phones is not None: + payload["phones"] = list(self.phones) + return payload + + +@dataclass(slots=True, frozen=True) +class ChangeParcelOptions: + """Опции создания заявки на изменение посылки.""" + + change_parcel_url: str + + def to_payload(self) -> dict[str, object]: + return {"changeParcelUrl": self.change_parcel_url} + + +@dataclass(slots=True, frozen=True) +class ChangeParcelRequest: + """Запрос создания заявки на изменение тестовой посылки.""" + + type: str + parcel_id: str + application: ChangeParcelApplication | None = None + options: ChangeParcelOptions | None = None + + def to_payload(self) -> dict[str, object]: + payload: dict[str, object] = {"type": self.type, "parcelID": self.parcel_id} + if self.application is not None: + payload["application"] = self.application.to_payload() + if self.options is not None: + payload["options"] = self.options.to_payload() + return payload + + +@dataclass(slots=True, frozen=True) +class SandboxCreateAnnouncementOptions: + """Опции создания тестового анонса.""" + + url_to_send_announcement: str + + def to_payload(self) -> dict[str, object]: + return {"urlToSendAnnouncement": self.url_to_send_announcement} + + +@dataclass(slots=True, frozen=True) +class SandboxDeliveryPoint: + """Точка отправки или приема в тестовом анонсе.""" + + provider: str + point_id: str | None = None + accuracy: str | None = None + + def to_payload(self) -> dict[str, object]: + payload: dict[str, object] = {"provider": self.provider} + if self.point_id is not None: + payload["id"] = self.point_id + if self.accuracy is not None: + payload["accuracy"] = self.accuracy + return payload + + +@dataclass(slots=True, frozen=True) +class SandboxAnnouncementDelivery: + """Логистическая точка участника тестового анонса.""" + + type: str + terminal: SandboxDeliveryPoint | None = None + sorting_center: SandboxDeliveryPoint | None = None + + def to_payload(self) -> dict[str, object]: + payload: dict[str, object] = {"type": self.type} + if self.terminal is not None: + payload["terminal"] = self.terminal.to_payload() + if self.sorting_center is not None: + payload["sortingCenter"] = self.sorting_center.to_payload() + return payload + + +@dataclass(slots=True, frozen=True) +class SandboxAnnouncementParticipant: + """Участник тестового анонса.""" + + type: str + phones: list[str] + email: str + name: str + delivery: SandboxAnnouncementDelivery + + def to_payload(self) -> dict[str, object]: + return { + "type": self.type, + "phones": list(self.phones), + "email": self.email, + "name": self.name, + "delivery": self.delivery.to_payload(), + } + + +@dataclass(slots=True, frozen=True) +class SandboxAnnouncementPackage: + """Грузоместо в тестовом анонсе.""" + + package_id: str + parcel_ids: list[str] + seal_id: str | None = None + + def to_payload(self) -> dict[str, object]: + payload: dict[str, object] = { + "id": self.package_id, + "parcelIDs": list(self.parcel_ids), + } + if self.seal_id is not None: + payload["sealID"] = self.seal_id + return payload + + +@dataclass(slots=True, frozen=True) +class SandboxCreateAnnouncementRequest: + """Запрос создания тестового анонса.""" + + announcement_id: str + barcode: str + sender: SandboxAnnouncementParticipant + receiver: SandboxAnnouncementParticipant + announcement_type: str + date: str + packages: list[SandboxAnnouncementPackage] + options: SandboxCreateAnnouncementOptions + + def to_payload(self) -> dict[str, object]: + return { + "announcementID": self.announcement_id, + "barcode": self.barcode, + "sender": self.sender.to_payload(), + "receiver": self.receiver.to_payload(), + "announcementType": self.announcement_type, + "date": self.date, + "packages": [package.to_payload() for package in self.packages], + "options": self.options.to_payload(), + } + + +@dataclass(slots=True, frozen=True) +class SandboxGetAnnouncementEventRequest: + """Запрос последнего события тестового анонса.""" + + announcement_id: str + + def to_payload(self) -> dict[str, object]: + return {"announcementID": self.announcement_id} + + +@dataclass(slots=True, frozen=True) +class GetChangeParcelInfoRequest: + """Запрос информации о заявке на изменение посылки.""" + + application_id: str + + def to_payload(self) -> dict[str, object]: + return {"applicationID": self.application_id} + + +@dataclass(slots=True, frozen=True) +class GetSandboxParcelInfoRequest: + """Запрос информации о тестовой посылке.""" + + parcel_id: str + + def to_payload(self) -> dict[str, object]: + return {"parcelID": self.parcel_id} + + +@dataclass(slots=True, frozen=True) +class GetRegisteredParcelIdRequest: + """Запрос ID зарегистрированной тестовой посылки.""" + + order_id: str + + def to_payload(self) -> dict[str, object]: + return {"orderID": self.order_id} + + +@dataclass(slots=True, frozen=True) +class SandboxAreasRequest: + """Запрос добавления зон sandbox-доставки.""" + + areas: list[SandboxArea] + + def to_payload(self) -> dict[str, object]: + return {"areas": [area.to_payload() for area in self.areas]} + + +@dataclass(slots=True, frozen=True) +class StockInfoRequest: + """Запрос текущих остатков.""" + + item_ids: list[int] + + def to_payload(self) -> dict[str, object]: + return {"itemIds": list(self.item_ids)} + + +@dataclass(slots=True, frozen=True) +class StockUpdateEntry: + """Остаток по одному объявлению.""" + + item_id: int + quantity: int + + def to_payload(self) -> dict[str, object]: + return {"item_id": self.item_id, "quantity": self.quantity} + + +@dataclass(slots=True, frozen=True) +class StockUpdateRequest: + """Запрос обновления остатков.""" + + stocks: list[StockUpdateEntry] + + def to_payload(self) -> dict[str, object]: + return {"stocks": [stock.to_payload() for stock in self.stocks]} + + +@dataclass(slots=True, frozen=True) +class OrderSummary(SerializableModel): """Краткая информация о заказе.""" order_id: str | None @@ -29,56 +1024,50 @@ class OrderSummary: created_at: str | None buyer_name: str | None total_price: int | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class OrdersResult: +class OrdersResult(SerializableModel): """Список заказов.""" items: list[OrderSummary] total: int | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class OrderActionResult: +class OrderActionResult(SerializableModel): """Результат операции над заказом.""" success: bool order_id: str | None = None status: str | None = None message: str | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class CourierRange: +class CourierRange(SerializableModel): """Доступный интервал курьерской доставки.""" interval_id: str | None date: str | None start_at: str | None end_at: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class CourierRangesResult: +class CourierRangesResult(SerializableModel): """Список доступных интервалов курьерской доставки.""" items: list[CourierRange] address: str | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class LabelTaskResult: +class LabelTaskResult(SerializableModel): """Результат генерации этикеток.""" task_id: str | None status: str | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -93,9 +1082,21 @@ def filename(self) -> str | None: return self.binary.filename + def to_dict(self) -> dict[str, object]: + """Сериализует бинарный результат без transport-объекта.""" + + return { + "filename": self.binary.filename, + "content_type": self.binary.content_type, + "content_base64": b64encode(self.binary.content).decode("ascii"), + } + + def model_dump(self) -> dict[str, object]: + return self.to_dict() + @dataclass(slots=True, frozen=True) -class DeliveryEntityResult: +class DeliveryEntityResult(SerializableModel): """Результат операции delivery API.""" success: bool @@ -104,39 +1105,35 @@ class DeliveryEntityResult: parcel_id: str | None = None status: str | None = None message: str | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class DeliverySortingCenter: +class DeliverySortingCenter(SerializableModel): """Сортировочный центр доставки.""" sorting_center_id: str | None name: str | None city: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class DeliverySortingCentersResult: +class DeliverySortingCentersResult(SerializableModel): """Список сортировочных центров доставки.""" items: list[DeliverySortingCenter] - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class DeliveryTaskInfo: +class DeliveryTaskInfo(SerializableModel): """Информация о задаче доставки.""" task_id: str | None status: str | None error: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class StockInfo: +class StockInfo(SerializableModel): """Информация по остаткам объявления.""" item_id: int | None @@ -144,31 +1141,27 @@ class StockInfo: is_multiple: bool | None is_unlimited: bool | None is_out_of_stock: bool | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class StockInfoResult: +class StockInfoResult(SerializableModel): """Список текущих остатков.""" items: list[StockInfo] - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class StockUpdateItem: +class StockUpdateItem(SerializableModel): """Результат обновления остатков объявления.""" item_id: int | None external_id: str | None success: bool errors: list[str] - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class StockUpdateResult: +class StockUpdateResult(SerializableModel): """Результат изменения остатков.""" items: list[StockUpdateItem] - raw_payload: Mapping[str, object] = field(default_factory=dict) diff --git a/avito/promotion/__init__.py b/avito/promotion/__init__.py index 1cd482b..059d0db 100644 --- a/avito/promotion/__init__.py +++ b/avito/promotion/__init__.py @@ -4,7 +4,6 @@ AutostrategyCampaign, BbipPromotion, CpaAuction, - DomainObject, PromotionOrder, TargetActionPricing, TrxPromotion, @@ -12,50 +11,99 @@ from avito.promotion.models import ( AutostrategyBudget, AutostrategyStat, - BbipForecastRequestItem, + AutostrategyStatItem, + AutostrategyStatTotals, + BbipBudgetOption, + BbipDurationRange, BbipForecastsResult, - BbipOrderItem, + BbipItem, + BbipSuggest, BbipSuggestsResult, CampaignActionResult, + CampaignDetailsResult, + CampaignForecast, + CampaignForecastRange, CampaignInfo, + CampaignItem, + CampaignListFilter, + CampaignOrderBy, CampaignsResult, + CampaignUpdateTimeFilter, CpaAuctionBidsResult, CreateItemBid, PromotionActionResult, + PromotionForecast, + PromotionOrderError, + PromotionOrderInfo, PromotionOrdersResult, - PromotionOrderStatusesResult, + PromotionOrderStatusItem, + PromotionOrderStatusResult, + PromotionService, PromotionServiceDictionary, PromotionServicesResult, - TargetActionPromotionsResult, + PromotionServiceType, + TargetActionAutoBids, + TargetActionAutoPromotion, + TargetActionBid, + TargetActionBudget, + TargetActionGetBidsResult, + TargetActionManualBids, + TargetActionManualPromotion, + TargetActionPromotion, + TargetActionPromotionsByItemIdsResult, TrxCommissionsResult, - TrxPromotionApplyItem, + TrxItem, ) __all__ = ( "AutostrategyBudget", "AutostrategyCampaign", "AutostrategyStat", - "BbipForecastRequestItem", + "AutostrategyStatItem", + "AutostrategyStatTotals", + "BbipBudgetOption", + "BbipDurationRange", "BbipForecastsResult", - "BbipOrderItem", + "BbipItem", "BbipPromotion", + "BbipSuggest", "BbipSuggestsResult", "CampaignActionResult", + "CampaignDetailsResult", + "CampaignForecast", + "CampaignForecastRange", "CampaignInfo", + "CampaignItem", + "CampaignListFilter", + "CampaignOrderBy", + "CampaignUpdateTimeFilter", "CampaignsResult", "CpaAuction", "CpaAuctionBidsResult", "CreateItemBid", - "DomainObject", "PromotionActionResult", + "PromotionForecast", "PromotionOrder", - "PromotionOrderStatusesResult", + "PromotionOrderError", + "PromotionOrderInfo", + "PromotionOrderStatusItem", + "PromotionOrderStatusResult", "PromotionOrdersResult", + "PromotionService", "PromotionServiceDictionary", + "PromotionServiceType", "PromotionServicesResult", "TargetActionPricing", - "TargetActionPromotionsResult", + "TargetActionAutoBids", + "TargetActionAutoPromotion", + "TargetActionBid", + "TargetActionBudget", + "TargetActionGetBidsResult", + "TargetActionManualBids", + "TargetActionManualPromotion", + "TargetActionPromotion", + "TargetActionPromotionsByItemIdsResult", "TrxCommissionsResult", + "TrxItem", "TrxPromotion", - "TrxPromotionApplyItem", ) diff --git a/avito/promotion/client.py b/avito/promotion/client.py index 440eac2..0c5f944 100644 --- a/avito/promotion/client.py +++ b/avito/promotion/client.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from avito.core import RequestContext, Transport +from avito.core.mapping import request_public_model from avito.promotion.mappers import ( map_autostrategy_budget, map_autostrategy_stat, @@ -15,11 +16,12 @@ map_campaigns, map_cpa_auction_bids, map_promotion_action, - map_promotion_order_statuses, + map_promotion_order_status, map_promotion_orders, map_promotion_service_dictionary, map_promotion_services, - map_target_action_promotions, + map_target_action_get_bids_out, + map_target_action_get_promotions_by_item_ids_out, map_trx_commissions, ) from avito.promotion.models import ( @@ -28,7 +30,7 @@ BbipForecastsResult, BbipSuggestsResult, CampaignActionResult, - CampaignInfo, + CampaignDetailsResult, CampaignsResult, CancelTrxPromotionRequest, CpaAuctionBidsResult, @@ -49,11 +51,12 @@ ListPromotionServicesRequest, PromotionActionResult, PromotionOrdersResult, - PromotionOrderStatusesResult, + PromotionOrderStatusResult, PromotionServiceDictionary, PromotionServicesResult, StopAutostrategyCampaignRequest, - TargetActionPromotionsResult, + TargetActionGetBidsResult, + TargetActionPromotionsByItemIdsResult, TrxCommissionsResult, UpdateAutoBidRequest, UpdateAutostrategyCampaignRequest, @@ -70,47 +73,51 @@ class PromotionClient: def get_service_dictionary(self) -> PromotionServiceDictionary: """Получает словарь услуг продвижения.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", "/promotion/v1/items/services/dict", context=RequestContext("promotion.get_service_dictionary", allow_retry=True), + mapper=map_promotion_service_dictionary, ) - return map_promotion_service_dictionary(payload) def list_services(self, request: ListPromotionServicesRequest) -> PromotionServicesResult: """Получает список услуг продвижения по объявлениям.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", "/promotion/v1/items/services/get", context=RequestContext("promotion.list_services", allow_retry=True), + mapper=map_promotion_services, json_body=request.to_payload(), ) - return map_promotion_services(payload) def list_orders(self, request: ListPromotionOrdersRequest) -> PromotionOrdersResult: """Получает список заявок на продвижение.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", "/promotion/v1/items/services/orders/get", context=RequestContext("promotion.list_orders", allow_retry=True), + mapper=map_promotion_orders, json_body=request.to_payload(), ) - return map_promotion_orders(payload) def get_order_status( self, request: GetPromotionOrderStatusRequest - ) -> PromotionOrderStatusesResult: + ) -> PromotionOrderStatusResult: """Получает статусы заявок на продвижение.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", "/promotion/v1/items/services/orders/status", context=RequestContext("promotion.get_order_status", allow_retry=True), + mapper=map_promotion_order_status, json_body=request.to_payload(), ) - return map_promotion_order_statuses(payload) @dataclass(slots=True) @@ -122,35 +129,46 @@ class BbipClient: def get_forecasts(self, request: CreateBbipForecastsRequest) -> BbipForecastsResult: """Получает прогнозы BBIP по объявлениям.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", "/promotion/v1/items/services/bbip/forecasts/get", context=RequestContext("promotion.bbip.get_forecasts", allow_retry=True), + mapper=map_bbip_forecasts, json_body=request.to_payload(), ) - return map_bbip_forecasts(payload) - def create_order(self, request: CreateBbipOrderRequest) -> PromotionActionResult: + def create_order( + self, + request: CreateBbipOrderRequest, + ) -> PromotionActionResult: """Подключает BBIP-услугу.""" + payload_to_send = request.to_payload() payload = self.transport.request_json( "PUT", "/promotion/v1/items/services/bbip/orders/create", context=RequestContext("promotion.bbip.create_order", allow_retry=True), - json_body=request.to_payload(), + json_body=payload_to_send, + ) + return map_promotion_action( + payload, + action="create_order", + target={"item_ids": [item.item_id for item in request.items]}, + request_payload=payload_to_send, ) - return map_promotion_action(payload) def get_suggests(self, request: CreateBbipSuggestsRequest) -> BbipSuggestsResult: """Получает варианты бюджета BBIP.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", "/promotion/v1/items/services/bbip/suggests/get", context=RequestContext("promotion.bbip.get_suggests", allow_retry=True), + mapper=map_bbip_suggests, json_body=request.to_payload(), ) - return map_bbip_suggests(payload) @dataclass(slots=True) @@ -159,39 +177,58 @@ class TrxPromoClient: transport: Transport - def apply(self, request: CreateTrxPromotionApplyRequest) -> PromotionActionResult: + def apply( + self, + request: CreateTrxPromotionApplyRequest, + ) -> PromotionActionResult: """Запускает TrxPromo.""" + payload_to_send = request.to_payload() payload = self.transport.request_json( "POST", "/trx-promo/1/apply", context=RequestContext("promotion.trx.apply", allow_retry=True), - json_body=request.to_payload(), + json_body=payload_to_send, + ) + return map_promotion_action( + payload, + action="apply", + target={"item_ids": [item.item_id for item in request.items]}, + request_payload=payload_to_send, ) - return map_promotion_action(payload) - def cancel(self, request: CancelTrxPromotionRequest) -> PromotionActionResult: + def cancel( + self, + request: CancelTrxPromotionRequest, + ) -> PromotionActionResult: """Останавливает TrxPromo.""" + payload_to_send = request.to_payload() payload = self.transport.request_json( "POST", "/trx-promo/1/cancel", context=RequestContext("promotion.trx.cancel", allow_retry=True), - json_body=request.to_payload(), + json_body=payload_to_send, + ) + return map_promotion_action( + payload, + action="delete", + target={"item_ids": list(request.item_ids)}, + request_payload=payload_to_send, ) - return map_promotion_action(payload) def get_commissions(self, *, item_ids: list[int] | None = None) -> TrxCommissionsResult: """Проверяет доступность TrxPromo и размер комиссий.""" params = {"itemIDs": ",".join(str(item_id) for item_id in item_ids)} if item_ids else None - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", "/trx-promo/1/commissions", context=RequestContext("promotion.trx.get_commissions"), + mapper=map_trx_commissions, params=params, ) - return map_trx_commissions(payload) @dataclass(slots=True) @@ -208,24 +245,34 @@ def get_user_bids( ) -> CpaAuctionBidsResult: """Получает действующие и доступные ставки.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", "/auction/1/bids", context=RequestContext("promotion.cpa_auction.get_user_bids"), + mapper=map_cpa_auction_bids, params={"fromItemID": from_item_id, "batchSize": batch_size}, ) - return map_cpa_auction_bids(payload) - def create_item_bids(self, request: CreateItemBidsRequest) -> PromotionActionResult: + def create_item_bids( + self, + request: CreateItemBidsRequest, + ) -> PromotionActionResult: """Сохраняет новые ставки.""" + payload_to_send = request.to_payload() payload = self.transport.request_json( "POST", "/auction/1/bids", context=RequestContext("promotion.cpa_auction.create_item_bids", allow_retry=True), - json_body=request.to_payload(), + json_body=payload_to_send, + ) + return map_promotion_action( + payload, + action="create_item_bids", + target={"item_ids": [item.item_id for item in request.items]}, + request_payload=payload_to_send, ) - return map_promotion_action(payload) @dataclass(slots=True) @@ -234,64 +281,93 @@ class TargetActionPriceClient: transport: Transport - def get_bids(self, *, item_id: int) -> TargetActionPromotionsResult: + def get_bids(self, *, item_id: int) -> TargetActionGetBidsResult: """Получает детализированные цены и бюджеты по объявлению.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", f"/cpxpromo/1/getBids/{item_id}", context=RequestContext("promotion.target_action.get_bids"), + mapper=map_target_action_get_bids_out, ) - return map_target_action_promotions(payload) def get_promotions_by_item_ids( self, request: GetPromotionsByItemIdsRequest, - ) -> TargetActionPromotionsResult: + ) -> TargetActionPromotionsByItemIdsResult: """Получает текущие цены и бюджеты по нескольким объявлениям.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", "/cpxpromo/1/getPromotionsByItemIds", context=RequestContext( "promotion.target_action.get_promotions_by_item_ids", allow_retry=True ), + mapper=map_target_action_get_promotions_by_item_ids_out, json_body=request.to_payload(), ) - return map_target_action_promotions(payload) - def delete_promotion(self, request: DeletePromotionRequest) -> PromotionActionResult: + def delete_promotion( + self, + request: DeletePromotionRequest, + ) -> PromotionActionResult: """Останавливает продвижение с ценой целевого действия.""" + payload_to_send = request.to_payload() payload = self.transport.request_json( "POST", "/cpxpromo/1/remove", context=RequestContext("promotion.target_action.delete_promotion", allow_retry=True), - json_body=request.to_payload(), + json_body=payload_to_send, + ) + return map_promotion_action( + payload, + action="delete", + target={"item_id": request.item_id}, + request_payload=payload_to_send, ) - return map_promotion_action(payload) - def update_auto_bid(self, request: UpdateAutoBidRequest) -> PromotionActionResult: + def update_auto_bid( + self, + request: UpdateAutoBidRequest, + ) -> PromotionActionResult: """Применяет автоматическую настройку.""" + payload_to_send = request.to_payload() payload = self.transport.request_json( "POST", "/cpxpromo/1/setAuto", context=RequestContext("promotion.target_action.update_auto_bid", allow_retry=True), - json_body=request.to_payload(), + json_body=payload_to_send, + ) + return map_promotion_action( + payload, + action="update_auto", + target={"item_id": request.item_id}, + request_payload=payload_to_send, ) - return map_promotion_action(payload) - def update_manual_bid(self, request: UpdateManualBidRequest) -> PromotionActionResult: + def update_manual_bid( + self, + request: UpdateManualBidRequest, + ) -> PromotionActionResult: """Применяет ручную настройку.""" + payload_to_send = request.to_payload() payload = self.transport.request_json( "POST", "/cpxpromo/1/setManual", context=RequestContext("promotion.target_action.update_manual_bid", allow_retry=True), - json_body=request.to_payload(), + json_body=payload_to_send, + ) + return map_promotion_action( + payload, + action="update_manual", + target={"item_id": request.item_id}, + request_payload=payload_to_send, ) - return map_promotion_action(payload) @dataclass(slots=True) @@ -303,79 +379,86 @@ class AutostrategyClient: def create_budget(self, request: CreateAutostrategyBudgetRequest) -> AutostrategyBudget: """Рассчитывает бюджет кампании.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", "/autostrategy/v1/budget", context=RequestContext("promotion.autostrategy.create_budget", allow_retry=True), + mapper=map_autostrategy_budget, json_body=request.to_payload(), ) - return map_autostrategy_budget(payload) def create_campaign(self, request: CreateAutostrategyCampaignRequest) -> CampaignActionResult: """Создает новую кампанию.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", "/autostrategy/v1/campaign/create", context=RequestContext("promotion.autostrategy.create_campaign", allow_retry=True), + mapper=map_campaign_action, json_body=request.to_payload(), ) - return map_campaign_action(payload) def edit_campaign(self, request: UpdateAutostrategyCampaignRequest) -> CampaignActionResult: """Редактирует кампанию.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", "/autostrategy/v1/campaign/edit", context=RequestContext("promotion.autostrategy.edit_campaign", allow_retry=True), + mapper=map_campaign_action, json_body=request.to_payload(), ) - return map_campaign_action(payload) - def get_campaign_info(self, request: GetAutostrategyCampaignInfoRequest) -> CampaignInfo: + def get_campaign_info(self, request: GetAutostrategyCampaignInfoRequest) -> CampaignDetailsResult: """Получает полную информацию о кампании.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", "/autostrategy/v1/campaign/info", context=RequestContext("promotion.autostrategy.get_campaign_info", allow_retry=True), + mapper=map_campaign_info, json_body=request.to_payload(), ) - return map_campaign_info(payload) def stop_campaign(self, request: StopAutostrategyCampaignRequest) -> CampaignActionResult: """Останавливает кампанию.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", "/autostrategy/v1/campaign/stop", context=RequestContext("promotion.autostrategy.stop_campaign", allow_retry=True), + mapper=map_campaign_action, json_body=request.to_payload(), ) - return map_campaign_action(payload) def list_campaigns(self, request: ListAutostrategyCampaignsRequest) -> CampaignsResult: """Получает список кампаний.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", "/autostrategy/v1/campaigns", context=RequestContext("promotion.autostrategy.list_campaigns", allow_retry=True), + mapper=map_campaigns, json_body=request.to_payload(), ) - return map_campaigns(payload) def get_stat(self, request: GetAutostrategyStatRequest) -> AutostrategyStat: """Получает статистику кампании.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", "/autostrategy/v1/stat", context=RequestContext("promotion.autostrategy.get_stat", allow_retry=True), + mapper=map_autostrategy_stat, json_body=request.to_payload(), ) - return map_autostrategy_stat(payload) __all__ = ( diff --git a/avito/promotion/domain.py b/avito/promotion/domain.py index 33b64b2..ec5868f 100644 --- a/avito/promotion/domain.py +++ b/avito/promotion/domain.py @@ -4,8 +4,15 @@ from collections.abc import Mapping from dataclasses import dataclass - -from avito.core import Transport +from datetime import datetime + +from avito.core import ValidationError +from avito.core.domain import DomainObject +from avito.core.validation import ( + validate_non_empty, + validate_non_empty_string, + validate_positive_int, +) from avito.promotion.client import ( AutostrategyClient, BbipClient, @@ -17,13 +24,17 @@ from avito.promotion.models import ( AutostrategyBudget, AutostrategyStat, - BbipForecastRequestItem, BbipForecastsResult, - BbipOrderItem, + BbipItem, + BbipItemInput, BbipSuggestsResult, + BidItemInput, CampaignActionResult, - CampaignInfo, + CampaignDetailsResult, + CampaignListFilter, + CampaignOrderBy, CampaignsResult, + CampaignUpdateTimeFilter, CancelTrxPromotionRequest, CpaAuctionBidsResult, CreateAutostrategyBudgetRequest, @@ -44,32 +55,47 @@ ListPromotionServicesRequest, PromotionActionResult, PromotionOrdersResult, - PromotionOrderStatusesResult, + PromotionOrderStatusResult, PromotionServiceDictionary, PromotionServicesResult, StopAutostrategyCampaignRequest, - TargetActionPromotionsResult, + TargetActionGetBidsResult, + TargetActionPromotionsByItemIdsResult, TrxCommissionsResult, - TrxPromotionApplyItem, + TrxItem, + TrxItemInput, UpdateAutoBidRequest, UpdateAutostrategyCampaignRequest, UpdateManualBidRequest, ) -@dataclass(slots=True, frozen=True) -class DomainObject: - """Базовый доменный объект раздела promotion.""" +def _preview_result( + *, + action: str, + target: Mapping[str, object], + request_payload: Mapping[str, object], +) -> PromotionActionResult: + return PromotionActionResult( + action=action, + target=dict(target), + status="preview", + applied=False, + request_payload=dict(request_payload), + details={"validated": True}, + ) - transport: Transport + +def _validate_optional_datetime(name: str, value: datetime | None) -> None: + if value is not None and not isinstance(value, datetime): + raise ValidationError(f"`{name}` должен быть datetime.") @dataclass(slots=True, frozen=True) class PromotionOrder(DomainObject): """Доменный объект заявок и словарей promotion API.""" - resource_id: int | str | None = None - user_id: int | str | None = None + order_id: int | str | None = None def get_service_dictionary(self) -> PromotionServiceDictionary: """Получает словарь услуг продвижения.""" @@ -95,16 +121,14 @@ def list_orders( ListPromotionOrdersRequest(item_ids=item_ids, order_ids=order_ids) ) - def get_order_status( - self, *, order_ids: list[str] | None = None - ) -> PromotionOrderStatusesResult: + def get_order_status(self, *, order_ids: list[str] | None = None) -> PromotionOrderStatusResult: """Получает статусы заявок на продвижение.""" resolved_order_ids = order_ids or ( - [str(self.resource_id)] if self.resource_id is not None else [] + [str(self.order_id)] if self.order_id is not None else [] ) if not resolved_order_ids: - raise ValueError("Для операции требуется хотя бы один `order_id`.") + raise ValidationError("Для операции требуется хотя бы один `order_id`.") return PromotionClient(self.transport).get_order_status( GetPromotionOrderStatusRequest(order_ids=resolved_order_ids) ) @@ -114,18 +138,56 @@ def get_order_status( class BbipPromotion(DomainObject): """Доменный объект BBIP-продвижения.""" - resource_id: int | str | None = None + item_id: int | str | None = None user_id: int | str | None = None - def get_forecasts(self, *, items: list[BbipForecastRequestItem]) -> BbipForecastsResult: + def get_forecasts(self, *, items: list[BbipItemInput]) -> BbipForecastsResult: """Получает прогнозы BBIP.""" - return BbipClient(self.transport).get_forecasts(CreateBbipForecastsRequest(items=items)) + bbip_items = [ + BbipItem( + item_id=item["item_id"], + duration=item["duration"], + price=item["price"], + old_price=item["old_price"], + ) + for item in items + ] + return BbipClient(self.transport).get_forecasts(CreateBbipForecastsRequest(items=bbip_items)) - def create_order(self, *, items: list[BbipOrderItem]) -> PromotionActionResult: + def create_order( + self, + *, + items: list[BbipItemInput], + dry_run: bool = False, + ) -> PromotionActionResult: """Подключает BBIP-продвижение.""" - return BbipClient(self.transport).create_order(CreateBbipOrderRequest(items=items)) + validate_non_empty("items", items) + for index, item in enumerate(items): + validate_positive_int(f"items[{index}].item_id", item["item_id"]) + validate_positive_int(f"items[{index}].duration", item["duration"]) + validate_positive_int(f"items[{index}].price", item["price"]) + validate_positive_int(f"items[{index}].old_price", item["old_price"]) + bbip_items = [ + BbipItem( + item_id=item["item_id"], + duration=item["duration"], + price=item["price"], + old_price=item["old_price"], + ) + for item in items + ] + request = CreateBbipOrderRequest(items=bbip_items) + request_payload = request.to_payload() + target: dict[str, object] = {"item_ids": [item["item_id"] for item in items]} + if dry_run: + return _preview_result( + action="create_order", + target=target, + request_payload=request_payload, + ) + return BbipClient(self.transport).create_order(request) def get_suggests(self, *, item_ids: list[int] | None = None) -> BbipSuggestsResult: """Получает варианты бюджета BBIP.""" @@ -136,30 +198,64 @@ def get_suggests(self, *, item_ids: list[int] | None = None) -> BbipSuggestsResu ) def _resource_item_ids(self) -> list[int]: - if self.resource_id is None: - raise ValueError("Для операции требуется `item_id` или список `item_ids`.") - return [int(self.resource_id)] + if self.item_id is None: + raise ValidationError("Для операции требуется `item_id` или список `item_ids`.") + return [int(self.item_id)] @dataclass(slots=True, frozen=True) class TrxPromotion(DomainObject): """Доменный объект TrxPromo.""" - resource_id: int | str | None = None + item_id: int | str | None = None user_id: int | str | None = None - def apply(self, *, items: list[TrxPromotionApplyItem]) -> PromotionActionResult: + def apply( + self, + *, + items: list[TrxItemInput], + dry_run: bool = False, + ) -> PromotionActionResult: """Запускает TrxPromo.""" - return TrxPromoClient(self.transport).apply(CreateTrxPromotionApplyRequest(items=items)) - - def delete(self, *, item_ids: list[int] | None = None) -> PromotionActionResult: + validate_non_empty("items", items) + for index, item in enumerate(items): + validate_positive_int(f"items[{index}].item_id", item["item_id"]) + validate_positive_int(f"items[{index}].commission", item["commission"]) + if not isinstance(item.get("date_from"), datetime): + raise ValidationError(f"items[{index}].date_from должен быть datetime.") + trx_items = [ + TrxItem( + item_id=item["item_id"], + commission=item["commission"], + date_from=item["date_from"], + date_to=item.get("date_to"), + ) + for item in items + ] + request = CreateTrxPromotionApplyRequest(items=trx_items) + request_payload = request.to_payload() + target: dict[str, object] = {"item_ids": [item["item_id"] for item in items]} + if dry_run: + return _preview_result(action="apply", target=target, request_payload=request_payload) + return TrxPromoClient(self.transport).apply(request) + + def delete( + self, + *, + item_ids: list[int] | None = None, + dry_run: bool = False, + ) -> PromotionActionResult: """Останавливает TrxPromo.""" resolved_item_ids = item_ids or self._resource_item_ids() - return TrxPromoClient(self.transport).cancel( - CancelTrxPromotionRequest(item_ids=resolved_item_ids) - ) + validate_non_empty("item_ids", resolved_item_ids) + request = CancelTrxPromotionRequest(item_ids=resolved_item_ids) + request_payload = request.to_payload() + target = {"item_ids": list(resolved_item_ids)} + if dry_run: + return _preview_result(action="delete", target=target, request_payload=request_payload) + return TrxPromoClient(self.transport).cancel(request) def get_commissions(self, *, item_ids: list[int] | None = None) -> TrxCommissionsResult: """Получает доступные комиссии TrxPromo.""" @@ -169,17 +265,16 @@ def get_commissions(self, *, item_ids: list[int] | None = None) -> TrxCommission ) def _resource_item_ids(self) -> list[int]: - if self.resource_id is None: - raise ValueError("Для операции требуется `item_id` или список `item_ids`.") - return [int(self.resource_id)] + if self.item_id is None: + raise ValidationError("Для операции требуется `item_id` или список `item_ids`.") + return [int(self.item_id)] @dataclass(slots=True, frozen=True) class CpaAuction(DomainObject): """Доменный объект CPA-аукциона.""" - resource_id: int | str | None = None - user_id: int | str | None = None + item_id: int | str | None = None def get_user_bids( self, @@ -194,20 +289,21 @@ def get_user_bids( batch_size=batch_size, ) - def create_item_bids(self, *, items: list[CreateItemBid]) -> PromotionActionResult: + def create_item_bids(self, *, items: list[BidItemInput]) -> PromotionActionResult: """Сохраняет новые ставки по объявлениям.""" - return CpaAuctionClient(self.transport).create_item_bids(CreateItemBidsRequest(items=items)) + bids = [CreateItemBid(item_id=item["item_id"], price_penny=item["price_penny"]) for item in items] + return CpaAuctionClient(self.transport).create_item_bids(CreateItemBidsRequest(items=bids)) @dataclass(slots=True, frozen=True) class TargetActionPricing(DomainObject): """Доменный объект цены целевого действия.""" - resource_id: int | str | None = None + item_id: int | str | None = None user_id: int | str | None = None - def get_bids(self, *, item_id: int | None = None) -> TargetActionPromotionsResult: + def get_bids(self, *, item_id: int | None = None) -> TargetActionGetBidsResult: """Получает детализированные цены и бюджеты.""" return TargetActionPriceClient(self.transport).get_bids( @@ -216,7 +312,7 @@ def get_bids(self, *, item_id: int | None = None) -> TargetActionPromotionsResul def get_promotions_by_item_ids( self, *, item_ids: list[int] | None = None - ) -> TargetActionPromotionsResult: + ) -> TargetActionPromotionsByItemIdsResult: """Получает текущие настройки по нескольким объявлениям.""" resolved_item_ids = item_ids or [self._require_item_id()] @@ -224,12 +320,22 @@ def get_promotions_by_item_ids( GetPromotionsByItemIdsRequest(item_ids=resolved_item_ids) ) - def delete(self, *, item_id: int | None = None) -> PromotionActionResult: + def delete( + self, + *, + item_id: int | None = None, + dry_run: bool = False, + ) -> PromotionActionResult: """Останавливает продвижение.""" - return TargetActionPriceClient(self.transport).delete_promotion( - DeletePromotionRequest(item_id=item_id or self._require_item_id()) - ) + resolved_item_id = item_id or self._require_item_id() + validate_positive_int("item_id", resolved_item_id) + request = DeletePromotionRequest(item_id=resolved_item_id) + request_payload = request.to_payload() + target = {"item_id": resolved_item_id} + if dry_run: + return _preview_result(action="delete", target=target, request_payload=request_payload) + return TargetActionPriceClient(self.transport).delete_promotion(request) def update_auto( self, @@ -238,17 +344,30 @@ def update_auto( budget_penny: int, budget_type: str, item_id: int | None = None, + dry_run: bool = False, ) -> PromotionActionResult: """Применяет автоматическую настройку.""" - return TargetActionPriceClient(self.transport).update_auto_bid( - UpdateAutoBidRequest( - item_id=item_id or self._require_item_id(), - action_type_id=action_type_id, - budget_penny=budget_penny, - budget_type=budget_type, - ) + resolved_item_id = item_id or self._require_item_id() + validate_positive_int("item_id", resolved_item_id) + validate_positive_int("action_type_id", action_type_id) + validate_positive_int("budget_penny", budget_penny) + validate_non_empty_string("budget_type", budget_type) + request = UpdateAutoBidRequest( + item_id=resolved_item_id, + action_type_id=action_type_id, + budget_penny=budget_penny, + budget_type=budget_type, ) + request_payload = request.to_payload() + target = {"item_id": resolved_item_id} + if dry_run: + return _preview_result( + action="update_auto", + target=target, + request_payload=request_payload, + ) + return TargetActionPriceClient(self.transport).update_auto_bid(request) def update_manual( self, @@ -257,53 +376,131 @@ def update_manual( bid_penny: int, limit_penny: int | None = None, item_id: int | None = None, + dry_run: bool = False, ) -> PromotionActionResult: """Применяет ручную настройку.""" - return TargetActionPriceClient(self.transport).update_manual_bid( - UpdateManualBidRequest( - item_id=item_id or self._require_item_id(), - action_type_id=action_type_id, - bid_penny=bid_penny, - limit_penny=limit_penny, - ) + resolved_item_id = item_id or self._require_item_id() + validate_positive_int("item_id", resolved_item_id) + validate_positive_int("action_type_id", action_type_id) + validate_positive_int("bid_penny", bid_penny) + if limit_penny is not None: + validate_positive_int("limit_penny", limit_penny) + request = UpdateManualBidRequest( + item_id=resolved_item_id, + action_type_id=action_type_id, + bid_penny=bid_penny, + limit_penny=limit_penny, ) + request_payload = request.to_payload() + target = {"item_id": resolved_item_id} + if dry_run: + return _preview_result( + action="update_manual", + target=target, + request_payload=request_payload, + ) + return TargetActionPriceClient(self.transport).update_manual_bid(request) def _require_item_id(self) -> int: - if self.resource_id is None: - raise ValueError("Для операции требуется `item_id`.") - return int(self.resource_id) + if self.item_id is None: + raise ValidationError("Для операции требуется `item_id`.") + return int(self.item_id) @dataclass(slots=True, frozen=True) class AutostrategyCampaign(DomainObject): """Доменный объект кампаний автостратегии.""" - resource_id: int | str | None = None + campaign_id: int | str | None = None user_id: int | str | None = None - def create_budget(self, *, payload: Mapping[str, object]) -> AutostrategyBudget: + def create_budget( + self, + *, + campaign_type: str, + start_time: datetime | None = None, + finish_time: datetime | None = None, + items: list[int] | None = None, + ) -> AutostrategyBudget: """Рассчитывает бюджет кампании.""" + _validate_optional_datetime("start_time", start_time) + _validate_optional_datetime("finish_time", finish_time) return AutostrategyClient(self.transport).create_budget( - CreateAutostrategyBudgetRequest(payload=payload) + CreateAutostrategyBudgetRequest( + campaign_type=campaign_type, + start_time=start_time, + finish_time=finish_time, + items=items, + ) ) - def create(self, *, payload: Mapping[str, object]) -> CampaignActionResult: + def create( + self, + *, + campaign_type: str, + title: str, + budget: int | None = None, + budget_bonus: int | None = None, + budget_real: int | None = None, + calc_id: int | None = None, + description: str | None = None, + finish_time: datetime | None = None, + items: list[int] | None = None, + start_time: datetime | None = None, + ) -> CampaignActionResult: """Создает новую кампанию.""" + _validate_optional_datetime("start_time", start_time) + _validate_optional_datetime("finish_time", finish_time) return AutostrategyClient(self.transport).create_campaign( - CreateAutostrategyCampaignRequest(payload=payload) + CreateAutostrategyCampaignRequest( + campaign_type=campaign_type, + title=title, + budget=budget, + budget_bonus=budget_bonus, + budget_real=budget_real, + calc_id=calc_id, + description=description, + finish_time=finish_time, + items=items, + start_time=start_time, + ) ) - def update(self, *, payload: Mapping[str, object]) -> CampaignActionResult: + def update( + self, + *, + version: int, + campaign_id: int | None = None, + budget: int | None = None, + calc_id: int | None = None, + description: str | None = None, + finish_time: datetime | None = None, + items: list[int] | None = None, + start_time: datetime | None = None, + title: str | None = None, + ) -> CampaignActionResult: """Редактирует кампанию.""" + _validate_optional_datetime("start_time", start_time) + _validate_optional_datetime("finish_time", finish_time) return AutostrategyClient(self.transport).edit_campaign( - UpdateAutostrategyCampaignRequest(payload=payload) + UpdateAutostrategyCampaignRequest( + campaign_id=campaign_id or self._require_campaign_id(), + version=version, + budget=budget, + calc_id=calc_id, + description=description, + finish_time=finish_time, + items=items, + start_time=start_time, + title=title, + ) ) - def get(self, *, campaign_id: int | None = None) -> CampaignInfo: + def get(self, *, campaign_id: int | None = None) -> CampaignDetailsResult: """Получает полную информацию о кампании.""" return AutostrategyClient(self.transport).get_campaign_info( @@ -312,18 +509,51 @@ def get(self, *, campaign_id: int | None = None) -> CampaignInfo: ) ) - def delete(self, *, campaign_id: int | None = None) -> CampaignActionResult: + def delete(self, *, version: int, campaign_id: int | None = None) -> CampaignActionResult: """Останавливает кампанию.""" return AutostrategyClient(self.transport).stop_campaign( - StopAutostrategyCampaignRequest(campaign_id=campaign_id or self._require_campaign_id()) + StopAutostrategyCampaignRequest( + campaign_id=campaign_id or self._require_campaign_id(), + version=version, + ) ) - def list(self, *, payload: Mapping[str, object] | None = None) -> CampaignsResult: + def list( + self, + *, + limit: int = 100, + offset: int | None = None, + status_id: list[int] | None = None, + order_by: list[tuple[str, str]] | None = None, + updated_from: datetime | None = None, + updated_to: datetime | None = None, + ) -> CampaignsResult: """Получает список кампаний.""" + filter_payload = ( + CampaignListFilter( + by_update_time=CampaignUpdateTimeFilter( + from_time=updated_from, + to_time=updated_to, + ) + ) + if updated_from is not None or updated_to is not None + else None + ) + order_by_payload = ( + [CampaignOrderBy(column=column, direction=direction) for column, direction in order_by] + if order_by is not None + else None + ) return AutostrategyClient(self.transport).list_campaigns( - ListAutostrategyCampaignsRequest(payload=payload or {}) + ListAutostrategyCampaignsRequest( + limit=limit, + offset=offset, + status_id=status_id, + order_by=order_by_payload, + filter=filter_payload, + ) ) def get_stat(self, *, campaign_id: int | None = None) -> AutostrategyStat: @@ -334,16 +564,15 @@ def get_stat(self, *, campaign_id: int | None = None) -> AutostrategyStat: ) def _require_campaign_id(self) -> int: - if self.resource_id is None: - raise ValueError("Для операции требуется `campaign_id`.") - return int(self.resource_id) + if self.campaign_id is None: + raise ValidationError("Для операции требуется `campaign_id`.") + return int(self.campaign_id) __all__ = ( "AutostrategyCampaign", "BbipPromotion", "CpaAuction", - "DomainObject", "PromotionOrder", "TargetActionPricing", "TrxPromotion", diff --git a/avito/promotion/enums.py b/avito/promotion/enums.py deleted file mode 100644 index 3071860..0000000 --- a/avito/promotion/enums.py +++ /dev/null @@ -1 +0,0 @@ -"""Строковые enum и константы пакета promotion.""" diff --git a/avito/promotion/mappers.py b/avito/promotion/mappers.py index c01bf48..41a2728 100644 --- a/avito/promotion/mappers.py +++ b/avito/promotion/mappers.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +from datetime import datetime from typing import cast from avito.core.exceptions import ResponseMappingError @@ -11,31 +12,44 @@ AutostrategyBudgetPoint, AutostrategyPriceRange, AutostrategyStat, + AutostrategyStatItem, + AutostrategyStatTotals, BbipBudgetOption, BbipDurationRange, - BbipForecast, BbipForecastsResult, BbipSuggest, BbipSuggestsResult, CampaignActionResult, + CampaignDetailsResult, + CampaignForecast, + CampaignForecastRange, CampaignInfo, + CampaignItem, CampaignsResult, CpaAuctionBidOption, CpaAuctionBidsResult, CpaAuctionItemBid, PromotionActionItem, PromotionActionResult, + PromotionForecast, + PromotionOrderError, PromotionOrderInfo, PromotionOrdersResult, - PromotionOrderStatus, - PromotionOrderStatusesResult, + PromotionOrderStatusItem, + PromotionOrderStatusResult, PromotionService, PromotionServiceDictionary, PromotionServicesResult, PromotionServiceType, + TargetActionAutoBids, + TargetActionAutoPromotion, TargetActionBid, + TargetActionBudget, + TargetActionGetBidsResult, + TargetActionManualBids, + TargetActionManualPromotion, TargetActionPromotion, - TargetActionPromotionsResult, + TargetActionPromotionsByItemIdsResult, TrxCommissionInfo, TrxCommissionRange, TrxCommissionsResult, @@ -92,6 +106,17 @@ def _bool(payload: Payload, *keys: str) -> bool | None: return None +def _datetime(payload: Payload, *keys: str) -> datetime | None: + for key in keys: + value = payload.get(key) + if isinstance(value, str): + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + continue + return None + + def _items_payload(payload: Payload) -> list[Payload]: return _list(payload, "items", "result", "services", "orders", "campaigns") @@ -105,11 +130,9 @@ def map_promotion_service_dictionary(payload: object) -> PromotionServiceDiction PromotionServiceType( code=_str(item, "code", "serviceCode", "id"), title=_str(item, "title", "name", "description"), - raw_payload=item, ) for item in _items_payload(data) ], - raw_payload=data, ) @@ -125,11 +148,9 @@ def map_promotion_services(payload: object) -> PromotionServicesResult: service_name=_str(item, "serviceName", "name", "title"), price=_int(item, "price", "pricePenny"), status=_str(item, "status"), - raw_payload=item, ) for item in _items_payload(data) ], - raw_payload=data, ) @@ -144,30 +165,50 @@ def map_promotion_orders(payload: object) -> PromotionOrdersResult: item_id=_int(item, "itemId", "itemID"), service_code=_str(item, "serviceCode", "code"), status=_str(item, "status"), - created_at=_str(item, "createdAt", "created_at"), - raw_payload=item, + created_at=_datetime(item, "createdAt", "created_at"), ) for item in _items_payload(data) ], - raw_payload=data, ) -def map_promotion_order_statuses(payload: object) -> PromotionOrderStatusesResult: - """Преобразует статусы заявок на продвижение.""" +def map_promotion_order_status(payload: object) -> PromotionOrderStatusResult: + """Преобразует documented shape статуса заявки на продвижение.""" data = _expect_mapping(payload) - return PromotionOrderStatusesResult( + order_id = _str(data, "orderId", "orderID", "id") + status = _str(data, "status") + if order_id is None or status is None: + raise ResponseMappingError( + "Статус заявки promotion должен содержать `orderId` и `status`.", + payload=payload, + ) + errors_payload = data.get("errors", []) + if errors_payload is not None and not isinstance(errors_payload, list): + raise ResponseMappingError("Поле `errors` должно быть массивом.", payload=payload) + return PromotionOrderStatusResult( + order_id=order_id, + status=status, + total_price=_int(data, "totalPrice"), items=[ - PromotionOrderStatus( - order_id=_str(item, "orderId", "orderID", "id"), + PromotionOrderStatusItem( + item_id=_int(item, "itemId", "itemID"), + price=_int(item, "price"), + slug=_str(item, "slug"), status=_str(item, "status"), - message=_str(item, "message", "description"), - raw_payload=item, + error_reason=_str(item, "errorReason"), ) - for item in _items_payload(data) + for item in _list(data, "items") + ], + errors=[ + PromotionOrderError( + item_id=_int(item, "itemId", "itemID"), + error_code=_int(item, "errorCode"), + error_text=_str(item, "errorText"), + ) + for item in errors_payload or [] + if isinstance(item, Mapping) ], - raw_payload=data, ) @@ -177,21 +218,25 @@ def map_bbip_forecasts(payload: object) -> BbipForecastsResult: data = _expect_mapping(payload) return BbipForecastsResult( items=[ - BbipForecast( + PromotionForecast( item_id=_int(item, "itemId", "itemID"), min_views=_int(item, "min"), max_views=_int(item, "max"), total_price=_int(item, "totalPrice"), total_old_price=_int(item, "totalOldPrice"), - raw_payload=item, ) for item in _items_payload(data) ], - raw_payload=data, ) -def map_promotion_action(payload: object) -> PromotionActionResult: +def map_promotion_action( + payload: object, + *, + action: str, + target: Mapping[str, object] | None, + request_payload: Mapping[str, object], +) -> PromotionActionResult: """Преобразует результат действия по продвижению.""" data = _expect_mapping(payload) @@ -199,21 +244,70 @@ def map_promotion_action(payload: object) -> PromotionActionResult: if not items_payload: success_payload = _mapping(data, "success") items_payload = _list(success_payload, "items", "result") + items = [ + PromotionActionItem( + item_id=_int(item, "itemId", "itemID"), + success=bool(item.get("success", True)), + status=_str(item, "status"), + message=_str(_mapping(item, "error"), "message") or _str(item, "message"), + upstream_reference=_str(item, "orderId", "requestId", "promotionId", "id"), + ) + for item in items_payload + ] + applied = bool(data.get("success", True)) if not items else all(item.success for item in items) + statuses = [item.status for item in items if item.status] + messages = [item.message for item in items if item.message] + resolved_status = _resolve_action_status(payload=data, statuses=statuses, applied=applied) + details: dict[str, object] = {} + if items: + details["items"] = [ + { + "item_id": item.item_id, + "success": item.success, + "status": item.status, + "message": item.message, + } + for item in items + ] + elif message := _str(data, "message", "status"): + details["message"] = message return PromotionActionResult( - items=[ - PromotionActionItem( - item_id=_int(item, "itemId", "itemID"), - success=bool(item.get("success", True)), - status=_str(item, "status"), - message=_str(_mapping(item, "error"), "message") or _str(item, "message"), - raw_payload=item, - ) - for item in items_payload - ], - raw_payload=data, + action=action, + target=dict(target) if target is not None else None, + status=resolved_status, + applied=applied, + request_payload=dict(request_payload), + warnings=messages if not applied else [], + upstream_reference=_extract_upstream_reference(data, items), + details=details, ) +def _resolve_action_status(*, payload: Payload, statuses: list[str], applied: bool) -> str: + if statuses: + unique_statuses = list(dict.fromkeys(statuses)) + if len(unique_statuses) == 1: + return unique_statuses[0] + return "applied" if applied else "partial" + payload_status = _str(payload, "status") + if payload_status is not None: + return payload_status + return "applied" if applied else "failed" + + +def _extract_upstream_reference( + payload: Payload, + items: list[PromotionActionItem], +) -> str | None: + reference = _str(payload, "orderId", "requestId", "promotionId", "id") + if reference is not None: + return reference + for item in items: + if item.upstream_reference is not None: + return item.upstream_reference + return None + + def map_bbip_suggests(payload: object) -> BbipSuggestsResult: """Преобразует варианты бюджета BBIP.""" @@ -224,11 +318,9 @@ def map_bbip_suggests(payload: object) -> BbipSuggestsResult: item_id=_int(item, "itemId", "itemID"), duration=_map_bbip_duration(_mapping(item, "duration")), budgets=[_map_bbip_budget(option) for option in _list(item, "budgets")], - raw_payload=item, ) for item in _items_payload(data) ], - raw_payload=data, ) @@ -237,7 +329,6 @@ def _map_bbip_budget(payload: Payload) -> BbipBudgetOption: price=_int(payload, "price"), old_price=_int(payload, "oldPrice"), is_recommended=_bool(payload, "isRecommended"), - raw_payload=payload, ) @@ -248,7 +339,6 @@ def _map_bbip_duration(payload: Payload) -> BbipDurationRange | None: start=_int(payload, "from"), stop=_int(payload, "to"), recommended=_int(payload, "recommended"), - raw_payload=payload, ) @@ -267,11 +357,9 @@ def map_trx_commissions(payload: object) -> TrxCommissionsResult: commission=_int(item, "commission"), is_active=_bool(item, "isActive", "active"), valid_commission_range=_map_trx_range(_mapping(item, "validCommissionRange")), - raw_payload=item, ) for item in items_payload ], - raw_payload=data, ) @@ -282,7 +370,6 @@ def _map_trx_range(payload: Payload) -> TrxCommissionRange | None: value_min=_int(payload, "valueMin"), value_max=_int(payload, "valueMax"), step=_int(payload, "step"), - raw_payload=payload, ) @@ -295,51 +382,153 @@ def map_cpa_auction_bids(payload: object) -> CpaAuctionBidsResult: CpaAuctionItemBid( item_id=_int(item, "itemId", "itemID"), price_penny=_int(item, "pricePenny"), - expiration_time=_str(item, "expirationTime"), + expiration_time=_datetime(item, "expirationTime"), available_prices=[ CpaAuctionBidOption( price_penny=_int(option, "pricePenny"), goodness=_int(option, "goodness"), - raw_payload=option, ) for option in _list(item, "availablePrices") ], - raw_payload=item, ) for item in _items_payload(data) ], - raw_payload=data, ) -def map_target_action_promotions(payload: object) -> TargetActionPromotionsResult: - """Преобразует текущие настройки цены целевого действия.""" +def _map_target_action_bid(item: Payload) -> TargetActionBid: + return TargetActionBid( + value_penny=_int(item, "valuePenny"), + min_forecast=_int(item, "minForecast"), + max_forecast=_int(item, "maxForecast"), + compare=_int(item, "compare"), + ) + + +def _map_target_action_budget(item: Payload) -> TargetActionBudget: + return TargetActionBudget( + budget_penny=_int(item, "valuePenny"), + min_forecast=_int(item, "minForecast"), + max_forecast=_int(item, "maxForecast"), + compare=_int(item, "compare"), + ) + + +def _map_target_action_manual(payload: Payload) -> TargetActionManualBids: + bids_payload = payload.get("bids") + if bids_payload is not None and not isinstance(bids_payload, list): + raise ResponseMappingError("Поле `manual.bids` должно быть массивом.", payload=payload) + return TargetActionManualBids( + bid_penny=_int(payload, "bidPenny"), + limit_penny=_int(payload, "limitPenny"), + rec_bid_penny=_int(payload, "recBidPenny"), + min_bid_penny=_int(payload, "minBidPenny"), + max_bid_penny=_int(payload, "maxBidPenny"), + min_limit_penny=_int(payload, "minLimitPenny"), + max_limit_penny=_int(payload, "maxLimitPenny"), + bids=[ + _map_target_action_bid(item) for item in bids_payload or [] if isinstance(item, Mapping) + ], + ) + + +def _map_budget_values(payload: Payload, key: str) -> list[TargetActionBudget]: + budget = payload.get(key) + if budget is None: + return [] + if not isinstance(budget, Mapping): + raise ResponseMappingError(f"Поле `{key}` должно быть объектом.", payload=payload) + values = budget.get("budgets") + if values is not None and not isinstance(values, list): + raise ResponseMappingError(f"Поле `{key}.budgets` должно быть массивом.", payload=payload) + return [_map_target_action_budget(item) for item in values or [] if isinstance(item, Mapping)] + + +def _map_target_action_auto(payload: Payload) -> TargetActionAutoBids: + return TargetActionAutoBids( + budget_penny=_int(payload, "budgetPenny"), + budget_type=_str(payload, "budgetType"), + min_budget_penny=_int(payload, "minBudgetPenny"), + max_budget_penny=_int(payload, "maxBudgetPenny"), + daily_budget=_map_budget_values(payload, "dailyBudget"), + weekly_budget=_map_budget_values(payload, "weeklyBudget"), + monthly_budget=_map_budget_values(payload, "monthlyBudget"), + ) + + +def map_target_action_get_bids_out(payload: object) -> TargetActionGetBidsResult: + """Преобразует documented shape GET /cpxpromo/1/getBids/{itemId}.""" data = _expect_mapping(payload) - return TargetActionPromotionsResult( - items=[ + action_type_id = _int(data, "actionTypeID") + selected_type = _str(data, "selectedType") + if action_type_id is None or selected_type is None: + raise ResponseMappingError( + "Ответ getBids должен содержать `actionTypeID` и `selectedType`.", + payload=payload, + ) + return TargetActionGetBidsResult( + action_type_id=action_type_id, + selected_type=selected_type, + auto=( + _map_target_action_auto(cast(Payload, data["auto"])) + if isinstance(data.get("auto"), Mapping) + else None + ), + manual=( + _map_target_action_manual(cast(Payload, data["manual"])) + if isinstance(data.get("manual"), Mapping) + else None + ), + ) + + +def map_target_action_get_promotions_by_item_ids_out( + payload: object, +) -> TargetActionPromotionsByItemIdsResult: + """Преобразует documented shape POST /cpxpromo/1/getPromotionsByItemIds.""" + + data = _expect_mapping(payload) + items_payload = data.get("items") + if not isinstance(items_payload, list): + raise ResponseMappingError( + "Ответ getPromotionsByItemIds должен содержать массив `items`.", payload=payload + ) + items: list[TargetActionPromotion] = [] + for item in items_payload: + if not isinstance(item, Mapping): + continue + item_id = _int(item, "itemID") + action_type_id = _int(item, "actionTypeID") + if item_id is None or action_type_id is None: + raise ResponseMappingError( + "Элемент getPromotionsByItemIds должен содержать `itemID` и `actionTypeID`.", + payload=item, + ) + items.append( TargetActionPromotion( - item_id=_int(item, "itemID", "itemId"), - action_type_id=_int(item, "actionTypeID", "actionTypeId"), - is_auto=_bool(item, "isAuto", "auto"), - bid_penny=_int(item, "bidPenny"), - budget_penny=_int(item, "budgetPenny"), - budget_type=_str(item, "budgetType"), - available_bids=[ - TargetActionBid( - value_penny=_int(option, "valuePenny"), - min_forecast=_int(option, "minForecast"), - max_forecast=_int(option, "maxForecast"), - compare=_int(option, "compare"), - raw_payload=option, + item_id=item_id, + action_type_id=action_type_id, + auto=( + TargetActionAutoPromotion( + budget_penny=_int(cast(Payload, item["autoPromotion"]), "budgetPenny"), + budget_type=_str(cast(Payload, item["autoPromotion"]), "budgetType"), ) - for option in _list(item, "availableBids", "bids") - ], - raw_payload=item, + if isinstance(item.get("autoPromotion"), Mapping) + else None + ), + manual=( + TargetActionManualPromotion( + bid_penny=_int(cast(Payload, item["manualPromotion"]), "bidPenny"), + limit_penny=_int(cast(Payload, item["manualPromotion"]), "limitPenny"), + ) + if isinstance(item.get("manualPromotion"), Mapping) + else None + ), ) - for item in _items_payload(data) - ], - raw_payload=data, + ) + return TargetActionPromotionsByItemIdsResult( + items=items, ) @@ -347,15 +536,13 @@ def map_autostrategy_budget(payload: object) -> AutostrategyBudget: """Преобразует расчет бюджета автокампании.""" data = _expect_mapping(payload) - budget_payload = _mapping(data, "budget") - source = budget_payload or data + source = _mapping(data, "budget") return AutostrategyBudget( - budget_id=_str(data, "budgetId", "budgetID", "id"), + calc_id=_int(data, "calcId"), recommended=_map_budget_point(_mapping(source, "recommended")), minimal=_map_budget_point(_mapping(source, "minimal")), maximal=_map_budget_point(_mapping(source, "maximal")), price_ranges=[_map_price_range(item) for item in _list(source, "priceRanges")], - raw_payload=data, ) @@ -370,7 +557,6 @@ def _map_budget_point(payload: Payload) -> AutostrategyBudgetPoint | None: calls_to=_int(payload, "callsTo"), views_from=_int(payload, "viewsFrom"), views_to=_int(payload, "viewsTo"), - raw_payload=payload, ) @@ -383,7 +569,6 @@ def _map_price_range(payload: Payload) -> AutostrategyPriceRange: calls_to=_int(payload, "callsTo"), views_from=_int(payload, "viewsFrom"), views_to=_int(payload, "viewsTo"), - raw_payload=payload, ) @@ -391,36 +576,83 @@ def map_campaign_action(payload: object) -> CampaignActionResult: """Преобразует результат операции с автокампанией.""" data = _expect_mapping(payload) - return CampaignActionResult( - campaign_id=_int(data, "campaignId", "campaignID", "id"), - status=_str(data, "status"), - raw_payload=data, + return CampaignActionResult(campaign=_map_campaign(_mapping(data, "campaign"))) + + +def _map_campaign(payload: Payload) -> CampaignInfo | None: + if not payload: + return None + return CampaignInfo( + campaign_id=_int(payload, "campaignId"), + campaign_type=_str(payload, "campaignType"), + budget=_int(payload, "budget"), + balance=_int(payload, "balance"), + create_time=_datetime(payload, "createTime"), + description=_str(payload, "description"), + finish_time=_datetime(payload, "finishTime"), + items_count=_int(payload, "itemsCount"), + start_time=_datetime(payload, "startTime"), + status_id=_int(payload, "statusId"), + title=_str(payload, "title"), + update_time=_datetime(payload, "updateTime"), + user_id=_int(payload, "userId"), + version=_int(payload, "version"), ) -def map_campaign_info(payload: object) -> CampaignInfo: - """Преобразует информацию об автокампании.""" +def map_campaign_info(payload: object) -> CampaignDetailsResult: + """Преобразует полную информацию об автокампании.""" data = _expect_mapping(payload) - source = _mapping(data, "campaign") or data - return CampaignInfo( - campaign_id=_int(source, "campaignId", "campaignID", "id"), - campaign_type=_str(source, "campaignType"), - status=_str(source, "status"), - budget=_int(source, "budget"), - balance=_int(source, "balance"), - title=_str(source, "title", "name"), - raw_payload=data, + return CampaignDetailsResult( + campaign=_map_campaign(_mapping(data, "campaign")), + forecast=_map_campaign_forecast(_mapping(data, "forecast")), + items=[_map_campaign_item(item) for item in _list(data, "items")], + ) + + +def _map_campaign_forecast(payload: Payload) -> CampaignForecast | None: + if not payload: + return None + return CampaignForecast( + calls=_map_campaign_forecast_range(_mapping(payload, "calls")), + views=_map_campaign_forecast_range(_mapping(payload, "views")), + ) + + +def _map_campaign_forecast_range(payload: Payload) -> CampaignForecastRange | None: + if not payload: + return None + return CampaignForecastRange( + from_value=_int(payload, "from"), + to_value=_int(payload, "to"), + ) + + +def _map_campaign_item(payload: Payload) -> CampaignItem: + return CampaignItem( + item_id=_int(payload, "itemId"), + is_active=_bool(payload, "isActive"), ) +def map_campaign_list_item(payload: object) -> CampaignInfo: + """Преобразует элемент списка автокампаний.""" + + data = _expect_mapping(payload) + campaign = _map_campaign(data) + if campaign is None: + raise ResponseMappingError("Не удалось смэппить кампанию.", payload=payload) + return campaign + + def map_campaigns(payload: object) -> CampaignsResult: """Преобразует список автокампаний.""" data = _expect_mapping(payload) return CampaignsResult( - items=[map_campaign_info(item) for item in _items_payload(data)], - raw_payload=data, + items=[map_campaign_list_item(item) for item in _list(data, "campaigns")], + total_count=_int(data, "totalCount"), ) @@ -428,11 +660,26 @@ def map_autostrategy_stat(payload: object) -> AutostrategyStat: """Преобразует статистику автокампании.""" data = _expect_mapping(payload) - source = _mapping(data, "stat") or data return AutostrategyStat( - campaign_id=_int(source, "campaignId", "campaignID", "id"), - views=_int(source, "views"), - contacts=_int(source, "contacts", "leads"), - spend=_int(source, "spend", "spendTotal"), - raw_payload=data, + items=[_map_autostrategy_stat_item(item) for item in _list(data, "stat")], + totals=_map_autostrategy_stat_totals(_mapping(data, "totals")), + ) + + +def _map_autostrategy_stat_item(payload: Payload) -> AutostrategyStatItem: + return AutostrategyStatItem( + date=_datetime(payload, "date"), + calls=_int(payload, "calls"), + views=_int(payload, "views"), + calls_forecast=_map_campaign_forecast_range(_mapping(payload, "callsForecast")), + views_forecast=_map_campaign_forecast_range(_mapping(payload, "viewsForecast")), + ) + + +def _map_autostrategy_stat_totals(payload: Payload) -> AutostrategyStatTotals | None: + if not payload: + return None + return AutostrategyStatTotals( + calls=_int(payload, "calls"), + views=_int(payload, "views"), ) diff --git a/avito/promotion/models.py b/avito/promotion/models.py index ef37cbe..757fc6c 100644 --- a/avito/promotion/models.py +++ b/avito/promotion/models.py @@ -2,25 +2,26 @@ from __future__ import annotations -from collections.abc import Mapping from dataclasses import dataclass, field +from datetime import datetime +from typing import TypedDict + +from avito.core.serialization import SerializableModel @dataclass(slots=True, frozen=True) -class PromotionServiceType: +class PromotionServiceType(SerializableModel): """Тип услуги продвижения.""" code: str | None title: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class PromotionServiceDictionary: +class PromotionServiceDictionary(SerializableModel): """Словарь услуг продвижения.""" items: list[PromotionServiceType] - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -36,7 +37,7 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class PromotionService: +class PromotionService(SerializableModel): """Услуга продвижения по объявлению.""" item_id: int | None @@ -44,15 +45,13 @@ class PromotionService: service_name: str | None price: int | None status: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class PromotionServicesResult: +class PromotionServicesResult(SerializableModel): """Список услуг продвижения.""" items: list[PromotionService] - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -74,23 +73,21 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class PromotionOrderInfo: +class PromotionOrderInfo(SerializableModel): """Заявка на продвижение.""" order_id: str | None item_id: int | None service_code: str | None status: str | None - created_at: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + created_at: datetime | None @dataclass(slots=True, frozen=True) -class PromotionOrdersResult: +class PromotionOrdersResult(SerializableModel): """Список заявок на продвижение.""" items: list[PromotionOrderInfo] - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -106,57 +103,100 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class PromotionOrderStatus: - """Статус заявки на продвижение.""" +class PromotionOrderError(SerializableModel): + """Ошибка по объявлению в ответе promotion API.""" - order_id: str | None - status: str | None - message: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + item_id: int | None + error_code: int | None + error_text: str | None @dataclass(slots=True, frozen=True) -class PromotionOrderStatusesResult: - """Набор статусов заявок.""" +class PromotionOrderStatusItem(SerializableModel): + """Статус услуги внутри заявки на продвижение.""" - items: list[PromotionOrderStatus] - raw_payload: Mapping[str, object] = field(default_factory=dict) + item_id: int | None + price: int | None + slug: str | None + status: str | None + error_reason: str | None @dataclass(slots=True, frozen=True) -class BbipForecastRequestItem: - """Параметры прогноза BBIP по объявлению.""" +class PromotionOrderStatusResult(SerializableModel): + """Статус заявки на продвижение.""" + + order_id: str | None + status: str | None + total_price: int | None + items: list[PromotionOrderStatusItem] + errors: list[PromotionOrderError] + + +class BbipItemInput(TypedDict): + """Входные параметры одного объявления для BBIP-методов.""" item_id: int duration: int price: int old_price: int - def to_payload(self) -> dict[str, object]: - """Сериализует один BBIP-элемент прогноза.""" - return { - "itemId": self.item_id, - "duration": self.duration, - "price": self.price, - "oldPrice": self.old_price, - } +class _TrxItemInputRequired(TypedDict): + """Обязательные поля входных параметров TrxPromo.""" + + item_id: int + commission: int + date_from: datetime + + +class TrxItemInput(_TrxItemInputRequired, total=False): + """Входные параметры одного объявления для TrxPromo-методов.""" + + date_to: datetime | None + + +class BidItemInput(TypedDict): + """Входные параметры одной ставки CPA-аукциона.""" + + item_id: int + price_penny: int + + +@dataclass(slots=True, frozen=True) +class BbipItem(SerializableModel): + """Параметры BBIP по объявлению (прогноз или заявка).""" + + item_id: int + duration: int + price: int + old_price: int @dataclass(slots=True, frozen=True) class CreateBbipForecastsRequest: """Запрос прогноза BBIP.""" - items: list[BbipForecastRequestItem] + items: list[BbipItem] def to_payload(self) -> dict[str, object]: """Сериализует запрос прогноза BBIP.""" - return {"items": [item.to_payload() for item in self.items]} + return { + "items": [ + { + "itemId": item.item_id, + "duration": item.duration, + "price": item.price, + "oldPrice": item.old_price, + } + for item in self.items + ] + } @dataclass(slots=True, frozen=True) -class BbipForecast: +class PromotionForecast(SerializableModel): """Прогноз BBIP по объявлению.""" item_id: int | None @@ -164,66 +204,60 @@ class BbipForecast: max_views: int | None total_price: int | None total_old_price: int | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class BbipForecastsResult: +class BbipForecastsResult(SerializableModel): """Результат прогноза BBIP.""" - items: list[BbipForecast] - raw_payload: Mapping[str, object] = field(default_factory=dict) - - -@dataclass(slots=True, frozen=True) -class BbipOrderItem: - """Параметры подключения BBIP по объявлению.""" - - item_id: int - duration: int - price: int - old_price: int - - def to_payload(self) -> dict[str, object]: - """Сериализует одну заявку BBIP.""" - - return { - "itemId": self.item_id, - "duration": self.duration, - "price": self.price, - "oldPrice": self.old_price, - } + items: list[PromotionForecast] @dataclass(slots=True, frozen=True) class CreateBbipOrderRequest: """Запрос подключения BBIP.""" - items: list[BbipOrderItem] + items: list[BbipItem] def to_payload(self) -> dict[str, object]: """Сериализует запрос подключения BBIP.""" - return {"items": [item.to_payload() for item in self.items]} + return { + "items": [ + { + "itemId": item.item_id, + "duration": item.duration, + "price": item.price, + "oldPrice": item.old_price, + } + for item in self.items + ] + } @dataclass(slots=True, frozen=True) -class PromotionActionItem: +class PromotionActionItem(SerializableModel): """Результат действия по одному объявлению.""" item_id: int | None success: bool status: str | None = None message: str | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) + upstream_reference: str | None = None @dataclass(slots=True, frozen=True) -class PromotionActionResult: - """Результат операции продвижения.""" +class PromotionActionResult(SerializableModel): + """Стабильный результат write-операции продвижения.""" - items: list[PromotionActionItem] - raw_payload: Mapping[str, object] = field(default_factory=dict) + action: str + target: dict[str, object] | None + status: str + applied: bool + request_payload: dict[str, object] | None = None + warnings: list[str] = field(default_factory=list) + upstream_reference: str | None = None + details: dict[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -239,75 +273,69 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class BbipBudgetOption: +class BbipBudgetOption(SerializableModel): """Вариант бюджета BBIP.""" price: int | None old_price: int | None is_recommended: bool | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class BbipDurationRange: +class BbipDurationRange(SerializableModel): """Доступный диапазон длительности BBIP.""" start: int | None stop: int | None recommended: int | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class BbipSuggest: +class BbipSuggest(SerializableModel): """Варианты бюджета BBIP по объявлению.""" item_id: int | None duration: BbipDurationRange | None budgets: list[BbipBudgetOption] - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class BbipSuggestsResult: +class BbipSuggestsResult(SerializableModel): """Результат вариантов бюджета BBIP.""" items: list[BbipSuggest] - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class TrxPromotionApplyItem: - """Параметры запуска TrxPromo по объявлению.""" +class TrxItem(SerializableModel): + """Параметры TrxPromo по объявлению.""" item_id: int commission: int - date_from: str - date_to: str | None = None - - def to_payload(self) -> dict[str, object]: - """Сериализует один элемент запуска TrxPromo.""" - - payload: dict[str, object] = { - "itemID": self.item_id, - "commission": self.commission, - "dateFrom": self.date_from, - } - if self.date_to is not None: - payload["dateTo"] = self.date_to - return payload + date_from: datetime + date_to: datetime | None = None @dataclass(slots=True, frozen=True) class CreateTrxPromotionApplyRequest: """Запрос запуска TrxPromo.""" - items: list[TrxPromotionApplyItem] + items: list[TrxItem] def to_payload(self) -> dict[str, object]: """Сериализует запрос запуска TrxPromo.""" - return {"items": [item.to_payload() for item in self.items]} + items_payload: list[dict[str, object]] = [] + for item in self.items: + entry: dict[str, object] = { + "itemID": item.item_id, + "commission": item.commission, + "dateFrom": item.date_from.isoformat(), + } + if item.date_to is not None: + entry["dateTo"] = item.date_to.isoformat() + items_payload.append(entry) + return {"items": items_payload} @dataclass(slots=True, frozen=True) @@ -323,60 +351,54 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class TrxCommissionRange: +class TrxCommissionRange(SerializableModel): """Диапазон комиссии TrxPromo.""" value_min: int | None value_max: int | None step: int | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class TrxCommissionInfo: +class TrxCommissionInfo(SerializableModel): """Доступность и комиссия TrxPromo по объявлению.""" item_id: int | None commission: int | None is_active: bool | None valid_commission_range: TrxCommissionRange | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class TrxCommissionsResult: +class TrxCommissionsResult(SerializableModel): """Список комиссий TrxPromo.""" items: list[TrxCommissionInfo] - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class CpaAuctionBidOption: +class CpaAuctionBidOption(SerializableModel): """Доступная ставка CPA-аукциона.""" price_penny: int | None goodness: int | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class CpaAuctionItemBid: +class CpaAuctionItemBid(SerializableModel): """Текущая и доступные ставки CPA-аукциона по объявлению.""" item_id: int | None price_penny: int | None - expiration_time: str | None + expiration_time: datetime | None available_prices: list[CpaAuctionBidOption] - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class CpaAuctionBidsResult: +class CpaAuctionBidsResult(SerializableModel): """Список ставок CPA-аукциона.""" items: list[CpaAuctionItemBid] - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -405,45 +427,93 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class TargetActionBid: +class TargetActionBid(SerializableModel): """Доступная цена целевого действия.""" value_penny: int | None min_forecast: int | None max_forecast: int | None compare: int | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class TargetActionBudget: - """Бюджет целевого действия.""" +class TargetActionManualBids(SerializableModel): + """Детали ручной ставки цены целевого действия.""" + + bid_penny: int | None + limit_penny: int | None + rec_bid_penny: int | None + min_bid_penny: int | None + max_bid_penny: int | None + min_limit_penny: int | None + max_limit_penny: int | None + bids: list[TargetActionBid] + + +@dataclass(slots=True, frozen=True) +class TargetActionBudget(SerializableModel): + """Диапазон доступных бюджетов цены целевого действия.""" + + budget_penny: int | None + min_forecast: int | None + max_forecast: int | None + compare: int | None + + +@dataclass(slots=True, frozen=True) +class TargetActionAutoBids(SerializableModel): + """Детали автоматического продвижения цены целевого действия.""" budget_penny: int | None budget_type: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + min_budget_penny: int | None + max_budget_penny: int | None + daily_budget: list[TargetActionBudget] + weekly_budget: list[TargetActionBudget] + monthly_budget: list[TargetActionBudget] @dataclass(slots=True, frozen=True) -class TargetActionPromotion: - """Текущая настройка цены целевого действия.""" +class TargetActionGetBidsResult(SerializableModel): + """Ответ GET /cpxpromo/1/getBids/{itemId}.""" + + action_type_id: int + selected_type: str + auto: TargetActionAutoBids | None = None + manual: TargetActionManualBids | None = None + + +@dataclass(slots=True, frozen=True) +class TargetActionPromotion(SerializableModel): + """Текущая настройка цены целевого действия по объявлению.""" + + item_id: int + action_type_id: int + auto: TargetActionAutoPromotion | None = None + manual: TargetActionManualPromotion | None = None + + +@dataclass(slots=True, frozen=True) +class TargetActionAutoPromotion(SerializableModel): + """Текущий auto-budget по объявлению.""" - item_id: int | None - action_type_id: int | None - is_auto: bool | None - bid_penny: int | None budget_penny: int | None budget_type: str | None - available_bids: list[TargetActionBid] - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class TargetActionPromotionsResult: - """Набор текущих настроек цены целевого действия.""" +class TargetActionManualPromotion(SerializableModel): + """Текущая manual-настройка по объявлению.""" + + bid_penny: int | None + limit_penny: int | None + + +@dataclass(slots=True, frozen=True) +class TargetActionPromotionsByItemIdsResult(SerializableModel): + """Ответ POST /cpxpromo/1/getPromotionsByItemIds.""" items: list[TargetActionPromotion] - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -513,7 +583,7 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class AutostrategyBudgetPoint: +class AutostrategyBudgetPoint(SerializableModel): """Оценка бюджета автокампании.""" total: int | None @@ -523,11 +593,10 @@ class AutostrategyBudgetPoint: calls_to: int | None views_from: int | None views_to: int | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class AutostrategyPriceRange: +class AutostrategyPriceRange(SerializableModel): """Ценовой диапазон бюджета автокампании.""" price_from: int | None @@ -537,96 +606,194 @@ class AutostrategyPriceRange: calls_to: int | None views_from: int | None views_to: int | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class AutostrategyBudget: +class AutostrategyBudget(SerializableModel): """Расчет бюджета автокампании.""" - budget_id: str | None + calc_id: int | None recommended: AutostrategyBudgetPoint | None minimal: AutostrategyBudgetPoint | None maximal: AutostrategyBudgetPoint | None price_ranges: list[AutostrategyPriceRange] - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) class CreateAutostrategyBudgetRequest: """Запрос расчета бюджета кампании.""" - payload: Mapping[str, object] + campaign_type: str + start_time: datetime | None = None + finish_time: datetime | None = None + items: list[int] | None = None def to_payload(self) -> dict[str, object]: """Сериализует запрос расчета бюджета.""" - return dict(self.payload) + payload: dict[str, object] = {"campaignType": self.campaign_type} + if self.start_time is not None: + payload["startTime"] = self.start_time.isoformat() + if self.finish_time is not None: + payload["finishTime"] = self.finish_time.isoformat() + if self.items is not None: + payload["items"] = list(self.items) + return payload @dataclass(slots=True, frozen=True) -class CampaignActionResult: +class CampaignActionResult(SerializableModel): """Результат операции с автокампанией.""" - campaign_id: int | None - status: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + campaign: CampaignInfo | None @dataclass(slots=True, frozen=True) -class CampaignInfo: +class CampaignInfo(SerializableModel): """Информация об автокампании.""" campaign_id: int | None campaign_type: str | None - status: str | None budget: int | None balance: int | None + create_time: datetime | None + description: str | None + finish_time: datetime | None + items_count: int | None + start_time: datetime | None + status_id: int | None title: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + update_time: datetime | None + user_id: int | None + version: int | None + + +@dataclass(slots=True, frozen=True) +class CampaignForecastRange(SerializableModel): + """Диапазон прогноза кампании.""" + + from_value: int | None + to_value: int | None @dataclass(slots=True, frozen=True) -class CampaignsResult: +class CampaignForecast(SerializableModel): + """Прогноз кампании автостратегии.""" + + calls: CampaignForecastRange | None + views: CampaignForecastRange | None + + +@dataclass(slots=True, frozen=True) +class CampaignItem(SerializableModel): + """Объявление внутри автокампании.""" + + item_id: int | None + is_active: bool | None + + +@dataclass(slots=True, frozen=True) +class CampaignDetailsResult(SerializableModel): + """Полный ответ ручки информации о кампании.""" + + campaign: CampaignInfo | None + forecast: CampaignForecast | None + items: list[CampaignItem] + + +@dataclass(slots=True, frozen=True) +class CampaignsResult(SerializableModel): """Список автокампаний.""" items: list[CampaignInfo] - raw_payload: Mapping[str, object] = field(default_factory=dict) + total_count: int | None = None @dataclass(slots=True, frozen=True) -class AutostrategyStat: +class AutostrategyStat(SerializableModel): """Статистика автокампании.""" - campaign_id: int | None - views: int | None - contacts: int | None - spend: int | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + items: list[AutostrategyStatItem] + totals: AutostrategyStatTotals | None @dataclass(slots=True, frozen=True) class CreateAutostrategyCampaignRequest: """Запрос создания автокампании.""" - payload: Mapping[str, object] + campaign_type: str + title: str + budget: int | None = None + budget_bonus: int | None = None + budget_real: int | None = None + calc_id: int | None = None + description: str | None = None + finish_time: datetime | None = None + items: list[int] | None = None + start_time: datetime | None = None def to_payload(self) -> dict[str, object]: """Сериализует запрос создания кампании.""" - return dict(self.payload) + payload: dict[str, object] = { + "campaignType": self.campaign_type, + "title": self.title, + } + if self.budget is not None: + payload["budget"] = self.budget + if self.budget_bonus is not None: + payload["budgetBonus"] = self.budget_bonus + if self.budget_real is not None: + payload["budgetReal"] = self.budget_real + if self.calc_id is not None: + payload["calcId"] = self.calc_id + if self.description is not None: + payload["description"] = self.description + if self.finish_time is not None: + payload["finishTime"] = self.finish_time.isoformat() + if self.items is not None: + payload["items"] = list(self.items) + if self.start_time is not None: + payload["startTime"] = self.start_time.isoformat() + return payload @dataclass(slots=True, frozen=True) class UpdateAutostrategyCampaignRequest: """Запрос редактирования автокампании.""" - payload: Mapping[str, object] + campaign_id: int + version: int + budget: int | None = None + calc_id: int | None = None + description: str | None = None + finish_time: datetime | None = None + items: list[int] | None = None + start_time: datetime | None = None + title: str | None = None def to_payload(self) -> dict[str, object]: """Сериализует запрос редактирования кампании.""" - return dict(self.payload) + payload: dict[str, object] = { + "campaignId": self.campaign_id, + "version": self.version, + } + if self.budget is not None: + payload["budget"] = self.budget + if self.calc_id is not None: + payload["calcId"] = self.calc_id + if self.description is not None: + payload["description"] = self.description + if self.finish_time is not None: + payload["finishTime"] = self.finish_time.isoformat() + if self.items is not None: + payload["items"] = list(self.items) + if self.start_time is not None: + payload["startTime"] = self.start_time.isoformat() + if self.title is not None: + payload["title"] = self.title + return payload @dataclass(slots=True, frozen=True) @@ -646,23 +813,83 @@ class StopAutostrategyCampaignRequest: """Запрос остановки автокампании.""" campaign_id: int + version: int def to_payload(self) -> dict[str, object]: """Сериализует запрос остановки кампании.""" - return {"campaignId": self.campaign_id} + return {"campaignId": self.campaign_id, "version": self.version} + + +@dataclass(slots=True, frozen=True) +class CampaignUpdateTimeFilter: + """Фильтр кампаний по времени обновления.""" + + from_time: datetime | None = None + to_time: datetime | None = None + + def to_payload(self) -> dict[str, object]: + """Сериализует фильтр по времени обновления.""" + + payload: dict[str, object] = {} + if self.from_time is not None: + payload["from"] = self.from_time.isoformat() + if self.to_time is not None: + payload["to"] = self.to_time.isoformat() + return payload + + +@dataclass(slots=True, frozen=True) +class CampaignListFilter: + """Фильтр списка кампаний.""" + + by_update_time: CampaignUpdateTimeFilter | None = None + + def to_payload(self) -> dict[str, object]: + """Сериализует фильтр списка кампаний.""" + + payload: dict[str, object] = {} + if self.by_update_time is not None: + payload["byUpdateTime"] = self.by_update_time.to_payload() + return payload + + +@dataclass(slots=True, frozen=True) +class CampaignOrderBy: + """Параметры сортировки списка кампаний.""" + + column: str + direction: str + + def to_payload(self) -> dict[str, object]: + """Сериализует сортировку списка кампаний.""" + + return {"column": self.column, "direction": self.direction} @dataclass(slots=True, frozen=True) class ListAutostrategyCampaignsRequest: """Запрос списка автокампаний.""" - payload: Mapping[str, object] = field(default_factory=dict) + limit: int + offset: int | None = None + status_id: list[int] | None = None + order_by: list[CampaignOrderBy] | None = None + filter: CampaignListFilter | None = None def to_payload(self) -> dict[str, object]: """Сериализует запрос списка кампаний.""" - return dict(self.payload) + payload: dict[str, object] = {"limit": self.limit} + if self.offset is not None: + payload["offset"] = self.offset + if self.status_id is not None: + payload["statusId"] = list(self.status_id) + if self.order_by is not None: + payload["orderBy"] = [item.to_payload() for item in self.order_by] + if self.filter is not None: + payload["filter"] = self.filter.to_payload() + return payload @dataclass(slots=True, frozen=True) @@ -675,3 +902,22 @@ def to_payload(self) -> dict[str, object]: """Сериализует запрос статистики кампании.""" return {"campaignId": self.campaign_id} + + +@dataclass(slots=True, frozen=True) +class AutostrategyStatItem(SerializableModel): + """Статистика кампании за день.""" + + date: datetime | None + calls: int | None + views: int | None + calls_forecast: CampaignForecastRange | None = None + views_forecast: CampaignForecastRange | None = None + + +@dataclass(slots=True, frozen=True) +class AutostrategyStatTotals(SerializableModel): + """Суммарная статистика кампании.""" + + calls: int | None + views: int | None diff --git a/avito/ratings/__init__.py b/avito/ratings/__init__.py index 6ece590..d389823 100644 --- a/avito/ratings/__init__.py +++ b/avito/ratings/__init__.py @@ -1,10 +1,9 @@ """Пакет ratings.""" -from avito.ratings.domain import DomainObject, RatingProfile, Review, ReviewAnswer +from avito.ratings.domain import RatingProfile, Review, ReviewAnswer from avito.ratings.models import RatingProfileInfo, ReviewAnswerInfo, ReviewInfo, ReviewsResult __all__ = ( - "DomainObject", "RatingProfile", "RatingProfileInfo", "Review", diff --git a/avito/ratings/client.py b/avito/ratings/client.py index 427c813..e37601f 100644 --- a/avito/ratings/client.py +++ b/avito/ratings/client.py @@ -2,12 +2,17 @@ from __future__ import annotations -from collections.abc import Mapping from dataclasses import dataclass from avito.core import RequestContext, Transport from avito.ratings.mappers import map_rating_profile, map_review_answer, map_reviews -from avito.ratings.models import JsonRequest, RatingProfileInfo, ReviewAnswerInfo, ReviewsResult +from avito.ratings.models import ( + CreateReviewAnswerRequest, + RatingProfileInfo, + ReviewAnswerInfo, + ReviewsQuery, + ReviewsResult, +) @dataclass(slots=True) @@ -16,7 +21,7 @@ class RatingsClient: transport: Transport - def create_review_answer_v1(self, request: JsonRequest) -> ReviewAnswerInfo: + def create_review_answer(self, request: CreateReviewAnswerRequest) -> ReviewAnswerInfo: payload = self.transport.request_json( "POST", "/ratings/v1/answers", @@ -25,7 +30,7 @@ def create_review_answer_v1(self, request: JsonRequest) -> ReviewAnswerInfo: ) return map_review_answer(payload) - def delete_review_answer_v1(self, *, answer_id: int | str) -> ReviewAnswerInfo: + def delete_review_answer(self, *, answer_id: int | str) -> ReviewAnswerInfo: payload = self.transport.request_json( "DELETE", f"/ratings/v1/answers/{answer_id}", @@ -33,7 +38,7 @@ def delete_review_answer_v1(self, *, answer_id: int | str) -> ReviewAnswerInfo: ) return map_review_answer(payload) - def get_ratings_info_v1(self) -> RatingProfileInfo: + def get_ratings_info(self) -> RatingProfileInfo: payload = self.transport.request_json( "GET", "/ratings/v1/info", @@ -41,11 +46,11 @@ def get_ratings_info_v1(self) -> RatingProfileInfo: ) return map_rating_profile(payload) - def list_reviews_v1(self, *, params: Mapping[str, object] | None = None) -> ReviewsResult: + def list_reviews(self, *, query: ReviewsQuery | None = None) -> ReviewsResult: payload = self.transport.request_json( "GET", "/ratings/v1/reviews", context=RequestContext("ratings.reviews.list"), - params=params, + params=query.to_params() if query is not None else None, ) return map_reviews(payload) diff --git a/avito/ratings/domain.py b/avito/ratings/domain.py index 847d7bf..71c784c 100644 --- a/avito/ratings/domain.py +++ b/avito/ratings/domain.py @@ -2,62 +2,61 @@ from __future__ import annotations -from collections.abc import Mapping from dataclasses import dataclass -from avito.core import Transport +from avito.core import ValidationError +from avito.core.domain import DomainObject from avito.ratings.client import RatingsClient -from avito.ratings.models import JsonRequest, RatingProfileInfo, ReviewAnswerInfo, ReviewsResult - - -@dataclass(slots=True, frozen=True) -class DomainObject: - """Базовый доменный объект раздела ratings.""" - - transport: Transport +from avito.ratings.models import ( + CreateReviewAnswerRequest, + RatingProfileInfo, + ReviewAnswerInfo, + ReviewsQuery, + ReviewsResult, +) @dataclass(slots=True, frozen=True) class Review(DomainObject): """Доменный объект отзывов.""" - resource_id: int | str | None = None user_id: int | str | None = None - def list_reviews_v1(self, *, params: Mapping[str, object] | None = None) -> ReviewsResult: - return RatingsClient(self.transport).list_reviews_v1(params=params) + def list(self, *, query: ReviewsQuery | None = None) -> ReviewsResult: + return RatingsClient(self.transport).list_reviews(query=query) @dataclass(slots=True, frozen=True) class ReviewAnswer(DomainObject): """Доменный объект ответов на отзывы.""" - resource_id: int | str | None = None + answer_id: int | str | None = None user_id: int | str | None = None - def create_review_answer_v1(self, *, payload: Mapping[str, object]) -> ReviewAnswerInfo: - return RatingsClient(self.transport).create_review_answer_v1(JsonRequest(payload)) + def create(self, *, review_id: int, text: str) -> ReviewAnswerInfo: + return RatingsClient(self.transport).create_review_answer( + CreateReviewAnswerRequest(review_id=review_id, text=text) + ) - def delete_review_answer_v1(self, *, answer_id: int | str | None = None) -> ReviewAnswerInfo: - return RatingsClient(self.transport).delete_review_answer_v1( + def delete(self, *, answer_id: int | str | None = None) -> ReviewAnswerInfo: + return RatingsClient(self.transport).delete_review_answer( answer_id=answer_id or self._require_answer_id() ) def _require_answer_id(self) -> str: - if self.resource_id is None: - raise ValueError("Для операции требуется `answer_id`.") - return str(self.resource_id) + if self.answer_id is None: + raise ValidationError("Для операции требуется `answer_id`.") + return str(self.answer_id) @dataclass(slots=True, frozen=True) class RatingProfile(DomainObject): """Доменный объект рейтингового профиля.""" - resource_id: int | str | None = None user_id: int | str | None = None - def get_ratings_info_v1(self) -> RatingProfileInfo: - return RatingsClient(self.transport).get_ratings_info_v1() + def get(self) -> RatingProfileInfo: + return RatingsClient(self.transport).get_ratings_info() -__all__ = ("DomainObject", "RatingProfile", "Review", "ReviewAnswer") +__all__ = ("RatingProfile", "Review", "ReviewAnswer") diff --git a/avito/ratings/enums.py b/avito/ratings/enums.py deleted file mode 100644 index c1725ee..0000000 --- a/avito/ratings/enums.py +++ /dev/null @@ -1 +0,0 @@ -"""Строковые enum и константы пакета ratings.""" diff --git a/avito/ratings/mappers.py b/avito/ratings/mappers.py index 1ab77a3..e784ef2 100644 --- a/avito/ratings/mappers.py +++ b/avito/ratings/mappers.py @@ -79,7 +79,6 @@ def map_review_answer(payload: object) -> ReviewAnswerInfo: answer_id=_str(data, "id"), created_at=_int(data, "createdAt"), success=_bool(data, "success"), - raw_payload=data, ) @@ -93,7 +92,6 @@ def map_rating_profile(payload: object) -> RatingProfileInfo: score=_float(rating, "score"), reviews_count=_int(rating, "reviewsCount"), reviews_with_score_count=_int(rating, "reviewsWithScoreCount"), - raw_payload=data, ) @@ -111,10 +109,8 @@ def map_reviews(payload: object) -> ReviewsResult: created_at=_int(item, "createdAt"), can_answer=_bool(item, "canAnswer"), used_in_score=_bool(item, "usedInScore"), - raw_payload=item, ) for item in _list(data, "reviews", "items") ], total=_int(data, "total"), - raw_payload=data, ) diff --git a/avito/ratings/models.py b/avito/ratings/models.py index 04487bd..b9a15cd 100644 --- a/avito/ratings/models.py +++ b/avito/ratings/models.py @@ -2,24 +2,41 @@ from __future__ import annotations -from collections.abc import Mapping -from dataclasses import dataclass, field +from dataclasses import dataclass + +from avito.core.serialization import SerializableModel + + +@dataclass(slots=True, frozen=True) +class ReviewsQuery: + """Query-параметры списка отзывов.""" + + page: int | None = None + + def to_params(self) -> dict[str, int]: + """Сериализует query-параметры списка отзывов.""" + + params: dict[str, int] = {} + if self.page is not None: + params["page"] = self.page + return params @dataclass(slots=True, frozen=True) -class JsonRequest: - """Типизированная обертка над JSON payload запроса.""" +class CreateReviewAnswerRequest: + """Запрос создания ответа на отзыв.""" - payload: Mapping[str, object] + review_id: int + text: str def to_payload(self) -> dict[str, object]: - """Сериализует payload запроса.""" + """Сериализует запрос создания ответа.""" - return dict(self.payload) + return {"reviewId": self.review_id, "text": self.text} @dataclass(slots=True, frozen=True) -class ReviewInfo: +class ReviewInfo(SerializableModel): """Информация об отзыве пользователя.""" review_id: str | None @@ -29,34 +46,30 @@ class ReviewInfo: created_at: int | None can_answer: bool | None used_in_score: bool | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class ReviewsResult: +class ReviewsResult(SerializableModel): """Список отзывов пользователя.""" items: list[ReviewInfo] total: int | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class ReviewAnswerInfo: +class ReviewAnswerInfo(SerializableModel): """Информация об ответе на отзыв.""" answer_id: str | None = None created_at: int | None = None success: bool | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class RatingProfileInfo: +class RatingProfileInfo(SerializableModel): """Информация о рейтинговом профиле.""" is_enabled: bool score: float | None = None reviews_count: int | None = None reviews_with_score_count: int | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) diff --git a/avito/realty/__init__.py b/avito/realty/__init__.py index 3e97dbd..65d9ad7 100644 --- a/avito/realty/__init__.py +++ b/avito/realty/__init__.py @@ -1,7 +1,6 @@ """Пакет realty.""" from avito.realty.domain import ( - DomainObject, RealtyAnalyticsReport, RealtyBooking, RealtyListing, @@ -10,20 +9,33 @@ from avito.realty.models import ( RealtyActionResult, RealtyAnalyticsInfo, + RealtyBaseParamsUpdateRequest, RealtyBookingInfo, + RealtyBookingsQuery, RealtyBookingsResult, + RealtyBookingsUpdateRequest, + RealtyInterval, + RealtyIntervalsRequest, RealtyMarketPriceInfo, + RealtyPricePeriod, + RealtyPricesUpdateRequest, ) __all__ = ( - "DomainObject", "RealtyActionResult", "RealtyAnalyticsInfo", "RealtyAnalyticsReport", + "RealtyBaseParamsUpdateRequest", "RealtyBooking", "RealtyBookingInfo", + "RealtyBookingsQuery", "RealtyBookingsResult", + "RealtyBookingsUpdateRequest", + "RealtyInterval", + "RealtyIntervalsRequest", "RealtyListing", "RealtyMarketPriceInfo", + "RealtyPricePeriod", "RealtyPricing", + "RealtyPricesUpdateRequest", ) diff --git a/avito/realty/client.py b/avito/realty/client.py index 260809f..5cfb2d1 100644 --- a/avito/realty/client.py +++ b/avito/realty/client.py @@ -7,11 +7,15 @@ from avito.core import RequestContext, Transport from avito.realty.mappers import map_action, map_analytics_report, map_bookings, map_market_price from avito.realty.models import ( - JsonRequest, RealtyActionResult, RealtyAnalyticsInfo, + RealtyBaseParamsUpdateRequest, + RealtyBookingsQuery, RealtyBookingsResult, + RealtyBookingsUpdateRequest, + RealtyIntervalsRequest, RealtyMarketPriceInfo, + RealtyPricesUpdateRequest, ) @@ -22,7 +26,7 @@ class ShortTermRentClient: transport: Transport def update_bookings_info( - self, *, user_id: int | str, item_id: int | str, request: JsonRequest + self, *, user_id: int | str, item_id: int | str, request: RealtyBookingsUpdateRequest ) -> RealtyActionResult: payload = self.transport.request_json( "POST", @@ -33,17 +37,18 @@ def update_bookings_info( return map_action(payload) def list_realty_bookings( - self, *, user_id: int | str, item_id: int | str + self, *, user_id: int | str, item_id: int | str, query: RealtyBookingsQuery ) -> RealtyBookingsResult: payload = self.transport.request_json( "GET", f"/realty/v1/accounts/{user_id}/items/{item_id}/bookings", context=RequestContext("realty.bookings.list"), + params=query.to_params(), ) return map_bookings(payload) def update_realty_prices( - self, *, user_id: int | str, item_id: int | str, request: JsonRequest + self, *, user_id: int | str, item_id: int | str, request: RealtyPricesUpdateRequest ) -> RealtyActionResult: payload = self.transport.request_json( "POST", @@ -53,7 +58,7 @@ def update_realty_prices( ) return map_action(payload) - def get_intervals(self, request: JsonRequest) -> RealtyActionResult: + def get_intervals(self, request: RealtyIntervalsRequest) -> RealtyActionResult: payload = self.transport.request_json( "POST", "/realty/v1/items/intervals", @@ -62,7 +67,9 @@ def get_intervals(self, request: JsonRequest) -> RealtyActionResult: ) return map_action(payload) - def update_base_params(self, *, item_id: int | str, request: JsonRequest) -> RealtyActionResult: + def update_base_params( + self, *, item_id: int | str, request: RealtyBaseParamsUpdateRequest + ) -> RealtyActionResult: payload = self.transport.request_json( "POST", f"/realty/v1/items/{item_id}/base", @@ -78,7 +85,7 @@ class RealtyAnalyticsClient: transport: Transport - def get_market_price_correspondence_v1( + def get_market_price_correspondence( self, *, item_id: int | str, price: int | str ) -> RealtyMarketPriceInfo: payload = self.transport.request_json( diff --git a/avito/realty/domain.py b/avito/realty/domain.py index db264d8..3604936 100644 --- a/avito/realty/domain.py +++ b/avito/realty/domain.py @@ -2,90 +2,106 @@ from __future__ import annotations -from collections.abc import Mapping from dataclasses import dataclass -from avito.core import Transport +from avito.core import ValidationError +from avito.core.domain import DomainObject from avito.realty.client import RealtyAnalyticsClient, ShortTermRentClient from avito.realty.models import ( - JsonRequest, RealtyActionResult, RealtyAnalyticsInfo, + RealtyBaseParamsUpdateRequest, + RealtyBookingsQuery, RealtyBookingsResult, + RealtyBookingsUpdateRequest, + RealtyInterval, + RealtyIntervalsRequest, RealtyMarketPriceInfo, + RealtyPricesUpdateRequest, ) -@dataclass(slots=True, frozen=True) -class DomainObject: - """Базовый доменный объект раздела realty.""" - - transport: Transport - - @dataclass(slots=True, frozen=True) class RealtyListing(DomainObject): """Доменный объект объявления краткосрочной аренды.""" - resource_id: int | str | None = None + item_id: int | str | None = None user_id: int | str | None = None - def get_intervals(self, *, payload: Mapping[str, object]) -> RealtyActionResult: - return ShortTermRentClient(self.transport).get_intervals(JsonRequest(payload)) + def get_intervals( + self, + *, + intervals: list[RealtyInterval], + item_id: int | None = None, + ) -> RealtyActionResult: + return ShortTermRentClient(self.transport).get_intervals( + RealtyIntervalsRequest( + item_id=item_id or int(self._require_item_id()), + intervals=intervals, + ) + ) def update_base_params( - self, *, payload: Mapping[str, object], item_id: int | str | None = None + self, *, request: RealtyBaseParamsUpdateRequest, item_id: int | str | None = None ) -> RealtyActionResult: return ShortTermRentClient(self.transport).update_base_params( item_id=item_id or self._require_item_id(), - request=JsonRequest(payload), + request=request, ) def _require_item_id(self) -> str: - if self.resource_id is None: - raise ValueError("Для операции требуется `item_id`.") - return str(self.resource_id) + if self.item_id is None: + raise ValidationError("Для операции требуется `item_id`.") + return str(self.item_id) @dataclass(slots=True, frozen=True) class RealtyBooking(DomainObject): """Доменный объект бронирований недвижимости.""" - resource_id: int | str | None = None + item_id: int | str | None = None user_id: int | str | None = None def update_bookings_info( self, *, - payload: Mapping[str, object], + request: RealtyBookingsUpdateRequest, user_id: int | str | None = None, item_id: int | str | None = None, ) -> RealtyActionResult: return ShortTermRentClient(self.transport).update_bookings_info( user_id=user_id or self._require_user_id(), item_id=item_id or self._require_item_id(), - request=JsonRequest(payload), + request=request, ) def list_realty_bookings( self, *, + date_start: str, + date_end: str, + with_unpaid: bool | None = None, user_id: int | str | None = None, item_id: int | str | None = None, ) -> RealtyBookingsResult: return ShortTermRentClient(self.transport).list_realty_bookings( user_id=user_id or self._require_user_id(), item_id=item_id or self._require_item_id(), + query=RealtyBookingsQuery( + date_start=date_start, + date_end=date_end, + with_unpaid=with_unpaid, + ), ) def _require_item_id(self) -> str: - if self.resource_id is None: - raise ValueError("Для операции требуется `item_id`.") - return str(self.resource_id) + if self.item_id is None: + raise ValidationError("Для операции требуется `item_id`.") + return str(self.item_id) def _require_user_id(self) -> str: if self.user_id is None: - raise ValueError("Для операции требуется `user_id`.") + raise ValidationError("Для операции требуется `user_id`.") return str(self.user_id) @@ -93,30 +109,30 @@ def _require_user_id(self) -> str: class RealtyPricing(DomainObject): """Доменный объект цен краткосрочной аренды.""" - resource_id: int | str | None = None + item_id: int | str | None = None user_id: int | str | None = None def update_realty_prices( self, *, - payload: Mapping[str, object], + request: RealtyPricesUpdateRequest, user_id: int | str | None = None, item_id: int | str | None = None, ) -> RealtyActionResult: return ShortTermRentClient(self.transport).update_realty_prices( user_id=user_id or self._require_user_id(), item_id=item_id or self._require_item_id(), - request=JsonRequest(payload), + request=request, ) def _require_item_id(self) -> str: - if self.resource_id is None: - raise ValueError("Для операции требуется `item_id`.") - return str(self.resource_id) + if self.item_id is None: + raise ValidationError("Для операции требуется `item_id`.") + return str(self.item_id) def _require_user_id(self) -> str: if self.user_id is None: - raise ValueError("Для операции требуется `user_id`.") + raise ValidationError("Для операции требуется `user_id`.") return str(self.user_id) @@ -124,16 +140,16 @@ def _require_user_id(self) -> str: class RealtyAnalyticsReport(DomainObject): """Доменный объект аналитики по недвижимости.""" - resource_id: int | str | None = None + item_id: int | str | None = None user_id: int | str | None = None - def get_market_price_correspondence_v1( + def get_market_price_correspondence( self, *, item_id: int | str | None = None, price: int | str, ) -> RealtyMarketPriceInfo: - return RealtyAnalyticsClient(self.transport).get_market_price_correspondence_v1( + return RealtyAnalyticsClient(self.transport).get_market_price_correspondence( item_id=item_id or self._require_item_id(), price=price, ) @@ -144,13 +160,12 @@ def get_report_for_classified(self, *, item_id: int | str | None = None) -> Real ) def _require_item_id(self) -> str: - if self.resource_id is None: - raise ValueError("Для операции требуется `item_id`.") - return str(self.resource_id) + if self.item_id is None: + raise ValidationError("Для операции требуется `item_id`.") + return str(self.item_id) __all__ = ( - "DomainObject", "RealtyAnalyticsReport", "RealtyBooking", "RealtyListing", diff --git a/avito/realty/enums.py b/avito/realty/enums.py deleted file mode 100644 index 50ae684..0000000 --- a/avito/realty/enums.py +++ /dev/null @@ -1 +0,0 @@ -"""Строковые enum и константы пакета realty.""" diff --git a/avito/realty/mappers.py b/avito/realty/mappers.py index 59931fa..cca2ab1 100644 --- a/avito/realty/mappers.py +++ b/avito/realty/mappers.py @@ -9,7 +9,9 @@ from avito.realty.models import ( RealtyActionResult, RealtyAnalyticsInfo, + RealtyBookingContact, RealtyBookingInfo, + RealtyBookingSafeDeposit, RealtyBookingsResult, RealtyMarketPriceInfo, ) @@ -66,7 +68,6 @@ def map_action(payload: object) -> RealtyActionResult: return RealtyActionResult( success=_str(data, "result") == "success" or bool(data.get("success", False)), status=_str(data, "result", "status"), - raw_payload=data, ) @@ -77,19 +78,34 @@ def map_bookings(payload: object) -> RealtyBookingsResult: return RealtyBookingsResult( items=[ RealtyBookingInfo( - booking_id=_str(item, "avito_booking_id", "id"), - status=_str(item, "status"), + booking_id=_int(item, "avito_booking_id", "id"), + base_price=_int(item, "base_price"), check_in=_str(item, "check_in"), check_out=_str(item, "check_out"), + contact=( + RealtyBookingContact( + name=_str(_mapping(item, "contact"), "name"), + email=_str(_mapping(item, "contact"), "email"), + phone=_str(_mapping(item, "contact"), "phone"), + ) + if isinstance(item.get("contact"), Mapping) + else None + ), guest_count=_int(item, "guest_count"), - base_price=_int(item, "base_price"), - guest_name=_str(_mapping(item, "contact"), "name"), - guest_email=_str(_mapping(item, "contact"), "email"), - raw_payload=item, + nights=_int(item, "nights"), + safe_deposit=( + RealtyBookingSafeDeposit( + owner_amount=_int(_mapping(item, "safe_deposit"), "owner_amount"), + tax=_int(_mapping(item, "safe_deposit"), "tax"), + total_amount=_int(_mapping(item, "safe_deposit"), "total_amount"), + ) + if isinstance(item.get("safe_deposit"), Mapping) + else None + ), + status=_str(item, "status"), ) for item in _list(data, "bookings", "items") ], - raw_payload=data, ) @@ -100,7 +116,6 @@ def map_market_price(payload: object) -> RealtyMarketPriceInfo: return RealtyMarketPriceInfo( correspondence=_str(data, "correspondence"), error_message=_str(_mapping(data, "error"), "message"), - raw_payload=data, ) @@ -115,5 +130,4 @@ def map_analytics_report(payload: object) -> RealtyAnalyticsInfo: success=bool(success_data) or _str(_mapping(data, "result"), "result") == "success", report_link=_str(success_data, "reportLink"), error_message=_str(errors, "message"), - raw_payload=data, ) diff --git a/avito/realty/models.py b/avito/realty/models.py index 90d859f..0dff6f1 100644 --- a/avito/realty/models.py +++ b/avito/realty/models.py @@ -2,68 +2,168 @@ from __future__ import annotations -from collections.abc import Mapping -from dataclasses import dataclass, field +from dataclasses import dataclass + +from avito.core.serialization import SerializableModel + + +@dataclass(slots=True, frozen=True) +class RealtyActionResult(SerializableModel): + """Результат mutation-операции по недвижимости.""" + + success: bool + status: str | None = None @dataclass(slots=True, frozen=True) -class JsonRequest: - """Типизированная обертка над JSON payload запроса.""" +class RealtyBookingsUpdateRequest: + """Запрос обновления занятости по объекту.""" - payload: Mapping[str, object] + blocked_dates: list[str] def to_payload(self) -> dict[str, object]: - """Сериализует payload запроса.""" + """Сериализует JSON payload запроса бронирований.""" - return dict(self.payload) + return {"blockedDates": list(self.blocked_dates)} @dataclass(slots=True, frozen=True) -class RealtyActionResult: - """Результат mutation-операции по недвижимости.""" +class RealtyBookingSafeDeposit(SerializableModel): + """Информация о предоплате по бронированию.""" + + owner_amount: int | None + tax: int | None + total_amount: int | None - success: bool - status: str | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) + +@dataclass(slots=True, frozen=True) +class RealtyBookingContact(SerializableModel): + """Контактные данные гостя.""" + + name: str | None + email: str | None + phone: str | None @dataclass(slots=True, frozen=True) -class RealtyBookingInfo: +class RealtyBookingInfo(SerializableModel): """Информация о бронировании объекта недвижимости.""" - booking_id: str | None - status: str | None + booking_id: int | None + base_price: int | None check_in: str | None check_out: str | None + contact: RealtyBookingContact | None guest_count: int | None - base_price: int | None - guest_name: str | None - guest_email: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + nights: int | None + safe_deposit: RealtyBookingSafeDeposit | None + status: str | None @dataclass(slots=True, frozen=True) -class RealtyBookingsResult: +class RealtyBookingsResult(SerializableModel): """Список бронирований по объявлению.""" items: list[RealtyBookingInfo] - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class RealtyMarketPriceInfo: +class RealtyBookingsQuery: + """Query-параметры запроса бронирований.""" + + date_start: str + date_end: str + with_unpaid: bool | None = None + + def to_params(self) -> dict[str, str]: + """Сериализует query-параметры запроса бронирований.""" + + params = {"date_start": self.date_start, "date_end": self.date_end} + if self.with_unpaid is not None: + params["with_unpaid"] = "true" if self.with_unpaid else "false" + return params + + +@dataclass(slots=True, frozen=True) +class RealtyPricePeriod: + """Период с ценой в запросе обновления цен.""" + + date_from: str + price: int + + def to_payload(self) -> dict[str, object]: + """Сериализует период с ценой.""" + + return {"dateFrom": self.date_from, "price": self.price} + + +@dataclass(slots=True, frozen=True) +class RealtyPricesUpdateRequest: + """Запрос обновления цен по объекту.""" + + periods: list[RealtyPricePeriod] + + def to_payload(self) -> dict[str, object]: + """Сериализует JSON payload запроса цен.""" + + return {"periods": [period.to_payload() for period in self.periods]} + + +@dataclass(slots=True, frozen=True) +class RealtyInterval: + """Интервал доступности объекта.""" + + date: str + available: bool + + def to_payload(self) -> dict[str, object]: + """Сериализует интервал доступности.""" + + return {"date": self.date, "available": self.available} + + +@dataclass(slots=True, frozen=True) +class RealtyIntervalsRequest: + """Запрос заполнения интервалов доступности.""" + + item_id: int + intervals: list[RealtyInterval] + + def to_payload(self) -> dict[str, object]: + """Сериализует JSON payload запроса интервалов.""" + + return { + "itemId": self.item_id, + "intervals": [interval.to_payload() for interval in self.intervals], + } + + +@dataclass(slots=True, frozen=True) +class RealtyBaseParamsUpdateRequest: + """Запрос обновления базовых параметров объекта.""" + + min_stay_days: int + + def to_payload(self) -> dict[str, object]: + """Сериализует JSON payload запроса базовых параметров.""" + + return {"minStayDays": self.min_stay_days} + + +@dataclass(slots=True, frozen=True) +class RealtyMarketPriceInfo(SerializableModel): """Соответствие цены рыночной стоимости.""" correspondence: str | None error_message: str | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class RealtyAnalyticsInfo: +class RealtyAnalyticsInfo(SerializableModel): """Информация об аналитическом отчете по недвижимости.""" success: bool report_link: str | None = None error_message: str | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) + + diff --git a/avito/settings.py b/avito/settings.py index cdc2da3..84bfe25 100644 --- a/avito/settings.py +++ b/avito/settings.py @@ -1,7 +1,15 @@ -"""Совместимые импорты конфигурации SDK.""" +"""Устаревший модуль совместимости — импортируйте из `avito.config` напрямую.""" + +import warnings from avito.config import AvitoSettings from avito.core.retries import RetryPolicy from avito.core.types import ApiTimeouts +warnings.warn( + "avito.settings устарел и будет удалён. Используйте `avito.config.AvitoSettings` напрямую.", + DeprecationWarning, + stacklevel=2, +) + __all__ = ("ApiTimeouts", "AvitoSettings", "RetryPolicy") diff --git a/avito/tariffs/__init__.py b/avito/tariffs/__init__.py index e756ddc..cee4ad9 100644 --- a/avito/tariffs/__init__.py +++ b/avito/tariffs/__init__.py @@ -1,6 +1,6 @@ """Пакет tariffs.""" -from avito.tariffs.domain import DomainObject, Tariff +from avito.tariffs.domain import Tariff from avito.tariffs.models import TariffContractInfo, TariffInfo -__all__ = ("DomainObject", "Tariff", "TariffContractInfo", "TariffInfo") +__all__ = ("Tariff", "TariffContractInfo", "TariffInfo") diff --git a/avito/tariffs/client.py b/avito/tariffs/client.py index 4833483..f06af37 100644 --- a/avito/tariffs/client.py +++ b/avito/tariffs/client.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from avito.core import RequestContext, Transport +from avito.core.mapping import request_public_model from avito.tariffs.mappers import map_tariff_info from avito.tariffs.models import TariffInfo @@ -16,9 +17,10 @@ class TariffsClient: transport: Transport def get_tariff_info(self) -> TariffInfo: - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", "/tariff/info/1", context=RequestContext("tariffs.info.get"), + mapper=map_tariff_info, ) - return map_tariff_info(payload) diff --git a/avito/tariffs/domain.py b/avito/tariffs/domain.py index 855b31b..0f73a30 100644 --- a/avito/tariffs/domain.py +++ b/avito/tariffs/domain.py @@ -4,27 +4,21 @@ from dataclasses import dataclass -from avito.core import Transport +from avito.core.domain import DomainObject from avito.tariffs.client import TariffsClient from avito.tariffs.models import TariffInfo -@dataclass(slots=True, frozen=True) -class DomainObject: - """Базовый доменный объект раздела tariffs.""" - - transport: Transport - - @dataclass(slots=True, frozen=True) class Tariff(DomainObject): """Доменный объект тарифа.""" - resource_id: int | str | None = None - user_id: int | str | None = None + tariff_id: int | str | None = None def get_tariff_info(self) -> TariffInfo: + """Получает информацию о тарифе аккаунта.""" + return TariffsClient(self.transport).get_tariff_info() -__all__ = ("DomainObject", "Tariff") +__all__ = ("Tariff",) diff --git a/avito/tariffs/enums.py b/avito/tariffs/enums.py deleted file mode 100644 index 9bd2e13..0000000 --- a/avito/tariffs/enums.py +++ /dev/null @@ -1 +0,0 @@ -"""Строковые enum и константы пакета tariffs.""" diff --git a/avito/tariffs/mappers.py b/avito/tariffs/mappers.py index dbee0a4..f46362e 100644 --- a/avito/tariffs/mappers.py +++ b/avito/tariffs/mappers.py @@ -76,7 +76,6 @@ def _map_contract(payload: Payload) -> TariffContractInfo | None: price=_float(price, "price"), original_price=_float(price, "originalPrice"), packages_count=packages_count, - raw_payload=payload, ) @@ -87,5 +86,4 @@ def map_tariff_info(payload: object) -> TariffInfo: return TariffInfo( current=_map_contract(_mapping(data, "current")), scheduled=_map_contract(_mapping(data, "scheduled")), - raw_payload=data, ) diff --git a/avito/tariffs/models.py b/avito/tariffs/models.py index 6b6bbdf..85822df 100644 --- a/avito/tariffs/models.py +++ b/avito/tariffs/models.py @@ -2,12 +2,13 @@ from __future__ import annotations -from collections.abc import Mapping -from dataclasses import dataclass, field +from dataclasses import dataclass + +from avito.core.serialization import SerializableModel @dataclass(slots=True, frozen=True) -class TariffContractInfo: +class TariffContractInfo(SerializableModel): """Информация о текущем или запланированном тарифном контракте.""" level: str | None @@ -18,13 +19,11 @@ class TariffContractInfo: price: float | None original_price: float | None packages_count: int | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class TariffInfo: +class TariffInfo(SerializableModel): """Информация по текущему и запланированному тарифу.""" current: TariffContractInfo | None = None scheduled: TariffContractInfo | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index a93b8d7..0000000 --- a/docs/index.md +++ /dev/null @@ -1,100 +0,0 @@ -# Каталог API - -Описание API произведено в формате Swagger 3.0. Вы можете использовать данный файл для ознакомления с методами API, а также для генерации базового кода для работы с API на удобном для вас языке программирования с помощью утилиты Swagger Codegen или online сервиса Swagger Editor. - -## Типы авторизации - -Для использования данного API запрос должен быть авторизован. В данный момент API Авито использует следующие механизмы авторизации: - -- Персональная авторизация -- Авторизация для приложений - -## Иерархия Аккаунтов - -API для взаимодействия с иерархией аккаунтов в Авито - -## CPA-аукцион - -Методы для работы с сервисом CPA-аукционов. - -## Авторизация - -Получение и обновление авторизационных токенов для персональной авторизации и авторизации приложений - -## Автозагрузка - -Получение отчётов о публикации и редактировании объявлений через Автозагрузку - -## Автостратегия - -API для работы с автостратегией - -## Автотека - -## CallTracking[КТ] - -Методы для работы с сервисом колл-трекинга - -## CPA Авито - -Методы для работы с сервисом CPA - -## Настройка цены целевого действия - -Методы для работы с настройками цены целевого действия. - -## Доставка - -Методы API для партнеров для работы с API Логистики. - -## Объявления - -API для получение статистики по объявлениям, применения дополнительных услуг, а также просмотр статусов объявлений - -## Авито.Работа - -API для размещения, редактирования и снятия с публикации вакансии Авито Работа - -## Мессенджер - -API Мессенджера - набор методов для получения списка чатов пользователя на Авито, получения сообщений в чате, отправки сообщения в чат и др. Через API Мессенджера можно организовать интеграцию между мессенджером Авито и сторонней системой в обе стороны. В Товарах и Работе Messenger API доступен только на уровне подписки «Максимальный». В Услугах Messenger API доступен на уровнях подписки «Расширенный» и «Максимальный». - -## Управление заказами - -Методы для управления заказами на доставку. - -## Продвижение - -API для управления услугами продвижения по объявлениям. [Подробная документация](https://developers.avito.ru/api-catalog/promotion/documentation). - -## Рейтинги и отзывы - -API для работы с рейтингами и отзывами на Авито. - -## Аналитика по недвижимости - -Методы для работы с аналитикой по недвижимости - -## Рассылка скидок и спецпредложений в мессенджере (beta-version) - -API для рассылки скидок и спецпредложений покупателям, которые добавили объявления в избранное, получения информации о доступных для применения услуги объявлениях, информации об остатке количества рассылок в тарифе (если тариф подключен). - -## Управление остатками - -Методы для работы с остатками в объявлении. - -## Краткосрочная аренда - -Методы API для партнёров для работы с API краткосрочной аренды (STR) - -## Тарифы - -API для работы с тарифами в категории транспорт - -## TrxPromo - -Методы для работы с сервисом TrxPromo. - -## Информация о пользователе - -API для получения баланса кошелька пользователя, истории операций и инфорации об авторизованном пользователе diff --git a/docs/inventory.md b/docs/inventory.md index 0683996..264fbf9 100644 --- a/docs/inventory.md +++ b/docs/inventory.md @@ -1,6 +1,6 @@ # Инвентарь Swagger -Инвентарь фиксирует покрытие этапа 1 для всех операций из `docs/*.json` и служит источником истины для теста `tests/test_inventory.py`. +Инвентарь фиксирует покрытие этапа 1 для всех операций из `docs/*.json` и служит источником истины для развития публичного API SDK, доменных тестов и документации репозитория. - Всего swagger-документов: 23. - Всего операций: 204. diff --git a/docs/release.md b/docs/release.md deleted file mode 100644 index b311c7f..0000000 --- a/docs/release.md +++ /dev/null @@ -1,55 +0,0 @@ -# Релизная политика - -Этот документ фиксирует минимальные требования к changelog и релизному checklist перед публикацией новой версии SDK. - -## Политика changelog - -Файл `CHANGELOG.md` ведётся по принципу "что изменилось для пользователя SDK". - -Для каждой версии нужно явно фиксировать: - -- добавленные домены, методы и модели; -- breaking changes в публичном API; -- изменения в auth, retries, transport и обработке ошибок; -- изменения в документации и примерах, если они влияют на интеграцию; -- исправления регрессий и несовместимостей со swagger. - -Формат записи: - -1. Версия и дата выпуска. -2. `Добавлено` -3. `Изменено` -4. `Исправлено` -5. `Удалено`, если это применимо - -## Checklist релиза - -Перед релизом должны быть выполнены все пункты: - -1. `docs/inventory.md` синхронизирован с `docs/*.json`. -2. Все новые или изменённые публичные методы отражены в `README.md`. -3. Для новых endpoint-ов добавлены mapping/contract/regression-тесты. -4. Deprecated-методы явно помечены и не загрязняют основной API. -5. Выполнены команды: - -```bash -make check -``` - -6. Обновлён `CHANGELOG.md`. -7. Проверены docstring публичных сущностей, затронутых изменениями. -8. Проверено, что `debug_info()` не раскрывает секреты. -9. GitHub Actions workflow для Python `3.14` проходит на ветке с релизом. -10. Для публикации настроен GitHub secret `PYPI_API_TOKEN`. -11. Git tag релиза создан в формате `vX.Y.Z` и именно он определяет публикуемую версию пакета. - -## Запреты перед публикацией - -Релиз нельзя считать готовым, если выполняется хотя бы одно условие: - -- хотя бы один swagger-endpoint не сопоставлен inventory; -- публичный метод возвращает сырой JSON вместо модели SDK; -- `mypy` strict или эквивалентный профиль не проходит; -- релизная ветка не проходит CI на Python `3.14`; -- tag релиза не соответствует формату `vX.Y.Z`; -- сборка требует ручных локальных правок после запуска `poetry build`. diff --git a/tests/conftest.py b/tests/conftest.py index f96b4d8..333a5dc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,15 @@ import sys from pathlib import Path +import pytest + +from tests.fake_transport import FakeTransport + ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) + + +@pytest.fixture +def fake_transport() -> FakeTransport: + return FakeTransport() diff --git a/tests/contracts/test_client_contracts.py b/tests/contracts/test_client_contracts.py new file mode 100644 index 0000000..36431d0 --- /dev/null +++ b/tests/contracts/test_client_contracts.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +import httpx +import pytest + +from avito import AuthSettings, AvitoClient, AvitoSettings +from avito.accounts import Account, AccountHierarchy +from avito.ads import Ad, AdPromotion, AdStats, AutoloadArchive, AutoloadProfile, AutoloadReport +from avito.auth import AlternateTokenClient, AuthProvider, TokenClient +from avito.autoteka import ( + AutotekaMonitoring, + AutotekaReport, + AutotekaScoring, + AutotekaValuation, + AutotekaVehicle, +) +from avito.core import Transport +from avito.core.types import ApiTimeouts +from avito.cpa import CallTrackingCall, CpaArchive, CpaCall, CpaChat, CpaLead +from avito.jobs import Application, JobDictionary, JobWebhook, Resume, Vacancy +from avito.messenger import Chat, ChatMedia, ChatMessage, ChatWebhook, SpecialOfferCampaign +from avito.orders import DeliveryOrder, DeliveryTask, Order, OrderLabel, SandboxDelivery, Stock +from avito.promotion import ( + AutostrategyCampaign, + BbipPromotion, + CpaAuction, + PromotionOrder, + TargetActionPricing, + TrxPromotion, +) +from avito.ratings import RatingProfile, Review, ReviewAnswer +from avito.realty import RealtyAnalyticsReport, RealtyBooking, RealtyListing, RealtyPricing +from avito.tariffs import Tariff + + +def test_single_client_exposes_domain_factories() -> None: + client = AvitoClient( + AvitoSettings(auth=AuthSettings(client_id="client-id", client_secret="client-secret")) + ) + + assert isinstance(client.auth(), AuthProvider) + assert isinstance(client.account(1), Account) + assert isinstance(client.account_hierarchy(1), AccountHierarchy) + assert isinstance(client.ad(1), Ad) + assert isinstance(client.ad_stats(1), AdStats) + assert isinstance(client.ad_promotion(1), AdPromotion) + assert isinstance(client.autoload_profile(1), AutoloadProfile) + assert isinstance(client.autoload_report(1), AutoloadReport) + assert isinstance(client.autoload_archive(1), AutoloadArchive) + assert isinstance(client.chat("chat-1", user_id=1), Chat) + assert isinstance(client.chat_message("msg-1", chat_id="chat-1", user_id=1), ChatMessage) + assert isinstance(client.chat_webhook(), ChatWebhook) + assert isinstance(client.chat_media(user_id=1), ChatMedia) + assert isinstance(client.special_offer_campaign(1), SpecialOfferCampaign) + assert isinstance(client.promotion_order(1), PromotionOrder) + assert isinstance(client.bbip_promotion(1), BbipPromotion) + assert isinstance(client.trx_promotion(1), TrxPromotion) + assert isinstance(client.cpa_auction(1), CpaAuction) + assert isinstance(client.target_action_pricing(1), TargetActionPricing) + assert isinstance(client.autostrategy_campaign(1), AutostrategyCampaign) + assert isinstance(client.order(), Order) + assert isinstance(client.order_label(1), OrderLabel) + assert isinstance(client.delivery_order(), DeliveryOrder) + assert isinstance(client.sandbox_delivery(), SandboxDelivery) + assert isinstance(client.delivery_task(1), DeliveryTask) + assert isinstance(client.stock(), Stock) + assert isinstance(client.vacancy(1), Vacancy) + assert isinstance(client.application(), Application) + assert isinstance(client.resume(1), Resume) + assert isinstance(client.job_webhook(), JobWebhook) + assert isinstance(client.job_dictionary(1), JobDictionary) + assert isinstance(client.cpa_lead(), CpaLead) + assert isinstance(client.cpa_chat(1), CpaChat) + assert isinstance(client.cpa_call(), CpaCall) + assert isinstance(client.cpa_archive(1), CpaArchive) + assert isinstance(client.call_tracking_call(1), CallTrackingCall) + assert isinstance(client.autoteka_vehicle(1), AutotekaVehicle) + assert isinstance(client.autoteka_report(1), AutotekaReport) + assert isinstance(client.autoteka_monitoring(), AutotekaMonitoring) + assert isinstance(client.autoteka_scoring(1), AutotekaScoring) + assert isinstance(client.autoteka_valuation(), AutotekaValuation) + assert isinstance(client.realty_listing(1), RealtyListing) + assert isinstance(client.realty_booking(1), RealtyBooking) + assert isinstance(client.realty_pricing(1), RealtyPricing) + assert isinstance(client.realty_analytics_report(1), RealtyAnalyticsReport) + assert isinstance(client.review(), Review) + assert isinstance(client.review_answer(1), ReviewAnswer) + assert isinstance(client.rating_profile(), RatingProfile) + assert isinstance(client.tariff(1), Tariff) + + +def test_package_exports_auth_settings_as_public_config_contract() -> None: + assert AuthSettings.__name__ == "AuthSettings" + + +def test_removed_legacy_factory_names_are_absent() -> None: + client = AvitoClient( + AvitoSettings(auth=AuthSettings(client_id="client-id", client_secret="client-secret")) + ) + + with pytest.raises(AttributeError): + _ = client.autoload_legacy # type: ignore[attr-defined] + + with pytest.raises(AttributeError): + _ = client.cpa_legacy # type: ignore[attr-defined] + + +def test_debug_info_and_context_manager_do_not_leak_secrets() -> None: + transport_http_client = httpx.Client() + token_http_client = httpx.Client() + alternate_http_client = httpx.Client() + autoteka_http_client = httpx.Client() + + settings = AvitoSettings( + base_url="https://api.avito.ru", + auth=AuthSettings(client_id="client-id", client_secret="super-secret"), + ) + auth_provider = AuthProvider( + settings.auth, + token_client=TokenClient(settings.auth, client=token_http_client), + alternate_token_client=AlternateTokenClient(settings.auth, client=alternate_http_client), + autoteka_token_client=TokenClient(settings.auth, client=autoteka_http_client), + ) + client = AvitoClient(settings) + client.transport = Transport(settings, auth_provider=auth_provider, client=transport_http_client) + client.auth_provider = auth_provider + + info = client.debug_info() + assert info.requires_auth is True + assert "secret" not in repr(info).lower() + + with client as managed_client: + assert managed_client.debug_info().requires_auth is True + + assert transport_http_client.is_closed is True + assert token_http_client.is_closed is True + assert alternate_http_client.is_closed is True + assert autoteka_http_client.is_closed is True + + +def test_auth_token_clients_use_explicit_sdk_timeouts() -> None: + settings = AvitoSettings( + base_url="https://api.avito.ru", + timeouts=ApiTimeouts(connect=2.5, read=11.0, write=13.0, pool=3.0), + auth=AuthSettings(client_id="client-id", client_secret="super-secret"), + ) + + client = AvitoClient(settings) + token_timeout = client.auth_provider.token_flow().client.timeout + alternate_timeout = client.auth_provider.alternate_token_flow().client.timeout + autoteka_timeout = client.auth_provider.autoteka_token_client.client.timeout + + assert token_timeout.connect == 2.5 + assert token_timeout.read == 11.0 + assert token_timeout.write == 13.0 + assert token_timeout.pool == 3.0 + assert alternate_timeout.connect == 2.5 + assert alternate_timeout.read == 11.0 + assert alternate_timeout.write == 13.0 + assert alternate_timeout.pool == 3.0 + assert autoteka_timeout.connect == 2.5 + assert autoteka_timeout.read == 11.0 + assert autoteka_timeout.write == 13.0 + assert autoteka_timeout.pool == 3.0 + + client.close() diff --git a/tests/contracts/test_model_contracts.py b/tests/contracts/test_model_contracts.py new file mode 100644 index 0000000..b7159bc --- /dev/null +++ b/tests/contracts/test_model_contracts.py @@ -0,0 +1,292 @@ +from __future__ import annotations + +import importlib +import json +from dataclasses import is_dataclass +from datetime import datetime +from inspect import isclass + +import httpx + +from avito.accounts import Account +from avito.accounts.models import AccountProfile +from avito.ads import Ad, AdStats +from avito.ads.models import AccountSpendings, CallStats, Listing, ListingStats, SpendingRecord +from avito.autoteka.models import ( + CatalogField, + CatalogFieldValue, + CatalogResolveRequest, + CatalogResolveResult, + MonitoringBucketRequest, +) +from avito.core.serialization import SerializableModel +from avito.core.types import BinaryResponse +from avito.cpa.models import CallTrackingRecord, CpaAudioRecord +from avito.messenger.models import SendMessageRequest +from avito.orders.models import LabelPdfResult +from avito.promotion import BbipPromotion, PromotionOrder, PromotionService +from avito.promotion.models import ( + AutostrategyBudget, + AutostrategyStat, + AutostrategyStatItem, + AutostrategyStatTotals, + BbipItem, + CampaignActionResult, + CampaignListFilter, + CampaignOrderBy, + CampaignsResult, + CampaignUpdateTimeFilter, + CreateAutostrategyBudgetRequest, + ListAutostrategyCampaignsRequest, + PromotionForecast, + PromotionOrderInfo, +) +from avito.tariffs.models import TariffContractInfo, TariffInfo +from tests.helpers.transport import make_transport + +MODEL_MODULES = ( + "avito.accounts.models", + "avito.ads.models", + "avito.autoteka.models", + "avito.cpa.models", + "avito.jobs.models", + "avito.messenger.models", + "avito.orders.models", + "avito.promotion.models", + "avito.ratings.models", + "avito.realty.models", + "avito.tariffs.models", +) + + +def iter_model_classes() -> list[tuple[str, str, type[object]]]: + classes: list[tuple[str, str, type[object]]] = [] + for module_name in MODEL_MODULES: + module = importlib.import_module(module_name) + for name, value in vars(module).items(): + if not isclass(value) or getattr(value, "__module__", None) != module_name: + continue + if not is_dataclass(value): + continue + classes.append((module_name, name, value)) + return classes + + +def test_all_model_dataclasses_expose_standard_serialization_methods() -> None: + missing = [ + f"{module_name}:{name}" + for module_name, name, cls in iter_model_classes() + if issubclass(cls, SerializableModel) + and (not hasattr(cls, "to_dict") or not hasattr(cls, "model_dump")) + ] + + assert missing == [] + + +def test_recursive_serialization_is_json_compatible_and_hides_transport_fields() -> None: + tariff = TariffInfo( + current=TariffContractInfo( + level="Максимальный", + is_active=True, + start_time=1713427200, + close_time=None, + bonus=10, + price=1990, + original_price=2490, + packages_count=2, + ), + scheduled=None, + ) + catalog = CatalogResolveResult( + items=[ + CatalogField( + field_id="brand", + label="Марка", + data_type="integer", + values=[CatalogFieldValue(value_id="1", label="Audi")], + ) + ], + ) + assert tariff.to_dict()["current"]["level"] == "Максимальный" + assert catalog.model_dump()["items"][0]["field_id"] == "brand" + json.dumps(tariff.to_dict()) + json.dumps(catalog.to_dict()) + request = SendMessageRequest(message="hello") + assert request.message == "hello" + assert request.type is None + + +def test_examples_and_binary_models_produce_expected_payloads() -> None: + budget_request = CreateAutostrategyBudgetRequest( + campaign_type="AS", + start_time=datetime.fromisoformat("2026-04-20T00:00:00+00:00"), + finish_time=datetime.fromisoformat("2026-04-27T00:00:00+00:00"), + items=[42, 43], + ) + campaigns_request = ListAutostrategyCampaignsRequest( + limit=50, + status_id=[1, 2], + order_by=[CampaignOrderBy(column="startTime", direction="asc")], + filter=CampaignListFilter( + by_update_time=CampaignUpdateTimeFilter( + from_time=datetime.fromisoformat("2026-04-01T00:00:00+00:00"), + to_time=datetime.fromisoformat("2026-04-30T00:00:00+00:00"), + ) + ), + ) + assert budget_request.to_payload()["campaignType"] == "AS" + assert campaigns_request.to_payload()["filter"]["byUpdateTime"]["from"].startswith("2026-04-01") + assert CatalogResolveRequest(brand_id=1).to_payload() == {"brandId": 1} + assert MonitoringBucketRequest(vehicles=["VIN-1"]).to_payload() == {"vehicles": ["VIN-1"]} + + response = BinaryResponse( + content=b"\x00\x01payload", + content_type="application/octet-stream", + filename="artifact.bin", + status_code=200, + headers={"x-test": "1"}, + ) + expected = { + "filename": "artifact.bin", + "content_type": "application/octet-stream", + "content_base64": "AAFwYXlsb2Fk", + } + assert LabelPdfResult(binary=response).to_dict() == expected + assert CpaAudioRecord(binary=response).model_dump() == expected + assert CallTrackingRecord(binary=response).to_dict() == expected + + +def test_primary_sdk_models_serialize_without_transport_fields() -> None: + profile = AccountProfile(user_id=7, name="Иван", email=None, phone="+7999") + listing = Listing( + item_id=101, + user_id=7, + title="Смартфон", + description=None, + status="active", + price=1000.0, + url=None, + ) + stats = ListingStats(item_id=101, views=42, contacts=None, favorites=3) + calls = CallStats(item_id=101, calls=4, answered_calls=3, missed_calls=1) + spendings = AccountSpendings( + items=[SpendingRecord(item_id=101, amount=77.5, service="xl")], + total=77.5, + ) + service = PromotionService( + item_id=101, + service_code="x2", + service_name="X2", + price=9900, + status="available", + ) + order = PromotionOrderInfo( + order_id="ord-1", + item_id=101, + service_code="x2", + status="created", + created_at=None, + ) + forecast = PromotionForecast( + item_id=101, + min_views=10, + max_views=25, + total_price=7000, + total_old_price=None, + ) + + assert profile.to_dict()["user_id"] == 7 + assert listing.to_dict()["item_id"] == 101 + assert stats.model_dump()["views"] == 42 + assert calls.to_dict()["calls"] == 4 + assert spendings.to_dict()["total"] == 77.5 + assert service.to_dict()["service_code"] == "x2" + assert order.to_dict()["order_id"] == "ord-1" + assert forecast.to_dict()["max_views"] == 25 + + +def test_model_read_flows_return_stable_sdk_models() -> None: + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + if path == "/core/v1/accounts/self": + return httpx.Response(200, json={"id": 7, "name": "Иван"}) + if path == "/core/v1/accounts/7/items/101/": + return httpx.Response(200, json={"id": 101, "user_id": 7, "title": "Смартфон"}) + if path == "/stats/v1/accounts/7/items": + return httpx.Response( + 200, + json={"items": [{"item_id": 101, "views": 42, "contacts": 5, "favorites": 3}]}, + ) + if path == "/core/v1/accounts/7/calls/stats/": + return httpx.Response( + 200, + json={ + "items": [{"item_id": 101, "calls": 4, "answered_calls": 3, "missed_calls": 1}] + }, + ) + if path == "/stats/v2/accounts/7/spendings": + return httpx.Response( + 200, + json={"items": [{"item_id": 101, "amount": 77.5, "service": "xl"}]}, + ) + if path == "/promotion/v1/items/services/get": + return httpx.Response( + 200, + json={ + "items": [ + { + "itemId": 101, + "serviceCode": "x2", + "serviceName": "X2", + "price": 9900, + "status": "available", + } + ] + }, + ) + if path == "/promotion/v1/items/services/orders/get": + return httpx.Response( + 200, + json={"items": [{"orderId": "ord-1", "itemId": 101, "serviceCode": "x2"}]}, + ) + return httpx.Response( + 200, + json={"items": [{"itemId": 101, "min": 10, "max": 25, "totalPrice": 7000}]}, + ) + + transport = make_transport(httpx.MockTransport(handler)) + + profile = Account(transport, user_id=7).get_self() + listing = Ad(transport, item_id=101, user_id=7).get() + stats = AdStats(transport, item_id=101, user_id=7).get_item_stats() + calls = AdStats(transport, item_id=101, user_id=7).get_calls_stats() + spendings = AdStats(transport, item_id=101, user_id=7).get_account_spendings() + services = PromotionOrder(transport, order_id="ord-1").list_services(item_ids=[101]) + orders = PromotionOrder(transport, order_id="ord-1").list_orders(item_ids=[101]) + forecasts = BbipPromotion(transport, item_id=101).get_forecasts( + items=[BbipItem(item_id=101, duration=7, price=1000, old_price=1200).to_dict()] + ) + + assert isinstance(profile, AccountProfile) + assert isinstance(listing, Listing) + assert isinstance(stats.items[0], ListingStats) + assert isinstance(calls.items[0], CallStats) + assert isinstance(spendings, AccountSpendings) + assert isinstance(services.items[0], PromotionService) + assert isinstance(orders.items[0], PromotionOrderInfo) + assert forecasts.items[0].max_views == 25 + + +def test_autostrategy_models_serialize_correctly() -> None: + budget = AutostrategyBudget(calc_id=1, recommended=None, minimal=None, maximal=None, price_ranges=[]) + result = CampaignActionResult(campaign=None) + campaigns = CampaignsResult(items=[], total_count=0) + stat = AutostrategyStat( + items=[AutostrategyStatItem(date="2026-01-01", calls=5, views=10)], + totals=AutostrategyStatTotals(calls=5, views=10), + ) + + assert budget.to_dict()["calc_id"] == 1 + assert result.to_dict() == {"campaign": None} + assert campaigns.to_dict() == {"items": [], "total_count": 0} + assert stat.to_dict()["totals"] == {"calls": 5, "views": 10} diff --git a/tests/contracts/test_public_surface.py b/tests/contracts/test_public_surface.py new file mode 100644 index 0000000..0cbb416 --- /dev/null +++ b/tests/contracts/test_public_surface.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +import importlib +import inspect +from dataclasses import fields, is_dataclass +from pathlib import Path + +import avito.autoteka as autoteka +import avito.jobs as jobs +import avito.orders as orders +import avito.realty as realty +from avito.autoteka import ( + AutotekaMonitoring, + AutotekaReport, + AutotekaScoring, + AutotekaValuation, + AutotekaVehicle, +) +from avito.jobs import Application, JobWebhook, Resume, Vacancy +from avito.messenger import ChatMedia +from avito.orders import DeliveryOrder, Order, OrderLabel, SandboxDelivery, Stock +from avito.realty import RealtyBooking, RealtyListing, RealtyPricing + +MODEL_MODULES = ( + "avito.accounts.models", + "avito.ads.models", + "avito.autoteka.models", + "avito.cpa.models", + "avito.jobs.models", + "avito.messenger.models", + "avito.orders.models", + "avito.promotion.models", + "avito.ratings.models", + "avito.realty.models", + "avito.tariffs.models", +) + + +def iter_public_dataclasses() -> list[tuple[str, str, type[object]]]: + classes: list[tuple[str, str, type[object]]] = [] + for module_name in MODEL_MODULES: + module = importlib.import_module(module_name) + for name, value in vars(module).items(): + if not inspect.isclass(value) or getattr(value, "__module__", None) != module_name: + continue + if not is_dataclass(value): + continue + classes.append((module_name, name, value)) + return classes + + +def test_removed_generic_request_wrappers_are_not_exported() -> None: + assert "RealtyRequest" not in realty.__all__ + assert "JobsRequest" not in jobs.__all__ + assert "JobsQuery" not in jobs.__all__ + assert "AutotekaRequest" not in autoteka.__all__ + assert "AutotekaQuery" not in autoteka.__all__ + assert "OrdersRequest" not in orders.__all__ + + +def test_public_signatures_use_typed_requests_instead_of_generic_wrappers() -> None: + methods = ( + RealtyBooking.update_bookings_info, + RealtyListing.get_intervals, + RealtyPricing.update_realty_prices, + Order.update_markings, + Order.apply, + Order.check_confirmation_code, + OrderLabel.create, + DeliveryOrder.create_announcement, + SandboxDelivery.add_areas, + Stock.update, + Application.apply, + Application.list, + JobWebhook.update, + Resume.list, + Vacancy.create, + Vacancy.update, + AutotekaVehicle.create_preview_by_vin, + AutotekaReport.create_report, + AutotekaMonitoring.get_monitoring_reg_actions, + AutotekaScoring.create_scoring_by_vehicle_id, + AutotekaValuation.get_valuation_by_specification, + ) + banned_tokens = ( + "RealtyRequest", + "JobsRequest", + "JobsQuery", + "AutotekaRequest", + "AutotekaQuery", + "OrdersRequest", + ) + + for method in methods: + public_text = str(inspect.signature(method)) + for token in banned_tokens: + assert token not in public_text + + +def test_public_surface_avoids_raw_dict_signatures_and_legacy_suffixes() -> None: + module_names = ( + "avito.accounts.domain", + "avito.accounts.client", + "avito.ads.domain", + "avito.ads.client", + "avito.autoteka.domain", + "avito.autoteka.client", + "avito.cpa.domain", + "avito.cpa.client", + "avito.jobs.domain", + "avito.jobs.client", + "avito.messenger.domain", + "avito.messenger.client", + "avito.orders.domain", + "avito.orders.client", + "avito.promotion.domain", + "avito.promotion.client", + "avito.ratings.domain", + "avito.ratings.client", + "avito.realty.domain", + "avito.realty.client", + "avito.tariffs.domain", + "avito.tariffs.client", + ) + banned_signature_tokens = ("Mapping[str, object]", "dict[str, object]", "object]") + banned_name_fragments = ("legacy_",) + banned_suffixes = ("_v1", "_v2") + offenders: list[str] = [] + + for module_name in module_names: + module = importlib.import_module(module_name) + for _, cls in inspect.getmembers(module, inspect.isclass): + if cls.__module__ != module_name or cls.__name__.startswith("_"): + continue + for method_name, method in inspect.getmembers(cls, inspect.isfunction): + if method_name.startswith("_"): + continue + signature_text = str(inspect.signature(method)) + if any(token in signature_text for token in banned_signature_tokens): + offenders.append(f"{module_name}.{cls.__name__}.{method_name}") + if any(fragment in method_name for fragment in banned_name_fragments): + offenders.append(f"{module_name}.{cls.__name__}.{method_name}") + if method_name.endswith(banned_suffixes): + offenders.append(f"{module_name}.{cls.__name__}.{method_name}") + + assert offenders == [] + + +def test_public_surface_does_not_raise_valueerror_in_domain_or_client_layers() -> None: + root = Path(__file__).resolve().parents[2] / "avito" + offenders: list[str] = [] + + for path in root.glob("*/domain.py"): + if "raise ValueError" in path.read_text(encoding="utf-8"): + offenders.append(path.as_posix()) + for path in root.glob("*/client.py"): + if "raise ValueError" in path.read_text(encoding="utf-8"): + offenders.append(path.as_posix()) + + assert offenders == [] + + +def test_public_models_do_not_expose_raw_payload_fields() -> None: + offenders = [] + for module_name, name, cls in iter_public_dataclasses(): + if any(field.name in {"raw_payload", "_payload"} for field in fields(cls)): + offenders.append(f"{module_name}:{name}") + + assert offenders == [] + + +def test_chat_media_upload_images_no_longer_accepts_raw_dict() -> None: + signature_text = str(inspect.signature(ChatMedia.upload_images)) + assert "dict[str, object]" not in signature_text + assert "UploadImageFile" in signature_text diff --git a/tests/test_auth.py b/tests/core/test_authentication.py similarity index 84% rename from tests/test_auth.py rename to tests/core/test_authentication.py index 4e107e1..171fe89 100644 --- a/tests/test_auth.py +++ b/tests/core/test_authentication.py @@ -9,10 +9,10 @@ from avito import AvitoClient from avito.auth import ( + AlternateTokenClient, AuthProvider, AuthSettings, ClientCredentialsRequest, - LegacyTokenClient, RefreshTokenRequest, TokenClient, ) @@ -122,7 +122,7 @@ def test_token_client_maps_authentication_error() -> None: assert error.value.error_code == "invalid_client" -def test_legacy_token_alias_does_not_create_duplicate_public_client_api() -> None: +def test_client_auth_surface_exposes_current_token_flows_only() -> None: settings = AvitoSettings( base_url="https://api.avito.ru", auth=AuthSettings(client_id="client-id", client_secret="client-secret"), @@ -133,27 +133,27 @@ def test_legacy_token_alias_does_not_create_duplicate_public_client_api() -> Non auth_provider = client.auth() assert isinstance(auth_provider.token_flow(), TokenClient) - assert isinstance(auth_provider.legacy_token_flow(), LegacyTokenClient) + assert isinstance(auth_provider.alternate_token_flow(), AlternateTokenClient) with pytest.raises(AttributeError): _ = client.legacy_auth # type: ignore[attr-defined] -def test_legacy_token_client_uses_same_public_contract_for_duplicate_token_path() -> None: +def test_alternate_token_client_uses_refresh_contract_on_duplicate_path() -> None: captured_paths: list[str] = [] def handler(request: httpx.Request) -> httpx.Response: captured_paths.append(request.url.path) return httpx.Response(200, json={"access_token": "legacy-access", "expires_in": 3600}) - legacy_token_client = LegacyTokenClient( + alternate_token_client = AlternateTokenClient( AuthSettings( - client_id="client-id", client_secret="client-secret", legacy_token_url="/token" + client_id="client-id", client_secret="client-secret", alternate_token_url="/token" ), client=make_token_http_client(httpx.MockTransport(handler)), ) - token_response = legacy_token_client.request_refresh_token( + token_response = alternate_token_client.request_refresh_token( RefreshTokenRequest( client_id="client-id", client_secret="client-secret", @@ -170,27 +170,19 @@ def test_auth_provider_uses_separate_autoteka_credentials_for_autoteka_token() - def handler(request: httpx.Request) -> httpx.Response: captured_payloads.append(request.content.decode()) - return httpx.Response( - 200, - json={"access_token": "autoteka-access", "expires_in": 3600}, - ) - + return httpx.Response(200, json={"access_token": "autoteka-access", "expires_in": 3600}) + + settings = AuthSettings( + client_id="client-id", + client_secret="client-secret", + autoteka_client_id="autoteka-client-id", + autoteka_client_secret="autoteka-client-secret", + autoteka_scope="autoteka:read", + ) provider = AuthProvider( - AuthSettings( - client_id="client-id", - client_secret="client-secret", - autoteka_client_id="autoteka-client-id", - autoteka_client_secret="autoteka-client-secret", - autoteka_scope="autoteka:read", - ), + settings, autoteka_token_client=TokenClient( - AuthSettings( - client_id="client-id", - client_secret="client-secret", - autoteka_client_id="autoteka-client-id", - autoteka_client_secret="autoteka-client-secret", - autoteka_scope="autoteka:read", - ), + settings, client=make_token_http_client(httpx.MockTransport(handler)), ), ) diff --git a/tests/core/test_configuration.py b/tests/core/test_configuration.py new file mode 100644 index 0000000..49c7c31 --- /dev/null +++ b/tests/core/test_configuration.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from avito import AvitoClient +from avito.auth import AuthSettings +from avito.config import AvitoSettings +from avito.core.exceptions import ConfigurationError + +ENV_KEYS = ( + "AVITO_BASE_URL", + "AVITO_USER_ID", + "AVITO_AUTH__CLIENT_ID", + "AVITO_AUTH__CLIENT_SECRET", + "AVITO_AUTH__REFRESH_TOKEN", + "AVITO_CLIENT_ID", + "AVITO_CLIENT_SECRET", +) + + +def clear_avito_env(monkeypatch: pytest.MonkeyPatch) -> None: + for key in ENV_KEYS: + monkeypatch.delenv(key, raising=False) + + +def write_env_file(path: Path, content: str) -> Path: + path.write_text(content, encoding="utf-8") + return path + + +def test_avito_settings_from_env_reads_full_configuration( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + clear_avito_env(monkeypatch) + env_file = write_env_file( + tmp_path / ".env", + "\n".join( + ( + "AVITO_BASE_URL=https://sandbox.avito.ru", + "AVITO_USER_ID=42", + "AVITO_AUTH__CLIENT_ID=client-id", + "AVITO_AUTH__CLIENT_SECRET=client-secret", + "AVITO_AUTH__REFRESH_TOKEN=refresh-token", + ) + ), + ) + + settings = AvitoSettings.from_env(env_file=env_file) + + assert settings.base_url == "https://sandbox.avito.ru" + assert settings.user_id == 42 + assert settings.auth.client_id == "client-id" + assert settings.auth.client_secret == "client-secret" + assert settings.auth.refresh_token == "refresh-token" + + +def test_avito_settings_from_env_supports_alias_variables( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + clear_avito_env(monkeypatch) + env_file = write_env_file( + tmp_path / ".env", + "\n".join( + ( + "AVITO_BASE_URL=https://file.avito.ru", + "AVITO_USER_ID=77", + "AVITO_CLIENT_ID=file-client-id", + "AVITO_CLIENT_SECRET=file-client-secret", + ) + ), + ) + + settings = AvitoSettings.from_env(env_file=env_file) + + assert settings.base_url == "https://file.avito.ru" + assert settings.user_id == 77 + assert settings.auth.client_id == "file-client-id" + assert settings.auth.client_secret == "file-client-secret" + + +def test_avito_settings_from_env_requires_explicit_auth_values( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + clear_avito_env(monkeypatch) + + with pytest.raises(ConfigurationError, match="client_id"): + AvitoSettings.from_env(env_file=write_env_file(tmp_path / ".env", "AVITO_CLIENT_SECRET=x")) + + clear_avito_env(monkeypatch) + with pytest.raises(ConfigurationError, match="client_secret"): + AvitoSettings.from_env(env_file=write_env_file(tmp_path / ".env", "AVITO_CLIENT_ID=x")) + + +def test_avito_settings_from_env_ignores_unsupported_generic_aliases( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + clear_avito_env(monkeypatch) + env_file = write_env_file( + tmp_path / ".env", + "\n".join( + ( + "BASE_URL=https://generic.avito.ru", + "USER_ID=77", + "CLIENT_ID=generic-client-id", + "SECRET=generic-client-secret", + "AVITO_SECRET=legacy-secret", + ) + ), + ) + + with pytest.raises(ConfigurationError, match="client_id"): + AvitoSettings.from_env(env_file=env_file) + + +def test_avito_client_from_env_initializes_client( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + clear_avito_env(monkeypatch) + env_file = write_env_file( + tmp_path / ".env", + "\n".join( + ( + "AVITO_BASE_URL=https://sandbox.avito.ru", + "AVITO_USER_ID=512", + "AVITO_AUTH__CLIENT_ID=client-id", + "AVITO_AUTH__CLIENT_SECRET=client-secret", + ) + ), + ) + + client = AvitoClient.from_env(env_file=env_file) + try: + assert client.settings.base_url == "https://sandbox.avito.ru" + assert client.settings.user_id == 512 + assert client.settings.auth.client_id == "client-id" + assert client.settings.auth.client_secret == "client-secret" + finally: + client.close() + + +def test_explicit_settings_do_not_implicitly_read_process_env( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("AVITO_CLIENT_SECRET", "from-process-env") + + settings = AvitoSettings(auth=AuthSettings(client_id="client-id")) + + assert settings.auth.client_secret is None + + +def test_process_environment_overrides_dotenv_and_parses_retry_options( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + clear_avito_env(monkeypatch) + env_file = write_env_file( + tmp_path / ".env", + "\n".join( + ( + "AVITO_BASE_URL=https://from-file.avito.ru", + "AVITO_AUTH__CLIENT_ID=file-client-id", + "AVITO_AUTH__CLIENT_SECRET=file-client-secret", + "AVITO_TIMEOUT_CONNECT=2.5", + "AVITO_TIMEOUT_READ=11", + "AVITO_RETRY_MAX_ATTEMPTS=4", + "AVITO_RETRY_BACKOFF_FACTOR=0.75", + "AVITO_RETRY_RETRYABLE_METHODS=GET,POST,PATCH", + "AVITO_RETRY_RETRY_ON_RATE_LIMIT=false", + "AVITO_RETRY_MAX_RATE_LIMIT_WAIT_SECONDS=12.5", + ) + ), + ) + monkeypatch.setenv("AVITO_BASE_URL", "https://from-env.avito.ru") + monkeypatch.setenv("AVITO_CLIENT_ID", "env-client-id") + monkeypatch.setenv("AVITO_CLIENT_SECRET", "env-client-secret") + + settings = AvitoSettings.from_env(env_file=env_file) + + assert settings.base_url == "https://from-env.avito.ru" + assert settings.auth.client_id == "env-client-id" + assert settings.auth.client_secret == "env-client-secret" + assert settings.timeouts.connect == 2.5 + assert settings.timeouts.read == 11.0 + assert settings.retry_policy.max_attempts == 4 + assert settings.retry_policy.backoff_factor == 0.75 + assert settings.retry_policy.retryable_methods == ("GET", "POST", "PATCH") + assert settings.retry_policy.retry_on_rate_limit is False + assert settings.retry_policy.max_rate_limit_wait_seconds == 12.5 diff --git a/tests/test_core.py b/tests/core/test_transport.py similarity index 78% rename from tests/test_core.py rename to tests/core/test_transport.py index a3cedb9..58e778e 100644 --- a/tests/test_core.py +++ b/tests/core/test_transport.py @@ -10,6 +10,8 @@ from avito.config import AvitoSettings from avito.core import ( AuthenticationError, + AuthorizationError, + ConflictError, JsonPage, PaginatedList, Paginator, @@ -18,6 +20,8 @@ ResponseMappingError, ServerError, Transport, + UnsupportedOperationError, + UpstreamApiError, ValidationError, ) from avito.core.retries import RetryPolicy @@ -110,59 +114,42 @@ def handler(request: httpx.Request) -> httpx.Response: sleep=lambda _: None, ) - with pytest.raises(Exception) as error: + with pytest.raises(Exception, match="offline"): transport.request_json("POST", "/items", context=RequestContext("create_item")) - assert "offline" in str(error.value) assert calls["count"] == 1 -def test_transport_handles_rate_limit_and_classifies_errors() -> None: - rate_limit_transport = Transport( - make_settings(retry_policy=RetryPolicy(max_attempts=1)), - client=httpx.Client( - transport=httpx.MockTransport( - lambda request: httpx.Response( - 429, headers={"retry-after": "60"}, json={"message": "too many"} - ) - ), - base_url="https://api.avito.ru", - ), - sleep=lambda _: None, - ) - - with pytest.raises(RateLimitError): - rate_limit_transport.request_json("GET", "/limited", context=RequestContext("limited")) - - server_transport = Transport( - make_settings(retry_policy=RetryPolicy(max_attempts=1)), - client=httpx.Client( - transport=httpx.MockTransport( - lambda request: httpx.Response(500, json={"message": "server down"}) - ), - base_url="https://api.avito.ru", - ), - sleep=lambda _: None, - ) - - with pytest.raises(ServerError): - server_transport.request_json("GET", "/server", context=RequestContext("server")) - - validation_transport = Transport( +@pytest.mark.parametrize( + ("status_code", "error_cls"), + ( + (400, ValidationError), + (401, AuthenticationError), + (403, AuthorizationError), + (405, UnsupportedOperationError), + (409, ConflictError), + (418, UpstreamApiError), + (422, ValidationError), + (429, RateLimitError), + (500, ServerError), + ), +) +def test_transport_maps_http_statuses_to_typed_sdk_errors( + status_code: int, error_cls: type[Exception] +) -> None: + transport = Transport( make_settings(retry_policy=RetryPolicy(max_attempts=1)), client=httpx.Client( transport=httpx.MockTransport( - lambda request: httpx.Response(422, json={"message": "invalid"}) + lambda request: httpx.Response(status_code, json={"message": "boom"}) ), base_url="https://api.avito.ru", ), sleep=lambda _: None, ) - with pytest.raises(ValidationError): - validation_transport.request_json( - "POST", "/validation", context=RequestContext("validation") - ) + with pytest.raises(error_cls): + transport.request_json("GET", "/broken", context=RequestContext("broken")) def test_transport_raises_mapping_error_for_invalid_json() -> None: @@ -222,26 +209,17 @@ def handler(request: httpx.Request) -> httpx.Response: assert binary_result.filename == "label.pdf" -def test_paginator_collects_typed_pages() -> None: +def test_paginator_and_paginated_list_keep_lazy_contract() -> None: pages = { 1: JsonPage(items=[1, 2], page=1, per_page=2, total=5), 2: JsonPage(items=[3, 4], page=2, per_page=2, total=5), 3: JsonPage(items=[5], page=3, per_page=2, total=5), } + calls: list[int] = [] paginator = Paginator(lambda page, cursor: pages[page or 1]) - assert paginator.collect() == [1, 2, 3, 4, 5] - -def test_paginated_list_behaves_like_list_and_loads_pages_lazily() -> None: - calls: list[int] = [] - pages = { - 1: JsonPage(items=[1, 2], page=1, per_page=2, total=5), - 2: JsonPage(items=[3, 4], page=2, per_page=2, total=5), - 3: JsonPage(items=[5], page=3, per_page=2, total=5), - } - def fetch(page: int | None, cursor: str | None) -> JsonPage[int]: resolved_page = page or 1 calls.append(resolved_page) @@ -249,17 +227,30 @@ def fetch(page: int | None, cursor: str | None) -> JsonPage[int]: items = PaginatedList(fetch, first_page=pages[1]) + assert items.loaded_count == 2 assert items[0] == 1 - assert calls == [] - assert items[3] == 4 assert calls == [2] + assert list(item for _, item in zip(range(3), items, strict=False)) == [1, 2, 3] + assert items.materialize() == [1, 2, 3, 4, 5] + assert items.is_materialized is True + - assert items[:] == [1, 2, 3, 4, 5] - assert calls == [2, 3] +def test_paginated_list_handles_empty_pages_and_failing_follow_up_page() -> None: + empty = PaginatedList( + lambda page, cursor: JsonPage(items=[], page=1, per_page=10, total=0), + first_page=JsonPage(items=[], page=1, per_page=10, total=0), + ) + assert empty.materialize() == [] + + def fetch(page: int | None, cursor: str | None) -> JsonPage[int]: + if (page or 1) == 2: + raise RateLimitError("page 2 failed") + return JsonPage(items=[1, 2], page=1, per_page=2, total=4) - assert len(items) == 5 - assert items == [1, 2, 3, 4, 5] + items = PaginatedList(fetch, first_page=JsonPage(items=[1, 2], page=1, per_page=2, total=4)) + with pytest.raises(RateLimitError, match="page 2 failed"): + _ = items[2] def test_transport_raises_authentication_error_after_failed_refresh() -> None: diff --git a/tests/domains/accounts/test_accounts.py b/tests/domains/accounts/test_accounts.py new file mode 100644 index 0000000..fb2118e --- /dev/null +++ b/tests/domains/accounts/test_accounts.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import json +from datetime import datetime + +import httpx + +from avito.accounts import Account, AccountHierarchy +from tests.helpers.transport import make_transport + + +def test_account_domain_maps_profile_balance_and_operations() -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/core/v1/accounts/self": + return httpx.Response( + 200, json={"id": 7, "name": "Иван", "email": "user@example.com", "phone": "+7999"} + ) + if request.url.path == "/core/v1/accounts/7/balance/": + return httpx.Response( + 200, + json={"user_id": 7, "balance": {"real": 150.5, "bonus": 20.0, "currency": "RUB"}}, + ) + assert request.url.path == "/core/v1/accounts/operations_history/" + assert json.loads(request.content.decode()) == { + "dateFrom": "2025-01-01T00:00:00+00:00", + "limit": 2, + "offset": 0, + } + return httpx.Response( + 200, + json={ + "total": 1, + "operations": [ + { + "id": "op-1", + "created_at": "2025-01-02T12:00:00Z", + "amount": 120.0, + "type": "payment", + "status": "done", + } + ], + }, + ) + + transport = make_transport(httpx.MockTransport(handler)) + account = Account(transport, user_id=7) + + profile = account.get_self() + balance = account.get_balance() + history = account.get_operations_history( + date_from=datetime.fromisoformat("2025-01-01T00:00:00+00:00"), + limit=2, + ) + + assert profile.user_id == 7 + assert balance.total == 170.5 + assert len(history.materialize()) == 1 + assert history[0].operation_type == "payment" + + +def test_account_hierarchy_domain_maps_employees_phones_and_items() -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/checkAhUserV1": + return httpx.Response(200, json={"user_id": 7, "is_active": True, "role": "manager"}) + if request.url.path == "/getEmployeesV1": + return httpx.Response( + 200, + json={"employees": [{"employee_id": 10, "user_id": 7, "name": "Пётр"}], "total": 1}, + ) + if request.url.path == "/listCompanyPhonesV1": + return httpx.Response( + 200, json={"phones": [{"id": 1, "phone": "+7000", "comment": "Основной"}]} + ) + if request.url.path == "/linkItemsV1": + assert json.loads(request.content.decode()) == {"employeeId": 10, "itemIds": [1, 2]} + return httpx.Response(200, json={"success": True, "message": "linked"}) + assert request.url.path == "/listItemsByEmployeeIdV1" + assert json.loads(request.content.decode()) == {"employeeId": 10, "limit": 5, "offset": 0} + return httpx.Response( + 200, + json={ + "items": [{"item_id": 1, "title": "Объявление", "status": "active", "price": 99}], + "total": 1, + }, + ) + + hierarchy = AccountHierarchy(make_transport(httpx.MockTransport(handler)), user_id=7) + + status = hierarchy.get_status() + employees = hierarchy.list_employees() + phones = hierarchy.list_company_phones() + linked = hierarchy.link_items(employee_id=10, item_ids=[1, 2]) + items = hierarchy.list_items_by_employee(employee_id=10, limit=5) + + assert status.is_active is True + assert employees.items[0].employee_id == 10 + assert phones.items[0].phone == "+7000" + assert linked.success is True + assert items[0].title == "Объявление" diff --git a/tests/domains/ads/test_ads.py b/tests/domains/ads/test_ads.py new file mode 100644 index 0000000..3a9b72c --- /dev/null +++ b/tests/domains/ads/test_ads.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import json +from datetime import datetime + +import httpx + +from avito.ads import Ad, AdPromotion, AdStats +from tests.helpers.transport import make_transport + + +def test_ads_list_uses_lazy_pagination_with_list_like_items() -> None: + seen_offsets: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/core/v1/items" + assert request.url.params["user_id"] == "7" + assert request.url.params["status"] == "active" + assert request.url.params["limit"] == "2" + + offset = request.url.params["offset"] + seen_offsets.append(offset) + page_items = { + "0": [{"id": 101, "title": "Смартфон"}, {"id": 102, "title": "Ноутбук"}], + "2": [{"id": 103, "title": "Планшет"}, {"id": 104, "title": "Наушники"}], + "4": [{"id": 105, "title": "Камера"}], + } + return httpx.Response(200, json={"items": page_items[offset], "total": 5}) + + ad = Ad(make_transport(httpx.MockTransport(handler)), user_id=7) + + items = ad.list(status="active", limit=2) + + assert seen_offsets == ["0"] + assert items[3].item_id == 104 + assert seen_offsets == ["0", "2"] + assert [item.title for item in items.materialize()] == [ + "Смартфон", + "Ноутбук", + "Планшет", + "Наушники", + "Камера", + ] + assert seen_offsets == ["0", "2", "4"] + + +def test_ads_domain_covers_item_stats_spendings_and_promotion() -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/core/v1/accounts/7/items/101/": + return httpx.Response( + 200, + json={"id": 101, "user_id": 7, "title": "Смартфон", "price": 1000, "status": "active"}, + ) + if request.url.path == "/stats/v1/accounts/7/items": + return httpx.Response( + 200, json={"items": [{"item_id": 101, "views": 45, "contacts": 5, "favorites": 2}]} + ) + if request.url.path == "/core/v1/accounts/7/calls/stats/": + return httpx.Response( + 200, + json={"items": [{"item_id": 101, "calls": 3, "answered_calls": 2, "missed_calls": 1}]}, + ) + if request.url.path == "/stats/v2/accounts/7/spendings": + return httpx.Response( + 200, json={"items": [{"item_id": 101, "amount": 77.5, "service": "xl"}]} + ) + if request.url.path == "/core/v1/accounts/7/items/101/vas": + assert json.loads(request.content.decode()) == {"codes": ["xl"]} + return httpx.Response(200, json={"success": True, "status": "applied"}) + assert request.url.path == "/core/v1/items/101/update_price" + assert json.loads(request.content.decode()) == {"price": 1500} + return httpx.Response(200, json={"item_id": 101, "price": 1500, "status": "updated"}) + + transport = make_transport(httpx.MockTransport(handler)) + ad = Ad(transport, item_id=101, user_id=7) + stats = AdStats(transport, item_id=101, user_id=7) + promotion = AdPromotion(transport, item_id=101, user_id=7) + + item = ad.get() + updated = ad.update_price(price=1500) + item_stats = stats.get_item_stats() + calls = stats.get_calls_stats() + spendings = stats.get_account_spendings() + applied = promotion.apply_vas(codes=["xl"]) + + assert item.title == "Смартфон" + assert updated.status == "updated" + assert item_stats.items[0].views == 45 + assert calls.items[0].answered_calls == 2 + assert spendings.total == 77.5 + assert applied.status == "applied" + + +def test_ad_stats_accept_datetime_filters_and_serialize_isoformat() -> None: + seen_payloads: list[dict[str, object]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen_payloads.append(json.loads(request.content.decode())) + return httpx.Response(200, json={"items": [{"item_id": 101, "views": 10}]}) + + stats = AdStats(make_transport(httpx.MockTransport(handler)), item_id=101, user_id=7) + started_at = datetime.fromisoformat("2026-04-18T00:00:00+03:00") + finished_at = datetime.fromisoformat("2026-04-18T23:59:59+03:00") + + stats.get_item_analytics(item_ids=[101], date_from=started_at, date_to=finished_at) + + assert seen_payloads[0]["dateFrom"] == "2026-04-18T00:00:00+03:00" + assert seen_payloads[0]["dateTo"] == "2026-04-18T23:59:59+03:00" diff --git a/tests/domains/autoteka/test_autoteka.py b/tests/domains/autoteka/test_autoteka.py new file mode 100644 index 0000000..fe9ef47 --- /dev/null +++ b/tests/domains/autoteka/test_autoteka.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import json + +import httpx + +from avito.autoteka import ( + AutotekaMonitoring, + AutotekaReport, + AutotekaScoring, + AutotekaValuation, + AutotekaVehicle, +) +from avito.autoteka.models import ( + MonitoringEventsQuery, +) +from tests.helpers.transport import make_transport + + +def test_autoteka_vehicle_flows() -> None: + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + payload = json.loads(request.content.decode()) if request.content else None + if path == "/autoteka/v1/catalogs/resolve": + assert payload == {"brandId": 1} + return httpx.Response(200, json={"result": {"fields": [{"id": 110000, "label": "Марка", "dataType": "integer", "values": [{"valueId": 1, "label": "Audi"}]}]}}) + if path == "/autoteka/v1/get-leads/": + return httpx.Response(200, json={"pagination": {"lastId": 321}, "result": [{"id": 12, "subscriptionId": 44, "payload": {"vin": "VIN-1", "itemId": 901, "brand": "Audi", "model": "A4"}}]}) + if path == "/autoteka/v1/previews": + return httpx.Response(200, json={"result": {"preview": {"previewId": 77}}}) + if path == "/autoteka/v1/request-preview-by-item-id": + return httpx.Response(200, json={"result": {"preview": {"previewId": 78}}}) + if path == "/autoteka/v1/request-preview-by-regnumber": + return httpx.Response(200, json={"result": {"preview": {"previewId": 79}}}) + if path == "/autoteka/v1/request-preview-by-external-item": + return httpx.Response(200, json={"result": {"preview": {"previewId": 80}}}) + if path == "/autoteka/v1/previews/77": + return httpx.Response(200, json={"result": {"preview": {"previewId": 77, "status": "success", "vin": "VIN-1", "regNumber": "A123AA77"}}}) + if path == "/autoteka/v1/specifications/by-plate-number": + return httpx.Response(200, json={"result": {"specification": {"specificationId": 501}}}) + if path == "/autoteka/v1/specifications/by-vehicle-id": + return httpx.Response(200, json={"result": {"specification": {"specificationId": 502}}}) + if path == "/autoteka/v1/specifications/specification/501": + return httpx.Response(200, json={"result": {"specification": {"specificationId": 501, "status": "success", "vehicleId": "VIN-1"}}}) + if path == "/autoteka/v1/teasers": + return httpx.Response(200, json={"result": {"teaser": {"teaserId": 601, "status": "processing"}}}) + return httpx.Response(200, json={"teaserId": 601, "status": "success", "data": {"brand": "Audi", "model": "A4", "year": 2018}}) + + vehicle = AutotekaVehicle(make_transport(httpx.MockTransport(handler)), vehicle_id="77") + + assert vehicle.resolve_catalog(brand_id=1).items[0].values[0].label == "Audi" + assert vehicle.get_leads(limit=1).last_id == 321 + assert vehicle.create_preview_by_vin(vin="VIN-1").preview_id == "77" + assert vehicle.create_preview_by_item_id(item_id=901).preview_id == "78" + assert vehicle.create_preview_by_reg_number(reg_number="A123AA77").preview_id == "79" + assert vehicle.create_preview_by_external_item(item_id="ext-1", site="cars.example").preview_id == "80" + assert vehicle.get_preview().vehicle_id == "VIN-1" + assert vehicle.create_specification_by_plate_number(plate_number="A123AA77").specification_id == "501" + assert vehicle.create_specification_by_vehicle_id(vehicle_id="VIN-1").specification_id == "502" + assert vehicle.get_specification_by_id(specification_id="501").status == "success" + assert vehicle.create_teaser(vehicle_id="VIN-1").teaser_id == "601" + assert vehicle.get_teaser(teaser_id="601").brand == "Audi" + + +def test_autoteka_report_monitoring_scoring_and_valuation_flows() -> None: + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + if path == "/autoteka/v1/packages/active_package": + return httpx.Response(200, json={"result": {"package": {"createdTime": "2026-04-01", "expireTime": "2026-05-01", "reportsCnt": 100, "reportsCntRemain": 77}}}) + if path == "/autoteka/v1/reports": + return httpx.Response(200, json={"result": {"report": {"reportId": 701, "status": "processing"}}}) + if path == "/autoteka/v1/reports-by-vehicle-id": + return httpx.Response(200, json={"result": {"report": {"reportId": 702, "status": "processing"}}}) + if path == "/autoteka/v1/reports/list/": + return httpx.Response(200, json={"result": [{"reportId": 701, "vin": "VIN-1", "createdAt": "2026-04-18 12:00:00"}]}) + if path == "/autoteka/v1/reports/701": + return httpx.Response(200, json={"result": {"report": {"reportId": 701, "status": "success", "webLink": "https://autoteka/web/701", "pdfLink": "https://autoteka/pdf/701", "data": {"vin": "VIN-1"}}}}) + if path == "/autoteka/v1/sync/create-by-regnumber": + return httpx.Response(200, json={"result": {"report": {"reportId": 703, "status": "success", "data": {"vin": "VIN-1"}}}}) + if path == "/autoteka/v1/sync/create-by-vin": + return httpx.Response(200, json={"result": {"report": {"reportId": 704, "status": "success", "data": {"vin": "VIN-1"}}}}) + if path == "/autoteka/v1/monitoring/bucket/add": + return httpx.Response(200, json={"result": {"isOk": True, "invalidVehicles": [{"vehicleID": "bad-vin", "description": "invalid"}]}}) + if path == "/autoteka/v1/monitoring/bucket/delete": + return httpx.Response(200, json={"result": {"isOk": True}}) + if path == "/autoteka/v1/monitoring/bucket/remove": + return httpx.Response(200, json={"result": {"isOk": True, "invalidVehicles": []}}) + if path == "/autoteka/v1/monitoring/get-reg-actions/": + return httpx.Response(200, json={"data": [{"vin": "VIN-1", "brand": "Audi", "model": "A4", "year": 2018, "operationCode": 11, "operationDateFrom": "2026-04-01T00:00:00+03:00"}], "pagination": {"hasNext": True, "nextCursor": "cursor-2", "nextLink": "https://api.avito.ru/next"}}) + if path == "/autoteka/v1/scoring/by-vehicle-id": + return httpx.Response(200, json={"result": {"scoring": {"scoringId": 801}}}) + if path == "/autoteka/v1/scoring/801": + return httpx.Response(200, json={"result": {"risksAssessment": {"scoringId": 801, "isCompleted": True, "createdAt": 1713427200}}}) + return httpx.Response(200, json={"result": {"status": "success", "vehicleId": "VIN-1", "brand": "Audi", "model": "A4", "year": 2018, "ownersCount": "2", "mileage": 30000, "valuation": {"avgPriceWithCondition": 2100000, "avgMarketPrice": 2200000}}}) + + transport = make_transport(httpx.MockTransport(handler)) + report = AutotekaReport(transport, report_id="701") + monitoring = AutotekaMonitoring(transport) + scoring = AutotekaScoring(transport, scoring_id="801") + valuation = AutotekaValuation(transport) + + assert report.get_active_package().reports_remaining == 77 + assert report.create_report(preview_id=77).report_id == "701" + assert report.create_report_by_vehicle_id(vehicle_id="VIN-1").report_id == "702" + assert report.list_reports().items[0].vehicle_id == "VIN-1" + assert report.get_report().web_link == "https://autoteka/web/701" + assert report.create_sync_report_by_reg_number(reg_number="A123AA77").status == "success" + assert report.create_sync_report_by_vin(vin="VIN-1").report_id == "704" + assert monitoring.create_monitoring_bucket_add(vehicles=["VIN-1", "bad-vin"]).invalid_vehicles[0].vehicle_id == "bad-vin" + assert monitoring.delete_bucket().success is True + assert monitoring.remove_bucket(vehicles=["VIN-1"]).success is True + assert monitoring.get_monitoring_reg_actions(query=MonitoringEventsQuery(limit=10)).items[0].operation_code == 11 + assert scoring.create_scoring_by_vehicle_id(vehicle_id="VIN-1").scoring_id == "801" + assert scoring.get_scoring_by_id().is_completed is True + assert valuation.get_valuation_by_specification(specification_id=501, mileage=30000).avg_price_with_condition == 2100000 diff --git a/tests/domains/cpa/test_cpa.py b/tests/domains/cpa/test_cpa.py new file mode 100644 index 0000000..2244da1 --- /dev/null +++ b/tests/domains/cpa/test_cpa.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import json + +import httpx + +from avito.cpa import CallTrackingCall, CpaArchive, CpaCall, CpaChat, CpaLead +from avito.cpa.models import ( + CpaChatsByTimeRequest, + CpaLeadComplaintRequest, + CpaPhonesFromChatsRequest, +) +from tests.helpers.transport import make_transport + + +def test_cpa_chat_and_phone_flows() -> None: + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + payload = json.loads(request.content.decode()) if request.content else None + if path == "/cpa/v1/chatByActionId/act-1": + return httpx.Response(200, json={"chat": {"chat": {"id": "chat-1", "actionId": "act-1"}, "buyer": {"userId": 501, "name": "Иван"}, "item": {"id": 9001, "title": "Велосипед"}, "isArbitrageAvailable": True}}) + if path == "/cpa/v1/chatsByTime": + assert payload == {"createdAtFrom": "2026-04-18T00:00:00+03:00"} + return httpx.Response(200, json={"chats": [{"chat": {"id": "chat-v1", "actionId": "legacy-1"}, "buyer": {"userId": 502, "name": "Петр"}, "item": {"id": 9002, "title": "Самокат"}, "isArbitrageAvailable": False}]}) + if path == "/cpa/v2/chatsByTime": + return httpx.Response(200, json={"chats": [{"chat": {"id": "chat-v2", "actionId": "act-2"}, "buyer": {"userId": 503, "name": "Мария"}, "item": {"id": 9003, "title": "Ноутбук"}, "isArbitrageAvailable": True}]}) + return httpx.Response(200, json={"total": 2, "results": [{"id": 101, "date": "2026-04-18T12:00:00+03:00", "phone_number": "+79990000001"}, {"id": 102, "date": "2026-04-18T12:05:00+03:00", "phone_number": "+79990000002"}]}) + + chat = CpaChat(make_transport(httpx.MockTransport(handler)), action_id="act-1") + assert chat.get().item_title == "Велосипед" + assert chat.list(request=CpaChatsByTimeRequest(created_at_from="2026-04-18T00:00:00+03:00"), version=1).items[0].buyer_name == "Петр" + assert chat.list(request=CpaChatsByTimeRequest(created_at_from="2026-04-18T00:00:00+03:00", limit=10)).items[0].is_arbitrage_available is True + assert chat.get_phones_info_from_chats(request=CpaPhonesFromChatsRequest(action_ids=["act-1", "act-2"])).items[1].phone_number == "+79990000002" + + +def test_cpa_calls_archive_and_balance_flows() -> None: + audio_bytes = b"ID3 fake audio" + + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + if path == "/cpa/v2/callsByTime": + return httpx.Response(200, json={"calls": [{"id": 2001, "itemId": 3001, "buyerPhone": "+79990000010", "sellerPhone": "+79990000011", "virtualPhone": "+79990000012", "statusId": 2, "price": 171600, "duration": 119, "waitingDuration": 0.5, "createTime": "2026-04-18T11:00:00+03:00", "recordUrl": "https://example.com/record-2001.mp3"}]}) + if path == "/cpa/v1/createComplaint": + return httpx.Response(200, json={"success": True}) + if path == "/cpa/v1/createComplaintByActionId": + return httpx.Response(200, json={"success": True}) + if path == "/cpa/v3/balanceInfo": + return httpx.Response(200, json={"balance": -5000}) + if path == "/cpa/v2/balanceInfo": + return httpx.Response(200, json={"balance": -5000, "advance": 1000, "debt": 0}) + if path == "/cpa/v2/callById": + return httpx.Response(200, json={"calls": {"id": 2001, "itemId": 3001, "buyerPhone": "+79990000010", "sellerPhone": "+79990000011", "virtualPhone": "+79990000012", "statusId": 2, "price": 171600, "duration": 119, "waitingDuration": 0.5, "createTime": "2026-04-18T11:00:00+03:00"}}) + return httpx.Response(200, content=audio_bytes, headers={"content-type": "audio/mpeg", "content-disposition": 'attachment; filename="call-2001.mp3"'}) + + transport = make_transport(httpx.MockTransport(handler)) + cpa_call = CpaCall(transport) + cpa_lead = CpaLead(transport) + archive = CpaArchive(transport, call_id="2001") + + assert cpa_call.list(date_time_from="2026-04-18T00:00:00+03:00", date_time_to="2026-04-18T23:59:59+03:00").items[0].record_url == "https://example.com/record-2001.mp3" + assert cpa_call.create_complaint(call_id=2001, reason="spam").success is True + assert cpa_lead.create_complaint_by_action_id(request=CpaLeadComplaintRequest(action_id="act-1", reason="duplicate")).success is True + assert cpa_lead.get_balance_info().balance == -5000 + assert archive.get_balance_info().advance == 1000 + assert archive.get_call_by_id(call_id=2001).call_id == "2001" + assert archive.get_call().binary.content == audio_bytes + + +def test_calltracking_flows() -> None: + audio_bytes = b"RIFF fake wave" + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/calltracking/v1/getCallById/": + return httpx.Response(200, json={"call": {"callId": 7001, "itemId": 9901, "buyerPhone": "+79990000100", "sellerPhone": "+79990000101", "virtualPhone": "+79990000102", "callTime": "2026-04-18T09:00:00Z", "talkDuration": 67, "waitingDuration": 1.25}, "error": {"code": 0, "message": ""}}) + if request.url.path == "/calltracking/v1/getCalls/": + return httpx.Response(200, json={"calls": [{"callId": 7001, "itemId": 9901, "buyerPhone": "+79990000100", "sellerPhone": "+79990000101", "virtualPhone": "+79990000102", "callTime": "2026-04-18T09:00:00Z", "talkDuration": 67, "waitingDuration": 1.25}], "error": {"code": 0, "message": ""}}) + return httpx.Response(200, content=audio_bytes, headers={"content-type": "audio/wav", "content-disposition": 'attachment; filename="record-7001.wav"'}) + + call = CallTrackingCall(make_transport(httpx.MockTransport(handler)), call_id="7001") + assert call.get().call.call_id == "7001" + assert call.list(date_time_from="2026-04-01T00:00:00Z", date_time_to="2026-04-18T23:59:59Z", limit=100, offset=0).items[0].buyer_phone == "+79990000100" + assert call.download().binary.content == audio_bytes diff --git a/tests/domains/jobs/test_jobs.py b/tests/domains/jobs/test_jobs.py new file mode 100644 index 0000000..e1c22a4 --- /dev/null +++ b/tests/domains/jobs/test_jobs.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import httpx + +from avito.jobs import Application, JobDictionary, JobWebhook, Resume, Vacancy +from avito.jobs.models import ( + ApplicationIdsQuery, + ApplicationIdsRequest, + ApplicationViewedItem, + ResumeSearchQuery, + VacancyArchiveRequest, + VacancyAutoRenewalRequest, + VacancyProlongateRequest, + VacancyUpdateRequest, +) +from tests.helpers.transport import make_transport + + +def test_application_webhook_and_resume_flows() -> None: + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + if path == "/job/v1/applications/get_ids": + return httpx.Response(200, json={"items": [{"id": "app-1", "updatedAt": "2026-04-18T10:00:00+03:00"}], "cursor": "app-1"}) + if path == "/job/v1/applications/get_by_ids": + return httpx.Response(200, json={"applies": [{"id": "app-1", "vacancy_id": 101, "state": "new", "is_viewed": False, "applicant": {"name": "Иван"}}]}) + if path == "/job/v1/applications/get_states": + return httpx.Response(200, json={"states": [{"slug": "new", "description": "Новый отклик"}]}) + if path == "/job/v1/applications/set_is_viewed": + return httpx.Response(200, json={"ok": True, "status": "viewed"}) + if path == "/job/v1/applications/apply_actions": + return httpx.Response(200, json={"ok": True, "status": "invited"}) + if path == "/job/v1/applications/webhook" and request.method == "GET": + return httpx.Response(200, json={"url": "https://example.com/job", "is_active": True, "version": "v1"}) + if path == "/job/v1/applications/webhook" and request.method == "PUT": + return httpx.Response(200, json={"url": "https://example.com/job", "is_active": True, "version": "v1"}) + if path == "/job/v1/applications/webhook" and request.method == "DELETE": + return httpx.Response(200, json={"ok": True}) + if path == "/job/v1/applications/webhooks": + return httpx.Response(200, json=[{"url": "https://example.com/job", "is_active": True, "version": "v1"}]) + if path == "/job/v1/resumes/": + return httpx.Response(200, json={"meta": {"cursor": "2", "total": 1}, "resumes": [{"id": "res-1", "title": "Оператор call-центра", "name": "Петр", "location": "Москва", "salary": 90000}]} ) + if path == "/job/v1/resumes/res-1/contacts/": + return httpx.Response(200, json={"name": "Петр", "phone": "+79990000000", "email": "petr@example.com"}) + return httpx.Response(200, json={"id": "res-1", "title": "Оператор call-центра", "fullName": "Петр Петров", "address_details": {"location": "Москва"}, "salary": {"from": 90000}}) + + transport = make_transport(httpx.MockTransport(handler)) + application = Application(transport) + webhook = JobWebhook(transport) + resume = Resume(transport, resume_id="res-1") + + assert application.list(query=ApplicationIdsQuery(updated_at_from="2026-04-18")).items[0].id == "app-1" + assert application.list(request=ApplicationIdsRequest(ids=["app-1"])).items[0].applicant_name == "Иван" + assert application.get_states().items[0].slug == "new" + assert application.update(applies=[ApplicationViewedItem(id="app-1", is_viewed=True)]).status == "viewed" + assert application.apply(ids=["app-1"], action="invited").status == "invited" + assert webhook.get().url == "https://example.com/job" + assert webhook.update(url="https://example.com/job").is_active is True + assert webhook.delete(url="https://example.com/job").success is True + assert webhook.list().items[0].version == "v1" + assert resume.list(query=ResumeSearchQuery(query="оператор")).items[0].candidate_name == "Петр" + assert resume.get_contacts().phone == "+79990000000" + assert resume.get().location == "Москва" + + +def test_vacancy_and_dictionary_flows() -> None: + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + if path == "/job/v1/vacancies": + return httpx.Response(201, json={"id": 101, "status": "created"}) + if path == "/job/v1/vacancies/101": + return httpx.Response(200, json={"ok": True, "status": "updated"}) + if path == "/job/v1/vacancies/archived/101": + return httpx.Response(200, json={"ok": True, "status": "archived"}) + if path == "/job/v1/vacancies/101/prolongate": + return httpx.Response(200, json={"ok": True, "status": "prolongated"}) + if path == "/job/v2/vacancies" and request.method == "GET": + return httpx.Response(200, json={"vacancies": [{"id": 101, "uuid": "vac-uuid-1", "title": "Продавец", "status": "active"}], "total": 1}) + if path == "/job/v2/vacancies": + return httpx.Response(202, json={"vacancy_uuid": "vac-uuid-1", "status": "created"}) + if path == "/job/v2/vacancies/batch": + return httpx.Response(200, json={"vacancies": [{"id": 101, "uuid": "vac-uuid-1", "title": "Продавец", "status": "active"}]}) + if path == "/job/v2/vacancies/statuses": + return httpx.Response(200, json={"items": [{"id": 101, "uuid": "vac-uuid-1", "status": "active"}]}) + if path == "/job/v2/vacancies/update/vac-uuid-1": + return httpx.Response(202, json={"vacancy_uuid": "vac-uuid-1", "status": "updated"}) + if path == "/job/v2/vacancies/101": + return httpx.Response(200, json={"id": 101, "uuid": "vac-uuid-1", "title": "Продавец", "status": "active", "url": "https://avito.ru/vacancy/101"}) + if path == "/job/v2/vacancies/vac-uuid-1/auto_renewal": + return httpx.Response(200, json={"ok": True, "status": "auto-renewal-updated"}) + if path == "/job/v2/vacancy/dict": + return httpx.Response(200, json=[{"id": "profession", "description": "Профессия"}]) + return httpx.Response(200, json=[{"id": 10106, "name": "IT, интернет, телеком", "deprecated": True}]) + + transport = make_transport(httpx.MockTransport(handler)) + vacancy = Vacancy(transport, vacancy_id="101") + dictionary = JobDictionary(transport, dictionary_id="profession") + + assert vacancy.create(title="Продавец", version=1).id == "101" + assert vacancy.update(request=VacancyUpdateRequest(title="Старший продавец"), version=1).status == "updated" + assert vacancy.delete(request=VacancyArchiveRequest(employee_id=7)).status == "archived" + assert vacancy.prolongate(request=VacancyProlongateRequest(billing_type="package")).status == "prolongated" + assert vacancy.list().items[0].uuid == "vac-uuid-1" + assert vacancy.create(title="Вакансия v2").id == "vac-uuid-1" + assert vacancy.get_by_ids(ids=[101]).items[0].title == "Продавец" + assert vacancy.get_statuses(ids=[101]).items[0].status == "active" + assert vacancy.update(request=VacancyUpdateRequest(title="Вакансия v2 updated"), version=2, vacancy_uuid="vac-uuid-1").status == "updated" + assert vacancy.get().url == "https://avito.ru/vacancy/101" + assert vacancy.update_auto_renewal(request=VacancyAutoRenewalRequest(auto_renewal=True), vacancy_uuid="vac-uuid-1").status == "auto-renewal-updated" + assert dictionary.list().items[0].id == "profession" + assert dictionary.get().items[0].deprecated is True diff --git a/tests/domains/messenger/test_messenger.py b/tests/domains/messenger/test_messenger.py new file mode 100644 index 0000000..96a4576 --- /dev/null +++ b/tests/domains/messenger/test_messenger.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import json + +import httpx + +from avito.messenger import Chat, ChatMedia, ChatMessage, ChatWebhook, SpecialOfferCampaign +from avito.messenger.models import UploadImageFile +from tests.helpers.transport import make_transport + + +def test_messenger_chat_message_and_media_flows() -> None: + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + if path == "/messenger/v2/accounts/7/chats": + return httpx.Response(200, json={"chats": [{"id": "chat-1", "user_id": 7, "title": "Покупатель"}]}) + if path == "/messenger/v2/accounts/7/chats/chat-1": + return httpx.Response(200, json={"id": "chat-1", "user_id": 7, "title": "Покупатель"}) + if path == "/messenger/v1/accounts/7/chats/chat-1/messages": + assert json.loads(request.content.decode()) == {"message": "Здравствуйте"} + return httpx.Response(200, json={"success": True, "message_id": "msg-1", "status": "sent"}) + if path == "/messenger/v1/accounts/7/uploadImages": + return httpx.Response(200, json={"images": [{"image_id": "img-1", "url": "https://cdn/img-1.jpg"}]}) + assert path == "/messenger/v1/accounts/7/chats/chat-1/messages/image" + return httpx.Response(200, json={"success": True, "message_id": "msg-img-1", "status": "sent"}) + + transport = make_transport(httpx.MockTransport(handler)) + chat = Chat(transport, chat_id="chat-1", user_id=7) + message = ChatMessage(transport, user_id=7) + media = ChatMedia(transport, user_id=7) + + chats = chat.list() + info = chat.get() + sent = message.send_message(chat_id="chat-1", message="Здравствуйте") + uploaded = media.upload_images( + files=[UploadImageFile(field_name="image", filename="photo.jpg", content=b"binary", content_type="image/jpeg")] + ) + image_sent = message.send_image(chat_id="chat-1", image_id=uploaded.items[0].image_id or "") + + assert chats.items[0].chat_id == "chat-1" + assert info.title == "Покупатель" + assert sent.message_id == "msg-1" + assert uploaded.items[0].image_id == "img-1" + assert image_sent.message_id == "msg-img-1" + + +def test_messenger_webhook_and_special_offer_flows() -> None: + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + payload = json.loads(request.content.decode()) if request.content else None + if path == "/messenger/v1/subscriptions": + return httpx.Response(200, json={"subscriptions": [{"url": "https://example.com/hook", "version": "v2", "status": "active"}]}) + if path == "/messenger/v3/webhook": + assert payload == {"url": "https://example.com/hook", "secret": "top-secret"} + return httpx.Response(200, json={"success": True, "status": "subscribed"}) + if path == "/special-offers/v1/multiCreate": + assert payload == {"itemIds": [1], "message": "Скидка 10%", "discountPercent": 10} + return httpx.Response(200, json={"campaign_id": "camp-1", "status": "draft"}) + if path == "/special-offers/v1/multiConfirm": + return httpx.Response(200, json={"success": True, "status": "confirmed"}) + assert path == "/special-offers/v1/stats" + return httpx.Response(200, json={"campaign_id": "camp-1", "sent_count": 20, "delivered_count": 18, "read_count": 10}) + + transport = make_transport(httpx.MockTransport(handler)) + webhook = ChatWebhook(transport) + campaign = SpecialOfferCampaign(transport, campaign_id="camp-1") + + subscriptions = webhook.list() + subscribed = webhook.subscribe(url="https://example.com/hook", secret="top-secret") + created = campaign.create_multi(item_ids=[1], message="Скидка 10%", discount_percent=10) + confirmed = campaign.confirm_multi() + stats = campaign.get_stats() + + assert subscriptions.items[0].status == "active" + assert subscribed.status == "subscribed" + assert created.status == "draft" + assert confirmed.status == "confirmed" + assert stats.delivered_count == 18 diff --git a/tests/domains/orders/test_orders.py b/tests/domains/orders/test_orders.py new file mode 100644 index 0000000..8636379 --- /dev/null +++ b/tests/domains/orders/test_orders.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import json + +import httpx + +from avito.orders import DeliveryOrder, DeliveryTask, Order, OrderLabel, Stock +from avito.orders.models import ( + StockUpdateEntry, +) +from tests.helpers.transport import make_transport + + +def test_order_management_flows() -> None: + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + payload = json.loads(request.content.decode()) if request.content else None + if path == "/order-management/1/orders": + return httpx.Response(200, json={"orders": [{"id": "ord-1", "status": "new", "buyerInfo": {"fullName": "Иван"}}], "total": 1}) + if path == "/order-management/1/markings": + assert payload == {"orderId": "ord-1", "codes": ["abc"]} + return httpx.Response(200, json={"result": {"success": True, "orderId": "ord-1", "status": "marked"}}) + if path == "/order-management/1/order/applyTransition": + return httpx.Response(200, json={"result": {"success": True, "orderId": "ord-1", "status": "confirmed"}}) + if path == "/order-management/1/order/checkConfirmationCode": + return httpx.Response(200, json={"result": {"success": True, "orderId": "ord-1", "status": "code-valid"}}) + if path == "/order-management/1/order/getCourierDeliveryRange": + return httpx.Response(200, json={"result": {"address": "Москва", "timeIntervals": [{"id": "int-1", "date": "2026-04-18", "startAt": "10:00", "endAt": "12:00"}]}}) + if path == "/order-management/1/order/setCourierDeliveryRange": + return httpx.Response(200, json={"result": {"success": True, "status": "range-set"}}) + if path == "/order-management/1/order/setTrackingNumber": + return httpx.Response(200, json={"result": {"success": True, "status": "tracking-set"}}) + return httpx.Response(200, json={"result": {"success": True, "status": "return-accepted"}}) + + order = Order(make_transport(httpx.MockTransport(handler))) + assert order.list().items[0].buyer_name == "Иван" + assert order.update_markings(order_id="ord-1", codes=["abc"]).status == "marked" + assert order.apply(order_id="ord-1", transition="confirm").status == "confirmed" + assert order.check_confirmation_code(order_id="ord-1", code="1234").status == "code-valid" + assert order.get_courier_delivery_range().items[0].interval_id == "int-1" + assert order.set_courier_delivery_range(order_id="ord-1", interval_id="int-1").status == "range-set" + assert order.update_tracking_number(order_id="ord-1", tracking_number="TRK-1").status == "tracking-set" + assert order.accept_return_order(order_id="ord-1", postal_office_id="ops-1").status == "return-accepted" + + +def test_labels_delivery_and_stock_flows() -> None: + pdf_bytes = b"%PDF-1.4 fake" + + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + payload = json.loads(request.content.decode()) if request.content else None + if path == "/order-management/1/orders/labels": + return httpx.Response(200, json={"result": {"taskId": 42, "status": "created"}}) + if path == "/order-management/1/orders/labels/42/download": + return httpx.Response(200, content=pdf_bytes, headers={"content-type": "application/pdf", "content-disposition": 'attachment; filename="label-42.pdf"'}) + if path == "/createAnnouncement": + assert payload == {"orderId": "ord-1"} + return httpx.Response(200, json={"data": {"taskId": 11, "status": "announcement-created"}}) + if path == "/createParcel": + return httpx.Response(200, json={"data": {"parcelId": "par-1", "status": "parcel-created"}}) + if path == "/cancelAnnouncement": + return httpx.Response(200, json={"data": {"status": "announcement-cancelled"}}) + if path == "/delivery/order/changeParcelResult": + return httpx.Response(200, json={"data": {"status": "callback-accepted"}}) + if path == "/sandbox/changeParcels": + return httpx.Response(200, json={"data": {"status": "parcels-updated"}}) + if path == "/delivery-sandbox/tasks/51": + return httpx.Response(200, json={"data": {"taskId": 51, "status": "done"}}) + if path == "/stock-management/1/info": + return httpx.Response(200, json={"stocks": [{"item_id": 123321, "quantity": 5, "is_multiple": True, "is_unlimited": False, "is_out_of_stock": False}]}) + if path == "/stock-management/1/stocks": + return httpx.Response(200, json={"stocks": [{"item_id": 123321, "external_id": "AB123456", "success": True, "errors": []}]}) + return httpx.Response(200, json={"data": {"taskId": 51, "status": "done"}}) + + transport = make_transport(httpx.MockTransport(handler)) + label = OrderLabel(transport, task_id="42") + delivery = DeliveryOrder(transport) + task = DeliveryTask(transport, task_id="51") + stock = Stock(transport) + + assert label.create(order_ids=["ord-1"]).task_id == "42" + assert label.download().binary.content == pdf_bytes + assert delivery.create_announcement(order_id="ord-1").task_id == "11" + assert delivery.create(order_id="ord-1", parcel_id="par-1").parcel_id == "par-1" + assert delivery.delete(order_id="ord-1").status == "announcement-cancelled" + assert delivery.create_change_parcel_result(parcel_id="par-1", result="ok").status == "callback-accepted" + assert delivery.update_change_parcels(parcel_ids=["par-1"]).status == "parcels-updated" + assert task.get().status == "done" + assert stock.get(item_ids=[123321]).items[0].quantity == 5 + assert stock.update(stocks=[StockUpdateEntry(item_id=123321, quantity=7)]).items[0].success is True diff --git a/tests/domains/promotion/test_promotion.py b/tests/domains/promotion/test_promotion.py new file mode 100644 index 0000000..b024fec --- /dev/null +++ b/tests/domains/promotion/test_promotion.py @@ -0,0 +1,223 @@ +from __future__ import annotations + +import json +from datetime import datetime + +import httpx +import pytest + +from avito.ads import AdPromotion +from avito.core import ResponseMappingError, ValidationError +from avito.promotion import ( + AutostrategyCampaign, + BbipPromotion, + CpaAuction, + PromotionOrder, + TargetActionPricing, + TrxPromotion, +) +from avito.promotion.models import ( + BbipItem, +) +from tests.helpers.transport import make_transport + + +def test_promotion_service_dictionary_and_orders_flow() -> None: + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + payload = json.loads(request.content.decode()) if request.content else None + if path == "/promotion/v1/items/services/dict": + return httpx.Response(200, json={"items": [{"code": "x2", "title": "X2"}]}) + if path == "/promotion/v1/items/services/get": + assert payload == {"itemIds": [101]} + return httpx.Response(200, json={"items": [{"itemId": 101, "serviceCode": "x2", "serviceName": "X2", "price": 9900, "status": "available"}]}) + if path == "/promotion/v1/items/services/orders/get": + assert payload == {"itemIds": [101]} + return httpx.Response(200, json={"items": [{"orderId": "ord-1", "itemId": 101, "serviceCode": "x2", "status": "created"}]}) + assert path == "/promotion/v1/items/services/orders/status" + return httpx.Response(200, json={"orderId": "ord-1", "status": "processed", "items": [], "errors": []}) + + promotion = PromotionOrder(make_transport(httpx.MockTransport(handler)), order_id="ord-1") + assert promotion.get_service_dictionary().items[0].code == "x2" + assert promotion.list_services(item_ids=[101]).items[0].price == 9900 + assert promotion.list_orders(item_ids=[101]).items[0].order_id == "ord-1" + assert promotion.get_order_status().status == "processed" + + +def test_bbip_trx_and_target_action_flows() -> None: + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + payload = json.loads(request.content.decode()) if request.content else None + if path == "/promotion/v1/items/services/bbip/forecasts/get": + return httpx.Response(200, json={"items": [{"itemId": 101, "min": 10, "max": 25, "totalPrice": 7000, "totalOldPrice": 8400}]}) + if path == "/promotion/v1/items/services/bbip/orders/create": + return httpx.Response(200, json={"items": [{"itemId": 101, "success": True, "status": "created"}]}) + if path == "/promotion/v1/items/services/bbip/suggests/get": + return httpx.Response(200, json={"items": [{"itemId": 101, "duration": {"from": 1, "to": 7, "recommended": 5}, "budgets": [{"price": 1000, "oldPrice": 1200, "isRecommended": True}]}]}) + if path == "/trx-promo/1/apply": + return httpx.Response(200, json={"success": {"items": [{"itemID": 101, "success": True}]}}) + if path == "/trx-promo/1/cancel": + return httpx.Response(200, json={"success": {"items": [{"itemID": 101, "success": True}]}}) + if path == "/trx-promo/1/commissions": + return httpx.Response(200, json={"success": {"items": [{"itemID": 101, "commission": 1500, "isActive": True, "validCommissionRange": {"valueMin": 100, "valueMax": 2000, "step": 100}}]}}) + if path == "/auction/1/bids" and request.method == "GET": + return httpx.Response(200, json={"items": [{"itemID": 101, "pricePenny": 1300, "availablePrices": [{"pricePenny": 1200, "goodness": 1}]}]}) + if path == "/auction/1/bids": + assert payload == {"items": [{"itemID": 101, "pricePenny": 1500}]} + return httpx.Response(200, json={"items": [{"itemID": 101, "success": True}]}) + if path == "/cpxpromo/1/getBids/101": + return httpx.Response(200, json={"actionTypeID": 5, "selectedType": "manual", "manual": {"bidPenny": 1400, "limitPenny": 15000, "recBidPenny": 1500, "minBidPenny": 1000, "maxBidPenny": 2000, "minLimitPenny": 5000, "maxLimitPenny": 50000, "bids": [{"valuePenny": 1500, "minForecast": 2, "maxForecast": 5, "compare": 10}]}}) + if path == "/cpxpromo/1/getPromotionsByItemIds": + return httpx.Response(200, json={"items": [{"itemID": 102, "actionTypeID": 7, "autoPromotion": {"budgetPenny": 9000, "budgetType": "7d"}}]}) + if path == "/cpxpromo/1/remove": + return httpx.Response(200, json={"items": [{"itemID": 101, "success": True, "status": "removed"}]}) + if path == "/cpxpromo/1/setAuto": + return httpx.Response(200, json={"items": [{"itemID": 101, "success": True, "status": "auto"}]}) + return httpx.Response(200, json={"items": [{"itemID": 101, "success": True, "status": "manual"}]}) + + transport = make_transport(httpx.MockTransport(handler)) + bbip = BbipPromotion(transport, item_id=101) + trx = TrxPromotion(transport, item_id=101) + auction = CpaAuction(transport) + pricing = TargetActionPricing(transport, item_id=101) + + bbip_item = BbipItem(item_id=101, duration=7, price=1000, old_price=1200).to_dict() + trx_item = { + "item_id": 101, + "commission": 1500, + "date_from": datetime.fromisoformat("2026-04-18T00:00:00+00:00"), + } + + assert bbip.get_forecasts(items=[bbip_item]).items[0].max_views == 25 + assert bbip.create_order(items=[bbip_item]).status == "created" + assert bbip.get_suggests().items[0].duration is not None + assert trx.apply(items=[trx_item]).applied is True + assert trx.delete().applied is True + assert trx.get_commissions().items[0].valid_commission_range is not None + assert auction.get_user_bids(from_item_id=100, batch_size=50).items[0].available_prices[0].price_penny == 1200 + assert auction.create_item_bids(items=[{"item_id": 101, "price_penny": 1500}]).applied is True + assert pricing.get_bids().manual is not None + assert pricing.get_promotions_by_item_ids(item_ids=[101, 102]).items[0].auto is not None + assert pricing.delete().status == "removed" + assert pricing.update_auto(action_type_id=5, budget_penny=8000, budget_type="7d").status == "auto" + assert pricing.update_manual(action_type_id=5, bid_penny=1500, limit_penny=15000).status == "manual" + + +def test_autostrategy_flows() -> None: + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + if path == "/autostrategy/v1/budget": + return httpx.Response(200, json={"calcId": 501, "budget": {"recommended": {"total": 10100}, "minimal": {"total": 5100}, "priceRanges": []}}) + if path == "/autostrategy/v1/campaign/create": + return httpx.Response(200, json={"campaign": {"campaignId": 77, "campaignType": "AS", "version": 3}}) + if path == "/autostrategy/v1/campaign/edit": + return httpx.Response(200, json={"campaign": {"campaignId": 77, "campaignType": "AS", "version": 4}}) + if path == "/autostrategy/v1/campaign/info": + return httpx.Response(200, json={"campaign": {"campaignId": 77, "campaignType": "AS", "statusId": 1, "budget": 10000, "balance": 9000, "title": "Весенняя кампания", "version": 4}, "forecast": {"calls": {"from": 2, "to": 5}, "views": {"from": 30, "to": 50}}, "items": [{"itemId": 101, "isActive": True}]}) + if path == "/autostrategy/v1/campaign/stop": + return httpx.Response(200, json={"campaign": {"campaignId": 77, "campaignType": "AS", "version": 5}}) + if path == "/autostrategy/v1/campaigns": + return httpx.Response(200, json={"campaigns": [{"campaignId": 77, "campaignType": "AS", "statusId": 1, "budget": 10000}], "totalCount": 1}) + return httpx.Response(200, json={"stat": [{"date": "2026-04-18", "calls": 30, "views": 500}], "totals": {"calls": 30, "views": 500}}) + + campaign = AutostrategyCampaign(make_transport(httpx.MockTransport(handler)), campaign_id=77) + start_time = datetime.fromisoformat("2026-04-20T00:00:00+00:00") + finish_time = datetime.fromisoformat("2026-04-27T00:00:00+00:00") + assert campaign.create_budget(campaign_type="AS", start_time=start_time, finish_time=finish_time, items=[101, 102]).calc_id == 501 + assert campaign.create(campaign_type="AS", title="Весенняя кампания", budget=10000, calc_id=501, items=[101, 102], start_time=start_time, finish_time=finish_time).campaign is not None + assert campaign.update(campaign_id=77, version=3, title="Обновленная кампания").campaign is not None + assert campaign.get().campaign is not None + assert campaign.delete(version=4).campaign is not None + assert campaign.list( + limit=20, + offset=10, + status_id=[1, 2], + order_by=[("startTime", "asc")], + updated_from=datetime.fromisoformat("2026-04-01T00:00:00+00:00"), + updated_to=datetime.fromisoformat("2026-04-30T00:00:00+00:00"), + ).total_count == 1 + assert campaign.get_stat().totals is not None + + +def test_autostrategy_datetime_parameters_fail_fast_on_invalid_type() -> None: + campaign = AutostrategyCampaign( + make_transport(httpx.MockTransport(lambda request: httpx.Response(500))), + campaign_id=77, + ) + + with pytest.raises(ValidationError, match="`start_time` должен быть datetime."): + campaign.create_budget(campaign_type="AS", start_time="2026-04-20T00:00:00+00:00") # type: ignore[arg-type] + + with pytest.raises(ValidationError, match="`finish_time` должен быть datetime."): + campaign.create( + campaign_type="AS", + title="Весенняя кампания", + finish_time="2026-04-27T00:00:00+00:00", # type: ignore[arg-type] + ) + + with pytest.raises(ValidationError, match="`start_time` должен быть datetime."): + campaign.update( + version=3, + start_time="2026-04-20T00:00:00+00:00", # type: ignore[arg-type] + ) + + +def test_promotion_write_methods_keep_same_payload_in_dry_run_mode() -> None: + seen: list[tuple[str, dict[str, object]]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + payload = json.loads(request.content.decode()) if request.content else {} + seen.append((request.url.path, payload)) + if request.url.path == "/core/v1/accounts/7/items/101/vas": + return httpx.Response(200, json={"success": True, "status": "applied"}) + if request.url.path == "/promotion/v1/items/services/bbip/orders/create": + return httpx.Response(200, json={"items": [{"itemId": 101, "success": True, "status": "created", "orderId": "ord-1"}]}) + return httpx.Response(200, json={"success": {"items": [{"itemID": 101, "success": True}]}}) + + transport = make_transport(httpx.MockTransport(handler)) + ad_promotion = AdPromotion(transport, item_id=101, user_id=7) + bbip = BbipPromotion(transport, item_id=101) + trx = TrxPromotion(transport, item_id=101) + bbip_item = BbipItem(item_id=101, duration=7, price=1000, old_price=1200).to_dict() + trx_item = { + "item_id": 101, + "commission": 1500, + "date_from": datetime.fromisoformat("2026-04-18T00:00:00+00:00"), + } + + vas_preview = ad_promotion.apply_vas(codes=["xl"], dry_run=True) + bbip_preview = bbip.create_order(items=[bbip_item], dry_run=True) + trx_preview = trx.apply(items=[trx_item], dry_run=True) + + assert vas_preview.status == "preview" + assert bbip_preview.status == "preview" + assert trx_preview.status == "preview" + + vas_apply = ad_promotion.apply_vas(codes=["xl"]) + bbip_apply = bbip.create_order(items=[bbip_item]) + trx_apply = trx.apply(items=[trx_item]) + + assert seen[0][1] == vas_preview.request_payload + assert seen[1][1] == bbip_preview.request_payload + assert seen[2][1] == trx_preview.request_payload + assert vas_apply.request_payload == vas_preview.request_payload + assert bbip_apply.request_payload == bbip_preview.request_payload + assert trx_apply.request_payload == trx_preview.request_payload + + +def test_promotion_read_mappers_raise_on_invalid_shape() -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/promotion/v1/items/services/orders/status": + return httpx.Response(200, json={"items": []}) + if request.url.path == "/cpxpromo/1/getBids/101": + return httpx.Response(200, json={"items": []}) + return httpx.Response(200, json={"items": [{"itemID": 102}]}) + + transport = make_transport(httpx.MockTransport(handler)) + + with pytest.raises(ResponseMappingError): + PromotionOrder(transport, order_id="ord-2").get_order_status() + with pytest.raises(ResponseMappingError): + TargetActionPricing(transport, item_id=101).get_bids() + with pytest.raises(ResponseMappingError): + TargetActionPricing(transport, item_id=101).get_promotions_by_item_ids(item_ids=[102]) diff --git a/tests/domains/ratings/test_ratings.py b/tests/domains/ratings/test_ratings.py new file mode 100644 index 0000000..93e5aea --- /dev/null +++ b/tests/domains/ratings/test_ratings.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import json + +import httpx + +from avito.ratings import RatingProfile, Review, ReviewAnswer +from avito.ratings.models import ReviewsQuery +from tests.helpers.transport import make_transport + + +def test_ratings_flows() -> None: + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + if path == "/ratings/v1/answers": + assert json.loads(request.content.decode()) == {"reviewId": 123, "text": "Спасибо за отзыв"} + return httpx.Response(200, json={"id": 456, "createdAt": 1713427200}) + if path == "/ratings/v1/answers/456": + return httpx.Response(200, json={"success": True}) + if path == "/ratings/v1/info": + return httpx.Response(200, json={"isEnabled": True, "rating": {"score": 4.7, "reviewsCount": 25, "reviewsWithScoreCount": 20}}) + return httpx.Response(200, json={"total": 25, "reviews": [{"id": 123, "score": 5, "stage": "done", "text": "Все отлично", "createdAt": 1713427200, "canAnswer": True, "usedInScore": True}]}) + + transport = make_transport(httpx.MockTransport(handler)) + answer = ReviewAnswer(transport, answer_id="456") + profile = RatingProfile(transport) + review = Review(transport) + + assert answer.create(review_id=123, text="Спасибо за отзыв").answer_id == "456" + assert answer.delete().success is True + assert profile.get().score == 4.7 + assert review.list(query=ReviewsQuery(page=2)).items[0].text == "Все отлично" diff --git a/tests/domains/realty/test_realty.py b/tests/domains/realty/test_realty.py new file mode 100644 index 0000000..e8d4472 --- /dev/null +++ b/tests/domains/realty/test_realty.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import json + +import httpx + +from avito.realty import RealtyAnalyticsReport, RealtyBooking, RealtyListing, RealtyPricing +from avito.realty.models import ( + RealtyBaseParamsUpdateRequest, + RealtyBookingsUpdateRequest, + RealtyInterval, + RealtyPricePeriod, + RealtyPricesUpdateRequest, +) +from tests.helpers.transport import make_transport + + +def test_realty_bookings_require_expected_params_and_map_fields() -> None: + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + payload = json.loads(request.content.decode()) if request.content else None + if path == "/core/v1/accounts/10/items/20/bookings": + assert payload == {"blockedDates": ["2026-04-18"]} + return httpx.Response(200, json={"result": "success"}) + if path == "/realty/v1/accounts/10/items/20/bookings": + assert request.url.params["date_start"] == "2026-05-01" + assert request.url.params["date_end"] == "2026-05-05" + assert request.url.params["with_unpaid"] == "true" + return httpx.Response(200, json={"bookings": [{"avito_booking_id": 777, "status": "active", "check_in": "2026-05-01", "check_out": "2026-05-05", "guest_count": 2, "nights": 4, "base_price": 12000, "contact": {"name": "Иван", "email": "ivan@example.com", "phone": "9997770000"}, "safe_deposit": {"owner_amount": 4500, "tax": 500, "total_amount": 5000}}]}) + if path == "/realty/v1/accounts/10/items/20/prices": + return httpx.Response(200, json={"result": "success"}) + if path == "/realty/v1/items/intervals": + return httpx.Response(200, json={"result": "success"}) + if path == "/realty/v1/items/20/base": + return httpx.Response(200, json={"result": "success"}) + if path == "/realty/v1/marketPriceCorrespondence/20/5000000": + return httpx.Response(200, json={"correspondence": "normal"}) + return httpx.Response(200, json={"success": {"success": {"reportLink": "https://example.com/realty-report/20"}}}) + + transport = make_transport(httpx.MockTransport(handler)) + booking = RealtyBooking(transport, item_id="20", user_id="10") + pricing = RealtyPricing(transport, item_id="20", user_id="10") + listing = RealtyListing(transport, item_id="20") + analytics = RealtyAnalyticsReport(transport, item_id="20") + + assert booking.update_bookings_info(request=RealtyBookingsUpdateRequest(blocked_dates=["2026-04-18"])).success is True + bookings = booking.list_realty_bookings(date_start="2026-05-01", date_end="2026-05-05", with_unpaid=True) + assert bookings.items[0].contact is not None + assert bookings.items[0].contact.name == "Иван" + assert pricing.update_realty_prices(request=RealtyPricesUpdateRequest(periods=[RealtyPricePeriod(date_from="2026-05-01", price=5000)])).status == "success" + assert listing.get_intervals(intervals=[RealtyInterval(date="2026-05-01", available=True)]).success is True + assert listing.update_base_params(request=RealtyBaseParamsUpdateRequest(min_stay_days=2)).success is True + assert analytics.get_market_price_correspondence(price=5000000).correspondence == "normal" + assert analytics.get_report_for_classified().report_link == "https://example.com/realty-report/20" diff --git a/tests/domains/tariffs/test_tariffs.py b/tests/domains/tariffs/test_tariffs.py new file mode 100644 index 0000000..97a96b3 --- /dev/null +++ b/tests/domains/tariffs/test_tariffs.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import httpx + +from avito.tariffs import Tariff +from tests.helpers.transport import make_transport + + +def test_tariff_flow() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/tariff/info/1" + return httpx.Response( + 200, + json={ + "current": { + "level": "Тариф Максимальный", + "isActive": True, + "startTime": 1713427200, + "closeTime": 1716029200, + "bonus": 10, + "packages": [{"id": 1}, {"id": 2}], + "price": {"price": 1990, "originalPrice": 2490}, + }, + "scheduled": { + "level": "Тариф Базовый", + "isActive": False, + "startTime": 1716029300, + "closeTime": None, + "bonus": 0, + "packages": [], + "price": {"price": 990, "originalPrice": 990}, + }, + }, + ) + + tariff = Tariff(make_transport(httpx.MockTransport(handler))) + info = tariff.get_tariff_info() + + assert info.current is not None + assert info.current.level == "Тариф Максимальный" + assert info.current.packages_count == 2 diff --git a/tests/fake_transport.py b/tests/fake_transport.py new file mode 100644 index 0000000..37c8bbd --- /dev/null +++ b/tests/fake_transport.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import json +from collections import deque +from collections.abc import Callable, Iterable, Mapping +from dataclasses import dataclass + +import httpx + +from avito.auth import AuthSettings +from avito.config import AvitoSettings +from avito.core import Transport +from avito.core.retries import RetryPolicy +from avito.core.types import ApiTimeouts + +JsonValue = dict[str, object] | list[object] | str | int | float | bool | None + + +@dataclass(slots=True, frozen=True) +class RecordedRequest: + method: str + path: str + params: dict[str, str] + headers: dict[str, str] + json_body: JsonValue + content: bytes + + +RouteResponder = Callable[[RecordedRequest], httpx.Response] | httpx.Response + + +class FakeTransport: + """Deterministic fake transport for SDK contract tests.""" + + def __init__(self, *, base_url: str = "https://api.avito.ru") -> None: + self.base_url = base_url.rstrip("/") + self.requests: list[RecordedRequest] = [] + self._routes: dict[tuple[str, str], deque[RouteResponder]] = {} + + def add( + self, + method: str, + path: str, + *responses: RouteResponder, + ) -> FakeTransport: + key = (method.upper(), path) + bucket = self._routes.setdefault(key, deque()) + bucket.extend(responses) + return self + + def add_json( + self, + method: str, + path: str, + payload: JsonValue, + *, + status_code: int = 200, + headers: Mapping[str, str] | None = None, + ) -> FakeTransport: + return self.add( + method, + path, + httpx.Response( + status_code, + json=payload, + headers=dict(headers or {}), + ), + ) + + def build( + self, + *, + retry_policy: RetryPolicy | None = None, + user_id: int | None = None, + ) -> Transport: + settings = AvitoSettings( + base_url=self.base_url, + user_id=user_id, + auth=AuthSettings(), + retry_policy=retry_policy or RetryPolicy(), + timeouts=ApiTimeouts(), + ) + return Transport( + settings, + auth_provider=None, + client=httpx.Client( + transport=httpx.MockTransport(self._handle), base_url=self.base_url + ), + sleep=lambda _: None, + ) + + def count(self, *, method: str | None = None, path: str | None = None) -> int: + return len( + [ + request + for request in self.requests + if (method is None or request.method == method.upper()) + and (path is None or request.path == path) + ] + ) + + def last(self, *, method: str | None = None, path: str | None = None) -> RecordedRequest: + matches = [ + request + for request in self.requests + if (method is None or request.method == method.upper()) + and (path is None or request.path == path) + ] + if not matches: + raise AssertionError(f"No requests matched method={method!r} path={path!r}") + return matches[-1] + + def _handle(self, request: httpx.Request) -> httpx.Response: + recorded = RecordedRequest( + method=request.method.upper(), + path=request.url.path, + params=dict(request.url.params), + headers=dict(request.headers), + json_body=self._decode_json(request), + content=request.content, + ) + self.requests.append(recorded) + + key = (recorded.method, recorded.path) + if key not in self._routes: + available = ", ".join(f"{method} {path}" for method, path in sorted(self._routes)) + raise AssertionError( + f"Unexpected request {recorded.method} {recorded.path}. Known: {available}" + ) + + responders = self._routes[key] + responder = responders[0] if len(responders) == 1 else responders.popleft() + response = responder(recorded) if callable(responder) else responder + response.request = request + return response + + @staticmethod + def _decode_json(request: httpx.Request) -> JsonValue: + if not request.content: + return None + try: + return json.loads(request.content.decode()) + except json.JSONDecodeError: + return None + + +def json_response( + payload: JsonValue, + *, + status_code: int = 200, + headers: Mapping[str, str] | None = None, +) -> httpx.Response: + return httpx.Response(status_code, json=payload, headers=dict(headers or {})) + + +def route_sequence(*responses: RouteResponder) -> Iterable[RouteResponder]: + return responses diff --git a/tests/helpers/transport.py b/tests/helpers/transport.py new file mode 100644 index 0000000..e9dc311 --- /dev/null +++ b/tests/helpers/transport.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import httpx + +from avito.auth import AuthSettings +from avito.config import AvitoSettings +from avito.core import Transport +from avito.core.retries import RetryPolicy +from avito.core.types import ApiTimeouts + + +def make_transport(handler: httpx.MockTransport, *, user_id: int | None = None) -> Transport: + settings = AvitoSettings( + base_url="https://api.avito.ru", + user_id=user_id, + auth=AuthSettings(), + retry_policy=RetryPolicy(), + timeouts=ApiTimeouts(), + ) + return Transport( + settings, + auth_provider=None, + client=httpx.Client(transport=handler, base_url="https://api.avito.ru"), + sleep=lambda _: None, + ) diff --git a/tests/test_facade.py b/tests/test_facade.py deleted file mode 100644 index cce629d..0000000 --- a/tests/test_facade.py +++ /dev/null @@ -1,80 +0,0 @@ -from avito import AvitoClient -from avito.accounts import Account, AccountHierarchy -from avito.ads import Ad, AdPromotion, AdStats, AutoloadLegacy, AutoloadProfile, AutoloadReport -from avito.auth import AuthProvider -from avito.autoteka import ( - AutotekaMonitoring, - AutotekaReport, - AutotekaScoring, - AutotekaValuation, - AutotekaVehicle, -) -from avito.cpa import CallTrackingCall, CpaCall, CpaChat, CpaLead, CpaLegacy -from avito.jobs import Application, JobDictionary, JobWebhook, Resume, Vacancy -from avito.messenger import Chat, ChatMedia, ChatMessage, ChatWebhook, SpecialOfferCampaign -from avito.orders import DeliveryOrder, DeliveryTask, Order, OrderLabel, SandboxDelivery, Stock -from avito.promotion import ( - AutostrategyCampaign, - BbipPromotion, - CpaAuction, - PromotionOrder, - TargetActionPricing, - TrxPromotion, -) -from avito.ratings import RatingProfile, Review, ReviewAnswer -from avito.realty import RealtyAnalyticsReport, RealtyBooking, RealtyListing, RealtyPricing -from avito.tariffs import Tariff - - -def test_single_client_exposes_domain_factories() -> None: - client = AvitoClient() - - assert isinstance(client.auth(), AuthProvider) - assert isinstance(client.account(1), Account) - assert isinstance(client.account_hierarchy(1), AccountHierarchy) - assert isinstance(client.ad(1), Ad) - assert isinstance(client.ad_stats(1), AdStats) - assert isinstance(client.ad_promotion(1), AdPromotion) - assert isinstance(client.autoload_profile(1), AutoloadProfile) - assert isinstance(client.autoload_report(1), AutoloadReport) - assert isinstance(client.autoload_legacy(1), AutoloadLegacy) - assert isinstance(client.chat("chat-1", user_id=1), Chat) - assert isinstance(client.chat_message("msg-1", chat_id="chat-1", user_id=1), ChatMessage) - assert isinstance(client.chat_webhook(), ChatWebhook) - assert isinstance(client.chat_media("media-1", user_id=1), ChatMedia) - assert isinstance(client.special_offer_campaign(1), SpecialOfferCampaign) - assert isinstance(client.promotion_order(1), PromotionOrder) - assert isinstance(client.bbip_promotion(1), BbipPromotion) - assert isinstance(client.trx_promotion(1), TrxPromotion) - assert isinstance(client.cpa_auction(1), CpaAuction) - assert isinstance(client.target_action_pricing(1), TargetActionPricing) - assert isinstance(client.autostrategy_campaign(1), AutostrategyCampaign) - assert isinstance(client.order(1), Order) - assert isinstance(client.order_label(1), OrderLabel) - assert isinstance(client.delivery_order(1), DeliveryOrder) - assert isinstance(client.sandbox_delivery(1), SandboxDelivery) - assert isinstance(client.delivery_task(1), DeliveryTask) - assert isinstance(client.stock(1), Stock) - assert isinstance(client.vacancy(1), Vacancy) - assert isinstance(client.application(1), Application) - assert isinstance(client.resume(1), Resume) - assert isinstance(client.job_webhook(), JobWebhook) - assert isinstance(client.job_dictionary(1), JobDictionary) - assert isinstance(client.cpa_lead(1), CpaLead) - assert isinstance(client.cpa_chat(1), CpaChat) - assert isinstance(client.cpa_call(1), CpaCall) - assert isinstance(client.cpa_legacy(1), CpaLegacy) - assert isinstance(client.call_tracking_call(1), CallTrackingCall) - assert isinstance(client.autoteka_vehicle(1), AutotekaVehicle) - assert isinstance(client.autoteka_report(1), AutotekaReport) - assert isinstance(client.autoteka_monitoring(1), AutotekaMonitoring) - assert isinstance(client.autoteka_scoring(1), AutotekaScoring) - assert isinstance(client.autoteka_valuation(1), AutotekaValuation) - assert isinstance(client.realty_listing(1), RealtyListing) - assert isinstance(client.realty_booking(1), RealtyBooking) - assert isinstance(client.realty_pricing(1), RealtyPricing) - assert isinstance(client.realty_analytics_report(1), RealtyAnalyticsReport) - assert isinstance(client.review(1), Review) - assert isinstance(client.review_answer(1), ReviewAnswer) - assert isinstance(client.rating_profile(1), RatingProfile) - assert isinstance(client.tariff(1), Tariff) diff --git a/tests/test_inventory.py b/tests/test_inventory.py deleted file mode 100644 index efe2064..0000000 --- a/tests/test_inventory.py +++ /dev/null @@ -1,141 +0,0 @@ -from __future__ import annotations - -import json -import unicodedata -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -DOCS_DIR = ROOT / "docs" -INVENTORY = DOCS_DIR / "inventory.md" -HTTP_METHODS = {"get", "post", "put", "patch", "delete", "options", "head", "trace"} -INVENTORY_HEADER_MAP = { - "раздел": "section", - "документ": "document", - "метод": "method", - "путь": "path", - "описание": "summary", - "deprecated": "deprecated", - "пакет_sdk": "package_sdk", - "доменный_объект": "domain_object", - "публичный_метод_sdk": "public_method_sdk", - "тип_запроса": "type_request", - "тип_ответа": "type_response", - "тип_теста": "type_test", - "примечания": "notes", -} -DOC_TO_PACKAGE = { - "Авторизация.json": "auth", - "Информацияопользователе.json": "accounts", - "ИерархияАккаунтов.json": "accounts", - "Объявления.json": "ads", - "Автозагрузка.json": "ads", - "Мессенджер.json": "messenger", - "Рассылкаскидокиспецпредложенийвмессенджере.json": "messenger", - "Продвижение.json": "promotion", - "TrxPromo.json": "promotion", - "CPA-аукцион.json": "promotion", - "Настройкаценыцелевогодействия.json": "promotion", - "Автостратегия.json": "promotion", - "Управлениезаказами.json": "orders", - "Доставка.json": "orders", - "Управлениеостатками.json": "orders", - "АвитоРабота.json": "jobs", - "CPAАвито.json": "cpa", - "CallTracking[КТ].json": "cpa", - "Автотека.json": "autoteka", - "Краткосрочнаяаренда.json": "realty", - "Аналитикапонедвижимости.json": "realty", - "Рейтингииотзывы.json": "ratings", - "Тарифы.json": "tariffs", -} - - -def _normalize(value: str) -> str: - return "".join( - ch for ch in unicodedata.normalize("NFKC", value) if unicodedata.category(ch) != "Cf" - ) - - -def _resolve_doc_path(document: str) -> Path: - direct_path = DOCS_DIR / document - if direct_path.exists(): - return direct_path - - normalized_target = _normalize(document) - for candidate in DOCS_DIR.iterdir(): - if _normalize(candidate.name) == normalized_target: - return candidate - - raise FileNotFoundError(f"Swagger document not found: {document}") - - -def _read_inventory_rows() -> list[dict[str, str]]: - in_table = False - rows: list[dict[str, str]] = [] - header: list[str] = [] - for line in INVENTORY.read_text(encoding="utf-8").splitlines(): - if line.strip() == "": - in_table = True - continue - if line.strip() == "": - break - if not in_table or not line.startswith("|"): - continue - cells = [cell.strip() for cell in line.strip().strip("|").split("|")] - if cells and all(cell and set(cell) == {"-"} for cell in cells): - continue - if cells[0] == "раздел": - header = [INVENTORY_HEADER_MAP[cell] for cell in cells] - continue - rows.append(dict(zip(header, cells, strict=True))) - return rows - - -def _swagger_rows() -> list[dict[str, str]]: - rows: list[dict[str, str]] = [] - for document in sorted(DOC_TO_PACKAGE): - data = json.loads(_resolve_doc_path(document).read_text(encoding="utf-8")) - for path, path_item in data.get("paths", {}).items(): - for method, operation in path_item.items(): - if method.lower() not in HTTP_METHODS: - continue - rows.append( - { - "document": document, - "method": method.upper(), - "path": _normalize(path), - "deprecated": "да" if operation.get("deprecated", False) else "нет", - } - ) - return rows - - -def test_inventory_covers_all_swagger_operations() -> None: - inventory_rows = _read_inventory_rows() - inventory_index = { - (row["document"], row["method"], _normalize(row["path"]), row["deprecated"]): row - for row in inventory_rows - } - - swagger_rows = _swagger_rows() - - assert len(inventory_rows) == len(swagger_rows) - - for swagger_row in swagger_rows: - key = ( - swagger_row["document"], - swagger_row["method"], - swagger_row["path"], - swagger_row["deprecated"], - ) - assert key in inventory_index, key - - -def test_inventory_rows_are_complete_and_package_mapping_is_stable() -> None: - for row in _read_inventory_rows(): - assert row["package_sdk"] == DOC_TO_PACKAGE[row["document"]] - assert row["domain_object"] - assert row["public_method_sdk"] - assert row["type_request"] - assert row["type_response"] - assert row["type_test"] diff --git a/tests/test_stage10_autoteka.py b/tests/test_stage10_autoteka.py deleted file mode 100644 index 98b518b..0000000 --- a/tests/test_stage10_autoteka.py +++ /dev/null @@ -1,358 +0,0 @@ -from __future__ import annotations - -import json - -import httpx - -from avito.auth import AuthSettings -from avito.autoteka import ( - AutotekaMonitoring, - AutotekaReport, - AutotekaScoring, - AutotekaValuation, - AutotekaVehicle, -) -from avito.config import AvitoSettings -from avito.core import Transport -from avito.core.retries import RetryPolicy -from avito.core.types import ApiTimeouts - - -def make_transport(handler: httpx.MockTransport) -> Transport: - settings = AvitoSettings( - base_url="https://api.avito.ru", - auth=AuthSettings(), - retry_policy=RetryPolicy(), - timeouts=ApiTimeouts(), - ) - return Transport( - settings, - auth_provider=None, - client=httpx.Client(transport=handler, base_url="https://api.avito.ru"), - sleep=lambda _: None, - ) - - -def test_autoteka_vehicle_flows() -> None: - def handler(request: httpx.Request) -> httpx.Response: - path = request.url.path - payload = json.loads(request.content.decode()) if request.content else None - if path == "/autoteka/v1/catalogs/resolve": - assert payload == {"brandId": 1} - return httpx.Response( - 200, - json={ - "result": { - "fields": [ - { - "id": 110000, - "label": "Марка", - "dataType": "integer", - "values": [{"valueId": 1, "label": "Audi"}], - } - ] - } - }, - ) - if path == "/autoteka/v1/get-leads/": - assert payload == {"limit": 1} - return httpx.Response( - 200, - json={ - "pagination": {"lastId": 321}, - "result": [ - { - "id": 12, - "subscriptionId": 44, - "payload": { - "vin": "VIN-1", - "itemId": 901, - "brand": "Audi", - "model": "A4", - "price": 1500000, - "itemCreatedAt": "2026-04-18 10:00", - "url": "https://avito.ru/item/901", - }, - } - ], - }, - ) - if path == "/autoteka/v1/previews": - assert payload == {"vin": "VIN-1"} - return httpx.Response(200, json={"result": {"preview": {"previewId": 77}}}) - if path == "/autoteka/v1/request-preview-by-item-id": - assert payload == {"itemId": 901} - return httpx.Response(200, json={"result": {"preview": {"previewId": 78}}}) - if path == "/autoteka/v1/request-preview-by-regnumber": - assert payload == {"regNumber": "A123AA77"} - return httpx.Response(200, json={"result": {"preview": {"previewId": 79}}}) - if path == "/autoteka/v1/request-preview-by-external-item": - assert payload == {"itemId": "ext-1", "site": "cars.example"} - return httpx.Response(200, json={"result": {"preview": {"previewId": 80}}}) - if path == "/autoteka/v1/previews/77": - return httpx.Response( - 200, - json={ - "result": { - "preview": { - "previewId": 77, - "status": "success", - "vin": "VIN-1", - "regNumber": "A123AA77", - } - } - }, - ) - if path == "/autoteka/v1/specifications/by-plate-number": - assert payload == {"plateNumber": "A123AA77"} - return httpx.Response(200, json={"result": {"specification": {"specificationId": 501}}}) - if path == "/autoteka/v1/specifications/by-vehicle-id": - assert payload == {"vehicleId": "VIN-1"} - return httpx.Response(200, json={"result": {"specification": {"specificationId": 502}}}) - if path == "/autoteka/v1/specifications/specification/501": - return httpx.Response( - 200, - json={ - "result": { - "specification": { - "specificationId": 501, - "status": "success", - "vehicleId": "VIN-1", - "plateNumber": "A123AA77", - } - } - }, - ) - if path == "/autoteka/v1/teasers": - assert payload == {"vehicleId": "VIN-1"} - return httpx.Response( - 200, json={"result": {"teaser": {"teaserId": 601, "status": "processing"}}} - ) - assert path == "/autoteka/v1/teasers/601" - return httpx.Response( - 200, - json={ - "teaserId": 601, - "status": "success", - "data": {"brand": "Audi", "model": "A4", "year": 2018}, - }, - ) - - vehicle = AutotekaVehicle(make_transport(httpx.MockTransport(handler)), resource_id="77") - - catalog = vehicle.get_catalogs_resolve(payload={"brandId": 1}) - leads = vehicle.get_leads(payload={"limit": 1}) - preview_vin = vehicle.create_preview_by_vin(payload={"vin": "VIN-1"}) - preview_item = vehicle.create_preview_by_item_id(payload={"itemId": 901}) - preview_reg = vehicle.create_preview_by_reg_number(payload={"regNumber": "A123AA77"}) - preview_external = vehicle.create_preview_by_external_item( - payload={"itemId": "ext-1", "site": "cars.example"} - ) - preview = vehicle.get_preview() - specification_plate = vehicle.create_specification_by_plate_number( - payload={"plateNumber": "A123AA77"} - ) - specification_vehicle = vehicle.create_specification_by_vehicle_id( - payload={"vehicleId": "VIN-1"} - ) - specification = vehicle.get_specification_get_by_id(specification_id="501") - teaser_create = vehicle.create_teaser(payload={"vehicleId": "VIN-1"}) - teaser = vehicle.get_teaser(teaser_id="601") - - assert catalog.items[0].values[0].label == "Audi" - assert leads.last_id == 321 - assert leads.items[0].brand == "Audi" - assert preview_vin.preview_id == "77" - assert preview_item.preview_id == "78" - assert preview_reg.preview_id == "79" - assert preview_external.preview_id == "80" - assert preview.vehicle_id == "VIN-1" - assert specification_plate.specification_id == "501" - assert specification_vehicle.specification_id == "502" - assert specification.status == "success" - assert teaser_create.teaser_id == "601" - assert teaser.brand == "Audi" - - -def test_autoteka_report_monitoring_scoring_and_valuation_flows() -> None: - def handler(request: httpx.Request) -> httpx.Response: - path = request.url.path - payload = json.loads(request.content.decode()) if request.content else None - if path == "/autoteka/v1/packages/active_package": - return httpx.Response( - 200, - json={ - "result": { - "package": { - "createdTime": "2026-04-01", - "expireTime": "2026-05-01", - "reportsCnt": 100, - "reportsCntRemain": 77, - } - } - }, - ) - if path == "/autoteka/v1/reports": - assert payload == {"previewId": 77} - return httpx.Response( - 200, json={"result": {"report": {"reportId": 701, "status": "processing"}}} - ) - if path == "/autoteka/v1/reports-by-vehicle-id": - assert payload == {"vehicleId": "VIN-1"} - return httpx.Response( - 200, json={"result": {"report": {"reportId": 702, "status": "processing"}}} - ) - if path == "/autoteka/v1/reports/list/": - return httpx.Response( - 200, - json={ - "result": [ - {"reportId": 701, "vin": "VIN-1", "createdAt": "2026-04-18 12:00:00"} - ] - }, - ) - if path == "/autoteka/v1/reports/701": - return httpx.Response( - 200, - json={ - "result": { - "report": { - "reportId": 701, - "status": "success", - "webLink": "https://autoteka/web/701", - "pdfLink": "https://autoteka/pdf/701", - "data": {"vin": "VIN-1"}, - } - } - }, - ) - if path == "/autoteka/v1/sync/create-by-regnumber": - assert payload == {"regNumber": "A123AA77"} - return httpx.Response( - 200, - json={ - "result": { - "report": {"reportId": 703, "status": "success", "data": {"vin": "VIN-1"}} - } - }, - ) - if path == "/autoteka/v1/sync/create-by-vin": - assert payload == {"vin": "VIN-1"} - return httpx.Response( - 200, - json={ - "result": { - "report": {"reportId": 704, "status": "success", "data": {"vin": "VIN-1"}} - } - }, - ) - if path == "/autoteka/v1/monitoring/bucket/add": - assert payload == {"vehicles": ["VIN-1", "bad-vin"]} - return httpx.Response( - 200, - json={ - "result": { - "isOk": True, - "invalidVehicles": [{"vehicleID": "bad-vin", "description": "invalid"}], - } - }, - ) - if path == "/autoteka/v1/monitoring/bucket/delete": - return httpx.Response(200, json={"result": {"isOk": True}}) - if path == "/autoteka/v1/monitoring/bucket/remove": - assert payload == {"vehicles": ["VIN-1"]} - return httpx.Response(200, json={"result": {"isOk": True, "invalidVehicles": []}}) - if path == "/autoteka/v1/monitoring/get-reg-actions/": - assert request.url.params["limit"] == "10" - return httpx.Response( - 200, - json={ - "data": [ - { - "vin": "VIN-1", - "brand": "Audi", - "model": "A4", - "year": 2018, - "operationCode": 11, - "operationDateFrom": "2026-04-01T00:00:00+03:00", - } - ], - "pagination": { - "hasNext": True, - "nextCursor": "cursor-2", - "nextLink": "https://api.avito.ru/next", - }, - }, - ) - if path == "/autoteka/v1/scoring/by-vehicle-id": - assert payload == {"vehicleId": "VIN-1"} - return httpx.Response(200, json={"result": {"scoring": {"scoringId": 801}}}) - if path == "/autoteka/v1/scoring/801": - return httpx.Response( - 200, - json={ - "result": { - "risksAssessment": { - "scoringId": 801, - "isCompleted": True, - "createdAt": 1713427200, - } - } - }, - ) - assert path == "/autoteka/v1/valuation/by-specification" - assert payload == {"specificationId": 501, "mileage": 30000} - return httpx.Response( - 200, - json={ - "result": { - "status": "success", - "vehicleId": "VIN-1", - "brand": "Audi", - "model": "A4", - "year": 2018, - "ownersCount": "2", - "mileage": 30000, - "valuation": {"avgPriceWithCondition": 2100000, "avgMarketPrice": 2200000}, - } - }, - ) - - transport = make_transport(httpx.MockTransport(handler)) - report = AutotekaReport(transport, resource_id="701") - monitoring = AutotekaMonitoring(transport) - scoring = AutotekaScoring(transport, resource_id="801") - valuation = AutotekaValuation(transport) - - package = report.get_active_package() - created = report.create_report(payload={"previewId": 77}) - created_by_vehicle = report.create_report_by_vehicle_id(payload={"vehicleId": "VIN-1"}) - reports = report.list_report_list() - fetched = report.get_report() - sync_reg = report.create_sync_create_report_by_reg_number(payload={"regNumber": "A123AA77"}) - sync_vin = report.create_sync_create_report_by_vin(payload={"vin": "VIN-1"}) - added = monitoring.create_monitoring_bucket_add(payload={"vehicles": ["VIN-1", "bad-vin"]}) - deleted = monitoring.list_monitoring_bucket_delete() - removed = monitoring.delete_monitoring_bucket_remove(payload={"vehicles": ["VIN-1"]}) - events = monitoring.get_monitoring_get_reg_actions(params={"limit": 10}) - scoring_created = scoring.create_scoring_by_vehicle_id(payload={"vehicleId": "VIN-1"}) - scoring_item = scoring.get_scoring_get_by_id() - valuation_item = valuation.get_valuation_by_specification( - payload={"specificationId": 501, "mileage": 30000} - ) - - assert package.reports_remaining == 77 - assert created.report_id == "701" - assert created_by_vehicle.report_id == "702" - assert reports.items[0].vehicle_id == "VIN-1" - assert fetched.web_link == "https://autoteka/web/701" - assert sync_reg.status == "success" - assert sync_vin.report_id == "704" - assert added.invalid_vehicles[0].vehicle_id == "bad-vin" - assert deleted.success is True - assert removed.success is True - assert events.has_next is True - assert events.items[0].operation_code == 11 - assert scoring_created.scoring_id == "801" - assert scoring_item.is_completed is True - assert valuation_item.avg_price_with_condition == 2100000 diff --git a/tests/test_stage11_realty_ratings_tariffs.py b/tests/test_stage11_realty_ratings_tariffs.py deleted file mode 100644 index 38fd9ef..0000000 --- a/tests/test_stage11_realty_ratings_tariffs.py +++ /dev/null @@ -1,195 +0,0 @@ -from __future__ import annotations - -import json - -import httpx - -from avito.auth import AuthSettings -from avito.config import AvitoSettings -from avito.core import Transport -from avito.core.retries import RetryPolicy -from avito.core.types import ApiTimeouts -from avito.ratings import RatingProfile, Review, ReviewAnswer -from avito.realty import RealtyAnalyticsReport, RealtyBooking, RealtyListing, RealtyPricing -from avito.tariffs import Tariff - - -def make_transport(handler: httpx.MockTransport) -> Transport: - settings = AvitoSettings( - base_url="https://api.avito.ru", - auth=AuthSettings(), - retry_policy=RetryPolicy(), - timeouts=ApiTimeouts(), - ) - return Transport( - settings, - auth_provider=None, - client=httpx.Client(transport=handler, base_url="https://api.avito.ru"), - sleep=lambda _: None, - ) - - -def test_realty_flows() -> None: - def handler(request: httpx.Request) -> httpx.Response: - path = request.url.path - payload = json.loads(request.content.decode()) if request.content else None - if path == "/core/v1/accounts/10/items/20/bookings": - assert payload == {"blockedDates": ["2026-04-18"]} - return httpx.Response(200, json={"result": "success"}) - if path == "/realty/v1/accounts/10/items/20/bookings": - return httpx.Response( - 200, - json={ - "bookings": [ - { - "avito_booking_id": 777, - "status": "active", - "check_in": "2026-05-01", - "check_out": "2026-05-05", - "guest_count": 2, - "base_price": 12000, - "contact": {"name": "Иван", "email": "ivan@example.com"}, - } - ] - }, - ) - if path == "/realty/v1/accounts/10/items/20/prices": - assert payload == {"periods": [{"dateFrom": "2026-05-01", "price": 5000}]} - return httpx.Response(200, json={"result": "success"}) - if path == "/realty/v1/items/intervals": - assert payload == { - "itemId": 20, - "intervals": [{"date": "2026-05-01", "available": True}], - } - return httpx.Response(200, json={"result": "success"}) - if path == "/realty/v1/items/20/base": - assert payload == {"minStayDays": 2} - return httpx.Response(200, json={"result": "success"}) - if path == "/realty/v1/marketPriceCorrespondence/20/5000000": - return httpx.Response(200, json={"correspondence": "normal"}) - assert path == "/realty/v1/report/create/20" - return httpx.Response( - 200, - json={"success": {"success": {"reportLink": "https://example.com/realty-report/20"}}}, - ) - - transport = make_transport(httpx.MockTransport(handler)) - booking = RealtyBooking(transport, resource_id="20", user_id="10") - pricing = RealtyPricing(transport, resource_id="20", user_id="10") - listing = RealtyListing(transport, resource_id="20") - analytics = RealtyAnalyticsReport(transport, resource_id="20") - - updated_bookings = booking.update_bookings_info(payload={"blockedDates": ["2026-04-18"]}) - bookings = booking.list_realty_bookings() - updated_prices = pricing.update_realty_prices( - payload={"periods": [{"dateFrom": "2026-05-01", "price": 5000}]} - ) - intervals = listing.get_intervals( - payload={"itemId": 20, "intervals": [{"date": "2026-05-01", "available": True}]} - ) - base = listing.update_base_params(payload={"minStayDays": 2}) - market = analytics.get_market_price_correspondence_v1(price=5000000) - report = analytics.get_report_for_classified() - - assert updated_bookings.success is True - assert bookings.items[0].guest_name == "Иван" - assert updated_prices.status == "success" - assert intervals.success is True - assert base.success is True - assert market.correspondence == "normal" - assert report.report_link == "https://example.com/realty-report/20" - - -def test_ratings_flows() -> None: - def handler(request: httpx.Request) -> httpx.Response: - path = request.url.path - if path == "/ratings/v1/answers": - assert json.loads(request.content.decode()) == { - "reviewId": 123, - "text": "Спасибо за отзыв", - } - return httpx.Response(200, json={"id": 456, "createdAt": 1713427200}) - if path == "/ratings/v1/answers/456": - return httpx.Response(200, json={"success": True}) - if path == "/ratings/v1/info": - return httpx.Response( - 200, - json={ - "isEnabled": True, - "rating": {"score": 4.7, "reviewsCount": 25, "reviewsWithScoreCount": 20}, - }, - ) - assert path == "/ratings/v1/reviews" - assert request.url.params["page"] == "2" - return httpx.Response( - 200, - json={ - "total": 25, - "reviews": [ - { - "id": 123, - "score": 5, - "stage": "done", - "text": "Все отлично", - "createdAt": 1713427200, - "canAnswer": True, - "usedInScore": True, - } - ], - }, - ) - - transport = make_transport(httpx.MockTransport(handler)) - answer = ReviewAnswer(transport, resource_id="456") - profile = RatingProfile(transport) - review = Review(transport) - - created = answer.create_review_answer_v1(payload={"reviewId": 123, "text": "Спасибо за отзыв"}) - deleted = answer.delete_review_answer_v1() - info = profile.get_ratings_info_v1() - reviews = review.list_reviews_v1(params={"page": 2}) - - assert created.answer_id == "456" - assert deleted.success is True - assert info.score == 4.7 - assert reviews.total == 25 - assert reviews.items[0].text == "Все отлично" - - -def test_tariff_flow() -> None: - def handler(request: httpx.Request) -> httpx.Response: - assert request.url.path == "/tariff/info/1" - return httpx.Response( - 200, - json={ - "current": { - "level": "Тариф Максимальный", - "isActive": True, - "startTime": 1713427200, - "closeTime": 1716029200, - "bonus": 10, - "packages": [{"id": 1}, {"id": 2}], - "price": {"price": 1990, "originalPrice": 2490}, - }, - "scheduled": { - "level": "Тариф Базовый", - "isActive": False, - "startTime": 1716029300, - "closeTime": None, - "bonus": 0, - "packages": [], - "price": {"price": 990, "originalPrice": 990}, - }, - }, - ) - - tariff = Tariff(make_transport(httpx.MockTransport(handler))) - - info = tariff.get_tariff_info() - - assert info.current is not None - assert info.current.level == "Тариф Максимальный" - assert info.current.packages_count == 2 - assert info.current.price == 1990 - assert info.scheduled is not None - assert info.scheduled.is_active is False diff --git a/tests/test_stage12_release_gate.py b/tests/test_stage12_release_gate.py deleted file mode 100644 index a8505af..0000000 --- a/tests/test_stage12_release_gate.py +++ /dev/null @@ -1,54 +0,0 @@ -from __future__ import annotations - -import httpx - -from avito import AvitoClient -from avito.auth import AuthProvider, LegacyTokenClient, TokenClient -from avito.auth.settings import AuthSettings -from avito.config import AvitoSettings -from avito.core import Transport - - -def test_debug_info_does_not_expose_secrets() -> None: - settings = AvitoSettings( - auth=AuthSettings(client_id="client-id", client_secret="super-secret"), - ) - - client = AvitoClient(settings) - info = client.debug_info() - - assert info.base_url == "https://api.avito.ru" - assert info.requires_auth is True - assert info.retry_max_attempts == settings.retry_policy.max_attempts - assert "secret" not in repr(info).lower() - client.close() - - -def test_client_context_manager_closes_transport_and_auth_clients() -> None: - transport_http_client = httpx.Client() - token_http_client = httpx.Client() - legacy_http_client = httpx.Client() - autoteka_http_client = httpx.Client() - - settings = AvitoSettings( - auth=AuthSettings(client_id="client-id", client_secret="client-secret"), - ) - auth_provider = AuthProvider( - settings.auth, - token_client=TokenClient(settings.auth, client=token_http_client), - legacy_token_client=LegacyTokenClient(settings.auth, client=legacy_http_client), - autoteka_token_client=TokenClient(settings.auth, client=autoteka_http_client), - ) - client = AvitoClient(settings) - client.transport = Transport( - settings, auth_provider=auth_provider, client=transport_http_client - ) - client.auth_provider = auth_provider - - with client as managed_client: - assert managed_client.debug_info().requires_auth is True - - assert transport_http_client.is_closed is True - assert token_http_client.is_closed is True - assert legacy_http_client.is_closed is True - assert autoteka_http_client.is_closed is True diff --git a/tests/test_stage4_accounts_ads.py b/tests/test_stage4_accounts_ads.py deleted file mode 100644 index c2e7f82..0000000 --- a/tests/test_stage4_accounts_ads.py +++ /dev/null @@ -1,355 +0,0 @@ -from __future__ import annotations - -import json - -import httpx - -from avito.accounts import Account, AccountHierarchy -from avito.ads import Ad, AdPromotion, AdStats, AutoloadLegacy, AutoloadProfile, AutoloadReport -from avito.auth import AuthSettings -from avito.config import AvitoSettings -from avito.core import Transport -from avito.core.retries import RetryPolicy -from avito.core.types import ApiTimeouts - - -def make_transport(handler: httpx.MockTransport) -> Transport: - settings = AvitoSettings( - base_url="https://api.avito.ru", - auth=AuthSettings(), - retry_policy=RetryPolicy(), - timeouts=ApiTimeouts(), - ) - return Transport( - settings, - auth_provider=None, - client=httpx.Client(transport=handler, base_url="https://api.avito.ru"), - sleep=lambda _: None, - ) - - -def test_accounts_domain_maps_profile_balance_and_operations() -> None: - def handler(request: httpx.Request) -> httpx.Response: - if request.url.path == "/core/v1/accounts/self": - return httpx.Response( - 200, json={"id": 7, "name": "Иван", "email": "user@example.com", "phone": "+7999"} - ) - if request.url.path == "/core/v1/accounts/7/balance/": - return httpx.Response( - 200, - json={"user_id": 7, "balance": {"real": 150.5, "bonus": 20.0, "currency": "RUB"}}, - ) - assert request.url.path == "/core/v1/accounts/operations_history/" - assert json.loads(request.content.decode()) == {"dateFrom": "2025-01-01", "limit": 2} - return httpx.Response( - 200, - json={ - "total": 1, - "operations": [ - { - "id": "op-1", - "created_at": "2025-01-02T12:00:00Z", - "amount": 120.0, - "type": "payment", - "status": "done", - } - ], - }, - ) - - transport = make_transport(httpx.MockTransport(handler)) - account = Account(transport, resource_id=7, user_id=7) - - profile = account.get_self() - balance = account.get_balance() - history = account.get_operations_history(date_from="2025-01-01", limit=2) - - assert profile.id == 7 - assert balance.total == 170.5 - assert history.total == 1 - assert history.operations[0].operation_type == "payment" - - -def test_account_hierarchy_domain_maps_employees_phones_and_items() -> None: - def handler(request: httpx.Request) -> httpx.Response: - if request.url.path == "/checkAhUserV1": - return httpx.Response(200, json={"user_id": 7, "is_active": True, "role": "manager"}) - if request.url.path == "/getEmployeesV1": - return httpx.Response( - 200, - json={"employees": [{"employee_id": 10, "user_id": 7, "name": "Пётр"}], "total": 1}, - ) - if request.url.path == "/listCompanyPhonesV1": - return httpx.Response( - 200, json={"phones": [{"id": 1, "phone": "+7000", "comment": "Основной"}]} - ) - if request.url.path == "/linkItemsV1": - assert json.loads(request.content.decode()) == {"employeeId": 10, "itemIds": [1, 2]} - return httpx.Response(200, json={"success": True, "message": "linked"}) - assert request.url.path == "/listItemsByEmployeeIdV1" - assert json.loads(request.content.decode()) == {"employeeId": 10, "limit": 5} - return httpx.Response( - 200, - json={ - "items": [{"item_id": 1, "title": "Объявление", "status": "active", "price": 99}], - "total": 1, - }, - ) - - hierarchy = AccountHierarchy( - make_transport(httpx.MockTransport(handler)), resource_id=7, user_id=7 - ) - - status = hierarchy.get_status() - employees = hierarchy.list_employees() - phones = hierarchy.list_company_phones() - linked = hierarchy.link_items(employee_id=10, item_ids=[1, 2]) - items = hierarchy.list_items_by_employee(employee_id=10, limit=5) - - assert status.is_active is True - assert employees.items[0].employee_id == 10 - assert phones.items[0].phone == "+7000" - assert linked.success is True - assert items.items[0].title == "Объявление" - - -def test_ads_list_uses_lazy_pagination_with_list_like_items() -> None: - seen_offsets: list[str] = [] - - def handler(request: httpx.Request) -> httpx.Response: - assert request.url.path == "/core/v1/items" - assert request.url.params["user_id"] == "7" - assert request.url.params["status"] == "active" - assert request.url.params["limit"] == "2" - - offset = request.url.params["offset"] - seen_offsets.append(offset) - page_items = { - "0": [{"id": 101, "title": "Смартфон"}, {"id": 102, "title": "Ноутбук"}], - "2": [{"id": 103, "title": "Планшет"}, {"id": 104, "title": "Наушники"}], - "4": [{"id": 105, "title": "Камера"}], - } - return httpx.Response(200, json={"items": page_items[offset], "total": 5}) - - transport = make_transport(httpx.MockTransport(handler)) - ad = Ad(transport, user_id=7) - - items = ad.list(status="active", limit=2) - - assert seen_offsets == ["0"] - assert items.items[0].id == 101 - assert items.items[3].id == 104 - assert len(items.items) == 5 - assert [item.title for item in items.items] == [ - "Смартфон", - "Ноутбук", - "Планшет", - "Наушники", - "Камера", - ] - assert seen_offsets == ["0", "2", "4"] - - -def test_ads_domain_covers_item_stats_spendings_and_promotion() -> None: - def handler(request: httpx.Request) -> httpx.Response: - if request.url.path == "/core/v1/accounts/7/items/101/": - return httpx.Response( - 200, - json={ - "id": 101, - "user_id": 7, - "title": "Смартфон", - "price": 1000, - "status": "active", - }, - ) - if request.url.path == "/core/v1/items": - assert request.url.params["user_id"] == "7" - assert request.url.params["status"] == "active" - return httpx.Response( - 200, json={"items": [{"id": 101, "title": "Смартфон"}], "total": 1} - ) - if request.url.path == "/core/v1/items/101/update_price": - assert json.loads(request.content.decode()) == {"price": 1500} - return httpx.Response(200, json={"item_id": 101, "price": 1500, "status": "updated"}) - if request.url.path == "/stats/v1/accounts/7/items": - body = json.loads(request.content.decode()) - assert body["itemIds"] == [101] - return httpx.Response( - 200, json={"items": [{"item_id": 101, "views": 45, "contacts": 5, "favorites": 2}]} - ) - if request.url.path == "/core/v1/accounts/7/calls/stats/": - return httpx.Response( - 200, - json={ - "items": [{"item_id": 101, "calls": 3, "answered_calls": 2, "missed_calls": 1}] - }, - ) - if request.url.path == "/stats/v2/accounts/7/items": - return httpx.Response( - 200, json={"period": "month", "items": [{"item_id": 101, "views": 100}]} - ) - if request.url.path == "/stats/v2/accounts/7/spendings": - return httpx.Response( - 200, json={"items": [{"item_id": 101, "amount": 77.5, "service": "xl"}]} - ) - if request.url.path == "/core/v1/accounts/7/vas/prices": - assert json.loads(request.content.decode()) == {"itemIds": [101], "locationId": 213} - return httpx.Response( - 200, - json={"services": [{"code": "xl", "title": "XL", "price": 50, "available": True}]}, - ) - if request.url.path == "/core/v1/accounts/7/items/101/vas": - assert json.loads(request.content.decode()) == {"codes": ["xl"]} - return httpx.Response(200, json={"success": True, "status": "applied"}) - if request.url.path == "/core/v2/accounts/7/items/101/vas_packages": - assert json.loads(request.content.decode()) == {"packageCode": "turbo"} - return httpx.Response(200, json={"success": True, "status": "package_applied"}) - assert request.url.path == "/core/v2/items/101/vas/" - assert json.loads(request.content.decode()) == {"codes": ["highlight"]} - return httpx.Response(200, json={"success": True, "status": "v2_applied"}) - - transport = make_transport(httpx.MockTransport(handler)) - ad = Ad(transport, resource_id=101, user_id=7) - stats = AdStats(transport, resource_id=101, user_id=7) - promotion = AdPromotion(transport, resource_id=101, user_id=7) - - item = ad.get() - items = ad.list(status="active") - price = ad.update_price(price=1500) - item_stats = ad.get_stats() - calls = stats.get_calls_stats() - analytics = stats.get_item_analytics() - spendings = stats.get_account_spendings() - vas_prices = promotion.get_vas_prices(item_ids=[101], location_id=213) - vas_apply = promotion.apply_vas(codes=["xl"]) - package_apply = promotion.apply_vas_package(package_code="turbo") - vas_v2_apply = promotion.apply_vas_v2(codes=["highlight"]) - - assert item.title == "Смартфон" - assert items.total == 1 - assert price.price == 1500 - assert item_stats.items[0].views == 45 - assert calls.items[0].answered_calls == 2 - assert analytics.period == "month" - assert spendings.total == 77.5 - assert vas_prices.items[0].code == "xl" - assert vas_apply.success is True - assert package_apply.message == "package_applied" - assert vas_v2_apply.status == "v2_applied" - - -def test_autoload_domains_cover_profile_report_and_legacy_flows() -> None: - def handler(request: httpx.Request) -> httpx.Response: - path = request.url.path - if path == "/autoload/v2/profile" and request.method == "GET": - return httpx.Response( - 200, json={"user_id": 7, "is_enabled": True, "upload_url": "https://upload"} - ) - if path == "/autoload/v2/profile" and request.method == "POST": - assert json.loads(request.content.decode()) == { - "isEnabled": True, - "email": "feed@example.com", - } - return httpx.Response(200, json={"success": True, "message": "saved"}) - if path == "/autoload/v1/upload": - assert json.loads(request.content.decode()) == {"url": "https://example.com/feed.xml"} - return httpx.Response(200, json={"success": True, "report_id": 501}) - if path == "/autoload/v1/user-docs/tree": - return httpx.Response( - 200, json={"tree": [{"slug": "transport", "title": "Транспорт", "children": []}]} - ) - if path == "/autoload/v1/user-docs/node/cars/fields": - return httpx.Response( - 200, - json={ - "fields": [ - {"slug": "brand", "title": "Марка", "type": "string", "required": True} - ] - }, - ) - if path == "/autoload/v2/reports": - return httpx.Response( - 200, json={"reports": [{"report_id": 501, "status": "done"}], "total": 1} - ) - if path == "/autoload/v3/reports/501": - return httpx.Response( - 200, - json={"report_id": 501, "status": "done", "errors_count": 0, "warnings_count": 1}, - ) - if path == "/autoload/v3/reports/last_completed_report": - return httpx.Response(200, json={"report_id": 500, "status": "done"}) - if path == "/autoload/v2/reports/501/items": - return httpx.Response( - 200, - json={ - "items": [ - {"item_id": 101, "avito_id": 9001, "status": "active", "title": "Авто"} - ], - "total": 1, - }, - ) - if path == "/autoload/v2/reports/501/items/fees": - return httpx.Response( - 200, json={"items": [{"item_id": 101, "amount": 42.0, "service": "xl"}]} - ) - if path == "/autoload/v2/items/ad_ids": - assert request.url.params["avito_ids"] == "9001,9002" - return httpx.Response(200, json={"mappings": [{"ad_id": 1, "avito_id": 9001}]}) - if path == "/autoload/v2/items/avito_ids": - assert request.url.params["ad_ids"] == "1,2" - return httpx.Response(200, json={"mappings": [{"ad_id": 1, "avito_id": 9001}]}) - if path == "/autoload/v2/reports/items": - assert request.url.params["item_ids"] == "101" - return httpx.Response( - 200, json={"items": [{"item_id": 101, "avito_id": 9001, "status": "active"}]} - ) - if path == "/autoload/v1/profile" and request.method == "GET": - return httpx.Response(200, json={"user_id": 7, "is_enabled": False}) - if path == "/autoload/v1/profile" and request.method == "POST": - return httpx.Response(200, json={"success": True, "message": "legacy_saved"}) - if path == "/autoload/v2/reports/last_completed_report": - return httpx.Response(200, json={"report_id": 401, "status": "legacy_done"}) - assert path == "/autoload/v2/reports/401" - return httpx.Response(200, json={"report_id": 401, "status": "legacy_done"}) - - transport = make_transport(httpx.MockTransport(handler)) - profile = AutoloadProfile(transport) - report = AutoloadReport(transport, resource_id=501) - legacy = AutoloadLegacy(transport, resource_id=401) - - current_profile = profile.get() - saved_profile = profile.save(is_enabled=True, email="feed@example.com") - upload = profile.upload_by_url(url="https://example.com/feed.xml") - tree = profile.get_tree() - fields = profile.get_node_fields(node_slug="cars") - reports = report.list() - report_details = report.get() - last_report = report.get_last_completed() - report_items = report.get_items() - report_fees = report.get_fees() - ad_ids = report.get_ad_ids_by_avito_ids(avito_ids=[9001, 9002]) - avito_ids = report.get_avito_ids_by_ad_ids(ad_ids=[1, 2]) - items_info = report.get_items_info(item_ids=[101]) - legacy_profile = legacy.get_profile() - legacy_saved = legacy.save_profile(email="legacy@example.com") - legacy_last = legacy.get_last_completed_report() - legacy_report = legacy.get_report() - - assert current_profile.is_enabled is True - assert saved_profile.success is True - assert upload.report_id == 501 - assert tree.items[0].slug == "transport" - assert fields.items[0].required is True - assert reports.total == 1 - assert report_details.warnings_count == 1 - assert last_report.report_id == 500 - assert report_items.items[0].avito_id == 9001 - assert report_fees.total == 42.0 - assert ad_ids.mappings[0] == (1, 9001) - assert avito_ids.mappings[0] == (1, 9001) - assert items_info.items[0].item_id == 101 - assert legacy_profile.is_enabled is False - assert legacy_saved.message == "legacy_saved" - assert legacy_last.status == "legacy_done" - assert legacy_report.report_id == 401 diff --git a/tests/test_stage5_messenger.py b/tests/test_stage5_messenger.py deleted file mode 100644 index 65fc4d1..0000000 --- a/tests/test_stage5_messenger.py +++ /dev/null @@ -1,213 +0,0 @@ -from __future__ import annotations - -import json - -import httpx - -from avito.auth import AuthSettings -from avito.config import AvitoSettings -from avito.core import Transport -from avito.core.retries import RetryPolicy -from avito.core.types import ApiTimeouts -from avito.messenger import Chat, ChatMedia, ChatMessage, ChatWebhook, SpecialOfferCampaign - - -def make_transport(handler: httpx.MockTransport) -> Transport: - settings = AvitoSettings( - base_url="https://api.avito.ru", - auth=AuthSettings(), - retry_policy=RetryPolicy(), - timeouts=ApiTimeouts(), - ) - return Transport( - settings, - auth_provider=None, - client=httpx.Client(transport=handler, base_url="https://api.avito.ru"), - sleep=lambda _: None, - ) - - -def test_messenger_chat_and_message_flows() -> None: - def handler(request: httpx.Request) -> httpx.Response: - path = request.url.path - if path == "/messenger/v2/accounts/7/chats": - return httpx.Response( - 200, - json={ - "chats": [ - {"id": "chat-1", "user_id": 7, "title": "Покупатель", "unread_count": 2} - ] - }, - ) - if path == "/messenger/v2/accounts/7/chats/chat-1": - return httpx.Response( - 200, - json={ - "id": "chat-1", - "user_id": 7, - "title": "Покупатель", - "last_message": {"text": "Привет"}, - }, - ) - if path == "/messenger/v1/accounts/7/chats/chat-1/read": - return httpx.Response(200, json={"success": True, "status": "read"}) - if path == "/messenger/v2/accounts/7/blacklist": - assert json.loads(request.content.decode()) == {"blacklistedUserId": 99} - return httpx.Response(200, json={"success": True, "status": "blacklisted"}) - if path == "/messenger/v1/accounts/7/chats/chat-1/messages": - assert json.loads(request.content.decode()) == {"message": "Здравствуйте"} - return httpx.Response( - 200, json={"success": True, "message_id": "msg-1", "status": "sent"} - ) - if path == "/messenger/v3/accounts/7/chats/chat-1/messages/": - return httpx.Response( - 200, - json={ - "messages": [ - { - "id": "msg-1", - "chat_id": "chat-1", - "text": "Здравствуйте", - "direction": "out", - } - ], - "total": 1, - }, - ) - assert path == "/messenger/v1/accounts/7/chats/chat-1/messages/msg-1" - return httpx.Response(200, json={"success": True, "status": "deleted"}) - - transport = make_transport(httpx.MockTransport(handler)) - chat = Chat(transport, resource_id="chat-1", user_id=7) - message = ChatMessage(transport, resource_id="msg-1", user_id=7) - - chats = chat.list() - chat_info = chat.get() - mark_read = chat.mark_read() - blacklisted = chat.blacklist(blacklisted_user_id=99) - sent = message.send_message(chat_id="chat-1", message="Здравствуйте") - messages = message.list(chat_id="chat-1") - deleted = message.delete(chat_id="chat-1") - - assert chats.items[0].id == "chat-1" - assert chat_info.last_message_text == "Привет" - assert mark_read.status == "read" - assert blacklisted.status == "blacklisted" - assert sent.message_id == "msg-1" - assert messages.total == 1 - assert deleted.status == "deleted" - - -def test_messenger_media_upload_and_send_image_flow() -> None: - def handler(request: httpx.Request) -> httpx.Response: - if request.url.path == "/messenger/v1/accounts/7/uploadImages": - assert request.headers["Content-Type"].startswith("multipart/form-data") - return httpx.Response( - 200, json={"images": [{"image_id": "img-1", "url": "https://cdn/img-1.jpg"}]} - ) - if request.url.path == "/messenger/v1/accounts/7/getVoiceFiles": - return httpx.Response( - 200, - json={ - "voice_files": [ - {"id": "voice-1", "url": "https://cdn/voice.mp3", "duration": 5} - ] - }, - ) - assert request.url.path == "/messenger/v1/accounts/7/chats/chat-1/messages/image" - assert json.loads(request.content.decode()) == {"imageId": "img-1", "caption": "Фото"} - return httpx.Response( - 200, json={"success": True, "message_id": "msg-img-1", "status": "sent"} - ) - - transport = make_transport(httpx.MockTransport(handler)) - media = ChatMedia(transport, user_id=7) - message = ChatMessage(transport, user_id=7) - - uploaded = media.upload_images(files={"image": ("photo.jpg", b"binary", "image/jpeg")}) - voice_files = media.get_voice_files() - sent = message.send_image( - chat_id="chat-1", image_id=uploaded.items[0].image_id or "", caption="Фото" - ) - - assert uploaded.items[0].image_id == "img-1" - assert voice_files.items[0].duration == 5 - assert sent.message_id == "msg-img-1" - - -def test_messenger_webhook_flows() -> None: - def handler(request: httpx.Request) -> httpx.Response: - if request.url.path == "/messenger/v1/subscriptions": - return httpx.Response( - 200, - json={ - "subscriptions": [ - {"url": "https://example.com/hook", "version": "v2", "status": "active"} - ] - }, - ) - if request.url.path == "/messenger/v1/webhook/unsubscribe": - assert json.loads(request.content.decode()) == {"url": "https://example.com/hook"} - return httpx.Response(200, json={"success": True, "status": "unsubscribed"}) - assert request.url.path == "/messenger/v3/webhook" - assert json.loads(request.content.decode()) == { - "url": "https://example.com/hook", - "secret": "top-secret", - } - return httpx.Response(200, json={"success": True, "status": "subscribed"}) - - webhook = ChatWebhook(make_transport(httpx.MockTransport(handler))) - - subscriptions = webhook.list() - unsubscribed = webhook.unsubscribe(url="https://example.com/hook") - subscribed = webhook.subscribe(url="https://example.com/hook", secret="top-secret") - - assert subscriptions.items[0].status == "active" - assert unsubscribed.status == "unsubscribed" - assert subscribed.status == "subscribed" - - -def test_special_offer_campaign_flows() -> None: - def handler(request: httpx.Request) -> httpx.Response: - path = request.url.path - payload = json.loads(request.content.decode()) if request.content else None - if path == "/special-offers/v1/available": - assert payload == {"itemIds": [1, 2]} - return httpx.Response( - 200, json={"items": [{"item_id": 1, "title": "Объявление", "is_available": True}]} - ) - if path == "/special-offers/v1/multiCreate": - assert payload == {"itemIds": [1], "message": "Скидка 10%", "discountPercent": 10} - return httpx.Response(200, json={"campaign_id": "camp-1", "status": "draft"}) - if path == "/special-offers/v1/multiConfirm": - assert payload == {"campaignId": "camp-1"} - return httpx.Response(200, json={"success": True, "status": "confirmed"}) - if path == "/special-offers/v1/stats": - assert payload == {"campaignId": "camp-1"} - return httpx.Response( - 200, - json={ - "campaign_id": "camp-1", - "sent_count": 20, - "delivered_count": 18, - "read_count": 10, - }, - ) - assert path == "/special-offers/v1/tariffInfo" - return httpx.Response(200, json={"price": 9.9, "currency": "RUB", "daily_limit": 100}) - - campaign = SpecialOfferCampaign( - make_transport(httpx.MockTransport(handler)), resource_id="camp-1" - ) - - available = campaign.get_available(item_ids=[1, 2]) - created = campaign.create_multi(item_ids=[1], message="Скидка 10%", discount_percent=10) - confirmed = campaign.confirm_multi() - stats = campaign.get_stats() - tariff = campaign.get_tariff_info() - - assert available.items[0].is_available is True - assert created.status == "draft" - assert confirmed.status == "confirmed" - assert stats.delivered_count == 18 - assert tariff.daily_limit == 100 diff --git a/tests/test_stage6_promotion.py b/tests/test_stage6_promotion.py deleted file mode 100644 index d7dd17c..0000000 --- a/tests/test_stage6_promotion.py +++ /dev/null @@ -1,393 +0,0 @@ -from __future__ import annotations - -import json - -import httpx - -from avito.auth import AuthSettings -from avito.config import AvitoSettings -from avito.core import Transport -from avito.core.retries import RetryPolicy -from avito.core.types import ApiTimeouts -from avito.promotion import ( - AutostrategyCampaign, - BbipForecastRequestItem, - BbipOrderItem, - BbipPromotion, - CpaAuction, - CreateItemBid, - PromotionOrder, - TargetActionPricing, - TrxPromotion, - TrxPromotionApplyItem, -) - - -def make_transport(handler: httpx.MockTransport) -> Transport: - settings = AvitoSettings( - base_url="https://api.avito.ru", - auth=AuthSettings(), - retry_policy=RetryPolicy(), - timeouts=ApiTimeouts(), - ) - return Transport( - settings, - auth_provider=None, - client=httpx.Client(transport=handler, base_url="https://api.avito.ru"), - sleep=lambda _: None, - ) - - -def test_promotion_dictionary_and_orders_flows() -> None: - def handler(request: httpx.Request) -> httpx.Response: - path = request.url.path - payload = json.loads(request.content.decode()) if request.content else None - if path == "/promotion/v1/items/services/dict": - return httpx.Response(200, json={"items": [{"code": "x2", "title": "X2"}]}) - if path == "/promotion/v1/items/services/get": - assert payload == {"itemIds": [101]} - return httpx.Response( - 200, - json={ - "items": [ - { - "itemId": 101, - "serviceCode": "x2", - "serviceName": "X2", - "price": 9900, - "status": "available", - } - ] - }, - ) - if path == "/promotion/v1/items/services/orders/get": - assert payload == {"itemIds": [101]} - return httpx.Response( - 200, - json={ - "items": [ - { - "orderId": "ord-1", - "itemId": 101, - "serviceCode": "x2", - "status": "created", - } - ] - }, - ) - assert path == "/promotion/v1/items/services/orders/status" - assert payload == {"orderIds": ["ord-1"]} - return httpx.Response(200, json={"items": [{"orderId": "ord-1", "status": "processed"}]}) - - promotion = PromotionOrder(make_transport(httpx.MockTransport(handler)), resource_id="ord-1") - - dictionary = promotion.get_service_dictionary() - services = promotion.list_services(item_ids=[101]) - orders = promotion.list_orders(item_ids=[101]) - statuses = promotion.get_order_status() - - assert dictionary.items[0].code == "x2" - assert services.items[0].price == 9900 - assert orders.items[0].order_id == "ord-1" - assert statuses.items[0].status == "processed" - - -def test_bbip_and_trxpromo_flows() -> None: - def handler(request: httpx.Request) -> httpx.Response: - path = request.url.path - payload = json.loads(request.content.decode()) if request.content else None - if path == "/promotion/v1/items/services/bbip/forecasts/get": - assert payload == { - "items": [{"itemId": 101, "duration": 7, "price": 1000, "oldPrice": 1200}] - } - return httpx.Response( - 200, - json={ - "items": [ - { - "itemId": 101, - "min": 10, - "max": 25, - "totalPrice": 7000, - "totalOldPrice": 8400, - } - ] - }, - ) - if path == "/promotion/v1/items/services/bbip/orders/create": - assert request.method == "PUT" - assert payload == { - "items": [{"itemId": 101, "duration": 7, "price": 1000, "oldPrice": 1200}] - } - return httpx.Response( - 200, json={"items": [{"itemId": 101, "success": True, "status": "created"}]} - ) - if path == "/promotion/v1/items/services/bbip/suggests/get": - assert payload == {"itemIds": [101]} - return httpx.Response( - 200, - json={ - "items": [ - { - "itemId": 101, - "duration": {"from": 1, "to": 7, "recommended": 5}, - "budgets": [{"price": 1000, "oldPrice": 1200, "isRecommended": True}], - } - ] - }, - ) - if path == "/trx-promo/1/apply": - assert payload == { - "items": [{"itemID": 101, "commission": 1500, "dateFrom": "2026-04-18"}] - } - return httpx.Response( - 200, json={"success": {"items": [{"itemID": 101, "success": True}]}} - ) - if path == "/trx-promo/1/cancel": - assert payload == {"items": [{"itemID": 101}]} - return httpx.Response( - 200, json={"success": {"items": [{"itemID": 101, "success": True}]}} - ) - assert path == "/trx-promo/1/commissions" - assert request.url.params["itemIDs"] == "101" - return httpx.Response( - 200, - json={ - "success": { - "items": [ - { - "itemID": 101, - "commission": 1500, - "isActive": True, - "validCommissionRange": { - "valueMin": 100, - "valueMax": 2000, - "step": 100, - }, - } - ] - } - }, - ) - - transport = make_transport(httpx.MockTransport(handler)) - bbip = BbipPromotion(transport, resource_id=101) - trx = TrxPromotion(transport, resource_id=101) - - forecasts = bbip.get_forecasts( - items=[BbipForecastRequestItem(item_id=101, duration=7, price=1000, old_price=1200)] - ) - order_result = bbip.create_order( - items=[BbipOrderItem(item_id=101, duration=7, price=1000, old_price=1200)] - ) - suggests = bbip.get_suggests() - applied = trx.apply( - items=[TrxPromotionApplyItem(item_id=101, commission=1500, date_from="2026-04-18")] - ) - cancelled = trx.delete() - commissions = trx.get_commissions() - - assert forecasts.items[0].max_views == 25 - assert order_result.items[0].status == "created" - assert suggests.items[0].duration is not None and suggests.items[0].duration.recommended == 5 - assert applied.items[0].success is True - assert cancelled.items[0].success is True - assert commissions.items[0].valid_commission_range is not None - assert commissions.items[0].valid_commission_range.value_max == 2000 - - -def test_cpa_auction_and_target_action_pricing_flows() -> None: - def handler(request: httpx.Request) -> httpx.Response: - path = request.url.path - payload = json.loads(request.content.decode()) if request.content else None - if path == "/auction/1/bids" and request.method == "GET": - assert request.url.params["fromItemID"] == "100" - assert request.url.params["batchSize"] == "50" - return httpx.Response( - 200, - json={ - "items": [ - { - "itemID": 101, - "pricePenny": 1300, - "expirationTime": "2026-04-18T10:00:00+03:00", - "availablePrices": [{"pricePenny": 1200, "goodness": 1}], - } - ] - }, - ) - if path == "/auction/1/bids": - assert payload == {"items": [{"itemID": 101, "pricePenny": 1500}]} - return httpx.Response(200, json={"items": [{"itemID": 101, "success": True}]}) - if path == "/cpxpromo/1/getBids/101": - return httpx.Response( - 200, - json={ - "items": [ - { - "itemID": 101, - "actionTypeID": 5, - "bidPenny": 1400, - "availableBids": [ - { - "valuePenny": 1500, - "minForecast": 2, - "maxForecast": 5, - "compare": 10, - } - ], - } - ] - }, - ) - if path == "/cpxpromo/1/getPromotionsByItemIds": - assert payload == {"itemIDs": [101, 102]} - return httpx.Response( - 200, - json={ - "items": [ - { - "itemID": 102, - "actionTypeID": 7, - "budgetPenny": 9000, - "budgetType": "7d", - "isAuto": True, - } - ] - }, - ) - if path == "/cpxpromo/1/remove": - assert payload == {"itemID": 101} - return httpx.Response( - 200, json={"items": [{"itemID": 101, "success": True, "status": "removed"}]} - ) - if path == "/cpxpromo/1/setAuto": - assert payload == { - "itemID": 101, - "actionTypeID": 5, - "budgetPenny": 8000, - "budgetType": "7d", - } - return httpx.Response( - 200, json={"items": [{"itemID": 101, "success": True, "status": "auto"}]} - ) - assert path == "/cpxpromo/1/setManual" - assert payload == {"itemID": 101, "actionTypeID": 5, "bidPenny": 1500, "limitPenny": 15000} - return httpx.Response( - 200, json={"items": [{"itemID": 101, "success": True, "status": "manual"}]} - ) - - transport = make_transport(httpx.MockTransport(handler)) - auction = CpaAuction(transport) - pricing = TargetActionPricing(transport, resource_id=101) - - bids = auction.get_user_bids(from_item_id=100, batch_size=50) - saved = auction.create_item_bids(items=[CreateItemBid(item_id=101, price_penny=1500)]) - details = pricing.get_bids() - promotions = pricing.get_promotions_by_item_ids(item_ids=[101, 102]) - removed = pricing.delete() - auto = pricing.update_auto(action_type_id=5, budget_penny=8000, budget_type="7d") - manual = pricing.update_manual(action_type_id=5, bid_penny=1500, limit_penny=15000) - - assert bids.items[0].available_prices[0].price_penny == 1200 - assert saved.items[0].success is True - assert details.items[0].available_bids[0].compare == 10 - assert promotions.items[0].is_auto is True - assert removed.items[0].status == "removed" - assert auto.items[0].status == "auto" - assert manual.items[0].status == "manual" - - -def test_autostrategy_flows() -> None: - def handler(request: httpx.Request) -> httpx.Response: - path = request.url.path - payload = json.loads(request.content.decode()) if request.content else None - if path == "/autostrategy/v1/budget": - assert payload == {"listingFee": 1000} - return httpx.Response( - 200, - json={ - "budgetId": "budget-1", - "budget": { - "recommended": { - "total": 10100, - "real": 10000, - "bonus": 100, - "viewsFrom": 30, - "viewsTo": 50, - }, - "minimal": {"total": 5100, "real": 5000, "bonus": 100}, - "priceRanges": [ - { - "priceFrom": 10000, - "priceTo": 20000, - "percent": 90, - "viewsFrom": 20, - "viewsTo": 40, - } - ], - }, - }, - ) - if path == "/autostrategy/v1/campaign/create": - assert payload == {"title": "Весенняя кампания", "budgetId": "budget-1"} - return httpx.Response(200, json={"campaignId": 77, "status": "created"}) - if path == "/autostrategy/v1/campaign/edit": - assert payload == {"campaignId": 77, "title": "Обновленная кампания"} - return httpx.Response(200, json={"campaignId": 77, "status": "updated"}) - if path == "/autostrategy/v1/campaign/info": - assert payload == {"campaignId": 77} - return httpx.Response( - 200, - json={ - "campaign": { - "campaignId": 77, - "campaignType": "AS", - "status": "active", - "budget": 10000, - "balance": 9000, - } - }, - ) - if path == "/autostrategy/v1/campaign/stop": - assert payload == {"campaignId": 77} - return httpx.Response(200, json={"campaignId": 77, "status": "stopped"}) - if path == "/autostrategy/v1/campaigns": - assert payload == {"status": "active"} - return httpx.Response( - 200, - json={ - "items": [ - { - "campaignId": 77, - "campaignType": "AS", - "status": "active", - "budget": 10000, - } - ] - }, - ) - assert path == "/autostrategy/v1/stat" - assert payload == {"campaignId": 77} - return httpx.Response( - 200, json={"stat": {"campaignId": 77, "views": 500, "contacts": 30, "spend": 4500}} - ) - - campaign = AutostrategyCampaign(make_transport(httpx.MockTransport(handler)), resource_id=77) - - budget = campaign.create_budget(payload={"listingFee": 1000}) - created = campaign.create(payload={"title": "Весенняя кампания", "budgetId": "budget-1"}) - updated = campaign.update(payload={"campaignId": 77, "title": "Обновленная кампания"}) - info = campaign.get() - stopped = campaign.delete() - campaigns = campaign.list(payload={"status": "active"}) - stat = campaign.get_stat() - - assert budget.budget_id == "budget-1" - assert budget.recommended is not None and budget.recommended.total == 10100 - assert created.status == "created" - assert updated.status == "updated" - assert info.balance == 9000 - assert stopped.status == "stopped" - assert campaigns.items[0].campaign_id == 77 - assert stat.spend == 4500 diff --git a/tests/test_stage7_orders.py b/tests/test_stage7_orders.py deleted file mode 100644 index 92663e2..0000000 --- a/tests/test_stage7_orders.py +++ /dev/null @@ -1,261 +0,0 @@ -from __future__ import annotations - -import json - -import httpx - -from avito.auth import AuthSettings -from avito.config import AvitoSettings -from avito.core import Transport -from avito.core.retries import RetryPolicy -from avito.core.types import ApiTimeouts -from avito.orders import DeliveryOrder, DeliveryTask, Order, OrderLabel, SandboxDelivery, Stock - - -def make_transport(handler: httpx.MockTransport) -> Transport: - settings = AvitoSettings( - base_url="https://api.avito.ru", - auth=AuthSettings(), - retry_policy=RetryPolicy(), - timeouts=ApiTimeouts(), - ) - return Transport( - settings, - auth_provider=None, - client=httpx.Client(transport=handler, base_url="https://api.avito.ru"), - sleep=lambda _: None, - ) - - -def test_order_management_flows() -> None: - def handler(request: httpx.Request) -> httpx.Response: - path = request.url.path - payload = json.loads(request.content.decode()) if request.content else None - if path == "/order-management/1/orders": - return httpx.Response( - 200, - json={ - "orders": [{"id": "ord-1", "status": "new", "buyerInfo": {"fullName": "Иван"}}], - "total": 1, - }, - ) - if path == "/order-management/1/markings": - assert payload == {"orderId": "ord-1", "codes": ["abc"]} - return httpx.Response( - 200, json={"result": {"success": True, "orderId": "ord-1", "status": "marked"}} - ) - if path == "/order-management/1/order/applyTransition": - assert payload == {"orderId": "ord-1", "transition": "confirm"} - return httpx.Response( - 200, json={"result": {"success": True, "orderId": "ord-1", "status": "confirmed"}} - ) - if path == "/order-management/1/order/checkConfirmationCode": - assert payload == {"orderId": "ord-1", "code": "1234"} - return httpx.Response( - 200, json={"result": {"success": True, "orderId": "ord-1", "status": "code-valid"}} - ) - if path == "/order-management/1/order/cncSetDetails": - assert payload == {"orderId": "ord-1", "pickupPointId": "pvz-1"} - return httpx.Response( - 200, json={"result": {"success": True, "orderId": "ord-1", "status": "pickup-set"}} - ) - if path == "/order-management/1/order/getCourierDeliveryRange": - return httpx.Response( - 200, - json={ - "result": { - "address": "Москва", - "timeIntervals": [ - { - "id": "int-1", - "date": "2026-04-18", - "startAt": "10:00", - "endAt": "12:00", - } - ], - } - }, - ) - if path == "/order-management/1/order/setCourierDeliveryRange": - assert payload == {"orderId": "ord-1", "intervalId": "int-1"} - return httpx.Response(200, json={"result": {"success": True, "status": "range-set"}}) - if path == "/order-management/1/order/setTrackingNumber": - assert payload == {"orderId": "ord-1", "trackingNumber": "TRK-1"} - return httpx.Response(200, json={"result": {"success": True, "status": "tracking-set"}}) - assert path == "/order-management/1/order/acceptReturnOrder" - assert payload == {"orderId": "ord-1", "postalOfficeId": "ops-1"} - return httpx.Response(200, json={"result": {"success": True, "status": "return-accepted"}}) - - order = Order(make_transport(httpx.MockTransport(handler)), resource_id="ord-1") - - orders = order.list() - marked = order.update_markings(payload={"orderId": "ord-1", "codes": ["abc"]}) - applied = order.apply(payload={"orderId": "ord-1", "transition": "confirm"}) - code_checked = order.check_confirmation_code(payload={"orderId": "ord-1", "code": "1234"}) - cnc = order.set_cnc_details(payload={"orderId": "ord-1", "pickupPointId": "pvz-1"}) - courier_ranges = order.get_courier_delivery_range() - courier_set = order.set_courier_delivery_range( - payload={"orderId": "ord-1", "intervalId": "int-1"} - ) - tracking = order.update_tracking_number(payload={"orderId": "ord-1", "trackingNumber": "TRK-1"}) - returned = order.accept_return_order(payload={"orderId": "ord-1", "postalOfficeId": "ops-1"}) - - assert orders.items[0].buyer_name == "Иван" - assert marked.status == "marked" - assert applied.status == "confirmed" - assert code_checked.status == "code-valid" - assert cnc.status == "pickup-set" - assert courier_ranges.items[0].interval_id == "int-1" - assert courier_set.status == "range-set" - assert tracking.status == "tracking-set" - assert returned.status == "return-accepted" - - -def test_labels_binary_download_flow() -> None: - pdf_bytes = b"%PDF-1.4 fake" - - def handler(request: httpx.Request) -> httpx.Response: - if request.url.path == "/order-management/1/orders/labels": - assert json.loads(request.content.decode()) == {"orderIds": ["ord-1"]} - return httpx.Response(200, json={"result": {"taskId": 42, "status": "created"}}) - assert request.url.path == "/order-management/1/orders/labels/42/download" - return httpx.Response( - 200, - content=pdf_bytes, - headers={ - "content-type": "application/pdf", - "content-disposition": 'attachment; filename="label-42.pdf"', - }, - ) - - label = OrderLabel(make_transport(httpx.MockTransport(handler)), resource_id="42") - - task = label.create(payload={"orderIds": ["ord-1"]}) - pdf = label.download() - - assert task.task_id == "42" - assert pdf.filename == "label-42.pdf" - assert pdf.binary.content == pdf_bytes - - -def test_delivery_production_and_sandbox_flows() -> None: - def handler(request: httpx.Request) -> httpx.Response: - path = request.url.path - payload = json.loads(request.content.decode()) if request.content else None - if path == "/createAnnouncement": - assert payload == {"orderId": "ord-1"} - return httpx.Response( - 200, json={"data": {"taskId": 11, "status": "announcement-created"}} - ) - if path == "/createParcel": - assert payload == {"orderId": "ord-1", "parcelId": "par-1"} - return httpx.Response( - 200, json={"data": {"parcelId": "par-1", "status": "parcel-created"}} - ) - if path == "/cancelAnnouncement": - assert payload == {"orderId": "ord-1"} - return httpx.Response(200, json={"data": {"status": "announcement-cancelled"}}) - if path == "/delivery/order/changeParcelResult": - assert payload == {"parcelId": "par-1", "result": "ok"} - return httpx.Response(200, json={"data": {"status": "callback-accepted"}}) - if path == "/sandbox/changeParcels": - assert payload == {"parcelIds": ["par-1"]} - return httpx.Response(200, json={"data": {"status": "parcels-updated"}}) - if path == "/delivery-sandbox/announcements/create": - assert payload == {"orderId": "sand-1"} - return httpx.Response( - 200, json={"data": {"taskId": 51, "status": "sandbox-announcement-created"}} - ) - if path == "/delivery-sandbox/announcements/track": - assert payload == {"orderId": "sand-1"} - return httpx.Response(200, json={"data": {"status": "tracked"}}) - if path == "/delivery-sandbox/sorting-center": - return httpx.Response( - 200, - json={ - "data": { - "sortingCenters": [{"id": "sc-1", "name": "Центр 1", "city": "Москва"}] - } - }, - ) - if path == "/delivery-sandbox/tariffs/tf-1/areas": - assert payload == {"areas": [{"city": "Москва"}]} - return httpx.Response(200, json={"data": {"taskId": 61, "status": "areas-added"}}) - if path == "/delivery-sandbox/v2/createParcel": - assert payload == {"orderId": "sand-1", "parcelId": "spar-1"} - return httpx.Response( - 200, json={"data": {"parcelId": "spar-1", "status": "sandbox-parcel-created"}} - ) - assert path == "/delivery-sandbox/tasks/51" - return httpx.Response(200, json={"data": {"taskId": 51, "status": "done"}}) - - transport = make_transport(httpx.MockTransport(handler)) - delivery = DeliveryOrder(transport, resource_id="ord-1") - sandbox = SandboxDelivery(transport, resource_id="sand-1") - task = DeliveryTask(transport, resource_id="51") - - announcement = delivery.create_announcement(payload={"orderId": "ord-1"}) - parcel = delivery.create(payload={"orderId": "ord-1", "parcelId": "par-1"}) - cancelled = delivery.delete(payload={"orderId": "ord-1"}) - callback = delivery.create_change_parcel_result(payload={"parcelId": "par-1", "result": "ok"}) - changed = delivery.update_change_parcels(payload={"parcelIds": ["par-1"]}) - sandbox_announcement = sandbox.create_announcement(payload={"orderId": "sand-1"}) - tracked = sandbox.track_announcement(payload={"orderId": "sand-1"}) - centers = sandbox.list_sorting_center() - added_areas = sandbox.add_areas(tariff_id="tf-1", payload={"areas": [{"city": "Москва"}]}) - sandbox_parcel = sandbox.create_parcel(payload={"orderId": "sand-1", "parcelId": "spar-1"}) - task_info = task.get() - - assert announcement.task_id == "11" - assert parcel.parcel_id == "par-1" - assert cancelled.status == "announcement-cancelled" - assert callback.status == "callback-accepted" - assert changed.status == "parcels-updated" - assert sandbox_announcement.task_id == "51" - assert tracked.status == "tracked" - assert centers.items[0].city == "Москва" - assert added_areas.status == "areas-added" - assert sandbox_parcel.parcel_id == "spar-1" - assert task_info.status == "done" - - -def test_stock_management_flows() -> None: - def handler(request: httpx.Request) -> httpx.Response: - if request.url.path == "/stock-management/1/info": - assert json.loads(request.content.decode()) == {"itemIds": [123321]} - return httpx.Response( - 200, - json={ - "stocks": [ - { - "item_id": 123321, - "quantity": 5, - "is_multiple": True, - "is_unlimited": False, - "is_out_of_stock": False, - } - ] - }, - ) - assert request.url.path == "/stock-management/1/stocks" - assert request.method == "PUT" - assert json.loads(request.content.decode()) == { - "stocks": [{"item_id": 123321, "quantity": 7}] - } - return httpx.Response( - 200, - json={ - "stocks": [ - {"item_id": 123321, "external_id": "AB123456", "success": True, "errors": []} - ] - }, - ) - - stock = Stock(make_transport(httpx.MockTransport(handler)), resource_id="123321") - - info = stock.get(payload={"itemIds": [123321]}) - updated = stock.update(payload={"stocks": [{"item_id": 123321, "quantity": 7}]}) - - assert info.items[0].quantity == 5 - assert updated.items[0].external_id == "AB123456" - assert updated.items[0].success is True diff --git a/tests/test_stage8_jobs.py b/tests/test_stage8_jobs.py deleted file mode 100644 index fe7e44e..0000000 --- a/tests/test_stage8_jobs.py +++ /dev/null @@ -1,274 +0,0 @@ -from __future__ import annotations - -import json - -import httpx - -from avito.auth import AuthSettings -from avito.config import AvitoSettings -from avito.core import Transport -from avito.core.retries import RetryPolicy -from avito.core.types import ApiTimeouts -from avito.jobs import Application, JobDictionary, JobWebhook, Resume, Vacancy - - -def make_transport(handler: httpx.MockTransport) -> Transport: - settings = AvitoSettings( - base_url="https://api.avito.ru", - auth=AuthSettings(), - retry_policy=RetryPolicy(), - timeouts=ApiTimeouts(), - ) - return Transport( - settings, - auth_provider=None, - client=httpx.Client(transport=handler, base_url="https://api.avito.ru"), - sleep=lambda _: None, - ) - - -def test_applications_and_webhooks_flows() -> None: - def handler(request: httpx.Request) -> httpx.Response: - path = request.url.path - if path == "/job/v1/applications/get_ids": - assert request.url.params["updatedAtFrom"] == "2026-04-18" - return httpx.Response( - 200, - json={ - "items": [{"id": "app-1", "updatedAt": "2026-04-18T10:00:00+03:00"}], - "cursor": "app-1", - }, - ) - if path == "/job/v1/applications/get_by_ids": - assert json.loads(request.content.decode()) == {"ids": ["app-1"]} - return httpx.Response( - 200, - json={ - "applies": [ - { - "id": "app-1", - "vacancy_id": 101, - "state": "new", - "is_viewed": False, - "applicant": {"name": "Иван"}, - } - ] - }, - ) - if path == "/job/v1/applications/get_states": - return httpx.Response( - 200, json={"states": [{"slug": "new", "description": "Новый отклик"}]} - ) - if path == "/job/v1/applications/set_is_viewed": - assert json.loads(request.content.decode()) == { - "applies": [{"id": "app-1", "is_viewed": True}] - } - return httpx.Response(200, json={"ok": True, "status": "viewed"}) - if path == "/job/v1/applications/apply_actions": - assert json.loads(request.content.decode()) == {"ids": ["app-1"], "action": "invited"} - return httpx.Response(200, json={"ok": True, "status": "invited"}) - if path == "/job/v1/applications/webhook" and request.method == "GET": - return httpx.Response( - 200, json={"url": "https://example.com/job", "is_active": True, "version": "v1"} - ) - if path == "/job/v1/applications/webhook" and request.method == "PUT": - assert json.loads(request.content.decode()) == {"url": "https://example.com/job"} - return httpx.Response( - 200, json={"url": "https://example.com/job", "is_active": True, "version": "v1"} - ) - if path == "/job/v1/applications/webhook" and request.method == "DELETE": - assert request.url.params["url"] == "https://example.com/job" - return httpx.Response(200, json={"ok": True}) - assert path == "/job/v1/applications/webhooks" - return httpx.Response( - 200, json=[{"url": "https://example.com/job", "is_active": True, "version": "v1"}] - ) - - transport = make_transport(httpx.MockTransport(handler)) - application = Application(transport, resource_id="app-1") - webhook = JobWebhook(transport) - - ids = application.list(params={"updatedAtFrom": "2026-04-18"}) - applications = application.list(payload={"ids": ["app-1"]}) - states = application.get_states() - viewed = application.update(payload={"applies": [{"id": "app-1", "is_viewed": True}]}) - applied = application.apply(payload={"ids": ["app-1"], "action": "invited"}) - current_hook = webhook.get() - updated_hook = webhook.update(payload={"url": "https://example.com/job"}) - deleted_hook = webhook.delete(url="https://example.com/job") - hooks = webhook.list() - - assert ids.items[0].id == "app-1" - assert applications.items[0].applicant_name == "Иван" - assert states.items[0].slug == "new" - assert viewed.status == "viewed" - assert applied.status == "invited" - assert current_hook.url == "https://example.com/job" - assert updated_hook.is_active is True - assert deleted_hook.success is True - assert hooks.items[0].version == "v1" - - -def test_resume_flows() -> None: - def handler(request: httpx.Request) -> httpx.Response: - if request.url.path == "/job/v1/resumes/": - assert request.url.params["query"] == "оператор" - return httpx.Response( - 200, - json={ - "meta": {"cursor": "2", "total": 1}, - "resumes": [ - { - "id": "res-1", - "title": "Оператор call-центра", - "name": "Петр", - "location": "Москва", - "salary": 90000, - } - ], - }, - ) - if request.url.path == "/job/v1/resumes/res-1/contacts/": - return httpx.Response( - 200, json={"name": "Петр", "phone": "+79990000000", "email": "petr@example.com"} - ) - assert request.url.path == "/job/v2/resumes/res-1" - return httpx.Response( - 200, - json={ - "id": "res-1", - "title": "Оператор call-центра", - "fullName": "Петр Петров", - "address_details": {"location": "Москва"}, - "salary": {"from": 90000}, - }, - ) - - resume = Resume(make_transport(httpx.MockTransport(handler)), resource_id="res-1") - - results = resume.list(params={"query": "оператор"}) - contacts = resume.get_contacts() - item = resume.get() - - assert results.cursor == "2" - assert results.items[0].candidate_name == "Петр" - assert contacts.phone == "+79990000000" - assert item.location == "Москва" - - -def test_vacancy_v1_v2_flows() -> None: - def handler(request: httpx.Request) -> httpx.Response: - path = request.url.path - if path == "/job/v1/vacancies": - assert json.loads(request.content.decode()) == {"title": "Продавец"} - return httpx.Response(201, json={"id": 101, "status": "created"}) - if path == "/job/v1/vacancies/101": - assert request.method == "PUT" - assert json.loads(request.content.decode()) == {"title": "Старший продавец"} - return httpx.Response(200, json={"ok": True, "status": "updated"}) - if path == "/job/v1/vacancies/archived/101": - assert json.loads(request.content.decode()) == {"employee_id": 7} - return httpx.Response(200, json={"ok": True, "status": "archived"}) - if path == "/job/v1/vacancies/101/prolongate": - assert json.loads(request.content.decode()) == {"billing_type": "package"} - return httpx.Response(200, json={"ok": True, "status": "prolongated"}) - if path == "/job/v2/vacancies": - if request.method == "GET": - return httpx.Response( - 200, - json={ - "vacancies": [ - { - "id": 101, - "uuid": "vac-uuid-1", - "title": "Продавец", - "status": "active", - } - ], - "total": 1, - }, - ) - assert json.loads(request.content.decode()) == {"title": "Вакансия v2"} - return httpx.Response(202, json={"vacancy_uuid": "vac-uuid-1", "status": "created"}) - if path == "/job/v2/vacancies/batch": - assert json.loads(request.content.decode()) == {"ids": [101]} - return httpx.Response( - 200, - json={ - "vacancies": [ - {"id": 101, "uuid": "vac-uuid-1", "title": "Продавец", "status": "active"} - ] - }, - ) - if path == "/job/v2/vacancies/statuses": - assert json.loads(request.content.decode()) == {"ids": [101]} - return httpx.Response( - 200, json={"items": [{"id": 101, "uuid": "vac-uuid-1", "status": "active"}]} - ) - if path == "/job/v2/vacancies/update/vac-uuid-1": - assert json.loads(request.content.decode()) == {"title": "Вакансия v2 updated"} - return httpx.Response(202, json={"vacancy_uuid": "vac-uuid-1", "status": "updated"}) - if path == "/job/v2/vacancies/101": - return httpx.Response( - 200, - json={ - "id": 101, - "uuid": "vac-uuid-1", - "title": "Продавец", - "status": "active", - "url": "https://avito.ru/vacancy/101", - }, - ) - assert path == "/job/v2/vacancies/vac-uuid-1/auto_renewal" - assert json.loads(request.content.decode()) == {"auto_renewal": True} - return httpx.Response(200, json={"ok": True, "status": "auto-renewal-updated"}) - - vacancy = Vacancy(make_transport(httpx.MockTransport(handler)), resource_id="101") - - created_v1 = vacancy.create(payload={"title": "Продавец"}, version=1) - updated_v1 = vacancy.update(payload={"title": "Старший продавец"}, version=1) - archived_v1 = vacancy.delete(payload={"employee_id": 7}) - prolonged_v1 = vacancy.prolongate(payload={"billing_type": "package"}) - list_v2 = vacancy.list() - created_v2 = vacancy.create(payload={"title": "Вакансия v2"}) - batch_v2 = vacancy.get_by_ids(payload={"ids": [101]}) - statuses_v2 = vacancy.get_statuses(payload={"ids": [101]}) - updated_v2 = vacancy.update( - payload={"title": "Вакансия v2 updated"}, version=2, vacancy_uuid="vac-uuid-1" - ) - item_v2 = vacancy.get() - auto_renewal = vacancy.update_auto_renewal( - payload={"auto_renewal": True}, vacancy_uuid="vac-uuid-1" - ) - - assert created_v1.id == "101" - assert updated_v1.status == "updated" - assert archived_v1.status == "archived" - assert prolonged_v1.status == "prolongated" - assert list_v2.items[0].uuid == "vac-uuid-1" - assert created_v2.id == "vac-uuid-1" - assert batch_v2.items[0].title == "Продавец" - assert statuses_v2.items[0].status == "active" - assert updated_v2.status == "updated" - assert item_v2.url == "https://avito.ru/vacancy/101" - assert auto_renewal.status == "auto-renewal-updated" - - -def test_job_dictionary_flows() -> None: - def handler(request: httpx.Request) -> httpx.Response: - if request.url.path == "/job/v2/vacancy/dict": - return httpx.Response(200, json=[{"id": "profession", "description": "Профессия"}]) - assert request.url.path == "/job/v2/vacancy/dict/profession" - return httpx.Response( - 200, json=[{"id": 10106, "name": "IT, интернет, телеком", "deprecated": True}] - ) - - dictionary = JobDictionary( - make_transport(httpx.MockTransport(handler)), resource_id="profession" - ) - - dictionaries = dictionary.list() - values = dictionary.get() - - assert dictionaries.items[0].id == "profession" - assert values.items[0].deprecated is True diff --git a/tests/test_stage9_cpa.py b/tests/test_stage9_cpa.py deleted file mode 100644 index 06103e3..0000000 --- a/tests/test_stage9_cpa.py +++ /dev/null @@ -1,304 +0,0 @@ -from __future__ import annotations - -import json - -import httpx - -from avito.auth import AuthSettings -from avito.config import AvitoSettings -from avito.core import Transport -from avito.core.retries import RetryPolicy -from avito.core.types import ApiTimeouts -from avito.cpa import CallTrackingCall, CpaCall, CpaChat, CpaLead, CpaLegacy - - -def make_transport(handler: httpx.MockTransport) -> Transport: - settings = AvitoSettings( - base_url="https://api.avito.ru", - auth=AuthSettings(), - retry_policy=RetryPolicy(), - timeouts=ApiTimeouts(), - ) - return Transport( - settings, - auth_provider=None, - client=httpx.Client(transport=handler, base_url="https://api.avito.ru"), - sleep=lambda _: None, - ) - - -def test_cpa_chat_and_phone_flows() -> None: - def handler(request: httpx.Request) -> httpx.Response: - path = request.url.path - payload = json.loads(request.content.decode()) if request.content else None - if path == "/cpa/v1/chatByActionId/act-1": - return httpx.Response( - 200, - json={ - "chat": { - "chat": { - "id": "chat-1", - "actionId": "act-1", - "createdAt": "2026-04-18T10:00:00+03:00", - "updatedAt": "2026-04-18T10:05:00+03:00", - }, - "buyer": {"userId": 501, "name": "Иван"}, - "item": {"id": 9001, "title": "Велосипед"}, - "isArbitrageAvailable": True, - } - }, - ) - if path == "/cpa/v1/chatsByTime": - assert payload == {"createdAtFrom": "2026-04-18T00:00:00+03:00"} - return httpx.Response( - 200, - json={ - "chats": [ - { - "chat": {"id": "chat-v1", "actionId": "legacy-1"}, - "buyer": {"userId": 502, "name": "Петр"}, - "item": {"id": 9002, "title": "Самокат"}, - "isArbitrageAvailable": False, - } - ] - }, - ) - if path == "/cpa/v2/chatsByTime": - assert payload == {"createdAtFrom": "2026-04-18T00:00:00+03:00", "limit": 10} - return httpx.Response( - 200, - json={ - "chats": [ - { - "chat": {"id": "chat-v2", "actionId": "act-2"}, - "buyer": {"userId": 503, "name": "Мария"}, - "item": {"id": 9003, "title": "Ноутбук"}, - "isArbitrageAvailable": True, - } - ] - }, - ) - assert path == "/cpa/v1/phonesInfoFromChats" - assert payload == {"actionIds": ["act-1", "act-2"]} - return httpx.Response( - 200, - json={ - "total": 2, - "results": [ - { - "id": 101, - "date": "2026-04-18T12:00:00+03:00", - "phone_number": "+79990000001", - "pricePenny": 1500, - "group": "Транспорт", - "url": "https://example.com/preview-1.jpg", - }, - { - "id": 102, - "date": "2026-04-18T12:05:00+03:00", - "phone_number": "+79990000002", - "pricePenny": 1700, - "group": "Электроника", - "url": "https://example.com/preview-2.jpg", - }, - ], - }, - ) - - chat = CpaChat(make_transport(httpx.MockTransport(handler)), resource_id="act-1") - - item = chat.get() - chats_v1 = chat.list(payload={"createdAtFrom": "2026-04-18T00:00:00+03:00"}, version=1) - chats_v2 = chat.list(payload={"createdAtFrom": "2026-04-18T00:00:00+03:00", "limit": 10}) - phones = chat.get_phones_info_from_chats(payload={"actionIds": ["act-1", "act-2"]}) - - assert item.chat_id == "chat-1" - assert item.item_title == "Велосипед" - assert chats_v1.items[0].buyer_name == "Петр" - assert chats_v2.items[0].is_arbitrage_available is True - assert phones.total == 2 - assert phones.items[1].phone_number == "+79990000002" - - -def test_cpa_calls_balance_and_legacy_flows() -> None: - audio_bytes = b"ID3 fake audio" - - def handler(request: httpx.Request) -> httpx.Response: - path = request.url.path - payload = json.loads(request.content.decode()) if request.content else None - if path == "/cpa/v2/callsByTime": - assert payload == { - "dateTimeFrom": "2026-04-18T00:00:00+03:00", - "dateTimeTo": "2026-04-18T23:59:59+03:00", - } - return httpx.Response( - 200, - json={ - "calls": [ - { - "id": 2001, - "itemId": 3001, - "buyerPhone": "+79990000010", - "sellerPhone": "+79990000011", - "virtualPhone": "+79990000012", - "statusId": 2, - "price": 171600, - "duration": 119, - "waitingDuration": 0.5, - "createTime": "2026-04-18T11:00:00+03:00", - "startTime": "2026-04-18T10:59:30+03:00", - "groupTitle": "Автомобили", - "recordUrl": "https://example.com/record-2001.mp3", - "isArbitrageAvailable": True, - } - ] - }, - ) - if path == "/cpa/v1/createComplaint": - assert payload == {"callId": 2001, "reason": "spam"} - return httpx.Response(200, json={"success": True}) - if path == "/cpa/v1/createComplaintByActionId": - assert payload == {"actionId": "act-1", "reason": "duplicate"} - return httpx.Response(200, json={"success": True}) - if path == "/cpa/v3/balanceInfo": - assert payload == {} - return httpx.Response(200, json={"balance": -5000}) - if path == "/cpa/v2/balanceInfo": - assert payload == {} - return httpx.Response(200, json={"balance": -5000, "advance": 1000, "debt": 0}) - if path == "/cpa/v2/callById": - assert payload == {"callId": 2001} - return httpx.Response( - 200, - json={ - "calls": { - "id": 2001, - "itemId": 3001, - "buyerPhone": "+79990000010", - "sellerPhone": "+79990000011", - "virtualPhone": "+79990000012", - "statusId": 2, - "price": 171600, - "duration": 119, - "waitingDuration": 0.5, - "createTime": "2026-04-18T11:00:00+03:00", - } - }, - ) - assert path == "/cpa/v1/call/2001" - return httpx.Response( - 200, - content=audio_bytes, - headers={ - "content-type": "audio/mpeg", - "content-disposition": 'attachment; filename="call-2001.mp3"', - }, - ) - - transport = make_transport(httpx.MockTransport(handler)) - cpa_call = CpaCall(transport, resource_id="2001") - cpa_lead = CpaLead(transport, resource_id="act-1") - legacy = CpaLegacy(transport, resource_id="2001") - - calls = cpa_call.list( - payload={ - "dateTimeFrom": "2026-04-18T00:00:00+03:00", - "dateTimeTo": "2026-04-18T23:59:59+03:00", - } - ) - complaint = cpa_call.create_create_complaint(payload={"callId": 2001, "reason": "spam"}) - complaint_by_action = cpa_lead.create_complaint_by_action_id( - payload={"actionId": "act-1", "reason": "duplicate"} - ) - balance_v3 = cpa_lead.create_balance_info_v3() - balance_v2 = legacy.legacy_create_balance_info_v2() - call_v2 = legacy.legacy_create_call_by_id_v2(payload={"callId": 2001}) - record = legacy.legacy_get_call() - - assert calls.items[0].record_url == "https://example.com/record-2001.mp3" - assert complaint.success is True - assert complaint_by_action.success is True - assert balance_v3.balance == -5000 - assert balance_v2.advance == 1000 - assert call_v2.call_id == "2001" - assert record.filename == "call-2001.mp3" - assert record.binary.content == audio_bytes - - -def test_calltracking_flows() -> None: - audio_bytes = b"RIFF fake wave" - - def handler(request: httpx.Request) -> httpx.Response: - path = request.url.path - if path == "/calltracking/v1/getCallById/": - assert json.loads(request.content.decode()) == {"callId": "7001"} - return httpx.Response( - 200, - json={ - "call": { - "callId": 7001, - "itemId": 9901, - "buyerPhone": "+79990000100", - "sellerPhone": "+79990000101", - "virtualPhone": "+79990000102", - "callTime": "2026-04-18T09:00:00Z", - "talkDuration": 67, - "waitingDuration": 1.25, - }, - "error": {"code": 0, "message": ""}, - }, - ) - if path == "/calltracking/v1/getCalls/": - assert json.loads(request.content.decode()) == { - "dateTimeFrom": "2026-04-01T00:00:00Z", - "dateTimeTo": "2026-04-18T23:59:59Z", - "limit": 100, - "offset": 0, - } - return httpx.Response( - 200, - json={ - "calls": [ - { - "callId": 7001, - "itemId": 9901, - "buyerPhone": "+79990000100", - "sellerPhone": "+79990000101", - "virtualPhone": "+79990000102", - "callTime": "2026-04-18T09:00:00Z", - "talkDuration": 67, - "waitingDuration": 1.25, - } - ], - "error": {"code": 0, "message": ""}, - }, - ) - assert path == "/calltracking/v1/getRecordByCallId/" - assert request.url.params["callId"] == "7001" - return httpx.Response( - 200, - content=audio_bytes, - headers={ - "content-type": "audio/wav", - "content-disposition": 'attachment; filename="record-7001.wav"', - }, - ) - - call = CallTrackingCall(make_transport(httpx.MockTransport(handler)), resource_id="7001") - - item = call.get() - items = call.list( - payload={ - "dateTimeFrom": "2026-04-01T00:00:00Z", - "dateTimeTo": "2026-04-18T23:59:59Z", - "limit": 100, - "offset": 0, - } - ) - record = call.download() - - assert item.call_id == "7001" - assert item.talk_duration == 67 - assert items.items[0].buyer_phone == "+79990000100" - assert record.filename == "record-7001.wav" - assert record.binary.content == audio_bytes diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..a5bc514 --- /dev/null +++ b/uv.lock @@ -0,0 +1,3 @@ +version = 1 +revision = 3 +requires-python = ">=3.14"