stapel-categories 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. stapel_categories-0.1.0/LICENSE +21 -0
  2. stapel_categories-0.1.0/PKG-INFO +91 -0
  3. stapel_categories-0.1.0/README.md +72 -0
  4. stapel_categories-0.1.0/__init__.py +31 -0
  5. stapel_categories-0.1.0/admin.py +211 -0
  6. stapel_categories-0.1.0/apps.py +15 -0
  7. stapel_categories-0.1.0/checks.py +16 -0
  8. stapel_categories-0.1.0/conf.py +32 -0
  9. stapel_categories-0.1.0/conftest.py +87 -0
  10. stapel_categories-0.1.0/dto.py +25 -0
  11. stapel_categories-0.1.0/errors.py +34 -0
  12. stapel_categories-0.1.0/events.py +31 -0
  13. stapel_categories-0.1.0/feature_editor.py +361 -0
  14. stapel_categories-0.1.0/forms.py +75 -0
  15. stapel_categories-0.1.0/functions.py +55 -0
  16. stapel_categories-0.1.0/migrations/0001_initial.py +118 -0
  17. stapel_categories-0.1.0/migrations/__init__.py +0 -0
  18. stapel_categories-0.1.0/models.py +354 -0
  19. stapel_categories-0.1.0/py.typed +0 -0
  20. stapel_categories-0.1.0/pyproject.toml +44 -0
  21. stapel_categories-0.1.0/schemas/emits/category.changed.json +18 -0
  22. stapel_categories-0.1.0/schemas/functions/categories.features.json +31 -0
  23. stapel_categories-0.1.0/serializers.py +325 -0
  24. stapel_categories-0.1.0/setup.cfg +4 -0
  25. stapel_categories-0.1.0/stapel_categories.egg-info/PKG-INFO +91 -0
  26. stapel_categories-0.1.0/stapel_categories.egg-info/SOURCES.txt +78 -0
  27. stapel_categories-0.1.0/stapel_categories.egg-info/dependency_links.txt +1 -0
  28. stapel_categories-0.1.0/stapel_categories.egg-info/requires.txt +6 -0
  29. stapel_categories-0.1.0/stapel_categories.egg-info/top_level.txt +1 -0
  30. stapel_categories-0.1.0/tests/__init__.py +0 -0
  31. stapel_categories-0.1.0/tests/test_category_commands.py +718 -0
  32. stapel_categories-0.1.0/tests/test_comm.py +153 -0
  33. stapel_categories-0.1.0/tests/test_extension_points.py +66 -0
  34. stapel_categories-0.1.0/tests/test_feature_editor.py +539 -0
  35. stapel_categories-0.1.0/tests/test_feature_editor_enhancements.py +319 -0
  36. stapel_categories-0.1.0/tests/test_meta_regression.py +39 -0
  37. stapel_categories-0.1.0/tests/test_models.py +277 -0
  38. stapel_categories-0.1.0/tests/test_public_api.py +28 -0
  39. stapel_categories-0.1.0/tests/test_translation_keys.py +200 -0
  40. stapel_categories-0.1.0/tests/urls.py +7 -0
  41. stapel_categories-0.1.0/translation.py +113 -0
  42. stapel_categories-0.1.0/translation_keys.py +360 -0
  43. stapel_categories-0.1.0/urls.py +13 -0
  44. stapel_categories-0.1.0/validators.py +25 -0
  45. stapel_categories-0.1.0/views.py +583 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stapel contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: stapel-categories
3
+ Version: 0.1.0
4
+ Summary: Category tree for the Stapel framework
5
+ License: MIT
6
+ Keywords: django,stapel,categories
7
+ Classifier: Framework :: Django
8
+ Classifier: Programming Language :: Python :: 3.11
9
+ Classifier: Programming Language :: Python :: 3.12
10
+ Requires-Python: >=3.11
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: stapel-core<0.4,>=0.3.0
14
+ Requires-Dist: stapel-attributes<0.2,>=0.1
15
+ Requires-Dist: django-treenode<0.24,>=0.23
16
+ Requires-Dist: drf-spectacular>=0.27
17
+ Provides-Extra: all
18
+ Dynamic: license-file
19
+
20
+ # stapel-categories
21
+
22
+ Category tree with typed features for the [Stapel framework](https://github.com/usestapel) —
23
+ composable Django apps that deploy as a monolith or as microservices without
24
+ changing module code.
25
+
26
+ A hierarchical **category** tree (django-treenode) and a parallel **feature**
27
+ tree whose typed `config` is validated by
28
+ [stapel-attributes](https://github.com/usestapel/stapel-attributes). Categories
29
+ own the tree structure, feature inheritance, the ordered category↔feature M2M,
30
+ and the feature-editor lifecycle; the attribute *engine* (types, config/DTO/DAO
31
+ validation, polymorphic serializers, admin widgets) lives in stapel-attributes
32
+ and is imported, never re-implemented.
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ pip install stapel-categories
38
+ ```
39
+
40
+ ```python
41
+ INSTALLED_APPS = [
42
+ # ...
43
+ "treenode", # django-treenode (tree-cache signals)
44
+ "stapel_categories",
45
+ ]
46
+
47
+ # urls.py — the host chooses the prefix
48
+ path("categories/", include("stapel_categories.urls"))
49
+ ```
50
+
51
+ `stapel-attributes` is an imported library (no app to install); its config
52
+ editor ships static assets, so run `collectstatic` if you use the admin.
53
+
54
+ ## Settings
55
+
56
+ All configuration lives in the `STAPEL_CATEGORIES` namespace (dict setting,
57
+ flat setting, or env var — resolved lazily):
58
+
59
+ | Key | Default | Meaning |
60
+ |---|---|---|
61
+ | `CAROUSEL_CACHE_TIMEOUT` | `300` | Seconds the `carousel` response is cached. |
62
+ | `FEATURE_DISPLAY_CACHE_TIMEOUT` | `60` | Seconds an admin feature display label is memoized. |
63
+ | `DISPLAY_TRANSLATOR` | `stapel_categories.translation.identity_translator` | Dotted path `(key)->str` for rendering translation keys (default: identity). |
64
+
65
+ ## comm surface
66
+
67
+ | Kind | Name | Contract |
68
+ |---|---|---|
69
+ | Function | `categories.features` | `{"category_id": int}` -> `{"category_id", "revision", "features":[{id,slug,name,mandatory,config}]}` — resolved schema (own + inherited), cacheable by `revision` |
70
+ | Action (emit) | `category.changed` | `{"category_id": int, "revision": int}` on any category/feature mutation — for downstream cache invalidation |
71
+
72
+ `categories.features` lets stapel-listings validate attribute values against a
73
+ category's schema without importing this module.
74
+
75
+ ## Extension points
76
+
77
+ See [MODULE.md](MODULE.md) — the agent-facing map of every fork-free seam
78
+ (settings, serializer seams, comm surface, feature-editor actions, admin-UI
79
+ pointer to stapel-attributes).
80
+
81
+ ## Development
82
+
83
+ ```bash
84
+ pip install -e . && pip install pytest pytest-django ruff
85
+ ./setup-hooks.sh
86
+ pytest tests/
87
+ ```
88
+
89
+ ## License
90
+
91
+ MIT
@@ -0,0 +1,72 @@
1
+ # stapel-categories
2
+
3
+ Category tree with typed features for the [Stapel framework](https://github.com/usestapel) —
4
+ composable Django apps that deploy as a monolith or as microservices without
5
+ changing module code.
6
+
7
+ A hierarchical **category** tree (django-treenode) and a parallel **feature**
8
+ tree whose typed `config` is validated by
9
+ [stapel-attributes](https://github.com/usestapel/stapel-attributes). Categories
10
+ own the tree structure, feature inheritance, the ordered category↔feature M2M,
11
+ and the feature-editor lifecycle; the attribute *engine* (types, config/DTO/DAO
12
+ validation, polymorphic serializers, admin widgets) lives in stapel-attributes
13
+ and is imported, never re-implemented.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pip install stapel-categories
19
+ ```
20
+
21
+ ```python
22
+ INSTALLED_APPS = [
23
+ # ...
24
+ "treenode", # django-treenode (tree-cache signals)
25
+ "stapel_categories",
26
+ ]
27
+
28
+ # urls.py — the host chooses the prefix
29
+ path("categories/", include("stapel_categories.urls"))
30
+ ```
31
+
32
+ `stapel-attributes` is an imported library (no app to install); its config
33
+ editor ships static assets, so run `collectstatic` if you use the admin.
34
+
35
+ ## Settings
36
+
37
+ All configuration lives in the `STAPEL_CATEGORIES` namespace (dict setting,
38
+ flat setting, or env var — resolved lazily):
39
+
40
+ | Key | Default | Meaning |
41
+ |---|---|---|
42
+ | `CAROUSEL_CACHE_TIMEOUT` | `300` | Seconds the `carousel` response is cached. |
43
+ | `FEATURE_DISPLAY_CACHE_TIMEOUT` | `60` | Seconds an admin feature display label is memoized. |
44
+ | `DISPLAY_TRANSLATOR` | `stapel_categories.translation.identity_translator` | Dotted path `(key)->str` for rendering translation keys (default: identity). |
45
+
46
+ ## comm surface
47
+
48
+ | Kind | Name | Contract |
49
+ |---|---|---|
50
+ | Function | `categories.features` | `{"category_id": int}` -> `{"category_id", "revision", "features":[{id,slug,name,mandatory,config}]}` — resolved schema (own + inherited), cacheable by `revision` |
51
+ | Action (emit) | `category.changed` | `{"category_id": int, "revision": int}` on any category/feature mutation — for downstream cache invalidation |
52
+
53
+ `categories.features` lets stapel-listings validate attribute values against a
54
+ category's schema without importing this module.
55
+
56
+ ## Extension points
57
+
58
+ See [MODULE.md](MODULE.md) — the agent-facing map of every fork-free seam
59
+ (settings, serializer seams, comm surface, feature-editor actions, admin-UI
60
+ pointer to stapel-attributes).
61
+
62
+ ## Development
63
+
64
+ ```bash
65
+ pip install -e . && pip install pytest pytest-django ruff
66
+ ./setup-hooks.sh
67
+ pytest tests/
68
+ ```
69
+
70
+ ## License
71
+
72
+ MIT
@@ -0,0 +1,31 @@
1
+ """stapel-categories — Category tree for the Stapel framework.
2
+
3
+ Public API (lazily exported, PEP 562 — importing this package never pulls
4
+ in Django or requires configured settings):
5
+
6
+ - ``categories_settings`` — resolved app settings (``stapel_categories.conf``).
7
+ """
8
+
9
+ __all__ = [
10
+ "categories_settings",
11
+ ]
12
+
13
+ # name -> submodule that defines it. Resolution is deferred until first
14
+ # attribute access so that `import stapel_categories` stays Django-free.
15
+ _LAZY_EXPORTS = {
16
+ "categories_settings": ".conf",
17
+ }
18
+
19
+
20
+ def __getattr__(name):
21
+ if name in _LAZY_EXPORTS:
22
+ from importlib import import_module
23
+
24
+ value = getattr(import_module(_LAZY_EXPORTS[name], __name__), name)
25
+ globals()[name] = value # cache for subsequent lookups
26
+ return value
27
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
28
+
29
+
30
+ def __dir__():
31
+ return sorted(set(globals()) | set(__all__))
@@ -0,0 +1,211 @@
1
+ """Django admin for Category and Feature.
2
+
3
+ The config editor UI is supplied by stapel-attributes (its ``ConfigEditorWidget``
4
+ ships the Lit bundle via ``Media``); this module wires the models, the
5
+ feature-editor forms, and validation admin-actions that delegate to the
6
+ attribute engine. CDN icons are opaque strings — no cdn dependency, no
7
+ image preview coupling.
8
+ """
9
+ from django.contrib import admin
10
+ from django.core.exceptions import ValidationError as DjangoValidationError
11
+ from django.utils.html import format_html
12
+ from django.utils.safestring import mark_safe
13
+ from django.utils.translation import gettext_lazy as _
14
+ from treenode.admin import TreeNodeModelAdmin
15
+
16
+ from stapel_attributes import get_feature_type, parse_config
17
+ from stapel_core.django.admin.mixins import RevisionAdmin
18
+
19
+ from .forms import CategoryAdminForm, FeatureAdminForm
20
+ from .models import Category, CategoryFeature, Feature
21
+
22
+
23
+ class SubFeatureInline(admin.TabularInline):
24
+ model = Feature
25
+ fields = ["name", "slug", "config", "mandatory", "show_as_badge", "show_at_title", "translate", "tn_priority"]
26
+ show_change_link = True
27
+ extra = 1
28
+
29
+
30
+ class FeatureCategoryInline(admin.TabularInline):
31
+ model = CategoryFeature
32
+ fk_name = "feature"
33
+ fields = ["category", "order"]
34
+ show_change_link = True
35
+ extra = 0
36
+ ordering = ["order", "id"]
37
+
38
+
39
+ @admin.register(Category)
40
+ class CategoryAdmin(RevisionAdmin, TreeNodeModelAdmin):
41
+ """Category admin with revision tracking + treenode display.
42
+
43
+ Inlines are intentionally omitted — use the Feature Editor and Children
44
+ Editor screens (built on stapel-attributes components) instead.
45
+ """
46
+
47
+ form = CategoryAdminForm
48
+ inlines = []
49
+ autocomplete_fields = ["tn_parent"]
50
+
51
+ list_display = [
52
+ "name_with_status", "tn_priority", "feature_count", "active",
53
+ "carousel_enabled", "catalog_icon", "carousel_icon", "translatable",
54
+ "revision", "deleted",
55
+ ]
56
+ list_filter = ["active", "carousel_enabled", "translatable", "deleted"]
57
+ search_fields = ["name", "slug"]
58
+ readonly_fields = ["revision"]
59
+ actions = ["undelete_branch", "validate_category_features"]
60
+
61
+ fieldsets = (
62
+ ("Basic Information", {
63
+ "fields": ("tn_parent", "translatable", "slug", "name", "comment", "active", "tn_priority"),
64
+ }),
65
+ ("Icons", {
66
+ "fields": ("catalog_icon", "carousel_icon", "carousel_enabled"),
67
+ "description": "CDN asset references as opaque strings (e.g. catalog/electronics).",
68
+ }),
69
+ ("Feature Editor", {"fields": ("draft",), "description": "Manage features for this category"}),
70
+ ("Advanced", {"fields": ("revision",), "classes": ("collapse",)}),
71
+ )
72
+
73
+ @admin.display(description="ID / Slug", ordering="slug")
74
+ def name_with_status(self, obj):
75
+ id_badge = format_html(
76
+ '<span style="color:#888;font-size:11px;margin-right:4px;">{}</span>', obj.id
77
+ )
78
+ if obj.deleted:
79
+ return format_html(
80
+ "{}"
81
+ '<span style="color:#e74c3c;text-decoration:line-through;opacity:0.6;">{}</span> '
82
+ '<span style="background:#e74c3c;color:#fff;padding:2px 6px;border-radius:3px;'
83
+ 'font-size:10px;font-weight:bold;">DELETED</span>',
84
+ id_badge, obj.slug,
85
+ )
86
+ return format_html("{}{}", id_badge, obj.slug)
87
+
88
+ @admin.display(description="Features")
89
+ def feature_count(self, obj):
90
+ return obj.features.count()
91
+
92
+ @admin.action(description=_("Undelete category branch (category and all descendants)"))
93
+ def undelete_branch(self, request, queryset):
94
+ count = 0
95
+ for category in queryset:
96
+ if category.deleted:
97
+ category.deleted = False
98
+ category.save()
99
+ count += 1
100
+ descendants_pks = category.tn_descendants_pks
101
+ if descendants_pks:
102
+ descendant_ids = [int(pk) for pk in str(descendants_pks).split(",") if pk]
103
+ count += Category.objects.filter(
104
+ id__in=descendant_ids, deleted=True
105
+ ).update(deleted=False)
106
+ self.message_user(request, _("%d category/categories restored (including descendants).") % count)
107
+
108
+ @admin.action(description=_("Validate feature configs in category"))
109
+ def validate_category_features(self, request, queryset):
110
+ errors_by_category = []
111
+ categories_with_errors = 0
112
+ validated_features = {}
113
+
114
+ for category in queryset:
115
+ all_features = list(category.get_all_features())
116
+ invalid_results = []
117
+ for feature in all_features:
118
+ if feature.pk in validated_features:
119
+ result = validated_features[feature.pk]
120
+ else:
121
+ result = _validate_feature(feature)
122
+ validated_features[feature.pk] = result
123
+ if result is not None:
124
+ invalid_results.append(result)
125
+
126
+ if invalid_results:
127
+ categories_with_errors += 1
128
+ lines = [f"Category: {category.name} ({category.slug}) [id={category.pk}] "
129
+ f"— {len(invalid_results)} error(s) of {len(all_features)} feature(s)"]
130
+ for result in invalid_results:
131
+ lines.append(
132
+ f" - feature {result['id']} ({result['slug']}): {result['error']}"
133
+ )
134
+ errors_by_category.append("\n".join(lines))
135
+
136
+ if categories_with_errors > 0:
137
+ message = (
138
+ f"Found errors in {categories_with_errors} of {queryset.count()} category/categories:\n\n"
139
+ + "\n".join(errors_by_category)
140
+ )
141
+ self.message_user(request, message, level="error")
142
+ else:
143
+ self.message_user(request, f"All features in {queryset.count()} category/categories are valid.")
144
+
145
+
146
+ @admin.register(Feature)
147
+ class FeatureAdmin(TreeNodeModelAdmin):
148
+ form = FeatureAdminForm
149
+ inlines = [SubFeatureInline, FeatureCategoryInline]
150
+ autocomplete_fields = ["tn_parent"]
151
+ list_display = [
152
+ "name", "slug", "feature_type_display", "config_status", "mandatory",
153
+ "show_as_badge", "show_at_title", "translate", "tn_priority",
154
+ ]
155
+ list_filter = ["mandatory", "show_as_badge", "show_at_title", "translate"]
156
+ search_fields = ["name", "slug", "comment"]
157
+ actions = ["validate_configs"]
158
+
159
+ fieldsets = (
160
+ ("Basic Information", {
161
+ "fields": ("tn_parent", "translate", "slug", "name", "icon", "comment", "tn_priority"),
162
+ }),
163
+ ("Type Configuration", {"fields": ("config",)}),
164
+ ("Display Options", {"fields": ("mandatory", "show_as_badge", "show_at_title")}),
165
+ )
166
+
167
+ @admin.display(description="Type")
168
+ def feature_type_display(self, obj):
169
+ return obj.feature_type
170
+
171
+ @admin.display(description="Config")
172
+ def config_status(self, obj):
173
+ result = _validate_feature(obj)
174
+ if result is None:
175
+ return mark_safe('<span style="color:#27ae60;font-size:16px;" title="Config is valid">&#10003;</span>')
176
+ return format_html(
177
+ '<span style="color:#e74c3c;font-size:16px;" title="{}">&#10007;</span>', result["error"]
178
+ )
179
+
180
+ @admin.action(description=_("Validate feature configs"))
181
+ def validate_configs(self, request, queryset):
182
+ errors = []
183
+ valid_count = 0
184
+ for feature in queryset:
185
+ result = _validate_feature(feature)
186
+ if result is None:
187
+ valid_count += 1
188
+ else:
189
+ errors.append(
190
+ f"id={feature.pk} name={feature.name} slug={feature.slug}: {result['error']}"
191
+ )
192
+ if errors:
193
+ message = (
194
+ f"Validation failed for {len(errors)} of {queryset.count()} feature(s):\n\n"
195
+ + "\n".join(errors)
196
+ )
197
+ self.message_user(request, message, level="error")
198
+ else:
199
+ self.message_user(request, f"All {valid_count} feature(s) are valid.")
200
+
201
+
202
+ def _validate_feature(feature):
203
+ """Validate one feature's config via stapel-attributes. Returns None if valid."""
204
+ try:
205
+ config = parse_config(feature.get_config_with_defaults())
206
+ feature_type = get_feature_type(config.type)
207
+ feature_type.validate_config(config)
208
+ return None
209
+ except (DjangoValidationError, ValueError) as exc:
210
+ error_msg = str(exc.messages[0]) if hasattr(exc, "messages") else str(exc)
211
+ return {"id": feature.pk, "slug": feature.slug, "name": feature.name, "error": error_msg}
@@ -0,0 +1,15 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class CategoriesConfig(AppConfig):
5
+ name = "stapel_categories"
6
+ label = "categories"
7
+ verbose_name = "Category tree"
8
+ default_auto_field = "django.db.models.BigAutoField"
9
+
10
+ def ready(self):
11
+ # Import-time side effects: comm functions/actions, system checks,
12
+ # error-key registration. Keep each in its own module.
13
+ from . import checks # noqa: F401
14
+ from . import errors # noqa: F401
15
+ from . import functions # noqa: F401
@@ -0,0 +1,16 @@
1
+ """Django system checks for stapel-categories configuration.
2
+
3
+ Policy (docs/library-standard.md §3.7): E-level for configuration the
4
+ service cannot run with; W-level for entries that degrade lazily (a broken
5
+ *unused* dotted path must not block deploys).
6
+
7
+ Example:
8
+
9
+ from django.core import checks
10
+
11
+ @checks.register(checks.Tags.compatibility)
12
+ def check_default_provider(app_configs, **kwargs):
13
+ if ...:
14
+ return [checks.Error("...", id="stapel_categories.E001")]
15
+ return []
16
+ """
@@ -0,0 +1,32 @@
1
+ """Settings namespace for stapel-categories.
2
+
3
+ All configuration is read through ``categories_settings`` (lazily, at call
4
+ time) — never via module-level ``os.getenv`` (values would freeze at import).
5
+ Resolution order per key: ``settings.STAPEL_CATEGORIES`` dict -> flat Django
6
+ setting of the same name -> environment variable -> default below.
7
+
8
+ Dotted-path keys listed in ``import_strings`` are resolved with
9
+ ``import_string`` — the fork-free escape hatch for swappable behavior.
10
+ """
11
+ from stapel_core.conf import AppSettings
12
+
13
+ categories_settings = AppSettings(
14
+ "STAPEL_CATEGORIES",
15
+ defaults={
16
+ # Seconds the ``categories/carousel`` response is cached in the
17
+ # Django cache backend.
18
+ "CAROUSEL_CACHE_TIMEOUT": 300,
19
+ # Seconds an admin feature display-name translation is memoized.
20
+ "FEATURE_DISPLAY_CACHE_TIMEOUT": 60,
21
+ # Dotted path to a callable ``(key: str) -> str`` used to render a
22
+ # translation key for admin/``__str__`` display (single strategy,
23
+ # REPLACE semantics). Default is identity: this module stores
24
+ # translation *keys* and does not resolve them — a host that wants
25
+ # resolved names points this at its translation backend (e.g. a
26
+ # wrapper over the ``translate.resolve`` comm Function).
27
+ "DISPLAY_TRANSLATOR": "stapel_categories.translation.identity_translator",
28
+ },
29
+ import_strings=("DISPLAY_TRANSLATOR",),
30
+ )
31
+
32
+ __all__ = ["categories_settings"]
@@ -0,0 +1,87 @@
1
+ def pytest_configure(config):
2
+ from django.conf import settings
3
+ if not settings.configured:
4
+ settings.configure(
5
+ SECRET_KEY="test-secret-key-not-for-production",
6
+ INSTALLED_APPS=[
7
+ "django.contrib.contenttypes",
8
+ "django.contrib.auth",
9
+ "django.contrib.sessions",
10
+ "django.contrib.staticfiles",
11
+ "django.contrib.admin",
12
+ "django.contrib.messages",
13
+ "stapel_core.django.users",
14
+ "rest_framework",
15
+ # treenode (django-treenode) registers the tree-cache signals
16
+ # its AppConfig.ready() wires up — required for tn_* fields.
17
+ "treenode",
18
+ # stapel_attributes is an L1 library (no Django app); imported,
19
+ # not installed. Categories depends on its type registry.
20
+ "stapel_categories",
21
+ ],
22
+ AUTH_USER_MODEL="users.User",
23
+ STATIC_URL="/static/",
24
+ DATABASES={
25
+ "default": {
26
+ "ENGINE": "django.db.backends.sqlite3",
27
+ "NAME": ":memory:",
28
+ }
29
+ },
30
+ DEFAULT_AUTO_FIELD="django.db.models.BigAutoField",
31
+ USE_TZ=True,
32
+ ROOT_URLCONF="stapel_categories.tests.urls",
33
+ CACHES={
34
+ "default": {
35
+ "BACKEND": "django.core.cache.backends.locmem.LocMemCache",
36
+ },
37
+ },
38
+ # Synchronous in-process comm with schema validation ON, so the
39
+ # committed contracts in schemas/ are enforced by the tests.
40
+ STAPEL_BUS_BACKEND="stapel_core.bus.backends.memory.MemoryBus",
41
+ STAPEL_COMM={
42
+ "OUTBOX_ENABLED": False,
43
+ "ACTION_TRANSPORT": "inprocess",
44
+ "VALIDATE_SCHEMAS": True,
45
+ },
46
+ MIGRATION_MODULES={
47
+ "users": None,
48
+ },
49
+ )
50
+ import django
51
+ django.setup()
52
+
53
+ from stapel_core.comm.schemas import autoload_schemas
54
+ autoload_schemas()
55
+
56
+
57
+ import pytest # noqa: E402
58
+
59
+
60
+ @pytest.fixture(autouse=True)
61
+ def _clear_treenode_state():
62
+ """django-treenode keeps two process-global stores that survive the
63
+ per-test transaction rollback: a tree cache (Django cache backend) and a
64
+ WeakSet of live model instances it back-fills tn_* fields into
65
+ (``treenode.memory.__refs__``). Under sqlite ``:memory:`` PKs are reused
66
+ across rolled-back tests, so a stale ref/cache entry from an earlier test
67
+ would shadow a new instance with the same PK — clear both around every
68
+ test so tree lookups are always recomputed from the current DB."""
69
+ from django.core.cache import cache
70
+ from treenode.memory import clear_refs
71
+
72
+ from stapel_categories.models import Category, Feature
73
+
74
+ def _reset():
75
+ cache.clear()
76
+ clear_refs(Category)
77
+ clear_refs(Feature)
78
+
79
+ _reset()
80
+ yield
81
+ _reset()
82
+
83
+
84
+ @pytest.fixture
85
+ def api_client():
86
+ from rest_framework.test import APIClient
87
+ return APIClient()
@@ -0,0 +1,25 @@
1
+ """Dataclass DTOs — the API models of stapel-categories (never ORM instances)."""
2
+ from dataclasses import dataclass
3
+ from typing import List, Optional
4
+
5
+
6
+ @dataclass
7
+ class UndeleteResponse:
8
+ """Restored soft-deleted categories.
9
+
10
+ Attributes:
11
+ restored: List of restored category IDs. Example: [1, 2, 3]
12
+ """
13
+
14
+ restored: List[int]
15
+
16
+
17
+ @dataclass
18
+ class FeatureEditorDraftResponse:
19
+ """Feature editor draft state.
20
+
21
+ Attributes:
22
+ draft: JSON-encoded draft string, null if no draft saved. Example: {"features": []}
23
+ """
24
+
25
+ draft: Optional[str]
@@ -0,0 +1,34 @@
1
+ """i18n error keys of stapel-categories.
2
+
3
+ Only ``error.<status>.<slug>`` keys leave this package — human-readable
4
+ strings are translations, never literals in responses.
5
+ """
6
+ from stapel_core.django.api.errors import register_service_errors
7
+
8
+ ERR_400_EXPECTED_LIST = "error.400.categories_expected_list"
9
+ ERR_400_DUPLICATE_SLUG = "error.400.categories_duplicate_slug"
10
+ ERR_400_DATABASE_ERROR = "error.400.categories_database_error"
11
+ ERR_400_CATEGORY_NOT_DELETED = "error.400.categories_not_deleted"
12
+ ERR_400_INVALID_CONVERSION = "error.400.categories_invalid_conversion"
13
+ ERR_400_CONFIG_REQUIRED = "error.400.categories_config_required"
14
+
15
+ STAPEL_CATEGORIES_ERRORS = {
16
+ ERR_400_EXPECTED_LIST: "Expected a list of objects",
17
+ ERR_400_DUPLICATE_SLUG: "A feature with slug '{slug}' already exists",
18
+ ERR_400_DATABASE_ERROR: "Database error while applying changes",
19
+ ERR_400_CATEGORY_NOT_DELETED: "Category is not deleted",
20
+ ERR_400_INVALID_CONVERSION: "Invalid type conversion (only select<->string is supported)",
21
+ ERR_400_CONFIG_REQUIRED: "A config object is required",
22
+ }
23
+
24
+ register_service_errors(STAPEL_CATEGORIES_ERRORS)
25
+
26
+ __all__ = [
27
+ "STAPEL_CATEGORIES_ERRORS",
28
+ "ERR_400_EXPECTED_LIST",
29
+ "ERR_400_DUPLICATE_SLUG",
30
+ "ERR_400_DATABASE_ERROR",
31
+ "ERR_400_CATEGORY_NOT_DELETED",
32
+ "ERR_400_INVALID_CONVERSION",
33
+ "ERR_400_CONFIG_REQUIRED",
34
+ ]
@@ -0,0 +1,31 @@
1
+ """comm event publishers for stapel-categories.
2
+
3
+ Events go through ``stapel_core.comm`` (transport is deployment
4
+ configuration: in-process in a monolith, bus in microservices). Emitted
5
+ through the transactional outbox, so the event leaves iff the surrounding
6
+ DB transaction commits. Payload contract lives in
7
+ ``schemas/emits/category.changed.json``.
8
+
9
+ Downstream consumers (e.g. stapel-listings) subscribe to ``category.changed``
10
+ to invalidate any cached ``categories.features`` result for that category.
11
+ """
12
+
13
+
14
+ def publish_category_changed(category_id, revision):
15
+ """Emit ``category.changed`` for a mutated category (or feature affecting it).
16
+
17
+ Deliberately does NOT swallow failures: ``emit`` runs inside the mutating
18
+ ``save()``'s atomic block, so a raise rolls the mutation back. That is the
19
+ whole point of the transactional outbox — the event and the row it
20
+ describes commit together or not at all. Swallowing here would let a
21
+ mutation commit with no event, leaving every downstream
22
+ ``categories.features`` cache silently stale (a cache-invalidation
23
+ contract can't tolerate a lost invalidation). Let it propagate.
24
+ """
25
+ from stapel_core.comm import emit
26
+
27
+ emit(
28
+ "category.changed",
29
+ {"category_id": int(category_id), "revision": int(revision or 0)},
30
+ key=str(category_id),
31
+ )