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.
Files changed (249) hide show
  1. obox_builder/__init__.py +155 -0
  2. obox_builder/actions.py +425 -0
  3. obox_builder/auth/__init__.py +20 -0
  4. obox_builder/auth/base.py +195 -0
  5. obox_builder/auth/oauth.py +146 -0
  6. obox_builder/auth/password.py +193 -0
  7. obox_builder/base.py +2527 -0
  8. obox_builder/contrib/__init__.py +0 -0
  9. obox_builder/contrib/beanie/__init__.py +9 -0
  10. obox_builder/contrib/beanie/admin.py +5 -0
  11. obox_builder/contrib/beanie/converters.py +232 -0
  12. obox_builder/contrib/beanie/fields.py +15 -0
  13. obox_builder/contrib/beanie/filters.py +559 -0
  14. obox_builder/contrib/beanie/helpers.py +186 -0
  15. obox_builder/contrib/beanie/view.py +480 -0
  16. obox_builder/contrib/mongoengine/__init__.py +9 -0
  17. obox_builder/contrib/mongoengine/admin.py +49 -0
  18. obox_builder/contrib/mongoengine/converters.py +282 -0
  19. obox_builder/contrib/mongoengine/exceptions.py +5 -0
  20. obox_builder/contrib/mongoengine/fields.py +87 -0
  21. obox_builder/contrib/mongoengine/filters.py +542 -0
  22. obox_builder/contrib/mongoengine/helpers.py +105 -0
  23. obox_builder/contrib/mongoengine/view.py +587 -0
  24. obox_builder/contrib/sqla/__init__.py +9 -0
  25. obox_builder/contrib/sqla/admin.py +146 -0
  26. obox_builder/contrib/sqla/converters.py +535 -0
  27. obox_builder/contrib/sqla/exceptions.py +17 -0
  28. obox_builder/contrib/sqla/ext/__init__.py +0 -0
  29. obox_builder/contrib/sqla/ext/pydantic.py +63 -0
  30. obox_builder/contrib/sqla/fields.py +106 -0
  31. obox_builder/contrib/sqla/filters.py +451 -0
  32. obox_builder/contrib/sqla/helpers.py +80 -0
  33. obox_builder/contrib/sqla/middleware.py +154 -0
  34. obox_builder/contrib/sqla/types.py +7 -0
  35. obox_builder/contrib/sqla/view.py +1034 -0
  36. obox_builder/contrib/sqlmodel/__init__.py +9 -0
  37. obox_builder/contrib/sqlmodel/admin.py +5 -0
  38. obox_builder/contrib/sqlmodel/view.py +136 -0
  39. obox_builder/contrib/tortoise/__init__.py +9 -0
  40. obox_builder/contrib/tortoise/admin.py +5 -0
  41. obox_builder/contrib/tortoise/converters.py +208 -0
  42. obox_builder/contrib/tortoise/exceptions.py +9 -0
  43. obox_builder/contrib/tortoise/fields.py +12 -0
  44. obox_builder/contrib/tortoise/filters.py +494 -0
  45. obox_builder/contrib/tortoise/helpers.py +54 -0
  46. obox_builder/contrib/tortoise/view.py +538 -0
  47. obox_builder/converters.py +268 -0
  48. obox_builder/cookies.py +47 -0
  49. obox_builder/events.py +455 -0
  50. obox_builder/exceptions.py +47 -0
  51. obox_builder/export/__init__.py +108 -0
  52. obox_builder/export/base.py +491 -0
  53. obox_builder/export/csv.py +65 -0
  54. obox_builder/export/helpers.py +107 -0
  55. obox_builder/export/json.py +26 -0
  56. obox_builder/export/pdf.py +111 -0
  57. obox_builder/export/tablib.py +89 -0
  58. obox_builder/fields.py +2768 -0
  59. obox_builder/filters/__init__.py +35 -0
  60. obox_builder/filters/array.py +67 -0
  61. obox_builder/filters/base.py +166 -0
  62. obox_builder/filters/boolean.py +22 -0
  63. obox_builder/filters/date.py +136 -0
  64. obox_builder/filters/enum.py +49 -0
  65. obox_builder/filters/generic.py +42 -0
  66. obox_builder/filters/numeric.py +106 -0
  67. obox_builder/filters/registry.py +126 -0
  68. obox_builder/filters/string.py +36 -0
  69. obox_builder/flash.py +123 -0
  70. obox_builder/helpers.py +617 -0
  71. obox_builder/i18n.py +333 -0
  72. obox_builder/importers/__init__.py +88 -0
  73. obox_builder/importers/base.py +142 -0
  74. obox_builder/importers/csv.py +49 -0
  75. obox_builder/importers/json.py +29 -0
  76. obox_builder/importers/tablib.py +61 -0
  77. obox_builder/logging.py +94 -0
  78. obox_builder/plugins.py +146 -0
  79. obox_builder/py.typed +0 -0
  80. obox_builder/routing.py +41 -0
  81. obox_builder/security/__init__.py +3 -0
  82. obox_builder/security/csrf.py +144 -0
  83. obox_builder/static/css/inline-edit.css +126 -0
  84. obox_builder/static/css/jsoneditor_overrides.css +9 -0
  85. obox_builder/static/css/panel_widget.css +10 -0
  86. obox_builder/static/css/select2-tabler.css +125 -0
  87. obox_builder/static/css/vendor/flatpickr.min.css +13 -0
  88. obox_builder/static/css/vendor/fontawesome.min.css +9 -0
  89. obox_builder/static/css/vendor/img/jsoneditor-icons.svg +749 -0
  90. obox_builder/static/css/vendor/jquery.json-viewer.css +8 -0
  91. obox_builder/static/css/vendor/jsoneditor.min.css +6 -0
  92. obox_builder/static/css/vendor/select2.min.css +1 -0
  93. obox_builder/static/css/vendor/tabler-theme.min.css +7 -0
  94. obox_builder/static/css/vendor/tabler.min.css +8 -0
  95. obox_builder/static/i18n/flatpickr/de.js +7 -0
  96. obox_builder/static/i18n/flatpickr/fr.js +7 -0
  97. obox_builder/static/i18n/flatpickr/pt.js +7 -0
  98. obox_builder/static/i18n/flatpickr/ru.js +7 -0
  99. obox_builder/static/i18n/flatpickr/tr.js +7 -0
  100. obox_builder/static/i18n/flatpickr/zh.js +7 -0
  101. obox_builder/static/js/actions.js +204 -0
  102. obox_builder/static/js/alerts.js +63 -0
  103. obox_builder/static/js/chart_widget.js +16 -0
  104. obox_builder/static/js/detail.js +1 -0
  105. obox_builder/static/js/filter-builder.js +519 -0
  106. obox_builder/static/js/form.js +330 -0
  107. obox_builder/static/js/import.js +379 -0
  108. obox_builder/static/js/list.js +606 -0
  109. obox_builder/static/js/sa-dom.js +42 -0
  110. obox_builder/static/js/stat_widget.js +39 -0
  111. obox_builder/static/js/tabs_widget.js +23 -0
  112. obox_builder/static/js/utils.js +60 -0
  113. obox_builder/static/js/vendor/apexcharts.min.js +5 -0
  114. obox_builder/static/js/vendor/countUp.umd.js +1 -0
  115. obox_builder/static/js/vendor/flatpickr.min.js +2 -0
  116. obox_builder/static/js/vendor/fslightbox.js +1 -0
  117. obox_builder/static/js/vendor/jquery.json-viewer.js +8 -0
  118. obox_builder/static/js/vendor/jquery.min.js +2 -0
  119. obox_builder/static/js/vendor/js.cookie.min.js +2 -0
  120. obox_builder/static/js/vendor/jsoneditor.min.js +49 -0
  121. obox_builder/static/js/vendor/select2.min.js +2 -0
  122. obox_builder/static/js/vendor/tabler.min.js +12 -0
  123. obox_builder/static/webfonts/fa-brands-400.woff2 +0 -0
  124. obox_builder/static/webfonts/fa-regular-400.woff2 +0 -0
  125. obox_builder/static/webfonts/fa-solid-900.woff2 +0 -0
  126. obox_builder/static/webfonts/fa-v4compatibility.woff2 +0 -0
  127. obox_builder/storage/__init__.py +23 -0
  128. obox_builder/storage/base.py +225 -0
  129. obox_builder/storage/local.py +104 -0
  130. obox_builder/storage/s3.py +199 -0
  131. obox_builder/templates/_column_header.html +51 -0
  132. obox_builder/templates/_filter_bar.html +20 -0
  133. obox_builder/templates/_filter_builder.html +142 -0
  134. obox_builder/templates/_form_footer.html +18 -0
  135. obox_builder/templates/_list_row.html +53 -0
  136. obox_builder/templates/_pagination.html +85 -0
  137. obox_builder/templates/_sa_templates.html +20 -0
  138. obox_builder/templates/actions/export_form.html +64 -0
  139. obox_builder/templates/actions.html +43 -0
  140. obox_builder/templates/base.html +158 -0
  141. obox_builder/templates/create.html +71 -0
  142. obox_builder/templates/detail.html +175 -0
  143. obox_builder/templates/edit.html +71 -0
  144. obox_builder/templates/error.html +35 -0
  145. obox_builder/templates/fields/_copy_button.html +9 -0
  146. obox_builder/templates/fields/detail/_empty.html +2 -0
  147. obox_builder/templates/fields/detail/_null.html +2 -0
  148. obox_builder/templates/fields/detail/boolean.html +9 -0
  149. obox_builder/templates/fields/detail/collection.html +16 -0
  150. obox_builder/templates/fields/detail/color.html +9 -0
  151. obox_builder/templates/fields/detail/email.html +9 -0
  152. obox_builder/templates/fields/detail/file.html +8 -0
  153. obox_builder/templates/fields/detail/image.html +14 -0
  154. obox_builder/templates/fields/detail/json.html +7 -0
  155. obox_builder/templates/fields/detail/list.html +7 -0
  156. obox_builder/templates/fields/detail/relation.html +5 -0
  157. obox_builder/templates/fields/detail/tags.html +7 -0
  158. obox_builder/templates/fields/detail/text.html +9 -0
  159. obox_builder/templates/fields/detail/textarea.html +11 -0
  160. obox_builder/templates/fields/detail/tinymce.html +1 -0
  161. obox_builder/templates/fields/detail/url.html +9 -0
  162. obox_builder/templates/fields/form/_delete.html +14 -0
  163. obox_builder/templates/fields/form/_error.html +3 -0
  164. obox_builder/templates/fields/form/_label.html +2 -0
  165. obox_builder/templates/fields/form/boolean.html +13 -0
  166. obox_builder/templates/fields/form/collection.html +14 -0
  167. obox_builder/templates/fields/form/computed.html +4 -0
  168. obox_builder/templates/fields/form/enum.html +22 -0
  169. obox_builder/templates/fields/form/file.html +18 -0
  170. obox_builder/templates/fields/form/image.html +15 -0
  171. obox_builder/templates/fields/form/input.html +26 -0
  172. obox_builder/templates/fields/form/json.html +10 -0
  173. obox_builder/templates/fields/form/list.html +48 -0
  174. obox_builder/templates/fields/form/relation.html +18 -0
  175. obox_builder/templates/fields/form/slug.html +23 -0
  176. obox_builder/templates/fields/form/tags.html +16 -0
  177. obox_builder/templates/fields/form/textarea.html +9 -0
  178. obox_builder/templates/fields/list/_truncated.html +8 -0
  179. obox_builder/templates/fields/list/boolean.html +12 -0
  180. obox_builder/templates/fields/list/collection.html +7 -0
  181. obox_builder/templates/fields/list/color.html +22 -0
  182. obox_builder/templates/fields/list/email.html +20 -0
  183. obox_builder/templates/fields/list/file.html +12 -0
  184. obox_builder/templates/fields/list/image.html +8 -0
  185. obox_builder/templates/fields/list/json.html +8 -0
  186. obox_builder/templates/fields/list/list.html +9 -0
  187. obox_builder/templates/fields/list/relation.html +8 -0
  188. obox_builder/templates/fields/list/tags.html +8 -0
  189. obox_builder/templates/fields/list/text.html +14 -0
  190. obox_builder/templates/fields/list/url.html +20 -0
  191. obox_builder/templates/index.html +16 -0
  192. obox_builder/templates/inline-edit/field.html +11 -0
  193. obox_builder/templates/inline-edit/popover.html +15 -0
  194. obox_builder/templates/inline-edit/popover_body.html +43 -0
  195. obox_builder/templates/inline.html +38 -0
  196. obox_builder/templates/inline_detail.html +47 -0
  197. obox_builder/templates/inline_row.html +34 -0
  198. obox_builder/templates/layout.html +243 -0
  199. obox_builder/templates/list.html +284 -0
  200. obox_builder/templates/login.html +151 -0
  201. obox_builder/templates/macros/views.html +76 -0
  202. obox_builder/templates/modals/actions.html +37 -0
  203. obox_builder/templates/modals/import.html +232 -0
  204. obox_builder/templates/modals/loading.html +18 -0
  205. obox_builder/templates/row-actions.html +110 -0
  206. obox_builder/templates/widgets/card_row_widget.html +5 -0
  207. obox_builder/templates/widgets/chart_widget.html +15 -0
  208. obox_builder/templates/widgets/column_widget.html +5 -0
  209. obox_builder/templates/widgets/divider_widget.html +1 -0
  210. obox_builder/templates/widgets/fieldset_widget.html +4 -0
  211. obox_builder/templates/widgets/form_field_widget.html +8 -0
  212. obox_builder/templates/widgets/grid_widget.html +5 -0
  213. obox_builder/templates/widgets/html_widget.html +1 -0
  214. obox_builder/templates/widgets/panel_widget.html +18 -0
  215. obox_builder/templates/widgets/row_widget.html +5 -0
  216. obox_builder/templates/widgets/stat_widget.html +36 -0
  217. obox_builder/templates/widgets/table_widget.html +33 -0
  218. obox_builder/templates/widgets/tabs_widget.html +28 -0
  219. obox_builder/templates/widgets/text_widget.html +7 -0
  220. obox_builder/theme.py +346 -0
  221. obox_builder/tools/__init__.py +7 -0
  222. obox_builder/tools/iter.py +51 -0
  223. obox_builder/translations/de/LC_MESSAGES/admin.mo +0 -0
  224. obox_builder/translations/de/LC_MESSAGES/admin.po +995 -0
  225. obox_builder/translations/en/LC_MESSAGES/admin.mo +0 -0
  226. obox_builder/translations/en/LC_MESSAGES/admin.po +1011 -0
  227. obox_builder/translations/fr/LC_MESSAGES/admin.mo +0 -0
  228. obox_builder/translations/fr/LC_MESSAGES/admin.po +1004 -0
  229. obox_builder/translations/pt/LC_MESSAGES/admin.mo +0 -0
  230. obox_builder/translations/pt/LC_MESSAGES/admin.po +990 -0
  231. obox_builder/translations/ru/LC_MESSAGES/admin.mo +0 -0
  232. obox_builder/translations/ru/LC_MESSAGES/admin.po +989 -0
  233. obox_builder/translations/tr/LC_MESSAGES/admin.mo +0 -0
  234. obox_builder/translations/tr/LC_MESSAGES/admin.po +985 -0
  235. obox_builder/translations/zh/LC_MESSAGES/admin.mo +0 -0
  236. obox_builder/translations/zh/LC_MESSAGES/admin.po +976 -0
  237. obox_builder/translations/zh_Hant/LC_MESSAGES/admin.mo +0 -0
  238. obox_builder/translations/zh_Hant/LC_MESSAGES/admin.po +976 -0
  239. obox_builder/types.py +141 -0
  240. obox_builder/utils/__init__.py +0 -0
  241. obox_builder/utils/countries.py +251 -0
  242. obox_builder/utils/timezones.py +436 -0
  243. obox_builder/validators.py +728 -0
  244. obox_builder/views.py +3329 -0
  245. obox_builder/widgets.py +809 -0
  246. obox_builder-1.0.2.dist-info/METADATA +182 -0
  247. obox_builder-1.0.2.dist-info/RECORD +249 -0
  248. obox_builder-1.0.2.dist-info/WHEEL +4 -0
  249. obox_builder-1.0.2.dist-info/licenses/LICENSE +21 -0
@@ -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
+ ]
@@ -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
+ ]