diff --git a/src/api/__init__.py b/src/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/api/urls.py b/src/api/urls.py new file mode 100644 index 0000000..fb0f492 --- /dev/null +++ b/src/api/urls.py @@ -0,0 +1,17 @@ +from django.contrib.auth import decorators as auth_decorators +from django.urls import include, path +from drf_spectacular import views as drf_views + +from . import v1 + +schema_view = drf_views.SpectacularAPIView.as_view() +swagger_view = drf_views.SpectacularSwaggerView.as_view(url_name="schema") +redoc_view = drf_views.SpectacularRedocView.as_view(url_name="schema") + +app_name = "api" +urlpatterns = [ + path("v1/", include((v1.urls, "api"), namespace="v1")), + path("schema/", auth_decorators.login_required(schema_view), name="schema"), + path("docs/", auth_decorators.login_required(swagger_view), name="docs"), + path("redoc/", auth_decorators.login_required(redoc_view), name="redoc"), +] diff --git a/src/api/v1/__init__.py b/src/api/v1/__init__.py new file mode 100644 index 0000000..fa599e4 --- /dev/null +++ b/src/api/v1/__init__.py @@ -0,0 +1 @@ +from . import urls diff --git a/src/api/v1/push_notifications/__init__.py b/src/api/v1/push_notifications/__init__.py new file mode 100644 index 0000000..fa599e4 --- /dev/null +++ b/src/api/v1/push_notifications/__init__.py @@ -0,0 +1 @@ +from . import urls diff --git a/src/api/v1/push_notifications/schemas.py b/src/api/v1/push_notifications/schemas.py new file mode 100644 index 0000000..4f8a751 --- /dev/null +++ b/src/api/v1/push_notifications/schemas.py @@ -0,0 +1,53 @@ +from rest_framework import serializers + +from app.consts import push_notification as push_notification_consts +from app.drf.fields import create_choice_human_field +from app.drf.serializers import inline_serializer +from push_notifications import models + + +class PushNotificationInputSchema(serializers.Serializer): + only_read = serializers.BooleanField(required=False, allow_null=True) + + +PushNotificationKindField = create_choice_human_field(constant_class=push_notification_consts.Kind) +PushNotificationStatusField = create_choice_human_field( + constant_class=push_notification_consts.Status +) + + +class PushNotificationOutputSchema(serializers.ModelSerializer): + kind = PushNotificationKindField() + status = PushNotificationStatusField() + data = inline_serializer( + name="PushNotificationEnrichedDataOutputSchema", + fields={ + "id": serializers.CharField(), + "createdAt": serializers.DateTimeField(), + "readAt": serializers.DateTimeField(), + "timeSinceCreated": serializers.DateTimeField(), + "kind": serializers.CharField(), + "meta": serializers.JSONField(), + }, + source="enriched_data", + ) + + class Meta: + model = models.PushNotification + fields = ( + "id", + "title", + "description", + "read_at", + "kind", + "status", + "data", + ) + + +class PushNotificationReadManyInputSchema(serializers.Serializer): + ids = serializers.ListField(required=False, allow_null=True) + + +class PushNotificationReadManyOutputSchema(serializers.Serializer): + read = serializers.IntegerField() diff --git a/src/api/v1/push_notifications/urls.py b/src/api/v1/push_notifications/urls.py new file mode 100644 index 0000000..acba305 --- /dev/null +++ b/src/api/v1/push_notifications/urls.py @@ -0,0 +1,8 @@ +from rest_framework.routers import SimpleRouter + +from . import views + +router = SimpleRouter(trailing_slash=False) +router.register("notifications", views.PushNotificationViewSet, basename="push_notifications") + +urlpatterns = router.urls diff --git a/src/api/v1/push_notifications/views.py b/src/api/v1/push_notifications/views.py new file mode 100644 index 0000000..4ca7638 --- /dev/null +++ b/src/api/v1/push_notifications/views.py @@ -0,0 +1,69 @@ +from rest_framework.decorators import action +from rest_framework.generics import get_object_or_404 +from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request +from rest_framework.response import Response + +from app.drf.openapi import limit_offset_openapi_schema, openapi_schema +from app.drf.viewsets import AppViewSet +from push_notifications import selectors, services + +from . import schemas + + +class PushNotificationViewSet(AppViewSet): + permission_classes = [IsAuthenticated] + + @limit_offset_openapi_schema( + wrapped_schema=schemas.PushNotificationOutputSchema, + operation_id="push-notification-list", + summary="Notifications list", + description="Returns a list of notifications", + request=None, + tags=["push notifications"], + add_unauthorized_response=True, + parameter_serializer=schemas.PushNotificationInputSchema, + ) + def list(self, request: Request) -> Response: + params = self.get_valid_query_params(srlzr_class=schemas.PushNotificationInputSchema) + qs = selectors.push_notification_get_viewable_qs(user=request.user, filters=params) + return self.get_paginated_response( + queryset=qs, srlzr_class=schemas.PushNotificationOutputSchema + ) + + @openapi_schema( + summary="Read many notifications", + description="Reads a list of notifications", + request=schemas.PushNotificationReadManyInputSchema, + responses={200: schemas.PushNotificationReadManyOutputSchema}, + tags=["push notifications"], + operation_id="push-notifications-read", + add_bad_request_response=True, + add_unauthorized_response=True, + ) + @action(methods=["PATCH"], detail=False, url_path="read") + def read_many(self, request: Request) -> Response: + data = self.get_valid_data(srlzr_class=schemas.PushNotificationReadManyInputSchema) + updated = services.push_notification_read_many(reader=request.user, ids=data.get("ids")) + return Response(data={"read": updated}) + + @openapi_schema( + summary="Read notification", + description="Reads a single notification", + request=None, + responses={200: schemas.PushNotificationOutputSchema}, + tags=["push notifications"], + operation_id="push-notification-read", + add_not_found_response=True, + add_bad_request_response=True, + add_unauthorized_response=True, + ) + @action(methods=["PATCH"], detail=True) + def read(self, request: Request, id: int) -> Response: + notification = get_object_or_404( + selectors.push_notification_get_viewable_qs(user=request.user), + pk=id, + ) + notification = services.push_notification_read(push_notification=notification) + out_srlzr = schemas.PushNotificationOutputSchema(instance=notification) + return Response(data=out_srlzr.data) diff --git a/src/api/v1/urls.py b/src/api/v1/urls.py new file mode 100644 index 0000000..aaca614 --- /dev/null +++ b/src/api/v1/urls.py @@ -0,0 +1,10 @@ +from django.urls import include, path + +from . import push_notifications + +urlpatterns = [ + path( + "notifications/", + include((push_notifications.urls, "push_notifications"), namespace="push_notifications"), + ), +] diff --git a/src/app/settings/conf.py b/src/app/settings/conf.py index 2ecb59a..0b4ef58 100644 --- a/src/app/settings/conf.py +++ b/src/app/settings/conf.py @@ -1,6 +1,6 @@ import os from pathlib import Path -from typing import Any, Dict, List, Tuple +from typing import Any, Dict, List import environ from django.utils.translation import gettext_lazy as _ diff --git a/src/app/urls.py b/src/app/urls.py index a816661..2f2edca 100644 --- a/src/app/urls.py +++ b/src/app/urls.py @@ -40,4 +40,5 @@ path("api/schema/", auth_decorators.login_required(schema_view), name="schema"), path("api/docs/", auth_decorators.login_required(swagger_view), name="docs"), path("api/redoc/", auth_decorators.login_required(redoc_view), name="redoc"), + path("api/", include("api.urls", namespace="api")), ] diff --git a/src/push_notifications/services.py b/src/push_notifications/services.py index 319c1a2..51733ab 100644 --- a/src/push_notifications/services.py +++ b/src/push_notifications/services.py @@ -14,7 +14,7 @@ from app.models import BaseModel from users.models import User -from . import models +from . import models, selectors def push_notification_create( @@ -31,8 +31,8 @@ def push_notification_create( push_notification = models.PushNotification( user=user, kind=kind, - title=title, - description=description, + title=title[: consts.push_notification.MaxSize.TITLE_MAX_SIZE_ANDROID], + description=description[: consts.push_notification.MaxSize.DESCRIPTION_MAX_SIZE_ANDROID], data=data, status=consts.push_notification.Status.CREATED, source_object=source_object, @@ -229,3 +229,13 @@ def push_notification_read( push_notification.status = consts.push_notification.Status.READ push_notification.save() return push_notification + + +def push_notification_read_many(*, reader: User, ids: Sequence[int]) -> int: + """Reads a sequence of ids visible for the given `reader`. If `ids` is empty + reads all unread notifications. + Returns the updated notification count""" + qs = selectors.push_notification_get_viewable_qs(user=reader).filter(read_at__isnull=True) + if ids: + qs = qs.filter(pk__in=ids) + return qs.update(read_at=timezone.now(), status=consts.push_notification.Status.READ) diff --git a/src/push_notifications/tasks.py b/src/push_notifications/tasks.py index ac7e77c..20e6372 100644 --- a/src/push_notifications/tasks.py +++ b/src/push_notifications/tasks.py @@ -5,6 +5,12 @@ from . import models, services +@task() +def push_notification_send(notification_ids: list[int]): + notifications = models.PushNotification.objects.filter(id__in=notification_ids) + services.push_notification_send(notifications=notifications) + + @task() def push_notification_handle_delivery_failure(notification_id: int): notification = models.PushNotification.objects.get(pk=notification_id)