Skip to content

Commit f728bd6

Browse files
authored
feat: allow django permissions in actions (#1230)
1 parent 319e4fd commit f728bd6

File tree

6 files changed

+614
-29
lines changed

6 files changed

+614
-29
lines changed

docs/actions/introduction.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,43 @@ class UserAdmin(ModelAdmin):
2727
pass
2828
```
2929

30+
## Permissions
31+
32+
Unfold provides two distinct ways to handle permissions for actions:
33+
34+
1. **ModelAdmin Method-based Permissions**
35+
- Define a permission check method in your ModelAdmin class
36+
- The method name should follow the pattern `has_{permission_name}_permission`
37+
- This method receives the request and can optionally receive the object instance
38+
- Example: `has_custom_action_permission` for a permission named "custom_action"
39+
40+
2. **Django Built-in Permissions**
41+
- Use Django's permission system with the format `app_label.permission_codename`
42+
- These are standard Django permissions defined in your models
43+
- **Note:** When using built-in permissions (containing a dot in the string), the permission check will not receive the object instance during detail view checks
44+
45+
```python
46+
from django.contrib import admin
47+
from django.contrib.auth.models import User
48+
49+
from unfold.admin import ModelAdmin
50+
from unfold.decorators import action
51+
52+
53+
@admin.register(User)
54+
class UserAdmin(ModelAdmin):
55+
@action(
56+
description="Custom action",
57+
permissions=["custom_action", "auth.view_user"] # Using both permission types
58+
)
59+
def custom_action(self, request, queryset):
60+
pass
61+
62+
def has_custom_action_permission(self, request, obj=None):
63+
# Custom permission logic here
64+
return request.user.is_superuser
65+
```
66+
3067
## Icon support
3168

3269
Unfold supports custom icons for actions. Icons are supported for all actions types. You can set the icon for an action by providing `icon` parameter to the `@action` decorator.

src/unfold/checks.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22

33
from django.contrib.admin.checks import ModelAdminChecks
44
from django.contrib.admin.options import BaseModelAdmin
5+
from django.contrib.auth.models import Permission
56
from django.core import checks
67

7-
from .dataclasses import UnfoldAction
8+
from unfold.dataclasses import UnfoldAction
89

910

1011
class UnfoldModelAdminChecks(ModelAdminChecks):
@@ -29,14 +30,35 @@ def _check_unfold_action_permission_methods(self, obj: Any) -> list[checks.Error
2930
for action in actions:
3031
if not hasattr(action.method, "allowed_permissions"):
3132
continue
33+
3234
for permission in action.method.allowed_permissions:
35+
# Check the existence of Django permission
36+
if "." in permission:
37+
app_label, codename = permission.split(".")
38+
39+
if not Permission.objects.filter(
40+
content_type__app_label=app_label,
41+
codename=codename,
42+
).exists():
43+
errors.append(
44+
checks.Error(
45+
f"@action decorator on {action.method.original_function_name}() in class {obj.__class__.__name__} specifies permission {permission} which does not exists.",
46+
obj=obj.__class__,
47+
id="admin.E129",
48+
)
49+
)
50+
51+
continue
52+
53+
# Check the permission method existence
3354
method_name = f"has_{permission}_permission"
3455
if not hasattr(obj, method_name):
3556
errors.append(
3657
checks.Error(
37-
f"{obj.__class__.__name__} must define a {method_name}() method for the {action.method.__name__} action.",
58+
f"{obj.__class__.__name__} must define a {method_name}() method for the {action.method.original_function_name}() action.",
3859
obj=obj.__class__,
3960
id="admin.E129",
4061
)
4162
)
63+
4264
return errors

src/unfold/decorators.py

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,19 @@ def inner(
2929
**kwargs,
3030
) -> Optional[HttpResponse]:
3131
if permissions:
32-
permission_checks = (
33-
getattr(model_admin, f"has_{permission}_permission")
34-
for permission in permissions
35-
)
36-
# Permissions methods have following syntax: has_<some>_permission(self, request, obj=None):
37-
# But obj is not examined by default in django admin and it would also require additional
38-
# fetch from database, therefore it is not supported yet
32+
permission_rules = []
33+
34+
for permission in permissions:
35+
if "." in permission:
36+
permission_rules.append(permission)
37+
else:
38+
# Permissions methods have following syntax: has_<some>_permission(self, request, obj=None):
39+
# But obj is not examined by default in django admin and it would also require additional
40+
# fetch from database, therefore it is not supported yet
41+
permission_rules.append(
42+
getattr(model_admin, f"has_{permission}_permission")
43+
)
44+
3945
has_detail_action = func.__name__ in model_admin._extract_action_names(
4046
model_admin.actions_detail
4147
)
@@ -46,12 +52,19 @@ def inner(
4652
)
4753
)
4854

49-
if not all(
50-
has_permission(request, kwargs.get("object_id"))
51-
if has_detail_action or has_submit_line_action
52-
else has_permission(request)
53-
for has_permission in permission_checks
54-
):
55+
permission_checks = []
56+
57+
for permission_rule in permission_rules:
58+
if isinstance(permission_rule, str) and "." in permission_rule:
59+
permission_checks.append(request.user.has_perm(permission_rule))
60+
elif has_detail_action or has_submit_line_action:
61+
permission_checks.append(
62+
permission_rule(request, kwargs.get("object_id"))
63+
)
64+
else:
65+
permission_checks.append(permission_rule(request))
66+
67+
if not all(permission_checks):
5568
raise PermissionDenied
5669
return func(model_admin, request, *args, **kwargs)
5770

@@ -73,6 +86,7 @@ def inner(
7386
inner.variant = ActionVariant.DEFAULT
7487

7588
inner.attrs = attrs or {}
89+
inner.original_function_name = func.__name__
7690
return inner
7791

7892
if function is None:

src/unfold/mixins/action_model_admin.py

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -316,19 +316,27 @@ def _filter_unfold_actions_by_permissions(
316316
filtered_actions.append(action)
317317
continue
318318

319-
permission_checks = (
320-
getattr(self, f"has_{permission}_permission")
321-
for permission in action.method.allowed_permissions
322-
)
323-
324-
if object_id:
325-
if all(
326-
has_permission(request, object_id)
327-
for has_permission in permission_checks
328-
):
329-
filtered_actions.append(action)
330-
else:
331-
if all(has_permission(request) for has_permission in permission_checks):
332-
filtered_actions.append(action)
319+
permission_rules = []
320+
321+
for permission in action.method.allowed_permissions:
322+
if "." in permission:
323+
permission_rules.append(permission)
324+
else:
325+
permission_rules.append(
326+
getattr(self, f"has_{permission}_permission")
327+
)
328+
329+
permission_checks = []
330+
331+
for permission_rule in permission_rules:
332+
if isinstance(permission_rule, str) and "." in permission_rule:
333+
permission_checks.append(request.user.has_perm(permission_rule))
334+
elif object_id:
335+
permission_checks.append(permission_rule(request, object_id))
336+
else:
337+
permission_checks.append(permission_rule(request))
338+
339+
if all(permission_checks):
340+
filtered_actions.append(action)
333341

334342
return filtered_actions

0 commit comments

Comments
 (0)