diff --git a/config/urls.py b/config/urls.py index 16d3a19..3ea525e 100644 --- a/config/urls.py +++ b/config/urls.py @@ -46,6 +46,10 @@ "", include("safe_locking_service.locking_events.urls", namespace="locking_events"), ), + path( + "", + include("safe_locking_service.campaigns.urls", namespace="locking_campaigns"), + ), ] urlpatterns = swagger_urlpatterns + [ diff --git a/safe_locking_service/campaigns/serializers.py b/safe_locking_service/campaigns/serializers.py new file mode 100644 index 0000000..96e2ace --- /dev/null +++ b/safe_locking_service/campaigns/serializers.py @@ -0,0 +1,27 @@ +from rest_framework import serializers + +from safe_locking_service.campaigns.models import Campaign + + +class ActivityMetadataSerializer(serializers.Serializer): + name = serializers.CharField() + description = serializers.CharField() + max_points = serializers.IntegerField() + + +class CampaignSerializer(serializers.Serializer): + resource_id = serializers.SerializerMethodField() + name = serializers.CharField() + description = serializers.CharField() + start_date = serializers.DateTimeField() + end_date = serializers.DateTimeField() + last_updated = serializers.SerializerMethodField() + activities_metadata = ActivityMetadataSerializer( + many=True, source="activity_metadata" + ) + + def get_resource_id(self, obj: Campaign): + return obj.uuid + + def get_last_updated(self, obj: Campaign): + return obj.last_updated diff --git a/safe_locking_service/campaigns/tests/factories.py b/safe_locking_service/campaigns/tests/factories.py index 2577c07..eed7fb0 100644 --- a/safe_locking_service/campaigns/tests/factories.py +++ b/safe_locking_service/campaigns/tests/factories.py @@ -1,14 +1,35 @@ -import factory +from django.utils import timezone + +from factory import Faker, LazyFunction, SubFactory from factory.django import DjangoModelFactory -from ..models import Campaign +from ..models import ActivityMetadata, Campaign, Period class CampaignFactory(DjangoModelFactory): class Meta: model = Campaign - name = factory.Faker("catch_phrase") - description = factory.Faker("bs") - start_date = factory.Faker("date_time") - end_date = factory.Faker("date_time") + name = Faker("catch_phrase") + description = Faker("bs") + start_date = LazyFunction(timezone.now) + end_date = LazyFunction(timezone.now) + + +class PeriodFactory(DjangoModelFactory): + class Meta: + model = Period + + campaign = SubFactory(CampaignFactory) + start_date = LazyFunction(lambda: timezone.now().date()) + end_date = LazyFunction(lambda: timezone.now().date()) + + +class ActivityMetadataFactory(DjangoModelFactory): + class Meta: + model = ActivityMetadata + + campaign = SubFactory(CampaignFactory) + name = Faker("catch_phrase") + description = Faker("bs") + max_points = Faker("pyint") diff --git a/safe_locking_service/campaigns/tests/test_views.py b/safe_locking_service/campaigns/tests/test_views.py new file mode 100644 index 0000000..714bda7 --- /dev/null +++ b/safe_locking_service/campaigns/tests/test_views.py @@ -0,0 +1,163 @@ +import uuid +from datetime import timedelta + +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone + +from rest_framework import status + +from ...campaigns.tests.factories import ( + ActivityMetadataFactory, + CampaignFactory, + PeriodFactory, +) +from ...utils.timestamp_helper import get_formated_timestamp + + +class TestCampaignViews(TestCase): + def test_empty_campaigns_view(self): + url = reverse("v1:locking_campaigns:list-campaigns") + response = self.client.get(url, format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + # No campaigns + response_json = response.json() + self.assertEqual(response_json["count"], 0) + + def test_campaign_view(self): + url = reverse("v1:locking_campaigns:list-campaigns") + # Add campaign, one activity and 2 periods + campaign_expected = CampaignFactory() + activity = ActivityMetadataFactory(campaign=campaign_expected) + previous_day = timezone.now().date() - timedelta(days=1) + PeriodFactory( + campaign=campaign_expected, start_date=previous_day, end_date=previous_day + ) + period_last = PeriodFactory(campaign=campaign_expected) + response = self.client.get(url, format="json") + response_json = response.json() + self.assertEqual(len(response_json["results"]), 1) + campaign_response = response_json["results"][0] + self.assertEqual( + campaign_response.get("resourceId"), str(campaign_expected.uuid) + ) + self.assertEqual(campaign_response.get("name"), campaign_expected.name) + self.assertEqual( + campaign_response.get("description"), campaign_expected.description + ) + self.assertEqual( + campaign_response.get("startDate"), + get_formated_timestamp(campaign_expected.start_date), + ) + self.assertEqual( + campaign_response.get("endDate"), + get_formated_timestamp(campaign_expected.end_date), + ) + + # LastUpdated should be the end_date of the last period + self.assertEqual( + campaign_response.get("lastUpdated"), + get_formated_timestamp(period_last.end_date), + ) + self.assertEqual(len(campaign_response.get("activitiesMetadata")), 1) + self.assertEqual( + campaign_response.get("activitiesMetadata")[0]["name"], activity.name + ) + + def test_no_activities_periods_campaigns_view(self): + # Add a campaign without activities and without period + url = reverse("v1:locking_campaigns:list-campaigns") + campaign_expected = CampaignFactory() + response = self.client.get(url, format="json") + response_json = response.json() + self.assertEqual(len(response_json["results"]), 1) + campaign_response = response_json["results"][0] + self.assertEqual( + campaign_response.get("resourceId"), str(campaign_expected.uuid) + ) + self.assertEqual(campaign_response.get("name"), campaign_expected.name) + self.assertEqual( + campaign_response.get("description"), campaign_expected.description + ) + self.assertEqual( + campaign_response.get("startDate"), + get_formated_timestamp(campaign_expected.start_date), + ) + self.assertEqual( + campaign_response.get("endDate"), + get_formated_timestamp(campaign_expected.end_date), + ) + self.assertIsNone(campaign_response.get("lastUpdated")) + self.assertIsInstance(campaign_response.get("activitiesMetadata"), list) + self.assertEqual(len(campaign_response.get("activitiesMetadata")), 0) + + def test_sort_campaign_view(self): + url = reverse("v1:locking_campaigns:list-campaigns") + previous_day = timezone.now().date() - timedelta(days=1) + CampaignFactory(start_date=previous_day, end_date=previous_day) + # Last campaign should be at the beginning + last_campaign = CampaignFactory() + response = self.client.get(url, format="json") + response_json = response.json() + self.assertEqual(len(response_json["results"]), 2) + campaign_response = response_json["results"][0] + self.assertEqual(campaign_response.get("resourceId"), str(last_campaign.uuid)) + self.assertEqual(campaign_response.get("name"), last_campaign.name) + self.assertEqual( + campaign_response.get("description"), last_campaign.description + ) + self.assertEqual( + campaign_response.get("startDate"), + get_formated_timestamp(last_campaign.start_date), + ) + self.assertEqual( + campaign_response.get("endDate"), + get_formated_timestamp(last_campaign.end_date), + ) + self.assertIsNone(campaign_response.get("lastUpdated")) + self.assertIsInstance(campaign_response.get("activitiesMetadata"), list) + self.assertEqual(len(campaign_response.get("activitiesMetadata")), 0) + + def test_retrieve_campaign_view(self): + resource_id = uuid.uuid4() + response = self.client.get( + reverse("v1:locking_campaigns:retrieve-campaign", args=(resource_id,)), + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + campaign_expected = CampaignFactory() + activity_expected = ActivityMetadataFactory(campaign=campaign_expected) + period_expected = PeriodFactory(campaign=campaign_expected) + resource_id = campaign_expected.uuid + + response = self.client.get( + reverse("v1:locking_campaigns:retrieve-campaign", args=(resource_id,)), + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + campaign_response = response.json() + self.assertEqual( + campaign_response.get("resourceId"), str(campaign_expected.uuid) + ) + self.assertEqual(campaign_response.get("name"), campaign_expected.name) + self.assertEqual( + campaign_response.get("description"), campaign_expected.description + ) + self.assertEqual( + campaign_response.get("startDate"), + get_formated_timestamp(campaign_expected.start_date), + ) + self.assertEqual( + campaign_response.get("endDate"), + get_formated_timestamp(campaign_expected.end_date), + ) + self.assertEqual( + campaign_response.get("lastUpdated"), + get_formated_timestamp(period_expected.end_date), + ) + self.assertEqual(len(campaign_response.get("activitiesMetadata")), 1) + self.assertEqual( + campaign_response.get("activitiesMetadata")[0]["name"], + activity_expected.name, + ) diff --git a/safe_locking_service/campaigns/urls.py b/safe_locking_service/campaigns/urls.py new file mode 100644 index 0000000..4031208 --- /dev/null +++ b/safe_locking_service/campaigns/urls.py @@ -0,0 +1,14 @@ +from django.urls import path + +from . import views + +app_name = "campaigns" + +urlpatterns = [ + path("campaigns/", views.CampaignsView.as_view(), name="list-campaigns"), + path( + "campaigns//", + views.RetrieveCampaignView.as_view(), + name="retrieve-campaign", + ), +] diff --git a/safe_locking_service/campaigns/views.py b/safe_locking_service/campaigns/views.py new file mode 100644 index 0000000..ec8dc61 --- /dev/null +++ b/safe_locking_service/campaigns/views.py @@ -0,0 +1,62 @@ +from django.db.models import Max +from django.utils.decorators import method_decorator +from django.views.decorators.cache import cache_page + +from rest_framework import status +from rest_framework.generics import ListAPIView, RetrieveAPIView +from rest_framework.response import Response + +from safe_locking_service.campaigns.serializers import CampaignSerializer +from safe_locking_service.locking_events.pagination import SmallPagination + +from .models import Campaign + + +class CampaignsView(ListAPIView): + """ + Returns a paginated list of campaigns. + """ + + pagination_class = SmallPagination + serializer_class = CampaignSerializer + + def get_queryset(self): + return ( + Campaign.objects.prefetch_related("activity_metadata") + .annotate(last_updated=Max("periods__end_date")) + .order_by("-start_date") + ) + + @method_decorator(cache_page(1 * 60)) # 1 minute + def list(self, request, *args, **kwargs): + queryset = self.get_queryset() + serializer = self.serializer_class(queryset, many=True) + paginated_data = self.paginate_queryset(serializer.data) + return self.get_paginated_response(paginated_data) + + +class RetrieveCampaignView(RetrieveAPIView): + """ + Returns a campaign for the provided campaign_id. + """ + + serializer_class = CampaignSerializer + + def get_queryset(self, resource_id: int): + return ( + Campaign.objects.filter(uuid=resource_id) + .prefetch_related("activity_metadata") + .annotate(last_updated=Max("periods__end_date")) + ) + + @method_decorator(cache_page(1 * 60)) # 1 minute + def get(self, request, *args, **kwargs): + resource_id = kwargs["resource_id"] + queryset = self.get_queryset(resource_id) + if not queryset: + return Response( + status=status.HTTP_404_NOT_FOUND, + ) + + serializer = self.serializer_class(queryset[0]) + return Response(status=status.HTTP_200_OK, data=serializer.data) diff --git a/safe_locking_service/locking_events/models.py b/safe_locking_service/locking_events/models.py index 61aa2e7..24bf248 100644 --- a/safe_locking_service/locking_events/models.py +++ b/safe_locking_service/locking_events/models.py @@ -16,6 +16,8 @@ Uint96Field, ) +from safe_locking_service.utils.timestamp_helper import get_formated_timestamp + class LeaderBoardRow(TypedDict): position: int @@ -101,7 +103,7 @@ def get_serialized_timestamp(self) -> str: :return: serialized timestamp """ - return self.timestamp.isoformat().replace("+00:00", "Z") + return get_formated_timestamp(self.timestamp) class Meta: abstract = True diff --git a/safe_locking_service/utils/timestamp_helper.py b/safe_locking_service/utils/timestamp_helper.py new file mode 100644 index 0000000..25c668e --- /dev/null +++ b/safe_locking_service/utils/timestamp_helper.py @@ -0,0 +1,10 @@ +import datetime + + +def get_formated_timestamp(timestamp: datetime): + """ + + :param timestamp: + :return: return formatted timestamp YYYY-MM-DDTHH:MM:SSZ + """ + return timestamp.isoformat().replace("+00:00", "Z")