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,155 @@
1
+ """
2
+ Services for materializing Open edX learner course completion summaries.
3
+ """
4
+ # pylint: disable=import-outside-toplevel,import-error
5
+ import logging
6
+ from decimal import ROUND_HALF_UP, Decimal
7
+
8
+ from django.db import transaction
9
+ from django.utils import timezone
10
+
11
+ from openedx_progress.models import CourseCompletionSummary, CourseCompletionSummaryDirty
12
+
13
+ PERCENT_COMPLETE_QUANT = Decimal('0.00001')
14
+ log = logging.getLogger(__name__)
15
+
16
+
17
+ def _course_id_for_storage(course_key):
18
+ """
19
+ Convert an opaque course key to its stable stored string form.
20
+ """
21
+ return str(course_key)
22
+
23
+
24
+ def _user_id(user):
25
+ """
26
+ Return the integer primary key from a user object or user id value.
27
+ """
28
+ return int(getattr(user, 'id', user))
29
+
30
+
31
+ def _get_course_blocks_completion_summary(course_key, user):
32
+ """
33
+ Load edx-platform's completion summary function lazily.
34
+ """
35
+ from lms.djangoapps.courseware.courses import get_course_blocks_completion_summary
36
+
37
+ return get_course_blocks_completion_summary(course_key, user)
38
+
39
+
40
+ def _get_unfiltered_course_unit_count(course_key, user):
41
+ """
42
+ Return the number of course units before learner access transformers prune them.
43
+ """
44
+ from lms.djangoapps.course_blocks.api import get_course_blocks
45
+ from openedx.core.djangoapps.content.block_structure.transformers import BlockStructureTransformers
46
+ from xmodule.modulestore.django import modulestore
47
+
48
+ store = modulestore()
49
+ course_usage_key = store.make_course_usage_key(course_key)
50
+ block_data = get_course_blocks(
51
+ user,
52
+ course_usage_key,
53
+ transformers=BlockStructureTransformers([]),
54
+ allow_start_dates_in_future=True,
55
+ )
56
+
57
+ unit_count = 0
58
+ for section_key in block_data.get_children(course_usage_key):
59
+ for subsection_key in block_data.get_children(section_key):
60
+ unit_count += len(block_data.get_children(subsection_key))
61
+ return unit_count
62
+
63
+
64
+ def calculate_locked_count(summary_locked_count, complete_count, incomplete_count, course_key, user):
65
+ """
66
+ Calculate locked units, including units removed by learner access filtering.
67
+ """
68
+ locked_count = int(summary_locked_count)
69
+ try:
70
+ total_unit_count = _get_unfiltered_course_unit_count(course_key, user)
71
+ except ImportError:
72
+ return locked_count
73
+ except Exception: # pylint: disable=broad-except
74
+ log.exception('Unable to calculate unfiltered course unit count for %s.', course_key)
75
+ return locked_count
76
+
77
+ derived_locked_count = max(total_unit_count - complete_count - incomplete_count, 0)
78
+ return max(locked_count, derived_locked_count)
79
+
80
+
81
+ def calculate_percent_complete(complete_count, incomplete_count):
82
+ """
83
+ Calculate complete / (complete + incomplete), excluding locked blocks.
84
+ """
85
+ denominator = complete_count + incomplete_count
86
+ if denominator == 0:
87
+ return None
88
+
89
+ return (
90
+ Decimal(complete_count) / Decimal(denominator)
91
+ ).quantize(PERCENT_COMPLETE_QUANT, rounding=ROUND_HALF_UP)
92
+
93
+
94
+ def compute_completion_summary(course_key, user):
95
+ """
96
+ Return persisted completion summary values for a learner and course.
97
+ """
98
+ summary = _get_course_blocks_completion_summary(course_key, user)
99
+ complete_count = int(summary['complete_count'])
100
+ incomplete_count = int(summary['incomplete_count'])
101
+ locked_count = calculate_locked_count(
102
+ summary['locked_count'],
103
+ complete_count,
104
+ incomplete_count,
105
+ course_key,
106
+ user,
107
+ )
108
+
109
+ return {
110
+ 'course_id': _course_id_for_storage(course_key),
111
+ 'user_id': _user_id(user),
112
+ 'complete_count': complete_count,
113
+ 'incomplete_count': incomplete_count,
114
+ 'locked_count': locked_count,
115
+ 'percent_complete': calculate_percent_complete(complete_count, incomplete_count),
116
+ 'computed_at': timezone.now(),
117
+ }
118
+
119
+
120
+ def upsert_completion_summary(course_key, user):
121
+ """
122
+ Compute and persist the summary row for a learner and course.
123
+ """
124
+ values = compute_completion_summary(course_key, user)
125
+ course_id = values.pop('course_id')
126
+ user_id = values.pop('user_id')
127
+
128
+ summary, _created = CourseCompletionSummary.objects.update_or_create(
129
+ course_id=course_id,
130
+ user_id=user_id,
131
+ defaults=values,
132
+ )
133
+ return summary
134
+
135
+
136
+ def mark_completion_summary_dirty(course_key, user, reason=''):
137
+ """
138
+ Mark a learner/course summary for later recomputation.
139
+ """
140
+ dirty, _created = CourseCompletionSummaryDirty.objects.update_or_create(
141
+ course_id=_course_id_for_storage(course_key),
142
+ user_id=_user_id(user),
143
+ defaults={
144
+ 'reason': reason,
145
+ 'last_error': '',
146
+ },
147
+ )
148
+ return dirty
149
+
150
+
151
+ def mark_completion_summary_dirty_on_commit(course_key, user, reason=''):
152
+ """
153
+ Mark a learner/course summary dirty after the current transaction commits.
154
+ """
155
+ transaction.on_commit(lambda: mark_completion_summary_dirty(course_key, user, reason=reason))
@@ -0,0 +1,3 @@
1
+ """
2
+ Settings package for openedx_progress.
3
+ """
@@ -0,0 +1,12 @@
1
+ """
2
+ Common settings for openedx_progress.
3
+ """
4
+
5
+
6
+ def plugin_settings(settings): # pylint: disable=unused-argument
7
+ """
8
+ Apply common Open edX settings for openedx_progress.
9
+
10
+ The plugin currently does not require runtime settings, but the hook is
11
+ registered so LMS and CMS can load the Django plugin consistently.
12
+ """
@@ -0,0 +1,89 @@
1
+ """
2
+ Optional signal handlers for keeping completion summaries fresh.
3
+ """
4
+ # pylint: disable=import-outside-toplevel
5
+ from django.db import transaction
6
+ from django.db.models.signals import post_delete, post_save
7
+
8
+ from openedx_progress import services
9
+
10
+
11
+ def _block_completion_model():
12
+ """
13
+ Return the Open edX BlockCompletion model when it is installed.
14
+ """
15
+ try:
16
+ from completion.models import BlockCompletion
17
+ except (ImportError, RuntimeError):
18
+ return None
19
+ return BlockCompletion
20
+
21
+
22
+ def _course_key_from_completion(instance):
23
+ """
24
+ Extract a course key from a BlockCompletion instance.
25
+ """
26
+ context_key = getattr(instance, 'context_key', None)
27
+ if context_key is not None:
28
+ # BlockCompletion also supports non-course learning contexts (libraries).
29
+ if not getattr(context_key, 'is_course', True):
30
+ return None
31
+ return context_key
32
+
33
+ block_key = getattr(instance, 'block_key', None)
34
+ return getattr(block_key, 'course_key', None)
35
+
36
+
37
+ def _user_id_from_completion(instance):
38
+ """
39
+ Extract a user id from a BlockCompletion instance.
40
+ """
41
+ return getattr(instance, 'user_id', None) or getattr(getattr(instance, 'user', None), 'id', None)
42
+
43
+
44
+ def _mark_dirty_from_completion(instance, reason):
45
+ """
46
+ Queue a summary recomputation for a BlockCompletion change.
47
+ """
48
+ course_key = _course_key_from_completion(instance)
49
+ user_id = _user_id_from_completion(instance)
50
+ if course_key is None or user_id is None:
51
+ return
52
+
53
+ transaction.on_commit(
54
+ lambda: services.mark_completion_summary_dirty(course_key, user_id, reason=reason)
55
+ )
56
+
57
+
58
+ def block_completion_saved(sender, instance, **kwargs): # pylint: disable=unused-argument
59
+ """
60
+ Mark a learner/course summary dirty after a completion save.
61
+ """
62
+ _mark_dirty_from_completion(instance, 'block_completion_saved')
63
+
64
+
65
+ def block_completion_deleted(sender, instance, **kwargs): # pylint: disable=unused-argument
66
+ """
67
+ Mark a learner/course summary dirty after a completion delete.
68
+ """
69
+ _mark_dirty_from_completion(instance, 'block_completion_deleted')
70
+
71
+
72
+ def register_signal_handlers():
73
+ """
74
+ Connect optional completion signals.
75
+ """
76
+ block_completion = _block_completion_model()
77
+ if block_completion is None:
78
+ return
79
+
80
+ post_save.connect(
81
+ block_completion_saved,
82
+ sender=block_completion,
83
+ dispatch_uid='openedx_progress.block_completion_saved',
84
+ )
85
+ post_delete.connect(
86
+ block_completion_deleted,
87
+ sender=block_completion,
88
+ dispatch_uid='openedx_progress.block_completion_deleted',
89
+ )
@@ -0,0 +1,26 @@
1
+
2
+
3
+ {% load i18n %}
4
+ {% trans "Dummy text to generate a translation (.po) source file. It is safe to delete this line. It is also safe to delete (load i18n) above if there are no other (trans) tags in the file" %}
5
+
6
+ {% comment %}
7
+ As the developer of this package, don't place anything here if you can help it
8
+ since this allows developers to have interoperability between your template
9
+ structure and their own.
10
+
11
+ Example: Developer melding the 2SoD pattern to fit inside with another pattern::
12
+
13
+ {% extends "base.html" %}
14
+ {% load static %}
15
+
16
+ <!-- Their site uses old school block layout -->
17
+ {% block extra_js %}
18
+
19
+ <!-- Your package using 2SoD block layout -->
20
+ {% block javascript %}
21
+ <script src="{% static 'js/ninja.js' %}" type="text/javascript"></script>
22
+ {% endblock javascript %}
23
+
24
+ {% endblock extra_js %}
25
+ {% endcomment %}
26
+
@@ -0,0 +1,10 @@
1
+ """
2
+ URLs for openedx_progress.
3
+ """
4
+ from django.urls import re_path # pylint: disable=unused-import
5
+ from django.views.generic import TemplateView # pylint: disable=unused-import
6
+
7
+ urlpatterns = [
8
+ # TODO: Fill in URL patterns and views here.
9
+ # re_path(r'', TemplateView.as_view(template_name="openedx_progress/base.html")),
10
+ ]
@@ -0,0 +1,224 @@
1
+ Metadata-Version: 2.4
2
+ Name: openedx-progress
3
+ Version: 1.0.0
4
+ Summary: Progress tracking for Open edX students
5
+ Home-page: https://github.com/aulasneo/openedx-progress
6
+ Author: Andrés González
7
+ Author-email: andres@aulasneo.com
8
+ License: AGPL 3.0
9
+ Keywords: Python edx
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Framework :: Django
12
+ Classifier: Framework :: Django :: 5.2
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)
15
+ Classifier: Natural Language :: English
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Requires-Python: >=3.12
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE.txt
21
+ Requires-Dist: Django<5.3,>=5.2.13
22
+ Requires-Dist: django-model-utils<6.0,>=5.0.0
23
+ Requires-Dist: edx-django-utils<9.0,>=8.0.1
24
+ Dynamic: author
25
+ Dynamic: author-email
26
+ Dynamic: classifier
27
+ Dynamic: description
28
+ Dynamic: description-content-type
29
+ Dynamic: home-page
30
+ Dynamic: keywords
31
+ Dynamic: license
32
+ Dynamic: license-file
33
+ Dynamic: requires-dist
34
+ Dynamic: requires-python
35
+ Dynamic: summary
36
+
37
+ # openedx-progress
38
+
39
+ Progress tracking for Open edX students.
40
+
41
+ This Django plugin materializes learner course completion summaries for Open edX
42
+ analytics. It stores one row per learner/course pair with the completion counts
43
+ returned by `lms.djangoapps.courseware.courses.get_course_blocks_completion_summary`,
44
+ derives locked units from the unfiltered course structure when available, and
45
+ uses `computed_at` for when the values were last computed.
46
+
47
+ ## Compatibility
48
+
49
+ Targets **Open edX Verawood**, using **Python 3.12+ and Django 5.2**.
50
+ Runtime dependency ranges allow compatible patch updates; development lockfiles
51
+ are not intended to replace the LMS requirements. Install the plugin into the
52
+ platform environment using its constraints.
53
+
54
+ See [the Verawood compatibility review](docs/verawood.md) for the checked
55
+ upstream versions, integration points, release impacts, and deployment checks.
56
+
57
+ ## Why this exists
58
+
59
+ In the LMS progress page, progress, also known as completion, is calculated at
60
+ runtime as:
61
+
62
+ ```text
63
+ units completed / (units completed + units incomplete)
64
+ ```
65
+
66
+ The caveat is that these unit counts do not include units that are locked for
67
+ that specific learner at that time. A unit can be locked because of visibility
68
+ controls, dated gates, or other course access rules.
69
+
70
+ Analytics add-ons such as Panorama often only have the total number of units
71
+ that exist in the course. They do not have learner-specific detail about how many
72
+ of those units are currently hidden or locked. As a result, analytics systems can
73
+ show a completion percentage that differs from the percentage shown in the LMS
74
+ progress page.
75
+
76
+ This plugin stores the learner-specific `complete_count`, `incomplete_count`,
77
+ and `locked_count` values in the summary table. By querying this table,
78
+ analytics systems can count locked units separately and produce a completion
79
+ calculation that more closely matches what the LMS knows about that learner at
80
+ the time the summary was computed.
81
+
82
+ For an LMS-style completion percentage, use:
83
+
84
+ ```text
85
+ complete_count / (complete_count + incomplete_count)
86
+ ```
87
+
88
+ If an analytics system starts from a course-wide `total_units` value, it can use
89
+ `locked_count` to derive the learner-specific denominator:
90
+
91
+ ```text
92
+ complete_count / (total_units - locked_count)
93
+ ```
94
+
95
+ In practice, `total_units - locked_count` should correspond to
96
+ `complete_count + incomplete_count` for the same course structure snapshot.
97
+
98
+ ## Database tables
99
+
100
+ This plugin creates two tables.
101
+
102
+ ### `openedx_progress_coursecompletionsummary`
103
+
104
+ Stores the latest materialized completion summary for each learner/course pair.
105
+ There is one row per `course_id` and `user_id`.
106
+
107
+ Columns:
108
+
109
+ - `id`: primary key.
110
+ - `created`: when the row was first created.
111
+ - `modified`: when the row was last saved.
112
+ - `user_id`: learner user id.
113
+ - `course_id`: Open edX course key stored as a string.
114
+ - `complete_count`: number of units completed by the learner.
115
+ - `incomplete_count`: number of visible, unlocked units not yet completed by the
116
+ learner.
117
+ - `locked_count`: number of units currently locked or hidden for that learner.
118
+ - `percent_complete`: stored LMS-style completion ratio, calculated from
119
+ `complete_count / (complete_count + incomplete_count)`.
120
+ - `computed_at`: when the completion summary was computed from edx-platform.
121
+
122
+ The table has a unique constraint on `course_id` and `user_id`, so repeated
123
+ backfills or dirty-queue processing update the existing learner/course summary
124
+ instead of creating duplicate rows.
125
+
126
+ ### `openedx_progress_coursecompletionsummarydirty`
127
+
128
+ Stores a lightweight queue of learner/course pairs that need their completion
129
+ summary recomputed.
130
+
131
+ Columns:
132
+
133
+ - `id`: primary key.
134
+ - `created`: when the queue row was first created.
135
+ - `modified`: when the queue row was last saved.
136
+ - `user_id`: learner user id.
137
+ - `course_id`: Open edX course key stored as a string.
138
+ - `reason`: short reason why the summary was marked dirty, such as a completion
139
+ signal.
140
+ - `attempts`: number of processing attempts.
141
+ - `last_error`: last processing error, if recomputation failed.
142
+
143
+ The table has a unique constraint on `course_id` and `user_id`, so a learner can
144
+ only have one pending recomputation row per course.
145
+
146
+ ## Course completion summaries
147
+
148
+ `CourseCompletionSummary` stores:
149
+
150
+ - `user_id`
151
+ - `course_id`
152
+ - `complete_count`
153
+ - `incomplete_count`
154
+ - `locked_count`
155
+ - `percent_complete`
156
+ - `computed_at`
157
+ - `created` and `modified`
158
+
159
+ The stored `percent_complete` is calculated as:
160
+
161
+ ```text
162
+ complete_count / (complete_count + incomplete_count)
163
+ ```
164
+
165
+ `locked_count` is intentionally excluded from the stored denominator to match the
166
+ LMS progress page calculation. If the denominator is zero, `percent_complete` is
167
+ stored as `NULL`.
168
+
169
+ ## Backfill command
170
+
171
+ Run the backfill command inside an LMS environment where edx-platform apps are
172
+ installed:
173
+
174
+ ```bash
175
+ ./manage.py lms backfill_course_completion_summaries
176
+ ```
177
+
178
+ Useful options:
179
+
180
+ - `--course-id course-v1:edX+DemoX+Demo_Course` restricts processing to one
181
+ course. When omitted, all courses from CourseOverview are processed.
182
+ - `--user-id 123` can be repeated to process specific learners.
183
+ - `--batch-size 500` controls database iteration batches.
184
+ - `--sleep 0.5` pauses between batches.
185
+ - `--dry-run` selects learners without writing rows.
186
+ - `--force` recomputes rows that already exist.
187
+
188
+ ## Dirty queue
189
+
190
+ When `completion.models.BlockCompletion` is importable, the plugin registers
191
+ `post_save` and `post_delete` signal handlers that enqueue learner/course pairs
192
+ in `CourseCompletionSummaryDirty` after transaction commit. Process queued rows
193
+ with:
194
+
195
+ ```bash
196
+ ./manage.py lms process_dirty_course_completion_summaries
197
+ ```
198
+
199
+ ## Getting help
200
+
201
+ For anything non-trivial, open an issue in this repository with as many details
202
+ as you can provide:
203
+
204
+ <https://github.com/aulasneo/openedx-progress/issues>
205
+
206
+ ## License
207
+
208
+ The code in this repository is licensed under the AGPL 3.0 unless otherwise
209
+ noted. See [LICENSE.txt](LICENSE.txt) for details.
210
+
211
+ ## Contributing
212
+
213
+ This project is currently accepting all types of contributions, including bug
214
+ fixes, security fixes, maintenance work, and new features. Please discuss new
215
+ feature ideas with the maintainers before beginning development to maximize the
216
+ chances of your change being accepted.
217
+
218
+ ## Security
219
+
220
+ Please do not report security issues in public. Email support@aulasneo.com.
221
+
222
+ ## Disclaimer
223
+
224
+ Part of this code was developed with the aid of AI tools.
@@ -0,0 +1,21 @@
1
+ openedx_progress/__init__.py,sha256=K9Nmmb7O9-3zMO7obQqjJcIgSAsLmk94-NNW7okKUpI,72
2
+ openedx_progress/apps.py,sha256=Ro433qnbFmkhcP7tpKm4uiIpzuvlOWzFw698gO9m_z0,1539
3
+ openedx_progress/models.py,sha256=dk8_g8yaNe_WrlVKthzoDicYf8cw6ddsaunTfpC7X8E,3012
4
+ openedx_progress/services.py,sha256=2J4klW5baeiox5DQnkA1dsb_h2T2-v3JxYHH2-ZfFvw,5004
5
+ openedx_progress/signals.py,sha256=W54Xfibg5yaJECcD0IaBoat_V0FnlOt2FLmz-bCljcs,2655
6
+ openedx_progress/urls.py,sha256=56MREMo1jAd062a9d-hYqJfqjxJqaJ6EZ6Q64whTXHk,333
7
+ openedx_progress/management/__init__.py,sha256=D2wnKQgkOSVIS4BdvQfh34vtTD5v4hzOZSdgiUBX3lk,57
8
+ openedx_progress/management/commands/__init__.py,sha256=YQl1Jxh3wiFH2CYMheY-78Az4XiSVkXSOIS16Wyfzk4,50
9
+ openedx_progress/management/commands/backfill_course_completion_summaries.py,sha256=vtv3EPVQGv04O12dUd2lXzJZF4Yd7En6L3RBMVJdA7A,7996
10
+ openedx_progress/management/commands/process_dirty_course_completion_summaries.py,sha256=LNBGiWimuud985dqoAWBEp5ugf9a_SvYRL0lhNvr-9w,4408
11
+ openedx_progress/migrations/0001_initial.py,sha256=6SBE_zfG8Rnxv6Q0OHUE89y6sRZOZ_S9xnPY-Yjv67g,4771
12
+ openedx_progress/migrations/__init__.py,sha256=l4oScNBg8EJlkEVn2oMJiUkiC0z8K43YEWFcB7-JBaU,50
13
+ openedx_progress/settings/__init__.py,sha256=KsucKRjkLY6NAbek3A3ey3gXyWPn6aHxcgTIjyNUJJg,47
14
+ openedx_progress/settings/common.py,sha256=YcQ9b8XLVCieACBYQg0XZ6-4LPTODc5pif-V50WTwEw,335
15
+ openedx_progress/templates/openedx_progress/base.html,sha256=NHmMV45xJTnPEKQltuhe5Ddw9MDZLIQD_rK8GRXhqrU,873
16
+ openedx_progress-1.0.0.dist-info/licenses/LICENSE.txt,sha256=GDpsPnW_1NKhPvZpZL9imz25P2nIpbwJPEhrlq4vPAU,34523
17
+ openedx_progress-1.0.0.dist-info/METADATA,sha256=UVltlpbaHuo4fajmEdzLBY7meN9nEAVEmUPSl38tvyE,7528
18
+ openedx_progress-1.0.0.dist-info/WHEEL,sha256=4YBfCYNH4wlLpv3pzq1hbEuIlXA4WJabKLFurZ7eTL0,109
19
+ openedx_progress-1.0.0.dist-info/entry_points.txt,sha256=EjfJAZ1LtaBeP7xZ4quMgtVXvcv9nAVDnI4qHxZlWgk,159
20
+ openedx_progress-1.0.0.dist-info/top_level.txt,sha256=SfNQVAzyS14tJL4RIpo9uv4L3d6e5fLge_xUK2PRisg,17
21
+ openedx_progress-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,6 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py2-none-any
5
+ Tag: py3-none-any
6
+
@@ -0,0 +1,5 @@
1
+ [cms.djangoapp]
2
+ openedx_progress = openedx_progress.apps:OpenedxProgressConfig
3
+
4
+ [lms.djangoapp]
5
+ openedx_progress = openedx_progress.apps:OpenedxProgressConfig