openedx-progress 1.0.0__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ """
2
+ Progress tracking for Open edX students.
3
+ """
4
+
5
+ __version__ = '1.0.0'
@@ -0,0 +1,51 @@
1
+ """
2
+ openedx_progress Django application initialization.
3
+ """
4
+
5
+ from django.apps import AppConfig
6
+ from edx_django_utils.plugins.constants import PluginSettings, PluginURLs
7
+
8
+
9
+ class OpenedxProgressConfig(AppConfig):
10
+ """
11
+ Configuration for the openedx_progress Django application.
12
+ """
13
+
14
+ name = 'openedx_progress'
15
+ verbose_name = 'Open edX Progress'
16
+ default_auto_field = 'django.db.models.AutoField'
17
+
18
+ plugin_app = {
19
+ PluginURLs.CONFIG: {
20
+ 'cms.djangoapp': {
21
+ PluginURLs.NAMESPACE: 'openedx_progress',
22
+ PluginURLs.REGEX: r'^api/progress/',
23
+ PluginURLs.RELATIVE_PATH: 'urls',
24
+ },
25
+ 'lms.djangoapp': {
26
+ PluginURLs.NAMESPACE: 'openedx_progress',
27
+ PluginURLs.REGEX: r'^api/progress/',
28
+ PluginURLs.RELATIVE_PATH: 'urls',
29
+ },
30
+ },
31
+ PluginSettings.CONFIG: {
32
+ 'cms.djangoapp': {
33
+ 'common': {
34
+ PluginSettings.RELATIVE_PATH: 'settings.common',
35
+ },
36
+ },
37
+ 'lms.djangoapp': {
38
+ 'common': {
39
+ PluginSettings.RELATIVE_PATH: 'settings.common',
40
+ },
41
+ },
42
+ },
43
+ }
44
+
45
+ def ready(self):
46
+ """
47
+ Register optional Open edX signal handlers when their apps are installed.
48
+ """
49
+ from openedx_progress.signals import register_signal_handlers # pylint: disable=import-outside-toplevel
50
+
51
+ register_signal_handlers()
@@ -0,0 +1,3 @@
1
+ """
2
+ Management command package for openedx_progress.
3
+ """
@@ -0,0 +1,3 @@
1
+ """
2
+ Management commands for openedx_progress.
3
+ """
@@ -0,0 +1,239 @@
1
+ """
2
+ Backfill learner course completion summaries.
3
+ """
4
+ # pylint: disable=import-outside-toplevel,import-error
5
+ import time
6
+ import traceback
7
+
8
+ from django.contrib.auth import get_user_model
9
+ from django.core.management.base import BaseCommand, CommandError
10
+
11
+ from openedx_progress import services
12
+ from openedx_progress.models import CourseCompletionSummary
13
+
14
+
15
+ def parse_course_key(course_id):
16
+ """
17
+ Parse a course key when opaque-keys is available.
18
+ """
19
+ if not isinstance(course_id, str):
20
+ return course_id
21
+
22
+ try:
23
+ from opaque_keys.edx.keys import CourseKey
24
+ except ImportError:
25
+ return course_id
26
+
27
+ return CourseKey.from_string(course_id)
28
+
29
+
30
+ def course_keys_from_overviews():
31
+ """
32
+ Return course keys for all courses known to CourseOverview.
33
+ """
34
+ try:
35
+ from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
36
+ except ImportError as exc:
37
+ raise CommandError('CourseOverview is required when --course-id is omitted.') from exc
38
+
39
+ overviews = (
40
+ CourseOverview.get_all_courses()
41
+ if hasattr(CourseOverview, 'get_all_courses')
42
+ else CourseOverview.objects.all()
43
+ )
44
+ if hasattr(overviews, 'order_by') and hasattr(overviews, 'values_list'):
45
+ return overviews.order_by('id').values_list('id', flat=True)
46
+
47
+ return (getattr(course_overview, 'id', course_overview) for course_overview in overviews)
48
+
49
+
50
+ def enrolled_users_for_course(course_key):
51
+ """
52
+ Return active users enrolled in a course.
53
+ """
54
+ from common.djangoapps.student.models import CourseEnrollment
55
+
56
+ manager = CourseEnrollment.objects
57
+ if hasattr(manager, 'users_enrolled_in'):
58
+ return manager.users_enrolled_in(course_key)
59
+
60
+ enrollments = manager.select_related('user').filter(course_id=str(course_key), is_active=True)
61
+ return get_user_model().objects.filter(id__in=enrollments.values('user_id'))
62
+
63
+
64
+ def user_queryset_for_ids(user_ids):
65
+ """
66
+ Return users for explicit ids, preserving database batching behavior.
67
+ """
68
+ return get_user_model().objects.filter(id__in=user_ids).order_by('id')
69
+
70
+
71
+ def batched(iterable, batch_size):
72
+ """
73
+ Yield lists of up to batch_size objects from iterable.
74
+ """
75
+ batch = []
76
+ for item in iterable:
77
+ batch.append(item)
78
+ if len(batch) == batch_size:
79
+ yield batch
80
+ batch = []
81
+ if batch:
82
+ yield batch
83
+
84
+
85
+ class Command(BaseCommand):
86
+ """
87
+ Materialize course completion summaries for learners in a course.
88
+ """
89
+
90
+ help = 'Backfill learner course completion summaries for Open edX courses.'
91
+
92
+ def add_arguments(self, parser):
93
+ parser.add_argument(
94
+ '--course-id',
95
+ default=None,
96
+ help='Optional opaque course id, for example course-v1:Org+Num+Run.',
97
+ )
98
+ parser.add_argument(
99
+ '--user-id',
100
+ dest='user_ids',
101
+ action='append',
102
+ type=int,
103
+ default=None,
104
+ help='Optional learner user id. Can be supplied multiple times.',
105
+ )
106
+ parser.add_argument('--batch-size', type=int, default=500, help='Number of learners to process per batch.')
107
+ parser.add_argument('--sleep', type=float, default=0, help='Seconds to sleep between batches.')
108
+ parser.add_argument('--dry-run', action='store_true', help='Compute work without writing summary rows.')
109
+ parser.add_argument('--force', action='store_true', help='Recompute rows that already exist.')
110
+
111
+ def handle(self, *args, **options):
112
+ course_id = options['course_id']
113
+ batch_size = options['batch_size']
114
+ sleep_seconds = options['sleep']
115
+
116
+ if batch_size < 1:
117
+ raise CommandError('--batch-size must be greater than 0.')
118
+ if sleep_seconds < 0:
119
+ raise CommandError('--sleep cannot be negative.')
120
+
121
+ stats = {
122
+ 'processed': 0,
123
+ 'updated': 0,
124
+ 'skipped': 0,
125
+ 'failed': 0,
126
+ }
127
+
128
+ for course_key in self._course_keys_for_options(course_id):
129
+ if not course_id:
130
+ self.stdout.write('Processing course {}'.format(course_key))
131
+
132
+ course_stats = self._process_course(course_key, options)
133
+ for key, value in course_stats.items():
134
+ stats[key] += value
135
+
136
+ self.stdout.write(
137
+ 'Done: processed={}, updated={}, skipped={}, failed={}'.format(
138
+ stats['processed'],
139
+ stats['updated'],
140
+ stats['skipped'],
141
+ stats['failed'],
142
+ )
143
+ )
144
+
145
+ def _course_keys_for_options(self, course_id):
146
+ """
147
+ Return the course keys requested by command options.
148
+ """
149
+ if course_id:
150
+ return [parse_course_key(course_id)]
151
+
152
+ return (parse_course_key(course_id) for course_id in course_keys_from_overviews())
153
+
154
+ def _process_course(self, course_key, options):
155
+ """
156
+ Process one course and return stats.
157
+ """
158
+ batch_size = options['batch_size']
159
+ sleep_seconds = options['sleep']
160
+ dry_run = options['dry_run']
161
+ force = options['force']
162
+ verbosity = options['verbosity']
163
+ users = self._users_for_options(course_key, options['user_ids'])
164
+ stats = {
165
+ 'processed': 0,
166
+ 'updated': 0,
167
+ 'skipped': 0,
168
+ 'failed': 0,
169
+ }
170
+
171
+ for batch_number, user_batch in enumerate(batched(self._iter_users(users, batch_size), batch_size), start=1):
172
+ for user in user_batch:
173
+ try:
174
+ action = self._process_user(course_key, user, force=force, dry_run=dry_run)
175
+ except Exception as exc: # pylint: disable=broad-except
176
+ stats['failed'] += 1
177
+ user_id = getattr(user, 'id', user)
178
+ message = self._format_exception(exc)
179
+ self.stderr.write('Failed user {}: {}'.format(user_id, message))
180
+ if verbosity > 1:
181
+ self.stderr.write(''.join(traceback.format_exception(type(exc), exc, exc.__traceback__)))
182
+ continue
183
+
184
+ stats['processed'] += 1
185
+ stats[action] += 1
186
+
187
+ self.stdout.write(
188
+ 'Batch {} complete: processed={}, updated={}, skipped={}, failed={}'.format(
189
+ batch_number,
190
+ stats['processed'],
191
+ stats['updated'],
192
+ stats['skipped'],
193
+ stats['failed'],
194
+ )
195
+ )
196
+ if sleep_seconds:
197
+ time.sleep(sleep_seconds)
198
+
199
+ return stats
200
+
201
+ def _users_for_options(self, course_key, user_ids):
202
+ """
203
+ Return the user queryset or iterable requested by command options.
204
+ """
205
+ if user_ids:
206
+ return user_queryset_for_ids(user_ids)
207
+ return enrolled_users_for_course(course_key)
208
+
209
+ def _iter_users(self, users, batch_size):
210
+ """
211
+ Iterate users with database chunking when available.
212
+ """
213
+ if hasattr(users, 'iterator'):
214
+ return users.iterator(chunk_size=batch_size)
215
+ return iter(users)
216
+
217
+ def _process_user(self, course_key, user, force, dry_run):
218
+ """
219
+ Process one learner and return the stats bucket to increment.
220
+ """
221
+ exists = CourseCompletionSummary.objects.filter(course_id=str(course_key), user_id=user.id).exists()
222
+ if exists and not force:
223
+ return 'skipped'
224
+
225
+ if dry_run:
226
+ return 'updated'
227
+
228
+ services.upsert_completion_summary(course_key, user)
229
+ return 'updated'
230
+
231
+ def _format_exception(self, exc):
232
+ """
233
+ Return a compact exception description for command output.
234
+ """
235
+ exc_type = type(exc).__name__
236
+ message = str(exc)
237
+ if message:
238
+ return '{}: {}'.format(exc_type, message)
239
+ return exc_type
@@ -0,0 +1,122 @@
1
+ """
2
+ Process queued dirty learner course completion summaries.
3
+ """
4
+ import time
5
+
6
+ from django.contrib.auth import get_user_model
7
+ from django.core.management.base import BaseCommand, CommandError
8
+
9
+ from openedx_progress import services
10
+ from openedx_progress.management.commands.backfill_course_completion_summaries import batched, parse_course_key
11
+ from openedx_progress.models import CourseCompletionSummaryDirty
12
+
13
+
14
+ class Command(BaseCommand):
15
+ """
16
+ Recompute summaries previously marked dirty by event hooks.
17
+ """
18
+
19
+ help = 'Process queued learner course completion summary recomputations.'
20
+
21
+ def add_arguments(self, parser):
22
+ parser.add_argument('--course-id', default=None, help='Optional course id to restrict processing.')
23
+ parser.add_argument(
24
+ '--user-id',
25
+ type=int,
26
+ default=None,
27
+ help='Optional learner user id to restrict processing.',
28
+ )
29
+ parser.add_argument('--limit', type=int, default=None, help='Maximum dirty rows to process.')
30
+ parser.add_argument(
31
+ '--batch-size',
32
+ type=int,
33
+ default=500,
34
+ help='Number of dirty rows to process per batch.',
35
+ )
36
+ parser.add_argument(
37
+ '--sleep',
38
+ type=float,
39
+ default=0,
40
+ help='Seconds to sleep between batches.',
41
+ )
42
+ parser.add_argument(
43
+ '--dry-run',
44
+ action='store_true',
45
+ help='Report queued rows without recomputing or deleting them.',
46
+ )
47
+
48
+ def handle(self, *args, **options):
49
+ batch_size = options['batch_size']
50
+ sleep_seconds = options['sleep']
51
+ if batch_size < 1:
52
+ raise CommandError('--batch-size must be greater than 0.')
53
+ if sleep_seconds < 0:
54
+ raise CommandError('--sleep cannot be negative.')
55
+
56
+ dirty_rows = self._dirty_rows(options)
57
+ stats = {
58
+ 'processed': 0,
59
+ 'updated': 0,
60
+ 'failed': 0,
61
+ }
62
+
63
+ dirty_batches = batched(dirty_rows.iterator(chunk_size=batch_size), batch_size)
64
+ for batch_number, dirty_batch in enumerate(dirty_batches, start=1):
65
+ for dirty in dirty_batch:
66
+ try:
67
+ if options['dry_run']:
68
+ stats['updated'] += 1
69
+ else:
70
+ self._process_dirty_row(dirty)
71
+ stats['updated'] += 1
72
+ except Exception as exc: # pylint: disable=broad-except
73
+ stats['failed'] += 1
74
+ dirty.attempts += 1
75
+ dirty.last_error = str(exc)
76
+ dirty.save(update_fields=['attempts', 'last_error', 'modified'])
77
+ self.stderr.write('Failed dirty row {}: {}'.format(dirty.id, exc))
78
+ continue
79
+
80
+ stats['processed'] += 1
81
+
82
+ self.stdout.write(
83
+ 'Batch {} complete: processed={}, updated={}, failed={}'.format(
84
+ batch_number,
85
+ stats['processed'],
86
+ stats['updated'],
87
+ stats['failed'],
88
+ )
89
+ )
90
+ if sleep_seconds:
91
+ time.sleep(sleep_seconds)
92
+
93
+ self.stdout.write(
94
+ 'Done: processed={}, updated={}, failed={}'.format(
95
+ stats['processed'],
96
+ stats['updated'],
97
+ stats['failed'],
98
+ )
99
+ )
100
+ if stats['failed']:
101
+ raise CommandError('{} dirty row(s) failed while processing summaries.'.format(stats['failed']))
102
+
103
+ def _dirty_rows(self, options):
104
+ """
105
+ Return the queued rows matching command filters.
106
+ """
107
+ queryset = CourseCompletionSummaryDirty.objects.order_by('modified', 'id')
108
+ if options['course_id']:
109
+ queryset = queryset.filter(course_id=options['course_id'])
110
+ if options['user_id']:
111
+ queryset = queryset.filter(user_id=options['user_id'])
112
+ if options['limit']:
113
+ queryset = queryset[:options['limit']]
114
+ return queryset
115
+
116
+ def _process_dirty_row(self, dirty):
117
+ """
118
+ Recompute and remove one dirty queue row.
119
+ """
120
+ user = get_user_model().objects.get(id=dirty.user_id)
121
+ services.upsert_completion_summary(parse_course_key(dirty.course_id), user)
122
+ dirty.delete()
@@ -0,0 +1,130 @@
1
+ # Generated by Django 4.2.30 on 2026-06-19 08:48
2
+
3
+ from django.db import migrations, models
4
+ import django.utils.timezone
5
+ import model_utils.fields
6
+
7
+
8
+ class Migration(migrations.Migration):
9
+
10
+ initial = True
11
+
12
+ dependencies = []
13
+
14
+ operations = [
15
+ migrations.CreateModel(
16
+ name="CourseCompletionSummary",
17
+ fields=[
18
+ (
19
+ "id",
20
+ models.AutoField(
21
+ auto_created=True,
22
+ primary_key=True,
23
+ serialize=False,
24
+ verbose_name="ID",
25
+ ),
26
+ ),
27
+ (
28
+ "created",
29
+ model_utils.fields.AutoCreatedField(
30
+ default=django.utils.timezone.now,
31
+ editable=False,
32
+ verbose_name="created",
33
+ ),
34
+ ),
35
+ (
36
+ "modified",
37
+ model_utils.fields.AutoLastModifiedField(
38
+ default=django.utils.timezone.now,
39
+ editable=False,
40
+ verbose_name="modified",
41
+ ),
42
+ ),
43
+ ("user_id", models.PositiveIntegerField(db_index=True)),
44
+ ("course_id", models.CharField(db_index=True, max_length=255)),
45
+ ("complete_count", models.PositiveIntegerField(default=0)),
46
+ ("incomplete_count", models.PositiveIntegerField(default=0)),
47
+ ("locked_count", models.PositiveIntegerField(default=0)),
48
+ (
49
+ "percent_complete",
50
+ models.DecimalField(
51
+ blank=True, decimal_places=5, max_digits=6, null=True
52
+ ),
53
+ ),
54
+ ("computed_at", models.DateTimeField(db_index=True)),
55
+ ],
56
+ ),
57
+ migrations.CreateModel(
58
+ name="CourseCompletionSummaryDirty",
59
+ fields=[
60
+ (
61
+ "id",
62
+ models.AutoField(
63
+ auto_created=True,
64
+ primary_key=True,
65
+ serialize=False,
66
+ verbose_name="ID",
67
+ ),
68
+ ),
69
+ (
70
+ "created",
71
+ model_utils.fields.AutoCreatedField(
72
+ default=django.utils.timezone.now,
73
+ editable=False,
74
+ verbose_name="created",
75
+ ),
76
+ ),
77
+ (
78
+ "modified",
79
+ model_utils.fields.AutoLastModifiedField(
80
+ default=django.utils.timezone.now,
81
+ editable=False,
82
+ verbose_name="modified",
83
+ ),
84
+ ),
85
+ ("user_id", models.PositiveIntegerField(db_index=True)),
86
+ ("course_id", models.CharField(db_index=True, max_length=255)),
87
+ ("reason", models.CharField(blank=True, max_length=255)),
88
+ ("attempts", models.PositiveIntegerField(default=0)),
89
+ ("last_error", models.TextField(blank=True)),
90
+ ],
91
+ options={
92
+ "indexes": [
93
+ models.Index(
94
+ fields=["course_id", "user_id"], name="op_dirty_course_user_idx"
95
+ ),
96
+ models.Index(fields=["modified"], name="op_dirty_modified_idx"),
97
+ ],
98
+ },
99
+ ),
100
+ migrations.AddConstraint(
101
+ model_name="coursecompletionsummarydirty",
102
+ constraint=models.UniqueConstraint(
103
+ fields=("course_id", "user_id"), name="op_unique_dirty_course_user"
104
+ ),
105
+ ),
106
+ migrations.AddIndex(
107
+ model_name="coursecompletionsummary",
108
+ index=models.Index(
109
+ fields=["course_id", "user_id"], name="op_course_user_idx"
110
+ ),
111
+ ),
112
+ migrations.AddIndex(
113
+ model_name="coursecompletionsummary",
114
+ index=models.Index(
115
+ fields=["course_id", "computed_at"], name="op_course_computed_idx"
116
+ ),
117
+ ),
118
+ migrations.AddIndex(
119
+ model_name="coursecompletionsummary",
120
+ index=models.Index(
121
+ fields=["user_id", "computed_at"], name="op_user_computed_idx"
122
+ ),
123
+ ),
124
+ migrations.AddConstraint(
125
+ model_name="coursecompletionsummary",
126
+ constraint=models.UniqueConstraint(
127
+ fields=("course_id", "user_id"), name="op_unique_course_user_summary"
128
+ ),
129
+ ),
130
+ ]
@@ -0,0 +1,3 @@
1
+ """
2
+ Database migrations for openedx_progress.
3
+ """
@@ -0,0 +1,91 @@
1
+ """
2
+ Database models for openedx_progress.
3
+ """
4
+ from django.db import models
5
+ from model_utils.models import TimeStampedModel
6
+
7
+
8
+ class CourseCompletionSummary(TimeStampedModel):
9
+ """
10
+ Materialized learner completion counts for a course.
11
+
12
+ .. pii: The user_id field stores an identifier linked to a learner.
13
+ The course_id and completion counts are educational records.
14
+ .. pii_types: id, other
15
+ .. pii_retirement: retained
16
+ """
17
+
18
+ user_id = models.PositiveIntegerField(db_index=True)
19
+ course_id = models.CharField(max_length=255, db_index=True)
20
+ complete_count = models.PositiveIntegerField(default=0)
21
+ incomplete_count = models.PositiveIntegerField(default=0)
22
+ locked_count = models.PositiveIntegerField(default=0)
23
+ percent_complete = models.DecimalField(
24
+ max_digits=6,
25
+ decimal_places=5,
26
+ null=True,
27
+ blank=True,
28
+ )
29
+ computed_at = models.DateTimeField(db_index=True)
30
+
31
+ def __str__(self):
32
+ """
33
+ Get a string representation of this model instance.
34
+ """
35
+ return '<CourseCompletionSummary, course_id: {}, user_id: {}>'.format(self.course_id, self.user_id)
36
+
37
+ class Meta:
38
+ """
39
+ Model metadata.
40
+ """
41
+
42
+ constraints = [
43
+ models.UniqueConstraint(
44
+ fields=['course_id', 'user_id'],
45
+ name='op_unique_course_user_summary',
46
+ ),
47
+ ]
48
+ indexes = [
49
+ models.Index(fields=['course_id', 'user_id'], name='op_course_user_idx'),
50
+ models.Index(fields=['course_id', 'computed_at'], name='op_course_computed_idx'),
51
+ models.Index(fields=['user_id', 'computed_at'], name='op_user_computed_idx'),
52
+ ]
53
+
54
+
55
+ class CourseCompletionSummaryDirty(TimeStampedModel):
56
+ """
57
+ Queue entry for recomputing one learner's course completion summary.
58
+
59
+ .. pii: The user_id field stores an identifier linked to a learner.
60
+ The course_id and reason fields may reveal learning activity.
61
+ .. pii_types: id, other
62
+ .. pii_retirement: retained
63
+ """
64
+
65
+ user_id = models.PositiveIntegerField(db_index=True)
66
+ course_id = models.CharField(max_length=255, db_index=True)
67
+ reason = models.CharField(max_length=255, blank=True)
68
+ attempts = models.PositiveIntegerField(default=0)
69
+ last_error = models.TextField(blank=True)
70
+
71
+ def __str__(self):
72
+ """
73
+ Get a string representation of this model instance.
74
+ """
75
+ return '<CourseCompletionSummaryDirty, course_id: {}, user_id: {}>'.format(self.course_id, self.user_id)
76
+
77
+ class Meta:
78
+ """
79
+ Model metadata.
80
+ """
81
+
82
+ constraints = [
83
+ models.UniqueConstraint(
84
+ fields=['course_id', 'user_id'],
85
+ name='op_unique_dirty_course_user',
86
+ ),
87
+ ]
88
+ indexes = [
89
+ models.Index(fields=['course_id', 'user_id'], name='op_dirty_course_user_idx'),
90
+ models.Index(fields=['modified'], name='op_dirty_modified_idx'),
91
+ ]