-
-
Notifications
You must be signed in to change notification settings - Fork 3.6k
Make APIv3 permission classes modular #5858
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 7 commits
c80fd05
6f0f9fa
5585e28
1077e02
766bb8d
67b7137
064889a
536a64a
18c4f32
3e34658
23e17f3
5ea105c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,31 +1,37 @@ | ||
from rest_framework.permissions import IsAuthenticated | ||
from rest_framework.permissions import BasePermission | ||
|
||
|
||
class PublicDetailPrivateListing(IsAuthenticated): | ||
class PublicDetailPrivateListing(BasePermission): | ||
|
||
""" | ||
Permission class for our custom use case. | ||
|
||
* Always give permission for a ``detail`` request | ||
* Only give permission for ``listing`` request if user is admin of the project | ||
""" | ||
|
||
def has_permission(self, request, view): | ||
if view.detail: | ||
return True | ||
|
||
project = view._get_parent_project() | ||
if view.has_admin_permission(request.user, project): | ||
return True | ||
|
||
|
||
class ListCreateProject(BasePermission): | ||
|
||
""" | ||
Permission class to grant projects listing and project creation. | ||
|
||
* Allow access to ``/projects`` (user's projects listing) | ||
""" | ||
|
||
def has_permission(self, request, view): | ||
is_authenticated = super().has_permission(request, view) | ||
if is_authenticated: | ||
if view.basename == 'projects' and any([ | ||
view.action == 'list', | ||
view.action is None, # needed for BrowsableAPIRenderer | ||
]): | ||
# hitting ``/projects/``, allowing | ||
return True | ||
|
||
if view.detail: | ||
return True | ||
|
||
project = view._get_parent_project() | ||
if view.has_admin_permission(request.user, project): | ||
return True | ||
|
||
return False | ||
if view.basename == 'projects' and any([ | ||
view.action == 'list', | ||
view.action == 'create', # used to create Form in BrowsableAPIRenderer | ||
view.action is None, # needed for BrowsableAPIRenderer | ||
]): | ||
# hitting ``/projects/``, allowing | ||
return True |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,47 @@ | ||
{ | ||
"_links": { | ||
"_self": "https://readthedocs.org/api/v3/projects/test-project/", | ||
"builds": "https://readthedocs.org/api/v3/projects/test-project/builds/", | ||
"subprojects": "https://readthedocs.org/api/v3/projects/test-project/subprojects/", | ||
"superproject": "https://readthedocs.org/api/v3/projects/test-project/superproject/", | ||
"translations": "https://readthedocs.org/api/v3/projects/test-project/translations/", | ||
"versions": "https://readthedocs.org/api/v3/projects/test-project/versions/" | ||
}, | ||
"created": "2019-04-29T14:00:00Z", | ||
"default_branch": "master", | ||
"default_version": "latest", | ||
"description": null, | ||
"id": 4, | ||
"language": { | ||
"code": "en", | ||
"name": "English" | ||
}, | ||
"modified": "2019-04-29T14:00:00Z", | ||
"name": "Test Project", | ||
"privacy_level": { | ||
"code": "public", | ||
"name": "Public" | ||
}, | ||
"programming_language": { | ||
"code": "words", | ||
"name": "Only Words" | ||
}, | ||
"repository": { | ||
"type": "git", | ||
"url": "https://github.com/rtfd/template" | ||
}, | ||
"slug": "test-project", | ||
"subproject_of": null, | ||
"tags": [], | ||
"translation_of": null, | ||
"urls": { | ||
"documentation": "http://readthedocs.org/docs/test-project/en/latest/", | ||
"project_homepage": null | ||
}, | ||
"users": [ | ||
{ | ||
"created": "2019-04-29T10:00:00Z", | ||
"username": "testuser" | ||
} | ||
] | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,7 @@ | ||
import django_filters.rest_framework as filters | ||
from django.utils.safestring import mark_safe | ||
from rest_flex_fields.views import FlexFieldsMixin | ||
from rest_framework import status | ||
from rest_framework.authentication import TokenAuthentication | ||
from rest_framework.decorators import action | ||
from rest_framework.metadata import SimpleMetadata | ||
|
@@ -10,24 +11,28 @@ | |
UpdateModelMixin, | ||
) | ||
from rest_framework.pagination import LimitOffsetPagination | ||
from rest_framework.permissions import IsAuthenticated | ||
from rest_framework.renderers import BrowsableAPIRenderer | ||
from rest_framework.response import Response | ||
from rest_framework.throttling import AnonRateThrottle, UserRateThrottle | ||
from rest_framework.viewsets import GenericViewSet, ReadOnlyModelViewSet | ||
from rest_framework_extensions.mixins import NestedViewSetMixin | ||
|
||
from readthedocs.builds.models import Build, Version | ||
from readthedocs.core.utils import trigger_build | ||
from readthedocs.core.utils import trigger_build, trigger_initial_build | ||
from readthedocs.projects.models import Project | ||
from readthedocs.projects.signals import project_import | ||
|
||
|
||
from .filters import BuildFilter, ProjectFilter, VersionFilter | ||
from .mixins import ProjectQuerySetMixin | ||
from .permissions import PublicDetailPrivateListing | ||
from .permissions import PublicDetailPrivateListing, ListCreateProject | ||
from .renderers import AlphabeticalSortedJSONRenderer | ||
from .serializers import ( | ||
BuildCreateSerializer, | ||
BuildSerializer, | ||
ProjectSerializer, | ||
ProjectCreateSerializer, | ||
VersionSerializer, | ||
VersionUpdateSerializer, | ||
) | ||
|
@@ -50,7 +55,7 @@ class APIv3Settings: | |
# Using only ``TokenAuthentication`` for now, so we can give access to | ||
# specific carefully selected users only | ||
authentication_classes = (TokenAuthentication,) | ||
permission_classes = (PublicDetailPrivateListing,) | ||
permission_classes = (IsAuthenticated & (ListCreateProject | PublicDetailPrivateListing),) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is starting to make me nervous and I don't really understand how There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ouch! I thought that splitting these permissions in different classes would be easier to understand them. Before this PR, everything was under a black box called My idea is:
then, the user has access to
OR
Let me know if this helps to clarify or if you have another idea how to make it better. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think overall, I'd feel a lot better if these special cases were not on the base class that every API endpoint extends from. The project endpoint is special (although with all the extra complexity, I'm starting to doubt that it should be) and these changes should be localized to there! |
||
|
||
pagination_class = LimitOffsetPagination | ||
LimitOffsetPagination.default_limit = 10 | ||
|
@@ -62,7 +67,7 @@ class APIv3Settings: | |
|
||
|
||
class ProjectsViewSet(APIv3Settings, NestedViewSetMixin, ProjectQuerySetMixin, | ||
FlexFieldsMixin, ReadOnlyModelViewSet): | ||
FlexFieldsMixin, CreateModelMixin, ReadOnlyModelViewSet): | ||
|
||
# Markdown docstring is automatically rendered by BrowsableAPIRenderer. | ||
|
||
|
@@ -110,14 +115,13 @@ class ProjectsViewSet(APIv3Settings, NestedViewSetMixin, ProjectQuerySetMixin, | |
* Subprojects of a project: ``/api/v3/projects/{project_slug}/subprojects/`` | ||
* Superproject of a project: ``/api/v3/projects/{project_slug}/superproject/`` | ||
|
||
Go to [https://docs.readthedocs.io/en/stable/api/v3.html](https://docs.readthedocs.io/en/stable/api/v3.html) | ||
Go to [https://docs.readthedocs.io/page/api/v3.html](https://docs.readthedocs.io/page/api/v3.html) | ||
for a complete documentation of the APIv3. | ||
""" # noqa | ||
|
||
model = Project | ||
lookup_field = 'slug' | ||
lookup_url_kwarg = 'project_slug' | ||
serializer_class = ProjectSerializer | ||
filterset_class = ProjectFilter | ||
queryset = Project.objects.all() | ||
permit_list_expands = [ | ||
|
@@ -126,6 +130,20 @@ class ProjectsViewSet(APIv3Settings, NestedViewSetMixin, ProjectQuerySetMixin, | |
'active_versions.last_build.config', | ||
] | ||
|
||
def get_serializer_class(self): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I see what you're doing here, but stylistically, I think it would be better to get rid of this method and make In all cases, you want the JSON output to use There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Actually, it looks like this is necessary due to how There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I changed this. Although, since this PR is based on the "Import Project" one, some things changed there and I will re-check this once that one gets merged. |
||
""" | ||
Return correct serializer depending on the action. | ||
|
||
For GET it returns a serializer with many fields and on PUT/PATCH/POST, | ||
it return a serializer to validate just a few fields. | ||
""" | ||
if self.action in ('list', 'retrieve', 'superproject'): | ||
return ProjectSerializer | ||
elif self.action in ('create',): | ||
return ProjectCreateSerializer | ||
# elif self.action in ('update', 'partial_update'): | ||
# return ProjectUpdateSerializer | ||
|
||
def get_queryset(self): | ||
# Allow hitting ``/api/v3/projects/`` to list their own projects | ||
if self.basename == 'projects' and self.action == 'list': | ||
|
@@ -161,6 +179,33 @@ def get_view_description(self, *args, **kwargs): # pylint: disable=arguments-di | |
return mark_safe(description.format(project_slug=project.slug)) | ||
return description | ||
|
||
def create(self, request, *args, **kwargs): | ||
""" | ||
Override method to importing a Project. | ||
|
||
* Save the Project object | ||
* Assign the user from the request as owner | ||
* Sent project_import signal | ||
* Trigger an initial Build | ||
""" | ||
serializer = self.get_serializer(data=request.data) | ||
serializer.is_valid(raise_exception=True) | ||
project = serializer.save() | ||
headers = self.get_success_headers(serializer.data) | ||
|
||
# TODO: these lines need to be adapted for Corporate | ||
project.users.add(request.user) | ||
project_import.send(sender=project, request=request) | ||
trigger_initial_build(project, request.user) | ||
|
||
# Full render Project | ||
serializer = ProjectSerializer(instance=project) | ||
return Response( | ||
serializer.data, | ||
status=status.HTTP_201_CREATED, | ||
headers=headers, | ||
) | ||
|
||
@action(detail=True, methods=['get']) | ||
def superproject(self, request, project_slug): | ||
"""Return the superproject of a ``Project``.""" | ||
|
@@ -170,7 +215,7 @@ def superproject(self, request, project_slug): | |
data = self.get_serializer(superproject).data | ||
return Response(data) | ||
except Exception: | ||
return Response(status=404) | ||
return Response(status=status.HTTP_404_NOT_FOUND) | ||
|
||
|
||
class SubprojectRelationshipViewSet(APIv3Settings, NestedViewSetMixin, | ||
|
@@ -259,7 +304,7 @@ def update(self, request, *args, **kwargs): | |
# ``httpOnly`` on our cookies and the ``PUT/PATCH`` method are triggered | ||
# via Javascript | ||
super().update(request, *args, **kwargs) | ||
return Response(status=204) | ||
return Response(status=status.HTTP_204_NO_CONTENT) | ||
|
||
|
||
class BuildsViewSet(APIv3Settings, NestedViewSetMixin, ProjectQuerySetMixin, | ||
|
@@ -299,8 +344,8 @@ def create(self, request, **kwargs): # pylint: disable=arguments-differ | |
|
||
if build: | ||
data.update({'triggered': True}) | ||
status = 202 | ||
code = status.HTTP_202_ACCEPTED | ||
else: | ||
data.update({'triggered': False}) | ||
status = 400 | ||
return Response(data=data, status=status) | ||
code = status.HTTP_400_BAD_REQUEST | ||
return Response(data=data, status=code) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
has_admin_permission
seems poorly named to me. It isn't immediately clear what it means. I realize it means that the user is a maintainer on the project but maybe it should be more explicit. How aboutis_project_maintainer
or something like that. It does seem to mirror the method name on theProjectQuerySet
but it is much more clear what that method does in context (Project.objects.for_admin_user
is fairly clear although could be better whileview.has_admin_permission
is not).There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is_project_maintainer
is a good name, but I'm not sure it does fit with corporate (or under an organizational structure) --where only read access is granted.Even if it doesn't fit 100%, it's still a better name 😄