fyro 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.
fyro/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from fyro.app import create_app
2
+ from fyro.conf import Settings
3
+ from fyro.models import Field, Model
4
+
5
+ __all__ = ["Field", "Model", "Settings", "create_app"]
fyro/admin/__init__.py ADDED
@@ -0,0 +1,50 @@
1
+ """The admin: /admin pages for superusers, mounted by create_app() when fyro.auth is installed."""
2
+
3
+ from pathlib import Path
4
+
5
+ from fastapi import FastAPI, Request
6
+ from fastapi.staticfiles import StaticFiles
7
+ from starlette.datastructures import MutableHeaders
8
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
9
+
10
+ from fyro.admin.routes import AdminResponse, router
11
+
12
+ STATIC_DIR = Path(__file__).parent / "static"
13
+ SECURITY_HEADERS = {
14
+ "Content-Security-Policy": "default-src 'self'; frame-ancestors 'none'",
15
+ "X-Frame-Options": "DENY",
16
+ "Referrer-Policy": "same-origin",
17
+ }
18
+
19
+
20
+ class AdminSecurityHeaders:
21
+ """Adds the admin's security headers to every /admin response (pure ASGI)."""
22
+
23
+ def __init__(self, app: ASGIApp) -> None:
24
+ self.app = app
25
+
26
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
27
+ if scope["type"] != "http" or not scope["path"].startswith("/admin"):
28
+ await self.app(scope, receive, send)
29
+ return
30
+
31
+ async def send_with_headers(message: Message) -> None:
32
+ if message["type"] == "http.response.start":
33
+ headers = MutableHeaders(scope=message)
34
+ for name, value in SECURITY_HEADERS.items():
35
+ headers[name] = value
36
+ await send(message)
37
+
38
+ await self.app(scope, receive, send_with_headers)
39
+
40
+
41
+ async def _admin_response(request: Request, exc: Exception) -> object:
42
+ return exc.response # type: ignore[attr-defined]
43
+
44
+
45
+ def mount(app: FastAPI) -> None:
46
+ # Static first: /admin/static/... must not match the /admin/{app}/{model} page route.
47
+ app.mount("/admin/static", StaticFiles(directory=STATIC_DIR), name="admin-static")
48
+ app.include_router(router)
49
+ app.add_exception_handler(AdminResponse, _admin_response)
50
+ app.add_middleware(AdminSecurityHeaders)
fyro/admin/forms.py ADDED
@@ -0,0 +1,137 @@
1
+ """Turn model fields into HTML form widgets, and submitted form data back into schema input."""
2
+
3
+ import enum
4
+ from dataclasses import dataclass, field
5
+ from datetime import UTC, date, datetime
6
+ from decimal import Decimal
7
+ from typing import Any
8
+
9
+ from pydantic import BaseModel, ValidationError
10
+
11
+ from fyro.models.base import _unwrap_optional
12
+ from fyro.models.fields import MISSING
13
+
14
+
15
+ @dataclass
16
+ class FormField:
17
+ name: str
18
+ label: str
19
+ widget: str # text | textarea | checkbox | number | date | datetime-local | select | password
20
+ value: str = ""
21
+ checked: bool = False
22
+ options: list[tuple[str, str]] = field(default_factory=list)
23
+ required: bool = False
24
+ readonly: bool = False
25
+ help: str = ""
26
+ error: str = ""
27
+
28
+
29
+ def label(name: str) -> str:
30
+ return name.replace("_", " ").capitalize()
31
+
32
+
33
+ def widget_for(tp: Any, info: Any) -> str:
34
+ if tp is bool:
35
+ return "checkbox"
36
+ if isinstance(tp, type) and issubclass(tp, enum.Enum):
37
+ return "select"
38
+ if tp in (int, float, Decimal):
39
+ return "number"
40
+ if tp is datetime:
41
+ return "datetime-local"
42
+ if tp is date:
43
+ return "date"
44
+ if tp is str and info.max_length is None:
45
+ return "textarea"
46
+ return "text"
47
+
48
+
49
+ def as_input(value: Any) -> str:
50
+ """A Python value as the string an <input> holds."""
51
+ if value is None:
52
+ return ""
53
+ if isinstance(value, enum.Enum):
54
+ return str(value.value)
55
+ if isinstance(value, datetime):
56
+ return value.astimezone(UTC).strftime("%Y-%m-%dT%H:%M")
57
+ if isinstance(value, date):
58
+ return value.isoformat()
59
+ return str(value)
60
+
61
+
62
+ def display(value: Any) -> str:
63
+ """A Python value as list-table text."""
64
+ if value is None:
65
+ return "—"
66
+ if isinstance(value, bool):
67
+ return "yes" if value else "no"
68
+ if isinstance(value, datetime):
69
+ return value.astimezone(UTC).strftime("%Y-%m-%d %H:%M UTC")
70
+ text = as_input(value)
71
+ return text if len(text) <= 80 else text[:79] + "…"
72
+
73
+
74
+ def build_fields(
75
+ model: Any, schema: type[BaseModel], instance: Any = None, *, editing: bool = False
76
+ ) -> list[FormField]:
77
+ """Editable fields come from the schema; read_only ones are shown, never submitted."""
78
+ fields: list[FormField] = []
79
+ for name, info in model.__fyro_fields__.items():
80
+ if name in schema.model_fields:
81
+ tp, nullable = _unwrap_optional(schema.model_fields[name].annotation)
82
+ widget = widget_for(tp, info)
83
+ current = getattr(instance, name) if instance is not None else None
84
+ if instance is None and info.default is not MISSING:
85
+ current = info.default
86
+ form_field = FormField(
87
+ name=name,
88
+ label=label(name),
89
+ widget=widget,
90
+ value="" if info.write_only else as_input(current),
91
+ checked=bool(current) if widget == "checkbox" else False,
92
+ options=[(str(m.value), m.name.title()) for m in tp] if widget == "select" else [],
93
+ required=not editing and not nullable and widget != "checkbox"
94
+ and info.default is MISSING and info.default_factory is None,
95
+ help="Leave blank to keep the current value." if editing and info.write_only else "",
96
+ )
97
+ fields.append(form_field)
98
+ elif instance is not None and info.read_only and not info.write_only:
99
+ fields.append(
100
+ FormField(
101
+ name=name, label=label(name), widget="text",
102
+ value=display(getattr(instance, name)), readonly=True,
103
+ )
104
+ )
105
+ return fields
106
+
107
+
108
+ def parse(
109
+ model: Any, schema: type[BaseModel], form: dict[str, list[str]], *, editing: bool
110
+ ) -> tuple[dict[str, Any], dict[str, str]]:
111
+ """Validate submitted form data with `schema`. Returns (values, errors by field)."""
112
+ raw: dict[str, Any] = {}
113
+ for name, schema_field in schema.model_fields.items():
114
+ info = model.__fyro_fields__[name]
115
+ tp, nullable = _unwrap_optional(schema_field.annotation)
116
+ if tp is bool:
117
+ raw[name] = name in form # an unchecked checkbox is simply absent
118
+ continue
119
+ value = form.get(name, [""])[0]
120
+ if value == "":
121
+ if editing and info.write_only:
122
+ continue # blank means "keep the current value"
123
+ if nullable:
124
+ raw[name] = None
125
+ elif tp is str and editing:
126
+ raw[name] = ""
127
+ continue # create: let the schema apply its default or report "Field required"
128
+ raw[name] = value
129
+ try:
130
+ validated = schema.model_validate(raw)
131
+ except ValidationError as error:
132
+ return {}, {str(e["loc"][0]) if e["loc"] else "__all__": e["msg"] for e in error.errors()}
133
+ values = validated.model_dump(exclude_unset=True)
134
+ for name, value in values.items():
135
+ if isinstance(value, datetime) and value.tzinfo is None:
136
+ values[name] = value.replace(tzinfo=UTC) # datetime-local has no zone: treat as UTC
137
+ return values, {}
fyro/admin/routes.py ADDED
@@ -0,0 +1,435 @@
1
+ """Server-rendered admin pages. Reads and writes go through the same query code, schemas and
2
+ manager as auto CRUD; HTMX (hx-boost) turns links and forms into partial page updates."""
3
+
4
+ import enum
5
+ import json
6
+ import math
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Annotated, Any
10
+ from urllib.parse import parse_qs, quote, urlencode
11
+
12
+ from fastapi import APIRouter, Depends, HTTPException, Request, Response
13
+ from fastapi.responses import HTMLResponse
14
+ from fastapi.templating import Jinja2Templates
15
+ from sqlalchemy.exc import IntegrityError
16
+
17
+ from fyro import apps
18
+ from fyro.admin import forms
19
+ from fyro.crud import list_query, unique_violation
20
+ from fyro.models.base import _snake, _unwrap_optional
21
+
22
+ TEMPLATES = Jinja2Templates(directory=Path(__file__).parent / "templates")
23
+ PAGE_SIZE = 50
24
+ # Strict CSP: no eval, no inline styles or scripts. 409/422 bodies are swapped so forms can
25
+ # show their errors; other 4xx/5xx are not.
26
+ HTMX_CONFIG = json.dumps(
27
+ {
28
+ "includeIndicatorStyles": False,
29
+ "allowEval": False,
30
+ "allowScriptTags": False,
31
+ "responseHandling": [
32
+ {"code": "204", "swap": False},
33
+ {"code": "[23]..", "swap": True},
34
+ {"code": "(403|409|422)", "swap": True, "error": False},
35
+ {"code": "[45]..", "swap": False, "error": True},
36
+ ],
37
+ }
38
+ )
39
+
40
+ router = APIRouter(prefix="/admin", include_in_schema=False)
41
+
42
+
43
+ class AdminResponse(Exception):
44
+ """Raised by the access check to answer with a redirect or a page."""
45
+
46
+ def __init__(self, response: Response) -> None:
47
+ self.response = response
48
+
49
+
50
+ @dataclass
51
+ class Entry:
52
+ model: Any
53
+ app_label: str
54
+ segment: str
55
+
56
+ @property
57
+ def url(self) -> str:
58
+ return f"/admin/{self.app_label}/{self.segment}"
59
+
60
+ @property
61
+ def plural(self) -> str:
62
+ return self.model.__name__ + "s"
63
+
64
+ @property
65
+ def is_user(self) -> bool:
66
+ return self.model.__module__ == "fyro.auth.models" and self.model.__name__ == "User"
67
+
68
+
69
+ def entries() -> list[Entry]:
70
+ return [
71
+ Entry(model, app.label, _snake(model.__name__))
72
+ for app in apps.registry.apps.values()
73
+ for model in app.models.values()
74
+ if model.__fyro_admin__
75
+ ]
76
+
77
+
78
+ def find_entry(app_label: str, segment: str) -> Entry:
79
+ for entry in entries():
80
+ if (entry.app_label, entry.segment) == (app_label, segment):
81
+ return entry
82
+ raise HTTPException(404, "Not found.")
83
+
84
+
85
+ def render(request: Request, template: str, user: Any, status: int = 200, **context: Any) -> HTMLResponse:
86
+ nav: dict[str, list[Entry]] = {}
87
+ for entry in entries():
88
+ nav.setdefault(entry.app_label, []).append(entry)
89
+ return TEMPLATES.TemplateResponse(
90
+ request,
91
+ template,
92
+ {"user": user, "nav": nav, "htmx_config": HTMX_CONFIG, **context},
93
+ status_code=status,
94
+ )
95
+
96
+
97
+ def redirect(url: str) -> Response:
98
+ """HTMX follows HX-Redirect with a full page load."""
99
+ return Response(status_code=200, headers={"HX-Redirect": url})
100
+
101
+
102
+ async def form_data(request: Request) -> dict[str, list[str]]:
103
+ # parse_qs instead of request.form(): no python-multipart dependency for urlencoded forms
104
+ return parse_qs((await request.body()).decode(), keep_blank_values=True)
105
+
106
+
107
+ async def superuser(request: Request) -> Any:
108
+ """Access check: anonymous -> login page, non-superuser -> 403, writes need HX-Request."""
109
+ from fyro.auth import optional_user
110
+
111
+ user = await optional_user(request) # raises 403 for cookie writes without the CSRF header
112
+ if user is None:
113
+ login = "/admin/login?next=" + quote(request.url.path, safe="")
114
+ raise AdminResponse(Response(status_code=303, headers={"Location": login}))
115
+ if not user.is_superuser:
116
+ raise AdminResponse(render(request, "forbidden.html", None, status=403))
117
+ return user
118
+
119
+
120
+ Superuser = Annotated[Any, Depends(superuser)]
121
+
122
+
123
+ def safe_next(url: str) -> str:
124
+ """Only redirect within the admin after login (no open redirect)."""
125
+ return url if url.startswith("/admin") and not url.startswith("//") else "/admin"
126
+
127
+
128
+ def parse_pk(entry: Entry, raw: str) -> Any:
129
+ column = next(iter(entry.model.__table__.primary_key.columns))
130
+ try:
131
+ return column.type.python_type(raw)
132
+ except (TypeError, ValueError) as error:
133
+ raise HTTPException(404, "Not found.") from error
134
+
135
+
136
+ def pk_name(entry: Entry) -> str:
137
+ return next(iter(entry.model.__table__.primary_key.columns)).key
138
+
139
+
140
+ async def get_row(entry: Entry, pk: Any) -> Any:
141
+ row = await entry.model.objects.filter(**{pk_name(entry): pk}).first()
142
+ if row is None:
143
+ raise HTTPException(404, "Not found.")
144
+ return row
145
+
146
+
147
+ # --- login / logout --------------------------------------------------------
148
+
149
+
150
+ @router.get("/login")
151
+ async def login_page(request: Request, next: str = "/admin") -> HTMLResponse:
152
+ return render(request, "login.html", None, next=safe_next(next))
153
+
154
+
155
+ @router.post("/login")
156
+ async def login(request: Request) -> Response:
157
+ from fyro.auth.sessions import authenticate, create_session, set_cookie
158
+
159
+ data = await form_data(request)
160
+ username, password = data.get("username", [""])[0], data.get("password", [""])[0]
161
+ next_url = safe_next(data.get("next", ["/admin"])[0])
162
+ user = await authenticate(username, password)
163
+ if user is None:
164
+ return render(
165
+ request, "login.html", None, status=401, next=next_url, username=username,
166
+ error="Invalid username or password.",
167
+ )
168
+ token, _ = await create_session(user)
169
+ response = redirect(next_url)
170
+ set_cookie(response, token)
171
+ return response
172
+
173
+
174
+ @router.post("/logout")
175
+ async def logout(request: Request, user: Superuser) -> Response:
176
+ from fyro.auth.sessions import clear_cookie, end_session
177
+
178
+ await end_session(request)
179
+ response = redirect("/admin/login")
180
+ clear_cookie(response)
181
+ return response
182
+
183
+
184
+ # --- pages -----------------------------------------------------------------
185
+
186
+
187
+ @router.get("")
188
+ async def index(request: Request, user: Superuser) -> HTMLResponse:
189
+ counts = {entry.url: await entry.model.objects.count() for entry in entries()}
190
+ return render(request, "index.html", user, counts=counts)
191
+
192
+
193
+ def filter_options(entry: Entry) -> list[tuple[str, list[tuple[str, str]]]]:
194
+ """Sidebar filters: bool and Enum fields that are listed in the table's Read schema."""
195
+ options = []
196
+ for name in entry.model.Read.model_fields:
197
+ if name not in entry.model.__fyro_fields__:
198
+ continue
199
+ tp, _ = _unwrap_optional(entry.model.Read.model_fields[name].annotation)
200
+ if tp is bool:
201
+ options.append((name, [("true", "Yes"), ("false", "No")]))
202
+ elif isinstance(tp, type) and issubclass(tp, enum.Enum):
203
+ options.append((name, [(str(m.value), m.name.title()) for m in tp]))
204
+ return options
205
+
206
+
207
+ @router.get("/{app_label}/{segment}")
208
+ async def list_page(
209
+ request: Request, app_label: str, segment: str, user: Superuser
210
+ ) -> HTMLResponse:
211
+ entry = find_entry(app_label, segment)
212
+ params = request.query_params
213
+ search, order_by = params.get("search", ""), params.get("order_by", "")
214
+ page = max(int(params.get("page", "1")) if params.get("page", "").isdigit() else 1, 1)
215
+ filters, active = {}, {}
216
+ for name, choices in filter_options(entry):
217
+ value = params.get(name)
218
+ if value in {v for v, _ in choices}:
219
+ active[name] = value
220
+ filters[name] = value == "true" if choices[0][0] == "true" else value
221
+ try:
222
+ qs = list_query(entry.model, filters, search or None, order_by or None)
223
+ except ValueError:
224
+ order_by = ""
225
+ qs = list_query(entry.model, filters, search or None)
226
+ total = await qs.count()
227
+ rows = await qs.limit(PAGE_SIZE).offset((page - 1) * PAGE_SIZE).all()
228
+ state = {"search": search, "order_by": order_by, **active}
229
+
230
+ def url(**changes: Any) -> str:
231
+ merged = {k: v for k, v in {**state, **changes}.items() if v not in ("", None)}
232
+ if merged.get("page") == 1:
233
+ del merged["page"]
234
+ return entry.url + ("?" + urlencode(merged) if merged else "")
235
+
236
+ columns = []
237
+ for name in entry.model.__fyro_list_display__:
238
+ flipped = name if order_by != name else f"-{name}"
239
+ arrow = " ↑" if order_by == name else " ↓" if order_by == f"-{name}" else ""
240
+ columns.append({"label": forms.label(name), "sort_url": url(order_by=flipped, page=1), "arrow": arrow})
241
+ pk = pk_name(entry)
242
+ table = [
243
+ {"pk": getattr(row, pk), "cells": [forms.display(getattr(row, n)) for n in entry.model.__fyro_list_display__]}
244
+ for row in rows
245
+ ]
246
+ pages = max(math.ceil(total / PAGE_SIZE), 1)
247
+ return render(
248
+ request, "list.html", user, entry=entry, columns=columns, rows=table, total=total,
249
+ page=page, pages=pages, search=search, url=url, active=active,
250
+ filters=filter_options(entry), keep=[(k, v) for k, v in state.items() if k != "search" and v],
251
+ searchable=bool(entry.model.__fyro_search_fields__),
252
+ )
253
+
254
+
255
+ @router.get("/{app_label}/{segment}/add")
256
+ async def add_page(
257
+ request: Request, app_label: str, segment: str, user: Superuser
258
+ ) -> HTMLResponse:
259
+ entry = find_entry(app_label, segment)
260
+ return render(
261
+ request, "form.html", user, entry=entry,
262
+ fields=add_fields(entry), action=f"{entry.url}/add", title=f"Add {entry.model.__name__}",
263
+ )
264
+
265
+
266
+ def add_fields(entry: Entry, errors: dict[str, str] | None = None, form: Any = None) -> list[forms.FormField]:
267
+ fields = forms.build_fields(entry.model, entry.model.Create)
268
+ if entry.is_user:
269
+ fields += [
270
+ forms.FormField(name="password", label="Password", widget="password", required=True),
271
+ forms.FormField(name="password2", label="Password (again)", widget="password", required=True),
272
+ ]
273
+ return with_errors(fields, errors or {}, form)
274
+
275
+
276
+ def edit_fields(entry: Entry, row: Any, errors: dict[str, str] | None = None, form: Any = None) -> list[forms.FormField]:
277
+ fields = forms.build_fields(entry.model, entry.model.Update, row, editing=True)
278
+ if entry.is_user:
279
+ fields.append(
280
+ forms.FormField(
281
+ name="new_password", label="New password", widget="password",
282
+ help="Leave blank to keep the current password. Changing it logs the user out everywhere.",
283
+ )
284
+ )
285
+ return with_errors(fields, errors or {}, form)
286
+
287
+
288
+ def with_errors(fields: list[forms.FormField], errors: dict[str, str], form: Any) -> list[forms.FormField]:
289
+ """Attach errors and keep what the user typed (except passwords)."""
290
+ for form_field in fields:
291
+ form_field.error = errors.get(form_field.name, "")
292
+ if form is not None and not form_field.readonly and form_field.widget != "password":
293
+ if form_field.widget == "checkbox":
294
+ form_field.checked = form_field.name in form
295
+ else:
296
+ form_field.value = form.get(form_field.name, [""])[0]
297
+ return fields
298
+
299
+
300
+ @router.post("/{app_label}/{segment}/add")
301
+ async def add(
302
+ request: Request, app_label: str, segment: str, user: Superuser
303
+ ) -> Response:
304
+ entry = find_entry(app_label, segment)
305
+ form = await form_data(request)
306
+ values, errors = forms.parse(entry.model, entry.model.Create, form, editing=False)
307
+ if entry.is_user and not errors:
308
+ password, again = form.get("password", [""])[0], form.get("password2", [""])[0]
309
+ if not password:
310
+ errors["password"] = "This field is required."
311
+ elif password != again:
312
+ errors["password2"] = "Passwords do not match."
313
+ else:
314
+ from fyro.auth.passwords import hash_password
315
+
316
+ values["password"] = hash_password(password)
317
+ status = 422
318
+ if not errors:
319
+ try:
320
+ await entry.model.objects.create(**values)
321
+ return redirect(entry.url)
322
+ except IntegrityError as error:
323
+ status, errors = conflict_errors(entry, error)
324
+ return render(
325
+ request, "form.html", user, status=status, entry=entry,
326
+ fields=add_fields(entry, errors, form), action=f"{entry.url}/add",
327
+ title=f"Add {entry.model.__name__}", error=errors.get("__all__", ""),
328
+ )
329
+
330
+
331
+ def conflict_errors(entry: Entry, error: IntegrityError) -> tuple[int, dict[str, str]]:
332
+ columns, message = unique_violation(entry.model, error)
333
+ return 409, {(columns[0] if len(columns) == 1 else "__all__"): message}
334
+
335
+
336
+ @router.post("/{app_label}/{segment}/bulk-delete")
337
+ async def bulk_delete(
338
+ request: Request, app_label: str, segment: str, user: Superuser
339
+ ) -> Response:
340
+ entry = find_entry(app_label, segment)
341
+ form = await form_data(request)
342
+ pks = [parse_pk(entry, raw) for raw in form.get("ids", [])]
343
+ rows = [row for pk in pks if (row := await entry.model.objects.filter(**{pk_name(entry): pk}).first())]
344
+ if entry.is_user and user.id in pks:
345
+ return confirm(request, user, entry, rows, status=422, error="You cannot delete yourself.")
346
+ if form.get("confirm", [""])[0] != "yes" or not rows:
347
+ return confirm(request, user, entry, rows)
348
+ for row in rows:
349
+ await entry.model.objects.filter(**{pk_name(entry): getattr(row, pk_name(entry))}).delete()
350
+ return redirect(entry.url)
351
+
352
+
353
+ def confirm(request: Request, user: Any, entry: Entry, rows: list[Any], status: int = 200, error: str = "") -> HTMLResponse:
354
+ pk = pk_name(entry)
355
+ shown = entry.model.__fyro_list_display__
356
+ return render(
357
+ request, "confirm_delete.html", user, status=status, entry=entry, error=error,
358
+ items=[" · ".join(forms.display(getattr(row, n)) for n in shown) for row in rows],
359
+ ids=[getattr(row, pk) for row in rows],
360
+ action=f"{entry.url}/bulk-delete",
361
+ )
362
+
363
+
364
+ @router.get("/{app_label}/{segment}/{pk}")
365
+ async def edit_page(
366
+ request: Request, app_label: str, segment: str, pk: str, user: Superuser
367
+ ) -> HTMLResponse:
368
+ entry = find_entry(app_label, segment)
369
+ row = await get_row(entry, parse_pk(entry, pk))
370
+ return render(
371
+ request, "form.html", user, entry=entry, fields=edit_fields(entry, row),
372
+ action=f"{entry.url}/{pk}", title=f"Edit {entry.model.__name__} {pk}",
373
+ delete_url=f"{entry.url}/{pk}/delete",
374
+ )
375
+
376
+
377
+ @router.post("/{app_label}/{segment}/{pk}")
378
+ async def edit(
379
+ request: Request, app_label: str, segment: str, pk: str, user: Superuser
380
+ ) -> Response:
381
+ entry = find_entry(app_label, segment)
382
+ pk_value = parse_pk(entry, pk)
383
+ row = await get_row(entry, pk_value)
384
+ form = await form_data(request)
385
+ values, errors = forms.parse(entry.model, entry.model.Update, form, editing=True)
386
+ ends_sessions = False
387
+ if entry.is_user and not errors:
388
+ if pk_value == user.id and (not values.get("is_active") or not values.get("is_superuser")):
389
+ errors["__all__"] = "You cannot deactivate or remove superuser status from yourself."
390
+ new_password = form.get("new_password", [""])[0]
391
+ if new_password:
392
+ from fyro.auth.passwords import hash_password
393
+
394
+ values["password"] = hash_password(new_password)
395
+ ends_sessions = bool(new_password) or (row.is_active and not values.get("is_active"))
396
+ status = 422
397
+ if not errors:
398
+ try:
399
+ if values:
400
+ await entry.model.objects.filter(**{pk_name(entry): pk_value}).update(**values)
401
+ if ends_sessions:
402
+ from fyro.auth.sessions import end_user_sessions
403
+
404
+ await end_user_sessions(pk_value)
405
+ return redirect(entry.url)
406
+ except IntegrityError as error:
407
+ status, errors = conflict_errors(entry, error)
408
+ return render(
409
+ request, "form.html", user, status=status, entry=entry,
410
+ fields=edit_fields(entry, row, errors, form), action=f"{entry.url}/{pk}",
411
+ title=f"Edit {entry.model.__name__} {pk}", delete_url=f"{entry.url}/{pk}/delete",
412
+ error=errors.get("__all__", ""),
413
+ )
414
+
415
+
416
+ @router.get("/{app_label}/{segment}/{pk}/delete")
417
+ async def delete_page(
418
+ request: Request, app_label: str, segment: str, pk: str, user: Superuser
419
+ ) -> HTMLResponse:
420
+ entry = find_entry(app_label, segment)
421
+ row = await get_row(entry, parse_pk(entry, pk))
422
+ return confirm(request, user, entry, [row])
423
+
424
+
425
+ @router.post("/{app_label}/{segment}/{pk}/delete")
426
+ async def delete(
427
+ request: Request, app_label: str, segment: str, pk: str, user: Superuser
428
+ ) -> Response:
429
+ entry = find_entry(app_label, segment)
430
+ pk_value = parse_pk(entry, pk)
431
+ row = await get_row(entry, pk_value)
432
+ if entry.is_user and pk_value == user.id:
433
+ return confirm(request, user, entry, [row], status=422, error="You cannot delete yourself.")
434
+ await entry.model.objects.filter(**{pk_name(entry): pk_value}).delete()
435
+ return redirect(entry.url)