api.py 39.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
"""

9
import json
10 11
import logging
import re
12 13
import requests
from django.conf import settings
14 15
from django_future.csrf import ensure_csrf_cookie
from django.views.decorators.cache import cache_control
16
from django.core.urlresolvers import reverse
17
from django.utils.translation import ugettext as _
18
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
19
from django.utils.html import strip_tags
20
from util.json_request import JsonResponse
21

22
from courseware.access import has_access
23
from courseware.courses import get_course_with_access, get_course_by_id
24
from django.contrib.auth.models import User
25
from django_comment_client.utils import has_forum_access
Miles Steele committed
26 27 28 29
from django_comment_common.models import (Role,
                                          FORUM_ROLE_ADMINISTRATOR,
                                          FORUM_ROLE_MODERATOR,
                                          FORUM_ROLE_COMMUNITY_TA)
30

31
from courseware.models import StudentModule
32
from student.models import unique_id_for_user
Miles Steele committed
33
import instructor_task.api
34
from instructor_task.api_helper import AlreadyRunningError
35
from instructor_task.views import get_task_completion_info
36
from instructor_task.models import ReportStore
37
import instructor.enrollment as enrollment
38
from instructor.enrollment import enroll_email, unenroll_email, get_email_params
39
from instructor.access import list_with_level, allow_access, revoke_access, update_forum_role
40 41 42
import analytics.basic
import analytics.distributions
import analytics.csvs
43
import csv
44

45 46
from bulk_email.models import CourseEmail

47 48 49 50 51 52 53 54 55 56
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,
)
57
from xmodule.modulestore import Location
58

59 60
log = logging.getLogger(__name__)

61

62
def common_exceptions_400(func):
63 64 65 66
    """
    Catches common exceptions and renders matching 400 errors.
    (decorator without arguments)
    """
67 68 69
    def wrapped(request, *args, **kwargs):  # pylint: disable=C0111
        use_json = (request.is_ajax() or
                    request.META.get("HTTP_ACCEPT", "").startswith("application/json"))
70
        try:
71
            return func(request, *args, **kwargs)
72
        except User.DoesNotExist:
73
            message = _("User does not exist.")
74
            if use_json:
75
                return JsonResponse({"error": message}, 400)
76
            else:
77
                return HttpResponseBadRequest(message)
78
        except AlreadyRunningError:
79
            message = _("Task is already running.")
80
            if use_json:
81
                return JsonResponse({"error": message}, 400)
82
            else:
83
                return HttpResponseBadRequest(message)
84 85 86
    return wrapped


87 88 89
def require_query_params(*args, **kwargs):
    """
    Checks for required paremters or renders a 400 error.
90 91
    (decorator with arguments)

92 93 94 95 96 97 98 99 100
    `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
101 102
    def decorator(func):  # pylint: disable=C0111
        def wrapped(*args, **kwargs):  # pylint: disable=C0111
103 104 105 106 107 108 109 110 111 112 113
            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:
114
                    error_response_data['parameters'].append(param)
115 116 117
                    error_response_data['info'][param] = extra

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

124

125 126
def require_post_params(*args, **kwargs):
    """
127
    Checks for required parameters or renders a 400 error.
128 129
    (decorator with arguments)

130 131
    Functions like 'require_query_params', but checks for
    POST parameters rather than GET parameters.
132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150
    """
    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:
151
                    error_response_data['parameters'].append(param)
152 153 154 155 156 157 158 159 160
                    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

161

162 163 164 165 166 167 168 169 170 171 172
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
173
        if they are not in the staff group.
174 175 176 177
    """
    if level not in ['instructor', 'staff']:
        raise ValueError("unrecognized level '{}'".format(level))

Miles Steele committed
178 179
    def decorator(func):  # pylint: disable=C0111
        def wrapped(*args, **kwargs):  # pylint: disable=C0111
180 181 182 183 184 185 186 187 188 189 190
            request = args[0]
            course = get_course_by_id(kwargs['course_id'])

            if has_access(request.user, course, level):
                return func(*args, **kwargs)
            else:
                return HttpResponseForbidden()
        return wrapped
    return decorator


191 192
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
193
@require_level('staff')
194 195
@require_query_params(action="enroll or unenroll", emails="stringified list of emails")
def students_update_enrollment(request, course_id):
196 197
    """
    Enroll or unenroll students by email.
198
    Requires staff access.
Miles Steele committed
199 200 201 202 203

    Query Parameters:
    - action in ['enroll', 'unenroll']
    - emails is string containing a list of emails separated by anything split_input_list can handle.
    - auto_enroll is a boolean (defaults to false)
204
        If auto_enroll is false, students will be allowed to enroll.
205 206 207 208
        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
209 210 211

    Returns an analog to this JSON structure: {
        "action": "enroll",
212
        "auto_enroll": false,
213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230
        "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
                }
            }
        ]
    }
231
    """
232

233
    action = request.GET.get('action')
234 235
    emails_raw = request.GET.get('emails')
    emails = _split_input_list(emails_raw)
236
    auto_enroll = request.GET.get('auto_enroll') in ['true', 'True', True]
237 238 239 240 241 242
    email_students = request.GET.get('email_students') in ['true', 'True', True]

    email_params = {}
    if email_students:
        course = get_course_by_id(course_id)
        email_params = get_email_params(course, auto_enroll)
243

244 245
    results = []
    for email in emails:
246
        try:
247
            if action == 'enroll':
248
                before, after = enroll_email(course_id, email, auto_enroll, email_students, email_params)
249
            elif action == 'unenroll':
250
                before, after = unenroll_email(course_id, email, email_students, email_params)
251
            else:
252 253 254
                return HttpResponseBadRequest(strip_tags(
                    "Unrecognized action '{}'".format(action)
                ))
255 256

            results.append({
257 258 259
                'email': email,
                'before': before.to_dict(),
                'after': after.to_dict(),
260 261 262
            })
        # catch and log any exceptions
        # so that one error doesn't cause a 500.
David Baumgold committed
263
        except Exception as exc:  # pylint: disable=W0703
264 265 266
            log.exception("Error while #{}ing student")
            log.exception(exc)
            results.append({
267 268
                'email': email,
                'error': True,
269
            })
270 271

    response_payload = {
272 273
        'action': action,
        'results': results,
Miles Steele committed
274
        'auto_enroll': auto_enroll,
275
    }
276
    return JsonResponse(response_payload)
277 278 279 280


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
281
@require_level('instructor')
282
@common_exceptions_400
283 284 285
@require_query_params(
    email="user email",
    rolename="'instructor', 'staff', or 'beta'",
286
    action="'allow' or 'revoke'"
287
)
288
def modify_access(request, course_id):
289
    """
290
    Modify staff/instructor access of other user.
291
    Requires instructor access.
292

293 294
    NOTE: instructors cannot remove their own instructor access.

295 296
    Query parameters:
    email is the target users email
297
    rolename is one of ['instructor', 'staff', 'beta']
298
    action is one of ['allow', 'revoke']
299
    """
300 301 302
    course = get_course_with_access(
        request.user, course_id, 'instructor', depth=None
    )
303

304
    email = strip_if_string(request.GET.get('email'))
Miles Steele committed
305
    rolename = request.GET.get('rolename')
306
    action = request.GET.get('action')
307

308
    if not rolename in ['instructor', 'staff', 'beta']:
309
        return HttpResponseBadRequest(strip_tags(
310
            "unknown rolename '{}'".format(rolename)
311
        ))
312

313 314
    user = User.objects.get(email=email)

315 316 317 318 319 320 321
    # disallow instructors from removing their own instructor access.
    if rolename == 'instructor' and user == request.user and action != 'allow':
        return HttpResponseBadRequest(
            "An instructor cannot remove their own instructor access."
        )

    if action == 'allow':
322
        allow_access(course, user, rolename)
323
    elif action == 'revoke':
324
        revoke_access(course, user, rolename)
325
    else:
326 327 328
        return HttpResponseBadRequest(strip_tags(
            "unrecognized action '{}'".format(action)
        ))
329 330

    response_payload = {
331 332
        'email': email,
        'rolename': rolename,
333
        'action': action,
334
        'success': 'yes',
335
    }
336
    return JsonResponse(response_payload)
337 338 339 340


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
341
@require_level('instructor')
342
@require_query_params(rolename="'instructor', 'staff', or 'beta'")
343
def list_course_role_members(request, course_id):
344 345
    """
    List instructors and staff.
346
    Requires instructor access.
Miles Steele committed
347

348
    rolename is one of ['instructor', 'staff', 'beta']
349 350 351 352 353 354 355 356 357 358 359 360

    Returns JSON of the form {
        "course_id": "some/course/id",
        "staff": [
            {
                "username": "staff1",
                "email": "staff1@example.org",
                "first_name": "Joe",
                "last_name": "Shmoe",
            }
        ]
    }
361
    """
362
    course = get_course_with_access(
363
        request.user, course_id, 'instructor', depth=None
364
    )
365

366
    rolename = request.GET.get('rolename')
Miles Steele committed
367

368
    if not rolename in ['instructor', 'staff', 'beta']:
Miles Steele committed
369 370 371
        return HttpResponseBadRequest()

    def extract_user_info(user):
372
        """ convert user into dicts for json view """
373 374 375 376 377 378 379 380
        return {
            'username': user.username,
            'email': user.email,
            'first_name': user.first_name,
            'last_name': user.last_name,
        }

    response_payload = {
381
        'course_id': course_id,
382
        rolename: map(extract_user_info, list_with_level(
383 384
            course, rolename
        )),
385
    }
386
    return JsonResponse(response_payload)
387 388 389 390


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
391 392
@require_level('staff')
def get_grading_config(request, course_id):
393 394 395
    """
    Respond with json which contains a html formatted grade summary.
    """
396 397 398
    course = get_course_with_access(
        request.user, course_id, 'staff', depth=None
    )
399 400 401 402 403 404
    grading_config_summary = analytics.basic.dump_grading_context(course)

    response_payload = {
        'course_id': course_id,
        'grading_config_summary': grading_config_summary,
    }
405
    return JsonResponse(response_payload)
406 407 408 409


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

415 416
    Responds with JSON
        {"students": [{-student-info-}, ...]}
417

Miles Steele committed
418
    TO DO accept requests for different attribute sets.
419
    """
Miles Steele committed
420
    available_features = analytics.basic.AVAILABLE_FEATURES
421
    query_features = ['username', 'name', 'email', 'language', 'location', 'year_of_birth', 'gender',
422 423
                      'level_of_education', 'mailing_address', 'goals']

424
    student_data = analytics.basic.enrolled_students_features(course_id, query_features)
425 426 427

    if not csv:
        response_payload = {
428 429 430 431
            'course_id': course_id,
            'students': student_data,
            'students_count': len(student_data),
            'queried_features': query_features,
432
            'available_features': available_features,
433
        }
434
        return JsonResponse(response_payload)
435
    else:
436
        header, datarows = analytics.csvs.format_dictlist(student_data, query_features)
437
        return analytics.csvs.create_csv_response("enrolled_profiles.csv", header, datarows)
438 439 440 441


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466
@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
    # centralized into analytics. Currently analytics has similar functionality
    # but not quite what's needed.
    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')
        response['Content-Disposition'] = 'attachment; filename={0}'.format(filename)
        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')
467
    header = ['User ID', 'Anonymized user ID']
468 469 470 471 472 473
    rows = [[s.id, unique_id_for_user(s)] for s in students]
    return csv_response(course_id.replace('/', '-') + '-anon-ids.csv', header, rows)


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
474 475
@require_level('staff')
def get_distribution(request, course_id):
476
    """
477
    Respond with json of the distribution of students over selected features which have choices.
478

479 480 481 482
    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']
483
    """
484 485 486 487 488 489
    feature = request.GET.get('feature')
    # alternate notations of None
    if feature in (None, 'null', ''):
        feature = None
    else:
        feature = str(feature)
490

Miles Steele committed
491
    available_features = analytics.distributions.AVAILABLE_PROFILE_FEATURES
492
    # allow None so that requests for no feature can list available features
Miles Steele committed
493
    if not feature in available_features + (None,):
494
        return HttpResponseBadRequest(strip_tags(
495
            "feature '{}' not available.".format(feature)
496
        ))
497 498

    response_payload = {
499
        'course_id': course_id,
500
        'queried_feature': feature,
Miles Steele committed
501
        'available_features': available_features,
502
        'feature_display_names': analytics.distributions.DISPLAY_NAMES,
503
    }
504 505 506 507 508

    p_dist = None
    if not feature is None:
        p_dist = analytics.distributions.profile_distribution(course_id, feature)
        response_payload['feature_results'] = {
David Baumgold committed
509 510 511 512
            'feature': p_dist.feature,
            'feature_display_name': p_dist.feature_display_name,
            'data': p_dist.data,
            'type': p_dist.type,
513 514 515 516 517
        }

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

518
    return JsonResponse(response_payload)
519 520 521 522


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
523 524
@common_exceptions_400
@require_level('staff')
525
@require_query_params(
526
    unique_student_identifier="email or username of student for whom to get progress url"
527
)
528 529 530 531 532
def get_student_progress_url(request, course_id):
    """
    Get the progress url of a student.
    Limited to staff access.

533
    Takes query paremeter unique_student_identifier and if the student exists
534 535 536 537
    returns e.g. {
        'progress_url': '/../...'
    }
    """
538
    user = get_student_from_identifier(request.GET.get('unique_student_identifier'))
539 540 541 542

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

    response_payload = {
543
        'course_id': course_id,
544 545
        'progress_url': progress_url,
    }
546
    return JsonResponse(response_payload)
547 548 549 550


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
551 552
@require_level('staff')
@require_query_params(
553
    problem_to_reset="problem urlname to reset"
554
)
555
@common_exceptions_400
556 557 558
def reset_student_attempts(request, course_id):
    """

559 560 561 562 563
    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
564
        - problem_to_reset is a urlname of a problem
565
        - unique_student_identifier is an email or username
566 567 568 569 570 571 572
        - 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
573
    """
574 575 576
    course = get_course_with_access(
        request.user, course_id, 'staff', depth=None
    )
577

578
    problem_to_reset = strip_if_string(request.GET.get('problem_to_reset'))
579 580 581 582
    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
583
    all_students = request.GET.get('all_students', False) in ['true', 'True', True]
584
    delete_module = request.GET.get('delete_module', False) in ['true', 'True', True]
Miles Steele committed
585

586
    # parameter combinations
587
    if all_students and student:
588
        return HttpResponseBadRequest(
589
            "all_students and unique_student_identifier are mutually exclusive."
590 591 592 593 594
        )
    if all_students and delete_module:
        return HttpResponseBadRequest(
            "all_students and delete_module are mutually exclusive."
        )
Miles Steele committed
595

596
    # instructor authorization
597 598
    if all_students or delete_module:
        if not has_access(request.user, course, 'instructor'):
599
            return HttpResponseForbidden("Requires instructor access.")
600

601
    module_state_key = _msk_from_problem_urlname(course_id, problem_to_reset)
Miles Steele committed
602 603 604 605

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

606
    if student:
Miles Steele committed
607
        try:
608
            enrollment.reset_student_attempts(course_id, student, module_state_key, delete_module=delete_module)
Miles Steele committed
609
        except StudentModule.DoesNotExist:
610
            return HttpResponseBadRequest("Module does not exist.")
611
        response_payload['student'] = student_identifier
Miles Steele committed
612
    elif all_students:
Miles Steele committed
613
        instructor_task.api.submit_reset_problem_attempts_for_all_students(request, course_id, module_state_key)
Miles Steele committed
614
        response_payload['task'] = 'created'
615
        response_payload['student'] = 'All Students'
Miles Steele committed
616 617 618
    else:
        return HttpResponseBadRequest()

619
    return JsonResponse(response_payload)
Miles Steele committed
620 621 622 623


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
624 625
@require_level('instructor')
@require_query_params(problem_to_reset="problem urlname to reset")
626
@common_exceptions_400
Miles Steele committed
627 628 629
def rescore_problem(request, course_id):
    """
    Starts a background process a students attempts counter. Optionally deletes student state for a problem.
630
    Limited to instructor access.
Miles Steele committed
631 632 633

    Takes either of the following query paremeters
        - problem_to_reset is a urlname of a problem
634
        - unique_student_identifier is an email or username
Miles Steele committed
635 636
        - all_students is a boolean

637
    all_students and unique_student_identifier cannot both be present.
Miles Steele committed
638
    """
639
    problem_to_reset = strip_if_string(request.GET.get('problem_to_reset'))
640 641 642 643
    student_identifier = request.GET.get('unique_student_identifier', None)
    student = None
    if student_identifier is not None:
        student = get_student_from_identifier(student_identifier)
644

645
    all_students = request.GET.get('all_students') in ['true', 'True', True]
646

647
    if not (problem_to_reset and (all_students or student)):
648 649
        return HttpResponseBadRequest("Missing query parameters.")

650
    if all_students and student:
651
        return HttpResponseBadRequest(
652
            "Cannot rescore with all_students and unique_student_identifier."
653
        )
654

655
    module_state_key = _msk_from_problem_urlname(course_id, problem_to_reset)
656

Miles Steele committed
657 658 659
    response_payload = {}
    response_payload['problem_to_reset'] = problem_to_reset

660
    if student:
661
        response_payload['student'] = student_identifier
Miles Steele committed
662
        instructor_task.api.submit_rescore_problem_for_student(request, course_id, module_state_key, student)
Miles Steele committed
663 664
        response_payload['task'] = 'created'
    elif all_students:
Miles Steele committed
665
        instructor_task.api.submit_rescore_problem_for_all_students(request, course_id, module_state_key)
Miles Steele committed
666 667 668 669
        response_payload['task'] = 'created'
    else:
        return HttpResponseBadRequest()

670
    return JsonResponse(response_payload)
Miles Steele committed
671 672


673 674 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
def extract_task_features(task):
    """
    Convert task to dict for json rendering.
    Expects tasks have the following features:
    * task_type (str, type of task)
    * task_input (dict, input(s) to the task)
    * task_id (str, celery id of the task)
    * requester (str, username who submitted the task)
    * task_state (str, state of task eg PROGRESS, COMPLETED)
    * created (datetime, when the task was completed)
    * task_output (optional)
    """
    # Pull out information from the task
    features = ['task_type', 'task_input', 'task_id', 'requester', 'task_state']
    task_feature_dict = {feature: str(getattr(task, feature)) for feature in features}
    # Some information (created, duration, status, task message) require additional formatting
    task_feature_dict['created'] = task.created.isoformat()

    # Get duration info, if known
    duration_sec = 'unknown'
    if hasattr(task, 'task_output') and task.task_output is not None:
        try:
            task_output = json.loads(task.task_output)
        except ValueError:
            log.error("Could not parse task output as valid json; task output: %s", task.task_output)
        else:
            if 'duration_ms' in task_output:
                duration_sec = int(task_output['duration_ms'] / 1000.0)
    task_feature_dict['duration_sec'] = duration_sec

    # Get progress status message & success information
    success, task_message = get_task_completion_info(task)
    status = _("Complete") if success else _("Incomplete")
    task_feature_dict['status'] = status
    task_feature_dict['task_message'] = task_message

    return task_feature_dict


Miles Steele committed
712 713
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731
@require_level('staff')
def list_background_email_tasks(request, course_id):  # pylint: disable=unused-argument
    """
    List background email tasks.
    """
    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')
Miles Steele committed
732 733 734 735
def list_instructor_tasks(request, course_id):
    """
    List instructor tasks.

736 737 738
    Takes optional query paremeters.
        - With no arguments, lists running tasks.
        - `problem_urlname` lists task history for problem
739
        - `problem_urlname` and `unique_student_identifier` lists task
740
            history for problem AND student (intersection)
Miles Steele committed
741
    """
742
    problem_urlname = strip_if_string(request.GET.get('problem_urlname', False))
743 744 745
    student = request.GET.get('unique_student_identifier', None)
    if student is not None:
        student = get_student_from_identifier(student)
Miles Steele committed
746

747
    if student and not problem_urlname:
748
        return HttpResponseBadRequest(
749
            "unique_student_identifier must accompany problem_urlname"
750
        )
751

Miles Steele committed
752
    if problem_urlname:
753
        module_state_key = _msk_from_problem_urlname(course_id, problem_urlname)
754
        if student:
755
            # Specifying for a single student's history on this problem
Miles Steele committed
756 757
            tasks = instructor_task.api.get_instructor_task_history(course_id, module_state_key, student)
        else:
758
            # Specifying for single problem's history
Miles Steele committed
759 760
            tasks = instructor_task.api.get_instructor_task_history(course_id, module_state_key)
    else:
761
        # If no problem or student, just get currently running tasks
Miles Steele committed
762 763
        tasks = instructor_task.api.get_running_instructor_tasks(course_id)

764
    response_payload = {
Miles Steele committed
765
        'tasks': map(extract_task_features, tasks),
766
    }
767
    return JsonResponse(response_payload)
Miles Steele committed
768 769 770 771


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
772
@require_level('staff')
773
def list_report_downloads(_request, course_id):
774 775 776
    """
    List grade CSV files that are available for download for this course.
    """
777
    report_store = ReportStore.from_config()
778 779

    response_payload = {
780
        'downloads': [
781
            dict(name=name, url=url, link='<a href="{}">{}</a>'.format(url, name))
782
            for name, url in report_store.links_for(course_id)
783 784 785 786 787 788 789 790 791 792 793 794 795 796
        ]
    }
    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.
    """
    try:
        instructor_task.api.submit_calculate_grades_csv(request, course_id)
797 798
        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})
799
    except AlreadyRunningError:
800
        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.")
801
        return JsonResponse({
802
            "status": already_running_status
803 804 805 806 807 808
        })


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff')
809
@require_query_params('rolename')
Miles Steele committed
810 811
def list_forum_members(request, course_id):
    """
812
    Lists forum members of a certain rolename.
Miles Steele committed
813 814
    Limited to staff access.

815 816 817 818 819
    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
820
    """
821 822 823 824 825 826
    course = get_course_by_id(course_id)
    has_instructor_access = has_access(request.user, course, 'instructor')
    has_forum_admin = has_forum_access(
        request.user, course_id, FORUM_ROLE_ADMINISTRATOR
    )

827
    rolename = request.GET.get('rolename')
Miles Steele committed
828

829 830 831 832 833 834 835 836 837 838 839
    # 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
840
    if not rolename in [FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA]:
841 842 843
        return HttpResponseBadRequest(strip_tags(
            "Unrecognized rolename '{}'.".format(rolename)
        ))
Miles Steele committed
844 845 846 847 848 849 850 851

    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):
852
        """ Convert user to dict for json rendering. """
Miles Steele committed
853 854 855 856 857 858 859 860 861
        return {
            'username': user.username,
            'email': user.email,
            'first_name': user.first_name,
            'last_name': user.last_name,
        }

    response_payload = {
        'course_id': course_id,
862
        rolename: map(extract_user_info, users),
Miles Steele committed
863
    }
864
    return JsonResponse(response_payload)
Miles Steele committed
865

866 867

@ensure_csrf_cookie
868 869
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff')
870
@require_post_params(send_to="sending to whom", subject="subject line", message="message text")
871 872 873
def send_email(request, course_id):
    """
    Send an email to self, staff, or everyone involved in a course.
874
    Query Parameters:
875
    - 'send_to' specifies what group the email should be sent to
876
       Options are defined by the CourseEmail model in
877
       lms/djangoapps/bulk_email/models.py
878 879 880
    - 'subject' specifies email's subject
    - 'message' specifies email's content
    """
881 882 883
    send_to = request.POST.get("send_to")
    subject = request.POST.get("subject")
    message = request.POST.get("message")
884 885 886 887 888 889 890 891 892

    # 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

893
    response_payload = {'course_id': course_id}
894 895 896 897 898 899
    return JsonResponse(response_payload)


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff')
900 901 902
@require_query_params(
    email="the target users email",
    rolename="the forum role",
903
    action="'allow' or 'revoke'",
904
)
905
@common_exceptions_400
Miles Steele committed
906 907
def update_forum_role_membership(request, course_id):
    """
908
    Modify user's forum role.
Miles Steele committed
909

910 911 912 913 914
    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
915
    Query parameters:
916 917 918
    - `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
919
    """
920 921 922 923 924 925
    course = get_course_by_id(course_id)
    has_instructor_access = has_access(request.user, course, 'instructor')
    has_forum_admin = has_forum_access(
        request.user, course_id, FORUM_ROLE_ADMINISTRATOR
    )

926
    email = strip_if_string(request.GET.get('email'))
927
    rolename = request.GET.get('rolename')
928
    action = request.GET.get('action')
Miles Steele committed
929

930 931 932 933 934 935 936 937 938 939
    # 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.")

940
    if not rolename in [FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA]:
941 942 943
        return HttpResponseBadRequest(strip_tags(
            "Unrecognized rolename '{}'.".format(rolename)
        ))
944 945 946 947 948 949

    user = User.objects.get(email=email)
    target_is_instructor = has_access(user, course, 'instructor')
    # cannot revoke instructor
    if target_is_instructor and action == 'revoke' and rolename == FORUM_ROLE_ADMINISTRATOR:
        return HttpResponseBadRequest("Cannot revoke instructor forum admin privelages.")
Miles Steele committed
950 951

    try:
952
        update_forum_role(course_id, user, rolename, action)
953 954
    except Role.DoesNotExist:
        return HttpResponseBadRequest("Role does not exist.")
Miles Steele committed
955 956 957

    response_payload = {
        'course_id': course_id,
958
        'action': action,
Miles Steele committed
959
    }
960
    return JsonResponse(response_payload)
Miles Steele committed
961

962

963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990
@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.
    """
    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,
        course_id,
        settings.ANALYTICS_API_KEY,
    )

    try:
        res = requests.get(url)
991
    except Exception:  # pylint: disable=broad-except
992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013
        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
        )


1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094
def _display_unit(unit):
    """
    Gets string for displaying unit to user.
    """
    name = getattr(unit, 'display_name', None)
    if name:
        return u'{0} ({1})'.format(name, unit.location.url())
    else:
        return unit.location.url()


@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.
    """
    course = get_course_by_id(course_id)
    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.
    """
    course = get_course_by_id(course_id)
    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.
    """
    course = get_course_by_id(course_id)
    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'))
    course = get_course_by_id(course_id)
    return JsonResponse(dump_student_extensions(course, student))


1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115
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


def _msk_from_problem_urlname(course_id, urlname):
    """
1116
    Convert a 'problem urlname' (name that instructor's input into dashboard)
1117 1118
    to a module state key (db field)
    """
1119
    if urlname.endswith(".xml"):
Miles Steele committed
1120 1121
        urlname = urlname[:-4]

1122 1123 1124 1125 1126
    # Combined open ended problems also have state that can be deleted.  However,
    # appending "problem" will only allow capa problems to be reset.
    # Get around this for combinedopenended problems.
    if "combinedopenended" not in urlname:
        urlname = "problem/" + urlname
Miles Steele committed
1127

1128 1129 1130
    parts = Location.parse_course_id(course_id)
    parts['urlname'] = urlname
    module_state_key = u"i4x://{org}/{course}/{urlname}".format(**parts)
Miles Steele committed
1131
    return module_state_key