Support team UI for regenerating certificates
* Add new role for support staff. * Move dashboard/support functionality into a new Django app called "support". * Add support view for searching and regenerating certificates. * Refactor certificates views into separate files.
This commit is contained in:
@@ -310,6 +310,17 @@ class CourseCreatorRole(RoleBase):
|
||||
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
|
||||
|
||||
@@ -29,6 +29,54 @@ from certificates.queue import XQueueCertInterface
|
||||
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:
|
||||
|
||||
@@ -176,7 +176,7 @@ class BadgeHandler(object):
|
||||
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)
|
||||
|
||||
@@ -155,7 +155,14 @@ class XQueueCertInterface(object):
|
||||
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):
|
||||
|
||||
|
||||
@@ -175,7 +175,12 @@ class RegenerateCertificatesTest(CertificateManagementTest):
|
||||
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))
|
||||
|
||||
|
||||
259
lms/djangoapps/certificates/tests/test_support_views.py
Normal file
259
lms/djangoapps/certificates/tests/test_support_views.py
Normal file
@@ -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)
|
||||
@@ -19,6 +19,7 @@ from student.tests.factories import UserFactory, CourseEnrollmentFactory
|
||||
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 @@ from certificates.tests.factories import (
|
||||
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 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase):
|
||||
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 @@ class TrackShareRedirectTest(ModuleStoreTestCase, EventTrackingTestCase):
|
||||
'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(
|
||||
|
||||
37
lms/djangoapps/certificates/urls.py
Normal file
37
lms/djangoapps/certificates/urls.py
Normal file
@@ -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<user_id>[^/]*)/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<network>[^/]+)/(?P<student_username>[^/]+)/$'.format(
|
||||
settings.COURSE_ID_PATTERN
|
||||
),
|
||||
views.track_share_redirect,
|
||||
name='badge_share_tracker'
|
||||
),
|
||||
)
|
||||
8
lms/djangoapps/certificates/views/__init__.py
Normal file
8
lms/djangoapps/certificates/views/__init__.py
Normal file
@@ -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 *
|
||||
31
lms/djangoapps/certificates/views/badges.py
Normal file
31
lms/djangoapps/certificates/views/badges.py
Normal file
@@ -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)
|
||||
185
lms/djangoapps/certificates/views/support.py
Normal file
185
lms/djangoapps/certificates/views/support.py
Normal file
@@ -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)
|
||||
@@ -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)
|
||||
# <class 'simples3.bucket.S3Error'>:
|
||||
# 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)
|
||||
232
lms/djangoapps/certificates/views/xqueue.py
Normal file
232
lms/djangoapps/certificates/views/xqueue.py
Normal file
@@ -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)
|
||||
# <class 'simples3.bucket.S3Error'>:
|
||||
# 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})
|
||||
@@ -43,6 +43,7 @@ from student.roles import (
|
||||
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 _has_access_string(user, action, perm):
|
||||
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)
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
0
lms/djangoapps/support/__init__.py
Normal file
0
lms/djangoapps/support/__init__.py
Normal file
24
lms/djangoapps/support/decorators.py
Normal file
24
lms/djangoapps/support/decorators.py
Normal file
@@ -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)
|
||||
3
lms/djangoapps/support/models.py
Normal file
3
lms/djangoapps/support/models.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Models for the student support app.
|
||||
"""
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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('<div class="certificates-content"></div>');
|
||||
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, '');
|
||||
});
|
||||
});
|
||||
});
|
||||
148
lms/djangoapps/support/static/support/js/views/certificates.js
Normal file
148
lms/djangoapps/support/static/support/js/views/certificates.js
Normal file
@@ -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);
|
||||
@@ -0,0 +1,17 @@
|
||||
|
||||
<div class="certificates-search">
|
||||
<form class="certificates-form">
|
||||
<label class="sr" for="certificate-query-input"><%- gettext("Search") %></label>
|
||||
<input
|
||||
id="certificate-query-input"
|
||||
type="text"
|
||||
name="query"
|
||||
value=""
|
||||
placeholder="<%- gettext("username or email") %>">
|
||||
</input>
|
||||
<input type="submit" value="<%- gettext("Search") %>" class="btn-disable-on-submit"></input>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="certificates-results">
|
||||
</div>
|
||||
@@ -0,0 +1,42 @@
|
||||
<% if (certificates.length === 0) { %>
|
||||
<p><%- gettext("No results") %></p>
|
||||
<% } else { %>
|
||||
<table>
|
||||
<tr>
|
||||
<th><%- gettext("Course Key") %></th>
|
||||
<th><%- gettext("Type") %></th>
|
||||
<th><%- gettext("Status") %></th>
|
||||
<th><%- gettext("Download URL") %></th>
|
||||
<th><%- gettext("Grade") %></th>
|
||||
<th><%- gettext("Last Updated") %></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
<% for (var i = 0; i < certificates.length; i++) {
|
||||
var cert = certificates.at(i);
|
||||
%>
|
||||
<tr>
|
||||
<td><%- cert.get("course_key") %></td>
|
||||
<td><%- cert.get("type") %></td>
|
||||
<td><%- cert.get("status") %></td>
|
||||
<td>
|
||||
<% if (cert.get("download_url")) { %>
|
||||
<a href="<%- cert.get("download_url") %>">Download</a>
|
||||
<span class="sr"><%- gettext("Download the user's certificate") %></span>
|
||||
<% } else { %>
|
||||
<%- gettext("Not available") %>
|
||||
<% } %>
|
||||
</td>
|
||||
<td><%- cert.get("grade") %></td>
|
||||
<td><%- cert.get("modified") %></td>
|
||||
<td>
|
||||
<button
|
||||
class="btn-cert-regenerate btn-disable-on-submit"
|
||||
data-username="<%- cert.get("username") %>"
|
||||
data-course-key="<%- cert.get("course_key") %>"
|
||||
><%- gettext("Regenerate") %></button>
|
||||
<span class="sr"><%- gettext("Regenerate the user's certificate") %></span>
|
||||
</td>
|
||||
</tr>
|
||||
<% } %>
|
||||
</table>
|
||||
<% } %>
|
||||
0
lms/djangoapps/support/tests/__init__.py
Normal file
0
lms/djangoapps/support/tests/__init__.py
Normal file
@@ -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 @@ class RefundTests(ModuleStoreTestCase):
|
||||
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 @@ class RefundTests(ModuleStoreTestCase):
|
||||
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)
|
||||
|
||||
118
lms/djangoapps/support/tests/test_views.py
Normal file
118
lms/djangoapps/support/tests/test_views.py
Normal file
@@ -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'")
|
||||
13
lms/djangoapps/support/urls.py
Normal file
13
lms/djangoapps/support/urls.py
Normal file
@@ -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"),
|
||||
)
|
||||
7
lms/djangoapps/support/views/__init__.py
Normal file
7
lms/djangoapps/support/views/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
Aggregate all views for the support app.
|
||||
"""
|
||||
# pylint: disable=wildcard-import
|
||||
from .index import *
|
||||
from .certificate import *
|
||||
from .refund import *
|
||||
35
lms/djangoapps/support/views/certificate.py
Normal file
35
lms/djangoapps/support/views/certificate.py
Normal file
@@ -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)
|
||||
34
lms/djangoapps/support/views/index.py
Normal file
34
lms/djangoapps/support/views/index.py
Normal file
@@ -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)
|
||||
@@ -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 @@ class RefundForm(forms.Form):
|
||||
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 @@ class RefundForm(forms.Form):
|
||||
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 @@ class Refund(FormView):
|
||||
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/')
|
||||
@@ -1835,6 +1835,9 @@ INSTALLED_APPS = (
|
||||
'bulk_email',
|
||||
'branding',
|
||||
|
||||
# Student support tools
|
||||
'support',
|
||||
|
||||
# External auth (OpenID, shib)
|
||||
'external_auth',
|
||||
'django_openid_auth',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'
|
||||
]),
|
||||
|
||||
/**
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
@import 'views/decoupled-verification';
|
||||
@import 'views/shoppingcart';
|
||||
@import 'views/homepage';
|
||||
@import 'views/support';
|
||||
@import 'course/auto-cert';
|
||||
|
||||
// applications
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
32
lms/static/sass/views/_support.scss
Normal file
32
lms/static/sass/views/_support.scss
Normal file
@@ -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;
|
||||
}
|
||||
1
lms/static/support
Symbolic link
1
lms/static/support
Symbolic link
@@ -0,0 +1 @@
|
||||
../djangoapps/support/static/support
|
||||
@@ -57,7 +57,7 @@ from django.core.urlresolvers import reverse
|
||||
<%
|
||||
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):
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
{% extends "main_django.html" %}
|
||||
{% load i18n %}
|
||||
{% block title %}<title>Support Dashboard</title>{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
|
||||
<ul>
|
||||
<li><a href="/support/refund/">{% trans "Manual Refund" %}</a></li>
|
||||
</ul>
|
||||
{% endblock %}
|
||||
26
lms/templates/support/certificates.html
Normal file
26
lms/templates/support/certificates.html
Normal file
@@ -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 }'
|
||||
});
|
||||
</%static:require_module>
|
||||
</%block>
|
||||
|
||||
<%block name="pagetitle">
|
||||
${_("Student Support")}
|
||||
</%block>
|
||||
|
||||
<%block name="content">
|
||||
<section class="container outside-app">
|
||||
<h1>${_("Student Support: Certificates")}</h1>
|
||||
<div class="certificates-content"></div>
|
||||
</section>
|
||||
</%block>
|
||||
23
lms/templates/support/index.html
Normal file
23
lms/templates/support/index.html
Normal file
@@ -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>
|
||||
|
||||
<%block name="content">
|
||||
<section class="container outside-app">
|
||||
<h1>${_("Student Support")}</h1>
|
||||
<ul>
|
||||
% for url in urls:
|
||||
<li><a href="${url["url"]}">${unicode(url["name"])}</a>: ${unicode(url["description"])}</li>
|
||||
% endfor
|
||||
</ul>
|
||||
</section>
|
||||
</%block>
|
||||
28
lms/urls.py
28
lms/urls.py
@@ -16,11 +16,6 @@ if settings.DEBUG or settings.FEATURES.get('ENABLE_DJANGO_ADMIN_SITE'):
|
||||
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 @@ if settings.FEATURES["ENABLE_SYSADMIN_DASHBOARD"]:
|
||||
)
|
||||
|
||||
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 @@ if settings.FEATURES.get('ENABLE_OAUTH2_PROVIDER'):
|
||||
),
|
||||
)
|
||||
|
||||
# Certificates Web/HTML View
|
||||
# Certificates
|
||||
urlpatterns += (
|
||||
url(r'^certificates/user/(?P<user_id>[^/]*)/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<network>[^/]+)/(?P<student_username>[^/]+)/$'.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'),
|
||||
|
||||
Reference in New Issue
Block a user