Commit 141e0a93 by Brian Beggs

Merge branch 'master' into dj18-release-merge

parents 0e66fad1 290de455
...@@ -35,9 +35,6 @@ var CourseDetails = Backbone.Model.extend({ ...@@ -35,9 +35,6 @@ var CourseDetails = Backbone.Model.extend({
if (newattrs.start_date === null) { if (newattrs.start_date === null) {
errors.start_date = gettext("The course must have an assigned start date."); errors.start_date = gettext("The course must have an assigned start date.");
} }
if (this.hasChanged("start_date") && this.get("has_cert_config") === false){
errors.start_date = gettext("The course must have at least one active certificate configuration before it can be started.");
}
if (newattrs.start_date && newattrs.end_date && newattrs.start_date >= newattrs.end_date) { if (newattrs.start_date && newattrs.end_date && newattrs.start_date >= newattrs.end_date) {
errors.end_date = gettext("The course end date must be later than the course start date."); errors.end_date = gettext("The course end date must be later than the course start date.");
} }
......
...@@ -72,13 +72,6 @@ define([ ...@@ -72,13 +72,6 @@ define([
); );
}); });
it('Changing course start date without active certificate configuration should result in error', function () {
this.view.$el.find('#course-start-date')
.val('10/06/2014')
.trigger('change');
expect(this.view.$el.find('span.message-error').text()).toContain("course must have at least one active certificate configuration");
});
it('Selecting a course in pre-requisite drop down should save it as part of course details', function () { it('Selecting a course in pre-requisite drop down should save it as part of course details', function () {
var pre_requisite_courses = ['test/CSS101/2012_T1']; var pre_requisite_courses = ['test/CSS101/2012_T1'];
var requests = AjaxHelpers.requests(this), var requests = AjaxHelpers.requests(this),
......
...@@ -3,6 +3,9 @@ ...@@ -3,6 +3,9 @@
Tests the "preview" selector in the LMS that allows changing between Staff, Student, and Content Groups. Tests the "preview" selector in the LMS that allows changing between Staff, Student, and Content Groups.
""" """
from nose.plugins.attrib import attr
from ..helpers import UniqueCourseTest, create_user_partition_json from ..helpers import UniqueCourseTest, create_user_partition_json
from ...pages.studio.auto_auth import AutoAuthPage from ...pages.studio.auto_auth import AutoAuthPage
from ...pages.lms.courseware import CoursewarePage from ...pages.lms.courseware import CoursewarePage
...@@ -14,6 +17,7 @@ from xmodule.partitions.partitions import Group ...@@ -14,6 +17,7 @@ from xmodule.partitions.partitions import Group
from textwrap import dedent from textwrap import dedent
@attr('shard_3')
class StaffViewTest(UniqueCourseTest): class StaffViewTest(UniqueCourseTest):
""" """
Tests that verify the staff view. Tests that verify the staff view.
...@@ -51,6 +55,7 @@ class StaffViewTest(UniqueCourseTest): ...@@ -51,6 +55,7 @@ class StaffViewTest(UniqueCourseTest):
return staff_page return staff_page
@attr('shard_3')
class CourseWithoutContentGroupsTest(StaffViewTest): class CourseWithoutContentGroupsTest(StaffViewTest):
""" """
Setup for tests that have no content restricted to specific content groups. Setup for tests that have no content restricted to specific content groups.
...@@ -81,6 +86,7 @@ class CourseWithoutContentGroupsTest(StaffViewTest): ...@@ -81,6 +86,7 @@ class CourseWithoutContentGroupsTest(StaffViewTest):
) )
@attr('shard_3')
class StaffViewToggleTest(CourseWithoutContentGroupsTest): class StaffViewToggleTest(CourseWithoutContentGroupsTest):
""" """
Tests for the staff view toggle button. Tests for the staff view toggle button.
...@@ -97,6 +103,7 @@ class StaffViewToggleTest(CourseWithoutContentGroupsTest): ...@@ -97,6 +103,7 @@ class StaffViewToggleTest(CourseWithoutContentGroupsTest):
self.assertFalse(course_page.has_tab('Instructor')) self.assertFalse(course_page.has_tab('Instructor'))
@attr('shard_3')
class StaffDebugTest(CourseWithoutContentGroupsTest): class StaffDebugTest(CourseWithoutContentGroupsTest):
""" """
Tests that verify the staff debug info. Tests that verify the staff debug info.
...@@ -228,6 +235,7 @@ class StaffDebugTest(CourseWithoutContentGroupsTest): ...@@ -228,6 +235,7 @@ class StaffDebugTest(CourseWithoutContentGroupsTest):
'for user {}'.format(self.USERNAME), msg) 'for user {}'.format(self.USERNAME), msg)
@attr('shard_3')
class CourseWithContentGroupsTest(StaffViewTest): class CourseWithContentGroupsTest(StaffViewTest):
""" """
Verifies that changing the "View this course as" selector works properly for content groups. Verifies that changing the "View this course as" selector works properly for content groups.
......
""" """
Acceptance tests for the Import and Export pages Acceptance tests for the Import and Export pages
""" """
from nose.plugins.attrib import attr
from datetime import datetime from datetime import datetime
from abc import abstractmethod from abc import abstractmethod
...@@ -33,6 +34,7 @@ class ExportTestMixin(object): ...@@ -33,6 +34,7 @@ class ExportTestMixin(object):
self.assertTrue(is_tarball_mimetype) self.assertTrue(is_tarball_mimetype)
@attr('shard_4')
class TestCourseExport(ExportTestMixin, StudioCourseTest): class TestCourseExport(ExportTestMixin, StudioCourseTest):
""" """
Export tests for courses. Export tests for courses.
...@@ -55,6 +57,7 @@ class TestCourseExport(ExportTestMixin, StudioCourseTest): ...@@ -55,6 +57,7 @@ class TestCourseExport(ExportTestMixin, StudioCourseTest):
self.assertEqual(self.export_page.header_text, 'Course Export') self.assertEqual(self.export_page.header_text, 'Course Export')
@attr('shard_4')
class TestLibraryExport(ExportTestMixin, StudioLibraryTest): class TestLibraryExport(ExportTestMixin, StudioLibraryTest):
""" """
Export tests for libraries. Export tests for libraries.
...@@ -103,6 +106,7 @@ class BadExportMixin(object): ...@@ -103,6 +106,7 @@ class BadExportMixin(object):
) )
@attr('shard_4')
class TestLibraryBadExport(BadExportMixin, StudioLibraryTest): class TestLibraryBadExport(BadExportMixin, StudioLibraryTest):
""" """
Verify exporting a bad library causes an error. Verify exporting a bad library causes an error.
...@@ -126,6 +130,7 @@ class TestLibraryBadExport(BadExportMixin, StudioLibraryTest): ...@@ -126,6 +130,7 @@ class TestLibraryBadExport(BadExportMixin, StudioLibraryTest):
) )
@attr('shard_4')
class TestCourseBadExport(BadExportMixin, StudioCourseTest): class TestCourseBadExport(BadExportMixin, StudioCourseTest):
""" """
Verify exporting a bad course causes an error. Verify exporting a bad course causes an error.
...@@ -157,6 +162,7 @@ class TestCourseBadExport(BadExportMixin, StudioCourseTest): ...@@ -157,6 +162,7 @@ class TestCourseBadExport(BadExportMixin, StudioCourseTest):
) )
@attr('shard_4')
class ImportTestMixin(object): class ImportTestMixin(object):
""" """
Tests to run for both course and library import pages. Tests to run for both course and library import pages.
...@@ -271,6 +277,7 @@ class ImportTestMixin(object): ...@@ -271,6 +277,7 @@ class ImportTestMixin(object):
self.import_page.wait_for_tasks(fail_on='Updating') self.import_page.wait_for_tasks(fail_on='Updating')
@attr('shard_4')
class TestEntranceExamCourseImport(ImportTestMixin, StudioCourseTest): class TestEntranceExamCourseImport(ImportTestMixin, StudioCourseTest):
""" """
Tests the Course import page Tests the Course import page
...@@ -316,6 +323,7 @@ class TestEntranceExamCourseImport(ImportTestMixin, StudioCourseTest): ...@@ -316,6 +323,7 @@ class TestEntranceExamCourseImport(ImportTestMixin, StudioCourseTest):
) )
@attr('shard_4')
class TestCourseImport(ImportTestMixin, StudioCourseTest): class TestCourseImport(ImportTestMixin, StudioCourseTest):
""" """
Tests the Course import page Tests the Course import page
...@@ -357,6 +365,7 @@ class TestCourseImport(ImportTestMixin, StudioCourseTest): ...@@ -357,6 +365,7 @@ class TestCourseImport(ImportTestMixin, StudioCourseTest):
self.assertEqual(self.import_page.header_text, 'Course Import') self.assertEqual(self.import_page.header_text, 'Course Import')
@attr('shard_4')
class TestLibraryImport(ImportTestMixin, StudioLibraryTest): class TestLibraryImport(ImportTestMixin, StudioLibraryTest):
""" """
Tests the Library import page Tests the Library import page
......
...@@ -312,6 +312,7 @@ class EditContainerTest(NestedVerticalTest): ...@@ -312,6 +312,7 @@ class EditContainerTest(NestedVerticalTest):
self.assertEqual(component.student_content, "modified content") self.assertEqual(component.student_content, "modified content")
@attr('shard_3')
class EditVisibilityModalTest(ContainerBase): class EditVisibilityModalTest(ContainerBase):
""" """
Tests of the visibility settings modal for components on the unit Tests of the visibility settings modal for components on the unit
...@@ -397,6 +398,7 @@ class EditVisibilityModalTest(ContainerBase): ...@@ -397,6 +398,7 @@ class EditVisibilityModalTest(ContainerBase):
# Re-open the modal and inspect its selected inputs # Re-open the modal and inspect its selected inputs
visibility_editor = self.edit_component_visibility(component) visibility_editor = self.edit_component_visibility(component)
self.verify_selected_labels(visibility_editor, expected_labels) self.verify_selected_labels(visibility_editor, expected_labels)
visibility_editor.save()
def verify_component_validation_error(self, component): def verify_component_validation_error(self, component):
""" """
...@@ -427,14 +429,13 @@ class EditVisibilityModalTest(ContainerBase): ...@@ -427,14 +429,13 @@ class EditVisibilityModalTest(ContainerBase):
self.browser.refresh() self.browser.refresh()
self.container_page.wait_for_page() self.container_page.wait_for_page()
def remove_missing_groups(self, component): def remove_missing_groups(self, visibility_editor, component):
""" """
Deselect the missing groups for a component. After save, Deselect the missing groups for a component. After save,
verify that there are no missing group messages in the modal verify that there are no missing group messages in the modal
and that there is no validation error on the component. and that there is no validation error on the component.
""" """
visibility_editor = self.edit_component_visibility(component) for option in visibility_editor.selected_options:
for option in self.edit_component_visibility(component).selected_options:
if option.text == self.MISSING_GROUP_LABEL: if option.text == self.MISSING_GROUP_LABEL:
option.click() option.click()
visibility_editor.save() visibility_editor.save()
...@@ -541,7 +542,7 @@ class EditVisibilityModalTest(ContainerBase): ...@@ -541,7 +542,7 @@ class EditVisibilityModalTest(ContainerBase):
self.verify_component_validation_error(self.html_component) self.verify_component_validation_error(self.html_component)
visibility_editor = self.edit_component_visibility(self.html_component) visibility_editor = self.edit_component_visibility(self.html_component)
self.verify_selected_labels(visibility_editor, [self.MISSING_GROUP_LABEL] * 2) self.verify_selected_labels(visibility_editor, [self.MISSING_GROUP_LABEL] * 2)
self.remove_missing_groups(self.html_component) self.remove_missing_groups(visibility_editor, self.html_component)
self.verify_visibility_set(self.html_component, False) self.verify_visibility_set(self.html_component, False)
def test_found_and_missing_groups(self): def test_found_and_missing_groups(self):
...@@ -565,7 +566,7 @@ class EditVisibilityModalTest(ContainerBase): ...@@ -565,7 +566,7 @@ class EditVisibilityModalTest(ContainerBase):
self.verify_component_validation_error(self.html_component) self.verify_component_validation_error(self.html_component)
visibility_editor = self.edit_component_visibility(self.html_component) visibility_editor = self.edit_component_visibility(self.html_component)
self.verify_selected_labels(visibility_editor, ['Dogs', 'Cats'] + [self.MISSING_GROUP_LABEL] * 2) self.verify_selected_labels(visibility_editor, ['Dogs', 'Cats'] + [self.MISSING_GROUP_LABEL] * 2)
self.remove_missing_groups(self.html_component) self.remove_missing_groups(visibility_editor, self.html_component)
visibility_editor = self.edit_component_visibility(self.html_component) visibility_editor = self.edit_component_visibility(self.html_component)
self.verify_selected_labels(visibility_editor, ['Dogs', 'Cats']) self.verify_selected_labels(visibility_editor, ['Dogs', 'Cats'])
self.verify_visibility_set(self.html_component, True) self.verify_visibility_set(self.html_component, True)
...@@ -1041,6 +1042,7 @@ class UnitPublishingTest(ContainerBase): ...@@ -1041,6 +1042,7 @@ class UnitPublishingTest(ContainerBase):
# self.assertEqual('discussion', self.courseware.xblock_component_type(1)) # self.assertEqual('discussion', self.courseware.xblock_component_type(1))
@attr('shard_3')
class DisplayNameTest(ContainerBase): class DisplayNameTest(ContainerBase):
""" """
Test consistent use of display_name_with_default Test consistent use of display_name_with_default
...@@ -1077,6 +1079,7 @@ class DisplayNameTest(ContainerBase): ...@@ -1077,6 +1079,7 @@ class DisplayNameTest(ContainerBase):
self.assertEqual(container.name, title_on_unit_page) self.assertEqual(container.name, title_on_unit_page)
@attr('shard_3')
class ProblemCategoryTabsTest(ContainerBase): class ProblemCategoryTabsTest(ContainerBase):
""" """
Test to verify tabs in problem category. Test to verify tabs in problem category.
......
...@@ -1755,6 +1755,7 @@ class DeprecationWarningMessageTest(CourseOutlineTest): ...@@ -1755,6 +1755,7 @@ class DeprecationWarningMessageTest(CourseOutlineTest):
) )
@attr('shard_4')
class SelfPacedOutlineTest(CourseOutlineTest): class SelfPacedOutlineTest(CourseOutlineTest):
"""Test the course outline for a self-paced course.""" """Test the course outline for a self-paced course."""
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
Acceptance tests for Studio's Settings Details pages Acceptance tests for Studio's Settings Details pages
""" """
from datetime import datetime, timedelta from datetime import datetime, timedelta
from nose.plugins.attrib import attr
from unittest import skip from unittest import skip
from .base_studio_test import StudioCourseTest from .base_studio_test import StudioCourseTest
...@@ -18,6 +19,7 @@ from ..helpers import ( ...@@ -18,6 +19,7 @@ from ..helpers import (
) )
@attr('shard_4')
class StudioSettingsDetailsTest(StudioCourseTest): class StudioSettingsDetailsTest(StudioCourseTest):
"""Base class for settings and details page tests.""" """Base class for settings and details page tests."""
...@@ -35,6 +37,7 @@ class StudioSettingsDetailsTest(StudioCourseTest): ...@@ -35,6 +37,7 @@ class StudioSettingsDetailsTest(StudioCourseTest):
self.assertTrue(self.settings_detail.is_browser_on_page()) self.assertTrue(self.settings_detail.is_browser_on_page())
@attr('shard_4')
class SettingsMilestonesTest(StudioSettingsDetailsTest): class SettingsMilestonesTest(StudioSettingsDetailsTest):
""" """
Tests for milestones feature in Studio's settings tab Tests for milestones feature in Studio's settings tab
...@@ -201,6 +204,7 @@ class SettingsMilestonesTest(StudioSettingsDetailsTest): ...@@ -201,6 +204,7 @@ class SettingsMilestonesTest(StudioSettingsDetailsTest):
)) ))
@attr('shard_4')
class CoursePacingTest(StudioSettingsDetailsTest): class CoursePacingTest(StudioSettingsDetailsTest):
"""Tests for setting a course to self-paced.""" """Tests for setting a course to self-paced."""
......
...@@ -38,6 +38,11 @@ class BadgeAssertionFactory(DjangoModelFactory): ...@@ -38,6 +38,11 @@ class BadgeAssertionFactory(DjangoModelFactory):
model = BadgeAssertion model = BadgeAssertion
mode = 'honor' mode = 'honor'
data = {
'image': 'http://www.example.com/image.png',
'json': {'id': 'http://www.example.com/assertion.json'},
'issuer': 'http://www.example.com/issuer.json',
}
class BadgeImageConfigurationFactory(DjangoModelFactory): class BadgeImageConfigurationFactory(DjangoModelFactory):
...@@ -75,7 +80,8 @@ class CertificateHtmlViewConfigurationFactory(DjangoModelFactory): ...@@ -75,7 +80,8 @@ class CertificateHtmlViewConfigurationFactory(DjangoModelFactory):
}, },
"honor": { "honor": {
"certificate_type": "Honor Code", "certificate_type": "Honor Code",
"certificate_title": "Certificate of Achievement" "certificate_title": "Certificate of Achievement",
"logo_url": "http://www.edx.org/honor_logo.png"
}, },
"verified": { "verified": {
"certificate_type": "Verified", "certificate_type": "Verified",
...@@ -84,6 +90,13 @@ class CertificateHtmlViewConfigurationFactory(DjangoModelFactory): ...@@ -84,6 +90,13 @@ class CertificateHtmlViewConfigurationFactory(DjangoModelFactory):
"xseries": { "xseries": {
"certificate_title": "XSeries Certificate of Achievement", "certificate_title": "XSeries Certificate of Achievement",
"certificate_type": "XSeries" "certificate_type": "XSeries"
},
"microsites": {
"testmicrosite": {
"company_about_url": "http://www.testmicrosite.org/about-us",
"company_privacy_url": "http://www.testmicrosite.org/edx-privacy-policy",
"company_tos_url": "http://www.testmicrosite.org/edx-terms-service"
}
} }
}""" }"""
......
...@@ -187,16 +187,6 @@ class UpdateExampleCertificateViewTest(TestCase): ...@@ -187,16 +187,6 @@ class UpdateExampleCertificateViewTest(TestCase):
self.assertEqual(content['return_code'], 0) self.assertEqual(content['return_code'], 0)
def fakemicrosite(name, default=None):
"""
This is a test mocking function to return a microsite configuration
"""
if name == 'microsite_config_key':
return 'test_microsite'
else:
return default
@attr('shard_1') @attr('shard_1')
class MicrositeCertificatesViewsTests(ModuleStoreTestCase): class MicrositeCertificatesViewsTests(ModuleStoreTestCase):
""" """
...@@ -270,7 +260,6 @@ class MicrositeCertificatesViewsTests(ModuleStoreTestCase): ...@@ -270,7 +260,6 @@ class MicrositeCertificatesViewsTests(ModuleStoreTestCase):
self.course.save() self.course.save()
self.store.update_item(self.course, self.user.id) self.store.update_item(self.course, self.user.id)
@patch("microsite_configuration.microsite.get_value", fakemicrosite)
@override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED) @override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED)
def test_html_view_for_microsite(self): def test_html_view_for_microsite(self):
test_configuration_string = """{ test_configuration_string = """{
...@@ -285,18 +274,20 @@ class MicrositeCertificatesViewsTests(ModuleStoreTestCase): ...@@ -285,18 +274,20 @@ class MicrositeCertificatesViewsTests(ModuleStoreTestCase):
"logo_src": "/static/certificates/images/logo-edx.svg", "logo_src": "/static/certificates/images/logo-edx.svg",
"logo_url": "http://www.edx.org" "logo_url": "http://www.edx.org"
}, },
"test_microsite": { "microsites": {
"accomplishment_class_append": "accomplishment-certificate", "testmicrosite": {
"platform_name": "platform_microsite", "accomplishment_class_append": "accomplishment-certificate",
"company_about_url": "http://www.microsite.org/about-us", "platform_name": "platform_microsite",
"company_privacy_url": "http://www.microsite.org/edx-privacy-policy", "company_about_url": "http://www.microsite.org/about-us",
"company_tos_url": "http://www.microsite.org/microsite-terms-service", "company_privacy_url": "http://www.microsite.org/edx-privacy-policy",
"company_verified_certificate_url": "http://www.microsite.org/verified-certificate", "company_tos_url": "http://www.microsite.org/microsite-terms-service",
"document_stylesheet_url_application": "/static/certificates/sass/main-ltr.css", "company_verified_certificate_url": "http://www.microsite.org/verified-certificate",
"logo_src": "/static/certificates/images/logo-microsite.svg", "document_stylesheet_url_application": "/static/certificates/sass/main-ltr.css",
"logo_url": "http://www.microsite.org", "logo_src": "/static/certificates/images/logo-microsite.svg",
"company_about_description": "This is special microsite aware company_about_description content", "logo_url": "http://www.microsite.org",
"company_about_title": "Microsite title" "company_about_description": "This is special microsite aware company_about_description content",
"company_about_title": "Microsite title"
}
}, },
"honor": { "honor": {
"certificate_type": "Honor Code" "certificate_type": "Honor Code"
...@@ -310,13 +301,12 @@ class MicrositeCertificatesViewsTests(ModuleStoreTestCase): ...@@ -310,13 +301,12 @@ class MicrositeCertificatesViewsTests(ModuleStoreTestCase):
course_id=unicode(self.course.id) course_id=unicode(self.course.id)
) )
self._add_course_certificates(count=1, signatory_count=2) self._add_course_certificates(count=1, signatory_count=2)
response = self.client.get(test_url) response = self.client.get(test_url, HTTP_HOST=settings.MICROSITE_TEST_HOSTNAME)
self.assertIn('platform_microsite', response.content) self.assertIn('platform_microsite', response.content)
self.assertIn('http://www.microsite.org', response.content) self.assertIn('http://www.microsite.org', response.content)
self.assertIn('This is special microsite aware company_about_description content', response.content) self.assertIn('This is special microsite aware company_about_description content', response.content)
self.assertIn('Microsite title', response.content) self.assertIn('Microsite title', response.content)
@patch("microsite_configuration.microsite.get_value", fakemicrosite)
@override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED) @override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED)
def test_html_view_microsite_configuration_missing(self): def test_html_view_microsite_configuration_missing(self):
test_configuration_string = """{ test_configuration_string = """{
...@@ -343,7 +333,7 @@ class MicrositeCertificatesViewsTests(ModuleStoreTestCase): ...@@ -343,7 +333,7 @@ class MicrositeCertificatesViewsTests(ModuleStoreTestCase):
course_id=unicode(self.course.id) course_id=unicode(self.course.id)
) )
self._add_course_certificates(count=1, signatory_count=2) self._add_course_certificates(count=1, signatory_count=2)
response = self.client.get(test_url) response = self.client.get(test_url, HTTP_HOST=settings.MICROSITE_TEST_HOSTNAME)
self.assertIn('edX', response.content) self.assertIn('edX', response.content)
self.assertNotIn('platform_microsite', response.content) self.assertNotIn('platform_microsite', response.content)
self.assertNotIn('http://www.microsite.org', response.content) self.assertNotIn('http://www.microsite.org', response.content)
......
...@@ -23,7 +23,6 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase ...@@ -23,7 +23,6 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from certificates.api import get_certificate_url from certificates.api import get_certificate_url
from certificates.models import ( from certificates.models import (
GeneratedCertificate, GeneratedCertificate,
BadgeAssertion,
CertificateStatuses, CertificateStatuses,
CertificateSocialNetworks, CertificateSocialNetworks,
CertificateTemplate, CertificateTemplate,
...@@ -33,6 +32,7 @@ from certificates.models import ( ...@@ -33,6 +32,7 @@ from certificates.models import (
from certificates.tests.factories import ( from certificates.tests.factories import (
CertificateHtmlViewConfigurationFactory, CertificateHtmlViewConfigurationFactory,
LinkedInAddToProfileConfigurationFactory, LinkedInAddToProfileConfigurationFactory,
BadgeAssertionFactory,
) )
from util import organizations_helpers as organizations_api from util import organizations_helpers as organizations_api
from django.test.client import RequestFactory from django.test.client import RequestFactory
...@@ -222,6 +222,104 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase): ...@@ -222,6 +222,104 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase):
self.assertIn('logo_test1.png', response.content) self.assertIn('logo_test1.png', response.content)
@override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED) @override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED)
@patch.dict("django.conf.settings.SOCIAL_SHARING_SETTINGS", {
"CERTIFICATE_TWITTER": True,
"CERTIFICATE_FACEBOOK": True,
})
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_id=unicode(self.course.id))
self._add_course_certificates(count=1, signatory_count=1, is_active=True)
BadgeAssertionFactory.create(
user=self.user, course_id=self.course_id,
)
self.course.cert_html_view_overrides = {
"logo_src": "/static/certificates/images/course_override_logo.png"
}
self.course.save()
self.store.update_item(self.course, self.user.id)
test_url = get_certificate_url(
user_id=self.user.id,
course_id=unicode(self.course.id)
)
response = self.client.get(test_url, HTTP_HOST=settings.MICROSITE_TEST_HOSTNAME)
# Test an item from basic info
self.assertIn(
'Terms of Service & Honor Code',
response.content
)
self.assertIn(
'Certificate ID Number',
response.content
)
# Test an item from html cert configuration
self.assertIn(
'<a class="logo" href="http://www.edx.org/honor_logo.png">',
response.content
)
# Test an item from course info
self.assertIn(
'course_title_0',
response.content
)
# Test an item from user info
self.assertIn(
"{fullname}, you've earned a certificate!".format(fullname=self.user.profile.name),
response.content
)
# Test an item from social info
self.assertIn(
"Post on Facebook",
response.content
)
self.assertIn(
"Share on Twitter",
response.content
)
# Test an item from certificate/org info
self.assertIn(
"a course of study offered by {partner_short_name}, "
"an online learning initiative of {partner_long_name} "
"through {platform_name}.".format(
partner_short_name=short_org_name,
partner_long_name=long_org_name,
platform_name='Test Microsite'
),
response.content
)
# Test item from badge info
self.assertIn(
"Add to Mozilla Backpack",
response.content
)
# Test item from microsite info
self.assertIn(
"http://www.testmicrosite.org/about-us",
response.content
)
# Test course overrides
self.assertIn(
"/static/certificates/images/course_override_logo.png",
response.content
)
@override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED)
def test_render_html_view_valid_certificate(self): def test_render_html_view_valid_certificate(self):
test_url = get_certificate_url( test_url = get_certificate_url(
user_id=self.user.id, user_id=self.user.id,
...@@ -398,7 +496,7 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase): ...@@ -398,7 +496,7 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase):
course_id=unicode(self.course.id) course_id=unicode(self.course.id)
) )
response = self.client.get(test_url + '?preview=honor') response = self.client.get(test_url + '?preview=honor')
#accessing certificate web view in preview mode without # accessing certificate web view in preview mode without
# staff or instructor access should show invalid certificate # staff or instructor access should show invalid certificate
self.assertIn('Cannot Find Certificate', response.content) self.assertIn('Cannot Find Certificate', response.content)
...@@ -495,16 +593,9 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase): ...@@ -495,16 +593,9 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase):
test_url = '{}?evidence_visit=1'.format(cert_url) test_url = '{}?evidence_visit=1'.format(cert_url)
self._add_course_certificates(count=1, signatory_count=2) self._add_course_certificates(count=1, signatory_count=2)
self.recreate_tracker() self.recreate_tracker()
assertion = BadgeAssertion( assertion = BadgeAssertionFactory.create(
user=self.user, course_id=self.course_id, mode='honor', user=self.user, course_id=self.course_id,
data={
'image': 'http://www.example.com/image.png',
'json': {'id': 'http://www.example.com/assertion.json'},
'issuer': 'http://www.example.com/issuer.json',
}
) )
assertion.save()
response = self.client.get(test_url) response = self.client.get(test_url)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
assert_event_matches( assert_event_matches(
......
# pylint: disable=bad-continuation
""" """
Certificate HTML webview. Certificate HTML webview.
""" """
...@@ -26,6 +27,7 @@ from student.models import LinkedInAddToProfileConfiguration ...@@ -26,6 +27,7 @@ from student.models import LinkedInAddToProfileConfiguration
from util import organizations_helpers as organization_api from util import organizations_helpers as organization_api
from util.views import handle_500 from util.views import handle_500
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
from certificates.api import ( from certificates.api import (
get_active_web_certificate, get_active_web_certificate,
...@@ -44,13 +46,6 @@ from certificates.models import ( ...@@ -44,13 +46,6 @@ from certificates.models import (
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
class CourseDoesNotExist(Exception):
"""
This exception is raised in the case where None is returned from the modulestore
"""
pass
def get_certificate_description(mode, certificate_type, platform_name): def get_certificate_description(mode, certificate_type, platform_name):
""" """
:return certificate_type_description on the basis of current mode :return certificate_type_description on the basis of current mode
...@@ -81,63 +76,13 @@ def get_certificate_description(mode, certificate_type, platform_name): ...@@ -81,63 +76,13 @@ def get_certificate_description(mode, certificate_type, platform_name):
return certificate_type_description return certificate_type_description
# pylint: disable=bad-continuation def _update_certificate_context(context, user_certificate, platform_name):
# pylint: disable=too-many-statements
def _update_certificate_context(context, course, user, user_certificate):
""" """
Build up the certificate web view context using the provided values Build up the certificate web view context using the provided values
(Helper method to keep the view clean) (Helper method to keep the view clean)
""" """
# Populate dynamic output values using the course/certificate data loaded above # Populate dynamic output values using the course/certificate data loaded above
user_fullname = user.profile.name
platform_name = microsite.get_value("platform_name", settings.PLATFORM_NAME)
certificate_type = context.get('certificate_type') certificate_type = context.get('certificate_type')
partner_short_name = course.display_organization if course.display_organization else course.org
partner_long_name = None
organizations = organization_api.get_course_organizations(course_id=course.id)
if organizations:
#TODO Need to add support for multiple organizations, Currently we are interested in the first one.
organization = organizations[0]
partner_long_name = organization.get('name', partner_long_name)
partner_short_name = organization.get('short_name', partner_short_name)
context['organization_long_name'] = partner_long_name
context['organization_short_name'] = partner_short_name
context['organization_logo'] = organization.get('logo', None)
context['username'] = user.username
context['course_mode'] = user_certificate.mode
context['accomplishment_user_id'] = user.id
context['accomplishment_copy_name'] = user_fullname
context['accomplishment_copy_username'] = user.username
context['accomplishment_copy_course_org'] = partner_short_name
course_title_from_cert = context['certificate_data'].get('course_title', '')
accomplishment_copy_course_name = course_title_from_cert if course_title_from_cert else course.display_name
context['accomplishment_copy_course_name'] = accomplishment_copy_course_name
share_settings = getattr(settings, 'SOCIAL_SHARING_SETTINGS', {})
context['facebook_share_enabled'] = share_settings.get('CERTIFICATE_FACEBOOK', False)
context['facebook_app_id'] = getattr(settings, "FACEBOOK_APP_ID", None)
context['facebook_share_text'] = share_settings.get(
'CERTIFICATE_FACEBOOK_TEXT',
_("I completed the {course_title} course on {platform_name}.").format(
course_title=accomplishment_copy_course_name,
platform_name=platform_name
)
)
context['twitter_share_enabled'] = share_settings.get('CERTIFICATE_TWITTER', False)
context['twitter_share_text'] = share_settings.get(
'CERTIFICATE_TWITTER_TEXT',
_("I completed a course on {platform_name}. Take a look at my certificate.").format(
platform_name=platform_name
)
)
course_number = course.display_coursenumber if course.display_coursenumber else course.number
context['course_number'] = course_number
try:
badge = BadgeAssertion.objects.get(user=user, course_id=course.location.course_key)
except BadgeAssertion.DoesNotExist:
badge = None
context['badge'] = badge
# Override the defaults with any mode-specific static values # Override the defaults with any mode-specific static values
context['certificate_id_number'] = user_certificate.verify_uuid context['certificate_id_number'] = user_certificate.verify_uuid
...@@ -154,39 +99,33 @@ def _update_certificate_context(context, course, user, user_certificate): ...@@ -154,39 +99,33 @@ def _update_certificate_context(context, course, user, user_certificate):
year=user_certificate.modified_date.year year=user_certificate.modified_date.year
) )
if partner_long_name: # Translators: This text represents the verification of the certificate
context['accomplishment_copy_course_description'] = _('a course of study offered by {partner_short_name}, an ' context['document_meta_description'] = _('This is a valid {platform_name} certificate for {user_name}, '
'online learning initiative of {partner_long_name} ' 'who participated in {partner_short_name} {course_number}').format(
'through {platform_name}.').format( platform_name=platform_name,
partner_short_name=partner_short_name, user_name=context['accomplishment_copy_name'],
partner_long_name=partner_long_name, partner_short_name=context['organization_short_name'],
platform_name=platform_name course_number=context['course_number']
)
else:
context['accomplishment_copy_course_description'] = _('a course of study offered by {partner_short_name}, '
'through {platform_name}.').format(
partner_short_name=partner_short_name,
platform_name=platform_name
)
# Translators: Accomplishments describe the awards/certifications obtained by students on this platform
context['accomplishment_copy_about'] = _('About {platform_name} Accomplishments').format(
platform_name=platform_name
) )
context['accomplishment_more_title'] = _("More Information About {user_name}'s Certificate:").format( # Translators: This text is bound to the HTML 'title' element of the page and appears in the browser title bar
user_name=user_fullname context['document_title'] = _("{partner_short_name} {course_number} Certificate | {platform_name}").format(
partner_short_name=context['organization_short_name'],
course_number=context['course_number'],
platform_name=platform_name
) )
# Translators: This line appears on the page just before the generation date for the certificate # Translators: This text fragment appears after the student's name (displayed in a large font) on the certificate
context['certificate_date_issued_title'] = _("Issued On:") # screen. The text describes the accomplishment represented by the certificate information displayed to the user
context['accomplishment_copy_description_full'] = _("successfully completed, received a passing grade, and was "
# Translators: The Certificate ID Number is an alphanumeric value unique to each individual certificate "awarded a {platform_name} {certificate_type} "
context['certificate_id_number_title'] = _('Certificate ID Number') "Certificate of Completion in ").format(
platform_name=platform_name,
certificate_type=context.get("certificate_type"))
context['certificate_info_title'] = _('About {platform_name} Certificates').format( certificate_type_description = get_certificate_description(user_certificate.mode, certificate_type, platform_name)
platform_name=platform_name if certificate_type_description:
) context['certificate_type_description'] = certificate_type_description
# Translators: This text describes the purpose (and therefore, value) of a course certificate # Translators: This text describes the purpose (and therefore, value) of a course certificate
# 'verifying your identity' refers to the process for establishing the authenticity of the student # 'verifying your identity' refers to the process for establishing the authenticity of the student
...@@ -197,7 +136,54 @@ def _update_certificate_context(context, course, user, user_certificate): ...@@ -197,7 +136,54 @@ def _update_certificate_context(context, course, user, user_certificate):
"<a href='{verified_cert_url}'> verifying your identity</a>.").format( "<a href='{verified_cert_url}'> verifying your identity</a>.").format(
platform_name=platform_name, platform_name=platform_name,
tos_url=context.get('company_tos_url'), tos_url=context.get('company_tos_url'),
verified_cert_url=context.get('company_verified_certificate_url') verified_cert_url=context.get('company_verified_certificate_url'))
def _update_context_with_basic_info(context, course_id, platform_name, configuration):
"""
Updates context dictionary with basic info required before rendering simplest
certificate templates.
"""
context['platform_name'] = platform_name
context['course_id'] = course_id
# Update the view context with the default ConfigurationModel settings
context.update(configuration.get('default', {}))
# Translators: 'All rights reserved' is a legal term used in copyrighting to protect published content
reserved = _("All rights reserved")
context['copyright_text'] = '&copy; {year} {platform_name}. {reserved}.'.format(
year=settings.COPYRIGHT_YEAR,
platform_name=platform_name,
reserved=reserved
)
# Translators: This text is bound to the HTML 'title' element of the page and appears
# in the browser title bar when a requested certificate is not found or recognized
context['document_title'] = _("Invalid Certificate")
# Translators: The &amp; characters represent an ampersand character and can be ignored
context['company_tos_urltext'] = _("Terms of Service &amp; Honor Code")
# Translators: A 'Privacy Policy' is a legal document/statement describing a website's use of personal information
context['company_privacy_urltext'] = _("Privacy Policy")
# Translators: This line appears as a byline to a header image and describes the purpose of the page
context['logo_subtitle'] = _("Certificate Validation")
# Translators: Accomplishments describe the awards/certifications obtained by students on this platform
context['accomplishment_copy_about'] = _('About {platform_name} Accomplishments').format(
platform_name=platform_name
)
# Translators: This line appears on the page just before the generation date for the certificate
context['certificate_date_issued_title'] = _("Issued On:")
# Translators: The Certificate ID Number is an alphanumeric value unique to each individual certificate
context['certificate_id_number_title'] = _('Certificate ID Number')
context['certificate_info_title'] = _('About {platform_name} Certificates').format(
platform_name=platform_name
) )
context['certificate_verify_title'] = _("How {platform_name} Validates Student Certificates").format( context['certificate_verify_title'] = _("How {platform_name} Validates Student Certificates").format(
...@@ -218,8 +204,7 @@ def _update_certificate_context(context, course, user, user_certificate): ...@@ -218,8 +204,7 @@ def _update_certificate_context(context, course, user, user_certificate):
"world's best universities, including MIT, Harvard, Berkeley, University " "world's best universities, including MIT, Harvard, Berkeley, University "
"of Texas, and many others. {platform_name} is a non-profit online " "of Texas, and many others. {platform_name} is a non-profit online "
"initiative created by founding partners Harvard and MIT.").format( "initiative created by founding partners Harvard and MIT.").format(
platform_name=platform_name platform_name=platform_name)
)
context['company_about_title'] = _("About {platform_name}").format(platform_name=platform_name) context['company_about_title'] = _("About {platform_name}").format(platform_name=platform_name)
...@@ -236,35 +221,103 @@ def _update_certificate_context(context, course, user, user_certificate): ...@@ -236,35 +221,103 @@ def _update_certificate_context(context, course, user, user_certificate):
platform_name=platform_name platform_name=platform_name
) )
# Translators: This text represents the verification of the certificate
context['document_meta_description'] = _('This is a valid {platform_name} certificate for {user_name}, '
'who participated in {partner_short_name} {course_number}').format(
platform_name=platform_name,
user_name=user_fullname,
partner_short_name=partner_short_name,
course_number=course_number
)
# Translators: This text is bound to the HTML 'title' element of the page and appears in the browser title bar def _update_course_context(request, context, course, platform_name):
context['document_title'] = _("{partner_short_name} {course_number} Certificate | {platform_name}").format( """
partner_short_name=partner_short_name, Updates context dictionary with course info.
course_number=course_number, """
platform_name=platform_name context['full_course_image_url'] = request.build_absolute_uri(course_image_url(course))
course_title_from_cert = context['certificate_data'].get('course_title', '')
accomplishment_copy_course_name = course_title_from_cert if course_title_from_cert else course.display_name
context['accomplishment_copy_course_name'] = accomplishment_copy_course_name
course_number = course.display_coursenumber if course.display_coursenumber else course.number
context['course_number'] = course_number
if context['organization_long_name']:
# Translators: This text represents the description of course
context['accomplishment_copy_course_description'] = _('a course of study offered by {partner_short_name}, '
'an online learning initiative of {partner_long_name} '
'through {platform_name}.').format(
partner_short_name=context['organization_short_name'],
partner_long_name=context['organization_long_name'],
platform_name=platform_name)
else:
# Translators: This text represents the description of course
context['accomplishment_copy_course_description'] = _('a course of study offered by {partner_short_name}, '
'through {platform_name}.').format(
partner_short_name=context['organization_short_name'],
platform_name=platform_name)
def _update_social_context(request, context, course, user, user_certificate, platform_name):
"""
Updates context dictionary with info required for social sharing.
"""
share_settings = getattr(settings, 'SOCIAL_SHARING_SETTINGS', {})
context['facebook_share_enabled'] = share_settings.get('CERTIFICATE_FACEBOOK', False)
context['facebook_app_id'] = getattr(settings, "FACEBOOK_APP_ID", None)
context['facebook_share_text'] = share_settings.get(
'CERTIFICATE_FACEBOOK_TEXT',
_("I completed the {course_title} course on {platform_name}.").format(
course_title=context['accomplishment_copy_course_name'],
platform_name=platform_name
)
)
context['twitter_share_enabled'] = share_settings.get('CERTIFICATE_TWITTER', False)
context['twitter_share_text'] = share_settings.get(
'CERTIFICATE_TWITTER_TEXT',
_("I completed a course on {platform_name}. Take a look at my certificate.").format(
platform_name=platform_name
)
) )
# Translators: This text fragment appears after the student's name (displayed in a large font) on the certificate share_url = request.build_absolute_uri(
# screen. The text describes the accomplishment represented by the certificate information displayed to the user reverse(
context['accomplishment_copy_description_full'] = _("successfully completed, received a passing grade, and was " 'certificates:html_view',
"awarded a {platform_name} {certificate_type} " kwargs=dict(user_id=str(user.id), course_id=unicode(course.id))
"Certificate of Completion in ").format( )
platform_name=platform_name,
certificate_type=context.get("certificate_type")
) )
context['share_url'] = share_url
twitter_url = ''
if context.get('twitter_share_enabled', False):
twitter_url = 'https://twitter.com/intent/tweet?text={twitter_share_text}&url={share_url}'.format(
twitter_share_text=smart_str(context['twitter_share_text']),
share_url=urllib.quote_plus(smart_str(share_url))
)
context['twitter_url'] = twitter_url
context['linked_in_url'] = None
# If enabled, show the LinkedIn "add to profile" button
# Clicking this button sends the user to LinkedIn where they
# can add the certificate information to their profile.
linkedin_config = LinkedInAddToProfileConfiguration.current()
certificate_type_description = get_certificate_description(user_certificate.mode, certificate_type, platform_name) # posting certificates to LinkedIn is not currently
if certificate_type_description: # supported in microsites/White Labels
context['certificate_type_description'] = certificate_type_description if linkedin_config.enabled and not microsite.is_request_in_microsite():
context['linked_in_url'] = linkedin_config.add_to_profile_url(
course.id,
course.display_name,
user_certificate.mode,
smart_str(request.build_absolute_uri(get_certificate_url(
user_id=user.id,
course_id=unicode(course.id)
)))
)
def _update_context_with_user_info(context, user, user_certificate):
"""
Updates context dictionary with user related info.
"""
user_fullname = user.profile.name
context['username'] = user.username
context['course_mode'] = user_certificate.mode
context['accomplishment_user_id'] = user.id
context['accomplishment_copy_name'] = user_fullname
context['accomplishment_copy_username'] = user.username
context['accomplishment_more_title'] = _("More Information About {user_name}'s Certificate:").format(
user_name=user_fullname
)
# Translators: This line is displayed to a user who has completed a course and achieved a certification # Translators: This line is displayed to a user who has completed a course and achieved a certification
context['accomplishment_banner_opening'] = _("{fullname}, you've earned a certificate!").format( context['accomplishment_banner_opening'] = _("{fullname}, you've earned a certificate!").format(
fullname=user_fullname fullname=user_fullname
...@@ -281,67 +334,13 @@ def _update_certificate_context(context, course, user, user_certificate): ...@@ -281,67 +334,13 @@ def _update_certificate_context(context, course, user, user_certificate):
) )
@handle_500( def _get_user_certificate(request, user, course_key, course, preview_mode=None):
template_path="certificates/server-error.html",
test_func=lambda request: request.GET.get('preview', None)
)
def render_html_view(request, user_id, course_id):
""" """
This public view generates an HTML representation of the specified student's certificate Retrieves user's certificate from db. Creates one in case of preview mode.
If a certificate is not available, we display a "Sorry!" screen instead Returns None if there is no certificate generated for given user
otherwise returns `GeneratedCertificate` instance.
""" """
# Create the initial view context, bootstrapping with Django settings and passed-in values
context = {}
context['platform_name'] = microsite.get_value("platform_name", settings.PLATFORM_NAME)
context['course_id'] = course_id
preview_mode = request.GET.get('preview', None)
# Update the view context with the default ConfigurationModel settings
configuration = CertificateHtmlViewConfiguration.get_config()
# if we are in a microsite, then let's first see if there is an override
# section in our config
config_key = microsite.get_value('microsite_config_key', 'default')
# if there is no special microsite override, then let's use default
if config_key not in configuration:
config_key = 'default'
context.update(configuration.get(config_key, {}))
# Translators: 'All rights reserved' is a legal term used in copyrighting to protect published content
reserved = _("All rights reserved")
context['copyright_text'] = '&copy; {year} {platform_name}. {reserved}.'.format(
year=settings.COPYRIGHT_YEAR,
platform_name=context.get('platform_name'),
reserved=reserved
)
# Translators: This text is bound to the HTML 'title' element of the page and appears
# in the browser title bar when a requested certificate is not found or recognized
context['document_title'] = _("Invalid Certificate")
# Translators: The &amp; characters represent an ampersand character and can be ignored
context['company_tos_urltext'] = _("Terms of Service &amp; Honor Code")
# Translators: A 'Privacy Policy' is a legal document/statement describing a website's use of personal information
context['company_privacy_urltext'] = _("Privacy Policy")
# Translators: This line appears as a byline to a header image and describes the purpose of the page
context['logo_subtitle'] = _("Certificate Validation")
invalid_template_path = 'certificates/invalid.html'
# Kick the user back to the "Invalid" screen if the feature is disabled
if not has_html_certificates_enabled(course_id):
return render_to_response(invalid_template_path, context)
# Load the core building blocks for the view context
try: try:
course_key = CourseKey.from_string(course_id)
user = User.objects.get(id=user_id)
course = modulestore().get_course(course_key)
if not course:
raise CourseDoesNotExist
# Attempt to load the user's generated certificate data # Attempt to load the user's generated certificate data
if preview_mode: if preview_mode:
user_certificate = GeneratedCertificate.objects.get( user_certificate = GeneratedCertificate.objects.get(
...@@ -359,126 +358,54 @@ def render_html_view(request, user_id, course_id): ...@@ -359,126 +358,54 @@ def render_html_view(request, user_id, course_id):
# If we are, we'll need to create a mock version of the user_certificate container for previewing # If we are, we'll need to create a mock version of the user_certificate container for previewing
except GeneratedCertificate.DoesNotExist: except GeneratedCertificate.DoesNotExist:
if preview_mode and ( if preview_mode and (
has_access(request.user, 'instructor', course) has_access(request.user, 'instructor', course) or
or has_access(request.user, 'staff', course) has_access(request.user, 'staff', course)):
):
user_certificate = GeneratedCertificate( user_certificate = GeneratedCertificate(
mode=preview_mode, mode=preview_mode,
verify_uuid=unicode(uuid4().hex), verify_uuid=unicode(uuid4().hex),
modified_date=datetime.now().date() modified_date=datetime.now().date()
) )
else: else:
return render_to_response(invalid_template_path, context) return None
# For any other expected exceptions, kick the user back to the "Invalid" screen
except (InvalidKeyError, CourseDoesNotExist, User.DoesNotExist):
return render_to_response(invalid_template_path, context)
# Badge Request Event Tracking Logic
if 'evidence_visit' in request.GET:
try:
badge = BadgeAssertion.objects.get(user=user, course_id=course_key)
tracker.emit(
'edx.badge.assertion.evidence_visited',
{
'user_id': user.id,
'course_id': unicode(course_key),
'enrollment_mode': badge.mode,
'assertion_id': badge.id,
'assertion_image_url': badge.data['image'],
'assertion_json_url': badge.data['json']['id'],
'issuer': badge.data['issuer'],
}
)
except BadgeAssertion.DoesNotExist:
log.warn(
"Could not find badge for %s on course %s.",
user.id,
course_key,
)
# Okay, now we have all of the pieces, time to put everything together
# Get the active certificate configuration for this course
# If we do not have an active certificate, we'll need to send the user to the "Invalid" screen
# Passing in the 'preview' parameter, if specified, will return a configuration, if defined
active_configuration = get_active_web_certificate(course, preview_mode)
if active_configuration is None:
return render_to_response(invalid_template_path, context)
else:
context['certificate_data'] = active_configuration
# Append/Override the existing view context values with any mode-specific ConfigurationModel values return user_certificate
context.update(configuration.get(user_certificate.mode, {}))
# Append/Override the existing view context values with request-time values
_update_certificate_context(context, course, user, user_certificate)
share_url = request.build_absolute_uri(
reverse(
'certificates:html_view',
kwargs=dict(user_id=str(user_id), course_id=unicode(course_id))
)
)
context['share_url'] = share_url
twitter_url = ''
if context.get('twitter_share_enabled', False):
twitter_url = 'https://twitter.com/intent/tweet?text={twitter_share_text}&url={share_url}'.format(
twitter_share_text=smart_str(context['twitter_share_text']),
share_url=urllib.quote_plus(smart_str(share_url))
)
context['twitter_url'] = twitter_url
context['full_course_image_url'] = request.build_absolute_uri(course_image_url(course))
# If enabled, show the LinkedIn "add to profile" button def _track_certificate_events(request, context, course, user, user_certificate):
# Clicking this button sends the user to LinkedIn where they """
# can add the certificate information to their profile. Tracks web certificate view related events.
linkedin_config = LinkedInAddToProfileConfiguration.current() """
badge = context['badge']
# posting certificates to LinkedIn is not currently # Badge Request Event Tracking Logic
# supported in microsites/White Labels if 'evidence_visit' in request.GET and badge:
if linkedin_config.enabled and not microsite.is_request_in_microsite(): tracker.emit(
context['linked_in_url'] = linkedin_config.add_to_profile_url( 'edx.badge.assertion.evidence_visited',
course.id, {
course.display_name, 'user_id': user.id,
user_certificate.mode, 'course_id': unicode(course.id),
smart_str(request.build_absolute_uri(get_certificate_url( 'enrollment_mode': badge.mode,
user_id=user.id, 'assertion_id': badge.id,
course_id=unicode(course.id) 'assertion_image_url': badge.data['image'],
))) 'assertion_json_url': badge.data['json']['id'],
'issuer': badge.data['issuer'],
}
) )
else:
context['linked_in_url'] = None
# Microsites will need to be able to override any hard coded
# content that was put into the context in the
# _update_certificate_context() call above. For example the
# 'company_about_description' talks about edX, which we most likely
# do not want to keep in a microsite
#
# So we need to re-apply any configuration/content that
# we are sourceing from the database. This is somewhat duplicative of
# the code at the beginning of this method, but we
# need the configuration at the top as some error code paths
# require that to be set up early on in the pipeline
#
microsite_config_key = microsite.get_value('microsite_config_key')
if microsite_config_key:
context.update(configuration.get(microsite_config_key, {}))
# track certificate evidence_visited event for analytics when certificate_user and accessing_user are different # track certificate evidence_visited event for analytics when certificate_user and accessing_user are different
if request.user and request.user.id != user.id: if request.user and request.user.id != user.id:
emit_certificate_event('evidence_visited', user, course_id, course, { emit_certificate_event('evidence_visited', user, unicode(course.id), course, {
'certificate_id': user_certificate.verify_uuid, 'certificate_id': user_certificate.verify_uuid,
'enrollment_mode': user_certificate.mode, 'enrollment_mode': user_certificate.mode,
'social_network': CertificateSocialNetworks.linkedin 'social_network': CertificateSocialNetworks.linkedin
}) })
# Append/Override the existing view context values with any course-specific static values from Advanced Settings
context.update(course.cert_html_view_overrides)
# FINALLY, generate and send the output the client def _render_certificate_template(request, context, course, user_certificate):
"""
Picks appropriate certificate templates and renders it.
"""
if settings.FEATURES.get('CUSTOM_CERTIFICATE_TEMPLATES_ENABLED', False): if settings.FEATURES.get('CUSTOM_CERTIFICATE_TEMPLATES_ENABLED', False):
custom_template = get_certificate_template(course_key, user_certificate.mode) custom_template = get_certificate_template(course.id, user_certificate.mode)
if custom_template: if custom_template:
template = Template( template = Template(
custom_template, custom_template,
...@@ -491,3 +418,134 @@ def render_html_view(request, user_id, course_id): ...@@ -491,3 +418,134 @@ def render_html_view(request, user_id, course_id):
return HttpResponse(template.render(context)) return HttpResponse(template.render(context))
return render_to_response("certificates/valid.html", context) return render_to_response("certificates/valid.html", context)
def _update_microsite_context(context, configuration):
"""
Updates context with microsites data.
Microsites will need to be able to override any hard coded
content that was put into the context in the
_update_certificate_context() call above. For example the
'company_about_description' talks about edX, which we most likely
do not want to keep in a microsite
So we need to re-apply any configuration/content that
we are sourcing from the database. This is somewhat duplicative of
the code at the beginning of this method, but we
need the configuration at the top as some error code paths
require that to be set up early on in the pipeline
"""
microsite_config_key = microsite.get_value('domain_prefix')
microsites_config = configuration.get("microsites", {})
if microsite_config_key and microsites_config:
context.update(microsites_config.get(microsite_config_key, {}))
def _update_badge_context(context, course, user):
"""
Updates context with badge info.
"""
try:
badge = BadgeAssertion.objects.get(user=user, course_id=course.location.course_key)
except BadgeAssertion.DoesNotExist:
badge = None
context['badge'] = badge
def _update_organization_context(context, course):
"""
Updates context with organization related info.
"""
partner_long_name, organization_logo = None, None
partner_short_name = course.display_organization if course.display_organization else course.org
organizations = organization_api.get_course_organizations(course_id=course.id)
if organizations:
#TODO Need to add support for multiple organizations, Currently we are interested in the first one.
organization = organizations[0]
partner_long_name = organization.get('name', partner_long_name)
partner_short_name = organization.get('short_name', partner_short_name)
organization_logo = organization.get('logo', None)
context['organization_long_name'] = partner_long_name
context['organization_short_name'] = partner_short_name
context['accomplishment_copy_course_org'] = partner_short_name
context['organization_logo'] = organization_logo
@handle_500(
template_path="certificates/server-error.html",
test_func=lambda request: request.GET.get('preview', None)
)
def render_html_view(request, user_id, course_id):
"""
This public view generates an HTML representation of the specified student's certificate
If a certificate is not available, we display a "Sorry!" screen instead
"""
preview_mode = request.GET.get('preview', None)
platform_name = microsite.get_value("platform_name", settings.PLATFORM_NAME)
configuration = CertificateHtmlViewConfiguration.get_config()
# Create the initial view context, bootstrapping with Django settings and passed-in values
context = {}
_update_context_with_basic_info(context, course_id, platform_name, configuration)
invalid_template_path = 'certificates/invalid.html'
# Kick the user back to the "Invalid" screen if the feature is disabled
if not has_html_certificates_enabled(course_id):
return render_to_response(invalid_template_path, context)
# Load the course and user objects
try:
course_key = CourseKey.from_string(course_id)
user = User.objects.get(id=user_id)
course = modulestore().get_course(course_key)
# For any other expected exceptions, kick the user back to the "Invalid" screen
except (InvalidKeyError, ItemNotFoundError, User.DoesNotExist):
return render_to_response(invalid_template_path, context)
# Load user's certificate
user_certificate = _get_user_certificate(request, user, course_key, course, preview_mode)
if not user_certificate:
return render_to_response(invalid_template_path, context)
# Get the active certificate configuration for this course
# If we do not have an active certificate, we'll need to send the user to the "Invalid" screen
# Passing in the 'preview' parameter, if specified, will return a configuration, if defined
active_configuration = get_active_web_certificate(course, preview_mode)
if active_configuration is None:
return render_to_response(invalid_template_path, context)
context['certificate_data'] = active_configuration
# Append/Override the existing view context values with any mode-specific ConfigurationModel values
context.update(configuration.get(user_certificate.mode, {}))
# Append organization info
_update_organization_context(context, course)
# Append course info
_update_course_context(request, context, course, platform_name)
# Append user info
_update_context_with_user_info(context, user, user_certificate)
# Append social sharing info
_update_social_context(request, context, course, user, user_certificate, platform_name)
# Append/Override the existing view context values with certificate specific values
_update_certificate_context(context, user_certificate, platform_name)
# Append badge info
_update_badge_context(context, course, user)
# Append microsite overrides
_update_microsite_context(context, configuration)
# Append/Override the existing view context values with any course-specific static values from Advanced Settings
context.update(course.cert_html_view_overrides)
# Track certificate view events
_track_certificate_events(request, context, course, user, user_certificate)
# FINALLY, render appropriate certificate
return _render_certificate_template(request, context, course, user_certificate)
...@@ -25,13 +25,7 @@ from discussion_api.permissions import ( ...@@ -25,13 +25,7 @@ from discussion_api.permissions import (
get_initializable_thread_fields, get_initializable_thread_fields,
) )
from discussion_api.serializers import CommentSerializer, ThreadSerializer, get_context from discussion_api.serializers import CommentSerializer, ThreadSerializer, get_context
from django_comment_client.base.views import ( from django_comment_client.base.views import track_comment_created_event, track_thread_created_event
THREAD_CREATED_EVENT_NAME,
get_comment_created_event_data,
get_comment_created_event_name,
get_thread_created_event_data,
track_forum_event,
)
from django_comment_common.signals import ( from django_comment_common.signals import (
thread_created, thread_created,
thread_edited, thread_edited,
...@@ -566,13 +560,7 @@ def create_thread(request, thread_data): ...@@ -566,13 +560,7 @@ def create_thread(request, thread_data):
api_thread = serializer.data api_thread = serializer.data
_do_extra_actions(api_thread, cc_thread, thread_data.keys(), actions_form, context) _do_extra_actions(api_thread, cc_thread, thread_data.keys(), actions_form, context)
track_forum_event( track_thread_created_event(request, course, cc_thread, actions_form.cleaned_data["following"])
request,
THREAD_CREATED_EVENT_NAME,
course,
cc_thread,
get_thread_created_event_data(cc_thread, followed=actions_form.cleaned_data["following"])
)
return api_thread return api_thread
...@@ -616,13 +604,7 @@ def create_comment(request, comment_data): ...@@ -616,13 +604,7 @@ def create_comment(request, comment_data):
api_comment = serializer.data api_comment = serializer.data
_do_extra_actions(api_comment, cc_comment, comment_data.keys(), actions_form, context) _do_extra_actions(api_comment, cc_comment, comment_data.keys(), actions_form, context)
track_forum_event( track_comment_created_event(request, context["course"], cc_comment, cc_thread["commentable_id"], followed=False)
request,
get_comment_created_event_name(cc_comment),
context["course"],
cc_comment,
get_comment_created_event_data(cc_comment, cc_thread["commentable_id"], followed=False)
)
return api_comment return api_comment
......
...@@ -13,7 +13,7 @@ from django.core.urlresolvers import reverse ...@@ -13,7 +13,7 @@ from django.core.urlresolvers import reverse
from request_cache.middleware import RequestCache from request_cache.middleware import RequestCache
from mock import patch, ANY, Mock from mock import patch, ANY, Mock
from nose.tools import assert_true, assert_equal # pylint: disable=no-name-in-module from nose.tools import assert_true, assert_equal # pylint: disable=no-name-in-module
from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.keys import CourseKey
from lms.lib.comment_client import Thread from lms.lib.comment_client import Thread
from common.test.utils import MockSignalHandlerMixin, disable_signal from common.test.utils import MockSignalHandlerMixin, disable_signal
...@@ -1641,6 +1641,40 @@ class ForumEventTestCase(ModuleStoreTestCase, MockRequestSetupMixin): ...@@ -1641,6 +1641,40 @@ class ForumEventTestCase(ModuleStoreTestCase, MockRequestSetupMixin):
self.assertEqual(name, event_name) self.assertEqual(name, event_name)
self.assertEqual(event['team_id'], team.team_id) self.assertEqual(event['team_id'], team.team_id)
@ddt.data(
('vote_for_thread', 'thread_id', 'thread'),
('undo_vote_for_thread', 'thread_id', 'thread'),
('vote_for_comment', 'comment_id', 'response'),
('undo_vote_for_comment', 'comment_id', 'response'),
)
@ddt.unpack
@patch('eventtracking.tracker.emit')
@patch('lms.lib.comment_client.utils.requests.request')
def test_thread_voted_event(self, view_name, obj_id_name, obj_type, mock_request, mock_emit):
undo = view_name.startswith('undo')
self._set_mock_request_data(mock_request, {
'closed': False,
'commentable_id': 'test_commentable_id',
'username': 'gumprecht',
})
request = RequestFactory().post('dummy_url', {})
request.user = self.student
request.view_name = view_name
view_function = getattr(views, view_name)
kwargs = dict(course_id=unicode(self.course.id))
kwargs[obj_id_name] = obj_id_name
if not undo:
kwargs.update(value='up')
view_function(request, **kwargs)
self.assertTrue(mock_emit.called)
event_name, event = mock_emit.call_args[0]
self.assertEqual(event_name, 'edx.forum.{}.voted'.format(obj_type))
self.assertEqual(event['target_username'], 'gumprecht')
self.assertEqual(event['undo_vote'], undo)
self.assertEqual(event['vote_value'], 'up')
class UsersEndpointTestCase(ModuleStoreTestCase, MockRequestSetupMixin): class UsersEndpointTestCase(ModuleStoreTestCase, MockRequestSetupMixin):
...@@ -1699,7 +1733,7 @@ class UsersEndpointTestCase(ModuleStoreTestCase, MockRequestSetupMixin): ...@@ -1699,7 +1733,7 @@ class UsersEndpointTestCase(ModuleStoreTestCase, MockRequestSetupMixin):
self.assertNotIn("users", content) self.assertNotIn("users", content)
def test_course_does_not_exist(self): def test_course_does_not_exist(self):
course_id = SlashSeparatedCourseKey.from_deprecated_string("does/not/exist") course_id = CourseKey.from_string("does/not/exist")
response = self.make_request(course_id=course_id, username="other") response = self.make_request(course_id=course_id, username="other")
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
......
...@@ -12,7 +12,6 @@ from django.utils.translation import ugettext as _ ...@@ -12,7 +12,6 @@ from django.utils.translation import ugettext as _
from django.views.decorators import csrf from django.views.decorators import csrf
from django.views.decorators.http import require_GET, require_POST from django.views.decorators.http import require_GET, require_POST
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from courseware.access import has_access from courseware.access import has_access
from util.file import store_uploaded_file from util.file import store_uploaded_file
...@@ -49,40 +48,7 @@ import lms.lib.comment_client as cc ...@@ -49,40 +48,7 @@ import lms.lib.comment_client as cc
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
TRACKING_MAX_FORUM_BODY = 2000 TRACKING_MAX_FORUM_BODY = 2000
_EVENT_NAME_TEMPLATE = 'edx.forum.{obj_type}.{action_name}'
THREAD_CREATED_EVENT_NAME = "edx.forum.thread.created"
RESPONSE_CREATED_EVENT_NAME = 'edx.forum.response.created'
COMMENT_CREATED_EVENT_NAME = 'edx.forum.comment.created'
def permitted(fn):
@functools.wraps(fn)
def wrapper(request, *args, **kwargs):
def fetch_content():
if "thread_id" in kwargs:
content = cc.Thread.find(kwargs["thread_id"]).to_dict()
elif "comment_id" in kwargs:
content = cc.Comment.find(kwargs["comment_id"]).to_dict()
elif "commentable_id" in kwargs:
content = cc.Commentable.find(kwargs["commentable_id"]).to_dict()
else:
content = None
return content
course_key = SlashSeparatedCourseKey.from_deprecated_string(kwargs['course_id'])
if check_permissions_by_view(request.user, course_key, fetch_content(), request.view_name):
return fn(request, *args, **kwargs)
else:
return JsonError("unauthorized", status=401)
return wrapper
def ajax_content_response(request, course_key, content):
user_info = cc.User.from_django_user(request.user).to_dict()
annotated_content_info = get_annotated_content_info(course_key, content, request.user, user_info)
return JsonResponse({
'content': prepare_content(content, course_key),
'annotated_content_info': annotated_content_info,
})
def track_forum_event(request, event_name, course, obj, data, id_map=None): def track_forum_event(request, event_name, course, obj, data, id_map=None):
...@@ -100,16 +66,9 @@ def track_forum_event(request, event_name, course, obj, data, id_map=None): ...@@ -100,16 +66,9 @@ def track_forum_event(request, event_name, course, obj, data, id_map=None):
if id_map is None: if id_map is None:
id_map = get_cached_discussion_id_map(course, [commentable_id], user) id_map = get_cached_discussion_id_map(course, [commentable_id], user)
if commentable_id in id_map: if commentable_id in id_map:
data['category_name'] = id_map[commentable_id]["title"] data['category_name'] = id_map[commentable_id]["title"]
data['category_id'] = commentable_id data['category_id'] = commentable_id
if len(obj.body) > TRACKING_MAX_FORUM_BODY:
data['truncated'] = True
else:
data['truncated'] = False
data['body'] = obj.body[:TRACKING_MAX_FORUM_BODY]
data['url'] = request.META.get('HTTP_REFERER', '') data['url'] = request.META.get('HTTP_REFERER', '')
data['user_forums_roles'] = [ data['user_forums_roles'] = [
role.name for role in user.roles.filter(course_id=course.id) role.name for role in user.roles.filter(course_id=course.id)
...@@ -121,12 +80,24 @@ def track_forum_event(request, event_name, course, obj, data, id_map=None): ...@@ -121,12 +80,24 @@ def track_forum_event(request, event_name, course, obj, data, id_map=None):
tracker.emit(event_name, data) tracker.emit(event_name, data)
def get_thread_created_event_data(thread, followed): def track_created_event(request, event_name, course, obj, data):
""" """
Get the event data payload for thread creation (excluding fields populated Send analytics event for a newly created thread, response or comment.
by track_forum_event)
""" """
return { if len(obj.body) > TRACKING_MAX_FORUM_BODY:
data['truncated'] = True
else:
data['truncated'] = False
data['body'] = obj.body[:TRACKING_MAX_FORUM_BODY]
track_forum_event(request, event_name, course, obj, data)
def track_thread_created_event(request, course, thread, followed):
"""
Send analytics event for a newly created thread.
"""
event_name = _EVENT_NAME_TEMPLATE.format(obj_type='thread', action_name='created')
event_data = {
'commentable_id': thread.commentable_id, 'commentable_id': thread.commentable_id,
'group_id': thread.get("group_id"), 'group_id': thread.get("group_id"),
'thread_type': thread.thread_type, 'thread_type': thread.thread_type,
...@@ -139,29 +110,84 @@ def get_thread_created_event_data(thread, followed): ...@@ -139,29 +110,84 @@ def get_thread_created_event_data(thread, followed):
# However, the view does not contain that data, and including it will # However, the view does not contain that data, and including it will
# likely require changes elsewhere. # likely require changes elsewhere.
} }
track_created_event(request, event_name, course, thread, event_data)
def get_comment_created_event_name(comment): def track_comment_created_event(request, course, comment, commentable_id, followed):
"""Get the appropriate event name for creating a response/comment"""
return COMMENT_CREATED_EVENT_NAME if comment.get("parent_id") else RESPONSE_CREATED_EVENT_NAME
def get_comment_created_event_data(comment, commentable_id, followed):
""" """
Get the event data payload for comment creation (excluding fields populated Send analytics event for a newly created response or comment.
by track_forum_event)
""" """
obj_type = 'comment' if comment.get("parent_id") else 'response'
event_name = _EVENT_NAME_TEMPLATE.format(obj_type=obj_type, action_name='created')
event_data = { event_data = {
'discussion': {'id': comment.thread_id}, 'discussion': {'id': comment.thread_id},
'commentable_id': commentable_id, 'commentable_id': commentable_id,
'options': {'followed': followed}, 'options': {'followed': followed},
} }
parent_id = comment.get('parent_id')
parent_id = comment.get("parent_id")
if parent_id: if parent_id:
event_data['response'] = {'id': parent_id} event_data['response'] = {'id': parent_id}
track_created_event(request, event_name, course, comment, event_data)
return event_data def track_voted_event(request, course, obj, vote_value, undo_vote=False):
"""
Send analytics event for a vote on a thread or response.
"""
if isinstance(obj, cc.Thread):
obj_type = 'thread'
else:
obj_type = 'response'
event_name = _EVENT_NAME_TEMPLATE.format(obj_type=obj_type, action_name='voted')
event_data = {
'commentable_id': obj.commentable_id,
'target_username': obj.get('username'),
'undo_vote': undo_vote,
'vote_value': vote_value,
}
track_forum_event(request, event_name, course, obj, event_data)
def permitted(func):
"""
View decorator to verify the user is authorized to access this endpoint.
"""
@functools.wraps(func)
def wrapper(request, *args, **kwargs):
"""
Wrapper for the view that only calls the view if the user is authorized.
"""
def fetch_content():
"""
Extract the forum object from the keyword arguments to the view.
"""
if "thread_id" in kwargs:
content = cc.Thread.find(kwargs["thread_id"]).to_dict()
elif "comment_id" in kwargs:
content = cc.Comment.find(kwargs["comment_id"]).to_dict()
elif "commentable_id" in kwargs:
content = cc.Commentable.find(kwargs["commentable_id"]).to_dict()
else:
content = None
return content
course_key = CourseKey.from_string(kwargs['course_id'])
if check_permissions_by_view(request.user, course_key, fetch_content(), request.view_name):
return func(request, *args, **kwargs)
else:
return JsonError("unauthorized", status=401)
return wrapper
def ajax_content_response(request, course_key, content):
"""
Standard AJAX response returning the content hierarchy of the current thread.
"""
user_info = cc.User.from_django_user(request.user).to_dict()
annotated_content_info = get_annotated_content_info(course_key, content, request.user, user_info)
return JsonResponse({
'content': prepare_content(content, course_key),
'annotated_content_info': annotated_content_info,
})
@require_POST @require_POST
...@@ -173,7 +199,7 @@ def create_thread(request, course_id, commentable_id): ...@@ -173,7 +199,7 @@ def create_thread(request, course_id, commentable_id):
""" """
log.debug("Creating new thread in %r, id %r", course_id, commentable_id) log.debug("Creating new thread in %r, id %r", course_id, commentable_id)
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) course_key = CourseKey.from_string(course_id)
course = get_course_with_access(request.user, 'load', course_key) course = get_course_with_access(request.user, 'load', course_key)
post = request.POST post = request.POST
user = request.user user = request.user
...@@ -234,12 +260,11 @@ def create_thread(request, course_id, commentable_id): ...@@ -234,12 +260,11 @@ def create_thread(request, course_id, commentable_id):
cc_user = cc.User.from_django_user(user) cc_user = cc.User.from_django_user(user)
cc_user.follow(thread) cc_user.follow(thread)
event_data = get_thread_created_event_data(thread, follow)
data = thread.to_dict() data = thread.to_dict()
add_courseware_context([data], course, user) add_courseware_context([data], course, user)
track_forum_event(request, THREAD_CREATED_EVENT_NAME, course, thread, event_data) track_thread_created_event(request, course, thread, follow)
if request.is_ajax(): if request.is_ajax():
return ajax_content_response(request, course_key, data) return ajax_content_response(request, course_key, data)
...@@ -259,7 +284,7 @@ def update_thread(request, course_id, thread_id): ...@@ -259,7 +284,7 @@ def update_thread(request, course_id, thread_id):
if 'body' not in request.POST or not request.POST['body'].strip(): if 'body' not in request.POST or not request.POST['body'].strip():
return JsonError(_("Body can't be empty")) return JsonError(_("Body can't be empty"))
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) course_key = CourseKey.from_string(course_id)
thread = cc.Thread.find(thread_id) thread = cc.Thread.find(thread_id)
# Get thread context first in order to be safe from reseting the values of thread object later # Get thread context first in order to be safe from reseting the values of thread object later
thread_context = getattr(thread, "context", "course") thread_context = getattr(thread, "context", "course")
...@@ -330,9 +355,7 @@ def _create_comment(request, course_key, thread_id=None, parent_id=None): ...@@ -330,9 +355,7 @@ def _create_comment(request, course_key, thread_id=None, parent_id=None):
cc_user = cc.User.from_django_user(request.user) cc_user = cc.User.from_django_user(request.user)
cc_user.follow(comment.thread) cc_user.follow(comment.thread)
event_name = get_comment_created_event_name(comment) track_comment_created_event(request, course, comment, comment.thread.commentable_id, followed)
event_data = get_comment_created_event_data(comment, comment.thread.commentable_id, followed)
track_forum_event(request, event_name, course, comment, event_data)
if request.is_ajax(): if request.is_ajax():
return ajax_content_response(request, course_key, comment.to_dict()) return ajax_content_response(request, course_key, comment.to_dict())
...@@ -350,7 +373,7 @@ def create_comment(request, course_id, thread_id): ...@@ -350,7 +373,7 @@ def create_comment(request, course_id, thread_id):
""" """
if is_comment_too_deep(parent=None): if is_comment_too_deep(parent=None):
return JsonError(_("Comment level too deep")) return JsonError(_("Comment level too deep"))
return _create_comment(request, SlashSeparatedCourseKey.from_deprecated_string(course_id), thread_id=thread_id) return _create_comment(request, CourseKey.from_string(course_id), thread_id=thread_id)
@require_POST @require_POST
...@@ -361,7 +384,7 @@ def delete_thread(request, course_id, thread_id): # pylint: disable=unused-argu ...@@ -361,7 +384,7 @@ def delete_thread(request, course_id, thread_id): # pylint: disable=unused-argu
given a course_id and thread_id, delete this thread given a course_id and thread_id, delete this thread
this is ajax only this is ajax only
""" """
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) course_key = CourseKey.from_string(course_id)
thread = cc.Thread.find(thread_id) thread = cc.Thread.find(thread_id)
thread.delete() thread.delete()
thread_deleted.send(sender=None, user=request.user, post=thread) thread_deleted.send(sender=None, user=request.user, post=thread)
...@@ -376,7 +399,7 @@ def update_comment(request, course_id, comment_id): ...@@ -376,7 +399,7 @@ def update_comment(request, course_id, comment_id):
given a course_id and comment_id, update the comment with payload attributes given a course_id and comment_id, update the comment with payload attributes
handles static and ajax submissions handles static and ajax submissions
""" """
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) course_key = CourseKey.from_string(course_id)
comment = cc.Comment.find(comment_id) comment = cc.Comment.find(comment_id)
if 'body' not in request.POST or not request.POST['body'].strip(): if 'body' not in request.POST or not request.POST['body'].strip():
return JsonError(_("Body can't be empty")) return JsonError(_("Body can't be empty"))
...@@ -399,7 +422,7 @@ def endorse_comment(request, course_id, comment_id): ...@@ -399,7 +422,7 @@ def endorse_comment(request, course_id, comment_id):
given a course_id and comment_id, toggle the endorsement of this comment, given a course_id and comment_id, toggle the endorsement of this comment,
ajax only ajax only
""" """
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) course_key = CourseKey.from_string(course_id)
comment = cc.Comment.find(comment_id) comment = cc.Comment.find(comment_id)
user = request.user user = request.user
comment.endorsed = request.POST.get('endorsed', 'false').lower() == 'true' comment.endorsed = request.POST.get('endorsed', 'false').lower() == 'true'
...@@ -417,7 +440,7 @@ def openclose_thread(request, course_id, thread_id): ...@@ -417,7 +440,7 @@ def openclose_thread(request, course_id, thread_id):
given a course_id and thread_id, toggle the status of this thread given a course_id and thread_id, toggle the status of this thread
ajax only ajax only
""" """
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) course_key = CourseKey.from_string(course_id)
thread = cc.Thread.find(thread_id) thread = cc.Thread.find(thread_id)
thread.closed = request.POST.get('closed', 'false').lower() == 'true' thread.closed = request.POST.get('closed', 'false').lower() == 'true'
thread.save() thread.save()
...@@ -438,7 +461,7 @@ def create_sub_comment(request, course_id, comment_id): ...@@ -438,7 +461,7 @@ def create_sub_comment(request, course_id, comment_id):
""" """
if is_comment_too_deep(parent=cc.Comment(comment_id)): if is_comment_too_deep(parent=cc.Comment(comment_id)):
return JsonError(_("Comment level too deep")) return JsonError(_("Comment level too deep"))
return _create_comment(request, SlashSeparatedCourseKey.from_deprecated_string(course_id), parent_id=comment_id) return _create_comment(request, CourseKey.from_string(course_id), parent_id=comment_id)
@require_POST @require_POST
...@@ -449,27 +472,42 @@ def delete_comment(request, course_id, comment_id): ...@@ -449,27 +472,42 @@ def delete_comment(request, course_id, comment_id):
given a course_id and comment_id delete this comment given a course_id and comment_id delete this comment
ajax only ajax only
""" """
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) course_key = CourseKey.from_string(course_id)
comment = cc.Comment.find(comment_id) comment = cc.Comment.find(comment_id)
comment.delete() comment.delete()
comment_deleted.send(sender=None, user=request.user, post=comment) comment_deleted.send(sender=None, user=request.user, post=comment)
return JsonResponse(prepare_content(comment.to_dict(), course_key)) return JsonResponse(prepare_content(comment.to_dict(), course_key))
def _vote_or_unvote(request, course_id, obj, value='up', undo_vote=False):
"""
Vote or unvote for a thread or a response.
"""
course_key = CourseKey.from_string(course_id)
course = get_course_with_access(request.user, 'load', course_key)
user = cc.User.from_django_user(request.user)
if undo_vote:
user.unvote(obj)
# TODO(smarnach): Determine the value of the vote that is undone. Currently, you can
# only cast upvotes in the user interface, so it is assumed that the vote value is 'up'.
# (People could theoretically downvote by handcrafting AJAX requests.)
else:
user.vote(obj, value)
track_voted_event(request, course, obj, value, undo_vote)
return JsonResponse(prepare_content(obj.to_dict(), course_key))
@require_POST @require_POST
@login_required @login_required
@permitted @permitted
def vote_for_comment(request, course_id, comment_id, value): def vote_for_comment(request, course_id, comment_id, value):
""" """
given a course_id and comment_id, Given a course_id and comment_id, vote for this response. AJAX only.
""" """
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
user = request.user
cc_user = cc.User.from_django_user(user)
comment = cc.Comment.find(comment_id) comment = cc.Comment.find(comment_id)
cc_user.vote(comment, value) result = _vote_or_unvote(request, course_id, comment, value)
comment_voted.send(sender=None, user=user, post=comment) comment_voted.send(sender=None, user=request.user, post=comment)
return JsonResponse(prepare_content(comment.to_dict(), course_key)) return result
@require_POST @require_POST
...@@ -480,11 +518,7 @@ def undo_vote_for_comment(request, course_id, comment_id): ...@@ -480,11 +518,7 @@ def undo_vote_for_comment(request, course_id, comment_id):
given a course id and comment id, remove vote given a course id and comment id, remove vote
ajax only ajax only
""" """
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) return _vote_or_unvote(request, course_id, cc.Comment.find(comment_id), undo_vote=True)
user = cc.User.from_django_user(request.user)
comment = cc.Comment.find(comment_id)
user.unvote(comment)
return JsonResponse(prepare_content(comment.to_dict(), course_key))
@require_POST @require_POST
...@@ -495,13 +529,21 @@ def vote_for_thread(request, course_id, thread_id, value): ...@@ -495,13 +529,21 @@ def vote_for_thread(request, course_id, thread_id, value):
given a course id and thread id vote for this thread given a course id and thread id vote for this thread
ajax only ajax only
""" """
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
user = request.user
cc_user = cc.User.from_django_user(user)
thread = cc.Thread.find(thread_id) thread = cc.Thread.find(thread_id)
cc_user.vote(thread, value) result = _vote_or_unvote(request, course_id, thread, value)
thread_voted.send(sender=None, user=user, post=thread) thread_voted.send(sender=None, user=request.user, post=thread)
return JsonResponse(prepare_content(thread.to_dict(), course_key)) return result
@require_POST
@login_required
@permitted
def undo_vote_for_thread(request, course_id, thread_id):
"""
given a course id and thread id, remove users vote for thread
ajax only
"""
return _vote_or_unvote(request, course_id, cc.Thread.find(thread_id), undo_vote=True)
@require_POST @require_POST
...@@ -512,7 +554,7 @@ def flag_abuse_for_thread(request, course_id, thread_id): ...@@ -512,7 +554,7 @@ def flag_abuse_for_thread(request, course_id, thread_id):
given a course_id and thread_id flag this thread for abuse given a course_id and thread_id flag this thread for abuse
ajax only ajax only
""" """
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) course_key = CourseKey.from_string(course_id)
user = cc.User.from_django_user(request.user) user = cc.User.from_django_user(request.user)
thread = cc.Thread.find(thread_id) thread = cc.Thread.find(thread_id)
thread.flagAbuse(user, thread) thread.flagAbuse(user, thread)
...@@ -529,7 +571,7 @@ def un_flag_abuse_for_thread(request, course_id, thread_id): ...@@ -529,7 +571,7 @@ def un_flag_abuse_for_thread(request, course_id, thread_id):
ajax only ajax only
""" """
user = cc.User.from_django_user(request.user) user = cc.User.from_django_user(request.user)
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) course_key = CourseKey.from_string(course_id)
course = get_course_by_id(course_key) course = get_course_by_id(course_key)
thread = cc.Thread.find(thread_id) thread = cc.Thread.find(thread_id)
remove_all = bool( remove_all = bool(
...@@ -549,7 +591,7 @@ def flag_abuse_for_comment(request, course_id, comment_id): ...@@ -549,7 +591,7 @@ def flag_abuse_for_comment(request, course_id, comment_id):
given a course and comment id, flag comment for abuse given a course and comment id, flag comment for abuse
ajax only ajax only
""" """
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) course_key = CourseKey.from_string(course_id)
user = cc.User.from_django_user(request.user) user = cc.User.from_django_user(request.user)
comment = cc.Comment.find(comment_id) comment = cc.Comment.find(comment_id)
comment.flagAbuse(user, comment) comment.flagAbuse(user, comment)
...@@ -565,7 +607,7 @@ def un_flag_abuse_for_comment(request, course_id, comment_id): ...@@ -565,7 +607,7 @@ def un_flag_abuse_for_comment(request, course_id, comment_id):
ajax only ajax only
""" """
user = cc.User.from_django_user(request.user) user = cc.User.from_django_user(request.user)
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) course_key = CourseKey.from_string(course_id)
course = get_course_by_id(course_key) course = get_course_by_id(course_key)
remove_all = bool( remove_all = bool(
has_permission(request.user, 'openclose_thread', course_key) or has_permission(request.user, 'openclose_thread', course_key) or
...@@ -579,28 +621,12 @@ def un_flag_abuse_for_comment(request, course_id, comment_id): ...@@ -579,28 +621,12 @@ def un_flag_abuse_for_comment(request, course_id, comment_id):
@require_POST @require_POST
@login_required @login_required
@permitted @permitted
def undo_vote_for_thread(request, course_id, thread_id):
"""
given a course id and thread id, remove users vote for thread
ajax only
"""
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
user = cc.User.from_django_user(request.user)
thread = cc.Thread.find(thread_id)
user.unvote(thread)
return JsonResponse(prepare_content(thread.to_dict(), course_key))
@require_POST
@login_required
@permitted
def pin_thread(request, course_id, thread_id): def pin_thread(request, course_id, thread_id):
""" """
given a course id and thread id, pin this thread given a course id and thread id, pin this thread
ajax only ajax only
""" """
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) course_key = CourseKey.from_string(course_id)
user = cc.User.from_django_user(request.user) user = cc.User.from_django_user(request.user)
thread = cc.Thread.find(thread_id) thread = cc.Thread.find(thread_id)
thread.pin(user, thread_id) thread.pin(user, thread_id)
...@@ -616,7 +642,7 @@ def un_pin_thread(request, course_id, thread_id): ...@@ -616,7 +642,7 @@ def un_pin_thread(request, course_id, thread_id):
given a course id and thread id, remove pin from this thread given a course id and thread id, remove pin from this thread
ajax only ajax only
""" """
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) course_key = CourseKey.from_string(course_id)
user = cc.User.from_django_user(request.user) user = cc.User.from_django_user(request.user)
thread = cc.Thread.find(thread_id) thread = cc.Thread.find(thread_id)
thread.un_pin(user, thread_id) thread.un_pin(user, thread_id)
...@@ -742,7 +768,7 @@ def users(request, course_id): ...@@ -742,7 +768,7 @@ def users(request, course_id):
Only exact matches are supported here, so the length of the result set will either be 0 or 1. Only exact matches are supported here, so the length of the result set will either be 0 or 1.
""" """
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) course_key = CourseKey.from_string(course_id)
try: try:
get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True) get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True)
except Http404: except Http404:
......
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
from opaque_keys import InvalidKeyError from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from courseware.courses import get_course from courseware.courses import get_course
...@@ -16,10 +15,7 @@ class Command(BaseCommand): ...@@ -16,10 +15,7 @@ class Command(BaseCommand):
raise CommandError("Only one course id may be specifiied") raise CommandError("Only one course id may be specifiied")
course_id = args[0] course_id = args[0]
try: course_key = CourseKey.from_string(course_id)
course_key = CourseKey.from_string(course_id)
except InvalidKeyError:
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
course = get_course(course_key) course = get_course(course_key)
if not course: if not course:
......
...@@ -3,7 +3,7 @@ Management command to seed default permissions and roles. ...@@ -3,7 +3,7 @@ Management command to seed default permissions and roles.
""" """
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
from django_comment_common.utils import seed_permissions_roles from django_comment_common.utils import seed_permissions_roles
from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.keys import CourseKey
class Command(BaseCommand): class Command(BaseCommand):
...@@ -15,6 +15,6 @@ class Command(BaseCommand): ...@@ -15,6 +15,6 @@ class Command(BaseCommand):
raise CommandError("Please provide a course id") raise CommandError("Please provide a course id")
if len(args) > 1: if len(args) > 1:
raise CommandError("Too many arguments") raise CommandError("Too many arguments")
course_id = SlashSeparatedCourseKey.from_deprecated_string(args[0]) course_id = CourseKey.from_string(args[0])
seed_permissions_roles(course_id) seed_permissions_roles(course_id)
...@@ -3,7 +3,7 @@ Tests for the django comment client integration models ...@@ -3,7 +3,7 @@ Tests for the django comment client integration models
""" """
from django.test.testcases import TestCase from django.test.testcases import TestCase
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.keys import CourseKey
from xmodule.modulestore.tests.django_utils import TEST_DATA_MIXED_TOY_MODULESTORE from xmodule.modulestore.tests.django_utils import TEST_DATA_MIXED_TOY_MODULESTORE
import django_comment_common.models as models import django_comment_common.models as models
...@@ -23,7 +23,7 @@ class RoleClassTestCase(ModuleStoreTestCase): ...@@ -23,7 +23,7 @@ class RoleClassTestCase(ModuleStoreTestCase):
# For course ID, syntax edx/classname/classdate is important # For course ID, syntax edx/classname/classdate is important
# because xmodel.course_module.id_to_location looks for a string to split # because xmodel.course_module.id_to_location looks for a string to split
self.course_id = SlashSeparatedCourseKey("edX", "toy", "2012_Fall") self.course_id = CourseKey.from_string("edX/toy/2012_Fall")
self.student_role = models.Role.objects.get_or_create(name="Student", self.student_role = models.Role.objects.get_or_create(name="Student",
course_id=self.course_id)[0] course_id=self.course_id)[0]
self.student_role.add_permission("delete_thread") self.student_role.add_permission("delete_thread")
...@@ -31,7 +31,7 @@ class RoleClassTestCase(ModuleStoreTestCase): ...@@ -31,7 +31,7 @@ class RoleClassTestCase(ModuleStoreTestCase):
course_id=self.course_id)[0] course_id=self.course_id)[0]
self.TA_role = models.Role.objects.get_or_create(name="Community TA", self.TA_role = models.Role.objects.get_or_create(name="Community TA",
course_id=self.course_id)[0] course_id=self.course_id)[0]
self.course_id_2 = SlashSeparatedCourseKey("edx", "6.002x", "2012_Fall") self.course_id_2 = CourseKey.from_string("edX/6.002x/2012_Fall")
self.TA_role_2 = models.Role.objects.get_or_create(name="Community TA", self.TA_role_2 = models.Role.objects.get_or_create(name="Community TA",
course_id=self.course_id_2)[0] course_id=self.course_id_2)[0]
......
...@@ -206,6 +206,16 @@ define([ ...@@ -206,6 +206,16 @@ define([
expectPaymentButtonEnabled( true ); expectPaymentButtonEnabled( true );
}); });
it('displays an error if no payment processors are available', function () {
var view = createView({processors: []});
expect(view.errorModel.get('shown')).toBe(true);
expect(view.errorModel.get('errorTitle')).toEqual(
'All payment options are currently unavailable.'
);
expect(view.errorModel.get('errorMsg')).toEqual(
'Try the transaction again in a few minutes.'
);
});
}); });
} }
); );
...@@ -56,7 +56,8 @@ define(['jquery', 'common/js/spec_helpers/template_helpers', 'js/verify_student/ ...@@ -56,7 +56,8 @@ define(['jquery', 'common/js/spec_helpers/template_helpers', 'js/verify_student/
var createView = function( displaySteps, currentStep ) { var createView = function( displaySteps, currentStep ) {
return new PayAndVerifyView({ return new PayAndVerifyView({
displaySteps: displaySteps, displaySteps: displaySteps,
currentStep: currentStep currentStep: currentStep,
errorModel: new ( Backbone.Model.extend({}) )()
}).render(); }).render();
}; };
......
...@@ -105,10 +105,20 @@ var edx = edx || {}; ...@@ -105,10 +105,20 @@ var edx = edx || {};
self._getProductText( templateContext.courseModeSlug, templateContext.upgrade ) self._getProductText( templateContext.courseModeSlug, templateContext.upgrade )
); );
// create a button for each payment processor if (processors.length === 0) {
_.each(processors.reverse(), function(processorName) { // No payment processors are enabled at the moment, so show an error message
$( 'div.payment-buttons' ).append( self._getPaymentButtonHtml(processorName) ); this.errorModel.set({
}); errorTitle: gettext('All payment options are currently unavailable.'),
errorMsg: gettext('Try the transaction again in a few minutes.'),
shown: true
})
}
else {
// create a button for each payment processor
_.each(processors.reverse(), function(processorName) {
$( 'div.payment-buttons' ).append( self._getPaymentButtonHtml(processorName) );
});
}
// Handle payment submission // Handle payment submission
$( '.payment-button' ).on( 'click', _.bind( this.createOrder, this ) ); $( '.payment-button' ).on( 'click', _.bind( this.createOrder, this ) );
......
...@@ -273,8 +273,8 @@ def run_jshint(options): ...@@ -273,8 +273,8 @@ def run_jshint(options):
_prepare_report_dir(jshint_report_dir) _prepare_report_dir(jshint_report_dir)
sh( sh(
"jshint {root} --config .jshintrc >> {jshint_report}".format( "jshint . --config .jshintrc >> {jshint_report}".format(
root=Env.REPO_ROOT, jshint_report=jshint_report jshint_report=jshint_report
), ),
ignore_error=True ignore_error=True
) )
......
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