Skip to content

Commit 2e1d339

Browse files
committed
fix: #212 confirm unsubscribe and skip already completed enrollments
1 parent 5f5f681 commit 2e1d339

8 files changed

Lines changed: 89 additions & 6 deletions

File tree

django_email_learning/jobs/deliver_contents_job.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,16 @@ def run(self) -> None:
4646
job_execution.finished_at = timezone.now()
4747
job_execution.save()
4848
else:
49-
self.process_delivery(delivery_schedule)
49+
try:
50+
self.process_delivery(delivery_schedule)
51+
except Exception as e:
52+
# Unhandled exception during delivery processing should not crash the job.
53+
# We log the error and mark the delivery as blocked to prevent further attempts until manual intervention.
54+
delivery_schedule.status = DeliveryStatus.BLOCKED
55+
delivery_schedule.save()
56+
logger.exception(
57+
f"Error processing delivery schedule: {str(e)}. Continuing with next task."
58+
)
5059

5160
def get_delivery_queue(self) -> DeliveryQueueProtocol:
5261
try:

django_email_learning/personalised/views.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,16 @@ def get(self, request, *args, **kwargs) -> HttpResponse: # type: ignore[no-unty
184184
decoded_token = self.get_decoded_token(request)
185185
if isinstance(decoded_token, HttpResponse):
186186
return decoded_token # Return error response if token is invalid
187+
if request.GET.get("confirm") != "true":
188+
return self.render_to_response(
189+
context={
190+
"page_title": _("Confirm Unsubscription"),
191+
"confirmation_message": _(
192+
"Are you sure you want to unsubscribe from our mailing list?"
193+
),
194+
"confirm_url": f"{request.path}?token={request.GET.get('token')}&confirm=true",
195+
}
196+
)
187197
command = UnsubscribeCommand(
188198
email=decoded_token["email"],
189199
course_slug=decoded_token["course_slug"],

django_email_learning/services/command_models/unsubscribe_command.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ def execute(self) -> None:
4747
return
4848

4949
enrollments = Enrollment.objects.filter(learner=learner, course=course).exclude(
50-
status=EnrollmentStatus.DEACTIVATED
50+
status__in=[EnrollmentStatus.DEACTIVATED, EnrollmentStatus.COMPLETED]
5151
)
5252
if not enrollments.exists():
5353
self.logger.warning(

django_email_learning/templates/personalised/command_result.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44
{% block head_script %}
55
<script>
66
const success_message = "{{ success_message|escapejs }}";
7+
const confirmation_message = "{{ confirmation_message|escapejs }}";
8+
const confirm_url = "{{ confirm_url|escapejs }}";
9+
const localeMessages = {
10+
"Confirm": "{% translate 'Confirm' %}",
11+
}
712
</script>
813
{% vite_asset 'personalised/command_result/CommandResult.jsx' %}
914
{% endblock %}

frontend/personalised/command_result/CommandResult.jsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1-
import { Alert, Box } from '@mui/material';
1+
import { Alert, Box, Button } from '@mui/material';
22
import render from '../../src/render.jsx';
33
import Layout from '../../public/components/Layout.jsx';
44

55

66
const CommandResult = () => {
77
return <Layout>
8-
{ !error_message ?<Alert severity='success' sx={{ maxWidth: 800, margin: '0 auto', backgroundColor: "background.light" }}>
8+
{ !error_message && !confirmation_message ?<Alert severity='success' sx={{ maxWidth: 800, margin: '0 auto', backgroundColor: "background.light" }}>
99
{success_message}
10+
</Alert> : confirmation_message ? <Alert severity="warning" sx={{ maxWidth: 800, margin: '20px auto' }}>
11+
{confirmation_message}<Box mt={2}><Button href={confirm_url} variant='contained' mt={2}>{localeMessages["Confirm"]}</Button></Box>
1012
</Alert> : <Alert severity="error" sx={{ maxWidth: 800, margin: '20px auto' }}>
1113
{error_message} (ref: {ref})
1214
</Alert>}

tests/jobs/test_deliver_content_jobs.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,3 +179,34 @@ def test_deliver_contents_job_reschedules_failed_delivery_and_increments_attempt
179179
assert delivery_schedule.status == DeliveryStatus.SCHEDULED
180180
assert delivery_schedule.failed_attempts == 2
181181
assert delivery_schedule.time > original_time
182+
183+
184+
def test_unhandled_exception_during_delivery_processing(
185+
db, delivery_queue_mock, enrollment, course_lesson_content
186+
):
187+
# Create mock DeliverySchedule object
188+
enrollment.status = EnrollmentStatus.ACTIVE
189+
enrollment.save()
190+
191+
delivery = ContentDelivery.objects.create(
192+
enrollment=enrollment, course_content=course_lesson_content
193+
)
194+
195+
delivery_schedule = DeliverySchedule.objects.create(delivery=delivery)
196+
197+
# Add task to the mock delivery queue
198+
delivery_queue_mock.add_task(delivery_schedule)
199+
200+
job = DeliverContentsJob()
201+
202+
# Patch the process_delivery method to always raise an exception
203+
with patch.object(
204+
DeliverContentsJob,
205+
"process_delivery",
206+
side_effect=Exception("Simulated processing failure"),
207+
):
208+
job.run()
209+
210+
# After running the job, the delivery schedule should be in BLOCKED status due to unhandled exception
211+
delivery_schedule.refresh_from_db()
212+
assert delivery_schedule.status == DeliveryStatus.BLOCKED

tests/personalised/test_views/test_unsubscribe_view.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ def test_unsubscribe_valid_token(command, enrollment, anonymous_client):
1616
}
1717
)
1818

19-
response = anonymous_client.get(f"{URL}?token={token}")
19+
response = anonymous_client.get(f"{URL}?token={token}&confirm=true")
2020

2121
assert command.return_value.execute.called
2222
assert response.status_code == 200
@@ -28,6 +28,30 @@ def test_unsubscribe_valid_token(command, enrollment, anonymous_client):
2828
)
2929

3030

31+
@patch("django_email_learning.personalised.views.UnsubscribeCommand")
32+
def test_unsubscribe_valid_token_confirmation(command, enrollment, anonymous_client):
33+
token = jwt_service.generate_jwt(
34+
{
35+
"email": enrollment.learner.email,
36+
"course_slug": enrollment.course.slug,
37+
"organization_id": enrollment.course.organization.id,
38+
}
39+
)
40+
41+
response = anonymous_client.get(f"{URL}?token={token}")
42+
43+
assert not command.return_value.execute.called
44+
assert response.status_code == 200
45+
assert "page_title" in response.context
46+
assert response.context["page_title"] == "Confirm Unsubscription"
47+
assert (
48+
response.context["confirmation_message"]
49+
== "Are you sure you want to unsubscribe from our mailing list?"
50+
)
51+
assert "confirm_url" in response.context
52+
assert response.context["confirm_url"] == f"{URL}?token={token}&confirm=true"
53+
54+
3155
def test_unsubscribe_invalid_token(anonymous_client):
3256
response = anonymous_client.get(f"{URL}?token=invalidtoken")
3357
assert response.status_code == 400

tests/services/test_jwt_service.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@ def test_jwt_service_invalid_token():
3636
)
3737
# Create an invalid token by altering the signature
3838
invalid_token = jwt.encode(
39-
payload_copy, "INVALID_SECRET", algorithm=jwt_service.ALGORITHM
39+
payload_copy,
40+
"INVALID_LONG_SECRET_KEY_FOR_TESTING_PURPOSES_ONLY",
41+
algorithm=jwt_service.ALGORITHM,
4042
)
4143

4244
with pytest.raises(jwt_service.InvalidTokenException):

0 commit comments

Comments
 (0)