From b8767cf60bb4f565760b7326f619e743628fa3e0 Mon Sep 17 00:00:00 2001 From: Manuel Kaufmann Date: Mon, 22 Jan 2024 17:21:38 +0100 Subject: [PATCH] APIv3: endpoints for notifications (#11009) * Initial work for notifications API endpoints * Implement missing endpoints - `/api/v3/organizations//notifications/` - `/api/v3/organizations//notifications//` - `/api/v3/users//notifications/` - `/api/v3/users//notifications//` Note that only notifications endpoints are available for `users` and `organizations` currently. So, the following endpoints return 404 on purpose: - `/api/v3/organizations/` - `/api/v3/organizations//` - `/api/v3/users/` - `/api/v3/users//` We can implement them when we are ready, but the pattern and structure of URLs is already prepared to support them. * Lint * Remove outdated comments * Refactor "notifications for user" as QuerySet This logic will be re-used when rendering templates. * Comments * Use `Subquery` to improve the db query * APIv3: lot of tests for the new endpoints * APIv3: update tests to make them pass * APIv3: update permissions on notification endpoints and add tests * APIv3: remove old comment code * APIv3: update `AdminPermission` to match what's expected * Wrong attribute --- readthedocs/api/v3/filters.py | 9 + readthedocs/api/v3/mixins.py | 40 +++- readthedocs/api/v3/permissions.py | 30 ++- readthedocs/api/v3/serializers.py | 81 +++++-- readthedocs/api/v3/tests/mixins.py | 46 ++++ .../tests/responses/notifications-list.json | 61 +++++ .../organizations-notifications-detail.json | 18 ++ .../organizations-notifications-list.json | 25 ++ .../projects-builds-notifications-detail.json | 18 ++ .../projects-builds-notifications-list.json | 25 ++ .../projects-notifications-detail.json | 18 ++ .../projects-notifications-list.json | 25 ++ .../responses/users-notifications-detail.json | 18 ++ .../responses/users-notifications-list.json | 25 ++ readthedocs/api/v3/tests/test_builds.py | 112 +++++++++ .../api/v3/tests/test_notifications.py | 44 ++++ .../api/v3/tests/test_organizations.py | 139 +++++++++++ readthedocs/api/v3/tests/test_projects.py | 107 +++++++++ readthedocs/api/v3/tests/test_subprojects.py | 2 + readthedocs/api/v3/tests/test_users.py | 148 ++++++++++++ readthedocs/api/v3/urls.py | 65 ++++- readthedocs/api/v3/views.py | 225 ++++++++++++------ readthedocs/core/permissions.py | 16 ++ readthedocs/notifications/querysets.py | 45 ++++ readthedocs/organizations/querysets.py | 2 +- 25 files changed, 1236 insertions(+), 108 deletions(-) create mode 100644 readthedocs/api/v3/tests/responses/notifications-list.json create mode 100644 readthedocs/api/v3/tests/responses/organizations-notifications-detail.json create mode 100644 readthedocs/api/v3/tests/responses/organizations-notifications-list.json create mode 100644 readthedocs/api/v3/tests/responses/projects-builds-notifications-detail.json create mode 100644 readthedocs/api/v3/tests/responses/projects-builds-notifications-list.json create mode 100644 readthedocs/api/v3/tests/responses/projects-notifications-detail.json create mode 100644 readthedocs/api/v3/tests/responses/projects-notifications-list.json create mode 100644 readthedocs/api/v3/tests/responses/users-notifications-detail.json create mode 100644 readthedocs/api/v3/tests/responses/users-notifications-list.json create mode 100644 readthedocs/api/v3/tests/test_notifications.py create mode 100644 readthedocs/api/v3/tests/test_organizations.py create mode 100644 readthedocs/api/v3/tests/test_users.py diff --git a/readthedocs/api/v3/filters.py b/readthedocs/api/v3/filters.py index ab5cd58a102..f9f06ba735d 100644 --- a/readthedocs/api/v3/filters.py +++ b/readthedocs/api/v3/filters.py @@ -2,6 +2,7 @@ from readthedocs.builds.constants import BUILD_FINAL_STATES from readthedocs.builds.models import Build, Version +from readthedocs.notifications.models import Notification from readthedocs.oauth.models import RemoteOrganization, RemoteRepository from readthedocs.projects.models import Project @@ -58,6 +59,14 @@ def get_running(self, queryset, name, value): return queryset.filter(state__in=BUILD_FINAL_STATES) +class NotificationFilter(filters.FilterSet): + class Meta: + model = Notification + fields = [ + "state", + ] + + class RemoteRepositoryFilter(filters.FilterSet): name = filters.CharFilter(field_name="name", lookup_expr="icontains") full_name = filters.CharFilter(field_name="full_name", lookup_expr="icontains") diff --git a/readthedocs/api/v3/mixins.py b/readthedocs/api/v3/mixins.py index 5328d43a876..02be261b1e9 100644 --- a/readthedocs/api/v3/mixins.py +++ b/readthedocs/api/v3/mixins.py @@ -1,8 +1,9 @@ +from django.contrib.auth.models import User from django.shortcuts import get_object_or_404 from rest_framework import status from rest_framework.response import Response -from readthedocs.builds.models import Version +from readthedocs.builds.models import Build, Version from readthedocs.core.history import safe_update_change_reason, set_change_reason from readthedocs.organizations.models import Organization from readthedocs.projects.models import Project @@ -67,6 +68,14 @@ class NestedParentObjectMixin: "organizations__slug", ] + BUILD_LOOKUP_NAMES = [ + "build__id", + ] + + USER_LOOKUP_NAMES = [ + "user__username", + ] + def _get_parent_object_lookup(self, lookup_names): query_dict = self.get_parents_query_dict() for lookup in lookup_names: @@ -84,6 +93,10 @@ def _get_parent_project(self): return get_object_or_404(Project, slug=slug) + def _get_parent_build(self): + pk = self._get_parent_object_lookup(self.BUILD_LOOKUP_NAMES) + return get_object_or_404(Build, pk=pk) + def _get_parent_version(self): project_slug = self._get_parent_object_lookup(self.PROJECT_LOOKUP_NAMES) slug = self._get_parent_object_lookup(self.VERSION_LOOKUP_NAMES) @@ -106,6 +119,15 @@ def _get_parent_organization(self): slug=slug, ) + def _get_parent_user(self): + username = self._get_parent_object_lookup(self.USER_LOOKUP_NAMES) + username = username or self.kwargs.get("user_username") + + return get_object_or_404( + User, + username=username, + ) + class ProjectQuerySetMixin(NestedParentObjectMixin): @@ -219,6 +241,22 @@ def get_queryset(self): return self.listing_objects(queryset, self.request.user) +class UserQuerySetMixin(NestedParentObjectMixin): + + """ + Mixin to define queryset permissions for ViewSet only in one place. + + All APIv3 user' ViewSet should inherit this mixin, unless specific permissions + required. In that case, a specific mixin for that case should be defined. + """ + + def has_admin_permission(self, requesting_user, accessing_user): + if requesting_user == accessing_user: + return True + + return False + + class UpdateMixin: """Make PUT to return 204 on success like PATCH does.""" diff --git a/readthedocs/api/v3/permissions.py b/readthedocs/api/v3/permissions.py index 0d2b7e8ee5c..77d7b26afea 100644 --- a/readthedocs/api/v3/permissions.py +++ b/readthedocs/api/v3/permissions.py @@ -50,6 +50,8 @@ class PublicDetailPrivateListing(BasePermission): * Always give permission for a ``detail`` request * Only give permission for ``listing`` request if user is admin of the project + + However, for notification endpoints we only allow users with access to the project. """ def has_permission(self, request, view): @@ -61,11 +63,29 @@ def has_permission(self, request, view): 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 + if view.basename not in ( + "projects-notifications", + "projects-builds-notifications", + ): + # We don't want to give detail access to resources' + # notifications to users that don't have access to those + # resources. + return True + + if view.basename.startswith("projects"): + project = view._get_parent_project() + if view.has_admin_permission(request.user, project): + return True + + if view.basename.startswith("organizations"): + organization = view._get_parent_organization() + if view.has_admin_permission(request.user, organization): + return True + + if view.basename.startswith("users"): + user = view._get_parent_user() + if view.has_admin_permission(request.user, user): + return True return False diff --git a/readthedocs/api/v3/serializers.py b/readthedocs/api/v3/serializers.py index ffa742a870b..b9f02b6c2d8 100644 --- a/readthedocs/api/v3/serializers.py +++ b/readthedocs/api/v3/serializers.py @@ -15,6 +15,7 @@ from readthedocs.core.resolver import Resolver from readthedocs.core.utils import slugify from readthedocs.core.utils.extend import SettingsOverrideObject +from readthedocs.notifications.messages import registry from readthedocs.notifications.models import Notification from readthedocs.oauth.models import RemoteOrganization, RemoteRepository from readthedocs.organizations.models import Organization, Team @@ -61,26 +62,55 @@ class Meta: fields = [] -# TODO: decide whether or not include a `_links` field on the object -# -# This also includes adding `/api/v3/notifications/` endpoint, -# which I'm not sure it's useful at this point. -# -# class NotificationLinksSerializer(BaseLinksSerializer): -# _self = serializers.SerializerMethodField() -# attached_to = serializers.SerializerMethodField() +class NotificationLinksSerializer(BaseLinksSerializer): + _self = serializers.SerializerMethodField() -# def get__self(self, obj): -# path = reverse( -# "notifications-detail", -# kwargs={ -# "pk": obj.pk, -# }, -# ) -# return self._absolute_url(path) + def get__self(self, obj): + content_type_name = obj.attached_to_content_type.name + if content_type_name == "user": + url = "users-notifications-detail" + path = reverse( + url, + kwargs={ + "notification_pk": obj.pk, + "parent_lookup_user__username": obj.attached_to.username, + }, + ) -# def get_attached_to(self, obj): -# return None + elif content_type_name == "build": + url = "projects-builds-notifications-detail" + project_slug = obj.attached_to.project.slug + path = reverse( + url, + kwargs={ + "notification_pk": obj.pk, + "parent_lookup_project__slug": project_slug, + "parent_lookup_build__id": obj.attached_to_id, + }, + ) + + elif content_type_name == "project": + url = "projects-notifications-detail" + project_slug = obj.attached_to.slug + path = reverse( + url, + kwargs={ + "notification_pk": obj.pk, + "parent_lookup_project__slug": project_slug, + }, + ) + + elif content_type_name == "organization": + url = "organizations-notifications-detail" + path = reverse( + url, + kwargs={ + "notification_pk": obj.pk, + "parent_lookup_organization__slug": obj.attached_to.slug, + }, + ) + + return self._absolute_url(path) class BuildLinksSerializer(BaseLinksSerializer): @@ -122,7 +152,7 @@ def get_project(self, obj): def get_notifications(self, obj): path = reverse( - "project-builds-notifications-list", + "projects-builds-notifications-list", kwargs={ "parent_lookup_project__slug": obj.project.slug, "parent_lookup_build__id": obj.pk, @@ -242,6 +272,10 @@ class Meta: class NotificationCreateSerializer(serializers.ModelSerializer): + message_id = serializers.ChoiceField( + choices=sorted([(key, key) for key in registry.messages]) + ) + class Meta: model = Notification fields = [ @@ -253,11 +287,10 @@ class Meta: class NotificationSerializer(serializers.ModelSerializer): - message = NotificationMessageSerializer(source="get_message") + message = NotificationMessageSerializer(source="get_message", read_only=True) attached_to_content_type = serializers.SerializerMethodField() - # TODO: review these fields - # _links = BuildLinksSerializer(source="*") - # urls = BuildURLsSerializer(source="*") + _links = NotificationLinksSerializer(source="*", read_only=True) + attached_to_id = serializers.IntegerField(read_only=True) class Meta: model = Notification @@ -269,7 +302,9 @@ class Meta: "attached_to_content_type", "attached_to_id", "message", + "_links", ] + read_only_fields = ["dismissable", "news"] def get_attached_to_content_type(self, obj): return obj.attached_to_content_type.name diff --git a/readthedocs/api/v3/tests/mixins.py b/readthedocs/api/v3/tests/mixins.py index 3736cf30a32..88378171788 100644 --- a/readthedocs/api/v3/tests/mixins.py +++ b/readthedocs/api/v3/tests/mixins.py @@ -4,6 +4,7 @@ import django_dynamic_fixture as fixture from django.contrib.auth.models import User +from django.contrib.contenttypes.models import ContentType from django.core.cache import cache from django.test import TestCase from django.test.utils import override_settings @@ -13,9 +14,15 @@ from readthedocs.builds.constants import TAG from readthedocs.builds.models import Build, Version +from readthedocs.core.notifications import MESSAGE_EMAIL_VALIDATION_PENDING +from readthedocs.doc_builder.exceptions import BuildCancelled +from readthedocs.notifications.models import Notification +from readthedocs.organizations.models import Organization from readthedocs.projects.constants import PUBLIC from readthedocs.projects.models import Project +from readthedocs.projects.notifications import MESSAGE_PROJECT_SKIP_BUILDS from readthedocs.redirects.models import Redirect +from readthedocs.subscriptions.notifications import MESSAGE_ORGANIZATION_DISABLED @override_settings( @@ -109,6 +116,7 @@ def setUp(self): Project, id=2, slug="others-project", + name="others-project", related_projects=[], main_language_project=None, users=[self.other], @@ -124,6 +132,44 @@ def setUp(self): has_htmlzip=True, ) + self.organization = fixture.get( + Organization, + id=1, + pub_date=self.created, + modified_date=self.modified, + name="organization", + slug="organization", + owners=[self.me], + ) + self.organization.projects.add(self.project) + + self.notification_organization = fixture.get( + Notification, + attached_to_content_type=ContentType.objects.get_for_model( + self.organization + ), + attached_to_id=self.organization.pk, + message_id=MESSAGE_ORGANIZATION_DISABLED, + ) + self.notification_project = fixture.get( + Notification, + attached_to_content_type=ContentType.objects.get_for_model(self.project), + attached_to_id=self.project.pk, + message_id=MESSAGE_PROJECT_SKIP_BUILDS, + ) + self.notification_build = fixture.get( + Notification, + attached_to_content_type=ContentType.objects.get_for_model(self.build), + attached_to_id=self.build.pk, + message_id=BuildCancelled.CANCELLED_BY_USER, + ) + self.notification_user = fixture.get( + Notification, + attached_to_content_type=ContentType.objects.get_for_model(self.me), + attached_to_id=self.me.pk, + message_id=MESSAGE_EMAIL_VALIDATION_PENDING, + ) + self.client = APIClient() def tearDown(self): diff --git a/readthedocs/api/v3/tests/responses/notifications-list.json b/readthedocs/api/v3/tests/responses/notifications-list.json new file mode 100644 index 00000000000..4e977849d7d --- /dev/null +++ b/readthedocs/api/v3/tests/responses/notifications-list.json @@ -0,0 +1,61 @@ +{ + "count": 3, + "next": null, + "previous": null, + "results": [ + { + "_links": { + "_self": "https://readthedocs.org/api/v3/users/testuser/notifications/4/" + }, + "attached_to_content_type": "user", + "attached_to_id": 1, + "dismissable": false, + "id": 4, + "message": { + "body": "Your primary email address is not verified.\nPlease verify it here.", + "header": "Email address not verified", + "icon_classes": "fas fa-circle-exclamation", + "id": "core:email:validation-pending", + "type": "warning" + }, + "news": false, + "state": "unread" + }, + { + "_links": { + "_self": "https://readthedocs.org/api/v3/projects/project/notifications/2/" + }, + "attached_to_content_type": "project", + "attached_to_id": 1, + "dismissable": false, + "id": 2, + "message": { + "body": "Your project is currently disabled for abuse of the system.\nPlease make sure it isn't using unreasonable amounts of resources or triggering lots of builds in a short amount of time.\nPlease contact support to get your project re-enabled.", + "header": "Build skipped for this project", + "icon_classes": "fas fa-circle-info", + "id": "project:invalid:skip-builds", + "type": "info" + }, + "news": false, + "state": "unread" + }, + { + "_links": { + "_self": "https://readthedocs.org/api/v3/organizations/organization/notifications/1/" + }, + "attached_to_content_type": "organization", + "attached_to_id": 1, + "dismissable": false, + "id": 1, + "message": { + "body": "The organization \"organization\" is currently disabled. You need to renew your subscription to keep using Read the Docs", + "header": "Your organization has been disabled", + "icon_classes": "fas fa-circle-info", + "id": "organization:disabled", + "type": "info" + }, + "news": false, + "state": "unread" + } + ] +} diff --git a/readthedocs/api/v3/tests/responses/organizations-notifications-detail.json b/readthedocs/api/v3/tests/responses/organizations-notifications-detail.json new file mode 100644 index 00000000000..a05309a3111 --- /dev/null +++ b/readthedocs/api/v3/tests/responses/organizations-notifications-detail.json @@ -0,0 +1,18 @@ +{ + "_links": { + "_self": "https://readthedocs.org/api/v3/organizations/organization/notifications/1/" + }, + "attached_to_content_type": "organization", + "attached_to_id": 1, + "dismissable": false, + "id": 1, + "message": { + "body": "The organization \"organization\" is currently disabled. You need to renew your subscription to keep using Read the Docs", + "header": "Your organization has been disabled", + "icon_classes": "fas fa-circle-info", + "id": "organization:disabled", + "type": "info" + }, + "news": false, + "state": "unread" +} diff --git a/readthedocs/api/v3/tests/responses/organizations-notifications-list.json b/readthedocs/api/v3/tests/responses/organizations-notifications-list.json new file mode 100644 index 00000000000..f0d7a2201c3 --- /dev/null +++ b/readthedocs/api/v3/tests/responses/organizations-notifications-list.json @@ -0,0 +1,25 @@ +{ + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "_links": { + "_self": "https://readthedocs.org/api/v3/organizations/organization/notifications/1/" + }, + "attached_to_content_type": "organization", + "attached_to_id": 1, + "dismissable": false, + "id": 1, + "message": { + "body": "The organization \"organization\" is currently disabled. You need to renew your subscription to keep using Read the Docs", + "header": "Your organization has been disabled", + "icon_classes": "fas fa-circle-info", + "id": "organization:disabled", + "type": "info" + }, + "news": false, + "state": "unread" + } + ] +} diff --git a/readthedocs/api/v3/tests/responses/projects-builds-notifications-detail.json b/readthedocs/api/v3/tests/responses/projects-builds-notifications-detail.json new file mode 100644 index 00000000000..2e0fc97cd3e --- /dev/null +++ b/readthedocs/api/v3/tests/responses/projects-builds-notifications-detail.json @@ -0,0 +1,18 @@ +{ + "_links": { + "_self": "https://readthedocs.org/api/v3/projects/project/builds/1/notifications/3/" + }, + "attached_to_content_type": "build", + "attached_to_id": 1, + "dismissable": false, + "id": 3, + "message": { + "body": "The user has cancelled this build.", + "header": "Build cancelled manually.", + "icon_classes": "fas fa-circle-xmark", + "id": "build:user:cancelled", + "type": "error" + }, + "news": false, + "state": "unread" +} diff --git a/readthedocs/api/v3/tests/responses/projects-builds-notifications-list.json b/readthedocs/api/v3/tests/responses/projects-builds-notifications-list.json new file mode 100644 index 00000000000..445e57bab18 --- /dev/null +++ b/readthedocs/api/v3/tests/responses/projects-builds-notifications-list.json @@ -0,0 +1,25 @@ +{ + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "_links": { + "_self": "https://readthedocs.org/api/v3/projects/project/builds/1/notifications/3/" + }, + "attached_to_content_type": "build", + "attached_to_id": 1, + "dismissable": false, + "id": 3, + "message": { + "body": "The user has cancelled this build.", + "header": "Build cancelled manually.", + "icon_classes": "fas fa-circle-xmark", + "id": "build:user:cancelled", + "type": "error" + }, + "news": false, + "state": "unread" + } + ] +} diff --git a/readthedocs/api/v3/tests/responses/projects-notifications-detail.json b/readthedocs/api/v3/tests/responses/projects-notifications-detail.json new file mode 100644 index 00000000000..d151310630e --- /dev/null +++ b/readthedocs/api/v3/tests/responses/projects-notifications-detail.json @@ -0,0 +1,18 @@ +{ + "_links": { + "_self": "https://readthedocs.org/api/v3/projects/project/notifications/2/" + }, + "attached_to_content_type": "project", + "attached_to_id": 1, + "dismissable": false, + "id": 2, + "message": { + "body": "Your project is currently disabled for abuse of the system.\nPlease make sure it isn't using unreasonable amounts of resources or triggering lots of builds in a short amount of time.\nPlease contact support to get your project re-enabled.", + "header": "Build skipped for this project", + "icon_classes": "fas fa-circle-info", + "id": "project:invalid:skip-builds", + "type": "info" + }, + "news": false, + "state": "unread" +} diff --git a/readthedocs/api/v3/tests/responses/projects-notifications-list.json b/readthedocs/api/v3/tests/responses/projects-notifications-list.json new file mode 100644 index 00000000000..0ac410e8712 --- /dev/null +++ b/readthedocs/api/v3/tests/responses/projects-notifications-list.json @@ -0,0 +1,25 @@ +{ + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "_links": { + "_self": "https://readthedocs.org/api/v3/projects/project/notifications/2/" + }, + "attached_to_content_type": "project", + "attached_to_id": 1, + "dismissable": false, + "id": 2, + "message": { + "body": "Your project is currently disabled for abuse of the system.\nPlease make sure it isn't using unreasonable amounts of resources or triggering lots of builds in a short amount of time.\nPlease contact support to get your project re-enabled.", + "header": "Build skipped for this project", + "icon_classes": "fas fa-circle-info", + "id": "project:invalid:skip-builds", + "type": "info" + }, + "news": false, + "state": "unread" + } + ] +} diff --git a/readthedocs/api/v3/tests/responses/users-notifications-detail.json b/readthedocs/api/v3/tests/responses/users-notifications-detail.json new file mode 100644 index 00000000000..565165fad99 --- /dev/null +++ b/readthedocs/api/v3/tests/responses/users-notifications-detail.json @@ -0,0 +1,18 @@ +{ + "_links": { + "_self": "https://readthedocs.org/api/v3/users/testuser/notifications/4/" + }, + "attached_to_content_type": "user", + "attached_to_id": 1, + "dismissable": false, + "id": 4, + "message": { + "body": "Your primary email address is not verified.\nPlease verify it here.", + "header": "Email address not verified", + "icon_classes": "fas fa-circle-exclamation", + "id": "core:email:validation-pending", + "type": "warning" + }, + "news": false, + "state": "unread" +} diff --git a/readthedocs/api/v3/tests/responses/users-notifications-list.json b/readthedocs/api/v3/tests/responses/users-notifications-list.json new file mode 100644 index 00000000000..23e46db5e7c --- /dev/null +++ b/readthedocs/api/v3/tests/responses/users-notifications-list.json @@ -0,0 +1,25 @@ +{ + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "_links": { + "_self": "https://readthedocs.org/api/v3/users/testuser/notifications/4/" + }, + "attached_to_content_type": "user", + "attached_to_id": 1, + "dismissable": false, + "id": 4, + "message": { + "body": "Your primary email address is not verified.\nPlease verify it here.", + "header": "Email address not verified", + "icon_classes": "fas fa-circle-exclamation", + "id": "core:email:validation-pending", + "type": "warning" + }, + "news": false, + "state": "unread" + } + ] +} diff --git a/readthedocs/api/v3/tests/test_builds.py b/readthedocs/api/v3/tests/test_builds.py index ee62dd7fc10..dcb5e783d47 100644 --- a/readthedocs/api/v3/tests/test_builds.py +++ b/readthedocs/api/v3/tests/test_builds.py @@ -81,3 +81,115 @@ def test_projects_versions_builds_list_post(self): response_json, self._get_response_dict("projects-versions-builds-list_POST"), ) + + def test_projects_builds_notifications_list(self): + url = reverse( + "projects-builds-notifications-list", + kwargs={ + "parent_lookup_project__slug": self.project.slug, + "parent_lookup_build__id": self.build.pk, + }, + ) + + self.client.logout() + response = self.client.get(url) + self.assertEqual(response.status_code, 401) + + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}") + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + self.assertDictEqual( + response.json(), + self._get_response_dict("projects-builds-notifications-list"), + ) + + def test_projects_builds_notifications_list_other_user(self): + url = reverse( + "projects-builds-notifications-list", + kwargs={ + "parent_lookup_project__slug": self.project.slug, + "parent_lookup_build__id": self.build.pk, + }, + ) + + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.others_token.key}") + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + + def test_projects_builds_notifications_list_post(self): + url = reverse( + "projects-builds-notifications-list", + kwargs={ + "parent_lookup_project__slug": self.project.slug, + "parent_lookup_build__id": self.build.pk, + }, + ) + + self.client.logout() + response = self.client.post(url) + self.assertEqual(response.status_code, 401) + + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}") + self.assertEqual(self.project.builds.count(), 1) + response = self.client.post(url) + + # We don't allow POST on this endpoint + self.assertEqual(response.status_code, 405) + + def test_projects_builds_notifitications_detail(self): + url = reverse( + "projects-builds-notifications-detail", + kwargs={ + "parent_lookup_project__slug": self.project.slug, + "parent_lookup_build__id": self.build.pk, + "notification_pk": self.notification_build.pk, + }, + ) + + self.client.logout() + response = self.client.get(url) + self.assertEqual(response.status_code, 401) + + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}") + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + self.assertDictEqual( + response.json(), + self._get_response_dict("projects-builds-notifications-detail"), + ) + + def test_projects_builds_notifitications_detail_other_user(self): + url = reverse( + "projects-builds-notifications-detail", + kwargs={ + "parent_lookup_project__slug": self.project.slug, + "parent_lookup_build__id": self.build.pk, + "notification_pk": self.notification_build.pk, + }, + ) + + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.others_token.key}") + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + + def test_projects_builds_notifitications_detail_post(self): + url = reverse( + "projects-builds-notifications-detail", + kwargs={ + "parent_lookup_project__slug": self.project.slug, + "parent_lookup_build__id": self.build.pk, + "notification_pk": self.notification_build.pk, + }, + ) + data = {"state": "read"} + + self.client.logout() + response = self.client.patch(url, data) + self.assertEqual(response.status_code, 401) + + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}") + response = self.client.patch(url, data) + self.assertEqual(response.status_code, 204) + self.assertEqual(self.build.notifications.first().state, "read") diff --git a/readthedocs/api/v3/tests/test_notifications.py b/readthedocs/api/v3/tests/test_notifications.py new file mode 100644 index 00000000000..1224fca198b --- /dev/null +++ b/readthedocs/api/v3/tests/test_notifications.py @@ -0,0 +1,44 @@ +from django.test import override_settings +from django.urls import reverse + +from readthedocs.subscriptions.constants import TYPE_CONCURRENT_BUILDS +from readthedocs.subscriptions.products import RTDProductFeature + +from .mixins import APIEndpointMixin + + +@override_settings( + RTD_ALLOW_ORGANIZATIONS=False, + ALLOW_PRIVATE_REPOS=False, + RTD_DEFAULT_FEATURES=dict( + [RTDProductFeature(TYPE_CONCURRENT_BUILDS, value=4).to_item()] + ), +) +class NotificationsEndpointTests(APIEndpointMixin): + def test_notifications_list(self): + url = reverse("notifications-list") + + self.client.logout() + response = self.client.get(url) + self.assertEqual(response.status_code, 401) + + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}") + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertDictEqual( + response.json(), + self._get_response_dict("notifications-list"), + ) + + def test_notifications_list_post(self): + url = reverse("notifications-list") + + self.client.logout() + response = self.client.post(url) + self.assertEqual(response.status_code, 401) + + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}") + response = self.client.post(url) + + # We don't allow POST on this endpoint + self.assertEqual(response.status_code, 405) diff --git a/readthedocs/api/v3/tests/test_organizations.py b/readthedocs/api/v3/tests/test_organizations.py new file mode 100644 index 00000000000..b1b97167048 --- /dev/null +++ b/readthedocs/api/v3/tests/test_organizations.py @@ -0,0 +1,139 @@ +from django.test import override_settings +from django.urls import reverse +from django.urls.exceptions import NoReverseMatch + +from readthedocs.subscriptions.constants import TYPE_CONCURRENT_BUILDS +from readthedocs.subscriptions.products import RTDProductFeature + +from .mixins import APIEndpointMixin + + +@override_settings( + RTD_ALLOW_ORGANIZATIONS=False, + ALLOW_PRIVATE_REPOS=False, + RTD_DEFAULT_FEATURES=dict( + [RTDProductFeature(TYPE_CONCURRENT_BUILDS, value=4).to_item()] + ), +) +class OrganizationsEndpointTests(APIEndpointMixin): + def test_organizations_list(self): + # We don't have this endpoint enabled on purpose + with self.assertRaises(NoReverseMatch) as e: + reverse("organizations-list") + + def test_organizations_detail(self): + # We don't have this endpoint enabled on purpose + with self.assertRaises(NoReverseMatch) as e: + reverse( + "organizations-detail", + kwargs={ + "organization_slug": self.organization.slug, + }, + ) + + def test_organizations_notifications_list(self): + url = reverse( + "organizations-notifications-list", + kwargs={ + "parent_lookup_organization__slug": self.organization.slug, + }, + ) + + self.client.logout() + response = self.client.get(url) + self.assertEqual(response.status_code, 401) + + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}") + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertDictEqual( + response.json(), + self._get_response_dict("organizations-notifications-list"), + ) + + def test_organizations_notifications_list_other_user(self): + url = reverse( + "organizations-notifications-list", + kwargs={ + "parent_lookup_organization__slug": self.organization.slug, + }, + ) + + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.others_token.key}") + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + + def test_organizations_notifications_list_post(self): + url = reverse( + "organizations-notifications-list", + kwargs={ + "parent_lookup_organization__slug": self.organization.slug, + }, + ) + + self.client.logout() + response = self.client.post(url) + self.assertEqual(response.status_code, 401) + + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}") + response = self.client.post(url) + + # We don't allow POST on this endpoint + self.assertEqual(response.status_code, 405) + + def test_organizations_notifications_detail(self): + url = reverse( + "organizations-notifications-detail", + kwargs={ + "parent_lookup_organization__slug": self.organization.slug, + "notification_pk": self.notification_organization.pk, + }, + ) + + self.client.logout() + response = self.client.get(url) + self.assertEqual(response.status_code, 401) + + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}") + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + self.assertDictEqual( + response.json(), + self._get_response_dict("organizations-notifications-detail"), + ) + + def test_organizations_notifications_detail_other(self): + url = reverse( + "organizations-notifications-detail", + kwargs={ + "parent_lookup_organization__slug": self.organization.slug, + "notification_pk": self.notification_organization.pk, + }, + ) + + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.others_token.key}") + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + def test_organizations_notifications_detail_patch(self): + url = reverse( + "organizations-notifications-detail", + kwargs={ + "parent_lookup_organization__slug": self.organization.slug, + "notification_pk": self.notification_organization.pk, + }, + ) + data = { + "state": "read", + } + + self.client.logout() + response = self.client.patch(url, data) + self.assertEqual(response.status_code, 401) + + self.assertEqual(self.organization.notifications.first().state, "unread") + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}") + response = self.client.patch(url, data) + self.assertEqual(response.status_code, 204) + self.assertEqual(self.organization.notifications.first().state, "read") diff --git a/readthedocs/api/v3/tests/test_projects.py b/readthedocs/api/v3/tests/test_projects.py index bd81b43908d..9cbc099aad1 100644 --- a/readthedocs/api/v3/tests/test_projects.py +++ b/readthedocs/api/v3/tests/test_projects.py @@ -558,3 +558,110 @@ def test_partial_update_project_invalid_privacy_level(self): self.project.refresh_from_db() self.assertEqual(self.project.privacy_level, "public") self.assertEqual(self.project.external_builds_privacy_level, "public") + + def test_projects_notifications_list(self): + url = reverse( + "projects-notifications-list", + kwargs={ + "parent_lookup_project__slug": self.project.slug, + }, + ) + + self.client.logout() + response = self.client.get(url) + self.assertEqual(response.status_code, 401) + + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}") + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertDictEqual( + response.json(), + self._get_response_dict("projects-notifications-list"), + ) + + def test_projects_notifications_list_other_user(self): + url = reverse( + "projects-notifications-list", + kwargs={ + "parent_lookup_project__slug": self.project.slug, + }, + ) + + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.others_token.key}") + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + + def test_projects_notifications_list_post(self): + url = reverse( + "projects-notifications-list", + kwargs={ + "parent_lookup_project__slug": self.project.slug, + }, + ) + + self.client.logout() + response = self.client.post(url) + self.assertEqual(response.status_code, 401) + + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}") + response = self.client.post(url) + + # We don't allow POST on this endpoint + self.assertEqual(response.status_code, 405) + + def test_projects_notifications_detail(self): + url = reverse( + "projects-notifications-detail", + kwargs={ + "parent_lookup_project__slug": self.project.slug, + "notification_pk": self.notification_project.pk, + }, + ) + + self.client.logout() + response = self.client.get(url) + self.assertEqual(response.status_code, 401) + + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}") + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + self.assertDictEqual( + response.json(), + self._get_response_dict("projects-notifications-detail"), + ) + + def test_projects_notifications_detail_other_user(self): + url = reverse( + "projects-notifications-detail", + kwargs={ + "parent_lookup_project__slug": self.project.slug, + "notification_pk": self.notification_project.pk, + }, + ) + + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.others_token.key}") + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + + def test_projects_notifications_detail_patch(self): + url = reverse( + "projects-notifications-detail", + kwargs={ + "parent_lookup_project__slug": self.project.slug, + "notification_pk": self.notification_project.pk, + }, + ) + data = { + "state": "read", + } + + self.client.logout() + response = self.client.patch(url, data) + self.assertEqual(response.status_code, 401) + + self.assertEqual(self.project.notifications.first().state, "unread") + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}") + response = self.client.patch(url, data) + self.assertEqual(response.status_code, 204) + self.assertEqual(self.project.notifications.first().state, "read") diff --git a/readthedocs/api/v3/tests/test_subprojects.py b/readthedocs/api/v3/tests/test_subprojects.py index f0ec5477126..687dbf14df7 100644 --- a/readthedocs/api/v3/tests/test_subprojects.py +++ b/readthedocs/api/v3/tests/test_subprojects.py @@ -56,6 +56,8 @@ def test_projects_subprojects_detail(self): def test_projects_subprojects_list_post(self): newproject = self._create_new_project() + self.organization.projects.add(newproject) + self.assertEqual(self.project.subprojects.count(), 1) url = reverse( "projects-subprojects-list", diff --git a/readthedocs/api/v3/tests/test_users.py b/readthedocs/api/v3/tests/test_users.py new file mode 100644 index 00000000000..9e2cebc6f69 --- /dev/null +++ b/readthedocs/api/v3/tests/test_users.py @@ -0,0 +1,148 @@ +from django.contrib.contenttypes.models import ContentType +from django.test import override_settings +from django.urls import reverse +from django.urls.exceptions import NoReverseMatch + +from readthedocs.notifications.models import Notification +from readthedocs.subscriptions.constants import TYPE_CONCURRENT_BUILDS +from readthedocs.subscriptions.products import RTDProductFeature + +from .mixins import APIEndpointMixin + + +@override_settings( + RTD_ALLOW_ORGANIZATIONS=False, + ALLOW_PRIVATE_REPOS=False, + RTD_DEFAULT_FEATURES=dict( + [RTDProductFeature(TYPE_CONCURRENT_BUILDS, value=4).to_item()] + ), +) +class UsersEndpointTests(APIEndpointMixin): + def test_users_list(self): + # We don't have this endpoint enabled on purpose + with self.assertRaises(NoReverseMatch) as e: + reverse("users-list") + + def test_users_detail(self): + # We don't have this endpoint enabled on purpose + with self.assertRaises(NoReverseMatch) as e: + reverse( + "users-detail", + kwargs={ + "user_username": self.me.username, + }, + ) + + def test_users_notifications_list(self): + url = reverse( + "users-notifications-list", + kwargs={ + "parent_lookup_user__username": self.me.username, + }, + ) + + self.client.logout() + response = self.client.get(url) + self.assertEqual(response.status_code, 401) + + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}") + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertDictEqual( + response.json(), + self._get_response_dict("users-notifications-list"), + ) + + def test_users_notifications_list_other_user(self): + url = reverse( + "users-notifications-list", + kwargs={ + "parent_lookup_user__username": self.me.username, + }, + ) + + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.others_token.key}") + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + + def test_users_notifications_list_post(self): + url = reverse( + "users-notifications-list", + kwargs={ + "parent_lookup_user__username": self.me.username, + }, + ) + + self.client.logout() + response = self.client.post(url) + self.assertEqual(response.status_code, 401) + + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}") + response = self.client.post(url) + + # We don't allow POST on this endpoint + self.assertEqual(response.status_code, 405) + + def test_users_notifications_detail(self): + url = reverse( + "users-notifications-detail", + kwargs={ + "parent_lookup_user__username": self.me.username, + "notification_pk": self.notification_user.pk, + }, + ) + + self.client.logout() + response = self.client.get(url) + self.assertEqual(response.status_code, 401) + + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}") + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + self.assertDictEqual( + response.json(), + self._get_response_dict("users-notifications-detail"), + ) + + def test_users_notifications_detail_other(self): + url = reverse( + "users-notifications-detail", + kwargs={ + "parent_lookup_user__username": self.me.username, + "notification_pk": self.notification_user.pk, + }, + ) + + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.others_token.key}") + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + def test_users_notifications_detail_patch(self): + url = reverse( + "users-notifications-detail", + kwargs={ + "parent_lookup_user__username": self.me.username, + "notification_pk": self.notification_user.pk, + }, + ) + data = { + "state": "read", + } + + self.client.logout() + response = self.client.patch(url, data) + self.assertEqual(response.status_code, 401) + + notification = Notification.objects.get( + attached_to_content_type=ContentType.objects.get_for_model(self.me), + attached_to_id=self.me.pk, + ) + + self.assertEqual(notification.state, "unread") + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}") + response = self.client.patch(url, data) + self.assertEqual(response.status_code, 204) + + notification.refresh_from_db() + self.assertEqual(notification.state, "read") diff --git a/readthedocs/api/v3/urls.py b/readthedocs/api/v3/urls.py index 528f576e0f1..c722eb7b725 100644 --- a/readthedocs/api/v3/urls.py +++ b/readthedocs/api/v3/urls.py @@ -3,13 +3,19 @@ BuildsCreateViewSet, BuildsViewSet, EnvironmentVariablesViewSet, - NotificationsViewSet, + NotificationsBuildViewSet, + NotificationsForUserViewSet, + NotificationsOrganizationViewSet, + NotificationsProjectViewSet, + NotificationsUserViewSet, + OrganizationsViewSet, ProjectsViewSet, RedirectsViewSet, RemoteOrganizationViewSet, RemoteRepositoryViewSet, SubprojectRelationshipViewSet, TranslationRelationshipViewSet, + UsersViewSet, VersionsViewSet, ) @@ -24,6 +30,14 @@ basename="projects", ) +# allows /api/v3/projects/pip/notifications/ +projects.register( + r"notifications", + NotificationsProjectViewSet, + basename="projects-notifications", + parents_query_lookups=["project__slug"], +) + # allows /api/v3/projects/pip/subprojects/ subprojects = projects.register( r"subprojects", @@ -70,21 +84,14 @@ parents_query_lookups=["project__slug"], ) -# NOTE: we are only listing notifications on APIv3 for now. -# The front-end will use this endpoint. # allows /api/v3/projects/pip/builds/1053/notifications/ builds.register( r"notifications", - NotificationsViewSet, - basename="project-builds-notifications", + NotificationsBuildViewSet, + basename="projects-builds-notifications", parents_query_lookups=["project__slug", "build__id"], ) -# TODO: create an APIv3 endpoint to PATCH Build/Project notifications. -# This way the front-end can mark them as READ/DISMISSED. -# -# TODO: create an APIv3 endpoint to list notifications for Projects. - # allows /api/v3/projects/pip/redirects/ # allows /api/v3/projects/pip/redirects/1053/ projects.register( @@ -103,6 +110,43 @@ parents_query_lookups=["project__slug"], ) +# allows /api/v3/users/ +users = router.register( + r"users", + UsersViewSet, + basename="users", +) + +# allows /api/v3/users//notifications/ +users.register( + r"notifications", + NotificationsUserViewSet, + basename="users-notifications", + parents_query_lookups=["user__username"], +) + +# allows /api/v3/organizations/ +organizations = router.register( + r"organizations", + OrganizationsViewSet, + basename="organizations", +) + +# allows /api/v3/organizations//notifications/ +organizations.register( + r"notifications", + NotificationsOrganizationViewSet, + basename="organizations-notifications", + parents_query_lookups=["organization__slug"], +) + +# allows /api/v3/notifications/ +router.register( + r"notifications", + NotificationsForUserViewSet, + basename="notifications", +) + # allows /api/v3/remote/repositories/ router.register( r"remote/repositories", @@ -117,5 +161,6 @@ basename="remoteorganizations", ) + urlpatterns = [] urlpatterns += router.urls diff --git a/readthedocs/api/v3/views.py b/readthedocs/api/v3/views.py index 3196980dca1..3087c9516a1 100644 --- a/readthedocs/api/v3/views.py +++ b/readthedocs/api/v3/views.py @@ -1,4 +1,5 @@ import django_filters.rest_framework as filters +from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.db.models import Exists, OuterRef from rest_flex_fields import is_expanded @@ -11,6 +12,7 @@ CreateModelMixin, DestroyModelMixin, ListModelMixin, + RetrieveModelMixin, UpdateModelMixin, ) from rest_framework.pagination import LimitOffsetPagination @@ -22,6 +24,7 @@ from rest_framework_extensions.mixins import NestedViewSetMixin from readthedocs.builds.models import Build, Version +from readthedocs.core.permissions import AdminPermission from readthedocs.core.utils import trigger_build from readthedocs.core.utils.extend import SettingsOverrideObject from readthedocs.notifications.models import Notification @@ -41,6 +44,7 @@ from .filters import ( BuildFilter, + NotificationFilter, ProjectFilter, RemoteOrganizationFilter, RemoteRepositoryFilter, @@ -52,13 +56,9 @@ RemoteQuerySetMixin, UpdateChangeReasonMixin, UpdateMixin, + UserQuerySetMixin, ) -from .permissions import ( - CommonPermissions, - IsOrganizationAdminMember, - IsProjectAdmin, - UserOrganizationsListing, -) +from .permissions import CommonPermissions, IsProjectAdmin from .renderers import AlphabeticalSortedJSONRenderer from .serializers import ( BuildCreateSerializer, @@ -76,6 +76,7 @@ SubprojectCreateSerializer, SubprojectDestroySerializer, SubprojectSerializer, + UserSerializer, VersionSerializer, VersionUpdateSerializer, ) @@ -384,30 +385,85 @@ def create(self, request, **kwargs): # pylint: disable=arguments-differ return Response(data=data, status=code) -class NotificationsViewSet( +class NotificationsForUserViewSet( + APIv3Settings, + FlexFieldsMixin, + ListModelMixin, + RetrieveModelMixin, + UpdateMixin, + UpdateModelMixin, + GenericViewSet, +): + + """ + Endpoint to return all the notifications related to the logged in user. + + Hitting this endpoint while logged in will return notifications attached to: + + - User making the request + - Organizations where the user is owner/member + - Projects where the user is admin/member + """ + + model = Notification + serializer_class = NotificationSerializer + queryset = Notification.objects.all() + + # Override global permissions here because it doesn't not make sense to hit + # this endpoint without being logged in. We can't use our + # ``CommonPermissions`` because it requires the endpoint to be nested under + # ``projects`` + permission_classes = (IsAuthenticated,) + filterset_class = NotificationFilter + + def get_queryset(self): + return Notification.objects.for_user(self.request.user) + + +class NotificationsProjectViewSet( APIv3Settings, NestedViewSetMixin, ProjectQuerySetMixin, FlexFieldsMixin, - ReadOnlyModelViewSet, + ListModelMixin, + RetrieveModelMixin, + UpdateMixin, + UpdateModelMixin, + GenericViewSet, ): model = Notification lookup_field = "pk" lookup_url_kwarg = "notification_pk" serializer_class = NotificationSerializer queryset = Notification.objects.all() - # filterset_class = BuildFilter + filterset_class = NotificationFilter - # http://chibisov.github.io/drf-extensions/docs/#usage-with-generic-relations def get_queryset(self): - queryset = self.queryset.filter( - attached_to_content_type=ContentType.objects.get_for_model(Build) - ) + project = self._get_parent_project() + return project.notifications.all() - # TODO: make sure if this particular filter should be applied here or somewhere else. - return queryset.filter( - attached_to_id=self.kwargs.get("parent_lookup_build__id") - ) + +class NotificationsBuildViewSet( + APIv3Settings, + NestedViewSetMixin, + ProjectQuerySetMixin, + FlexFieldsMixin, + ListModelMixin, + RetrieveModelMixin, + UpdateMixin, + UpdateModelMixin, + GenericViewSet, +): + model = Notification + lookup_field = "pk" + lookup_url_kwarg = "notification_pk" + serializer_class = NotificationSerializer + queryset = Notification.objects.all() + filterset_class = NotificationFilter + + def get_queryset(self): + build = self._get_parent_build() + return build.notifications.all() class RedirectsViewSet( @@ -472,57 +528,6 @@ def perform_create(self, serializer): serializer.save() -class OrganizationsViewSet( - APIv3Settings, - NestedViewSetMixin, - OrganizationQuerySetMixin, - ReadOnlyModelViewSet, -): - model = Organization - lookup_field = "slug" - lookup_url_kwarg = "organization_slug" - queryset = Organization.objects.all() - serializer_class = OrganizationSerializer - permission_classes = [ - IsAuthenticated & (UserOrganizationsListing | IsOrganizationAdminMember) - ] - permit_list_expands = [ - "projects", - "teams", - "teams.members", - ] - - def get_view_name(self): - return f"Organizations {self.suffix}" - - def get_queryset(self): - # Allow hitting ``/api/v3/organizations/`` to list their own organizations - if self.basename == "organizations" and self.action == "list": - # We force returning ``Organization`` objects here because it's - # under the ``organizations`` view. - return self.admin_organizations(self.request.user) - - return super().get_queryset() - - -class OrganizationsProjectsViewSet( - APIv3Settings, NestedViewSetMixin, OrganizationQuerySetMixin, ReadOnlyModelViewSet -): - model = Project - lookup_field = "slug" - lookup_url_kwarg = "project_slug" - queryset = Project.objects.all() - serializer_class = ProjectSerializer - permission_classes = [IsAuthenticated & IsOrganizationAdminMember] - permit_list_expands = [ - "organization", - "organization.teams", - ] - - def get_view_name(self): - return f"Organizations Projects {self.suffix}" - - class RemoteRepositoryViewSet( APIv3Settings, RemoteQuerySetMixin, FlexFieldsMixin, ListModelMixin, GenericViewSet ): @@ -565,3 +570,87 @@ class RemoteOrganizationViewSet( filterset_class = RemoteOrganizationFilter queryset = RemoteOrganization.objects.all() permission_classes = (IsAuthenticated,) + + +class UsersViewSet( + APIv3Settings, + GenericViewSet, +): + # NOTE: this viewset is only useful for nested URLs required for notifications: + # /api/v3/users//notifications/ + # However, accessing to /api/v3/users/ or /api/v3/users// will return 404. + # We can implement these endpoints when we need them, tho. + + model = User + serializer_class = UserSerializer + queryset = User.objects.none() + permission_classes = (IsAuthenticated,) + + +class NotificationsUserViewSet( + APIv3Settings, + NestedViewSetMixin, + UserQuerySetMixin, + ListModelMixin, + RetrieveModelMixin, + UpdateMixin, + UpdateModelMixin, + GenericViewSet, +): + model = Notification + lookup_field = "pk" + lookup_url_kwarg = "notification_pk" + serializer_class = NotificationSerializer + queryset = Notification.objects.all() + filterset_class = NotificationFilter + + def get_queryset(self): + # Filter the queryset by only notifications attached to the particular user + # that's making the request to this endpoint + content_type = ContentType.objects.get_for_model(User) + return self.queryset.filter( + attached_to_content_type=content_type, + attached_to_id=self.request.user.pk, + ) + + +class OrganizationsViewSet( + APIv3Settings, + GenericViewSet, +): + # NOTE: this viewset is only useful for nested URLs required for notifications: + # /api/v3/organizations//notifications/ + # However, accessing to /api/v3/organizations/ or /api/v3/organizations// will return 404. + # We can implement these endpoints when we need them, tho. + + model = Organization + serializer_class = OrganizationSerializer + queryset = Organization.objects.none() + permission_classes = (IsAuthenticated,) + + +class NotificationsOrganizationViewSet( + APIv3Settings, + NestedViewSetMixin, + OrganizationQuerySetMixin, + ListModelMixin, + RetrieveModelMixin, + UpdateMixin, + UpdateModelMixin, + GenericViewSet, +): + model = Notification + lookup_field = "pk" + lookup_url_kwarg = "notification_pk" + serializer_class = NotificationSerializer + queryset = Notification.objects.all() + filterset_class = NotificationFilter + + def get_queryset(self): + content_type = ContentType.objects.get_for_model(Organization) + return self.queryset.filter( + attached_to_content_type=content_type, + attached_to_id__in=AdminPermission.organizations( + self.request.user, owner=True, member=False + ).values("id"), + ) diff --git a/readthedocs/core/permissions.py b/readthedocs/core/permissions.py index cba18ddac41..733a0d29411 100644 --- a/readthedocs/core/permissions.py +++ b/readthedocs/core/permissions.py @@ -74,6 +74,22 @@ def projects(cls, user, admin=False, member=False): return projects + @classmethod + def organizations(cls, user, owner=False, member=False): + from readthedocs.organizations.models import Organization + + organizations = Organization.objects.none() + + if owner: + organizations |= Organization.objects.filter(owners__in=[user]).distinct() + + if member: + organizations |= Organization.objects.filter( + projects__in=cls.projects(user, admin=True, member=True) + ).distinct() + + return organizations + @classmethod def has_sso_enabled(cls, obj, provider=None): return False diff --git a/readthedocs/notifications/querysets.py b/readthedocs/notifications/querysets.py index 787ecf17197..3c569427537 100644 --- a/readthedocs/notifications/querysets.py +++ b/readthedocs/notifications/querysets.py @@ -1,7 +1,10 @@ +from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.db import models from django.utils import timezone +from readthedocs.core.permissions import AdminPermission + class NotificationQuerySet(models.QuerySet): def add(self, *args, **kwargs): @@ -32,3 +35,45 @@ def add(self, *args, **kwargs): return notification return super().create(*args, attached_to=attached_to, **kwargs) + + def for_user(self, user): + """ + Retrieve all notifications related to a particular user. + + Given a user, returns all the notifications that: + + - are attached to an ``Organization`` where the user is owner + - are attached to a ``Project`` where the user is admin + - are attacehd to the ``User`` themselves + """ + # Need to be here due to circular imports + from readthedocs.organizations.models import Organization + from readthedocs.projects.models import Project + + # http://chibisov.github.io/drf-extensions/docs/#usage-with-generic-relations + user_notifications = self.filter( + attached_to_content_type=ContentType.objects.get_for_model(User), + attached_to_id=user.pk, + ) + + project_notifications = self.filter( + attached_to_content_type=ContentType.objects.get_for_model(Project), + attached_to_id__in=AdminPermission.projects( + user, + admin=True, + member=False, + ).values("id"), + ) + + organization_notifications = self.filter( + attached_to_content_type=ContentType.objects.get_for_model(Organization), + attached_to_id__in=AdminPermission.organizations( + user, + owner=True, + member=False, + ).values("id"), + ) + + # Return all the notifications related to this user attached to: + # User, Project and Organization models where the user is admin. + return user_notifications | project_notifications | organization_notifications diff --git a/readthedocs/organizations/querysets.py b/readthedocs/organizations/querysets.py index decb0a6272d..f775d7b3e94 100644 --- a/readthedocs/organizations/querysets.py +++ b/readthedocs/organizations/querysets.py @@ -23,7 +23,7 @@ def for_user(self, user): ).distinct() def for_admin_user(self, user): - return self.filter(owners__in=[user],).distinct() + return self.filter(owners__in=[user]).distinct() def created_days_ago(self, days, field='pub_date'): """