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": {