From 4361024ecddb5064b750d289a1a9f512c771f046 Mon Sep 17 00:00:00 2001 From: Ezeudoh Tochukwu Date: Wed, 8 Mar 2023 23:57:50 +0100 Subject: [PATCH 1/2] Allow permission instance in permission definition. #52 --- .../api_controller_permission.md | 32 ++++++++++++++- ninja_extra/__init__.py | 2 +- ninja_extra/controllers/base.py | 6 ++- ninja_extra/controllers/route/__init__.py | 34 +++++++++++----- .../controllers/route/route_functions.py | 4 +- ninja_extra/operation.py | 2 +- ninja_extra/permissions/base.py | 36 +++++++++++------ ninja_extra/types.py | 6 +-- tests/test_permissions.py | 39 ++++++++++++++++++- 9 files changed, 131 insertions(+), 30 deletions(-) diff --git a/docs/api_controller/api_controller_permission.md b/docs/api_controller/api_controller_permission.md index e7b84a7b..d1071076 100644 --- a/docs/api_controller/api_controller_permission.md +++ b/docs/api_controller/api_controller_permission.md @@ -38,7 +38,37 @@ class PermissionController: def must_be_authenticated(self, word: str): return dict(says=word) ``` +!!! Note + New in v0.18.8 + Controller Permission and Route Function `permissions` can now take `BasePermission` instance. + + For example, we can pass the `ReadOnly` instance to the `permission` parameter. + ```python + from ninja_extra import permissions, api_controller, http_get + + class ReadOnly(permissions.BasePermission): + def has_permission(self, request, view): + return request.method in permissions.SAFE_METHODS + + @api_controller(permissions=[permissions.IsAuthenticated | ReadOnly()]) + ... + ``` +For example: +```python +from ninja_extra import permissions + +class UserWithPermission(permissions.BasePermission): + def __init__(self, permission: str) -> None: + self._permission = permission + + def has_permission(self, request, view): + return request.user.has_perm(self._permission) + +# in controller or route function +permissions=[UserWithPermission('blog.add')] +``` + ## **Permissions Supported Operands** - & (and) eg: `permissions.IsAuthenticated & ReadOnly` - | (or) eg: `permissions.IsAuthenticated | ReadOnly` -- ~ (not) eg: `!(permissions.IsAuthenticated & ReadOnly)` \ No newline at end of file +- ~ (not) eg: `!(permissions.IsAuthenticated & ReadOnly)` diff --git a/ninja_extra/__init__.py b/ninja_extra/__init__.py index ec1eac94..6124ced8 100644 --- a/ninja_extra/__init__.py +++ b/ninja_extra/__init__.py @@ -1,6 +1,6 @@ """Django Ninja Extra - Class Based Utility and more for Django Ninja(Fast Django REST framework)""" -__version__ = "0.18.6" +__version__ = "0.18.8" import django diff --git a/ninja_extra/controllers/base.py b/ninja_extra/controllers/base.py index eba62316..ddfeb055 100644 --- a/ninja_extra/controllers/base.py +++ b/ninja_extra/controllers/base.py @@ -34,6 +34,7 @@ from ninja_extra.helper import get_function_name from ninja_extra.operation import ControllerPathView, Operation from ninja_extra.permissions import AllowAny, BasePermission +from ninja_extra.permissions.base import OperationHolderMixin from ninja_extra.shortcuts import ( fail_silently, get_object_or_exception, @@ -168,7 +169,10 @@ def _get_permissions(self) -> Iterable[BasePermission]: return for permission_class in self.context.permission_classes: - permission_instance = permission_class() + if isinstance(permission_class, (type, OperationHolderMixin)): + permission_instance = permission_class() # type: ignore[operator] + else: + permission_instance = permission_class yield permission_instance def check_permissions(self) -> None: diff --git a/ninja_extra/controllers/route/__init__.py b/ninja_extra/controllers/route/__init__.py index 4f338c2c..cccc42fa 100644 --- a/ninja_extra/controllers/route/__init__.py +++ b/ninja_extra/controllers/route/__init__.py @@ -54,7 +54,9 @@ def __init__( exclude_none: bool = False, url_name: Optional[str] = None, include_in_schema: bool = True, - permissions: Optional[List[Type[BasePermission]]] = None, + permissions: Optional[ + List[Union[Type[BasePermission], BasePermission, Any]] + ] = None, openapi_extra: Optional[Dict[str, Any]] = None, ) -> None: @@ -112,7 +114,7 @@ def __init__( ) self.route_params = ninja_route_params self.is_async = is_async(view_func) - self.permissions = permissions + self.permissions = permissions # type: ignore[assignment] self.view_func = view_func @classmethod @@ -135,7 +137,9 @@ def _create_route_function( exclude_none: bool = False, url_name: Optional[str] = None, include_in_schema: bool = True, - permissions: Optional[List[Type[BasePermission]]] = None, + permissions: Optional[ + List[Union[Type[BasePermission], BasePermission, Any]] + ] = None, openapi_extra: Optional[Dict[str, Any]] = None, ) -> RouteFunction: if response is NOT_SET: @@ -184,7 +188,9 @@ def get( exclude_none: bool = False, url_name: Optional[str] = None, include_in_schema: bool = True, - permissions: Optional[List[Type[BasePermission]]] = None, + permissions: Optional[ + List[Union[Type[BasePermission], BasePermission, Any]] + ] = None, openapi_extra: Optional[Dict[str, Any]] = None, ) -> Callable[[TCallable], RouteFunction]: """ @@ -256,7 +262,9 @@ def post( exclude_none: bool = False, url_name: Optional[str] = None, include_in_schema: bool = True, - permissions: Optional[List[Type[BasePermission]]] = None, + permissions: Optional[ + List[Union[Type[BasePermission], BasePermission, Any]] + ] = None, openapi_extra: Optional[Dict[str, Any]] = None, ) -> Callable[[TCallable], RouteFunction]: """ @@ -328,7 +336,9 @@ def delete( exclude_none: bool = False, url_name: Optional[str] = None, include_in_schema: bool = True, - permissions: Optional[List[Type[BasePermission]]] = None, + permissions: Optional[ + List[Union[Type[BasePermission], BasePermission, Any]] + ] = None, openapi_extra: Optional[Dict[str, Any]] = None, ) -> Callable[[TCallable], RouteFunction]: """ @@ -400,7 +410,9 @@ def patch( exclude_none: bool = False, url_name: Optional[str] = None, include_in_schema: bool = True, - permissions: Optional[List[Type[BasePermission]]] = None, + permissions: Optional[ + List[Union[Type[BasePermission], BasePermission, Any]] + ] = None, openapi_extra: Optional[Dict[str, Any]] = None, ) -> Callable[[TCallable], RouteFunction]: """ @@ -473,7 +485,9 @@ def put( exclude_none: bool = False, url_name: Optional[str] = None, include_in_schema: bool = True, - permissions: Optional[List[Type[BasePermission]]] = None, + permissions: Optional[ + List[Union[Type[BasePermission], BasePermission, Any]] + ] = None, openapi_extra: Optional[Dict[str, Any]] = None, ) -> Callable[[TCallable], RouteFunction]: """ @@ -547,7 +561,9 @@ def generic( exclude_none: bool = False, url_name: Optional[str] = None, include_in_schema: bool = True, - permissions: Optional[List[Type[BasePermission]]] = None, + permissions: Optional[ + List[Union[Type[BasePermission], BasePermission, Any]] + ] = None, openapi_extra: Optional[Dict[str, Any]] = None, ) -> Callable[[TCallable], RouteFunction]: """ diff --git a/ninja_extra/controllers/route/route_functions.py b/ninja_extra/controllers/route/route_functions.py index e07a6a2a..8b27d97c 100644 --- a/ninja_extra/controllers/route/route_functions.py +++ b/ninja_extra/controllers/route/route_functions.py @@ -48,7 +48,7 @@ def __call__( context = get_route_execution_context( request, temporal_response, - self.route.permissions or _api_controller.permission_classes, + self.route.permissions or _api_controller.permission_classes, # type: ignore[arg-type] *args, **kwargs, ) @@ -199,7 +199,7 @@ async def __call__( context = get_route_execution_context( request, temporal_response, - self.route.permissions or _api_controller.permission_classes, + self.route.permissions or _api_controller.permission_classes, # type: ignore[arg-type] *args, **kwargs, ) diff --git a/ninja_extra/operation.py b/ninja_extra/operation.py index 4094dd0f..2e326adf 100644 --- a/ninja_extra/operation.py +++ b/ninja_extra/operation.py @@ -130,7 +130,7 @@ def get_execution_context( _api_controller = route_function.get_api_controller() permission_classes = ( - route_function.route.permissions or _api_controller.permission_classes + route_function.route.permissions or _api_controller.permission_classes # type: ignore[assignment] ) return get_route_execution_context( diff --git a/ninja_extra/permissions/base.py b/ninja_extra/permissions/base.py index 6d9833ce..b589262e 100644 --- a/ninja_extra/permissions/base.py +++ b/ninja_extra/permissions/base.py @@ -3,7 +3,7 @@ Provides a set of pluggable permission policies. """ from abc import ABC, ABCMeta, abstractmethod -from typing import TYPE_CHECKING, Any, Generic, Tuple, Type, TypeVar +from typing import TYPE_CHECKING, Any, Generic, Tuple, Type, TypeVar, Union from django.http import HttpRequest from ninja.types import DictStrAny @@ -17,19 +17,23 @@ class OperationHolderMixin: - def __and__(self, other: Type["BasePermission"]) -> "OperandHolder[AND]": + def __and__( + self, other: Union[Type["BasePermission"], "BasePermission"] + ) -> "OperandHolder[AND]": return OperandHolder(AND, self, other) # type: ignore - def __or__(self, other: Type["BasePermission"]) -> "OperandHolder[OR]": + def __or__( + self, other: Union[Type["BasePermission"], "BasePermission"] + ) -> "OperandHolder[OR]": return OperandHolder(OR, self, other) # type: ignore def __rand__( - self, other: Type["BasePermission"] + self, other: Union[Type["BasePermission"], "BasePermission"] ) -> "OperandHolder[AND]": # pragma: no cover return OperandHolder(AND, other, self) # type: ignore def __ror__( - self, other: Type["BasePermission"] + self, other: Union[Type["BasePermission"], "BasePermission"] ) -> "OperandHolder[OR]": # pragma: no cover return OperandHolder(OR, other, self) # type: ignore @@ -68,14 +72,18 @@ def has_object_permission( class SingleOperandHolder(OperationHolderMixin, Generic[T]): def __init__( - self, operator_class: Type[BasePermission], op1_class: Type[BasePermission] + self, + operator_class: Type[BasePermission], + op1_class: Union[Type["BasePermission"], "BasePermission"], ) -> None: super().__init__() self.operator_class = operator_class self.op1_class = op1_class def __call__(self, *args: Tuple[Any], **kwargs: DictStrAny) -> BasePermission: - op1 = self.op1_class() + op1 = self.op1_class + if isinstance(self.op1_class, (type, OperationHolderMixin)): + op1 = self.op1_class() return self.operator_class(op1) # type: ignore @@ -83,16 +91,22 @@ class OperandHolder(OperationHolderMixin, Generic[T]): def __init__( self, operator_class: Type["BasePermission"], - op1_class: Type["BasePermission"], - op2_class: Type["BasePermission"], + op1_class: Union[Type["BasePermission"], "BasePermission"], + op2_class: Union[Type["BasePermission"], "BasePermission"], ) -> None: self.operator_class = operator_class self.op1_class = op1_class self.op2_class = op2_class def __call__(self, *args: Tuple[Any], **kwargs: DictStrAny) -> BasePermission: - op1 = self.op1_class() - op2 = self.op2_class() + op1 = self.op1_class + op2 = self.op2_class + + if isinstance(self.op1_class, (type, OperationHolderMixin)): + op1 = self.op1_class() + + if isinstance(self.op2_class, (type, OperationHolderMixin)): + op2 = self.op2_class() return self.operator_class(op1, op2) # type: ignore diff --git a/ninja_extra/types.py b/ninja_extra/types.py index a76afe03..b2944944 100644 --- a/ninja_extra/types.py +++ b/ninja_extra/types.py @@ -1,4 +1,4 @@ -from typing import List, Type, Union +from typing import Any, List, Type, Union from ninja_extra.permissions.base import ( BasePermission, @@ -6,6 +6,6 @@ SingleOperandHolder, ) -PermissionType = Union[ - List[Type[BasePermission]], List[OperandHolder], List[SingleOperandHolder], List +PermissionType = List[ + Union[Type[BasePermission], OperandHolder, SingleOperandHolder, BasePermission, Any] ] diff --git a/tests/test_permissions.py b/tests/test_permissions.py index 45c8b06a..89ef08cb 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -4,7 +4,8 @@ import pytest from django.contrib.auth.models import AnonymousUser, User -from ninja_extra import permissions +from ninja_extra import ControllerBase, api_controller, http_get, permissions +from ninja_extra.testing import TestClient anonymous_request = Mock() anonymous_request.user = AnonymousUser() @@ -214,3 +215,39 @@ def test_object_and_lazyness(self): assert hasperm is False assert mock_deny.call_count == 1 mock_allow.assert_not_called() + + +@api_controller( + "permission/", permissions=[permissions.AllowAny, permissions.IsAdminUser()] +) +class Some2Controller(ControllerBase): + @http_get("index/") + def index(self): + return {"success": True} + + @http_get( + "permission/", + permissions=[permissions.IsAdminUser() & permissions.IsAuthenticatedOrReadOnly], + ) + def permission_accept_type_and_instance(self): + return {"success": True} + + +@pytest.mark.django_db +@pytest.mark.parametrize("route", ["permission/", "index/"]) +def test_permission_controller_instance(route): + user = User.objects.create_user( + username="eadwin", + email="eadwin@example.com", + password="password", + is_staff=True, + is_superuser=True, + ) + + client = TestClient(Some2Controller) + res = client.get(route, user=AnonymousUser()) + assert res.status_code == 403 + + res = client.get(route, user=user) + assert res.status_code == 200 + assert res.json() == {"success": True} From 125b7232a02b80afc984b88b4d24357cd62c9f20 Mon Sep 17 00:00:00 2001 From: Ezeudoh Tochukwu Date: Thu, 9 Mar 2023 12:35:40 +0100 Subject: [PATCH 2/2] 0.18.7 --- docs/api_controller/api_controller_permission.md | 2 +- ninja_extra/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/api_controller/api_controller_permission.md b/docs/api_controller/api_controller_permission.md index d1071076..2a3f706a 100644 --- a/docs/api_controller/api_controller_permission.md +++ b/docs/api_controller/api_controller_permission.md @@ -39,7 +39,7 @@ class PermissionController: return dict(says=word) ``` !!! Note - New in v0.18.8 + New in **v0.18.7** Controller Permission and Route Function `permissions` can now take `BasePermission` instance. For example, we can pass the `ReadOnly` instance to the `permission` parameter. diff --git a/ninja_extra/__init__.py b/ninja_extra/__init__.py index 6124ced8..4c1d5df7 100644 --- a/ninja_extra/__init__.py +++ b/ninja_extra/__init__.py @@ -1,6 +1,6 @@ """Django Ninja Extra - Class Based Utility and more for Django Ninja(Fast Django REST framework)""" -__version__ = "0.18.8" +__version__ = "0.18.7" import django