Skip to content
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 required for APIv3 in corporate #6489

Merged
merged 27 commits into from
Nov 16, 2020
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
43186ad
Split PublicDetailPrivateListing permission class
humitos Dec 23, 2019
20f82e1
Use a CommonPermissions class that can be overriden from corporate
humitos Dec 23, 2019
cb471c3
Update docstrings
humitos Dec 23, 2019
fde7f8f
Allow to extend Project serializers
humitos Dec 31, 2019
d8a3957
Merge branch 'master' of github.com:readthedocs/readthedocs.org into …
humitos Jan 6, 2020
44852f4
Merge branch 'master' of github.com:readthedocs/readthedocs.org into …
humitos Jul 13, 2020
3d76347
Allow to extend ProjectsViewSet
humitos Jul 13, 2020
ed5d210
Organizations APIv3
humitos Jul 14, 2020
87a8bab
Allow to extend organizations API views
humitos Jul 15, 2020
93fa4bb
Fallback to get parent organization
humitos Jul 15, 2020
3f4b53b
Allow to expand `projects` from organizations
humitos Jul 15, 2020
697f9d5
Remove old TODO comment
humitos Jul 15, 2020
651ca61
Lint
humitos Jul 15, 2020
c4635c5
Avoid calling PublicDetailPrivateListing() on /projects/ URL
humitos Jul 20, 2020
597d8a4
Return `subproject_of` field in project details
humitos Jul 20, 2020
72ffd4f
Use ModelSerializer for Team
humitos Jul 20, 2020
08cadaf
Make `teams` expandable
humitos Jul 20, 2020
68871e3
Allow _get_response_dict to load files from different path
humitos Jul 20, 2020
f90c2e7
Merge branch 'master' of github.com:readthedocs/readthedocs.org into …
humitos Oct 5, 2020
7b4030f
Merge branch 'master' of github.com:readthedocs/readthedocs.org into …
humitos Oct 27, 2020
5520ed9
Improve SQL query
humitos Oct 27, 2020
493c866
Add ``members`` to Team endpoint response
humitos Oct 27, 2020
fcdcadc
Makes `organizations.teams.members` expandable
humitos Oct 27, 2020
f4aead5
Merge branch 'master' into humitos/apiv3-corporate
stsewd Nov 4, 2020
1cc505a
Merge branch 'master' of github.com:readthedocs/readthedocs.org into …
humitos Nov 11, 2020
6c161f8
Lint
humitos Nov 11, 2020
1f4b53c
Re-write any() as "attribute in tuple"
humitos Nov 11, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions readthedocs/api/v3/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from rest_framework.response import Response

from readthedocs.builds.models import Version
from readthedocs.organizations.models import Organization
from readthedocs.projects.models import Project


Expand All @@ -22,6 +23,11 @@ class NestedParentObjectMixin:
'version__slug',
]

ORGANIZATION_LOOKUP_NAMES = [
'organization__slug',
'organizations__slug',
]

def _get_parent_object_lookup(self, lookup_names):
query_dict = self.get_parents_query_dict()
for lookup in lookup_names:
Expand All @@ -48,6 +54,19 @@ def _get_parent_version(self):
project__slug=project_slug,
)

def _get_parent_organization(self):
slug = self._get_parent_object_lookup(self.ORGANIZATION_LOOKUP_NAMES)

# when hitting ``/organizations/<slug>/`` we don't have a "parent" organization
# because this endpoint is the base one, so we just get the organization from
# ``organization_slug`` kwargs
slug = slug or self.kwargs.get('organization_slug')

return get_object_or_404(
Organization,
slug=slug,
)


class ProjectQuerySetMixin(NestedParentObjectMixin):

Expand Down Expand Up @@ -103,6 +122,60 @@ def get_queryset(self):
return self.listing_objects(queryset, self.request.user)


class OrganizationQuerySetMixin(NestedParentObjectMixin):

"""
Mixin to define queryset permissions for ViewSet only in one place.

All APIv3 organizations' ViewSet should inherit this mixin, unless specific permissions
required. In that case, a specific mixin for that case should be defined.
"""

def detail_objects(self, queryset, user):
# Filter results by user
return queryset.for_user(user=user)

def listing_objects(self, queryset, user):
organization = self._get_parent_organization()
if self.has_admin_permission(user, organization):
return queryset

return queryset.none()

def has_admin_permission(self, user, organization):
# Use .only for small optimization
admin_organizations = self.admin_organizations(user).only('id')
humitos marked this conversation as resolved.
Show resolved Hide resolved

if organization in admin_organizations:
return True

return False

def admin_organizations(self, user):
return Organization.objects.for_admin_user(user=user)

def get_queryset(self):
"""
Filter results based on user permissions.

1. returns ``Organizations`` where the user is admin if ``/organizations/`` is hit
2. filters by parent ``organization_slug`` (NestedViewSetMixin)
2. returns ``detail_objects`` results if it's a detail view
3. returns ``listing_objects`` results if it's a listing view
4. raise a ``NotFound`` exception otherwise
"""

# We need to have defined the class attribute as ``queryset = Model.objects.all()``
queryset = super().get_queryset()

# Detail requests are public
if self.detail:
return self.detail_objects(queryset, self.request.user)

# List view are only allowed if user is owner of parent project
return self.listing_objects(queryset, self.request.user)


class UpdateMixin:

"""Make PUT to return 204 on success like PATCH does."""
Expand Down
96 changes: 72 additions & 24 deletions readthedocs/api/v3/permissions.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
from rest_framework.permissions import IsAuthenticated, BasePermission

from readthedocs.core.utils.extend import SettingsOverrideObject


class UserProjectsListing(BasePermission):

"""Allow access to ``/projects`` (user's projects listing)."""

def has_permission(self, request, view):
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
humitos marked this conversation as resolved.
Show resolved Hide resolved
]):
# hitting ``/projects/``, allowing
return True


class PublicDetailPrivateListing(IsAuthenticated):

Expand All @@ -8,33 +24,22 @@ class PublicDetailPrivateListing(IsAuthenticated):

* Always give permission for a ``detail`` request
* Only give permission for ``listing`` request if user is admin of the project
* 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 == 'create', # used to create Form in BrowsableAPIRenderer
view.action is None, # needed for BrowsableAPIRenderer
]):
# hitting ``/projects/``, allowing
return True

# NOTE: ``superproject`` is an action name, defined by the class
# method under ``ProjectViewSet``. We should apply the same
# permissions restrictions than for a detail action (since it only
# returns one superproject if exists). ``list`` and ``retrieve`` are
# DRF standard action names (same as ``update`` or ``partial_update``).
if view.detail and view.action in ('list', 'retrieve', 'superproject'):
# detail view is only allowed on list/retrieve actions (not
# ``update`` or ``partial_update``).
return True

project = view._get_parent_project()
if view.has_admin_permission(request.user, project):
return True
# NOTE: ``superproject`` is an action name, defined by the class
# method under ``ProjectViewSet``. We should apply the same
# permissions restrictions than for a detail action (since it only
# returns one superproject if exists). ``list`` and ``retrieve`` are
# DRF standard action names (same as ``update`` or ``partial_update``).
if view.detail and view.action in ('list', 'retrieve', 'superproject'):
# detail view is only allowed on list/retrieve actions (not
# ``update`` or ``partial_update``).
return True

project = view._get_parent_project()
if view.has_admin_permission(request.user, project):
return True

return False

Expand All @@ -47,3 +52,46 @@ def has_permission(self, request, view):
project = view._get_parent_project()
if view.has_admin_permission(request.user, project):
return True


class IsOrganizationAdmin(BasePermission):

def has_permission(self, request, view):
organization = view._get_parent_organization()
if view.has_admin_permission(request.user, organization):
return True


class UserOrganizationsListing(BasePermission):

def has_permission(self, request, view):
if view.basename == 'organizations' and any([
view.action == 'list',
view.action is None, # needed for BrowsableAPIRenderer
]):
# hitting ``/organizations/``, allowing
return True


class CommonPermissionsBase(BasePermission):

"""
Common permission class used for most APIv3 endpoints.

This class should be used by ``APIv3Settings.permission_classes`` to define
the permissions for most APIv3 endpoints. It has to be overriden from
corporate to define proper permissions there.
"""

def has_permission(self, request, view):
if not IsAuthenticated().has_permission(request, view):
return False

return (
UserProjectsListing().has_permission(request, view) or
PublicDetailPrivateListing().has_permission(request, view)
)


class CommonPermissions(SettingsOverrideObject):
_default_class = CommonPermissionsBase
92 changes: 87 additions & 5 deletions readthedocs/api/v3/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@
from rest_flex_fields.serializers import FlexFieldsSerializerMixin
from rest_framework import serializers

from readthedocs.core.utils.extend import SettingsOverrideObject
from readthedocs.builds.models import Build, Version
from readthedocs.core.utils import slugify
from readthedocs.organizations.models import Organization, Team
from readthedocs.projects.constants import (
LANGUAGES,
PROGRAMMING_LANGUAGES,
REPO_CHOICES,
PRIVACY_CHOICES,
PROTECTED,
)
from readthedocs.projects.models import Project, EnvironmentVariable, ProjectRelationship
from readthedocs.redirects.models import Redirect, TYPE_CHOICES as REDIRECT_TYPE_CHOICES
Expand Down Expand Up @@ -433,7 +433,7 @@ def get_translations(self, obj):
return self._absolute_url(path)


class ProjectCreateSerializer(FlexFieldsModelSerializer):
class ProjectCreateSerializerBase(FlexFieldsModelSerializer):

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

Expand All @@ -459,7 +459,11 @@ def validate_name(self, value):
return value


class ProjectUpdateSerializer(FlexFieldsModelSerializer):
class ProjectCreateSerializer(SettingsOverrideObject):
_default_class = ProjectCreateSerializerBase


class ProjectUpdateSerializerBase(FlexFieldsModelSerializer):

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

Expand Down Expand Up @@ -492,7 +496,11 @@ class Meta:
)


class ProjectSerializer(FlexFieldsModelSerializer):
class ProjectUpdateSerializer(SettingsOverrideObject):
_default_class = ProjectUpdateSerializerBase


class ProjectSerializerBase(FlexFieldsModelSerializer):

homepage = serializers.SerializerMethodField()
language = LanguageSerializer()
Expand Down Expand Up @@ -567,6 +575,10 @@ def get_subproject_of(self, obj):
return None


class ProjectSerializer(SettingsOverrideObject):
_default_class = ProjectSerializerBase


class SubprojectCreateSerializer(FlexFieldsModelSerializer):

"""Serializer used to define a Project as subproject of another Project."""
Expand Down Expand Up @@ -800,3 +812,73 @@ class Meta:
'project',
'_links',
]


class OrganizationLinksSerializer(BaseLinksSerializer):
_self = serializers.SerializerMethodField()
projects = serializers.SerializerMethodField()

def get__self(self, obj):
path = reverse(
'organizations-detail',
kwargs={
'organization_slug': obj.slug,
})
return self._absolute_url(path)

def get_projects(self, obj):
path = reverse(
'organizations-projects-list',
kwargs={
'parent_lookup_organizations__slug': obj.slug,
},
)
return self._absolute_url(path)


class TeamSerializer(serializers.ModelSerializer):

# TODO: add ``projects`` as flex field when we have a
# /organizations/<slug>/teams/<slug>/projects endpoint

created = serializers.DateTimeField(source='pub_date')
modified = serializers.DateTimeField(source='modified_date')
humitos marked this conversation as resolved.
Show resolved Hide resolved

class Meta:
model = Team
fields = (
'name',
'slug',
'created',
'modified',
'access',
)


class OrganizationSerializer(FlexFieldsModelSerializer):

created = serializers.DateTimeField(source='pub_date')
modified = serializers.DateTimeField(source='modified_date')
owners = UserSerializer(many=True)

_links = OrganizationLinksSerializer(source='*')

class Meta:
model = Organization
fields = (
'name',
'description',
'url',
'slug',
'email',
'owners',
'created',
'modified',
'disabled',
'_links',
)

expandable_fields = {
'projects': (ProjectSerializer, {'many': True}),
'teams': (TeamSerializer, {'many': True}),
}
5 changes: 3 additions & 2 deletions readthedocs/api/v3/tests/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,9 @@ def _create_subproject(self):
)
self.project_relationship = self.project.add_subproject(self.subproject)

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

def assertDictEqual(self, d1, d2):
Expand Down
Loading