· 11 years ago · Jul 28, 2015, 11:03 PM
1"""
2Instructor Dashboard API views
3
4JSON views which the instructor dashboard requests.
5
6Many of these GETs may become PUTs in the future.
7"""
8import StringIO
9import json
10import logging
11import re
12import time
13import requests
14from django.conf import settings
15from django.views.decorators.csrf import ensure_csrf_cookie
16from django.views.decorators.http import require_POST
17from django.views.decorators.cache import cache_control
18from django.core.exceptions import ValidationError, PermissionDenied
19from django.core.mail.message import EmailMessage
20from django.db import IntegrityError
21from django.core.urlresolvers import reverse
22from django.core.validators import validate_email
23from django.utils.translation import ugettext as _
24from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseNotFound
25from django.utils.html import strip_tags
26from django.shortcuts import redirect
27import string # pylint: disable=deprecated-module
28import random
29import unicodecsv
30import urllib
31import decimal
32from student import auth
33from student.roles import GlobalStaff, CourseSalesAdminRole, CourseFinanceAdminRole
34from util.file import (
35 store_uploaded_file, course_and_time_based_filename_generator,
36 FileValidationException, UniversalNewlineIterator
37)
38from util.json_request import JsonResponse
39from instructor.views.instructor_task_helpers import extract_email_features, extract_task_features
40
41from microsite_configuration import microsite
42
43from courseware.access import has_access
44from courseware.courses import get_course_with_access, get_course_by_id
45from django.contrib.auth.models import User
46from django_comment_client.utils import has_forum_access
47from django_comment_common.models import (
48 Role,
49 FORUM_ROLE_ADMINISTRATOR,
50 FORUM_ROLE_MODERATOR,
51 FORUM_ROLE_COMMUNITY_TA,
52)
53from edxmako.shortcuts import render_to_response, render_to_string
54from courseware.models import StudentModule
55from shoppingcart.models import (
56 Coupon,
57 CourseRegistrationCode,
58 RegistrationCodeRedemption,
59 Invoice,
60 CourseMode,
61 CourseRegistrationCodeInvoiceItem,
62)
63from student.models import (
64 CourseEnrollment, unique_id_for_user, anonymous_id_for_user,
65 UserProfile, Registration, EntranceExamConfiguration,
66 ManualEnrollmentAudit, UNENROLLED_TO_ALLOWEDTOENROLL, ALLOWEDTOENROLL_TO_ENROLLED,
67 ENROLLED_TO_ENROLLED, ENROLLED_TO_UNENROLLED, UNENROLLED_TO_ENROLLED,
68 UNENROLLED_TO_UNENROLLED, ALLOWEDTOENROLL_TO_UNENROLLED, DEFAULT_TRANSITION_STATE
69)
70import instructor_task.api
71from instructor_task.api_helper import AlreadyRunningError
72from instructor_task.models import ReportStore
73import instructor.enrollment as enrollment
74from instructor.enrollment import (
75 get_user_email_language,
76 enroll_email,
77 send_mail_to_student,
78 get_email_params,
79 send_beta_role_email,
80 unenroll_email,
81)
82from instructor.access import list_with_level, allow_access, revoke_access, ROLES, update_forum_role
83from instructor.offline_gradecalc import student_grades
84import instructor_analytics.basic
85import instructor_analytics.distributions
86import instructor_analytics.csvs
87import csv
88from openedx.core.djangoapps.user_api.preferences.api import get_user_preference, set_user_preference
89from instructor.views import INVOICE_KEY
90
91from submissions import api as sub_api # installed from the edx-submissions repository
92
93from certificates import api as certs_api
94
95from bulk_email.models import CourseEmail
96
97from .tools import (
98 dump_student_extensions,
99 dump_module_extensions,
100 find_unit,
101 get_student_from_identifier,
102 require_student_from_identifier,
103 handle_dashboard_error,
104 parse_datetime,
105 set_due_date_extension,
106 strip_if_string,
107 bulk_email_is_enabled_for_course,
108 add_block_ids,
109)
110from opaque_keys.edx.keys import CourseKey
111from opaque_keys.edx.locations import SlashSeparatedCourseKey
112from opaque_keys import InvalidKeyError
113from openedx.core.djangoapps.course_groups.cohorts import is_course_cohorted
114
115log = logging.getLogger(__name__)
116
117
118def common_exceptions_400(func):
119 """
120 Catches common exceptions and renders matching 400 errors.
121 (decorator without arguments)
122 """
123 def wrapped(request, *args, **kwargs): # pylint: disable=missing-docstring
124 use_json = (request.is_ajax() or
125 request.META.get("HTTP_ACCEPT", "").startswith("application/json"))
126 try:
127 return func(request, *args, **kwargs)
128 except User.DoesNotExist:
129 message = _("User does not exist.")
130 if use_json:
131 return JsonResponse({"error": message}, 400)
132 else:
133 return HttpResponseBadRequest(message)
134 except AlreadyRunningError:
135 message = _("Task is already running.")
136 if use_json:
137 return JsonResponse({"error": message}, 400)
138 else:
139 return HttpResponseBadRequest(message)
140 return wrapped
141
142
143def require_query_params(*args, **kwargs):
144 """
145 Checks for required paremters or renders a 400 error.
146 (decorator with arguments)
147
148 `args` is a *list of required GET parameter names.
149 `kwargs` is a **dict of required GET parameter names
150 to string explanations of the parameter
151 """
152 required_params = []
153 required_params += [(arg, None) for arg in args]
154 required_params += [(key, kwargs[key]) for key in kwargs]
155 # required_params = e.g. [('action', 'enroll or unenroll'), ['emails', None]]
156
157 def decorator(func): # pylint: disable=missing-docstring
158 def wrapped(*args, **kwargs): # pylint: disable=missing-docstring
159 request = args[0]
160
161 error_response_data = {
162 'error': 'Missing required query parameter(s)',
163 'parameters': [],
164 'info': {},
165 }
166
167 for (param, extra) in required_params:
168 default = object()
169 if request.GET.get(param, default) == default:
170 error_response_data['parameters'].append(param)
171 error_response_data['info'][param] = extra
172
173 if len(error_response_data['parameters']) > 0:
174 return JsonResponse(error_response_data, status=400)
175 else:
176 return func(*args, **kwargs)
177 return wrapped
178 return decorator
179
180
181def require_post_params(*args, **kwargs):
182 """
183 Checks for required parameters or renders a 400 error.
184 (decorator with arguments)
185
186 Functions like 'require_query_params', but checks for
187 POST parameters rather than GET parameters.
188 """
189 required_params = []
190 required_params += [(arg, None) for arg in args]
191 required_params += [(key, kwargs[key]) for key in kwargs]
192 # required_params = e.g. [('action', 'enroll or unenroll'), ['emails', None]]
193
194 def decorator(func): # pylint: disable=missing-docstring
195 def wrapped(*args, **kwargs): # pylint: disable=missing-docstring
196 request = args[0]
197
198 error_response_data = {
199 'error': 'Missing required query parameter(s)',
200 'parameters': [],
201 'info': {},
202 }
203
204 for (param, extra) in required_params:
205 default = object()
206 if request.POST.get(param, default) == default:
207 error_response_data['parameters'].append(param)
208 error_response_data['info'][param] = extra
209
210 if len(error_response_data['parameters']) > 0:
211 return JsonResponse(error_response_data, status=400)
212 else:
213 return func(*args, **kwargs)
214 return wrapped
215 return decorator
216
217
218def require_level(level):
219 """
220 Decorator with argument that requires an access level of the requesting
221 user. If the requirement is not satisfied, returns an
222 HttpResponseForbidden (403).
223
224 Assumes that request is in args[0].
225 Assumes that course_id is in kwargs['course_id'].
226
227 `level` is in ['instructor', 'staff']
228 if `level` is 'staff', instructors will also be allowed, even
229 if they are not in the staff group.
230 """
231 if level not in ['instructor', 'staff']:
232 raise ValueError("unrecognized level '{}'".format(level))
233
234 def decorator(func): # pylint: disable=missing-docstring
235 def wrapped(*args, **kwargs): # pylint: disable=missing-docstring
236 request = args[0]
237 course = get_course_by_id(CourseKey.from_string(kwargs['course_id']))
238
239 if has_access(request.user, level, course):
240 return func(*args, **kwargs)
241 else:
242 return HttpResponseForbidden()
243 return wrapped
244 return decorator
245
246
247def require_global_staff(func):
248 """View decorator that requires that the user have global staff permissions. """
249 def wrapped(request, *args, **kwargs): # pylint: disable=missing-docstring
250 if GlobalStaff().has_user(request.user):
251 return func(request, *args, **kwargs)
252 else:
253 return HttpResponseForbidden(
254 u"Must be {platform_name} staff to perform this action.".format(
255 platform_name=settings.PLATFORM_NAME
256 )
257 )
258 return wrapped
259
260
261def require_sales_admin(func):
262 """
263 Decorator for checking sales administrator access before executing an HTTP endpoint. This decorator
264 is designed to be used for a request based action on a course. It assumes that there will be a
265 request object as well as a course_id attribute to leverage to check course level privileges.
266
267 If the user does not have privileges for this operation, this will return HttpResponseForbidden (403).
268 """
269 def wrapped(request, course_id): # pylint: disable=missing-docstring
270
271 try:
272 course_key = CourseKey.from_string(course_id)
273 except InvalidKeyError:
274 log.error(u"Unable to find course with course key %s", course_id)
275 return HttpResponseNotFound()
276
277 access = auth.has_access(request.user, CourseSalesAdminRole(course_key))
278
279 if access:
280 return func(request, course_id)
281 else:
282 return HttpResponseForbidden()
283 return wrapped
284
285
286def require_finance_admin(func):
287 """
288 Decorator for checking finance administrator access before executing an HTTP endpoint. This decorator
289 is designed to be used for a request based action on a course. It assumes that there will be a
290 request object as well as a course_id attribute to leverage to check course level privileges.
291
292 If the user does not have privileges for this operation, this will return HttpResponseForbidden (403).
293 """
294 def wrapped(request, course_id): # pylint: disable=missing-docstring
295
296 try:
297 course_key = CourseKey.from_string(course_id)
298 except InvalidKeyError:
299 log.error(u"Unable to find course with course key %s", course_id)
300 return HttpResponseNotFound()
301
302 access = auth.has_access(request.user, CourseFinanceAdminRole(course_key))
303
304 if access:
305 return func(request, course_id)
306 else:
307 return HttpResponseForbidden()
308 return wrapped
309
310
311EMAIL_INDEX = 0
312USERNAME_INDEX = 1
313NAME_INDEX = 2
314COUNTRY_INDEX = 3
315
316
317@ensure_csrf_cookie
318@cache_control(no_cache=True, no_store=True, must_revalidate=True)
319@require_level('staff')
320def register_and_enroll_students(request, course_id): # pylint: disable=too-many-statements
321 """
322 Create new account and Enroll students in this course.
323 Passing a csv file that contains a list of students.
324 Order in csv should be the following email = 0; username = 1; name = 2; country = 3.
325 Requires staff access.
326
327 -If the email address and username already exists and the user is enrolled in the course,
328 do nothing (including no email gets sent out)
329
330 -If the email address already exists, but the username is different,
331 match on the email address only and continue to enroll the user in the course using the email address
332 as the matching criteria. Note the change of username as a warning message (but not a failure). Send a standard enrollment email
333 which is the same as the existing manual enrollment
334
335 -If the username already exists (but not the email), assume it is a different user and fail to create the new account.
336 The failure will be messaged in a response in the browser.
337 """
338
339 if not microsite.get_value('ALLOW_AUTOMATED_SIGNUPS', settings.FEATURES.get('ALLOW_AUTOMATED_SIGNUPS', False)):
340 return HttpResponseForbidden()
341
342 course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
343 warnings = []
344 row_errors = []
345 general_errors = []
346
347 if 'students_list' in request.FILES:
348 students = []
349
350 try:
351 upload_file = request.FILES.get('students_list')
352 if upload_file.name.endswith('.csv'):
353 students = [row for row in csv.reader(upload_file.read().splitlines())]
354 course = get_course_by_id(course_id)
355 else:
356 general_errors.append({
357 'username': '', 'email': '',
358 'response': _('Make sure that the file you upload is in CSV format with no extraneous characters or rows.')
359 })
360
361 except Exception: # pylint: disable=broad-except
362 general_errors.append({
363 'username': '', 'email': '', 'response': _('Could not read uploaded file.')
364 })
365 finally:
366 upload_file.close()
367
368 generated_passwords = []
369 row_num = 0
370 for student in students:
371 row_num = row_num + 1
372
373 # verify that we have exactly four columns in every row but allow for blank lines
374 if len(student) != 4:
375 if len(student) > 0:
376 general_errors.append({
377 'username': '',
378 'email': '',
379 'response': _('Data in row #{row_num} must have exactly four columns: email, username, full name, and country').format(row_num=row_num)
380 })
381 continue
382
383 # Iterate each student in the uploaded csv file.
384 email = student[EMAIL_INDEX]
385 username = student[USERNAME_INDEX]
386 name = student[NAME_INDEX]
387 country = student[COUNTRY_INDEX][:2]
388
389 email_params = get_email_params(course, True, secure=request.is_secure())
390 try:
391 validate_email(email) # Raises ValidationError if invalid
392 except ValidationError:
393 row_errors.append({
394 'username': username, 'email': email, 'response': _('Invalid email {email_address}.').format(email_address=email)})
395 else:
396 if User.objects.filter(email=email).exists():
397 # Email address already exists. assume it is the correct user
398 # and just register the user in the course and send an enrollment email.
399 user = User.objects.get(email=email)
400
401 # see if it is an exact match with email and username
402 # if it's not an exact match then just display a warning message, but continue onwards
403 if not User.objects.filter(email=email, username=username).exists():
404 warning_message = _(
405 'An account with email {email} exists but the provided username {username} '
406 'is different. Enrolling anyway with {email}.'
407 ).format(email=email, username=username)
408
409 warnings.append({
410 'username': username, 'email': email, 'response': warning_message
411 })
412 log.warning(u'email %s already exist', email)
413 else:
414 log.info(
415 u"user already exists with username '%s' and email '%s'",
416 username,
417 email
418 )
419
420 # make sure user is enrolled in course
421 if not CourseEnrollment.is_enrolled(user, course_id):
422 enrollment_obj = CourseEnrollment.enroll(user, course_id)
423 reason = 'Enrolling via csv upload'
424 ManualEnrollmentAudit.create_manual_enrollment_audit(
425 request.user, email, UNENROLLED_TO_ENROLLED, reason, enrollment_obj
426 )
427 log.info(
428 u'user %s enrolled in the course %s',
429 username,
430 course.id,
431 )
432 enroll_email(course_id=course_id, student_email=email, auto_enroll=True, email_students=True, email_params=email_params)
433 else:
434 # This email does not yet exist, so we need to create a new account
435 # If username already exists in the database, then create_and_enroll_user
436 # will raise an IntegrityError exception.
437 password = generate_unique_password(generated_passwords)
438
439 try:
440 enrollment_obj = create_and_enroll_user(email, username, name, country, password, course_id)
441 reason = 'Enrolling via csv upload'
442 ManualEnrollmentAudit.create_manual_enrollment_audit(
443 request.user, email, UNENROLLED_TO_ENROLLED, reason, enrollment_obj
444 )
445 except IntegrityError:
446 row_errors.append({
447 'username': username, 'email': email, 'response': _('Username {user} already exists.').format(user=username)})
448 except Exception as ex:
449 log.exception(type(ex).__name__)
450 row_errors.append({
451 'username': username, 'email': email, 'response': type(ex).__name__})
452 else:
453 # It's a new user, an email will be sent to each newly created user.
454 email_params['message'] = 'account_creation_and_enrollment'
455 email_params['email_address'] = email
456 email_params['password'] = password
457 email_params['platform_name'] = microsite.get_value('platform_name', settings.PLATFORM_NAME)
458 send_mail_to_student(email, email_params)
459 log.info(u'email sent to new created user at %s', email)
460
461 else:
462 general_errors.append({
463 'username': '', 'email': '', 'response': _('File is not attached.')
464 })
465
466 results = {
467 'row_errors': row_errors,
468 'general_errors': general_errors,
469 'warnings': warnings
470 }
471 return JsonResponse(results)
472
473
474def generate_random_string(length):
475 """
476 Create a string of random characters of specified length
477 """
478 chars = [
479 char for char in string.ascii_uppercase + string.digits + string.ascii_lowercase
480 if char not in 'aAeEiIoOuU1l'
481 ]
482
483 return string.join((random.choice(chars) for __ in range(length)), '')
484
485
486def generate_unique_password(generated_passwords, password_length=12):
487 """
488 generate a unique password for each student.
489 """
490
491 password = generate_random_string(password_length)
492 while password in generated_passwords:
493 password = generate_random_string(password_length)
494
495 generated_passwords.append(password)
496
497 return password
498
499
500def create_and_enroll_user(email, username, name, country, password, course_id):
501 """ Creates a user and enroll him/her in the course"""
502
503 user = User.objects.create_user(username, email, password)
504 reg = Registration()
505 reg.register(user)
506
507 profile = UserProfile(user=user)
508 profile.name = name
509 profile.country = country
510 profile.save()
511
512 # try to enroll the user in this course
513 return CourseEnrollment.enroll(user, course_id)
514
515
516@ensure_csrf_cookie
517@cache_control(no_cache=True, no_store=True, must_revalidate=True)
518@require_level('staff')
519@require_post_params(action="enroll or unenroll", identifiers="stringified list of emails and/or usernames")
520def students_update_enrollment(request, course_id):
521 """
522 Enroll or unenroll students by email.
523 Requires staff access.
524
525 Query Parameters:
526 - action in ['enroll', 'unenroll']
527 - identifiers is string containing a list of emails and/or usernames separated by anything split_input_list can handle.
528 - auto_enroll is a boolean (defaults to false)
529 If auto_enroll is false, students will be allowed to enroll.
530 If auto_enroll is true, students will be enrolled as soon as they register.
531 - email_students is a boolean (defaults to false)
532 If email_students is true, students will be sent email notification
533 If email_students is false, students will not be sent email notification
534
535 Returns an analog to this JSON structure: {
536 "action": "enroll",
537 "auto_enroll": false,
538 "results": [
539 {
540 "email": "testemail@test.org",
541 "before": {
542 "enrollment": false,
543 "auto_enroll": false,
544 "user": true,
545 "allowed": false
546 },
547 "after": {
548 "enrollment": true,
549 "auto_enroll": false,
550 "user": true,
551 "allowed": false
552 }
553 }
554 ]
555 }
556 """
557 course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
558 action = request.POST.get('action')
559 identifiers_raw = request.POST.get('identifiers')
560 identifiers = _split_input_list(identifiers_raw)
561 auto_enroll = request.POST.get('auto_enroll') in ['true', 'True', True]
562 email_students = request.POST.get('email_students') in ['true', 'True', True]
563 is_white_label = CourseMode.is_white_label(course_id)
564 reason = request.POST.get('reason')
565 if is_white_label:
566 if not reason:
567 return JsonResponse(
568 {
569 'action': action,
570 'results': [{'error': True}],
571 'auto_enroll': auto_enroll,
572 }, status=400)
573 enrollment_obj = None
574 state_transition = DEFAULT_TRANSITION_STATE
575
576 email_params = {}
577 if email_students:
578 course = get_course_by_id(course_id)
579 email_params = get_email_params(course, auto_enroll, secure=request.is_secure())
580
581 results = []
582 for identifier in identifiers:
583 # First try to get a user object from the identifer
584 user = None
585 email = None
586 language = None
587 try:
588 user = get_student_from_identifier(identifier)
589 except User.DoesNotExist:
590 email = identifier
591 else:
592 email = user.email
593 language = get_user_email_language(user)
594
595 try:
596 # Use django.core.validators.validate_email to check email address
597 # validity (obviously, cannot check if email actually /exists/,
598 # simply that it is plausibly valid)
599 validate_email(email) # Raises ValidationError if invalid
600 if action == 'enroll':
601 before, after, enrollment_obj = enroll_email(
602 course_id, email, auto_enroll, email_students, email_params, language=language
603 )
604 before_enrollment = before.to_dict()['enrollment']
605 before_user_registered = before.to_dict()['user']
606 before_allowed = before.to_dict()['allowed']
607 after_enrollment = after.to_dict()['enrollment']
608 after_allowed = after.to_dict()['allowed']
609
610 if before_user_registered:
611 if after_enrollment:
612 if before_enrollment:
613 state_transition = ENROLLED_TO_ENROLLED
614 else:
615 if before_allowed:
616 state_transition = ALLOWEDTOENROLL_TO_ENROLLED
617 else:
618 state_transition = UNENROLLED_TO_ENROLLED
619 else:
620 if after_allowed:
621 state_transition = UNENROLLED_TO_ALLOWEDTOENROLL
622
623 elif action == 'unenroll':
624 before, after = unenroll_email(
625 course_id, email, email_students, email_params, language=language
626 )
627 before_enrollment = before.to_dict()['enrollment']
628 before_allowed = before.to_dict()['allowed']
629
630 if before_enrollment:
631 state_transition = ENROLLED_TO_UNENROLLED
632 else:
633 if before_allowed:
634 state_transition = ALLOWEDTOENROLL_TO_UNENROLLED
635 else:
636 state_transition = UNENROLLED_TO_UNENROLLED
637
638 else:
639 return HttpResponseBadRequest(strip_tags(
640 "Unrecognized action '{}'".format(action)
641 ))
642
643 except ValidationError:
644 # Flag this email as an error if invalid, but continue checking
645 # the remaining in the list
646 results.append({
647 'identifier': identifier,
648 'invalidIdentifier': True,
649 })
650
651 except Exception as exc: # pylint: disable=broad-except
652 # catch and log any exceptions
653 # so that one error doesn't cause a 500.
654 log.exception(u"Error while #{}ing student")
655 log.exception(exc)
656 results.append({
657 'identifier': identifier,
658 'error': True,
659 })
660
661 else:
662 ManualEnrollmentAudit.create_manual_enrollment_audit(
663 request.user, email, state_transition, reason, enrollment_obj
664 )
665 results.append({
666 'identifier': identifier,
667 'before': before.to_dict(),
668 'after': after.to_dict(),
669 })
670
671 response_payload = {
672 'action': action,
673 'results': results,
674 'auto_enroll': auto_enroll,
675 }
676 return JsonResponse(response_payload)
677
678
679@ensure_csrf_cookie
680@cache_control(no_cache=True, no_store=True, must_revalidate=True)
681@require_level('instructor')
682@common_exceptions_400
683@require_post_params(
684 identifiers="stringified list of emails and/or usernames",
685 action="add or remove",
686)
687def bulk_beta_modify_access(request, course_id):
688 """
689 Enroll or unenroll users in beta testing program.
690
691 Query parameters:
692 - identifiers is string containing a list of emails and/or usernames separated by
693 anything split_input_list can handle.
694 - action is one of ['add', 'remove']
695 """
696 course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
697 action = request.POST.get('action')
698 identifiers_raw = request.POST.get('identifiers')
699 identifiers = _split_input_list(identifiers_raw)
700 email_students = request.POST.get('email_students') in ['true', 'True', True]
701 auto_enroll = request.POST.get('auto_enroll') in ['true', 'True', True]
702 results = []
703 rolename = 'beta'
704 course = get_course_by_id(course_id)
705
706 email_params = {}
707 if email_students:
708 secure = request.is_secure()
709 email_params = get_email_params(course, auto_enroll=auto_enroll, secure=secure)
710
711 for identifier in identifiers:
712 try:
713 error = False
714 user_does_not_exist = False
715 user = get_student_from_identifier(identifier)
716
717 if action == 'add':
718 allow_access(course, user, rolename)
719 elif action == 'remove':
720 revoke_access(course, user, rolename)
721 else:
722 return HttpResponseBadRequest(strip_tags(
723 "Unrecognized action '{}'".format(action)
724 ))
725 except User.DoesNotExist:
726 error = True
727 user_does_not_exist = True
728 # catch and log any unexpected exceptions
729 # so that one error doesn't cause a 500.
730 except Exception as exc: # pylint: disable=broad-except
731 log.exception(u"Error while #{}ing student")
732 log.exception(exc)
733 error = True
734 else:
735 # If no exception thrown, see if we should send an email
736 if email_students:
737 send_beta_role_email(action, user, email_params)
738 # See if we should autoenroll the student
739 if auto_enroll:
740 # Check if student is already enrolled
741 if not CourseEnrollment.is_enrolled(user, course_id):
742 CourseEnrollment.enroll(user, course_id)
743
744 finally:
745 # Tabulate the action result of this email address
746 results.append({
747 'identifier': identifier,
748 'error': error,
749 'userDoesNotExist': user_does_not_exist
750 })
751
752 response_payload = {
753 'action': action,
754 'results': results,
755 }
756 return JsonResponse(response_payload)
757
758
759@ensure_csrf_cookie
760@cache_control(no_cache=True, no_store=True, must_revalidate=True)
761@require_level('instructor')
762@common_exceptions_400
763@require_query_params(
764 unique_student_identifier="email or username of user to change access",
765 rolename="'instructor', 'staff', 'beta', or 'ccx_coach'",
766 action="'allow' or 'revoke'"
767)
768def modify_access(request, course_id):
769 """
770 Modify staff/instructor access of other user.
771 Requires instructor access.
772
773 NOTE: instructors cannot remove their own instructor access.
774
775 Query parameters:
776 unique_student_identifer is the target user's username or email
777 rolename is one of ['instructor', 'staff', 'beta', 'ccx_coach']
778 action is one of ['allow', 'revoke']
779 """
780 course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
781 course = get_course_with_access(
782 request.user, 'instructor', course_id, depth=None
783 )
784 try:
785 user = get_student_from_identifier(request.GET.get('unique_student_identifier'))
786 except User.DoesNotExist:
787 response_payload = {
788 'unique_student_identifier': request.GET.get('unique_student_identifier'),
789 'userDoesNotExist': True,
790 }
791 return JsonResponse(response_payload)
792
793 # Check that user is active, because add_users
794 # in common/djangoapps/student/roles.py fails
795 # silently when we try to add an inactive user.
796 if not user.is_active:
797 response_payload = {
798 'unique_student_identifier': user.username,
799 'inactiveUser': True,
800 }
801 return JsonResponse(response_payload)
802
803 rolename = request.GET.get('rolename')
804 action = request.GET.get('action')
805
806 if rolename not in ROLES:
807 error = strip_tags("unknown rolename '{}'".format(rolename))
808 log.error(error)
809 return HttpResponseBadRequest(error)
810
811 # disallow instructors from removing their own instructor access.
812 if rolename == 'instructor' and user == request.user and action != 'allow':
813 response_payload = {
814 'unique_student_identifier': user.username,
815 'rolename': rolename,
816 'action': action,
817 'removingSelfAsInstructor': True,
818 }
819 return JsonResponse(response_payload)
820
821 if action == 'allow':
822 allow_access(course, user, rolename)
823 elif action == 'revoke':
824 revoke_access(course, user, rolename)
825 else:
826 return HttpResponseBadRequest(strip_tags(
827 "unrecognized action '{}'".format(action)
828 ))
829
830 response_payload = {
831 'unique_student_identifier': user.username,
832 'rolename': rolename,
833 'action': action,
834 'success': 'yes',
835 }
836 return JsonResponse(response_payload)
837
838
839@ensure_csrf_cookie
840@cache_control(no_cache=True, no_store=True, must_revalidate=True)
841@require_level('instructor')
842@require_query_params(rolename="'instructor', 'staff', or 'beta'")
843def list_course_role_members(request, course_id):
844 """
845 List instructors and staff.
846 Requires instructor access.
847
848 rolename is one of ['instructor', 'staff', 'beta', 'ccx_coach']
849
850 Returns JSON of the form {
851 "course_id": "some/course/id",
852 "staff": [
853 {
854 "username": "staff1",
855 "email": "staff1@example.org",
856 "first_name": "Joe",
857 "last_name": "Shmoe",
858 }
859 ]
860 }
861 """
862 course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
863 course = get_course_with_access(
864 request.user, 'instructor', course_id, depth=None
865 )
866
867 rolename = request.GET.get('rolename')
868
869 if rolename not in ROLES:
870 return HttpResponseBadRequest()
871
872 def extract_user_info(user):
873 """ convert user into dicts for json view """
874 return {
875 'username': user.username,
876 'email': user.email,
877 'first_name': user.first_name,
878 'last_name': user.last_name,
879 }
880
881 response_payload = {
882 'course_id': course_id.to_deprecated_string(),
883 rolename: map(extract_user_info, list_with_level(
884 course, rolename
885 )),
886 }
887 return JsonResponse(response_payload)
888
889
890@ensure_csrf_cookie
891@cache_control(no_cache=True, no_store=True, must_revalidate=True)
892@require_level('staff')
893def get_grading_config(request, course_id):
894 """
895 Respond with json which contains a html formatted grade summary.
896 """
897 course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
898 course = get_course_with_access(
899 request.user, 'staff', course_id, depth=None
900 )
901 grading_config_summary = instructor_analytics.basic.dump_grading_context(course)
902
903 response_payload = {
904 'course_id': course_id.to_deprecated_string(),
905 'grading_config_summary': grading_config_summary,
906 }
907 return JsonResponse(response_payload)
908
909
910@ensure_csrf_cookie
911@cache_control(no_cache=True, no_store=True, must_revalidate=True)
912@require_level('staff')
913def get_sale_records(request, course_id, csv=False): # pylint: disable=unused-argument, redefined-outer-name
914 """
915 return the summary of all sales records for a particular course
916 """
917 course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
918 query_features = [
919 'company_name', 'company_contact_name', 'company_contact_email', 'total_codes', 'total_used_codes',
920 'total_amount', 'created_at', 'customer_reference_number', 'recipient_name', 'recipient_email', 'created_by',
921 'internal_reference', 'invoice_number', 'codes', 'course_id'
922 ]
923
924 sale_data = instructor_analytics.basic.sale_record_features(course_id, query_features)
925
926 if not csv:
927 for item in sale_data:
928 item['created_by'] = item['created_by'].username
929
930 response_payload = {
931 'course_id': course_id.to_deprecated_string(),
932 'sale': sale_data,
933 'queried_features': query_features
934 }
935 return JsonResponse(response_payload)
936 else:
937 header, datarows = instructor_analytics.csvs.format_dictlist(sale_data, query_features)
938 return instructor_analytics.csvs.create_csv_response("e-commerce_sale_invoice_records.csv", header, datarows)
939
940
941@ensure_csrf_cookie
942@cache_control(no_cache=True, no_store=True, must_revalidate=True)
943@require_level('staff')
944def get_sale_order_records(request, course_id): # pylint: disable=unused-argument, redefined-outer-name
945 """
946 return the summary of all sales records for a particular course
947 """
948 course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
949 query_features = [
950 ('id', 'Order Id'),
951 ('company_name', 'Company Name'),
952 ('company_contact_name', 'Company Contact Name'),
953 ('company_contact_email', 'Company Contact Email'),
954 ('logged_in_username', 'Login Username'),
955 ('logged_in_email', 'Login User Email'),
956 ('purchase_time', 'Date of Sale'),
957 ('customer_reference_number', 'Customer Reference Number'),
958 ('recipient_name', 'Recipient Name'),
959 ('recipient_email', 'Recipient Email'),
960 ('bill_to_street1', 'Street 1'),
961 ('bill_to_street2', 'Street 2'),
962 ('bill_to_city', 'City'),
963 ('bill_to_state', 'State'),
964 ('bill_to_postalcode', 'Postal Code'),
965 ('bill_to_country', 'Country'),
966 ('order_type', 'Order Type'),
967 ('status', 'Order Item Status'),
968 ('coupon_code', 'Coupon Code'),
969 ('list_price', 'List Price'),
970 ('unit_cost', 'Unit Price'),
971 ('quantity', 'Quantity'),
972 ('total_discount', 'Total Discount'),
973 ('total_amount', 'Total Amount Paid'),
974 ]
975
976 db_columns = [x[0] for x in query_features]
977 csv_columns = [x[1] for x in query_features]
978 sale_data = instructor_analytics.basic.sale_order_record_features(course_id, db_columns)
979 header, datarows = instructor_analytics.csvs.format_dictlist(sale_data, db_columns) # pylint: disable=unused-variable
980 return instructor_analytics.csvs.create_csv_response("e-commerce_sale_order_records.csv", csv_columns, datarows)
981
982
983@require_level('staff')
984@require_POST
985def sale_validation(request, course_id):
986 """
987 This method either invalidate or re validate the sale against the invoice number depending upon the event type
988 """
989 try:
990 invoice_number = request.POST["invoice_number"]
991 except KeyError:
992 return HttpResponseBadRequest("Missing required invoice_number parameter")
993 try:
994 invoice_number = int(invoice_number)
995 except ValueError:
996 return HttpResponseBadRequest(
997 "invoice_number must be an integer, {value} provided".format(
998 value=invoice_number
999 )
1000 )
1001 try:
1002 event_type = request.POST["event_type"]
1003 except KeyError:
1004 return HttpResponseBadRequest("Missing required event_type parameter")
1005
1006 course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
1007 try:
1008 obj_invoice = CourseRegistrationCodeInvoiceItem.objects.select_related('invoice').get(
1009 invoice_id=invoice_number,
1010 course_id=course_id
1011 )
1012 obj_invoice = obj_invoice.invoice
1013 except CourseRegistrationCodeInvoiceItem.DoesNotExist: # Check for old type invoices
1014 return HttpResponseNotFound(_("Invoice number '{num}' does not exist.").format(num=invoice_number))
1015
1016 if event_type == "invalidate":
1017 return invalidate_invoice(obj_invoice)
1018 else:
1019 return re_validate_invoice(obj_invoice)
1020
1021
1022def invalidate_invoice(obj_invoice):
1023 """
1024 This method invalidate the sale against the invoice number
1025 """
1026 if not obj_invoice.is_valid:
1027 return HttpResponseBadRequest(_("The sale associated with this invoice has already been invalidated."))
1028 obj_invoice.is_valid = False
1029 obj_invoice.save()
1030 message = _('Invoice number {0} has been invalidated.').format(obj_invoice.id)
1031 return JsonResponse({'message': message})
1032
1033
1034def re_validate_invoice(obj_invoice):
1035 """
1036 This method re-validate the sale against the invoice number
1037 """
1038 if obj_invoice.is_valid:
1039 return HttpResponseBadRequest(_("This invoice is already active."))
1040
1041 obj_invoice.is_valid = True
1042 obj_invoice.save()
1043 message = _('The registration codes for invoice {0} have been re-activated.').format(obj_invoice.id)
1044 return JsonResponse({'message': message})
1045
1046
1047@ensure_csrf_cookie
1048@cache_control(no_cache=True, no_store=True, must_revalidate=True)
1049@require_level('staff')
1050def get_students_features(request, course_id, csv=False): # pylint: disable=redefined-outer-name
1051 """
1052 Respond with json which contains a summary of all enrolled students profile information.
1053
1054 Responds with JSON
1055 {"students": [{-student-info-}, ...]}
1056
1057 TO DO accept requests for different attribute sets.
1058 """
1059 course_key = CourseKey.from_string(course_id)
1060 course = get_course_by_id(course_key)
1061
1062 available_features = instructor_analytics.basic.AVAILABLE_FEATURES
1063
1064 # Allow for microsites to be able to define additional columns (e.g. )
1065 query_features = microsite.get_value('student_profile_download_fields')
1066
1067 if not query_features:
1068 query_features = [
1069 'id', 'username', 'name', 'email', 'language', 'location',
1070 'year_of_birth', 'gender', 'level_of_education', 'mailing_address',
1071 'goals'
1072 ]
1073
1074 # Provide human-friendly and translatable names for these features. These names
1075 # will be displayed in the table generated in data_download.coffee. It is not (yet)
1076 # used as the header row in the CSV, but could be in the future.
1077 query_features_names = {
1078 'id': _('User ID'),
1079 'username': _('Username'),
1080 'name': _('Name'),
1081 'email': _('Email'),
1082 'language': _('Language'),
1083 'location': _('Location'),
1084 'year_of_birth': _('Birth Year'),
1085 'gender': _('Gender'),
1086 'level_of_education': _('Level of Education'),
1087 'mailing_address': _('Mailing Address'),
1088 'goals': _('Goals'),
1089 }
1090
1091 if is_course_cohorted(course.id):
1092 # Translators: 'Cohort' refers to a group of students within a course.
1093 query_features.append('cohort')
1094 query_features_names['cohort'] = _('Cohort')
1095
1096 if not csv:
1097 student_data = instructor_analytics.basic.enrolled_students_features(course_key, query_features)
1098 response_payload = {
1099 'course_id': unicode(course_key),
1100 'students': student_data,
1101 'students_count': len(student_data),
1102 'queried_features': query_features,
1103 'feature_names': query_features_names,
1104 'available_features': available_features,
1105 }
1106 return JsonResponse(response_payload)
1107 else:
1108 try:
1109 instructor_task.api.submit_calculate_students_features_csv(request, course_key, query_features)
1110 success_status = _(
1111 "Your enrolled student profile report is being generated! "
1112 "You can view the status of the generation task in the 'Pending Tasks' section.")
1113 return JsonResponse({"status": success_status})
1114 except AlreadyRunningError:
1115 already_running_status = _(
1116 "An enrolled student profile report generation task is already in progress. "
1117 "Check the 'Pending Tasks' table for the status of the task. When completed, "
1118 "the report will be available for download in the table below.")
1119 return JsonResponse({"status": already_running_status})
1120
1121
1122@ensure_csrf_cookie
1123@cache_control(no_cache=True, no_store=True, must_revalidate=True)
1124@require_level('staff')
1125def get_students_who_may_enroll(request, course_id):
1126 """
1127 Initiate generation of a CSV file containing information about
1128 students who may enroll in a course.
1129
1130 Responds with JSON
1131 {"status": "... status message ..."}
1132
1133 """
1134 course_key = CourseKey.from_string(course_id)
1135 query_features = ['email']
1136 try:
1137 instructor_task.api.submit_calculate_may_enroll_csv(request, course_key, query_features)
1138 success_status = _(
1139 "Your students who may enroll report is being generated! "
1140 "You can view the status of the generation task in the 'Pending Tasks' section."
1141 )
1142 return JsonResponse({"status": success_status})
1143 except AlreadyRunningError:
1144 already_running_status = _(
1145 "A students who may enroll report generation task is already in progress. "
1146 "Check the 'Pending Tasks' table for the status of the task. "
1147 "When completed, the report will be available for download in the table below."
1148 )
1149 return JsonResponse({"status": already_running_status})
1150
1151
1152@ensure_csrf_cookie
1153@cache_control(no_cache=True, no_store=True, must_revalidate=True)
1154@require_POST
1155@require_level('staff')
1156def add_users_to_cohorts(request, course_id):
1157 """
1158 View method that accepts an uploaded file (using key "uploaded-file")
1159 containing cohort assignments for users. This method spawns a celery task
1160 to do the assignments, and a CSV file with results is provided via data downloads.
1161 """
1162 course_key = SlashSeparatedCourseKey.from_string(course_id)
1163
1164 try:
1165 def validator(file_storage, file_to_validate):
1166 """
1167 Verifies that the expected columns are present.
1168 """
1169 with file_storage.open(file_to_validate) as f:
1170 reader = unicodecsv.reader(UniversalNewlineIterator(f), encoding='utf-8')
1171 try:
1172 fieldnames = next(reader)
1173 except StopIteration:
1174 fieldnames = []
1175 msg = None
1176 if "cohort" not in fieldnames:
1177 msg = _("The file must contain a 'cohort' column containing cohort names.")
1178 elif "email" not in fieldnames and "username" not in fieldnames:
1179 msg = _("The file must contain a 'username' column, an 'email' column, or both.")
1180 if msg:
1181 raise FileValidationException(msg)
1182
1183 __, filename = store_uploaded_file(
1184 request, 'uploaded-file', ['.csv'],
1185 course_and_time_based_filename_generator(course_key, "cohorts"),
1186 max_file_size=2000000, # limit to 2 MB
1187 validator=validator
1188 )
1189 # The task will assume the default file storage.
1190 instructor_task.api.submit_cohort_students(request, course_key, filename)
1191 except (FileValidationException, PermissionDenied) as err:
1192 return JsonResponse({"error": unicode(err)}, status=400)
1193
1194 return JsonResponse()
1195
1196
1197@ensure_csrf_cookie
1198@cache_control(no_cache=True, no_store=True, must_revalidate=True)
1199@require_level('staff')
1200def get_coupon_codes(request, course_id): # pylint: disable=unused-argument
1201 """
1202 Respond with csv which contains a summary of all Active Coupons.
1203 """
1204 course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
1205 coupons = Coupon.objects.filter(course_id=course_id)
1206
1207 query_features = [
1208 ('code', _('Coupon Code')),
1209 ('course_id', _('Course Id')),
1210 ('percentage_discount', _('% Discount')),
1211 ('description', _('Description')),
1212 ('expiration_date', _('Expiration Date')),
1213 ('is_active', _('Is Active')),
1214 ('code_redeemed_count', _('Code Redeemed Count')),
1215 ('total_discounted_seats', _('Total Discounted Seats')),
1216 ('total_discounted_amount', _('Total Discounted Amount')),
1217 ]
1218 db_columns = [x[0] for x in query_features]
1219 csv_columns = [x[1] for x in query_features]
1220
1221 coupons_list = instructor_analytics.basic.coupon_codes_features(db_columns, coupons, course_id)
1222 __, data_rows = instructor_analytics.csvs.format_dictlist(coupons_list, db_columns)
1223 return instructor_analytics.csvs.create_csv_response('Coupons.csv', csv_columns, data_rows)
1224
1225
1226@ensure_csrf_cookie
1227@cache_control(no_cache=True, no_store=True, must_revalidate=True)
1228@require_level('staff')
1229@require_finance_admin
1230def get_enrollment_report(request, course_id):
1231 """
1232 get the enrollment report for the particular course.
1233 """
1234 course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
1235 try:
1236 instructor_task.api.submit_detailed_enrollment_features_csv(request, course_key)
1237 success_status = _(
1238 "Your detailed enrollment report is being generated! "
1239 "You can view the status of the generation task in the 'Pending Tasks' section.")
1240 return JsonResponse({"status": success_status})
1241 except AlreadyRunningError:
1242 already_running_status = _(
1243 "A detailed enrollment report generation task is already in progress. "
1244 "Check the 'Pending Tasks' table for the status of the task. "
1245 "When completed, the report will be available for download in the table below.")
1246 return JsonResponse({
1247 "status": already_running_status
1248 })
1249
1250
1251@ensure_csrf_cookie
1252@cache_control(no_cache=True, no_store=True, must_revalidate=True)
1253@require_level('staff')
1254@require_finance_admin
1255def get_exec_summary_report(request, course_id):
1256 """
1257 get the executive summary report for the particular course.
1258 """
1259 course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
1260 try:
1261 instructor_task.api.submit_executive_summary_report(request, course_key)
1262 status_response = _(
1263 "Your executive summary report is being created. "
1264 "To view the status of the report, see the 'Pending Tasks' section.")
1265 except AlreadyRunningError:
1266 status_response = _(
1267 "An executive summary report is currently in progress. "
1268 "To view the status of the report, see the 'Pending Tasks' section. "
1269 "When completed, the report will be available for download in the table below. "
1270 "You will be able to download the report when it is complete."
1271 )
1272 return JsonResponse({
1273 "status": status_response
1274 })
1275
1276
1277def save_registration_code(user, course_id, mode_slug, invoice=None, order=None, invoice_item=None):
1278 """
1279 recursive function that generate a new code every time and saves in the Course Registration Table
1280 if validation check passes
1281
1282 Args:
1283 user (User): The user creating the course registration codes.
1284 course_id (str): The string representation of the course ID.
1285 mode_slug (str): The Course Mode Slug associated with any enrollment made by these codes.
1286 invoice (Invoice): (Optional) The associated invoice for this code.
1287 order (Order): (Optional) The associated order for this code.
1288 invoice_item (CourseRegistrationCodeInvoiceItem) : (Optional) The associated CourseRegistrationCodeInvoiceItem
1289
1290 Returns:
1291 The newly created CourseRegistrationCode.
1292
1293 """
1294 code = random_code_generator()
1295
1296 # check if the generated code is in the Coupon Table
1297 matching_coupons = Coupon.objects.filter(code=code, is_active=True)
1298 if matching_coupons:
1299 return save_registration_code(
1300 user, course_id, mode_slug, invoice=invoice, order=order, invoice_item=invoice_item
1301 )
1302
1303 course_registration = CourseRegistrationCode(
1304 code=code,
1305 course_id=unicode(course_id),
1306 created_by=user,
1307 invoice=invoice,
1308 order=order,
1309 mode_slug=mode_slug,
1310 invoice_item=invoice_item
1311 )
1312 try:
1313 course_registration.save()
1314 return course_registration
1315 except IntegrityError:
1316 return save_registration_code(
1317 user, course_id, mode_slug, invoice=invoice, order=order, invoice_item=invoice_item
1318 )
1319
1320
1321def registration_codes_csv(file_name, codes_list, csv_type=None):
1322 """
1323 Respond with the csv headers and data rows
1324 given a dict of codes list
1325 :param file_name:
1326 :param codes_list:
1327 :param csv_type:
1328 """
1329 # csv headers
1330 query_features = [
1331 'code', 'redeem_code_url', 'course_id', 'company_name', 'created_by',
1332 'redeemed_by', 'invoice_id', 'purchaser', 'customer_reference_number', 'internal_reference', 'is_valid'
1333 ]
1334
1335 registration_codes = instructor_analytics.basic.course_registration_features(query_features, codes_list, csv_type)
1336 header, data_rows = instructor_analytics.csvs.format_dictlist(registration_codes, query_features)
1337 return instructor_analytics.csvs.create_csv_response(file_name, header, data_rows)
1338
1339
1340def random_code_generator():
1341 """
1342 generate a random alphanumeric code of length defined in
1343 REGISTRATION_CODE_LENGTH settings
1344 """
1345 code_length = getattr(settings, 'REGISTRATION_CODE_LENGTH', 8)
1346 return generate_random_string(code_length)
1347
1348
1349@ensure_csrf_cookie
1350@cache_control(no_cache=True, no_store=True, must_revalidate=True)
1351@require_level('staff')
1352@require_POST
1353def get_registration_codes(request, course_id): # pylint: disable=unused-argument
1354 """
1355 Respond with csv which contains a summary of all Registration Codes.
1356 """
1357 course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
1358
1359 #filter all the course registration codes
1360 registration_codes = CourseRegistrationCode.objects.filter(
1361 course_id=course_id
1362 ).order_by('invoice_item__invoice__company_name')
1363
1364 company_name = request.POST['download_company_name']
1365 if company_name:
1366 registration_codes = registration_codes.filter(invoice_item__invoice__company_name=company_name)
1367
1368 csv_type = 'download'
1369 return registration_codes_csv("Registration_Codes.csv", registration_codes, csv_type)
1370
1371
1372@ensure_csrf_cookie
1373@cache_control(no_cache=True, no_store=True, must_revalidate=True)
1374@require_sales_admin
1375@require_POST
1376def generate_registration_codes(request, course_id):
1377 """
1378 Respond with csv which contains a summary of all Generated Codes.
1379 """
1380 course_id = CourseKey.from_string(course_id)
1381 invoice_copy = False
1382
1383 # covert the course registration code number into integer
1384 try:
1385 course_code_number = int(request.POST['total_registration_codes'])
1386 except ValueError:
1387 course_code_number = int(float(request.POST['total_registration_codes']))
1388
1389 company_name = request.POST['company_name']
1390 company_contact_name = request.POST['company_contact_name']
1391 company_contact_email = request.POST['company_contact_email']
1392 unit_price = request.POST['unit_price']
1393
1394 try:
1395 unit_price = (
1396 decimal.Decimal(unit_price)
1397 ).quantize(
1398 decimal.Decimal('.01'),
1399 rounding=decimal.ROUND_DOWN
1400 )
1401 except decimal.InvalidOperation:
1402 return HttpResponse(
1403 status=400,
1404 content=_(u"Could not parse amount as a decimal")
1405 )
1406
1407 recipient_name = request.POST['recipient_name']
1408 recipient_email = request.POST['recipient_email']
1409 address_line_1 = request.POST['address_line_1']
1410 address_line_2 = request.POST['address_line_2']
1411 address_line_3 = request.POST['address_line_3']
1412 city = request.POST['city']
1413 state = request.POST['state']
1414 zip_code = request.POST['zip']
1415 country = request.POST['country']
1416 internal_reference = request.POST['internal_reference']
1417 customer_reference_number = request.POST['customer_reference_number']
1418 recipient_list = [recipient_email]
1419 if request.POST.get('invoice', False):
1420 recipient_list.append(request.user.email)
1421 invoice_copy = True
1422
1423 sale_price = unit_price * course_code_number
1424 set_user_preference(request.user, INVOICE_KEY, invoice_copy)
1425 sale_invoice = Invoice.objects.create(
1426 total_amount=sale_price,
1427 company_name=company_name,
1428 company_contact_email=company_contact_email,
1429 company_contact_name=company_contact_name,
1430 course_id=course_id,
1431 recipient_name=recipient_name,
1432 recipient_email=recipient_email,
1433 address_line_1=address_line_1,
1434 address_line_2=address_line_2,
1435 address_line_3=address_line_3,
1436 city=city,
1437 state=state,
1438 zip=zip_code,
1439 country=country,
1440 internal_reference=internal_reference,
1441 customer_reference_number=customer_reference_number
1442 )
1443
1444 invoice_item = CourseRegistrationCodeInvoiceItem.objects.create(
1445 invoice=sale_invoice,
1446 qty=course_code_number,
1447 unit_price=unit_price,
1448 course_id=course_id
1449 )
1450
1451 course = get_course_by_id(course_id, depth=0)
1452 paid_modes = CourseMode.paid_modes_for_course(course_id)
1453
1454 if len(paid_modes) != 1:
1455 msg = (
1456 u"Generating Code Redeem Codes for Course '{course_id}', which must have a single paid course mode. "
1457 u"This is a configuration issue. Current course modes with payment options: {paid_modes}"
1458 ).format(course_id=course_id, paid_modes=paid_modes)
1459 log.error(msg)
1460 return HttpResponse(
1461 status=500,
1462 content=_(u"Unable to generate redeem codes because of course misconfiguration.")
1463 )
1464
1465 course_mode = paid_modes[0]
1466 course_price = course_mode.min_price
1467
1468 registration_codes = []
1469 for __ in range(course_code_number): # pylint: disable=redefined-outer-name
1470 generated_registration_code = save_registration_code(
1471 request.user, course_id, course_mode.slug, invoice=sale_invoice, order=None, invoice_item=invoice_item
1472 )
1473 registration_codes.append(generated_registration_code)
1474
1475 site_name = microsite.get_value('SITE_NAME', 'localhost')
1476 quantity = course_code_number
1477 discount = (float(quantity * course_price) - float(sale_price))
1478 course_url = '{base_url}{course_about}'.format(
1479 base_url=microsite.get_value('SITE_NAME', settings.SITE_NAME),
1480 course_about=reverse('about_course', kwargs={'course_id': course_id.to_deprecated_string()})
1481 )
1482 dashboard_url = '{base_url}{dashboard}'.format(
1483 base_url=microsite.get_value('SITE_NAME', settings.SITE_NAME),
1484 dashboard=reverse('dashboard')
1485 )
1486
1487 try:
1488 pdf_file = sale_invoice.generate_pdf_invoice(course, course_price, int(quantity), float(sale_price))
1489 except Exception: # pylint: disable=broad-except
1490 log.exception('Exception at creating pdf file.')
1491 pdf_file = None
1492
1493 from_address = microsite.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL)
1494 context = {
1495 'invoice': sale_invoice,
1496 'site_name': site_name,
1497 'course': course,
1498 'course_price': course_price,
1499 'sub_total': course_price * quantity,
1500 'discount': discount,
1501 'sale_price': sale_price,
1502 'quantity': quantity,
1503 'registration_codes': registration_codes,
1504 'currency_symbol': settings.PAID_COURSE_REGISTRATION_CURRENCY[1],
1505 'course_url': course_url,
1506 'platform_name': microsite.get_value('platform_name', settings.PLATFORM_NAME),
1507 'dashboard_url': dashboard_url,
1508 'contact_email': from_address,
1509 'corp_address': microsite.get_value('invoice_corp_address', settings.INVOICE_CORP_ADDRESS),
1510 'payment_instructions': microsite.get_value('invoice_payment_instructions', settings. INVOICE_PAYMENT_INSTRUCTIONS),
1511 'date': time.strftime("%m/%d/%Y")
1512 }
1513 # composes registration codes invoice email
1514 subject = u'Confirmation and Invoice for {course_name}'.format(course_name=course.display_name)
1515 message = render_to_string('emails/registration_codes_sale_email.txt', context)
1516
1517 invoice_attachment = render_to_string('emails/registration_codes_sale_invoice_attachment.txt', context)
1518
1519 #send_mail(subject, message, from_address, recipient_list, fail_silently=False)
1520 csv_file = StringIO.StringIO()
1521 csv_writer = csv.writer(csv_file)
1522 for registration_code in registration_codes:
1523 full_redeem_code_url = 'http://{base_url}{redeem_code_url}'.format(
1524 base_url=microsite.get_value('SITE_NAME', settings.SITE_NAME),
1525 redeem_code_url=reverse('register_code_redemption', kwargs={'registration_code': registration_code.code})
1526 )
1527 csv_writer.writerow([registration_code.code, full_redeem_code_url])
1528 finance_email = microsite.get_value('finance_email', settings.FINANCE_EMAIL)
1529 if finance_email:
1530 # append the finance email into the recipient_list
1531 recipient_list.append(finance_email)
1532
1533 # send a unique email for each recipient, don't put all email addresses in a single email
1534 for recipient in recipient_list:
1535 email = EmailMessage()
1536 email.subject = subject
1537 email.body = message
1538 email.from_email = from_address
1539 email.to = [recipient]
1540 email.attach(u'RegistrationCodes.csv', csv_file.getvalue(), 'text/csv')
1541 email.attach(u'Invoice.txt', invoice_attachment, 'text/plain')
1542 if pdf_file is not None:
1543 email.attach(u'Invoice.pdf', pdf_file.getvalue(), 'application/pdf')
1544 else:
1545 file_buffer = StringIO.StringIO(_('pdf download unavailable right now, please contact support.'))
1546 email.attach(u'pdf_unavailable.txt', file_buffer.getvalue(), 'text/plain')
1547 email.send()
1548
1549 return registration_codes_csv("Registration_Codes.csv", registration_codes)
1550
1551
1552@ensure_csrf_cookie
1553@cache_control(no_cache=True, no_store=True, must_revalidate=True)
1554@require_level('staff')
1555@require_POST
1556def active_registration_codes(request, course_id): # pylint: disable=unused-argument
1557 """
1558 Respond with csv which contains a summary of all Active Registration Codes.
1559 """
1560 course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
1561
1562 # find all the registration codes in this course
1563 registration_codes_list = CourseRegistrationCode.objects.filter(
1564 course_id=course_id
1565 ).order_by('invoice_item__invoice__company_name')
1566
1567 company_name = request.POST['active_company_name']
1568 if company_name:
1569 registration_codes_list = registration_codes_list.filter(invoice_item__invoice__company_name=company_name)
1570 # find the redeemed registration codes if any exist in the db
1571 code_redemption_set = RegistrationCodeRedemption.objects.select_related(
1572 'registration_code', 'registration_code__invoice_item__invoice'
1573 ).filter(registration_code__course_id=course_id)
1574 if code_redemption_set.exists():
1575 redeemed_registration_codes = [code.registration_code.code for code in code_redemption_set]
1576 # exclude the redeemed registration codes from the registration codes list and you will get
1577 # all the registration codes that are active
1578 registration_codes_list = registration_codes_list.exclude(code__in=redeemed_registration_codes)
1579
1580 return registration_codes_csv("Active_Registration_Codes.csv", registration_codes_list)
1581
1582
1583@ensure_csrf_cookie
1584@cache_control(no_cache=True, no_store=True, must_revalidate=True)
1585@require_level('staff')
1586@require_POST
1587def spent_registration_codes(request, course_id): # pylint: disable=unused-argument
1588 """
1589 Respond with csv which contains a summary of all Spent(used) Registration Codes.
1590 """
1591 course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
1592
1593 # find the redeemed registration codes if any exist in the db
1594 code_redemption_set = RegistrationCodeRedemption.objects.select_related('registration_code').filter(
1595 registration_code__course_id=course_id
1596 )
1597 spent_codes_list = []
1598 if code_redemption_set.exists():
1599 redeemed_registration_codes = [code.registration_code.code for code in code_redemption_set]
1600 # filter the Registration Codes by course id and the redeemed codes and
1601 # you will get a list of all the spent(Redeemed) Registration Codes
1602 spent_codes_list = CourseRegistrationCode.objects.filter(
1603 course_id=course_id, code__in=redeemed_registration_codes
1604 ).order_by('invoice_item__invoice__company_name').select_related('invoice_item__invoice')
1605
1606 company_name = request.POST['spent_company_name']
1607 if company_name:
1608 spent_codes_list = spent_codes_list.filter(invoice_item__invoice__company_name=company_name) # pylint: disable=maybe-no-member
1609
1610 csv_type = 'spent'
1611 return registration_codes_csv("Spent_Registration_Codes.csv", spent_codes_list, csv_type)
1612
1613
1614@ensure_csrf_cookie
1615@cache_control(no_cache=True, no_store=True, must_revalidate=True)
1616@require_level('staff')
1617def get_anon_ids(request, course_id): # pylint: disable=unused-argument
1618 """
1619 Respond with 2-column CSV output of user-id, anonymized-user-id
1620 """
1621 # TODO: the User.objects query and CSV generation here could be
1622 # centralized into instructor_analytics. Currently instructor_analytics
1623 # has similar functionality but not quite what's needed.
1624 course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
1625
1626 def csv_response(filename, header, rows):
1627 """Returns a CSV http response for the given header and rows (excel/utf-8)."""
1628 response = HttpResponse(mimetype='text/csv')
1629 response['Content-Disposition'] = 'attachment; filename={0}'.format(unicode(filename).encode('utf-8'))
1630 writer = csv.writer(response, dialect='excel', quotechar='"', quoting=csv.QUOTE_ALL)
1631 # In practice, there should not be non-ascii data in this query,
1632 # but trying to do the right thing anyway.
1633 encoded = [unicode(s).encode('utf-8') for s in header]
1634 writer.writerow(encoded)
1635 for row in rows:
1636 encoded = [unicode(s).encode('utf-8') for s in row]
1637 writer.writerow(encoded)
1638 return response
1639
1640 students = User.objects.filter(
1641 courseenrollment__course_id=course_id,
1642 ).order_by('id')
1643 header = ['User ID', 'Anonymized User ID', 'Course Specific Anonymized User ID']
1644 rows = [[s.id, unique_id_for_user(s, save=False), anonymous_id_for_user(s, course_id, save=False)] for s in students]
1645 return csv_response(course_id.to_deprecated_string().replace('/', '-') + '-anon-ids.csv', header, rows)
1646
1647
1648@ensure_csrf_cookie
1649@cache_control(no_cache=True, no_store=True, must_revalidate=True)
1650@require_level('staff')
1651def get_distribution(request, course_id):
1652 """
1653 Respond with json of the distribution of students over selected features which have choices.
1654
1655 Ask for a feature through the `feature` query parameter.
1656 If no `feature` is supplied, will return response with an
1657 empty response['feature_results'] object.
1658 A list of available will be available in the response['available_features']
1659 """
1660 course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
1661 feature = request.GET.get('feature')
1662 # alternate notations of None
1663 if feature in (None, 'null', ''):
1664 feature = None
1665 else:
1666 feature = str(feature)
1667
1668 available_features = instructor_analytics.distributions.AVAILABLE_PROFILE_FEATURES
1669 # allow None so that requests for no feature can list available features
1670 if feature not in available_features + (None,):
1671 return HttpResponseBadRequest(strip_tags(
1672 "feature '{}' not available.".format(feature)
1673 ))
1674
1675 response_payload = {
1676 'course_id': course_id.to_deprecated_string(),
1677 'queried_feature': feature,
1678 'available_features': available_features,
1679 'feature_display_names': instructor_analytics.distributions.DISPLAY_NAMES,
1680 }
1681
1682 p_dist = None
1683 if feature is not None:
1684 p_dist = instructor_analytics.distributions.profile_distribution(course_id, feature)
1685 response_payload['feature_results'] = {
1686 'feature': p_dist.feature,
1687 'feature_display_name': p_dist.feature_display_name,
1688 'data': p_dist.data,
1689 'type': p_dist.type,
1690 }
1691
1692 if p_dist.type == 'EASY_CHOICE':
1693 response_payload['feature_results']['choices_display_names'] = p_dist.choices_display_names
1694
1695 return JsonResponse(response_payload)
1696
1697
1698@ensure_csrf_cookie
1699@cache_control(no_cache=True, no_store=True, must_revalidate=True)
1700@common_exceptions_400
1701@require_level('staff')
1702@require_query_params(
1703 unique_student_identifier="email or username of student for whom to get progress url"
1704)
1705def get_student_progress_url(request, course_id):
1706 """
1707 Get the progress url of a student.
1708 Limited to staff access.
1709
1710 Takes query paremeter unique_student_identifier and if the student exists
1711 returns e.g. {
1712 'progress_url': '/../...'
1713 }
1714 """
1715 course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
1716 user = get_student_from_identifier(request.GET.get('unique_student_identifier'))
1717
1718 progress_url = reverse('student_progress', kwargs={'course_id': course_id.to_deprecated_string(), 'student_id': user.id})
1719
1720 response_payload = {
1721 'course_id': course_id.to_deprecated_string(),
1722 'progress_url': progress_url,
1723 }
1724 return JsonResponse(response_payload)
1725
1726
1727@ensure_csrf_cookie
1728@cache_control(no_cache=True, no_store=True, must_revalidate=True)
1729@require_level('staff')
1730@require_query_params(
1731 problem_to_reset="problem urlname to reset"
1732)
1733@common_exceptions_400
1734def reset_student_attempts(request, course_id):
1735 """
1736
1737 Resets a students attempts counter or starts a task to reset all students
1738 attempts counters. Optionally deletes student state for a problem. Limited
1739 to staff access. Some sub-methods limited to instructor access.
1740
1741 Takes some of the following query paremeters
1742 - problem_to_reset is a urlname of a problem
1743 - unique_student_identifier is an email or username
1744 - all_students is a boolean
1745 requires instructor access
1746 mutually exclusive with delete_module
1747 mutually exclusive with delete_module
1748 - delete_module is a boolean
1749 requires instructor access
1750 mutually exclusive with all_students
1751 """
1752 course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
1753 course = get_course_with_access(
1754 request.user, 'staff', course_id, depth=None
1755 )
1756
1757 problem_to_reset = strip_if_string(request.GET.get('problem_to_reset'))
1758 student_identifier = request.GET.get('unique_student_identifier', None)
1759 student = None
1760 if student_identifier is not None:
1761 student = get_student_from_identifier(student_identifier)
1762 all_students = request.GET.get('all_students', False) in ['true', 'True', True]
1763 delete_module = request.GET.get('delete_module', False) in ['true', 'True', True]
1764
1765 # parameter combinations
1766 if all_students and student:
1767 return HttpResponseBadRequest(
1768 "all_students and unique_student_identifier are mutually exclusive."
1769 )
1770 if all_students and delete_module:
1771 return HttpResponseBadRequest(
1772 "all_students and delete_module are mutually exclusive."
1773 )
1774
1775 # instructor authorization
1776 if all_students or delete_module:
1777 if not has_access(request.user, 'instructor', course):
1778 return HttpResponseForbidden("Requires instructor access.")
1779
1780 try:
1781 module_state_key = course_id.make_usage_key_from_deprecated_string(problem_to_reset)
1782 except InvalidKeyError:
1783 return HttpResponseBadRequest()
1784
1785 response_payload = {}
1786 response_payload['problem_to_reset'] = problem_to_reset
1787
1788 if student:
1789 try:
1790 enrollment.reset_student_attempts(course_id, student, module_state_key, delete_module=delete_module)
1791 except StudentModule.DoesNotExist:
1792 return HttpResponseBadRequest(_("Module does not exist."))
1793 except sub_api.SubmissionError:
1794 # Trust the submissions API to log the error
1795 error_msg = _("An error occurred while deleting the score.")
1796 return HttpResponse(error_msg, status=500)
1797 response_payload['student'] = student_identifier
1798 elif all_students:
1799 instructor_task.api.submit_reset_problem_attempts_for_all_students(request, module_state_key)
1800 response_payload['task'] = 'created'
1801 response_payload['student'] = 'All Students'
1802 else:
1803 return HttpResponseBadRequest()
1804
1805 return JsonResponse(response_payload)
1806
1807
1808@ensure_csrf_cookie
1809@cache_control(no_cache=True, no_store=True, must_revalidate=True)
1810@require_level('staff')
1811@common_exceptions_400
1812def reset_student_attempts_for_entrance_exam(request, course_id): # pylint: disable=invalid-name
1813 """
1814
1815 Resets a students attempts counter or starts a task to reset all students
1816 attempts counters for entrance exam. Optionally deletes student state for
1817 entrance exam. Limited to staff access. Some sub-methods limited to instructor access.
1818
1819 Following are possible query parameters
1820 - unique_student_identifier is an email or username
1821 - all_students is a boolean
1822 requires instructor access
1823 mutually exclusive with delete_module
1824 - delete_module is a boolean
1825 requires instructor access
1826 mutually exclusive with all_students
1827 """
1828 course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
1829 course = get_course_with_access(
1830 request.user, 'staff', course_id, depth=None
1831 )
1832
1833 if not course.entrance_exam_id:
1834 return HttpResponseBadRequest(
1835 _("Course has no entrance exam section.")
1836 )
1837
1838 student_identifier = request.GET.get('unique_student_identifier', None)
1839 student = None
1840 if student_identifier is not None:
1841 student = get_student_from_identifier(student_identifier)
1842 all_students = request.GET.get('all_students', False) in ['true', 'True', True]
1843 delete_module = request.GET.get('delete_module', False) in ['true', 'True', True]
1844
1845 # parameter combinations
1846 if all_students and student:
1847 return HttpResponseBadRequest(
1848 _("all_students and unique_student_identifier are mutually exclusive.")
1849 )
1850 if all_students and delete_module:
1851 return HttpResponseBadRequest(
1852 _("all_students and delete_module are mutually exclusive.")
1853 )
1854
1855 # instructor authorization
1856 if all_students or delete_module:
1857 if not has_access(request.user, 'instructor', course):
1858 return HttpResponseForbidden(_("Requires instructor access."))
1859
1860 try:
1861 entrance_exam_key = course_id.make_usage_key_from_deprecated_string(course.entrance_exam_id)
1862 if delete_module:
1863 instructor_task.api.submit_delete_entrance_exam_state_for_student(request, entrance_exam_key, student)
1864 else:
1865 instructor_task.api.submit_reset_problem_attempts_in_entrance_exam(request, entrance_exam_key, student)
1866 except InvalidKeyError:
1867 return HttpResponseBadRequest(_("Course has no valid entrance exam section."))
1868
1869 response_payload = {'student': student_identifier or _('All Students'), 'task': 'created'}
1870 return JsonResponse(response_payload)
1871
1872
1873@ensure_csrf_cookie
1874@cache_control(no_cache=True, no_store=True, must_revalidate=True)
1875@require_level('instructor')
1876@require_query_params(problem_to_reset="problem urlname to reset")
1877@common_exceptions_400
1878def rescore_problem(request, course_id):
1879 """
1880 Starts a background process a students attempts counter. Optionally deletes student state for a problem.
1881 Limited to instructor access.
1882
1883 Takes either of the following query paremeters
1884 - problem_to_reset is a urlname of a problem
1885 - unique_student_identifier is an email or username
1886 - all_students is a boolean
1887
1888 all_students and unique_student_identifier cannot both be present.
1889 """
1890 course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
1891 problem_to_reset = strip_if_string(request.GET.get('problem_to_reset'))
1892 student_identifier = request.GET.get('unique_student_identifier', None)
1893 student = None
1894 if student_identifier is not None:
1895 student = get_student_from_identifier(student_identifier)
1896
1897 all_students = request.GET.get('all_students') in ['true', 'True', True]
1898
1899 if not (problem_to_reset and (all_students or student)):
1900 return HttpResponseBadRequest("Missing query parameters.")
1901
1902 if all_students and student:
1903 return HttpResponseBadRequest(
1904 "Cannot rescore with all_students and unique_student_identifier."
1905 )
1906
1907 try:
1908 module_state_key = course_id.make_usage_key_from_deprecated_string(problem_to_reset)
1909 except InvalidKeyError:
1910 return HttpResponseBadRequest("Unable to parse problem id")
1911
1912 response_payload = {}
1913 response_payload['problem_to_reset'] = problem_to_reset
1914
1915 if student:
1916 response_payload['student'] = student_identifier
1917 instructor_task.api.submit_rescore_problem_for_student(request, module_state_key, student)
1918 response_payload['task'] = 'created'
1919 elif all_students:
1920 instructor_task.api.submit_rescore_problem_for_all_students(request, module_state_key)
1921 response_payload['task'] = 'created'
1922 else:
1923 return HttpResponseBadRequest()
1924
1925 return JsonResponse(response_payload)
1926
1927
1928@ensure_csrf_cookie
1929@cache_control(no_cache=True, no_store=True, must_revalidate=True)
1930@require_level('instructor')
1931@common_exceptions_400
1932def rescore_entrance_exam(request, course_id):
1933 """
1934 Starts a background process a students attempts counter for entrance exam.
1935 Optionally deletes student state for a problem. Limited to instructor access.
1936
1937 Takes either of the following query parameters
1938 - unique_student_identifier is an email or username
1939 - all_students is a boolean
1940
1941 all_students and unique_student_identifier cannot both be present.
1942 """
1943 course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
1944 course = get_course_with_access(
1945 request.user, 'staff', course_id, depth=None
1946 )
1947
1948 student_identifier = request.GET.get('unique_student_identifier', None)
1949 student = None
1950 if student_identifier is not None:
1951 student = get_student_from_identifier(student_identifier)
1952
1953 all_students = request.GET.get('all_students') in ['true', 'True', True]
1954
1955 if not course.entrance_exam_id:
1956 return HttpResponseBadRequest(
1957 _("Course has no entrance exam section.")
1958 )
1959
1960 if all_students and student:
1961 return HttpResponseBadRequest(
1962 _("Cannot rescore with all_students and unique_student_identifier.")
1963 )
1964
1965 try:
1966 entrance_exam_key = course_id.make_usage_key_from_deprecated_string(course.entrance_exam_id)
1967 except InvalidKeyError:
1968 return HttpResponseBadRequest(_("Course has no valid entrance exam section."))
1969
1970 response_payload = {}
1971 if student:
1972 response_payload['student'] = student_identifier
1973 else:
1974 response_payload['student'] = _("All Students")
1975 instructor_task.api.submit_rescore_entrance_exam_for_student(request, entrance_exam_key, student)
1976 response_payload['task'] = 'created'
1977 return JsonResponse(response_payload)
1978
1979
1980@ensure_csrf_cookie
1981@cache_control(no_cache=True, no_store=True, must_revalidate=True)
1982@require_level('staff')
1983def list_background_email_tasks(request, course_id): # pylint: disable=unused-argument
1984 """
1985 List background email tasks.
1986 """
1987 course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
1988 task_type = 'bulk_course_email'
1989 # Specifying for the history of a single task type
1990 tasks = instructor_task.api.get_instructor_task_history(course_id, task_type=task_type)
1991
1992 response_payload = {
1993 'tasks': map(extract_task_features, tasks),
1994 }
1995 return JsonResponse(response_payload)
1996
1997
1998@ensure_csrf_cookie
1999@cache_control(no_cache=True, no_store=True, must_revalidate=True)
2000@require_level('staff')
2001def list_email_content(request, course_id): # pylint: disable=unused-argument
2002 """
2003 List the content of bulk emails sent
2004 """
2005 course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
2006 task_type = 'bulk_course_email'
2007 # First get tasks list of bulk emails sent
2008 emails = instructor_task.api.get_instructor_task_history(course_id, task_type=task_type)
2009
2010 response_payload = {
2011 'emails': map(extract_email_features, emails),
2012 }
2013 return JsonResponse(response_payload)
2014
2015
2016@ensure_csrf_cookie
2017@cache_control(no_cache=True, no_store=True, must_revalidate=True)
2018@require_level('staff')
2019def list_instructor_tasks(request, course_id):
2020 """
2021 List instructor tasks.
2022
2023 Takes optional query paremeters.
2024 - With no arguments, lists running tasks.
2025 - `problem_location_str` lists task history for problem
2026 - `problem_location_str` and `unique_student_identifier` lists task
2027 history for problem AND student (intersection)
2028 """
2029 course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
2030 problem_location_str = strip_if_string(request.GET.get('problem_location_str', False))
2031 student = request.GET.get('unique_student_identifier', None)
2032 if student is not None:
2033 student = get_student_from_identifier(student)
2034
2035 if student and not problem_location_str:
2036 return HttpResponseBadRequest(
2037 "unique_student_identifier must accompany problem_location_str"
2038 )
2039
2040 if problem_location_str:
2041 try:
2042 module_state_key = course_id.make_usage_key_from_deprecated_string(problem_location_str)
2043 except InvalidKeyError:
2044 return HttpResponseBadRequest()
2045 if student:
2046 # Specifying for a single student's history on this problem
2047 tasks = instructor_task.api.get_instructor_task_history(course_id, module_state_key, student)
2048 else:
2049 # Specifying for single problem's history
2050 tasks = instructor_task.api.get_instructor_task_history(course_id, module_state_key)
2051 else:
2052 # If no problem or student, just get currently running tasks
2053 tasks = instructor_task.api.get_running_instructor_tasks(course_id)
2054
2055 response_payload = {
2056 'tasks': map(extract_task_features, tasks),
2057 }
2058 return JsonResponse(response_payload)
2059
2060
2061@ensure_csrf_cookie
2062@cache_control(no_cache=True, no_store=True, must_revalidate=True)
2063@require_level('staff')
2064def list_entrance_exam_instructor_tasks(request, course_id): # pylint: disable=invalid-name
2065 """
2066 List entrance exam related instructor tasks.
2067
2068 Takes either of the following query parameters
2069 - unique_student_identifier is an email or username
2070 - all_students is a boolean
2071 """
2072 course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
2073 course = get_course_by_id(course_id)
2074 student = request.GET.get('unique_student_identifier', None)
2075 if student is not None:
2076 student = get_student_from_identifier(student)
2077
2078 try:
2079 entrance_exam_key = course_id.make_usage_key_from_deprecated_string(course.entrance_exam_id)
2080 except InvalidKeyError:
2081 return HttpResponseBadRequest(_("Course has no valid entrance exam section."))
2082 if student:
2083 # Specifying for a single student's entrance exam history
2084 tasks = instructor_task.api.get_entrance_exam_instructor_task_history(course_id, entrance_exam_key, student)
2085 else:
2086 # Specifying for all student's entrance exam history
2087 tasks = instructor_task.api.get_entrance_exam_instructor_task_history(course_id, entrance_exam_key)
2088
2089 response_payload = {
2090 'tasks': map(extract_task_features, tasks),
2091 }
2092 return JsonResponse(response_payload)
2093
2094
2095@ensure_csrf_cookie
2096@cache_control(no_cache=True, no_store=True, must_revalidate=True)
2097@require_level('staff')
2098def list_report_downloads(_request, course_id):
2099 """
2100 List grade CSV files that are available for download for this course.
2101 """
2102 course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
2103 report_store = ReportStore.from_config(config_name='GRADES_DOWNLOAD')
2104
2105 response_payload = {
2106 'downloads': [
2107 dict(name=name, url=url, link='<a href="{}">{}</a>'.format(url, name))
2108 for name, url in report_store.links_for(course_id)
2109 ]
2110 }
2111 return JsonResponse(response_payload)
2112
2113
2114@ensure_csrf_cookie
2115@cache_control(no_cache=True, no_store=True, must_revalidate=True)
2116@require_level('staff')
2117@require_finance_admin
2118def list_financial_report_downloads(_request, course_id):
2119 """
2120 List grade CSV files that are available for download for this course.
2121 """
2122 course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
2123 report_store = ReportStore.from_config(config_name='FINANCIAL_REPORTS')
2124
2125 response_payload = {
2126 'downloads': [
2127 dict(name=name, url=url, link='<a href="{}">{}</a>'.format(url, name))
2128 for name, url in report_store.links_for(course_id)
2129 ]
2130 }
2131 return JsonResponse(response_payload)
2132
2133
2134@ensure_csrf_cookie
2135@cache_control(no_cache=True, no_store=True, must_revalidate=True)
2136@require_level('staff')
2137def calculate_grades_csv(request, course_id):
2138 """
2139 AlreadyRunningError is raised if the course's grades are already being updated.
2140 """
2141 course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
2142 try:
2143 instructor_task.api.submit_calculate_grades_csv(request, course_key)
2144 success_status = _(
2145 "Your grade report is being generated! "
2146 "You can view the status of the generation task in the 'Pending Tasks' section.")
2147 return JsonResponse({"status": success_status})
2148 except AlreadyRunningError:
2149 already_running_status = _(
2150 "A grade report generation task is already in progress. "
2151 "Check the 'Pending Tasks' table for the status of the task. "
2152 "When completed, the report will be available for download in the table below.")
2153 return JsonResponse({
2154 "status": already_running_status
2155 })
2156
2157
2158@ensure_csrf_cookie
2159@cache_control(no_cache=True, no_store=True, must_revalidate=True)
2160@require_level('staff')
2161def problem_grade_report(request, course_id):
2162 """
2163 Request a CSV showing students' grades for all problems in the
2164 course.
2165
2166 AlreadyRunningError is raised if the course's grades are already being
2167 updated.
2168 """
2169 course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
2170 try:
2171 instructor_task.api.submit_problem_grade_report(request, course_key)
2172 success_status = _(
2173 "Your problem grade report is being generated! "
2174 "You can view the status of the generation task in the 'Pending Tasks' section.")
2175 return JsonResponse({"status": success_status})
2176 except AlreadyRunningError:
2177 already_running_status = _(
2178 "A problem grade report is already being generated. "
2179 "Check the 'Pending Tasks' table for the status of the task. "
2180 "When completed, the report will be available for download in the table below.")
2181 return JsonResponse({
2182 "status": already_running_status
2183 })
2184
2185
2186@ensure_csrf_cookie
2187@cache_control(no_cache=True, no_store=True, must_revalidate=True)
2188@require_level('staff')
2189@require_query_params('rolename')
2190def list_forum_members(request, course_id):
2191 """
2192 Lists forum members of a certain rolename.
2193 Limited to staff access.
2194
2195 The requesting user must be at least staff.
2196 Staff forum admins can access all roles EXCEPT for FORUM_ROLE_ADMINISTRATOR
2197 which is limited to instructors.
2198
2199 Takes query parameter `rolename`.
2200 """
2201 course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
2202 course = get_course_by_id(course_id)
2203 has_instructor_access = has_access(request.user, 'instructor', course)
2204 has_forum_admin = has_forum_access(
2205 request.user, course_id, FORUM_ROLE_ADMINISTRATOR
2206 )
2207
2208 rolename = request.GET.get('rolename')
2209
2210 # default roles require either (staff & forum admin) or (instructor)
2211 if not (has_forum_admin or has_instructor_access):
2212 return HttpResponseBadRequest(
2213 "Operation requires staff & forum admin or instructor access"
2214 )
2215
2216 # EXCEPT FORUM_ROLE_ADMINISTRATOR requires (instructor)
2217 if rolename == FORUM_ROLE_ADMINISTRATOR and not has_instructor_access:
2218 return HttpResponseBadRequest("Operation requires instructor access.")
2219
2220 # filter out unsupported for roles
2221 if rolename not in [FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA]:
2222 return HttpResponseBadRequest(strip_tags(
2223 "Unrecognized rolename '{}'.".format(rolename)
2224 ))
2225
2226 try:
2227 role = Role.objects.get(name=rolename, course_id=course_id)
2228 users = role.users.all().order_by('username')
2229 except Role.DoesNotExist:
2230 users = []
2231
2232 def extract_user_info(user):
2233 """ Convert user to dict for json rendering. """
2234 return {
2235 'username': user.username,
2236 'email': user.email,
2237 'first_name': user.first_name,
2238 'last_name': user.last_name,
2239 }
2240
2241 response_payload = {
2242 'course_id': course_id.to_deprecated_string(),
2243 rolename: map(extract_user_info, users),
2244 }
2245 return JsonResponse(response_payload)
2246
2247
2248@ensure_csrf_cookie
2249@cache_control(no_cache=True, no_store=True, must_revalidate=True)
2250@require_level('staff')
2251@require_post_params(send_to="sending to whom", subject="subject line", message="message text")
2252def send_email(request, course_id):
2253 """
2254 Send an email to self, staff, or everyone involved in a course.
2255 Query Parameters:
2256 - 'send_to' specifies what group the email should be sent to
2257 Options are defined by the CourseEmail model in
2258 lms/djangoapps/bulk_email/models.py
2259 - 'subject' specifies email's subject
2260 - 'message' specifies email's content
2261 """
2262 course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
2263
2264 if not bulk_email_is_enabled_for_course(course_id):
2265 return HttpResponseForbidden("Email is not enabled for this course.")
2266
2267 send_to = request.POST.get("send_to")
2268 subject = request.POST.get("subject")
2269 message = request.POST.get("message")
2270
2271 # allow two branding points to come from Microsites: which CourseEmailTemplate should be used
2272 # and what the 'from' field in the email should be
2273 #
2274 # If these are None (because we are not in a Microsite or they are undefined in Microsite config) than
2275 # the system will use normal system defaults
2276 template_name = microsite.get_value('course_email_template_name')
2277 from_addr = microsite.get_value('course_email_from_addr')
2278
2279 # Create the CourseEmail object. This is saved immediately, so that
2280 # any transaction that has been pending up to this point will also be
2281 # committed.
2282 email = CourseEmail.create(
2283 course_id,
2284 request.user,
2285 send_to,
2286 subject, message,
2287 template_name=template_name,
2288 from_addr=from_addr
2289 )
2290
2291 # Submit the task, so that the correct InstructorTask object gets created (for monitoring purposes)
2292 instructor_task.api.submit_bulk_course_email(request, course_id, email.id) # pylint: disable=no-member
2293
2294 response_payload = {
2295 'course_id': course_id.to_deprecated_string(),
2296 'success': True,
2297 }
2298 return JsonResponse(response_payload)
2299
2300
2301@ensure_csrf_cookie
2302@cache_control(no_cache=True, no_store=True, must_revalidate=True)
2303@require_level('staff')
2304@require_query_params(
2305 unique_student_identifier="email or username of user to change access",
2306 rolename="the forum role",
2307 action="'allow' or 'revoke'",
2308)
2309@common_exceptions_400
2310def update_forum_role_membership(request, course_id):
2311 """
2312 Modify user's forum role.
2313
2314 The requesting user must be at least staff.
2315 Staff forum admins can access all roles EXCEPT for FORUM_ROLE_ADMINISTRATOR
2316 which is limited to instructors.
2317 No one can revoke an instructors FORUM_ROLE_ADMINISTRATOR status.
2318
2319 Query parameters:
2320 - `email` is the target users email
2321 - `rolename` is one of [FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA]
2322 - `action` is one of ['allow', 'revoke']
2323 """
2324 course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
2325 course = get_course_by_id(course_id)
2326 has_instructor_access = has_access(request.user, 'instructor', course)
2327 has_forum_admin = has_forum_access(
2328 request.user, course_id, FORUM_ROLE_ADMINISTRATOR
2329 )
2330
2331 unique_student_identifier = request.GET.get('unique_student_identifier')
2332 rolename = request.GET.get('rolename')
2333 action = request.GET.get('action')
2334
2335 # default roles require either (staff & forum admin) or (instructor)
2336 if not (has_forum_admin or has_instructor_access):
2337 return HttpResponseBadRequest(
2338 "Operation requires staff & forum admin or instructor access"
2339 )
2340
2341 # EXCEPT FORUM_ROLE_ADMINISTRATOR requires (instructor)
2342 if rolename == FORUM_ROLE_ADMINISTRATOR and not has_instructor_access:
2343 return HttpResponseBadRequest("Operation requires instructor access.")
2344
2345 if rolename not in [FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA]:
2346 return HttpResponseBadRequest(strip_tags(
2347 "Unrecognized rolename '{}'.".format(rolename)
2348 ))
2349
2350 user = get_student_from_identifier(unique_student_identifier)
2351
2352 try:
2353 update_forum_role(course_id, user, rolename, action)
2354 except Role.DoesNotExist:
2355 return HttpResponseBadRequest("Role does not exist.")
2356
2357 response_payload = {
2358 'course_id': course_id.to_deprecated_string(),
2359 'action': action,
2360 }
2361 return JsonResponse(response_payload)
2362
2363
2364@ensure_csrf_cookie
2365@cache_control(no_cache=True, no_store=True, must_revalidate=True)
2366@require_level('staff')
2367@require_query_params(
2368 aname="name of analytic to query",
2369)
2370@common_exceptions_400
2371def proxy_legacy_analytics(request, course_id):
2372 """
2373 Proxies to the analytics cron job server.
2374
2375 `aname` is a query parameter specifying which analytic to query.
2376 """
2377 course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
2378 analytics_name = request.GET.get('aname')
2379
2380 # abort if misconfigured
2381 if not (hasattr(settings, 'ANALYTICS_SERVER_URL') and
2382 hasattr(settings, 'ANALYTICS_API_KEY') and
2383 settings.ANALYTICS_SERVER_URL and settings.ANALYTICS_API_KEY):
2384 return HttpResponse("Analytics service not configured.", status=501)
2385
2386 url = "{}get?aname={}&course_id={}&apikey={}".format(
2387 settings.ANALYTICS_SERVER_URL,
2388 analytics_name,
2389 urllib.quote(unicode(course_id)),
2390 settings.ANALYTICS_API_KEY,
2391 )
2392
2393 try:
2394 res = requests.get(url)
2395 except Exception: # pylint: disable=broad-except
2396 log.exception(u"Error requesting from analytics server at %s", url)
2397 return HttpResponse("Error requesting from analytics server.", status=500)
2398
2399 if res.status_code is 200:
2400 payload = json.loads(res.content)
2401 add_block_ids(payload)
2402 content = json.dumps(payload)
2403 # return the successful request content
2404 return HttpResponse(content, content_type="application/json")
2405 elif res.status_code is 404:
2406 # forward the 404 and content
2407 return HttpResponse(res.content, content_type="application/json", status=404)
2408 else:
2409 # 500 on all other unexpected status codes.
2410 log.error(
2411 u"Error fetching %s, code: %s, msg: %s",
2412 url, res.status_code, res.content
2413 )
2414 return HttpResponse(
2415 "Error from analytics server ({}).".format(res.status_code),
2416 status=500
2417 )
2418
2419
2420@require_POST
2421def get_user_invoice_preference(request, course_id): # pylint: disable=unused-argument
2422 """
2423 Gets invoice copy user's preferences.
2424 """
2425 invoice_copy_preference = True
2426 invoice_preference_value = get_user_preference(request.user, INVOICE_KEY)
2427 if invoice_preference_value is not None:
2428 invoice_copy_preference = invoice_preference_value == 'True'
2429
2430 return JsonResponse({
2431 'invoice_copy': invoice_copy_preference
2432 })
2433
2434
2435def _display_unit(unit):
2436 """
2437 Gets string for displaying unit to user.
2438 """
2439 name = getattr(unit, 'display_name', None)
2440 if name:
2441 return u'{0} ({1})'.format(name, unit.location.to_deprecated_string())
2442 else:
2443 return unit.location.to_deprecated_string()
2444
2445
2446@handle_dashboard_error
2447@ensure_csrf_cookie
2448@cache_control(no_cache=True, no_store=True, must_revalidate=True)
2449@require_level('staff')
2450@require_query_params('student', 'url', 'due_datetime')
2451def change_due_date(request, course_id):
2452 """
2453 Grants a due date extension to a student for a particular unit.
2454 """
2455 course = get_course_by_id(SlashSeparatedCourseKey.from_deprecated_string(course_id))
2456 student = require_student_from_identifier(request.GET.get('student'))
2457 unit = find_unit(course, request.GET.get('url'))
2458 due_date = parse_datetime(request.GET.get('due_datetime'))
2459 set_due_date_extension(course, unit, student, due_date)
2460
2461 return JsonResponse(_(
2462 'Successfully changed due date for student {0} for {1} '
2463 'to {2}').format(student.profile.name, _display_unit(unit),
2464 due_date.strftime('%Y-%m-%d %H:%M')))
2465
2466
2467@handle_dashboard_error
2468@ensure_csrf_cookie
2469@cache_control(no_cache=True, no_store=True, must_revalidate=True)
2470@require_level('staff')
2471@require_query_params('student', 'url')
2472def reset_due_date(request, course_id):
2473 """
2474 Rescinds a due date extension for a student on a particular unit.
2475 """
2476 course = get_course_by_id(SlashSeparatedCourseKey.from_deprecated_string(course_id))
2477 student = require_student_from_identifier(request.GET.get('student'))
2478 unit = find_unit(course, request.GET.get('url'))
2479 set_due_date_extension(course, unit, student, None)
2480 if not getattr(unit, "due", None):
2481 # It's possible the normal due date was deleted after an extension was granted:
2482 return JsonResponse(
2483 _("Successfully removed invalid due date extension (unit has no due date).")
2484 )
2485
2486 original_due_date_str = unit.due.strftime('%Y-%m-%d %H:%M')
2487 return JsonResponse(_(
2488 'Successfully reset due date for student {0} for {1} '
2489 'to {2}').format(student.profile.name, _display_unit(unit),
2490 original_due_date_str))
2491
2492
2493@handle_dashboard_error
2494@ensure_csrf_cookie
2495@cache_control(no_cache=True, no_store=True, must_revalidate=True)
2496@require_level('staff')
2497@require_query_params('url')
2498def show_unit_extensions(request, course_id):
2499 """
2500 Shows all of the students which have due date extensions for the given unit.
2501 """
2502 course = get_course_by_id(SlashSeparatedCourseKey.from_deprecated_string(course_id))
2503 unit = find_unit(course, request.GET.get('url'))
2504 return JsonResponse(dump_module_extensions(course, unit))
2505
2506
2507@handle_dashboard_error
2508@ensure_csrf_cookie
2509@cache_control(no_cache=True, no_store=True, must_revalidate=True)
2510@require_level('staff')
2511@require_query_params('student')
2512def show_student_extensions(request, course_id):
2513 """
2514 Shows all of the due date extensions granted to a particular student in a
2515 particular course.
2516 """
2517 student = require_student_from_identifier(request.GET.get('student'))
2518 course = get_course_by_id(SlashSeparatedCourseKey.from_deprecated_string(course_id))
2519 return JsonResponse(dump_student_extensions(course, student))
2520
2521
2522def _split_input_list(str_list):
2523 """
2524 Separate out individual student email from the comma, or space separated string.
2525
2526 e.g.
2527 in: "Lorem@ipsum.dolor, sit@amet.consectetur\nadipiscing@elit.Aenean\r convallis@at.lacus\r, ut@lacinia.Sed"
2528 out: ['Lorem@ipsum.dolor', 'sit@amet.consectetur', 'adipiscing@elit.Aenean', 'convallis@at.lacus', 'ut@lacinia.Sed']
2529
2530 `str_list` is a string coming from an input text area
2531 returns a list of separated values
2532 """
2533
2534 new_list = re.split(r'[\n\r\s,]', str_list)
2535 new_list = [s.strip() for s in new_list]
2536 new_list = [s for s in new_list if s != '']
2537
2538 return new_list
2539
2540
2541def _instructor_dash_url(course_key, section=None):
2542 """Return the URL for a section in the instructor dashboard.
2543
2544 Arguments:
2545 course_key (CourseKey)
2546
2547 Keyword Arguments:
2548 section (str): The name of the section to load.
2549
2550 Returns:
2551 unicode: The URL of a section in the instructor dashboard.
2552
2553 """
2554 url = reverse('instructor_dashboard', kwargs={'course_id': unicode(course_key)})
2555 if section is not None:
2556 url += u'#view-{section}'.format(section=section)
2557 return url
2558
2559
2560@require_global_staff
2561@require_POST
2562def generate_example_certificates(request, course_id=None): # pylint: disable=unused-argument
2563 """Start generating a set of example certificates.
2564
2565 Example certificates are used to verify that certificates have
2566 been configured correctly for the course.
2567
2568 Redirects back to the intructor dashboard once certificate
2569 generation has begun.
2570
2571 """
2572 course_key = CourseKey.from_string(course_id)
2573 certs_api.generate_example_certificates(course_key)
2574 return redirect(_instructor_dash_url(course_key, section='certificates'))
2575
2576
2577@require_global_staff
2578@require_POST
2579def enable_certificate_generation(request, course_id=None):
2580 """Enable/disable self-generated certificates for a course.
2581
2582 Once self-generated certificates have been enabled, students
2583 who have passed the course will be able to generate certificates.
2584
2585 Redirects back to the intructor dashboard once the
2586 setting has been updated.
2587
2588 """
2589 course_key = CourseKey.from_string(course_id)
2590 is_enabled = (request.POST.get('certificates-enabled', 'false') == 'true')
2591 certs_api.set_cert_generation_enabled(course_key, is_enabled)
2592 return redirect(_instructor_dash_url(course_key, section='certificates'))
2593
2594
2595#---- Gradebook (shown to small courses only) ----
2596@cache_control(no_cache=True, no_store=True, must_revalidate=True)
2597@require_level('staff')
2598def spoc_gradebook(request, course_id):
2599 """
2600 Show the gradebook for this course:
2601 - Only shown for courses with enrollment < settings.FEATURES.get("MAX_ENROLLMENT_INSTR_BUTTONS")
2602 - Only displayed to course staff
2603 """
2604 course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
2605 course = get_course_with_access(request.user, 'staff', course_key, depth=None)
2606
2607 enrolled_students = User.objects.filter(
2608 courseenrollment__course_id=course_key,
2609 courseenrollment__is_active=1
2610 ).order_by('username').select_related("profile")
2611
2612 # possible extension: implement pagination to show to large courses
2613
2614 student_info = [
2615 {
2616 'username': student.username,
2617 'id': student.id,
2618 'email': student.email,
2619 'grade_summary': student_grades(student, request, course),
2620 'realname': student.profile.name,
2621 }
2622 for student in enrolled_students
2623 ]
2624
2625 return render_to_response('courseware/gradebook.html', {
2626 'students': student_info,
2627 'course': course,
2628 'course_id': course_key,
2629 # Checked above
2630 'staff_access': True,
2631 'ordered_grades': sorted(course.grade_cutoffs.items(), key=lambda i: i[1], reverse=True),
2632 })
2633
2634
2635@ensure_csrf_cookie
2636@cache_control(no_cache=True, no_store=True, must_revalidate=True)
2637@require_level('staff')
2638@require_POST
2639def mark_student_can_skip_entrance_exam(request, course_id): # pylint: disable=invalid-name
2640 """
2641 Mark a student to skip entrance exam.
2642 Takes `unique_student_identifier` as required POST parameter.
2643 """
2644 course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
2645 student_identifier = request.POST.get('unique_student_identifier')
2646 student = get_student_from_identifier(student_identifier)
2647
2648 __, created = EntranceExamConfiguration.objects.get_or_create(user=student, course_id=course_id)
2649 if created:
2650 message = _('This student (%s) will skip the entrance exam.') % student_identifier
2651 else:
2652 message = _('This student (%s) is already allowed to skip the entrance exam.') % student_identifier
2653 response_payload = {
2654 'message': message,
2655 }
2656 return JsonResponse(response_payload)
2657
2658
2659@ensure_csrf_cookie
2660@cache_control(no_cache=True, no_store=True, must_revalidate=True)
2661@require_global_staff
2662@require_POST
2663def start_certificate_generation(request, course_id):
2664 """
2665 Start generating certificates for all students enrolled in given course.
2666 """
2667 course_key = CourseKey.from_string(course_id)
2668 task = instructor_task.api.generate_certificates_for_all_students(request, course_key)
2669 message = _('Certificate generation task for all students of this course has been started. '
2670 'You can view the status of the generation task in the "Pending Tasks" section.')
2671 response_payload = {
2672 'message': message,
2673 'task_id': task.task_id
2674 }
2675 return JsonResponse(response_payload)