Skip to content

Commit 57a09ad

Browse files
committed
feat: [AXM-2307] add management command to backfill ContentDate model with existing assignments
1 parent d20b87b commit 57a09ad

File tree

3 files changed

+263
-0
lines changed

3 files changed

+263
-0
lines changed

openedx/core/djangoapps/course_date_signals/management/__init__.py

Whitespace-only changes.

openedx/core/djangoapps/course_date_signals/management/commands/__init__.py

Whitespace-only changes.
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
"""
2+
Django management command to extract assignment dates from modulestore and populate ContentDate table.
3+
"""
4+
5+
import logging
6+
from typing import List
7+
8+
from django.contrib.auth import get_user_model
9+
from django.core.management.base import BaseCommand, CommandError
10+
from django.db import transaction
11+
from edx_when.api import update_or_create_assignments_due_dates, models as when_models
12+
from opaque_keys import InvalidKeyError
13+
from opaque_keys.edx.keys import CourseKey
14+
from xmodule.modulestore.django import modulestore
15+
from xmodule.modulestore.exceptions import ItemNotFoundError
16+
17+
from lms.djangoapps.courseware.courses import get_course_assignments
18+
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
19+
20+
log = logging.getLogger(__name__)
21+
22+
User = get_user_model()
23+
24+
25+
class Command(BaseCommand):
26+
"""
27+
Management command to seed ContentDate table with assignment due dates from modulestore.
28+
29+
Example usage:
30+
# Dry run for all courses
31+
python manage.py lms seed_content_dates --dry-run
32+
33+
# Seed specific course
34+
python manage.py lms seed_content_dates --course-id "course-v1:MITx+6.00x+2023_Fall"
35+
36+
# Seed all courses for specific org
37+
python manage.py lms seed_content_dates --org "MITx"
38+
39+
# Force update existing entries
40+
python manage.py lms seed_content_dates --force-update
41+
"""
42+
43+
help = "Extract assignment dates from modulestore and populate ContentDate table"
44+
dry_run = False
45+
force_update = False
46+
batch_size = 100
47+
48+
def add_arguments(self, parser):
49+
parser.add_argument(
50+
"--course-id",
51+
type=str,
52+
help='Specific course ID to process (e.g., "course-v1:MITx+6.00x+2023_Fall")',
53+
)
54+
parser.add_argument("--org", type=str, help="Organization to filter courses by")
55+
parser.add_argument(
56+
"--dry-run",
57+
action="store_true",
58+
help="Show what would be processed without making changes",
59+
)
60+
parser.add_argument(
61+
"--force-update",
62+
action="store_true",
63+
help="Update existing ContentDate entries (default: skip existing)",
64+
)
65+
parser.add_argument(
66+
"--batch-size",
67+
type=int,
68+
default=100,
69+
help="Number of assignments to process in each batch (default: 100)",
70+
)
71+
72+
def handle(self, *args, **options):
73+
self.dry_run = options["dry_run"]
74+
self.force_update = options["force_update"]
75+
self.batch_size = options["batch_size"]
76+
77+
logging.basicConfig(level=logging.INFO)
78+
79+
if self.dry_run:
80+
self.stdout.write(
81+
self.style.WARNING(
82+
"DRY RUN MODE: No changes will be made to the database"
83+
)
84+
)
85+
86+
try:
87+
course_keys = self._get_course_keys(options)
88+
89+
total_processed = 0
90+
total_created = 0
91+
total_updated = 0
92+
total_skipped = 0
93+
94+
for course_key in course_keys:
95+
self.stdout.write(f"Processing course: {course_key}")
96+
97+
try:
98+
processed, created, updated, skipped = self._process_course(
99+
course_key
100+
)
101+
total_processed += processed
102+
total_created += created
103+
total_updated += updated
104+
total_skipped += skipped
105+
106+
self.stdout.write(
107+
f" Course {course_key}: {processed} assignments processed, "
108+
f"{created} created, {updated} updated, {skipped} skipped"
109+
)
110+
111+
except Exception as e: # pylint: disable=broad-exception-caught
112+
self.stdout.write(
113+
self.style.ERROR(
114+
f"Error processing course {course_key}: {str(e)}"
115+
)
116+
)
117+
log.exception(f"Error processing course {course_key}")
118+
continue
119+
120+
self.stdout.write(
121+
self.style.SUCCESS(
122+
f"\nSUMMARY:\n"
123+
f"Total assignments processed: {total_processed}\n"
124+
f"Total created: {total_created}\n"
125+
f"Total updated: {total_updated}\n"
126+
f"Total skipped: {total_skipped}"
127+
)
128+
)
129+
130+
except CommandError:
131+
raise
132+
except Exception as e:
133+
raise CommandError(f"Command failed: {str(e)}") from e
134+
135+
def _get_course_keys(self, options) -> List[CourseKey]:
136+
"""
137+
Get list of course keys to process based on command options.
138+
"""
139+
course_keys = []
140+
141+
if options["course_id"]:
142+
try:
143+
course_key = CourseKey.from_string(options["course_id"])
144+
145+
if not CourseOverview.objects.filter(id=course_key).exists():
146+
raise CommandError(f"Course not found: {options['course_id']}")
147+
course_keys.append(course_key)
148+
except InvalidKeyError as e:
149+
raise CommandError(f"Invalid course ID format: {options['course_id']}") from e
150+
151+
else:
152+
queryset = CourseOverview.objects.all()
153+
if options["org"]:
154+
queryset = queryset.filter(org=options["org"])
155+
156+
course_keys = [overview.id for overview in queryset]
157+
158+
if not course_keys:
159+
filter_msg = f" for org '{options['org']}'" if options["org"] else ""
160+
raise CommandError(f"No courses found{filter_msg}")
161+
162+
return course_keys
163+
164+
def _process_course(self, course_key: CourseKey) -> tuple[int, int, int, int]:
165+
"""
166+
Process a single course and return (processed, created, updated, skipped) counts.
167+
"""
168+
store = modulestore()
169+
170+
try:
171+
course = store.get_course(course_key)
172+
if not course:
173+
raise ItemNotFoundError(
174+
f"Course not found in modulestore: {course_key}"
175+
)
176+
except ItemNotFoundError:
177+
log.warning(f"Course not found in modulestore: {course_key}")
178+
return 0, 0, 0, 0
179+
180+
staff_user = User.objects.filter(is_staff=True).first()
181+
if not staff_user:
182+
return
183+
assignments = get_course_assignments(course_key, staff_user)
184+
185+
if not assignments:
186+
log.info(f"No assignments with due dates found in course: {course_key}")
187+
return 0, 0, 0, 0
188+
189+
processed = len(assignments)
190+
created = 0
191+
updated = 0
192+
skipped = 0
193+
194+
if self.dry_run:
195+
self.stdout.write(f" Would process {processed} assignments")
196+
for assignment in assignments[:5]: # Show first 5 as preview
197+
self.stdout.write(f" - {assignment.title} (due: {assignment.date})")
198+
if len(assignments) > 5:
199+
self.stdout.write(f" ... and {len(assignments) - 5} more")
200+
return processed, 0, 0, 0
201+
202+
# Process assignments in batches
203+
for i in range(0, len(assignments), self.batch_size):
204+
batch = assignments[i: i + self.batch_size]
205+
206+
with transaction.atomic():
207+
batch_created, batch_updated, batch_skipped = (
208+
self._process_assignment_batch(course_key, batch)
209+
)
210+
created += batch_created
211+
updated += batch_updated
212+
skipped += batch_skipped
213+
214+
return processed, created, updated, skipped
215+
216+
def _process_assignment_batch(self, course_key: CourseKey, assignments) -> tuple[int, int, int]:
217+
"""
218+
Process a batch of assignments and return (created, updated, skipped) counts.
219+
"""
220+
created = 0
221+
updated = 0
222+
skipped = 0
223+
224+
for assignment in assignments:
225+
self.stdout.write(
226+
f"Processing assignment: {assignment.block_key}/{assignment.assignment_type} (due: {assignment.date})"
227+
)
228+
existing = when_models.ContentDate.objects.filter(
229+
course_id=course_key, location=assignment.block_key, field="due"
230+
).first()
231+
232+
if existing and not self.force_update:
233+
skipped += 1
234+
log.info(
235+
f"Skipping existing ContentDate for {assignment.title} "
236+
f"in course {course_key}"
237+
)
238+
continue
239+
240+
try:
241+
update_or_create_assignments_due_dates(course_key, [assignment])
242+
243+
if existing:
244+
updated += 1
245+
log.info(
246+
f"Updated ContentDate for {assignment.title} "
247+
f"in course {course_key}"
248+
)
249+
else:
250+
created += 1
251+
log.info(
252+
f"Created ContentDate for {assignment.title} "
253+
f"in course {course_key}"
254+
)
255+
256+
except Exception as e: # pylint: disable=broad-exception-caught
257+
log.error(
258+
f"Failed to process assignment {assignment.title} "
259+
f"in course {course_key}: {str(e)}"
260+
)
261+
continue
262+
263+
return created, updated, skipped

0 commit comments

Comments
 (0)