diff --git a/app/controllers/api/v1/participants_controller.rb b/app/controllers/api/v1/participants_controller.rb new file mode 100644 index 000000000..675ae7a1e --- /dev/null +++ b/app/controllers/api/v1/participants_controller.rb @@ -0,0 +1,199 @@ +class Api::V1::ParticipantsController < ApplicationController + include ParticipantsHelper + + # Returns true if the user has TA privileges; otherwise, denies access by returning false. + def action_allowed? + has_required_role?('Teaching Assistant') + end + + # Return a list of participants for a given user + # params - user_id + # GET /participants/user/:user_id + def list_user_participants + user = find_user if params[:user_id].present? + return if params[:user_id].present? && user.nil? + + participants = filter_user_participants(user) + + if participants.nil? + render json: participants.errors, status: :unprocessable_entity + else + render json: participants, status: :ok + end + end + + # Return a list of participants for a given assignment + # params - assignment_id + # GET /participants/assignment/:assignment_id + def list_assignment_participants + assignment = find_assignment if params[:assignment_id].present? + return if params[:assignment_id].present? && assignment.nil? + + participants = filter_assignment_participants(assignment) + + if participants.nil? + render json: participants.errors, status: :unprocessable_entity + else + render json: participants, status: :ok + end + end + + # Return a specified participant + # params - id + # GET /participants/:id + def show + participant = Participant.find(params[:id]) + + if participant.nil? + render json: participant.errors, status: :unprocessable_entity + else + render json: participant, status: :created + end + end + + # Assign the specified authorization to the participant and add them to an assignment + # POST /participants/:authorization + def add + user = find_user + return unless user + + assignment = find_assignment + return unless assignment + + authorization = validate_authorization + return unless authorization + + permissions = retrieve_participant_permissions(authorization) + + participant = assignment.add_participant(user) + participant.authorization = authorization + participant.can_submit = permissions[:can_submit] + participant.can_review = permissions[:can_review] + participant.can_take_quiz = permissions[:can_take_quiz] + participant.can_mentor = permissions[:can_mentor] + + if participant.save + render json: participant, status: :created + else + render json: participant.errors, status: :unprocessable_entity + end + end + + # Update the specified participant to the specified authorization + # PATCH /participants/:id/:authorization + def update_authorization + participant = find_participant + return unless participant + + authorization = validate_authorization + return unless authorization + + permissions = retrieve_participant_permissions(authorization) + + participant.authorization = authorization + participant.can_submit = permissions[:can_submit] + participant.can_review = permissions[:can_review] + participant.can_take_quiz = permissions[:can_take_quiz] + participant.can_mentor = permissions[:can_mentor] + + if participant.save + render json: participant, status: :created + else + render json: participant.errors, status: :unprocessable_entity + end + end + + # Delete a participant + # params - id + # DELETE /participants/:id + def destroy + participant = Participant.find_by(id: params[:id]) + + if participant.nil? + render json: { error: 'Not Found' }, status: :not_found + elsif participant.destroy + successful_deletion_message = if params[:team_id].nil? + "Participant #{params[:id]} in Assignment #{params[:assignment_id]} has been deleted successfully!" + else + "Participant #{params[:id]} in Team #{params[:team_id]} of Assignment #{params[:assignment_id]} has been deleted successfully!" + end + render json: { message: successful_deletion_message }, status: :ok + else + render json: participant.errors, status: :unprocessable_entity + end + end + + # Permitted parameters for creating a Participant object + def participant_params + params.require(:participant).permit(:user_id, :assignment_id, :authorization, :can_submit, + :can_review, :can_take_quiz, :can_mentor, :handle, + :team_id, :join_team_request_id, :permission_granted, + :topic, :current_stage, :stage_deadline) + end + + private + + # Filters participants based on the provided user + # Returns participants ordered by their IDs + def filter_user_participants(user) + participants = Participant.all + participants = participants.where(user_id: user.id) if user + participants.order(:id) + end + + # Filters participants based on the provided assignment + # Returns participants ordered by their IDs + def filter_assignment_participants(assignment) + participants = Participant.all + participants = participants.where(assignment_id: assignment.id) if assignment + participants.order(:id) + end + + # Finds a user based on the user_id parameter + # Returns the user if found + def find_user + user_id = params[:user_id] + user = User.find_by(id: user_id) + render json: { error: 'User not found' }, status: :not_found unless user + user + end + + # Finds an assignment based on the assignment_id parameter + # Returns the assignment if found + def find_assignment + assignment_id = params[:assignment_id] + assignment = Assignment.find_by(id: assignment_id) + render json: { error: 'Assignment not found' }, status: :not_found unless assignment + assignment + end + + # Finds a participant based on the id parameter + # Returns the participant if found + def find_participant + participant_id = params[:id] + participant = Participant.find_by(id: participant_id) + render json: { error: 'Participant not found' }, status: :not_found unless participant + participant + end + + # Validates that the authorization parameter is present and is one of the following valid authorizations: reader, reviewer, submitter, mentor + # Returns the authorization if valid + def validate_authorization + valid_authorizations = %w[reader reviewer submitter mentor] + authorization = params[:authorization] + authorization = authorization.downcase if authorization.present? + + unless authorization + render json: { error: 'authorization is required' }, status: :unprocessable_entity + return + end + + unless valid_authorizations.include?(authorization) + render json: { error: 'authorization not valid. Valid authorizations are: Reader, Reviewer, Submitter, Mentor' }, + status: :unprocessable_entity + return + end + + authorization + end +end diff --git a/app/helpers/participants_helper.rb b/app/helpers/participants_helper.rb new file mode 100644 index 000000000..6a3e7011a --- /dev/null +++ b/app/helpers/participants_helper.rb @@ -0,0 +1,28 @@ +module ParticipantsHelper + # =========================================================== + # A participant can be one of the following authorizations: + # Reader + # Reviewer + # Submitter + # Mentor + # =========================================================== + # Grant a participant permissions to submit, review, + # take quizzes, and mentor based on their authorization + def retrieve_participant_permissions(authorization) + default_permissions = { + can_submit: true, + can_review: true, + can_take_quiz: true, + can_mentor: false + } + + permissions_map = { + 'reader' => { can_submit: false }, + 'reviewer' => { can_submit: false, can_take_quiz: false }, + 'submitter' => { can_review: false, can_take_quiz: false }, + 'mentor' => { can_mentor: true } + } + + default_permissions.merge(permissions_map[authorization]) + end +end diff --git a/app/models/course.rb b/app/models/course.rb index 9e70ccf7d..e008fda7c 100644 --- a/app/models/course.rb +++ b/app/models/course.rb @@ -49,4 +49,4 @@ def copy_course new_course.name += '_copy' new_course.save end -end \ No newline at end of file +end diff --git a/app/models/participant.rb b/app/models/participant.rb index 4ab489ab2..cce037f0b 100644 --- a/app/models/participant.rb +++ b/app/models/participant.rb @@ -1,9 +1,17 @@ class Participant < ApplicationRecord + # Associations belongs_to :user belongs_to :assignment, foreign_key: 'assignment_id', inverse_of: false has_many :join_team_requests, dependent: :destroy belongs_to :team, optional: true + delegate :course, to: :assignment + + # Validations + validates :user_id, presence: true + validates :assignment_id, presence: true + + # Methods def fullname user.fullname end diff --git a/app/models/user.rb b/app/models/user.rb index 87f138fe5..ebcdf9ed0 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -17,6 +17,7 @@ class User < ApplicationRecord has_many :assignments has_many :teams_users, dependent: :destroy has_many :teams, through: :teams_users + has_many :participants scope :students, -> { where role_id: Role::STUDENT } scope :tas, -> { where role_id: Role::TEACHING_ASSISTANT } diff --git a/config/routes.rb b/config/routes.rb index fc8a710a2..e5d805c4f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -109,6 +109,17 @@ get :processed, action: :processed_requests end end + + resources :participants do + collection do + get '/user/:user_id', to: 'participants#user_index' + get '/assignment/:assignment_id', to: 'participants#assignment_index' + get '/:id', to: 'participants#show' + post '/:authorization', to: 'participants#add' + patch '/:id/:authorization', to: 'participants#update_authorization' + delete '/:id', to: 'participants#destroy' + end + end end end end \ No newline at end of file diff --git a/db/migrate/20231026002451_add_can_submit_to_participant.rb b/db/migrate/20231026002451_add_can_submit_to_participant.rb index 5257a362f..166cc638a 100644 --- a/db/migrate/20231026002451_add_can_submit_to_participant.rb +++ b/db/migrate/20231026002451_add_can_submit_to_participant.rb @@ -1,5 +1,5 @@ -class AddCanSubmitToParticipant < ActiveRecord::Migration[7.0] - def change - add_column :participants, :can_submit, :boolean, :default => true - end -end +class AddCanSubmitToParticipant < ActiveRecord::Migration[7.0] + def change + add_column :participants, :can_submit, :boolean, :default => true + end +end \ No newline at end of file diff --git a/db/migrate/20231026002543_add_can_review_to_participant.rb b/db/migrate/20231026002543_add_can_review_to_participant.rb index 482e34b4f..3deac9c3c 100644 --- a/db/migrate/20231026002543_add_can_review_to_participant.rb +++ b/db/migrate/20231026002543_add_can_review_to_participant.rb @@ -1,5 +1,5 @@ -class AddCanReviewToParticipant < ActiveRecord::Migration[7.0] - def change - add_column :participants, :can_review, :boolean, :default => true - end -end +class AddCanReviewToParticipant < ActiveRecord::Migration[7.0] + def change + add_column :participants, :can_review, :boolean, :default => true + end +end \ No newline at end of file diff --git a/db/migrate/20241201224112_add_can_take_quiz_to_participants.rb b/db/migrate/20241201224112_add_can_take_quiz_to_participants.rb new file mode 100644 index 000000000..97dd2ea7a --- /dev/null +++ b/db/migrate/20241201224112_add_can_take_quiz_to_participants.rb @@ -0,0 +1,5 @@ +class AddCanTakeQuizToParticipants < ActiveRecord::Migration[7.0] + def change + add_column :participants, :can_take_quiz, :boolean + end +end diff --git a/db/migrate/20241201224137_add_can_mentor_to_participants.rb b/db/migrate/20241201224137_add_can_mentor_to_participants.rb new file mode 100644 index 000000000..890458682 --- /dev/null +++ b/db/migrate/20241201224137_add_can_mentor_to_participants.rb @@ -0,0 +1,5 @@ +class AddCanMentorToParticipants < ActiveRecord::Migration[7.0] + def change + add_column :participants, :can_mentor, :boolean + end +end diff --git a/db/migrate/20241202165201_add_authorization_to_participants.rb b/db/migrate/20241202165201_add_authorization_to_participants.rb new file mode 100644 index 000000000..3ca665d5f --- /dev/null +++ b/db/migrate/20241202165201_add_authorization_to_participants.rb @@ -0,0 +1,5 @@ +class AddAuthorizationToParticipants < ActiveRecord::Migration[7.0] + def change + add_column :participants, :authorization, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 1770d8997..e50704472 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[7.0].define(version: 2024_04_15_192048) do +ActiveRecord::Schema[7.0].define(version: 2024_12_02_165201) do create_table "account_requests", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "username" t.string "full_name" @@ -187,6 +187,9 @@ t.string "topic" t.string "current_stage" t.datetime "stage_deadline" + t.boolean "can_take_quiz" + t.boolean "can_mentor" + t.string "authorization" t.index ["assignment_id"], name: "index_participants_on_assignment_id" t.index ["join_team_request_id"], name: "index_participants_on_join_team_request_id" t.index ["team_id"], name: "index_participants_on_team_id" diff --git a/db/seeds.rb b/db/seeds.rb index 134ac82e5..b6de376f2 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,8 +1,8 @@ begin #Create an instritution - Institution.create!( + inst_id = Institution.create!( name: 'North Carolina State University', - ) + ).id # Create an admin user User.create!( @@ -13,6 +13,115 @@ institution_id: 1, role_id: 1 ) + + + #Generate Random Users + num_students = 48 + num_assignments = 8 + num_teams = 16 + num_courses = 2 + num_instructors = 2 + + puts "creating instructors" + instructor_user_ids = [] + num_instructors.times do + instructor_user_ids << User.create( + name: Faker::Internet.unique.username, + email: Faker::Internet.unique.email, + password: "password", + full_name: Faker::Name.name, + institution_id: 1, + role_id: 3, + ).id + end + + puts "creating courses" + course_ids = [] + num_courses.times do |i| + course_ids << Course.create( + instructor_id: instructor_user_ids[i], + institution_id: inst_id, + directory_path: Faker::File.dir(segment_count: 2), + name: Faker::Company.industry, + info: "A fake class", + private: false + ).id + end + + puts "creating assignments" + assignment_ids = [] + num_assignments.times do |i| + assignment_ids << Assignment.create( + name: Faker::Verb.base, + instructor_id: instructor_user_ids[i%num_instructors], + course_id: course_ids[i%num_courses], + has_teams: true, + private: false + ).id + end + + + puts "creating teams" + team_ids = [] + num_teams.times do |i| + team_ids << Team.create( + assignment_id: assignment_ids[i%num_assignments] + ).id + end + + puts "creating students" + student_user_ids = [] + num_students.times do + student_user_ids << User.create( + name: Faker::Internet.unique.username, + email: Faker::Internet.unique.email, + password: "password", + full_name: Faker::Name.name, + institution_id: 1, + role_id: 5, + ).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 + + 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] + ) + if teams_user.persisted? + teams_users_ids << teams_user.id + puts "Created TeamsUser with ID: #{teams_user.id}" + else + puts "Failed to create TeamsUser: #{teams_user.errors.full_messages.join(', ')}" + end + end + + puts "assigning participant to students, teams, courses, and assignments" + participant_ids = [] + num_students.times do |i| + participant_ids << Participant.create( + user_id: student_user_ids[i], + assignment_id: assignment_ids[i%num_assignments], + team_id: team_ids[i%num_teams], + ).id + end + + + + + + + + rescue ActiveRecord::RecordInvalid => e puts 'The db has already been seeded' -end \ No newline at end of file +end diff --git a/spec/requests/api/v1/participants_controller_spec.rb b/spec/requests/api/v1/participants_controller_spec.rb new file mode 100644 index 000000000..36f933031 --- /dev/null +++ b/spec/requests/api/v1/participants_controller_spec.rb @@ -0,0 +1,328 @@ +require 'swagger_helper' + +RSpec.describe 'Participants API', type: :request do + before(:all) do + # Log in and retrieve the token once before all tests + post '/login', params: { user_name: 'admin', password: 'password123' }, headers: { 'Host' => 'localhost:3002' } + expect(response.status).to eq(200) + @token = JSON.parse(response.body)['token'] + end + + path '/api/v1/participants/user/{user_id}' do + get 'Retrieve participants for a specific user' do + tags 'Participants' + produces 'application/json' + + parameter name: :user_id, in: :path, type: :integer, description: 'ID of the user' + parameter name: 'Authorization', in: :header, type: :string, required: true, description: 'Bearer token' + + response '200', 'Returns participants' do + let(:user_id) { 4 } + let(:'Authorization') { "Bearer #{@token}" } + + run_test! do |response| + data = JSON.parse(response.body) + participant = data[0] + expect(participant).to be_a(Hash) + expect(participant['id']).to eq(1) + expect(participant['user_id']).to eq(4) + expect(participant['assignment_id']).to eq(1) + end + end + + response '200', 'Participant not found with user_id' do + let(:user_id) { 1 } + let(:'Authorization') { "Bearer #{@token}" } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data).to be_an(Array) + expect(data).to be_empty + end + end + + response '404', 'User Not Found' do + let(:user_id) { 99 } + let(:'Authorization') { "Bearer #{@token}" } + + run_test! do |response| + expect(JSON.parse(response.body)['error']).to eql('User not found') + end + end + + response '401', 'Unauthorized' do + let(:user_id) { 1 } + let(:'Authorization') { 'Bearer invalid_token' } + + run_test! do |response| + expect(JSON.parse(response.body)['error']).to eql('Not Authorized') + end + end + end + end + + path '/api/v1/participants/assignment/{assignment_id}' do + get 'Retrieve participants for a specific assignment' do + tags 'Participants' + produces 'application/json' + + parameter name: :assignment_id, in: :path, type: :integer, description: 'ID of the assignment' + parameter name: 'Authorization', in: :header, type: :string, required: true, description: 'Bearer token' + + response '200', 'Returns participants' do + let(:assignment_id) { 2 } + let(:'Authorization') { "Bearer #{@token}" } + + run_test! do |response| + data = JSON.parse(response.body) + participant = data[0] + expect(participant).to be_a(Hash) + expect(participant['id']).to eq(2) + expect(participant['user_id']).to eq(5) + expect(participant['assignment_id']).to eq(2) + end + end + + response '404', 'Assignment Not Found' do + let(:assignment_id) { 99 } + let(:'Authorization') { "Bearer #{@token}" } + + run_test! do |response| + expect(JSON.parse(response.body)['error']).to eql('Assignment not found') + end + end + + response '401', 'Unauthorized' do + let(:assignment_id) { 2 } + let(:'Authorization') { 'Bearer invalid_token' } + + run_test! do |response| + expect(JSON.parse(response.body)['error']).to eql('Not Authorized') + end + end + end + end + + path '/api/v1/participants/{id}' do + get 'Retrieve a specific participant' do + tags 'Participants' + produces 'application/json' + + parameter name: :id, in: :path, type: :integer, description: 'ID of the participant' + parameter name: 'Authorization', in: :header, type: :string, required: true, description: 'Bearer token' + + response '201', 'Returns a participant' do + let(:id) { 2 } + let(:'Authorization') { "Bearer #{@token}" } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data['user_id']).to eq(5) + expect(data['assignment_id']).to eq(2) + end + end + + response '404', 'Participant not found' do + let(:id) { 99 } + let(:'Authorization') { "Bearer #{@token}" } + + run_test! do |response| + expect(JSON.parse(response.body)['error']).to eql('Not Found') + end + end + + response '401', 'Unauthorized' do + let(:id) { 2 } + let(:'Authorization') { 'Bearer invalid_token' } + + run_test! do |response| + expect(JSON.parse(response.body)['error']).to eql('Not Authorized') + end + end + end + + delete 'Delete a specific participant' do + tags 'Participants' + parameter name: :id, in: :path, type: :integer, description: 'ID of the participant' + parameter name: 'Authorization', in: :header, type: :string, required: true, description: 'Bearer token' + + response '200', 'Participant deleted' do + let(:id) { 2 } + let(:'Authorization') { "Bearer #{@token}" } + + run_test! do |response| + expect(JSON.parse(response.body)['message']).to include('Participant') + end + end + + response '404', 'Participant not found' do + let(:id) { 99 } + let(:'Authorization') { "Bearer #{@token}" } + + run_test! do |response| + expect(JSON.parse(response.body)['error']).to eql('Not Found') + end + end + + response '401', 'Unauthorized' do + let(:id) { 2 } + let(:'Authorization') { 'Bearer invalid_token' } + + run_test! do |response| + expect(JSON.parse(response.body)['error']).to eql('Not Authorized') + end + end + end + end + + path '/api/v1/participants/{id}/{authorization}' do + patch 'Update participant authorization' do + tags 'Participants' + consumes 'application/json' + produces 'application/json' + + parameter name: :id, in: :path, type: :integer, description: 'ID of the participant' + parameter name: :authorization, in: :path, type: :string, description: 'New authorization' + parameter name: 'Authorization', in: :header, type: :string, required: true, description: 'Bearer token' + + response '201', 'Participant updated' do + let(:id) { 2 } + let(:authorization) { 'mentor' } + let(:'Authorization') { "Bearer #{@token}" } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data['authorization']).to eq('mentor') + end + end + + response '404', 'Participant not found' do + let(:id) { 99 } + let(:authorization) { 'mentor' } + let(:'Authorization') { "Bearer #{@token}" } + + run_test! do |response| + expect(JSON.parse(response.body)['error']).to eql('Participant not found') + end + end + + response '404', 'Participant not found' do + let(:id) { 99 } + let(:authorization) { 'teacher' } + let(:'Authorization') { "Bearer #{@token}" } + + run_test! do |response| + expect(JSON.parse(response.body)['error']).to eql('Participant not found') + end + end + + response '422', 'Authorization not found' do + let(:id) { 1 } + let(:authorization) { 'teacher' } + let(:'Authorization') { "Bearer #{@token}" } + + run_test! do |response| + expect(JSON.parse(response.body)['error']).to eql('authorization not valid. Valid authorizations are: Reader, Reviewer, Submitter, Mentor') + end + end + + response '401', 'Unauthorized' do + let(:id) { 2 } + let(:authorization) { 'mentor' } + let(:'Authorization') { 'Bearer invalid_token' } + + run_test! do |response| + expect(JSON.parse(response.body)['error']).to eql('Not Authorized') + end + end + end + end + + path '/api/v1/participants/{authorization}' do + post 'Add a participant' do + tags 'Participants' + consumes 'application/json' + produces 'application/json' + + parameter name: :authorization, in: :path, type: :string, description: 'Authorization level (Reader, Reviewer, Submitter, Mentor)' + parameter name: 'Authorization', in: :header, type: :string, required: true, description: 'Bearer token' + parameter name: :participant, in: :body, schema: { + type: :object, + properties: { + user_id: { type: :integer, description: 'ID of the user' }, + assignment_id: { type: :integer, description: 'ID of the assignment' } + }, + required: %w[user_id assignment_id] + } + + response '201', 'Participant successfully added' do + let(:authorization) { 'mentor' } + let(:'Authorization') { "Bearer #{@token}" } + let(:participant) { { user_id: 3, assignment_id: 1 } } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data['user_id']).to eq(3) + expect(data['assignment_id']).to eq(1) + expect(data['authorization']).to eq('mentor') + end + end + + def fetch_username(user_id) + User.find(user_id).name + end + + response '500', 'Participant already exist' do + let(:authorization) { 'mentor' } + let(:'Authorization') { "Bearer #{@token}" } + let(:participant) { { user_id: 4, assignment_id: 1 } } + let(:name) { User.find(participant[:user_id]).name } + + run_test! do |response| + + expect(JSON.parse(response.body)['exception']).to eq("#") + end + end + + response '404', 'User not found' do + let(:authorization) { 'mentor' } + let(:'Authorization') { "Bearer #{@token}" } + let(:participant) { { user_id: 99, assignment_id: 1 } } + + run_test! do |response| + expect(JSON.parse(response.body)['error']).to eq('User not found') + end + end + + response '404', 'Assignment not found' do + let(:authorization) { 'mentor' } + let(:'Authorization') { "Bearer #{@token}" } + let(:participant) { { user_id: 3, assignment_id: 99 } } + + run_test! do |response| + expect(JSON.parse(response.body)['error']).to eq('Assignment not found') + end + end + + response '422', 'Authorization not found' do + let(:authorization) { 'teacher' } + let(:'Authorization') { "Bearer #{@token}" } + let(:participant) { { user_id: 3, assignment_id: 1 } } + + run_test! do |response| + expect(JSON.parse(response.body)['error']).to eql('authorization not valid. Valid authorizations are: Reader, Reviewer, Submitter, Mentor') + end + end + + response '422', 'Invalid authorization' do + let(:authorization) { 'invalid_auth' } + let(:'Authorization') { "Bearer #{@token}" } + let(:participant) { { user_id: 3, assignment_id: 1 } } + + run_test! do |response| + expect(JSON.parse(response.body)['error']).to include('authorization not valid') + end + end + end + end +end \ No newline at end of file