Skip to content

Commit

Permalink
fixup! ✨(backend) add start and end date on order groups model
Browse files Browse the repository at this point in the history
  • Loading branch information
jonathanreveille committed Jan 30, 2025
1 parent b844aa3 commit b48a129
Show file tree
Hide file tree
Showing 9 changed files with 414 additions and 75 deletions.
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Generated by Django 4.2.18 on 2025-01-29 16:04
# Generated by Django 4.2.18 on 2025-01-30 13:33

from django.db import migrations, models

Expand All @@ -13,16 +13,12 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='ordergroup',
name='end',
field=models.DateTimeField(blank=True, null=True, verbose_name='order group end'),
field=models.DateTimeField(blank=True, help_text='order group’s end date and time', null=True, verbose_name='order group end datetime'),
),
migrations.AddField(
model_name='ordergroup',
name='start',
field=models.DateTimeField(blank=True, null=True, verbose_name='order group start'),
),
migrations.AddConstraint(
model_name='ordergroup',
constraint=models.CheckConstraint(check=models.Q(models.Q(('start__isnull', True), _negated=True), ('end__isnull', True), _connector='XOR'), name='both_start_and_end_dates_must_be_set_or_neither', violation_error_message='Both start and end dates must be set, or neither.'),
field=models.DateTimeField(blank=True, help_text='order group’s start date and time', null=True, verbose_name='order group start datetime'),
),
migrations.AddConstraint(
model_name='ordergroup',
Expand Down
23 changes: 13 additions & 10 deletions src/backend/joanie/core/models/products.py
Original file line number Diff line number Diff line change
Expand Up @@ -379,19 +379,22 @@ class OrderGroup(BaseModel):
on_delete=models.CASCADE,
)
is_active = models.BooleanField(_("is active"), default=True)
# Available start to end period
start = models.DateTimeField(_("order group start"), blank=True, null=True)
end = models.DateTimeField(_("order group end"), blank=True, null=True)
# Available start to end period of activation of the OrderGroup
start = models.DateTimeField(
help_text=_("order group’s start date and time"),
verbose_name=_("order group start datetime"),
blank=True,
null=True,
)
end = models.DateTimeField(
help_text=_("order group’s end date and time"),
verbose_name=_("order group end datetime"),
blank=True,
null=True,
)

class Meta:
constraints = [
models.CheckConstraint(
check=~models.Q(start__isnull=True) ^ models.Q(end__isnull=True),
name="both_start_and_end_dates_must_be_set_or_neither",
violation_error_message=_(
"Both start and end dates must be set, or neither."
),
),
models.CheckConstraint(
check=models.Q(start__lte=models.F("end")),
name="check_start_before_end",
Expand Down
27 changes: 23 additions & 4 deletions src/backend/joanie/core/serializers/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
ThumbnailDetailField,
)
from joanie.core.utils import Echo
from joanie.core.utils.ordergroup import is_active
from joanie.payment import models as payment_models


Expand Down Expand Up @@ -451,10 +452,7 @@ class AdminOrderGroupSerializer(serializers.ModelSerializer):
.validators[1]
.limit_value,
)
is_active = serializers.BooleanField(
required=False,
default=models.OrderGroup._meta.get_field("is_active").default,
)
is_active = serializers.SerializerMethodField(read_only=True)
nb_available_seats = serializers.SerializerMethodField(read_only=True)

class Meta:
Expand All @@ -475,6 +473,25 @@ 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 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"]

return super().update(instance, validated_data)


@extend_schema_serializer(exclude_fields=("course_product_relation",))
class AdminOrderGroupCreateSerializer(AdminOrderGroupSerializer):
Expand All @@ -485,6 +502,8 @@ class AdminOrderGroupCreateSerializer(AdminOrderGroupSerializer):
the order group.
"""

is_active = serializers.BooleanField(required=False)

class Meta(AdminOrderGroupSerializer.Meta):
fields = [*AdminOrderGroupSerializer.Meta.fields, "course_product_relation"]

Expand Down
27 changes: 27 additions & 0 deletions src/backend/joanie/core/utils/ordergroup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""
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
227 changes: 227 additions & 0 deletions src/backend/joanie/tests/core/test_api_admin_order_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@
Test suite for OrderGroup Admin API.
"""

from datetime import datetime
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
Expand Down Expand Up @@ -439,3 +442,227 @@ def test_admin_api_order_group_patch_start_date_or_end_date(self):
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):
"""
The `is_active` field is dynamically computed when the `start` datetime field is set.
Otherwise, it should retrieve the stored value from 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=False
)

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(order_group.is_active) # Stored value in database
self.assertFalse(content["is_active"])

# 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",
},
)

order_group.refresh_from_db()
content = response.json()

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

# 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,
},
)

order_group.refresh_from_db()
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)

def test_admin_api_order_group_patch_end_date_should_compute_is_active(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.
"""
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}/"
)

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
)

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(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",
)

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}/"
)

content = response.json()
order_group.refresh_from_db()

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()
order_group.refresh_from_db()

self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertFalse(content["is_active"])
self.assertFalse(order_group.is_active)

response = self.client.get(
f"{self.base_url}/{relation.id}/order-groups/{order_group.id}/"
)

content = response.json()
order_group.refresh_from_db()

self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertFalse(content["is_active"])
self.assertFalse(order_group.is_active)
Loading

0 comments on commit b48a129

Please sign in to comment.