adminsite 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.
Files changed (195) hide show
  1. adminsite/__init__.py +69 -0
  2. adminsite/_http/__init__.py +1 -0
  3. adminsite/_http/actions.py +163 -0
  4. adminsite/_http/activity.py +295 -0
  5. adminsite/_http/api.py +461 -0
  6. adminsite/_http/csrf.py +71 -0
  7. adminsite/_http/dashboard.py +21 -0
  8. adminsite/_http/detail.py +278 -0
  9. adminsite/_http/document_forms.py +229 -0
  10. adminsite/_http/editing.py +339 -0
  11. adminsite/_http/export.py +166 -0
  12. adminsite/_http/files.py +30 -0
  13. adminsite/_http/form_rows.py +123 -0
  14. adminsite/_http/forms.py +278 -0
  15. adminsite/_http/history.py +234 -0
  16. adminsite/_http/importing.py +231 -0
  17. adminsite/_http/inline_tables.py +289 -0
  18. adminsite/_http/languages.py +33 -0
  19. adminsite/_http/list_editing.py +184 -0
  20. adminsite/_http/listing.py +599 -0
  21. adminsite/_http/lookups.py +113 -0
  22. adminsite/_http/pages.py +33 -0
  23. adminsite/_http/palette.py +121 -0
  24. adminsite/_http/requests.py +107 -0
  25. adminsite/_http/saved_views.py +115 -0
  26. adminsite/_http/signing_in.py +114 -0
  27. adminsite/_http/templating.py +169 -0
  28. adminsite/_http/timezones.py +86 -0
  29. adminsite/_http/urls.py +226 -0
  30. adminsite/_importing.py +446 -0
  31. adminsite/_orm/__init__.py +1 -0
  32. adminsite/_orm/cursor.py +65 -0
  33. adminsite/_orm/loader.py +66 -0
  34. adminsite/_orm/repository.py +767 -0
  35. adminsite/_orm/values.py +39 -0
  36. adminsite/_storage.py +86 -0
  37. adminsite/_text.py +104 -0
  38. adminsite/actions/__init__.py +9 -0
  39. adminsite/actions/_parameters.py +595 -0
  40. adminsite/actions/base.py +262 -0
  41. adminsite/actions/selection.py +257 -0
  42. adminsite/admin.py +831 -0
  43. adminsite/audit/__init__.py +22 -0
  44. adminsite/audit/_actor.py +52 -0
  45. adminsite/audit/_inputs.py +72 -0
  46. adminsite/audit/entry.py +218 -0
  47. adminsite/audit/log.py +181 -0
  48. adminsite/audit/store.py +116 -0
  49. adminsite/auth/__init__.py +11 -0
  50. adminsite/auth/passwords.py +70 -0
  51. adminsite/auth/provider.py +255 -0
  52. adminsite/columns.py +167 -0
  53. adminsite/dashboard.py +496 -0
  54. adminsite/database.py +491 -0
  55. adminsite/exceptions.py +170 -0
  56. adminsite/fields/__init__.py +46 -0
  57. adminsite/fields/_documents/__init__.py +20 -0
  58. adminsite/fields/_documents/document.py +496 -0
  59. adminsite/fields/_documents/error_messages.py +115 -0
  60. adminsite/fields/_documents/inputs.py +294 -0
  61. adminsite/fields/_documents/schema_reader.py +302 -0
  62. adminsite/fields/_documents/shapes.py +118 -0
  63. adminsite/fields/_documents/shown.py +123 -0
  64. adminsite/fields/base.py +326 -0
  65. adminsite/fields/choices.py +294 -0
  66. adminsite/fields/computed.py +145 -0
  67. adminsite/fields/files.py +200 -0
  68. adminsite/fields/json_field.py +260 -0
  69. adminsite/fields/lists.py +101 -0
  70. adminsite/fields/passwords.py +57 -0
  71. adminsite/fields/registry.py +118 -0
  72. adminsite/fields/relations.py +119 -0
  73. adminsite/fields/scalars.py +170 -0
  74. adminsite/fields/temporal.py +169 -0
  75. adminsite/fields/tones.py +36 -0
  76. adminsite/files.py +183 -0
  77. adminsite/filters/__init__.py +28 -0
  78. adminsite/filters/base.py +147 -0
  79. adminsite/filters/sql.py +542 -0
  80. adminsite/i18n.py +446 -0
  81. adminsite/inspector.py +280 -0
  82. adminsite/locales/ar.json +476 -0
  83. adminsite/locales/de.json +424 -0
  84. adminsite/locales/es.json +424 -0
  85. adminsite/locales/fa.json +424 -0
  86. adminsite/locales/fr.json +424 -0
  87. adminsite/locales/ja.json +385 -0
  88. adminsite/locales/pt-BR.json +424 -0
  89. adminsite/locales/ru.json +437 -0
  90. adminsite/locales/tr.json +424 -0
  91. adminsite/locales/zh-Hans.json +385 -0
  92. adminsite/locales/zh-Hant.json +385 -0
  93. adminsite/markup.py +43 -0
  94. adminsite/messages.py +101 -0
  95. adminsite/pages.py +126 -0
  96. adminsite/permissions.py +54 -0
  97. adminsite/plugins.py +48 -0
  98. adminsite/py.typed +0 -0
  99. adminsite/query.py +150 -0
  100. adminsite/saved_views.py +193 -0
  101. adminsite/schema.py +169 -0
  102. adminsite/static/adminsite.css +2 -0
  103. adminsite/static/alpine.min.js +21 -0
  104. adminsite/static/htmx.min.js +1 -0
  105. adminsite/templates/adminsite/_action_forms.html +51 -0
  106. adminsite/templates/adminsite/_action_inputs.html +17 -0
  107. adminsite/templates/adminsite/_browser_timezone.html +12 -0
  108. adminsite/templates/adminsite/_bulk_actions.html +57 -0
  109. adminsite/templates/adminsite/_bulk_summary.html +21 -0
  110. adminsite/templates/adminsite/_document_form.html +225 -0
  111. adminsite/templates/adminsite/_document_json.html +18 -0
  112. adminsite/templates/adminsite/_document_switch.html +15 -0
  113. adminsite/templates/adminsite/_document_view.html +111 -0
  114. adminsite/templates/adminsite/_empty.html +2 -0
  115. adminsite/templates/adminsite/_field.html +55 -0
  116. adminsite/templates/adminsite/_filter_chips.html +26 -0
  117. adminsite/templates/adminsite/_filter_counts.html +9 -0
  118. adminsite/templates/adminsite/_filters.html +136 -0
  119. adminsite/templates/adminsite/_filters_count.html +4 -0
  120. adminsite/templates/adminsite/_filters_footer.html +18 -0
  121. adminsite/templates/adminsite/_form_layout.html +73 -0
  122. adminsite/templates/adminsite/_history.html +62 -0
  123. adminsite/templates/adminsite/_icons.html +54 -0
  124. adminsite/templates/adminsite/_inlines.html +107 -0
  125. adminsite/templates/adminsite/_json.html +73 -0
  126. adminsite/templates/adminsite/_list_forms.html +52 -0
  127. adminsite/templates/adminsite/_list_menus.html +27 -0
  128. adminsite/templates/adminsite/_lookup.html +6 -0
  129. adminsite/templates/adminsite/_message.html +27 -0
  130. adminsite/templates/adminsite/_option_count.html +9 -0
  131. adminsite/templates/adminsite/_page_numbers.html +21 -0
  132. adminsite/templates/adminsite/_palette.html +18 -0
  133. adminsite/templates/adminsite/_records_count.html +12 -0
  134. adminsite/templates/adminsite/_records_counted.html +10 -0
  135. adminsite/templates/adminsite/_records_total.html +9 -0
  136. adminsite/templates/adminsite/_row.html +87 -0
  137. adminsite/templates/adminsite/_row_actions.html +25 -0
  138. adminsite/templates/adminsite/_saved_tabs.html +33 -0
  139. adminsite/templates/adminsite/_table.html +220 -0
  140. adminsite/templates/adminsite/_toolbar.html +38 -0
  141. adminsite/templates/adminsite/_value_editing.html +137 -0
  142. adminsite/templates/adminsite/_value_editor.html +18 -0
  143. adminsite/templates/adminsite/_value_saved.html +4 -0
  144. adminsite/templates/adminsite/_values.html +19 -0
  145. adminsite/templates/adminsite/activity.html +78 -0
  146. adminsite/templates/adminsite/base.html +1214 -0
  147. adminsite/templates/adminsite/dashboard/chart.html +61 -0
  148. adminsite/templates/adminsite/dashboard/counts.html +17 -0
  149. adminsite/templates/adminsite/dashboard/recent.html +15 -0
  150. adminsite/templates/adminsite/dashboard/stat.html +23 -0
  151. adminsite/templates/adminsite/detail.html +268 -0
  152. adminsite/templates/adminsite/error.html +16 -0
  153. adminsite/templates/adminsite/form.html +158 -0
  154. adminsite/templates/adminsite/import.html +61 -0
  155. adminsite/templates/adminsite/import_preview.html +95 -0
  156. adminsite/templates/adminsite/index.html +33 -0
  157. adminsite/templates/adminsite/list.html +110 -0
  158. adminsite/templates/adminsite/login.html +63 -0
  159. adminsite/templates/adminsite/widgets/_option.html +14 -0
  160. adminsite/templates/adminsite/widgets/_picker.html +108 -0
  161. adminsite/templates/adminsite/widgets/checkbox.html +6 -0
  162. adminsite/templates/adminsite/widgets/file.html +20 -0
  163. adminsite/templates/adminsite/widgets/image.html +1 -0
  164. adminsite/templates/adminsite/widgets/input.html +7 -0
  165. adminsite/templates/adminsite/widgets/json.html +48 -0
  166. adminsite/templates/adminsite/widgets/list.html +4 -0
  167. adminsite/templates/adminsite/widgets/password.html +6 -0
  168. adminsite/templates/adminsite/widgets/readonly.html +5 -0
  169. adminsite/templates/adminsite/widgets/relation.html +32 -0
  170. adminsite/templates/adminsite/widgets/select.html +18 -0
  171. adminsite/templates/adminsite/widgets/textarea.html +3 -0
  172. adminsite/timezones.py +146 -0
  173. adminsite/views/__init__.py +19 -0
  174. adminsite/views/_actions.py +452 -0
  175. adminsite/views/_audit.py +151 -0
  176. adminsite/views/_checks.py +398 -0
  177. adminsite/views/_fields.py +291 -0
  178. adminsite/views/_forms.py +271 -0
  179. adminsite/views/_inline_views.py +150 -0
  180. adminsite/views/_linked_names.py +106 -0
  181. adminsite/views/_links.py +124 -0
  182. adminsite/views/_pages.py +423 -0
  183. adminsite/views/_picker.py +203 -0
  184. adminsite/views/_reader.py +407 -0
  185. adminsite/views/_saver.py +414 -0
  186. adminsite/views/_settings.py +277 -0
  187. adminsite/views/contexts.py +225 -0
  188. adminsite/views/inlines.py +67 -0
  189. adminsite/views/layout.py +284 -0
  190. adminsite/views/model_view.py +761 -0
  191. adminsite/views/registry.py +152 -0
  192. adminsite-0.1.0.dist-info/METADATA +172 -0
  193. adminsite-0.1.0.dist-info/RECORD +195 -0
  194. adminsite-0.1.0.dist-info/WHEEL +4 -0
  195. adminsite-0.1.0.dist-info/licenses/LICENSE +21 -0
adminsite/__init__.py ADDED
@@ -0,0 +1,69 @@
1
+ from importlib.metadata import version
2
+
3
+ from adminsite.admin import Admin
4
+ from adminsite.columns import ColumnReference, Descending, Link
5
+ from adminsite.dashboard import Chart, ModelCounts, RecentRecords, Stat, Widget
6
+ from adminsite.database import Statement
7
+ from adminsite.exceptions import (
8
+ AdminSiteError,
9
+ PermissionDeniedError,
10
+ RecordNotFoundError,
11
+ RefusedError,
12
+ )
13
+ from adminsite.fields import BaseField, Field
14
+ from adminsite.markup import Html
15
+ from adminsite.messages import Message
16
+ from adminsite.pages import AdminPage
17
+ from adminsite.permissions import Permission, RequestAction
18
+ from adminsite.plugins import Plugin
19
+ from adminsite.query import CountMode, Pagination
20
+ from adminsite.saved_views import SavedViews
21
+ from adminsite.views import (
22
+ DeleteContext,
23
+ FieldsetWidget,
24
+ Inline,
25
+ ModelView,
26
+ PanelWidget,
27
+ RowWidget,
28
+ SaveContext,
29
+ TabsWidget,
30
+ )
31
+
32
+ __version__ = version("adminsite")
33
+
34
+ __all__ = [
35
+ "Admin",
36
+ "AdminPage",
37
+ "AdminSiteError",
38
+ "BaseField",
39
+ "Chart",
40
+ "ColumnReference",
41
+ "CountMode",
42
+ "DeleteContext",
43
+ "Descending",
44
+ "Field",
45
+ "FieldsetWidget",
46
+ "Html",
47
+ "Inline",
48
+ "Link",
49
+ "Message",
50
+ "ModelCounts",
51
+ "ModelView",
52
+ "Pagination",
53
+ "PanelWidget",
54
+ "Permission",
55
+ "PermissionDeniedError",
56
+ "Plugin",
57
+ "RecentRecords",
58
+ "RecordNotFoundError",
59
+ "RefusedError",
60
+ "RequestAction",
61
+ "RowWidget",
62
+ "SaveContext",
63
+ "SavedViews",
64
+ "Stat",
65
+ "Statement",
66
+ "TabsWidget",
67
+ "Widget",
68
+ "__version__",
69
+ ]
@@ -0,0 +1 @@
1
+ __all__ = []
@@ -0,0 +1,163 @@
1
+ """Running an action over rows, one record or the whole view."""
2
+
3
+ from collections.abc import Mapping
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ from starlette.exceptions import HTTPException
7
+ from starlette.requests import Request
8
+ from starlette.responses import RedirectResponse, Response
9
+
10
+ from adminsite._http.listing import read_list_request
11
+ from adminsite._http.requests import find_view, key_of, read_form
12
+ from adminsite._http.urls import Urls
13
+ from adminsite.actions import Selection
14
+ from adminsite.exceptions import (
15
+ IntegrityError,
16
+ RefusedError,
17
+ )
18
+ from adminsite.i18n import gettext as _
19
+ from adminsite.messages import add_message
20
+ from adminsite.views import ModelView
21
+
22
+ if TYPE_CHECKING:
23
+ from adminsite.admin import Admin
24
+ from adminsite.database import SessionAdapter
25
+
26
+ __all__ = [
27
+ "back_from_action",
28
+ "back_to_list",
29
+ "perform",
30
+ "run_action",
31
+ ]
32
+
33
+
34
+ async def run_action(admin: "Admin", request: Request) -> Response:
35
+ """Run an action: over the chosen rows, over one record, or over the view."""
36
+ view = find_view(admin, request)
37
+ found = view._actions.find(request.path_params["name"], request)
38
+ if found is None:
39
+ raise HTTPException(status_code=404, detail=_("No such action."))
40
+
41
+ submitted = await read_form(request)
42
+ inputs = view._forms.parse_action_inputs(found, submitted)
43
+ if not inputs.ok:
44
+ problems = _("; ").join(
45
+ _(
46
+ "{field}: {problem}",
47
+ field=found.input_label(item),
48
+ problem=inputs.errors[item.name],
49
+ )
50
+ for item in found.inputs
51
+ if item.name in inputs.errors
52
+ )
53
+ add_message(
54
+ request,
55
+ _(
56
+ "{action} was not done. {problems}",
57
+ action=found.label,
58
+ problems=problems,
59
+ ),
60
+ kind="error",
61
+ )
62
+ return back_from_action(admin, request, view, found, submitted)
63
+
64
+ async with admin.database.session() as session:
65
+ try:
66
+ # Anything that fails rolls the action back before the error page,
67
+ # so the audit log can write the attempt down as failed.
68
+ async with session.transaction():
69
+ answer = await perform(
70
+ admin, request, view, found, session, submitted, inputs.values
71
+ )
72
+ except RefusedError as error:
73
+ add_message(request, str(error), kind="error")
74
+ return back_from_action(admin, request, view, found, submitted)
75
+ except IntegrityError as error:
76
+ add_message(
77
+ request,
78
+ _(
79
+ "{action} was not done. {reason}",
80
+ action=found.label,
81
+ reason=str(error),
82
+ ),
83
+ kind="error",
84
+ )
85
+ return back_from_action(admin, request, view, found, submitted)
86
+ # An action that answers with a file or JSON sends it as it is.
87
+ if isinstance(answer, Response):
88
+ return answer
89
+
90
+ add_message(request, answer)
91
+ return back_from_action(admin, request, view, found, submitted)
92
+
93
+
94
+ async def perform(
95
+ admin: "Admin",
96
+ request: Request,
97
+ view: ModelView[Any],
98
+ found: Any,
99
+ session: "SessionAdapter",
100
+ submitted: Mapping[str, Any],
101
+ values: Mapping[str, Any],
102
+ ) -> Any:
103
+ """Run one action, whatever it acts on."""
104
+ if found.on_view:
105
+ return await view._actions.run_on_view(
106
+ found, session, request=request, values=values
107
+ )
108
+
109
+ keys = submitted.get("keys", [])
110
+ chosen = [str(key) for key in (keys if isinstance(keys, list) else [keys])]
111
+
112
+ if found.on_record:
113
+ record = None
114
+ if chosen:
115
+ record = await view._reader.fetch_record(
116
+ session,
117
+ key_of(chosen[0]),
118
+ paths=view._pages.load_paths(request),
119
+ request=request,
120
+ )
121
+ if record is None:
122
+ raise HTTPException(status_code=404, detail=_("No such record."))
123
+ return await view._actions.run_on_record(
124
+ found, record, session, request=request, values=values
125
+ )
126
+
127
+ read = read_list_request(request, view)
128
+ spec = view._reader.build_spec(
129
+ request=request, search=read.search, filters=read.values, sort=read.sort
130
+ )
131
+ selection = Selection(
132
+ view=view,
133
+ session=session,
134
+ spec=spec,
135
+ keys=tuple(chosen),
136
+ everything=submitted.get("everything") == "1",
137
+ request=request,
138
+ )
139
+ return await view._actions.run_on_selection(
140
+ found, selection, request=request, values=values
141
+ )
142
+
143
+
144
+ def back_from_action(
145
+ admin: "Admin",
146
+ request: Request,
147
+ view: ModelView[Any],
148
+ found: Any,
149
+ submitted: Mapping[str, Any],
150
+ ) -> RedirectResponse:
151
+ """Where an action lands: the record it ran on, or the list it came from."""
152
+ keys = submitted.get("keys", [])
153
+ chosen = keys if isinstance(keys, list) else [keys]
154
+ if found.on_record and chosen and view.can_view_detail:
155
+ return RedirectResponse(Urls(request).detail(view, str(chosen[0])), 303)
156
+ return back_to_list(request, view)
157
+
158
+
159
+ def back_to_list(request: Request, view: ModelView[Any]) -> RedirectResponse:
160
+ """Return to the list the action was started from."""
161
+ query = request.url.query
162
+ target = Urls(request).list(view)
163
+ return RedirectResponse(f"{target}?{query}" if query else target, status_code=303)
@@ -0,0 +1,295 @@
1
+ from collections.abc import Sequence
2
+ from dataclasses import dataclass, replace
3
+ from datetime import UTC, date, datetime, time, timedelta
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ from starlette.datastructures import QueryParams
7
+ from starlette.exceptions import HTTPException
8
+ from starlette.requests import Request
9
+ from starlette.responses import Response
10
+
11
+ from adminsite._http.history import describe
12
+ from adminsite._http.urls import Urls
13
+ from adminsite.audit import AuditEntry, AuditEvent, AuditQuery, AuditStore
14
+ from adminsite.audit.entry import SIGN_IN_EVENTS
15
+ from adminsite.exceptions import PermissionDeniedError
16
+ from adminsite.i18n import gettext as _
17
+ from adminsite.permissions import Permission
18
+ from adminsite.timezones import to_column_time
19
+
20
+ if TYPE_CHECKING:
21
+ from adminsite.admin import Admin
22
+ from adminsite.views import ModelView
23
+
24
+ __all__ = [
25
+ "ACTIVITY_PAGE",
26
+ "LARGEST_READ",
27
+ "READS_PER_PAGE",
28
+ "ActivityFilters",
29
+ "activity",
30
+ "event_choices",
31
+ "position_of",
32
+ "read_filters",
33
+ "read_page",
34
+ "read_position",
35
+ "readable_entries",
36
+ ]
37
+
38
+ # How many entries one page of the Activity page shows.
39
+ ACTIVITY_PAGE = 50
40
+ # A page whose entries are mostly kept from the person reading reads on,
41
+ # each read twice the one before, up to the largest, and stops after so
42
+ # many reads: the next page goes on from there.
43
+ LARGEST_READ = 400
44
+ READS_PER_PAGE = 6
45
+
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class ActivityFilters:
49
+ """What the Activity page was asked to show, read from its address."""
50
+
51
+ view: str | None = None
52
+ event: AuditEvent | None = None
53
+ user: str | None = None
54
+ since: date | None = None
55
+ until: date | None = None
56
+ record: str | None = None
57
+ older: str | None = None
58
+
59
+ def query(self, allowed: Sequence[str]) -> AuditQuery:
60
+ """The entries these filters ask for, among the views one may read."""
61
+ return AuditQuery(
62
+ views=allowed,
63
+ view=self.view,
64
+ record_key=self.record,
65
+ user=self.user,
66
+ events=[self.event] if self.event is not None else (),
67
+ since=_start_of(self.since),
68
+ # The last day counts in full.
69
+ until=_start_of(self.until + timedelta(days=1) if self.until else None),
70
+ older_than=read_position(self.older),
71
+ )
72
+
73
+ def params(self, **changes: Any) -> dict[str, Any]:
74
+ """The filters as the address holds them, with some changed."""
75
+ found: dict[str, Any] = {
76
+ "view": self.view,
77
+ "event": self.event.value if self.event is not None else None,
78
+ "user": self.user,
79
+ "since": self.since.isoformat() if self.since else None,
80
+ "until": self.until.isoformat() if self.until else None,
81
+ "record": self.record,
82
+ "older": self.older,
83
+ }
84
+ found.update(changes)
85
+ return found
86
+
87
+ @property
88
+ def narrowed(self) -> bool:
89
+ """Whether anything beyond the view narrows the list."""
90
+ return any((self.event, self.user, self.since, self.until, self.record))
91
+
92
+
93
+ def read_filters(
94
+ params: QueryParams, allowed: Sequence[str], events: Sequence[AuditEvent]
95
+ ) -> ActivityFilters:
96
+ """Read the filters, dropping any the person may not use or that do not parse."""
97
+ view = params.get("view") or None
98
+ event = _event(params.get("event"))
99
+ return ActivityFilters(
100
+ view=view if view in allowed and view else None,
101
+ event=event if event in events else None,
102
+ user=_text(params.get("user")),
103
+ since=_day(params.get("since")),
104
+ until=_day(params.get("until")),
105
+ record=_text(params.get("record")),
106
+ older=params.get("older") if read_position(params.get("older")) else None,
107
+ )
108
+
109
+
110
+ def event_choices(sign_ins: bool) -> list[tuple[AuditEvent, str]]:
111
+ """The kinds of entry to filter by, with their names, sign ins if allowed."""
112
+ choices = [
113
+ (AuditEvent.CREATED, _("Created")),
114
+ (AuditEvent.UPDATED, _("Changed")),
115
+ (AuditEvent.DELETED, _("Deleted")),
116
+ (AuditEvent.ACTION, _("Actions")),
117
+ (AuditEvent.EXPORTED, _("Exports")),
118
+ (AuditEvent.SIGNED_IN, _("Sign ins")),
119
+ (AuditEvent.SIGN_IN_FAILED, _("Failed sign ins")),
120
+ (AuditEvent.SIGNED_OUT, _("Sign outs")),
121
+ ]
122
+ return [item for item in choices if sign_ins or item[0] not in SIGN_IN_EVENTS]
123
+
124
+
125
+ def position_of(entry: AuditEntry) -> str:
126
+ """Where a page ends, for the address of the page after it."""
127
+ return f"{entry.occurred_at.isoformat()}_{entry.id}"
128
+
129
+
130
+ def read_position(text: str | None) -> tuple[datetime, int] | None:
131
+ """Read back what `position_of` wrote, or nothing if it does not parse."""
132
+ if not text or "_" not in text:
133
+ return None
134
+ when, _separator, key = text.rpartition("_")
135
+ try:
136
+ return datetime.fromisoformat(when), int(key)
137
+ except ValueError:
138
+ return None
139
+
140
+
141
+ def _event(value: str | None) -> AuditEvent | None:
142
+ try:
143
+ return AuditEvent(value) if value else None
144
+ except ValueError:
145
+ return None
146
+
147
+
148
+ def _day(value: str | None) -> date | None:
149
+ try:
150
+ return date.fromisoformat(value) if value else None
151
+ except ValueError:
152
+ return None
153
+
154
+
155
+ def _text(value: str | None) -> str | None:
156
+ value = (value or "").strip()
157
+ return value[:200] or None
158
+
159
+
160
+ def _start_of(day: date | None) -> datetime | None:
161
+ # The day starts at midnight on the reader's clock, and the log keeps UTC.
162
+ if day is None:
163
+ return None
164
+ return to_column_time(datetime.combine(day, time.min), stored_in=UTC)
165
+
166
+
167
+ async def readable_entries(
168
+ admin: "Admin",
169
+ entries: Sequence[AuditEntry],
170
+ views: Sequence["ModelView[Any]"],
171
+ request: Request,
172
+ ) -> list[AuditEntry]:
173
+ """The entries whose record this person may read the history of.
174
+
175
+ Each view is asked once about all of its records among the entries. An
176
+ entry about no record, such as an export, needs only its view.
177
+ """
178
+ by_name = {view.name: view for view in views}
179
+ keys: dict[str, set[str]] = {}
180
+ for entry in entries:
181
+ if entry.record_key and entry.view in by_name:
182
+ keys.setdefault(entry.view, set()).add(entry.record_key)
183
+ readable: dict[str, set[str]] = {}
184
+ if keys:
185
+ async with admin.database.session() as session:
186
+ for name, wanted in keys.items():
187
+ readable[name] = await by_name[name]._reader.readable_history(
188
+ session, wanted, request=request
189
+ )
190
+ return [
191
+ entry
192
+ for entry in entries
193
+ if not entry.record_key or entry.record_key in readable.get(entry.view, ())
194
+ ]
195
+
196
+
197
+ async def read_page(
198
+ admin: "Admin",
199
+ store: AuditStore,
200
+ query: AuditQuery,
201
+ views: Sequence["ModelView[Any]"],
202
+ request: Request,
203
+ ) -> tuple[list[AuditEntry], AuditEntry | None]:
204
+ """A page of the entries this person may read, and the one the next page follows.
205
+
206
+ The query keeps to the views they may read. Which records they may read
207
+ the history of is only known once the entries are read, since the log
208
+ may be kept apart from the records, so a page reads on past the entries
209
+ it leaves out until it is full, the log runs out, or it has read
210
+ READS_PER_PAGE times.
211
+ """
212
+ kept: list[AuditEntry] = []
213
+ found: list[AuditEntry] = []
214
+ position = query.older_than
215
+ for read in range(READS_PER_PAGE):
216
+ # One more than a page says whether there is a page after this one.
217
+ size = min((ACTIVITY_PAGE + 1) * 2**read, LARGEST_READ)
218
+ found = await store.find(replace(query, older_than=position), limit=size)
219
+ kept += await readable_entries(admin, found, views, request)
220
+ if len(kept) > ACTIVITY_PAGE:
221
+ return kept[:ACTIVITY_PAGE], kept[ACTIVITY_PAGE - 1]
222
+ if len(found) < size or found[-1].id is None:
223
+ return kept, None
224
+ position = (found[-1].occurred_at, found[-1].id)
225
+ return kept, found[-1]
226
+
227
+
228
+ async def activity(admin: "Admin", request: Request) -> Response:
229
+ """The log across the admin, newest first, filtered and a page at a time."""
230
+ if admin.audit is None:
231
+ raise HTTPException(status_code=404, detail=_("Auditing is not switched on."))
232
+
233
+ readable = await admin.history_views(request)
234
+ allowed = [view.name for view in readable]
235
+ # Signing in happens to no record, so its entries have no view, and are
236
+ # for someone who reads every record of every view.
237
+ everything = len(readable) == len(admin.views.views) and not any(
238
+ view._reader.narrows(request) for view in readable
239
+ )
240
+ sign_ins = admin.auth is not None and await admin.auth.may_read_sign_ins(
241
+ request, reads_everything=everything
242
+ )
243
+ if sign_ins:
244
+ allowed.append("")
245
+ if not allowed:
246
+ raise PermissionDeniedError(Permission.HISTORY.value, _("the activity"))
247
+
248
+ choices = event_choices(sign_ins)
249
+ filters = read_filters(
250
+ request.query_params, allowed, [event for event, _label in choices]
251
+ )
252
+ entries, last = await read_page(
253
+ admin, admin.audit, filters.query(allowed), readable, request
254
+ )
255
+
256
+ urls = Urls(request)
257
+ older = (
258
+ urls.activity(**filters.params(older=position_of(last)))
259
+ if last is not None
260
+ else None
261
+ )
262
+ tabs = [
263
+ (_("Everything"), urls.activity(**filters.params(view=None, older=None)), None),
264
+ *(
265
+ (
266
+ item.label_plural,
267
+ urls.activity(**filters.params(view=item.name, older=None)),
268
+ item.name,
269
+ )
270
+ for item in readable
271
+ ),
272
+ ]
273
+ return await admin.render(
274
+ "activity.html",
275
+ request,
276
+ {
277
+ "items": describe(admin, entries, request),
278
+ "tabs": tabs,
279
+ "filters": filters,
280
+ "event_choices": choices,
281
+ "older_url": older,
282
+ "newest_url": urls.activity(**filters.params(older=None))
283
+ if filters.older
284
+ else None,
285
+ "clear_url": urls.activity(view=filters.view),
286
+ "detail_views": {
287
+ view.name
288
+ for view in await admin.views_allowing(
289
+ request, Permission.VIEW, Permission.VIEW_DETAIL
290
+ )
291
+ },
292
+ "view": None,
293
+ "on_activity": True,
294
+ },
295
+ )