Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ and this project adheres to

## [Unreleased]

### Changed

- Add course offer information into course webhook synchronization payload

## [2.17.1] - 2025-03-06

### Fixed
Expand Down
5 changes: 5 additions & 0 deletions src/backend/joanie/core/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ def ready(self):
sender=models.ProductTargetCourseRelation,
dispatch_uid="save_product_target_course_relation",
)
post_save.connect(
signals.on_save_product,
sender=models.Product,
dispatch_uid="save_product",
)
m2m_changed.connect(
signals.on_change_course_product_relation,
sender=models.Course.products.through,
Expand Down
4 changes: 4 additions & 0 deletions src/backend/joanie/core/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,3 +230,7 @@
(PAYMENT_STATE_CANCELED, _("Canceled")),
(PAYMENT_STATE_ERROR, _("Error")),
)

# Course offers
COURSE_OFFER_PAID = "paid"
COURSE_OFFER_FREE = "free"
21 changes: 20 additions & 1 deletion src/backend/joanie/core/models/courses.py
Original file line number Diff line number Diff line change
Expand Up @@ -869,7 +869,7 @@ def uri(self):

return f"https://{site.domain:s}{resource_path:s}"

def get_serialized(self, visibility=None):
def get_serialized(self, visibility=None, certifying=True):
"""
Return data for the course run that will be sent to the remote web hooks.
Course run visibility can be forced via the eponym argument.
Expand All @@ -895,11 +895,30 @@ def get_serialized(self, visibility=None):
"enrollment_end": self.enrollment_end.isoformat()
if self.enrollment_end
else None,
"certificate_offer": self.get_certificate_offer() if certifying else None,
"languages": self.languages,
"resource_link": self.uri,
"start": self.start.isoformat() if self.start else None,
}

def get_certificate_offer(self):
"""
Return certificate offer if the related course has a certificate product.
According to the product price, the offer is set to 'paid' or 'free'.
"""
max_product_price = self.course.products.filter(
type=enums.PRODUCT_TYPE_CERTIFICATE
).aggregate(models.Max("price"))["price__max"]

if max_product_price is None:
return None

return (
enums.COURSE_OFFER_PAID
if max_product_price > 0
else enums.COURSE_OFFER_FREE
)

# pylint: disable=invalid-name
def get_equivalent_serialized_course_runs_for_related_products(
self, visibility=None
Expand Down
59 changes: 59 additions & 0 deletions src/backend/joanie/core/models/products.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,9 @@ def get_equivalent_course_run_data(self, visibility=None):
equivalent course run are calculated based on the course runs of each course targeted
by this product.

The offer properties may vary according to the product price. If the product is not free,
we don't want to set explicitly a price.

If a product has no target courses or no related course runs, it will still return
an equivalent course run with null dates and hidden visibility.
"""
Expand All @@ -203,6 +206,7 @@ def get_equivalent_course_run_data(self, visibility=None):
or (enums.COURSE_AND_SEARCH if any(dates.values()) else enums.HIDDEN),
"languages": self.get_equivalent_course_run_languages(),
# Get dates from aggregate
**self.get_equivalent_course_run_offer(),
**{
key: value.isoformat() if value else None
for key, value in dates.items()
Expand Down Expand Up @@ -231,6 +235,29 @@ def get_equivalent_course_run_dates(self, ignore_archived=False):
ignore_archived=ignore_archived,
)

def get_equivalent_course_run_offer(self):
"""
Return the offer properties for the equivalent course run.
If the product is a certificate, we bind offer information into
certificate_offer properties otherwise we bind into offer properties.

Furthermore, if the product is free, we don't want to set explicitly a price.
"""

fields = {"offer": "offer", "price": "price"}

if self.type == enums.PRODUCT_TYPE_CERTIFICATE:
fields = {"offer": "certificate_offer", "price": "certificate_price"}

if self.price == 0:
return {fields["offer"]: enums.COURSE_OFFER_FREE}

return {
fields["offer"]: enums.COURSE_OFFER_PAID,
fields["price"]: self.price,
"price_currency": settings.DEFAULT_CURRENCY,
}

@staticmethod
def get_equivalent_serialized_course_runs_for_products(
products, courses=None, visibility=None
Expand Down Expand Up @@ -268,6 +295,38 @@ def get_equivalent_serialized_course_runs_for_products(

return equivalent_course_runs

@staticmethod
def get_serialized_certificated_course_runs(
products, courses=None, certifying=True
):
"""
Return a list of serialized course runs related to
the given certificate products.

visibility: [CATALOG_VISIBILITY_CHOICES]:
If not None, force visibility for the synchronized products. Useful when
synchronizing a product that does not have anymore course runs and should
therefore be hidden.
"""
serialized_course_runs = []
now = timezone.now()

for product in products:
if product.type != enums.PRODUCT_TYPE_CERTIFICATE:
continue

courses = courses or product.courses.all()
course_runs = CourseRun.objects.filter(course__in=courses, end__gt=now)

serialized_course_runs.extend(
[
course_run.get_serialized(certifying=certifying)
for course_run in course_runs
]
)

return serialized_course_runs

@property
def state(self) -> str:
"""
Expand Down
52 changes: 51 additions & 1 deletion src/backend/joanie/core/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ def on_save_product_target_course_relation(instance, **kwargs):
webhooks.synchronize_course_runs(serialized_course_runs)


# pylint: disable=too-many-branches
# ruff: noqa: PLR0912
def on_change_course_product_relation(action, instance, pk_set, **kwargs):
"""Synchronize products related to the course/product relation being changed."""
if isinstance(instance, models.Course):
Expand Down Expand Up @@ -133,7 +135,31 @@ def on_change_course_product_relation(action, instance, pk_set, **kwargs):
return

elif isinstance(instance, models.Product):
if action == "post_add":
# If the product is a certificate, we need to synchronize the course runs
# on which the product is linked (through the course)
if instance.type == enums.PRODUCT_TYPE_CERTIFICATE:
if action == "post_add":
serialized_course_runs = (
models.Product.get_serialized_certificated_course_runs([instance])
)
elif action == "pre_clear":
serialized_course_runs = (
models.Product.get_serialized_certificated_course_runs(
[instance],
certifying=False,
)
)
elif action == "pre_remove":
serialized_course_runs = (
models.Product.get_serialized_certificated_course_runs(
[instance],
models.Course.objects.filter(pk__in=pk_set),
certifying=False,
)
)
else:
return
elif action == "post_add":
serialized_course_runs = (
models.Product.get_equivalent_serialized_course_runs_for_products(
[instance]
Expand All @@ -159,3 +185,27 @@ def on_change_course_product_relation(action, instance, pk_set, **kwargs):
return

webhooks.synchronize_course_runs(serialized_course_runs)


def on_save_product(instance, created, **kwargs):
"""
Synchronize product or all ongoing and future course runs
if the product is a certificate when product is updated.
"""
# If the product is created, it can't have yet any course linked to it
# so we don't need to synchronize it
if created:
return

if instance.type == enums.PRODUCT_TYPE_CERTIFICATE:
serialized_course_runs = models.Product.get_serialized_certificated_course_runs(
[instance]
)
else:
serialized_course_runs = (
models.Product.get_equivalent_serialized_course_runs_for_products(
[instance]
)
)

webhooks.synchronize_course_runs(serialized_course_runs)
13 changes: 10 additions & 3 deletions src/backend/joanie/core/utils/webhooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import logging

from django.conf import settings
from django.core.serializers.json import DjangoJSONEncoder

import requests
from urllib3.util import Retry
Expand All @@ -33,7 +34,10 @@ def synchronize_course_runs(serialized_course_runs):
if not settings.COURSE_WEB_HOOKS or not serialized_course_runs:
return

json_course_runs = json.dumps(serialized_course_runs).encode("utf-8")
json_course_runs = json.dumps(serialized_course_runs, cls=DjangoJSONEncoder).encode(
"utf-8"
)

for webhook in settings.COURSE_WEB_HOOKS:
signature = hmac.new(
str(webhook["secret"]).encode("utf-8"),
Expand All @@ -44,8 +48,11 @@ def synchronize_course_runs(serialized_course_runs):
try:
response = session.post(
webhook["url"],
json=serialized_course_runs,
headers={"Authorization": f"SIG-HMAC-SHA256 {signature:s}"},
data=json_course_runs,
headers={
"Authorization": f"SIG-HMAC-SHA256 {signature:s}",
"Content-Type": "application/json",
},
verify=bool(webhook.get("verify", True)),
timeout=3,
)
Expand Down
55 changes: 53 additions & 2 deletions src/backend/joanie/tests/core/test_models_course_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from django.test import TestCase
from django.utils import timezone as django_timezone

from joanie.core import factories
from joanie.core import enums, factories
from joanie.core.factories import CourseRunFactory
from joanie.core.models import CourseRun, CourseState, Enrollment

Expand Down Expand Up @@ -452,6 +452,7 @@ def test_model_course_run_get_serialized(self):
"enrollment_end": "2022-09-09T09:00:00+00:00",
"languages": course_run.languages,
"catalog_visibility": "course_and_search",
"certificate_offer": None,
},
)
course_run.is_listed = False
Expand All @@ -467,6 +468,7 @@ def test_model_course_run_get_serialized(self):
"enrollment_end": "2022-09-09T09:00:00+00:00",
"languages": course_run.languages,
"catalog_visibility": "hidden",
"certificate_offer": None,
},
)

Expand All @@ -493,6 +495,7 @@ def test_model_course_run_get_serialized_hidden(self):
"enrollment_end": "2022-09-09T09:00:00+00:00",
"languages": course_run.languages,
"catalog_visibility": "hidden",
"certificate_offer": None,
},
)

Expand Down Expand Up @@ -616,10 +619,58 @@ def test_models_course_run_user_can_enroll_because_old_course_run_is_closed_alre

def test_models_course_run_user_with_no_enrollment_can_enroll(self):
"""
Test that a user that has no enrollment yet, can enroll to the an opened course run.
Test that a user that has no enrollment yet, can enroll to an opened course run.
"""
user = factories.UserFactory()
course_run = factories.CourseRunFactory()

self.assertTrue(course_run.can_enroll(user))
self.assertEqual(Enrollment.objects.count(), 0)

def test_models_course_run_get_certificate_offer_none(self):
"""
Test the get_certificate_offer method of the CourseRun model.
If no certificate product is related to the course, the course run should have
no offer.
"""
course_run = factories.CourseRunFactory()
self.assertEqual(course_run.get_certificate_offer(), None)

def test_models_course_run_get_certificate_offer_none_with_credential_product(self):
"""
Test the get_certificate_offer method of the CourseRun model.
If no certificate product is related to the course, the course run should have
no offer.
"""
course_run = factories.CourseRunFactory()
factories.ProductFactory(
courses=[course_run.course],
type=enums.PRODUCT_TYPE_CREDENTIAL,
)
self.assertEqual(course_run.get_certificate_offer(), None)

def test_models_course_run_get_certificate_offer_free(self):
"""
Test the get_certificate_offer method of the CourseRun model.
If a free certificate product is linked to the course, the course run should have
a free offer.
"""
course_run = factories.CourseRunFactory()
factories.ProductFactory(
courses=[course_run.course], type=enums.PRODUCT_TYPE_CERTIFICATE, price=0
)
self.assertEqual(course_run.get_certificate_offer(), enums.COURSE_OFFER_FREE)

def test_models_course_run_get_certificate_offer_paid(self):
"""
Test the get_certificate_offer method of the CourseRun model.
If a not free certificate product is linked to the course, the course run should have
a paid offer.
"""
course_run = factories.CourseRunFactory()
factories.ProductFactory(
courses=[course_run.course],
type=enums.PRODUCT_TYPE_CERTIFICATE,
price=42.00,
)
self.assertEqual(course_run.get_certificate_offer(), enums.COURSE_OFFER_PAID)
Loading