fastapi-panel 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.
- fastapi_panel/__init__.py +77 -0
- fastapi_panel/__main__.py +97 -0
- fastapi_panel/auth.py +189 -0
- fastapi_panel/db.py +41 -0
- fastapi_panel/filters.py +215 -0
- fastapi_panel/forms.py +733 -0
- fastapi_panel/imports.py +264 -0
- fastapi_panel/introspect.py +111 -0
- fastapi_panel/log.py +52 -0
- fastapi_panel/options.py +717 -0
- fastapi_panel/py.typed +0 -0
- fastapi_panel/security.py +139 -0
- fastapi_panel/site.py +602 -0
- fastapi_panel/static/panel.css +420 -0
- fastapi_panel/static/panel.js +404 -0
- fastapi_panel/static/theme.js +5 -0
- fastapi_panel/storage.py +38 -0
- fastapi_panel/templates/fastapi_panel/base.html +76 -0
- fastapi_panel/templates/fastapi_panel/delete.html +25 -0
- fastapi_panel/templates/fastapi_panel/error.html +10 -0
- fastapi_panel/templates/fastapi_panel/form.html +113 -0
- fastapi_panel/templates/fastapi_panel/history.html +23 -0
- fastapi_panel/templates/fastapi_panel/import.html +76 -0
- fastapi_panel/templates/fastapi_panel/index.html +43 -0
- fastapi_panel/templates/fastapi_panel/list.html +131 -0
- fastapi_panel/templates/fastapi_panel/login.html +15 -0
- fastapi_panel/templates/fastapi_panel/password.html +17 -0
- fastapi_panel/templates/fastapi_panel/popup_done.html +4 -0
- fastapi_panel/templates/fastapi_panel/widgets.html +74 -0
- fastapi_panel/views.py +710 -0
- fastapi_panel-0.1.0.dist-info/METADATA +151 -0
- fastapi_panel-0.1.0.dist-info/RECORD +34 -0
- fastapi_panel-0.1.0.dist-info/WHEEL +4 -0
- fastapi_panel-0.1.0.dist-info/licenses/LICENSE +21 -0
fastapi_panel/imports.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import io
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
from sqlalchemy.exc import DBAPIError
|
|
9
|
+
from starlette.datastructures import UploadFile
|
|
10
|
+
from starlette.exceptions import HTTPException
|
|
11
|
+
from starlette.responses import Response
|
|
12
|
+
|
|
13
|
+
from .forms import BooleanField, ManyToManyField
|
|
14
|
+
from .introspect import humanize, pk_criterion, pk_keys
|
|
15
|
+
from .log import record
|
|
16
|
+
from .options import ModelAdmin
|
|
17
|
+
from .views import _root, redirect
|
|
18
|
+
|
|
19
|
+
FALSE_WORDS = {"", "0", "false", "no", "off", "n", "f"}
|
|
20
|
+
FORMULA_START = ("=", "+", "-", "@", "\t", "\r")
|
|
21
|
+
PREVIEW_ROWS = 50
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ImportProblem(Exception):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RowData(dict):
|
|
29
|
+
"""Looks like submitted form data, so an imported row takes the same path as a saved form."""
|
|
30
|
+
|
|
31
|
+
def getlist(self, name: str) -> list[str]:
|
|
32
|
+
return self.get(name) or []
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def undo_csv_safe(text: str) -> str:
|
|
36
|
+
return text[1:] if text[:1] == "'" and text[1:2] in FORMULA_START else text
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def form_input(field: Any, text: str) -> Any:
|
|
40
|
+
"""Turns what an export wrote, like "Yes" or a category's name, back into what the form expects."""
|
|
41
|
+
text = undo_csv_safe(text.strip())
|
|
42
|
+
if isinstance(field, BooleanField):
|
|
43
|
+
return "" if text.lower() in FALSE_WORDS else "on"
|
|
44
|
+
labels = {label.lower(): value for value, label in field.choices}
|
|
45
|
+
if isinstance(field, ManyToManyField):
|
|
46
|
+
parts = [part.strip() for part in text.split(",") if part.strip()]
|
|
47
|
+
return [labels.get(part.lower(), part) for part in parts]
|
|
48
|
+
return labels.get(text.lower(), text)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def parse_csv(admin: ModelAdmin, raw: bytes) -> list[tuple[int, list[str]]]:
|
|
52
|
+
try:
|
|
53
|
+
text = raw.decode("utf-8-sig")
|
|
54
|
+
except UnicodeDecodeError:
|
|
55
|
+
raise ImportProblem("This file isn't UTF-8. Save it as “CSV UTF-8” and try again.") from None
|
|
56
|
+
first_line = text.split("\n", 1)[0]
|
|
57
|
+
delimiter = max((",", ";", "\t"), key=first_line.count)
|
|
58
|
+
reader = csv.reader(io.StringIO(text, newline=""), delimiter=delimiter)
|
|
59
|
+
try:
|
|
60
|
+
rows = [(reader.line_num, row) for row in reader if any(cell.strip() for cell in row)]
|
|
61
|
+
except csv.Error as exc:
|
|
62
|
+
raise ImportProblem(f"This file can't be read as CSV: {exc}.") from None
|
|
63
|
+
if len(rows) < 2:
|
|
64
|
+
raise ImportProblem("This file has no rows to import. The first line must name the columns.")
|
|
65
|
+
if len(rows) - 1 > admin.import_max_rows:
|
|
66
|
+
raise ImportProblem(f"Import up to {admin.import_max_rows:,} rows at a time.")
|
|
67
|
+
return rows
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def match_columns(
|
|
71
|
+
admin: ModelAdmin, request: Any, header: list[str]
|
|
72
|
+
) -> tuple[dict[str, int], dict[str, int], list[str]]:
|
|
73
|
+
"""Which position holds each field and primary key, by field name or by the label an export uses."""
|
|
74
|
+
wanted = {}
|
|
75
|
+
for name in admin.get_import_fields(request):
|
|
76
|
+
wanted[name.lower()] = name
|
|
77
|
+
wanted[admin.column_label(name).lower()] = name
|
|
78
|
+
keys = {key.lower(): key for key in pk_keys(admin.model)}
|
|
79
|
+
keys.update({humanize(key).lower(): key for key in pk_keys(admin.model)})
|
|
80
|
+
fields: dict[str, int] = {}
|
|
81
|
+
pks: dict[str, int] = {}
|
|
82
|
+
ignored = []
|
|
83
|
+
for position, title in enumerate(header):
|
|
84
|
+
cleaned = title.strip().lower()
|
|
85
|
+
if cleaned in wanted:
|
|
86
|
+
fields[wanted[cleaned]] = position
|
|
87
|
+
elif cleaned in keys:
|
|
88
|
+
pks[keys[cleaned]] = position
|
|
89
|
+
elif cleaned:
|
|
90
|
+
ignored.append(title.strip())
|
|
91
|
+
if not fields:
|
|
92
|
+
raise ImportProblem("None of the columns match. Download the template to see which ones are expected.")
|
|
93
|
+
return fields, pks, ignored
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def find_existing(
|
|
97
|
+
session: Any, request: Any, admin: ModelAdmin, pks: dict[str, int], cells: list[str]
|
|
98
|
+
) -> tuple[Any, bool]:
|
|
99
|
+
"""(the row, whether the CSV named a primary key). Goes through get_queryset, so scoping still applies."""
|
|
100
|
+
keys = pk_keys(admin.model)
|
|
101
|
+
if set(pks) != set(keys):
|
|
102
|
+
return None, False
|
|
103
|
+
parts = [cells[pks[key]].strip() if pks[key] < len(cells) else "" for key in keys]
|
|
104
|
+
if not all(parts):
|
|
105
|
+
return None, False
|
|
106
|
+
criterion = pk_criterion(admin.model, ",".join(quote(part, safe="") for part in parts))
|
|
107
|
+
return session.scalars(admin.get_queryset(request).where(criterion)).first(), True
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def import_rows(
|
|
111
|
+
session: Any, request: Any, admin: ModelAdmin, rows: list[tuple[int, list[str]]], *, apply: bool
|
|
112
|
+
) -> dict:
|
|
113
|
+
"""Runs every row inside one transaction. Previews roll it back, so what they report is what will happen."""
|
|
114
|
+
fields, pks, ignored = match_columns(admin, request, rows[0][1])
|
|
115
|
+
available, _ = admin._available_fields()
|
|
116
|
+
required = [name for name in admin.get_import_fields(request) if getattr(available.get(name), "required", False)]
|
|
117
|
+
can_add = admin.has_add_permission(request)
|
|
118
|
+
results = []
|
|
119
|
+
counts = {"add": 0, "change": 0, "same": 0, "error": 0}
|
|
120
|
+
|
|
121
|
+
for line, cells in rows[1:]:
|
|
122
|
+
result = {
|
|
123
|
+
"line": line,
|
|
124
|
+
"status": "error",
|
|
125
|
+
"errors": [],
|
|
126
|
+
"cells": [cells[i] if i < len(cells) else "" for i in fields.values()],
|
|
127
|
+
}
|
|
128
|
+
results.append(result)
|
|
129
|
+
problem = _import_row(session, request, admin, fields, pks, cells, required, can_add, available, result, apply)
|
|
130
|
+
counts[result["status"]] += 1
|
|
131
|
+
if problem:
|
|
132
|
+
# A database error ends the transaction, so the rows after it can't be checked.
|
|
133
|
+
result["errors"].append(problem)
|
|
134
|
+
break
|
|
135
|
+
|
|
136
|
+
report = {
|
|
137
|
+
"results": results,
|
|
138
|
+
"counts": counts,
|
|
139
|
+
"columns": [admin.column_label(name) for name in fields],
|
|
140
|
+
"ignored": ignored,
|
|
141
|
+
"is_valid": counts["error"] == 0,
|
|
142
|
+
}
|
|
143
|
+
if apply and report["is_valid"]:
|
|
144
|
+
session.commit()
|
|
145
|
+
else:
|
|
146
|
+
session.rollback()
|
|
147
|
+
return report
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _import_row(session, request, admin, fields, pks, cells, required, can_add, available, result, apply) -> str | None:
|
|
151
|
+
try:
|
|
152
|
+
obj, named = find_existing(session, request, admin, pks, cells)
|
|
153
|
+
except ValueError:
|
|
154
|
+
result["errors"].append("The primary key isn't valid.")
|
|
155
|
+
return None
|
|
156
|
+
if obj is None and named and all(getattr(available.get(key), "generated", True) for key in pks):
|
|
157
|
+
result["errors"].append("No record has this ID, or you can't see it.")
|
|
158
|
+
return None
|
|
159
|
+
if obj is not None and not admin.has_change_permission(request, obj):
|
|
160
|
+
result["errors"].append("You can't change this record.")
|
|
161
|
+
return None
|
|
162
|
+
if obj is None and not can_add:
|
|
163
|
+
result["errors"].append("You can't add records.")
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
present = list(fields)
|
|
167
|
+
names = present if obj is not None else present + [name for name in required if name not in fields]
|
|
168
|
+
target = obj if obj is not None else admin.model()
|
|
169
|
+
form = admin.build_form(session, request, target, persisted=obj is not None, names=names)
|
|
170
|
+
data = RowData()
|
|
171
|
+
for field in form.editable:
|
|
172
|
+
position = fields.get(field.name)
|
|
173
|
+
text = cells[position] if position is not None and position < len(cells) else ""
|
|
174
|
+
data[field.html_name] = form_input(field, text)
|
|
175
|
+
admin.validate_form(session, form, data)
|
|
176
|
+
if not form.is_valid:
|
|
177
|
+
result["errors"] += form.errors + [f"{f.label}: {error}" for f in form.fields for error in f.errors]
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
changed = form.apply(target)
|
|
181
|
+
if obj is not None and not changed:
|
|
182
|
+
result["status"] = "same"
|
|
183
|
+
return None
|
|
184
|
+
try:
|
|
185
|
+
if apply:
|
|
186
|
+
admin.save_model(session, request, target, obj is not None)
|
|
187
|
+
else:
|
|
188
|
+
session.add(target)
|
|
189
|
+
session.flush()
|
|
190
|
+
if apply:
|
|
191
|
+
record(session, request, admin, target, "change" if obj is not None else "add", "Imported from CSV")
|
|
192
|
+
except DBAPIError:
|
|
193
|
+
session.rollback()
|
|
194
|
+
return "The database refused this row, so the rows after it weren't checked."
|
|
195
|
+
result["status"] = "change" if obj is not None else "add"
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _template_response(request: Any, admin: ModelAdmin) -> Response:
|
|
200
|
+
buffer = io.StringIO()
|
|
201
|
+
csv.writer(buffer).writerow(admin.column_label(name) for name in admin.get_import_fields(request))
|
|
202
|
+
return Response(
|
|
203
|
+
buffer.getvalue(),
|
|
204
|
+
media_type="text/csv; charset=utf-8",
|
|
205
|
+
headers={"Content-Disposition": f'attachment; filename="{admin.slug}-template.csv"'},
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def import_view(session: Any, request: Any, admin: ModelAdmin) -> Response:
|
|
210
|
+
if not admin.csv_import:
|
|
211
|
+
raise HTTPException(404, "Not found.")
|
|
212
|
+
admin.require(admin.can_import(request))
|
|
213
|
+
if request.query_params.get("template"):
|
|
214
|
+
return _template_response(request, admin)
|
|
215
|
+
|
|
216
|
+
report = None
|
|
217
|
+
error = ""
|
|
218
|
+
text = ""
|
|
219
|
+
if request.method == "POST":
|
|
220
|
+
data = request.state.form
|
|
221
|
+
upload = data.get("file")
|
|
222
|
+
if isinstance(upload, UploadFile) and upload.filename:
|
|
223
|
+
raw = upload.file.read()
|
|
224
|
+
else:
|
|
225
|
+
raw = str(data.get("csv_text", "")).encode()
|
|
226
|
+
try:
|
|
227
|
+
if not raw:
|
|
228
|
+
raise ImportProblem("Choose a CSV file first.")
|
|
229
|
+
rows = parse_csv(admin, raw)
|
|
230
|
+
text = raw.decode("utf-8-sig")
|
|
231
|
+
report = import_rows(session, request, admin, rows, apply="_confirm" in data)
|
|
232
|
+
except ImportProblem as exc:
|
|
233
|
+
error = str(exc)
|
|
234
|
+
if report and report["is_valid"] and "_confirm" in data:
|
|
235
|
+
counts = report["counts"]
|
|
236
|
+
admin.message_user(
|
|
237
|
+
request, f"Imported: {counts['add']} added, {counts['change']} changed, {counts['same']} unchanged."
|
|
238
|
+
)
|
|
239
|
+
return redirect(f"{_root(request)}/{admin.slug}")
|
|
240
|
+
|
|
241
|
+
preview = []
|
|
242
|
+
if report:
|
|
243
|
+
ordered = sorted(report["results"], key=lambda row: row["status"] != "error")
|
|
244
|
+
preview = ordered[:PREVIEW_ROWS]
|
|
245
|
+
list_url = f"{_root(request)}/{admin.slug}"
|
|
246
|
+
title = f"Import {admin.name_plural.lower()}"
|
|
247
|
+
return admin.site.render(
|
|
248
|
+
request,
|
|
249
|
+
"fastapi_panel/import.html",
|
|
250
|
+
{
|
|
251
|
+
"admin": admin,
|
|
252
|
+
"title": title,
|
|
253
|
+
"crumbs": [("Home", f"{_root(request)}/"), (admin.name_plural, list_url), ("Import", None)],
|
|
254
|
+
"error": error,
|
|
255
|
+
"report": report,
|
|
256
|
+
"preview": preview,
|
|
257
|
+
"hidden_rows": len(report["results"]) - len(preview) if report else 0,
|
|
258
|
+
"csv_text": text,
|
|
259
|
+
"post_url": request.url.path,
|
|
260
|
+
"list_url": list_url,
|
|
261
|
+
"columns": [admin.column_label(name) for name in admin.get_import_fields(request)],
|
|
262
|
+
"pk_label": humanize(pk_keys(admin.model)[0]),
|
|
263
|
+
},
|
|
264
|
+
)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import quote, unquote
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import String, and_, cast, func, inspect
|
|
9
|
+
from sqlalchemy.sql.elements import ColumnElement
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def humanize(name: str) -> str:
|
|
13
|
+
text = name.replace("__", " ").replace("_", " ").strip()
|
|
14
|
+
return text[:1].upper() + text[1:]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def split_camel(name: str) -> str:
|
|
18
|
+
return re.sub(r"(?<=[a-z0-9])(?=[A-Z])", " ", name)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def pluralize(name: str) -> str:
|
|
22
|
+
if name.endswith("y") and name[-2:-1] not in "aeiou":
|
|
23
|
+
return name[:-1] + "ies"
|
|
24
|
+
if name.endswith(("s", "x", "ch", "sh")):
|
|
25
|
+
return name + "es"
|
|
26
|
+
return name + "s"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def pk_keys(model: type) -> list[str]:
|
|
30
|
+
mapper = inspect(model)
|
|
31
|
+
return [mapper.get_property_by_column(c).key for c in mapper.primary_key]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def pk_string(obj: Any) -> str:
|
|
35
|
+
return ",".join(quote(str(getattr(obj, key)), safe="") for key in pk_keys(type(obj)))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def coerce_to_column(column: Any, raw: str) -> Any:
|
|
39
|
+
"""Converts form or URL text to the column's type. Anything that doesn't fit is a ValueError."""
|
|
40
|
+
try:
|
|
41
|
+
return column.type.python_type(raw)
|
|
42
|
+
except (TypeError, ArithmeticError, NotImplementedError) as exc:
|
|
43
|
+
raise ValueError(raw) from exc
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def pk_criterion(model: type, raw: str) -> ColumnElement[bool]:
|
|
47
|
+
"""Raises ValueError when `raw` can't be a primary key of `model`."""
|
|
48
|
+
keys = pk_keys(model)
|
|
49
|
+
parts = [unquote(part) for part in raw.split(",")]
|
|
50
|
+
if len(parts) != len(keys):
|
|
51
|
+
raise ValueError(raw)
|
|
52
|
+
columns = [getattr(model, key) for key in keys]
|
|
53
|
+
return and_(*[col == coerce_to_column(col, part) for col, part in zip(columns, parts, strict=True)])
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def object_label(obj: Any) -> str:
|
|
57
|
+
cls = type(obj)
|
|
58
|
+
if cls.__str__ is not object.__str__ and not cls.__str__.__module__.startswith("pydantic"):
|
|
59
|
+
return str(obj)
|
|
60
|
+
for attr in ("name", "title", "label", "username", "email"):
|
|
61
|
+
value = getattr(obj, attr, None)
|
|
62
|
+
if value:
|
|
63
|
+
return str(value)
|
|
64
|
+
return f"{split_camel(cls.__name__)} #{pk_string(obj)}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def resolve_attr(model: type, path: str) -> Any:
|
|
68
|
+
"""Returns the attribute at the end of `category__parent__name`, or raises AttributeError."""
|
|
69
|
+
head, _, rest = path.partition("__")
|
|
70
|
+
attr = getattr(model, head)
|
|
71
|
+
return resolve_attr(attr.property.mapper.class_, rest) if rest else attr
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def related_criterion(model: type, path: str, build: Callable[[Any], Any]) -> Any:
|
|
75
|
+
head, _, rest = path.partition("__")
|
|
76
|
+
attr = getattr(model, head)
|
|
77
|
+
if not rest:
|
|
78
|
+
return build(attr)
|
|
79
|
+
inner = related_criterion(attr.property.mapper.class_, rest, build)
|
|
80
|
+
return attr.any(inner) if attr.property.uselist else attr.has(inner)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def search_criterion(model: type, spec: str, term: str) -> Any:
|
|
84
|
+
"""`spec` prefixes: `^` starts with, `=` exact, otherwise contains."""
|
|
85
|
+
mode, path = (spec[0], spec[1:]) if spec[0] in "^=" else ("", spec)
|
|
86
|
+
|
|
87
|
+
def build(column: Any) -> Any:
|
|
88
|
+
text = column if isinstance(column.type, String) else cast(column, String)
|
|
89
|
+
if mode == "^":
|
|
90
|
+
return text.istartswith(term, autoescape=True)
|
|
91
|
+
if mode == "=":
|
|
92
|
+
return func.lower(text) == term.lower()
|
|
93
|
+
return text.icontains(term, autoescape=True)
|
|
94
|
+
|
|
95
|
+
return related_criterion(model, path, build)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def relationship_paths(model: type, names: list[str]) -> list[str]:
|
|
99
|
+
"""The names that traverse a relationship, so the changelist can load them up front."""
|
|
100
|
+
paths = []
|
|
101
|
+
for name in names:
|
|
102
|
+
current, walked = model, []
|
|
103
|
+
for part in name.split("__"):
|
|
104
|
+
prop = inspect(current).relationships.get(part)
|
|
105
|
+
if prop is None:
|
|
106
|
+
break
|
|
107
|
+
walked.append(part)
|
|
108
|
+
current = prop.mapper.class_
|
|
109
|
+
if walked:
|
|
110
|
+
paths.append("__".join(walked))
|
|
111
|
+
return paths
|
fastapi_panel/log.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import datetime as dt
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import DateTime, Index, String, Text
|
|
7
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
8
|
+
|
|
9
|
+
from .introspect import object_label, pk_string
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Base(DeclarativeBase):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AdminLog(Base):
|
|
17
|
+
__tablename__ = "fastapi_panel_log"
|
|
18
|
+
__table_args__ = (
|
|
19
|
+
Index("ix_fastapi_panel_log_object", "model", "object_id"),
|
|
20
|
+
Index("ix_fastapi_panel_log_user", "user_id", "created_at"),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
24
|
+
created_at: Mapped[dt.datetime] = mapped_column(
|
|
25
|
+
DateTime, default=lambda: dt.datetime.now(dt.timezone.utc).replace(tzinfo=None)
|
|
26
|
+
)
|
|
27
|
+
user_id: Mapped[str] = mapped_column(String(255))
|
|
28
|
+
user_label: Mapped[str] = mapped_column(String(255))
|
|
29
|
+
model: Mapped[str] = mapped_column(String(100))
|
|
30
|
+
object_id: Mapped[str] = mapped_column(String(255))
|
|
31
|
+
object_label: Mapped[str] = mapped_column(String(255))
|
|
32
|
+
action: Mapped[str] = mapped_column(String(10))
|
|
33
|
+
# Field names only. Values stay out of the log so it never collects sensitive data.
|
|
34
|
+
message: Mapped[str] = mapped_column(Text, default="")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def record(session: Any, request: Any, admin: Any, obj: Any, action: str, message: str = "") -> None:
|
|
38
|
+
if not admin.site.audit_log:
|
|
39
|
+
return
|
|
40
|
+
auth = admin.site.auth
|
|
41
|
+
user = request.state.user
|
|
42
|
+
session.add(
|
|
43
|
+
AdminLog(
|
|
44
|
+
user_id=auth.user_id(user)[:255],
|
|
45
|
+
user_label=auth.user_label(user)[:255],
|
|
46
|
+
model=admin.slug,
|
|
47
|
+
object_id=pk_string(obj)[:255],
|
|
48
|
+
object_label=object_label(obj)[:255],
|
|
49
|
+
action=action,
|
|
50
|
+
message=message,
|
|
51
|
+
)
|
|
52
|
+
)
|