supython 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.
- supython/__init__.py +24 -0
- supython/admin/__init__.py +3 -0
- supython/admin/api/__init__.py +24 -0
- supython/admin/api/auth.py +118 -0
- supython/admin/api/auth_templates.py +67 -0
- supython/admin/api/auth_users.py +225 -0
- supython/admin/api/db.py +174 -0
- supython/admin/api/functions.py +92 -0
- supython/admin/api/jobs.py +192 -0
- supython/admin/api/ops.py +224 -0
- supython/admin/api/realtime.py +281 -0
- supython/admin/api/service_auth.py +49 -0
- supython/admin/api/service_auth_templates.py +83 -0
- supython/admin/api/service_auth_users.py +346 -0
- supython/admin/api/service_db.py +214 -0
- supython/admin/api/service_functions.py +287 -0
- supython/admin/api/service_jobs.py +282 -0
- supython/admin/api/service_ops.py +213 -0
- supython/admin/api/service_realtime.py +30 -0
- supython/admin/api/service_storage.py +220 -0
- supython/admin/api/storage.py +117 -0
- supython/admin/api/system.py +37 -0
- supython/admin/audit.py +29 -0
- supython/admin/deps.py +22 -0
- supython/admin/errors.py +16 -0
- supython/admin/schemas.py +310 -0
- supython/admin/session.py +52 -0
- supython/admin/spa.py +38 -0
- supython/admin/static/assets/Alert-dluGVkos.js +49 -0
- supython/admin/static/assets/Audit-Njung3HI.js +2 -0
- supython/admin/static/assets/Backups-DzPlFgrm.js +2 -0
- supython/admin/static/assets/Buckets-ByacGkU1.js +2 -0
- supython/admin/static/assets/Channels-BoIuTtam.js +353 -0
- supython/admin/static/assets/ChevronRight-CtQH1EQ1.js +2 -0
- supython/admin/static/assets/CodeViewer-Bqy7-wvH.js +2 -0
- supython/admin/static/assets/Crons-B67vc39F.js +2 -0
- supython/admin/static/assets/DashboardView-CUTFVL6k.js +2 -0
- supython/admin/static/assets/DataTable-COAAWEft.js +747 -0
- supython/admin/static/assets/DescriptionsItem-P8JUDaBs.js +75 -0
- supython/admin/static/assets/DrawerContent-TpYTFgF1.js +139 -0
- supython/admin/static/assets/Empty-cr2r7e2u.js +25 -0
- supython/admin/static/assets/EmptyState-DeDck-OL.js +2 -0
- supython/admin/static/assets/Grid-hFkp9F4P.js +2 -0
- supython/admin/static/assets/Input-DppYTq9C.js +259 -0
- supython/admin/static/assets/Invoke-DW3Nveeh.js +2 -0
- supython/admin/static/assets/JsonField-DibyJgun.js +2 -0
- supython/admin/static/assets/LoginView-BjLyE3Ds.css +1 -0
- supython/admin/static/assets/LoginView-CoOjECT_.js +111 -0
- supython/admin/static/assets/Logs-D9WYrnIT.js +2 -0
- supython/admin/static/assets/Logs-DS1XPa0h.css +1 -0
- supython/admin/static/assets/Migrations-DOSC2ddQ.js +2 -0
- supython/admin/static/assets/ObjectBrowser-_5w8vOX8.js +2 -0
- supython/admin/static/assets/Queue-CywZs6vI.js +2 -0
- supython/admin/static/assets/RefreshTokens-Ccjr53jg.js +2 -0
- supython/admin/static/assets/RlsEditor-BSlH9vSc.js +2 -0
- supython/admin/static/assets/Routes-BiLXE49D.js +2 -0
- supython/admin/static/assets/Routes-C-ianIGD.css +1 -0
- supython/admin/static/assets/SchemaBrowser-DKy2_KQi.css +1 -0
- supython/admin/static/assets/SchemaBrowser-XFvFbtDB.js +2 -0
- supython/admin/static/assets/Select-DIzZyRZb.js +434 -0
- supython/admin/static/assets/Space-n5-XcguU.js +400 -0
- supython/admin/static/assets/SqlEditor-b8pTsILY.js +3 -0
- supython/admin/static/assets/SqlWorkspace-BUS7IntH.js +104 -0
- supython/admin/static/assets/TableData-CQIagLKn.js +2 -0
- supython/admin/static/assets/Tag-D1fOKpTH.js +72 -0
- supython/admin/static/assets/Templates-BS-ugkdq.js +2 -0
- supython/admin/static/assets/Thing-CEAniuMg.js +107 -0
- supython/admin/static/assets/Users-wzwajhlh.js +2 -0
- supython/admin/static/assets/_plugin-vue_export-helper-DGA9ry_j.js +1 -0
- supython/admin/static/assets/dist-VXIJLCYq.js +13 -0
- supython/admin/static/assets/format-length-CGCY1rMh.js +2 -0
- supython/admin/static/assets/get-Ca6unauB.js +2 -0
- supython/admin/static/assets/index-CeE6v959.js +951 -0
- supython/admin/static/assets/pinia-COXwfrOX.js +2 -0
- supython/admin/static/assets/resources-Bt6thQCD.js +44 -0
- supython/admin/static/assets/use-locale-mtgM0a3a.js +2 -0
- supython/admin/static/assets/use-merged-state-BvhkaHNX.js +2 -0
- supython/admin/static/assets/useConfirm-tMjvBFXR.js +2 -0
- supython/admin/static/assets/useResource-C_rJCY8C.js +2 -0
- supython/admin/static/assets/useTable-CnZc5zhi.js +363 -0
- supython/admin/static/assets/useTable-Dg0XlRlq.css +1 -0
- supython/admin/static/assets/useToast-DsZKx0IX.js +2 -0
- supython/admin/static/assets/utils-sbXoq7Ir.js +2 -0
- supython/admin/static/favicon.svg +1 -0
- supython/admin/static/icons.svg +24 -0
- supython/admin/static/index.html +24 -0
- supython/app.py +162 -0
- supython/auth/__init__.py +3 -0
- supython/auth/_email_job.py +11 -0
- supython/auth/providers/__init__.py +34 -0
- supython/auth/providers/github.py +22 -0
- supython/auth/providers/google.py +19 -0
- supython/auth/providers/oauth.py +56 -0
- supython/auth/providers/registry.py +16 -0
- supython/auth/ratelimit.py +39 -0
- supython/auth/router.py +282 -0
- supython/auth/schemas.py +79 -0
- supython/auth/service.py +587 -0
- supython/backups/__init__.py +24 -0
- supython/backups/_backup_job.py +170 -0
- supython/backups/schemas.py +18 -0
- supython/backups/service.py +217 -0
- supython/body_size.py +184 -0
- supython/cli.py +1663 -0
- supython/client/__init__.py +67 -0
- supython/client/_auth.py +249 -0
- supython/client/_client.py +145 -0
- supython/client/_config.py +92 -0
- supython/client/_functions.py +69 -0
- supython/client/_storage.py +255 -0
- supython/client/py.typed +0 -0
- supython/db.py +151 -0
- supython/db_admin.py +8 -0
- supython/extensions.py +36 -0
- supython/functions/__init__.py +19 -0
- supython/functions/context.py +262 -0
- supython/functions/loader.py +307 -0
- supython/functions/router.py +228 -0
- supython/functions/schemas.py +50 -0
- supython/gen/__init__.py +5 -0
- supython/gen/_introspect.py +137 -0
- supython/gen/types_py.py +270 -0
- supython/gen/types_ts.py +365 -0
- supython/health.py +229 -0
- supython/hooks.py +117 -0
- supython/jobs/__init__.py +31 -0
- supython/jobs/backends.py +97 -0
- supython/jobs/context.py +58 -0
- supython/jobs/cron.py +152 -0
- supython/jobs/cron_inproc.py +119 -0
- supython/jobs/decorators.py +76 -0
- supython/jobs/registry.py +79 -0
- supython/jobs/router.py +136 -0
- supython/jobs/schemas.py +92 -0
- supython/jobs/service.py +311 -0
- supython/jobs/worker.py +219 -0
- supython/jwks.py +257 -0
- supython/keyset.py +279 -0
- supython/logging_config.py +291 -0
- supython/mail.py +33 -0
- supython/mailer.py +65 -0
- supython/migrate.py +81 -0
- supython/migrations/0001_extensions_and_roles.sql +46 -0
- supython/migrations/0002_auth_schema.sql +66 -0
- supython/migrations/0003_demo_todos.sql +42 -0
- supython/migrations/0004_auth_v0_2.sql +47 -0
- supython/migrations/0005_storage_schema.sql +117 -0
- supython/migrations/0006_realtime_schema.sql +206 -0
- supython/migrations/0007_jobs_schema.sql +254 -0
- supython/migrations/0008_jobs_last_error.sql +56 -0
- supython/migrations/0009_auth_rate_limits.sql +33 -0
- supython/migrations/0010_worker_heartbeat.sql +14 -0
- supython/migrations/0011_admin_schema.sql +45 -0
- supython/migrations/0012_auth_banned_until.sql +10 -0
- supython/migrations/0013_email_templates.sql +19 -0
- supython/migrations/0014_realtime_payload_warning.sql +96 -0
- supython/migrations/0015_backups_schema.sql +14 -0
- supython/passwords.py +15 -0
- supython/realtime/__init__.py +6 -0
- supython/realtime/broker.py +814 -0
- supython/realtime/protocol.py +234 -0
- supython/realtime/router.py +184 -0
- supython/realtime/schemas.py +207 -0
- supython/realtime/service.py +261 -0
- supython/realtime/topics.py +175 -0
- supython/realtime/websocket.py +586 -0
- supython/scaffold/__init__.py +5 -0
- supython/scaffold/init_project.py +144 -0
- supython/scaffold/templates/Caddyfile.tmpl +4 -0
- supython/scaffold/templates/README.md.tmpl +22 -0
- supython/scaffold/templates/apps_hooks.py.tmpl +11 -0
- supython/scaffold/templates/apps_jobs.py.tmpl +8 -0
- supython/scaffold/templates/asgi.py.tmpl +14 -0
- supython/scaffold/templates/docker-compose.prod.yml.tmpl +84 -0
- supython/scaffold/templates/docker-compose.yml.tmpl +45 -0
- supython/scaffold/templates/docker_postgres_Dockerfile.tmpl +9 -0
- supython/scaffold/templates/docker_postgres_postgresql.conf.tmpl +3 -0
- supython/scaffold/templates/env.example.tmpl +168 -0
- supython/scaffold/templates/functions_README.md.tmpl +21 -0
- supython/scaffold/templates/gitignore.tmpl +14 -0
- supython/scaffold/templates/manage.py.tmpl +11 -0
- supython/scaffold/templates/migrations/.gitkeep +0 -0
- supython/scaffold/templates/package_init.py.tmpl +1 -0
- supython/scaffold/templates/settings.py.tmpl +31 -0
- supython/secretset.py +347 -0
- supython/security_headers.py +78 -0
- supython/settings.py +244 -0
- supython/settings_module.py +117 -0
- supython/storage/__init__.py +5 -0
- supython/storage/backends.py +392 -0
- supython/storage/router.py +341 -0
- supython/storage/schemas.py +50 -0
- supython/storage/service.py +445 -0
- supython/storage/signing.py +119 -0
- supython/tokens.py +85 -0
- supython-0.1.0.dist-info/METADATA +756 -0
- supython-0.1.0.dist-info/RECORD +200 -0
- supython-0.1.0.dist-info/WHEEL +4 -0
- supython-0.1.0.dist-info/entry_points.txt +2 -0
- supython-0.1.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
|
+
)
|