learning-credentials 0.3.1rc2__py3-none-any.whl → 0.4.0rc1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- learning_credentials/admin.py +30 -3
- learning_credentials/api/v1/serializers.py +13 -0
- learning_credentials/api/v1/urls.py +2 -1
- learning_credentials/api/v1/views.py +61 -1
- learning_credentials/compat.py +3 -6
- learning_credentials/generators.py +30 -69
- learning_credentials/migrations/0007_validation.py +94 -0
- learning_credentials/models.py +60 -11
- learning_credentials/templates/learning_credentials/verify.html +83 -0
- learning_credentials/urls.py +2 -0
- {learning_credentials-0.3.1rc2.dist-info → learning_credentials-0.4.0rc1.dist-info}/METADATA +4 -13
- {learning_credentials-0.3.1rc2.dist-info → learning_credentials-0.4.0rc1.dist-info}/RECORD +16 -13
- {learning_credentials-0.3.1rc2.dist-info → learning_credentials-0.4.0rc1.dist-info}/WHEEL +0 -0
- {learning_credentials-0.3.1rc2.dist-info → learning_credentials-0.4.0rc1.dist-info}/entry_points.txt +0 -0
- {learning_credentials-0.3.1rc2.dist-info → learning_credentials-0.4.0rc1.dist-info}/licenses/LICENSE.txt +0 -0
- {learning_credentials-0.3.1rc2.dist-info → learning_credentials-0.4.0rc1.dist-info}/top_level.txt +0 -0
learning_credentials/admin.py
CHANGED
|
@@ -7,8 +7,9 @@ import inspect
|
|
|
7
7
|
from typing import TYPE_CHECKING
|
|
8
8
|
|
|
9
9
|
from django import forms
|
|
10
|
-
from django.contrib import admin
|
|
10
|
+
from django.contrib import admin, messages
|
|
11
11
|
from django.core.exceptions import ValidationError
|
|
12
|
+
from django.urls import reverse
|
|
12
13
|
from django.utils.html import format_html
|
|
13
14
|
from django_object_actions import DjangoObjectActions, action
|
|
14
15
|
from django_reverse_admin import ReverseModelAdmin
|
|
@@ -225,7 +226,7 @@ class CredentialConfigurationAdmin(DjangoObjectActions, ReverseModelAdmin):
|
|
|
225
226
|
|
|
226
227
|
|
|
227
228
|
@admin.register(Credential)
|
|
228
|
-
class CredentialAdmin(admin.ModelAdmin): # noqa: D101
|
|
229
|
+
class CredentialAdmin(DjangoObjectActions, admin.ModelAdmin): # noqa: D101
|
|
229
230
|
list_display = (
|
|
230
231
|
'user_id',
|
|
231
232
|
'user_full_name',
|
|
@@ -237,19 +238,32 @@ class CredentialAdmin(admin.ModelAdmin): # noqa: D101
|
|
|
237
238
|
'modified',
|
|
238
239
|
)
|
|
239
240
|
readonly_fields = (
|
|
241
|
+
'uuid',
|
|
242
|
+
'verify_uuid',
|
|
240
243
|
'user_id',
|
|
241
244
|
'created',
|
|
242
245
|
'modified',
|
|
243
246
|
'user_full_name',
|
|
244
247
|
'learning_context_key',
|
|
248
|
+
'learning_context_name',
|
|
245
249
|
'credential_type',
|
|
246
250
|
'status',
|
|
247
251
|
'url',
|
|
248
252
|
'legacy_id',
|
|
249
253
|
'generation_task_id',
|
|
250
254
|
)
|
|
251
|
-
search_fields = ("learning_context_key", "user_id", "user_full_name")
|
|
255
|
+
search_fields = ("learning_context_key", "user_id", "user_full_name", "uuid")
|
|
252
256
|
list_filter = ("learning_context_key", "credential_type", "status")
|
|
257
|
+
change_actions = ('reissue_credential',)
|
|
258
|
+
|
|
259
|
+
def save_model(self, request: HttpRequest, obj: Credential, _form: forms.ModelForm, _change: bool): # noqa: FBT001
|
|
260
|
+
"""Display validation errors as messages in the admin interface."""
|
|
261
|
+
try:
|
|
262
|
+
obj.save()
|
|
263
|
+
except ValidationError as e:
|
|
264
|
+
self.message_user(request, e.message or "Invalid data", level=messages.ERROR)
|
|
265
|
+
# Optionally, redirect to the change form with the error message
|
|
266
|
+
return
|
|
253
267
|
|
|
254
268
|
def get_form(self, request: HttpRequest, obj: Credential | None = None, **kwargs) -> forms.ModelForm:
|
|
255
269
|
"""Hide the download_url field."""
|
|
@@ -263,3 +277,16 @@ class CredentialAdmin(admin.ModelAdmin): # noqa: D101
|
|
|
263
277
|
if obj.download_url:
|
|
264
278
|
return format_html("<a href='{url}'>{url}</a>", url=obj.download_url)
|
|
265
279
|
return "-"
|
|
280
|
+
|
|
281
|
+
@action(label="Reissue credential", description="Reissue the credential for the user.")
|
|
282
|
+
def reissue_credential(self, request: HttpRequest, obj: Credential):
|
|
283
|
+
"""Reissue the credential for the user."""
|
|
284
|
+
try:
|
|
285
|
+
new_credential = obj.reissue()
|
|
286
|
+
admin_url = reverse('admin:learning_credentials_credential_change', args=[new_credential.pk])
|
|
287
|
+
message = format_html(
|
|
288
|
+
'The credential has been reissued as <a href="{}">{}</a>.', admin_url, new_credential.uuid
|
|
289
|
+
)
|
|
290
|
+
messages.success(request, message)
|
|
291
|
+
except CredentialConfiguration.DoesNotExist:
|
|
292
|
+
messages.error(request, "The configuration does not exist.")
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""API serializers for learning credentials."""
|
|
2
|
+
|
|
3
|
+
from rest_framework import serializers
|
|
4
|
+
|
|
5
|
+
from learning_credentials.models import Credential
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CredentialSerializer(serializers.ModelSerializer):
|
|
9
|
+
"""Serializer that returns credential metadata."""
|
|
10
|
+
|
|
11
|
+
class Meta: # noqa: D106
|
|
12
|
+
model = Credential
|
|
13
|
+
fields = ('user_full_name', 'created', 'learning_context_name', 'status', 'invalidation_reason')
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from django.urls import path
|
|
4
4
|
|
|
5
|
-
from .views import CredentialConfigurationCheckView
|
|
5
|
+
from .views import CredentialConfigurationCheckView, CredentialMetadataView
|
|
6
6
|
|
|
7
7
|
urlpatterns = [
|
|
8
8
|
path(
|
|
@@ -10,4 +10,5 @@ urlpatterns = [
|
|
|
10
10
|
CredentialConfigurationCheckView.as_view(),
|
|
11
11
|
name='credential_configuration_check',
|
|
12
12
|
),
|
|
13
|
+
path('metadata/<uuid:uuid>/', CredentialMetadataView.as_view(), name='credential-metadata'),
|
|
13
14
|
]
|
|
@@ -9,9 +9,10 @@ from rest_framework.permissions import IsAuthenticated
|
|
|
9
9
|
from rest_framework.response import Response
|
|
10
10
|
from rest_framework.views import APIView
|
|
11
11
|
|
|
12
|
-
from learning_credentials.models import CredentialConfiguration
|
|
12
|
+
from learning_credentials.models import Credential, CredentialConfiguration
|
|
13
13
|
|
|
14
14
|
from .permissions import CanAccessLearningContext
|
|
15
|
+
from .serializers import CredentialSerializer
|
|
15
16
|
|
|
16
17
|
if TYPE_CHECKING:
|
|
17
18
|
from rest_framework.request import Request
|
|
@@ -81,3 +82,62 @@ class CredentialConfigurationCheckView(APIView):
|
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
return Response(response_data, status=status.HTTP_200_OK)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class CredentialMetadataView(APIView):
|
|
88
|
+
"""API view to retrieve credential metadata by UUID."""
|
|
89
|
+
|
|
90
|
+
@apidocs.schema(
|
|
91
|
+
parameters=[
|
|
92
|
+
apidocs.string_parameter(
|
|
93
|
+
"uuid",
|
|
94
|
+
ParameterLocation.PATH,
|
|
95
|
+
description="The UUID of the credential to retrieve.",
|
|
96
|
+
),
|
|
97
|
+
],
|
|
98
|
+
responses={
|
|
99
|
+
200: "Successfully retrieved the credential metadata.",
|
|
100
|
+
404: "Credential not found or not valid.",
|
|
101
|
+
},
|
|
102
|
+
)
|
|
103
|
+
def get(self, _request: "Request", uuid: str) -> Response:
|
|
104
|
+
"""
|
|
105
|
+
Retrieve credential metadata by its UUID.
|
|
106
|
+
|
|
107
|
+
**Example Request**
|
|
108
|
+
|
|
109
|
+
``GET /api/learning_credentials/v1/metadata/123e4567-e89b-12d3-a456-426614174000/``
|
|
110
|
+
|
|
111
|
+
**Response Values**
|
|
112
|
+
|
|
113
|
+
- **200 OK**: Successfully retrieved the credential metadata.
|
|
114
|
+
- **404 Not Found**: Credential not found or not valid.
|
|
115
|
+
|
|
116
|
+
**Example Response**
|
|
117
|
+
|
|
118
|
+
.. code-block:: json
|
|
119
|
+
|
|
120
|
+
{
|
|
121
|
+
"user_full_name": "John Doe",
|
|
122
|
+
"created": "2023-01-01",
|
|
123
|
+
"learning_context_name": "Demo Course",
|
|
124
|
+
"status": "available",
|
|
125
|
+
"invalidation_reason": ""
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
{
|
|
130
|
+
"user_full_name": "John Doe",
|
|
131
|
+
"created": "2023-01-01",
|
|
132
|
+
"learning_context_name": "Demo Course",
|
|
133
|
+
"status": "invalidated",
|
|
134
|
+
"invalidation_reason": "Reissued due to name change."
|
|
135
|
+
}
|
|
136
|
+
"""
|
|
137
|
+
try:
|
|
138
|
+
credential = Credential.objects.get(verify_uuid=uuid)
|
|
139
|
+
except Credential.DoesNotExist:
|
|
140
|
+
return Response({'error': 'Credential not found.'}, status=status.HTTP_404_NOT_FOUND)
|
|
141
|
+
|
|
142
|
+
serializer = CredentialSerializer(credential)
|
|
143
|
+
return Response(serializer.data, status=status.HTTP_200_OK)
|
learning_credentials/compat.py
CHANGED
|
@@ -52,13 +52,10 @@ 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.learning_sequences.api import get_course_outline
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
name = course_overview.cert_name_long or course_overview.display_name or name
|
|
60
|
-
|
|
61
|
-
return name
|
|
57
|
+
course_outline = get_course_outline(course_id)
|
|
58
|
+
return (course_outline and course_outline.title) or str(course_id)
|
|
62
59
|
|
|
63
60
|
|
|
64
61
|
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
|
|
23
|
-
from reportlab.pdfbase.ttfonts import
|
|
24
|
-
from reportlab.pdfgen
|
|
22
|
+
from reportlab.pdfbase import pdfmetrics
|
|
23
|
+
from reportlab.pdfbase.ttfonts import TTFont
|
|
24
|
+
from reportlab.pdfgen 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,7 +33,6 @@ 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
|
|
37
36
|
|
|
38
37
|
|
|
39
38
|
def _get_user_name(user: User) -> str:
|
|
@@ -46,29 +45,25 @@ def _get_user_name(user: User) -> str:
|
|
|
46
45
|
return user.profile.name or f"{user.first_name} {user.last_name}"
|
|
47
46
|
|
|
48
47
|
|
|
49
|
-
def _register_font(
|
|
48
|
+
def _register_font(options: dict[str, Any]) -> str:
|
|
50
49
|
"""
|
|
51
50
|
Register a custom font if specified in options. If not specified, use the default font (Helvetica).
|
|
52
51
|
|
|
53
|
-
:param
|
|
54
|
-
:returns: The font name
|
|
52
|
+
:param options: A dictionary containing the font.
|
|
53
|
+
:returns: The font name.
|
|
55
54
|
"""
|
|
56
|
-
if
|
|
57
|
-
|
|
55
|
+
if font := options.get('font'):
|
|
56
|
+
pdfmetrics.registerFont(TTFont(font, CredentialAsset.get_asset_by_slug(font)))
|
|
58
57
|
|
|
59
|
-
|
|
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
|
|
58
|
+
return font or 'Helvetica'
|
|
65
59
|
|
|
66
60
|
|
|
67
|
-
def _write_text_on_template(template:
|
|
61
|
+
def _write_text_on_template(template: any, font: str, username: str, context_name: str, options: dict[str, Any]) -> any:
|
|
68
62
|
"""
|
|
69
63
|
Prepare a new canvas and write the user and course name onto it.
|
|
70
64
|
|
|
71
65
|
:param template: Pdf template.
|
|
66
|
+
:param font: Font name.
|
|
72
67
|
:param username: The name of the user to generate the credential for.
|
|
73
68
|
:param context_name: The name of the learning context.
|
|
74
69
|
:param options: A dictionary documented in the `generate_pdf_credential` function.
|
|
@@ -91,26 +86,19 @@ def _write_text_on_template(template: PageObject, username: str, context_name: s
|
|
|
91
86
|
return tuple(int(hex_color[i : i + 2], 16) / 255 for i in range(0, 6, 2))
|
|
92
87
|
|
|
93
88
|
template_width, template_height = template.mediabox[2:]
|
|
94
|
-
pdf_canvas = Canvas(io.BytesIO(), pagesize=(template_width, template_height))
|
|
95
|
-
font = _register_font(options.get('font')) or 'Helvetica'
|
|
89
|
+
pdf_canvas = canvas.Canvas(io.BytesIO(), pagesize=(template_width, template_height))
|
|
96
90
|
|
|
97
91
|
# Write the learner name.
|
|
98
|
-
|
|
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))
|
|
92
|
+
pdf_canvas.setFont(font, options.get('name_size', 32))
|
|
103
93
|
name_color = options.get('name_color', '#000')
|
|
104
94
|
pdf_canvas.setFillColorRGB(*hex_to_rgb(name_color))
|
|
105
95
|
|
|
106
96
|
name_x = (template_width - pdf_canvas.stringWidth(username)) / 2
|
|
107
97
|
name_y = options.get('name_y', 290)
|
|
108
|
-
|
|
109
98
|
pdf_canvas.drawString(name_x, name_y, username)
|
|
110
99
|
|
|
111
100
|
# Write the learning context name.
|
|
112
|
-
|
|
113
|
-
pdf_canvas.setFont(context_name_font, options.get('context_name_size', 28))
|
|
101
|
+
pdf_canvas.setFont(font, options.get('context_name_size', 28))
|
|
114
102
|
context_name_color = options.get('context_name_color', '#000')
|
|
115
103
|
pdf_canvas.setFillColorRGB(*hex_to_rgb(context_name_color))
|
|
116
104
|
|
|
@@ -125,23 +113,13 @@ def _write_text_on_template(template: PageObject, username: str, context_name: s
|
|
|
125
113
|
|
|
126
114
|
# Write the issue date.
|
|
127
115
|
issue_date = get_localized_credential_date()
|
|
128
|
-
|
|
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))
|
|
116
|
+
pdf_canvas.setFont(font, 12)
|
|
133
117
|
issue_date_color = options.get('issue_date_color', '#000')
|
|
134
118
|
pdf_canvas.setFillColorRGB(*hex_to_rgb(issue_date_color))
|
|
135
119
|
|
|
136
120
|
issue_date_x = (template_width - pdf_canvas.stringWidth(issue_date)) / 2
|
|
137
|
-
issue_date_x += options.get('issue_date_x', 0)
|
|
138
121
|
issue_date_y = options.get('issue_date_y', 120)
|
|
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)
|
|
122
|
+
pdf_canvas.drawString(issue_date_x, issue_date_y, issue_date)
|
|
145
123
|
|
|
146
124
|
return pdf_canvas
|
|
147
125
|
|
|
@@ -190,7 +168,7 @@ def generate_pdf_credential(
|
|
|
190
168
|
credential_uuid: UUID,
|
|
191
169
|
options: dict[str, Any],
|
|
192
170
|
) -> str:
|
|
193
|
-
|
|
171
|
+
"""
|
|
194
172
|
Generate a PDF credential.
|
|
195
173
|
|
|
196
174
|
:param learning_context_key: The ID of the course or learning path the credential is for.
|
|
@@ -201,50 +179,33 @@ def generate_pdf_credential(
|
|
|
201
179
|
|
|
202
180
|
Options:
|
|
203
181
|
- template: The path to the PDF template file.
|
|
204
|
-
-
|
|
205
|
-
A
|
|
206
|
-
- font: The name of the font to use.
|
|
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.
|
|
207
185
|
- name_y: The Y coordinate of the name on the credential (vertical position on the template).
|
|
208
186
|
- name_color: The color of the name on the credential (hexadecimal color code).
|
|
209
187
|
- name_size: The font size of the name on the credential. The default value is 32.
|
|
210
|
-
-
|
|
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.
|
|
188
|
+
- context_name: Specify the custom course or Learning Path name.
|
|
215
189
|
- context_name_y: The Y coordinate of the context name on the credential (vertical position on the template).
|
|
216
190
|
- context_name_color: The color of the context name on the credential (hexadecimal color code).
|
|
217
191
|
- 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).
|
|
221
192
|
- issue_date_y: The Y coordinate of the issue date on the credential (vertical position on the template).
|
|
222
193
|
- 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.
|
|
229
194
|
"""
|
|
230
195
|
log.info("Starting credential generation for user %s", user.id)
|
|
231
196
|
|
|
232
197
|
username = _get_user_name(user)
|
|
233
198
|
context_name = options.get('context_name') or get_learning_context_name(learning_context_key)
|
|
234
|
-
template_path = options.get('template')
|
|
235
|
-
|
|
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)
|
|
245
199
|
|
|
246
200
|
# Get template from the CredentialAsset.
|
|
247
|
-
|
|
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'])
|
|
207
|
+
|
|
208
|
+
font = _register_font(options)
|
|
248
209
|
|
|
249
210
|
# Load the PDF template.
|
|
250
211
|
with template_file.open('rb') as template_file:
|
|
@@ -253,7 +214,7 @@ def generate_pdf_credential(
|
|
|
253
214
|
credential = PdfWriter()
|
|
254
215
|
|
|
255
216
|
# Create a new canvas, prepare the page and write the data
|
|
256
|
-
pdf_canvas = _write_text_on_template(template, username, context_name, options)
|
|
217
|
+
pdf_canvas = _write_text_on_template(template, font, username, context_name, options)
|
|
257
218
|
|
|
258
219
|
overlay_pdf = PdfReader(io.BytesIO(pdf_canvas.getpdfdata()))
|
|
259
220
|
template.merge_page(overlay_pdf.pages[0])
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Generated by Django 4.2.25 on 2025-10-31 17:43
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
import uuid
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def backfill_credential_fields(apps, schema_editor):
|
|
8
|
+
"""Generate verification UUIDs and backfill learning_context_name for all existing credentials."""
|
|
9
|
+
from learning_credentials.compat import get_learning_context_name
|
|
10
|
+
|
|
11
|
+
Credential = apps.get_model("learning_credentials", "Credential")
|
|
12
|
+
for credential in Credential.objects.all():
|
|
13
|
+
credential.verify_uuid = uuid.uuid4()
|
|
14
|
+
credential.learning_context_name = get_learning_context_name(credential.learning_context_key)
|
|
15
|
+
credential.save(update_fields=["verify_uuid", "learning_context_name"])
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Migration(migrations.Migration):
|
|
19
|
+
dependencies = [
|
|
20
|
+
("learning_credentials", "0006_cleanup_openedx_certificates_tables"),
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
operations = [
|
|
24
|
+
migrations.AlterUniqueTogether(
|
|
25
|
+
name="credential",
|
|
26
|
+
unique_together=set(),
|
|
27
|
+
),
|
|
28
|
+
migrations.AlterField(
|
|
29
|
+
model_name="credential",
|
|
30
|
+
name="user_full_name",
|
|
31
|
+
field=models.CharField(
|
|
32
|
+
editable=False,
|
|
33
|
+
help_text="User receiving the credential. This field is used for validation purposes.",
|
|
34
|
+
max_length=255,
|
|
35
|
+
),
|
|
36
|
+
),
|
|
37
|
+
migrations.AddField(
|
|
38
|
+
model_name="credential",
|
|
39
|
+
name="invalidated_at",
|
|
40
|
+
field=models.DateTimeField(
|
|
41
|
+
editable=False,
|
|
42
|
+
help_text="Timestamp when the credential was invalidated",
|
|
43
|
+
null=True,
|
|
44
|
+
),
|
|
45
|
+
),
|
|
46
|
+
migrations.AddField(
|
|
47
|
+
model_name="credential",
|
|
48
|
+
name="invalidation_reason",
|
|
49
|
+
field=models.CharField(
|
|
50
|
+
blank=True,
|
|
51
|
+
help_text="Reason for invalidating the credential",
|
|
52
|
+
max_length=255,
|
|
53
|
+
),
|
|
54
|
+
),
|
|
55
|
+
migrations.AddField(
|
|
56
|
+
model_name="credential",
|
|
57
|
+
name="learning_context_name",
|
|
58
|
+
field=models.CharField(
|
|
59
|
+
editable=False,
|
|
60
|
+
help_text="Name of the learning context for which the credential was issued. This field is used for validation purposes.",
|
|
61
|
+
max_length=255,
|
|
62
|
+
null=True,
|
|
63
|
+
),
|
|
64
|
+
),
|
|
65
|
+
migrations.AddField(
|
|
66
|
+
model_name="credential",
|
|
67
|
+
name="verify_uuid",
|
|
68
|
+
field=models.UUIDField(
|
|
69
|
+
default=uuid.uuid4,
|
|
70
|
+
editable=False,
|
|
71
|
+
help_text="UUID used for verifying the credential",
|
|
72
|
+
null=True,
|
|
73
|
+
),
|
|
74
|
+
),
|
|
75
|
+
migrations.RunPython(backfill_credential_fields, reverse_code=migrations.RunPython.noop),
|
|
76
|
+
migrations.AlterField(
|
|
77
|
+
model_name="credential",
|
|
78
|
+
name="learning_context_name",
|
|
79
|
+
field=models.CharField(
|
|
80
|
+
editable=False,
|
|
81
|
+
help_text="Name of the learning context for which the credential was issued. This field is used for validation purposes.",
|
|
82
|
+
max_length=255,
|
|
83
|
+
),
|
|
84
|
+
),
|
|
85
|
+
migrations.AlterField(
|
|
86
|
+
model_name="credential",
|
|
87
|
+
name="verify_uuid",
|
|
88
|
+
field=models.UUIDField(
|
|
89
|
+
default=uuid.uuid4,
|
|
90
|
+
editable=False,
|
|
91
|
+
help_text="UUID used for verifying the credential",
|
|
92
|
+
),
|
|
93
|
+
),
|
|
94
|
+
]
|
learning_credentials/models.py
CHANGED
|
@@ -4,10 +4,10 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
import logging
|
|
7
|
-
import uuid
|
|
7
|
+
import uuid as uuid_lib
|
|
8
8
|
from importlib import import_module
|
|
9
9
|
from pathlib import Path
|
|
10
|
-
from typing import TYPE_CHECKING
|
|
10
|
+
from typing import TYPE_CHECKING, Self
|
|
11
11
|
|
|
12
12
|
import jsonfield
|
|
13
13
|
from django.conf import settings
|
|
@@ -16,6 +16,7 @@ from django.core.exceptions import ValidationError
|
|
|
16
16
|
from django.db import models
|
|
17
17
|
from django.db.models.signals import post_delete
|
|
18
18
|
from django.dispatch import receiver
|
|
19
|
+
from django.utils import timezone
|
|
19
20
|
from django.utils.translation import gettext_lazy as _
|
|
20
21
|
from django_celery_beat.models import IntervalSchedule, PeriodicTask
|
|
21
22
|
from edx_ace import Message, Recipient, ace
|
|
@@ -179,7 +180,7 @@ class CredentialConfiguration(TimeStampedModel):
|
|
|
179
180
|
custom_options = {**self.credential_type.custom_options, **self.custom_options}
|
|
180
181
|
return func(self.learning_context_key, custom_options)
|
|
181
182
|
|
|
182
|
-
def generate_credential_for_user(self, user_id: int, celery_task_id: int = 0):
|
|
183
|
+
def generate_credential_for_user(self, user_id: int, celery_task_id: int = 0) -> Credential:
|
|
183
184
|
"""
|
|
184
185
|
Celery task for processing a single user's credential.
|
|
185
186
|
|
|
@@ -190,19 +191,24 @@ class CredentialConfiguration(TimeStampedModel):
|
|
|
190
191
|
Args:
|
|
191
192
|
user_id: The ID of the user to process the credential for.
|
|
192
193
|
celery_task_id (optional): The ID of the Celery task that is running this function.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
The generated Credential object.
|
|
193
197
|
"""
|
|
194
198
|
user = get_user_model().objects.get(id=user_id)
|
|
195
199
|
# Use the name from the profile if it is not empty. Otherwise, use the first and last name.
|
|
196
200
|
# We check if the profile exists because it may not exist in some cases (e.g., when a User is created manually).
|
|
197
201
|
user_full_name = getattr(getattr(user, 'profile', None), 'name', f"{user.first_name} {user.last_name}")
|
|
202
|
+
learning_context_name = get_learning_context_name(self.learning_context_key)
|
|
198
203
|
custom_options = {**self.credential_type.custom_options, **self.custom_options}
|
|
199
204
|
|
|
200
|
-
credential, _ = Credential.objects.update_or_create(
|
|
205
|
+
credential, _ = Credential.objects.exclude(status=Credential.Status.INVALIDATED).update_or_create(
|
|
201
206
|
user_id=user_id,
|
|
202
207
|
learning_context_key=self.learning_context_key,
|
|
203
208
|
credential_type=self.credential_type.name,
|
|
204
209
|
defaults={
|
|
205
210
|
'user_full_name': user_full_name,
|
|
211
|
+
'learning_context_name': learning_context_name,
|
|
206
212
|
'status': Credential.Status.GENERATING,
|
|
207
213
|
'generation_task_id': celery_task_id,
|
|
208
214
|
},
|
|
@@ -228,6 +234,8 @@ class CredentialConfiguration(TimeStampedModel):
|
|
|
228
234
|
if user.is_active and user.has_usable_password():
|
|
229
235
|
credential.send_email()
|
|
230
236
|
|
|
237
|
+
return credential
|
|
238
|
+
|
|
231
239
|
|
|
232
240
|
# noinspection PyUnusedLocal
|
|
233
241
|
@receiver(post_delete, sender=CredentialConfiguration)
|
|
@@ -262,16 +270,33 @@ class Credential(TimeStampedModel):
|
|
|
262
270
|
|
|
263
271
|
uuid = models.UUIDField(
|
|
264
272
|
primary_key=True,
|
|
265
|
-
default=
|
|
273
|
+
default=uuid_lib.uuid4,
|
|
266
274
|
editable=False,
|
|
267
275
|
help_text=_('Auto-generated UUID of the credential'),
|
|
268
276
|
)
|
|
277
|
+
verify_uuid = models.UUIDField(
|
|
278
|
+
default=uuid_lib.uuid4,
|
|
279
|
+
editable=False,
|
|
280
|
+
help_text=_('UUID used for verifying the credential'),
|
|
281
|
+
)
|
|
269
282
|
user_id = models.IntegerField(help_text=_('ID of the user receiving the credential'))
|
|
270
|
-
user_full_name = models.CharField(
|
|
283
|
+
user_full_name = models.CharField(
|
|
284
|
+
max_length=255,
|
|
285
|
+
editable=False,
|
|
286
|
+
help_text=_('User receiving the credential. This field is used for validation purposes.'),
|
|
287
|
+
)
|
|
271
288
|
learning_context_key = LearningContextKeyField(
|
|
272
289
|
max_length=255,
|
|
273
290
|
help_text=_('ID of a learning context (e.g., a course or a Learning Path) for which the credential was issued'),
|
|
274
291
|
)
|
|
292
|
+
learning_context_name = models.CharField(
|
|
293
|
+
max_length=255,
|
|
294
|
+
editable=False,
|
|
295
|
+
help_text=_(
|
|
296
|
+
'Name of the learning context for which the credential was issued. '
|
|
297
|
+
'This field is used for validation purposes.'
|
|
298
|
+
),
|
|
299
|
+
)
|
|
275
300
|
credential_type = models.CharField(max_length=255, help_text=_('Type of the credential'))
|
|
276
301
|
status = models.CharField(
|
|
277
302
|
max_length=32,
|
|
@@ -282,16 +307,26 @@ class Credential(TimeStampedModel):
|
|
|
282
307
|
download_url = models.URLField(blank=True, help_text=_('URL of the generated credential PDF (e.g., to S3)'))
|
|
283
308
|
legacy_id = models.IntegerField(null=True, help_text=_('Legacy ID of the credential imported from another system'))
|
|
284
309
|
generation_task_id = models.CharField(max_length=255, help_text=_('Task ID from the Celery queue'))
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
310
|
+
invalidated_at = models.DateTimeField(
|
|
311
|
+
null=True, editable=False, help_text=_('Timestamp when the credential was invalidated')
|
|
312
|
+
)
|
|
313
|
+
invalidation_reason = models.CharField(
|
|
314
|
+
max_length=255, blank=True, help_text=_('Reason for invalidating the credential')
|
|
315
|
+
)
|
|
288
316
|
|
|
289
317
|
def __str__(self): # noqa: D105
|
|
290
318
|
return f"{self.credential_type} for {self.user_full_name} in {self.learning_context_key}"
|
|
291
319
|
|
|
320
|
+
def save(self, *args, **kwargs):
|
|
321
|
+
"""If the invalidation reason is set, update the status and timestamp."""
|
|
322
|
+
if self.invalidation_reason and self.status != Credential.Status.INVALIDATED:
|
|
323
|
+
self.status = Credential.Status.INVALIDATED
|
|
324
|
+
if self.status == Credential.Status.INVALIDATED and not self.invalidated_at:
|
|
325
|
+
self.invalidated_at = timezone.now()
|
|
326
|
+
super().save(*args, **kwargs)
|
|
327
|
+
|
|
292
328
|
def send_email(self):
|
|
293
329
|
"""Send a credential link to the student."""
|
|
294
|
-
learning_context_name = get_learning_context_name(self.learning_context_key)
|
|
295
330
|
user = get_user_model().objects.get(id=self.user_id)
|
|
296
331
|
msg = Message(
|
|
297
332
|
name="certificate_generated",
|
|
@@ -300,12 +335,26 @@ class Credential(TimeStampedModel):
|
|
|
300
335
|
language='en',
|
|
301
336
|
context={
|
|
302
337
|
'certificate_link': self.download_url,
|
|
303
|
-
'course_name': learning_context_name,
|
|
338
|
+
'course_name': self.learning_context_name,
|
|
304
339
|
'platform_name': settings.PLATFORM_NAME,
|
|
305
340
|
},
|
|
306
341
|
)
|
|
307
342
|
ace.send(msg)
|
|
308
343
|
|
|
344
|
+
def reissue(self) -> Self:
|
|
345
|
+
"""Invalidate the current credential and create a new one."""
|
|
346
|
+
config = CredentialConfiguration.objects.get(
|
|
347
|
+
learning_context_key=self.learning_context_key,
|
|
348
|
+
credential_type__name=self.credential_type,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
if self.invalidation_reason:
|
|
352
|
+
self.invalidation_reason += '\n'
|
|
353
|
+
self.invalidation_reason += 'Reissued'
|
|
354
|
+
self.save()
|
|
355
|
+
|
|
356
|
+
return config.generate_credential_for_user(self.user_id)
|
|
357
|
+
|
|
309
358
|
|
|
310
359
|
class CredentialAsset(TimeStampedModel):
|
|
311
360
|
"""
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
{% extends "main_django.html" %}
|
|
2
|
+
{% load i18n %}
|
|
3
|
+
{% load static %}
|
|
4
|
+
|
|
5
|
+
{% block bodyextra %}
|
|
6
|
+
<div id="content" class="credential-verification-container content-wrapper main-container">
|
|
7
|
+
<section class="container">
|
|
8
|
+
<h1>{% trans "Credential verification page" %}</h1>
|
|
9
|
+
<form id="credentialVerificationForm" method="post">
|
|
10
|
+
{% csrf_token %}
|
|
11
|
+
<div class="form-group">
|
|
12
|
+
<label for="credentialID">{% trans 'Credential ID' %}</label>
|
|
13
|
+
<input type="text" id="credentialID" class="form-control" name="credentialID" required>
|
|
14
|
+
<div id="formError" class="text-danger mt-2" style="display: none;"></div>
|
|
15
|
+
</div>
|
|
16
|
+
<button type="submit" class="btn btn-primary">
|
|
17
|
+
{% trans 'Verify' %}
|
|
18
|
+
</button>
|
|
19
|
+
</form>
|
|
20
|
+
|
|
21
|
+
<div id="verificationResults" class="p-0 border-0 mt-5"></div>
|
|
22
|
+
</section>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<script>
|
|
26
|
+
document.getElementById('credentialVerificationForm').onsubmit = async function(event) {
|
|
27
|
+
event.preventDefault();
|
|
28
|
+
let formError = document.getElementById('formError');
|
|
29
|
+
let verificationResults = document.getElementById('verificationResults');
|
|
30
|
+
|
|
31
|
+
let credentialId = document.getElementById('credentialID').value;
|
|
32
|
+
var url = '/api/learning_credentials/v1/metadata/' + encodeURIComponent(credentialId);
|
|
33
|
+
|
|
34
|
+
// Hide previous error messages.
|
|
35
|
+
formError.style.display = 'none';
|
|
36
|
+
|
|
37
|
+
await fetch(url)
|
|
38
|
+
.then(response => response.json())
|
|
39
|
+
.then(data => {
|
|
40
|
+
const table = document.createElement('table');
|
|
41
|
+
table.className = 'table table-striped table-bordered';
|
|
42
|
+
const tbody = document.createElement('tbody');
|
|
43
|
+
Object.entries(data).forEach(([key, value]) => {
|
|
44
|
+
if (!!value) {
|
|
45
|
+
const row = tbody.insertRow();
|
|
46
|
+
const cellKey = row.insertCell();
|
|
47
|
+
const cellValue = row.insertCell();
|
|
48
|
+
cellKey.textContent = key.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
|
49
|
+
cellValue.textContent = value;
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
table.appendChild(tbody);
|
|
53
|
+
verificationResults.textContent = '';
|
|
54
|
+
verificationResults.appendChild(table);
|
|
55
|
+
})
|
|
56
|
+
.catch((error) => {
|
|
57
|
+
console.error('Error:', error);
|
|
58
|
+
verificationResults.textContent = '';
|
|
59
|
+
formError.innerHTML = "An error occurred during verification. Please check the Credential ID and try again.";
|
|
60
|
+
formError.style.display = 'block';
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
$(document).ready(function(){
|
|
66
|
+
// Fill the credential ID field with URL 'credential' query param.
|
|
67
|
+
let urlParams = new URLSearchParams(window.location.search);
|
|
68
|
+
const uuid = urlParams.get('credential');
|
|
69
|
+
|
|
70
|
+
if (uuid) {
|
|
71
|
+
$('#credentialID').val(uuid);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// If the field is filled, submit the form automatically.
|
|
75
|
+
if ($('#credentialID').val()) {
|
|
76
|
+
$('#credentialVerificationForm').submit();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Focus the field for user convenience.
|
|
80
|
+
$('#credentialID').focus();
|
|
81
|
+
});
|
|
82
|
+
</script>
|
|
83
|
+
{% endblock %}
|
learning_credentials/urls.py
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
"""URLs for learning_credentials."""
|
|
2
2
|
|
|
3
3
|
from django.urls import include, path
|
|
4
|
+
from django.views.generic import TemplateView
|
|
4
5
|
|
|
5
6
|
from .api import urls as api_urls
|
|
6
7
|
|
|
7
8
|
urlpatterns = [
|
|
8
9
|
path('api/learning_credentials/', include(api_urls)),
|
|
10
|
+
path('learning_credentials/verify/', TemplateView.as_view(template_name="learning_credentials/verify.html")),
|
|
9
11
|
]
|
{learning_credentials-0.3.1rc2.dist-info → learning_credentials-0.4.0rc1.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: learning-credentials
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0rc1
|
|
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,23 +176,14 @@ Unreleased
|
|
|
176
176
|
|
|
177
177
|
*
|
|
178
178
|
|
|
179
|
-
0.
|
|
179
|
+
0.4.0 - 2025-11-03
|
|
180
180
|
******************
|
|
181
181
|
|
|
182
182
|
Added
|
|
183
183
|
=====
|
|
184
184
|
|
|
185
|
-
*
|
|
186
|
-
*
|
|
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``.
|
|
185
|
+
* Frontend form and backend API endpoint for verifying credentials.
|
|
186
|
+
* Option to invalidate issued credentials.
|
|
196
187
|
|
|
197
188
|
0.3.0 - 2025-09-17
|
|
198
189
|
******************
|
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
learning_credentials/__init__.py,sha256=8Q0-3Hdnfmcj41EKu1GSfzEfwWcYNDlItyEEke2r9bs,62
|
|
2
|
-
learning_credentials/admin.py,sha256=
|
|
2
|
+
learning_credentials/admin.py,sha256=gLVpCn5oOHLL3u-wnx4R1yJXfar1Z32vk8zcTVjtBFY,11791
|
|
3
3
|
learning_credentials/apps.py,sha256=trdQxe-JRhUdUaOQoQWiGL1sn6I1sfDiTvdCwy8yGuw,1037
|
|
4
|
-
learning_credentials/compat.py,sha256=
|
|
4
|
+
learning_credentials/compat.py,sha256=OvgzxnhGG5A7Ij65mBv3kyaYojKKQ381RbUGsWXtyOg,4651
|
|
5
5
|
learning_credentials/exceptions.py,sha256=UaqBVXFMWR2Iob7_LMb3j4NNVmWQFAgLi_MNMRUvGsI,290
|
|
6
|
-
learning_credentials/generators.py,sha256=
|
|
7
|
-
learning_credentials/models.py,sha256=
|
|
6
|
+
learning_credentials/generators.py,sha256=KCB166rOx-bwnm6kOc-Vz2HomGdJrQXqglv2MPVBF_Q,9075
|
|
7
|
+
learning_credentials/models.py,sha256=DncnadwVrmVcbrka9LBmKvTMqLq8os_wHXmWc3Zet6s,17949
|
|
8
8
|
learning_credentials/processors.py,sha256=LkdjmkLBnXc9qeMcksB1T8AQ5ZhYaECyQO__KfHB_aU,15212
|
|
9
9
|
learning_credentials/tasks.py,sha256=byoFEUvN_ayVaU5K5SlEiA7vu9BRPaSSmKnB9g5toec,1927
|
|
10
|
-
learning_credentials/urls.py,sha256=
|
|
10
|
+
learning_credentials/urls.py,sha256=KXZtvPXXl2X_nTREWaCFxcAgY2XET1eWRbcx2rq_6eI,348
|
|
11
11
|
learning_credentials/api/__init__.py,sha256=q8sLFfwo5RwQu8FY6BJUL_Jrt3TUojbZK-Zlw9v08EM,40
|
|
12
12
|
learning_credentials/api/urls.py,sha256=wW27hrrJ7D_h8PbFDbSxzeaneNla0R-56gjKy9zISG8,216
|
|
13
13
|
learning_credentials/api/v1/__init__.py,sha256=A7ZqENtM4QM1A7j_cAfnzw4zn0kuyfXSWtylFIE0_f8,43
|
|
14
14
|
learning_credentials/api/v1/permissions.py,sha256=TqM50TpR3JGUgZgIgKZF0-R_g1_P2V9bqKzYXgk-VvY,3436
|
|
15
|
-
learning_credentials/api/v1/
|
|
16
|
-
learning_credentials/api/v1/
|
|
15
|
+
learning_credentials/api/v1/serializers.py,sha256=H7l-vRTwLBplveCBjnNgSawJqpSVskeHTz7wpUiNB3g,417
|
|
16
|
+
learning_credentials/api/v1/urls.py,sha256=RytArViuKZQkWs46sk58VfaVCwLV-QrgTG7cQLE_NtU,408
|
|
17
|
+
learning_credentials/api/v1/views.py,sha256=CJEVPwCXs_ii463agPRpJeX6NCgyyFX9ZIBJh0BAc9I,4926
|
|
17
18
|
learning_credentials/conf/locale/config.yaml,sha256=jPen2DmckNDKK30axCKEd2Q2ha9oOG3IBxrJ63Pvznk,2280
|
|
18
19
|
learning_credentials/migrations/0001_initial.py,sha256=61EvThCv-0UAnhCE5feyQVfjRodbp-6cDaAr4CY5PMA,8435
|
|
19
20
|
learning_credentials/migrations/0002_migrate_to_learning_credentials.py,sha256=vUhcnQKDdwOsppkXsjz2zZwOGMwIJ-fkQRsaj-K7l1o,1779
|
|
@@ -21,19 +22,21 @@ learning_credentials/migrations/0003_rename_certificates_to_credentials.py,sha25
|
|
|
21
22
|
learning_credentials/migrations/0004_replace_course_keys_with_learning_context_keys.py,sha256=5KaXvASl69qbEaHX5_Ty_3Dr7K4WV6p8VWOx72yJnTU,1919
|
|
22
23
|
learning_credentials/migrations/0005_rename_processors_and_generators.py,sha256=5UCqjq-CBJnRo1qBAoWs91ngyEuSMN8_tQtfzsuR5SI,5271
|
|
23
24
|
learning_credentials/migrations/0006_cleanup_openedx_certificates_tables.py,sha256=aJs_gOP4TmW9J-Dmr21m94jBfLQxzjAu6-ua7x4uYLE,727
|
|
25
|
+
learning_credentials/migrations/0007_validation.py,sha256=3vzjwDDFlwtFWmaCQ1wYfyl7Jj-cJB5Jpfq3cGo_vuI,3391
|
|
24
26
|
learning_credentials/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
25
27
|
learning_credentials/settings/__init__.py,sha256=tofc5eg3Q2lV13Ff_jjg1ggGgWpKYoeESkP1qxl3H_A,29
|
|
26
28
|
learning_credentials/settings/common.py,sha256=Cck-nyFt11G1NLiz-bHfKJp8MV6sDZGqTwdbC8_1WE0,360
|
|
27
29
|
learning_credentials/settings/production.py,sha256=6P0P7JxbpWNsk4Lk8lfyxHirOWMgU4UWOb3EYKLjiVQ,542
|
|
28
30
|
learning_credentials/templates/learning_credentials/base.html,sha256=wtjBYqfHmOnyEY5tN3VGOmzYLsOD24MXdEUhTZ7OmwI,662
|
|
31
|
+
learning_credentials/templates/learning_credentials/verify.html,sha256=vXTiZMZkTLhp6cnqKBJcQnDu7qUxLvrTzN2m1KM8_9k,3414
|
|
29
32
|
learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.html,sha256=t-i1Ra9AC4pX-rPRifDJIvBBZuxCxdrFqg1NKTjHBOk,813
|
|
30
33
|
learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.txt,sha256=IF_x8aF_-dORlQB-RCh0IkJDl2ktD489E8qGgLe9M3Y,677
|
|
31
34
|
learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/from_name.txt,sha256=-n8tjPSwfwAfeOSZ1WhcCTrpOah4VswzMZ5mh63Pxow,20
|
|
32
35
|
learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/head.html,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
33
36
|
learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/subject.txt,sha256=S7Hc5T_sZSsSBXm5_H5HBNNv16Ohl0oZn0nVqqeWL0g,132
|
|
34
|
-
learning_credentials-0.
|
|
35
|
-
learning_credentials-0.
|
|
36
|
-
learning_credentials-0.
|
|
37
|
-
learning_credentials-0.
|
|
38
|
-
learning_credentials-0.
|
|
39
|
-
learning_credentials-0.
|
|
37
|
+
learning_credentials-0.4.0rc1.dist-info/licenses/LICENSE.txt,sha256=GDpsPnW_1NKhPvZpZL9imz25P2nIpbwJPEhrlq4vPAU,34523
|
|
38
|
+
learning_credentials-0.4.0rc1.dist-info/METADATA,sha256=AMzNwr8Uif8XG05ihc6hM0FmRXWQB7PVG8tdJSx8VF0,7298
|
|
39
|
+
learning_credentials-0.4.0rc1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
40
|
+
learning_credentials-0.4.0rc1.dist-info/entry_points.txt,sha256=hHqqLUEdzAN24v5OGBX9Fr-wh3ATDPjQjByKz03eC2Y,91
|
|
41
|
+
learning_credentials-0.4.0rc1.dist-info/top_level.txt,sha256=Ce-4_leZe_nny7CpmkeRiemcDV6jIHpIvLjlcQBuf18,21
|
|
42
|
+
learning_credentials-0.4.0rc1.dist-info/RECORD,,
|
|
File without changes
|
{learning_credentials-0.3.1rc2.dist-info → learning_credentials-0.4.0rc1.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
{learning_credentials-0.3.1rc2.dist-info → learning_credentials-0.4.0rc1.dist-info}/top_level.txt
RENAMED
|
File without changes
|