Skip to content

Commit 61462a6

Browse files
committed
feat: add optional external_id field
1 parent 86038a9 commit 61462a6

63 files changed

Lines changed: 1394 additions & 243 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/controllers/concerns/course/users_controller_management_concern.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ def should_update_personalized_timeline
123123

124124
def course_user_params
125125
@course_user_params ||= params.require(:course_user).permit(
126-
:user_id, :name, :timeline_algorithm, :role, :phantom, :reference_timeline_id
126+
:user_id, :name, :timeline_algorithm, :role, :phantom, :reference_timeline_id, :external_id
127127
)
128128
end
129129

app/controllers/course/user_invitations_controller.rb

Lines changed: 43 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ def create
1919
create_invitation_success(result)
2020
else
2121
propagate_errors
22-
render json: { errors: current_course.errors.full_messages.to_sentence }, status: :bad_request
22+
errors = current_course.errors[:base]
23+
render json: { errors: errors }, status: :bad_request
2324
end
2425
end
2526

@@ -59,7 +60,8 @@ def course_user_invitation_params
5960
@course_user_invitation_params ||= begin
6061
params[:course] = { invitations_attributes: {} } unless params.key?(:course)
6162
params.require(:course).permit(:invitations_file, :registration_key,
62-
invitations_attributes: [:name, :email, :role, :phantom, :timeline_algorithm])
63+
invitations_attributes: [:name, :email, :role, :phantom,
64+
:timeline_algorithm, :external_id])
6365
end
6466
end
6567

@@ -120,7 +122,7 @@ def invite_by_file?
120122
def invite
121123
invitation_service.invite(invitation_params)
122124
rescue CSV::MalformedCSVError => e
123-
current_course.errors.add(:invitations_file, e.message)
125+
current_course.errors.add(:base, e.message)
124126
false
125127
end
126128

@@ -135,15 +137,7 @@ def invitation_service
135137
#
136138
# @return [void]
137139
def propagate_errors
138-
propagate_errors_to_file if invite_by_file?
139-
end
140-
141-
# Propagates errors from the generated records to the file.
142-
#
143-
# @return [void]
144-
def propagate_errors_to_file
145-
errors = aggregate_errors
146-
current_course.errors.add(:invitations_file, errors.to_sentence) unless errors.empty?
140+
aggregate_errors.each { |msg| current_course.errors.add(:base, msg) }
147141
end
148142

149143
# Aggregates errors from all the known sources of failure.
@@ -157,9 +151,23 @@ def aggregate_errors
157151
#
158152
# @return [Array<String>]
159153
def invalid_course_user_errors
160-
invalid_course_users.map do |course_user|
161-
user = self.class.helpers.display_course_user(course_user)
162-
t('errors.course.user_invitations.duplicate_user', user: user)
154+
invalid_course_users.flat_map do |course_user|
155+
email = course_user.user&.email || course_user.name
156+
errors = []
157+
if course_user.errors[:external_id].any?
158+
errors << t('errors.course.user_invitations.duplicate_external_id',
159+
email: email, external_id: course_user.external_id)
160+
end
161+
if course_user.errors[:user].any? || course_user.errors[:course].any?
162+
errors << t('errors.course.user_invitations.already_enrolled', email: email)
163+
end
164+
other_errors = course_user.errors.reject { |e| %w[external_id user course].include?(e.attribute.to_s) }
165+
if other_errors.any?
166+
message = other_errors.map { |e| course_user.errors.full_message(e.attribute, e.message) }.
167+
to_sentence
168+
errors << t('errors.course.user_invitations.other_error', email: email, message: message)
169+
end
170+
errors
163171
end
164172
end
165173

@@ -174,9 +182,25 @@ def invalid_course_users
174182
#
175183
# @return [Array<String>]
176184
def invalid_invitation_email_errors
177-
invalid_invitations.map do |invitation|
178-
message = invitation.errors.full_messages.to_sentence
179-
t('errors.course.user_invitations.invalid_email', email: invitation.email, message: message)
185+
invalid_invitations.flat_map do |invitation|
186+
email = invitation.email
187+
errors = []
188+
if invitation.errors[:external_id].any?
189+
errors << t('errors.course.user_invitations.duplicate_external_id',
190+
email: email, external_id: invitation.external_id)
191+
end
192+
if invitation.errors[:email].any?
193+
message = invitation.errors[:email].to_sentence
194+
errors << t('errors.course.user_invitations.invalid_email', email: email, message: message)
195+
end
196+
errors << t('errors.course.user_invitations.duplicate_invitation', email: email) if invitation.errors[:base].any?
197+
other_errors = invitation.errors.reject { |e| %w[external_id email base].include?(e.attribute.to_s) }
198+
if other_errors.any?
199+
message = other_errors.map { |e| invitation.errors.full_message(e.attribute, e.message) }.
200+
to_sentence
201+
errors << t('errors.course.user_invitations.other_error', email: email, message: message)
202+
end
203+
errors
180204
end
181205
end
182206

@@ -254,7 +278,7 @@ def destroy_invitation_success
254278

255279
def destroy_invitation_failure
256280
respond_to do |format|
257-
format.json { render json: { errors: @invitation.errors.full_messages.to_sentence }, status: :bad_request }
281+
format.json { render json: { errors: @invitation.errors.full_messages }, status: :bad_request }
258282
end
259283
end
260284

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# frozen_string_literal: true
2+
# This concern validates that external IDs are unique within a course,
3+
# across both course users and pending invitations.
4+
#
5+
# Nil and blank external IDs are allowed.
6+
module Course::UniqueExternalIdConcern
7+
extend ActiveSupport::Concern
8+
9+
included do
10+
before_validation :normalize_external_id
11+
12+
validate :validate_unique_external_id_within_course
13+
end
14+
15+
private
16+
17+
# Normalizes blank external IDs to nil.
18+
#
19+
# @return [void]
20+
def normalize_external_id
21+
self.external_id = nil if external_id.blank?
22+
end
23+
24+
# Validates that the external ID is unique within the course,
25+
# across both course users and invitations.
26+
#
27+
# @return [void]
28+
def validate_unique_external_id_within_course
29+
return if external_id.blank?
30+
31+
invitation_exists = Course::UserInvitation.
32+
unconfirmed.
33+
where(course_id: course_id, external_id: external_id).
34+
where.not(id: id).
35+
exists?
36+
37+
course_user_exists = CourseUser.
38+
where(course_id: course_id, external_id: external_id).
39+
where.not(id: id).
40+
exists?
41+
42+
return unless invitation_exists || course_user_exists
43+
44+
errors.add(:external_id, :taken)
45+
end
46+
end

app/models/course/user_invitation.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ class Course::UserInvitation < ApplicationRecord
44
after_initialize :set_defaults, if: :new_record?
55
before_validation :set_defaults, if: :new_record?
66

7+
include Course::UniqueExternalIdConcern
8+
79
validates :email, format: { with: Devise.email_regexp }, if: :email_changed?
810
validates :name, presence: true
911
validates :role, presence: true

app/models/course_user.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ class CourseUser < ApplicationRecord
33
include CourseUser::StaffConcern
44
include CourseUser::LevelProgressConcern
55
include CourseUser::TodoConcern
6+
include Course::UniqueExternalIdConcern
67

78
after_initialize :set_defaults, if: :new_record?
89
before_validation :set_defaults, if: :new_record?

app/models/user.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ def build_course_user_from_invitation(invitation)
129129
phantom: invitation.phantom,
130130
timeline_algorithm: invitation.timeline_algorithm ||
131131
invitation.course&.default_timeline_algorithm,
132+
external_id: invitation.external_id,
132133
creator: self,
133134
updater: self)
134135
end

app/models/user/email.rb

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,14 @@ def accept_all_pending_invitations
3535
unconfirmed_invitation.confirm!(confirmer: user)
3636
next
3737
end
38-
user.build_course_user_from_invitation(unconfirmed_invitation)
39-
unconfirmed_invitation.confirm!(confirmer: user) if user.save && user.persisted?
38+
# Confirm the invitation before saving the CourseUser so that the
39+
# UniqueExternalIdConcern validation doesn't reject the new CourseUser
40+
# for sharing an external_id with what is now a confirmed invitation.
41+
CourseUser.transaction do
42+
unconfirmed_invitation.confirm!(confirmer: user)
43+
user.build_course_user_from_invitation(unconfirmed_invitation)
44+
raise ActiveRecord::Rollback unless user.save && user.persisted?
45+
end
4046
end
4147
end
4248
end

app/services/concerns/course/user_invitation_service/parse_invitation_concern.rb

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,16 @@ def parse_invitations(users)
4848
def partition_unique_users(users)
4949
users.each { |user| user[:email] = user[:email].downcase }
5050
unique_users = {}
51+
seen_external_ids = {}
5152
duplicate_users = []
5253
users.each do |user|
54+
ext_id = user[:external_id].presence
5355
if unique_users.key?(user[:email])
54-
duplicate_users.push(user)
56+
duplicate_users.push(user.merge(reason: :duplicate_email))
57+
elsif ext_id && seen_external_ids.key?(ext_id)
58+
duplicate_users.push(user.merge(reason: :duplicate_external_id))
5559
else
60+
seen_external_ids[ext_id] = true if ext_id
5661
unique_users[user[:email]] = user
5762
end
5863
end
@@ -86,7 +91,8 @@ def parse_from_form(users)
8691
email: value[:email],
8792
role: value[:role],
8893
phantom: phantom,
89-
timeline_algorithm: value[:timeline_algorithm] }
94+
timeline_algorithm: value[:timeline_algorithm],
95+
external_id: value[:external_id] }
9096
end
9197
end
9298

@@ -144,11 +150,17 @@ def parse_file_row(row)
144150
return nil if row[1].blank?
145151

146152
row[0] = row[1] if row[0].blank?
147-
148153
role = parse_file_role(row[2])
149154
phantom = parse_file_phantom(row[3])
150-
timeline_algorithm = parse_file_timeline_algorithm(row[4])
151-
{ name: row[0], email: row[1], role: role, phantom: phantom, timeline_algorithm: timeline_algorithm }
155+
if @current_course.show_personalized_timeline_features?
156+
timeline_algorithm = parse_file_timeline_algorithm(row[4])
157+
external_id = parse_file_external_id(row[5])
158+
else
159+
external_id = parse_file_external_id(row[4])
160+
timeline_algorithm = parse_file_timeline_algorithm(nil)
161+
end
162+
{ name: row[0], email: row[1], role: role, phantom: phantom,
163+
timeline_algorithm: timeline_algorithm, external_id: external_id }
152164
end
153165

154166
# Parses the role column from the CSV file.
@@ -188,6 +200,17 @@ def parse_file_timeline_algorithm(timeline_algorithm)
188200
symbol || @current_course.default_timeline_algorithm
189201
end
190202

203+
# Parses file value for an invitation's external ID.
204+
# Returns nil if value is not specified.
205+
#
206+
# @param [String|nil] external_id External ID as specified in the CSV file.
207+
# @return [String|nil] The external ID string, or nil if blank.
208+
def parse_file_external_id(external_id)
209+
return nil if external_id.blank?
210+
211+
external_id
212+
end
213+
191214
# Removes the UTF-8 byte order mark (BOM) from the string.
192215
# The BOM exists at the start of in CSVs (optionally) to indicate the
193216
# encoding of the file.

app/services/concerns/course/user_invitation_service/process_invitation_concern.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ def add_existing_users(users)
7373
@current_course.course_users.build(user: user[:user], name: user[:name],
7474
role: user[:role], phantom: user[:phantom],
7575
timeline_algorithm: @current_course.default_timeline_algorithm,
76+
external_id: user[:external_id],
7677
creator: @current_user, updater: @current_user)
7778
@current_course.enrol_requests.pending.find_by(user: user[:user].id)&.destroy!
7879
end
@@ -113,7 +114,8 @@ def invite_new_users(users)
113114
new_invitations <<
114115
@current_course.invitations.build(name: user[:name], email: user[:email],
115116
role: user[:role], phantom: user[:phantom],
116-
timeline_algorithm: user[:timeline_algorithm])
117+
timeline_algorithm: user[:timeline_algorithm],
118+
external_id: user[:external_id])
117119
end
118120
end
119121

app/services/course/statistics/assessments_score_summary_download_service.rb

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ def load_total_grades
4646

4747
@submission_grade_hash = submission_grade_hash
4848
@all_students = @course.course_users.students.order_alphabetically.preload(user: :emails)
49+
@include_external_id = @all_students.any? { |s| s.external_id.present? }
4950
end
5051

5152
def submission_grade_hash
@@ -67,15 +68,15 @@ def download_score_summary(csv)
6768
I18n.t('csv.score_summary.headers.name'),
6869
I18n.t('csv.score_summary.headers.email'),
6970
I18n.t('csv.score_summary.headers.type'),
70-
*@assessments.map(&:title)
71+
*@assessments.map(&:title),
72+
*(@include_external_id ? [I18n.t('csv.score_summary.headers.external_id')] : [])
7173
]
7274

7375
# content
7476
@all_students.each do |student|
7577
csv << [student.name, student.user.email, student.phantom? ? 'phantom' : 'normal',
76-
*@assessments.flat_map do |assessment|
77-
@submission_grade_hash[[student.id, assessment.id]] || ''
78-
end]
78+
*@assessments.flat_map { |a| @submission_grade_hash[[student.id, a.id]] || '' },
79+
*(@include_external_id ? [student.external_id || ''] : [])]
7980
end
8081
end
8182
end

0 commit comments

Comments
 (0)