Files
edx-platform/lms/djangoapps/ccx/utils.py
2024-09-05 15:32:06 +03:00

442 lines
16 KiB
Python

"""
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
from contextlib import contextmanager
from smtplib import SMTPException
import pytz
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.urls import reverse
from django.utils.translation import gettext as _
from common.djangoapps.student.models import CourseEnrollment, CourseEnrollmentException
from common.djangoapps.student.roles import CourseCcxCoachRole, CourseInstructorRole, CourseStaffRole
from lms.djangoapps.ccx.custom_exception import CCXUserValidationException
from lms.djangoapps.ccx.models import CustomCourseForEdX
from lms.djangoapps.ccx.overrides import get_override_for_ccx
from lms.djangoapps.instructor.access import allow_access, list_with_level, revoke_access
from lms.djangoapps.instructor.enrollment import enroll_email, get_email_params, unenroll_email
from lms.djangoapps.instructor.views.api import _split_input_list
from lms.djangoapps.instructor.views.tools import get_student_from_identifier
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.lib.courses import get_course_by_id
log = logging.getLogger("edx.ccx")
def get_ccx_creation_dict(course):
"""
Return dict of rendering create ccx form.
Arguments:
course (CourseBlockWithMixins): An edx course
Returns:
dict: A attribute dict for view rendering
"""
context = {
'course': course,
'create_ccx_url': reverse('create_ccx', kwargs={'course_id': course.id}),
'has_ccx_connector': "true" if hasattr(course, 'ccx_connector') and course.ccx_connector else "false",
'use_ccx_con_error_message': _(
"A CCX can only be created on this course through an external service."
" Contact a course admin to give you access."
)
}
return context
def get_ccx_from_ccx_locator(course_id):
""" helper function to allow querying ccx fields from templates """
ccx_id = getattr(course_id, 'ccx', None)
ccx = None
if ccx_id:
ccx = CustomCourseForEdX.objects.filter(id=ccx_id)
if not ccx:
log.warning(
"CCX does not exist for course with id %s",
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 get_enrollment_action_and_identifiers(request):
"""
Extracts action type and student identifiers from the request
on Enrollment tab of CCX Dashboard.
"""
action, identifiers = None, None
student_action = request.POST.get('student-action', None)
batch_action = request.POST.get('enrollment-button', None)
if student_action:
action = student_action
student_id = request.POST.get('student-id', '')
identifiers = [student_id]
elif batch_action:
action = batch_action
identifiers_raw = request.POST.get('student-ids')
identifiers = _split_input_list(identifiers_raw)
return action, identifiers
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 = list(map(int, date.split('-')))
hour, minute = list(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_ccx_by_ccx_id(course, coach, ccx_id):
"""
Finds a CCX of given coach on given master course.
Arguments:
course (CourseBlock): Master course
coach (User): Coach to ccx
ccx_id (long): Id of ccx
Returns:
ccx (CustomCourseForEdX): Instance of CCX.
"""
try:
ccx = CustomCourseForEdX.objects.get(
id=ccx_id,
course_id=course.id,
coach=coach
)
except CustomCourseForEdX.DoesNotExist:
return None
return ccx
def get_valid_student_with_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:
(tuple): tuple containing:
email (str): A validated email for the user to enroll.
user (User): A valid User object or None.
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(f'Could not find a user with name or email "{identifier}" ') # lint-amnesty, pylint: disable=raise-missing-from
return email, user
def ccx_students_enrolling_center(action, identifiers, email_students, course_key, email_params, coach):
"""
Function to enroll or unenroll/revoke students.
Arguments:
action (str): type of action to perform (Enroll, Unenroll/revoke)
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
coach (User): ccx coach
Returns:
list: list of error
"""
errors = []
if action == 'Enroll':
ccx_course_overview = CourseOverview.get_from_id(course_key)
course_locator = course_key.to_course_locator()
staff = CourseStaffRole(course_locator).users_with_role()
admins = CourseInstructorRole(course_locator).users_with_role()
for identifier in identifiers:
must_enroll = False
try:
email, student = get_valid_student_with_email(identifier)
if student:
must_enroll = student in staff or student in admins or student == coach
except CCXUserValidationException as exp:
log.info("%s", exp)
errors.append(f"{exp}")
continue
if CourseEnrollment.objects.is_course_full(ccx_course_overview) and not must_enroll:
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
enroll_email(
course_key,
email,
auto_enroll=True,
message_students=email_students,
message_params=email_params
)
elif action == 'Unenroll' or action == 'revoke': # lint-amnesty, pylint: disable=consider-using-in
for identifier in identifiers:
try:
email, __ = get_valid_student_with_email(identifier)
except CCXUserValidationException as exp:
log.info("%s", exp)
errors.append(f"{exp}")
continue
unenroll_email(course_key, email, message_students=email_students, message_params=email_params)
return errors
@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_staff_role_to_ccx(ccx_locator, user, master_course_id):
"""
Check if user has ccx_coach role on master course then assign them staff 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 staff role on ccx.
role = CourseStaffRole(ccx_locator)
if not role.has_user(user):
# assign user the staff role on ccx
with ccx_course(ccx_locator) as course:
allow_access(course, user, "staff", send_email=False)
def is_email(identifier):
"""
Checks if an `identifier` string is a valid email
"""
try:
validate_email(identifier)
except ValidationError:
return False
return True
def add_master_course_staff_to_ccx(master_course, ccx_key, display_name, send_email=True):
"""
Add staff and instructor roles on ccx to all the staff and instructors members of master course.
Arguments:
master_course (CourseBlockWithMixins): Master course instance.
ccx_key (CCXLocator): CCX course key.
display_name (str): ccx display name for email.
send_email (bool): flag to switch on or off email to the users on access grant.
"""
list_staff = list_with_level(master_course.id, 'staff')
list_instructor = list_with_level(master_course.id, 'instructor')
with ccx_course(ccx_key) as course_ccx:
email_params = get_email_params(course_ccx, auto_enroll=True, course_key=ccx_key, display_name=display_name)
list_staff_ccx = list_with_level(course_ccx.id, 'staff')
list_instructor_ccx = list_with_level(course_ccx.id, 'instructor')
for staff in list_staff:
# this call should be idempotent
if staff not in list_staff_ccx:
try:
# Enroll the staff in the ccx
enroll_email(
course_id=ccx_key,
student_email=staff.email,
auto_enroll=True,
message_students=send_email,
message_params=email_params,
)
# allow 'staff' access on ccx to staff of master course
allow_access(course_ccx, staff, 'staff')
except CourseEnrollmentException:
log.warning(
"Unable to enroll staff %s to course with id %s",
staff.email,
ccx_key
)
continue
except SMTPException:
continue
for instructor in list_instructor:
# this call should be idempotent
if instructor not in list_instructor_ccx:
try:
# Enroll the instructor in the ccx
enroll_email(
course_id=ccx_key,
student_email=instructor.email,
auto_enroll=True,
message_students=send_email,
message_params=email_params,
)
# allow 'instructor' access on ccx to instructor of master course
allow_access(course_ccx, instructor, 'instructor')
except CourseEnrollmentException:
log.warning(
"Unable to enroll instructor %s to course with id %s",
instructor.email,
ccx_key
)
continue
except SMTPException:
continue
def remove_master_course_staff_from_ccx(master_course, ccx_key, display_name, send_email=True):
"""
Remove staff and instructor roles on ccx to all the staff and instructors members of master course.
Arguments:
master_course (CourseBlockWithMixins): Master course instance.
ccx_key (CCXLocator): CCX course key.
display_name (str): ccx display name for email.
send_email (bool): flag to switch on or off email to the users on revoke access.
"""
list_staff = list_with_level(master_course.id, 'staff')
list_instructor = list_with_level(master_course.id, 'instructor')
with ccx_course(ccx_key) as course_ccx:
list_staff_ccx = list_with_level(course_ccx.id, 'staff')
list_instructor_ccx = list_with_level(course_ccx.id, 'instructor')
email_params = get_email_params(course_ccx, auto_enroll=True, course_key=ccx_key, display_name=display_name)
for staff in list_staff:
if staff in list_staff_ccx:
# revoke 'staff' access on ccx.
revoke_access(course_ccx, staff, 'staff')
# Unenroll the staff on ccx.
unenroll_email(
course_id=ccx_key,
student_email=staff.email,
message_students=send_email,
message_params=email_params,
)
for instructor in list_instructor:
if instructor in list_instructor_ccx:
# revoke 'instructor' access on ccx.
revoke_access(course_ccx, instructor, 'instructor')
# Unenroll the instructor on ccx.
unenroll_email(
course_id=ccx_key,
student_email=instructor.email,
message_students=send_email,
message_params=email_params,
)