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 +5 -0
- fyro/admin/__init__.py +50 -0
- fyro/admin/forms.py +137 -0
- fyro/admin/routes.py +435 -0
- fyro/admin/static/admin.css +147 -0
- fyro/admin/static/htmx.LICENSE +16 -0
- fyro/admin/static/htmx.min.js +1 -0
- fyro/admin/static/jetbrains-mono.LICENSE +97 -0
- fyro/admin/static/jetbrains-mono.woff2 +0 -0
- fyro/admin/templates/base.html +33 -0
- fyro/admin/templates/confirm_delete.html +16 -0
- fyro/admin/templates/forbidden.html +9 -0
- fyro/admin/templates/form.html +37 -0
- fyro/admin/templates/index.html +12 -0
- fyro/admin/templates/list.html +64 -0
- fyro/admin/templates/login.html +12 -0
- fyro/app.py +102 -0
- fyro/apps.py +100 -0
- fyro/auth/__init__.py +6 -0
- fyro/auth/models.py +35 -0
- fyro/auth/passwords.py +20 -0
- fyro/auth/routes.py +45 -0
- fyro/auth/sessions.py +130 -0
- fyro/cli.py +395 -0
- fyro/conf.py +121 -0
- fyro/crud.py +193 -0
- fyro/db/__init__.py +12 -0
- fyro/db/engine.py +47 -0
- fyro/db/session.py +81 -0
- fyro/migrations/__init__.py +279 -0
- fyro/migrations/template/env.py +34 -0
- fyro/migrations/template/script.py.mako +28 -0
- fyro/models/__init__.py +4 -0
- fyro/models/base.py +281 -0
- fyro/models/fields.py +62 -0
- fyro/models/manager.py +128 -0
- fyro/models/schemas.py +57 -0
- fyro-0.1.0.dist-info/METADATA +101 -0
- fyro-0.1.0.dist-info/RECORD +42 -0
- fyro-0.1.0.dist-info/WHEEL +4 -0
- fyro-0.1.0.dist-info/entry_points.txt +3 -0
- fyro-0.1.0.dist-info/licenses/LICENSE +21 -0
fyro/__init__.py
ADDED
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)
|