Skip to content

Commit

Permalink
Merge pull request #54 from eadwinCode/allow_permission_instance
Browse files Browse the repository at this point in the history
Allow permission instance in permissions parameter
  • Loading branch information
eadwinCode authored Mar 10, 2023
2 parents 0c8bb86 + 125b723 commit 388023d
Show file tree
Hide file tree
Showing 9 changed files with 131 additions and 30 deletions.
32 changes: 31 additions & 1 deletion docs/api_controller/api_controller_permission.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,37 @@ class PermissionController:
def must_be_authenticated(self, word: str):
return dict(says=word)
```
!!! Note
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.
```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)`
- ~ (not) eg: `!(permissions.IsAuthenticated & ReadOnly)`
2 changes: 1 addition & 1 deletion ninja_extra/__init__.py
Original file line number Diff line number Diff line change
@@ -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.7"

import django

Expand Down
6 changes: 5 additions & 1 deletion ninja_extra/controllers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
34 changes: 25 additions & 9 deletions ninja_extra/controllers/route/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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]:
"""
Expand Down Expand Up @@ -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]:
"""
Expand Down Expand Up @@ -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]:
"""
Expand Down Expand Up @@ -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]:
"""
Expand Down Expand Up @@ -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]:
"""
Expand Down Expand Up @@ -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]:
"""
Expand Down
4 changes: 2 additions & 2 deletions ninja_extra/controllers/route/route_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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,
)
Expand Down
2 changes: 1 addition & 1 deletion ninja_extra/operation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
36 changes: 25 additions & 11 deletions ninja_extra/permissions/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -68,31 +72,41 @@ 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


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


Expand Down
6 changes: 3 additions & 3 deletions ninja_extra/types.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
from typing import List, Type, Union
from typing import Any, List, Type, Union

from ninja_extra.permissions.base import (
BasePermission,
OperandHolder,
SingleOperandHolder,
)

PermissionType = Union[
List[Type[BasePermission]], List[OperandHolder], List[SingleOperandHolder], List
PermissionType = List[
Union[Type[BasePermission], OperandHolder, SingleOperandHolder, BasePermission, Any]
]
39 changes: 38 additions & 1 deletion tests/test_permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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="[email protected]",
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}

0 comments on commit 388023d

Please sign in to comment.