api.py 55.8 KB
Newer Older
1 2 3
"""
Instructor Dashboard API views

4
JSON views which the instructor dashboard requests.
5

Miles Steele committed
6
Many of these GETs may become PUTs in the future.
7
"""
8
from django.views.decorators.http import require_POST
9

10
import json
11 12
import logging
import re
13 14
import requests
from django.conf import settings
15 16
from django_future.csrf import ensure_csrf_cookie
from django.views.decorators.cache import cache_control
17
from django.core.exceptions import ValidationError
18
from django.db import IntegrityError
19
from django.core.urlresolvers import reverse
20
from django.core.validators import validate_email
21
from django.utils.translation import ugettext as _
22
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
23
from django.utils.html import strip_tags
24 25
import string  # pylint: disable=W0402
import random
26
from util.json_request import JsonResponse
27
from instructor.views.instructor_task_helpers import extract_email_features, extract_task_features
28

29
from courseware.access import has_access
30
from courseware.courses import get_course_with_access, get_course_by_id
31
from django.contrib.auth.models import User
32
from django_comment_client.utils import has_forum_access
33 34 35 36 37 38
from django_comment_common.models import (
    Role,
    FORUM_ROLE_ADMINISTRATOR,
    FORUM_ROLE_MODERATOR,
    FORUM_ROLE_COMMUNITY_TA,
)
39
from edxmako.shortcuts import render_to_response
40
from courseware.models import StudentModule
41
from shoppingcart.models import Coupon, CourseRegistrationCode, RegistrationCodeRedemption
42
from student.models import CourseEnrollment, unique_id_for_user, anonymous_id_for_user
Miles Steele committed
43
import instructor_task.api
44
from instructor_task.api_helper import AlreadyRunningError
45
from instructor_task.models import ReportStore
46
import instructor.enrollment as enrollment
47 48 49 50 51 52
from instructor.enrollment import (
    enroll_email,
    get_email_params,
    send_beta_role_email,
    unenroll_email
)
53
from instructor.access import list_with_level, allow_access, revoke_access, update_forum_role
54
from instructor.offline_gradecalc import student_grades
55 56 57
import instructor_analytics.basic
import instructor_analytics.distributions
import instructor_analytics.csvs
58
import csv
59

60
from submissions import api as sub_api  # installed from the edx-submissions repository
61

62 63
from bulk_email.models import CourseEmail

64 65 66 67 68 69 70 71 72
from .tools import (
    dump_student_extensions,
    dump_module_extensions,
    find_unit,
    get_student_from_identifier,
    handle_dashboard_error,
    parse_datetime,
    set_due_date_extension,
    strip_if_string,
73
    bulk_email_is_enabled_for_course,
74
)
75
from opaque_keys.edx.locations import SlashSeparatedCourseKey
76
from opaque_keys import InvalidKeyError
77

78 79
log = logging.getLogger(__name__)

80

81
def common_exceptions_400(func):
82 83 84 85
    """
    Catches common exceptions and renders matching 400 errors.
    (decorator without arguments)
    """
86 87 88
    def wrapped(request, *args, **kwargs):  # pylint: disable=C0111
        use_json = (request.is_ajax() or
                    request.META.get("HTTP_ACCEPT", "").startswith("application/json"))
89
        try:
90
            return func(request, *args, **kwargs)
91
        except User.DoesNotExist:
92
            message = _("User does not exist.")
93
            if use_json:
94
                return JsonResponse({"error": message}, 400)
95
            else:
96
                return HttpResponseBadRequest(message)
97
        except AlreadyRunningError:
98
            message = _("Task is already running.")
99
            if use_json:
100
                return JsonResponse({"error": message}, 400)
101
            else:
102
                return HttpResponseBadRequest(message)
103 104 105
    return wrapped


106 107 108
def require_query_params(*args, **kwargs):
    """
    Checks for required paremters or renders a 400 error.
109 110
    (decorator with arguments)

111 112 113 114 115 116 117 118 119
    `args` is a *list of required GET parameter names.
    `kwargs` is a **dict of required GET parameter names
        to string explanations of the parameter
    """
    required_params = []
    required_params += [(arg, None) for arg in args]
    required_params += [(key, kwargs[key]) for key in kwargs]
    # required_params = e.g. [('action', 'enroll or unenroll'), ['emails', None]]

Miles Steele committed
120 121
    def decorator(func):  # pylint: disable=C0111
        def wrapped(*args, **kwargs):  # pylint: disable=C0111
122 123 124 125 126 127 128 129 130 131 132
            request = args[0]

            error_response_data = {
                'error': 'Missing required query parameter(s)',
                'parameters': [],
                'info': {},
            }

            for (param, extra) in required_params:
                default = object()
                if request.GET.get(param, default) == default:
133
                    error_response_data['parameters'].append(param)
134 135 136
                    error_response_data['info'][param] = extra

            if len(error_response_data['parameters']) > 0:
137
                return JsonResponse(error_response_data, status=400)
138 139 140 141 142
            else:
                return func(*args, **kwargs)
        return wrapped
    return decorator

143

144 145
def require_post_params(*args, **kwargs):
    """
146
    Checks for required parameters or renders a 400 error.
147 148
    (decorator with arguments)

149 150
    Functions like 'require_query_params', but checks for
    POST parameters rather than GET parameters.
151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169
    """
    required_params = []
    required_params += [(arg, None) for arg in args]
    required_params += [(key, kwargs[key]) for key in kwargs]
    # required_params = e.g. [('action', 'enroll or unenroll'), ['emails', None]]

    def decorator(func):  # pylint: disable=C0111
        def wrapped(*args, **kwargs):  # pylint: disable=C0111
            request = args[0]

            error_response_data = {
                'error': 'Missing required query parameter(s)',
                'parameters': [],
                'info': {},
            }

            for (param, extra) in required_params:
                default = object()
                if request.POST.get(param, default) == default:
170
                    error_response_data['parameters'].append(param)
171 172 173 174 175 176 177 178 179
                    error_response_data['info'][param] = extra

            if len(error_response_data['parameters']) > 0:
                return JsonResponse(error_response_data, status=400)
            else:
                return func(*args, **kwargs)
        return wrapped
    return decorator

180

181 182 183 184 185 186 187 188 189 190 191
def require_level(level):
    """
    Decorator with argument that requires an access level of the requesting
    user. If the requirement is not satisfied, returns an
    HttpResponseForbidden (403).

    Assumes that request is in args[0].
    Assumes that course_id is in kwargs['course_id'].

    `level` is in ['instructor', 'staff']
    if `level` is 'staff', instructors will also be allowed, even
192
        if they are not in the staff group.
193 194 195 196
    """
    if level not in ['instructor', 'staff']:
        raise ValueError("unrecognized level '{}'".format(level))

Miles Steele committed
197 198
    def decorator(func):  # pylint: disable=C0111
        def wrapped(*args, **kwargs):  # pylint: disable=C0111
199
            request = args[0]
200
            course = get_course_by_id(SlashSeparatedCourseKey.from_deprecated_string(kwargs['course_id']))
201

202
            if has_access(request.user, level, course):
203 204 205 206 207 208 209
                return func(*args, **kwargs)
            else:
                return HttpResponseForbidden()
        return wrapped
    return decorator


210 211
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
212
@require_level('staff')
213
@require_query_params(action="enroll or unenroll", identifiers="stringified list of emails and/or usernames")
214
def students_update_enrollment(request, course_id):
215 216
    """
    Enroll or unenroll students by email.
217
    Requires staff access.
Miles Steele committed
218 219 220

    Query Parameters:
    - action in ['enroll', 'unenroll']
221
    - identifiers is string containing a list of emails and/or usernames separated by anything split_input_list can handle.
Miles Steele committed
222
    - auto_enroll is a boolean (defaults to false)
223
        If auto_enroll is false, students will be allowed to enroll.
224 225 226 227
        If auto_enroll is true, students will be enrolled as soon as they register.
    - email_students is a boolean (defaults to false)
        If email_students is true, students will be sent email notification
        If email_students is false, students will not be sent email notification
228 229 230

    Returns an analog to this JSON structure: {
        "action": "enroll",
231
        "auto_enroll": false,
232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249
        "results": [
            {
                "email": "testemail@test.org",
                "before": {
                    "enrollment": false,
                    "auto_enroll": false,
                    "user": true,
                    "allowed": false
                },
                "after": {
                    "enrollment": true,
                    "auto_enroll": false,
                    "user": true,
                    "allowed": false
                }
            }
        ]
    }
250
    """
251
    course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
252

253
    action = request.GET.get('action')
254 255
    identifiers_raw = request.GET.get('identifiers')
    identifiers = _split_input_list(identifiers_raw)
256
    auto_enroll = request.GET.get('auto_enroll') in ['true', 'True', True]
257 258 259 260 261
    email_students = request.GET.get('email_students') in ['true', 'True', True]

    email_params = {}
    if email_students:
        course = get_course_by_id(course_id)
262
        email_params = get_email_params(course, auto_enroll, secure=request.is_secure())
263

264
    results = []
265 266 267 268 269 270 271 272 273 274 275
    for identifier in identifiers:
        # First try to get a user object from the identifer
        user = None
        email = None
        try:
            user = get_student_from_identifier(identifier)
        except User.DoesNotExist:
            email = identifier
        else:
            email = user.email

276
        try:
277 278 279
            # Use django.core.validators.validate_email to check email address
            # validity (obviously, cannot check if email actually /exists/,
            # simply that it is plausibly valid)
280
            validate_email(email)  # Raises ValidationError if invalid
281

282
            if action == 'enroll':
283
                before, after = enroll_email(course_id, email, auto_enroll, email_students, email_params)
284
            elif action == 'unenroll':
285
                before, after = unenroll_email(course_id, email, email_students, email_params)
286
            else:
287 288 289
                return HttpResponseBadRequest(strip_tags(
                    "Unrecognized action '{}'".format(action)
                ))
290

291 292 293
        except ValidationError:
            # Flag this email as an error if invalid, but continue checking
            # the remaining in the list
294
            results.append({
295 296
                'identifier': identifier,
                'invalidIdentifier': True,
297
            })
298

David Baumgold committed
299
        except Exception as exc:  # pylint: disable=W0703
300 301
            # catch and log any exceptions
            # so that one error doesn't cause a 500.
302 303 304
            log.exception("Error while #{}ing student")
            log.exception(exc)
            results.append({
305
                'identifier': identifier,
306
                'error': True,
307 308 309 310 311 312 313
            })

        else:
            results.append({
                'identifier': identifier,
                'before': before.to_dict(),
                'after': after.to_dict(),
314
            })
315 316

    response_payload = {
317 318
        'action': action,
        'results': results,
Miles Steele committed
319
        'auto_enroll': auto_enroll,
320
    }
321
    return JsonResponse(response_payload)
322 323 324 325


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
326
@require_level('instructor')
327
@common_exceptions_400
328
@require_query_params(
329
    identifiers="stringified list of emails and/or usernames",
330 331
    action="add or remove",
)
332 333 334 335 336
def bulk_beta_modify_access(request, course_id):
    """
    Enroll or unenroll users in beta testing program.

    Query parameters:
337
    - identifiers is string containing a list of emails and/or usernames separated by
338
      anything split_input_list can handle.
339 340
    - action is one of ['add', 'remove']
    """
341
    course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
342
    action = request.GET.get('action')
343 344
    identifiers_raw = request.GET.get('identifiers')
    identifiers = _split_input_list(identifiers_raw)
345
    email_students = request.GET.get('email_students') in ['true', 'True', True]
346
    auto_enroll = request.GET.get('auto_enroll') in ['true', 'True', True]
347 348 349
    results = []
    rolename = 'beta'
    course = get_course_by_id(course_id)
350 351 352

    email_params = {}
    if email_students:
353 354
        secure = request.is_secure()
        email_params = get_email_params(course, auto_enroll=auto_enroll, secure=secure)
355

356
    for identifier in identifiers:
357
        try:
358 359
            error = False
            user_does_not_exist = False
360
            user = get_student_from_identifier(identifier)
361

362 363 364 365 366 367 368 369
            if action == 'add':
                allow_access(course, user, rolename)
            elif action == 'remove':
                revoke_access(course, user, rolename)
            else:
                return HttpResponseBadRequest(strip_tags(
                    "Unrecognized action '{}'".format(action)
                ))
370 371 372 373
        except User.DoesNotExist:
            error = True
            user_does_not_exist = True
        # catch and log any unexpected exceptions
374
        # so that one error doesn't cause a 500.
375
        except Exception as exc:  # pylint: disable=broad-except
376 377
            log.exception("Error while #{}ing student")
            log.exception(exc)
378 379 380 381 382
            error = True
        else:
            # If no exception thrown, see if we should send an email
            if email_students:
                send_beta_role_email(action, user, email_params)
383 384 385 386 387 388
            # See if we should autoenroll the student
            if auto_enroll:
                # Check if student is already enrolled
                if not CourseEnrollment.is_enrolled(user, course_id):
                    CourseEnrollment.enroll(user, course_id)

389 390
        finally:
            # Tabulate the action result of this email address
391
            results.append({
392
                'identifier': identifier,
393 394
                'error': error,
                'userDoesNotExist': user_does_not_exist
395 396 397 398 399 400 401 402 403 404 405 406 407 408
            })

    response_payload = {
        'action': action,
        'results': results,
    }
    return JsonResponse(response_payload)


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('instructor')
@common_exceptions_400
@require_query_params(
409
    unique_student_identifier="email or username of user to change access",
410
    rolename="'instructor', 'staff', or 'beta'",
411
    action="'allow' or 'revoke'"
412
)
413
def modify_access(request, course_id):
414
    """
415
    Modify staff/instructor access of other user.
416
    Requires instructor access.
417

418 419
    NOTE: instructors cannot remove their own instructor access.

420
    Query parameters:
421
    unique_student_identifer is the target user's username or email
422
    rolename is one of ['instructor', 'staff', 'beta']
423
    action is one of ['allow', 'revoke']
424
    """
425
    course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
426
    course = get_course_with_access(
427
        request.user, 'instructor', course_id, depth=None
428
    )
429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446
    try:
        user = get_student_from_identifier(request.GET.get('unique_student_identifier'))
    except User.DoesNotExist:
        response_payload = {
            'unique_student_identifier': request.GET.get('unique_student_identifier'),
            'userDoesNotExist': True,
        }
        return JsonResponse(response_payload)

    # Check that user is active, because add_users
    # in common/djangoapps/student/roles.py fails
    # silently when we try to add an inactive user.
    if not user.is_active:
        response_payload = {
            'unique_student_identifier': user.username,
            'inactiveUser': True,
        }
        return JsonResponse(response_payload)
447

Miles Steele committed
448
    rolename = request.GET.get('rolename')
449
    action = request.GET.get('action')
450

451
    if not rolename in ['instructor', 'staff', 'beta']:
452
        return HttpResponseBadRequest(strip_tags(
453
            "unknown rolename '{}'".format(rolename)
454
        ))
455

456 457
    # disallow instructors from removing their own instructor access.
    if rolename == 'instructor' and user == request.user and action != 'allow':
458 459 460 461 462 463 464
        response_payload = {
            'unique_student_identifier': user.username,
            'rolename': rolename,
            'action': action,
            'removingSelfAsInstructor': True,
        }
        return JsonResponse(response_payload)
465 466

    if action == 'allow':
467
        allow_access(course, user, rolename)
468
    elif action == 'revoke':
469
        revoke_access(course, user, rolename)
470
    else:
471 472 473
        return HttpResponseBadRequest(strip_tags(
            "unrecognized action '{}'".format(action)
        ))
474 475

    response_payload = {
476
        'unique_student_identifier': user.username,
477
        'rolename': rolename,
478
        'action': action,
479
        'success': 'yes',
480
    }
481
    return JsonResponse(response_payload)
482 483 484 485


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
486
@require_level('instructor')
487
@require_query_params(rolename="'instructor', 'staff', or 'beta'")
488
def list_course_role_members(request, course_id):
489 490
    """
    List instructors and staff.
491
    Requires instructor access.
Miles Steele committed
492

493
    rolename is one of ['instructor', 'staff', 'beta']
494 495 496 497 498 499 500 501 502 503 504 505

    Returns JSON of the form {
        "course_id": "some/course/id",
        "staff": [
            {
                "username": "staff1",
                "email": "staff1@example.org",
                "first_name": "Joe",
                "last_name": "Shmoe",
            }
        ]
    }
506
    """
507
    course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
508
    course = get_course_with_access(
509
        request.user, 'instructor', course_id, depth=None
510
    )
511

512
    rolename = request.GET.get('rolename')
Miles Steele committed
513

514
    if not rolename in ['instructor', 'staff', 'beta']:
Miles Steele committed
515 516 517
        return HttpResponseBadRequest()

    def extract_user_info(user):
518
        """ convert user into dicts for json view """
519 520 521 522 523 524 525 526
        return {
            'username': user.username,
            'email': user.email,
            'first_name': user.first_name,
            'last_name': user.last_name,
        }

    response_payload = {
527
        'course_id': course_id.to_deprecated_string(),
528
        rolename: map(extract_user_info, list_with_level(
529 530
            course, rolename
        )),
531
    }
532
    return JsonResponse(response_payload)
533 534 535 536


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
537 538
@require_level('staff')
def get_grading_config(request, course_id):
539 540 541
    """
    Respond with json which contains a html formatted grade summary.
    """
542
    course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
543
    course = get_course_with_access(
544
        request.user, 'staff', course_id, depth=None
545
    )
546
    grading_config_summary = instructor_analytics.basic.dump_grading_context(course)
547 548

    response_payload = {
549
        'course_id': course_id.to_deprecated_string(),
550 551
        'grading_config_summary': grading_config_summary,
    }
552
    return JsonResponse(response_payload)
553 554 555 556


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
557
@require_level('staff')
558 559 560 561 562 563 564 565 566 567 568
def get_purchase_transaction(request, course_id, csv=False):  # pylint: disable=W0613, W0621
    """
    return the summary of all purchased transactions for a particular course
    """
    course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
    query_features = [
        'id', 'username', 'email', 'course_id', 'list_price', 'coupon_code',
        'unit_cost', 'purchase_time', 'orderitem_id',
        'order_id',
    ]

569
    student_data = instructor_analytics.basic.purchase_transactions(course_id, query_features)
570 571 572 573 574 575 576 577 578

    if not csv:
        response_payload = {
            'course_id': course_id.to_deprecated_string(),
            'students': student_data,
            'queried_features': query_features
        }
        return JsonResponse(response_payload)
    else:
579 580
        header, datarows = instructor_analytics.csvs.format_dictlist(student_data, query_features)
        return instructor_analytics.csvs.create_csv_response("e-commerce_purchase_transactions.csv", header, datarows)
581 582 583 584 585


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff')
586
def get_students_features(request, course_id, csv=False):  # pylint: disable=W0613, W0621
587 588 589
    """
    Respond with json which contains a summary of all enrolled students profile information.

590 591
    Responds with JSON
        {"students": [{-student-info-}, ...]}
592

Miles Steele committed
593
    TO DO accept requests for different attribute sets.
594
    """
595 596
    course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)

597
    available_features = instructor_analytics.basic.AVAILABLE_FEATURES
598
    query_features = [
599 600 601
        'id', 'username', 'name', 'email', 'language', 'location',
        'year_of_birth', 'gender', 'level_of_education', 'mailing_address',
        'goals',
602
    ]
603

604
    student_data = instructor_analytics.basic.enrolled_students_features(course_id, query_features)
605

606 607 608
    # Provide human-friendly and translatable names for these features. These names
    # will be displayed in the table generated in data_download.coffee. It is not (yet)
    # used as the header row in the CSV, but could be in the future.
609
    query_features_names = {
610
        'id': _('User ID'),
611 612 613 614 615 616 617 618 619 620 621 622
        'username': _('Username'),
        'name': _('Name'),
        'email': _('Email'),
        'language': _('Language'),
        'location': _('Location'),
        'year_of_birth': _('Birth Year'),
        'gender': _('Gender'),
        'level_of_education': _('Level of Education'),
        'mailing_address': _('Mailing Address'),
        'goals': _('Goals'),
    }

623 624
    if not csv:
        response_payload = {
625
            'course_id': course_id.to_deprecated_string(),
626 627 628
            'students': student_data,
            'students_count': len(student_data),
            'queried_features': query_features,
629
            'feature_names': query_features_names,
630
            'available_features': available_features,
631
        }
632
        return JsonResponse(response_payload)
633
    else:
634 635
        header, datarows = instructor_analytics.csvs.format_dictlist(student_data, query_features)
        return instructor_analytics.csvs.create_csv_response("enrolled_profiles.csv", header, datarows)
636 637


638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673
def save_registration_codes(request, course_id, generated_codes_list, group_name):
    """
    recursive function that generate a new code every time and saves in the Course Registration Table
    if validation check passes
    """
    code = random_code_generator()

    # check if the generated code is in the Coupon Table
    matching_coupons = Coupon.objects.filter(code=code, is_active=True)
    if matching_coupons:
        return save_registration_codes(request, course_id, generated_codes_list, group_name)

    course_registration = CourseRegistrationCode(
        code=code, course_id=course_id.to_deprecated_string(),
        transaction_group_name=group_name, created_by=request.user
    )
    try:
        course_registration.save()
        generated_codes_list.append(course_registration)
    except IntegrityError:
        return save_registration_codes(request, course_id, generated_codes_list, group_name)


def registration_codes_csv(file_name, codes_list, csv_type=None):
    """
    Respond with the csv headers and data rows
    given a dict of codes list
    :param file_name:
    :param codes_list:
    :param csv_type:
    """
    # csv headers
    query_features = ['code', 'course_id', 'transaction_group_name', 'created_by', 'redeemed_by']

    registration_codes = instructor_analytics.basic.course_registration_features(query_features, codes_list, csv_type)
    header, data_rows = instructor_analytics.csvs.format_dictlist(registration_codes, query_features)
674
    return instructor_analytics.csvs.create_csv_response(file_name, header, data_rows)
675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786


def random_code_generator():
    """
    generate a random alphanumeric code of length defined in
    REGISTRATION_CODE_LENGTH settings
    """
    chars = string.ascii_uppercase + string.digits + string.ascii_lowercase
    code_length = getattr(settings, 'REGISTRATION_CODE_LENGTH', 8)
    return string.join((random.choice(chars) for _ in range(code_length)), '')


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff')
@require_POST
def get_registration_codes(request, course_id):  # pylint: disable=W0613
    """
    Respond with csv which contains a summary of all Registration Codes.
    """
    course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)

    #filter all the  course registration codes
    registration_codes = CourseRegistrationCode.objects.filter(course_id=course_id).order_by('transaction_group_name')

    group_name = request.POST['download_transaction_group_name']
    if group_name:
        registration_codes = registration_codes.filter(transaction_group_name=group_name)

    csv_type = 'download'
    return registration_codes_csv("Registration_Codes.csv", registration_codes, csv_type)


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff')
@require_POST
def generate_registration_codes(request, course_id):
    """
    Respond with csv which contains a summary of all Generated Codes.
    """
    course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
    course_registration_codes = []

    # covert the course registration code number into integer
    try:
        course_code_number = int(request.POST['course_registration_code_number'])
    except ValueError:
        course_code_number = int(float(request.POST['course_registration_code_number']))

    group_name = request.POST['transaction_group_name']

    for _ in range(course_code_number):  # pylint: disable=W0621
        save_registration_codes(request, course_id, course_registration_codes, group_name)

    return registration_codes_csv("Registration_Codes.csv", course_registration_codes)


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff')
@require_POST
def active_registration_codes(request, course_id):  # pylint: disable=W0613
    """
    Respond with csv which contains a summary of all Active Registration Codes.
    """
    course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)

    # find all the registration codes in this course
    registration_codes_list = CourseRegistrationCode.objects.filter(course_id=course_id).order_by('transaction_group_name')

    group_name = request.POST['active_transaction_group_name']
    if group_name:
        registration_codes_list = registration_codes_list.filter(transaction_group_name=group_name)
    # find the redeemed registration codes if any exist in the db
    code_redemption_set = RegistrationCodeRedemption.objects.select_related('registration_code').filter(registration_code__course_id=course_id)
    if code_redemption_set.exists():
        redeemed_registration_codes = [code.registration_code.code for code in code_redemption_set]
        # exclude the redeemed registration codes from the registration codes list and you will get
        # all the registration codes that are active
        registration_codes_list = registration_codes_list.exclude(code__in=redeemed_registration_codes)

    return registration_codes_csv("Active_Registration_Codes.csv", registration_codes_list)


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff')
@require_POST
def spent_registration_codes(request, course_id):  # pylint: disable=W0613
    """
    Respond with csv which contains a summary of all Spent(used) Registration Codes.
    """
    course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)

    # find the redeemed registration codes if any exist in the db
    code_redemption_set = RegistrationCodeRedemption.objects.select_related('registration_code').filter(registration_code__course_id=course_id)
    spent_codes_list = []
    if code_redemption_set.exists():
        redeemed_registration_codes = [code.registration_code.code for code in code_redemption_set]
        # filter the Registration Codes by course id and the redeemed codes and
        # you will get a list of all the spent(Redeemed) Registration Codes
        spent_codes_list = CourseRegistrationCode.objects.filter(course_id=course_id, code__in=redeemed_registration_codes).order_by('transaction_group_name')

        group_name = request.POST['spent_transaction_group_name']
        if group_name:
            spent_codes_list = spent_codes_list.filter(transaction_group_name=group_name)  # pylint:  disable=E1103

    csv_type = 'spent'
    return registration_codes_csv("Spent_Registration_Codes.csv", spent_codes_list, csv_type)


787 788
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
789 790 791 792 793 794
@require_level('staff')
def get_anon_ids(request, course_id):  # pylint: disable=W0613
    """
    Respond with 2-column CSV output of user-id, anonymized-user-id
    """
    # TODO: the User.objects query and CSV generation here could be
795
    # centralized into instructor_analytics. Currently instructor_analytics
796
    # has similar functionality but not quite what's needed.
797
    course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
798

799 800 801
    def csv_response(filename, header, rows):
        """Returns a CSV http response for the given header and rows (excel/utf-8)."""
        response = HttpResponse(mimetype='text/csv')
Abdallah committed
802
        response['Content-Disposition'] = 'attachment; filename={0}'.format(unicode(filename).encode('utf-8'))
803 804 805 806 807 808 809 810 811 812 813 814 815
        writer = csv.writer(response, dialect='excel', quotechar='"', quoting=csv.QUOTE_ALL)
        # In practice, there should not be non-ascii data in this query,
        # but trying to do the right thing anyway.
        encoded = [unicode(s).encode('utf-8') for s in header]
        writer.writerow(encoded)
        for row in rows:
            encoded = [unicode(s).encode('utf-8') for s in row]
            writer.writerow(encoded)
        return response

    students = User.objects.filter(
        courseenrollment__course_id=course_id,
    ).order_by('id')
816 817
    header = ['User ID', 'Anonymized User ID', 'Course Specific Anonymized User ID']
    rows = [[s.id, unique_id_for_user(s, save=False), anonymous_id_for_user(s, course_id, save=False)] for s in students]
Calen Pennington committed
818
    return csv_response(course_id.to_deprecated_string().replace('/', '-') + '-anon-ids.csv', header, rows)
819 820 821 822


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
823 824
@require_level('staff')
def get_distribution(request, course_id):
825
    """
826
    Respond with json of the distribution of students over selected features which have choices.
827

828 829 830 831
    Ask for a feature through the `feature` query parameter.
    If no `feature` is supplied, will return response with an
        empty response['feature_results'] object.
    A list of available will be available in the response['available_features']
832
    """
833
    course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
834 835 836 837 838 839
    feature = request.GET.get('feature')
    # alternate notations of None
    if feature in (None, 'null', ''):
        feature = None
    else:
        feature = str(feature)
840

841
    available_features = instructor_analytics.distributions.AVAILABLE_PROFILE_FEATURES
842
    # allow None so that requests for no feature can list available features
Miles Steele committed
843
    if not feature in available_features + (None,):
844
        return HttpResponseBadRequest(strip_tags(
845
            "feature '{}' not available.".format(feature)
846
        ))
847 848

    response_payload = {
849
        'course_id': course_id.to_deprecated_string(),
850
        'queried_feature': feature,
Miles Steele committed
851
        'available_features': available_features,
852
        'feature_display_names': instructor_analytics.distributions.DISPLAY_NAMES,
853
    }
854 855 856

    p_dist = None
    if not feature is None:
857
        p_dist = instructor_analytics.distributions.profile_distribution(course_id, feature)
858
        response_payload['feature_results'] = {
David Baumgold committed
859 860 861 862
            'feature': p_dist.feature,
            'feature_display_name': p_dist.feature_display_name,
            'data': p_dist.data,
            'type': p_dist.type,
863 864 865 866 867
        }

        if p_dist.type == 'EASY_CHOICE':
            response_payload['feature_results']['choices_display_names'] = p_dist.choices_display_names

868
    return JsonResponse(response_payload)
869 870 871 872


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
873 874
@common_exceptions_400
@require_level('staff')
875
@require_query_params(
876
    unique_student_identifier="email or username of student for whom to get progress url"
877
)
878 879 880 881 882
def get_student_progress_url(request, course_id):
    """
    Get the progress url of a student.
    Limited to staff access.

883
    Takes query paremeter unique_student_identifier and if the student exists
884 885 886 887
    returns e.g. {
        'progress_url': '/../...'
    }
    """
888
    course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
889
    user = get_student_from_identifier(request.GET.get('unique_student_identifier'))
890

891
    progress_url = reverse('student_progress', kwargs={'course_id': course_id.to_deprecated_string(), 'student_id': user.id})
892 893

    response_payload = {
894
        'course_id': course_id.to_deprecated_string(),
895 896
        'progress_url': progress_url,
    }
897
    return JsonResponse(response_payload)
898 899 900 901


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
902 903
@require_level('staff')
@require_query_params(
904
    problem_to_reset="problem urlname to reset"
905
)
906
@common_exceptions_400
907 908 909
def reset_student_attempts(request, course_id):
    """

910 911 912 913 914
    Resets a students attempts counter or starts a task to reset all students
    attempts counters. Optionally deletes student state for a problem. Limited
    to staff access. Some sub-methods limited to instructor access.

    Takes some of the following query paremeters
Miles Steele committed
915
        - problem_to_reset is a urlname of a problem
916
        - unique_student_identifier is an email or username
917 918 919 920 921 922 923
        - all_students is a boolean
            requires instructor access
            mutually exclusive with delete_module
            mutually exclusive with delete_module
        - delete_module is a boolean
            requires instructor access
            mutually exclusive with all_students
924
    """
925
    course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
926
    course = get_course_with_access(
927
        request.user, 'staff', course_id, depth=None
928
    )
929

930
    problem_to_reset = strip_if_string(request.GET.get('problem_to_reset'))
931 932 933 934
    student_identifier = request.GET.get('unique_student_identifier', None)
    student = None
    if student_identifier is not None:
        student = get_student_from_identifier(student_identifier)
Miles Steele committed
935
    all_students = request.GET.get('all_students', False) in ['true', 'True', True]
936
    delete_module = request.GET.get('delete_module', False) in ['true', 'True', True]
Miles Steele committed
937

938
    # parameter combinations
939
    if all_students and student:
940
        return HttpResponseBadRequest(
941
            "all_students and unique_student_identifier are mutually exclusive."
942 943 944 945 946
        )
    if all_students and delete_module:
        return HttpResponseBadRequest(
            "all_students and delete_module are mutually exclusive."
        )
Miles Steele committed
947

948
    # instructor authorization
949
    if all_students or delete_module:
950
        if not has_access(request.user, 'instructor', course):
951
            return HttpResponseForbidden("Requires instructor access.")
952

953
    try:
954
        module_state_key = course_id.make_usage_key_from_deprecated_string(problem_to_reset)
955 956
    except InvalidKeyError:
        return HttpResponseBadRequest()
Miles Steele committed
957 958 959 960

    response_payload = {}
    response_payload['problem_to_reset'] = problem_to_reset

961
    if student:
Miles Steele committed
962
        try:
963
            enrollment.reset_student_attempts(course_id, student, module_state_key, delete_module=delete_module)
Miles Steele committed
964
        except StudentModule.DoesNotExist:
965 966 967 968 969
            return HttpResponseBadRequest(_("Module does not exist."))
        except sub_api.SubmissionError:
            # Trust the submissions API to log the error
            error_msg = _("An error occurred while deleting the score.")
            return HttpResponse(error_msg, status=500)
970
        response_payload['student'] = student_identifier
Miles Steele committed
971
    elif all_students:
972
        instructor_task.api.submit_reset_problem_attempts_for_all_students(request, module_state_key)
Miles Steele committed
973
        response_payload['task'] = 'created'
974
        response_payload['student'] = 'All Students'
Miles Steele committed
975 976 977
    else:
        return HttpResponseBadRequest()

978
    return JsonResponse(response_payload)
Miles Steele committed
979 980 981 982


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
983 984
@require_level('instructor')
@require_query_params(problem_to_reset="problem urlname to reset")
985
@common_exceptions_400
Miles Steele committed
986 987 988
def rescore_problem(request, course_id):
    """
    Starts a background process a students attempts counter. Optionally deletes student state for a problem.
989
    Limited to instructor access.
Miles Steele committed
990 991 992

    Takes either of the following query paremeters
        - problem_to_reset is a urlname of a problem
993
        - unique_student_identifier is an email or username
Miles Steele committed
994 995
        - all_students is a boolean

996
    all_students and unique_student_identifier cannot both be present.
Miles Steele committed
997
    """
998
    course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
999
    problem_to_reset = strip_if_string(request.GET.get('problem_to_reset'))
1000 1001 1002 1003
    student_identifier = request.GET.get('unique_student_identifier', None)
    student = None
    if student_identifier is not None:
        student = get_student_from_identifier(student_identifier)
1004

1005
    all_students = request.GET.get('all_students') in ['true', 'True', True]
1006

1007
    if not (problem_to_reset and (all_students or student)):
1008 1009
        return HttpResponseBadRequest("Missing query parameters.")

1010
    if all_students and student:
1011
        return HttpResponseBadRequest(
1012
            "Cannot rescore with all_students and unique_student_identifier."
1013
        )
1014

1015
    try:
1016
        module_state_key = course_id.make_usage_key_from_deprecated_string(problem_to_reset)
1017
    except InvalidKeyError:
Calen Pennington committed
1018
        return HttpResponseBadRequest("Unable to parse problem id")
1019

Miles Steele committed
1020 1021 1022
    response_payload = {}
    response_payload['problem_to_reset'] = problem_to_reset

1023
    if student:
1024
        response_payload['student'] = student_identifier
1025
        instructor_task.api.submit_rescore_problem_for_student(request, module_state_key, student)
Miles Steele committed
1026 1027
        response_payload['task'] = 'created'
    elif all_students:
1028
        instructor_task.api.submit_rescore_problem_for_all_students(request, module_state_key)
Miles Steele committed
1029 1030 1031 1032
        response_payload['task'] = 'created'
    else:
        return HttpResponseBadRequest()

1033
    return JsonResponse(response_payload)
Miles Steele committed
1034 1035 1036 1037


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
1038 1039 1040 1041 1042
@require_level('staff')
def list_background_email_tasks(request, course_id):  # pylint: disable=unused-argument
    """
    List background email tasks.
    """
1043
    course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056
    task_type = 'bulk_course_email'
    # Specifying for the history of a single task type
    tasks = instructor_task.api.get_instructor_task_history(course_id, task_type=task_type)

    response_payload = {
        'tasks': map(extract_task_features, tasks),
    }
    return JsonResponse(response_payload)


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff')
1057
def list_email_content(request, course_id):  # pylint: disable=unused-argument
1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074
    """
    List the content of bulk emails sent
    """
    course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
    task_type = 'bulk_course_email'
    # First get tasks list of bulk emails sent
    emails = instructor_task.api.get_instructor_task_history(course_id, task_type=task_type)

    response_payload = {
        'emails': map(extract_email_features, emails),
    }
    return JsonResponse(response_payload)


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff')
Miles Steele committed
1075 1076 1077 1078
def list_instructor_tasks(request, course_id):
    """
    List instructor tasks.

1079 1080
    Takes optional query paremeters.
        - With no arguments, lists running tasks.
1081 1082
        - `problem_location_str` lists task history for problem
        - `problem_location_str` and `unique_student_identifier` lists task
1083
            history for problem AND student (intersection)
Miles Steele committed
1084
    """
1085 1086
    course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
    problem_location_str = strip_if_string(request.GET.get('problem_location_str', False))
1087 1088 1089
    student = request.GET.get('unique_student_identifier', None)
    if student is not None:
        student = get_student_from_identifier(student)
Miles Steele committed
1090

1091
    if student and not problem_location_str:
1092
        return HttpResponseBadRequest(
1093
            "unique_student_identifier must accompany problem_location_str"
1094
        )
1095

1096 1097
    if problem_location_str:
        try:
1098
            module_state_key = course_id.make_usage_key_from_deprecated_string(problem_location_str)
1099 1100
        except InvalidKeyError:
            return HttpResponseBadRequest()
1101
        if student:
1102
            # Specifying for a single student's history on this problem
Miles Steele committed
1103 1104
            tasks = instructor_task.api.get_instructor_task_history(course_id, module_state_key, student)
        else:
1105
            # Specifying for single problem's history
Miles Steele committed
1106 1107
            tasks = instructor_task.api.get_instructor_task_history(course_id, module_state_key)
    else:
1108
        # If no problem or student, just get currently running tasks
Miles Steele committed
1109 1110
        tasks = instructor_task.api.get_running_instructor_tasks(course_id)

1111
    response_payload = {
Miles Steele committed
1112
        'tasks': map(extract_task_features, tasks),
1113
    }
1114
    return JsonResponse(response_payload)
Miles Steele committed
1115 1116 1117 1118


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
1119
@require_level('staff')
1120
def list_report_downloads(_request, course_id):
1121 1122 1123
    """
    List grade CSV files that are available for download for this course.
    """
1124
    course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
1125
    report_store = ReportStore.from_config()
1126 1127

    response_payload = {
1128
        'downloads': [
1129
            dict(name=name, url=url, link='<a href="{}">{}</a>'.format(url, name))
1130
            for name, url in report_store.links_for(course_id)
1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142
        ]
    }
    return JsonResponse(response_payload)


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff')
def calculate_grades_csv(request, course_id):
    """
    AlreadyRunningError is raised if the course's grades are already being updated.
    """
1143
    course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
1144
    try:
1145
        instructor_task.api.submit_calculate_grades_csv(request, course_key)
1146 1147
        success_status = _("Your grade report is being generated! You can view the status of the generation task in the 'Pending Instructor Tasks' section.")
        return JsonResponse({"status": success_status})
1148
    except AlreadyRunningError:
1149
        already_running_status = _("A grade report generation task is already in progress. Check the 'Pending Instructor Tasks' table for the status of the task. When completed, the report will be available for download in the table below.")
1150
        return JsonResponse({
1151
            "status": already_running_status
1152 1153 1154 1155 1156 1157
        })


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff')
1158
@require_query_params('rolename')
Miles Steele committed
1159 1160
def list_forum_members(request, course_id):
    """
1161
    Lists forum members of a certain rolename.
Miles Steele committed
1162 1163
    Limited to staff access.

1164 1165 1166 1167 1168
    The requesting user must be at least staff.
    Staff forum admins can access all roles EXCEPT for FORUM_ROLE_ADMINISTRATOR
        which is limited to instructors.

    Takes query parameter `rolename`.
Miles Steele committed
1169
    """
1170
    course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
1171
    course = get_course_by_id(course_id)
1172
    has_instructor_access = has_access(request.user, 'instructor', course)
1173 1174 1175 1176
    has_forum_admin = has_forum_access(
        request.user, course_id, FORUM_ROLE_ADMINISTRATOR
    )

1177
    rolename = request.GET.get('rolename')
Miles Steele committed
1178

1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189
    # default roles require either (staff & forum admin) or (instructor)
    if not (has_forum_admin or has_instructor_access):
        return HttpResponseBadRequest(
            "Operation requires staff & forum admin or instructor access"
        )

    # EXCEPT FORUM_ROLE_ADMINISTRATOR requires (instructor)
    if rolename == FORUM_ROLE_ADMINISTRATOR and not has_instructor_access:
        return HttpResponseBadRequest("Operation requires instructor access.")

    # filter out unsupported for roles
Miles Steele committed
1190
    if not rolename in [FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA]:
1191 1192 1193
        return HttpResponseBadRequest(strip_tags(
            "Unrecognized rolename '{}'.".format(rolename)
        ))
Miles Steele committed
1194 1195 1196 1197 1198 1199 1200 1201

    try:
        role = Role.objects.get(name=rolename, course_id=course_id)
        users = role.users.all().order_by('username')
    except Role.DoesNotExist:
        users = []

    def extract_user_info(user):
1202
        """ Convert user to dict for json rendering. """
Miles Steele committed
1203 1204 1205 1206 1207 1208 1209 1210
        return {
            'username': user.username,
            'email': user.email,
            'first_name': user.first_name,
            'last_name': user.last_name,
        }

    response_payload = {
1211
        'course_id': course_id.to_deprecated_string(),
1212
        rolename: map(extract_user_info, users),
Miles Steele committed
1213
    }
1214
    return JsonResponse(response_payload)
Miles Steele committed
1215

1216 1217

@ensure_csrf_cookie
1218 1219
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff')
1220
@require_post_params(send_to="sending to whom", subject="subject line", message="message text")
1221 1222 1223
def send_email(request, course_id):
    """
    Send an email to self, staff, or everyone involved in a course.
1224
    Query Parameters:
1225
    - 'send_to' specifies what group the email should be sent to
1226
       Options are defined by the CourseEmail model in
1227
       lms/djangoapps/bulk_email/models.py
1228 1229 1230
    - 'subject' specifies email's subject
    - 'message' specifies email's content
    """
1231
    course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
1232 1233 1234 1235

    if not bulk_email_is_enabled_for_course(course_id):
        return HttpResponseForbidden("Email is not enabled for this course.")

1236 1237 1238
    send_to = request.POST.get("send_to")
    subject = request.POST.get("subject")
    message = request.POST.get("message")
1239 1240 1241 1242 1243 1244 1245 1246 1247

    # Create the CourseEmail object.  This is saved immediately, so that
    # any transaction that has been pending up to this point will also be
    # committed.
    email = CourseEmail.create(course_id, request.user, send_to, subject, message)

    # Submit the task, so that the correct InstructorTask object gets created (for monitoring purposes)
    instructor_task.api.submit_bulk_course_email(request, course_id, email.id)  # pylint: disable=E1101

1248
    response_payload = {
Calen Pennington committed
1249
        'course_id': course_id.to_deprecated_string(),
1250 1251
        'success': True,
    }
1252 1253 1254 1255 1256 1257
    return JsonResponse(response_payload)


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff')
1258
@require_query_params(
1259
    unique_student_identifier="email or username of user to change access",
1260
    rolename="the forum role",
1261
    action="'allow' or 'revoke'",
1262
)
1263
@common_exceptions_400
Miles Steele committed
1264 1265
def update_forum_role_membership(request, course_id):
    """
1266
    Modify user's forum role.
Miles Steele committed
1267

1268 1269 1270 1271 1272
    The requesting user must be at least staff.
    Staff forum admins can access all roles EXCEPT for FORUM_ROLE_ADMINISTRATOR
        which is limited to instructors.
    No one can revoke an instructors FORUM_ROLE_ADMINISTRATOR status.

Miles Steele committed
1273
    Query parameters:
1274 1275 1276
    - `email` is the target users email
    - `rolename` is one of [FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA]
    - `action` is one of ['allow', 'revoke']
Miles Steele committed
1277
    """
1278
    course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
1279
    course = get_course_by_id(course_id)
1280
    has_instructor_access = has_access(request.user, 'instructor', course)
1281 1282 1283 1284
    has_forum_admin = has_forum_access(
        request.user, course_id, FORUM_ROLE_ADMINISTRATOR
    )

1285
    unique_student_identifier = request.GET.get('unique_student_identifier')
1286
    rolename = request.GET.get('rolename')
1287
    action = request.GET.get('action')
Miles Steele committed
1288

1289 1290 1291 1292 1293 1294 1295 1296 1297 1298
    # default roles require either (staff & forum admin) or (instructor)
    if not (has_forum_admin or has_instructor_access):
        return HttpResponseBadRequest(
            "Operation requires staff & forum admin or instructor access"
        )

    # EXCEPT FORUM_ROLE_ADMINISTRATOR requires (instructor)
    if rolename == FORUM_ROLE_ADMINISTRATOR and not has_instructor_access:
        return HttpResponseBadRequest("Operation requires instructor access.")

1299
    if not rolename in [FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA]:
1300 1301 1302
        return HttpResponseBadRequest(strip_tags(
            "Unrecognized rolename '{}'.".format(rolename)
        ))
1303

1304
    user = get_student_from_identifier(unique_student_identifier)
1305
    target_is_instructor = has_access(user, 'instructor', course)
1306 1307
    # cannot revoke instructor
    if target_is_instructor and action == 'revoke' and rolename == FORUM_ROLE_ADMINISTRATOR:
1308
        return HttpResponseBadRequest("Cannot revoke instructor forum admin privileges.")
Miles Steele committed
1309 1310

    try:
1311
        update_forum_role(course_id, user, rolename, action)
1312 1313
    except Role.DoesNotExist:
        return HttpResponseBadRequest("Role does not exist.")
Miles Steele committed
1314 1315

    response_payload = {
1316
        'course_id': course_id.to_deprecated_string(),
1317
        'action': action,
Miles Steele committed
1318
    }
1319
    return JsonResponse(response_payload)
Miles Steele committed
1320

1321

1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff')
@require_query_params(
    aname="name of analytic to query",
)
@common_exceptions_400
def proxy_legacy_analytics(request, course_id):
    """
    Proxies to the analytics cron job server.

    `aname` is a query parameter specifying which analytic to query.
    """
1335
    course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
1336 1337 1338 1339 1340 1341 1342 1343 1344
    analytics_name = request.GET.get('aname')

    # abort if misconfigured
    if not (hasattr(settings, 'ANALYTICS_SERVER_URL') and hasattr(settings, 'ANALYTICS_API_KEY')):
        return HttpResponse("Analytics service not configured.", status=501)

    url = "{}get?aname={}&course_id={}&apikey={}".format(
        settings.ANALYTICS_SERVER_URL,
        analytics_name,
1345
        course_id.to_deprecated_string(),
1346 1347 1348 1349 1350
        settings.ANALYTICS_API_KEY,
    )

    try:
        res = requests.get(url)
1351
    except Exception:  # pylint: disable=broad-except
1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373
        log.exception("Error requesting from analytics server at %s", url)
        return HttpResponse("Error requesting from analytics server.", status=500)

    if res.status_code is 200:
        # return the successful request content
        return HttpResponse(res.content, content_type="application/json")
    elif res.status_code is 404:
        # forward the 404 and content
        return HttpResponse(res.content, content_type="application/json", status=404)
    else:
        # 500 on all other unexpected status codes.
        log.error(
            "Error fetching {}, code: {}, msg: {}".format(
                url, res.status_code, res.content
            )
        )
        return HttpResponse(
            "Error from analytics server ({}).".format(res.status_code),
            status=500
        )


1374 1375 1376 1377 1378 1379
def _display_unit(unit):
    """
    Gets string for displaying unit to user.
    """
    name = getattr(unit, 'display_name', None)
    if name:
1380
        return u'{0} ({1})'.format(name, unit.location.to_deprecated_string())
1381
    else:
1382
        return unit.location.to_deprecated_string()
1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393


@handle_dashboard_error
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff')
@require_query_params('student', 'url', 'due_datetime')
def change_due_date(request, course_id):
    """
    Grants a due date extension to a student for a particular unit.
    """
1394
    course = get_course_by_id(SlashSeparatedCourseKey.from_deprecated_string(course_id))
1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414
    student = get_student_from_identifier(request.GET.get('student'))
    unit = find_unit(course, request.GET.get('url'))
    due_date = parse_datetime(request.GET.get('due_datetime'))
    set_due_date_extension(course, unit, student, due_date)

    return JsonResponse(_(
        'Successfully changed due date for student {0} for {1} '
        'to {2}').format(student.profile.name, _display_unit(unit),
                         due_date.strftime('%Y-%m-%d %H:%M')))


@handle_dashboard_error
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff')
@require_query_params('student', 'url')
def reset_due_date(request, course_id):
    """
    Rescinds a due date extension for a student on a particular unit.
    """
1415
    course = get_course_by_id(SlashSeparatedCourseKey.from_deprecated_string(course_id))
1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434
    student = get_student_from_identifier(request.GET.get('student'))
    unit = find_unit(course, request.GET.get('url'))
    set_due_date_extension(course, unit, student, None)

    return JsonResponse(_(
        'Successfully reset due date for student {0} for {1} '
        'to {2}').format(student.profile.name, _display_unit(unit),
                         unit.due.strftime('%Y-%m-%d %H:%M')))


@handle_dashboard_error
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff')
@require_query_params('url')
def show_unit_extensions(request, course_id):
    """
    Shows all of the students which have due date extensions for the given unit.
    """
1435
    course = get_course_by_id(SlashSeparatedCourseKey.from_deprecated_string(course_id))
1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450
    unit = find_unit(course, request.GET.get('url'))
    return JsonResponse(dump_module_extensions(course, unit))


@handle_dashboard_error
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff')
@require_query_params('student')
def show_student_extensions(request, course_id):
    """
    Shows all of the due date extensions granted to a particular student in a
    particular course.
    """
    student = get_student_from_identifier(request.GET.get('student'))
1451
    course = get_course_by_id(SlashSeparatedCourseKey.from_deprecated_string(course_id))
1452 1453 1454
    return JsonResponse(dump_student_extensions(course, student))


1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471
def _split_input_list(str_list):
    """
    Separate out individual student email from the comma, or space separated string.

    e.g.
    in: "Lorem@ipsum.dolor, sit@amet.consectetur\nadipiscing@elit.Aenean\r convallis@at.lacus\r, ut@lacinia.Sed"
    out: ['Lorem@ipsum.dolor', 'sit@amet.consectetur', 'adipiscing@elit.Aenean', 'convallis@at.lacus', 'ut@lacinia.Sed']

    `str_list` is a string coming from an input text area
    returns a list of separated values
    """

    new_list = re.split(r'[\n\r\s,]', str_list)
    new_list = [s.strip() for s in new_list]
    new_list = [s for s in new_list if s != '']

    return new_list
1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511


#---- Gradebook (shown to small courses only) ----
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff')
def spoc_gradebook(request, course_id):
    """
    Show the gradebook for this course:
    - Only shown for courses with enrollment < settings.FEATURES.get("MAX_ENROLLMENT_INSTR_BUTTONS")
    - Only displayed to course staff
    """
    course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
    course = get_course_with_access(request.user, 'staff', course_key, depth=None)

    enrolled_students = User.objects.filter(
        courseenrollment__course_id=course_key,
        courseenrollment__is_active=1
    ).order_by('username').select_related("profile")

    # possible extension: implement pagination to show to large courses

    student_info = [
        {
            'username': student.username,
            'id': student.id,
            'email': student.email,
            'grade_summary': student_grades(student, request, course),
            'realname': student.profile.name,
        }
        for student in enrolled_students
    ]

    return render_to_response('courseware/gradebook.html', {
        'students': student_info,
        'course': course,
        'course_id': course_key,
        # Checked above
        'staff_access': True,
        'ordered_grades': sorted(course.grade_cutoffs.items(), key=lambda i: i[1], reverse=True),
    })