Commit ad9d9eb0 by Will Daly

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.
parent 330ef94e
......@@ -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))
......
"""
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(
......
"""
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'
),
)
"""
Aggregate all views exposed by the certificates app.
"""
# pylint: disable=wildcard-import
from .xqueue import *
from .support import *
from .webview import *
from .badges import *
"""
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)
"""
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)
"""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)
"""
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)
......
"""
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"),
)
"""
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)
"""
Models for the student support app.
"""
;(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);
;(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);
(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);
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, '');
});
});
});
;(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);
<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>
<% 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>
<% } %>
"""
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)
......
"""
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'")
"""
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"),
)
"""
Aggregate all views for the support app.
"""
# pylint: disable=wildcard-import
from .index import *
from .certificate import *
from .refund import *
"""
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)
"""
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)
"""
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;
}
}
// 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;
}
../djangoapps/support/static/support
\ No newline at end of file
......@@ -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):
......
{% 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 %}
<%!
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>
## 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>
......@@ -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'),
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment