diff --git a/2 b/2 new file mode 100644 index 000000000..e69de29bb diff --git a/Dockerfile b/Dockerfile index e89a49ee8..b6ba3662f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,24 +1,62 @@ -FROM ruby:3.2.7 +# Make sure it matches the Ruby version in .ruby-version and Gemfile +ARG RUBY_VERSION=3.2.7 +FROM ruby:$RUBY_VERSION -LABEL maintainer="Ankur Mundra " -# Install dependencies -RUN apt-get update && \ - apt-get install -y curl && \ - curl -fsSL https://deb.nodesource.com/setup_18.x | bash - && \ - apt-get install -y nodejs && \ - apt-get install -y netcat-openbsd +# Install libvips for Active Storage preview support +RUN apt-get update -qq && \ + apt-get install -y build-essential libvips && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* /usr/share/doc /usr/share/man -# Set the working directory +# Rails app lives here WORKDIR /app -# Copy your application files from current location to WORKDIR -COPY . . +# Set production environment +ENV RAILS_LOG_TO_STDOUT="1" \ + RAILS_SERVE_STATIC_FILES="true" \ + RAILS_ENV="production" + +# Set DATABASE_URL here +ENV DATABASE_URL="mysql2://user:password@localhost:3306/app_name" -# Install Ruby dependencies -RUN gem update --system && gem install bundler:2.4.7 +# Install application gems +COPY Gemfile Gemfile.lock ./ RUN bundle install -EXPOSE 3002 +# Copy application code +COPY . . + +# Precompile bootsnap code for faster boot times +RUN bundle exec bootsnap precompile --gemfile app/ lib/ + +# Install Yarn +RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - +RUN echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list +RUN apt-get update -qq && apt-get install -y yarn + +# Create user and group for app +ARG UID=1000 +ARG GID=1000 + +RUN bash -c "set -o pipefail && apt-get update \ + && apt-get install -y --no-install-recommends build-essential curl git libpq-dev \ + && curl -sSL https://deb.nodesource.com/setup_18.x | bash - \ + && curl -sSL https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \ + && echo 'deb https://dl.yarnpkg.com/debian/ stable main' | tee /etc/apt/sources.list.d/yarn.list \ + && apt-get update && apt-get install -y --no-install-recommends nodejs yarn \ + && rm -rf /var/lib/apt/lists/* /usr/share/doc /usr/share/man \ + && apt-get clean \ + && groupadd -g \"${GID}\" ruby \ + && useradd --create-home --no-log-init -u \"${UID}\" -g \"${GID}\" ruby \ + && mkdir /node_modules && chown ruby:ruby -R /node_modules /app" + +# Precompiling assets for production without requiring secret RAILS_MASTER_KEY +RUN bundle exec rails assets:precompile +RUN bundle exec rails db:migrate +RUN bundle exec rails db:seed + +# Expose port 3000 +EXPOSE 3000 -# Set the entry point -ENTRYPOINT ["/app/setup.sh"] +# Start the server by default, this can be overwritten at runtime +CMD ["./bin/rails", "server", "-b", "0.0.0.0"] diff --git a/Gemfile b/Gemfile index 462e09123..e51bd7f6a 100644 --- a/Gemfile +++ b/Gemfile @@ -8,6 +8,7 @@ gem 'puma', '~> 5.0' gem 'rails', '~> 8.0', '>= 8.0.1' gem 'rswag-api' gem 'rswag-ui' +gem 'sprockets-rails' # Build JSON APIs with ease [https://github.com/rails/jbuilder] # gem "jbuilder" diff --git a/app/controllers/api/v1/team_participants_controller.rb b/app/controllers/api/v1/team_participants_controller.rb new file mode 100644 index 000000000..f18bbf5da --- /dev/null +++ b/app/controllers/api/v1/team_participants_controller.rb @@ -0,0 +1,114 @@ +class TeamsParticipantsController < ApplicationController + include AuthorizationHelper + + # Update duties only if student privileges are present + def action_allowed? + return current_user_has_student_privileges? if params[:action] == "update_duties" + + current_user_has_ta_privileges? + end + + # Auto completing username when adding a new member to a team + def auto_complete_for_user_name + team = Team.find(session[:team_id]) + + # Finds possible team members based on partial names + @users = team.possible_team_members(params[:user][:name]) + render inline: "<%= auto_complete_result @users, 'name' %>", layout: false + end + + # Updating the duties assigned to a team member + def update_duties + team_participant = TeamsParticipant.find(params[:teams_participant_id]) + team_participant.update(duty_id: params[:teams_participant]['duty_id']) + redirect_to controller: 'student_teams', action: 'view', student_id: params[:participant_id] + end + + # Listing all participants of a team + def list + @team = Team.find(params[:id]) + @assignment = Assignment.find(@team.parent_id) + @teams_participants = TeamsParticipant.where(team_id: params[:id]).page(params[:page]).per(10) + end + + # Finding a team by the team id + def new + @team = Team.find(params[:id]) + end + + # Adding a participant to a team + def create + user = User.find_by(name: params[:user][:name].strip) + # Throwing an error if no user is found + if user.nil? + flash[:error] = user_not_found_message(params[:user][:name]) + redirect_back fallback_location: root_path + return + end + + team = Team.find(params[:id]) + assignment_or_course = team.parent + + # Checking if a participant is valid for the assigned team or course + unless valid_participant?(user, assignment_or_course) + redirect_back fallback_location: root_path + return + end + + # Adding a member to a team and then checking if there are any errors to that + begin + add_member_return = team.add_member(user, team.parent_id) + flash[:error] = "This team already has the maximum number of members." if add_member_return == false + undo_link("The participant \"#{user.name}\" has been successfully added to \"#{team.name}\".") if add_member_return + rescue + flash[:error] = "The user #{user.name} is already a member of the team #{team.name}" + end + + redirect_to controller: 'teams', action: 'list', id: team.parent_id + end + + # Removing a participant from a team + def delete + team_participant = TeamsParticipant.find(params[:id]) + parent_id = Team.find(team_participant.team_id).parent_id + user = User.find(team_participant.participant.user_id) + team_participant.destroy + + undo_link("The participant \"#{user.name}\" has been successfully removed.") + redirect_to controller: 'teams', action: 'list', id: parent_id + end + + # Deleting multiple participants from a team + def delete_selected + TeamsParticipant.where(id: params[:item]).destroy_all + redirect_to action: 'list', id: params[:id] + end + + private + def user_not_found_message(user_name) + urlCreate = url_for controller: 'users', action: 'new' + "\"#{user_name.strip}\" is not defined. Please create this user before continuing." + end + + # Checking if a participant is already assigned to a team + def valid_participant?(user, assignment_or_course) + if assignment_or_course.user_on_team?(user) + flash[:error] = "This user is already assigned to a team for this #{assignment_or_course.class.name.downcase}." + return false + end + + participant = assignment_or_course.participants.find_by(user_id: user.id) + unless participant + flash[:error] = participant_not_found_message(user.name, assignment_or_course) + return false + end + + true + end + + # Returning an error when a participant is not found + def participant_not_found_message(user_name, assignment_or_course) + urlParticipantList = url_for controller: 'participants', action: 'list', id: assignment_or_course.id, model: assignment_or_course.class.name, authorization: 'participant' + "\"#{user_name}\" is not a participant of the current #{assignment_or_course.class.name.downcase}. Please add this user before continuing." + end +end diff --git a/app/models/assignment_team.rb b/app/models/assignment_team.rb new file mode 100644 index 000000000..0c4d62a1c --- /dev/null +++ b/app/models/assignment_team.rb @@ -0,0 +1,118 @@ +class AssignmentTeam < Team + # require File.dirname(__FILE__) + '/analytic/assignment_team_analytic' + # include AssignmentTeamAnalytic + # include Scoring + + belongs_to :assignment, class_name: 'Assignment', foreign_key: 'parent_id' + has_many :review_mappings, class_name: 'ReviewResponseMap', foreign_key: 'reviewee_id' + has_many :review_response_maps, foreign_key: 'reviewee_id' + has_many :responses, through: :review_response_maps, foreign_key: 'map_id' + + # E2516: Fetches the user_id of the first member of the team. + # If the current_user is part of the team, return their id. + # @param [User] current_user - The user currently logged in (optional) + # @return [Integer] user_id of the team member + def user_id(current_user = nil) + return current_user.id if current_user.present? && users.include?(current_user) + users.first.id + end + + # E2516: Checks if a given participant is part of the team. + # @param [Participant] participant - The participant to check + # @return [Boolean] true if the participant is in the team, false otherwise + def includes?(participant) + participants.include?(participant) + end + + # E2516: Returns the parent model name (Assignment) for the current team. + # @return [String] Parent model name + def parent_model + 'Assignment' + end + + # E2516: Assigns a reviewer to this team. + # Calls `create_review_map` to generate a ReviewResponseMap. + # @param [Reviewer] reviewer - The reviewer to be assigned + def assign_reviewer(reviewer) + assignment = Assignment.find_by(id: parent_id) + raise 'The assignment cannot be found.' if assignment.nil? + + # E2516: Create a ReviewResponseMap for this reviewer + create_review_map(reviewer, assignment) + end + + # E2516: Creates and returns a review map. + # Extracted to avoid duplicate code in `assign_reviewer`. + # @param [Reviewer] reviewer - The reviewer to assign + # @param [Assignment] assignment - The assignment being reviewed + # @return [ReviewResponseMap] The created review map + def create_review_map(reviewer, assignment) + ReviewResponseMap.create!( + reviewee_id: id, + reviewer_id: reviewer.get_reviewer.id, + reviewed_object_id: assignment.id, + team_reviewing_enabled: assignment.team_reviewing_enabled + ) + end + + # E2516: Checks if a review has been done by a given reviewer. + # @param [Reviewer] reviewer - The reviewer to check + # @return [Boolean] true if reviewed, false otherwise + def reviewed_by?(reviewer) + ReviewResponseMap.exists?(reviewee_id: id, reviewer_id: reviewer.get_reviewer.id, reviewed_object_id: assignment.id) + end + + # E2516: Fetches the participants associated with the team. + # Delegates this to TeamsParticipant to promote DRY principles. + # @return [Array] Array of participants + def participants + TeamsParticipant.team_members(id) + end + + # E2516: Adds a participant to the team via TeamsParticipant. + # @param [Integer] assignment_id - The assignment id + # @param [User] user - The user to be added as a participant + def add_participant(assignment_id, user) + return if TeamsParticipant.exists?(participant_id: user.id, team_id: id) + + TeamsParticipant.create!(participant_id: user.id, team_id: id) + end + + # E2516: Creates a new team and associates a user and topic. + # Extracted to simplify `create_new_team` and reduce nesting. + # @param [Integer] user_id - ID of the user + # @param [SignUpTopic] signuptopic - The selected topic + def create_new_team(user_id, signuptopic) + t_user = TeamsUser.create!(team_id: id, user_id: user_id) + SignedUpTeam.create!(topic_id: signuptopic.id, team_id: id, is_waitlisted: 0) + parent = TeamNode.create!(parent_id: signuptopic.assignment_id, node_object_id: id) + TeamUserNode.create!(parent_id: parent.id, node_object_id: t_user.id) + end + + # E2516: Submits a hyperlink to the team's submission. + # Moved to TeamFileService for better separation of concerns. + # @param [String] hyperlink - The URL to submit + def submit_hyperlink(hyperlink) + TeamFileService.submit_hyperlink(self, hyperlink) + end + + # E2516: Removes a hyperlink from the team's submission. + # Moved to TeamFileService for consistency. + # @param [String] hyperlink_to_delete - The URL to remove + def remove_hyperlink(hyperlink_to_delete) + TeamFileService.remove_hyperlink(self, hyperlink_to_delete) + end + + # E2516: Checks if the team has submitted any files or hyperlinks. + # @return [Boolean] true if submissions exist, false otherwise + def has_submissions? + submitted_files.any? || submitted_hyperlinks.present? + end + + # E2516: Returns the most recent submission by the team. + # Optimized query with order to fetch the latest submission. + # @return [SubmissionRecord] The most recent submission + def most_recent_submission + SubmissionRecord.where(team_id: id, assignment_id: parent_id).order(updated_at: :desc).first + end +end diff --git a/app/models/mentored_team.rb b/app/models/mentored_team.rb new file mode 100644 index 000000000..de2a7ea7c --- /dev/null +++ b/app/models/mentored_team.rb @@ -0,0 +1,105 @@ +class MentoredTeam < AssignmentTeam + # E2516: Adds a member to the team and assigns a mentor if required. + # Ensures that the mentor is only assigned once per assignment. + # @param [User] user - The user to add + # @param [Integer] _assignment_id - Assignment ID (optional) + # @return [Boolean] true if member added successfully, false otherwise + def add_member(user, _assignment_id = nil) + # E2516: Raise error if the user is already part of the team + raise "The user #{user.name} is already a member of the team #{name}" if user?(user) + + # E2516: Validate if the user can be added (team not full and mentor check passed) + return false unless can_add_member?(user) + + # E2516: Add the user to the team and link them to the parent node + add_team_user(user) + add_participant_to_team(user) + + # E2516: Assign a mentor to the team only if necessary + assign_mentor_if_needed(_assignment_id) + + # E2516: Log team member addition action + ExpertizaLogger.info LoggerMessage.new('Model:Team', user.name, "Added member to the team #{id}") + true + end + + # E2516: Imports multiple team members from a CSV row + # @param [Hash] row_hash - Hash with team members' data + def import_team_members(row_hash) + row_hash[:teammembers].each do |teammate| + # E2516: Skip empty or invalid entries + next if teammate.to_s.strip.empty? + + # E2516: Find the user or raise error if user not found + user = find_or_raise_user(teammate) + + # E2516: Add the user if they are not already in the team + add_member(user, parent_id) if user_not_in_team?(user) + end + end + + private + + # E2516: Validates if a user can be added to the team + # @param [User] user - The user to check + # @return [Boolean] true if user can be added, false otherwise + def can_add_member?(user) + # E2516: Check if the team is not full and mentor conditions are satisfied + !full? && mentor_assignment_valid?(user) + end + + # E2516: Creates a TeamsUser and links it to the parent TeamNode + # @param [User] user - The user to add + def add_team_user(user) + t_user = TeamsUser.create!(user_id: user.id, team_id: id) + parent = TeamNode.find_by(node_object_id: id) + + # E2516: Create a TeamUserNode to link user to the team node + TeamUserNode.create!(parent_id: parent.id, node_object_id: t_user.id) + end + + # E2516: Adds the participant to the team + # @param [User] user - The user to be added as a participant + def add_participant_to_team(user) + parent = TeamNode.find_by(node_object_id: id) + # E2516: Add the participant to the team + add_participant(parent.id, user) + end + + # E2516: Assigns a mentor to the team only if required + # @param [Integer] _assignment_id - Assignment ID (optional) + def assign_mentor_if_needed(_assignment_id) + # E2516: Assign mentor only if mentor conditions are satisfied + MentorManagement.assign_mentor(_assignment_id, id) if mentor_assignment_valid? + end + + # E2516: Checks if assigning a mentor is valid for the team + # @param [User] user - The user being added + # @return [Boolean] true if mentor can be assigned, false otherwise + def mentor_assignment_valid?(user) + # E2516: Allow adding if the user is a mentor or no mentor exists in the team + return true if mentor_user?(user) || !team_has_mentor? + + # E2516: Raise error if team already has a mentor + raise "A mentor is already assigned to the team #{id}" + end + + # E2516: Checks if the user is not already part of the team + # @param [User] user - The user to check + # @return [Boolean] true if user is not in the team, false otherwise + def user_not_in_team?(user) + TeamsUser.find_by(team_id: id, user_id: user.id).nil? + end + + # E2516: Finds a user or raises an error if the user is not found + # @param [String] teammate - Name of the user to be searched + # @return [User] the user object if found + def find_or_raise_user(teammate) + user = User.find_by(name: teammate.to_s) + + # E2516: Raise ImportError if user not found + raise ImportError, "The user '#{teammate}' was not found. Create this user?" if user.nil? + + user + end +end diff --git a/app/models/teams_participant.rb b/app/models/teams_participant.rb new file mode 100644 index 000000000..3d29d054e --- /dev/null +++ b/app/models/teams_participant.rb @@ -0,0 +1,21 @@ +class TeamsParticipant < ApplicationRecord + belongs_to :participant + belongs_to :team + + delegate :name, to: :user, prefix: true, allow_nil: true + + # E2516: Fetches all participants associated with a given team. + # @param [Integer] team_id - ID of the team + # @return [Array] Array of user records + def self.team_members(team_id) + participant_ids = where(team_id: team_id).pluck(:participant_id) + User.where(id: participant_ids) + end + + # E2516: Removes a participant from the team. + # @param [Integer] user_id - ID of the participant to be removed + # @param [Integer] team_id - ID of the team + def self.remove_participant_from_team(participant_id, team_id) + find_by(participant_id: participant_id, team_id: team_id)&.destroy + end +end diff --git a/config/application.rb b/config/application.rb index 4bd4ca23e..bb1101cbe 100644 --- a/config/application.rb +++ b/config/application.rb @@ -29,6 +29,7 @@ class Application < Rails::Application # Middleware like session, flash, cookies can be added back manually. # Skip views, helpers and assets when generating a new resource. config.api_only = true + config.middleware.use ActionDispatch::Static config.cache_store = :redis_store, ENV['CACHE_STORE'], { expires_in: 3.days, raise_errors: false } end end diff --git a/db/schema.rb b/db/schema.rb index 7db16863e..c5b681b12 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -349,6 +349,7 @@ t.datetime "updated_at", null: false t.bigint "assignment_id", null: false t.index ["assignment_id"], name: "index_teams_on_assignment_id" + t.integer "parent_id" end create_table "teams_users", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| diff --git a/spec/controllers/teams_participants_controller_spec.rb b/spec/controllers/teams_participants_controller_spec.rb new file mode 100644 index 000000000..5a2e0a30a --- /dev/null +++ b/spec/controllers/teams_participants_controller_spec.rb @@ -0,0 +1,32 @@ +require 'rails_helper' + +RSpec.describe TeamsParticipantsController, type: :controller do + let(:assignment) { create(:assignment) } + let(:team) { create(:assignment_team, parent_id: assignment.id) } + let(:participant) { create(:participant) } + + describe 'POST #create' do + it 'adds a participant to the team' do + post :create, params: { team_id: team.id, participant_id: participant.id } + expect(response).to redirect_to(edit_assignment_team_path(team.id)) + expect(flash[:notice]).to match(/successfully added/) + end + + it 'does not add duplicate participants' do + TeamsParticipant.create(team_id: team.id, participant_id: participant.id) + post :create, params: { team_id: team.id, participant_id: participant.id } + expect(response).to redirect_to(edit_assignment_team_path(team.id)) + expect(flash[:error]).to match(/already a member/) + end + end + + describe 'DELETE #destroy' do + let!(:teams_participant) { create(:teams_participant, team: team, participant: participant) } + + it 'removes a participant from the team' do + delete :destroy, params: { id: teams_participant.id } + expect(response).to redirect_to(edit_assignment_team_path(team.id)) + expect(flash[:notice]).to match(/successfully removed/) + end + end +end diff --git a/spec/factories.rb b/spec/factories.rb index 758fa51a2..e0895ed4b 100644 --- a/spec/factories.rb +++ b/spec/factories.rb @@ -19,13 +19,4 @@ topic_id { 1 } end - factory :user do - sequence(:name) { |_n| Faker::Name.name.to_s.delete(" \t\r\n").downcase } - sequence(:email) { |_n| Faker::Internet.email.to_s } - password { 'password' } - sequence(:full_name) { |_n| "#{Faker::Name.name}#{Faker::Name.name}".downcase } - role factory: :role - institution factory: :institution - end - end diff --git a/spec/factories/assignment_team.rb b/spec/factories/assignment_team.rb new file mode 100644 index 000000000..ce5fdf1df --- /dev/null +++ b/spec/factories/assignment_team.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :assignment_team, class: 'AssignmentTeam' do + # name { "Team #{SecureRandom.hex(3)}" } + # association :assignment + end + end + \ No newline at end of file diff --git a/spec/factories/assignments.rb b/spec/factories/assignments.rb index 11788ce8c..4efe84d02 100644 --- a/spec/factories/assignments.rb +++ b/spec/factories/assignments.rb @@ -1,8 +1,11 @@ -# spec/factories/assignments.rb FactoryBot.define do factory :assignment do - sequence(:name) { |n| "Assignment #{n}" } - directory_path { "assignment_#{name.downcase.gsub(/\s+/, '_')}" } + transient do + assignment_number { Faker::Number.unique.number(digits: 2) } + end + + name { "Assignment #{assignment_number}" } + directory_path { "assignment_#{assignment_number}" } # Required associations association :instructor, factory: [:user, :instructor] @@ -13,9 +16,9 @@ num_reviews_allowed { 3 } num_metareviews_required { 3 } num_metareviews_allowed { 3 } - rounds_of_reviews { 1 } # This is the correct attribute name + rounds_of_reviews { 1 } - # Boolean flags with default values + # Boolean flags is_calibrated { false } has_badge { false } enable_pair_programming { false } @@ -52,4 +55,4 @@ end end end -end \ No newline at end of file +end diff --git a/spec/factories/roles.rb b/spec/factories/roles.rb index 1783617b6..b0ee70e10 100644 --- a/spec/factories/roles.rb +++ b/spec/factories/roles.rb @@ -63,4 +63,10 @@ factory :team do sequence(:name) { |n| "Team #{n}" } end +end + +FactoryBot.define do + factory :instructor, parent: :user do + instructor + end end \ No newline at end of file diff --git a/spec/factories/users.rb b/spec/factories/users.rb new file mode 100644 index 000000000..4e0a1fd3f --- /dev/null +++ b/spec/factories/users.rb @@ -0,0 +1,32 @@ +# Existing factory (just enhance it) +FactoryBot.define do + factory :user do + sequence(:name) { |n| "User #{n}" } + full_name { "Test User #{SecureRandom.hex(2)}" } + sequence(:email) { |n| "user#{n}@example.com" } + password { "password123" } + institution + role { Role.find_or_create_by(id: Role::STUDENT) } + + trait :student do + role { create(:role, :student) } + end + + trait :ta do + role { create(:role, :ta) } + end + + trait :instructor do + role { create(:role, :instructor) } + end + + trait :administrator do + role { create(:role, :administrator) } + end + + trait :super_administrator do + role { create(:role, :super_administrator) } + end + end + end + \ No newline at end of file diff --git a/spec/models/assignment_team_spec.rb b/spec/models/assignment_team_spec.rb new file mode 100644 index 000000000..c51c24a69 --- /dev/null +++ b/spec/models/assignment_team_spec.rb @@ -0,0 +1,23 @@ +require 'rails_helper' + +RSpec.describe AssignmentTeam, type: :model do + let(:assignment) { create(:assignment) } + let(:team) { create(:assignment_team, parent_id: assignment.id) } + let(:user) { create(:user) } + + describe '#add_participant' do + context 'when participant is not in the team' do + it 'adds the participant successfully' do + expect(team.add_participant(assignment.id, user)).to be_truthy + expect(AssignmentParticipant.exists?(parent_id: assignment.id, user_id: user.id)).to be true + end + end + + context 'when participant already exists' do + it 'does not create a duplicate' do + team.add_participant(assignment.id, user) + expect { team.add_participant(assignment.id, user) }.not_to change(AssignmentParticipant, :count) + end + end + end +end diff --git a/spec/models/mentored_team_spec.rb b/spec/models/mentored_team_spec.rb new file mode 100644 index 000000000..2666dcfce --- /dev/null +++ b/spec/models/mentored_team_spec.rb @@ -0,0 +1,48 @@ +require 'rails_helper' + +RSpec.describe MentoredTeam, type: :model do + let(:assignment) { create(:assignment) } + let(:team) { create(:mentored_team, parent_id: assignment.id) } + let(:user) { create(:user) } + let(:mentor) { create(:user, is_mentor: true) } + + describe '#add_member' do + context 'when user is not part of the team' do + it 'adds the user successfully' do + expect(team.add_member(user, assignment.id)).to be true + expect(TeamsUser.exists?(team_id: team.id, user_id: user.id)).to be true + end + end + + context 'when user is already a member' do + it 'raises an error' do + team.add_member(user, assignment.id) + expect { team.add_member(user, assignment.id) }.to raise_error(RuntimeError, /already a member/) + end + end + + context 'when mentor assignment is invalid' do + it 'raises an error if a mentor already exists' do + team.add_member(mentor, assignment.id) + expect { team.add_member(create(:user, is_mentor: true), assignment.id) }.to raise_error(RuntimeError, /A mentor is already assigned/) + end + end + end + + describe '#import_team_members' do + context 'when team members are valid' do + it 'imports members successfully' do + row_hash = { teammembers: [user.name] } + expect { team.import_team_members(row_hash) }.not_to raise_error + expect(TeamsUser.exists?(team_id: team.id, user_id: user.id)).to be true + end + end + + context 'when a member does not exist' do + it 'raises an ImportError' do + row_hash = { teammembers: ['nonexistent_user'] } + expect { team.import_team_members(row_hash) }.to raise_error(ImportError, /was not found/) + end + end + end +end diff --git a/spec/models/teams_participant_spec.rb b/spec/models/teams_participant_spec.rb new file mode 100644 index 000000000..cb2cc376b --- /dev/null +++ b/spec/models/teams_participant_spec.rb @@ -0,0 +1,20 @@ +require 'rails_helper' + +RSpec.describe TeamsParticipant, type: :model do + let(:participant) { create(:participant) } + let(:team) { create(:assignment_team) } + let!(:teams_participant) { create(:teams_participant, participant: participant, team: team) } + + describe '.team_members' do + it 'returns all team members' do + members = TeamsParticipant.team_members(team.id) + expect(members).to include(participant.user) + end + end + + describe '.remove_participant_from_team' do + it 'removes a participant from the team' do + expect { TeamsParticipant.remove_participant_from_team(participant.id, team.id) }.to change { TeamsParticipant.count }.by(-1) + end + end +end diff --git a/spec/requests/api/v1/assignment_team_spec.rb b/spec/requests/api/v1/assignment_team_spec.rb new file mode 100644 index 000000000..02bb6507f --- /dev/null +++ b/spec/requests/api/v1/assignment_team_spec.rb @@ -0,0 +1,141 @@ +require 'rails_helper' + +RSpec.describe AssignmentTeam, type: :model do + let(:assignment) { create(:assignment) } + let(:team) { create(:assignment_team, assignment: assignment) } + let(:user) { create(:user) } + let(:participant) { create(:participant, user: user, assignment: assignment) } + let(:reviewer) { create(:participant, assignment: assignment) } + let(:review_response_map) { create(:review_response_map, reviewee: team, reviewer: reviewer) } + + describe '#user_id' do + it 'returns the user_id of the first team member' do + team.users << user + expect(team.user_id).to eq(user.id) + end + it 'returns current_user.id if they are in the team' do + team.users << user + expect(team.user_id(user)).to eq(user.id) + end + end + + describe '#includes?' do + it 'returns true if a participant is in the team' do + allow(team).to receive(:participants).and_return([participant]) + expect(team.includes?(participant)).to be true + end + + it 'returns false if a participant is not in the team' do + allow(team).to receive(:participants).and_return([]) + expect(team.includes?(participant)).to be false + end + end + + describe '#parent_model' do + it 'returns "Assignment"' do + expect(team.parent_model).to eq('Assignment') + end + end + + describe '#assign_reviewer' do + it 'raises an error if the assignment is not found' do + allow(Assignment).to receive(:find_by).and_return(nil) + expect { team.assign_reviewer(reviewer) }.to raise_error('The assignment cannot be found.') + end + + it 'creates a review map for the reviewer' do + expect { team.assign_reviewer(reviewer) }.to change { ReviewResponseMap.count }.by(1) + end + end + + describe '#create_review_map' do + it 'creates a new ReviewResponseMap' do + expect { + team.create_review_map(reviewer, assignment) + }.to change { ReviewResponseMap.count }.by(1) + end + end + + describe '#reviewed_by?' do + it 'returns true if the team has been reviewed by the given reviewer' do + review_response_map + expect(team.reviewed_by?(reviewer)).to be true + end + + it 'returns false if the team has not been reviewed by the given reviewer' do + expect(team.reviewed_by?(reviewer)).to be false + end + end + + describe '#participants' do + it 'returns the participants of the team' do + allow(TeamsParticipant).to receive(:team_members).with(team.id).and_return([participant]) + expect(team.participants).to include(participant) + end + end + + describe '#add_participant' do + it 'adds a participant to the team' do + expect { + team.add_participant(assignment.id, user) + }.to change { TeamsParticipant.count }.by(1) + end + + it 'does not add a participant if they are already in the team' do + team.add_participant(assignment.id, user) + expect { + team.add_participant(assignment.id, user) + }.not_to change { TeamsParticipant.count } + end + end + + describe '#create_new_team' do + let(:signuptopic) { create(:sign_up_topic, assignment: assignment) } + + it 'creates a new team user and associates topic' do + expect { + team.create_new_team(user.id, signuptopic) + }.to change { TeamsUser.count }.by(1) + .and change { SignedUpTeam.count }.by(1) + .and change { TeamNode.count }.by(1) + .and change { TeamUserNode.count }.by(1) + end + end + + describe '#submit_hyperlink' do + it 'calls TeamFileService.submit_hyperlink' do + expect(TeamFileService).to receive(:submit_hyperlink).with(team, 'http://example.com') + team.submit_hyperlink('http://example.com') + end + end + + describe '#remove_hyperlink' do + it 'calls TeamFileService.remove_hyperlink' do + expect(TeamFileService).to receive(:remove_hyperlink).with(team, 'http://example.com') + team.remove_hyperlink('http://example.com') + end + end + + describe '#has_submissions?' do + it 'returns true if the team has submissions' do + allow(team).to receive(:submitted_files).and_return(['file1']) + allow(team).to receive(:submitted_hyperlinks).and_return(nil) + expect(team.has_submissions?).to be true + end + + it 'returns false if the team has no submissions' do + allow(team).to receive(:submitted_files).and_return([]) + allow(team).to receive(:submitted_hyperlinks).and_return(nil) + expect(team.has_submissions?).to be false + end + end + + describe '#most_recent_submission' do + it 'returns the latest submission' do + submission1 = create(:submission_record, team: team, assignment: assignment, updated_at: 1.day.ago) + submission2 = create(:submission_record, team: team, assignment: assignment, updated_at: Time.current) + + expect(team.most_recent_submission).to eq(submission2) + end + end +end diff --git a/spec/requests/api/v1/mentored_team_spec.rb b/spec/requests/api/v1/mentored_team_spec.rb new file mode 100644 index 000000000..e886774f0 --- /dev/null +++ b/spec/requests/api/v1/mentored_team_spec.rb @@ -0,0 +1,94 @@ +require 'rails_helper' + +RSpec.describe MentoredTeam, type: :model do + let(:team) { create(:mentored_team) } + let(:user) { create(:user) } + let(:mentor) { create(:user, role: :mentor) } + + describe '#import_team_members' do + it 'imports members successfully from a given list' do + members = [create(:user), create(:user)] + expect { team.import_team_members(members) }.to change { team.users.count }.by(2) + end + end + + describe '#find_or_raise_user' do + it 'returns the user if found' do + expect(team.find_or_raise_user(user.id)).to eq(user) + end + + it 'raises an error if the user is not found' do + expect { team.find_or_raise_user(999) }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + describe '#user_not_in_team?' do + it 'returns true if user is not in the team' do + expect(team.user_not_in_team?(user)).to be true + end + + it 'returns false if user is in the team' do + team.users << user + expect(team.user_not_in_team?(user)).to be false + end + end + + describe '#mentor_assignment_valid?' do + it 'returns true if a mentor can be assigned' do + expect(team.mentor_assignment_valid?(mentor)).to be true + end + + it 'returns false if an invalid mentor is assigned' do + expect(team.mentor_assignment_valid?(user)).to be false + end + end + + describe '#add_member' do + it 'adds a user to the team' do + expect { team.add_member(user) }.to change { team.users.count }.by(1) + end + + it 'does not add a user who is already in the team' do + team.users << user + expect { team.add_member(user) }.not_to change { team.users.count } + end + end + + describe '#can_add_member?' do + it 'returns true if the team can add a member' do + expect(team.can_add_member?).to be true + end + + it 'returns false if the team has reached its limit' do + allow(team).to receive(:users).and_return(Array.new(10) { create(:user) }) + expect(team.can_add_member?).to be false + end + end + + describe '#add_team_user' do + it 'adds a user to the team successfully' do + expect { team.add_team_user(user) }.to change { team.users.count }.by(1) + end + end + + describe '#add_participant_to_team' do + it 'adds a participant to the team' do + participant = create(:participant) + expect { team.add_participant_to_team(participant) }.to change { team.users.count }.by(1) + end + end + + describe '#assign_mentor_if_needed' do + it 'assigns a mentor if no mentor is present' do + team.assign_mentor_if_needed(mentor) + expect(team.mentor).to eq(mentor) + end + + it 'does not assign a mentor if one is already assigned' do + existing_mentor = create(:user, role: :mentor) + team.mentor = existing_mentor + team.assign_mentor_if_needed(mentor) + expect(team.mentor).to eq(existing_mentor) + end + end +end diff --git a/spec/requests/api/v1/teams_participant_controller_spec.rb b/spec/requests/api/v1/teams_participant_controller_spec.rb new file mode 100644 index 000000000..158bfd7bf --- /dev/null +++ b/spec/requests/api/v1/teams_participant_controller_spec.rb @@ -0,0 +1,103 @@ +require 'rails_helper' + +RSpec.describe TeamsParticipantsController, type: :controller do + let(:assignment) { create(:assignment) } + let(:team) { create(:assignment_team, assignment: assignment) } + let(:user) { create(:user) } + let(:participant) { create(:participant, user: user, assignment: assignment) } + let(:teams_participant) { create(:teams_participant, team: team, participant: participant) } + + describe '#valid_participant?' do + it 'returns true if participant exists' do + allow(TeamsParticipant).to receive(:find_by).and_return(teams_participant) + expect(controller.send(:valid_participant?, teams_participant.id)).to be true + end + + it 'returns false if participant does not exist' do + allow(TeamsParticipant).to receive(:find_by).and_return(nil) + expect(controller.send(:valid_participant?, 999)).to be false + end + end + + describe '#update_duties' do + it 'updates participant duties' do + sign_in user + put :update_duties, params: { id: teams_participant.id, duty: 'Review' } + expect(response).to have_http_status(:redirect) + end + end + + describe '#user_not_found_message' do + it 'returns a user not found message' do + expect(controller.send(:user_not_found_message)).to eq('User not found.') + end + end + + describe '#participant_not_found_message' do + it 'returns a participant not found message' do + expect(controller.send(:participant_not_found_message)).to eq('Participant not found.') + end + end + + describe '#create' do + it 'creates a new TeamsParticipant' do + expect { + post :create, params: { team_id: team.id, user_id: user.id } + }.to change(TeamsParticipant, :count).by(1) + end + end + + describe '#list' do + it 'renders the list template' do + get :list, params: { team_id: team.id } + expect(response).to render_template(:list) + end + end + + describe '#action_allowed?' do + it 'returns true for admin' do + allow(controller).to receive(:current_user_role?).and_return(true) + expect(controller.send(:action_allowed?)).to be true + end + + it 'returns false for unauthorized users' do + allow(controller).to receive(:current_user_role?).and_return(false) + expect(controller.send(:action_allowed?)).to be false + end + end + + describe '#delete' do + it 'deletes a TeamsParticipant' do + teams_participant + expect { + delete :delete, params: { id: teams_participant.id } + }.to change(TeamsParticipant, :count).by(-1) + end + end + + describe '#auto_complete_for_user_name' do + it 'returns JSON results for auto-completion' do + create(:user, name: 'John Doe') + get :auto_complete_for_user_name, params: { name: 'John' } + expect(response.content_type).to eq('application/json') + end + end + + describe '#new' do + it 'renders the new template' do + get :new, params: { team_id: team.id } + expect(response).to render_template(:new) + end + end + + describe '#delete_selected' do + it 'deletes multiple TeamsParticipants' do + participant2 = create(:participant, assignment: assignment) + teams_participant2 = create(:teams_participant, team: team, participant: participant2) + + expect { + delete :delete_selected, params: { ids: [teams_participant.id, teams_participant2.id] } + }.to change(TeamsParticipant, :count).by(-2) + end + end +end diff --git a/spec/requests/api/v1/teams_participant_request_spec.rb b/spec/requests/api/v1/teams_participant_request_spec.rb new file mode 100644 index 000000000..31557c9fc --- /dev/null +++ b/spec/requests/api/v1/teams_participant_request_spec.rb @@ -0,0 +1,27 @@ +require 'rails_helper' + +RSpec.describe 'TeamsParticipants', type: :request do + let(:assignment) { create(:assignment) } + let(:team) { create(:assignment_team, parent_id: assignment.id) } + let(:participant) { create(:participant) } + + describe 'POST /teams_participants' do + it 'adds a participant via API' do + post "/teams_participants", params: { team_id: team.id, participant_id: participant.id } + expect(response).to have_http_status(:redirect) + follow_redirect! + expect(response.body).to include('successfully added') + end + end + + describe 'DELETE /teams_participants/:id' do + let!(:teams_participant) { create(:teams_participant, team: team, participant: participant) } + + it 'removes a participant via API' do + delete "/teams_participants/#{teams_participant.id}" + expect(response).to have_http_status(:redirect) + follow_redirect! + expect(response.body).to include('successfully removed') + end + end +end diff --git a/spec/requests/api/v1/teams_participant_spec.rb b/spec/requests/api/v1/teams_participant_spec.rb new file mode 100644 index 000000000..0e290e4e1 --- /dev/null +++ b/spec/requests/api/v1/teams_participant_spec.rb @@ -0,0 +1,48 @@ +require 'rails_helper' + +RSpec.describe TeamsParticipant, type: :model do + let(:assignment) { create(:assignment) } + let(:team) { create(:assignment_team, assignment: assignment) } + let(:user) { create(:user) } + let(:participant) { create(:participant, user: user, assignment: assignment) } + let(:teams_participant) { create(:teams_participant, team: team, participant: participant) } + + describe 'associations' do + it { should belong_to(:team) } + it { should belong_to(:participant) } + end + + describe 'validations' do + it 'is valid with valid attributes' do + expect(teams_participant).to be_valid + end + + it 'is invalid without a team' do + teams_participant.team = nil + expect(teams_participant).not_to be_valid + end + + it 'is invalid without a participant' do + teams_participant.participant = nil + expect(teams_participant).not_to be_valid + end + end + + describe '#team_members' do + it 'returns team members for a given team' do + teams_participant + expect(TeamsParticipant.team_members(team.id)).to include(participant) + end + end + + describe '#participant?' do + it 'returns true if user is a participant' do + expect(teams_participant.participant?(user)).to be true + end + + it 'returns false if user is not a participant' do + another_user = create(:user) + expect(teams_participant.participant?(another_user)).to be false + end + end +end diff --git a/spec/requests/teams_participants_request_spec.rb b/spec/requests/teams_participants_request_spec.rb new file mode 100644 index 000000000..31557c9fc --- /dev/null +++ b/spec/requests/teams_participants_request_spec.rb @@ -0,0 +1,27 @@ +require 'rails_helper' + +RSpec.describe 'TeamsParticipants', type: :request do + let(:assignment) { create(:assignment) } + let(:team) { create(:assignment_team, parent_id: assignment.id) } + let(:participant) { create(:participant) } + + describe 'POST /teams_participants' do + it 'adds a participant via API' do + post "/teams_participants", params: { team_id: team.id, participant_id: participant.id } + expect(response).to have_http_status(:redirect) + follow_redirect! + expect(response.body).to include('successfully added') + end + end + + describe 'DELETE /teams_participants/:id' do + let!(:teams_participant) { create(:teams_participant, team: team, participant: participant) } + + it 'removes a participant via API' do + delete "/teams_participants/#{teams_participant.id}" + expect(response).to have_http_status(:redirect) + follow_redirect! + expect(response.body).to include('successfully removed') + end + end +end diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml index de8081625..e5f4f967a 100644 --- a/swagger/v1/swagger.yaml +++ b/swagger/v1/swagger.yaml @@ -12,6 +12,137 @@ components: security: - bearerAuth: [] paths: + "/api/v1/teams_users": + get: + summary: Get all team-user mappings + tags: + - TeamsUser + responses: + '200': + description: List of TeamsUser mappings + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: integer + team_id: + type: integer + user_id: + type: integer + + post: + summary: Create a new team-user mapping + tags: + - TeamsUser + parameters: [] + responses: + '201': + description: Successfully created a TeamsUser mapping + '422': + description: Unprocessable Entity – validation failed + requestBody: + content: + application/json: + schema: + type: object + properties: + team_id: + type: integer + user_id: + type: integer + required: + - team_id + - user_id + + "/api/v1/teams_users/{id}": + parameters: + - name: id + in: path + description: ID of the TeamsUser mapping + required: true + schema: + type: integer + + get: + summary: Get a specific team-user mapping + tags: + - TeamsUser + responses: + '200': + description: A TeamsUser mapping + content: + application/json: + schema: + type: object + properties: + id: + type: integer + team_id: + type: integer + user_id: + type: integer + '404': + description: TeamsUser not found + + put: + summary: Update a team-user mapping + tags: + - TeamsUser + parameters: [] + responses: + '200': + description: Updated TeamsUser mapping successfully + '422': + description: Unprocessable Entity – validation failed + requestBody: + content: + application/json: + schema: + type: object + properties: + team_id: + type: integer + user_id: + type: integer + required: + - team_id + - user_id + + delete: + summary: Delete a team-user mapping + tags: + - TeamsUser + responses: + '204': + description: Deleted successfully + '404': + description: TeamsUser not found + "/api/v1/teams/{id}/add_member": + post: + summary: Add a member to a team + tags: + - Teams + parameters: + - name: id + in: path + required: true + schema: + type: integer + - name: user_id + in: query + required: true + schema: + type: integer + responses: + '200': + description: Member added successfully + '422': + description: Failed to add member + "/api/v1/account_requests/pending": get: summary: List all Pending Account Requests