phzyx-forge 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. phzyx_forge-0.1.0/PKG-INFO +74 -0
  2. phzyx_forge-0.1.0/README.md +23 -0
  3. phzyx_forge-0.1.0/pyproject.toml +67 -0
  4. phzyx_forge-0.1.0/src/phzyx/admin/__init__.py +153 -0
  5. phzyx_forge-0.1.0/src/phzyx/admin/actions.py +269 -0
  6. phzyx_forge-0.1.0/src/phzyx/admin/async_service.py +263 -0
  7. phzyx_forge-0.1.0/src/phzyx/admin/audit.py +50 -0
  8. phzyx_forge-0.1.0/src/phzyx/admin/auth.py +69 -0
  9. phzyx_forge-0.1.0/src/phzyx/admin/branding.py +30 -0
  10. phzyx_forge-0.1.0/src/phzyx/admin/errors.py +50 -0
  11. phzyx_forge-0.1.0/src/phzyx/admin/export_copy.py +86 -0
  12. phzyx_forge-0.1.0/src/phzyx/admin/fastapi/__init__.py +7 -0
  13. phzyx_forge-0.1.0/src/phzyx/admin/fastapi/board.py +190 -0
  14. phzyx_forge-0.1.0/src/phzyx/admin/fastapi/routes.py +731 -0
  15. phzyx_forge-0.1.0/src/phzyx/admin/filters.py +153 -0
  16. phzyx_forge-0.1.0/src/phzyx/admin/i18n.py +61 -0
  17. phzyx_forge-0.1.0/src/phzyx/admin/identity.py +63 -0
  18. phzyx_forge-0.1.0/src/phzyx/admin/layouts.py +45 -0
  19. phzyx_forge-0.1.0/src/phzyx/admin/litestar/__init__.py +127 -0
  20. phzyx_forge-0.1.0/src/phzyx/admin/media.py +113 -0
  21. phzyx_forge-0.1.0/src/phzyx/admin/openapi_export.py +265 -0
  22. phzyx_forge-0.1.0/src/phzyx/admin/opener.py +35 -0
  23. phzyx_forge-0.1.0/src/phzyx/admin/relation_manager.py +63 -0
  24. phzyx_forge-0.1.0/src/phzyx/admin/resource.py +511 -0
  25. phzyx_forge-0.1.0/src/phzyx/admin/schema_status.py +90 -0
  26. phzyx_forge-0.1.0/src/phzyx/admin/search.py +77 -0
  27. phzyx_forge-0.1.0/src/phzyx/admin/security.py +66 -0
  28. phzyx_forge-0.1.0/src/phzyx/admin/service.py +908 -0
  29. phzyx_forge-0.1.0/src/phzyx/admin/settings.py +71 -0
  30. phzyx_forge-0.1.0/src/phzyx/admin/storage.py +158 -0
  31. phzyx_forge-0.1.0/src/phzyx/admin/storage_cloud.py +353 -0
  32. phzyx_forge-0.1.0/src/phzyx/admin/stream_export.py +145 -0
  33. phzyx_forge-0.1.0/src/phzyx/admin/themes.py +50 -0
  34. phzyx_forge-0.1.0/src/phzyx/admin/unique_checks.py +159 -0
  35. phzyx_forge-0.1.0/src/phzyx/admin/web/dist/apple-touch-icon.png +0 -0
  36. phzyx_forge-0.1.0/src/phzyx/admin/web/dist/assets/index-CRk1fSOo.js +76 -0
  37. phzyx_forge-0.1.0/src/phzyx/admin/web/dist/assets/index-ZF5lh_j6.css +1 -0
  38. phzyx_forge-0.1.0/src/phzyx/admin/web/dist/favicon-64.png +0 -0
  39. phzyx_forge-0.1.0/src/phzyx/admin/web/dist/favicon.png +0 -0
  40. phzyx_forge-0.1.0/src/phzyx/admin/web/dist/index.html +20 -0
  41. phzyx_forge-0.1.0/src/phzyx/admin/web/dist/phzyx-mark.svg +61 -0
  42. phzyx_forge-0.1.0/src/phzyx/admin/widgets.py +62 -0
  43. phzyx_forge-0.1.0/src/phzyx/cli/__init__.py +542 -0
  44. phzyx_forge-0.1.0/src/phzyx/cli/add_admin.py +43 -0
  45. phzyx_forge-0.1.0/src/phzyx/cli/add_auth.py +156 -0
  46. phzyx_forge-0.1.0/src/phzyx/cli/doctor.py +287 -0
  47. phzyx_forge-0.1.0/src/phzyx/cli/forge_init.py +58 -0
  48. phzyx_forge-0.1.0/src/phzyx/cli/new.py +108 -0
  49. phzyx_forge-0.1.0/src/phzyx/cli/presets/__init__.py +21 -0
  50. phzyx_forge-0.1.0/src/phzyx/cli/presets/full.json +16 -0
  51. phzyx_forge-0.1.0/src/phzyx/cli/presets/max.json +16 -0
  52. phzyx_forge-0.1.0/src/phzyx/cli/presets/minimal.json +14 -0
  53. phzyx_forge-0.1.0/src/phzyx/cli/presets/normal.json +15 -0
  54. phzyx_forge-0.1.0/src/phzyx/cli/scaffold/__init__.py +89 -0
  55. phzyx_forge-0.1.0/src/phzyx/cli/scaffold/detect.py +134 -0
  56. phzyx_forge-0.1.0/src/phzyx/cli/scaffold/plan.py +217 -0
  57. phzyx_forge-0.1.0/src/phzyx/cli/scaffold/presets.py +87 -0
  58. phzyx_forge-0.1.0/src/phzyx/cli/scaffold/prompts.py +37 -0
  59. phzyx_forge-0.1.0/src/phzyx/cli/scaffold/templates.py +791 -0
  60. phzyx_forge-0.1.0/src/phzyx/cli/scaffold/types.py +77 -0
  61. phzyx_forge-0.1.0/src/phzyx/cli/scaffold/write.py +45 -0
  62. phzyx_forge-0.1.0/src/phzyx/engine/__init__.py +99 -0
  63. phzyx_forge-0.1.0/src/phzyx/engine/apply_ops.py +118 -0
  64. phzyx_forge-0.1.0/src/phzyx/engine/capabilities.py +133 -0
  65. phzyx_forge-0.1.0/src/phzyx/engine/config.py +45 -0
  66. phzyx_forge-0.1.0/src/phzyx/engine/connection.py +106 -0
  67. phzyx_forge-0.1.0/src/phzyx/engine/dialect_spi.py +28 -0
  68. phzyx_forge-0.1.0/src/phzyx/engine/diff.py +124 -0
  69. phzyx_forge-0.1.0/src/phzyx/engine/dsn.py +126 -0
  70. phzyx_forge-0.1.0/src/phzyx/engine/errors.py +19 -0
  71. phzyx_forge-0.1.0/src/phzyx/engine/introspect_mssql.py +164 -0
  72. phzyx_forge-0.1.0/src/phzyx/engine/introspect_mysql.py +156 -0
  73. phzyx_forge-0.1.0/src/phzyx/engine/introspect_postgres.py +176 -0
  74. phzyx_forge-0.1.0/src/phzyx/engine/introspect_sqlite.py +134 -0
  75. phzyx_forge-0.1.0/src/phzyx/engine/lint.py +121 -0
  76. phzyx_forge-0.1.0/src/phzyx/engine/migrate.py +303 -0
  77. phzyx_forge-0.1.0/src/phzyx/engine/normalize.py +84 -0
  78. phzyx_forge-0.1.0/src/phzyx/engine/ops.py +57 -0
  79. phzyx_forge-0.1.0/src/phzyx/engine/plan.py +42 -0
  80. phzyx_forge-0.1.0/src/phzyx/engine/policy.py +61 -0
  81. phzyx_forge-0.1.0/src/phzyx/engine/push.py +82 -0
  82. phzyx_forge-0.1.0/src/phzyx/engine/tui_push.py +85 -0
  83. phzyx_forge-0.1.0/src/phzyx/orm/__init__.py +130 -0
  84. phzyx_forge-0.1.0/src/phzyx/orm/bulk.py +131 -0
  85. phzyx_forge-0.1.0/src/phzyx/orm/engine_async.py +82 -0
  86. phzyx_forge-0.1.0/src/phzyx/orm/engine_sync.py +132 -0
  87. phzyx_forge-0.1.0/src/phzyx/orm/errors.py +15 -0
  88. phzyx_forge-0.1.0/src/phzyx/orm/expr.py +153 -0
  89. phzyx_forge-0.1.0/src/phzyx/orm/files.py +65 -0
  90. phzyx_forge-0.1.0/src/phzyx/orm/hydrate.py +31 -0
  91. phzyx_forge-0.1.0/src/phzyx/orm/insert_prep.py +131 -0
  92. phzyx_forge-0.1.0/src/phzyx/orm/keyset.py +104 -0
  93. phzyx_forge-0.1.0/src/phzyx/orm/pk_util.py +42 -0
  94. phzyx_forge-0.1.0/src/phzyx/orm/query_api.md +60 -0
  95. phzyx_forge-0.1.0/src/phzyx/orm/relations.py +57 -0
  96. phzyx_forge-0.1.0/src/phzyx/orm/session_async.py +413 -0
  97. phzyx_forge-0.1.0/src/phzyx/orm/session_sync.py +481 -0
  98. phzyx_forge-0.1.0/src/phzyx/orm/soft_delete.py +53 -0
  99. phzyx_forge-0.1.0/src/phzyx/orm/sql.py +222 -0
  100. phzyx_forge-0.1.0/src/phzyx/orm/sqlite_params.py +32 -0
  101. phzyx_forge-0.1.0/src/phzyx/orm/upsert.py +162 -0
  102. phzyx_forge-0.1.0/src/phzyx/schema/__init__.py +109 -0
  103. phzyx_forge-0.1.0/src/phzyx/schema/ddl.py +308 -0
  104. phzyx_forge-0.1.0/src/phzyx/schema/errors.py +7 -0
  105. phzyx_forge-0.1.0/src/phzyx/schema/field.py +265 -0
  106. phzyx_forge-0.1.0/src/phzyx/schema/manifest.py +473 -0
  107. phzyx_forge-0.1.0/src/phzyx/schema/model.py +196 -0
  108. phzyx_forge-0.1.0/src/phzyx/schema/registry.py +151 -0
  109. phzyx_forge-0.1.0/src/phzyx/schema/types.py +245 -0
@@ -0,0 +1,74 @@
1
+ Metadata-Version: 2.4
2
+ Name: phzyx-forge
3
+ Version: 0.1.0
4
+ Summary: Phzyx Forge — schema, failsafe migrations, ORM, AdminBoard, CLI
5
+ Project-URL: Homepage, https://phzyx.xyz
6
+ Project-URL: Documentation, https://phzyx.xyz/docs
7
+ Project-URL: Repository, https://github.com/phzyxyz/forge
8
+ Project-URL: Issues, https://github.com/phzyxyz/forge/issues
9
+ Author: Athul Nandaswaroop
10
+ License-Expression: MIT
11
+ Keywords: admin,fastapi,migrations,orm,phzyx,pydantic
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Framework :: FastAPI
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Database
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.11
24
+ Requires-Dist: aiosqlite
25
+ Requires-Dist: fastapi>=0.115
26
+ Requires-Dist: pydantic>=2
27
+ Requires-Dist: python-multipart>=0.0.32
28
+ Provides-Extra: auth
29
+ Requires-Dist: phzyx-auth[fastapi]<0.2,>=0.1.0; extra == 'auth'
30
+ Provides-Extra: cache
31
+ Requires-Dist: phzyx-cache<0.2,>=0.1.0; extra == 'cache'
32
+ Provides-Extra: full
33
+ Requires-Dist: asyncpg; extra == 'full'
34
+ Requires-Dist: litestar>=2.12; extra == 'full'
35
+ Requires-Dist: phzyx-auth[fastapi]<0.2,>=0.1.0; extra == 'full'
36
+ Requires-Dist: phzyx-cache<0.2,>=0.1.0; extra == 'full'
37
+ Requires-Dist: psycopg[binary]>=3; extra == 'full'
38
+ Requires-Dist: pymssql>=2.3; extra == 'full'
39
+ Requires-Dist: pymysql; extra == 'full'
40
+ Provides-Extra: litestar
41
+ Requires-Dist: litestar>=2.12; extra == 'litestar'
42
+ Provides-Extra: mssql
43
+ Requires-Dist: pymssql>=2.3; extra == 'mssql'
44
+ Provides-Extra: mysql
45
+ Requires-Dist: pymysql; extra == 'mysql'
46
+ Provides-Extra: postgres
47
+ Requires-Dist: asyncpg; extra == 'postgres'
48
+ Requires-Dist: psycopg[binary]>=3; extra == 'postgres'
49
+ Provides-Extra: sqlite
50
+ Description-Content-Type: text/markdown
51
+
52
+ # phzyx-forge
53
+
54
+ **Forge** — Phzyx data stack in one install: Pydantic schema, failsafe migrations,
55
+ ORM, AdminBoard (FastAPI-first), and the `phzyx` CLI.
56
+
57
+ ```bash
58
+ uv add phzyx-forge
59
+ ```
60
+
61
+ ```python
62
+ from phzyx.schema.model import Model
63
+ from phzyx.orm import Session
64
+ from phzyx.admin.fastapi import AdminBoard
65
+ ```
66
+
67
+ Optional batteries (separate packages):
68
+
69
+ - `phzyx-auth` — identity / sessions / JWT / RBAC (`uv add 'phzyx-forge[auth]'`)
70
+ - `phzyx-cache` — versioned resource cache
71
+
72
+ Pre-1.0: APIs may change. Pin `==0.1.0` for apps.
73
+
74
+ Homepage: https://phzyx.xyz · Source: https://github.com/phzyxyz/forge
@@ -0,0 +1,23 @@
1
+ # phzyx-forge
2
+
3
+ **Forge** — Phzyx data stack in one install: Pydantic schema, failsafe migrations,
4
+ ORM, AdminBoard (FastAPI-first), and the `phzyx` CLI.
5
+
6
+ ```bash
7
+ uv add phzyx-forge
8
+ ```
9
+
10
+ ```python
11
+ from phzyx.schema.model import Model
12
+ from phzyx.orm import Session
13
+ from phzyx.admin.fastapi import AdminBoard
14
+ ```
15
+
16
+ Optional batteries (separate packages):
17
+
18
+ - `phzyx-auth` — identity / sessions / JWT / RBAC (`uv add 'phzyx-forge[auth]'`)
19
+ - `phzyx-cache` — versioned resource cache
20
+
21
+ Pre-1.0: APIs may change. Pin `==0.1.0` for apps.
22
+
23
+ Homepage: https://phzyx.xyz · Source: https://github.com/phzyxyz/forge
@@ -0,0 +1,67 @@
1
+ [project]
2
+ name = "phzyx-forge"
3
+ version = "0.1.0"
4
+ description = "Phzyx Forge — schema, failsafe migrations, ORM, AdminBoard, CLI"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = "MIT"
8
+ authors = [{ name = "Athul Nandaswaroop" }]
9
+ keywords = ["phzyx", "orm", "pydantic", "admin", "migrations", "fastapi"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Intended Audience :: Developers",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Programming Language :: Python :: 3.14",
19
+ "Framework :: FastAPI",
20
+ "Topic :: Database",
21
+ "Typing :: Typed",
22
+ ]
23
+ dependencies = [
24
+ "pydantic>=2",
25
+ "fastapi>=0.115",
26
+ "python-multipart>=0.0.32",
27
+ "aiosqlite", # default local stack; postgres/mysql/mssql stay extras
28
+ ]
29
+
30
+ [project.optional-dependencies]
31
+ # [sqlite] kept as no-op alias so old docs still resolve
32
+ sqlite = []
33
+ postgres = ["asyncpg", "psycopg[binary]>=3"]
34
+ mysql = ["pymysql"]
35
+ mssql = ["pymssql>=2.3"]
36
+ auth = ["phzyx-auth[fastapi]>=0.1.0,<0.2"]
37
+ cache = ["phzyx-cache>=0.1.0,<0.2"]
38
+ litestar = ["litestar>=2.12"]
39
+ # Literally full: every driver + batteries + Litestar (SQLite already in core).
40
+ full = [
41
+ "asyncpg",
42
+ "psycopg[binary]>=3",
43
+ "pymysql",
44
+ "pymssql>=2.3",
45
+ "phzyx-auth[fastapi]>=0.1.0,<0.2",
46
+ "phzyx-cache>=0.1.0,<0.2",
47
+ "litestar>=2.12",
48
+ ]
49
+
50
+ [project.scripts]
51
+ phzyx = "phzyx.cli:main"
52
+
53
+ [project.urls]
54
+ Homepage = "https://phzyx.xyz"
55
+ Documentation = "https://phzyx.xyz/docs"
56
+ Repository = "https://github.com/phzyxyz/forge"
57
+ Issues = "https://github.com/phzyxyz/forge/issues"
58
+
59
+ [build-system]
60
+ requires = ["hatchling"]
61
+ build-backend = "hatchling.build"
62
+
63
+ [tool.hatch.build.targets.wheel]
64
+ packages = ["src/phzyx"]
65
+
66
+ [tool.hatch.build.targets.sdist]
67
+ include = ["src/phzyx", "README.md", "pyproject.toml"]
@@ -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
+ ]
@@ -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
+ )