learning-credentials 0.3.0rc10__tar.gz → 0.3.1rc2__tar.gz
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-0.3.0rc10 → learning_credentials-0.3.1rc2}/CHANGELOG.rst +18 -0
- {learning_credentials-0.3.0rc10/learning_credentials.egg-info → learning_credentials-0.3.1rc2}/PKG-INFO +19 -1
- learning_credentials-0.3.1rc2/learning_credentials/api/urls.py +9 -0
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/api/v1/urls.py +1 -1
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/apps.py +2 -2
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/compat.py +6 -3
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/generators.py +69 -30
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/models.py +1 -1
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/urls.py +3 -1
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2/learning_credentials.egg-info}/PKG-INFO +19 -1
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/pyproject.toml +4 -4
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/tests/test_generators.py +137 -24
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/tests/test_processors.py +1 -1
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/tests/test_views.py +2 -2
- learning_credentials-0.3.0rc10/learning_credentials/api/urls.py +0 -12
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/LICENSE.txt +0 -0
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/MANIFEST.in +0 -0
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/README.rst +0 -0
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/__init__.py +0 -0
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/admin.py +0 -0
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/api/__init__.py +0 -0
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/api/v1/__init__.py +0 -0
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/api/v1/permissions.py +0 -0
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/api/v1/views.py +0 -0
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/conf/locale/config.yaml +0 -0
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/exceptions.py +0 -0
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/migrations/0001_initial.py +0 -0
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/migrations/0002_migrate_to_learning_credentials.py +0 -0
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/migrations/0003_rename_certificates_to_credentials.py +0 -0
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/migrations/0004_replace_course_keys_with_learning_context_keys.py +0 -0
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/migrations/0005_rename_processors_and_generators.py +0 -0
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/migrations/0006_cleanup_openedx_certificates_tables.py +0 -0
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/migrations/__init__.py +0 -0
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/processors.py +0 -0
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/settings/__init__.py +0 -0
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/settings/common.py +0 -0
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/settings/production.py +0 -0
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/tasks.py +0 -0
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/templates/learning_credentials/base.html +0 -0
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.html +0 -0
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.txt +0 -0
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/from_name.txt +0 -0
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/head.html +0 -0
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/subject.txt +0 -0
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials.egg-info/SOURCES.txt +0 -0
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials.egg-info/dependency_links.txt +0 -0
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials.egg-info/entry_points.txt +0 -0
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials.egg-info/requires.txt +0 -0
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials.egg-info/top_level.txt +0 -0
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/setup.cfg +0 -0
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/tests/test_models.py +0 -0
- {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/tests/test_tasks.py +0 -0
|
@@ -16,6 +16,24 @@ Unreleased
|
|
|
16
16
|
|
|
17
17
|
*
|
|
18
18
|
|
|
19
|
+
0.3.1 - 2025-12-15
|
|
20
|
+
******************
|
|
21
|
+
|
|
22
|
+
Added
|
|
23
|
+
=====
|
|
24
|
+
|
|
25
|
+
* Support for defining the course name using the ``cert_name_long`` field (in Studio's Advanced Settings).
|
|
26
|
+
* Support for specifying individual fonts for PDF text elements.
|
|
27
|
+
* Support for \n in learning context names in PDF certificates.
|
|
28
|
+
* Options for uppercase name and issue date in PDF certificates.
|
|
29
|
+
* Option for defining character spacing for issue date in PDF certificates.
|
|
30
|
+
* Option for defining the horizontal offset of the issue date from its centered position (``issue_date_x``).
|
|
31
|
+
|
|
32
|
+
Modified
|
|
33
|
+
========
|
|
34
|
+
|
|
35
|
+
* Replaced ``template_two_lines`` with ``template_multiline``.
|
|
36
|
+
|
|
19
37
|
0.3.0 - 2025-09-17
|
|
20
38
|
******************
|
|
21
39
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: learning-credentials
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1rc2
|
|
4
4
|
Summary: A pluggable service for preparing Open edX credentials.
|
|
5
5
|
Author-email: OpenCraft <help@opencraft.com>
|
|
6
6
|
License-Expression: AGPL-3.0-or-later
|
|
@@ -176,6 +176,24 @@ Unreleased
|
|
|
176
176
|
|
|
177
177
|
*
|
|
178
178
|
|
|
179
|
+
0.3.1 - 2025-12-15
|
|
180
|
+
******************
|
|
181
|
+
|
|
182
|
+
Added
|
|
183
|
+
=====
|
|
184
|
+
|
|
185
|
+
* Support for defining the course name using the ``cert_name_long`` field (in Studio's Advanced Settings).
|
|
186
|
+
* Support for specifying individual fonts for PDF text elements.
|
|
187
|
+
* Support for \n in learning context names in PDF certificates.
|
|
188
|
+
* Options for uppercase name and issue date in PDF certificates.
|
|
189
|
+
* Option for defining character spacing for issue date in PDF certificates.
|
|
190
|
+
* Option for defining the horizontal offset of the issue date from its centered position (``issue_date_x``).
|
|
191
|
+
|
|
192
|
+
Modified
|
|
193
|
+
========
|
|
194
|
+
|
|
195
|
+
* Replaced ``template_two_lines`` with ``template_multiline``.
|
|
196
|
+
|
|
179
197
|
0.3.0 - 2025-09-17
|
|
180
198
|
******************
|
|
181
199
|
|
{learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/apps.py
RENAMED
|
@@ -18,8 +18,8 @@ class LearningCredentialsConfig(AppConfig):
|
|
|
18
18
|
plugin_app: ClassVar[dict[str, dict[str, dict]]] = {
|
|
19
19
|
PluginURLs.CONFIG: {
|
|
20
20
|
'lms.djangoapp': {
|
|
21
|
-
PluginURLs.NAMESPACE:
|
|
22
|
-
PluginURLs.APP_NAME:
|
|
21
|
+
PluginURLs.NAMESPACE: name,
|
|
22
|
+
PluginURLs.APP_NAME: name,
|
|
23
23
|
}
|
|
24
24
|
},
|
|
25
25
|
PluginSettings.CONFIG: {
|
{learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/compat.py
RENAMED
|
@@ -52,10 +52,13 @@ def get_course_grading_policy(course_id: CourseKey) -> dict:
|
|
|
52
52
|
def _get_course_name(course_id: CourseKey) -> str:
|
|
53
53
|
"""Get the course name from Open edX."""
|
|
54
54
|
# noinspection PyUnresolvedReferences,PyPackageRequirements
|
|
55
|
-
from openedx.core.djangoapps.content.
|
|
55
|
+
from openedx.core.djangoapps.content.course_overviews.api import get_course_overview_or_none
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
name = str(course_id)
|
|
58
|
+
if course_overview := get_course_overview_or_none(course_id):
|
|
59
|
+
name = course_overview.cert_name_long or course_overview.display_name or name
|
|
60
|
+
|
|
61
|
+
return name
|
|
59
62
|
|
|
60
63
|
|
|
61
64
|
def _get_learning_path_name(learning_path_key: LearningPathKey) -> str:
|
{learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/generators.py
RENAMED
|
@@ -19,9 +19,9 @@ from django.core.files.base import ContentFile
|
|
|
19
19
|
from django.core.files.storage import FileSystemStorage, default_storage
|
|
20
20
|
from pypdf import PdfReader, PdfWriter
|
|
21
21
|
from pypdf.constants import UserAccessPermissions
|
|
22
|
-
from reportlab.pdfbase import
|
|
23
|
-
from reportlab.pdfbase.ttfonts import TTFont
|
|
24
|
-
from reportlab.pdfgen import
|
|
22
|
+
from reportlab.pdfbase.pdfmetrics import FontError, FontNotFoundError, registerFont
|
|
23
|
+
from reportlab.pdfbase.ttfonts import TTFError, TTFont
|
|
24
|
+
from reportlab.pdfgen.canvas import Canvas
|
|
25
25
|
|
|
26
26
|
from .compat import get_default_storage_url, get_learning_context_name, get_localized_credential_date
|
|
27
27
|
from .models import CredentialAsset
|
|
@@ -33,6 +33,7 @@ if TYPE_CHECKING: # pragma: no cover
|
|
|
33
33
|
|
|
34
34
|
from django.contrib.auth.models import User
|
|
35
35
|
from opaque_keys.edx.keys import CourseKey
|
|
36
|
+
from pypdf import PageObject
|
|
36
37
|
|
|
37
38
|
|
|
38
39
|
def _get_user_name(user: User) -> str:
|
|
@@ -45,25 +46,29 @@ def _get_user_name(user: User) -> str:
|
|
|
45
46
|
return user.profile.name or f"{user.first_name} {user.last_name}"
|
|
46
47
|
|
|
47
48
|
|
|
48
|
-
def _register_font(
|
|
49
|
+
def _register_font(font_name: str) -> str | None:
|
|
49
50
|
"""
|
|
50
51
|
Register a custom font if specified in options. If not specified, use the default font (Helvetica).
|
|
51
52
|
|
|
52
|
-
:param
|
|
53
|
-
:returns: The font name.
|
|
53
|
+
:param font_name: The name of the font to register.
|
|
54
|
+
:returns: The font name if registered successfully, otherwise None.
|
|
54
55
|
"""
|
|
55
|
-
if
|
|
56
|
-
|
|
56
|
+
if not font_name:
|
|
57
|
+
return None
|
|
57
58
|
|
|
58
|
-
|
|
59
|
+
try:
|
|
60
|
+
registerFont(TTFont(font_name, CredentialAsset.get_asset_by_slug(font_name)))
|
|
61
|
+
except (FontError, FontNotFoundError, TTFError):
|
|
62
|
+
log.exception("Error registering font %s", font_name)
|
|
63
|
+
else:
|
|
64
|
+
return font_name
|
|
59
65
|
|
|
60
66
|
|
|
61
|
-
def _write_text_on_template(template:
|
|
67
|
+
def _write_text_on_template(template: PageObject, username: str, context_name: str, options: dict[str, Any]) -> Canvas:
|
|
62
68
|
"""
|
|
63
69
|
Prepare a new canvas and write the user and course name onto it.
|
|
64
70
|
|
|
65
71
|
:param template: Pdf template.
|
|
66
|
-
:param font: Font name.
|
|
67
72
|
:param username: The name of the user to generate the credential for.
|
|
68
73
|
:param context_name: The name of the learning context.
|
|
69
74
|
:param options: A dictionary documented in the `generate_pdf_credential` function.
|
|
@@ -86,19 +91,26 @@ def _write_text_on_template(template: any, font: str, username: str, context_nam
|
|
|
86
91
|
return tuple(int(hex_color[i : i + 2], 16) / 255 for i in range(0, 6, 2))
|
|
87
92
|
|
|
88
93
|
template_width, template_height = template.mediabox[2:]
|
|
89
|
-
pdf_canvas =
|
|
94
|
+
pdf_canvas = Canvas(io.BytesIO(), pagesize=(template_width, template_height))
|
|
95
|
+
font = _register_font(options.get('font')) or 'Helvetica'
|
|
90
96
|
|
|
91
97
|
# Write the learner name.
|
|
92
|
-
|
|
98
|
+
if options.get('name_uppercase', getattr(settings, 'LEARNING_CREDENTIALS_NAME_UPPERCASE', False)):
|
|
99
|
+
username = username.upper()
|
|
100
|
+
|
|
101
|
+
name_font = _register_font(options.get('name_font')) or font
|
|
102
|
+
pdf_canvas.setFont(name_font, options.get('name_size', 32))
|
|
93
103
|
name_color = options.get('name_color', '#000')
|
|
94
104
|
pdf_canvas.setFillColorRGB(*hex_to_rgb(name_color))
|
|
95
105
|
|
|
96
106
|
name_x = (template_width - pdf_canvas.stringWidth(username)) / 2
|
|
97
107
|
name_y = options.get('name_y', 290)
|
|
108
|
+
|
|
98
109
|
pdf_canvas.drawString(name_x, name_y, username)
|
|
99
110
|
|
|
100
111
|
# Write the learning context name.
|
|
101
|
-
|
|
112
|
+
context_name_font = _register_font(options.get('context_name_font')) or font
|
|
113
|
+
pdf_canvas.setFont(context_name_font, options.get('context_name_size', 28))
|
|
102
114
|
context_name_color = options.get('context_name_color', '#000')
|
|
103
115
|
pdf_canvas.setFillColorRGB(*hex_to_rgb(context_name_color))
|
|
104
116
|
|
|
@@ -113,13 +125,23 @@ def _write_text_on_template(template: any, font: str, username: str, context_nam
|
|
|
113
125
|
|
|
114
126
|
# Write the issue date.
|
|
115
127
|
issue_date = get_localized_credential_date()
|
|
116
|
-
|
|
128
|
+
if options.get('issue_date_uppercase', getattr(settings, 'LEARNING_CREDENTIALS_ISSUE_DATE_UPPERCASE', False)):
|
|
129
|
+
issue_date = issue_date.upper()
|
|
130
|
+
|
|
131
|
+
issue_date_font = _register_font(options.get('issue_date_font')) or font
|
|
132
|
+
pdf_canvas.setFont(issue_date_font, options.get('issue_date_size', 12))
|
|
117
133
|
issue_date_color = options.get('issue_date_color', '#000')
|
|
118
134
|
pdf_canvas.setFillColorRGB(*hex_to_rgb(issue_date_color))
|
|
119
135
|
|
|
120
136
|
issue_date_x = (template_width - pdf_canvas.stringWidth(issue_date)) / 2
|
|
137
|
+
issue_date_x += options.get('issue_date_x', 0)
|
|
121
138
|
issue_date_y = options.get('issue_date_y', 120)
|
|
122
|
-
|
|
139
|
+
|
|
140
|
+
issue_date_char_space = options.get(
|
|
141
|
+
'issue_date_char_space', getattr(settings, 'LEARNING_CREDENTIALS_ISSUE_DATE_CHAR_SPACE', 0)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
pdf_canvas.drawString(issue_date_x, issue_date_y, issue_date, charSpace=issue_date_char_space)
|
|
123
145
|
|
|
124
146
|
return pdf_canvas
|
|
125
147
|
|
|
@@ -168,7 +190,7 @@ def generate_pdf_credential(
|
|
|
168
190
|
credential_uuid: UUID,
|
|
169
191
|
options: dict[str, Any],
|
|
170
192
|
) -> str:
|
|
171
|
-
"""
|
|
193
|
+
r"""
|
|
172
194
|
Generate a PDF credential.
|
|
173
195
|
|
|
174
196
|
:param learning_context_key: The ID of the course or learning path the credential is for.
|
|
@@ -179,33 +201,50 @@ def generate_pdf_credential(
|
|
|
179
201
|
|
|
180
202
|
Options:
|
|
181
203
|
- template: The path to the PDF template file.
|
|
182
|
-
-
|
|
183
|
-
A
|
|
184
|
-
- font: The name of the font to use.
|
|
204
|
+
- template_multiline: The path to the PDF template file for multiline context names.
|
|
205
|
+
A multiline context name is specified by using '\n' or ';' as a separator.
|
|
206
|
+
- font: The name of the font to use. The default font is Helvetica.
|
|
185
207
|
- name_y: The Y coordinate of the name on the credential (vertical position on the template).
|
|
186
208
|
- name_color: The color of the name on the credential (hexadecimal color code).
|
|
187
209
|
- name_size: The font size of the name on the credential. The default value is 32.
|
|
188
|
-
-
|
|
210
|
+
- name_font: The font of the name on the credential. It overrides the `font` option.
|
|
211
|
+
- name_uppercase: If set to true (without quotes), the name will be converted to uppercase.
|
|
212
|
+
The default value is False, unless specified otherwise in the instance settings.
|
|
213
|
+
- context_name: Specify the custom course or Learning Path name. If not provided, it will be retrieved
|
|
214
|
+
automatically from the "cert_name_long" or "display_name" fields for courses, or from the Learning Path model.
|
|
189
215
|
- context_name_y: The Y coordinate of the context name on the credential (vertical position on the template).
|
|
190
216
|
- context_name_color: The color of the context name on the credential (hexadecimal color code).
|
|
191
217
|
- context_name_size: The font size of the context name on the credential. The default value is 28.
|
|
218
|
+
- context_name_font: The font of the context name on the credential. It overrides the `font` option.
|
|
219
|
+
- issue_date_x: The horizontal offset for the issue date from its centered position
|
|
220
|
+
(positive values move right, negative values move left; default is 0).
|
|
192
221
|
- issue_date_y: The Y coordinate of the issue date on the credential (vertical position on the template).
|
|
193
222
|
- issue_date_color: The color of the issue date on the credential (hexadecimal color code).
|
|
223
|
+
- issue_date_size: The font size of the issue date on the credential. The default value is 12.
|
|
224
|
+
- issue_date_font: The font of the issue date on the credential. It overrides the `font` option.
|
|
225
|
+
- issue_date_char_space: The character spacing of the issue date on the credential
|
|
226
|
+
(default is 0.0, unless specified otherwise in the instance settings).
|
|
227
|
+
- issue_date_uppercase: If set to true (without quotes), the issue date will be converted to uppercase.
|
|
228
|
+
The default value is False, unless specified otherwise in the instance settings.
|
|
194
229
|
"""
|
|
195
230
|
log.info("Starting credential generation for user %s", user.id)
|
|
196
231
|
|
|
197
232
|
username = _get_user_name(user)
|
|
198
233
|
context_name = options.get('context_name') or get_learning_context_name(learning_context_key)
|
|
234
|
+
template_path = options.get('template')
|
|
199
235
|
|
|
200
|
-
#
|
|
201
|
-
|
|
202
|
-
if '
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
236
|
+
# Handle multiline context name (we support semicolon as a separator to preserve backward compatibility).
|
|
237
|
+
context_name = context_name.replace(';', '\n').replace(r'\n', '\n')
|
|
238
|
+
if '\n' in context_name:
|
|
239
|
+
# `template_two_lines` is kept for backward compatibility.
|
|
240
|
+
template_path = options.get('template_multiline', options.get('template_two_lines', template_path))
|
|
241
|
+
|
|
242
|
+
if not template_path:
|
|
243
|
+
msg = "Template path must be specified in options."
|
|
244
|
+
raise ValueError(msg)
|
|
207
245
|
|
|
208
|
-
|
|
246
|
+
# Get template from the CredentialAsset.
|
|
247
|
+
template_file = CredentialAsset.get_asset_by_slug(template_path)
|
|
209
248
|
|
|
210
249
|
# Load the PDF template.
|
|
211
250
|
with template_file.open('rb') as template_file:
|
|
@@ -214,7 +253,7 @@ def generate_pdf_credential(
|
|
|
214
253
|
credential = PdfWriter()
|
|
215
254
|
|
|
216
255
|
# Create a new canvas, prepare the page and write the data
|
|
217
|
-
pdf_canvas = _write_text_on_template(template,
|
|
256
|
+
pdf_canvas = _write_text_on_template(template, username, context_name, options)
|
|
218
257
|
|
|
219
258
|
overlay_pdf = PdfReader(io.BytesIO(pdf_canvas.getpdfdata()))
|
|
220
259
|
template.merge_page(overlay_pdf.pages[0])
|
{learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/models.py
RENAMED
|
@@ -112,7 +112,7 @@ class CredentialConfiguration(TimeStampedModel):
|
|
|
112
112
|
task_path = f"{task.__wrapped__.__module__}.{task.__wrapped__.__name__}"
|
|
113
113
|
|
|
114
114
|
if self._state.adding:
|
|
115
|
-
schedule,
|
|
115
|
+
schedule, _created = IntervalSchedule.objects.get_or_create(every=10, period=IntervalSchedule.DAYS)
|
|
116
116
|
self.periodic_task = PeriodicTask.objects.create(
|
|
117
117
|
enabled=False,
|
|
118
118
|
interval=schedule,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: learning-credentials
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1rc2
|
|
4
4
|
Summary: A pluggable service for preparing Open edX credentials.
|
|
5
5
|
Author-email: OpenCraft <help@opencraft.com>
|
|
6
6
|
License-Expression: AGPL-3.0-or-later
|
|
@@ -176,6 +176,24 @@ Unreleased
|
|
|
176
176
|
|
|
177
177
|
*
|
|
178
178
|
|
|
179
|
+
0.3.1 - 2025-12-15
|
|
180
|
+
******************
|
|
181
|
+
|
|
182
|
+
Added
|
|
183
|
+
=====
|
|
184
|
+
|
|
185
|
+
* Support for defining the course name using the ``cert_name_long`` field (in Studio's Advanced Settings).
|
|
186
|
+
* Support for specifying individual fonts for PDF text elements.
|
|
187
|
+
* Support for \n in learning context names in PDF certificates.
|
|
188
|
+
* Options for uppercase name and issue date in PDF certificates.
|
|
189
|
+
* Option for defining character spacing for issue date in PDF certificates.
|
|
190
|
+
* Option for defining the horizontal offset of the issue date from its centered position (``issue_date_x``).
|
|
191
|
+
|
|
192
|
+
Modified
|
|
193
|
+
========
|
|
194
|
+
|
|
195
|
+
* Replaced ``template_two_lines`` with ``template_multiline``.
|
|
196
|
+
|
|
179
197
|
0.3.0 - 2025-09-17
|
|
180
198
|
******************
|
|
181
199
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "learning-credentials"
|
|
3
|
-
version = "0.3.
|
|
3
|
+
version = "0.3.1-rc2"
|
|
4
4
|
description = "A pluggable service for preparing Open edX credentials."
|
|
5
5
|
dynamic = ["readme"]
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -65,7 +65,7 @@ test = [
|
|
|
65
65
|
"factory-boy",
|
|
66
66
|
]
|
|
67
67
|
django42 = ["django>=4.2,<5.0"]
|
|
68
|
-
django52 = ["django>=5.2,<
|
|
68
|
+
django52 = ["django>=5.2,<6.0"]
|
|
69
69
|
ci = ["tox", "tox-uv"]
|
|
70
70
|
quality = ["ruff", "yamllint"]
|
|
71
71
|
doc = ["Sphinx", "doc8", "sphinx-book-theme", "twine"]
|
|
@@ -79,14 +79,13 @@ dev = [
|
|
|
79
79
|
"ty", # Type checker.
|
|
80
80
|
"django-types", # Type stubs for Django.
|
|
81
81
|
# External dev constraints (DO NOT REMOVE THIS LINE)
|
|
82
|
-
"Django<
|
|
82
|
+
"Django<6.0",
|
|
83
83
|
]
|
|
84
84
|
|
|
85
85
|
[tool.uv]
|
|
86
86
|
constraint-dependencies = [
|
|
87
87
|
# External constraints (DO NOT REMOVE THIS LINE)
|
|
88
88
|
"elasticsearch<7.14.0",
|
|
89
|
-
"pip<24.3",
|
|
90
89
|
]
|
|
91
90
|
conflicts = [
|
|
92
91
|
[
|
|
@@ -188,6 +187,7 @@ ignore = [
|
|
|
188
187
|
'RUF018', # assignment-in-assert
|
|
189
188
|
'ARG002', # unused-method-argument
|
|
190
189
|
'PLR0913', # too-many-arguments
|
|
190
|
+
'FBT001', # flake8-boolean-trap
|
|
191
191
|
]
|
|
192
192
|
|
|
193
193
|
[tool.ruff.lint.flake8-annotations]
|
|
@@ -17,6 +17,7 @@ from pypdf import PdfWriter
|
|
|
17
17
|
from pypdf.constants import UserAccessPermissions
|
|
18
18
|
|
|
19
19
|
from learning_credentials.generators import (
|
|
20
|
+
FontError,
|
|
20
21
|
_get_user_name,
|
|
21
22
|
_register_font,
|
|
22
23
|
_save_credential,
|
|
@@ -41,27 +42,40 @@ def test_get_user_name():
|
|
|
41
42
|
@patch("learning_credentials.generators.CredentialAsset.get_asset_by_slug")
|
|
42
43
|
def test_register_font_without_custom_font(mock_get_asset_by_slug: Mock):
|
|
43
44
|
"""Test the _register_font falls back to the default font when no custom font is specified."""
|
|
44
|
-
|
|
45
|
-
assert _register_font(options) == "Helvetica"
|
|
45
|
+
assert _register_font('') is None
|
|
46
46
|
mock_get_asset_by_slug.assert_not_called()
|
|
47
47
|
|
|
48
48
|
|
|
49
49
|
@patch("learning_credentials.generators.CredentialAsset.get_asset_by_slug")
|
|
50
50
|
@patch('learning_credentials.generators.TTFont')
|
|
51
|
-
@patch("learning_credentials.generators.
|
|
51
|
+
@patch("learning_credentials.generators.registerFont")
|
|
52
52
|
def test_register_font_with_custom_font(mock_register_font: Mock, mock_font_class: Mock, mock_get_asset_by_slug: Mock):
|
|
53
53
|
"""Test the _register_font registers the custom font when specified."""
|
|
54
54
|
custom_font = "MyFont"
|
|
55
|
-
options = {"font": custom_font}
|
|
56
|
-
|
|
57
55
|
mock_get_asset_by_slug.return_value = "font_path"
|
|
58
56
|
|
|
59
|
-
assert _register_font(
|
|
57
|
+
assert _register_font(custom_font) == custom_font
|
|
60
58
|
mock_get_asset_by_slug.assert_called_once_with(custom_font)
|
|
61
59
|
mock_font_class.assert_called_once_with(custom_font, mock_get_asset_by_slug.return_value)
|
|
62
60
|
mock_register_font.assert_called_once_with(mock_font_class.return_value)
|
|
63
61
|
|
|
64
62
|
|
|
63
|
+
@patch("learning_credentials.generators.CredentialAsset.get_asset_by_slug")
|
|
64
|
+
@patch('learning_credentials.generators.TTFont', side_effect=FontError("Font registration failed"))
|
|
65
|
+
@patch("learning_credentials.generators.registerFont")
|
|
66
|
+
def test_register_font_with_registration_failure(
|
|
67
|
+
mock_register_font: Mock, mock_font_class: Mock, mock_get_asset_by_slug: Mock
|
|
68
|
+
):
|
|
69
|
+
"""Test the _register_font returns None when font registration fails."""
|
|
70
|
+
custom_font = "MyFont"
|
|
71
|
+
mock_get_asset_by_slug.return_value = "font_path"
|
|
72
|
+
|
|
73
|
+
assert _register_font(custom_font) is None
|
|
74
|
+
mock_get_asset_by_slug.assert_called_once_with(custom_font)
|
|
75
|
+
mock_font_class.assert_called_once_with(custom_font, mock_get_asset_by_slug.return_value)
|
|
76
|
+
mock_register_font.assert_not_called()
|
|
77
|
+
|
|
78
|
+
|
|
65
79
|
@pytest.mark.parametrize(
|
|
66
80
|
("context_name", "options", "expected"),
|
|
67
81
|
[
|
|
@@ -71,6 +85,7 @@ def test_register_font_with_custom_font(mock_register_font: Mock, mock_font_clas
|
|
|
71
85
|
{
|
|
72
86
|
'name_y': 250,
|
|
73
87
|
'context_name_y': 200,
|
|
88
|
+
'issue_date_x': 100,
|
|
74
89
|
'issue_date_y': 150,
|
|
75
90
|
'name_color': '123',
|
|
76
91
|
'context_name_color': '#9B192A',
|
|
@@ -87,7 +102,7 @@ def test_register_font_with_custom_font(mock_register_font: Mock, mock_font_clas
|
|
|
87
102
|
('Programming\n101\nAdvanced Programming', {}, {}), # Multiline course name.
|
|
88
103
|
],
|
|
89
104
|
)
|
|
90
|
-
@patch('learning_credentials.generators.
|
|
105
|
+
@patch('learning_credentials.generators.Canvas', return_value=Mock(stringWidth=Mock(return_value=10)))
|
|
91
106
|
def test_write_text_on_template(mock_canvas_class: Mock, context_name: str, options: dict[str, int], expected: dict):
|
|
92
107
|
"""Test the _write_text_on_template function."""
|
|
93
108
|
username = 'John Doe'
|
|
@@ -106,7 +121,7 @@ def test_write_text_on_template(mock_canvas_class: Mock, context_name: str, opti
|
|
|
106
121
|
|
|
107
122
|
# Call the function with test parameters and mocks
|
|
108
123
|
with patch('learning_credentials.generators.get_localized_credential_date', return_value=test_date):
|
|
109
|
-
_write_text_on_template(template_mock,
|
|
124
|
+
_write_text_on_template(template_mock, username, context_name, options)
|
|
110
125
|
|
|
111
126
|
# Verifying that Canvas was the correct pagesize.
|
|
112
127
|
# Use `call_args_list` to ignore the first argument, which is an instance of io.BytesIO.
|
|
@@ -120,7 +135,7 @@ def test_write_text_on_template(mock_canvas_class: Mock, context_name: str, opti
|
|
|
120
135
|
expected_name_y = options.get('name_y', 290)
|
|
121
136
|
expected_context_name_x = (template_width - string_width) / 2
|
|
122
137
|
expected_context_name_y = options.get('context_name_y', 220)
|
|
123
|
-
expected_issue_date_x = (template_width - string_width) / 2
|
|
138
|
+
expected_issue_date_x = (template_width - string_width) / 2 + options.get('issue_date_x', 0)
|
|
124
139
|
expected_issue_date_y = options.get('issue_date_y', 120)
|
|
125
140
|
|
|
126
141
|
# Expected colors for setFillColorRGB method
|
|
@@ -155,6 +170,90 @@ def test_write_text_on_template(mock_canvas_class: Mock, context_name: str, opti
|
|
|
155
170
|
assert canvas_object.drawString.mock_calls[-1][1] == (expected_issue_date_x, expected_issue_date_y, test_date)
|
|
156
171
|
|
|
157
172
|
|
|
173
|
+
@pytest.mark.parametrize(
|
|
174
|
+
("option_value", "setting_value", "expected_uppercase"),
|
|
175
|
+
[
|
|
176
|
+
(None, False, False), # No option set, setting is False - use default (lowercase)
|
|
177
|
+
(None, True, True), # No option set, setting is True - use setting (uppercase)
|
|
178
|
+
(False, False, False), # Explicitly disabled, setting is False
|
|
179
|
+
(False, True, False), # Explicitly disabled, setting is True - option overrides
|
|
180
|
+
(True, False, True), # Explicitly enabled, setting is False - option overrides
|
|
181
|
+
(True, True, True), # Explicitly enabled, setting is True
|
|
182
|
+
],
|
|
183
|
+
)
|
|
184
|
+
@patch('learning_credentials.generators.get_localized_credential_date', return_value='April 1, 2021')
|
|
185
|
+
@patch('learning_credentials.generators.Canvas', return_value=Mock(stringWidth=Mock(return_value=10)))
|
|
186
|
+
def test_write_text_on_template_uppercase(
|
|
187
|
+
mock_canvas_class: Mock,
|
|
188
|
+
mock_get_date: Mock,
|
|
189
|
+
option_value: bool | None,
|
|
190
|
+
setting_value: bool,
|
|
191
|
+
expected_uppercase: bool,
|
|
192
|
+
):
|
|
193
|
+
"""Test the _write_text_on_template function with uppercase option and settings."""
|
|
194
|
+
username = "John Doe"
|
|
195
|
+
context_name = "Programming 101"
|
|
196
|
+
template_mock = Mock(mediabox=[0, 0, 300, 200])
|
|
197
|
+
options = {}
|
|
198
|
+
|
|
199
|
+
if expected_uppercase:
|
|
200
|
+
expected_name = "JOHN DOE"
|
|
201
|
+
expected_date = "APRIL 1, 2021"
|
|
202
|
+
else:
|
|
203
|
+
expected_name = username
|
|
204
|
+
expected_date = mock_get_date.return_value
|
|
205
|
+
|
|
206
|
+
if option_value is not None:
|
|
207
|
+
options['name_uppercase'] = option_value
|
|
208
|
+
options['issue_date_uppercase'] = option_value
|
|
209
|
+
|
|
210
|
+
with override_settings(
|
|
211
|
+
LEARNING_CREDENTIALS_NAME_UPPERCASE=setting_value,
|
|
212
|
+
LEARNING_CREDENTIALS_ISSUE_DATE_UPPERCASE=setting_value,
|
|
213
|
+
):
|
|
214
|
+
_write_text_on_template(template_mock, username, context_name, options)
|
|
215
|
+
|
|
216
|
+
assert mock_canvas_class.return_value.drawString.mock_calls[-3][1][2] == expected_name
|
|
217
|
+
assert mock_canvas_class.return_value.drawString.mock_calls[-1][1][2] == expected_date
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@pytest.mark.parametrize(
|
|
221
|
+
("option_value", "setting_value", "expected_char_space"),
|
|
222
|
+
[
|
|
223
|
+
(None, 0, 0), # No option set, setting is 0 - use default
|
|
224
|
+
(None, 2, 2), # No option set, setting has value - use setting
|
|
225
|
+
(0, 0, 0), # Explicitly set to 0, setting is 0
|
|
226
|
+
(0, 2, 0), # Explicitly set to 0, setting has value - option overrides
|
|
227
|
+
(3, 0, 3), # Explicitly set to 3, setting is 0 - option overrides
|
|
228
|
+
(3, 2, 3), # Explicitly set to 3, setting has value - option overrides
|
|
229
|
+
],
|
|
230
|
+
)
|
|
231
|
+
@patch('learning_credentials.generators.get_localized_credential_date', return_value='April 1, 2021')
|
|
232
|
+
@patch('learning_credentials.generators.Canvas', return_value=Mock(stringWidth=Mock(return_value=10)))
|
|
233
|
+
def test_write_text_on_template_issue_date_char_space(
|
|
234
|
+
mock_canvas_class: Mock,
|
|
235
|
+
_mock_get_date: Mock, # noqa: PT019
|
|
236
|
+
option_value: int | None,
|
|
237
|
+
setting_value: int,
|
|
238
|
+
expected_char_space: int,
|
|
239
|
+
):
|
|
240
|
+
"""Test the _write_text_on_template function with issue_date_char_space option and settings."""
|
|
241
|
+
username = "John Doe"
|
|
242
|
+
context_name = "Programming 101"
|
|
243
|
+
template_mock = Mock(mediabox=[0, 0, 300, 200])
|
|
244
|
+
options = {}
|
|
245
|
+
|
|
246
|
+
if option_value is not None:
|
|
247
|
+
options['issue_date_char_space'] = option_value
|
|
248
|
+
|
|
249
|
+
with override_settings(LEARNING_CREDENTIALS_ISSUE_DATE_CHAR_SPACE=setting_value):
|
|
250
|
+
_write_text_on_template(template_mock, username, context_name, options)
|
|
251
|
+
|
|
252
|
+
# Check that the last drawString call (for issue date) has the correct charSpace parameter
|
|
253
|
+
last_draw_call = mock_canvas_class.return_value.drawString.mock_calls[-1]
|
|
254
|
+
assert last_draw_call[2]['charSpace'] == expected_char_space
|
|
255
|
+
|
|
256
|
+
|
|
158
257
|
@override_settings(LMS_ROOT_URL="https://example.com", MEDIA_URL="media/")
|
|
159
258
|
@pytest.mark.parametrize(
|
|
160
259
|
"storage",
|
|
@@ -222,20 +321,22 @@ def test_save_credential(mock_contentfile: Mock, mock_token_hex: Mock, storage:
|
|
|
222
321
|
("context_name", "options", "expected_template_slug", "expected_context_name"),
|
|
223
322
|
[
|
|
224
323
|
# Default.
|
|
225
|
-
('Test Course', {'template': '
|
|
226
|
-
# Specify a different template for two-line course names and replace semicolon with newline in course name.
|
|
227
|
-
(
|
|
228
|
-
'Test Course; Test Course',
|
|
229
|
-
{'template': 'template_slug', 'template_two_lines': 'template_two_lines_slug'},
|
|
230
|
-
'template_two_lines_slug',
|
|
231
|
-
'Test Course\n Test Course',
|
|
232
|
-
),
|
|
233
|
-
# Do not replace semicolon with newline when the `template_two_lines` option is not specified.
|
|
234
|
-
('Test Course; Test Course', {'template': 'template_slug'}, 'template_slug', 'Test Course; Test Course'),
|
|
324
|
+
('Test Course', {'template': 'default'}, 'default', 'Test Course'),
|
|
235
325
|
# Override course name.
|
|
236
|
-
('Test Course', {'template': '
|
|
326
|
+
('Test Course', {'template': 'default', 'context_name': 'Override'}, 'default', 'Override'),
|
|
237
327
|
# Ignore empty course name override.
|
|
238
|
-
('Test Course', {'template': '
|
|
328
|
+
('Test Course', {'template': 'default', 'context_name': ''}, 'default', 'Test Course'),
|
|
329
|
+
# Specify a different template for multiline course names and replace \n with newline.
|
|
330
|
+
('Test\nCourse', {'template': 'default', 'template_multiline': 'multiline'}, 'multiline', 'Test\nCourse'),
|
|
331
|
+
('Test\\nCourse', {'template_multiline': 'multiline'}, 'multiline', 'Test\nCourse'),
|
|
332
|
+
# Backward compatibility with semicolon separator.
|
|
333
|
+
('Test;Course', {'template_multiline': 'multiline'}, 'multiline', 'Test\nCourse'),
|
|
334
|
+
# Mixed semicolon and newline separators.
|
|
335
|
+
('Te\nst\\nCourse;', {'template_multiline': 'multiline'}, 'multiline', 'Te\nst\nCourse\n'),
|
|
336
|
+
# Check backward compatibility with `template_two_lines` option.
|
|
337
|
+
('Test\\nCourse', {'template': 'default', 'template_two_lines': 'two_lines'}, 'two_lines', 'Test\nCourse'),
|
|
338
|
+
# Ensure that the default template is used when no multiline template is specified.
|
|
339
|
+
('Test\\nCourse', {'template': 'default'}, 'default', 'Test\nCourse'),
|
|
239
340
|
],
|
|
240
341
|
)
|
|
241
342
|
@patch(
|
|
@@ -251,7 +352,6 @@ def test_save_credential(mock_contentfile: Mock, mock_token_hex: Mock, storage:
|
|
|
251
352
|
)
|
|
252
353
|
@patch('learning_credentials.generators._get_user_name')
|
|
253
354
|
@patch('learning_credentials.generators.get_learning_context_name')
|
|
254
|
-
@patch('learning_credentials.generators._register_font')
|
|
255
355
|
@patch('learning_credentials.generators.PdfReader')
|
|
256
356
|
@patch('learning_credentials.generators.PdfWriter')
|
|
257
357
|
@patch(
|
|
@@ -264,7 +364,6 @@ def test_generate_pdf_credential(
|
|
|
264
364
|
mock_write_text_on_template: Mock,
|
|
265
365
|
mock_pdf_writer: Mock,
|
|
266
366
|
mock_pdf_reader: Mock,
|
|
267
|
-
mock_register_font: Mock,
|
|
268
367
|
mock_get_learning_context_name: Mock,
|
|
269
368
|
mock_get_user_name: Mock,
|
|
270
369
|
mock_get_asset_by_slug: Mock,
|
|
@@ -287,7 +386,6 @@ def test_generate_pdf_credential(
|
|
|
287
386
|
mock_get_learning_context_name.assert_not_called()
|
|
288
387
|
else:
|
|
289
388
|
mock_get_learning_context_name.assert_called_once_with(course_id)
|
|
290
|
-
mock_register_font.assert_called_once_with(options)
|
|
291
389
|
assert mock_pdf_reader.call_count == 2
|
|
292
390
|
mock_pdf_writer.assert_called_once_with()
|
|
293
391
|
|
|
@@ -297,3 +395,18 @@ def test_generate_pdf_credential(
|
|
|
297
395
|
assert args[-1] == options
|
|
298
396
|
|
|
299
397
|
mock_save_credential.assert_called_once()
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
@patch('learning_credentials.generators.get_learning_context_name')
|
|
401
|
+
@patch('learning_credentials.generators._get_user_name')
|
|
402
|
+
def test_generate_pdf_credential_no_template(mock_get_user_name: Mock, mock_get_learning_context_name: Mock):
|
|
403
|
+
"""Test that generate_pdf_credential raises ValueError when no template is specified."""
|
|
404
|
+
course_id = CourseKey.from_string('course-v1:edX+DemoX+Demo_Course')
|
|
405
|
+
user = Mock()
|
|
406
|
+
options = {} # No template specified.
|
|
407
|
+
|
|
408
|
+
with pytest.raises(ValueError, match=r"Template path must be specified in options."):
|
|
409
|
+
generate_pdf_credential(course_id, user, Mock(), options)
|
|
410
|
+
|
|
411
|
+
mock_get_user_name.assert_called_once_with(user)
|
|
412
|
+
mock_get_learning_context_name.assert_called_once_with(course_id)
|
|
@@ -137,7 +137,7 @@ def test_are_grades_passing_criteria(
|
|
|
137
137
|
user_grades: dict[str, float],
|
|
138
138
|
required_grades: dict[str, float],
|
|
139
139
|
category_weights: dict[str, float],
|
|
140
|
-
expected: bool,
|
|
140
|
+
expected: bool,
|
|
141
141
|
):
|
|
142
142
|
"""Test that the user grades are compared to the required grades correctly."""
|
|
143
143
|
assert _are_grades_passing_criteria(user_grades, required_grades, category_weights) == expected
|
|
@@ -120,7 +120,7 @@ class TestCredentialConfigurationCheckViewPermissions:
|
|
|
120
120
|
"""Helper to make GET request to the endpoint."""
|
|
121
121
|
client = _get_api_client(user)
|
|
122
122
|
url = reverse(
|
|
123
|
-
'learning_credentials_api_v1:
|
|
123
|
+
'learning_credentials_api_v1:credential_configuration_check',
|
|
124
124
|
kwargs={'learning_context_key': str(learning_context_key)},
|
|
125
125
|
)
|
|
126
126
|
return client.get(url)
|
|
@@ -241,7 +241,7 @@ class TestCredentialConfigurationCheckView:
|
|
|
241
241
|
"""Helper to make GET request to the endpoint."""
|
|
242
242
|
client = _get_api_client(user)
|
|
243
243
|
url = reverse(
|
|
244
|
-
'learning_credentials_api_v1:
|
|
244
|
+
'learning_credentials_api_v1:credential_configuration_check',
|
|
245
245
|
kwargs={'learning_context_key': str(learning_context_key)},
|
|
246
246
|
)
|
|
247
247
|
return client.get(url)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/__init__.py
RENAMED
|
File without changes
|
{learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/admin.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/exceptions.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/processors.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/tasks.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|