for Open edX operators who still have users with legacy PDF certificates, retirement requires first extracting information from the user's GeneratedCertificate record in order to delete the 4 associated files for each PDF certificate, and then removing the links to the relevant files. this creates a management command to do that work.
After thinking about it, I have removed the update to `status` from this management command, as per the original specification of the ticket. I added it for completeness originally, but was already uncomfortable, because it's not exactly accurate. The `CertificateStatuses` enum does define a `deleted` status:
```
deleted - The PDF certificate has been deleted.
```
but I think it's inappropriate to use here.
#### Why not use `CertificateStatuses.deleted` in the first place
There are multiple places in the code where it's clear that almost all of the statuses are legacy and unused (eg. [Example 1](6c6fd84e53/lms/djangoapps/certificates/data.py (L12-L34)), [Example 2](1029de5537/common/djangoapps/student/helpers.py (L491-L492))). There are innumerable APIs in the system that have expectations about what might possibly be returned from a `GeneratedCertificate.status` object, and none of them is expecting `deleted`
#### Why not revoke the certificate
Ultimately, the certificate isn't revoked, which has a specific meaning around saying it was unearned. The certificate was earned; it has simply been deleted. We should not be kicking off program certificate invalidation, because that's not what's happening. We should be trusting the normal user retirement process to remove/purge PII from any program certificates that might exist. The nature of web certificates simply means that we are going through this process outside of the normal retirement flow. The normal retirement flow can be trusted to implement any certificate object revocation/removal/PII-purging, and doing an extra step outside of that flow is counterproductive.
#### Why not robustly add a flow for `CertificateStatuses.deleted`
When PDF certificates were removed from the system, they weren't removed in their entirety. Instead, we have this vestigial remains of PDF certificates code, just enough to allow learners to display and use the ones that they already have, without any of the support systems for modifying them. Adding a `deleted` status, verifying that all other APIs wouldn't break in the presence of a certificate with that status, adding the signals to process and propagate the change: all of this would be adding more tech debt upon the already existing technical debt which is the PDF certs code. Better to simply add this one necessary data integrity change, and focus on a process which might allow us to eventually remove the web certificates code.
#### Why it is good enough to ignore the status
The original ask was simply to enforce data integrity: to remove links to files that have been deleted, as an indication that they've been deleted. I only added `status` update out of a (misplaced but well-intentioned) completionist urge.
FIXES: APER-3889
132 lines
4.7 KiB
Python
132 lines
4.7 KiB
Python
"""
|
|
django admin pages for certificates models
|
|
"""
|
|
|
|
|
|
from operator import itemgetter
|
|
|
|
from config_models.admin import ConfigurationModelAdmin
|
|
from django import forms
|
|
from django.conf import settings
|
|
from django.contrib import admin
|
|
from django.utils.safestring import mark_safe
|
|
from organizations.api import get_organizations
|
|
|
|
from lms.djangoapps.certificates.models import (
|
|
CertificateDateOverride,
|
|
CertificateGenerationConfiguration,
|
|
CertificateGenerationCommandConfiguration,
|
|
CertificateGenerationCourseSetting,
|
|
CertificateHtmlViewConfiguration,
|
|
CertificateTemplate,
|
|
CertificateTemplateAsset,
|
|
GeneratedCertificate,
|
|
ModifiedCertificateTemplateCommandConfiguration,
|
|
PurgeReferencestoPDFCertificatesCommandConfiguration,
|
|
)
|
|
|
|
|
|
class CertificateTemplateForm(forms.ModelForm):
|
|
"""
|
|
Django admin form for CertificateTemplate model
|
|
"""
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
organizations = get_organizations()
|
|
org_choices = [(org["id"], org["name"]) for org in organizations]
|
|
org_choices.insert(0, ('', 'None'))
|
|
self.fields['organization_id'] = forms.TypedChoiceField(
|
|
choices=org_choices, required=False, coerce=int, empty_value=None
|
|
)
|
|
languages = list(settings.CERTIFICATE_TEMPLATE_LANGUAGES.items())
|
|
lang_choices = sorted(languages, key=itemgetter(1))
|
|
lang_choices.insert(0, (None, 'All Languages'))
|
|
self.fields['language'] = forms.ChoiceField(
|
|
choices=lang_choices, required=False
|
|
)
|
|
|
|
class Meta:
|
|
model = CertificateTemplate
|
|
fields = '__all__'
|
|
|
|
|
|
class CertificateTemplateAdmin(admin.ModelAdmin):
|
|
"""
|
|
Django admin customizations for CertificateTemplate model
|
|
"""
|
|
list_display = ('name', 'description', 'organization_id', 'course_key', 'mode', 'language', 'is_active')
|
|
form = CertificateTemplateForm
|
|
|
|
|
|
class CertificateTemplateAssetAdmin(admin.ModelAdmin):
|
|
"""
|
|
Django admin customizations for CertificateTemplateAsset model
|
|
"""
|
|
|
|
list_display = ('description', 'asset_slug',)
|
|
prepopulated_fields = {"asset_slug": ("description",)}
|
|
|
|
# see PROD-1153 for the details
|
|
def changelist_view(self, request, extra_context=None):
|
|
if '.stage.edx.org' in request.get_host():
|
|
extra_context = {'title': mark_safe('Select Certificate Template Asset to change <br/><br/>'
|
|
'<div><strong style="color: red;"> Warning!</strong> Updating '
|
|
'stage asset would also update production asset</div>')}
|
|
return super().changelist_view(request, extra_context=extra_context)
|
|
|
|
|
|
class GeneratedCertificateAdmin(admin.ModelAdmin):
|
|
"""
|
|
Django admin customizations for GeneratedCertificate model
|
|
"""
|
|
raw_id_fields = ('user',)
|
|
show_full_result_count = False
|
|
search_fields = ('course_id', 'user__username')
|
|
list_display = ('id', 'course_id', 'mode', 'user')
|
|
|
|
|
|
class CertificateGenerationCourseSettingAdmin(admin.ModelAdmin):
|
|
"""
|
|
Django admin customizations for CertificateGenerationCourseSetting model
|
|
"""
|
|
list_display = ('course_key', 'self_generation_enabled', 'language_specific_templates_enabled')
|
|
search_fields = ('course_key',)
|
|
show_full_result_count = False
|
|
|
|
|
|
@admin.register(ModifiedCertificateTemplateCommandConfiguration)
|
|
class ModifiedCertificateTemplateCommandConfigurationAdmin(ConfigurationModelAdmin):
|
|
pass
|
|
|
|
|
|
@admin.register(CertificateGenerationCommandConfiguration)
|
|
class CertificateGenerationCommandConfigurationAdmin(ConfigurationModelAdmin):
|
|
pass
|
|
|
|
|
|
@admin.register(PurgeReferencestoPDFCertificatesCommandConfiguration)
|
|
class PurgeReferencestoPDFCertificatesCommandConfigurationAdmin(ConfigurationModelAdmin):
|
|
pass
|
|
|
|
|
|
class CertificateDateOverrideAdmin(admin.ModelAdmin):
|
|
"""
|
|
# Django admin customizations for CertificateDateOverride model
|
|
"""
|
|
list_display = ('generated_certificate', 'date', 'reason', 'overridden_by')
|
|
raw_id_fields = ('generated_certificate',)
|
|
readonly_fields = ('overridden_by',)
|
|
|
|
def save_model(self, request, obj, form, change):
|
|
obj.overridden_by = request.user
|
|
super().save_model(request, obj, form, change)
|
|
|
|
|
|
admin.site.register(CertificateGenerationConfiguration)
|
|
admin.site.register(CertificateGenerationCourseSetting, CertificateGenerationCourseSettingAdmin)
|
|
admin.site.register(CertificateHtmlViewConfiguration, ConfigurationModelAdmin)
|
|
admin.site.register(CertificateTemplate, CertificateTemplateAdmin)
|
|
admin.site.register(CertificateTemplateAsset, CertificateTemplateAssetAdmin)
|
|
admin.site.register(GeneratedCertificate, GeneratedCertificateAdmin)
|
|
admin.site.register(CertificateDateOverride, CertificateDateOverrideAdmin)
|