# stapel-listings 0.8.0

Listings and catalog vertical: a Listing core (owner, opaque category, typed attribute values, price + price_base, inventory) with a draft/publish lifecycle including the moderation takedown state 'blocked', an independent moderation status, a value-validation pipeline delegated to stapel-attributes against a category schema fetched over comm, a publish service, first-class favorites, and the two pull seams its consumers read it through — listings.search_documents / listings.search_export for an indexer and listings.moderation_content for a moderation queue.

Contract: axes 2 · surface 7 · extension points 6 · operations 17 · error codes 64.
Generated from docs/capabilities.json by `stapel-llms-txt` — do not edit; drift-gated by `make contract-check`.

## Configuration axes — what a product switches on
Settings keys; `default` is what you get by saying nothing. Turning an axis off unmounts the operations it gates.
- AUTO_APPROVE_ON_PUBLISH [bool, default false] — Auto-approve listings on publish (no moderation module)
  Escape hatch for deployments with no stapel-moderation module installed: publish skips the moderation wait and goes live immediately.
- REQUIRE_IMAGE_ON_PUBLISH [bool, default true] — Require a photo to publish a listing
  Require at least one image reference before a listing can be published.

## Usage surface — call these before writing your own
This is the answer to "does Stapel already have something for X?". `instead of` names the outside symbol this one displaces.
### gate_function
- get_feature_configs — stapel_listings.services.category_schema.get_feature_configs
  instead of: stapel_core.comm.call("categories.features", ...)
  Cached, revision-consistent fetch of a category's feature schema via the categories.features comm Function. Call this instead of calling `call("categories.features", ...)` directly and rolling a bespoke cache — the revision-pointer design (M-6) is specifically what stops a concurrent category.changed from re-caching a stale schema under the live key, a race a hand-rolled get/call/set cache would reintroduce.
- invalidate — stapel_listings.services.category_schema.invalidate
  Hard reset of the feature-config cache for one category — drops the revision pointer so the next read refetches from categories.features. The tool to reach for from a management shell or an ops runbook when a category.changed event was lost or delayed and validate/publish are visibly serving a stale schema, instead of waiting out FEATURE_CONFIG_CACHE_TIMEOUT.
- note_changed — stapel_listings.services.category_schema.note_changed
  Advance the feature-config cache's revision pointer for one category. The built-in category.changed subscription (actions.py) already calls this on every event; reach for it directly only when replaying/reconciling category revisions outside that subscription (a backfill job, a custom event pipeline).
- publish_listing — stapel_listings.services.publish.publish_listing
  The one function that promotes a validated draft, atomically writing every promoted field and emitting listing.submitted (or auto-approving under AUTO_APPROVE_ON_PUBLISH) in one mutate_and_emit() block. A first publication goes to PENDING; re-publishing a listing that is already PUBLISHED keeps it published and moves only moderation_status to PENDING (post-moderation — the edit is live at once, a rejecting verdict takes it down through the published→blocked edge). Reach for this instead of hand-rolling the draft-to-published field promotion when building a bulk-publish tool, an import job or any second entry point — a promotion that skips this can leave a listing PENDING with no moderation request ever sent.
- validate_countable_stock — stapel_listings.models.validate_countable_stock
  Enforce the countable/stock_quantity invariant (a physical good needs a non-negative quantity, a service must carry none) on any write path that bypasses ListingDraftSerializer and Listing.clean() — a bulk operation, a custom admin action, a data migration. Call it first for a clean ValidationError instead of letting the DB's listing_stock_invariant_chk constraint reject the write as an opaque IntegrityError; it is deliberately NOT wired into Listing.save() itself (see the docstring), so nothing enforces it there for you.
- validate_draft — stapel_listings.services.publish.validate_draft
  Structured, machine-readable validation of a listing's draft (category-schema feature values + description length), including the M-7 unknown-slug check that keeps validate-draft and publish agreeing. Call this from any alternate publish UI or workflow that needs a preview validation state instead of re-assembling stapel-attributes' validators and the description-bounds check by hand — it is exactly what the validate-draft and publish views delegate to.
### predicate
- is_valid — stapel_listings.services.publish.is_valid
  Strict all-clear over a ValidationBatchResult: requires .valid AND every individual result at OK status. Use this rather than reading .valid alone before an automated action (e.g. an auto-approve or bulk-publish pipeline) — a batch can carry .valid=True with a non-OK entry that a human-facing form tolerates but an unattended pipeline should not wave through.

## Extension points — what a product replaces, fork-free
- AUTH_USER_MODEL [swappable_model]
  Standard Django user swap — Listing.owner and Favorite bind to settings.AUTH_USER_MODEL (models.py:197, models.py:480).
- CATEGORY_FEATURES_FUNCTION [comm_function_name]
  Name of the comm Function resolving a category's feature schema (default 'categories.features', provided by stapel-categories) — REPLACE, single provider (conf.py, MODULE.md).
- LISTING_URL_TEMPLATE [string]
  Template of a listing's public URL formatted with listing_id, returned by listings.moderation_content so a moderator's card can link to the real page. Empty by default — this module serves no site of its own and returns "" rather than guessing one (conf.py, functions.py).
- MODERATION_TARGET_TYPE [string]
  The target_type this module answers to in target-generic moderation.completed verdicts (default "listing"); verdicts for any other target type are ignored. Must match the key a composite registers listings under in STAPEL_MODERATION['TARGET_TYPES'] (conf.py, actions.py).
- PRICE_BASE_CONVERTER [dotted_path]
  Single-strategy REPLACE seam: callable (amount, currency, base) -> Decimal computing price_base. Default is identity; a host with stapel-currencies wires it to a real conversion backend (conf.py, MODULE.md Extension points).
- serializer_seams [class_override]
  ListingViewSet resolves detail_serializer_class / card_serializer_class / draft_serializer_class per action from overridable class attributes; subclass and remount the router to swap any of them (views.py:63-82, MODULE.md Serializer seams).

## Fits with — fleet dependencies
- stapel-attributes (required) — delegates every attribute value check and DTO->DAO conversion to stapel-attributes' engine (pyproject.toml dependency; serializers.py:13, views.py:18)
- stapel-categories (optional) — comm-only dependency: the categories.features Function provides the category feature schema for value validation; no direct Python import (MODULE.md boundaries: 'this module never imports it')
- stapel-core (required) — comm bus (mutate_and_emit/emit/function/call), GDPR provider registry, AUTH_USER_MODEL base (pyproject.toml dependency; apps.py:18, models.py:31, functions.py:20, events.py)

## HTTP operations (17) — call by operationId, never by a typed path
Paths are relative to `/listings/api/v1/listings/`.
### listings
- POST /{id}/archive/ — listings_api_v1_listings_archive_create
- POST /{id}/complete/ — listings_api_v1_listings_complete_create
- POST / — listings_api_v1_listings_create
- DELETE /{id}/ — listings_api_v1_listings_destroy
- POST /{id}/favorite/ — listings_api_v1_listings_favorite_create
- GET / — listings_api_v1_listings_list
- GET /my/counters/ — listings_api_v1_listings_my_counters_retrieve
- GET /my/favorites/ — listings_api_v1_listings_my_favorites_list
- GET /my/listings/ — listings_api_v1_listings_my_listings_list
- PATCH /{id}/ — listings_api_v1_listings_partial_update
- POST /{id}/publish/ — listings_api_v1_listings_publish_create
- GET /{id}/ — listings_api_v1_listings_retrieve
- POST /{id}/save-draft/ — listings_api_v1_listings_save_draft_create
- GET /{id}/status/ — listings_api_v1_listings_status_retrieve
- POST /{id}/unfavorite/ — listings_api_v1_listings_unfavorite_create
- PUT /{id}/ — listings_api_v1_listings_update
- GET /{id}/validate-draft/ — listings_api_v1_listings_validate_draft_retrieve

## Error codes (64) — the StapelError envelope
Render `t(code, params)`; branch UX on the remediation. Localized text lives in docs/errors.<lang>.md, not here.
- error.400.bad_request [400] fix_input
- error.400.captcha_invalid [400] retry
- error.400.captcha_required [400] retry
- error.400.category_required [400] fix_input
- error.400.description_too_long [400] fix_input {max_length}
- error.400.description_too_short [400] fix_input {min_length}
- error.400.expected_list [400] fix_input
- error.400.feature_above_maximum [400] fix_input {feature}
- error.400.feature_below_minimum [400] fix_input {feature}
- error.400.feature_invalid_config [400] fix_input {feature}
- error.400.feature_invalid_format [400] fix_input {feature}
- error.400.feature_invalid_type [400] fix_input {feature}
- error.400.feature_mandatory_missing [400] fix_input {feature}
- error.400.feature_not_allowed [400] fix_input {feature}
- error.400.feature_not_in_options [400] fix_input {feature}
- error.400.feature_unknown [400] fix_input {feature}
- error.400.feature_unknown_type [400] fix_input {feature}
- error.400.field.blank [400] fix_input {field}
- error.400.field.does_not_exist [400] fix_input {field}
- error.400.field.invalid [400] fix_input {field}
- error.400.field.invalid_choice [400] fix_input {field}
- error.400.field.max_length [400] fix_input {field,max_length}
- error.400.field.max_value [400] fix_input {field,max_value}
- error.400.field.min_length [400] fix_input {field,min_length}
- error.400.field.min_value [400] fix_input {field,min_value}
- error.400.field.null [400] fix_input {field}
- error.400.field.required [400] fix_input {field}
- error.400.field.unique [400] fix_input {field}
- error.400.image_required [400] fix_input
- error.400.invalid_ad_id [400] fix_input
- error.400.listing_feature_not_allowed [400] fix_input {feature}
- error.400.listing_invalid_status_filter [400] fix_input {status}
- error.400.publish_validation_failed [400] fix_input
- error.400.validation_error [400] fix_input
- error.400.verification_failed [400] verify
- error.400.verification_invalid_factor [400] verify
- error.401.unauthorized [401] reauthenticate
- error.402.payment_required [402] retry
- error.403.forbidden [403] retry
- error.403.listing_not_owner [403] retry
- error.403.network_blocked [403] contact_support
- error.403.verification_enrollment_required [403] verify
- error.403.verification_required [403] verify
- error.404.ad_not_found [404] retry
- error.404.listing_not_found [404] retry
- error.404.not_found [404] retry
- error.404.verification_challenge_not_found [404] verify
- error.405.method_not_allowed [405] retry
- error.406.not_acceptable [406] retry
- error.408.request_timeout [408] retry
- error.409.already_favorited [409] fix_input
- error.409.conflict [409] fix_input
- error.409.invalid_listing_transition [409] fix_input {from_status}
- error.409.listing_cannot_delete_active [409] fix_input
- error.410.gone [410] retry
- error.413.payload_too_large [413] retry
- error.415.unsupported_media_type [415] retry
- error.422.unprocessable_entity [422] wait_and_retry
- error.423.locked [423] wait_and_retry
- error.423.verification_locked [423] wait_and_retry
- error.429.rate_limit [429] wait_and_retry {retry_after_minutes}
- error.429.too_many_requests [429] wait_and_retry
- error.500.internal [500] contact_support
- error.503.mandate_unavailable [503] retry
