Skip to content

Commit 902c7ab

Browse files
authored
feat: dropdown actions (#1063)
1 parent 69e72c9 commit 902c7ab

File tree

14 files changed

+658
-424
lines changed

14 files changed

+658
-424
lines changed

src/unfold/admin.py

Lines changed: 17 additions & 414 deletions
Large diffs are not rendered by default.

src/unfold/dataclasses.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ class UnfoldAction:
1212
path: str
1313
attrs: Optional[dict] = None
1414
object_id: Optional[Union[int, str]] = None
15+
icon: Optional[str] = None
1516

1617

1718
@dataclass

src/unfold/decorators.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ def action(
1717
description: Optional[str] = None,
1818
url_path: Optional[str] = None,
1919
attrs: Optional[dict[str, Any]] = None,
20+
icon: Optional[str] = None,
2021
) -> ActionFunction:
2122
def decorator(func: Callable) -> ActionFunction:
2223
def inner(
@@ -48,10 +49,16 @@ def inner(
4849

4950
if permissions is not None:
5051
inner.allowed_permissions = permissions
52+
5153
if description is not None:
5254
inner.short_description = description
55+
5356
if url_path is not None:
5457
inner.url_path = url_path
58+
59+
if icon is not None:
60+
inner.icon = icon
61+
5562
inner.attrs = attrs or {}
5663
return inner
5764

src/unfold/fields.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,18 @@
1717
from django.utils.safestring import SafeText, mark_safe
1818
from django.utils.text import capfirst
1919

20-
from .settings import get_config
21-
from .utils import display_for_field, prettify_json
22-
from .widgets import CHECKBOX_LABEL_CLASSES, LABEL_CLASSES
20+
from unfold.mixins import BaseModelAdminMixin
21+
from unfold.settings import get_config
22+
from unfold.utils import display_for_field, prettify_json
23+
from unfold.widgets import CHECKBOX_LABEL_CLASSES, LABEL_CLASSES
2324

2425

2526
class UnfoldAdminReadonlyField(helpers.AdminReadonlyField):
2627
def label_tag(self) -> SafeText:
27-
from .admin import ModelAdmin, ModelAdminMixin
28+
from .admin import ModelAdmin
2829

2930
if not isinstance(self.model_admin, ModelAdmin) and not isinstance(
30-
self.model_admin, ModelAdminMixin
31+
self.model_admin, BaseModelAdminMixin
3132
):
3233
return super().label_tag()
3334

src/unfold/mixins/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from unfold.mixins.action_model_admin import ActionModelAdminMixin
2+
from unfold.mixins.base_model_admin import BaseModelAdminMixin
3+
4+
__all__ = ["BaseModelAdminMixin", "ActionModelAdminMixin"]
Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
from typing import Any, Callable, Optional, Union
2+
3+
from django.db.models import Model
4+
from django.forms import Form
5+
from django.http import HttpRequest
6+
from django.template.response import TemplateResponse
7+
from django.urls import reverse
8+
9+
from unfold.dataclasses import UnfoldAction
10+
from unfold.exceptions import UnfoldException
11+
12+
13+
class ActionModelAdminMixin:
14+
"""
15+
Adds support for various ModelAdmin actions (list, detail, row, submit line)
16+
"""
17+
18+
actions_list = () # Displayed in changelist at the top
19+
actions_row = () # Displayed in changelist for each row in the table
20+
actions_detail = () # Displayed in changeform at the top
21+
actions_submit_line = () # Displayed in changeform in the submit line (form buttons)
22+
23+
def changelist_view(
24+
self, request: HttpRequest, extra_context: Optional[dict[str, str]] = None
25+
) -> TemplateResponse:
26+
"""
27+
Changelist contains `actions_list` and `actions_row` custom actions. In case of `actions_row` they
28+
are displayed in the each row of the table.
29+
"""
30+
extra_context = extra_context or {}
31+
32+
actions_row = [
33+
{
34+
"title": action.description,
35+
"icon": action.icon,
36+
"attrs": action.method.attrs,
37+
# This is just a path name as string and in template is used in {% url %} tag
38+
# with custom instance pk value
39+
"raw_path": f"{self.admin_site.name}:{action.action_name}",
40+
}
41+
for action in self.get_actions_row(request)
42+
]
43+
44+
# `actions_list` may contain custom structure with dropdowns so it is needed
45+
# to use `_get_actions_navigation` to build the final structure for the template
46+
actions_list = self._get_actions_navigation(
47+
self.actions_list, self.get_actions_list(request)
48+
)
49+
50+
extra_context.update(
51+
{
52+
"actions_list": actions_list,
53+
"actions_row": actions_row,
54+
}
55+
)
56+
57+
return super().changelist_view(request, extra_context)
58+
59+
def changeform_view(
60+
self,
61+
request: HttpRequest,
62+
object_id: Optional[str] = None,
63+
form_url: str = "",
64+
extra_context: Optional[dict[str, bool]] = None,
65+
) -> Any:
66+
"""
67+
Changeform contains `actions_submit_line` and `actions_detail` custom actions.
68+
"""
69+
extra_context = extra_context or {}
70+
71+
# `actions_submit_line` is a list of actions that are displayed in the submit line they
72+
# are displayed as form buttons
73+
actions_submit_line = self.get_actions_submit_line(request, object_id)
74+
75+
# `actions_detail` may contain custom structure with dropdowns so it is needed
76+
# to use `_get_actions_navigation` to build the final structure for the template
77+
actions_detail = self._get_actions_navigation(
78+
self.actions_detail,
79+
self.get_actions_detail(request, object_id),
80+
object_id,
81+
)
82+
83+
extra_context.update(
84+
{
85+
"actions_submit_line": actions_submit_line,
86+
"actions_detail": actions_detail,
87+
}
88+
)
89+
90+
return super().changeform_view(request, object_id, form_url, extra_context)
91+
92+
def save_model(
93+
self, request: HttpRequest, obj: Model, form: Form, change: Any
94+
) -> None:
95+
"""
96+
When saving object, run all appropriate actions from `actions_submit_line`
97+
"""
98+
super().save_model(request, obj, form, change)
99+
100+
# After saving object, check if any button from `actions_submit_line` was pressed
101+
# and call the corresponding method
102+
for action in self.get_actions_submit_line(request, obj.pk):
103+
if action.action_name not in request.POST:
104+
continue
105+
106+
action.method(request, obj)
107+
108+
def get_unfold_action(self, action: str) -> UnfoldAction:
109+
"""
110+
Converts action name into UnfoldAction object.
111+
"""
112+
method = self._get_instance_method(action)
113+
114+
return UnfoldAction(
115+
action_name=f"{self.model._meta.app_label}_{self.model._meta.model_name}_{action}",
116+
method=method,
117+
description=self._get_action_description(method, action),
118+
path=getattr(method, "url_path", action),
119+
attrs=method.attrs if hasattr(method, "attrs") else None,
120+
icon=method.icon if hasattr(method, "icon") else None,
121+
)
122+
123+
def get_actions_list(self, request: HttpRequest) -> list[UnfoldAction]:
124+
"""
125+
Filters `actions_list` by permissions and returns list of UnfoldAction objects.
126+
"""
127+
return self._filter_unfold_actions_by_permissions(
128+
request, self._get_base_actions_list()
129+
)
130+
131+
def get_actions_detail(
132+
self, request: HttpRequest, object_id: int
133+
) -> list[UnfoldAction]:
134+
"""
135+
Filters `actions_detail` by permissions and returns list of UnfoldAction objects.
136+
"""
137+
return self._filter_unfold_actions_by_permissions(
138+
request, self._get_base_actions_detail(), object_id
139+
)
140+
141+
def get_actions_row(self, request: HttpRequest) -> list[UnfoldAction]:
142+
"""
143+
Filters `actions_row` by permissions and returns list of UnfoldAction objects.
144+
"""
145+
return self._filter_unfold_actions_by_permissions(
146+
request, self._get_base_actions_row()
147+
)
148+
149+
def get_actions_submit_line(
150+
self, request: HttpRequest, object_id: int
151+
) -> list[UnfoldAction]:
152+
"""
153+
Filters `actions_submit_line` by permissions and returns list of UnfoldAction objects.
154+
"""
155+
return self._filter_unfold_actions_by_permissions(
156+
request, self._get_base_actions_submit_line(), object_id
157+
)
158+
159+
def _extract_action_names(self, actions: list[Union[str, dict]]) -> list[str]:
160+
"""
161+
Gets the list of only actions names from the actions structure provided in ModelAdmin
162+
"""
163+
results = []
164+
165+
for action in actions or []:
166+
if isinstance(action, dict) and "items" in action:
167+
results.extend(action["items"])
168+
else:
169+
results.append(action)
170+
171+
return results
172+
173+
def _get_base_actions_list(self) -> list[UnfoldAction]:
174+
"""
175+
Returns list of UnfoldAction objects for `actions_list`.
176+
"""
177+
return [
178+
self.get_unfold_action(action)
179+
for action in self._extract_action_names(self.actions_list)
180+
]
181+
182+
def _get_base_actions_detail(self) -> list[UnfoldAction]:
183+
"""
184+
Returns list of UnfoldAction objects for `actions_detail`.
185+
"""
186+
return [
187+
self.get_unfold_action(action)
188+
for action in self._extract_action_names(self.actions_detail) or []
189+
]
190+
191+
def _get_base_actions_row(self) -> list[UnfoldAction]:
192+
"""
193+
Returns list of UnfoldAction objects for `actions_row`.
194+
"""
195+
return [
196+
self.get_unfold_action(action)
197+
for action in self._extract_action_names(self.actions_row) or []
198+
]
199+
200+
def _get_base_actions_submit_line(self) -> list[UnfoldAction]:
201+
"""
202+
Returns list of UnfoldAction objects for `actions_submit_line`.
203+
"""
204+
return [
205+
self.get_unfold_action(action)
206+
for action in self._extract_action_names(self.actions_submit_line) or []
207+
]
208+
209+
def _get_instance_method(self, method_name: str) -> Callable:
210+
"""
211+
Searches for method on self instance based on method_name and returns it if it exists.
212+
If it does not exist or is not callable, it raises UnfoldException
213+
"""
214+
try:
215+
method = getattr(self, method_name)
216+
except AttributeError as e:
217+
raise UnfoldException(
218+
f"Method {method_name} specified does not exist on current object"
219+
) from e
220+
221+
if not callable(method):
222+
raise UnfoldException(f"{method_name} is not callable")
223+
224+
return method
225+
226+
def _get_actions_navigation(
227+
self,
228+
provided_actions: list[Union[str, dict]],
229+
allowed_actions: list[UnfoldAction],
230+
object_id: Optional[str] = None,
231+
) -> list[Union[str, dict]]:
232+
"""
233+
Builds navigation structure for the actions which is going to be provided to the template.
234+
"""
235+
navigation = []
236+
237+
def get_action_by_name(name: str) -> UnfoldAction:
238+
"""
239+
Searches for an action in allowed_actions by its name.
240+
"""
241+
for action in allowed_actions:
242+
full_action_name = (
243+
f"{self.model._meta.app_label}_{self.model._meta.model_name}_{name}"
244+
)
245+
246+
if action.action_name == full_action_name:
247+
return action
248+
249+
def get_action_path(action: UnfoldAction) -> str:
250+
"""
251+
Returns the URL path for an action.
252+
"""
253+
path_name = f"{self.admin_site.name}:{action.action_name}"
254+
255+
if object_id:
256+
return reverse(path_name, args=(object_id,))
257+
258+
return reverse(path_name)
259+
260+
def get_action_attrs(action: UnfoldAction) -> dict:
261+
"""
262+
Returns the attributes for an action which will be used in the template.
263+
"""
264+
return {
265+
"title": action.description,
266+
"icon": action.icon,
267+
"attrs": action.method.attrs,
268+
"path": get_action_path(action),
269+
}
270+
271+
def build_dropdown(nav_item: dict) -> Optional[dict]:
272+
"""
273+
Builds a dropdown structure for the action.
274+
"""
275+
dropdown = {
276+
"title": nav_item["title"],
277+
"icon": nav_item.get("icon"),
278+
"items": [],
279+
}
280+
281+
for child in nav_item["items"]:
282+
if action := get_action_by_name(child):
283+
dropdown["items"].append(get_action_attrs(action))
284+
285+
if len(dropdown["items"]) > 0:
286+
return dropdown
287+
288+
for nav_item in provided_actions:
289+
if isinstance(nav_item, str):
290+
if action := get_action_by_name(nav_item):
291+
navigation.append(get_action_attrs(action))
292+
elif isinstance(nav_item, dict):
293+
if dropdown := build_dropdown(nav_item):
294+
navigation.append(dropdown)
295+
296+
return navigation
297+
298+
def _filter_unfold_actions_by_permissions(
299+
self,
300+
request: HttpRequest,
301+
actions: list[UnfoldAction],
302+
object_id: Optional[Union[int, str]] = None,
303+
) -> list[UnfoldAction]:
304+
"""
305+
Filters out actions that the user doesn't have access to.
306+
"""
307+
filtered_actions = []
308+
309+
for action in actions:
310+
if not hasattr(action.method, "allowed_permissions"):
311+
filtered_actions.append(action)
312+
continue
313+
314+
permission_checks = (
315+
getattr(self, f"has_{permission}_permission")
316+
for permission in action.method.allowed_permissions
317+
)
318+
319+
if object_id:
320+
if all(
321+
has_permission(request, object_id)
322+
for has_permission in permission_checks
323+
):
324+
filtered_actions.append(action)
325+
else:
326+
if all(has_permission(request) for has_permission in permission_checks):
327+
filtered_actions.append(action)
328+
329+
return filtered_actions

0 commit comments

Comments
 (0)