learning-credentials 0.2.2__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.
- learning_credentials/__init__.py +1 -0
- learning_credentials/admin.py +265 -0
- learning_credentials/apps.py +24 -0
- learning_credentials/compat.py +119 -0
- learning_credentials/conf/locale/config.yaml +85 -0
- learning_credentials/exceptions.py +9 -0
- learning_credentials/generators.py +225 -0
- learning_credentials/migrations/0001_initial.py +205 -0
- learning_credentials/migrations/0002_migrate_to_learning_credentials.py +40 -0
- learning_credentials/migrations/0003_rename_certificates_to_credentials.py +128 -0
- learning_credentials/migrations/0004_replace_course_keys_with_learning_context_keys.py +59 -0
- learning_credentials/migrations/0005_rename_processors_and_generators.py +106 -0
- learning_credentials/migrations/0006_cleanup_openedx_certificates_tables.py +21 -0
- learning_credentials/migrations/__init__.py +0 -0
- learning_credentials/models.py +381 -0
- learning_credentials/processors.py +378 -0
- learning_credentials/settings/__init__.py +1 -0
- learning_credentials/settings/common.py +9 -0
- learning_credentials/settings/production.py +13 -0
- learning_credentials/tasks.py +53 -0
- learning_credentials/templates/learning_credentials/base.html +22 -0
- learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.html +22 -0
- learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.txt +13 -0
- learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/from_name.txt +1 -0
- learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/head.html +0 -0
- learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/subject.txt +4 -0
- learning_credentials/urls.py +9 -0
- learning_credentials/views.py +1 -0
- learning_credentials-0.2.2.dist-info/METADATA +212 -0
- learning_credentials-0.2.2.dist-info/RECORD +34 -0
- learning_credentials-0.2.2.dist-info/WHEEL +5 -0
- learning_credentials-0.2.2.dist-info/entry_points.txt +2 -0
- learning_credentials-0.2.2.dist-info/licenses/LICENSE.txt +664 -0
- learning_credentials-0.2.2.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."""
|