Skip to content

APIv3 CRUD for Redirect objects #5879

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

Merged
merged 10 commits into from
Jul 9, 2019
12 changes: 11 additions & 1 deletion readthedocs/api/v3/permissions.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from rest_framework.permissions import IsAuthenticated
from rest_framework.permissions import IsAuthenticated, BasePermission


class PublicDetailPrivateListing(IsAuthenticated):
Expand Down Expand Up @@ -29,3 +29,13 @@ def has_permission(self, request, view):
return True

return False


class IsProjectAdmin(BasePermission):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This permission seems to be generating something like 6 extra queries which was surprising. However, it is not generating N queries where N is the number of redirects.

Copy link
Member Author

@humitos humitos Jul 3, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, this is the basic permission check that we need in all the endpoints for user admin of a project. I split it here to make it simpler and have it isolated.

So, if this check is slow, we should take care of it since it's used a on most of the endpoints.

Basically, it does:

  • get a Project (from slug in the URL)
  • performs a Project.objects.filter(users__in=[user])

I think we can use .only('id') in ProjectQuerySetMixin.has_admin_permission in the if, to avoid bringing the full objects since we only want to make a simple check.

In my very simple local tests, there is a timing difference. I'll add a commit for this.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, some of these method are called more than once (at least for the BrowsableAPIRenderer) and returned value won't change in the same request, so we could do something there if we care enough.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall it probably isn't a big deal either way. Some number of queries is necessary for the permission check but 6 just seemed high. I do wonder if we shouldn't just cache the project object onto the view object since most of our views relate to projects.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can optimize it later if needed --I think it's a good option. Although, I don't want to introduce a "buggy cached object" at this point before knowing that everything is working as we expect :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm ok with that for now. Let's keep it in mind though.


"""Grant permission if user has admin rights on the Project."""

def has_permission(self, request, view):
project = view._get_parent_project()
if view.has_admin_permission(request.user, project):
return True
71 changes: 71 additions & 0 deletions readthedocs/api/v3/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from readthedocs.builds.models import Build, Version
from readthedocs.projects.constants import LANGUAGES, PROGRAMMING_LANGUAGES
from readthedocs.projects.models import Project
from readthedocs.redirects.models import Redirect


class UserSerializer(FlexFieldsModelSerializer):
Expand Down Expand Up @@ -321,6 +322,7 @@ class ProjectLinksSerializer(BaseLinksSerializer):

versions = serializers.SerializerMethodField()
builds = serializers.SerializerMethodField()
redirects = serializers.SerializerMethodField()
subprojects = serializers.SerializerMethodField()
superproject = serializers.SerializerMethodField()
translations = serializers.SerializerMethodField()
Expand All @@ -338,6 +340,15 @@ def get_versions(self, obj):
)
return self._absolute_url(path)

def get_redirects(self, obj):
path = reverse(
'projects-redirects-list',
kwargs={
'parent_lookup_project__slug': obj.slug,
},
)
return self._absolute_url(path)

def get_builds(self, obj):
path = reverse(
'projects-builds-list',
Expand Down Expand Up @@ -451,3 +462,63 @@ def get_subproject_of(self, obj):
return self.__class__(obj.superprojects.first().parent).data
except Exception:
return None


class RedirectLinksSerializer(BaseLinksSerializer):
_self = serializers.SerializerMethodField()
project = serializers.SerializerMethodField()

def get__self(self, obj):
path = reverse(
'projects-redirects-detail',
kwargs={
'parent_lookup_project__slug': obj.project.slug,
'redirect_pk': obj.pk,
},
)
return self._absolute_url(path)

def get_project(self, obj):
path = reverse(
'projects-detail',
kwargs={
'project_slug': obj.project.slug,
},
)
return self._absolute_url(path)


class RedirectSerializerBase(serializers.ModelSerializer):

project = serializers.SlugRelatedField(slug_field='slug', read_only=True)
_links = RedirectLinksSerializer(source='*', read_only=True)

class Meta:
model = Redirect
fields = [
'pk',
'project',
'redirect_type',
'from_url',
'to_url',
'_links',
]


class RedirectCreateSerializer(RedirectSerializerBase):
pass


class RedirectDetailSerializer(RedirectSerializerBase):
"""Override RedirectSerializerBase to sanitize the empty fields."""

from_url = serializers.SerializerMethodField()
to_url = serializers.SerializerMethodField()

def get_from_url(self, obj):
# Overridden only to return ``None`` when the description is ``''``
return obj.from_url or None

def get_to_url(self, obj):
# Overridden only to return ``None`` when the description is ``''``
return obj.to_url or None
10 changes: 10 additions & 0 deletions readthedocs/api/v3/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
BuildsCreateViewSet,
BuildsViewSet,
ProjectsViewSet,
RedirectsViewSet,
SubprojectRelationshipViewSet,
TranslationRelationshipViewSet,
VersionsViewSet,
Expand Down Expand Up @@ -66,5 +67,14 @@
parents_query_lookups=['project__slug'],
)

# allows /api/v3/projects/pip/redirects/
# allows /api/v3/projects/pip/redirects/1053/
projects.register(
r'redirects',
RedirectsViewSet,
basename='projects-redirects',
parents_query_lookups=['project__slug'],
)

urlpatterns = []
urlpatterns += router.urls
29 changes: 27 additions & 2 deletions readthedocs/api/v3/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,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.viewsets import GenericViewSet, ReadOnlyModelViewSet, ModelViewSet
from rest_framework_extensions.mixins import NestedViewSetMixin

from readthedocs.builds.models import Build, Version
from readthedocs.core.utils import trigger_build
from readthedocs.projects.models import Project
from readthedocs.redirects.models import Redirect

from .filters import BuildFilter, ProjectFilter, VersionFilter
from .mixins import ProjectQuerySetMixin
from .permissions import PublicDetailPrivateListing
from .permissions import PublicDetailPrivateListing, IsProjectAdmin
from .renderers import AlphabeticalSortedJSONRenderer
from .serializers import (
BuildCreateSerializer,
BuildSerializer,
ProjectSerializer,
RedirectCreateSerializer,
RedirectDetailSerializer,
VersionSerializer,
VersionUpdateSerializer,
)
Expand Down Expand Up @@ -304,3 +308,24 @@ def create(self, request, **kwargs): # pylint: disable=arguments-differ
data.update({'triggered': False})
status = 400
return Response(data=data, status=status)


class RedirectsViewSet(APIv3Settings, NestedViewSetMixin, ProjectQuerySetMixin,
FlexFieldsMixin, ModelViewSet):
model = Redirect
lookup_field = 'pk'
lookup_url_kwarg = 'redirect_pk'
queryset = Redirect.objects.all()
permission_classes = (IsAuthenticated & IsProjectAdmin,)

def get_serializer_class(self):
if self.action in ('create', 'update', 'partial_update'):
return RedirectCreateSerializer
return RedirectDetailSerializer

def perform_create(self, serializer):
# Inject the project from the URL into the serializer
serializer.validated_data.update({
'project': self._get_parent_project(),
})
serializer.save()
23 changes: 0 additions & 23 deletions readthedocs/redirects/managers.py

This file was deleted.

4 changes: 2 additions & 2 deletions readthedocs/redirects/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from readthedocs.core.resolver import resolve_path
from readthedocs.projects.models import Project

from .managers import RedirectManager
from .querysets import RedirectQuerySet


log = logging.getLogger(__name__)
Expand Down Expand Up @@ -94,7 +94,7 @@ class Redirect(models.Model):
create_dt = models.DateTimeField(auto_now_add=True)
update_dt = models.DateTimeField(auto_now=True)

objects = RedirectManager()
objects = RedirectQuerySet.as_manager()

class Meta:
verbose_name = _('redirect')
Expand Down
41 changes: 41 additions & 0 deletions readthedocs/redirects/querysets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Queryset for the redirects app."""

from django.db import models

from readthedocs.core.utils.extend import SettingsOverrideObject
from readthedocs.projects import constants


class RedirectQuerySetBase(models.QuerySet):

"""Redirects take into account their own privacy_level setting."""

use_for_related_fields = True

def _add_user_repos(self, queryset, user):
if user.is_authenticated:
projects_pk = user.projects.all().values_list('pk', flat=True)
user_queryset = self.filter(project__in=projects_pk)
queryset = user_queryset | queryset
return queryset.distinct()

def api(self, user=None, detail=True):
queryset = self.none()
if user:
queryset = self._add_user_repos(queryset, user)
return queryset

def get_redirect_path_with_status(self, path, language=None, version_slug=None):
for redirect in self.select_related('project'):
new_path = redirect.get_redirect_path(
path=path,
language=language,
version_slug=version_slug,
)
if new_path:
return new_path, redirect.http_status
return (None, None)


class RedirectQuerySet(SettingsOverrideObject):
_default_class = RedirectQuerySetBase