Commit f8f23240 by Matt Drayer

Merge pull request #8996 from edx/ziafazal/SOL-1044

ziafazal/SOL-1044: Custom Web Certificates
parents c197e725 2467d697
...@@ -4,6 +4,7 @@ Utility library for working with the edx-organizations app ...@@ -4,6 +4,7 @@ Utility library for working with the edx-organizations app
""" """
from django.conf import settings from django.conf import settings
from django.db.utils import DatabaseError
def add_organization(organization_data): def add_organization(organization_data):
...@@ -43,7 +44,15 @@ def get_organizations(): ...@@ -43,7 +44,15 @@ def get_organizations():
if not settings.FEATURES.get('ORGANIZATIONS_APP', False): if not settings.FEATURES.get('ORGANIZATIONS_APP', False):
return [] return []
from organizations import api as organizations_api from organizations import api as organizations_api
# Due to the way unit tests run for edx-platform, models are not yet available at the time
# of Django admin form instantiation. This unfortunately results in an invocation of the following
# workflow, because the test configuration is (correctly) configured to exercise the application
# The good news is that this case does not manifest in the Real World, because migrations have
# been run ahead of application instantiation and the flag set only when that is truly the case.
try:
return organizations_api.get_organizations() return organizations_api.get_organizations()
except DatabaseError:
return []
def get_organization_courses(organization_id): def get_organization_courses(organization_id):
......
...@@ -2,12 +2,49 @@ ...@@ -2,12 +2,49 @@
django admin pages for certificates models django admin pages for certificates models
""" """
from django.contrib import admin from django.contrib import admin
from django import forms
from config_models.admin import ConfigurationModelAdmin from config_models.admin import ConfigurationModelAdmin
from util.organizations_helpers import get_organizations
from certificates.models import ( from certificates.models import (
CertificateGenerationConfiguration, CertificateHtmlViewConfiguration, BadgeImageConfiguration CertificateGenerationConfiguration,
CertificateHtmlViewConfiguration,
BadgeImageConfiguration,
CertificateTemplate,
CertificateTemplateAsset,
) )
class CertificateTemplateForm(forms.ModelForm):
"""
Django admin form for CertificateTemplate model
"""
organizations = get_organizations()
org_choices = [(org["id"], org["name"]) for org in organizations]
org_choices.insert(0, ('', 'None'))
organization_id = forms.TypedChoiceField(choices=org_choices, required=False, coerce=int, empty_value=None)
class Meta(object):
""" Meta definitions for CertificateTemplateForm """
model = CertificateTemplate
class CertificateTemplateAdmin(admin.ModelAdmin):
"""
Django admin customizations for CertificateTemplate model
"""
list_display = ('name', 'description', 'organization_id', 'course_key', 'mode', 'is_active')
form = CertificateTemplateForm
class CertificateTemplateAssetAdmin(admin.ModelAdmin):
"""
Django admin customizations for CertificateTemplateAsset model
"""
list_display = ('description', '__unicode__')
admin.site.register(CertificateGenerationConfiguration) admin.site.register(CertificateGenerationConfiguration)
admin.site.register(CertificateHtmlViewConfiguration, ConfigurationModelAdmin) admin.site.register(CertificateHtmlViewConfiguration, ConfigurationModelAdmin)
admin.site.register(BadgeImageConfiguration) admin.site.register(BadgeImageConfiguration)
admin.site.register(CertificateTemplate, CertificateTemplateAdmin)
admin.site.register(CertificateTemplateAsset, CertificateTemplateAssetAdmin)
...@@ -14,6 +14,7 @@ from opaque_keys.edx.keys import CourseKey ...@@ -14,6 +14,7 @@ from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from util.organizations_helpers import get_course_organizations
from certificates.models import ( from certificates.models import (
CertificateStatuses, CertificateStatuses,
...@@ -21,7 +22,8 @@ from certificates.models import ( ...@@ -21,7 +22,8 @@ from certificates.models import (
CertificateGenerationCourseSetting, CertificateGenerationCourseSetting,
CertificateGenerationConfiguration, CertificateGenerationConfiguration,
ExampleCertificateSet, ExampleCertificateSet,
GeneratedCertificate GeneratedCertificate,
CertificateTemplate,
) )
from certificates.queue import XQueueCertInterface from certificates.queue import XQueueCertInterface
...@@ -373,6 +375,46 @@ def get_active_web_certificate(course, is_preview_mode=None): ...@@ -373,6 +375,46 @@ def get_active_web_certificate(course, is_preview_mode=None):
return None return None
def get_certificate_template(course_key, mode):
"""
Retrieves the custom certificate template based on course_key and mode.
"""
org_id, template = None, None
# fetch organization of the course
course_organization = get_course_organizations(course_key)
if course_organization:
org_id = course_organization[0]['id']
if org_id and mode:
template = CertificateTemplate.objects.filter(
organization_id=org_id,
course_key=course_key,
mode=mode,
is_active=True
)
# if don't template find by org and mode
if not template and org_id and mode:
template = CertificateTemplate.objects.filter(
organization_id=org_id,
mode=mode,
is_active=True
)
# if don't template find by only org
if not template and org_id:
template = CertificateTemplate.objects.filter(
organization_id=org_id,
is_active=True
)
# if we still don't template find by only course mode
if not template and mode:
template = CertificateTemplate.objects.filter(
mode=mode,
is_active=True
)
return template[0].template if template else None
def emit_certificate_event(event_name, user, course_id, course=None, event_data=None): def emit_certificate_event(event_name, user, course_id, course=None, event_data=None):
""" """
Emits certificate event. Emits certificate event.
......
...@@ -674,6 +674,88 @@ class BadgeImageConfiguration(models.Model): ...@@ -674,6 +674,88 @@ class BadgeImageConfiguration(models.Model):
return cls.objects.get(default=True).icon return cls.objects.get(default=True).icon
class CertificateTemplate(TimeStampedModel):
"""A set of custom web certificate templates.
Web certificate templates are Django web templates
to replace PDF certificate.
A particular course may have several kinds of certificate templates
(e.g. honor and verified).
"""
name = models.CharField(
max_length=255,
help_text=_(u'Name of template.'),
)
description = models.CharField(
max_length=255,
null=True,
blank=True,
help_text=_(u'Description and/or admin notes.'),
)
template = models.TextField(
help_text=_(u'Django template HTML.'),
)
organization_id = models.IntegerField(
null=True,
blank=True,
db_index=True,
help_text=_(u'Organization of template.'),
)
course_key = CourseKeyField(
max_length=255,
null=True,
blank=True,
db_index=True,
)
mode = models.CharField(
max_length=125,
choices=GeneratedCertificate.MODES,
default=GeneratedCertificate.MODES.honor,
null=True,
blank=True,
help_text=_(u'The course mode for this template.'),
)
is_active = models.BooleanField(
help_text=_(u'On/Off switch.'),
default=False,
)
def __unicode__(self):
return u'%s' % (self.name, )
class Meta(object): # pylint: disable=missing-docstring
get_latest_by = 'created'
unique_together = (('organization_id', 'course_key', 'mode'),)
class CertificateTemplateAsset(TimeStampedModel):
"""A set of assets to be used in custom web certificate templates.
This model stores assets used in custom web certificate templates
such as image, css files.
"""
description = models.CharField(
max_length=255,
null=True,
blank=True,
help_text=_(u'Description of the asset.'),
)
asset = models.FileField(
max_length=255,
upload_to='certificate_template_assets',
help_text=_(u'Asset file. It could be an image or css file.'),
)
def __unicode__(self):
return u'%s' % (self.asset.url, ) # pylint: disable=no-member
class Meta(object): # pylint: disable=missing-docstring
get_latest_by = 'created'
@receiver(post_save, sender=GeneratedCertificate) @receiver(post_save, sender=GeneratedCertificate)
#pylint: disable=unused-argument #pylint: disable=unused-argument
def create_badge(sender, instance, **kwargs): def create_badge(sender, instance, **kwargs):
......
...@@ -30,6 +30,7 @@ from certificates.models import ( ...@@ -30,6 +30,7 @@ from certificates.models import (
CertificateStatuses, CertificateStatuses,
CertificateHtmlViewConfiguration, CertificateHtmlViewConfiguration,
CertificateSocialNetworks, CertificateSocialNetworks,
CertificateTemplate,
) )
from certificates.tests.factories import ( from certificates.tests.factories import (
...@@ -45,6 +46,11 @@ FEATURES_WITH_CERTS_ENABLED['CERTIFICATES_HTML_VIEW'] = True ...@@ -45,6 +46,11 @@ FEATURES_WITH_CERTS_ENABLED['CERTIFICATES_HTML_VIEW'] = True
FEATURES_WITH_CERTS_DISABLED = settings.FEATURES.copy() FEATURES_WITH_CERTS_DISABLED = settings.FEATURES.copy()
FEATURES_WITH_CERTS_DISABLED['CERTIFICATES_HTML_VIEW'] = False FEATURES_WITH_CERTS_DISABLED['CERTIFICATES_HTML_VIEW'] = False
FEATURES_WITH_CUSTOM_CERTS_ENABLED = {
"CUSTOM_CERTIFICATE_TEMPLATES_ENABLED": True
}
FEATURES_WITH_CUSTOM_CERTS_ENABLED.update(FEATURES_WITH_CERTS_ENABLED)
@attr('shard_1') @attr('shard_1')
@ddt.ddt @ddt.ddt
...@@ -427,6 +433,30 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase): ...@@ -427,6 +433,30 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase):
self.course.save() self.course.save()
self.store.update_item(self.course, self.user.id) self.store.update_item(self.course, self.user.id)
def _create_custom_template(self, org_id=None, mode=None, course_key=None):
"""
Creates a custom certificate template entry in DB.
"""
template_html = """
<html>
<body>
lang: ${LANGUAGE_CODE}
course name: ${accomplishment_copy_course_name}
mode: ${course_mode}
${accomplishment_copy_course_description}
</body>
</html>
"""
template = CertificateTemplate(
name='custom template',
template=template_html,
organization_id=org_id,
course_key=course_key,
mode=mode,
is_active=True
)
template.save()
@override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED) @override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED)
def test_rendering_course_organization_data(self): def test_rendering_course_organization_data(self):
""" """
...@@ -724,6 +754,75 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase): ...@@ -724,6 +754,75 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase):
response_json = json.loads(response.content) response_json = json.loads(response.content)
self.assertEqual(CertificateStatuses.generating, response_json['add_status']) self.assertEqual(CertificateStatuses.generating, response_json['add_status'])
@override_settings(FEATURES=FEATURES_WITH_CUSTOM_CERTS_ENABLED)
@override_settings(LANGUAGE_CODE='fr')
def test_certificate_custom_template_with_org_mode_course(self):
"""
Tests custom template search and rendering.
"""
self._add_course_certificates(count=1, signatory_count=2)
self._create_custom_template(1, mode='honor', course_key=unicode(self.course.id))
self._create_custom_template(2, mode='honor')
test_url = get_certificate_url(
user_id=self.user.id,
course_id=unicode(self.course.id)
)
with patch('certificates.api.get_course_organizations') as mock_get_orgs:
mock_get_orgs.side_effect = [
[{"id": 1, "name": "organization name"}],
[{"id": 2, "name": "organization name 2"}],
]
response = self.client.get(test_url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'lang: fr')
self.assertContains(response, 'course name: {}'.format(self.course.display_name))
# test with second organization template
response = self.client.get(test_url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'lang: fr')
self.assertContains(response, 'course name: {}'.format(self.course.display_name))
@override_settings(FEATURES=FEATURES_WITH_CUSTOM_CERTS_ENABLED)
def test_certificate_custom_template_with_org(self):
"""
Tests custom template search if if have a single template for all courses of organization.
"""
self._add_course_certificates(count=1, signatory_count=2)
self._create_custom_template(1)
self._create_custom_template(1, mode='honor')
test_url = get_certificate_url(
user_id=self.user.id,
course_id=unicode(self.course.id)
)
with patch('certificates.api.get_course_organizations') as mock_get_orgs:
mock_get_orgs.side_effect = [
[{"id": 1, "name": "organization name"}],
]
response = self.client.get(test_url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'course name: {}'.format(self.course.display_name))
@override_settings(FEATURES=FEATURES_WITH_CUSTOM_CERTS_ENABLED)
def test_certificate_custom_template_with_course_mode(self):
"""
Tests custom template search if if have a single template for a course mode.
"""
mode = 'honor'
self._add_course_certificates(count=1, signatory_count=2)
self._create_custom_template(mode=mode)
test_url = get_certificate_url(
user_id=self.user.id,
course_id=unicode(self.course.id)
)
with patch('certificates.api.get_course_organizations') as mock_get_orgs:
mock_get_orgs.return_value = []
response = self.client.get(test_url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'mode: {}'.format(mode))
class TrackShareRedirectTest(UrlResetMixin, ModuleStoreTestCase, EventTrackingTestCase): class TrackShareRedirectTest(UrlResetMixin, ModuleStoreTestCase, EventTrackingTestCase):
""" """
......
...@@ -7,22 +7,27 @@ import logging ...@@ -7,22 +7,27 @@ import logging
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.http import HttpResponse
from django.template import RequestContext
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from opaque_keys import InvalidKeyError from courseware.courses import course_image_url
from opaque_keys.edx.keys import CourseKey
from microsite_configuration import microsite
from edxmako.shortcuts import render_to_response from edxmako.shortcuts import render_to_response
from edxmako.template import Template
from eventtracking import tracker from eventtracking import tracker
from xmodule.modulestore.django import modulestore from microsite_configuration import microsite
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from student.models import LinkedInAddToProfileConfiguration from student.models import LinkedInAddToProfileConfiguration
from courseware.courses import course_image_url
from util import organizations_helpers as organization_api from util import organizations_helpers as organization_api
from xmodule.modulestore.django import modulestore
from certificates.api import ( from certificates.api import (
get_active_web_certificate, get_active_web_certificate,
get_certificate_url, get_certificate_url,
emit_certificate_event, emit_certificate_event,
has_html_certificates_enabled has_html_certificates_enabled,
get_certificate_template
) )
from certificates.models import ( from certificates.models import (
GeneratedCertificate, GeneratedCertificate,
...@@ -31,7 +36,6 @@ from certificates.models import ( ...@@ -31,7 +36,6 @@ from certificates.models import (
BadgeAssertion BadgeAssertion
) )
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -401,4 +405,11 @@ def render_html_view(request, user_id, course_id): ...@@ -401,4 +405,11 @@ def render_html_view(request, user_id, course_id):
context.update(course.cert_html_view_overrides) context.update(course.cert_html_view_overrides)
# FINALLY, generate and send the output the client # FINALLY, generate and send the output the client
if settings.FEATURES.get('CUSTOM_CERTIFICATE_TEMPLATES_ENABLED', False):
custom_template = get_certificate_template(course_key, user_certificate.mode)
if custom_template:
template = Template(custom_template)
context = RequestContext(request, context)
return HttpResponse(template.render(context))
return render_to_response("certificates/valid.html", context) return render_to_response("certificates/valid.html", context)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment