diff --git a/.ruby-version b/.ruby-version index df9407bbb..54978911c 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -ruby-3.4.5 \ No newline at end of file +ruby-3.4.5 diff --git a/Gemfile b/Gemfile index 020dbe491..d3d733e54 100644 --- a/Gemfile +++ b/Gemfile @@ -5,7 +5,7 @@ ruby '3.4.5' gem 'mysql2', '~> 0.5.7' gem 'sqlite3', '~> 1.4' # Alternative for development -gem 'puma', '~> 5.0' +gem 'puma', '~> 6.0' gem 'rails', '~> 8.0', '>= 8.0.1' gem 'mini_portile2', '~> 2.8' # Helps with native gem compilation gem 'observer' # Required for Ruby 3.4.5 compatibility with Rails 8.0 diff --git a/Gemfile.lock b/Gemfile.lock index 5e7341cb0..6fdf41173 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -106,6 +106,7 @@ GEM term-ansicolor thor crass (1.0.6) + csv (3.3.5) danger (9.5.3) base64 (~> 0.2) claide (~> 1.0) @@ -128,6 +129,7 @@ GEM debug (1.11.0) irb (~> 1.10) reline (>= 0.3.8) + delegate (0.4.0) diff-lcs (1.6.2) docile (1.4.1) domain_name (0.6.20240107) @@ -149,8 +151,11 @@ GEM faraday (>= 0.8) faraday-net_http (3.4.1) net-http (>= 0.5.0) + faraday-retry (2.3.2) + faraday (~> 2.0) find_with_order (1.3.1) activerecord (>= 3) + forwardable (1.3.3) git (2.3.3) activesupport (>= 5.0) addressable (~> 2.8) @@ -197,10 +202,13 @@ GEM mime-types-data (~> 3.2025, >= 3.2025.0507) mime-types-data (3.2025.0924) mini_mime (1.1.5) + mini_portile2 (2.8.9) minitest (5.25.5) mize (0.6.1) + monitor (0.2.0) msgpack (1.8.0) multi_json (1.17.0) + mutex_m (0.3.0) mysql2 (0.5.7) bigdecimal nap (1.1.0) @@ -217,7 +225,7 @@ GEM net-protocol netrc (0.11.0) nio4r (2.5.9) - nokogiri (1.15.2-aarch64-linux) + nokogiri (1.18.10-aarch64-linux-gnu) racc (~> 1.4) nokogiri (1.18.10-arm64-darwin) racc (~> 1.4) @@ -230,6 +238,7 @@ GEM faraday (>= 1, < 3) sawyer (~> 0.9) open4 (1.3.4) + ostruct (0.6.3) parallel (1.27.0) parser (3.3.9.0) ast (~> 2.4.1) @@ -350,6 +359,7 @@ GEM addressable (>= 2.3.5) faraday (>= 0.17.3, < 3) securerandom (0.4.1) + set (1.1.2) shoulda-matchers (6.5.0) activesupport (>= 5.2.0) simplecov (0.22.0) @@ -358,7 +368,10 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) + singleton (0.3.0) spring (4.4.0) + sqlite3 (1.7.3) + mini_portile2 (~> 2.8.0) stringio (3.1.7) sync (0.5.0) term-ansicolor (1.11.3) @@ -398,18 +411,30 @@ PLATFORMS DEPENDENCIES active_model_serializers (~> 0.10.0) bcrypt (~> 3.1.7) + bigdecimal bootsnap (>= 1.18.4) coveralls + csv danger database_cleaner-active_record + date debug + delegate factory_bot_rails faker + faraday-retry find_with_order + forwardable jwt (~> 2.7, >= 2.7.1) lingua - mysql2 (~> 0.5.5) + logger + mini_portile2 (~> 2.8) + monitor + mutex_m + mysql2 (~> 0.5.7) observer + ostruct + psych (~> 5.2) puma (~> 6.0) rack-cors rails (~> 8.0, >= 8.0.1) @@ -418,14 +443,19 @@ DEPENDENCIES rswag-specs rswag-ui rubocop + set shoulda-matchers simplecov simplecov_json_formatter + singleton spring + sqlite3 (~> 1.4) + timeout tzinfo-data + uri RUBY VERSION - ruby 3.2.7p253 + ruby 3.4.5p51 BUNDLED WITH 2.4.14 diff --git a/app/controllers/assignments_controller.rb b/app/controllers/assignments_controller.rb index 84d99f7c0..0ce7831a7 100644 --- a/app/controllers/assignments_controller.rb +++ b/app/controllers/assignments_controller.rb @@ -151,7 +151,7 @@ def show_assignment_details end # check if assignment has topics - # has_topics is set to true if there is SignUpTopic corresponding to the input assignment id + # has_topics is set to true if there is ProjectTopic corresponding to the input assignment id def has_topics assignment = Assignment.find_by(id: params[:assignment_id]) if assignment.nil? @@ -213,7 +213,7 @@ def varying_rubrics_by_round? private # Only allow a list of trusted parameters through. def assignment_params - params.require(:assignment).permit(:title, :description) + params.require(:assignment).permit(:title, :description, :allow_bookmarks) end # Helper method to determine staggered_and_no_topic for the assignment diff --git a/app/controllers/project_topics_controller.rb b/app/controllers/project_topics_controller.rb new file mode 100644 index 000000000..5e1433567 --- /dev/null +++ b/app/controllers/project_topics_controller.rb @@ -0,0 +1,71 @@ +class ProjectTopicsController < ApplicationController + before_action :set_project_topic, only: %i[ show update ] + + # GET /project_topics?assignment_id=&topic_ids[]= + def index + if params[:assignment_id].nil? + render json: { message: 'Assignment ID is required!' }, status: :unprocessable_entity + else + @project_topics = ProjectTopic.find_by_assignment_and_topic_ids(params[:assignment_id], params[:topic_ids]) + render json: @project_topics.map(&:to_json_with_computed_data), status: :ok + end + end + + # POST /project_topics + def create + result = ProjectTopic.create_topic_with_assignment( + project_topic_params, + params[:project_topic][:assignment_id], + params[:micropayment] + ) + + if result[:success] + render json: { message: result[:message] }, status: :created + else + render json: { message: result[:message] }, status: :unprocessable_entity + end + end + + # PATCH/PUT /project_topics/1 + def update + result = @project_topic.update_topic(project_topic_params) + + if result[:success] + render json: { message: result[:message] }, status: :ok + else + render json: { message: result[:message] }, status: :unprocessable_entity + end + end + + # Show a ProjectTopic by ID + def show + render json: @project_topic, status: :ok + end + + # Destroy ProjectTopics by assignment_id and optional topic_ids + def destroy + if params[:assignment_id].nil? + render json: { message: 'Assignment ID is required!' }, status: :unprocessable_entity + else + result = ProjectTopic.delete_by_assignment_and_topic_ids(params[:assignment_id], params[:topic_ids]) + + if result[:success] + render json: { message: result[:message] }, status: :no_content + else + render json: { message: result[:message] }, status: :unprocessable_entity + end + end + end + + private + + # Use callbacks to share common setup or constraints between actions. + def set_project_topic + @project_topic = ProjectTopic.find(params[:id]) + end + + # Only allow a list of trusted parameters through. + def project_topic_params + params.require(:project_topic).permit(:topic_identifier, :category, :topic_name, :max_choosers, :assignment_id, :description, :link) + end +end diff --git a/app/controllers/sign_up_topics_controller.rb b/app/controllers/sign_up_topics_controller.rb deleted file mode 100644 index 11ddf5c7b..000000000 --- a/app/controllers/sign_up_topics_controller.rb +++ /dev/null @@ -1,84 +0,0 @@ -class SignUpTopicsController < ApplicationController - before_action :set_sign_up_topic, only: %i[ show update ] - - # GET /sign_up_topics?assignment_id=&topic_ids[]= - # Retrieve SignUpTopics by two query parameters - assignment_id (compulsory) and an array of topic_ids (optional) - def index - if params[:assignment_id].nil? - render json: { message: 'Assignment ID is required!' }, status: :unprocessable_entity - elsif params[:topic_ids].nil? - @sign_up_topics = SignUpTopic.where(assignment_id: params[:assignment_id]) - render json: @sign_up_topics, status: :ok - else - @sign_up_topics = SignUpTopic.where(assignment_id: params[:assignment_id], topic_identifier: params[:topic_ids]) - render json: @sign_up_topics, status: :ok - end - # render json: {message: 'All selected topics have been loaded successfully.', sign_up_topics: @stopics}, status: 200 - end - - # POST /sign_up_topics - # The create method allows the instructor to create a new topic - # params[:sign_up_topic][:topic_identifier] follows a json format - # The method takes inputs and outputs the if the topic creation was successful. - def create - @sign_up_topic = SignUpTopic.new(sign_up_topic_params) - @assignment = Assignment.find(params[:sign_up_topic][:assignment_id]) - @sign_up_topic.micropayment = params[:micropayment] if @assignment.microtask? - if @sign_up_topic.save - # undo_link "The topic: \"#{@sign_up_topic.topic_name}\" has been created successfully. " - render json: { message: "The topic: \"#{@sign_up_topic.topic_name}\" has been created successfully. " }, status: :created - else - render json: { message: @sign_up_topic.errors }, status: :unprocessable_entity - end - end - - # PATCH/PUT /sign_up_topics/1 - # updates parameters present in sign_up_topic_params. - def update - if @sign_up_topic.update(sign_up_topic_params) - render json: { message: "The topic: \"#{@sign_up_topic.topic_name}\" has been updated successfully. " }, status: 200 - else - render json: @sign_up_topic.errors, status: :unprocessable_entity - end - end - - # Show a SignUpTopic by ID - def show - render json: @sign_up_topic, status: :ok - end - - # Similar to index method, this method destroys SignUpTopics by two query parameters - # assignment_id is compulsory. - # topic_ids[] is optional - def destroy - # render json: {message: @sign_up_topic} - # filters topics based on assignment id (required) and topic identifiers (optional) - if params[:assignment_id].nil? - render json: { message: 'Assignment ID is required!' }, status: :unprocessable_entity - elsif params[:topic_ids].nil? - @sign_up_topics = SignUpTopic.where(assignment_id: params[:assignment_id]) - # render json: @sign_up_topics, status: :ok - else - @sign_up_topics = SignUpTopic.where(assignment_id: params[:assignment_id], topic_identifier: params[:topic_ids]) - # render json: @sign_up_topics, status: :ok - end - - if @sign_up_topics.each(&:delete) - render json: { message: "The topic has been deleted successfully. " }, status: :no_content - else - render json: @sign_up_topic.errors, status: :unprocessable_entity - end - end - - private - - # Use callbacks to share common setup or constraints between actions. - def set_sign_up_topic - @sign_up_topic = SignUpTopic.find(params[:id]) - end - - # Only allow a list of trusted parameters through. - def sign_up_topic_params - params.require(:sign_up_topic).permit(:topic_identifier, :category, :topic_name, :max_choosers, :assignment_id) - end -end diff --git a/app/controllers/signed_up_teams_controller.rb b/app/controllers/signed_up_teams_controller.rb index 75dc05d67..aa18db9b8 100644 --- a/app/controllers/signed_up_teams_controller.rb +++ b/app/controllers/signed_up_teams_controller.rb @@ -1,12 +1,14 @@ class SignedUpTeamsController < ApplicationController # Returns signed up topics using sign_up_topic assignment id - # Retrieves sign_up_topic using topic_id as a parameter def index - # puts params[:topic_id] - @sign_up_topic = SignUpTopic.find(params[:topic_id]) - @signed_up_team = SignedUpTeam.find_team_participants(@sign_up_topic.assignment_id) - render json: @signed_up_team + result = SignedUpTeam.get_team_participants_for_topic(params[:topic_id]) + + if result[:success] + render json: result[:participants], status: :ok + else + render json: { message: result[:message] }, status: :not_found + end end # Implemented by signed_up_team.rb (Model) --> create_signed_up_team @@ -14,53 +16,68 @@ def create; end # Update signed_up_team using parameters. def update - @signed_up_team = SignedUpTeam.find(params[:id]) - if @signed_up_team.update(signed_up_teams_params) - render json: { message: "The team has been updated successfully. " }, status: 200 + result = SignedUpTeam.update_signed_up_team(params[:id], signed_up_teams_params) + + if result[:success] + render json: { message: result[:message] }, status: :ok else - render json: @signed_up_team.errors, status: :unprocessable_entity + render json: { message: result[:message] }, status: :unprocessable_entity end end # Sign up using parameters: team_id and topic_id - # Calls model method create_signed_up_team def sign_up - team_id = params[:team_id] - topic_id = params[:topic_id] - @signed_up_team = SignedUpTeam.create_signed_up_team(topic_id, team_id) - if @signed_up_team - render json: { message: "Signed up team successful!" }, status: :created + result = SignedUpTeam.sign_up_team_for_topic(params[:team_id], params[:topic_id]) + + if result[:success] + render json: result.except(:success), status: :created else - render json: { message: @signed_up_team.errors }, status: :unprocessable_entity + render json: { message: result[:message] }, status: :unprocessable_entity end end # Method for signing up as student - # Params : topic_id - # Get team_id using model method get_team_participants - # Call create_signed_up_team Model method def sign_up_student - user_id = params[:user_id] - topic_id = params[:topic_id] - team_id = SignedUpTeam.get_team_participants(user_id) - # @teams_user = TeamsUser.where(user_id: user_id).first - # team_id = @teams_user.team_id - @signed_up_team = SignedUpTeam.create_signed_up_team(topic_id, team_id) - # create(topic_id, team_id) - if @signed_up_team - render json: { message: "Signed up team successful!" }, status: :created + result = SignedUpTeam.sign_up_student_for_topic(params[:user_id], params[:topic_id]) + + if result[:success] + render json: result.except(:success), status: :created else - render json: { message: @signed_up_team.errors }, status: :unprocessable_entity + render json: { message: result[:message] }, status: :unprocessable_entity end end - # Delete signed_up team. Calls method delete_signed_up_team from the model. + # Delete signed_up team def destroy @signed_up_team = SignedUpTeam.find(params[:id]) if SignedUpTeam.delete_signed_up_team(@signed_up_team.team_id) render json: { message: 'Signed up teams was deleted successfully!' }, status: :ok else - render json: @signed_up_team.errors, status: :unprocessable_entity + render json: { message: 'Failed to delete signed up team' }, status: :unprocessable_entity + end + end + + # Drop a topic for a student + def drop_topic + result = SignedUpTeam.drop_topic_for_student(params[:user_id], params[:topic_id]) + + if result[:success] + render json: result.except(:success), status: :ok + else + status = result[:message].include?("not found") ? :not_found : :unprocessable_entity + render json: { message: result[:message] }, status: status + end + end + + # Drop a team from a topic (admin function) + def drop_team_from_topic + result = SignedUpTeam.drop_team_from_topic_by_admin(params[:topic_id], params[:team_id]) + + if result[:success] + render json: result.except(:success), status: :ok + else + status = result[:message].include?("not found") ? :not_found : :unprocessable_entity + render json: { message: result[:message] }, status: status end end diff --git a/app/models/assignment.rb b/app/models/assignment.rb index 144cd5369..8da10e48e 100644 --- a/app/models/assignment.rb +++ b/app/models/assignment.rb @@ -10,8 +10,8 @@ class Assignment < ApplicationRecord has_many :questionnaires, through: :assignment_questionnaires has_many :response_maps, foreign_key: 'reviewed_object_id', dependent: :destroy, inverse_of: :assignment has_many :review_mappings, class_name: 'ReviewResponseMap', foreign_key: 'reviewed_object_id', dependent: :destroy, inverse_of: :assignment - has_many :sign_up_topics , class_name: 'SignUpTopic', foreign_key: 'assignment_id', dependent: :destroy - has_many :due_dates,as: :parent, class_name: 'DueDate', dependent: :destroy + has_many :project_topics , class_name: 'ProjectTopic', foreign_key: 'assignment_id', dependent: :destroy + has_many :due_dates, class_name: 'DueDate', dependent: :destroy, as: :parent belongs_to :course, optional: true belongs_to :instructor, class_name: 'User', inverse_of: :assignments @@ -140,7 +140,7 @@ def staggered_and_no_topic?(topic_id) #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? - @has_topics ||= sign_up_topics.any? + @has_topics ||= project_topics.any? end #This method return if the given assignment is a team assignment. diff --git a/app/models/bookmark.rb b/app/models/bookmark.rb index 59b7ba68a..e2cca0f0a 100644 --- a/app/models/bookmark.rb +++ b/app/models/bookmark.rb @@ -2,7 +2,7 @@ class Bookmark < ApplicationRecord belongs_to :user - # belongs_to :topic, class_name: "SignUpTopic" + # belongs_to :topic, class_name: "ProjectTopic" has_many :bookmark_ratings validates :url, presence: true validates :title, presence: true diff --git a/app/models/project_topic.rb b/app/models/project_topic.rb new file mode 100644 index 000000000..e867e8d4b --- /dev/null +++ b/app/models/project_topic.rb @@ -0,0 +1,187 @@ +class ProjectTopic < ApplicationRecord + has_many :signed_up_teams, dependent: :destroy + has_many :teams, through: :signed_up_teams + belongs_to :assignment + + # Ensures the number of max choosers is non-negative + validates :max_choosers, numericality: { + only_integer: true, + greater_than_or_equal_to: 0 + } + + # Ensures topic name is present + validates :topic_name, presence: true + + # Attempts to sign up a team for this topic. + # If slots are available, it's confirmed; otherwise, waitlisted. + # Also removes any previous waitlist entries for the same team on other topics. + # CHANGED: Renamed from signup_team to sign_team_up for verb-based clarity (E2552) + def sign_team_up(team) + return false if signed_up_teams.exists?(team: team) + ActiveRecord::Base.transaction do + signed_up_team = signed_up_teams.create!( + team: team, + is_waitlisted: !slot_available? + ) + remove_from_waitlist(team) unless signed_up_team.is_waitlisted? + true + end + rescue ActiveRecord::RecordInvalid + false + end + + # Drops a team from this topic and promotes a waitlisted team if necessary. + def drop_team(team) + signed_up_team = signed_up_teams.find_by(team: team) + return unless signed_up_team + team_confirmed = !signed_up_team.is_waitlisted? + signed_up_team.destroy! + promote_waitlisted_team if team_confirmed + end + + # Returns the number of available slots left for this topic. + def available_slots + max_choosers - confirmed_teams_count + end + + # Checks if there are any open slots for this topic. + def slot_available? + available_slots.positive? + end + + # Returns all SignedUpTeam entries (both confirmed and waitlisted). + def get_signed_up_teams + signed_up_teams + end + + # Returns only confirmed teams associated with this topic. + def confirmed_teams + teams.joins(:signed_up_teams) + .where(signed_up_teams: { is_waitlisted: false }) + end + + # Returns only waitlisted teams ordered by signup time (FIFO). + def waitlisted_teams + teams.joins(:signed_up_teams) + .where(signed_up_teams: { is_waitlisted: true }) + .order('signed_up_teams.created_at ASC') + end + + # Business logic for creating a project topic + def self.create_topic_with_assignment(topic_params, assignment_id, micropayment = nil) + assignment = Assignment.find(assignment_id) + topic = new(topic_params) + topic.micropayment = micropayment if assignment.microtask? + + if topic.save + { + success: true, + message: "The topic: \"#{topic.topic_name}\" has been created successfully.", + topic: topic + } + else + { + success: false, + message: topic.errors.full_messages.join(', '), + errors: topic.errors + } + end + end + + # Business logic for updating a project topic + def update_topic(topic_params) + if update(topic_params) + { + success: true, + message: "The topic: \"#{topic_name}\" has been updated successfully.", + topic: self + } + else + { + success: false, + message: errors.full_messages.join(', '), + errors: errors + } + end + end + + # Business logic for finding topics by assignment and optional topic IDs + def self.find_by_assignment_and_topic_ids(assignment_id, topic_ids = nil) + if topic_ids.nil? + where(assignment_id: assignment_id) + else + where(assignment_id: assignment_id, topic_identifier: topic_ids) + end + end + + # Business logic for deleting topics by assignment and optional topic IDs + def self.delete_by_assignment_and_topic_ids(assignment_id, topic_ids = nil) + topics = find_by_assignment_and_topic_ids(assignment_id, topic_ids) + + if topics.exists? + topics.each(&:destroy!) + { + success: true, + message: "The topics have been deleted successfully.", + count: topics.count + } + else + { + success: false, + message: "No topics found to delete." + } + end + end + + # Converts ProjectTopic to JSON format with pre-calculated fields for API responses. + # This eliminates the need for frontend to calculate available_slots, confirmed_teams, + # and waitlisted_teams by providing complete data in a single API call. + def to_json_with_computed_data + as_json.merge( + available_slots: available_slots, + confirmed_teams: confirmed_teams.map { |team| + { + teamId: team.id.to_s, + members: team.users.map { |user| + { + id: user.id.to_s, + name: user.full_name || user.name + } + } + } + }, + waitlisted_teams: waitlisted_teams.map { |team| + { + teamId: team.id.to_s, + members: team.users.map { |user| + { + id: user.id.to_s, + name: user.full_name || user.name + } + } + } + } + ) + end + + private + + # Returns the count of teams confirmed for this topic. + def confirmed_teams_count + signed_up_teams.confirmed.count + end + + # Promotes the earliest waitlisted team to confirmed. + def promote_waitlisted_team + next_signup = SignedUpTeam.where(project_topic_id: id, is_waitlisted: true).order(:created_at).first + return unless next_signup + + next_signup.update_column(:is_waitlisted, false) + remove_from_waitlist(next_signup.team) + end + + # Removes waitlist entries for the given team from all other topics. + def remove_from_waitlist(team) + team.signed_up_teams.waitlisted.where.not(project_topic_id: id).destroy_all + end +end diff --git a/app/models/sign_up_topic.rb b/app/models/sign_up_topic.rb deleted file mode 100644 index 1d89b687b..000000000 --- a/app/models/sign_up_topic.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -class SignUpTopic < ApplicationRecord - has_many :signed_up_teams, foreign_key: 'topic_id', dependent: :destroy - has_many :teams, through: :signed_up_teams # list all teams choose this topic, no matter in waitlist or not - has_many :assignment_questionnaires, class_name: 'AssignmentQuestionnaire', foreign_key: 'topic_id', dependent: :destroy - has_many :due_dates, as: :parent,class_name: 'DueDate', dependent: :destroy - belongs_to :assignment -end diff --git a/app/models/signed_up_team.rb b/app/models/signed_up_team.rb index 5d1a47f19..a68175b92 100644 --- a/app/models/signed_up_team.rb +++ b/app/models/signed_up_team.rb @@ -1,6 +1,246 @@ # frozen_string_literal: true class SignedUpTeam < ApplicationRecord - belongs_to :sign_up_topic + # Scope to return confirmed signups + scope :confirmed, -> { where(is_waitlisted: false) } + + # Scope to return waitlisted signups + scope :waitlisted, -> { where(is_waitlisted: true) } + + belongs_to :project_topic belongs_to :team + + # Validations for presence and uniqueness of team-topic pairing + validates :project_topic, presence: true + validates :team, presence: true, + uniqueness: { scope: :project_topic } + + # Calls ProjectTopic's sign_team_up method to initiate signup + # CHANGED: Updated to call sign_team_up instead of signup_team (E2552) + def self.sign_up_for_topic(team, topic) + topic.sign_team_up(team) + end + + # Removes all signups (confirmed and waitlisted) for the given team + def self.remove_team_signups(team) + team.signed_up_teams.includes(:project_topic).each do |sut| + sut.project_topic.drop_team(team) + end + end + + # Returns all users in a given team + def self.find_team_participants(team_id) + team = Team.find_by(id: team_id) + return [] unless team + + team.users.to_a + end + + # Returns all users in a given team that's signed up for a topic + def self.find_project_topic_team_users(team_id) + signed_up_team = SignedUpTeam.find_by(team_id: team_id) + return [] unless signed_up_team + + find_team_participants(team_id) + end + + # Returns project topic the given user signed up for + def self.find_user_project_topic(user_id) + user = User.find_by(id: user_id) + return [] unless user + + ProjectTopic.joins(:signed_up_teams) + .where(signed_up_teams: { team_id: user.teams.pluck(:id) }) + .distinct.to_a + end + + # Creates a signed up team record and handles topic signup + def self.create_signed_up_team(topic_id, team_id) + return nil unless topic_id && team_id + + project_topic = ProjectTopic.find_by(id: topic_id) + team = Team.find_by(id: team_id) + + return nil unless project_topic && team + + # Use the existing sign_up_for_topic method which calls project_topic.sign_team_up + if sign_up_for_topic(team, project_topic) + # Find and return the created signed up team record + find_by(project_topic: project_topic, team: team) + else + nil + end + end + + # Deletes a signed up team and handles topic drop + def self.delete_signed_up_team(team_id) + team = Team.find_by(id: team_id) + return false unless team + + # Use the existing remove_team_signups method + remove_team_signups(team) + true + end + + # Gets any team ID for a given user (legacy behavior) + def self.get_team_participants(user_id) + user = User.find_by(id: user_id) + return nil unless user + + user.teams.first&.id + end + + # Gets the user's team ID for a specific assignment (preferred for student signup) + def self.get_team_for_assignment(user_id, assignment_id) + user = User.find_by(id: user_id) + return nil unless user && assignment_id + + user.teams.where(type: 'AssignmentTeam', parent_id: assignment_id).first&.id + end + + # Ensure a student has an AssignmentTeam for the given assignment. Creates one if missing. + def self.ensure_team_for_assignment(user_id, assignment_id) + return nil unless user_id && assignment_id + # If already has a team, return it + existing_id = get_team_for_assignment(user_id, assignment_id) + return existing_id if existing_id + + # Create a new assignment team and link the user + team = AssignmentTeam.create!(name: "Team-#{user_id}-#{assignment_id}", parent_id: assignment_id) + user = User.find_by(id: user_id) + # Create or fetch assignment participant with a valid handle + participant = AssignmentParticipant.find_or_initialize_by(user_id: user_id, parent_id: assignment_id) + if participant.new_record? + participant.handle = user&.handle.presence || user&.name || "user-#{user_id}" + participant.team_id = team.id + participant.save! + else + participant.update!(team_id: team.id) unless participant.team_id == team.id + end + TeamsParticipant.create!(team_id: team.id, user_id: user_id, participant_id: participant.id) + team.id + rescue StandardError + nil + end + + # Business logic for student signup with automatic topic switching + def self.sign_up_student_for_topic(user_id, topic_id) + assignment_id = ProjectTopic.find_by(id: topic_id)&.assignment_id + team_id = get_team_for_assignment(user_id, assignment_id) || ensure_team_for_assignment(user_id, assignment_id) || get_team_participants(user_id) + return { success: false, message: "User is not part of any team" } unless team_id + + # Drop any existing topic signups for this team + drop_existing_signups_for_team(team_id) + + # Sign up for the new topic + signed_up_team = create_signed_up_team(topic_id, team_id) + + if signed_up_team + { + success: true, + message: "Signed up team successful!", + signed_up_team: signed_up_team, + available_slots: signed_up_team.project_topic.available_slots + } + else + { success: false, message: "Failed to sign up for topic. Topic may be full or already signed up." } + end + end + + # Business logic for dropping a topic for a student + def self.drop_topic_for_student(user_id, topic_id) + assignment_id = ProjectTopic.find_by(id: topic_id)&.assignment_id + team_id = get_team_for_assignment(user_id, assignment_id) || get_team_participants(user_id) + return { success: false, message: "User is not part of any team" } unless team_id + + project_topic = ProjectTopic.find_by(id: topic_id) + team = Team.find_by(id: team_id) + + return { success: false, message: "Topic or team not found" } unless project_topic && team + + signed_up_team = find_by(project_topic: project_topic, team: team) + return { success: false, message: "Team is not signed up for this topic" } unless signed_up_team + + # Drop the team from the topic + project_topic.drop_team(team) + + { + success: true, + message: "Successfully dropped topic!", + available_slots: project_topic.available_slots + } + end + + # Business logic for admin dropping a team from a topic + def self.drop_team_from_topic_by_admin(topic_id, team_id) + project_topic = ProjectTopic.find_by(id: topic_id) + team = Team.find_by(id: team_id) + + return { success: false, message: "Topic or team not found" } unless project_topic && team + + signed_up_team = find_by(project_topic: project_topic, team: team) + return { success: false, message: "Team is not signed up for this topic" } unless signed_up_team + + # Drop the team from the topic + project_topic.drop_team(team) + + { + success: true, + message: "Successfully dropped team from topic!", + available_slots: project_topic.available_slots + } + end + + # Business logic for team signup + def self.sign_up_team_for_topic(team_id, topic_id) + signed_up_team = create_signed_up_team(topic_id, team_id) + + if signed_up_team + { + success: true, + message: "Signed up team successful!", + signed_up_team: signed_up_team + } + else + { success: false, message: "Failed to sign up for topic" } + end + end + + # Business logic for updating signed up team + def self.update_signed_up_team(id, params) + signed_up_team = find(id) + + if signed_up_team.update(params) + { + success: true, + message: "The team has been updated successfully.", + signed_up_team: signed_up_team + } + else + { + success: false, + message: signed_up_team.errors.full_messages.join(', '), + errors: signed_up_team.errors + } + end + end + + # Business logic for getting team participants for a topic + def self.get_team_participants_for_topic(topic_id) + project_topic = ProjectTopic.find_by(id: topic_id) + return { success: false, message: "Topic not found" } unless project_topic + + participants = find_team_participants(project_topic.assignment_id) + { success: true, participants: participants } + end + + private + + # Helper method to drop existing signups for a team + def self.drop_existing_signups_for_team(team_id) + existing_signups = where(team_id: team_id) + existing_signups.each do |signup| + signup.project_topic.drop_team(signup.team) + end + end end diff --git a/app/models/user.rb b/app/models/user.rb index 65dd59724..85d470bc3 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -17,8 +17,8 @@ class User < ApplicationRecord has_many :users, foreign_key: 'parent_id', dependent: :nullify has_many :invitations has_many :assignments - has_many :teams_users, dependent: :destroy - has_many :teams, through: :teams_users + has_many :teams_participants, dependent: :destroy + has_many :teams, through: :teams_participants has_many :participants scope :students, -> { where role_id: Role::STUDENT } diff --git a/config/routes.rb b/config/routes.rb index b77a95f63..786f85f7d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -81,6 +81,8 @@ collection do post '/sign_up', to: 'signed_up_teams#sign_up' post '/sign_up_student', to: 'signed_up_teams#sign_up_student' + delete '/drop_topic', to: 'signed_up_teams#drop_topic' + delete '/drop_team_from_topic', to: 'signed_up_teams#drop_team_from_topic' end end @@ -92,10 +94,10 @@ - resources :sign_up_topics do + resources :project_topics do collection do get :filter - delete '/', to: 'sign_up_topics#destroy' + delete '/', to: 'project_topics#destroy' end end diff --git a/db/migrate/20231129050431_create_sign_up_topics.rb b/db/migrate/20231129050431_create_sign_up_topics.rb index e7bbb15d7..0af1d2928 100644 --- a/db/migrate/20231129050431_create_sign_up_topics.rb +++ b/db/migrate/20231129050431_create_sign_up_topics.rb @@ -2,7 +2,7 @@ class CreateSignUpTopics < ActiveRecord::Migration[7.0] def change - create_table :sign_up_topics do |t| + create_table :project_topics do |t| t.text :topic_name, null: false t.references :assignment, null: false, foreign_key: true t.integer :max_choosers, default: 0, null: false diff --git a/db/migrate/20250319015434_rename_sign_up_topics_to_project_topics.rb b/db/migrate/20250319015434_rename_sign_up_topics_to_project_topics.rb new file mode 100644 index 000000000..018520189 --- /dev/null +++ b/db/migrate/20250319015434_rename_sign_up_topics_to_project_topics.rb @@ -0,0 +1,4 @@ +class RenameSignUpTopicsToProjectTopics < ActiveRecord::Migration[8.0] + def change + end +end diff --git a/db/migrate/20250321222753_rename_sign_up_topic_to_project_topic_in_signed_up_teams.rb b/db/migrate/20250321222753_rename_sign_up_topic_to_project_topic_in_signed_up_teams.rb new file mode 100644 index 000000000..1d9305315 --- /dev/null +++ b/db/migrate/20250321222753_rename_sign_up_topic_to_project_topic_in_signed_up_teams.rb @@ -0,0 +1,8 @@ +class RenameSignUpTopicToProjectTopicInSignedUpTeams < ActiveRecord::Migration[8.0] + def change + rename_column :signed_up_teams, :sign_up_topic_id, :project_topic_id + rename_index :signed_up_teams, + :index_signed_up_teams_on_sign_up_topic_id, + :index_signed_up_teams_on_project_topic_id + end +end diff --git a/db/migrate/20251028200538_add_allow_bookmarks_to_assignments.rb b/db/migrate/20251028200538_add_allow_bookmarks_to_assignments.rb new file mode 100644 index 000000000..a69574ac7 --- /dev/null +++ b/db/migrate/20251028200538_add_allow_bookmarks_to_assignments.rb @@ -0,0 +1,5 @@ +class AddAllowBookmarksToAssignments < ActiveRecord::Migration[8.0] + def change + add_column :assignments, :allow_bookmarks, :boolean, default: false, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 462029322..8d854a75a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_04_27_014225) do +ActiveRecord::Schema[8.0].define(version: 2025_10_28_200538) do create_table "account_requests", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "username" t.string "full_name" @@ -102,6 +102,7 @@ t.boolean "enable_pair_programming", default: false t.boolean "has_teams", default: false t.boolean "has_topics", default: false + t.boolean "allow_bookmarks", default: false, null: false t.index ["course_id"], name: "index_assignments_on_course_id" t.index ["instructor_id"], name: "index_assignments_on_instructor_id" end @@ -238,6 +239,22 @@ t.index ["user_id"], name: "index_participants_on_user_id" end + create_table "project_topics", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.text "topic_name", null: false + t.bigint "assignment_id", null: false + t.integer "max_choosers", default: 0, null: false + t.text "category" + t.string "topic_identifier", limit: 10 + t.integer "micropayment", default: 0 + t.integer "private_to" + t.text "description" + t.string "link" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["assignment_id"], name: "fk_sign_up_categories_sign_up_topics" + t.index ["assignment_id"], name: "index_project_topics_on_assignment_id" + end + create_table "question_advices", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "question_id", null: false t.integer "score" @@ -307,30 +324,14 @@ t.index ["parent_id"], name: "fk_rails_4404228d2f" end - create_table "sign_up_topics", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.text "topic_name", null: false - t.bigint "assignment_id", null: false - t.integer "max_choosers", default: 0, null: false - t.text "category" - t.string "topic_identifier", limit: 10 - t.integer "micropayment", default: 0 - t.integer "private_to" - t.text "description" - t.string "link" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["assignment_id"], name: "fk_sign_up_categories_sign_up_topics" - t.index ["assignment_id"], name: "index_sign_up_topics_on_assignment_id" - end - create_table "signed_up_teams", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.bigint "sign_up_topic_id", null: false + t.bigint "project_topic_id", null: false t.bigint "team_id", null: false t.boolean "is_waitlisted" t.integer "preference_priority_number" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["sign_up_topic_id"], name: "index_signed_up_teams_on_sign_up_topic_id" + t.index ["project_topic_id"], name: "index_signed_up_teams_on_project_topic_id" t.index ["team_id"], name: "index_signed_up_teams_on_team_id" end @@ -416,10 +417,10 @@ add_foreign_key "participants", "join_team_requests" add_foreign_key "participants", "teams" add_foreign_key "participants", "users" + add_foreign_key "project_topics", "assignments" add_foreign_key "question_advices", "items", column: "question_id" add_foreign_key "roles", "roles", column: "parent_id", on_delete: :cascade - add_foreign_key "sign_up_topics", "assignments" - add_foreign_key "signed_up_teams", "sign_up_topics" + add_foreign_key "signed_up_teams", "project_topics" add_foreign_key "signed_up_teams", "teams" add_foreign_key "ta_mappings", "courses" add_foreign_key "ta_mappings", "users" diff --git a/db/seeds.rb b/db/seeds.rb index 9828977ea..32eee304a 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -5,7 +5,20 @@ inst_id = Institution.create!( name: 'North Carolina State University', ).id - + + roles = {} + + roles[:super_admin] = Role.find_or_create_by!(name: "Super Administrator", parent_id: nil) + + roles[:admin] = Role.find_or_create_by!(name: "Administrator", parent_id: roles[:super_admin].id) + + roles[:instructor] = Role.find_or_create_by!(name: "Instructor", parent_id: roles[:admin].id) + + roles[:ta] = Role.find_or_create_by!(name: "Teaching Assistant", parent_id: roles[:instructor].id) + + roles[:student] = Role.find_or_create_by!(name: "Student", parent_id: roles[:ta].id) + + puts "reached here" # Create an admin user User.create!( name: 'admin', @@ -15,6 +28,19 @@ institution_id: 1, role_id: 1 ) + + # Create test student users student1..student5 for easy testing + (1..5).each do |i| + created_student = User.create!( + name: "student#{i}", + email: "student#{i}@test.com", + password: 'password123', + full_name: "Student #{i}", + institution_id: 1, + role_id: 5 + ) + puts "Created test student: #{created_student.email} with password: password123" + end #Generate Random Users @@ -85,26 +111,23 @@ ).id end - puts "assigning students to teams" - teams_users_ids = [] - #num_students.times do |i| - # teams_users_ids << TeamsUser.create( - # team_id: team_ids[i%num_teams], - # user_id: student_user_ids[i] - # ).id - #end - + puts "assigning students to teams (TeamsParticipant)" + teams_participant_ids = [] num_students.times do |i| - puts "Creating TeamsUser with team_id: #{team_ids[i % num_teams]}, user_id: #{student_user_ids[i]}" - teams_user = TeamsUser.create( - team_id: team_ids[i % num_teams], - user_id: student_user_ids[i] + team_id = team_ids[i % num_teams] + user_id = student_user_ids[i] + participant = AssignmentParticipant.find_by(user_id: user_id, parent_id: assignment_ids[i%num_assignments]) + participant ||= AssignmentParticipant.create(user_id: user_id, parent_id: assignment_ids[i%num_assignments], team_id: team_id) + + tp = TeamsParticipant.create( + team_id: team_id, + user_id: user_id, + participant_id: participant.id ) - if teams_user.persisted? - teams_users_ids << teams_user.id - puts "Created TeamsUser with ID: #{teams_user.id}" + if tp.persisted? + teams_participant_ids << tp.id else - puts "Failed to create TeamsUser: #{teams_user.errors.full_messages.join(', ')}" + puts "Failed to create TeamsParticipant: #{tp.errors.full_messages.join(', ')}" end end @@ -114,10 +137,33 @@ participant_ids << AssignmentParticipant.create( user_id: student_user_ids[i], parent_id: assignment_ids[i%num_assignments], - team_id: team_ids[i%num_teams], + team_id: team_ids[i%num_teams] ).id end + puts "creating project topics for testing" + if assignment_ids.any? + # Generate random topics for each assignment + assignment_ids.each do |assignment_id| + num_topics = rand(3..6) + + num_topics.times do |i| + # Ensure topic_identifier within 10 chars limit + identifier = "T" + Faker::Alphanumeric.alphanumeric(number: 5).upcase + ProjectTopic.create!( + topic_identifier: identifier, + topic_name: Faker::Educator.course_name, + category: Faker::Book.genre, + max_choosers: rand(2..5), + description: Faker::Lorem.sentence(word_count: 10), + link: Faker::Internet.url, + assignment_id: assignment_id + ) + end + puts "Created #{num_topics} topics for assignment #{assignment_id}" + end + end + @@ -126,5 +172,6 @@ rescue ActiveRecord::RecordInvalid => e + puts e.message puts 'The db has already been seeded' end diff --git a/docker-compose.yml b/docker-compose.yml index f22dc27ef..f2e7f52c4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,15 @@ services: app: build: . - command: tail -f /dev/null + entrypoint: ["/bin/bash", "-c"] + command: > + "bundle install && + rm -f tmp/pids/server.pid && + rake db:drop || true && + rake db:create && + rake db:migrate && + rake db:seed && + rails s -p 3002 -b '0.0.0.0'" environment: RAILS_ENV: development DATABASE_URL: mysql2://root:expertiza@db:3306/reimplementation? diff --git a/safe.log b/safe.log new file mode 100644 index 000000000..b9b045a3c --- /dev/null +++ b/safe.log @@ -0,0 +1,3 @@ +2025-04-16T04:36:19.538819Z mysqld_safe Logging to '/var/log/mysql/error.log'. +2025-04-16T04:36:19.556429Z mysqld_safe Starting mysqld daemon with databases from /var/lib/mysql +2025-04-16T04:36:56.271171Z mysqld_safe mysqld from pid file /var/lib/mysql/DESKTOP-JM8MLSR.pid ended diff --git a/spec/factories/assignments.rb b/spec/factories/assignments.rb index 33ec9f14e..bd851ec4f 100644 --- a/spec/factories/assignments.rb +++ b/spec/factories/assignments.rb @@ -4,6 +4,7 @@ FactoryBot.define do factory :assignment do sequence(:name) { |n| "Assignment #{n}" } + microtask { false } directory_path { "assignment_#{name.downcase.gsub(/\s+/, '_')}" } # Required associations diff --git a/spec/factories/project_topics.rb b/spec/factories/project_topics.rb new file mode 100644 index 000000000..2891f001b --- /dev/null +++ b/spec/factories/project_topics.rb @@ -0,0 +1,9 @@ +FactoryBot.define do + factory :project_topic do + sequence(:topic_name) { |n| "Topic #{n}" } + sequence(:topic_identifier) { |n| "T#{n}" } + max_choosers { 2 } + category { "General" } + association :assignment + end +end \ No newline at end of file diff --git a/spec/models/due_date_spec.rb b/spec/models/due_date_spec.rb index 510a583a5..c0733a5bf 100644 --- a/spec/models/due_date_spec.rb +++ b/spec/models/due_date_spec.rb @@ -138,12 +138,12 @@ end end - context 'when parent_type is SignUpTopic' do + context 'when parent_type is ProjectTopic' do let!(:assignment) { Assignment.create!(id: 2, name: 'Test Assignment', instructor:) } let!(:assignment2) { Assignment.create(id: 6, name: 'Test Assignment2', instructor:) } - let!(:topic1) { SignUpTopic.create!(id: 2, topic_name: 'Test Topic', assignment:) } - let!(:topic2) { SignUpTopic.create(id: 4, topic_name: 'Test Topic2', assignment: assignment2) } - let!(:topic3) { SignUpTopic.create(id: 5, topic_name: 'Test Topic2', assignment: assignment2) } + let!(:topic1) { ProjectTopic.create!(id: 2, topic_name: 'Test Topic', assignment:) } + let!(:topic2) { ProjectTopic.create(id: 4, topic_name: 'Test Topic2', assignment: assignment2) } + let!(:topic3) { ProjectTopic.create(id: 5, topic_name: 'Test Topic2', assignment: assignment2) } let!(:topic_due_date1) do DueDate.create(parent: topic1, due_at: 2.days.from_now, submission_allowed_id: 3, review_allowed_id: 3, deadline_type_id: 3, type: 'TopicDueDate') diff --git a/spec/models/project_topic_spec.rb b/spec/models/project_topic_spec.rb new file mode 100644 index 000000000..939544609 --- /dev/null +++ b/spec/models/project_topic_spec.rb @@ -0,0 +1,268 @@ +require 'rails_helper' + +RSpec.describe ProjectTopic, type: :model do + let!(:role) { Role.find_or_create_by!(name: "Instructor") } + let!(:instructor) do + Instructor.create!( + name: "test_instructor", + password: "password", + full_name: "Test Instructor", + email: "instructor@example.com", + role: role + ) + end + let!(:assignment) { Assignment.create!(name: "Test Assignment", instructor: instructor) } + let!(:project_topic) { ProjectTopic.create!(topic_name: "Test Topic", assignment: assignment, max_choosers: 2) } + # CHANGED: Updated to use AssignmentTeam instead of generic Team for proper validation (E2552) + let!(:team) { AssignmentTeam.create!(name: "Test Team", assignment: assignment) } + + # CHANGED: Updated test description to reflect renamed method (E2552) + describe '#sign_team_up' do + context 'when slots are available' do + it 'adds team as confirmed' do + # This test verifies that a team is added as confirmed when slots are available. + expect(project_topic.sign_team_up(team)).to be true + expect(project_topic.confirmed_teams).to include(team) + end + + it 'removes team from waitlist of other topics' do + # This checks that a team signing up for one topic is removed from the waitlists of other topics. + other_topic = ProjectTopic.create!(topic_name: "Other Topic", assignment: assignment, max_choosers: 1) + other_topic.sign_team_up(team) + project_topic.sign_team_up(team) + expect(other_topic.reload.waitlisted_teams).not_to include(team) + end + end + + context 'when slots are full' do + before do + # Fill all slots before each test in this context. + 2.times { project_topic.sign_team_up(AssignmentTeam.create!(name: "Team", assignment: assignment)) } + end + + it 'adds team to waitlist' do + # When no slots are available, the team should be added to the waitlist. + new_team = AssignmentTeam.create!(name: "Team", assignment: assignment) + expect(project_topic.sign_team_up(new_team)).to be true + expect(project_topic.waitlisted_teams).to include(new_team) + end + end + + context 'when team already signed up' do + before { project_topic.sign_team_up(team) } + it 'returns false' do + # A team cannot sign up more than once. The method returns false if already signed up. + expect(project_topic.sign_team_up(team)).to be false + end + end + + it 'does not raise exception when transaction fails' do + # This test simulates an error in ActiveRecord and ensures the method handles it gracefully. + allow(project_topic).to receive(:signed_up_teams).and_raise(ActiveRecord::RecordInvalid) + expect(project_topic.sign_team_up(team)).to be false + end + end + + describe '#drop_team' do + before { project_topic.sign_team_up(team) } + + it 'returns nil if team is not signed up' do + # Verifies that dropping a team not signed up to the topic returns nil. + new_team = AssignmentTeam.create!(name: "Team", assignment: assignment) + expect(project_topic.drop_team(new_team)).to be_nil + end + + it 'does not raise error for non-existent team' do + # Ensures dropping a non-existent team doesn't raise an exception. + phantom_team = double("Team", id: -1) + expect { project_topic.drop_team(phantom_team) }.not_to raise_error + end + end + + describe '#available_slots' do + it 'returns correct number of slots' do + # Confirms available_slots returns the correct number after signups. + expect(project_topic.available_slots).to eq(2) + project_topic.sign_team_up(team) + expect(project_topic.available_slots).to eq(1) + end + + it 'returns 0 when full' do + # Ensures it returns 0 when max_choosers is reached. + 2.times { project_topic.sign_team_up(AssignmentTeam.create!(name: "Team", assignment: assignment)) } + expect(project_topic.available_slots).to eq(0) + end + end + + describe '#get_signed_up_teams' do + it 'returns all signed up teams' do + # Checks that all teams, both confirmed and waitlisted, are returned. + teams = 3.times.map { AssignmentTeam.create!(name: "Team", assignment: assignment) } + teams.each { |t| project_topic.sign_team_up(t) } + expect(project_topic.get_signed_up_teams.pluck(:team_id)).to include(*teams.map(&:id)) + end + + it 'returns only SignedUpTeam records' do + # Verifies that returned records are of the SignedUpTeam model. + team1 = AssignmentTeam.create!(name: "Team", assignment: assignment) + project_topic.sign_team_up(team1) + expect(project_topic.get_signed_up_teams.first).to be_a(SignedUpTeam) + end + end + + describe '#slot_available?' do + it 'returns true when slots are available' do + # Confirms slot_available? returns true before topic is full. + expect(project_topic.slot_available?).to be true + end + + it 'returns false when no slots are left' do + # Confirms slot_available? returns false once topic is full. + 2.times { project_topic.sign_team_up(AssignmentTeam.create!(name: "Team", assignment: assignment)) } + expect(project_topic.slot_available?).to be false + end + end + + describe '#confirmed_teams' do + it 'returns only confirmed teams' do + # Verifies that confirmed_teams returns only those not waitlisted. + project_topic.sign_team_up(team) + expect(project_topic.confirmed_teams).to contain_exactly(team) + end + + it 'returns empty array if no confirmed teams' do + # Returns an empty array when no confirmed signups exist. + expect(project_topic.confirmed_teams).to be_empty + end + end + + describe '#waitlisted_teams' do + it 'returns waitlisted teams in order' do + # Ensures waitlisted teams are returned in the order they were added. + 5.times { project_topic.sign_team_up(AssignmentTeam.create!(name: "Team", assignment: assignment)) } + waitlisted = project_topic.waitlisted_teams + expect(waitlisted.size).to eq(3) + expect(waitlisted).to eq(waitlisted.sort_by(&:created_at)) + end + + it 'returns empty array if no waitlisted teams' do + # Returns an empty array when no teams are waitlisted. + expect(project_topic.waitlisted_teams).to eq([]) + end + end + + describe 'validations' do + it 'requires topic_name' do + # Validates presence of topic_name field. + topic = ProjectTopic.new(assignment: assignment, max_choosers: 1) + expect(topic).not_to be_valid + expect(topic.errors[:topic_name]).to include("can't be blank") + end + + it 'requires non-negative integer for max_choosers' do + # Validates that max_choosers is a non-negative number. + topic = ProjectTopic.new(topic_name: "Invalid", assignment: assignment, max_choosers: -1) + expect(topic).not_to be_valid + expect(topic.errors[:max_choosers]).to include("must be greater than or equal to 0") + end + + # CHANGED: Added new test case for zero max_choosers validation (E2552) + it 'allows zero max_choosers for waitlist-only topics' do + # Validates that max_choosers can be zero for waitlist-only topics. + topic = ProjectTopic.new(topic_name: "Waitlist Only", assignment: assignment, max_choosers: 0) + expect(topic).to be_valid + end + end + + describe 'functional checks' do + it 'increases confirmed team count on signup' do + # Ensures that the count of confirmed teams increases after signup. + expect { project_topic.sign_team_up(team) }.to change { project_topic.confirmed_teams.count }.by(1) + end + + it 'does not allow more than max_choosers confirmed teams' do + # Confirms that additional teams beyond limit go to waitlist. + t1 = AssignmentTeam.create!(name: "Team", assignment: assignment) + t2 = AssignmentTeam.create!(name: "Team", assignment: assignment) + t3 = AssignmentTeam.create!(name: "Team", assignment: assignment) + project_topic.sign_team_up(t1) + project_topic.sign_team_up(t2) + project_topic.sign_team_up(t3) + expect(project_topic.confirmed_teams.count).to eq(2) + expect(project_topic.waitlisted_teams.count).to eq(1) + end + + it 'removes team’s other waitlisted entries on confirmed signup' do + # Ensures a confirmed team is removed from other topic waitlists. + t = AssignmentTeam.create!(name: "Team", assignment: assignment) + t1 = ProjectTopic.create!(topic_name: "Alt Topic", assignment: assignment, max_choosers: 0) + t1.sign_team_up(t) + expect(t1.waitlisted_teams).to include(t) + project_topic.sign_team_up(t) + expect(t1.reload.waitlisted_teams).not_to include(t) + end + + it 'get_signed_up_teams includes waitlisted and confirmed teams' do + # Validates that all signed-up teams, regardless of status, are returned. + t1 = AssignmentTeam.create!(name: "Team", assignment: assignment) + t2 = AssignmentTeam.create!(name: "Team", assignment: assignment) + project_topic.sign_team_up(t1) + project_topic.sign_team_up(t2) + expect(project_topic.get_signed_up_teams.map(&:team_id)).to include(t1.id, t2.id) + end + + it 'slot_available? reflects accurate state after signup and drop' do + # Checks dynamic behavior of slot availability after signup and drop. + t1 = AssignmentTeam.create!(name: "Team", assignment: assignment) + t2 = AssignmentTeam.create!(name: "Team", assignment: assignment) + project_topic.sign_team_up(t1) + project_topic.sign_team_up(t2) + expect(project_topic.slot_available?).to be false + project_topic.drop_team(t1) + expect(project_topic.slot_available?).to be true + end + + it 'signed_up_team records are removed when team is dropped' do + # Confirms that dropping a team deletes the associated record. + project_topic.sign_team_up(team) + expect { project_topic.drop_team(team) }.to change { SignedUpTeam.count }.by(-1) + end + + it 'multiple topics maintain independent signups' do + # Ensures that signups in one topic do not affect another topic. + topic2 = ProjectTopic.create!(topic_name: "Topic 2", assignment: assignment, max_choosers: 1) + team2 = AssignmentTeam.create!(name: "Team", assignment: assignment) + project_topic.sign_team_up(team) + topic2.sign_team_up(team2) + expect(project_topic.confirmed_teams).to include(team) + expect(topic2.confirmed_teams).to include(team2) + end + + it 'promotes the earliest waitlisted team after dropping a confirmed one' do + # Ensures that when a confirmed team is dropped, the earliest waitlisted is promoted. + t1 = AssignmentTeam.create!(name: "Team", assignment: assignment) + t2 = AssignmentTeam.create!(name: "Team", assignment: assignment) + t3 = AssignmentTeam.create!(name: "Team", assignment: assignment) + project_topic.sign_team_up(t1) + project_topic.sign_team_up(t2) + project_topic.sign_team_up(t3) + expect(project_topic.waitlisted_teams.first).to eq(t3) + project_topic.drop_team(t1) + expect(project_topic.confirmed_teams).to include(t2, t3) + expect(project_topic.waitlisted_teams).to be_empty + end + + it 'does not increase available slots after promoting a waitlisted team' do + # Verifies that slot count remains constant when a waitlisted team is promoted. + t1 = AssignmentTeam.create!(name: "Team", assignment: assignment) + t2 = AssignmentTeam.create!(name: "Team", assignment: assignment) + t3 = AssignmentTeam.create!(name: "Team", assignment: assignment) + project_topic.sign_team_up(t1) + project_topic.sign_team_up(t2) + project_topic.sign_team_up(t3) + expect(project_topic.available_slots).to eq(0) + project_topic.drop_team(t1) + expect(project_topic.available_slots).to eq(0) + end + end +end diff --git a/spec/models/signed_up_team_spec.rb b/spec/models/signed_up_team_spec.rb new file mode 100644 index 000000000..52afc3bcc --- /dev/null +++ b/spec/models/signed_up_team_spec.rb @@ -0,0 +1,272 @@ +# spec/models/signed_up_team_spec.rb +require 'rails_helper' + +RSpec.describe SignedUpTeam, type: :model do + # Setup roles, users, assignment, topic and team to be reused across tests + let!(:role) { Role.find_or_create_by!(name: "Instructor") } + let!(:student_role) { Role.find_or_create_by!(name: "Student") } + + let!(:instructor) do + User.create!( + name: "test_instructor", + full_name: "Test Instructor", + password: "password", + email: "instructor@example.com", + role: role + ) + end + + let!(:assignment) { Assignment.create!(name: "Test Assignment", instructor: instructor) } + let!(:project_topic) { ProjectTopic.create!(topic_name: "Test Topic", assignment: assignment) } + let!(:team) { AssignmentTeam.create!(name: "Test Team", assignment: assignment) } + + describe 'validations' do + # Ensure a project_topic is mandatory + it 'requires a project topic' do + sut = SignedUpTeam.new(team: team) + expect(sut).not_to be_valid + expect(sut.errors[:project_topic]).to include("must exist") + end + + # Ensure a team is mandatory + it 'requires a team' do + sut = SignedUpTeam.new(project_topic: project_topic) + expect(sut).not_to be_valid + expect(sut.errors[:team]).to include("must exist") + end + + # Ensure uniqueness of team per project_topic + it 'enforces unique team per project topic' do + SignedUpTeam.create!(project_topic: project_topic, team: team) + duplicate = SignedUpTeam.new(project_topic: project_topic, team: team) + expect(duplicate).not_to be_valid + expect(duplicate.errors[:team]).to include("has already been taken") + end + end + + describe 'scopes' do + # Create one confirmed and one waitlisted signup for testing scopes + let!(:confirmed_signup) { SignedUpTeam.create!(project_topic: project_topic, team: team, is_waitlisted: false) } + let!(:waitlisted_signup) { SignedUpTeam.create!(project_topic: project_topic, team: AssignmentTeam.create!(name: "Team", assignment: assignment), is_waitlisted: true) } + + # Scope should only return confirmed signups + it 'returns confirmed signups' do + expect(SignedUpTeam.confirmed).to contain_exactly(confirmed_signup) + end + + # Scope should only return waitlisted signups + it 'returns waitlisted signups' do + expect(SignedUpTeam.waitlisted).to contain_exactly(waitlisted_signup) + end + end + + # CHANGED: Updated test description to reflect renamed method (E2552) + describe 'sign_up_for_topic' do + # Should delegate logic to ProjectTopic's sign_team_up + it 'delegates to project topic signup' do + allow(project_topic).to receive(:sign_team_up).with(team).and_return(true) + result = SignedUpTeam.sign_up_for_topic(team, project_topic) + expect(result).to be true + expect(project_topic).to have_received(:sign_team_up).with(team) + end + + # Should return false if ProjectTopic rejects the signup + it 'returns false if topic rejects signup' do + allow(project_topic).to receive(:sign_team_up).with(team).and_return(false) + result = SignedUpTeam.sign_up_for_topic(team, project_topic) + expect(result).to be false + end + end + + describe 'remove_team_signups' do + # Setup two topics and signups for deletion tests + let!(:topic1) { ProjectTopic.create!(topic_name: "Topic 1", assignment: assignment) } + let!(:topic2) { ProjectTopic.create!(topic_name: "Topic 2", assignment: assignment) } + let!(:signup1) { SignedUpTeam.create!(project_topic: topic1, team: team) } + let!(:signup2) { SignedUpTeam.create!(project_topic: topic2, team: team) } + + # Should remove all signups for the team across all topics + it 'removes all team signups across topics' do + expect { + SignedUpTeam.remove_team_signups(team) + }.to change(SignedUpTeam, :count).by(-2) + end + + # Should not error if team has no signups + it 'does not raise error if team has no signups' do + new_team = AssignmentTeam.create!(name: "Team", assignment: assignment) + expect { SignedUpTeam.remove_team_signups(new_team) }.not_to raise_error + end + end + + describe 'custom methods' do + # Create test users to populate team + let!(:user1) do + User.create!( + name: "Alice", + full_name: "Alice Wonderland", + password: "password", + email: "alice@example.com", + role: student_role + ) + end + + let!(:user2) do + User.create!( + name: "Bob", + full_name: "Bob Builder", + password: "password", + email: "bob@example.com", + role: student_role + ) + end + + before do + # Create participants and add them to the team properly + participant1 = AssignmentParticipant.create!(parent_id: assignment.id, user: user1, handle: user1.name) + participant2 = AssignmentParticipant.create!(parent_id: assignment.id, user: user2, handle: user2.name) + TeamsParticipant.create!(team: team, participant: participant1, user: user1) + TeamsParticipant.create!(team: team, participant: participant2, user: user2) + end + + describe '.find_team_participants' do + # Should return users of a valid team + it 'returns all users in a given team' do + participants = SignedUpTeam.find_team_participants(team.id) + expect(participants).to contain_exactly(user1, user2) + end + + # Invalid team_id should return [] + it 'returns empty array if team does not exist' do + expect(SignedUpTeam.find_team_participants(-1)).to eq([]) + end + + # Team with no users should return [] + it 'returns empty array when team exists but has no users' do + new_team = AssignmentTeam.create!(name: "Team", assignment: assignment) + expect(SignedUpTeam.find_team_participants(new_team.id)).to eq([]) + end + end + + describe '.find_project_topic_team_users' do + let!(:sut) { SignedUpTeam.create!(project_topic: project_topic, team: team) } + + # Should return users of a team signed up for a topic + it 'returns all users in a team that signed up for a topic' do + users = SignedUpTeam.find_project_topic_team_users(team.id) + expect(users).to contain_exactly(user1, user2) + end + + # Should return [] if team is not signed up to a topic + it 'returns empty array if no signed up team found' do + new_team = AssignmentTeam.create!(name: "Team", assignment: assignment) + expect(SignedUpTeam.find_project_topic_team_users(new_team.id)).to eq([]) + end + + # Gracefully handle nil + it 'handles nil team_id gracefully' do + expect(SignedUpTeam.find_project_topic_team_users(nil)).to eq([]) + end + + # CHANGED: Added test to verify DRY principle is applied (E2552) + # Should use find_team_participants internally (DRY principle) + it 'delegates to find_team_participants method' do + allow(SignedUpTeam).to receive(:find_team_participants).with(team.id).and_return([user1, user2]) + SignedUpTeam.find_project_topic_team_users(team.id) + expect(SignedUpTeam).to have_received(:find_team_participants).with(team.id) + end + end + + describe '.find_user_project_topic' do + let!(:sut) { SignedUpTeam.create!(project_topic: project_topic, team: team) } + + # Returns project topic the user signed up for + it 'returns project topic signed up by user' do + topics = SignedUpTeam.find_user_project_topic(user1.id) + expect(topics).to include(project_topic) + end + + # Should return empty array for unknown user + it 'returns empty array if user has no teams or no signups' do + unknown = User.create!( + name: "Ghost", + full_name: "Ghost User", + password: "password", + email: "ghost@example.com", + role: student_role + ) + expect(SignedUpTeam.find_user_project_topic(unknown.id)).to eq([]) + end + + # Gracefully handle nil user_id + it 'handles nil user_id gracefully' do + expect(SignedUpTeam.find_user_project_topic(nil)).to eq([]) + end + end + end + + describe 'functional behavior' do + # Should create a record on successful signup + it 'creates a record when sign_up_for_topic succeeds' do + expect { + SignedUpTeam.sign_up_for_topic(team, project_topic) + }.to change { SignedUpTeam.count }.by(1) + end + + # Should prevent duplicate signups + it 'does not create a duplicate signup' do + SignedUpTeam.sign_up_for_topic(team, project_topic) + expect { + SignedUpTeam.sign_up_for_topic(team, project_topic) + }.not_to change { SignedUpTeam.count } + end + + # Ensure cleanup removes all records + it 'remove_team_signups deletes all signups for team' do + topic1 = ProjectTopic.create!(topic_name: "Another Topic", assignment: assignment) + topic2 = ProjectTopic.create!(topic_name: "Third Topic", assignment: assignment) + SignedUpTeam.sign_up_for_topic(team, topic1) + SignedUpTeam.sign_up_for_topic(team, topic2) + + expect { + SignedUpTeam.remove_team_signups(team) + }.to change { SignedUpTeam.count }.by(-2) + end + + # Scopes should work correctly with multiple signups + it 'confirmed and waitlisted scopes handle multiple entries' do + confirmed = SignedUpTeam.create!(project_topic: project_topic, team: team, is_waitlisted: false) + waitlisted = SignedUpTeam.create!( + project_topic: ProjectTopic.create!(topic_name: "Waitlist Topic", assignment: assignment), + team: AssignmentTeam.create!(name: "Team", assignment: assignment), + is_waitlisted: true + ) + expect(SignedUpTeam.confirmed).to include(confirmed) + expect(SignedUpTeam.waitlisted).to include(waitlisted) + end + + # CHANGED: Added comprehensive test cases for waitlisting and team removal (E2552) + # Test waitlisting behavior + it 'creates waitlisted signup when topic is full' do + # Fill the topic to capacity + project_topic.update!(max_choosers: 1) + SignedUpTeam.sign_up_for_topic(AssignmentTeam.create!(name: "Team", assignment: assignment), project_topic) + + # Next signup should be waitlisted + new_team = AssignmentTeam.create!(name: "Team", assignment: assignment) + SignedUpTeam.sign_up_for_topic(new_team, project_topic) + + signup = SignedUpTeam.find_by(team: new_team, project_topic: project_topic) + expect(signup.is_waitlisted).to be true + end + + # Test team removal from topic + it 'removes team from topic when dropped' do + SignedUpTeam.sign_up_for_topic(team, project_topic) + expect(SignedUpTeam.exists?(team: team, project_topic: project_topic)).to be true + + project_topic.drop_team(team) + expect(SignedUpTeam.exists?(team: team, project_topic: project_topic)).to be false + end + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 9d528540b..c79c0954f 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -77,7 +77,8 @@ end RSpec.configure do |config| # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures - config.fixture_path = Rails.root.join('spec/fixtures') + # CHANGED: Commented out fixture_path to fix RSpec configuration error (E2552) + # config.fixture_path = Rails.root.join('spec/fixtures') # If you're not using ActiveRecord, or you'd prefer not to run each of your # examples within a transaction, remove the following line or assign false diff --git a/spec/routing/project_topics_routing_spec.rb b/spec/routing/project_topics_routing_spec.rb new file mode 100644 index 000000000..5f4594c7a --- /dev/null +++ b/spec/routing/project_topics_routing_spec.rb @@ -0,0 +1,29 @@ +require "rails_helper" + +RSpec.describe ProjectTopicsController, type: :routing do + describe "routing" do + it "routes to #index" do + expect(get: "/project_topics").to route_to("project_topics#index") + end + + it "routes to #show" do + expect(get: "/project_topics/1").to route_to("project_topics#show", id: "1") + end + + it "routes to #create" do + expect(post: "/project_topics").to route_to("project_topics#create") + end + + it "routes to #update via PUT" do + expect(put: "/project_topics/1").to route_to("project_topics#update", id: "1") + end + + it "routes to #update via PATCH" do + expect(patch: "/project_topics/1").to route_to("project_topics#update", id: "1") + end + + it "routes to #destroy" do + expect(delete: "/project_topics/").to route_to("project_topics#destroy") + end + end +end \ No newline at end of file diff --git a/spec/routing/sign_up_topics_routing_spec.rb b/spec/routing/sign_up_topics_routing_spec.rb deleted file mode 100644 index ad3ade057..000000000 --- a/spec/routing/sign_up_topics_routing_spec.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" - -RSpec.describe SignUpTopicsController, type: :routing do - describe "routing" do - it "routes to #index" do - expect(get: "/sign_up_topics").to route_to("sign_up_topics#index") - end - - it "routes to #show" do - expect(get: "/sign_up_topics/1").to route_to("sign_up_topics#show", id: "1") - end - - it "routes to #create" do - expect(post: "/sign_up_topics").to route_to("sign_up_topics#create") - end - - it "routes to #update via PUT" do - expect(put: "/sign_up_topics/1").to route_to("sign_up_topics#update", id: "1") - end - - it "routes to #update via PATCH" do - expect(patch: "/sign_up_topics/1").to route_to("sign_up_topics#update", id: "1") - end - - it "routes to #destroy" do - expect(delete: "/sign_up_topics/1").to route_to("sign_up_topics#destroy", id: "1") - end - end -end \ No newline at end of file diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml index cc0294e73..cb9130fe8 100644 --- a/swagger/v1/swagger.yaml +++ b/swagger/v1/swagger.yaml @@ -1038,54 +1038,56 @@ paths: responses: '204': description: successful - "/sign_up_topics": + "/api/v1/project_topics": get: - summary: Get sign-up topics + summary: Get project topics parameters: - - name: assignment_id - in: query - description: Assignment ID - required: true - schema: - type: integer - - name: topic_ids - in: query - description: Topic Identifier - required: false - schema: - type: string + - name: assignment_id + in: query + description: Assignment ID + required: true + schema: + type: integer + - name: topic_ids + in: query + description: Topic Identifiers (topic_identifier) + required: false + schema: + type: array + items: + type: string tags: - - SignUpTopic + - ProjectTopic responses: '200': description: successful delete: - summary: Delete sign-up topics + summary: Delete project topics parameters: - - name: assignment_id - in: query - description: Assignment ID - required: true - schema: - type: integer - - name: topic_ids - in: query - items: - type: string - description: Topic Identifiers to delete - required: false - schema: - type: array + - name: assignment_id + in: query + description: Assignment ID + required: true + schema: + type: integer + - name: topic_ids + in: query + items: + type: string + description: Topic Identifiers to delete + required: false + schema: + type: array tags: - - SignUpTopic + - ProjectTopic responses: '200': description: successful post: summary: create a new topic in the sheet tags: - - SignUpTopic - parameters: [] + - ProjectTopic + parameters: [ ] responses: '201': description: Success @@ -1107,26 +1109,30 @@ paths: type: integer micropayment: type: integer + description: + type: string + link: + type: string required: - - topic_identifier - - topic_name - - max_choosers - - category - - assignment_id - - micropayment - "/sign_up_topics/{id}": + - topic_identifier + - topic_name + - max_choosers + - category + - assignment_id + - micropayment + "/api/v1/project_topics/{id}": parameters: - - name: id - in: path - description: id of the sign up topic - required: true - schema: - type: integer + - name: id + in: path + description: id of the sign up topic + required: true + schema: + type: integer put: summary: update a new topic in the sheet tags: - - SignUpTopic - parameters: [] + - ProjectTopic + parameters: [ ] responses: '200': description: successful @@ -1148,12 +1154,16 @@ paths: type: integer micropayment: type: integer + description: + type: string + link: + type: string required: - - topic_identifier - - topic_name - - category - - assignment_id - "/signed_up_teams/sign_up": + - topic_identifier + - topic_name + - category + - assignment_id + "/api/v1/signed_up_teams/sign_up": post: summary: Creates a signed up team tags: