Skip to content
Open
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
136 changes: 136 additions & 0 deletions common/djangoapps/student/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@


from typing import TYPE_CHECKING
import csv
import io
import logging

from django.contrib.auth import get_user_model
from django.conf import settings
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey

from common.djangoapps.student.models import CourseEnrollment
Expand Down Expand Up @@ -159,3 +162,136 @@ def get_course_enrollments(
course_enrollments = course_enrollments.filter(course_id__in=course_ids)

return course_enrollments


# Header cells (case-insensitive, whitespace-trimmed) that mark an optional first row as a header.
BULK_UNENROLL_CSV_HEADERS = frozenset({"course_id", "course id"})


class BulkUnenrollCsvTooManyRows(Exception):
"""
Raised as soon as a bulk-unenroll CSV passes the caller's row limit, so an
oversized file is never parsed in full. Carries the limit so it can be reported.
"""

def __init__(self, max_rows):
self.max_rows = max_rows
super().__init__(f"CSV exceeds the maximum of {max_rows} rows.")


class BulkUnenrollCsvUnreadable(Exception):
"""
Raised when the upload cannot be read as CSV text at all.

Distinct from the per-row errors this parser collects: a file that is not UTF-8
(a ``.xls`` renamed to ``.csv``) or is malformed CSV has no rows to report
against. Callers turn this into a 400 rather than a 500.
"""


def _iter_csv_rows(text):
"""
Yield CSV rows, reporting a malformed file as ``BulkUnenrollCsvUnreadable``.

``csv.reader`` raises ``csv.Error`` lazily, from the middle of iteration, so the
guard has to wrap the stepping rather than the reader's construction.
"""
reader = csv.reader(io.StringIO(text))
while True:
try:
yield next(reader)
except StopIteration:
return
except csv.Error as exc:
raise BulkUnenrollCsvUnreadable(f"File is not readable as CSV: {exc}") from exc


def parse_bulk_unenroll_csv(file_obj, max_rows=None):
"""
Parse a bulk-unenroll CSV into course keys.

UTF-8 (BOM optional), exactly **one** non-empty cell per row: a course id. An
optional ``course_id`` / ``course id`` header and blank rows are skipped, and
duplicates are collapsed to the first occurrence without being reported. A row
with more than one cell is an error — deliberately narrower than the
``bulk_unenroll`` management command's ``username,course_id``, whose username
column the whole-course worker would silently ignore while unenrolling everyone.

Arguments:
file_obj: a file-like object opened in binary or text mode.
max_rows: optional cap on *data* rows (every non-blank, non-header row,
including duplicates and invalid ones). ``None`` means no limit.

Raises:
BulkUnenrollCsvTooManyRows: if ``max_rows`` is exceeded.
BulkUnenrollCsvUnreadable: if the bytes are not UTF-8 or not parseable CSV.

Returns:
(course_keys, errors) — course keys de-duplicated in input order, and
``{"row": int, "value": str, "error": str}`` dicts whose 1-based row number
counts every physical row, so it matches what a spreadsheet shows.
"""
raw = file_obj.read()
if isinstance(raw, bytes):
try:
text = raw.decode("utf-8-sig")
except UnicodeDecodeError as exc:
raise BulkUnenrollCsvUnreadable(
"File is not valid UTF-8 text. Re-save it as a UTF-8 CSV and try again."
) from exc
else:
# Strip a leading BOM if the caller handed us already-decoded text.
# Spelled as an escape: the literal character is invisible in a diff.
text = raw.removeprefix("\ufeff")

course_keys = []
errors = []
seen = set()
data_rows = 0

for row_number, row in enumerate(_iter_csv_rows(text), start=1):
cells = [cell.strip() for cell in row]
non_empty = [cell for cell in cells if cell]

if not non_empty:
# Blank row (possibly all-whitespace or trailing newline) — skip.
continue

# Skip a single-cell header row if it names the column.
if row_number == 1 and len(non_empty) == 1 and non_empty[0].lower() in BULK_UNENROLL_CSV_HEADERS:
continue

# Count every data row, not just the ones that survive to `course_keys`:
# duplicates and invalid rows still cost time and response size.
data_rows += 1
if max_rows is not None and data_rows > max_rows:
raise BulkUnenrollCsvTooManyRows(max_rows)

raw_value = ",".join(cells).strip(",")

if len(non_empty) > 1:
errors.append({
"row": row_number,
"value": raw_value,
"error": "Expected a single course_id column",
})
continue

value = non_empty[0]
try:
course_key = CourseKey.from_string(value)
except InvalidKeyError:
errors.append({
"row": row_number,
"value": value,
"error": "Invalid course id",
})
continue

if course_key in seen:
# Duplicate — collapse silently so a course is never double-queued.
continue
seen.add(course_key)
course_keys.append(course_key)

return course_keys, errors
128 changes: 128 additions & 0 deletions common/djangoapps/student/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,23 @@
Test Student api.py
"""

import csv
import io

import ddt

from django.test import SimpleTestCase
from opaque_keys.edx.keys import CourseKey
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory

from common.djangoapps.student.api import (
BulkUnenrollCsvTooManyRows,
BulkUnenrollCsvUnreadable,
is_user_enrolled_in_course,
is_user_staff_or_instructor_in_course,
get_course_enrollments,
parse_bulk_unenroll_csv,
)
from common.djangoapps.student.models import CourseEnrollment
from common.djangoapps.student.tests.factories import (
Expand Down Expand Up @@ -100,3 +110,121 @@ def test_get_filtered_course_enrollments(self):
result = get_course_enrollments(self.user, True, course_ids=[course_2.id])

self.assertEqual(list(expected), list(result))


@ddt.ddt
class TestParseBulkUnenrollCsv(SimpleTestCase):
"""
Tests for parse_bulk_unenroll_csv (pure parser, no DB access).
"""

DEMO_2024 = CourseKey.from_string("course-v1:edX+DemoX+2024")
DEMO_2025 = CourseKey.from_string("course-v1:edX+DemoX+2025")

@staticmethod
def _file(content):
"""Build a binary file-like object (mimicking a Django UploadedFile)."""
if isinstance(content, str):
content = content.encode("utf-8")
return io.BytesIO(content)

@ddt.data(
# (content, expected keys) — one course id per row, header optional.
("course-v1:edX+DemoX+2024\ncourse-v1:edX+DemoX+2025\n", 2),
("course_id\ncourse-v1:edX+DemoX+2024\n", 1), # header skipped
("Course ID\ncourse-v1:edX+DemoX+2024\n", 1), # header variant
("\ufeffcourse_id\ncourse-v1:edX+DemoX+2024\n", 1), # Excel's BOM
("course-v1:edX+DemoX+2024\ncourse-v1:edX+DemoX+2024\n", 1), # duplicate collapsed
("course-v1:edX+DemoX+2024\n\n \ncourse-v1:edX+DemoX+2025\n", 2), # blank rows
("course-v1:edX+DemoX+2024,\n", 1), # stray trailing comma
("", 0), # empty file
)
@ddt.unpack
def test_valid_files_parse_without_errors(self, content, expected_count):
keys, errors = parse_bulk_unenroll_csv(self._file(content))
assert not errors
assert len(keys) == expected_count
assert keys == list(dict.fromkeys(keys)) # de-duplicated, input order kept

def test_already_decoded_text_with_a_bom(self):
"""Callers may hand us text rather than bytes; the BOM still has to go."""
keys, errors = parse_bulk_unenroll_csv(io.StringIO("\ufeffcourse-v1:edX+DemoX+2024\n"))
assert not errors
assert keys == [self.DEMO_2024]

def test_legacy_username_course_id_rejected(self):
"""
The management command accepts ``username,course_id``; this parser must not.
The whole-course worker would ignore the username and unenroll everyone.
"""
keys, errors = parse_bulk_unenroll_csv(self._file(
"course-v1:edX+DemoX+2024\nalice,course-v1:edX+DemoX+2025\n"
))
assert keys == [self.DEMO_2024]
assert errors == [{
"row": 2,
"value": "alice,course-v1:edX+DemoX+2025",
"error": "Expected a single course_id column",
}]

def test_invalid_course_id_reports_the_spreadsheet_row_number(self):
"""Row numbers count every physical row, header included, so they line up."""
keys, errors = parse_bulk_unenroll_csv(self._file(
"course_id\ncourse-v1:edX+DemoX+2024\nbad-key\n"
))
assert keys == [self.DEMO_2024]
assert errors == [{"row": 3, "value": "bad-key", "error": "Invalid course id"}]

def test_a_file_of_nothing_but_bad_ids_yields_errors_and_no_keys(self):
keys, errors = parse_bulk_unenroll_csv(self._file("foo\nbar\n"))
assert not keys
assert [e["row"] for e in errors] == [1, 2]
assert all(e["error"] == "Invalid course id" for e in errors)

# --- the row limit guards the *file*, not the de-duplicated course list ---

@ddt.data(
"course-v1:edX+DemoX+2024\ncourse-v1:edX+DemoX+2025\ncourse-v1:edX+DemoX+2026\n",
"course-v1:edX+DemoX+2024\n" * 3, # duplicates still cost time and size
"bad-1\nbad-2\nbad-3\n", # so do invalid rows
)
def test_max_rows_counts_every_data_row(self, content):
with self.assertRaises(BulkUnenrollCsvTooManyRows):
parse_bulk_unenroll_csv(self._file(content), max_rows=2)

def test_max_rows_ignores_header_and_blank_rows(self):
keys, errors = parse_bulk_unenroll_csv(
self._file("course_id\ncourse-v1:edX+DemoX+2024\n\ncourse-v1:edX+DemoX+2025\n\n"),
max_rows=2,
)
assert not errors
assert len(keys) == 2

def test_max_rows_none_is_unlimited(self):
keys, errors = parse_bulk_unenroll_csv(self._file("bad-1\nbad-2\nbad-3\n"))
assert not keys
assert len(errors) == 3

# Unreadable files have no row to attach an error to, so they are raised
# rather than collected; the upload view turns them into a 400, not a 500.

@ddt.data(
b"course-v1:edX+D\xe9moX+2024\n", # Latin-1 export: never valid UTF-8
b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1", # a real .xls saved as .csv
)
def test_non_utf8_bytes_are_rejected_as_unreadable(self, content):
with self.assertRaises(BulkUnenrollCsvUnreadable):
parse_bulk_unenroll_csv(self._file(content))

def test_malformed_csv_is_rejected_as_unreadable(self):
"""
An unterminated quoted field swallows the rest of the file and trips csv's
field-size limit. csv.reader raises that mid-iteration, after the first row
was already handed back, so the guard has to wrap the stepping rather than
the reader's construction.
"""
runaway_quote = '"' + "a" * (csv.field_size_limit() + 10)
with self.assertRaises(BulkUnenrollCsvUnreadable):
parse_bulk_unenroll_csv(self._file(
"course-v1:edX+DemoX+2024\n" + runaway_quote
))
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Generated by Django 4.2.24 on 2026-08-10 06:04

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import model_utils.fields
import opaque_keys.edx.django.models
import uuid


class Migration(migrations.Migration):

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('support', '0006_alter_historicalusersocialauth_extra_data_and_more'),
]

operations = [
migrations.CreateModel(
name='BulkUnenrollBatch',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)),
('csv_filename', models.CharField(blank=True, default='', max_length=255)),
('reason', models.CharField(blank=True, default='', max_length=255)),
('state', models.CharField(choices=[('validated', 'Validated'), ('pending', 'Pending'), ('cancelled', 'Cancelled'), ('running', 'Running'), ('succeeded', 'Succeeded'), ('partial', 'Partial'), ('failed', 'Failed')], default='validated', max_length=20)),
('total_courses', models.PositiveIntegerField(default=0)),
('requester', models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='BulkUnenrollCourseState',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('course_id', opaque_keys.edx.django.models.CourseKeyField(db_index=True, max_length=255)),
('active_count', models.PositiveIntegerField(default=0)),
('state', models.CharField(choices=[('pending', 'Pending'), ('running', 'Running'), ('succeeded', 'Succeeded'), ('failed', 'Failed'), ('skipped', 'Skipped'), ('cancelled', 'Cancelled')], default='pending', max_length=20)),
('error', models.CharField(blank=True, default='', max_length=255)),
('total_enrollments', models.PositiveIntegerField(default=0)),
('unenrolled', models.PositiveIntegerField(default=0)),
('already_inactive', models.PositiveIntegerField(default=0)),
('failed_count', models.PositiveIntegerField(default=0)),
('chunks_total', models.PositiveIntegerField(default=0)),
('chunks_finished', models.PositiveIntegerField(default=0)),
('attempt', models.PositiveIntegerField(default=1)),
('started', models.DateTimeField(blank=True, null=True)),
('finished', models.DateTimeField(blank=True, null=True)),
('batch', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='courses', to='support.bulkunenrollbatch')),
],
options={
'unique_together': {('batch', 'course_id')},
},
),
migrations.CreateModel(
name='BulkUnenrollChunk',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('attempt', models.PositiveIntegerField(default=1)),
('chunk_index', models.PositiveIntegerField()),
('continuation', models.PositiveIntegerField(default=0)),
('state', models.CharField(choices=[('pending', 'Pending'), ('finished', 'Finished')], default='pending', max_length=20)),
('finished', models.DateTimeField(blank=True, null=True)),
('course_state', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chunks', to='support.bulkunenrollcoursestate')),
],
options={
'unique_together': {('course_state', 'attempt', 'chunk_index', 'continuation')},
},
),
]
Loading
Loading