Skip to content

Commit 892db33

Browse files
authored
Merge pull request #6489 from readthedocs/humitos/apiv3-corporate
Changes required for APIv3 in corporate
2 parents 555194f + 1f4b53c commit 892db33

8 files changed

+461
-38
lines changed

readthedocs/api/v3/mixins.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from rest_framework.response import Response
44

55
from readthedocs.builds.models import Version
6+
from readthedocs.organizations.models import Organization
67
from readthedocs.projects.models import Project
78

89

@@ -22,6 +23,11 @@ class NestedParentObjectMixin:
2223
'version__slug',
2324
]
2425

26+
ORGANIZATION_LOOKUP_NAMES = [
27+
'organization__slug',
28+
'organizations__slug',
29+
]
30+
2531
def _get_parent_object_lookup(self, lookup_names):
2632
query_dict = self.get_parents_query_dict()
2733
for lookup in lookup_names:
@@ -48,6 +54,19 @@ def _get_parent_version(self):
4854
project__slug=project_slug,
4955
)
5056

57+
def _get_parent_organization(self):
58+
slug = self._get_parent_object_lookup(self.ORGANIZATION_LOOKUP_NAMES)
59+
60+
# when hitting ``/organizations/<slug>/`` we don't have a "parent" organization
61+
# because this endpoint is the base one, so we just get the organization from
62+
# ``organization_slug`` kwargs
63+
slug = slug or self.kwargs.get('organization_slug')
64+
65+
return get_object_or_404(
66+
Organization,
67+
slug=slug,
68+
)
69+
5170

5271
class ProjectQuerySetMixin(NestedParentObjectMixin):
5372

@@ -103,6 +122,57 @@ def get_queryset(self):
103122
return self.listing_objects(queryset, self.request.user)
104123

105124

125+
class OrganizationQuerySetMixin(NestedParentObjectMixin):
126+
127+
"""
128+
Mixin to define queryset permissions for ViewSet only in one place.
129+
130+
All APIv3 organizations' ViewSet should inherit this mixin, unless specific permissions
131+
required. In that case, a specific mixin for that case should be defined.
132+
"""
133+
134+
def detail_objects(self, queryset, user):
135+
# Filter results by user
136+
return queryset.for_user(user=user)
137+
138+
def listing_objects(self, queryset, user):
139+
organization = self._get_parent_organization()
140+
if self.has_admin_permission(user, organization):
141+
return queryset
142+
143+
return queryset.none()
144+
145+
def has_admin_permission(self, user, organization):
146+
if self.admin_organizations(user).filter(pk=organization.pk).exists():
147+
return True
148+
149+
return False
150+
151+
def admin_organizations(self, user):
152+
return Organization.objects.for_admin_user(user=user)
153+
154+
def get_queryset(self):
155+
"""
156+
Filter results based on user permissions.
157+
158+
1. returns ``Organizations`` where the user is admin if ``/organizations/`` is hit
159+
2. filters by parent ``organization_slug`` (NestedViewSetMixin)
160+
2. returns ``detail_objects`` results if it's a detail view
161+
3. returns ``listing_objects`` results if it's a listing view
162+
4. raise a ``NotFound`` exception otherwise
163+
"""
164+
165+
# We need to have defined the class attribute as ``queryset = Model.objects.all()``
166+
queryset = super().get_queryset()
167+
168+
# Detail requests are public
169+
if self.detail:
170+
return self.detail_objects(queryset, self.request.user)
171+
172+
# List view are only allowed if user is owner of parent project
173+
return self.listing_objects(queryset, self.request.user)
174+
175+
106176
class UpdateMixin:
107177

108178
"""Make PUT to return 204 on success like PATCH does."""

readthedocs/api/v3/permissions.py

Lines changed: 72 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
from rest_framework.permissions import IsAuthenticated, BasePermission
22

3+
from readthedocs.core.utils.extend import SettingsOverrideObject
4+
5+
6+
class UserProjectsListing(BasePermission):
7+
8+
"""Allow access to ``/projects`` (user's projects listing)."""
9+
10+
def has_permission(self, request, view):
11+
if view.basename == 'projects' and view.action in (
12+
'list',
13+
'create', # used to create Form in BrowsableAPIRenderer
14+
None, # needed for BrowsableAPIRenderer
15+
):
16+
# hitting ``/projects/``, allowing
17+
return True
18+
319

420
class PublicDetailPrivateListing(IsAuthenticated):
521

@@ -8,33 +24,22 @@ class PublicDetailPrivateListing(IsAuthenticated):
824
925
* Always give permission for a ``detail`` request
1026
* Only give permission for ``listing`` request if user is admin of the project
11-
* Allow access to ``/projects`` (user's projects listing)
1227
"""
1328

1429
def has_permission(self, request, view):
15-
is_authenticated = super().has_permission(request, view)
16-
if is_authenticated:
17-
if view.basename == 'projects' and any([
18-
view.action == 'list',
19-
view.action == 'create', # used to create Form in BrowsableAPIRenderer
20-
view.action is None, # needed for BrowsableAPIRenderer
21-
]):
22-
# hitting ``/projects/``, allowing
23-
return True
24-
25-
# NOTE: ``superproject`` is an action name, defined by the class
26-
# method under ``ProjectViewSet``. We should apply the same
27-
# permissions restrictions than for a detail action (since it only
28-
# returns one superproject if exists). ``list`` and ``retrieve`` are
29-
# DRF standard action names (same as ``update`` or ``partial_update``).
30-
if view.detail and view.action in ('list', 'retrieve', 'superproject'):
31-
# detail view is only allowed on list/retrieve actions (not
32-
# ``update`` or ``partial_update``).
33-
return True
34-
35-
project = view._get_parent_project()
36-
if view.has_admin_permission(request.user, project):
37-
return True
30+
# NOTE: ``superproject`` is an action name, defined by the class
31+
# method under ``ProjectViewSet``. We should apply the same
32+
# permissions restrictions than for a detail action (since it only
33+
# returns one superproject if exists). ``list`` and ``retrieve`` are
34+
# DRF standard action names (same as ``update`` or ``partial_update``).
35+
if view.detail and view.action in ('list', 'retrieve', 'superproject'):
36+
# detail view is only allowed on list/retrieve actions (not
37+
# ``update`` or ``partial_update``).
38+
return True
39+
40+
project = view._get_parent_project()
41+
if view.has_admin_permission(request.user, project):
42+
return True
3843

3944
return False
4045

@@ -47,3 +52,46 @@ def has_permission(self, request, view):
4752
project = view._get_parent_project()
4853
if view.has_admin_permission(request.user, project):
4954
return True
55+
56+
57+
class IsOrganizationAdmin(BasePermission):
58+
59+
def has_permission(self, request, view):
60+
organization = view._get_parent_organization()
61+
if view.has_admin_permission(request.user, organization):
62+
return True
63+
64+
65+
class UserOrganizationsListing(BasePermission):
66+
67+
def has_permission(self, request, view):
68+
if view.basename == 'organizations' and view.action in (
69+
'list',
70+
None, # needed for BrowsableAPIRenderer
71+
):
72+
# hitting ``/organizations/``, allowing
73+
return True
74+
75+
76+
class CommonPermissionsBase(BasePermission):
77+
78+
"""
79+
Common permission class used for most APIv3 endpoints.
80+
81+
This class should be used by ``APIv3Settings.permission_classes`` to define
82+
the permissions for most APIv3 endpoints. It has to be overriden from
83+
corporate to define proper permissions there.
84+
"""
85+
86+
def has_permission(self, request, view):
87+
if not IsAuthenticated().has_permission(request, view):
88+
return False
89+
90+
return (
91+
UserProjectsListing().has_permission(request, view) or
92+
PublicDetailPrivateListing().has_permission(request, view)
93+
)
94+
95+
96+
class CommonPermissions(SettingsOverrideObject):
97+
_default_class = CommonPermissionsBase

readthedocs/api/v3/serializers.py

Lines changed: 91 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,14 @@
1010
from rest_flex_fields.serializers import FlexFieldsSerializerMixin
1111
from rest_framework import serializers
1212

13+
from readthedocs.core.utils.extend import SettingsOverrideObject
1314
from readthedocs.builds.models import Build, Version
1415
from readthedocs.core.utils import slugify
16+
from readthedocs.organizations.models import Organization, Team
1517
from readthedocs.projects.constants import (
1618
LANGUAGES,
1719
PROGRAMMING_LANGUAGES,
1820
REPO_CHOICES,
19-
PRIVACY_CHOICES,
20-
PROTECTED,
2121
)
2222
from readthedocs.projects.models import Project, EnvironmentVariable, ProjectRelationship
2323
from readthedocs.redirects.models import Redirect, TYPE_CHOICES as REDIRECT_TYPE_CHOICES
@@ -433,7 +433,7 @@ def get_translations(self, obj):
433433
return self._absolute_url(path)
434434

435435

436-
class ProjectCreateSerializer(FlexFieldsModelSerializer):
436+
class ProjectCreateSerializerBase(FlexFieldsModelSerializer):
437437

438438
"""Serializer used to Import a Project."""
439439

@@ -459,7 +459,11 @@ def validate_name(self, value):
459459
return value
460460

461461

462-
class ProjectUpdateSerializer(FlexFieldsModelSerializer):
462+
class ProjectCreateSerializer(SettingsOverrideObject):
463+
_default_class = ProjectCreateSerializerBase
464+
465+
466+
class ProjectUpdateSerializerBase(FlexFieldsModelSerializer):
463467

464468
"""Serializer used to modify a Project once imported."""
465469

@@ -492,7 +496,11 @@ class Meta:
492496
)
493497

494498

495-
class ProjectSerializer(FlexFieldsModelSerializer):
499+
class ProjectUpdateSerializer(SettingsOverrideObject):
500+
_default_class = ProjectUpdateSerializerBase
501+
502+
503+
class ProjectSerializerBase(FlexFieldsModelSerializer):
496504

497505
homepage = serializers.SerializerMethodField()
498506
language = LanguageSerializer()
@@ -567,6 +575,10 @@ def get_subproject_of(self, obj):
567575
return None
568576

569577

578+
class ProjectSerializer(SettingsOverrideObject):
579+
_default_class = ProjectSerializerBase
580+
581+
570582
class SubprojectCreateSerializer(FlexFieldsModelSerializer):
571583

572584
"""Serializer used to define a Project as subproject of another Project."""
@@ -800,3 +812,77 @@ class Meta:
800812
'project',
801813
'_links',
802814
]
815+
816+
817+
class OrganizationLinksSerializer(BaseLinksSerializer):
818+
_self = serializers.SerializerMethodField()
819+
projects = serializers.SerializerMethodField()
820+
821+
def get__self(self, obj):
822+
path = reverse(
823+
'organizations-detail',
824+
kwargs={
825+
'organization_slug': obj.slug,
826+
})
827+
return self._absolute_url(path)
828+
829+
def get_projects(self, obj):
830+
path = reverse(
831+
'organizations-projects-list',
832+
kwargs={
833+
'parent_lookup_organizations__slug': obj.slug,
834+
},
835+
)
836+
return self._absolute_url(path)
837+
838+
839+
class TeamSerializer(FlexFieldsModelSerializer):
840+
841+
# TODO: add ``projects`` as flex field when we have a
842+
# /organizations/<slug>/teams/<slug>/projects endpoint
843+
844+
created = serializers.DateTimeField(source='pub_date')
845+
modified = serializers.DateTimeField(source='modified_date')
846+
847+
class Meta:
848+
model = Team
849+
fields = (
850+
'name',
851+
'slug',
852+
'created',
853+
'modified',
854+
'access',
855+
)
856+
857+
expandable_fields = {
858+
'members': (UserSerializer, {'many': True}),
859+
}
860+
861+
862+
class OrganizationSerializer(FlexFieldsModelSerializer):
863+
864+
created = serializers.DateTimeField(source='pub_date')
865+
modified = serializers.DateTimeField(source='modified_date')
866+
owners = UserSerializer(many=True)
867+
868+
_links = OrganizationLinksSerializer(source='*')
869+
870+
class Meta:
871+
model = Organization
872+
fields = (
873+
'name',
874+
'description',
875+
'url',
876+
'slug',
877+
'email',
878+
'owners',
879+
'created',
880+
'modified',
881+
'disabled',
882+
'_links',
883+
)
884+
885+
expandable_fields = {
886+
'projects': (ProjectSerializer, {'many': True}),
887+
'teams': (TeamSerializer, {'many': True}),
888+
}

readthedocs/api/v3/tests/mixins.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,8 +153,9 @@ def _create_subproject(self):
153153
)
154154
self.project_relationship = self.project.add_subproject(self.subproject)
155155

156-
def _get_response_dict(self, view_name):
157-
filename = Path(__file__).absolute().parent / 'responses' / f'{view_name}.json'
156+
def _get_response_dict(self, view_name, filepath=None):
157+
filepath = filepath or __file__
158+
filename = Path(filepath).absolute().parent / 'responses' / f'{view_name}.json'
158159
return json.load(open(filename))
159160

160161
def assertDictEqual(self, d1, d2):

0 commit comments

Comments
 (0)