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.
Files changed (110) hide show
  1. phzyx/admin/__init__.py +153 -0
  2. phzyx/admin/actions.py +269 -0
  3. phzyx/admin/async_service.py +263 -0
  4. phzyx/admin/audit.py +50 -0
  5. phzyx/admin/auth.py +69 -0
  6. phzyx/admin/branding.py +30 -0
  7. phzyx/admin/errors.py +50 -0
  8. phzyx/admin/export_copy.py +86 -0
  9. phzyx/admin/fastapi/__init__.py +7 -0
  10. phzyx/admin/fastapi/board.py +190 -0
  11. phzyx/admin/fastapi/routes.py +731 -0
  12. phzyx/admin/filters.py +153 -0
  13. phzyx/admin/i18n.py +61 -0
  14. phzyx/admin/identity.py +63 -0
  15. phzyx/admin/layouts.py +45 -0
  16. phzyx/admin/litestar/__init__.py +127 -0
  17. phzyx/admin/media.py +113 -0
  18. phzyx/admin/openapi_export.py +265 -0
  19. phzyx/admin/opener.py +35 -0
  20. phzyx/admin/relation_manager.py +63 -0
  21. phzyx/admin/resource.py +511 -0
  22. phzyx/admin/schema_status.py +90 -0
  23. phzyx/admin/search.py +77 -0
  24. phzyx/admin/security.py +66 -0
  25. phzyx/admin/service.py +908 -0
  26. phzyx/admin/settings.py +71 -0
  27. phzyx/admin/storage.py +158 -0
  28. phzyx/admin/storage_cloud.py +353 -0
  29. phzyx/admin/stream_export.py +145 -0
  30. phzyx/admin/themes.py +50 -0
  31. phzyx/admin/unique_checks.py +159 -0
  32. phzyx/admin/web/dist/apple-touch-icon.png +0 -0
  33. phzyx/admin/web/dist/assets/index-CRk1fSOo.js +76 -0
  34. phzyx/admin/web/dist/assets/index-ZF5lh_j6.css +1 -0
  35. phzyx/admin/web/dist/favicon-64.png +0 -0
  36. phzyx/admin/web/dist/favicon.png +0 -0
  37. phzyx/admin/web/dist/index.html +20 -0
  38. phzyx/admin/web/dist/phzyx-mark.svg +61 -0
  39. phzyx/admin/widgets.py +62 -0
  40. phzyx/cli/__init__.py +542 -0
  41. phzyx/cli/add_admin.py +43 -0
  42. phzyx/cli/add_auth.py +156 -0
  43. phzyx/cli/doctor.py +287 -0
  44. phzyx/cli/forge_init.py +58 -0
  45. phzyx/cli/new.py +108 -0
  46. phzyx/cli/presets/__init__.py +21 -0
  47. phzyx/cli/presets/full.json +16 -0
  48. phzyx/cli/presets/max.json +16 -0
  49. phzyx/cli/presets/minimal.json +14 -0
  50. phzyx/cli/presets/normal.json +15 -0
  51. phzyx/cli/scaffold/__init__.py +89 -0
  52. phzyx/cli/scaffold/detect.py +134 -0
  53. phzyx/cli/scaffold/plan.py +217 -0
  54. phzyx/cli/scaffold/presets.py +87 -0
  55. phzyx/cli/scaffold/prompts.py +37 -0
  56. phzyx/cli/scaffold/templates.py +791 -0
  57. phzyx/cli/scaffold/types.py +77 -0
  58. phzyx/cli/scaffold/write.py +45 -0
  59. phzyx/engine/__init__.py +99 -0
  60. phzyx/engine/apply_ops.py +118 -0
  61. phzyx/engine/capabilities.py +133 -0
  62. phzyx/engine/config.py +45 -0
  63. phzyx/engine/connection.py +106 -0
  64. phzyx/engine/dialect_spi.py +28 -0
  65. phzyx/engine/diff.py +124 -0
  66. phzyx/engine/dsn.py +126 -0
  67. phzyx/engine/errors.py +19 -0
  68. phzyx/engine/introspect_mssql.py +164 -0
  69. phzyx/engine/introspect_mysql.py +156 -0
  70. phzyx/engine/introspect_postgres.py +176 -0
  71. phzyx/engine/introspect_sqlite.py +134 -0
  72. phzyx/engine/lint.py +121 -0
  73. phzyx/engine/migrate.py +303 -0
  74. phzyx/engine/normalize.py +84 -0
  75. phzyx/engine/ops.py +57 -0
  76. phzyx/engine/plan.py +42 -0
  77. phzyx/engine/policy.py +61 -0
  78. phzyx/engine/push.py +82 -0
  79. phzyx/engine/tui_push.py +85 -0
  80. phzyx/orm/__init__.py +130 -0
  81. phzyx/orm/bulk.py +131 -0
  82. phzyx/orm/engine_async.py +82 -0
  83. phzyx/orm/engine_sync.py +132 -0
  84. phzyx/orm/errors.py +15 -0
  85. phzyx/orm/expr.py +153 -0
  86. phzyx/orm/files.py +65 -0
  87. phzyx/orm/hydrate.py +31 -0
  88. phzyx/orm/insert_prep.py +131 -0
  89. phzyx/orm/keyset.py +104 -0
  90. phzyx/orm/pk_util.py +42 -0
  91. phzyx/orm/query_api.md +60 -0
  92. phzyx/orm/relations.py +57 -0
  93. phzyx/orm/session_async.py +413 -0
  94. phzyx/orm/session_sync.py +481 -0
  95. phzyx/orm/soft_delete.py +53 -0
  96. phzyx/orm/sql.py +222 -0
  97. phzyx/orm/sqlite_params.py +32 -0
  98. phzyx/orm/upsert.py +162 -0
  99. phzyx/schema/__init__.py +109 -0
  100. phzyx/schema/ddl.py +308 -0
  101. phzyx/schema/errors.py +7 -0
  102. phzyx/schema/field.py +265 -0
  103. phzyx/schema/manifest.py +473 -0
  104. phzyx/schema/model.py +196 -0
  105. phzyx/schema/registry.py +151 -0
  106. phzyx/schema/types.py +245 -0
  107. phzyx_forge-0.1.0.dist-info/METADATA +74 -0
  108. phzyx_forge-0.1.0.dist-info/RECORD +110 -0
  109. phzyx_forge-0.1.0.dist-info/WHEEL +4 -0
  110. phzyx_forge-0.1.0.dist-info/entry_points.txt +2 -0
@@ -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)