polyadmin 0.1.0b1__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.
- polyadmin/__init__.py +47 -0
- polyadmin/core/__init__.py +0 -0
- polyadmin/core/_async.py +21 -0
- polyadmin/core/action.py +135 -0
- polyadmin/core/admin.py +129 -0
- polyadmin/core/audit.py +65 -0
- polyadmin/core/auth.py +52 -0
- polyadmin/core/authorization.py +48 -0
- polyadmin/core/csrf.py +70 -0
- polyadmin/core/dashboard.py +41 -0
- polyadmin/core/delete.py +146 -0
- polyadmin/core/exporter.py +136 -0
- polyadmin/core/field.py +201 -0
- polyadmin/core/filter.py +305 -0
- polyadmin/core/inline.py +97 -0
- polyadmin/core/login.py +110 -0
- polyadmin/core/model_admin.py +349 -0
- polyadmin/core/page.py +62 -0
- polyadmin/core/pagination.py +70 -0
- polyadmin/core/query.py +243 -0
- polyadmin/core/relation.py +40 -0
- polyadmin/core/slug.py +57 -0
- polyadmin/core/template_context.py +679 -0
- polyadmin/core/widget.py +280 -0
- polyadmin/fastapi/__init__.py +3 -0
- polyadmin/fastapi/audit.py +52 -0
- polyadmin/fastapi/auth.py +112 -0
- polyadmin/fastapi/csrf.py +93 -0
- polyadmin/fastapi/deletes.py +76 -0
- polyadmin/fastapi/errors.py +93 -0
- polyadmin/fastapi/handlers.py +798 -0
- polyadmin/fastapi/inlines.py +177 -0
- polyadmin/fastapi/locale.py +128 -0
- polyadmin/fastapi/login.py +110 -0
- polyadmin/fastapi/pages.py +97 -0
- polyadmin/fastapi/relations.py +264 -0
- polyadmin/fastapi/responses.py +55 -0
- polyadmin/fastapi/router.py +174 -0
- polyadmin/fastapi/static.py +21 -0
- polyadmin/i18n/__init__.py +50 -0
- polyadmin/i18n/context.py +54 -0
- polyadmin/i18n/negotiation.py +56 -0
- polyadmin/i18n/setup.py +81 -0
- polyadmin/i18n/translator.py +124 -0
- polyadmin/locale/fr/LC_MESSAGES/polyadmin.mo +0 -0
- polyadmin/locale/fr/LC_MESSAGES/polyadmin.po +486 -0
- polyadmin/locale/polyadmin.pot +485 -0
- polyadmin/locale/ru/LC_MESSAGES/polyadmin.mo +0 -0
- polyadmin/locale/ru/LC_MESSAGES/polyadmin.po +496 -0
- polyadmin/templates/admin/base.html +91 -0
- polyadmin/templates/admin/components/action_confirm_modal.html +86 -0
- polyadmin/templates/admin/components/csrf-field.html +5 -0
- polyadmin/templates/admin/components/error_fragment.html +6 -0
- polyadmin/templates/admin/components/field.html +60 -0
- polyadmin/templates/admin/components/form_wrapper.html +131 -0
- polyadmin/templates/admin/components/icons.html +67 -0
- polyadmin/templates/admin/components/inline.html +251 -0
- polyadmin/templates/admin/components/inline_fragment.html +2 -0
- polyadmin/templates/admin/components/list_content.html +54 -0
- polyadmin/templates/admin/components/lookup_results.html +19 -0
- polyadmin/templates/admin/components/search.html +19 -0
- polyadmin/templates/admin/components/toasts.html +151 -0
- polyadmin/templates/admin/components/ui/breadcrumb.html +30 -0
- polyadmin/templates/admin/components/ui/bulk-actions.html +69 -0
- polyadmin/templates/admin/components/ui/calendar.html +175 -0
- polyadmin/templates/admin/components/ui/combobox.html +82 -0
- polyadmin/templates/admin/components/ui/delete-preview.html +37 -0
- polyadmin/templates/admin/components/ui/dropdown-menu.html +71 -0
- polyadmin/templates/admin/components/ui/field.html +110 -0
- polyadmin/templates/admin/components/ui/filter-panel.html +155 -0
- polyadmin/templates/admin/components/ui/locale-switcher.html +30 -0
- polyadmin/templates/admin/components/ui/multi-select.html +253 -0
- polyadmin/templates/admin/components/ui/pagination.html +81 -0
- polyadmin/templates/admin/components/ui/radio-group.html +28 -0
- polyadmin/templates/admin/components/ui/select.html +165 -0
- polyadmin/templates/admin/components/ui/sidebar.html +175 -0
- polyadmin/templates/admin/components/ui/slider.html +22 -0
- polyadmin/templates/admin/components/ui/switch.html +36 -0
- polyadmin/templates/admin/components/ui/table.html +221 -0
- polyadmin/templates/admin/components/ui/theme-toggle.html +33 -0
- polyadmin/templates/admin/dashboard.html +35 -0
- polyadmin/templates/admin/error.html +33 -0
- polyadmin/templates/admin/login.html +94 -0
- polyadmin/templates/admin/resource/delete.html +29 -0
- polyadmin/templates/admin/resource/delete_selected.html +49 -0
- polyadmin/templates/admin/resource/detail.html +78 -0
- polyadmin/templates/admin/resource/form.html +5 -0
- polyadmin/templates/admin/resource/list.html +5 -0
- polyadmin/templates/admin/theme.html +372 -0
- polyadmin/templates/admin/widgets/activity.html +8 -0
- polyadmin/templates/admin/widgets/chart.html +15 -0
- polyadmin/templates/admin/widgets/donut.html +59 -0
- polyadmin/templates/admin/widgets/metric.html +1 -0
- polyadmin/templates/admin/widgets/progress.html +7 -0
- polyadmin/templates/admin/widgets/stat.html +22 -0
- polyadmin/templates/admin/widgets/table.html +29 -0
- polyadmin/templates/admin/widgets/tabs.html +34 -0
- polyadmin/templates/admin/widgets/timeline.html +21 -0
- polyadmin/templating.py +528 -0
- polyadmin/ui.py +817 -0
- polyadmin-0.1.0b1.dist-info/METADATA +239 -0
- polyadmin-0.1.0b1.dist-info/RECORD +104 -0
- polyadmin-0.1.0b1.dist-info/WHEEL +4 -0
- polyadmin-0.1.0b1.dist-info/licenses/LICENSE +21 -0
polyadmin/core/delete.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Delete previews (docs/deletes.md): an optional ModelAdmin capability that
|
|
2
|
+
says what else a delete takes out and what forbids it, and the resolution
|
|
3
|
+
that makes a host's answer safe to show one principal.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, Protocol
|
|
12
|
+
|
|
13
|
+
from polyadmin.core.authorization import resource_permission
|
|
14
|
+
|
|
15
|
+
# How many records of a group are listed; the rest are counted.
|
|
16
|
+
DELETE_PREVIEW_SAMPLE = 10
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class DeleteGroup:
|
|
21
|
+
"""One related type's share of a preview. `resource` is a registered slug,
|
|
22
|
+
or "" for a type the admin does not manage (then `label` is required).
|
|
23
|
+
`label` is a host string translated at render; "" falls back to the
|
|
24
|
+
resource's verbose name. `objects` is a sample; `total` the full count,
|
|
25
|
+
raised to len(objects) when smaller.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
resource: str = ""
|
|
29
|
+
label: str = ""
|
|
30
|
+
objects: Sequence[Any] = ()
|
|
31
|
+
total: int = 0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class DeletePreview:
|
|
36
|
+
cascades: Sequence[DeleteGroup] = () # deleted along with the objects
|
|
37
|
+
protected: Sequence[DeleteGroup] = () # prevent the delete while they exist
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class DeletePreviewer(Protocol):
|
|
41
|
+
"""An optional ModelAdmin capability: implement `delete_preview` to show
|
|
42
|
+
what else a delete takes out, and to block deletes your storage would
|
|
43
|
+
refuse. Same optional-capability shape as ListQuerier: implement more,
|
|
44
|
+
get more, and nothing breaks if you do not.
|
|
45
|
+
|
|
46
|
+
`objects` is always a list -- one record from the delete page, the whole
|
|
47
|
+
selection from delete_selected -- so an implementation over SQL can
|
|
48
|
+
answer a bulk delete with one count and one sample query per relation.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def delete_preview(self, objects: list[Any]) -> DeletePreview: ...
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class ResolvedDeleteGroup:
|
|
56
|
+
label: str # untranslated
|
|
57
|
+
total: int
|
|
58
|
+
model_admin: Any = None # None for a type the admin does not manage
|
|
59
|
+
visible: list[Any] = field(default_factory=list)
|
|
60
|
+
texts: list[str] = field(default_factory=list)
|
|
61
|
+
hidden: int = 0
|
|
62
|
+
more: int = 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class ResolvedDeletePreview:
|
|
67
|
+
cascades: list[ResolvedDeleteGroup] = field(default_factory=list)
|
|
68
|
+
protected: list[ResolvedDeleteGroup] = field(default_factory=list)
|
|
69
|
+
denied_types: list[str] = field(default_factory=list) # untranslated labels
|
|
70
|
+
blocked: bool = False
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def previews_deletes(model_admin: Any) -> bool:
|
|
74
|
+
return callable(getattr(model_admin, "delete_preview", None))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def resolve_delete_preview(admin: Any, model_admin: Any, principal: Any, objects: Sequence[Any]) -> ResolvedDeletePreview:
|
|
78
|
+
"""Ask model_admin what deleting objects takes with it and filter the answer
|
|
79
|
+
for principal. Without the capability: an empty, non-blocking result, and
|
|
80
|
+
nothing is called.
|
|
81
|
+
"""
|
|
82
|
+
if not previews_deletes(model_admin):
|
|
83
|
+
return ResolvedDeletePreview()
|
|
84
|
+
preview = model_admin.delete_preview(list(objects))
|
|
85
|
+
out = ResolvedDeletePreview()
|
|
86
|
+
for group in preview.cascades:
|
|
87
|
+
resolved = _resolve_group(admin, principal, group)
|
|
88
|
+
if resolved is None:
|
|
89
|
+
continue
|
|
90
|
+
out.cascades.append(resolved)
|
|
91
|
+
target = resolved.model_admin
|
|
92
|
+
if target is not None and not _allows(
|
|
93
|
+
admin, principal, target.can_delete, resource_permission(target.get_slug(), "delete"), target
|
|
94
|
+
):
|
|
95
|
+
out.denied_types.append(resolved.label)
|
|
96
|
+
for group in preview.protected:
|
|
97
|
+
resolved = _resolve_group(admin, principal, group)
|
|
98
|
+
if resolved is not None:
|
|
99
|
+
out.protected.append(resolved)
|
|
100
|
+
out.blocked = bool(out.protected or out.denied_types)
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _resolve_group(admin: Any, principal: Any, group: DeleteGroup) -> ResolvedDeleteGroup | None:
|
|
105
|
+
total = max(group.total, len(group.objects))
|
|
106
|
+
if total == 0:
|
|
107
|
+
return None
|
|
108
|
+
sample = list(group.objects)[:DELETE_PREVIEW_SAMPLE]
|
|
109
|
+
resolved = ResolvedDeleteGroup(label=group.label, total=total, more=total - len(sample))
|
|
110
|
+
if not group.resource:
|
|
111
|
+
if not group.label:
|
|
112
|
+
raise ValueError("A DeleteGroup needs a resource or a label.")
|
|
113
|
+
resolved.texts = [str(obj) for obj in sample]
|
|
114
|
+
return resolved
|
|
115
|
+
try:
|
|
116
|
+
target = admin.get_model_admin(group.resource)
|
|
117
|
+
except KeyError:
|
|
118
|
+
raise ValueError(f"delete_preview names unregistered resource {group.resource!r}.") from None
|
|
119
|
+
resolved.model_admin = target
|
|
120
|
+
resolved.label = resolved.label or target.get_verbose_name()
|
|
121
|
+
# The detail page's two checks: the coarse one against the resource, then
|
|
122
|
+
# the per-object one. A record failing either is counted, never named.
|
|
123
|
+
permission = resource_permission(target.get_slug(), "view")
|
|
124
|
+
viewable = _allows(admin, principal, target.can_view, permission, target)
|
|
125
|
+
for obj in sample:
|
|
126
|
+
if viewable and _allows(admin, principal, True, permission, obj):
|
|
127
|
+
resolved.visible.append(obj)
|
|
128
|
+
else:
|
|
129
|
+
resolved.hidden += 1
|
|
130
|
+
return resolved
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _allows(admin: Any, principal: Any, capability: bool, permission: str, resource: Any) -> bool:
|
|
134
|
+
"""compute_permissions' rule for one check: the static toggle, then the
|
|
135
|
+
Authorizer if there is one."""
|
|
136
|
+
if not capability:
|
|
137
|
+
return False
|
|
138
|
+
return admin.authorizer is None or admin.authorizer.can(principal, permission, resource)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def selection_fingerprint(model_admin: Any, objects: Sequence[Any]) -> str:
|
|
142
|
+
"""The hex SHA-256 of the records' primary keys, sorted and newline-joined.
|
|
143
|
+
The delete_selected confirmation carries it so "all N matching" deletes
|
|
144
|
+
exactly the set that was reviewed."""
|
|
145
|
+
pks = sorted(str(model_admin.get_pk(obj)) for obj in objects)
|
|
146
|
+
return hashlib.sha256("\n".join(pks).encode()).hexdigest()
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Exporter: turns a ModelAdmin's list query result into a downloadable
|
|
2
|
+
file. The adapter feeds it the *same* filtered/ordered
|
|
3
|
+
result set `execute_list_query` produced for the list view, so an
|
|
4
|
+
export always represents exactly the dataset the user was looking at,
|
|
5
|
+
respecting the resource's configured columns (`list_display`).
|
|
6
|
+
|
|
7
|
+
CSV streams row-by-row all the way to the HTTP response and never
|
|
8
|
+
holds more than one row in memory. XLSX is written with openpyxl's
|
|
9
|
+
write-only mode, which avoids materializing a Python object per row
|
|
10
|
+
--but the zip container format has to be finalized before any bytes
|
|
11
|
+
go out, so unlike CSV it is buffered once, not chunked, to the client.
|
|
12
|
+
Large XLSX exports should stay reasonable in row count for that reason.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import csv
|
|
18
|
+
import io
|
|
19
|
+
from collections.abc import Iterable, Iterator
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from polyadmin.core.admin import Admin
|
|
23
|
+
from polyadmin.core.field import Field
|
|
24
|
+
from polyadmin.core.model_admin import ModelAdmin
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def cell_value(admin: Admin, field: Field, obj: Any) -> Any:
|
|
28
|
+
"""A field's export-friendly value: relation fields resolve to
|
|
29
|
+
their target's display label (or a comma-joined list of them for
|
|
30
|
+
ManyToMany) rather than exporting a raw Python object.
|
|
31
|
+
"""
|
|
32
|
+
value = field.get_value(obj)
|
|
33
|
+
if field.field_type in ("foreignkey", "onetoone"):
|
|
34
|
+
if value is None:
|
|
35
|
+
return ""
|
|
36
|
+
target_admin = admin.get_model_admin(field.relation.target)
|
|
37
|
+
return target_admin.get_field(field.relation.display_field).get_value(value)
|
|
38
|
+
if field.field_type == "manytomany":
|
|
39
|
+
if not value:
|
|
40
|
+
return ""
|
|
41
|
+
target_admin = admin.get_model_admin(field.relation.target)
|
|
42
|
+
display_field = target_admin.get_field(field.relation.display_field)
|
|
43
|
+
return ", ".join(str(display_field.get_value(item)) for item in value)
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Exporter:
|
|
48
|
+
format: str
|
|
49
|
+
content_type: str
|
|
50
|
+
|
|
51
|
+
def file_extension(self) -> str:
|
|
52
|
+
raise NotImplementedError
|
|
53
|
+
|
|
54
|
+
def stream(
|
|
55
|
+
self,
|
|
56
|
+
admin: Admin,
|
|
57
|
+
model_admin: ModelAdmin,
|
|
58
|
+
objects: Iterable[Any],
|
|
59
|
+
columns: list[str],
|
|
60
|
+
header: list[str] | None = None,
|
|
61
|
+
) -> Iterator[bytes]:
|
|
62
|
+
raise NotImplementedError
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class CSVExporter(Exporter):
|
|
66
|
+
format = "csv"
|
|
67
|
+
content_type = "text/csv"
|
|
68
|
+
|
|
69
|
+
def file_extension(self) -> str:
|
|
70
|
+
return "csv"
|
|
71
|
+
|
|
72
|
+
def stream(
|
|
73
|
+
self,
|
|
74
|
+
admin: Admin,
|
|
75
|
+
model_admin: ModelAdmin,
|
|
76
|
+
objects: Iterable[Any],
|
|
77
|
+
columns: list[str],
|
|
78
|
+
header: list[str] | None = None,
|
|
79
|
+
) -> Iterator[bytes]:
|
|
80
|
+
fields = [model_admin.get_field(name) for name in columns]
|
|
81
|
+
buffer = io.StringIO()
|
|
82
|
+
writer = csv.writer(buffer)
|
|
83
|
+
|
|
84
|
+
writer.writerow(header or [field.label for field in fields])
|
|
85
|
+
yield buffer.getvalue().encode("utf-8")
|
|
86
|
+
buffer.seek(0)
|
|
87
|
+
buffer.truncate(0)
|
|
88
|
+
|
|
89
|
+
for obj in objects:
|
|
90
|
+
writer.writerow([cell_value(admin, field, obj) for field in fields])
|
|
91
|
+
yield buffer.getvalue().encode("utf-8")
|
|
92
|
+
buffer.seek(0)
|
|
93
|
+
buffer.truncate(0)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class XLSXExporter(Exporter):
|
|
97
|
+
format = "xlsx"
|
|
98
|
+
content_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
99
|
+
|
|
100
|
+
def file_extension(self) -> str:
|
|
101
|
+
return "xlsx"
|
|
102
|
+
|
|
103
|
+
def stream(
|
|
104
|
+
self,
|
|
105
|
+
admin: Admin,
|
|
106
|
+
model_admin: ModelAdmin,
|
|
107
|
+
objects: Iterable[Any],
|
|
108
|
+
columns: list[str],
|
|
109
|
+
header: list[str] | None = None,
|
|
110
|
+
) -> Iterator[bytes]:
|
|
111
|
+
from openpyxl import Workbook
|
|
112
|
+
|
|
113
|
+
fields = [model_admin.get_field(name) for name in columns]
|
|
114
|
+
workbook = Workbook(write_only=True)
|
|
115
|
+
sheet = workbook.create_sheet(model_admin.get_verbose_name())
|
|
116
|
+
|
|
117
|
+
sheet.append(header or [field.label for field in fields])
|
|
118
|
+
for obj in objects:
|
|
119
|
+
row = [cell_value(admin, field, obj) for field in fields]
|
|
120
|
+
# openpyxl only accepts str/int/float/bool/datetime cells --
|
|
121
|
+
# coerce anything else (None, UUID, dict/list from a JSON
|
|
122
|
+
# field, ...) to a plain string.
|
|
123
|
+
sheet.append(
|
|
124
|
+
[
|
|
125
|
+
""
|
|
126
|
+
if value is None
|
|
127
|
+
else value
|
|
128
|
+
if isinstance(value, (str, int, float, bool))
|
|
129
|
+
else str(value)
|
|
130
|
+
for value in row
|
|
131
|
+
]
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
buffer = io.BytesIO()
|
|
135
|
+
workbook.save(buffer)
|
|
136
|
+
yield buffer.getvalue()
|
polyadmin/core/field.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Field abstraction.
|
|
2
|
+
|
|
3
|
+
A Field represents a model property plus its admin presentation, not
|
|
4
|
+
merely an HTML input. Context-aware rendering (list/detail/form/filter)
|
|
5
|
+
is added in Phase 2; this module defines the data-level contract that
|
|
6
|
+
rendering will build on.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable, Sequence
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
from polyadmin.i18n import gettext
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from polyadmin.core.relation import Relation
|
|
18
|
+
|
|
19
|
+
_UNSET = object()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Field:
|
|
23
|
+
"""Base class for all admin field types."""
|
|
24
|
+
|
|
25
|
+
field_type: str = "field"
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
name: str,
|
|
30
|
+
*,
|
|
31
|
+
label: str | None = None,
|
|
32
|
+
required: bool = False,
|
|
33
|
+
readonly: bool = False,
|
|
34
|
+
disabled: bool = False,
|
|
35
|
+
default: Any = _UNSET,
|
|
36
|
+
help_text: str = "",
|
|
37
|
+
placeholder: str = "",
|
|
38
|
+
validators: Sequence[Callable[[Any], None]] = (),
|
|
39
|
+
) -> None:
|
|
40
|
+
self.name = name
|
|
41
|
+
self.label = label or name.replace("_", " ").title()
|
|
42
|
+
self.required = required
|
|
43
|
+
self.readonly = readonly
|
|
44
|
+
self.disabled = disabled
|
|
45
|
+
self.default = default
|
|
46
|
+
self.help_text = help_text
|
|
47
|
+
self.placeholder = placeholder
|
|
48
|
+
self.validators = list(validators)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def has_default(self) -> bool:
|
|
52
|
+
return self.default is not _UNSET
|
|
53
|
+
|
|
54
|
+
def get_value(self, obj: Any) -> Any:
|
|
55
|
+
"""Read this field's value off a model instance."""
|
|
56
|
+
value = getattr(obj, self.name, _UNSET)
|
|
57
|
+
if value is _UNSET:
|
|
58
|
+
return self.default if self.has_default else None
|
|
59
|
+
return value
|
|
60
|
+
|
|
61
|
+
def parse_form_value(self, raw: Any) -> Any:
|
|
62
|
+
"""Coerce a raw submitted form value into this field's Python
|
|
63
|
+
type. An empty string becomes None for every field type so that
|
|
64
|
+
`required` validation (which treats None and "" the same) still
|
|
65
|
+
fires; adapters are responsible for feeding booleans in as
|
|
66
|
+
checkbox presence rather than a raw string.
|
|
67
|
+
"""
|
|
68
|
+
if raw is None or raw == "":
|
|
69
|
+
return None
|
|
70
|
+
if self.field_type == "integer":
|
|
71
|
+
try:
|
|
72
|
+
return int(raw)
|
|
73
|
+
except (TypeError, ValueError):
|
|
74
|
+
return raw
|
|
75
|
+
if self.field_type == "decimal":
|
|
76
|
+
try:
|
|
77
|
+
return float(raw)
|
|
78
|
+
except (TypeError, ValueError):
|
|
79
|
+
return raw
|
|
80
|
+
if self.field_type == "json":
|
|
81
|
+
import json
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
return json.loads(raw)
|
|
85
|
+
except (TypeError, ValueError):
|
|
86
|
+
return raw
|
|
87
|
+
return raw
|
|
88
|
+
|
|
89
|
+
def validate(self, value: Any) -> list[str]:
|
|
90
|
+
"""Run configured validators, returning a list of error messages."""
|
|
91
|
+
errors: list[str] = []
|
|
92
|
+
if self.required and (value is None or value == ""):
|
|
93
|
+
errors.append(gettext("%(label)s is required.") % {"label": gettext(self.label)})
|
|
94
|
+
return errors
|
|
95
|
+
for validator in self.validators:
|
|
96
|
+
try:
|
|
97
|
+
validator(value)
|
|
98
|
+
except ValueError as exc:
|
|
99
|
+
# A static English message translates from the host's
|
|
100
|
+
# catalog; one already translated misses and passes through.
|
|
101
|
+
errors.append(gettext(str(exc)))
|
|
102
|
+
return errors
|
|
103
|
+
|
|
104
|
+
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
|
105
|
+
return f"{type(self).__name__}({self.name!r})"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class StringField(Field):
|
|
109
|
+
field_type = "string"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class TextField(Field):
|
|
113
|
+
field_type = "text"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class IntegerField(Field):
|
|
117
|
+
field_type = "integer"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class DecimalField(Field):
|
|
121
|
+
field_type = "decimal"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class BooleanField(Field):
|
|
125
|
+
field_type = "boolean"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class DateField(Field):
|
|
129
|
+
field_type = "date"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class DateTimeField(Field):
|
|
133
|
+
field_type = "datetime"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class EmailField(Field):
|
|
137
|
+
field_type = "email"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class URLField(Field):
|
|
141
|
+
field_type = "url"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class UUIDField(Field):
|
|
145
|
+
field_type = "uuid"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class EnumField(Field):
|
|
149
|
+
field_type = "enum"
|
|
150
|
+
|
|
151
|
+
def __init__(self, name: str, *, choices: Sequence[Any], **kwargs: Any) -> None:
|
|
152
|
+
super().__init__(name, **kwargs)
|
|
153
|
+
self.choices = list(choices)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class JSONField(Field):
|
|
157
|
+
field_type = "json"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class PasswordField(Field):
|
|
161
|
+
field_type = "password"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class ForeignKeyField(Field):
|
|
165
|
+
"""A single related object. `get_value` returns the
|
|
166
|
+
related object itself (or None), not a scalar -- rendering resolves
|
|
167
|
+
its display label via `relation.display_field` on the target
|
|
168
|
+
ModelAdmin.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
field_type = "foreignkey"
|
|
172
|
+
|
|
173
|
+
def __init__(self, name: str, *, relation: Relation, **kwargs: Any) -> None:
|
|
174
|
+
super().__init__(name, **kwargs)
|
|
175
|
+
self.relation = relation
|
|
176
|
+
|
|
177
|
+
def get_value(self, obj: Any) -> Any:
|
|
178
|
+
# A relation with get_related builds the related object from what the
|
|
179
|
+
# row carries (typically an id and a label read over HTTP); without one
|
|
180
|
+
# the row's attribute of this field's name is the related object.
|
|
181
|
+
if self.relation.get_related is not None:
|
|
182
|
+
return self.relation.get_related(obj)
|
|
183
|
+
return super().get_value(obj)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class OneToOneField(ForeignKeyField):
|
|
187
|
+
field_type = "onetoone"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class ManyToManyField(Field):
|
|
191
|
+
"""A collection of related objects."""
|
|
192
|
+
|
|
193
|
+
field_type = "manytomany"
|
|
194
|
+
|
|
195
|
+
def __init__(self, name: str, *, relation: Relation, **kwargs: Any) -> None:
|
|
196
|
+
super().__init__(name, **kwargs)
|
|
197
|
+
self.relation = relation
|
|
198
|
+
|
|
199
|
+
def get_value(self, obj: Any) -> Any:
|
|
200
|
+
value = self.relation.get_related(obj) if self.relation.get_related is not None else super().get_value(obj)
|
|
201
|
+
return value if value is not None else []
|