Skip to content

Commit

Permalink
refactor!: [FC-0074] make receivers trigger tasks to implement retry …
Browse files Browse the repository at this point in the history
…mechanism (#8)
  • Loading branch information
mariajgrimaldi authored Jan 24, 2025
1 parent d5003eb commit ee00ac2
Show file tree
Hide file tree
Showing 14 changed files with 635 additions and 102 deletions.
9 changes: 9 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ Unreleased

*

[0.2.0] - 2024-01-24
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Added
_____

* Modernize repo and readme with latest Open edX Events updates.
* Make receivers trigger tasks to implement retry mechanism.

[0.1.0] - 2021-09-13
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand Down
2 changes: 1 addition & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ For anything non-trivial, the best path is to open an issue in this
repository with as many details about the issue you are facing as you
can provide.

https://github.com/openedx/openedx-events-2-zapier/issues
https://github.com/edunext/openedx-events-2-zapier/issues

For more information about these options, see the `Getting Help <https://openedx.org/getting-help>`__ page.

Expand Down
2 changes: 1 addition & 1 deletion openedx_events_2_zapier/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
This repository contains real-life use cases for Open edX Events..
"""

__version__ = "0.1.0"
__version__ = "0.2.0"

default_app_config = "openedx_events_2_zapier.apps.OpenedxEvents2ZapierConfig"
43 changes: 16 additions & 27 deletions openedx_events_2_zapier/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,6 @@
Where handlers for Open edX Events are defined.
"""

import logging

import requests
from attr import asdict
from django.conf import settings
from django.dispatch import receiver
Expand All @@ -14,18 +11,19 @@
STUDENT_REGISTRATION_COMPLETED,
)

from openedx_events_2_zapier.utils import flatten_dict, serialize_course_key

ZAPIER_REQUEST_TIMEOUT = 5
log = logging.getLogger(__name__)
from openedx_events_2_zapier.tasks import send_data_to_zapier
from openedx_events_2_zapier.utils import serialize_course_key


@receiver(STUDENT_REGISTRATION_COMPLETED)
def send_user_data_to_webhook(
signal, sender, user, metadata, **kwargs # pylint: disable=unused-argument
):
"""
POST user's data after STUDENT_REGISTRATION_COMPLETED event is sent.
Trigger a task to send the user data to the Zapier webhook.
This handler is triggered when the STUDENT_REGISTRATION_COMPLETED event
is sent.
Arguments:
signal: The signal that was sent.
Expand Down Expand Up @@ -55,20 +53,17 @@ def send_user_data_to_webhook(
"user": asdict(user),
"event_metadata": asdict(metadata),
}
log.info("Sending user data to Zapier: %s", zapier_payload)
requests.post(
settings.ZAPIER_REGISTRATION_WEBHOOK,
flatten_dict(zapier_payload),
timeout=ZAPIER_REQUEST_TIMEOUT,
)
send_data_to_zapier.delay(settings.ZAPIER_REGISTRATION_WEBHOOK, zapier_payload)


@receiver(COURSE_ENROLLMENT_CREATED)
def send_enrollment_data_to_webhook(
signal, sender, enrollment, metadata, **kwargs # pylint: disable=unused-argument
):
"""
POST enrollment's data after COURSE_ENROLLMENT_CREATED event is sent.
Trigger a task to send the enrollment data to the Zapier webhook.
This handler is triggered when the COURSE_ENROLLMENT_CREATED event is sent.
Arguments:
signal: The signal that was sent.
Expand Down Expand Up @@ -106,20 +101,17 @@ def send_enrollment_data_to_webhook(
"enrollment": asdict(enrollment, value_serializer=serialize_course_key),
"event_metadata": asdict(metadata),
}
log.info("Sending enrollment data to Zapier: %s", zapier_payload)
requests.post(
settings.ZAPIER_ENROLLMENT_WEBHOOK,
flatten_dict(zapier_payload),
timeout=ZAPIER_REQUEST_TIMEOUT,
)
send_data_to_zapier.delay(settings.ZAPIER_ENROLLMENT_WEBHOOK, zapier_payload)


@receiver(PERSISTENT_GRADE_SUMMARY_CHANGED)
def send_persistent_grade_course_data_to_webhook(
signal, sender, grade, metadata, **kwargs # pylint: disable=unused-argument
):
"""
POST user's data after PERSISTENT_GRADE_SUMMARY_CHANGED event is sent.
Trigger a task to send the grade data to the Zapier webhook.
This handler is triggered when the PERSISTENT_GRADE_SUMMARY_CHANGED event is sent.
Arguments:
signal: The signal that was sent.
Expand Down Expand Up @@ -153,9 +145,6 @@ def send_persistent_grade_course_data_to_webhook(
"grade": asdict(grade, value_serializer=serialize_course_key),
"event_metadata": asdict(metadata),
}
log.info("Sending grade data to Zapier: %s", zapier_payload)
requests.post(
settings.ZAPIER_PERSISTENT_GRADE_COURSE_WEBHOOK,
flatten_dict(zapier_payload),
timeout=ZAPIER_REQUEST_TIMEOUT,
send_data_to_zapier.delay(
settings.ZAPIER_PERSISTENT_GRADE_COURSE_WEBHOOK, zapier_payload
)
36 changes: 36 additions & 0 deletions openedx_events_2_zapier/tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""Celery tasks for sending data to Zapier or another webhook."""

import logging

from celery import shared_task
from requests import exceptions, post

from openedx_events_2_zapier.utils import flatten_dict

ZAPIER_REQUEST_TIMEOUT = 5
ZAPIER_RETRY_COUNTDOWN = 3
log = logging.getLogger(__name__)


@shared_task(
bind=True,
autoretry_for=(exceptions.RequestException,),
retry_backoff=True,
retry_kwargs={"max_retries": ZAPIER_RETRY_COUNTDOWN},
)
def send_data_to_zapier(self, zap_url, data): # pylint: disable=unused-argument
"""
Send data to Zapier using a webhook.
Arguments:
self: The task instance.
zap_url: The URL of the Zapier webhook.
data: The data to send to the webhook.
"""
flattened_data = flatten_dict(data)
try:
log.info("Sending data to Zapier: %s", flattened_data)
post(zap_url, flattened_data, timeout=ZAPIER_REQUEST_TIMEOUT)
except exceptions.RequestException as e:
log.error("Error sending data to Zapier: %s", e)
raise
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
"""This file contains all test for the handlers.py file.
"""This file contains all test for the handlers.py file."""

Classes:
EventsToolingTest: Test events tooling.
"""

import datetime
from datetime import datetime
from unittest.mock import patch

from django.test import TestCase
from attr import asdict
from django.test import TestCase, override_settings
from opaque_keys.edx.keys import CourseKey
from openedx_events.data import EventsMetadata
from openedx_events.learning.data import (
Expand All @@ -28,6 +25,7 @@
send_persistent_grade_course_data_to_webhook,
send_user_data_to_webhook,
)
from openedx_events_2_zapier.utils import serialize_course_key


class RegistrationCompletedReceiverTest(TestCase):
Expand All @@ -54,33 +52,38 @@ def setUp(self):
minorversion=0,
)

@patch("openedx_events_2_zapier.handlers.requests")
def test_receiver_called_after_event(self, request_mock):
@override_settings(ZAPIER_REGISTRATION_WEBHOOK="https://webhook.site")
@patch("openedx_events_2_zapier.handlers.send_data_to_zapier")
def test_receiver_called_after_event(self, task_mock):
"""
Test that send_user_data_to_webhook is called the correct information after sending
STUDENT_REGISTRATION_COMPLETED event.
Expected Behavior:
- The task is called with the correct URL and payload.
"""
expected_payload_subset = {
"user_id": 39,
"user_is_active": True,
"user_pii_username": "test",
"user_pii_email": "[email protected]",
"user_pii_name": "Test Example",
"event_metadata_event_type": self.metadata.event_type,
"event_metadata_minorversion": self.metadata.minorversion,
"event_metadata_source": self.metadata.source,
"event_metadata_sourcehost": self.metadata.sourcehost,
"event_metadata_sourcelib": list(self.metadata.sourcelib),
expected_payload = {
"user": asdict(self.user),
"event_metadata": asdict(self.metadata),
}
STUDENT_REGISTRATION_COMPLETED.connect(send_user_data_to_webhook)

STUDENT_REGISTRATION_COMPLETED.send_event(
user=self.user,
)

task_mock.delay.assert_called_once()
self.assertDictContainsSubset(
expected_payload_subset,
request_mock.post.call_args.args[1],
expected_payload["user"],
task_mock.delay.call_args[0][1]["user"],
)
self.assertEqual(
task_mock.delay.call_args[0][1]["event_metadata"],
{
**expected_payload["event_metadata"],
"id": task_mock.delay.call_args[0][1]["event_metadata"]["id"],
"time": task_mock.delay.call_args[0][1]["event_metadata"]["time"],
},
)


Expand Down Expand Up @@ -110,55 +113,54 @@ def setUp(self):
),
mode="audit",
is_active=True,
creation_date=datetime.datetime(2021, 9, 21, 17, 40, 27),
creation_date=datetime(2021, 9, 21, 17, 40, 27),
)
self.metadata = EventsMetadata(
event_type="org.openedx.learning.course.enrollment.created.v1",
minorversion=0,
)

@patch("openedx_events_2_zapier.handlers.requests")
def test_receiver_called_after_event(self, request_mock):
@override_settings(ZAPIER_ENROLLMENT_WEBHOOK="https://webhook.site")
@patch("openedx_events_2_zapier.handlers.send_data_to_zapier")
def test_receiver_called_after_event(self, task_mock):
"""
Test that send_user_data_to_webhook is called the correct information after sending
COURSE_ENROLLMENT_CREATED event.
Expected Behavior:
- The task is called with the correct URL and payload.
"""
expected_payload_subset = {
"enrollment_user_id": 42,
"enrollment_user_is_active": True,
"enrollment_user_pii_username": "test",
"enrollment_user_pii_email": "[email protected]",
"enrollment_user_pii_name": "Test Example",
"enrollment_course_course_key": "course-v1:edX+100+2021",
"enrollment_course_display_name": "Demonstration Course",
"enrollment_course_start": None,
"enrollment_course_end": None,
"enrollment_mode": "audit",
"enrollment_is_active": True,
"enrollment_creation_date": datetime.datetime(2021, 9, 21, 17, 40, 27),
"enrollment_created_by": None,
"event_metadata_event_type": self.metadata.event_type,
"event_metadata_minorversion": self.metadata.minorversion,
"event_metadata_source": self.metadata.source,
"event_metadata_sourcehost": self.metadata.sourcehost,
"event_metadata_sourcelib": list(self.metadata.sourcelib),
expected_payload = {
"enrollment": asdict(
self.enrollment, value_serializer=serialize_course_key
),
"event_metadata": asdict(self.metadata),
}
COURSE_ENROLLMENT_CREATED.connect(send_enrollment_data_to_webhook)

COURSE_ENROLLMENT_CREATED.send_event(
enrollment=self.enrollment,
)

self.assertDictContainsSubset(
expected_payload_subset,
request_mock.post.call_args.args[1],
task_mock.delay.assert_called_once()
self.assertEqual(
expected_payload["enrollment"],
task_mock.delay.call_args[0][1]["enrollment"],
)
self.assertEqual(
task_mock.delay.call_args[0][1]["event_metadata"],
{
**expected_payload["event_metadata"],
"id": task_mock.delay.call_args[0][1]["event_metadata"]["id"],
"time": task_mock.delay.call_args[0][1]["event_metadata"]["time"],
},
)


class PersistentGradeEventsTest(TestCase):
"""
Test that send_persistent_grade_course_data_to_webhook is called the correct information after sending
COURSE_ENROLLMENT_CREATED event.
PERSISTENT_GRADE_SUMMARY_CHANGED event.
"""

def setUp(self):
Expand All @@ -172,39 +174,31 @@ def setUp(self):
course_key=CourseKey.from_string("course-v1:edX+100+2021"),
display_name="Demonstration Course",
),
course_edited_timestamp=datetime.datetime(2021, 9, 21, 17, 40, 27),
course_edited_timestamp=datetime(2021, 9, 21, 17, 40, 27),
course_version="",
grading_policy_hash="",
percent_grade=80,
letter_grade="Great",
passed_timestamp=datetime.datetime(2021, 9, 21, 17, 40, 27),
passed_timestamp=datetime(2021, 9, 21, 17, 40, 27),
)
self.metadata = EventsMetadata(
event_type="org.openedx.learning.course.persistent_grade_summary.changed.v1",
minorversion=0,
)

@patch("openedx_events_2_zapier.handlers.requests")
def test_receiver_called_after_event(self, request_mock):
@override_settings(ZAPIER_PERSISTENT_GRADE_COURSE_WEBHOOK="https://webhook.site")
@patch("openedx_events_2_zapier.handlers.send_data_to_zapier")
def test_receiver_called_after_event(self, task_mock):
"""
Test that send_persistent_grade_course_data_to_webhook is called the correct information after sending
COURSE_ENROLLMENT_CREATED event.
PERSISTENT_GRADE_SUMMARY_CHANGED event.
Expected Behavior:
- The task is called with the correct URL and payload.
"""
expected_payload_subset = {
"grade_user_id": 42,
"grade_course_course_key": "course-v1:edX+100+2021",
"grade_course_display_name": "Demonstration Course",
"grade_course_edited_timestamp": datetime.datetime(2021, 9, 21, 17, 40, 27),
"grade_course_version": "",
"grade_grading_policy_hash": "",
"grade_percent_grade": 80,
"grade_letter_grade": "Great",
"grade_passed_timestamp": datetime.datetime(2021, 9, 21, 17, 40, 27),
"event_metadata_event_type": self.metadata.event_type,
"event_metadata_minorversion": self.metadata.minorversion,
"event_metadata_source": self.metadata.source,
"event_metadata_sourcehost": self.metadata.sourcehost,
"event_metadata_sourcelib": list(self.metadata.sourcelib),
expected_payload = {
"grade": asdict(self.grade, value_serializer=serialize_course_key),
"event_metadata": asdict(self.metadata),
}
PERSISTENT_GRADE_SUMMARY_CHANGED.connect(
send_persistent_grade_course_data_to_webhook
Expand All @@ -214,7 +208,16 @@ def test_receiver_called_after_event(self, request_mock):
grade=self.grade,
)

self.assertDictContainsSubset(
expected_payload_subset,
request_mock.post.call_args.args[1],
task_mock.delay.assert_called_once()
self.assertEqual(
expected_payload["grade"],
task_mock.delay.call_args[0][1]["grade"],
)
self.assertEqual(
task_mock.delay.call_args[0][1]["event_metadata"],
{
**expected_payload["event_metadata"],
"id": task_mock.delay.call_args[0][1]["event_metadata"]["id"],
"time": task_mock.delay.call_args[0][1]["event_metadata"]["time"],
},
)
Loading

0 comments on commit ee00ac2

Please sign in to comment.