splent-framework 1.6.2__tar.gz → 1.7.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 (103) hide show
  1. {splent_framework-1.6.2/src/splent_framework.egg-info → splent_framework-1.7.0}/PKG-INFO +1 -1
  2. {splent_framework-1.6.2 → splent_framework-1.7.0}/pyproject.toml +1 -1
  3. splent_framework-1.7.0/src/splent_framework/admin/__init__.py +29 -0
  4. splent_framework-1.7.0/src/splent_framework/admin/registry.py +149 -0
  5. splent_framework-1.7.0/src/splent_framework/assets/asset_registry.py +68 -0
  6. splent_framework-1.7.0/src/splent_framework/hooks/template_hooks.py +43 -0
  7. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/managers/feature_loader.py +14 -0
  8. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/managers/feature_manager.py +35 -12
  9. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/managers/jinja_manager.py +2 -0
  10. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/managers/locale_manager.py +10 -4
  11. splent_framework-1.7.0/src/splent_framework/nav/nav_registry.py +50 -0
  12. splent_framework-1.7.0/src/splent_framework/settings/__init__.py +0 -0
  13. splent_framework-1.7.0/src/splent_framework/settings/settings_schema.py +103 -0
  14. splent_framework-1.7.0/src/splent_framework/signals/__init__.py +0 -0
  15. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/signals/signal_utils.py +16 -4
  16. splent_framework-1.7.0/src/splent_framework/utils/__init__.py +0 -0
  17. {splent_framework-1.6.2 → splent_framework-1.7.0/src/splent_framework.egg-info}/PKG-INFO +1 -1
  18. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework.egg-info/SOURCES.txt +8 -0
  19. splent_framework-1.6.2/src/splent_framework/hooks/template_hooks.py +0 -36
  20. {splent_framework-1.6.2 → splent_framework-1.7.0}/LICENSE +0 -0
  21. {splent_framework-1.6.2 → splent_framework-1.7.0}/README.md +0 -0
  22. {splent_framework-1.6.2 → splent_framework-1.7.0}/setup.cfg +0 -0
  23. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/_init__.py +0 -0
  24. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/app_factory.py +0 -0
  25. {splent_framework-1.6.2/src/splent_framework/blueprints → splent_framework-1.7.0/src/splent_framework/assets}/__init__.py +0 -0
  26. {splent_framework-1.6.2/src/splent_framework/bootstraps → splent_framework-1.7.0/src/splent_framework/blueprints}/__init__.py +0 -0
  27. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/blueprints/base_blueprint.py +0 -0
  28. {splent_framework-1.6.2/src/splent_framework/configuration → splent_framework-1.7.0/src/splent_framework/bootstraps}/__init__.py +0 -0
  29. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/bootstraps/locustfile_bootstrap.py +0 -0
  30. {splent_framework-1.6.2/src/splent_framework/context → splent_framework-1.7.0/src/splent_framework/configuration}/__init__.py +0 -0
  31. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/configuration/configuration.py +0 -0
  32. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/configuration/default_config.py +0 -0
  33. {splent_framework-1.6.2/src/splent_framework/decorators → splent_framework-1.7.0/src/splent_framework/context}/__init__.py +0 -0
  34. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/context/context_manager.py +0 -0
  35. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/db.py +0 -0
  36. {splent_framework-1.6.2/src/splent_framework/environment → splent_framework-1.7.0/src/splent_framework/decorators}/__init__.py +0 -0
  37. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/decorators/decorators.py +0 -0
  38. {splent_framework-1.6.2/src/splent_framework/fixtures → splent_framework-1.7.0/src/splent_framework/environment}/__init__.py +0 -0
  39. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/environment/host.py +0 -0
  40. {splent_framework-1.6.2/src/splent_framework/helpers → splent_framework-1.7.0/src/splent_framework/fixtures}/__init__.py +0 -0
  41. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/fixtures/fixtures.py +0 -0
  42. {splent_framework-1.6.2/src/splent_framework/hooks → splent_framework-1.7.0/src/splent_framework/helpers}/__init__.py +0 -0
  43. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/helpers/test_helpers_auth.py +0 -0
  44. {splent_framework-1.6.2/src/splent_framework/locust → splent_framework-1.7.0/src/splent_framework/hooks}/__init__.py +0 -0
  45. {splent_framework-1.6.2/src/splent_framework/managers → splent_framework-1.7.0/src/splent_framework/locust}/__init__.py +0 -0
  46. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/locust/common.py +0 -0
  47. {splent_framework-1.6.2/src/splent_framework/migrations → splent_framework-1.7.0/src/splent_framework/managers}/__init__.py +0 -0
  48. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/managers/config_manager.py +0 -0
  49. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/managers/db_manager.py +0 -0
  50. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/managers/error_handler_manager.py +0 -0
  51. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/managers/feature_order.py +0 -0
  52. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/managers/logging_manager.py +0 -0
  53. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/managers/migration_manager.py +0 -0
  54. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/managers/namespace_manager.py +0 -0
  55. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/managers/session_manager.py +0 -0
  56. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/managers/task_queue_manager.py +0 -0
  57. {splent_framework-1.6.2/src/splent_framework/namespaces → splent_framework-1.7.0/src/splent_framework/migrations}/__init__.py +0 -0
  58. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/migrations/feature_env.py +0 -0
  59. {splent_framework-1.6.2/src/splent_framework/repositories → splent_framework-1.7.0/src/splent_framework/namespaces}/__init__.py +0 -0
  60. {splent_framework-1.6.2/src/splent_framework/resources → splent_framework-1.7.0/src/splent_framework/nav}/__init__.py +0 -0
  61. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/refinement/__init__.py +0 -0
  62. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/refinement/model_extender.py +0 -0
  63. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/refinement/parser.py +0 -0
  64. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/refinement/registry.py +0 -0
  65. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/refinement/validator.py +0 -0
  66. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/repositories/BaseRepository.py +0 -0
  67. {splent_framework-1.6.2/src/splent_framework/seeders → splent_framework-1.7.0/src/splent_framework/repositories}/__init__.py +0 -0
  68. {splent_framework-1.6.2/src/splent_framework/selenium → splent_framework-1.7.0/src/splent_framework/resources}/__init__.py +0 -0
  69. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/resources/generic_resource.py +0 -0
  70. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/seeders/BaseSeeder.py +0 -0
  71. {splent_framework-1.6.2/src/splent_framework/serialisers → splent_framework-1.7.0/src/splent_framework/seeders}/__init__.py +0 -0
  72. {splent_framework-1.6.2/src/splent_framework/services → splent_framework-1.7.0/src/splent_framework/selenium}/__init__.py +0 -0
  73. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/selenium/common.py +0 -0
  74. {splent_framework-1.6.2/src/splent_framework/signals → splent_framework-1.7.0/src/splent_framework/serialisers}/__init__.py +0 -0
  75. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/serialisers/serializer.py +0 -0
  76. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/services/BaseService.py +0 -0
  77. {splent_framework-1.6.2/src/splent_framework/utils → splent_framework-1.7.0/src/splent_framework/services}/__init__.py +0 -0
  78. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/services/service_locator.py +0 -0
  79. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/signals/registry.py +0 -0
  80. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/utils/app_loader.py +0 -0
  81. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/utils/feature_utils.py +0 -0
  82. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/utils/form_helpers.py +0 -0
  83. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/utils/path_utils.py +0 -0
  84. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework/utils/pyproject_reader.py +0 -0
  85. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework.egg-info/dependency_links.txt +0 -0
  86. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework.egg-info/requires.txt +0 -0
  87. {splent_framework-1.6.2 → splent_framework-1.7.0}/src/splent_framework.egg-info/top_level.txt +0 -0
  88. {splent_framework-1.6.2 → splent_framework-1.7.0}/tests/test_base_blueprint_security.py +0 -0
  89. {splent_framework-1.6.2 → splent_framework-1.7.0}/tests/test_base_repository.py +0 -0
  90. {splent_framework-1.6.2 → splent_framework-1.7.0}/tests/test_config_manager.py +0 -0
  91. {splent_framework-1.6.2 → splent_framework-1.7.0}/tests/test_config_manager_return.py +0 -0
  92. {splent_framework-1.6.2 → splent_framework-1.7.0}/tests/test_context_manager.py +0 -0
  93. {splent_framework-1.6.2 → splent_framework-1.7.0}/tests/test_default_config.py +0 -0
  94. {splent_framework-1.6.2 → splent_framework-1.7.0}/tests/test_error_handler_manager.py +0 -0
  95. {splent_framework-1.6.2 → splent_framework-1.7.0}/tests/test_feature_manager_parse.py +0 -0
  96. {splent_framework-1.6.2 → splent_framework-1.7.0}/tests/test_feature_order.py +0 -0
  97. {splent_framework-1.6.2 → splent_framework-1.7.0}/tests/test_feature_utils.py +0 -0
  98. {splent_framework-1.6.2 → splent_framework-1.7.0}/tests/test_generic_resource.py +0 -0
  99. {splent_framework-1.6.2 → splent_framework-1.7.0}/tests/test_migration_manager.py +0 -0
  100. {splent_framework-1.6.2 → splent_framework-1.7.0}/tests/test_namespace_manager.py +0 -0
  101. {splent_framework-1.6.2 → splent_framework-1.7.0}/tests/test_pyproject_reader.py +0 -0
  102. {splent_framework-1.6.2 → splent_framework-1.7.0}/tests/test_pyproject_reader_env.py +0 -0
  103. {splent_framework-1.6.2 → splent_framework-1.7.0}/tests/test_refinement.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: splent_framework
3
- Version: 1.6.2
3
+ Version: 1.7.0
4
4
  Summary: SPLENT-FRAMEWORK is a set of libraries for agile product development within SPLENT.
5
5
  Author-email: DiversoLab <diversolab@us.es>
6
6
  Project-URL: Homepage, https://github.com/diverso-lab/splent_framework
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "splent_framework"
7
- version = "1.6.2"
7
+ version = "1.7.0"
8
8
  description = "SPLENT-FRAMEWORK is a set of libraries for agile product development within SPLENT."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -0,0 +1,29 @@
1
+ """Admin resource registry — declare how models surface in the admin panel.
2
+
3
+ A content feature registers its model with :func:`register_admin_resource`
4
+ (typically from ``init_feature``); the ``admin`` feature reads the registry to
5
+ build a grouped, wp-admin-style menu and CRUD screens. Mirrors the singleton
6
+ pattern of ``splent_framework.hooks.template_hooks``.
7
+ """
8
+
9
+ from splent_framework.admin.registry import (
10
+ WIDGETS,
11
+ AdminResource,
12
+ clear_admin_resources,
13
+ get_admin_groups,
14
+ get_admin_resource,
15
+ get_admin_resource_for_model,
16
+ get_admin_resources,
17
+ register_admin_resource,
18
+ )
19
+
20
+ __all__ = [
21
+ "WIDGETS",
22
+ "AdminResource",
23
+ "register_admin_resource",
24
+ "get_admin_resource",
25
+ "get_admin_resource_for_model",
26
+ "get_admin_resources",
27
+ "get_admin_groups",
28
+ "clear_admin_resources",
29
+ ]
@@ -0,0 +1,149 @@
1
+ # splent_framework/admin/registry.py
2
+ #
3
+ # Admin resource registry — the analogue of WordPress `register_post_type`.
4
+ #
5
+ # A content feature registers its model as an *admin resource*, declaring how it
6
+ # should appear in the admin panel (label, icon, menu group, list columns,
7
+ # per-field widgets). The `admin` feature reads this registry to build a curated,
8
+ # grouped, wp-admin-style menu and CRUD screens — instead of dumping every
9
+ # `db.Model` subclass in a flat list.
10
+ #
11
+ # NOTE: _resources is a module-level registry populated once at app startup
12
+ # (during feature registration / init_feature) and read-only during request
13
+ # handling — same contract as template_hooks.py. Not thread-safe for concurrent
14
+ # writes; do not call register_admin_resource() from request handlers.
15
+
16
+ from dataclasses import dataclass, field
17
+
18
+ # Widget hints the admin form renderer understands. Features may pass any of
19
+ # these per column via `field_widgets`; unknown columns fall back to an
20
+ # introspection-derived default chosen by the admin feature.
21
+ WIDGETS = (
22
+ "text", # single-line string
23
+ "textarea", # multi-line plain text
24
+ "richtext", # WYSIWYG / HTML body
25
+ "number",
26
+ "bool",
27
+ "date",
28
+ "datetime",
29
+ "select", # enum / fixed choices
30
+ "fk", # foreign key -> related record picker
31
+ "image", # media id / upload
32
+ "url",
33
+ "slug",
34
+ "color",
35
+ )
36
+
37
+
38
+ @dataclass
39
+ class AdminResource:
40
+ """Metadata describing how a model is administered in the admin panel."""
41
+
42
+ model: type
43
+ name: str # url-safe key, e.g. "project"
44
+ label: str # singular human label, e.g. "Project"
45
+ label_plural: str # e.g. "Projects"
46
+ icon: str = "file" # feather icon name used in the menu
47
+ group: str = "Content" # menu section this resource is filed under
48
+ order: int = 100 # sort order within the group
49
+ list_columns: list[str] | None = None # None -> admin auto-picks
50
+ search_fields: list[str] = field(default_factory=list)
51
+ field_widgets: dict[str, str] = field(default_factory=dict) # column -> WIDGETS
52
+ readonly_fields: list[str] = field(default_factory=list)
53
+ scope: dict = field(default_factory=dict) # query filter — lets one shared
54
+ # table back several menu entries
55
+ # (e.g. {"collection": "event"})
56
+ create_defaults: dict = field(
57
+ default_factory=dict
58
+ ) # column values forced on create
59
+ feature: str | None = None # owning feature short name (provenance)
60
+
61
+ @property
62
+ def model_name(self) -> str:
63
+ """The SQLAlchemy class name — the key the CRUD routes resolve by."""
64
+ return self.model.__name__
65
+
66
+
67
+ # Module-level singleton registry (keyed by resource `name`).
68
+ _resources: dict[str, AdminResource] = {}
69
+
70
+
71
+ def register_admin_resource(
72
+ model: type,
73
+ *,
74
+ name: str | None = None,
75
+ label: str | None = None,
76
+ label_plural: str | None = None,
77
+ icon: str = "file",
78
+ group: str = "Content",
79
+ order: int = 100,
80
+ list_columns: list[str] | None = None,
81
+ search_fields: list[str] | None = None,
82
+ field_widgets: dict[str, str] | None = None,
83
+ readonly_fields: list[str] | None = None,
84
+ scope: dict | None = None,
85
+ create_defaults: dict | None = None,
86
+ feature: str | None = None,
87
+ ) -> AdminResource:
88
+ """Register a model as an administered resource (additive, idempotent by name).
89
+
90
+ Call from a feature's ``init_feature(app)``. The owning content feature thus
91
+ declares its admin surface once; the admin panel renders it. Re-registering
92
+ the same ``name`` overrides the previous entry (last registration wins), which
93
+ lets a refinement feature curate another feature's resource.
94
+ """
95
+ key = name or model.__name__.lower()
96
+ resolved_label = label or model.__name__
97
+ res = AdminResource(
98
+ model=model,
99
+ name=key,
100
+ label=resolved_label,
101
+ label_plural=label_plural or f"{resolved_label}s",
102
+ icon=icon,
103
+ group=group,
104
+ order=order,
105
+ list_columns=list_columns,
106
+ search_fields=search_fields or [],
107
+ field_widgets=field_widgets or {},
108
+ readonly_fields=readonly_fields or [],
109
+ scope=scope or {},
110
+ create_defaults=create_defaults or {},
111
+ feature=feature,
112
+ )
113
+ _resources[key] = res
114
+ return res
115
+
116
+
117
+ def get_admin_resource(name: str) -> AdminResource | None:
118
+ """Return a single registered resource by its url-safe name, or None."""
119
+ return _resources.get(name)
120
+
121
+
122
+ def get_admin_resource_for_model(model_name: str) -> AdminResource | None:
123
+ """Return the resource whose model class name matches ``model_name``."""
124
+ for res in _resources.values():
125
+ if res.model_name == model_name:
126
+ return res
127
+ return None
128
+
129
+
130
+ def get_admin_resources() -> dict[str, AdminResource]:
131
+ """Return all registered resources keyed by name (insertion order)."""
132
+ return dict(_resources)
133
+
134
+
135
+ def get_admin_groups() -> dict[str, list[AdminResource]]:
136
+ """Return resources bucketed by menu group, each sorted by (order, label).
137
+
138
+ This is what the admin sidebar iterates to render a grouped, wp-admin-style
139
+ menu (a section per group, content types listed underneath).
140
+ """
141
+ groups: dict[str, list[AdminResource]] = {}
142
+ for res in sorted(_resources.values(), key=lambda r: (r.order, r.label)):
143
+ groups.setdefault(res.group, []).append(res)
144
+ return groups
145
+
146
+
147
+ def clear_admin_resources() -> None:
148
+ """Remove all registered resources. Intended for test teardown."""
149
+ _resources.clear()
@@ -0,0 +1,68 @@
1
+ # splent_framework/assets/asset_registry.py
2
+ #
3
+ # SPL asset registry. Features DECLARE their stylesheets/scripts here (from
4
+ # init_feature) instead of hand-writing <link>/<script> tags in hooks or
5
+ # inlining JS into templates. The public shell renders get_assets("css") in
6
+ # <head> and get_assets("js") before </body>, DEDUPLICATED and ORDERED — so the
7
+ # cascade is deterministic and asset injection is consistent across features.
8
+ # No ad-hoc tags, no inline scripts: a feature ships a file under its blueprint's
9
+ # assets/ folder (served by the blueprint asset route) and registers it here.
10
+ #
11
+ # Cleared per app at feature-load (like the nav registry) so each product only
12
+ # emits the assets of ITS installed features. Registration is idempotent.
13
+
14
+ _assets: list = []
15
+
16
+
17
+ def register_asset(kind: str, endpoint: str, order: int = 100, **params) -> None:
18
+ """Declare a static asset served by a feature blueprint's asset route.
19
+
20
+ Args:
21
+ kind: "css" or "js".
22
+ endpoint: the blueprint asset endpoint, e.g. "slider.assets".
23
+ order: load order (lower first) — governs the cascade deterministically.
24
+ Convention: theme base ~0, features ~100, skins ~200 (win last).
25
+ **params: url_for params for the asset route, e.g.
26
+ subfolder="css", filename="slider.css".
27
+ """
28
+ key = (kind, endpoint, tuple(sorted(params.items())))
29
+ for a in _assets:
30
+ if a["key"] == key:
31
+ return
32
+ _assets.append(
33
+ {
34
+ "key": key,
35
+ "kind": kind,
36
+ "endpoint": endpoint,
37
+ "params": params,
38
+ "order": order,
39
+ }
40
+ )
41
+
42
+
43
+ def get_assets(kind: str) -> list:
44
+ """Resolved asset URLs of a kind, ordered by (order, endpoint) and deduped.
45
+
46
+ url_for is resolved at call time, so invoke this from the template (request
47
+ context). Assets whose route can't be resolved are skipped, not raised.
48
+ """
49
+ from flask import url_for
50
+
51
+ seen = set()
52
+ out = []
53
+ for a in sorted(_assets, key=lambda x: (x["order"], x["endpoint"])):
54
+ if a["kind"] != kind:
55
+ continue
56
+ try:
57
+ href = url_for(a["endpoint"], **a["params"])
58
+ except Exception:
59
+ continue
60
+ if href not in seen:
61
+ seen.add(href)
62
+ out.append(href)
63
+ return out
64
+
65
+
66
+ def clear_assets() -> None:
67
+ """Drop all registered assets. Called per app before feature load."""
68
+ _assets.clear()
@@ -0,0 +1,43 @@
1
+ # splent_framework/template_hooks.py
2
+ #
3
+ # NOTE: _hooks is a module-level registry populated once at app startup
4
+ # (during feature registration) and read-only during request handling.
5
+ # It is NOT thread-safe for concurrent writes; do not call
6
+ # register_template_hook() from request handlers or background threads.
7
+
8
+ # Each slot holds (order, func) pairs; get_template_hooks returns the funcs
9
+ # sorted by order (lower first), so slots like "home.section" compose features
10
+ # in a deterministic order regardless of feature load order.
11
+ _hooks: dict[str, list] = {}
12
+
13
+
14
+ def register_template_hook(name: str, func, order: int = 100) -> None:
15
+ """Append a callback to a named hook slot (additive).
16
+
17
+ order: lower renders first within the slot (default 100).
18
+ """
19
+ _hooks.setdefault(name, []).append((order, func))
20
+
21
+
22
+ def replace_template_hook(name: str, func, order: int = 100) -> None:
23
+ """Replace ALL existing callbacks for a named hook slot.
24
+
25
+ Used by refinement features to override a base feature's hook content.
26
+ """
27
+ _hooks[name] = [(order, func)]
28
+
29
+
30
+ def remove_template_hook(name: str, func) -> None:
31
+ """Remove a specific callback from a hook slot."""
32
+ if name in _hooks:
33
+ _hooks[name] = [(o, f) for (o, f) in _hooks[name] if f is not func]
34
+
35
+
36
+ def get_template_hooks(name: str) -> list:
37
+ """Callbacks for a slot, ordered by their registration `order`."""
38
+ return [f for _, f in sorted(_hooks.get(name, []), key=lambda pair: pair[0])]
39
+
40
+
41
+ def clear_hooks() -> None:
42
+ """Remove all registered hooks. Intended for use in test teardown."""
43
+ _hooks.clear()
@@ -248,6 +248,7 @@ class FeatureIntegrator:
248
248
  self._call_init(module, import_name)
249
249
  self._apply_service_overrides(import_name)
250
250
  self._register_blueprints(module, import_name)
251
+ self._register_context_vars(module, import_name)
251
252
  self._register_translations(module, import_name)
252
253
  self._register_commands(import_name)
253
254
 
@@ -358,6 +359,19 @@ class FeatureIntegrator:
358
359
  elif self._strict:
359
360
  raise FeatureError(f"{import_name} lacks init_feature(app)")
360
361
 
362
+ def _register_context_vars(self, module, import_name: str) -> None:
363
+ """Collect the feature's ``inject_context_vars`` so the JinjaManager
364
+ context processor merges its returned dict into every template's context
365
+ on each request (see docs: framework/template-system, framework/
366
+ feature-loading). Without this the function is never called.
367
+ """
368
+ fn = getattr(module, "inject_context_vars", None)
369
+ if fn is None:
370
+ return
371
+ if not hasattr(self._app, "context_processors"):
372
+ self._app.context_processors = []
373
+ self._app.context_processors.append(fn)
374
+
361
375
  def _register_blueprints(self, module, import_name: str) -> None:
362
376
  candidates = self._collect_candidate_modules(module, import_name)
363
377
  registered = sum(
@@ -62,6 +62,18 @@ class FeatureManager:
62
62
  return
63
63
  self._registered = True
64
64
 
65
+ # The nav and asset registries are process-global; clear them before
66
+ # loading so the composed menu / injected assets reflect ONLY this
67
+ # product's installed features (no leakage when several products'
68
+ # create_app() run in one interpreter).
69
+ from splent_framework.assets.asset_registry import clear_assets
70
+ from splent_framework.nav.nav_registry import clear_nav_items
71
+ from splent_framework.settings.settings_schema import clear_settings_schemas
72
+
73
+ clear_nav_items()
74
+ clear_assets()
75
+ clear_settings_schemas()
76
+
65
77
  splent_app = self._require_splent_app()
66
78
  product_dir = os.path.join(PathUtils.get_working_dir(), splent_app)
67
79
 
@@ -121,27 +133,33 @@ class FeatureManager:
121
133
  # ------------------------------------------------------------------
122
134
 
123
135
  def _apply_template_overrides(self, registry) -> None:
124
- """Reorder Jinja blueprint template loaders so refiner templates win.
136
+ """Make refiner templates win over the base feature's same-path templates.
125
137
 
126
- Flask's DispatchingJinjaLoader searches blueprints in registration order.
127
- Since refiners load AFTER their base, their templates would normally lose.
128
- We swap the refiner's blueprint ahead of the base's in the internal list.
138
+ Flask's DispatchingJinjaLoader searches blueprints in REGISTRATION order
139
+ (first registered wins), and a refiner loads AFTER its base — so the base
140
+ template would win. We prepend each refiner's template folder to the
141
+ app's Jinja loader so its override is resolved first.
129
142
  """
143
+ from jinja2 import ChoiceLoader, FileSystemLoader
144
+
130
145
  template_overrides = [
131
146
  e for e in registry.all_entries() if e.category == "template"
132
147
  ]
133
148
  if not template_overrides:
134
149
  return
135
150
 
151
+ app = self._app
152
+ folders: list[str] = []
153
+ seen: set[str] = set()
136
154
  for entry in template_overrides:
137
- # Find the refiner's blueprint (convention: refiner registers its own bp)
138
- # The refiner's templates are served from its own blueprint template folder.
139
- # We need its blueprint to appear BEFORE the base's in the search order.
140
- # Flask 3.x uses app.blueprints (ordered dict), and the
141
- # DispatchingJinjaLoader iterates app.iter_blueprints() which
142
- # returns them in reverse registration order (last registered = first searched).
143
- # So the refiner, being loaded after the base, is already searched first.
144
- # This means template override works out of the box for same-path templates.
155
+ short = entry.refiner.replace("splent_feature_", "")
156
+ bp = app.blueprints.get(short)
157
+ if bp is None or not bp.template_folder:
158
+ continue
159
+ folder = os.path.join(bp.root_path, bp.template_folder)
160
+ if folder not in seen and os.path.isdir(folder):
161
+ seen.add(folder)
162
+ folders.append(folder)
145
163
  logger.info(
146
164
  "Template override: %s (from %s, by %s)",
147
165
  entry.target,
@@ -149,6 +167,11 @@ class FeatureManager:
149
167
  entry.refiner,
150
168
  )
151
169
 
170
+ if folders:
171
+ app.jinja_env.loader = ChoiceLoader(
172
+ [FileSystemLoader(folders), app.jinja_env.loader]
173
+ )
174
+
152
175
  def _setup_refinement_registry(self, product_dir: str, ordered: list[str]) -> None:
153
176
  """Read extensible/refinement declarations from feature pyproject files,
154
177
  validate them, and populate the global RefinementRegistry."""
@@ -1,4 +1,5 @@
1
1
  from flask import Flask
2
+ from splent_framework.assets.asset_registry import get_assets
2
3
  from splent_framework.context.context_manager import build_jinja_context
3
4
  from splent_framework.hooks.template_hooks import get_template_hooks
4
5
 
@@ -17,3 +18,4 @@ class JinjaManager:
17
18
 
18
19
  def _register_globals(self):
19
20
  self.app.jinja_env.globals["get_template_hooks"] = get_template_hooks
21
+ self.app.jinja_env.globals["get_assets"] = get_assets
@@ -72,9 +72,15 @@ class LocaleManager:
72
72
  if translations_dir not in dirs:
73
73
  dirs.append(translations_dir)
74
74
 
75
- # Babel supports multiple translation directories
76
- babel = app.extensions.get("splent_babel")
77
- if babel and hasattr(babel, "translation_directories"):
78
- babel.translation_directories.append(translations_dir)
75
+ # flask-babel computes its translation directories ONCE in init_app
76
+ # (BabelConfiguration.translation_directories) and never re-reads the
77
+ # config. Features load AFTER Babel is initialised, so we (1) keep the
78
+ # config in sync and (2) mutate the live BabelConfiguration list
79
+ # (app.extensions["babel"]) so the new directory actually takes effect.
80
+ app.config["BABEL_TRANSLATION_DIRECTORIES"] = ";".join(dirs)
81
+ babel_cfg = app.extensions.get("babel")
82
+ live = getattr(babel_cfg, "translation_directories", None)
83
+ if isinstance(live, list) and translations_dir not in live:
84
+ live.append(translations_dir)
79
85
 
80
86
  logger.debug("Registered translations: %s", translations_dir)
@@ -0,0 +1,50 @@
1
+ # splent_framework/nav/nav_registry.py
2
+ #
3
+ # SPL navigation registry. Each CONTENT feature declares its public main-nav
4
+ # entry in init_feature() via register_nav_item(); the menu is therefore
5
+ # *composed from the features actually selected in the product's derivation*,
6
+ # not hardcoded. The theme reads get_nav_items() and layers the admin "Menus"
7
+ # editor's runtime overrides (order / visibility / label) and custom links on
8
+ # top. Remove a feature (re-derive) and its entry disappears on its own; add
9
+ # one and it shows up — that is the SPL variability surfacing in the UI.
10
+ #
11
+ # Populated once at app startup during feature registration; read-only during
12
+ # request handling. Registration is idempotent by key, so a second create_app()
13
+ # (tests, reload) does not duplicate entries. Not thread-safe for writes — do
14
+ # not call register_nav_item() from request handlers.
15
+
16
+ _nav_items: dict = {}
17
+
18
+
19
+ def register_nav_item(
20
+ key: str, label: str, href: str, order: int = 100, icon=None
21
+ ) -> None:
22
+ """Declare one entry in the public main navigation (opt-in, per feature).
23
+
24
+ Args:
25
+ key: stable identifier (e.g. "projects"); used to reconcile runtime
26
+ overrides across re-derivations. Unique per feature.
27
+ label: default human label (translated at render time via gettext).
28
+ href: public URL/path the entry points to (e.g. "/projects").
29
+ order: lower sorts first when no runtime override exists.
30
+ icon: optional feather icon name.
31
+ """
32
+ _nav_items[key] = {
33
+ "key": key,
34
+ "label": label,
35
+ "href": href,
36
+ "order": order,
37
+ "icon": icon,
38
+ }
39
+
40
+
41
+ def get_nav_items() -> list:
42
+ """All declared nav entries, sorted by (order, label)."""
43
+ return sorted(
44
+ _nav_items.values(), key=lambda i: (i.get("order", 100), i.get("label", ""))
45
+ )
46
+
47
+
48
+ def clear_nav_items() -> None:
49
+ """Drop all entries. Intended for test teardown."""
50
+ _nav_items.clear()
@@ -0,0 +1,103 @@
1
+ # splent_framework/settings/settings_schema.py
2
+ #
3
+ # SPL feature-settings schema registry — the generic way to make any feature
4
+ # that injects UI configurable from the admin.
5
+ #
6
+ # A feature DECLARES its admin-editable settings (a typed schema) via
7
+ # register_settings() in init_feature(). The `settings` feature renders ONE
8
+ # generic admin panel for every declared schema and persists values through
9
+ # SettingsService. No per-feature panel code, no ad-hoc config modules: declare
10
+ # the schema, get a panel + typed reads.
11
+ #
12
+ # Setting keys are namespaced as "<feature>_<field>" automatically, so features
13
+ # never collide. Cleared per app at feature-load (like the nav/asset registries).
14
+
15
+ _schemas: dict = {}
16
+
17
+ FIELD_TYPES = ("bool", "int", "text", "color", "select")
18
+
19
+
20
+ def register_settings(
21
+ feature: str, title: str, fields: list, icon: str = "sliders"
22
+ ) -> None:
23
+ """Declare a feature's admin-editable settings.
24
+
25
+ Args:
26
+ feature: stable key, e.g. "slider" (also the panel URL segment and the
27
+ setting-key namespace).
28
+ title: human title shown in the panel/sidebar, e.g. "Slider".
29
+ fields: list of dicts ``{key, type, default, label, help?, options?}``.
30
+ ``type`` in bool/int/text/color/select. For select, provide
31
+ ``options`` as a list of ``(value, label)`` pairs.
32
+ icon: feather icon name for the sidebar entry.
33
+ """
34
+ _schemas[feature] = {
35
+ "feature": feature,
36
+ "title": title,
37
+ "icon": icon,
38
+ "fields": list(fields),
39
+ }
40
+
41
+
42
+ def get_schemas() -> list:
43
+ """All declared schemas, sorted by title."""
44
+ return sorted(_schemas.values(), key=lambda s: s["title"].lower())
45
+
46
+
47
+ def get_schema(feature: str):
48
+ return _schemas.get(feature)
49
+
50
+
51
+ def clear_settings_schemas() -> None:
52
+ _schemas.clear()
53
+
54
+
55
+ def setting_key(feature: str, field_key: str) -> str:
56
+ """The namespaced SettingsService key for a feature field."""
57
+ return f"{feature}_{field_key}"
58
+
59
+
60
+ def _cast(field: dict, raw):
61
+ """Coerce a stored raw string to the field's type, falling back to default."""
62
+ t = field.get("type")
63
+ default = field.get("default")
64
+ if t == "bool":
65
+ if raw is None:
66
+ return default in (True, "1", 1)
67
+ return raw in (True, "1", 1)
68
+ if t == "int":
69
+ try:
70
+ return int(raw)
71
+ except (TypeError, ValueError):
72
+ try:
73
+ return int(default)
74
+ except (TypeError, ValueError):
75
+ return 0
76
+ if t == "color":
77
+ import re
78
+
79
+ if raw and re.match(r"^#[0-9a-fA-F]{3,8}$", raw):
80
+ return raw
81
+ return default if default else "#000000"
82
+ # text / select
83
+ return raw if raw not in (None, "") else (default if default is not None else "")
84
+
85
+
86
+ def get_config(feature: str) -> dict:
87
+ """Typed current values for a feature's settings, keyed by the SHORT field
88
+ key (e.g. {"autoplay": True, "interval": 6}). Reads SettingsService and
89
+ falls back to declared defaults."""
90
+ from splent_framework.services.service_locator import service_proxy
91
+
92
+ schema = _schemas.get(feature)
93
+ if not schema:
94
+ return {}
95
+ try:
96
+ svc = service_proxy("SettingsService")
97
+ except Exception:
98
+ svc = None
99
+ out = {}
100
+ for f in schema["fields"]:
101
+ raw = svc.get(setting_key(feature, f["key"]), None) if svc else None
102
+ out[f["key"]] = _cast(f, raw)
103
+ return out
@@ -20,6 +20,11 @@ logger = logging.getLogger(__name__)
20
20
  # Shared blinker namespace for all SPLENT feature signals
21
21
  _splent_ns = Namespace()
22
22
 
23
+ # Handlers that connected before their producer defined the signal, keyed by
24
+ # signal name. Flushed by define_signal(), so feature load order doesn't matter
25
+ # (a captcha provider can connect to "comment-submitting" before comments loads).
26
+ _pending: dict = {}
27
+
23
28
 
24
29
  def define_signal(name: str, provider: str) -> NamedSignal:
25
30
  """Define a feature signal and register it in the signal registry.
@@ -39,6 +44,10 @@ def define_signal(name: str, provider: str) -> NamedSignal:
39
44
  """
40
45
  signal = _splent_ns.signal(name)
41
46
  register_signal(name, signal, provider)
47
+ # Flush handlers that connected before this signal was defined (a listener
48
+ # feature that loaded before its producer). Load order is now irrelevant.
49
+ for fn in _pending.pop(name, []):
50
+ signal.connect(fn)
42
51
  return signal
43
52
 
44
53
 
@@ -62,14 +71,17 @@ def connect_signal(signal_name: str, listener_feature: str):
62
71
  """
63
72
 
64
73
  def decorator(fn):
74
+ register_listener(signal_name, listener_feature)
65
75
  signal = get_signal(signal_name)
66
76
  if signal is not None:
67
77
  signal.connect(fn)
68
- register_listener(signal_name, listener_feature)
69
78
  else:
70
- logger.warning(
71
- "Signal '%s' not found — handler %s.%s not connected. "
72
- "Is the provider feature installed?",
79
+ # Producer not loaded yet (or absent). Defer the connection: if the
80
+ # producer's define_signal() runs later it flushes this handler. If
81
+ # the producer is never installed, the handler simply never fires.
82
+ _pending.setdefault(signal_name, []).append(fn)
83
+ logger.debug(
84
+ "Signal '%s' not defined yet — deferring handler %s.%s.",
73
85
  signal_name,
74
86
  fn.__module__,
75
87
  fn.__name__,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: splent_framework
3
- Version: 1.6.2
3
+ Version: 1.7.0
4
4
  Summary: SPLENT-FRAMEWORK is a set of libraries for agile product development within SPLENT.
5
5
  Author-email: DiversoLab <diversolab@us.es>
6
6
  Project-URL: Homepage, https://github.com/diverso-lab/splent_framework
@@ -9,6 +9,10 @@ src/splent_framework.egg-info/SOURCES.txt
9
9
  src/splent_framework.egg-info/dependency_links.txt
10
10
  src/splent_framework.egg-info/requires.txt
11
11
  src/splent_framework.egg-info/top_level.txt
12
+ src/splent_framework/admin/__init__.py
13
+ src/splent_framework/admin/registry.py
14
+ src/splent_framework/assets/__init__.py
15
+ src/splent_framework/assets/asset_registry.py
12
16
  src/splent_framework/blueprints/__init__.py
13
17
  src/splent_framework/blueprints/base_blueprint.py
14
18
  src/splent_framework/bootstraps/__init__.py
@@ -47,6 +51,8 @@ src/splent_framework/managers/task_queue_manager.py
47
51
  src/splent_framework/migrations/__init__.py
48
52
  src/splent_framework/migrations/feature_env.py
49
53
  src/splent_framework/namespaces/__init__.py
54
+ src/splent_framework/nav/__init__.py
55
+ src/splent_framework/nav/nav_registry.py
50
56
  src/splent_framework/refinement/__init__.py
51
57
  src/splent_framework/refinement/model_extender.py
52
58
  src/splent_framework/refinement/parser.py
@@ -65,6 +71,8 @@ src/splent_framework/serialisers/serializer.py
65
71
  src/splent_framework/services/BaseService.py
66
72
  src/splent_framework/services/__init__.py
67
73
  src/splent_framework/services/service_locator.py
74
+ src/splent_framework/settings/__init__.py
75
+ src/splent_framework/settings/settings_schema.py
68
76
  src/splent_framework/signals/__init__.py
69
77
  src/splent_framework/signals/registry.py
70
78
  src/splent_framework/signals/signal_utils.py
@@ -1,36 +0,0 @@
1
- # splent_framework/template_hooks.py
2
- #
3
- # NOTE: _hooks is a module-level registry populated once at app startup
4
- # (during feature registration) and read-only during request handling.
5
- # It is NOT thread-safe for concurrent writes; do not call
6
- # register_template_hook() from request handlers or background threads.
7
-
8
- _hooks: dict[str, list] = {}
9
-
10
-
11
- def register_template_hook(name: str, func) -> None:
12
- """Append a callback to a named hook slot (additive)."""
13
- _hooks.setdefault(name, []).append(func)
14
-
15
-
16
- def replace_template_hook(name: str, func) -> None:
17
- """Replace ALL existing callbacks for a named hook slot.
18
-
19
- Used by refinement features to override a base feature's hook content.
20
- """
21
- _hooks[name] = [func]
22
-
23
-
24
- def remove_template_hook(name: str, func) -> None:
25
- """Remove a specific callback from a hook slot."""
26
- if name in _hooks:
27
- _hooks[name] = [f for f in _hooks[name] if f is not func]
28
-
29
-
30
- def get_template_hooks(name: str) -> list:
31
- return _hooks.get(name, [])
32
-
33
-
34
- def clear_hooks() -> None:
35
- """Remove all registered hooks. Intended for use in test teardown."""
36
- _hooks.clear()