diff --git a/app/controllers/api/v1/teams_participants_controller.rb b/app/controllers/api/v1/teams_participants_controller.rb
new file mode 100644
index 000000000..5bf4439d6
--- /dev/null
+++ b/app/controllers/api/v1/teams_participants_controller.rb
@@ -0,0 +1,149 @@
+class Api::V1::TeamsParticipantsController < ApplicationController
+ include AuthorizationHelper
+
+ # Determines if the current user is allowed to perform the requested action.
+ def action_allowed?
+ if %w[update_duties].include? params[:action]
+ current_user_has_student_privileges?
+ else
+ current_user_has_ta_privileges?
+ end
+ end
+
+ # Fetches and renders an auto-complete list of possible team members based on a partial name input.
+ def auto_complete_for_participant_name
+ # Fetch the current team using the session-stored `team_id`.
+ current_team = Team.find(session[:team_id])
+
+ # Fetch potential members for the team based on the input name.
+ @potential_team_members = current_team.get_possible_team_members(params[:user][:name])
+
+ # Render the autocomplete suggestions.
+ render inline: "<%= auto_complete_result @potential_team_members, 'name' %>", layout: false
+ end
+
+ # Updates the duty (role) assigned to a participant in a team.
+ def update_duties
+ # Find the team member relationship using the provided ID.
+ team_member_relationship = TeamsUser.find(params[:teams_user_id])
+
+ # Update the duty of the team member.
+ team_member_relationship.update_attribute(:duty_id, params[:teams_user]['duty_id'])
+
+ # Redirect to the participant's team view page.
+ redirect_to controller: 'student_teams', action: 'view', student_id: params[:participant_id]
+ end
+
+ # Displays a paginated list of all participants in a specific team.
+ def list_participants
+ # Fetch the team based on the provided ID.
+ current_team = Team.find(params[:id])
+
+ # Retrieve the associated assignment or course for the team.
+ associated_assignment_or_course = Assignment.find(current_team.parent_id)
+
+ # Query and paginate participants of the current team.
+ @team_participants = TeamsUser.page(params[:page]).per_page(10).where(team_id: current_team.id)
+
+ @team = current_team
+ @assignment = associated_assignment_or_course
+ end
+
+ # Renders the form for adding a new participant to a team.
+ def add_new_participant
+ # Fetch the team for which a participant is to be added.
+ @team = Team.find(params[:id])
+ end
+ #Deletes the selected participant
+ def delete_selected_participant
+ @teams_user = TeamsUser.find(params[:id])
+ parent_id = Team.find(@teams_user.team_id).parent_id
+ @user = User.find(@teams_user.user_id)
+ @teams_user.destroy
+ undo_link("The team user \"#{@user.name}\" has been successfully removed. ")
+ redirect_to controller: 'teams', action: 'list', id: parent_id
+ end
+
+ # Adds a new participant to a team after validation.
+ def create_participant
+ # Find the user by their name from the input.
+ find_participant = find_participant_by_name
+ # Fetch the team using the provided ID.
+ current_team = find_team_by_id
+ if validate_participant_and_team(participant, team)
+ if team.add_participants_with_validation(participant, team.parent_id)
+ undo_link("The participant \"#{participant.name}\" has been successfully added to \"#{team.name}\".")
+ else
+ flash[:error] = 'This team already has the maximum number of members.'
+ end
+ end
+ # Redirect to the list of teams for the parent assignment or course.
+ redirect_to controller: 'teams', action: 'list', id: current_team.parent_id
+ end
+
+ private
+
+ # Helper method to find a user by their name.
+ def find_participant_by_name
+ # Locate the user by their name.
+ find_participant = User.find_by(name: params[:user][:name].strip)
+
+ # Display an error if the user is not found.
+ unless find_participant
+ flash[:error] = participant_not_found_error
+ redirect_back fallback_location: root_path
+ end
+ participant
+ end
+
+ # Helper method to fetch a team by its ID.
+ def find_team_by_id
+ Team.find(params[:id])
+ end
+
+ # Validates whether a participant can be added to the given team.
+ def validate_participant_and_team(participant, team)
+ # Check if the participant is valid for the team type.
+ validation_result = if team.is_a?(AssignmentTeam)
+ Assignment.find(team.parent_id).valid_team_participant?(participant)
+ else
+ Course.find(team.parent_id).valid_team_participant?(participant)
+ end
+
+ # Handle validation errors if any.
+ if validation_result[:success]
+ true
+ else
+ flash[:error] = validation_result[:error]
+ redirect_back fallback_location: root_path
+ false
+ end
+ end
+
+ # Adds the participant to the team while handling constraints.
+ def add_participant_to_team(find_participant, team)
+ # Add the participant to the team and handle the outcome.
+ addition_result = find_team_by_id.add_participant(find_participant, team.parent_id)
+ process_participant_addition_result(find_participant, team, addition_result)
+ end
+
+ # Handles the result of adding a participant to the team.
+ def process_participant_addition_result(find_participant, team, addition_result)
+ if addition_result == false
+ flash[:error] = 'This team already has the maximum number of members.'
+ else
+ undo_link("The team user \"#{find_participant.name}\" has been successfully added to \"#{team.name}\".")
+ end
+ end
+
+ # Generates an error message when a user is not found.
+ def participant_not_found_error
+ new_participnat_url = url_for controller: 'users', action: 'new'
+ "\"#{params[:user][:name].strip}\" is not defined. Please create this user before continuing."
+ end
+
+ def non_participant_error(find_participant, parent_id, model)
+ urlParticipantList = url_for controller: 'participants', action: 'list', id: parent_id, model: model, authorization: 'participant'
+ "\"#{find_participant.name}\" is not a participant of the current course/assignment. Please add this user before continuing."
+ end
+end
diff --git a/app/models/assignment.rb b/app/models/assignment.rb
index 45e8d2acf..4a4401e88 100644
--- a/app/models/assignment.rb
+++ b/app/models/assignment.rb
@@ -11,6 +11,7 @@ class Assignment < ApplicationRecord
has_many :sign_up_topics , class_name: 'SignUpTopic', foreign_key: 'assignment_id', dependent: :destroy
belongs_to :course, optional: true
belongs_to :instructor, class_name: 'User', inverse_of: :assignments
+ validates :max_team_size, numericality: { only_integer: true, greater_than_or_equal_to: 1 }, allow_nil: true
#This method return the value of the has_badge field for the given assignment object.
attr_accessor :title, :description, :has_badge, :enable_pair_programming, :is_calibrated, :staggered_deadline
@@ -22,10 +23,11 @@ def review_questionnaire_id
def teams?
@has_teams ||= teams.any?
end
+
def num_review_rounds
rounds_of_reviews
end
-
+
# Add a participant to the assignment based on the provided user_id.
# This method first finds the User with the given user_id. If the user does not exist, it raises an error.
# It then checks if the user is already a participant in the assignment. If so, it raises an error.
@@ -79,8 +81,6 @@ def remove_assignment_from_course
self
end
-
-
# Assign a course to the assignment based on the provided course_id.
# If the assignment already belongs to the specified course, an error is raised.
# Returns the modified assignment object with the updated course assignment.
@@ -98,7 +98,6 @@ def assign_course(course_id)
assignment
end
-
# Create a copy of the assignment, including its name, instructor, and course assignment.
# The new assignment is named "Copy of [original assignment name]".
# Returns the newly created assignment object, which is a copy of the original assignment.
@@ -117,6 +116,7 @@ def copy
copied_assignment
end
+
def is_calibrated?
is_calibrated
end
@@ -133,7 +133,6 @@ def staggered_and_no_topic?(topic_id)
staggered_deadline? && topic_id.nil?
end
-
#This method return the value of the has_topics field for the given assignment object.
# has_topics is of boolean type and is set true if there is any topic associated with the assignment.
def topics?
@@ -183,7 +182,6 @@ def valid_num_review(review_type)
end
end
-
#This method check if for the given assignment,different type of rubrics are used in different round.
# Checks if for the given assignment any questionnaire is present with used_in_round field not nil.
# Returns a bolean value whether such questionnaire is present.
@@ -192,7 +190,40 @@ def varying_rubrics_by_round?
# Check if any rubric has a specified round
rubric_with_round.present?
end
-
-
+
+#E2479
+#check if the user is on the team
+def user_on_team?(user)
+ teams = self.teams
+ users = []
+ teams.each do |team|
+ users << team.users
+ end
+ users.flatten.include? user
+end
+# Validates if a user is eligible to join a team for the current assignment.
+# This method ensures that:
+# - The user is not already part of another team for this assignment.
+# - The user is a valid participant in the assignment.
+# Params:
+# - user: The user to validate for team membership.
+# Returns:
+# - A hash indicating the validation result:
+# - { success: true } if the user can join the team.
+# - { success: false, error: "Reason for failure" } if the user cannot join the team.
+def valid_team_participant?(user)
+ # Check if the user is already part of a team for this assignment.
+ if user_on_team?(user)
+ { success: false, error: "This user is already assigned to a team for this assignment" }
+
+ # Check if the user is a registered participant in the assignment.
+ elsif AssignmentParticipant.find_by(user_id: user.id, parent_id: assignment_id).nil?
+ { success: false, error: "#{user.name} is not a participant in this assignment" }
+
+ # If both checks pass, the user is eligible to join the team.
+ else
+ { success: true }
+ end
+end
end
\ No newline at end of file
diff --git a/app/models/assignment_participant.rb b/app/models/assignment_participant.rb
index 10ca53b5d..1114152ad 100644
--- a/app/models/assignment_participant.rb
+++ b/app/models/assignment_participant.rb
@@ -5,7 +5,6 @@ class AssignmentParticipant < Participant
belongs_to :user
validates :handle, presence: true
-
def set_handle
self.handle = if user.handle.nil? || (user.handle == '')
user.name
@@ -16,5 +15,13 @@ def set_handle
end
self.save
end
+ #E2479
+ def team
+ AssignmentTeam.team(self)
+ end
+
+ def team_user
+ TeamsUser.where(team_id: team.id, user_id: user_id).first if team
+ end
end
\ No newline at end of file
diff --git a/app/models/course.rb b/app/models/course.rb
index 9e70ccf7d..04992ddbf 100644
--- a/app/models/course.rb
+++ b/app/models/course.rb
@@ -11,7 +11,7 @@ def path
raise 'Path can not be created as the course must be associated with an instructor.' if instructor_id.nil?
Rails.root + '/' + Institution.find(institution_id).name.gsub(" ", "") + '/' + User.find(instructor_id).name.gsub(" ", "") + '/' + directory_path + '/'
end
-
+
# Add a Teaching Assistant to the course
def add_ta(user)
if user.nil?
@@ -49,4 +49,39 @@ def copy_course
new_course.name += '_copy'
new_course.save
end
+ #E2479
+ #checks if the user is in the team
+ def user_on_team?(user)
+ teams = self.teams
+ users = []
+ teams.each do |team|
+ users << team.users
+ end
+ users.flatten.include? user
+ end
+ # Checks if a user is eligible to join a specific team for a course.
+# This method ensures that:
+# - The user is not already a member of another team for the course.
+# - The user is a valid participant in the course.
+# Params:
+# - user: The user to be validated for team membership.
+# Returns:
+# - A hash indicating success or failure:
+# - { success: true } if the user can be added to the team.
+# - { success: false, error: "Reason for failure" } if the user cannot be added.
+def valid_team_participant?(user)
+ # Check if the user is already a member of another team for the same course.
+ if user_on_team?(user)
+ { success: false, error: "This user is already assigned to a team for this course" }
+
+ # Check if the user is a participant in the course associated with this team.
+ elsif CourseParticipant.find_by(user_id: user.id, parent_id: course_id).nil?
+ { success: false, error: "#{user.name} is not a participant in this course" }
+
+ # If both checks pass, the user is eligible to join the team.
+ else
+ { success: true }
+ end
+end
+
end
\ No newline at end of file
diff --git a/app/models/course_participant.rb b/app/models/course_participant.rb
new file mode 100644
index 000000000..6c284130a
--- /dev/null
+++ b/app/models/course_participant.rb
@@ -0,0 +1,35 @@
+class CourseParticipant < Participant
+ belongs_to :course, class_name: 'Course', foreign_key: 'parent_id'
+
+ # Copy this participant to an assignment
+ def copy_to_assignment(assignment_id)
+ part = AssignmentParticipant.find_or_create_by(user_id: user_id, parent_id: assignment_id)
+ part.set_handle if part.persisted?
+ part
+ end
+
+ # Provide import functionality for Course Participants
+ def self.import(row_hash, session, course_id)
+ raise ArgumentError, 'No user ID has been specified.' if row_hash.empty?
+
+ user = User.find_by(name: row_hash[:username])
+ unless user
+ raise ArgumentError, "The record containing #{row_hash[:username]} does not have enough items." if row_hash.length < 4
+
+ attributes = ImportFileHelper.define_attributes(row_hash)
+ user = ImportFileHelper.create_new_user(attributes, session)
+ end
+
+ course = Course.find(course_id)
+ raise ImportError, "The course with the ID #{course_id} was not found." unless course
+
+ unless exists?(user_id: user.id, parent_id: course_id)
+ create(user_id: user.id, parent_id: course_id)
+ end
+ end
+
+ # Generate a path for this participant
+ def path
+ course.path.join(directory_num.to_s)
+ end
+end
diff --git a/app/models/mentor_management.rb b/app/models/mentor_management.rb
new file mode 100644
index 000000000..54a710964
--- /dev/null
+++ b/app/models/mentor_management.rb
@@ -0,0 +1,122 @@
+class MentorManagement
+ # Select a mentor using the following algorithm
+ #
+ # 1) Find all assignment participants for the
+ # assignment with id [assignment_id] whose
+ # duty is the same as [Particpant#DUTY_MENTOR].
+ # 2) Count the number of teams those participants
+ # are a part of, acting as a proxy for the
+ # number of teams they mentor.
+ # 3) Return the mentor with the fewest number of
+ # teams they're currently mentoring.
+ #
+ # This method's runtime is O(n lg n) due to the call to
+ # Hash#sort_by. This assertion assumes that the
+ # database management system is capable of fetching the
+ # required rows at least as quickly.
+ #
+ # Implementation detail: Any tie between the top 2
+ # mentors is decided by the Hash#sort_by algorithm.
+ #
+ # @return The id of the mentor with the fewest teams
+ # they are assigned to. Returns `nil` if there are
+ # no participants with mentor duty for [assignment_id].
+ def self.select_mentor(assignment_id)
+ mentor_user_id, = zip_mentors_with_team_count(assignment_id).first
+ User.where(id: mentor_user_id).first
+ end
+
+ # = Mentor Management
+ # E2115: Handles calls when an assignment has the auto_assign_mentor flag enabled and triggered by the event when a new member joins an assignment team.
+ #
+ # This event happens when:
+ # 1.) An invited student user accepts and successfully added to a team from
+ # app/models/invitation.rb
+ # 2.) A student user is successfully added to the team manually from
+ # app/controllers/teams_users_controller.rb.
+ #
+ # This method will determine if a mentor needs to be assigned, if so,
+ # selects one, and adds the mentor to the team if:
+ # 1.) The assignment does not have a topic.
+ # 2.) If the team has reached >50% full capacity.
+ # 3.) If the team does not have a mentor.
+ def self.assign_mentor(assignment_id, team_id)
+ assignment = Assignment.find(assignment_id)
+ team = Team.find(team_id)
+
+ # RuboCop 'use guard clause instead of nested conditionals'
+ # return if assignments can't accept mentors
+ return unless assignment.auto_assign_mentor
+
+ # RuboCop 'use guard clause instead of nested conditionals'
+ # return if the assignment or team already have a topic
+ return if assignment.topics? || !team.topic.nil?
+
+ curr_team_size = Team.size(team_id)
+ max_team_members = Assignment.find(assignment_id).max_team_size
+
+ # RuboCop 'use guard clause instead of nested conditionals'
+ # return if the team size hasn't reached > 50% of capacity
+ return if curr_team_size * 2 <= max_team_members
+
+ # RuboCop 'use guard clause instead of nested conditionals'
+ # return if there's already a mentor in place
+ return if team.participants.any? { |participant| participant.can_mentor == true }
+
+ mentor_user = select_mentor(assignment_id)
+
+ # Add the mentor using team model class.
+ team_member_added = mentor_user.nil? ? false : team.add_member(mentor_user, assignment_id)
+
+ return unless team_member_added
+
+ notify_team_of_mentor_assignment(mentor_user, team)
+ end
+
+ def self.notify_team_of_mentor_assignment(mentor, team)
+ members = team.users
+ emails = members.map(&:email)
+ members_info = members.map { |mem| "#{mem.fullname} - #{mem.email}" }
+ mentor_info = "#{mentor.fullname} (#{mentor.email})"
+ message = "#{mentor_info} has been assigned as your mentor for assignment #{Assignment.find(team.parent_id).name}
Current members:
#{members_info.join('
')}"
+
+ Mailer.delayed_message(bcc: emails,
+ subject: '[Expertiza]: New Mentor Assignment',
+ body: message).deliver_now
+ end
+
+ # Returns true if [user] is a mentor, and false if not.
+ #
+ # [user] must be a User object.
+ #
+ # Checks the Participant relation to see if a row exists with
+ # user_id == user.id that also has 'mentor' in the duty attribute.
+ def self.user_a_mentor?(user)
+ Participant.exists?(user_id: user.id, can_mentor: true)
+ end
+
+ # Select all the participants who's duty in the participant
+ # table is [DUTY_MENTOR], and who are a participant of
+ # [assignment_id].
+ #
+ # @see participant.rb for the value of DUTY_MENTOR
+ def self.mentors_for_assignment(assignment_id)
+ Participant.where(parent_id: assignment_id, can_mentor: true)
+ end
+
+ # Produces a hash mapping mentor's user_ids to the aggregated
+ # number of teams they're part of, which acts as a proxy for
+ # the number of teams they're mentoring.
+ def self.zip_mentors_with_team_count(assignment_id)
+ mentor_ids = mentors_for_assignment(assignment_id).pluck(:user_id)
+
+ return [] if mentor_ids.empty?
+
+ team_counts = {}
+ mentor_ids.each { |id| team_counts[id] = 0 }
+ #E2351 removed (:team_id) after .count to fix balancing algorithm
+ team_counts.update(TeamsUser.where(user_id: mentor_ids).group(:user_id).count)
+
+ team_counts.sort_by { |_, v| v }
+ end
+end
diff --git a/app/models/mentored_team.rb b/app/models/mentored_team.rb
new file mode 100644
index 000000000..6b38ac48e
--- /dev/null
+++ b/app/models/mentored_team.rb
@@ -0,0 +1,27 @@
+class MentoredTeam < AssignmentTeam
+ # Class created during refactoring of E2351
+ # Overridden method to include the MentorManagement workflow
+ def add_member(user, _assignment_id = nil)
+ raise "The user #{user.name} is already a member of the team #{name}" if user?(user)
+ raise "A mentor already exists for team #{name}" if mentor_exists? && user.mentor_role?
+ can_add_member = false
+ unless full? || user.mentor_role?
+ can_add_member = true
+ t_user = TeamsUser.create(user_id: user.id, team_id: id)
+ parent = TeamNode.find_by(node_object_id: id)
+ TeamUserNode.create(parent_id: parent.id, node_object_id: t_user.id)
+ add_participant(parent_id, user)
+ ExpertizaLogger.info LoggerMessage.new('Model:Team', user.name, "Added member to the team #{id}")
+ end
+ if can_add_member
+ MentorManagement.assign_mentor(_assignment_id, id) if user.mentor_role?
+ end
+ can_add_member
+ end
+ #E2479
+ private
+
+ def mentor_exists?
+ teams_users.where(role: 'mentor').exists?
+ end
+end
diff --git a/app/models/team.rb b/app/models/team.rb
index afb8ac66f..d0f6157c2 100644
--- a/app/models/team.rb
+++ b/app/models/team.rb
@@ -1,15 +1,20 @@
class Team < ApplicationRecord
has_many :signed_up_teams, dependent: :destroy
has_many :teams_users, dependent: :destroy
+ has_many :join_team_requests, dependent: :destroy
+ has_one :team_node, foreign_key: :node_object_id, dependent: :destroy
has_many :users, through: :teams_users
+ has_many :bids, dependent: :destroy
has_many :participants
belongs_to :assignment
+ validates :name, presence: true
attr_accessor :max_participants
-
+ scope :find_team_for_assignment_and_user, lambda { |assignment_id, user_id|
+ joins(:teams_users).where('teams.parent_id = ? AND teams_users.user_id = ?', assignment_id, user_id)
+ }
# TODO Team implementing Teams controller and model should implement this method better.
# TODO partial implementation here just for the functionality needed for join_team_tequests controller
def full?
-
max_participants ||= 3
if participants.count >= max_participants
true
@@ -17,4 +22,32 @@ def full?
false
end
end
+
+ #E2479
+ #Check if a user is already a member of the team if not it adds the user
+ def add_participant(user, _assignment_id = nil)
+ raise "The user #{user.name} is already a member of the team #{name}" if user?(user)
+
+ can_add_member = false
+ unless full?
+ can_add_member = true
+ t_user = TeamsUser.create(user_id: user.id, team_id: id)
+ parent = TeamNode.find_by(node_object_id: id)
+ TeamUserNode.create(parent_id: parent.id, node_object_id: t_user.id)
+ add_participant(parent_id, user)
+ ExpertizaLogger.info LoggerMessage.new('Model:Team', user.name, "Added member to the team #{id}")
+ end
+ can_add_member
+ end
+
+def add_participants_with_handling(user, parent_id)
+ begin
+ # Attempt to add the user to the team.
+ addition_result = add_member(user, parent_id)
+ addition_result
+ rescue StandardError => e
+ # Return a failure message if an error occurs (e.g., user already in the team).
+ { success: false, error: "The user #{user.name} is already a member of the team #{name}" }
+ end
+end
end
\ No newline at end of file
diff --git a/app/models/teams_user.rb b/app/models/teams_user.rb
deleted file mode 100644
index 9e1768b94..000000000
--- a/app/models/teams_user.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-class TeamsUser < ApplicationRecord
- belongs_to :user
- belongs_to :team
-
- def name(ip_address = nil)
- name = user.name(ip_address)
- end
-
- def get_team_members(team_id)
- team_members = TeamsUser.where('team_id = ?', team_id)
- user_ids = team_members.pluck(:user_id)
- users = User.where(id: user_ids)
-
- return users
- end
-
- # Removes entry in the TeamUsers table for the given user and given team id
- def self.remove_team(user_id, team_id)
- team_user = TeamsUser.where('user_id = ? and team_id = ?', user_id, team_id).first
- team_user&.destroy
- end
-
-end
diff --git a/app/models/teams_users.rb b/app/models/teams_users.rb
new file mode 100644
index 000000000..0232dd8f2
--- /dev/null
+++ b/app/models/teams_users.rb
@@ -0,0 +1,31 @@
+class TeamsUser < ApplicationRecord
+ belongs_to :user
+ belongs_to :team
+ has_one :team_user_node, foreign_key: 'node_object_id', dependent: :destroy
+
+ # Retrieves the team members for a specific team.
+ def get_team_members(team_id)
+ team_members = TeamsUser.where('team_id = ?', team_id)
+ user_ids = team_members.pluck(:user_id)
+ User.where(id: user_ids)
+ end
+
+ # Removes entry in the TeamUsers table for the given user and team ID.
+ def self.remove_team(user_id, team_id)
+ team_user = TeamsUser.find_by(user_id: user_id, team_id: team_id)
+ team_user&.destroy
+ end
+
+#E2479
+ # Deletes multiple team members in bulk.
+ def self.delete_multiple_participants(team_user_ids)
+ where(id: team_user_ids).destroy_all
+ end
+
+ # Custom name display for mentors.
+ def name(ip_address = nil)
+ name = user.name(ip_address)
+ name += ' (Mentor)' if MentorManagement.user_a_mentor?(user)
+ name
+ end
+end
diff --git a/app/views/teams_participants/_form.html.erb b/app/views/teams_participants/_form.html.erb
new file mode 100644
index 000000000..de299c570
--- /dev/null
+++ b/app/views/teams_participants/_form.html.erb
@@ -0,0 +1,5 @@
+
+<%= hidden_field_tag 'id', @team.id %>
+Enter user login: <%= text_field_with_auto_complete :user, :name, {:size => 41} %>
+
+<% end %>
diff --git a/app/views/teams_participants/_members.html.erb b/app/views/teams_participants/_members.html.erb
new file mode 100644
index 000000000..5483ecbc1
--- /dev/null
+++ b/app/views/teams_participants/_members.html.erb
@@ -0,0 +1,7 @@
+