phzyx-forge 0.1.0__py3-none-any.whl
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.
- phzyx/admin/__init__.py +153 -0
- phzyx/admin/actions.py +269 -0
- phzyx/admin/async_service.py +263 -0
- phzyx/admin/audit.py +50 -0
- phzyx/admin/auth.py +69 -0
- phzyx/admin/branding.py +30 -0
- phzyx/admin/errors.py +50 -0
- phzyx/admin/export_copy.py +86 -0
- phzyx/admin/fastapi/__init__.py +7 -0
- phzyx/admin/fastapi/board.py +190 -0
- phzyx/admin/fastapi/routes.py +731 -0
- phzyx/admin/filters.py +153 -0
- phzyx/admin/i18n.py +61 -0
- phzyx/admin/identity.py +63 -0
- phzyx/admin/layouts.py +45 -0
- phzyx/admin/litestar/__init__.py +127 -0
- phzyx/admin/media.py +113 -0
- phzyx/admin/openapi_export.py +265 -0
- phzyx/admin/opener.py +35 -0
- phzyx/admin/relation_manager.py +63 -0
- phzyx/admin/resource.py +511 -0
- phzyx/admin/schema_status.py +90 -0
- phzyx/admin/search.py +77 -0
- phzyx/admin/security.py +66 -0
- phzyx/admin/service.py +908 -0
- phzyx/admin/settings.py +71 -0
- phzyx/admin/storage.py +158 -0
- phzyx/admin/storage_cloud.py +353 -0
- phzyx/admin/stream_export.py +145 -0
- phzyx/admin/themes.py +50 -0
- phzyx/admin/unique_checks.py +159 -0
- phzyx/admin/web/dist/apple-touch-icon.png +0 -0
- phzyx/admin/web/dist/assets/index-CRk1fSOo.js +76 -0
- phzyx/admin/web/dist/assets/index-ZF5lh_j6.css +1 -0
- phzyx/admin/web/dist/favicon-64.png +0 -0
- phzyx/admin/web/dist/favicon.png +0 -0
- phzyx/admin/web/dist/index.html +20 -0
- phzyx/admin/web/dist/phzyx-mark.svg +61 -0
- phzyx/admin/widgets.py +62 -0
- phzyx/cli/__init__.py +542 -0
- phzyx/cli/add_admin.py +43 -0
- phzyx/cli/add_auth.py +156 -0
- phzyx/cli/doctor.py +287 -0
- phzyx/cli/forge_init.py +58 -0
- phzyx/cli/new.py +108 -0
- phzyx/cli/presets/__init__.py +21 -0
- phzyx/cli/presets/full.json +16 -0
- phzyx/cli/presets/max.json +16 -0
- phzyx/cli/presets/minimal.json +14 -0
- phzyx/cli/presets/normal.json +15 -0
- phzyx/cli/scaffold/__init__.py +89 -0
- phzyx/cli/scaffold/detect.py +134 -0
- phzyx/cli/scaffold/plan.py +217 -0
- phzyx/cli/scaffold/presets.py +87 -0
- phzyx/cli/scaffold/prompts.py +37 -0
- phzyx/cli/scaffold/templates.py +791 -0
- phzyx/cli/scaffold/types.py +77 -0
- phzyx/cli/scaffold/write.py +45 -0
- phzyx/engine/__init__.py +99 -0
- phzyx/engine/apply_ops.py +118 -0
- phzyx/engine/capabilities.py +133 -0
- phzyx/engine/config.py +45 -0
- phzyx/engine/connection.py +106 -0
- phzyx/engine/dialect_spi.py +28 -0
- phzyx/engine/diff.py +124 -0
- phzyx/engine/dsn.py +126 -0
- phzyx/engine/errors.py +19 -0
- phzyx/engine/introspect_mssql.py +164 -0
- phzyx/engine/introspect_mysql.py +156 -0
- phzyx/engine/introspect_postgres.py +176 -0
- phzyx/engine/introspect_sqlite.py +134 -0
- phzyx/engine/lint.py +121 -0
- phzyx/engine/migrate.py +303 -0
- phzyx/engine/normalize.py +84 -0
- phzyx/engine/ops.py +57 -0
- phzyx/engine/plan.py +42 -0
- phzyx/engine/policy.py +61 -0
- phzyx/engine/push.py +82 -0
- phzyx/engine/tui_push.py +85 -0
- phzyx/orm/__init__.py +130 -0
- phzyx/orm/bulk.py +131 -0
- phzyx/orm/engine_async.py +82 -0
- phzyx/orm/engine_sync.py +132 -0
- phzyx/orm/errors.py +15 -0
- phzyx/orm/expr.py +153 -0
- phzyx/orm/files.py +65 -0
- phzyx/orm/hydrate.py +31 -0
- phzyx/orm/insert_prep.py +131 -0
- phzyx/orm/keyset.py +104 -0
- phzyx/orm/pk_util.py +42 -0
- phzyx/orm/query_api.md +60 -0
- phzyx/orm/relations.py +57 -0
- phzyx/orm/session_async.py +413 -0
- phzyx/orm/session_sync.py +481 -0
- phzyx/orm/soft_delete.py +53 -0
- phzyx/orm/sql.py +222 -0
- phzyx/orm/sqlite_params.py +32 -0
- phzyx/orm/upsert.py +162 -0
- phzyx/schema/__init__.py +109 -0
- phzyx/schema/ddl.py +308 -0
- phzyx/schema/errors.py +7 -0
- phzyx/schema/field.py +265 -0
- phzyx/schema/manifest.py +473 -0
- phzyx/schema/model.py +196 -0
- phzyx/schema/registry.py +151 -0
- phzyx/schema/types.py +245 -0
- phzyx_forge-0.1.0.dist-info/METADATA +74 -0
- phzyx_forge-0.1.0.dist-info/RECORD +110 -0
- phzyx_forge-0.1.0.dist-info/WHEEL +4 -0
- phzyx_forge-0.1.0.dist-info/entry_points.txt +2 -0
phzyx/admin/__init__.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Phzyx AdminBoard core — headless admin service and SPA assets."""
|
|
2
|
+
|
|
3
|
+
from pkgutil import extend_path
|
|
4
|
+
|
|
5
|
+
from phzyx.admin.actions import (
|
|
6
|
+
AdminAction,
|
|
7
|
+
BulkAction,
|
|
8
|
+
list_admin_actions,
|
|
9
|
+
list_bulk_actions,
|
|
10
|
+
run_admin_action,
|
|
11
|
+
run_bulk_action,
|
|
12
|
+
)
|
|
13
|
+
from phzyx.admin.async_service import AsyncAdminService
|
|
14
|
+
from phzyx.admin.audit import AuditEvent, AuditLog
|
|
15
|
+
from phzyx.admin.auth import AllowAll, Authz, DenyWrites, require_authz
|
|
16
|
+
from phzyx.admin.errors import (
|
|
17
|
+
AdminError,
|
|
18
|
+
ConflictError,
|
|
19
|
+
ForbiddenError,
|
|
20
|
+
NotFoundError,
|
|
21
|
+
UnauthorizedError,
|
|
22
|
+
ValidationAdminError,
|
|
23
|
+
)
|
|
24
|
+
from phzyx.admin.export_copy import copy_payload, export_csv, export_json
|
|
25
|
+
from phzyx.admin.i18n import DEFAULT_CATALOG, I18n
|
|
26
|
+
from phzyx.admin.identity import decode_identity, encode_identity
|
|
27
|
+
from phzyx.admin.layouts import LayoutPrefs, LayoutStore
|
|
28
|
+
from phzyx.admin.media import MediaItem, MediaLibrary
|
|
29
|
+
from phzyx.admin.opener import OpenMeta, open_meta_for_key
|
|
30
|
+
from phzyx.admin.relation_manager import RelationManager, all_relation_managers
|
|
31
|
+
from phzyx.admin.resource import (
|
|
32
|
+
ColumnConfig,
|
|
33
|
+
FieldsetConfig,
|
|
34
|
+
RelationConfig,
|
|
35
|
+
ResourceConfig,
|
|
36
|
+
infer_resource,
|
|
37
|
+
m2m_relation,
|
|
38
|
+
override_resource,
|
|
39
|
+
wire_relations,
|
|
40
|
+
)
|
|
41
|
+
from phzyx.admin.search import SearchHit, global_search
|
|
42
|
+
from phzyx.admin.security import (
|
|
43
|
+
assert_no_mass_assignment,
|
|
44
|
+
csp_headers,
|
|
45
|
+
csrf_cookie_notes,
|
|
46
|
+
is_production_like,
|
|
47
|
+
)
|
|
48
|
+
from phzyx.admin.service import AdminService, ListResult
|
|
49
|
+
from phzyx.admin.settings import AdminSettings, SettingsStore
|
|
50
|
+
from phzyx.admin.storage import LocalStorage, MemoryStorage, StorageBackend, guess_content_type
|
|
51
|
+
from phzyx.admin.storage_cloud import AzureBlobStorage, GCSStorage, S3CompatibleStorage
|
|
52
|
+
from phzyx.admin.stream_export import (
|
|
53
|
+
BOUNDED_RSS_NOTE,
|
|
54
|
+
DEFAULT_EXPORT_POLICY,
|
|
55
|
+
EXCEL_MAX_ROWS,
|
|
56
|
+
ExportPolicy,
|
|
57
|
+
export_xlsx,
|
|
58
|
+
stream_csv,
|
|
59
|
+
stream_json_array,
|
|
60
|
+
stream_jsonl,
|
|
61
|
+
)
|
|
62
|
+
from phzyx.admin.themes import (
|
|
63
|
+
DEFAULT_THEME,
|
|
64
|
+
PRIMARY_THEMES,
|
|
65
|
+
ThemeName,
|
|
66
|
+
normalize_theme,
|
|
67
|
+
resolve_theme,
|
|
68
|
+
theme_tokens_css,
|
|
69
|
+
)
|
|
70
|
+
from phzyx.admin.widgets import CountWidget, resource_count_widgets
|
|
71
|
+
|
|
72
|
+
__path__ = extend_path(__path__, __name__)
|
|
73
|
+
|
|
74
|
+
__version__ = "0.1.0"
|
|
75
|
+
|
|
76
|
+
__all__ = [
|
|
77
|
+
"BOUNDED_RSS_NOTE",
|
|
78
|
+
"DEFAULT_EXPORT_POLICY",
|
|
79
|
+
"DEFAULT_THEME",
|
|
80
|
+
"EXCEL_MAX_ROWS",
|
|
81
|
+
"PRIMARY_THEMES",
|
|
82
|
+
"AdminAction",
|
|
83
|
+
"AdminError",
|
|
84
|
+
"AdminService",
|
|
85
|
+
"AdminSettings",
|
|
86
|
+
"AsyncAdminService",
|
|
87
|
+
"AllowAll",
|
|
88
|
+
"CountWidget",
|
|
89
|
+
"DEFAULT_CATALOG",
|
|
90
|
+
"I18n",
|
|
91
|
+
"RelationManager",
|
|
92
|
+
"SearchHit",
|
|
93
|
+
"AuditEvent",
|
|
94
|
+
"AuditLog",
|
|
95
|
+
"Authz",
|
|
96
|
+
"AzureBlobStorage",
|
|
97
|
+
"BulkAction",
|
|
98
|
+
"ColumnConfig",
|
|
99
|
+
"ConflictError",
|
|
100
|
+
"DenyWrites",
|
|
101
|
+
"FieldsetConfig",
|
|
102
|
+
"ExportPolicy",
|
|
103
|
+
"ForbiddenError",
|
|
104
|
+
"GCSStorage",
|
|
105
|
+
"LayoutPrefs",
|
|
106
|
+
"LayoutStore",
|
|
107
|
+
"ListResult",
|
|
108
|
+
"LocalStorage",
|
|
109
|
+
"MediaItem",
|
|
110
|
+
"MediaLibrary",
|
|
111
|
+
"MemoryStorage",
|
|
112
|
+
"NotFoundError",
|
|
113
|
+
"OpenMeta",
|
|
114
|
+
"RelationConfig",
|
|
115
|
+
"m2m_relation",
|
|
116
|
+
"ResourceConfig",
|
|
117
|
+
"S3CompatibleStorage",
|
|
118
|
+
"SettingsStore",
|
|
119
|
+
"StorageBackend",
|
|
120
|
+
"ThemeName",
|
|
121
|
+
"UnauthorizedError",
|
|
122
|
+
"ValidationAdminError",
|
|
123
|
+
"__version__",
|
|
124
|
+
"assert_no_mass_assignment",
|
|
125
|
+
"copy_payload",
|
|
126
|
+
"csp_headers",
|
|
127
|
+
"csrf_cookie_notes",
|
|
128
|
+
"decode_identity",
|
|
129
|
+
"encode_identity",
|
|
130
|
+
"all_relation_managers",
|
|
131
|
+
"export_csv",
|
|
132
|
+
"export_json",
|
|
133
|
+
"export_xlsx",
|
|
134
|
+
"global_search",
|
|
135
|
+
"guess_content_type",
|
|
136
|
+
"resource_count_widgets",
|
|
137
|
+
"infer_resource",
|
|
138
|
+
"is_production_like",
|
|
139
|
+
"list_admin_actions",
|
|
140
|
+
"list_bulk_actions",
|
|
141
|
+
"normalize_theme",
|
|
142
|
+
"open_meta_for_key",
|
|
143
|
+
"override_resource",
|
|
144
|
+
"require_authz",
|
|
145
|
+
"resolve_theme",
|
|
146
|
+
"run_admin_action",
|
|
147
|
+
"run_bulk_action",
|
|
148
|
+
"stream_csv",
|
|
149
|
+
"stream_json_array",
|
|
150
|
+
"stream_jsonl",
|
|
151
|
+
"theme_tokens_css",
|
|
152
|
+
"wire_relations",
|
|
153
|
+
]
|
phzyx/admin/actions.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Admin action registry — row / bulk / global (Diamond D2/D5)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
8
|
+
|
|
9
|
+
from phzyx.admin.auth import require_authz
|
|
10
|
+
from phzyx.admin.errors import NotFoundError, ValidationAdminError
|
|
11
|
+
from phzyx.admin.identity import decode_identity
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from phzyx.admin.service import AdminService
|
|
15
|
+
|
|
16
|
+
Scope = Literal["row", "bulk", "global"]
|
|
17
|
+
|
|
18
|
+
# client: SPA-side execution hint (no server mutation body required)
|
|
19
|
+
ClientKind = str | None # export:csv | export:json | export:xlsx | copy:json | None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class AdminAction:
|
|
24
|
+
"""Declarative admin action (Diamond D5)."""
|
|
25
|
+
|
|
26
|
+
name: str
|
|
27
|
+
label: str
|
|
28
|
+
description: str = ""
|
|
29
|
+
scope: Scope = "bulk"
|
|
30
|
+
confirm: bool = False
|
|
31
|
+
danger: bool = False
|
|
32
|
+
client: ClientKind = None # if set, SPA runs client handler
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Backward-compatible alias
|
|
36
|
+
BulkAction = AdminAction
|
|
37
|
+
|
|
38
|
+
Handler = Callable[["AdminService", str, Sequence[str], Any | None], dict[str, Any]]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def action_deactivate(
|
|
42
|
+
service: AdminService,
|
|
43
|
+
resource: str,
|
|
44
|
+
identities: Sequence[str],
|
|
45
|
+
principal: Any | None = None,
|
|
46
|
+
) -> dict[str, Any]:
|
|
47
|
+
"""Set ``active=False`` on each identity when the model has that field."""
|
|
48
|
+
res = service.get_resource(resource)
|
|
49
|
+
if not any(c.name == "active" for c in res.columns):
|
|
50
|
+
raise ValidationAdminError("resource has no active field")
|
|
51
|
+
updated = 0
|
|
52
|
+
for ident in identities:
|
|
53
|
+
key = decode_identity(res.model, ident)
|
|
54
|
+
obj = service.session.get(res.model, key)
|
|
55
|
+
if obj is None:
|
|
56
|
+
continue
|
|
57
|
+
service.session.update(obj, active=False)
|
|
58
|
+
updated += 1
|
|
59
|
+
service.session.commit()
|
|
60
|
+
if service.audit:
|
|
61
|
+
pid = getattr(principal, "id", None) if principal else None
|
|
62
|
+
service.audit.record(
|
|
63
|
+
"bulk_deactivate",
|
|
64
|
+
resource,
|
|
65
|
+
principal_id=pid,
|
|
66
|
+
count=updated,
|
|
67
|
+
)
|
|
68
|
+
return {"action": "deactivate", "updated": updated, "ok": True}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def action_activate(
|
|
72
|
+
service: AdminService,
|
|
73
|
+
resource: str,
|
|
74
|
+
identities: Sequence[str],
|
|
75
|
+
principal: Any | None = None,
|
|
76
|
+
) -> dict[str, Any]:
|
|
77
|
+
"""Set ``active=True`` on selected identities."""
|
|
78
|
+
res = service.get_resource(resource)
|
|
79
|
+
if not any(c.name == "active" for c in res.columns):
|
|
80
|
+
raise ValidationAdminError("resource has no active field")
|
|
81
|
+
updated = 0
|
|
82
|
+
for ident in identities:
|
|
83
|
+
key = decode_identity(res.model, ident)
|
|
84
|
+
obj = service.session.get(res.model, key)
|
|
85
|
+
if obj is None:
|
|
86
|
+
continue
|
|
87
|
+
service.session.update(obj, active=True)
|
|
88
|
+
updated += 1
|
|
89
|
+
service.session.commit()
|
|
90
|
+
if service.audit:
|
|
91
|
+
pid = getattr(principal, "id", None) if principal else None
|
|
92
|
+
service.audit.record("bulk_activate", resource, principal_id=pid, count=updated)
|
|
93
|
+
return {"action": "activate", "updated": updated, "ok": True}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
DEFAULT_HANDLERS: dict[str, Handler] = {
|
|
97
|
+
"deactivate": action_deactivate,
|
|
98
|
+
"activate": action_activate,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
# Prefer DEFAULT_HANDLERS; keep alias for D2 imports
|
|
102
|
+
DEFAULT_BULK_HANDLERS = DEFAULT_HANDLERS
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def default_actions(*, has_active: bool) -> list[AdminAction]:
|
|
106
|
+
"""Built-in catalog: server mutations + client export/copy."""
|
|
107
|
+
acts: list[AdminAction] = [
|
|
108
|
+
AdminAction(
|
|
109
|
+
name="export_csv",
|
|
110
|
+
label="Export CSV",
|
|
111
|
+
description="Download resource as CSV",
|
|
112
|
+
scope="global",
|
|
113
|
+
client="export:csv",
|
|
114
|
+
),
|
|
115
|
+
AdminAction(
|
|
116
|
+
name="export_json",
|
|
117
|
+
label="Export JSON",
|
|
118
|
+
description="Download resource as JSON",
|
|
119
|
+
scope="global",
|
|
120
|
+
client="export:json",
|
|
121
|
+
),
|
|
122
|
+
AdminAction(
|
|
123
|
+
name="export_xlsx",
|
|
124
|
+
label="Export Excel",
|
|
125
|
+
description="Download resource as Excel",
|
|
126
|
+
scope="global",
|
|
127
|
+
client="export:xlsx",
|
|
128
|
+
),
|
|
129
|
+
AdminAction(
|
|
130
|
+
name="copy_json",
|
|
131
|
+
label="Copy JSON",
|
|
132
|
+
description="Copy selection or all as JSON",
|
|
133
|
+
scope="bulk",
|
|
134
|
+
client="copy:json",
|
|
135
|
+
confirm=False,
|
|
136
|
+
),
|
|
137
|
+
AdminAction(
|
|
138
|
+
name="copy_row_json",
|
|
139
|
+
label="Copy JSON",
|
|
140
|
+
description="Copy this row as JSON",
|
|
141
|
+
scope="row",
|
|
142
|
+
client="copy:json",
|
|
143
|
+
confirm=False,
|
|
144
|
+
),
|
|
145
|
+
]
|
|
146
|
+
if has_active:
|
|
147
|
+
acts.extend(
|
|
148
|
+
[
|
|
149
|
+
AdminAction(
|
|
150
|
+
name="deactivate",
|
|
151
|
+
label="Deactivate",
|
|
152
|
+
description="Set active=false on selected rows",
|
|
153
|
+
scope="bulk",
|
|
154
|
+
confirm=True,
|
|
155
|
+
danger=False,
|
|
156
|
+
),
|
|
157
|
+
AdminAction(
|
|
158
|
+
name="activate",
|
|
159
|
+
label="Activate",
|
|
160
|
+
description="Set active=true on selected rows",
|
|
161
|
+
scope="bulk",
|
|
162
|
+
confirm=True,
|
|
163
|
+
),
|
|
164
|
+
AdminAction(
|
|
165
|
+
name="deactivate",
|
|
166
|
+
label="Deactivate",
|
|
167
|
+
description="Set active=false on this row",
|
|
168
|
+
scope="row",
|
|
169
|
+
confirm=True,
|
|
170
|
+
),
|
|
171
|
+
AdminAction(
|
|
172
|
+
name="activate",
|
|
173
|
+
label="Activate",
|
|
174
|
+
description="Set active=true on this row",
|
|
175
|
+
scope="row",
|
|
176
|
+
confirm=True,
|
|
177
|
+
),
|
|
178
|
+
]
|
|
179
|
+
)
|
|
180
|
+
return acts
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
DEFAULT_BULK_ACTIONS: tuple[AdminAction, ...] = tuple(
|
|
184
|
+
a for a in default_actions(has_active=True) if a.scope == "bulk" and not a.client
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _public(a: AdminAction) -> dict[str, Any]:
|
|
189
|
+
return {
|
|
190
|
+
"name": a.name,
|
|
191
|
+
"label": a.label,
|
|
192
|
+
"description": a.description,
|
|
193
|
+
"scope": a.scope,
|
|
194
|
+
"confirm": a.confirm,
|
|
195
|
+
"danger": a.danger,
|
|
196
|
+
"client": a.client,
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def list_admin_actions(
|
|
201
|
+
resource_name: str,
|
|
202
|
+
*,
|
|
203
|
+
has_active: bool,
|
|
204
|
+
scope: str | None = None,
|
|
205
|
+
) -> list[dict[str, Any]]:
|
|
206
|
+
"""Public action descriptors for SPA (Diamond D5)."""
|
|
207
|
+
_ = resource_name
|
|
208
|
+
out = [_public(a) for a in default_actions(has_active=has_active)]
|
|
209
|
+
if scope:
|
|
210
|
+
out = [a for a in out if a["scope"] == scope]
|
|
211
|
+
return out
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def list_bulk_actions(resource_name: str, has_active: bool) -> list[dict[str, Any]]:
|
|
215
|
+
"""Backward-compatible bulk-only list (D2)."""
|
|
216
|
+
return list_admin_actions(resource_name, has_active=has_active, scope="bulk")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def run_admin_action(
|
|
220
|
+
service: AdminService,
|
|
221
|
+
resource: str,
|
|
222
|
+
action: str,
|
|
223
|
+
identities: Sequence[str] | None = None,
|
|
224
|
+
*,
|
|
225
|
+
principal: Any | None = None,
|
|
226
|
+
handlers: dict[str, Handler] | None = None,
|
|
227
|
+
) -> dict[str, Any]:
|
|
228
|
+
"""Run a server-side action (row or bulk)."""
|
|
229
|
+
require_authz(service.authz, principal, f"action:{action}", resource)
|
|
230
|
+
# Client-only actions should not hit the server runner
|
|
231
|
+
catalog = {
|
|
232
|
+
a.name: a
|
|
233
|
+
for a in default_actions(
|
|
234
|
+
has_active=any(c.name == "active" for c in service.get_resource(resource).columns)
|
|
235
|
+
)
|
|
236
|
+
if a.client is None
|
|
237
|
+
}
|
|
238
|
+
reg_probe = handlers or DEFAULT_HANDLERS
|
|
239
|
+
if action not in catalog and action not in reg_probe and action not in DEFAULT_HANDLERS:
|
|
240
|
+
raise NotFoundError(f"unknown action {action}")
|
|
241
|
+
|
|
242
|
+
idents = list(identities or [])
|
|
243
|
+
if not idents:
|
|
244
|
+
raise ValidationAdminError("identities required")
|
|
245
|
+
reg = handlers or DEFAULT_HANDLERS
|
|
246
|
+
handler = reg.get(action)
|
|
247
|
+
if handler is None:
|
|
248
|
+
raise NotFoundError(f"unknown action {action}")
|
|
249
|
+
return handler(service, resource, idents, principal)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def run_bulk_action(
|
|
253
|
+
service: AdminService,
|
|
254
|
+
resource: str,
|
|
255
|
+
action: str,
|
|
256
|
+
identities: Sequence[str],
|
|
257
|
+
*,
|
|
258
|
+
principal: Any | None = None,
|
|
259
|
+
handlers: dict[str, Handler] | None = None,
|
|
260
|
+
) -> dict[str, Any]:
|
|
261
|
+
"""Backward-compatible bulk runner (D2)."""
|
|
262
|
+
return run_admin_action(
|
|
263
|
+
service,
|
|
264
|
+
resource,
|
|
265
|
+
action,
|
|
266
|
+
identities,
|
|
267
|
+
principal=principal,
|
|
268
|
+
handlers=handlers,
|
|
269
|
+
)
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Async AdminService — battle-ready CRUD over AsyncSession."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from phzyx.admin.audit import AuditLog
|
|
9
|
+
from phzyx.admin.auth import AllowAll, AllowAllFields, Authz, FieldAuthz, require_authz
|
|
10
|
+
from phzyx.admin.errors import ConflictError, NotFoundError, ValidationAdminError
|
|
11
|
+
from phzyx.admin.filters import build_where
|
|
12
|
+
from phzyx.admin.identity import decode_identity, encode_identity
|
|
13
|
+
from phzyx.admin.resource import ResourceConfig
|
|
14
|
+
from phzyx.admin.security import assert_no_mass_assignment
|
|
15
|
+
from phzyx.admin.service import ListResult, _validation_error
|
|
16
|
+
from phzyx.admin.unique_checks import raise_if_unique_violations_async
|
|
17
|
+
from phzyx.orm.errors import ConflictError as OrmConflict
|
|
18
|
+
from phzyx.orm.insert_prep import utcnow
|
|
19
|
+
from phzyx.orm.keyset import default_order_columns, order_from_columns
|
|
20
|
+
from phzyx.orm.session_async import AsyncSession
|
|
21
|
+
from phzyx.schema.model import get_table_info
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _identity_key(model: type[Any], obj: Any) -> Any:
|
|
25
|
+
pk = get_table_info(model).primary_key
|
|
26
|
+
if len(pk) == 1:
|
|
27
|
+
return getattr(obj, pk[0])
|
|
28
|
+
return {c: getattr(obj, c) for c in pk}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _serialize(model: type[Any], obj: Any) -> dict[str, Any]:
|
|
32
|
+
data = obj.model_dump(mode="json")
|
|
33
|
+
data["_identity"] = encode_identity(model, _identity_key(model, obj))
|
|
34
|
+
return data
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class AsyncAdminService:
|
|
39
|
+
"""Headless admin ops over AsyncSession (list/get/create/update/delete)."""
|
|
40
|
+
|
|
41
|
+
resources: dict[str, ResourceConfig]
|
|
42
|
+
session: AsyncSession
|
|
43
|
+
authz: Authz = field(default_factory=AllowAll)
|
|
44
|
+
field_authz: FieldAuthz = field(default_factory=AllowAllFields)
|
|
45
|
+
audit: AuditLog | None = None
|
|
46
|
+
storage: Any | None = None
|
|
47
|
+
|
|
48
|
+
def get_resource(self, name: str) -> ResourceConfig:
|
|
49
|
+
if name not in self.resources:
|
|
50
|
+
raise NotFoundError(f"unknown resource {name}")
|
|
51
|
+
return self.resources[name]
|
|
52
|
+
|
|
53
|
+
def _where(
|
|
54
|
+
self,
|
|
55
|
+
res: ResourceConfig,
|
|
56
|
+
*,
|
|
57
|
+
search: str | None,
|
|
58
|
+
filters: dict[str, str] | None,
|
|
59
|
+
) -> list[Any]:
|
|
60
|
+
"""Same filter rules as sync AdminService (shared ``build_where``)."""
|
|
61
|
+
return build_where(res, search=search, filters=filters, hide_soft_deleted=True)
|
|
62
|
+
|
|
63
|
+
async def list(
|
|
64
|
+
self,
|
|
65
|
+
name: str,
|
|
66
|
+
*,
|
|
67
|
+
page: int = 1,
|
|
68
|
+
page_size: int = 50,
|
|
69
|
+
search: str | None = None,
|
|
70
|
+
filters: dict[str, str] | None = None,
|
|
71
|
+
cursor: str | None = None,
|
|
72
|
+
pagination: str = "keyset",
|
|
73
|
+
principal: Any | None = None,
|
|
74
|
+
) -> ListResult:
|
|
75
|
+
"""List records (mirrors sync AdminService pagination rules)."""
|
|
76
|
+
from phzyx.admin.service import _resolve_list_pagination
|
|
77
|
+
|
|
78
|
+
res = self.get_resource(name)
|
|
79
|
+
require_authz(self.authz, principal, "list", name)
|
|
80
|
+
page = max(1, page)
|
|
81
|
+
page_size = min(max(1, page_size), 200)
|
|
82
|
+
where = self._where(res, search=search, filters=filters)
|
|
83
|
+
order = order_from_columns(default_order_columns(res.model))
|
|
84
|
+
mode = _resolve_list_pagination(pagination, page=page, cursor=cursor)
|
|
85
|
+
total = await self.session.count(res.model, where=where)
|
|
86
|
+
if mode == "keyset":
|
|
87
|
+
page_data = await self.session.query_keyset(
|
|
88
|
+
res.model,
|
|
89
|
+
where=where,
|
|
90
|
+
order_by=order,
|
|
91
|
+
limit=page_size,
|
|
92
|
+
cursor=cursor,
|
|
93
|
+
)
|
|
94
|
+
items = [_serialize(res.model, r) for r in page_data.items]
|
|
95
|
+
return ListResult(
|
|
96
|
+
items=items,
|
|
97
|
+
total=total,
|
|
98
|
+
page=page,
|
|
99
|
+
page_size=page_size,
|
|
100
|
+
next_cursor=page_data.next_cursor,
|
|
101
|
+
prev_cursor=page_data.prev_cursor,
|
|
102
|
+
has_more=page_data.has_more,
|
|
103
|
+
pagination="keyset",
|
|
104
|
+
)
|
|
105
|
+
rows = await self.session.query(
|
|
106
|
+
res.model,
|
|
107
|
+
where=where,
|
|
108
|
+
order_by=order,
|
|
109
|
+
limit=page_size,
|
|
110
|
+
offset=(page - 1) * page_size,
|
|
111
|
+
)
|
|
112
|
+
items = [_serialize(res.model, r) for r in rows]
|
|
113
|
+
return ListResult(
|
|
114
|
+
items=items,
|
|
115
|
+
total=total,
|
|
116
|
+
page=page,
|
|
117
|
+
page_size=page_size,
|
|
118
|
+
has_more=(page * page_size) < total,
|
|
119
|
+
pagination="offset",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
async def get(
|
|
123
|
+
self,
|
|
124
|
+
name: str,
|
|
125
|
+
identity: str,
|
|
126
|
+
*,
|
|
127
|
+
principal: Any | None = None,
|
|
128
|
+
) -> dict[str, Any]:
|
|
129
|
+
res = self.get_resource(name)
|
|
130
|
+
require_authz(self.authz, principal, "get", name)
|
|
131
|
+
key = decode_identity(res.model, identity)
|
|
132
|
+
obj = await self.session.get(res.model, key)
|
|
133
|
+
if obj is None:
|
|
134
|
+
raise NotFoundError(f"{name} {identity} not found")
|
|
135
|
+
return _serialize(res.model, obj)
|
|
136
|
+
|
|
137
|
+
async def create(
|
|
138
|
+
self,
|
|
139
|
+
name: str,
|
|
140
|
+
data: dict[str, Any],
|
|
141
|
+
*,
|
|
142
|
+
principal: Any | None = None,
|
|
143
|
+
) -> dict[str, Any]:
|
|
144
|
+
res = self.get_resource(name)
|
|
145
|
+
require_authz(self.authz, principal, "create", name)
|
|
146
|
+
if res.readonly:
|
|
147
|
+
raise ValidationAdminError("resource is readonly")
|
|
148
|
+
clean = assert_no_mass_assignment(res, data, partial=False)
|
|
149
|
+
ai_pks: set[str] = set()
|
|
150
|
+
for pk in res.primary_key:
|
|
151
|
+
col = res.column(pk)
|
|
152
|
+
if col and col.autoincrement and (pk not in clean or clean.get(pk) in (None, "")):
|
|
153
|
+
clean.pop(pk, None)
|
|
154
|
+
ai_pks.add(pk)
|
|
155
|
+
# Drop generated fields from client payload
|
|
156
|
+
for c in res.columns:
|
|
157
|
+
meta = get_table_info(res.model).columns.get(c.name)
|
|
158
|
+
if meta and meta.generated:
|
|
159
|
+
clean.pop(c.name, None)
|
|
160
|
+
try:
|
|
161
|
+
obj = res.model.model_validate(clean)
|
|
162
|
+
except Exception as exc:
|
|
163
|
+
missing = [
|
|
164
|
+
fname
|
|
165
|
+
for fname, finfo in res.model.model_fields.items()
|
|
166
|
+
if fname not in ai_pks and finfo.is_required() and fname not in clean
|
|
167
|
+
]
|
|
168
|
+
if missing or not ai_pks:
|
|
169
|
+
raise _validation_error(exc) from exc
|
|
170
|
+
try:
|
|
171
|
+
obj = res.model.model_construct(**clean)
|
|
172
|
+
except Exception as exc2:
|
|
173
|
+
raise _validation_error(exc2) from exc2
|
|
174
|
+
await raise_if_unique_violations_async(
|
|
175
|
+
res.model,
|
|
176
|
+
clean,
|
|
177
|
+
query=lambda where: self.session.query(res.model, where=where, limit=5),
|
|
178
|
+
)
|
|
179
|
+
try:
|
|
180
|
+
obj = await self.session.add(obj)
|
|
181
|
+
await self.session.commit()
|
|
182
|
+
except Exception as exc:
|
|
183
|
+
await self.session.rollback()
|
|
184
|
+
msg = str(exc).lower()
|
|
185
|
+
if "unique" in msg or "duplicate" in msg or "integrity" in msg:
|
|
186
|
+
raise ValidationAdminError(
|
|
187
|
+
"Validation failed",
|
|
188
|
+
fields={"_": "unique constraint violated"},
|
|
189
|
+
) from exc
|
|
190
|
+
raise
|
|
191
|
+
if self.audit:
|
|
192
|
+
pid = getattr(principal, "id", None) if principal else None
|
|
193
|
+
self.audit.record("create", name, principal_id=pid)
|
|
194
|
+
return _serialize(res.model, obj)
|
|
195
|
+
|
|
196
|
+
async def update(
|
|
197
|
+
self,
|
|
198
|
+
name: str,
|
|
199
|
+
identity: str,
|
|
200
|
+
data: dict[str, Any],
|
|
201
|
+
*,
|
|
202
|
+
principal: Any | None = None,
|
|
203
|
+
) -> dict[str, Any]:
|
|
204
|
+
res = self.get_resource(name)
|
|
205
|
+
require_authz(self.authz, principal, "update", name)
|
|
206
|
+
if res.readonly:
|
|
207
|
+
raise ValidationAdminError("resource is readonly")
|
|
208
|
+
key = decode_identity(res.model, identity)
|
|
209
|
+
obj = await self.session.get(res.model, key)
|
|
210
|
+
if obj is None:
|
|
211
|
+
raise NotFoundError(f"{name} {identity} not found")
|
|
212
|
+
clean = assert_no_mass_assignment(res, data, partial=True)
|
|
213
|
+
for c in res.columns:
|
|
214
|
+
meta = get_table_info(res.model).columns.get(c.name)
|
|
215
|
+
if meta and meta.generated:
|
|
216
|
+
clean.pop(c.name, None)
|
|
217
|
+
merged = {**_serialize(res.model, obj), **clean}
|
|
218
|
+
merged.pop("_identity", None)
|
|
219
|
+
await raise_if_unique_violations_async(
|
|
220
|
+
res.model,
|
|
221
|
+
merged,
|
|
222
|
+
query=lambda where: self.session.query(res.model, where=where, limit=5),
|
|
223
|
+
exclude_key=key,
|
|
224
|
+
)
|
|
225
|
+
try:
|
|
226
|
+
updated = await self.session.update(obj, **clean)
|
|
227
|
+
await self.session.commit()
|
|
228
|
+
except OrmConflict as exc:
|
|
229
|
+
await self.session.rollback()
|
|
230
|
+
raise ConflictError(str(exc), resource=name, identity=identity) from exc
|
|
231
|
+
except Exception as exc:
|
|
232
|
+
await self.session.rollback()
|
|
233
|
+
raise _validation_error(exc) from exc
|
|
234
|
+
if self.audit:
|
|
235
|
+
pid = getattr(principal, "id", None) if principal else None
|
|
236
|
+
self.audit.record("update", name, principal_id=pid, identity=identity)
|
|
237
|
+
return _serialize(res.model, updated)
|
|
238
|
+
|
|
239
|
+
async def delete(
|
|
240
|
+
self,
|
|
241
|
+
name: str,
|
|
242
|
+
identity: str,
|
|
243
|
+
*,
|
|
244
|
+
principal: Any | None = None,
|
|
245
|
+
soft: bool | None = None,
|
|
246
|
+
) -> None:
|
|
247
|
+
res = self.get_resource(name)
|
|
248
|
+
require_authz(self.authz, principal, "delete", name)
|
|
249
|
+
if res.readonly:
|
|
250
|
+
raise ValidationAdminError("resource is readonly")
|
|
251
|
+
key = decode_identity(res.model, identity)
|
|
252
|
+
obj = await self.session.get(res.model, key)
|
|
253
|
+
if obj is None:
|
|
254
|
+
raise NotFoundError(f"{name} {identity} not found")
|
|
255
|
+
use_soft = soft if soft is not None else ("deleted_at" in res.model.model_fields)
|
|
256
|
+
if use_soft and "deleted_at" in res.model.model_fields:
|
|
257
|
+
await self.session.update(obj, deleted_at=utcnow().isoformat())
|
|
258
|
+
else:
|
|
259
|
+
await self.session.delete(obj)
|
|
260
|
+
await self.session.commit()
|
|
261
|
+
if self.audit:
|
|
262
|
+
pid = getattr(principal, "id", None) if principal else None
|
|
263
|
+
self.audit.record("delete", name, principal_id=pid, identity=identity)
|