views.py 21.9 KB
Newer Older
1
"""
cewing committed
2
Views related to the Custom Courses feature.
3
"""
4
import csv
5 6 7 8
import datetime
import functools
import json
import logging
9
from copy import deepcopy
10 11
from cStringIO import StringIO

12 13
import pytz
from ccx_keys.locator import CCXLocator
14
from django.conf import settings
15
from django.contrib import messages
16 17
from django.contrib.auth.models import User
from django.core.urlresolvers import reverse
18
from django.db import transaction
19
from django.http import Http404, HttpResponse, HttpResponseForbidden
20 21 22
from django.shortcuts import redirect
from django.utils.translation import ugettext as _
from django.views.decorators.cache import cache_control
23
from django.views.decorators.csrf import ensure_csrf_cookie
24
from opaque_keys.edx.keys import CourseKey
25

26
from courseware.access import has_access
27 28
from courseware.courses import get_course_by_id
from courseware.field_overrides import disable_overrides
29 30
from django_comment_common.models import FORUM_ROLE_ADMINISTRATOR, assign_role
from django_comment_common.utils import seed_permissions_roles
31
from edxmako.shortcuts import render_to_response
32 33
from lms.djangoapps.ccx.models import CustomCourseForEdX
from lms.djangoapps.ccx.overrides import (
34
    bulk_delete_ccx_override_fields,
35 36 37
    clear_ccx_field_info_from_ccx_map,
    get_override_for_ccx,
    override_field_for_ccx
38
)
39
from lms.djangoapps.ccx.utils import (
40
    add_master_course_staff_to_ccx,
41
    assign_staff_role_to_ccx,
42 43
    ccx_course,
    ccx_students_enrolling_center,
44
    get_ccx_by_ccx_id,
45
    get_ccx_creation_dict,
46
    get_ccx_for_coach,
47 48 49
    get_date,
    parse_date,
)
50
from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory
51 52 53 54 55 56
from lms.djangoapps.instructor.enrollment import enroll_email, get_email_params
from lms.djangoapps.instructor.views.api import _split_input_list
from lms.djangoapps.instructor.views.gradebook_api import get_grade_book_page
from student.models import CourseEnrollment
from student.roles import CourseCcxCoachRole
from xmodule.modulestore.django import SignalHandler
57

58
log = logging.getLogger(__name__)
59
TODAY = datetime.datetime.today  # for patching in tests
60 61 62 63


def coach_dashboard(view):
    """
cewing committed
64
    View decorator which enforces that the user have the CCX coach role on the
65 66 67 68 69
    given course and goes ahead and translates the course_id from the Django
    route into a course object.
    """
    @functools.wraps(view)
    def wrapper(request, course_id):
70 71 72 73
        """
        Wraps the view function, performing access check, loading the course,
        and modifying the view's call signature.
        """
74
        course_key = CourseKey.from_string(course_id)
cewing committed
75 76 77
        ccx = None
        if isinstance(course_key, CCXLocator):
            ccx_id = course_key.ccx
78 79 80 81
            try:
                ccx = CustomCourseForEdX.objects.get(pk=ccx_id)
            except CustomCourseForEdX.DoesNotExist:
                raise Http404
cewing committed
82

83 84
        if ccx:
            course_key = ccx.course_id
85
        course = get_course_by_id(course_key, depth=None)
86

87 88
        if not course.enable_ccx:
            raise Http404
89
        else:
90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106
            is_staff = has_access(request.user, 'staff', course)
            is_instructor = has_access(request.user, 'instructor', course)

            if is_staff or is_instructor:
                # if user is staff or instructor then he can view ccx coach dashboard.
                return view(request, course, ccx)
            else:
                # if there is a ccx, we must validate that it is the ccx for this coach
                role = CourseCcxCoachRole(course_key)
                if not role.has_user(request.user):
                    return HttpResponseForbidden(_('You must be a CCX Coach to access this view.'))
                elif ccx is not None:
                    coach_ccx = get_ccx_by_ccx_id(course, request.user, ccx.id)
                    if coach_ccx is None:
                        return HttpResponseForbidden(
                            _('You must be the coach for this ccx to access this view')
                        )
107

cewing committed
108
        return view(request, course, ccx)
109 110 111 112 113 114
    return wrapper


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@coach_dashboard
cewing committed
115
def dashboard(request, course, ccx=None):
116
    """
cewing committed
117
    Display the CCX Coach Dashboard.
118
    """
cewing committed
119 120 121 122 123
    # right now, we can only have one ccx per user and course
    # so, if no ccx is passed in, we can sefely redirect to that
    if ccx is None:
        ccx = get_ccx_for_coach(course, request.user)
        if ccx:
124 125
            url = reverse(
                'ccx_coach_dashboard',
126
                kwargs={'course_id': CCXLocator.from_course_locator(course.id, unicode(ccx.id))}
127
            )
cewing committed
128 129
            return redirect(url)

130 131
    context = {
        'course': course,
cewing committed
132
        'ccx': ccx,
133
    }
134
    context.update(get_ccx_creation_dict(course))
135

cewing committed
136
    if ccx:
137
        ccx_locator = CCXLocator.from_course_locator(course.id, unicode(ccx.id))
138 139
        # At this point we are done with verification that current user is ccx coach.
        assign_staff_role_to_ccx(ccx_locator, request.user, course.id)
cewing committed
140 141 142
        schedule = get_ccx_schedule(course, ccx)
        grading_policy = get_override_for_ccx(
            ccx, course, 'grading_policy', course.grading_policy)
143 144
        context['schedule'] = json.dumps(schedule, indent=4)
        context['save_url'] = reverse(
cewing committed
145
            'save_ccx', kwargs={'course_id': ccx_locator})
146
        context['ccx_members'] = CourseEnrollment.objects.filter(course_id=ccx_locator, is_active=True)
147
        context['gradebook_url'] = reverse(
cewing committed
148
            'ccx_gradebook', kwargs={'course_id': ccx_locator})
149
        context['grades_csv_url'] = reverse(
cewing committed
150
            'ccx_grades_csv', kwargs={'course_id': ccx_locator})
151 152
        context['grading_policy'] = json.dumps(grading_policy, indent=4)
        context['grading_policy_url'] = reverse(
cewing committed
153
            'ccx_set_grading_policy', kwargs={'course_id': ccx_locator})
154

155 156 157
        with ccx_course(ccx_locator) as course:
            context['course'] = course

158
    else:
cewing committed
159 160 161
        context['create_ccx_url'] = reverse(
            'create_ccx', kwargs={'course_id': course.id})
    return render_to_response('ccx/coach_dashboard.html', context)
162 163 164 165 166


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@coach_dashboard
cewing committed
167
def create_ccx(request, course, ccx=None):
168
    """
cewing committed
169
    Create a new CCX
170 171
    """
    name = request.POST.get('name')
172

173 174 175 176 177 178 179
    if hasattr(course, 'ccx_connector') and course.ccx_connector:
        # if ccx connector url is set in course settings then inform user that he can
        # only create ccx by using ccx connector url.
        context = get_ccx_creation_dict(course)
        messages.error(request, context['use_ccx_con_error_message'])
        return render_to_response('ccx/coach_dashboard.html', context)

180 181
    # prevent CCX objects from being created for deprecated course ids.
    if course.id.deprecated:
182
        messages.error(request, _(
183 184
            "You cannot create a CCX from a course using a deprecated id. "
            "Please create a rerun of this course in the studio to allow "
185
            "this action."))
186
        url = reverse('ccx_coach_dashboard', kwargs={'course_id': course.id})
187 188
        return redirect(url)

cewing committed
189
    ccx = CustomCourseForEdX(
190 191 192
        course_id=course.id,
        coach=request.user,
        display_name=name)
cewing committed
193
    ccx.save()
194 195

    # Make sure start/due are overridden for entire course
196
    start = TODAY().replace(tzinfo=pytz.UTC)
cewing committed
197 198
    override_field_for_ccx(ccx, course, 'start', start)
    override_field_for_ccx(ccx, course, 'due', None)
199

200 201 202
    # Enforce a static limit for the maximum amount of students that can be enrolled
    override_field_for_ccx(ccx, course, 'max_student_enrollments_allowed', settings.CCX_MAX_STUDENTS_ALLOWED)

203 204 205
    # Hide anything that can show up in the schedule
    hidden = 'visible_to_staff_only'
    for chapter in course.get_children():
cewing committed
206
        override_field_for_ccx(ccx, chapter, hidden, True)
207
        for sequential in chapter.get_children():
cewing committed
208
            override_field_for_ccx(ccx, sequential, hidden, True)
209
            for vertical in sequential.get_children():
cewing committed
210
                override_field_for_ccx(ccx, vertical, hidden, True)
211

212
    ccx_id = CCXLocator.from_course_locator(course.id, unicode(ccx.id))
213

214 215 216 217 218
    # Create forum roles
    seed_permissions_roles(ccx_id)
    # Assign administrator forum role to CCX coach
    assign_role(ccx_id, request.user, FORUM_ROLE_ADMINISTRATOR)

cewing committed
219
    url = reverse('ccx_coach_dashboard', kwargs={'course_id': ccx_id})
220 221 222 223 224 225 226 227 228 229 230

    # Enroll the coach in the course
    email_params = get_email_params(course, auto_enroll=True, course_key=ccx_id, display_name=ccx.display_name)
    enroll_email(
        course_id=ccx_id,
        student_email=request.user.email,
        auto_enroll=True,
        email_students=True,
        email_params=email_params,
    )

231
    assign_staff_role_to_ccx(ccx_id, request.user, course.id)
232
    add_master_course_staff_to_ccx(course, ccx_id, ccx.display_name)
233 234 235 236 237 238 239 240 241

    # using CCX object as sender here.
    responses = SignalHandler.course_published.send(
        sender=ccx,
        course_key=CCXLocator.from_course_locator(course.id, unicode(ccx.id))
    )
    for rec, response in responses:
        log.info('Signal fired when course is published. Receiver: %s. Response: %s', rec, response)

242 243 244 245 246 247
    return redirect(url)


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@coach_dashboard
cewing committed
248
def save_ccx(request, course, ccx=None):
249
    """
cewing committed
250
    Save changes to CCX.
251
    """
cewing committed
252 253
    if not ccx:
        raise Http404
254

255
    def override_fields(parent, data, graded, earliest=None, ccx_ids_to_delete=None):
256
        """
cewing committed
257
        Recursively apply CCX schedule data to CCX by overriding the
258 259 260
        `visible_to_staff_only`, `start` and `due` fields for units in the
        course.
        """
261 262
        if ccx_ids_to_delete is None:
            ccx_ids_to_delete = []
263 264 265
        blocks = {
            str(child.location): child
            for child in parent.get_children()}
266

267 268
        for unit in data:
            block = blocks[unit['location']]
cewing committed
269 270
            override_field_for_ccx(
                ccx, block, 'visible_to_staff_only', unit['hidden'])
271

272 273 274 275
            start = parse_date(unit['start'])
            if start:
                if not earliest or start < earliest:
                    earliest = start
cewing committed
276
                override_field_for_ccx(ccx, block, 'start', start)
277
            else:
278 279 280
                ccx_ids_to_delete.append(get_override_for_ccx(ccx, block, 'start_id'))
                clear_ccx_field_info_from_ccx_map(ccx, block, 'start')

281 282 283 284 285 286 287 288
            # Only subsection (aka sequential) and unit (aka vertical) have due dates.
            if 'due' in unit:  # checking that the key (due) exist in dict (unit).
                due = parse_date(unit['due'])
                if due:
                    override_field_for_ccx(ccx, block, 'due', due)
                else:
                    ccx_ids_to_delete.append(get_override_for_ccx(ccx, block, 'due_id'))
                    clear_ccx_field_info_from_ccx_map(ccx, block, 'due')
289
            else:
290
                # In case of section aka chapter we do not have due date.
291 292
                ccx_ids_to_delete.append(get_override_for_ccx(ccx, block, 'due_id'))
                clear_ccx_field_info_from_ccx_map(ccx, block, 'due')
293

294
            if not unit['hidden'] and block.graded:
295
                graded[block.format] = graded.get(block.format, 0) + 1
296

297
            children = unit.get('children', None)
298 299 300 301 302 303 304 305 306 307
            # For a vertical, override start and due dates of all its problems.
            if unit.get('category', None) == u'vertical':
                for component in block.get_children():
                    # override start and due date of problem (Copy dates of vertical into problems)
                    if start:
                        override_field_for_ccx(ccx, component, 'start', start)

                    if due:
                        override_field_for_ccx(ccx, component, 'due', due)

308
            if children:
309 310
                override_fields(block, children, graded, earliest, ccx_ids_to_delete)
        return earliest, ccx_ids_to_delete
311

312
    graded = {}
313 314
    earliest, ccx_ids_to_delete = override_fields(course, json.loads(request.body), graded, [])
    bulk_delete_ccx_override_fields(ccx, ccx_ids_to_delete)
315
    if earliest:
cewing committed
316
        override_field_for_ccx(ccx, course, 'start', earliest)
317

318 319
    # Attempt to automatically adjust grading policy
    changed = False
cewing committed
320 321
    policy = get_override_for_ccx(
        ccx, course, 'grading_policy', course.grading_policy
322 323 324 325 326
    )
    policy = deepcopy(policy)
    grader = policy['GRADER']
    for section in grader:
        count = graded.get(section.get('type'), 0)
327
        if count < section.get('min_count', 0):
328 329 330
            changed = True
            section['min_count'] = count
    if changed:
cewing committed
331
        override_field_for_ccx(ccx, course, 'grading_policy', policy)
332

333 334 335 336 337 338 339 340
    # using CCX object as sender here.
    responses = SignalHandler.course_published.send(
        sender=ccx,
        course_key=CCXLocator.from_course_locator(course.id, unicode(ccx.id))
    )
    for rec, response in responses:
        log.info('Signal fired when course is published. Receiver: %s. Response: %s', rec, response)

341
    return HttpResponse(
342
        json.dumps({
cewing committed
343
            'schedule': get_ccx_schedule(course, ccx),
344 345 346 347 348 349 350 351
            'grading_policy': json.dumps(policy, indent=4)}),
        content_type='application/json',
    )


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@coach_dashboard
cewing committed
352
def set_grading_policy(request, course, ccx=None):
353
    """
cewing committed
354
    Set grading policy for the CCX.
355
    """
cewing committed
356 357 358
    if not ccx:
        raise Http404

cewing committed
359 360
    override_field_for_ccx(
        ccx, course, 'grading_policy', json.loads(request.POST['policy']))
361

362 363 364 365 366 367 368 369
    # using CCX object as sender here.
    responses = SignalHandler.course_published.send(
        sender=ccx,
        course_key=CCXLocator.from_course_locator(course.id, unicode(ccx.id))
    )
    for rec, response in responses:
        log.info('Signal fired when course is published. Receiver: %s. Response: %s', rec, response)

cewing committed
370 371
    url = reverse(
        'ccx_coach_dashboard',
372
        kwargs={'course_id': CCXLocator.from_course_locator(course.id, unicode(ccx.id))}
cewing committed
373
    )
374
    return redirect(url)
375 376


cewing committed
377
def get_ccx_schedule(course, ccx):
378
    """
cewing committed
379
    Generate a JSON serializable CCX schedule.
380 381
    """
    def visit(node, depth=1):
382
        """
cewing committed
383
        Recursive generator function which yields CCX schedule nodes.
384 385
        We convert dates to string to get them ready for use by the js date
        widgets, which use text inputs.
386 387
        Visits students visible nodes only; nodes children of hidden ones
        are skipped as well.
388 389 390 391 392 393 394 395

        Dates:
        Only start date is applicable to a section. If ccx coach did not override start date then
        getting it from the master course.
        Both start and due dates are applicable to a subsection (aka sequential). If ccx coach did not override
        these dates then getting these dates from corresponding subsection in master course.
        Unit inherits start date and due date from its subsection. If ccx coach did not override these dates
        then getting them from corresponding subsection in master course.
396
        """
397
        for child in node.get_children():
398 399 400
            # in case the children are visible to staff only, skip them
            if child.visible_to_staff_only:
                continue
401

cewing committed
402 403
            hidden = get_override_for_ccx(
                ccx, child, 'visible_to_staff_only',
404
                child.visible_to_staff_only)
405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431

            start = get_date(ccx, child, 'start')
            if depth > 1:
                # Subsection has both start and due dates and unit inherit dates from their subsections
                if depth == 2:
                    due = get_date(ccx, child, 'due')
                elif depth == 3:
                    # Get start and due date of subsection in case unit has not override dates.
                    due = get_date(ccx, child, 'due', node)
                    start = get_date(ccx, child, 'start', node)

                visited = {
                    'location': str(child.location),
                    'display_name': child.display_name,
                    'category': child.category,
                    'start': start,
                    'due': due,
                    'hidden': hidden,
                }
            else:
                visited = {
                    'location': str(child.location),
                    'display_name': child.display_name,
                    'category': child.category,
                    'start': start,
                    'hidden': hidden,
                }
432 433 434 435 436 437 438 439 440 441 442 443 444 445 446
            if depth < 3:
                children = tuple(visit(child, depth + 1))
                if children:
                    visited['children'] = children
                    yield visited
            else:
                yield visited

    with disable_overrides():
        return tuple(visit(course))


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@coach_dashboard
447
def ccx_schedule(request, course, ccx=None):  # pylint: disable=unused-argument
448 449 450
    """
    get json representation of ccx schedule
    """
cewing committed
451 452 453
    if not ccx:
        raise Http404

cewing committed
454
    schedule = get_ccx_schedule(course, ccx)
455
    json_schedule = json.dumps(schedule, indent=4)
456
    return HttpResponse(json_schedule, content_type='application/json')
457 458 459 460 461


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@coach_dashboard
cewing committed
462
def ccx_invite(request, course, ccx=None):
463
    """
cewing committed
464
    Invite users to new ccx
465
    """
cewing committed
466 467 468
    if not ccx:
        raise Http404

469 470 471
    action = request.POST.get('enrollment-button')
    identifiers_raw = request.POST.get('student-ids')
    identifiers = _split_input_list(identifiers_raw)
472
    email_students = 'email-students' in request.POST
473
    course_key = CCXLocator.from_course_locator(course.id, unicode(ccx.id))
474
    email_params = get_email_params(course, auto_enroll=True, course_key=course_key, display_name=ccx.display_name)
475

476
    ccx_students_enrolling_center(action, identifiers, email_students, course_key, email_params, ccx.coach)
477

478 479
    url = reverse('ccx_coach_dashboard', kwargs={'course_id': course_key})
    return redirect(url)
480 481


482 483 484
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@coach_dashboard
cewing committed
485
def ccx_student_management(request, course, ccx=None):
486 487
    """
    Manage the enrollment of individual students in a CCX
488
    """
cewing committed
489 490 491
    if not ccx:
        raise Http404

492 493
    action = request.POST.get('student-action', None)
    student_id = request.POST.get('student-id', '')
494 495
    email_students = 'email-students' in request.POST
    identifiers = [student_id]
496
    course_key = CCXLocator.from_course_locator(course.id, unicode(ccx.id))
497 498
    email_params = get_email_params(course, auto_enroll=True, course_key=course_key, display_name=ccx.display_name)

499
    errors = ccx_students_enrolling_center(action, identifiers, email_students, course_key, email_params, ccx.coach)
500 501

    for error_message in errors:
502
        messages.error(request, error_message)
503

504
    url = reverse('ccx_coach_dashboard', kwargs={'course_id': course_key})
505 506 507
    return redirect(url)


508 509
# Grades can potentially be written - if so, let grading manage the transaction.
@transaction.non_atomic_requests
510 511
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@coach_dashboard
cewing committed
512
def ccx_gradebook(request, course, ccx=None):
513
    """
cewing committed
514
    Show the gradebook for this CCX.
515
    """
cewing committed
516 517
    if not ccx:
        raise Http404
518

519
    ccx_key = CCXLocator.from_course_locator(course.id, unicode(ccx.id))
cewing committed
520
    with ccx_course(ccx_key) as course:
521
        student_info, page = get_grade_book_page(request, course, course_key=ccx_key)
522 523

        return render_to_response('courseware/gradebook.html', {
524 525
            'page': page,
            'page_url': reverse('ccx_gradebook', kwargs={'course_id': ccx_key}),
526 527 528 529 530 531 532
            'students': student_info,
            'course': course,
            'course_id': course.id,
            'staff_access': request.user.is_staff,
            'ordered_grades': sorted(
                course.grade_cutoffs.items(), key=lambda i: i[1], reverse=True),
        })
533 534


535 536
# Grades can potentially be written - if so, let grading manage the transaction.
@transaction.non_atomic_requests
537 538
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@coach_dashboard
cewing committed
539
def ccx_grades_csv(request, course, ccx=None):
540 541 542
    """
    Download grades as CSV.
    """
cewing committed
543 544 545
    if not ccx:
        raise Http404

546
    ccx_key = CCXLocator.from_course_locator(course.id, unicode(ccx.id))
cewing committed
547
    with ccx_course(ccx_key) as course:
548

549
        enrolled_students = User.objects.filter(
550 551
            courseenrollment__course_id=ccx_key,
            courseenrollment__is_active=1
552
        ).order_by('username').select_related("profile")
553
        grades = CourseGradeFactory().iter(enrolled_students, course)
554 555 556

        header = None
        rows = []
557 558
        for student, course_grade, __ in grades:
            if course_grade:
559 560 561 562 563 564
                # We were able to successfully grade this student for this
                # course.
                if not header:
                    # Encode the header row in utf-8 encoding in case there are
                    # unicode characters
                    header = [section['label'].encode('utf-8')
565
                              for section in course_grade.summary[u'section_breakdown']]
566 567 568 569
                    rows.append(["id", "email", "username", "grade"] + header)

                percents = {
                    section['label']: section.get('percent', 0.0)
570
                    for section in course_grade.summary[u'section_breakdown']
571 572 573 574 575
                    if 'label' in section
                }

                row_percents = [percents.get(label, 0.0) for label in header]
                rows.append([student.id, student.email, student.username,
576
                             course_grade.percent] + row_percents)
577

578 579
        buf = StringIO()
        writer = csv.writer(buf)
580 581 582
        for row in rows:
            writer.writerow(row)

583 584 585 586
        response = HttpResponse(buf.getvalue(), content_type='text/csv')
        response['Content-Disposition'] = 'attachment'

        return response