# stapel-listings 0.4.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.
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 to PENDING, atomically writing every promoted field and emitting listing.submitted (or auto-approving under AUTO_APPROVE_ON_PUBLISH) in one mutate_and_emit() block. 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)
