learning-credentials 0.2.2rc2__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.
Files changed (34) hide show
  1. learning_credentials/__init__.py +1 -0
  2. learning_credentials/admin.py +265 -0
  3. learning_credentials/apps.py +24 -0
  4. learning_credentials/compat.py +119 -0
  5. learning_credentials/conf/locale/config.yaml +85 -0
  6. learning_credentials/exceptions.py +9 -0
  7. learning_credentials/generators.py +225 -0
  8. learning_credentials/migrations/0001_initial.py +205 -0
  9. learning_credentials/migrations/0002_migrate_to_learning_credentials.py +40 -0
  10. learning_credentials/migrations/0003_rename_certificates_to_credentials.py +128 -0
  11. learning_credentials/migrations/0004_replace_course_keys_with_learning_context_keys.py +59 -0
  12. learning_credentials/migrations/0005_rename_processors_and_generators.py +106 -0
  13. learning_credentials/migrations/0006_cleanup_openedx_certificates_tables.py +21 -0
  14. learning_credentials/migrations/__init__.py +0 -0
  15. learning_credentials/models.py +381 -0
  16. learning_credentials/processors.py +378 -0
  17. learning_credentials/settings/__init__.py +1 -0
  18. learning_credentials/settings/common.py +9 -0
  19. learning_credentials/settings/production.py +13 -0
  20. learning_credentials/tasks.py +53 -0
  21. learning_credentials/templates/learning_credentials/base.html +22 -0
  22. learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.html +22 -0
  23. learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.txt +13 -0
  24. learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/from_name.txt +1 -0
  25. learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/head.html +0 -0
  26. learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/subject.txt +4 -0
  27. learning_credentials/urls.py +9 -0
  28. learning_credentials/views.py +1 -0
  29. learning_credentials-0.2.2rc2.dist-info/METADATA +212 -0
  30. learning_credentials-0.2.2rc2.dist-info/RECORD +34 -0
  31. learning_credentials-0.2.2rc2.dist-info/WHEEL +5 -0
  32. learning_credentials-0.2.2rc2.dist-info/entry_points.txt +2 -0
  33. learning_credentials-0.2.2rc2.dist-info/licenses/LICENSE.txt +664 -0
  34. learning_credentials-0.2.2rc2.dist-info/top_level.txt +1 -0
@@ -0,0 +1 @@
1
+ """A pluggable service for preparing Open edX credentials."""
@@ -0,0 +1,265 @@
1
+ """Admin page configuration for the learning-credentials app."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import inspect
7
+ from typing import TYPE_CHECKING
8
+
9
+ from django import forms
10
+ from django.contrib import admin
11
+ from django.core.exceptions import ValidationError
12
+ from django.utils.html import format_html
13
+ from django_object_actions import DjangoObjectActions, action
14
+ from django_reverse_admin import ReverseModelAdmin
15
+ from learning_paths.keys import LearningPathKey
16
+ from opaque_keys import InvalidKeyError
17
+ from opaque_keys.edx.keys import CourseKey
18
+
19
+ from .models import (
20
+ Credential,
21
+ CredentialAsset,
22
+ CredentialConfiguration,
23
+ CredentialType,
24
+ )
25
+ from .tasks import generate_credentials_for_config_task
26
+
27
+ if TYPE_CHECKING: # pragma: no cover
28
+ from collections.abc import Generator
29
+
30
+ from django.http import HttpRequest
31
+ from django_celery_beat.models import IntervalSchedule
32
+
33
+
34
+ class DocstringOptionsMixin:
35
+ """A mixin to add the docstring of the function to the help text of the function field."""
36
+
37
+ @staticmethod
38
+ def _get_docstring_custom_options(func: str) -> str:
39
+ """
40
+ Get the docstring of the function and return the "Options:" section.
41
+
42
+ :param func: The function to get the docstring for.
43
+ :returns: The "Options:" section of the docstring.
44
+ """
45
+ try:
46
+ docstring = (
47
+ 'Custom options:'
48
+ + inspect.getdoc(
49
+ getattr(
50
+ importlib.import_module(func.rsplit('.', 1)[0]),
51
+ func.rsplit('.', 1)[1],
52
+ ),
53
+ ).split("Options:")[1]
54
+ )
55
+ except IndexError:
56
+ docstring = (
57
+ 'Custom options are not documented for this function. If you selected a different function, '
58
+ 'you need to save your changes to see an updated docstring.'
59
+ )
60
+ # Use pre to preserve the newlines and indentation.
61
+ return f'<pre>{docstring}</pre>'
62
+
63
+
64
+ class CredentialTypeAdminForm(forms.ModelForm, DocstringOptionsMixin):
65
+ """Generate a list of available functions for the function fields."""
66
+
67
+ retrieval_func = forms.ChoiceField(choices=[])
68
+ generation_func = forms.ChoiceField(choices=[])
69
+
70
+ @staticmethod
71
+ def _available_functions(module: str, prefix: str) -> Generator[tuple[str, str], None, None]:
72
+ """
73
+ Import a module and return all functions in it that start with a specific prefix.
74
+
75
+ :param module: The name of the module to import.
76
+ :param prefix: The prefix of the function names to return.
77
+
78
+ :return: A tuple containing the functions that start with the prefix in the module.
79
+ """
80
+ # TODO: Implement plugin support for the functions.
81
+ _module = importlib.import_module(module)
82
+ return (
83
+ (f'{obj.__module__}.{name}', f'{obj.__module__}.{name}')
84
+ for name, obj in inspect.getmembers(_module, inspect.isfunction)
85
+ if name.startswith(prefix)
86
+ )
87
+
88
+ def __init__(self, *args, **kwargs):
89
+ """Initializes the choices for the retrieval and generation function selection fields."""
90
+ super().__init__(*args, **kwargs)
91
+ self.fields['retrieval_func'].choices = self._available_functions(
92
+ 'learning_credentials.processors',
93
+ 'retrieve_',
94
+ )
95
+ if self.instance.retrieval_func:
96
+ self.fields['retrieval_func'].help_text = self._get_docstring_custom_options(self.instance.retrieval_func)
97
+ self.fields['generation_func'].choices = self._available_functions(
98
+ 'learning_credentials.generators',
99
+ 'generate_',
100
+ )
101
+ if self.instance.generation_func:
102
+ self.fields['generation_func'].help_text = self._get_docstring_custom_options(self.instance.generation_func)
103
+
104
+ class Meta: # noqa: D106
105
+ model = CredentialType
106
+ fields = '__all__' # noqa: DJ007
107
+
108
+
109
+ @admin.register(CredentialType)
110
+ class CredentialTypeAdmin(admin.ModelAdmin): # noqa: D101
111
+ form = CredentialTypeAdminForm
112
+ list_display = ('name', 'retrieval_func', 'generation_func')
113
+
114
+
115
+ @admin.register(CredentialAsset)
116
+ class CredentialAssetAdmin(admin.ModelAdmin): # noqa: D101
117
+ list_display = ('description', 'asset_slug')
118
+ prepopulated_fields = {"asset_slug": ("description",)} # noqa: RUF012
119
+
120
+
121
+ class CredentialConfigurationForm(forms.ModelForm, DocstringOptionsMixin): # noqa: D101
122
+ class Meta: # noqa: D106
123
+ model = CredentialConfiguration
124
+ fields = ('learning_context_key', 'credential_type', 'custom_options')
125
+
126
+ def __init__(self, *args, **kwargs):
127
+ """Initializes the choices for the retrieval and generation function selection fields."""
128
+ super().__init__(*args, **kwargs)
129
+ options = ''
130
+
131
+ if self.instance and getattr(self.instance, 'credential_type', None):
132
+ if self.instance.credential_type.generation_func:
133
+ generation_options = self._get_docstring_custom_options(self.instance.credential_type.generation_func)
134
+ options += generation_options.replace('Custom options:', '\nGeneration options:')
135
+ if self.instance.credential_type.retrieval_func:
136
+ retrieval_options = self._get_docstring_custom_options(self.instance.credential_type.retrieval_func)
137
+ options += retrieval_options.replace('Custom options:', '\nRetrieval options:')
138
+
139
+ self.fields['custom_options'].help_text += options
140
+
141
+ def clean_learning_context_key(self) -> str:
142
+ """Validate the learning_context_key field to ensure it is a valid CourseKey or LearningPathKey."""
143
+ learning_context_key = self.cleaned_data.get('learning_context_key')
144
+ try:
145
+ try:
146
+ CourseKey.from_string(learning_context_key)
147
+ except InvalidKeyError:
148
+ LearningPathKey.from_string(learning_context_key)
149
+ except InvalidKeyError as exc:
150
+ msg = (
151
+ "Invalid key format. Must be either a valid course key ('course-v1:{org}+{course}+{run}') "
152
+ "or a valid Learning Path key ('path-v1:{org}+{number}+{run}+{group}')."
153
+ )
154
+ raise ValidationError(msg) from exc
155
+ return learning_context_key
156
+
157
+
158
+ @admin.register(CredentialConfiguration)
159
+ class CredentialConfigurationAdmin(DjangoObjectActions, ReverseModelAdmin):
160
+ """
161
+ Admin page for the context-specific credential configuration for each credential type.
162
+
163
+ It manages the associations between configuration and its corresponding periodic task.
164
+ The reverse inline provides a way to manage the periodic task from the configuration page.
165
+ """
166
+
167
+ form = CredentialConfigurationForm
168
+ inline_type = 'stacked'
169
+ inline_reverse = [ # noqa: RUF012
170
+ (
171
+ 'periodic_task',
172
+ {'fields': ['enabled', 'interval', 'crontab', 'clocked', 'start_time', 'expires', 'one_off']},
173
+ ),
174
+ ]
175
+ list_display = ('learning_context_key', 'credential_type', 'enabled', 'interval')
176
+ search_fields = ('learning_context_key', 'credential_type__name')
177
+ list_filter = ('learning_context_key', 'credential_type')
178
+
179
+ def get_inline_instances(
180
+ self,
181
+ request: HttpRequest,
182
+ obj: CredentialConfiguration = None,
183
+ ) -> list[admin.ModelAdmin]:
184
+ """
185
+ Hide inlines on the "Add" view in Django admin, and show them on the "Change" view.
186
+
187
+ It differentiates "add" and change "view" based on the requested path because the `obj` parameter can be `None`
188
+ in the "Change" view when rendering the inlines.
189
+
190
+ :param request: HttpRequest object
191
+ :param obj: The object being changed, None for add view
192
+ :return: A list of InlineModelAdmin instances to be rendered for add/changing an object
193
+ """
194
+ return super().get_inline_instances(request, obj) if '/add/' not in request.path else []
195
+
196
+ def enabled(self, obj: CredentialConfiguration) -> bool:
197
+ """Return the 'enabled' status of the periodic task."""
198
+ return obj.periodic_task.enabled
199
+
200
+ enabled.boolean = True
201
+
202
+ # noinspection PyMethodMayBeStatic
203
+ def interval(self, obj: CredentialConfiguration) -> IntervalSchedule:
204
+ """Return the interval of the credentialedential generation task."""
205
+ return obj.periodic_task.interval
206
+
207
+ def get_readonly_fields(self, _request: HttpRequest, obj: CredentialConfiguration = None) -> tuple:
208
+ """Make the learning_context_key field read-only."""
209
+ if obj: # editing an existing object
210
+ return *self.readonly_fields, 'learning_context_key', 'credential_type'
211
+ return self.readonly_fields
212
+
213
+ @action(label="Generate credentials")
214
+ def generate_credentials(self, _request: HttpRequest, obj: CredentialConfiguration):
215
+ """
216
+ Custom action to generate credential for the current CredentialConfiguration instance.
217
+
218
+ Args:
219
+ _request: The request object.
220
+ obj: The CredentialConfiguration instance.
221
+ """
222
+ generate_credentials_for_config_task.delay(obj.id)
223
+
224
+ change_actions = ('generate_credentials',)
225
+
226
+
227
+ @admin.register(Credential)
228
+ class CredentialAdmin(admin.ModelAdmin): # noqa: D101
229
+ list_display = (
230
+ 'user_id',
231
+ 'user_full_name',
232
+ 'learning_context_key',
233
+ 'credential_type',
234
+ 'status',
235
+ 'url',
236
+ 'created',
237
+ 'modified',
238
+ )
239
+ readonly_fields = (
240
+ 'user_id',
241
+ 'created',
242
+ 'modified',
243
+ 'user_full_name',
244
+ 'learning_context_key',
245
+ 'credential_type',
246
+ 'status',
247
+ 'url',
248
+ 'legacy_id',
249
+ 'generation_task_id',
250
+ )
251
+ search_fields = ("learning_context_key", "user_id", "user_full_name")
252
+ list_filter = ("learning_context_key", "credential_type", "status")
253
+
254
+ def get_form(self, request: HttpRequest, obj: Credential | None = None, **kwargs) -> forms.ModelForm:
255
+ """Hide the download_url field."""
256
+ form = super().get_form(request, obj, **kwargs)
257
+ form.base_fields['download_url'].widget = forms.HiddenInput()
258
+ return form
259
+
260
+ # noinspection PyMethodMayBeStatic
261
+ def url(self, obj: Credential) -> str:
262
+ """Display the download URL as a clickable link."""
263
+ if obj.download_url:
264
+ return format_html("<a href='{url}'>{url}</a>", url=obj.download_url)
265
+ return "-"
@@ -0,0 +1,24 @@
1
+ """learning_credentials Django application initialization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import ClassVar
6
+
7
+ from django.apps import AppConfig
8
+
9
+
10
+ class LearningCredentialsConfig(AppConfig):
11
+ """Configuration for the learning_credentials Django application."""
12
+
13
+ name = 'learning_credentials'
14
+ verbose_name = 'Learning Credentials'
15
+
16
+ # https://edx.readthedocs.io/projects/edx-django-utils/en/latest/plugins/how_tos/how_to_create_a_plugin_app.html
17
+ plugin_app: ClassVar[dict[str, dict[str, dict]]] = {
18
+ 'settings_config': {
19
+ 'lms.djangoapp': {
20
+ 'common': {'relative_path': 'settings.common'},
21
+ 'production': {'relative_path': 'settings.production'},
22
+ },
23
+ },
24
+ }
@@ -0,0 +1,119 @@
1
+ """
2
+ Proxies and compatibility code for edx-platform features.
3
+
4
+ This module moderates access to all edx-platform features allowing for cross-version compatibility code.
5
+ It also simplifies running tests outside edx-platform's environment by stubbing these functions in unit tests.
6
+ """
7
+
8
+ # ruff: noqa: PLC0415
9
+
10
+ from __future__ import annotations
11
+
12
+ from contextlib import contextmanager
13
+ from datetime import datetime
14
+ from typing import TYPE_CHECKING
15
+
16
+ import pytz
17
+ from celery import Celery
18
+ from django.conf import settings
19
+ from learning_paths.models import LearningPath
20
+
21
+ if TYPE_CHECKING: # pragma: no cover
22
+ from django.contrib.auth.models import User
23
+ from learning_paths.keys import LearningPathKey
24
+ from opaque_keys.edx.keys import CourseKey, LearningContextKey
25
+
26
+
27
+ def get_celery_app() -> Celery:
28
+ """Get Celery app to reuse configuration and queues."""
29
+ if getattr(settings, "TESTING", False):
30
+ # We can ignore this in the testing environment.
31
+ return Celery(task_always_eager=True)
32
+
33
+ # noinspection PyUnresolvedReferences,PyPackageRequirements
34
+ from lms import CELERY_APP
35
+
36
+ return CELERY_APP # pragma: no cover
37
+
38
+
39
+ def get_default_storage_url() -> str:
40
+ """Get the default storage URL from Open edX."""
41
+ return f"{settings.LMS_ROOT_URL}{settings.MEDIA_URL}"
42
+
43
+
44
+ def get_course_grading_policy(course_id: CourseKey) -> dict:
45
+ """Get the course grading policy from Open edX."""
46
+ # noinspection PyUnresolvedReferences,PyPackageRequirements
47
+ from xmodule.modulestore.django import modulestore
48
+
49
+ return modulestore().get_course(course_id).grading_policy["GRADER"]
50
+
51
+
52
+ def _get_course_name(course_id: CourseKey) -> str:
53
+ """Get the course name from Open edX."""
54
+ # noinspection PyUnresolvedReferences,PyPackageRequirements
55
+ from openedx.core.djangoapps.content.learning_sequences.api import get_course_outline
56
+
57
+ course_outline = get_course_outline(course_id)
58
+ return (course_outline and course_outline.title) or str(course_id)
59
+
60
+
61
+ def _get_learning_path_name(learning_path_key: LearningPathKey) -> str:
62
+ """Get the Learning Path name from the plugin."""
63
+ try:
64
+ return LearningPath.objects.get(key=learning_path_key).display_name
65
+ except LearningPath.DoesNotExist:
66
+ return str(learning_path_key)
67
+
68
+
69
+ def get_learning_context_name(learning_context_key: LearningContextKey) -> str:
70
+ """Get the learning context (course or Learning Path) name."""
71
+ if learning_context_key.is_course:
72
+ return _get_course_name(learning_context_key)
73
+ return _get_learning_path_name(learning_context_key)
74
+
75
+
76
+ def get_course_enrollments(course_id: CourseKey) -> list[User]:
77
+ """Get the course enrollments from Open edX."""
78
+ # noinspection PyUnresolvedReferences,PyPackageRequirements
79
+ from common.djangoapps.student.models import CourseEnrollment
80
+
81
+ enrollments = CourseEnrollment.objects.filter(course_id=course_id, is_active=True).select_related('user')
82
+ return [enrollment.user for enrollment in enrollments]
83
+
84
+
85
+ @contextmanager
86
+ def prefetch_course_grades(course_id: CourseKey, users: list[User]):
87
+ """
88
+ Prefetch the course grades from Open edX.
89
+
90
+ This optimizes retrieving grades for multiple users.
91
+ """
92
+ # noinspection PyUnresolvedReferences,PyPackageRequirements
93
+ from lms.djangoapps.grades.api import clear_prefetched_course_grades, prefetch_course_and_subsection_grades
94
+
95
+ prefetch_course_and_subsection_grades(course_id, users)
96
+ try:
97
+ yield
98
+ finally:
99
+ # This uses `clear_prefetched_course_grades` instead of `clear_prefetched_course_and_subsection_grades` because
100
+ # these function names were accidentally swapped in the Open edX codebase.
101
+ # Ref: https://github.com/openedx/edx-platform/blob/1fe67d3f6b40233791d4599bae28df8c0ac91c4d/lms/djangoapps/grades/models_api.py#L30-L36
102
+ clear_prefetched_course_grades(course_id)
103
+
104
+
105
+ def get_course_grade(user: User, course_id: CourseKey): # noqa: ANN201
106
+ """Get the `CourseGrade` instance from Open edX."""
107
+ # noinspection PyUnresolvedReferences,PyPackageRequirements
108
+ from lms.djangoapps.grades.api import CourseGradeFactory
109
+
110
+ return CourseGradeFactory().read(user, course_key=course_id)
111
+
112
+
113
+ def get_localized_credential_date() -> str:
114
+ """Get the localized date from Open edX."""
115
+ # noinspection PyUnresolvedReferences,PyPackageRequirements
116
+ from common.djangoapps.util.date_utils import strftime_localized
117
+
118
+ date = datetime.now(pytz.timezone(settings.TIME_ZONE))
119
+ return strftime_localized(date, settings.CERTIFICATE_DATE_FORMAT)
@@ -0,0 +1,85 @@
1
+ # Configuration for i18n workflow.
2
+
3
+ locales:
4
+ - en # English - Source Language
5
+ - am # Amharic
6
+ - ar # Arabic
7
+ - az # Azerbaijani
8
+ - bg_BG # Bulgarian (Bulgaria)
9
+ - bn_BD # Bengali (Bangladesh)
10
+ - bn_IN # Bengali (India)
11
+ - bs # Bosnian
12
+ - ca # Catalan
13
+ - ca@valencia # Catalan (Valencia)
14
+ - cs # Czech
15
+ - cy # Welsh
16
+ - da # Danish
17
+ - de_DE # German (Germany)
18
+ - el # Greek
19
+ - en # English
20
+ - en_GB # English (United Kingdom)
21
+ # Don't pull these until we figure out why pages randomly display in these locales,
22
+ # when the user's browser is in English and the user is not logged in.
23
+ # - en@lolcat # LOLCAT English
24
+ # - en@pirate # Pirate English
25
+ - es_419 # Spanish (Latin America)
26
+ - es_AR # Spanish (Argentina)
27
+ - es_EC # Spanish (Ecuador)
28
+ - es_ES # Spanish (Spain)
29
+ - es_MX # Spanish (Mexico)
30
+ - es_PE # Spanish (Peru)
31
+ - et_EE # Estonian (Estonia)
32
+ - eu_ES # Basque (Spain)
33
+ - fa # Persian
34
+ - fa_IR # Persian (Iran)
35
+ - fi_FI # Finnish (Finland)
36
+ - fil # Filipino
37
+ - fr # French
38
+ - gl # Galician
39
+ - gu # Gujarati
40
+ - he # Hebrew
41
+ - hi # Hindi
42
+ - hr # Croatian
43
+ - hu # Hungarian
44
+ - hy_AM # Armenian (Armenia)
45
+ - id # Indonesian
46
+ - it_IT # Italian (Italy)
47
+ - ja_JP # Japanese (Japan)
48
+ - kk_KZ # Kazakh (Kazakhstan)
49
+ - km_KH # Khmer (Cambodia)
50
+ - kn # Kannada
51
+ - ko_KR # Korean (Korea)
52
+ - lt_LT # Lithuanian (Lithuania)
53
+ - ml # Malayalam
54
+ - mn # Mongolian
55
+ - mr # Marathi
56
+ - ms # Malay
57
+ - nb # Norwegian Bokmål
58
+ - ne # Nepali
59
+ - nl_NL # Dutch (Netherlands)
60
+ - or # Oriya
61
+ - pl # Polish
62
+ - pt_BR # Portuguese (Brazil)
63
+ - pt_PT # Portuguese (Portugal)
64
+ - ro # Romanian
65
+ - ru # Russian
66
+ - si # Sinhala
67
+ - sk # Slovak
68
+ - sl # Slovenian
69
+ - sq # Albanian
70
+ - sr # Serbian
71
+ - ta # Tamil
72
+ - te # Telugu
73
+ - th # Thai
74
+ - tr_TR # Turkish (Turkey)
75
+ - uk # Ukranian
76
+ - ur # Urdu
77
+ - uz # Uzbek
78
+ - vi # Vietnamese
79
+ - zh_CN # Chinese (China)
80
+ - zh_HK # Chinese (Hong Kong)
81
+ - zh_TW # Chinese (Taiwan)
82
+
83
+ # The locales used for fake-accented English, for testing.
84
+ dummy_locales:
85
+ - eo
@@ -0,0 +1,9 @@
1
+ """Custom exceptions for the learning-credentials app."""
2
+
3
+
4
+ class AssetNotFoundError(Exception):
5
+ """Raised when the asset_slug is not found in the CredentialAsset model."""
6
+
7
+
8
+ class CredentialGenerationError(Exception):
9
+ """Raised when the credential generation Celery task fails."""