From c4e0cdde2fd932fe5f7de87492ca9c17a0e73b69 Mon Sep 17 00:00:00 2001 From: lukas Date: Tue, 15 Apr 2025 13:11:02 +0200 Subject: [PATCH 1/2] feat: allow django permissions in actions --- src/unfold/checks.py | 26 +- src/unfold/decorators.py | 43 +-- src/unfold/mixins/action_model_admin.py | 36 ++- tests/fixtures.py | 167 ++++++++++++ tests/test_actions.py | 337 ++++++++++++++++++++++++ 5 files changed, 578 insertions(+), 31 deletions(-) diff --git a/src/unfold/checks.py b/src/unfold/checks.py index b3e7bac7..7370a69f 100644 --- a/src/unfold/checks.py +++ b/src/unfold/checks.py @@ -2,9 +2,10 @@ from django.contrib.admin.checks import ModelAdminChecks from django.contrib.admin.options import BaseModelAdmin +from django.contrib.auth.models import Permission from django.core import checks -from .dataclasses import UnfoldAction +from unfold.dataclasses import UnfoldAction class UnfoldModelAdminChecks(ModelAdminChecks): @@ -29,14 +30,35 @@ def _check_unfold_action_permission_methods(self, obj: Any) -> list[checks.Error for action in actions: if not hasattr(action.method, "allowed_permissions"): continue + for permission in action.method.allowed_permissions: + # Check the existence of Django permission + if "." in permission: + app_label, codename = permission.split(".") + + if not Permission.objects.filter( + content_type__app_label=app_label, + codename=codename, + ).exists(): + errors.append( + checks.Error( + f"@action decorator on {action.method.original_function_name}() in class {obj.__class__.__name__} specifies permission {permission} which does not exists.", + obj=obj.__class__, + id="admin.E129", + ) + ) + + continue + + # Check the permission method existence method_name = f"has_{permission}_permission" if not hasattr(obj, method_name): errors.append( checks.Error( - f"{obj.__class__.__name__} must define a {method_name}() method for the {action.method.__name__} action.", + f"{obj.__class__.__name__} must define a {method_name}() method for the {action.method.original_function_name}() action.", obj=obj.__class__, id="admin.E129", ) ) + return errors diff --git a/src/unfold/decorators.py b/src/unfold/decorators.py index fd7ee3e2..e3304252 100644 --- a/src/unfold/decorators.py +++ b/src/unfold/decorators.py @@ -8,8 +8,7 @@ from django.http import HttpRequest, HttpResponse from unfold.enums import ActionVariant - -from .typing import ActionFunction +from unfold.typing import ActionFunction def action( @@ -30,13 +29,19 @@ def inner( **kwargs, ) -> Optional[HttpResponse]: if permissions: - permission_checks = ( - getattr(model_admin, f"has_{permission}_permission") - for permission in permissions - ) - # Permissions methods have following syntax: has__permission(self, request, obj=None): - # But obj is not examined by default in django admin and it would also require additional - # fetch from database, therefore it is not supported yet + permission_rules = [] + + for permission in permissions: + if "." in permission: + permission_rules.append(permission) + else: + # Permissions methods have following syntax: has__permission(self, request, obj=None): + # But obj is not examined by default in django admin and it would also require additional + # fetch from database, therefore it is not supported yet + permission_rules.append( + getattr(model_admin, f"has_{permission}_permission") + ) + has_detail_action = func.__name__ in model_admin._extract_action_names( model_admin.actions_detail ) @@ -47,12 +52,19 @@ def inner( ) ) - if not all( - has_permission(request, kwargs.get("object_id")) - if has_detail_action or has_submit_line_action - else has_permission(request) - for has_permission in permission_checks - ): + permission_checks = [] + + for permission_rule in permission_rules: + if isinstance(permission_rule, str) and "." in permission_rule: + permission_checks.append(request.user.has_perm(permission_rule)) + elif has_detail_action or has_submit_line_action: + permission_checks.append( + permission_rule(request, kwargs.get("object_id")) + ) + else: + permission_checks.append(permission_rule(request)) + + if not all(permission_checks): raise PermissionDenied return func(model_admin, request, *args, **kwargs) @@ -74,6 +86,7 @@ def inner( inner.variant = ActionVariant.DEFAULT inner.attrs = attrs or {} + inner.original_function_name = func.__name__ return inner if function is None: diff --git a/src/unfold/mixins/action_model_admin.py b/src/unfold/mixins/action_model_admin.py index 1ff475e6..2f4ba8d1 100644 --- a/src/unfold/mixins/action_model_admin.py +++ b/src/unfold/mixins/action_model_admin.py @@ -316,19 +316,27 @@ def _filter_unfold_actions_by_permissions( filtered_actions.append(action) continue - permission_checks = ( - getattr(self, f"has_{permission}_permission") - for permission in action.method.allowed_permissions - ) - - if object_id: - if all( - has_permission(request, object_id) - for has_permission in permission_checks - ): - filtered_actions.append(action) - else: - if all(has_permission(request) for has_permission in permission_checks): - filtered_actions.append(action) + permission_rules = [] + + for permission in action.method.allowed_permissions: + if "." in permission: + permission_rules.append(permission) + else: + permission_rules.append( + getattr(self, f"has_{permission}_permission") + ) + + permission_checks = [] + + for permission_rule in permission_rules: + if isinstance(permission_rule, str) and "." in permission_rule: + permission_checks.append(request.user.has_perm(permission_rule)) + elif object_id: + permission_checks.append(permission_rule(request, object_id)) + else: + permission_checks.append(permission_rule(request)) + + if all(permission_checks): + filtered_actions.append(action) return filtered_actions diff --git a/tests/fixtures.py b/tests/fixtures.py index ac1ecbb8..b7eb6085 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -2,6 +2,7 @@ from django.contrib import admin, messages from django.contrib.auth import get_user_model from django.contrib.auth.admin import UserAdmin as BaseUserAdmin +from django.contrib.auth.models import Permission from django.shortcuts import redirect from django.test import RequestFactory from django.urls import reverse_lazy @@ -26,6 +27,24 @@ def admin_user(): ) +@pytest.fixture +def staff_user(): + view_user_permission = Permission.objects.get(codename="view_user") + change_user_permission = Permission.objects.get(codename="change_user") + + user = User.objects.create_user( + username="staff@example.com", + email="staff@example.com", + password="password", + date_joined=now(), + ) + user.is_staff = True + user.save() + user.user_permissions.add(view_user_permission) + user.user_permissions.add(change_user_permission) + return user + + @pytest.fixture def admin_request(admin_user): request = RequestFactory().get("/") @@ -68,6 +87,9 @@ class UserModelAdmin(BaseUserAdmin, ModelAdmin): ] actions_list = [ "changelist_action", + "changelist_action_mixed_permissions_true", + "changelist_action_mixed_permissions_false", + "changelist_action_mixed_permissions_perm_not_granted", "changelist_action_permission_true", "changelist_action_permission_false", "changelist_action_multiple_different_permissions", @@ -80,12 +102,18 @@ class UserModelAdmin(BaseUserAdmin, ModelAdmin): ] actions_row = [ "changelist_row_action", + "changelist_row_action_mixed_permissions_true", + "changelist_row_action_mixed_permissions_false", + "changelist_row_action_mixed_permissions_perm_not_granted", "changelist_row_action_permission_true", "changelist_row_action_permission_false", "changelist_row_action_multiple_different_permissions", ] actions_detail = [ "changeform_action", + "changeform_action_mixed_permissions_true", + "changeform_action_mixed_permissions_false", + "changeform_action_mixed_permissions_perm_not_granted", "changeform_action_permission_true", "changeform_action_permission_false", "changeform_action_multiple_different_permissions", @@ -98,6 +126,9 @@ class UserModelAdmin(BaseUserAdmin, ModelAdmin): ] actions_submit_line = [ "submit_line_action", + "submit_line_action_mixed_permissions_true", + "submit_line_action_mixed_permissions_false", + "submit_line_action_mixed_permissions_perm_not_granted", "submit_line_action_permission_true", "submit_line_action_permission_false", "submit_line_action_multiple_different_permissions", @@ -118,6 +149,39 @@ def changelist_action(self, request): messages.success(request, "Changelist action successfully executed") return redirect(reverse_lazy("admin:example_user_changelist")) + @action( + description="Changelist action with mixed permissions true", + permissions=["changelist_action_true", "example.view_user"], + ) + def changelist_action_mixed_permissions_true(self, request): + messages.success( + request, + "Changelist action with mixed permissions true successfully executed", + ) + return redirect(reverse_lazy("admin:example_user_changelist")) + + @action( + description="Changelist action with mixed permissions false", + permissions=["changelist_action_false", "example.view_user"], + ) + def changelist_action_mixed_permissions_false(self, request): + messages.success( + request, + "Changelist action with mixed permissions false successfully executed", + ) + return redirect(reverse_lazy("admin:example_user_changelist")) + + @action( + description="Changelist action with mixed permissions perm not granted", + permissions=["changelist_action_true", "example.delete_user"], + ) + def changelist_action_mixed_permissions_perm_not_granted(self, request): + messages.success( + request, + "Changelist action with mixed permissions perm not granted successfully executed", + ) + return redirect(reverse_lazy("admin:example_user_changelist")) + @action( description="Changelist action permission true", permissions=["changelist_action_true"], @@ -166,6 +230,41 @@ def changelist_row_action(self, request, object_id): messages.success(request, "Changelist row action successfully executed") return redirect(reverse_lazy("admin:example_user_changelist")) + @action( + description="Changelist row action with mixed permissions true", + permissions=["changelist_row_action_true", "example.view_user"], + ) + def changelist_row_action_mixed_permissions_true(self, request, object_id): + messages.success( + request, + "Changelist row action with mixed permissions true successfully executed", + ) + return redirect(reverse_lazy("admin:example_user_changelist")) + + @action( + description="Changelist row action with mixed permissions false", + permissions=["changelist_row_action_false", "example.view_user"], + ) + def changelist_row_action_mixed_permissions_false(self, request, object_id): + messages.success( + request, + "Changelist row action with mixed permissions false successfully executed", + ) + return redirect(reverse_lazy("admin:example_user_changelist")) + + @action( + description="Changelist row action with mixed permissions perm not granted", + permissions=["changelist_row_action_true", "example.delete_user"], + ) + def changelist_row_action_mixed_permissions_perm_not_granted( + self, request, object_id + ): + messages.success( + request, + "Changelist row action with mixed permissions perm not granted successfully executed", + ) + return redirect(reverse_lazy("admin:example_user_changelist")) + @action( description="Changelist row action permission true", permissions=["changelist_row_action_true"], @@ -226,6 +325,41 @@ def changeform_action_dropdown(self, request, object_id): def has_changeform_action_dropdown_permission(self, request, object_id): return True + @action( + description="Changeform action with mixed permissions true", + permissions=["example.view_user", "changeform_action_true"], + ) + def changeform_action_mixed_permissions_true(self, request, object_id): + messages.success( + request, + "Changeform action with mixed permissions true successfully executed", + ) + return redirect(reverse_lazy("admin:example_user_changelist")) + + @action( + description="Changeform action with mixed permissions false", + permissions=["example.view_user", "changeform_action_false"], + ) + def changeform_action_mixed_permissions_false(self, request, object_id): + messages.success( + request, + "Changeform action with mixed permissions false successfully executed", + ) + return redirect(reverse_lazy("admin:example_user_changelist")) + + @action( + description="Changeform action with mixed permissions perm not granted", + permissions=["example.delete_user", "changeform_action_true"], + ) + def changeform_action_mixed_permissions_perm_not_granted( + self, request, object_id + ): + messages.success( + request, + "Changeform action with mixed permissions perm not granted successfully executed", + ) + return redirect(reverse_lazy("admin:example_user_changelist")) + @action(description="Changeform action") def changeform_action(self, request, object_id): messages.success(request, "Changeform action successfully executed") @@ -280,6 +414,39 @@ def has_changeform_action_false_permission(self, request, object_id): def submit_line_action(self, request, obj): messages.success(request, "Submit line action successfully executed") + @action( + description="Submit line action with mixed permissions true", + permissions=["submit_line_action_true", "example.view_user"], + ) + def submit_line_action_mixed_permissions_true(self, request, obj): + messages.success( + request, + "Submit line action with mixed permissions true successfully executed", + ) + return redirect(reverse_lazy("admin:example_user_changelist")) + + @action( + description="Submit line action with mixed permissions false", + permissions=["submit_line_action_false", "example.view_user"], + ) + def submit_line_action_mixed_permissions_false(self, request, obj): + messages.success( + request, + "Submit line action with mixed permissions false successfully executed", + ) + return redirect(reverse_lazy("admin:example_user_changelist")) + + @action( + description="Submit line action with mixed permissions perm not granted", + permissions=["submit_line_action_true", "example.delete_user"], + ) + def submit_line_action_mixed_permissions_perm_not_granted(self, request, obj): + messages.success( + request, + "Submit line action with mixed permissions perm not granted successfully executed", + ) + return redirect(reverse_lazy("admin:example_user_changelist")) + @action( description="Submit line action permission true", permissions=["submit_line_action_true"], diff --git a/tests/test_actions.py b/tests/test_actions.py index 6a8376fe..47f262a3 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -50,6 +50,77 @@ def test_actions_list_with_dropdown(client, admin_user, user_model_admin_with_ac ) +@pytest.mark.django_db +def test_actions_list_mixed_permissions_true( + client, staff_user, user_model_admin_with_actions +): + client.force_login(staff_user) + response = client.get(reverse_lazy("admin:example_user_changelist")) + + assert response.status_code == HTTPStatus.OK + assert "Changelist action with mixed permissions true" in response.content.decode() + + response = client.get( + reverse_lazy("admin:example_user_changelist_action_mixed_permissions_true"), + follow=True, + ) + assert response.status_code == HTTPStatus.OK + assert ( + "Changelist action with mixed permissions true successfully executed" + in response.content.decode() + ) + + +@pytest.mark.django_db +def test_actions_list_mixed_permissions_false( + client, staff_user, user_model_admin_with_actions +): + client.force_login(staff_user) + response = client.get(reverse_lazy("admin:example_user_changelist")) + + assert response.status_code == HTTPStatus.OK + assert ( + "Changelist action with mixed permissions false" + not in response.content.decode() + ) + + response = client.get( + reverse_lazy("admin:example_user_changelist_action_mixed_permissions_false"), + follow=True, + ) + assert response.status_code == HTTPStatus.FORBIDDEN + assert ( + "Changelist action with mixed permissions false successfully executed" + not in response.content.decode() + ) + + +@pytest.mark.django_db +def test_actions_list_mixed_permissions_perm_not_granted( + client, staff_user, user_model_admin_with_actions +): + client.force_login(staff_user) + response = client.get(reverse_lazy("admin:example_user_changelist")) + + assert response.status_code == HTTPStatus.OK + assert ( + "Changelist action with mixed permissions perm not granted" + not in response.content.decode() + ) + + response = client.get( + reverse_lazy( + "admin:example_user_changelist_action_mixed_permissions_perm_not_granted" + ), + follow=True, + ) + assert response.status_code == HTTPStatus.FORBIDDEN + assert ( + "Changelist action with mixed permissions perm not granted successfully executed" + not in response.content.decode() + ) + + @pytest.mark.django_db def test_actions_list_permission_true( client, admin_user, user_model_admin_with_actions @@ -146,6 +217,86 @@ def test_actions_row(client, admin_user, user_model_admin_with_actions): assert "Changelist row action successfully executed" in response.content.decode() +@pytest.mark.django_db +def test_actions_row_mixed_permissions_true( + client, staff_user, user_model_admin_with_actions +): + client.force_login(staff_user) + response = client.get(reverse_lazy("admin:example_user_changelist")) + + assert response.status_code == HTTPStatus.OK + assert ( + "Changelist row action with mixed permissions true" in response.content.decode() + ) + + response = client.get( + reverse_lazy( + "admin:example_user_changelist_row_action_mixed_permissions_true", + args=(staff_user.pk,), + ), + follow=True, + ) + assert response.status_code == HTTPStatus.OK + assert ( + "Changelist row action with mixed permissions true successfully executed" + in response.content.decode() + ) + + +@pytest.mark.django_db +def test_actions_row_mixed_permissions_false( + client, staff_user, user_model_admin_with_actions +): + client.force_login(staff_user) + response = client.get(reverse_lazy("admin:example_user_changelist")) + + assert response.status_code == HTTPStatus.OK + assert ( + "Changelist row action with mixed permissions false" + not in response.content.decode() + ) + + response = client.get( + reverse_lazy( + "admin:example_user_changelist_row_action_mixed_permissions_false", + args=(staff_user.pk,), + ), + follow=True, + ) + assert response.status_code == HTTPStatus.FORBIDDEN + assert ( + "Changelist row action with mixed permissions false successfully executed" + not in response.content.decode() + ) + + +@pytest.mark.django_db +def test_actions_row_mixed_permissions_perm_not_granted( + client, staff_user, user_model_admin_with_actions +): + client.force_login(staff_user) + response = client.get(reverse_lazy("admin:example_user_changelist")) + + assert response.status_code == HTTPStatus.OK + assert ( + "Changelist row action with mixed permissions perm not granted" + not in response.content.decode() + ) + + response = client.get( + reverse_lazy( + "admin:example_user_changelist_row_action_mixed_permissions_perm_not_granted", + args=(staff_user.pk,), + ), + follow=True, + ) + assert response.status_code == HTTPStatus.FORBIDDEN + assert ( + "Changelist row action with mixed permissions perm not granted successfully executed" + not in response.content.decode() + ) + + @pytest.mark.django_db def test_actions_row_permission_true(client, admin_user, user_model_admin_with_actions): client.force_login(admin_user) @@ -277,6 +428,90 @@ def test_actions_changeform_with_dropdown( ) +@pytest.mark.django_db +def test_actions_changeform_mixed_permissions_true( + client, staff_user, user_model_admin_with_actions +): + client.force_login(staff_user) + response = client.get( + reverse_lazy("admin:example_user_change", args=(staff_user.pk,)) + ) + + assert response.status_code == HTTPStatus.OK + assert "Changeform action with mixed permissions true" in response.content.decode() + + response = client.get( + reverse_lazy( + "admin:example_user_changeform_action_mixed_permissions_true", + args=(staff_user.pk,), + ), + follow=True, + ) + assert response.status_code == HTTPStatus.OK + assert ( + "Changeform action with mixed permissions true successfully executed" + in response.content.decode() + ) + + +@pytest.mark.django_db +def test_actions_changeform_mixed_permissions_false( + client, staff_user, user_model_admin_with_actions +): + client.force_login(staff_user) + response = client.get( + reverse_lazy("admin:example_user_change", args=(staff_user.pk,)) + ) + + assert response.status_code == HTTPStatus.OK + assert ( + "Changeform action with mixed permissions false" + not in response.content.decode() + ) + + response = client.get( + reverse_lazy( + "admin:example_user_changeform_action_mixed_permissions_false", + args=(staff_user.pk,), + ), + follow=True, + ) + assert response.status_code == HTTPStatus.FORBIDDEN + assert ( + "Changeform action with mixed permissions false successfully executed" + not in response.content.decode() + ) + + +@pytest.mark.django_db +def test_actions_changeform_mixed_permissions_perm_not_granted( + client, staff_user, user_model_admin_with_actions +): + client.force_login(staff_user) + response = client.get( + reverse_lazy("admin:example_user_change", args=(staff_user.pk,)) + ) + + assert response.status_code == HTTPStatus.OK + assert ( + "Changeform action with mixed permissions perm not granted" + not in response.content.decode() + ) + + response = client.get( + reverse_lazy( + "admin:example_user_changeform_action_mixed_permissions_perm_not_granted", + args=(staff_user.pk,), + ), + follow=True, + ) + assert response.status_code == HTTPStatus.FORBIDDEN + assert ( + "Changeform action with mixed permissions perm not granted successfully executed" + not in response.content.decode() + ) + + @pytest.mark.django_db def test_actions_changeform_permission_true( client, admin_user, user_model_admin_with_actions @@ -387,6 +622,108 @@ def test_submit_line(client, admin_user, user_model_admin_with_actions): assert "Submit line action successfully executed" in response.content.decode() +@pytest.mark.django_db +def test_submit_line_mixed_permissions_true( + client, staff_user, user_model_admin_with_actions +): + client.force_login(staff_user) + response = client.get( + reverse_lazy("admin:example_user_change", args=(staff_user.pk,)) + ) + assert response.status_code == HTTPStatus.OK + assert "Submit line action with mixed permissions true" in response.content.decode() + + response = client.post( + reverse_lazy("admin:example_user_change", args=(staff_user.pk,)), + { + "username": staff_user.username, + "date_joined_0": "2025-01-01", + "date_joined_1": "12:00", + "is_active": True, + "is_staff": True, + "is_superuser": True, + "example_user_submit_line_action_mixed_permissions_true": "1", + }, + follow=True, + ) + + assert response.status_code == HTTPStatus.OK + assert ( + "Submit line action with mixed permissions true successfully executed" + in response.content.decode() + ) + + +@pytest.mark.django_db +def test_submit_line_mixed_permissions_false( + client, staff_user, user_model_admin_with_actions +): + client.force_login(staff_user) + response = client.get( + reverse_lazy("admin:example_user_change", args=(staff_user.pk,)) + ) + assert response.status_code == HTTPStatus.OK + assert ( + "Submit line action with mixed permissions false" + not in response.content.decode() + ) + + response = client.post( + reverse_lazy("admin:example_user_change", args=(staff_user.pk,)), + { + "username": staff_user.username, + "date_joined_0": "2025-01-01", + "date_joined_1": "12:00", + "is_active": True, + "is_staff": True, + "is_superuser": True, + "example_user_submit_line_action_mixed_permissions_false": "1", + }, + follow=True, + ) + + assert response.status_code == HTTPStatus.OK + assert ( + "Submit line action with mixed permissions false successfully executed" + not in response.content.decode() + ) + + +@pytest.mark.django_db +def test_submit_line_mixed_permissions_perm_not_granted( + client, staff_user, user_model_admin_with_actions +): + client.force_login(staff_user) + response = client.get( + reverse_lazy("admin:example_user_change", args=(staff_user.pk,)) + ) + assert response.status_code == HTTPStatus.OK + assert ( + "Submit line action with mixed permissions perm not granted" + not in response.content.decode() + ) + + response = client.post( + reverse_lazy("admin:example_user_change", args=(staff_user.pk,)), + { + "username": staff_user.username, + "date_joined_0": "2025-01-01", + "date_joined_1": "12:00", + "is_active": True, + "is_staff": True, + "is_superuser": True, + "example_user_submit_line_action_mixed_permissions_perm_not_granted": "1", + }, + follow=True, + ) + + assert response.status_code == HTTPStatus.OK + assert ( + "Submit line action with mixed permissions perm not granted successfully executed" + not in response.content.decode() + ) + + @pytest.mark.django_db def test_submit_line_permission_true(client, admin_user, user_model_admin_with_actions): client.force_login(admin_user) From 7005d6e2b831aabe53e0211627b0b82aa3d3722c Mon Sep 17 00:00:00 2001 From: lukas Date: Wed, 16 Apr 2025 10:24:03 +0200 Subject: [PATCH 2/2] docs: action permissions --- docs/actions/introduction.md | 37 ++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/docs/actions/introduction.md b/docs/actions/introduction.md index ae8c59cb..95d661ea 100644 --- a/docs/actions/introduction.md +++ b/docs/actions/introduction.md @@ -27,6 +27,43 @@ class UserAdmin(ModelAdmin): pass ``` +## Permissions + +Unfold provides two distinct ways to handle permissions for actions: + +1. **ModelAdmin Method-based Permissions** + - Define a permission check method in your ModelAdmin class + - The method name should follow the pattern `has_{permission_name}_permission` + - This method receives the request and can optionally receive the object instance + - Example: `has_custom_action_permission` for a permission named "custom_action" + +2. **Django Built-in Permissions** + - Use Django's permission system with the format `app_label.permission_codename` + - These are standard Django permissions defined in your models + - **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 + +```python +from django.contrib import admin +from django.contrib.auth.models import User + +from unfold.admin import ModelAdmin +from unfold.decorators import action + + +@admin.register(User) +class UserAdmin(ModelAdmin): + @action( + description="Custom action", + permissions=["custom_action", "auth.view_user"] # Using both permission types + ) + def custom_action(self, request, queryset): + pass + + def has_custom_action_permission(self, request, obj=None): + # Custom permission logic here + return request.user.is_superuser +``` + ## Icon support 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.