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.
Files changed (52) hide show
  1. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/CHANGELOG.rst +18 -0
  2. {learning_credentials-0.3.0rc10/learning_credentials.egg-info → learning_credentials-0.3.1rc2}/PKG-INFO +19 -1
  3. learning_credentials-0.3.1rc2/learning_credentials/api/urls.py +9 -0
  4. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/api/v1/urls.py +1 -1
  5. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/apps.py +2 -2
  6. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/compat.py +6 -3
  7. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/generators.py +69 -30
  8. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/models.py +1 -1
  9. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/urls.py +3 -1
  10. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2/learning_credentials.egg-info}/PKG-INFO +19 -1
  11. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/pyproject.toml +4 -4
  12. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/tests/test_generators.py +137 -24
  13. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/tests/test_processors.py +1 -1
  14. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/tests/test_views.py +2 -2
  15. learning_credentials-0.3.0rc10/learning_credentials/api/urls.py +0 -12
  16. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/LICENSE.txt +0 -0
  17. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/MANIFEST.in +0 -0
  18. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/README.rst +0 -0
  19. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/__init__.py +0 -0
  20. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/admin.py +0 -0
  21. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/api/__init__.py +0 -0
  22. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/api/v1/__init__.py +0 -0
  23. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/api/v1/permissions.py +0 -0
  24. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/api/v1/views.py +0 -0
  25. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/conf/locale/config.yaml +0 -0
  26. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/exceptions.py +0 -0
  27. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/migrations/0001_initial.py +0 -0
  28. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/migrations/0002_migrate_to_learning_credentials.py +0 -0
  29. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/migrations/0003_rename_certificates_to_credentials.py +0 -0
  30. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/migrations/0004_replace_course_keys_with_learning_context_keys.py +0 -0
  31. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/migrations/0005_rename_processors_and_generators.py +0 -0
  32. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/migrations/0006_cleanup_openedx_certificates_tables.py +0 -0
  33. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/migrations/__init__.py +0 -0
  34. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/processors.py +0 -0
  35. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/settings/__init__.py +0 -0
  36. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/settings/common.py +0 -0
  37. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/settings/production.py +0 -0
  38. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/tasks.py +0 -0
  39. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/templates/learning_credentials/base.html +0 -0
  40. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.html +0 -0
  41. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.txt +0 -0
  42. {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
  43. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/head.html +0 -0
  44. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/subject.txt +0 -0
  45. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials.egg-info/SOURCES.txt +0 -0
  46. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials.egg-info/dependency_links.txt +0 -0
  47. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials.egg-info/entry_points.txt +0 -0
  48. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials.egg-info/requires.txt +0 -0
  49. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/learning_credentials.egg-info/top_level.txt +0 -0
  50. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/setup.cfg +0 -0
  51. {learning_credentials-0.3.0rc10 → learning_credentials-0.3.1rc2}/tests/test_models.py +0 -0
  52. {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.0rc10
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
 
@@ -0,0 +1,9 @@
1
+ """API URLs."""
2
+
3
+ from django.urls import include, path
4
+
5
+ from .v1 import urls as v1_urls
6
+
7
+ urlpatterns = [
8
+ path("v1/", include((v1_urls, "learning_credentials_api_v1"), namespace="learning_credentials_api_v1")),
9
+ ]
@@ -8,6 +8,6 @@ urlpatterns = [
8
8
  path(
9
9
  'configured/<str:learning_context_key>/',
10
10
  CredentialConfigurationCheckView.as_view(),
11
- name='credential-configuration-check',
11
+ name='credential_configuration_check',
12
12
  ),
13
13
  ]
@@ -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: 'learning_credentials',
22
- PluginURLs.APP_NAME: 'learning_credentials',
21
+ PluginURLs.NAMESPACE: name,
22
+ PluginURLs.APP_NAME: name,
23
23
  }
24
24
  },
25
25
  PluginSettings.CONFIG: {
@@ -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.learning_sequences.api import get_course_outline
55
+ from openedx.core.djangoapps.content.course_overviews.api import get_course_overview_or_none
56
56
 
57
- course_outline = get_course_outline(course_id)
58
- return (course_outline and course_outline.title) or str(course_id)
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:
@@ -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 pdfmetrics
23
- from reportlab.pdfbase.ttfonts import TTFont
24
- from reportlab.pdfgen import canvas
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(options: dict[str, Any]) -> str:
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 options: A dictionary containing the font.
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 font := options.get('font'):
56
- pdfmetrics.registerFont(TTFont(font, CredentialAsset.get_asset_by_slug(font)))
56
+ if not font_name:
57
+ return None
57
58
 
58
- return font or 'Helvetica'
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: any, font: str, username: str, context_name: str, options: dict[str, Any]) -> any:
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 = canvas.Canvas(io.BytesIO(), pagesize=(template_width, template_height))
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
- pdf_canvas.setFont(font, options.get('name_size', 32))
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
- pdf_canvas.setFont(font, options.get('context_name_size', 28))
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
- pdf_canvas.setFont(font, 12)
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
- pdf_canvas.drawString(issue_date_x, issue_date_y, issue_date)
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
- - 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.
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
- - context_name: Specify the custom course or Learning Path name.
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
- # Get template from the CredentialAsset.
201
- # HACK: We support two-line strings by using a semicolon as a separator.
202
- if ';' in context_name and (template_path := options.get('template_two_lines')):
203
- template_file = CredentialAsset.get_asset_by_slug(template_path)
204
- context_name = context_name.replace(';', '\n')
205
- else:
206
- template_file = CredentialAsset.get_asset_by_slug(options['template'])
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
- font = _register_font(options)
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, font, username, context_name, options)
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])
@@ -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, created = IntervalSchedule.objects.get_or_create(every=10, period=IntervalSchedule.DAYS)
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,
@@ -2,6 +2,8 @@
2
2
 
3
3
  from django.urls import include, path
4
4
 
5
+ from .api import urls as api_urls
6
+
5
7
  urlpatterns = [
6
- path('api/learning_credentials/', include('learning_credentials.api.urls')),
8
+ path('api/learning_credentials/', include(api_urls)),
7
9
  ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: learning-credentials
3
- Version: 0.3.0rc10
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.0-rc10"
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,<5.3"]
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<5.0",
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
- options = {}
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.pdfmetrics.registerFont")
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(options) == custom_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.canvas.Canvas', return_value=Mock(stringWidth=Mock(return_value=10)))
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, font, username, context_name, options)
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': 'template_slug'}, 'template_slug', 'Test Course'),
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': 'template_slug', 'context_name': 'Override'}, 'template_slug', 'Override'),
326
+ ('Test Course', {'template': 'default', 'context_name': 'Override'}, 'default', 'Override'),
237
327
  # Ignore empty course name override.
238
- ('Test Course', {'template': 'template_slug', 'context_name': ''}, 'template_slug', 'Test Course'),
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, # noqa: FBT001
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:credential-configuration-check',
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:credential-configuration-check',
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)
@@ -1,12 +0,0 @@
1
- """API URLs."""
2
-
3
- from django.urls import include, path
4
-
5
- urlpatterns = [
6
- path(
7
- "v1/",
8
- include(
9
- ("learning_credentials.api.v1.urls", "learning_credentials_api_v1"), namespace="learning_credentials_api_v1"
10
- ),
11
- ),
12
- ]