diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 000000000..2cf7c700c Binary files /dev/null and b/.DS_Store differ diff --git a/app/controllers/assignments_controller.rb b/app/controllers/assignments_controller.rb index 95cd55220..e16ec85fa 100644 --- a/app/controllers/assignments_controller.rb +++ b/app/controllers/assignments_controller.rb @@ -209,7 +209,7 @@ def varying_rubrics_by_round? end end end - + private # Only allow a list of trusted parameters through. def assignment_params diff --git a/app/controllers/submitted_content_controller.rb b/app/controllers/submitted_content_controller.rb index a84dd6812..6557d5e67 100644 --- a/app/controllers/submitted_content_controller.rb +++ b/app/controllers/submitted_content_controller.rb @@ -302,6 +302,41 @@ def list_files render_error("Failed to list directory contents: #{e.message}. Please try again.", :internal_server_error) end + # GET /submitted_content/:id/view_submissions + def view_submissions + assignment = Assignment.find_by(id: params[:id]) + return render json: { error: "Assignment not found" }, status: :not_found if assignment.nil? + + submissions = assignment.teams.map do |team| + members = team.teams_users.includes(:user).map do |tu| + user = tu.user + { + full_name: user&.full_name, + github: "", + email: user&.email + } + end + + links = current_team_links(team, assignment.id) + files = current_team_files(team, assignment.id) + + { + id: team.id, + team_id: team.id, + team_name: team.name, + members: members, + links: links, + files: files + } + end + + render json: { + assignment_id: assignment.id, + assignment_name: assignment.name, + submissions: submissions + }, status: :ok + end + private # Before action callback: Sets @submission_record for the show action @@ -377,4 +412,56 @@ def create_submission_record_for(record_type, content, operation) operation: operation # Operation description (e.g., 'Submit File') ) end + + def current_team_links(team, assignment_id) + hyperlink_records = SubmissionRecord + .where(team_id: team.id, assignment_id: assignment_id, record_type: 'hyperlink') + .order(created_at: :desc) + + team.hyperlinks.each_with_index.map do |hyperlink, index| + matching_record = hyperlink_records.find { |record| record.content == hyperlink } + + { + id: matching_record&.id || index + 1, + url: hyperlink, + display_name: hyperlink, + name: hyperlink, + type: 'Hyperlink', + modified: matching_record&.created_at || team.updated_at + } + end + end + + def current_team_files(team, assignment_id) + team.set_team_directory_num + base_path = team.path.to_s + return [] unless File.directory?(base_path) + + file_records = SubmissionRecord + .where(team_id: team.id, assignment_id: assignment_id, record_type: 'file') + .order(created_at: :desc) + + file_entries = Dir.glob(File.join(base_path, '**', '*')).select { |entry| File.file?(entry) }.sort + + file_entries.each_with_index.map do |entry, index| + relative_path = entry.delete_prefix("#{base_path}/") + current_folder = File.dirname(relative_path) + current_folder = current_folder == '.' ? '/' : "/#{current_folder}" + file_name = File.basename(relative_path) + matching_record = + file_records.find do |record| + record.content.to_s == entry || File.basename(record.content.to_s) == file_name + end + + { + id: matching_record&.id || index + 1, + url: "/submitted_content/download?download=#{URI.encode_uri_component(file_name)}¤t_folder[name]=#{URI.encode_uri_component(current_folder)}", + display_name: file_name, + name: file_name, + size: File.size(entry), + type: File.extname(file_name).delete('.').upcase, + modified: File.mtime(entry) + } + end + end end diff --git a/app/helpers/submitted_content_helper.rb b/app/helpers/submitted_content_helper.rb index 2b6b19033..5ef599e8f 100644 --- a/app/helpers/submitted_content_helper.rb +++ b/app/helpers/submitted_content_helper.rb @@ -214,14 +214,17 @@ def copy_selected_file def delete_selected_files # Wrap the delete operation with error handling handle_file_operation_error('deleting') do + files_to_delete = resolved_files_for_deletion + + if files_to_delete.empty? + render json: { error: 'No files were selected for deletion.' }, status: :bad_request + return + end + # Track successfully deleted files for response deleted_files = [] - # Iterate through each file index in the chk_files param - Array(params[:chk_files]).each do |idx| - # Build the full file path for this index - file_path = File.join(params[:directories][idx], params[:filenames][idx]) - + files_to_delete.each do |file_path| # Check if file exists before attempting deletion if File.exist?(file_path) # Remove file or directory recursively @@ -231,7 +234,7 @@ def delete_selected_files deleted_files << file_path else # File doesn't exist, return error - render json: { error: "Cannot delete '#{params[:filenames][idx]}': File does not exist. It may have already been deleted." }, status: :not_found + render json: { error: "Cannot delete '#{File.basename(file_path)}': File does not exist. It may have already been deleted." }, status: :not_found return end end @@ -258,4 +261,35 @@ def create_new_folder render json: { message: "Directory '#{params[:faction][:create]}' created successfully." }, status: :created end end + + def resolved_files_for_deletion + current_folder_param = + params.dig(:current_folder, :name) || + params.dig('current_folder', 'name') || + params[:current_folder] || + params['current_folder'] + delete_target = params.dig(:faction, :delete) || params.dig('faction', 'delete') + + if current_folder_param.present? && delete_target.present? + team = participant_team + team.set_team_directory_num + + current_folder = clean_folder(current_folder_param) + base_path = team.path.to_s + directory_path = current_folder == '/' ? base_path : File.join(base_path, current_folder) + + return Array(delete_target).filter_map do |entry_name| + sanitized_name = clean_filename(entry_name) + File.join(directory_path, sanitized_name) if sanitized_name.present? + end + end + + Array(params[:chk_files]).filter_map do |idx| + directory = params.dig(:directories, idx) || params.dig(:directories, idx.to_s) + filename = params.dig(:filenames, idx) || params.dig(:filenames, idx.to_s) + next if directory.blank? || filename.blank? + + File.join(directory, filename) + end + end end diff --git a/config/routes.rb b/config/routes.rb index 57559d007..c2c77ef24 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -40,6 +40,21 @@ end end + resources :submitted_content do + collection do + get :download + get :list_files + delete :remove_hyperlink + post :submit_file + post :submit_hyperlink + post :folder_action + end + + member do + get :view_submissions + end + end + resources :bookmarks, except: [:new, :edit] do member do get 'bookmarkratings', to: 'bookmarks#get_bookmark_rating_score' @@ -108,17 +123,6 @@ delete :remove_advertisement end end - - resources :submitted_content do - collection do - get :download - get :list_files - delete :remove_hyperlink - post :submit_file - post :submit_hyperlink - post :folder_action - end - end resources :join_team_requests do member do diff --git a/db/schema.rb b/db/schema.rb index cddbe12c6..6a198491c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -456,9 +456,9 @@ add_foreign_key "assignments_duties", "duties" add_foreign_key "courses", "institutions" add_foreign_key "courses", "users", column: "instructor_id" + add_foreign_key "duties", "users", column: "instructor_id" add_foreign_key "invitations", "participants", column: "from_id" add_foreign_key "invitations", "participants", column: "to_id" - add_foreign_key "duties", "users", column: "instructor_id" add_foreign_key "items", "questionnaires" add_foreign_key "participants", "duties" add_foreign_key "participants", "join_team_requests" diff --git a/db/seeds.rb b/db/seeds.rb index d49c80f33..7ad755524 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -135,6 +135,57 @@ end end + puts "creating submission records (hyperlinks and files)" + hyperlink_samples = [ + "https://github.com/ncsu-csc/project-repo", + "https://github.com/ncsu-csc/demo-app", + "https://youtu.be/dQw4w9WgXcQ", + "https://docs.google.com/presentation/d/example", + "https://github.com/student-team/final-project" + ] + + file_samples = [ + { name: "report.pdf", url: "https://www.w3.org/WAI/UR/terms/media/sample.pdf", type: "pdf" }, + { name: "slides.pptx", url: "https://file-examples.com/storage/sample.pptx", type: "pptx" }, + { name: "readme.md", url: "https://raw.githubusercontent.com/github/docs/main/README.md", type: "md" } + ] + + num_teams.times do |i| + team = AssignmentTeam.find(team_ids[i]) + participant = AssignmentParticipant.find_by(team_id: team.id) + next unless participant + + assignment_id = team.parent_id + + # Add 1-2 hyperlinks per team + rand(1..2).times do |j| + url = hyperlink_samples[(i + j) % hyperlink_samples.length] + # Store on the team's hyperlinks list + team.submit_hyperlink(url) rescue nil + # Create audit record + SubmissionRecord.create( + record_type: 'hyperlink', + content: url, + operation: 'Submit Hyperlink', + team_id: team.id, + user: participant.user&.name || "student#{i}", + assignment_id: assignment_id, + created_at: Faker::Time.backward(days: 14) + ) + end + + file = file_samples[i % file_samples.length] + SubmissionRecord.create( + record_type: 'file', + content: file[:url], # store real URL instead of local path + operation: 'Submit File', + team_id: team.id, + user: participant.user&.name || "student#{i}", + assignment_id: team.parent_id, + created_at: Faker::Time.backward(days: 7) + ) + end + rescue ActiveRecord::RecordInvalid => e puts e, 'The db has already been seeded' end diff --git a/spec/requests/view_submissions_spec.rb b/spec/requests/view_submissions_spec.rb new file mode 100644 index 000000000..3e46ff24e --- /dev/null +++ b/spec/requests/view_submissions_spec.rb @@ -0,0 +1,393 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'json_web_token' + +RSpec.describe 'GET /submitted_content/:id/view_submissions', type: :request do + # ------------------------------------------------------------------ + # Shared test data + # ------------------------------------------------------------------ + let(:institution) { Institution.create!(name: 'NC State University') } + + let(:instructor) do + User.create!( + name: 'instructor1', + email: 'instructor@example.com', + password: 'password', + full_name: 'Test Instructor', + institution_id: institution.id, + role_id: Role.find_or_create_by!(name: 'Instructor').id + ) + end + + let(:assignment) do + Assignment.create!( + name: 'Test Assignment', + instructor_id: instructor.id, + has_teams: true, + private: false + ) + end + + let(:student_role) { Role.find_or_create_by!(name: 'Student') } + + let(:student1) do + User.create!( + name: 'student1', + email: 'student1@example.com', + password: 'password', + full_name: 'Alice Smith', + institution_id: institution.id, + role_id: student_role.id + ) + end + + let(:student2) do + User.create!( + name: 'student2', + email: 'student2@example.com', + password: 'password', + full_name: 'Bob Jones', + institution_id: institution.id, + role_id: student_role.id + ) + end + + let(:team) do + AssignmentTeam.create!(name: 'Team Alpha', parent_id: assignment.id) + end + + let!(:teams_user1) { TeamsUser.create!(team_id: team.id, user_id: student1.id) } + let!(:teams_user2) { TeamsUser.create!(team_id: team.id, user_id: student2.id) } + + let(:auth_headers) do + token = JsonWebToken.encode({ id: instructor.id }) + { 'Authorization' => "Bearer #{token}" } + end + + # ------------------------------------------------------------------ + # Helper + # ------------------------------------------------------------------ + def get_view_submissions(id, headers: auth_headers) + get "/submitted_content/#{id}/view_submissions", headers: headers + end + + # ================================================================== + # 1. Assignment not found + # ================================================================== + describe 'when assignment does not exist' do + it 'returns 404 with an error message' do + get_view_submissions(999_999) + + expect(response).to have_http_status(:not_found) + expect(response.parsed_body['error']).to eq('Assignment not found') + end + end + + # ================================================================== + # 2. Assignment exists but has no teams + # ================================================================== + describe 'when assignment has no teams' do + let(:empty_assignment) do + Assignment.create!( + name: 'Empty Assignment', + instructor_id: instructor.id, + has_teams: true, + private: false + ) + end + + it 'returns 200 with an empty submissions array' do + get_view_submissions(empty_assignment.id) + + expect(response).to have_http_status(:ok) + body = response.parsed_body + expect(body['assignment_id']).to eq(empty_assignment.id) + expect(body['submissions']).to eq([]) + end + end + + # ================================================================== + # 3. Assignment with teams but no submission records + # ================================================================== + describe 'when teams exist but have no submission records' do + it 'returns 200 with teams listed but empty links and files' do + get_view_submissions(assignment.id) + + expect(response).to have_http_status(:ok) + body = response.parsed_body + + expect(body['assignment_id']).to eq(assignment.id) + expect(body['assignment_name']).to eq('Test Assignment') + expect(body['submissions'].length).to eq(1) + + submission = body['submissions'].first + expect(submission['team_name']).to eq('Team Alpha') + expect(submission['links']).to eq([]) + expect(submission['files']).to eq([]) + end + + it 'includes all team members with correct fields' do + get_view_submissions(assignment.id) + + members = response.parsed_body['submissions'].first['members'] + expect(members.length).to eq(2) + + emails = members.map { |m| m['email'] } + expect(emails).to contain_exactly(student1.email, student2.email) + + full_names = members.map { |m| m['full_name'] } + expect(full_names).to contain_exactly(student1.full_name, student2.full_name) + end + end + + # ================================================================== + # 4. Team with hyperlink submission records + # ================================================================== + describe 'when team has hyperlink submission records' do + let!(:hyperlink_record) do + SubmissionRecord.create!( + record_type: 'hyperlink', + content: 'https://github.com/student1/project', + operation: 'Submit Hyperlink', + team_id: team.id, + user: student1.name, + assignment_id: assignment.id + ) + end + + it 'returns the hyperlink in the links array' do + get_view_submissions(assignment.id) + + expect(response).to have_http_status(:ok) + links = response.parsed_body['submissions'].first['links'] + expect(links.length).to eq(1) + expect(links.first['url']).to eq('https://github.com/student1/project') + expect(links.first['type']).to eq('Hyperlink') + end + + it 'does not include hyperlinks in the files array' do + get_view_submissions(assignment.id) + + files = response.parsed_body['submissions'].first['files'] + expect(files).to eq([]) + end + + it 'returns multiple hyperlinks when several exist' do + SubmissionRecord.create!( + record_type: 'hyperlink', + content: 'https://youtu.be/demo-video', + operation: 'Submit Hyperlink', + team_id: team.id, + user: student2.name, + assignment_id: assignment.id + ) + + get_view_submissions(assignment.id) + + links = response.parsed_body['submissions'].first['links'] + expect(links.length).to eq(2) + urls = links.map { |l| l['url'] } + expect(urls).to contain_exactly( + 'https://github.com/student1/project', + 'https://youtu.be/demo-video' + ) + end + end + + # ================================================================== + # 5. Team with file submission records + # ================================================================== + describe 'when team has file submission records' do + let!(:file_record) do + SubmissionRecord.create!( + record_type: 'file', + content: 'https://example.com/uploads/report.pdf', + operation: 'Submit File', + team_id: team.id, + user: student1.name, + assignment_id: assignment.id + ) + end + + it 'returns the file in the files array' do + get_view_submissions(assignment.id) + + expect(response).to have_http_status(:ok) + files = response.parsed_body['submissions'].first['files'] + expect(files.length).to eq(1) + expect(files.first['name']).to eq('report.pdf') + expect(files.first['url']).to eq('https://example.com/uploads/report.pdf') + end + + it 'correctly extracts file extension as type' do + get_view_submissions(assignment.id) + + files = response.parsed_body['submissions'].first['files'] + expect(files.first['type']).to eq('PDF') + end + + it 'does not include files in the links array' do + get_view_submissions(assignment.id) + + links = response.parsed_body['submissions'].first['links'] + expect(links).to eq([]) + end + end + + # ================================================================== + # 6. Team with both hyperlinks and files + # ================================================================== + describe 'when team has both hyperlinks and files' do + before do + SubmissionRecord.create!( + record_type: 'hyperlink', + content: 'https://github.com/team/repo', + operation: 'Submit Hyperlink', + team_id: team.id, + user: student1.name, + assignment_id: assignment.id + ) + SubmissionRecord.create!( + record_type: 'file', + content: 'https://example.com/slides.pptx', + operation: 'Submit File', + team_id: team.id, + user: student2.name, + assignment_id: assignment.id + ) + end + + it 'returns links and files in separate arrays' do + get_view_submissions(assignment.id) + + submission = response.parsed_body['submissions'].first + expect(submission['links'].length).to eq(1) + expect(submission['files'].length).to eq(1) + expect(submission['links'].first['type']).to eq('Hyperlink') + expect(submission['files'].first['type']).to eq('PPTX') + end + end + + # ================================================================== + # 7. Submission records from another assignment are not included + # ================================================================== + describe 'when submission records exist for a different assignment' do + let(:other_assignment) do + Assignment.create!( + name: 'Other Assignment', + instructor_id: instructor.id, + has_teams: true, + private: false + ) + end + + before do + SubmissionRecord.create!( + record_type: 'hyperlink', + content: 'https://github.com/other/repo', + operation: 'Submit Hyperlink', + team_id: team.id, + user: student1.name, + assignment_id: other_assignment.id # different assignment + ) + end + + it 'does not include records from other assignments' do + get_view_submissions(assignment.id) + + links = response.parsed_body['submissions'].first['links'] + expect(links).to eq([]) + end + end + + # ================================================================== + # 8. Multiple teams + # ================================================================== + describe 'when the assignment has multiple teams' do + let(:team2) { AssignmentTeam.create!(name: 'Team Beta', parent_id: assignment.id) } + let(:student3) do + User.create!( + name: 'student3', + email: 'student3@example.com', + password: 'password', + full_name: 'Carol White', + institution_id: institution.id, + role_id: student_role.id + ) + end + + before do + TeamsUser.create!(team_id: team2.id, user_id: student3.id) + SubmissionRecord.create!( + record_type: 'hyperlink', + content: 'https://github.com/team2/repo', + operation: 'Submit Hyperlink', + team_id: team2.id, + user: student3.name, + assignment_id: assignment.id + ) + end + + it 'returns a submission entry for each team' do + get_view_submissions(assignment.id) + + submissions = response.parsed_body['submissions'] + expect(submissions.length).to eq(2) + team_names = submissions.map { |s| s['team_name'] } + expect(team_names).to contain_exactly('Team Alpha', 'Team Beta') + end + + it 'only includes each team\'s own records' do + get_view_submissions(assignment.id) + + submissions = response.parsed_body['submissions'] + alpha = submissions.find { |s| s['team_name'] == 'Team Alpha' } + beta = submissions.find { |s| s['team_name'] == 'Team Beta' } + + expect(alpha['links']).to eq([]) + expect(beta['links'].first['url']).to eq('https://github.com/team2/repo') + end + end + + # ================================================================== + # 9. Team with no members + # ================================================================== + describe 'when a team has no members' do + let!(:empty_team) { AssignmentTeam.create!(name: 'Empty Team', parent_id: assignment.id) } + + it 'returns the team with an empty members array' do + get "/submitted_content/#{assignment.id}/view_submissions", headers: auth_headers + + submission = response.parsed_body['submissions'].find { |s| s['team_name'] == 'Empty Team' } + expect(submission).not_to be_nil + expect(submission['members']).to eq([]) + end + end + + # ================================================================== + # 10. Response shape contract + # ================================================================== + describe 'response shape' do + it 'always includes required top-level keys' do + get_view_submissions(assignment.id) + + body = response.parsed_body + expect(body.keys).to include('assignment_id', 'assignment_name', 'submissions') + end + + it 'each submission always includes required keys' do + get_view_submissions(assignment.id) + + submission = response.parsed_body['submissions'].first + expect(submission.keys).to include('id', 'team_id', 'team_name', 'members', 'links', 'files') + end + + it 'each member always includes required keys' do + get_view_submissions(assignment.id) + + member = response.parsed_body['submissions'].first['members'].first + expect(member.keys).to include('full_name', 'email', 'github') + end + end +end \ No newline at end of file