Skip to main content

stapel-categories

CI coverage pypi downloads python license llms.txt

Category tree with typed features: a hierarchical category tree (django-treenode) and a parallel feature tree whose typed config is validated by stapel-attributes, an ordered category<->feature M2M, feature inheritance, and a feature-editor lifecycle (keep/add/edit/inherit/remove/create/replace) with optimistic-concurrency apply over a category subtree. Each node also declares how its CHILDREN are presented (children_as: tiles for real subcategories, chips for a partition of one attribute template), authored or derived by the derive_children_as command, and the whole visible tree is readable nested in one cached call (GET /categories/api/v1/tree/?depth=N).

Part of the Stapel framework — composable Django apps that deploy as a monolith or as microservices without changing module code.

Install

pip install stapel-categories

At a glance

Fact Value
Version 0.21.2
Python >=3.11 (3.11, 3.12, 3.13, 3.14)
HTTP operations 34
Config axes 1
Usage surface 22
Extension points 4
Error codes 64
Fleet dependencies stapel-attributes · stapel-core

Documentation

OpenAPI · capabilities.json · llms.txt (for agents)

What this is

A hierarchical category tree (django-treenode) and a parallel feature tree whose typed config is validated by stapel-attributes. Categories own the tree structure, feature inheritance, the ordered category↔feature M2M, and the feature-editor lifecycle; the attribute engine (types, config/DTO/DAO validation, polymorphic serializers, admin widgets) lives in stapel-attributes and is imported, never re-implemented.

Quick start

INSTALLED_APPS = [
    # ...
    "treenode",            # django-treenode (tree-cache signals)
    "stapel_categories",
]

# urls.py — this module's own urls.py bakes in only `v1/`; the host
# contributes `api/`, giving the canonical `/categories/api/v1/...` prefix
# (exactly what stapel-example-monolith already does for this module).
path("categories/api/", include("stapel_categories.urls"))

stapel-attributes is an imported library (no app to install); its config editor ships static assets, so run collectstatic if you use the admin.

Presenting the tree

Every node says how its children should be drawn, as children_as on every public read:

value meaning
tiles the children are real subcategories — a tile grid of destinations
chips the children partition ONE attribute template (new/used, buy/sell/rent, boys/girls) — a chip row over the parent's own feed
transparent browsing SKIPS this node: its children appear where it would, and its own page is its parent's
null the node has no children

Children are counted live. Every public read carries children_pks (the ids of the children a reader can see, in order) and children_count. Both are GET /categories/{id}/children/ — the same visibility rule, the same order — and children_as resolves off the same count, so a node whose every child is soft-deleted answers null rather than sending a storefront to draw a grid of nothing. Do not build on tn_children_pks/tn_children_count: those are django-treenode's structure columns and count soft-deleted and retired rows (a live stand read "68,67,221" where /children/ returned one row). They stay on the payload for the revision-sync feed.

transparent is the import wrapper — a level a catalogue keeps for placement that nobody should have to browse through («Предложение услуг» between a root and the 34 groups that are the real level). The TREE is unchanged: the node keeps its id, its path and its place as the target of a listing; only the presentation of it is. It is AUTHORED only — the collapse is an editorial call read off a census, and no signal on a tree can make it, so derive_children_as never emits it and never overwrites it.

A chips or transparent parent that declares no features of its own answers the effective schema — the intersection of its children's — wherever features are read (GET /categories/<id>/features/, the categories.features Function). It renders the feed and the chip row for the whole partition, so "what can be filtered here" is a question about the children. A feature only SOME children carry is not in it (it appears when its chip is picked); a feature the children disagree on carries divergent: true beside the WIDEST config of theirs, so a client may render it (it refuses nothing a child accepts) or hide it until a chip is picked. The HTTP read says which it did in the X-Effective-From header (own / children), the Function in effective_from. A parent with features of its own keeps them ALONE — own only, never own plus the intersection. A transparent node has no page of its own, but a composer walking through it and every caller of categories.features still ask what it types, and a wrapper's own links are empty by construction — so it takes the same rule. That is the whole of the overlap: it draws no chip row and gets no axis caption.

A chip row also needs a NAME for the axis it splits on — «Все | С пробегом | Новые» is a set of values, and only the parent can say what they are values of. That name is children_axis_label, an optional translation key on the parent (empty means the row is drawn uncaptioned), authored in the admin or over the staff serializer and carried on every public read next to children_as.

The stored column takes a third value, auto, which a reader never sees: auto means "nobody has decided", and it is resolved server-side. Two columns hold the two answers — children_as is the authored intent, children_as_derived is the derivation's cache — so a re-run can improve its own output without ever overwriting an operator's.

django-admin derive_children_as              # report only
django-admin derive_children_as --apply      # write the derived column

An --apply run also NAMES the axis of the rows it makes chips, from the vocabulary group it already matched (deal type, condition, for whom) — a translation key per group, never a rendered word. Authored text always wins, and the command only ever replaces a label it emitted itself, which is what keeps the step re-runnable.

The command prints one line per parent with the decision, the signal that carried it (schema, vocabulary, empty-schema, structure, vocabulary>structure) and the Jaccard overlap of the children's own feature keys, so a wrong call can be pinned by hand — set children_as to tiles or chips in the admin and no future run touches it. A node already authored (including transparent) is printed with its value and skipped.

A census is applied without the admin, in one command, idempotently:

django-admin set_children_as --path uslugi/predlozhenie-uslug --value transparent
django-admin set_children_as --paths-from census.txt --value transparent
django-admin set_children_as --path a/b --value tiles --dry-run

The same command owns children_axis_label — the caption a chip row needs for its axis («Тип жилья» over Новостройка | Вторичка). derive_children_as only ever fills a blank label or improves its own previous key; text an engineer actually wrote had no command of its own until now:

django-admin set_children_as --path a/b --axis-label "Тип жилья"
django-admin set_children_as --path a/b --clear-axis-label

--value and --axis-label/--clear-axis-label combine in one run — one save per node either way — and at least one of the three is required.

The path is the slug path root→self — the exact form the derivation report prints, so a census read off that report pastes straight back. A bare slug works too (the column is unique); a longer path is checked against the tree and refused if it no longer matches. Every path resolves before any is written, so one bad line leaves nothing half-applied, and a node that already carries the value is reported unchanged and not re-saved.

A child set that spells a partition is a chip row even where some of those children have children of their own: КвартирыПродам/Сдам/Куплю/ Сниму is a partition whether or not Продам splits further, and each such child is then a parent whose own children are decided by the same rules. structure stays a veto only where the names say nothing.

children_as travels in the catalogue fixture (export_catalog / load_catalog), so an authored decision survives an image rebuild; the derived cache does not — a load leaves it to be re-derived.

Which feature is the make

A handful of features are not properties of an object — they are the axis a classified is organised along, and a product has to know which feature that is before it can do anything with it: «Найти больше вариантов этой марки» needs the make of the leaf a listing sits in, and an AI descent must fill make before model before generation because each narrows the next. So a feature carries axis_rolemake, model, generation, year, mileage, or null for the overwhelming majority — and every read that carries a feature schema carries it: GET /categories/{id}/features/, feature_defs() and the categories.features comm Function alike. A consumer asks by_axis_role(features)["make"] (stapel-attributes) instead of keeping a closed table of slugs that a catalogue spelling the axis a fourth way drops out of silently.

Two columns again, for the same reason children_as needs two. axis_role is authored — by the fixture, the admin, or set_axis_role; axis_role_derived is load_catalog's cache, filled after every apply from a documented slug table (brand/make/vendor/manufacturer → make, model → model, generation, year/god_vypuska, mileage/kilometrage, with a trailing _ref_select stripped first, so make_ref_select is not the spelling nobody added). The reader sees the resolved answer.

A category offering two candidates for one role (brand AND vendor) derives neither — there and everywhere else, since the row is shared — because a link built off the wrong one sends a buyer to a facet they did not click. load_catalog and catalog_health both name such a pair, and the tie is broken by hand:

django-admin set_axis_role --slug vendor --role make
django-admin set_axis_role --slug proizvoditel --slug marka --role make
django-admin set_axis_role --slug body_type --clear

An authored role travels in the catalogue fixture, so it survives an image rebuild; the derived cache does not — a load re-derives it.

The whole visible tree comes back nested in one cached call:

GET /categories/api/v1/tree/?depth=3     # 1..4, default 3

Active nodes, ordered by tn_priority descending at every level, carrying id, slug, name, path (the /-joined id path a search query takes), catalog_icon, children_as, children_axis_label and children. One query whatever the depth.

Settings

All configuration lives in the STAPEL_CATEGORIES namespace (dict setting, flat setting, or env var — resolved lazily):

Key Default Meaning
CAROUSEL_CACHE_TIMEOUT 300 Seconds the carousel response is cached.
FEATURE_DISPLAY_CACHE_TIMEOUT 60 Seconds an admin feature display label is memoized.
FEATURE_RENAME_HOOK "auto" comm Function load_catalog --rename-features hands a feature-slug rename to, so the stored listing answers move with the schema.
DISPLAY_TRANSLATOR stapel_categories.translation.identity_translator Dotted path (key)->str for rendering translation keys (default: identity).

comm surface

Kind Name Contract
Function categories.features {"category_id": int} -> {"category_id", "revision", "features":[{id,slug,name,mandatory,config}]} — resolved schema (own + inherited), cacheable by revision
Function categories.path {"category_ids": [int, ...]} -> {"<id>": ["<root_id>", ..., "<id>"]} — root->leaf ancestry, one query for the batch; segments are ids, an unknown id is absent
Function categories.by_slug {"slugs": ["transport", ...]} -> {"<slug>": ["<root_id>", ..., "<id>"]} — the same ancestry keyed by slug (Category.slug is globally unique); an unknown slug is absent, an inactive node still answers
Action (emit) category.changed {"category_id": int, "revision": int} on any category/feature mutation — for downstream cache invalidation

categories.features lets stapel-listings validate attribute values against a category's schema without importing this module. categories.path is the provider stapel-search declares by canonical name for category rollup — without it a search index degrades to a single path segment and a filter on a parent category finds none of its descendants. categories.by_slug is the same answer in the other namespace: it is what lets a page addressed /c/avtomobili ask for its own feed, and without it every slug segment of a search query degrades instead of filtering.

Contract

docs/{schema,flows,errors}.json are emitted from a single-module {categories + core} Django instance mounted at the canonical /categories/api/v1 prefix (make contract / make contract-check; see _codegen.py) — the same mechanism stapel-search, stapel-chat and stapel-forms already use. docs/flows.json is []: no flow is declared via @flow yet, same state as every other contract-complete module today. docs/capabilities.json stays hand-authored (see the Makefile comment); only its surface section is derived.

The ten feature-value shapes (FeatureConfig, FeatureDto) are a proper discriminated oneOf keyed by type, contributed by stapel-attributes and now used consistently everywhere a config or a values-DTO crosses the wire — Feature.config, FeatureBulk.config, the convert-type request body and validate-dto's features field all resolve through it (previously the last three fell back to an untyped JSONField/DictField).

Delta note — one pair of fields stays untyped, and it isn't this module's to fix. FeatureValidationResult.id / .ref_value render as free-form in the schema. Both are defined by stapel-attributes (FeatureValidationResult dataclass + its serializers.JSONField projection in results.py), not by this module: id is Optional[Union[int, str]] and ref_value is Optional[Union[str, int, float, list]] — plain scalar unions with no type discriminator to key a oneOf on, unlike the ten-way config/DTO shapes. Typing them is upstream's serializer to extend, not a gap stapel-categories introduced or can close by itself.

Extension points

See MODULE.md — the agent-facing map of every fork-free seam (settings, serializer seams, comm surface, feature-editor actions, admin-UI pointer to stapel-attributes).

Development

pip install -e . && pip install pytest pytest-django ruff
./setup-hooks.sh
pytest tests/

License

MIT — see LICENSE.


This page is assembled by stapel-readme from docs/readme.md plus the contract artifacts in docs/. Edit the prose in docs/readme.md; the badges, facts and links above and below it are generated — do not hand-edit README.md.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

stapel_categories-0.21.2.tar.gz (306.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

stapel_categories-0.21.2-py3-none-any.whl (215.5 kB view details)

Uploaded Python 3

File details

Details for the file stapel_categories-0.21.2.tar.gz.

File metadata

  • Download URL: stapel_categories-0.21.2.tar.gz
  • Upload date:
  • Size: 306.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for stapel_categories-0.21.2.tar.gz
Algorithm Hash digest
SHA256 a05ff9a68bedbe32d8a0ce1db24034b512e1836be99f9ac0d27fe5fb3c07f0c9
MD5 1525222074fd4679881db1a937e13114
BLAKE2b-256 03b1f0251e5e7d5230c16d42064412b1015d1a1b413bb1859561c46b9b6037df

See more details on using hashes here.

Provenance

The following attestation bundles were made for stapel_categories-0.21.2.tar.gz:

Publisher: publish.yml on usestapel/stapel-categories

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file stapel_categories-0.21.2-py3-none-any.whl.

File metadata

File hashes

Hashes for stapel_categories-0.21.2-py3-none-any.whl
Algorithm Hash digest
SHA256 d146fc13fe1bdf97d041932f6052c9d9821b2c1d4a155f1bde1a4317a24d1f15
MD5 3b40b89b224493c04f1f8c79c904a56b
BLAKE2b-256 5bde257d277660a5115f3423b24ee3f0b302545e7481a0b2b6c573c38a93838f

See more details on using hashes here.

Provenance

The following attestation bundles were made for stapel_categories-0.21.2-py3-none-any.whl:

Publisher: publish.yml on usestapel/stapel-categories

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

0.23.1

2 files

0.22.4

2 files

0.22.3

2 files

0.22.2

2 files

0.22.1

2 files

0.22.0

2 files

0.21.7

2 files

0.21.6

2 files

0.21.5

2 files

0.21.4

2 files

0.21.3

2 files

This release

0.21.2 This release

2 files

0.21.1

2 files

0.21.0

2 files

0.20.6

2 files

0.20.5

2 files

0.20.4

2 files

0.20.3

2 files

0.20.2

2 files

0.20.1

2 files

0.20.0

2 files

0.19.1

2 files

0.19.0

2 files

0.18.0

2 files

0.17.0

2 files

0.16.1

2 files

0.16.0

2 files

0.15.2

2 files

0.15.0

2 files

0.14.0

2 files

0.13.0

2 files

0.12.2

2 files

0.12.1

2 files

0.12.0

2 files

0.11.0

2 files

0.10.0

2 files

0.9.1

2 files

0.9.0

2 files

0.8.4

2 files

0.8.3

2 files

0.8.2

2 files

0.8.0

2 files

0.7.0

2 files

0.6.2

2 files

0.6.1

2 files

0.6.0

2 files

0.5.6

2 files

0.5.5

2 files

0.5.4

2 files

0.5.3

2 files

0.5.2

2 files

0.5.1

2 files

0.5.0

2 files

0.4.4

2 files

0.4.3

2 files

0.4.2

2 files

0.4.1

2 files

0.4.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page