diff --git a/common/djangoapps/student/roles.py b/common/djangoapps/student/roles.py index 8172fea0a5db..50de144dec2f 100644 --- a/common/djangoapps/student/roles.py +++ b/common/djangoapps/student/roles.py @@ -310,6 +310,17 @@ def __init__(self, *args, **kwargs): super(CourseCreatorRole, self).__init__(self.ROLE, *args, **kwargs) +@register_access_role +class SupportStaffRole(RoleBase): + """ + Student support team members. + """ + ROLE = "support" + + def __init__(self, *args, **kwargs): + super(SupportStaffRole, self).__init__(self.ROLE, *args, **kwargs) + + class UserBasedRole(object): """ Backward mapping: given a user, manipulate the courses and roles diff --git a/lms/djangoapps/certificates/api.py b/lms/djangoapps/certificates/api.py index 25a152a9e511..5b786a2f0b8d 100644 --- a/lms/djangoapps/certificates/api.py +++ b/lms/djangoapps/certificates/api.py @@ -29,6 +29,54 @@ log = logging.getLogger("edx.certificate") +def get_certificates_for_user(username): + """ + Retrieve certificate information for a particular user. + + Arguments: + username (unicode): The identifier of the user. + + Returns: list + + Example Usage: + >>> get_certificates_for_user("bob") + [ + { + "username": "bob", + "course_key": "edX/DemoX/Demo_Course", + "type": "verified", + "status": "downloadable", + "download_url": "http://www.example.com/cert.pdf", + "grade": "0.98", + "created": 2015-07-31T00:00:00Z, + "modified": 2015-07-31T00:00:00Z + } + ] + + """ + return [ + { + "username": username, + "course_key": cert.course_id, + "type": cert.mode, + "status": cert.status, + "grade": cert.grade, + "created": cert.created_date, + "modified": cert.modified_date, + + # NOTE: the download URL is not currently being set for webview certificates. + # In the future, we can update this to construct a URL to the webview certificate + # for courses that have this feature enabled. + "download_url": ( + cert.download_url + if cert.status == CertificateStatuses.downloadable + else None + ), + } + for cert in GeneratedCertificate.objects.filter(user__username=username).order_by("course_id") + ] + + def generate_user_certificates(student, course_key, course=None, insecure=False, generation_mode='batch', forced_grade=None): """ @@ -96,7 +144,14 @@ def regenerate_user_certificates(student, course_key, course=None, xqueue.use_https = False generate_pdf = not has_html_certificates_enabled(course_key, course) - return xqueue.regen_cert(student, course_key, course, forced_grade, template_file, generate_pdf) + return xqueue.regen_cert( + student, + course_key, + course=course, + forced_grade=forced_grade, + template_file=template_file, + generate_pdf=generate_pdf + ) def certificate_downloadable_status(student, course_key): @@ -281,7 +336,11 @@ def get_certificate_url(user_id, course_id): url = "" if settings.FEATURES.get('CERTIFICATES_HTML_VIEW', False): url = reverse( - 'cert_html_view', kwargs=dict(user_id=str(user_id), course_id=unicode(course_id)) + 'certificates:html_view', + kwargs={ + "user_id": str(user_id), + "course_id": unicode(course_id), + } ) else: try: diff --git a/lms/djangoapps/certificates/badge_handler.py b/lms/djangoapps/certificates/badge_handler.py index fa05508d50c2..2b12a2bea40b 100644 --- a/lms/djangoapps/certificates/badge_handler.py +++ b/lms/djangoapps/certificates/badge_handler.py @@ -176,7 +176,7 @@ def create_assertion(self, user, mode): data = { 'email': user.email, 'evidence': self.site_prefix() + reverse( - 'cert_html_view', kwargs={'user_id': user.id, 'course_id': unicode(self.course_key)} + 'certificates:html_view', kwargs={'user_id': user.id, 'course_id': unicode(self.course_key)} ) + '?evidence_visit=1' } response = requests.post(self.assertion_url(mode), headers=self.get_headers(), data=data) diff --git a/lms/djangoapps/certificates/queue.py b/lms/djangoapps/certificates/queue.py index 3cc0c63bf401..ea005ddf3c8d 100644 --- a/lms/djangoapps/certificates/queue.py +++ b/lms/djangoapps/certificates/queue.py @@ -155,7 +155,14 @@ def regen_cert(self, student, course_id, course=None, forced_grade=None, templat except GeneratedCertificate.DoesNotExist: pass - return self.add_cert(student, course_id, course, forced_grade, template_file, generate_pdf) + return self.add_cert( + student, + course_id, + course=course, + forced_grade=forced_grade, + template_file=template_file, + generate_pdf=generate_pdf + ) def del_cert(self, student, course_id): diff --git a/lms/djangoapps/certificates/tests/test_cert_management.py b/lms/djangoapps/certificates/tests/test_cert_management.py index d015e884e849..4e025266f59b 100644 --- a/lms/djangoapps/certificates/tests/test_cert_management.py +++ b/lms/djangoapps/certificates/tests/test_cert_management.py @@ -175,7 +175,12 @@ def test_clear_badge(self, xqueue): grade_value=None ) xqueue.return_value.regen_cert.assert_called_with( - self.user, key, self.course, None, None, True + self.user, + key, + course=self.course, + forced_grade=None, + template_file=None, + generate_pdf=True ) self.assertFalse(BadgeAssertion.objects.filter(user=self.user, course_id=key)) diff --git a/lms/djangoapps/certificates/tests/test_support_views.py b/lms/djangoapps/certificates/tests/test_support_views.py new file mode 100644 index 000000000000..47a6774cc00d --- /dev/null +++ b/lms/djangoapps/certificates/tests/test_support_views.py @@ -0,0 +1,259 @@ +""" +Tests for certificate app views used by the support team. +""" + +import json + +import ddt +from django.core.urlresolvers import reverse +from django.test import TestCase + +from opaque_keys.edx.keys import CourseKey +from student.tests.factories import UserFactory +from student.models import CourseEnrollment +from student.roles import GlobalStaff, SupportStaffRole +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory +from certificates.models import GeneratedCertificate, CertificateStatuses + + +class CertificateSupportTestCase(TestCase): + """ + Base class for tests of the certificate support views. + """ + + SUPPORT_USERNAME = "support" + SUPPORT_EMAIL = "support@example.com" + SUPPORT_PASSWORD = "support" + + STUDENT_USERNAME = "student" + STUDENT_EMAIL = "student@example.com" + STUDENT_PASSWORD = "student" + + CERT_COURSE_KEY = CourseKey.from_string("edX/DemoX/Demo_Course") + CERT_GRADE = 0.89 + CERT_STATUS = CertificateStatuses.downloadable + CERT_MODE = "verified" + CERT_DOWNLOAD_URL = "http://www.example.com/cert.pdf" + + def setUp(self): + """ + Create a support team member and a student with a certificate. + Log in as the support team member. + """ + super(CertificateSupportTestCase, self).setUp() + + # Create the support staff user + self.support = UserFactory( + username=self.SUPPORT_USERNAME, + email=self.SUPPORT_EMAIL, + password=self.SUPPORT_PASSWORD, + ) + SupportStaffRole().add_users(self.support) + + # Create a student + self.student = UserFactory( + username=self.STUDENT_USERNAME, + email=self.STUDENT_EMAIL, + password=self.STUDENT_PASSWORD, + ) + + # Create certificates for the student + self.cert = GeneratedCertificate.objects.create( + user=self.student, + course_id=self.CERT_COURSE_KEY, + grade=self.CERT_GRADE, + status=self.CERT_STATUS, + mode=self.CERT_MODE, + download_url=self.CERT_DOWNLOAD_URL, + ) + + # Login as support staff + success = self.client.login(username=self.SUPPORT_USERNAME, password=self.SUPPORT_PASSWORD) + self.assertTrue(success, msg="Couldn't log in as support staff") + + +@ddt.ddt +class CertificateSearchTests(CertificateSupportTestCase): + """ + Tests for the certificate search end-point used by the support team. + """ + + @ddt.data( + (GlobalStaff, True), + (SupportStaffRole, True), + (None, False), + ) + @ddt.unpack + def test_access_control(self, role, has_access): + # Create a user and log in + user = UserFactory(username="foo", password="foo") + success = self.client.login(username="foo", password="foo") + self.assertTrue(success, msg="Could not log in") + + # Assign the user to the role + if role is not None: + role().add_users(user) + + # Retrieve the page + response = self._search("foo") + + if has_access: + self.assertContains(response, json.dumps([])) + else: + self.assertEqual(response.status_code, 403) + + @ddt.data( + (CertificateSupportTestCase.STUDENT_USERNAME, True), + (CertificateSupportTestCase.STUDENT_EMAIL, True), + ("bar", False), + ("bar@example.com", False), + ) + @ddt.unpack + def test_search(self, query, expect_result): + response = self._search(query) + self.assertEqual(response.status_code, 200) + + results = json.loads(response.content) + self.assertEqual(len(results), 1 if expect_result else 0) + + def test_results(self): + response = self._search(self.STUDENT_USERNAME) + self.assertEqual(response.status_code, 200) + results = json.loads(response.content) + + self.assertEqual(len(results), 1) + retrieved_cert = results[0] + + self.assertEqual(retrieved_cert["username"], self.STUDENT_USERNAME) + self.assertEqual(retrieved_cert["course_key"], unicode(self.CERT_COURSE_KEY)) + self.assertEqual(retrieved_cert["created"], self.cert.created_date.isoformat()) + self.assertEqual(retrieved_cert["modified"], self.cert.modified_date.isoformat()) + self.assertEqual(retrieved_cert["grade"], unicode(self.CERT_GRADE)) + self.assertEqual(retrieved_cert["status"], self.CERT_STATUS) + self.assertEqual(retrieved_cert["type"], self.CERT_MODE) + + def _search(self, query): + """Execute a search and return the response. """ + url = reverse("certificates:search") + "?query=" + query + return self.client.get(url) + + +@ddt.ddt +class CertificateRegenerateTests(ModuleStoreTestCase, CertificateSupportTestCase): + """ + Tests for the certificate regeneration end-point used by the support team. + """ + + def setUp(self): + """ + Create a course and enroll the student in the course. + """ + super(CertificateRegenerateTests, self).setUp() + self.course = CourseFactory( + org=self.CERT_COURSE_KEY.org, + course=self.CERT_COURSE_KEY.course, + run=self.CERT_COURSE_KEY.run, + ) + CourseEnrollment.enroll(self.student, self.CERT_COURSE_KEY, self.CERT_MODE) + + @ddt.data( + (GlobalStaff, True), + (SupportStaffRole, True), + (None, False), + ) + @ddt.unpack + def test_access_control(self, role, has_access): + # Create a user and log in + user = UserFactory(username="foo", password="foo") + success = self.client.login(username="foo", password="foo") + self.assertTrue(success, msg="Could not log in") + + # Assign the user to the role + if role is not None: + role().add_users(user) + + # Make a POST request + # Since we're not passing valid parameters, we'll get an error response + # but at least we'll know we have access + response = self._regenerate() + + if has_access: + self.assertEqual(response.status_code, 400) + else: + self.assertEqual(response.status_code, 403) + + def test_regenerate_certificate(self): + response = self._regenerate( + course_key=self.course.id, # pylint: disable=no-member + username=self.STUDENT_USERNAME, + ) + self.assertEqual(response.status_code, 200) + + # Check that the user's certificate was updated + # Since the student hasn't actually passed the course, + # we'd expect that the certificate status will be "notpassing" + cert = GeneratedCertificate.objects.get(user=self.student) + self.assertEqual(cert.status, CertificateStatuses.notpassing) + + def test_regenerate_certificate_missing_params(self): + # Missing username + response = self._regenerate(course_key=self.CERT_COURSE_KEY) + self.assertEqual(response.status_code, 400) + + # Missing course key + response = self._regenerate(username=self.STUDENT_USERNAME) + self.assertEqual(response.status_code, 400) + + def test_regenerate_no_such_user(self): + response = self._regenerate( + course_key=unicode(self.CERT_COURSE_KEY), + username="invalid_username", + ) + self.assertEqual(response.status_code, 400) + + def test_regenerate_no_such_course(self): + response = self._regenerate( + course_key=CourseKey.from_string("edx/invalid/course"), + username=self.STUDENT_USERNAME + ) + self.assertEqual(response.status_code, 400) + + def test_regenerate_user_is_not_enrolled(self): + # Unenroll the user + CourseEnrollment.unenroll(self.student, self.CERT_COURSE_KEY) + + # Can no longer regenerate certificates for the user + response = self._regenerate( + course_key=self.CERT_COURSE_KEY, + username=self.STUDENT_USERNAME + ) + self.assertEqual(response.status_code, 400) + + def test_regenerate_user_has_no_certificate(self): + # Delete the user's certificate + GeneratedCertificate.objects.all().delete() + + # Should be able to regenerate + response = self._regenerate( + course_key=self.CERT_COURSE_KEY, + username=self.STUDENT_USERNAME + ) + self.assertEqual(response.status_code, 200) + + # A new certificate is created + num_certs = GeneratedCertificate.objects.filter(user=self.student).count() + self.assertEqual(num_certs, 1) + + def _regenerate(self, course_key=None, username=None): + """Call the regeneration end-point and return the response. """ + url = reverse("certificates:regenerate_certificate_for_user") + params = {} + + if course_key is not None: + params["course_key"] = course_key + + if username is not None: + params["username"] = username + + return self.client.post(url, params) diff --git a/lms/djangoapps/certificates/tests/test_views.py b/lms/djangoapps/certificates/tests/test_views.py index 98fea8ff8f3b..46c0a5cc2959 100644 --- a/lms/djangoapps/certificates/tests/test_views.py +++ b/lms/djangoapps/certificates/tests/test_views.py @@ -19,6 +19,7 @@ from track.tests import EventTrackingTestCase from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from util.testing import UrlResetMixin from certificates.api import get_certificate_url from certificates.models import ( @@ -36,7 +37,6 @@ LinkedInAddToProfileConfigurationFactory, BadgeAssertionFactory, ) -from lms import urls from util import organizations_helpers as organizations_api FEATURES_WITH_CERTS_ENABLED = settings.FEATURES.copy() @@ -725,12 +725,14 @@ def test_request_certificate_after_passing(self): self.assertEqual(CertificateStatuses.generating, response_json['add_status']) -class TrackShareRedirectTest(ModuleStoreTestCase, EventTrackingTestCase): +class TrackShareRedirectTest(UrlResetMixin, ModuleStoreTestCase, EventTrackingTestCase): """ Verifies the badge image share event is sent out. """ + + @patch.dict(settings.FEATURES, {"ENABLE_OPENBADGES": True}) def setUp(self): - super(TrackShareRedirectTest, self).setUp() + super(TrackShareRedirectTest, self).setUp('certificates.urls') self.client = Client() self.course = CourseFactory.create( org='testorg', number='run1', display_name='trackable course' @@ -742,13 +744,6 @@ def setUp(self): 'issuer': 'http://www.example.com/issuer.json', }, ) - # Enabling the feature flag isn't enough to change the URLs-- they're already loaded by this point. - self.old_patterns = urls.urlpatterns - urls.urlpatterns += (urls.BADGE_SHARE_TRACKER_URL,) - - def tearDown(self): - super(TrackShareRedirectTest, self).tearDown() - urls.urlpatterns = self.old_patterns def test_social_event_sent(self): test_url = '/certificates/badge_share_tracker/{}/social_network/{}/'.format( diff --git a/lms/djangoapps/certificates/urls.py b/lms/djangoapps/certificates/urls.py new file mode 100644 index 000000000000..73865ed091ab --- /dev/null +++ b/lms/djangoapps/certificates/urls.py @@ -0,0 +1,37 @@ +""" +URLs for the certificates app. +""" + +from django.conf.urls import patterns, url +from django.conf import settings + +from certificates import views + +urlpatterns = patterns( + '', + + # Certificates HTML view + url( + r'^user/(?P[^/]*)/course/{course_id}'.format(course_id=settings.COURSE_ID_PATTERN), + views.render_html_view, + name='html_view' + ), + + # End-points used by student support + # The views in the lms/djangoapps/support use these end-points + # to retrieve certificate information and regenerate certificates. + url(r'search', views.search_by_user, name="search"), + url(r'regenerate', views.regenerate_certificate_for_user, name="regenerate_certificate_for_user"), +) + + +if settings.FEATURES.get("ENABLE_OPENBADGES", False): + urlpatterns += ( + url( + r'^badge_share_tracker/{}/(?P[^/]+)/(?P[^/]+)/$'.format( + settings.COURSE_ID_PATTERN + ), + views.track_share_redirect, + name='badge_share_tracker' + ), + ) diff --git a/lms/djangoapps/certificates/views/__init__.py b/lms/djangoapps/certificates/views/__init__.py new file mode 100644 index 000000000000..3b65c22ceeb7 --- /dev/null +++ b/lms/djangoapps/certificates/views/__init__.py @@ -0,0 +1,8 @@ +""" +Aggregate all views exposed by the certificates app. +""" +# pylint: disable=wildcard-import +from .xqueue import * +from .support import * +from .webview import * +from .badges import * diff --git a/lms/djangoapps/certificates/views/badges.py b/lms/djangoapps/certificates/views/badges.py new file mode 100644 index 000000000000..db9ebceed759 --- /dev/null +++ b/lms/djangoapps/certificates/views/badges.py @@ -0,0 +1,31 @@ +""" +Certificate views for open badges. +""" +from django.shortcuts import redirect, get_object_or_404 + +from opaque_keys.edx.locator import CourseLocator +from util.views import ensure_valid_course_key +from eventtracking import tracker +from certificates.models import BadgeAssertion + + +@ensure_valid_course_key +def track_share_redirect(request__unused, course_id, network, student_username): + """ + Tracks when a user downloads a badge for sharing. + """ + course_id = CourseLocator.from_string(course_id) + assertion = get_object_or_404(BadgeAssertion, user__username=student_username, course_id=course_id) + tracker.emit( + 'edx.badge.assertion.shared', { + 'course_id': unicode(course_id), + 'social_network': network, + 'assertion_id': assertion.id, + 'assertion_json_url': assertion.data['json']['id'], + 'assertion_image_url': assertion.image_url, + 'user_id': assertion.user.id, + 'enrollment_mode': assertion.mode, + 'issuer': assertion.data['issuer'], + } + ) + return redirect(assertion.image_url) diff --git a/lms/djangoapps/certificates/views/support.py b/lms/djangoapps/certificates/views/support.py new file mode 100644 index 000000000000..9cabc9baff68 --- /dev/null +++ b/lms/djangoapps/certificates/views/support.py @@ -0,0 +1,185 @@ +""" +Certificate end-points used by the student support UI. + +See lms/djangoapps/support for more details. + +""" +import logging +from functools import wraps + +from django.http import ( + HttpResponse, + HttpResponseBadRequest, + HttpResponseForbidden, + HttpResponseServerError +) +from django.views.decorators.http import require_GET, require_POST +from django.db.models import Q +from django.utils.translation import ugettext as _ + +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey +from xmodule.modulestore.django import modulestore +from student.models import User, CourseEnrollment +from courseware.access import has_access +from util.json_request import JsonResponse +from certificates import api + + +log = logging.getLogger(__name__) + + +def require_certificate_permission(func): + """ + View decorator that requires permission to view and regenerate certificates. + """ + @wraps(func) + def inner(request, *args, **kwargs): # pylint:disable=missing-docstring + if has_access(request.user, "certificates", "global"): + return func(request, *args, **kwargs) + else: + return HttpResponseForbidden() + + return inner + + +@require_GET +@require_certificate_permission +def search_by_user(request): + """ + Search for certificates for a particular user. + + Supports search by either username or email address. + + Arguments: + request (HttpRequest): The request object. + + Returns: + JsonResponse + + Example Usage: + GET /certificates/search?query=bob@example.com + + Response: 200 OK + Content-Type: application/json + [ + { + "username": "bob", + "course_key": "edX/DemoX/Demo_Course", + "type": "verified", + "status": "downloadable", + "download_url": "http://www.example.com/cert.pdf", + "grade": "0.98", + "created": 2015-07-31T00:00:00Z, + "modified": 2015-07-31T00:00:00Z + } + ] + + """ + query = request.GET.get("query") + if not query: + return JsonResponse([]) + + try: + user = User.objects.get(Q(email=query) | Q(username=query)) + except User.DoesNotExist: + return JsonResponse([]) + + certificates = api.get_certificates_for_user(user.username) + for cert in certificates: + cert["course_key"] = unicode(cert["course_key"]) + cert["created"] = cert["created"].isoformat() + cert["modified"] = cert["modified"].isoformat() + + return JsonResponse(certificates) + + +def _validate_regen_post_params(params): + """ + Validate request POST parameters to the regenerate certificates end-point. + + Arguments: + params (QueryDict): Request parameters. + + Returns: tuple of (dict, HttpResponse) + + """ + # Validate the username + try: + username = params.get("username") + user = User.objects.get(username=username) + except User.DoesNotExist: + msg = _("User {username} does not exist").format(username=username) + return None, HttpResponseBadRequest(msg) + + # Validate the course key + try: + course_key = CourseKey.from_string(params.get("course_key")) + except InvalidKeyError: + msg = _("{course_key} is not a valid course key").format(course_key=params.get("course_key")) + return None, HttpResponseBadRequest(msg) + + return {"user": user, "course_key": course_key}, None + + +@require_POST +@require_certificate_permission +def regenerate_certificate_for_user(request): + """ + Regenerate certificates for a user. + + This is meant to be used by support staff through the UI in lms/djangoapps/support + + Arguments: + request (HttpRequest): The request object + + Returns: + HttpResponse + + Example Usage: + + POST /certificates/regenerate + * username: "bob" + * course_key: "edX/DemoX/Demo_Course" + + Response: 200 OK + + """ + # Check the POST parameters, returning a 400 response if they're not valid. + params, response = _validate_regen_post_params(request.POST) + if response is not None: + return response + + # Check that the course exists + course = modulestore().get_course(params["course_key"]) + if course is None: + msg = _("The course {course_key} does not exist").format(course_key=params["course_key"]) + return HttpResponseBadRequest(msg) + + # Check that the user is enrolled in the course + if not CourseEnrollment.is_enrolled(params["user"], params["course_key"]): + msg = _("User {username} is not enrolled in the course {course_key}").format( + username=params["user"].username, + course_key=params["course_key"] + ) + return HttpResponseBadRequest(msg) + + # Attempt to regenerate certificates + try: + api.regenerate_user_certificates(params["user"], params["course_key"], course=course) + except: # pylint: disable=bare-except + # We are pessimistic about the kinds of errors that might get thrown by the + # certificates API. This may be overkill, but we're logging everything so we can + # track down unexpected errors. + log.exception( + "Could not regenerate certificates for user %s in course %s", + params["user"].id, + params["course_key"] + ) + return HttpResponseServerError(_("An unexpected error occurred while regenerating certificates.")) + + log.info( + "Started regenerating certificates for user %s in course %s from the support page.", + params["user"].id, params["course_key"] + ) + return HttpResponse(200) diff --git a/lms/djangoapps/certificates/views.py b/lms/djangoapps/certificates/views/webview.py similarity index 66% rename from lms/djangoapps/certificates/views.py rename to lms/djangoapps/certificates/views/webview.py index 143e73231bef..01303c595027 100644 --- a/lms/djangoapps/certificates/views.py +++ b/lms/djangoapps/certificates/views/webview.py @@ -1,51 +1,38 @@ -"""URL handlers related to certificate handling by LMS""" -from microsite_configuration import microsite +""" +Certificate HTML webview. +""" from datetime import datetime from uuid import uuid4 -from django.shortcuts import redirect, get_object_or_404 -from opaque_keys.edx.locator import CourseLocator -from eventtracking import tracker -import dogstats_wrapper as dog_stats_api -import json import logging from django.conf import settings from django.contrib.auth.models import User -from django.http import HttpResponse, Http404, HttpResponseForbidden from django.utils.translation import ugettext as _ -from django.views.decorators.csrf import csrf_exempt -from django.views.decorators.http import require_POST -from capa.xqueue_interface import XQUEUE_METRIC_NAME +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey +from microsite_configuration import microsite +from edxmako.shortcuts import render_to_response +from eventtracking import tracker +from xmodule.modulestore.django import modulestore +from student.models import LinkedInAddToProfileConfiguration +from courseware.courses import course_image_url +from util import organizations_helpers as organization_api from certificates.api import ( get_active_web_certificate, get_certificate_url, - generate_user_certificates, emit_certificate_event, has_html_certificates_enabled ) from certificates.models import ( - certificate_status_for_student, - CertificateStatuses, GeneratedCertificate, - ExampleCertificate, CertificateHtmlViewConfiguration, CertificateSocialNetworks, BadgeAssertion ) -from edxmako.shortcuts import render_to_response -from util.views import ensure_valid_course_key -from xmodule.modulestore.django import modulestore -from opaque_keys import InvalidKeyError -from opaque_keys.edx.keys import CourseKey -from opaque_keys.edx.locations import SlashSeparatedCourseKey -from student.models import LinkedInAddToProfileConfiguration -from util.json_request import JsonResponse, JsonResponseBadRequest -from util.bad_request_rate_limiter import BadRequestRateLimiter -from courseware.courses import course_image_url -from util import organizations_helpers as organization_api -logger = logging.getLogger(__name__) + +log = logging.getLogger(__name__) class CourseDoesNotExist(Exception): @@ -55,211 +42,6 @@ class CourseDoesNotExist(Exception): pass -@csrf_exempt -def request_certificate(request): - """Request the on-demand creation of a certificate for some user, course. - - A request doesn't imply a guarantee that such a creation will take place. - We intentionally use the same machinery as is used for doing certification - at the end of a course run, so that we can be sure users get graded and - then if and only if they pass, do they get a certificate issued. - """ - if request.method == "POST": - if request.user.is_authenticated(): - username = request.user.username - student = User.objects.get(username=username) - course_key = SlashSeparatedCourseKey.from_deprecated_string(request.POST.get('course_id')) - course = modulestore().get_course(course_key, depth=2) - - status = certificate_status_for_student(student, course_key)['status'] - if status in [CertificateStatuses.unavailable, CertificateStatuses.notpassing, CertificateStatuses.error]: - log_msg = u'Grading and certification requested for user %s in course %s via /request_certificate call' - logger.info(log_msg, username, course_key) - status = generate_user_certificates(student, course_key, course=course) - return HttpResponse(json.dumps({'add_status': status}), mimetype='application/json') - return HttpResponse(json.dumps({'add_status': 'ERRORANONYMOUSUSER'}), mimetype='application/json') - - -@csrf_exempt -def update_certificate(request): - """ - Will update GeneratedCertificate for a new certificate or - modify an existing certificate entry. - - See models.py for a state diagram of certificate states - - This view should only ever be accessed by the xqueue server - """ - - status = CertificateStatuses - if request.method == "POST": - - xqueue_body = json.loads(request.POST.get('xqueue_body')) - xqueue_header = json.loads(request.POST.get('xqueue_header')) - - try: - course_key = SlashSeparatedCourseKey.from_deprecated_string(xqueue_body['course_id']) - - cert = GeneratedCertificate.objects.get( - user__username=xqueue_body['username'], - course_id=course_key, - key=xqueue_header['lms_key']) - - except GeneratedCertificate.DoesNotExist: - logger.critical( - 'Unable to lookup certificate\n' - 'xqueue_body: %s\n' - 'xqueue_header: %s', - xqueue_body, - xqueue_header - ) - - return HttpResponse(json.dumps({ - 'return_code': 1, - 'content': 'unable to lookup key'}), - mimetype='application/json') - - if 'error' in xqueue_body: - cert.status = status.error - if 'error_reason' in xqueue_body: - - # Hopefully we will record a meaningful error - # here if something bad happened during the - # certificate generation process - # - # example: - # (aamorm BerkeleyX/CS169.1x/2012_Fall) - # : - # HTTP error (reason=error(32, 'Broken pipe'), filename=None) : - # certificate_agent.py:175 - - cert.error_reason = xqueue_body['error_reason'] - else: - if cert.status in [status.generating, status.regenerating]: - cert.download_uuid = xqueue_body['download_uuid'] - cert.verify_uuid = xqueue_body['verify_uuid'] - cert.download_url = xqueue_body['url'] - cert.status = status.downloadable - elif cert.status in [status.deleting]: - cert.status = status.deleted - else: - logger.critical( - 'Invalid state for cert update: %s', cert.status - ) - return HttpResponse( - json.dumps({ - 'return_code': 1, - 'content': 'invalid cert status' - }), - mimetype='application/json' - ) - - dog_stats_api.increment(XQUEUE_METRIC_NAME, tags=[ - u'action:update_certificate', - u'course_id:{}'.format(cert.course_id) - ]) - - cert.save() - return HttpResponse(json.dumps({'return_code': 0}), - mimetype='application/json') - - -@csrf_exempt -@require_POST -def update_example_certificate(request): - """Callback from the XQueue that updates example certificates. - - Example certificates are used to verify that certificate - generation is configured correctly for a course. - - Unlike other certificates, example certificates - are not associated with a particular user or displayed - to students. - - For this reason, we need a different end-point to update - the status of generated example certificates. - - Arguments: - request (HttpRequest) - - Returns: - HttpResponse (200): Status was updated successfully. - HttpResponse (400): Invalid parameters. - HttpResponse (403): Rate limit exceeded for bad requests. - HttpResponse (404): Invalid certificate identifier or access key. - - """ - logger.info(u"Received response for example certificate from XQueue.") - - rate_limiter = BadRequestRateLimiter() - - # Check the parameters and rate limits - # If these are invalid, return an error response. - if rate_limiter.is_rate_limit_exceeded(request): - logger.info(u"Bad request rate limit exceeded for update example certificate end-point.") - return HttpResponseForbidden("Rate limit exceeded") - - if 'xqueue_body' not in request.POST: - logger.info(u"Missing parameter 'xqueue_body' for update example certificate end-point") - rate_limiter.tick_bad_request_counter(request) - return JsonResponseBadRequest("Parameter 'xqueue_body' is required.") - - if 'xqueue_header' not in request.POST: - logger.info(u"Missing parameter 'xqueue_header' for update example certificate end-point") - rate_limiter.tick_bad_request_counter(request) - return JsonResponseBadRequest("Parameter 'xqueue_header' is required.") - - try: - xqueue_body = json.loads(request.POST['xqueue_body']) - xqueue_header = json.loads(request.POST['xqueue_header']) - except (ValueError, TypeError): - logger.info(u"Could not decode params to example certificate end-point as JSON.") - rate_limiter.tick_bad_request_counter(request) - return JsonResponseBadRequest("Parameters must be JSON-serialized.") - - # Attempt to retrieve the example certificate record - # so we can update the status. - try: - uuid = xqueue_body.get('username') - access_key = xqueue_header.get('lms_key') - cert = ExampleCertificate.objects.get(uuid=uuid, access_key=access_key) - except ExampleCertificate.DoesNotExist: - # If we are unable to retrieve the record, it means the uuid or access key - # were not valid. This most likely means that the request is NOT coming - # from the XQueue. Return a 404 and increase the bad request counter - # to protect against a DDOS attack. - logger.info(u"Could not find example certificate with uuid '%s' and access key '%s'", uuid, access_key) - rate_limiter.tick_bad_request_counter(request) - raise Http404 - - if 'error' in xqueue_body: - # If an error occurs, save the error message so we can fix the issue. - error_reason = xqueue_body.get('error_reason') - cert.update_status(ExampleCertificate.STATUS_ERROR, error_reason=error_reason) - logger.warning( - ( - u"Error occurred during example certificate generation for uuid '%s'. " - u"The error response was '%s'." - ), uuid, error_reason - ) - else: - # If the certificate generated successfully, save the download URL - # so we can display the example certificate. - download_url = xqueue_body.get('url') - if download_url is None: - rate_limiter.tick_bad_request_counter(request) - logger.warning(u"No download URL provided for example certificate with uuid '%s'.", uuid) - return JsonResponseBadRequest( - "Parameter 'download_url' is required for successfully generated certificates." - ) - else: - cert.update_status(ExampleCertificate.STATUS_SUCCESS, download_url=download_url) - logger.info("Successfully updated example certificate with uuid '%s'.", uuid) - - # Let the XQueue know that we handled the response - return JsonResponse({'return_code': 0}) - - def get_certificate_description(mode, certificate_type, platform_name): """ :return certificate_type_description on the basis of current mode @@ -291,6 +73,7 @@ def get_certificate_description(mode, certificate_type, platform_name): # pylint: disable=bad-continuation +# 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 @@ -567,7 +350,7 @@ def render_html_view(request, user_id, course_id): } ) except BadgeAssertion.DoesNotExist: - logger.warn( + log.warn( "Could not find badge for %s on course %s.", user.id, course_key, @@ -619,25 +402,3 @@ def render_html_view(request, user_id, course_id): # FINALLY, generate and send the output the client return render_to_response("certificates/valid.html", context) - - -@ensure_valid_course_key -def track_share_redirect(request__unused, course_id, network, student_username): - """ - Tracks when a user downloads a badge for sharing. - """ - course_id = CourseLocator.from_string(course_id) - assertion = get_object_or_404(BadgeAssertion, user__username=student_username, course_id=course_id) - tracker.emit( - 'edx.badge.assertion.shared', { - 'course_id': unicode(course_id), - 'social_network': network, - 'assertion_id': assertion.id, - 'assertion_json_url': assertion.data['json']['id'], - 'assertion_image_url': assertion.image_url, - 'user_id': assertion.user.id, - 'enrollment_mode': assertion.mode, - 'issuer': assertion.data['issuer'], - } - ) - return redirect(assertion.image_url) diff --git a/lms/djangoapps/certificates/views/xqueue.py b/lms/djangoapps/certificates/views/xqueue.py new file mode 100644 index 000000000000..453fe9c0b923 --- /dev/null +++ b/lms/djangoapps/certificates/views/xqueue.py @@ -0,0 +1,232 @@ +""" +Views used by XQueue certificate generation. +""" +import json +import logging + +from django.contrib.auth.models import User +from django.http import HttpResponse, Http404, HttpResponseForbidden +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_POST +import dogstats_wrapper as dog_stats_api + +from capa.xqueue_interface import XQUEUE_METRIC_NAME +from xmodule.modulestore.django import modulestore +from opaque_keys.edx.locations import SlashSeparatedCourseKey +from util.json_request import JsonResponse, JsonResponseBadRequest +from util.bad_request_rate_limiter import BadRequestRateLimiter +from certificates.api import generate_user_certificates +from certificates.models import ( + certificate_status_for_student, + CertificateStatuses, + GeneratedCertificate, + ExampleCertificate, +) + + +log = logging.getLogger(__name__) + + +@csrf_exempt +def request_certificate(request): + """Request the on-demand creation of a certificate for some user, course. + + A request doesn't imply a guarantee that such a creation will take place. + We intentionally use the same machinery as is used for doing certification + at the end of a course run, so that we can be sure users get graded and + then if and only if they pass, do they get a certificate issued. + """ + if request.method == "POST": + if request.user.is_authenticated(): + username = request.user.username + student = User.objects.get(username=username) + course_key = SlashSeparatedCourseKey.from_deprecated_string(request.POST.get('course_id')) + course = modulestore().get_course(course_key, depth=2) + + status = certificate_status_for_student(student, course_key)['status'] + if status in [CertificateStatuses.unavailable, CertificateStatuses.notpassing, CertificateStatuses.error]: + log_msg = u'Grading and certification requested for user %s in course %s via /request_certificate call' + log.info(log_msg, username, course_key) + status = generate_user_certificates(student, course_key, course=course) + return HttpResponse(json.dumps({'add_status': status}), mimetype='application/json') + return HttpResponse(json.dumps({'add_status': 'ERRORANONYMOUSUSER'}), mimetype='application/json') + + +@csrf_exempt +def update_certificate(request): + """ + Will update GeneratedCertificate for a new certificate or + modify an existing certificate entry. + + See models.py for a state diagram of certificate states + + This view should only ever be accessed by the xqueue server + """ + + status = CertificateStatuses + if request.method == "POST": + + xqueue_body = json.loads(request.POST.get('xqueue_body')) + xqueue_header = json.loads(request.POST.get('xqueue_header')) + + try: + course_key = SlashSeparatedCourseKey.from_deprecated_string(xqueue_body['course_id']) + + cert = GeneratedCertificate.objects.get( + user__username=xqueue_body['username'], + course_id=course_key, + key=xqueue_header['lms_key']) + + except GeneratedCertificate.DoesNotExist: + log.critical( + 'Unable to lookup certificate\n' + 'xqueue_body: %s\n' + 'xqueue_header: %s', + xqueue_body, + xqueue_header + ) + + return HttpResponse(json.dumps({ + 'return_code': 1, + 'content': 'unable to lookup key' + }), mimetype='application/json') + + if 'error' in xqueue_body: + cert.status = status.error + if 'error_reason' in xqueue_body: + + # Hopefully we will record a meaningful error + # here if something bad happened during the + # certificate generation process + # + # example: + # (aamorm BerkeleyX/CS169.1x/2012_Fall) + # : + # HTTP error (reason=error(32, 'Broken pipe'), filename=None) : + # certificate_agent.py:175 + + cert.error_reason = xqueue_body['error_reason'] + else: + if cert.status in [status.generating, status.regenerating]: + cert.download_uuid = xqueue_body['download_uuid'] + cert.verify_uuid = xqueue_body['verify_uuid'] + cert.download_url = xqueue_body['url'] + cert.status = status.downloadable + elif cert.status in [status.deleting]: + cert.status = status.deleted + else: + log.critical( + 'Invalid state for cert update: %s', cert.status + ) + return HttpResponse( + json.dumps({ + 'return_code': 1, + 'content': 'invalid cert status' + }), + mimetype='application/json' + ) + + dog_stats_api.increment(XQUEUE_METRIC_NAME, tags=[ + u'action:update_certificate', + u'course_id:{}'.format(cert.course_id) + ]) + + cert.save() + return HttpResponse(json.dumps({'return_code': 0}), + mimetype='application/json') + + +@csrf_exempt +@require_POST +def update_example_certificate(request): + """Callback from the XQueue that updates example certificates. + + Example certificates are used to verify that certificate + generation is configured correctly for a course. + + Unlike other certificates, example certificates + are not associated with a particular user or displayed + to students. + + For this reason, we need a different end-point to update + the status of generated example certificates. + + Arguments: + request (HttpRequest) + + Returns: + HttpResponse (200): Status was updated successfully. + HttpResponse (400): Invalid parameters. + HttpResponse (403): Rate limit exceeded for bad requests. + HttpResponse (404): Invalid certificate identifier or access key. + + """ + log.info(u"Received response for example certificate from XQueue.") + + rate_limiter = BadRequestRateLimiter() + + # Check the parameters and rate limits + # If these are invalid, return an error response. + if rate_limiter.is_rate_limit_exceeded(request): + log.info(u"Bad request rate limit exceeded for update example certificate end-point.") + return HttpResponseForbidden("Rate limit exceeded") + + if 'xqueue_body' not in request.POST: + log.info(u"Missing parameter 'xqueue_body' for update example certificate end-point") + rate_limiter.tick_bad_request_counter(request) + return JsonResponseBadRequest("Parameter 'xqueue_body' is required.") + + if 'xqueue_header' not in request.POST: + log.info(u"Missing parameter 'xqueue_header' for update example certificate end-point") + rate_limiter.tick_bad_request_counter(request) + return JsonResponseBadRequest("Parameter 'xqueue_header' is required.") + + try: + xqueue_body = json.loads(request.POST['xqueue_body']) + xqueue_header = json.loads(request.POST['xqueue_header']) + except (ValueError, TypeError): + log.info(u"Could not decode params to example certificate end-point as JSON.") + rate_limiter.tick_bad_request_counter(request) + return JsonResponseBadRequest("Parameters must be JSON-serialized.") + + # Attempt to retrieve the example certificate record + # so we can update the status. + try: + uuid = xqueue_body.get('username') + access_key = xqueue_header.get('lms_key') + cert = ExampleCertificate.objects.get(uuid=uuid, access_key=access_key) + except ExampleCertificate.DoesNotExist: + # If we are unable to retrieve the record, it means the uuid or access key + # were not valid. This most likely means that the request is NOT coming + # from the XQueue. Return a 404 and increase the bad request counter + # to protect against a DDOS attack. + log.info(u"Could not find example certificate with uuid '%s' and access key '%s'", uuid, access_key) + rate_limiter.tick_bad_request_counter(request) + raise Http404 + + if 'error' in xqueue_body: + # If an error occurs, save the error message so we can fix the issue. + error_reason = xqueue_body.get('error_reason') + cert.update_status(ExampleCertificate.STATUS_ERROR, error_reason=error_reason) + log.warning( + ( + u"Error occurred during example certificate generation for uuid '%s'. " + u"The error response was '%s'." + ), uuid, error_reason + ) + else: + # If the certificate generated successfully, save the download URL + # so we can display the example certificate. + download_url = xqueue_body.get('url') + if download_url is None: + rate_limiter.tick_bad_request_counter(request) + log.warning(u"No download URL provided for example certificate with uuid '%s'.", uuid) + return JsonResponseBadRequest( + "Parameter 'download_url' is required for successfully generated certificates." + ) + else: + cert.update_status(ExampleCertificate.STATUS_SUCCESS, download_url=download_url) + log.info("Successfully updated example certificate with uuid '%s'.", uuid) + + # Let the XQueue know that we handled the response + return JsonResponse({'return_code': 0}) diff --git a/lms/djangoapps/courseware/access.py b/lms/djangoapps/courseware/access.py index e544afab5ed6..4f2378cc9ebb 100644 --- a/lms/djangoapps/courseware/access.py +++ b/lms/djangoapps/courseware/access.py @@ -43,6 +43,7 @@ CourseInstructorRole, CourseStaffRole, GlobalStaff, + SupportStaffRole, OrgInstructorRole, OrgStaffRole, ) @@ -636,6 +637,8 @@ def _has_access_string(user, action, perm): Valid actions: 'staff' -- global staff access. + 'support' -- access to student support functionality + 'certificates' --- access to view and regenerate certificates for other users. """ def check_staff(): @@ -647,8 +650,19 @@ def check_staff(): return ACCESS_DENIED return ACCESS_GRANTED if GlobalStaff().has_user(user) else ACCESS_DENIED + def check_support(): + """Check that the user has access to the support UI. """ + if perm != 'global': + return ACCESS_DENIED + return ( + ACCESS_GRANTED if GlobalStaff().has_user(user) or SupportStaffRole().has_user(user) + else ACCESS_DENIED + ) + checkers = { - 'staff': check_staff + 'staff': check_staff, + 'support': check_support, + 'certificates': check_support, } return _dispatch(checkers, action, user, perm) diff --git a/lms/djangoapps/dashboard/support_urls.py b/lms/djangoapps/dashboard/support_urls.py deleted file mode 100644 index d16432b31766..000000000000 --- a/lms/djangoapps/dashboard/support_urls.py +++ /dev/null @@ -1,13 +0,0 @@ -""" -URLs for support dashboard -""" -from django.conf.urls import patterns, url -from django.contrib.auth.decorators import permission_required - -from dashboard import support - -urlpatterns = patterns( - '', - url(r'^$', permission_required('student.change_courseenrollment')(support.SupportDash.as_view()), name="support_dashboard"), - url(r'^refund/?$', permission_required('student.change_courseenrollment')(support.Refund.as_view()), name="support_refund"), -) diff --git a/lms/djangoapps/support/__init__.py b/lms/djangoapps/support/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/support/decorators.py b/lms/djangoapps/support/decorators.py new file mode 100644 index 000000000000..090fb882b421 --- /dev/null +++ b/lms/djangoapps/support/decorators.py @@ -0,0 +1,24 @@ +""" +Decorators used by the support app. +""" +from functools import wraps + +from django.http import HttpResponseForbidden +from django.contrib.auth.decorators import login_required + +from courseware.access import has_access + + +def require_support_permission(func): + """ + View decorator that requires the user to have permission to use the support UI. + """ + @wraps(func) + def inner(request, *args, **kwargs): # pylint: disable=missing-docstring + if has_access(request.user, "support", "global"): + return func(request, *args, **kwargs) + else: + return HttpResponseForbidden() + + # In order to check the user's permission, he/she needs to be logged in. + return login_required(inner) diff --git a/lms/djangoapps/support/models.py b/lms/djangoapps/support/models.py new file mode 100644 index 000000000000..7b54f0f4da30 --- /dev/null +++ b/lms/djangoapps/support/models.py @@ -0,0 +1,3 @@ +""" +Models for the student support app. +""" diff --git a/lms/djangoapps/support/static/support/js/certificates_factory.js b/lms/djangoapps/support/static/support/js/certificates_factory.js new file mode 100644 index 000000000000..753e4e1c5e03 --- /dev/null +++ b/lms/djangoapps/support/static/support/js/certificates_factory.js @@ -0,0 +1,13 @@ +;(function (define) { + 'use strict'; + + define(['jquery', 'underscore', 'support/js/views/certificates'], + function ($, _, CertificatesView) { + return function (options) { + options = _.extend(options, { + el: $('.certificates-content') + }); + return new CertificatesView(options).render(); + }; + }); +}).call(this, define || RequireJS.define); diff --git a/lms/djangoapps/support/static/support/js/collections/certificate.js b/lms/djangoapps/support/static/support/js/collections/certificate.js new file mode 100644 index 000000000000..b959ba6864ea --- /dev/null +++ b/lms/djangoapps/support/static/support/js/collections/certificate.js @@ -0,0 +1,21 @@ +;(function (define) { + 'use strict'; + define(['backbone', 'support/js/models/certificate'], + function(Backbone, CertModel) { + return Backbone.Collection.extend({ + model: CertModel, + + initialize: function(options) { + this.userQuery = options.userQuery || ''; + }, + + setUserQuery: function(userQuery) { + this.userQuery = userQuery; + }, + + url: function() { + return '/certificates/search?query=' + this.userQuery; + } + }); + }); +}).call(this, define || RequireJS.define); diff --git a/lms/djangoapps/support/static/support/js/models/certificate.js b/lms/djangoapps/support/static/support/js/models/certificate.js new file mode 100644 index 000000000000..7cb7d02eda53 --- /dev/null +++ b/lms/djangoapps/support/static/support/js/models/certificate.js @@ -0,0 +1,17 @@ +(function (define) { + 'use strict'; + define(['backbone'], function (Backbone) { + return Backbone.Model.extend({ + defaults: { + username: null, + course_key: null, + type: null, + status: null, + download_url: null, + grade: null, + created: null, + modified: null + } + }); + }); +}).call(this, define || RequireJS.define); diff --git a/lms/djangoapps/support/static/support/js/spec/certificates_spec.js b/lms/djangoapps/support/static/support/js/spec/certificates_spec.js new file mode 100644 index 000000000000..a4bf26909b74 --- /dev/null +++ b/lms/djangoapps/support/static/support/js/spec/certificates_spec.js @@ -0,0 +1,163 @@ +define([ + 'jquery', + 'common/js/spec_helpers/ajax_helpers', + 'support/js/views/certificates' +], function($, AjaxHelpers, CertificatesView) { + 'use strict'; + + describe('CertificatesView', function() { + + var view = null, + + SEARCH_RESULTS = [ + { + 'username': 'student', + 'status': 'notpassing', + 'created': '2015-08-05T17:32:25+00:00', + 'grade': '0.0', + 'type': 'honor', + 'course_key': 'course-v1:edX+DemoX+Demo_Course', + 'download_url': null, + 'modified': '2015-08-06T19:47:07+00:00' + }, + { + 'username': 'student', + 'status': 'downloadable', + 'created': '2015-08-05T17:53:33+00:00', + 'grade': '1.0', + 'type': 'verified', + 'course_key': 'edx/test/2015', + 'download_url': 'http://www.example.com/certificate.pdf', + 'modified': '2015-08-06T19:47:05+00:00' + }, + ], + + getSearchResults = function() { + var results = []; + + $('.certificates-results tr').each(function(rowIndex, rowValue) { + var columns = []; + $(rowValue).children('td').each(function(colIndex, colValue) { + columns[colIndex] = $(colValue).html(); + }); + + if (columns.length > 0) { + results.push(columns); + } + }); + + return results; + }, + + searchFor = function(query, requests, response) { + // Enter the search term and submit + view.setUserQuery(query); + view.triggerSearch(); + + // Simulate a response from the server + AjaxHelpers.expectJsonRequest(requests, 'GET', '/certificates/search?query=student@example.com'); + AjaxHelpers.respondWithJson(requests, response); + }, + + regenerateCerts = function(username, courseKey) { + var sel = '.btn-cert-regenerate[data-course-key="' + courseKey + '"]'; + $(sel).click(); + }; + + beforeEach(function () { + setFixtures('
'); + view = new CertificatesView({ + el: $('.certificates-content') + }).render(); + }); + + it('renders itself', function() { + expect($('.certificates-search').length).toEqual(1); + expect($('.certificates-results').length).toEqual(1); + }); + + it('searches for certificates and displays results', function() { + var requests = AjaxHelpers.requests(this), + results = []; + + searchFor('student@example.com', requests, SEARCH_RESULTS); + results = getSearchResults(); + + // Expect that the results displayed on the page match the results + // returned by the server. + expect(results.length).toEqual(SEARCH_RESULTS.length); + + // Check the first row of results + expect(results[0][0]).toEqual(SEARCH_RESULTS[0].course_key); + expect(results[0][1]).toEqual(SEARCH_RESULTS[0].type); + expect(results[0][2]).toEqual(SEARCH_RESULTS[0].status); + expect(results[0][3]).toContain('Not available'); + expect(results[0][4]).toEqual(SEARCH_RESULTS[0].grade); + expect(results[0][5]).toEqual(SEARCH_RESULTS[0].modified); + + // Check the second row of results + expect(results[1][0]).toEqual(SEARCH_RESULTS[1].course_key); + expect(results[1][1]).toEqual(SEARCH_RESULTS[1].type); + expect(results[1][2]).toEqual(SEARCH_RESULTS[1].status); + expect(results[1][3]).toContain(SEARCH_RESULTS[1].download_url); + expect(results[1][4]).toEqual(SEARCH_RESULTS[1].grade); + expect(results[1][5]).toEqual(SEARCH_RESULTS[1].modified); + }); + + it('searches for certificates and displays a message when there are no results', function() { + var requests = AjaxHelpers.requests(this), + results = []; + + searchFor('student@example.com', requests, []); + results = getSearchResults(); + + // Expect that no results are found + expect(results.length).toEqual(0); + + // Expect a message saying there are no results + expect($('.certificates-results').text()).toContain('No results'); + }); + + it('automatically searches for an initial query if one is provided', function() { + var requests = AjaxHelpers.requests(this), + results = []; + + // Re-render the view, this time providing an initial query. + view = new CertificatesView({ + el: $('.certificates-content'), + userQuery: 'student@example.com' + }).render(); + + // Simulate a response from the server + AjaxHelpers.expectJsonRequest(requests, 'GET', '/certificates/search?query=student@example.com'); + AjaxHelpers.respondWithJson(requests, SEARCH_RESULTS); + + // Check the search results + results = getSearchResults(); + expect(results.length).toEqual(SEARCH_RESULTS.length); + }); + + it('regenerates a certificate for a student', function() { + var requests = AjaxHelpers.requests(this); + + // Trigger a search + searchFor('student@example.com', requests, SEARCH_RESULTS); + + // Click the button to regenerate certificates for a user + regenerateCerts('student', 'course-v1:edX+DemoX+Demo_Course'); + + // Expect a request to the server + AjaxHelpers.expectPostRequest( + requests, + '/certificates/regenerate', + $.param({ + username: 'student', + course_key: 'course-v1:edX+DemoX+Demo_Course' + }) + ); + + // Respond with success + AjaxHelpers.respondWithJson(requests, ''); + }); + }); +}); diff --git a/lms/djangoapps/support/static/support/js/views/certificates.js b/lms/djangoapps/support/static/support/js/views/certificates.js new file mode 100644 index 000000000000..39e96f594cc0 --- /dev/null +++ b/lms/djangoapps/support/static/support/js/views/certificates.js @@ -0,0 +1,148 @@ +;(function (define) { + 'use strict'; + + define([ + 'backbone', + 'underscore', + 'gettext', + 'support/js/collections/certificate', + 'text!support/templates/certificates.underscore', + 'text!support/templates/certificates_results.underscore' + ], function (Backbone, _, gettext, CertCollection, certificatesTpl, resultsTpl) { + return Backbone.View.extend({ + events: { + 'submit .certificates-form': 'search', + 'click .btn-cert-regenerate': 'regenerateCertificate' + }, + + initialize: function(options) { + _.bindAll(this, 'search', 'updateCertificates', 'regenerateCertificate', 'handleSearchError'); + this.certificates = new CertCollection({}); + this.initialQuery = options.userQuery || null; + }, + + render: function() { + this.$el.html(_.template(certificatesTpl)); + + // If there is an initial query, then immediately trigger a search. + // This is useful because it allows users to share search results: + // if the URL contains ?query="foo" then anyone who loads that URL + // will automatically search for "foo". + if (this.initialQuery) { + this.setUserQuery(this.initialQuery); + this.triggerSearch(); + } + + return this; + }, + + renderResults: function() { + var context = { + certificates: this.certificates, + }; + + this.setResults(_.template(resultsTpl, context)); + }, + + renderError: function(error) { + var errorMsg = error || gettext('An unexpected error occurred. Please try again.'); + this.setResults(errorMsg); + }, + + search: function(event) { + + // Fetch the certificate collection for the given user + var query = this.getUserQuery(), + url = '/support/certificates?query=' + query; + + // Prevent form submission, since we're handling it ourselves. + event.preventDefault(); + + // Push a URL into history with the search query as a GET parameter. + // That way, if the user reloads the page or sends someone the link + // then the same search will be performed on page load. + window.history.pushState({}, window.document.title, url); + + // Perform a search for the user's certificates. + this.disableButtons(); + this.certificates.setUserQuery(query); + this.certificates.fetch({ + success: this.updateCertificates, + error: this.handleSearchError + }); + }, + + regenerateCertificate: function(event) { + var $button = $(event.target); + + // Regenerate certificates for a particular user and course. + // If this is successful, reload the certificate results so they show + // the updated status. + this.disableButtons(); + $.ajax({ + url: '/certificates/regenerate', + type: 'POST', + data: { + username: $button.data('username'), + course_key: $button.data('course-key'), + }, + context: this, + success: function() { + this.certificates.fetch({ + success: this.updateCertificates, + error: this.handleSearchError, + }); + }, + error: this.handleRegenerateError + }); + }, + + updateCertificates: function() { + this.renderResults(); + this.enableButtons(); + }, + + handleSearchError: function(jqxhr) { + this.renderError(jqxhr.responseText); + this.enableButtons(); + }, + + handleRegenerateError: function(jqxhr) { + // Since there are multiple "regenerate" buttons on the page, + // it's difficult to show the error message in the UI. + // Since this page is used only by internal staff, I think the + // quick-and-easy way is reasonable. + alert(jqxhr.responseText); + this.enableButtons(); + }, + + triggerSearch: function() { + $('.certificates-form').submit(); + }, + + getUserQuery: function() { + return $('.certificates-form input[name="query"]').val(); + }, + + setUserQuery: function(query) { + $('.certificates-form input[name="query"]').val(query); + }, + + setResults: function(html) { + $(".certificates-results", this.$el).html(html); + }, + + disableButtons: function() { + $('.btn-disable-on-submit') + .addClass("is-disabled") + .attr("disabled", true); + }, + + enableButtons: function() { + $('.btn-disable-on-submit') + .removeClass('is-disabled') + .attr('disabled', false); + } + }); + }); +}).call(this, define || RequireJS.define); diff --git a/lms/djangoapps/support/static/support/templates/certificates.underscore b/lms/djangoapps/support/static/support/templates/certificates.underscore new file mode 100644 index 000000000000..e6f0ab69c667 --- /dev/null +++ b/lms/djangoapps/support/static/support/templates/certificates.underscore @@ -0,0 +1,17 @@ + + + +
+
diff --git a/lms/djangoapps/support/static/support/templates/certificates_results.underscore b/lms/djangoapps/support/static/support/templates/certificates_results.underscore new file mode 100644 index 000000000000..72a4a43433b0 --- /dev/null +++ b/lms/djangoapps/support/static/support/templates/certificates_results.underscore @@ -0,0 +1,42 @@ +<% if (certificates.length === 0) { %> +

<%- gettext("No results") %>

+<% } else { %> + + + + + + + + + + + <% for (var i = 0; i < certificates.length; i++) { + var cert = certificates.at(i); + %> + + + + + + + + + + <% } %> +
<%- gettext("Course Key") %><%- gettext("Type") %><%- gettext("Status") %><%- gettext("Download URL") %><%- gettext("Grade") %><%- gettext("Last Updated") %>
<%- cert.get("course_key") %><%- cert.get("type") %><%- cert.get("status") %> + <% if (cert.get("download_url")) { %> + ">Download + <%- gettext("Download the user's certificate") %> + <% } else { %> + <%- gettext("Not available") %> + <% } %> + <%- cert.get("grade") %><%- cert.get("modified") %> + + <%- gettext("Regenerate the user's certificate") %> +
+<% } %> diff --git a/lms/djangoapps/support/tests/__init__.py b/lms/djangoapps/support/tests/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/dashboard/tests/test_support.py b/lms/djangoapps/support/tests/test_refund.py similarity index 91% rename from lms/djangoapps/dashboard/tests/test_support.py rename to lms/djangoapps/support/tests/test_refund.py index e2b7378768ff..4e7f4a6c8269 100644 --- a/lms/djangoapps/dashboard/tests/test_support.py +++ b/lms/djangoapps/support/tests/test_refund.py @@ -1,21 +1,25 @@ """ -Tests for support dashboard +Tests for refunds on the support dashboard + +DEPRECATION WARNING: +This test suite is deliberately separate from the other view tests +so we can easily deprecate it once the transition from shoppingcart +to the E-Commerce service is complete. + """ import datetime -from django.contrib.auth.models import Permission from django.test.client import Client -from nose.plugins.attrib import attr from course_modes.models import CourseMode from shoppingcart.models import CertificateItem, Order from student.models import CourseEnrollment +from student.roles import SupportStaffRole from student.tests.factories import UserFactory from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -@attr('shard_1') class RefundTests(ModuleStoreTestCase): """ Tests for the manual refund page @@ -33,8 +37,9 @@ def setUp(self): email='test_admin+support@edx.org', password='foo' ) - self.admin.user_permissions.add(Permission.objects.get(codename='change_courseenrollment')) + SupportStaffRole().add_users(self.admin) self.client.login(username=self.admin.username, password='foo') + self.student = UserFactory.create( username='student', email='student+refund@edx.org' @@ -67,7 +72,7 @@ def test_support_access(self): self.assertTrue(response.status_code, 200) # users without the permission can't access support - self.admin.user_permissions.clear() + SupportStaffRole().remove_users(self.admin) response = self.client.get('/support/') self.assertTrue(response.status_code, 302) diff --git a/lms/djangoapps/support/tests/test_views.py b/lms/djangoapps/support/tests/test_views.py new file mode 100644 index 000000000000..04e6faaa7090 --- /dev/null +++ b/lms/djangoapps/support/tests/test_views.py @@ -0,0 +1,118 @@ +""" +Tests for support views. +""" + +import ddt +from django.test import TestCase +from django.core.urlresolvers import reverse + +from student.roles import GlobalStaff, SupportStaffRole +from student.tests.factories import UserFactory + + +class SupportViewTestCase(TestCase): + """ + Base class for support view tests. + """ + + USERNAME = "support" + EMAIL = "support@example.com" + PASSWORD = "support" + + def setUp(self): + """Create a user and log in. """ + super(SupportViewTestCase, self).setUp() + self.user = UserFactory(username=self.USERNAME, email=self.EMAIL, password=self.PASSWORD) + success = self.client.login(username=self.USERNAME, password=self.PASSWORD) + self.assertTrue(success, msg="Could not log in") + + +@ddt.ddt +class SupportViewAccessTests(SupportViewTestCase): + """ + Tests for access control of support views. + """ + + @ddt.data( + ("support:index", GlobalStaff, True), + ("support:index", SupportStaffRole, True), + ("support:index", None, False), + ("support:certificates", GlobalStaff, True), + ("support:certificates", SupportStaffRole, True), + ("support:certificates", None, False), + ("support:refund", GlobalStaff, True), + ("support:refund", SupportStaffRole, True), + ("support:refund", None, False), + ) + @ddt.unpack + def test_access(self, url_name, role, has_access): + if role is not None: + role().add_users(self.user) + + url = reverse(url_name) + response = self.client.get(url) + + if has_access: + self.assertEqual(response.status_code, 200) + else: + self.assertEqual(response.status_code, 403) + + @ddt.data("support:index", "support:certificates", "support:refund") + def test_require_login(self, url_name): + url = reverse(url_name) + + # Log out then try to retrieve the page + self.client.logout() + response = self.client.get(url) + + # Expect a redirect to the login page + redirect_url = "{login_url}?next={original_url}".format( + login_url=reverse("signin_user"), + original_url=url, + ) + self.assertRedirects(response, redirect_url) + + +class SupportViewIndexTests(SupportViewTestCase): + """ + Tests for the support index view. + """ + + EXPECTED_URL_NAMES = [ + "support:certificates", + "support:refund", + ] + + def setUp(self): + """Make the user support staff. """ + super(SupportViewIndexTests, self).setUp() + SupportStaffRole().add_users(self.user) + + def test_index(self): + response = self.client.get(reverse("support:index")) + self.assertContains(response, "Support") + + # Check that all the expected links appear on the index page. + for url_name in self.EXPECTED_URL_NAMES: + self.assertContains(response, reverse(url_name)) + + +class SupportViewCertificatesTests(SupportViewTestCase): + """ + Tests for the certificates support view. + """ + def setUp(self): + """Make the user support staff. """ + super(SupportViewCertificatesTests, self).setUp() + SupportStaffRole().add_users(self.user) + + def test_certificates_no_query(self): + # Check that an empty initial query is passed to the JavaScript client correctly. + response = self.client.get(reverse("support:certificates")) + self.assertContains(response, "userQuery: ''") + + def test_certificates_with_query(self): + # Check that an initial query is passed to the JavaScript client. + url = reverse("support:certificates") + "?query=student@example.com" + response = self.client.get(url) + self.assertContains(response, "userQuery: 'student@example.com'") diff --git a/lms/djangoapps/support/urls.py b/lms/djangoapps/support/urls.py new file mode 100644 index 000000000000..332369f9b874 --- /dev/null +++ b/lms/djangoapps/support/urls.py @@ -0,0 +1,13 @@ +""" +URLs for the student support app. +""" +from django.conf.urls import patterns, url + +from support import views + +urlpatterns = patterns( + '', + url(r'^$', views.index, name="index"), + url(r'^certificates/?$', views.CertificatesSupportView.as_view(), name="certificates"), + url(r'^refund/?$', views.RefundSupportView.as_view(), name="refund"), +) diff --git a/lms/djangoapps/support/views/__init__.py b/lms/djangoapps/support/views/__init__.py new file mode 100644 index 000000000000..be473fdf3adb --- /dev/null +++ b/lms/djangoapps/support/views/__init__.py @@ -0,0 +1,7 @@ +""" +Aggregate all views for the support app. +""" +# pylint: disable=wildcard-import +from .index import * +from .certificate import * +from .refund import * diff --git a/lms/djangoapps/support/views/certificate.py b/lms/djangoapps/support/views/certificate.py new file mode 100644 index 000000000000..1f6fc23e0f72 --- /dev/null +++ b/lms/djangoapps/support/views/certificate.py @@ -0,0 +1,35 @@ +""" +Certificate tool in the student support app. +""" +from django.views.generic import View +from django.utils.decorators import method_decorator + +from edxmako.shortcuts import render_to_response +from support.decorators import require_support_permission + + +class CertificatesSupportView(View): + """ + View for viewing and regenerating certificates for users. + + This is used by the support team to re-issue certificates + to users if something went wrong during the initial certificate generation, + such as: + + * The user's name was spelled incorrectly. + * The user later earned a higher grade and wants it on his/her certificate and dashboard. + * The user accidentally received an honor code certificate because his/her + verification expired before certs were generated. + + Most of the heavy lifting is performed client-side through API + calls directly to the certificates app. + + """ + + @method_decorator(require_support_permission) + def get(self, request): + """Render the certificates support view. """ + context = { + "user_query": request.GET.get("query", "") + } + return render_to_response("support/certificates.html", context) diff --git a/lms/djangoapps/support/views/index.py b/lms/djangoapps/support/views/index.py new file mode 100644 index 000000000000..2831eafcc6ec --- /dev/null +++ b/lms/djangoapps/support/views/index.py @@ -0,0 +1,34 @@ +""" +Index view for the support app. +""" +from django.core.urlresolvers import reverse_lazy +from django.utils.translation import ugettext_lazy as _ + +from edxmako.shortcuts import render_to_response +from support.decorators import require_support_permission + + +SUPPORT_INDEX_URLS = [ + { + "url": reverse_lazy("support:certificates"), + "name": _("Certificates"), + "description": _("View and regenerate certificates."), + }, + + # DEPRECATION WARNING: We can remove this end-point + # once shoppingcart has been replaced by the E-Commerce service. + { + "url": reverse_lazy("support:refund"), + "name": _("Manual Refund"), + "description": _("Track refunds issued directly through CyberSource."), + }, +] + + +@require_support_permission +def index(request): # pylint: disable=unused-argument + """Render the support index view. """ + context = { + "urls": SUPPORT_INDEX_URLS + } + return render_to_response("support/index.html", context) diff --git a/lms/djangoapps/dashboard/support.py b/lms/djangoapps/support/views/refund.py similarity index 69% rename from lms/djangoapps/dashboard/support.py rename to lms/djangoapps/support/views/refund.py index eda7a31218f3..bf922cb3ba41 100644 --- a/lms/djangoapps/dashboard/support.py +++ b/lms/djangoapps/support/views/refund.py @@ -1,19 +1,30 @@ """ -Views for support dashboard +Views for manual refunds in the student support UI. + +This interface is used by the support team to track refunds +entered manually in CyberSource (our payment gateway). + +DEPRECATION WARNING: +We are currently in the process of replacing lms/djangoapps/shoppingcart +with an E-Commerce service that supports automatic refunds. Once that +transition is complete, we can remove this view. + """ import logging from django.contrib.auth.models import User from django.views.generic.edit import FormView -from django.views.generic.base import TemplateView from django.utils.translation import ugettext as _ from django.http import HttpResponseRedirect from django.contrib import messages from django import forms +from django.utils.decorators import method_decorator + from student.models import CourseEnrollment from opaque_keys.edx.keys import CourseKey from opaque_keys import InvalidKeyError from opaque_keys.edx.locations import SlashSeparatedCourseKey +from support.decorators import require_support_permission log = logging.getLogger(__name__) @@ -59,11 +70,16 @@ def clean(self): if user and course_id: self.cleaned_data['enrollment'] = enrollment = CourseEnrollment.get_or_create_enrollment(user, course_id) if enrollment.refundable(): - raise forms.ValidationError(_("Course {course_id} not past the refund window.").format(course_id=course_id)) + msg = _("Course {course_id} not past the refund window.").format(course_id=course_id) + raise forms.ValidationError(msg) try: - self.cleaned_data['cert'] = enrollment.certificateitem_set.filter(mode='verified', status='purchased')[0] + self.cleaned_data['cert'] = enrollment.certificateitem_set.filter( + mode='verified', + status='purchased' + )[0] except IndexError: - raise forms.ValidationError(_("No order found for {user} in course {course_id}").format(user=user, course_id=course_id)) + msg = _("No order found for {user} in course {course_id}").format(user=user, course_id=course_id) + raise forms.ValidationError(msg) return self.cleaned_data def is_valid(self): @@ -82,21 +98,18 @@ def is_valid(self): return is_valid -class SupportDash(TemplateView): - """ - Support dashboard view - """ - template_name = 'dashboard/support.html' - - -class Refund(FormView): +class RefundSupportView(FormView): """ Refund form view """ - template_name = 'dashboard/_dashboard_refund.html' + template_name = 'support/refund.html' form_class = RefundForm success_url = '/support/' + @method_decorator(require_support_permission) + def dispatch(self, *args, **kwargs): + return super(RefundSupportView, self).dispatch(*args, **kwargs) + def get_context_data(self, **kwargs): """ extra context data to add to page @@ -119,6 +132,18 @@ def form_valid(self, form): enrollment.update_enrollment(is_active=False) log.info(u"%s manually refunded %s %s", self.request.user, user, course_id) - messages.success(self.request, _("Unenrolled {user} from {course_id}").format(user=user, course_id=course_id)) - messages.success(self.request, _("Refunded {cost} for order id {order_id}").format(cost=cert.unit_cost, order_id=cert.order.id)) + messages.success( + self.request, + _("Unenrolled {user} from {course_id}").format( + user=user, + course_id=course_id + ) + ) + messages.success( + self.request, + _("Refunded {cost} for order id {order_id}").format( + cost=cert.unit_cost, + order_id=cert.order.id + ) + ) return HttpResponseRedirect('/support/refund/') diff --git a/lms/envs/common.py b/lms/envs/common.py index efc358446f90..19c3e3503245 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1835,6 +1835,9 @@ 'bulk_email', 'branding', + # Student support tools + 'support', + # External auth (OpenID, shib) 'external_auth', 'django_openid_auth', diff --git a/lms/static/js/spec/main.js b/lms/static/js/spec/main.js index b6e3aa8bca14..a6a42c97aa39 100644 --- a/lms/static/js/spec/main.js +++ b/lms/static/js/spec/main.js @@ -799,6 +799,7 @@ 'lms/include/teams/js/spec/views/teams_tab_spec.js', 'lms/include/teams/js/spec/views/topic_card_spec.js', 'lms/include/teams/js/spec/views/topics_spec.js', + 'lms/include/support/js/spec/certificates_spec.js' ]); }).call(this, requirejs, define); diff --git a/lms/static/js_test.yml b/lms/static/js_test.yml index 4aa034d22fba..b6eaabd62299 100644 --- a/lms/static/js_test.yml +++ b/lms/static/js_test.yml @@ -71,11 +71,13 @@ src_paths: - common/js - teams/js - xmodule_js/common_static/coffee + - support/js # Paths to spec (test) JavaScript files spec_paths: - js/spec - teams/js/spec + - support/js/spec # Paths to fixture files (optional) # The fixture path will be set automatically when using jasmine-jquery. @@ -105,6 +107,7 @@ fixture_paths: - templates/discovery - common/templates - teams/templates + - support/templates requirejs: paths: diff --git a/lms/static/lms/js/build.js b/lms/static/lms/js/build.js index 81d8568dd944..a79d96c84784 100644 --- a/lms/static/lms/js/build.js +++ b/lms/static/lms/js/build.js @@ -28,7 +28,8 @@ 'js/student_account/views/finish_auth_factory', 'js/student_profile/views/learner_profile_factory', 'js/views/message_banner', - 'teams/js/teams_tab_factory' + 'teams/js/teams_tab_factory', + 'support/js/certificates_factory' ]), /** diff --git a/lms/static/sass/_build-lms.scss b/lms/static/sass/_build-lms.scss index 8e2bb34ce214..5b0298dc046a 100644 --- a/lms/static/sass/_build-lms.scss +++ b/lms/static/sass/_build-lms.scss @@ -53,6 +53,7 @@ @import 'views/decoupled-verification'; @import 'views/shoppingcart'; @import 'views/homepage'; +@import 'views/support'; @import 'course/auto-cert'; // applications diff --git a/lms/static/sass/multicourse/_error-pages.scss b/lms/static/sass/multicourse/_error-pages.scss index 200dd2d32c64..075c99b31395 100644 --- a/lms/static/sass/multicourse/_error-pages.scss +++ b/lms/static/sass/multicourse/_error-pages.scss @@ -8,8 +8,13 @@ section.outside-app { margin-bottom: ($baseline*2); } - p { + p, ul, form { max-width: 600px; margin: 0 auto; + font: normal 1em/1.6em $serif; + } + + li { + margin-top: 12px; } } diff --git a/lms/static/sass/views/_support.scss b/lms/static/sass/views/_support.scss new file mode 100644 index 000000000000..d99a7dc5817b --- /dev/null +++ b/lms/static/sass/views/_support.scss @@ -0,0 +1,32 @@ +// lms - views - support +// These styles are included on admin pages used by the support team. +// =================================================================== + +.certificates-search { + margin: 40px 0; + + input[name="query"] { + width: 476px; + } +} + + +.certificates-results { + table { + margin: 0 auto; + } + + th { + text-align: center; + text-decoration: underline; + } + + th, td { + padding: 10px 10px; + vertical-align: middle; + } +} + +.btn-cert-regenerate { + font-size: 12px; +} diff --git a/lms/static/support b/lms/static/support new file mode 120000 index 000000000000..9fcf60187bd3 --- /dev/null +++ b/lms/static/support @@ -0,0 +1 @@ +../djangoapps/support/static/support \ No newline at end of file diff --git a/lms/templates/certificates/_accomplishment-banner.html b/lms/templates/certificates/_accomplishment-banner.html index c920d9c808a2..466f2e361523 100644 --- a/lms/templates/certificates/_accomplishment-banner.html +++ b/lms/templates/certificates/_accomplishment-banner.html @@ -57,7 +57,7 @@

${_("Print or share your certificate:")}

<% facebook_share_text = _("I completed the {course_title} course on {platform_name}.").format(course_title=accomplishment_course_title, platform_name=platform_name) twitter_share_text = _("I completed a course on {platform_name}. Take a look at my certificate.").format(platform_name=platform_name) - share_url = request.build_absolute_uri(reverse('cert_html_view', kwargs=dict(user_id=str(user.id),course_id=unicode(course_id)))) + share_url = request.build_absolute_uri(reverse('certificates:html_view', kwargs=dict(user_id=str(user.id),course_id=unicode(course_id)))) if share_settings.get('CERTIFICATE_FACEBOOK_TEXT', None): facebook_share_text = share_settings.get('CERTIFICATE_FACEBOOK_TEXT') if share_settings.get('CERTIFICATE_TWITTER_TEXT', None): diff --git a/lms/templates/dashboard/support.html b/lms/templates/dashboard/support.html deleted file mode 100644 index dd324d3dc2e5..000000000000 --- a/lms/templates/dashboard/support.html +++ /dev/null @@ -1,10 +0,0 @@ -{% extends "main_django.html" %} -{% load i18n %} -{% block title %}Support Dashboard{% endblock %} - -{% block body %} - - -{% endblock %} diff --git a/lms/templates/support/certificates.html b/lms/templates/support/certificates.html new file mode 100644 index 000000000000..15fa9b2139c0 --- /dev/null +++ b/lms/templates/support/certificates.html @@ -0,0 +1,26 @@ +<%! +from django.core.urlresolvers import reverse +from django.utils.translation import ugettext as _ +%> +<%namespace name='static' file='../static_content.html'/> + +<%inherit file="../main.html" /> + +<%block name="js_extra"> +<%static:require_module module_name="support/js/certificates_factory" class_name="CertificatesFactory"> + new CertificatesFactory({ + userQuery: '${ user_query }' + }); + + + +<%block name="pagetitle"> +${_("Student Support")} + + +<%block name="content"> +
+

${_("Student Support: Certificates")}

+
+
+ diff --git a/lms/templates/support/index.html b/lms/templates/support/index.html new file mode 100644 index 000000000000..ec25d7c175c3 --- /dev/null +++ b/lms/templates/support/index.html @@ -0,0 +1,23 @@ +## mako +<%! +from django.core.urlresolvers import reverse +from django.utils.translation import ugettext as _ +%> +<%namespace name='static' file='../static_content.html'/> + +<%inherit file="../main.html" /> + +<%block name="pagetitle"> +${_("Student Support")} + + +<%block name="content"> +
+

${_("Student Support")}

+ +
+ diff --git a/lms/templates/dashboard/_dashboard_refund.html b/lms/templates/support/refund.html similarity index 100% rename from lms/templates/dashboard/_dashboard_refund.html rename to lms/templates/support/refund.html diff --git a/lms/urls.py b/lms/urls.py index ac14a73852da..3ba04eadf503 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -16,11 +16,6 @@ urlpatterns = ( '', - # certificate view - url(r'^update_certificate$', 'certificates.views.update_certificate'), - url(r'^update_example_certificate$', 'certificates.views.update_example_certificate'), - url(r'^request_certificate$', 'certificates.views.request_certificate'), - url(r'^$', 'branding.views.index', name="root"), # Main marketing page, or redirect to courseware url(r'^dashboard$', 'student.views.dashboard', name="dashboard"), url(r'^login_ajax$', 'student.views.login_user', name="login"), @@ -147,10 +142,10 @@ ) urlpatterns += ( - url(r'^support/', include('dashboard.support_urls')), + url(r'^support/', include('support.urls', app_name="support", namespace='support')), ) -#Semi-static views (these need to be rendered and have the login bar, but don't change) +# Semi-static views (these need to be rendered and have the login bar, but don't change) urlpatterns += ( url(r'^404$', 'static_template_view.views.render', {'template': '404.html'}, name="404"), @@ -661,23 +656,16 @@ ), ) -# Certificates Web/HTML View +# Certificates urlpatterns += ( - url(r'^certificates/user/(?P[^/]*)/course/{course_id}'.format(course_id=settings.COURSE_ID_PATTERN), - 'certificates.views.render_html_view', name='cert_html_view'), -) + url(r'^certificates/', include('certificates.urls', app_name="certificates", namespace="certificates")), -BADGE_SHARE_TRACKER_URL = url( - r'^certificates/badge_share_tracker/{}/(?P[^/]+)/(?P[^/]+)/$'.format( - settings.COURSE_ID_PATTERN - ), - 'certificates.views.track_share_redirect', - name='badge_share_tracker' + # Backwards compatibility with XQueue, which uses URLs that are not prefixed with /certificates/ + url(r'^update_certificate$', 'certificates.views.update_certificate'), + url(r'^update_example_certificate$', 'certificates.views.update_example_certificate'), + url(r'^request_certificate$', 'certificates.views.request_certificate'), ) -if settings.FEATURES.get('ENABLE_OPENBADGES', False): - urlpatterns += (BADGE_SHARE_TRACKER_URL,) - # XDomain proxy urlpatterns += ( url(r'^xdomain_proxy.html$', 'cors_csrf.views.xdomain_proxy', name='xdomain_proxy'),