diff --git a/src/django_grainy/backends.py b/src/django_grainy/backends.py index 69f1611..0ec986b 100644 --- a/src/django_grainy/backends.py +++ b/src/django_grainy/backends.py @@ -1,6 +1,8 @@ import re +from typing import Any, Optional from django.contrib.auth.backends import ModelBackend +from django.contrib.auth.models import User from .helpers import django_op_to_flag from .models import namespace @@ -13,14 +15,16 @@ class GrainyBackend(ModelBackend): Authenticate actions using grainy permissions """ - def has_module_perms(self, user, obj=None): + def has_module_perms(self, user: User, obj: str = None) -> bool: + # superusers have access to everything if user.is_superuser: return True return Permissions(user).check(obj, django_op_to_flag("view")) - def has_perm(self, user, perm, obj=None): + def has_perm(self, user: User, perm: str, obj: Optional[Any] = None) -> bool: + # superusers have access to everything if user.is_superuser: return True diff --git a/src/django_grainy/decorators.py b/src/django_grainy/decorators.py index 9f207e2..0bfe7dd 100644 --- a/src/django_grainy/decorators.py +++ b/src/django_grainy/decorators.py @@ -1,9 +1,17 @@ import inspect import json +from typing import Any, Callable, Optional, Type, Union +from django.core.handlers.wsgi import WSGIRequest from django.core.serializers.json import DjangoJSONEncoder +from django.db.models import Model from django.http import HttpRequest, HttpResponse, JsonResponse +from django.http.response import HttpResponse, JsonResponse +from django.views import View from grainy.core import Namespace +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.serializers import Serializer from .exceptions import DecoratorRequiresNamespace from .handlers import GrainyHandler, GrainyModelHandler @@ -27,7 +35,12 @@ class grainy_decorator: # if true, this decorator cannot have a None namespace require_namespace = False - def __init__(self, namespace=None, namespace_instance=None, **kwargs): + def __init__( + self, + namespace: Optional[str] = None, + namespace_instance: Optional[Namespace] = None, + **kwargs, + ): self.namespace = namespace self.namespace_instance = namespace_instance self.extra = kwargs @@ -35,7 +48,7 @@ def __init__(self, namespace=None, namespace_instance=None, **kwargs): if self.require_namespace and not namespace: raise DecoratorRequiresNamespace(self) - def make_grainy_handler(self, target): + def make_grainy_handler(self, target: Any) -> GrainyHandler: class Grainy(self.handler_class): pass @@ -68,11 +81,13 @@ class grainy_model(grainy_decorator): handler_class = GrainyModelHandler - def __init__(self, namespace=None, parent=None, **kwargs): + def __init__( + self, namespace: Optional[str] = None, parent: Optional[str] = None, **kwargs + ) -> grainy_decorator: self.parent = parent return super().__init__(namespace=namespace, **kwargs) - def __call__(self, model): + def __call__(self, model: Model) -> Model: model.Grainy = self.make_grainy_handler(model) if self.parent: model.Grainy.parent_field = self.parent @@ -88,7 +103,7 @@ def __call__(self, model): return model - def parent_namespacing(self, model): + def parent_namespacing(self, model: Model): namespace = [model.Grainy.namespace_instance_template] if not namespace: namespace = [model.Grainy.namespace(), "{instance.pk}"] @@ -137,7 +152,8 @@ class grainy_view_response(grainy_decorator): view = None - def __call__(self, view_function): + def __call__(self, view_function: Callable) -> Callable: + get_object = self.get_object apply_perms = self.apply_perms extra = self.extra @@ -146,7 +162,9 @@ def __call__(self, view_function): grainy_handler = self.make_grainy_handler(view_function) - def response_handler(*args, **kwargs): + def response_handler( + *args: Any, **kwargs: Any + ) -> Union[HttpResponse, JsonResponse, Response]: if isinstance(args[0], HttpRequest): self = None request = args[0] @@ -195,7 +213,7 @@ def response_handler(*args, **kwargs): response_handler.__name__ = view_function.__name__ return response_handler - def get_object(self, view): + def get_object(self, view: View) -> Optional[Any]: """ Attempts to call and return `get_object` on the decorated view. @@ -207,13 +225,19 @@ def get_object(self, view): return view.get_object() return None - def apply_perms(self, request, response, view_function, view): + def apply_perms( + self, + request: WSGIRequest, + response: HttpResponse, + view_function: Callable, + view: View, + ) -> HttpResponse: """ Apply permissions to the generated response """ return response - def augment_request(self, request): + def augment_request(self, request: WSGIRequest) -> WSGIRequest: """ Augment the request instance """ @@ -234,7 +258,13 @@ class grainy_json_view_response(grainy_view_response): - json_dumps_params : passed to DjangoJSONEncoder """ - def _apply_perms(self, request, data, view_function, view): + def _apply_perms( + self, + request: Union[WSGIRequest, Request], + data: Any, + view_function: Callable, + view: View, + ) -> Union[dict, list]: perms = self.permissions_cls(request.user) try: self.get_object(view) @@ -264,7 +294,13 @@ def _apply_perms(self, request, data, view_function, view): else: return {} - def apply_perms(self, request, response, view_function, view): + def apply_perms( + self, + request: WSGIRequest, + response: JsonResponse, + view_function: Callable, + view: View, + ) -> JsonResponse: response.content = JsonResponse( self._apply_perms( request, @@ -298,17 +334,19 @@ class grainy_rest_viewset_response(grainy_json_view_response): require_namespace = True - def get_object(self, view): + def get_object(self, view: View) -> Optional[Any]: try: return super().get_object(view) except AssertionError: return None - def apply_perms(self, request, response, view_function, view): + def apply_perms( + self, request: Request, response: Response, view_function: Callable, view: View + ) -> Response: response.data = self._apply_perms(request, response.data, view_function, view) return response - def augment_request(self, request): + def augment_request(self, request: Request) -> Request: """ Augments the request by adding the following methods @@ -325,7 +363,8 @@ def augment_request(self, request): ) perms = decorator.permissions_cls(request.user) - def grainy_data(request, defaults): + def grainy_data(request: Request, defaults: dict): + """ Returns a cleaned up dict for request.data @@ -353,7 +392,9 @@ def grainy_data(request, defaults): return data - def grainy_update_serializer(serializer_cls, instance, **kwargs): + def grainy_update_serializer( + serializer_cls: Type[Serializer], instance: Serializer, **kwargs + ) -> Request: """ returns a django-rest-framework serializer instance with for saves. @@ -389,12 +430,13 @@ class grainy_view(grainy_decorator): decorator = grainy_view_response require_namespace = True - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any): self.args = args self.kwargs = kwargs super().__init__(*args, **kwargs) - def __call__(self, view): + def __call__(self, view: View): + view.Grainy = self.make_grainy_handler(view) if inspect.isclass(view): @@ -413,7 +455,7 @@ def __call__(self, view): else: return self.decorate(view) - def decorate(self, view): + def decorate(self, view: View): return self.decorator(*self.args, **self.kwargs)(view) diff --git a/src/django_grainy/exceptions.py b/src/django_grainy/exceptions.py index e248e69..3191797 100644 --- a/src/django_grainy/exceptions.py +++ b/src/django_grainy/exceptions.py @@ -1,9 +1,12 @@ +from typing import Any, Callable + + class DecoratorRequiresNamespace(ValueError): - def __init__(self, decorator): + def __init__(self, decorator: Callable): super().__init__("This decorator requires you to specify a namespace") self.decorator = decorator class PermissionDenied(Exception): - def __init__(self, reason): + def __init__(self, reason: Any): super().__init__(f"Permission denied: {reason}") diff --git a/src/django_grainy/fields.py b/src/django_grainy/fields.py index df10a7d..c7512a3 100644 --- a/src/django_grainy/fields.py +++ b/src/django_grainy/fields.py @@ -1,3 +1,5 @@ +from typing import List, Union + from django import forms from django.db import models @@ -5,7 +7,7 @@ class PermissionFormField(forms.IntegerField): - def prepare_value(self, value): + def prepare_value(self, value: Union[List[int], int]) -> List[int]: # if the form field is passed a bitmask we # need to convert it to a list, where each # item represents a choice (flag) @@ -19,7 +21,7 @@ def prepare_value(self, value): value = value or 0 return value - def clean(self, value): + def clean(self, value: List[int]) -> int: if isinstance(value, list): _value = 0 for flag in value: @@ -29,7 +31,7 @@ def clean(self, value): class PermissionField(models.IntegerField): - def to_python(self, value): + def to_python(self, value: Union[int, str]) -> int: # if a string is passed it should be parsed # for string flags (for example 'c', 'r', 'u' and 'd') # as specified in the PERM_CHOICES setting and diff --git a/src/django_grainy/handlers.py b/src/django_grainy/handlers.py index 12e7d55..31fbaa8 100644 --- a/src/django_grainy/handlers.py +++ b/src/django_grainy/handlers.py @@ -1,3 +1,6 @@ +from typing import Any, Union + +from django.db.models import Model from grainy.core import Namespace @@ -11,7 +14,9 @@ class GrainyHandler: namespace_instance_template = "{namespace}.{instance}" @classmethod - def namespace_instance(cls, instance, **kwargs): + def namespace_instance( + cls, instance: Union[object, str, Namespace], **kwargs: Any + ) -> str: """ Returns the permissioning namespace for the passed instance @@ -45,7 +50,9 @@ def namespace_instance(cls, instance, **kwargs): ).lower() @classmethod - def namespace(cls, instance=None, **kwargs): + def namespace( + cls, instance: Union[object, str, Namespace] = None, **kwargs: Any + ) -> str: """ Wrapper function to return either the result of namespace_base or namespace instance depending on whether or not a value was passed in @@ -68,13 +75,13 @@ def namespace(cls, instance=None, **kwargs): return namespace.lower() @classmethod - def set_namespace_base(cls, value): + def set_namespace_base(cls, value: Namespace): if not isinstance(value, Namespace): raise TypeError("`value` needs to be a Namespace instance") cls.namespace_base = value @classmethod - def set_parent(cls, parent): + def set_parent(cls, parent: Model): cls.parent = parent @@ -88,7 +95,7 @@ class GrainyModelHandler(GrainyHandler): namespace_instance_template = "{namespace}.{instance.pk}" @classmethod - def set_parent(cls, model): + def set_parent(cls, model: Model): cls.parent = model cls.model = model cls.set_namespace_base( diff --git a/src/django_grainy/helpers.py b/src/django_grainy/helpers.py index 4f1cb12..9dd1693 100644 --- a/src/django_grainy/helpers.py +++ b/src/django_grainy/helpers.py @@ -1,11 +1,15 @@ import inspect +from typing import Any, Union +from django.core.handlers.wsgi import WSGIRequest from grainy.core import Namespace +from rest_framework.request import Request from .conf import DJANGO_OP_TO_FLAG, PERM_CHOICES, REQUEST_METHOD_TO_FLAG -def namespace(target, **kwargs): +def namespace(target: Any, **kwargs: Any) -> str: + """ Convert `target` to permissioning namespace @@ -49,7 +53,7 @@ def namespace(target, **kwargs): ) -def dict_get_namespace(data, namespace): +def dict_get_namespace(data: Any, namespace: Namespace) -> Any: d = data path = [] for k in namespace: @@ -60,7 +64,7 @@ def dict_get_namespace(data, namespace): return d -def request_to_flag(request): +def request_to_flag(request: Request) -> int: """ Returns the appropriate grainy permission flag for the request depending on the request's method. @@ -74,7 +78,7 @@ def request_to_flag(request): return request_method_to_flag(request.method) -def request_method_to_flag(method): +def request_method_to_flag(method: str) -> int: """ Converts a request method to the matching grainy permission flag @@ -88,7 +92,7 @@ def request_method_to_flag(method): return REQUEST_METHOD_TO_FLAG.get(method.upper(), 0) -def django_op_to_flag(op): +def django_op_to_flag(op: str) -> int: """ Converts a django admin operation string to the matching grainy permission flag @@ -102,7 +106,7 @@ def django_op_to_flag(op): return DJANGO_OP_TO_FLAG.get(op, 0) -def int_flags(flags): +def int_flags(flags: Union[int, str]) -> int: """ Converts string permission flags into integer permission flags @@ -132,7 +136,7 @@ def int_flags(flags): return r -def str_flags(flags): +def str_flags(flags: int) -> str: """ Converts integer permission flags into string permission flags diff --git a/src/django_grainy/models.py b/src/django_grainy/models.py index bfb2353..790d52b 100644 --- a/src/django_grainy/models.py +++ b/src/django_grainy/models.py @@ -1,3 +1,5 @@ +from typing import Any, Union + from django.contrib.auth import get_user_model from django.contrib.auth.models import Group from django.db import models @@ -15,7 +17,7 @@ class PermissionQuerySet(models.QuerySet): GroupPermission queries """ - def permission_set(self): + def permission_set(self) -> PermissionSet: """ Builds and returns a grainy.PermissionSet object from all the rows in the query @@ -35,10 +37,10 @@ class PermissionManager(models.Manager): UserPermission and GroupPermission objects """ - def get_queryset(self): + def get_queryset(self) -> PermissionQuerySet: return PermissionQuerySet(self.model, using=self._db) - def add_permission_set(self, pset): + def add_permission_set(self, pset: Union[PermissionSet, dict]) -> None: """ Add all permissions specified in a PermissionSet @@ -60,7 +62,7 @@ def add_permission_set(self, pset): namespace=_namespace, defaults={"permission": permission.value} ) - def add_permission(self, target, permission): + def add_permission(self, target: Any, permission: Union[int, str]) -> None: """ Add permission for the specified target @@ -73,7 +75,7 @@ def add_permission(self, target, permission): namespace=namespace(target), defaults={"permission": int_flags(permission)} ) - def delete_permission(self, target): + def delete_permission(self, target: Any) -> None: """ Remove an explicit permission rule set for the specified target. @@ -87,7 +89,7 @@ def delete_permission(self, target): self.get_queryset().filter(namespace=namespace(target)).delete() - def permission_set(self): + def permission_set(self) -> PermissionSet: """ Return grainy.PermissionSet instance from all rows returned from get_queryset() @@ -111,7 +113,7 @@ class Meta: ) permission = PermissionField(default=PERM_READ) - def __unicode__(self): + def __unicode__(self) -> str: return f"{self.namespace}: {self.permission}" diff --git a/src/django_grainy/remote.py b/src/django_grainy/remote.py index bb1e173..33072fb 100644 --- a/src/django_grainy/remote.py +++ b/src/django_grainy/remote.py @@ -8,11 +8,15 @@ to request and check permissions from the provider. """ import json +from typing import Any, Union +from django.contrib.auth.models import AnonymousUser, Group, User from django.core.cache import cache +from django.db.models import Model from django.http import HttpResponse, JsonResponse from django.views import View -from grainy.core import Permission, PermissionSet +from grainy.core import Namespace, Permission, PermissionSet +from rest_framework.request import Request import django_grainy.util @@ -81,7 +85,7 @@ class ProvideGet(Provider): namespace """ - def get(self, request, namespace): + def get(self, request: Request, namespace: Namespace) -> HttpResponse: self.authenticate(request) as_string = bool(request.GET.get("as_string", 0)) explicit = bool(request.GET.get("explicit", 0)) @@ -96,7 +100,7 @@ class ProvideLoad(Provider): Provide full permission set (dict) """ - def get(self, request): + def get(self, request: Request) -> JsonResponse: self.authenticate(request) return JsonResponse( self.permissions.pset.permissions, @@ -116,7 +120,13 @@ class Permissions(django_grainy.util.Permissions): """ - def __init__(self, obj, url_load=None, url_get=None, cache=5): + def __init__( + self, + obj: Union[User, AnonymousUser, Group, Model], + url_load: str = None, + url_get: str = None, + cache: int = 5, + ): """ Arguments: - obj (`User`|`AnonymousUser`|`Group`|Model`) @@ -142,7 +152,8 @@ def __init__(self, obj, url_load=None, url_get=None, cache=5): ) self.cache = cache - def fetch(self, url, cache_key, **params): + def fetch(self, url: str, cache_key: str, **params: Any) -> dict: + """ Retrieve grainy permissions from remote endpoint @@ -171,7 +182,7 @@ def fetch(self, url, cache_key, **params): return data - def load(self, refresh=False): + def load(self, refresh: bool = False) -> None: """ Load permission set from the remote provider @@ -191,7 +202,9 @@ def load(self, refresh=False): self.pset.update(self.fetch(self.url_load, cache_key)) self.loaded = True - def get(self, target, as_string=False, explicit=False): + def get( + self, target: Any, as_string: bool = False, explicit: bool = False + ) -> Union[int, str]: """ Retrieve permission flags for the specified target namespace from the remote provider @@ -230,7 +243,13 @@ def get(self, target, as_string=False, explicit=False): return django_grainy.util.str_flags(r) return int(r) - def check(self, target, permissions, explicit=False, **kwargs): + def check( + self, + target: Any, + permissions: Union[int, str], + explicit: bool = False, + **kwargs, + ) -> bool: if self.url_load: self.load() return super().check( @@ -240,7 +259,7 @@ def check(self, target, permissions, explicit=False, **kwargs): perms = self.get(target, explicit=explicit) return perms & django_grainy.util.int_flags(permissions) != 0 - def apply(self, *args, **kwargs): + def apply(self, *args: Any, **kwargs: Any) -> dict: if not self.url_load: raise NotImplementedError( "Specify `url_load` in order to use `apply` with remote permissions" @@ -248,7 +267,7 @@ def apply(self, *args, **kwargs): self.load() return super().apply(*args, **kwargs) - def instances(self, *args, **kwargs): + def instances(self, *args: Any, **kwargs: Any) -> list: if not self.url_load: raise NotImplementedError( "Specify `url_load` in order to use `instances` with remote permissions" diff --git a/src/django_grainy/rest.py b/src/django_grainy/rest.py index 2ef55e6..6fad91e 100644 --- a/src/django_grainy/rest.py +++ b/src/django_grainy/rest.py @@ -1,4 +1,6 @@ +from django.views import View from rest_framework.permissions import BasePermission +from rest_framework.request import Request from .helpers import request_method_to_flag from .util import check_permissions @@ -19,7 +21,7 @@ class ModelViewSetPermissions(BasePermission): - partial update """ - def has_permission(self, request, view): + def has_permission(self, request: Request, view: View): if hasattr(view, "Grainy"): flag = request_method_to_flag(request.method) return check_permissions(request.user, view, flag) @@ -28,6 +30,6 @@ def has_permission(self, request, view): return True - def has_object_permission(self, request, view, obj): + def has_object_permission(self, request: Request, view: View, obj: object): flag = request_method_to_flag(request.method) return check_permissions(request.user, obj, flag) diff --git a/src/django_grainy/util.py b/src/django_grainy/util.py index 7c73898..7188c96 100644 --- a/src/django_grainy/util.py +++ b/src/django_grainy/util.py @@ -1,20 +1,30 @@ +from typing import Any, List, Union + from django.contrib.auth import get_user_model -from django.contrib.auth.models import AnonymousUser, Group -from django.db.models import QuerySet +from django.contrib.auth.models import AnonymousUser, Group, User +from django.db.models import Model, QuerySet +from django.db.models.base import ModelBase from grainy.core import Applicator, PermissionSet from .conf import ANONYMOUS_GROUP, ANONYMOUS_PERMS from .helpers import int_flags, namespace, str_flags -def check_permissions(obj, target, permissions, **kwargs): +def check_permissions( + obj: Union[User, AnonymousUser, Group, Model], + target: Any, + permissions: Union[int, str], + **kwargs: Any +): if not hasattr(obj, "_permissions_util"): obj._permissions_util = Permissions(obj) return obj._permissions_util.check(target, permissions, **kwargs) -def get_permissions(obj, target, **kwargs): +def get_permissions( + obj: Union[User, AnonymousUser, Group, Model], target: Any, **kwargs: Any +): if not hasattr(obj, "_permissions_util"): obj._permissions_util = Permissions(obj) return obj._permissions_util.get(target, **kwargs) @@ -27,7 +37,7 @@ class Permissions: for a user or group on cached permission sets """ - def __init__(self, obj): + def __init__(self, obj: Union[User, AnonymousUser, Group, Model]) -> None: """ Arguments: - obj @@ -47,7 +57,7 @@ def __init__(self, obj): self.grant_all = isinstance(obj, get_user_model()) and obj.is_superuser - def load(self, refresh=False): + def load(self, refresh: bool = False) -> None: """ Loads the permission set for the user or group specified in `self.obj` @@ -93,7 +103,13 @@ def load(self, refresh=False): ) self.loaded = True - def check(self, target, permissions, explicit=False, ignore_grant_all=False): + def check( + self, + target: Any, + permissions: Union[int, str], + explicit: bool = False, + ignore_grant_all: bool = False, + ) -> bool: """ Check permissions for the specified target @@ -114,7 +130,12 @@ def check(self, target, permissions, explicit=False, ignore_grant_all=False): namespace(target), int_flags(permissions), explicit=explicit ) - def get(self, target, as_string=False, explicit=False): + def get( + self, + target: Any, + as_string: bool = False, + explicit: bool = False, + ) -> Union[int, str]: """ Returns the permission flags for the specified target @@ -136,7 +157,7 @@ def get(self, target, as_string=False, explicit=False): ) return self.pset.get_permissions(namespace(target), explicit=explicit) - def apply(self, data): + def apply(self, data: dict) -> dict: """ Applies permissions to the specified data, removing all content that is not permissioned on a READ level @@ -152,7 +173,13 @@ def apply(self, data): self.applicator.pset = self.pset return self.pset.apply(data, applicator=self.applicator) - def instances(self, model, permissions, explicit=False, ignore_grant_all=False): + def instances( + self, + model: Union[Model, QuerySet], + permissions: Union[int, str], + explicit: bool = False, + ignore_grant_all: bool = False, + ) -> list: """ Return a list of all instances of the specified model that are permissioned at the specified level