diff --git a/lms/djangoapps/ccx/utils.py b/lms/djangoapps/ccx/utils.py index 8b9d80d919..e2556a51ca 100644 --- a/lms/djangoapps/ccx/utils.py +++ b/lms/djangoapps/ccx/utils.py @@ -3,14 +3,43 @@ CCX Enrollment operations for use by Coach APIs. Does not include any access control, be sure to check access before calling. """ +import datetime import logging +import pytz + +from contextlib import contextmanager + +from django.contrib.auth.models import User +from django.core.exceptions import ValidationError +from django.utils.translation import ugettext as _ +from django.core.validators import validate_email +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview + +from courseware.courses import get_course_by_id +from courseware.model_data import FieldDataCache +from courseware.module_render import get_module_for_descriptor +from instructor.enrollment import ( + enroll_email, + unenroll_email, +) +from instructor.access import allow_access +from instructor.views.tools import get_student_from_identifier +from student.models import CourseEnrollment +from student.roles import CourseCcxCoachRole from lms.djangoapps.ccx.models import CustomCourseForEdX - +from lms.djangoapps.ccx.overrides import get_override_for_ccx log = logging.getLogger("edx.ccx") +class CCXUserValidationException(Exception): + """ + Custom Exception for validation of users in CCX + """ + pass + + def get_ccx_from_ccx_locator(course_id): """ helper function to allow querying ccx fields from templates """ ccx_id = getattr(course_id, 'ccx', None) @@ -24,3 +53,206 @@ def get_ccx_from_ccx_locator(course_id): ) return None return ccx[0] + + +def get_date(ccx, node, date_type=None, parent_node=None): + """ + This returns override or master date for section, subsection or a unit. + + :param ccx: ccx instance + :param node: chapter, subsection or unit + :param date_type: start or due + :param parent_node: parent of node + :return: start or due date + """ + date = get_override_for_ccx(ccx, node, date_type, None) + if date_type == "start": + master_date = node.start + else: + master_date = node.due + + if date is not None: + # Setting override date [start or due] + date = date.strftime('%Y-%m-%d %H:%M') + elif not parent_node and master_date is not None: + # Setting date from master course + date = master_date.strftime('%Y-%m-%d %H:%M') + elif parent_node is not None: + # Set parent date (vertical has same dates as subsections) + date = get_date(ccx, node=parent_node, date_type=date_type) + + return date + + +def validate_date(year, month, day, hour, minute): + """ + avoid corrupting db if bad dates come in + """ + 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 + + +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(':')) + if validate_date(year, month, day, hour, minute): + return datetime.datetime( + year, month, day, hour, minute, tzinfo=pytz.UTC) + + return None + + +def get_ccx_for_coach(course, coach): + """ + Looks to see if user is coach of a CCX for this course. Returns the CCX or + None. + """ + 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 + + +def get_valid_student_email(identifier): + """ + Helper function to get an user email from an identifier and validate it. + + In the UI a Coach can enroll users using both an email and an username. + This function takes care of: + - in case the identifier is an username, extracting the user object from + the DB and then the associated email + - validating the email + + Arguments: + identifier (str): Username or email of the user to enroll + + Returns: + str: A validated email for the user to enroll + + Raises: + CCXUserValidationException: if the username is not found or the email + is not valid. + """ + user = email = None + try: + user = get_student_from_identifier(identifier) + except User.DoesNotExist: + email = identifier + else: + email = user.email + try: + validate_email(email) + except ValidationError: + raise CCXUserValidationException('Could not find a user with name or email "{0}" '.format(identifier)) + return email + + +def ccx_students_enrolling_center(action, identifiers, email_students, course_key, email_params): + """ + Function to enroll/add or unenroll/revoke students. + + This function exists for backwards compatibility: in CCX there are + two different views to manage students that used to implement + a different logic. Now the logic has been reconciled at the point that + this function can be used by both. + The two different views can be merged after some UI refactoring. + + Arguments: + action (str): type of action to perform (add, Enroll, revoke, Unenroll) + identifiers (list): list of students username/email + email_students (bool): Flag to send an email to students + course_key (CCXLocator): a CCX course key + email_params (dict): dictionary of settings for the email to be sent + + Returns: + list: list of error + """ + errors = [] + + if action == 'Enroll' or action == 'add': + ccx_course_overview = CourseOverview.get_from_id(course_key) + for identifier in identifiers: + if CourseEnrollment.objects.is_course_full(ccx_course_overview): + error = _('The course is full: the limit is {max_student_enrollments_allowed}').format( + max_student_enrollments_allowed=ccx_course_overview.max_student_enrollments_allowed) + log.info("%s", error) + errors.append(error) + break + try: + email = get_valid_student_email(identifier) + except CCXUserValidationException as exp: + log.info("%s", exp) + errors.append("{0}".format(exp)) + continue + enroll_email(course_key, email, auto_enroll=True, email_students=email_students, email_params=email_params) + elif action == 'Unenroll' or action == 'revoke': + for identifier in identifiers: + try: + email = get_valid_student_email(identifier) + except CCXUserValidationException as exp: + log.info("%s", exp) + errors.append("{0}".format(exp)) + continue + unenroll_email(course_key, email, email_students=email_students, email_params=email_params) + return errors + + +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( + request.user, request, course, field_data_cache, course.id, course=course + ) + + course._field_data_cache = {} # pylint: disable=protected-access + course.set_grading_policy(course.grading_policy) + + +@contextmanager +def ccx_course(ccx_locator): + """Create a context in which the course identified by course_locator exists + """ + course = get_course_by_id(ccx_locator) + yield course + + +def assign_coach_role_to_ccx(ccx_locator, user, master_course_id): + """ + Check if user has ccx_coach role on master course then assign him coach role on ccx only + if role is not already assigned. Because of this coach can open dashboard from master course + as well as ccx. + :param ccx_locator: CCX key + :param user: User to whom we want to assign role. + :param master_course_id: Master course key + """ + coach_role_on_master_course = CourseCcxCoachRole(master_course_id) + # check if user has coach role on master course + if coach_role_on_master_course.has_user(user): + # Check if user has coach role on ccx. + role = CourseCcxCoachRole(ccx_locator) + if not role.has_user(user): + # assign user role coach on ccx + with ccx_course(ccx_locator) as course: + allow_access(course, user, "ccx_coach", send_email=False) diff --git a/lms/djangoapps/ccx/views.py b/lms/djangoapps/ccx/views.py index d8db58e3d3..9f9b449d6e 100644 --- a/lms/djangoapps/ccx/views.py +++ b/lms/djangoapps/ccx/views.py @@ -8,7 +8,6 @@ import json import logging import pytz -from contextlib import contextmanager from copy import deepcopy from cStringIO import StringIO @@ -19,8 +18,6 @@ from django.http import ( HttpResponseForbidden, ) from django.contrib import messages -from django.core.exceptions import ValidationError -from django.core.validators import validate_email from django.db import transaction from django.http import Http404 from django.shortcuts import redirect @@ -33,19 +30,14 @@ from courseware.courses import get_course_by_id from courseware.field_overrides import disable_overrides from courseware.grades import iterate_grades_for -from courseware.model_data import FieldDataCache -from courseware.module_render import get_module_for_descriptor from edxmako.shortcuts import render_to_response from opaque_keys.edx.keys import CourseKey -from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from ccx_keys.locator import CCXLocator from student.roles import CourseCcxCoachRole from student.models import CourseEnrollment -from instructor.access import allow_access from instructor.views.api import _split_input_list from instructor.views.gradebook_api import get_grade_book_page -from instructor.views.tools import get_student_from_identifier from instructor.enrollment import ( enroll_email, unenroll_email, @@ -59,18 +51,20 @@ from lms.djangoapps.ccx.overrides import ( clear_ccx_field_info_from_ccx_map, bulk_delete_ccx_override_fields, ) +from lms.djangoapps.ccx.utils import ( + assign_coach_role_to_ccx, + ccx_course, + ccx_students_enrolling_center, + get_ccx_for_coach, + get_date, + parse_date, + prep_course_for_grading, +) log = logging.getLogger(__name__) TODAY = datetime.datetime.today # for patching in tests -class CCXUserValidationException(Exception): - """ - Custom Exception for validation of users in CCX - """ - pass - - def coach_dashboard(view): """ View decorator which enforces that the user have the CCX coach role on the @@ -218,26 +212,6 @@ def create_ccx(request, course, ccx=None): return redirect(url) -def assign_coach_role_to_ccx(ccx_locator, user, master_course_id): - """ - Check if user has ccx_coach role on master course then assign him coach role on ccx only - if role is not already assigned. Because of this coach can open dashboard from master course - as well as ccx. - :param ccx_locator: CCX key - :param user: User to whom we want to assign role. - :param master_course_id: Master course key - """ - coach_role_on_master_course = CourseCcxCoachRole(master_course_id) - # check if user has coach role on master course - if coach_role_on_master_course.has_user(user): - # Check if user has coach role on ccx. - role = CourseCcxCoachRole(ccx_locator) - if not role.has_user(user): - # assign user role coach on ccx - with ccx_course(ccx_locator) as course: - allow_access(course, user, "ccx_coach", send_email=False) - - @ensure_csrf_cookie @cache_control(no_cache=True, no_store=True, must_revalidate=True) @coach_dashboard @@ -354,85 +328,6 @@ def set_grading_policy(request, course, ccx=None): return redirect(url) -def validate_date(year, month, day, hour, minute): - """ - avoid corrupting db if bad dates come in - """ - 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 - - -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(':')) - if validate_date(year, month, day, hour, minute): - return datetime.datetime( - year, month, day, hour, minute, tzinfo=pytz.UTC) - - return None - - -def get_ccx_for_coach(course, coach): - """ - Looks to see if user is coach of a CCX for this course. Returns the CCX or - None. - """ - 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 - - -def get_date(ccx, node, date_type=None, parent_node=None): - """ - This returns override or master date for section, subsection or a unit. - - :param ccx: ccx instance - :param node: chapter, subsection or unit - :param date_type: start or due - :param parent_node: parent of node - :return: start or due date - """ - date = get_override_for_ccx(ccx, node, date_type, None) - if date_type == "start": - master_date = node.start - else: - master_date = node.due - - if date is not None: - # Setting override date [start or due] - date = date.strftime('%Y-%m-%d %H:%M') - elif not parent_node and master_date is not None: - # Setting date from master course - date = master_date.strftime('%Y-%m-%d %H:%M') - elif parent_node is not None: - # Set parent date (vertical has same dates as subsections) - date = get_date(ccx, node=parent_node, date_type=date_type) - - return date - - def get_ccx_schedule(course, ccx): """ Generate a JSON serializable CCX schedule. @@ -515,90 +410,6 @@ def ccx_schedule(request, course, ccx=None): # pylint: disable=unused-argument return HttpResponse(json_schedule, content_type='application/json') -def get_valid_student_email(identifier): - """ - Helper function to get an user email from an identifier and validate it. - - In the UI a Coach can enroll users using both an email and an username. - This function takes care of: - - in case the identifier is an username, extracting the user object from - the DB and then the associated email - - validating the email - - Arguments: - identifier (str): Username or email of the user to enroll - - Returns: - str: A validated email for the user to enroll - - Raises: - CCXUserValidationException: if the username is not found or the email - is not valid. - """ - user = email = None - try: - user = get_student_from_identifier(identifier) - except User.DoesNotExist: - email = identifier - else: - email = user.email - try: - validate_email(email) - except ValidationError: - raise CCXUserValidationException('Could not find a user with name or email "{0}" '.format(identifier)) - return email - - -def _ccx_students_enrrolling_center(action, identifiers, email_students, course_key, email_params): - """ - Function to enroll/add or unenroll/revoke students. - - This function exists for backwards compatibility: in CCX there are - two different views to manage students that used to implement - a different logic. Now the logic has been reconciled at the point that - this function can be used by both. - The two different views can be merged after some UI refactoring. - - Arguments: - action (str): type of action to perform (add, Enroll, revoke, Unenroll) - identifiers (list): list of students username/email - email_students (bool): Flag to send an email to students - course_key (CCXLocator): a CCX course key - email_params (dict): dictionary of settings for the email to be sent - - Returns: - list: list of error - """ - errors = [] - - if action == 'Enroll' or action == 'add': - ccx_course_overview = CourseOverview.get_from_id(course_key) - for identifier in identifiers: - if CourseEnrollment.objects.is_course_full(ccx_course_overview): - error = ('The course is full: the limit is {0}'.format( - ccx_course_overview.max_student_enrollments_allowed)) - log.info("%s", error) - errors.append(error) - break - try: - email = get_valid_student_email(identifier) - except CCXUserValidationException as exp: - log.info("%s", exp) - errors.append("{0}".format(exp)) - continue - enroll_email(course_key, email, auto_enroll=True, email_students=email_students, email_params=email_params) - elif action == 'Unenroll' or action == 'revoke': - for identifier in identifiers: - try: - email = get_valid_student_email(identifier) - except CCXUserValidationException as exp: - log.info("%s", exp) - errors.append("{0}".format(exp)) - continue - unenroll_email(course_key, email, email_students=email_students, email_params=email_params) - return errors - - @ensure_csrf_cookie @cache_control(no_cache=True, no_store=True, must_revalidate=True) @coach_dashboard @@ -616,7 +427,7 @@ def ccx_invite(request, course, ccx=None): course_key = CCXLocator.from_course_locator(course.id, ccx.id) email_params = get_email_params(course, auto_enroll=True, course_key=course_key, display_name=ccx.display_name) - _ccx_students_enrrolling_center(action, identifiers, email_students, course_key, email_params) + ccx_students_enrolling_center(action, identifiers, email_students, course_key, email_params) url = reverse('ccx_coach_dashboard', kwargs={'course_id': course_key}) return redirect(url) @@ -639,7 +450,7 @@ def ccx_student_management(request, course, ccx=None): course_key = CCXLocator.from_course_locator(course.id, ccx.id) email_params = get_email_params(course, auto_enroll=True, course_key=course_key, display_name=ccx.display_name) - errors = _ccx_students_enrrolling_center(action, identifiers, email_students, course_key, email_params) + errors = ccx_students_enrolling_center(action, identifiers, email_students, course_key, email_params) for error_message in errors: messages.error(request, error_message) @@ -648,26 +459,6 @@ def ccx_student_management(request, course, ccx=None): return redirect(url) -@contextmanager -def ccx_course(ccx_locator): - """Create a context in which the course identified by course_locator exists - """ - 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( - request.user, request, course, field_data_cache, course.id, course=course - ) - - course._field_data_cache = {} # pylint: disable=protected-access - course.set_grading_policy(course.grading_policy) - - # Grades can potentially be written - if so, let grading manage the transaction. @transaction.non_atomic_requests @cache_control(no_cache=True, no_store=True, must_revalidate=True)