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/submitted_content_controller.rb b/app/controllers/submitted_content_controller.rb new file mode 100644 index 000000000..129581fbe --- /dev/null +++ b/app/controllers/submitted_content_controller.rb @@ -0,0 +1,375 @@ +class 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, :list_files] + before_action :ensure_participant_team, only: [:submit_hyperlink, :remove_hyperlink, :submit_file, :folder_action, :download, :list_files] + + # 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 /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 /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 + render json: record.errors, status: :unprocessable_content + end + end + + # 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) + 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 + + # 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 + 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 /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 + 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 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 + + # 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) || '/') + + # 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(@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) + + # 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)) + + # Write the file to disk in binary mode + File.open(full_path, 'wb') { |f| f.write(file_bytes) } + + # 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 /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) + 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? + 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 + # 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 /submitted_content/download + # 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) + 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 + + # 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 + + # 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 + 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 + 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 + + # 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 + + # 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 + + # 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 + + # 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 + + # 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, # '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 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..ce8deb565 --- /dev/null +++ b/app/helpers/submitted_content_helper.rb @@ -0,0 +1,260 @@ +module SubmittedContentHelper + include FileHelper + + # 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 + + # 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 + 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) + + # Create parent directories if they don't exist + FileUtils.mkdir_p(File.dirname(file_path)) + + # 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 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 + # 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 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 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 + + # 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 + 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][primary_selected_file_index], + 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][primary_selected_file_index], + 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 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]) + + # Check if file exists before attempting deletion + if File.exist?(file_path) + # 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) + create_directory_from_path(location) + + # Render success response with folder name + render json: { message: "Directory '#{params[:faction][:create]}' created successfully." }, status: :created + 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..4817c91c2 100644 --- a/app/models/assignment_participant.rb +++ b/app/models/assignment_participant.rb @@ -1,9 +1,31 @@ # 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 + # 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 + delegate :path, to: :team, prefix: true, allow_nil: 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..03d3de9f1 100644 --- a/app/models/assignment_team.rb +++ b/app/models/assignment_team.rb @@ -3,7 +3,71 @@ 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' + # Delegation to avoid Law of Demeter violations + delegate :path, to: :assignment, prefix: true + + 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_participants = TeamsParticipant.where(user_id: participant.user_id) + return nil unless teams_participants + + teams_participants.each do |teams_participant| + if teams_participant.team_id.nil? + next + end + team = AssignmentTeam.find(teams_participant.team_id) + return team if team.parent_id == participant.parent_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..35aa87e52 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -9,136 +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 + 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 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/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 462029322..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_04_27_014225) 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" @@ -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,8 @@ t.datetime "created_at", null: false 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" 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..b786e63d2 --- /dev/null +++ b/spec/requests/api/v1/submitted_content_spec.rb @@ -0,0 +1,817 @@ +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(: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)}" } } + 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 '/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_content) + end + end + end + end + + path '/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 '/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, '/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, '/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 include('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 conflict' do + send(method, '/submitted_content/submit_hyperlink', + params: { id: id, submission: submission }, + headers: auth_headers_student) + + expect(response).to have_http_status(:conflict) + parsed = json + expect(parsed['error']).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 bad request with error' do + send(method, '/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 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 '/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, '/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, '/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 include('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 internal server error' do + send(method, '/submitted_content/remove_hyperlink', + params: { id: id, chk_links: chk_links }, + headers: auth_headers_student) + + expect(response).to have_http_status(:internal_server_error) + parsed = json + expect(parsed['error']).to include('Failed to remove 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 '/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, '/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 include('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(SubmittedContentController) + .to receive(:is_file_small_enough).and_return(false) + end + + it 'returns bad request for size limit' do + send(method, '/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 + Rack::Test::UploadedFile.new( + StringIO.new('test content'), + 'application/x-msdownload', + original_filename: 'test.exe' + ) + end + + before do + allow_any_instance_of(SubmittedContentController) + .to receive(:is_file_small_enough).and_return(true) + allow_any_instance_of(SubmittedContentController) + .to receive(:check_extension_integrity).and_return(false) + end + + it 'returns bad request for invalid extension' do + send(method, '/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 extension not allowed') + end + end + + context 'with valid file' do + let(:id) { participant.id } + let(:uploaded_file) do + 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 + allow_any_instance_of(SubmittedContentController) + .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 }, + headers: auth_headers_student) + + expect(response).to have_http_status(:created) + parsed = json + expect(parsed['message']).to eq('The file has been submitted successfully.') + end + end + + context 'with zip file and unzip flag' do + let(:id) { participant.id } + let(:uploaded_file) do + 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 + allow_any_instance_of(SubmittedContentController) + .to receive(:is_file_small_enough).and_return(true) + allow_any_instance_of(SubmittedContentController) + .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) + end + + it 'unzips the file when requested' do + expect(SubmittedContentHelper).to receive(:unzip_file) + + send(method, '/submitted_content/submit_file', + params: { id: id, uploaded_file: uploaded_file, unzip: true }, + headers: auth_headers_student) + + expect(response).to have_http_status(:created) + 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 '/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, '/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 include('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(SubmittedContentController) + .to receive(:delete_selected_files).and_return(nil) + end + + it 'calls delete_selected_files' do + expect_any_instance_of(SubmittedContentController) + .to receive(:delete_selected_files) + + send(method, '/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(SubmittedContentController) + .to receive(:rename_selected_file).and_return(nil) + end + + it 'calls rename_selected_file' do + expect_any_instance_of(SubmittedContentController) + .to receive(:rename_selected_file) + + send(method, '/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(SubmittedContentController) + .to receive(:move_selected_file).and_return(nil) + end + + it 'calls move_selected_file' do + expect_any_instance_of(SubmittedContentController) + .to receive(:move_selected_file) + + send(method, '/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(SubmittedContentController) + .to receive(:copy_selected_file).and_return(nil) + end + + it 'calls copy_selected_file' do + expect_any_instance_of(SubmittedContentController) + .to receive(:copy_selected_file) + + send(method, '/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(SubmittedContentController) + .to receive(:create_new_folder).and_return(nil) + end + + it 'calls create_new_folder' do + expect_any_instance_of(SubmittedContentController) + .to receive(:create_new_folder) + + send(method, '/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 '/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 + # Ensure participant and team are created before the test runs + participant + team + teams_participant + 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['error']).to include('Folder name is required') + 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['error']).to include('File name is required') + 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['error']).to include('Cannot download a directory') + 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['error']).to include('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(SubmittedContentController) + .to receive(:send_file).and_return(nil) + end + + it 'sends the file' do + expect_any_instance_of(SubmittedContentController) + .to receive(:send_file).with(file_path, disposition: 'inline') + + get '/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 '/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 '/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 include('not associated with a team') + end + end + end +end 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: