learning-credentials 0.4.0rc2__tar.gz → 0.4.0rc3__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 (54) hide show
  1. {learning_credentials-0.4.0rc2/learning_credentials.egg-info → learning_credentials-0.4.0rc3}/PKG-INFO +1 -1
  2. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/learning_credentials/generators.py +34 -10
  3. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3/learning_credentials.egg-info}/PKG-INFO +1 -1
  4. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/pyproject.toml +2 -1
  5. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/tests/test_generators.py +128 -12
  6. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/CHANGELOG.rst +0 -0
  7. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/LICENSE.txt +0 -0
  8. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/MANIFEST.in +0 -0
  9. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/README.rst +0 -0
  10. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/learning_credentials/__init__.py +0 -0
  11. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/learning_credentials/admin.py +0 -0
  12. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/learning_credentials/api/__init__.py +0 -0
  13. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/learning_credentials/api/urls.py +0 -0
  14. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/learning_credentials/api/v1/__init__.py +0 -0
  15. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/learning_credentials/api/v1/permissions.py +0 -0
  16. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/learning_credentials/api/v1/serializers.py +0 -0
  17. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/learning_credentials/api/v1/urls.py +0 -0
  18. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/learning_credentials/api/v1/views.py +0 -0
  19. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/learning_credentials/apps.py +0 -0
  20. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/learning_credentials/compat.py +0 -0
  21. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/learning_credentials/conf/locale/config.yaml +0 -0
  22. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/learning_credentials/exceptions.py +0 -0
  23. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/learning_credentials/migrations/0001_initial.py +0 -0
  24. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/learning_credentials/migrations/0002_migrate_to_learning_credentials.py +0 -0
  25. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/learning_credentials/migrations/0003_rename_certificates_to_credentials.py +0 -0
  26. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/learning_credentials/migrations/0004_replace_course_keys_with_learning_context_keys.py +0 -0
  27. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/learning_credentials/migrations/0005_rename_processors_and_generators.py +0 -0
  28. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/learning_credentials/migrations/0006_cleanup_openedx_certificates_tables.py +0 -0
  29. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/learning_credentials/migrations/0007_validation.py +0 -0
  30. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/learning_credentials/migrations/__init__.py +0 -0
  31. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/learning_credentials/models.py +0 -0
  32. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/learning_credentials/processors.py +0 -0
  33. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/learning_credentials/settings/__init__.py +0 -0
  34. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/learning_credentials/settings/common.py +0 -0
  35. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/learning_credentials/settings/production.py +0 -0
  36. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/learning_credentials/tasks.py +0 -0
  37. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/learning_credentials/templates/learning_credentials/base.html +0 -0
  38. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.html +0 -0
  39. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.txt +0 -0
  40. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/from_name.txt +0 -0
  41. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/head.html +0 -0
  42. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/subject.txt +0 -0
  43. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/learning_credentials/templates/learning_credentials/verify.html +0 -0
  44. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/learning_credentials/urls.py +0 -0
  45. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/learning_credentials.egg-info/SOURCES.txt +0 -0
  46. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/learning_credentials.egg-info/dependency_links.txt +0 -0
  47. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/learning_credentials.egg-info/entry_points.txt +0 -0
  48. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/learning_credentials.egg-info/requires.txt +0 -0
  49. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/learning_credentials.egg-info/top_level.txt +0 -0
  50. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/setup.cfg +0 -0
  51. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/tests/test_models.py +0 -0
  52. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/tests/test_processors.py +0 -0
  53. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/tests/test_tasks.py +0 -0
  54. {learning_credentials-0.4.0rc2 → learning_credentials-0.4.0rc3}/tests/test_views.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: learning-credentials
3
- Version: 0.4.0rc2
3
+ Version: 0.4.0rc3
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
@@ -95,6 +95,9 @@ def _write_text_on_template(template: PageObject, username: str, context_name: s
95
95
  font = _register_font(options.get('font')) or 'Helvetica'
96
96
 
97
97
  # Write the learner name.
98
+ if options.get('name_uppercase', getattr(settings, 'LEARNING_CREDENTIALS_NAME_UPPERCASE', False)):
99
+ username = username.upper()
100
+
98
101
  name_font = _register_font(options.get('name_font')) or font
99
102
  pdf_canvas.setFont(name_font, options.get('name_size', 32))
100
103
  name_color = options.get('name_color', '#000')
@@ -102,6 +105,7 @@ def _write_text_on_template(template: PageObject, username: str, context_name: s
102
105
 
103
106
  name_x = (template_width - pdf_canvas.stringWidth(username)) / 2
104
107
  name_y = options.get('name_y', 290)
108
+
105
109
  pdf_canvas.drawString(name_x, name_y, username)
106
110
 
107
111
  # Write the learning context name.
@@ -121,6 +125,9 @@ def _write_text_on_template(template: PageObject, username: str, context_name: s
121
125
 
122
126
  # Write the issue date.
123
127
  issue_date = get_localized_credential_date()
128
+ if options.get('issue_date_uppercase', getattr(settings, 'LEARNING_CREDENTIALS_ISSUE_DATE_UPPERCASE', False)):
129
+ issue_date = issue_date.upper()
130
+
124
131
  issue_date_font = _register_font(options.get('issue_date_font')) or font
125
132
  pdf_canvas.setFont(issue_date_font, options.get('issue_date_size', 12))
126
133
  issue_date_color = options.get('issue_date_color', '#000')
@@ -128,7 +135,12 @@ def _write_text_on_template(template: PageObject, username: str, context_name: s
128
135
 
129
136
  issue_date_x = (template_width - pdf_canvas.stringWidth(issue_date)) / 2
130
137
  issue_date_y = options.get('issue_date_y', 120)
131
- pdf_canvas.drawString(issue_date_x, issue_date_y, issue_date)
138
+
139
+ issue_date_char_space = options.get(
140
+ 'issue_date_char_space', getattr(settings, 'LEARNING_CREDENTIALS_ISSUE_DATE_CHAR_SPACE', 0)
141
+ )
142
+
143
+ pdf_canvas.drawString(issue_date_x, issue_date_y, issue_date, charSpace=issue_date_char_space)
132
144
 
133
145
  return pdf_canvas
134
146
 
@@ -177,7 +189,7 @@ def generate_pdf_credential(
177
189
  credential_uuid: UUID,
178
190
  options: dict[str, Any],
179
191
  ) -> str:
180
- """
192
+ r"""
181
193
  Generate a PDF credential.
182
194
 
183
195
  :param learning_context_key: The ID of the course or learning path the credential is for.
@@ -188,13 +200,15 @@ def generate_pdf_credential(
188
200
 
189
201
  Options:
190
202
  - template: The path to the PDF template file.
191
- - template_two_lines: The path to the PDF template file for two-line context names.
192
- A two-line context name is specified by using a semicolon as a separator.
203
+ - template_multiline: The path to the PDF template file for multiline context names.
204
+ A multiline context name is specified by using '\n' or ';' as a separator.
193
205
  - font: The name of the font to use. The default font is Helvetica.
194
206
  - name_y: The Y coordinate of the name on the credential (vertical position on the template).
195
207
  - name_color: The color of the name on the credential (hexadecimal color code).
196
208
  - name_size: The font size of the name on the credential. The default value is 32.
197
209
  - name_font: The font of the name on the credential. It overrides the `font` option.
210
+ - name_uppercase: If set to true (without quotes), the name will be converted to uppercase.
211
+ The default value is False, unless specified otherwise in the instance settings.
198
212
  - context_name: Specify the custom course or Learning Path name. If not provided, it will be retrieved
199
213
  automatically from the "cert_name_long" or "display_name" fields for courses, or from the Learning Path model.
200
214
  - context_name_y: The Y coordinate of the context name on the credential (vertical position on the template).
@@ -205,19 +219,29 @@ def generate_pdf_credential(
205
219
  - issue_date_color: The color of the issue date on the credential (hexadecimal color code).
206
220
  - issue_date_size: The font size of the issue date on the credential. The default value is 12.
207
221
  - issue_date_font: The font of the issue date on the credential. It overrides the `font` option.
222
+ - issue_date_char_space: The character spacing of the issue date on the credential
223
+ (default is 0.0, unless specified otherwise in the instance settings).
224
+ - issue_date_uppercase: If set to true (without quotes), the issue date will be converted to uppercase.
225
+ The default value is False, unless specified otherwise in the instance settings.
208
226
  """
209
227
  log.info("Starting credential generation for user %s", user.id)
210
228
 
211
229
  username = _get_user_name(user)
212
230
  context_name = options.get('context_name') or get_learning_context_name(learning_context_key)
231
+ template_path = options.get('template')
232
+
233
+ # Handle multiline context name (we support semicolon as a separator to preserve backward compatibility).
234
+ context_name = context_name.replace(';', '\n').replace(r'\n', '\n')
235
+ if '\n' in context_name:
236
+ # `template_two_lines` is kept for backward compatibility.
237
+ template_path = options.get('template_multiline', options.get('template_two_lines', template_path))
238
+
239
+ if not template_path:
240
+ msg = "Template path must be specified in options."
241
+ raise ValueError(msg)
213
242
 
214
243
  # Get template from the CredentialAsset.
215
- # HACK: We support two-line strings by using a semicolon as a separator.
216
- if ';' in context_name and (template_path := options.get('template_two_lines')):
217
- template_file = CredentialAsset.get_asset_by_slug(template_path)
218
- context_name = context_name.replace(';', '\n')
219
- else:
220
- template_file = CredentialAsset.get_asset_by_slug(options['template'])
244
+ template_file = CredentialAsset.get_asset_by_slug(template_path)
221
245
 
222
246
  # Load the PDF template.
223
247
  with template_file.open('rb') as template_file:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: learning-credentials
3
- Version: 0.4.0rc2
3
+ Version: 0.4.0rc3
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "learning-credentials"
3
- version = "0.4.0-rc2"
3
+ version = "0.4.0-rc3"
4
4
  description = "A pluggable service for preparing Open edX credentials."
5
5
  dynamic = ["readme"]
6
6
  requires-python = ">=3.11"
@@ -188,6 +188,7 @@ ignore = [
188
188
  'RUF018', # assignment-in-assert
189
189
  'ARG002', # unused-method-argument
190
190
  'PLR0913', # too-many-arguments
191
+ 'FBT001', # flake8-boolean-trap
191
192
  ]
192
193
 
193
194
  [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,
@@ -59,6 +60,21 @@ def test_register_font_with_custom_font(mock_register_font: Mock, mock_font_clas
59
60
  mock_font_class.assert_called_once_with(custom_font, mock_get_asset_by_slug.return_value)
60
61
  mock_register_font.assert_called_once_with(mock_font_class.return_value)
61
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
+
62
78
 
63
79
  @pytest.mark.parametrize(
64
80
  ("context_name", "options", "expected"),
@@ -153,6 +169,90 @@ def test_write_text_on_template(mock_canvas_class: Mock, context_name: str, opti
153
169
  assert canvas_object.drawString.mock_calls[-1][1] == (expected_issue_date_x, expected_issue_date_y, test_date)
154
170
 
155
171
 
172
+ @pytest.mark.parametrize(
173
+ ("option_value", "setting_value", "expected_uppercase"),
174
+ [
175
+ (None, False, False), # No option set, setting is False - use default (lowercase)
176
+ (None, True, True), # No option set, setting is True - use setting (uppercase)
177
+ (False, False, False), # Explicitly disabled, setting is False
178
+ (False, True, False), # Explicitly disabled, setting is True - option overrides
179
+ (True, False, True), # Explicitly enabled, setting is False - option overrides
180
+ (True, True, True), # Explicitly enabled, setting is True
181
+ ],
182
+ )
183
+ @patch('learning_credentials.generators.get_localized_credential_date', return_value='April 1, 2021')
184
+ @patch('learning_credentials.generators.Canvas', return_value=Mock(stringWidth=Mock(return_value=10)))
185
+ def test_write_text_on_template_uppercase(
186
+ mock_canvas_class: Mock,
187
+ mock_get_date: Mock,
188
+ option_value: bool | None,
189
+ setting_value: bool,
190
+ expected_uppercase: bool,
191
+ ):
192
+ """Test the _write_text_on_template function with uppercase option and settings."""
193
+ username = "John Doe"
194
+ context_name = "Programming 101"
195
+ template_mock = Mock(mediabox=[0, 0, 300, 200])
196
+ options = {}
197
+
198
+ if expected_uppercase:
199
+ expected_name = "JOHN DOE"
200
+ expected_date = "APRIL 1, 2021"
201
+ else:
202
+ expected_name = username
203
+ expected_date = mock_get_date.return_value
204
+
205
+ if option_value is not None:
206
+ options['name_uppercase'] = option_value
207
+ options['issue_date_uppercase'] = option_value
208
+
209
+ with override_settings(
210
+ LEARNING_CREDENTIALS_NAME_UPPERCASE=setting_value,
211
+ LEARNING_CREDENTIALS_ISSUE_DATE_UPPERCASE=setting_value,
212
+ ):
213
+ _write_text_on_template(template_mock, username, context_name, options)
214
+
215
+ assert mock_canvas_class.return_value.drawString.mock_calls[-3][1][2] == expected_name
216
+ assert mock_canvas_class.return_value.drawString.mock_calls[-1][1][2] == expected_date
217
+
218
+
219
+ @pytest.mark.parametrize(
220
+ ("option_value", "setting_value", "expected_char_space"),
221
+ [
222
+ (None, 0, 0), # No option set, setting is 0 - use default
223
+ (None, 2, 2), # No option set, setting has value - use setting
224
+ (0, 0, 0), # Explicitly set to 0, setting is 0
225
+ (0, 2, 0), # Explicitly set to 0, setting has value - option overrides
226
+ (3, 0, 3), # Explicitly set to 3, setting is 0 - option overrides
227
+ (3, 2, 3), # Explicitly set to 3, setting has value - option overrides
228
+ ],
229
+ )
230
+ @patch('learning_credentials.generators.get_localized_credential_date', return_value='April 1, 2021')
231
+ @patch('learning_credentials.generators.Canvas', return_value=Mock(stringWidth=Mock(return_value=10)))
232
+ def test_write_text_on_template_issue_date_char_space(
233
+ mock_canvas_class: Mock,
234
+ _mock_get_date: Mock, # noqa: PT019
235
+ option_value: int | None,
236
+ setting_value: int,
237
+ expected_char_space: int,
238
+ ):
239
+ """Test the _write_text_on_template function with issue_date_char_space option and settings."""
240
+ username = "John Doe"
241
+ context_name = "Programming 101"
242
+ template_mock = Mock(mediabox=[0, 0, 300, 200])
243
+ options = {}
244
+
245
+ if option_value is not None:
246
+ options['issue_date_char_space'] = option_value
247
+
248
+ with override_settings(LEARNING_CREDENTIALS_ISSUE_DATE_CHAR_SPACE=setting_value):
249
+ _write_text_on_template(template_mock, username, context_name, options)
250
+
251
+ # Check that the last drawString call (for issue date) has the correct charSpace parameter
252
+ last_draw_call = mock_canvas_class.return_value.drawString.mock_calls[-1]
253
+ assert last_draw_call[2]['charSpace'] == expected_char_space
254
+
255
+
156
256
  @override_settings(LMS_ROOT_URL="https://example.com", MEDIA_URL="media/")
157
257
  @pytest.mark.parametrize(
158
258
  "storage",
@@ -220,20 +320,22 @@ def test_save_credential(mock_contentfile: Mock, mock_token_hex: Mock, storage:
220
320
  ("context_name", "options", "expected_template_slug", "expected_context_name"),
221
321
  [
222
322
  # Default.
223
- ('Test Course', {'template': 'template_slug'}, 'template_slug', 'Test Course'),
224
- # Specify a different template for two-line course names and replace semicolon with newline in course name.
225
- (
226
- 'Test Course; Test Course',
227
- {'template': 'template_slug', 'template_two_lines': 'template_two_lines_slug'},
228
- 'template_two_lines_slug',
229
- 'Test Course\n Test Course',
230
- ),
231
- # Do not replace semicolon with newline when the `template_two_lines` option is not specified.
232
- ('Test Course; Test Course', {'template': 'template_slug'}, 'template_slug', 'Test Course; Test Course'),
323
+ ('Test Course', {'template': 'default'}, 'default', 'Test Course'),
233
324
  # Override course name.
234
- ('Test Course', {'template': 'template_slug', 'context_name': 'Override'}, 'template_slug', 'Override'),
325
+ ('Test Course', {'template': 'default', 'context_name': 'Override'}, 'default', 'Override'),
235
326
  # Ignore empty course name override.
236
- ('Test Course', {'template': 'template_slug', 'context_name': ''}, 'template_slug', 'Test Course'),
327
+ ('Test Course', {'template': 'default', 'context_name': ''}, 'default', 'Test Course'),
328
+ # Specify a different template for multiline course names and replace \n with newline.
329
+ ('Test\nCourse', {'template': 'default', 'template_multiline': 'multiline'}, 'multiline', 'Test\nCourse'),
330
+ ('Test\\nCourse', {'template_multiline': 'multiline'}, 'multiline', 'Test\nCourse'),
331
+ # Backward compatibility with semicolon separator.
332
+ ('Test;Course', {'template_multiline': 'multiline'}, 'multiline', 'Test\nCourse'),
333
+ # Mixed semicolon and newline separators.
334
+ ('Te\nst\\nCourse;', {'template_multiline': 'multiline'}, 'multiline', 'Te\nst\nCourse\n'),
335
+ # Check backward compatibility with `template_two_lines` option.
336
+ ('Test\\nCourse', {'template': 'default', 'template_two_lines': 'two_lines'}, 'two_lines', 'Test\nCourse'),
337
+ # Ensure that the default template is used when no multiline template is specified.
338
+ ('Test\\nCourse', {'template': 'default'}, 'default', 'Test\nCourse'),
237
339
  ],
238
340
  )
239
341
  @patch(
@@ -292,3 +394,17 @@ def test_generate_pdf_credential(
292
394
  assert args[-1] == options
293
395
 
294
396
  mock_save_credential.assert_called_once()
397
+
398
+ @patch('learning_credentials.generators.get_learning_context_name')
399
+ @patch('learning_credentials.generators._get_user_name')
400
+ def test_generate_pdf_credential_no_template(mock_get_user_name: Mock, mock_get_learning_context_name: Mock):
401
+ """Test that generate_pdf_credential raises ValueError when no template is specified."""
402
+ course_id = CourseKey.from_string('course-v1:edX+DemoX+Demo_Course')
403
+ user = Mock()
404
+ options = {} # No template specified.
405
+
406
+ with pytest.raises(ValueError, match=r"Template path must be specified in options."):
407
+ generate_pdf_credential(course_id, user, Mock(), options)
408
+
409
+ mock_get_user_name.assert_called_once_with(user)
410
+ mock_get_learning_context_name.assert_called_once_with(course_id)