views.py 19 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

cewing committed
11
from contextlib import contextmanager
12
from copy import deepcopy
13 14
from cStringIO import StringIO

15
from django.core.urlresolvers import reverse
16 17 18 19 20
from django.http import (
    HttpResponse,
    HttpResponseForbidden,
    HttpResponseRedirect,
)
21
from django.contrib import messages
22 23
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
cewing committed
24
from django.http import Http404
25 26 27
from django.shortcuts import redirect
from django.utils.translation import ugettext as _
from django.views.decorators.cache import cache_control
28
from django.views.decorators.csrf import ensure_csrf_cookie
29
from django.contrib.auth.decorators import login_required
30 31
from django.contrib.auth.models import User

32 33 34 35 36 37 38
from courseware.courses import get_course_by_id  # pylint: disable=import-error

from courseware.field_overrides import disable_overrides  # pylint: disable=import-error
from courseware.grades import iterate_grades_for  # pylint: disable=import-error
from courseware.model_data import FieldDataCache  # pylint: disable=import-error
from courseware.module_render import get_module_for_descriptor  # pylint: disable=import-error
from edxmako.shortcuts import render_to_response  # pylint: disable=import-error
39
from opaque_keys.edx.keys import CourseKey
cewing committed
40
from ccx_keys.locator import CCXLocator
41
from student.roles import CourseCcxCoachRole  # pylint: disable=import-error
42

43 44 45
from instructor.offline_gradecalc import student_grades  # pylint: disable=import-error
from instructor.views.api import _split_input_list  # pylint: disable=import-error
from instructor.views.tools import get_student_from_identifier  # pylint: disable=import-error
46

cewing committed
47
from .models import CustomCourseForEdX, CcxMembership
48
from .overrides import (
cewing committed
49 50 51
    clear_override_for_ccx,
    get_override_for_ccx,
    override_field_for_ccx,
52
)
53 54 55 56 57
from .utils import (
    enroll_email,
    unenroll_email,
)

58 59

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


def coach_dashboard(view):
    """
cewing committed
65
    View decorator which enforces that the user have the CCX coach role on the
66 67 68 69 70
    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):
71 72 73 74
        """
        Wraps the view function, performing access check, loading the course,
        and modifying the view's call signature.
        """
75
        course_key = CourseKey.from_string(course_id)
cewing committed
76 77 78 79 80 81
        ccx = None
        if isinstance(course_key, CCXLocator):
            ccx_id = course_key.ccx
            ccx = CustomCourseForEdX.objects.get(pk=ccx_id)
            course_key = ccx.course_id

cewing committed
82
        role = CourseCcxCoachRole(course_key)
83 84
        if not role.has_user(request.user):
            return HttpResponseForbidden(
cewing committed
85
                _('You must be a CCX Coach to access this view.'))
cewing committed
86

87
        course = get_course_by_id(course_key, depth=None)
88 89 90 91 92 93 94 95 96

        # if there is a ccx, we must validate that it is the ccx for this coach
        if ccx is not None:
            coach_ccx = get_ccx_for_coach(course, request.user)
            if coach_ccx is None or coach_ccx.id != ccx.id:
                return HttpResponseForbidden(
                    _('You must be the coach for this ccx to access this view')
                )

cewing committed
97
        return view(request, course, ccx)
98 99 100 101 102 103
    return wrapper


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@coach_dashboard
cewing committed
104
def dashboard(request, course, ccx=None):
105
    """
cewing committed
106
    Display the CCX Coach Dashboard.
107
    """
cewing committed
108 109 110 111 112
    # 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:
113 114 115 116
            url = reverse(
                'ccx_coach_dashboard',
                kwargs={'course_id': CCXLocator.from_course_locator(course.id, ccx.id)}
            )
cewing committed
117 118
            return redirect(url)

119 120
    context = {
        'course': course,
cewing committed
121
        'ccx': ccx,
122
    }
123

cewing committed
124
    if ccx:
cewing committed
125
        ccx_locator = CCXLocator.from_course_locator(course.id, ccx.id)
cewing committed
126 127 128
        schedule = get_ccx_schedule(course, ccx)
        grading_policy = get_override_for_ccx(
            ccx, course, 'grading_policy', course.grading_policy)
129 130
        context['schedule'] = json.dumps(schedule, indent=4)
        context['save_url'] = reverse(
cewing committed
131
            'save_ccx', kwargs={'course_id': ccx_locator})
cewing committed
132
        context['ccx_members'] = CcxMembership.objects.filter(ccx=ccx)
133
        context['gradebook_url'] = reverse(
cewing committed
134
            'ccx_gradebook', kwargs={'course_id': ccx_locator})
135
        context['grades_csv_url'] = reverse(
cewing committed
136
            'ccx_grades_csv', kwargs={'course_id': ccx_locator})
137 138
        context['grading_policy'] = json.dumps(grading_policy, indent=4)
        context['grading_policy_url'] = reverse(
cewing committed
139
            'ccx_set_grading_policy', kwargs={'course_id': ccx_locator})
140
    else:
cewing committed
141 142 143
        context['create_ccx_url'] = reverse(
            'create_ccx', kwargs={'course_id': course.id})
    return render_to_response('ccx/coach_dashboard.html', context)
144 145 146 147 148


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@coach_dashboard
cewing committed
149
def create_ccx(request, course, ccx=None):
150
    """
cewing committed
151
    Create a new CCX
152 153
    """
    name = request.POST.get('name')
154 155 156

    # prevent CCX objects from being created for deprecated course ids.
    if course.id.deprecated:
157
        messages.error(request, _(
158 159
            "You cannot create a CCX from a course using a deprecated id. "
            "Please create a rerun of this course in the studio to allow "
160
            "this action."))
161 162 163
        url = reverse('ccx_coach_dashboard', kwargs={'course_id', course.id})
        return redirect(url)

cewing committed
164
    ccx = CustomCourseForEdX(
165 166 167
        course_id=course.id,
        coach=request.user,
        display_name=name)
cewing committed
168
    ccx.save()
169 170

    # Make sure start/due are overridden for entire course
171
    start = TODAY().replace(tzinfo=pytz.UTC)
cewing committed
172 173
    override_field_for_ccx(ccx, course, 'start', start)
    override_field_for_ccx(ccx, course, 'due', None)
174 175 176 177

    # Hide anything that can show up in the schedule
    hidden = 'visible_to_staff_only'
    for chapter in course.get_children():
cewing committed
178
        override_field_for_ccx(ccx, chapter, hidden, True)
179
        for sequential in chapter.get_children():
cewing committed
180
            override_field_for_ccx(ccx, sequential, hidden, True)
181
            for vertical in sequential.get_children():
cewing committed
182
                override_field_for_ccx(ccx, vertical, hidden, True)
183

184
    ccx_id = CCXLocator.from_course_locator(course.id, ccx.id)  # pylint: disable=no-member
cewing committed
185
    url = reverse('ccx_coach_dashboard', kwargs={'course_id': ccx_id})
186 187 188 189 190 191
    return redirect(url)


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@coach_dashboard
cewing committed
192
def save_ccx(request, course, ccx=None):
193
    """
cewing committed
194
    Save changes to CCX.
195
    """
cewing committed
196 197
    if not ccx:
        raise Http404
198

199
    def override_fields(parent, data, graded, earliest=None):
200
        """
cewing committed
201
        Recursively apply CCX schedule data to CCX by overriding the
202 203 204
        `visible_to_staff_only`, `start` and `due` fields for units in the
        course.
        """
205 206 207 208 209
        blocks = {
            str(child.location): child
            for child in parent.get_children()}
        for unit in data:
            block = blocks[unit['location']]
cewing committed
210 211
            override_field_for_ccx(
                ccx, block, 'visible_to_staff_only', unit['hidden'])
212 213 214 215
            start = parse_date(unit['start'])
            if start:
                if not earliest or start < earliest:
                    earliest = start
cewing committed
216
                override_field_for_ccx(ccx, block, 'start', start)
217
            else:
cewing committed
218
                clear_override_for_ccx(ccx, block, 'start')
219 220
            due = parse_date(unit['due'])
            if due:
cewing committed
221
                override_field_for_ccx(ccx, block, 'due', due)
222
            else:
cewing committed
223
                clear_override_for_ccx(ccx, block, 'due')
224

225
            if not unit['hidden'] and block.graded:
226
                graded[block.format] = graded.get(block.format, 0) + 1
227

228 229
            children = unit.get('children', None)
            if children:
230
                override_fields(block, children, graded, earliest)
231 232
        return earliest

233 234
    graded = {}
    earliest = override_fields(course, json.loads(request.body), graded)
235
    if earliest:
cewing committed
236
        override_field_for_ccx(ccx, course, 'start', earliest)
237

238 239
    # Attempt to automatically adjust grading policy
    changed = False
cewing committed
240 241
    policy = get_override_for_ccx(
        ccx, course, 'grading_policy', course.grading_policy
242 243 244 245 246 247 248 249 250
    )
    policy = deepcopy(policy)
    grader = policy['GRADER']
    for section in grader:
        count = graded.get(section.get('type'), 0)
        if count < section['min_count']:
            changed = True
            section['min_count'] = count
    if changed:
cewing committed
251
        override_field_for_ccx(ccx, course, 'grading_policy', policy)
252

253
    return HttpResponse(
254
        json.dumps({
cewing committed
255
            'schedule': get_ccx_schedule(course, ccx),
256 257 258 259 260 261 262 263
            '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
264
def set_grading_policy(request, course, ccx=None):
265
    """
cewing committed
266
    Set grading policy for the CCX.
267
    """
cewing committed
268 269 270
    if not ccx:
        raise Http404

cewing committed
271 272
    override_field_for_ccx(
        ccx, course, 'grading_policy', json.loads(request.POST['policy']))
273

cewing committed
274 275 276 277
    url = reverse(
        'ccx_coach_dashboard',
        kwargs={'course_id': CCXLocator.from_course_locator(course.id, ccx.id)}
    )
278
    return redirect(url)
279 280


281
def validate_date(year, month, day, hour, minute):
282 283 284
    """
    avoid corrupting db if bad dates come in
    """
285 286 287 288 289 290 291 292 293 294 295 296 297 298
    valid = True
    if year < 0:
        valid = False
    if month < 1 or month > 12:
        valid = False
    if day < 1 or day > 31:
        valid = False
    if hour < 0 or hour > 23:
        valid = False
    if minute < 0 or minute > 59:
        valid = False
    return valid


299 300 301 302 303 304 305 306 307
def parse_date(datestring):
    """
    Generate a UTC datetime.datetime object from a string of the form
    'YYYY-MM-DD HH:MM'.  If string is empty or `None`, returns `None`.
    """
    if datestring:
        date, time = datestring.split(' ')
        year, month, day = map(int, date.split('-'))
        hour, minute = map(int, time.split(':'))
308 309 310
        if validate_date(year, month, day, hour, minute):
            return datetime.datetime(
                year, month, day, hour, minute, tzinfo=pytz.UTC)
311 312 313 314

    return None


cewing committed
315
def get_ccx_for_coach(course, coach):
316
    """
cewing committed
317
    Looks to see if user is coach of a CCX for this course.  Returns the CCX or
318 319
    None.
    """
cewing committed
320 321 322 323 324 325 326 327 328
    ccxs = CustomCourseForEdX.objects.filter(
        course_id=course.id,
        coach=coach
    )
    # XXX: In the future, it would be nice to support more than one ccx per
    # coach per course.  This is a place where that might happen.
    if ccxs.exists():
        return ccxs[0]
    return None
329 330


cewing committed
331
def get_ccx_schedule(course, ccx):
332
    """
cewing committed
333
    Generate a JSON serializable CCX schedule.
334 335
    """
    def visit(node, depth=1):
336
        """
cewing committed
337
        Recursive generator function which yields CCX schedule nodes.
338 339
        We convert dates to string to get them ready for use by the js date
        widgets, which use text inputs.
340
        """
341
        for child in node.get_children():
cewing committed
342
            start = get_override_for_ccx(ccx, child, 'start', None)
343 344
            if start:
                start = str(start)[:-9]
cewing committed
345
            due = get_override_for_ccx(ccx, child, 'due', None)
346 347
            if due:
                due = str(due)[:-9]
cewing committed
348 349
            hidden = get_override_for_ccx(
                ccx, child, 'visible_to_staff_only',
350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373
                child.visible_to_staff_only)
            visited = {
                'location': str(child.location),
                'display_name': child.display_name,
                'category': child.category,
                'start': start,
                'due': due,
                'hidden': hidden,
            }
            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
374
def ccx_schedule(request, course, ccx=None):  # pylint: disable=unused-argument
375 376 377
    """
    get json representation of ccx schedule
    """
cewing committed
378 379 380
    if not ccx:
        raise Http404

cewing committed
381
    schedule = get_ccx_schedule(course, ccx)
382 383 384 385 386 387 388
    json_schedule = json.dumps(schedule, indent=4)
    return HttpResponse(json_schedule, mimetype='application/json')


@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@coach_dashboard
cewing committed
389
def ccx_invite(request, course, ccx=None):
390
    """
cewing committed
391
    Invite users to new ccx
392
    """
cewing committed
393 394 395
    if not ccx:
        raise Http404

396 397 398
    action = request.POST.get('enrollment-button')
    identifiers_raw = request.POST.get('student-ids')
    identifiers = _split_input_list(identifiers_raw)
399 400
    auto_enroll = True if 'auto-enroll' in request.POST else False
    email_students = True if 'email-students' in request.POST else False
401 402 403 404 405 406 407 408 409 410 411 412
    for identifier in identifiers:
        user = None
        email = None
        try:
            user = get_student_from_identifier(identifier)
        except User.DoesNotExist:
            email = identifier
        else:
            email = user.email
        try:
            validate_email(email)
            if action == 'Enroll':
413
                enroll_email(
cewing committed
414
                    ccx,
415 416 417 418
                    email,
                    auto_enroll=auto_enroll,
                    email_students=email_students
                )
419
            if action == "Unenroll":
cewing committed
420
                unenroll_email(ccx, email, email_students=email_students)
421
        except ValidationError:
422
            log.info('Invalid user name or email when trying to invite students: %s', email)
cewing committed
423 424 425 426
    url = reverse(
        'ccx_coach_dashboard',
        kwargs={'course_id': CCXLocator.from_course_locator(course.id, ccx.id)}
    )
427
    return redirect(url)
428 429


430 431 432
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@coach_dashboard
cewing committed
433
def ccx_student_management(request, course, ccx=None):
cewing committed
434
    """Manage the enrollment of individual students in a CCX
435
    """
cewing committed
436 437 438
    if not ccx:
        raise Http404

439 440 441 442 443 444 445 446 447 448 449 450 451 452
    action = request.POST.get('student-action', None)
    student_id = request.POST.get('student-id', '')
    user = email = None
    try:
        user = get_student_from_identifier(student_id)
    except User.DoesNotExist:
        email = student_id
    else:
        email = user.email

    try:
        validate_email(email)
        if action == 'add':
            # by decree, no emails sent to students added this way
453
            # by decree, any students added this way are auto_enrolled
cewing committed
454
            enroll_email(ccx, email, auto_enroll=True, email_students=False)
455
        elif action == 'revoke':
cewing committed
456
            unenroll_email(ccx, email, email_students=False)
457
    except ValidationError:
458
        log.info('Invalid user name or email when trying to enroll student: %s', email)
459

cewing committed
460 461 462 463
    url = reverse(
        'ccx_coach_dashboard',
        kwargs={'course_id': CCXLocator.from_course_locator(course.id, ccx.id)}
    )
464 465 466
    return redirect(url)


cewing committed
467 468
@contextmanager
def ccx_course(ccx_locator):
469 470
    """Create a context in which the course identified by course_locator exists
    """
cewing committed
471 472 473 474 475 476 477 478 479
    course = get_course_by_id(ccx_locator)
    yield course


def prep_course_for_grading(course, request):
    """Set up course module for overrides to function properly"""
    field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
        course.id, request.user, course, depth=2)
    course = get_module_for_descriptor(
480 481
        request.user, request, course, field_data_cache, course.id, course=course
    )
cewing committed
482 483 484 485 486

    course._field_data_cache = {}  # pylint: disable=protected-access
    course.set_grading_policy(course.grading_policy)


487 488
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@coach_dashboard
cewing committed
489
def ccx_gradebook(request, course, ccx=None):
490
    """
cewing committed
491
    Show the gradebook for this CCX.
492
    """
cewing committed
493 494
    if not ccx:
        raise Http404
495

cewing committed
496 497 498
    ccx_key = CCXLocator.from_course_locator(course.id, ccx.id)
    with ccx_course(ccx_key) as course:
        prep_course_for_grading(course, request)
499

500
        enrolled_students = User.objects.filter(
cewing committed
501 502
            ccxmembership__ccx=ccx,
            ccxmembership__active=1
503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523
        ).order_by('username').select_related("profile")

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

        return render_to_response('courseware/gradebook.html', {
            'students': student_info,
            'course': course,
            'course_id': course.id,
            'staff_access': request.user.is_staff,
            'ordered_grades': sorted(
                course.grade_cutoffs.items(), key=lambda i: i[1], reverse=True),
        })
524 525 526 527


@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@coach_dashboard
cewing committed
528
def ccx_grades_csv(request, course, ccx=None):
529 530 531
    """
    Download grades as CSV.
    """
cewing committed
532 533 534 535 536 537
    if not ccx:
        raise Http404

    ccx_key = CCXLocator.from_course_locator(course.id, ccx.id)
    with ccx_course(ccx_key) as course:
        prep_course_for_grading(course, request)
538

539
        enrolled_students = User.objects.filter(
cewing committed
540 541
            ccxmembership__ccx=ccx,
            ccxmembership__active=1
542 543 544 545 546
        ).order_by('username').select_related("profile")
        grades = iterate_grades_for(course, enrolled_students)

        header = None
        rows = []
547
        for student, gradeset, __ in grades:
548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567
            if gradeset:
                # 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')
                              for section in gradeset[u'section_breakdown']]
                    rows.append(["id", "email", "username", "grade"] + header)

                percents = {
                    section['label']: section.get('percent', 0.0)
                    for section in gradeset[u'section_breakdown']
                    if 'label' in section
                }

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

568 569
        buf = StringIO()
        writer = csv.writer(buf)
570 571 572
        for row in rows:
            writer.writerow(row)

573
        return HttpResponse(buf.getvalue(), content_type='text/plain')