simple-module-feature-flags 0.0.37__tar.gz → 0.0.39__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.
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/.gitignore +1 -2
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/PKG-INFO +5 -5
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/README.md +1 -1
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/feature_flags/contracts/schemas.py +1 -1
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/feature_flags/endpoints/views.py +3 -1
- simple_module_feature_flags-0.0.39/feature_flags/locales/de.json +56 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/feature_flags/locales/en.json +2 -1
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/feature_flags/module.py +11 -5
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/feature_flags/pages/Browse.tsx +2 -4
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/feature_flags/service.py +27 -6
- simple_module_feature_flags-0.0.39/feature_flags/tenant_cleanup.py +18 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/package.json +1 -1
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/pyproject.toml +4 -4
- simple_module_feature_flags-0.0.39/tests/test_feature_flags_tenant_cleanup.py +34 -0
- simple_module_feature_flags-0.0.39/tests/test_flag_description_i18n.py +38 -0
- simple_module_feature_flags-0.0.39/tests-js/feature_flags_Browse.test.tsx +195 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/LICENSE +0 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/feature_flags/__init__.py +0 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/feature_flags/constants.py +0 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/feature_flags/contracts/__init__.py +0 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/feature_flags/deps.py +0 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/feature_flags/endpoints/__init__.py +0 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/feature_flags/endpoints/api.py +0 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/feature_flags/models.py +0 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/feature_flags/pages/components/FlagTable.tsx +0 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/feature_flags/pages/components/ScopeCard.tsx +0 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/feature_flags/pages/components/TenantPicker.tsx +0 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/feature_flags/pages/components/ToggleConfirmDialog.tsx +0 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/feature_flags/pages/types.ts +0 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/feature_flags/py.typed +0 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/tests/conftest.py +0 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/tests/test_feature_flags_api.py +0 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/tests/test_feature_flags_audit_links.py +0 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/tests/test_feature_flags_menu_section.py +0 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/tests/test_feature_flags_service.py +0 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/tests/test_feature_flags_tenancy.py +0 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/tests/test_store_outage_fallback.py +0 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/tsconfig.json +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: simple_module_feature_flags
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.39
|
|
4
4
|
Summary: Simple feature-flag module with per-tenant overrides and a consumer API for simple_module
|
|
5
5
|
Project-URL: Homepage, https://github.com/antosubash/simple_module_python
|
|
6
6
|
Project-URL: Repository, https://github.com/antosubash/simple_module_python
|
|
@@ -21,9 +21,9 @@ Classifier: Topic :: Internet :: WWW/HTTP
|
|
|
21
21
|
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
22
22
|
Classifier: Typing :: Typed
|
|
23
23
|
Requires-Python: >=3.12
|
|
24
|
-
Requires-Dist: simple-module-core==0.0.
|
|
25
|
-
Requires-Dist: simple-module-db==0.0.
|
|
26
|
-
Requires-Dist: simple-module-hosting==0.0.
|
|
24
|
+
Requires-Dist: simple-module-core==0.0.39
|
|
25
|
+
Requires-Dist: simple-module-db==0.0.39
|
|
26
|
+
Requires-Dist: simple-module-hosting==0.0.39
|
|
27
27
|
Description-Content-Type: text/markdown
|
|
28
28
|
|
|
29
29
|
# simple_module_feature_flags
|
|
@@ -61,7 +61,7 @@ Gate a route using the registry dependency:
|
|
|
61
61
|
```python
|
|
62
62
|
from fastapi import APIRouter, HTTPException
|
|
63
63
|
|
|
64
|
-
from feature_flags.deps import FeatureFlagRegistryDep
|
|
64
|
+
from feature_flags.deps import FeatureFlagRegistryDep
|
|
65
65
|
|
|
66
66
|
router = APIRouter()
|
|
67
67
|
|
|
@@ -33,7 +33,7 @@ Gate a route using the registry dependency:
|
|
|
33
33
|
```python
|
|
34
34
|
from fastapi import APIRouter, HTTPException
|
|
35
35
|
|
|
36
|
-
from feature_flags.deps import FeatureFlagRegistryDep
|
|
36
|
+
from feature_flags.deps import FeatureFlagRegistryDep
|
|
37
37
|
|
|
38
38
|
router = APIRouter()
|
|
39
39
|
|
|
@@ -11,7 +11,7 @@ from sqlmodel import Field, SQLModel
|
|
|
11
11
|
class FeatureFlagOverrideOut(SQLModel):
|
|
12
12
|
"""Stored override row as returned by the service/API."""
|
|
13
13
|
|
|
14
|
-
model_config = ConfigDict(from_attributes=True)
|
|
14
|
+
model_config = ConfigDict(from_attributes=True) # ty: ignore[invalid-assignment]
|
|
15
15
|
|
|
16
16
|
id: int
|
|
17
17
|
scope: str
|
|
@@ -9,6 +9,7 @@ frontend doesn't need separate routes per scope.
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
from fastapi import APIRouter, Depends, Request
|
|
12
|
+
from simple_module_hosting.i18n_deps import TranslatorDep
|
|
12
13
|
from simple_module_hosting.inertia_deps import InertiaDep
|
|
13
14
|
from simple_module_hosting.permissions import RequiresPermission
|
|
14
15
|
from simple_module_inertia import InertiaResponse
|
|
@@ -75,9 +76,10 @@ async def browse(
|
|
|
75
76
|
inertia: InertiaDep,
|
|
76
77
|
service: FeatureFlagServiceDep,
|
|
77
78
|
registry: FeatureFlagRegistryDep,
|
|
79
|
+
translator: TranslatorDep,
|
|
78
80
|
tenant_id: str | None = None,
|
|
79
81
|
) -> InertiaResponse:
|
|
80
|
-
flags = await service.list_flags(registry, tenant_id=tenant_id)
|
|
82
|
+
flags = await service.list_flags(registry, tenant_id=tenant_id, translate=translator.t)
|
|
81
83
|
tenants = await service.list_tenants_with_overrides()
|
|
82
84
|
return await inertia.render(
|
|
83
85
|
PAGE_BROWSE,
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"browse": {
|
|
3
|
+
"title": "Funktionsschalter",
|
|
4
|
+
"description": "Laufzeitschalter. Eine Organisationsüberschreibung hat Vorrang vor dem Systemwert.",
|
|
5
|
+
"empty_title": "Keine Funktionsschalter registriert",
|
|
6
|
+
"empty_description": "Module deklarieren Schalter über register_feature_flags(). Installieren Sie eines, um sie hier zu sehen.",
|
|
7
|
+
"scope_label": "Geltungsbereich",
|
|
8
|
+
"viewing_system": "Systemweite Schalter — gelten für jede Organisation, sofern sie nicht überschrieben werden.",
|
|
9
|
+
"viewing_tenant": "Überschreibungen für Organisation {tenant_id} — nicht gesetzte Schalter verwenden den Systemwert.",
|
|
10
|
+
"view_history": "Änderungsverlauf anzeigen →",
|
|
11
|
+
"audit_note": "Jede Umschaltung wird mit Akteur und vorherigem Wert im Änderungsprotokoll erfasst.",
|
|
12
|
+
"tenant_id_label": "Organisations-ID",
|
|
13
|
+
"tenant_id_placeholder": "z. B. acme",
|
|
14
|
+
"scope_system": "System",
|
|
15
|
+
"scope_custom": "Andere…",
|
|
16
|
+
"go": "Anzeigen",
|
|
17
|
+
"cancel": "Abbrechen"
|
|
18
|
+
},
|
|
19
|
+
"table": {
|
|
20
|
+
"name": "Name",
|
|
21
|
+
"description": "Beschreibung",
|
|
22
|
+
"system": "System",
|
|
23
|
+
"effective": "Wirksam",
|
|
24
|
+
"actions": "Aktionen",
|
|
25
|
+
"on": "ein",
|
|
26
|
+
"off": "aus",
|
|
27
|
+
"enabled": "Aktiviert",
|
|
28
|
+
"disabled": "Deaktiviert",
|
|
29
|
+
"overridden": "Überschreibung",
|
|
30
|
+
"following_default": "Standardwert gilt",
|
|
31
|
+
"following_system": "Systemwert gilt",
|
|
32
|
+
"clear_override": "Überschreibung entfernen"
|
|
33
|
+
},
|
|
34
|
+
"confirm": {
|
|
35
|
+
"enable_title": "„{name}“ aktivieren?",
|
|
36
|
+
"disable_title": "„{name}“ deaktivieren?",
|
|
37
|
+
"scope_system": "Dies ändert den Wert sofort für jede Organisation.",
|
|
38
|
+
"scope_tenant": "Dies setzt eine Überschreibung nur für die Organisation „{tenant_id}“. Andere Organisationen verwenden weiterhin den Systemwert.",
|
|
39
|
+
"enable_action": "Aktivieren",
|
|
40
|
+
"disable_action": "Deaktivieren",
|
|
41
|
+
"cancel": "Abbrechen"
|
|
42
|
+
},
|
|
43
|
+
"toasts": {
|
|
44
|
+
"enabled": "„{name}“ aktiviert",
|
|
45
|
+
"disabled": "„{name}“ deaktiviert",
|
|
46
|
+
"cleared": "Überschreibung für „{name}“ entfernt",
|
|
47
|
+
"toggle_failed": "Funktionsschalter konnte nicht aktualisiert werden"
|
|
48
|
+
},
|
|
49
|
+
"audit": {
|
|
50
|
+
"override": "Funktionsschalterüberschreibung"
|
|
51
|
+
},
|
|
52
|
+
"nav": {
|
|
53
|
+
"feature_flags": "Funktionsschalter"
|
|
54
|
+
},
|
|
55
|
+
"permission_group": "Feature-Flags"
|
|
56
|
+
}
|
{simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/feature_flags/module.py
RENAMED
|
@@ -29,6 +29,7 @@ from feature_flags.constants import (
|
|
|
29
29
|
)
|
|
30
30
|
|
|
31
31
|
if TYPE_CHECKING:
|
|
32
|
+
from simple_module_core.events import EventBus
|
|
32
33
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
33
34
|
|
|
34
35
|
_logger = logging.getLogger(__name__)
|
|
@@ -67,6 +68,13 @@ class FeatureFlagsModule(ModuleBase):
|
|
|
67
68
|
i18n_audience="admin",
|
|
68
69
|
)
|
|
69
70
|
|
|
71
|
+
def register_event_handlers(self, bus: EventBus, app: FastAPI | None = None) -> None:
|
|
72
|
+
from simple_module_core.tenant_events import TenantDeleted
|
|
73
|
+
|
|
74
|
+
from feature_flags.tenant_cleanup import purge_tenant_data
|
|
75
|
+
|
|
76
|
+
bus.subscribe_durable(TenantDeleted, purge_tenant_data)
|
|
77
|
+
|
|
70
78
|
def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None:
|
|
71
79
|
from feature_flags.endpoints.api import router as api
|
|
72
80
|
from feature_flags.endpoints.views import router as views
|
|
@@ -103,14 +111,11 @@ class FeatureFlagsModule(ModuleBase):
|
|
|
103
111
|
from feature_flags.models import FeatureFlagOverride
|
|
104
112
|
|
|
105
113
|
registry.register(
|
|
106
|
-
AuditLink(
|
|
107
|
-
|
|
108
|
-
# The table name travels alongside as the audit log's type tag.
|
|
109
|
-
entity_type=FeatureFlagOverride.__name__,
|
|
114
|
+
AuditLink.for_model(
|
|
115
|
+
FeatureFlagOverride,
|
|
110
116
|
url_template=f"{MENU_URL}?{QP_OVERRIDE}={{id}}",
|
|
111
117
|
label=AUDIT_LINK_LABEL,
|
|
112
118
|
label_key=AUDIT_LINK_LABEL_KEY,
|
|
113
|
-
table_name=FeatureFlagOverride.__tablename__,
|
|
114
119
|
label_resolver=_resolve_override_labels,
|
|
115
120
|
)
|
|
116
121
|
)
|
|
@@ -122,6 +127,7 @@ class FeatureFlagsModule(ModuleBase):
|
|
|
122
127
|
PERM_FEATURE_FLAGS_VIEW,
|
|
123
128
|
PERM_FEATURE_FLAGS_MANAGE,
|
|
124
129
|
],
|
|
130
|
+
label_key="feature_flags.permission_group",
|
|
125
131
|
)
|
|
126
132
|
|
|
127
133
|
def locale_dirs(self) -> dict[str, Path]:
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Head, router
|
|
1
|
+
import { Head, router } from '@inertiajs/react';
|
|
2
2
|
import { keys, useT } from '@simple-module-py/i18n';
|
|
3
3
|
import { PageShell } from '@simple-module-py/ui/components/PageShell';
|
|
4
4
|
import { usePermissions } from '@simple-module-py/ui/hooks/use-permissions';
|
|
@@ -32,9 +32,7 @@ function actionUrl(name: string, action: 'toggle' | 'clear', tenantId: string |
|
|
|
32
32
|
return tenantId ? `${base}?tenant_id=${encodeURIComponent(tenantId)}` : base;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
function Browse() {
|
|
36
|
-
const { flags, tenant_id, tenants, audit_log_url } = usePage<{ props: Props }>()
|
|
37
|
-
.props as unknown as Props;
|
|
35
|
+
function Browse({ flags, tenant_id, tenants, audit_log_url }: Props) {
|
|
38
36
|
const { t } = useT();
|
|
39
37
|
const { can } = usePermissions();
|
|
40
38
|
const canManage = can('feature_flags.manage');
|
{simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/feature_flags/service.py
RENAMED
|
@@ -7,9 +7,12 @@ mirrors mutations into the registry.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
|
|
10
12
|
from simple_module_core.feature_flags import FeatureFlagDefinition, FeatureFlagRegistry
|
|
11
13
|
from sqlalchemy import select
|
|
12
14
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
15
|
+
from sqlmodel import col
|
|
13
16
|
|
|
14
17
|
from feature_flags.constants import SCOPE_SYSTEM, SCOPE_TENANT, SYSTEM_SCOPE_ID
|
|
15
18
|
from feature_flags.contracts.schemas import FeatureFlagOverrideOut, FeatureFlagView
|
|
@@ -21,16 +24,25 @@ def _registry_tenant_id(scope: str, scope_id: str) -> str | None:
|
|
|
21
24
|
return scope_id if scope == SCOPE_TENANT else None
|
|
22
25
|
|
|
23
26
|
|
|
27
|
+
def _description(flag: FeatureFlagDefinition, translate: Callable[[str], str] | None) -> str:
|
|
28
|
+
# Translator.t() echoes an unknown key back; keep the literal in that case.
|
|
29
|
+
if not flag.description_key or translate is None:
|
|
30
|
+
return flag.description
|
|
31
|
+
translated = translate(flag.description_key)
|
|
32
|
+
return flag.description if translated == flag.description_key else translated
|
|
33
|
+
|
|
34
|
+
|
|
24
35
|
def _build_view(
|
|
25
36
|
flag: FeatureFlagDefinition,
|
|
26
37
|
*,
|
|
27
38
|
enabled: bool,
|
|
28
39
|
overridden: bool,
|
|
29
40
|
system_enabled: bool | None = None,
|
|
41
|
+
translate: Callable[[str], str] | None = None,
|
|
30
42
|
) -> FeatureFlagView:
|
|
31
43
|
return FeatureFlagView(
|
|
32
44
|
name=flag.name,
|
|
33
|
-
description=flag
|
|
45
|
+
description=_description(flag, translate),
|
|
34
46
|
default_enabled=flag.default_enabled,
|
|
35
47
|
enabled=enabled,
|
|
36
48
|
overridden=overridden,
|
|
@@ -73,7 +85,10 @@ class FeatureFlagService:
|
|
|
73
85
|
return list(result.scalars())
|
|
74
86
|
|
|
75
87
|
async def list_flags(
|
|
76
|
-
self,
|
|
88
|
+
self,
|
|
89
|
+
registry: FeatureFlagRegistry,
|
|
90
|
+
tenant_id: str | None = None,
|
|
91
|
+
translate: Callable[[str], str] | None = None,
|
|
77
92
|
) -> list[FeatureFlagView]:
|
|
78
93
|
"""Join registered definitions with persisted overrides for admin display.
|
|
79
94
|
|
|
@@ -95,7 +110,12 @@ class FeatureFlagService:
|
|
|
95
110
|
system_enabled = system_value if system_value is not None else flag.default_enabled
|
|
96
111
|
if tenant_id is None:
|
|
97
112
|
views.append(
|
|
98
|
-
_build_view(
|
|
113
|
+
_build_view(
|
|
114
|
+
flag,
|
|
115
|
+
enabled=system_enabled,
|
|
116
|
+
overridden=system_value is not None,
|
|
117
|
+
translate=translate,
|
|
118
|
+
)
|
|
99
119
|
)
|
|
100
120
|
continue
|
|
101
121
|
tenant_value = registry.tenant_override(flag.name, tenant_id)
|
|
@@ -105,6 +125,7 @@ class FeatureFlagService:
|
|
|
105
125
|
enabled=tenant_value if tenant_value is not None else system_enabled,
|
|
106
126
|
overridden=tenant_value is not None,
|
|
107
127
|
system_enabled=system_enabled,
|
|
128
|
+
translate=translate,
|
|
108
129
|
)
|
|
109
130
|
)
|
|
110
131
|
return views
|
|
@@ -131,9 +152,9 @@ class FeatureFlagService:
|
|
|
131
152
|
async def _find(self, scope: str, scope_id: str, name: str) -> FeatureFlagOverride | None:
|
|
132
153
|
result = await self.db.execute(
|
|
133
154
|
select(FeatureFlagOverride).where(
|
|
134
|
-
FeatureFlagOverride.scope == scope,
|
|
135
|
-
FeatureFlagOverride.scope_id == scope_id,
|
|
136
|
-
FeatureFlagOverride.name == name,
|
|
155
|
+
col(FeatureFlagOverride.scope) == scope,
|
|
156
|
+
col(FeatureFlagOverride.scope_id) == scope_id,
|
|
157
|
+
col(FeatureFlagOverride.name) == name,
|
|
137
158
|
)
|
|
138
159
|
)
|
|
139
160
|
return result.scalar_one_or_none()
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Durable cleanup of tenant-scoped feature flag overrides."""
|
|
2
|
+
|
|
3
|
+
from simple_module_core.durable_events import DurableContext
|
|
4
|
+
from simple_module_core.tenant_events import TenantDeleted
|
|
5
|
+
from sqlmodel import col, delete
|
|
6
|
+
|
|
7
|
+
from feature_flags.constants import SCOPE_TENANT
|
|
8
|
+
from feature_flags.models import FeatureFlagOverride
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def purge_tenant_data(event: TenantDeleted, ctx: DurableContext) -> None:
|
|
12
|
+
"""Keep system/other tenants' overrides; acknowledge with the worker transaction."""
|
|
13
|
+
await ctx.db.execute(
|
|
14
|
+
delete(FeatureFlagOverride).where(
|
|
15
|
+
col(FeatureFlagOverride.scope) == SCOPE_TENANT,
|
|
16
|
+
col(FeatureFlagOverride.scope_id) == event.tenant_id,
|
|
17
|
+
)
|
|
18
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "simple_module_feature_flags"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.39"
|
|
4
4
|
description = "Simple feature-flag module with per-tenant overrides and a consumer API for simple_module"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -21,9 +21,9 @@ classifiers = [
|
|
|
21
21
|
"Typing :: Typed",
|
|
22
22
|
]
|
|
23
23
|
dependencies = [
|
|
24
|
-
"simple_module_core==0.0.
|
|
25
|
-
"simple_module_db==0.0.
|
|
26
|
-
"simple_module_hosting==0.0.
|
|
24
|
+
"simple_module_core==0.0.39",
|
|
25
|
+
"simple_module_db==0.0.39",
|
|
26
|
+
"simple_module_hosting==0.0.39",
|
|
27
27
|
]
|
|
28
28
|
|
|
29
29
|
[project.entry-points.simple_module]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Tenant deletion purges tenant overrides without touching other scopes."""
|
|
2
|
+
|
|
3
|
+
from feature_flags.constants import SCOPE_SYSTEM, SCOPE_TENANT
|
|
4
|
+
from feature_flags.models import FeatureFlagOverride
|
|
5
|
+
from feature_flags.module import FeatureFlagsModule
|
|
6
|
+
from simple_module_core.durable_events import DurableContext, event_key
|
|
7
|
+
from simple_module_core.events import EventBus
|
|
8
|
+
from simple_module_core.tenant_events import TenantDeleted
|
|
9
|
+
from sqlmodel import select
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def test_durable_tenant_cleanup_is_scoped_idempotent_and_transactional(db_session):
|
|
13
|
+
bus = EventBus()
|
|
14
|
+
FeatureFlagsModule().register_event_handlers(bus)
|
|
15
|
+
names = bus.durable.handler_names(TenantDeleted)
|
|
16
|
+
assert len(names) == 1
|
|
17
|
+
subscription = bus.durable.lookup(event_key(TenantDeleted), names[0])
|
|
18
|
+
assert subscription is not None
|
|
19
|
+
scopes = [(SCOPE_TENANT, "deleted"), (SCOPE_TENANT, "other"), (SCOPE_SYSTEM, "deleted")]
|
|
20
|
+
for scope, scope_id in scopes:
|
|
21
|
+
db_session.add(FeatureFlagOverride(scope=scope, scope_id=scope_id, name="test.flag"))
|
|
22
|
+
await db_session.commit()
|
|
23
|
+
event = TenantDeleted(tenant_id="deleted", slug="deleted", name="Deleted")
|
|
24
|
+
ctx = DurableContext(db=db_session, attempt=1)
|
|
25
|
+
await subscription.handler(event, ctx)
|
|
26
|
+
await db_session.rollback()
|
|
27
|
+
assert len((await db_session.execute(select(FeatureFlagOverride))).scalars().all()) == 3
|
|
28
|
+
await subscription.handler(event, ctx)
|
|
29
|
+
await subscription.handler(event, DurableContext(db=db_session, attempt=2))
|
|
30
|
+
rows = (await db_session.execute(select(FeatureFlagOverride))).scalars().all()
|
|
31
|
+
assert {(row.scope, row.scope_id) for row in rows} == {
|
|
32
|
+
(SCOPE_TENANT, "other"),
|
|
33
|
+
(SCOPE_SYSTEM, "deleted"),
|
|
34
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Feature flag descriptions are translated server-side (TEST-016)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from feature_flags.service import FeatureFlagService
|
|
6
|
+
from simple_module_core.feature_flags import FeatureFlagDefinition, FeatureFlagRegistry
|
|
7
|
+
from simple_module_core.i18n import Translator
|
|
8
|
+
|
|
9
|
+
PUBLIC_UPLOADS = "file_storage.public_uploads"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _translator(app, locale: str) -> Translator:
|
|
13
|
+
registry = app.state.sm.i18n_registry
|
|
14
|
+
if locale not in registry.supported_locales: # test hosts default to ["en"]
|
|
15
|
+
registry.supported_locales = [*registry.supported_locales, locale]
|
|
16
|
+
registry.load()
|
|
17
|
+
return Translator(registry, locale=locale, default_locale="en")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def _description(app, db_session, locale: str) -> str:
|
|
21
|
+
svc = FeatureFlagService(db_session)
|
|
22
|
+
views = await svc.list_flags(app.state.sm.feature_flags, translate=_translator(app, locale).t)
|
|
23
|
+
return next(v.description for v in views if v.name == PUBLIC_UPLOADS)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def test_description_follows_the_locale(app, db_session):
|
|
27
|
+
english = await _description(app, db_session, "en")
|
|
28
|
+
german = await _description(app, db_session, "de")
|
|
29
|
+
assert english == "Allow unauthenticated uploads (future)."
|
|
30
|
+
assert german != english
|
|
31
|
+
assert "Uploads" in german
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def test_unresolved_key_keeps_the_literal(db_session):
|
|
35
|
+
reg = FeatureFlagRegistry()
|
|
36
|
+
reg.add(FeatureFlagDefinition(name="x", description="Literal", description_key="nope.key"))
|
|
37
|
+
views = await FeatureFlagService(db_session).list_flags(reg, translate=lambda k: k)
|
|
38
|
+
assert views[0].description == "Literal"
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { configureI18n, keys } from '@simple-module-py/i18n';
|
|
2
|
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
3
|
+
import type { ComponentProps } from 'react';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
configureI18n({ locale: 'en', messages: {} });
|
|
7
|
+
|
|
8
|
+
const state = vi.hoisted(() => ({ props: {} as Record<string, unknown> }));
|
|
9
|
+
const post = vi.hoisted(() => vi.fn());
|
|
10
|
+
const visit = vi.hoisted(() => vi.fn());
|
|
11
|
+
|
|
12
|
+
vi.mock('@inertiajs/react', () => ({
|
|
13
|
+
Head: () => null,
|
|
14
|
+
Link: ({ children, href }: { children: React.ReactNode; href: string }) => (
|
|
15
|
+
<a href={href}>{children}</a>
|
|
16
|
+
),
|
|
17
|
+
router: { post, visit },
|
|
18
|
+
usePage: () => ({ props: state.props }),
|
|
19
|
+
}));
|
|
20
|
+
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
|
21
|
+
|
|
22
|
+
import { toast } from 'sonner';
|
|
23
|
+
import Browse from '../feature_flags/pages/Browse';
|
|
24
|
+
import type { FeatureFlag } from '../feature_flags/pages/types';
|
|
25
|
+
|
|
26
|
+
const FLAGS: FeatureFlag[] = [
|
|
27
|
+
{
|
|
28
|
+
name: 'beta_search',
|
|
29
|
+
description: 'New search',
|
|
30
|
+
default_enabled: false,
|
|
31
|
+
enabled: false,
|
|
32
|
+
overridden: false,
|
|
33
|
+
system_enabled: null,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: 'dark_mode',
|
|
37
|
+
description: 'Dark theme',
|
|
38
|
+
default_enabled: false,
|
|
39
|
+
enabled: true,
|
|
40
|
+
overridden: true,
|
|
41
|
+
system_enabled: null,
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
function setProps(over: Record<string, unknown> = {}, permissions = ['feature_flags.manage']) {
|
|
46
|
+
state.props = {
|
|
47
|
+
flags: FLAGS,
|
|
48
|
+
tenant_id: null,
|
|
49
|
+
tenants: ['acme'],
|
|
50
|
+
audit_log_url: null,
|
|
51
|
+
auth: { permissions },
|
|
52
|
+
...over,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type PostOptions = { onSuccess: () => void; onError: () => void; preserveScroll: boolean };
|
|
57
|
+
|
|
58
|
+
const confirm = keys.feature_flags.confirm;
|
|
59
|
+
const table = keys.feature_flags.table;
|
|
60
|
+
const browse = keys.feature_flags.browse;
|
|
61
|
+
|
|
62
|
+
describe('feature_flags Browse', () => {
|
|
63
|
+
beforeEach(() => setProps());
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
vi.clearAllMocks();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('renders every flag with its description and override badge', () => {
|
|
69
|
+
render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
|
|
70
|
+
expect(screen.getByText('beta_search')).toBeInTheDocument();
|
|
71
|
+
expect(screen.getByText('dark_mode')).toBeInTheDocument();
|
|
72
|
+
expect(screen.getByText('Dark theme')).toBeInTheDocument();
|
|
73
|
+
expect(screen.getAllByText(table.overridden)).toHaveLength(1);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('shows the empty state when no module registers flags', () => {
|
|
77
|
+
setProps({ flags: [] });
|
|
78
|
+
render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
|
|
79
|
+
expect(screen.getByText(browse.empty_title)).toBeInTheDocument();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('links to the audit history only when an audit log is installed', () => {
|
|
83
|
+
const { unmount } = render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
|
|
84
|
+
expect(screen.queryByText(browse.view_history)).toBeNull();
|
|
85
|
+
unmount();
|
|
86
|
+
setProps({ audit_log_url: '/admin/audit?x=1' });
|
|
87
|
+
render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
|
|
88
|
+
expect(screen.getByText(browse.view_history)).toHaveAttribute('href', '/admin/audit?x=1');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('makes the switches read-only without the manage permission', () => {
|
|
92
|
+
setProps({}, []);
|
|
93
|
+
render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
|
|
94
|
+
expect(screen.getByRole('switch', { name: 'beta_search' })).toBeDisabled();
|
|
95
|
+
expect(screen.queryByText(table.clear_override)).toBeNull();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('stages a toggle behind a confirm and writes nothing until confirmed', async () => {
|
|
99
|
+
render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
|
|
100
|
+
fireEvent.click(screen.getByRole('switch', { name: 'beta_search' }));
|
|
101
|
+
expect(await screen.findByText(confirm.scope_system)).toBeInTheDocument();
|
|
102
|
+
expect(post).not.toHaveBeenCalled();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('cancelling the confirm leaves the flag and the server untouched', async () => {
|
|
106
|
+
render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
|
|
107
|
+
fireEvent.click(screen.getByRole('switch', { name: 'beta_search' }));
|
|
108
|
+
fireEvent.click(await screen.findByRole('button', { name: confirm.cancel }));
|
|
109
|
+
await waitFor(() => expect(screen.queryByText(confirm.scope_system)).toBeNull());
|
|
110
|
+
expect(post).not.toHaveBeenCalled();
|
|
111
|
+
expect(screen.getByRole('switch', { name: 'beta_search' })).not.toBeChecked();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('enables a flag on confirm, posting enabled=true to the system scope', async () => {
|
|
115
|
+
render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
|
|
116
|
+
fireEvent.click(screen.getByRole('switch', { name: 'beta_search' }));
|
|
117
|
+
fireEvent.click(await screen.findByRole('button', { name: confirm.enable_action }));
|
|
118
|
+
|
|
119
|
+
expect(post).toHaveBeenCalledTimes(1);
|
|
120
|
+
const [url, data, options] = post.mock.calls[0] as [string, unknown, PostOptions];
|
|
121
|
+
expect(url).toBe('/admin/feature-flags/beta_search/toggle');
|
|
122
|
+
expect(data).toEqual({ enabled: true });
|
|
123
|
+
expect(options.preserveScroll).toBe(true);
|
|
124
|
+
options.onSuccess();
|
|
125
|
+
expect(toast.success).toHaveBeenCalledWith(keys.feature_flags.toasts.enabled);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('disables a flag on confirm and toasts a failure from the server', async () => {
|
|
129
|
+
render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
|
|
130
|
+
fireEvent.click(screen.getByRole('switch', { name: 'dark_mode' }));
|
|
131
|
+
fireEvent.click(await screen.findByRole('button', { name: confirm.disable_action }));
|
|
132
|
+
|
|
133
|
+
const [, data, options] = post.mock.calls[0] as [string, unknown, PostOptions];
|
|
134
|
+
expect(data).toEqual({ enabled: false });
|
|
135
|
+
options.onSuccess();
|
|
136
|
+
expect(toast.success).toHaveBeenCalledWith(keys.feature_flags.toasts.disabled);
|
|
137
|
+
options.onError();
|
|
138
|
+
expect(toast.error).toHaveBeenCalledWith(keys.feature_flags.toasts.toggle_failed);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('scopes the toggle URL and the confirm text to the viewed tenant', async () => {
|
|
142
|
+
setProps({ tenant_id: 'acme corp' });
|
|
143
|
+
render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
|
|
144
|
+
fireEvent.click(screen.getByRole('switch', { name: 'beta_search' }));
|
|
145
|
+
expect(await screen.findByText(confirm.scope_tenant)).toBeInTheDocument();
|
|
146
|
+
fireEvent.click(screen.getByRole('button', { name: confirm.enable_action }));
|
|
147
|
+
expect(post.mock.calls[0][0]).toBe(
|
|
148
|
+
'/admin/feature-flags/beta_search/toggle?tenant_id=acme%20corp',
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('clears an override without a confirm and reports the result', () => {
|
|
153
|
+
render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
|
|
154
|
+
fireEvent.click(screen.getAllByText(table.clear_override)[0]);
|
|
155
|
+
const [url, data, options] = post.mock.calls[0] as [string, unknown, PostOptions];
|
|
156
|
+
expect(url).toBe('/admin/feature-flags/dark_mode/clear');
|
|
157
|
+
expect(data).toEqual({});
|
|
158
|
+
options.onSuccess();
|
|
159
|
+
expect(toast.success).toHaveBeenCalledWith(keys.feature_flags.toasts.cleared);
|
|
160
|
+
options.onError();
|
|
161
|
+
expect(toast.error).toHaveBeenCalledWith(keys.feature_flags.toasts.toggle_failed);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('explains which value a non-overridden flag follows', () => {
|
|
165
|
+
render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
|
|
166
|
+
expect(screen.getAllByText(table.following_default).length).toBeGreaterThan(0);
|
|
167
|
+
cleanup();
|
|
168
|
+
setProps({ tenant_id: 'acme' });
|
|
169
|
+
render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
|
|
170
|
+
expect(screen.getAllByText(table.following_system).length).toBeGreaterThan(0);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('switches scope by visiting the tenant URL, or the system URL', () => {
|
|
174
|
+
render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
|
|
175
|
+
fireEvent.click(screen.getByRole('radio', { name: 'acme' }));
|
|
176
|
+
expect(visit).toHaveBeenCalledWith('/admin/feature-flags/?tenant_id=acme');
|
|
177
|
+
cleanup();
|
|
178
|
+
setProps({ tenant_id: 'acme' });
|
|
179
|
+
render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
|
|
180
|
+
fireEvent.click(screen.getByRole('radio', { name: browse.scope_system }));
|
|
181
|
+
expect(visit).toHaveBeenLastCalledWith('/admin/feature-flags/');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('lets an operator name a tenant with no overrides yet', () => {
|
|
185
|
+
render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
|
|
186
|
+
fireEvent.click(screen.getByRole('radio', { name: browse.scope_custom }));
|
|
187
|
+
const go = screen.getByRole('button', { name: browse.go });
|
|
188
|
+
expect(go).toBeDisabled();
|
|
189
|
+
fireEvent.change(screen.getByLabelText(browse.tenant_id_label), {
|
|
190
|
+
target: { value: ' new-co ' },
|
|
191
|
+
});
|
|
192
|
+
fireEvent.click(go);
|
|
193
|
+
expect(visit).toHaveBeenCalledWith('/admin/feature-flags/?tenant_id=new-co');
|
|
194
|
+
});
|
|
195
|
+
});
|
|
File without changes
|
{simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/feature_flags/__init__.py
RENAMED
|
File without changes
|
{simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/feature_flags/constants.py
RENAMED
|
File without changes
|
|
File without changes
|
{simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/feature_flags/deps.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/feature_flags/models.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/feature_flags/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|