supython 0.5.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 (188) hide show
  1. supython/__init__.py +8 -0
  2. supython/admin/__init__.py +3 -0
  3. supython/admin/api/__init__.py +24 -0
  4. supython/admin/api/auth.py +118 -0
  5. supython/admin/api/auth_templates.py +67 -0
  6. supython/admin/api/auth_users.py +225 -0
  7. supython/admin/api/db.py +174 -0
  8. supython/admin/api/functions.py +92 -0
  9. supython/admin/api/jobs.py +192 -0
  10. supython/admin/api/ops.py +224 -0
  11. supython/admin/api/realtime.py +281 -0
  12. supython/admin/api/service_auth.py +49 -0
  13. supython/admin/api/service_auth_templates.py +83 -0
  14. supython/admin/api/service_auth_users.py +346 -0
  15. supython/admin/api/service_db.py +214 -0
  16. supython/admin/api/service_functions.py +287 -0
  17. supython/admin/api/service_jobs.py +282 -0
  18. supython/admin/api/service_ops.py +213 -0
  19. supython/admin/api/service_realtime.py +30 -0
  20. supython/admin/api/service_storage.py +220 -0
  21. supython/admin/api/storage.py +117 -0
  22. supython/admin/api/system.py +37 -0
  23. supython/admin/audit.py +29 -0
  24. supython/admin/deps.py +22 -0
  25. supython/admin/errors.py +16 -0
  26. supython/admin/schemas.py +310 -0
  27. supython/admin/session.py +52 -0
  28. supython/admin/spa.py +38 -0
  29. supython/admin/static/assets/Alert-dluGVkos.js +49 -0
  30. supython/admin/static/assets/Audit-Njung3HI.js +2 -0
  31. supython/admin/static/assets/Backups-DzPlFgrm.js +2 -0
  32. supython/admin/static/assets/Buckets-ByacGkU1.js +2 -0
  33. supython/admin/static/assets/Channels-BoIuTtam.js +353 -0
  34. supython/admin/static/assets/ChevronRight-CtQH1EQ1.js +2 -0
  35. supython/admin/static/assets/CodeViewer-Bqy7-wvH.js +2 -0
  36. supython/admin/static/assets/Crons-B67vc39F.js +2 -0
  37. supython/admin/static/assets/DashboardView-CUTFVL6k.js +2 -0
  38. supython/admin/static/assets/DataTable-COAAWEft.js +747 -0
  39. supython/admin/static/assets/DescriptionsItem-P8JUDaBs.js +75 -0
  40. supython/admin/static/assets/DrawerContent-TpYTFgF1.js +139 -0
  41. supython/admin/static/assets/Empty-cr2r7e2u.js +25 -0
  42. supython/admin/static/assets/EmptyState-DeDck-OL.js +2 -0
  43. supython/admin/static/assets/Grid-hFkp9F4P.js +2 -0
  44. supython/admin/static/assets/Input-DppYTq9C.js +259 -0
  45. supython/admin/static/assets/Invoke-DW3Nveeh.js +2 -0
  46. supython/admin/static/assets/JsonField-DibyJgun.js +2 -0
  47. supython/admin/static/assets/LoginView-BjLyE3Ds.css +1 -0
  48. supython/admin/static/assets/LoginView-CoOjECT_.js +111 -0
  49. supython/admin/static/assets/Logs-D9WYrnIT.js +2 -0
  50. supython/admin/static/assets/Logs-DS1XPa0h.css +1 -0
  51. supython/admin/static/assets/Migrations-DOSC2ddQ.js +2 -0
  52. supython/admin/static/assets/ObjectBrowser-_5w8vOX8.js +2 -0
  53. supython/admin/static/assets/Queue-CywZs6vI.js +2 -0
  54. supython/admin/static/assets/RefreshTokens-Ccjr53jg.js +2 -0
  55. supython/admin/static/assets/RlsEditor-BSlH9vSc.js +2 -0
  56. supython/admin/static/assets/Routes-BiLXE49D.js +2 -0
  57. supython/admin/static/assets/Routes-C-ianIGD.css +1 -0
  58. supython/admin/static/assets/SchemaBrowser-DKy2_KQi.css +1 -0
  59. supython/admin/static/assets/SchemaBrowser-XFvFbtDB.js +2 -0
  60. supython/admin/static/assets/Select-DIzZyRZb.js +434 -0
  61. supython/admin/static/assets/Space-n5-XcguU.js +400 -0
  62. supython/admin/static/assets/SqlEditor-b8pTsILY.js +3 -0
  63. supython/admin/static/assets/SqlWorkspace-BUS7IntH.js +104 -0
  64. supython/admin/static/assets/TableData-CQIagLKn.js +2 -0
  65. supython/admin/static/assets/Tag-D1fOKpTH.js +72 -0
  66. supython/admin/static/assets/Templates-BS-ugkdq.js +2 -0
  67. supython/admin/static/assets/Thing-CEAniuMg.js +107 -0
  68. supython/admin/static/assets/Users-wzwajhlh.js +2 -0
  69. supython/admin/static/assets/_plugin-vue_export-helper-DGA9ry_j.js +1 -0
  70. supython/admin/static/assets/dist-VXIJLCYq.js +13 -0
  71. supython/admin/static/assets/format-length-CGCY1rMh.js +2 -0
  72. supython/admin/static/assets/get-Ca6unauB.js +2 -0
  73. supython/admin/static/assets/index-CeE6v959.js +951 -0
  74. supython/admin/static/assets/pinia-COXwfrOX.js +2 -0
  75. supython/admin/static/assets/resources-Bt6thQCD.js +44 -0
  76. supython/admin/static/assets/use-locale-mtgM0a3a.js +2 -0
  77. supython/admin/static/assets/use-merged-state-BvhkaHNX.js +2 -0
  78. supython/admin/static/assets/useConfirm-tMjvBFXR.js +2 -0
  79. supython/admin/static/assets/useResource-C_rJCY8C.js +2 -0
  80. supython/admin/static/assets/useTable-CnZc5zhi.js +363 -0
  81. supython/admin/static/assets/useTable-Dg0XlRlq.css +1 -0
  82. supython/admin/static/assets/useToast-DsZKx0IX.js +2 -0
  83. supython/admin/static/assets/utils-sbXoq7Ir.js +2 -0
  84. supython/admin/static/favicon.svg +1 -0
  85. supython/admin/static/icons.svg +24 -0
  86. supython/admin/static/index.html +24 -0
  87. supython/app.py +149 -0
  88. supython/auth/__init__.py +3 -0
  89. supython/auth/_email_job.py +11 -0
  90. supython/auth/providers/__init__.py +34 -0
  91. supython/auth/providers/github.py +22 -0
  92. supython/auth/providers/google.py +19 -0
  93. supython/auth/providers/oauth.py +56 -0
  94. supython/auth/providers/registry.py +16 -0
  95. supython/auth/ratelimit.py +39 -0
  96. supython/auth/router.py +282 -0
  97. supython/auth/schemas.py +79 -0
  98. supython/auth/service.py +587 -0
  99. supython/body_size.py +184 -0
  100. supython/cli.py +1653 -0
  101. supython/client/__init__.py +67 -0
  102. supython/client/_auth.py +249 -0
  103. supython/client/_client.py +145 -0
  104. supython/client/_config.py +92 -0
  105. supython/client/_functions.py +69 -0
  106. supython/client/_storage.py +255 -0
  107. supython/client/py.typed +0 -0
  108. supython/db.py +151 -0
  109. supython/db_admin.py +8 -0
  110. supython/functions/__init__.py +19 -0
  111. supython/functions/context.py +262 -0
  112. supython/functions/loader.py +307 -0
  113. supython/functions/router.py +228 -0
  114. supython/functions/schemas.py +50 -0
  115. supython/gen/__init__.py +5 -0
  116. supython/gen/_introspect.py +137 -0
  117. supython/gen/types_py.py +270 -0
  118. supython/gen/types_ts.py +365 -0
  119. supython/health.py +229 -0
  120. supython/hooks.py +117 -0
  121. supython/jobs/__init__.py +31 -0
  122. supython/jobs/backends.py +97 -0
  123. supython/jobs/context.py +58 -0
  124. supython/jobs/cron.py +152 -0
  125. supython/jobs/cron_inproc.py +118 -0
  126. supython/jobs/decorators.py +76 -0
  127. supython/jobs/registry.py +79 -0
  128. supython/jobs/router.py +136 -0
  129. supython/jobs/schemas.py +92 -0
  130. supython/jobs/service.py +311 -0
  131. supython/jobs/worker.py +219 -0
  132. supython/jwks.py +257 -0
  133. supython/keyset.py +279 -0
  134. supython/logging_config.py +291 -0
  135. supython/mail.py +33 -0
  136. supython/mailer.py +65 -0
  137. supython/migrate.py +81 -0
  138. supython/migrations/0001_extensions_and_roles.sql +46 -0
  139. supython/migrations/0002_auth_schema.sql +66 -0
  140. supython/migrations/0003_demo_todos.sql +42 -0
  141. supython/migrations/0004_auth_v0_2.sql +47 -0
  142. supython/migrations/0005_storage_schema.sql +117 -0
  143. supython/migrations/0006_realtime_schema.sql +206 -0
  144. supython/migrations/0007_jobs_schema.sql +254 -0
  145. supython/migrations/0008_jobs_last_error.sql +56 -0
  146. supython/migrations/0009_auth_rate_limits.sql +33 -0
  147. supython/migrations/0010_worker_heartbeat.sql +14 -0
  148. supython/migrations/0011_admin_schema.sql +45 -0
  149. supython/migrations/0012_auth_banned_until.sql +10 -0
  150. supython/migrations/0013_email_templates.sql +19 -0
  151. supython/migrations/0014_realtime_payload_warning.sql +96 -0
  152. supython/migrations/0015_backups_schema.sql +14 -0
  153. supython/passwords.py +15 -0
  154. supython/realtime/__init__.py +6 -0
  155. supython/realtime/broker.py +814 -0
  156. supython/realtime/protocol.py +234 -0
  157. supython/realtime/router.py +184 -0
  158. supython/realtime/schemas.py +207 -0
  159. supython/realtime/service.py +261 -0
  160. supython/realtime/topics.py +175 -0
  161. supython/realtime/websocket.py +586 -0
  162. supython/scaffold/__init__.py +5 -0
  163. supython/scaffold/init_project.py +133 -0
  164. supython/scaffold/templates/Caddyfile.tmpl +4 -0
  165. supython/scaffold/templates/README.md.tmpl +22 -0
  166. supython/scaffold/templates/docker-compose.prod.yml.tmpl +84 -0
  167. supython/scaffold/templates/docker-compose.yml.tmpl +41 -0
  168. supython/scaffold/templates/docker_postgres_Dockerfile.tmpl +9 -0
  169. supython/scaffold/templates/docker_postgres_postgresql.conf.tmpl +3 -0
  170. supython/scaffold/templates/env.example.tmpl +149 -0
  171. supython/scaffold/templates/functions_README.md.tmpl +21 -0
  172. supython/scaffold/templates/gitignore.tmpl +14 -0
  173. supython/scaffold/templates/migrations/.gitkeep +0 -0
  174. supython/secretset.py +347 -0
  175. supython/security_headers.py +78 -0
  176. supython/settings.py +198 -0
  177. supython/storage/__init__.py +5 -0
  178. supython/storage/backends.py +392 -0
  179. supython/storage/router.py +341 -0
  180. supython/storage/schemas.py +50 -0
  181. supython/storage/service.py +445 -0
  182. supython/storage/signing.py +119 -0
  183. supython/tokens.py +85 -0
  184. supython-0.5.0.dist-info/METADATA +714 -0
  185. supython-0.5.0.dist-info/RECORD +188 -0
  186. supython-0.5.0.dist-info/WHEEL +4 -0
  187. supython-0.5.0.dist-info/entry_points.txt +2 -0
  188. supython-0.5.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,281 @@
1
+ """Admin realtime control plane.
2
+
3
+ GET /admin/api/v1/realtime/tables
4
+ List every row in ``realtime.enabled_tables``.
5
+
6
+ GET /admin/api/v1/realtime/inspect?topic=...
7
+ SSE stream; subscribe to the broker under ``service_role`` and
8
+ receive broadcast / postgres_changes events in real time. 1 000-event
9
+ cap, then the stream closes cleanly (matches frontend ``useLiveTail``).
10
+
11
+ POST /admin/api/v1/realtime/broadcast
12
+ Confirm-required broadcast to a topic. Wraps the broker's
13
+ ``broadcast()`` method. Audit-logged.
14
+ """
15
+
16
+ import asyncio
17
+ import ipaddress
18
+ import json
19
+ import logging
20
+ from collections.abc import AsyncGenerator
21
+ from typing import Annotated
22
+ from uuid import UUID
23
+
24
+ from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request, status
25
+ from fastapi.responses import StreamingResponse
26
+
27
+ from ... import db
28
+ from ...realtime import get_broker
29
+ from ...realtime.broker import BrokerError
30
+ from ...realtime.schemas import (
31
+ BroadcastResponse,
32
+ EnabledTable,
33
+ JoinConfig,
34
+ PostgresChangesFilter,
35
+ )
36
+ from ...realtime.topics import TopicError, assign_subscription_ids, validate_topic
37
+ from .. import audit
38
+ from ..deps import require_admin
39
+ from ..schemas import AdminBroadcastRequest
40
+ from . import service_realtime
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+ router = APIRouter(prefix="/admin/api/v1/realtime", tags=["admin.realtime"])
45
+
46
+ # Maximum events pushed before the SSE inspect stream self-closes.
47
+ _INSPECT_EVENT_CAP = 1000
48
+
49
+
50
+ def _client_ip(request: Request) -> str | None:
51
+ if request.client is None:
52
+ return None
53
+ try:
54
+ ipaddress.ip_address(request.client.host)
55
+ except ValueError:
56
+ return None
57
+ return request.client.host
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # GET /tables — list realtime-enabled tables
62
+ # ---------------------------------------------------------------------------
63
+
64
+
65
+ @router.get("/tables", response_model=list[EnabledTable])
66
+ async def list_tables(
67
+ _: Annotated[UUID, Depends(require_admin)],
68
+ ) -> list[EnabledTable]:
69
+ """List every row in ``realtime.enabled_tables``."""
70
+ async with db.as_service_role() as conn:
71
+ return await service_realtime.list_enabled_tables(conn)
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # GET /inspect — SSE broker subscriber
76
+ # ---------------------------------------------------------------------------
77
+
78
+
79
+ def _sse_event(event: str, data: dict) -> str:
80
+ """Format a single SSE event."""
81
+ payload = json.dumps(data, separators=(",", ":"), default=str)
82
+ return f"event: {event}\ndata: {payload}\n\n"
83
+
84
+
85
+ async def _inspect_events(
86
+ topic: str,
87
+ ) -> AsyncGenerator[str, None]:
88
+ """Async generator that yields SSE frames from the broker for *topic*.
89
+
90
+ Registers a virtual connection under ``service_role``, subscribes to
91
+ *topic* with broadcast and all-tables postgres_changes, then drains
92
+ the broker outbound queue. Stops after ``_INSPECT_EVENT_CAP`` frames
93
+ or when the client disconnects.
94
+ """
95
+ broker = get_broker()
96
+
97
+ try:
98
+ conn = await broker.register(
99
+ role="service_role",
100
+ claims={"role": "service_role"},
101
+ )
102
+ except BrokerError as exc:
103
+ yield _sse_event("error", {"code": "broker_full", "message": str(exc)})
104
+ return
105
+
106
+ # Build postgres_changes subscriptions for every enabled table so the
107
+ # admin sees all DB changes flowing through any channel on this topic.
108
+ # Any failure between register and subscribe must unregister the
109
+ # virtual connection so it does not leak a broker slot.
110
+ try:
111
+ async with db.as_service_role() as db_conn:
112
+ enabled = await service_realtime.list_enabled_tables(db_conn)
113
+
114
+ postgres_changes = [
115
+ PostgresChangesFilter(
116
+ event="*",
117
+ schema=t.schema_name,
118
+ table=t.table_name,
119
+ )
120
+ for t in enabled
121
+ ]
122
+ config = JoinConfig(postgres_changes=postgres_changes)
123
+ resolved = assign_subscription_ids(config)
124
+
125
+ await broker.subscribe(
126
+ conn,
127
+ topic=topic,
128
+ join_ref=None,
129
+ postgres_changes=resolved,
130
+ broadcast_self=True,
131
+ presence_key="",
132
+ )
133
+ except Exception as exc:
134
+ await broker.unregister(conn)
135
+ yield _sse_event(
136
+ "error",
137
+ {"code": "subscribe_failed", "message": str(exc)},
138
+ )
139
+ return
140
+
141
+ sent = 0
142
+ try:
143
+ # Yield an initial "connected" event so the frontend knows the
144
+ # subscription is live.
145
+ yield _sse_event(
146
+ "connected",
147
+ {"topic": topic, "tables": len(enabled)},
148
+ )
149
+
150
+ while sent < _INSPECT_EVENT_CAP:
151
+ try:
152
+ frame = await asyncio.wait_for(conn.outbound.get(), timeout=30.0)
153
+ except TimeoutError:
154
+ # Heartbeat to keep the connection alive.
155
+ yield _sse_event("heartbeat", {})
156
+ continue
157
+
158
+ yield _sse_event(
159
+ frame.event,
160
+ {
161
+ "topic": frame.topic,
162
+ "payload": frame.payload,
163
+ },
164
+ )
165
+ sent += 1
166
+ except asyncio.CancelledError:
167
+ pass
168
+ finally:
169
+ try:
170
+ await broker.unregister(conn)
171
+ except Exception:
172
+ logger.debug(
173
+ "realtime inspect: unregister failed for topic=%s",
174
+ topic,
175
+ exc_info=True,
176
+ )
177
+
178
+
179
+ @router.get("/inspect")
180
+ async def inspect(
181
+ request: Request,
182
+ _: Annotated[UUID, Depends(require_admin)],
183
+ topic: Annotated[str, Query(description="realtime:<name> topic to subscribe to")],
184
+ ) -> StreamingResponse:
185
+ """Subscribe to the broker for *topic* and stream events as SSE.
186
+
187
+ Events are pushed as ``text/event-stream``. The stream self-closes
188
+ after 1 000 events or 30 s of inactivity. Every event carries an
189
+ ``event:`` field matching the Phoenix frame event and a ``data:``
190
+ field with ``{topic, payload}`` JSON.
191
+ """
192
+ try:
193
+ validate_topic(topic)
194
+ except TopicError as exc:
195
+ raise HTTPException(
196
+ status.HTTP_400_BAD_REQUEST,
197
+ detail={"code": "invalid_topic", "message": str(exc)},
198
+ ) from exc
199
+
200
+ broker = get_broker()
201
+ if not broker.is_healthy:
202
+ raise HTTPException(
203
+ status.HTTP_503_SERVICE_UNAVAILABLE,
204
+ detail={
205
+ "code": "broker_unhealthy",
206
+ "message": "Realtime broker is not running",
207
+ },
208
+ )
209
+
210
+ logger.debug("realtime inspect: admin subscribed to topic=%s", topic)
211
+
212
+ async def _stream() -> AsyncGenerator[str, None]:
213
+ async for chunk in _inspect_events(topic):
214
+ if await request.is_disconnected():
215
+ break
216
+ yield chunk
217
+
218
+ return StreamingResponse(
219
+ _stream(),
220
+ media_type="text/event-stream",
221
+ headers={
222
+ "Cache-Control": "no-cache",
223
+ "X-Accel-Buffering": "no",
224
+ },
225
+ )
226
+
227
+
228
+ # ---------------------------------------------------------------------------
229
+ # POST /broadcast — confirm-required broadcast
230
+ # ---------------------------------------------------------------------------
231
+
232
+
233
+ @router.post(
234
+ "/broadcast",
235
+ response_model=BroadcastResponse,
236
+ status_code=status.HTTP_202_ACCEPTED,
237
+ )
238
+ async def broadcast(
239
+ request: Request,
240
+ admin_id: Annotated[UUID, Depends(require_admin)],
241
+ body: Annotated[AdminBroadcastRequest, Body()],
242
+ ) -> BroadcastResponse:
243
+ """Fan a broadcast frame out to every subscriber of *topic*.
244
+
245
+ Confirm-required by the frontend (Story 10.3). The confirmation
246
+ dialog is presented in the UI before this endpoint is called; the
247
+ backend simply executes the broadcast under ``service_role`` and
248
+ logs an audit event.
249
+ """
250
+ try:
251
+ validate_topic(body.topic)
252
+ except TopicError as exc:
253
+ raise HTTPException(
254
+ status.HTTP_400_BAD_REQUEST,
255
+ detail={"code": "invalid_topic", "message": str(exc)},
256
+ ) from exc
257
+
258
+ broker = get_broker()
259
+ delivered = broker.broadcast(
260
+ topic=body.topic,
261
+ event=body.event,
262
+ payload=body.payload,
263
+ sender_id=None,
264
+ )
265
+
266
+ ip = _client_ip(request)
267
+ ua = request.headers.get("user-agent")
268
+ async with db.as_service_role() as conn:
269
+ await audit.write(
270
+ conn,
271
+ admin_id=admin_id,
272
+ action="realtime.broadcast",
273
+ target=body.topic,
274
+ payload={
275
+ "event": body.event,
276
+ "delivered": delivered,
277
+ },
278
+ ip=ip,
279
+ ua=ua,
280
+ )
281
+ return BroadcastResponse(topic=body.topic, delivered=delivered)
@@ -0,0 +1,49 @@
1
+ from datetime import datetime
2
+ from uuid import UUID
3
+
4
+ import asyncpg
5
+
6
+ from ..errors import AdminError
7
+
8
+
9
+ async def authenticate(
10
+ conn: asyncpg.Connection, email: str, password: str
11
+ ) -> tuple[UUID, str]:
12
+ """Verify credentials and return (admin_id, email).
13
+
14
+ Raises AdminError("invalid_credentials", 401) on bad email/password.
15
+ """
16
+ from ... import passwords
17
+
18
+ row = await conn.fetchrow(
19
+ """
20
+ select id, email, password_hash
21
+ from admin.admin_users
22
+ where email = $1
23
+ """,
24
+ email,
25
+ )
26
+ if not row or not passwords.verify_password(row["password_hash"], password):
27
+ raise AdminError("invalid_credentials", "Invalid credentials", 401)
28
+ return row["id"], row["email"]
29
+
30
+
31
+ async def touch_last_login(conn: asyncpg.Connection, admin_id: UUID) -> None:
32
+ await conn.execute(
33
+ "update admin.admin_users set last_login_at = now() where id = $1",
34
+ admin_id,
35
+ )
36
+
37
+
38
+ async def fetch_admin(
39
+ conn: asyncpg.Connection, admin_id: UUID
40
+ ) -> tuple[str, datetime | None] | None:
41
+ row = await conn.fetchrow(
42
+ """
43
+ select email, last_login_at
44
+ from admin.admin_users
45
+ where id = $1
46
+ """,
47
+ admin_id,
48
+ )
49
+ return (row["email"], row["last_login_at"]) if row else None
@@ -0,0 +1,83 @@
1
+ """Raw SQL helpers for email template CRUD — no Pydantic, no FastAPI."""
2
+
3
+ from datetime import datetime, timezone
4
+
5
+ import asyncpg
6
+
7
+ from ..errors import AdminError
8
+ from ..schemas import EmailTemplate
9
+
10
+
11
+ async def list_templates(conn: asyncpg.Connection) -> list[EmailTemplate]:
12
+ rows = await conn.fetch(
13
+ """
14
+ select name, subject, text_body, updated_at
15
+ from admin.email_templates
16
+ order by name
17
+ """
18
+ )
19
+ return [
20
+ EmailTemplate(
21
+ name=row["name"],
22
+ subject=row["subject"],
23
+ text_body=row["text_body"],
24
+ updated_at=row["updated_at"],
25
+ )
26
+ for row in rows
27
+ ]
28
+
29
+
30
+ async def get_template(conn: asyncpg.Connection, name: str) -> EmailTemplate:
31
+ row = await conn.fetchrow(
32
+ """
33
+ select name, subject, text_body, updated_at
34
+ from admin.email_templates
35
+ where name = $1
36
+ """,
37
+ name,
38
+ )
39
+ if row is None:
40
+ raise AdminError("not_found", f"template {name!r} not found", 404)
41
+ return EmailTemplate(
42
+ name=row["name"],
43
+ subject=row["subject"],
44
+ text_body=row["text_body"],
45
+ updated_at=row["updated_at"],
46
+ )
47
+
48
+
49
+ async def update_template(
50
+ conn: asyncpg.Connection,
51
+ name: str,
52
+ subject: str | None,
53
+ text_body: str | None,
54
+ ) -> EmailTemplate:
55
+ """Patch one or both fields. Returns the updated row."""
56
+ existing = await conn.fetchrow(
57
+ "select subject, text_body from admin.email_templates where name = $1",
58
+ name,
59
+ )
60
+ if existing is None:
61
+ raise AdminError("not_found", f"template {name!r} not found", 404)
62
+
63
+ new_subject = subject if subject is not None else existing["subject"]
64
+ new_body = text_body if text_body is not None else existing["text_body"]
65
+
66
+ row = await conn.fetchrow(
67
+ """
68
+ update admin.email_templates
69
+ set subject = $2, text_body = $3, updated_at = $4
70
+ where name = $1
71
+ returning name, subject, text_body, updated_at
72
+ """,
73
+ name,
74
+ new_subject,
75
+ new_body,
76
+ datetime.now(tz=timezone.utc),
77
+ )
78
+ return EmailTemplate(
79
+ name=row["name"],
80
+ subject=row["subject"],
81
+ text_body=row["text_body"],
82
+ updated_at=row["updated_at"],
83
+ )