app/services/concerns/course/user_invitation_service/parse_invitation_concern.rb
# frozen_string_literal: true
require 'csv'
# This concern includes methods required to parse the invitations data.
# This can either be from a form, or a CSV file.
class Course::UserInvitationService; end
module Course::UserInvitationService::ParseInvitationConcern
extend ActiveSupport::Autoload
TRUE_VALUES = ['t', 'true', 'y', 'yes'].freeze
private
# Invites users to the given course.
#
# @param [Array<Hash>|File|TempFile] users Invites the given users.
# @return [
# [Array<Hash{Symbol=>String}>],
# [Array<Hash>]
# ]
# Both subarrays are mutable array of users to add. Each hash must have four attributes:
# the +:name+,
# the +:email+ of the user to add,
# the intended +:role+ in the course, as well as
# whether the user is a +:phantom:+ or not.
# The provided +emails+ are NOT case sensitive.
# The second subarray contains the leftover duplicate users.
# @raise [CSV::MalformedCSVError] When the file provided is invalid.
def parse_invitations(users)
result =
if users.is_a?(File) || users.is_a?(Tempfile)
parse_from_file(users)
else
parse_from_form(users)
end
partition_unique_users(restrict_invitee_role(result))
end
# Partition users into unique (including first duplicate instance) and duplicate users.
#
# @param [Array<Hash>] users
# @return [
# [Array<Hash>],
# [Array<Hash>]
# ]
def partition_unique_users(users)
users.each { |user| user[:email] = user[:email].downcase }
unique_users = {}
duplicate_users = []
users.each do |user|
if unique_users.key?(user[:email])
duplicate_users.push(user)
else
unique_users[user[:email]] = user
end
end
[unique_users.values, duplicate_users]
end
# Change all invitees' roles to :student if inviter is a teaching_assistant.
# Currently our course user roles are not ranked, so invitation's role are restricted
# such that TAs can only invite students.
# TODO: When TAs invite non-student roles, skip non-student invitees and alert users
# instead of silently changing invitee roles.
#
# @param [Array<Hash>] users
# @return [Array<Hash>] users
def restrict_invitee_role(users)
users.each { |invitee| invitee[:role] = :student } if @current_course_user&.role == 'teaching_assistant'
users
end
# Invites the users from the form submission, which reflects the actual model associations.
#
# We do not use this format in the service object because it is very clumsy.
#
# @param [Hash] users The attributes from the client.
# @return [Array<Hash>] Array of users to be invited
def parse_from_form(users)
users.map do |(_, value)|
name = value[:name].presence || value[:email]
phantom = ActiveRecord::Type::Boolean.new.cast(value[:phantom])
{ name: name,
email: value[:email],
role: value[:role],
phantom: phantom,
timeline_algorithm: value[:timeline_algorithm] }
end
end
# Loads the given file, and entries with blanks in either fields are ignored.
# The first row is ignored if it's a header row (contains "name, email"),
# else it's treated like a row of student data.
#
# This method also handles the presence of UTF-8 Byte Order Marks at the
# start of the file, if it exists. These are invisible characters that might
# be persisted as the name of the student if not caught.
#
# @param [File] file Reads the given file, in CSV format, for the name and email.
# @return [Array<Hash>] The array of records read from the file.
# @raise [CSV::MalformedCSVError] When the file provided is invalid, eg. UTF-16 encoding.
def parse_from_file(file)
row_num = 0
[].tap do |invites|
CSV.foreach(file).with_index(1) do |row, row_number|
row_num = row_number
row[0] = remove_utf8_byte_order_mark(row[0]) if row_number == 1
row = strip_row(row)
# Ignore first row if it's a header row.
next if row_number == 1 && header_row?(row)
invite = parse_file_row(row)
invites << invite if invite
end
end
rescue StandardError => e
raise CSV::MalformedCSVError.new(e, row_num), e.message
end
# Returns a boolean to determine whether the row is a header row.
#
# @param[Array] row Array read from CSV file.
# @return [Boolean] Whether the row is a header row
def header_row?(row)
row[0].casecmp('Name') == 0 && row[1].casecmp('Email') == 0
end
# Strips a row of whitespaces.
#
# @param[Array] row Array read from CSV file.
# @return [Array] Provided row with string stripped of whitespates.
def strip_row(row)
row.map { |item| item&.strip }
end
# Parses the given CSV row (array) and returns attributes for a user invitation.
# - Sets the name as the given email if a name was not provided.
#
# @param [Array] row Array with 3 parameters: name, email and role respectively.
# @return [Hash] The parsed invitation attributes given the row.
def parse_file_row(row)
return nil if row[1].blank?
row[0] = row[1] if row[0].blank?
role = parse_file_role(row[2])
phantom = parse_file_phantom(row[3])
timeline_algorithm = parse_file_timeline_algorithm(row[4])
{ name: row[0], email: row[1], role: role, phantom: phantom, timeline_algorithm: timeline_algorithm }
end
# Parses the role column from the CSV file.
# This method handles the case where the role is not specified too, where "student" will be assumed.
#
# @param [String] role The role as specified in the CSV file
# @return [Integer] The enum integer for +Course::UserInvitation.role+ matching the input.
# (+Course::UserInvitation.roles[:student]+) is returned by default.
def parse_file_role(role)
return :student if role.blank?
symbol = role.parameterize(separator: '_').to_sym
symbol || :student
end
# Parses file value for whether an invitation is a phantom or not.
# Sets phantom as false if value is not specified.
#
# @param [String|nil] Phantom column for the given user invitation.
# @return [Boolean] Whether the value is a true or false
def parse_file_phantom(phantom)
return false if phantom.blank?
TRUE_VALUES.include?(phantom.downcase)
end
# Parses file value for an invitation's timeline algorithm.
# Sets timeline algorithm as course default if value is not specified.
#
# @param [String|nil] Timeline algorithm as specified in the CSV file.
# @return [Integer] The enum integer for +Course::UserInvitation.timeline_algorithm+ matching the input.
# current_course.default_timeline_algorithm is returned by default.
def parse_file_timeline_algorithm(timeline_algorithm)
return @current_course.default_timeline_algorithm if timeline_algorithm.blank?
symbol = timeline_algorithm.parameterize(separator: '_').to_sym
symbol || @current_course.default_timeline_algorithm
end
# Removes the UTF-8 byte order mark (BOM) from the string.
# The BOM exists at the start of in CSVs (optionally) to indicate the
# encoding of the file.
#
# @param [String] String to remove UTF-8 BOM
# @return [String] String with removed UTF-8 BOM
def remove_utf8_byte_order_mark(str)
str.sub("\xEF\xBB\xBF", '')
end
end