diff --git a/blitz_api/factories.py b/blitz_api/factories.py index 8714d2e1..95aa729c 100644 --- a/blitz_api/factories.py +++ b/blitz_api/factories.py @@ -24,6 +24,12 @@ OrderLineBaseProduct, Coupon, ) +from workplace.models import ( + TimeSlot, + Reservation as TimeSlotReservation, + Workplace, + Period, +) from faker import Faker User = get_user_model() @@ -182,3 +188,47 @@ class Meta: end_time = "2020-01-06T15:11:06-05:00" max_use = 100 max_use_per_user = 2 + + +class WorkplaceFactory(DjangoModelFactory): + class Meta: + model = Workplace + + name = factory.Sequence(lambda n: f'Blitz {n}') + seats = factory.fuzzy.FuzzyInteger(0) + details = "short_description" + address_line1 = "123 random street" + postal_code = "123 456" + state_province = "Random state" + country = "Random country" + + +class PeriodFactory(DjangoModelFactory): + class Meta: + model = Period + + name = factory.Sequence(lambda n: f'Period {n}') + workplace = factory.SubFactory(WorkplaceFactory) + start_date = datetime(2130, 1, 1, 1) + end_date = datetime(2130, 12, 12, 12) + price = factory.fuzzy.FuzzyDecimal(0, 1000, 2) + is_active = True + + +class TimeSlotFactory(DjangoModelFactory): + class Meta: + model = TimeSlot + + period = factory.SubFactory(PeriodFactory) + price = factory.fuzzy.FuzzyDecimal(0, 1000, 2) + start_time = datetime(2130, 1, 15, 8) + end_time = datetime(2130, 1, 15, 12) + + +class TimeSlotReservationFactory(DjangoModelFactory): + class Meta: + model = TimeSlotReservation + + user = factory.SubFactory(UserFactory) + timeslot = factory.SubFactory(TimeSlotFactory) + is_active = True diff --git a/blitz_api/models.py b/blitz_api/models.py index 0259a3df..168c0ed0 100644 --- a/blitz_api/models.py +++ b/blitz_api/models.py @@ -202,98 +202,72 @@ def send_new_activation_email(self): ) def get_number_of_past_tomatoes(self): - timeslots = self.get_nb_tomatoes_timeslot() - virtual_retreats = self.get_nb_tomatoes_virtual_retreat() - physical_retreats = self.get_nb_tomatoes_physical_retreat() - - past_count = timeslots['past'] + \ - virtual_retreats['past'] + \ - physical_retreats['past'] - - custom_tomatoes = Tomato.objects.filter( + today = timezone.now() + nb_tomatoes = Tomato.objects.filter( user=self, - ).aggregate( - Sum('number_of_tomato'), - ) - - if custom_tomatoes['number_of_tomato__sum'] is not None: - past_count += custom_tomatoes['number_of_tomato__sum'] + acquisition_date__lte=today + ).aggregate(Sum('number_of_tomato'))['number_of_tomato__sum'] + nb_tomatoes = nb_tomatoes if nb_tomatoes else 0 - return past_count + return nb_tomatoes def get_number_of_future_tomatoes(self): timeslots = self.get_nb_tomatoes_timeslot() - virtual_retreats = self.get_nb_tomatoes_virtual_retreat() - physical_retreats = self.get_nb_tomatoes_physical_retreat() + retreats = self.get_nb_tomatoes_retreat() - future_count = timeslots['future'] + \ - virtual_retreats['future'] + \ - physical_retreats['future'] + future_count = timeslots['future'] + retreats['future'] return future_count def get_nb_tomatoes_timeslot(self): from workplace.models import Reservation as TimeslotReservation + today = timezone.now() + nb_tomatoes = Tomato.objects.filter( + user=self, + source=Tomato.TOMATO_SOURCE_TIMESLOT, + acquisition_date__lte=today + ).aggregate(Sum('number_of_tomato'))['number_of_tomato__sum'] + past_count = nb_tomatoes if nb_tomatoes else 0 reservations = TimeslotReservation.objects.filter( user=self, is_active=True, ) - - past_count = 0 future_count = 0 - for reservation in reservations: - if reservation.timeslot.end_time < timezone.now(): - past_count += settings.NUMBER_OF_TOMATOES_TIMESLOT - else: - future_count += settings.NUMBER_OF_TOMATOES_TIMESLOT + if reservation.timeslot.end_time >= timezone.now(): + future_count += reservation.timeslot.number_of_tomatoes return { 'past': past_count, 'future': future_count, } - def get_nb_tomatoes_virtual_retreat(self): - from retirement.models import Reservation as RetreatReservation - - reservations = RetreatReservation.objects.filter( - user=self, - is_active=True, - retreat__type__is_virtual=True, + def get_nb_tomatoes_retreat(self): + from retirement.models import ( + Reservation as RetreatReservation, + RetreatDate, ) - - past_count = 0 - future_count = 0 - - for reservation in reservations: - if reservation.retreat.end_time < timezone.now(): - past_count += reservation.retreat.get_number_of_tomatoes() - else: - future_count += reservation.retreat.get_number_of_tomatoes() - - return { - 'past': past_count, - 'future': future_count, - } - - def get_nb_tomatoes_physical_retreat(self): - from retirement.models import Reservation as RetreatReservation + today = timezone.now() + nb_tomatoes = Tomato.objects.filter( + user=self, + source=Tomato.TOMATO_SOURCE_RETREAT, + acquisition_date__lte=today + ).aggregate(Sum('number_of_tomato'))['number_of_tomato__sum'] + past_count = nb_tomatoes if nb_tomatoes else 0 reservations = RetreatReservation.objects.filter( user=self, is_active=True, - retreat__type__is_virtual=False, ) - - past_count = 0 + future_dates = RetreatDate.objects.filter( + end_time__gte=today, + tomatoes_assigned=False, + retreat__reservations__in=reservations, + ) future_count = 0 - - for reservation in reservations: - if reservation.retreat.end_time < timezone.now(): - past_count += reservation.retreat.get_number_of_tomatoes() - else: - future_count += reservation.retreat.get_number_of_tomatoes() + for date in future_dates: + future_count += date.number_of_tomatoes return { 'past': past_count, diff --git a/blitz_api/tests/tests_model_User.py b/blitz_api/tests/tests_model_User.py index 73140bad..63da2ed8 100644 --- a/blitz_api/tests/tests_model_User.py +++ b/blitz_api/tests/tests_model_User.py @@ -1,9 +1,18 @@ from django.utils import timezone +from datetime import datetime from rest_framework.test import APITestCase from blitz_api.factories import UserFactory from tomato.factories import TomatoFactory from tomato.models import Tomato +from blitz_api.factories import ( + RetreatFactory, + RetreatTypeFactory, + RetreatDateFactory, + ReservationFactory, + TimeSlotFactory, + TimeSlotReservationFactory, +) class UserTests(APITestCase): @@ -69,3 +78,224 @@ def test_property_current_month_tomatoes(self): ) self.assertEqual(user3.current_month_tomatoes, 0) + + def test_get_nb_tomatoes_retreat(self): + """ + Ensure we get the correct value for property get_nb_tomatoes_retreat + """ + today = timezone.now() + user1 = UserFactory() + user2 = UserFactory() + type = RetreatTypeFactory() + r = RetreatFactory( + number_of_tomatoes=10, + type=type, + display_start_time=datetime(1990, 1, 15, 8) + ) + date = RetreatDateFactory(retreat=r) + resa = ReservationFactory( + retreat=r, + user=user1, + is_active=True + ) + r2 = RetreatFactory( + number_of_tomatoes=30, + type=type, + display_start_time=datetime(1990, 1, 15, 8) + ) + date2 = RetreatDateFactory(retreat=r2) + resa2 = ReservationFactory( + retreat=r2, + user=user1, + is_active=True + ) + r3 = RetreatFactory( + number_of_tomatoes=15, + type=type, + display_start_time=datetime(1990, 1, 15, 8) + ) + date3 = RetreatDateFactory( + retreat=r3, + start_time=datetime(1990, 1, 15, 8), + end_time=datetime(1990, 1, 17, 12) + ) + resa3 = ReservationFactory( + retreat=r3, + user=user1, + is_active=True + ) + t1_1 = TomatoFactory( + user=user1, + number_of_tomato=r3.number_of_tomatoes, + acquisition_date=today, + source=Tomato.TOMATO_SOURCE_RETREAT) + t1_2 = TomatoFactory( + user=user1, + acquisition_date=today, + source=Tomato.TOMATO_SOURCE_CHRONO) + t1_3 = TomatoFactory( + user=user1, + number_of_tomato=12.5, + acquisition_date=today, + source=Tomato.TOMATO_SOURCE_MANUAL) + t1_4 = TomatoFactory( + user=user1, + acquisition_date=today, + source=Tomato.TOMATO_SOURCE_TIMESLOT) + TomatoFactory( + user=user1, + acquisition_date=today - timezone.timedelta(days=31), + source=Tomato.TOMATO_SOURCE_TIMESLOT) + TomatoFactory( + user=user1, + acquisition_date=today + timezone.timedelta(days=31), + source=Tomato.TOMATO_SOURCE_CHRONO) + self.assertEqual( + user1.get_nb_tomatoes_retreat(), + { + 'past': t1_1.number_of_tomato, + 'future': r.number_of_tomatoes + r2.number_of_tomatoes, + }) + self.assertEqual( + user2.get_nb_tomatoes_retreat(), + { + 'past': 0, + 'future': 0, + }) + + def test_get_nb_tomatoes_timeslot(self): + """ + Ensure we get the correct value for property get_nb_tomatoes_timeslot + """ + today = timezone.now() + user1 = UserFactory() + user2 = UserFactory() + user3 = UserFactory() + t1_1 = TomatoFactory( + user=user1, + number_of_tomato=15, + acquisition_date=today, + source=Tomato.TOMATO_SOURCE_RETREAT) + t1_2 = TomatoFactory( + user=user1, + acquisition_date=today, + source=Tomato.TOMATO_SOURCE_CHRONO) + t1_3 = TomatoFactory( + user=user1, + number_of_tomato=12.5, + acquisition_date=today, + source=Tomato.TOMATO_SOURCE_MANUAL) + t1_4 = TomatoFactory( + user=user1, + acquisition_date=today, + source=Tomato.TOMATO_SOURCE_TIMESLOT) + t1_5 = TomatoFactory( + user=user1, + acquisition_date=today - timezone.timedelta(days=31), + source=Tomato.TOMATO_SOURCE_TIMESLOT) + TomatoFactory( + user=user1, + acquisition_date=today + timezone.timedelta(days=31), + source=Tomato.TOMATO_SOURCE_CHRONO) + + TomatoFactory( + user=user2, + acquisition_date=today - timezone.timedelta(days=31), + source=Tomato.TOMATO_SOURCE_TIMESLOT) + TomatoFactory( + user=user2, + acquisition_date=today + timezone.timedelta(days=31), + source=Tomato.TOMATO_SOURCE_CHRONO) + TomatoFactory( + user=user2, + acquisition_date=today, + source=Tomato.TOMATO_SOURCE_MANUAL) + TomatoFactory( + user=user2, + acquisition_date=today, + source=Tomato.TOMATO_SOURCE_TIMESLOT) + + t1 = TimeSlotFactory() + r1 = TimeSlotReservationFactory(timeslot=t1, user=user1) + + t2 = TimeSlotFactory() + r2 = TimeSlotReservationFactory( + timeslot=t2, is_active=False, user=user1) + + t3 = TimeSlotFactory( + start_time=datetime(1990, 1, 15, 8), + end_time=datetime(1990, 1, 16, 8)) + r3 = TimeSlotReservationFactory(timeslot=t3, user=user1) + + self.assertEqual( + user1.get_nb_tomatoes_timeslot(), + { + 'past': t1_4.number_of_tomato + t1_5.number_of_tomato, + 'future': t1.number_of_tomatoes, + }) + self.assertEqual( + user3.get_nb_tomatoes_timeslot(), + { + 'past': 0, + 'future': 0, + }) + + def test_get_number_of_past_tomatoes(self): + """ + Ensure we get the correct value for property + get_number_of_past_tomatoes + """ + today = timezone.now() + user1 = UserFactory() + user2 = UserFactory() + user3 = UserFactory() + t1_1 = TomatoFactory( + user=user1, + number_of_tomato=15, + acquisition_date=today, + source=Tomato.TOMATO_SOURCE_RETREAT) + t1_2 = TomatoFactory( + user=user1, + acquisition_date=today, + source=Tomato.TOMATO_SOURCE_CHRONO) + t1_3 = TomatoFactory( + user=user1, + number_of_tomato=12.5, + acquisition_date=today, + source=Tomato.TOMATO_SOURCE_MANUAL) + t1_4 = TomatoFactory( + user=user1, + acquisition_date=today - timezone.timedelta(days=31), + source=Tomato.TOMATO_SOURCE_TIMESLOT) + TomatoFactory( + user=user1, + acquisition_date=today + timezone.timedelta(days=31), + source=Tomato.TOMATO_SOURCE_CHRONO) + + TomatoFactory( + user=user2, + acquisition_date=today - timezone.timedelta(days=31), + source=Tomato.TOMATO_SOURCE_TIMESLOT) + TomatoFactory( + user=user2, + acquisition_date=today + timezone.timedelta(days=31), + source=Tomato.TOMATO_SOURCE_CHRONO) + TomatoFactory( + user=user2, + acquisition_date=today, + source=Tomato.TOMATO_SOURCE_MANUAL) + TomatoFactory( + user=user2, + acquisition_date=today, + source=Tomato.TOMATO_SOURCE_TIMESLOT) + self.assertEqual( + # convert float to avoid Decimal(x), handled by serializer + user1.get_number_of_past_tomatoes(), + sum([ + t1_1.number_of_tomato, + t1_2.number_of_tomato, + t1_3.number_of_tomato, + t1_4.number_of_tomato]) + ) + + self.assertEqual(user3.get_number_of_past_tomatoes(), 0) diff --git a/retirement/models.py b/retirement/models.py index 1a66dd35..28f25f8b 100644 --- a/retirement/models.py +++ b/retirement/models.py @@ -989,6 +989,16 @@ def get_participants_emails(self): participant_emails.add(reservation.user.email) return list(participant_emails) + def get_participants(self): + """ + Return a list of active participants + """ + participants = set() + active_reservations = self.reservations.filter(is_active=True) + for reservation in active_reservations: + participants.add(reservation.user) + return list(participants) + def process_impacted_users(self, reason, reason_message, force_refund): """ Notify and potentially refund user for a reason happening on retreat: @@ -997,11 +1007,13 @@ def process_impacted_users(self, reason, reason_message, force_refund): they can book again the retreat if they want """ if self.total_reservations > 0: + # retrieve active users before we cancel the reservation + users = self.get_participants() from .services import send_updated_retreat_email self.cancel_participants_reservation(force_refund) send_updated_retreat_email( self, - self.get_participants_emails(), + users, reason, reason_message, ) diff --git a/retirement/services.py b/retirement/services.py index 4597f149..e0106e2f 100644 --- a/retirement/services.py +++ b/retirement/services.py @@ -376,7 +376,7 @@ def send_automatic_email(user, retreat, email): return response_send_mail -def send_updated_retreat_email(retreat, users_emails, reason, reason_message): +def send_updated_retreat_email(retreat, users, reason, reason_message): """ This function sends an automatic email to notify all registered users of a retreat that it has been updated. For example dates have changed or @@ -393,7 +393,7 @@ def send_updated_retreat_email(retreat, users_emails, reason, reason_message): } response_send_mail = send_templated_email( - users_emails, + users, context, reason_template[reason] ) diff --git a/retirement/tests/tests_model_Retreat.py b/retirement/tests/tests_model_Retreat.py index 79428107..9059f2f9 100644 --- a/retirement/tests/tests_model_Retreat.py +++ b/retirement/tests/tests_model_Retreat.py @@ -752,3 +752,28 @@ def test_get_participants_emails(self): self.assertEqual(2, len(emails)) self.assertTrue(user.email in emails) self.assertTrue(user2.email in emails) + + def test_get_participants_participants(self): + user = UserFactory() + user2 = UserFactory() + user3 = UserFactory() + + Reservation.objects.create( + user=user, + retreat=self.retreat, + is_active=True, + ) + Reservation.objects.create( + user=user2, + retreat=self.retreat, + is_active=True, + ) + Reservation.objects.create( + user=user3, + retreat=self.retreat, + is_active=False, + ) + users = self.retreat.get_participants() + self.assertEqual(2, len(users)) + self.assertTrue(user in users) + self.assertTrue(user2 in users) diff --git a/retirement/tests/tests_viewset_Reservation.py b/retirement/tests/tests_viewset_Reservation.py index fa9d5841..639b0cc8 100644 --- a/retirement/tests/tests_viewset_Reservation.py +++ b/retirement/tests/tests_viewset_Reservation.py @@ -625,6 +625,62 @@ def test_list(self): self.check_attributes(content['results'][0]) + def test_list_ordering_by_retreat_name(self): + """ + Test that we can order the reservation list by retreat name + """ + self.client.force_authenticate(user=self.admin) + response = self.client.get( + reverse('retreat:reservation-list'), + { + 'ordering': 'retreat__name' + }, + format='json', + ) + + content = json.loads(response.content) + + self.assertEqual( + response.status_code, + status.HTTP_200_OK + ) + + self.assertEqual(content['count'], 3) + self.assertEqual( + content['results'][0]['retreat_details']['name'], + self.retreat.name + ) + self.assertEqual( + content['results'][2]['retreat_details']['name'], + self.retreat2.name + ) + + self.client.force_authenticate(user=self.admin) + response = self.client.get( + reverse('retreat:reservation-list'), + { + 'ordering': '-retreat__name' + }, + format='json', + ) + + content = json.loads(response.content) + + self.assertEqual( + response.status_code, + status.HTTP_200_OK + ) + + self.assertEqual(content['count'], 3) + self.assertEqual( + content['results'][0]['retreat_details']['name'], + self.retreat2.name + ) + self.assertEqual( + content['results'][2]['retreat_details']['name'], + self.retreat.name + ) + def test_list_as_non_admin(self): """ Ensure that a user can list its reservations. diff --git a/retirement/tests/tests_viewset_Retreat.py b/retirement/tests/tests_viewset_Retreat.py index 9e9e1865..e2eb5bf8 100644 --- a/retirement/tests/tests_viewset_Retreat.py +++ b/retirement/tests/tests_viewset_Retreat.py @@ -1111,7 +1111,7 @@ def test_delete_with_participants(self, mock_email, mock_cancel): mock_email.assert_called_once_with( self.retreat, - self.retreat.get_participants_emails(), + self.retreat.get_participants(), 'deletion', deletion_message ) diff --git a/retirement/tests/tests_viewset_RetreatDate.py b/retirement/tests/tests_viewset_RetreatDate.py index 4a14874e..27383674 100644 --- a/retirement/tests/tests_viewset_RetreatDate.py +++ b/retirement/tests/tests_viewset_RetreatDate.py @@ -204,7 +204,7 @@ def test_update_no_change_as_admin(self, mock_email, mock_cancel): mock_email.assert_called_once_with( self.retreat, - self.retreat.get_participants_emails(), + self.retreat.get_participants(), 'update', reason_message, ) @@ -254,7 +254,7 @@ def test_update_change_after_as_admin(self, mock_email, mock_cancel): mock_email.assert_called_once_with( self.retreat, - self.retreat.get_participants_emails(), + self.retreat.get_participants(), 'update', reason_message, ) @@ -304,7 +304,7 @@ def test_update_change_before_as_admin(self, mock_email, mock_cancel): mock_email.assert_called_once_with( self.retreat, - self.retreat.get_participants_emails(), + self.retreat.get_participants(), 'update', reason_message, ) @@ -374,7 +374,7 @@ def test_delete_no_change_as_admin(self, mock_email, mock_cancel): mock_email.assert_called_once_with( self.retreat, - self.retreat.get_participants_emails(), + self.retreat.get_participants(), 'update', reason_message, ) @@ -422,7 +422,7 @@ def test_delete_change_after_as_admin(self, mock_email, mock_cancel): mock_email.assert_called_once_with( self.retreat, - self.retreat.get_participants_emails(), + self.retreat.get_participants(), 'update', reason_message, ) @@ -470,7 +470,7 @@ def test_delete_change_before_as_admin(self, mock_email, mock_cancel): mock_email.assert_called_once_with( self.retreat, - self.retreat.get_participants_emails(), + self.retreat.get_participants(), 'update', reason_message, ) diff --git a/retirement/views.py b/retirement/views.py index 11404de2..eb8f648f 100644 --- a/retirement/views.py +++ b/retirement/views.py @@ -581,6 +581,7 @@ class ReservationViewSet(ExportMixin, viewsets.ModelViewSet): 'cancelation_date', 'cancelation_reason', 'cancelation_action', + 'retreat__name', ) export_resource = ReservationResource() diff --git a/tomato/tests/tests_viewset_Tomato.py b/tomato/tests/tests_viewset_Tomato.py index 963dc517..3cd6881f 100644 --- a/tomato/tests/tests_viewset_Tomato.py +++ b/tomato/tests/tests_viewset_Tomato.py @@ -21,7 +21,7 @@ User = get_user_model() -class ReportTests(CustomAPITestCase): +class TomatoTests(CustomAPITestCase): ATTRIBUTES = [ 'id', @@ -36,7 +36,7 @@ class ReportTests(CustomAPITestCase): @classmethod def setUpClass(cls): - super(ReportTests, cls).setUpClass() + super(TomatoTests, cls).setUpClass() cls.client = APIClient() cls.user = UserFactory() @@ -484,3 +484,490 @@ def test_community_tomatoes(self): result['community_tomato'], sum(current_entries) ) + + def test_statistics_tomatoes_invalid_start(self): + """ + Test we cant get tomatoes statistics with invalid start + """ + self.client.force_authenticate(user=self.user) + response = self.client.get( + reverse('tomato-statistics'), + data={ + 'start_date': 'invalid start', + 'end_date': '2010-01-01T00:00:00Z' + }, + content_type='application/json', + ) + self.assertEqual( + response.status_code, + status.HTTP_400_BAD_REQUEST, + response.content + ) + + def test_statistics_tomatoes_invalid_end(self): + """ + Test we cant get tomatoes statistics with invalid end + """ + self.client.force_authenticate(user=self.user) + response = self.client.get( + reverse('tomato-statistics'), + data={ + 'end_date': 'invalid start', + 'start_date': '2010-01-01T00:00:00Z' + }, + format='json', + ) + self.assertEqual( + response.status_code, + status.HTTP_400_BAD_REQUEST, + response.content + ) + + def test_statistics_tomatoes_invalid_dates(self): + """ + Test we cant get tomatoes statistics with invalid dates + """ + self.client.force_authenticate(user=self.user) + response = self.client.get( + reverse('tomato-statistics'), + data={ + 'start_date': '2012-01-01T00:00:00Z', + 'end_date': '2010-01-01T00:00:00Z' + }, + format='json', + ) + self.assertEqual( + response.status_code, + status.HTTP_400_BAD_REQUEST, + response.content + ) + + def test_statistics_tomatoes_empty_data(self): + """ + Test we can get tomatoes statistics + even if there is no data at all + """ + today = timezone.datetime(2023, 2, 26, 0, 0, 0) + last_year = today - timedelta(days=366) + limit_last_year = last_year + timedelta(days=1) + + end = today.strftime('%Y-%m-%dT%H:%M:%SZ') + start = limit_last_year.strftime('%Y-%m-%dT%H:%M:%SZ') + self.client.force_authenticate(user=self.user) + response = self.client.get( + reverse('tomato-statistics'), + data={ + 'start_date': start, + 'end_date': end + }, + content_type='application/json', + ) + result = response.json() + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + response.content + ) + + self.assertEqual(result['totals']['global'], 0) + self.assertEqual(result['totals']['user'], 0) + self.assertEqual( + result['graph']['labels'], + [ + '2022-02-01T00:00:00', + '2022-03-01T00:00:00', + '2022-04-01T00:00:00', + '2022-05-01T00:00:00', + '2022-06-01T00:00:00', + '2022-07-01T00:00:00', + '2022-08-01T00:00:00', + '2022-09-01T00:00:00', + '2022-10-01T00:00:00', + '2022-11-01T00:00:00', + '2022-12-01T00:00:00', + '2023-01-01T00:00:00', + '2023-02-01T00:00:00', + ], + ) + self.assertEqual( + result['graph']['datasets'], + [ + { + 'label': 'number_of_tomato', + 'data': [ + {'x': '2022-02-01T00:00:00', 'y': 0.0}, + {'x': '2022-03-01T00:00:00', 'y': 0.0}, + {'x': '2022-04-01T00:00:00', 'y': 0.0}, + {'x': '2022-05-01T00:00:00', 'y': 0.0}, + {'x': '2022-06-01T00:00:00', 'y': 0.0}, + {'x': '2022-07-01T00:00:00', 'y': 0.0}, + {'x': '2022-08-01T00:00:00', 'y': 0.0}, + {'x': '2022-09-01T00:00:00', 'y': 0.0}, + {'x': '2022-10-01T00:00:00', 'y': 0.0}, + {'x': '2022-11-01T00:00:00', 'y': 0.0}, + {'x': '2022-12-01T00:00:00', 'y': 0.0}, + {'x': '2023-01-01T00:00:00', 'y': 0.0}, + {'x': '2023-02-01T00:00:00', 'y': 0.0}, + ] + } + ], + ) + + def test_statistics_tomatoes_year(self): + """ + Test we can get tomatoes statistics of current year + """ + today = timezone.datetime(2023, 2, 26, 0, 0, 0) + last_year = today - timedelta(days=366) + limit_last_year = last_year + timedelta(days=1) + + end = today.strftime('%Y-%m-%dT%H:%M:%SZ') + start = limit_last_year.strftime('%Y-%m-%dT%H:%M:%SZ') + self.client.force_authenticate(user=self.user) + + Tomato.objects.create(user=self.user, number_of_tomato=5) + Tomato.objects.create(user=self.admin, number_of_tomato=4) + Tomato.objects.create(user=self.user, number_of_tomato=7) + Tomato.objects.all().update(acquisition_date=last_year) + + t1 = Tomato.objects.create( + user=self.user, + number_of_tomato=15, + acquisition_date=today - timedelta(days=1), + ) + t2 = Tomato.objects.create( + user=self.admin, + number_of_tomato=23, + acquisition_date=today - timedelta(days=2), + ) + t3 = Tomato.objects.create( + user=self.user, + number_of_tomato=45, + acquisition_date=today - timedelta(days=3), + ) + current_entries = [ + t1.number_of_tomato, + t2.number_of_tomato, + t3.number_of_tomato, + ] + user_entries = [ + t1.number_of_tomato, + t3.number_of_tomato, + ] + + response = self.client.get( + reverse('tomato-statistics'), + data={ + 'start_date': start, + 'end_date': end + }, + content_type='application/json', + ) + result = response.json() + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + response.content + ) + + self.assertEqual(result['totals']['global'], sum(current_entries)) + self.assertEqual(result['totals']['user'], sum(user_entries)) + self.assertEqual( + result['graph']['labels'], + [ + '2022-02-01T00:00:00', + '2022-03-01T00:00:00', + '2022-04-01T00:00:00', + '2022-05-01T00:00:00', + '2022-06-01T00:00:00', + '2022-07-01T00:00:00', + '2022-08-01T00:00:00', + '2022-09-01T00:00:00', + '2022-10-01T00:00:00', + '2022-11-01T00:00:00', + '2022-12-01T00:00:00', + '2023-01-01T00:00:00', + '2023-02-01T00:00:00', + ], + ) + self.assertEqual( + result['graph']['datasets'], + [ + { + 'label': 'number_of_tomato', + 'data': [ + {'x': '2022-02-01T00:00:00', 'y': 0.0}, + {'x': '2022-03-01T00:00:00', 'y': 0.0}, + {'x': '2022-04-01T00:00:00', 'y': 0.0}, + {'x': '2022-05-01T00:00:00', 'y': 0.0}, + {'x': '2022-06-01T00:00:00', 'y': 0.0}, + {'x': '2022-07-01T00:00:00', 'y': 0.0}, + {'x': '2022-08-01T00:00:00', 'y': 0.0}, + {'x': '2022-09-01T00:00:00', 'y': 0.0}, + {'x': '2022-10-01T00:00:00', 'y': 0.0}, + {'x': '2022-11-01T00:00:00', 'y': 0.0}, + {'x': '2022-12-01T00:00:00', 'y': 0.0}, + {'x': '2023-01-01T00:00:00', 'y': 0.0}, + { + 'x': '2023-02-01T00:00:00', + 'y': 60.0 + }, + ] + } + ], + ) + + def test_statistics_tomatoes_month(self): + """ + Test we can get tomatoes statistics of current month + """ + today = timezone.datetime(2023, 2, 26, 0, 0, 0) + last_month = today - timedelta(days=32) + limit_last_month = last_month + timedelta(days=1) + + end = today.strftime('%Y-%m-%dT%H:%M:%SZ') + start = limit_last_month.strftime('%Y-%m-%dT%H:%M:%SZ') + self.client.force_authenticate(user=self.user) + + Tomato.objects.create(user=self.user, number_of_tomato=5) + Tomato.objects.create(user=self.admin, number_of_tomato=4) + Tomato.objects.create(user=self.user, number_of_tomato=7) + Tomato.objects.all().update(acquisition_date=last_month) + + t1 = Tomato.objects.create( + user=self.user, + number_of_tomato=15, + acquisition_date=today - timedelta(days=1), + ) + t2 = Tomato.objects.create( + user=self.admin, + number_of_tomato=23, + acquisition_date=today - timedelta(days=2), + ) + t3 = Tomato.objects.create( + user=self.user, + number_of_tomato=45, + acquisition_date=today - timedelta(days=3), + ) + current_entries = [ + t1.number_of_tomato, + t2.number_of_tomato, + t3.number_of_tomato, + ] + user_entries = [ + t1.number_of_tomato, + t3.number_of_tomato, + ] + + response = self.client.get( + reverse('tomato-statistics'), + data={ + 'start_date': start, + 'end_date': end + }, + content_type='application/json', + ) + result = response.json() + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + response.content + ) + + self.assertEqual(result['totals']['global'], sum(current_entries)) + self.assertEqual(result['totals']['user'], sum(user_entries)) + self.assertEqual( + result['graph']['labels'], + [ + '2023-01-25T00:00:00', + '2023-01-26T00:00:00', + '2023-01-27T00:00:00', + '2023-01-28T00:00:00', + '2023-01-29T00:00:00', + '2023-01-30T00:00:00', + '2023-01-31T00:00:00', + '2023-02-01T00:00:00', + '2023-02-02T00:00:00', + '2023-02-03T00:00:00', + '2023-02-04T00:00:00', + '2023-02-05T00:00:00', + '2023-02-06T00:00:00', + '2023-02-07T00:00:00', + '2023-02-08T00:00:00', + '2023-02-09T00:00:00', + '2023-02-10T00:00:00', + '2023-02-11T00:00:00', + '2023-02-12T00:00:00', + '2023-02-13T00:00:00', + '2023-02-14T00:00:00', + '2023-02-15T00:00:00', + '2023-02-16T00:00:00', + '2023-02-17T00:00:00', + '2023-02-18T00:00:00', + '2023-02-19T00:00:00', + '2023-02-20T00:00:00', + '2023-02-21T00:00:00', + '2023-02-22T00:00:00', + '2023-02-23T00:00:00', + '2023-02-24T00:00:00', + '2023-02-25T00:00:00', + ], + ) + self.assertEqual( + result['graph']['datasets'], + [ + { + 'label': 'number_of_tomato', + 'data': [ + {'x': '2023-01-25T00:00:00', 'y': 0.0}, + {'x': '2023-01-26T00:00:00', 'y': 0.0}, + {'x': '2023-01-27T00:00:00', 'y': 0.0}, + {'x': '2023-01-28T00:00:00', 'y': 0.0}, + {'x': '2023-01-29T00:00:00', 'y': 0.0}, + {'x': '2023-01-30T00:00:00', 'y': 0.0}, + {'x': '2023-01-31T00:00:00', 'y': 0.0}, + {'x': '2023-02-01T00:00:00', 'y': 0.0}, + {'x': '2023-02-02T00:00:00', 'y': 0.0}, + {'x': '2023-02-03T00:00:00', 'y': 0.0}, + {'x': '2023-02-04T00:00:00', 'y': 0.0}, + {'x': '2023-02-05T00:00:00', 'y': 0.0}, + {'x': '2023-02-06T00:00:00', 'y': 0.0}, + {'x': '2023-02-07T00:00:00', 'y': 0.0}, + {'x': '2023-02-08T00:00:00', 'y': 0.0}, + {'x': '2023-02-09T00:00:00', 'y': 0.0}, + {'x': '2023-02-10T00:00:00', 'y': 0.0}, + {'x': '2023-02-11T00:00:00', 'y': 0.0}, + {'x': '2023-02-12T00:00:00', 'y': 0.0}, + {'x': '2023-02-13T00:00:00', 'y': 0.0}, + {'x': '2023-02-14T00:00:00', 'y': 0.0}, + {'x': '2023-02-15T00:00:00', 'y': 0.0}, + {'x': '2023-02-16T00:00:00', 'y': 0.0}, + {'x': '2023-02-17T00:00:00', 'y': 0.0}, + {'x': '2023-02-18T00:00:00', 'y': 0.0}, + {'x': '2023-02-19T00:00:00', 'y': 0.0}, + {'x': '2023-02-20T00:00:00', 'y': 0.0}, + {'x': '2023-02-21T00:00:00', 'y': 0.0}, + {'x': '2023-02-22T00:00:00', 'y': 0.0}, + { + 'x': '2023-02-23T00:00:00', + 'y': 45.0 + }, + {'x': '2023-02-24T00:00:00', 'y': 0.0}, + { + 'x': '2023-02-25T00:00:00', + 'y': 15.0 + } + ] + } + ], + ) + + def test_statistics_tomatoes_week(self): + """ + Test we can get tomatoes statistics of current week + """ + today = timezone.datetime(2023, 2, 26, 0, 0, 0) + last_week = today - timedelta(days=7) + limit_last_week = last_week + timedelta(days=1) + + end = today.strftime('%Y-%m-%dT%H:%M:%SZ') + start = limit_last_week.strftime('%Y-%m-%dT%H:%M:%SZ') + self.client.force_authenticate(user=self.user) + + Tomato.objects.create(user=self.user, number_of_tomato=5) + Tomato.objects.create(user=self.admin, number_of_tomato=4) + Tomato.objects.create(user=self.user, number_of_tomato=7) + Tomato.objects.all().update(acquisition_date=last_week) + + t1 = Tomato.objects.create( + user=self.user, + number_of_tomato=15, + acquisition_date=today - timedelta(days=1), + ) + t2 = Tomato.objects.create( + user=self.admin, + number_of_tomato=23, + acquisition_date=today - timedelta(days=2), + ) + t3 = Tomato.objects.create( + user=self.user, + number_of_tomato=45, + acquisition_date=today - timedelta(days=3), + ) + current_entries = [ + t1.number_of_tomato, + t2.number_of_tomato, + t3.number_of_tomato, + ] + user_entries = [ + t1.number_of_tomato, + t3.number_of_tomato, + ] + + response = self.client.get( + reverse('tomato-statistics'), + data={ + 'start_date': start, + 'end_date': end + }, + content_type='application/json', + ) + result = response.json() + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + response.content + ) + + self.assertEqual(result['totals']['global'], sum(current_entries)) + self.assertEqual(result['totals']['user'], sum(user_entries)) + self.assertEqual( + result['graph']['labels'], + [ + '2023-02-19T00:00:00', + '2023-02-20T00:00:00', + '2023-02-21T00:00:00', + '2023-02-22T00:00:00', + '2023-02-23T00:00:00', + '2023-02-24T00:00:00', + '2023-02-25T00:00:00', + ], + ) + self.assertEqual( + result['graph']['datasets'], + [ + { + 'label': 'number_of_tomato', + 'data': [ + { + 'x': '2023-02-19T00:00:00', + 'y': 0.0, + }, + { + 'x': '2023-02-20T00:00:00', + 'y': 0.0, + }, + { + 'x': '2023-02-21T00:00:00', + 'y': 0.0, + }, + { + 'x': '2023-02-22T00:00:00', + 'y': 0.0, + }, + { + 'x': '2023-02-23T00:00:00', + 'y': 45.0 + }, + { + 'x': '2023-02-24T00:00:00', + 'y': 0.0 + }, + { + 'x': '2023-02-25T00:00:00', + 'y': 15.0 + } + ] + }, + ], + ) diff --git a/tomato/views.py b/tomato/views.py index 39ca1e8f..cdf7b7d0 100644 --- a/tomato/views.py +++ b/tomato/views.py @@ -1,9 +1,13 @@ +import pytz import asyncio +from django.conf import settings +from django.utils.dateparse import parse_datetime from tomato.models import ( Message, Attendance, Report, Tomato, ) +from django.db.models.functions import TruncMonth, TruncDay from django.utils.translation import ugettext_lazy as _ from django.utils import timezone from rest_framework.response import Response @@ -247,7 +251,7 @@ def get_queryset(self): return queryset def get_permissions(self): - if self.action in ['create', 'list', 'retrieve']: + if self.action in ['create', 'list', 'retrieve', 'statistics']: permission_classes = [ IsAuthenticated, ] @@ -261,6 +265,16 @@ def get_permissions(self): ] return [permission() for permission in permission_classes] + @staticmethod + def get_queryset_number_of_tomatoes(queryset): + """ + Returns the number of tomatoes for a given queryset + """ + nb_tomatoes = queryset.aggregate( + Sum('number_of_tomato'))['number_of_tomato__sum'] + nb_tomatoes = nb_tomatoes if nb_tomatoes else 0 + return nb_tomatoes + @action(detail=False, methods=["get"]) def community_tomatoes(self, request): """ @@ -271,10 +285,178 @@ def community_tomatoes(self, request): start = today.replace(day=1, hour=0, minute=0, second=0, microsecond=0) t = Tomato.objects.filter( acquisition_date__gte=start, acquisition_date__lte=today) - nb_tomatoes = t.aggregate( - Sum('number_of_tomato'))['number_of_tomato__sum'] - nb_tomatoes = nb_tomatoes if nb_tomatoes else 0 response_data = { - 'community_tomato': nb_tomatoes, + 'community_tomato': self.get_queryset_number_of_tomatoes(t), } return Response(response_data, status=status.HTTP_200_OK) + + @action(detail=False, methods=["get"]) + def statistics(self, request): + start = parse_datetime(request.query_params.get('start_date', None)) + end = parse_datetime(request.query_params.get('end_date', None)) + + if not start or not end or end < start: + return Response( + { + 'dates': _('Please select a valid start and end dates'), + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + return Response( + { + "totals": self._get_total_data( + start_date=start, + end_date=end, + ), + "graph": self._get_graph_data( + start_date=start, + end_date=end, + ) + }, + status=status.HTTP_200_OK, + ) + + def _get_total_data(self, start_date, end_date): + all_queryset = Tomato.objects.filter( + acquisition_date__lte=end_date, + acquisition_date__gte=start_date, + ) + user_queryset = all_queryset.filter(user=self.request.user) + totals = { + "global": self.get_queryset_number_of_tomatoes(all_queryset), + "user": self.get_queryset_number_of_tomatoes(user_queryset) + } + + return totals + + def _get_graph_data(self, start_date, end_date): + interval_param = self._define_interval(start_date, end_date) + + self.timezone = self.request.META.get( + 'HTTP_REQUEST_TIMEZONE', + 'America/Montreal', + ) + + start_date = start_date.astimezone(pytz.timezone(self.timezone)) + end_date = end_date.astimezone(pytz.timezone(self.timezone)) + + queryset = Tomato.objects.filter(user=self.request.user) + + if end_date: + queryset = queryset.filter(acquisition_date__lte=end_date) + if start_date: + queryset = queryset.filter(acquisition_date__gte=start_date) + + queryset = queryset.annotate( + interval=self._trunc_interval(interval_param), + ) + + labels = self._get_intervals(start_date, end_date, interval_param) + + response_data = { + 'labels': labels, + 'datasets': self._get_datasets(queryset, labels) + } + + return response_data + + @staticmethod + def _define_interval(start_date, end_date): + """ + Return interval based on date span. diff_date being given in days and + seconds, we translate it in full seconds to handle partial day. + """ + diff_date = end_date - start_date + seconds_in_day = 3600 * 24 + seconds = diff_date.seconds + (diff_date.days * seconds_in_day) + + if seconds <= 31 * seconds_in_day: + return 'day' + else: + return 'month' + + def _trunc_interval(self, interval_param): + trunc_function = TruncMonth + + if interval_param == 'day': + trunc_function = TruncDay + + return trunc_function( + 'acquisition_date', tzinfo=pytz.timezone(self.timezone) + ) + + @staticmethod + def _get_intervals(start, end, interval_param): + labels = set() + + date = start.replace(hour=0, minute=0, second=0) + + if interval_param == 'day': + while end >= date: + labels.add( + date.strftime("%Y-%m-%dT%H:%M:%S") + ) + date += timedelta(days=1) + else: + date = date.replace(day=1) + end = end.replace(day=1, hour=0, minute=0, second=0) + while end >= date: + labels.add( + date.strftime("%Y-%m-%dT%H:%M:%S") + ) + # Get first day of next month + date += timedelta(days=32) + date = date.replace(day=1) + + labels = list(labels) + labels.sort() + return labels + + def _get_datasets(self, queryset, labels): + queryset_by_interval = queryset.values('interval').annotate( + number_of_tomato=(Sum('number_of_tomato')), + ) + data_set_types = ['number_of_tomato'] + + data_sets = [] + for data_set_type in data_set_types: + data_set = { + 'label': data_set_type, + 'data': self._get_data( + queryset_by_interval, + data_set_type, + labels, + ) + } + data_sets.append(data_set) + + return data_sets + + @staticmethod + def _get_data(queryset, data_set_type, labels): + results = list() + + non_covered_labels = labels.copy() + for data in queryset: + label = data['interval'].strftime('%Y-%m-%dT%H:%M:%S') + results.append( + { + 'x': label, + 'y': data.get(data_set_type), + } + ) + non_covered_labels.remove(label) + + # Adding missing datasets + for label in non_covered_labels: + results.append( + { + 'x': label, + 'y': 0.0, + } + ) + + results.sort(key=lambda d: parse_datetime(d['x'])) + + return results