legacy.py 49.9 KB
Newer Older
1 2 3
"""
Instructor Views
"""
4 5
## NOTE: This is the code for the legacy instructor dashboard
## We are no longer supporting this file or accepting changes into it.
6
from contextlib import contextmanager
7
import csv
8
import json
9
import logging
10
import os
Victor Shnayder committed
11
import re
12
import requests
13
import urllib
14

Calen Pennington committed
15 16 17
from collections import defaultdict, OrderedDict
from markupsafe import escape
from requests.status_codes import codes
18
from StringIO import StringIO
19 20

from django.conf import settings
21
from django.contrib.auth.models import User
22
from django.http import HttpResponse
23 24
from django_future.csrf import ensure_csrf_cookie
from django.views.decorators.cache import cache_control
25
from django.core.urlresolvers import reverse
26
from django.core.mail import send_mail
27
from django.utils import timezone
28

29
import xmodule.graders as xmgraders
30
from xmodule.modulestore import ModuleStoreEnum
31
from xmodule.modulestore.django import modulestore
32
from opaque_keys.edx.locations import SlashSeparatedCourseKey
33

34
from courseware import grades
35
from courseware.access import has_access
36
from courseware.courses import get_course_with_access, get_cms_course_link
37
from courseware.models import StudentModule
38
from django_comment_common.models import FORUM_ROLE_ADMINISTRATOR
39
from django_comment_client.utils import has_forum_access
40
from instructor.offline_gradecalc import student_grades, offline_grades_available
41
from instructor.views.tools import strip_if_string, bulk_email_is_enabled_for_course, add_block_ids
42 43 44 45
from instructor_task.api import (
    get_running_instructor_tasks,
    get_instructor_task_history,
)
46
from instructor_task.views import get_task_completion_info
David Baumgold committed
47
from edxmako.shortcuts import render_to_response, render_to_string
48
from class_dashboard import dashboard_data
49
from psychometrics import psychoanalyze
50 51 52 53
from student.models import (
    CourseEnrollment,
    CourseEnrollmentAllowed,
)
54
import track.views
55
from django.utils.translation import ugettext as _
56

57
from microsite_configuration import microsite
58
from opaque_keys.edx.locations import i4xEncoder
59 60
from openedx.core.djangoapps.course_groups.cohorts import is_course_cohorted

61

62
log = logging.getLogger(__name__)
63

Brian Wilson committed
64
# internal commands for managing forum roles:
65 66
FORUM_ROLE_ADD = 'add'
FORUM_ROLE_REMOVE = 'remove'
ichuang committed
67

68 69 70
# For determining if a shibboleth course
SHIBBOLETH_DOMAIN_PREFIX = 'shib:'

Calen Pennington committed
71

72
def split_by_comma_and_whitespace(a_str):
73
    """
74
    Return string a_str, split by , or whitespace
75
    """
76
    return re.split(r'[\s,]', a_str)
77

Calen Pennington committed
78

79 80 81 82
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
def instructor_dashboard(request, course_id):
    """Display the instructor dashboard for a course."""
83 84
    course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
    course = get_course_with_access(request.user, 'staff', course_key, depth=None)
85

86
    instructor_access = has_access(request.user, 'instructor', course)   # an instructor can manage staff lists
87

88
    forum_admin_access = has_forum_access(request.user, course_key, FORUM_ROLE_ADMINISTRATOR)
Brian Wilson committed
89

90
    msg = ''
91
    show_email_tab = False
92 93
    problems = []
    plots = []
94
    datatable = {}
95

96 97
    # the instructor dashboard page is modal: grades, psychometrics, admin
    # keep that state in request.session (defaults to grades mode)
Calen Pennington committed
98
    idash_mode = request.POST.get('idash_mode', '')
99
    idash_mode_key = u'idash_mode:{0}'.format(course_id)
100
    if idash_mode:
101
        request.session[idash_mode_key] = idash_mode
102
    else:
103
        idash_mode = request.session.get(idash_mode_key, 'Grades')
104

105
    enrollment_number = CourseEnrollment.num_enrolled_in(course_key)
106

107
    # assemble some course statistics for output to instructor
108
    def get_course_stats_table():
109 110
        datatable = {
            'header': ['Statistic', 'Value'],
111
            'title': _('Course Statistics At A Glance'),
112
        }
113 114

        data = [['Date', timezone.now().isoformat()]]
115 116
        data += compute_course_stats(course).items()
        if request.user.is_staff:
Calen Pennington committed
117
            for field in course.fields.values():
118
                if getattr(field.scope, 'user', False):
119 120
                    continue

121 122 123 124
                data.append([
                    field.name,
                    json.dumps(field.read_json(course), cls=i4xEncoder)
                ])
125 126
        datatable['data'] = data
        return datatable
127

128
    def return_csv(func, datatable, file_pointer=None):
129
        """Outputs a CSV file from the contents of a datatable."""
130
        if file_pointer is None:
131
            response = HttpResponse(mimetype='text/csv')
Abdallah committed
132
            response['Content-Disposition'] = (u'attachment; filename={0}'.format(func)).encode('utf-8')
133
        else:
134
            response = file_pointer
ichuang committed
135
        writer = csv.writer(response, dialect='excel', quotechar='"', quoting=csv.QUOTE_ALL)
136 137
        encoded_row = [unicode(s).encode('utf-8') for s in datatable['header']]
        writer.writerow(encoded_row)
138
        for datarow in datatable['data']:
139
            # 's' here may be an integer, float (eg score) or string (eg student name)
140 141 142 143 144 145 146 147
            encoded_row = [
                # If s is already a UTF-8 string, trying to make a unicode
                # object out of it will fail unless we pass in an encoding to
                # the constructor. But we can't do that across the board,
                # because s is often a numeric type. So just do this.
                s if isinstance(s, str) else unicode(s).encode('utf-8')
                for s in datarow
            ]
148
            writer.writerow(encoded_row)
149 150 151
        return response

    # process actions from form POST
ichuang committed
152
    action = request.POST.get('action', '')
Calen Pennington committed
153
    use_offline = request.POST.get('use_offline_grades', False)
154

155
    if settings.FEATURES['ENABLE_MANUAL_GIT_RELOAD']:
156
        if 'GIT pull' in action:
Ned Batchelder committed
157
            data_dir = course.data_dir
158
            log.debug('git pull {0}'.format(data_dir))
159 160
            gdir = settings.DATA_DIR / data_dir
            if not os.path.exists(gdir):
161
                msg += "====> ERROR in gitreload - no such directory {0}".format(gdir)
162
            else:
163 164 165
                cmd = "cd {0}; git reset --hard HEAD; git clean -f -d; git pull origin; chmod g+w course.xml".format(gdir)
                msg += "git pull on {0}:<p>".format(data_dir)
                msg += "<pre>{0}</pre></p>".format(escape(os.popen(cmd).read()))
166
                track.views.server_track(request, "git-pull", {"directory": data_dir}, page="idashboard")
167 168

        if 'Reload course' in action:
169
            log.debug('reloading {0} ({1})'.format(course_key, course))
170
            try:
Ned Batchelder committed
171
                data_dir = course.data_dir
172
                modulestore().try_load_course(data_dir)
173
                msg += "<br/><p>Course reloaded from {0}</p>".format(data_dir)
174
                track.views.server_track(request, "reload", {"directory": data_dir}, page="idashboard")
175
                course_errors = modulestore().get_course_errors(course.id)
176 177
                msg += '<ul>'
                for cmsg, cerr in course_errors:
Calen Pennington committed
178
                    msg += "<li>{0}: <pre>{1}</pre>".format(cmsg, escape(cerr))
179
                msg += '</ul>'
180
            except Exception as err:  # pylint: disable=broad-except
181
                msg += '<br/><p>Error: {0}</p>'.format(escape(err))
182

Calen Pennington committed
183
    if action == 'Dump list of enrolled students' or action == 'List enrolled students':
184
        log.debug(action)
185
        datatable = get_student_grade_summary_data(request, course, get_grades=False, use_offline=use_offline)
186
        datatable['title'] = _('List of students enrolled in {course_key}').format(course_key=course_key.to_deprecated_string())
187
        track.views.server_track(request, "list-students", {}, page="idashboard")
188 189 190

    elif 'Dump all RAW grades' in action:
        log.debug(action)
191
        datatable = get_student_grade_summary_data(request, course, get_grades=True,
192
                                                   get_raw_scores=True, use_offline=use_offline)
193
        datatable['title'] = _('Raw Grades of students enrolled in {course_key}').format(course_key=course_key)
194
        track.views.server_track(request, "dump-grades-raw", {}, page="idashboard")
195 196

    elif 'Download CSV of all RAW grades' in action:
197
        track.views.server_track(request, "dump-grades-csv-raw", {}, page="idashboard")
198
        return return_csv('grades_{0}_raw.csv'.format(course_key.to_deprecated_string()),
199
                          get_student_grade_summary_data(request, course, get_raw_scores=True, use_offline=use_offline))
200

201
    elif 'Download CSV of answer distributions' in action:
202
        track.views.server_track(request, "dump-answer-dist-csv", {}, page="idashboard")
203
        return return_csv('answer_dist_{0}.csv'.format(course_key.to_deprecated_string()), get_answers_distribution(request, course_key))
204

205
    #----------------------------------------
206 207
    # export grades to remote gradebook

Calen Pennington committed
208
    elif action == 'List assignments available in remote gradebook':
209 210 211
        msg2, datatable = _do_remote_gradebook(request.user, course, 'get-assignments')
        msg += msg2

Calen Pennington committed
212
    elif action == 'List assignments available for this course':
213
        log.debug(action)
214
        allgrades = get_student_grade_summary_data(request, course, get_grades=True, use_offline=use_offline)
215 216

        assignments = [[x] for x in allgrades['assignments']]
217
        datatable = {'header': [_('Assignment Name')]}
218 219 220 221 222
        datatable['data'] = assignments
        datatable['title'] = action

        msg += 'assignments=<pre>%s</pre>' % assignments

Calen Pennington committed
223
    elif action == 'List enrolled students matching remote gradebook':
224
        stud_data = get_student_grade_summary_data(request, course, get_grades=False, use_offline=use_offline)
225 226
        msg2, rg_stud_data = _do_remote_gradebook(request.user, course, 'get-membership')
        datatable = {'header': ['Student  email', 'Match?']}
Calen Pennington committed
227
        rg_students = [x['email'] for x in rg_stud_data['retdata']]
228

229 230 231
        def domatch(student):
            """Returns 'yes' if student is pressent in the remote gradebook student list, else returns 'No'"""
            return 'yes' if student.email in rg_students else 'No'
232 233 234 235 236 237 238 239
        datatable['data'] = [[x.email, domatch(x)] for x in stud_data['students']]
        datatable['title'] = action

    elif action in ['Display grades for assignment', 'Export grades for assignment to remote gradebook',
                    'Export CSV file of grades for assignment']:

        log.debug(action)
        datatable = {}
Calen Pennington committed
240
        aname = request.POST.get('assignment_name', '')
241
        if not aname:
242
            msg += "<font color='red'>{text}</font>".format(text=_("Please enter an assignment name"))
243
        else:
244
            allgrades = get_student_grade_summary_data(request, course, get_grades=True, use_offline=use_offline)
245
            if aname not in allgrades['assignments']:
David Baumgold committed
246
                msg += "<font color='red'>{text}</font>".format(
247
                    text=_("Invalid assignment name '{name}'").format(name=aname)
David Baumgold committed
248
                )
249 250
            else:
                aidx = allgrades['assignments'].index(aname)
251
                datatable = {'header': [_('External email'), aname]}
252
                ddata = []
253
                for student in allgrades['students']:  # do one by one in case there is a student who has only partial grades
254
                    try:
255
                        ddata.append([student.email, student.grades[aidx]])
256
                    except IndexError:
257 258 259 260 261
                        log.debug(u'No grade for assignment %(idx)s (%(name)s) for student %(email)s', {
                            "idx": aidx,
                            "name": aname,
                            "email": student.email,
                        })
262
                datatable['data'] = ddata
263

264
                datatable['title'] = _('Grades for assignment "{name}"').format(name=aname)
265 266 267

                if 'Export CSV' in action:
                    # generate and return CSV file
David Baumgold committed
268
                    return return_csv('grades {name}.csv'.format(name=aname), datatable)
269 270

                elif 'remote gradebook' in action:
271 272 273 274
                    file_pointer = StringIO()
                    return_csv('', datatable, file_pointer=file_pointer)
                    file_pointer.seek(0)
                    files = {'datafile': file_pointer}
275
                    msg2, __ = _do_remote_gradebook(request.user, course, 'post-grades', files=files)
276 277
                    msg += msg2

278
    #----------------------------------------
279 280 281
    # DataDump

    elif 'Download CSV of all responses to problem' in action:
282
        problem_to_dump = request.POST.get('problem_to_dump', '')
283

284 285
        if problem_to_dump[-4:] == ".xml":
            problem_to_dump = problem_to_dump[:-4]
286
        try:
287
            module_state_key = course_key.make_usage_key_from_deprecated_string(problem_to_dump)
288
            smdat = StudentModule.objects.filter(
289
                course_id=course_key,
290 291
                module_state_key=module_state_key
            )
292
            smdat = smdat.order_by('student')
293
            msg += _("Found {num} records to dump.").format(num=smdat)
294
        except Exception as err:  # pylint: disable=broad-except
David Baumgold committed
295
            msg += "<font color='red'>{text}</font><pre>{err}</pre>".format(
296
                text=_("Couldn't find module with that urlname."),
David Baumgold committed
297 298
                err=escape(err)
            )
299
            smdat = []
300

301 302
        if smdat:
            datatable = {'header': ['username', 'state']}
303
            datatable['data'] = [[x.student.username, x.state] for x in smdat]
304 305
            datatable['title'] = _('Student state for problem {problem}').format(problem=problem_to_dump)
            return return_csv('student_state_from_{problem}.csv'.format(problem=problem_to_dump), datatable)
306 307

    #----------------------------------------
308 309 310
    # enrollment

    elif action == 'List students who may enroll but may not have yet signed up':
311
        ceaset = CourseEnrollmentAllowed.objects.filter(course_id=course_key)
312 313 314 315
        datatable = {'header': ['StudentEmail']}
        datatable['data'] = [[x.email] for x in ceaset]
        datatable['title'] = action

316
    elif action == 'Enroll multiple students':
317

318
        is_shib_course = uses_shib(course)
319
        students = request.POST.get('multiple_students', '')
320
        auto_enroll = bool(request.POST.get('auto_enroll'))
321
        email_students = bool(request.POST.get('email_students'))
322 323
        secure = request.is_secure()
        ret = _do_enroll_students(course, course_key, students, secure=secure, auto_enroll=auto_enroll, email_students=email_students, is_shib_course=is_shib_course)
324
        datatable = ret['datatable']
325

326
    elif action == 'Unenroll multiple students':
327

328
        students = request.POST.get('multiple_students', '')
329
        email_students = bool(request.POST.get('email_students'))
330
        ret = _do_unenroll_students(course_key, students, email_students=email_students)
331 332 333 334
        datatable = ret['datatable']

    elif action == 'List sections available in remote gradebook':

335
        msg2, datatable = _do_remote_gradebook(request.user, course, 'get-sections')
336 337
        msg += msg2

338
    elif action in ['List students in section in remote gradebook',
339 340 341
                    'Overload enrollment list using remote gradebook',
                    'Merge enrollment list with remote gradebook']:

Calen Pennington committed
342 343
        section = request.POST.get('gradebook_section', '')
        msg2, datatable = _do_remote_gradebook(request.user, course, 'get-membership', dict(section=section))
344 345
        msg += msg2

David Baumgold committed
346
        if 'List' not in action:
347 348
            students = ','.join([x['email'] for x in datatable['retdata']])
            overload = 'Overload' in action
349 350
            secure = request.is_secure()
            ret = _do_enroll_students(course, course_key, students, secure=secure, overload=overload)
351
            datatable = ret['datatable']
352

353
    #----------------------------------------
354 355 356 357 358 359
    # psychometrics

    elif action == 'Generate Histogram and IRT Plot':
        problem = request.POST['Problem']
        nmsg, plots = psychoanalyze.generate_plots_for_problem(problem)
        msg += nmsg
360
        track.views.server_track(request, "psychometrics-histogram-generation", {"problem": unicode(problem)}, page="idashboard")
361

Calen Pennington committed
362
    if idash_mode == 'Psychometrics':
363
        problems = psychoanalyze.problems_with_psychometric_data(course_key)
364

365 366
    #----------------------------------------
    # analytics
367
    def get_analytics_result(analytics_name):
368
        """Return data for an Analytic piece, or None if it doesn't exist. It
369 370
        logs and swallows errors.
        """
371
        url = settings.ANALYTICS_SERVER_URL + \
372
            u"get?aname={}&course_id={}&apikey={}".format(
373
                analytics_name, urllib.quote(unicode(course_key)), settings.ANALYTICS_API_KEY
374
            )
375 376
        try:
            res = requests.get(url)
377
        except Exception:  # pylint: disable=broad-except
378 379 380
            log.exception("Error trying to access analytics at %s", url)
            return None

381 382
        if res.status_code == codes.OK:
            # WARNING: do not use req.json because the preloaded json doesn't
383
            # preserve the order of the original record (hence OrderedDict).
384 385 386
            payload = json.loads(res.content, object_pairs_hook=OrderedDict)
            add_block_ids(payload)
            return payload
387 388
        else:
            log.error("Error fetching %s, code: %s, msg: %s",
389
                      url, res.status_code, res.content)
390
        return None
391

392
    analytics_results = {}
393

394
    if idash_mode == 'Analytics':
395
        dashboard_analytics = [
396 397 398 399 400 401
            # "StudentsAttemptedProblems",  # num students who tried given problem
            "StudentsDailyActivity",  # active students by day
            "StudentsDropoffPerDay",  # active students dropoff by day
            # "OverallGradeDistribution",  # overall point distribution for course
            # "StudentsPerProblemCorrect",  # foreach problem, num students correct
            "ProblemGradeDistribution",  # foreach problem, grade distribution
402
        ]
403

404
        for analytic_name in dashboard_analytics:
405
            analytics_results[analytic_name] = get_analytics_result(analytic_name)
406

407
    #----------------------------------------
408 409 410 411
    # Metrics

    metrics_results = {}
    if settings.FEATURES.get('CLASS_DASHBOARD') and idash_mode == 'Metrics':
412 413
        metrics_results['section_display_name'] = dashboard_data.get_section_display_name(course_key)
        metrics_results['section_has_problem'] = dashboard_data.get_array_section_has_problem(course_key)
414 415

    #----------------------------------------
416
    # offline grades?
417

418
    if use_offline:
David Baumgold committed
419
        msg += "<br/><font color='orange'>{text}</font>".format(
420
            text=_("Grades from {course_id}").format(
421
                course_id=offline_grades_available(course_key)
David Baumgold committed
422 423
            )
        )
424

425
    # generate list of pending background tasks
426
    if settings.FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS'):
427
        instructor_tasks = get_running_instructor_tasks(course_key)
428
    else:
429
        instructor_tasks = None
430

431
    # determine if this is a studio-backed course so we can provide a link to edit this course in studio
432
    is_studio_course = modulestore().get_modulestore_type(course_key) != ModuleStoreEnum.Type.xml
433 434
    studio_url = None
    if is_studio_course:
435
        studio_url = get_cms_course_link(course)
436

437
    if bulk_email_is_enabled_for_course(course_key):
438
        show_email_tab = True
439

440
    # display course stats only if there is no other table to display:
441
    course_stats = None
442
    if not datatable:
443
        course_stats = get_course_stats_table()
444

445 446
    # disable buttons for large courses
    disable_buttons = False
447
    max_enrollment_for_buttons = settings.FEATURES.get("MAX_ENROLLMENT_INSTR_BUTTONS")
448 449 450
    if max_enrollment_for_buttons is not None:
        disable_buttons = enrollment_number > max_enrollment_for_buttons

451
    #----------------------------------------
452
    # context for rendering
453

454 455
    context = {
        'course': course,
456
        'course_is_cohorted': is_course_cohorted(course.id),
457 458 459 460 461 462 463 464 465 466 467 468
        'staff_access': True,
        'admin_access': request.user.is_staff,
        'instructor_access': instructor_access,
        'forum_admin_access': forum_admin_access,
        'datatable': datatable,
        'course_stats': course_stats,
        'msg': msg,
        'modeflag': {idash_mode: 'selectedmode'},
        'studio_url': studio_url,

        'show_email_tab': show_email_tab,  # email

469 470 471
        'problems': problems,  # psychometrics
        'plots': plots,  # psychometrics
        'course_errors': modulestore().get_course_errors(course.id),
472
        'instructor_tasks': instructor_tasks,
473
        'offline_grade_log': offline_grades_available(course_key),
474 475

        'analytics_results': analytics_results,
476 477
        'disable_buttons': disable_buttons,
        'metrics_results': metrics_results,
478
    }
479

480
    context['standard_dashboard_url'] = reverse('instructor_dashboard', kwargs={'course_id': course_key.to_deprecated_string()})
481

482
    return render_to_response('courseware/legacy_instructor_dashboard.html', context)
483

484

485
def _do_remote_gradebook(user, course, action, args=None, files=None):
486 487 488
    '''
    Perform remote gradebook action.  Returns msg, datatable.
    '''
489 490
    rgb = course.remote_gradebook
    if not rgb:
491
        msg = _("No remote gradebook defined in course metadata")
492
        return msg, {}
493

494 495
    rgburl = settings.FEATURES.get('REMOTE_GRADEBOOK_URL', '')
    if not rgburl:
496
        msg = _("No remote gradebook url defined in settings.FEATURES")
497
        return msg, {}
498

499 500
    rgbname = rgb.get('name', '')
    if not rgbname:
501
        msg = _("No gradebook name defined in course remote_gradebook metadata")
502
        return msg, {}
503

504 505
    if args is None:
        args = {}
506
    data = dict(submit=action, gradebook=rgbname, user=user.email)
507 508 509
    data.update(args)

    try:
510
        resp = requests.post(rgburl, data=data, verify=False, files=files)
511
        retdict = json.loads(resp.content)
512
    except Exception as err:  # pylint: disable=broad-except
513
        msg = _("Failed to communicate with gradebook server at {url}").format(url=rgburl) + "<br/>"
514 515 516
        msg += _("Error: {err}").format(err=err)
        msg += "<br/>resp={resp}".format(resp=resp.content)
        msg += "<br/>data={data}".format(data=data)
517 518
        return msg, {}

519 520
    msg = '<pre>{msg}</pre>'.format(msg=retdict['msg'].replace('\n', '<br/>'))
    retdata = retdict['data']  # a list of dicts
521 522 523 524

    if retdata:
        datatable = {'header': retdata[0].keys()}
        datatable['data'] = [x.values() for x in retdata]
525
        datatable['title'] = _('Remote gradebook response for {action}').format(action=action)
526 527 528 529 530 531
        datatable['retdata'] = retdata
    else:
        datatable = {}

    return msg, datatable

532

533
def _role_members_table(role, title, course_key):
534 535 536 537
    """
    Return a data table of usernames and names of users in group_name.

    Arguments:
538
        role -- a student.roles.AccessRole
539 540 541 542 543 544 545 546
        title -- a descriptive title to show the user

    Returns:
        a dictionary with keys
        'header': ['Username', 'Full name'],
        'data': [[username, name] for all users]
        'title': "{title} in course {course}"
    """
547
    uset = role.users_with_role()
548
    datatable = {'header': [_('Username'), _('Full name')]}
549
    datatable['data'] = [[x.username, x.profile.name] for x in uset]
550
    datatable['title'] = _('{title} in course {course_key}').format(title=title, course_key=course_key.to_deprecated_string())
551 552
    return datatable

553

554
def _user_from_name_or_email(username_or_email):
555
    """
556 557 558 559
    Return the `django.contrib.auth.User` with the supplied username or email.

    If `username_or_email` contains an `@` it is treated as an email, otherwise
    it is treated as the username
560
    """
561
    username_or_email = strip_if_string(username_or_email)
562

563 564 565 566
    if '@' in username_or_email:
        return User.objects.get(email=username_or_email)
    else:
        return User.objects.get(username=username_or_email)
567

568

569
def add_user_to_role(request, username_or_email, role, group_title, event_name):
570 571 572 573 574 575
    """
    Look up the given user by username (if no '@') or email (otherwise), and add them to group.

    Arguments:
       request: django request--used for tracking log
       username_or_email: who to add.  Decide if it's an email by presense of an '@'
576
       group: A group name
577 578 579 580 581 582
       group_title: what to call this group in messages to user--e.g. "beta-testers".
       event_name: what to call this event when logging to tracking logs.

    Returns:
       html to insert in the message field
    """
583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606
    username_or_email = strip_if_string(username_or_email)
    try:
        user = _user_from_name_or_email(username_or_email)
    except User.DoesNotExist:
        return u'<font color="red">Error: unknown username or email "{0}"</font>'.format(username_or_email)

    role.add_users(user)

    # Deal with historical event names
    if event_name in ('staff', 'beta-tester'):
        track.views.server_track(
            request,
            "add-or-remove-user-group",
            {
                "event_name": event_name,
                "user": unicode(user),
                "event": "add"
            },
            page="idashboard"
        )
    else:
        track.views.server_track(request, "add-instructor", {"instructor": unicode(user)}, page="idashboard")

    return '<font color="green">Added {0} to {1}</font>'.format(user, group_title)
607

Calen Pennington committed
608

609
def remove_user_from_role(request, username_or_email, role, group_title, event_name):
610
    """
611
    Look up the given user by username (if no '@') or email (otherwise), and remove them from the supplied role.
612 613 614 615

    Arguments:
       request: django request--used for tracking log
       username_or_email: who to remove.  Decide if it's an email by presense of an '@'
616
       role: A student.roles.AccessRole
617 618 619 620 621 622
       group_title: what to call this group in messages to user--e.g. "beta-testers".
       event_name: what to call this event when logging to tracking logs.

    Returns:
       html to insert in the message field
    """
623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647

    username_or_email = strip_if_string(username_or_email)
    try:
        user = _user_from_name_or_email(username_or_email)
    except User.DoesNotExist:
        return u'<font color="red">Error: unknown username or email "{0}"</font>'.format(username_or_email)

    role.remove_users(user)

    # Deal with historical event names
    if event_name in ('staff', 'beta-tester'):
        track.views.server_track(
            request,
            "add-or-remove-user-group",
            {
                "event_name": event_name,
                "user": unicode(user),
                "event": "remove"
            },
            page="idashboard"
        )
    else:
        track.views.server_track(request, "remove-instructor", {"instructor": unicode(user)}, page="idashboard")

    return '<font color="green">Removed {0} from {1}</font>'.format(user, group_title)
648

ichuang committed
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 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 712 713 714 715 716 717
class GradeTable(object):
    """
    Keep track of grades, by student, for all graded assignment
    components.  Each student's grades are stored in a list.  The
    index of this list specifies the assignment component.  Not
    all lists have the same length, because at the start of going
    through the set of grades, it is unknown what assignment
    compoments exist.  This is because some students may not do
    all the assignment components.

    The student grades are then stored in a dict, with the student
    id as the key.
    """
    def __init__(self):
        self.components = OrderedDict()
        self.grades = {}
        self._current_row = {}

    def _add_grade_to_row(self, component, score):
        """Creates component if needed, and assigns score

        Args:
            component (str): Course component being graded
            score (float): Score of student on component

        Returns:
           None
        """
        component_index = self.components.setdefault(component, len(self.components))
        self._current_row[component_index] = score

    @contextmanager
    def add_row(self, student_id):
        """Context management for a row of grades

        Uses a new dictionary to get all grades of a specified student
        and closes by adding that dict to the internal table.

        Args:
            student_id (str): Student id that is having grades set

        """
        self._current_row = {}
        yield self._add_grade_to_row
        self.grades[student_id] = self._current_row

    def get_grade(self, student_id):
        """Retrieves padded list of grades for specified student

        Args:
            student_id (str): Student ID for desired grades

        Returns:
            list: Ordered list of grades for student

        """
        row = self.grades.get(student_id, [])
        ncomp = len(self.components)
        return [row.get(comp, None) for comp in range(ncomp)]

    def get_graded_components(self):
        """
        Return a list of components that have been
        discovered so far.
        """
        return self.components.keys()


718 719
def get_student_grade_summary_data(request, course, get_grades=True, get_raw_scores=False, use_offline=False):
    """
720 721 722
    Return data arrays with student identity and grades for specified course.

    course = CourseDescriptor
723
    course_key = course ID
724 725

    Note: both are passed in, only because instructor_dashboard already has them already.
ichuang committed
726

727 728 729 730 731 732 733
    returns datatable = dict(header=header, data=data)
    where

    header = list of strings labeling the data fields
    data = list (one per student) of lists of data corresponding to the fields

    If get_raw_scores=True, then instead of grade summaries, the raw grades for all graded modules are returned.
734 735
    """
    course_key = course.id
736
    enrolled_students = User.objects.filter(
737
        courseenrollment__course_id=course_key,
738 739
        courseenrollment__is_active=1,
    ).prefetch_related("groups").order_by('username')
740

741
    header = [_('ID'), _('Username'), _('Full Name'), _('edX email'), _('External email')]
742

743
    datatable = {'header': header, 'students': enrolled_students}
744 745
    data = []

746 747
    gtab = GradeTable()

748
    for student in enrolled_students:
Calen Pennington committed
749
        datarow = [student.id, student.username, student.profile.name, student.email]
750 751
        try:
            datarow.append(student.externalauthmap.external_email)
752
        except Exception:  # pylint: disable=broad-except
753 754 755
            datarow.append('')

        if get_grades:
756
            gradeset = student_grades(student, request, course, keep_raw_scores=get_raw_scores, use_offline=use_offline)
757
            log.debug(u'student=%s, gradeset=%s', student, gradeset)
758 759 760 761 762 763 764 765 766
            with gtab.add_row(student.id) as add_grade:
                if get_raw_scores:
                    # TODO (ichuang) encode Score as dict instead of as list, so score[0] -> score['earned']
                    for score in gradeset['raw_scores']:
                        add_grade(score.section, getattr(score, 'earned', score[0]))
                else:
                    for grade_item in gradeset['section_breakdown']:
                        add_grade(grade_item['label'], grade_item['percent'])
            student.grades = gtab.get_grade(student.id)
767 768

        data.append(datarow)
769 770 771 772 773 774 775 776 777 778 779 780 781 782

    # if getting grades, need to do a second pass, and add grades to each datarow;
    # on the first pass we don't know all the graded components
    if get_grades:
        for datarow in data:
            # get grades for student
            sgrades = gtab.get_grade(datarow[0])
            datarow += sgrades

        # get graded components and add to table header
        assignments = gtab.get_graded_components()
        header += assignments
        datatable['assignments'] = assignments

783 784 785
    datatable['data'] = data
    return datatable

786
#-----------------------------------------------------------------------------
ichuang committed
787

788
# Gradebook has moved to instructor.api.spoc_gradebook #
789

790 791 792
#-----------------------------------------------------------------------------
# enrollment

793

794
def _do_enroll_students(course, course_key, students, secure=False, overload=False, auto_enroll=False, email_students=False, is_shib_course=False):
795 796 797 798
    """
    Do the actual work of enrolling multiple students, presented as a string
    of emails separated by commas or returns
    `course` is course object
799
    `course_key` id of course (a CourseKey)
800 801 802 803 804
    `students` string of student emails separated by commas or returns (a `str`)
    `overload` un-enrolls all existing students (a `boolean`)
    `auto_enroll` is user input preference (a `boolean`)
    `email_students` is user input preference (a `boolean`)
    """
805

dcadams committed
806
    new_students, new_students_lc = get_and_clean_student_list(students)
Calen Pennington committed
807
    status = dict([x, 'unprocessed'] for x in new_students)
808

809 810
    if overload:  # delete all but staff
        todelete = CourseEnrollment.objects.filter(course_id=course_key)
811 812 813 814
        for enrollee in todelete:
            if not has_access(enrollee.user, 'staff', course) and enrollee.user.email.lower() not in new_students_lc:
                status[enrollee.user.email] = 'deleted'
                enrollee.deactivate()
815
            else:
816
                status[enrollee.user.email] = 'is staff'
817
        ceaset = CourseEnrollmentAllowed.objects.filter(course_id=course_key)
818 819 820 821
        for cea in ceaset:
            status[cea.email] = 'removed from pending enrollment list'
        ceaset.delete()

822
    if email_students:
823
        protocol = 'https' if secure else 'http'
824
        stripped_site_name = microsite.get_value(
825 826 827
            'SITE_NAME',
            settings.SITE_NAME
        )
828
        # TODO: Use request.build_absolute_uri rather than '{proto}://{site}{path}'.format
829
        # and check with the Services team that this works well with microsites
830 831 832
        registration_url = '{proto}://{site}{path}'.format(
            proto=protocol,
            site=stripped_site_name,
833
            path=reverse('register_user')
834
        )
835 836 837 838
        course_url = '{proto}://{site}{path}'.format(
            proto=protocol,
            site=stripped_site_name,
            path=reverse('course_root', kwargs={'course_id': course_key.to_deprecated_string()})
839 840 841 842
        )
        # We can't get the url to the course's About page if the marketing site is enabled.
        course_about_url = None
        if not settings.FEATURES.get('ENABLE_MKTG_SITE', False):
843 844 845 846
            course_about_url = u'{proto}://{site}{path}'.format(
                proto=protocol,
                site=stripped_site_name,
                path=reverse('about_course', kwargs={'course_id': course_key.to_deprecated_string()})
847 848 849
            )

        # Composition of email
850
        email_data = {
851 852 853 854 855 856 857 858
            'site_name': stripped_site_name,
            'registration_url': registration_url,
            'course': course,
            'auto_enroll': auto_enroll,
            'course_url': course_url,
            'course_about_url': course_about_url,
            'is_shib_course': is_shib_course
        }
859

860 861
    for student in new_students:
        try:
Calen Pennington committed
862
            user = User.objects.get(email=student)
863
        except User.DoesNotExist:
dcadams committed
864

865
            # Student not signed up yet, put in pending enrollment allowed table
866
            cea = CourseEnrollmentAllowed.objects.filter(email=student, course_id=course_key)
dcadams committed
867

868 869
            # If enrollmentallowed already exists, update auto_enroll flag to however it was set in UI
            # Will be 0 or 1 records as there is a unique key on email + course_id
870 871 872
            if cea:
                cea[0].auto_enroll = auto_enroll
                cea[0].save()
873 874
                status[student] = 'user does not exist, enrollment already allowed, pending with auto enrollment ' \
                    + ('on' if auto_enroll else 'off')
875
                continue
876

877
            # EnrollmentAllowed doesn't exist so create it
878
            cea = CourseEnrollmentAllowed(email=student, course_id=course_key, auto_enroll=auto_enroll)
879
            cea.save()
880 881 882 883 884

            status[student] = 'user does not exist, enrollment allowed, pending with auto enrollment ' \
                + ('on' if auto_enroll else 'off')

            if email_students:
885
                # User is allowed to enroll but has not signed up yet
886 887 888
                email_data['email_address'] = student
                email_data['message'] = 'allowed_enroll'
                send_mail_ret = send_mail_to_student(student, email_data)
889
                status[student] += (', email sent' if send_mail_ret else '')
890 891
            continue

892 893
        # Student has already registered
        if CourseEnrollment.is_enrolled(user, course_key):
894 895
            status[student] = 'already enrolled'
            continue
896

897
        try:
898 899
            # Not enrolled yet
            CourseEnrollment.enroll(user, course_key)
900
            status[student] = 'added'
901 902

            if email_students:
903
                # User enrolled for first time, populate dict with user specific info
904 905 906 907
                email_data['email_address'] = student
                email_data['full_name'] = user.profile.name
                email_data['message'] = 'enrolled_enroll'
                send_mail_ret = send_mail_to_student(student, email_data)
908 909
                status[student] += (', email sent' if send_mail_ret else '')

910
        except Exception:  # pylint: disable=broad-except
911 912 913
            status[student] = 'rejected'

    datatable = {'header': ['StudentEmail', 'action']}
914
    datatable['data'] = [[x, status[x]] for x in sorted(status)]
915
    datatable['title'] = _('Enrollment of students')
916

917 918
    def sf(stat):
        return [x for x in status if status[x] == stat]
919

Calen Pennington committed
920
    data = dict(added=sf('added'), rejected=sf('rejected') + sf('exists'),
921 922 923 924 925
                deleted=sf('deleted'), datatable=datatable)

    return data


926
#Unenrollment
927
def _do_unenroll_students(course_key, students, email_students=False):
928 929 930
    """
    Do the actual work of un-enrolling multiple students, presented as a string
    of emails separated by commas or returns
931
    `course_key` is id of course (a `str`)
932 933 934
    `students` is string of student emails separated by commas or returns (a `str`)
    `email_students` is user input preference (a `boolean`)
    """
935

936
    old_students, __ = get_and_clean_student_list(students)
937
    status = dict([x, 'unprocessed'] for x in old_students)
938

939
    stripped_site_name = microsite.get_value(
940 941 942
        'SITE_NAME',
        settings.SITE_NAME
    )
943
    if email_students:
944
        course = modulestore().get_course(course_key)
945 946 947 948 949
        # Composition of email
        data = {
            'site_name': stripped_site_name,
            'course': course
        }
950

951
    for student in old_students:
952

953
        isok = False
954
        cea = CourseEnrollmentAllowed.objects.filter(course_id=course_key, email=student)
955
        # Will be 0 or 1 records as there is a unique key on email + course_id
956 957 958 959
        if cea:
            cea[0].delete()
            status[student] = "un-enrolled"
            isok = True
dcadams committed
960

961 962
        try:
            user = User.objects.get(email=student)
963
        except User.DoesNotExist:
964 965

            if isok and email_students:
966 967 968 969
                # User was allowed to join but had not signed up yet
                data['email_address'] = student
                data['message'] = 'allowed_unenroll'
                send_mail_ret = send_mail_to_student(student, data)
970 971
                status[student] += (', email sent' if send_mail_ret else '')

972
            continue
973

974
        # Will be 0 or 1 records as there is a unique key on user + course_id
975
        if CourseEnrollment.is_enrolled(user, course_key):
976
            try:
977
                CourseEnrollment.unenroll(user, course_key)
978
                status[student] = "un-enrolled"
979
                if email_students:
980 981 982 983 984
                    # User was enrolled
                    data['email_address'] = student
                    data['full_name'] = user.profile.name
                    data['message'] = 'enrolled_unenroll'
                    send_mail_ret = send_mail_to_student(student, data)
985 986
                    status[student] += (', email sent' if send_mail_ret else '')

987
            except Exception:  # pylint: disable=broad-except
988 989 990 991
                if not isok:
                    status[student] = "Error!  Failed to un-enroll"

    datatable = {'header': ['StudentEmail', 'action']}
992
    datatable['data'] = [[x, status[x]] for x in sorted(status)]
993
    datatable['title'] = _('Un-enrollment of students')
dcadams committed
994

995
    return dict(datatable=datatable)
996 997


998 999 1000 1001
def send_mail_to_student(student, param_dict):
    """
    Construct the email using templates and then send it.
    `student` is the student's email address (a `str`),
1002

1003 1004 1005
    `param_dict` is a `dict` with keys [
    `site_name`: name given to edX instance (a `str`)
    `registration_url`: url for registration (a `str`)
1006
    `course_key`: id of course (a CourseKey)
1007 1008 1009
    `auto_enroll`: user input option (a `str`)
    `course_url`: url of course (a `str`)
    `email_address`: email of student (a `str`)
1010
    `full_name`: student full name (a `str`)
1011
    `message`: type of email to send and template to use (a `str`)
1012
    `is_shib_course`: (a `boolean`)
1013 1014 1015 1016
                                        ]
    Returns a boolean indicating whether the email was sent successfully.
    """

1017 1018 1019
    # add some helpers and microconfig subsitutions
    if 'course' in param_dict:
        param_dict['course_name'] = param_dict['course'].display_name_with_default
1020
    param_dict['site_name'] = microsite.get_value(
1021 1022 1023 1024 1025 1026 1027 1028
        'SITE_NAME',
        param_dict.get('site_name', '')
    )

    subject = None
    message = None

    message_type = param_dict['message']
1029

1030 1031 1032 1033 1034 1035 1036 1037
    email_template_dict = {
        'allowed_enroll': ('emails/enroll_email_allowedsubject.txt', 'emails/enroll_email_allowedmessage.txt'),
        'enrolled_enroll': ('emails/enroll_email_enrolledsubject.txt', 'emails/enroll_email_enrolledmessage.txt'),
        'allowed_unenroll': ('emails/unenroll_email_subject.txt', 'emails/unenroll_email_allowedmessage.txt'),
        'enrolled_unenroll': ('emails/unenroll_email_subject.txt', 'emails/unenroll_email_enrolledmessage.txt'),
    }

    subject_template, message_template = email_template_dict.get(message_type, (None, None))
1038 1039 1040 1041
    if subject_template is not None and message_template is not None:
        subject = render_to_string(subject_template, param_dict)
        message = render_to_string(message_template, param_dict)

1042
    if subject and message:
1043 1044 1045
        # Remove leading and trailing whitespace from body
        message = message.strip()

1046 1047
        # Email subject *must not* contain newlines
        subject = ''.join(subject.splitlines())
1048
        from_address = microsite.get_value(
1049 1050 1051 1052 1053
            'email_from_address',
            settings.DEFAULT_FROM_EMAIL
        )

        send_mail(subject, message, from_address, [student], fail_silently=False)
1054

1055 1056 1057 1058 1059
        return True
    else:
        return False


1060
def get_and_clean_student_list(students):
1061 1062
    """
    Separate out individual student email from the comma, or space separated string.
1063 1064
    `students` is string of student emails separated by commas or returns (a `str`)
    Returns:
1065 1066 1067 1068
    students: list of cleaned student emails
    students_lc: list of lower case cleaned student emails
    """

1069
    students = split_by_comma_and_whitespace(students)
1070
    students = [unicode(s.strip()) for s in students]
1071
    students = [s for s in students if s != '']
1072 1073
    students_lc = [x.lower() for x in students]

dcadams committed
1074
    return students, students_lc
1075

1076 1077 1078
#-----------------------------------------------------------------------------
# answer distribution

1079

1080
def get_answers_distribution(request, course_key):
1081 1082 1083 1084 1085 1086 1087
    """
    Get the distribution of answers for all graded problems in the course.

    Return a dict with two keys:
    'header': a header row
    'data': a list of rows
    """
1088
    course = get_course_with_access(request.user, 'staff', course_key)
1089

wwj718 committed
1090
    course_answer_distributions = grades.answer_distributions(course.id)
1091

1092 1093
    dist = {}
    dist['header'] = ['url_name', 'display name', 'answer id', 'answer', 'count']
1094

1095
    dist['data'] = [
1096
        [url_name, display_name, answer_id, a, answers[a]]
wwj718 committed
1097
        for (url_name, display_name, answer_id), answers in sorted(course_answer_distributions.items())
1098 1099
        for a in answers
    ]
1100
    return dist
1101 1102


1103 1104
#-----------------------------------------------------------------------------

ichuang committed
1105

1106
def compute_course_stats(course):
1107
    """
1108 1109 1110
    Compute course statistics, including number of problems, videos, html.

    course is a CourseDescriptor from the xmodule system.
1111
    """
1112 1113 1114 1115 1116 1117 1118 1119

    # walk the course by using get_children() until we come to the leaves; count the
    # number of different leaf types

    counts = defaultdict(int)

    def walk(module):
        children = module.get_children()
1120
        category = module.__class__.__name__  # HtmlDescriptor, CapaDescriptor, ...
1121
        counts[category] += 1
1122 1123
        for child in children:
            walk(child)
1124 1125

    walk(course)
1126
    stats = dict(counts)  # number of each kind of module
1127
    return stats
1128 1129 1130


def dump_grading_context(course):
1131
    """
1132 1133
    Dump information about course grading context (eg which problems are graded in what assignments)
    Very useful for debugging grading_policy.json and policy.json
1134
    """
1135 1136
    msg = "-----------------------------------------------------------------------------\n"
    msg += "Course grader:\n"
1137

1138 1139
    msg += '%s\n' % course.grader.__class__
    graders = {}
1140
    if isinstance(course.grader, xmgraders.WeightedSubsectionsGrader):
1141 1142 1143 1144 1145 1146 1147 1148 1149
        msg += '\n'
        msg += "Graded sections:\n"
        for subgrader, category, weight in course.grader.sections:
            msg += "  subgrader=%s, type=%s, category=%s, weight=%s\n" % (subgrader.__class__, subgrader.type, category, weight)
            subgrader.index = 1
            graders[subgrader.type] = subgrader
    msg += "-----------------------------------------------------------------------------\n"
    msg += "Listing grading context for course %s\n" % course.id

1150
    gcontext = course.grading_context
1151
    msg += "graded sections:\n"
1152

1153 1154 1155
    msg += '%s\n' % gcontext['graded_sections'].keys()
    for (gsections, gsvals) in gcontext['graded_sections'].items():
        msg += "--> Section %s:\n" % (gsections)
1156
        for sec in gsvals:
1157 1158
            sdesc = sec['section_descriptor']
            grade_format = getattr(sdesc, 'grade_format', None)
1159
            aname = ''
1160
            if grade_format in graders:
1161 1162 1163 1164 1165 1166
                gfmt = graders[grade_format]
                aname = '%s %02d' % (gfmt.short_label, gfmt.index)
                gfmt.index += 1
            elif sdesc.display_name in graders:
                gfmt = graders[sdesc.display_name]
                aname = '%s' % gfmt.short_label
1167
            notes = ''
1168
            if getattr(sdesc, 'score_by_attempt', False):
1169
                notes = ', score by attempt!'
1170
            msg += "      %s (grade_format=%s, Assignment=%s%s)\n" % (sdesc.display_name, grade_format, aname, notes)
1171
    msg += "all descriptors:\n"
1172
    msg += "length=%d\n" % len(gcontext['all_descriptors'])
1173
    msg = '<pre>%s</pre>' % msg.replace('<', '&lt;')
1174
    return msg
1175 1176


1177
def get_background_task_table(course_key, problem_url=None, student=None, task_type=None):
1178 1179 1180 1181
    """
    Construct the "datatable" structure to represent background task history.

    Filters the background task history to the specified course and problem.
1182
    If a student is provided, filters to only those tasks for which that student
1183 1184 1185 1186 1187
    was specified.

    Returns a tuple of (msg, datatable), where the msg is a possible error message,
    and the datatable is the datatable to be used for display.
    """
1188
    history_entries = get_instructor_task_history(course_key, problem_url, student, task_type)
1189
    datatable = {}
1190 1191 1192 1193
    msg = ""
    # first check to see if there is any history at all
    # (note that we don't have to check that the arguments are valid; it
    # just won't find any entries.)
1194
    if (history_entries.count()) == 0:
1195
        if problem_url is None:
1196 1197 1198
            msg += '<font color="red">Failed to find any background tasks for course "{course}".</font>'.format(
                course=course_key.to_deprecated_string()
            )
1199
        elif student is not None:
1200
            template = '<font color="red">' + _('Failed to find any background tasks for course "{course}", module "{problem}" and student "{student}".') + '</font>'
1201
            msg += template.format(course=course_key.to_deprecated_string(), problem=problem_url, student=student.username)
1202
        else:
1203 1204 1205
            msg += '<font color="red">' + _('Failed to find any background tasks for course "{course}" and module "{problem}".').format(
                course=course_key.to_deprecated_string(), problem=problem_url
            ) + '</font>'
1206
    else:
1207 1208 1209 1210
        datatable['header'] = ["Task Type",
                               "Task Id",
                               "Requester",
                               "Submitted",
Brian Wilson committed
1211
                               "Duration (sec)",
1212 1213 1214
                               "Task State",
                               "Task Status",
                               "Task Output"]
1215 1216

        datatable['data'] = []
1217
        for instructor_task in history_entries:
1218
            # get duration info, if known:
Brian Wilson committed
1219 1220
            duration_sec = 'unknown'
            if hasattr(instructor_task, 'task_output') and instructor_task.task_output is not None:
1221
                task_output = json.loads(instructor_task.task_output)
1222
                if 'duration_ms' in task_output:
Brian Wilson committed
1223
                    duration_sec = int(task_output['duration_ms'] / 1000.0)
1224
            # get progress status message:
1225
            success, task_message = get_task_completion_info(instructor_task)
1226
            status = "Complete" if success else "Incomplete"
1227
            # generate row for this task:
1228 1229 1230 1231 1232 1233 1234 1235 1236 1237
            row = [
                str(instructor_task.task_type),
                str(instructor_task.task_id),
                str(instructor_task.requester),
                instructor_task.created.isoformat(' '),
                duration_sec,
                str(instructor_task.task_state),
                status,
                task_message
            ]
1238 1239
            datatable['data'].append(row)

1240
        if problem_url is None:
1241
            datatable['title'] = "{course_id}".format(course_id=course_key.to_deprecated_string())
1242
        elif student is not None:
1243 1244 1245 1246 1247
            datatable['title'] = "{course_id} > {location} > {student}".format(
                course_id=course_key.to_deprecated_string(),
                location=problem_url,
                student=student.username
            )
1248
        else:
1249 1250 1251
            datatable['title'] = "{course_id} > {location}".format(
                course_id=course_key.to_deprecated_string(), location=problem_url
            )
1252

1253
    return msg, datatable
1254 1255 1256 1257 1258 1259 1260 1261 1262


def uses_shib(course):
    """
    Used to return whether course has Shibboleth as the enrollment domain

    Returns a boolean indicating if Shibboleth authentication is set for this course.
    """
    return course.enrollment_domain and course.enrollment_domain.startswith(SHIBBOLETH_DOMAIN_PREFIX)