diff --git a/app/controllers/export_controller.rb b/app/controllers/export_controller.rb new file mode 100644 index 000000000..c4dc8a0a4 --- /dev/null +++ b/app/controllers/export_controller.rb @@ -0,0 +1,151 @@ +# This controller handles exporting data from the application to various formats. +class ExportController < ApplicationController + SUPPORTED_EXPORT_CLASSES = { + "User" => User, + "Team" => Team, + "CourseParticipant" => CourseParticipant, + "AssignmentParticipant" => AssignmentParticipant, + "ProjectTopic" => ProjectTopic, + "Questionnaire" => Questionnaire, + "Item" => Item, + "QuestionAdvice" => QuestionAdvice + }.freeze + + before_action :export_params + + def resolve_export_class(name) + SUPPORTED_EXPORT_CLASSES[name.to_s] + end + + def index + klass = resolve_export_class(params[:class]) + raise ArgumentError, "Unsupported export class: #{params[:class]}" if klass.nil? + + render json: export_metadata_for(klass), status: :ok + rescue StandardError => e + render json: { error: e.message }, status: :unprocessable_entity + end + + def export + # Parse ordered fields from JSON, if provided + ordered_fields = + begin + JSON.parse(params[:ordered_fields]) if params[:ordered_fields] + rescue JSON::ParserError + render json: { error: "Invalid JSON for ordered_fields" }, status: :unprocessable_entity + return + end + + klass = resolve_export_class(params[:class]) + raise ArgumentError, "Unsupported export class: #{params[:class]}" if klass.nil? + + csv_file = if klass == Team + Team.with_assignment_context(params[:assignment_id]) do + Export.perform(klass, ordered_fields) + end + elsif klass == AssignmentParticipant + # AssignmentParticipant export should include only the + # participants for the selected assignment. + AssignmentParticipant.with_assignment_context(params[:assignment_id], current_user) do + named_export_files(Export.perform(klass, ordered_fields), assignment_participant_export_name(params[:assignment_id])) + end + elsif klass == CourseParticipant + # CourseParticipant export should include only the + # participants for the selected course. + CourseParticipant.with_course_context(params[:course_id], current_user) do + named_export_files(Export.perform(klass, ordered_fields), course_participant_export_name(params[:course_id])) + end + elsif klass == ProjectTopic + ProjectTopic.with_assignment_context(params[:assignment_id]) do + Export.perform(klass, ordered_fields) + end + else + Export.perform(klass, ordered_fields) + end + + render json: { + message: "#{params[:class]} has been exported!", + file: csv_file + }, status: :ok + + rescue StandardError => e + render json: { error: e.message }, status: :unprocessable_entity + end + + private + + def export_params + params.permit(:class, :ordered_fields, :assignment_id, :course_id) + end + + def export_metadata_for(klass) + # The participant CSV intentionally exposes username only. Other user + # details are previewed from the users table but not exported as input. + if klass == AssignmentParticipant + AssignmentParticipant.with_assignment_context(params[:assignment_id], current_user) do + return { + mandatory_fields: klass.mandatory_fields, + optional_fields: klass.optional_fields, + external_fields: klass.external_fields + } + end + end + + if klass == CourseParticipant + CourseParticipant.with_course_context(params[:course_id], current_user) do + return { + mandatory_fields: klass.mandatory_fields, + optional_fields: klass.optional_fields, + external_fields: klass.external_fields + } + end + end + + if klass == Team + Team.with_assignment_context(params[:assignment_id]) do + return { + mandatory_fields: klass.mandatory_fields, + optional_fields: klass.optional_fields, + external_fields: klass.external_fields + } + end + end + + if klass == ProjectTopic + ProjectTopic.with_assignment_context(params[:assignment_id]) do + return { + mandatory_fields: klass.mandatory_fields, + optional_fields: klass.optional_fields, + external_fields: klass.external_fields + } + end + end + + { + mandatory_fields: klass.mandatory_fields, + optional_fields: klass.optional_fields, + external_fields: klass.external_fields + } + end + + # Scoped participant exports use readable filenames while keeping the CSV + # body limited to import-friendly fields such as username. + def named_export_files(files, name) + Array(files).map { |file| file.merge(name: name) } + end + + def assignment_participant_export_name(assignment_id) + assignment = Assignment.find_by(id: assignment_id) + scoped_export_name('AssignmentParticipant', assignment&.name, assignment_id) + end + + def course_participant_export_name(course_id) + course = Course.find_by(id: course_id) + scoped_export_name('CourseParticipant', course&.name, course_id) + end + + def scoped_export_name(base_name, scope_name, scope_id) + parts = [base_name, scope_name.presence || 'scope', scope_id.presence].compact + parts.join('_').parameterize(separator: '_') + end +end diff --git a/app/controllers/grades_controller.rb b/app/controllers/grades_controller.rb index 2687d23a6..7aafa4064 100644 --- a/app/controllers/grades_controller.rb +++ b/app/controllers/grades_controller.rb @@ -1,12 +1,17 @@ +require 'csv' + class GradesController < ApplicationController include GradesHelper + GRADES_EXPORT_HEADERS = %w[username grade comment].freeze + GRADES_EXPORT_OPTIONAL_HEADERS = %w[email].freeze + def action_allowed? case params[:action] when 'view_our_scores','view_my_scores' set_participant_and_team_via_assignment current_user_is_assignment_participant?(params[:assignment_id]) - when 'view_all_scores', 'get_review_tableau_data' + when 'view_all_scores', 'get_review_tableau_data', 'export' current_user_teaching_staff_of_assignment?(params[:assignment_id]) when 'edit', 'assign_grade', 'instructor_review' set_team_and_assignment_via_participant @@ -37,6 +42,20 @@ def view_all_scores } end + # export (GET /grades/:assignment_id/export) + # Exports fixed, gradebook-friendly CSV columns for an assignment. + def export + assignment = Assignment.find(params[:assignment_id]) + filename = "#{assignment.name.parameterize.presence || 'assignment'}-grades.csv" + + send_data( + grades_csv_for(assignment, include_email: include_email_in_grades_export?), + type: 'text/csv; charset=utf-8', + disposition: 'attachment', + filename: filename + ) + end + # view_our_scores (GET /grades/:assignment_id/view_our_scores) # similar to view but scoped to the requesting student’s own team. @@ -245,6 +264,32 @@ def set_participant_and_team_via_assignment @assignment = @participant.assignment end + # Export one row per assignment participant. A participant-specific grade + # overrides the team submission grade; submission comments remain team-level. + def grades_csv_for(assignment, include_email: false) + headers = GRADES_EXPORT_HEADERS + (include_email ? GRADES_EXPORT_OPTIONAL_HEADERS : []) + + CSV.generate(headers: true) do |csv| + csv << headers + + assignment.participants.includes(:user).find_each do |participant| + team = participant.team + row = [ + participant.user_name, + participant.grade || team&.grade_for_submission, + team&.comment_for_submission + ] + row << participant.user&.email if include_email + + csv << row + end + end + end + + def include_email_in_grades_export? + ActiveModel::Type::Boolean.new.cast(params[:include_email]) + end + # returns the heatgrid data required for a team to view their scores and average score of their work for an assignment def get_our_scores_data(team) @@ -374,4 +419,4 @@ def get_answer(score, index) reviewee_name: reviewee_name } end -end \ No newline at end of file +end diff --git a/app/controllers/import_controller.rb b/app/controllers/import_controller.rb new file mode 100644 index 000000000..31c875abd --- /dev/null +++ b/app/controllers/import_controller.rb @@ -0,0 +1,191 @@ +# This controller handles importing CSV data into any supported model. +# It exposes two endpoints: +# • GET /import -> returns field requirements for the selected class +# • POST /import -> processes the uploaded CSV file +# +# The controller delegates actual import logic to: +# klass.try_import_records(...) +# +# Each model that supports importing must implement: +# mandatory_fields +# optional_fields +# external_fields +# try_import_records(file, ordered_fields, use_header:) +# + +class ImportController < ApplicationController + SUPPORTED_IMPORT_CLASSES = { + "User" => User, + "Team" => Team, + "CourseParticipant" => CourseParticipant, + "AssignmentParticipant" => AssignmentParticipant, + "ProjectTopic" => ProjectTopic, + "Questionnaire" => Questionnaire, + "Item" => Item, + "QuestionAdvice" => QuestionAdvice + }.freeze + + # Ensure strong parameters are processed before each action + before_action :import_params + + ## + # GET /import + # + # Returns metadata about which fields a given class requires or accepts. + # The frontend uses this to build the mapping UI (drag/drop field matching). + # + def index + imported_class = resolve_import_class!(params[:class]) + + render json: import_metadata_for(imported_class), status: :ok + rescue ArgumentError => e + render json: { error: e.message }, status: :unprocessable_entity + end + + ## + # POST /import + # + # This action performs the actual import process. It: + # 1. Reads the uploaded CSV file + # 2. Determines whether the CSV includes headers + # 3. Applies user-chosen field ordering (if provided) + # 4. Hands off import logic to the model via `try_import_records` + # + def import + uploaded_file = params[:csv_file] + + # Convert use_headers ("true"/"false") into actual boolean + use_headers = ActiveRecord::Type::Boolean.new.deserialize(params[:use_headers]) + + # If the user provided a custom field ordering, load it from JSON + ordered_fields = JSON.parse(params[:ordered_fields]) if params[:ordered_fields] + + # Dynamically load the model class (e.g., "User", "Team", etc.) + klass = resolve_import_class!(params[:class]) + defaults = import_defaults_for(klass) + + # Load the chosen duplicate action (Skip, Update, Change) + dup_action = params[:dup_action]&.constantize + + # AssignmentParticipant import is assignment-scoped and uses username lookup + # rather than the generic table-column importer behavior. + result = if klass == AssignmentParticipant + AssignmentParticipant.with_assignment_context(params[:assignment_id], current_user) do + Import.new(klass: klass, file: uploaded_file, headers: ordered_fields, dup_action: dup_action&.new, defaults: defaults).perform(use_headers) + end + elsif klass == CourseParticipant + CourseParticipant.with_course_context(params[:course_id], current_user) do + Import.new(klass: klass, file: uploaded_file, headers: ordered_fields, dup_action: dup_action&.new, defaults: defaults).perform(use_headers) + end + elsif klass == ProjectTopic + ProjectTopic.with_assignment_context(params[:assignment_id]) do + Import.new(klass: klass, file: uploaded_file, headers: ordered_fields, dup_action: dup_action&.new, defaults: defaults).perform(use_headers) + end + else + Import.new(klass: klass, file: uploaded_file, headers: ordered_fields, dup_action: dup_action&.new, defaults: defaults).perform(use_headers) + end + + # If no exceptions occur, return success + render json: { message: "#{klass.name} has been imported!", **result }, status: :created + + rescue ArgumentError => e + render json: { error: e.message }, status: :unprocessable_entity + rescue StandardError => e + # Catch any unexpected runtime errors + puts "An unexpected error occurred during import: #{e.message}" + + render json: { error: e.message }, status: :unprocessable_entity + end + + private + + ## + # Strong parameters for import operations + # + def import_params + params.permit(:csv_file, :use_headers, :class, :ordered_fields, :dup_action, :assignment_id, :course_id) + end + + def import_defaults_for(klass) + return team_import_defaults if klass == Team + return project_topic_import_defaults if klass == ProjectTopic + return {} unless klass == User && current_user.present? + + { + parent_id: current_user.id, + institution_id: current_user.institution_id + } + end + + def import_metadata_for(imported_class) + # Provide the username-only field list while preserving the shared import + # modal flow used by other importable models. + if imported_class == AssignmentParticipant + AssignmentParticipant.with_assignment_context(params[:assignment_id], current_user) do + return { + mandatory_fields: imported_class.mandatory_fields, + optional_fields: imported_class.optional_fields, + external_fields: imported_class.external_fields, + available_actions_on_dup: imported_class.available_actions_on_duplicate.map { |klass| klass.class.name } + } + end + end + + if imported_class == CourseParticipant + CourseParticipant.with_course_context(params[:course_id], current_user) do + return { + mandatory_fields: imported_class.mandatory_fields, + optional_fields: imported_class.optional_fields, + external_fields: imported_class.external_fields, + available_actions_on_dup: imported_class.available_actions_on_duplicate.map { |klass| klass.class.name } + } + end + end + + if imported_class == Team + Team.with_assignment_context(params[:assignment_id]) do + return { + mandatory_fields: imported_class.mandatory_fields, + optional_fields: imported_class.optional_fields, + external_fields: imported_class.external_fields, + available_actions_on_dup: imported_class.available_actions_on_duplicate.map { |klass| klass.class.name } + } + end + end + + if imported_class == ProjectTopic + ProjectTopic.with_assignment_context(params[:assignment_id]) do + return { + mandatory_fields: imported_class.mandatory_fields, + optional_fields: imported_class.optional_fields, + external_fields: imported_class.external_fields, + available_actions_on_dup: imported_class.available_actions_on_duplicate.map { |klass| klass.class.name } + } + end + end + + { + mandatory_fields: imported_class.mandatory_fields, + optional_fields: imported_class.optional_fields, + external_fields: imported_class.external_fields, + available_actions_on_dup: imported_class.available_actions_on_duplicate.map { |klass| klass.class.name } + } + end + + def team_import_defaults + return {} if params[:assignment_id].blank? + + { assignment_id: params[:assignment_id].to_i } + end + + def project_topic_import_defaults + return {} if params[:assignment_id].blank? + + { assignment_id: params[:assignment_id].to_i } + end + + # Restricts imports to the explicit set of classes currently supported by the API. + def resolve_import_class!(name) + SUPPORTED_IMPORT_CLASSES[name.to_s] || raise(ArgumentError, "Unsupported import class: #{name}") + end +end diff --git a/app/controllers/participants_controller.rb b/app/controllers/participants_controller.rb index 607faa015..a5110f085 100644 --- a/app/controllers/participants_controller.rb +++ b/app/controllers/participants_controller.rb @@ -33,6 +33,22 @@ def list_assignment_participants end end + # Return a list of participants for a given course + # params - course_id + # GET /participants/course/:course_id + def list_course_participants + course = find_course if params[:course_id].present? + return if params[:course_id].present? && course.nil? + + participants = filter_course_participants(course) + + if participants.nil? + render json: participants.errors, status: :unprocessable_entity + else + render json: participants.as_json(include: { user: { include: %i[role parent] } }), status: :ok + end + end + # Return a specified participant # params - id # GET /participants/:id @@ -142,6 +158,13 @@ def filter_assignment_participants(assignment) participants.order(:id) end + # Filters participants based on the provided course + # Returns participants ordered by their IDs + def filter_course_participants(course) + participants = Participant.where(parent_id: course.id, type: 'CourseParticipant') if course + participants.order(:id) + end + # Finds a user based on the user_id parameter # Returns the user if found def find_user @@ -160,6 +183,15 @@ def find_assignment assignment end + # Finds a course based on the course_id parameter + # Returns the course if found + def find_course + course_id = params[:course_id] + course = Course.find_by(id: course_id) + render json: { error: 'Course not found' }, status: :not_found unless course + course + end + # Finds a participant based on the id parameter # Returns the participant if found def find_participant @@ -189,4 +221,4 @@ def validate_authorization authorization end -end \ No newline at end of file +end diff --git a/app/controllers/project_topics_controller.rb b/app/controllers/project_topics_controller.rb index 4b5fbb2f7..6dc8d56bf 100644 --- a/app/controllers/project_topics_controller.rb +++ b/app/controllers/project_topics_controller.rb @@ -8,9 +8,11 @@ def index render json: { message: 'Assignment ID is required!' }, status: :unprocessable_entity elsif params[:topic_ids].nil? @project_topics = ProjectTopic.where(assignment_id: params[:assignment_id]) + .includes(signed_up_teams: { team: :users }) render json: @project_topics, status: :ok else @project_topics = ProjectTopic.where(assignment_id: params[:assignment_id], topic_identifier: params[:topic_ids]) + .includes(signed_up_teams: { team: :users }) render json: @project_topics, status: :ok end # render json: {message: 'All selected topics have been loaded successfully.', project_topics: @stopics}, status: 200 @@ -85,11 +87,11 @@ def destroy # Use callbacks to share common setup or constraints between actions. def set_project_topic - @project_topic = ProjectTopic.find(params[:id]) + @project_topic = ProjectTopic.includes(signed_up_teams: { team: :users }).find(params[:id]) end # Only allow a list of trusted parameters through. def project_topic_params - params.require(:project_topic).permit(:topic_identifier, :category, :topic_name, :max_choosers, :assignment_id) + params.require(:project_topic).permit(:topic_identifier, :category, :topic_name, :max_choosers, :assignment_id, :description, :link) end end diff --git a/app/controllers/questionnaire_packages_controller.rb b/app/controllers/questionnaire_packages_controller.rb new file mode 100644 index 000000000..5de828aaf --- /dev/null +++ b/app/controllers/questionnaire_packages_controller.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +require 'base64' + +# Custom package workflow for questionnaire templates. The generic import/export +# endpoints handle one model at a time, but templates must move questionnaires, +# items, and advice together while excluding responses and quiz data. +class QuestionnairePackagesController < ApplicationController + ALLOWED_DUPLICATE_ACTIONS = { + 'SkipRecordAction' => SkipRecordAction, + 'UpdateExistingRecordAction' => UpdateExistingRecordAction, + 'ChangeOffendingFieldAction' => ChangeOffendingFieldAction + }.freeze + + before_action :questionnaire_package_params + + # Exposes the package contract used by the import modal. + def package_config + render json: { + required_files: QuestionnairePackageImportService::REQUIRED_FILES, + csv_header_requirements: QuestionnairePackageImportService::CSV_HEADER_REQUIREMENTS, + available_templates: available_templates, + package_type: QuestionnairePackageImportService::PACKAGE_TYPE, + version: QuestionnairePackageImportService::VERSION, + available_actions_on_dup: ALLOWED_DUPLICATE_ACTIONS.keys + }, status: :ok + end + + # Downloads blank CSV templates or a full blank package zip. + def template + package_template = QuestionnairePackageTemplateService.new(template_name: params[:template_name]).perform + + render json: { + filename: package_template[:filename], + content_type: package_template[:content_type], + data: Base64.strict_encode64(package_template[:data]) + }, status: :ok + rescue StandardError => e + render json: { error: e.message }, status: :unprocessable_entity + end + + # Returns the related CSVs as one base64 zip for the JSON API. + def export + questionnaire_ids = parse_questionnaire_ids + export_all = ActiveRecord::Type::Boolean.new.deserialize(params[:export_all]) + if questionnaire_ids.blank? && !export_all + render json: { error: 'Select one or more questionnaires to export, or choose export all.' }, status: :unprocessable_entity + return + end + + scope = questionnaire_ids.present? ? Questionnaire.where(id: questionnaire_ids) : Questionnaire.all + package = QuestionnairePackageExportService.new( + questionnaires: scope, + include_question_advices: include_question_advices? + ).perform + + render json: { + message: 'Questionnaire template package has been exported!', + filename: package[:filename], + content_type: package[:content_type], + data: Base64.strict_encode64(package[:data]), + counts: package[:counts] + }, status: :ok + rescue StandardError => e + render json: { error: e.message }, status: :unprocessable_entity + end + + # Imports either an exported zip or role-specific CSV uploads. This stays + # custom because cross-file links are required to rebuild templates correctly. + def import + dup_action = duplicate_action_for(params[:dup_action]) + result = QuestionnairePackageImportService.new( + package_file: params[:package_file], + questionnaire_file: params[:questionnaire_file], + items_file: params[:items_file], + question_advices_file: params[:question_advices_file], + dup_action: dup_action + ).perform + + render json: { message: 'Questionnaire template package has been imported!', **result }, status: :created + rescue StandardError => e + render json: { error: e.message }, status: :unprocessable_entity + end + + # Dry-runs the same inputs as import so users can inspect row actions first. + def preview + dup_action = duplicate_action_for(params[:dup_action]) + result = QuestionnairePackageImportService.new( + package_file: params[:package_file], + questionnaire_file: params[:questionnaire_file], + items_file: params[:items_file], + question_advices_file: params[:question_advices_file], + dup_action: dup_action + ).preview + + render json: result, status: :ok + rescue StandardError => e + render json: { error: e.message }, status: :unprocessable_entity + end + + private + + def available_templates + QuestionnairePackageTemplateService::TEMPLATE_DEFINITIONS.keys + [QuestionnairePackageTemplateService::PACKAGE_TEMPLATE_NAME] + end + + # Permit package-only fields without mixing them into questionnaire params. + def questionnaire_package_params + params.permit( + :package_file, + :questionnaire_file, + :items_file, + :question_advices_file, + :dup_action, + :export_all, + :include_question_advices, + questionnaire_ids: [] + ) + end + + # Defaults to exporting advice CSVs unless the frontend explicitly opts out. + def include_question_advices? + return true unless params.key?(:include_question_advices) + + ActiveRecord::Type::Boolean.new.deserialize(params[:include_question_advices]) + end + + # Multipart export forms may send IDs as an array or JSON string. + def parse_questionnaire_ids + ids = params[:questionnaire_ids] + return ids if ids.is_a?(Array) + return [] if ids.blank? + + JSON.parse(ids) + rescue JSON::ParserError + [] + end + + # Reuse duplicate-action classes through a package-specific allowlist. + def duplicate_action_for(action_name) + return nil if action_name.blank? + + action_class = ALLOWED_DUPLICATE_ACTIONS[action_name] + raise StandardError, "Unsupported duplicate action: #{action_name}" if action_class.nil? + + action_class.new + end +end diff --git a/app/controllers/questionnaires_controller.rb b/app/controllers/questionnaires_controller.rb index 278f70c07..c4630a99c 100644 --- a/app/controllers/questionnaires_controller.rb +++ b/app/controllers/questionnaires_controller.rb @@ -17,6 +17,15 @@ def show render json: $ERROR_INFO.to_s, status: :not_found and return end end + + # Lightweight item list for the package export modal. + # GET on /questionnaires/:id/items + def items + @questionnaire = Questionnaire.find(params[:id]) + render json: @questionnaire.items.order(:seq), status: :ok + rescue ActiveRecord::RecordNotFound + render json: $ERROR_INFO.to_s, status: :not_found + end # Create method creates a questionnaire and returns the JSON object of the created questionnaire # POST on /questionnaires @@ -98,4 +107,4 @@ def sanitize_display_type(type) display_type end -end \ No newline at end of file +end diff --git a/app/controllers/teams_controller.rb b/app/controllers/teams_controller.rb index a24cd23f2..98f0de23b 100644 --- a/app/controllers/teams_controller.rb +++ b/app/controllers/teams_controller.rb @@ -23,7 +23,7 @@ def show # POST /teams # Creates a new team associated with the current user def create - @team = Team.new(team_params) + @team = Team.new(normalized_team_params) if @team.save render json: @team, serializer: TeamSerializer, status: :created else @@ -31,6 +31,26 @@ def create end end + # PATCH/PUT /teams/:id + # Updates a specific team based on ID + def update + if @team.update(normalized_team_params) + render json: @team, serializer: TeamSerializer, status: :ok + else + render json: { errors: @team.errors.full_messages }, status: :unprocessable_entity + end + end + + # DELETE /teams/:id + # Removes a specific team based on ID + def destroy + if @team.destroy + head :no_content + else + render json: { errors: @team.errors.full_messages }, status: :unprocessable_entity + end + end + # GET /teams/:id/members # Lists all members of a specific team def members @@ -94,7 +114,14 @@ def set_team # Whitelists the parameters allowed for team creation/updation def team_params - params.require(:team).permit(:name, :type, :assignment_id) + params.require(:team).permit(:name, :type, :assignment_id, :parent_id) + end + + # Normalizes incoming team params so assignment-backed requests populate parent_id consistently. + def normalized_team_params + permitted = team_params.to_h + permitted[:parent_id] ||= permitted.delete('assignment_id') || permitted.delete(:assignment_id) + permitted end # Whitelists parameters required to add a team member diff --git a/app/helpers/importable_exportable_helper.rb b/app/helpers/importable_exportable_helper.rb new file mode 100644 index 000000000..5c94152cf --- /dev/null +++ b/app/helpers/importable_exportable_helper.rb @@ -0,0 +1,437 @@ +# importable_exportable_helper.rb +# +# =============================================================== +# ExternalClass +# +# Represents a class referenced by another class during import. +# For example: +# - Importing Teams may also need to create Users or Roles +# - Importing Assignments may need to create Topics +# +# This object encodes: +# • Which class is referenced +# • Whether it should be LOOKED UP or CREATED +# • What field should be used to perform look_ups +# +# The importer uses this information to: +# • Map CSV fields to the external class +# • Attempt to find existing referenced objects +# • Create new referenced objects when required +# +# Example: +# ExternalClass.new(User, should_look_up: true, should_create: false, look_up_field: :email) +# =============================================================== +class ExternalClass + attr_accessor :ref_class, :should_look_up, :should_create + + def initialize(ref_class, should_look_up = false, should_create = true, look_up_field = nil) + @ref_class = ref_class # The class being referenced (e.g., User) + @should_look_up = should_look_up # Whether existing objects should be searched for + @should_create = should_create # Whether new objects should be created if no match found + @look_up_field = look_up_field # Column used to identify existing objects + end + + # -------------------------------------------------------------- + # Resolve what fields belong to the external class. + # + # If the class itself includes ImportableExportable, we use its + # internal_fields. Otherwise, we fall back to: + # - the look_up field, or + # - the primary key + # + # All returned fields are namespaced (role_name, user_email, etc.) + # -------------------------------------------------------------- + def fields + if @ref_class.respond_to?(:internal_fields) + @ref_class.internal_fields.map { |field| self.class.append_class_name(@ref_class, field) } + else + [self.class.append_class_name(@ref_class, @look_up_field.to_s), self.class.append_class_name(@ref_class, @ref_class.primary_key)] + end + end + + # -------------------------------------------------------------- + # look_up an external object in the database. + # + # Uses either: + # • a look_up field, or + # • the primary key + # + # It will try both the namespaced version (e.g. role_name) + # and the raw version (name) depending on what exists in the model. + # -------------------------------------------------------------- + def look_up(class_values) + class_name_look_up_field = self.class.append_class_name(@ref_class, @look_up_field.to_s) + class_name_primary_field = self.class.append_class_name(@ref_class, @ref_class.primary_key) + + value = nil + + # ---------- Try look_up field ---------- + if @look_up_field && class_values[class_name_look_up_field] + if @ref_class.attribute_method?(@look_up_field) + value = @ref_class.find_by(@look_up_field => class_values[class_name_look_up_field]) + elsif @ref_class.attribute_method?(class_name_look_up_field) + value = @ref_class.find_by(class_name_look_up_field => class_values[class_name_look_up_field]) + end + + # ---------- Try primary key ---------- + elsif class_values[class_name_primary_field] + if @ref_class.attribute_method?(@ref_class.primary_key) + value = @ref_class.find_by(@ref_class.primary_key => class_values[class_name_primary_field]) + elsif @ref_class.attribute_method?(class_name_primary_field) + value = @ref_class.find_by(class_name_primary_field => class_values[class_name_primary_field]) + end + end + + value + end + + # -------------------------------------------------------------- + # Convert CSV attributes (namespaced) into attributes that match + # the external class (un-namespaced). + # -------------------------------------------------------------- + def from_hash(attrs) + fixed = {} + attrs.each { |k, v| fixed[self.class.unappended_class_name(@ref_class, k)] = v } + @ref_class.new(fixed) + end + + # Prefix column with the class name ("role_name", "user_email") + def self.append_class_name(ref_class, field) + "#{ref_class.name.underscore}_#{field}" + end + + # Remove class name prefix + def self.unappended_class_name(ref_class, name) + name.delete_prefix("#{ref_class.name.underscore}_") + end +end + +# =============================================================== +# ImportableExportableHelper +# +# This module adds import/export metadata and behavior to models. +# +# It supports: +# • mandatory fields +# • optional fields +# • external class definitions +# • combining internal and external fields +# • row-level import logic +# +# Any model including this module becomes import/export capable. +# +# Example: +# +# class Team < ApplicationRecord +# extend ImportableExportableHelper +# mandatory_fields :name +# external_classes ExternalClass.new(User, true, true, :email) +# end +# +# =============================================================== +module ImportableExportableHelper + + # -------------------------------------------------------------- + # When extended by a class, inherit parent import settings. + # + # This allows STI or subclassed models to reuse configuration. + # -------------------------------------------------------------- + def self.extended(base) + if base.superclass.respond_to?(:mandatory_fields) + base.instance_variable_set(:@mandatory_fields, base.superclass.mandatory_fields) + base.instance_variable_set(:@external_classes, base.superclass.external_classes) + base.instance_variable_set(:@class_name, base.superclass.name) + base.instance_variable_set(:@available_actions_on_duplicate, base.superclass.available_actions_on_duplicate) + else + base.instance_variable_set(:@class_name, base.name) + end + end + + ## Provide filter_proc with a custom method to aggregare records and spoof nonexistent models. + ## + def filter(filter_proc = nil) + @filter_method = filter_proc if filter_proc + @filter_method || -> { all } + end + + # -------------------------------------------------------------- + # Define or retrieve mandatory fields. + # These must be present in the CSV. + # -------------------------------------------------------------- + def mandatory_fields(*fields) + if fields.any? + @mandatory_fields = fields.map(&:to_s) - hidden_fields + else + @mandatory_fields + end + end + + def hidden_fields(*fields) + if fields.any? + @hidden_fields = fields.map(&:to_s) + else + @hidden_fields || [] + end + end + + # -------------------------------------------------------------- + # Optional = internal fields - mandatory + # -------------------------------------------------------------- + def optional_fields + unhidden_fields - (mandatory_fields || []) + end + + def unhidden_fields + internal_fields - (hidden_fields || []) + end + + # -------------------------------------------------------------- + # Define or retrieve external classes. + # + # Example: + # external_classes ExternalClass.new(Role, true, false, :name) + # -------------------------------------------------------------- + def external_classes(*fields) + if fields.any? + @external_classes = fields + else + @external_classes || [] + end + end + + # -------------------------------------------------------------- + # Define or retrieve available duplicate actions. + # + # Example: + # available_actions_on_duplicate DuplicateAction, SkipRecordAction + # -------------------------------------------------------------- + def available_actions_on_duplicate(*fields) + if fields.any? + @available_actions_on_duplicate = fields + else + @available_actions_on_duplicate || [] + end + end + + # -------------------------------------------------------------- + # INTERNAL FIELDS + # + # Internal fields come from: + # • database column names + # • mandatory_fields + # + # Then external fields are removed (to prevent duplication). + # -------------------------------------------------------------- + def internal_fields + (column_names + (mandatory_fields || [])).uniq - external_fields - hidden_fields + end + + # -------------------------------------------------------------- + # EXTERNAL FIELDS + # + # Flatten all internal fields from all external class definitions. + # -------------------------------------------------------------- + def external_fields + fields = [] + external_classes&.each { |external_class| fields += external_class.fields } + + fields + end + + # Combined fields for full CSV mapping + def internal_and_external_fields + internal_fields + external_fields + end + + # -------------------------------------------------------------- + # Construct an object from a CSV row hash. + # + # For internal fields, the value is stored as an array during + # parsing, so we take the first element. + # -------------------------------------------------------------- + def from_hash(attrs) + cleaned = {} + attrs.each { |k, v| cleaned[k] = v[0] } + new(cleaned) + end + + # -------------------------------------------------------------- + # Export helper + # Returns a hash of internal fields → values + # -------------------------------------------------------------- + def to_hash(fields = self.class.internal_fields) + fields.to_h { |f| [f, send(f)] } + end + + + # -------------------------------------------------------------- + # MAIN IMPORT WORKFLOW + # + # Creates a temporary file with normalized headers, + # then iterates through rows, importing them one by one. + # + # Duplicate objects are collected and returned. + # -------------------------------------------------------------- + def try_import_records(file, headers, use_header, defaults = {}) + temp_file = 'output.csv' + csv_file = CSV.read(file) + + mapping = [] + + # ---- Normalize header row ---- + CSV.open(temp_file, "w") do |csv| + if use_header + headers = csv_file.shift.map { |h| h.parameterize.underscore } + else + headers = headers.map { |header| header.parameterize.underscore } + end + + mapping = FieldMapping.from_header(self, headers) + + csv << headers + csv_file.each { |row| csv << row } + end + + temp_contents = CSV.read(temp_file) + temp_contents.shift # drop header + + duplicate_records = [] + + ActiveRecord::Base.transaction do + temp_contents.each do |row| + dup = import_row(row, mapping, defaults) + duplicate_records << dup if dup && dup != true + end + + rescue StandardError => e + puts e.message + raise ActiveRecord::Rollback + end + + File.delete(temp_file) + duplicate_records + end + + # -------------------------------------------------------------- + # Import a single row into the current model. + # + # Handles: + # • mapping values + # • building internal object + # • external object look_up/creation + # • save + duplicate capture + # + # Returns: + # • true if saved successfully + # • duplicate object if duplicate occurred + # -------------------------------------------------------------- + def import_row(row, mapping, defaults = {}) + + # Build row_hash where each key maps to all found values + row_hash = {} + mapping.ordered_fields.zip(row).each do |key, value| + row_hash[key] ||= [] + row_hash[key] << value + end + + # Create object for this class + current_class_attrs = row_hash.slice(*internal_fields) + created_object = from_hash(current_class_attrs) + defaults.compact.each do |field, value| + next unless created_object.respond_to?(field) + next if created_object.public_send(field).present? + + created_object.public_send("#{field}=", value) + end + + # for each external class, try to look them up + external_classes&.each do |external_class| + look_up_external_class(row_hash, external_class, created_object) + end + + duplicate = save_object(created_object) + return duplicate if duplicate && duplicate != true + + return true if external_classes.empty? + + external_classes.each do |external_class| + create_external_class(row_hash, external_class, created_object) + end + + true + + end + + private + + # -------------------------------------------------------------- + # Attempt to find an external object via look_up rules. + # If found, attach it to the parent object. + # -------------------------------------------------------------- + def look_up_external_class(row_hash, external_class, parent_obj) + if external_class.should_look_up && (found = external_class.look_up(row_hash)) + parent_obj.send("#{external_class.ref_class.name.downcase}=", found) + nil + end + end + + # -------------------------------------------------------------- + # When look_ups fail AND the external class allows creation, + # build and save new external objects. + # + # Handles multi-row data such as: + # field1: ["A", "B"] + # field2: ["X", "Y"] + # + # Which turns into: + # [{field1: "A", field2: "X"}, {field1: "B", field2: "Y"}] + # -------------------------------------------------------------- + def create_external_class(row_hash, external_class, parent_obj) + return unless external_class.should_create + + current_class_attrs = row_hash.slice(*external_class.fields) + + object_sets = current_class_attrs.values.transpose + object_sets_with_keys = object_sets.map do |row_values| + Hash[current_class_attrs.keys.zip(row_values)] + end + + object_sets_with_keys.each do |attrs| + created_object = external_class.from_hash(attrs) + + # Set relationship to parent + created_object.send("#{@class_name.underscore}=", parent_obj) + + save_object(created_object) + end + end + + # -------------------------------------------------------------- + # Save an object safely, detecting: + # • Validation errors + # • Uniqueness violations + # + # Returns: + # • created_object on uniqueness error (for duplicate workflow) + # • true if saved + # -------------------------------------------------------------- + def save_object(created_object) + created_object.save! + rescue ActiveRecord::RecordInvalid => e + # Check if a specific attribute has a :uniqueness error + is_taken = created_object.errors.details.any? { |attribute, error_details_array| error_details_array.any? { |detail_hash| detail_hash[:error] == :taken } } + multiple_field_errors = created_object.errors.details.size > 1 + single_field_errors = created_object.errors.details.any? { |attribute, error_details_array| error_details_array.size > 1} + + if is_taken && !multiple_field_errors && !single_field_errors + return created_object + end + + raise StandardError.new(e.message) + + + rescue ActiveRecord::RecordNotUnique => e + puts "Unique constraint violation: #{e.message}" + created_object + end +end diff --git a/app/models/Item.rb b/app/models/Item.rb index 0b6535228..def6484b2 100644 --- a/app/models/Item.rb +++ b/app/models/Item.rb @@ -1,9 +1,16 @@ # frozen_string_literal: true class Item < ApplicationRecord + extend ImportableExportableHelper + mandatory_fields :txt, :weight, :seq, :question_type, :break_before, :questionnaire_name + hidden_fields :id, :created_at, :updated_at + external_classes ExternalClass.new(Questionnaire, true, false, :name) + filter nil before_create :set_seq belongs_to :questionnaire # each item belongs to a specific questionnaire has_many :answers, dependent: :destroy, foreign_key: 'item_id' + # Lets package export include template scoring advice. + has_many :question_advices, dependent: :destroy, foreign_key: 'item_id' attr_accessor :choice_strategy validates :seq, presence: true, numericality: true # sequence must be numeric @@ -91,4 +98,4 @@ def self.for(record) # Cast the existing record to the desired subclass klass.new(record.attributes) end -end \ No newline at end of file +end diff --git a/app/models/answer.rb b/app/models/answer.rb index 3c3320afe..98dc95ac7 100644 --- a/app/models/answer.rb +++ b/app/models/answer.rb @@ -1,6 +1,13 @@ # frozen_string_literal: true class Answer < ApplicationRecord + extend ImportableExportableHelper + mandatory_fields :answer, :comments, :item_seq + hidden_fields :id, :created_at, :updated_at + external_classes ExternalClass.new(Item, true, false, :seq), + ExternalClass.new(Response, true, false, :additional_comment) + + filter nil belongs_to :response belongs_to :item end diff --git a/app/models/assignment_participant.rb b/app/models/assignment_participant.rb index f3f1f38b2..c585ad532 100644 --- a/app/models/assignment_participant.rb +++ b/app/models/assignment_participant.rb @@ -1,7 +1,18 @@ # frozen_string_literal: true +require 'csv' + class AssignmentParticipant < Participant + extend ImportableExportableHelper include ReviewAggregator + PARTICIPANT_IMPORT_EXPORT_FIELDS = %w[ + user_name + ].freeze + + mandatory_fields :user_name + available_actions_on_duplicate SkipRecordAction.new, UpdateExistingRecordAction.new + filter -> { export_scope } + has_many :sent_invitations, class_name: 'Invitation', foreign_key: 'from_id' has_many :review_mappings, class_name: 'ReviewResponseMap', foreign_key: 'reviewee_id' has_many :response_maps, foreign_key: 'reviewee_id' @@ -53,4 +64,133 @@ def retract_sent_invitations def aggregate_teammate_review_grade(teammate_review_mappings) compute_average_review_score(teammate_review_mappings) end + + def user_name + user&.name + end + + class << self + # Import/export exposes a deliberately small CSV surface. Assignment + # participants are existing users attached to an assignment, so the CSV + # should identify the user and avoid editing user profile data. + def internal_fields + PARTICIPANT_IMPORT_EXPORT_FIELDS + end + + def optional_fields + PARTICIPANT_IMPORT_EXPORT_FIELDS - mandatory_fields + end + + def external_fields + [] + end + + def internal_and_external_fields + internal_fields + end + + # The shared import/export controllers are model-oriented, but assignment + # participants must be scoped to one assignment. Store that request context + # for the duration of the import/export operation. + def with_assignment_context(assignment_id, current_user = nil) + previous_assignment_id = import_export_assignment_id + previous_current_user = import_export_current_user + self.import_export_assignment_id = assignment_id + self.import_export_current_user = current_user + yield + ensure + self.import_export_assignment_id = previous_assignment_id + self.import_export_current_user = previous_current_user + end + + # Import a username-only CSV and attach each existing user to the current + # assignment. This intentionally does not create or update User records. + def try_import_records(file, headers, use_header, _defaults = {}) + assignment_id = import_export_assignment_id + raise StandardError, 'assignment_id is required for participant import' if assignment_id.blank? + + csv_table = CSV.read(file, headers: use_header) + normalized_headers = + if use_header + csv_table.headers.map { |header| header.to_s.parameterize.underscore } + else + Array(headers).map { |header| header.to_s.parameterize.underscore } + end + + mapping = FieldMapping.from_header(self, normalized_headers) + validate_import_mapping!(mapping) + rows = use_header ? csv_table.map(&:fields) : csv_table + + ActiveRecord::Base.transaction do + rows.each do |row| + import_participant_row(row, mapping, assignment_id) + end + end + + [] + end + + private + + # Keep the CSV contract explicit so a missing or misspelled username column + # fails before any participants are changed. + def validate_import_mapping!(mapping) + missing_fields = mandatory_fields - mapping.ordered_fields + return if missing_fields.empty? + + raise StandardError, "Missing mandatory participant fields: #{missing_fields.join(', ')}" + end + + # Create the assignment participant link for the resolved user, or reuse the + # existing participant if the user is already attached to this assignment. + def import_participant_row(row, mapping, assignment_id) + row_hash = {} + mapping.ordered_fields.zip(row).each do |key, value| + row_hash[key] = value + end + + user = find_import_user(row_hash) + participant = find_or_initialize_by( + parent_id: assignment_id, + user_id: user.id, + type: name + ) + + participant.handle = row_hash['handle'].presence || participant.handle || user.name + participant.save! + end + + # Username import is a lookup, not a user creation path. This prevents an + # instructor import from accidentally adding malformed or duplicate users. + def find_import_user(row_hash) + username = row_hash['user_name'].to_s.strip + user = User.find_by(name: username) + return user if user + + raise StandardError, "User '#{username}' was not found. Assignment participant import expects existing users." + end + + # Export only assignment participants in the active assignment context when + # one is provided by the controller. + def export_scope + scope = includes(:user).where(type: name) + import_export_assignment_id.present? ? scope.where(parent_id: import_export_assignment_id) : scope + end + + def import_export_assignment_id + Thread.current[:assignment_participant_import_export_assignment_id] + end + + def import_export_assignment_id=(assignment_id) + Thread.current[:assignment_participant_import_export_assignment_id] = assignment_id.presence&.to_i + end + + def import_export_current_user + Thread.current[:assignment_participant_import_export_current_user] + end + + def import_export_current_user=(user) + Thread.current[:assignment_participant_import_export_current_user] = user + end + end end diff --git a/app/models/course.rb b/app/models/course.rb index f33f1875f..bdd1fccc6 100644 --- a/app/models/course.rb +++ b/app/models/course.rb @@ -6,6 +6,7 @@ class Course < ApplicationRecord has_many :assignments, dependent: :destroy validates :name, presence: true validates :directory_path, presence: true + has_many :course_participants, class_name: 'CourseParticipant', foreign_key: 'parent_id', dependent: :destroy, inverse_of: :course has_many :participants, class_name: 'CourseParticipant', foreign_key: 'parent_id', dependent: :destroy, inverse_of: :course has_many :users, through: :course_participants, inverse_of: :course has_many :ta_mappings, dependent: :destroy @@ -56,4 +57,5 @@ def copy_course new_course.name += '_copy' new_course.save end + end diff --git a/app/models/course_participant.rb b/app/models/course_participant.rb index 59d83a842..a106d7bf2 100644 --- a/app/models/course_participant.rb +++ b/app/models/course_participant.rb @@ -1,25 +1,150 @@ # frozen_string_literal: true +require 'csv' + class CourseParticipant < Participant + extend ImportableExportableHelper + + PARTICIPANT_IMPORT_EXPORT_FIELDS = %w[ + user_name + ].freeze + + mandatory_fields :user_name + available_actions_on_duplicate SkipRecordAction.new, UpdateExistingRecordAction.new + filter -> { export_scope } + belongs_to :user validates :handle, presence: true + def user_name + user&.name + end + def set_handle - # normalize the user’s preferred handle + # Normalize the user's preferred handle. desired = user.handle.to_s.strip self.handle = if desired.empty? - # no handle on the user, fall back to their name user.name elsif CourseParticipant.exists?(parent_id: course.id, handle: desired) - # someone else in this course already has that handle user.name else - # it’s unique, so use it desired end save end + + class << self + # Course participants are existing users attached to a course. Keep the CSV + # surface narrow so imports cannot accidentally create or edit users. + def internal_fields + PARTICIPANT_IMPORT_EXPORT_FIELDS + end + + def optional_fields + PARTICIPANT_IMPORT_EXPORT_FIELDS - mandatory_fields + end + + def external_fields + [] + end + + def internal_and_external_fields + internal_fields + end + + def with_course_context(course_id, current_user = nil) + previous_course_id = import_export_course_id + previous_current_user = import_export_current_user + self.import_export_course_id = course_id + self.import_export_current_user = current_user + yield + ensure + self.import_export_course_id = previous_course_id + self.import_export_current_user = previous_current_user + end + + def try_import_records(file, headers, use_header, _defaults = {}) + course_id = import_export_course_id + raise StandardError, 'course_id is required for course participant import' if course_id.blank? + raise StandardError, "Course '#{course_id}' was not found." unless Course.exists?(course_id) + + csv_table = CSV.read(file, headers: use_header) + normalized_headers = + if use_header + csv_table.headers.map { |header| header.to_s.parameterize.underscore } + else + Array(headers).map { |header| header.to_s.parameterize.underscore } + end + + mapping = FieldMapping.from_header(self, normalized_headers) + validate_import_mapping!(mapping) + rows = use_header ? csv_table.map(&:fields) : csv_table + + ActiveRecord::Base.transaction do + rows.each do |row| + import_participant_row(row, mapping, course_id) + end + end + + [] + end + + private + + def validate_import_mapping!(mapping) + missing_fields = mandatory_fields - mapping.ordered_fields + return if missing_fields.empty? + + raise StandardError, "Missing mandatory course participant fields: #{missing_fields.join(', ')}" + end + + def import_participant_row(row, mapping, course_id) + row_hash = {} + mapping.ordered_fields.zip(row).each do |key, value| + row_hash[key] = value + end + + user = find_import_user(row_hash) + participant = find_or_initialize_by( + parent_id: course_id, + user_id: user.id, + type: name + ) + + participant.handle = participant.handle.presence || user.handle.presence || user.name + participant.save! + end + + def find_import_user(row_hash) + username = row_hash['user_name'].to_s.strip + user = User.find_by(name: username) + return user if user + + raise StandardError, "User '#{username}' was not found. Course participant import expects existing users." + end + + def export_scope + scope = includes(:user).where(type: name) + import_export_course_id.present? ? scope.where(parent_id: import_export_course_id) : scope + end + + def import_export_course_id + Thread.current[:course_participant_import_export_course_id] + end + + def import_export_course_id=(course_id) + Thread.current[:course_participant_import_export_course_id] = course_id.presence&.to_i + end + + def import_export_current_user + Thread.current[:course_participant_import_export_current_user] + end + + def import_export_current_user=(user) + Thread.current[:course_participant_import_export_current_user] = user + end + end end diff --git a/app/models/multiple_choice_checkbox.rb b/app/models/multiple_choice_checkbox.rb index a3ec77e99..e31ebddfb 100644 --- a/app/models/multiple_choice_checkbox.rb +++ b/app/models/multiple_choice_checkbox.rb @@ -4,7 +4,7 @@ class MultipleChoiceCheckbox < QuizItem def edit - quiz_question_choices = QuizQuestionChoice.where(question_id: id) + quiz_question_choices = QuizQuestionChoice.where(item_id: id) data = { id: id, @@ -24,7 +24,7 @@ def edit end def complete - quiz_question_choices = QuizQuestionChoice.where(question_id: id) + quiz_question_choices = QuizQuestionChoice.where(item_id: id) data = { id: id, @@ -38,7 +38,7 @@ def complete end def view_completed_item(user_answer) - quiz_question_choices = QuizQuestionChoice.where(question_id: id) + quiz_question_choices = QuizQuestionChoice.where(item_id: id) data = { question_choices: quiz_question_choices.map do |choice| diff --git a/app/models/multiple_choice_radio.rb b/app/models/multiple_choice_radio.rb index 910659074..0c5fdbfbd 100644 --- a/app/models/multiple_choice_radio.rb +++ b/app/models/multiple_choice_radio.rb @@ -4,7 +4,7 @@ class MultipleChoiceRadio < QuizItem def edit - quiz_question_choices = QuizQuestionChoice.where(question_id: id) + quiz_question_choices = QuizQuestionChoice.where(item_id: id) choices = quiz_question_choices.map.with_index(1) do |choice, index| { @@ -24,7 +24,7 @@ def edit end def complete - quiz_question_choices = QuizQuestionChoice.where(question_id: id) + quiz_question_choices = QuizQuestionChoice.where(item_id: id) choices = quiz_question_choices.map.with_index(1) do |choice, index| { @@ -35,14 +35,14 @@ def complete end { - question_id: id, + item_id: id, question_text: txt, choices: choices }.to_json end def view_completed_item(user_answer) - quiz_question_choices = QuizQuestionChoice.where(question_id: id) + quiz_question_choices = QuizQuestionChoice.where(item_id: id) choices = quiz_question_choices.map do |choice| { diff --git a/app/models/project_topic.rb b/app/models/project_topic.rb index 3627be58b..f350cba7d 100644 --- a/app/models/project_topic.rb +++ b/app/models/project_topic.rb @@ -1,8 +1,15 @@ class ProjectTopic < ApplicationRecord + extend ImportableExportableHelper + mandatory_fields :topic_name + hidden_fields :id, :created_at, :updated_at + available_actions_on_duplicate SkipRecordAction.new, UpdateExistingRecordAction.new, ChangeOffendingFieldAction.new + filter -> { export_scope } + has_many :signed_up_teams, dependent: :destroy has_many :teams, through: :signed_up_teams + has_many :assignment_questionnaires, class_name: 'AssignmentQuestionnaire', foreign_key: 'topic_id', dependent: :destroy + has_many :due_dates, as: :parent, class_name: 'DueDate', dependent: :destroy belongs_to :assignment - # Ensures the number of max choosers is non-negative validates :max_choosers, numericality: { only_integer: true, @@ -86,4 +93,28 @@ def promote_waitlisted_team def remove_from_waitlist(team) team.signed_up_teams.waitlisted.where.not(project_topic_id: id).destroy_all end + + class << self + def with_assignment_context(assignment_id) + previous_assignment_id = import_export_assignment_id + self.import_export_assignment_id = assignment_id + yield + ensure + self.import_export_assignment_id = previous_assignment_id + end + + private + + def export_scope + import_export_assignment_id.present? ? where(assignment_id: import_export_assignment_id) : all + end + + def import_export_assignment_id + Thread.current[:project_topic_import_export_assignment_id] + end + + def import_export_assignment_id=(assignment_id) + Thread.current[:project_topic_import_export_assignment_id] = assignment_id.presence&.to_i + end + end end diff --git a/app/models/question_advice.rb b/app/models/question_advice.rb index 76b54c56d..0ba81e909 100644 --- a/app/models/question_advice.rb +++ b/app/models/question_advice.rb @@ -1,24 +1,29 @@ # frozen_string_literal: true class QuestionAdvice < ApplicationRecord - belongs_to :item - def self.export_fields(_options) - QuestionAdvice.columns.map(&:name) - end - - def self.export(csv, parent_id, _options) - questionnaire = Questionnaire.find(parent_id) - questionnaire.items.each do |item| - QuestionAdvice.where(question_id: item.id).each do |advice| - csv << advice.attributes.values - end + extend ImportableExportableHelper + mandatory_fields :score, :advice, :item_seq + hidden_fields :id, :created_at, :updated_at + external_classes ExternalClass.new(Item, true, false, :seq) + filter nil + belongs_to :item + def self.export_fields(_options) + QuestionAdvice.columns.map(&:name) + end + + def self.export(csv, parent_id, _options) + questionnaire = Questionnaire.find(parent_id) + questionnaire.items.each do |item| + QuestionAdvice.where(item_id: item.id).each do |advice| + csv << advice.attributes.values end end - - def self.to_json_by_question_id(question_id) - question_advices = QuestionAdvice.where(question_id: question_id).order(:id) - question_advices.map do |advice| - { score: advice.score, advice: advice.advice } - end + end + + def self.to_json_by_question_id(question_id) + question_advices = QuestionAdvice.where(item_id: question_id).order(:id) + question_advices.map do |advice| + { score: advice.score, advice: advice.advice } end - end \ No newline at end of file + end +end diff --git a/app/models/questionnaire.rb b/app/models/questionnaire.rb index b7eeee017..98a4b4d1e 100644 --- a/app/models/questionnaire.rb +++ b/app/models/questionnaire.rb @@ -1,10 +1,15 @@ # frozen_string_literal: true class Questionnaire < ApplicationRecord + extend ImportableExportableHelper + mandatory_fields :name, :min_question_score, :max_question_score, :questionnaire_type, :display_type, :instructor_name + hidden_fields :id, :created_at, :updated_at, :instruction_loc + external_classes ExternalClass.new(Instructor, true, false, :name) + available_actions_on_duplicate SkipRecordAction.new, UpdateExistingRecordAction.new, ChangeOffendingFieldAction.new + filter nil belongs_to :instructor has_many :items, class_name: "Item", foreign_key: "questionnaire_id", dependent: :destroy # the collection of items associated with this Questionnaire before_destroy :check_for_question_associations - validate :validate_questionnaire validates :name, presence: true validates :max_question_score, :min_question_score, numericality: true diff --git a/app/models/quiz_item.rb b/app/models/quiz_item.rb index 8367ca20e..4bfce92c0 100644 --- a/app/models/quiz_item.rb +++ b/app/models/quiz_item.rb @@ -3,8 +3,10 @@ require 'json' class QuizItem < Item - has_many :quiz_question_choices, class_name: 'QuizQuestionChoice', foreign_key: 'question_id', inverse_of: false, dependent: :nullify - + extend ImportableExportableHelper + hidden_fields :id, :created_at, :updated_at + has_many :quiz_question_choices, class_name: 'QuizQuestionChoice', foreign_key: 'item_id', inverse_of: false, dependent: :nullify + filter nil def edit end diff --git a/app/models/role.rb b/app/models/role.rb index 32c3221d0..8897ec192 100644 --- a/app/models/role.rb +++ b/app/models/role.rb @@ -6,11 +6,11 @@ class Role < ApplicationRecord has_many :users, dependent: :nullify # Role IDs - STUDENT_ID = 1 - TEACHING_ASSISTANT_ID = 2 + STUDENT_ID = 5 + TEACHING_ASSISTANT_ID = 4 INSTRUCTOR_ID = 3 - ADMINISTRATOR_ID = 4 - SUPER_ADMINISTRATOR_ID = 5 + ADMINISTRATOR_ID = 2 + SUPER_ADMINISTRATOR_ID = 1 def super_administrator? name['Super Administrator'] diff --git a/app/models/team.rb b/app/models/team.rb index e2c39fbaa..fde14595d 100644 --- a/app/models/team.rb +++ b/app/models/team.rb @@ -1,6 +1,39 @@ # frozen_string_literal: true class Team < ApplicationRecord + extend ImportableExportableHelper + TEAM_PARTICIPANT_COLUMN_PREFIX = 'participant_' + DEFAULT_TEAM_IMPORT_EXPORT_PARTICIPANT_COLUMNS = 10 + mandatory_fields :participant_1 + hidden_fields :id, :created_at, :updated_at + available_actions_on_duplicate SkipRecordAction.new, UpdateExistingRecordAction.new, ChangeOffendingFieldAction.new + filter -> { export_rows } + TeamExportRow = Struct.new(:team, :participants) do + # Normalizes exported rows so missing participant slots return nil cleanly. + def initialize(team, participants) + super(team, participants) + self.participants ||= [] + end + + # Exposes the wrapped team's name as the exported row's base column. + def name + team.name + end + + # Dynamically resolves participant_N export columns to participant usernames by position. + def method_missing(method_name, *_args) + method = method_name.to_s + return super unless method.start_with?(TEAM_PARTICIPANT_COLUMN_PREFIX) + + index = method.delete_prefix(TEAM_PARTICIPANT_COLUMN_PREFIX).to_i - 1 + participants[index]&.user&.name + end + + # Advertises support for participant_N dynamic columns during export. + def respond_to_missing?(method_name, include_private = false) + method_name.to_s.start_with?(TEAM_PARTICIPANT_COLUMN_PREFIX) || super + end + end # Core associations has_many :signed_up_teams, dependent: :destroy @@ -10,6 +43,7 @@ class Team < ApplicationRecord has_many :users, through: :teams_participants has_many :participants, through: :teams_participants has_many :join_team_requests, dependent: :destroy + has_many :sent_invitations, class_name: 'Invitation', foreign_key: 'from_id', dependent: :destroy # The team is either an AssignmentTeam or a CourseTeam belongs_to :assignment, class_name: 'Assignment', foreign_key: 'parent_id', optional: true @@ -21,16 +55,17 @@ class Team < ApplicationRecord validates :type, presence: true, inclusion: { in: %w[AssignmentTeam CourseTeam MentoredTeam], message: "must be 'Assignment' or 'Course' or 'Mentor'" } after_update :release_topics_if_empty + before_destroy :clear_participant_team_references def has_member?(user) participants.exists?(user_id: user.id) end - + # Returns the current number of team members def team_size users.count end - + # Returns the maximum allowed team size def max_size if is_a?(AssignmentTeam) && assignment&.max_team_size @@ -41,7 +76,7 @@ def max_size nil end end - + def full? current_size = participants.count @@ -54,6 +89,7 @@ def full? false end + # Returns true when the given user already belongs to this team. # Checks if the given participant is already on any team for the associated assignment or course. def participant_on_team?(participant) # pick the correct “scope” (assignment or course) based on this team’s class @@ -115,10 +151,10 @@ def remove_member(participant) # Remove the join record if it exists tp = TeamsParticipant.find_by(team_id: id, participant_id: participant.id) tp&.destroy - + # Update the participant's team_id column - will remove the team reference inside participants table later. keeping it for now # this will remove the reference only if the participant's current team is the same team removing the participant - if participant.team_id==id + if participant.team_id == id participant.update!(team_id: nil) end @@ -155,13 +191,193 @@ def can_participant_join_team?(participant) # All checks passed; participant is eligible to join the team { success: true } - end private + # Clears legacy participant.team_id pointers before deleting the team record. + def clear_participant_team_references + Participant.where(team_id: id).update_all(team_id: nil) + end + + # Releases any claimed topics when the team becomes empty. def release_topics_if_empty return unless participants.empty? project_topics.each { |topic| topic.drop_team(self) } end + + class << self + # Returns the full import/export field list, including dynamic participant columns. + def internal_fields + ['name'] + participant_field_names + end + + # Treats team name and extra participant columns as optional CSV fields. + def optional_fields + (['name'] + participant_field_names) - mandatory_fields + end + + # Team import/export relies only on internal fields, with no external lookup columns. + def external_fields + [] + end + + # Returns the full CSV contract for teams without additional external fields. + def internal_and_external_fields + internal_fields + end + + # Builds lightweight export rows that expose participant usernames in stable column order. + def export_rows + export_scope.includes(participants: :user).map do |team| + TeamExportRow.new(team, team.participants.order(:id).to_a) + end + end + + # Imports teams from CSV rows and attaches participants by exported username columns. + def try_import_records(file, headers, use_header, defaults = {}) + csv_table = CSV.read(file, headers: use_header) + normalized_headers = + if use_header + csv_table.headers.map { |header| header.to_s.parameterize.underscore } + else + Array(headers).map { |header| header.to_s.parameterize.underscore } + end + + mapping = FieldMapping.from_header(self, normalized_headers) + validate_import_mapping!(mapping) + rows = use_header ? csv_table.map(&:fields) : csv_table + + ActiveRecord::Base.transaction do + rows.each do |row| + import_team_row(row, mapping, defaults) + end + end + + [] + end + + def with_assignment_context(assignment_id) + previous_assignment_id = import_export_assignment_id + self.import_export_assignment_id = assignment_id + yield + ensure + self.import_export_assignment_id = previous_assignment_id + end + + private + + # Imports a single team row, creating the team and linking any listed participants. + def import_team_row(row, mapping, defaults) + row_hash = {} + mapping.ordered_fields.zip(row).each do |key, value| + row_hash[key] = value + end + validate_import_row!(row_hash) + + team = find_or_build_import_team(row_hash, defaults) + team.save! if team.new_record? || team.changed? + + participant_values_from_row(row_hash).each do |participant_value| + participant = find_import_participant(team, participant_value) + next unless participant + next if team.participants.exists?(id: participant.id) + + result = team.add_member(participant) + next if result[:success] + + raise StandardError, result[:error] + end + end + + # Requires at least the first participant username column for team imports. + def validate_import_mapping!(mapping) + missing_fields = mandatory_fields - mapping.ordered_fields + return if missing_fields.empty? + + raise StandardError, "Missing required fields: #{missing_fields.join(', ')}" + end + + # Rejects rows where the required participant username cell is blank. + def validate_import_row!(row_hash) + return if row_hash['participant_1'].present? + + raise StandardError, 'participant_1 is required for team import' + end + + # Finds an existing assignment team by name or initializes it within the current assignment context. + def find_or_build_import_team(row_hash, defaults) + assignment_id = defaults[:assignment_id] || import_export_assignment_id + raise StandardError, 'assignment_id is required for team import' if assignment_id.blank? + + name = row_hash['name'].presence || generated_team_name(row_hash, assignment_id) + + find_or_initialize_by(name: name, type: 'AssignmentTeam', parent_id: assignment_id) + end + + # Builds a stable fallback name when team CSVs are organized by usernames only. + def generated_team_name(row_hash, assignment_id) + usernames = participant_values_from_row(row_hash) + raise StandardError, 'participant_1 is required for team import' if usernames.empty? + + base_name = usernames.join('_').parameterize(separator: '_').presence || 'team' + "Team_#{assignment_id}_#{base_name}" + end + + # Resolves a participant username into the correct participant subtype for the team. + def find_import_participant(team, participant_value) + participant_class = participant_class_for(team.type) + value = participant_value.to_s.strip + return if value.blank? + + participant_class + .joins(:user) + .find_by(parent_id: team.parent_id, users: { name: value }) + end + + # Chooses the participant model that matches the imported team subtype. + def participant_class_for(team_type) + %w[AssignmentTeam MentoredTeam].include?(team_type) ? AssignmentParticipant : CourseParticipant + end + + # Generates participant_1..participant_N column names for team CSVs. + def participant_field_names + (1..participant_column_count).map { |index| "#{TEAM_PARTICIPANT_COLUMN_PREFIX}#{index}" } + end + + # Sizes the participant column set using assignment context, falling back to a fixed default. + def participant_column_count + assignment = Assignment.find_by(id: import_export_assignment_id) if import_export_assignment_id.present? + return DEFAULT_TEAM_IMPORT_EXPORT_PARTICIPANT_COLUMNS unless assignment + return assignment.max_team_size if assignment.max_team_size.present? + return assignment.participants.count if assignment.participants.count.positive? + + DEFAULT_TEAM_IMPORT_EXPORT_PARTICIPANT_COLUMNS + end + + # Extracts non-blank participant usernames from the current imported row. + def participant_values_from_row(row_hash) + row_hash + .slice(*participant_field_names) + .values + .map(&:presence) + .compact + end + + # Limits team export rows to assignment-scoped team types, optionally within one assignment. + def export_scope + scope = where(type: %w[AssignmentTeam MentoredTeam]) + import_export_assignment_id.present? ? scope.where(parent_id: import_export_assignment_id) : scope + end + + # Stores the current assignment import/export scope in thread-local state. + def import_export_assignment_id + Thread.current[:team_import_export_assignment_id] + end + + # Sets the current assignment import/export scope in thread-local state. + def import_export_assignment_id=(assignment_id) + Thread.current[:team_import_export_assignment_id] = assignment_id.presence&.to_i + end + end end diff --git a/app/models/user.rb b/app/models/user.rb index 0e77e25dc..0af55f920 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,9 +1,17 @@ # frozen_string_literal: true class User < ApplicationRecord + extend ImportableExportableHelper + mandatory_fields :name, :email, :password, :full_name, :role_name, :institution_name + hidden_fields :id, :created_at, :updated_at + external_classes ExternalClass.new(Role, true, false, :name), + ExternalClass.new(Institution, true, false, :name) + available_actions_on_duplicate SkipRecordAction.new, UpdateExistingRecordAction.new, ChangeOffendingFieldAction.new + filter nil has_secure_password after_initialize :set_defaults + # name must be lowercase and unique validates :name, presence: true, uniqueness: true, allow_blank: false # format: { with: /\A[a-z]+\z/, message: 'must be in lowercase' } @@ -34,14 +42,14 @@ class User < ApplicationRecord delegate :super_administrator?, to: :role def self.instantiate(record) - case record.role - when Role::TEACHING_ASSISTANT + case record.role.id + when Role::TEACHING_ASSISTANT_ID record.becomes(Ta) - when Role::INSTRUCTOR + when Role::INSTRUCTOR_ID record.becomes(Instructor) - when Role::ADMINISTRATOR + when Role::ADMINISTRATOR_ID record.becomes(Administrator) - when Role::SUPER_ADMINISTRATOR + when Role::SUPER_ADMINISTRATOR_ID record.becomes(SuperAdministrator) else super diff --git a/app/serializers/project_topic_serializer.rb b/app/serializers/project_topic_serializer.rb new file mode 100644 index 000000000..5c830e359 --- /dev/null +++ b/app/serializers/project_topic_serializer.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class ProjectTopicSerializer < ActiveModel::Serializer + attributes :id, :topic_identifier, :topic_name, :assignment_id, :max_choosers, + :category, :description, :link, :created_at, :updated_at, + :available_slots, :confirmed_teams, :waitlisted_teams + + # Exposes the remaining capacity after confirmed team signups are accounted for. + def available_slots + object.available_slots + end + + # Serializes confirmed teams into the frontend topic-team shape. + def confirmed_teams + serialize_teams(object.confirmed_teams) + end + + # Serializes waitlisted teams into the frontend topic-team shape. + def waitlisted_teams + serialize_teams(object.waitlisted_teams) + end + + private + + # Converts team records into the lightweight nested structure expected by topic-management screens. + def serialize_teams(teams) + teams.includes(:users).map do |team| + { + teamId: team.id.to_s, + members: team.users.map do |user| + { + id: user.id.to_s, + name: user.full_name.presence || user.name + } + end + } + end + end +end diff --git a/app/serializers/team_serializer.rb b/app/serializers/team_serializer.rb index cbc797761..ae0a79045 100644 --- a/app/serializers/team_serializer.rb +++ b/app/serializers/team_serializer.rb @@ -1,25 +1,34 @@ # frozen_string_literal: true class TeamSerializer < ActiveModel::Serializer - attributes :id, :name, :type, :team_size + attributes :id, :name, :type, :team_size, :parent_id, :assignment_id has_many :members, serializer: ParticipantSerializer has_many :users, serializer: UserSerializer + # Serializes participants through the join table so team membership matches the current team roster. def members # Use teams_participants association to get participants object.teams_participants.includes(:participant).map(&:participant) end + # Returns the current member count without loading all serialized users. def team_size object.teams_participants.count end + # Exposes parent_id as assignment_id only for assignment-backed teams. + def assignment_id + object.parent_id if object.is_a?(AssignmentTeam) + end + + # Returns the topic this team is currently signed up for, if any. def sign_up_topic signed_up_team&.project_topic end + # Looks up the signup join row used to derive topic context for the team. def signed_up_team SignedUpTeam.find_by(team_id: object.id) end -end \ No newline at end of file +end diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb index a1a7e28ee..3204221a4 100644 --- a/app/serializers/user_serializer.rb +++ b/app/serializers/user_serializer.rb @@ -1,13 +1,38 @@ # frozen_string_literal: true class UserSerializer < ActiveModel::Serializer - attributes :id, :username, :email, :fullName + attributes :id, :name, :email, :full_name, :email_on_review, :email_on_submission, + :email_on_review_of_review, :date_format_pref, :created_at, :updated_at, + :role, :parent, :institution - def username - object.name + def role + return nil unless object.role + + { + id: object.role.id, + name: object.role.name + } + end + + def parent + return { id: nil, name: nil } unless object.parent + + { + id: object.parent.id, + name: object.parent.name + } + end + + def institution + return { id: nil, name: nil } unless object.institution + + { + id: object.institution.id, + name: object.institution.name + } end - def fullName - object.full_name + def date_format_pref + nil end end diff --git a/app/services/change_offending_field_action.rb b/app/services/change_offending_field_action.rb new file mode 100644 index 000000000..73b2480bb --- /dev/null +++ b/app/services/change_offending_field_action.rb @@ -0,0 +1,85 @@ +# =============================================================== +# ChangeOffendingFieldAction +# +# Strategy: **Automatically adjust the offending (unique) fields** +# so the imported row can still be inserted. +# +# This is the default strategy used by Import unless overridden. +# +# Example: +# existing.name = "Alice" +# incoming.name = "Alice" +# +# → incoming.name becomes "Alice_copy" +# +# If still not unique: +# "Alice_copy2", "Alice_copy3", etc. +# +# How it works: +# 1. Collect all fields with uniqueness validators +# 2. If incoming[field] == existing[field], mutate it +# 3. Keep incrementing until the value no longer exists in the DB +# +# =============================================================== +class ChangeOffendingFieldAction + def on_duplicate_record(klass, records) + # Normalize both existing and incoming row formats + existing = records[:existing] + incoming = records[:incoming].dup + + # Determine fields with uniqueness validators + unique_fields = unique_constraint_fields(klass) + + # For each unique field, adjust if conflict detected + unique_fields.each do |field| + next unless incoming[field] == existing[field] + + incoming[field] = generate_unique_value( + klass: klass, + field: field, + base: incoming[field] + ) + end + + incoming # Returning one resolved record + end + + private + + # Standardize input into a symbolized hash + def normalize(record) + return record.symbolize_keys if record.is_a?(Hash) + record.attributes.symbolize_keys + end + + # Extract all attributes validated as unique via ActiveRecord + def unique_constraint_fields(klass) + klass.validators + .select { |v| v.is_a?(ActiveRecord::Validations::UniquenessValidator) } + .flat_map(&:attributes) + .map(&:to_sym) + end + + # Generate a new unique value by appending suffixes until unique + # + # Example: + # base = "Alice" + # → "Alice_copy" + # → "Alice_copy2" + # → "Alice_copy3" + # + def generate_unique_value(klass:, field:, base:) + candidate = base.to_s + counter = 1 + + # Keep generating values until one does not exist in DB + while klass.exists?(field => candidate) + candidate = + "#{base}_copy#{counter == 1 ? '' : counter}" + + counter += 1 + end + + candidate + end +end diff --git a/app/services/export.rb b/app/services/export.rb new file mode 100644 index 000000000..af4d1bec3 --- /dev/null +++ b/app/services/export.rb @@ -0,0 +1,56 @@ +# app/services/export.rb + +## +# Export +# +# This service provides CSV export for models that expose import/export +# metadata. The model supplies the record scope through its filter, while +# FieldMapping controls the order and translation of requested headers. +# +class Export + + ## + # Convert model records into CSV format. + # + # This generates: + # * A header row using the requested export headers + # * One CSV row for each record in the model's export scope + # + # Example output: + # name,participant_1,participant_2 + # Team 1,alice,bob + # + def self.export_csv(export_class, ordered_headers) + ordered_headers ||= export_class.internal_and_external_fields + mapping = FieldMapping.from_header(export_class, ordered_headers) + + csv_contents = CSV.generate do |csv| + class_fields = mapping.ordered_fields.select { |ele| export_class.internal_fields.include?(ele) } + + # Preserve the selected frontend field order in the CSV header. + csv << ordered_headers + + # Insert each scoped model record in the same selected field order. + export_class.filter.call.each do |record| + row = class_fields.map { |f| record.send(f) } + + Array(export_class.external_classes).each do |external_class| + ext_class_fields = mapping.ordered_fields.select { |ele| external_class.fields.include?(ele) } + found_record = record.send(external_class.ref_class.name.underscore) + row += ext_class_fields.map do |f| + found_record&.send(ExternalClass.unappended_class_name(external_class.ref_class, f)) if f + end + end + + csv << row + end + end + + [{ name: export_class.name, contents: csv_contents }] + end + + def self.perform(export_class, ordered_headers = nil) + export_csv(export_class, ordered_headers) + end + +end diff --git a/app/services/field_mapping.rb b/app/services/field_mapping.rb new file mode 100644 index 000000000..8df4a8975 --- /dev/null +++ b/app/services/field_mapping.rb @@ -0,0 +1,140 @@ +# app/services/field_mapping.rb +# +# =============================================================== +# FieldMapping +# +# This class defines how CSV fields are mapped to an internal +# ActiveRecord model’s attributes. It is used by the import/export +# service layer to: +# +# • Determine the order of fields in an exported CSV +# • Interpret CSV rows and produce attribute hashes +# • Build mappings based on CSV headers, if the import uses headers +# +# The mapping is intentionally simple: it stores an array of field +# names (strings) in the order that the import/export process should +# follow. +# +# =============================================================== +class FieldMapping + attr_reader :model_class, :ordered_fields + + # -------------------------------------------------------------- + # Initialize a new mapping. + # + # model_class: + # An ActiveRecord model class (e.g., User, Team, Assignment) + # + # ordered_fields: + # Array of field names that define the order CSV fields appear + # in. We convert everything to strings to ensure consistent + # look_ups (symbols vs strings cause unnecessary mismatches). + # + # Example: + # FieldMapping.new(User, [:email, "first_name", :last_name]) + # + # Output: + # @ordered_fields = ["email", "first_name", "last_name"] + # -------------------------------------------------------------- + def initialize(model_class, ordered_fields) + @model_class = model_class + @ordered_fields = ordered_fields.map(&:to_s) + end + + # -------------------------------------------------------------- + # Build a mapping using the header row from a CSV file. + # + # header_row: + # Array of strings taken from the first row of a CSV file: + # ["Email", "Last Name", "First Name"] + # + # How matching works: + # - Normalize headers (strip whitespace, lowercase comparison) + # - Compare headers case-insensitively against all internal + external + # fields allowed by the model. + # - Only headers that match valid model fields are kept. + # + # Example: + # model_class.internal_and_external_fields = [:email, :first_name, :last_name] + # + # headers = ["EMAIL", "First Name", "Ignored Column"] + # + # matched = ["email", "first_name"] + # + # -------------------------------------------------------------- + def self.from_header(model_class, header_row) + # Normalize header strings + header_row = header_row.map { |h| h.to_s.strip } + + # Retrieve valid model fields (convert to strings for comparison) + valid_fields = model_class.internal_and_external_fields.map(&:to_s) + + # Match CSV headers to valid model fields (case-insensitive) + matched = header_row.filter_map do |h| + valid_fields.find { |f| f.casecmp?(h) } + end + + new(model_class, matched) + end + + # -------------------------------------------------------------- + # Returns the internal CSV header row for export. + # + # This is simply the list of ordered fields. + # -------------------------------------------------------------- + def headers + ordered_fields + end + + # -------------------------------------------------------------- + # Detect duplicate headers in the mapping. + # + # Useful for import validation, e.g., if a CSV contains: + # ["name", "email", "email"] + # + # Returns: + # ["email"] + # + # -------------------------------------------------------------- + def duplicate_headers + ordered_fields + .group_by { |h| h } + .select { |_k, v| v.size > 1 } + .keys + end + + # -------------------------------------------------------------- + # Given an ActiveRecord instance, extract values in the order needed + # for CSV export. + # + # Example: + # ordered_fields = ["email", "first_name"] + # record.email → "bob@example.com" + # record.first_name → "Bob" + # + # Output: + # ["bob@example.com", "Bob"] + # + # -------------------------------------------------------------- + def values_for(record) + ordered_fields.map { |f| record.public_send(f) } + end + + # -------------------------------------------------------------- + # Convert mapping to a JSON-friendly structure. + # + # Used by APIs or import UI to remember mapping preferences. + # + # Example output: + # { + # model_class: "User", + # ordered_fields: ["email", "first_name"] + # } + # -------------------------------------------------------------- + def to_h + { + model_class: model_class.name, + ordered_fields: ordered_fields + } + end +end diff --git a/app/services/import.rb b/app/services/import.rb new file mode 100644 index 000000000..94178693f --- /dev/null +++ b/app/services/import.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +require 'csv' + +# By default, if the caller does not specify a duplicate action, +# we use ChangeOffendingFieldAction. This ensures the importer +# always has a duplicate-resolution strategy. +DEFAULT_DUPLICATE_ACTION = ChangeOffendingFieldAction.new + +## +# Import class +# +# This class handles end-to-end CSV importing for any model that includes +# the ImportableExportable mixin. Its responsibilities include: +# +# • Loading CSV data +# • Mapping CSV columns into model attributes +# • Attempting to save each record +# • Collecting duplicate rows +# • Handling duplicates through a DuplicateAction strategy object +# +# The importer does NOT save the duplicates immediately. Instead it delegates +# conflict resolution to DuplicateAction subclasses. +# +class Import + ## + # Initializes an Import instance + # + # @param klass [Class] ActiveRecord model to import into + # @param file [String] path to CSV file + # @param mapping [FieldMapping, nil] optional mapping override + # @param dup_action [DuplicateAction, nil] optional duplicate handler override + # + def initialize(klass:, file:, headers: nil, dup_action: nil, defaults: {}) + @klass = klass + @file = file + @headers = headers + @duplicate_action = dup_action || DEFAULT_DUPLICATE_ACTION + @defaults = defaults || {} + end + + # -------------------------------------------------------------- + # MAIN IMPORT PROCESS + # -------------------------------------------------------------- + + ## + # Runs the full import: + # 1. Builds or uses existing field mapping + # 2. Parses the CSV into attribute hashes + # 3. Attempts to insert each row + # 4. On failure, collects duplicates into groups + # 5. Processes duplicate groups with assigned DuplicateAction + # + # Returns a summary with :imported and :duplicates count + # + def perform(use_headers) + duplicate_groups = [] # Will hold duplicate row sets + successful_inserts = 0 # Counter for successful saves + + # Call the model-level importer (defined in each model using the import mixin) + dups = @klass.try_import_records( + @file, + @headers, + use_headers, + @defaults + ) + + dups.each {|dup| duplicate_groups << normalize_duplicate(dup)} + + + # Let the duplicate action process all collected conflicts + process_duplicates(@klass, duplicate_groups) + + + # Return summary of import results + { + imported: successful_inserts, + duplicates: duplicate_groups.length + } + end + + private + + # -------------------------------------------------------------- + # DUPLICATE PROCESSING + # -------------------------------------------------------------- + + ## + # Normalizes duplicate information into a two-element array: + # + # [ existing_record_hash, incoming_record_hash ] + # + # Where: + # • existing_record_hash may be {} if not found in DB + # • incoming_record_hash is always the failed attributes + # + # This format is used by DuplicateAction subclasses to determine + # how the conflict should be resolved. + # + def normalize_duplicate(incoming_obj) + # Try to find the existing record using the primary key value + field = find_offending_field(incoming_obj) + + value = {} + value[field] = incoming_obj.as_json()[field.to_s] + + existing = @klass.find_by(value) + { + existing: existing, # Existing row (maybe empty) + incoming: incoming_obj # Incoming row + } + end + + def find_offending_field(incoming_obj) + incoming_obj.validate + incoming_obj.errors.details.each do |attribute, error_details_array| + return attribute if error_details_array.any? { |detail_hash| detail_hash[:error] == :taken } + end + end + + ## + # For every duplicate group (existing, incoming), call the provided duplicate + # action strategy. If the strategy returns an array of cleaned/merged rows, + # reinsert them into the DB. + # + # A duplicate action is expected to implement: + # + # on_duplicate_record(klass:, records:) + # + def process_duplicates(klass, groups) + groups.each do |records| + processed = @duplicate_action.on_duplicate_record( + klass, + records + ) + + # If the duplicate action returns nil, it means “skip insertion” + next if processed.nil? + + processed.save! + end + end + +end diff --git a/app/services/questionnaire_package_export_service.rb b/app/services/questionnaire_package_export_service.rb new file mode 100644 index 000000000..c8c026b5e --- /dev/null +++ b/app/services/questionnaire_package_export_service.rb @@ -0,0 +1,205 @@ +# frozen_string_literal: true + +require 'csv' +require 'json' +require 'zip' + +# Portable questionnaire-template export. Unlike the generic model exporter, it +# keeps template records together and excludes responses, answers, and quiz data. +class QuestionnairePackageExportService + PACKAGE_TYPE = 'questionnaire_template_export' + VERSION = 1 + REQUIRED_FILES = %w[questionnaires.csv items.csv].freeze + OPTIONAL_FILES = %w[question_advices.csv].freeze + FILES = (REQUIRED_FILES + OPTIONAL_FILES).freeze + INCLUDED_RESOURCES = %w[questionnaires items question_advices].freeze + EXCLUDED_RESOURCES = %w[answers responses quiz_questionnaires quiz_items quiz_question_choices].freeze + + QUESTIONNAIRE_HEADERS = %w[ + name + questionnaire_type + display_type + private + min_question_score + max_question_score + instructor_name + ].freeze + + ITEM_HEADERS = %w[ + questionnaire_name + questionnaire_instructor_name + seq + txt + question_type + weight + break_before + min_label + max_label + alternatives + size + ].freeze + + QUESTION_ADVICE_HEADERS = %w[ + questionnaire_name + questionnaire_instructor_name + item_seq + item_txt + score + advice + ].freeze + + def initialize(questionnaires: nil, include_question_advices: true) + @questionnaires = questionnaires + @include_question_advices = include_question_advices + end + + # Builds the manifest and ordered CSVs used by the matching import service. + def perform + exportable_questionnaires = questionnaire_scope + .includes(items: :question_advices) + .order(:id) + + questionnaire_csv = build_csv(QUESTIONNAIRE_HEADERS, questionnaire_rows(exportable_questionnaires)) + item_csv = build_csv(ITEM_HEADERS, item_rows(exportable_questionnaires)) + question_advice_csv = build_csv(QUESTION_ADVICE_HEADERS, question_advice_rows(exportable_questionnaires)) if include_question_advices? + + zip_data = Zip::OutputStream.write_buffer do |zip| + zip.put_next_entry('manifest.json') + zip.write( + JSON.pretty_generate( + { + package_type: PACKAGE_TYPE, + version: VERSION, + files: package_files, + includes: included_resources, + excludes: EXCLUDED_RESOURCES, + exported_at: Time.zone.now.iso8601, + questionnaire_count: exportable_questionnaires.size + } + ) + ) + + zip.put_next_entry('questionnaires.csv') + zip.write(questionnaire_csv) + + zip.put_next_entry('items.csv') + zip.write(item_csv) + + if include_question_advices? + zip.put_next_entry('question_advices.csv') + zip.write(question_advice_csv) + end + end + + { + filename: "questionnaire_template_package_#{Time.zone.now.strftime('%Y%m%d_%H%M%S')}.zip", + content_type: 'application/zip', + data: zip_data.string, + counts: { + questionnaires: exportable_questionnaires.size, + items: exportable_questionnaires.sum { |questionnaire| exportable_items_for(questionnaire).size }, + question_advices: question_advice_count(exportable_questionnaires) + } + } + end + + private + + # Controls whether question_advices.csv is included in the generated package. + def include_question_advices? + @include_question_advices + end + + # Keeps the manifest file list aligned with the optional advice export flag. + def package_files + include_question_advices? ? FILES : REQUIRED_FILES + end + + # Keeps the manifest resource list aligned with the optional advice export flag. + def included_resources + include_question_advices? ? INCLUDED_RESOURCES : %w[questionnaires items] + end + + # Reports advice count as zero when advice rows were deliberately excluded. + def question_advice_count(questionnaires) + return 0 unless include_question_advices? + + questionnaires.sum do |questionnaire| + exportable_items_for(questionnaire).sum { |item| item.question_advices.size } + end + end + + # Quiz questionnaires need quiz-specific choice data this package omits. + def questionnaire_scope + scope = @questionnaires || Questionnaire.all + scope.where.not(questionnaire_type: 'QuizQuestionnaire') + end + + # Use instructor names because database IDs are not portable. + def questionnaire_rows(questionnaires) + questionnaires.map do |questionnaire| + [ + questionnaire.name, + questionnaire.questionnaire_type, + questionnaire.display_type, + questionnaire.private, + questionnaire.min_question_score, + questionnaire.max_question_score, + questionnaire.instructor&.name + ] + end + end + + # Include only fields needed to rebuild template items. + def item_rows(questionnaires) + questionnaires.flat_map do |questionnaire| + exportable_items_for(questionnaire).map do |item| + [ + questionnaire.name, + questionnaire.instructor&.name, + item.seq, + item.txt, + item.question_type, + item.weight, + item.break_before, + item.min_label, + item.max_label, + item.alternatives, + item.size + ] + end + end + end + + # Reference items by exported fields instead of non-portable item IDs. + def question_advice_rows(questionnaires) + questionnaires.flat_map do |questionnaire| + exportable_items_for(questionnaire).flat_map do |item| + item.question_advices.map do |question_advice| + [ + questionnaire.name, + questionnaire.instructor&.name, + item.seq, + item.txt, + question_advice.score, + question_advice.advice + ] + end + end + end + end + + # Exclude quiz items that depend on choice data outside this format. + def exportable_items_for(questionnaire) + questionnaire.items.reject do |item| + item.question_type.to_s.casecmp('multiple_choice').zero? || item.is_a?(QuizItem) + end + end + + def build_csv(headers, rows) + CSV.generate do |csv| + csv << headers + rows.each { |row| csv << row } + end + end +end diff --git a/app/services/questionnaire_package_import_service.rb b/app/services/questionnaire_package_import_service.rb new file mode 100644 index 000000000..34fc96f9a --- /dev/null +++ b/app/services/questionnaire_package_import_service.rb @@ -0,0 +1,556 @@ +# frozen_string_literal: true + +require 'csv' +require 'json' +require 'set' +require 'zip' + +# Custom questionnaire-template import. It coordinates several CSVs in one +# transaction, which the generic single-model importer cannot do. +class QuestionnairePackageImportService + PACKAGE_TYPE = QuestionnairePackageExportService::PACKAGE_TYPE + VERSION = QuestionnairePackageExportService::VERSION + REQUIRED_FILES = %w[manifest.json questionnaires.csv items.csv].freeze + OPTIONAL_FILES = %w[question_advices.csv].freeze + QUESTIONNAIRE_REQUIRED_HEADERS = %w[ + name + questionnaire_type + display_type + private + min_question_score + max_question_score + instructor_name + ].freeze + ITEM_REQUIRED_HEADERS = %w[ + questionnaire_name + questionnaire_instructor_name + seq + txt + question_type + weight + break_before + ].freeze + QUESTION_ADVICE_REQUIRED_HEADERS = %w[ + questionnaire_name + questionnaire_instructor_name + item_seq + item_txt + score + advice + ].freeze + CSV_HEADER_REQUIREMENTS = { + questionnaires: QUESTIONNAIRE_REQUIRED_HEADERS, + items: ITEM_REQUIRED_HEADERS, + question_advices: QUESTION_ADVICE_REQUIRED_HEADERS + }.freeze + DEFAULT_DUPLICATE_ACTION = ChangeOffendingFieldAction.new + + def initialize(package_file: nil, questionnaire_file: nil, items_file: nil, question_advices_file: nil, dup_action: nil) + @package_file = package_file + @questionnaire_file = questionnaire_file + @items_file = items_file + @question_advices_file = question_advices_file + @duplicate_action = dup_action || DEFAULT_DUPLICATE_ACTION + end + + # Import parents before dependent rows, using package keys instead of DB IDs. + def perform + questionnaire_rows, item_rows, question_advice_rows = parsed_rows + + imported_counts = { + questionnaires: 0, + items: 0, + question_advices: 0 + } + duplicate_counts = { + questionnaires: 0, + items: 0, + question_advices: 0 + } + + ActiveRecord::Base.transaction do + imported_questionnaires, skipped_questionnaire_keys = import_questionnaires( + questionnaire_rows, + imported_counts, + duplicate_counts + ) + imported_items = import_items(item_rows, imported_questionnaires, skipped_questionnaire_keys, imported_counts) + import_question_advices( + question_advice_rows, + imported_questionnaires, + imported_items, + skipped_questionnaire_keys, + imported_counts + ) + end + + { + imported: imported_counts, + duplicates: duplicate_counts + } + end + + # Dry-run the package and return row-level actions without writing records. + def preview + questionnaire_rows, item_rows, question_advice_rows = parsed_rows + questionnaire_preview = preview_questionnaires(questionnaire_rows) + item_preview = preview_items(item_rows, questionnaire_preview) + advice_preview = preview_question_advices(question_advice_rows, questionnaire_preview, item_preview) + + { + summary: preview_summary(questionnaire_preview, item_preview, advice_preview), + questionnaires: questionnaire_preview[:rows], + items: item_preview[:rows], + question_advices: advice_preview[:rows], + errors: questionnaire_preview[:errors] + item_preview[:errors] + advice_preview[:errors] + } + end + + private + + def parsed_rows + csv_sources = resolve_csv_sources + + [ + parse_csv(csv_sources.fetch(:questionnaires), :questionnaires), + parse_csv(csv_sources[:items], :items), + parse_csv(csv_sources[:question_advices], :question_advices) + ] + end + + # Accept either the canonical zip or separate role-specific CSV uploads. + def resolve_csv_sources + if @package_file.present? + entries = read_zip_entries + validate_package!(entries) + return { + questionnaires: entries['questionnaires.csv'], + items: entries['items.csv'], + question_advices: entries['question_advices.csv'] + } + end + + raise StandardError, 'A questionnaire CSV file is required.' if @questionnaire_file.blank? + + { + questionnaires: read_uploaded_file(@questionnaire_file), + items: read_uploaded_file(@items_file), + question_advices: read_uploaded_file(@question_advices_file) + } + end + + # Read package entries by zip path; manifest validation happens next. + def read_zip_entries + entries = {} + + Zip::File.open(@package_file.path) do |zip_file| + zip_file.each do |entry| + next if entry.directory? + + entries[entry.name] = entry.get_input_stream.read + end + end + + entries + rescue Zip::Error => e + raise StandardError, "Invalid questionnaire package: #{e.message}" + end + + def read_uploaded_file(file) + return nil if file.blank? + + file.respond_to?(:read) ? file.read : File.read(file.path) + ensure + file.rewind if file.respond_to?(:rewind) + end + + # Reject unrelated or unsupported package versions before reading CSV rows. + def validate_package!(entries) + missing_files = REQUIRED_FILES - entries.keys + raise StandardError, "Questionnaire package is missing required files: #{missing_files.join(', ')}" if missing_files.any? + + manifest = JSON.parse(entries['manifest.json']) + unless manifest['package_type'] == PACKAGE_TYPE + raise StandardError, "Unsupported questionnaire package type: #{manifest['package_type']}" + end + + return if manifest['version'].to_i == VERSION + + raise StandardError, "Unsupported questionnaire package version: #{manifest['version']}" + rescue JSON::ParserError => e + raise StandardError, "Invalid questionnaire package manifest: #{e.message}" + end + + # Normalize headers before validation so CSVs match FieldMapping behavior. + def parse_csv(contents, role) + return [] if contents.blank? + + contents = normalize_csv_contents(contents) + table = CSV.parse(contents, headers: true) + headers = table.headers.map { |header| normalize_header(header) } + rows = table.map do |row| + row.to_h.transform_keys { |key| normalize_header(key) } + end + validate_headers!(role, headers) + rows + rescue CSV::MalformedCSVError => e + raise StandardError, "Invalid #{csv_label(role)} CSV: #{e.message}" + end + + # Fail early with header errors instead of later relationship errors. + def validate_headers!(role, headers) + missing_headers = CSV_HEADER_REQUIREMENTS.fetch(role) - headers + return if missing_headers.empty? + + raise StandardError, "#{csv_label(role)} CSV is missing required headers: #{missing_headers.join(', ')}" + end + + def csv_label(role) + role.to_s.humanize + end + + def normalize_header(header) + header.to_s.parameterize.underscore + end + + # Tolerate common spreadsheet-export encoding issues. + def normalize_csv_contents(contents) + contents.to_s.force_encoding('UTF-8').encode('UTF-8', invalid: :replace, undef: :replace) + end + + def preview_questionnaires(rows) + active_questionnaire_keys = Set.new + skipped_questionnaire_keys = Set.new + errors = [] + + preview_rows = rows.each_with_index.map do |row, index| + source_key = questionnaire_source_key(row['name'], row['instructor_name']) + instructor = Instructor.find_by(name: row['instructor_name']) + existing = instructor ? Questionnaire.find_by(name: row['name'], instructor_id: instructor.id) : nil + action = preview_questionnaire_action(existing) + error = instructor.nil? ? "Instructor '#{row['instructor_name']}' was not found." : nil + + if error + errors << preview_error(:questionnaires, index, error) + elsif action == 'skip' + skipped_questionnaire_keys.add(source_key) + else + active_questionnaire_keys.add(source_key) + end + + { + row: index + 2, + name: row['name'], + instructor_name: row['instructor_name'], + questionnaire_type: row['questionnaire_type'], + action: error ? 'error' : action, + duplicate: existing.present?, + message: error + }.compact + end + + { + rows: preview_rows, + active_keys: active_questionnaire_keys, + skipped_keys: skipped_questionnaire_keys, + errors: errors + } + end + + def preview_questionnaire_action(existing) + return 'create' if existing.nil? + return 'skip' if @duplicate_action.is_a?(SkipRecordAction) + return 'update' if @duplicate_action.is_a?(UpdateExistingRecordAction) + + 'create_copy' + end + + def preview_items(rows, questionnaire_preview) + active_item_keys = Set.new + errors = [] + + preview_rows = rows.each_with_index.map do |row, index| + questionnaire_key = questionnaire_source_key(row['questionnaire_name'], row['questionnaire_instructor_name']) + item_key = item_source_key(row['questionnaire_name'], row['questionnaire_instructor_name'], row['seq'], row['txt']) + action, message, error = preview_item_state(row, questionnaire_key, questionnaire_preview) + + if error + errors << preview_error(:items, index, error) + elsif action == 'create' + active_item_keys.add(item_key) + end + + { + row: index + 2, + questionnaire_name: row['questionnaire_name'], + seq: row['seq'], + txt: row['txt'], + question_type: row['question_type'], + action: action, + message: message || error + }.compact + end + + { + rows: preview_rows, + active_keys: active_item_keys, + errors: errors + } + end + + def preview_item_state(row, questionnaire_key, questionnaire_preview) + if questionnaire_preview[:skipped_keys].include?(questionnaire_key) + return ['skip', "Questionnaire '#{row['questionnaire_name']}' will be skipped.", nil] + end + + return ['create', nil, nil] if questionnaire_preview[:active_keys].include?(questionnaire_key) + + ['error', nil, "Unable to resolve questionnaire '#{row['questionnaire_name']}'."] + end + + def preview_question_advices(rows, questionnaire_preview, item_preview) + errors = [] + + preview_rows = rows.each_with_index.map do |row, index| + questionnaire_key = questionnaire_source_key(row['questionnaire_name'], row['questionnaire_instructor_name']) + item_key = item_source_key(row['questionnaire_name'], row['questionnaire_instructor_name'], row['item_seq'], row['item_txt']) + action, message, error = preview_advice_state(row, questionnaire_key, item_key, questionnaire_preview, item_preview) + + errors << preview_error(:question_advices, index, error) if error + + { + row: index + 2, + questionnaire_name: row['questionnaire_name'], + item_seq: row['item_seq'], + item_txt: row['item_txt'], + score: row['score'], + advice: row['advice'], + action: action, + message: message || error + }.compact + end + + { + rows: preview_rows, + errors: errors + } + end + + def preview_advice_state(row, questionnaire_key, item_key, questionnaire_preview, item_preview) + if questionnaire_preview[:skipped_keys].include?(questionnaire_key) + return ['skip', "Questionnaire '#{row['questionnaire_name']}' will be skipped.", nil] + end + + return ['create', nil, nil] if item_preview[:active_keys].include?(item_key) + + ['error', nil, "Unable to resolve item '#{row['item_txt']}'."] + end + + def preview_summary(*previews) + rows = previews.flat_map { |preview| preview[:rows] } + + { + questionnaires: previews[0][:rows].size, + items: previews[1][:rows].size, + question_advices: previews[2][:rows].size, + creates: rows.count { |row| %w[create create_copy].include?(row[:action]) }, + updates: rows.count { |row| row[:action] == 'update' }, + skips: rows.count { |row| row[:action] == 'skip' }, + duplicates: rows.count { |row| row[:duplicate] }, + errors: rows.count { |row| row[:action] == 'error' } + } + end + + def preview_error(file, index, message) + { + file: file, + row: index + 2, + message: message + } + end + + # Track imported questionnaires so dependent CSVs can attach to them. + def import_questionnaires(rows, imported_counts, duplicate_counts) + mapping = FieldMapping.from_header(Questionnaire, rows.first&.keys || []) + imported_questionnaires = {} + skipped_questionnaire_keys = Set.new + + rows.each do |row| + source_key = questionnaire_source_key(row['name'], row['instructor_name']) + record, duplicate, skipped = import_questionnaire_row(row, mapping) + if skipped + skipped_questionnaire_keys.add(source_key) + duplicate_counts[:questionnaires] += 1 + next + end + + next if record.nil? + + imported_questionnaires[source_key] = record + imported_counts[:questionnaires] += 1 + duplicate_counts[:questionnaires] += 1 if duplicate + end + + [imported_questionnaires, skipped_questionnaire_keys] + end + + # Resolve questionnaire duplicates before importing dependent rows. + def import_questionnaire_row(row, mapping) + incoming = build_questionnaire(row, mapping) + existing = find_questionnaire_record(row) + + if existing.nil? + incoming.save! + return [incoming, false, false] + end + + processed = resolve_duplicate_questionnaire(existing, incoming) + return [existing, true, true] if processed.nil? + + processed.save! + [processed, true, false] + end + + # Recreate template items and keep a lookup for advice rows. + def import_items(rows, imported_questionnaires, skipped_questionnaire_keys, imported_counts) + imported_items = {} + + rows.each do |row| + source_key = questionnaire_source_key(row['questionnaire_name'], row['questionnaire_instructor_name']) + next if skipped_questionnaire_keys.include?(source_key) + + questionnaire = imported_questionnaires[source_key] + raise StandardError, "Unable to resolve questionnaire for item '#{row['txt']}'." if questionnaire.nil? + + item = Item.new( + txt: row['txt'], + weight: row['weight'], + seq: row['seq'], + question_type: row['question_type'], + size: row['size'], + alternatives: row['alternatives'], + break_before: normalize_boolean(row['break_before']), + min_label: row['min_label'], + max_label: row['max_label'] + ) + item.questionnaire = questionnaire + + imported_seq = item.seq + item.save! + item.update_column(:seq, imported_seq) if imported_seq.present? && item.seq.to_s != imported_seq.to_s + + imported_items[item_source_key(row['questionnaire_name'], row['questionnaire_instructor_name'], row['seq'], row['txt'])] = item + imported_counts[:items] += 1 + end + + imported_items + end + + # Prefer package item keys, with a DB fallback for update flows. + def import_question_advices(rows, imported_questionnaires, imported_items, skipped_questionnaire_keys, imported_counts) + rows.each do |row| + source_key = questionnaire_source_key(row['questionnaire_name'], row['questionnaire_instructor_name']) + next if skipped_questionnaire_keys.include?(source_key) + + questionnaire = imported_questionnaires[source_key] + raise StandardError, "Unable to resolve questionnaire for advice '#{row['advice']}'." if questionnaire.nil? + + item = imported_items[item_source_key(row['questionnaire_name'], row['questionnaire_instructor_name'], row['item_seq'], row['item_txt'])] || + questionnaire.items.find_by(seq: row['item_seq'], txt: row['item_txt']) + raise StandardError, "Unable to resolve item for advice '#{row['advice']}'." if item.nil? + + question_advice = QuestionAdvice.new( + score: row['score'], + advice: row['advice'] + ) + question_advice.item = item + question_advice.save! + + imported_counts[:question_advices] += 1 + end + end + + # Scope duplicates by instructor name because packages avoid DB IDs. + def find_questionnaire_record(row) + instructor = Instructor.find_by(name: row['instructor_name']) + return nil if instructor.nil? + + Questionnaire.find_by(name: row['name'], instructor_id: instructor.id) + end + + # Reuse existing mapping so questionnaire conversion stays consistent. + def build_questionnaire(row, mapping) + row_values = mapping.ordered_fields.map { |field| row[field] } + row_hash = {} + mapping.ordered_fields.zip(row_values).each do |key, value| + row_hash[key] ||= [] + row_hash[key] << value + end + + questionnaire = Questionnaire.from_hash(row_hash.slice(*Questionnaire.internal_fields)) + Questionnaire.external_classes.each do |external_class| + next unless external_class.should_look_up + + found = external_class.look_up(row_hash) + questionnaire.public_send("#{external_class.ref_class.name.downcase}=", found) if found + end + + questionnaire + end + + # Translate selected duplicate action into package-level behavior. + def resolve_duplicate_questionnaire(existing, incoming) + case @duplicate_action + when SkipRecordAction + nil + when UpdateExistingRecordAction + update_existing_questionnaire(existing, incoming) + else + incoming.name = unique_questionnaire_name(incoming.name, incoming.instructor_id) + incoming + end + end + + # Update only template fields represented in the package CSV. + def update_existing_questionnaire(existing, incoming) + existing.assign_attributes( + incoming.attributes.slice( + 'questionnaire_type', + 'display_type', + 'private', + 'min_question_score', + 'max_question_score' + ) + ) + existing + end + + # Default duplicate handling preserves both records with a readable copy name. + def unique_questionnaire_name(name, instructor_id) + base = name.to_s + candidate = base + counter = 1 + + while Questionnaire.exists?(name: candidate, instructor_id: instructor_id) + candidate = "#{base}_copy#{counter == 1 ? '' : counter}" + counter += 1 + end + + candidate + end + + # Portable questionnaire key used across package CSVs. + def questionnaire_source_key(name, instructor_name) + "#{instructor_name}::#{name}" + end + + # Portable item key used by advice rows. + def item_source_key(questionnaire_name, instructor_name, seq, txt) + "#{questionnaire_source_key(questionnaire_name, instructor_name)}::#{seq}::#{txt}" + end + + # Spreadsheet uploads provide booleans as strings. + def normalize_boolean(value) + ActiveRecord::Type::Boolean.new.deserialize(value) + end +end diff --git a/app/services/questionnaire_package_template_service.rb b/app/services/questionnaire_package_template_service.rb new file mode 100644 index 000000000..b83213414 --- /dev/null +++ b/app/services/questionnaire_package_template_service.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require 'csv' +require 'json' +require 'zip' + +# Generates blank questionnaire package templates from the import/export schema. +class QuestionnairePackageTemplateService + TEMPLATE_DEFINITIONS = { + 'questionnaires' => { + filename: 'questionnaires_import_sample.csv', + headers: QuestionnairePackageExportService::QUESTIONNAIRE_HEADERS, + sample_row: [ + 'Sample Review Questionnaire', + 'ReviewQuestionnaire', + 'Likert', + 'false', + '0', + '5', + 'instructor_username' + ] + }, + 'items' => { + filename: 'items_import_sample.csv', + headers: QuestionnairePackageExportService::ITEM_HEADERS, + sample_row: [ + 'Sample Review Questionnaire', + 'instructor_username', + '1', + 'How clear is the submitted work?', + 'Scale', + '1', + 'true', + 'Needs work', + 'Excellent', + '', + '' + ] + }, + 'question_advices' => { + filename: 'question_advices_import_sample.csv', + headers: QuestionnairePackageExportService::QUESTION_ADVICE_HEADERS, + sample_row: [ + 'Sample Review Questionnaire', + 'instructor_username', + '1', + 'How clear is the submitted work?', + '5', + 'Mention the strongest evidence and reasoning.' + ] + } + }.freeze + + PACKAGE_TEMPLATE_NAME = 'package' + + def initialize(template_name:) + @template_name = template_name.to_s + end + + def perform + return package_template if @template_name == PACKAGE_TEMPLATE_NAME + + csv_template + end + + private + + def csv_template + definition = TEMPLATE_DEFINITIONS[@template_name] + raise StandardError, "Unsupported questionnaire package template: #{@template_name}" if definition.nil? + + { + filename: definition[:filename], + content_type: 'text/csv', + data: build_csv(definition) + } + end + + def package_template + { + filename: 'questionnaire_package_import_sample.zip', + content_type: 'application/zip', + data: build_package_zip + } + end + + def build_package_zip + Zip::OutputStream.write_buffer do |zip| + zip.put_next_entry('manifest.json') + zip.write( + JSON.pretty_generate( + { + package_type: QuestionnairePackageExportService::PACKAGE_TYPE, + version: QuestionnairePackageExportService::VERSION, + files: QuestionnairePackageExportService::FILES, + includes: QuestionnairePackageExportService::INCLUDED_RESOURCES, + excludes: QuestionnairePackageExportService::EXCLUDED_RESOURCES + } + ) + ) + + TEMPLATE_DEFINITIONS.each_value do |definition| + zip.put_next_entry(package_csv_filename(definition[:filename])) + zip.write(build_csv(definition)) + end + end.string + end + + def package_csv_filename(filename) + filename.sub('_import_sample', '') + end + + def build_csv(definition) + CSV.generate do |csv| + csv << definition[:headers] + csv << definition[:sample_row] + end + end +end diff --git a/app/services/skip_record_action.rb b/app/services/skip_record_action.rb new file mode 100644 index 000000000..e231c93f5 --- /dev/null +++ b/app/services/skip_record_action.rb @@ -0,0 +1,18 @@ +# =============================================================== +# SkipRecordAction +# +# Strategy: **Ignore the incoming row entirely.** +# +# Usage example: +# - User chooses "Skip duplicates" on import +# - Any row that violates uniqueness constraints is dropped +# +# Behavior: +# Returning `nil` instructs Import.perform to do nothing. +# =============================================================== +class SkipRecordAction + def on_duplicate_record(klass, + records) + nil + end +end diff --git a/app/services/update_existing_record_action.rb b/app/services/update_existing_record_action.rb new file mode 100644 index 000000000..d079b76bb --- /dev/null +++ b/app/services/update_existing_record_action.rb @@ -0,0 +1,37 @@ +# =============================================================== +# UpdateExistingRecordAction +# +# Strategy: **Merge all duplicates into a single updated record.** +# +# Meaning: +# - If both existing and incoming records have data, +# incoming values overwrite existing ones (unless nil). +# +# Example: +# existing = { id: 5, name: "Alice", score: 80 } +# incoming = { id: 5, name: "Alice B.", score: nil } +# +# result = { id: 5, name: "Alice B.", score: 80 } +# +# Use case: +# "Update existing records with imported values" +# +# Importer will delete the original conflicting record and replace it +# with the merged one. +# =============================================================== +class UpdateExistingRecordAction + + def on_duplicate_record(klass, records) + merged = {} + + existing = records[:existing] + + klass.mandatory_fields.each do |field| + value = {} + value[field] = records[:incoming].send(field) + existing.send(:assign_attributes, value) + end + + existing + end +end diff --git a/config/database.yml b/config/database.yml index b9f5aa055..8a714597f 100644 --- a/config/database.yml +++ b/config/database.yml @@ -3,16 +3,18 @@ default: &default encoding: utf8mb4 pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> port: 3306 + username: root + password: expertiza socket: /var/run/mysqld/mysqld.sock development: <<: *default - url: <%= ENV['DATABASE_URL'].gsub('?', '_development?') %> + database: reimplementation_development test: <<: *default - url: <%= ENV['DATABASE_URL'].gsub('?', '_test?') %> + database: reimplementation_test production: <<: *default - url: <%= ENV['DATABASE_URL'].gsub('?', '_production?') %> \ No newline at end of file + database: reimplementation_production diff --git a/config/routes.rb b/config/routes.rb index 57559d007..364fd9991 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -62,10 +62,13 @@ 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' + resources :questionnaires do + member do + get :items + end + collection do + post 'copy/:id', to: 'questionnaires#copy', as: 'copy' + get 'toggle_access/:id', to: 'questionnaires#toggle_access', as: 'toggle_access' end end @@ -156,9 +159,10 @@ 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' + get '/user/:user_id', to: 'participants#list_user_participants' + get '/assignment/:assignment_id', to: 'participants#list_assignment_participants' + get '/course/:course_id', to: 'participants#list_course_participants' + get '/:id', to: 'participants#show' post '/:authorization', to: 'participants#add' patch '/:id/:authorization', to: 'participants#update_authorization' delete '/:id', to: 'participants#destroy' @@ -195,11 +199,12 @@ delete :delete_participants end end - resources :grades do - collection do - get '/:assignment_id/view_all_scores', to: 'grades#view_all_scores' - patch '/:participant_id/assign_grade', to: 'grades#assign_grade' - get '/:participant_id/edit', to: 'grades#edit' + resources :grades do + collection do + get '/:assignment_id/export', to: 'grades#export' + get '/:assignment_id/view_all_scores', to: 'grades#view_all_scores' + patch '/:participant_id/assign_grade', to: 'grades#assign_grade' + get '/:participant_id/edit', to: 'grades#edit' get '/:assignment_id/:participant_id/get_review_tableau_data', to: 'grades#get_review_tableau_data' get '/:assignment_id/view_our_scores', to: 'grades#view_our_scores' get '/:assignment_id/view_my_scores', to: 'grades#view_my_scores' @@ -214,4 +219,27 @@ resources :assignments do resources :duties, controller: 'assignments_duties', only: [:index, :create, :destroy] end -end + resources :import, path: :import, only: [] do + collection do + get "/:class", to: "import#index" + post "/:class", to: "import#import" + end + end + resources :export, path: :export, only: [] do + collection do + get "/:class", to: "export#index" + post "/:class", to: "export#export" + end + end + + # Package workflow preserves questionnaire, item, and advice relationships. + resources :questionnaire_packages, only: [] do + collection do + get :config, action: :package_config + get 'templates/:template_name', action: :template + post :export + post :preview + post :import + end + end +end diff --git a/db/migrate/20250321222753_rename_sign_up_topic_to_project_topic_in_signed_up_teams.rb b/db/migrate/20250321222753_rename_sign_up_topic_to_project_topic_in_signed_up_teams.rb index 1d9305315..e6f27f221 100644 --- a/db/migrate/20250321222753_rename_sign_up_topic_to_project_topic_in_signed_up_teams.rb +++ b/db/migrate/20250321222753_rename_sign_up_topic_to_project_topic_in_signed_up_teams.rb @@ -1,8 +1,13 @@ class RenameSignUpTopicToProjectTopicInSignedUpTeams < ActiveRecord::Migration[8.0] def change - rename_column :signed_up_teams, :sign_up_topic_id, :project_topic_id - rename_index :signed_up_teams, - :index_signed_up_teams_on_sign_up_topic_id, - :index_signed_up_teams_on_project_topic_id + if column_exists?(:signed_up_teams, :sign_up_topic_id) + rename_column :signed_up_teams, :sign_up_topic_id, :project_topic_id + end + + if index_exists?(:signed_up_teams, :index_signed_up_teams_on_sign_up_topic_id) + rename_index :signed_up_teams, + :index_signed_up_teams_on_sign_up_topic_id, + :index_signed_up_teams_on_project_topic_id + end end end diff --git a/db/migrate/20250418004442_change_to_polymorphic_association_in_teams.rb b/db/migrate/20250418004442_change_to_polymorphic_association_in_teams.rb index cd2fb7094..ae84636c8 100644 --- a/db/migrate/20250418004442_change_to_polymorphic_association_in_teams.rb +++ b/db/migrate/20250418004442_change_to_polymorphic_association_in_teams.rb @@ -3,9 +3,13 @@ class ChangeToPolymorphicAssociationInTeams < ActiveRecord::Migration[8.0] def change # Remove old assignment reference (course reference doesn't exist) - remove_reference :teams, :assignment, foreign_key: true + if column_exists?(:teams, :assignment_id) + remove_reference :teams, :assignment, foreign_key: true + end # Add polymorphic association fields (type column already exists) - add_column :teams, :parent_id, :integer, null: false + unless column_exists?(:teams, :parent_id) + add_column :teams, :parent_id, :integer, null: false + end end end diff --git a/db/migrate/20250629190818_add_comment_for_submission_to_team.rb b/db/migrate/20250629190818_add_comment_for_submission_to_team.rb index ac7e5dd44..053d24519 100644 --- a/db/migrate/20250629190818_add_comment_for_submission_to_team.rb +++ b/db/migrate/20250629190818_add_comment_for_submission_to_team.rb @@ -1,5 +1,7 @@ class AddCommentForSubmissionToTeam < ActiveRecord::Migration[8.0] def change - add_column :teams, :comment_for_submission, :string + unless column_exists?(:teams, :name) + add_column :teams, :comment_for_submission, :string + end end end diff --git a/db/migrate/20251022160053_change_invitation_from_id_foreign_key.rb b/db/migrate/20251022160053_change_invitation_from_id_foreign_key.rb index 9f5ab7f09..7ab99a427 100644 --- a/db/migrate/20251022160053_change_invitation_from_id_foreign_key.rb +++ b/db/migrate/20251022160053_change_invitation_from_id_foreign_key.rb @@ -1,9 +1,14 @@ class ChangeInvitationFromIdForeignKey < ActiveRecord::Migration[7.0] def change - # Remove old foreign key to participants - remove_foreign_key :invitations, column: :from_id + change_column :invitations, :from_id, :bigint + # Remove old foreign key to participants + if foreign_key_exists?(:invitations, column: :from_id) + remove_foreign_key :invitations, column: :from_id + end # Add new foreign key to teams - add_foreign_key :invitations, :teams, column: :from_id + unless foreign_key_exists?(:invitations, column: :from_id) + add_foreign_key :invitations, :teams, column: :from_id + end end end \ No newline at end of file diff --git a/db/migrate/20251126161701_remove_participant_ref_from_invitations.rb b/db/migrate/20251126161701_remove_participant_ref_from_invitations.rb index f6b0d2e68..ef7550e22 100644 --- a/db/migrate/20251126161701_remove_participant_ref_from_invitations.rb +++ b/db/migrate/20251126161701_remove_participant_ref_from_invitations.rb @@ -1,6 +1,10 @@ class RemoveParticipantRefFromInvitations < ActiveRecord::Migration[8.0] def change - remove_reference :invitations, :participant - remove_column :invitations, :participant_id, :integer + if column_exists?(:invitations, :participant_id) + remove_reference :invitations, :participant + if column_exists?(:invitations, :participant_id) + remove_column :invitations, :participant_id, :integer + end + end end end diff --git a/db/migrate/20251129040855_rename_item_id_in_question_tables.rb b/db/migrate/20251129040855_rename_item_id_in_question_tables.rb new file mode 100644 index 000000000..d8321be93 --- /dev/null +++ b/db/migrate/20251129040855_rename_item_id_in_question_tables.rb @@ -0,0 +1,16 @@ +class RenameItemIdInQuestionTables < ActiveRecord::Migration[8.0] + def change + rename_column_if_needed :answers + rename_column_if_needed :question_advices + rename_column_if_needed :quiz_question_choices + end + + private + + def rename_column_if_needed(table_name) + return unless column_exists?(table_name, :question_id) + return if column_exists?(table_name, :item_id) + + rename_column table_name, :question_id, :item_id + end +end \ No newline at end of file diff --git a/db/migrate/20260313064334_add_submission_fields_to_teams.rb b/db/migrate/20260313064334_add_submission_fields_to_teams.rb index d73ffe4f0..399d6a5c8 100644 --- a/db/migrate/20260313064334_add_submission_fields_to_teams.rb +++ b/db/migrate/20260313064334_add_submission_fields_to_teams.rb @@ -1,6 +1,10 @@ class AddSubmissionFieldsToTeams < ActiveRecord::Migration[8.0] def change - add_column :teams, :submitted_hyperlinks, :text - add_column :teams, :directory_num, :integer + unless column_exists?(:teams, :submitted_hyperlinks) + add_column :teams, :submitted_hyperlinks, :text + end + unless column_exists?(:teams, :directory_num) + add_column :teams, :directory_num, :integer + end end end diff --git a/db/migrate/20260328170000_rename_sign_up_topics_to_project_topics.rb b/db/migrate/20260328170000_rename_sign_up_topics_to_project_topics.rb new file mode 100644 index 000000000..6e9a8016e --- /dev/null +++ b/db/migrate/20260328170000_rename_sign_up_topics_to_project_topics.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +class RenameSignUpTopicsToProjectTopics < ActiveRecord::Migration[8.0] + def up + return if table_exists?(:project_topics) + return unless table_exists?(:sign_up_topics) + + if foreign_key_exists?(:signed_up_teams, :sign_up_topics) + remove_foreign_key :signed_up_teams, :sign_up_topics + end + + rename_table :sign_up_topics, :project_topics + + if index_name_exists?(:project_topics, "fk_sign_up_categories_sign_up_topics") && + !index_name_exists?(:project_topics, "index_project_topics_on_assignment_id") + rename_index :project_topics, + "fk_sign_up_categories_sign_up_topics", + "index_project_topics_on_assignment_id" + end + + if column_exists?(:signed_up_teams, :project_topic_id) && + !foreign_key_exists?(:signed_up_teams, :project_topics) + add_foreign_key :signed_up_teams, :project_topics + end + end + + def down + return if table_exists?(:sign_up_topics) + return unless table_exists?(:project_topics) + + remove_foreign_key :signed_up_teams, :project_topics if foreign_key_exists?(:signed_up_teams, :project_topics) + + if index_name_exists?(:project_topics, "index_project_topics_on_assignment_id") && + !index_name_exists?(:project_topics, "fk_sign_up_categories_sign_up_topics") + rename_index :project_topics, + "index_project_topics_on_assignment_id", + "fk_sign_up_categories_sign_up_topics" + end + + rename_table :project_topics, :sign_up_topics + + if column_exists?(:signed_up_teams, :project_topic_id) && + !foreign_key_exists?(:signed_up_teams, :sign_up_topics) + add_foreign_key :signed_up_teams, :sign_up_topics, column: :project_topic_id + end + end +end diff --git a/db/migrate/20260424000000_add_comment_for_submission_to_teams_if_missing.rb b/db/migrate/20260424000000_add_comment_for_submission_to_teams_if_missing.rb new file mode 100644 index 000000000..4729620c4 --- /dev/null +++ b/db/migrate/20260424000000_add_comment_for_submission_to_teams_if_missing.rb @@ -0,0 +1,5 @@ +class AddCommentForSubmissionToTeamsIfMissing < ActiveRecord::Migration[8.0] + def change + add_column :teams, :comment_for_submission, :string unless column_exists?(:teams, :comment_for_submission) + end +end diff --git a/db/schema.rb b/db/schema.rb index cddbe12c6..273f0a6fc 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: 2026_03_13_064334) do +ActiveRecord::Schema[8.0].define(version: 2026_04_24_000000) do create_table "account_requests", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "username" t.string "full_name" @@ -135,11 +135,6 @@ t.datetime "updated_at", null: false end - create_table "cakes", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - end - create_table "courses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name" t.string "directory_path" @@ -196,14 +191,14 @@ create_table "invitations", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.integer "assignment_id" + t.bigint "from_id" + t.integer "to_id" t.string "reply_status", limit: 1 t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.bigint "from_id", null: false - t.bigint "to_id", null: false t.index ["assignment_id"], name: "fk_invitation_assignments" - t.index ["from_id"], name: "index_invitations_on_from_id" - t.index ["to_id"], name: "index_invitations_on_to_id" + t.index ["from_id"], name: "fk_invitationfrom_users" + t.index ["to_id"], name: "fk_invitationto_users" end create_table "items", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -279,16 +274,17 @@ t.string "link" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.index ["assignment_id"], name: "fk_sign_up_categories_sign_up_topics" t.index ["assignment_id"], name: "index_project_topics_on_assignment_id" end create_table "question_advices", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.bigint "question_id", null: false + t.bigint "item_id", null: false t.integer "score" t.text "advice" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["question_id"], name: "index_question_advices_on_question_id" + t.index ["item_id"], name: "index_question_advices_on_item_id" end create_table "question_types", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -317,7 +313,7 @@ end create_table "quiz_question_choices", id: :integer, charset: "latin1", force: :cascade do |t| - t.integer "question_id" + t.integer "item_id" t.text "txt" t.boolean "iscorrect", default: false t.datetime "created_at", null: false @@ -330,7 +326,6 @@ t.integer "reviewee_id", default: 0, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.string "type" t.index ["reviewer_id"], name: "fk_response_map_reviewer" end @@ -389,15 +384,17 @@ end create_table "teams", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.string "name", null: false + t.integer "parent_id" t.string "type", null: false - t.integer "parent_id", null: false - t.integer "grade_for_submission" - t.string "comment_for_submission" t.text "submitted_hyperlinks" t.integer "directory_num" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.integer "grade_for_submission" + t.string "comment_for_submission" + t.index ["parent_id"], name: "index_teams_on_parent_id" + t.index ["type"], name: "index_teams_on_type" end create_table "teams_participants", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -417,6 +414,7 @@ t.bigint "user_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.index ["team_id", "user_id"], name: "index_teams_users_on_team_id_and_user_id", unique: true t.index ["team_id"], name: "index_teams_users_on_team_id" t.index ["user_id"], name: "index_teams_users_on_user_id" end @@ -456,16 +454,15 @@ add_foreign_key "assignments_duties", "duties" add_foreign_key "courses", "institutions" add_foreign_key "courses", "users", column: "instructor_id" - add_foreign_key "invitations", "participants", column: "from_id" - add_foreign_key "invitations", "participants", column: "to_id" add_foreign_key "duties", "users", column: "instructor_id" + add_foreign_key "invitations", "teams", column: "from_id" add_foreign_key "items", "questionnaires" add_foreign_key "participants", "duties" add_foreign_key "participants", "join_team_requests" add_foreign_key "participants", "teams" add_foreign_key "participants", "users" add_foreign_key "project_topics", "assignments" - add_foreign_key "question_advices", "items", column: "question_id" + add_foreign_key "question_advices", "items" add_foreign_key "roles", "roles", column: "parent_id", on_delete: :cascade add_foreign_key "signed_up_teams", "project_topics" add_foreign_key "signed_up_teams", "teams" diff --git a/db/seeds.rb b/db/seeds.rb index d49c80f33..b942480ad 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,5 +1,23 @@ # frozen_string_literal: true +def seed_assignment_grades + puts "assigning seeded grades" + + AssignmentTeam.order(:id).find_each.with_index do |team, index| + team.update!( + grade_for_submission: team.grade_for_submission || 80 + (index % 16), + comment_for_submission: team.comment_for_submission.presence || "Seeded grade for #{team.name}" + ) + end + + AssignmentParticipant.order(:id).find_each.with_index do |participant, index| + next unless (index % 5).zero? + next if participant.grade.present? + + participant.update!(grade: 85 + (index % 10)) + end +end + begin # Create an instritution inst_id = Institution.create!( @@ -67,6 +85,30 @@ ).id end + puts "creating project topics" + project_topic_ids = [] + assignment_ids.each_with_index do |assignment_id, assignment_index| + 3.times do |topic_index| + topic_number = assignment_index * 3 + topic_index + 1 + project_topic = ProjectTopic.create( + assignment_id: assignment_id, + topic_identifier: "T#{topic_number}", + topic_name: "Project Topic #{topic_number}", + category: ["Design", "Implementation", "Testing"].sample, + max_choosers: rand(1..3), + description: "Seeded project topic #{topic_number} for assignment #{assignment_id}", + link: "https://example.com/project_topics/#{topic_number}" + ) + + if project_topic.persisted? + project_topic_ids << project_topic.id + puts "Created ProjectTopic with ID: #{project_topic.id}" + else + puts "Failed to create ProjectTopic: #{project_topic.errors.full_messages.join(', ')}" + end + end + end + puts "creating teams" team_ids = [] num_teams.times do |i| @@ -90,6 +132,23 @@ ).id end + puts "assigning students to courses" + course_participant_ids = [] + num_students.times do |i| + course_participant = CourseParticipant.create( + user_id: student_user_ids[i], + parent_id: course_ids[i % num_courses], + handle: Faker::Internet.unique.username + ) + + if course_participant.persisted? + puts "Created CourseParticipant with ID: #{course_participant.id}" + course_participant_ids << course_participant.id + else + puts "Failed to create CourseParticipant: #{course_participant.errors.full_messages.join(', ')}" + end + end + puts "assigning students to teams" teams_users_ids = [] # num_students.times do |i| @@ -135,6 +194,165 @@ end end + seed_assignment_grades + + puts "creating questionnaires with items, question advices, and answers" + questionnaire_blueprints = [ + { + name: 'Seed Review Questionnaire', + questionnaire_type: 'ReviewQuestionnaire', + display_type: 'Likert', + instruction_loc: 'seed/review_instructions', + items: [ + { + txt: 'How clear is the overall design?', + weight: 5, + seq: 1, + question_type: 'Scale', + break_before: true, + advices: [ + { score: 1, advice: 'Start by clarifying the main design goal.' }, + { score: 5, advice: 'Highlight the strongest design tradeoffs and rationale.' } + ] + }, + { + txt: 'How complete is the implementation?', + weight: 4, + seq: 2, + question_type: 'Scale', + break_before: true, + advices: [ + { score: 2, advice: 'Point out the missing edge cases and incomplete flows.' }, + { score: 4, advice: 'Call out the implemented features that work end-to-end.' } + ] + } + ] + }, + { + name: 'Seed Teammate Review Questionnaire', + questionnaire_type: 'TeammateReviewQuestionnaire', + display_type: 'Likert', + instruction_loc: 'seed/teammate_review_instructions', + items: [ + { + txt: 'How effectively did this teammate communicate?', + weight: 3, + seq: 1, + question_type: 'Scale', + break_before: true, + advices: [ + { score: 1, advice: 'Share examples of communication gaps and missed updates.' }, + { score: 3, advice: 'Mention consistent coordination and timely follow-ups.' } + ] + }, + { + txt: 'How reliable was this teammate in meeting commitments?', + weight: 5, + seq: 2, + question_type: 'Scale', + break_before: true, + advices: [ + { score: 2, advice: 'Describe any late or incomplete deliverables.' }, + { score: 5, advice: 'Recognize steady, dependable contribution across milestones.' } + ] + } + ] + }, + { + name: 'Seed Survey Questionnaire', + questionnaire_type: 'SurveyQuestionnaire', + display_type: 'Likert', + instruction_loc: 'seed/survey_instructions', + items: [ + { + txt: 'How useful were the project materials?', + weight: 4, + seq: 1, + question_type: 'Scale', + break_before: true, + advices: [ + { score: 1, advice: 'Note which project materials were hard to use or missing.' }, + { score: 4, advice: 'Mention the resources that were especially helpful.' } + ] + }, + { + txt: 'How confident do you feel about the project goals?', + weight: 4, + seq: 2, + question_type: 'Scale', + break_before: true, + advices: [ + { score: 2, advice: 'Explain where the goals or requirements still feel unclear.' }, + { score: 4, advice: 'Explain which goals are now clearly understood.' } + ] + } + ] + } + ] + + questionnaire_blueprints.each_with_index do |blueprint, index| + instructor = Instructor.find(instructor_user_ids[index % instructor_user_ids.length]) + assignment_id = assignment_ids[index % assignment_ids.length] + assignment_participants = AssignmentParticipant.where(parent_id: assignment_id).order(:id).limit(2) + + questionnaire = Questionnaire.create!( + name: blueprint[:name], + instructor: instructor, + private: false, + min_question_score: 0, + max_question_score: 5, + questionnaire_type: blueprint[:questionnaire_type], + display_type: blueprint[:display_type], + instruction_loc: blueprint[:instruction_loc] + ) + + response = nil + if assignment_participants.size == 2 + response_map = ResponseMap.create!( + reviewer_id: assignment_participants.first.id, + reviewee_id: assignment_participants.second.id, + reviewed_object_id: assignment_id + ) + + response = Response.create!( + map_id: response_map.id, + additional_comment: "Seeded response for #{questionnaire.name}", + is_submitted: true, + round: 1, + version_num: 1 + ) + end + + blueprint[:items].each do |item_blueprint| + item = Item.create!( + questionnaire: questionnaire, + txt: item_blueprint[:txt], + weight: item_blueprint[:weight], + seq: item_blueprint[:seq], + question_type: item_blueprint[:question_type], + break_before: item_blueprint[:break_before] + ) + + item_blueprint[:advices].each do |advice_blueprint| + QuestionAdvice.create!( + item: item, + score: advice_blueprint[:score], + advice: advice_blueprint[:advice] + ) + end + + next unless response + + Answer.create!( + item: item, + response: response, + answer: rand(questionnaire.min_question_score..questionnaire.max_question_score), + comments: "Seeded answer for #{item.txt}" + ) + end + end + rescue ActiveRecord::RecordInvalid => e puts e, 'The db has already been seeded' + seed_assignment_grades end diff --git a/spec/fixtures/files/empty.csv b/spec/fixtures/files/empty.csv new file mode 100644 index 000000000..e69de29bb diff --git a/spec/fixtures/files/empty_with_headers.csv b/spec/fixtures/files/empty_with_headers.csv new file mode 100644 index 000000000..ed444a5d4 --- /dev/null +++ b/spec/fixtures/files/empty_with_headers.csv @@ -0,0 +1 @@ +Name,Email,Password,Full Name,Role ID \ No newline at end of file diff --git a/spec/fixtures/files/import_test.csv b/spec/fixtures/files/import_test.csv new file mode 100644 index 000000000..896171415 --- /dev/null +++ b/spec/fixtures/files/import_test.csv @@ -0,0 +1,2 @@ +id,name +1,Alice \ No newline at end of file diff --git a/spec/fixtures/files/multiple_users_no_headers.csv b/spec/fixtures/files/multiple_users_no_headers.csv new file mode 100644 index 000000000..5c4114233 --- /dev/null +++ b/spec/fixtures/files/multiple_users_no_headers.csv @@ -0,0 +1,2 @@ +John,jdoe@email.com,password,John Doe,Student +Jane,jndoe@email.com,password,Jane Doe,Teaching Assistant diff --git a/spec/fixtures/files/multiple_users_with_headers.csv b/spec/fixtures/files/multiple_users_with_headers.csv new file mode 100644 index 000000000..22e7a7172 --- /dev/null +++ b/spec/fixtures/files/multiple_users_with_headers.csv @@ -0,0 +1,3 @@ +Name,Email,Password,Full Name,Role ID +John,jdoe@email.com,password,John Doe,4 +Jane,jndoe@email.com,password,Jane Doe,5 diff --git a/spec/fixtures/files/questionnaire_item_with_headers.csv b/spec/fixtures/files/questionnaire_item_with_headers.csv new file mode 100644 index 000000000..6c4d21c72 --- /dev/null +++ b/spec/fixtures/files/questionnaire_item_with_headers.csv @@ -0,0 +1,2 @@ +txt,weight,seq,Question type,Break before,Questionnaire Name,Question Advice Advice,Question Advice Score,Question Advice Advice,Question Advice Score +test,10,2,dropdown,TRUE,Test Questionnaire,okay,1,good,2 diff --git a/spec/fixtures/files/single_user_email_invalid.csv b/spec/fixtures/files/single_user_email_invalid.csv new file mode 100644 index 000000000..3e63bb2ff --- /dev/null +++ b/spec/fixtures/files/single_user_email_invalid.csv @@ -0,0 +1,2 @@ +Name,Email,Password,Full Name,Role ID +John,wrong,password,John Doe,4 diff --git a/spec/fixtures/files/single_user_no_headers.csv b/spec/fixtures/files/single_user_no_headers.csv new file mode 100644 index 000000000..525ea96d4 --- /dev/null +++ b/spec/fixtures/files/single_user_no_headers.csv @@ -0,0 +1 @@ +John,jdoe@email.com,password,John Doe,Student diff --git a/spec/fixtures/files/single_user_role_doe_not_exist.csv b/spec/fixtures/files/single_user_role_doe_not_exist.csv new file mode 100644 index 000000000..2c1ace5b3 --- /dev/null +++ b/spec/fixtures/files/single_user_role_doe_not_exist.csv @@ -0,0 +1,2 @@ +Name,Email,Password,Full Name,Role ID +John,jdoe@email.com,password,John Doe,10 diff --git a/spec/fixtures/files/single_user_with_headers.csv b/spec/fixtures/files/single_user_with_headers.csv new file mode 100644 index 000000000..687abaaac --- /dev/null +++ b/spec/fixtures/files/single_user_with_headers.csv @@ -0,0 +1,2 @@ +Name,Email,Password,Full Name,Role ID +John,jdoe@email.com,password,John Doe,4 diff --git a/spec/fixtures/files/single_user_with_headers_changed.csv b/spec/fixtures/files/single_user_with_headers_changed.csv new file mode 100644 index 000000000..f9765a73a --- /dev/null +++ b/spec/fixtures/files/single_user_with_headers_changed.csv @@ -0,0 +1,2 @@ +Name,Email,Password,Full Name,Role ID +John,jdoe@email.com,password,John Mulch,4 diff --git a/spec/fixtures/files/users_duplicate_records.csv b/spec/fixtures/files/users_duplicate_records.csv new file mode 100644 index 000000000..374fe5079 --- /dev/null +++ b/spec/fixtures/files/users_duplicate_records.csv @@ -0,0 +1,6 @@ +Name,Email,Password,Full Name,Role ID +John,jdoe@email.com,password,John Doe,4 +John,jdoe@email.com,password,John Doe,4 +John,jdoe@email.com,password,John Doe,4 +Jane,jndoe@email.com,password,Jane Doe,5 +Jane,jndoe@email.com,password,Jane Doe,5 diff --git a/spec/helpers/import_export_spec.rb b/spec/helpers/import_export_spec.rb new file mode 100644 index 000000000..1ae3bec77 --- /dev/null +++ b/spec/helpers/import_export_spec.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'csv' + +RSpec.describe ImportableExportableHelper, type: :helper do + include RolesHelper + + + before(:all) do + @roles = create_roles_hierarchy + + @institution = Institution.create!(name: 'NC State') + + + @instructor = Instructor.create!( + name: 'instructor', + full_name: 'Instructor User', + email: 'instructor@example.com', + password_digest: 'password', + role: @roles[:instructor], + institution: @institution + ) + + @questionnaire = Questionnaire.create!( + name: 'Test Questionnaire', + questionnaire_type: '', + private: true, + min_question_score: 1, + max_question_score: 10, + instructor: @instructor + ) + end + + + describe 'Create tests for each of the different importable classes' do + it 'Import a class with no headers' do + expect(User.count).to eq(1) + + csv_file = file_fixture('single_user_no_headers.csv') + headers = ['Name', 'Email', 'Password', 'Full Name', 'Role Name'] + + pp User.try_import_records(csv_file, headers, false) + + expect(User.count).to eq(2) + expect(User.find_by(email: 'jdoe@email.com')).to be_present + end + + it 'Import a class with headers' do + expect(User.count).to eq(1) + + csv_file = file_fixture('single_user_with_headers.csv') + + User.try_import_records(csv_file, nil, use_header: true) + + expect(User.count).to eq(2) + expect(User.find_by(email: 'jdoe@email.com')).to be_present + end + + it 'Import a file with multiple records' do + expect(User.count).to eq(1) + + csv_file = file_fixture('multiple_users_with_headers.csv') + + User.try_import_records(csv_file, nil, use_header: true) + + expect(User.count).to eq(3) + expect(User.find_by(email: 'jdoe@email.com')).to be_present + expect(User.find_by(email: 'jndoe@email.com')).to be_present + end + + it 'Import a class with external lookup and create classes, and can take duplicate headers' do + expect(Questionnaire.count).to eq(1) + expect(Questionnaire.find_by(name: 'Test Questionnaire')).to be_present + expect(QuizItem.count).to eq(0) + expect(QuestionAdvice.count).to eq(0) + + csv_file = file_fixture('questionnaire_item_with_headers.csv') + QuizItem.try_import_records(csv_file, nil, true) + + expect(QuizItem.count).to eq(1) + expect(QuizItem.find_by(txt: 'test')).to be_present + + expect(QuestionAdvice.count).to eq(2) + + advice_one = QuestionAdvice.find_by(advice: 'okay') + expect(advice_one).to be_present + expect(advice_one.score).to eq(1) + expect(advice_one.item.txt).to eq('test') + + advice_two = QuestionAdvice.find_by(advice: 'good') + expect(advice_two).to be_present + expect(advice_two.score).to eq(2) + expect(advice_two.item.txt).to eq('test') + end + end + + + # * Create a test with external lookup class that doesn't exist + # * Create a test with an empty CSV (With Headers) + # * Create a test with an empty CSV (Without Headers) + describe 'Create Tests to test Errors/Edge Cases' do + it 'Import a class with an invalid field (User with invalid email)' do + csv_file = file_fixture('single_user_email_invalid.csv') + + expect {User.try_import_records(csv_file, nil, true)}.not_to change(User, :count) + end + + it 'Import a class with external lookup class that does not exist' do + expect(User.count).to eq(1) + + csv_file = file_fixture('single_user_role_doe_not_exist.csv') + + expect { User.try_import_records(csv_file, nil, true) }.not_to change(User, :count) + + expect(User.count).to eq(1) + expect(User.find_by(email: 'jdoe@email.com')).not_to be_present + end + + it 'Import an empty CSV (With Headers)' do + csv_file = file_fixture('empty_with_headers.csv') + + expect{User.try_import_records(csv_file, nil, true)}.not_to change(User, :count) + end + + it 'Import an empty CSV (Without Headers)' do + csv_file = file_fixture('empty.csv') + + expect{User.try_import_records(csv_file, [], false)}.not_to change(User, :count) + end + end +end diff --git a/spec/integration/export_controller_spec.rb b/spec/integration/export_controller_spec.rb new file mode 100644 index 000000000..11479f4a0 --- /dev/null +++ b/spec/integration/export_controller_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true +require "rails_helper" + +RSpec.describe "Export API", type: :request do + # + # Authentication + authorization bypass + # + before do + allow_any_instance_of(JwtToken) + .to receive(:authenticate_request!) + .and_return(true) + + allow_any_instance_of(Authorization) + .to receive(:authorize) + .and_return(true) + end + + # + # Fake model used for constantize + # + class FakeModel + def self.mandatory_fields; ["id", "name"]; end + def self.optional_fields; ["email"]; end + def self.external_fields; ["institution"]; end + end + + describe "GET /export/:class" do + it "returns mandatory, optional, and external fields with status 200" do + get "/export/FakeModel" + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + + expect(json["mandatory_fields"]).to eq(["id", "name"]) + expect(json["optional_fields"]).to eq(["email"]) + expect(json["external_fields"]).to eq(["institution"]) + end + end + + describe "POST /export/:class" do + it "returns 200 and calls Export.perform with ordered fields" do + ordered_fields = ["id", "name"] + export_return = [{ name: "FakeModel", contents: "fake_csv_data" }] + + expect(Export).to receive(:perform) + .with(FakeModel, ordered_fields) + .and_return(export_return) + + post "/export/FakeModel", params: { + ordered_fields: ordered_fields.to_json + } + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + + expect(json["message"]).to eq("FakeModel has been exported!") + expect(json["file"]).to eq([{ "name" => "FakeModel", "contents" => "fake_csv_data" }]) + end + + it "passes nil ordered_fields when none are provided" do + export_return = [{ name: "FakeModel", contents: "csv_without_ordering" }] + + expect(Export).to receive(:perform) + .with(FakeModel, nil) + .and_return(export_return) + + post "/export/FakeModel" + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json["file"]).to eq([{ "name" => "FakeModel", "contents" => "csv_without_ordering" }]) + end + + it "returns 422 if constantize fails" do + post "/export/DoesNotExist" + + expect(response.status).to eq(422) + end + + it "returns 422 if Export.perform raises an error" do + allow(Export).to receive(:perform) + .and_raise(StandardError.new("Boom!")) + + post "/export/FakeModel", params: { ordered_fields: ["id"].to_json } + + expect(response.status).to eq(422) + json = JSON.parse(response.body) + expect(json["error"]).to eq("Boom!") + end + + it "returns 422 if ordered_fields is invalid JSON" do + post "/export/FakeModel", params: { + ordered_fields: "not-json" + } + + expect(response.status).to eq(422) + end + end +end diff --git a/spec/integration/import_controller_spec.rb b/spec/integration/import_controller_spec.rb new file mode 100644 index 000000000..151e18323 --- /dev/null +++ b/spec/integration/import_controller_spec.rb @@ -0,0 +1,151 @@ +require "rails_helper" + +RSpec.describe "Import API", type: :request do + # + # Disable BOTH authentication layers: + # • JwtToken.authenticate_request! + # • Authorization.authorize + # + before do + allow_any_instance_of(JwtToken) + .to receive(:authenticate_request!) + .and_return(true) + + allow_any_instance_of(Authorization) + .to receive(:authorize) + .and_return(true) + end + + # + # Stub a fake model class for import operations + # + before do + stub_const("FakeModel", Class.new do + class << self + attr_accessor :mandatory_fields, :optional_fields, :external_fields, :available_actions_on_duplicate + end + + def self.try_import_records(*args); end + end) + + allow(FakeModel).to receive(:mandatory_fields).and_return(["id", "name"]) + allow(FakeModel).to receive(:optional_fields).and_return(["email"]) + allow(FakeModel).to receive(:external_fields).and_return(["mentor_id"]) + allow(FakeModel).to receive(:available_actions_on_duplicate).and_return([]) + allow(FakeModel).to receive(:try_import_records).and_return([]) + end + + # + # Fixture file used for import + # + let(:file_path) { Rails.root.join("spec/fixtures/files/import_test.csv") } + let(:uploaded_file) { Rack::Test::UploadedFile.new(file_path, "text/csv") } + + # ------------------------------------------------------------ + # BASIC TESTS + # ------------------------------------------------------------ + + describe "GET /import/:class" do + it "returns metadata with status 200" do + get "/import/FakeModel" + + expect(response.status).to eq(200) + + json = JSON.parse(response.body) + expect(json["mandatory_fields"]).to eq(["id", "name"]) + expect(json["optional_fields"]).to eq(["email"]) + expect(json["external_fields"]).to eq(["mentor_id"]) + expect(json["available_actions_on_dup"]).to eq([]) + end + end + + describe "POST /import/:class" do + it "returns 201 when import succeeds" do + post "/import/FakeModel", + params: { + csv_file: uploaded_file, + use_headers: true, + ordered_fields: ["id", "name"].to_json + } + + expect(response.status).to eq(201) + expect(JSON.parse(response.body)["message"]) + .to eq("FakeModel has been imported!") + end + + it "returns 422 when import raises an error" do + allow(FakeModel).to receive(:try_import_records) + .and_raise(StandardError.new("BOOM")) + + post "/import/FakeModel", + params: { + csv_file: uploaded_file, + use_headers: true + } + + expect(response.status).to eq(422) + expect(JSON.parse(response.body)["error"]).to eq("BOOM") + end + end + + # ------------------------------------------------------------ + # ADDITIONAL EDGE CASE TESTS + # ------------------------------------------------------------ + + describe "Additional ImportController tests" do + it "returns 500 if class constantization fails" do + get "/import/ThisModelDoesNotExist" + + expect(response.status).to eq(500) + expect(response.body).to include("uninitialized constant") + end + + it "returns 201 even if csv_file is missing (controller allows nil file)" do + post "/import/FakeModel", params: { use_headers: true } + + expect(response.status).to eq(201) + expect(JSON.parse(response.body)["message"]) + .to eq("FakeModel has been imported!") + end + + it "allows POST without ordered_fields" do + post "/import/FakeModel", + params: { + csv_file: uploaded_file, + use_headers: "false" + } + + expect(response.status).to eq(201) + end + + it "correctly passes use_headers as boolean" do + post "/import/FakeModel", + params: { + csv_file: uploaded_file, + use_headers: "false", + ordered_fields: ["id"].to_json + } + + expect(FakeModel) + .to have_received(:try_import_records) + .with( + kind_of(ActionDispatch::Http::UploadedFile), + ["id"], + false, + {} + ) + end + + it "returns 422 for malformed ordered_fields JSON" do + post "/import/FakeModel", + params: { + csv_file: uploaded_file, + use_headers: true, + ordered_fields: "{ this is invalid json" + } + + expect(response.status).to eq(422) + expect(JSON.parse(response.body)["error"]).to be_present + end + end +end diff --git a/spec/models/team_import_export_spec.rb b/spec/models/team_import_export_spec.rb new file mode 100644 index 000000000..c28ea9f9e --- /dev/null +++ b/spec/models/team_import_export_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'csv' +require 'tempfile' + +RSpec.describe Team, type: :model do + describe 'team import/export' do + it 'exports assignment teams with participant username columns' do + assignment = create(:assignment) + user_one = create(:user, :student, name: 'student_export_one', full_name: 'Student Export One') + user_two = create(:user, :student, name: 'student_export_two', full_name: 'Student Export Two') + participant_one = create(:assignment_participant, assignment: assignment, user: user_one) + participant_two = create(:assignment_participant, assignment: assignment, user: user_two) + team = AssignmentTeam.create!(name: 'Export Team', parent_id: assignment.id, type: 'AssignmentTeam') + + expect(team.add_member(participant_one)[:success]).to be(true) + expect(team.add_member(participant_two)[:success]).to be(true) + + export_payload = Team.with_assignment_context(assignment.id) do + Export.perform(Team, %w[name participant_1 participant_2 participant_3]) + end + csv_text = export_payload.first[:contents] + + rows = CSV.parse(csv_text, headers: true) + exported_row = rows.find { |row| row['name'] == 'Export Team' } + + expect(exported_row).not_to be_nil + expect(exported_row['participant_1']).to eq(user_one.name) + expect(exported_row['participant_2']).to eq(user_two.name) + expect(exported_row['participant_3']).to be_blank + end + + it 'imports assignment teams and attaches members from participant username columns' do + assignment = create(:assignment) + user_one = create(:user, :student, name: 'student_import_one', full_name: 'Student Import One') + user_two = create(:user, :student, name: 'student_import_two', full_name: 'Student Import Two') + participant_one = create(:assignment_participant, assignment: assignment, user: user_one) + participant_two = create(:assignment_participant, assignment: assignment, user: user_two) + + file = Tempfile.new(['team-import', '.csv']) + file.write("name,participant_1,participant_2\n") + file.write("Imported Team,#{user_one.name},#{user_two.name}\n") + file.rewind + + expect do + Team.with_assignment_context(assignment.id) do + Team.try_import_records(file.path, nil, true, assignment_id: assignment.id) + end + end.to change { AssignmentTeam.where(name: 'Imported Team', parent_id: assignment.id).count }.by(1) + + imported_team = AssignmentTeam.find_by!(name: 'Imported Team', parent_id: assignment.id) + expect(imported_team.participants).to include(participant_one, participant_two) + ensure + file.close! + end + + it 'imports assignment teams without a team name' do + assignment = create(:assignment) + user = create(:user, :student, name: 'student_import_without_team_name', full_name: 'Student Without Team Name') + participant = create(:assignment_participant, assignment: assignment, user: user) + + file = Tempfile.new(['team-import', '.csv']) + file.write("participant_1\n") + file.write("#{user.name}\n") + file.rewind + + expect do + Team.with_assignment_context(assignment.id) do + Team.try_import_records(file.path, nil, true, assignment_id: assignment.id) + end + end.to change { AssignmentTeam.where(parent_id: assignment.id).count }.by(1) + + imported_team = AssignmentTeam.find_by!(parent_id: assignment.id) + expect(imported_team.name).to eq("Team_#{assignment.id}_#{user.name}") + expect(imported_team.participants).to include(participant) + ensure + file.close! + end + end +end diff --git a/spec/requests/api/v1/export_spec.rb b/spec/requests/api/v1/export_spec.rb new file mode 100644 index 000000000..75368e956 --- /dev/null +++ b/spec/requests/api/v1/export_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'swagger_helper' +require 'json_web_token' + +RSpec.describe 'Export API', type: :request do + before(:all) do + @roles = create_roles_hierarchy + end + + let(:adm) { + User.create( + name: "adma", + password_digest: "password", + role_id: @roles[:admin].id, + full_name: "Admin A", + email: "testuser@example.com", + mru_directory_path: "/home/testuser", + ) + } + + let(:token) { JsonWebToken.encode({id: adm.id}) } + let(:Authorization) { "Bearer #{token}" } + + path '/export/{id}' do + parameter name: 'id', in: :path, type: :string, description: 'class name' + + let(:id) { "User" } + + get('Show Class Fields for Export') do + tags 'Export' + response(200, 'successful') do + + after do |example| + example.metadata[:response][:content] = { + 'application/json' => { + example: JSON.parse(response.body, symbolize_names: true) + } + } + end + run_test! do |response| + data = JSON.parse(response.body) + pp data + expect(data["mandatory_fields"].length).to eq(4) + expect(data).to have_key("optional_fields") + expect(data).to have_key('external_fields') + end + end + end + end +end diff --git a/spec/requests/api/v1/grades_controller_spec.rb b/spec/requests/api/v1/grades_controller_spec.rb index e78a3ffdf..ab9223657 100644 --- a/spec/requests/api/v1/grades_controller_spec.rb +++ b/spec/requests/api/v1/grades_controller_spec.rb @@ -1,5 +1,6 @@ -require 'swagger_helper' -require 'json_web_token' +require 'swagger_helper' +require 'json_web_token' +require 'csv' RSpec.describe 'Grades API', type: :request do before(:all) do @@ -63,9 +64,70 @@ let(:ta_token) { JsonWebToken.encode({id: ta.id}) } let(:student_token) { JsonWebToken.encode({id: student.id}) } - let(:Authorization) { "Bearer #{instructor_token}" } - - path '/grades/{assignment_id}/view_all_scores' do + let(:Authorization) { "Bearer #{instructor_token}" } + + path '/grades/{assignment_id}/export' do + get 'Export assignment grades as CSV' do + tags 'Grades' + produces 'text/csv' + security [bearer_auth: []] + + parameter name: :assignment_id, in: :path, type: :integer, description: 'ID of the assignment' + parameter name: :include_email, in: :query, type: :boolean, required: false, description: 'Include participant email addresses' + parameter name: 'Authorization', in: :header, type: :string, required: true, description: 'Bearer token' + + response '200', 'Returns grades CSV' do + let(:assignment_id) { assignment.id } + + before do + team.update!(grade_for_submission: 95, comment_for_submission: 'Excellent work!') + participant2.update!(grade: 88) + end + + run_test! do |response| + rows = CSV.parse(response.body, headers: true) + + expect(rows.headers).to eq(%w[username grade comment]) + expect(rows.size).to eq(2) + + rows_by_username = rows.index_by { |row| row['username'] } + expect(rows_by_username[student.name]['grade']).to eq('95') + expect(rows_by_username[student.name]['comment']).to eq('Excellent work!') + expect(rows_by_username[student2.name]['grade']).to eq('88.0') + expect(rows_by_username[student2.name]['comment']).to eq('Excellent work!') + end + end + + response '200', 'Returns grades CSV with optional email column' do + let(:assignment_id) { assignment.id } + let(:include_email) { true } + + before do + team.update!(grade_for_submission: 95, comment_for_submission: 'Excellent work!') + end + + run_test! do |response| + rows = CSV.parse(response.body, headers: true) + + expect(rows.headers).to eq(%w[username grade comment email]) + rows_by_username = rows.index_by { |row| row['username'] } + expect(rows_by_username[student.name]['email']).to eq(student.email) + expect(rows_by_username[student2.name]['email']).to eq(student2.email) + end + end + + response '403', 'Forbidden - Student cannot export grades' do + let(:assignment_id) { assignment.id } + let(:Authorization) { "Bearer #{student_token}" } + + run_test! do |response| + expect(JSON.parse(response.body)['error']).to eq('You are not authorized to export this grades') + end + end + end + end + + path '/grades/{assignment_id}/view_all_scores' do get 'Retrieve all review scores for an assignment' do tags 'Grades' produces 'application/json' @@ -432,4 +494,4 @@ expect(response).to have_http_status(:forbidden) end end -end \ No newline at end of file +end diff --git a/spec/requests/api/v1/import_spec.rb b/spec/requests/api/v1/import_spec.rb new file mode 100644 index 000000000..24c454696 --- /dev/null +++ b/spec/requests/api/v1/import_spec.rb @@ -0,0 +1,49 @@ +require 'swagger_helper' + +RSpec.describe 'api/v1/import', type: :request do + before(:all) do + @roles = create_roles_hierarchy + end + + let(:adm) { + User.create( + name: "adma", + password_digest: "password", + role_id: @roles[:admin].id, + full_name: "Admin A", + email: "testuser@example.com", + mru_directory_path: "/home/testuser", + ) + } + + let(:token) { JsonWebToken.encode({id: adm.id}) } + let(:Authorization) { "Bearer #{token}" } + + path '/import/{id}' do + parameter name: 'id', in: :path, type: :string, description: 'class name' + + let(:id) { "User" } + + get('Show Class Fields for Import') do + tags 'Import' + response(200, 'successful') do + + after do |example| + example.metadata[:response][:content] = { + 'application/json' => { + example: JSON.parse(response.body, symbolize_names: true) + } + } + end + run_test! do |response| + data = JSON.parse(response.body) + pp data + expect(data["mandatory_fields"].length).to eq(4) + expect(data).to have_key("optional_fields") + expect(data).to have_key('external_fields') + expect(data["available_actions_on_dup"].length).to eq(3) + end + end + end + end +end diff --git a/spec/requests/api/v1/questionnaires_controller_spec.rb b/spec/requests/api/v1/questionnaires_controller_spec.rb index 046efd83e..0766a0687 100644 --- a/spec/requests/api/v1/questionnaires_controller_spec.rb +++ b/spec/requests/api/v1/questionnaires_controller_spec.rb @@ -285,6 +285,65 @@ end end + path '/questionnaires/{id}/items' do + parameter name: 'id', in: :path, type: :integer + + let(:valid_questionnaire_params) do + { + name: 'Test Questionnaire With Items', + questionnaire_type: 'ReviewQuestionnaire', + private: false, + min_question_score: 0, + max_question_score: 5, + instructor_id: prof.id + } + end + + let(:questionnaire) do + prof + Questionnaire.create!(valid_questionnaire_params) + end + + let(:id) do + Item.create!( + questionnaire: questionnaire, + txt: 'Second item', + weight: 1, + seq: 2, + question_type: 'Scale', + break_before: true + ) + Item.create!( + questionnaire: questionnaire, + txt: 'First item', + weight: 1, + seq: 1, + question_type: 'Scale', + break_before: true + ) + questionnaire.id + end + + get('list questionnaire items') do + tags 'Questionnaires' + produces 'application/json' + + response(200, 'successful') do + run_test! do + body = JSON.parse(response.body) + expect(body.map { |item| item['txt'] }).to eq(['First item', 'Second item']) + end + end + + response(404, 'not found') do + let(:id) { 0 } + run_test! do + expect(response.body).to include("Couldn't find Questionnaire") + end + end + end + end + path '/questionnaires/toggle_access/{id}' do parameter name: 'id', in: :path, type: :integer let(:valid_questionnaire_params) do @@ -388,4 +447,4 @@ end end end -end \ No newline at end of file +end diff --git a/spec/requests/import_export_requests_spec.rb b/spec/requests/import_export_requests_spec.rb new file mode 100644 index 000000000..927045918 --- /dev/null +++ b/spec/requests/import_export_requests_spec.rb @@ -0,0 +1,513 @@ +# frozen_string_literal: true + +require "rails_helper" +require "csv" +require "tempfile" + +RSpec.describe "Import/export requests", type: :request do + before do + allow_any_instance_of(JwtToken) + .to receive(:authenticate_request!) + .and_return(true) + + allow_any_instance_of(Authorization) + .to receive(:authorize) + .and_return(true) + end + + def uploaded_csv(contents) + file = Tempfile.new(["import", ".csv"]) + file.write(contents) + file.rewind + Rack::Test::UploadedFile.new(file.path, "text/csv") + end + + describe "GET /import/:class" do + context "metadata responses" do + it "returns metadata for Team" do + get "/import/Team", params: { assignment_id: 1 } + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + expect(json["mandatory_fields"]).to eq(["participant_1"]) + expect(json["optional_fields"]).to include("name") + expect(json["available_actions_on_dup"]).to match_array( + %w[SkipRecordAction UpdateExistingRecordAction ChangeOffendingFieldAction] + ) + end + + it "returns metadata for ProjectTopic" do + get "/import/ProjectTopic" + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + expect(json["mandatory_fields"]).to eq(["topic_name"]) + expect(json["optional_fields"]).to include("assignment_id") + expect(json["available_actions_on_dup"]).to match_array( + %w[SkipRecordAction UpdateExistingRecordAction ChangeOffendingFieldAction] + ) + end + + it "returns metadata for AssignmentParticipant" do + get "/import/AssignmentParticipant", params: { assignment_id: 1 } + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + expect(json["mandatory_fields"]).to eq(["user_name"]) + expect(json["optional_fields"]).to eq([]) + expect(json["available_actions_on_dup"]).to match_array( + %w[SkipRecordAction UpdateExistingRecordAction] + ) + end + + it "returns role_name and institution_name as external fields for User import" do + Role.create!(name: "Super Administrator", parent_id: nil) + + get "/import/User" + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + expect(json["mandatory_fields"]).to include("name", "email", "password", "full_name") + expect(json["mandatory_fields"]).not_to include("role_id", "institution_id") + expect(json["external_fields"]).to include("role_name", "institution_name") + end + + it "returns course participant import metadata with user_name" do + get "/import/CourseParticipant", params: { course_id: 1 } + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + expect(json["mandatory_fields"]).to eq(["user_name"]) + expect(json["optional_fields"]).to eq([]) + expect(json["available_actions_on_dup"]).to match_array( + %w[SkipRecordAction UpdateExistingRecordAction] + ) + end + + it "does not expose instruction_loc for Questionnaire import metadata" do + get "/import/Questionnaire" + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + expect(json["mandatory_fields"]).not_to include("instruction_loc") + expect(json["optional_fields"]).not_to include("instruction_loc") + end + end + end + + describe "POST /import/:class" do + let!(:instructor_role) do + Role.create!(name: "Instructor", parent_id: nil) + end + + let!(:student_role) do + Role.create!(name: "Student", parent_id: instructor_role.id) + end + + let!(:institution) do + Institution.create!(name: "NC State") + end + + let!(:instructor) do + User.create!( + name: "teacher", + full_name: "Teacher Example", + email: "teacher@example.com", + password: "password", + role: instructor_role, + institution: institution + ) + end + + before do + allow_any_instance_of(ImportController) + .to receive(:current_user) + .and_return(instructor) + end + + let!(:assignment) do + Assignment.create!( + name: "Import Assignment", + instructor: instructor + ) + end + + let!(:course) do + Course.create!( + name: "Import Course", + directory_path: "import_course", + instructor: instructor, + institution: institution + ) + end + + context "team imports" do + it "imports teams" do + student = User.create!( + name: "student_team_import", + full_name: "Student Team Import", + email: "student_team_import@example.com", + password: "password", + role: student_role, + institution: institution + ) + participant = AssignmentParticipant.create!(user: student, parent_id: assignment.id, handle: student.name) + file = uploaded_csv("name,participant_1\nTeam Alpha,#{student.name}\n") + + post "/import/Team", + params: { + csv_file: file, + use_headers: true, + dup_action: "SkipRecordAction", + assignment_id: assignment.id + } + + expect(response).to have_http_status(:created) + imported_team = AssignmentTeam.find_by(name: "Team Alpha", parent_id: assignment.id) + expect(imported_team).to be_present + expect(imported_team.participants).to include(participant) + end + end + + context "topic imports" do + it "imports topics" do + file = uploaded_csv("topic_name,assignment_id\nTopic A,#{assignment.id}\n") + + post "/import/ProjectTopic", + params: { + csv_file: file, + use_headers: true, + dup_action: "SkipRecordAction" + } + + expect(response).to have_http_status(:created) + expect(ProjectTopic.find_by(topic_name: "Topic A", assignment_id: assignment.id)).to be_present + end + + it "uses assignment_id context as a default for topic imports" do + file = uploaded_csv("topic_name\nTopic From Context\n") + + post "/import/ProjectTopic", + params: { + csv_file: file, + use_headers: true, + dup_action: "SkipRecordAction", + assignment_id: assignment.id + } + + expect(response).to have_http_status(:created) + expect(ProjectTopic.find_by(topic_name: "Topic From Context", assignment_id: assignment.id)).to be_present + end + end + + context "assignment participant imports" do + it "imports assignment participants by username within the selected assignment" do + student = User.create!( + name: "student_participant_import", + full_name: "Student Participant Import", + email: "student_participant_import@example.com", + password: "password", + role: student_role, + institution: institution + ) + file = uploaded_csv("user_name\n#{student.name}\n") + + post "/import/AssignmentParticipant", + params: { + csv_file: file, + use_headers: true, + dup_action: "SkipRecordAction", + assignment_id: assignment.id + } + + expect(response).to have_http_status(:created) + expect(AssignmentParticipant.find_by(user: student, parent_id: assignment.id)).to be_present + end + end + + context "user imports" do + it "imports users with parent and institution defaults using role_name" do + file = uploaded_csv("name,email,password,full_name,role_name\nstudentone,student1@example.com,password,Student One,Student\n") + + post "/import/User", + params: { + csv_file: file, + use_headers: true, + dup_action: "SkipRecordAction" + } + + expect(response).to have_http_status(:created) + + imported_user = User.find_by(email: "student1@example.com") + expect(imported_user).to be_present + expect(imported_user.parent_id).to eq(instructor.id) + expect(imported_user.institution_id).to eq(institution.id) + expect(imported_user.role_id).to eq(student_role.id) + end + + it "imports users using role_name and institution_name" do + other_institution = Institution.create!(name: "Other School") + file = uploaded_csv("name,full_name,email,password,role_name,institution_name\nstudenttwo,Student Two,student2@example.com,password,Student,Other School\n") + + post "/import/User", + params: { + csv_file: file, + use_headers: true, + dup_action: "SkipRecordAction" + } + + expect(response).to have_http_status(:created) + + imported_user = User.find_by(email: "student2@example.com") + expect(imported_user).to be_present + expect(imported_user.institution_id).to eq(other_institution.id) + expect(imported_user.parent_id).to eq(instructor.id) + expect(imported_user.role_id).to eq(student_role.id) + end + end + + context "course participant imports" do + it "imports course participants by username within the selected course" do + student = User.create!( + name: "student_course_participant_import", + full_name: "Student Course Participant Import", + email: "student_course_participant_import@example.com", + password: "password", + role: student_role, + institution: institution + ) + file = uploaded_csv("user_name\n#{student.name}\n") + + post "/import/CourseParticipant", + params: { + csv_file: file, + use_headers: true, + dup_action: "SkipRecordAction", + course_id: course.id + } + + expect(response).to have_http_status(:created) + expect(CourseParticipant.find_by(user: student, parent_id: course.id)).to be_present + end + end + end + + describe "POST /export/:class" do + let!(:role) do + Role.create!(name: "Instructor", parent_id: nil) + end + + let!(:institution) do + Institution.create!(name: "NC State") + end + + let!(:instructor) do + User.create!( + name: "teacher_export", + full_name: "Teacher Export", + email: "teacher_export@example.com", + password: "password", + role: role, + institution: institution + ) + end + + let!(:assignment) do + Assignment.create!( + name: "Export Assignment", + instructor: instructor + ) + end + + let!(:team) do + AssignmentTeam.create!( + name: "Export Team", + parent_id: assignment.id, + type: "AssignmentTeam" + ) + end + + let!(:topic) do + ProjectTopic.create!( + topic_name: "Export Topic", + assignment_id: assignment.id + ) + end + + context "team exports" do + it "exports teams" do + participant_user = User.create!( + name: "student_team_export", + full_name: "Student Team Export", + email: "student_team_export@example.com", + password: "password", + role: role, + institution: institution + ) + participant_role = Role.find_or_create_by!(name: "Student", parent_id: role.id) + participant_user.update!(role: participant_role) + participant = AssignmentParticipant.create!(user: participant_user, parent_id: assignment.id, handle: participant_user.name) + team.add_member(participant) + + post "/export/Team", params: { ordered_fields: %w[name participant_1].to_json, assignment_id: assignment.id } + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + exported_file = Array(json["file"]).first + expect(exported_file["contents"]).to include("name,participant_1") + expect(exported_file["contents"]).to include("Export Team,#{participant_user.name}") + end + end + + context "questionnaire exports" do + it "does not expose instruction_loc in export metadata" do + get "/export/Questionnaire" + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + expect(json["mandatory_fields"]).not_to include("instruction_loc") + expect(json["optional_fields"]).not_to include("instruction_loc") + end + end + + context "topic exports" do + it "exports topics" do + post "/export/ProjectTopic", params: { ordered_fields: %w[topic_name assignment_id].to_json } + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + exported_file = Array(json["file"]).first + expect(exported_file["contents"]).to include("topic_name,assignment_id") + expect(exported_file["contents"]).to include("Export Topic") + end + + it "exports topics without assignment_id when it is not selected" do + post "/export/ProjectTopic", params: { ordered_fields: %w[topic_name].to_json, assignment_id: assignment.id } + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + exported_file = Array(json["file"]).first + rows = CSV.parse(exported_file["contents"]) + expect(rows.first).to eq(["topic_name"]) + expect(rows.flatten).to include("Export Topic") + expect(exported_file["contents"]).not_to include("assignment_id") + end + + it "scopes topic exports to the provided assignment_id" do + other_assignment = Assignment.create!( + name: "Other Export Assignment", + instructor: instructor + ) + ProjectTopic.create!( + topic_name: "Other Export Topic", + assignment_id: other_assignment.id + ) + + post "/export/ProjectTopic", params: { ordered_fields: %w[topic_name assignment_id].to_json, assignment_id: assignment.id } + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + exported_file = Array(json["file"]).first + expect(exported_file["contents"]).to include("Export Topic") + expect(exported_file["contents"]).not_to include("Other Export Topic") + end + end + + context "assignment participant exports" do + it "exports only participants for the provided assignment_id" do + other_assignment = Assignment.create!( + name: "Other Participant Export Assignment", + instructor: instructor + ) + student = User.create!( + name: "student_participant_export", + full_name: "Student Participant Export", + email: "student_participant_export@example.com", + password: "password", + role: role, + institution: institution + ) + other_student = User.create!( + name: "other_student_participant_export", + full_name: "Other Student Participant Export", + email: "other_student_participant_export@example.com", + password: "password", + role: role, + institution: institution + ) + AssignmentParticipant.create!(user: student, parent_id: assignment.id, handle: student.name) + AssignmentParticipant.create!(user: other_student, parent_id: other_assignment.id, handle: other_student.name) + + post "/export/AssignmentParticipant", params: { ordered_fields: %w[user_name].to_json, assignment_id: assignment.id } + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + exported_file = Array(json["file"]).first + expected_name = "assignmentparticipant_#{assignment.name}_#{assignment.id}".parameterize(separator: "_") + expect(exported_file["name"]).to eq(expected_name) + expect(exported_file["contents"]).to include("student_participant_export") + expect(exported_file["contents"]).not_to include("other_student_participant_export") + end + end + + context "course participant exports" do + it "exports only participants for the provided course_id" do + course = Course.create!( + name: "Participant Export Course", + directory_path: "participant_export_course", + instructor: instructor, + institution: institution + ) + other_course = Course.create!( + name: "Other Participant Export Course", + directory_path: "other_participant_export_course", + instructor: instructor, + institution: institution + ) + student = User.create!( + name: "student_course_participant_export", + full_name: "Student Course Participant Export", + email: "student_course_participant_export@example.com", + password: "password", + role: role, + institution: institution + ) + other_student = User.create!( + name: "other_student_course_participant_export", + full_name: "Other Student Course Participant Export", + email: "other_student_course_participant_export@example.com", + password: "password", + role: role, + institution: institution + ) + CourseParticipant.create!(user: student, parent_id: course.id, handle: student.name) + CourseParticipant.create!(user: other_student, parent_id: other_course.id, handle: other_student.name) + + post "/export/CourseParticipant", params: { ordered_fields: %w[user_name].to_json, course_id: course.id } + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + exported_file = Array(json["file"]).first + expected_name = "courseparticipant_#{course.name}_#{course.id}".parameterize(separator: "_") + + expect(exported_file["name"]).to eq(expected_name) + expect(exported_file["contents"]).to include("user_name") + expect(exported_file["contents"]).to include("student_course_participant_export") + expect(exported_file["contents"]).not_to include("other_student_course_participant_export") + end + end + end +end diff --git a/spec/requests/questionnaire_packages_spec.rb b/spec/requests/questionnaire_packages_spec.rb new file mode 100644 index 000000000..f754f9c45 --- /dev/null +++ b/spec/requests/questionnaire_packages_spec.rb @@ -0,0 +1,603 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'csv' +require 'json' +require 'zip' + +RSpec.describe 'QuestionnairePackages API', type: :request do + before do + allow_any_instance_of(JwtToken) + .to receive(:authenticate_request!) + .and_return(true) + + allow_any_instance_of(Authorization) + .to receive(:authorize) + .and_return(true) + end + + describe 'GET /questionnaire_packages/config' do + it 'returns questionnaire package configuration' do + get '/questionnaire_packages/config' + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + expect(json['required_files']).to include('manifest.json', 'questionnaires.csv', 'items.csv') + expect(json['required_files']).not_to include('question_advices.csv') + expect(json['csv_header_requirements']['questionnaires']).to include('name', 'questionnaire_type', 'instructor_name') + expect(json['csv_header_requirements']['questionnaires']).not_to include('instruction_loc') + expect(json['csv_header_requirements']['items']).to include('questionnaire_name', 'seq', 'txt') + expect(json['csv_header_requirements']['question_advices']).to include('questionnaire_name', 'item_seq', 'advice') + expect(json['available_templates']).to include('questionnaires', 'items', 'question_advices', 'package') + expect(json['package_type']).to eq('questionnaire_template_export') + expect(json['version']).to eq(1) + expect(json['available_actions_on_dup']).to include('SkipRecordAction', 'UpdateExistingRecordAction', 'ChangeOffendingFieldAction') + end + end + + describe 'GET /questionnaire_packages/templates/:template_name' do + it 'downloads a CSV template with a sample row' do + get '/questionnaire_packages/templates/items' + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + expect(json['filename']).to eq('items_import_sample.csv') + expect(json['content_type']).to eq('text/csv') + + csv = CSV.parse(Base64.decode64(json['data']), headers: true) + expect(csv.headers).to include('questionnaire_name', 'seq', 'txt', 'question_type') + expect(csv.count).to eq(1) + expect(csv.first['questionnaire_name']).to eq('Sample Review Questionnaire') + expect(csv.first['txt']).to eq('How clear is the submitted work?') + end + + it 'downloads a package template zip with sample rows' do + get '/questionnaire_packages/templates/package' + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + expect(json['filename']).to eq('questionnaire_package_import_sample.zip') + expect(json['content_type']).to eq('application/zip') + + contents = read_zip_entries(json['data']) + expect(contents.keys).to contain_exactly('manifest.json', 'questionnaires.csv', 'items.csv', 'question_advices.csv') + expect(JSON.parse(contents['manifest.json'])).to include( + 'package_type' => 'questionnaire_template_export', + 'version' => 1 + ) + questionnaire_headers = CSV.parse(contents['questionnaires.csv'], headers: true).headers + expect(questionnaire_headers).to include('name', 'questionnaire_type', 'instructor_name') + expect(questionnaire_headers).not_to include('instruction_loc') + expect(CSV.parse(contents['items.csv'], headers: true).headers).to include('questionnaire_name', 'seq', 'txt') + expect(CSV.parse(contents['question_advices.csv'], headers: true).headers).to include('questionnaire_name', 'item_seq', 'advice') + expect(CSV.parse(contents['questionnaires.csv'], headers: true).first['name']).to eq('Sample Review Questionnaire') + expect(CSV.parse(contents['items.csv'], headers: true).first['txt']).to eq('How clear is the submitted work?') + expect(CSV.parse(contents['question_advices.csv'], headers: true).first['advice']).to eq('Mention the strongest evidence and reasoning.') + end + + it 'rejects unknown template names' do + get '/questionnaire_packages/templates/unknown' + + expect(response).to have_http_status(:unprocessable_entity) + expect(JSON.parse(response.body)['error']).to include('Unsupported questionnaire package template') + end + end + + describe 'POST /questionnaire_packages/export' do + it 'exports a questionnaire template package without answers, responses, or quiz data' do + role = create(:role, :instructor) + institution = create(:institution) + instructor = Instructor.create!( + name: 'packageexporter', + email: 'packageexporter@example.com', + full_name: 'Package Exporter', + password: 'password', + role: role, + institution: institution + ) + + questionnaire = Questionnaire.create!( + name: 'Package Questionnaire', + instructor: instructor, + private: false, + min_question_score: 0, + max_question_score: 10, + questionnaire_type: 'ReviewQuestionnaire', + display_type: 'Likert', + instruction_loc: 'instructions' + ) + + item = Item.create!( + questionnaire: questionnaire, + txt: 'How clear was the feedback?', + weight: 2, + seq: 1, + question_type: 'Scale', + break_before: true + ) + + QuestionAdvice.create!(item: item, score: 4, advice: 'Be more specific.') + + assignment = create(:assignment, instructor: instructor) + reviewer = create(:assignment_participant, assignment: assignment) + reviewee = create(:assignment_participant, assignment: assignment) + response_map = ResponseMap.create!( + reviewer_id: reviewer.id, + reviewee_id: reviewee.id, + reviewed_object_id: assignment.id + ) + review_response = Response.create!( + map_id: response_map.id, + additional_comment: 'Do not export this response' + ) + Answer.create!( + item: item, + response: review_response, + answer: 3, + comments: 'Do not export this answer' + ) + + quiz_questionnaire = Questionnaire.create!( + name: 'Quiz Questionnaire', + instructor: instructor, + private: false, + min_question_score: 0, + max_question_score: 10, + questionnaire_type: 'QuizQuestionnaire', + display_type: 'Quiz', + instruction_loc: 'quiz instructions' + ) + + Item.create!( + questionnaire: quiz_questionnaire, + txt: 'Quiz question', + weight: 1, + seq: 1, + question_type: 'multiple_choice', + break_before: true + ) + + post '/questionnaire_packages/export', params: { export_all: true } + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + expect(json['filename']).to end_with('.zip') + expect(json['counts']['questionnaires']).to eq(1) + + contents = read_zip_entries(json['data']) + expect(contents.keys).to contain_exactly('manifest.json', 'questionnaires.csv', 'items.csv', 'question_advices.csv') + manifest = JSON.parse(contents['manifest.json']) + expect(manifest).to include( + 'package_type' => 'questionnaire_template_export', + 'version' => 1, + 'includes' => %w[questionnaires items question_advices], + 'excludes' => %w[answers responses quiz_questionnaires quiz_items quiz_question_choices] + ) + + questionnaire_rows = CSV.parse(contents['questionnaires.csv'], headers: true) + item_rows = CSV.parse(contents['items.csv'], headers: true) + advice_rows = CSV.parse(contents['question_advices.csv'], headers: true) + + expect(questionnaire_rows.headers).not_to include('instruction_loc') + expect(questionnaire_rows.map { |row| row['name'] }).to contain_exactly('Package Questionnaire') + expect(item_rows.map { |row| row['txt'] }).to contain_exactly('How clear was the feedback?') + expect(advice_rows.map { |row| row['advice'] }).to contain_exactly('Be more specific.') + expect(json['counts']).to include( + 'questionnaires' => 1, + 'items' => 1, + 'question_advices' => 1 + ) + + package_text = contents.values.join("\n") + expect(package_text).not_to include('answers.csv') + expect(package_text).not_to include('responses.csv') + expect(package_text).not_to include('quiz_question_choices.csv') + expect(package_text).not_to include('Do not export this response') + expect(package_text).not_to include('Do not export this answer') + expect(package_text).not_to include('Quiz Questionnaire') + expect(package_text).not_to include('Quiz question') + end + + it 'exports a questionnaire template package without question advices when excluded' do + role = create(:role, :instructor) + institution = create(:institution) + instructor = Instructor.create!( + name: 'noadviceexporter', + email: 'noadviceexporter@example.com', + full_name: 'No Advice Exporter', + password: 'password', + role: role, + institution: institution + ) + questionnaire = Questionnaire.create!( + name: 'No Advice Package Questionnaire', + instructor: instructor, + private: false, + min_question_score: 0, + max_question_score: 10, + questionnaire_type: 'ReviewQuestionnaire', + display_type: 'Likert' + ) + item = Item.create!( + questionnaire: questionnaire, + txt: 'No advice item', + weight: 2, + seq: 1, + question_type: 'Scale', + break_before: true + ) + QuestionAdvice.create!(item: item, score: 4, advice: 'Do not export this advice.') + + post '/questionnaire_packages/export', + params: { + questionnaire_ids: [questionnaire.id], + include_question_advices: false + } + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + contents = read_zip_entries(json['data']) + manifest = JSON.parse(contents['manifest.json']) + + expect(contents.keys).to contain_exactly('manifest.json', 'questionnaires.csv', 'items.csv') + expect(manifest['files']).not_to include('question_advices.csv') + expect(manifest['includes']).not_to include('question_advices') + expect(json['counts']).to include('question_advices' => 0) + expect(contents.values.join("\n")).not_to include('Do not export this advice.') + end + end + + describe 'POST /questionnaire_packages/import' do + it 'previews separate CSV uploads without importing records' do + role = create(:role, :instructor) + institution = create(:institution) + Instructor.create!( + name: 'previewimporter', + email: 'previewimporter@example.com', + full_name: 'Preview Importer', + password: 'password', + role: role, + institution: institution + ) + + questionnaire_file = build_csv_upload( + filename: 'preview questionnaires.csv', + contents: <<~CSV + name,questionnaire_type,display_type,private,min_question_score,max_question_score,instructor_name + Preview Questionnaire,ReviewQuestionnaire,Likert,false,0,5,previewimporter + CSV + ) + items_file = build_csv_upload( + filename: 'preview items.csv', + contents: <<~CSV + questionnaire_name,questionnaire_instructor_name,seq,txt,question_type,weight,break_before,min_label,max_label,alternatives,size + Preview Questionnaire,previewimporter,1,Preview item,Scale,2,true,poor,excellent,, + CSV + ) + question_advices_file = build_csv_upload( + filename: 'preview advices.csv', + contents: <<~CSV + questionnaire_name,questionnaire_instructor_name,item_seq,item_txt,score,advice + Preview Questionnaire,previewimporter,1,Preview item,5,Preview advice + CSV + ) + + post '/questionnaire_packages/preview', params: { + questionnaire_file: questionnaire_file, + items_file: items_file, + question_advices_file: question_advices_file + } + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + expect(json['summary']).to include( + 'questionnaires' => 1, + 'items' => 1, + 'question_advices' => 1, + 'creates' => 3, + 'errors' => 0 + ) + expect(json['questionnaires'].first).to include( + 'name' => 'Preview Questionnaire', + 'action' => 'create' + ) + expect(json['items'].first).to include('txt' => 'Preview item', 'action' => 'create') + expect(json['question_advices'].first).to include('advice' => 'Preview advice', 'action' => 'create') + expect(Questionnaire.find_by(name: 'Preview Questionnaire')).to be_nil + end + + it 'previews duplicate and unresolved rows' do + role = create(:role, :instructor) + institution = create(:institution) + instructor = Instructor.create!( + name: 'previewduplicate', + email: 'previewduplicate@example.com', + full_name: 'Preview Duplicate', + password: 'password', + role: role, + institution: institution + ) + Questionnaire.create!( + name: 'Preview Duplicate Questionnaire', + instructor: instructor, + private: false, + min_question_score: 0, + max_question_score: 5, + questionnaire_type: 'ReviewQuestionnaire', + display_type: 'Likert', + instruction_loc: 'old instructions' + ) + + questionnaire_file = build_csv_upload( + filename: 'preview duplicate questionnaires.csv', + contents: <<~CSV + name,questionnaire_type,display_type,private,min_question_score,max_question_score,instructor_name + Preview Duplicate Questionnaire,ReviewQuestionnaire,Likert,false,0,5,previewduplicate + Missing Instructor Questionnaire,ReviewQuestionnaire,Likert,false,0,5,missingpreviewinstructor + CSV + ) + items_file = build_csv_upload( + filename: 'preview duplicate items.csv', + contents: <<~CSV + questionnaire_name,questionnaire_instructor_name,seq,txt,question_type,weight,break_before,min_label,max_label,alternatives,size + Preview Duplicate Questionnaire,previewduplicate,1,Duplicate preview item,Scale,2,true,,, + Missing Instructor Questionnaire,missingpreviewinstructor,1,Missing instructor item,Scale,2,true,,, + CSV + ) + + post '/questionnaire_packages/preview', params: { + questionnaire_file: questionnaire_file, + items_file: items_file, + dup_action: 'UpdateExistingRecordAction' + } + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + expect(json['summary']).to include( + 'questionnaires' => 2, + 'items' => 2, + 'duplicates' => 1, + 'updates' => 1, + 'errors' => 2 + ) + expect(json['questionnaires'].first).to include('action' => 'update', 'duplicate' => true) + expect(json['errors'].map { |error| error['file'] }).to include('questionnaires', 'items') + end + + it 'imports questionnaire packages from a zip file' do + role = create(:role, :instructor) + institution = create(:institution) + Instructor.create!( + name: 'packageimporter', + email: 'packageimporter@example.com', + full_name: 'Package Importer', + password: 'password', + role: role, + institution: institution + ) + + uploaded_file = build_package_upload( + manifest: { + package_type: 'questionnaire_template_export', + version: 1, + files: %w[questionnaires.csv items.csv question_advices.csv] + }, + questionnaires_csv: <<~CSV, + name,questionnaire_type,display_type,private,min_question_score,max_question_score,instructor_name + Imported Questionnaire,ReviewQuestionnaire,Likert,false,0,5,packageimporter + CSV + items_csv: <<~CSV, + questionnaire_name,questionnaire_instructor_name,seq,txt,question_type,weight,break_before,min_label,max_label,alternatives,size + Imported Questionnaire,packageimporter,1,Imported item,Scale,2,true,poor,excellent,, + CSV + question_advices_csv: <<~CSV + questionnaire_name,questionnaire_instructor_name,item_seq,item_txt,score,advice + Imported Questionnaire,packageimporter,1,Imported item,5,Great work + CSV + ) + + post '/questionnaire_packages/import', params: { + package_file: uploaded_file, + dup_action: 'ChangeOffendingFieldAction' + } + + expect(response).to have_http_status(:created) + + imported_questionnaire = Questionnaire.find_by(name: 'Imported Questionnaire') + expect(imported_questionnaire).to be_present + expect(imported_questionnaire.items.find_by(txt: 'Imported item')).to be_present + expect(QuestionAdvice.joins(:item).find_by(items: { txt: 'Imported item' }, advice: 'Great work')).to be_present + + json = JSON.parse(response.body) + expect(json['imported']).to include( + 'questionnaires' => 1, + 'items' => 1, + 'question_advices' => 1 + ) + end + + it 'rejects unsupported package manifests' do + uploaded_file = build_package_upload( + manifest: { + package_type: 'questionnaire_export', + version: 1, + files: %w[questionnaires.csv items.csv question_advices.csv] + }, + questionnaires_csv: "name,questionnaire_type,display_type,private,min_question_score,max_question_score,instructor_name\n", + items_csv: "questionnaire_name,questionnaire_instructor_name,seq,txt,question_type,weight,break_before,min_label,max_label,alternatives,size\n", + question_advices_csv: "questionnaire_name,questionnaire_instructor_name,item_seq,item_txt,score,advice\n" + ) + + post '/questionnaire_packages/import', params: { + package_file: uploaded_file + } + + expect(response).to have_http_status(:unprocessable_entity) + expect(JSON.parse(response.body)['error']).to include('Unsupported questionnaire package type') + end + + it 'imports questionnaire CSVs from role-specific fields without requiring specific filenames' do + role = create(:role, :instructor) + institution = create(:institution) + Instructor.create!( + name: 'csvroleimporter', + email: 'csvroleimporter@example.com', + full_name: 'CSV Role Importer', + password: 'password', + role: role, + institution: institution + ) + + questionnaire_file = build_csv_upload( + filename: 'my rubric list.csv', + contents: <<~CSV + name,questionnaire_type,display_type,private,min_question_score,max_question_score,instructor_name + Role Field Questionnaire,ReviewQuestionnaire,Likert,false,0,5,csvroleimporter + CSV + ) + items_file = build_csv_upload( + filename: 'these are the questions.csv', + contents: <<~CSV + questionnaire_name,questionnaire_instructor_name,seq,txt,question_type,weight,break_before,min_label,max_label,alternatives,size + Role Field Questionnaire,csvroleimporter,1,Role field item,Scale,2,true,poor,excellent,, + CSV + ) + question_advices_file = build_csv_upload( + filename: 'helpful scoring notes.csv', + contents: <<~CSV + questionnaire_name,questionnaire_instructor_name,item_seq,item_txt,score,advice + Role Field Questionnaire,csvroleimporter,1,Role field item,5,Well done + CSV + ) + + post '/questionnaire_packages/import', params: { + questionnaire_file: questionnaire_file, + items_file: items_file, + question_advices_file: question_advices_file, + dup_action: 'ChangeOffendingFieldAction' + } + + expect(response).to have_http_status(:created) + + imported_questionnaire = Questionnaire.find_by(name: 'Role Field Questionnaire') + expect(imported_questionnaire).to be_present + expect(imported_questionnaire.items.find_by(txt: 'Role field item')).to be_present + expect(QuestionAdvice.joins(:item).find_by(items: { txt: 'Role field item' }, advice: 'Well done')).to be_present + end + + it 'validates separate CSV uploads by required headers' do + questionnaire_file = build_csv_upload( + filename: 'bad questionnaire upload.csv', + contents: <<~CSV + name,questionnaire_type + Missing Headers,ReviewQuestionnaire + CSV + ) + + post '/questionnaire_packages/import', params: { + questionnaire_file: questionnaire_file + } + + expect(response).to have_http_status(:unprocessable_entity) + expect(JSON.parse(response.body)['error']).to include('Questionnaires CSV is missing required headers') + end + + it 'renames duplicate questionnaires when using the default duplicate action' do + role = create(:role, :instructor) + institution = create(:institution) + instructor = Instructor.create!( + name: 'duplicatedpackageimporter', + email: 'duplicatedpackageimporter@example.com', + full_name: 'Duplicated Package Importer', + password: 'password', + role: role, + institution: institution + ) + Questionnaire.create!( + name: 'Duplicate Questionnaire', + instructor: instructor, + private: false, + min_question_score: 0, + max_question_score: 5, + questionnaire_type: 'ReviewQuestionnaire', + display_type: 'Likert', + instruction_loc: 'old instructions' + ) + + uploaded_file = build_package_upload( + manifest: { + package_type: 'questionnaire_template_export', + version: 1, + files: %w[questionnaires.csv items.csv question_advices.csv] + }, + questionnaires_csv: <<~CSV, + name,questionnaire_type,display_type,private,min_question_score,max_question_score,instructor_name + Duplicate Questionnaire,ReviewQuestionnaire,Likert,false,0,5,duplicatedpackageimporter + CSV + items_csv: <<~CSV, + questionnaire_name,questionnaire_instructor_name,seq,txt,question_type,weight,break_before,min_label,max_label,alternatives,size + Duplicate Questionnaire,duplicatedpackageimporter,1,Duplicated item,Scale,1,true,,, + CSV + question_advices_csv: "questionnaire_name,questionnaire_instructor_name,item_seq,item_txt,score,advice\n" + ) + + post '/questionnaire_packages/import', params: { + package_file: uploaded_file + } + + expect(response).to have_http_status(:created) + copied_questionnaire = Questionnaire.find_by(name: 'Duplicate Questionnaire_copy') + expect(copied_questionnaire).to be_present + expect(copied_questionnaire.items.find_by(txt: 'Duplicated item')).to be_present + expect(JSON.parse(response.body)['duplicates']).to include('questionnaires' => 1) + end + end + + def read_zip_entries(encoded_data) + buffer = StringIO.new(Base64.decode64(encoded_data)) + contents = {} + + Zip::File.open_buffer(buffer) do |zip_file| + zip_file.each do |entry| + contents[entry.name] = entry.get_input_stream.read + end + end + + contents + end + + def build_package_upload(manifest:, questionnaires_csv:, items_csv:, question_advices_csv:) + file = Tempfile.new(['questionnaire_package', '.zip']) + + Zip::OutputStream.open(file.path) do |zip| + zip.put_next_entry('manifest.json') + zip.write(JSON.generate(manifest)) + + zip.put_next_entry('questionnaires.csv') + zip.write(questionnaires_csv) + + zip.put_next_entry('items.csv') + zip.write(items_csv) + + zip.put_next_entry('question_advices.csv') + zip.write(question_advices_csv) + end + + Rack::Test::UploadedFile.new(file.path, 'application/zip') + end + + def build_csv_upload(filename:, contents:) + file = Tempfile.new([File.basename(filename, '.csv'), '.csv']) + file.write(contents) + file.rewind + + Rack::Test::UploadedFile.new(file.path, 'text/csv', original_filename: filename) + end +end