Skip to content

Commit babf6b7

Browse files
committed
✨(backend) bind course offer into course webhook payload
From Richie we recently add new fields to CourseRun models to set offer available on the CourseRun. As in Joanie we are able to set the course run offer according to product linked to the related course, we update the course synchronization to add this information.
1 parent 8af019c commit babf6b7

File tree

10 files changed

+502
-13
lines changed

10 files changed

+502
-13
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ and this project adheres to
88

99
## [Unreleased]
1010

11+
### Changed
12+
13+
- Add course offer information into course webhook synchronization payload
14+
1115
## [2.17.1] - 2025-03-06
1216

1317
### Fixed

src/backend/joanie/core/apps.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ def ready(self):
2626
sender=models.ProductTargetCourseRelation,
2727
dispatch_uid="save_product_target_course_relation",
2828
)
29+
post_save.connect(
30+
signals.on_save_product,
31+
sender=models.Product,
32+
dispatch_uid="save_product",
33+
)
2934
m2m_changed.connect(
3035
signals.on_change_course_product_relation,
3136
sender=models.Course.products.through,

src/backend/joanie/core/enums.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,3 +230,7 @@
230230
(PAYMENT_STATE_CANCELED, _("Canceled")),
231231
(PAYMENT_STATE_ERROR, _("Error")),
232232
)
233+
234+
# Course offers
235+
COURSE_OFFER_PAID = "paid"
236+
COURSE_OFFER_FREE = "free"

src/backend/joanie/core/models/courses.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -869,7 +869,7 @@ def uri(self):
869869

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

872-
def get_serialized(self, visibility=None):
872+
def get_serialized(self, visibility=None, certifying=True):
873873
"""
874874
Return data for the course run that will be sent to the remote web hooks.
875875
Course run visibility can be forced via the eponym argument.
@@ -895,11 +895,30 @@ def get_serialized(self, visibility=None):
895895
"enrollment_end": self.enrollment_end.isoformat()
896896
if self.enrollment_end
897897
else None,
898+
"certificate_offer": self.get_certificate_offer() if certifying else None,
898899
"languages": self.languages,
899900
"resource_link": self.uri,
900901
"start": self.start.isoformat() if self.start else None,
901902
}
902903

904+
def get_certificate_offer(self):
905+
"""
906+
Return certificate offer if the related course has a certificate product.
907+
According to the product price, the offer is set to 'paid' or 'free'.
908+
"""
909+
max_product_price = self.course.products.filter(
910+
type=enums.PRODUCT_TYPE_CERTIFICATE
911+
).aggregate(models.Max("price"))["price__max"]
912+
913+
if max_product_price is None:
914+
return None
915+
916+
return (
917+
enums.COURSE_OFFER_PAID
918+
if max_product_price > 0
919+
else enums.COURSE_OFFER_FREE
920+
)
921+
903922
# pylint: disable=invalid-name
904923
def get_equivalent_serialized_course_runs_for_related_products(
905924
self, visibility=None

src/backend/joanie/core/models/products.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,9 @@ def get_equivalent_course_run_data(self, visibility=None):
190190
equivalent course run are calculated based on the course runs of each course targeted
191191
by this product.
192192
193+
The offer properties may vary according to the product price. If the product is not free,
194+
we don't want to set explicitly a price.
195+
193196
If a product has no target courses or no related course runs, it will still return
194197
an equivalent course run with null dates and hidden visibility.
195198
"""
@@ -203,6 +206,7 @@ def get_equivalent_course_run_data(self, visibility=None):
203206
or (enums.COURSE_AND_SEARCH if any(dates.values()) else enums.HIDDEN),
204207
"languages": self.get_equivalent_course_run_languages(),
205208
# Get dates from aggregate
209+
**self.get_equivalent_course_run_offer(),
206210
**{
207211
key: value.isoformat() if value else None
208212
for key, value in dates.items()
@@ -231,6 +235,29 @@ def get_equivalent_course_run_dates(self, ignore_archived=False):
231235
ignore_archived=ignore_archived,
232236
)
233237

238+
def get_equivalent_course_run_offer(self):
239+
"""
240+
Return the offer properties for the equivalent course run.
241+
If the product is a certificate, we bind offer information into
242+
certificate_offer properties otherwise we bind into offer properties.
243+
244+
Furthermore, if the product is free, we don't want to set explicitly a price.
245+
"""
246+
247+
fields = {"offer": "offer", "price": "price"}
248+
249+
if self.type == enums.PRODUCT_TYPE_CERTIFICATE:
250+
fields = {"offer": "certificate_offer", "price": "certificate_price"}
251+
252+
if self.price == 0:
253+
return {fields["offer"]: enums.COURSE_OFFER_FREE}
254+
255+
return {
256+
fields["offer"]: enums.COURSE_OFFER_PAID,
257+
fields["price"]: self.price,
258+
"price_currency": settings.DEFAULT_CURRENCY,
259+
}
260+
234261
@staticmethod
235262
def get_equivalent_serialized_course_runs_for_products(
236263
products, courses=None, visibility=None
@@ -268,6 +295,38 @@ def get_equivalent_serialized_course_runs_for_products(
268295

269296
return equivalent_course_runs
270297

298+
@staticmethod
299+
def get_serialized_certificated_course_runs(
300+
products, courses=None, certifying=True
301+
):
302+
"""
303+
Return a list of serialized course runs related to
304+
the given certificate products.
305+
306+
visibility: [CATALOG_VISIBILITY_CHOICES]:
307+
If not None, force visibility for the synchronized products. Useful when
308+
synchronizing a product that does not have anymore course runs and should
309+
therefore be hidden.
310+
"""
311+
serialized_course_runs = []
312+
now = timezone.now()
313+
314+
for product in products:
315+
if product.type != enums.PRODUCT_TYPE_CERTIFICATE:
316+
continue
317+
318+
courses = courses or product.courses.all()
319+
course_runs = CourseRun.objects.filter(course__in=courses, end__gt=now)
320+
321+
serialized_course_runs.extend(
322+
[
323+
course_run.get_serialized(certifying=certifying)
324+
for course_run in course_runs
325+
]
326+
)
327+
328+
return serialized_course_runs
329+
271330
@property
272331
def state(self) -> str:
273332
"""

src/backend/joanie/core/signals.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ def on_save_product_target_course_relation(instance, **kwargs):
104104
webhooks.synchronize_course_runs(serialized_course_runs)
105105

106106

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

135137
elif isinstance(instance, models.Product):
136-
if action == "post_add":
138+
# If the product is a certificate, we need to synchronize the course runs
139+
# on which the product is linked (through the course)
140+
if instance.type == enums.PRODUCT_TYPE_CERTIFICATE:
141+
if action == "post_add":
142+
serialized_course_runs = (
143+
models.Product.get_serialized_certificated_course_runs([instance])
144+
)
145+
elif action == "pre_clear":
146+
serialized_course_runs = (
147+
models.Product.get_serialized_certificated_course_runs(
148+
[instance],
149+
certifying=False,
150+
)
151+
)
152+
elif action == "pre_remove":
153+
serialized_course_runs = (
154+
models.Product.get_serialized_certificated_course_runs(
155+
[instance],
156+
models.Course.objects.filter(pk__in=pk_set),
157+
certifying=False,
158+
)
159+
)
160+
else:
161+
return
162+
elif action == "post_add":
137163
serialized_course_runs = (
138164
models.Product.get_equivalent_serialized_course_runs_for_products(
139165
[instance]
@@ -159,3 +185,27 @@ def on_change_course_product_relation(action, instance, pk_set, **kwargs):
159185
return
160186

161187
webhooks.synchronize_course_runs(serialized_course_runs)
188+
189+
190+
def on_save_product(instance, created, **kwargs):
191+
"""
192+
Synchronize product or all ongoing and future course runs
193+
if the product is a certificate when product is updated.
194+
"""
195+
# If the product is created, it can't have yet any course linked to it
196+
# so we don't need to synchronize it
197+
if created:
198+
return
199+
200+
if instance.type == enums.PRODUCT_TYPE_CERTIFICATE:
201+
serialized_course_runs = models.Product.get_serialized_certificated_course_runs(
202+
[instance]
203+
)
204+
else:
205+
serialized_course_runs = (
206+
models.Product.get_equivalent_serialized_course_runs_for_products(
207+
[instance]
208+
)
209+
)
210+
211+
webhooks.synchronize_course_runs(serialized_course_runs)

src/backend/joanie/core/utils/webhooks.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import logging
99

1010
from django.conf import settings
11+
from django.core.serializers.json import DjangoJSONEncoder
1112

1213
import requests
1314
from urllib3.util import Retry
@@ -33,7 +34,10 @@ def synchronize_course_runs(serialized_course_runs):
3334
if not settings.COURSE_WEB_HOOKS or not serialized_course_runs:
3435
return
3536

36-
json_course_runs = json.dumps(serialized_course_runs).encode("utf-8")
37+
json_course_runs = json.dumps(serialized_course_runs, cls=DjangoJSONEncoder).encode(
38+
"utf-8"
39+
)
40+
3741
for webhook in settings.COURSE_WEB_HOOKS:
3842
signature = hmac.new(
3943
str(webhook["secret"]).encode("utf-8"),
@@ -44,8 +48,11 @@ def synchronize_course_runs(serialized_course_runs):
4448
try:
4549
response = session.post(
4650
webhook["url"],
47-
json=serialized_course_runs,
48-
headers={"Authorization": f"SIG-HMAC-SHA256 {signature:s}"},
51+
data=json_course_runs,
52+
headers={
53+
"Authorization": f"SIG-HMAC-SHA256 {signature:s}",
54+
"Content-Type": "application/json",
55+
},
4956
verify=bool(webhook.get("verify", True)),
5057
timeout=3,
5158
)

src/backend/joanie/tests/core/test_models_course_run.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from django.test import TestCase
1212
from django.utils import timezone as django_timezone
1313

14-
from joanie.core import factories
14+
from joanie.core import enums, factories
1515
from joanie.core.factories import CourseRunFactory
1616
from joanie.core.models import CourseRun, CourseState, Enrollment
1717

@@ -452,6 +452,7 @@ def test_model_course_run_get_serialized(self):
452452
"enrollment_end": "2022-09-09T09:00:00+00:00",
453453
"languages": course_run.languages,
454454
"catalog_visibility": "course_and_search",
455+
"certificate_offer": None,
455456
},
456457
)
457458
course_run.is_listed = False
@@ -467,6 +468,7 @@ def test_model_course_run_get_serialized(self):
467468
"enrollment_end": "2022-09-09T09:00:00+00:00",
468469
"languages": course_run.languages,
469470
"catalog_visibility": "hidden",
471+
"certificate_offer": None,
470472
},
471473
)
472474

@@ -493,6 +495,7 @@ def test_model_course_run_get_serialized_hidden(self):
493495
"enrollment_end": "2022-09-09T09:00:00+00:00",
494496
"languages": course_run.languages,
495497
"catalog_visibility": "hidden",
498+
"certificate_offer": None,
496499
},
497500
)
498501

@@ -623,3 +626,51 @@ def test_models_course_run_user_with_no_enrollment_can_enroll(self):
623626

624627
self.assertTrue(course_run.can_enroll(user))
625628
self.assertEqual(Enrollment.objects.count(), 0)
629+
630+
def test_models_course_run_get_certificate_offer_none(self):
631+
"""
632+
Test the get_certificate_offer method of the CourseRun model.
633+
If no certificate product is related to the course, the course run should have
634+
a no offer.
635+
"""
636+
course_run = factories.CourseRunFactory()
637+
self.assertEqual(course_run.get_certificate_offer(), None)
638+
639+
def test_models_course_run_get_certificate_offer_none_with_credential_product(self):
640+
"""
641+
Test the get_certificate_offer method of the CourseRun model.
642+
If no certificate product is related to the course, the course run should have
643+
a no offer.
644+
"""
645+
course_run = factories.CourseRunFactory()
646+
factories.ProductFactory(
647+
courses=[course_run.course],
648+
type=enums.PRODUCT_TYPE_CREDENTIAL,
649+
)
650+
self.assertEqual(course_run.get_certificate_offer(), None)
651+
652+
def test_models_course_run_get_certificate_offer_free(self):
653+
"""
654+
Test the get_certificate_offer method of the CourseRun model.
655+
If a free certificate product is linked to the course, the course run should have
656+
a free offer.
657+
"""
658+
course_run = factories.CourseRunFactory()
659+
factories.ProductFactory(
660+
courses=[course_run.course], type=enums.PRODUCT_TYPE_CERTIFICATE, price=0
661+
)
662+
self.assertEqual(course_run.get_certificate_offer(), enums.COURSE_OFFER_FREE)
663+
664+
def test_models_course_run_get_certificate_offer_paid(self):
665+
"""
666+
Test the get_certificate_offer method of the CourseRun model.
667+
If a not free certificate product is linked to the course, the course run should have
668+
a paid offer.
669+
"""
670+
course_run = factories.CourseRunFactory()
671+
factories.ProductFactory(
672+
courses=[course_run.course],
673+
type=enums.PRODUCT_TYPE_CERTIFICATE,
674+
price=42.00,
675+
)
676+
self.assertEqual(course_run.get_certificate_offer(), enums.COURSE_OFFER_PAID)

0 commit comments

Comments
 (0)