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.
- adminsite/__init__.py +69 -0
- adminsite/_http/__init__.py +1 -0
- adminsite/_http/actions.py +163 -0
- adminsite/_http/activity.py +295 -0
- adminsite/_http/api.py +461 -0
- adminsite/_http/csrf.py +71 -0
- adminsite/_http/dashboard.py +21 -0
- adminsite/_http/detail.py +278 -0
- adminsite/_http/document_forms.py +229 -0
- adminsite/_http/editing.py +339 -0
- adminsite/_http/export.py +166 -0
- adminsite/_http/files.py +30 -0
- adminsite/_http/form_rows.py +123 -0
- adminsite/_http/forms.py +278 -0
- adminsite/_http/history.py +234 -0
- adminsite/_http/importing.py +231 -0
- adminsite/_http/inline_tables.py +289 -0
- adminsite/_http/languages.py +33 -0
- adminsite/_http/list_editing.py +184 -0
- adminsite/_http/listing.py +599 -0
- adminsite/_http/lookups.py +113 -0
- adminsite/_http/pages.py +33 -0
- adminsite/_http/palette.py +121 -0
- adminsite/_http/requests.py +107 -0
- adminsite/_http/saved_views.py +115 -0
- adminsite/_http/signing_in.py +114 -0
- adminsite/_http/templating.py +169 -0
- adminsite/_http/timezones.py +86 -0
- adminsite/_http/urls.py +226 -0
- adminsite/_importing.py +446 -0
- adminsite/_orm/__init__.py +1 -0
- adminsite/_orm/cursor.py +65 -0
- adminsite/_orm/loader.py +66 -0
- adminsite/_orm/repository.py +767 -0
- adminsite/_orm/values.py +39 -0
- adminsite/_storage.py +86 -0
- adminsite/_text.py +104 -0
- adminsite/actions/__init__.py +9 -0
- adminsite/actions/_parameters.py +595 -0
- adminsite/actions/base.py +262 -0
- adminsite/actions/selection.py +257 -0
- adminsite/admin.py +831 -0
- adminsite/audit/__init__.py +22 -0
- adminsite/audit/_actor.py +52 -0
- adminsite/audit/_inputs.py +72 -0
- adminsite/audit/entry.py +218 -0
- adminsite/audit/log.py +181 -0
- adminsite/audit/store.py +116 -0
- adminsite/auth/__init__.py +11 -0
- adminsite/auth/passwords.py +70 -0
- adminsite/auth/provider.py +255 -0
- adminsite/columns.py +167 -0
- adminsite/dashboard.py +496 -0
- adminsite/database.py +491 -0
- adminsite/exceptions.py +170 -0
- adminsite/fields/__init__.py +46 -0
- adminsite/fields/_documents/__init__.py +20 -0
- adminsite/fields/_documents/document.py +496 -0
- adminsite/fields/_documents/error_messages.py +115 -0
- adminsite/fields/_documents/inputs.py +294 -0
- adminsite/fields/_documents/schema_reader.py +302 -0
- adminsite/fields/_documents/shapes.py +118 -0
- adminsite/fields/_documents/shown.py +123 -0
- adminsite/fields/base.py +326 -0
- adminsite/fields/choices.py +294 -0
- adminsite/fields/computed.py +145 -0
- adminsite/fields/files.py +200 -0
- adminsite/fields/json_field.py +260 -0
- adminsite/fields/lists.py +101 -0
- adminsite/fields/passwords.py +57 -0
- adminsite/fields/registry.py +118 -0
- adminsite/fields/relations.py +119 -0
- adminsite/fields/scalars.py +170 -0
- adminsite/fields/temporal.py +169 -0
- adminsite/fields/tones.py +36 -0
- adminsite/files.py +183 -0
- adminsite/filters/__init__.py +28 -0
- adminsite/filters/base.py +147 -0
- adminsite/filters/sql.py +542 -0
- adminsite/i18n.py +446 -0
- adminsite/inspector.py +280 -0
- adminsite/locales/ar.json +476 -0
- adminsite/locales/de.json +424 -0
- adminsite/locales/es.json +424 -0
- adminsite/locales/fa.json +424 -0
- adminsite/locales/fr.json +424 -0
- adminsite/locales/ja.json +385 -0
- adminsite/locales/pt-BR.json +424 -0
- adminsite/locales/ru.json +437 -0
- adminsite/locales/tr.json +424 -0
- adminsite/locales/zh-Hans.json +385 -0
- adminsite/locales/zh-Hant.json +385 -0
- adminsite/markup.py +43 -0
- adminsite/messages.py +101 -0
- adminsite/pages.py +126 -0
- adminsite/permissions.py +54 -0
- adminsite/plugins.py +48 -0
- adminsite/py.typed +0 -0
- adminsite/query.py +150 -0
- adminsite/saved_views.py +193 -0
- adminsite/schema.py +169 -0
- adminsite/static/adminsite.css +2 -0
- adminsite/static/alpine.min.js +21 -0
- adminsite/static/htmx.min.js +1 -0
- adminsite/templates/adminsite/_action_forms.html +51 -0
- adminsite/templates/adminsite/_action_inputs.html +17 -0
- adminsite/templates/adminsite/_browser_timezone.html +12 -0
- adminsite/templates/adminsite/_bulk_actions.html +57 -0
- adminsite/templates/adminsite/_bulk_summary.html +21 -0
- adminsite/templates/adminsite/_document_form.html +225 -0
- adminsite/templates/adminsite/_document_json.html +18 -0
- adminsite/templates/adminsite/_document_switch.html +15 -0
- adminsite/templates/adminsite/_document_view.html +111 -0
- adminsite/templates/adminsite/_empty.html +2 -0
- adminsite/templates/adminsite/_field.html +55 -0
- adminsite/templates/adminsite/_filter_chips.html +26 -0
- adminsite/templates/adminsite/_filter_counts.html +9 -0
- adminsite/templates/adminsite/_filters.html +136 -0
- adminsite/templates/adminsite/_filters_count.html +4 -0
- adminsite/templates/adminsite/_filters_footer.html +18 -0
- adminsite/templates/adminsite/_form_layout.html +73 -0
- adminsite/templates/adminsite/_history.html +62 -0
- adminsite/templates/adminsite/_icons.html +54 -0
- adminsite/templates/adminsite/_inlines.html +107 -0
- adminsite/templates/adminsite/_json.html +73 -0
- adminsite/templates/adminsite/_list_forms.html +52 -0
- adminsite/templates/adminsite/_list_menus.html +27 -0
- adminsite/templates/adminsite/_lookup.html +6 -0
- adminsite/templates/adminsite/_message.html +27 -0
- adminsite/templates/adminsite/_option_count.html +9 -0
- adminsite/templates/adminsite/_page_numbers.html +21 -0
- adminsite/templates/adminsite/_palette.html +18 -0
- adminsite/templates/adminsite/_records_count.html +12 -0
- adminsite/templates/adminsite/_records_counted.html +10 -0
- adminsite/templates/adminsite/_records_total.html +9 -0
- adminsite/templates/adminsite/_row.html +87 -0
- adminsite/templates/adminsite/_row_actions.html +25 -0
- adminsite/templates/adminsite/_saved_tabs.html +33 -0
- adminsite/templates/adminsite/_table.html +220 -0
- adminsite/templates/adminsite/_toolbar.html +38 -0
- adminsite/templates/adminsite/_value_editing.html +137 -0
- adminsite/templates/adminsite/_value_editor.html +18 -0
- adminsite/templates/adminsite/_value_saved.html +4 -0
- adminsite/templates/adminsite/_values.html +19 -0
- adminsite/templates/adminsite/activity.html +78 -0
- adminsite/templates/adminsite/base.html +1214 -0
- adminsite/templates/adminsite/dashboard/chart.html +61 -0
- adminsite/templates/adminsite/dashboard/counts.html +17 -0
- adminsite/templates/adminsite/dashboard/recent.html +15 -0
- adminsite/templates/adminsite/dashboard/stat.html +23 -0
- adminsite/templates/adminsite/detail.html +268 -0
- adminsite/templates/adminsite/error.html +16 -0
- adminsite/templates/adminsite/form.html +158 -0
- adminsite/templates/adminsite/import.html +61 -0
- adminsite/templates/adminsite/import_preview.html +95 -0
- adminsite/templates/adminsite/index.html +33 -0
- adminsite/templates/adminsite/list.html +110 -0
- adminsite/templates/adminsite/login.html +63 -0
- adminsite/templates/adminsite/widgets/_option.html +14 -0
- adminsite/templates/adminsite/widgets/_picker.html +108 -0
- adminsite/templates/adminsite/widgets/checkbox.html +6 -0
- adminsite/templates/adminsite/widgets/file.html +20 -0
- adminsite/templates/adminsite/widgets/image.html +1 -0
- adminsite/templates/adminsite/widgets/input.html +7 -0
- adminsite/templates/adminsite/widgets/json.html +48 -0
- adminsite/templates/adminsite/widgets/list.html +4 -0
- adminsite/templates/adminsite/widgets/password.html +6 -0
- adminsite/templates/adminsite/widgets/readonly.html +5 -0
- adminsite/templates/adminsite/widgets/relation.html +32 -0
- adminsite/templates/adminsite/widgets/select.html +18 -0
- adminsite/templates/adminsite/widgets/textarea.html +3 -0
- adminsite/timezones.py +146 -0
- adminsite/views/__init__.py +19 -0
- adminsite/views/_actions.py +452 -0
- adminsite/views/_audit.py +151 -0
- adminsite/views/_checks.py +398 -0
- adminsite/views/_fields.py +291 -0
- adminsite/views/_forms.py +271 -0
- adminsite/views/_inline_views.py +150 -0
- adminsite/views/_linked_names.py +106 -0
- adminsite/views/_links.py +124 -0
- adminsite/views/_pages.py +423 -0
- adminsite/views/_picker.py +203 -0
- adminsite/views/_reader.py +407 -0
- adminsite/views/_saver.py +414 -0
- adminsite/views/_settings.py +277 -0
- adminsite/views/contexts.py +225 -0
- adminsite/views/inlines.py +67 -0
- adminsite/views/layout.py +284 -0
- adminsite/views/model_view.py +761 -0
- adminsite/views/registry.py +152 -0
- adminsite-0.1.0.dist-info/METADATA +172 -0
- adminsite-0.1.0.dist-info/RECORD +195 -0
- adminsite-0.1.0.dist-info/WHEEL +4 -0
- 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
|
+
)
|