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.
Files changed (104) hide show
  1. polyadmin/__init__.py +47 -0
  2. polyadmin/core/__init__.py +0 -0
  3. polyadmin/core/_async.py +21 -0
  4. polyadmin/core/action.py +135 -0
  5. polyadmin/core/admin.py +129 -0
  6. polyadmin/core/audit.py +65 -0
  7. polyadmin/core/auth.py +52 -0
  8. polyadmin/core/authorization.py +48 -0
  9. polyadmin/core/csrf.py +70 -0
  10. polyadmin/core/dashboard.py +41 -0
  11. polyadmin/core/delete.py +146 -0
  12. polyadmin/core/exporter.py +136 -0
  13. polyadmin/core/field.py +201 -0
  14. polyadmin/core/filter.py +305 -0
  15. polyadmin/core/inline.py +97 -0
  16. polyadmin/core/login.py +110 -0
  17. polyadmin/core/model_admin.py +349 -0
  18. polyadmin/core/page.py +62 -0
  19. polyadmin/core/pagination.py +70 -0
  20. polyadmin/core/query.py +243 -0
  21. polyadmin/core/relation.py +40 -0
  22. polyadmin/core/slug.py +57 -0
  23. polyadmin/core/template_context.py +679 -0
  24. polyadmin/core/widget.py +280 -0
  25. polyadmin/fastapi/__init__.py +3 -0
  26. polyadmin/fastapi/audit.py +52 -0
  27. polyadmin/fastapi/auth.py +112 -0
  28. polyadmin/fastapi/csrf.py +93 -0
  29. polyadmin/fastapi/deletes.py +76 -0
  30. polyadmin/fastapi/errors.py +93 -0
  31. polyadmin/fastapi/handlers.py +798 -0
  32. polyadmin/fastapi/inlines.py +177 -0
  33. polyadmin/fastapi/locale.py +128 -0
  34. polyadmin/fastapi/login.py +110 -0
  35. polyadmin/fastapi/pages.py +97 -0
  36. polyadmin/fastapi/relations.py +264 -0
  37. polyadmin/fastapi/responses.py +55 -0
  38. polyadmin/fastapi/router.py +174 -0
  39. polyadmin/fastapi/static.py +21 -0
  40. polyadmin/i18n/__init__.py +50 -0
  41. polyadmin/i18n/context.py +54 -0
  42. polyadmin/i18n/negotiation.py +56 -0
  43. polyadmin/i18n/setup.py +81 -0
  44. polyadmin/i18n/translator.py +124 -0
  45. polyadmin/locale/fr/LC_MESSAGES/polyadmin.mo +0 -0
  46. polyadmin/locale/fr/LC_MESSAGES/polyadmin.po +486 -0
  47. polyadmin/locale/polyadmin.pot +485 -0
  48. polyadmin/locale/ru/LC_MESSAGES/polyadmin.mo +0 -0
  49. polyadmin/locale/ru/LC_MESSAGES/polyadmin.po +496 -0
  50. polyadmin/templates/admin/base.html +91 -0
  51. polyadmin/templates/admin/components/action_confirm_modal.html +86 -0
  52. polyadmin/templates/admin/components/csrf-field.html +5 -0
  53. polyadmin/templates/admin/components/error_fragment.html +6 -0
  54. polyadmin/templates/admin/components/field.html +60 -0
  55. polyadmin/templates/admin/components/form_wrapper.html +131 -0
  56. polyadmin/templates/admin/components/icons.html +67 -0
  57. polyadmin/templates/admin/components/inline.html +251 -0
  58. polyadmin/templates/admin/components/inline_fragment.html +2 -0
  59. polyadmin/templates/admin/components/list_content.html +54 -0
  60. polyadmin/templates/admin/components/lookup_results.html +19 -0
  61. polyadmin/templates/admin/components/search.html +19 -0
  62. polyadmin/templates/admin/components/toasts.html +151 -0
  63. polyadmin/templates/admin/components/ui/breadcrumb.html +30 -0
  64. polyadmin/templates/admin/components/ui/bulk-actions.html +69 -0
  65. polyadmin/templates/admin/components/ui/calendar.html +175 -0
  66. polyadmin/templates/admin/components/ui/combobox.html +82 -0
  67. polyadmin/templates/admin/components/ui/delete-preview.html +37 -0
  68. polyadmin/templates/admin/components/ui/dropdown-menu.html +71 -0
  69. polyadmin/templates/admin/components/ui/field.html +110 -0
  70. polyadmin/templates/admin/components/ui/filter-panel.html +155 -0
  71. polyadmin/templates/admin/components/ui/locale-switcher.html +30 -0
  72. polyadmin/templates/admin/components/ui/multi-select.html +253 -0
  73. polyadmin/templates/admin/components/ui/pagination.html +81 -0
  74. polyadmin/templates/admin/components/ui/radio-group.html +28 -0
  75. polyadmin/templates/admin/components/ui/select.html +165 -0
  76. polyadmin/templates/admin/components/ui/sidebar.html +175 -0
  77. polyadmin/templates/admin/components/ui/slider.html +22 -0
  78. polyadmin/templates/admin/components/ui/switch.html +36 -0
  79. polyadmin/templates/admin/components/ui/table.html +221 -0
  80. polyadmin/templates/admin/components/ui/theme-toggle.html +33 -0
  81. polyadmin/templates/admin/dashboard.html +35 -0
  82. polyadmin/templates/admin/error.html +33 -0
  83. polyadmin/templates/admin/login.html +94 -0
  84. polyadmin/templates/admin/resource/delete.html +29 -0
  85. polyadmin/templates/admin/resource/delete_selected.html +49 -0
  86. polyadmin/templates/admin/resource/detail.html +78 -0
  87. polyadmin/templates/admin/resource/form.html +5 -0
  88. polyadmin/templates/admin/resource/list.html +5 -0
  89. polyadmin/templates/admin/theme.html +372 -0
  90. polyadmin/templates/admin/widgets/activity.html +8 -0
  91. polyadmin/templates/admin/widgets/chart.html +15 -0
  92. polyadmin/templates/admin/widgets/donut.html +59 -0
  93. polyadmin/templates/admin/widgets/metric.html +1 -0
  94. polyadmin/templates/admin/widgets/progress.html +7 -0
  95. polyadmin/templates/admin/widgets/stat.html +22 -0
  96. polyadmin/templates/admin/widgets/table.html +29 -0
  97. polyadmin/templates/admin/widgets/tabs.html +34 -0
  98. polyadmin/templates/admin/widgets/timeline.html +21 -0
  99. polyadmin/templating.py +528 -0
  100. polyadmin/ui.py +817 -0
  101. polyadmin-0.1.0b1.dist-info/METADATA +239 -0
  102. polyadmin-0.1.0b1.dist-info/RECORD +104 -0
  103. polyadmin-0.1.0b1.dist-info/WHEEL +4 -0
  104. polyadmin-0.1.0b1.dist-info/licenses/LICENSE +21 -0
@@ -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()
@@ -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 []