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
6 changes: 6 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
Release Notes
=============

Version 1.164.1
---------------

- add command to trasfer grades to another account (#3627)
- Expose refund state on the order receipt payload (#3873)

Version 1.164.0 (Released August 31, 2026)
---------------

Expand Down
273 changes: 273 additions & 0 deletions courses/management/commands/transfer_user_course_records.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
"""Transfer course-related user records from one user to another."""

from argparse import RawTextHelpFormatter

from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction

from courses.models import (
CourseRunCertificate,
CourseRunEnrollment,
CourseRunGrade,
ProgramCertificate,
ProgramEnrollment,
)
from users.api import fetch_user

User = get_user_model()


class Command(BaseCommand):
"""
Transfer course-related records between two users.

Moves course run/program enrollments, grades, and certificates from one
user to another, matching users by email address. The transfer is
aborted if the destination user already has any overlapping records.

Example: transfer_user_course_records --from_email=old@example.com --to_email=new@example.com
"""

help = __doc__

def add_arguments(self, parser):
"""Add command line arguments."""
parser.formatter_class = RawTextHelpFormatter
parser.add_argument(
"--from_email",
"--from-email",
dest="from_email",
type=str,
required=True,
help="Email address for the user records should be moved from",
)
parser.add_argument(
"--to_email",
"--to-email",
dest="to_email",
type=str,
required=True,
help="Email address for the user records should be moved to",
)
super().add_arguments(parser)

def handle(self, *args, **options): # noqa: ARG002
"""Handle command execution."""
source_user = self._fetch_user(options["from_email"], "from_email")
destination_user = self._fetch_user(options["to_email"], "to_email")

if source_user.pk == destination_user.pk:
raise CommandError("Source and destination users must be different.") # noqa: EM101

source_records = self._load_source_records(source_user)
self._raise_on_conflicts(source_records, destination_user)

with transaction.atomic():
transfer_counts = self._transfer_records(source_records, destination_user)

self.stdout.write(
self.style.SUCCESS(
"Transferred records from {source_email} to {destination_email}: "
"{counts}".format(
source_email=source_user.email,
destination_email=destination_user.email,
counts=", ".join(
f"{label}={count}" for label, count in transfer_counts.items()
),
)
)
)

def _fetch_user(self, email, option_name):
"""Look up a user by email and normalize fetch errors to CommandError."""
try:
return fetch_user(email)
except User.DoesNotExist as exc:
msg = f"Could not find user for --{option_name}={email}."
raise CommandError(msg) from exc

def _load_source_records(self, source_user):
"""Load all transfer candidates for the source user."""
return {
"course_run_enrollments": list(
CourseRunEnrollment.all_objects.filter(user=source_user).select_related(
"run"
)
),
"program_enrollments": list(
ProgramEnrollment.all_objects.filter(user=source_user).select_related(
"program"
)
),
"course_run_grades": list(
CourseRunGrade.objects.filter(user=source_user).select_related(
"course_run"
)
),
"course_run_certificates": list(
CourseRunCertificate.all_objects.filter(
user=source_user
).select_related("course_run")
),
"program_certificates": list(
ProgramCertificate.all_objects.filter(user=source_user).select_related(
"program"
)
),
}

def _raise_on_conflicts(self, source_records, destination_user):
"""Abort if the destination already has any overlapping records."""
conflicts = []

course_run_ids = [
enrollment.run_id for enrollment in source_records["course_run_enrollments"]
]
program_ids = [
enrollment.program_id
for enrollment in source_records["program_enrollments"]
]
graded_course_run_ids = [
grade.course_run_id for grade in source_records["course_run_grades"]
]
certificate_course_run_ids = [
certificate.course_run_id
for certificate in source_records["course_run_certificates"]
]
certificate_program_ids = [
certificate.program_id
for certificate in source_records["program_certificates"]
]

conflicting_course_run_enrollments = list(
CourseRunEnrollment.all_objects.filter(
user=destination_user, run_id__in=course_run_ids
).select_related("run")
)
if conflicting_course_run_enrollments:
conflicts.append(
"course run enrollments for {}".format(
", ".join(
sorted(
{
enrollment.run.courseware_id
for enrollment in conflicting_course_run_enrollments
}
)
)
)
)

conflicting_program_enrollments = list(
ProgramEnrollment.all_objects.filter(
user=destination_user, program_id__in=program_ids
).select_related("program")
)
if conflicting_program_enrollments:
conflicts.append(
"program enrollments for {}".format(
", ".join(
sorted(
{
enrollment.program.readable_id
for enrollment in conflicting_program_enrollments
}
)
)
)
)

conflicting_grades = list(
CourseRunGrade.objects.filter(
user=destination_user, course_run_id__in=graded_course_run_ids
).select_related("course_run")
)
if conflicting_grades:
conflicts.append(
"course run grades for {}".format(
", ".join(
sorted(
{
grade.course_run.courseware_id
for grade in conflicting_grades
}
)
)
)
)

conflicting_course_run_certificates = list(
CourseRunCertificate.all_objects.filter(
user=destination_user, course_run_id__in=certificate_course_run_ids
).select_related("course_run")
)
if conflicting_course_run_certificates:
conflicts.append(
"course run certificates for {}".format(
", ".join(
sorted(
{
certificate.course_run.courseware_id
for certificate in conflicting_course_run_certificates
}
)
)
)
)

conflicting_program_certificates = list(
ProgramCertificate.all_objects.filter(
user=destination_user, program_id__in=certificate_program_ids
).select_related("program")
)
if conflicting_program_certificates:
conflicts.append(
"program certificates for {}".format(
", ".join(
sorted(
{
certificate.program.readable_id
for certificate in conflicting_program_certificates
}
)
)
)
)

if conflicts:
raise CommandError(
"Transfer aborted because the destination user already has {}.".format( # noqa: EM103
"; ".join(conflicts)
)
)

def _transfer_records(self, source_records, destination_user):
"""Transfer each record set and return counts by label."""
for enrollment in source_records["course_run_enrollments"]:
enrollment.user = destination_user
enrollment.save_and_log(None)

for enrollment in source_records["program_enrollments"]:
enrollment.user = destination_user
enrollment.save_and_log(None)

for grade in source_records["course_run_grades"]:
grade.user = destination_user
grade.save_and_log(None)

for certificate in source_records["course_run_certificates"]:
certificate.user = destination_user
certificate.save(update_fields=["user"])

for certificate in source_records["program_certificates"]:
certificate.user = destination_user
certificate.save(update_fields=["user"])

return {
"course_run_enrollments": len(source_records["course_run_enrollments"]),
"program_enrollments": len(source_records["program_enrollments"]),
"course_run_grades": len(source_records["course_run_grades"]),
"course_run_certificates": len(source_records["course_run_certificates"]),
"program_certificates": len(source_records["program_certificates"]),
}
15 changes: 15 additions & 0 deletions courses/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
from main.utils import serialize_model_object
from openedx.constants import (
EDX_DEFAULT_ENROLLMENT_MODE,
EDX_ENROLLMENT_AUDIT_MODE,
EDX_ENROLLMENT_VERIFIED_MODE,
EDX_ENROLLMENTS_PAID_MODES,
)
Expand Down Expand Up @@ -250,6 +251,13 @@ def text_id(self):
"""Gets the readable_id"""
return self.readable_id

@property
def has_free_audit(self):
"""Whether this can still be taken for free once a paid enrollment ends."""
return self.enrollment_modes.filter(
mode_slug=EDX_ENROLLMENT_AUDIT_MODE
).exists()

@property
def related_programs_qs(self):
"""
Expand Down Expand Up @@ -1616,6 +1624,13 @@ def text_id(self):
"""Gets the courseware_id"""
return self.courseware_id

@property
def has_free_audit(self):
"""Whether this can still be taken for free once a paid enrollment ends."""
return self.enrollment_modes.filter(
mode_slug=EDX_ENROLLMENT_AUDIT_MODE
).exists()

@property
def course_number(self):
"""
Expand Down
45 changes: 17 additions & 28 deletions drf_lint_baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
"cms/serializers.py:97:12:ORM001",
"courses/serializers/base.py:53:16:ORM001",
"courses/serializers/v1/base.py:75:20:ORM002",
"courses/serializers/v1/courses.py:173:18:ORM001",
"courses/serializers/v1/courses.py:59:16:ORM001",
"courses/serializers/v1/programs.py:181:12:ORM001",
"courses/serializers/v1/programs.py:196:12:ORM001",
Expand All @@ -18,14 +17,11 @@
"courses/serializers/v1/programs.py:318:16:ORM001",
"courses/serializers/v1/programs.py:335:17:ORM001",
"courses/serializers/v2/courses.py:276:17:ORM002",
"courses/serializers/v2/courses.py:339:18:ORM001",
"courses/serializers/v2/departments.py:35:40:ORM002",
"courses/serializers/v2/departments.py:49:42:ORM002",
"courses/serializers/v2/programs.py:387:50:ORM002",
"courses/serializers/v2/programs.py:500:12:ORM002",
"courses/serializers/v3/courses.py:113:14:ORM001",
"courses/serializers/v3/courses.py:57:12:ORM002",
"courses/serializers/v3/programs.py:55:22:ORM001",
"ecommerce/serializers/__init__.py:205:17:ORM001",
"ecommerce/serializers/__init__.py:207:18:ORM001",
"ecommerce/serializers/__init__.py:208:18:ORM001",
Expand All @@ -42,34 +38,27 @@
"ecommerce/serializers/__init__.py:587:20:ORM002",
"ecommerce/serializers/__init__.py:719:22:ORM002",
"ecommerce/serializers/__init__.py:901:28:ORM002",
"ecommerce/serializers/v0/__init__.py:284:17:ORM001",
"ecommerce/serializers/v0/__init__.py:286:18:ORM001",
"ecommerce/serializers/v0/__init__.py:287:18:ORM001",
"ecommerce/serializers/v0/__init__.py:309:26:ORM002",
"ecommerce/serializers/v0/__init__.py:397:26:ORM002",
"ecommerce/serializers/v0/__init__.py:406:35:ORM002",
"ecommerce/serializers/v0/__init__.py:413:20:ORM002",
"ecommerce/serializers/v0/__init__.py:419:35:ORM002",
"ecommerce/serializers/v0/__init__.py:435:31:ORM002",
"ecommerce/serializers/v0/__init__.py:511:24:ORM002",
"ecommerce/serializers/v0/__init__.py:530:12:ORM001",
"ecommerce/serializers/v0/__init__.py:554:22:ORM002",
"ecommerce/serializers/v0/__init__.py:601:22:ORM002",
"ecommerce/serializers/v0/__init__.py:672:20:ORM002",
"ecommerce/serializers/v0/__init__.py:821:22:ORM002",
"ecommerce/serializers/v0/__init__.py:965:28:ORM002",
"flexiblepricing/serializers.py:129:38:ORM001",
"flexiblepricing/serializers.py:132:34:ORM001",
"ecommerce/serializers/v0/__init__.py:292:17:ORM001",
"ecommerce/serializers/v0/__init__.py:294:18:ORM001",
"ecommerce/serializers/v0/__init__.py:295:18:ORM001",
"ecommerce/serializers/v0/__init__.py:317:26:ORM002",
"ecommerce/serializers/v0/__init__.py:405:26:ORM002",
"ecommerce/serializers/v0/__init__.py:414:35:ORM002",
"ecommerce/serializers/v0/__init__.py:421:20:ORM002",
"ecommerce/serializers/v0/__init__.py:427:35:ORM002",
"ecommerce/serializers/v0/__init__.py:443:31:ORM002",
"ecommerce/serializers/v0/__init__.py:540:24:ORM002",
"ecommerce/serializers/v0/__init__.py:559:12:ORM001",
"ecommerce/serializers/v0/__init__.py:583:22:ORM002",
"ecommerce/serializers/v0/__init__.py:630:22:ORM002",
"ecommerce/serializers/v0/__init__.py:705:20:ORM002",
"ecommerce/serializers/v0/__init__.py:854:22:ORM002",
"ecommerce/serializers/v0/__init__.py:998:28:ORM002",
"flexiblepricing/serializers.py:147:34:ORM001",
"flexiblepricing/serializers.py:170:34:ORM001",
"flexiblepricing/serializers.py:173:20:ORM001",
"flexiblepricing/serializers.py:204:30:ORM001",
"flexiblepricing/serializers.py:207:31:ORM001",
"flexiblepricing/serializers.py:212:16:ORM001",
"flexiblepricing/serializers.py:216:16:ORM001",
"users/serializers.py:227:16:ORM001",
"users/serializers.py:280:20:ORM001",
"users/serializers.py:327:19:ORM001",
"users/serializers.py:459:13:ORM001",
"users/serializers.py:495:11:ORM001"
"flexiblepricing/serializers.py:216:16:ORM001"
]
Loading
Loading