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.
@@ -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
+ )