Files
edx-platform/lms/djangoapps/teams/csv.py
Jansen Kantor d4d6b33272 EDUCATOR-4879: Implement teams CSV GET endpoint (#23005)
* wip

* get

* quality

* change oauth2 to deprecated?

* quality

* remove duplicate enrollment check function

* code review
2020-02-06 15:06:23 -05:00

321 lines
12 KiB
Python

"""
CSV processing and generation utilities for Teams LMS app.
"""
import csv
from django.contrib.auth.models import User
from lms.djangoapps.teams.models import CourseTeam, CourseTeamMembership
from student.models import CourseEnrollment
from .utils import emit_team_event
def load_team_membership_csv(course, response):
"""
Load a CSV detailing course membership.
Arguments:
course (CourseDescriptor): Course module for which CSV
download has been requested.
response (HttpResponse): Django response object to which
the CSV content will be written.
"""
headers = _get_team_membership_csv_headers(course)
writer = csv.DictWriter(response, fieldnames=headers, extrasaction="ignore")
writer.writeheader()
team_membership_data = _lookup_team_membership_data(course)
writer.writerows(team_membership_data)
def _get_team_membership_csv_headers(course):
"""
Get headers for team membership csv.
['user', 'mode', <teamset_id_1>, ..., ,<teamset_id_n>]
"""
headers = ['user', 'mode']
for teamset in sorted(course.teams_configuration.teamsets, key=lambda ts: ts.teamset_id):
headers.append(teamset.teamset_id)
return headers
def _lookup_team_membership_data(course):
"""
Returns a list of dicts, in the following form:
[
{
'user': <username>,
'mode': <student enrollment mode for the given course>,
<teamset id>: <team name> for each teamset in which the given user is on a team
}
for student in course
]
"""
course_students = CourseEnrollment.objects.users_enrolled_in(course.id).order_by('username')
CourseEnrollment.bulk_fetch_enrollment_states(course_students, course.id)
course_team_memberships = CourseTeamMembership.objects.filter(
team__course_id=course.id
).select_related('team', 'user').all()
teamset_memberships_by_user = _group_teamset_memberships_by_user(course_team_memberships)
team_membership_data = []
for user in course_students:
student_row = teamset_memberships_by_user.get(user, dict())
student_row['user'] = user.username
student_row['mode'], _ = CourseEnrollment.enrollment_mode_for_user(user, course.id)
team_membership_data.append(student_row)
return team_membership_data
def _group_teamset_memberships_by_user(course_team_memberships):
"""
Parameters:
- course_team_memberships: a collection of CourseTeamMemberships
Returns:
{
<User>: {
<teamset_id>: <team_name>
for CourseTeamMembership in input corresponding to <User>
}
per user represented in input
}
"""
teamset_memberships_by_user = dict()
for team_membership in course_team_memberships:
user = team_membership.user
if user not in teamset_memberships_by_user:
teamset_memberships_by_user[user] = dict()
topic_id = team_membership.team.topic_id
team_name = team_membership.team.name
teamset_memberships_by_user[user][topic_id] = team_name
return teamset_memberships_by_user
class TeamMembershipImportManager(object):
"""
A manager class that is responsible the import process of csv file including validation and creation of
team_courseteam and teams_courseteammembership objects.
"""
def __init__(self, course):
self.validation_errors = []
self.teamset_ids = []
self.user_ids_by_teamset_id = {}
self.number_of_records_added = 0
self.course = course
self.max_errors = 0
self.existing_course_team_memberships = {}
self.existing_course_teams = {}
@property
def import_succeeded(self):
"""
Helper wrapper that tells us the status of the import
"""
return not self.validation_errors
def set_team_membership_from_csv(self, input_file):
"""
Assigns team membership based on the content of an uploaded CSV file.
Returns true if there were no issues.
"""
reader = csv.DictReader((line.decode('utf-8-sig').strip() for line in input_file.readlines()))
self.teamset_ids = reader.fieldnames[2:]
row_dictionaries = []
csv_usernames = set()
if not self.validate_teamsets():
return False
self.load_user_ids_by_teamset_id()
self.load_course_team_memberships()
self.load_course_teams()
# process student rows:
for row in reader:
username = row['user']
if not username:
continue
if not self.is_username_unique(username, csv_usernames):
return False
csv_usernames.add(username)
user = self.get_user(username)
if user is None:
continue
if not self.validate_user_enrolled_in_course(user):
row['user'] = None
continue
row['user'] = user
if not self.validate_user_assignment_to_team_and_teamset(row):
return False
row_dictionaries.append(row)
if not self.validation_errors:
for row in row_dictionaries:
self.add_user_to_team(row)
return True
else:
return False
def load_course_team_memberships(self):
"""
Caches existing team memberships by (user_id, teamset_id)
"""
for membership in CourseTeamMembership.get_memberships(course_ids=[self.course.id]):
user_id = membership.user_id
teamset_id = membership.team.topic_id
self.existing_course_team_memberships[(user_id, teamset_id)] = membership.team.id
def load_course_teams(self):
"""
Caches existing course teams by (team_name, topic_id)
"""
for team in CourseTeam.objects.filter(course_id=self.course.id):
self.existing_course_teams[(team.name, team.topic_id)] = team
def validate_teamsets(self):
"""
Validates team set ids. Returns true if there are no errors.
The following conditions result in errors:
Teamset does not exist
Teamset id is duplicated
"""
valid_teamset_ids = {ts.teamset_id for ts in self.course.teams_configuration.teamsets}
dupe_set = set()
for teamset_id in self.teamset_ids:
if teamset_id in dupe_set:
self.validation_errors.append("Teamset with id " + teamset_id + " is duplicated.")
return False
dupe_set.add(teamset_id)
if teamset_id not in valid_teamset_ids:
self.validation_errors.append("Teamset with id " + teamset_id + " does not exist.")
return False
return True
def load_user_ids_by_teamset_id(self):
for teamset_id in self.teamset_ids:
self.user_ids_by_teamset_id[teamset_id] = {
membership.user_id for membership in
CourseTeamMembership.objects.filter(
team__course_id=self.course.id, team__topic_id=teamset_id
)
}
def validate_user_enrolled_in_course(self, user):
"""
Invalid states:
user not enrolled in course
"""
if not CourseEnrollment.is_enrolled(user, self.course.id):
self.validation_errors.append('User ' + user.username + ' is not enrolled in this course.')
return False
return True
def is_username_unique(self, username, usernames_found_so_far):
"""
Ensures that username exists only once in an input file
"""
if username in usernames_found_so_far:
error_message = 'Username {} was found more than once in input file.'.format(username)
if self.add_error_and_check_if_max_exceeded(error_message):
return False
return True
def validate_user_assignment_to_team_and_teamset(self, row):
"""
Validates a user entry relative to an existing team.
row is a dictionary where key is column name and value is the row value
[andrew],masters,team1,,team3
[joe],masters,,team2,team3
"""
user = row['user']
for teamset_id in self.teamset_ids:
team_name = row[teamset_id]
if not team_name:
continue
try:
# checks for a team inside a specific team set. This way team names can be duplicated across
# teamsets
team = self.existing_course_teams[(team_name, teamset_id)]
except KeyError:
# if a team doesn't exists, the validation doesn't apply to it.
all_teamset_user_ids = self.user_ids_by_teamset_id[teamset_id]
error_message = 'The user {0} is already a member of a team inside teamset {1} in this course.'.format(
user.username, teamset_id
)
if user.id in all_teamset_user_ids and self.add_error_and_check_if_max_exceeded(error_message):
return False
else:
self.user_ids_by_teamset_id[teamset_id].add(user.id)
continue
max_team_size = self.course.teams_configuration.default_max_team_size
if max_team_size is not None and team.users.count() >= max_team_size:
if self.add_error_and_check_if_max_exceeded('Team ' + team.team_id + ' is already full.'):
return False
if (user.id, team.topic_id) in self.existing_course_team_memberships:
error_message = 'The user {0} is already a member of a team inside teamset {1} in this course.'.format(
user.username, team.topic_id
)
if self.add_error_and_check_if_max_exceeded(error_message):
return False
return True
def add_error_and_check_if_max_exceeded(self, error_message):
"""
Adds an error to the error collection.
:param error_message:
:return: True if maximum error threshold is exceeded and processing must stop
False if maximum error threshold is NOT exceeded and processing can continue
"""
self.validation_errors.append(error_message)
return len(self.validation_errors) >= self.max_errors
def add_user_to_team(self, user_row):
"""
Creates a CourseTeamMembership entry - i.e: a relationship between a user and a team.
user_row is a dictionary where key is column name and value is the row value.
{'mode': ' masters','topic_0': '','topic_1': 'team 2','topic_2': None,'user': <user_obj>}
andrew,masters,team1,,team3
joe,masters,,team2,team3
"""
user = user_row['user']
for teamset_id in self.teamset_ids:
team_name = user_row[teamset_id]
if not team_name:
continue
if (team_name, teamset_id) not in self.existing_course_teams:
team = CourseTeam.create(
name=team_name,
course_id=self.course.id,
description='Import from csv',
topic_id=teamset_id
)
team.save()
team.add_user(user)
emit_team_event(
'edx.team.learner_added',
team.course_id,
{
'team_id': team.team_id,
'user_id': user.id,
'add_method': 'team_csv_import'
}
)
self.number_of_records_added += 1
def get_user(self, user_name):
"""
Gets the user object from user_name/email/locator
user_name: the user_name/email/user locator
"""
try:
return User.objects.get(username=user_name)
except User.DoesNotExist:
try:
return User.objects.get(email=user_name)
except User.DoesNotExist:
self.validation_errors.append('Username or email ' + user_name + ' does not exist.')
return None
# TODO - handle user key case