"""Tests for certificates views. """
import datetime
from unittest import skipUnless
from unittest.mock import patch
from urllib.parse import urlencode
from uuid import uuid4
import ddt
from django.conf import settings
from django.test.client import Client, RequestFactory
from django.test.utils import override_settings
from django.urls import reverse
from edx_toggles.toggles.testutils import override_waffle_switch
from organizations import api as organizations_api
from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.student.roles import CourseStaffRole
from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory
from common.djangoapps.track.tests import EventTrackingTestCase
from common.djangoapps.util.date_utils import strftime_localized
from lms.djangoapps.certificates.config import AUTO_CERTIFICATE_GENERATION
from lms.djangoapps.certificates.models import (
CertificateGenerationCourseSetting,
CertificateSocialNetworks,
CertificateStatuses,
CertificateTemplate,
CertificateTemplateAsset,
GeneratedCertificate
)
from lms.djangoapps.certificates.tests.factories import (
CertificateDateOverrideFactory,
CertificateHtmlViewConfigurationFactory,
GeneratedCertificateFactory,
LinkedInAddToProfileConfigurationFactory
)
from lms.djangoapps.certificates.utils import get_certificate_url
from openedx.core.djangoapps.dark_lang.models import DarkLangConfig
from openedx.core.djangoapps.site_configuration.tests.test_util import (
with_site_configuration,
with_site_configuration_context
)
from openedx.core.djangolib.js_utils import js_escaped_string
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase
from openedx.core.lib.courses import course_image_url
from openedx.core.lib.tests.assertions.events import assert_event_matches
from openedx.features.name_affirmation_api.utils import get_name_affirmation_service
from xmodule.data import CertificatesDisplayBehaviors # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order
FEATURES_WITH_CERTS_ENABLED = settings.FEATURES.copy()
FEATURES_WITH_CERTS_ENABLED['CERTIFICATES_HTML_VIEW'] = True
FEATURES_WITH_CERTS_DISABLED = settings.FEATURES.copy()
FEATURES_WITH_CERTS_DISABLED['CERTIFICATES_HTML_VIEW'] = False
FEATURES_WITH_CUSTOM_CERTS_ENABLED = FEATURES_WITH_CERTS_ENABLED.copy()
FEATURES_WITH_CUSTOM_CERTS_ENABLED['CUSTOM_CERTIFICATE_TEMPLATES_ENABLED'] = True
name_affirmation_service = get_name_affirmation_service()
class CommonCertificatesTestCase(ModuleStoreTestCase):
"""
Common setUp and utility methods for Certificate tests
"""
ENABLED_SIGNALS = ['course_published']
def setUp(self):
super().setUp()
self.client = Client()
self.course = CourseFactory.create(
org='testorg',
number='run1',
display_name='refundable course',
certificate_available_date=datetime.datetime.today() - datetime.timedelta(days=1),
certificates_display_behavior=CertificatesDisplayBehaviors.END_WITH_DATE
)
self.course_id = self.course.location.course_key
self.user = UserFactory.create(
email='joe_user@edx.org',
username='joeuser',
password='foo'
)
self.user.profile.name = "Joe User"
self.user.profile.save()
self.client.login(username=self.user.username, password='foo')
self.request = RequestFactory().request()
self.linkedin_url = 'https://www.linkedin.com/profile/add?startTask=CERTIFICATION_NAME&{params}'
self.cert = GeneratedCertificateFactory.create(
user=self.user,
course_id=self.course_id,
download_uuid=uuid4().hex,
download_url="https://www.example.com/certificates/download",
grade="0.95",
key='the_key',
distinction=True,
status=CertificateStatuses.downloadable,
mode='honor',
name=self.user.profile.name,
)
CourseEnrollmentFactory.create(
user=self.user,
course_id=self.course_id,
mode=CourseMode.HONOR,
)
CertificateHtmlViewConfigurationFactory.create()
LinkedInAddToProfileConfigurationFactory.create()
def _add_course_certificates(self, count=1, signatory_count=0, is_active=True):
"""
Create certificate for the course.
"""
signatories = [
{
'name': 'Signatory_Name ' + str(i),
'title': 'Signatory_Title ' + str(i),
'organization': 'Signatory_Organization ' + str(i),
'signature_image_path': f'/static/certificates/images/demo-sig{i}.png',
'id': i
} for i in range(signatory_count)
]
certificates = [
{
'id': i,
'name': 'Name ' + str(i),
'description': 'Description ' + str(i),
'course_title': 'course_title_' + str(i),
'org_logo_path': f'/t4x/orgX/testX/asset/org-logo-{i}.png',
'signatories': signatories,
'version': 1,
'is_active': is_active
} for i in range(count)
]
self.course.certificates = {'certificates': certificates}
self.course.cert_html_view_enabled = True
self.course.save()
self.update_course(self.course, self.user.id)
def _create_custom_template(self, org_id=None, mode=None, course_key=None, language=None):
"""
Creates a custom certificate template entry in DB.
"""
template_html = """
<%namespace name='static' file='static_content.html'/>
lang: ${LANGUAGE_CODE}
course name: ${accomplishment_copy_course_name}
mode: ${course_mode}
${accomplishment_copy_course_description}
${twitter_url}
"""
template = CertificateTemplate(
name='custom template',
template=template_html,
organization_id=org_id,
course_key=course_key,
mode=mode,
is_active=True,
language=language
)
template.save()
def _create_custom_named_template(self, template_name, org_id=None, mode=None, course_key=None, language=None):
"""
Creates a custom certificate template entry in DB.
"""
template_html = """
<%namespace name='static' file='static_content.html'/>
lang: ${LANGUAGE_CODE}
course name: """ + template_name + """
mode: ${course_mode}
${accomplishment_copy_course_description}
${twitter_url}
"""
template = CertificateTemplate(
name=template_name,
template=template_html,
organization_id=org_id,
course_key=course_key,
mode=mode,
is_active=True,
language=language
)
template.save()
def _create_custom_template_with_hours_of_effort(self, org_id=None, mode=None, course_key=None, language=None):
"""
Creates a custom certificate template entry in DB that includes hours of effort.
"""
template_html = """
<%namespace name='static' file='static_content.html'/>
lang: ${LANGUAGE_CODE}
course name: ${accomplishment_copy_course_name}
mode: ${course_mode}
% if hours_of_effort:
hours of effort: ${hours_of_effort}
% endif
${accomplishment_copy_course_description}
${twitter_url}
"""
template = CertificateTemplate(
name='custom template',
template=template_html,
organization_id=org_id,
course_key=course_key,
mode=mode,
is_active=True,
language=language
)
template.save()
def _create_custom_template_with_verified_description(self, org_id=None, course_key=None, language=None):
"""
Creates a custom certificate template entry in DB. This custom certificate can be used to test
that the correct language is used if the IDV requirement on certificates has been enabled for a course.
"""
template_html = """
<%namespace name='static' file='static_content.html'/>
lang: ${LANGUAGE_CODE}
course name: ${accomplishment_copy_course_name}
mode: verified
${accomplishment_copy_course_description}
${certificate_type_description}
% if not idv_enabled_for_certificates:
IDV disabled
%endif
${twitter_url}
"""
template = CertificateTemplate(
name='custom template',
template=template_html,
organization_id=org_id,
course_key=course_key,
mode='verified',
is_active=True,
language=language
)
template.save()
def _add_certificate_date_override(self):
"""
Creates a mock CertificateDateOverride and adds it to the certificate
"""
self.cert.date_override = CertificateDateOverrideFactory.create(
generated_certificate=self.cert,
overridden_by=self.user,
)
@ddt.ddt
class CertificatesViewsTests(CommonCertificatesTestCase, CacheIsolationTestCase):
"""
Tests for the certificates web/html views
"""
def setUp(self):
super().setUp()
self.mock_course_run_details = {
'content_language': 'en',
'weeks_to_complete': '4',
'max_effort': '10'
}
@override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED)
def test_linkedin_share_url(self):
"""
Test: LinkedIn share URL.
"""
self._add_course_certificates(count=1, signatory_count=1, is_active=True)
test_url = get_certificate_url(course_id=self.course.id, uuid=self.cert.verify_uuid)
response = self.client.get(test_url)
assert response.status_code == 200
params = {
'name': '{platform_name} Honor Code Certificate for {course_name}'.format(
platform_name=settings.PLATFORM_NAME, course_name=self.course.display_name,
).encode('utf-8'),
'certUrl': self.request.build_absolute_uri(test_url),
# default value from the LinkedInAddToProfileConfigurationFactory company_identifier
'organizationId': 1337,
'certId': self.cert.verify_uuid,
'issueYear': self.cert.created_date.year,
'issueMonth': self.cert.created_date.month,
}
self.assertContains(
response,
js_escaped_string(self.linkedin_url.format(params=urlencode(params))),
)
@override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED)
@with_site_configuration(
configuration={
'platform_name': 'My Platform Site', 'LINKEDIN_COMPANY_ID': 2448,
},
)
def test_linkedin_share_url_site(self):
"""
Test: LinkedIn share URL should be visible when called from within a site.
"""
self._add_course_certificates(count=1, signatory_count=1, is_active=True)
test_url = get_certificate_url(course_id=self.cert.course_id, uuid=self.cert.verify_uuid)
response = self.client.get(test_url, HTTP_HOST='test.localhost')
assert response.status_code == 200
# the linkedIn share URL with appropriate parameters should be present
params = {
'name': 'My Platform Site Honor Code Certificate for {course_name}'.format(
course_name=self.course.display_name,
).encode('utf-8'),
'certUrl': 'http://test.localhost' + test_url,
'organizationId': 2448,
'certId': self.cert.verify_uuid,
'issueYear': self.cert.created_date.year,
'issueMonth': self.cert.created_date.month,
}
self.assertContains(
response,
js_escaped_string(self.linkedin_url.format(params=urlencode(params))),
)
@patch.dict("django.conf.settings.SOCIAL_SHARING_SETTINGS", {
"CERTIFICATE_FACEBOOK": True,
"CERTIFICATE_FACEBOOK_TEXT": "test FB text"
})
@override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED)
def test_render_certificate_html_view_with_facebook_meta_tags(self):
"""
Test view html certificate if share to FB is enabled.
If 'facebook_share_enabled=True', tags with property="og:..."
must be enabled to pass parameters to FB.
"""
self._add_course_certificates(count=1, signatory_count=1, is_active=True)
self.course.cert_html_view_enabled = True
self.course.save()
self.update_course(self.course, self.user.id)
test_url = get_certificate_url(
user_id=self.user.id,
course_id=str(self.course.id),
uuid=self.cert.verify_uuid
)
platform_name = settings.PLATFORM_NAME
share_url = f'http://testserver{test_url}'
full_course_image_url = f'http://testserver{course_image_url(self.course)}'
document_title = f'{self.course.org} {self.course.number} Certificate | {platform_name}'
response = self.client.get(test_url)
assert response.status_code == 200
self.assertContains(response, f'')
self.assertContains(response, f'')
self.assertContains(response, '')
self.assertContains(response, f'')
self.assertContains(response, '')
@patch.dict("django.conf.settings.SOCIAL_SHARING_SETTINGS", {
"CERTIFICATE_FACEBOOK": False,
})
@override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED)
def test_render_certificate_html_view_without_facebook_meta_tags(self):
"""
Test view html certificate if share to FB is disabled.
If 'facebook_share_enabled=False', html certificate view
should not contain tags with parameters property="og:..."
"""
self._add_course_certificates(count=1, signatory_count=1, is_active=True)
self.course.cert_html_view_enabled = True
self.course.save()
self.update_course(self.course, self.user.id)
test_url = get_certificate_url(
user_id=self.user.id,
course_id=str(self.course.id),
uuid=self.cert.verify_uuid
)
response = self.client.get(test_url)
assert response.status_code == 200
self.assertNotContains(response, '')
self.assertNotContains(response, 'test_organization {self.course.number} Certificate |')
self.assertContains(response, 'logo_test1.png')
@override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED)
@patch.dict("django.conf.settings.SOCIAL_SHARING_SETTINGS", {
"CERTIFICATE_TWITTER": True,
"CERTIFICATE_FACEBOOK": True,
})
@with_site_configuration(
configuration=dict(
platform_name='My Platform Site',
SITE_NAME='test_site.localhost',
urls=dict(
ABOUT='https://www.test-site.org/about-us',
),
),
)
def test_rendering_maximum_data(self):
"""
Tests at least one data item from different context update methods to
make sure every context update method is invoked while rendering certificate template.
"""
long_org_name = 'Long org name'
short_org_name = 'short_org_name'
test_organization_data = {
'name': long_org_name,
'short_name': short_org_name,
'description': 'Test Organization Description',
'active': True,
'logo': '/logo_test1.png'
}
test_org = organizations_api.add_organization(organization_data=test_organization_data)
organizations_api.add_organization_course(organization_data=test_org, course_key=str(self.course.id))
self._add_course_certificates(count=1, signatory_count=1, is_active=True)
self.course.cert_html_view_overrides = {
"logo_src": "/static/certificates/images/course_override_logo.png"
}
self.course.save()
self.update_course(self.course, self.user.id)
test_url = get_certificate_url(
user_id=self.user.id,
course_id=str(self.course.id),
uuid=self.cert.verify_uuid
)
response = self.client.get(test_url, HTTP_HOST='test.localhost')
# Test an item from basic info
self.assertContains(response, 'Terms of Service & Honor Code')
self.assertContains(response, 'Certificate ID Number')
# Test an item from html cert configuration
self.assertContains(response, '')
# Test an item from course info
self.assertContains(response, 'course_title_0')
# Test an item from user info
self.assertContains(response, f"{self.user.profile.name}, you earned a certificate!")
# Test an item from social info
self.assertContains(response, "Post on Facebook")
self.assertContains(response, "Share on Twitter")
# Test an item from certificate/org info
self.assertContains(
response,
"a course of study offered by {partner_short_name}, "
"an online learning initiative of "
"{partner_long_name}.".format(
partner_short_name=short_org_name,
partner_long_name=long_org_name,
),
)
# Test item from site configuration
self.assertContains(response, "https://www.test-site.org/about-us")
# Test course overrides
self.assertContains(response, "/static/certificates/images/course_override_logo.png")
@override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED)
def test_render_html_view_valid_certificate(self):
self._add_course_certificates(count=1, signatory_count=2)
test_url = get_certificate_url(
user_id=self.user.id,
course_id=str(self.course.id),
uuid=self.cert.verify_uuid
)
response = self.client.get(test_url)
self.assertContains(response, str(self.cert.verify_uuid))
# Hit any "verified" mode-specific branches
self.cert.mode = 'verified'
self.cert.save()
response = self.client.get(test_url)
self.assertContains(response, str(self.cert.verify_uuid))
# Hit any 'xseries' mode-specific branches
self.cert.mode = 'xseries'
self.cert.save()
response = self.client.get(test_url)
self.assertContains(response, str(self.cert.verify_uuid))
@override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED)
def test_render_certificate_only_for_downloadable_status(self):
"""
Tests that Certificate HTML Web View returns Certificate only if certificate status is 'downloadable',
for other statuses it should return "Invalid Certificate".
"""
self._add_course_certificates(count=1, signatory_count=2)
test_url = get_certificate_url(
user_id=self.user.id,
course_id=str(self.course.id),
uuid=self.cert.verify_uuid
)
# Validate certificate
response = self.client.get(test_url)
self.assertContains(response, str(self.cert.verify_uuid))
# Change status to 'generating' and validate that Certificate Web View returns "Invalid Certificate"
self.cert.status = CertificateStatuses.generating
self.cert.save()
response = self.client.get(test_url)
assert response.status_code == 404
@ddt.data(
(CertificateStatuses.downloadable, True),
(CertificateStatuses.audit_passing, False),
(CertificateStatuses.audit_notpassing, False),
)
@ddt.unpack
@override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED)
def test_audit_certificate_display(self, status, eligible_for_certificate):
"""
Ensure that audit-mode certs are only shown in the web view if they
are eligible for a certificate.
"""
# Convert the cert to audit, with the specified eligibility
self.cert.mode = 'audit'
self.cert.status = status
self.cert.save()
self._add_course_certificates(count=1, signatory_count=2)
test_url = get_certificate_url(
user_id=self.user.id,
course_id=str(self.course.id),
uuid=self.cert.verify_uuid
)
response = self.client.get(test_url)
if eligible_for_certificate:
self.assertContains(response, str(self.cert.verify_uuid))
else:
assert response.status_code == 404
@override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED)
def test_html_view_returns_404_for_invalid_certificate(self):
"""
Tests that Certificate HTML Web View successfully retrieves certificate only
if the certificate is not invalidated otherwise returns 404
"""
self._add_course_certificates(count=1, signatory_count=2)
test_url = get_certificate_url(
user_id=self.user.id,
course_id=str(self.course.id),
uuid=self.cert.verify_uuid
)
# Validate certificate
response = self.client.get(test_url)
self.assertContains(response, str(self.cert.verify_uuid))
# invalidate certificate and verify that "Cannot Find Certificate" is returned
self.cert.invalidate()
response = self.client.get(test_url)
assert response.status_code == 404
@override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED)
def test_html_lang_attribute_is_dynamic_for_certificate_html_view(self):
"""
Tests that Certificate HTML Web View's lang attribute is based on user language.
"""
self._add_course_certificates(count=1, signatory_count=2)
test_url = get_certificate_url(
user_id=self.user.id,
course_id=str(self.course.id),
uuid=self.cert.verify_uuid
)
user_language = 'fr'
self.client.cookies[settings.LANGUAGE_COOKIE_NAME] = user_language
response = self.client.get(test_url)
self.assertContains(response, '')
user_language = 'ar'
self.client.cookies[settings.LANGUAGE_COOKIE_NAME] = user_language
response = self.client.get(test_url)
self.assertContains(response, '')
@ddt.data(False, True)
@override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED)
def test_html_view_for_non_viewable_certificate_and_for_student_user(self, date_override):
"""
Tests that Certificate HTML Web View returns "Cannot Find Certificate"
if certificate is not viewable yet, regardless of certificate date
override
"""
test_certificates = [
{
'id': 0,
'name': 'Certificate Name 0',
'signatories': [],
'version': 1,
'is_active': True
}
]
# A certificate with an available date in the future should not be
# viewable, regardless of the date override.
if date_override:
self._add_certificate_date_override()
self.course.certificates = {'certificates': test_certificates}
self.course.cert_html_view_enabled = True
self.course.certificate_available_date = datetime.datetime.today() + datetime.timedelta(days=1)
self.course.save()
self.update_course(self.course, self.user.id)
test_url = get_certificate_url(
user_id=self.user.id,
course_id=str(self.course.id),
uuid=self.cert.verify_uuid
)
response = self.client.get(test_url)
self.assertContains(response, "Invalid Certificate")
self.assertContains(response, "Cannot Find Certificate")
self.assertContains(response, "We cannot find a certificate with this URL or ID number.")
@override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED)
def test_render_html_view_with_valid_signatories(self):
self._add_course_certificates(count=1, signatory_count=2)
test_url = get_certificate_url(
user_id=self.user.id,
course_id=str(self.course.id),
uuid=self.cert.verify_uuid
)
response = self.client.get(test_url)
self.assertContains(response, 'course_title_0')
self.assertContains(response, 'Signatory_Name 0')
self.assertContains(response, 'Signatory_Title 0')
self.assertContains(response, 'Signatory_Organization 0')
self.assertContains(response, '/static/certificates/images/demo-sig0.png')
@override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED)
def test_course_display_name_not_override_with_course_title(self):
# if certificate in block has not course_title then course name should not be overridden with this title.
test_certificates = [
{
'id': 0,
'name': 'Name 0',
'description': 'Description 0',
'signatories': [],
'version': 1,
'is_active':True
}
]
self.course.certificates = {'certificates': test_certificates}
self.course.cert_html_view_enabled = True
self.course.save()
self.update_course(self.course, self.user.id)
test_url = get_certificate_url(
user_id=self.user.id,
course_id=str(self.course.id),
uuid=self.cert.verify_uuid
)
response = self.client.get(test_url)
self.assertNotContains(response, 'test_course_title_0')
self.assertContains(response, 'refundable course')
@override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED)
def test_course_display_overrides(self):
"""
Tests if `Course Number Display String` or `Course Organization Display` is set for a course
in advance settings
Then web certificate should display that course number and course org set in advance
settings instead of original course number and course org.
"""
self._add_course_certificates(count=1, signatory_count=2)
test_url = get_certificate_url(
user_id=self.user.id,
course_id=str(self.course.id),
uuid=self.cert.verify_uuid
)
self.course.display_coursenumber = "overridden_number"
self.course.display_organization = "overridden_org"
self.update_course(self.course, self.user.id)
response = self.client.get(test_url)
self.assertContains(response, 'overridden_number')
self.assertContains(response, 'overridden_org')
@override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED)
def test_certificate_view_without_org_logo(self):
test_certificates = [
{
'id': 0,
'name': 'Certificate Name 0',
'signatories': [],
'version': 1,
'is_active': True
}
]
self.course.certificates = {'certificates': test_certificates}
self.course.cert_html_view_enabled = True
self.course.save()
self.update_course(self.course, self.user.id)
test_url = get_certificate_url(
user_id=self.user.id,
course_id=str(self.course.id),
uuid=self.cert.verify_uuid
)
response = self.client.get(test_url)
# make sure response html has only one organization logo container for edX
self.assertContains(response, "", 1)
@override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED)
def test_render_html_view_without_signatories(self):
self._add_course_certificates(count=1, signatory_count=0)
test_url = get_certificate_url(
user_id=self.user.id,
course_id=str(self.course.id),
uuid=self.cert.verify_uuid
)
response = self.client.get(test_url)
self.assertNotContains(response, 'Signatory_Name 0')
self.assertNotContains(response, 'Signatory_Title 0')
@override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED)
def test_render_html_view_is_html_escaped(self):
test_certificates = [
{
'id': 0,
'name': 'Certificate Name',
'description': '',
'course_title': '',
'org_logo_path': '/t4x/orgX/testX/asset/org-logo-1.png',
'signatories': [],
'version': 1,
'is_active': True
}
]
self.course.certificates = {'certificates': test_certificates}
self.course.cert_html_view_enabled = True
self.course.save()
self.update_course(self.course, self.user.id)
test_url = get_certificate_url(
user_id=self.user.id,
course_id=str(self.course.id),
uuid=self.cert.verify_uuid
)
response = self.client.get(test_url)
self.assertNotContains(response, '