From c867b401ee11ae6076ce868cdb017dac9bc4278e Mon Sep 17 00:00:00 2001 From: Jonathan Reveille Date: Wed, 29 Jan 2025 17:01:50 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(backend)=20add=20start=20and=20end=20?= =?UTF-8?q?date=20on=20order=20groups=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We want to be able to define a start or an end date on the order group. Both dates can be setted as well. The enabled property defines if the order group can accept new orders when it is in relation with a course product relation. With this work, the field `is_active` has become a switch for admin user to enabled or not the group. Then, the property `is_enabled` computes the value depending on the activation of the group and the time constraints. Fix #1029 --- CHANGELOG.md | 4 + src/backend/joanie/core/admin.py | 17 +- .../0056_alter_ordergroup_and_more.py | 53 ++++ src/backend/joanie/core/models/products.py | 74 ++++- src/backend/joanie/core/serializers/admin.py | 10 +- src/backend/joanie/core/serializers/client.py | 14 +- .../core/api/admin/orders/test_retrieve.py | 3 + .../tests/core/api/order/test_create.py | 253 ++++++++++++++++- .../tests/core/test_api_admin_order_group.py | 264 +++++++++++++++++- .../core/test_api_course_product_relations.py | 15 + .../tests/core/test_models_order_group.py | 141 +++++++++- .../joanie/tests/swagger/admin-swagger.json | 92 +++++- src/backend/joanie/tests/swagger/swagger.json | 27 +- 13 files changed, 929 insertions(+), 38 deletions(-) create mode 100644 src/backend/joanie/core/migrations/0056_alter_ordergroup_and_more.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 40d6a3abf..44439a963 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to ## [Unreleased] +### Added + +- Add `start` and `end` datetime fields on order group model + ## [2.16.0] - 2025-02-13 ### Added diff --git a/src/backend/joanie/core/admin.py b/src/backend/joanie/core/admin.py index 9b6c6c47a..b965bfb25 100644 --- a/src/backend/joanie/core/admin.py +++ b/src/backend/joanie/core/admin.py @@ -202,15 +202,22 @@ class OrderGroupAdmin(admin.ModelAdmin): list_display = ( "course_product_relation", "is_active", + "is_enabled", "nb_available_seats", + "start", + "end", ) - search_fields = ("course_product_relation",) - fields = ("course_product_relation", "is_active", "nb_seats", "nb_available_seats") - readonly_fields = ("nb_available_seats",) - readonly_update_fields = ( + search_fields = ("course_product_relation", "start", "end") + fields = ( "course_product_relation", + "is_enabled", + "is_active", "nb_seats", + "start", + "end", ) + readonly_fields = ("nb_available_seats", "is_enabled") + readonly_update_fields = ("course_product_relation", "nb_seats") def get_readonly_fields(self, request, obj=None): """ @@ -223,7 +230,7 @@ def get_readonly_fields(self, request, obj=None): def nb_available_seats(self, obj): # pylint: disable=no-self-use """Return the number of available seats for this order group.""" - return obj.nb_seats - obj.get_nb_binding_orders() + return obj.available_seats class CourseProductRelationInline(admin.StackedInline): diff --git a/src/backend/joanie/core/migrations/0056_alter_ordergroup_and_more.py b/src/backend/joanie/core/migrations/0056_alter_ordergroup_and_more.py new file mode 100644 index 000000000..fe86be24c --- /dev/null +++ b/src/backend/joanie/core/migrations/0056_alter_ordergroup_and_more.py @@ -0,0 +1,53 @@ +# Generated by Django 4.2.18 on 2025-02-06 10:16 + +import django.core.validators +from django.db import migrations, models + + +def migrate_order_group_nb_seats_zero_to_none(apps, schema_editor): + """ + Update order groups where the seat number is 0 to None. + """ + OrderGroup = apps.get_model("core", "OrderGroup") + OrderGroup.objects.filter(nb_seats=0).update(nb_seats=None) + + +def rollback_order_group_nb_seats_none_to_zero(apps, schema_editor): + """ + Restore order group where number of seats is None to 0. + """ + OrderGroup = apps.get_model("core", "OrderGroup") + OrderGroup.objects.filter(nb_seats=None).update(nb_seats=0) + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0055_alter_documentimage_options_alter_ordergroup_options'), + ] + + operations = [ + migrations.AddField( + model_name='ordergroup', + name='end', + field=models.DateTimeField(blank=True, help_text='Date at which the order group activation ends', null=True, verbose_name='order group end datetime'), + ), + migrations.AddField( + model_name='ordergroup', + name='start', + field=models.DateTimeField(blank=True, help_text='Date at which the order group activation begins', null=True, verbose_name='order group start datetime'), + ), + migrations.AlterField( + model_name='ordergroup', + name='nb_seats', + field=models.PositiveSmallIntegerField(blank=True, default=None, help_text='The maximum number of orders that can be validated for a given order group', null=True, verbose_name='Number of seats'), + ), + migrations.AddConstraint( + model_name='ordergroup', + constraint=models.CheckConstraint(check=models.Q(('start__lte', models.F('end'))), name='check_start_before_end', violation_error_message='Start date cannot be greater than end date'), + ), + migrations.RunPython( + migrate_order_group_nb_seats_zero_to_none, + rollback_order_group_nb_seats_none_to_zero, + ), + ] diff --git a/src/backend/joanie/core/models/products.py b/src/backend/joanie/core/models/products.py index 89414a12a..795e2d192 100755 --- a/src/backend/joanie/core/models/products.py +++ b/src/backend/joanie/core/models/products.py @@ -366,11 +366,13 @@ class OrderGroup(BaseModel): """Order group to enforce a maximum number of seats for a product.""" nb_seats = models.PositiveSmallIntegerField( - default=0, + default=None, verbose_name=_("Number of seats"), help_text=_( "The maximum number of orders that can be validated for a given order group" ), + null=True, + blank=True, ) course_product_relation = models.ForeignKey( to=CourseProductRelation, @@ -379,6 +381,27 @@ class OrderGroup(BaseModel): on_delete=models.CASCADE, ) is_active = models.BooleanField(_("is active"), default=True) + start = models.DateTimeField( + help_text=_("Date at which the order group activation begins"), + verbose_name=_("order group start datetime"), + blank=True, + null=True, + ) + end = models.DateTimeField( + help_text=_("Date at which the order group activation ends"), + verbose_name=_("order group end datetime"), + blank=True, + null=True, + ) + + class Meta: + constraints = [ + models.CheckConstraint( + check=models.Q(start__lte=models.F("end")), + name="check_start_before_end", + violation_error_message=_("Start date cannot be greater than end date"), + ), + ] def get_nb_binding_orders(self): """Query the number of binding orders related to this order group.""" @@ -397,6 +420,28 @@ def can_edit(self): """Return True if the order group can be edited.""" return not self.orders.exists() + @property + def available_seats(self) -> int | None: + """Return the number of available seats on the order group.""" + if self.nb_seats is None: + return None + return self.nb_seats - self.get_nb_binding_orders() + + @property + def is_enabled(self): + """ + Returns boolean whether the order group is enabled based on its activation status + and time constraints. + """ + if not self.is_active: + return False + + now = timezone.now() + start = self.start or now + end = self.end or now + + return start <= now <= end + class OrderManager(models.Manager): """Custom manager for the Order model.""" @@ -796,20 +841,31 @@ def clean(self): f"and the course {course_title}." ) - if ( - course_product_relation - and course_product_relation.order_groups.filter(is_active=True).exists() + if course_product_relation and any( + order_group.is_enabled + for order_group in course_product_relation.order_groups.all() ): - if not self.order_group_id or not self.order_group.is_active: + if not self.order_group_id or not self.order_group.is_enabled: error_dict["order_group"].append( - f"An active order group is required for product {product_title:s}." + f"An enabled order group is required for product {product_title:s}." ) - else: - nb_seats = self.order_group.nb_seats - if 0 < nb_seats <= self.order_group.get_nb_binding_orders(): + elif ( + self.order_group.is_enabled + and self.order_group.nb_seats is not None + ): + if self.order_group.available_seats == 0: error_dict["order_group"].append( f"Maximum number of orders reached for product {product_title:s}" ) + elif ( + course_product_relation + and self.order_group + and not self.order_group.is_enabled + ): + error_dict["order_group"].append( + f"This order group is not enabled for product {product_title:s} " + "and does not accept any orders at the moment." + ) if error_dict: raise ValidationError(error_dict) diff --git a/src/backend/joanie/core/serializers/admin.py b/src/backend/joanie/core/serializers/admin.py index 29678cd60..a41d03953 100755 --- a/src/backend/joanie/core/serializers/admin.py +++ b/src/backend/joanie/core/serializers/admin.py @@ -441,6 +441,7 @@ class AdminOrderGroupSerializer(serializers.ModelSerializer): nb_seats = serializers.IntegerField( required=False, + allow_null=True, label=models.OrderGroup._meta.get_field("nb_seats").verbose_name, help_text=models.OrderGroup._meta.get_field("nb_seats").help_text, default=models.OrderGroup._meta.get_field("nb_seats").default, @@ -466,12 +467,15 @@ class Meta: "nb_available_seats", "created_on", "can_edit", + "is_enabled", + "start", + "end", ] - read_only_fields = ["id", "can_edit", "created_on"] + read_only_fields = ["id", "can_edit", "created_on", "is_enabled"] - def get_nb_available_seats(self, order_group) -> int: + def get_nb_available_seats(self, order_group) -> int | None: """Return the number of available seats for this order group.""" - return order_group.nb_seats - order_group.get_nb_binding_orders() + return order_group.available_seats @extend_schema_serializer(exclude_fields=("course_product_relation",)) diff --git a/src/backend/joanie/core/serializers/client.py b/src/backend/joanie/core/serializers/client.py index 848378350..698449463 100644 --- a/src/backend/joanie/core/serializers/client.py +++ b/src/backend/joanie/core/serializers/client.py @@ -763,12 +763,20 @@ class OrderGroupSerializer(serializers.ModelSerializer): class Meta: model = models.OrderGroup - fields = ["id", "is_active", "nb_seats", "nb_available_seats"] + fields = [ + "id", + "is_active", + "nb_seats", + "nb_available_seats", + "is_enabled", + "start", + "end", + ] read_only_fields = fields - def get_nb_available_seats(self, order_group) -> int: + def get_nb_available_seats(self, order_group) -> int | None: """Return the number of available seats for this order group.""" - return order_group.nb_seats - order_group.get_nb_binding_orders() + return order_group.available_seats class DefinitionResourcesProductSerializer(serializers.ModelSerializer): diff --git a/src/backend/joanie/tests/core/api/admin/orders/test_retrieve.py b/src/backend/joanie/tests/core/api/admin/orders/test_retrieve.py index 07799240c..5c714473a 100644 --- a/src/backend/joanie/tests/core/api/admin/orders/test_retrieve.py +++ b/src/backend/joanie/tests/core/api/admin/orders/test_retrieve.py @@ -112,10 +112,13 @@ def test_api_admin_orders_course_retrieve(self): "id": str(order_group.id), "nb_seats": order_group.nb_seats, "is_active": order_group.is_active, + "is_enabled": order_group.is_enabled, "nb_available_seats": order_group.nb_seats - order_group.get_nb_binding_orders(), "created_on": format_date(order_group.created_on), "can_edit": order_group.can_edit, + "start": None, + "end": None, }, "total": float(order.total), "total_currency": settings.DEFAULT_CURRENCY, diff --git a/src/backend/joanie/tests/core/api/order/test_create.py b/src/backend/joanie/tests/core/api/order/test_create.py index 93b300d46..f2df8e03d 100644 --- a/src/backend/joanie/tests/core/api/order/test_create.py +++ b/src/backend/joanie/tests/core/api/order/test_create.py @@ -3,10 +3,12 @@ # pylint: disable=too-many-lines import random import uuid +from datetime import timedelta from http import HTTPStatus from unittest import mock from django.conf import settings +from django.utils import timezone from joanie.core import enums, factories, models from joanie.core.api.client import OrderViewSet @@ -1395,7 +1397,7 @@ def test_api_order_create_authenticated_payment_binding(self, _mock_thumbnail): def test_api_order_create_authenticated_nb_seats(self): """ - The number of validated/pending orders on a product should not be above the limit + The number of completed/pending orders on a product should not be above the limit set by the number of seats """ user = factories.UserFactory() @@ -1448,8 +1450,8 @@ def test_api_order_create_authenticated_nb_seats(self): def test_api_order_create_authenticated_no_seats(self): """ - If nb_seats is set to 0 on an active order group, there should be no limit - to the number of orders + If the number of seats is set to 0 on an active order group, we should not be able + to create a new order on this group. """ user = factories.UserFactory() course = factories.CourseFactory() @@ -1460,12 +1462,9 @@ def test_api_order_create_authenticated_no_seats(self): organizations=factories.OrganizationFactory.create_batch(2), ) order_group = models.OrderGroup.objects.create( - course_product_relation=relation, nb_seats=0 + course_product_relation=relation, is_active=True, nb_seats=0 ) billing_address = BillingAddressDictFactory() - factories.OrderFactory.create_batch( - size=100, product=product, course=course, order_group=order_group - ) data = { "course_code": course.code, "organization_id": str(relation.organizations.first().id), @@ -1476,17 +1475,70 @@ def test_api_order_create_authenticated_no_seats(self): } token = self.generate_token_from_user(user) - with self.assertNumQueries(115): + with self.assertNumQueries(24): response = self.client.post( "/api/v1.0/orders/", data=data, content_type="application/json", HTTP_AUTHORIZATION=f"Bearer {token}", ) + + self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST) + self.assertDictEqual( + response.json(), + { + "order_group": [ + f"Maximum number of orders reached for product {relation.product.title}" + ] + }, + ) self.assertEqual( - models.Order.objects.filter(product=product, course=course).count(), 101 + models.Order.objects.filter(product=product, course=course).count(), 0 + ) + + def test_api_order_create_authenticated_nb_seat_is_none_on_active_order_group(self): + """ + If `nb_seats` is set to `None` on an active order group, there should be no limit + to the number of orders. + """ + user = factories.UserFactory() + token = self.generate_token_from_user(user) + + relation = factories.CourseProductRelationFactory( + organizations=factories.OrganizationFactory.create_batch(2), + ) + order_group = factories.OrderGroupFactory( + course_product_relation=relation, nb_seats=None, is_active=True + ) + factories.OrderFactory.create_batch( + 10, + product=relation.product, + course=relation.course, + order_group=order_group, ) + data = { + "course_code": relation.course.code, + "organization_id": str(relation.organizations.first().id), + "order_group_id": str(order_group.id), + "product_id": str(relation.product.id), + "billing_address": BillingAddressDictFactory(), + "has_waived_withdrawal_right": True, + } + + response = self.client.post( + "/api/v1.0/orders/", + data=data, + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + self.assertEqual(response.status_code, HTTPStatus.CREATED) + self.assertEqual( + models.Order.objects.filter( + product=relation.product, course=relation.course + ).count(), + 11, + ) def test_api_order_create_authenticated_free_product_no_billing_address(self): """ @@ -1586,7 +1638,7 @@ def test_api_order_create_order_group_required(self): response.json(), { "order_group": [ - f"An active order group is required for product {product.title}." + f"An enabled order group is required for product {product.title}." ] }, ) @@ -1700,6 +1752,9 @@ def test_api_order_create_several_order_groups(self): self.assertEqual( models.Order.objects.filter(course=course, product=product).count(), 2 ) + self.assertEqual( + models.Order.objects.filter(order_group=order_group2).count(), 1 + ) def test_api_order_create_inactive_order_groups(self): """An inactive order group should not be taken into account.""" @@ -1892,3 +1947,181 @@ def test_api_order_create_authenticated_product_enrollment_unicity_when_not_in_i ] }, ) + + def test_api_order_create_when_order_group_is_active_and_nb_seats_is_none(self): + """ + When create an order and the order group is active and has a number of seat + set to None, it should let us create unlimited number of orders. Although, + when the order group is not active, it should not create the order on the order group. + """ + user = factories.UserFactory() + token = self.generate_token_from_user(user) + relation = factories.CourseProductRelationFactory() + order_group = factories.OrderGroupFactory( + course_product_relation=relation, + is_active=True, + nb_seats=None, + ) + factories.OrderFactory.create_batch( + 2, + course=relation.course, + product=relation.product, + order_group=order_group, + state=enums.ORDER_STATE_PENDING, + ) + + order_group.is_active = False + order_group.save() + order_group.refresh_from_db() + + data = { + "course_code": relation.course.code, + "organization_id": str(relation.organizations.first().id), + "order_group_id": str(order_group.id), + "product_id": str(relation.product.id), + "billing_address": BillingAddressDictFactory(), + "has_waived_withdrawal_right": True, + } + + response = self.client.post( + "/api/v1.0/orders/", + data=data, + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + + self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST) + self.assertDictEqual( + response.json(), + { + "order_group": [ + f"This order group is not enabled for product {relation.product.title} " + "and does not accept any orders at the moment." + ] + }, + ) + self.assertEqual( + models.Order.objects.filter(order_group=order_group).count(), 2 + ) + + order_group.is_active = True + order_group.save() + order_group.refresh_from_db() + + response = self.client.post( + "/api/v1.0/orders/", + data=data, + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + + self.assertEqual(response.status_code, HTTPStatus.CREATED) + self.assertEqual( + models.Order.objects.filter(order_group=order_group).count(), 3 + ) + + def test_api_order_create_when_order_group_is_not_active_and_nb_seats_is_0(self): + """ + When we want to create an order and the order group is not active and + has a number of seat to 0, an error should be raised and the order cannot be created. + """ + user = factories.UserFactory() + token = self.generate_token_from_user(user) + + relation = factories.CourseProductRelationFactory() + order_group = factories.OrderGroupFactory( + course_product_relation=relation, + is_active=False, + nb_seats=0, + ) + + data = { + "course_code": relation.course.code, + "organization_id": str(relation.organizations.first().id), + "order_group_id": str(order_group.id), + "product_id": str(relation.product.id), + "billing_address": BillingAddressDictFactory(), + "has_waived_withdrawal_right": True, + } + + response = self.client.post( + "/api/v1.0/orders/", + data=data, + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + + self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST) + self.assertDictEqual( + response.json(), + { + "order_group": [ + f"This order group is not enabled for product {relation.product.title} " + "and does not accept any orders at the moment." + ] + }, + ) + + def test_api_order_create_when_order_group_is_active_but_not_enabled_yet(self): + """ + When an authenticated user passes in the payload an order group that is not yet enabled + to create his order, the order should not be created. When the user passes an order + group that is enabled, the order is created. + """ + user = factories.UserFactory() + token = self.generate_token_from_user(user) + + relation = factories.CourseProductRelationFactory() + # This order group will be enabled tomorrow + order_group = factories.OrderGroupFactory( + course_product_relation=relation, + is_active=True, + start=timezone.now() + timedelta(days=1), + ) + # This order group has expired in time + factories.OrderGroupFactory( + course_product_relation=relation, + is_active=False, + end=timezone.now() - timedelta(days=1), + ) + # This order group should have been chosen in the payload + order_group_enabled = factories.OrderGroupFactory( + course_product_relation=relation, is_active=True, start=timezone.now() + ) + + data = { + "course_code": relation.course.code, + "organization_id": str(relation.organizations.first().id), + "order_group_id": str(order_group.id), + "product_id": str(relation.product.id), + "billing_address": BillingAddressDictFactory(), + "has_waived_withdrawal_right": True, + } + + response = self.client.post( + "/api/v1.0/orders/", + data=data, + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + + self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST) + self.assertDictEqual( + response.json(), + { + "order_group": [ + f"An enabled order group is required for product {relation.product.title}." + ] + }, + ) + + data.update(order_group_id=str(order_group_enabled.id)) + + response = self.client.post( + "/api/v1.0/orders/", + data=data, + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + + self.assertEqual(response.status_code, HTTPStatus.CREATED) diff --git a/src/backend/joanie/tests/core/test_api_admin_order_group.py b/src/backend/joanie/tests/core/test_api_admin_order_group.py index 285ac7937..6e91fd8f5 100644 --- a/src/backend/joanie/tests/core/test_api_admin_order_group.py +++ b/src/backend/joanie/tests/core/test_api_admin_order_group.py @@ -2,10 +2,13 @@ Test suite for OrderGroup Admin API. """ +from datetime import timedelta from http import HTTPStatus from operator import itemgetter +from django.db import IntegrityError from django.test import TestCase +from django.utils import timezone as django_timezone from joanie.core import factories, models @@ -53,10 +56,13 @@ def test_admin_api_order_group_list_authenticated(self): "id": str(order_group.id), "nb_seats": order_group.nb_seats, "is_active": order_group.is_active, + "is_enabled": order_group.is_enabled, "nb_available_seats": order_group.nb_seats - order_group.get_nb_binding_orders(), "created_on": order_group.created_on.isoformat().replace("+00:00", "Z"), "can_edit": True, + "start": None, + "end": None, } for order_group in order_groups ] @@ -121,17 +127,20 @@ def test_admin_api_order_group_retrieve_authenticated(self): "id": str(order_group.id), "nb_seats": order_group.nb_seats, "is_active": order_group.is_active, + "is_enabled": order_group.is_enabled, "nb_available_seats": order_group.nb_seats - order_group.get_nb_binding_orders(), "created_on": order_group.created_on.isoformat().replace("+00:00", "Z"), "can_edit": True, + "start": None, + "end": None, } self.assertEqual(content, expected_return) # create def test_admin_api_order_group_create_anonymous(self): """ - Anonymous users should not be able to create an order groups. + Anonymous users should not be able to create an order group. """ relation = factories.CourseProductRelationFactory() @@ -147,6 +156,33 @@ def test_admin_api_order_group_create_anonymous(self): content["detail"], "Authentication credentials were not provided." ) + def test_admin_api_order_group_create_authenticated_with_nb_seats_is_none(self): + """ + Authenticated users should be able to create an order group and set None for + `nb_seats`. + """ + admin = factories.UserFactory(is_staff=True, is_superuser=True) + self.client.login(username=admin.username, password="password") + relation = factories.CourseProductRelationFactory() + data = { + "nb_seats": None, + "is_active": True, + } + + response = self.client.post( + f"{self.base_url}/{relation.id}/order-groups/", + content_type="application/json", + data=data, + ) + + content = response.json() + + self.assertEqual(response.status_code, HTTPStatus.CREATED) + self.assertIsNone(content["nb_seats"]) + self.assertEqual(content["is_active"], data["is_active"]) + self.assertTrue(content["is_enabled"]) + self.assertEqual(models.OrderGroup.objects.filter(**data).count(), 1) + def test_admin_api_order_group_create_authenticated(self): """ Authenticated users should be able to request order groups list. @@ -312,5 +348,231 @@ def test_admin_api_order_group_delete_cannot_edit(self): response = self.client.delete( f"{self.base_url}/{relation.id}/order-groups/{order_group.id}/", ) + self.assertEqual(response.status_code, HTTPStatus.NO_CONTENT) self.assertFalse(models.OrderGroup.objects.filter(id=order_group.id).exists()) + + def test_admin_api_order_group_create_start_and_end_date(self): + """ + Authenticated admin user should be able to create an order group and set + a start and end date. + """ + admin = factories.UserFactory(is_staff=True, is_superuser=True) + self.client.login(username=admin.username, password="password") + + relation = factories.CourseProductRelationFactory() + data = { + "start": "2025-06-01T00:00:00Z", + "end": "2025-06-20T00:00:00Z", + "nb_seats": 10, + "is_active": False, + } + + response = self.client.post( + f"{self.base_url}/{relation.id}/order-groups/", + content_type="application/json", + data=data, + ) + + self.assertEqual(response.status_code, HTTPStatus.CREATED) + + content = response.json() + + self.assertEqual(content["start"], data["start"]) + self.assertEqual(content["end"], data["end"]) + self.assertFalse(content["is_active"]) + + def test_admin_api_order_group_create_start_date_greater_than_end_date(self): + """ + Authenticated admin user should not be able to create an order group when the + start date is greater than the end date. + """ + admin = factories.UserFactory(is_staff=True, is_superuser=True) + self.client.login(username=admin.username, password="password") + + relation = factories.CourseProductRelationFactory() + data = { + "start": "2025-01-20T00:00:00Z", + "end": "2025-01-01T00:00:00Z", + } + + with self.assertRaises(IntegrityError): + self.client.post( + f"{self.base_url}/{relation.id}/order-groups/", + content_type="application/json", + data=data, + ) + + def test_admin_api_order_group_update_start_and_end_date(self): + """ + Authenticated admin user can update the start and end date of an existing + order group. + """ + admin = factories.UserFactory(is_staff=True, is_superuser=True) + self.client.login(username=admin.username, password="password") + + relation = factories.CourseProductRelationFactory() + order_group = factories.OrderGroupFactory( + course_product_relation=relation, + start="2025-01-11T00:00:00Z", + end="2025-01-20T00:00:00Z", + ) + + data = { + "start": "2025-02-13T00:00:00Z", + "end": "2025-02-19T00:00:00Z", + } + + response = self.client.put( + f"{self.base_url}/{relation.id}/order-groups/{order_group.id}/", + content_type="application/json", + data=data, + ) + + content = response.json() + + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertEqual(content["start"], data["start"]) + self.assertEqual(content["end"], data["end"]) + + def test_admin_api_order_group_is_enabled_and_is_active( + self, + ): + """ + When the order group is not yet active, even if the dates qualifies for early-birds + or last minutes sales, the property `is_enabled` should return False. Otherwise, + when the order group is active, the computed value of the property `is_enabled` will + return True if the datetimes meet the conditions. + """ + admin = factories.UserFactory(is_staff=True, is_superuser=True) + self.client.login(username=admin.username, password="password") + + relation = factories.CourseProductRelationFactory() + test_cases = [ + { + "start": None, + "end": django_timezone.now() + timedelta(days=1), + }, + {"start": django_timezone.now(), "end": None}, + { + "start": django_timezone.now() - timedelta(days=1), + "end": django_timezone.now() + timedelta(days=1), + }, + ] + + for case in test_cases: + with self.subTest(start=case["start"], end=case["end"]): + order_group = factories.OrderGroupFactory( + course_product_relation=relation, + is_active=False, + start=case["start"], + end=case["end"], + ) + + response = self.client.get( + f"{self.base_url}/{relation.id}/order-groups/{order_group.id}/" + ) + + content = response.json() + + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertFalse(content["is_active"]) + self.assertFalse(content["is_enabled"]) + + order_group.is_active = True + order_group.save() + + response = self.client.get( + f"{self.base_url}/{relation.id}/order-groups/{order_group.id}/" + ) + + content = response.json() + + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertTrue(content["is_active"]) + self.assertTrue(content["is_enabled"]) + + def test_admin_api_order_group_is_not_enabled_start_end_outside_datetime_range( + self, + ): + """ + When the order group is active but is not yet within the datetime ranges to be enabled, + it should return False. + """ + admin = factories.UserFactory(is_staff=True, is_superuser=True) + self.client.login(username=admin.username, password="password") + + relation = factories.CourseProductRelationFactory() + test_cases = [ + { + "start": None, + "end": django_timezone.now() - timedelta(days=1), + }, + {"start": django_timezone.now() + timedelta(days=1), "end": None}, + { + "start": django_timezone.now() + timedelta(days=1), + "end": django_timezone.now() + timedelta(days=2), + }, + ] + + for case in test_cases: + with self.subTest(start=case["start"], end=case["end"]): + order_group = factories.OrderGroupFactory( + course_product_relation=relation, + is_active=True, + start=case["start"], + end=case["end"], + ) + + response = self.client.get( + f"{self.base_url}/{relation.id}/order-groups/{order_group.id}/" + ) + + content = response.json() + + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertTrue(content["is_active"]) + self.assertFalse(content["is_enabled"]) + + def test_admin_api_order_group_is_active_and_nb_seats_is_enabled(self): + """ + When the order group is active and the number of seats is None, + `is_enabled` should return the value True. Otherwise, it should + return False. + """ + admin = factories.UserFactory(is_staff=True, is_superuser=True) + self.client.login(username=admin.username, password="password") + + relation = factories.CourseProductRelationFactory() + order_group = factories.OrderGroupFactory( + course_product_relation=relation, + is_active=True, + nb_seats=None, + start=None, + end=None, + ) + + response = self.client.get( + f"{self.base_url}/{relation.id}/order-groups/{order_group.id}/" + ) + + content = response.json() + + self.assertTrue(content["is_enabled"]) + self.assertTrue(content["is_active"]) + self.assertIsNone(content["nb_available_seats"]) + self.assertIsNone(content["nb_seats"]) + + order_group.is_active = False + order_group.save() + + response = self.client.get( + f"{self.base_url}/{relation.id}/order-groups/{order_group.id}/" + ) + + content = response.json() + + self.assertFalse(content["is_enabled"]) + self.assertFalse(content["is_active"]) + self.assertIsNone(content["nb_available_seats"]) + self.assertIsNone(content["nb_seats"]) diff --git a/src/backend/joanie/tests/core/test_api_course_product_relations.py b/src/backend/joanie/tests/core/test_api_course_product_relations.py index c5aa4e94d..922ea7ddc 100644 --- a/src/backend/joanie/tests/core/test_api_course_product_relations.py +++ b/src/backend/joanie/tests/core/test_api_course_product_relations.py @@ -800,14 +800,20 @@ def test_api_course_product_relation_read_detail_with_order_groups(self): { "id": str(order_group1.id), "is_active": True, + "is_enabled": True, "nb_available_seats": order_group1.nb_seats - 3, "nb_seats": order_group1.nb_seats, + "start": None, + "end": None, }, { "id": str(order_group2.id), "is_active": True, + "is_enabled": True, "nb_available_seats": order_group2.nb_seats, "nb_seats": order_group2.nb_seats, + "start": None, + "end": None, }, ], ) @@ -839,8 +845,11 @@ def test_api_course_product_relation_read_detail_with_order_groups_cache(self): { "id": str(order_group.id), "is_active": True, + "is_enabled": True, "nb_available_seats": 10, "nb_seats": 10, + "start": None, + "end": None, }, ], ) @@ -862,8 +871,11 @@ def test_api_course_product_relation_read_detail_with_order_groups_cache(self): { "id": str(order_group.id), "is_active": True, + "is_enabled": True, "nb_available_seats": 9, "nb_seats": 10, + "start": None, + "end": None, }, ], ) @@ -884,8 +896,11 @@ def test_api_course_product_relation_read_detail_with_order_groups_cache(self): { "id": str(order_group.id), "is_active": True, + "is_enabled": True, "nb_available_seats": 10, "nb_seats": 10, + "start": None, + "end": None, }, ], ) diff --git a/src/backend/joanie/tests/core/test_models_order_group.py b/src/backend/joanie/tests/core/test_models_order_group.py index 803fa6162..2487cd85e 100644 --- a/src/backend/joanie/tests/core/test_models_order_group.py +++ b/src/backend/joanie/tests/core/test_models_order_group.py @@ -2,7 +2,11 @@ Test suite for OrderGroup model """ +from datetime import timedelta + +from django.db import IntegrityError from django.test import TestCase +from django.utils import timezone from joanie.core import factories @@ -10,7 +14,7 @@ class OrderGroupModelTestCase(TestCase): """Test suite for the OrderGroup model.""" - def test_model_order_group_can_edit(self): + def test_models_order_group_can_edit(self): """ OrderGroup can_edit property should return True if the relation is not linked to any order, False otherwise. @@ -24,3 +28,138 @@ def test_model_order_group_can_edit(self): course=order_group.course_product_relation.course, ) self.assertFalse(order_group.can_edit) + + def test_models_order_group_check_start_before_end(self): + """ + The order group start value can't be greater than the end value. + """ + start = timezone.now() + end = timezone.now() - timedelta(days=10) + + with self.assertRaises(IntegrityError) as context: + factories.OrderGroupFactory(start=start, end=end) + + self.assertTrue( + 'new row for relation "core_ordergroup" violates' + ' check constraint "check_start_before_end"' in str(context.exception) + ) + + def test_models_order_group_set_start_and_end_date(self): + """ + When the start date is not greater than the end date, the order group should be created. + We can also set a start date only or an end date. + """ + start = timezone.now() + end = start + timedelta(days=10) + + order_group_1 = factories.OrderGroupFactory(start=start, end=end) + + self.assertEqual(order_group_1.start, start) + self.assertEqual(order_group_1.end, end) + + order_group_2 = factories.OrderGroupFactory(start=None, end=end) + + self.assertEqual(order_group_2.start, None) + self.assertEqual(order_group_2.end, end) + + order_group_3 = factories.OrderGroupFactory(start=start, end=None) + + self.assertEqual(order_group_3.start, start) + self.assertEqual(order_group_3.end, None) + + def test_models_order_group_is_enabled_when_is_not_active(self): + """ + When the order group is not active, the computed value of `is_enabled` should always + return False. Otherwise, if the group is active, it should return True. + """ + order_group = factories.OrderGroupFactory(is_active=False, start=None, end=None) + + self.assertFalse(order_group.is_enabled) + + order_group.is_active = True + order_group.save() + + self.assertTrue(order_group.is_enabled) + + def test_models_order_group_is_enabled_is_active_with_start_and_end_dates( + self, + ): + """ + When the order group is active and the current day is in the interval of start and end + dates, the computed value of `is_enabled` should return True. If `is_active` is set to + False afterwards, the computed value of `is_enabled` should return False. + """ + order_group_1 = factories.OrderGroupFactory( + is_active=True, + start=timezone.now() - timedelta(days=1), + end=timezone.now() + timedelta(days=1), + ) + + self.assertTrue(order_group_1.is_enabled) + + order_group_1.is_active = False + order_group_1.save() + + self.assertFalse(order_group_1.is_enabled) + + order_group_2 = factories.OrderGroupFactory( + is_active=True, + start=timezone.now() + timedelta(days=1), + end=timezone.now() + timedelta(days=2), + ) + + self.assertFalse(order_group_2.is_enabled) + + def test_models_order_group_is_enabled_is_active_start_date(self): + """ + When the order group start date is reached, the order group should be enabled if it's + active only. Otherwise, if the start date is not reached, the order group should not + be enabled. + """ + order_group_1 = factories.OrderGroupFactory( + is_active=True, + start=timezone.now() - timedelta(hours=1), + end=None, + ) + + self.assertTrue(order_group_1.is_enabled) + + order_group_1.is_active = False + order_group_1.save() + + self.assertFalse(order_group_1.is_enabled) + + order_group_2 = factories.OrderGroupFactory( + is_active=True, + start=timezone.now() + timedelta(hours=1), + end=None, + ) + + self.assertFalse(order_group_2.is_enabled) + + def test_models_order_group_is_enabled_is_active_end_date(self): + """ + When the order group end date is not yet reached, the order group should be enabled if + it's active only. Otherwise, if the end date is passed, the order group should not + be enabled. + """ + order_group_1 = factories.OrderGroupFactory( + is_active=True, + start=timezone.now() - timedelta(hours=1), + end=None, + ) + + self.assertTrue(order_group_1.is_enabled) + + order_group_1.is_active = False + order_group_1.save() + + self.assertFalse(order_group_1.is_enabled) + + order_group_2 = factories.OrderGroupFactory( + is_active=True, + start=timezone.now() + timedelta(hours=1), + end=None, + ) + + self.assertFalse(order_group_2.is_enabled) diff --git a/src/backend/joanie/tests/swagger/admin-swagger.json b/src/backend/joanie/tests/swagger/admin-swagger.json index e208c31b4..095b608c5 100644 --- a/src/backend/joanie/tests/swagger/admin-swagger.json +++ b/src/backend/joanie/tests/swagger/admin-swagger.json @@ -6393,7 +6393,7 @@ "type": "integer", "maximum": 32767, "minimum": 0, - "default": 0, + "nullable": true, "title": "Number of seats", "description": "The maximum number of orders that can be validated for a given order group" }, @@ -6403,6 +6403,7 @@ }, "nb_available_seats": { "type": "integer", + "nullable": true, "description": "Return the number of available seats for this order group.", "readOnly": true }, @@ -6415,12 +6416,31 @@ "can_edit": { "type": "string", "readOnly": true + }, + "is_enabled": { + "type": "string", + "readOnly": true + }, + "start": { + "type": "string", + "format": "date-time", + "nullable": true, + "title": "Order group start datetime", + "description": "Date at which the order group activation begins" + }, + "end": { + "type": "string", + "format": "date-time", + "nullable": true, + "title": "Order group end datetime", + "description": "Date at which the order group activation ends" } }, "required": [ "can_edit", "created_on", "id", + "is_enabled", "nb_available_seats" ] }, @@ -6438,7 +6458,7 @@ "type": "integer", "maximum": 32767, "minimum": 0, - "default": 0, + "nullable": true, "title": "Number of seats", "description": "The maximum number of orders that can be validated for a given order group" }, @@ -6448,6 +6468,7 @@ }, "nb_available_seats": { "type": "integer", + "nullable": true, "description": "Return the number of available seats for this order group.", "readOnly": true }, @@ -6460,12 +6481,31 @@ "can_edit": { "type": "string", "readOnly": true + }, + "is_enabled": { + "type": "string", + "readOnly": true + }, + "start": { + "type": "string", + "format": "date-time", + "nullable": true, + "title": "Order group start datetime", + "description": "Date at which the order group activation begins" + }, + "end": { + "type": "string", + "format": "date-time", + "nullable": true, + "title": "Order group end datetime", + "description": "Date at which the order group activation ends" } }, "required": [ "can_edit", "created_on", "id", + "is_enabled", "nb_available_seats" ] }, @@ -6477,13 +6517,27 @@ "type": "integer", "maximum": 32767, "minimum": 0, - "default": 0, + "nullable": true, "title": "Number of seats", "description": "The maximum number of orders that can be validated for a given order group" }, "is_active": { "type": "boolean", "default": true + }, + "start": { + "type": "string", + "format": "date-time", + "nullable": true, + "title": "Order group start datetime", + "description": "Date at which the order group activation begins" + }, + "end": { + "type": "string", + "format": "date-time", + "nullable": true, + "title": "Order group end datetime", + "description": "Date at which the order group activation ends" } } }, @@ -6495,13 +6549,27 @@ "type": "integer", "maximum": 32767, "minimum": 0, - "default": 0, + "nullable": true, "title": "Number of seats", "description": "The maximum number of orders that can be validated for a given order group" }, "is_active": { "type": "boolean", "default": true + }, + "start": { + "type": "string", + "format": "date-time", + "nullable": true, + "title": "Order group start datetime", + "description": "Date at which the order group activation begins" + }, + "end": { + "type": "string", + "format": "date-time", + "nullable": true, + "title": "Order group end datetime", + "description": "Date at which the order group activation ends" } } }, @@ -8537,13 +8605,27 @@ "type": "integer", "maximum": 32767, "minimum": 0, - "default": 0, + "nullable": true, "title": "Number of seats", "description": "The maximum number of orders that can be validated for a given order group" }, "is_active": { "type": "boolean", "default": true + }, + "start": { + "type": "string", + "format": "date-time", + "nullable": true, + "title": "Order group start datetime", + "description": "Date at which the order group activation begins" + }, + "end": { + "type": "string", + "format": "date-time", + "nullable": true, + "title": "Order group end datetime", + "description": "Date at which the order group activation ends" } } }, diff --git a/src/backend/joanie/tests/swagger/swagger.json b/src/backend/joanie/tests/swagger/swagger.json index 11989cb28..13873860e 100644 --- a/src/backend/joanie/tests/swagger/swagger.json +++ b/src/backend/joanie/tests/swagger/swagger.json @@ -6233,20 +6233,45 @@ "nb_seats": { "type": "integer", "readOnly": true, + "nullable": true, "title": "Number of seats", "description": "The maximum number of orders that can be validated for a given order group" }, "nb_available_seats": { "type": "integer", + "nullable": true, "description": "Return the number of available seats for this order group.", "readOnly": true + }, + "is_enabled": { + "type": "string", + "readOnly": true + }, + "start": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true, + "title": "Order group start datetime", + "description": "Date at which the order group activation begins" + }, + "end": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true, + "title": "Order group end datetime", + "description": "Date at which the order group activation ends" } }, "required": [ + "end", "id", "is_active", + "is_enabled", "nb_available_seats", - "nb_seats" + "nb_seats", + "start" ] }, "OrderPayment": {