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.
- stapel_categories-0.1.0/LICENSE +21 -0
- stapel_categories-0.1.0/PKG-INFO +91 -0
- stapel_categories-0.1.0/README.md +72 -0
- stapel_categories-0.1.0/__init__.py +31 -0
- stapel_categories-0.1.0/admin.py +211 -0
- stapel_categories-0.1.0/apps.py +15 -0
- stapel_categories-0.1.0/checks.py +16 -0
- stapel_categories-0.1.0/conf.py +32 -0
- stapel_categories-0.1.0/conftest.py +87 -0
- stapel_categories-0.1.0/dto.py +25 -0
- stapel_categories-0.1.0/errors.py +34 -0
- stapel_categories-0.1.0/events.py +31 -0
- stapel_categories-0.1.0/feature_editor.py +361 -0
- stapel_categories-0.1.0/forms.py +75 -0
- stapel_categories-0.1.0/functions.py +55 -0
- stapel_categories-0.1.0/migrations/0001_initial.py +118 -0
- stapel_categories-0.1.0/migrations/__init__.py +0 -0
- stapel_categories-0.1.0/models.py +354 -0
- stapel_categories-0.1.0/py.typed +0 -0
- stapel_categories-0.1.0/pyproject.toml +44 -0
- stapel_categories-0.1.0/schemas/emits/category.changed.json +18 -0
- stapel_categories-0.1.0/schemas/functions/categories.features.json +31 -0
- stapel_categories-0.1.0/serializers.py +325 -0
- stapel_categories-0.1.0/setup.cfg +4 -0
- stapel_categories-0.1.0/stapel_categories.egg-info/PKG-INFO +91 -0
- stapel_categories-0.1.0/stapel_categories.egg-info/SOURCES.txt +78 -0
- stapel_categories-0.1.0/stapel_categories.egg-info/dependency_links.txt +1 -0
- stapel_categories-0.1.0/stapel_categories.egg-info/requires.txt +6 -0
- stapel_categories-0.1.0/stapel_categories.egg-info/top_level.txt +1 -0
- stapel_categories-0.1.0/tests/__init__.py +0 -0
- stapel_categories-0.1.0/tests/test_category_commands.py +718 -0
- stapel_categories-0.1.0/tests/test_comm.py +153 -0
- stapel_categories-0.1.0/tests/test_extension_points.py +66 -0
- stapel_categories-0.1.0/tests/test_feature_editor.py +539 -0
- stapel_categories-0.1.0/tests/test_feature_editor_enhancements.py +319 -0
- stapel_categories-0.1.0/tests/test_meta_regression.py +39 -0
- stapel_categories-0.1.0/tests/test_models.py +277 -0
- stapel_categories-0.1.0/tests/test_public_api.py +28 -0
- stapel_categories-0.1.0/tests/test_translation_keys.py +200 -0
- stapel_categories-0.1.0/tests/urls.py +7 -0
- stapel_categories-0.1.0/translation.py +113 -0
- stapel_categories-0.1.0/translation_keys.py +360 -0
- stapel_categories-0.1.0/urls.py +13 -0
- stapel_categories-0.1.0/validators.py +25 -0
- 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">✓</span>')
|
|
176
|
+
return format_html(
|
|
177
|
+
'<span style="color:#e74c3c;font-size:16px;" title="{}">✗</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
|
+
)
|