/
models.py
366 lines (290 loc) · 12 KB
/
models.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
"""
Models for the credentials service.
"""
import logging
import uuid
import bleach
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.contrib.sites.models import Site
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from django_extensions.db.models import TimeStampedModel
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from simple_history.models import HistoricalRecords
from credentials.apps.catalog.api import get_program_details_by_uuid
from credentials.apps.catalog.models import CourseRun, Program
from credentials.apps.core.utils import _choices
from credentials.apps.credentials import constants
from credentials.apps.credentials.exceptions import NoMatchingProgramException
log = logging.getLogger(__name__)
def signatory_assets_path(instance, filename):
"""
Returns path for signatory assets.
Arguments:
instance(Signatory): Signatory object
filename(str): file to upload
Returns:
Path to asset.
"""
return f"signatories/{instance.id}/{filename}"
def validate_image(image):
"""
Validates that a particular image is small enough.
"""
if image.size > (250 * 1024):
raise ValidationError(_("The image file size must be less than 250KB."))
def validate_course_key(course_key):
"""
Validate the course_key is correct.
"""
try:
CourseKey.from_string(course_key)
except InvalidKeyError:
raise ValidationError(_("Invalid course key."))
class AbstractCredential(TimeStampedModel):
"""
Abstract Credentials configuration model.
.. no_pii: This model has no PII.
"""
site = models.ForeignKey(Site, on_delete=models.CASCADE)
is_active = models.BooleanField(default=False)
class Meta:
abstract = True
class Signatory(TimeStampedModel):
"""
Signatory model to add certificate signatories.
.. no_pii: This model has no learner PII. The name used here is the name of the professor who signed the
certificate.
"""
name = models.CharField(max_length=255)
title = models.CharField(max_length=255)
organization_name_override = models.CharField(
max_length=255,
null=True,
blank=True,
help_text=_("Signatory organization name if its different from issuing organization."),
)
image = models.ImageField(
help_text=_("Image must be square PNG files. The file size should be under 250KB."),
upload_to=signatory_assets_path,
validators=[validate_image],
)
class Meta:
verbose_name_plural = "Signatories"
def __str__(self):
return f"{self.name}, {self.title}"
def save(self, *args, **kwargs):
"""
A primary key/ID will not be assigned until the model is written to
the database. Given that our file path relies on this ID, save the
model initially with no file. After the initial save, update the file
and save again. All subsequent saves will write to the database only
once.
"""
if self.pk is None:
temp_image = self.image
self.image = None
super().save(*args, **kwargs)
self.image = temp_image
super().save(force_update=True)
class AbstractCertificate(AbstractCredential):
"""
Abstract Certificate configuration to support multiple type of certificates
i.e. Programs, Courses.
.. no_pii: This model has no PII.
"""
signatories = models.ManyToManyField(Signatory)
title = models.CharField(
max_length=255,
null=True,
blank=True,
help_text="Custom certificate title to override default display_name for a course/program.",
)
class Meta:
abstract = True
class UserCredential(TimeStampedModel):
"""
Credentials issued to a learner.
.. pii: Stores username for a user.
pii values: username
.. pii_types: username
.. pii_retirement: retained
"""
AWARDED, REVOKED = (
"awarded",
"revoked",
)
STATUSES_CHOICES = (
(AWARDED, _("awarded")),
(REVOKED, _("revoked")),
)
credential_content_type = models.ForeignKey(
ContentType,
limit_choices_to={"model__in": ("coursecertificate", "programcertificate")},
on_delete=models.CASCADE,
)
credential_id = models.PositiveIntegerField()
credential = GenericForeignKey("credential_content_type", "credential_id")
username = models.CharField(max_length=255, db_index=True)
status = models.CharField(
max_length=255,
choices=_choices(constants.UserCredentialStatus.AWARDED, constants.UserCredentialStatus.REVOKED),
default=constants.UserCredentialStatus.AWARDED,
)
download_url = models.CharField(
max_length=255, blank=True, null=True, help_text=_("URL at which the credential can be downloaded")
)
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
class Meta:
unique_together = (("username", "credential_content_type", "credential_id"),)
def get_absolute_url(self):
return reverse("credentials:render", kwargs={"uuid": self.uuid.hex})
def revoke(self):
""" Sets the status to revoked, and saves this instance. """
self.status = UserCredential.REVOKED
self.save()
class CourseCertificate(AbstractCertificate):
"""
Configuration for Course Certificates.
.. no_pii: This model has no PII.
"""
course_id = models.CharField(max_length=255, validators=[validate_course_key])
course_run = models.OneToOneField(CourseRun, null=True, on_delete=models.PROTECT)
certificate_type = models.CharField(
max_length=255,
choices=_choices(
constants.CertificateType.HONOR,
constants.CertificateType.PROFESSIONAL,
constants.CertificateType.VERIFIED,
constants.CertificateType.NO_ID_PROFESSIONAL,
),
)
user_credentials = GenericRelation(
UserCredential,
content_type_field="credential_content_type",
object_id_field="credential_id",
related_query_name="course_credentials",
)
class Meta:
unique_together = (("course_id", "certificate_type", "site"),)
verbose_name = "Course certificate configuration"
@cached_property
def course_key(self):
return CourseKey.from_string(self.course_id)
class ProgramCertificate(AbstractCertificate):
"""
Configuration for Program Certificates.
.. no_pii: This model has no PII.
"""
program_uuid = models.UUIDField(db_index=True, null=False, blank=False, verbose_name=_("Program UUID"))
# PROTECT prevents the Program from being delete if it's being used for a program cert. This allows copy_catalog
# to be safer when deleting
program = models.OneToOneField(Program, null=True, on_delete=models.PROTECT)
user_credentials = GenericRelation(
UserCredential,
content_type_field="credential_content_type",
object_id_field="credential_id",
related_query_name="program_credentials",
)
use_org_name = models.BooleanField(
default=False,
help_text=_(
"Display the associated organization's name (e.g. ACME University) "
"instead of its short name (e.g. ACMEx)"
),
verbose_name=_("Use organization name"),
)
include_hours_of_effort = models.BooleanField(
default=False,
help_text="Display the estimated total number of hours needed to complete all courses in the program. This "
"feature will only be displayed in the certificate if the attribute 'Total hours of effort' has "
"been set for the program in Discovery.",
)
language = models.CharField(
max_length=8, null=True, help_text="Locale in which certificates for this program will be rendered"
)
def __str__(self):
return f"ProgramCertificate: {self.program_uuid}"
class Meta:
verbose_name = "Program certificate configuration"
unique_together = (("site", "program_uuid"),)
@cached_property
def program_details(self):
""" Returns details about the program associated with this certificate. """
program_details = get_program_details_by_uuid(uuid=self.program_uuid, site=self.site)
if not program_details:
msg = f"No matching program with UUID [{self.program_uuid}] in credentials catalog for program certificate"
raise NoMatchingProgramException(msg)
if self.use_org_name:
for org in program_details.organizations:
org.display_name = org.name
if not self.include_hours_of_effort:
program_details.hours_of_effort = None
program_details.credential_title = self.title
return program_details
class UserCredentialAttribute(TimeStampedModel):
"""
Different attributes of User's Credential such as white list, grade etc.
.. no_pii: This model has no PII.
"""
user_credential = models.ForeignKey(UserCredential, related_name="attributes", on_delete=models.CASCADE)
name = models.CharField(max_length=255)
value = models.CharField(max_length=255)
class Meta:
unique_together = (("user_credential", "name"),)
class ProgramCompletionEmailConfiguration(TimeStampedModel):
"""
Template to add additional content into the program completion emails.
identifier should either be a:
- UUID <string> (for a specific program)
- program type <string> (for a program type)
- or "default" (the DEFAULT_TEMPLATE_IDENTIFIER) to be the global template used for all programs
html_template should be the HTML version of the email
plaintext_template should be the plaintext version of the email
enabled is what determines if we send the emails at all
.. no_pii: This model has no PII.
"""
DEFAULT_TEMPLATE_IDENTIFIER = "default"
# identifier will either be a:
# - UUID <string> (for a specific program)
# - program type <string> (for a program type)
# - or "default" (the DEFAULT_TEMPLATE_IDENTIFIER) to be the global template used for all programs
identifier = models.CharField(
max_length=50,
unique=True,
help_text=(
"""Should be either "default" to affect all programs, the program type slug, or the UUID of the program. """
"""Values are unique."""
),
)
html_template = models.TextField(
help_text=("For HTML emails." "Allows tags include (a, b, blockquote, div, em, i, li, ol, span, strong, ul)")
)
plaintext_template = models.TextField(help_text="For plaintext emails. No formatting tags. Text will send as is.")
enabled = models.BooleanField(default=False)
history = HistoricalRecords()
def save(self, **kwargs):
self.html_template = bleach.clean(self.html_template, tags=settings.ALLOWED_EMAIL_HTML_TAGS)
super().save(**kwargs)
@classmethod
def get_email_config_for_program(cls, program_uuid, program_type_slug):
"""
Gets the email config for the program, with the most specific match being returned,
or None of there are no matches
Because the UUID of the program will have hyphens, but we want to make it easy on PCs copying values,
we will check both the hyphenated version, and an unhyphenated version (.hex)
"""
# By converting the uuid parameter to a string then back to a UUID we can guarantee it will be a UUID later on
converted_program_uuid = uuid.UUID(str(program_uuid))
return (
cls.objects.filter(identifier=converted_program_uuid).first()
or cls.objects.filter(identifier=converted_program_uuid.hex).first()
or cls.objects.filter(identifier=program_type_slug).first()
or cls.objects.filter(identifier=cls.DEFAULT_TEMPLATE_IDENTIFIER).first()
)