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.
- openedx_progress/__init__.py +5 -0
- openedx_progress/apps.py +51 -0
- openedx_progress/management/__init__.py +3 -0
- openedx_progress/management/commands/__init__.py +3 -0
- openedx_progress/management/commands/backfill_course_completion_summaries.py +239 -0
- openedx_progress/management/commands/process_dirty_course_completion_summaries.py +122 -0
- openedx_progress/migrations/0001_initial.py +130 -0
- openedx_progress/migrations/__init__.py +3 -0
- openedx_progress/models.py +91 -0
- openedx_progress/services.py +155 -0
- openedx_progress/settings/__init__.py +3 -0
- openedx_progress/settings/common.py +12 -0
- openedx_progress/signals.py +89 -0
- openedx_progress/templates/openedx_progress/base.html +26 -0
- openedx_progress/urls.py +10 -0
- openedx_progress-1.0.0.dist-info/METADATA +224 -0
- openedx_progress-1.0.0.dist-info/RECORD +21 -0
- openedx_progress-1.0.0.dist-info/WHEEL +6 -0
- openedx_progress-1.0.0.dist-info/entry_points.txt +5 -0
- openedx_progress-1.0.0.dist-info/licenses/LICENSE.txt +664 -0
- openedx_progress-1.0.0.dist-info/top_level.txt +1 -0
openedx_progress/apps.py
ADDED
|
@@ -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,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,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
|
+
]
|