learning-credentials 0.2.0rc1__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.
- learning_credentials/__init__.py +3 -0
- learning_credentials/admin.py +263 -0
- learning_credentials/apps.py +24 -0
- learning_credentials/compat.py +117 -0
- learning_credentials/exceptions.py +9 -0
- learning_credentials/generators.py +224 -0
- learning_credentials/migrations/0001_initial.py +205 -0
- learning_credentials/migrations/0002_migrate_to_learning_credentials.py +32 -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/__init__.py +0 -0
- learning_credentials/models.py +381 -0
- learning_credentials/processors.py +306 -0
- learning_credentials/settings/__init__.py +1 -0
- learning_credentials/settings/common.py +12 -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/head.html +0 -0
- learning_credentials/urls.py +9 -0
- learning_credentials/views.py +1 -0
- learning_credentials-0.2.0rc1.dist-info/METADATA +204 -0
- learning_credentials-0.2.0rc1.dist-info/RECORD +34 -0
- learning_credentials-0.2.0rc1.dist-info/WHEEL +6 -0
- learning_credentials-0.2.0rc1.dist-info/entry_points.txt +3 -0
- learning_credentials-0.2.0rc1.dist-info/licenses/LICENSE.txt +664 -0
- learning_credentials-0.2.0rc1.dist-info/top_level.txt +2 -0
- openedx_certificates/__init__.py +1 -0
- openedx_certificates/apps.py +11 -0
- openedx_certificates/migrations/0001_initial.py +206 -0
- openedx_certificates/migrations/__init__.py +0 -0
- openedx_certificates/models.py +38 -0
|
@@ -0,0 +1,263 @@
|
|
|
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
|
+
|
|
252
|
+
def get_form(self, request: HttpRequest, obj: Credential | None = None, **kwargs) -> forms.ModelForm:
|
|
253
|
+
"""Hide the download_url field."""
|
|
254
|
+
form = super().get_form(request, obj, **kwargs)
|
|
255
|
+
form.base_fields['download_url'].widget = forms.HiddenInput()
|
|
256
|
+
return form
|
|
257
|
+
|
|
258
|
+
# noinspection PyMethodMayBeStatic
|
|
259
|
+
def url(self, obj: Credential) -> str:
|
|
260
|
+
"""Display the download URL as a clickable link."""
|
|
261
|
+
if obj.download_url:
|
|
262
|
+
return format_html("<a href='{url}'>{url}</a>", url=obj.download_url)
|
|
263
|
+
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,117 @@
|
|
|
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
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
import pytz
|
|
15
|
+
from celery import Celery
|
|
16
|
+
from django.conf import settings
|
|
17
|
+
from learning_paths.models import LearningPath
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
20
|
+
from django.contrib.auth.models import User
|
|
21
|
+
from learning_paths.keys import LearningPathKey
|
|
22
|
+
from opaque_keys.edx.keys import CourseKey, LearningContextKey
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_celery_app() -> Celery:
|
|
26
|
+
"""Get Celery app to reuse configuration and queues."""
|
|
27
|
+
if getattr(settings, "TESTING", False):
|
|
28
|
+
# We can ignore this in the testing environment.
|
|
29
|
+
return Celery(task_always_eager=True)
|
|
30
|
+
|
|
31
|
+
# noinspection PyUnresolvedReferences,PyPackageRequirements
|
|
32
|
+
from lms import CELERY_APP
|
|
33
|
+
|
|
34
|
+
return CELERY_APP # pragma: no cover
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_default_storage_url() -> str:
|
|
38
|
+
"""Get the default storage URL from Open edX."""
|
|
39
|
+
return f"{settings.LMS_ROOT_URL}{settings.MEDIA_URL}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_course_grading_policy(course_id: CourseKey) -> dict:
|
|
43
|
+
"""Get the course grading policy from Open edX."""
|
|
44
|
+
# noinspection PyUnresolvedReferences,PyPackageRequirements
|
|
45
|
+
from xmodule.modulestore.django import modulestore
|
|
46
|
+
|
|
47
|
+
return modulestore().get_course(course_id).grading_policy["GRADER"]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _get_course_name(course_id: CourseKey) -> str:
|
|
51
|
+
"""Get the course name from Open edX."""
|
|
52
|
+
# noinspection PyUnresolvedReferences,PyPackageRequirements
|
|
53
|
+
from openedx.core.djangoapps.content.learning_sequences.api import get_course_outline
|
|
54
|
+
|
|
55
|
+
course_outline = get_course_outline(course_id)
|
|
56
|
+
return (course_outline and course_outline.title) or str(course_id)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _get_learning_path_name(learning_path_key: LearningPathKey) -> str:
|
|
60
|
+
"""Get the Learning Path name from the plugin."""
|
|
61
|
+
try:
|
|
62
|
+
return LearningPath.objects.get(key=learning_path_key).display_name
|
|
63
|
+
except LearningPath.DoesNotExist:
|
|
64
|
+
return str(learning_path_key)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def get_learning_context_name(learning_context_key: LearningContextKey) -> str:
|
|
68
|
+
"""Get the learning context (course or Learning Path) name."""
|
|
69
|
+
if learning_context_key.is_course:
|
|
70
|
+
return _get_course_name(learning_context_key)
|
|
71
|
+
return _get_learning_path_name(learning_context_key)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_course_enrollments(course_id: CourseKey) -> list[User]:
|
|
75
|
+
"""Get the course enrollments from Open edX."""
|
|
76
|
+
# noinspection PyUnresolvedReferences,PyPackageRequirements
|
|
77
|
+
from common.djangoapps.student.models import CourseEnrollment
|
|
78
|
+
|
|
79
|
+
enrollments = CourseEnrollment.objects.filter(course_id=course_id, is_active=True).select_related('user')
|
|
80
|
+
return [enrollment.user for enrollment in enrollments]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@contextmanager
|
|
84
|
+
def prefetch_course_grades(course_id: CourseKey, users: list[User]):
|
|
85
|
+
"""
|
|
86
|
+
Prefetch the course grades from Open edX.
|
|
87
|
+
|
|
88
|
+
This optimizes retrieving grades for multiple users.
|
|
89
|
+
"""
|
|
90
|
+
# noinspection PyUnresolvedReferences,PyPackageRequirements
|
|
91
|
+
from lms.djangoapps.grades.api import clear_prefetched_course_grades, prefetch_course_and_subsection_grades
|
|
92
|
+
|
|
93
|
+
prefetch_course_and_subsection_grades(course_id, users)
|
|
94
|
+
try:
|
|
95
|
+
yield
|
|
96
|
+
finally:
|
|
97
|
+
# This uses `clear_prefetched_course_grades` instead of `clear_prefetched_course_and_subsection_grades` because
|
|
98
|
+
# these function names were accidentally swapped in the Open edX codebase.
|
|
99
|
+
# Ref: https://github.com/openedx/edx-platform/blob/1fe67d3f6b40233791d4599bae28df8c0ac91c4d/lms/djangoapps/grades/models_api.py#L30-L36
|
|
100
|
+
clear_prefetched_course_grades(course_id)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def get_course_grade(user: User, course_id: CourseKey): # noqa: ANN201
|
|
104
|
+
"""Get the `CourseGrade` instance from Open edX."""
|
|
105
|
+
# noinspection PyUnresolvedReferences,PyPackageRequirements
|
|
106
|
+
from lms.djangoapps.grades.api import CourseGradeFactory
|
|
107
|
+
|
|
108
|
+
return CourseGradeFactory().read(user, course_key=course_id)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def get_localized_credential_date() -> str:
|
|
112
|
+
"""Get the localized date from Open edX."""
|
|
113
|
+
# noinspection PyUnresolvedReferences,PyPackageRequirements
|
|
114
|
+
from common.djangoapps.util.date_utils import strftime_localized
|
|
115
|
+
|
|
116
|
+
date = datetime.now(pytz.timezone(settings.TIME_ZONE))
|
|
117
|
+
return strftime_localized(date, settings.CERTIFICATE_DATE_FORMAT)
|
|
@@ -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."""
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module provides functions to generate credentials.
|
|
3
|
+
|
|
4
|
+
The functions prefixed with `generate_` are automatically detected by the admin page and are used to generate the
|
|
5
|
+
credentials for the users.
|
|
6
|
+
|
|
7
|
+
We will move this module to an external repository (a plugin).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import io
|
|
13
|
+
import logging
|
|
14
|
+
import secrets
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
from django.conf import settings
|
|
18
|
+
from django.core.files.base import ContentFile
|
|
19
|
+
from django.core.files.storage import FileSystemStorage, default_storage
|
|
20
|
+
from pypdf import PdfReader, PdfWriter
|
|
21
|
+
from pypdf.constants import UserAccessPermissions
|
|
22
|
+
from reportlab.pdfbase import pdfmetrics
|
|
23
|
+
from reportlab.pdfbase.ttfonts import TTFont
|
|
24
|
+
from reportlab.pdfgen import canvas
|
|
25
|
+
|
|
26
|
+
from .compat import get_default_storage_url, get_learning_context_name, get_localized_credential_date
|
|
27
|
+
from .models import CredentialAsset
|
|
28
|
+
|
|
29
|
+
log = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
32
|
+
from uuid import UUID
|
|
33
|
+
|
|
34
|
+
from django.contrib.auth.models import User
|
|
35
|
+
from opaque_keys.edx.keys import CourseKey
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _get_user_name(user: User) -> str:
|
|
39
|
+
"""
|
|
40
|
+
Retrieve the user's name.
|
|
41
|
+
|
|
42
|
+
:param user: The user to generate the credential for.
|
|
43
|
+
:return: Username.
|
|
44
|
+
"""
|
|
45
|
+
return user.profile.name or f"{user.first_name} {user.last_name}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _register_font(options: dict[str, Any]) -> str:
|
|
49
|
+
"""
|
|
50
|
+
Register a custom font if specified in options. If not specified, use the default font (Helvetica).
|
|
51
|
+
|
|
52
|
+
:param options: A dictionary containing the font.
|
|
53
|
+
:returns: The font name.
|
|
54
|
+
"""
|
|
55
|
+
if font := options.get('font'):
|
|
56
|
+
pdfmetrics.registerFont(TTFont(font, CredentialAsset.get_asset_by_slug(font)))
|
|
57
|
+
|
|
58
|
+
return font or 'Helvetica'
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _write_text_on_template(template: any, font: str, username: str, context_name: str, options: dict[str, Any]) -> any:
|
|
62
|
+
"""
|
|
63
|
+
Prepare a new canvas and write the user and course name onto it.
|
|
64
|
+
|
|
65
|
+
:param template: Pdf template.
|
|
66
|
+
:param font: Font name.
|
|
67
|
+
:param username: The name of the user to generate the credential for.
|
|
68
|
+
:param context_name: The name of the learning context.
|
|
69
|
+
:param options: A dictionary documented in the `generate_pdf_credential` function.
|
|
70
|
+
:returns: A canvas with written data.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def hex_to_rgb(hex_color: str) -> tuple[float, float, float]:
|
|
74
|
+
"""
|
|
75
|
+
Convert a hexadecimal color code to an RGB tuple with floating-point values.
|
|
76
|
+
|
|
77
|
+
:param hex_color: A hexadecimal color string, which can start with '#' and be either 3 or 6 characters long.
|
|
78
|
+
:returns: A tuple representing the RGB color as (red, green, blue), with each value ranging from 0.0 to 1.0.
|
|
79
|
+
"""
|
|
80
|
+
hex_color = hex_color.lstrip('#')
|
|
81
|
+
# Expand shorthand form (e.g. "158" to "115588")
|
|
82
|
+
if len(hex_color) == 3:
|
|
83
|
+
hex_color = ''.join([c * 2 for c in hex_color])
|
|
84
|
+
|
|
85
|
+
# noinspection PyTypeChecker
|
|
86
|
+
return tuple(int(hex_color[i : i + 2], 16) / 255 for i in range(0, 6, 2))
|
|
87
|
+
|
|
88
|
+
template_width, template_height = template.mediabox[2:]
|
|
89
|
+
pdf_canvas = canvas.Canvas(io.BytesIO(), pagesize=(template_width, template_height))
|
|
90
|
+
|
|
91
|
+
# Write the learner name.
|
|
92
|
+
pdf_canvas.setFont(font, 32)
|
|
93
|
+
name_color = options.get('name_color', '#000')
|
|
94
|
+
pdf_canvas.setFillColorRGB(*hex_to_rgb(name_color))
|
|
95
|
+
|
|
96
|
+
name_x = (template_width - pdf_canvas.stringWidth(username)) / 2
|
|
97
|
+
name_y = options.get('name_y', 290)
|
|
98
|
+
pdf_canvas.drawString(name_x, name_y, username)
|
|
99
|
+
|
|
100
|
+
# Write the learning context name.
|
|
101
|
+
pdf_canvas.setFont(font, 28)
|
|
102
|
+
context_name_color = options.get('context_name_color', '#000')
|
|
103
|
+
pdf_canvas.setFillColorRGB(*hex_to_rgb(context_name_color))
|
|
104
|
+
|
|
105
|
+
context_name_y = options.get('context_name_y', 220)
|
|
106
|
+
context_name_line_height = 28 * 1.1
|
|
107
|
+
|
|
108
|
+
# Split the learning context name into lines and write each of them in the center of the template.
|
|
109
|
+
for line_number, line in enumerate(context_name.split('\n')):
|
|
110
|
+
line_x = (template_width - pdf_canvas.stringWidth(line)) / 2
|
|
111
|
+
line_y = context_name_y - (line_number * context_name_line_height)
|
|
112
|
+
pdf_canvas.drawString(line_x, line_y, line)
|
|
113
|
+
|
|
114
|
+
# Write the issue date.
|
|
115
|
+
issue_date = get_localized_credential_date()
|
|
116
|
+
pdf_canvas.setFont(font, 12)
|
|
117
|
+
issue_date_color = options.get('issue_date_color', '#000')
|
|
118
|
+
pdf_canvas.setFillColorRGB(*hex_to_rgb(issue_date_color))
|
|
119
|
+
|
|
120
|
+
issue_date_x = (template_width - pdf_canvas.stringWidth(issue_date)) / 2
|
|
121
|
+
issue_date_y = options.get('issue_date_y', 120)
|
|
122
|
+
pdf_canvas.drawString(issue_date_x, issue_date_y, issue_date)
|
|
123
|
+
|
|
124
|
+
return pdf_canvas
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _save_credential(credential: PdfWriter, credential_uuid: UUID) -> str:
|
|
128
|
+
"""
|
|
129
|
+
Save the final PDF file to BytesIO and upload it using Django default storage.
|
|
130
|
+
|
|
131
|
+
:param credential: Pdf credential.
|
|
132
|
+
:param credential_uuid: The UUID of the credential.
|
|
133
|
+
:returns: The URL of the saved credential.
|
|
134
|
+
"""
|
|
135
|
+
# Save the final PDF file to BytesIO.
|
|
136
|
+
output_path = f'external_certificates/{credential_uuid}.pdf'
|
|
137
|
+
|
|
138
|
+
view_print_extract_permission = (
|
|
139
|
+
UserAccessPermissions.PRINT
|
|
140
|
+
| UserAccessPermissions.PRINT_TO_REPRESENTATION
|
|
141
|
+
| UserAccessPermissions.EXTRACT_TEXT_AND_GRAPHICS
|
|
142
|
+
)
|
|
143
|
+
credential.encrypt('', secrets.token_hex(32), permissions_flag=view_print_extract_permission, algorithm='AES-256')
|
|
144
|
+
|
|
145
|
+
pdf_bytes = io.BytesIO()
|
|
146
|
+
credential.write(pdf_bytes)
|
|
147
|
+
pdf_bytes.seek(0) # Rewind to start.
|
|
148
|
+
# Upload with Django default storage.
|
|
149
|
+
credential_file = ContentFile(pdf_bytes.read())
|
|
150
|
+
# Delete the file if it already exists.
|
|
151
|
+
if default_storage.exists(output_path):
|
|
152
|
+
default_storage.delete(output_path)
|
|
153
|
+
default_storage.save(output_path, credential_file)
|
|
154
|
+
if isinstance(default_storage, FileSystemStorage):
|
|
155
|
+
url = f"{get_default_storage_url()}{output_path}"
|
|
156
|
+
else:
|
|
157
|
+
url = default_storage.url(output_path)
|
|
158
|
+
|
|
159
|
+
if custom_domain := getattr(settings, 'LEARNING_CREDENTIALS_CUSTOM_DOMAIN', None):
|
|
160
|
+
url = f"{custom_domain}/{credential_uuid}.pdf"
|
|
161
|
+
|
|
162
|
+
return url
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def generate_pdf_credential(
|
|
166
|
+
learning_context_key: CourseKey,
|
|
167
|
+
user: User,
|
|
168
|
+
credential_uuid: UUID,
|
|
169
|
+
options: dict[str, Any],
|
|
170
|
+
) -> str:
|
|
171
|
+
"""
|
|
172
|
+
Generate a PDF credential.
|
|
173
|
+
|
|
174
|
+
:param learning_context_key: The ID of the course or learning path the credential is for.
|
|
175
|
+
:param user: The user to generate the credential for.
|
|
176
|
+
:param credential_uuid: The UUID of the credential to generate.
|
|
177
|
+
:param options: The custom options for the credential.
|
|
178
|
+
:returns: The URL of the saved credential.
|
|
179
|
+
|
|
180
|
+
Options:
|
|
181
|
+
- template: The path to the PDF template file.
|
|
182
|
+
- template_two_lines: The path to the PDF template file for two-line context names.
|
|
183
|
+
A two-line context name is specified by using a semicolon as a separator.
|
|
184
|
+
- font: The name of the font to use.
|
|
185
|
+
- name_y: The Y coordinate of the name on the credential (vertical position on the template).
|
|
186
|
+
- name_color: The color of the name on the credential (hexadecimal color code).
|
|
187
|
+
- context_name: Specify the custom course or Learning Path name.
|
|
188
|
+
- context_name_y: The Y coordinate of the context name on the credential (vertical position on the template).
|
|
189
|
+
- context_name_color: The color of the context name on the credential (hexadecimal color code).
|
|
190
|
+
- issue_date_y: The Y coordinate of the issue date on the credential (vertical position on the template).
|
|
191
|
+
- issue_date_color: The color of the issue date on the credential (hexadecimal color code).
|
|
192
|
+
"""
|
|
193
|
+
log.info("Starting credential generation for user %s", user.id)
|
|
194
|
+
|
|
195
|
+
username = _get_user_name(user)
|
|
196
|
+
context_name = options.get('context_name') or get_learning_context_name(learning_context_key)
|
|
197
|
+
|
|
198
|
+
# Get template from the CredentialAsset.
|
|
199
|
+
# HACK: We support two-line strings by using a semicolon as a separator.
|
|
200
|
+
if ';' in context_name and (template_path := options.get('template_two_lines')):
|
|
201
|
+
template_file = CredentialAsset.get_asset_by_slug(template_path)
|
|
202
|
+
context_name = context_name.replace(';', '\n')
|
|
203
|
+
else:
|
|
204
|
+
template_file = CredentialAsset.get_asset_by_slug(options['template'])
|
|
205
|
+
|
|
206
|
+
font = _register_font(options)
|
|
207
|
+
|
|
208
|
+
# Load the PDF template.
|
|
209
|
+
with template_file.open('rb') as template_file:
|
|
210
|
+
template = PdfReader(template_file).pages[0]
|
|
211
|
+
|
|
212
|
+
credential = PdfWriter()
|
|
213
|
+
|
|
214
|
+
# Create a new canvas, prepare the page and write the data
|
|
215
|
+
pdf_canvas = _write_text_on_template(template, font, username, context_name, options)
|
|
216
|
+
|
|
217
|
+
overlay_pdf = PdfReader(io.BytesIO(pdf_canvas.getpdfdata()))
|
|
218
|
+
template.merge_page(overlay_pdf.pages[0])
|
|
219
|
+
credential.add_page(template)
|
|
220
|
+
|
|
221
|
+
url = _save_credential(credential, credential_uuid)
|
|
222
|
+
|
|
223
|
+
log.info("Credential saved to %s", url)
|
|
224
|
+
return url
|