obox-builder 1.0.2__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.
- obox_builder/__init__.py +155 -0
- obox_builder/actions.py +425 -0
- obox_builder/auth/__init__.py +20 -0
- obox_builder/auth/base.py +195 -0
- obox_builder/auth/oauth.py +146 -0
- obox_builder/auth/password.py +193 -0
- obox_builder/base.py +2527 -0
- obox_builder/contrib/__init__.py +0 -0
- obox_builder/contrib/beanie/__init__.py +9 -0
- obox_builder/contrib/beanie/admin.py +5 -0
- obox_builder/contrib/beanie/converters.py +232 -0
- obox_builder/contrib/beanie/fields.py +15 -0
- obox_builder/contrib/beanie/filters.py +559 -0
- obox_builder/contrib/beanie/helpers.py +186 -0
- obox_builder/contrib/beanie/view.py +480 -0
- obox_builder/contrib/mongoengine/__init__.py +9 -0
- obox_builder/contrib/mongoengine/admin.py +49 -0
- obox_builder/contrib/mongoengine/converters.py +282 -0
- obox_builder/contrib/mongoengine/exceptions.py +5 -0
- obox_builder/contrib/mongoengine/fields.py +87 -0
- obox_builder/contrib/mongoengine/filters.py +542 -0
- obox_builder/contrib/mongoengine/helpers.py +105 -0
- obox_builder/contrib/mongoengine/view.py +587 -0
- obox_builder/contrib/sqla/__init__.py +9 -0
- obox_builder/contrib/sqla/admin.py +146 -0
- obox_builder/contrib/sqla/converters.py +535 -0
- obox_builder/contrib/sqla/exceptions.py +17 -0
- obox_builder/contrib/sqla/ext/__init__.py +0 -0
- obox_builder/contrib/sqla/ext/pydantic.py +63 -0
- obox_builder/contrib/sqla/fields.py +106 -0
- obox_builder/contrib/sqla/filters.py +451 -0
- obox_builder/contrib/sqla/helpers.py +80 -0
- obox_builder/contrib/sqla/middleware.py +154 -0
- obox_builder/contrib/sqla/types.py +7 -0
- obox_builder/contrib/sqla/view.py +1034 -0
- obox_builder/contrib/sqlmodel/__init__.py +9 -0
- obox_builder/contrib/sqlmodel/admin.py +5 -0
- obox_builder/contrib/sqlmodel/view.py +136 -0
- obox_builder/contrib/tortoise/__init__.py +9 -0
- obox_builder/contrib/tortoise/admin.py +5 -0
- obox_builder/contrib/tortoise/converters.py +208 -0
- obox_builder/contrib/tortoise/exceptions.py +9 -0
- obox_builder/contrib/tortoise/fields.py +12 -0
- obox_builder/contrib/tortoise/filters.py +494 -0
- obox_builder/contrib/tortoise/helpers.py +54 -0
- obox_builder/contrib/tortoise/view.py +538 -0
- obox_builder/converters.py +268 -0
- obox_builder/cookies.py +47 -0
- obox_builder/events.py +455 -0
- obox_builder/exceptions.py +47 -0
- obox_builder/export/__init__.py +108 -0
- obox_builder/export/base.py +491 -0
- obox_builder/export/csv.py +65 -0
- obox_builder/export/helpers.py +107 -0
- obox_builder/export/json.py +26 -0
- obox_builder/export/pdf.py +111 -0
- obox_builder/export/tablib.py +89 -0
- obox_builder/fields.py +2768 -0
- obox_builder/filters/__init__.py +35 -0
- obox_builder/filters/array.py +67 -0
- obox_builder/filters/base.py +166 -0
- obox_builder/filters/boolean.py +22 -0
- obox_builder/filters/date.py +136 -0
- obox_builder/filters/enum.py +49 -0
- obox_builder/filters/generic.py +42 -0
- obox_builder/filters/numeric.py +106 -0
- obox_builder/filters/registry.py +126 -0
- obox_builder/filters/string.py +36 -0
- obox_builder/flash.py +123 -0
- obox_builder/helpers.py +617 -0
- obox_builder/i18n.py +333 -0
- obox_builder/importers/__init__.py +88 -0
- obox_builder/importers/base.py +142 -0
- obox_builder/importers/csv.py +49 -0
- obox_builder/importers/json.py +29 -0
- obox_builder/importers/tablib.py +61 -0
- obox_builder/logging.py +94 -0
- obox_builder/plugins.py +146 -0
- obox_builder/py.typed +0 -0
- obox_builder/routing.py +41 -0
- obox_builder/security/__init__.py +3 -0
- obox_builder/security/csrf.py +144 -0
- obox_builder/static/css/inline-edit.css +126 -0
- obox_builder/static/css/jsoneditor_overrides.css +9 -0
- obox_builder/static/css/panel_widget.css +10 -0
- obox_builder/static/css/select2-tabler.css +125 -0
- obox_builder/static/css/vendor/flatpickr.min.css +13 -0
- obox_builder/static/css/vendor/fontawesome.min.css +9 -0
- obox_builder/static/css/vendor/img/jsoneditor-icons.svg +749 -0
- obox_builder/static/css/vendor/jquery.json-viewer.css +8 -0
- obox_builder/static/css/vendor/jsoneditor.min.css +6 -0
- obox_builder/static/css/vendor/select2.min.css +1 -0
- obox_builder/static/css/vendor/tabler-theme.min.css +7 -0
- obox_builder/static/css/vendor/tabler.min.css +8 -0
- obox_builder/static/i18n/flatpickr/de.js +7 -0
- obox_builder/static/i18n/flatpickr/fr.js +7 -0
- obox_builder/static/i18n/flatpickr/pt.js +7 -0
- obox_builder/static/i18n/flatpickr/ru.js +7 -0
- obox_builder/static/i18n/flatpickr/tr.js +7 -0
- obox_builder/static/i18n/flatpickr/zh.js +7 -0
- obox_builder/static/js/actions.js +204 -0
- obox_builder/static/js/alerts.js +63 -0
- obox_builder/static/js/chart_widget.js +16 -0
- obox_builder/static/js/detail.js +1 -0
- obox_builder/static/js/filter-builder.js +519 -0
- obox_builder/static/js/form.js +330 -0
- obox_builder/static/js/import.js +379 -0
- obox_builder/static/js/list.js +606 -0
- obox_builder/static/js/sa-dom.js +42 -0
- obox_builder/static/js/stat_widget.js +39 -0
- obox_builder/static/js/tabs_widget.js +23 -0
- obox_builder/static/js/utils.js +60 -0
- obox_builder/static/js/vendor/apexcharts.min.js +5 -0
- obox_builder/static/js/vendor/countUp.umd.js +1 -0
- obox_builder/static/js/vendor/flatpickr.min.js +2 -0
- obox_builder/static/js/vendor/fslightbox.js +1 -0
- obox_builder/static/js/vendor/jquery.json-viewer.js +8 -0
- obox_builder/static/js/vendor/jquery.min.js +2 -0
- obox_builder/static/js/vendor/js.cookie.min.js +2 -0
- obox_builder/static/js/vendor/jsoneditor.min.js +49 -0
- obox_builder/static/js/vendor/select2.min.js +2 -0
- obox_builder/static/js/vendor/tabler.min.js +12 -0
- obox_builder/static/webfonts/fa-brands-400.woff2 +0 -0
- obox_builder/static/webfonts/fa-regular-400.woff2 +0 -0
- obox_builder/static/webfonts/fa-solid-900.woff2 +0 -0
- obox_builder/static/webfonts/fa-v4compatibility.woff2 +0 -0
- obox_builder/storage/__init__.py +23 -0
- obox_builder/storage/base.py +225 -0
- obox_builder/storage/local.py +104 -0
- obox_builder/storage/s3.py +199 -0
- obox_builder/templates/_column_header.html +51 -0
- obox_builder/templates/_filter_bar.html +20 -0
- obox_builder/templates/_filter_builder.html +142 -0
- obox_builder/templates/_form_footer.html +18 -0
- obox_builder/templates/_list_row.html +53 -0
- obox_builder/templates/_pagination.html +85 -0
- obox_builder/templates/_sa_templates.html +20 -0
- obox_builder/templates/actions/export_form.html +64 -0
- obox_builder/templates/actions.html +43 -0
- obox_builder/templates/base.html +158 -0
- obox_builder/templates/create.html +71 -0
- obox_builder/templates/detail.html +175 -0
- obox_builder/templates/edit.html +71 -0
- obox_builder/templates/error.html +35 -0
- obox_builder/templates/fields/_copy_button.html +9 -0
- obox_builder/templates/fields/detail/_empty.html +2 -0
- obox_builder/templates/fields/detail/_null.html +2 -0
- obox_builder/templates/fields/detail/boolean.html +9 -0
- obox_builder/templates/fields/detail/collection.html +16 -0
- obox_builder/templates/fields/detail/color.html +9 -0
- obox_builder/templates/fields/detail/email.html +9 -0
- obox_builder/templates/fields/detail/file.html +8 -0
- obox_builder/templates/fields/detail/image.html +14 -0
- obox_builder/templates/fields/detail/json.html +7 -0
- obox_builder/templates/fields/detail/list.html +7 -0
- obox_builder/templates/fields/detail/relation.html +5 -0
- obox_builder/templates/fields/detail/tags.html +7 -0
- obox_builder/templates/fields/detail/text.html +9 -0
- obox_builder/templates/fields/detail/textarea.html +11 -0
- obox_builder/templates/fields/detail/tinymce.html +1 -0
- obox_builder/templates/fields/detail/url.html +9 -0
- obox_builder/templates/fields/form/_delete.html +14 -0
- obox_builder/templates/fields/form/_error.html +3 -0
- obox_builder/templates/fields/form/_label.html +2 -0
- obox_builder/templates/fields/form/boolean.html +13 -0
- obox_builder/templates/fields/form/collection.html +14 -0
- obox_builder/templates/fields/form/computed.html +4 -0
- obox_builder/templates/fields/form/enum.html +22 -0
- obox_builder/templates/fields/form/file.html +18 -0
- obox_builder/templates/fields/form/image.html +15 -0
- obox_builder/templates/fields/form/input.html +26 -0
- obox_builder/templates/fields/form/json.html +10 -0
- obox_builder/templates/fields/form/list.html +48 -0
- obox_builder/templates/fields/form/relation.html +18 -0
- obox_builder/templates/fields/form/slug.html +23 -0
- obox_builder/templates/fields/form/tags.html +16 -0
- obox_builder/templates/fields/form/textarea.html +9 -0
- obox_builder/templates/fields/list/_truncated.html +8 -0
- obox_builder/templates/fields/list/boolean.html +12 -0
- obox_builder/templates/fields/list/collection.html +7 -0
- obox_builder/templates/fields/list/color.html +22 -0
- obox_builder/templates/fields/list/email.html +20 -0
- obox_builder/templates/fields/list/file.html +12 -0
- obox_builder/templates/fields/list/image.html +8 -0
- obox_builder/templates/fields/list/json.html +8 -0
- obox_builder/templates/fields/list/list.html +9 -0
- obox_builder/templates/fields/list/relation.html +8 -0
- obox_builder/templates/fields/list/tags.html +8 -0
- obox_builder/templates/fields/list/text.html +14 -0
- obox_builder/templates/fields/list/url.html +20 -0
- obox_builder/templates/index.html +16 -0
- obox_builder/templates/inline-edit/field.html +11 -0
- obox_builder/templates/inline-edit/popover.html +15 -0
- obox_builder/templates/inline-edit/popover_body.html +43 -0
- obox_builder/templates/inline.html +38 -0
- obox_builder/templates/inline_detail.html +47 -0
- obox_builder/templates/inline_row.html +34 -0
- obox_builder/templates/layout.html +243 -0
- obox_builder/templates/list.html +284 -0
- obox_builder/templates/login.html +151 -0
- obox_builder/templates/macros/views.html +76 -0
- obox_builder/templates/modals/actions.html +37 -0
- obox_builder/templates/modals/import.html +232 -0
- obox_builder/templates/modals/loading.html +18 -0
- obox_builder/templates/row-actions.html +110 -0
- obox_builder/templates/widgets/card_row_widget.html +5 -0
- obox_builder/templates/widgets/chart_widget.html +15 -0
- obox_builder/templates/widgets/column_widget.html +5 -0
- obox_builder/templates/widgets/divider_widget.html +1 -0
- obox_builder/templates/widgets/fieldset_widget.html +4 -0
- obox_builder/templates/widgets/form_field_widget.html +8 -0
- obox_builder/templates/widgets/grid_widget.html +5 -0
- obox_builder/templates/widgets/html_widget.html +1 -0
- obox_builder/templates/widgets/panel_widget.html +18 -0
- obox_builder/templates/widgets/row_widget.html +5 -0
- obox_builder/templates/widgets/stat_widget.html +36 -0
- obox_builder/templates/widgets/table_widget.html +33 -0
- obox_builder/templates/widgets/tabs_widget.html +28 -0
- obox_builder/templates/widgets/text_widget.html +7 -0
- obox_builder/theme.py +346 -0
- obox_builder/tools/__init__.py +7 -0
- obox_builder/tools/iter.py +51 -0
- obox_builder/translations/de/LC_MESSAGES/admin.mo +0 -0
- obox_builder/translations/de/LC_MESSAGES/admin.po +995 -0
- obox_builder/translations/en/LC_MESSAGES/admin.mo +0 -0
- obox_builder/translations/en/LC_MESSAGES/admin.po +1011 -0
- obox_builder/translations/fr/LC_MESSAGES/admin.mo +0 -0
- obox_builder/translations/fr/LC_MESSAGES/admin.po +1004 -0
- obox_builder/translations/pt/LC_MESSAGES/admin.mo +0 -0
- obox_builder/translations/pt/LC_MESSAGES/admin.po +990 -0
- obox_builder/translations/ru/LC_MESSAGES/admin.mo +0 -0
- obox_builder/translations/ru/LC_MESSAGES/admin.po +989 -0
- obox_builder/translations/tr/LC_MESSAGES/admin.mo +0 -0
- obox_builder/translations/tr/LC_MESSAGES/admin.po +985 -0
- obox_builder/translations/zh/LC_MESSAGES/admin.mo +0 -0
- obox_builder/translations/zh/LC_MESSAGES/admin.po +976 -0
- obox_builder/translations/zh_Hant/LC_MESSAGES/admin.mo +0 -0
- obox_builder/translations/zh_Hant/LC_MESSAGES/admin.po +976 -0
- obox_builder/types.py +141 -0
- obox_builder/utils/__init__.py +0 -0
- obox_builder/utils/countries.py +251 -0
- obox_builder/utils/timezones.py +436 -0
- obox_builder/validators.py +728 -0
- obox_builder/views.py +3329 -0
- obox_builder/widgets.py +809 -0
- obox_builder-1.0.2.dist-info/METADATA +182 -0
- obox_builder-1.0.2.dist-info/RECORD +249 -0
- obox_builder-1.0.2.dist-info/WHEEL +4 -0
- obox_builder-1.0.2.dist-info/licenses/LICENSE +21 -0
obox_builder/__init__.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
__version__ = "1.0.2"
|
|
2
|
+
|
|
3
|
+
from .actions import ActionSelection as ActionSelection
|
|
4
|
+
from .actions import action as action
|
|
5
|
+
from .actions import link_row_action as link_row_action
|
|
6
|
+
from .actions import row_action as row_action
|
|
7
|
+
from .base import BaseAdmin as BaseAdmin
|
|
8
|
+
from .fields import ArrowField as ArrowField
|
|
9
|
+
from .fields import BaseField as BaseField
|
|
10
|
+
from .fields import BooleanField as BooleanField
|
|
11
|
+
from .fields import CollectionField as CollectionField
|
|
12
|
+
from .fields import ColorField as ColorField
|
|
13
|
+
from .fields import ComputedField as ComputedField
|
|
14
|
+
from .fields import CountryField as CountryField
|
|
15
|
+
from .fields import CurrencyField as CurrencyField
|
|
16
|
+
from .fields import DateField as DateField
|
|
17
|
+
from .fields import DateTimeField as DateTimeField
|
|
18
|
+
from .fields import DecimalField as DecimalField
|
|
19
|
+
from .fields import EmailField as EmailField
|
|
20
|
+
from .fields import EnumField as EnumField
|
|
21
|
+
from .fields import FileField as FileField
|
|
22
|
+
from .fields import FloatField as FloatField
|
|
23
|
+
from .fields import HasMany as HasMany
|
|
24
|
+
from .fields import HasOne as HasOne
|
|
25
|
+
from .fields import ImageField as ImageField
|
|
26
|
+
from .fields import IntegerField as IntegerField
|
|
27
|
+
from .fields import IPAddressField as IPAddressField
|
|
28
|
+
from .fields import JSONField as JSONField
|
|
29
|
+
from .fields import ListField as ListField
|
|
30
|
+
from .fields import NumberField as NumberField
|
|
31
|
+
from .fields import PasswordField as PasswordField
|
|
32
|
+
from .fields import PhoneField as PhoneField
|
|
33
|
+
from .fields import RelationField as RelationField
|
|
34
|
+
from .fields import SlugField as SlugField
|
|
35
|
+
from .fields import StringField as StringField
|
|
36
|
+
from .fields import TagsField as TagsField
|
|
37
|
+
from .fields import TextAreaField as TextAreaField
|
|
38
|
+
from .fields import TimeField as TimeField
|
|
39
|
+
from .fields import TimeZoneField as TimeZoneField
|
|
40
|
+
from .fields import TinyMCEEditorField as TinyMCEEditorField
|
|
41
|
+
from .fields import URLField as URLField
|
|
42
|
+
from .fields import UUIDField as UUIDField
|
|
43
|
+
from .flash import flash as flash
|
|
44
|
+
from .flash import get_flashed_messages as get_flashed_messages
|
|
45
|
+
from .i18n import I18nConfig as I18nConfig
|
|
46
|
+
from .i18n import TimezoneConfig as TimezoneConfig
|
|
47
|
+
from .plugins import BasePlugin as BasePlugin
|
|
48
|
+
from .plugins import PluginError as PluginError
|
|
49
|
+
from .routing import route as route
|
|
50
|
+
from .theme import BaseTheme as BaseTheme
|
|
51
|
+
from .theme import DefaultTheme as DefaultTheme
|
|
52
|
+
from .theme import TablerSettings as TablerSettings
|
|
53
|
+
from .types import RequestAction as RequestAction
|
|
54
|
+
from .types import RowActionsDisplayType as RowActionsDisplayType
|
|
55
|
+
from .types import RowActionsPosition as RowActionsPosition
|
|
56
|
+
from .views import BaseModelView as BaseModelView
|
|
57
|
+
from .views import CustomView as CustomView
|
|
58
|
+
from .views import DefaultIndexView as DefaultIndexView
|
|
59
|
+
from .views import DropDown as DropDown
|
|
60
|
+
from .views import InlineModelView as InlineModelView
|
|
61
|
+
from .views import Link as Link
|
|
62
|
+
from .widgets import BaseWidget as BaseWidget
|
|
63
|
+
from .widgets import Breakpoints as Breakpoints
|
|
64
|
+
from .widgets import CardRowWidget as CardRowWidget
|
|
65
|
+
from .widgets import ChartWidget as ChartWidget
|
|
66
|
+
from .widgets import Col as Col
|
|
67
|
+
from .widgets import ColumnWidget as ColumnWidget
|
|
68
|
+
from .widgets import DividerWidget as DividerWidget
|
|
69
|
+
from .widgets import FieldRef as FieldRef
|
|
70
|
+
from .widgets import FieldsetWidget as FieldsetWidget
|
|
71
|
+
from .widgets import GridWidget as GridWidget
|
|
72
|
+
from .widgets import HtmlWidget as HtmlWidget
|
|
73
|
+
from .widgets import PanelWidget as PanelWidget
|
|
74
|
+
from .widgets import RowWidget as RowWidget
|
|
75
|
+
from .widgets import StatWidget as StatWidget
|
|
76
|
+
from .widgets import TableWidget as TableWidget
|
|
77
|
+
from .widgets import TabsWidget as TabsWidget
|
|
78
|
+
from .widgets import TextWidget as TextWidget
|
|
79
|
+
|
|
80
|
+
__all__ = [
|
|
81
|
+
"ActionSelection",
|
|
82
|
+
"ArrowField",
|
|
83
|
+
"BaseAdmin",
|
|
84
|
+
"BaseField",
|
|
85
|
+
"BaseModelView",
|
|
86
|
+
"BaseTheme",
|
|
87
|
+
"BaseWidget",
|
|
88
|
+
"BooleanField",
|
|
89
|
+
"Breakpoints",
|
|
90
|
+
"CardRowWidget",
|
|
91
|
+
"ChartWidget",
|
|
92
|
+
"Col",
|
|
93
|
+
"CollectionField",
|
|
94
|
+
"ColorField",
|
|
95
|
+
"ColumnWidget",
|
|
96
|
+
"ComputedField",
|
|
97
|
+
"CountryField",
|
|
98
|
+
"CurrencyField",
|
|
99
|
+
"CustomView",
|
|
100
|
+
"DateField",
|
|
101
|
+
"DateTimeField",
|
|
102
|
+
"DecimalField",
|
|
103
|
+
"DefaultIndexView",
|
|
104
|
+
"DefaultTheme",
|
|
105
|
+
"DividerWidget",
|
|
106
|
+
"DropDown",
|
|
107
|
+
"EmailField",
|
|
108
|
+
"EnumField",
|
|
109
|
+
"FieldRef",
|
|
110
|
+
"FieldsetWidget",
|
|
111
|
+
"FileField",
|
|
112
|
+
"FloatField",
|
|
113
|
+
"GridWidget",
|
|
114
|
+
"HasMany",
|
|
115
|
+
"HasOne",
|
|
116
|
+
"HtmlWidget",
|
|
117
|
+
"I18nConfig",
|
|
118
|
+
"IPAddressField",
|
|
119
|
+
"ImageField",
|
|
120
|
+
"InlineModelView",
|
|
121
|
+
"IntegerField",
|
|
122
|
+
"JSONField",
|
|
123
|
+
"Link",
|
|
124
|
+
"ListField",
|
|
125
|
+
"NumberField",
|
|
126
|
+
"PanelWidget",
|
|
127
|
+
"PasswordField",
|
|
128
|
+
"PhoneField",
|
|
129
|
+
"RelationField",
|
|
130
|
+
"RequestAction",
|
|
131
|
+
"RowActionsDisplayType",
|
|
132
|
+
"RowActionsPosition",
|
|
133
|
+
"RowWidget",
|
|
134
|
+
"SlugField",
|
|
135
|
+
"StatWidget",
|
|
136
|
+
"StringField",
|
|
137
|
+
"TableWidget",
|
|
138
|
+
"TablerSettings",
|
|
139
|
+
"TabsWidget",
|
|
140
|
+
"TagsField",
|
|
141
|
+
"TextAreaField",
|
|
142
|
+
"TextWidget",
|
|
143
|
+
"TimeField",
|
|
144
|
+
"TimeZoneField",
|
|
145
|
+
"TimezoneConfig",
|
|
146
|
+
"TinyMCEEditorField",
|
|
147
|
+
"URLField",
|
|
148
|
+
"UUIDField",
|
|
149
|
+
"action",
|
|
150
|
+
"flash",
|
|
151
|
+
"get_flashed_messages",
|
|
152
|
+
"link_row_action",
|
|
153
|
+
"route",
|
|
154
|
+
"row_action",
|
|
155
|
+
]
|
obox_builder/actions.py
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
2
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
3
|
+
|
|
4
|
+
from obox_builder.exceptions import ActionFailed
|
|
5
|
+
from obox_builder.filters.base import FilterGroup
|
|
6
|
+
from obox_builder.i18n import lazy_gettext as _
|
|
7
|
+
from starlette.requests import Request
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from obox_builder.views import BaseModelView
|
|
11
|
+
|
|
12
|
+
ModalSize = Literal["sm", "md", "lg", "xl"]
|
|
13
|
+
_MODAL_SIZES: frozenset[str] = frozenset(("sm", "md", "lg", "xl"))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ActionSelection:
|
|
17
|
+
"""The rows a batch action targets, resolved lazily.
|
|
18
|
+
|
|
19
|
+
Behaves identically whether the user checked rows one by one or clicked
|
|
20
|
+
"select all matching" in the banner: call `rows()`, `pks()`, or `count()`
|
|
21
|
+
and the right query runs underneath.
|
|
22
|
+
|
|
23
|
+
`is_select_all`, `filters`, and `q` are exposed for handlers that want to
|
|
24
|
+
reason about *how* rows were selected, e.g. to push the whole operation
|
|
25
|
+
down as a single bulk query instead of materializing rows.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
view: "BaseModelView",
|
|
31
|
+
request: Request,
|
|
32
|
+
pks: list[Any] | None,
|
|
33
|
+
filters: FilterGroup | None,
|
|
34
|
+
q: str | None,
|
|
35
|
+
) -> None:
|
|
36
|
+
self._view = view
|
|
37
|
+
self._request = request
|
|
38
|
+
self._pks = pks
|
|
39
|
+
self.is_select_all = pks is None
|
|
40
|
+
self.filters = filters
|
|
41
|
+
self.q = q
|
|
42
|
+
self._rows: Sequence[Any] | None = None
|
|
43
|
+
self._count: int | None = None
|
|
44
|
+
|
|
45
|
+
async def rows(self) -> Sequence[Any]:
|
|
46
|
+
"""The target rows. Fetched once and cached across calls."""
|
|
47
|
+
if self._rows is None:
|
|
48
|
+
if self.is_select_all:
|
|
49
|
+
await self._count_matching_within_limit()
|
|
50
|
+
self._rows = await self._view.find_all(
|
|
51
|
+
self._request, skip=0, limit=-1, q=self.q, filters=self.filters
|
|
52
|
+
)
|
|
53
|
+
else:
|
|
54
|
+
self._rows = await self._view.find_by_pks(
|
|
55
|
+
self._request, self._pks or []
|
|
56
|
+
)
|
|
57
|
+
return self._rows
|
|
58
|
+
|
|
59
|
+
async def pks(self) -> list[Any]:
|
|
60
|
+
"""Primary keys of the target rows."""
|
|
61
|
+
if not self.is_select_all:
|
|
62
|
+
return list(self._pks or [])
|
|
63
|
+
return [
|
|
64
|
+
str(await self._view.get_pk_value(self._request, obj))
|
|
65
|
+
for obj in await self.rows()
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
async def count(self) -> int:
|
|
69
|
+
"""Number of rows the action targets.
|
|
70
|
+
|
|
71
|
+
In select-all mode, this runs a `count()` query and enforces
|
|
72
|
+
`action_select_all_limit`, raising `ActionFailed` when the current
|
|
73
|
+
filter/search matches too many rows.
|
|
74
|
+
"""
|
|
75
|
+
if self._count is not None:
|
|
76
|
+
return self._count
|
|
77
|
+
if self._rows is not None:
|
|
78
|
+
self._count = len(self._rows)
|
|
79
|
+
elif self.is_select_all:
|
|
80
|
+
return await self._count_matching_within_limit()
|
|
81
|
+
else:
|
|
82
|
+
self._count = len(self._pks or [])
|
|
83
|
+
return self._count
|
|
84
|
+
|
|
85
|
+
async def _count_matching_within_limit(self) -> int:
|
|
86
|
+
if self._count is None:
|
|
87
|
+
count = await self._view.count(
|
|
88
|
+
self._request, q=self.q, filters=self.filters
|
|
89
|
+
)
|
|
90
|
+
if count > self._view.action_select_all_limit:
|
|
91
|
+
raise ActionFailed(
|
|
92
|
+
_(
|
|
93
|
+
"Too many rows match the current filter (%(count)s); "
|
|
94
|
+
"refine your filter (limit: %(limit)s)"
|
|
95
|
+
)
|
|
96
|
+
% {"count": count, "limit": self._view.action_select_all_limit}
|
|
97
|
+
)
|
|
98
|
+
self._count = count
|
|
99
|
+
return self._count
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def action(
|
|
103
|
+
name: str,
|
|
104
|
+
text: str,
|
|
105
|
+
confirmation: str | None = None,
|
|
106
|
+
header: str | None = None,
|
|
107
|
+
submit_btn_class: str | None = None,
|
|
108
|
+
submit_btn_text: str | None = _("Yes, Proceed"),
|
|
109
|
+
icon_class: str | None = None,
|
|
110
|
+
form: str | Callable[..., str | Awaitable[str]] | None = None,
|
|
111
|
+
custom_response: bool | None = False,
|
|
112
|
+
allow_empty_selection: bool = False,
|
|
113
|
+
dedicated_button: bool = False,
|
|
114
|
+
modal_size: ModalSize = "sm",
|
|
115
|
+
) -> Callable[[Callable[..., Awaitable[Any]]], Any]:
|
|
116
|
+
"""Decorator to add a custom batch action to a
|
|
117
|
+
[ModelView][obox_builder.views.BaseModelView].
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
name: Unique action name within the ModelView.
|
|
121
|
+
text: Action text displayed to users.
|
|
122
|
+
confirmation: Confirmation text. If not provided, the action executes
|
|
123
|
+
unconditionally.
|
|
124
|
+
header: Title shown in the confirmation modal header. If not provided,
|
|
125
|
+
the modal renders without a header.
|
|
126
|
+
submit_btn_text: Submit button text.
|
|
127
|
+
submit_btn_class: Complete class string for the submit button
|
|
128
|
+
(e.g. `btn btn-outline-danger`), or a class role the active theme
|
|
129
|
+
maps. Defaults to the theme's `action.modal_confirm_button` role.
|
|
130
|
+
icon_class: Icon class (e.g. `fa-lite fa-folder`, `fa-duotone fa-circle-right`).
|
|
131
|
+
form: Custom form to collect data from the user. Either a static HTML
|
|
132
|
+
string, or a callable that builds the form HTML per request —
|
|
133
|
+
`(request) -> str`, or `(request, view) -> str` to also receive the
|
|
134
|
+
`BaseModelView` instance. Sync or async either way.
|
|
135
|
+
custom_response: Set to True when you want to return a custom Starlette response
|
|
136
|
+
instead of `None`.
|
|
137
|
+
allow_empty_selection: Set to True for actions that operate on the whole
|
|
138
|
+
collection rather than on a row selection, such as a full sync or a
|
|
139
|
+
bulk import. These actions render in a separate, always visible
|
|
140
|
+
"Actions" dropdown on the list page and can be triggered without
|
|
141
|
+
selecting any row, so `selection` may resolve to zero rows.
|
|
142
|
+
Defaults to False, which requires at least one selected row.
|
|
143
|
+
dedicated_button: Set to True to render this action as its own toolbar
|
|
144
|
+
button instead of an entry in the grouped "Actions" dropdown.
|
|
145
|
+
Requires `allow_empty_selection=True`; combining it with a
|
|
146
|
+
selection-only action raises `ValueError` at startup.
|
|
147
|
+
modal_size: Size of the confirmation modal: `sm`, `md`, `lg`, or `xl`.
|
|
148
|
+
Defaults to `sm`. Only relevant when `confirmation` is set.
|
|
149
|
+
|
|
150
|
+
!!! usage
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
class ArticleView(ModelView):
|
|
154
|
+
actions = ['make_published', 'redirect']
|
|
155
|
+
|
|
156
|
+
@action(
|
|
157
|
+
name="make_published",
|
|
158
|
+
text="Mark selected articles as published",
|
|
159
|
+
confirmation="Are you sure you want to mark selected articles as published ?",
|
|
160
|
+
submit_btn_text="Yes, proceed",
|
|
161
|
+
submit_btn_class="btn btn-success",
|
|
162
|
+
form='''
|
|
163
|
+
<form>
|
|
164
|
+
<div class="mt-3">
|
|
165
|
+
<input type="text" class="form-control" name="example-text-input" placeholder="Enter value">
|
|
166
|
+
</div>
|
|
167
|
+
</form>
|
|
168
|
+
'''
|
|
169
|
+
)
|
|
170
|
+
async def make_published_action(
|
|
171
|
+
self, request: Request, selection: ActionSelection
|
|
172
|
+
) -> None:
|
|
173
|
+
# Retrieve form data submitted by the user
|
|
174
|
+
data: FormData = await request.form()
|
|
175
|
+
user_input = data.get("example-text-input")
|
|
176
|
+
|
|
177
|
+
for article in await selection.rows():
|
|
178
|
+
# TODO: Implement database update logic here
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
if ...:
|
|
182
|
+
# Halt execution and display a descriptive error to the user
|
|
183
|
+
raise ActionFailed("Sorry, we cannot process this action right now.")
|
|
184
|
+
|
|
185
|
+
# Confirm successful execution via a UI flash message
|
|
186
|
+
flash(request, "{} articles were successfully marked as published".format(await selection.count()), "success")
|
|
187
|
+
|
|
188
|
+
# Return a Starlette Response instead of None when custom_response=True
|
|
189
|
+
@action(
|
|
190
|
+
name="redirect",
|
|
191
|
+
text="Redirect",
|
|
192
|
+
custom_response=True,
|
|
193
|
+
confirmation="Fill the form",
|
|
194
|
+
form='''
|
|
195
|
+
<form>
|
|
196
|
+
<div class="mt-3">
|
|
197
|
+
<input type="text" class="form-control" name="value" placeholder="Enter value">
|
|
198
|
+
</div>
|
|
199
|
+
</form>
|
|
200
|
+
'''
|
|
201
|
+
)
|
|
202
|
+
async def redirect_action(
|
|
203
|
+
self, request: Request, selection: ActionSelection
|
|
204
|
+
) -> Response:
|
|
205
|
+
data = await request.form()
|
|
206
|
+
return RedirectResponse(f"https://example.com/?value={data['value']}")
|
|
207
|
+
|
|
208
|
+
# `form` can also be a callable, to build the form HTML per request
|
|
209
|
+
def build_export_form(request: Request) -> str:
|
|
210
|
+
default_filename = request.query_params.get("q", "export")
|
|
211
|
+
return f'''
|
|
212
|
+
<form>
|
|
213
|
+
<div class="mt-3">
|
|
214
|
+
<input type="text" class="form-control" name="filename" value="{escape(default_filename)}">
|
|
215
|
+
</div>
|
|
216
|
+
</form>
|
|
217
|
+
'''
|
|
218
|
+
|
|
219
|
+
@action(
|
|
220
|
+
name="export",
|
|
221
|
+
text="Export selected",
|
|
222
|
+
confirmation="Choose a filename",
|
|
223
|
+
form=build_export_form,
|
|
224
|
+
)
|
|
225
|
+
async def export_action(
|
|
226
|
+
self, request: Request, selection: ActionSelection
|
|
227
|
+
) -> None:
|
|
228
|
+
data = await request.form()
|
|
229
|
+
filename = data["filename"]
|
|
230
|
+
# TODO: Implement export logic here, e.g. via selection.rows()
|
|
231
|
+
```
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
if modal_size not in _MODAL_SIZES:
|
|
235
|
+
raise ValueError(
|
|
236
|
+
f"Action `{name}`: modal_size must be one of {sorted(_MODAL_SIZES)}, "
|
|
237
|
+
f"got {modal_size!r}"
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
def wrap(f: Callable[..., Awaitable[Any]]) -> Callable[..., Awaitable[Any]]:
|
|
241
|
+
f._action = { # type: ignore
|
|
242
|
+
"name": name,
|
|
243
|
+
"text": text,
|
|
244
|
+
"confirmation": confirmation,
|
|
245
|
+
"header": header,
|
|
246
|
+
"submit_btn_text": submit_btn_text,
|
|
247
|
+
"submit_btn_class": submit_btn_class,
|
|
248
|
+
"icon_class": icon_class,
|
|
249
|
+
"form": form if form is not None else "",
|
|
250
|
+
"custom_response": custom_response,
|
|
251
|
+
"allow_empty_selection": allow_empty_selection,
|
|
252
|
+
"dedicated_button": dedicated_button,
|
|
253
|
+
"modal_size": modal_size,
|
|
254
|
+
}
|
|
255
|
+
return f
|
|
256
|
+
|
|
257
|
+
return wrap
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def row_action(
|
|
261
|
+
name: str,
|
|
262
|
+
text: str,
|
|
263
|
+
confirmation: str | None = None,
|
|
264
|
+
header: str | None = None,
|
|
265
|
+
action_btn_class: str | None = None,
|
|
266
|
+
submit_btn_class: str | None = None,
|
|
267
|
+
submit_btn_text: str | None = _("Yes, Proceed"),
|
|
268
|
+
icon_class: str | None = None,
|
|
269
|
+
form: str | Callable[[Request, Any], str | Awaitable[str]] | None = None,
|
|
270
|
+
custom_response: bool | None = False,
|
|
271
|
+
exclude_from_list: bool = False,
|
|
272
|
+
exclude_from_detail: bool = False,
|
|
273
|
+
modal_size: ModalSize = "sm",
|
|
274
|
+
) -> Callable[[Callable[..., Awaitable[Any]]], Any]:
|
|
275
|
+
"""Decorator to add a custom row action to a
|
|
276
|
+
[ModelView][obox_builder.views.BaseModelView].
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
name: Unique row action name for the ModelView.
|
|
280
|
+
text: Action text displayed to users.
|
|
281
|
+
confirmation: Confirmation text. If provided, the action requires confirmation.
|
|
282
|
+
header: Title shown in the confirmation modal header. If not provided,
|
|
283
|
+
the modal renders without a header.
|
|
284
|
+
action_btn_class: Complete class string for the action button on the
|
|
285
|
+
detail page (e.g. `btn btn-info`), or a class role the active
|
|
286
|
+
theme maps. Defaults to the theme's `action.row_item` role.
|
|
287
|
+
submit_btn_class: Complete class string for the submit button
|
|
288
|
+
(e.g. `btn btn-outline-danger`), or a class role the active theme
|
|
289
|
+
maps. Defaults to the theme's `action.modal_confirm_button` role.
|
|
290
|
+
submit_btn_text: Text for the submit button.
|
|
291
|
+
icon_class: Icon class (e.g. `fa-lite fa-folder`, `fa-duotone fa-circle-right`).
|
|
292
|
+
form: Custom HTML to collect data from the user. Either a static HTML
|
|
293
|
+
string, or a callable `(request, obj) -> str` (sync or async) that
|
|
294
|
+
builds the form HTML for that specific row.
|
|
295
|
+
custom_response: Set to True when you want to return a custom Starlette response
|
|
296
|
+
instead of `None`.
|
|
297
|
+
exclude_from_list: Set to True to exclude the action from the list view.
|
|
298
|
+
exclude_from_detail: Set to True to exclude the action from the detail view.
|
|
299
|
+
modal_size: Size of the confirmation modal: `sm`, `md`, `lg`, or `xl`.
|
|
300
|
+
Defaults to `sm`. Only relevant when `confirmation` is set.
|
|
301
|
+
|
|
302
|
+
!!! usage
|
|
303
|
+
|
|
304
|
+
```python
|
|
305
|
+
@row_action(
|
|
306
|
+
name="make_published",
|
|
307
|
+
text="Mark as published",
|
|
308
|
+
confirmation="Are you sure you want to mark this article as published ?",
|
|
309
|
+
icon_class="fas fa-check-circle",
|
|
310
|
+
submit_btn_text="Yes, proceed",
|
|
311
|
+
submit_btn_class="btn btn-success",
|
|
312
|
+
action_btn_class="btn btn-info",
|
|
313
|
+
)
|
|
314
|
+
async def make_published_row_action(self, request: Request, pk: Any) -> None:
|
|
315
|
+
session: Session = request.state.session
|
|
316
|
+
article = await self.find_by_pk(request, pk)
|
|
317
|
+
if article.status == Status.Published:
|
|
318
|
+
raise ActionFailed("The article is already marked as published.")
|
|
319
|
+
article.status = Status.Published
|
|
320
|
+
session.add(article)
|
|
321
|
+
session.flush()
|
|
322
|
+
flash(request, "The article was successfully marked as published.", "success")
|
|
323
|
+
|
|
324
|
+
# `form` can also be a callable, to build the form HTML per row
|
|
325
|
+
def build_rename_form(request: Request, obj: Any) -> str:
|
|
326
|
+
return f'''
|
|
327
|
+
<form>
|
|
328
|
+
<div class="mt-3">
|
|
329
|
+
<input type="text" class="form-control" name="title" value="{obj.title}">
|
|
330
|
+
</div>
|
|
331
|
+
</form>
|
|
332
|
+
'''
|
|
333
|
+
|
|
334
|
+
@row_action(
|
|
335
|
+
name="rename",
|
|
336
|
+
text="Rename",
|
|
337
|
+
form=build_rename_form,
|
|
338
|
+
)
|
|
339
|
+
async def rename_row_action(self, request: Request, pk: Any) -> None:
|
|
340
|
+
data = await request.form()
|
|
341
|
+
article = await self.find_by_pk(request, pk)
|
|
342
|
+
article.title = data["title"]
|
|
343
|
+
```
|
|
344
|
+
"""
|
|
345
|
+
|
|
346
|
+
if modal_size not in _MODAL_SIZES:
|
|
347
|
+
raise ValueError(
|
|
348
|
+
f"Row action `{name}`: modal_size must be one of {sorted(_MODAL_SIZES)}, "
|
|
349
|
+
f"got {modal_size!r}"
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
def wrap(f: Callable[..., Awaitable[Any]]) -> Callable[..., Awaitable[Any]]:
|
|
353
|
+
f._row_action = { # type: ignore
|
|
354
|
+
"name": name,
|
|
355
|
+
"text": text,
|
|
356
|
+
"confirmation": confirmation,
|
|
357
|
+
"header": header,
|
|
358
|
+
"action_btn_class": action_btn_class,
|
|
359
|
+
"submit_btn_text": submit_btn_text,
|
|
360
|
+
"submit_btn_class": submit_btn_class,
|
|
361
|
+
"icon_class": icon_class,
|
|
362
|
+
"form": form if form is not None else "",
|
|
363
|
+
"custom_response": custom_response,
|
|
364
|
+
"exclude_from_list": exclude_from_list,
|
|
365
|
+
"exclude_from_detail": exclude_from_detail,
|
|
366
|
+
"modal_size": modal_size,
|
|
367
|
+
}
|
|
368
|
+
return f
|
|
369
|
+
|
|
370
|
+
return wrap
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def link_row_action(
|
|
374
|
+
name: str,
|
|
375
|
+
text: str,
|
|
376
|
+
action_btn_class: str | None = None,
|
|
377
|
+
icon_class: str | None = None,
|
|
378
|
+
exclude_from_list: bool = False,
|
|
379
|
+
exclude_from_detail: bool = False,
|
|
380
|
+
) -> Callable[[Callable[..., str]], Any]:
|
|
381
|
+
"""Decorator to add a custom row action that redirects to a URL instead of running
|
|
382
|
+
server-side logic.
|
|
383
|
+
|
|
384
|
+
!!! note
|
|
385
|
+
|
|
386
|
+
Use this decorator when a row action should navigate users to a website or
|
|
387
|
+
internal page rather than execute a handler and stay on the current page.
|
|
388
|
+
|
|
389
|
+
Args:
|
|
390
|
+
name: Unique row action name for the ModelView.
|
|
391
|
+
text: Action text displayed to users.
|
|
392
|
+
action_btn_class: Complete class string for the action button on the
|
|
393
|
+
detail page (e.g. `btn btn-info`), or a class role the active
|
|
394
|
+
theme maps. Defaults to the theme's `action.row_item` role.
|
|
395
|
+
icon_class: Icon class (e.g. `fa-lite fa-folder`, `fa-duotone fa-circle-right`).
|
|
396
|
+
exclude_from_list: Set to True to exclude the action from the list view.
|
|
397
|
+
exclude_from_detail: Set to True to exclude the action from the detail view.
|
|
398
|
+
|
|
399
|
+
!!! usage
|
|
400
|
+
|
|
401
|
+
```python
|
|
402
|
+
@link_row_action(
|
|
403
|
+
name="go_to_example",
|
|
404
|
+
text="Go to example.com",
|
|
405
|
+
icon_class="fas fa-arrow-up-right-from-square",
|
|
406
|
+
)
|
|
407
|
+
def go_to_example_row_action(self, request: Request, pk: Any) -> str:
|
|
408
|
+
return f"https://example.com/?pk={pk}"
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
"""
|
|
412
|
+
|
|
413
|
+
def wrap(f: Callable[..., str]) -> Callable[..., str]:
|
|
414
|
+
f._row_action = { # type: ignore
|
|
415
|
+
"name": name,
|
|
416
|
+
"text": text,
|
|
417
|
+
"action_btn_class": action_btn_class,
|
|
418
|
+
"icon_class": icon_class,
|
|
419
|
+
"is_link": True,
|
|
420
|
+
"exclude_from_list": exclude_from_list,
|
|
421
|
+
"exclude_from_detail": exclude_from_detail,
|
|
422
|
+
}
|
|
423
|
+
return f
|
|
424
|
+
|
|
425
|
+
return wrap
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from obox_builder.auth.base import (
|
|
2
|
+
AdminUser,
|
|
3
|
+
AuthMiddleware,
|
|
4
|
+
BaseAuthProvider,
|
|
5
|
+
login_not_required,
|
|
6
|
+
)
|
|
7
|
+
from obox_builder.auth.oauth import OAuthProvider
|
|
8
|
+
from obox_builder.auth.password import AuthProvider
|
|
9
|
+
from obox_builder.exceptions import FormValidationError, LoginFailed
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"AdminUser",
|
|
13
|
+
"AuthMiddleware",
|
|
14
|
+
"AuthProvider",
|
|
15
|
+
"BaseAuthProvider",
|
|
16
|
+
"FormValidationError",
|
|
17
|
+
"LoginFailed",
|
|
18
|
+
"OAuthProvider",
|
|
19
|
+
"login_not_required",
|
|
20
|
+
]
|