Skip to content
Open

E2610 #315

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 26 additions & 26 deletions app/controllers/join_team_requests_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -166,45 +166,45 @@ def destroy
# Accept a join team request and add the participant to the team
def accept
team = @join_team_request.team
if team.full?
return render json: { error: 'Team is full' }, status: :unprocessable_entity
end

participant = @join_team_request.participant

# Use a transaction to ensure both removal and addition happen atomically
ActiveRecord::Base.transaction do
# Find and remove participant from their old team (if any)
error_message = nil

team.with_lock do
# Re-check while holding the lock (race-safe)
if team.full?
error_message = 'Team is full'
raise ActiveRecord::Rollback
end

# Remove participant from their old team (if any)
old_team_participant = TeamsParticipant.find_by(participant_id: participant.id)
if old_team_participant
old_team = old_team_participant.team
if old_team_participant && old_team_participant.team_id != team.id
old_team_participant.destroy!

# If the old team is now empty, optionally clean up (but keep the team for now)
Rails.logger.info "Removed participant #{participant.id} from old team #{old_team&.id}"
end

# Add participant to the new team
TeamsParticipant.create!(
participant_id: participant.id,
team_id: team.id,
user_id: participant.user_id
)
# Add participant to the new team (uses Team#add_member capacity check + MentoredTeam override)
result = team.add_member(participant)
unless result[:success]
error_message = result[:error].presence || 'Unable to add participant to team'
raise ActiveRecord::Rollback
end

# Update the request status
# Update the request status (only after successful add)
@join_team_request.update!(reply_status: ACCEPTED)

render json: {
message: 'Join team request accepted successfully',
join_team_request: JoinTeamRequestSerializer.new(@join_team_request).as_json
}, status: :ok
end

return render json: { error: error_message }, status: :unprocessable_entity if error_message

render json: {
message: 'Join team request accepted successfully',
join_team_request: JoinTeamRequestSerializer.new(@join_team_request).as_json
}, status: :ok
rescue ActiveRecord::RecordInvalid => e
render json: { error: e.message }, status: :unprocessable_entity
rescue StandardError => e
render json: { error: e.message }, status: :unprocessable_entity
end

# PATCH /join_team_requests/1/decline
# Decline a join team request
def decline
Expand Down Expand Up @@ -271,4 +271,4 @@ def current_user_is_team_member?

participant && team.participants.include?(participant)
end
end
end
18 changes: 18 additions & 0 deletions app/controllers/teams_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,24 @@ class TeamsController < ApplicationController
# Validate team type only during team creation
before_action :validate_team_type, only: [:create]

# Authorization policy for this controller (used by Authorization concern).
# - Teaching staff (TA and above) can access all actions.
# - Students can view only teams they belong to, and cannot manage membership.
def action_allowed?
return true if current_user_has_ta_privileges?

case params[:action]
when 'index'
false
when 'show', 'members'
@team&.has_member?(current_user)
when 'create', 'add_member', 'remove_member'
false
else
false
end
end

# GET /teams
# Fetches all teams and renders them using TeamSerializer
def index
Expand Down
10 changes: 5 additions & 5 deletions app/models/assignment_team.rb
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,13 @@ def path
# - Copies team members from the assignment team to the course team
def copy_to_course_team(course)
course_team = CourseTeam.new(
name: "#{name} (Course)", # Appends "(Course)" to the team name
max_team_size: max_team_size, # Preserves original max team size
course: course # Associates new team with the given course
name: "#{name} (Course)",
parent_id: course.id
)
if course_team.save
team_members.each do |member|
course_team.add_member(member.user) # Copies each member to the new course team
participants.each do |participant|
c_participant = CourseParticipant.find_by(user_id: participant.user_id, parent_id: course.id)
course_team.add_member(c_participant) if c_participant
end
end
course_team # Returns the newly created course team object
Expand Down
20 changes: 10 additions & 10 deletions app/models/course_team.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,33 +5,33 @@ class CourseTeam < Team
belongs_to :course, class_name: 'Course', foreign_key: 'parent_id'

#adds members to the course team post validation
def add_member(user)
def add_member(participant)
# return false unless validate_membership(user)
super(user)
super(participant)
end

# Copies the current course team to an assignment team
# - Creates a new AssignmentTeam with a modified name
# - Copies team members from the assignment team to the course team
def copy_to_assignment_team(assignment)
assignment_team = AssignmentTeam.new(
name: "#{name} (Assignment)", # Appends "(Assignment)" to the team name
max_team_size: max_team_size, # Preserves original max team size
assignment: assignment # Associates the course team with an assignment
name: "#{name} (Assignment)",
parent_id: assignment.id
)
if assignment_team.save
team_members.each do |member|
assignment_team.add_member(member.user) # Copies each member to the new assignment team
participants.each do |participant|
a_participant = AssignmentParticipant.find_by(user_id: participant.user_id, parent_id: assignment.id)
assignment_team.add_member(a_participant) if a_participant
end
end
assignment_team # Returns the newly created assignment team object
end

protected

def validate_membership(user)
# Check if user is enrolled in any assignment in the course
course.assignments.any? { |assignment| assignment.participants.exists?(user: user) }
def validate_membership(participant)
# Verify user is enrolled in a course (E2610)
CourseParticipant.exists?(user_id: participant.id, parent_id: course.id)
end

private
Expand Down
76 changes: 45 additions & 31 deletions app/models/invitation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,48 +31,59 @@ def self.invited?(from_id, to_id, assignment_id, reply_status = InvitationValida
# send invite email
def send_invite_email
InvitationMailer.with(invitation: self)
.send_invitation_email
.deliver_later
.send_invitation_email
.deliver_later
end

# This method handles all that needs to be done upon a user accepting an invitation.
def accept
inviter_team = from_participant.team
invitee_team = to_participant.team

# Wrap in transaction to prevent partial updates and concurrency
error_message = nil
accepted = false

ActiveRecord::Base.transaction do
# 1. Add the invitee to the inviter's team
inviter_team.add_member(to_participant)

# if participant is member of an existing team then only step 2 and 3 makes sense. otherwise just need to add the participant to the inviter team
if invitee_team.present?
# 2. Update the participant’s and team's assigned topic
inviter_signed_up_team = SignedUpTeam.find_by(team_id: inviter_team.id)
invitee_signed_up_team = SignedUpTeam.find_by(team_id: invitee_team.id)

SignedUpTeam.update_topic_after_invite_accept(inviter_signed_up_team,invitee_signed_up_team)

# 3. Remove participant from their old team
invitee_team.remove_member(to_participant)
end
inviter_team.with_lock do
# 0. Capacity check (race-safe)
if inviter_team.full?
error_message = 'Team is full.'
raise ActiveRecord::Rollback
end

# If participant is member of an existing team, update topics and move them between teams.
if invitee_team.present? && invitee_team.id != inviter_team.id
inviter_signed_up_team = SignedUpTeam.find_by(team_id: inviter_team.id)
invitee_signed_up_team = SignedUpTeam.find_by(team_id: invitee_team.id)

SignedUpTeam.update_topic_after_invite_accept(inviter_signed_up_team, invitee_signed_up_team)

# Remove from old team first so participant_id uniqueness does not block the move.
invitee_team.remove_member(to_participant)
end

# 4. Mark this invitation as accepted
update!(reply_status: InvitationValidator::ACCEPT_STATUS)
# Add the invitee to the inviter's team and verify success.
add_result = inviter_team.add_member(to_participant)
unless add_result[:success]
error_message = add_result[:error].presence || 'Unable to add participant to team.'
raise ActiveRecord::Rollback
end

update!(reply_status: InvitationValidator::ACCEPT_STATUS)
accepted = true
end
end

{ success: true, message: "Invitation accepted successfully." }
return { success: false, error: (error_message || 'Invitation acceptance failed.') } unless accepted

rescue TeamFullError => e
{ success: false, error: e.message }
rescue => e
{ success: true, message: 'Invitation accepted successfully.' }
rescue StandardError => e
{ success: false, error: "Unexpected error: #{e.message}" }
end


# This method handles all that needs to be done upon an user declining an invitation.
def decline
update(reply_status: InvitationValidator::DECLINED_STATUS)
update(reply_status: InvitationValidator::DECLINED_STATUS)
end

# This method handles all that need to be done upon an invitation retraction.
Expand All @@ -89,20 +100,23 @@ def as_json(options = {})
only: %i[id reply_status created_at updated_at],
include: {
assignment: { only: %i[id name] },
from_participant: {
from_participant: {
only: %i[id],
include: {
user: { only: %i[id name full_name email] },
team: {only: %i[id name]}
}},
team: { only: %i[id name] }
}
},
# from_team: { only: %i[id name] },
to_participant: {
to_participant: {
only: [:id],
include: {
user: { only: %i[id name full_name email] }
}}
}
}
}
})).tap do |hash|
end
end
end
end

50 changes: 35 additions & 15 deletions app/models/mentored_team.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,34 @@

class MentoredTeam < AssignmentTeam

# Custom validation to ensure the team type is 'MentoredTeam'
validate :type_must_be_mentored_team

# adds members to the team who are not mentors
def add_member(user)
return false if user == mentor
super(user)
def add_member(participant)
return false if participant == mentor
super(participant)
end

# Assigning a user as mentor of the team
# Validates if user has the role and permission to be a mentor
def assign_mentor(user)
return false unless user.role&.name&.downcase&.include?('mentor')
self.mentor = user
save
# Assigning a participant as mentor of the team
# Validates if participant has the duty of mentor
def assign_mentor(participant)
mentor_duty = Duty.find_by(name: 'Mentor')
return false unless mentor_duty
return false unless participants.exists?(id: participant.id)

participant.update(duty_id: mentor_duty.id)
end

# Unassigns mentor from team
def remove_mentor
self.mentor = nil
save
mentor_duty = Duty.find_by(name: 'Mentor')
return unless mentor_duty

# Use raw SQL join because AssignmentParticipant has no has_many :teams_participants association.
# duty_id lives on participants table; team scoping is done via teams_participants.team_id.
mentor_participant = AssignmentParticipant
.joins('INNER JOIN teams_participants ON teams_participants.participant_id = participants.id')

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please explain with a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added comments

.where('teams_participants.team_id = ? AND participants.duty_id = ?', id, mentor_duty.id)
.first
mentor_participant&.update(duty_id: nil)
end

private
Expand All @@ -31,4 +38,17 @@ def remove_mentor
def type_must_be_mentored_team
errors.add(:type, 'must be MentoredTeam') unless type == 'MentoredTeam'
end
end

# Returns the participant on this team who has the Mentor duty
def mentor
mentor_duty = Duty.find_by(name: 'Mentor')
return nil unless mentor_duty

# Use raw SQL join because AssignmentParticipant has no has_many :teams_participants association.
# duty_id lives on participants table; team scoping is done via teams_participants.team_id.
AssignmentParticipant
.joins('INNER JOIN teams_participants ON teams_participants.participant_id = participants.id')
.where('teams_participants.team_id = ? AND participants.duty_id = ?', id, mentor_duty.id)
.first
end
end
13 changes: 4 additions & 9 deletions app/models/team.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,23 +35,18 @@ def team_size
def max_size
if is_a?(AssignmentTeam) && assignment&.max_team_size
assignment.max_team_size
elsif is_a?(CourseTeam) && course&.max_team_size

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this check removed?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restored the elsif is_a?(CourseTeam) check in max_size that was previously removed. Added a respond_to? guard since the Course model does not currently have a max_team_size column

elsif is_a?(CourseTeam) && course&.respond_to?(:max_team_size) && course&.max_team_size
course.max_team_size
else
nil
end
end

def full?
current_size = participants.count
max = max_size
return false if max.blank?

# assignment teams use the column max_team_size
if is_a?(AssignmentTeam) && assignment&.max_team_size
return current_size >= assignment.max_team_size
end

# course teams never fill up by default
false
participants.count >= max
end

# Checks if the given participant is already on any team for the associated assignment or course.
Expand Down
10 changes: 10 additions & 0 deletions app/models/teams_participant.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,14 @@ class TeamsParticipant < ApplicationRecord
validates :participant_id, uniqueness: { scope: :team_id }
validates :user_id, presence: true

validate :team_not_full, on: :create

private

def team_not_full
return unless team
max = team.max_size
return if max.blank?
errors.add(:base, 'Team is at full capacity') if team.participants.count >= max
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class AddUniqueIndexOnParticipantIdToTeamsParticipants < ActiveRecord::Migration[8.0]
def change
# Uniqueness is enforced at model level via participant_on_team? in Team#add_member
# A DB-level unique index on participant_id alone would prevent participants from moving between teams
end
end
Loading
Loading