|
| 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