Skip to content

Commit e778db5

Browse files
humitosagjohnson
andauthored
New notification system: implementation (#10922)
* Add some comments to understand the scope of the changes * Add more comments about the plan * Delete old code We are not sending any kind of notification from the Django admin. We used to use this in the past but we are not using it anymore. I'm deleting it now to avoid re-implementing it using the new notification system. * Add more comments to organize the work of new notification system * Notification: initial modelling * Define `max_length=128` that's required for SQLite (tests) * Initial implementation for build notifications API endpoint * API endpoint to create build notifications * Use APIv2 for internal endpoint to call from builders * Use APIv2 to create notifications from the builder * Check project slug when attaching to a Build/Project instance * Add extra TODO comments * Initial migration from Exceptions to Messages * Disable `Notification.format_values` for now I'll come back to this later. * Define all the notifications * Messages for symlink exceptions * Simplify how `NOTIFICATION_MESSAGES` is built * Add `Notification.format_values` field This allows to store values required for this notification at render time * Notification with `format_values` passed when exception is raised * Use `get_display_icon` to show the proper icon in the serializer * Proper relation name * Initial work to replace site notifications * We are not going to create Notifications via APIv3 for now * Send `format_values` to exception * Small changes and comments added * Initial work to migrate an old SiteNotification to new system * Create a notification `registry` to register messages by application * Use a custom `models.Manager` to implement de-duplication Use `Notification.objects.add()` to create a notification with de-duplication capabilities. If the notification already exists, it updates all the fields with the new ones and set the `modified` datetime to now. This commit also migrates the "Your primary email address is not verified" to the new notification system, which it was when I implemented the de-duplication logic. * Small refactor to email notifications * Render build/project notifications from Django templates Use a small hack on build detail's page to refresh the page once the build is finished. This way, we don't have to re-implement the new notification system in the old Knockout javascript code and we focus ourselves only in the new dashboard. * Attach "build.commands" in beta notification * Initial migration pattern for YAML config errors * Pass default settings for notifications * Organization disabled notification * Email not verified notification * Project skipped notification * Missing file for NotificationQuerySet * Migrate `oauth` app notifications to the new system * Add small comment about notification migration * Rename import * Fix queryset when creating a notification via `.add()` * Remove `SiteNotification` completely * Rename file and remove `__init__` magic * Remove `django-message-extends` completely We are not going to use it anymore with the new notification system. * Note about user notifications in base template * Remove commented code for debugging * Typo (from review) * Migrate `ConfigError` to the new notification system * Implement refact `icon` and `icon_type` into `icon_classes` * Remove unused setting * Update config file test cases to match changes * Mark test that depend on search to be skipped * Cancel build test fixed * Update APIv3 test cases * Down to a few of tests failing * Raise exception properly by passing `message_id` * More tests updated/fixed * BuildUser/BuildAdd error * Instantiate `BuildDirector` on `before_start` It makes more sense to be there than under `execute()`, since it's a pre-requirement. It also helps when testing. * Update test case to pass * Update more tests related to notifications * Make usage of `.add` for deduplication * Move the link to the message itself. * Create message IDs property * Use IDs to avoid notifications * Update comments * Attach organization on retry * Test we are hitting APIv2 for notifications * Remove old comment * Remove temporary debugging link * Remove old comment * Update send build status celery tests * Explain how to show notifications attached to users * Better comment in template * Lint * Minor changes * Refactor `ValidationError` to raise a proper exception/notification * Update tests for config after refactor * Pass `user_notifications` to templates * More updates on config tests * Remove debugging code * Add context processor for user notifications * Don't retrieve notifications if anonymous user * Use proper exception * Apply suggestions from code review Co-authored-by: Anthony <[email protected]> * Remove old TODO messages * Remove `deprecated_` utils and notifications They are not useful anymore since it's not possible to build without a config file or using a config file v1. * Use the proper HTML/CSS for user notifications * Message for deploy key (required in commercial) * Migrate Docker errors to new system * Migrate more messages to the new notifications system * Typo * Fix error type * Remove showing errors from `build.error` We are using the `Notification`s attached to the object now. These old errors are migrated to the new system by a one-time script. * Typo in copy * Revert "Remove showing errors from `build.error`" This reverts commit d4951a8. We decided to keep this code around for now. See #10980 (comment) * Comment explaining the decision about `Build.error` HTML+KO code * Use `textwrap.dedent` + `.strip()` for all messages Closes #10987 * Avoid breaking at rendering time If the message ID is not found we return a generic message to avoid breaking the request and we log an internal error to fix the issue. * Apply feedback from review --------- Co-authored-by: Anthony <[email protected]>
1 parent 9c8fe6f commit e778db5

File tree

92 files changed

+2746
-2140
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

92 files changed

+2746
-2140
lines changed

common

readthedocs/api/v2/serializers.py

+54
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@
22

33

44
from allauth.socialaccount.models import SocialAccount
5+
from django.core.exceptions import ObjectDoesNotExist
6+
from django.utils.translation import gettext as _
7+
from generic_relations.relations import GenericRelatedField
58
from rest_framework import serializers
69

710
from readthedocs.api.v2.utils import normalize_build_command
811
from readthedocs.builds.models import Build, BuildCommandResult, Version
912
from readthedocs.core.resolver import Resolver
13+
from readthedocs.notifications.models import Notification
1014
from readthedocs.oauth.models import RemoteOrganization, RemoteRepository
1115
from readthedocs.projects.models import Domain, Project
1216

@@ -378,3 +382,53 @@ def get_username(self, obj):
378382
or obj.extra_data.get("login")
379383
# FIXME: which one is GitLab?
380384
)
385+
386+
387+
class NotificationAttachedToRelatedField(serializers.RelatedField):
388+
389+
"""
390+
Attached to related field for Notifications.
391+
392+
Used together with ``rest-framework-generic-relations`` to accept multiple object types on ``attached_to``.
393+
394+
See https://github.com/LilyFoote/rest-framework-generic-relations
395+
"""
396+
397+
default_error_messages = {
398+
"required": _("This field is required."),
399+
"does_not_exist": _("Object does not exist."),
400+
"incorrect_type": _(
401+
"Incorrect type. Expected URL string, received {data_type}."
402+
),
403+
}
404+
405+
def to_representation(self, value):
406+
return f"{self.queryset.model._meta.model_name}/{value.pk}"
407+
408+
def to_internal_value(self, data):
409+
# TODO: handle exceptions
410+
model, pk = data.strip("/").split("/")
411+
if self.queryset.model._meta.model_name != model:
412+
self.fail("incorrect_type")
413+
414+
try:
415+
return self.queryset.get(pk=pk)
416+
except (ObjectDoesNotExist, ValueError, TypeError):
417+
self.fail("does_not_exist")
418+
419+
420+
class NotificationSerializer(serializers.ModelSerializer):
421+
# Accept different object types (Project, Build, User, etc) depending on what the notification is attached to.
422+
# The client has to send a value like "<model>/<pk>".
423+
# Example: "build/3522" will attach the notification to the Build object with id 3522
424+
attached_to = GenericRelatedField(
425+
{
426+
Build: NotificationAttachedToRelatedField(queryset=Build.objects.all()),
427+
Project: NotificationAttachedToRelatedField(queryset=Project.objects.all()),
428+
},
429+
required=True,
430+
)
431+
432+
class Meta:
433+
model = Notification
434+
exclude = ["attached_to_id", "attached_to_content_type"]

readthedocs/api/v2/urls.py

+2
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
BuildCommandViewSet,
1313
BuildViewSet,
1414
DomainViewSet,
15+
NotificationViewSet,
1516
ProjectViewSet,
1617
RemoteOrganizationViewSet,
1718
RemoteRepositoryViewSet,
@@ -25,6 +26,7 @@
2526
router.register(r"version", VersionViewSet, basename="version")
2627
router.register(r"project", ProjectViewSet, basename="project")
2728
router.register(r"domain", DomainViewSet, basename="domain")
29+
router.register(r"notifications", NotificationViewSet, basename="notifications")
2830
router.register(
2931
r"remote/org",
3032
RemoteOrganizationViewSet,

readthedocs/api/v2/views/model_views.py

+38
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from readthedocs.api.v2.utils import normalize_build_command
1919
from readthedocs.builds.constants import INTERNAL
2020
from readthedocs.builds.models import Build, BuildCommandResult, Version
21+
from readthedocs.notifications.models import Notification
2122
from readthedocs.oauth.models import RemoteOrganization, RemoteRepository
2223
from readthedocs.oauth.services import registry
2324
from readthedocs.projects.models import Domain, Project
@@ -29,6 +30,7 @@
2930
BuildCommandSerializer,
3031
BuildSerializer,
3132
DomainSerializer,
33+
NotificationSerializer,
3234
ProjectAdminSerializer,
3335
ProjectSerializer,
3436
RemoteOrganizationSerializer,
@@ -361,6 +363,42 @@ def get_queryset_for_api_key(self, api_key):
361363
return self.model.objects.filter(build__project=api_key.project)
362364

363365

366+
class NotificationViewSet(DisableListEndpoint, CreateModelMixin, UserSelectViewSet):
367+
368+
"""
369+
Create a notification attached to an object (User, Project, Build, Organization).
370+
371+
This endpoint is currently used only internally by the builder.
372+
Notifications are attached to `Build` objects only when using this endpoint.
373+
This limitation will change in the future when re-implementing this on APIv3 if neeed.
374+
"""
375+
376+
parser_classes = [JSONParser, MultiPartParser]
377+
permission_classes = [HasBuildAPIKey]
378+
renderer_classes = (JSONRenderer,)
379+
serializer_class = NotificationSerializer
380+
model = Notification
381+
382+
def perform_create(self, serializer):
383+
"""Restrict creation to notifications attached to the project's builds from the api key."""
384+
attached_to = serializer.validated_data["attached_to"]
385+
386+
build_api_key = self.request.build_api_key
387+
388+
project_slug = None
389+
if isinstance(attached_to, Build):
390+
project_slug = attached_to.project.slug
391+
elif isinstance(attached_to, Project):
392+
project_slug = attached_to.slug
393+
394+
# Limit the permissions to create a notification on this object only if the API key
395+
# is attached to the related project
396+
if not project_slug or build_api_key.project.slug != project_slug:
397+
raise PermissionDenied()
398+
399+
return super().perform_create(serializer)
400+
401+
364402
class DomainViewSet(DisableListEndpoint, UserSelectViewSet):
365403
permission_classes = [ReadOnlyPermission]
366404
renderer_classes = (JSONRenderer,)

readthedocs/api/v3/serializers.py

+85
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from readthedocs.core.resolver import Resolver
1616
from readthedocs.core.utils import slugify
1717
from readthedocs.core.utils.extend import SettingsOverrideObject
18+
from readthedocs.notifications.models import Notification
1819
from readthedocs.oauth.models import RemoteOrganization, RemoteRepository
1920
from readthedocs.organizations.models import Organization, Team
2021
from readthedocs.projects.constants import (
@@ -60,10 +61,33 @@ class Meta:
6061
fields = []
6162

6263

64+
# TODO: decide whether or not include a `_links` field on the object
65+
#
66+
# This also includes adding `/api/v3/notifications/<pk>` endpoint,
67+
# which I'm not sure it's useful at this point.
68+
#
69+
# class NotificationLinksSerializer(BaseLinksSerializer):
70+
# _self = serializers.SerializerMethodField()
71+
# attached_to = serializers.SerializerMethodField()
72+
73+
# def get__self(self, obj):
74+
# path = reverse(
75+
# "notifications-detail",
76+
# kwargs={
77+
# "pk": obj.pk,
78+
# },
79+
# )
80+
# return self._absolute_url(path)
81+
82+
# def get_attached_to(self, obj):
83+
# return None
84+
85+
6386
class BuildLinksSerializer(BaseLinksSerializer):
6487
_self = serializers.SerializerMethodField()
6588
version = serializers.SerializerMethodField()
6689
project = serializers.SerializerMethodField()
90+
notifications = serializers.SerializerMethodField()
6791

6892
def get__self(self, obj):
6993
path = reverse(
@@ -96,6 +120,16 @@ def get_project(self, obj):
96120
)
97121
return self._absolute_url(path)
98122

123+
def get_notifications(self, obj):
124+
path = reverse(
125+
"project-builds-notifications-list",
126+
kwargs={
127+
"parent_lookup_project__slug": obj.project.slug,
128+
"parent_lookup_build__id": obj.pk,
129+
},
130+
)
131+
return self._absolute_url(path)
132+
99133

100134
class BuildURLsSerializer(BaseLinksSerializer, serializers.Serializer):
101135
build = serializers.URLField(source="get_full_url")
@@ -190,6 +224,57 @@ def get_success(self, obj):
190224
return None
191225

192226

227+
class NotificationMessageSerializer(serializers.Serializer):
228+
id = serializers.SlugField()
229+
header = serializers.CharField(source="get_rendered_header")
230+
body = serializers.CharField(source="get_rendered_body")
231+
type = serializers.CharField()
232+
icon_classes = serializers.CharField(source="get_display_icon_classes")
233+
234+
class Meta:
235+
fields = [
236+
"id",
237+
"header",
238+
"body",
239+
"type",
240+
"icon_classes",
241+
]
242+
243+
244+
class NotificationCreateSerializer(serializers.ModelSerializer):
245+
class Meta:
246+
model = Notification
247+
fields = [
248+
"message_id",
249+
"dismissable",
250+
"news",
251+
"state",
252+
]
253+
254+
255+
class NotificationSerializer(serializers.ModelSerializer):
256+
message = NotificationMessageSerializer(source="get_message")
257+
attached_to_content_type = serializers.SerializerMethodField()
258+
# TODO: review these fields
259+
# _links = BuildLinksSerializer(source="*")
260+
# urls = BuildURLsSerializer(source="*")
261+
262+
class Meta:
263+
model = Notification
264+
fields = [
265+
"id",
266+
"state",
267+
"dismissable",
268+
"news",
269+
"attached_to_content_type",
270+
"attached_to_id",
271+
"message",
272+
]
273+
274+
def get_attached_to_content_type(self, obj):
275+
return obj.attached_to_content_type.name
276+
277+
193278
class VersionLinksSerializer(BaseLinksSerializer):
194279
_self = serializers.SerializerMethodField()
195280
builds = serializers.SerializerMethodField()

readthedocs/api/v3/tests/mixins.py

+1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
)
2727
class APIEndpointMixin(TestCase):
2828
fixtures = []
29+
maxDiff = None # So we get an actual diff when it fails
2930

3031
def setUp(self):
3132
self.created = make_aware(datetime.datetime(2019, 4, 29, 10, 0, 0))

readthedocs/api/v3/tests/responses/projects-builds-detail.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
"_links": {
99
"_self": "https://readthedocs.org/api/v3/projects/project/builds/1/",
1010
"project": "https://readthedocs.org/api/v3/projects/project/",
11-
"version": "https://readthedocs.org/api/v3/projects/project/versions/v1.0/"
11+
"version": "https://readthedocs.org/api/v3/projects/project/versions/v1.0/",
12+
"notifications": "https://readthedocs.org/api/v3/projects/project/builds/1/notifications/"
1213
},
1314
"urls": {
1415
"build": "https://readthedocs.org/projects/project/builds/1/",

readthedocs/api/v3/tests/responses/projects-detail.json

+1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"id": 1,
2424
"_links": {
2525
"_self": "https://readthedocs.org/api/v3/projects/project/builds/1/",
26+
"notifications": "https://readthedocs.org/api/v3/projects/project/builds/1/notifications/",
2627
"project": "https://readthedocs.org/api/v3/projects/project/",
2728
"version": "https://readthedocs.org/api/v3/projects/project/versions/v1.0/"
2829
},

readthedocs/api/v3/tests/responses/projects-versions-builds-list_POST.json

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"id": 2,
99
"_links": {
1010
"_self": "https://readthedocs.org/api/v3/projects/project/builds/2/",
11+
"notifications": "https://readthedocs.org/api/v3/projects/project/builds/2/notifications/",
1112
"project": "https://readthedocs.org/api/v3/projects/project/",
1213
"version": "https://readthedocs.org/api/v3/projects/project/versions/v1.0/"
1314
},

readthedocs/api/v3/urls.py

+17-2
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
BuildsCreateViewSet,
44
BuildsViewSet,
55
EnvironmentVariablesViewSet,
6+
NotificationsViewSet,
67
ProjectsViewSet,
78
RedirectsViewSet,
89
RemoteOrganizationViewSet,
@@ -12,7 +13,6 @@
1213
VersionsViewSet,
1314
)
1415

15-
1616
router = DefaultRouterWithNesting()
1717

1818
# allows /api/v3/projects/
@@ -63,13 +63,28 @@
6363

6464
# allows /api/v3/projects/pip/builds/
6565
# allows /api/v3/projects/pip/builds/1053/
66-
projects.register(
66+
builds = projects.register(
6767
r"builds",
6868
BuildsViewSet,
6969
basename="projects-builds",
7070
parents_query_lookups=["project__slug"],
7171
)
7272

73+
# NOTE: we are only listing notifications on APIv3 for now.
74+
# The front-end will use this endpoint.
75+
# allows /api/v3/projects/pip/builds/1053/notifications/
76+
builds.register(
77+
r"notifications",
78+
NotificationsViewSet,
79+
basename="project-builds-notifications",
80+
parents_query_lookups=["project__slug", "build__id"],
81+
)
82+
83+
# TODO: create an APIv3 endpoint to PATCH Build/Project notifications.
84+
# This way the front-end can mark them as READ/DISMISSED.
85+
#
86+
# TODO: create an APIv3 endpoint to list notifications for Projects.
87+
7388
# allows /api/v3/projects/pip/redirects/
7489
# allows /api/v3/projects/pip/redirects/1053/
7590
projects.register(

readthedocs/api/v3/views.py

+29
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import django_filters.rest_framework as filters
2+
from django.contrib.contenttypes.models import ContentType
23
from django.db.models import Exists, OuterRef
34
from rest_flex_fields import is_expanded
45
from rest_flex_fields.views import FlexFieldsMixin
@@ -23,6 +24,7 @@
2324
from readthedocs.builds.models import Build, Version
2425
from readthedocs.core.utils import trigger_build
2526
from readthedocs.core.utils.extend import SettingsOverrideObject
27+
from readthedocs.notifications.models import Notification
2628
from readthedocs.oauth.models import (
2729
RemoteOrganization,
2830
RemoteRepository,
@@ -62,6 +64,7 @@
6264
BuildCreateSerializer,
6365
BuildSerializer,
6466
EnvironmentVariableSerializer,
67+
NotificationSerializer,
6568
OrganizationSerializer,
6669
ProjectCreateSerializer,
6770
ProjectSerializer,
@@ -381,6 +384,32 @@ def create(self, request, **kwargs): # pylint: disable=arguments-differ
381384
return Response(data=data, status=code)
382385

383386

387+
class NotificationsViewSet(
388+
APIv3Settings,
389+
NestedViewSetMixin,
390+
ProjectQuerySetMixin,
391+
FlexFieldsMixin,
392+
ReadOnlyModelViewSet,
393+
):
394+
model = Notification
395+
lookup_field = "pk"
396+
lookup_url_kwarg = "notification_pk"
397+
serializer_class = NotificationSerializer
398+
queryset = Notification.objects.all()
399+
# filterset_class = BuildFilter
400+
401+
# http://chibisov.github.io/drf-extensions/docs/#usage-with-generic-relations
402+
def get_queryset(self):
403+
queryset = self.queryset.filter(
404+
attached_to_content_type=ContentType.objects.get_for_model(Build)
405+
)
406+
407+
# TODO: make sure if this particular filter should be applied here or somewhere else.
408+
return queryset.filter(
409+
attached_to_id=self.kwargs.get("parent_lookup_build__id")
410+
)
411+
412+
384413
class RedirectsViewSet(
385414
APIv3Settings,
386415
NestedViewSetMixin,

0 commit comments

Comments
 (0)