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
10 changes: 10 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@ Unreleased

*

0.6.0 - 2026-10-22
******************

Fixed
=====

* Process pending Learning Path enrollments when a user activates the account or changes the email address, instead
of only when the account is created. Enrollments are no longer created for accounts that have not been activated.
* Link all unclaimed enrollment allowed records to the user, including the inactive ones.

0.5.0 - 2026-08-11
******************

Expand Down
2 changes: 1 addition & 1 deletion learning_paths/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
Learning Paths plugin.
"""

__version__ = "0.5.0"
__version__ = "0.6.0"
60 changes: 39 additions & 21 deletions learning_paths/receivers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

import logging

from django.db import IntegrityError
from django.db.models.signals import post_save
from django.dispatch import receiver

Expand All @@ -19,63 +18,82 @@

def process_pending_enrollments(sender, instance, created, **kwargs):
"""
Process pending enrollments after a user instance has been created.
Process pending enrollments when a user becomes able to claim them.

Bulk enrollment API allows enrolling users with just the email. So learners who
do not have an account yet would also be enrolled. This information is stored
in the LearningPathEnrollmentAllowed model. This signal handler processes such
instances and created the corresponding LearningPathEnrollment objects.
instances and creates the corresponding LearningPathEnrollment objects.

The entries are processed only for active users (i.e., users who confirmed their email address) when:
1. The user is created (e.g., when the account is created already activated).
2. The user activates the account.
3. The user changes the email address (the change is applied after confirming the new email address).

Args:
sender: User model class.
instance: The actual instance being saved.
created: A boolean indicating whether this is a creation and not an update.
"""
if not created:
logger.debug(
"[LearningPaths] Skipping processing of pending enrollments for user %s.",
instance,
)
# `_changed_fields` is set by the `pre_save` receiver of the User model in edx-platform.
changed_fields = getattr(instance, "_changed_fields", {})
if not instance.is_active or not (created or {"email", "is_active"} & changed_fields.keys()):
return

logger.info("[LearningPaths] Processing pending enrollments for user %s", instance)
pending_enrollments = LearningPathEnrollmentAllowed.objects.filter(email=instance.email, is_active=True)
process_pending_enrollments_for_user(instance)


def process_pending_enrollments_for_user(user) -> int:
"""
Process the LearningPathEnrollmentAllowed entries matching the user's email.

Active entries are converted into enrollments, unless the user already has an enrollment (active or not) in the
learning path. All entries that are not linked to any user yet are deactivated and linked to the user, so they
cannot be claimed by another account that changes its email to the same address.

Returns:
The number of created enrollments.
"""
logger.info("[LearningPaths] Processing pending enrollments for user %s", user)
pending_enrollments = LearningPathEnrollmentAllowed.objects.filter(email=user.email, user__isnull=True)
enrollments_created = 0

for entry in pending_enrollments:
try:
enrollment_exists = LearningPathEnrollment.objects.filter(learning_path=entry.learning_path, user=user).exists()

if entry.is_active and not enrollment_exists:
audit_data = {
"enrolled_by": instance,
"enrolled_by": user,
"state_transition": LearningPathEnrollmentAudit.ALLOWEDTOENROLL_TO_ENROLLED,
}
if last_allowed_audit := entry.audit.order_by("-created").first():
for field in ["reason", "org", "role"]:
audit_data[field] = getattr(last_allowed_audit, field, "")

enrollment = LearningPathEnrollment(learning_path=entry.learning_path, user=instance)
enrollment = LearningPathEnrollment(learning_path=entry.learning_path, user=user)
enrollment._audit = audit_data # pylint: disable=protected-access
enrollment.save()
enrollments_created += 1

# Link existing audits from the "allowed to enroll" entry to the new enrollment.
entry.audit.update(enrollment=enrollment)

except IntegrityError: # pragma: no cover
elif entry.is_active:
logger.info(
"[LearningPaths] Enrollment already exists for user %s in the learning path %s",
instance,
user,
entry.learning_path.key,
)
finally:
entry.is_active = False
entry.user = instance
entry.save()

entry.is_active = False
entry.user = user
entry.save()

logger.info(
"[LearningPaths] Processed %d pending Learning Path enrollments for user %s.",
enrollments_created,
instance,
user,
)
return enrollments_created


def _create_enrollment_audit(instance: LearningPathEnrollment | LearningPathEnrollmentAllowed, audit_data: dict):
Expand Down
113 changes: 110 additions & 3 deletions learning_paths/tests/test_receivers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@
LearningPathEnrollmentAllowed,
LearningPathEnrollmentAudit,
)
from learning_paths.receivers import process_pending_enrollments
from learning_paths.receivers import (
process_pending_enrollments,
process_pending_enrollments_for_user,
)

from .factories import (
LearningPathEnrollmentAllowedFactory,
Expand Down Expand Up @@ -88,7 +91,8 @@ def test_process_pending_enrollments_only_processes_active_records(user_email, l
"""
GIVEN that there are both active and inactive LearningPathEnrollmentAllowed objects for an email
WHEN the process_pending_enrollments signal handler is triggered
THEN only active enrollment allowed records are processed
THEN only active enrollment allowed records are converted into enrollments
AND all enrollment allowed records are linked to the user
"""
active_entry = LearningPathEnrollmentAllowedFactory(
email=user_email, learning_path=learning_paths[0], is_active=True
Expand All @@ -110,7 +114,7 @@ def test_process_pending_enrollments_only_processes_active_records(user_email, l

inactive_entry.refresh_from_db()
assert inactive_entry.is_active is False
assert inactive_entry.user is None
assert inactive_entry.user == user


@pytest.mark.django_db
Expand Down Expand Up @@ -146,6 +150,109 @@ def test_process_pending_enrollments_when_not_created(user_email):
assert len(enrollments) == 0


# pylint: disable=protected-access
@pytest.mark.django_db
@pytest.mark.parametrize(
"created,is_active,changed_fields,should_process",
[
(True, True, {}, True),
(True, False, {}, False),
(False, True, {"email": "[email protected]"}, True),
(False, True, {"is_active": False}, True),
(False, False, {"email": "[email protected]"}, False),
(False, True, {"last_login": None}, False),
(False, True, {}, False),
],
ids=[
"created_active",
"created_inactive",
"email_changed",
"account_activated",
"email_changed_inactive",
"unrelated_change",
"no_changes",
],
)
def test_process_pending_enrollments_triggers( # pylint: disable=too-many-positional-arguments
user_email, learning_paths, created, is_active, changed_fields, should_process
):
"""
GIVEN that there is an active LearningPathEnrollmentAllowed object for an email
WHEN the process_pending_enrollments signal handler is triggered for a user with this email
THEN the entry is processed only for active users that were created, activated, or changed their email
"""
entry = LearningPathEnrollmentAllowedFactory(email=user_email, learning_path=learning_paths[0])
user = UserFactory(email=user_email, is_active=is_active)
user._changed_fields = changed_fields

process_pending_enrollments(sender=User, instance=user, created=created)

entry.refresh_from_db()
assert LearningPathEnrollment.objects.filter(user=user).exists() is should_process
assert entry.is_active is not should_process
assert entry.user == (user if should_process else None)


@pytest.mark.django_db
def test_process_pending_enrollments_does_not_reactivate_existing_enrollment(user_email, learning_paths):
"""
GIVEN that there is an active LearningPathEnrollmentAllowed object for an email
AND the user with this email has an inactive enrollment in the same learning path
WHEN the process_pending_enrollments signal handler is triggered
THEN the existing enrollment is not reactivated
AND the enrollment allowed record is deactivated and linked to the user
"""
user = UserFactory(email=user_email)
enrollment = LearningPathEnrollmentFactory(user=user, learning_path=learning_paths[0], is_active=False)
entry = LearningPathEnrollmentAllowedFactory(email=user_email, learning_path=learning_paths[0])

process_pending_enrollments(sender=User, instance=user, created=True)

enrollment.refresh_from_db()
assert enrollment.is_active is False
assert LearningPathEnrollment.objects.count() == 1

entry.refresh_from_db()
assert entry.is_active is False
assert entry.user == user


@pytest.mark.django_db
def test_process_pending_enrollments_skips_entries_claimed_by_another_user(user_email, learning_paths):
"""
GIVEN that there is a LearningPathEnrollmentAllowed object linked to another user
WHEN a user changes the email to the one from this object
THEN the entry is not processed for this user
"""
other_user = UserFactory()
entry = LearningPathEnrollmentAllowedFactory(
email=user_email, learning_path=learning_paths[0], user=other_user, is_active=False
)
user = UserFactory(email=user_email)
user._changed_fields = {"email": "[email protected]"}

process_pending_enrollments(sender=User, instance=user, created=False)

entry.refresh_from_db()
assert entry.user == other_user
assert not LearningPathEnrollment.objects.filter(user=user).exists()


@pytest.mark.django_db
def test_process_pending_enrollments_for_user_returns_created_count(user_email, learning_paths):
"""
GIVEN that there are active and inactive LearningPathEnrollmentAllowed objects for an email
WHEN process_pending_enrollments_for_user is called
THEN it returns the number of created enrollments
"""
LearningPathEnrollmentAllowedFactory(email=user_email, learning_path=learning_paths[0])
LearningPathEnrollmentAllowedFactory(email=user_email, learning_path=learning_paths[1], is_active=False)
user = UserFactory(email=user_email)

assert process_pending_enrollments_for_user(user) == 1
assert process_pending_enrollments_for_user(user) == 0


# pylint: disable=protected-access
@pytest.mark.django_db
class TestEnrollmentAuditReceivers:
Expand Down
Loading