From c02c62896f7beedfb37f1f5fa3fd7f476bf9a3ef Mon Sep 17 00:00:00 2001 From: Shawty 2084 Date: Tue, 21 Oct 2025 17:28:31 -0400 Subject: [PATCH 01/18] E2551: Integrate SubmittedContentController --- Gemfile | 5 +- Gemfile.lock | 38 +- .../api/v1/submitted_content_controller.rb | 190 ++++ app/helpers/file_helper.rb | 34 + app/helpers/submitted_content_helper.rb | 125 +++ app/models/assignment.rb | 16 +- app/models/assignment_participant.rb | 16 + app/models/assignment_team.rb | 61 ++ app/models/submission_record.rb | 21 + config/routes.rb | 18 + ...0240323164131_create_submission_records.rb | 13 + ...pe_to_record_type_in_submission_records.rb | 13 + ...54115_add_submitted_hyperlinks_to_teams.rb | 5 + db/schema.rb | 14 +- db/seeds.rb | 21 +- spec/rails_helper.rb | 2 +- .../requests/api/v1/submitted_content_spec.rb | 819 +++++++++++++++++ swagger/v1/swagger.yaml | 844 +++++++++++++----- 18 files changed, 2020 insertions(+), 235 deletions(-) create mode 100644 app/controllers/api/v1/submitted_content_controller.rb create mode 100644 app/helpers/file_helper.rb create mode 100644 app/helpers/submitted_content_helper.rb create mode 100644 app/models/submission_record.rb create mode 100644 db/migrate/20240323164131_create_submission_records.rb create mode 100644 db/migrate/20250922160812_rename_type_to_record_type_in_submission_records.rb create mode 100644 db/migrate/20251019154115_add_submitted_hyperlinks_to_teams.rb create mode 100644 spec/requests/api/v1/submitted_content_spec.rb diff --git a/Gemfile b/Gemfile index 020dbe491..22ca5518f 100644 --- a/Gemfile +++ b/Gemfile @@ -5,7 +5,7 @@ ruby '3.4.5' gem 'mysql2', '~> 0.5.7' gem 'sqlite3', '~> 1.4' # Alternative for development -gem 'puma', '~> 5.0' +gem 'puma', '~> 6.0' gem 'rails', '~> 8.0', '>= 8.0.1' gem 'mini_portile2', '~> 2.8' # Helps with native gem compilation gem 'observer' # Required for Ruby 3.4.5 compatibility with Rails 8.0 @@ -55,6 +55,9 @@ gem 'lingua' # This is a really small gem that can be used to retrieve objects from the database in the order of the list given gem 'find_with_order' +# For handling zip file uploads and extraction +gem 'rubyzip' + group :development, :test do gem 'debug', platforms: %i[mri mingw x64_mingw] diff --git a/Gemfile.lock b/Gemfile.lock index 5e7341cb0..6dec3de0f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -106,6 +106,7 @@ GEM term-ansicolor thor crass (1.0.6) + csv (3.3.5) danger (9.5.3) base64 (~> 0.2) claide (~> 1.0) @@ -128,6 +129,7 @@ GEM debug (1.11.0) irb (~> 1.10) reline (>= 0.3.8) + delegate (0.4.0) diff-lcs (1.6.2) docile (1.4.1) domain_name (0.6.20240107) @@ -149,8 +151,11 @@ GEM faraday (>= 0.8) faraday-net_http (3.4.1) net-http (>= 0.5.0) + faraday-retry (2.3.2) + faraday (~> 2.0) find_with_order (1.3.1) activerecord (>= 3) + forwardable (1.3.3) git (2.3.3) activesupport (>= 5.0) addressable (~> 2.8) @@ -197,10 +202,13 @@ GEM mime-types-data (~> 3.2025, >= 3.2025.0507) mime-types-data (3.2025.0924) mini_mime (1.1.5) + mini_portile2 (2.8.9) minitest (5.25.5) mize (0.6.1) + monitor (0.2.0) msgpack (1.8.0) multi_json (1.17.0) + mutex_m (0.3.0) mysql2 (0.5.7) bigdecimal nap (1.1.0) @@ -217,7 +225,7 @@ GEM net-protocol netrc (0.11.0) nio4r (2.5.9) - nokogiri (1.15.2-aarch64-linux) + nokogiri (1.18.10-aarch64-linux-gnu) racc (~> 1.4) nokogiri (1.18.10-arm64-darwin) racc (~> 1.4) @@ -230,6 +238,7 @@ GEM faraday (>= 1, < 3) sawyer (~> 0.9) open4 (1.3.4) + ostruct (0.6.3) parallel (1.27.0) parser (3.3.9.0) ast (~> 2.4.1) @@ -346,10 +355,12 @@ GEM parser (>= 3.3.7.2) prism (~> 1.4) ruby-progressbar (1.13.0) + rubyzip (3.2.0) sawyer (0.9.2) addressable (>= 2.3.5) faraday (>= 0.17.3, < 3) securerandom (0.4.1) + set (1.1.2) shoulda-matchers (6.5.0) activesupport (>= 5.2.0) simplecov (0.22.0) @@ -358,7 +369,10 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) + singleton (0.3.0) spring (4.4.0) + sqlite3 (1.7.3) + mini_portile2 (~> 2.8.0) stringio (3.1.7) sync (0.5.0) term-ansicolor (1.11.3) @@ -398,18 +412,30 @@ PLATFORMS DEPENDENCIES active_model_serializers (~> 0.10.0) bcrypt (~> 3.1.7) + bigdecimal bootsnap (>= 1.18.4) coveralls + csv danger database_cleaner-active_record + date debug + delegate factory_bot_rails faker + faraday-retry find_with_order + forwardable jwt (~> 2.7, >= 2.7.1) lingua - mysql2 (~> 0.5.5) + logger + mini_portile2 (~> 2.8) + monitor + mutex_m + mysql2 (~> 0.5.7) observer + ostruct + psych (~> 5.2) puma (~> 6.0) rack-cors rails (~> 8.0, >= 8.0.1) @@ -418,14 +444,20 @@ DEPENDENCIES rswag-specs rswag-ui rubocop + rubyzip + set shoulda-matchers simplecov simplecov_json_formatter + singleton spring + sqlite3 (~> 1.4) + timeout tzinfo-data + uri RUBY VERSION - ruby 3.2.7p253 + ruby 3.4.5p51 BUNDLED WITH 2.4.14 diff --git a/app/controllers/api/v1/submitted_content_controller.rb b/app/controllers/api/v1/submitted_content_controller.rb new file mode 100644 index 000000000..896548784 --- /dev/null +++ b/app/controllers/api/v1/submitted_content_controller.rb @@ -0,0 +1,190 @@ +class Api::V1::SubmittedContentController < ApplicationController + include SubmittedContentHelper + include FileHelper + + before_action :set_submission_record, only: [:show] + before_action :set_participant, only: [:submit_hyperlink, :remove_hyperlink, :submit_file, :folder_action, :download] + before_action :ensure_participant_team, only: [:submit_hyperlink, :remove_hyperlink, :submit_file, :folder_action, :download] + + # GET /api/v1/submitted_content + def index + render json: SubmissionRecord.all, status: :ok + end + + # GET /api/v1/submitted_content/:id + def show + render json: @submission_record, status: :ok + end + + # POST /api/v1/submitted_content + def create + attrs = submitted_content_params + attrs[:record_type] ||= attrs[:content].to_s.start_with?('http') ? 'hyperlink' : 'file' + record = SubmissionRecord.new(attrs) + + if record.save + render json: record, status: :created + else + render json: record.errors, status: :unprocessable_entity + end + end + + # POST /api/v1/submitted_content/submit_hyperlink + # GET /api/v1/submitted_content/submit_hyperlink + def submit_hyperlink + team = @participant.team + submission = params[:submission].to_s.strip + + if submission.blank? + return render json: { error: 'Submission cannot be blank' }, status: :bad_request + end + + if team.hyperlinks.include?(submission) + return render json: { message: 'You or your teammate(s) have already submitted the same hyperlink.' }, status: :unprocessable_entity + end + + begin + team.submit_hyperlink(submission) + create_submission_record_for('hyperlink', submission, 'Submit Hyperlink') + render json: { message: 'The link has been successfully submitted.' }, status: :ok + rescue StandardError => e + render json: { error: "The URL or URI is invalid. Reason: #{e.message}" }, status: :unprocessable_entity + end + end + + # POST /api/v1/submitted_content/remove_hyperlink + # GET /api/v1/submitted_content/remove_hyperlink + def remove_hyperlink + team = @participant.team + index = params['chk_links'].to_i + hyperlink_to_delete = team.hyperlinks[index] + + unless hyperlink_to_delete + return render json: { error: 'Hyperlink not found' }, status: :not_found + end + + begin + team.remove_hyperlink(hyperlink_to_delete) + create_submission_record_for('hyperlink', hyperlink_to_delete, 'Remove Hyperlink') + head :no_content + rescue StandardError => e + render json: { error: "There was an error deleting the hyperlink. Reason: #{e.message}" }, status: :unprocessable_entity + end + end + + # POST /api/v1/submitted_content/submit_file + # GET /api/v1/submitted_content/submit_file + def submit_file + uploaded = params[:uploaded_file] + return render json: { error: 'No file provided' }, status: :bad_request unless uploaded + + file_size_limit_mb = 5 + unless check_content_size(uploaded, file_size_limit_mb) + return render json: { error: "File size must be smaller than #{file_size_limit_mb}MB" }, status: :bad_request + end + + unless check_extension_integrity(uploaded.original_filename) + render json: { error: 'File extension does not match' }, status: :unprocessable_entity + return + end + + file_bytes = uploaded.read + current_folder = sanitize_folder(params.dig(:current_folder, :name) || '/') + team = @participant.team + team.set_student_directory_num + + current_directory = File.join(team.path.to_s, current_folder) + FileUtils.mkdir_p(current_directory) unless File.exist?(current_directory) + + safe_filename = sanitize_filename(uploaded.original_filename.tr('\\', '/')).gsub(' ', '_') + full_path = File.join(current_directory, File.basename(safe_filename)) + + # Save file + File.open(full_path, 'wb') { |f| f.write(file_bytes) } + + # Unzip if requested and allowed + if params[:unzip] && file_type(safe_filename) == 'zip' + SubmittedContentHelper.unzip_file(full_path, current_directory, true) + end + + create_submission_record_for('file', full_path, 'Submit File') + render json: { message: 'The file has been submitted.' }, status: :ok + rescue StandardError => e + render json: { error: "File submission failed: #{e.message}" }, status: :unprocessable_entity + end + + # POST /api/v1/submitted_content/folder_action + # GET /api/v1/submitted_content/folder_action + def folder_action + faction = params[:faction] || {} + if faction[:delete].present? + delete_selected_files + elsif faction[:rename].present? + rename_selected_file + elsif faction[:move].present? + move_selected_file + elsif faction[:copy].present? + copy_selected_file + elsif faction[:create].present? + create_new_folder + else + render json: { error: 'No folder action specified' }, status: :bad_request + end + end + + # GET /api/v1/submitted_content/download + def download + folder_name = sanitize_folder(params.dig(:current_folder, :name) || '/') + file_name = params[:download] + + if folder_name.blank? + return render json: { message: 'Folder_name is nil.' }, status: :bad_request + elsif file_name.blank? + return render json: { message: 'File name is nil.' }, status: :bad_request + end + + path = File.join(folder_name, file_name) + if File.directory?(path) + return render json: { message: 'Cannot send a whole folder.' }, status: :bad_request + elsif !File.exist?(path) + return render json: { message: 'File does not exist.' }, status: :not_found + end + + # send_file will stream and return; do NOT render after send_file + send_file(path, disposition: 'inline') + end + + private + + def set_submission_record + @submission_record = SubmissionRecord.find(params[:id]) + rescue ActiveRecord::RecordNotFound => e + render json: { error: e.message }, status: :not_found and return + end + + def set_participant + @participant = AssignmentParticipant.find(params[:id]) + end + + def ensure_participant_team + unless @participant && @participant.team + render json: { error: 'Participant or team not found' }, status: :not_found and return + end + end + + def submitted_content_params + params.require(:submitted_content).permit(:id, :content, :operation, :team_id, :user, :assignment_id, :record_type) + end + + # single place to create records for both files and hyperlinks + def create_submission_record_for(record_type, content, operation) + SubmissionRecord.create!( + record_type: record_type, + content: content, + user: @participant.user.try(:name), + team_id: @participant.team.try(:id), + assignment_id: @participant.assignment.try(:id), + operation: operation + ) + end +end diff --git a/app/helpers/file_helper.rb b/app/helpers/file_helper.rb new file mode 100644 index 000000000..457895353 --- /dev/null +++ b/app/helpers/file_helper.rb @@ -0,0 +1,34 @@ +module FileHelper + # Replace invalid characters with underscore + def clean_path(file_name) + file_name.gsub(%r{[^\w\.\_/]}, '_').tr("'", '_') + end + + # Removes any extension or paths from file_name + def sanitize_filename(file_name) + just_filename = File.basename(file_name) + clean_path(just_filename) + end + + # Moves file from old location to a new location + def move_file(old_loc, new_loc) + new_dir, filename = File.split(new_loc) + new_dir = clean_path(new_dir) + filename = sanitize_filename(filename) + + create_directory_from_path(new_dir) + FileUtils.mv old_loc, File.join(new_dir, filename) + end + + # Removes parent directory '..' from folder path + def sanitize_folder(folder) + folder.gsub('..', '') + end + + # Creates a new directory on the specified path + def create_directory_from_path(path) + FileUtils.mkdir_p(path) unless File.exist?(path) + rescue StandardError => e + raise "An error occurred while creating this directory: #{e.message}" + end +end diff --git a/app/helpers/submitted_content_helper.rb b/app/helpers/submitted_content_helper.rb new file mode 100644 index 000000000..8a6a55ca1 --- /dev/null +++ b/app/helpers/submitted_content_helper.rb @@ -0,0 +1,125 @@ +module SubmittedContentHelper + include FileHelper + + # Unzipping the requested file in a new directory + def self.unzip_file(file_name, unzip_dir, should_delete) + unless File.exist?(file_name) + return { error: "File #{file_name} does not exist" } + end + + Zip::File.open(file_name) do |zf| + zf.each do |e| + extract_entry(e, unzip_dir) + end + end + + File.delete(file_name) if should_delete + { message: "Unzipped successfully" } + end + + private + + # Extract all the subfolders in the zipped file + def self.extract_entry(e, unzip_dir) + safe_name = FileHelper.sanitize_filename(e.name) + file_path = File.join(unzip_dir, safe_name) + FileUtils.mkdir_p(File.dirname(file_path)) + e.extract(file_path) { true } # overwrite if exists + end + + def get_filename + "#{params[:directories][params[:chk_files]]}/#{params[:filenames][params[:chk_files]]}" + end + + def handle_file_operation_error(operation) + yield + rescue StandardError => e + render json: { error: "There was a problem #{operation} the file: #{e.message}"}, status: :unprocessable_entity + end + + def check_extension_integrity(original_filename) + allowed_extensions = %w[pdf png jpeg jpg zip tar gz 7z odt docx md rb mp4 txt] + file_extension = original_filename&.split('.')&.last&.downcase + allowed_extensions.include?(file_extension) + end + + def check_content_size(file, size_mb) + file.size <= size_mb * 1024 * 1024 + end + + def file_type(file_name) + File.extname(file_name).delete('.') + end + + def move_selected_file + old_filename = get_filename + new_location = File.join(@participant.dir_path, params[:faction][:move]) + + handle_file_operation_error('moving') do + FileHelper.move_file(old_filename, new_location) + render json: { message: "The file was successfully moved." }, status: :ok + return + end + end + + def rename_selected_file + old_filename = get_filename + new_filename = File.join(params[:directories][params[:chk_files]], + FileHelper.sanitize_filename(params[:faction][:rename])) + + handle_file_operation_error('renaming') do + if File.exist?(new_filename) + render json: { message: "A file already exists with that name" }, status: :conflict + return + end + File.rename(old_filename, new_filename) + end + end + + def copy_selected_file + old_filename = get_filename + new_filename = File.join(params[:directories][params[:chk_files]], + FileHelper.sanitize_filename(params[:faction][:copy])) + + handle_file_operation_error('copying') do + if File.exist?(new_filename) + render json: { message: 'A file with this name already exists.' }, status: :bad_request + return + end + unless File.exist?(old_filename) + render json: { message: 'The referenced file does not exist.' }, status: :not_found + return + end + + FileUtils.cp_r(old_filename, new_filename) + end + end + + def delete_selected_files + handle_file_operation_error('deleting') do + deleted_files = [] + + Array(params[:chk_files]).each do |idx| + file_path = File.join(params[:directories][idx], params[:filenames][idx]) + + if File.exist?(file_path) + FileUtils.rm_rf(file_path) # removes file or directory recursively + deleted_files << file_path + else + render json: { message: "File #{file_path} does not exist." }, status: :not_found + return + end + end + + render json: { message: "Deleted successfully.", files: deleted_files }, status: :ok + end + end + + def create_new_folder + location = File.join(@participant.dir_path, params[:faction][:create]) + handle_file_operation_error('creating directory') do + FileHelper.create_directory_from_path(location) + render json: { message: "The directory #{params[:faction][:create]} was created."}, status: :ok + end + end +end diff --git a/app/models/assignment.rb b/app/models/assignment.rb index 144cd5369..bfb976783 100644 --- a/app/models/assignment.rb +++ b/app/models/assignment.rb @@ -11,7 +11,7 @@ class Assignment < ApplicationRecord has_many :response_maps, foreign_key: 'reviewed_object_id', dependent: :destroy, inverse_of: :assignment has_many :review_mappings, class_name: 'ReviewResponseMap', foreign_key: 'reviewed_object_id', dependent: :destroy, inverse_of: :assignment has_many :sign_up_topics , class_name: 'SignUpTopic', foreign_key: 'assignment_id', dependent: :destroy - has_many :due_dates,as: :parent, class_name: 'DueDate', dependent: :destroy + has_many :due_dates, as: :parent, class_name: 'DueDate', dependent: :destroy belongs_to :course, optional: true belongs_to :instructor, class_name: 'User', inverse_of: :assignments @@ -29,6 +29,20 @@ def num_review_rounds rounds_of_reviews end + # Initializes the directory path for + def path + if course_id.nil? && instructor_id.nil? + raise 'The path cannot be created. The assignment must be associated with either a course or an instructor.' + end + + path_text = if !course_id.nil? && course_id > 0 + "#{Rails.root}/pg_data/#{FileHelper.clean_path(instructor[:name])}/#{FileHelper.clean_path(course.directory_path)}/" + else + "#{Rails.root}/pg_data/#{FileHelper.clean_path(instructor[:name])}/" + end + path_text + FileHelper.clean_path(directory_path) + end + # Add a participant to the assignment based on the provided user_id. # This method first finds the User with the given user_id. If the user does not exist, it raises an error. # It then checks if the user is already a participant in the assignment. If so, it raises an error. diff --git a/app/models/assignment_participant.rb b/app/models/assignment_participant.rb index 4edeee5c6..e95e9a047 100644 --- a/app/models/assignment_participant.rb +++ b/app/models/assignment_participant.rb @@ -1,9 +1,25 @@ # frozen_string_literal: true class AssignmentParticipant < Participant + has_many :review_mappings, class_name: 'ReviewResponseMap', foreign_key: 'reviewee_id' + has_many :response_maps, foreign_key: 'reviewee_id' belongs_to :user validates :handle, presence: true + # Fetches the team for specific participant + def team + AssignmentTeam.team(self) + end + + # Fetches Assignment Directory. + def dir_path + assignment.try :directory_path + end + + # Gets the student directory path + def path + "#{assignment.path}/#{team.directory_num}" + end def set_handle self.handle = if user.handle.nil? || (user.handle == '') diff --git a/app/models/assignment_team.rb b/app/models/assignment_team.rb index 77a99cfd4..f2d2304c8 100644 --- a/app/models/assignment_team.rb +++ b/app/models/assignment_team.rb @@ -3,7 +3,68 @@ class AssignmentTeam < Team # Each AssignmentTeam must belong to a specific assignment belongs_to :assignment, class_name: 'Assignment', foreign_key: 'parent_id' + has_many :review_mappings, class_name: 'ReviewResponseMap', foreign_key: 'reviewee_id' + def hyperlinks + submitted_hyperlinks.blank? ? [] : YAML.safe_load(submitted_hyperlinks) + end + + def submit_hyperlink(hyperlink) + hyperlink.strip! + raise 'The hyperlink cannot be empty!' if hyperlink.empty? + + hyperlink = "https://#{hyperlink}" unless hyperlink.start_with?('http://', 'https://') + # If not a valid URL, it will throw an exception + response_code = Net::HTTP.get_response(URI(hyperlink)) + raise "HTTP status code: #{response_code}" if response_code.code =~ /[45][0-9]{2}/ + + hyperlinks = self.hyperlinks + hyperlinks << hyperlink + self.submitted_hyperlinks = YAML.dump(hyperlinks) + save + end + + # Note: This method is not used yet. It is here in the case it will be needed. + # @exception If the index does not exist in the array + + def remove_hyperlink(hyperlink_to_delete) + hyperlinks = self.hyperlinks + hyperlinks.delete(hyperlink_to_delete) + self.submitted_hyperlinks = YAML.dump(hyperlinks) + save + end + + # return the team given the participant + def self.team(participant) + return nil if participant.nil? + + team = nil + teams_users = TeamsUser.where(users_id: participant.user_id) + return nil unless teams_users + + teams_users.each do |teams_user| + if teams_user.teams_id.nil? + next + end + team = AssignmentTeam.find(teams_user.teams_id) + return team if team.assignment_id == participant.assignment_id + end + nil + end + + # Set the directory num for this team + def set_student_directory_num + return if directory_num && (directory_num >= 0) + + max_num = AssignmentTeam.where(assignment_id:).order('directory_num desc').first.directory_num + dir_num = max_num ? max_num + 1 : 0 + update(directory_num: dir_num) + end + + # Gets the student directory path + def path + "#{assignment.path}/#{directory_num}" + end # Copies the current assignment team to a course team # - Creates a new CourseTeam with a modified name diff --git a/app/models/submission_record.rb b/app/models/submission_record.rb new file mode 100644 index 000000000..415a965be --- /dev/null +++ b/app/models/submission_record.rb @@ -0,0 +1,21 @@ +class SubmissionRecord < ApplicationRecord + RECORD_TYPES = %w[hyperlink file].freeze + + validates :record_type, presence: true, inclusion: { in: RECORD_TYPES } + validates :content, presence: true + validates :operation, presence: true + validates :team_id, presence: true + validates :user, presence: true + validates :assignment_id, presence: true + + scope :files, -> { where(record_type: 'file') } + scope :hyperlinks, -> { where(record_type: 'hyperlink') } + + def file? + record_type == 'file' + end + + def hyperlink? + record_type == 'hyperlink' + end +end diff --git a/config/routes.rb b/config/routes.rb index b77a95f63..8e443dc20 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -141,4 +141,22 @@ delete :delete_participants end end + + namespace :api do + namespace :v1 do + resources :submitted_content do + collection do + get :download + get :folder_action + post :folder_action + get :remove_hyperlink + post :remove_hyperlink + get :submit_file + post :submit_file + get :submit_hyperlink + post :submit_hyperlink + end + end + end + end end diff --git a/db/migrate/20240323164131_create_submission_records.rb b/db/migrate/20240323164131_create_submission_records.rb new file mode 100644 index 000000000..7baefcd8a --- /dev/null +++ b/db/migrate/20240323164131_create_submission_records.rb @@ -0,0 +1,13 @@ +class CreateSubmissionRecords < ActiveRecord::Migration[7.0] + def change + create_table :submission_records do |t| + t.text :type + t.string :content + t.string :operation + t.integer :team_id + t.string :user + t.integer :assignment_id + t.timestamps null: false + end + end +end diff --git a/db/migrate/20250922160812_rename_type_to_record_type_in_submission_records.rb b/db/migrate/20250922160812_rename_type_to_record_type_in_submission_records.rb new file mode 100644 index 000000000..f3888445c --- /dev/null +++ b/db/migrate/20250922160812_rename_type_to_record_type_in_submission_records.rb @@ -0,0 +1,13 @@ +class RenameTypeToRecordTypeInSubmissionRecords < ActiveRecord::Migration[7.0] + def change + # If the old column :type exists, rename it to :record_type (avoid STI conflicts) + if column_exists?(:submission_records, :type) + rename_column :submission_records, :type, :record_type + else + add_column :submission_records, :record_type, :string + end + + # make content a text field to store longer file paths or URLs + change_column :submission_records, :content, :text if column_exists?(:submission_records, :content) + end +end diff --git a/db/migrate/20251019154115_add_submitted_hyperlinks_to_teams.rb b/db/migrate/20251019154115_add_submitted_hyperlinks_to_teams.rb new file mode 100644 index 000000000..753a9dc79 --- /dev/null +++ b/db/migrate/20251019154115_add_submitted_hyperlinks_to_teams.rb @@ -0,0 +1,5 @@ +class AddSubmittedHyperlinksToTeams < ActiveRecord::Migration[8.0] + def change + add_column :teams, :submitted_hyperlinks, :text + end +end diff --git a/db/schema.rb b/db/schema.rb index 462029322..d0c38070c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_04_27_014225) do +ActiveRecord::Schema[8.0].define(version: 2025_10_19_154115) do create_table "account_requests", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "username" t.string "full_name" @@ -334,6 +334,17 @@ t.index ["team_id"], name: "index_signed_up_teams_on_team_id" end + create_table "submission_records", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.text "record_type" + t.text "content" + t.string "operation" + t.integer "team_id" + t.string "user" + t.integer "assignment_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "ta_mappings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "course_id", null: false t.bigint "user_id", null: false @@ -353,6 +364,7 @@ t.datetime "created_at", null: false t.datetime "updated_at", null: false t.integer "parent_id", null: false + t.text "submitted_hyperlinks" t.index ["mentor_id"], name: "index_teams_on_mentor_id" t.index ["type"], name: "index_teams_on_type" t.index ["user_id"], name: "index_teams_on_user_id" diff --git a/db/seeds.rb b/db/seeds.rb index 9828977ea..e546be397 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -5,15 +5,28 @@ inst_id = Institution.create!( name: 'North Carolina State University', ).id - + + roles = {} + + roles[:super_admin] = Role.find_or_create_by!(name: "Super Administrator", parent_id: nil) + + roles[:admin] = Role.find_or_create_by!(name: "Administrator", parent_id: roles[:super_admin].id) + + roles[:instructor] = Role.find_or_create_by!(name: "Instructor", parent_id: roles[:admin].id) + + roles[:ta] = Role.find_or_create_by!(name: "Teaching Assistant", parent_id: roles[:instructor].id) + + roles[:student] = Role.find_or_create_by!(name: "Student", parent_id: roles[:ta].id) + + puts "reached here" # Create an admin user User.create!( name: 'admin', email: 'admin2@example.com', password: 'password123', full_name: 'admin admin', - institution_id: 1, - role_id: 1 + institution_id: inst_id, + role_id: roles[:admin].id ) @@ -57,6 +70,7 @@ name: Faker::Verb.base, instructor_id: instructor_user_ids[i%num_instructors], course_id: course_ids[i%num_courses], + directory_path: "assignment_#{i+1}", has_teams: true, private: false ).id @@ -126,5 +140,6 @@ rescue ActiveRecord::RecordInvalid => e + puts e.message puts 'The db has already been seeded' end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 9d528540b..031fa13cf 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -77,7 +77,7 @@ end RSpec.configure do |config| # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures - config.fixture_path = Rails.root.join('spec/fixtures') + # config.fixture_path = Rails.root.join('spec/fixtures') # Deprecated in RSpec Rails 6+ # If you're not using ActiveRecord, or you'd prefer not to run each of your # examples within a transaction, remove the following line or assign false diff --git a/spec/requests/api/v1/submitted_content_spec.rb b/spec/requests/api/v1/submitted_content_spec.rb new file mode 100644 index 000000000..b9545b516 --- /dev/null +++ b/spec/requests/api/v1/submitted_content_spec.rb @@ -0,0 +1,819 @@ +require 'swagger_helper' +require 'rails_helper' +require 'action_dispatch/http/upload' +require 'json_web_token' + +# Load STI models (parent class must be loaded before child) +require Rails.root.join('app/models/participant') +require Rails.root.join('app/models/assignment_participant') +require Rails.root.join('app/models/assignment_team') +require Rails.root.join('app/models/assignment') + +RSpec.describe 'Submitted Content API', type: :request do + before(:all) do + @roles = create_roles_hierarchy + end + + let(:institution) { Institution.create!(name: 'NC State') } + + let(:instructor) do + User.create!( + name: 'profa', + password_digest: 'password', + role_id: @roles[:instructor].id, + full_name: 'Prof A', + email: 'testuser@example.com', + mru_directory_path: '/home/testuser', + institution_id: institution.id + ) + end + + let(:student) do + User.create!( + full_name: 'Student Member', + name: 'student_member', + email: 'studentmember@example.com', + password_digest: 'password', + role_id: @roles[:student].id, + institution_id: institution.id + ) + end + + let(:assignment) { Assignment.create!(name: 'Assignment 1', instructor_id: instructor.id, max_team_size: 3) } + + let(:team) do + AssignmentTeam.create!( + parent_id: assignment.id, + name: 'Team 1', + user_id: student.id + ) + end + + let(:participant) do + AssignmentParticipant.create!( + user_id: student.id, + parent_id: assignment.id, + handle: student.name + ) + end + + let(:Authorization) { auth_headers_student['Authorization'] } + let(:auth_headers_instructor) { { 'Authorization' => "Bearer #{JsonWebToken.encode(id: instructor.id)}" } } + let(:auth_headers_student) { { 'Authorization' => "Bearer #{JsonWebToken.encode(id: student.id)}" } } + + def json + JSON.parse(response.body) + end + + # helper to create submission records + def create_submission_record(attrs = {}) + SubmissionRecord.create!({ + record_type: 'file', + content: '/path/to/file.txt', + operation: 'Submit File', + team_id: team.id, + user: student.name, + assignment_id: assignment.id + }.merge(attrs)) + end + + path '/api/v1/submitted_content' do + get('list all submission records') do + tags 'SubmittedContent' + produces 'application/json' + + response(200, 'successful') do + before do + create_submission_record + create_submission_record(content: '/path/to/file2.txt') + end + + after do |example| + if response && response.body.present? + example.metadata[:response][:content] = { + 'application/json' => { example: JSON.parse(response.body, symbolize_names: true) } + } + end + end + + run_test! do + expect(response).to have_http_status(:ok) + expect(JSON.parse(response.body).size).to eq(2) + end + end + end + + post('create a submission record') do + tags 'SubmittedContent' + consumes 'application/json' + produces 'application/json' + parameter name: :submitted_content, in: :body, schema: { + type: :object, + properties: { + submitted_content: { + type: :object, + properties: { + record_type: { type: :string }, + content: { type: :string }, + operation: { type: :string }, + team_id: { type: :integer }, + user: { type: :string }, + assignment_id: { type: :integer } + }, + required: %w[content team_id user assignment_id] + } + } + } + + response(201, 'created') do + let(:submitted_content) do + { + submitted_content: { + content: 'http://example.com', + operation: 'Submit Hyperlink', + team_id: team.id, + user: student.name, + assignment_id: assignment.id + } + } + end + + after do |example| + if response && response.body.present? + example.metadata[:response][:content] = { + 'application/json' => { example: JSON.parse(response.body, symbolize_names: true) } + } + end + end + + run_test! do + expect(response).to have_http_status(:created) + parsed = json + expect(parsed['record_type']).to eq('hyperlink') + expect(parsed['content']).to eq('http://example.com') + end + end + + response(422, 'unprocessable entity') do + let(:submitted_content) do + { + submitted_content: { + content: 'test content' + # missing required keys intentionally + } + } + end + + run_test! do + expect(response).to have_http_status(:unprocessable_entity) + end + end + end + end + + path '/api/v1/submitted_content/{id}' do + get('show a submission record') do + tags 'SubmittedContent' + produces 'application/json' + parameter name: 'id', in: :path, type: :string, description: 'id' + + response(200, 'successful') do + let(:submission_record) { create_submission_record } + let(:id) { submission_record.id } + + after do |example| + if response && response.body.present? + example.metadata[:response][:content] = { + 'application/json' => { example: JSON.parse(response.body, symbolize_names: true) } + } + end + end + + run_test! do + expect(response).to have_http_status(:ok) + parsed = json + expect(parsed['id']).to eq(submission_record.id) + end + end + + response(404, 'not found') do + let(:id) { 'invalid' } + + run_test! do + expect(response).to have_http_status(:not_found) + parsed = json + expect(parsed['error']).to include("Couldn't find SubmissionRecord") + end + end + end + end + + path '/api/v1/submitted_content/submit_hyperlink' do + shared_examples 'hyperlink submission' do |method| + before do + allow(AssignmentParticipant).to receive(:find).and_return(participant) + allow(participant).to receive(:team).and_return(team) + allow(participant).to receive(:user).and_return(student) + allow(participant).to receive(:assignment).and_return(assignment) + end + + context 'with valid submission' do + let(:id) { participant.id } + let(:submission) { 'http://valid-link.com' } + + before do + allow(team).to receive(:hyperlinks).and_return([]) + allow(team).to receive(:submit_hyperlink) + end + + it 'returns success' do + send(method, '/api/v1/submitted_content/submit_hyperlink', + params: { id: id, submission: submission }, + headers: auth_headers_student) + + expect(response).to have_http_status(:ok) + parsed = json + expect(parsed['message']).to eq('The link has been successfully submitted.') + end + end + + context 'with blank submission' do + let(:id) { participant.id } + let(:submission) { '' } + + it 'returns bad request' do + send(method, '/api/v1/submitted_content/submit_hyperlink', + params: { id: id, submission: submission }, + headers: auth_headers_student) + + expect(response).to have_http_status(:bad_request) + parsed = json + expect(parsed['error']).to eq('Submission cannot be blank') + end + end + + context 'with duplicate hyperlink' do + let(:id) { participant.id } + let(:submission) { 'http://duplicate-link.com' } + + before do + allow(team).to receive(:hyperlinks).and_return([submission]) + end + + it 'returns unprocessable entity' do + send(method, '/api/v1/submitted_content/submit_hyperlink', + params: { id: id, submission: submission }, + headers: auth_headers_student) + + expect(response).to have_http_status(:unprocessable_entity) + parsed = json + expect(parsed['message']).to include('already submitted the same hyperlink') + end + end + + context 'with invalid URL' do + let(:id) { participant.id } + let(:submission) { 'invalid-url' } + + before do + allow(team).to receive(:hyperlinks).and_return([]) + allow(team).to receive(:submit_hyperlink).and_raise(StandardError, 'Invalid URL format') + end + + it 'returns unprocessable entity with error' do + send(method, '/api/v1/submitted_content/submit_hyperlink', + params: { id: id, submission: submission }, + headers: auth_headers_student) + + expect(response).to have_http_status(:unprocessable_entity) + parsed = json + expect(parsed['error']).to include('The URL or URI is invalid') + end + end + end + + describe 'POST' do + it_behaves_like 'hyperlink submission', :post + end + + describe 'GET' do + it_behaves_like 'hyperlink submission', :get + end + end + + path '/api/v1/submitted_content/remove_hyperlink' do + shared_examples 'hyperlink removal' do |method| + before do + allow(AssignmentParticipant).to receive(:find).and_return(participant) + allow(participant).to receive(:team).and_return(team) + allow(participant).to receive(:user).and_return(student) + allow(participant).to receive(:assignment).and_return(assignment) + end + + context 'with valid hyperlink index' do + let(:id) { participant.id } + let(:chk_links) { 0 } + let(:hyperlink) { 'http://link-to-remove.com' } + + before do + allow(team).to receive(:hyperlinks).and_return([hyperlink]) + allow(team).to receive(:remove_hyperlink) + end + + it 'returns no content' do + send(method, '/api/v1/submitted_content/remove_hyperlink', + params: { id: id, chk_links: chk_links }, + headers: auth_headers_student) + + expect(response).to have_http_status(:no_content) + end + end + + context 'with invalid hyperlink index' do + let(:id) { participant.id } + let(:chk_links) { 10 } + + before do + allow(team).to receive(:hyperlinks).and_return([]) + end + + it 'returns not found' do + send(method, '/api/v1/submitted_content/remove_hyperlink', + params: { id: id, chk_links: chk_links }, + headers: auth_headers_student) + + expect(response).to have_http_status(:not_found) + parsed = json + expect(parsed['error']).to eq('Hyperlink not found') + end + end + + context 'with removal error' do + let(:id) { participant.id } + let(:chk_links) { 0 } + let(:hyperlink) { 'http://link-with-error.com' } + + before do + allow(team).to receive(:hyperlinks).and_return([hyperlink]) + allow(team).to receive(:remove_hyperlink).and_raise(StandardError, 'Database error') + end + + it 'returns unprocessable entity' do + send(method, '/api/v1/submitted_content/remove_hyperlink', + params: { id: id, chk_links: chk_links }, + headers: auth_headers_student) + + expect(response).to have_http_status(:unprocessable_entity) + parsed = json + expect(parsed['error']).to include('There was an error deleting the hyperlink') + end + end + end + + describe 'POST' do + it_behaves_like 'hyperlink removal', :post + end + + describe 'GET' do + it_behaves_like 'hyperlink removal', :get + end + end + + path '/api/v1/submitted_content/submit_file' do + shared_examples 'file submission' do |method| + before do + allow(AssignmentParticipant).to receive(:find).and_return(participant) + allow(participant).to receive(:team).and_return(team) + allow(participant).to receive(:user).and_return(student) + allow(participant).to receive(:assignment).and_return(assignment) + allow(team).to receive(:set_student_directory_num) + allow(team).to receive(:path).and_return('/test/path') + end + + context 'without file' do + let(:id) { participant.id } + + it 'returns bad request' do + send(method, '/api/v1/submitted_content/submit_file', + params: { id: id }, + headers: auth_headers_student) + + expect(response).to have_http_status(:bad_request) + parsed = json + expect(parsed['error']).to eq('No file provided') + end + end + + context 'with oversized file' do + let(:id) { participant.id } + let(:uploaded_file) do + # Create a tempfile with block syntax + temp_file = nil + Tempfile.create(['test', '.txt']) do |file| + file.write('a' * 6.megabytes) + file.rewind + temp_file = ActionDispatch::Http::UploadedFile.new( + tempfile: file, + filename: 'large_file.txt', + type: 'text/plain' + ) + end + temp_file + end + + before do + allow_any_instance_of(Api::V1::SubmittedContentController) + .to receive(:check_content_size).and_return(false) + end + + it 'returns bad request for size limit' do + send(method, '/api/v1/submitted_content/submit_file', + params: { id: id, uploaded_file: uploaded_file }, + headers: auth_headers_student) + + expect(response).to have_http_status(:bad_request) + parsed = json + expect(parsed['error']).to include('File size must be smaller than') + end + end + + context 'with invalid extension' do + let(:id) { participant.id } + let(:uploaded_file) do + temp_file = nil + Tempfile.create(['test', '.exe']) do |file| + file.write('test content') + file.rewind + temp_file = ActionDispatch::Http::UploadedFile.new( + tempfile: file, + filename: 'test.exe', + type: 'application/x-msdownload' + ) + end + temp_file + end + + before do + allow_any_instance_of(Api::V1::SubmittedContentController) + .to receive(:check_content_size).and_return(true) + allow_any_instance_of(Api::V1::SubmittedContentController) + .to receive(:check_extension_integrity).and_return(false) + end + + it 'returns bad request for invalid extension' do + send(method, '/api/v1/submitted_content/submit_file', + params: { id: id, uploaded_file: uploaded_file }, + headers: auth_headers_student) + + expect(response).to have_http_status(:unprocessable_entity) + parsed = json + expect(parsed['error']).to include('File extension does not match') + end + end + + context 'with valid file' do + let(:id) { participant.id } + let(:uploaded_file) do + temp_file = nil + Tempfile.create(['test', '.txt']) do |file| + file.write('test content') + file.rewind + temp_file = ActionDispatch::Http::UploadedFile.new( + tempfile: file, + filename: 'test.txt', + type: 'text/plain' + ) + end + temp_file + end + + before do + allow_any_instance_of(Api::V1::SubmittedContentController) + .to receive(:check_content_size).and_return(true) + allow_any_instance_of(Api::V1::SubmittedContentController) + .to receive(:check_extension_integrity).and_return(true) + allow(FileUtils).to receive(:mkdir_p) + allow(File).to receive(:exist?).and_return(false, true) # First for directory check, then exists after creation + allow(File).to receive(:open).and_call_original + fake_file = StringIO.new + allow(File).to receive(:open).with(any_args).and_yield(fake_file) + allow_any_instance_of(Api::V1::SubmittedContentController) + .to receive(:create_submission_record_for).and_return(true) + end + + it 'returns success' do + send(method, '/api/v1/submitted_content/submit_file', + params: { id: id, uploaded_file: uploaded_file }, + headers: auth_headers_student) + + expect(response).to have_http_status(:ok) + parsed = json + expect(parsed['message']).to eq('The file has been submitted.') + end + end + + context 'with zip file and unzip flag' do + let(:id) { participant.id } + let(:uploaded_file) do + temp_file = nil + Tempfile.create(['test', '.zip']) do |file| + file.binmode # Set binary mode for zip files + file.write('PK') # Minimal zip file signature + file.write("\x03\x04" + "\x00" * 18) # Basic zip header + file.rewind + temp_file = ActionDispatch::Http::UploadedFile.new( + tempfile: file, + filename: 'test.zip', + type: 'application/zip' + ) + end + temp_file + end + + before do + allow_any_instance_of(Api::V1::SubmittedContentController) + .to receive(:check_content_size).and_return(true) + allow_any_instance_of(Api::V1::SubmittedContentController) + .to receive(:check_extension_integrity).and_return(true) + allow_any_instance_of(Api::V1::SubmittedContentController) + .to receive(:file_type).and_return('zip') + allow(FileUtils).to receive(:mkdir_p) + allow(File).to receive(:exist?).and_return(false, true) + allow(File).to receive(:open).and_call_original + fake_file = StringIO.new + allow(File).to receive(:open).with(any_args).and_yield(fake_file) + allow(SubmittedContentHelper).to receive(:unzip_file).and_return({ message: 'Unzipped successfully' }) + allow_any_instance_of(Api::V1::SubmittedContentController) + .to receive(:create_submission_record_for).and_return(true) + end + + it 'unzips the file when requested' do + expect(SubmittedContentHelper).to receive(:unzip_file) + + send(method, '/api/v1/submitted_content/submit_file', + params: { id: id, uploaded_file: uploaded_file, unzip: true }, + headers: auth_headers_student) + + expect(response).to have_http_status(:ok) + end + end + end + + describe 'POST' do + it_behaves_like 'file submission', :post + end + + describe 'GET' do + it_behaves_like 'file submission', :get + end + end + + path '/api/v1/submitted_content/folder_action' do + shared_examples 'folder actions' do |method| + before do + allow(AssignmentParticipant).to receive(:find).and_return(participant) + allow(participant).to receive(:team).and_return(team) + end + + context 'without action specified' do + let(:id) { participant.id } + + it 'returns bad request' do + send(method, '/api/v1/submitted_content/folder_action', + params: { id: id }, + headers: auth_headers_student) + + expect(response).to have_http_status(:bad_request) + parsed = json + expect(parsed['error']).to eq('No folder action specified') + end + end + + context 'with delete action' do + let(:id) { participant.id } + let(:faction) { { delete: 'true' } } + + before do + allow_any_instance_of(Api::V1::SubmittedContentController) + .to receive(:delete_selected_files).and_return(nil) + end + + it 'calls delete_selected_files' do + expect_any_instance_of(Api::V1::SubmittedContentController) + .to receive(:delete_selected_files) + + send(method, '/api/v1/submitted_content/folder_action', + params: { id: id, faction: faction }, + headers: auth_headers_student) + end + end + + context 'with rename action' do + let(:id) { participant.id } + let(:faction) { { rename: 'new_name.txt' } } + + before do + allow_any_instance_of(Api::V1::SubmittedContentController) + .to receive(:rename_selected_file).and_return(nil) + end + + it 'calls rename_selected_file' do + expect_any_instance_of(Api::V1::SubmittedContentController) + .to receive(:rename_selected_file) + + send(method, '/api/v1/submitted_content/folder_action', + params: { id: id, faction: faction }, + headers: auth_headers_student) + end + end + + context 'with move action' do + let(:id) { participant.id } + let(:faction) { { move: '/new/location' } } + + before do + allow_any_instance_of(Api::V1::SubmittedContentController) + .to receive(:move_selected_file).and_return(nil) + end + + it 'calls move_selected_file' do + expect_any_instance_of(Api::V1::SubmittedContentController) + .to receive(:move_selected_file) + + send(method, '/api/v1/submitted_content/folder_action', + params: { id: id, faction: faction }, + headers: auth_headers_student) + end + end + + context 'with copy action' do + let(:id) { participant.id } + let(:faction) { { copy: '/copy/location' } } + + before do + allow_any_instance_of(Api::V1::SubmittedContentController) + .to receive(:copy_selected_file).and_return(nil) + end + + it 'calls copy_selected_file' do + expect_any_instance_of(Api::V1::SubmittedContentController) + .to receive(:copy_selected_file) + + send(method, '/api/v1/submitted_content/folder_action', + params: { id: id, faction: faction }, + headers: auth_headers_student) + end + end + + context 'with create action' do + let(:id) { participant.id } + let(:faction) { { create: 'new_folder' } } + + before do + allow_any_instance_of(Api::V1::SubmittedContentController) + .to receive(:create_new_folder).and_return(nil) + end + + it 'calls create_new_folder' do + expect_any_instance_of(Api::V1::SubmittedContentController) + .to receive(:create_new_folder) + + send(method, '/api/v1/submitted_content/folder_action', + params: { id: id, faction: faction }, + headers: auth_headers_student) + end + end + end + + describe 'POST' do + it_behaves_like 'folder actions', :post + end + + describe 'GET' do + it_behaves_like 'folder actions', :get + end + end + + path '/api/v1/submitted_content/download' do + get('download file') do + tags 'SubmittedContent' + produces 'application/octet-stream' + parameter name: 'current_folder[name]', in: :query, type: :string, required: true + parameter name: :download, in: :query, type: :string, required: true + parameter name: :id, in: :query, type: :string, required: true + + before do + allow(AssignmentParticipant).to receive(:find).and_return(participant) + allow(participant).to receive(:team).and_return(team) + end + + response(400, 'folder name is nil') do + let(:'current_folder[name]') { '' } + let(:download) { 'test.txt' } + let(:id) { participant.id } + + run_test! do + parsed = json + expect(parsed['message']).to eq('Folder_name is nil.') + end + end + + response(400, 'file name is nil') do + let(:id) { participant.id } + let(:'current_folder[name]') { '/test' } + let(:download) { '' } + + run_test! do + parsed = json + expect(parsed['message']).to eq('File name is nil.') + end + end + + response(400, 'cannot send whole folder') do + let(:id) { participant.id } + let(:'current_folder[name]') { '/test' } + let(:download) { 'folder_name' } + + before do + allow(File).to receive(:directory?).and_return(true) + end + + run_test! do + parsed = json + expect(parsed['message']).to eq('Cannot send a whole folder.') + end + end + + response(404, 'file does not exist') do + let(:id) { participant.id } + let(:'current_folder[name]') { '/test' } + let(:download) { 'nonexistent.txt' } + + before do + allow(File).to receive(:directory?).and_return(false) + allow(File).to receive(:exist?).and_return(false) + end + + run_test! do + parsed = json + expect(parsed['message']).to eq('File does not exist.') + end + end + + response(200, 'file downloaded') do + let(:id) { participant.id } + let(:current_folder) { { name: '/test' } } + let(:download) { 'existing.txt' } + let(:file_path) { File.join('/test', 'existing.txt') } + + before do + allow(File).to receive(:directory?).with(file_path).and_return(false) + allow(File).to receive(:exist?).with(file_path).and_return(true) + allow_any_instance_of(Api::V1::SubmittedContentController) + .to receive(:send_file).and_return(nil) + end + + it 'sends the file' do + expect_any_instance_of(Api::V1::SubmittedContentController) + .to receive(:send_file).with(file_path, disposition: 'inline') + + get '/api/v1/submitted_content/download', + params: { id: id, current_folder: current_folder, download: download }, + headers: auth_headers_student + end + end + end + end + + describe 'Error handling' do + context 'when participant not found' do + it 'returns 500 (RecordNotFound bubbles)' do + allow(AssignmentParticipant).to receive(:find).and_raise(ActiveRecord::RecordNotFound) + + post '/api/v1/submitted_content/submit_hyperlink', + params: { id: 999, submission: 'http://test.com' }, + headers: auth_headers_student + + # controller currently does not rescue set_participant -> RecordNotFound => 500 + expect(response).to have_http_status(:not_found) + end + end + + context 'when team not found' do + before do + allow(AssignmentParticipant).to receive(:find).and_return(participant) + allow(participant).to receive(:team).and_return(nil) + end + + it 'returns not found for submit_hyperlink' do + post '/api/v1/submitted_content/submit_hyperlink', + params: { id: participant.id, submission: 'http://test.com' }, + headers: auth_headers_student + + expect(response).to have_http_status(:not_found) + parsed = json + expect(parsed['error']).to eq('Participant or team not found') + end + end + end +end \ No newline at end of file diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml index cc0294e73..46536e029 100644 --- a/swagger/v1/swagger.yaml +++ b/swagger/v1/swagger.yaml @@ -130,6 +130,296 @@ paths: responses: '204': description: successful + "/assignments": + get: + summary: Get assignments + tags: + - Get All Assignments + parameters: + - name: Content-Type + in: header + schema: + type: string + responses: + '200': + description: assignment successfully + "/assignments/{assignment_id}/add_participant/{user_id}": + parameters: + - name: assignment_id + in: path + required: true + schema: + type: string + - name: user_id + in: path + required: true + schema: + type: string + post: + summary: Adds a participant to an assignment + tags: + - Assignments + parameters: + - name: Content-Type + in: header + schema: + type: string + responses: + '200': + description: participant added successfully + '404': + description: assignment not found + "/assignments/{assignment_id}/remove_participant/{user_id}": + parameters: + - name: assignment_id + in: path + required: true + schema: + type: string + - name: user_id + in: path + required: true + schema: + type: string + delete: + summary: Removes a participant from an assignment + tags: + - Assignments + parameters: + - name: Content-Type + in: header + schema: + type: string + responses: + '200': + description: participant removed successfully + '404': + description: assignment or user not found + "/assignments/{assignment_id}/assign_course/{course_id}": + parameters: + - name: assignment_id + in: path + required: true + schema: + type: string + - name: course_id + in: path + required: true + schema: + type: string + patch: + summary: Make course_id of assignment null + tags: + - Assignments + parameters: + - name: Content-Type + in: header + schema: + type: string + responses: + '200': + description: course_id assigned successfully + '404': + description: assignment not found + "/assignments/{assignment_id}/remove_assignment_from_course": + patch: + summary: Removes assignment from course + tags: + - Assignments + parameters: + - name: assignment_id + in: path + required: true + schema: + type: integer + - name: Content-Type + in: header + schema: + type: string + responses: + '200': + description: assignment removed from course + '404': + description: assignment not found + "/assignments/{assignment_id}/copy_assignment": + parameters: + - name: assignment_id + in: path + required: true + schema: + type: string + post: + summary: Copy an existing assignment + tags: + - Assignments + parameters: + - name: Content-Type + in: header + schema: + type: string + responses: + '200': + description: assignment copied successfully + '404': + description: assignment not found + "/assignments/{id}": + parameters: + - name: id + in: path + description: Assignment ID + required: true + schema: + type: integer + delete: + summary: Delete an assignment + tags: + - Assignments + parameters: + - name: Content-Type + in: header + schema: + type: string + responses: + '200': + description: successful + '404': + description: Assignment not found + "/assignments/{assignment_id}/has_topics": + parameters: + - name: assignment_id + in: path + description: Assignment ID + required: true + schema: + type: integer + get: + summary: Check if an assignment has topics + tags: + - Assignments + parameters: + - name: Authorization + in: header + schema: + type: string + - name: Content-Type + in: header + schema: + type: string + responses: + '200': + description: successful + '404': + description: Assignment not found + "/assignments/{assignment_id}/team_assignment": + parameters: + - name: assignment_id + in: path + description: Assignment ID + required: true + schema: + type: integer + get: + summary: Check if an assignment is a team assignment + tags: + - Assignments + parameters: + - name: Authorization + in: header + schema: + type: string + - name: Content-Type + in: header + schema: + type: string + responses: + '200': + description: successful + '404': + description: Assignment not found + "/assignments/{assignment_id}/valid_num_review/{review_type}": + parameters: + - name: assignment_id + in: path + description: Assignment ID + required: true + schema: + type: integer + - name: review_type + in: path + description: Review Type + required: true + schema: + type: string + get: + summary: Check if an assignment has a valid number of reviews + tags: + - Assignments + parameters: + - name: Authorization + in: header + schema: + type: string + - name: Content-Type + in: header + schema: + type: string + responses: + '200': + description: successful + '404': + description: Assignment not found + "/assignments/{assignment_id}/has_teams": + parameters: + - name: assignment_id + in: path + description: Assignment ID + required: true + schema: + type: integer + get: + summary: Check if an assignment has teams + tags: + - Assignments + parameters: + - name: Authorization + in: header + schema: + type: string + - name: Content-Type + in: header + schema: + type: string + responses: + '200': + description: successful + '404': + description: Assignment not found + "/assignments/{id}/show_assignment_details": + parameters: + - name: id + in: path + description: Assignment ID + required: true + schema: + type: integer + get: + summary: Retrieve assignment details + tags: + - Assignments + parameters: + - name: Authorization + in: header + schema: + type: string + - name: Content-Type + in: header + schema: + type: string + responses: + '200': + description: successful + '404': + description: Assignment not found "/bookmarks": get: summary: list bookmarks @@ -162,14 +452,11 @@ paths: type: string topic_id: type: integer - rating: - type: integer required: - url - title - description - topic_id - - rating "/bookmarks/{id}": parameters: - name: id @@ -196,12 +483,6 @@ paths: description: successful '404': description: not found - '422': - description: unprocessable entity - content: - application/json: - schema: - type: string requestBody: content: application/json: @@ -475,7 +756,7 @@ paths: responses: '200': description: successful - patch: + put: summary: update institution tags: - Institutions @@ -495,7 +776,7 @@ paths: type: string required: - name - put: + patch: summary: update institution tags: - Institutions @@ -628,6 +909,156 @@ paths: description: Show all invitations for the user for an assignment '404': description: Not found + "/participants/user/{user_id}": + get: + summary: Retrieve participants for a specific user + tags: + - Participants + parameters: + - name: user_id + in: path + description: ID of the user + required: true + schema: + type: integer + responses: + '200': + description: Participant not found with user_id + '404': + description: User Not Found + '401': + description: Unauthorized + "/participants/assignment/{assignment_id}": + get: + summary: Retrieve participants for a specific assignment + tags: + - Participants + parameters: + - name: assignment_id + in: path + description: ID of the assignment + required: true + schema: + type: integer + responses: + '200': + description: Returns participants + '404': + description: Assignment Not Found + '401': + description: Unauthorized + "/participants/{id}": + get: + summary: Retrieve a specific participant + tags: + - Participants + parameters: + - name: id + in: path + description: ID of the participant + required: true + schema: + type: integer + responses: + '201': + description: Returns a participant + '404': + description: Participant not found + '401': + description: Unauthorized + delete: + summary: Delete a specific participant + tags: + - Participants + parameters: + - name: id + in: path + description: ID of the participant + required: true + schema: + type: integer + responses: + '200': + description: Participant deleted + '404': + description: Participant not found + '401': + description: Unauthorized + "/participants/{id}/{authorization}": + patch: + summary: Update participant authorization + tags: + - Participants + parameters: + - name: id + in: path + description: ID of the participant + required: true + schema: + type: integer + - name: authorization + in: path + description: New authorization + required: true + schema: + type: string + - name: Authorization + in: header + required: true + description: Bearer token + schema: + type: string + responses: + '201': + description: Participant updated + '404': + description: Participant not found + '422': + description: Authorization not found + '401': + description: Unauthorized + "/participants/{authorization}": + post: + summary: Add a participant + tags: + - Participants + parameters: + - name: authorization + in: path + description: Authorization level (Reader, Reviewer, Submitter, Mentor) + required: true + schema: + type: string + - name: Authorization + in: header + required: true + description: Bearer token + schema: + type: string + responses: + '201': + description: Participant successfully added + '500': + description: Participant already exist + '404': + description: Assignment not found + '422': + description: Invalid authorization + requestBody: + content: + application/json: + schema: + type: object + properties: + user_id: + type: integer + description: ID of the user + assignment_id: + type: integer + description: ID of the assignment + required: + - user_id + - assignment_id "/questionnaires": get: summary: list questionnaires @@ -638,8 +1069,6 @@ paths: description: successful post: summary: create questionnaire - tags: - - Questionnaires parameters: [] responses: '201': @@ -703,7 +1132,9 @@ paths: content: application/json: schema: - type: string + type: array + items: + type: string requestBody: content: application/json: @@ -727,7 +1158,9 @@ paths: content: application/json: schema: - type: string + type: array + items: + type: string requestBody: content: application/json: @@ -850,7 +1283,7 @@ paths: content: application/json: schema: - type: string + type: object requestBody: content: application/json: @@ -876,7 +1309,7 @@ paths: content: application/json: schema: - type: string + type: object requestBody: content: application/json: @@ -1038,247 +1471,236 @@ paths: responses: '204': description: successful - "/sign_up_topics": + "/student_tasks/list": get: - summary: Get sign-up topics + summary: student tasks list + tags: + - StudentTasks parameters: - - name: assignment_id - in: query - description: Assignment ID - required: true - schema: - type: integer - - name: topic_ids - in: query - description: Topic Identifier - required: false + - name: Authorization + in: header schema: type: string - tags: - - SignUpTopic responses: '200': - description: successful - delete: - summary: Delete sign-up topics + description: authorized request has proper JSON schema + '401': + description: unauthorized request has error response + "/student_tasks/view": + get: + summary: Retrieve a specific student task by ID + tags: + - StudentTasks parameters: - - name: assignment_id + - name: id in: query - description: Assignment ID required: true schema: - type: integer - - name: topic_ids - in: query - items: - type: string - description: Topic Identifiers to delete - required: false + type: Integer + - name: Authorization + in: header schema: - type: array + type: string + responses: + '200': + description: successful retrieval of a student task + '500': + description: participant not found + '401': + description: unauthorized request has error response + "/api/v1/submitted_content": + get: + summary: list all submission records tags: - - SignUpTopic + - SubmittedContent responses: '200': description: successful post: - summary: create a new topic in the sheet + summary: create a submission record tags: - - SignUpTopic + - SubmittedContent parameters: [] responses: '201': - description: Success + description: created + '422': + description: unprocessable entity requestBody: content: application/json: schema: type: object properties: - topic_identifier: - type: integer - topic_name: - type: string - max_choosers: - type: integer - category: - type: string - assignment_id: - type: integer - micropayment: - type: integer - required: - - topic_identifier - - topic_name - - max_choosers - - category - - assignment_id - - micropayment - "/sign_up_topics/{id}": - parameters: - - name: id - in: path - description: id of the sign up topic - required: true - schema: - type: integer - put: - summary: update a new topic in the sheet + submitted_content: + type: object + properties: + record_type: + type: string + content: + type: string + operation: + type: string + team_id: + type: integer + user: + type: string + assignment_id: + type: integer + required: + - content + - team_id + - user + - assignment_id + "/api/v1/submitted_content/{id}": + get: + summary: show a submission record tags: - - SignUpTopic - parameters: [] + - SubmittedContent + parameters: + - name: id + in: path + description: id + required: true + schema: + type: string responses: '200': description: successful - requestBody: - content: - application/json: - schema: - type: object - properties: - topic_identifier: - type: integer - topic_name: - type: string - max_choosers: - type: integer - category: - type: string - assignment_id: - type: integer - micropayment: - type: integer - required: - - topic_identifier - - topic_name - - category - - assignment_id - "/signed_up_teams/sign_up": - post: - summary: Creates a signed up team + '404': + description: not found + "/api/v1/submitted_content/download": + get: + summary: download file + tags: + - SubmittedContent + parameters: + - name: current_folder[name] + in: query + required: true + schema: + type: string + - name: download + in: query + required: true + schema: + type: string + - name: id + in: query + required: true + schema: + type: string + responses: + '400': + description: cannot send whole folder + '404': + description: file does not exist + '200': + description: file downloaded + "/teams_participants/update_duty": + put: + summary: update participant duty tags: - - SignedUpTeams + - Teams Participants parameters: [] responses: - '201': - description: signed up team created - '422': - description: invalid request + '200': + description: duty updated successfully + '403': + description: 'forbidden: user not authorized' + '404': + description: teams participant not found requestBody: content: application/json: schema: type: object properties: - team_id: - type: integer - topic_id: + teams_participant_id: type: integer + teams_participant: + type: object + properties: + duty_id: + type: integer + required: + - duty_id required: - - team_id - - topic_id - "/signed_up_teams/sign_up_student": - parameters: - - name: user_id - in: query - description: User ID - required: true - schema: - type: integer + - teams_participant_id + - teams_participant + "/teams_participants/{id}/list_participants": + get: + summary: list participants + tags: + - Teams Participants + parameters: + - name: id + in: path + description: Team ID + required: true + schema: + type: integer + responses: + '200': + description: for course + '404': + description: team not found + "/teams_participants/{id}/add_participant": post: - summary: Creates a signed up team by student + summary: add participant tags: - - SignedUpTeams - parameters: [] + - Teams Participants + parameters: + - name: id + in: path + description: Team ID + required: true + schema: + type: integer responses: - '201': - description: signed up team created - '422': - description: invalid request + '200': + description: added to course + '404': + description: participant not found requestBody: content: application/json: schema: type: object properties: - topic_id: - type: integer + name: + type: string required: - - topic_id - "/signed_up_teams": - parameters: - - name: topic_id - in: query - description: Topic ID - required: true - schema: - type: integer - get: - summary: Retrieves signed up teams + - name + "/teams_participants/{id}/delete_participants": + delete: + summary: delete participants tags: - - SignedUpTeams + - Teams Participants + parameters: + - name: id + in: path + description: Team ID + required: true + schema: + type: integer responses: '200': - description: signed up teams found - content: - application/json: - schema: - type: array - properties: - id: - type: integer - topic_id: - type: integer - team_id: - type: integer - is_waitlisted: - type: boolean - preference_priority_number: - type: integer - required: - - id - - topic_id - - team_id - - is_waitlisted - - preference_priority_number + description: no participants selected '404': - description: signed up teams not found - "/signed_up_teams/{id}": - parameters: - - name: id - in: path - required: true - schema: - type: integer - put: - summary: Updates a signed up team - tags: - - SignedUpTeams - parameters: [] - responses: - '200': - description: signed up team updated - '422': - description: invalid request + description: team not found requestBody: content: application/json: schema: type: object properties: - is_waitlisted: - type: boolean - preference_priority_number: - type: integer - delete: - summary: Deletes a signed up team - tags: - - SignedUpTeams - responses: - '204': - description: signed up team deleted - '422': - description: invalid request + item: + type: array + items: + type: integer + required: + - item "/login": post: summary: Logs in a user @@ -1303,34 +1725,6 @@ paths: required: - user_name - password - /student_tasks/list: - get: - summary: List all Student Tasks - tags: - - Student Tasks - responses: - '200': - description: An array of student tasks - /student_tasks/view: - get: - summary: View a student task - tags: - - Student Tasks - parameters: - - in: query - name: id - schema: - type: string - required: true - description: The ID of the student task to retrieve - responses: - '200': - description: A specific student task - - - - - servers: - url: http://{defaultHost} variables: From 68db467702aebd93ccb380afaf1f8aa31a89883f Mon Sep 17 00:00:00 2001 From: Shawty 2084 Date: Tue, 21 Oct 2025 18:20:09 -0400 Subject: [PATCH 02/18] Updated submitted_content_controller, assignment_participant, and tests for submitted content API --- .../api/v1/submitted_content_controller.rb | 89 +++++++++++++------ app/models/assignment_participant.rb | 5 ++ .../requests/api/v1/submitted_content_spec.rb | 85 +++++++++--------- 3 files changed, 111 insertions(+), 68 deletions(-) diff --git a/app/controllers/api/v1/submitted_content_controller.rb b/app/controllers/api/v1/submitted_content_controller.rb index 896548784..ba169b41d 100644 --- a/app/controllers/api/v1/submitted_content_controller.rb +++ b/app/controllers/api/v1/submitted_content_controller.rb @@ -7,16 +7,19 @@ class Api::V1::SubmittedContentController < ApplicationController before_action :ensure_participant_team, only: [:submit_hyperlink, :remove_hyperlink, :submit_file, :folder_action, :download] # GET /api/v1/submitted_content + # Retrieves all submission records def index render json: SubmissionRecord.all, status: :ok end # GET /api/v1/submitted_content/:id + # Retrieves a specific submission record by ID def show render json: @submission_record, status: :ok end # POST /api/v1/submitted_content + # Creates a new submission record with automatic type detection (hyperlink or file) def create attrs = submitted_content_params attrs[:record_type] ||= attrs[:content].to_s.start_with?('http') ? 'hyperlink' : 'file' @@ -25,42 +28,46 @@ def create if record.save render json: record, status: :created else - render json: record.errors, status: :unprocessable_entity + render json: record.errors, status: :unprocessable_content end end # POST /api/v1/submitted_content/submit_hyperlink # GET /api/v1/submitted_content/submit_hyperlink + # Validates and submits a hyperlink for the participant's team + # Checks for blank submissions and duplicate hyperlinks before creating submission record def submit_hyperlink - team = @participant.team + team = current_team submission = params[:submission].to_s.strip if submission.blank? - return render json: { error: 'Submission cannot be blank' }, status: :bad_request + return render_error('Submission cannot be blank', :bad_request) end if team.hyperlinks.include?(submission) - return render json: { message: 'You or your teammate(s) have already submitted the same hyperlink.' }, status: :unprocessable_entity + return render_success('You or your teammate(s) have already submitted the same hyperlink.', :unprocessable_content) end begin team.submit_hyperlink(submission) create_submission_record_for('hyperlink', submission, 'Submit Hyperlink') - render json: { message: 'The link has been successfully submitted.' }, status: :ok + render_success('The link has been successfully submitted.') rescue StandardError => e - render json: { error: "The URL or URI is invalid. Reason: #{e.message}" }, status: :unprocessable_entity + render_error("The URL or URI is invalid. Reason: #{e.message}") end end # POST /api/v1/submitted_content/remove_hyperlink # GET /api/v1/submitted_content/remove_hyperlink + # Removes a hyperlink at the specified index from the team's hyperlinks + # Creates a submission record for the removal action def remove_hyperlink - team = @participant.team + team = current_team index = params['chk_links'].to_i hyperlink_to_delete = team.hyperlinks[index] unless hyperlink_to_delete - return render json: { error: 'Hyperlink not found' }, status: :not_found + return render_error('Hyperlink not found', :not_found) end begin @@ -68,35 +75,37 @@ def remove_hyperlink create_submission_record_for('hyperlink', hyperlink_to_delete, 'Remove Hyperlink') head :no_content rescue StandardError => e - render json: { error: "There was an error deleting the hyperlink. Reason: #{e.message}" }, status: :unprocessable_entity + render_error("There was an error deleting the hyperlink. Reason: #{e.message}") end end # POST /api/v1/submitted_content/submit_file # GET /api/v1/submitted_content/submit_file + # Handles file upload for the participant's team + # Validates file presence, size, and extension before saving to team directory + # Optionally unzips files if requested def submit_file uploaded = params[:uploaded_file] - return render json: { error: 'No file provided' }, status: :bad_request unless uploaded + return render_error('No file provided', :bad_request) unless uploaded file_size_limit_mb = 5 unless check_content_size(uploaded, file_size_limit_mb) - return render json: { error: "File size must be smaller than #{file_size_limit_mb}MB" }, status: :bad_request + return render_error("File size must be smaller than #{file_size_limit_mb}MB", :bad_request) end - unless check_extension_integrity(uploaded.original_filename) - render json: { error: 'File extension does not match' }, status: :unprocessable_entity - return + unless check_extension_integrity(uploaded_file_name(uploaded)) + return render_error('File extension does not match') end file_bytes = uploaded.read current_folder = sanitize_folder(params.dig(:current_folder, :name) || '/') - team = @participant.team + team = current_team team.set_student_directory_num current_directory = File.join(team.path.to_s, current_folder) FileUtils.mkdir_p(current_directory) unless File.exist?(current_directory) - safe_filename = sanitize_filename(uploaded.original_filename.tr('\\', '/')).gsub(' ', '_') + safe_filename = sanitize_filename(uploaded_file_name(uploaded).tr('\\', '/')).gsub(' ', '_') full_path = File.join(current_directory, File.basename(safe_filename)) # Save file @@ -108,13 +117,15 @@ def submit_file end create_submission_record_for('file', full_path, 'Submit File') - render json: { message: 'The file has been submitted.' }, status: :ok + render_success('The file has been submitted.') rescue StandardError => e - render json: { error: "File submission failed: #{e.message}" }, status: :unprocessable_entity + render_error("File submission failed: #{e.message}") end # POST /api/v1/submitted_content/folder_action # GET /api/v1/submitted_content/folder_action + # Dispatches folder management actions (delete, rename, move, copy, create) + # based on the faction parameter def folder_action faction = params[:faction] || {} if faction[:delete].present? @@ -128,26 +139,28 @@ def folder_action elsif faction[:create].present? create_new_folder else - render json: { error: 'No folder action specified' }, status: :bad_request + render_error('No folder action specified', :bad_request) end end # GET /api/v1/submitted_content/download + # Validates and streams a file for download + # Ensures the requested path is a file (not directory) and exists before streaming def download folder_name = sanitize_folder(params.dig(:current_folder, :name) || '/') file_name = params[:download] if folder_name.blank? - return render json: { message: 'Folder_name is nil.' }, status: :bad_request + return render_success('Folder_name is nil.', :bad_request) elsif file_name.blank? - return render json: { message: 'File name is nil.' }, status: :bad_request + return render_success('File name is nil.', :bad_request) end path = File.join(folder_name, file_name) if File.directory?(path) - return render json: { message: 'Cannot send a whole folder.' }, status: :bad_request + return render_success('Cannot send a whole folder.', :bad_request) elsif !File.exist?(path) - return render json: { message: 'File does not exist.' }, status: :not_found + return render_success('File does not exist.', :not_found) end # send_file will stream and return; do NOT render after send_file @@ -176,14 +189,38 @@ def submitted_content_params params.require(:submitted_content).permit(:id, :content, :operation, :team_id, :user, :assignment_id, :record_type) end + # Memoized team retrieval to avoid multiple database calls + def current_team + @current_team ||= @participant.team + end + + # Helper method to render error responses + def render_error(message, status = :unprocessable_content) + render json: { error: message }, status: status + end + + # Helper method to render success responses + def render_success(message, status = :ok) + render json: { message: message }, status: status + end + + # Helper method to safely get filename from uploaded file or string + def uploaded_file_name(uploaded) + if uploaded.respond_to?(:original_filename) + uploaded.original_filename + else + uploaded.to_s + end + end + # single place to create records for both files and hyperlinks def create_submission_record_for(record_type, content, operation) SubmissionRecord.create!( record_type: record_type, content: content, - user: @participant.user.try(:name), - team_id: @participant.team.try(:id), - assignment_id: @participant.assignment.try(:id), + user: @participant.user_name, + team_id: @participant.team_id, + assignment_id: @participant.assignment_id, operation: operation ) end diff --git a/app/models/assignment_participant.rb b/app/models/assignment_participant.rb index e95e9a047..0a8350bc9 100644 --- a/app/models/assignment_participant.rb +++ b/app/models/assignment_participant.rb @@ -6,6 +6,11 @@ class AssignmentParticipant < Participant belongs_to :user validates :handle, presence: true + # Delegation methods to avoid Law of Demeter violations + delegate :name, to: :user, prefix: true, allow_nil: true + delegate :id, to: :team, prefix: true, allow_nil: true + delegate :id, to: :assignment, prefix: true, allow_nil: true + # Fetches the team for specific participant def team AssignmentTeam.team(self) diff --git a/spec/requests/api/v1/submitted_content_spec.rb b/spec/requests/api/v1/submitted_content_spec.rb index b9545b516..77b73f7b8 100644 --- a/spec/requests/api/v1/submitted_content_spec.rb +++ b/spec/requests/api/v1/submitted_content_spec.rb @@ -165,7 +165,7 @@ def create_submission_record(attrs = {}) end run_test! do - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) end end end @@ -265,7 +265,7 @@ def create_submission_record(attrs = {}) params: { id: id, submission: submission }, headers: auth_headers_student) - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) parsed = json expect(parsed['message']).to include('already submitted the same hyperlink') end @@ -285,7 +285,7 @@ def create_submission_record(attrs = {}) params: { id: id, submission: submission }, headers: auth_headers_student) - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) parsed = json expect(parsed['error']).to include('The URL or URI is invalid') end @@ -363,7 +363,7 @@ def create_submission_record(attrs = {}) params: { id: id, chk_links: chk_links }, headers: auth_headers_student) - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) parsed = json expect(parsed['error']).to include('There was an error deleting the hyperlink') end @@ -440,17 +440,11 @@ def create_submission_record(attrs = {}) context 'with invalid extension' do let(:id) { participant.id } let(:uploaded_file) do - temp_file = nil - Tempfile.create(['test', '.exe']) do |file| - file.write('test content') - file.rewind - temp_file = ActionDispatch::Http::UploadedFile.new( - tempfile: file, - filename: 'test.exe', - type: 'application/x-msdownload' - ) - end - temp_file + Rack::Test::UploadedFile.new( + StringIO.new('test content'), + 'application/x-msdownload', + original_filename: 'test.exe' + ) end before do @@ -465,7 +459,7 @@ def create_submission_record(attrs = {}) params: { id: id, uploaded_file: uploaded_file }, headers: auth_headers_student) - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) parsed = json expect(parsed['error']).to include('File extension does not match') end @@ -474,17 +468,14 @@ def create_submission_record(attrs = {}) context 'with valid file' do let(:id) { participant.id } let(:uploaded_file) do - temp_file = nil - Tempfile.create(['test', '.txt']) do |file| - file.write('test content') - file.rewind - temp_file = ActionDispatch::Http::UploadedFile.new( - tempfile: file, - filename: 'test.txt', - type: 'text/plain' - ) - end - temp_file + file = Tempfile.new(['test', '.txt']) + file.write('test content') + file.rewind + ActionDispatch::Http::UploadedFile.new( + tempfile: file, + filename: 'test.txt', + type: 'text/plain' + ) end before do @@ -515,19 +506,16 @@ def create_submission_record(attrs = {}) context 'with zip file and unzip flag' do let(:id) { participant.id } let(:uploaded_file) do - temp_file = nil - Tempfile.create(['test', '.zip']) do |file| - file.binmode # Set binary mode for zip files - file.write('PK') # Minimal zip file signature - file.write("\x03\x04" + "\x00" * 18) # Basic zip header - file.rewind - temp_file = ActionDispatch::Http::UploadedFile.new( - tempfile: file, - filename: 'test.zip', - type: 'application/zip' - ) - end - temp_file + file = Tempfile.new(['test', '.zip']) + file.binmode + file.write('PK') # Minimal zip file signature + file.write("\x03\x04" + "\x00" * 18) # Basic zip header + file.rewind + ActionDispatch::Http::UploadedFile.new( + tempfile: file, + filename: 'test.zip', + type: 'application/zip' + ) end before do @@ -695,6 +683,12 @@ def create_submission_record(attrs = {}) end path '/api/v1/submitted_content/download' do + before(:all) do + require Rails.root.join('app/models/participant') + require Rails.root.join('app/models/assignment_participant') + require Rails.root.join('app/models/assignment_team') + end + get('download file') do tags 'SubmittedContent' produces 'application/octet-stream' @@ -703,8 +697,9 @@ def create_submission_record(attrs = {}) parameter name: :id, in: :query, type: :string, required: true before do - allow(AssignmentParticipant).to receive(:find).and_return(participant) - allow(participant).to receive(:team).and_return(team) + # Ensure participant and team are created before the test runs + participant + team end response(400, 'folder name is nil') do @@ -786,6 +781,12 @@ def create_submission_record(attrs = {}) end describe 'Error handling' do + before(:all) do + require Rails.root.join('app/models/participant') + require Rails.root.join('app/models/assignment_participant') + require Rails.root.join('app/models/assignment_team') + end + context 'when participant not found' do it 'returns 500 (RecordNotFound bubbles)' do allow(AssignmentParticipant).to receive(:find).and_raise(ActiveRecord::RecordNotFound) From 36e09f644c480270a4eb15816d2822e77d2dc6e4 Mon Sep 17 00:00:00 2001 From: Shawty 2084 Date: Fri, 24 Oct 2025 01:21:12 -0400 Subject: [PATCH 03/18] standardize HTTP status codes across SubmittedContentController --- .../api/v1/submitted_content_controller.rb | 26 ++++++++-------- app/helpers/submitted_content_helper.rb | 18 ++++++----- .../requests/api/v1/submitted_content_spec.rb | 30 +++++++++---------- 3 files changed, 40 insertions(+), 34 deletions(-) diff --git a/app/controllers/api/v1/submitted_content_controller.rb b/app/controllers/api/v1/submitted_content_controller.rb index ba169b41d..03759e1d8 100644 --- a/app/controllers/api/v1/submitted_content_controller.rb +++ b/app/controllers/api/v1/submitted_content_controller.rb @@ -45,7 +45,7 @@ def submit_hyperlink end if team.hyperlinks.include?(submission) - return render_success('You or your teammate(s) have already submitted the same hyperlink.', :unprocessable_content) + return render_error('You or your teammate(s) have already submitted the same hyperlink.', :conflict) end begin @@ -53,7 +53,7 @@ def submit_hyperlink create_submission_record_for('hyperlink', submission, 'Submit Hyperlink') render_success('The link has been successfully submitted.') rescue StandardError => e - render_error("The URL or URI is invalid. Reason: #{e.message}") + render_error("The URL or URI is invalid. Reason: #{e.message}", :bad_request) end end @@ -75,7 +75,7 @@ def remove_hyperlink create_submission_record_for('hyperlink', hyperlink_to_delete, 'Remove Hyperlink') head :no_content rescue StandardError => e - render_error("There was an error deleting the hyperlink. Reason: #{e.message}") + render_error("There was an error deleting the hyperlink. Reason: #{e.message}", :internal_server_error) end end @@ -94,7 +94,7 @@ def submit_file end unless check_extension_integrity(uploaded_file_name(uploaded)) - return render_error('File extension does not match') + return render_error('File extension does not match', :bad_request) end file_bytes = uploaded.read @@ -117,9 +117,9 @@ def submit_file end create_submission_record_for('file', full_path, 'Submit File') - render_success('The file has been submitted.') + render_success('The file has been submitted successfully.', :created) rescue StandardError => e - render_error("File submission failed: #{e.message}") + render_error("File submission failed: #{e.message}", :internal_server_error) end # POST /api/v1/submitted_content/folder_action @@ -147,20 +147,22 @@ def folder_action # Validates and streams a file for download # Ensures the requested path is a file (not directory) and exists before streaming def download - folder_name = sanitize_folder(params.dig(:current_folder, :name) || '/') + folder_name_param = params.dig(:current_folder, :name) file_name = params[:download] - if folder_name.blank? - return render_success('Folder_name is nil.', :bad_request) + if folder_name_param.blank? + return render_error('Folder name is required.', :bad_request) elsif file_name.blank? - return render_success('File name is nil.', :bad_request) + return render_error('File name is required.', :bad_request) end + folder_name = sanitize_folder(folder_name_param) path = File.join(folder_name, file_name) + if File.directory?(path) - return render_success('Cannot send a whole folder.', :bad_request) + return render_error('Cannot download a directory. Please specify a file.', :bad_request) elsif !File.exist?(path) - return render_success('File does not exist.', :not_found) + return render_error('File does not exist.', :not_found) end # send_file will stream and return; do NOT render after send_file diff --git a/app/helpers/submitted_content_helper.rb b/app/helpers/submitted_content_helper.rb index 8a6a55ca1..faa5d3e14 100644 --- a/app/helpers/submitted_content_helper.rb +++ b/app/helpers/submitted_content_helper.rb @@ -69,10 +69,12 @@ def rename_selected_file handle_file_operation_error('renaming') do if File.exist?(new_filename) - render json: { message: "A file already exists with that name" }, status: :conflict + render json: { error: "A file already exists with that name." }, status: :conflict return end File.rename(old_filename, new_filename) + render json: { message: "File renamed successfully." }, status: :ok + return end end @@ -81,17 +83,19 @@ def copy_selected_file new_filename = File.join(params[:directories][params[:chk_files]], FileHelper.sanitize_filename(params[:faction][:copy])) - handle_file_operation_error('copying') do + handle_file_operation_error('copying') do if File.exist?(new_filename) - render json: { message: 'A file with this name already exists.' }, status: :bad_request + render json: { error: 'A file with this name already exists.' }, status: :conflict return end unless File.exist?(old_filename) - render json: { message: 'The referenced file does not exist.' }, status: :not_found + render json: { error: 'The referenced file does not exist.' }, status: :not_found return end FileUtils.cp_r(old_filename, new_filename) + render json: { message: 'File copied successfully.' }, status: :ok + return end end @@ -106,12 +110,12 @@ def delete_selected_files FileUtils.rm_rf(file_path) # removes file or directory recursively deleted_files << file_path else - render json: { message: "File #{file_path} does not exist." }, status: :not_found + render json: { error: "File #{file_path} does not exist." }, status: :not_found return end end - render json: { message: "Deleted successfully.", files: deleted_files }, status: :ok + render json: { message: "Files deleted successfully.", files: deleted_files }, status: :no_content end end @@ -119,7 +123,7 @@ def create_new_folder location = File.join(@participant.dir_path, params[:faction][:create]) handle_file_operation_error('creating directory') do FileHelper.create_directory_from_path(location) - render json: { message: "The directory #{params[:faction][:create]} was created."}, status: :ok + render json: { message: "Directory '#{params[:faction][:create]}' created successfully." }, status: :created end end end diff --git a/spec/requests/api/v1/submitted_content_spec.rb b/spec/requests/api/v1/submitted_content_spec.rb index 77b73f7b8..f32f41cdd 100644 --- a/spec/requests/api/v1/submitted_content_spec.rb +++ b/spec/requests/api/v1/submitted_content_spec.rb @@ -260,14 +260,14 @@ def create_submission_record(attrs = {}) allow(team).to receive(:hyperlinks).and_return([submission]) end - it 'returns unprocessable entity' do + it 'returns conflict' do send(method, '/api/v1/submitted_content/submit_hyperlink', params: { id: id, submission: submission }, headers: auth_headers_student) - expect(response).to have_http_status(:unprocessable_content) + expect(response).to have_http_status(:conflict) parsed = json - expect(parsed['message']).to include('already submitted the same hyperlink') + expect(parsed['error']).to include('already submitted the same hyperlink') end end @@ -280,12 +280,12 @@ def create_submission_record(attrs = {}) allow(team).to receive(:submit_hyperlink).and_raise(StandardError, 'Invalid URL format') end - it 'returns unprocessable entity with error' do + it 'returns bad request with error' do send(method, '/api/v1/submitted_content/submit_hyperlink', params: { id: id, submission: submission }, headers: auth_headers_student) - expect(response).to have_http_status(:unprocessable_content) + expect(response).to have_http_status(:bad_request) parsed = json expect(parsed['error']).to include('The URL or URI is invalid') end @@ -358,12 +358,12 @@ def create_submission_record(attrs = {}) allow(team).to receive(:remove_hyperlink).and_raise(StandardError, 'Database error') end - it 'returns unprocessable entity' do + it 'returns internal server error' do send(method, '/api/v1/submitted_content/remove_hyperlink', params: { id: id, chk_links: chk_links }, headers: auth_headers_student) - expect(response).to have_http_status(:unprocessable_content) + expect(response).to have_http_status(:internal_server_error) parsed = json expect(parsed['error']).to include('There was an error deleting the hyperlink') end @@ -459,7 +459,7 @@ def create_submission_record(attrs = {}) params: { id: id, uploaded_file: uploaded_file }, headers: auth_headers_student) - expect(response).to have_http_status(:unprocessable_content) + expect(response).to have_http_status(:bad_request) parsed = json expect(parsed['error']).to include('File extension does not match') end @@ -497,9 +497,9 @@ def create_submission_record(attrs = {}) params: { id: id, uploaded_file: uploaded_file }, headers: auth_headers_student) - expect(response).to have_http_status(:ok) + expect(response).to have_http_status(:created) parsed = json - expect(parsed['message']).to eq('The file has been submitted.') + expect(parsed['message']).to eq('The file has been submitted successfully.') end end @@ -542,7 +542,7 @@ def create_submission_record(attrs = {}) params: { id: id, uploaded_file: uploaded_file, unzip: true }, headers: auth_headers_student) - expect(response).to have_http_status(:ok) + expect(response).to have_http_status(:created) end end end @@ -709,7 +709,7 @@ def create_submission_record(attrs = {}) run_test! do parsed = json - expect(parsed['message']).to eq('Folder_name is nil.') + expect(parsed['error']).to eq('Folder name is required.') end end @@ -720,7 +720,7 @@ def create_submission_record(attrs = {}) run_test! do parsed = json - expect(parsed['message']).to eq('File name is nil.') + expect(parsed['error']).to eq('File name is required.') end end @@ -735,7 +735,7 @@ def create_submission_record(attrs = {}) run_test! do parsed = json - expect(parsed['message']).to eq('Cannot send a whole folder.') + expect(parsed['error']).to eq('Cannot download a directory. Please specify a file.') end end @@ -751,7 +751,7 @@ def create_submission_record(attrs = {}) run_test! do parsed = json - expect(parsed['message']).to eq('File does not exist.') + expect(parsed['error']).to eq('File does not exist.') end end From 3406b63169d6186b77ed36d6c5194a44c8dc45a0 Mon Sep 17 00:00:00 2001 From: Shawty 2084 Date: Fri, 24 Oct 2025 01:40:11 -0400 Subject: [PATCH 04/18] improve error messages across SubmittedContentController --- .../api/v1/submitted_content_controller.rb | 24 +++++----- app/helpers/submitted_content_helper.rb | 47 +++++++++++++------ 2 files changed, 44 insertions(+), 27 deletions(-) diff --git a/app/controllers/api/v1/submitted_content_controller.rb b/app/controllers/api/v1/submitted_content_controller.rb index 03759e1d8..d41599e1f 100644 --- a/app/controllers/api/v1/submitted_content_controller.rb +++ b/app/controllers/api/v1/submitted_content_controller.rb @@ -41,7 +41,7 @@ def submit_hyperlink submission = params[:submission].to_s.strip if submission.blank? - return render_error('Submission cannot be blank', :bad_request) + return render_error('Hyperlink submission cannot be blank. Please provide a valid URL.', :bad_request) end if team.hyperlinks.include?(submission) @@ -67,7 +67,7 @@ def remove_hyperlink hyperlink_to_delete = team.hyperlinks[index] unless hyperlink_to_delete - return render_error('Hyperlink not found', :not_found) + return render_error('Hyperlink not found at the specified index. It may have already been removed.', :not_found) end begin @@ -75,7 +75,7 @@ def remove_hyperlink create_submission_record_for('hyperlink', hyperlink_to_delete, 'Remove Hyperlink') head :no_content rescue StandardError => e - render_error("There was an error deleting the hyperlink. Reason: #{e.message}", :internal_server_error) + render_error("Failed to remove hyperlink from team submissions due to a server error: #{e.message}. Please try again or contact support if the issue persists.", :internal_server_error) end end @@ -86,7 +86,7 @@ def remove_hyperlink # Optionally unzips files if requested def submit_file uploaded = params[:uploaded_file] - return render_error('No file provided', :bad_request) unless uploaded + return render_error('No file provided. Please select a file to upload using the "uploaded_file" parameter.', :bad_request) unless uploaded file_size_limit_mb = 5 unless check_content_size(uploaded, file_size_limit_mb) @@ -94,7 +94,7 @@ def submit_file end unless check_extension_integrity(uploaded_file_name(uploaded)) - return render_error('File extension does not match', :bad_request) + return render_error('File extension not allowed. Supported formats: pdf, png, jpeg, jpg, zip, tar, gz, 7z, odt, docx, md, rb, mp4, txt.', :bad_request) end file_bytes = uploaded.read @@ -119,7 +119,7 @@ def submit_file create_submission_record_for('file', full_path, 'Submit File') render_success('The file has been submitted successfully.', :created) rescue StandardError => e - render_error("File submission failed: #{e.message}", :internal_server_error) + render_error("Failed to save file to server: #{e.message}. Please verify the file is not corrupted and try again.", :internal_server_error) end # POST /api/v1/submitted_content/folder_action @@ -139,7 +139,7 @@ def folder_action elsif faction[:create].present? create_new_folder else - render_error('No folder action specified', :bad_request) + render_error('No folder action specified. Valid actions: delete, rename, move, copy, create. Provide one in the "faction" parameter.', :bad_request) end end @@ -151,18 +151,18 @@ def download file_name = params[:download] if folder_name_param.blank? - return render_error('Folder name is required.', :bad_request) + return render_error('Folder name is required. Please provide a folder path in the "current_folder[name]" parameter.', :bad_request) elsif file_name.blank? - return render_error('File name is required.', :bad_request) + return render_error('File name is required. Please specify the file to download in the "download" parameter.', :bad_request) end folder_name = sanitize_folder(folder_name_param) path = File.join(folder_name, file_name) if File.directory?(path) - return render_error('Cannot download a directory. Please specify a file.', :bad_request) + return render_error('Cannot download a directory. Please specify a file path, not a folder path.', :bad_request) elsif !File.exist?(path) - return render_error('File does not exist.', :not_found) + return render_error("File '#{file_name}' does not exist in the specified folder. Please verify the file name and path.", :not_found) end # send_file will stream and return; do NOT render after send_file @@ -183,7 +183,7 @@ def set_participant def ensure_participant_team unless @participant && @participant.team - render json: { error: 'Participant or team not found' }, status: :not_found and return + render json: { error: 'Participant is not associated with a team. Please ensure the participant has joined a team before performing this action.' }, status: :not_found and return end end diff --git a/app/helpers/submitted_content_helper.rb b/app/helpers/submitted_content_helper.rb index faa5d3e14..b6c6ac99b 100644 --- a/app/helpers/submitted_content_helper.rb +++ b/app/helpers/submitted_content_helper.rb @@ -4,17 +4,23 @@ module SubmittedContentHelper # Unzipping the requested file in a new directory def self.unzip_file(file_name, unzip_dir, should_delete) unless File.exist?(file_name) - return { error: "File #{file_name} does not exist" } + return { error: "Cannot unzip file: '#{file_name}' does not exist. The file may have been moved or deleted." } end - Zip::File.open(file_name) do |zf| - zf.each do |e| - extract_entry(e, unzip_dir) + begin + Zip::File.open(file_name) do |zf| + zf.each do |e| + extract_entry(e, unzip_dir) + end end - end - File.delete(file_name) if should_delete - { message: "Unzipped successfully" } + File.delete(file_name) if should_delete + { message: "File unzipped successfully to #{unzip_dir}" } + rescue Zip::Error => e + { error: "Failed to unzip file: #{e.message}. The file may be corrupted or not a valid ZIP archive." } + rescue StandardError => e + { error: "Error during unzip operation: #{e.message}. Please try uploading the file again." } + end end private @@ -33,8 +39,14 @@ def get_filename def handle_file_operation_error(operation) yield + rescue Errno::EACCES => e + render json: { error: "Permission denied while #{operation} the file. You may not have the necessary permissions to perform this action."}, status: :forbidden + rescue Errno::ENOENT => e + render json: { error: "File or directory not found while #{operation}. The file may have been moved or deleted."}, status: :not_found + rescue Errno::ENOSPC => e + render json: { error: "Insufficient disk space while #{operation} the file. Please contact your system administrator."}, status: :insufficient_storage rescue StandardError => e - render json: { error: "There was a problem #{operation} the file: #{e.message}"}, status: :unprocessable_entity + render json: { error: "Failed while #{operation} the file: #{e.message}. Please verify the file path and try again."}, status: :unprocessable_entity end def check_extension_integrity(original_filename) @@ -69,11 +81,15 @@ def rename_selected_file handle_file_operation_error('renaming') do if File.exist?(new_filename) - render json: { error: "A file already exists with that name." }, status: :conflict + render json: { error: "A file named '#{params[:faction][:rename]}' already exists in this directory. Please choose a different name." }, status: :conflict + return + end + unless File.exist?(old_filename) + render json: { error: "Source file not found. It may have been moved or deleted." }, status: :not_found return end File.rename(old_filename, new_filename) - render json: { message: "File renamed successfully." }, status: :ok + render json: { message: "File renamed successfully to '#{params[:faction][:rename]}'." }, status: :ok return end end @@ -85,16 +101,16 @@ def copy_selected_file handle_file_operation_error('copying') do if File.exist?(new_filename) - render json: { error: 'A file with this name already exists.' }, status: :conflict + render json: { error: "A file named '#{params[:faction][:copy]}' already exists in this directory. Please choose a different name or delete the existing file first." }, status: :conflict return end unless File.exist?(old_filename) - render json: { error: 'The referenced file does not exist.' }, status: :not_found + render json: { error: 'The source file does not exist. It may have been moved or deleted. Please refresh and try again.' }, status: :not_found return end FileUtils.cp_r(old_filename, new_filename) - render json: { message: 'File copied successfully.' }, status: :ok + render json: { message: "File copied successfully to '#{params[:faction][:copy]}'." }, status: :ok return end end @@ -110,12 +126,13 @@ def delete_selected_files FileUtils.rm_rf(file_path) # removes file or directory recursively deleted_files << file_path else - render json: { error: "File #{file_path} does not exist." }, status: :not_found + render json: { error: "Cannot delete '#{params[:filenames][idx]}': File does not exist. It may have already been deleted." }, status: :not_found return end end - render json: { message: "Files deleted successfully.", files: deleted_files }, status: :no_content + file_count = deleted_files.size + render json: { message: "Successfully deleted #{file_count} file(s).", files: deleted_files }, status: :no_content end end From 79cbec928d7d1f4bf19a26b8e7120691f8e3cfb3 Mon Sep 17 00:00:00 2001 From: Shawty 2084 Date: Sun, 26 Oct 2025 13:28:05 -0400 Subject: [PATCH 05/18] refactor: reduce instance vars + add inline docs --- .../api/v1/submitted_content_controller.rb | 138 ++++++++++++++---- 1 file changed, 109 insertions(+), 29 deletions(-) diff --git a/app/controllers/api/v1/submitted_content_controller.rb b/app/controllers/api/v1/submitted_content_controller.rb index d41599e1f..ffaea2a96 100644 --- a/app/controllers/api/v1/submitted_content_controller.rb +++ b/app/controllers/api/v1/submitted_content_controller.rb @@ -7,24 +7,32 @@ class Api::V1::SubmittedContentController < ApplicationController before_action :ensure_participant_team, only: [:submit_hyperlink, :remove_hyperlink, :submit_file, :folder_action, :download] # GET /api/v1/submitted_content - # Retrieves all submission records + # Retrieves all submission records from the database def index + # Return all submission records as JSON with 200 OK status render json: SubmissionRecord.all, status: :ok end # GET /api/v1/submitted_content/:id - # Retrieves a specific submission record by ID + # Retrieves a specific submission record by ID (set by before_action) def show + # @submission_record is set by set_submission_record before_action render json: @submission_record, status: :ok end # POST /api/v1/submitted_content # Creates a new submission record with automatic type detection (hyperlink or file) def create + # Get permitted parameters from request attrs = submitted_content_params + + # Auto-detect record type: if content starts with 'http', it's a hyperlink, otherwise file attrs[:record_type] ||= attrs[:content].to_s.start_with?('http') ? 'hyperlink' : 'file' + + # Create new record with the attributes record = SubmissionRecord.new(attrs) + # Attempt to save and return appropriate response if record.save render json: record, status: :created else @@ -35,24 +43,35 @@ def create # POST /api/v1/submitted_content/submit_hyperlink # GET /api/v1/submitted_content/submit_hyperlink # Validates and submits a hyperlink for the participant's team - # Checks for blank submissions and duplicate hyperlinks before creating submission record def submit_hyperlink - team = current_team + # Get the participant's team (requires @participant from before_action) + team = participant_team + + # Clean up the submitted hyperlink by stripping whitespace submission = params[:submission].to_s.strip + # Validate that the hyperlink is not blank if submission.blank? return render_error('Hyperlink submission cannot be blank. Please provide a valid URL.', :bad_request) end + # Check for duplicate hyperlinks in the team's existing submissions if team.hyperlinks.include?(submission) return render_error('You or your teammate(s) have already submitted the same hyperlink.', :conflict) end + # Attempt to submit the hyperlink and record the submission begin + # Add hyperlink to team's submission list (validates URL format) team.submit_hyperlink(submission) + + # Create a submission record for audit trail create_submission_record_for('hyperlink', submission, 'Submit Hyperlink') + + # Return success response render_success('The link has been successfully submitted.') rescue StandardError => e + # Handle any errors during hyperlink submission (invalid URL, network issues, etc.) render_error("The URL or URI is invalid. Reason: #{e.message}", :bad_request) end end @@ -60,74 +79,108 @@ def submit_hyperlink # POST /api/v1/submitted_content/remove_hyperlink # GET /api/v1/submitted_content/remove_hyperlink # Removes a hyperlink at the specified index from the team's hyperlinks - # Creates a submission record for the removal action def remove_hyperlink - team = current_team + # Get the participant's team + team = participant_team + + # Get the index of the hyperlink to delete from params index = params['chk_links'].to_i + + # Retrieve the hyperlink at the specified index hyperlink_to_delete = team.hyperlinks[index] + # Validate that a hyperlink exists at this index unless hyperlink_to_delete return render_error('Hyperlink not found at the specified index. It may have already been removed.', :not_found) end + # Attempt to remove the hyperlink begin + # Remove the hyperlink from team's submission list team.remove_hyperlink(hyperlink_to_delete) + + # Create a submission record for the removal action create_submission_record_for('hyperlink', hyperlink_to_delete, 'Remove Hyperlink') + + # Return 204 No Content for successful deletion head :no_content rescue StandardError => e + # Handle any errors during removal (database errors, etc.) render_error("Failed to remove hyperlink from team submissions due to a server error: #{e.message}. Please try again or contact support if the issue persists.", :internal_server_error) end end # POST /api/v1/submitted_content/submit_file # GET /api/v1/submitted_content/submit_file - # Handles file upload for the participant's team - # Validates file presence, size, and extension before saving to team directory - # Optionally unzips files if requested + # Handles file upload for the participant's team with validation and optional unzipping def submit_file + # Get the uploaded file from request parameters uploaded = params[:uploaded_file] + + # Validate that a file was provided return render_error('No file provided. Please select a file to upload using the "uploaded_file" parameter.', :bad_request) unless uploaded + # Define file size limit (5MB) file_size_limit_mb = 5 + + # Validate file size against the limit unless check_content_size(uploaded, file_size_limit_mb) return render_error("File size must be smaller than #{file_size_limit_mb}MB", :bad_request) end + # Validate file extension against allowed types unless check_extension_integrity(uploaded_file_name(uploaded)) return render_error('File extension not allowed. Supported formats: pdf, png, jpeg, jpg, zip, tar, gz, 7z, odt, docx, md, rb, mp4, txt.', :bad_request) end + # Read the file contents into memory file_bytes = uploaded.read + + # Get the current folder from params, default to root '/' current_folder = sanitize_folder(params.dig(:current_folder, :name) || '/') - team = current_team + + # Get team and ensure it has a directory number assigned + team = participant_team team.set_student_directory_num + # Build the full directory path where file will be saved current_directory = File.join(team.path.to_s, current_folder) + + # Create the directory if it doesn't exist FileUtils.mkdir_p(current_directory) unless File.exist?(current_directory) + # Sanitize the filename: remove backslashes, replace spaces with underscores safe_filename = sanitize_filename(uploaded_file_name(uploaded).tr('\\', '/')).gsub(' ', '_') + + # Build the full file path (use basename to prevent directory traversal) full_path = File.join(current_directory, File.basename(safe_filename)) - # Save file + # Write the file to disk in binary mode File.open(full_path, 'wb') { |f| f.write(file_bytes) } - # Unzip if requested and allowed + # If unzip flag is set and file is a zip, extract contents if params[:unzip] && file_type(safe_filename) == 'zip' SubmittedContentHelper.unzip_file(full_path, current_directory, true) end + # Create submission record for audit trail create_submission_record_for('file', full_path, 'Submit File') + + # Return success response with 201 Created status render_success('The file has been submitted successfully.', :created) rescue StandardError => e + # Handle any errors during file upload (disk space, permissions, corruption, etc.) render_error("Failed to save file to server: #{e.message}. Please verify the file is not corrupted and try again.", :internal_server_error) end # POST /api/v1/submitted_content/folder_action # GET /api/v1/submitted_content/folder_action - # Dispatches folder management actions (delete, rename, move, copy, create) - # based on the faction parameter + # Dispatches folder management actions based on the faction parameter def folder_action + # Get the faction parameter (specifies which action to perform) faction = params[:faction] || {} + + # Route to appropriate action based on which faction key is present if faction[:delete].present? delete_selected_files elsif faction[:rename].present? @@ -139,91 +192,118 @@ def folder_action elsif faction[:create].present? create_new_folder else + # No valid action specified, return error render_error('No folder action specified. Valid actions: delete, rename, move, copy, create. Provide one in the "faction" parameter.', :bad_request) end end # GET /api/v1/submitted_content/download # Validates and streams a file for download - # Ensures the requested path is a file (not directory) and exists before streaming def download + # Extract folder name and file name from params folder_name_param = params.dig(:current_folder, :name) file_name = params[:download] + # Validate that folder name was provided if folder_name_param.blank? return render_error('Folder name is required. Please provide a folder path in the "current_folder[name]" parameter.', :bad_request) + # Validate that file name was provided elsif file_name.blank? return render_error('File name is required. Please specify the file to download in the "download" parameter.', :bad_request) end + # Sanitize the folder name to prevent directory traversal attacks folder_name = sanitize_folder(folder_name_param) + + # Build the full path to the requested file path = File.join(folder_name, file_name) + # Check if the path is a directory (cannot download directories) if File.directory?(path) return render_error('Cannot download a directory. Please specify a file path, not a folder path.', :bad_request) + # Check if the file exists elsif !File.exist?(path) return render_error("File '#{file_name}' does not exist in the specified folder. Please verify the file name and path.", :not_found) end - # send_file will stream and return; do NOT render after send_file + # Stream the file to the client (disposition: 'inline' displays in browser if possible) + # Note: send_file returns immediately, do NOT render after this line send_file(path, disposition: 'inline') end private + # Before action callback: Sets @submission_record for the show action def set_submission_record + # Find the submission record by ID from params @submission_record = SubmissionRecord.find(params[:id]) rescue ActiveRecord::RecordNotFound => e + # Return 404 if record not found render json: { error: e.message }, status: :not_found and return end + # Before action callback: Sets @participant for actions that require participant context def set_participant + # Find the participant by ID from params + # @participant is kept as instance variable because it's set by before_action @participant = AssignmentParticipant.find(params[:id]) end + # Before action callback: Ensures participant has an associated team def ensure_participant_team + # Check that participant exists and has a team unless @participant && @participant.team render json: { error: 'Participant is not associated with a team. Please ensure the participant has joined a team before performing this action.' }, status: :not_found and return end end + # Strong parameters for submission record creation def submitted_content_params + # Permit only specified attributes for security params.require(:submitted_content).permit(:id, :content, :operation, :team_id, :user, :assignment_id, :record_type) end - # Memoized team retrieval to avoid multiple database calls - def current_team - @current_team ||= @participant.team + # Returns the participant's team (local method, no instance variable caching) + def participant_team + # Simply return the team associated with @participant + # Note: @participant is set by before_action, so it's safe to use here + @participant.team end - # Helper method to render error responses + # Renders an error response with the given message and HTTP status def render_error(message, status = :unprocessable_content) + # Render JSON error response with specified status code render json: { error: message }, status: status end - # Helper method to render success responses + # Renders a success response with the given message and HTTP status def render_success(message, status = :ok) + # Render JSON success response with specified status code render json: { message: message }, status: status end - # Helper method to safely get filename from uploaded file or string + # Safely extracts filename from uploaded file object or string def uploaded_file_name(uploaded) + # Check if uploaded object has original_filename method (ActionDispatch::Http::UploadedFile) if uploaded.respond_to?(:original_filename) uploaded.original_filename else + # Fallback to string representation uploaded.to_s end end - # single place to create records for both files and hyperlinks + # Creates a submission record for audit trail (used by both file and hyperlink operations) def create_submission_record_for(record_type, content, operation) + # Create a new submission record with participant and team information + # Note: @participant is set by before_action, safe to access here SubmissionRecord.create!( - record_type: record_type, - content: content, - user: @participant.user_name, - team_id: @participant.team_id, - assignment_id: @participant.assignment_id, - operation: operation + record_type: record_type, # 'file' or 'hyperlink' + content: content, # File path or URL + user: @participant.user_name, # Username from participant + team_id: @participant.team_id, # Team ID from participant + assignment_id: @participant.assignment_id, # Assignment ID from participant + operation: operation # Operation description (e.g., 'Submit File') ) end end From d166ffed59fa6f865c5e6450de8a1ecdede560f7 Mon Sep 17 00:00:00 2001 From: Shawty 2084 Date: Sun, 26 Oct 2025 13:31:52 -0400 Subject: [PATCH 06/18] refactor: add inline docs to helper and update tests --- app/helpers/submitted_content_helper.rb | 109 +++++++++++++++++- .../requests/api/v1/submitted_content_spec.rb | 30 ++--- 2 files changed, 120 insertions(+), 19 deletions(-) diff --git a/app/helpers/submitted_content_helper.rb b/app/helpers/submitted_content_helper.rb index b6c6ac99b..6dc3676ab 100644 --- a/app/helpers/submitted_content_helper.rb +++ b/app/helpers/submitted_content_helper.rb @@ -1,145 +1,246 @@ module SubmittedContentHelper include FileHelper - # Unzipping the requested file in a new directory + # Unzips a file to the specified directory with error handling + # @param file_name [String] Path to the ZIP file to extract + # @param unzip_dir [String] Directory where contents will be extracted + # @param should_delete [Boolean] Whether to delete the ZIP file after extraction + # @return [Hash] Result hash with :message on success or :error on failure def self.unzip_file(file_name, unzip_dir, should_delete) + # Verify that the ZIP file exists before attempting to unzip unless File.exist?(file_name) return { error: "Cannot unzip file: '#{file_name}' does not exist. The file may have been moved or deleted." } end begin + # Open the ZIP file and extract all entries Zip::File.open(file_name) do |zf| + # Iterate through each entry in the ZIP file zf.each do |e| + # Extract the entry with sanitization extract_entry(e, unzip_dir) end end + # Delete the original ZIP file if requested File.delete(file_name) if should_delete + + # Return success message { message: "File unzipped successfully to #{unzip_dir}" } rescue Zip::Error => e + # Handle ZIP-specific errors (corrupted file, invalid format, etc.) { error: "Failed to unzip file: #{e.message}. The file may be corrupted or not a valid ZIP archive." } rescue StandardError => e + # Handle any other unexpected errors during unzip { error: "Error during unzip operation: #{e.message}. Please try uploading the file again." } end end private - # Extract all the subfolders in the zipped file + # Extracts a single ZIP entry to the target directory with path sanitization + # @param e [Zip::Entry] The ZIP entry to extract + # @param unzip_dir [String] Target directory for extraction def self.extract_entry(e, unzip_dir) + # Sanitize the entry name to prevent directory traversal attacks safe_name = FileHelper.sanitize_filename(e.name) + + # Build the full path where the entry will be extracted file_path = File.join(unzip_dir, safe_name) + + # Create parent directories if they don't exist FileUtils.mkdir_p(File.dirname(file_path)) - e.extract(file_path) { true } # overwrite if exists + + # Extract the entry, overwriting if file already exists (true = overwrite) + e.extract(file_path) { true } end + # Constructs the full file path from params for file operations + # @return [String] Full path to the file def get_filename + # Build path from directories array and filenames array using chk_files index "#{params[:directories][params[:chk_files]]}/#{params[:filenames][params[:chk_files]]}" end + # Wraps file operations with comprehensive error handling + # Catches specific errno errors and renders appropriate JSON responses + # @param operation [String] Description of the operation for error messages def handle_file_operation_error(operation) + # Execute the block containing the file operation yield rescue Errno::EACCES => e + # Handle permission denied errors (403 Forbidden) render json: { error: "Permission denied while #{operation} the file. You may not have the necessary permissions to perform this action."}, status: :forbidden rescue Errno::ENOENT => e + # Handle file/directory not found errors (404 Not Found) render json: { error: "File or directory not found while #{operation}. The file may have been moved or deleted."}, status: :not_found rescue Errno::ENOSPC => e + # Handle insufficient disk space errors (507 Insufficient Storage) render json: { error: "Insufficient disk space while #{operation} the file. Please contact your system administrator."}, status: :insufficient_storage rescue StandardError => e + # Handle all other unexpected errors (422 Unprocessable Entity) render json: { error: "Failed while #{operation} the file: #{e.message}. Please verify the file path and try again."}, status: :unprocessable_entity end + # Validates if a file has an allowed extension + # @param original_filename [String] The filename to check + # @return [Boolean] true if extension is allowed, false otherwise def check_extension_integrity(original_filename) + # Define list of allowed file extensions allowed_extensions = %w[pdf png jpeg jpg zip tar gz 7z odt docx md rb mp4 txt] + + # Extract the file extension (last part after final dot) and convert to lowercase file_extension = original_filename&.split('.')&.last&.downcase + + # Check if the extension is in the allowed list allowed_extensions.include?(file_extension) end + # Validates if a file size is within the specified limit + # @param file [File] The file object to check + # @param size_mb [Integer] Maximum allowed size in megabytes + # @return [Boolean] true if file size is acceptable, false otherwise def check_content_size(file, size_mb) + # Compare file size (in bytes) against limit (converted from MB to bytes) file.size <= size_mb * 1024 * 1024 end + # Extracts the file extension without the leading dot + # @param file_name [String] The filename to extract extension from + # @return [String] File extension without the dot (e.g., 'txt', 'pdf') def file_type(file_name) + # Get extension with File.extname, then remove the leading dot File.extname(file_name).delete('.') end + # Moves a selected file to a new location within the participant's directory def move_selected_file + # Get the source file path from params old_filename = get_filename + + # Build the destination path using participant's directory and move location from params new_location = File.join(@participant.dir_path, params[:faction][:move]) + # Wrap the move operation with error handling handle_file_operation_error('moving') do + # Perform the file move using FileHelper FileHelper.move_file(old_filename, new_location) + + # Render success response render json: { message: "The file was successfully moved." }, status: :ok return end end + # Renames a selected file with validation to prevent conflicts def rename_selected_file + # Get the source file path old_filename = get_filename + + # Build new filename with sanitization in the same directory new_filename = File.join(params[:directories][params[:chk_files]], FileHelper.sanitize_filename(params[:faction][:rename])) + # Wrap the rename operation with error handling handle_file_operation_error('renaming') do + # Check if a file with the new name already exists if File.exist?(new_filename) render json: { error: "A file named '#{params[:faction][:rename]}' already exists in this directory. Please choose a different name." }, status: :conflict return end + + # Check if the source file exists unless File.exist?(old_filename) render json: { error: "Source file not found. It may have been moved or deleted." }, status: :not_found return end + + # Perform the rename operation File.rename(old_filename, new_filename) + + # Render success response render json: { message: "File renamed successfully to '#{params[:faction][:rename]}'." }, status: :ok return end end + # Copies a selected file to a new name in the same directory def copy_selected_file + # Get the source file path old_filename = get_filename + + # Build destination filename with sanitization new_filename = File.join(params[:directories][params[:chk_files]], FileHelper.sanitize_filename(params[:faction][:copy])) + # Wrap the copy operation with error handling handle_file_operation_error('copying') do + # Check if destination file already exists if File.exist?(new_filename) render json: { error: "A file named '#{params[:faction][:copy]}' already exists in this directory. Please choose a different name or delete the existing file first." }, status: :conflict return end + + # Check if source file exists unless File.exist?(old_filename) render json: { error: 'The source file does not exist. It may have been moved or deleted. Please refresh and try again.' }, status: :not_found return end + # Copy file recursively (handles both files and directories) FileUtils.cp_r(old_filename, new_filename) + + # Render success response render json: { message: "File copied successfully to '#{params[:faction][:copy]}'." }, status: :ok return end end + # Deletes one or more selected files def delete_selected_files + # Wrap the delete operation with error handling handle_file_operation_error('deleting') do + # 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]) + # Check if file exists before attempting deletion if File.exist?(file_path) - FileUtils.rm_rf(file_path) # removes file or directory recursively + # Remove file or directory recursively + FileUtils.rm_rf(file_path) + + # Add to deleted files list 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 return end end + # Count total deleted files file_count = deleted_files.size + + # Render success response with deleted file list render json: { message: "Successfully deleted #{file_count} file(s).", files: deleted_files }, status: :no_content end end + # Creates a new folder in the participant's directory def create_new_folder + # Build the full path for the new folder location = File.join(@participant.dir_path, params[:faction][:create]) + + # Wrap the folder creation with error handling handle_file_operation_error('creating directory') do + # Create the directory (and any necessary parent directories) FileHelper.create_directory_from_path(location) + + # Render success response with folder name render json: { message: "Directory '#{params[:faction][:create]}' created successfully." }, status: :created end end diff --git a/spec/requests/api/v1/submitted_content_spec.rb b/spec/requests/api/v1/submitted_content_spec.rb index f32f41cdd..68f066585 100644 --- a/spec/requests/api/v1/submitted_content_spec.rb +++ b/spec/requests/api/v1/submitted_content_spec.rb @@ -248,7 +248,7 @@ def create_submission_record(attrs = {}) expect(response).to have_http_status(:bad_request) parsed = json - expect(parsed['error']).to eq('Submission cannot be blank') + expect(parsed['error']).to include('cannot be blank') end end @@ -344,7 +344,7 @@ def create_submission_record(attrs = {}) expect(response).to have_http_status(:not_found) parsed = json - expect(parsed['error']).to eq('Hyperlink not found') + expect(parsed['error']).to include('Hyperlink not found') end end @@ -365,7 +365,7 @@ def create_submission_record(attrs = {}) expect(response).to have_http_status(:internal_server_error) parsed = json - expect(parsed['error']).to include('There was an error deleting the hyperlink') + expect(parsed['error']).to include('Failed to remove hyperlink') end end end @@ -400,7 +400,7 @@ def create_submission_record(attrs = {}) expect(response).to have_http_status(:bad_request) parsed = json - expect(parsed['error']).to eq('No file provided') + expect(parsed['error']).to include('No file provided') end end @@ -461,7 +461,7 @@ def create_submission_record(attrs = {}) expect(response).to have_http_status(:bad_request) parsed = json - expect(parsed['error']).to include('File extension does not match') + expect(parsed['error']).to include('File extension not allowed') end end @@ -485,9 +485,9 @@ def create_submission_record(attrs = {}) .to receive(:check_extension_integrity).and_return(true) allow(FileUtils).to receive(:mkdir_p) allow(File).to receive(:exist?).and_return(false, true) # First for directory check, then exists after creation - allow(File).to receive(:open).and_call_original + # Mock File.open only for write mode ('wb') fake_file = StringIO.new - allow(File).to receive(:open).with(any_args).and_yield(fake_file) + allow(File).to receive(:open).with(anything, 'wb').and_yield(fake_file) allow_any_instance_of(Api::V1::SubmittedContentController) .to receive(:create_submission_record_for).and_return(true) end @@ -527,9 +527,9 @@ def create_submission_record(attrs = {}) .to receive(:file_type).and_return('zip') allow(FileUtils).to receive(:mkdir_p) allow(File).to receive(:exist?).and_return(false, true) - allow(File).to receive(:open).and_call_original + # Mock File.open only for write mode ('wb') fake_file = StringIO.new - allow(File).to receive(:open).with(any_args).and_yield(fake_file) + allow(File).to receive(:open).with(anything, 'wb').and_yield(fake_file) allow(SubmittedContentHelper).to receive(:unzip_file).and_return({ message: 'Unzipped successfully' }) allow_any_instance_of(Api::V1::SubmittedContentController) .to receive(:create_submission_record_for).and_return(true) @@ -573,7 +573,7 @@ def create_submission_record(attrs = {}) expect(response).to have_http_status(:bad_request) parsed = json - expect(parsed['error']).to eq('No folder action specified') + expect(parsed['error']).to include('No folder action specified') end end @@ -709,7 +709,7 @@ def create_submission_record(attrs = {}) run_test! do parsed = json - expect(parsed['error']).to eq('Folder name is required.') + expect(parsed['error']).to include('Folder name is required') end end @@ -720,7 +720,7 @@ def create_submission_record(attrs = {}) run_test! do parsed = json - expect(parsed['error']).to eq('File name is required.') + expect(parsed['error']).to include('File name is required') end end @@ -735,7 +735,7 @@ def create_submission_record(attrs = {}) run_test! do parsed = json - expect(parsed['error']).to eq('Cannot download a directory. Please specify a file.') + expect(parsed['error']).to include('Cannot download a directory') end end @@ -751,7 +751,7 @@ def create_submission_record(attrs = {}) run_test! do parsed = json - expect(parsed['error']).to eq('File does not exist.') + expect(parsed['error']).to include('does not exist') end end @@ -813,7 +813,7 @@ def create_submission_record(attrs = {}) expect(response).to have_http_status(:not_found) parsed = json - expect(parsed['error']).to eq('Participant or team not found') + expect(parsed['error']).to include('not associated with a team') end end end From adf7acf184216b5629321fa3f1e0e25ddede9064 Mon Sep 17 00:00:00 2001 From: asreeku Date: Mon, 27 Oct 2025 18:22:16 -0400 Subject: [PATCH 07/18] Creating TeamsParticipant entry for checking download action --- app/models/assignment_team.rb | 12 ++++++------ spec/requests/api/v1/submitted_content_spec.rb | 9 +++++++++ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/app/models/assignment_team.rb b/app/models/assignment_team.rb index f2d2304c8..b2a03df65 100644 --- a/app/models/assignment_team.rb +++ b/app/models/assignment_team.rb @@ -39,15 +39,15 @@ def self.team(participant) return nil if participant.nil? team = nil - teams_users = TeamsUser.where(users_id: participant.user_id) - return nil unless teams_users + teams_participants = TeamsParticipant.where(user_id: participant.user_id) + return nil unless teams_participants - teams_users.each do |teams_user| - if teams_user.teams_id.nil? + teams_participants.each do |teams_participant| + if teams_participant.team_id.nil? next end - team = AssignmentTeam.find(teams_user.teams_id) - return team if team.assignment_id == participant.assignment_id + team = AssignmentTeam.find(teams_participant.team_id) + return team if team.parent_id == participant.parent_id end nil end diff --git a/spec/requests/api/v1/submitted_content_spec.rb b/spec/requests/api/v1/submitted_content_spec.rb index 68f066585..5442d1c4a 100644 --- a/spec/requests/api/v1/submitted_content_spec.rb +++ b/spec/requests/api/v1/submitted_content_spec.rb @@ -56,6 +56,14 @@ handle: student.name ) end + + let(:teams_participant) do + TeamsParticipant.create!( + team_id: team.id, + user_id: student.id, + participant_id: participant.id + ) + end let(:Authorization) { auth_headers_student['Authorization'] } let(:auth_headers_instructor) { { 'Authorization' => "Bearer #{JsonWebToken.encode(id: instructor.id)}" } } @@ -700,6 +708,7 @@ def create_submission_record(attrs = {}) # Ensure participant and team are created before the test runs participant team + teams_participant end response(400, 'folder name is nil') do From 7c7bcdd23763a647c605c562fc0b9d7b2e7d9ead Mon Sep 17 00:00:00 2001 From: Shawty 2084 Date: Tue, 28 Oct 2025 15:57:31 -0400 Subject: [PATCH 08/18] refactor: fix Law of Demeter violations and FileHelper consistency --- .../api/v1/submitted_content_controller.rb | 2 +- app/helpers/submitted_content_helper.rb | 11 ++++++----- app/models/assignment_participant.rb | 1 + app/models/assignment_team.rb | 5 ++++- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/app/controllers/api/v1/submitted_content_controller.rb b/app/controllers/api/v1/submitted_content_controller.rb index ffaea2a96..f65375961 100644 --- a/app/controllers/api/v1/submitted_content_controller.rb +++ b/app/controllers/api/v1/submitted_content_controller.rb @@ -144,7 +144,7 @@ def submit_file team.set_student_directory_num # Build the full directory path where file will be saved - current_directory = File.join(team.path.to_s, current_folder) + current_directory = File.join(@participant.team_path.to_s, current_folder) # Create the directory if it doesn't exist FileUtils.mkdir_p(current_directory) unless File.exist?(current_directory) diff --git a/app/helpers/submitted_content_helper.rb b/app/helpers/submitted_content_helper.rb index 6dc3676ab..781187926 100644 --- a/app/helpers/submitted_content_helper.rb +++ b/app/helpers/submitted_content_helper.rb @@ -43,7 +43,8 @@ def self.unzip_file(file_name, unzip_dir, should_delete) # @param unzip_dir [String] Target directory for extraction def self.extract_entry(e, unzip_dir) # Sanitize the entry name to prevent directory traversal attacks - safe_name = FileHelper.sanitize_filename(e.name) + just_filename = File.basename(e.name) + safe_name = just_filename.gsub(%r{[^\w\.\_/]}, '_').tr("'", '_') # Build the full path where the entry will be extracted file_path = File.join(unzip_dir, safe_name) @@ -124,7 +125,7 @@ def move_selected_file # Wrap the move operation with error handling handle_file_operation_error('moving') do # Perform the file move using FileHelper - FileHelper.move_file(old_filename, new_location) + move_file(old_filename, new_location) # Render success response render json: { message: "The file was successfully moved." }, status: :ok @@ -139,7 +140,7 @@ def rename_selected_file # Build new filename with sanitization in the same directory new_filename = File.join(params[:directories][params[:chk_files]], - FileHelper.sanitize_filename(params[:faction][:rename])) + sanitize_filename(params[:faction][:rename])) # Wrap the rename operation with error handling handle_file_operation_error('renaming') do @@ -171,7 +172,7 @@ def copy_selected_file # Build destination filename with sanitization new_filename = File.join(params[:directories][params[:chk_files]], - FileHelper.sanitize_filename(params[:faction][:copy])) + sanitize_filename(params[:faction][:copy])) # Wrap the copy operation with error handling handle_file_operation_error('copying') do @@ -238,7 +239,7 @@ def create_new_folder # Wrap the folder creation with error handling handle_file_operation_error('creating directory') do # Create the directory (and any necessary parent directories) - FileHelper.create_directory_from_path(location) + create_directory_from_path(location) # Render success response with folder name render json: { message: "Directory '#{params[:faction][:create]}' created successfully." }, status: :created diff --git a/app/models/assignment_participant.rb b/app/models/assignment_participant.rb index 0a8350bc9..4817c91c2 100644 --- a/app/models/assignment_participant.rb +++ b/app/models/assignment_participant.rb @@ -10,6 +10,7 @@ class AssignmentParticipant < Participant delegate :name, to: :user, prefix: true, allow_nil: true delegate :id, to: :team, prefix: true, allow_nil: true delegate :id, to: :assignment, prefix: true, allow_nil: true + delegate :path, to: :team, prefix: true, allow_nil: true # Fetches the team for specific participant def team diff --git a/app/models/assignment_team.rb b/app/models/assignment_team.rb index b2a03df65..03d3de9f1 100644 --- a/app/models/assignment_team.rb +++ b/app/models/assignment_team.rb @@ -5,6 +5,9 @@ class AssignmentTeam < Team belongs_to :assignment, class_name: 'Assignment', foreign_key: 'parent_id' has_many :review_mappings, class_name: 'ReviewResponseMap', foreign_key: 'reviewee_id' + # Delegation to avoid Law of Demeter violations + delegate :path, to: :assignment, prefix: true + def hyperlinks submitted_hyperlinks.blank? ? [] : YAML.safe_load(submitted_hyperlinks) end @@ -63,7 +66,7 @@ def set_student_directory_num # Gets the student directory path def path - "#{assignment.path}/#{directory_num}" + "#{assignment_path}/#{directory_num}" end # Copies the current assignment team to a course team From 753821f1127cfe66920280f8bf76e8eef739be43 Mon Sep 17 00:00:00 2001 From: Shawty 2084 Date: Tue, 28 Oct 2025 19:05:55 -0400 Subject: [PATCH 09/18] add list_files endpoint and directory_num migration --- .../api/v1/submitted_content_controller.rb | 70 ++++++++++++++++++- config/routes.rb | 1 + ...251028195837_add_directory_num_to_teams.rb | 5 ++ db/schema.rb | 3 +- 4 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 db/migrate/20251028195837_add_directory_num_to_teams.rb diff --git a/app/controllers/api/v1/submitted_content_controller.rb b/app/controllers/api/v1/submitted_content_controller.rb index f65375961..491858111 100644 --- a/app/controllers/api/v1/submitted_content_controller.rb +++ b/app/controllers/api/v1/submitted_content_controller.rb @@ -3,8 +3,8 @@ class Api::V1::SubmittedContentController < ApplicationController include FileHelper before_action :set_submission_record, only: [:show] - before_action :set_participant, only: [:submit_hyperlink, :remove_hyperlink, :submit_file, :folder_action, :download] - before_action :ensure_participant_team, only: [:submit_hyperlink, :remove_hyperlink, :submit_file, :folder_action, :download] + before_action :set_participant, only: [:submit_hyperlink, :remove_hyperlink, :submit_file, :folder_action, :download, :list_files] + before_action :ensure_participant_team, only: [:submit_hyperlink, :remove_hyperlink, :submit_file, :folder_action, :download, :list_files] # GET /api/v1/submitted_content # Retrieves all submission records from the database @@ -231,6 +231,72 @@ def download send_file(path, disposition: 'inline') end + # GET /api/v1/submitted_content/list_files + # Lists all files and directories in the participant's submission folder + def list_files + # Get the team and ensure it has a directory + team = participant_team + team.set_student_directory_num + + # Get the folder path from params, default to root + folder_param = params.dig(:folder, :name) || params[:folder] || '/' + folder_path = sanitize_folder(folder_param) + + # Build the full directory path + base_path = @participant.team_path.to_s + full_path = folder_path == '/' ? base_path : File.join(base_path, folder_path) + + # Check if directory exists + unless File.exist?(full_path) + # Create the directory if it doesn't exist + FileUtils.mkdir_p(full_path) + return render json: { files: [], folders: [], hyperlinks: team.hyperlinks }, status: :ok + end + + # Check if path is actually a directory + unless File.directory?(full_path) + return render_error('The specified path is not a directory.', :bad_request) + end + + # Collect files and folders + files = [] + folders = [] + + Dir.entries(full_path).each do |entry| + # Skip current and parent directory references + next if entry == '.' || entry == '..' + + entry_path = File.join(full_path, entry) + + if File.directory?(entry_path) + # It's a folder + folders << { + name: entry, + modified_at: File.mtime(entry_path) + } + else + # It's a file + files << { + name: entry, + size: File.size(entry_path), + type: File.extname(entry).delete('.'), + modified_at: File.mtime(entry_path) + } + end + end + + # Return the file listing with hyperlinks + render json: { + current_folder: folder_path, + files: files.sort_by { |f| f[:name] }, + folders: folders.sort_by { |f| f[:name] }, + hyperlinks: team.hyperlinks + }, status: :ok + rescue StandardError => e + # Handle any errors during file listing + render_error("Failed to list directory contents: #{e.message}. Please try again.", :internal_server_error) + end + private # Before action callback: Sets @submission_record for the show action diff --git a/config/routes.rb b/config/routes.rb index 8e443dc20..2847f142c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -147,6 +147,7 @@ resources :submitted_content do collection do get :download + get :list_files get :folder_action post :folder_action get :remove_hyperlink diff --git a/db/migrate/20251028195837_add_directory_num_to_teams.rb b/db/migrate/20251028195837_add_directory_num_to_teams.rb new file mode 100644 index 000000000..5df188551 --- /dev/null +++ b/db/migrate/20251028195837_add_directory_num_to_teams.rb @@ -0,0 +1,5 @@ +class AddDirectoryNumToTeams < ActiveRecord::Migration[8.0] + def change + add_column :teams, :directory_num, :integer + end +end diff --git a/db/schema.rb b/db/schema.rb index d0c38070c..4575912af 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_10_19_154115) do +ActiveRecord::Schema[8.0].define(version: 2025_10_28_195837) do create_table "account_requests", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "username" t.string "full_name" @@ -365,6 +365,7 @@ t.datetime "updated_at", null: false t.integer "parent_id", null: false t.text "submitted_hyperlinks" + t.integer "directory_num" t.index ["mentor_id"], name: "index_teams_on_mentor_id" t.index ["type"], name: "index_teams_on_type" t.index ["user_id"], name: "index_teams_on_user_id" From 4356b22fcdf908402986a93b086c10182b0d51c5 Mon Sep 17 00:00:00 2001 From: Shawty 2084 Date: Tue, 28 Oct 2025 21:15:38 -0400 Subject: [PATCH 10/18] Fix download test failures by removing duplicate before(:all) blocks --- spec/requests/api/v1/submitted_content_spec.rb | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/spec/requests/api/v1/submitted_content_spec.rb b/spec/requests/api/v1/submitted_content_spec.rb index 5442d1c4a..a858019ed 100644 --- a/spec/requests/api/v1/submitted_content_spec.rb +++ b/spec/requests/api/v1/submitted_content_spec.rb @@ -691,12 +691,6 @@ def create_submission_record(attrs = {}) end path '/api/v1/submitted_content/download' do - before(:all) do - require Rails.root.join('app/models/participant') - require Rails.root.join('app/models/assignment_participant') - require Rails.root.join('app/models/assignment_team') - end - get('download file') do tags 'SubmittedContent' produces 'application/octet-stream' @@ -790,12 +784,6 @@ def create_submission_record(attrs = {}) end describe 'Error handling' do - before(:all) do - require Rails.root.join('app/models/participant') - require Rails.root.join('app/models/assignment_participant') - require Rails.root.join('app/models/assignment_team') - end - context 'when participant not found' do it 'returns 500 (RecordNotFound bubbles)' do allow(AssignmentParticipant).to receive(:find).and_raise(ActiveRecord::RecordNotFound) From bd0410ac4a1615f34a2244910d5bddd384aecbd0 Mon Sep 17 00:00:00 2001 From: asreeku Date: Fri, 14 Nov 2025 08:57:12 -0500 Subject: [PATCH 11/18] Moving apis and routes out of api/v1 --- .../v1 => }/submitted_content_controller.rb | 28 +- config/routes.rb | 293 +++++++++--------- 2 files changed, 158 insertions(+), 163 deletions(-) rename app/controllers/{api/v1 => }/submitted_content_controller.rb (95%) diff --git a/app/controllers/api/v1/submitted_content_controller.rb b/app/controllers/submitted_content_controller.rb similarity index 95% rename from app/controllers/api/v1/submitted_content_controller.rb rename to app/controllers/submitted_content_controller.rb index 491858111..a85536f35 100644 --- a/app/controllers/api/v1/submitted_content_controller.rb +++ b/app/controllers/submitted_content_controller.rb @@ -1,4 +1,4 @@ -class Api::V1::SubmittedContentController < ApplicationController +class SubmittedContentController < ApplicationController include SubmittedContentHelper include FileHelper @@ -6,21 +6,21 @@ class Api::V1::SubmittedContentController < ApplicationController before_action :set_participant, only: [:submit_hyperlink, :remove_hyperlink, :submit_file, :folder_action, :download, :list_files] before_action :ensure_participant_team, only: [:submit_hyperlink, :remove_hyperlink, :submit_file, :folder_action, :download, :list_files] - # GET /api/v1/submitted_content + # GET /submitted_content # Retrieves all submission records from the database def index # Return all submission records as JSON with 200 OK status render json: SubmissionRecord.all, status: :ok end - # GET /api/v1/submitted_content/:id + # GET /submitted_content/:id # Retrieves a specific submission record by ID (set by before_action) def show # @submission_record is set by set_submission_record before_action render json: @submission_record, status: :ok end - # POST /api/v1/submitted_content + # POST /submitted_content # Creates a new submission record with automatic type detection (hyperlink or file) def create # Get permitted parameters from request @@ -40,8 +40,8 @@ def create end end - # POST /api/v1/submitted_content/submit_hyperlink - # GET /api/v1/submitted_content/submit_hyperlink + # POST /submitted_content/submit_hyperlink + # GET /submitted_content/submit_hyperlink # Validates and submits a hyperlink for the participant's team def submit_hyperlink # Get the participant's team (requires @participant from before_action) @@ -76,8 +76,8 @@ def submit_hyperlink end end - # POST /api/v1/submitted_content/remove_hyperlink - # GET /api/v1/submitted_content/remove_hyperlink + # POST /submitted_content/remove_hyperlink + # GET /submitted_content/remove_hyperlink # Removes a hyperlink at the specified index from the team's hyperlinks def remove_hyperlink # Get the participant's team @@ -110,8 +110,8 @@ def remove_hyperlink end end - # POST /api/v1/submitted_content/submit_file - # GET /api/v1/submitted_content/submit_file + # POST /submitted_content/submit_file + # GET /submitted_content/submit_file # Handles file upload for the participant's team with validation and optional unzipping def submit_file # Get the uploaded file from request parameters @@ -173,8 +173,8 @@ def submit_file render_error("Failed to save file to server: #{e.message}. Please verify the file is not corrupted and try again.", :internal_server_error) end - # POST /api/v1/submitted_content/folder_action - # GET /api/v1/submitted_content/folder_action + # POST /submitted_content/folder_action + # GET /submitted_content/folder_action # Dispatches folder management actions based on the faction parameter def folder_action # Get the faction parameter (specifies which action to perform) @@ -197,7 +197,7 @@ def folder_action end end - # GET /api/v1/submitted_content/download + # GET /submitted_content/download # Validates and streams a file for download def download # Extract folder name and file name from params @@ -231,7 +231,7 @@ def download send_file(path, disposition: 'inline') end - # GET /api/v1/submitted_content/list_files + # GET /submitted_content/list_files # Lists all files and directories in the participant's submission folder def list_files # Get the team and ensure it has a directory diff --git a/config/routes.rb b/config/routes.rb index 2847f142c..35aa87e52 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -9,155 +9,150 @@ # Defines the root path route ("/") # root "articles#index" post '/login', to: 'authentication#login' - resources :institutions - resources :roles do - collection do - # Get all roles that are subordinate to a role of a logged in user - get 'subordinate_roles', action: :subordinate_roles - end - end - resources :users do - collection do - get 'institution/:id', action: :institution_users - get ':id/managed', action: :managed_users - get 'role/:name', action: :role_users - end - end - resources :assignments do - collection do - post '/:assignment_id/add_participant/:user_id',action: :add_participant - delete '/:assignment_id/remove_participant/:user_id',action: :remove_participant - patch '/:assignment_id/remove_assignment_from_course',action: :remove_assignment_from_course - patch '/:assignment_id/assign_course/:course_id',action: :assign_course - post '/:assignment_id/copy_assignment', action: :copy_assignment - get '/:assignment_id/has_topics',action: :has_topics - get '/:assignment_id/show_assignment_details',action: :show_assignment_details - get '/:assignment_id/team_assignment', action: :team_assignment - get '/:assignment_id/has_teams', action: :has_teams - get '/:assignment_id/valid_num_review/:review_type', action: :valid_num_review - get '/:assignment_id/varying_rubrics_by_round', action: :varying_rubrics_by_round? - post '/:assignment_id/create_node',action: :create_node - end - end - - resources :bookmarks, except: [:new, :edit] do - member do - get 'bookmarkratings', to: 'bookmarks#get_bookmark_rating_score' - post 'bookmarkratings', to: 'bookmarks#save_bookmark_rating_score' - end - end - resources :student_tasks do - collection do - get :list, action: :list - get :view - end - end - - resources :courses do - collection do - get ':id/add_ta/:ta_id', action: :add_ta - get ':id/tas', action: :view_tas - get ':id/remove_ta/:ta_id', action: :remove_ta - get ':id/copy', action: :copy - end - end - - resources :questionnaires do - collection do - post 'copy/:id', to: 'questionnaires#copy', as: 'copy' - get 'toggle_access/:id', to: 'questionnaires#toggle_access', as: 'toggle_access' - end - end - - resources :questions do - collection do - get :types - get 'show_all/questionnaire/:id', to:'questions#show_all#questionnaire', as: 'show_all' - delete 'delete_all/questionnaire/:id', to:'questions#delete_all#questionnaire', as: 'delete_all' - end - end - - resources :signed_up_teams do - collection do - post '/sign_up', to: 'signed_up_teams#sign_up' - post '/sign_up_student', to: 'signed_up_teams#sign_up_student' - end - end - - resources :join_team_requests do - collection do - post 'decline/:id', to:'join_team_requests#decline' - end - end - - - - resources :sign_up_topics do - collection do - get :filter - delete '/', to: 'sign_up_topics#destroy' - end - end - - resources :invitations do - get 'user/:user_id/assignment/:assignment_id/', on: :collection, action: :invitations_for_user_assignment - end - - resources :account_requests do - collection do - get :pending, action: :pending_requests - get :processed, action: :processed_requests - end - end - - resources :participants do - collection do - get '/user/:user_id', to: 'participants#list_user_participants' - get '/assignment/:assignment_id', to: 'participants#list_assignment_participants' - get '/:id', to: 'participants#show' - post '/:authorization', to: 'participants#add' - patch '/:id/:authorization', to: 'participants#update_authorization' - delete '/:id', to: 'participants#destroy' - end - end - resources :teams do - member do - get 'members' - post 'members', to: 'teams#add_member' - delete 'members/:user_id', to: 'teams#remove_member' - - get 'join_requests' - post 'join_requests', to: 'teams#create_join_request' - put 'join_requests/:join_request_id', to: 'teams#update_join_request' - end - end - resources :teams_participants, only: [] do - collection do - put :update_duty - end - member do - get :list_participants - post :add_participant - delete :delete_participants - end - end - - namespace :api do - namespace :v1 do - resources :submitted_content do - collection do - get :download - get :list_files - get :folder_action - post :folder_action - get :remove_hyperlink - post :remove_hyperlink - get :submit_file - post :submit_file - get :submit_hyperlink - post :submit_hyperlink - end - end + resources :institutions + resources :roles do + collection do + # Get all roles that are subordinate to a role of a logged in user + get 'subordinate_roles', action: :subordinate_roles + end + end + resources :users do + collection do + get 'institution/:id', action: :institution_users + get ':id/managed', action: :managed_users + get 'role/:name', action: :role_users + end + end + resources :assignments do + collection do + post '/:assignment_id/add_participant/:user_id',action: :add_participant + delete '/:assignment_id/remove_participant/:user_id',action: :remove_participant + patch '/:assignment_id/remove_assignment_from_course',action: :remove_assignment_from_course + patch '/:assignment_id/assign_course/:course_id',action: :assign_course + post '/:assignment_id/copy_assignment', action: :copy_assignment + get '/:assignment_id/has_topics',action: :has_topics + get '/:assignment_id/show_assignment_details',action: :show_assignment_details + get '/:assignment_id/team_assignment', action: :team_assignment + get '/:assignment_id/has_teams', action: :has_teams + get '/:assignment_id/valid_num_review/:review_type', action: :valid_num_review + get '/:assignment_id/varying_rubrics_by_round', action: :varying_rubrics_by_round? + post '/:assignment_id/create_node',action: :create_node + end + end + + resources :bookmarks, except: [:new, :edit] do + member do + get 'bookmarkratings', to: 'bookmarks#get_bookmark_rating_score' + post 'bookmarkratings', to: 'bookmarks#save_bookmark_rating_score' + end + end + resources :student_tasks do + collection do + get :list, action: :list + get :view + end + end + + resources :courses do + collection do + get ':id/add_ta/:ta_id', action: :add_ta + get ':id/tas', action: :view_tas + get ':id/remove_ta/:ta_id', action: :remove_ta + get ':id/copy', action: :copy + end + end + + resources :questionnaires do + collection do + post 'copy/:id', to: 'questionnaires#copy', as: 'copy' + get 'toggle_access/:id', to: 'questionnaires#toggle_access', as: 'toggle_access' + end + end + + resources :questions do + collection do + get :types + get 'show_all/questionnaire/:id', to:'questions#show_all#questionnaire', as: 'show_all' + delete 'delete_all/questionnaire/:id', to:'questions#delete_all#questionnaire', as: 'delete_all' + end + end + + resources :signed_up_teams do + collection do + post '/sign_up', to: 'signed_up_teams#sign_up' + post '/sign_up_student', to: 'signed_up_teams#sign_up_student' + end + end + + resources :join_team_requests do + collection do + post 'decline/:id', to:'join_team_requests#decline' + end + end + + + + resources :sign_up_topics do + collection do + get :filter + delete '/', to: 'sign_up_topics#destroy' + end + end + + resources :invitations do + get 'user/:user_id/assignment/:assignment_id/', on: :collection, action: :invitations_for_user_assignment + end + + resources :account_requests do + collection do + get :pending, action: :pending_requests + get :processed, action: :processed_requests + end + end + + resources :participants do + collection do + get '/user/:user_id', to: 'participants#list_user_participants' + get '/assignment/:assignment_id', to: 'participants#list_assignment_participants' + get '/:id', to: 'participants#show' + post '/:authorization', to: 'participants#add' + patch '/:id/:authorization', to: 'participants#update_authorization' + delete '/:id', to: 'participants#destroy' + end + end + resources :teams do + member do + get 'members' + post 'members', to: 'teams#add_member' + delete 'members/:user_id', to: 'teams#remove_member' + + get 'join_requests' + post 'join_requests', to: 'teams#create_join_request' + put 'join_requests/:join_request_id', to: 'teams#update_join_request' + end + end + resources :teams_participants, only: [] do + collection do + put :update_duty + end + member do + get :list_participants + post :add_participant + delete :delete_participants + end + end + resources :submitted_content do + collection do + get :download + get :list_files + get :folder_action + post :folder_action + get :remove_hyperlink + post :remove_hyperlink + get :submit_file + post :submit_file + get :submit_hyperlink + post :submit_hyperlink end end end From ec19b932524e0f570c41aadb76b06a6bd2bcc3e0 Mon Sep 17 00:00:00 2001 From: asreeku Date: Fri, 14 Nov 2025 09:16:57 -0500 Subject: [PATCH 12/18] Renaming function name from check_content_size to is_file_small_enough --- app/controllers/submitted_content_controller.rb | 2 +- app/helpers/submitted_content_helper.rb | 4 ++-- spec/requests/api/v1/submitted_content_spec.rb | 10 +++++----- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/controllers/submitted_content_controller.rb b/app/controllers/submitted_content_controller.rb index a85536f35..129581fbe 100644 --- a/app/controllers/submitted_content_controller.rb +++ b/app/controllers/submitted_content_controller.rb @@ -124,7 +124,7 @@ def submit_file file_size_limit_mb = 5 # Validate file size against the limit - unless check_content_size(uploaded, file_size_limit_mb) + unless is_file_small_enough(uploaded, file_size_limit_mb) return render_error("File size must be smaller than #{file_size_limit_mb}MB", :bad_request) end diff --git a/app/helpers/submitted_content_helper.rb b/app/helpers/submitted_content_helper.rb index 781187926..5468e5af7 100644 --- a/app/helpers/submitted_content_helper.rb +++ b/app/helpers/submitted_content_helper.rb @@ -97,11 +97,11 @@ def check_extension_integrity(original_filename) allowed_extensions.include?(file_extension) end - # Validates if a file size is within the specified limit + # Validates that a file is within the specified size limit # @param file [File] The file object to check # @param size_mb [Integer] Maximum allowed size in megabytes # @return [Boolean] true if file size is acceptable, false otherwise - def check_content_size(file, size_mb) + def is_file_small_enough(file, size_mb) # Compare file size (in bytes) against limit (converted from MB to bytes) file.size <= size_mb * 1024 * 1024 end diff --git a/spec/requests/api/v1/submitted_content_spec.rb b/spec/requests/api/v1/submitted_content_spec.rb index a858019ed..ce2f275b7 100644 --- a/spec/requests/api/v1/submitted_content_spec.rb +++ b/spec/requests/api/v1/submitted_content_spec.rb @@ -431,7 +431,7 @@ def create_submission_record(attrs = {}) before do allow_any_instance_of(Api::V1::SubmittedContentController) - .to receive(:check_content_size).and_return(false) + .to receive(:is_file_small_enough).and_return(false) end it 'returns bad request for size limit' do @@ -457,7 +457,7 @@ def create_submission_record(attrs = {}) before do allow_any_instance_of(Api::V1::SubmittedContentController) - .to receive(:check_content_size).and_return(true) + .to receive(:is_file_small_enough).and_return(true) allow_any_instance_of(Api::V1::SubmittedContentController) .to receive(:check_extension_integrity).and_return(false) end @@ -488,7 +488,7 @@ def create_submission_record(attrs = {}) before do allow_any_instance_of(Api::V1::SubmittedContentController) - .to receive(:check_content_size).and_return(true) + .to receive(:is_file_small_enough).and_return(true) allow_any_instance_of(Api::V1::SubmittedContentController) .to receive(:check_extension_integrity).and_return(true) allow(FileUtils).to receive(:mkdir_p) @@ -528,7 +528,7 @@ def create_submission_record(attrs = {}) before do allow_any_instance_of(Api::V1::SubmittedContentController) - .to receive(:check_content_size).and_return(true) + .to receive(:is_file_small_enough).and_return(true) allow_any_instance_of(Api::V1::SubmittedContentController) .to receive(:check_extension_integrity).and_return(true) allow_any_instance_of(Api::V1::SubmittedContentController) @@ -814,4 +814,4 @@ def create_submission_record(attrs = {}) end end end -end \ No newline at end of file +end From 08a788d614895b8d848e3cf31ea7b4903169101b Mon Sep 17 00:00:00 2001 From: asreeku Date: Fri, 14 Nov 2025 09:30:27 -0500 Subject: [PATCH 13/18] Remove use of chk_files name --- app/helpers/submitted_content_helper.rb | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/app/helpers/submitted_content_helper.rb b/app/helpers/submitted_content_helper.rb index 5468e5af7..ce8deb565 100644 --- a/app/helpers/submitted_content_helper.rb +++ b/app/helpers/submitted_content_helper.rb @@ -59,8 +59,20 @@ def self.extract_entry(e, unzip_dir) # Constructs the full file path from params for file operations # @return [String] Full path to the file def get_filename - # Build path from directories array and filenames array using chk_files index - "#{params[:directories][params[:chk_files]]}/#{params[:filenames][params[:chk_files]]}" + # Build path from directories array and filenames array using the selected file index + idx = primary_selected_file_index + "#{params[:directories][idx]}/#{params[:filenames][idx]}" + end + + # Returns the list (or single value) of selected file indexes + def selected_file_indexes + params[:selected_file_indexes] + end + + # Returns the first selected file index for operations that act on one file + def primary_selected_file_index + indexes = selected_file_indexes + indexes.is_a?(Array) ? indexes.first : indexes end # Wraps file operations with comprehensive error handling @@ -139,7 +151,7 @@ def rename_selected_file old_filename = get_filename # Build new filename with sanitization in the same directory - new_filename = File.join(params[:directories][params[:chk_files]], + new_filename = File.join(params[:directories][primary_selected_file_index], sanitize_filename(params[:faction][:rename])) # Wrap the rename operation with error handling @@ -171,7 +183,7 @@ def copy_selected_file old_filename = get_filename # Build destination filename with sanitization - new_filename = File.join(params[:directories][params[:chk_files]], + new_filename = File.join(params[:directories][primary_selected_file_index], sanitize_filename(params[:faction][:copy])) # Wrap the copy operation with error handling @@ -204,8 +216,8 @@ def delete_selected_files # 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| + # Iterate through each file index in the selected_file_indexes param + Array(selected_file_indexes).each do |idx| # Build the full file path for this index file_path = File.join(params[:directories][idx], params[:filenames][idx]) From ac5bc5cfa9b1014a6b6264d5e33fb6588978eac6 Mon Sep 17 00:00:00 2001 From: asreeku Date: Fri, 14 Nov 2025 10:12:05 -0500 Subject: [PATCH 14/18] Refactoring api/v1 route usages --- .../requests/api/v1/submitted_content_spec.rb | 100 +++++++++--------- 1 file changed, 50 insertions(+), 50 deletions(-) diff --git a/spec/requests/api/v1/submitted_content_spec.rb b/spec/requests/api/v1/submitted_content_spec.rb index ce2f275b7..b786e63d2 100644 --- a/spec/requests/api/v1/submitted_content_spec.rb +++ b/spec/requests/api/v1/submitted_content_spec.rb @@ -85,7 +85,7 @@ def create_submission_record(attrs = {}) }.merge(attrs)) end - path '/api/v1/submitted_content' do + path '/submitted_content' do get('list all submission records') do tags 'SubmittedContent' produces 'application/json' @@ -179,7 +179,7 @@ def create_submission_record(attrs = {}) end end - path '/api/v1/submitted_content/{id}' do + path '/submitted_content/{id}' do get('show a submission record') do tags 'SubmittedContent' produces 'application/json' @@ -216,7 +216,7 @@ def create_submission_record(attrs = {}) end end - path '/api/v1/submitted_content/submit_hyperlink' do + path '/submitted_content/submit_hyperlink' do shared_examples 'hyperlink submission' do |method| before do allow(AssignmentParticipant).to receive(:find).and_return(participant) @@ -235,7 +235,7 @@ def create_submission_record(attrs = {}) end it 'returns success' do - send(method, '/api/v1/submitted_content/submit_hyperlink', + send(method, '/submitted_content/submit_hyperlink', params: { id: id, submission: submission }, headers: auth_headers_student) @@ -250,7 +250,7 @@ def create_submission_record(attrs = {}) let(:submission) { '' } it 'returns bad request' do - send(method, '/api/v1/submitted_content/submit_hyperlink', + send(method, '/submitted_content/submit_hyperlink', params: { id: id, submission: submission }, headers: auth_headers_student) @@ -269,7 +269,7 @@ def create_submission_record(attrs = {}) end it 'returns conflict' do - send(method, '/api/v1/submitted_content/submit_hyperlink', + send(method, '/submitted_content/submit_hyperlink', params: { id: id, submission: submission }, headers: auth_headers_student) @@ -289,7 +289,7 @@ def create_submission_record(attrs = {}) end it 'returns bad request with error' do - send(method, '/api/v1/submitted_content/submit_hyperlink', + send(method, '/submitted_content/submit_hyperlink', params: { id: id, submission: submission }, headers: auth_headers_student) @@ -309,7 +309,7 @@ def create_submission_record(attrs = {}) end end - path '/api/v1/submitted_content/remove_hyperlink' do + path '/submitted_content/remove_hyperlink' do shared_examples 'hyperlink removal' do |method| before do allow(AssignmentParticipant).to receive(:find).and_return(participant) @@ -329,7 +329,7 @@ def create_submission_record(attrs = {}) end it 'returns no content' do - send(method, '/api/v1/submitted_content/remove_hyperlink', + send(method, '/submitted_content/remove_hyperlink', params: { id: id, chk_links: chk_links }, headers: auth_headers_student) @@ -346,7 +346,7 @@ def create_submission_record(attrs = {}) end it 'returns not found' do - send(method, '/api/v1/submitted_content/remove_hyperlink', + send(method, '/submitted_content/remove_hyperlink', params: { id: id, chk_links: chk_links }, headers: auth_headers_student) @@ -367,7 +367,7 @@ def create_submission_record(attrs = {}) end it 'returns internal server error' do - send(method, '/api/v1/submitted_content/remove_hyperlink', + send(method, '/submitted_content/remove_hyperlink', params: { id: id, chk_links: chk_links }, headers: auth_headers_student) @@ -387,7 +387,7 @@ def create_submission_record(attrs = {}) end end - path '/api/v1/submitted_content/submit_file' do + path '/submitted_content/submit_file' do shared_examples 'file submission' do |method| before do allow(AssignmentParticipant).to receive(:find).and_return(participant) @@ -402,7 +402,7 @@ def create_submission_record(attrs = {}) let(:id) { participant.id } it 'returns bad request' do - send(method, '/api/v1/submitted_content/submit_file', + send(method, '/submitted_content/submit_file', params: { id: id }, headers: auth_headers_student) @@ -430,12 +430,12 @@ def create_submission_record(attrs = {}) end before do - allow_any_instance_of(Api::V1::SubmittedContentController) + allow_any_instance_of(SubmittedContentController) .to receive(:is_file_small_enough).and_return(false) end it 'returns bad request for size limit' do - send(method, '/api/v1/submitted_content/submit_file', + send(method, '/submitted_content/submit_file', params: { id: id, uploaded_file: uploaded_file }, headers: auth_headers_student) @@ -456,14 +456,14 @@ def create_submission_record(attrs = {}) end before do - allow_any_instance_of(Api::V1::SubmittedContentController) + allow_any_instance_of(SubmittedContentController) .to receive(:is_file_small_enough).and_return(true) - allow_any_instance_of(Api::V1::SubmittedContentController) + allow_any_instance_of(SubmittedContentController) .to receive(:check_extension_integrity).and_return(false) end it 'returns bad request for invalid extension' do - send(method, '/api/v1/submitted_content/submit_file', + send(method, '/submitted_content/submit_file', params: { id: id, uploaded_file: uploaded_file }, headers: auth_headers_student) @@ -487,21 +487,21 @@ def create_submission_record(attrs = {}) end before do - allow_any_instance_of(Api::V1::SubmittedContentController) + allow_any_instance_of(SubmittedContentController) .to receive(:is_file_small_enough).and_return(true) - allow_any_instance_of(Api::V1::SubmittedContentController) + allow_any_instance_of(SubmittedContentController) .to receive(:check_extension_integrity).and_return(true) allow(FileUtils).to receive(:mkdir_p) allow(File).to receive(:exist?).and_return(false, true) # First for directory check, then exists after creation # Mock File.open only for write mode ('wb') fake_file = StringIO.new allow(File).to receive(:open).with(anything, 'wb').and_yield(fake_file) - allow_any_instance_of(Api::V1::SubmittedContentController) + allow_any_instance_of(SubmittedContentController) .to receive(:create_submission_record_for).and_return(true) end it 'returns success' do - send(method, '/api/v1/submitted_content/submit_file', + send(method, '/submitted_content/submit_file', params: { id: id, uploaded_file: uploaded_file }, headers: auth_headers_student) @@ -527,11 +527,11 @@ def create_submission_record(attrs = {}) end before do - allow_any_instance_of(Api::V1::SubmittedContentController) + allow_any_instance_of(SubmittedContentController) .to receive(:is_file_small_enough).and_return(true) - allow_any_instance_of(Api::V1::SubmittedContentController) + allow_any_instance_of(SubmittedContentController) .to receive(:check_extension_integrity).and_return(true) - allow_any_instance_of(Api::V1::SubmittedContentController) + allow_any_instance_of(SubmittedContentController) .to receive(:file_type).and_return('zip') allow(FileUtils).to receive(:mkdir_p) allow(File).to receive(:exist?).and_return(false, true) @@ -539,14 +539,14 @@ def create_submission_record(attrs = {}) fake_file = StringIO.new allow(File).to receive(:open).with(anything, 'wb').and_yield(fake_file) allow(SubmittedContentHelper).to receive(:unzip_file).and_return({ message: 'Unzipped successfully' }) - allow_any_instance_of(Api::V1::SubmittedContentController) + allow_any_instance_of(SubmittedContentController) .to receive(:create_submission_record_for).and_return(true) end it 'unzips the file when requested' do expect(SubmittedContentHelper).to receive(:unzip_file) - send(method, '/api/v1/submitted_content/submit_file', + send(method, '/submitted_content/submit_file', params: { id: id, uploaded_file: uploaded_file, unzip: true }, headers: auth_headers_student) @@ -564,7 +564,7 @@ def create_submission_record(attrs = {}) end end - path '/api/v1/submitted_content/folder_action' do + path '/submitted_content/folder_action' do shared_examples 'folder actions' do |method| before do allow(AssignmentParticipant).to receive(:find).and_return(participant) @@ -575,7 +575,7 @@ def create_submission_record(attrs = {}) let(:id) { participant.id } it 'returns bad request' do - send(method, '/api/v1/submitted_content/folder_action', + send(method, '/submitted_content/folder_action', params: { id: id }, headers: auth_headers_student) @@ -590,15 +590,15 @@ def create_submission_record(attrs = {}) let(:faction) { { delete: 'true' } } before do - allow_any_instance_of(Api::V1::SubmittedContentController) + allow_any_instance_of(SubmittedContentController) .to receive(:delete_selected_files).and_return(nil) end it 'calls delete_selected_files' do - expect_any_instance_of(Api::V1::SubmittedContentController) + expect_any_instance_of(SubmittedContentController) .to receive(:delete_selected_files) - send(method, '/api/v1/submitted_content/folder_action', + send(method, '/submitted_content/folder_action', params: { id: id, faction: faction }, headers: auth_headers_student) end @@ -609,15 +609,15 @@ def create_submission_record(attrs = {}) let(:faction) { { rename: 'new_name.txt' } } before do - allow_any_instance_of(Api::V1::SubmittedContentController) + allow_any_instance_of(SubmittedContentController) .to receive(:rename_selected_file).and_return(nil) end it 'calls rename_selected_file' do - expect_any_instance_of(Api::V1::SubmittedContentController) + expect_any_instance_of(SubmittedContentController) .to receive(:rename_selected_file) - send(method, '/api/v1/submitted_content/folder_action', + send(method, '/submitted_content/folder_action', params: { id: id, faction: faction }, headers: auth_headers_student) end @@ -628,15 +628,15 @@ def create_submission_record(attrs = {}) let(:faction) { { move: '/new/location' } } before do - allow_any_instance_of(Api::V1::SubmittedContentController) + allow_any_instance_of(SubmittedContentController) .to receive(:move_selected_file).and_return(nil) end it 'calls move_selected_file' do - expect_any_instance_of(Api::V1::SubmittedContentController) + expect_any_instance_of(SubmittedContentController) .to receive(:move_selected_file) - send(method, '/api/v1/submitted_content/folder_action', + send(method, '/submitted_content/folder_action', params: { id: id, faction: faction }, headers: auth_headers_student) end @@ -647,15 +647,15 @@ def create_submission_record(attrs = {}) let(:faction) { { copy: '/copy/location' } } before do - allow_any_instance_of(Api::V1::SubmittedContentController) + allow_any_instance_of(SubmittedContentController) .to receive(:copy_selected_file).and_return(nil) end it 'calls copy_selected_file' do - expect_any_instance_of(Api::V1::SubmittedContentController) + expect_any_instance_of(SubmittedContentController) .to receive(:copy_selected_file) - send(method, '/api/v1/submitted_content/folder_action', + send(method, '/submitted_content/folder_action', params: { id: id, faction: faction }, headers: auth_headers_student) end @@ -666,15 +666,15 @@ def create_submission_record(attrs = {}) let(:faction) { { create: 'new_folder' } } before do - allow_any_instance_of(Api::V1::SubmittedContentController) + allow_any_instance_of(SubmittedContentController) .to receive(:create_new_folder).and_return(nil) end it 'calls create_new_folder' do - expect_any_instance_of(Api::V1::SubmittedContentController) + expect_any_instance_of(SubmittedContentController) .to receive(:create_new_folder) - send(method, '/api/v1/submitted_content/folder_action', + send(method, '/submitted_content/folder_action', params: { id: id, faction: faction }, headers: auth_headers_student) end @@ -690,7 +690,7 @@ def create_submission_record(attrs = {}) end end - path '/api/v1/submitted_content/download' do + path '/submitted_content/download' do get('download file') do tags 'SubmittedContent' produces 'application/octet-stream' @@ -767,15 +767,15 @@ def create_submission_record(attrs = {}) before do allow(File).to receive(:directory?).with(file_path).and_return(false) allow(File).to receive(:exist?).with(file_path).and_return(true) - allow_any_instance_of(Api::V1::SubmittedContentController) + allow_any_instance_of(SubmittedContentController) .to receive(:send_file).and_return(nil) end it 'sends the file' do - expect_any_instance_of(Api::V1::SubmittedContentController) + expect_any_instance_of(SubmittedContentController) .to receive(:send_file).with(file_path, disposition: 'inline') - get '/api/v1/submitted_content/download', + get '/submitted_content/download', params: { id: id, current_folder: current_folder, download: download }, headers: auth_headers_student end @@ -788,7 +788,7 @@ def create_submission_record(attrs = {}) it 'returns 500 (RecordNotFound bubbles)' do allow(AssignmentParticipant).to receive(:find).and_raise(ActiveRecord::RecordNotFound) - post '/api/v1/submitted_content/submit_hyperlink', + post '/submitted_content/submit_hyperlink', params: { id: 999, submission: 'http://test.com' }, headers: auth_headers_student @@ -804,7 +804,7 @@ def create_submission_record(attrs = {}) end it 'returns not found for submit_hyperlink' do - post '/api/v1/submitted_content/submit_hyperlink', + post '/submitted_content/submit_hyperlink', params: { id: participant.id, submission: 'http://test.com' }, headers: auth_headers_student From d0aaa6f5ebb93d354a01b53072fdc9b9b2785a5b Mon Sep 17 00:00:00 2001 From: asreeku Date: Thu, 27 Nov 2025 17:46:38 -0500 Subject: [PATCH 15/18] Using parent_id instead of assignment_id; Fixing test cases --- app/controllers/application_controller.rb | 4 + .../submitted_content_controller.rb | 3 +- app/helpers/file_helper.rb | 3 +- app/models/assignment_team.rb | 2 +- app/models/role.rb | 2 + .../requests/api/v1/submitted_content_spec.rb | 234 ++++++++++++++++-- 6 files changed, 224 insertions(+), 24 deletions(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 8129da6b6..c0b4e3f1e 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,5 +1,9 @@ # frozen_string_literal: true +# Ensure concerns are loaded before including +require_relative 'concerns/authorization' +require_relative 'concerns/jwt_token' + class ApplicationController < ActionController::API include Authorization include JwtToken diff --git a/app/controllers/submitted_content_controller.rb b/app/controllers/submitted_content_controller.rb index 129581fbe..bf23c050b 100644 --- a/app/controllers/submitted_content_controller.rb +++ b/app/controllers/submitted_content_controller.rb @@ -241,10 +241,11 @@ def list_files # Get the folder path from params, default to root folder_param = params.dig(:folder, :name) || params[:folder] || '/' folder_path = sanitize_folder(folder_param) + relative_folder = folder_path.sub(/\A\//, '') # Build the full directory path base_path = @participant.team_path.to_s - full_path = folder_path == '/' ? base_path : File.join(base_path, folder_path) + full_path = relative_folder.blank? ? base_path : File.join(base_path, relative_folder) # Check if directory exists unless File.exist?(full_path) diff --git a/app/helpers/file_helper.rb b/app/helpers/file_helper.rb index 457895353..7e64034e3 100644 --- a/app/helpers/file_helper.rb +++ b/app/helpers/file_helper.rb @@ -1,7 +1,8 @@ module FileHelper + extend self # Replace invalid characters with underscore def clean_path(file_name) - file_name.gsub(%r{[^\w\.\_/]}, '_').tr("'", '_') + file_name.to_s.gsub(%r{[^\w\.\_/]}, '_').tr("'", '_') end # Removes any extension or paths from file_name diff --git a/app/models/assignment_team.rb b/app/models/assignment_team.rb index 03d3de9f1..23db88d2b 100644 --- a/app/models/assignment_team.rb +++ b/app/models/assignment_team.rb @@ -59,7 +59,7 @@ def self.team(participant) def set_student_directory_num return if directory_num && (directory_num >= 0) - max_num = AssignmentTeam.where(assignment_id:).order('directory_num desc').first.directory_num + max_num = AssignmentTeam.where(parent_id:).order('directory_num desc').first&.directory_num dir_num = max_num ? max_num + 1 : 0 update(directory_num: dir_num) end diff --git a/app/models/role.rb b/app/models/role.rb index 3cce77975..32c3221d0 100644 --- a/app/models/role.rb +++ b/app/models/role.rb @@ -55,6 +55,8 @@ def all_privileges_of?(target_role) 'Super Administrator' => 5 } + return false unless target_role && privileges[name] && privileges[target_role.name] + privileges[name] >= privileges[target_role.name] end diff --git a/spec/requests/api/v1/submitted_content_spec.rb b/spec/requests/api/v1/submitted_content_spec.rb index b786e63d2..fab7f7be7 100644 --- a/spec/requests/api/v1/submitted_content_spec.rb +++ b/spec/requests/api/v1/submitted_content_spec.rb @@ -2,16 +2,60 @@ require 'rails_helper' require 'action_dispatch/http/upload' require 'json_web_token' - -# Load STI models (parent class must be loaded before child) +require 'tmpdir' +# Explicitly load dependencies to avoid autoload ordering issues +require Rails.root.join('app/models/application_record') +require Rails.root.join('app/models/user') +require Rails.root.join('app/models/role') +require Rails.root.join('app/models/institution') require Rails.root.join('app/models/participant') require Rails.root.join('app/models/assignment_participant') +require Rails.root.join('app/models/team') require Rails.root.join('app/models/assignment_team') +require Rails.root.join('app/models/teams_participant') +require Rails.root.join('app/helpers/file_helper') +require Rails.root.join('app/helpers/metric_helper') +require Rails.root.join('app/helpers/submitted_content_helper') require Rails.root.join('app/models/assignment') +require Rails.root.join('app/controllers/concerns/jwt_token') +require Rails.root.join('app/controllers/concerns/authorization') +require Rails.root.join('app/controllers/application_controller') +require Rails.root.join('app/controllers/submitted_content_controller') RSpec.describe 'Submitted Content API', type: :request do before(:all) do @roles = create_roles_hierarchy + Assignment + end + + def assignment_participant_class + Object.const_get('AssignmentParticipant') + rescue NameError + require Rails.root.join('app/models/application_record') + require Rails.root.join('app/models/user') + require Rails.root.join('app/models/role') + require Rails.root.join('app/models/institution') + require Rails.root.join('app/models/participant') + require Rails.root.join('app/models/assignment_participant') + require Rails.root.join('app/models/team') + require Rails.root.join('app/models/assignment_team') + require Rails.root.join('app/models/teams_participant') + require Rails.root.join('app/helpers/file_helper') + require Rails.root.join('app/helpers/metric_helper') + require Rails.root.join('app/helpers/submitted_content_helper') + require Rails.root.join('app/models/assignment') + require Rails.root.join('app/controllers/concerns/jwt_token') + require Rails.root.join('app/controllers/application_controller') + require Rails.root.join('app/controllers/concerns/authorization') + require Rails.root.join('app/controllers/submitted_content_controller') + Object.const_get('AssignmentParticipant') + end + + def assignment_class + Object.const_get('Assignment') + rescue NameError + load Rails.root.join('app/models/assignment.rb') + Object.const_get('Assignment') end let(:institution) { Institution.create!(name: 'NC State') } @@ -39,7 +83,7 @@ ) end - let(:assignment) { Assignment.create!(name: 'Assignment 1', instructor_id: instructor.id, max_team_size: 3) } + let(:assignment) { assignment_class.create!(name: 'Assignment 1', instructor_id: instructor.id, max_team_size: 3) } let(:team) do AssignmentTeam.create!( @@ -50,7 +94,7 @@ end let(:participant) do - AssignmentParticipant.create!( + assignment_participant_class.create!( user_id: student.id, parent_id: assignment.id, handle: student.name @@ -389,11 +433,22 @@ def create_submission_record(attrs = {}) path '/submitted_content/submit_file' do shared_examples 'file submission' do |method| + let(:team_directory) { Dir.mktmpdir } + + after do + FileUtils.remove_entry(team_directory) if team_directory && Dir.exist?(team_directory) + end + before do allow(AssignmentParticipant).to receive(:find).and_return(participant) allow(participant).to receive(:team).and_return(team) allow(participant).to receive(:user).and_return(student) allow(participant).to receive(:assignment).and_return(assignment) + allow(participant).to receive(:team_path).and_return(team_directory) + allow_any_instance_of(SubmittedContentController) + .to receive(:ensure_participant_team).and_return(true) + allow_any_instance_of(SubmittedContentController) + .to receive(:participant_team).and_return(team) allow(team).to receive(:set_student_directory_num) allow(team).to receive(:path).and_return('/test/path') end @@ -491,18 +546,13 @@ def create_submission_record(attrs = {}) .to receive(:is_file_small_enough).and_return(true) allow_any_instance_of(SubmittedContentController) .to receive(:check_extension_integrity).and_return(true) - allow(FileUtils).to receive(:mkdir_p) - allow(File).to receive(:exist?).and_return(false, true) # First for directory check, then exists after creation - # Mock File.open only for write mode ('wb') - fake_file = StringIO.new - allow(File).to receive(:open).with(anything, 'wb').and_yield(fake_file) allow_any_instance_of(SubmittedContentController) .to receive(:create_submission_record_for).and_return(true) end it 'returns success' do send(method, '/submitted_content/submit_file', - params: { id: id, uploaded_file: uploaded_file }, + params: { id: id, uploaded_file: uploaded_file, current_folder: { name: '' } }, headers: auth_headers_student) expect(response).to have_http_status(:created) @@ -533,11 +583,6 @@ def create_submission_record(attrs = {}) .to receive(:check_extension_integrity).and_return(true) allow_any_instance_of(SubmittedContentController) .to receive(:file_type).and_return('zip') - allow(FileUtils).to receive(:mkdir_p) - allow(File).to receive(:exist?).and_return(false, true) - # Mock File.open only for write mode ('wb') - fake_file = StringIO.new - allow(File).to receive(:open).with(anything, 'wb').and_yield(fake_file) allow(SubmittedContentHelper).to receive(:unzip_file).and_return({ message: 'Unzipped successfully' }) allow_any_instance_of(SubmittedContentController) .to receive(:create_submission_record_for).and_return(true) @@ -547,7 +592,7 @@ def create_submission_record(attrs = {}) expect(SubmittedContentHelper).to receive(:unzip_file) send(method, '/submitted_content/submit_file', - params: { id: id, uploaded_file: uploaded_file, unzip: true }, + params: { id: id, uploaded_file: uploaded_file, unzip: true, current_folder: { name: '' } }, headers: auth_headers_student) expect(response).to have_http_status(:created) @@ -569,6 +614,8 @@ def create_submission_record(attrs = {}) before do allow(AssignmentParticipant).to receive(:find).and_return(participant) allow(participant).to receive(:team).and_return(team) + allow_any_instance_of(SubmittedContentController) + .to receive(:ensure_participant_team).and_return(true) end context 'without action specified' do @@ -694,7 +741,13 @@ def create_submission_record(attrs = {}) get('download file') do tags 'SubmittedContent' produces 'application/octet-stream' - parameter name: 'current_folder[name]', in: :query, type: :string, required: true + parameter name: :current_folder, in: :query, schema: { + type: :object, + properties: { + name: { type: :string } + }, + required: [:name] + } parameter name: :download, in: :query, type: :string, required: true parameter name: :id, in: :query, type: :string, required: true @@ -706,7 +759,7 @@ def create_submission_record(attrs = {}) end response(400, 'folder name is nil') do - let(:'current_folder[name]') { '' } + let(:current_folder) { { name: '' } } let(:download) { 'test.txt' } let(:id) { participant.id } @@ -718,7 +771,7 @@ def create_submission_record(attrs = {}) response(400, 'file name is nil') do let(:id) { participant.id } - let(:'current_folder[name]') { '/test' } + let(:current_folder) { { name: '/test' } } let(:download) { '' } run_test! do @@ -729,7 +782,7 @@ def create_submission_record(attrs = {}) response(400, 'cannot send whole folder') do let(:id) { participant.id } - let(:'current_folder[name]') { '/test' } + let(:current_folder) { { name: '/test' } } let(:download) { 'folder_name' } before do @@ -744,7 +797,7 @@ def create_submission_record(attrs = {}) response(404, 'file does not exist') do let(:id) { participant.id } - let(:'current_folder[name]') { '/test' } + let(:current_folder) { { name: '/test' } } let(:download) { 'nonexistent.txt' } before do @@ -765,6 +818,8 @@ def create_submission_record(attrs = {}) let(:file_path) { File.join('/test', 'existing.txt') } before do + allow(File).to receive(:directory?).and_call_original + allow(File).to receive(:exist?).and_call_original allow(File).to receive(:directory?).with(file_path).and_return(false) allow(File).to receive(:exist?).with(file_path).and_return(true) allow_any_instance_of(SubmittedContentController) @@ -783,6 +838,59 @@ def create_submission_record(attrs = {}) end end + path '/submitted_content/list_files' do + get('list files and hyperlinks') do + tags 'SubmittedContent' + produces 'application/json' + parameter name: :id, in: :query, type: :string, required: true + parameter name: :folder, in: :query, schema: { + type: :object, + properties: { + name: { type: :string } + } + } + + let(:id) { participant.id } + let(:folder) { { name: '/' } } + let(:temp_dir) { Dir.mktmpdir } + + before do + allow(AssignmentParticipant).to receive(:find).and_return(participant) + allow(participant).to receive(:team).and_return(team) + allow(participant).to receive(:team_path).and_return(temp_dir) + allow(team).to receive(:set_student_directory_num) + allow(team).to receive(:hyperlinks).and_return([]) + FileUtils.mkdir_p(File.join(temp_dir, 'subfolder')) + File.write(File.join(temp_dir, 'file.txt'), 'content') + end + + after do + FileUtils.remove_entry(temp_dir) if temp_dir && Dir.exist?(temp_dir) + end + + response(200, 'directory listed') do + run_test! do + expect(response).to have_http_status(:ok) + parsed = json + expect(parsed['current_folder']).to eq('/') + expect(parsed['files']).to be_an(Array) + expect(parsed['folders']).to be_an(Array) + expect(parsed['hyperlinks']).to be_an(Array) + end + end + + response(400, 'not a directory') do + let(:folder) { { name: '/file.txt' } } + + run_test! do + expect(response).to have_http_status(:bad_request) + parsed = json + expect(parsed['error']).to include('not a directory') + end + end + end + end + describe 'Error handling' do context 'when participant not found' do it 'returns 500 (RecordNotFound bubbles)' do @@ -814,4 +922,88 @@ def create_submission_record(attrs = {}) end end end + + # Minimal happy-path coverage to ensure rswag generation for all routes + describe 'SubmittedContent happy paths' do + before do + allow(AssignmentParticipant).to receive(:find).and_return(participant) + allow(participant).to receive(:team).and_return(team) + allow(participant).to receive(:team_path).and_return(Dir.mktmpdir) + allow(team).to receive(:hyperlinks).and_return([]) + allow(team).to receive(:submit_hyperlink) + allow(team).to receive(:remove_hyperlink) + allow(team).to receive(:set_student_directory_num) + allow(team).to receive(:path).and_return('/tmp') + end + + it 'lists submission records' do + create_submission_record + get '/submitted_content', headers: auth_headers_student + expect(response).to have_http_status(:ok) + end + + it 'shows a submission record' do + rec = create_submission_record + get "/submitted_content/#{rec.id}", headers: auth_headers_student + expect(response).to have_http_status(:ok) + end + + it 'submits a hyperlink (POST)' do + post '/submitted_content/submit_hyperlink', + params: { id: participant.id, submission: 'http://example.com' }, + headers: auth_headers_student + expect(response).to have_http_status(:ok) + end + + it 'removes a hyperlink (POST)' do + allow(team).to receive(:hyperlinks).and_return(['http://example.com']) + post '/submitted_content/remove_hyperlink', + params: { id: participant.id, chk_links: 0 }, + headers: auth_headers_student + expect(response).to have_http_status(:no_content) + end + + it 'submits a file (POST)' do + tempfile = Tempfile.new(['happy', '.txt']) + tempfile.write('hi') + tempfile.rewind + uploaded = ActionDispatch::Http::UploadedFile.new(tempfile: tempfile, filename: 'happy.txt', type: 'text/plain') + allow_any_instance_of(SubmittedContentController).to receive(:check_extension_integrity).and_return(true) + allow_any_instance_of(SubmittedContentController).to receive(:create_submission_record_for).and_return(true) + post '/submitted_content/submit_file', + params: { id: participant.id, uploaded_file: uploaded, current_folder: { name: '' } }, + headers: auth_headers_student + expect(response).to have_http_status(:created) + ensure + tempfile&.close! + end + + it 'performs a folder action (POST)' do + allow_any_instance_of(SubmittedContentController).to receive(:delete_selected_files).and_return(nil) + post '/submitted_content/folder_action', + params: { id: participant.id, faction: { delete: 'true' } }, + headers: auth_headers_student + expect([200, 204]).to include(response.status) + end + + it 'downloads a file (GET)' do + dir = Dir.mktmpdir + file_path = File.join(dir, 'dl.txt') + File.write(file_path, 'hi') + allow(participant).to receive(:team_path).and_return(dir) + get '/submitted_content/download', + params: { id: participant.id, current_folder: { name: dir }, download: 'dl.txt' }, + headers: auth_headers_student + expect(response).to have_http_status(:ok) + ensure + FileUtils.remove_entry(dir) if dir && Dir.exist?(dir) + end + + it 'lists files (GET)' do + get '/submitted_content/list_files', + params: { id: participant.id, folder: { name: '/' } }, + headers: auth_headers_student + expect(response).to have_http_status(:ok) + end + end end From 77e2b1cd223f0ca34889c843f2cd06fc57141413 Mon Sep 17 00:00:00 2001 From: asreeku Date: Thu, 27 Nov 2025 21:33:23 -0500 Subject: [PATCH 16/18] Fixing submit_file test cases --- app/controllers/application_controller.rb | 2 ++ .../submitted_content_controller.rb | 19 +++++++++++++++---- .../requests/api/v1/submitted_content_spec.rb | 7 +++++-- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index c0b4e3f1e..74dfb9ecf 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -3,6 +3,8 @@ # Ensure concerns are loaded before including require_relative 'concerns/authorization' require_relative 'concerns/jwt_token' +require_relative '../helpers/submitted_content_helper' +require_relative '../helpers/file_helper' class ApplicationController < ActionController::API include Authorization diff --git a/app/controllers/submitted_content_controller.rb b/app/controllers/submitted_content_controller.rb index bf23c050b..6e2232ffc 100644 --- a/app/controllers/submitted_content_controller.rb +++ b/app/controllers/submitted_content_controller.rb @@ -133,8 +133,15 @@ def submit_file return render_error('File extension not allowed. Supported formats: pdf, png, jpeg, jpg, zip, tar, gz, 7z, odt, docx, md, rb, mp4, txt.', :bad_request) end - # Read the file contents into memory - file_bytes = uploaded.read + # Read the file contents into memory; support both uploaded IOs and plain strings + file_bytes = + if uploaded.respond_to?(:read) + uploaded.read + elsif uploaded.is_a?(String) && File.exist?(uploaded) + File.binread(uploaded) + else + uploaded.to_s + end # Get the current folder from params, default to root '/' current_folder = sanitize_folder(params.dig(:current_folder, :name) || '/') @@ -170,6 +177,7 @@ def submit_file render_success('The file has been submitted successfully.', :created) rescue StandardError => e # Handle any errors during file upload (disk space, permissions, corruption, etc.) + Rails.logger.error("submit_file failed: #{e.class}: #{e.message}\n#{e.backtrace.join("\n")}") if defined?(Rails) render_error("Failed to save file to server: #{e.message}. Please verify the file is not corrupted and try again.", :internal_server_error) end @@ -201,7 +209,7 @@ def folder_action # Validates and streams a file for download def download # Extract folder name and file name from params - folder_name_param = params.dig(:current_folder, :name) + folder_name_param = params.dig(:current_folder, :name) || params[:current_folder] || params[:name] file_name = params[:download] # Validate that folder name was provided @@ -229,6 +237,9 @@ def download # Stream the file to the client (disposition: 'inline' displays in browser if possible) # Note: send_file returns immediately, do NOT render after this line send_file(path, disposition: 'inline') + rescue StandardError => e + Rails.logger.error("download failed: #{e.class}: #{e.message}\n#{e.backtrace.join("\n")}") if defined?(Rails) + render_error("Failed to download file: #{e.message}", :not_found) end # GET /submitted_content/list_files @@ -239,7 +250,7 @@ def list_files team.set_student_directory_num # Get the folder path from params, default to root - folder_param = params.dig(:folder, :name) || params[:folder] || '/' + folder_param = params.dig(:folder, :name) || params[:folder] || params[:name] || '/' folder_path = sanitize_folder(folder_param) relative_folder = folder_path.sub(/\A\//, '') diff --git a/spec/requests/api/v1/submitted_content_spec.rb b/spec/requests/api/v1/submitted_content_spec.rb index fab7f7be7..4ec90bc37 100644 --- a/spec/requests/api/v1/submitted_content_spec.rb +++ b/spec/requests/api/v1/submitted_content_spec.rb @@ -799,10 +799,13 @@ def create_submission_record(attrs = {}) let(:id) { participant.id } let(:current_folder) { { name: '/test' } } let(:download) { 'nonexistent.txt' } + let(:file_path) { File.join('/test', 'nonexistent.txt') } before do - allow(File).to receive(:directory?).and_return(false) - allow(File).to receive(:exist?).and_return(false) + allow(File).to receive(:directory?).and_call_original + allow(File).to receive(:exist?).and_call_original + allow(File).to receive(:directory?).with(file_path).and_return(false) + allow(File).to receive(:exist?).with(file_path).and_return(false) end run_test! do From 9c95e887879a721aba3a4617be8666890e2c3356 Mon Sep 17 00:00:00 2001 From: asreeku Date: Thu, 27 Nov 2025 22:00:02 -0500 Subject: [PATCH 17/18] Updating swagger to add all submitted_content endpoints --- .../requests/api/v1/submitted_content_spec.rb | 121 +++++++++++++++ swagger/v1/swagger.yaml | 144 +++++++++++++++++- 2 files changed, 259 insertions(+), 6 deletions(-) diff --git a/spec/requests/api/v1/submitted_content_spec.rb b/spec/requests/api/v1/submitted_content_spec.rb index 4ec90bc37..e4d6ea757 100644 --- a/spec/requests/api/v1/submitted_content_spec.rb +++ b/spec/requests/api/v1/submitted_content_spec.rb @@ -351,6 +351,31 @@ def create_submission_record(attrs = {}) describe 'GET' do it_behaves_like 'hyperlink submission', :get end + + # Minimal rswag-friendly example so swaggerize captures the route + post('submit hyperlink (swagger)') do + tags 'SubmittedContent' + parameter name: :Authorization, in: :header, schema: { type: :string } + parameter name: :id, in: :query, schema: { type: :string }, required: true + parameter name: :submission, in: :query, schema: { type: :string }, required: true + + response(200, 'successful') do + before do + allow(AssignmentParticipant).to receive(:find).and_return(participant) + allow(participant).to receive(:team).and_return(team) + allow(participant).to receive(:user).and_return(student) + allow(participant).to receive(:assignment).and_return(assignment) + allow(team).to receive(:hyperlinks).and_return([]) + allow(team).to receive(:submit_hyperlink) + end + + let(:Authorization) { auth_headers_student['Authorization'] } + let(:id) { participant.id } + let(:submission) { 'http://example.com' } + + run_test! + end + end end path '/submitted_content/remove_hyperlink' do @@ -429,6 +454,31 @@ def create_submission_record(attrs = {}) describe 'GET' do it_behaves_like 'hyperlink removal', :get end + + post('remove hyperlink (swagger)') do + tags 'SubmittedContent' + parameter name: :Authorization, in: :header, schema: { type: :string } + parameter name: :id, in: :query, schema: { type: :string }, required: true + parameter name: :chk_links, in: :query, schema: { type: :integer }, required: true + + response(204, 'removed') do + before do + allow(AssignmentParticipant).to receive(:find).and_return(participant) + allow(participant).to receive(:team).and_return(team) + allow(participant).to receive(:user).and_return(student) + allow(participant).to receive(:assignment).and_return(assignment) + allow(team).to receive(:hyperlinks).and_return(['http://example.com']) + allow(team).to receive(:remove_hyperlink) + allow_any_instance_of(SubmittedContentController).to receive(:ensure_participant_team).and_return(true) + end + + let(:Authorization) { auth_headers_student['Authorization'] } + let(:id) { participant.id } + let(:chk_links) { 0 } + + run_test! + end + end end path '/submitted_content/submit_file' do @@ -607,6 +657,50 @@ def create_submission_record(attrs = {}) describe 'GET' do it_behaves_like 'file submission', :get end + + post('submit file (swagger)') do + tags 'SubmittedContent' + consumes 'multipart/form-data' + parameter name: :Authorization, in: :header, schema: { type: :string } + parameter name: :id, in: :query, schema: { type: :string }, required: true + parameter name: :uploaded_file, in: :formData, schema: { type: :string, format: :binary }, required: true + parameter name: :current_folder, in: :query, schema: { type: :object, properties: { name: { type: :string } } } + + response(201, 'file submitted') do + let(:Authorization) { auth_headers_student['Authorization'] } + let(:id) { participant.id } + let(:uploaded_file) do + file = Tempfile.new(['swagger', '.txt']) + file.write('content') + file.rewind + ActionDispatch::Http::UploadedFile.new( + tempfile: file, + filename: 'swagger.txt', + type: 'text/plain' + ) + end + let(:current_folder) { { name: '' } } + + before do + allow(AssignmentParticipant).to receive(:find).and_return(participant) + allow(participant).to receive(:team).and_return(team) + allow(participant).to receive(:user).and_return(student) + allow(participant).to receive(:assignment).and_return(assignment) + allow(participant).to receive(:team_path).and_return(Dir.mktmpdir) + allow_any_instance_of(SubmittedContentController) + .to receive(:ensure_participant_team).and_return(true) + allow_any_instance_of(SubmittedContentController) + .to receive(:participant_team).and_return(team) + allow(team).to receive(:set_student_directory_num) + allow_any_instance_of(SubmittedContentController) + .to receive(:check_extension_integrity).and_return(true) + allow_any_instance_of(SubmittedContentController) + .to receive(:create_submission_record_for).and_return(true) + end + + run_test! + end + end end path '/submitted_content/folder_action' do @@ -735,6 +829,33 @@ def create_submission_record(attrs = {}) describe 'GET' do it_behaves_like 'folder actions', :get end + + post('folder action (swagger)') do + tags 'SubmittedContent' + consumes 'application/json' + parameter name: :Authorization, in: :header, schema: { type: :string } + parameter name: :'Content-Type', in: :header, schema: { type: :string } + parameter name: :id, in: :query, schema: { type: :string }, required: true + parameter name: :faction, in: :body, schema: { type: :object } + + response(400, 'folder action') do + before do + allow(AssignmentParticipant).to receive(:find).and_return(participant) + allow(participant).to receive(:team).and_return(team) + allow_any_instance_of(SubmittedContentController) + .to receive(:ensure_participant_team).and_return(true) + allow_any_instance_of(SubmittedContentController) + .to receive(:delete_selected_files).and_return(nil) + end + + let(:Authorization) { auth_headers_student['Authorization'] } + let(:'Content-Type') { 'application/json' } + let(:id) { participant.id } + let(:faction) { { delete: 'true' } } + + run_test! + end + end end path '/submitted_content/download' do diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml index 46536e029..b606604ba 100644 --- a/swagger/v1/swagger.yaml +++ b/swagger/v1/swagger.yaml @@ -1508,7 +1508,7 @@ paths: description: participant not found '401': description: unauthorized request has error response - "/api/v1/submitted_content": + "/submitted_content": get: summary: list all submission records tags: @@ -1552,7 +1552,7 @@ paths: - team_id - user - assignment_id - "/api/v1/submitted_content/{id}": + "/submitted_content/{id}": get: summary: show a submission record tags: @@ -1569,17 +1569,126 @@ paths: description: successful '404': description: not found - "/api/v1/submitted_content/download": - get: - summary: download file + "/submitted_content/submit_hyperlink": + post: + summary: submit hyperlink (swagger) + tags: + - SubmittedContent + parameters: + - name: Authorization + in: header + schema: + type: string + - name: id + in: query + schema: + type: string + required: true + - name: submission + in: query + schema: + type: string + required: true + responses: + '200': + description: successful + "/submitted_content/remove_hyperlink": + post: + summary: remove hyperlink (swagger) + tags: + - SubmittedContent + parameters: + - name: Authorization + in: header + schema: + type: string + - name: id + in: query + schema: + type: string + required: true + - name: chk_links + in: query + schema: + type: integer + required: true + responses: + '204': + description: removed + "/submitted_content/submit_file": + post: + summary: submit file (swagger) tags: - SubmittedContent parameters: - - name: current_folder[name] + - name: Authorization + in: header + schema: + type: string + - name: id + in: query + schema: + type: string + required: true + - name: current_folder in: query + schema: + type: object + properties: + name: + type: string + responses: + '201': + description: file submitted + requestBody: + content: + multipart/form-data: + schema: + type: string + format: binary required: true + "/submitted_content/folder_action": + post: + summary: folder action (swagger) + tags: + - SubmittedContent + parameters: + - name: Authorization + in: header schema: type: string + - name: Content-Type + in: header + schema: + type: string + - name: id + in: query + schema: + type: string + required: true + responses: + '400': + description: folder action + requestBody: + content: + application/json: + schema: + type: object + "/submitted_content/download": + get: + summary: download file + tags: + - SubmittedContent + parameters: + - name: current_folder + in: query + schema: + type: object + properties: + name: + type: string + required: + - name - name: download in: query required: true @@ -1597,6 +1706,29 @@ paths: description: file does not exist '200': description: file downloaded + "/submitted_content/list_files": + get: + summary: list files and hyperlinks + tags: + - SubmittedContent + parameters: + - name: id + in: query + required: true + schema: + type: string + - name: folder + in: query + schema: + type: object + properties: + name: + type: string + responses: + '200': + description: directory listed + '400': + description: not a directory "/teams_participants/update_duty": put: summary: update participant duty From f03060f8228abe8640bd8e035326b8f4ac342967 Mon Sep 17 00:00:00 2001 From: Shawty 2084 Date: Tue, 2 Dec 2025 21:59:01 -0500 Subject: [PATCH 18/18] Fix SubmittedContent endpoints: file upload, download, and folder creation bugs --- app/controllers/submitted_content_controller.rb | 7 +++++-- app/helpers/submitted_content_helper.rb | 2 +- spec/requests/api/v1/submitted_content_spec.rb | 10 +++++++++- swagger/v1/swagger.yaml | 7 +++++-- 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/app/controllers/submitted_content_controller.rb b/app/controllers/submitted_content_controller.rb index 6e2232ffc..2d991fbdf 100644 --- a/app/controllers/submitted_content_controller.rb +++ b/app/controllers/submitted_content_controller.rb @@ -222,9 +222,12 @@ def download # Sanitize the folder name to prevent directory traversal attacks folder_name = sanitize_folder(folder_name_param) + relative_folder = folder_name.sub(/\A\//, '') - # Build the full path to the requested file - path = File.join(folder_name, file_name) + # Build the full path to the requested file using participant's team path + base_path = @participant.team_path.to_s + full_path = relative_folder.blank? ? base_path : File.join(base_path, relative_folder) + path = File.join(full_path, file_name) # Check if the path is a directory (cannot download directories) if File.directory?(path) diff --git a/app/helpers/submitted_content_helper.rb b/app/helpers/submitted_content_helper.rb index ce8deb565..52a2c0ea3 100644 --- a/app/helpers/submitted_content_helper.rb +++ b/app/helpers/submitted_content_helper.rb @@ -246,7 +246,7 @@ def delete_selected_files # Creates a new folder in the participant's directory def create_new_folder # Build the full path for the new folder - location = File.join(@participant.dir_path, params[:faction][:create]) + location = File.join(@participant.team_path.to_s, params[:faction][:create]) # Wrap the folder creation with error handling handle_file_operation_error('creating directory') do diff --git a/spec/requests/api/v1/submitted_content_spec.rb b/spec/requests/api/v1/submitted_content_spec.rb index e4d6ea757..d497bf738 100644 --- a/spec/requests/api/v1/submitted_content_spec.rb +++ b/spec/requests/api/v1/submitted_content_spec.rb @@ -663,8 +663,16 @@ def create_submission_record(attrs = {}) consumes 'multipart/form-data' parameter name: :Authorization, in: :header, schema: { type: :string } parameter name: :id, in: :query, schema: { type: :string }, required: true - parameter name: :uploaded_file, in: :formData, schema: { type: :string, format: :binary }, required: true parameter name: :current_folder, in: :query, schema: { type: :object, properties: { name: { type: :string } } } + parameter name: :uploaded_file, in: :body, required: true, schema: { + type: :object, + properties: { + uploaded_file: { + type: :string, + format: :binary + } + } + } response(201, 'file submitted') do let(:Authorization) { auth_headers_student['Authorization'] } diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml index b606604ba..e10775fe4 100644 --- a/swagger/v1/swagger.yaml +++ b/swagger/v1/swagger.yaml @@ -1644,8 +1644,11 @@ paths: content: multipart/form-data: schema: - type: string - format: binary + type: object + properties: + uploaded_file: + type: string + format: binary required: true "/submitted_content/folder_action": post: