views.py 22 KB
Newer Older
1
"""
cewing committed
2
Views related to the Custom Courses feature.
3
"""
4
import csv
5 6 7 8 9 10
import datetime
import functools
import json
import logging
import pytz

11
from copy import deepcopy
12 13
from cStringIO import StringIO

14
from django.conf import settings
15
from django.core.urlresolvers import reverse
16 17 18 19
from django.http import (
    HttpResponse,
    HttpResponseForbidden,
)
20
from django.contrib import messages
21
from django.db import transaction
cewing committed
22
from django.http import Http404
23 24 25
from django.shortcuts import redirect
from django.utils.translation import ugettext as _
from django.views.decorators.cache import cache_control
26
from django.views.decorators.csrf import ensure_csrf_cookie
27 28
from django.contrib.auth.models import User

29
from courseware.access import has_access
30
from courseware.courses import get_course_by_id
31

32
from courseware.field_overrides import disable_overrides
33 34
from django_comment_common.models import FORUM_ROLE_ADMINISTRATOR, assign_role
from django_comment_common.utils import seed_permissions_roles
35
from edxmako.shortcuts import render_to_response
36
from lms.djangoapps.grades.new.course_grade import CourseGradeFactory
37
from opaque_keys.edx.keys import CourseKey
cewing committed
38
from ccx_keys.locator import CCXLocator
39
from student.roles import CourseCcxCoachRole
40
from student.models import CourseEnrollment
41
from xmodule.modulestore.django import SignalHandler
42

43 44 45
from lms.djangoapps.instructor.views.api import _split_input_list
from lms.djangoapps.instructor.views.gradebook_api import get_grade_book_page
from lms.djangoapps.instructor.enrollment import (
46 47 48
    enroll_email,
    get_email_params,
)
49 50 51

from lms.djangoapps.ccx.models import CustomCourseForEdX
from lms.djangoapps.ccx.overrides import (
cewing committed
52 53
    get_override_for_ccx,
    override_field_for_ccx,
54 55
    clear_ccx_field_info_from_ccx_map,
    bulk_delete_ccx_override_fields,
56
)
57
from lms.djangoapps.ccx.utils import (
58
    add_master_course_staff_to_ccx,
59
    assign_staff_role_to_ccx,
60 61 62
    ccx_course,
    ccx_students_enrolling_center,
    get_ccx_for_coach,
63
    get_ccx_by_ccx_id,
64
    get_ccx_creation_dict,
65 66 67 68
    get_date,
    parse_date,
    prep_course_for_grading,
)
69

70
log = logging.getLogger(__name__)
71
TODAY = datetime.datetime.today  # for patching in tests
72 73 74 75


def coach_dashboard(view):
    """
cewing committed
76
    View decorator which enforces that the user have the CCX coach role on the
77 78 79 80 81
    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):
82 83 84 85
        """
        Wraps the view function, performing access check, loading the course,
        and modifying the view's call signature.
        """
86
        course_key = CourseKey.from_string(course_id)
cewing committed
87 88 89
        ccx = None
        if isinstance(course_key, CCXLocator):
            ccx_id = course_key.ccx
90 91 92 93
            try:
                ccx = CustomCourseForEdX.objects.get(pk=ccx_id)
            except CustomCourseForEdX.DoesNotExist:
                raise Http404
cewing committed
94

95 96
        if ccx:
            course_key = ccx.course_id
97
        course = get_course_by_id(course_key, depth=None)
98

99 100
        if not course.enable_ccx:
            raise Http404
101
        else:
102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118
            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')
                        )
119

cewing committed
120
        return view(request, course, ccx)
121 122 123 124 125 126
    return wrapper


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@coach_dashboard
cewing committed
127
def dashboard(request, course, ccx=None):
128
    """
cewing committed
129
    Display the CCX Coach Dashboard.
130
    """
cewing committed
131 132 133 134 135
    # 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:
136 137
            url = reverse(
                'ccx_coach_dashboard',
138
                kwargs={'course_id': CCXLocator.from_course_locator(course.id, unicode(ccx.id))}
139
            )
cewing committed
140 141
            return redirect(url)

142 143
    context = {
        'course': course,
cewing committed
144
        'ccx': ccx,
145
    }
146
    context.update(get_ccx_creation_dict(course))
147

cewing committed
148
    if ccx:
149
        ccx_locator = CCXLocator.from_course_locator(course.id, unicode(ccx.id))
150 151
        # 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
152 153 154
        schedule = get_ccx_schedule(course, ccx)
        grading_policy = get_override_for_ccx(
            ccx, course, 'grading_policy', course.grading_policy)
155 156
        context['schedule'] = json.dumps(schedule, indent=4)
        context['save_url'] = reverse(
cewing committed
157
            'save_ccx', kwargs={'course_id': ccx_locator})
158
        context['ccx_members'] = CourseEnrollment.objects.filter(course_id=ccx_locator, is_active=True)
159
        context['gradebook_url'] = reverse(
cewing committed
160
            'ccx_gradebook', kwargs={'course_id': ccx_locator})
161
        context['grades_csv_url'] = reverse(
cewing committed
162
            'ccx_grades_csv', kwargs={'course_id': ccx_locator})
163 164
        context['grading_policy'] = json.dumps(grading_policy, indent=4)
        context['grading_policy_url'] = reverse(
cewing committed
165
            'ccx_set_grading_policy', kwargs={'course_id': ccx_locator})
166

167 168 169
        with ccx_course(ccx_locator) as course:
            context['course'] = course

170
    else:
cewing committed
171 172 173
        context['create_ccx_url'] = reverse(
            'create_ccx', kwargs={'course_id': course.id})
    return render_to_response('ccx/coach_dashboard.html', context)
174 175 176 177 178


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@coach_dashboard
cewing committed
179
def create_ccx(request, course, ccx=None):
180
    """
cewing committed
181
    Create a new CCX
182 183
    """
    name = request.POST.get('name')
184

185 186 187 188 189 190 191
    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)

192 193
    # prevent CCX objects from being created for deprecated course ids.
    if course.id.deprecated:
194
        messages.error(request, _(
195 196
            "You cannot create a CCX from a course using a deprecated id. "
            "Please create a rerun of this course in the studio to allow "
197
            "this action."))
198
        url = reverse('ccx_coach_dashboard', kwargs={'course_id': course.id})
199 200
        return redirect(url)

cewing committed
201
    ccx = CustomCourseForEdX(
202 203 204
        course_id=course.id,
        coach=request.user,
        display_name=name)
cewing committed
205
    ccx.save()
206 207

    # Make sure start/due are overridden for entire course
208
    start = TODAY().replace(tzinfo=pytz.UTC)
cewing committed
209 210
    override_field_for_ccx(ccx, course, 'start', start)
    override_field_for_ccx(ccx, course, 'due', None)
211

212 213 214
    # 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)

215 216 217
    # Hide anything that can show up in the schedule
    hidden = 'visible_to_staff_only'
    for chapter in course.get_children():
cewing committed
218
        override_field_for_ccx(ccx, chapter, hidden, True)
219
        for sequential in chapter.get_children():
cewing committed
220
            override_field_for_ccx(ccx, sequential, hidden, True)
221
            for vertical in sequential.get_children():
cewing committed
222
                override_field_for_ccx(ccx, vertical, hidden, True)
223

224
    ccx_id = CCXLocator.from_course_locator(course.id, unicode(ccx.id))
225

226 227 228 229 230
    # 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
231
    url = reverse('ccx_coach_dashboard', kwargs={'course_id': ccx_id})
232 233 234 235 236 237 238 239 240 241 242

    # 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,
    )

243
    assign_staff_role_to_ccx(ccx_id, request.user, course.id)
244
    add_master_course_staff_to_ccx(course, ccx_id, ccx.display_name)
245 246 247 248 249 250 251 252 253

    # 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)

254 255 256 257 258 259
    return redirect(url)


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@coach_dashboard
cewing committed
260
def save_ccx(request, course, ccx=None):
261
    """
cewing committed
262
    Save changes to CCX.
263
    """
cewing committed
264 265
    if not ccx:
        raise Http404
266

267
    def override_fields(parent, data, graded, earliest=None, ccx_ids_to_delete=None):
268
        """
cewing committed
269
        Recursively apply CCX schedule data to CCX by overriding the
270 271 272
        `visible_to_staff_only`, `start` and `due` fields for units in the
        course.
        """
273 274
        if ccx_ids_to_delete is None:
            ccx_ids_to_delete = []
275 276 277
        blocks = {
            str(child.location): child
            for child in parent.get_children()}
278

279 280
        for unit in data:
            block = blocks[unit['location']]
cewing committed
281 282
            override_field_for_ccx(
                ccx, block, 'visible_to_staff_only', unit['hidden'])
283

284 285 286 287
            start = parse_date(unit['start'])
            if start:
                if not earliest or start < earliest:
                    earliest = start
cewing committed
288
                override_field_for_ccx(ccx, block, 'start', start)
289
            else:
290 291 292
                ccx_ids_to_delete.append(get_override_for_ccx(ccx, block, 'start_id'))
                clear_ccx_field_info_from_ccx_map(ccx, block, 'start')

293 294 295 296 297 298 299 300
            # 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')
301
            else:
302
                # In case of section aka chapter we do not have due date.
303 304
                ccx_ids_to_delete.append(get_override_for_ccx(ccx, block, 'due_id'))
                clear_ccx_field_info_from_ccx_map(ccx, block, 'due')
305

306
            if not unit['hidden'] and block.graded:
307
                graded[block.format] = graded.get(block.format, 0) + 1
308

309
            children = unit.get('children', None)
310 311 312 313 314 315 316 317 318 319
            # 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)

320
            if children:
321 322
                override_fields(block, children, graded, earliest, ccx_ids_to_delete)
        return earliest, ccx_ids_to_delete
323

324
    graded = {}
325 326
    earliest, ccx_ids_to_delete = override_fields(course, json.loads(request.body), graded, [])
    bulk_delete_ccx_override_fields(ccx, ccx_ids_to_delete)
327
    if earliest:
cewing committed
328
        override_field_for_ccx(ccx, course, 'start', earliest)
329

330 331
    # Attempt to automatically adjust grading policy
    changed = False
cewing committed
332 333
    policy = get_override_for_ccx(
        ccx, course, 'grading_policy', course.grading_policy
334 335 336 337 338
    )
    policy = deepcopy(policy)
    grader = policy['GRADER']
    for section in grader:
        count = graded.get(section.get('type'), 0)
339
        if count < section.get('min_count', 0):
340 341 342
            changed = True
            section['min_count'] = count
    if changed:
cewing committed
343
        override_field_for_ccx(ccx, course, 'grading_policy', policy)
344

345 346 347 348 349 350 351 352
    # 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)

353
    return HttpResponse(
354
        json.dumps({
cewing committed
355
            'schedule': get_ccx_schedule(course, ccx),
356 357 358 359 360 361 362 363
            '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
364
def set_grading_policy(request, course, ccx=None):
365
    """
cewing committed
366
    Set grading policy for the CCX.
367
    """
cewing committed
368 369 370
    if not ccx:
        raise Http404

cewing committed
371 372
    override_field_for_ccx(
        ccx, course, 'grading_policy', json.loads(request.POST['policy']))
373

374 375 376 377 378 379 380 381
    # 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
382 383
    url = reverse(
        'ccx_coach_dashboard',
384
        kwargs={'course_id': CCXLocator.from_course_locator(course.id, unicode(ccx.id))}
cewing committed
385
    )
386
    return redirect(url)
387 388


cewing committed
389
def get_ccx_schedule(course, ccx):
390
    """
cewing committed
391
    Generate a JSON serializable CCX schedule.
392 393
    """
    def visit(node, depth=1):
394
        """
cewing committed
395
        Recursive generator function which yields CCX schedule nodes.
396 397
        We convert dates to string to get them ready for use by the js date
        widgets, which use text inputs.
398 399
        Visits students visible nodes only; nodes children of hidden ones
        are skipped as well.
400 401 402 403 404 405 406 407

        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.
408
        """
409
        for child in node.get_children():
410 411 412
            # in case the children are visible to staff only, skip them
            if child.visible_to_staff_only:
                continue
413

cewing committed
414 415
            hidden = get_override_for_ccx(
                ccx, child, 'visible_to_staff_only',
416
                child.visible_to_staff_only)
417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443

            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,
                }
444 445 446 447 448 449 450 451 452 453 454 455 456 457 458
            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
459
def ccx_schedule(request, course, ccx=None):  # pylint: disable=unused-argument
460 461 462
    """
    get json representation of ccx schedule
    """
cewing committed
463 464 465
    if not ccx:
        raise Http404

cewing committed
466
    schedule = get_ccx_schedule(course, ccx)
467
    json_schedule = json.dumps(schedule, indent=4)
468
    return HttpResponse(json_schedule, content_type='application/json')
469 470 471 472 473


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@coach_dashboard
cewing committed
474
def ccx_invite(request, course, ccx=None):
475
    """
cewing committed
476
    Invite users to new ccx
477
    """
cewing committed
478 479 480
    if not ccx:
        raise Http404

481 482 483
    action = request.POST.get('enrollment-button')
    identifiers_raw = request.POST.get('student-ids')
    identifiers = _split_input_list(identifiers_raw)
484
    email_students = 'email-students' in request.POST
485
    course_key = CCXLocator.from_course_locator(course.id, unicode(ccx.id))
486
    email_params = get_email_params(course, auto_enroll=True, course_key=course_key, display_name=ccx.display_name)
487

488
    ccx_students_enrolling_center(action, identifiers, email_students, course_key, email_params, ccx.coach)
489

490 491
    url = reverse('ccx_coach_dashboard', kwargs={'course_id': course_key})
    return redirect(url)
492 493


494 495 496
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@coach_dashboard
cewing committed
497
def ccx_student_management(request, course, ccx=None):
498 499
    """
    Manage the enrollment of individual students in a CCX
500
    """
cewing committed
501 502 503
    if not ccx:
        raise Http404

504 505
    action = request.POST.get('student-action', None)
    student_id = request.POST.get('student-id', '')
506 507
    email_students = 'email-students' in request.POST
    identifiers = [student_id]
508
    course_key = CCXLocator.from_course_locator(course.id, unicode(ccx.id))
509 510
    email_params = get_email_params(course, auto_enroll=True, course_key=course_key, display_name=ccx.display_name)

511
    errors = ccx_students_enrolling_center(action, identifiers, email_students, course_key, email_params, ccx.coach)
512 513

    for error_message in errors:
514
        messages.error(request, error_message)
515

516
    url = reverse('ccx_coach_dashboard', kwargs={'course_id': course_key})
517 518 519
    return redirect(url)


520 521
# Grades can potentially be written - if so, let grading manage the transaction.
@transaction.non_atomic_requests
522 523
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@coach_dashboard
cewing committed
524
def ccx_gradebook(request, course, ccx=None):
525
    """
cewing committed
526
    Show the gradebook for this CCX.
527
    """
cewing committed
528 529
    if not ccx:
        raise Http404
530

531
    ccx_key = CCXLocator.from_course_locator(course.id, unicode(ccx.id))
cewing committed
532 533
    with ccx_course(ccx_key) as course:
        prep_course_for_grading(course, request)
534
        student_info, page = get_grade_book_page(request, course, course_key=ccx_key)
535 536

        return render_to_response('courseware/gradebook.html', {
537 538
            'page': page,
            'page_url': reverse('ccx_gradebook', kwargs={'course_id': ccx_key}),
539 540 541 542 543 544 545
            '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),
        })
546 547


548 549
# Grades can potentially be written - if so, let grading manage the transaction.
@transaction.non_atomic_requests
550 551
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@coach_dashboard
cewing committed
552
def ccx_grades_csv(request, course, ccx=None):
553 554 555
    """
    Download grades as CSV.
    """
cewing committed
556 557 558
    if not ccx:
        raise Http404

559
    ccx_key = CCXLocator.from_course_locator(course.id, unicode(ccx.id))
cewing committed
560 561
    with ccx_course(ccx_key) as course:
        prep_course_for_grading(course, request)
562

563
        enrolled_students = User.objects.filter(
564 565
            courseenrollment__course_id=ccx_key,
            courseenrollment__is_active=1
566
        ).order_by('username').select_related("profile")
567
        grades = CourseGradeFactory().iter(course, enrolled_students)
568 569 570

        header = None
        rows = []
571 572
        for student, course_grade, __ in grades:
            if course_grade:
573 574 575 576 577 578
                # 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')
579
                              for section in course_grade.summary[u'section_breakdown']]
580 581 582 583
                    rows.append(["id", "email", "username", "grade"] + header)

                percents = {
                    section['label']: section.get('percent', 0.0)
584
                    for section in course_grade.summary[u'section_breakdown']
585 586 587 588 589
                    if 'label' in section
                }

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

592 593
        buf = StringIO()
        writer = csv.writer(buf)
594 595 596
        for row in rows:
            writer.writerow(row)

597 598 599 600
        response = HttpResponse(buf.getvalue(), content_type='text/csv')
        response['Content-Disposition'] = 'attachment'

        return response