Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨(backend) add start and end date on OrderGroup model #1032

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to

### Added

- Add `start` and `end` datetime fields on order group model
- `owner` and `is_main` fields on `CreditCard` model are deprecated
and been replaced with many-to-many `owners` field instead
- Add a management command to delete unused credit cards
Expand Down
17 changes: 12 additions & 5 deletions src/backend/joanie/core/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand All @@ -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):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Generated by Django 4.2.18 on 2025-02-06 10:16

import django.core.validators
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('core', '0054_discount_discount_discount_rate_or_amount_required_and_more'),
]

operations = [
migrations.AddField(
model_name='ordergroup',
name='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, help_text='order group’s start date and time', 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'),
),
]
99 changes: 94 additions & 5 deletions src/backend/joanie/core/models/products.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -379,6 +381,28 @@ class OrderGroup(BaseModel):
on_delete=models.CASCADE,
)
is_active = models.BooleanField(_("is active"), default=True)
# Available start to end period of activation of the OrderGroup
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# Available start to end period of activation of the OrderGroup

start = models.DateTimeField(
help_text=_("order group’s start date and time"),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe something like this ?

Suggested change
help_text=_("order group’s start date and time"),
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=_("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__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."""
Expand All @@ -397,6 +421,58 @@ 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()

# ruff: noqa: PLR0911
# pylint: disable=too-many-return-statements
@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.
"""
if not self.is_active:
return False
# Unlimited spots
if self.nb_seats is None:
return True
if self.nb_seats - self.get_nb_binding_orders() == 0:
return False
if not self.start and not self.end:
return True

now = timezone.now()
if self.start and self.end:
return self.start <= now <= self.end

if self.course_product_relation.product.type == enums.PRODUCT_TYPE_CERTIFICATE: # pylint: disable=no-member
course_run_dates = (
self.course_product_relation.course.get_equivalent_course_run_dates( # pylint: disable=no-member
ignore_archived=True
)
)
else:
course_run_dates = (
self.course_product_relation.product.get_equivalent_course_run_dates( # pylint: disable=no-member
ignore_archived=True
)
)
if self.end: # Early birds
return course_run_dates["enrollment_start"] <= now <= self.end
if self.start: # Last minutes
return self.start <= now <= course_run_dates["enrollment_end"]

return False


class OrderManager(models.Manager):
"""Custom manager for the Order model."""
Expand Down Expand Up @@ -806,10 +882,23 @@ def clean(self):
)
else:
nb_seats = self.order_group.nb_seats
if 0 < nb_seats <= self.order_group.get_nb_binding_orders():
error_dict["order_group"].append(
f"Maximum number of orders reached for product {product_title:s}"
)
# When nb_seats is None, the order group has unlimited available seats
if nb_seats is not None:
if 0 < nb_seats <= self.order_group.get_nb_binding_orders():
error_dict["order_group"].append(
f"Maximum number of orders reached for product {product_title:s}"
)
# For order group that does not have a fix nb of seats and is not active
# We should block the order creation for that group
if (
course_product_relation
and self.order_group
and not self.order_group.is_active
and self.order_group.nb_seats is None
):
error_dict["order_group"].append(
f"This order group does not accept anymore orders for {product_title:s}"
)

if error_dict:
raise ValidationError(error_dict)
Expand Down
10 changes: 7 additions & 3 deletions src/backend/joanie/core/serializers/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",))
Expand Down
14 changes: 11 additions & 3 deletions src/backend/joanie/core/serializers/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ def test_api_admin_orders_course_retrieve(self):
total=D("1.00"),
)

with self.assertNumQueries(39):
with self.assertNumQueries(40):
response = self.client.get(f"/api/v1.0/admin/orders/{order.id}/")

self.assertEqual(response.status_code, HTTPStatus.OK)
Expand Down Expand Up @@ -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,
Expand Down
84 changes: 84 additions & 0 deletions src/backend/joanie/tests/core/api/order/test_create.py
Original file line number Diff line number Diff line change
Expand Up @@ -1488,6 +1488,90 @@ def test_api_order_create_authenticated_no_seats(self):
)
self.assertEqual(response.status_code, HTTPStatus.CREATED)

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_nb_seat_is_none_on_not_active_order_group(
self,
):
"""
If nb_seats is set to None and the order group is not active, it should not be possible
to create an order.
"""
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=False
)
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.assertEqual(
response.json(),
{
"order_group": [
f"This order group does not accept anymore orders for {relation.product.title}"
]
},
)

def test_api_order_create_authenticated_free_product_no_billing_address(self):
"""
Create an order on a free product without billing address
Expand Down
Loading