Source code for ab.api.models.catalog

"""Catalog API models.

Field shapes ported against ``ab/api/schemas/catalog.json`` (swagger,
Tier 3) with ``ABConnectTools/ABConnect/api/models/catalog.py`` as
secondary reference (Tier 4). The prior placeholder implementation
had invented field names for ``AddCatalogRequest``,
``UpdateCatalogRequest``, and ``BulkInsertRequest`` that did not match
either source — see ``specs/036-lotsdb-migration-prep/gap-recommendations.md``.

**Staging schema migration.** These models target the migrated Catalog
schema, which at the time of writing is deployed to staging only. The
breaking changes all follow the database's retired columns:

- ``Catalogs.Agent`` is retired (``ObsoleteAgent``). Agents are now a
  many-to-many through ``CatalogAgentCompanies``, surfaced as
  ``agentCompanyIds``.
- ``Catalogs.CustomerCatalogId`` is retired
  (``ObsoleteCustomerCatalogId``). The catalog's external identity now
  lives per-platform on
  :class:`~ab.api.models.platforms.CatalogPlatformDto.platform_catalog_id`.
- A catalog now has **one** seller (``Catalogs.SellerId``), not a list.
  ``CatalogWithSellersDto`` is gone; reads return ``seller`` and writes
  take ``sellerId``.

The bulk upsert models are the exception — they still speak
``customerCatalogId`` / ``customerItemId`` because bulk import matches
incoming rows on the caller's own identifiers.

``SellerDto`` is a TYPE_CHECKING-only import to break the
``catalog`` ↔ ``sellers`` circular reference; :func:`_rebuild_catalog_models`
at the bottom of the module does the runtime import and calls
``model_rebuild()`` on any class whose annotations need it.
"""

from __future__ import annotations

from datetime import datetime
from typing import TYPE_CHECKING, List, Optional

from pydantic import Field

from ab.api.models.base import RequestModel, ResponseModel
from ab.api.models.lots import LotCatalogInformationDto, LotDataDto
from ab.api.models.platforms import CatalogPlatformDto

if TYPE_CHECKING:
    from ab.api.models.sellers import SellerDto


[docs] class CatalogDto(ResponseModel): """Core catalog information — parent of :class:`CatalogExpandedDto` and embedded in ``SellerExpandedDto.catalogs``.""" id: int = Field(..., description="Catalog ID") agent_company_ids: Optional[List[str]] = Field( None, alias="agentCompanyIds", description="Agent company IDs assigned to the catalog", ) title: Optional[str] = Field(None, description="Catalog title") start_date: datetime = Field(..., alias="startDate", description="Catalog start date-time") end_date: datetime = Field(..., alias="endDate", description="Catalog end date-time") is_completed: bool = Field(..., alias="isCompleted", description="Whether the catalog is completed")
[docs] class CatalogExpandedDto(CatalogDto): """Catalog with its seller and platform associations — returned by ``POST /Catalog`` and ``PUT /Catalog/{id}``.""" seller: Optional["SellerDto"] = Field(None, description="Owning seller") platforms: Optional[List[CatalogPlatformDto]] = Field( None, description="Platform associations carrying the catalog's external IDs", )
[docs] class CatalogWithLotsDto(CatalogExpandedDto): """Catalog with seller, platforms and lot summaries — returned by ``GET /Catalog``, ``GET /Catalog/{id}``, ``POST /Catalog/{targetId}/merge/{sourceId}`` and ``POST /Bulk/upsert``.""" lots: Optional[List[LotCatalogInformationDto]] = Field( None, description="Lot summaries belonging to the catalog", )
[docs] class AddCatalogRequest(RequestModel): """Body for ``POST /Catalog``. ``title``, ``start_date``, ``end_date``, ``is_completed`` and ``seller_id`` are required. Swagger declares ``title`` nullable; the API source requires it, so it is required here — a payload without one is a 400 waiting to happen. ``agent_company_ids`` and ``platforms`` are collections, not tri-state flags: they default to an empty list rather than ``None``, so "no agent companies" and "no platforms" have exactly one representation. """ agent_company_ids: List[str] = Field( default_factory=list, alias="agentCompanyIds", description="Agent company IDs to assign", ) title: str = Field(..., description="Catalog title") start_date: datetime = Field(..., alias="startDate", description="Catalog start date-time") end_date: datetime = Field(..., alias="endDate", description="Catalog end date-time") is_completed: bool = Field(..., alias="isCompleted", description="Whether the catalog is completed") seller_id: int = Field(..., alias="sellerId", description="Owning seller ID") platforms: List[CatalogPlatformDto] = Field( default_factory=list, description="Platforms this catalog is published to, with its external ID on each", )
[docs] class UpdateCatalogRequest(AddCatalogRequest): """Body for ``PUT /Catalog/{id}``. Same shape as :class:`AddCatalogRequest` per swagger."""
[docs] class CatalogListParams(RequestModel): """Query parameters for ``GET /Catalog``.""" id: Optional[int] = Field(None, alias="Id", description="Filter by catalog ID") agent_company_id: Optional[str] = Field( None, alias="AgentCompanyId", description="Filter by assigned agent company ID", ) platform_catalog_id: Optional[str] = Field( None, alias="PlatformCatalogId", description="Filter by the catalog's external ID on a platform", ) title: Optional[str] = Field(None, alias="Title", description="Filter by title") start_date: Optional[str] = Field(None, alias="StartDate", description="Filter by start date (date-time)") end_date: Optional[str] = Field(None, alias="EndDate", description="Filter by end date (date-time)") is_completed: Optional[bool] = Field(None, alias="IsCompleted", description="Filter by completion status") seller_ids: Optional[List[int]] = Field(None, alias="SellerIds", description="Filter by seller IDs") page_size: Optional[int] = Field(None, alias="PageSize", description="Number of items per page") page_number: Optional[int] = Field(None, alias="PageNumber", description="Page number")
# ============================================================================= # Bulk request models # =============================================================================
[docs] class BulkUpsertSellerRequest(RequestModel): """Seller entry inside a bulk upsert payload. Every field is required. Swagger declares ``name`` nullable; the API source does not, and a bulk row has to name the seller it creates or matches. """ name: str = Field(..., description="Seller name") customer_display_id: int = Field(..., alias="customerDisplayId", description="Customer display ID") is_active: bool = Field(..., alias="isActive", description="Whether the seller is active")
[docs] class BulkUpsertLotRequest(RequestModel): """Lot entry inside a bulk upsert payload. ``data`` is a single :class:`LotDataDto` — the bulk shape does not split initial and override measurements the way ``POST /Lot`` does. ``customer_item_id``, ``lot_number`` and ``data`` are required per the API source: the first two are the identity bulk matches rows on, and a lot with no ``data`` carries nothing to upsert. ``image_links`` is a collection and defaults to empty rather than ``None``. """ customer_item_id: str = Field(..., alias="customerItemId", description="Customer item ID") lot_number: str = Field(..., alias="lotNumber", description="Lot number") image_links: List[str] = Field( default_factory=list, alias="imageLinks", description="Image URLs", ) data: LotDataDto = Field(..., description="Lot measurements")
[docs] class BulkUpsertCatalogRequest(RequestModel): """Catalog entry inside a bulk upsert payload. Nests its lots and its single seller. ``customer_catalog_id``, ``title``, ``start_date``, ``end_date`` and ``seller`` are all required per the API source — swagger declares the first two nullable and gets both wrong, the same way it got ``seller`` wrong (a payload without one answers ``400 Catalogs[0].Seller: The Seller field is required``; verified against staging on 2026-08-23). ``lots`` is a collection and defaults to empty rather than ``None``. ``platform_id`` does not appear in swagger at all: the property carries ``[SwaggerIgnore]``, which hides it from the generated document while leaving it a perfectly ordinary JSON property the API binds and reads. It is **required here for every caller**, which is stricter than the API: - **Corporate users** (``CorporateAdmin``, ``CorporateAccounting``) have no choice — their tokens intentionally carry no ``partnerIds`` claim, so the API has nothing to resolve a platform from and answers ``404`` without one. - **Non-corporate users** *could* omit it: the API resolves their platform from ``partnerIds``. It is required here anyway so that one payload shape works for every caller and the intended platform is recorded in the body rather than left implicit in whoever held the token. Be aware of what the API does with the value, verified against staging on 2026-08-28: for a non-corporate bearer it is **ignored**, not checked. A platform user on partner 1 who names platform 15 is silently given platform 11 — their own — with a ``200``, not an error. The value is authoritative only for corporate callers, who have no ``partnerIds`` to fall back to. A caller who does not know their platform ID can derive it from their own token: :meth:`~ab.http.HttpClient.bearer_claims` exposes ``partner_ids``, and ``api.platforms.list(partner_id=...)`` maps a partner to its platforms. Note what is *not* here: ``partnerIds`` is a claim on the token and never a property of the body. """ customer_catalog_id: str = Field(..., alias="customerCatalogId", description="Customer catalog ID") title: str = Field(..., description="Catalog title") start_date: datetime = Field(..., alias="startDate", description="Catalog start date-time") end_date: datetime = Field(..., alias="endDate", description="Catalog end date-time") platform_id: int = Field( ..., alias="platformId", description=( "Platform this catalog belongs to. Required for every caller. " "Authoritative for corporate bearers, which carry no partnerIds " "for the API to resolve from; ignored for everyone else, whose " "platform the API takes from their partnerIds claim regardless." ), ) lots: List[BulkUpsertLotRequest] = Field( default_factory=list, description="Lots in this catalog", ) seller: BulkUpsertSellerRequest = Field(..., description="Seller that owns this catalog")
[docs] class BulkUpsertRequest(RequestModel): """Body for ``POST /Bulk/upsert``. Per swagger, the top-level payload contains exactly one key — ``catalogs`` — a list of :class:`BulkUpsertCatalogRequest`. Each nested catalog carries its own lots and seller. This is a nested bulk shape, *not* a flat list of rows. Rows are matched on ``customer_catalog_id`` / ``customer_item_id``, so re-sending a payload updates the existing records rather than duplicating them — the behaviour change from the retired ``POST /Bulk/insert``. """ catalogs: List[BulkUpsertCatalogRequest] = Field( default_factory=list, description="Catalogs to upsert, each with nested lots and seller", )
def _rebuild_catalog_models() -> None: """Resolve the TYPE_CHECKING forward reference to ``SellerDto``. Deferred because ``sellers.py`` imports :class:`CatalogDto` from this module — a real import of ``SellerDto`` at the top of this file would create a load-time cycle. """ from ab.api.models.sellers import SellerDto # noqa: F401 CatalogExpandedDto.model_rebuild() CatalogWithLotsDto.model_rebuild() _rebuild_catalog_models()