From a3c41537e49a8ffa5dfc734a022d329d4878b321 Mon Sep 17 00:00:00 2001 From: Jonathan Reveille Date: Mon, 3 Feb 2025 19:05:28 +0100 Subject: [PATCH] =?UTF-8?q?fixup!=20=E2=9C=A8(backend)=20add=20start=20and?= =?UTF-8?q?=20end=20date=20on=20order=20groups=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/backend/joanie/core/admin.py | 4 + ...e.py => 0054_alter_ordergroup_and_more.py} | 2 +- src/backend/joanie/core/models/products.py | 36 ++ src/backend/joanie/core/serializers/admin.py | 22 +- src/backend/joanie/core/serializers/client.py | 5 + src/backend/joanie/core/utils/ordergroup.py | 27 -- .../core/api/admin/orders/test_retrieve.py | 2 +- .../tests/core/test_api_admin_order_group.py | 376 ++++++------------ .../core/test_api_course_product_relations.py | 2 +- .../tests/core/test_models_order_group.py | 211 +++++++++- .../tests/core/utils/test_utils_ordergroup.py | 92 ----- .../joanie/tests/swagger/admin-swagger.json | 7 +- src/backend/joanie/tests/swagger/swagger.json | 2 +- 13 files changed, 401 insertions(+), 387 deletions(-) rename src/backend/joanie/core/migrations/{0053_alter_ordergroup_and_more.py => 0054_alter_ordergroup_and_more.py} (92%) delete mode 100644 src/backend/joanie/core/utils/ordergroup.py delete mode 100644 src/backend/joanie/tests/core/utils/test_utils_ordergroup.py diff --git a/src/backend/joanie/core/admin.py b/src/backend/joanie/core/admin.py index e2add759b..1fde7b4d9 100644 --- a/src/backend/joanie/core/admin.py +++ b/src/backend/joanie/core/admin.py @@ -231,6 +231,10 @@ 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() + def get_is_active(self, obj): + """Return the computed value of is_active""" + return obj.is_enabled + class CourseProductRelationInline(admin.StackedInline): """Admin class for the CourseProductRelation model""" diff --git a/src/backend/joanie/core/migrations/0053_alter_ordergroup_and_more.py b/src/backend/joanie/core/migrations/0054_alter_ordergroup_and_more.py similarity index 92% rename from src/backend/joanie/core/migrations/0053_alter_ordergroup_and_more.py rename to src/backend/joanie/core/migrations/0054_alter_ordergroup_and_more.py index 453700bde..fc1fdcdea 100644 --- a/src/backend/joanie/core/migrations/0053_alter_ordergroup_and_more.py +++ b/src/backend/joanie/core/migrations/0054_alter_ordergroup_and_more.py @@ -6,7 +6,7 @@ class Migration(migrations.Migration): dependencies = [ - ('core', '0052_alter_unique_constraint_order_when_canceled_or_refund'), + ('core', '0053_alter_certificate_options_and_more'), ] operations = [ diff --git a/src/backend/joanie/core/models/products.py b/src/backend/joanie/core/models/products.py index c891283c3..5b51c8277 100755 --- a/src/backend/joanie/core/models/products.py +++ b/src/backend/joanie/core/models/products.py @@ -419,6 +419,42 @@ def can_edit(self): """Return True if the order group can be edited.""" return not self.orders.exists() + @property + def is_enabled(self): + """ + Returns boolean whether the order group is active based on its status, availability, + and time constraints. + When a start or end date is setted, we need to verify if we are in the period + of early bird, or last minute depending on the course run enrollment start or + enrollment end datetimes. When both dates are setted, we need to make sure that the + current day is between those. + """ + now = timezone.now() + is_active = False + + if self.course_product_relation.product.type == enums.PRODUCT_TYPE_CERTIFICATE: # pylint: disable=no-member + instance = self.course_product_relation.course + else: + instance = self.course_product_relation.product + course_run_dates = instance.get_equivalent_course_run_dates( # pylint: disable=no-member + ignore_archived=True + ) + + available_seats = self.nb_seats - self.get_nb_binding_orders() + + if not self.is_active or not available_seats: + is_active = False + elif not self.start and not self.end and available_seats: + is_active = self.is_active + elif self.start and self.end and available_seats: + is_active = self.start <= now <= self.end + elif self.end and available_seats: # Early birds + is_active = self.end >= now >= course_run_dates["enrollment_start"] + elif self.start and available_seats: # Last minutes + is_active = self.start <= now <= course_run_dates["enrollment_end"] + + return is_active + class OrderManager(models.Manager): """Custom manager for the Order model.""" diff --git a/src/backend/joanie/core/serializers/admin.py b/src/backend/joanie/core/serializers/admin.py index 8e8c404a5..5b44e48ad 100755 --- a/src/backend/joanie/core/serializers/admin.py +++ b/src/backend/joanie/core/serializers/admin.py @@ -19,7 +19,6 @@ ThumbnailDetailField, ) from joanie.core.utils import Echo -from joanie.core.utils.ordergroup import is_active from joanie.payment import models as payment_models @@ -473,23 +472,20 @@ def get_nb_available_seats(self, order_group) -> int: """Return the number of available seats for this order group.""" return order_group.nb_seats - order_group.get_nb_binding_orders() - def get_is_active(self, order_group): - """ - Return if the order group is active if start or end dates are setted. Else, it returns - the value in the database. - """ - return is_active(order_group) + def get_is_active(self, instance): + """Return the computed value of is_active of the order group.""" + return instance.is_enabled def update(self, instance, validated_data): """ Update instance and ensure `is_active` is only updated if explicitly passed in the request. Else, don't update the field. """ - is_active_explicit = "is_active" in self.initial_data - - if is_active_explicit: - instance.is_active = self.initial_data["is_active"] - + instance.is_active = ( + self.initial_data["is_active"] + if "is_active" in self.initial_data + else instance.is_active + ) return super().update(instance, validated_data) @@ -502,8 +498,6 @@ class AdminOrderGroupCreateSerializer(AdminOrderGroupSerializer): the order group. """ - is_active = serializers.BooleanField(required=False) - class Meta(AdminOrderGroupSerializer.Meta): fields = [*AdminOrderGroupSerializer.Meta.fields, "course_product_relation"] diff --git a/src/backend/joanie/core/serializers/client.py b/src/backend/joanie/core/serializers/client.py index 9c5d4b81e..459ff1b56 100644 --- a/src/backend/joanie/core/serializers/client.py +++ b/src/backend/joanie/core/serializers/client.py @@ -760,6 +760,7 @@ class OrderGroupSerializer(serializers.ModelSerializer): """Serializer for order groups in a product.""" nb_available_seats = serializers.SerializerMethodField(read_only=True) + is_active = serializers.SerializerMethodField(read_only=True) class Meta: model = models.OrderGroup @@ -770,6 +771,10 @@ def get_nb_available_seats(self, order_group) -> int: """Return the number of available seats for this order group.""" return order_group.nb_seats - order_group.get_nb_binding_orders() + def get_is_active(self, instance): + """Return the computed value of is_active of the order group.""" + return instance.is_enabled + class DefinitionResourcesProductSerializer(serializers.ModelSerializer): """ diff --git a/src/backend/joanie/core/utils/ordergroup.py b/src/backend/joanie/core/utils/ordergroup.py deleted file mode 100644 index a38c72771..000000000 --- a/src/backend/joanie/core/utils/ordergroup.py +++ /dev/null @@ -1,27 +0,0 @@ -""" -Util to manage OrderGroup -""" - -from django.utils import timezone - - -def is_active(order_group): - """ - Determine the value of `is_active` of an OrderGroup object. If `start` nor `end` datetime - fields are set, we return the value setted in the database. Otherwise when `start`, `end` - or both datetime are setted, we should compare it to timezone.now() calculate if it's - the window period to set `is_active`. - """ - if not order_group.start and not order_group.end: - return order_group.is_active - - now = timezone.now() - - if order_group.start and order_group.end: - return order_group.start <= now <= order_group.end - if order_group.start: - return now >= order_group.start - if order_group.end: - return now <= order_group.end - - return False 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 33df6519c..d7ecb8af7 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 @@ -61,7 +61,7 @@ def test_api_admin_orders_course_retrieve(self): total=D("1.00"), ) - with self.assertNumQueries(39): + with self.assertNumQueries(42): response = self.client.get(f"/api/v1.0/admin/orders/{order.id}/") self.assertEqual(response.status_code, HTTPStatus.OK) 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 a1b43ef22..9c9573d19 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,16 +2,15 @@ Test suite for OrderGroup Admin API. """ -from datetime import datetime +from datetime import timedelta from http import HTTPStatus from operator import itemgetter -from unittest import mock -from zoneinfo import ZoneInfo from django.db import IntegrityError from django.test import TestCase +from django.utils import timezone as django_timezone -from joanie.core import factories, models +from joanie.core import enums, factories, models class OrderGroupAdminApiTest(TestCase): @@ -48,7 +47,7 @@ def test_admin_api_order_group_list_authenticated(self): ) factories.OrderGroupFactory.create_batch(5) - with self.assertNumQueries(10): + with self.assertNumQueries(19): response = self.client.get(f"{self.base_url}/{relation.id}/order-groups/") self.assertEqual(response.status_code, HTTPStatus.OK) content = response.json() @@ -116,7 +115,7 @@ def test_admin_api_order_group_retrieve_authenticated(self): relation = factories.CourseProductRelationFactory() order_group = factories.OrderGroupFactory(course_product_relation=relation) - with self.assertNumQueries(5): + with self.assertNumQueries(8): response = self.client.get( f"{self.base_url}/{relation.id}/order-groups/{order_group.id}/" ) @@ -167,7 +166,7 @@ def test_admin_api_order_group_create_authenticated(self): "nb_seats": 5, "is_active": True, } - with self.assertNumQueries(6): + with self.assertNumQueries(9): response = self.client.post( f"{self.base_url}/{relation.id}/order-groups/", content_type="application/json", @@ -211,7 +210,7 @@ def test_admin_api_order_group_update_authenticated(self): "nb_seats": 505, "is_active": True, } - with self.assertNumQueries(6): + with self.assertNumQueries(9): response = self.client.put( f"{self.base_url}/{relation.id}/order-groups/{str(order_group.id)}/", content_type="application/json", @@ -255,7 +254,7 @@ def test_admin_api_order_group_patch_authenticated(self): data = { "is_active": True, } - with self.assertNumQueries(6): + with self.assertNumQueries(9): response = self.client.patch( f"{self.base_url}/{relation.id}/order-groups/{str(order_group.id)}/", content_type="application/json", @@ -320,23 +319,24 @@ 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_with_start_and_end_date(self): """ Authenticated admin user should be able to create an order group and set - a start and end date + 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-01-01T00:00:00Z", - "end": "2025-01-20T00:00:00Z", + "start": "2025-06-01T00:00:00Z", + "end": "2025-06-20T00:00:00Z", "nb_seats": 10, - "is_active": True, + "is_active": False, } response = self.client.post( @@ -346,9 +346,12 @@ def test_admin_api_order_group_create_with_start_and_end_date(self): ) 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_with_start_date_greater_than_end_date(self): """ @@ -396,273 +399,156 @@ def test_admin_api_order_group_update_start_and_end_date(self): data=data, ) - self.assertEqual(response.status_code, HTTPStatus.OK) content = response.json() - self.assertEqual(content["start"], data["start"]) - self.assertEqual(content["end"], data["end"]) - - def test_admin_api_order_group_patch_start_date_or_end_date(self): - """ - Authenticated admin user should be able to patch order group by updating - the start date when the start and end date are setted. - """ - 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-01-12T00:00:00Z", - } - - response = self.client.patch( - f"{self.base_url}/{relation.id}/order-groups/{order_group.id}/", - content_type="application/json", - data=data, - ) self.assertEqual(response.status_code, HTTPStatus.OK) - content = response.json() self.assertEqual(content["start"], data["start"]) - - data = { - "end": "2025-01-22T00:00:00Z", - } - - response = self.client.patch( - f"{self.base_url}/{relation.id}/order-groups/{order_group.id}/", - content_type="application/json", - data=data, - ) - - self.assertEqual(response.status_code, HTTPStatus.OK) - content = response.json() self.assertEqual(content["end"], data["end"]) - def test_admin_api_order_group_patch_start_date_should_compute_is_active(self): + def test_admin_order_group_compute_is_active_when_product_type_credentials(self): """ - The `is_active` field is dynamically computed when the `start` datetime field is set. - Otherwise, it should retrieve the stored value from the database. + Tests when the product is type credential and the order group must be set to + `is_active=True` before computing the value. Even if the dates qualifies for early-birds + sales or last minutes sales, if `is_active` remains False unless explicitly enabled + by an admin, we always return False. Otherwise, if `is_active` is True, we then compute the + value. """ 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=False - ) - response = self.client.get( - f"{self.base_url}/{relation.id}/order-groups/{order_group.id}/" + earliest_start_date = django_timezone.now() - timedelta(days=2) + latest_end_date = django_timezone.now() + timedelta(days=3) + latest_enrollment_start_date = django_timezone.now() - timedelta(days=3) + earliest_enrollment_end_date = django_timezone.now() + timedelta(days=2) + + courses = ( + factories.CourseRunFactory( + start=earliest_start_date, + end=latest_end_date, + enrollment_start=latest_enrollment_start_date - timedelta(days=2), + enrollment_end=earliest_enrollment_end_date + timedelta(days=2), + ).course, + factories.CourseRunFactory( + start=earliest_start_date + timedelta(days=2), + end=latest_end_date - timedelta(days=2), + enrollment_start=latest_enrollment_start_date, + enrollment_end=earliest_enrollment_end_date, + ).course, + ) + product = factories.ProductFactory( + price=123, + type=enums.PRODUCT_TYPE_CREDENTIAL, + target_courses=courses, ) + relation = factories.CourseProductRelationFactory( + product=product, + ) + 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), + }, + ] - content = response.json() + 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"], + ) - self.assertEqual(response.status_code, HTTPStatus.OK) - self.assertFalse(order_group.is_active) # Stored value in database - self.assertFalse(content["is_active"]) + response = self.client.get( + f"{self.base_url}/{relation.id}/order-groups/{order_group.id}/" + ) - # Update the `start` field that should make is_active to True (start <= now) - mocked_now = datetime(2025, 1, 29, 16, 20, tzinfo=ZoneInfo("UTC")) - with mock.patch("django.utils.timezone.now", return_value=mocked_now): - response = self.client.patch( - f"{self.base_url}/{relation.id}/order-groups/{order_group.id}/", - content_type="application/json", - data={ - "start": "2025-01-10T00:00:00Z", - }, - ) + content = response.json() - order_group.refresh_from_db() - content = response.json() + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertFalse(content["is_active"]) - self.assertEqual(response.status_code, HTTPStatus.OK) - self.assertEqual(content["start"], "2025-01-10T00:00:00Z") - self.assertTrue(content["is_active"]) - self.assertFalse(order_group.is_active) # Stored value in database + order_group.is_active = True + order_group.save() - # Let's pretend that start date is tomorrow from the mocked date - with mock.patch("django.utils.timezone.now", return_value=mocked_now): - response = self.client.patch( - f"{self.base_url}/{relation.id}/order-groups/{order_group.id}/", - content_type="application/json", - data={ - "start": "2025-01-30T00:00:00Z", - "is_active": True, - }, - ) + response = self.client.get( + f"{self.base_url}/{relation.id}/order-groups/{order_group.id}/" + ) - order_group.refresh_from_db() - content = response.json() + content = response.json() - self.assertEqual(response.status_code, HTTPStatus.OK) - self.assertEqual(content["start"], "2025-01-30T00:00:00Z") - self.assertFalse(content["is_active"]) - self.assertTrue(order_group.is_active) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertTrue(content["is_active"]) - def test_admin_api_order_group_patch_end_date_should_compute_is_active(self): + def test_admin_order_group_compute_is_active_when_product_type_certificate(self): """ - The `is_active` field is dynamically computed when the `end` datetime field is set. - Otherwise, it should retrieve its stored value from the database. + Tests when the product is type certificate and the order group must be set to + `is_active=True` before computing the value. Even if the dates qualifies for early-birds + sales or last minutes sales, if `is_active` remains False unless explicitly enabled + by an admin, we always return False. Otherwise, if `is_active` is True, we then compute the + value. """ 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=False - ) - response = self.client.get( - f"{self.base_url}/{relation.id}/order-groups/{order_group.id}/" + course_run = factories.CourseRunFactory( + enrollment_start=django_timezone.now() - timedelta(days=4), + enrollment_end=django_timezone.now() + timedelta(days=2), + start=django_timezone.now() - timedelta(days=1), + end=django_timezone.now() + timedelta(days=4), ) - - content = response.json() - self.assertEqual(response.status_code, HTTPStatus.OK) - self.assertFalse(order_group.is_active) - self.assertFalse(content["is_active"]) - - mocked_now = datetime(2025, 1, 30, 16, 20, tzinfo=ZoneInfo("UTC")) - with mock.patch("django.utils.timezone.now", return_value=mocked_now): - response = self.client.patch( - f"{self.base_url}/{relation.id}/order-groups/{order_group.id}/", - content_type="application/json", - data={ - "end": "2025-02-20T00:00:00Z", - }, - ) - - order_group.refresh_from_db() - content = response.json() - - self.assertEqual(response.status_code, HTTPStatus.OK) - self.assertEqual(content["end"], "2025-02-20T00:00:00Z") - self.assertTrue(content["is_active"]) - self.assertFalse(order_group.is_active) - - # Let's pretend that we passed over the end datetime and we set the `is_active` to True - # in for the object in database, the serializer should return the computed value instead - with mock.patch("django.utils.timezone.now", return_value=mocked_now): - response = self.client.patch( - f"{self.base_url}/{relation.id}/order-groups/{order_group.id}/", - content_type="application/json", - data={ - "end": "2025-01-29T00:00:00Z", - "is_active": True, - }, - ) - - order_group.refresh_from_db() - content = response.json() - - self.assertEqual(response.status_code, HTTPStatus.OK) - self.assertEqual(content["end"], "2025-01-29T00:00:00Z") - self.assertFalse(content["is_active"]) - self.assertTrue(order_group.is_active) - - def test_api_admin_is_active_start_and_end_date_should_compute_is_active(self): - """ - When the `start` and `end` date on an order group correspond to the activation window, - computed is_active should return True - """ - 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=False + product = factories.ProductFactory( + price=3, + type=enums.PRODUCT_TYPE_CERTIFICATE, ) - - response = self.client.get( - f"{self.base_url}/{relation.id}/order-groups/{order_group.id}/" + relation = factories.CourseProductRelationFactory( + course=course_run.course, + product=product, ) - content = response.json() - self.assertEqual(response.status_code, HTTPStatus.OK) - self.assertFalse(order_group.is_active) - self.assertFalse(content["is_active"]) - - mocked_now = datetime(2025, 1, 30, 16, 20, tzinfo=ZoneInfo("UTC")) - with mock.patch("django.utils.timezone.now", return_value=mocked_now): - response = self.client.patch( - f"{self.base_url}/{relation.id}/order-groups/{order_group.id}/", - content_type="application/json", - data={ - "start": "2025-01-15T00:00:00Z", - "end": "2025-02-20T00:00:00Z", - }, - ) - - order_group.refresh_from_db() - content = response.json() - - self.assertEqual(response.status_code, HTTPStatus.OK) - self.assertEqual(content["start"], "2025-01-15T00:00:00Z") - self.assertEqual(content["end"], "2025-02-20T00:00:00Z") - self.assertTrue(content["is_active"]) - self.assertFalse(order_group.is_active) - - def test_api_admin_order_group_force_to_is_active_false_when_is_active_due_to_start_and_end( - self, - ): - """ - When we want to force an order group to stop suddenly, we need to set the start - and end date to None in order to update `is_active` in the database. - """ - 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, - start="2025-01-15T00:00:00Z", - end="2025-02-20T00:00:00Z", - ) + 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), + }, + ] - mocked_now = datetime(2025, 1, 30, 16, 20, tzinfo=ZoneInfo("UTC")) - with mock.patch("django.utils.timezone.now", return_value=mocked_now): - response = self.client.get( - f"{self.base_url}/{relation.id}/order-groups/{order_group.id}/" - ) + 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"], + ) - content = response.json() - order_group.refresh_from_db() + response = self.client.get( + f"{self.base_url}/{relation.id}/order-groups/{order_group.id}/" + ) - self.assertEqual(response.status_code, HTTPStatus.OK) - self.assertTrue(content["is_active"]) - self.assertTrue(order_group.is_active) - - # If we want to force it to stop and return is_active False - # We should set the start and end date to None - # Because : When there are date, the truth is the computed `is_active` value. - # When there are no dates at all, `is_active` in database value is the truth. - with mock.patch("django.utils.timezone.now", return_value=mocked_now): - response = self.client.patch( - f"{self.base_url}/{relation.id}/order-groups/{order_group.id}/", - content_type="application/json", - data={ - "start": None, - "end": None, - "is_active": False, - }, - ) + content = response.json() - content = response.json() - order_group.refresh_from_db() + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertFalse(content["is_active"]) - self.assertEqual(response.status_code, HTTPStatus.OK) - self.assertFalse(content["is_active"]) - self.assertFalse(order_group.is_active) + order_group.is_active = True + order_group.save() - response = self.client.get( - f"{self.base_url}/{relation.id}/order-groups/{order_group.id}/" - ) + response = self.client.get( + f"{self.base_url}/{relation.id}/order-groups/{order_group.id}/" + ) - content = response.json() - order_group.refresh_from_db() + content = response.json() - self.assertEqual(response.status_code, HTTPStatus.OK) - self.assertFalse(content["is_active"]) - self.assertFalse(order_group.is_active) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertTrue(content["is_active"]) 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 94b7851a8..f51705afd 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 @@ -777,7 +777,7 @@ def test_api_course_product_relation_read_detail_with_order_groups(self): course=course, product=product, order_group=order_group1, state=state ) - with self.assertNumQueries(53): + with self.assertNumQueries(57): self.client.get( f"/api/v1.0/course-product-relations/{relation.id}/", HTTP_AUTHORIZATION=f"Bearer {token}", 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 df26569c8..483845990 100644 --- a/src/backend/joanie/tests/core/test_models_order_group.py +++ b/src/backend/joanie/tests/core/test_models_order_group.py @@ -9,7 +9,7 @@ from django.test import TestCase from django.utils import timezone -from joanie.core import factories +from joanie.core import enums, factories class OrderGroupModelTestCase(TestCase): @@ -63,3 +63,212 @@ def test_model_order_group_set_start_and_end_date(self): self.assertEqual(order_group_2.start, None) self.assertEqual(order_group_2.end, None) + + def test_models_order_group_is_enabled_when_is_active_is_equal_to_false(self): + """ + When the order group field `is_active` is set to False, the computed value should + return False. + """ + order_group = factories.OrderGroupFactory(is_active=False, start=None, end=None) + self.assertFalse(order_group.is_enabled) + + def test_models_order_group_is_enabled_when_is_active_is_equal_to_true_with_no_dates( + self, + ): + """ + When the order group field `is_active` is set to True and there are no dates setted on + `start` and `end`, the computed value should return True. + """ + order_group = factories.OrderGroupFactory(is_active=True, start=None, end=None) + self.assertTrue(order_group.is_enabled) + + def test_models_order_group_is_enabled_when_current_day_in_between_start_and_end_dates( + self, + ): + """ + When the order group field `is_active` is set to True and the current date is between + the start and end date of the order group, the computed value should return True. + Otherwise, when set to False, the computed value should always return False. + """ + order_group = factories.OrderGroupFactory( + is_active=True, + start=timezone.now() - timedelta(days=1), + end=timezone.now() + timedelta(days=1), + ) + self.assertTrue(order_group.is_enabled) + + order_group.is_active = False + order_group.save() + + self.assertFalse(order_group.is_enabled) + + def test_models_order_group_is_enabled_when_is_active_is_equal_to_true_but_no_more_seats( + self, + ): + """ + When the order group field `is_active` is set to True and but there are no more + seats, the computed value should return False. + """ + relation = factories.CourseProductRelationFactory() + order_group = factories.OrderGroupFactory( + course_product_relation=relation, + is_active=True, + start=timezone.now() - timedelta(days=1), + end=timezone.now() + timedelta(days=1), + nb_seats=1, + ) + # Create an order on that order group and set as completed + factories.OrderGeneratorFactory( + course=relation.course, + product=relation.product, + order_group=order_group, + state=enums.ORDER_STATE_COMPLETED, + ) + + self.assertFalse(order_group.is_enabled) + + def test_models_order_group_is_enabled_current_day_between_enrollment_start_and_end_cred_prod( + self, + ): + """ + When the product is of type credential and the order group is not active but the + current date is in between the `enrollment_start` date and the order group `end` date, + the computed value should return False. Otherwise when the is_active field is set to + True, the computed value should return True. + """ + course_run_1, course_run_2 = factories.CourseRunFactory.create_batch( + 2, + start=timezone.now() - timedelta(days=1), + end=timezone.now() + timedelta(days=3), + enrollment_start=timezone.now() - timedelta(days=2), + enrollment_end=timezone.now() + timedelta(days=1), + ) + product = factories.ProductFactory( + price=123, + type=enums.PRODUCT_TYPE_CREDENTIAL, + target_courses=[course_run_1.course, course_run_2.course], + ) + relation = factories.CourseProductRelationFactory(product=product) + + order_group = factories.OrderGroupFactory( + course_product_relation=relation, + is_active=False, + start=None, + end=timezone.now() + timedelta(days=1), + ) + + 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_current_day_between_enrollment_end_and_start_cred_prod( + self, + ): + """ + When the product is of type credential and the order group is not active but the + current date is in between the `enrollment_end` date and the order group `start` date, + the computed value should returnFalse. Otherwise, if the order group `is_active` is + set to True, it should return True. + """ + course_run_1, course_run_2 = factories.CourseRunFactory.create_batch( + 2, + start=timezone.now() - timedelta(days=1), + end=timezone.now() + timedelta(days=3), + enrollment_start=timezone.now() - timedelta(days=2), + enrollment_end=timezone.now() + timedelta(days=1), + ) + product = factories.ProductFactory( + price=123, + type=enums.PRODUCT_TYPE_CREDENTIAL, + target_courses=[course_run_1.course, course_run_2.course], + ) + relation = factories.CourseProductRelationFactory(product=product) + + order_group = factories.OrderGroupFactory( + course_product_relation=relation, + is_active=False, + start=timezone.now(), + 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_current_day_between_enrollment_start_and_end_cert_prod( + self, + ): + """ + When the product is type certificate and the order group is not active but the current + date is in between the `enrollment_start` and order group `end` datetime, the computed + value should return False. Otherwise, if the order group is active then it should + return True. + """ + course_run = factories.CourseRunFactory( + enrollment_start=timezone.now() - timedelta(days=4), + enrollment_end=timezone.now() + timedelta(days=2), + start=timezone.now() - timedelta(days=1), + end=timezone.now() + timedelta(days=4), + ) + product = factories.ProductFactory( + price=3, + type=enums.PRODUCT_TYPE_CERTIFICATE, + ) + relation = factories.CourseProductRelationFactory( + course=course_run.course, + product=product, + ) + order_group = factories.OrderGroupFactory( + course_product_relation=relation, + is_active=False, + start=None, + end=timezone.now() + timedelta(days=1), + ) + 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_current_day_between_enrollment_end_and_start_cert_prod( + self, + ): + """ + When the product is type certificate and the order group is not active but the current + date is in between the `enrollment_end` and order group `start` datetime, the computed + value should return False. Otherwise, if the order group is active then it should + return True. + """ + course_run = factories.CourseRunFactory( + enrollment_start=timezone.now() - timedelta(days=4), + enrollment_end=timezone.now() + timedelta(days=2), + start=timezone.now() - timedelta(days=1), + end=timezone.now() + timedelta(days=4), + ) + product = factories.ProductFactory( + price=3, + type=enums.PRODUCT_TYPE_CERTIFICATE, + ) + relation = factories.CourseProductRelationFactory( + course=course_run.course, + product=product, + ) + order_group = factories.OrderGroupFactory( + course_product_relation=relation, + is_active=False, + start=timezone.now(), + end=None, + ) + self.assertFalse(order_group.is_enabled) + + order_group.is_active = True + order_group.save() + + self.assertTrue(order_group.is_enabled) diff --git a/src/backend/joanie/tests/core/utils/test_utils_ordergroup.py b/src/backend/joanie/tests/core/utils/test_utils_ordergroup.py deleted file mode 100644 index 43c943c2d..000000000 --- a/src/backend/joanie/tests/core/utils/test_utils_ordergroup.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Test suite for utils ordergroup methods""" - -from datetime import datetime -from unittest import mock -from zoneinfo import ZoneInfo - -from django.test import TestCase - -from joanie.core.factories import OrderGroupFactory -from joanie.core.utils.ordergroup import is_active - - -class UtilsOrderGroupTestCase(TestCase): - """Test suite for utils ordergroup methods""" - - def test_utils_ordergroup_is_active_should_return_false_when_no_start_nor_end_date_setted( - self, - ): - """ - When there are no start nor end datetime, the method `is_active()` should return - the value set on the object's field. - """ - order_group_1 = OrderGroupFactory(is_active=True, start=None, end=None) - order_group_2 = OrderGroupFactory(is_active=False, start=None, end=None) - - self.assertTrue(is_active(order_group_1)) - self.assertFalse(is_active(order_group_2)) - - def test_utils_ordergroup_is_active_when_only_start_date_setted(self): - """ - When there is a start datetime, the object's field value `is_active` does not matter - anymore, the value for `is_active` is computed with timezone.now() and the start datetime - setted. - """ - order_group = OrderGroupFactory( - is_active=True, - start=datetime(2025, 1, 15, 12, 0, tzinfo=ZoneInfo("UTC")), - end=None, - ) - - # Datetime inferior to start date - mocked_now = datetime(2025, 1, 10, 12, 0, tzinfo=ZoneInfo("UTC")) - with mock.patch("django.utils.timezone.now", return_value=mocked_now): - self.assertFalse(is_active(order_group)) - - # Datetime superior to start date - mocked_now = datetime(2025, 1, 18, 12, 0, tzinfo=ZoneInfo("UTC")) - with mock.patch("django.utils.timezone.now", return_value=mocked_now): - self.assertTrue(is_active(order_group)) - - def test_utils_ordergroup_is_active_when_only_end_date_setted(self): - """ - When there is a end datetime, the object's field value `is_active` does not matter anymore, - the value for `is_active` is computed with timezone.now() and the end datetime setted. - """ - order_group = OrderGroupFactory( - is_active=True, - start=None, - end=datetime(2025, 1, 15, 12, 0, tzinfo=ZoneInfo("UTC")), - ) - - # Datetime superior to end date: last minute orders are finished - mocked_now = datetime(2025, 1, 18, 12, 0, tzinfo=ZoneInfo("UTC")) - with mock.patch("django.utils.timezone.now", return_value=mocked_now): - self.assertFalse(is_active(order_group)) - - # Datetime inferior to end date : last minite orders are opened - mocked_now = datetime(2025, 1, 10, 12, 0, tzinfo=ZoneInfo("UTC")) - with mock.patch("django.utils.timezone.now", return_value=mocked_now): - self.assertTrue(is_active(order_group)) - - def test_utils_ordergroup_is_active_when_both_datetime_start_and_end_date(self): - """ - When there is a start and end datetime, the object's field value `is_active` does not - matter anymore, the value for `is_active` is computed with timezone.now() and the - start and end datetime values setted. - """ - order_group = OrderGroupFactory( - is_active=True, - start=datetime(2025, 1, 15, 12, 0, tzinfo=ZoneInfo("UTC")), - end=datetime(2025, 2, 15, 12, 0, tzinfo=ZoneInfo("UTC")), - ) - - # Datetime outside the range of start and end datetime - mocked_now = datetime(2025, 3, 18, 12, 0, tzinfo=ZoneInfo("UTC")) - with mock.patch("django.utils.timezone.now", return_value=mocked_now): - self.assertFalse(is_active(order_group)) - - # Datetime inside the range of start and end datetime - mocked_now = datetime(2025, 2, 12, 12, 0, tzinfo=ZoneInfo("UTC")) - with mock.patch("django.utils.timezone.now", return_value=mocked_now): - self.assertTrue(is_active(order_group)) diff --git a/src/backend/joanie/tests/swagger/admin-swagger.json b/src/backend/joanie/tests/swagger/admin-swagger.json index 062518086..23353ea54 100644 --- a/src/backend/joanie/tests/swagger/admin-swagger.json +++ b/src/backend/joanie/tests/swagger/admin-swagger.json @@ -6458,7 +6458,8 @@ "description": "The maximum number of orders that can be validated for a given order group" }, "is_active": { - "type": "boolean" + "type": "string", + "readOnly": true }, "nb_available_seats": { "type": "integer", @@ -6494,6 +6495,7 @@ "can_edit", "created_on", "id", + "is_active", "nb_available_seats" ] }, @@ -6509,9 +6511,6 @@ "title": "Number of seats", "description": "The maximum number of orders that can be validated for a given order group" }, - "is_active": { - "type": "boolean" - }, "start": { "type": "string", "format": "date-time", diff --git a/src/backend/joanie/tests/swagger/swagger.json b/src/backend/joanie/tests/swagger/swagger.json index fec3c1bc7..4c49eddb8 100644 --- a/src/backend/joanie/tests/swagger/swagger.json +++ b/src/backend/joanie/tests/swagger/swagger.json @@ -6178,7 +6178,7 @@ "description": "primary key for the record as UUID" }, "is_active": { - "type": "boolean", + "type": "string", "readOnly": true }, "nb_seats": {