diff --git a/readthedocs/api/v3/mixins.py b/readthedocs/api/v3/mixins.py index 66309219193..e0d500c4b7b 100644 --- a/readthedocs/api/v3/mixins.py +++ b/readthedocs/api/v3/mixins.py @@ -1,5 +1,4 @@ from django.shortcuts import get_object_or_404 -from rest_framework.exceptions import NotFound from readthedocs.builds.models import Version from readthedocs.projects.models import Project @@ -62,7 +61,10 @@ def listing_objects(self, queryset, user): return queryset.none() def has_admin_permission(self, user, project): - if project in self.admin_projects(user): + # Use .only for small optimization + admin_projects = self.admin_projects(user).only('id') + + if project in admin_projects: return True return False diff --git a/readthedocs/api/v3/permissions.py b/readthedocs/api/v3/permissions.py index d822b9a1fea..c067c1f9422 100644 --- a/readthedocs/api/v3/permissions.py +++ b/readthedocs/api/v3/permissions.py @@ -1,4 +1,4 @@ -from rest_framework.permissions import IsAuthenticated +from rest_framework.permissions import IsAuthenticated, BasePermission class PublicDetailPrivateListing(IsAuthenticated): @@ -29,3 +29,13 @@ def has_permission(self, request, view): return True return False + + +class IsProjectAdmin(BasePermission): + + """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 diff --git a/readthedocs/api/v3/serializers.py b/readthedocs/api/v3/serializers.py index d0efcf066c1..f820449091d 100644 --- a/readthedocs/api/v3/serializers.py +++ b/readthedocs/api/v3/serializers.py @@ -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, TYPE_CHOICES as REDIRECT_TYPE_CHOICES class UserSerializer(FlexFieldsModelSerializer): @@ -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() @@ -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', @@ -451,3 +462,70 @@ 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) + created = serializers.DateTimeField(source='create_dt', read_only=True) + modified = serializers.DateTimeField(source='update_dt', read_only=True) + _links = RedirectLinksSerializer(source='*', read_only=True) + + type = serializers.ChoiceField(source='redirect_type', choices=REDIRECT_TYPE_CHOICES) + + class Meta: + model = Redirect + fields = [ + 'pk', + 'created', + 'modified', + 'project', + '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 diff --git a/readthedocs/api/v3/tests/responses/projects-detail.json b/readthedocs/api/v3/tests/responses/projects-detail.json index b0c98fe33e8..25e7b775e05 100644 --- a/readthedocs/api/v3/tests/responses/projects-detail.json +++ b/readthedocs/api/v3/tests/responses/projects-detail.json @@ -60,6 +60,7 @@ "_links": { "_self": "https://readthedocs.org/api/v3/projects/project/", "builds": "https://readthedocs.org/api/v3/projects/project/builds/", + "redirects": "https://readthedocs.org/api/v3/projects/project/redirects/", "subprojects": "https://readthedocs.org/api/v3/projects/project/subprojects/", "superproject": "https://readthedocs.org/api/v3/projects/project/superproject/", "translations": "https://readthedocs.org/api/v3/projects/project/translations/", diff --git a/readthedocs/api/v3/tests/responses/projects-list.json b/readthedocs/api/v3/tests/responses/projects-list.json index bc08292f9f5..20f001fc8e5 100644 --- a/readthedocs/api/v3/tests/responses/projects-list.json +++ b/readthedocs/api/v3/tests/responses/projects-list.json @@ -49,6 +49,7 @@ "_self": "https://readthedocs.org/api/v3/projects/project/", "versions": "https://readthedocs.org/api/v3/projects/project/versions/", "builds": "https://readthedocs.org/api/v3/projects/project/builds/", + "redirects": "https://readthedocs.org/api/v3/projects/project/redirects/", "subprojects": "https://readthedocs.org/api/v3/projects/project/subprojects/", "superproject": "https://readthedocs.org/api/v3/projects/project/superproject/", "translations": "https://readthedocs.org/api/v3/projects/project/translations/" diff --git a/readthedocs/api/v3/tests/responses/projects-redirects-detail.json b/readthedocs/api/v3/tests/responses/projects-redirects-detail.json new file mode 100644 index 00000000000..87992a9840d --- /dev/null +++ b/readthedocs/api/v3/tests/responses/projects-redirects-detail.json @@ -0,0 +1,13 @@ +{ + "_links": { + "_self": "https://readthedocs.org/api/v3/projects/project/redirects/1/", + "project": "https://readthedocs.org/api/v3/projects/project/" + }, + "created": "2019-04-29T10:00:00Z", + "modified": "2019-04-29T12:00:00Z", + "from_url": "/docs/", + "pk": 1, + "project": "project", + "type": "page", + "to_url": "/documentation/" +} diff --git a/readthedocs/api/v3/tests/responses/projects-redirects-detail_PUT.json b/readthedocs/api/v3/tests/responses/projects-redirects-detail_PUT.json new file mode 100644 index 00000000000..eab7e6a9e6a --- /dev/null +++ b/readthedocs/api/v3/tests/responses/projects-redirects-detail_PUT.json @@ -0,0 +1,13 @@ +{ + "_links": { + "_self": "https://readthedocs.org/api/v3/projects/project/redirects/1/", + "project": "https://readthedocs.org/api/v3/projects/project/" + }, + "created": "2019-04-29T10:00:00Z", + "modified": "2019-04-29T12:00:00Z", + "from_url": "/changed/", + "pk": 1, + "project": "project", + "type": "page", + "to_url": "/toanother/" +} diff --git a/readthedocs/api/v3/tests/responses/projects-redirects-list.json b/readthedocs/api/v3/tests/responses/projects-redirects-list.json new file mode 100644 index 00000000000..ce8ca8ea42a --- /dev/null +++ b/readthedocs/api/v3/tests/responses/projects-redirects-list.json @@ -0,0 +1,20 @@ +{ + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "_links": { + "_self": "https://readthedocs.org/api/v3/projects/project/redirects/1/", + "project": "https://readthedocs.org/api/v3/projects/project/" + }, + "created": "2019-04-29T10:00:00Z", + "modified": "2019-04-29T12:00:00Z", + "from_url": "/docs/", + "pk": 1, + "project": "project", + "type": "page", + "to_url": "/documentation/" + } + ] +} diff --git a/readthedocs/api/v3/tests/responses/projects-redirects-list_POST.json b/readthedocs/api/v3/tests/responses/projects-redirects-list_POST.json new file mode 100644 index 00000000000..90285fb9406 --- /dev/null +++ b/readthedocs/api/v3/tests/responses/projects-redirects-list_POST.json @@ -0,0 +1,13 @@ +{ + "_links": { + "_self": "https://readthedocs.org/api/v3/projects/project/redirects/2/", + "project": "https://readthedocs.org/api/v3/projects/project/" + }, + "created": "2019-04-29T10:00:00Z", + "modified": "2019-04-29T12:00:00Z", + "from_url": "/page/", + "pk": 2, + "project": "project", + "type": "page", + "to_url": "/another/" +} diff --git a/readthedocs/api/v3/tests/responses/projects-subprojects-list.json b/readthedocs/api/v3/tests/responses/projects-subprojects-list.json index 2c1cbc50a9b..806fdbf2d0c 100644 --- a/readthedocs/api/v3/tests/responses/projects-subprojects-list.json +++ b/readthedocs/api/v3/tests/responses/projects-subprojects-list.json @@ -68,6 +68,7 @@ "_self": "https://readthedocs.org/api/v3/projects/project/", "versions": "https://readthedocs.org/api/v3/projects/project/versions/", "builds": "https://readthedocs.org/api/v3/projects/project/builds/", + "redirects": "https://readthedocs.org/api/v3/projects/project/redirects/", "subprojects": "https://readthedocs.org/api/v3/projects/project/subprojects/", "superproject": "https://readthedocs.org/api/v3/projects/project/superproject/", "translations": "https://readthedocs.org/api/v3/projects/project/translations/" @@ -90,6 +91,7 @@ "_self": "https://readthedocs.org/api/v3/projects/subproject/", "versions": "https://readthedocs.org/api/v3/projects/subproject/versions/", "builds": "https://readthedocs.org/api/v3/projects/subproject/builds/", + "redirects": "https://readthedocs.org/api/v3/projects/subproject/redirects/", "subprojects": "https://readthedocs.org/api/v3/projects/subproject/subprojects/", "superproject": "https://readthedocs.org/api/v3/projects/subproject/superproject/", "translations": "https://readthedocs.org/api/v3/projects/subproject/translations/" diff --git a/readthedocs/api/v3/tests/responses/projects-superproject.json b/readthedocs/api/v3/tests/responses/projects-superproject.json index abe0c5de862..293ac76a07d 100644 --- a/readthedocs/api/v3/tests/responses/projects-superproject.json +++ b/readthedocs/api/v3/tests/responses/projects-superproject.json @@ -11,6 +11,7 @@ "_links": { "_self": "https://readthedocs.org/api/v3/projects/project/", "builds": "https://readthedocs.org/api/v3/projects/project/builds/", + "redirects": "https://readthedocs.org/api/v3/projects/project/redirects/", "subprojects": "https://readthedocs.org/api/v3/projects/project/subprojects/", "superproject": "https://readthedocs.org/api/v3/projects/project/superproject/", "translations": "https://readthedocs.org/api/v3/projects/project/translations/", diff --git a/readthedocs/api/v3/tests/responses/projects-versions-builds-list_POST.json b/readthedocs/api/v3/tests/responses/projects-versions-builds-list_POST.json index c5c65075ae0..5375b95f12b 100644 --- a/readthedocs/api/v3/tests/responses/projects-versions-builds-list_POST.json +++ b/readthedocs/api/v3/tests/responses/projects-versions-builds-list_POST.json @@ -32,6 +32,7 @@ "_links": { "_self": "https://readthedocs.org/api/v3/projects/project/", "builds": "https://readthedocs.org/api/v3/projects/project/builds/", + "redirects": "https://readthedocs.org/api/v3/projects/project/redirects/", "subprojects": "https://readthedocs.org/api/v3/projects/project/subprojects/", "superproject": "https://readthedocs.org/api/v3/projects/project/superproject/", "translations": "https://readthedocs.org/api/v3/projects/project/translations/", diff --git a/readthedocs/api/v3/tests/test_projects.py b/readthedocs/api/v3/tests/test_projects.py index 797f31852ab..8bf4b6cd23c 100644 --- a/readthedocs/api/v3/tests/test_projects.py +++ b/readthedocs/api/v3/tests/test_projects.py @@ -13,6 +13,7 @@ from readthedocs.builds.models import Build, Version from readthedocs.projects.models import Project +from readthedocs.redirects.models import Redirect class APIEndpointTests(TestCase): @@ -49,6 +50,16 @@ def setUp(self): for tag in ('tag', 'project', 'test'): self.project.tags.add(tag) + self.redirect = fixture.get( + Redirect, + create_dt=created, + update_dt=modified, + from_url='/docs/', + to_url='/documentation/', + redirect_type='page', + project=self.project, + ) + self.subproject = fixture.get( Project, pub_date=created, @@ -372,3 +383,149 @@ def test_projects_versions_detail_unique(self): ) self.assertEqual(response.status_code, 200) + + def test_unauthed_projects_redirects_list(self): + response = self.client.get( + reverse( + 'projects-redirects-list', + kwargs={ + 'parent_lookup_project__slug': self.project.slug, + }), + ) + self.assertEqual(response.status_code, 401) + + def test_projects_redirects_list(self): + self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}') + response = self.client.get( + reverse( + 'projects-redirects-list', + kwargs={ + 'parent_lookup_project__slug': self.project.slug, + }), + ) + self.assertEqual(response.status_code, 200) + + response_json = response.json() + self.assertDictEqual( + response_json, + self._get_response_dict('projects-redirects-list'), + ) + + def test_unauthed_projects_redirects_detail(self): + response = self.client.get( + reverse( + 'projects-redirects-detail', + kwargs={ + 'parent_lookup_project__slug': self.project.slug, + 'redirect_pk': self.redirect.pk, + }), + ) + self.assertEqual(response.status_code, 401) + + def test_projects_redirects_detail(self): + self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}') + response = self.client.get( + reverse( + 'projects-redirects-detail', + kwargs={ + 'parent_lookup_project__slug': self.project.slug, + 'redirect_pk': self.redirect.pk, + }), + ) + self.assertEqual(response.status_code, 200) + + response_json = response.json() + self.assertDictEqual( + response_json, + self._get_response_dict('projects-redirects-detail'), + ) + + def test_unauthed_projects_redirects_list_post(self): + data = {} + + response = self.client.post( + reverse( + 'projects-redirects-list', + kwargs={ + 'parent_lookup_project__slug': self.others_project.slug, + }), + data, + ) + self.assertEqual(response.status_code, 401) + + self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}') + response = self.client.post( + reverse( + 'projects-redirects-list', + kwargs={ + 'parent_lookup_project__slug': self.others_project.slug, + }), + data, + ) + self.assertEqual(response.status_code, 403) + + def test_projects_redirects_list_post(self): + data = { + 'from_url': '/page/', + 'to_url': '/another/', + 'type': 'page', + } + + self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}') + response = self.client.post( + reverse( + 'projects-redirects-list', + kwargs={ + 'parent_lookup_project__slug': self.project.slug, + }), + data, + ) + self.assertEqual(response.status_code, 201) + + response_json = response.json() + response_json['created'] = '2019-04-29T10:00:00Z' + response_json['modified'] = '2019-04-29T12:00:00Z' + self.assertDictEqual( + response_json, + self._get_response_dict('projects-redirects-list_POST'), + ) + + def test_projects_redirects_detail_put(self): + data = { + 'from_url': '/changed/', + 'to_url': '/toanother/', + 'type': 'page', + } + + self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}') + response = self.client.put( + reverse( + 'projects-redirects-detail', + kwargs={ + 'parent_lookup_project__slug': self.project.slug, + 'redirect_pk': self.redirect.pk, + }), + data, + ) + self.assertEqual(response.status_code, 200) + + response_json = response.json() + response_json['modified'] = '2019-04-29T12:00:00Z' + self.assertDictEqual( + response_json, + self._get_response_dict('projects-redirects-detail_PUT'), + ) + + def test_projects_redirects_detail_delete(self): + self.assertEqual(self.project.redirects.count(), 1) + self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}') + response = self.client.delete( + reverse( + 'projects-redirects-detail', + kwargs={ + 'parent_lookup_project__slug': self.project.slug, + 'redirect_pk': self.redirect.pk, + }), + ) + self.assertEqual(response.status_code, 204) + self.assertEqual(self.project.redirects.count(), 0) diff --git a/readthedocs/api/v3/urls.py b/readthedocs/api/v3/urls.py index 55d3ada2881..74c163d206a 100644 --- a/readthedocs/api/v3/urls.py +++ b/readthedocs/api/v3/urls.py @@ -3,6 +3,7 @@ BuildsCreateViewSet, BuildsViewSet, ProjectsViewSet, + RedirectsViewSet, SubprojectRelationshipViewSet, TranslationRelationshipViewSet, VersionsViewSet, @@ -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 diff --git a/readthedocs/api/v3/views.py b/readthedocs/api/v3/views.py index 0e8b2b55a0e..5958c81c737 100644 --- a/readthedocs/api/v3/views.py +++ b/readthedocs/api/v3/views.py @@ -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, ) @@ -304,3 +308,28 @@ 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_queryset(self): + queryset = super().get_queryset() + return queryset.select_related('project') + + 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() diff --git a/readthedocs/redirects/managers.py b/readthedocs/redirects/managers.py deleted file mode 100644 index ef4a9946e71..00000000000 --- a/readthedocs/redirects/managers.py +++ /dev/null @@ -1,23 +0,0 @@ -# -*- coding: utf-8 -*- - -"""Manager and queryset for the redirects app.""" - -from django.db.models import Manager -from django.db.models.query import QuerySet - - -class RedirectQuerySet(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) - - -RedirectManager = Manager.from_queryset(RedirectQuerySet) diff --git a/readthedocs/redirects/models.py b/readthedocs/redirects/models.py index 72748717070..bd0673b733e 100644 --- a/readthedocs/redirects/models.py +++ b/readthedocs/redirects/models.py @@ -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__) @@ -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') diff --git a/readthedocs/redirects/querysets.py b/readthedocs/redirects/querysets.py new file mode 100644 index 00000000000..155ec4762fb --- /dev/null +++ b/readthedocs/redirects/querysets.py @@ -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