tungsten-admin 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. tungsten/__init__.py +53 -0
  2. tungsten/actions/__init__.py +25 -0
  3. tungsten/actions/action.py +432 -0
  4. tungsten/actions/prebuilt.py +396 -0
  5. tungsten/auth/__init__.py +333 -0
  6. tungsten/auth/profile.py +73 -0
  7. tungsten/auth/rbac.py +138 -0
  8. tungsten/auth/two_factor.py +147 -0
  9. tungsten/auth/verification.py +91 -0
  10. tungsten/cli/__init__.py +352 -0
  11. tungsten/cli/stubs.py +251 -0
  12. tungsten/context.py +112 -0
  13. tungsten/forms/__init__.py +36 -0
  14. tungsten/forms/base.py +57 -0
  15. tungsten/forms/fields.py +1960 -0
  16. tungsten/forms/form.py +292 -0
  17. tungsten/forms/layout.py +341 -0
  18. tungsten/hosts.py +343 -0
  19. tungsten/i18n.py +284 -0
  20. tungsten/importexport.py +413 -0
  21. tungsten/infolists.py +263 -0
  22. tungsten/lang/hi.json +362 -0
  23. tungsten/models.py +97 -0
  24. tungsten/navigation.py +37 -0
  25. tungsten/notifications.py +120 -0
  26. tungsten/pages.py +222 -0
  27. tungsten/panel.py +494 -0
  28. tungsten/plugins.py +35 -0
  29. tungsten/rendering.py +71 -0
  30. tungsten/resources/__init__.py +4 -0
  31. tungsten/resources/relation_manager.py +88 -0
  32. tungsten/resources/resource.py +232 -0
  33. tungsten/routes.py +1305 -0
  34. tungsten/static/tungsten/favicon.svg +1 -0
  35. tungsten/static/tungsten/tungsten.css +1 -0
  36. tungsten/static/tungsten/tungsten.js +330 -0
  37. tungsten/static/tungsten/vendor/alpine.min.js +21 -0
  38. tungsten/static/tungsten/vendor/chart.umd.min.js +14 -0
  39. tungsten/static/tungsten/vendor/htmx.min.js +1 -0
  40. tungsten/static/tungsten/vendor/qrcode.js +2297 -0
  41. tungsten/static/tungsten/vendor/sortable.min.js +2 -0
  42. tungsten/static/tungsten/vendor/tom-select.complete.min.js +454 -0
  43. tungsten/static/tungsten/vendor/tom-select.css +413 -0
  44. tungsten/static/tungsten/vendor/trix.css +470 -0
  45. tungsten/static/tungsten/vendor/trix.umd.min.js +7 -0
  46. tungsten/storage.py +69 -0
  47. tungsten/support/__init__.py +0 -0
  48. tungsten/support/aio.py +71 -0
  49. tungsten/support/colors.py +172 -0
  50. tungsten/support/component.py +57 -0
  51. tungsten/support/evaluate.py +52 -0
  52. tungsten/support/html.py +85 -0
  53. tungsten/support/icons.json +1 -0
  54. tungsten/support/icons.py +69 -0
  55. tungsten/support/palettes.py +26 -0
  56. tungsten/support/state.py +99 -0
  57. tungsten/tables/__init__.py +38 -0
  58. tungsten/tables/columns.py +849 -0
  59. tungsten/tables/filters.py +365 -0
  60. tungsten/tables/query_builder.py +617 -0
  61. tungsten/tables/summarizers.py +81 -0
  62. tungsten/tables/table.py +826 -0
  63. tungsten/templates/tungsten/actions/button.html +21 -0
  64. tungsten/templates/tungsten/actions/group.html +14 -0
  65. tungsten/templates/tungsten/actions/modal.html +47 -0
  66. tungsten/templates/tungsten/auth/forgot-password.html +19 -0
  67. tungsten/templates/tungsten/auth/login.html +21 -0
  68. tungsten/templates/tungsten/auth/register.html +19 -0
  69. tungsten/templates/tungsten/auth/reset-password.html +17 -0
  70. tungsten/templates/tungsten/auth/two-factor-challenge.html +15 -0
  71. tungsten/templates/tungsten/auth/two-factor-setup.html +69 -0
  72. tungsten/templates/tungsten/auth/verify-email.html +36 -0
  73. tungsten/templates/tungsten/components/brand.html +10 -0
  74. tungsten/templates/tungsten/components/global-search.html +13 -0
  75. tungsten/templates/tungsten/components/notification-badge.html +1 -0
  76. tungsten/templates/tungsten/components/notification-bell.html +11 -0
  77. tungsten/templates/tungsten/components/notifications.html +32 -0
  78. tungsten/templates/tungsten/components/page-filters.html +6 -0
  79. tungsten/templates/tungsten/components/page-header.html +14 -0
  80. tungsten/templates/tungsten/components/search-results.html +34 -0
  81. tungsten/templates/tungsten/components/toasts.html +21 -0
  82. tungsten/templates/tungsten/components/ui.html +182 -0
  83. tungsten/templates/tungsten/components/widgets.html +16 -0
  84. tungsten/templates/tungsten/forms/fields/checkbox-list.html +26 -0
  85. tungsten/templates/tungsten/forms/fields/checkbox.html +10 -0
  86. tungsten/templates/tungsten/forms/fields/color-picker.html +12 -0
  87. tungsten/templates/tungsten/forms/fields/date-picker.html +8 -0
  88. tungsten/templates/tungsten/forms/fields/file-upload.html +50 -0
  89. tungsten/templates/tungsten/forms/fields/hidden.html +1 -0
  90. tungsten/templates/tungsten/forms/fields/key-value.html +32 -0
  91. tungsten/templates/tungsten/forms/fields/permissions.html +34 -0
  92. tungsten/templates/tungsten/forms/fields/placeholder.html +4 -0
  93. tungsten/templates/tungsten/forms/fields/radio.html +16 -0
  94. tungsten/templates/tungsten/forms/fields/repeater.html +75 -0
  95. tungsten/templates/tungsten/forms/fields/rich-editor.html +15 -0
  96. tungsten/templates/tungsten/forms/fields/select.html +26 -0
  97. tungsten/templates/tungsten/forms/fields/tags-input.html +30 -0
  98. tungsten/templates/tungsten/forms/fields/text-input.html +33 -0
  99. tungsten/templates/tungsten/forms/fields/textarea.html +10 -0
  100. tungsten/templates/tungsten/forms/fields/toggle-buttons.html +28 -0
  101. tungsten/templates/tungsten/forms/fields/toggle.html +23 -0
  102. tungsten/templates/tungsten/forms/form.html +7 -0
  103. tungsten/templates/tungsten/forms/grid.html +6 -0
  104. tungsten/templates/tungsten/forms/layouts/fieldset.html +4 -0
  105. tungsten/templates/tungsten/forms/layouts/group.html +1 -0
  106. tungsten/templates/tungsten/forms/layouts/section.html +26 -0
  107. tungsten/templates/tungsten/forms/layouts/tabs.html +19 -0
  108. tungsten/templates/tungsten/forms/layouts/wizard.html +41 -0
  109. tungsten/templates/tungsten/forms/macros.html +46 -0
  110. tungsten/templates/tungsten/infolists/entry.html +9 -0
  111. tungsten/templates/tungsten/infolists/infolist.html +3 -0
  112. tungsten/templates/tungsten/infolists/key-value-entry.html +15 -0
  113. tungsten/templates/tungsten/infolists/repeatable-entry.html +8 -0
  114. tungsten/templates/tungsten/layout/app.html +167 -0
  115. tungsten/templates/tungsten/layout/auth.html +36 -0
  116. tungsten/templates/tungsten/layout/base.html +53 -0
  117. tungsten/templates/tungsten/pages/dashboard.html +13 -0
  118. tungsten/templates/tungsten/pages/error.html +14 -0
  119. tungsten/templates/tungsten/pages/page.html +19 -0
  120. tungsten/templates/tungsten/pages/profile.html +23 -0
  121. tungsten/templates/tungsten/resources/list.html +8 -0
  122. tungsten/templates/tungsten/resources/record.html +54 -0
  123. tungsten/templates/tungsten/tables/columns/checkbox.html +5 -0
  124. tungsten/templates/tungsten/tables/columns/color.html +3 -0
  125. tungsten/templates/tungsten/tables/columns/icon.html +3 -0
  126. tungsten/templates/tungsten/tables/columns/image.html +12 -0
  127. tungsten/templates/tungsten/tables/columns/select.html +8 -0
  128. tungsten/templates/tungsten/tables/columns/text-input.html +6 -0
  129. tungsten/templates/tungsten/tables/columns/text.html +33 -0
  130. tungsten/templates/tungsten/tables/columns/toggle.html +11 -0
  131. tungsten/templates/tungsten/tables/query-builder.html +74 -0
  132. tungsten/templates/tungsten/tables/table.html +253 -0
  133. tungsten/templates/tungsten/widgets/account.html +9 -0
  134. tungsten/templates/tungsten/widgets/chart.html +37 -0
  135. tungsten/templates/tungsten/widgets/progress.html +21 -0
  136. tungsten/templates/tungsten/widgets/stats.html +7 -0
  137. tungsten/templates/tungsten/widgets/table.html +14 -0
  138. tungsten/tenancy.py +102 -0
  139. tungsten/widgets.py +269 -0
  140. tungsten_admin-0.1.0.dist-info/METADATA +581 -0
  141. tungsten_admin-0.1.0.dist-info/RECORD +144 -0
  142. tungsten_admin-0.1.0.dist-info/WHEEL +4 -0
  143. tungsten_admin-0.1.0.dist-info/entry_points.txt +2 -0
  144. tungsten_admin-0.1.0.dist-info/licenses/LICENSE +21 -0
tungsten/__init__.py ADDED
@@ -0,0 +1,53 @@
1
+ """Tungsten — a Filament-style admin panel for FastAPI.
2
+
3
+ Quick start::
4
+
5
+ from tungsten import Panel, Resource, Auth
6
+ from tungsten.forms import TextInput
7
+ from tungsten.tables import TextColumn
8
+
9
+ class ProductResource(Resource):
10
+ model = Product
11
+
12
+ @classmethod
13
+ def form(cls, form):
14
+ return form.schema([TextInput("name").required()])
15
+
16
+ @classmethod
17
+ def table(cls, table):
18
+ return table.columns([TextColumn("name").searchable().sortable()])
19
+
20
+ panel = Panel(engine=engine, secret_key="...", auth=Auth(User))
21
+ panel.resources([ProductResource]).mount(app)
22
+ """
23
+
24
+ from .auth import Auth, hash_password, verify_password
25
+ from .context import Context
26
+ from .i18n import __, translate
27
+ from .navigation import NavigationGroup, NavigationItem
28
+ from .notifications import Notification
29
+ from .pages import Dashboard, Page
30
+ from .panel import VERSION as __version__
31
+ from .panel import Panel
32
+ from .plugins import Plugin
33
+ from .resources.relation_manager import RelationManager
34
+ from .resources.resource import Resource
35
+ from .storage import LocalStorage, Storage
36
+ from .tenancy import Tenancy
37
+ from .widgets import (
38
+ AccountWidget,
39
+ ChartWidget,
40
+ ProgressItem,
41
+ ProgressListWidget,
42
+ Stat,
43
+ StatsOverviewWidget,
44
+ TableWidget,
45
+ Widget,
46
+ )
47
+
48
+ __all__ = [
49
+ "AccountWidget", "Auth", "ChartWidget", "Context", "Dashboard", "LocalStorage", "NavigationGroup",
50
+ "NavigationItem", "Notification", "Page", "Panel", "Plugin", "ProgressItem", "ProgressListWidget",
51
+ "RelationManager", "Resource", "Stat", "StatsOverviewWidget", "Storage", "TableWidget", "Tenancy", "Widget",
52
+ "__", "__version__", "hash_password", "translate", "verify_password",
53
+ ]
@@ -0,0 +1,25 @@
1
+ """Actions: buttons, confirmation boxes and modal forms."""
2
+
3
+ from .action import Action, ActionGroup, BulkAction, BulkActionGroup, Halt, flatten_actions
4
+ from .prebuilt import (
5
+ AttachAction,
6
+ CreateAction,
7
+ DeleteAction,
8
+ DeleteBulkAction,
9
+ DetachAction,
10
+ DetachBulkAction,
11
+ EditAction,
12
+ ForceDeleteAction,
13
+ ForceDeleteBulkAction,
14
+ ReplicateAction,
15
+ RestoreAction,
16
+ RestoreBulkAction,
17
+ ViewAction,
18
+ )
19
+
20
+ __all__ = [
21
+ "Action", "ActionGroup", "AttachAction", "BulkAction", "BulkActionGroup", "CreateAction", "DeleteAction",
22
+ "DeleteBulkAction", "DetachAction", "DetachBulkAction", "EditAction", "ForceDeleteAction",
23
+ "ForceDeleteBulkAction", "Halt", "ReplicateAction", "RestoreAction", "RestoreBulkAction", "ViewAction",
24
+ "flatten_actions",
25
+ ]
@@ -0,0 +1,432 @@
1
+ """Actions: buttons that run code, open a confirmation box or a modal form."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any, Callable
6
+
7
+ from markupsafe import Markup
8
+
9
+ from ..i18n import maybe
10
+ from ..i18n import translate as __
11
+ from ..support.component import Component, headline
12
+ from ..support.evaluate import call, evaluate
13
+
14
+ if TYPE_CHECKING: # pragma: no cover
15
+ from ..context import Context
16
+ from ..forms.form import Form
17
+ from ..hosts import Host
18
+
19
+
20
+ class Halt(Exception):
21
+ """Raise inside an action to stop it quietly (the modal stays open)."""
22
+
23
+
24
+ class Action(Component):
25
+ """A button. Examples::
26
+
27
+ Action("approve").icon("check").color("success").requires_confirmation()
28
+ .action(lambda record: record.approve())
29
+
30
+ Action("email").form([TextInput("subject").required(), Textarea("body")])
31
+ .action(lambda record, data: send_mail(record.email, **data))
32
+ """
33
+
34
+ #: where this action lives — set by the host when it is resolved
35
+ scope = "page"
36
+ #: shown as icon buttons in table rows when an icon exists
37
+ default_style = "button"
38
+
39
+ def __init__(self, name: str) -> None:
40
+ super().__init__()
41
+ self.name = name
42
+ self._label: Any = None
43
+ self._icon: Any = None
44
+ self._color: Any = "primary"
45
+ self._url: Any = None
46
+ self._new_tab = False
47
+ self._requires_confirmation = False
48
+ self._modal_heading: Any = None
49
+ self._modal_description: Any = None
50
+ self._modal_submit_label: Any = None
51
+ self._modal_cancel_label: Any = "Cancel"
52
+ self._modal_icon: Any = None
53
+ self._modal_icon_color: Any = None
54
+ self._modal_width = "lg"
55
+ self._slide_over = False
56
+ self._form: Any = None
57
+ self._form_columns = 1
58
+ self._fill_form: Callable | None = None
59
+ self._action: Callable | None = None
60
+ self._success_title: Any = None
61
+ self._success_body: Any = None
62
+ self._failure_title: Any = None
63
+ self._success_redirect: Any = None
64
+ self._disabled: Any = False
65
+ self._tooltip: Any = None
66
+ self._style: str | None = None
67
+ self._size = "md"
68
+ self._badge: Any = None
69
+ self._outlined = False
70
+ self._authorize: Any = None
71
+ self._hidden_label = False
72
+ self._keyboard: str | None = None
73
+ self._refresh_table = True
74
+ self._modal_content: Any = None
75
+ self._deselect_after = True
76
+
77
+ # ------------------------------------------------------------------ config
78
+ def label(self, label: Any) -> "Action":
79
+ self._label = label
80
+ return self
81
+
82
+ def icon(self, icon: Any) -> "Action":
83
+ self._icon = icon
84
+ return self
85
+
86
+ def color(self, color: Any) -> "Action":
87
+ self._color = color
88
+ return self
89
+
90
+ def url(self, url: Any, open_in_new_tab: bool = False) -> "Action":
91
+ """Make the action a link. ``url`` may be a closure: ``lambda record: ...``."""
92
+ self._url = url
93
+ self._new_tab = open_in_new_tab
94
+ return self
95
+
96
+ def requires_confirmation(self, condition: bool = True) -> "Action":
97
+ self._requires_confirmation = condition
98
+ return self
99
+
100
+ def modal_heading(self, text: Any) -> "Action":
101
+ self._modal_heading = text
102
+ return self
103
+
104
+ def modal_description(self, text: Any) -> "Action":
105
+ self._modal_description = text
106
+ return self
107
+
108
+ def modal_submit_action_label(self, text: Any) -> "Action":
109
+ self._modal_submit_label = text
110
+ return self
111
+
112
+ modal_submit_label = modal_submit_action_label
113
+
114
+ def modal_cancel_action_label(self, text: Any) -> "Action":
115
+ self._modal_cancel_label = text
116
+ return self
117
+
118
+ def modal_icon(self, icon: Any, color: Any = None) -> "Action":
119
+ self._modal_icon = icon
120
+ self._modal_icon_color = color
121
+ return self
122
+
123
+ def modal_width(self, width: str) -> "Action":
124
+ """sm, md, lg, xl, 2xl, 3xl, 4xl, 5xl"""
125
+ self._modal_width = width
126
+ return self
127
+
128
+ def modal_content(self, content: Any) -> "Action":
129
+ """Extra HTML shown in the modal (``Markup`` or a closure returning it)."""
130
+ self._modal_content = content
131
+ return self
132
+
133
+ def slide_over(self, condition: bool = True) -> "Action":
134
+ self._slide_over = condition
135
+ return self
136
+
137
+ def form(self, schema: Any, columns: int = 1) -> "Action":
138
+ """A list of fields (or ``lambda form: form.schema([...])``) shown in a modal."""
139
+ self._form = schema
140
+ self._form_columns = columns
141
+ return self
142
+
143
+ def fill_form(self, fn: Callable) -> "Action":
144
+ """Initial modal data: ``fill_form(lambda record: {"status": record.status})``."""
145
+ self._fill_form = fn
146
+ return self
147
+
148
+ def action(self, fn: Callable) -> "Action":
149
+ """The code to run. Gets ``record``/``records``/``data``/``ctx``/``db``/``user``."""
150
+ self._action = fn
151
+ return self
152
+
153
+ def success_notification_title(self, title: Any) -> "Action":
154
+ self._success_title = title
155
+ return self
156
+
157
+ def success_notification_body(self, body: Any) -> "Action":
158
+ self._success_body = body
159
+ return self
160
+
161
+ def failure_notification_title(self, title: Any) -> "Action":
162
+ self._failure_title = title
163
+ return self
164
+
165
+ def success_redirect_url(self, url: Any) -> "Action":
166
+ self._success_redirect = url
167
+ return self
168
+
169
+ def disabled(self, condition: Any = True) -> "Action":
170
+ self._disabled = condition
171
+ return self
172
+
173
+ def tooltip(self, text: Any) -> "Action":
174
+ self._tooltip = text
175
+ return self
176
+
177
+ def button(self) -> "Action":
178
+ self._style = "button"
179
+ return self
180
+
181
+ def link(self) -> "Action":
182
+ self._style = "link"
183
+ return self
184
+
185
+ def icon_button(self) -> "Action":
186
+ self._style = "icon"
187
+ return self
188
+
189
+ def soft(self) -> "Action":
190
+ """Light colored button (used for bulk actions)."""
191
+ self._style = "soft"
192
+ return self
193
+
194
+ def outlined(self, condition: bool = True) -> "Action":
195
+ self._outlined = condition
196
+ return self
197
+
198
+ def size(self, size: str) -> "Action":
199
+ self._size = size
200
+ return self
201
+
202
+ def badge(self, badge: Any) -> "Action":
203
+ self._badge = badge
204
+ return self
205
+
206
+ def hidden_label(self, condition: bool = True) -> "Action":
207
+ self._hidden_label = condition
208
+ return self
209
+
210
+ def authorize(self, ability: Any) -> "Action":
211
+ """A permission ability (``"delete"``), a closure, or a bool."""
212
+ self._authorize = ability
213
+ return self
214
+
215
+ def keyboard_shortcut(self, keys: str) -> "Action":
216
+ self._keyboard = keys
217
+ return self
218
+
219
+ def deselect_records_after_completion(self, condition: bool = True) -> "Action":
220
+ self._deselect_after = condition
221
+ return self
222
+
223
+ # ------------------------------------------------------------------ resolve
224
+ def ev(self, ctx: "Context | None", record: Any = None, records: Any = None, **extra: Any) -> dict[str, Any]:
225
+ return {
226
+ "ctx": ctx,
227
+ "request": ctx.request if ctx else None,
228
+ "user": ctx.user if ctx else None,
229
+ "db": ctx.db if ctx else None,
230
+ "tenant": ctx.tenant if ctx else None,
231
+ "record": record,
232
+ "records": records,
233
+ "action": self,
234
+ **extra,
235
+ }
236
+
237
+ def get_label(self, ev: dict | None = None) -> str:
238
+ if self._label is None:
239
+ return __(headline(self.name))
240
+ return __(str(evaluate(self._label, **(ev or {}))))
241
+
242
+ def needs_modal(self) -> bool:
243
+ return bool(self._requires_confirmation or self._form is not None)
244
+
245
+ def is_url(self) -> bool:
246
+ return self._url is not None
247
+
248
+ def is_authorized(self, host: "Host | None", ctx: "Context", record: Any = None) -> bool:
249
+ if self._authorize is None:
250
+ return True
251
+ if isinstance(self._authorize, str):
252
+ return host.can(ctx, self._authorize, record) if host else ctx.can(self._authorize, record)
253
+ return bool(evaluate(self._authorize, **self.ev(ctx, record)))
254
+
255
+ def is_available(self, host: "Host | None", ctx: "Context", record: Any = None) -> bool:
256
+ ev = self.ev(ctx, record, host=host)
257
+ return self.is_visible(**ev) and self.is_authorized(host, ctx, record)
258
+
259
+ def get_url(self, ctx: "Context", record: Any = None) -> str | None:
260
+ return evaluate(self._url, **self.ev(ctx, record)) if self._url is not None else None
261
+
262
+ def build_form(self, ctx: "Context", host: "Host | None", record: Any = None, records: Any = None) -> "Form | None":
263
+ from ..forms.form import Form
264
+
265
+ if self._form is None:
266
+ return None
267
+ form = Form()
268
+ form.columns(self._form_columns)
269
+ if callable(self._form) and not isinstance(self._form, list):
270
+ result = call(self._form, **{**self.ev(ctx, record, records), "form": form})
271
+ if isinstance(result, Form):
272
+ form = result
273
+ elif isinstance(result, list):
274
+ form.schema(result)
275
+ else:
276
+ form.schema(list(self._form))
277
+ if host is not None and getattr(host, "model", None) is not None and form._model is None:
278
+ form.model(host.model)
279
+ return form
280
+
281
+ def fill(self, form: "Form", ctx: "Context", record: Any = None, records: Any = None) -> None:
282
+ form.fill(None)
283
+ if self._fill_form is not None:
284
+ data = call(self._fill_form, **self.ev(ctx, record, records, form=form)) or {}
285
+ form.fill_from(data)
286
+
287
+ def run(self, ctx: "Context", host: "Host | None", record: Any = None, records: Any = None,
288
+ data: dict | None = None, form: "Form | None" = None) -> Any:
289
+ if self._action is None:
290
+ return None
291
+ return call(self._action, **{**self.ev(ctx, record, records), "data": data or {}, "form": form, "host": host})
292
+
293
+ def success_notification(self, ctx: "Context", record: Any = None, records: Any = None):
294
+ from ..notifications import Notification
295
+
296
+ title = evaluate(self._success_title, **self.ev(ctx, record, records))
297
+ if not title:
298
+ return None
299
+ body = evaluate(self._success_body, **self.ev(ctx, record, records))
300
+ return Notification(title).body(body).success()
301
+
302
+ # ------------------------------------------------------------------ render
303
+ def view_data(self, ctx: "Context", host: "Host | None", record: Any = None, *, style: str | None = None,
304
+ table_id: str | None = None) -> dict[str, Any]:
305
+ ev = self.ev(ctx, record, host=host)
306
+ style = style or self._style or self.default_style
307
+ color = evaluate(self._color, **ev) or "primary"
308
+ url = self.get_url(ctx, record)
309
+ record_key = host.record_key(record) if (host is not None and record is not None) else None
310
+ endpoint = ctx.panel.url("_tw", "action")
311
+ vals = {"_tw_host": host.key if host else "", "_tw_scope": self.scope, "_tw_name": self.name}
312
+ if record_key is not None:
313
+ vals["_tw_record"] = record_key
314
+ label = self.get_label(ev)
315
+ return {
316
+ "name": self.name,
317
+ "label": label,
318
+ "icon": evaluate(self._icon, **ev),
319
+ "color": color,
320
+ "style": style,
321
+ "size": self._size,
322
+ "url": url,
323
+ "new_tab": self._new_tab,
324
+ "modal": self.needs_modal(),
325
+ "endpoint": endpoint,
326
+ "vals": vals,
327
+ "disabled": bool(evaluate(self._disabled, **ev)),
328
+ "tooltip": maybe(evaluate(self._tooltip, **ev)) or (label if style == "icon" else None),
329
+ "badge": evaluate(self._badge, **ev),
330
+ "outlined": self._outlined,
331
+ "hidden_label": self._hidden_label,
332
+ "bulk": self.scope == "bulk",
333
+ "table_id": table_id,
334
+ "keyboard": self._keyboard,
335
+ }
336
+
337
+ def render(self, ctx: "Context", host: "Host | None", record: Any = None, *, style: str | None = None,
338
+ table_id: str | None = None) -> Markup:
339
+ if not self.is_available(host, ctx, record):
340
+ return Markup("")
341
+ return ctx.panel.renderer.render(
342
+ "tungsten/actions/button.html",
343
+ a=self.view_data(ctx, host, record, style=style, table_id=table_id),
344
+ )
345
+
346
+
347
+ class BulkAction(Action):
348
+ """An action run on the selected table rows. The closure gets ``records``."""
349
+
350
+ scope = "bulk"
351
+ default_style = "soft"
352
+
353
+ def __init__(self, name: str) -> None:
354
+ super().__init__(name)
355
+ self._color = "gray"
356
+
357
+ def is_available(self, host: "Host | None", ctx: "Context", record: Any = None) -> bool:
358
+ ev = self.ev(ctx, None, host=host)
359
+ return self.is_visible(**ev) and self.is_authorized(host, ctx, None)
360
+
361
+
362
+ class ActionGroup(Component):
363
+ """Several actions behind one dropdown button (``...``)."""
364
+
365
+ def __init__(self, actions: list[Action]) -> None:
366
+ super().__init__()
367
+ self.actions = list(actions)
368
+ self._label: Any = "More actions"
369
+ self._icon: Any = "ellipsis"
370
+ self._color = "gray"
371
+ self._button = False
372
+ self._style = None
373
+
374
+ def label(self, label: Any) -> "ActionGroup":
375
+ self._label = label
376
+ return self
377
+
378
+ def icon(self, icon: Any) -> "ActionGroup":
379
+ self._icon = icon
380
+ return self
381
+
382
+ def color(self, color: str) -> "ActionGroup":
383
+ self._color = color
384
+ return self
385
+
386
+ def button(self) -> "ActionGroup":
387
+ """Show as a labelled button instead of an icon."""
388
+ self._button = True
389
+ return self
390
+
391
+ def flatten(self) -> list[Action]:
392
+ out: list[Action] = []
393
+ for a in self.actions:
394
+ out.extend(a.flatten() if isinstance(a, ActionGroup) else [a])
395
+ return out
396
+
397
+ def render(self, ctx: "Context", host: "Host | None", record: Any = None, *, style: str | None = None,
398
+ table_id: str | None = None) -> Markup:
399
+ items = [a.view_data(ctx, host, record, style="dropdown", table_id=table_id)
400
+ for a in self.flatten() if a.is_available(host, ctx, record)]
401
+ if not items:
402
+ return Markup("")
403
+ return ctx.panel.renderer.render(
404
+ "tungsten/actions/group.html",
405
+ items=items,
406
+ label=maybe(evaluate(self._label, record=record)),
407
+ group_icon=self._icon,
408
+ color=self._color,
409
+ button=self._button or style == "soft",
410
+ soft=style == "soft",
411
+ table_id=table_id,
412
+ )
413
+
414
+
415
+ class BulkActionGroup(ActionGroup):
416
+ def __init__(self, actions: list[Action]) -> None:
417
+ super().__init__(actions)
418
+ self._button = True
419
+
420
+
421
+ def flatten_actions(actions: list) -> list[Action]:
422
+ out: list[Action] = []
423
+ for a in actions or []:
424
+ out.extend(a.flatten() if isinstance(a, ActionGroup) else [a])
425
+ return out
426
+
427
+
428
+ def _to_state(form: "Form", key: str, value: Any) -> Any:
429
+ found = form.find(key)
430
+ if found:
431
+ return found[0].to_state(value)
432
+ return value