From 9b0ab9f1f83ef1c06ac3e54ea396d08480ace456 Mon Sep 17 00:00:00 2001 From: TaylorBrown96 Date: Wed, 19 Nov 2025 03:00:09 -0500 Subject: [PATCH 01/80] Added: - app/helpers/importable_exportable_helper.rb - app/helpers/duplicated_action_helper.rb - app/services/export.rb - app/services/import.rb - app/services/field_mapping.rb - app/controllers/export_controller.rb - app/controllers/import_controller.rb Note: I've added some filler code so GitHub doesn't throw "Cannot upload empty files" error when commited. --- app/controllers/export_controller.rb | 9 ++++ app/controllers/import_controller.rb | 32 ++++++++++++++ app/helpers/duplicated_action_helper.rb | 42 ++++++++++++++++++ app/helpers/importable_exportable_helper.rb | 48 +++++++++++++++++++++ app/services/export.rb | 23 ++++++++++ app/services/field_mapping.rb | 23 ++++++++++ app/services/import.rb | 36 ++++++++++++++++ 7 files changed, 213 insertions(+) create mode 100644 app/controllers/export_controller.rb create mode 100644 app/controllers/import_controller.rb create mode 100644 app/helpers/duplicated_action_helper.rb create mode 100644 app/helpers/importable_exportable_helper.rb create mode 100644 app/services/export.rb create mode 100644 app/services/field_mapping.rb create mode 100644 app/services/import.rb diff --git a/app/controllers/export_controller.rb b/app/controllers/export_controller.rb new file mode 100644 index 000000000..d7ac74d13 --- /dev/null +++ b/app/controllers/export_controller.rb @@ -0,0 +1,9 @@ +# This file holds the logic for exporting data from the application to various formats. + +class ExportController < ApplicationController + def export_data + # Logic for exporting data + data = DataExporter.new.export(format: params[:format]) + send_data data, filename: "exported_data.#{params[:format]}" + end +end \ No newline at end of file diff --git a/app/controllers/import_controller.rb b/app/controllers/import_controller.rb new file mode 100644 index 000000000..b5866f1cd --- /dev/null +++ b/app/controllers/import_controller.rb @@ -0,0 +1,32 @@ +# This file holds the logic for importing data from external sources into the application. + +class Import + def initialize(source) + @source = source + end + + def perform + data = fetch_data + mapped_data = map_fields(data) + save_data(mapped_data) + end + + private + + def fetch_data + # Logic to fetch data from the external source + @source.get_data + end + + def map_fields(data) + # Logic to map fields from external data to internal model + FieldMapping.new(data).map + end + + def save_data(mapped_data) + # Logic to save the mapped data into the application database + mapped_data.each do |record| + Model.create(record) + end + end +end \ No newline at end of file diff --git a/app/helpers/duplicated_action_helper.rb b/app/helpers/duplicated_action_helper.rb new file mode 100644 index 000000000..f76b58df9 --- /dev/null +++ b/app/helpers/duplicated_action_helper.rb @@ -0,0 +1,42 @@ +# This file defines a helper class for handling data duplication events. +# It provides methods to manage and process duplicated actions within the application. +# It is designed to be used in conjunction with controllers and views that deal with duplicated data. +module DuplicatedActionHelper + # This method processes duplicated actions based on the provided parameters. + # + # @param action [String] the action to be performed on the duplicated data + # @param data [Array] the data to be processed + # @return [Array] the processed data after performing the action + def process_duplicated_action(action, data) + case action + when 'merge' + merge_duplicated_data(data) + when 'delete' + delete_duplicated_data(data) + else + raise ArgumentError, "Unknown action: #{action}" + end + end + + private + + # Merges duplicated data entries into a single entry. + # + # @param data [Array] the duplicated data to be merged + # @return [Array] the merged data + def merge_duplicated_data(data) + # Implementation of merging logic goes here + # This is a placeholder implementation + data.uniq { |entry| entry[:id] } + end + + # Deletes duplicated data entries. + # + # @param data [Array] the duplicated data to be deleted + # @return [Array] the remaining data after deletion + def delete_duplicated_data(data) + # Implementation of deletion logic goes here + # This is a placeholder implementation + [] + end +end \ No newline at end of file diff --git a/app/helpers/importable_exportable_helper.rb b/app/helpers/importable_exportable_helper.rb new file mode 100644 index 000000000..59f1da692 --- /dev/null +++ b/app/helpers/importable_exportable_helper.rb @@ -0,0 +1,48 @@ +# importable_exportable_helper.rb +module ImportableExportableHelper + # This module provides methods to import and export data in various formats. + + require 'csv' + require 'json' + require 'yaml' + + # Exports data to the specified format. + # + # @param data [Array] The data to be exported. + # @param format [Symbol] The format to export the data in (:csv, :json, :yaml). + # @return [String] The exported data as a string. + def export_data(data, format) + case format + when :csv + CSV.generate do |csv| + csv << data.first.keys if data.any? + data.each { |row| csv << row.values } + end + when :json + JSON.pretty_generate(data) + when :yaml + data.to_yaml + else + raise ArgumentError, "Unsupported format: #{format}" + end + end + + # Imports data from the specified format. + # + # @param data_string [String] The string containing the data to be imported. + # @param format [Symbol] The format of the input data (:csv, :json, :yaml). + # @return [Array] The imported data as an array of hashes. + def import_data(data_string, format) + case format + when :csv + csv = CSV.parse(data_string, headers: true) + csv.map(&:to_h) + when :json + JSON.parse(data_string) + when :yaml + YAML.safe_load(data_string) + else + raise ArgumentError, "Unsupported format: #{format}" + end + end +end \ No newline at end of file diff --git a/app/services/export.rb b/app/services/export.rb new file mode 100644 index 000000000..b8ca26206 --- /dev/null +++ b/app/services/export.rb @@ -0,0 +1,23 @@ +# This file defines a service class for handling data export operations. +class Export + def initialize(data) + @data = data + end + + def to_csv + CSV.generate do |csv| + csv << @data.first.keys # Add headers + @data.each do |row| + csv << row.values + end + end + end + + def to_json + @data.to_json + end + + def to_xml + @data.to_xml(root: 'records', skip_types: true) + end +end \ No newline at end of file diff --git a/app/services/field_mapping.rb b/app/services/field_mapping.rb new file mode 100644 index 000000000..fcb4bf1f9 --- /dev/null +++ b/app/services/field_mapping.rb @@ -0,0 +1,23 @@ +# This file is used to map fields from an external data source to the internal data model. +class FieldMapping + def initialize(data) + @data = data + end + + def map + @data.map do |record| + { + internal_field_1: record[:external_field_a], + internal_field_2: record[:external_field_b], + internal_field_3: transform_field(record[:external_field_c]) + } + end + end + + private + + def transform_field(value) + # Example transformation logic + value.strip.upcase + end +end \ No newline at end of file diff --git a/app/services/import.rb b/app/services/import.rb new file mode 100644 index 000000000..3e2140432 --- /dev/null +++ b/app/services/import.rb @@ -0,0 +1,36 @@ +# This file defines a service class for handling data import operations. + +module Services + class Import + def initialize(file_path) + @file_path = file_path + end + + def perform + data = read_file + parsed_data = parse_data(data) + save_data(parsed_data) + end + + private + + def read_file + File.read(@file_path) + rescue Errno::ENOENT + raise "File not found: #{@file_path}" + end + + def parse_data(data) + # Assuming the data is in CSV format for this example + require 'csv' + CSV.parse(data, headers: true) + end + + def save_data(parsed_data) + parsed_data.each do |row| + # Assuming we are importing into a Model called Record + Record.create!(row.to_h) + end + end + end +end \ No newline at end of file From 2c841b980d645aeeacdc2f19aa2d00fb92940cbd Mon Sep 17 00:00:00 2001 From: arrao3 Date: Mon, 24 Nov 2025 16:37:07 -0500 Subject: [PATCH 02/80] added field_mappings class --- app/services/field_mapping.rb | 54 ++++++++++++++++++++++++----------- 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/app/services/field_mapping.rb b/app/services/field_mapping.rb index fcb4bf1f9..21d95f710 100644 --- a/app/services/field_mapping.rb +++ b/app/services/field_mapping.rb @@ -1,23 +1,45 @@ -# This file is used to map fields from an external data source to the internal data model. +# app/services/field_mapping.rb class FieldMapping - def initialize(data) - @data = data + attr_reader :model_class, :ordered_fields + + # model_class: an ActiveRecord class (User, Assignment, Team, etc.) + # ordered_fields: array of symbols/strings like [:email, :last_name] + def initialize(model_class, ordered_fields) + @model_class = model_class + @ordered_fields = ordered_fields.map(&:to_s) end - def map - @data.map do |record| - { - internal_field_1: record[:external_field_a], - internal_field_2: record[:external_field_b], - internal_field_3: transform_field(record[:external_field_c]) - } - end + # Build mapping from a CSV header row + # header_row is an array like ["Email", "Last Name", "First Name"] + def self.from_header(model_class, header_row) + header_row = header_row.map(&:strip) + + valid_fields = + model_class.mandatory_fields + + model_class.optional_fields + + matched = header_row.map do |h| + valid_fields.find { |f| f.casecmp?(h) } + end.compact + + new(model_class, matched) end - private + # Return CSV header row + def headers + ordered_fields + end + + # Return values in correct order for a record + def values_for(record) + ordered_fields.map { |f| record.public_send(f) } + end - def transform_field(value) - # Example transformation logic - value.strip.upcase + # JSON-friendly + def to_h + { + model_class: model_class.name, + ordered_fields: ordered_fields + } end -end \ No newline at end of file +end From d851de8ae77194a82b436d6e5f67f85e4d129a20 Mon Sep 17 00:00:00 2001 From: Camille Jones Date: Tue, 25 Nov 2025 22:37:51 -0500 Subject: [PATCH 03/80] Added importexport helper and import methods. --- app/controllers/import_controller.rb | 35 ++--- app/helpers/importable_exportable_helper.rb | 140 ++++++++++++++------ app/models/user.rb | 5 + app/services/field_mapping.rb | 9 +- config/routes.rb | 12 ++ 5 files changed, 136 insertions(+), 65 deletions(-) diff --git a/app/controllers/import_controller.rb b/app/controllers/import_controller.rb index b5866f1cd..8f7abfdc9 100644 --- a/app/controllers/import_controller.rb +++ b/app/controllers/import_controller.rb @@ -1,32 +1,23 @@ # This file holds the logic for importing data from external sources into the application. -class Import - def initialize(source) - @source = source - end +class ImportController < ApplicationController + before_action :import_params + def index + imported_class = params[:class].constantize + mapping = FieldMapping.new(imported_class, ["name", "id", "email"]) - def perform - data = fetch_data - mapped_data = map_fields(data) - save_data(mapped_data) + p mapping.duplicate_headers end - private + def import + uploaded_file = params[:csv_file] + use_headers = ActiveRecord::Type::Boolean.new.deserialize(params[:use_headers]) - def fetch_data - # Logic to fetch data from the external source - @source.get_data + User.try_import_records(uploaded_file, User.import_export_fields, use_header: use_headers) end - def map_fields(data) - # Logic to map fields from external data to internal model - FieldMapping.new(data).map - end - - def save_data(mapped_data) - # Logic to save the mapped data into the application database - mapped_data.each do |record| - Model.create(record) - end + private + def import_params + params.permit(:csv_file, :use_headers, :class) end end \ No newline at end of file diff --git a/app/helpers/importable_exportable_helper.rb b/app/helpers/importable_exportable_helper.rb index 59f1da692..9a322c45c 100644 --- a/app/helpers/importable_exportable_helper.rb +++ b/app/helpers/importable_exportable_helper.rb @@ -1,48 +1,108 @@ # importable_exportable_helper.rb module ImportableExportableHelper - # This module provides methods to import and export data in various formats. - - require 'csv' - require 'json' - require 'yaml' - - # Exports data to the specified format. - # - # @param data [Array] The data to be exported. - # @param format [Symbol] The format to export the data in (:csv, :json, :yaml). - # @return [String] The exported data as a string. - def export_data(data, format) - case format - when :csv - CSV.generate do |csv| - csv << data.first.keys if data.any? - data.each { |row| csv << row.values } + attr_accessor :available_actions_on_duplicate + def self.included(base) + base.extend(ClassMethods) + end + + module ClassMethods + attr_accessor :mandatory_fields, :optional_fields, :external_classes + + def mandatory_fields(*fields) + if fields.any? + @mandatory_fields = fields.map(&:to_s) + else + @mandatory_fields end - when :json - JSON.pretty_generate(data) - when :yaml - data.to_yaml - else - raise ArgumentError, "Unsupported format: #{format}" end - end - # Imports data from the specified format. - # - # @param data_string [String] The string containing the data to be imported. - # @param format [Symbol] The format of the input data (:csv, :json, :yaml). - # @return [Array] The imported data as an array of hashes. - def import_data(data_string, format) - case format - when :csv - csv = CSV.parse(data_string, headers: true) - csv.map(&:to_h) - when :json - JSON.parse(data_string) - when :yaml - YAML.safe_load(data_string) - else - raise ArgumentError, "Unsupported format: #{format}" + def optional_fields(*fields) + if fields.any? + @optional_fields = fields.map(&:to_s) + else + @optional_fields + end + end + + def external_classes(*fields) + if fields.any? + @external_classes = fields.map(&:to_s) + else + @external_classes + end + end + + def import_export_fields() + (@mandatory_fields || []) + (@optional_fields || []) + end + + # Factory method for importing a record from a hash + def from_hash(attrs) + new(attrs) + end + + # todo - possibly extract this function to the service + def try_import_records(file, headers, use_header: false) + temp_file = 'output.csv' + csv_file = CSV.open(file, headers: false) + + # In a temp file, so that headers can be added to the top if the use_header options isn't selected + CSV.open(temp_file, "w") do |csv| + unless use_header + csv << headers + end + + # then copy the rest of the csv file + csv_file.each() do |row| + csv << row + end + end + + CSV.foreach(temp_file) do |row| + # Get the row as a hash, with the header pointing towards the attribute value + import_row(row, temp_file) + # pp row + end + + File.delete(temp_file) + end + + # Import row function takes a hash for a row and tries to save it in the current class. + # It takes a related class and object so that it can be used recursively. If a row should + # update two classes,and one relies upon another, the recurison can be used to set the + # belongs torelationship. + # (EX if ) + def import_row(row, file, related_class: nil, related_obj: nil) + # Open the csv file, get the header row, and build the mapping with only the fields available in the current class + header_row = CSV.open(file, &:first) + mapping = FieldMapping.from_header(self, header_row) + + # For internal fields, get the attributes and save a new version of the class + row_hash = Hash[mapping.ordered_fields.zip(row)] + attrs = row_hash.slice(*self.import_export_fields) + created_object = self.from_hash(attrs) + + # If this is an auxilary class (on recursive run), make sure the main class is linked to this auxilary class + created_object.send("#{related_class}=", related_obj) if related_class + + pp created_object + # created_object.save! todo - return to this after testing + + + # Recursive call to this import_row function. This means that any auxilary class is expected to + # also use this helper + if self.external_classes + self.external_classes.each do |external_class| + external_class.import_row(row, file, mapping, use_header, self.class.name, created_object) + end + end end end + + + + # Instance method to serialize a record for export + def to_hash(fields = self.class.import_export_fields) + fields.to_h { |f| [f, send(f)] } + end end \ No newline at end of file diff --git a/app/models/user.rb b/app/models/user.rb index 65dd59724..c651cc3f9 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,9 +1,14 @@ # frozen_string_literal: true class User < ApplicationRecord + include ImportableExportableHelper + mandatory_fields :name, :email, :password, :full_name + + 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' } diff --git a/app/services/field_mapping.rb b/app/services/field_mapping.rb index 21d95f710..509aa9e02 100644 --- a/app/services/field_mapping.rb +++ b/app/services/field_mapping.rb @@ -14,9 +14,7 @@ def initialize(model_class, ordered_fields) def self.from_header(model_class, header_row) header_row = header_row.map(&:strip) - valid_fields = - model_class.mandatory_fields + - model_class.optional_fields + valid_fields = model_class.import_export_fields matched = header_row.map do |h| valid_fields.find { |f| f.casecmp?(h) } @@ -30,6 +28,11 @@ def headers ordered_fields end + def duplicate_headers + ordered_fields.group_by{ |header| header }.select{|_, v| v.size > 1}.map(&:first) + # .map { |k, v| [k, v.size()] }.to_h + end + # Return values in correct order for a record def values_for(record) ordered_fields.map { |f| record.public_send(f) } diff --git a/config/routes.rb b/config/routes.rb index b77a95f63..e387d7bb7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -141,4 +141,16 @@ delete :delete_participants 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 end From e337816bf0bc877d6d809deccd312b8d31dd28ab Mon Sep 17 00:00:00 2001 From: Camille Jones Date: Fri, 28 Nov 2025 23:07:07 -0500 Subject: [PATCH 04/80] Added capability to import users with external classes for lookup. Mostly implemented external classes for creation as well. --- app/controllers/import_controller.rb | 9 +- app/helpers/importable_exportable_helper.rb | 240 ++++++++++++++++---- app/models/Item.rb | 6 + app/models/question_advice.rb | 3 + app/models/quiz_item.rb | 1 + app/models/user.rb | 4 +- app/services/field_mapping.rb | 5 +- 7 files changed, 225 insertions(+), 43 deletions(-) diff --git a/app/controllers/import_controller.rb b/app/controllers/import_controller.rb index 8f7abfdc9..650d13ce2 100644 --- a/app/controllers/import_controller.rb +++ b/app/controllers/import_controller.rb @@ -10,14 +10,19 @@ def index end def import + pp params uploaded_file = params[:csv_file] use_headers = ActiveRecord::Type::Boolean.new.deserialize(params[:use_headers]) + ordered_fields = JSON.parse(params[:ordered_fields]) if params[:ordered_fields] - User.try_import_records(uploaded_file, User.import_export_fields, use_header: use_headers) + puts "Item Mandatory: #{Item.mandatory_fields}" + puts "Quiz Item Mandatory: #{QuizItem.mandatory_fields}" + + params[:class].constantize.try_import_records(uploaded_file, ordered_fields, use_header: use_headers) end private def import_params - params.permit(:csv_file, :use_headers, :class) + params.permit(:csv_file, :use_headers, :class, :ordered_fields) end end \ No newline at end of file diff --git a/app/helpers/importable_exportable_helper.rb b/app/helpers/importable_exportable_helper.rb index 9a322c45c..f49393df2 100644 --- a/app/helpers/importable_exportable_helper.rb +++ b/app/helpers/importable_exportable_helper.rb @@ -1,12 +1,80 @@ # importable_exportable_helper.rb + +# Class for combining external class information. Keeps track of whether this class should have +# its information looked up or saved. Assumes that information should be created when initialized +class ExternalClass + attr_accessor :ref_class, :should_lookup, :should_create + + def initialize(ref_class, should_lookup = false, should_create = true, lookup_field = nil) + @ref_class = ref_class + @should_lookup = should_lookup + @should_create = should_create + @lookup_field = lookup_field + end + + # If the ref class has the ImportableExportable Mixin, refer to that version of the import export fields func. + # If it doessn't, return the lookup field (or the primary key) + def internal_fields + if @ref_class.respond_to?(:internal_fields) + @ref_class.internal_fields + else + [append_class_name(@lookup_field.to_s), @ref_class.primary_key] + end + + end + + # Method to add the class name to a field. This is useful when the CSV might refer to a column with + # the class name appended (Ex role_name) but the internal field drops the class name (Ex Role.name) + def append_class_name(field) + @ref_class.name.downcase + "_" + field + end + + # Attempts too look in the database for any mention of the current class. It looks using the + # given lookup field and the primary key. It checks both with and without the classname appended + # to the front + def lookup(class_values) + # See if lookup field or primary key is in values hash. If not, return nothing + if @lookup_field && class_values[append_class_name(@lookup_field.to_s)] + class_name_lookup_field = append_class_name(@lookup_field.to_s) + + # Ex. field: name, value: class_values[role_name] + value = @ref_class.find_by(@lookup_field => class_values[class_name_lookup_field]) + # Ex. field: role_name, value: class_values[role_name] + value ||= @ref_class.find_by(class_name_lookup_field => class_values[class_name_lookup_field]) + end + + if class_values[append_class_name(@ref_class.primary_key)] + class_name_primary_key = append_class_name(@ref_class.primary_key) + + # Ex. field: id, value: class_values[role_id] + value ||= @ref_class.find_by(@ref_class.primary_key => class_values[class_name_primary_key]) + # Ex. field: role_id value: class_values[role_id] + value ||= @ref_class.find_by(class_name_primary_key => class_values[class_name_primary_key]) + end + + value + end +end + module ImportableExportableHelper - attr_accessor :available_actions_on_duplicate - def self.included(base) - base.extend(ClassMethods) + attr_accessor :available_actions_on_duplicate, :mandatory_fields, :external_classes + + # def self.included(base) + # base.extend(ClassMethods) + # end + + 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) + else + base.instance_variable_set(:@class_name, base.name) + end + end - module ClassMethods - attr_accessor :mandatory_fields, :optional_fields, :external_classes + # module ClassMethods def mandatory_fields(*fields) if fields.any? @@ -16,24 +84,36 @@ def mandatory_fields(*fields) end end - def optional_fields(*fields) - if fields.any? - @optional_fields = fields.map(&:to_s) - else - @optional_fields - end + def optional_fields + internal_fields - mandatory_fields end def external_classes(*fields) if fields.any? - @external_classes = fields.map(&:to_s) + @external_classes = fields else @external_classes end end - def import_export_fields() - (@mandatory_fields || []) + (@optional_fields || []) + + # use the column names and mandaroty fields to know which fields constitute + # internal fields. This is becuase of cases such as the password of a user being + # the password_digest column in the database, but we need to assign it to the + # password field of the object. + def internal_fields + (column_names + (mandatory_fields || [])).uniq - external_fields + end + + def external_fields + fields = [] + external_classes.each{ |external_class| fields += external_class.internal_fields } if external_classes + + fields + end + + def internal_and_external_fields + internal_fields + external_fields end # Factory method for importing a record from a hash @@ -44,26 +124,44 @@ def from_hash(attrs) # todo - possibly extract this function to the service def try_import_records(file, headers, use_header: false) temp_file = 'output.csv' - csv_file = CSV.open(file, headers: false) + csv_file = CSV.read(file, headers: false) # In a temp file, so that headers can be added to the top if the use_header options isn't selected CSV.open(temp_file, "w") do |csv| - unless use_header - csv << headers + + if use_header + headers = csv_file[0].map{ |header| header.parameterize.underscore } + csv_file.shift + else + headers = headers.map{ |header| header.parameterize.underscore } end + csv << headers + # then copy the rest of the csv file - csv_file.each() do |row| + csv_file.each do |row| csv << row end end - CSV.foreach(temp_file) do |row| - # Get the row as a hash, with the header pointing towards the attribute value - import_row(row, temp_file) - # pp row + temp_contents = CSV.read(temp_file) + temp_contents.shift + + dup_records = [] + + ActiveRecord::Base.transaction do + temp_contents.each do |row| + # Get the row as a hash, with the header pointing towards the attribute value + dup_obj = import_row(row, temp_file) + dup_records << dup_obj if dup_obj + end + + puts "okay take it back" + raise ActiveRecord::Rollback end + # todo - add duplicate action and error handling for this + File.delete(temp_file) end @@ -72,37 +170,101 @@ def try_import_records(file, headers, use_header: false) # update two classes,and one relies upon another, the recurison can be used to set the # belongs torelationship. # (EX if ) - def import_row(row, file, related_class: nil, related_obj: nil) + def import_row(row, file) # Open the csv file, get the header row, and build the mapping with only the fields available in the current class header_row = CSV.open(file, &:first) - mapping = FieldMapping.from_header(self, header_row) - - # For internal fields, get the attributes and save a new version of the class + mapping = FieldMapping.from_header(self, header_row) # Get mapping of only internal fields row_hash = Hash[mapping.ordered_fields.zip(row)] - attrs = row_hash.slice(*self.import_export_fields) - created_object = self.from_hash(attrs) + puts "Row Hash: #{row_hash}" - # If this is an auxilary class (on recursive run), make sure the main class is linked to this auxilary class - created_object.send("#{related_class}=", related_obj) if related_class + current_class_attrs = row_hash.slice(*internal_fields) + created_object = from_hash(current_class_attrs) - pp created_object - # created_object.save! todo - return to this after testing + # for each external class, try to look them up + if external_classes + external_classes.each do |external_class| + if external_class.should_lookup + handle_external_class(row_hash, external_class, self, created_object) + end + end + end + save_object(created_object) - # Recursive call to this import_row function. This means that any auxilary class is expected to - # also use this helper - if self.external_classes - self.external_classes.each do |external_class| - external_class.import_row(row, file, mapping, use_header, self.class.name, created_object) + puts "now do the external classes" + if external_classes + external_classes.each do |external_class| + if external_class.should_create + handle_external_class(row_hash, external_class, self, created_object) + end end end end - end + + def handle_external_class(row_hash, external_class, parent_class, parent_obj) + # Open the csv file, get the header row, and build the mapping with only the fields available in the current class + # header_row = CSV.open(file, &:first) + # mapping = FieldMapping.from_header(header_row) + # row_hash = Hash[mapping.ordered_fields.zip(row)] + + # Lookup - If the external class is marked as a lookup and a value is found + if external_class.should_lookup && (lookup_value = external_class.lookup(row_hash)) + # Connect lookup value to the parent obj + parent_obj.send("#{external_class.ref_class.name.downcase}=", lookup_value) + return + end + + # Create - If the external class is marked as a create, attempt to create a new obj and link to parents + # This can happen if it is marked and a lookup val wasn't found + if external_class.should_create + current_class_attrs = row_hash.slice(*external_class.internal_fields) + created_object = external_class.ref_class.from_hash(current_class_attrs) + + puts "The parent object: #{@class_name.downcase}" + pp created_object + # link the newly created object and the parent both ways + created_object.send("#{@class_name.downcase}=", parent_obj) + parent_obj.send("#{external_class.ref_class.name.downcase}=", created_object) + + # Rare Case: Nested External Classes - for each external class, try to either look them up or create them + external_class.ref_class.external_classes.each do |inner_external_class| + handle_external_class(row_hash, inner_external_class, self, created_object) + end + + save_object(created_object) + end + end + + def save_object(created_object) + begin + puts "Create Obj:" + pp created_object + created_object.save! # todo - change to save! when ready to finish testing + puts "wat" + rescue ActiveRecord::RecordInvalid => e + # Handle validation errors + puts "Validation error: #{e.message}" + pp "hi" + raise ActiveRecord::Rollback + rescue ActiveRecord::RecordNotUnique => e + # Handle unique constraint violations + puts "Unique constraint violation: #{e.message}" + pp "uh oh" + return created_object + rescue StandardError => e + puts "An unexpected error occurred: #{e.message}" + pp "bye" + raise ActiveRecord::Rollback + end + end + # end # Instance method to serialize a record for export - def to_hash(fields = self.class.import_export_fields) + def to_hash(fields = self.class.internal_fields) fields.to_h { |f| [f, send(f)] } end + + end \ No newline at end of file diff --git a/app/models/Item.rb b/app/models/Item.rb index 57217459d..887d85f13 100644 --- a/app/models/Item.rb +++ b/app/models/Item.rb @@ -1,6 +1,12 @@ # frozen_string_literal: true class Item < ApplicationRecord + extend ImportableExportableHelper + mandatory_fields :txt, :weight, :seq, :question_type, :break_before + external_classes ExternalClass.new(Questionnaire, true, false, :name), + ExternalClass.new(QuestionAdvice, false, true) + + before_create :set_seq belongs_to :questionnaire # each item belongs to a specific questionnaire has_many :answers, dependent: :destroy, foreign_key: 'question_id' diff --git a/app/models/question_advice.rb b/app/models/question_advice.rb index 76b54c56d..7ba103dec 100644 --- a/app/models/question_advice.rb +++ b/app/models/question_advice.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true class QuestionAdvice < ApplicationRecord + extend ImportableExportableHelper + mandatory_fields :score, :advice + belongs_to :item def self.export_fields(_options) QuestionAdvice.columns.map(&:name) diff --git a/app/models/quiz_item.rb b/app/models/quiz_item.rb index 8367ca20e..2f92eceac 100644 --- a/app/models/quiz_item.rb +++ b/app/models/quiz_item.rb @@ -3,6 +3,7 @@ require 'json' class QuizItem < Item + extend ImportableExportableHelper has_many :quiz_question_choices, class_name: 'QuizQuestionChoice', foreign_key: 'question_id', inverse_of: false, dependent: :nullify def edit diff --git a/app/models/user.rb b/app/models/user.rb index c651cc3f9..f2b1857bd 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,8 +1,10 @@ # frozen_string_literal: true class User < ApplicationRecord - include ImportableExportableHelper + extend ImportableExportableHelper mandatory_fields :name, :email, :password, :full_name + external_classes ExternalClass.new(Role, true, false, :name), + ExternalClass.new(Institution, true, false, :name) has_secure_password diff --git a/app/services/field_mapping.rb b/app/services/field_mapping.rb index 509aa9e02..2b7703112 100644 --- a/app/services/field_mapping.rb +++ b/app/services/field_mapping.rb @@ -14,12 +14,15 @@ def initialize(model_class, ordered_fields) def self.from_header(model_class, header_row) header_row = header_row.map(&:strip) - valid_fields = model_class.import_export_fields + valid_fields = model_class.internal_and_external_fields matched = header_row.map do |h| valid_fields.find { |f| f.casecmp?(h) } end.compact + pp "matched" + puts "Header Row: #{header_row}" + new(model_class, matched) end From dd8b30f6ca696e265bfd31c50f6cdb9e5b8ccde1 Mon Sep 17 00:00:00 2001 From: Camille Jones Date: Sat, 29 Nov 2025 00:41:15 -0500 Subject: [PATCH 05/80] Finished adding the ability to have duplicate headers with comments relating to the Questionnaire Item and Question Advice example. --- app/helpers/importable_exportable_helper.rb | 91 +++++++++++++-------- 1 file changed, 56 insertions(+), 35 deletions(-) diff --git a/app/helpers/importable_exportable_helper.rb b/app/helpers/importable_exportable_helper.rb index f49393df2..ebd20fcfc 100644 --- a/app/helpers/importable_exportable_helper.rb +++ b/app/helpers/importable_exportable_helper.rb @@ -16,7 +16,7 @@ def initialize(ref_class, should_lookup = false, should_create = true, lookup_fi # If it doessn't, return the lookup field (or the primary key) def internal_fields if @ref_class.respond_to?(:internal_fields) - @ref_class.internal_fields + @ref_class.internal_fields.map {|field| append_class_name(field)} else [append_class_name(@lookup_field.to_s), @ref_class.primary_key] end @@ -26,7 +26,11 @@ def internal_fields # Method to add the class name to a field. This is useful when the CSV might refer to a column with # the class name appended (Ex role_name) but the internal field drops the class name (Ex Role.name) def append_class_name(field) - @ref_class.name.downcase + "_" + field + @ref_class.name.underscore + "_" + field + end + + def unappended_class_name(name) + name.delete_prefix(@ref_class.name.underscore + "_") end # Attempts too look in the database for any mention of the current class. It looks using the @@ -54,6 +58,13 @@ def lookup(class_values) value end + + def from_hash(attrs) + fixed_attrs = {} + attrs.each {|k, v| fixed_attrs[unappended_class_name(k)] = v} + + @ref_class.new(fixed_attrs) + end end module ImportableExportableHelper @@ -118,7 +129,10 @@ def internal_and_external_fields # Factory method for importing a record from a hash def from_hash(attrs) - new(attrs) + fixed_attrs = {} + attrs.each {|k, v| fixed_attrs[k] = v[0]} + + new(fixed_attrs) end # todo - possibly extract this function to the service @@ -167,14 +181,20 @@ def try_import_records(file, headers, use_header: false) # Import row function takes a hash for a row and tries to save it in the current class. # It takes a related class and object so that it can be used recursively. If a row should - # update two classes,and one relies upon another, the recurison can be used to set the - # belongs torelationship. + # update two classes,and one relies upon another, the recursion can be used to set the + # belongs to relationship. # (EX if ) def import_row(row, file) # Open the csv file, get the header row, and build the mapping with only the fields available in the current class header_row = CSV.open(file, &:first) mapping = FieldMapping.from_header(self, header_row) # Get mapping of only internal fields - row_hash = Hash[mapping.ordered_fields.zip(row)] + pp row + row_hash = {} + mapping.ordered_fields.zip(row).each do |key, value| + row_hash[key] ||= [] # Initialize an empty array if the key is new + row_hash[key] << value + end + puts "Row Hash: #{row_hash}" current_class_attrs = row_hash.slice(*internal_fields) @@ -183,55 +203,62 @@ def import_row(row, file) # for each external class, try to look them up if external_classes external_classes.each do |external_class| - if external_class.should_lookup - handle_external_class(row_hash, external_class, self, created_object) - end + lookup_external_class(row_hash, external_class, self, created_object) end end save_object(created_object) - puts "now do the external classes" + # Then create external classes that rely on the object we just created if external_classes external_classes.each do |external_class| - if external_class.should_create - handle_external_class(row_hash, external_class, self, created_object) - end + create_external_class(row_hash, mapping, external_class, self, created_object) end end end - def handle_external_class(row_hash, external_class, parent_class, parent_obj) - # Open the csv file, get the header row, and build the mapping with only the fields available in the current class - # header_row = CSV.open(file, &:first) - # mapping = FieldMapping.from_header(header_row) - # row_hash = Hash[mapping.ordered_fields.zip(row)] - + def lookup_external_class(row_hash, external_class, parent_class, parent_obj) # Lookup - If the external class is marked as a lookup and a value is found if external_class.should_lookup && (lookup_value = external_class.lookup(row_hash)) # Connect lookup value to the parent obj parent_obj.send("#{external_class.ref_class.name.downcase}=", lookup_value) return end + end + def create_external_class(row_hash, mapping, external_class, parent_class, parent_obj) # Create - If the external class is marked as a create, attempt to create a new obj and link to parents # This can happen if it is marked and a lookup val wasn't found if external_class.should_create + + # Get the attributes, with duplicates in an array current_class_attrs = row_hash.slice(*external_class.internal_fields) - created_object = external_class.ref_class.from_hash(current_class_attrs) - puts "The parent object: #{@class_name.downcase}" - pp created_object - # link the newly created object and the parent both ways - created_object.send("#{@class_name.downcase}=", parent_obj) - parent_obj.send("#{external_class.ref_class.name.downcase}=", created_object) + # In the order of the attributes, pair them together in new hashes. These new hashes + # are ready to be made into the new object + # Ex. + # Initial: {"question_advice_score" => ["1", "2"], + # "question_advice_advice" => ["okay", "good"]} + # Result: [{"question_advice_score" => "1", "question_advice_advice" => "okay"}, + # {"question_advice_score" => "2", "question_advice_advice" => "good"}] + # + 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 + - # Rare Case: Nested External Classes - for each external class, try to either look them up or create them - external_class.ref_class.external_classes.each do |inner_external_class| - handle_external_class(row_hash, inner_external_class, self, created_object) + # Use each set to create the new objects + object_sets_with_keys.each do |attrs| + created_object = external_class.from_hash(attrs) + + # link the newly created object and the parent both ways + created_object.send("#{@class_name.underscore}=", parent_obj) + # parent_obj.send("#{external_class.ref_class.name.underscore}=", created_object) + + save_object(created_object) end - save_object(created_object) end end @@ -240,27 +267,21 @@ def save_object(created_object) puts "Create Obj:" pp created_object created_object.save! # todo - change to save! when ready to finish testing - puts "wat" rescue ActiveRecord::RecordInvalid => e # Handle validation errors puts "Validation error: #{e.message}" - pp "hi" raise ActiveRecord::Rollback rescue ActiveRecord::RecordNotUnique => e # Handle unique constraint violations puts "Unique constraint violation: #{e.message}" - pp "uh oh" return created_object rescue StandardError => e puts "An unexpected error occurred: #{e.message}" - pp "bye" raise ActiveRecord::Rollback end end # end - - # Instance method to serialize a record for export def to_hash(fields = self.class.internal_fields) fields.to_h { |f| [f, send(f)] } From 72e49e640e03e7ccfd3e07e77696e3c4c5d83372 Mon Sep 17 00:00:00 2001 From: Camille Jones Date: Sat, 29 Nov 2025 00:42:40 -0500 Subject: [PATCH 06/80] Changed a small comment and moved a todo. --- app/helpers/importable_exportable_helper.rb | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/helpers/importable_exportable_helper.rb b/app/helpers/importable_exportable_helper.rb index ebd20fcfc..7ae9f1cb9 100644 --- a/app/helpers/importable_exportable_helper.rb +++ b/app/helpers/importable_exportable_helper.rb @@ -170,8 +170,7 @@ def try_import_records(file, headers, use_header: false) dup_records << dup_obj if dup_obj end - puts "okay take it back" - raise ActiveRecord::Rollback + raise ActiveRecord::Rollback # todo - remove this when wanting to actually channge the data end # todo - add duplicate action and error handling for this @@ -266,7 +265,7 @@ def save_object(created_object) begin puts "Create Obj:" pp created_object - created_object.save! # todo - change to save! when ready to finish testing + created_object.save! rescue ActiveRecord::RecordInvalid => e # Handle validation errors puts "Validation error: #{e.message}" From 85de9a471a521af0d9f4c8a1ecc3bc4765a67490 Mon Sep 17 00:00:00 2001 From: Camille Jones Date: Sat, 29 Nov 2025 14:02:47 -0500 Subject: [PATCH 07/80] Added the duplicate actions helper and added a place for the array of duplicate records in the try_import_records method. This method should be extracted to the service. --- app/helpers/duplicated_action_helper.rb | 120 +++++++++++++++++--- app/helpers/importable_exportable_helper.rb | 18 ++- app/services/field_mapping.rb | 5 +- 3 files changed, 119 insertions(+), 24 deletions(-) diff --git a/app/helpers/duplicated_action_helper.rb b/app/helpers/duplicated_action_helper.rb index f76b58df9..5092bb971 100644 --- a/app/helpers/duplicated_action_helper.rb +++ b/app/helpers/duplicated_action_helper.rb @@ -1,42 +1,130 @@ -# This file defines a helper class for handling data duplication events. -# It provides methods to manage and process duplicated actions within the application. -# It is designed to be used in conjunction with controllers and views that deal with duplicated data. +# This file defines a helper module for handling duplicate record events. +# It is backwards-compatible with the previous interface while also +# providing a full class-based DuplicateAction architecture. module DuplicatedActionHelper - # This method processes duplicated actions based on the provided parameters. + ############################################### + # PUBLIC METHOD (Existing API) + ############################################### + # Processes duplicated actions using the legacy string-based API + # OR the new class-based DuplicateAction actions. # - # @param action [String] the action to be performed on the duplicated data - # @param data [Array] the data to be processed - # @return [Array] the processed data after performing the action + # @param action [String, DuplicateAction] the action to perform + # @param data [Array] duplicated data entries + # @return [Array] result after applying the action def process_duplicated_action(action, data) case action when 'merge' merge_duplicated_data(data) + when 'delete' delete_duplicated_data(data) + + # NEW: Allow passing in an object that implements DuplicateAction + when DuplicateAction + record = OpenStruct.new(data.first) # Adapts hash input to object-like behavior + klass = OpenStruct # For non-ActiveRecord duplicate workflows + resolved = action.on_duplicate_record(klass: klass, record: record) + resolved ? [resolved.to_h] : [] + else raise ArgumentError, "Unknown action: #{action}" end end + ############################################### + # EXISTING PRIVATE METHODS (unchanged behavior) + ############################################### private - # Merges duplicated data entries into a single entry. # - # @param data [Array] the duplicated data to be merged - # @return [Array] the merged data + # @param data [Array] + # @return [Array] def merge_duplicated_data(data) - # Implementation of merging logic goes here - # This is a placeholder implementation data.uniq { |entry| entry[:id] } end # Deletes duplicated data entries. # - # @param data [Array] the duplicated data to be deleted - # @return [Array] the remaining data after deletion + # @param data [Array] + # @return [Array] def delete_duplicated_data(data) - # Implementation of deletion logic goes here - # This is a placeholder implementation [] end + + ############################################### + # NEW — DUPLICATE ACTION SYSTEM + ############################################### + module DuplicateAction + # Abstract method that all actions must implement. + def on_duplicate_record(klass:, record:) + raise NotImplementedError, + "on_duplicate_record must be implemented in #{self.class.name}" + end + + private + + # Offending fields for systems using uniqueness attributes. + # For simple hash-based data, this defaults to [:id]. + def offending_fields_for(_klass, record) + record.to_h.keys.select { |k| k.to_s.include?("id") } + end + end + + ############################################### + # NEW ACTION CLASS: SkipRecord + ############################################### + class SkipRecord + include DuplicateAction + + def on_duplicate_record(klass:, record:) + Rails.logger.info("Skipping duplicate record: #{record.to_h}") + nil + end + end + + ############################################### + # NEW ACTION CLASS: ChangeField (“_copy” resolver) + ############################################### + class ChangeField + include DuplicateAction + + MAX_ATTEMPTS = 10 + + def on_duplicate_record(klass:, record:) + fields = offending_fields_for(klass, record) + return nil if fields.empty? + + updated = record.dup + attempts = 0 + + while attempts < MAX_ATTEMPTS + fields.each do |f| + value = updated.send(f) + updated.send("#{f}=", "#{value}_copy") + end + + # For simple usage (hash input), assume "copy" resolves duplicates + return updated unless updated.to_h.values.any?(&:nil?) + + attempts += 1 + end + + Rails.logger.warn("Could not resolve duplicate after #{MAX_ATTEMPTS} attempts") + nil + end + end + + ############################################### + # NEW ACTION CLASS: UpdateExistingRecord + ############################################### + class UpdateExistingRecord + include DuplicateAction + + def on_duplicate_record(klass:, record:) + # For hash-based workflows, just return the provided record + # (Existing record is "updated" logically) + record + end + end + end \ No newline at end of file diff --git a/app/helpers/importable_exportable_helper.rb b/app/helpers/importable_exportable_helper.rb index 7ae9f1cb9..ca175056b 100644 --- a/app/helpers/importable_exportable_helper.rb +++ b/app/helpers/importable_exportable_helper.rb @@ -167,13 +167,18 @@ def try_import_records(file, headers, use_header: false) temp_contents.each do |row| # Get the row as a hash, with the header pointing towards the attribute value dup_obj = import_row(row, temp_file) - dup_records << dup_obj if dup_obj + dup_records << dup_obj if dup_obj && dup_obj != true end + pp dup_records + + # todo - Handle duplicate records that are thrown out when importing the rows + + + raise ActiveRecord::Rollback # todo - remove this when wanting to actually channge the data end - # todo - add duplicate action and error handling for this File.delete(temp_file) end @@ -187,7 +192,7 @@ def import_row(row, file) # Open the csv file, get the header row, and build the mapping with only the fields available in the current class header_row = CSV.open(file, &:first) mapping = FieldMapping.from_header(self, header_row) # Get mapping of only internal fields - pp row + # pp row row_hash = {} mapping.ordered_fields.zip(row).each do |key, value| row_hash[key] ||= [] # Initialize an empty array if the key is new @@ -206,7 +211,9 @@ def import_row(row, file) end end - save_object(created_object) + dup_obj = save_object(created_object) + + return dup_obj if dup_obj # Then create external classes that rely on the object we just created if external_classes @@ -257,7 +264,6 @@ def create_external_class(row_hash, mapping, external_class, parent_class, paren save_object(created_object) end - end end @@ -269,7 +275,7 @@ def save_object(created_object) rescue ActiveRecord::RecordInvalid => e # Handle validation errors puts "Validation error: #{e.message}" - raise ActiveRecord::Rollback + return created_object rescue ActiveRecord::RecordNotUnique => e # Handle unique constraint violations puts "Unique constraint violation: #{e.message}" diff --git a/app/services/field_mapping.rb b/app/services/field_mapping.rb index 2b7703112..0cee2336e 100644 --- a/app/services/field_mapping.rb +++ b/app/services/field_mapping.rb @@ -20,8 +20,9 @@ def self.from_header(model_class, header_row) valid_fields.find { |f| f.casecmp?(h) } end.compact - pp "matched" - puts "Header Row: #{header_row}" + # todo - remove debug statements + # pp "matched" + # puts "Header Row: #{header_row}" new(model_class, matched) end From 3876a112e8a64cacb6d7adad2fd46787262f9e4f Mon Sep 17 00:00:00 2001 From: crjone24 Date: Sun, 30 Nov 2025 01:38:38 -0500 Subject: [PATCH 08/80] Started adding the unit tests for the helper file. --- spec/helpers/import_export_spec.rb | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 spec/helpers/import_export_spec.rb diff --git a/spec/helpers/import_export_spec.rb b/spec/helpers/import_export_spec.rb new file mode 100644 index 000000000..f484122f7 --- /dev/null +++ b/spec/helpers/import_export_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ImportableExportableHelper, type: :helper do + describe 'Create tests for each of the different importable classes' do + xit 'Import a class with no headers' do + + end + + xit 'Import a class with headers' do + + end + + xit 'Import a file with multiple records' do + + end + + xit 'Import a class with external lookup and create classe' do + + end + + xit 'Import a class with external create classes can take duplicate headers' do + + end + end +end From 371ca19bddeadf79b4accb3c686780da8ab17478 Mon Sep 17 00:00:00 2001 From: Camille Jones Date: Sun, 30 Nov 2025 13:16:38 -0500 Subject: [PATCH 09/80] Added tests and fixtures for importable_exportable_helper.rb. --- app/controllers/import_controller.rb | 4 - app/helpers/importable_exportable_helper.rb | 257 +++++++++--------- spec/fixtures/files/empty.csv | 0 spec/fixtures/files/empty_with_headers.csv | 1 + .../files/multiple_users_no_headers.csv | 2 + .../files/multiple_users_with_headers.csv | 3 + .../files/questionnaire_item_with_headers.csv | 2 + .../fixtures/files/single_user_no_headers.csv | 1 + .../files/single_user_role_doe_not_exist.csv | 2 + .../files/single_user_with_headers.csv | 2 + .../files/users_duplicate_records.csv | 6 + spec/helpers/import_export_spec.rb | 112 +++++++- 12 files changed, 259 insertions(+), 133 deletions(-) create mode 100644 spec/fixtures/files/empty.csv create mode 100644 spec/fixtures/files/empty_with_headers.csv create mode 100644 spec/fixtures/files/multiple_users_no_headers.csv create mode 100644 spec/fixtures/files/multiple_users_with_headers.csv create mode 100644 spec/fixtures/files/questionnaire_item_with_headers.csv create mode 100644 spec/fixtures/files/single_user_no_headers.csv create mode 100644 spec/fixtures/files/single_user_role_doe_not_exist.csv create mode 100644 spec/fixtures/files/single_user_with_headers.csv create mode 100644 spec/fixtures/files/users_duplicate_records.csv diff --git a/app/controllers/import_controller.rb b/app/controllers/import_controller.rb index 650d13ce2..a2e525889 100644 --- a/app/controllers/import_controller.rb +++ b/app/controllers/import_controller.rb @@ -10,14 +10,10 @@ def index end def import - pp params uploaded_file = params[:csv_file] use_headers = ActiveRecord::Type::Boolean.new.deserialize(params[:use_headers]) ordered_fields = JSON.parse(params[:ordered_fields]) if params[:ordered_fields] - puts "Item Mandatory: #{Item.mandatory_fields}" - puts "Quiz Item Mandatory: #{QuizItem.mandatory_fields}" - params[:class].constantize.try_import_records(uploaded_file, ordered_fields, use_header: use_headers) end diff --git a/app/helpers/importable_exportable_helper.rb b/app/helpers/importable_exportable_helper.rb index ca175056b..4c7e35f42 100644 --- a/app/helpers/importable_exportable_helper.rb +++ b/app/helpers/importable_exportable_helper.rb @@ -38,22 +38,31 @@ def unappended_class_name(name) # to the front def lookup(class_values) # See if lookup field or primary key is in values hash. If not, return nothing - if @lookup_field && class_values[append_class_name(@lookup_field.to_s)] - class_name_lookup_field = append_class_name(@lookup_field.to_s) - + # puts "starting lookup" + class_name_lookup_field = append_class_name(@lookup_field.to_s) + class_name_primary_key = append_class_name(@ref_class.primary_key) + if @lookup_field && class_values[class_name_lookup_field] + # puts @lookup_field # Ex. field: name, value: class_values[role_name] - value = @ref_class.find_by(@lookup_field => class_values[class_name_lookup_field]) + if @ref_class.attribute_method?(@lookup_field) + value = @ref_class.find_by(@lookup_field => class_values[class_name_lookup_field]) + # puts "lookup field: #{value}" # Ex. field: role_name, value: class_values[role_name] - value ||= @ref_class.find_by(class_name_lookup_field => class_values[class_name_lookup_field]) - end - - if class_values[append_class_name(@ref_class.primary_key)] - class_name_primary_key = append_class_name(@ref_class.primary_key) + elsif @ref_class.attribute_method?(class_name_lookup_field) + value ||= @ref_class.find_by(class_name_lookup_field => class_values[class_name_lookup_field]) + # puts "append class name? #{value}" + end + elsif class_values[class_name_primary_key] # Ex. field: id, value: class_values[role_id] - value ||= @ref_class.find_by(@ref_class.primary_key => class_values[class_name_primary_key]) + if @ref_class.attribute_method?(@ref_class.primary_key) + value ||= @ref_class.find_by(@ref_class.primary_key => class_values[class_name_primary_key]) + # puts "primary key: #{value}" # Ex. field: role_id value: class_values[role_id] - value ||= @ref_class.find_by(class_name_primary_key => class_values[class_name_primary_key]) + elsif @ref_class.attribute_method?(class_name_primary_key) + value ||= @ref_class.find_by(class_name_primary_key => class_values[class_name_primary_key]) + # puts "pk with class name: #{value}" + end end value @@ -87,150 +96,150 @@ def self.extended(base) # module ClassMethods - def mandatory_fields(*fields) - if fields.any? - @mandatory_fields = fields.map(&:to_s) - else - @mandatory_fields - end + def mandatory_fields(*fields) + if fields.any? + @mandatory_fields = fields.map(&:to_s) + else + @mandatory_fields end + end - def optional_fields - internal_fields - mandatory_fields - end + def optional_fields + internal_fields - mandatory_fields + end - def external_classes(*fields) - if fields.any? - @external_classes = fields - else - @external_classes - end + def external_classes(*fields) + if fields.any? + @external_classes = fields + else + @external_classes end + end # use the column names and mandaroty fields to know which fields constitute # internal fields. This is becuase of cases such as the password of a user being # the password_digest column in the database, but we need to assign it to the # password field of the object. - def internal_fields - (column_names + (mandatory_fields || [])).uniq - external_fields - end + def internal_fields + (column_names + (mandatory_fields || [])).uniq - external_fields + end - def external_fields - fields = [] - external_classes.each{ |external_class| fields += external_class.internal_fields } if external_classes + def external_fields + fields = [] + external_classes.each{ |external_class| fields += external_class.internal_fields } if external_classes - fields - end + fields + end - def internal_and_external_fields - internal_fields + external_fields - end + def internal_and_external_fields + internal_fields + external_fields + end # Factory method for importing a record from a hash - def from_hash(attrs) - fixed_attrs = {} - attrs.each {|k, v| fixed_attrs[k] = v[0]} + def from_hash(attrs) + fixed_attrs = {} + attrs.each {|k, v| fixed_attrs[k] = v[0]} - new(fixed_attrs) - end + new(fixed_attrs) + end # todo - possibly extract this function to the service - def try_import_records(file, headers, use_header: false) - temp_file = 'output.csv' - csv_file = CSV.read(file, headers: false) - - # In a temp file, so that headers can be added to the top if the use_header options isn't selected - CSV.open(temp_file, "w") do |csv| - - if use_header - headers = csv_file[0].map{ |header| header.parameterize.underscore } - csv_file.shift - else - headers = headers.map{ |header| header.parameterize.underscore } - end + def try_import_records(file, headers, use_header: false) + temp_file = 'output.csv' + csv_file = CSV.read(file, headers: false) - csv << headers + # In a temp file, so that headers can be added to the top if the use_header options isn't selected + CSV.open(temp_file, "w") do |csv| - # then copy the rest of the csv file - csv_file.each do |row| - csv << row - end + if use_header + headers = csv_file[0].map{ |header| header.parameterize.underscore } + csv_file.shift + else + headers = headers.map{ |header| header.parameterize.underscore } end - temp_contents = CSV.read(temp_file) - temp_contents.shift + csv << headers - dup_records = [] + # then copy the rest of the csv file + csv_file.each do |row| + csv << row + end + end - ActiveRecord::Base.transaction do - temp_contents.each do |row| - # Get the row as a hash, with the header pointing towards the attribute value - dup_obj = import_row(row, temp_file) - dup_records << dup_obj if dup_obj && dup_obj != true - end + temp_contents = CSV.read(temp_file) + temp_contents.shift - pp dup_records + dup_records = [] - # todo - Handle duplicate records that are thrown out when importing the rows + ActiveRecord::Base.transaction do + temp_contents.each do |row| + # Get the row as a hash, with the header pointing towards the attribute value + dup_obj = import_row(row, temp_file) + dup_records << dup_obj if dup_obj && dup_obj != true + end + pp dup_records + # todo - Handle duplicate records that are thrown out when importing the rows - raise ActiveRecord::Rollback # todo - remove this when wanting to actually channge the data - end + # Comment this out if you want to run the tests + raise ActiveRecord::Rollback # todo - remove this when wanting to actually channge the data + end - File.delete(temp_file) - end + File.delete(temp_file) + end # Import row function takes a hash for a row and tries to save it in the current class. # It takes a related class and object so that it can be used recursively. If a row should # update two classes,and one relies upon another, the recursion can be used to set the # belongs to relationship. # (EX if ) - def import_row(row, file) - # Open the csv file, get the header row, and build the mapping with only the fields available in the current class - header_row = CSV.open(file, &:first) - mapping = FieldMapping.from_header(self, header_row) # Get mapping of only internal fields - # pp row - row_hash = {} - mapping.ordered_fields.zip(row).each do |key, value| - row_hash[key] ||= [] # Initialize an empty array if the key is new - row_hash[key] << value - end + def import_row(row, file) + # Open the csv file, get the header row, and build the mapping with only the fields available in the current class + header_row = CSV.open(file, &:first) + mapping = FieldMapping.from_header(self, header_row) # Get mapping of only internal fields + # pp row + row_hash = {} + mapping.ordered_fields.zip(row).each do |key, value| + row_hash[key] ||= [] # Initialize an empty array if the key is new + row_hash[key] << value + end - puts "Row Hash: #{row_hash}" + puts "Row Hash: #{row_hash}" - current_class_attrs = row_hash.slice(*internal_fields) - created_object = from_hash(current_class_attrs) + current_class_attrs = row_hash.slice(*internal_fields) + created_object = from_hash(current_class_attrs) - # for each external class, try to look them up - if external_classes - external_classes.each do |external_class| - lookup_external_class(row_hash, external_class, self, created_object) - end + # for each external class, try to look them up + if external_classes + external_classes.each do |external_class| + lookup_external_class(row_hash, external_class, self, created_object) end + end - dup_obj = save_object(created_object) + dup_obj = save_object(created_object) - return dup_obj if dup_obj + return dup_obj if dup_obj && dup_obj != true - # Then create external classes that rely on the object we just created - if external_classes - external_classes.each do |external_class| - create_external_class(row_hash, mapping, external_class, self, created_object) - end + # Then create external classes that rely on the object we just created + + if external_classes + external_classes.each do |external_class| + create_external_class(row_hash, mapping, external_class, self, created_object) end end + end - def lookup_external_class(row_hash, external_class, parent_class, parent_obj) - # Lookup - If the external class is marked as a lookup and a value is found - if external_class.should_lookup && (lookup_value = external_class.lookup(row_hash)) - # Connect lookup value to the parent obj - parent_obj.send("#{external_class.ref_class.name.downcase}=", lookup_value) - return - end + def lookup_external_class(row_hash, external_class, parent_class, parent_obj) + # Lookup - If the external class is marked as a lookup and a value is found + if external_class.should_lookup && (lookup_value = external_class.lookup(row_hash)) + # Connect lookup value to the parent obj + parent_obj.send("#{external_class.ref_class.name.downcase}=", lookup_value) + return end + end def create_external_class(row_hash, mapping, external_class, parent_class, parent_obj) # Create - If the external class is marked as a create, attempt to create a new obj and link to parents @@ -267,24 +276,24 @@ def create_external_class(row_hash, mapping, external_class, parent_class, paren end end - def save_object(created_object) - begin - puts "Create Obj:" - pp created_object - created_object.save! - rescue ActiveRecord::RecordInvalid => e - # Handle validation errors - puts "Validation error: #{e.message}" - return created_object - rescue ActiveRecord::RecordNotUnique => e - # Handle unique constraint violations - puts "Unique constraint violation: #{e.message}" - return created_object - rescue StandardError => e - puts "An unexpected error occurred: #{e.message}" - raise ActiveRecord::Rollback - end + def save_object(created_object) + begin + puts "Create Obj:" + pp created_object + created_object.save! + rescue ActiveRecord::RecordInvalid => e + # Handle validation errors + puts "Validation error: #{e.message}" + return created_object + rescue ActiveRecord::RecordNotUnique => e + # Handle unique constraint violations + puts "Unique constraint violation: #{e.message}" + return created_object + rescue StandardError => e + puts "An unexpected error occurred: #{e.message}" + raise ActiveRecord::Rollback end + end # end # Instance method to serialize a record for export 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/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_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/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 index f484122f7..d14c1523d 100644 --- a/spec/helpers/import_export_spec.rb +++ b/spec/helpers/import_export_spec.rb @@ -1,27 +1,129 @@ # 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 - xit 'Import a class with no headers' 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'] + + User.try_import_records(csv_file, headers, use_header: false) + + expect(User.count).to eq(2) + expect(User.find_by(email: 'jdoe@email.com')).to be_present end - xit 'Import a class with headers' do + 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 - xit 'Import a file with multiple records' do + 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 - xit 'Import a class with external lookup and create classe' do + 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, use_header: 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 + - xit 'Import a class with external create classes can take duplicate headers' do + # * 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 'Create a test 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') + + User.try_import_records(csv_file, nil, use_header: true) + + expect(User.count).to eq(1) + expect(User.find_by(email: 'jdoe@email.com')).not_to be_present end + + it 'Create a test with an empty CSV (With Headers)' do + csv_file = file_fixture('empty_with_headers.csv') + + expect{User.try_import_records(csv_file, nil, use_header: true)}.not_to change(User, :count) + end + + it 'Create a test with an empty CSV (Without Headers)' do + csv_file = file_fixture('empty.csv') + + expect{User.try_import_records(csv_file, [], use_header: false)}.not_to change(User, :count) + end + + end + end From 0dc32fd1b86e9bc459e1036bac44cdcaa63d484b Mon Sep 17 00:00:00 2001 From: Camille Jones Date: Sun, 30 Nov 2025 15:15:14 -0500 Subject: [PATCH 10/80] Updated the import controller to return the expected values. --- app/controllers/import_controller.rb | 14 ++++++- app/helpers/importable_exportable_helper.rb | 41 ++++++++++++++------- spec/helpers/import_export_spec.rb | 14 +++++-- 3 files changed, 49 insertions(+), 20 deletions(-) diff --git a/app/controllers/import_controller.rb b/app/controllers/import_controller.rb index a2e525889..e7591a5d4 100644 --- a/app/controllers/import_controller.rb +++ b/app/controllers/import_controller.rb @@ -4,9 +4,13 @@ class ImportController < ApplicationController before_action :import_params def index imported_class = params[:class].constantize - mapping = FieldMapping.new(imported_class, ["name", "id", "email"]) - p mapping.duplicate_headers + render json: { + mandatory_fields: imported_class.mandatory_fields, + optional_fields: imported_class.optional_fields, + external_fields: imported_class.external_fields, + available_actions_on_dup: [] # Only for import + }, status: :ok end def import @@ -15,6 +19,12 @@ def import ordered_fields = JSON.parse(params[:ordered_fields]) if params[:ordered_fields] params[:class].constantize.try_import_records(uploaded_file, ordered_fields, use_header: use_headers) + + render json: { message: "#{params[:class].name} has been imported!" }, status: :created + + rescue StandardError => e + puts "An unexpected error occurred: #{e.message}" + render json: { error: e.message }, status: :unprocessable_entity end private diff --git a/app/helpers/importable_exportable_helper.rb b/app/helpers/importable_exportable_helper.rb index 4c7e35f42..19f9a0fa8 100644 --- a/app/helpers/importable_exportable_helper.rb +++ b/app/helpers/importable_exportable_helper.rb @@ -23,15 +23,6 @@ def internal_fields end - # Method to add the class name to a field. This is useful when the CSV might refer to a column with - # the class name appended (Ex role_name) but the internal field drops the class name (Ex Role.name) - def append_class_name(field) - @ref_class.name.underscore + "_" + field - end - - def unappended_class_name(name) - name.delete_prefix(@ref_class.name.underscore + "_") - end # Attempts too look in the database for any mention of the current class. It looks using the # given lookup field and the primary key. It checks both with and without the classname appended @@ -74,6 +65,17 @@ def from_hash(attrs) @ref_class.new(fixed_attrs) end + + private + # Method to add the class name to a field. This is useful when the CSV might refer to a column with + # the class name appended (Ex role_name) but the internal field drops the class name (Ex Role.name) + def append_class_name(field) + @ref_class.name.underscore + "_" + field + end + + def unappended_class_name(name) + name.delete_prefix(@ref_class.name.underscore + "_") + end end module ImportableExportableHelper @@ -88,6 +90,7 @@ def self.extended(base) 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 @@ -184,11 +187,13 @@ def try_import_records(file, headers, use_header: false) # todo - Handle duplicate records that are thrown out when importing the rows # Comment this out if you want to run the tests - raise ActiveRecord::Rollback # todo - remove this when wanting to actually channge the data + # raise ActiveRecord::Rollback # todo - remove this when wanting to actually channge the data end File.delete(temp_file) + dup_records + end # Import row function takes a hash for a row and tries to save it in the current class. @@ -284,14 +289,22 @@ def save_object(created_object) rescue ActiveRecord::RecordInvalid => e # Handle validation errors puts "Validation error: #{e.message}" - return created_object + + # Check if a specific attribute has a :uniqueness error + if created_object.errors.details[:attribute_name].any? { |detail| detail[:error] == :uniqueness } + puts "Uniqueness violation on attribute_name!" + return created_object + else + raise StandardError + end + + rescue ActiveRecord::RecordNotUnique => e # Handle unique constraint violations puts "Unique constraint violation: #{e.message}" return created_object - rescue StandardError => e - puts "An unexpected error occurred: #{e.message}" - raise ActiveRecord::Rollback + rescue StandardError + raise StandardError end end # end diff --git a/spec/helpers/import_export_spec.rb b/spec/helpers/import_export_spec.rb index d14c1523d..21b1568b1 100644 --- a/spec/helpers/import_export_spec.rb +++ b/spec/helpers/import_export_spec.rb @@ -100,24 +100,30 @@ # * 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 'Create a test with external lookup class that does not exist' 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, use_header: true)}.to raise_error + 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') - User.try_import_records(csv_file, nil, use_header: true) + expect { User.try_import_records(csv_file, nil, use_header: true) }.to raise_error expect(User.count).to eq(1) expect(User.find_by(email: 'jdoe@email.com')).not_to be_present end - it 'Create a test with an empty CSV (With Headers)' do + it 'Import an empty CSV (With Headers)' do csv_file = file_fixture('empty_with_headers.csv') expect{User.try_import_records(csv_file, nil, use_header: true)}.not_to change(User, :count) end - it 'Create a test with an empty CSV (Without Headers)' do + it 'Import an empty CSV (Without Headers)' do csv_file = file_fixture('empty.csv') expect{User.try_import_records(csv_file, [], use_header: false)}.not_to change(User, :count) From 2643b2cfa51ef3a08a11e3020af63013cbd558d6 Mon Sep 17 00:00:00 2001 From: Camille Jones Date: Sun, 30 Nov 2025 15:17:57 -0500 Subject: [PATCH 11/80] A while back the Question table was changed to the Item table. However, everywhere kept refering to the question_id. This wouldn't work for importing, so I changed the schema to use item_id rather than question_id and changed all the references I found to use item_id as well. --- app/helpers/scorable_helper.rb | 6 +++--- app/models/Item.rb | 2 +- app/models/multiple_choice_checkbox.rb | 6 +++--- app/models/multiple_choice_radio.rb | 8 ++++---- app/models/question_advice.rb | 4 ++-- app/models/quiz_item.rb | 2 +- app/models/response.rb | 2 +- ...1129040855_rename_item_id_in_question_tables.rb | 7 +++++++ db/schema.rb | 14 +++++++------- 9 files changed, 29 insertions(+), 22 deletions(-) create mode 100644 db/migrate/20251129040855_rename_item_id_in_question_tables.rb diff --git a/app/helpers/scorable_helper.rb b/app/helpers/scorable_helper.rb index deac24055..7de21052f 100644 --- a/app/helpers/scorable_helper.rb +++ b/app/helpers/scorable_helper.rb @@ -8,7 +8,7 @@ def calculate_total_score # answer for scorable questions, and they will not be counted towards the total score) sum = 0 - question_ids = scores.map(&:question_id) + question_ids = scores.map(&:item_id) # We use find with order here to ensure that the list of questions we get is in the same order as that of question_ids questions = Item.find_with_order(question_ids) @@ -35,7 +35,7 @@ def maximum_score # Only count the scorable questions, only when the answer is not nil (we accept nil as # answer for scorable questions, and they will not be counted towards the total score) total_weight = 0 - question_ids = scores.map(&:question_id) + question_ids = scores.map(&:item_id) # We use find with order here to ensure that the list of questions we get is in the same order as that of question_ids questions = Item.find_with_order(question_ids) @@ -60,7 +60,7 @@ def questionnaire_by_answer(answer) assignment = map.response_assignment questionnaire = Questionnaire.find(assignment.review_questionnaire_id) else - questionnaire = Item.find(answer.question_id).questionnaire + questionnaire = Item.find(answer.item_id).questionnaire end questionnaire end diff --git a/app/models/Item.rb b/app/models/Item.rb index 887d85f13..084efde86 100644 --- a/app/models/Item.rb +++ b/app/models/Item.rb @@ -9,7 +9,7 @@ class Item < ApplicationRecord before_create :set_seq belongs_to :questionnaire # each item belongs to a specific questionnaire - has_many :answers, dependent: :destroy, foreign_key: 'question_id' + has_many :answers, dependent: :destroy, foreign_key: 'item_id' attr_accessor :choice_strategy validates :seq, presence: true, numericality: true # sequence must be numeric 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/question_advice.rb b/app/models/question_advice.rb index 7ba103dec..65b1c7bab 100644 --- a/app/models/question_advice.rb +++ b/app/models/question_advice.rb @@ -12,14 +12,14 @@ def self.export_fields(_options) 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| + QuestionAdvice.where(item_id: item.id).each do |advice| csv << advice.attributes.values end end end def self.to_json_by_question_id(question_id) - question_advices = QuestionAdvice.where(question_id: question_id).order(:id) + question_advices = QuestionAdvice.where(item_id: question_id).order(:id) question_advices.map do |advice| { score: advice.score, advice: advice.advice } end diff --git a/app/models/quiz_item.rb b/app/models/quiz_item.rb index 2f92eceac..60b3d9ef7 100644 --- a/app/models/quiz_item.rb +++ b/app/models/quiz_item.rb @@ -4,7 +4,7 @@ class QuizItem < Item extend ImportableExportableHelper - has_many :quiz_question_choices, class_name: 'QuizQuestionChoice', foreign_key: 'question_id', inverse_of: false, dependent: :nullify + has_many :quiz_question_choices, class_name: 'QuizQuestionChoice', foreign_key: 'item_id', inverse_of: false, dependent: :nullify def edit end diff --git a/app/models/response.rb b/app/models/response.rb index 9e07fd79d..949c70eb0 100644 --- a/app/models/response.rb +++ b/app/models/response.rb @@ -50,7 +50,7 @@ def aggregate_questionnaire_score # we accept nil as answer for scorable questions, and they will not be counted towards the total score sum = 0 scores.each do |s| - item = Item.find(s.question_id) + item = Item.find(s.item_id) # For quiz responses, the weights will be 1 or 0, depending on if correct sum += s.answer * item.weight unless s.answer.nil? || !item.scorable? 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..ec37dc86a --- /dev/null +++ b/db/migrate/20251129040855_rename_item_id_in_question_tables.rb @@ -0,0 +1,7 @@ +class RenameItemIdInQuestionTables < ActiveRecord::Migration[8.0] + def change + rename_column :answers, :question_id, :item_id + rename_column :question_advices, :question_id, :item_id + rename_column :quiz_question_choices, :question_id, :item_id + end +end diff --git a/db/schema.rb b/db/schema.rb index 462029322..9a160e829 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_04_27_014225) do +ActiveRecord::Schema[8.0].define(version: 2025_11_29_040855) do create_table "account_requests", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "username" t.string "full_name" @@ -26,13 +26,13 @@ end create_table "answers", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.integer "question_id", default: 0, null: false + t.integer "item_id", default: 0, null: false t.integer "response_id" t.integer "answer" t.text "comments" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["question_id"], name: "fk_score_questions" + t.index ["item_id"], name: "fk_score_questions" t.index ["response_id"], name: "fk_score_response" end @@ -239,12 +239,12 @@ 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| @@ -273,7 +273,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 @@ -416,7 +416,7 @@ add_foreign_key "participants", "join_team_requests" add_foreign_key "participants", "teams" add_foreign_key "participants", "users" - 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 "sign_up_topics", "assignments" add_foreign_key "signed_up_teams", "sign_up_topics" From afaf5cfd976cd76a80509659ec71d397573250ab Mon Sep 17 00:00:00 2001 From: Camille Jones Date: Sun, 30 Nov 2025 15:18:40 -0500 Subject: [PATCH 12/80] Added a csv file for invalid email for users. --- spec/fixtures/files/single_user_email_invalid.csv | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 spec/fixtures/files/single_user_email_invalid.csv 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 From 65ecaec058a048d63c6745c0c4e6fd5b375f4d93 Mon Sep 17 00:00:00 2001 From: crjone24 Date: Sun, 30 Nov 2025 22:35:08 -0500 Subject: [PATCH 13/80] Added import export helper to the Team class --- app/models/team.rb | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/models/team.rb b/app/models/team.rb index 9c8813c08..927f5ff2a 100644 --- a/app/models/team.rb +++ b/app/models/team.rb @@ -1,6 +1,11 @@ # frozen_string_literal: true class Team < ApplicationRecord + extend ImportableExportableHelper + mandatory_fields :name, :type + external_classes ExternalClass.new(Assignment, true, false, :title), + ExternalClass.new(Course, true, false, :name), + ExternalClass.new(User, true, false, :name) # Core associations has_many :signed_up_teams, dependent: :destroy From 3c63a99ff8f295de5a57cfa10eda11dd0e3f90cf Mon Sep 17 00:00:00 2001 From: Camille Jones Date: Mon, 1 Dec 2025 17:19:18 -0500 Subject: [PATCH 14/80] Updated external classes to list their fields with the class name appended. --- app/helpers/importable_exportable_helper.rb | 184 ++++++++++---------- 1 file changed, 91 insertions(+), 93 deletions(-) diff --git a/app/helpers/importable_exportable_helper.rb b/app/helpers/importable_exportable_helper.rb index 19f9a0fa8..7b6fb1de9 100644 --- a/app/helpers/importable_exportable_helper.rb +++ b/app/helpers/importable_exportable_helper.rb @@ -18,7 +18,7 @@ def internal_fields if @ref_class.respond_to?(:internal_fields) @ref_class.internal_fields.map {|field| append_class_name(field)} else - [append_class_name(@lookup_field.to_s), @ref_class.primary_key] + [append_class_name(@lookup_field.to_s), append_class_name(@ref_class.primary_key)] end end @@ -37,7 +37,7 @@ def lookup(class_values) # Ex. field: name, value: class_values[role_name] if @ref_class.attribute_method?(@lookup_field) value = @ref_class.find_by(@lookup_field => class_values[class_name_lookup_field]) - # puts "lookup field: #{value}" + # puts "lookup field: #{value}" # Ex. field: role_name, value: class_values[role_name] elsif @ref_class.attribute_method?(class_name_lookup_field) value ||= @ref_class.find_by(class_name_lookup_field => class_values[class_name_lookup_field]) @@ -48,7 +48,7 @@ def lookup(class_values) # Ex. field: id, value: class_values[role_id] if @ref_class.attribute_method?(@ref_class.primary_key) value ||= @ref_class.find_by(@ref_class.primary_key => class_values[class_name_primary_key]) - # puts "primary key: #{value}" + # puts "primary key: #{value}" # Ex. field: role_id value: class_values[role_id] elsif @ref_class.attribute_method?(class_name_primary_key) value ||= @ref_class.find_by(class_name_primary_key => class_values[class_name_primary_key]) @@ -67,26 +67,27 @@ def from_hash(attrs) end private + # Method to add the class name to a field. This is useful when the CSV might refer to a column with # the class name appended (Ex role_name) but the internal field drops the class name (Ex Role.name) def append_class_name(field) - @ref_class.name.underscore + "_" + field + "#{@ref_class.name.underscore}_#{field}" end def unappended_class_name(name) - name.delete_prefix(@ref_class.name.underscore + "_") + name.delete_prefix("#{@ref_class.name.underscore}_") end end module ImportableExportableHelper - attr_accessor :available_actions_on_duplicate, :mandatory_fields, :external_classes + attr_accessor :available_actions_on_duplicate # def self.included(base) # base.extend(ClassMethods) # end def self.extended(base) - if base.superclass&.respond_to?(:mandatory_fields) + 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) @@ -120,17 +121,17 @@ def external_classes(*fields) end - # use the column names and mandaroty fields to know which fields constitute - # internal fields. This is becuase of cases such as the password of a user being - # the password_digest column in the database, but we need to assign it to the - # password field of the object. + # use the column names and mandaroty fields to know which fields constitute + # internal fields. This is becuase of cases such as the password of a user being + # the password_digest column in the database, but we need to assign it to the + # password field of the object. def internal_fields (column_names + (mandatory_fields || [])).uniq - external_fields end def external_fields fields = [] - external_classes.each{ |external_class| fields += external_class.internal_fields } if external_classes + external_classes&.each { |external_class| fields += external_class.internal_fields } fields end @@ -139,7 +140,7 @@ def internal_and_external_fields internal_fields + external_fields end - # Factory method for importing a record from a hash + # Factory method for importing a record from a hash def from_hash(attrs) fixed_attrs = {} attrs.each {|k, v| fixed_attrs[k] = v[0]} @@ -147,19 +148,24 @@ def from_hash(attrs) new(fixed_attrs) end - # todo - possibly extract this function to the service + # Instance method to serialize a record for export + def to_hash(fields = self.class.internal_fields) + fields.to_h { |f| [f, send(f)] } + end + + # todo - possibly extract this function to the service def try_import_records(file, headers, use_header: false) temp_file = 'output.csv' - csv_file = CSV.read(file, headers: false) + csv_file = CSV.read(file) # In a temp file, so that headers can be added to the top if the use_header options isn't selected - CSV.open(temp_file, "w") do |csv| + CSV.open(temp_file, 'w') do |csv| if use_header - headers = csv_file[0].map{ |header| header.parameterize.underscore } + headers = csv_file[0].map { |header| header.parameterize.underscore } csv_file.shift else - headers = headers.map{ |header| header.parameterize.underscore } + headers = headers.map { |header| header.parameterize.underscore } end csv << headers @@ -196,11 +202,11 @@ def try_import_records(file, headers, use_header: false) end - # Import row function takes a hash for a row and tries to save it in the current class. - # It takes a related class and object so that it can be used recursively. If a row should - # update two classes,and one relies upon another, the recursion can be used to set the - # belongs to relationship. - # (EX if ) + # Import row function takes a hash for a row and tries to save it in the current class. + # It takes a related class and object so that it can be used recursively. If a row should + # update two classes,and one relies upon another, the recursion can be used to set the + # belongs to relationship. + # (EX if ) def import_row(row, file) # Open the csv file, get the header row, and build the mapping with only the fields available in the current class header_row = CSV.open(file, &:first) @@ -218,10 +224,8 @@ def import_row(row, file) created_object = from_hash(current_class_attrs) # for each external class, try to look them up - if external_classes - external_classes.each do |external_class| - lookup_external_class(row_hash, external_class, self, created_object) - end + external_classes&.each do |external_class| + lookup_external_class(row_hash, external_class, created_object) end dup_obj = save_object(created_object) @@ -230,89 +234,83 @@ def import_row(row, file) # Then create external classes that rely on the object we just created - if external_classes - external_classes.each do |external_class| - create_external_class(row_hash, mapping, external_class, self, created_object) - end + return unless external_classes + + external_classes.each do |external_class| + create_external_class(row_hash, external_class, created_object) end + end - def lookup_external_class(row_hash, external_class, parent_class, parent_obj) + private + + def lookup_external_class(row_hash, external_class, parent_obj) # Lookup - If the external class is marked as a lookup and a value is found if external_class.should_lookup && (lookup_value = external_class.lookup(row_hash)) # Connect lookup value to the parent obj parent_obj.send("#{external_class.ref_class.name.downcase}=", lookup_value) - return + nil end end - def create_external_class(row_hash, mapping, external_class, parent_class, parent_obj) - # Create - If the external class is marked as a create, attempt to create a new obj and link to parents - # This can happen if it is marked and a lookup val wasn't found - if external_class.should_create - - # Get the attributes, with duplicates in an array - current_class_attrs = row_hash.slice(*external_class.internal_fields) - - # In the order of the attributes, pair them together in new hashes. These new hashes - # are ready to be made into the new object - # Ex. - # Initial: {"question_advice_score" => ["1", "2"], - # "question_advice_advice" => ["okay", "good"]} - # Result: [{"question_advice_score" => "1", "question_advice_advice" => "okay"}, - # {"question_advice_score" => "2", "question_advice_advice" => "good"}] - # - 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 - - - # Use each set to create the new objects - object_sets_with_keys.each do |attrs| - created_object = external_class.from_hash(attrs) - - # link the newly created object and the parent both ways - created_object.send("#{@class_name.underscore}=", parent_obj) - # parent_obj.send("#{external_class.ref_class.name.underscore}=", created_object) - - save_object(created_object) - end - end + def create_external_class(row_hash, external_class, parent_obj) + # Create - If the external class is marked as a create, attempt to create a new obj and link to parents + # This can happen if it is marked and a lookup val wasn't found + return unless external_class.should_create + + # Get the attributes, with duplicates in an array + current_class_attrs = row_hash.slice(*external_class.internal_fields) + + # In the order of the attributes, pair them together in new hashes. These new hashes + # are ready to be made into the new object + # Ex. + # Initial: {"question_advice_score" => ["1", "2"], + # "question_advice_advice" => ["okay", "good"]} + # Result: [{"question_advice_score" => "1", "question_advice_advice" => "okay"}, + # {"question_advice_score" => "2", "question_advice_advice" => "good"}] + # + 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 - def save_object(created_object) - begin - puts "Create Obj:" - pp created_object - created_object.save! - rescue ActiveRecord::RecordInvalid => e - # Handle validation errors - puts "Validation error: #{e.message}" - - # Check if a specific attribute has a :uniqueness error - if created_object.errors.details[:attribute_name].any? { |detail| detail[:error] == :uniqueness } - puts "Uniqueness violation on attribute_name!" - return created_object - else - raise StandardError - end + # Use each set to create the new objects + object_sets_with_keys.each do |attrs| + created_object = external_class.from_hash(attrs) - rescue ActiveRecord::RecordNotUnique => e - # Handle unique constraint violations - puts "Unique constraint violation: #{e.message}" - return created_object - rescue StandardError - raise StandardError + # link the newly created object and the parent both ways + created_object.send("#{@class_name.underscore}=", parent_obj) + # parent_obj.send("#{external_class.ref_class.name.underscore}=", created_object) + + save_object(created_object) end + end - # end - # Instance method to serialize a record for export - def to_hash(fields = self.class.internal_fields) - fields.to_h { |f| [f, send(f)] } + def save_object(created_object) + puts 'Create Obj:' + pp created_object + created_object.save! + rescue ActiveRecord::RecordInvalid => e + # Check if a specific attribute has a :uniqueness error + unless created_object.errors.details[:attribute_name].any? { |detail| detail[:error] == :uniqueness } + raise StandardError.new(e.message) + end + + # Handle validation errors + puts "Validation error: #{e.message}" + + puts 'Uniqueness violation on attribute_name!' + created_object + rescue ActiveRecord::RecordNotUnique => e + # Handle unique constraint violations + puts "Unique constraint violation: #{e.message}" + created_object + rescue StandardError => e + raise e + end -end \ No newline at end of file +end From d13e69d4ad07bbe6856b28861b327efb4291cbc4 Mon Sep 17 00:00:00 2001 From: TaylorBrown96 Date: Mon, 1 Dec 2025 20:36:57 -0500 Subject: [PATCH 15/80] Implement assignment-specific team export functionality - Added script to export AssignmentTeam records filtered by assignment_id - Included team members, emails, and member counts in detailed export - Improved export process explanation and cleaned up runner command usage - Ensured compatibility even without parent association by resolving assignment names manually --- app/helpers/duplicated_action_helper.rb | 229 ++++++++++++------------ app/services/export.rb | 10 +- app/services/field_mapping.rb | 18 +- app/services/import.rb | 115 +++++++++--- 4 files changed, 216 insertions(+), 156 deletions(-) diff --git a/app/helpers/duplicated_action_helper.rb b/app/helpers/duplicated_action_helper.rb index 5092bb971..e0123162e 100644 --- a/app/helpers/duplicated_action_helper.rb +++ b/app/helpers/duplicated_action_helper.rb @@ -1,130 +1,131 @@ -# This file defines a helper module for handling duplicate record events. -# It is backwards-compatible with the previous interface while also -# providing a full class-based DuplicateAction architecture. -module DuplicatedActionHelper - ############################################### - # PUBLIC METHOD (Existing API) - ############################################### - # Processes duplicated actions using the legacy string-based API - # OR the new class-based DuplicateAction actions. - # - # @param action [String, DuplicateAction] the action to perform - # @param data [Array] duplicated data entries - # @return [Array] result after applying the action - def process_duplicated_action(action, data) - case action - when 'merge' - merge_duplicated_data(data) - - when 'delete' - delete_duplicated_data(data) - - # NEW: Allow passing in an object that implements DuplicateAction - when DuplicateAction - record = OpenStruct.new(data.first) # Adapts hash input to object-like behavior - klass = OpenStruct # For non-ActiveRecord duplicate workflows - resolved = action.on_duplicate_record(klass: klass, record: record) - resolved ? [resolved.to_h] : [] - - else - raise ArgumentError, "Unknown action: #{action}" - end +# frozen_string_literal: true + +# =============================================================== +# DuplicateAction (ABSTRACT MIXIN) +# +# All duplicate-resolution strategies used during import must include +# this module and implement: +# +# on_duplicate_record(klass:, records:) +# +# Parameters: +# klass => ActiveRecord model affected +# records => Array of hashes or model objects representing conflicts +# +# Return: +# - nil → skip inserting record +# - Array → rows to (re)insert +# +# =============================================================== +module DuplicateAction + def on_duplicate_record(klass:, records:) + raise NotImplementedError, + "#{self.class} must implement `on_duplicate_record`" end - - ############################################### - # EXISTING PRIVATE METHODS (unchanged behavior) - ############################################### - private - # Merges duplicated data entries into a single entry. - # - # @param data [Array] - # @return [Array] - def merge_duplicated_data(data) - data.uniq { |entry| entry[:id] } +end + +# =============================================================== +# SkipRecordAction +# +# Simply skips the offending row. Nothing is inserted. +# =============================================================== +class SkipRecordAction + include DuplicateAction + + def on_duplicate_record(klass:, records:) + nil end +end + +# =============================================================== +# UpdateExistingRecordAction +# +# Takes all conflicting rows and merges them. Later values override earlier ones. +# Result: +# One fully merged record replacing the original. +# =============================================================== +class UpdateExistingRecordAction + include DuplicateAction + + def on_duplicate_record(klass:, records:) + merged = {} + + # Accept both Hashes and ActiveRecord objects + records.each do |rec| + row = rec.is_a?(Hash) ? rec : rec.attributes.symbolize_keys + row.each do |key, value| + merged[key] = value unless value.nil? + end + end - # Deletes duplicated data entries. - # - # @param data [Array] - # @return [Array] - def delete_duplicated_data(data) - [] + [merged] # Return exactly one merged record end - - ############################################### - # NEW — DUPLICATE ACTION SYSTEM - ############################################### - module DuplicateAction - # Abstract method that all actions must implement. - def on_duplicate_record(klass:, record:) - raise NotImplementedError, - "on_duplicate_record must be implemented in #{self.class.name}" +end + +# =============================================================== +# ChangeOffendingFieldAction +# +# Autoresolves uniqueness violations by modifying unique fields. +# Example: +# existing: { name: "Alice" } +# incoming: { name: "Alice" } +# +# Becomes: +# { name: "Alice_copy" } +# +# If still not unique: +# { name: "Alice_copy2" } +# +# =============================================================== +class ChangeOffendingFieldAction + include DuplicateAction + + def on_duplicate_record(klass:, records:) + existing = normalize(records.first) + incoming = normalize(records.last).dup + + unique_fields = unique_constraint_fields(klass) + + unique_fields.each do |field| + next unless incoming[field] == existing[field] + + incoming[field] = + generate_unique_value( + klass: klass, + field: field, + base: incoming[field] + ) end - private - - # Offending fields for systems using uniqueness attributes. - # For simple hash-based data, this defaults to [:id]. - def offending_fields_for(_klass, record) - record.to_h.keys.select { |k| k.to_s.include?("id") } - end + [incoming] end - ############################################### - # NEW ACTION CLASS: SkipRecord - ############################################### - class SkipRecord - include DuplicateAction + private - def on_duplicate_record(klass:, record:) - Rails.logger.info("Skipping duplicate record: #{record.to_h}") - nil - end + # Accept ActiveRecord or hash + def normalize(record) + return record.symbolize_keys if record.is_a?(Hash) + record.attributes.symbolize_keys end - ############################################### - # NEW ACTION CLASS: ChangeField (“_copy” resolver) - ############################################### - class ChangeField - include DuplicateAction - - MAX_ATTEMPTS = 10 - - def on_duplicate_record(klass:, record:) - fields = offending_fields_for(klass, record) - return nil if fields.empty? - - updated = record.dup - attempts = 0 - - while attempts < MAX_ATTEMPTS - fields.each do |f| - value = updated.send(f) - updated.send("#{f}=", "#{value}_copy") - end - - # For simple usage (hash input), assume "copy" resolves duplicates - return updated unless updated.to_h.values.any?(&:nil?) - - attempts += 1 - end - - Rails.logger.warn("Could not resolve duplicate after #{MAX_ATTEMPTS} attempts") - nil - end + # Use AR validators to detect which fields are unique + def unique_constraint_fields(klass) + klass.validators + .select { |v| v.is_a?(ActiveRecord::Validations::UniquenessValidator) } + .flat_map(&:attributes) + .map(&:to_sym) end - ############################################### - # NEW ACTION CLASS: UpdateExistingRecord - ############################################### - class UpdateExistingRecord - include DuplicateAction + # Increment until unique in DB + def generate_unique_value(klass:, field:, base:) + candidate = base.to_s + counter = 1 - def on_duplicate_record(klass:, record:) - # For hash-based workflows, just return the provided record - # (Existing record is "updated" logically) - record + while klass.exists?(field => candidate) + candidate = "#{base}_copy#{counter == 1 ? '' : counter}" + counter += 1 end - end -end \ No newline at end of file + candidate + end +end diff --git a/app/services/export.rb b/app/services/export.rb index b8ca26206..e07ab2e82 100644 --- a/app/services/export.rb +++ b/app/services/export.rb @@ -1,4 +1,4 @@ -# This file defines a service class for handling data export operations. +# app/services/export.rb class Export def initialize(data) @data = data @@ -6,10 +6,8 @@ def initialize(data) def to_csv CSV.generate do |csv| - csv << @data.first.keys # Add headers - @data.each do |row| - csv << row.values - end + csv << @data.first.keys + @data.each { |row| csv << row.values } end end @@ -20,4 +18,4 @@ def to_json def to_xml @data.to_xml(root: 'records', skip_types: true) end -end \ No newline at end of file +end diff --git a/app/services/field_mapping.rb b/app/services/field_mapping.rb index 0cee2336e..415c02e67 100644 --- a/app/services/field_mapping.rb +++ b/app/services/field_mapping.rb @@ -12,17 +12,13 @@ def initialize(model_class, ordered_fields) # Build mapping from a CSV header row # header_row is an array like ["Email", "Last Name", "First Name"] def self.from_header(model_class, header_row) - header_row = header_row.map(&:strip) + header_row = header_row.map { |h| h.to_s.strip } - valid_fields = model_class.internal_and_external_fields + valid_fields = model_class.internal_and_external_fields.map(&:to_s) - matched = header_row.map do |h| + matched = header_row.filter_map do |h| valid_fields.find { |f| f.casecmp?(h) } - end.compact - - # todo - remove debug statements - # pp "matched" - # puts "Header Row: #{header_row}" + end new(model_class, matched) end @@ -33,8 +29,10 @@ def headers end def duplicate_headers - ordered_fields.group_by{ |header| header }.select{|_, v| v.size > 1}.map(&:first) - # .map { |k, v| [k, v.size()] }.to_h + ordered_fields + .group_by { |h| h } + .select { |_k, v| v.size > 1 } + .keys end # Return values in correct order for a record diff --git a/app/services/import.rb b/app/services/import.rb index 3e2140432..1279a4d80 100644 --- a/app/services/import.rb +++ b/app/services/import.rb @@ -1,36 +1,99 @@ -# This file defines a service class for handling data import operations. +# frozen_string_literal: true -module Services - class Import - def initialize(file_path) - @file_path = file_path - end +require 'csv' +require_relative 'field_mapping' +require_relative 'duplicate_action' - def perform - data = read_file - parsed_data = parse_data(data) - save_data(parsed_data) - end +# Always use ChangeOffendingFieldAction unless replaced later. +DEFAULT_DUPLICATE_ACTION = ChangeOffendingFieldAction.new + +class Import + def initialize(klass:, file:, mapping: nil, dup_action: nil) + @klass = klass + @file = file + @mapping = mapping + @duplicate_action = dup_action || DEFAULT_DUPLICATE_ACTION + end - private + # -------------------------------------------------------------- + # MAIN IMPORT PROCESS + # -------------------------------------------------------------- + def perform + mapping = @mapping || default_mapping(@klass) - def read_file - File.read(@file_path) - rescue Errno::ENOENT - raise "File not found: #{@file_path}" - end + rows = parse_csv(@file, mapping) + duplicate_groups = [] + successful_inserts = 0 + + ActiveRecord::Base.transaction do + rows.each do |attrs| + begin + obj = @klass.new(attrs) + obj.save! + successful_inserts += 1 - def parse_data(data) - # Assuming the data is in CSV format for this example - require 'csv' - CSV.parse(data, headers: true) + rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => e + duplicate_groups << normalize_duplicate(attrs) + end + end + + process_duplicates(@klass, duplicate_groups) end - def save_data(parsed_data) - parsed_data.each do |row| - # Assuming we are importing into a Model called Record - Record.create!(row.to_h) + { + imported: successful_inserts, + duplicates: duplicate_groups.length + } + end + + private + + # -------------------------------------------------------------- + # DUPLICATE PROCESSING + # -------------------------------------------------------------- + def normalize_duplicate(incoming_hash) + pk = @klass.primary_key.to_sym + existing = @klass.find_by(pk => incoming_hash[pk]) + + [ + existing&.attributes&.symbolize_keys || {}, + incoming_hash.symbolize_keys + ] + end + + def process_duplicates(klass, groups) + groups.each do |records| + processed = @duplicate_action.on_duplicate_record( + klass: klass, + records: records + ) + + next if processed.nil? + + processed.each do |attrs| + klass.create!(attrs) end end end -end \ No newline at end of file + + # -------------------------------------------------------------- + # MAPPING + # -------------------------------------------------------------- + def default_mapping(klass) + FieldMapping.new(klass, klass.internal_and_external_fields) + end + + # -------------------------------------------------------------- + # CSV PARSING + # -------------------------------------------------------------- + def parse_csv(file, mapping) + fields = mapping.ordered_fields + rows = [] + + CSV.foreach(file, headers: false) do |row| + rows << Hash[fields.zip(row)] + end + + rows + end +end From 3430dc1d168bd824bd0f4692cb91dccd9847c118 Mon Sep 17 00:00:00 2001 From: TaylorBrown96 Date: Tue, 2 Dec 2025 02:27:38 -0500 Subject: [PATCH 16/80] added comments that give explicitly verbose descriptions --- app/controllers/import_controller.rb | 69 +++- app/helpers/duplicated_action_helper.rb | 116 ++++-- app/helpers/importable_exportable_helper.rb | 415 ++++++++++++-------- app/services/export.rb | 70 +++- app/services/field_mapping.rb | 104 ++++- app/services/import.rb | 95 ++++- 6 files changed, 645 insertions(+), 224 deletions(-) diff --git a/app/controllers/import_controller.rb b/app/controllers/import_controller.rb index e7591a5d4..6c21e94a0 100644 --- a/app/controllers/import_controller.rb +++ b/app/controllers/import_controller.rb @@ -1,7 +1,28 @@ -# This file holds the logic for importing data from external sources into the application. +# 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 + # 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 = params[:class].constantize @@ -9,26 +30,56 @@ def index mandatory_fields: imported_class.mandatory_fields, optional_fields: imported_class.optional_fields, external_fields: imported_class.external_fields, - available_actions_on_dup: [] # Only for import + + # Import does not provide duplicate-resolution strategies (those apply to export) + available_actions_on_dup: [] }, status: :ok 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] - use_headers = ActiveRecord::Type::Boolean.new.deserialize(params[:use_headers]) + 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] - params[:class].constantize.try_import_records(uploaded_file, ordered_fields, use_header: use_headers) + # Dynamically load the model class (e.g., "User", "Team", etc.) + klass = params[:class].constantize - render json: { message: "#{params[:class].name} has been imported!" }, status: :created + # Call the model-level importer (defined in each model using the import mixin) + klass.try_import_records( + uploaded_file, + ordered_fields, + use_header: use_headers + ) + + # If no exceptions occur, return success + render json: { message: "#{klass.name} has been imported!" }, status: :created rescue StandardError => e - puts "An unexpected error occurred: #{e.message}" - render json: { error: e.message }, status: :unprocessable_entity + # 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) end -end \ No newline at end of file +end diff --git a/app/helpers/duplicated_action_helper.rb b/app/helpers/duplicated_action_helper.rb index e0123162e..2b622f14e 100644 --- a/app/helpers/duplicated_action_helper.rb +++ b/app/helpers/duplicated_action_helper.rb @@ -3,18 +3,24 @@ # =============================================================== # DuplicateAction (ABSTRACT MIXIN) # -# All duplicate-resolution strategies used during import must include -# this module and implement: +# All duplicate-resolution strategies used by Import must include +# this module. It defines a single required method: # # on_duplicate_record(klass:, records:) # -# Parameters: -# klass => ActiveRecord model affected -# records => Array of hashes or model objects representing conflicts +# Arguments: +# klass: The ActiveRecord model being imported (e.g., Team, User) +# records: An Array containing two elements: +# [ existing_record_hash, incoming_record_hash ] +# Both may be: +# - Hashes (symbolized) +# - ActiveRecord instances # -# Return: -# - nil → skip inserting record -# - Array → rows to (re)insert +# Return value expectations: +# nil → indicates the duplicate should not be inserted +# Array → 1 or more hashes representing records to create +# +# The Import class will call klass.create!(hash) for each returned hash. # # =============================================================== module DuplicateAction @@ -24,10 +30,18 @@ def on_duplicate_record(klass:, records:) end end + # =============================================================== # SkipRecordAction # -# Simply skips the offending row. Nothing is inserted. +# 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 include DuplicateAction @@ -37,12 +51,27 @@ def on_duplicate_record(klass:, records:) end end + # =============================================================== # UpdateExistingRecordAction # -# Takes all conflicting rows and merges them. Later values override earlier ones. -# Result: -# One fully merged record replacing the original. +# 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 include DuplicateAction @@ -50,65 +79,80 @@ class UpdateExistingRecordAction def on_duplicate_record(klass:, records:) merged = {} - # Accept both Hashes and ActiveRecord objects records.each do |rec| + # Accept Hashes OR ActiveRecord instances row = rec.is_a?(Hash) ? rec : rec.attributes.symbolize_keys + + # Merge: + # Later values override earlier ones unless nil row.each do |key, value| merged[key] = value unless value.nil? end end - [merged] # Return exactly one merged record + # Return one record to create + [merged] end end + # =============================================================== # ChangeOffendingFieldAction # -# Autoresolves uniqueness violations by modifying unique fields. +# 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" } +# existing.name = "Alice" +# incoming.name = "Alice" # -# Becomes: -# { name: "Alice_copy" } +# → incoming.name becomes "Alice_copy" # # If still not unique: -# { name: "Alice_copy2" } +# "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 include DuplicateAction def on_duplicate_record(klass:, records:) + # Normalize both existing and incoming row formats existing = normalize(records.first) incoming = normalize(records.last).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] - ) + incoming[field] = generate_unique_value( + klass: klass, + field: field, + base: incoming[field] + ) end - [incoming] + [incoming] # Returning one resolved record end private - # Accept ActiveRecord or hash + # Standardize input into a symbolized hash def normalize(record) return record.symbolize_keys if record.is_a?(Hash) record.attributes.symbolize_keys end - # Use AR validators to detect which fields are unique + # Extract all attributes validated as unique via ActiveRecord def unique_constraint_fields(klass) klass.validators .select { |v| v.is_a?(ActiveRecord::Validations::UniquenessValidator) } @@ -116,13 +160,23 @@ def unique_constraint_fields(klass) .map(&:to_sym) end - # Increment until unique in DB + # 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}" + candidate = + "#{base}_copy#{counter == 1 ? '' : counter}" + counter += 1 end diff --git a/app/helpers/importable_exportable_helper.rb b/app/helpers/importable_exportable_helper.rb index 19f9a0fa8..e205fca52 100644 --- a/app/helpers/importable_exportable_helper.rb +++ b/app/helpers/importable_exportable_helper.rb @@ -1,90 +1,144 @@ # importable_exportable_helper.rb - -# Class for combining external class information. Keeps track of whether this class should have -# its information looked up or saved. Assumes that information should be created when initialized +# +# =============================================================== +# 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 lookups +# +# 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_lookup: true, should_create: false, lookup_field: :email) +# =============================================================== class ExternalClass attr_accessor :ref_class, :should_lookup, :should_create def initialize(ref_class, should_lookup = false, should_create = true, lookup_field = nil) - @ref_class = ref_class - @should_lookup = should_lookup - @should_create = should_create - @lookup_field = lookup_field + @ref_class = ref_class # The class being referenced (e.g., User) + @should_lookup = should_lookup # Whether existing objects should be searched for + @should_create = should_create # Whether new objects should be created if no match found + @lookup_field = lookup_field # Column used to identify existing objects end - # If the ref class has the ImportableExportable Mixin, refer to that version of the import export fields func. - # If it doessn't, return the lookup field (or the primary key) + # -------------------------------------------------------------- + # 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 lookup field, or + # - the primary key + # + # All returned fields are namespaced (role_name, user_email, etc.) + # -------------------------------------------------------------- def internal_fields if @ref_class.respond_to?(:internal_fields) - @ref_class.internal_fields.map {|field| append_class_name(field)} + @ref_class.internal_fields.map { |field| append_class_name(field) } else [append_class_name(@lookup_field.to_s), @ref_class.primary_key] end - end - - # Attempts too look in the database for any mention of the current class. It looks using the - # given lookup field and the primary key. It checks both with and without the classname appended - # to the front + # -------------------------------------------------------------- + # Lookup an external object in the database. + # + # Uses either: + # • a lookup 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 lookup(class_values) - # See if lookup field or primary key is in values hash. If not, return nothing - # puts "starting lookup" - class_name_lookup_field = append_class_name(@lookup_field.to_s) - class_name_primary_key = append_class_name(@ref_class.primary_key) + class_name_lookup_field = append_class_name(@lookup_field.to_s) + class_name_primary_field = append_class_name(@ref_class.primary_key) + + value = nil + + # ---------- Try lookup field ---------- if @lookup_field && class_values[class_name_lookup_field] - # puts @lookup_field - # Ex. field: name, value: class_values[role_name] if @ref_class.attribute_method?(@lookup_field) value = @ref_class.find_by(@lookup_field => class_values[class_name_lookup_field]) - # puts "lookup field: #{value}" - # Ex. field: role_name, value: class_values[role_name] elsif @ref_class.attribute_method?(class_name_lookup_field) - value ||= @ref_class.find_by(class_name_lookup_field => class_values[class_name_lookup_field]) - # puts "append class name? #{value}" + value = @ref_class.find_by(class_name_lookup_field => class_values[class_name_lookup_field]) end - elsif class_values[class_name_primary_key] - # Ex. field: id, value: class_values[role_id] + # ---------- 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_key]) - # puts "primary key: #{value}" - # Ex. field: role_id value: class_values[role_id] - elsif @ref_class.attribute_method?(class_name_primary_key) - value ||= @ref_class.find_by(class_name_primary_key => class_values[class_name_primary_key]) - # puts "pk with class name: #{value}" + 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 = {} - attrs.each {|k, v| fixed_attrs[unappended_class_name(k)] = v} - - @ref_class.new(fixed_attrs) + fixed = {} + attrs.each { |k, v| fixed[unappended_class_name(k)] = v } + @ref_class.new(fixed) end private - # Method to add the class name to a field. This is useful when the CSV might refer to a column with - # the class name appended (Ex role_name) but the internal field drops the class name (Ex Role.name) + + # Prefix column with the class name ("role_name", "user_email") def append_class_name(field) - @ref_class.name.underscore + "_" + field + "#{@ref_class.name.underscore}_#{field}" end + # Remove class name prefix def unappended_class_name(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 attr_accessor :available_actions_on_duplicate, :mandatory_fields, :external_classes - # def self.included(base) - # base.extend(ClassMethods) - # end - + # -------------------------------------------------------------- + # 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) @@ -94,11 +148,12 @@ def self.extended(base) else base.instance_variable_set(:@class_name, base.name) end - end - # module ClassMethods - + # -------------------------------------------------------------- + # 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) @@ -107,10 +162,19 @@ def mandatory_fields(*fields) end end + # -------------------------------------------------------------- + # Optional = internal fields - mandatory + # -------------------------------------------------------------- def optional_fields internal_fields - mandatory_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 @@ -119,200 +183,211 @@ def external_classes(*fields) end end - - # use the column names and mandaroty fields to know which fields constitute - # internal fields. This is becuase of cases such as the password of a user being - # the password_digest column in the database, but we need to assign it to the - # password field of the object. + # -------------------------------------------------------------- + # 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 end + # -------------------------------------------------------------- + # EXTERNAL FIELDS + # + # Flatten all internal fields from all external class definitions. + # -------------------------------------------------------------- def external_fields fields = [] - external_classes.each{ |external_class| fields += external_class.internal_fields } if external_classes - + external_classes&.each do |ext| + fields += ext.internal_fields + end fields end + # Combined fields for full CSV mapping def internal_and_external_fields internal_fields + external_fields end - # Factory method for importing a record from a hash + # -------------------------------------------------------------- + # 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) - fixed_attrs = {} - attrs.each {|k, v| fixed_attrs[k] = v[0]} - - new(fixed_attrs) + cleaned = {} + attrs.each { |k, v| cleaned[k] = v[0] } + new(cleaned) end - # todo - possibly extract this function to the service + # -------------------------------------------------------------- + # 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: false) temp_file = 'output.csv' csv_file = CSV.read(file, headers: false) - # In a temp file, so that headers can be added to the top if the use_header options isn't selected + # ---- Normalize header row ---- CSV.open(temp_file, "w") do |csv| - if use_header - headers = csv_file[0].map{ |header| header.parameterize.underscore } - csv_file.shift + headers = csv_file.shift.map { |h| h.parameterize.underscore } else - headers = headers.map{ |header| header.parameterize.underscore } + headers = headers.map { |h| h.parameterize.underscore } end csv << headers - - # then copy the rest of the csv file - csv_file.each do |row| - csv << row - end + csv_file.each { |row| csv << row } end temp_contents = CSV.read(temp_file) - temp_contents.shift + temp_contents.shift # drop header - dup_records = [] + duplicate_records = [] ActiveRecord::Base.transaction do temp_contents.each do |row| - # Get the row as a hash, with the header pointing towards the attribute value - dup_obj = import_row(row, temp_file) - dup_records << dup_obj if dup_obj && dup_obj != true + dup = import_row(row, temp_file) + duplicate_records << dup if dup && dup != true end - pp dup_records - - # todo - Handle duplicate records that are thrown out when importing the rows - - # Comment this out if you want to run the tests - # raise ActiveRecord::Rollback # todo - remove this when wanting to actually channge the data + # Keep duplicates for UI, roll back DB changes + # raise ActiveRecord::Rollback end - File.delete(temp_file) - dup_records - + duplicate_records end - # Import row function takes a hash for a row and tries to save it in the current class. - # It takes a related class and object so that it can be used recursively. If a row should - # update two classes,and one relies upon another, the recursion can be used to set the - # belongs to relationship. - # (EX if ) + # -------------------------------------------------------------- + # Import a single row into the current model. + # + # Handles: + # • mapping values + # • building internal object + # • external object lookup/creation + # • save + duplicate capture + # + # Returns: + # • true if saved successfully + # • duplicate object if duplicate occurred + # -------------------------------------------------------------- def import_row(row, file) - # Open the csv file, get the header row, and build the mapping with only the fields available in the current class header_row = CSV.open(file, &:first) - mapping = FieldMapping.from_header(self, header_row) # Get mapping of only internal fields - # pp row + mapping = FieldMapping.from_header(self, header_row) + + # 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] ||= [] # Initialize an empty array if the key is new + row_hash[key] ||= [] row_hash[key] << value end puts "Row Hash: #{row_hash}" + # Create object for this class current_class_attrs = row_hash.slice(*internal_fields) - created_object = from_hash(current_class_attrs) + created_obj = from_hash(current_class_attrs) - # for each external class, try to look them up - if external_classes - external_classes.each do |external_class| - lookup_external_class(row_hash, external_class, self, created_object) - end + # ----- Lookup referenced external objects ----- + external_classes&.each do |ext| + lookup_external_class(row_hash, ext, self, created_obj) end - dup_obj = save_object(created_object) - - return dup_obj if dup_obj && dup_obj != true - - # Then create external classes that rely on the object we just created + duplicate = save_object(created_obj) + return duplicate if duplicate && duplicate != true - if external_classes - external_classes.each do |external_class| - create_external_class(row_hash, mapping, external_class, self, created_object) - end + # ----- Create dependent external objects ----- + external_classes&.each do |ext| + create_external_class(row_hash, mapping, ext, self, created_obj) end end + # -------------------------------------------------------------- + # Attempt to find an external object via lookup rules. + # If found, attach it to the parent object. + # -------------------------------------------------------------- def lookup_external_class(row_hash, external_class, parent_class, parent_obj) - # Lookup - If the external class is marked as a lookup and a value is found - if external_class.should_lookup && (lookup_value = external_class.lookup(row_hash)) - # Connect lookup value to the parent obj - parent_obj.send("#{external_class.ref_class.name.downcase}=", lookup_value) - return + if external_class.should_lookup && (found = external_class.lookup(row_hash)) + parent_obj.send("#{external_class.ref_class.name.downcase}=", found) end end + # -------------------------------------------------------------- + # When lookups 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, mapping, external_class, parent_class, parent_obj) - # Create - If the external class is marked as a create, attempt to create a new obj and link to parents - # This can happen if it is marked and a lookup val wasn't found - if external_class.should_create - - # Get the attributes, with duplicates in an array - current_class_attrs = row_hash.slice(*external_class.internal_fields) - - # In the order of the attributes, pair them together in new hashes. These new hashes - # are ready to be made into the new object - # Ex. - # Initial: {"question_advice_score" => ["1", "2"], - # "question_advice_advice" => ["okay", "good"]} - # Result: [{"question_advice_score" => "1", "question_advice_advice" => "okay"}, - # {"question_advice_score" => "2", "question_advice_advice" => "good"}] - # - 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 - - - # Use each set to create the new objects - object_sets_with_keys.each do |attrs| - created_object = external_class.from_hash(attrs) - - # link the newly created object and the parent both ways - created_object.send("#{@class_name.underscore}=", parent_obj) - # parent_obj.send("#{external_class.ref_class.name.underscore}=", created_object) - - save_object(created_object) - end - end + return unless external_class.should_create + + current_attrs = row_hash.slice(*external_class.internal_fields) + + # Transpose arrays to pair values correctly + object_sets = current_attrs.values.transpose + object_sets_with_keys = + object_sets.map { |vals| Hash[current_attrs.keys.zip(vals)] } + + object_sets_with_keys.each do |attrs| + new_obj = external_class.from_hash(attrs) + + # Set relationship to parent + new_obj.send("#{@class_name.underscore}=", parent_obj) + + save_object(new_obj) 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) - begin - puts "Create Obj:" - pp created_object - created_object.save! - rescue ActiveRecord::RecordInvalid => e - # Handle validation errors - puts "Validation error: #{e.message}" - - # Check if a specific attribute has a :uniqueness error - if created_object.errors.details[:attribute_name].any? { |detail| detail[:error] == :uniqueness } - puts "Uniqueness violation on attribute_name!" - return created_object - else - raise StandardError - end + puts "Create Obj:" + pp created_object + created_object.save! + rescue ActiveRecord::RecordInvalid => e + puts "Validation error: #{e.message}" - rescue ActiveRecord::RecordNotUnique => e - # Handle unique constraint violations - puts "Unique constraint violation: #{e.message}" + if created_object.errors.details[:attribute_name].any? { |d| d[:error] == :uniqueness } + puts "Uniqueness violation on attribute_name!" return created_object - rescue StandardError - raise StandardError + else + raise end + + rescue ActiveRecord::RecordNotUnique => e + puts "Unique constraint violation: #{e.message}" + return created_object end - # end - # Instance method to serialize a record for export + # -------------------------------------------------------------- + # 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 - - -end \ No newline at end of file +end diff --git a/app/services/export.rb b/app/services/export.rb index e07ab2e82..b5ebd51c4 100644 --- a/app/services/export.rb +++ b/app/services/export.rb @@ -1,21 +1,87 @@ # app/services/export.rb + +## +# Export +# +# This service provides simple, consistent export functionality for any +# array of hashes. Each hash represents one “row” of data, and the keys +# represent column names. The Export class can convert these rows +# into CSV, JSON, or XML. +# +# Example input format: +# [ +# { id: 1, name: "Team 1", members: "Alice,Bob" }, +# { id: 2, name: "Team 2", members: "Carol,Dan" } +# ] +# +# The class intentionally does NOT perform queries itself — it expects +# the controller or the caller to assemble the dataset. +# class Export + ## + # @param data [Array] + # A list of row hashes. Keys must be consistent across all rows. + # def initialize(data) @data = data end + ## + # Convert the dataset into CSV format. + # + # This generates: + # • A header row using the keys of the first hash + # • One CSV row for each hash using its values + # + # Example output: + # id,name,members + # 1,Team 1,Alice; Bob + # 2,Team 2,Carol; Dan + # def to_csv CSV.generate do |csv| + # Extract column headers from the first row's keys csv << @data.first.keys - @data.each { |row| csv << row.values } + + # Insert each row in order, using the values of the hash + @data.each do |row| + csv << row.values + end end end + ## + # Convert the data into JSON format. + # + # Produces: + # [ + # { "id": 1, "name": "Team 1", "members": "Alice; Bob" }, + # { "id": 2, "name": "Team 2", "members": "Carol; Dan" } + # ] + # def to_json @data.to_json end + ## + # Convert the data into XML format. + # + # Produces: + # + # + # 1 + # Team 1 + # Alice; Bob + # + # ... + # + # + # Using skip_types avoids type metadata inside XML nodes. + # def to_xml - @data.to_xml(root: 'records', skip_types: true) + @data.to_xml( + root: 'records', # wrap all rows in + skip_types: true # cleaner XML, no type="integer" noise + ) end end diff --git a/app/services/field_mapping.rb b/app/services/field_mapping.rb index 415c02e67..ba75f4e02 100644 --- a/app/services/field_mapping.rb +++ b/app/services/field_mapping.rb @@ -1,21 +1,75 @@ # 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 - # model_class: an ActiveRecord class (User, Assignment, Team, etc.) - # ordered_fields: array of symbols/strings like [:email, :last_name] + # -------------------------------------------------------------- + # 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 + # lookups (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 mapping from a CSV header row - # header_row is an array like ["Email", "Last Name", "First Name"] + # -------------------------------------------------------------- + # 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 @@ -23,11 +77,25 @@ def self.from_header(model_class, header_row) new(model_class, matched) end - # Return CSV header row + # -------------------------------------------------------------- + # 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 } @@ -35,12 +103,34 @@ def duplicate_headers .keys end - # Return values in correct order for a record + # -------------------------------------------------------------- + # 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 - # JSON-friendly + # -------------------------------------------------------------- + # 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, diff --git a/app/services/import.rb b/app/services/import.rb index 1279a4d80..811ec8b64 100644 --- a/app/services/import.rb +++ b/app/services/import.rb @@ -4,10 +4,35 @@ require_relative 'field_mapping' require_relative 'duplicate_action' -# Always use ChangeOffendingFieldAction unless replaced later. +# 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:, mapping: nil, dup_action: nil) @klass = klass @file = file @@ -18,28 +43,47 @@ def initialize(klass:, file:, mapping: nil, dup_action: nil) # -------------------------------------------------------------- # 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 provided mapping or fall back to default derived from model mapping = @mapping || default_mapping(@klass) + # Convert CSV rows into attribute hashes using the field mapping rows = parse_csv(@file, mapping) - duplicate_groups = [] - successful_inserts = 0 + duplicate_groups = [] # Will hold duplicate row sets + successful_inserts = 0 # Counter for successful saves + + # Wrap everything in a transaction to ensure consistency ActiveRecord::Base.transaction do rows.each do |attrs| begin + # Attempt to create the record obj = @klass.new(attrs) obj.save! successful_inserts += 1 rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => e + # Any uniqueness or validation failure is treated as a duplicate duplicate_groups << normalize_duplicate(attrs) end end + # Let the duplicate action process all collected conflicts process_duplicates(@klass, duplicate_groups) end + # Return summary of import results { imported: successful_inserts, duplicates: duplicate_groups.length @@ -51,16 +95,40 @@ def perform # -------------------------------------------------------------- # 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_hash) pk = @klass.primary_key.to_sym + + # Try to find the existing record using the primary key value existing = @klass.find_by(pk => incoming_hash[pk]) [ - existing&.attributes&.symbolize_keys || {}, - incoming_hash.symbolize_keys + existing&.attributes&.symbolize_keys || {}, # Existing row (maybe empty) + incoming_hash.symbolize_keys # Incoming row ] 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( @@ -68,8 +136,10 @@ def process_duplicates(klass, groups) records: records ) + # If the duplicate action returns nil, it means “skip insertion” next if processed.nil? + # Otherwise, treat each returned hash as a new valid record processed.each do |attrs| klass.create!(attrs) end @@ -79,6 +149,12 @@ def process_duplicates(klass, groups) # -------------------------------------------------------------- # MAPPING # -------------------------------------------------------------- + + ## + # Generates a default field mapping using all internal and external fields + # exposed by the model. This ensures every column that CAN be imported + # will be imported. + # def default_mapping(klass) FieldMapping.new(klass, klass.internal_and_external_fields) end @@ -86,10 +162,19 @@ def default_mapping(klass) # -------------------------------------------------------------- # CSV PARSING # -------------------------------------------------------------- + + ## + # Reads the CSV and builds an array of attribute hashes: + # + # [ {field1: val1, field2: val2, ...}, ... ] + # + # The mapping determines which fields correspond to which columns. + # def parse_csv(file, mapping) fields = mapping.ordered_fields rows = [] + # No headers — CSV columns must follow mapping order precisely. CSV.foreach(file, headers: false) do |row| rows << Hash[fields.zip(row)] end From 5c5dd87cbc559dd536b6b6438485f322540f0f18 Mon Sep 17 00:00:00 2001 From: Camille Jones Date: Tue, 2 Dec 2025 14:42:10 -0500 Subject: [PATCH 17/80] Merged changes i had made to importable_exportable_helper.rb. Updated the export controller to be able to send stuff to the frontend for export and index --- app/controllers/export_controller.rb | 26 +++++- app/helpers/importable_exportable_helper.rb | 88 +++++++++++---------- 2 files changed, 70 insertions(+), 44 deletions(-) diff --git a/app/controllers/export_controller.rb b/app/controllers/export_controller.rb index d7ac74d13..f28de09ab 100644 --- a/app/controllers/export_controller.rb +++ b/app/controllers/export_controller.rb @@ -1,9 +1,33 @@ # This file holds the logic for exporting data from the application to various formats. class ExportController < ApplicationController - def export_data + before_action :export_params + def index + imported_class = params[:class].constantize + + render json: { + mandatory_fields: imported_class.mandatory_fields, + optional_fields: imported_class.optional_fields, + external_fields: imported_class.external_fields + }, status: :ok + end + + def export + ordered_fields = JSON.parse(params[:ordered_fields]) if params[:ordered_fields] + + params[:class].constantize.try_import_records(uploaded_file, ordered_fields, use_header: use_headers) + # Logic for exporting data data = DataExporter.new.export(format: params[:format]) send_data data, filename: "exported_data.#{params[:format]}" + + render json: { message: "#{params[:class].name} has been imported!" }, status: :ok + + end + + private + def export_params + puts params + params.permit(:class, :ordered_fields) end end \ No newline at end of file diff --git a/app/helpers/importable_exportable_helper.rb b/app/helpers/importable_exportable_helper.rb index e205fca52..ec776f2ae 100644 --- a/app/helpers/importable_exportable_helper.rb +++ b/app/helpers/importable_exportable_helper.rb @@ -41,11 +41,11 @@ def initialize(ref_class, should_lookup = false, should_create = true, lookup_fi # # All returned fields are namespaced (role_name, user_email, etc.) # -------------------------------------------------------------- - def internal_fields + def fields if @ref_class.respond_to?(:internal_fields) @ref_class.internal_fields.map { |field| append_class_name(field) } else - [append_class_name(@lookup_field.to_s), @ref_class.primary_key] + [append_class_name(@lookup_field.to_s), append_class_name(@ref_class.primary_key)] end end @@ -104,7 +104,7 @@ def append_class_name(field) # Remove class name prefix def unappended_class_name(name) - name.delete_prefix(@ref_class.name.underscore + "_") + name.delete_prefix("#{@ref_class.name.underscore}_") end end @@ -132,7 +132,7 @@ def unappended_class_name(name) # # =============================================================== module ImportableExportableHelper - attr_accessor :available_actions_on_duplicate, :mandatory_fields, :external_classes + attr_accessor :available_actions_on_duplicate # -------------------------------------------------------------- # When extended by a class, inherit parent import settings. @@ -140,7 +140,7 @@ module ImportableExportableHelper # This allows STI or subclassed models to reuse configuration. # -------------------------------------------------------------- def self.extended(base) - if base.superclass&.respond_to?(:mandatory_fields) + 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) @@ -203,9 +203,8 @@ def internal_fields # -------------------------------------------------------------- def external_fields fields = [] - external_classes&.each do |ext| - fields += ext.internal_fields - end + external_classes&.each { |external_class| fields += external_class.fields } + fields end @@ -226,6 +225,15 @@ def from_hash(attrs) 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 # @@ -236,14 +244,14 @@ def from_hash(attrs) # -------------------------------------------------------------- def try_import_records(file, headers, use_header: false) temp_file = 'output.csv' - csv_file = CSV.read(file, headers: false) + csv_file = CSV.read(file) # ---- 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 { |h| h.parameterize.underscore } + headers = headers.map { |header| header.parameterize.underscore } end csv << headers @@ -299,27 +307,32 @@ def import_row(row, file) current_class_attrs = row_hash.slice(*internal_fields) created_obj = from_hash(current_class_attrs) - # ----- Lookup referenced external objects ----- - external_classes&.each do |ext| - lookup_external_class(row_hash, ext, self, created_obj) + # for each external class, try to look them up + external_classes&.each do |external_class| + lookup_external_class(row_hash, external_class, created_object) end duplicate = save_object(created_obj) return duplicate if duplicate && duplicate != true - # ----- Create dependent external objects ----- - external_classes&.each do |ext| - create_external_class(row_hash, mapping, ext, self, created_obj) + return unless external_classes + + external_classes.each do |external_class| + create_external_class(row_hash, external_class, created_object) end + end + private + # -------------------------------------------------------------- # Attempt to find an external object via lookup rules. # If found, attach it to the parent object. # -------------------------------------------------------------- - def lookup_external_class(row_hash, external_class, parent_class, parent_obj) + def lookup_external_class(row_hash, external_class, parent_obj) if external_class.should_lookup && (found = external_class.lookup(row_hash)) parent_obj.send("#{external_class.ref_class.name.downcase}=", found) + nil end end @@ -334,23 +347,23 @@ def lookup_external_class(row_hash, external_class, parent_class, parent_obj) # Which turns into: # [{field1: "A", field2: "X"}, {field1: "B", field2: "Y"}] # -------------------------------------------------------------- - def create_external_class(row_hash, mapping, external_class, parent_class, parent_obj) + def create_external_class(row_hash, external_class, parent_obj) return unless external_class.should_create - current_attrs = row_hash.slice(*external_class.internal_fields) + current_class_attrs = row_hash.slice(*external_class.internal_fields) - # Transpose arrays to pair values correctly - object_sets = current_attrs.values.transpose - object_sets_with_keys = - object_sets.map { |vals| Hash[current_attrs.keys.zip(vals)] } + 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| - new_obj = external_class.from_hash(attrs) + created_object = external_class.from_hash(attrs) # Set relationship to parent - new_obj.send("#{@class_name.underscore}=", parent_obj) + created_object.send("#{@class_name.underscore}=", parent_obj) - save_object(new_obj) + save_object(created_object) end end @@ -364,30 +377,19 @@ def create_external_class(row_hash, mapping, external_class, parent_class, paren # • true if saved # -------------------------------------------------------------- def save_object(created_object) - puts "Create Obj:" - pp created_object - created_object.save! rescue ActiveRecord::RecordInvalid => e + # Check if a specific attribute has a :uniqueness error puts "Validation error: #{e.message}" - if created_object.errors.details[:attribute_name].any? { |d| d[:error] == :uniqueness } - puts "Uniqueness violation on attribute_name!" - return created_object - else - raise + unless created_object.errors.details[:attribute_name].any? { |detail| detail[:error] == :uniqueness } + raise StandardError.new(e.message) end + puts 'Uniqueness violation on attribute_name!' + created_object rescue ActiveRecord::RecordNotUnique => e puts "Unique constraint violation: #{e.message}" - return created_object - 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)] } + created_object end end From 58c46d0a804b44e8d8d8ed5f5bc2dc618702d90f Mon Sep 17 00:00:00 2001 From: crjone24 Date: Tue, 2 Dec 2025 20:52:40 -0500 Subject: [PATCH 18/80] Made small fix for user table to populate --- app/models/role.rb | 8 ++++---- app/models/user.rb | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/app/models/role.rb b/app/models/role.rb index 3cce77975..2ae587aa6 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/user.rb b/app/models/user.rb index f2b1857bd..b08923436 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -41,14 +41,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 From 77e40a5a75036801e05235156b9429147f4b286d Mon Sep 17 00:00:00 2001 From: crjone24 Date: Tue, 2 Dec 2025 21:18:55 -0500 Subject: [PATCH 19/80] Fixed var in the backend --- app/helpers/importable_exportable_helper.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/helpers/importable_exportable_helper.rb b/app/helpers/importable_exportable_helper.rb index ec776f2ae..5535f4b2f 100644 --- a/app/helpers/importable_exportable_helper.rb +++ b/app/helpers/importable_exportable_helper.rb @@ -305,14 +305,14 @@ def import_row(row, file) # Create object for this class current_class_attrs = row_hash.slice(*internal_fields) - created_obj = from_hash(current_class_attrs) + created_object = from_hash(current_class_attrs) # for each external class, try to look them up external_classes&.each do |external_class| lookup_external_class(row_hash, external_class, created_object) end - duplicate = save_object(created_obj) + duplicate = save_object(created_object) return duplicate if duplicate && duplicate != true return unless external_classes From c47323c39595298e58566d773f7fd9089c7a03ee Mon Sep 17 00:00:00 2001 From: crjone24 Date: Tue, 2 Dec 2025 23:30:39 -0500 Subject: [PATCH 20/80] Fixed export in the backend. --- app/controllers/export_controller.rb | 7 +-- app/helpers/importable_exportable_helper.rb | 20 +++---- app/services/export.rb | 64 ++++++--------------- 3 files changed, 30 insertions(+), 61 deletions(-) diff --git a/app/controllers/export_controller.rb b/app/controllers/export_controller.rb index f28de09ab..e99858b24 100644 --- a/app/controllers/export_controller.rb +++ b/app/controllers/export_controller.rb @@ -15,13 +15,10 @@ def index def export ordered_fields = JSON.parse(params[:ordered_fields]) if params[:ordered_fields] - params[:class].constantize.try_import_records(uploaded_file, ordered_fields, use_header: use_headers) + csv_file = Export.perform(params[:class].constantize, ordered_fields) - # Logic for exporting data - data = DataExporter.new.export(format: params[:format]) - send_data data, filename: "exported_data.#{params[:format]}" - render json: { message: "#{params[:class].name} has been imported!" }, status: :ok + render json: { message: "#{params[:class]} has been imported!", file: csv_file }, status: :ok end diff --git a/app/helpers/importable_exportable_helper.rb b/app/helpers/importable_exportable_helper.rb index 5535f4b2f..fb84fd3d9 100644 --- a/app/helpers/importable_exportable_helper.rb +++ b/app/helpers/importable_exportable_helper.rb @@ -43,9 +43,9 @@ def initialize(ref_class, should_lookup = false, should_create = true, lookup_fi # -------------------------------------------------------------- def fields if @ref_class.respond_to?(:internal_fields) - @ref_class.internal_fields.map { |field| append_class_name(field) } + @ref_class.internal_fields.map { |field| self.class.append_class_name(@ref_class, field) } else - [append_class_name(@lookup_field.to_s), append_class_name(@ref_class.primary_key)] + [self.class.append_class_name(@ref_class, @lookup_field.to_s), self.class.append_class_name(@ref_class, @ref_class.primary_key)] end end @@ -60,8 +60,8 @@ def fields # and the raw version (name) depending on what exists in the model. # -------------------------------------------------------------- def lookup(class_values) - class_name_lookup_field = append_class_name(@lookup_field.to_s) - class_name_primary_field = append_class_name(@ref_class.primary_key) + class_name_lookup_field = self.class.append_class_name(@ref_class, @lookup_field.to_s) + class_name_primary_field = self.class.append_class_name(@ref_class, @ref_class.primary_key) value = nil @@ -91,20 +91,18 @@ def lookup(class_values) # -------------------------------------------------------------- def from_hash(attrs) fixed = {} - attrs.each { |k, v| fixed[unappended_class_name(k)] = v } + attrs.each { |k, v| fixed[self.class.unappended_class_name(@ref_class, k)] = v } @ref_class.new(fixed) end - private - # Prefix column with the class name ("role_name", "user_email") - def append_class_name(field) - "#{@ref_class.name.underscore}_#{field}" + def self.append_class_name(ref_class, field) + "#{ref_class.name.underscore}_#{field}" end # Remove class name prefix - def unappended_class_name(name) - name.delete_prefix("#{@ref_class.name.underscore}_") + def self.unappended_class_name(ref_class, name) + name.delete_prefix("#{ref_class.name.underscore}_") end end diff --git a/app/services/export.rb b/app/services/export.rb index b5ebd51c4..f371bf9b0 100644 --- a/app/services/export.rb +++ b/app/services/export.rb @@ -18,13 +18,6 @@ # the controller or the caller to assemble the dataset. # class Export - ## - # @param data [Array] - # A list of row hashes. Keys must be consistent across all rows. - # - def initialize(data) - @data = data - end ## # Convert the dataset into CSV format. @@ -38,50 +31,31 @@ def initialize(data) # 1,Team 1,Alice; Bob # 2,Team 2,Carol; Dan # - def to_csv + def self.perform(export_class, ordered_headers) + mapping = FieldMapping.from_header(export_class, ordered_headers) + CSV.generate do |csv| + class_fields = mapping.ordered_fields.select{ |ele| export_class.internal_fields.include?(ele) } + + # Extract column headers from the first row's keys - csv << @data.first.keys + csv << ordered_headers # Insert each row in order, using the values of the hash - @data.each do |row| - csv << row.values + export_class.all.each do |record| + row = class_fields.map{|f| record.send(f)} + + export_class.external_classes.each do |external_class| + ext_class_fields = mapping.ordered_fields.select{ |ele| external_class.fields.include?(ele) } + lookup_record = record.send(external_class.ref_class.name.underscore) + row += ext_class_fields.map do |f| + lookup_record.send(ExternalClass.unappended_class_name(external_class.ref_class, f)) if f + end + end + + csv << row end end end - ## - # Convert the data into JSON format. - # - # Produces: - # [ - # { "id": 1, "name": "Team 1", "members": "Alice; Bob" }, - # { "id": 2, "name": "Team 2", "members": "Carol; Dan" } - # ] - # - def to_json - @data.to_json - end - - ## - # Convert the data into XML format. - # - # Produces: - # - # - # 1 - # Team 1 - # Alice; Bob - # - # ... - # - # - # Using skip_types avoids type metadata inside XML nodes. - # - def to_xml - @data.to_xml( - root: 'records', # wrap all rows in - skip_types: true # cleaner XML, no type="integer" noise - ) - end end From a4c8de05e24f741531c58f35076bdd39d19a626f Mon Sep 17 00:00:00 2001 From: crjone24 Date: Wed, 3 Dec 2025 04:16:47 -0500 Subject: [PATCH 21/80] Got unique actions working for imports --- app/controllers/import_controller.rb | 19 +-- app/helpers/duplicated_action_helper.rb | 57 ++++---- app/helpers/importable_exportable_helper.rb | 45 +++++-- app/models/user.rb | 1 + app/services/change_offending_field_action.rb | 62 +++++++++ app/services/import.rb | 127 +++++++++++------- app/services/skip_record_action.rb | 6 + app/services/update_existing_record_action.rb | 16 +++ .../single_user_with_headers_changed.csv | 2 + 9 files changed, 236 insertions(+), 99 deletions(-) create mode 100644 app/services/change_offending_field_action.rb create mode 100644 app/services/skip_record_action.rb create mode 100644 app/services/update_existing_record_action.rb create mode 100644 spec/fixtures/files/single_user_with_headers_changed.csv diff --git a/app/controllers/import_controller.rb b/app/controllers/import_controller.rb index 6c21e94a0..fb7ad4357 100644 --- a/app/controllers/import_controller.rb +++ b/app/controllers/import_controller.rb @@ -32,7 +32,7 @@ def index external_fields: imported_class.external_fields, # Import does not provide duplicate-resolution strategies (those apply to export) - available_actions_on_dup: [] + available_actions_on_dup: imported_class.available_actions_on_duplicate.map{|klass| klass.class.name}, }, status: :ok end @@ -57,15 +57,16 @@ def import # Dynamically load the model class (e.g., "User", "Team", etc.) klass = params[:class].constantize - # Call the model-level importer (defined in each model using the import mixin) - klass.try_import_records( - uploaded_file, - ordered_fields, - use_header: use_headers - ) + # Load the chosen duplicate action (Skip, Update, Change) + dup_action = params[:dup_action].constantize + + pp dup_action + + importService = Import.new(klass: klass, file: uploaded_file, headers: ordered_fields, dup_action: dup_action.new) + result = importService.perform(use_headers) # If no exceptions occur, return success - render json: { message: "#{klass.name} has been imported!" }, status: :created + render json: { message: "#{klass.name} has been imported!", **result }, status: :created rescue StandardError => e # Catch any unexpected runtime errors @@ -80,6 +81,6 @@ def import # Strong parameters for import operations # def import_params - params.permit(:csv_file, :use_headers, :class, :ordered_fields) + params.permit(:csv_file, :use_headers, :class, :ordered_fields, :dup_action) end end diff --git a/app/helpers/duplicated_action_helper.rb b/app/helpers/duplicated_action_helper.rb index 2b622f14e..5e6bb1347 100644 --- a/app/helpers/duplicated_action_helper.rb +++ b/app/helpers/duplicated_action_helper.rb @@ -23,7 +23,7 @@ # The Import class will call klass.create!(hash) for each returned hash. # # =============================================================== -module DuplicateAction +module DuplicateActionHelper def on_duplicate_record(klass:, records:) raise NotImplementedError, "#{self.class} must implement `on_duplicate_record`" @@ -43,13 +43,11 @@ def on_duplicate_record(klass:, records:) # Behavior: # Returning `nil` instructs Import.perform to do nothing. # =============================================================== -class SkipRecordAction - include DuplicateAction - - def on_duplicate_record(klass:, records:) - nil - end -end +# class SkipRecordAction +# def on_duplicate_record(klass:, records:) +# nil +# end +# end # =============================================================== @@ -73,27 +71,26 @@ def on_duplicate_record(klass:, records:) # Importer will delete the original conflicting record and replace it # with the merged one. # =============================================================== -class UpdateExistingRecordAction - include DuplicateAction - - def on_duplicate_record(klass:, records:) - merged = {} - - records.each do |rec| - # Accept Hashes OR ActiveRecord instances - row = rec.is_a?(Hash) ? rec : rec.attributes.symbolize_keys - - # Merge: - # Later values override earlier ones unless nil - row.each do |key, value| - merged[key] = value unless value.nil? - end - end - - # Return one record to create - [merged] - end -end +# class UpdateExistingRecordAction +# +# def on_duplicate_record(klass:, records:) +# merged = {} +# +# records.each do |rec| +# # Accept Hashes OR ActiveRecord instances +# row = rec.is_a?(Hash) ? rec : rec.attributes.symbolize_keys +# +# # Merge: +# # Later values override earlier ones unless nil +# row.each do |key, value| +# merged[key] = value unless value.nil? +# end +# end +# +# # Return one record to create +# [merged] +# end +# end # =============================================================== @@ -120,8 +117,6 @@ def on_duplicate_record(klass:, records:) # # =============================================================== class ChangeOffendingFieldAction - include DuplicateAction - def on_duplicate_record(klass:, records:) # Normalize both existing and incoming row formats existing = normalize(records.first) diff --git a/app/helpers/importable_exportable_helper.rb b/app/helpers/importable_exportable_helper.rb index fb84fd3d9..62537a75d 100644 --- a/app/helpers/importable_exportable_helper.rb +++ b/app/helpers/importable_exportable_helper.rb @@ -130,7 +130,6 @@ def self.unappended_class_name(ref_class, name) # # =============================================================== module ImportableExportableHelper - attr_accessor :available_actions_on_duplicate # -------------------------------------------------------------- # When extended by a class, inherit parent import settings. @@ -181,6 +180,20 @@ def external_classes(*fields) 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 # @@ -240,7 +253,7 @@ def to_hash(fields = self.class.internal_fields) # # Duplicate objects are collected and returned. # -------------------------------------------------------------- - def try_import_records(file, headers, use_header: false) + def try_import_records(file, headers, use_header) temp_file = 'output.csv' csv_file = CSV.read(file) @@ -267,8 +280,8 @@ def try_import_records(file, headers, use_header: false) duplicate_records << dup if dup && dup != true end - # Keep duplicates for UI, roll back DB changes - # raise ActiveRecord::Rollback + rescue StandardError + raise ActiveRecord::Rollback end File.delete(temp_file) @@ -380,12 +393,28 @@ def save_object(created_object) # Check if a specific attribute has a :uniqueness error puts "Validation error: #{e.message}" - unless created_object.errors.details[:attribute_name].any? { |detail| detail[:error] == :uniqueness } - raise StandardError.new(e.message) + + + # created_object.errors.details.each do |attribute, error_details_array| + # error_details_array.each do |detail_hash| + # error_type = detail_hash[:error] + # if error_type == :taken + # + # end + # end + + + 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 - puts 'Uniqueness violation on attribute_name!' - created_object + raise StandardError.new(e.message) + + rescue ActiveRecord::RecordNotUnique => e puts "Unique constraint violation: #{e.message}" created_object diff --git a/app/models/user.rb b/app/models/user.rb index b08923436..6117a961b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -5,6 +5,7 @@ class User < ApplicationRecord mandatory_fields :name, :email, :password, :full_name external_classes ExternalClass.new(Role, true, false, :name), ExternalClass.new(Institution, true, false, :name) + available_actions_on_duplicate SkipRecordAction.new, UpdateExistingRecordAction.new, ChangeOffendingFieldAction.new has_secure_password diff --git a/app/services/change_offending_field_action.rb b/app/services/change_offending_field_action.rb new file mode 100644 index 000000000..856a3b577 --- /dev/null +++ b/app/services/change_offending_field_action.rb @@ -0,0 +1,62 @@ +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 \ No newline at end of file diff --git a/app/services/import.rb b/app/services/import.rb index 811ec8b64..3413a147a 100644 --- a/app/services/import.rb +++ b/app/services/import.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true require 'csv' -require_relative 'field_mapping' -require_relative 'duplicate_action' # By default, if the caller does not specify a duplicate action, # we use ChangeOffendingFieldAction. This ensures the importer @@ -33,10 +31,10 @@ class Import # @param mapping [FieldMapping, nil] optional mapping override # @param dup_action [DuplicateAction, nil] optional duplicate handler override # - def initialize(klass:, file:, mapping: nil, dup_action: nil) + def initialize(klass:, file:, headers: nil, dup_action: nil) @klass = klass @file = file - @mapping = mapping + @headers = headers @duplicate_action = dup_action || DEFAULT_DUPLICATE_ACTION end @@ -54,34 +52,44 @@ def initialize(klass:, file:, mapping: nil, dup_action: nil) # # Returns a summary with :imported and :duplicates count # - def perform + def perform(use_headers) # Use provided mapping or fall back to default derived from model - mapping = @mapping || default_mapping(@klass) + # mapping = @mapping || default_mapping(@klass) # Convert CSV rows into attribute hashes using the field mapping - rows = parse_csv(@file, mapping) + # rows = parse_csv(@file, mapping) duplicate_groups = [] # Will hold duplicate row sets successful_inserts = 0 # Counter for successful saves # Wrap everything in a transaction to ensure consistency - ActiveRecord::Base.transaction do - rows.each do |attrs| - begin - # Attempt to create the record - obj = @klass.new(attrs) - obj.save! - successful_inserts += 1 - - rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => e - # Any uniqueness or validation failure is treated as a duplicate - duplicate_groups << normalize_duplicate(attrs) - end - end - - # Let the duplicate action process all collected conflicts - process_duplicates(@klass, duplicate_groups) - end + # ActiveRecord::Base.transaction do + # rows.each do |attrs| + # begin + # # Attempt to create the record + # obj = @klass.new(attrs) + # obj.save! + # successful_inserts += 1 + # + # rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => e + # # Any uniqueness or validation failure is treated as a duplicate + # + # end + # end + + # Call the model-level importer (defined in each model using the import mixin) + dups = @klass.try_import_records( + @file, + @headers, + use_headers + ) + + 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 { @@ -108,16 +116,36 @@ def perform # This format is used by DuplicateAction subclasses to determine # how the conflict should be resolved. # - def normalize_duplicate(incoming_hash) - pk = @klass.primary_key.to_sym + def normalize_duplicate(incoming_obj) + # pk = @klass.primary_key # Try to find the existing record using the primary key value - existing = @klass.find_by(pk => incoming_hash[pk]) + # pp incoming_hash + hash = incoming_obj.as_json + pp hash + pp incoming_obj.as_json.slice([find_offending_field(incoming_obj)]) + field = find_offending_field(incoming_obj) + pp field + pp hash[field.to_s] + + + value = {} + value[field] = incoming_obj.as_json()[field.to_s] + pp value + + existing = @klass.find_by(value) + pp existing + { + existing: existing, # Existing row (maybe empty) + incoming: incoming_obj # Incoming row + } + end - [ - existing&.attributes&.symbolize_keys || {}, # Existing row (maybe empty) - incoming_hash.symbolize_keys # Incoming row - ] + 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 ## @@ -132,17 +160,14 @@ def normalize_duplicate(incoming_hash) def process_duplicates(klass, groups) groups.each do |records| processed = @duplicate_action.on_duplicate_record( - klass: klass, - records: records + klass, + records ) # If the duplicate action returns nil, it means “skip insertion” next if processed.nil? - # Otherwise, treat each returned hash as a new valid record - processed.each do |attrs| - klass.create!(attrs) - end + processed.save! end end @@ -155,9 +180,9 @@ def process_duplicates(klass, groups) # exposed by the model. This ensures every column that CAN be imported # will be imported. # - def default_mapping(klass) - FieldMapping.new(klass, klass.internal_and_external_fields) - end + # def default_mapping(klass) + # FieldMapping.new(klass, klass.internal_and_external_fields) + # end # -------------------------------------------------------------- # CSV PARSING @@ -170,15 +195,15 @@ def default_mapping(klass) # # The mapping determines which fields correspond to which columns. # - def parse_csv(file, mapping) - fields = mapping.ordered_fields - rows = [] - - # No headers — CSV columns must follow mapping order precisely. - CSV.foreach(file, headers: false) do |row| - rows << Hash[fields.zip(row)] - end - - rows - end + # def parse_csv(file, mapping) + # fields = mapping.ordered_fields + # rows = [] + # + # # No headers — CSV columns must follow mapping order precisely. + # CSV.foreach(file, headers: false) do |row| + # rows << Hash[fields.zip(row)] + # end + # + # rows + # end end diff --git a/app/services/skip_record_action.rb b/app/services/skip_record_action.rb new file mode 100644 index 000000000..c6fae6842 --- /dev/null +++ b/app/services/skip_record_action.rb @@ -0,0 +1,6 @@ +class SkipRecordAction + def on_duplicate_record(klass, + records) + nil + end +end \ No newline at end of file diff --git a/app/services/update_existing_record_action.rb b/app/services/update_existing_record_action.rb new file mode 100644 index 000000000..d158397b1 --- /dev/null +++ b/app/services/update_existing_record_action.rb @@ -0,0 +1,16 @@ +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 \ No newline at end of file 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 From 7f88a13e9bfa20ff7a7ae3f1be458954259742f0 Mon Sep 17 00:00:00 2001 From: TaylorBrown96 Date: Wed, 3 Dec 2025 11:00:41 -0500 Subject: [PATCH 22/80] Added test suite --- app/controllers/export_controller.rb | 37 +++-- ..._action_helper.rb => duplicated_action.rb} | 0 spec/fixtures/files/import_test.csv | 2 + spec/integration/export_controller_spec.rb | 100 ++++++++++++ spec/integration/import_controller_spec.rb | 149 ++++++++++++++++++ 5 files changed, 277 insertions(+), 11 deletions(-) rename app/helpers/{duplicated_action_helper.rb => duplicated_action.rb} (100%) create mode 100644 spec/fixtures/files/import_test.csv create mode 100644 spec/integration/export_controller_spec.rb create mode 100644 spec/integration/import_controller_spec.rb diff --git a/app/controllers/export_controller.rb b/app/controllers/export_controller.rb index e99858b24..0f004070b 100644 --- a/app/controllers/export_controller.rb +++ b/app/controllers/export_controller.rb @@ -1,30 +1,45 @@ -# This file holds the logic for exporting data from the application to various formats. - +# This controller handles exporting data from the application to various formats. class ExportController < ApplicationController before_action :export_params + def index - imported_class = params[:class].constantize + klass = params[:class].constantize render json: { - mandatory_fields: imported_class.mandatory_fields, - optional_fields: imported_class.optional_fields, - external_fields: imported_class.external_fields + mandatory_fields: klass.mandatory_fields, + optional_fields: klass.optional_fields, + external_fields: klass.external_fields }, status: :ok + rescue StandardError => e + render json: { error: e.message }, status: :unprocessable_entity end def export - ordered_fields = JSON.parse(params[:ordered_fields]) if params[:ordered_fields] + # 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 - csv_file = Export.perform(params[:class].constantize, ordered_fields) + klass = params[:class].constantize + csv_file = Export.perform(klass, ordered_fields) - render json: { message: "#{params[:class]} has been imported!", file: csv_file }, status: :ok + 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 - puts params params.permit(:class, :ordered_fields) end -end \ No newline at end of file +end diff --git a/app/helpers/duplicated_action_helper.rb b/app/helpers/duplicated_action.rb similarity index 100% rename from app/helpers/duplicated_action_helper.rb rename to app/helpers/duplicated_action.rb 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/integration/export_controller_spec.rb b/spec/integration/export_controller_spec.rb new file mode 100644 index 000000000..90d22c122 --- /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 = "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("fake_csv_data") + end + + it "passes nil ordered_fields when none are provided" do + export_return = "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("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..bae8d9958 --- /dev/null +++ b/spec/integration/import_controller_spec.rb @@ -0,0 +1,149 @@ +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 + 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(:try_import_records) + 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"], + use_header: 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 From eaa6d112d751a04bbc5cba2b18d58bfb5142ebdc Mon Sep 17 00:00:00 2001 From: crjone24 Date: Fri, 5 Dec 2025 00:09:32 -0500 Subject: [PATCH 23/80] Fixed helper tests, removed commented out code. --- app/helpers/duplicated_action.rb | 180 ------------------ app/helpers/importable_exportable_helper.rb | 75 +++----- app/services/change_offending_field_action.rb | 25 ++- app/services/export.rb | 4 +- app/services/field_mapping.rb | 2 +- app/services/import.rb | 67 ------- app/services/skip_record_action.rb | 14 +- app/services/update_existing_record_action.rb | 23 ++- spec/helpers/import_export_spec.rb | 15 +- 9 files changed, 99 insertions(+), 306 deletions(-) delete mode 100644 app/helpers/duplicated_action.rb diff --git a/app/helpers/duplicated_action.rb b/app/helpers/duplicated_action.rb deleted file mode 100644 index 5e6bb1347..000000000 --- a/app/helpers/duplicated_action.rb +++ /dev/null @@ -1,180 +0,0 @@ -# frozen_string_literal: true - -# =============================================================== -# DuplicateAction (ABSTRACT MIXIN) -# -# All duplicate-resolution strategies used by Import must include -# this module. It defines a single required method: -# -# on_duplicate_record(klass:, records:) -# -# Arguments: -# klass: The ActiveRecord model being imported (e.g., Team, User) -# records: An Array containing two elements: -# [ existing_record_hash, incoming_record_hash ] -# Both may be: -# - Hashes (symbolized) -# - ActiveRecord instances -# -# Return value expectations: -# nil → indicates the duplicate should not be inserted -# Array → 1 or more hashes representing records to create -# -# The Import class will call klass.create!(hash) for each returned hash. -# -# =============================================================== -module DuplicateActionHelper - def on_duplicate_record(klass:, records:) - raise NotImplementedError, - "#{self.class} must implement `on_duplicate_record`" - end -end - - -# =============================================================== -# 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 - - -# =============================================================== -# 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 = {} -# -# records.each do |rec| -# # Accept Hashes OR ActiveRecord instances -# row = rec.is_a?(Hash) ? rec : rec.attributes.symbolize_keys -# -# # Merge: -# # Later values override earlier ones unless nil -# row.each do |key, value| -# merged[key] = value unless value.nil? -# end -# end -# -# # Return one record to create -# [merged] -# end -# end - - -# =============================================================== -# 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 = normalize(records.first) - incoming = normalize(records.last).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/helpers/importable_exportable_helper.rb b/app/helpers/importable_exportable_helper.rb index 62537a75d..0a1ef8e84 100644 --- a/app/helpers/importable_exportable_helper.rb +++ b/app/helpers/importable_exportable_helper.rb @@ -11,7 +11,7 @@ # This object encodes: # • Which class is referenced # • Whether it should be LOOKED UP or CREATED -# • What field should be used to perform lookups +# • What field should be used to perform look_ups # # The importer uses this information to: # • Map CSV fields to the external class @@ -19,16 +19,16 @@ # • Create new referenced objects when required # # Example: -# ExternalClass.new(User, should_lookup: true, should_create: false, lookup_field: :email) +# ExternalClass.new(User, should_look_up: true, should_create: false, look_up_field: :email) # =============================================================== class ExternalClass - attr_accessor :ref_class, :should_lookup, :should_create + attr_accessor :ref_class, :should_look_up, :should_create - def initialize(ref_class, should_lookup = false, should_create = true, lookup_field = nil) + 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_lookup = should_lookup # Whether existing objects should be searched for + @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 - @lookup_field = lookup_field # Column used to identify existing objects + @look_up_field = look_up_field # Column used to identify existing objects end # -------------------------------------------------------------- @@ -36,7 +36,7 @@ def initialize(ref_class, should_lookup = false, should_create = true, lookup_fi # # If the class itself includes ImportableExportable, we use its # internal_fields. Otherwise, we fall back to: - # - the lookup field, or + # - the look_up field, or # - the primary key # # All returned fields are namespaced (role_name, user_email, etc.) @@ -45,32 +45,32 @@ 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, @lookup_field.to_s), self.class.append_class_name(@ref_class, @ref_class.primary_key)] + [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 # -------------------------------------------------------------- - # Lookup an external object in the database. + # look_up an external object in the database. # # Uses either: - # • a lookup field, or + # • 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 lookup(class_values) - class_name_lookup_field = self.class.append_class_name(@ref_class, @lookup_field.to_s) + 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 lookup field ---------- - if @lookup_field && class_values[class_name_lookup_field] - if @ref_class.attribute_method?(@lookup_field) - value = @ref_class.find_by(@lookup_field => class_values[class_name_lookup_field]) - elsif @ref_class.attribute_method?(class_name_lookup_field) - value = @ref_class.find_by(class_name_lookup_field => class_values[class_name_lookup_field]) + # ---------- 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 ---------- @@ -257,6 +257,8 @@ def try_import_records(file, headers, use_header) temp_file = 'output.csv' csv_file = CSV.read(file) + mapping = [] + # ---- Normalize header row ---- CSV.open(temp_file, "w") do |csv| if use_header @@ -265,6 +267,8 @@ def try_import_records(file, headers, use_header) headers = headers.map { |header| header.parameterize.underscore } end + mapping = FieldMapping.from_header(self, headers) + csv << headers csv_file.each { |row| csv << row } end @@ -276,7 +280,7 @@ def try_import_records(file, headers, use_header) ActiveRecord::Base.transaction do temp_contents.each do |row| - dup = import_row(row, temp_file) + dup = import_row(row, mapping) duplicate_records << dup if dup && dup != true end @@ -294,16 +298,14 @@ def try_import_records(file, headers, use_header) # Handles: # • mapping values # • building internal object - # • external object lookup/creation + # • external object look_up/creation # • save + duplicate capture # # Returns: # • true if saved successfully # • duplicate object if duplicate occurred # -------------------------------------------------------------- - def import_row(row, file) - header_row = CSV.open(file, &:first) - mapping = FieldMapping.from_header(self, header_row) + def import_row(row, mapping) # Build row_hash where each key maps to all found values row_hash = {} @@ -312,15 +314,13 @@ def import_row(row, file) row_hash[key] << value end - puts "Row Hash: #{row_hash}" - # Create object for this class current_class_attrs = row_hash.slice(*internal_fields) created_object = from_hash(current_class_attrs) # for each external class, try to look them up external_classes&.each do |external_class| - lookup_external_class(row_hash, external_class, created_object) + look_up_external_class(row_hash, external_class, created_object) end duplicate = save_object(created_object) @@ -337,18 +337,18 @@ def import_row(row, file) private # -------------------------------------------------------------- - # Attempt to find an external object via lookup rules. + # Attempt to find an external object via look_up rules. # If found, attach it to the parent object. # -------------------------------------------------------------- - def lookup_external_class(row_hash, external_class, parent_obj) - if external_class.should_lookup && (found = external_class.lookup(row_hash)) + 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 lookups fail AND the external class allows creation, + # When look_ups fail AND the external class allows creation, # build and save new external objects. # # Handles multi-row data such as: @@ -361,7 +361,7 @@ def lookup_external_class(row_hash, external_class, parent_obj) def create_external_class(row_hash, external_class, parent_obj) return unless external_class.should_create - current_class_attrs = row_hash.slice(*external_class.internal_fields) + 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| @@ -391,19 +391,6 @@ def save_object(created_object) created_object.save! rescue ActiveRecord::RecordInvalid => e # Check if a specific attribute has a :uniqueness error - puts "Validation error: #{e.message}" - - - - # created_object.errors.details.each do |attribute, error_details_array| - # error_details_array.each do |detail_hash| - # error_type = detail_hash[:error] - # if error_type == :taken - # - # end - # end - - 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} diff --git a/app/services/change_offending_field_action.rb b/app/services/change_offending_field_action.rb index 856a3b577..73b2480bb 100644 --- a/app/services/change_offending_field_action.rb +++ b/app/services/change_offending_field_action.rb @@ -1,3 +1,26 @@ +# =============================================================== +# 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 @@ -59,4 +82,4 @@ def generate_unique_value(klass:, field:, base:) candidate end -end \ No newline at end of file +end diff --git a/app/services/export.rb b/app/services/export.rb index f371bf9b0..75ceffb43 100644 --- a/app/services/export.rb +++ b/app/services/export.rb @@ -47,9 +47,9 @@ def self.perform(export_class, ordered_headers) export_class.external_classes.each do |external_class| ext_class_fields = mapping.ordered_fields.select{ |ele| external_class.fields.include?(ele) } - lookup_record = record.send(external_class.ref_class.name.underscore) + found_record = record.send(external_class.ref_class.name.underscore) row += ext_class_fields.map do |f| - lookup_record.send(ExternalClass.unappended_class_name(external_class.ref_class, f)) if f + found_record.send(ExternalClass.unappended_class_name(external_class.ref_class, f)) if f end end diff --git a/app/services/field_mapping.rb b/app/services/field_mapping.rb index ba75f4e02..8df4a8975 100644 --- a/app/services/field_mapping.rb +++ b/app/services/field_mapping.rb @@ -28,7 +28,7 @@ class FieldMapping # ordered_fields: # Array of field names that define the order CSV fields appear # in. We convert everything to strings to ensure consistent - # lookups (symbols vs strings cause unnecessary mismatches). + # look_ups (symbols vs strings cause unnecessary mismatches). # # Example: # FieldMapping.new(User, [:email, "first_name", :last_name]) diff --git a/app/services/import.rb b/app/services/import.rb index 3413a147a..639951835 100644 --- a/app/services/import.rb +++ b/app/services/import.rb @@ -53,30 +53,9 @@ def initialize(klass:, file:, headers: nil, dup_action: nil) # Returns a summary with :imported and :duplicates count # def perform(use_headers) - # Use provided mapping or fall back to default derived from model - # mapping = @mapping || default_mapping(@klass) - - # Convert CSV rows into attribute hashes using the field mapping - # rows = parse_csv(@file, mapping) - duplicate_groups = [] # Will hold duplicate row sets successful_inserts = 0 # Counter for successful saves - # Wrap everything in a transaction to ensure consistency - # ActiveRecord::Base.transaction do - # rows.each do |attrs| - # begin - # # Attempt to create the record - # obj = @klass.new(attrs) - # obj.save! - # successful_inserts += 1 - # - # rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => e - # # Any uniqueness or validation failure is treated as a duplicate - # - # end - # end - # Call the model-level importer (defined in each model using the import mixin) dups = @klass.try_import_records( @file, @@ -117,24 +96,13 @@ def perform(use_headers) # how the conflict should be resolved. # def normalize_duplicate(incoming_obj) - # pk = @klass.primary_key - # Try to find the existing record using the primary key value - # pp incoming_hash - hash = incoming_obj.as_json - pp hash - pp incoming_obj.as_json.slice([find_offending_field(incoming_obj)]) field = find_offending_field(incoming_obj) - pp field - pp hash[field.to_s] - value = {} value[field] = incoming_obj.as_json()[field.to_s] - pp value existing = @klass.find_by(value) - pp existing { existing: existing, # Existing row (maybe empty) incoming: incoming_obj # Incoming row @@ -171,39 +139,4 @@ def process_duplicates(klass, groups) end end - # -------------------------------------------------------------- - # MAPPING - # -------------------------------------------------------------- - - ## - # Generates a default field mapping using all internal and external fields - # exposed by the model. This ensures every column that CAN be imported - # will be imported. - # - # def default_mapping(klass) - # FieldMapping.new(klass, klass.internal_and_external_fields) - # end - - # -------------------------------------------------------------- - # CSV PARSING - # -------------------------------------------------------------- - - ## - # Reads the CSV and builds an array of attribute hashes: - # - # [ {field1: val1, field2: val2, ...}, ... ] - # - # The mapping determines which fields correspond to which columns. - # - # def parse_csv(file, mapping) - # fields = mapping.ordered_fields - # rows = [] - # - # # No headers — CSV columns must follow mapping order precisely. - # CSV.foreach(file, headers: false) do |row| - # rows << Hash[fields.zip(row)] - # end - # - # rows - # end end diff --git a/app/services/skip_record_action.rb b/app/services/skip_record_action.rb index c6fae6842..e231c93f5 100644 --- a/app/services/skip_record_action.rb +++ b/app/services/skip_record_action.rb @@ -1,6 +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 \ No newline at end of file +end diff --git a/app/services/update_existing_record_action.rb b/app/services/update_existing_record_action.rb index d158397b1..d079b76bb 100644 --- a/app/services/update_existing_record_action.rb +++ b/app/services/update_existing_record_action.rb @@ -1,3 +1,24 @@ +# =============================================================== +# 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) @@ -13,4 +34,4 @@ def on_duplicate_record(klass, records) existing end -end \ No newline at end of file +end diff --git a/spec/helpers/import_export_spec.rb b/spec/helpers/import_export_spec.rb index 21b1568b1..1ae3bec77 100644 --- a/spec/helpers/import_export_spec.rb +++ b/spec/helpers/import_export_spec.rb @@ -40,7 +40,7 @@ csv_file = file_fixture('single_user_no_headers.csv') headers = ['Name', 'Email', 'Password', 'Full Name', 'Role Name'] - User.try_import_records(csv_file, headers, use_header: false) + 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 @@ -76,7 +76,7 @@ expect(QuestionAdvice.count).to eq(0) csv_file = file_fixture('questionnaire_item_with_headers.csv') - QuizItem.try_import_records(csv_file, nil, use_header: true) + QuizItem.try_import_records(csv_file, nil, true) expect(QuizItem.count).to eq(1) expect(QuizItem.find_by(txt: 'test')).to be_present @@ -103,7 +103,7 @@ 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, use_header: true)}.to raise_error + 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 @@ -111,7 +111,7 @@ csv_file = file_fixture('single_user_role_doe_not_exist.csv') - expect { User.try_import_records(csv_file, nil, use_header: true) }.to raise_error + 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 @@ -120,16 +120,13 @@ it 'Import an empty CSV (With Headers)' do csv_file = file_fixture('empty_with_headers.csv') - expect{User.try_import_records(csv_file, nil, use_header: true)}.not_to change(User, :count) + 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, [], use_header: false)}.not_to change(User, :count) + expect{User.try_import_records(csv_file, [], false)}.not_to change(User, :count) end - - end - end From e4920bf93d9df43bf6a74b07e37cb2a717fb89ca Mon Sep 17 00:00:00 2001 From: crjone24 Date: Fri, 5 Dec 2025 00:21:07 -0500 Subject: [PATCH 24/80] Added initial rswag tests for controller --- spec/requests/api/v1/export_spec.rb | 51 +++++++++++++++++++++++++++++ spec/requests/api/v1/import_spec.rb | 49 +++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 spec/requests/api/v1/export_spec.rb create mode 100644 spec/requests/api/v1/import_spec.rb 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/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 From cf24ad78c3526839d0699b132806036aebdb496e Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Fri, 13 Mar 2026 18:49:22 -0500 Subject: [PATCH 25/80] Changing migration to conditionally rename columns. Pushed new schema.rb file due to migration being run. --- Dockerfile | 2 +- ...40855_rename_item_id_in_question_tables.rb | 15 +++- db/schema.rb | 87 +++++++++++-------- 3 files changed, 64 insertions(+), 40 deletions(-) diff --git a/Dockerfile b/Dockerfile index ff57d5dd5..053732f7e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,4 +21,4 @@ RUN bundle install EXPOSE 3002 # Set the entry point -ENTRYPOINT ["/app/setup.sh"] +ENTRYPOINT ["./setup.sh"] diff --git a/db/migrate/20251129040855_rename_item_id_in_question_tables.rb b/db/migrate/20251129040855_rename_item_id_in_question_tables.rb index ec37dc86a..e9d528b6f 100644 --- a/db/migrate/20251129040855_rename_item_id_in_question_tables.rb +++ b/db/migrate/20251129040855_rename_item_id_in_question_tables.rb @@ -1,7 +1,16 @@ class RenameItemIdInQuestionTables < ActiveRecord::Migration[8.0] def change - rename_column :answers, :question_id, :item_id - rename_column :question_advices, :question_id, :item_id - rename_column :quiz_question_choices, :question_id, :item_id + 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 diff --git a/db/schema.rb b/db/schema.rb index 73da0b160..dab7137f2 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_11_29_040855) do +ActiveRecord::Schema[8.0].define(version: 2026_02_27_203258) do create_table "account_requests", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "username" t.string "full_name" @@ -103,10 +103,20 @@ t.boolean "enable_pair_programming", default: false t.boolean "has_teams", default: false t.boolean "has_topics", default: false + t.boolean "vary_by_round", default: false, null: false t.index ["course_id"], name: "index_assignments_on_course_id" t.index ["instructor_id"], name: "index_assignments_on_instructor_id" end + create_table "assignments_duties", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "assignment_id", null: false + t.bigint "duty_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["assignment_id"], name: "index_assignments_duties_on_assignment_id" + t.index ["duty_id"], name: "index_assignments_duties_on_duty_id" + end + create_table "bookmark_ratings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.integer "bookmark_id" t.integer "user_id" @@ -125,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" @@ -168,6 +173,16 @@ t.index ["parent_type", "parent_id"], name: "index_due_dates_on_parent" end + create_table "duties", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.string "name" + t.boolean "private", default: false + t.bigint "instructor_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.integer "max_members_for_duty" + t.index ["instructor_id"], name: "index_duties_on_instructor_id" + end + create_table "institutions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name" t.datetime "created_at", null: false @@ -181,10 +196,8 @@ t.datetime "updated_at", null: false t.bigint "from_id", null: false t.bigint "to_id", null: false - t.bigint "participant_id", null: false t.index ["assignment_id"], name: "fk_invitation_assignments" t.index ["from_id"], name: "index_invitations_on_from_id" - t.index ["participant_id"], name: "index_invitations_on_participant_id" t.index ["to_id"], name: "index_invitations_on_to_id" end @@ -211,7 +224,7 @@ t.integer "participant_id" t.integer "team_id" t.text "comments" - t.string "reply_status" + t.string "status" end create_table "nodes", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -241,12 +254,30 @@ t.integer "parent_id", null: false t.string "type", null: false t.float "grade" + t.bigint "duty_id" + t.index ["duty_id"], name: "index_participants_on_duty_id" t.index ["join_team_request_id"], name: "index_participants_on_join_team_request_id" t.index ["team_id"], name: "index_participants_on_team_id" t.index ["user_id"], name: "fk_participant_users" t.index ["user_id"], name: "index_participants_on_user_id" end + create_table "project_topics", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.text "topic_name", null: false + t.bigint "assignment_id", null: false + t.integer "max_choosers", default: 0, null: false + t.text "category" + t.string "topic_identifier", limit: 10 + t.integer "micropayment", default: 0 + t.integer "private_to" + t.text "description" + 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 "item_id", null: false t.integer "score" @@ -295,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 @@ -319,32 +349,14 @@ t.index ["parent_id"], name: "fk_rails_4404228d2f" end - create_table "sign_up_topics", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.text "topic_name", null: false - t.bigint "assignment_id", null: false - t.integer "max_choosers", default: 0, null: false - t.text "category" - t.string "topic_identifier", limit: 10 - t.integer "micropayment", default: 0 - t.integer "private_to" - t.text "description" - 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_sign_up_topics_on_assignment_id" - end - create_table "signed_up_teams", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.bigint "sign_up_topic_id", null: false + t.bigint "project_topic_id", null: false t.bigint "team_id", null: false t.boolean "is_waitlisted" t.integer "preference_priority_number" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.text "comments_for_advertisement" - t.boolean "advertise_for_partner" - t.index ["sign_up_topic_id"], name: "index_signed_up_teams_on_sign_up_topic_id" + t.index ["project_topic_id"], name: "index_signed_up_teams_on_project_topic_id" t.index ["team_id"], name: "index_signed_up_teams_on_team_id" end @@ -359,10 +371,10 @@ end create_table "teams", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.string "name", null: false - t.string "type", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "name", null: false + t.string "type", null: false t.integer "parent_id", null: false t.integer "grade_for_submission" t.string "comment_for_submission" @@ -385,6 +397,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 @@ -420,18 +433,20 @@ add_foreign_key "account_requests", "roles" add_foreign_key "assignments", "courses" add_foreign_key "assignments", "users", column: "instructor_id" + add_foreign_key "assignments_duties", "assignments" + 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 "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" add_foreign_key "roles", "roles", column: "parent_id", on_delete: :cascade - add_foreign_key "sign_up_topics", "assignments" - add_foreign_key "signed_up_teams", "sign_up_topics" + add_foreign_key "signed_up_teams", "project_topics" add_foreign_key "signed_up_teams", "teams" add_foreign_key "ta_mappings", "courses" add_foreign_key "ta_mappings", "users" From 9a1cee4544e3dd512c333aa32555a1e6671552de Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Sun, 15 Mar 2026 16:14:18 -0500 Subject: [PATCH 26/80] I guess not all migrations were run so updating schema.rb to reflect all migrations being run. --- db/schema.rb | 57 ++++++++++++++++++++++++++++------------------------ 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/db/schema.rb b/db/schema.rb index dab7137f2..6dc955e86 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -191,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| @@ -224,7 +224,7 @@ t.integer "participant_id" t.integer "team_id" t.text "comments" - t.string "status" + t.string "reply_status" end create_table "nodes", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -262,22 +262,6 @@ t.index ["user_id"], name: "index_participants_on_user_id" end - create_table "project_topics", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.text "topic_name", null: false - t.bigint "assignment_id", null: false - t.integer "max_choosers", default: 0, null: false - t.text "category" - t.string "topic_identifier", limit: 10 - t.integer "micropayment", default: 0 - t.integer "private_to" - t.text "description" - 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 "item_id", null: false t.integer "score" @@ -349,6 +333,22 @@ t.index ["parent_id"], name: "fk_rails_4404228d2f" end + create_table "sign_up_topics", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.text "topic_name", null: false + t.bigint "assignment_id", null: false + t.integer "max_choosers", default: 0, null: false + t.text "category" + t.string "topic_identifier", limit: 10 + t.integer "micropayment", default: 0 + t.integer "private_to" + t.text "description" + 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_sign_up_topics_on_assignment_id" + end + create_table "signed_up_teams", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "project_topic_id", null: false t.bigint "team_id", null: false @@ -371,13 +371,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.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| @@ -438,15 +442,16 @@ add_foreign_key "courses", "institutions" add_foreign_key "courses", "users", column: "instructor_id" add_foreign_key "duties", "users", column: "instructor_id" + add_foreign_key "invitations", "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" add_foreign_key "roles", "roles", column: "parent_id", on_delete: :cascade - add_foreign_key "signed_up_teams", "project_topics" + add_foreign_key "sign_up_topics", "assignments" + add_foreign_key "signed_up_teams", "sign_up_topics", column: "project_topic_id" add_foreign_key "signed_up_teams", "teams" add_foreign_key "ta_mappings", "courses" add_foreign_key "ta_mappings", "users" From b847f8ec2ba2e0e25ce4626c22ad863160d7d727 Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Sun, 15 Mar 2026 16:41:25 -0500 Subject: [PATCH 27/80] reverted setup.sh location within dockerfile because I wasn't starting containers properly before. --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 053732f7e..687c70771 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,4 +21,4 @@ RUN bundle install EXPOSE 3002 # Set the entry point -ENTRYPOINT ["./setup.sh"] +ENTRYPOINT ["/app/setup.sh"] \ No newline at end of file From 61681555b8fa01e85ed4adda55eab658c113d34d Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Thu, 19 Mar 2026 15:05:26 -0500 Subject: [PATCH 28/80] updating mandatory fields and available actions for Team and SignUpTopic models. Stubbing out specs for import/export. --- app/helpers/importable_exportable_helper.rb | 10 +- app/models/sign_up_topic.rb | 4 + app/models/team.rb | 6 +- app/services/export.rb | 5 +- spec/requests/import_export_entities_spec.rb | 168 +++++++++++++++++++ 5 files changed, 183 insertions(+), 10 deletions(-) create mode 100644 spec/requests/import_export_entities_spec.rb diff --git a/app/helpers/importable_exportable_helper.rb b/app/helpers/importable_exportable_helper.rb index 0a1ef8e84..00ed71c42 100644 --- a/app/helpers/importable_exportable_helper.rb +++ b/app/helpers/importable_exportable_helper.rb @@ -163,7 +163,7 @@ def mandatory_fields(*fields) # Optional = internal fields - mandatory # -------------------------------------------------------------- def optional_fields - internal_fields - mandatory_fields + internal_fields - (mandatory_fields || []) end # -------------------------------------------------------------- @@ -176,7 +176,7 @@ def external_classes(*fields) if fields.any? @external_classes = fields else - @external_classes + @external_classes || [] end end @@ -190,7 +190,7 @@ def available_actions_on_duplicate(*fields) if fields.any? @available_actions_on_duplicate = fields else - @available_actions_on_duplicate + @available_actions_on_duplicate || [] end end @@ -326,12 +326,14 @@ def import_row(row, mapping) duplicate = save_object(created_object) return duplicate if duplicate && duplicate != true - return unless external_classes + 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 diff --git a/app/models/sign_up_topic.rb b/app/models/sign_up_topic.rb index 1d89b687b..d05d66120 100644 --- a/app/models/sign_up_topic.rb +++ b/app/models/sign_up_topic.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true class SignUpTopic < ApplicationRecord + extend ImportableExportableHelper + mandatory_fields :topic_name, :assignment_id + available_actions_on_duplicate SkipRecordAction.new, UpdateExistingRecordAction.new, ChangeOffendingFieldAction.new + has_many :signed_up_teams, foreign_key: 'topic_id', dependent: :destroy has_many :teams, through: :signed_up_teams # list all teams choose this topic, no matter in waitlist or not has_many :assignment_questionnaires, class_name: 'AssignmentQuestionnaire', foreign_key: 'topic_id', dependent: :destroy diff --git a/app/models/team.rb b/app/models/team.rb index 927f5ff2a..8d7121668 100644 --- a/app/models/team.rb +++ b/app/models/team.rb @@ -2,10 +2,8 @@ class Team < ApplicationRecord extend ImportableExportableHelper - mandatory_fields :name, :type - external_classes ExternalClass.new(Assignment, true, false, :title), - ExternalClass.new(Course, true, false, :name), - ExternalClass.new(User, true, false, :name) + mandatory_fields :name, :type, :parent_id + available_actions_on_duplicate SkipRecordAction.new, UpdateExistingRecordAction.new, ChangeOffendingFieldAction.new # Core associations has_many :signed_up_teams, dependent: :destroy diff --git a/app/services/export.rb b/app/services/export.rb index 75ceffb43..ee307c0fb 100644 --- a/app/services/export.rb +++ b/app/services/export.rb @@ -32,6 +32,7 @@ class Export # 2,Team 2,Carol; Dan # def self.perform(export_class, ordered_headers) + ordered_headers ||= export_class.internal_and_external_fields mapping = FieldMapping.from_header(export_class, ordered_headers) CSV.generate do |csv| @@ -45,11 +46,11 @@ def self.perform(export_class, ordered_headers) export_class.all.each do |record| row = class_fields.map{|f| record.send(f)} - export_class.external_classes.each do |external_class| + 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 + found_record&.send(ExternalClass.unappended_class_name(external_class.ref_class, f)) if f end end diff --git a/spec/requests/import_export_entities_spec.rb b/spec/requests/import_export_entities_spec.rb new file mode 100644 index 000000000..a485739b4 --- /dev/null +++ b/spec/requests/import_export_entities_spec.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +require "rails_helper" +require "tempfile" + +RSpec.describe "Import/export entities", 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 + it "returns metadata for Team" do + get "/import/Team" + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + expect(json["mandatory_fields"]).to include("name", "type", "parent_id") + expect(json["available_actions_on_dup"]).to match_array( + %w[SkipRecordAction UpdateExistingRecordAction ChangeOffendingFieldAction] + ) + end + + it "returns metadata for SignUpTopic" do + get "/import/SignUpTopic" + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + expect(json["mandatory_fields"]).to include("topic_name", "assignment_id") + expect(json["available_actions_on_dup"]).to match_array( + %w[SkipRecordAction UpdateExistingRecordAction ChangeOffendingFieldAction] + ) + end + end + + describe "POST /import/: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", + full_name: "Teacher Example", + email: "teacher@example.com", + password: "password", + role: role, + institution: institution + ) + end + + let!(:assignment) do + Assignment.create!( + name: "Import Assignment", + instructor: instructor + ) + end + + it "imports teams" do + file = uploaded_csv("name,parent_id,type\nTeam Alpha,#{assignment.id},AssignmentTeam\n") + + post "/import/Team", + params: { + csv_file: file, + use_headers: true, + dup_action: "SkipRecordAction" + } + + expect(response).to have_http_status(:created) + expect(AssignmentTeam.find_by(name: "Team Alpha", parent_id: assignment.id)).to be_present + end + + it "imports topics" do + file = uploaded_csv("topic_name,assignment_id\nTopic A,#{assignment.id}\n") + + post "/import/SignUpTopic", + params: { + csv_file: file, + use_headers: true, + dup_action: "SkipRecordAction" + } + + expect(response).to have_http_status(:created) + expect(SignUpTopic.find_by(topic_name: "Topic A", assignment_id: assignment.id)).to be_present + 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 + SignUpTopic.create!( + topic_name: "Export Topic", + assignment_id: assignment.id + ) + end + + it "exports teams" do + post "/export/Team", params: { ordered_fields: %w[name parent_id type].to_json } + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + expect(json["file"]).to include("name,parent_id,type") + expect(json["file"]).to include("Export Team") + end + + it "exports topics" do + post "/export/SignUpTopic", params: { ordered_fields: %w[topic_name assignment_id].to_json } + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + expect(json["file"]).to include("topic_name,assignment_id") + expect(json["file"]).to include("Export Topic") + end + end +end From 0a06bda5da4571859467064fc09e8818ccf6ca60 Mon Sep 17 00:00:00 2001 From: Mihir Kamat Date: Fri, 20 Mar 2026 14:06:35 -0400 Subject: [PATCH 29/80] Make questionnaire importable-exportable (tested export) --- app/models/questionnaire.rb | 4 ++++ config/database.yml | 8 +++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/app/models/questionnaire.rb b/app/models/questionnaire.rb index 82c950ca2..7b0febdb8 100644 --- a/app/models/questionnaire.rb +++ b/app/models/questionnaire.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true class Questionnaire < ApplicationRecord + extend ImportableExportableHelper + mandatory_fields :name, :min_question_score, :max_question_score, :questionnaire_type, :display_type, :instruction_loc + external_classes ExternalClass.new(User, true, false, :name) + belongs_to :instructor has_many :items, class_name: "Item", foreign_key: "questionnaire_id", dependent: :destroy # the collection of questions associated with this Questionnaire before_destroy :check_for_question_associations diff --git a/config/database.yml b/config/database.yml index b9f5aa055..a6c49bd92 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: mihir + password: mihir 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 From ce62daa538d367722cbe18bf03c186f340b9bbe6 Mon Sep 17 00:00:00 2001 From: Mihir Kamat Date: Fri, 20 Mar 2026 16:34:42 -0400 Subject: [PATCH 30/80] Add recursive export (export_helper.rb) for has_many and belongs_to models of a particular root model TODO: integrate into main export workflow. Refer Tulasi. --- app/helpers/export_helper.rb | 184 +++++++++++++++++++++++++++++ app/models/answer.rb | 5 + app/models/question_advice.rb | 36 +++--- app/models/questionnaire.rb | 1 + spec/helpers/export_helper_spec.rb | 77 ++++++++++++ 5 files changed, 286 insertions(+), 17 deletions(-) create mode 100644 app/helpers/export_helper.rb create mode 100644 spec/helpers/export_helper_spec.rb diff --git a/app/helpers/export_helper.rb b/app/helpers/export_helper.rb new file mode 100644 index 000000000..9050ff424 --- /dev/null +++ b/app/helpers/export_helper.rb @@ -0,0 +1,184 @@ +# frozen_string_literal: true + +require 'set' + +module ExportHelper + module_function + + # Builds a recursive graph of a class and related associations. + # The graph uses a single has_many list and can include inferred inverse + # has_many edges from belongs_to declarations in other models. + # Also runs Export.perform for each unique class in that graph. + def export_has_many_graph(root_class) + graph = build_has_many_graph(root_class) + headers_by_class = {} + + each_graph_node(graph) do |node| + klass = node[:class_name].constantize + headers = mandatory_headers_for(klass) + + # Ensure child exports include mandatory fields used to link child back to parent. + if node[:parent_external_relation] + headers |= node[:parent_external_relation][:fields] + end + + headers_by_class[node[:class_name]] ||= [] + headers_by_class[node[:class_name]] |= headers + headers_by_class[node[:class_name]] = remove_identifier_fields(headers_by_class[node[:class_name]]) + end + + exports = {} + headers_by_class.each do |class_name, headers| + exports[class_name] = Export.perform(class_name.constantize, headers) + end + + { graph: graph, exports: exports } + end + + def build_has_many_graph(root_class, visited = Set.new, parent_class: nil) + klass = normalize_class(root_class) + class_name = klass.name + + relation_to_parent = external_relation_to_parent(klass, parent_class) + + if visited.include?(class_name) + return { + class_name: class_name, + parent_external_relation: relation_to_parent, + cyclic_reference: true, + has_many: [] + } + end + + visited.add(class_name) + + explicit_has_many_edges = has_many_edges_for(klass, visited) + inferred_from_belongs_to_edges = inferred_inverse_has_many_edges_for(klass, visited) + + { + class_name: class_name, + parent_external_relation: relation_to_parent, + has_many: dedupe_edges(explicit_has_many_edges + inferred_from_belongs_to_edges) + } + end + + def has_many_edges_for(klass, visited) + klass.reflect_on_all_associations(:has_many).filter_map do |association| + begin + associated_klass = association.klass + rescue StandardError + next + end + + { + association: association.name.to_s, + association_type: 'has_many', + graph: build_has_many_graph(associated_klass, visited, parent_class: klass) + } + end + end + private_class_method :has_many_edges_for + + # Treat belongs_to as the inverse of has_many: + # if ModelX belongs_to klass, include ModelX as a child node of klass. + def inferred_inverse_has_many_edges_for(klass, visited) + descendants = ActiveRecord::Base.descendants.select { |model| model < ApplicationRecord } + + descendants.filter_map do |candidate| + next if candidate == klass + + association = candidate.reflect_on_all_associations(:belongs_to).find do |belongs_to_association| + begin + belongs_to_association.klass == klass || klass <= belongs_to_association.klass + rescue StandardError + false + end + end + + next unless association + + { + association: candidate.name.underscore.pluralize, + association_type: 'inferred_has_many_from_belongs_to', + inferred_from: association.name.to_s, + graph: build_has_many_graph(candidate, visited, parent_class: klass) + } + end + end + private_class_method :inferred_inverse_has_many_edges_for + + def dedupe_edges(edges) + edges.uniq { |edge| [edge[:association], edge[:graph][:class_name]] } + end + private_class_method :dedupe_edges + + def each_graph_node(graph, seen = Set.new, &block) + signature = [graph[:class_name], graph[:parent_external_relation]&.dig(:ref_class)] + return if seen.include?(signature) + + seen.add(signature) + block.call(graph) + + graph[:has_many].each do |child| + each_graph_node(child[:graph], seen, &block) + end + end + private_class_method :each_graph_node + + def mandatory_headers_for(klass) + if klass.respond_to?(:mandatory_fields) + mandatory = Array(klass.mandatory_fields).map(&:to_s) + return remove_identifier_fields(mandatory) unless mandatory.empty? + end + + if klass.respond_to?(:internal_and_external_fields) + return remove_identifier_fields(klass.internal_and_external_fields.map(&:to_s)) + end + + return remove_identifier_fields(klass.column_names) if klass.respond_to?(:column_names) + + raise ArgumentError, "No export headers available for #{klass.name}" + end + private_class_method :mandatory_headers_for + + def external_relation_to_parent(klass, parent_class) + return nil unless parent_class && klass.respond_to?(:external_classes) + + ext = klass.external_classes&.find do |external_class| + next false unless external_class.respond_to?(:ref_class) + + external_class.ref_class == parent_class || parent_class <= external_class.ref_class + end + + return nil unless ext + + { + ref_class: ext.ref_class.name, + should_look_up: ext.should_look_up, + should_create: ext.should_create, + look_up_field: ext.instance_variable_get(:@look_up_field)&.to_s, + fields: external_mandatory_fields(ext) + } + end + private_class_method :external_relation_to_parent + + def external_mandatory_fields(external_class) + ref_class = external_class.ref_class + return [] unless ref_class.respond_to?(:mandatory_fields) + + mandatory = Array(ref_class.mandatory_fields).map(&:to_s) + namespaced = mandatory.map { |field| ExternalClass.append_class_name(ref_class, field) } + remove_identifier_fields(namespaced) + end + private_class_method :external_mandatory_fields + + def remove_identifier_fields(fields) + Array(fields).map(&:to_s).uniq.reject { |field| field == 'id' || field.end_with?('_id') } + end + private_class_method :remove_identifier_fields + + def normalize_class(root_class) + root_class.is_a?(String) ? root_class.constantize : root_class + end + private_class_method :normalize_class +end diff --git a/app/models/answer.rb b/app/models/answer.rb index 3c3320afe..e0ad2b804 100644 --- a/app/models/answer.rb +++ b/app/models/answer.rb @@ -1,6 +1,11 @@ # frozen_string_literal: true class Answer < ApplicationRecord + extend ImportableExportableHelper + mandatory_fields :answer, :comments + external_classes ExternalClass.new(Item, true, false, :txt), + ExternalClass.new(Response, true, false, :additional_comment) + belongs_to :response belongs_to :item end diff --git a/app/models/question_advice.rb b/app/models/question_advice.rb index 65b1c7bab..f7daf537a 100644 --- a/app/models/question_advice.rb +++ b/app/models/question_advice.rb @@ -4,24 +4,26 @@ class QuestionAdvice < ApplicationRecord extend ImportableExportableHelper mandatory_fields :score, :advice - belongs_to :item - def self.export_fields(_options) - QuestionAdvice.columns.map(&:name) - end + external_classes ExternalClass.new(Item, true, false, :txt) - 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 + 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(item_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 \ No newline at end of file diff --git a/app/models/questionnaire.rb b/app/models/questionnaire.rb index 7b0febdb8..9a84d2ee9 100644 --- a/app/models/questionnaire.rb +++ b/app/models/questionnaire.rb @@ -4,6 +4,7 @@ class Questionnaire < ApplicationRecord extend ImportableExportableHelper mandatory_fields :name, :min_question_score, :max_question_score, :questionnaire_type, :display_type, :instruction_loc external_classes ExternalClass.new(User, true, false, :name) + available_actions_on_duplicate SkipRecordAction.new, UpdateExistingRecordAction.new, ChangeOffendingFieldAction.new belongs_to :instructor has_many :items, class_name: "Item", foreign_key: "questionnaire_id", dependent: :destroy # the collection of questions associated with this Questionnaire diff --git a/spec/helpers/export_helper_spec.rb b/spec/helpers/export_helper_spec.rb new file mode 100644 index 000000000..ee60e0086 --- /dev/null +++ b/spec/helpers/export_helper_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'csv' +require 'json' + +RSpec.describe ExportHelper, type: :helper do + describe '.build_has_many_graph' do + it 'captures ExternalClass relation and infers question_advices from belongs_to :item' do + graph = described_class.build_has_many_graph(Questionnaire) + + puts "\nExport Graph:" + puts JSON.pretty_generate(graph) + + expect(graph[:class_name]).to eq('Questionnaire') + + items_node = graph[:has_many].find { |node| node[:association] == 'items' } + expect(items_node).to be_present + expect(items_node[:association_type]).to eq('has_many') + + item_graph = items_node[:graph] + relation = item_graph[:parent_external_relation] + + expect(item_graph[:class_name]).to eq('Item') + expect(relation).to be_present + expect(relation[:ref_class]).to eq('Questionnaire') + expect(relation[:fields]).to include('questionnaire_name') + + question_advices_node = item_graph[:has_many].find { |node| node[:association] == 'question_advices' } + expect(question_advices_node).to be_present + expect(question_advices_node[:association_type]).to eq('inferred_has_many_from_belongs_to') + expect(question_advices_node[:inferred_from]).to eq('item') + expect(question_advices_node[:graph][:class_name]).to eq('QuestionAdvice') + end + end + + describe '.export_has_many_graph' do + it 'uses mandatory fields and includes inferred QuestionAdvice export' do + calls = {} + + allow(Export).to receive(:perform) do |klass, headers| + calls[klass.name] = headers + CSV.generate do |csv| + csv << headers + csv << headers.map { |header| "#{klass.name.downcase}_#{header}" } + end + end + + result = described_class.export_has_many_graph(Questionnaire) + + puts "\nExport Graph (from export_has_many_graph):" + puts JSON.pretty_generate(result[:graph]) + + puts "\nCSV Exports:" + result[:exports].each do |klass_name, csv_text| + puts "--- #{klass_name} ---" + puts csv_text + end + + expect(result).to have_key(:graph) + expect(result).to have_key(:exports) + expect(result[:exports]).to include('Questionnaire') + expect(result[:exports]).to include('Item') + expect(result[:exports]).to include('QuestionAdvice') + + expect(Questionnaire.mandatory_fields - calls['Questionnaire']).to be_empty + expect(calls['Questionnaire']).not_to include('id') + + expect(Item.mandatory_fields - calls['Item']).to be_empty + expect(calls['Item']).to include('questionnaire_name') + expect(calls['Item']).not_to include('id') + + expect(QuestionAdvice.mandatory_fields - calls['QuestionAdvice']).to be_empty + expect(calls['QuestionAdvice']).not_to include('id') + end + end +end From d787275bc03a51c447e8d902db9f129c6e4134cd Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Sat, 21 Mar 2026 15:55:51 -0500 Subject: [PATCH 31/80] Add route alias for project_topics in sign_up_topics controller because frontend expects that. --- config/routes.rb | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/config/routes.rb b/config/routes.rb index 4ed345a3e..d760f72e5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -97,6 +97,13 @@ end end + resources :project_topics, controller: :sign_up_topics do + collection do + get :filter + delete '/', action: :destroy + end + end + resources :invitations do get 'user/:user_id/assignment/:assignment_id/', on: :collection, action: :invitations_for_user_assignment end From ba5f2982ed09939168c05f6214bdc54fa08634a7 Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Sat, 21 Mar 2026 16:25:57 -0500 Subject: [PATCH 32/80] modifying controller to accept both sign_up_topics and project_topics from the frontend. This may be a temporary solution. --- app/controllers/sign_up_topics_controller.rb | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/app/controllers/sign_up_topics_controller.rb b/app/controllers/sign_up_topics_controller.rb index 11ddf5c7b..0af0604a7 100644 --- a/app/controllers/sign_up_topics_controller.rb +++ b/app/controllers/sign_up_topics_controller.rb @@ -22,7 +22,7 @@ def index # The method takes inputs and outputs the if the topic creation was successful. def create @sign_up_topic = SignUpTopic.new(sign_up_topic_params) - @assignment = Assignment.find(params[:sign_up_topic][:assignment_id]) + @assignment = Assignment.find(topic_payload[:assignment_id]) @sign_up_topic.micropayment = params[:micropayment] if @assignment.microtask? if @sign_up_topic.save # undo_link "The topic: \"#{@sign_up_topic.topic_name}\" has been created successfully. " @@ -77,8 +77,16 @@ def set_sign_up_topic @sign_up_topic = SignUpTopic.find(params[:id]) end + # Temporary compatibility shim for legacy frontend naming. + # Remove once the frontend consistently submits `sign_up_topic`. + def topic_payload + params[:sign_up_topic] || params[:project_topic] || {} + end + # Only allow a list of trusted parameters through. def sign_up_topic_params - params.require(:sign_up_topic).permit(:topic_identifier, :category, :topic_name, :max_choosers, :assignment_id) + ActionController::Parameters + .new(topic_payload) + .permit(:topic_identifier, :category, :topic_name, :max_choosers, :assignment_id, :description, :link) end end From 7e1e407d9512bb56bb909aa2e2a77eb9269abb61 Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Sat, 21 Mar 2026 16:48:34 -0500 Subject: [PATCH 33/80] fixing implementation of the sign_up_topics and project_topics compatibility code. Previously was prioritizing sign_up_topics info being received from frontend. Replaced this to prioritize project_topics info since that is reality atm. --- app/controllers/sign_up_topics_controller.rb | 31 ++++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/app/controllers/sign_up_topics_controller.rb b/app/controllers/sign_up_topics_controller.rb index 0af0604a7..c07ebe03c 100644 --- a/app/controllers/sign_up_topics_controller.rb +++ b/app/controllers/sign_up_topics_controller.rb @@ -22,8 +22,14 @@ def index # The method takes inputs and outputs the if the topic creation was successful. def create @sign_up_topic = SignUpTopic.new(sign_up_topic_params) - @assignment = Assignment.find(topic_payload[:assignment_id]) - @sign_up_topic.micropayment = params[:micropayment] if @assignment.microtask? + + @assignment = Assignment.find_by(id: @sign_up_topic.assignment_id) + if @sign_up_topic.assignment_id.blank? || @assignment.nil? + render json: { message: 'Assignment ID is invalid or missing.' }, status: :unprocessable_entity + return + end + + @sign_up_topic.micropayment = micropayment_value if @assignment.microtask? if @sign_up_topic.save # undo_link "The topic: \"#{@sign_up_topic.topic_name}\" has been created successfully. " render json: { message: "The topic: \"#{@sign_up_topic.topic_name}\" has been created successfully. " }, status: :created @@ -35,6 +41,9 @@ def create # PATCH/PUT /sign_up_topics/1 # updates parameters present in sign_up_topic_params. def update + assignment = Assignment.find_by(id: @sign_up_topic.assignment_id) + @sign_up_topic.micropayment = micropayment_value if assignment&.microtask? + if @sign_up_topic.update(sign_up_topic_params) render json: { message: "The topic: \"#{@sign_up_topic.topic_name}\" has been updated successfully. " }, status: 200 else @@ -80,13 +89,23 @@ def set_sign_up_topic # Temporary compatibility shim for legacy frontend naming. # Remove once the frontend consistently submits `sign_up_topic`. def topic_payload - params[:sign_up_topic] || params[:project_topic] || {} + params[:project_topic] || params[:sign_up_topic] || ActionController::Parameters.new + end + + def micropayment_value + topic_payload[:micropayment] || params[:micropayment] end # Only allow a list of trusted parameters through. def sign_up_topic_params - ActionController::Parameters - .new(topic_payload) - .permit(:topic_identifier, :category, :topic_name, :max_choosers, :assignment_id, :description, :link) + topic_payload.permit( + :topic_identifier, + :category, + :topic_name, + :max_choosers, + :assignment_id, + :description, + :link + ) end end From dbc6d21a09af5eb2cdbcb6a2250bd869a922ac50 Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Sun, 22 Mar 2026 08:50:50 -0500 Subject: [PATCH 34/80] Updating user_serializer with more attributes and fields to return. This should fix editing of users and allow them to be manually placed into teams on the frontend. --- app/serializers/user_serializer.rb | 35 +++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb index 287456b04..3204221a4 100644 --- a/app/serializers/user_serializer.rb +++ b/app/serializers/user_serializer.rb @@ -1,5 +1,38 @@ # frozen_string_literal: true class UserSerializer < ActiveModel::Serializer - attributes :id, :name, :email, :full_name + 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 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 date_format_pref + nil + end end From 9c86eda48d609e98395a640b75c58bf2e074b01b Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Sun, 22 Mar 2026 10:46:27 -0500 Subject: [PATCH 35/80] Added support for default values for parent and institution IDs. Updated import service and controller to handle new defaults, and adjust tests to verify correct behavior with role and institution names. --- app/controllers/import_controller.rb | 12 +++- app/helpers/importable_exportable_helper.rb | 12 +++- app/services/import.rb | 6 +- spec/integration/import_controller_spec.rb | 3 +- spec/requests/import_export_entities_spec.rb | 66 +++++++++++++++++++- 5 files changed, 90 insertions(+), 9 deletions(-) diff --git a/app/controllers/import_controller.rb b/app/controllers/import_controller.rb index fb7ad4357..cd38037fd 100644 --- a/app/controllers/import_controller.rb +++ b/app/controllers/import_controller.rb @@ -56,13 +56,14 @@ def import # Dynamically load the model class (e.g., "User", "Team", etc.) klass = params[:class].constantize + defaults = import_defaults_for(klass) # Load the chosen duplicate action (Skip, Update, Change) dup_action = params[:dup_action].constantize pp dup_action - importService = Import.new(klass: klass, file: uploaded_file, headers: ordered_fields, dup_action: dup_action.new) + importService = Import.new(klass: klass, file: uploaded_file, headers: ordered_fields, dup_action: dup_action.new, defaults: defaults) result = importService.perform(use_headers) # If no exceptions occur, return success @@ -83,4 +84,13 @@ def import def import_params params.permit(:csv_file, :use_headers, :class, :ordered_fields, :dup_action) end + + def import_defaults_for(klass) + return {} unless klass == User && current_user.present? + + { + parent_id: current_user.id, + institution_id: current_user.institution_id + } + end end diff --git a/app/helpers/importable_exportable_helper.rb b/app/helpers/importable_exportable_helper.rb index 00ed71c42..f7ee89377 100644 --- a/app/helpers/importable_exportable_helper.rb +++ b/app/helpers/importable_exportable_helper.rb @@ -253,7 +253,7 @@ def to_hash(fields = self.class.internal_fields) # # Duplicate objects are collected and returned. # -------------------------------------------------------------- - def try_import_records(file, headers, use_header) + def try_import_records(file, headers, use_header, defaults = {}) temp_file = 'output.csv' csv_file = CSV.read(file) @@ -280,7 +280,7 @@ def try_import_records(file, headers, use_header) ActiveRecord::Base.transaction do temp_contents.each do |row| - dup = import_row(row, mapping) + dup = import_row(row, mapping, defaults) duplicate_records << dup if dup && dup != true end @@ -305,7 +305,7 @@ def try_import_records(file, headers, use_header) # • true if saved successfully # • duplicate object if duplicate occurred # -------------------------------------------------------------- - def import_row(row, mapping) + def import_row(row, mapping, defaults = {}) # Build row_hash where each key maps to all found values row_hash = {} @@ -317,6 +317,12 @@ def import_row(row, mapping) # 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| diff --git a/app/services/import.rb b/app/services/import.rb index 639951835..94178693f 100644 --- a/app/services/import.rb +++ b/app/services/import.rb @@ -31,11 +31,12 @@ class Import # @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) + 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 # -------------------------------------------------------------- @@ -60,7 +61,8 @@ def perform(use_headers) dups = @klass.try_import_records( @file, @headers, - use_headers + use_headers, + @defaults ) dups.each {|dup| duplicate_groups << normalize_duplicate(dup)} diff --git a/spec/integration/import_controller_spec.rb b/spec/integration/import_controller_spec.rb index bae8d9958..3292caa17 100644 --- a/spec/integration/import_controller_spec.rb +++ b/spec/integration/import_controller_spec.rb @@ -130,7 +130,8 @@ def self.try_import_records(*args); end .with( kind_of(ActionDispatch::Http::UploadedFile), ["id"], - use_header: false + false, + {} ) end diff --git a/spec/requests/import_export_entities_spec.rb b/spec/requests/import_export_entities_spec.rb index a485739b4..ca99a05ab 100644 --- a/spec/requests/import_export_entities_spec.rb +++ b/spec/requests/import_export_entities_spec.rb @@ -45,13 +45,30 @@ def uploaded_csv(contents) %w[SkipRecordAction UpdateExistingRecordAction ChangeOffendingFieldAction] ) 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 end describe "POST /import/:class" do - let!(:role) 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 @@ -62,11 +79,17 @@ def uploaded_csv(contents) full_name: "Teacher Example", email: "teacher@example.com", password: "password", - role: role, + 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", @@ -101,6 +124,45 @@ def uploaded_csv(contents) expect(response).to have_http_status(:created) expect(SignUpTopic.find_by(topic_name: "Topic A", assignment_id: assignment.id)).to be_present end + + 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 describe "POST /export/:class" do From 37b002bb4b16848fa6e370bf3578acdd0a76c1ec Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Sun, 22 Mar 2026 10:58:47 -0500 Subject: [PATCH 36/80] renamed request spec for import and export of users/teams/topics to better match it's purpose. --- spec/requests/import_export_entities_spec.rb | 230 ------------------ spec/requests/import_export_requests_spec.rb | 242 +++++++++++++++++++ 2 files changed, 242 insertions(+), 230 deletions(-) delete mode 100644 spec/requests/import_export_entities_spec.rb create mode 100644 spec/requests/import_export_requests_spec.rb diff --git a/spec/requests/import_export_entities_spec.rb b/spec/requests/import_export_entities_spec.rb deleted file mode 100644 index ca99a05ab..000000000 --- a/spec/requests/import_export_entities_spec.rb +++ /dev/null @@ -1,230 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" -require "tempfile" - -RSpec.describe "Import/export entities", 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 - it "returns metadata for Team" do - get "/import/Team" - - expect(response).to have_http_status(:ok) - - json = JSON.parse(response.body) - expect(json["mandatory_fields"]).to include("name", "type", "parent_id") - expect(json["available_actions_on_dup"]).to match_array( - %w[SkipRecordAction UpdateExistingRecordAction ChangeOffendingFieldAction] - ) - end - - it "returns metadata for SignUpTopic" do - get "/import/SignUpTopic" - - expect(response).to have_http_status(:ok) - - json = JSON.parse(response.body) - expect(json["mandatory_fields"]).to include("topic_name", "assignment_id") - expect(json["available_actions_on_dup"]).to match_array( - %w[SkipRecordAction UpdateExistingRecordAction ChangeOffendingFieldAction] - ) - 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 - 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 - - it "imports teams" do - file = uploaded_csv("name,parent_id,type\nTeam Alpha,#{assignment.id},AssignmentTeam\n") - - post "/import/Team", - params: { - csv_file: file, - use_headers: true, - dup_action: "SkipRecordAction" - } - - expect(response).to have_http_status(:created) - expect(AssignmentTeam.find_by(name: "Team Alpha", parent_id: assignment.id)).to be_present - end - - it "imports topics" do - file = uploaded_csv("topic_name,assignment_id\nTopic A,#{assignment.id}\n") - - post "/import/SignUpTopic", - params: { - csv_file: file, - use_headers: true, - dup_action: "SkipRecordAction" - } - - expect(response).to have_http_status(:created) - expect(SignUpTopic.find_by(topic_name: "Topic A", assignment_id: assignment.id)).to be_present - end - - 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 - - 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 - SignUpTopic.create!( - topic_name: "Export Topic", - assignment_id: assignment.id - ) - end - - it "exports teams" do - post "/export/Team", params: { ordered_fields: %w[name parent_id type].to_json } - - expect(response).to have_http_status(:ok) - - json = JSON.parse(response.body) - expect(json["file"]).to include("name,parent_id,type") - expect(json["file"]).to include("Export Team") - end - - it "exports topics" do - post "/export/SignUpTopic", params: { ordered_fields: %w[topic_name assignment_id].to_json } - - expect(response).to have_http_status(:ok) - - json = JSON.parse(response.body) - expect(json["file"]).to include("topic_name,assignment_id") - expect(json["file"]).to include("Export Topic") - end - end -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..f0dc7e57a --- /dev/null +++ b/spec/requests/import_export_requests_spec.rb @@ -0,0 +1,242 @@ +# frozen_string_literal: true + +require "rails_helper" +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" + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + expect(json["mandatory_fields"]).to include("name", "type", "parent_id") + expect(json["available_actions_on_dup"]).to match_array( + %w[SkipRecordAction UpdateExistingRecordAction ChangeOffendingFieldAction] + ) + end + + it "returns metadata for SignUpTopic" do + get "/import/SignUpTopic" + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + expect(json["mandatory_fields"]).to include("topic_name", "assignment_id") + expect(json["available_actions_on_dup"]).to match_array( + %w[SkipRecordAction UpdateExistingRecordAction ChangeOffendingFieldAction] + ) + 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 + 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 + + context "team imports" do + it "imports teams" do + file = uploaded_csv("name,parent_id,type\nTeam Alpha,#{assignment.id},AssignmentTeam\n") + + post "/import/Team", + params: { + csv_file: file, + use_headers: true, + dup_action: "SkipRecordAction" + } + + expect(response).to have_http_status(:created) + expect(AssignmentTeam.find_by(name: "Team Alpha", parent_id: assignment.id)).to be_present + end + end + + context "topic imports" do + it "imports topics" do + file = uploaded_csv("topic_name,assignment_id\nTopic A,#{assignment.id}\n") + + post "/import/SignUpTopic", + params: { + csv_file: file, + use_headers: true, + dup_action: "SkipRecordAction" + } + + expect(response).to have_http_status(:created) + expect(SignUpTopic.find_by(topic_name: "Topic A", assignment_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 + 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 + SignUpTopic.create!( + topic_name: "Export Topic", + assignment_id: assignment.id + ) + end + + context "team exports" do + it "exports teams" do + post "/export/Team", params: { ordered_fields: %w[name parent_id type].to_json } + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + expect(json["file"]).to include("name,parent_id,type") + expect(json["file"]).to include("Export Team") + end + end + + context "topic exports" do + it "exports topics" do + post "/export/SignUpTopic", params: { ordered_fields: %w[topic_name assignment_id].to_json } + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + expect(json["file"]).to include("topic_name,assignment_id") + expect(json["file"]).to include("Export Topic") + end + end + end +end From 875fc04d1f93a6d8a9c1e77fc1b2d50ae8d5ca9c Mon Sep 17 00:00:00 2001 From: Mihir Kamat Date: Wed, 25 Mar 2026 21:41:19 -0400 Subject: [PATCH 37/80] Simplify graph export --- app/helpers/export_helper.rb | 154 ++++++++------------- app/models/Item.rb | 4 +- app/models/questionnaire.rb | 2 +- spec/helpers/export_helper_spec.rb | 209 ++++++++++++++++++++++------- 4 files changed, 219 insertions(+), 150 deletions(-) diff --git a/app/helpers/export_helper.rb b/app/helpers/export_helper.rb index 9050ff424..a7ac3085d 100644 --- a/app/helpers/export_helper.rb +++ b/app/helpers/export_helper.rb @@ -5,26 +5,16 @@ module ExportHelper module_function - # Builds a recursive graph of a class and related associations. - # The graph uses a single has_many list and can include inferred inverse - # has_many edges from belongs_to declarations in other models. - # Also runs Export.perform for each unique class in that graph. + # Builds a minimal recursive graph with only class names and headers, + # then runs Export.perform for each unique class in that graph. def export_has_many_graph(root_class) - graph = build_has_many_graph(root_class) - headers_by_class = {} - - each_graph_node(graph) do |node| - klass = node[:class_name].constantize - headers = mandatory_headers_for(klass) - - # Ensure child exports include mandatory fields used to link child back to parent. - if node[:parent_external_relation] - headers |= node[:parent_external_relation][:fields] - end + graph = build_export_graph(root_class) - headers_by_class[node[:class_name]] ||= [] - headers_by_class[node[:class_name]] |= headers - headers_by_class[node[:class_name]] = remove_identifier_fields(headers_by_class[node[:class_name]]) + headers_by_class = {} + each_graph_node_for_export(graph) do |node, headers_for_export| + class_name = node[:class_name] + headers_by_class[class_name] ||= [] + headers_by_class[class_name] |= headers_for_export end exports = {} @@ -35,16 +25,14 @@ def export_has_many_graph(root_class) { graph: graph, exports: exports } end - def build_has_many_graph(root_class, visited = Set.new, parent_class: nil) + def build_export_graph(root_class, visited = Set.new) klass = normalize_class(root_class) class_name = klass.name - relation_to_parent = external_relation_to_parent(klass, parent_class) - if visited.include?(class_name) return { class_name: class_name, - parent_external_relation: relation_to_parent, + headers: mandatory_headers_for(klass), cyclic_reference: true, has_many: [] } @@ -52,79 +40,86 @@ def build_has_many_graph(root_class, visited = Set.new, parent_class: nil) visited.add(class_name) - explicit_has_many_edges = has_many_edges_for(klass, visited) - inferred_from_belongs_to_edges = inferred_inverse_has_many_edges_for(klass, visited) - - { - class_name: class_name, - parent_external_relation: relation_to_parent, - has_many: dedupe_edges(explicit_has_many_edges + inferred_from_belongs_to_edges) - } - end + children = [] - def has_many_edges_for(klass, visited) - klass.reflect_on_all_associations(:has_many).filter_map do |association| + klass.reflect_on_all_associations(:has_many).each do |association| begin - associated_klass = association.klass + child_klass = association.klass rescue StandardError next end - { - association: association.name.to_s, - association_type: 'has_many', - graph: build_has_many_graph(associated_klass, visited, parent_class: klass) - } + children << build_export_graph(child_klass, visited) + end + + descendants_with_belongs_to_parent(klass).each do |child_klass| + children << build_export_graph(child_klass, visited) end + + { + class_name: class_name, + headers: mandatory_headers_for(klass), + has_many: dedupe_children(children) + } end - private_class_method :has_many_edges_for - # Treat belongs_to as the inverse of has_many: - # if ModelX belongs_to klass, include ModelX as a child node of klass. - def inferred_inverse_has_many_edges_for(klass, visited) + def descendants_with_belongs_to_parent(parent_klass) descendants = ActiveRecord::Base.descendants.select { |model| model < ApplicationRecord } - descendants.filter_map do |candidate| - next if candidate == klass + descendants.select do |candidate| + next false if candidate == parent_klass - association = candidate.reflect_on_all_associations(:belongs_to).find do |belongs_to_association| + candidate.reflect_on_all_associations(:belongs_to).any? do |belongs_to_association| begin - belongs_to_association.klass == klass || klass <= belongs_to_association.klass + belongs_to_association.klass == parent_klass || parent_klass <= belongs_to_association.klass rescue StandardError false end end - - next unless association - - { - association: candidate.name.underscore.pluralize, - association_type: 'inferred_has_many_from_belongs_to', - inferred_from: association.name.to_s, - graph: build_has_many_graph(candidate, visited, parent_class: klass) - } end end - private_class_method :inferred_inverse_has_many_edges_for + private_class_method :descendants_with_belongs_to_parent - def dedupe_edges(edges) - edges.uniq { |edge| [edge[:association], edge[:graph][:class_name]] } + def dedupe_children(children) + children.uniq { |child| child[:class_name] } end - private_class_method :dedupe_edges + private_class_method :dedupe_children def each_graph_node(graph, seen = Set.new, &block) - signature = [graph[:class_name], graph[:parent_external_relation]&.dig(:ref_class)] - return if seen.include?(signature) + return if seen.include?(graph[:class_name]) - seen.add(signature) + seen.add(graph[:class_name]) block.call(graph) graph[:has_many].each do |child| - each_graph_node(child[:graph], seen, &block) + each_graph_node(child, seen, &block) end end private_class_method :each_graph_node + def each_graph_node_for_export(graph, inherited_headers = [], seen = Set.new, &block) + return if seen.include?(graph[:class_name]) + + seen.add(graph[:class_name]) + headers_for_export = remove_identifier_fields(Array(graph[:headers]) + Array(inherited_headers)) + block.call(graph, headers_for_export) + + prefixed_parent_headers = prefix_headers_with_class_name(graph[:headers], graph[:class_name]) + child_inherited_headers = remove_identifier_fields(Array(inherited_headers) + Array(prefixed_parent_headers)) + + graph[:has_many].each do |child| + each_graph_node_for_export(child, child_inherited_headers, seen, &block) + end + end + private_class_method :each_graph_node_for_export + + def prefix_headers_with_class_name(headers, class_name) + klass = class_name.is_a?(Class) ? class_name : class_name.constantize + prefix = klass.name.underscore + Array(headers).map { |header| "#{prefix}_#{header}" } + end + private_class_method :prefix_headers_with_class_name + def mandatory_headers_for(klass) if klass.respond_to?(:mandatory_fields) mandatory = Array(klass.mandatory_fields).map(&:to_s) @@ -141,37 +136,6 @@ def mandatory_headers_for(klass) end private_class_method :mandatory_headers_for - def external_relation_to_parent(klass, parent_class) - return nil unless parent_class && klass.respond_to?(:external_classes) - - ext = klass.external_classes&.find do |external_class| - next false unless external_class.respond_to?(:ref_class) - - external_class.ref_class == parent_class || parent_class <= external_class.ref_class - end - - return nil unless ext - - { - ref_class: ext.ref_class.name, - should_look_up: ext.should_look_up, - should_create: ext.should_create, - look_up_field: ext.instance_variable_get(:@look_up_field)&.to_s, - fields: external_mandatory_fields(ext) - } - end - private_class_method :external_relation_to_parent - - def external_mandatory_fields(external_class) - ref_class = external_class.ref_class - return [] unless ref_class.respond_to?(:mandatory_fields) - - mandatory = Array(ref_class.mandatory_fields).map(&:to_s) - namespaced = mandatory.map { |field| ExternalClass.append_class_name(ref_class, field) } - remove_identifier_fields(namespaced) - end - private_class_method :external_mandatory_fields - def remove_identifier_fields(fields) Array(fields).map(&:to_s).uniq.reject { |field| field == 'id' || field.end_with?('_id') } end diff --git a/app/models/Item.rb b/app/models/Item.rb index 7e5eab28d..44570389f 100644 --- a/app/models/Item.rb +++ b/app/models/Item.rb @@ -3,9 +3,7 @@ class Item < ApplicationRecord extend ImportableExportableHelper mandatory_fields :txt, :weight, :seq, :question_type, :break_before - external_classes ExternalClass.new(Questionnaire, true, false, :name), - ExternalClass.new(QuestionAdvice, false, true) - + external_classes ExternalClass.new(Questionnaire, true, false, :name) before_create :set_seq belongs_to :questionnaire # each item belongs to a specific questionnaire diff --git a/app/models/questionnaire.rb b/app/models/questionnaire.rb index 9a84d2ee9..d74ca699c 100644 --- a/app/models/questionnaire.rb +++ b/app/models/questionnaire.rb @@ -3,7 +3,7 @@ class Questionnaire < ApplicationRecord extend ImportableExportableHelper mandatory_fields :name, :min_question_score, :max_question_score, :questionnaire_type, :display_type, :instruction_loc - external_classes ExternalClass.new(User, true, false, :name) + external_classes ExternalClass.new(Instructor, true, false, :name) available_actions_on_duplicate SkipRecordAction.new, UpdateExistingRecordAction.new, ChangeOffendingFieldAction.new belongs_to :instructor diff --git a/spec/helpers/export_helper_spec.rb b/spec/helpers/export_helper_spec.rb index ee60e0086..ac7714907 100644 --- a/spec/helpers/export_helper_spec.rb +++ b/spec/helpers/export_helper_spec.rb @@ -5,73 +5,180 @@ require 'json' RSpec.describe ExportHelper, type: :helper do - describe '.build_has_many_graph' do - it 'captures ExternalClass relation and infers question_advices from belongs_to :item' do - graph = described_class.build_has_many_graph(Questionnaire) - - puts "\nExport Graph:" - puts JSON.pretty_generate(graph) + describe '.export_has_many_graph' do + it 'returns a minimal class/header graph and exports real db records' do + role = create(:role, :instructor) + institution = create(:institution) + instructor = Instructor.create!( + name: 'exportinstructor', + email: 'exportinstructor@example.com', + full_name: 'Export Instructor', + password: 'password', + role: role, + institution: institution + ) + + questionnaire_record = Questionnaire.create!( + name: 'Graph Export Questionnaire', + instructor: instructor, + private: false, + min_question_score: 0, + max_question_score: 10, + questionnaire_type: 'ReviewQuestionnaire', + display_type: 'Likert', + instruction_loc: 'instructions' + ) + + item_record = Item.create!( + questionnaire: questionnaire_record, + txt: 'How clear was the design?', + weight: 5, + seq: 1, + question_type: 'Scale', + break_before: true + ) + + advice_record = QuestionAdvice.create!( + item: item_record, + score: 4, + advice: 'Add concrete examples to improve clarity.' + ) + + assignment_record = create(:assignment, instructor: instructor) + reviewer_participant = create(:assignment_participant, assignment: assignment_record) + reviewee_participant = create(:assignment_participant, assignment: assignment_record) + + response_map_record = ResponseMap.create!( + reviewer_id: reviewer_participant.id, + reviewee_id: reviewee_participant.id, + reviewed_object_id: assignment_record.id + ) + + response_record = Response.create!( + map_id: response_map_record.id, + additional_comment: 'response comment' + ) + + answer_record = Answer.create!( + item: item_record, + response: response_record, + answer: 3, + comments: 'Strong rationale.' + ) + + questionnaire_external = Item.external_classes.find { |ext| ext.ref_class == Questionnaire } + allow(Item).to receive(:external_classes).and_return([questionnaire_external].compact) - expect(graph[:class_name]).to eq('Questionnaire') + result = described_class.export_has_many_graph(Questionnaire) - items_node = graph[:has_many].find { |node| node[:association] == 'items' } - expect(items_node).to be_present - expect(items_node[:association_type]).to eq('has_many') + puts "\nExport Graph (from export_has_many_graph):" + puts JSON.pretty_generate(result[:graph]) - item_graph = items_node[:graph] - relation = item_graph[:parent_external_relation] + expect(result).to have_key(:graph) + expect(result[:graph]).to be_a(Hash) + expect(result[:graph][:class_name]).to eq('Questionnaire') + expect(result[:graph][:headers]).to match_array(Questionnaire.mandatory_fields.map(&:to_s)) + + class_names = [] + stack = [result[:graph]] + until stack.empty? + node = stack.pop + class_names << node[:class_name] + stack.concat(node[:has_many] || []) + end - expect(item_graph[:class_name]).to eq('Item') - expect(relation).to be_present - expect(relation[:ref_class]).to eq('Questionnaire') - expect(relation[:fields]).to include('questionnaire_name') + expect(class_names).to include('Item') + expect(class_names).to include('QuestionAdvice') + expect(class_names).to include('Answer') - question_advices_node = item_graph[:has_many].find { |node| node[:association] == 'question_advices' } - expect(question_advices_node).to be_present - expect(question_advices_node[:association_type]).to eq('inferred_has_many_from_belongs_to') - expect(question_advices_node[:inferred_from]).to eq('item') - expect(question_advices_node[:graph][:class_name]).to eq('QuestionAdvice') + expect(result).to have_key(:exports) + expect(result[:exports]).to include('Questionnaire', 'Item', 'QuestionAdvice', 'Answer') + + questionnaire_rows = CSV.parse(result[:exports]['Questionnaire'], headers: true) + item_rows = CSV.parse(result[:exports]['Item'], headers: true) + advice_rows = CSV.parse(result[:exports]['QuestionAdvice'], headers: true) + answer_rows = CSV.parse(result[:exports]['Answer'], headers: true) + + expect(questionnaire_rows.map { |row| row['name'] }).to include(questionnaire_record.name) + expect(item_rows.map { |row| row['txt'] }).to include(item_record.txt) + expect(advice_rows.map { |row| row['advice'] }).to include(advice_record.advice) + expect(answer_rows.map { |row| row['comments'] }).to include(answer_record.comments) + expect(answer_rows.map { |row| row['answer'] }).to include(answer_record.answer.to_s) end - end - describe '.export_has_many_graph' do - it 'uses mandatory fields and includes inferred QuestionAdvice export' do - calls = {} - - allow(Export).to receive(:perform) do |klass, headers| - calls[klass.name] = headers - CSV.generate do |csv| - csv << headers - csv << headers.map { |header| "#{klass.name.downcase}_#{header}" } - end - end + it 'exports csv and prints each class csv output' do + role = create(:role, :instructor) + institution = create(:institution) + instructor = Instructor.create!( + name: 'csvprintinstructor', + email: 'csvprintinstructor@example.com', + full_name: 'CSV Print Instructor', + password: 'password', + role: role, + institution: institution + ) + + questionnaire_record = Questionnaire.create!( + name: 'CSV Print Questionnaire', + instructor: instructor, + private: false, + min_question_score: 0, + max_question_score: 10, + questionnaire_type: 'ReviewQuestionnaire', + display_type: 'Likert', + instruction_loc: 'instructions' + ) + + item_record = Item.create!( + questionnaire: questionnaire_record, + txt: 'What should improve?', + weight: 2, + seq: 1, + question_type: 'Scale', + break_before: true + ) + + QuestionAdvice.create!( + item: item_record, + score: 2, + advice: 'Consider edge cases.' + ) + + assignment_record = create(:assignment, instructor: instructor) + reviewer_participant = create(:assignment_participant, assignment: assignment_record) + reviewee_participant = create(:assignment_participant, assignment: assignment_record) + response_map_record = ResponseMap.create!( + reviewer_id: reviewer_participant.id, + reviewee_id: reviewee_participant.id, + reviewed_object_id: assignment_record.id + ) + response_record = Response.create!( + map_id: response_map_record.id, + additional_comment: 'print response comment' + ) + Answer.create!( + item: item_record, + response: response_record, + answer: 1, + comments: 'Needs work.' + ) + + questionnaire_external = Item.external_classes.find { |ext| ext.ref_class == Questionnaire } + allow(Item).to receive(:external_classes).and_return([questionnaire_external].compact) result = described_class.export_has_many_graph(Questionnaire) - puts "\nExport Graph (from export_has_many_graph):" - puts JSON.pretty_generate(result[:graph]) - puts "\nCSV Exports:" result[:exports].each do |klass_name, csv_text| puts "--- #{klass_name} ---" puts csv_text end - expect(result).to have_key(:graph) - expect(result).to have_key(:exports) - expect(result[:exports]).to include('Questionnaire') - expect(result[:exports]).to include('Item') - expect(result[:exports]).to include('QuestionAdvice') - - expect(Questionnaire.mandatory_fields - calls['Questionnaire']).to be_empty - expect(calls['Questionnaire']).not_to include('id') - - expect(Item.mandatory_fields - calls['Item']).to be_empty - expect(calls['Item']).to include('questionnaire_name') - expect(calls['Item']).not_to include('id') - - expect(QuestionAdvice.mandatory_fields - calls['QuestionAdvice']).to be_empty - expect(calls['QuestionAdvice']).not_to include('id') + expect(result[:exports]).to include('Questionnaire', 'Item', 'QuestionAdvice', 'Answer') + expect(result[:exports]['Questionnaire']).to include('name') + expect(result[:exports]['Item']).to include('txt') + expect(result[:exports]['QuestionAdvice']).to include('advice') + expect(result[:exports]['Answer']).to include('comments') end end end From cc02b8fb1c105833252822e1156a11baa9a98d84 Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Sat, 28 Mar 2026 11:55:06 -0500 Subject: [PATCH 38/80] Remove unrelated schema and Dockerfile diffs --- Dockerfile | 2 +- db/schema.rb | 62 ++++++++++++++++++---------------------------------- 2 files changed, 22 insertions(+), 42 deletions(-) diff --git a/Dockerfile b/Dockerfile index 687c70771..ff57d5dd5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,4 +21,4 @@ RUN bundle install EXPOSE 3002 # Set the entry point -ENTRYPOINT ["/app/setup.sh"] \ No newline at end of file +ENTRYPOINT ["/app/setup.sh"] diff --git a/db/schema.rb b/db/schema.rb index 6dc955e86..73da0b160 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_02_27_203258) do +ActiveRecord::Schema[8.0].define(version: 2025_11_29_040855) do create_table "account_requests", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "username" t.string "full_name" @@ -103,20 +103,10 @@ t.boolean "enable_pair_programming", default: false t.boolean "has_teams", default: false t.boolean "has_topics", default: false - t.boolean "vary_by_round", default: false, null: false t.index ["course_id"], name: "index_assignments_on_course_id" t.index ["instructor_id"], name: "index_assignments_on_instructor_id" end - create_table "assignments_duties", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.bigint "assignment_id", null: false - t.bigint "duty_id", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["assignment_id"], name: "index_assignments_duties_on_assignment_id" - t.index ["duty_id"], name: "index_assignments_duties_on_duty_id" - end - create_table "bookmark_ratings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.integer "bookmark_id" t.integer "user_id" @@ -135,6 +125,11 @@ 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" @@ -173,16 +168,6 @@ t.index ["parent_type", "parent_id"], name: "index_due_dates_on_parent" end - create_table "duties", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.string "name" - t.boolean "private", default: false - t.bigint "instructor_id", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.integer "max_members_for_duty" - t.index ["instructor_id"], name: "index_duties_on_instructor_id" - end - create_table "institutions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name" t.datetime "created_at", null: false @@ -191,14 +176,16 @@ 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.bigint "participant_id", null: false t.index ["assignment_id"], name: "fk_invitation_assignments" - t.index ["from_id"], name: "fk_invitationfrom_users" - t.index ["to_id"], name: "fk_invitationto_users" + t.index ["from_id"], name: "index_invitations_on_from_id" + t.index ["participant_id"], name: "index_invitations_on_participant_id" + t.index ["to_id"], name: "index_invitations_on_to_id" end create_table "items", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -254,8 +241,6 @@ t.integer "parent_id", null: false t.string "type", null: false t.float "grade" - t.bigint "duty_id" - t.index ["duty_id"], name: "index_participants_on_duty_id" t.index ["join_team_request_id"], name: "index_participants_on_join_team_request_id" t.index ["team_id"], name: "index_participants_on_team_id" t.index ["user_id"], name: "fk_participant_users" @@ -310,6 +295,7 @@ 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 @@ -350,13 +336,15 @@ end create_table "signed_up_teams", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.bigint "project_topic_id", null: false + t.bigint "sign_up_topic_id", null: false t.bigint "team_id", null: false t.boolean "is_waitlisted" t.integer "preference_priority_number" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["project_topic_id"], name: "index_signed_up_teams_on_project_topic_id" + t.text "comments_for_advertisement" + t.boolean "advertise_for_partner" + t.index ["sign_up_topic_id"], name: "index_signed_up_teams_on_sign_up_topic_id" t.index ["team_id"], name: "index_signed_up_teams_on_team_id" end @@ -372,16 +360,12 @@ create_table "teams", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name", null: false - t.integer "parent_id" t.string "type", null: false - t.text "submitted_hyperlinks" - t.integer "directory_num" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.integer "parent_id", 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| @@ -401,7 +385,6 @@ 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 @@ -437,21 +420,18 @@ add_foreign_key "account_requests", "roles" add_foreign_key "assignments", "courses" add_foreign_key "assignments", "users", column: "instructor_id" - add_foreign_key "assignments_duties", "assignments" - add_foreign_key "assignments_duties", "duties" add_foreign_key "courses", "institutions" add_foreign_key "courses", "users", column: "instructor_id" - add_foreign_key "duties", "users", column: "instructor_id" - add_foreign_key "invitations", "teams", column: "from_id" + add_foreign_key "invitations", "participants", column: "from_id" + add_foreign_key "invitations", "participants", column: "to_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 "question_advices", "items" add_foreign_key "roles", "roles", column: "parent_id", on_delete: :cascade add_foreign_key "sign_up_topics", "assignments" - add_foreign_key "signed_up_teams", "sign_up_topics", column: "project_topic_id" + add_foreign_key "signed_up_teams", "sign_up_topics" add_foreign_key "signed_up_teams", "teams" add_foreign_key "ta_mappings", "courses" add_foreign_key "ta_mappings", "users" From 32e4c32a3fc990c85347cdea019ec34dd78593d4 Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Sat, 28 Mar 2026 14:11:25 -0500 Subject: [PATCH 39/80] Remove unrelated migration and schema diffs --- Dockerfile | 2 +- ...40855_rename_item_id_in_question_tables.rb | 15 +---- db/schema.rb | 62 +++++++------------ 3 files changed, 25 insertions(+), 54 deletions(-) diff --git a/Dockerfile b/Dockerfile index 687c70771..ff57d5dd5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,4 +21,4 @@ RUN bundle install EXPOSE 3002 # Set the entry point -ENTRYPOINT ["/app/setup.sh"] \ No newline at end of file +ENTRYPOINT ["/app/setup.sh"] diff --git a/db/migrate/20251129040855_rename_item_id_in_question_tables.rb b/db/migrate/20251129040855_rename_item_id_in_question_tables.rb index e9d528b6f..ec37dc86a 100644 --- a/db/migrate/20251129040855_rename_item_id_in_question_tables.rb +++ b/db/migrate/20251129040855_rename_item_id_in_question_tables.rb @@ -1,16 +1,7 @@ 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 + rename_column :answers, :question_id, :item_id + rename_column :question_advices, :question_id, :item_id + rename_column :quiz_question_choices, :question_id, :item_id end end diff --git a/db/schema.rb b/db/schema.rb index 6dc955e86..73da0b160 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_02_27_203258) do +ActiveRecord::Schema[8.0].define(version: 2025_11_29_040855) do create_table "account_requests", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "username" t.string "full_name" @@ -103,20 +103,10 @@ t.boolean "enable_pair_programming", default: false t.boolean "has_teams", default: false t.boolean "has_topics", default: false - t.boolean "vary_by_round", default: false, null: false t.index ["course_id"], name: "index_assignments_on_course_id" t.index ["instructor_id"], name: "index_assignments_on_instructor_id" end - create_table "assignments_duties", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.bigint "assignment_id", null: false - t.bigint "duty_id", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["assignment_id"], name: "index_assignments_duties_on_assignment_id" - t.index ["duty_id"], name: "index_assignments_duties_on_duty_id" - end - create_table "bookmark_ratings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.integer "bookmark_id" t.integer "user_id" @@ -135,6 +125,11 @@ 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" @@ -173,16 +168,6 @@ t.index ["parent_type", "parent_id"], name: "index_due_dates_on_parent" end - create_table "duties", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.string "name" - t.boolean "private", default: false - t.bigint "instructor_id", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.integer "max_members_for_duty" - t.index ["instructor_id"], name: "index_duties_on_instructor_id" - end - create_table "institutions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name" t.datetime "created_at", null: false @@ -191,14 +176,16 @@ 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.bigint "participant_id", null: false t.index ["assignment_id"], name: "fk_invitation_assignments" - t.index ["from_id"], name: "fk_invitationfrom_users" - t.index ["to_id"], name: "fk_invitationto_users" + t.index ["from_id"], name: "index_invitations_on_from_id" + t.index ["participant_id"], name: "index_invitations_on_participant_id" + t.index ["to_id"], name: "index_invitations_on_to_id" end create_table "items", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -254,8 +241,6 @@ t.integer "parent_id", null: false t.string "type", null: false t.float "grade" - t.bigint "duty_id" - t.index ["duty_id"], name: "index_participants_on_duty_id" t.index ["join_team_request_id"], name: "index_participants_on_join_team_request_id" t.index ["team_id"], name: "index_participants_on_team_id" t.index ["user_id"], name: "fk_participant_users" @@ -310,6 +295,7 @@ 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 @@ -350,13 +336,15 @@ end create_table "signed_up_teams", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.bigint "project_topic_id", null: false + t.bigint "sign_up_topic_id", null: false t.bigint "team_id", null: false t.boolean "is_waitlisted" t.integer "preference_priority_number" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["project_topic_id"], name: "index_signed_up_teams_on_project_topic_id" + t.text "comments_for_advertisement" + t.boolean "advertise_for_partner" + t.index ["sign_up_topic_id"], name: "index_signed_up_teams_on_sign_up_topic_id" t.index ["team_id"], name: "index_signed_up_teams_on_team_id" end @@ -372,16 +360,12 @@ create_table "teams", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name", null: false - t.integer "parent_id" t.string "type", null: false - t.text "submitted_hyperlinks" - t.integer "directory_num" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.integer "parent_id", 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| @@ -401,7 +385,6 @@ 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 @@ -437,21 +420,18 @@ add_foreign_key "account_requests", "roles" add_foreign_key "assignments", "courses" add_foreign_key "assignments", "users", column: "instructor_id" - add_foreign_key "assignments_duties", "assignments" - add_foreign_key "assignments_duties", "duties" add_foreign_key "courses", "institutions" add_foreign_key "courses", "users", column: "instructor_id" - add_foreign_key "duties", "users", column: "instructor_id" - add_foreign_key "invitations", "teams", column: "from_id" + add_foreign_key "invitations", "participants", column: "from_id" + add_foreign_key "invitations", "participants", column: "to_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 "question_advices", "items" add_foreign_key "roles", "roles", column: "parent_id", on_delete: :cascade add_foreign_key "sign_up_topics", "assignments" - add_foreign_key "signed_up_teams", "sign_up_topics", column: "project_topic_id" + add_foreign_key "signed_up_teams", "sign_up_topics" add_foreign_key "signed_up_teams", "teams" add_foreign_key "ta_mappings", "courses" add_foreign_key "ta_mappings", "users" From 91e42d518dc6b94d26a86a542e2fd73465b1fa9d Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Sat, 28 Mar 2026 14:38:40 -0500 Subject: [PATCH 40/80] Fix import duplicate action fallback and topic update assignment lookup --- app/controllers/import_controller.rb | 4 ++-- app/controllers/sign_up_topics_controller.rb | 3 ++- spec/integration/import_controller_spec.rb | 5 +++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/app/controllers/import_controller.rb b/app/controllers/import_controller.rb index cd38037fd..7d0299086 100644 --- a/app/controllers/import_controller.rb +++ b/app/controllers/import_controller.rb @@ -59,11 +59,11 @@ def import defaults = import_defaults_for(klass) # Load the chosen duplicate action (Skip, Update, Change) - dup_action = params[:dup_action].constantize + dup_action = params[:dup_action]&.constantize pp dup_action - importService = Import.new(klass: klass, file: uploaded_file, headers: ordered_fields, dup_action: dup_action.new, defaults: defaults) + importService = Import.new(klass: klass, file: uploaded_file, headers: ordered_fields, dup_action: dup_action&.new, defaults: defaults) result = importService.perform(use_headers) # If no exceptions occur, return success diff --git a/app/controllers/sign_up_topics_controller.rb b/app/controllers/sign_up_topics_controller.rb index c07ebe03c..61ed32304 100644 --- a/app/controllers/sign_up_topics_controller.rb +++ b/app/controllers/sign_up_topics_controller.rb @@ -41,7 +41,8 @@ def create # PATCH/PUT /sign_up_topics/1 # updates parameters present in sign_up_topic_params. def update - assignment = Assignment.find_by(id: @sign_up_topic.assignment_id) + assignment_id = sign_up_topic_params[:assignment_id] || @sign_up_topic.assignment_id + assignment = Assignment.find_by(id: assignment_id) @sign_up_topic.micropayment = micropayment_value if assignment&.microtask? if @sign_up_topic.update(sign_up_topic_params) diff --git a/spec/integration/import_controller_spec.rb b/spec/integration/import_controller_spec.rb index 3292caa17..151e18323 100644 --- a/spec/integration/import_controller_spec.rb +++ b/spec/integration/import_controller_spec.rb @@ -22,7 +22,7 @@ before do stub_const("FakeModel", Class.new do class << self - attr_accessor :mandatory_fields, :optional_fields, :external_fields + attr_accessor :mandatory_fields, :optional_fields, :external_fields, :available_actions_on_duplicate end def self.try_import_records(*args); end @@ -31,7 +31,8 @@ def self.try_import_records(*args); 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(:try_import_records) + allow(FakeModel).to receive(:available_actions_on_duplicate).and_return([]) + allow(FakeModel).to receive(:try_import_records).and_return([]) end # From 1d20ad6ace1a9fce2bb51f62b2917dc1845f5730 Mon Sep 17 00:00:00 2001 From: Mihir Kamat Date: Sat, 28 Mar 2026 17:46:29 -0400 Subject: [PATCH 41/80] Add extra layer of abstraction when exporting This could enable spoofing models that don't exist, useful for grades etc, which exist as attributes but not models --- app/helpers/importable_exportable_helper.rb | 8 ++++++++ app/models/Item.rb | 2 +- app/models/answer.rb | 1 + app/models/question_advice.rb | 2 +- app/models/questionnaire.rb | 2 +- app/models/quiz_item.rb | 2 +- app/models/team.rb | 2 +- app/models/user.rb | 2 +- app/services/export.rb | 2 +- 9 files changed, 16 insertions(+), 7 deletions(-) diff --git a/app/helpers/importable_exportable_helper.rb b/app/helpers/importable_exportable_helper.rb index 0a1ef8e84..34c826700 100644 --- a/app/helpers/importable_exportable_helper.rb +++ b/app/helpers/importable_exportable_helper.rb @@ -147,6 +147,14 @@ def self.extended(base) 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. diff --git a/app/models/Item.rb b/app/models/Item.rb index 44570389f..1ea2b6e9d 100644 --- a/app/models/Item.rb +++ b/app/models/Item.rb @@ -4,7 +4,7 @@ class Item < ApplicationRecord extend ImportableExportableHelper mandatory_fields :txt, :weight, :seq, :question_type, :break_before 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' diff --git a/app/models/answer.rb b/app/models/answer.rb index e0ad2b804..e0a16d7b9 100644 --- a/app/models/answer.rb +++ b/app/models/answer.rb @@ -6,6 +6,7 @@ class Answer < ApplicationRecord external_classes ExternalClass.new(Item, true, false, :txt), ExternalClass.new(Response, true, false, :additional_comment) + filter nil belongs_to :response belongs_to :item end diff --git a/app/models/question_advice.rb b/app/models/question_advice.rb index f7daf537a..2ed2de08a 100644 --- a/app/models/question_advice.rb +++ b/app/models/question_advice.rb @@ -5,7 +5,7 @@ class QuestionAdvice < ApplicationRecord mandatory_fields :score, :advice external_classes ExternalClass.new(Item, true, false, :txt) - + filter nil belongs_to :item def self.export_fields(_options) QuestionAdvice.columns.map(&:name) diff --git a/app/models/questionnaire.rb b/app/models/questionnaire.rb index d74ca699c..7993dcb15 100644 --- a/app/models/questionnaire.rb +++ b/app/models/questionnaire.rb @@ -5,7 +5,7 @@ class Questionnaire < ApplicationRecord mandatory_fields :name, :min_question_score, :max_question_score, :questionnaire_type, :display_type, :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 questions associated with this Questionnaire before_destroy :check_for_question_associations diff --git a/app/models/quiz_item.rb b/app/models/quiz_item.rb index 60b3d9ef7..c020caf48 100644 --- a/app/models/quiz_item.rb +++ b/app/models/quiz_item.rb @@ -5,7 +5,7 @@ class QuizItem < Item extend ImportableExportableHelper 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/team.rb b/app/models/team.rb index 927f5ff2a..f46e55a09 100644 --- a/app/models/team.rb +++ b/app/models/team.rb @@ -6,7 +6,7 @@ class Team < ApplicationRecord external_classes ExternalClass.new(Assignment, true, false, :title), ExternalClass.new(Course, true, false, :name), ExternalClass.new(User, true, false, :name) - + filter nil # Core associations has_many :signed_up_teams, dependent: :destroy has_many :teams_users, dependent: :destroy diff --git a/app/models/user.rb b/app/models/user.rb index ef0ea7f11..6cee9f159 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -6,7 +6,7 @@ class User < ApplicationRecord 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 diff --git a/app/services/export.rb b/app/services/export.rb index 75ceffb43..62f0777b1 100644 --- a/app/services/export.rb +++ b/app/services/export.rb @@ -42,7 +42,7 @@ def self.perform(export_class, ordered_headers) csv << ordered_headers # Insert each row in order, using the values of the hash - export_class.all.each do |record| + export_class.filter.call.each do |record| row = class_fields.map{|f| record.send(f)} export_class.external_classes.each do |external_class| From 5349c55f3b9c79c9ff5d50646d5e915bec0b1b85 Mon Sep 17 00:00:00 2001 From: Mihir Kamat Date: Sat, 28 Mar 2026 18:19:31 -0400 Subject: [PATCH 42/80] Add spoofed grades class for exporting. Untested, potentially broken --- app/models/grades.rb | 73 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 app/models/grades.rb diff --git a/app/models/grades.rb b/app/models/grades.rb new file mode 100644 index 000000000..b1dc427b8 --- /dev/null +++ b/app/models/grades.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +class Grades + COLUMN_NAMES = %w[ + assignment_id + assignment_name + team_id + team_name + participant_id + participant_name + participant_email + submission_grade + submission_comment + review_grade + teammate_review_grade + author_feedback_grade + ].freeze + + Row = Struct.new(*COLUMN_NAMES.map(&:to_sym), keyword_init: true) + + extend ImportableExportableHelper + + mandatory_fields :assignment_name, :team_name, :participant_name + filter -> { aggregate_grades } + + # Spoof a table-backed model so ImportableExportableHelper can treat + # Grades like a normal exportable class. + def self.column_names + COLUMN_NAMES + end + + def self.aggregate_grades + AssignmentParticipant.includes(:user).map do |participant| + assignment = participant.assignment + team = participant.team + next if assignment.nil? || team.nil? + + reviews_of_me_maps = TeammateReviewResponseMap.where( + reviewed_object_id: assignment.id, + reviewee_id: participant.id + ).to_a + + reviews_by_me_maps = TeammateReviewResponseMap.where( + reviewed_object_id: assignment.id, + reviewer_id: participant.id + ).to_a + + my_reviews_of_other_teams_maps = ReviewResponseMap.where( + reviewed_object_id: assignment.id, + reviewer_id: participant.id + ) + + feedback_from_my_reviewees_maps = my_reviews_of_other_teams_maps.filter_map do |map| + FeedbackResponseMap.find_by(reviewed_object_id: map.id, reviewee_id: participant.id) + end + + Row.new( + assignment_id: assignment.id, + assignment_name: assignment.name, + team_id: team.id, + team_name: team.name, + participant_id: participant.id, + participant_name: participant.user_name, + participant_email: participant.user&.email, + submission_grade: team.grade_for_submission, + submission_comment: team.comment_for_submission, + review_grade: team.aggregate_review_grade, + teammate_review_grade: participant.aggregate_teammate_review_grade(reviews_of_me_maps), + author_feedback_grade: participant.aggregate_teammate_review_grade(feedback_from_my_reviewees_maps) + ) + end.compact + end +end From b873400b347e542f73bde512818ec1b4368c6572 Mon Sep 17 00:00:00 2001 From: Mihir Kamat Date: Sat, 28 Mar 2026 18:20:07 -0400 Subject: [PATCH 43/80] Update importable exportable helper to include filter methods --- app/helpers/importable_exportable_helper.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/app/helpers/importable_exportable_helper.rb b/app/helpers/importable_exportable_helper.rb index 34c826700..988b2b65f 100644 --- a/app/helpers/importable_exportable_helper.rb +++ b/app/helpers/importable_exportable_helper.rb @@ -148,7 +148,6 @@ def self.extended(base) 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 From f26517da812c2a121cbc5d78c2bbb0910c11c6d0 Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Sat, 28 Mar 2026 21:16:56 -0500 Subject: [PATCH 44/80] fixing migrations --- ...topic_to_project_topic_in_signed_up_teams.rb | 13 +++++++++---- ...hange_to_polymorphic_association_in_teams.rb | 8 ++++++-- ...190818_add_comment_for_submission_to_team.rb | 4 +++- ...053_change_invitation_from_id_foreign_key.rb | 11 ++++++++--- ...1_remove_participant_ref_from_invitations.rb | 8 ++++++-- ...9040855_rename_item_id_in_question_tables.rb | 17 +++++++++++++---- 6 files changed, 45 insertions(+), 16 deletions(-) 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 index ec37dc86a..d8321be93 100644 --- a/db/migrate/20251129040855_rename_item_id_in_question_tables.rb +++ b/db/migrate/20251129040855_rename_item_id_in_question_tables.rb @@ -1,7 +1,16 @@ class RenameItemIdInQuestionTables < ActiveRecord::Migration[8.0] def change - rename_column :answers, :question_id, :item_id - rename_column :question_advices, :question_id, :item_id - rename_column :quiz_question_choices, :question_id, :item_id + rename_column_if_needed :answers + rename_column_if_needed :question_advices + rename_column_if_needed :quiz_question_choices end -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 From 6098b2d20e261d5392a38adcd052fb5c34327de3 Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Sat, 28 Mar 2026 21:39:35 -0500 Subject: [PATCH 45/80] added conditionals on newest migration --- .../20260313064334_add_submission_fields_to_teams.rb | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 From a3d069c55c99561dac31d3fa4dffd5afc6740804 Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Sun, 29 Mar 2026 00:01:55 -0500 Subject: [PATCH 46/80] ported over importable/exportable mixin into project_topic and added serializer to structure data for frontend. --- app/controllers/project_topics_controller.rb | 4 +- app/models/project_topic.rb | 6 +++ app/serializers/project_topic_serializer.rb | 35 ++++++++++++++ ...rename_sign_up_topics_to_project_topics.rb | 47 +++++++++++++++++++ 4 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 app/serializers/project_topic_serializer.rb create mode 100644 db/migrate/20260328170000_rename_sign_up_topics_to_project_topics.rb diff --git a/app/controllers/project_topics_controller.rb b/app/controllers/project_topics_controller.rb index 4b5fbb2f7..78350e96d 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,7 +87,7 @@ 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. diff --git a/app/models/project_topic.rb b/app/models/project_topic.rb index 3627be58b..56bd1af9c 100644 --- a/app/models/project_topic.rb +++ b/app/models/project_topic.rb @@ -1,6 +1,12 @@ class ProjectTopic < ApplicationRecord + extend ImportableExportableHelper + mandatory_fields :topic_name, :assignment_id + available_actions_on_duplicate SkipRecordAction.new, UpdateExistingRecordAction.new, ChangeOffendingFieldAction.new + 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 diff --git a/app/serializers/project_topic_serializer.rb b/app/serializers/project_topic_serializer.rb new file mode 100644 index 000000000..05c8f1fb2 --- /dev/null +++ b/app/serializers/project_topic_serializer.rb @@ -0,0 +1,35 @@ +# 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 + + def available_slots + object.available_slots + end + + def confirmed_teams + serialize_teams(object.confirmed_teams) + end + + def waitlisted_teams + serialize_teams(object.waitlisted_teams) + end + + private + + 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/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 From cc6375c5e32f8896e9612592c2dfec5adc45cb18 Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Sun, 29 Mar 2026 00:07:26 -0500 Subject: [PATCH 47/80] fixing leftover vscode diff printed into schema --- db/schema.rb | 70 +++++++++++++++++++++++----------------------------- 1 file changed, 31 insertions(+), 39 deletions(-) diff --git a/db/schema.rb b/db/schema.rb index 485e4c8c4..42698aec1 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,11 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -<<<<<<< HEAD ActiveRecord::Schema[8.0].define(version: 2026_03_13_064334) do -======= -ActiveRecord::Schema[8.0].define(version: 2025_11_29_040855) do ->>>>>>> E2560-main create_table "account_requests", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "username" t.string "full_name" @@ -139,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" @@ -200,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| @@ -271,21 +262,6 @@ t.index ["user_id"], name: "index_participants_on_user_id" end - create_table "project_topics", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.text "topic_name", null: false - t.bigint "assignment_id", null: false - t.integer "max_choosers", default: 0, null: false - t.text "category" - t.string "topic_identifier", limit: 10 - t.integer "micropayment", default: 0 - t.integer "private_to" - t.text "description" - t.string "link" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - 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 "item_id", null: false t.integer "score" @@ -334,7 +310,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 @@ -358,6 +333,22 @@ t.index ["parent_id"], name: "fk_rails_4404228d2f" end + create_table "sign_up_topics", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.text "topic_name", null: false + t.bigint "assignment_id", null: false + t.integer "max_choosers", default: 0, null: false + t.text "category" + t.string "topic_identifier", limit: 10 + t.integer "micropayment", default: 0 + t.integer "private_to" + t.text "description" + 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_sign_up_topics_on_assignment_id" + end + create_table "signed_up_teams", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "project_topic_id", null: false t.bigint "team_id", null: false @@ -393,15 +384,16 @@ 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.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| @@ -421,6 +413,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 @@ -460,18 +453,17 @@ 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 "sign_up_topics", "assignments" + add_foreign_key "signed_up_teams", "sign_up_topics", column: "project_topic_id" add_foreign_key "signed_up_teams", "teams" add_foreign_key "ta_mappings", "courses" add_foreign_key "ta_mappings", "users" From c6c30fde71d4ee926e0a2fe181fd384504fef428 Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Sun, 29 Mar 2026 11:18:04 -0500 Subject: [PATCH 48/80] Added seeds and additional fields within topics table to be importable/exportable --- app/controllers/project_topics_controller.rb | 2 +- db/seeds.rb | 24 ++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/app/controllers/project_topics_controller.rb b/app/controllers/project_topics_controller.rb index 78350e96d..6dc8d56bf 100644 --- a/app/controllers/project_topics_controller.rb +++ b/app/controllers/project_topics_controller.rb @@ -92,6 +92,6 @@ def set_project_topic # 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/db/seeds.rb b/db/seeds.rb index d49c80f33..3d254e75c 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -67,6 +67,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| From 7ae0d9219a52393a49fbcee1e039506798c81f12 Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Sun, 29 Mar 2026 12:16:26 -0500 Subject: [PATCH 49/80] backend changes to help link assignment teams to frontend --- app/controllers/teams_controller.rb | 30 +++++++++++++++++++++++++++-- app/serializers/team_serializer.rb | 6 +++++- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/app/controllers/teams_controller.rb b/app/controllers/teams_controller.rb index a24cd23f2..b827f7086 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,13 @@ 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 + + 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/serializers/team_serializer.rb b/app/serializers/team_serializer.rb index cbc797761..b00ec6036 100644 --- a/app/serializers/team_serializer.rb +++ b/app/serializers/team_serializer.rb @@ -1,7 +1,7 @@ # 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 @@ -14,6 +14,10 @@ def team_size object.teams_participants.count end + def assignment_id + object.parent_id if object.is_a?(AssignmentTeam) + end + def sign_up_topic signed_up_team&.project_topic end From 829ecee53802044465237bb051c5f435e6fb1f1e Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Sun, 29 Mar 2026 17:19:26 -0500 Subject: [PATCH 50/80] customized import/export for teams to use a unique name/participant scheme for csv table. Passed and made use of assignment_id to help scope the specific assignment an import/export operation is occuring on. Specs were also changed to match this new format. --- app/controllers/export_controller.rb | 34 +++- app/controllers/import_controller.rb | 38 +++- app/models/team.rb | 179 +++++++++++++++++-- db/schema.rb | 38 ++-- spec/models/team_import_export_spec.rb | 57 ++++++ spec/requests/import_export_requests_spec.rb | 42 ++++- 6 files changed, 330 insertions(+), 58 deletions(-) create mode 100644 spec/models/team_import_export_spec.rb diff --git a/app/controllers/export_controller.rb b/app/controllers/export_controller.rb index 0f004070b..ee25ddff2 100644 --- a/app/controllers/export_controller.rb +++ b/app/controllers/export_controller.rb @@ -5,11 +5,7 @@ class ExportController < ApplicationController def index klass = params[:class].constantize - render json: { - mandatory_fields: klass.mandatory_fields, - optional_fields: klass.optional_fields, - external_fields: klass.external_fields - }, status: :ok + render json: export_metadata_for(klass), status: :ok rescue StandardError => e render json: { error: e.message }, status: :unprocessable_entity end @@ -26,7 +22,13 @@ def export klass = params[:class].constantize - csv_file = Export.perform(klass, ordered_fields) + csv_file = if klass == Team + Team.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!", @@ -40,6 +42,24 @@ def export private def export_params - params.permit(:class, :ordered_fields) + params.permit(:class, :ordered_fields, :assignment_id) + end + + def export_metadata_for(klass) + 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 + + { + mandatory_fields: klass.mandatory_fields, + optional_fields: klass.optional_fields, + external_fields: klass.external_fields + } end end diff --git a/app/controllers/import_controller.rb b/app/controllers/import_controller.rb index 7d0299086..71e63e167 100644 --- a/app/controllers/import_controller.rb +++ b/app/controllers/import_controller.rb @@ -26,14 +26,7 @@ class ImportController < ApplicationController def index imported_class = params[:class].constantize - render json: { - mandatory_fields: imported_class.mandatory_fields, - optional_fields: imported_class.optional_fields, - external_fields: imported_class.external_fields, - - # Import does not provide duplicate-resolution strategies (those apply to export) - available_actions_on_dup: imported_class.available_actions_on_duplicate.map{|klass| klass.class.name}, - }, status: :ok + render json: import_metadata_for(imported_class), status: :ok end ## @@ -82,10 +75,11 @@ def import # Strong parameters for import operations # def import_params - params.permit(:csv_file, :use_headers, :class, :ordered_fields, :dup_action) + params.permit(:csv_file, :use_headers, :class, :ordered_fields, :dup_action, :assignment_id) end def import_defaults_for(klass) + return team_import_defaults if klass == Team return {} unless klass == User && current_user.present? { @@ -93,4 +87,30 @@ def import_defaults_for(klass) institution_id: current_user.institution_id } end + + def import_metadata_for(imported_class) + 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 + + { + 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 end diff --git a/app/models/team.rb b/app/models/team.rb index bfe14a890..591a716e4 100644 --- a/app/models/team.rb +++ b/app/models/team.rb @@ -2,11 +2,34 @@ class Team < ApplicationRecord extend ImportableExportableHelper - mandatory_fields :name, :type - external_classes ExternalClass.new(Assignment, true, false, :title), - ExternalClass.new(Course, true, false, :name), - ExternalClass.new(User, true, false, :name) - filter nil + TEAM_PARTICIPANT_COLUMN_PREFIX = 'participant_' + DEFAULT_TEAM_IMPORT_EXPORT_PARTICIPANT_COLUMNS = 10 + mandatory_fields :name + filter -> { export_rows } + + TeamExportRow = Struct.new(:team, :participants) do + def initialize(team, participants) + super(team, participants) + self.participants ||= [] + end + + def name + team.name + end + + 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]&.id + end + + 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 has_many :project_topics, through: :signed_up_teams @@ -15,6 +38,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 @@ -26,16 +50,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 @@ -46,7 +71,7 @@ def max_size nil end end - + def full? current_size = participants.count @@ -120,10 +145,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 @@ -160,13 +185,145 @@ def can_participant_join_team?(participant) # All checks passed; participant is eligible to join the team { success: true } - end private + def clear_participant_team_references + Participant.where(team_id: id).update_all(team_id: nil) + end + def release_topics_if_empty return unless participants.empty? project_topics.each { |topic| topic.drop_team(self) } end + + class << self + def internal_fields + ['name'] + participant_field_names + end + + def optional_fields + participant_field_names + end + + def external_fields + [] + end + + def internal_and_external_fields + internal_fields + end + + def export_rows + export_scope.includes(:participants).map do |team| + TeamExportRow.new(team, team.participants.order(:id).to_a) + end + end + + 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) + 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 + + def import_team_row(row, mapping, defaults) + row_hash = {} + mapping.ordered_fields.zip(row).each do |key, value| + row_hash[key] = value + end + + team = find_or_build_import_team(row_hash, defaults) + team.save! if team.new_record? || team.changed? + + participant_ids_from_row(row_hash).each do |participant_id| + participant = find_import_participant(team, participant_id) + 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 + + 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 + raise StandardError, 'name is required for team import' if name.blank? + + find_or_initialize_by(name: name, type: 'AssignmentTeam', parent_id: assignment_id) + end + + def find_import_participant(team, participant_id) + participant_class = participant_class_for(team.type) + participant_class.find_by(id: participant_id, parent_id: team.parent_id) + end + + def participant_class_for(team_type) + %w[AssignmentTeam MentoredTeam].include?(team_type) ? AssignmentParticipant : CourseParticipant + end + + def participant_field_names + (1..participant_column_count).map { |index| "#{TEAM_PARTICIPANT_COLUMN_PREFIX}#{index}" } + end + + 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 + + def participant_ids_from_row(row_hash) + row_hash + .slice(*participant_field_names) + .values + .map(&:presence) + .compact + end + + def export_scope + scope = where(type: %w[AssignmentTeam MentoredTeam]) + import_export_assignment_id.present? ? scope.where(parent_id: import_export_assignment_id) : scope + end + + def import_export_assignment_id + Thread.current[:team_import_export_assignment_id] + end + + 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/db/schema.rb b/db/schema.rb index 485e4c8c4..fd74f89f5 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,11 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -<<<<<<< HEAD -ActiveRecord::Schema[8.0].define(version: 2026_03_13_064334) do -======= -ActiveRecord::Schema[8.0].define(version: 2025_11_29_040855) do ->>>>>>> E2560-main +ActiveRecord::Schema[8.0].define(version: 2026_03_28_170000) do create_table "account_requests", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "username" t.string "full_name" @@ -139,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" @@ -200,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| @@ -283,6 +274,7 @@ 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 @@ -334,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 @@ -393,15 +384,16 @@ 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.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| @@ -421,6 +413,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 @@ -460,16 +453,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/spec/models/team_import_export_spec.rb b/spec/models/team_import_export_spec.rb new file mode 100644 index 000000000..5224b5a0c --- /dev/null +++ b/spec/models/team_import_export_spec.rb @@ -0,0 +1,57 @@ +# 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 id 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) + + csv_text = Team.with_assignment_context(assignment.id) do + Export.perform(Team, %w[name participant_1 participant_2 participant_3]) + end + + 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(participant_one.id.to_s) + expect(exported_row['participant_2']).to eq(participant_two.id.to_s) + expect(exported_row['participant_3']).to be_blank + end + + it 'imports assignment teams and attaches members from participant id 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,#{participant_one.id},#{participant_two.id}\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 + end +end diff --git a/spec/requests/import_export_requests_spec.rb b/spec/requests/import_export_requests_spec.rb index f0dc7e57a..3bb07dea5 100644 --- a/spec/requests/import_export_requests_spec.rb +++ b/spec/requests/import_export_requests_spec.rb @@ -24,12 +24,13 @@ def uploaded_csv(contents) describe "GET /import/:class" do context "metadata responses" do it "returns metadata for Team" do - get "/import/Team" + get "/import/Team", params: { assignment_id: 1 } expect(response).to have_http_status(:ok) json = JSON.parse(response.body) - expect(json["mandatory_fields"]).to include("name", "type", "parent_id") + expect(json["mandatory_fields"]).to eq(["name"]) + expect(json["optional_fields"]).to include("participant_1") expect(json["available_actions_on_dup"]).to match_array( %w[SkipRecordAction UpdateExistingRecordAction ChangeOffendingFieldAction] ) @@ -101,17 +102,29 @@ def uploaded_csv(contents) context "team imports" do it "imports teams" do - file = uploaded_csv("name,parent_id,type\nTeam Alpha,#{assignment.id},AssignmentTeam\n") + 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) + file = uploaded_csv("name,participant_1\nTeam Alpha,#{participant.id}\n") post "/import/Team", params: { csv_file: file, use_headers: true, - dup_action: "SkipRecordAction" + dup_action: "SkipRecordAction", + assignment_id: assignment.id } expect(response).to have_http_status(:created) - expect(AssignmentTeam.find_by(name: "Team Alpha", parent_id: assignment.id)).to be_present + 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 @@ -217,13 +230,26 @@ def uploaded_csv(contents) context "team exports" do it "exports teams" do - post "/export/Team", params: { ordered_fields: %w[name parent_id type].to_json } + 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) + 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) - expect(json["file"]).to include("name,parent_id,type") - expect(json["file"]).to include("Export Team") + expect(json["file"]).to include("name,participant_1") + expect(json["file"]).to include("Export Team,#{participant.id}") end end From 1c4b6be72dea351067f162d1b0c296252126491d Mon Sep 17 00:00:00 2001 From: Mihir Kamat Date: Mon, 30 Mar 2026 01:08:56 -0400 Subject: [PATCH 51/80] Integrate graph export into export with switch --- app/controllers/export_controller.rb | 7 +-- app/helpers/export_helper.rb | 8 ++-- app/services/export.rb | 12 ++++- spec/helpers/export_helper_spec.rb | 53 +++++++--------------- spec/integration/export_controller_spec.rb | 29 +++++++++--- spec/models/team_import_export_spec.rb | 3 +- 6 files changed, 60 insertions(+), 52 deletions(-) diff --git a/app/controllers/export_controller.rb b/app/controllers/export_controller.rb index ee25ddff2..b469e177a 100644 --- a/app/controllers/export_controller.rb +++ b/app/controllers/export_controller.rb @@ -21,13 +21,14 @@ def export end klass = params[:class].constantize + graph_export = ActiveModel::Type::Boolean.new.cast(params[:graph_export]) csv_file = if klass == Team Team.with_assignment_context(params[:assignment_id]) do - Export.perform(klass, ordered_fields) + Export.perform(klass, ordered_fields, graph_export: graph_export) end else - Export.perform(klass, ordered_fields) + Export.perform(klass, ordered_fields, graph_export: graph_export) end render json: { @@ -42,7 +43,7 @@ def export private def export_params - params.permit(:class, :ordered_fields, :assignment_id) + params.permit(:class, :ordered_fields, :assignment_id, :graph_export) end def export_metadata_for(klass) diff --git a/app/helpers/export_helper.rb b/app/helpers/export_helper.rb index a7ac3085d..81b1ed033 100644 --- a/app/helpers/export_helper.rb +++ b/app/helpers/export_helper.rb @@ -6,7 +6,7 @@ module ExportHelper module_function # Builds a minimal recursive graph with only class names and headers, - # then runs Export.perform for each unique class in that graph. + # then returns one export payload per unique class in that graph. def export_has_many_graph(root_class) graph = build_export_graph(root_class) @@ -17,12 +17,12 @@ def export_has_many_graph(root_class) headers_by_class[class_name] |= headers_for_export end - exports = {} + exports = [] headers_by_class.each do |class_name, headers| - exports[class_name] = Export.perform(class_name.constantize, headers) + exports.concat(Export.perform(class_name.constantize, headers, graph_export: false)) end - { graph: graph, exports: exports } + exports end def build_export_graph(root_class, visited = Set.new) diff --git a/app/services/export.rb b/app/services/export.rb index 410229006..f74618bdb 100644 --- a/app/services/export.rb +++ b/app/services/export.rb @@ -31,11 +31,11 @@ class Export # 1,Team 1,Alice; Bob # 2,Team 2,Carol; Dan # - def self.perform(export_class, ordered_headers) + 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.generate do |csv| + csv_contents = CSV.generate do |csv| class_fields = mapping.ordered_fields.select{ |ele| export_class.internal_fields.include?(ele) } @@ -57,6 +57,14 @@ def self.perform(export_class, ordered_headers) csv << row end end + + [{ name: export_class.name, contents: csv_contents }] + end + + def self.perform(export_class, ordered_headers = nil, graph_export: false) + return ExportHelper.export_has_many_graph(export_class) if graph_export + + export_csv(export_class, ordered_headers) end end diff --git a/spec/helpers/export_helper_spec.rb b/spec/helpers/export_helper_spec.rb index ac7714907..42cf793cc 100644 --- a/spec/helpers/export_helper_spec.rb +++ b/spec/helpers/export_helper_spec.rb @@ -2,11 +2,10 @@ require 'rails_helper' require 'csv' -require 'json' RSpec.describe ExportHelper, type: :helper do describe '.export_has_many_graph' do - it 'returns a minimal class/header graph and exports real db records' do + it 'returns export payloads for each class in the graph with real db records' do role = create(:role, :instructor) institution = create(:institution) instructor = Instructor.create!( @@ -70,34 +69,15 @@ allow(Item).to receive(:external_classes).and_return([questionnaire_external].compact) result = described_class.export_has_many_graph(Questionnaire) + exports_by_class = result.index_by { |entry| entry[:name] } - puts "\nExport Graph (from export_has_many_graph):" - puts JSON.pretty_generate(result[:graph]) + expect(result).to all(include(:name, :contents)) + expect(exports_by_class.keys).to include('Questionnaire', 'Item', 'QuestionAdvice', 'Answer') - expect(result).to have_key(:graph) - expect(result[:graph]).to be_a(Hash) - expect(result[:graph][:class_name]).to eq('Questionnaire') - expect(result[:graph][:headers]).to match_array(Questionnaire.mandatory_fields.map(&:to_s)) - - class_names = [] - stack = [result[:graph]] - until stack.empty? - node = stack.pop - class_names << node[:class_name] - stack.concat(node[:has_many] || []) - end - - expect(class_names).to include('Item') - expect(class_names).to include('QuestionAdvice') - expect(class_names).to include('Answer') - - expect(result).to have_key(:exports) - expect(result[:exports]).to include('Questionnaire', 'Item', 'QuestionAdvice', 'Answer') - - questionnaire_rows = CSV.parse(result[:exports]['Questionnaire'], headers: true) - item_rows = CSV.parse(result[:exports]['Item'], headers: true) - advice_rows = CSV.parse(result[:exports]['QuestionAdvice'], headers: true) - answer_rows = CSV.parse(result[:exports]['Answer'], headers: true) + questionnaire_rows = CSV.parse(exports_by_class['Questionnaire'][:contents], headers: true) + item_rows = CSV.parse(exports_by_class['Item'][:contents], headers: true) + advice_rows = CSV.parse(exports_by_class['QuestionAdvice'][:contents], headers: true) + answer_rows = CSV.parse(exports_by_class['Answer'][:contents], headers: true) expect(questionnaire_rows.map { |row| row['name'] }).to include(questionnaire_record.name) expect(item_rows.map { |row| row['txt'] }).to include(item_record.txt) @@ -167,18 +147,19 @@ allow(Item).to receive(:external_classes).and_return([questionnaire_external].compact) result = described_class.export_has_many_graph(Questionnaire) + exports_by_class = result.index_by { |entry| entry[:name] } puts "\nCSV Exports:" - result[:exports].each do |klass_name, csv_text| - puts "--- #{klass_name} ---" - puts csv_text + result.each do |export_entry| + puts "--- #{export_entry[:name]} ---" + puts export_entry[:contents] end - expect(result[:exports]).to include('Questionnaire', 'Item', 'QuestionAdvice', 'Answer') - expect(result[:exports]['Questionnaire']).to include('name') - expect(result[:exports]['Item']).to include('txt') - expect(result[:exports]['QuestionAdvice']).to include('advice') - expect(result[:exports]['Answer']).to include('comments') + expect(exports_by_class.keys).to include('Questionnaire', 'Item', 'QuestionAdvice', 'Answer') + expect(exports_by_class['Questionnaire'][:contents]).to include('name') + expect(exports_by_class['Item'][:contents]).to include('txt') + expect(exports_by_class['QuestionAdvice'][:contents]).to include('advice') + expect(exports_by_class['Answer'][:contents]).to include('comments') end end end diff --git a/spec/integration/export_controller_spec.rb b/spec/integration/export_controller_spec.rb index 90d22c122..6d95cb39e 100644 --- a/spec/integration/export_controller_spec.rb +++ b/spec/integration/export_controller_spec.rb @@ -41,10 +41,10 @@ def self.external_fields; ["institution"]; end describe "POST /export/:class" do it "returns 200 and calls Export.perform with ordered fields" do ordered_fields = ["id", "name"] - export_return = "fake_csv_data" + export_return = [{ name: "FakeModel", contents: "fake_csv_data" }] expect(Export).to receive(:perform) - .with(FakeModel, ordered_fields) + .with(FakeModel, ordered_fields, graph_export: false) .and_return(export_return) post "/export/FakeModel", params: { @@ -55,21 +55,38 @@ def self.external_fields; ["institution"]; end json = JSON.parse(response.body) expect(json["message"]).to eq("FakeModel has been exported!") - expect(json["file"]).to eq("fake_csv_data") + expect(json["file"]).to eq([{ "name" => "FakeModel", "contents" => "fake_csv_data" }]) end it "passes nil ordered_fields when none are provided" do - export_return = "csv_without_ordering" + export_return = [{ name: "FakeModel", contents: "csv_without_ordering" }] expect(Export).to receive(:perform) - .with(FakeModel, nil) + .with(FakeModel, nil, graph_export: false) .and_return(export_return) post "/export/FakeModel" expect(response).to have_http_status(:ok) json = JSON.parse(response.body) - expect(json["file"]).to eq("csv_without_ordering") + expect(json["file"]).to eq([{ "name" => "FakeModel", "contents" => "csv_without_ordering" }]) + end + + it "passes graph_export when requested" do + export_return = [{ name: "FakeModel", contents: "graph_csv_data" }] + + expect(Export).to receive(:perform) + .with(FakeModel, ["id"], graph_export: true) + .and_return(export_return) + + post "/export/FakeModel", params: { + ordered_fields: ["id"].to_json, + graph_export: "true" + } + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json["file"]).to eq([{ "name" => "FakeModel", "contents" => "graph_csv_data" }]) end it "returns 422 if constantize fails" do diff --git a/spec/models/team_import_export_spec.rb b/spec/models/team_import_export_spec.rb index 5224b5a0c..fa5f142c3 100644 --- a/spec/models/team_import_export_spec.rb +++ b/spec/models/team_import_export_spec.rb @@ -17,9 +17,10 @@ expect(team.add_member(participant_one)[:success]).to be(true) expect(team.add_member(participant_two)[:success]).to be(true) - csv_text = Team.with_assignment_context(assignment.id) do + 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' } From f397e9a4e327be23256726c6da632362db22383a Mon Sep 17 00:00:00 2001 From: Mihir Kamat Date: Mon, 30 Mar 2026 01:25:00 -0400 Subject: [PATCH 52/80] Add sample questionnaire with items --- db/seeds.rb | 156 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) diff --git a/db/seeds.rb b/db/seeds.rb index 3d254e75c..505f223fc 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -159,6 +159,162 @@ end end + 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' end From 70662563986fb929612b449bb479ed2380899e02 Mon Sep 17 00:00:00 2001 From: Mihir Kamat Date: Mon, 30 Mar 2026 14:48:00 -0400 Subject: [PATCH 53/80] Add hidden fields option --- app/helpers/importable_exportable_helper.rb | 16 ++++++++++++++-- app/models/Item.rb | 3 ++- app/models/answer.rb | 1 + app/models/grades.rb | 1 + app/models/project_topic.rb | 1 + app/models/question_advice.rb | 3 ++- app/models/questionnaire.rb | 1 + app/models/quiz_item.rb | 1 + app/models/team.rb | 1 + app/models/user.rb | 1 + 10 files changed, 25 insertions(+), 4 deletions(-) diff --git a/app/helpers/importable_exportable_helper.rb b/app/helpers/importable_exportable_helper.rb index c2ffc0405..b614383e8 100644 --- a/app/helpers/importable_exportable_helper.rb +++ b/app/helpers/importable_exportable_helper.rb @@ -160,17 +160,29 @@ def filter(filter_proc = nil) # -------------------------------------------------------------- def mandatory_fields(*fields) if fields.any? - @mandatory_fields = fields.map(&:to_s) + @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 - internal_fields - (mandatory_fields || []) + unhidden_fields - (mandatory_fields || []) + end + + def unhidden_fields + internal_fields - (hidden_fields || []) end # -------------------------------------------------------------- diff --git a/app/models/Item.rb b/app/models/Item.rb index 7f6c38ecb..6c17d2100 100644 --- a/app/models/Item.rb +++ b/app/models/Item.rb @@ -3,6 +3,7 @@ class Item < ApplicationRecord extend ImportableExportableHelper mandatory_fields :txt, :weight, :seq, :question_type, :break_before + hidden_fields :id, :created_at, :updated_at external_classes ExternalClass.new(Questionnaire, true, false, :name) filter nil before_create :set_seq @@ -95,4 +96,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 e0a16d7b9..5f16bb7d0 100644 --- a/app/models/answer.rb +++ b/app/models/answer.rb @@ -3,6 +3,7 @@ class Answer < ApplicationRecord extend ImportableExportableHelper mandatory_fields :answer, :comments + hidden_fields :id, :created_at, :updated_at external_classes ExternalClass.new(Item, true, false, :txt), ExternalClass.new(Response, true, false, :additional_comment) diff --git a/app/models/grades.rb b/app/models/grades.rb index b1dc427b8..eeecb6f83 100644 --- a/app/models/grades.rb +++ b/app/models/grades.rb @@ -21,6 +21,7 @@ class Grades extend ImportableExportableHelper mandatory_fields :assignment_name, :team_name, :participant_name + # hidden_fields :id, :created_at, :updated_at filter -> { aggregate_grades } # Spoof a table-backed model so ImportableExportableHelper can treat diff --git a/app/models/project_topic.rb b/app/models/project_topic.rb index 56bd1af9c..8547d3890 100644 --- a/app/models/project_topic.rb +++ b/app/models/project_topic.rb @@ -1,6 +1,7 @@ class ProjectTopic < ApplicationRecord extend ImportableExportableHelper mandatory_fields :topic_name, :assignment_id + hidden_fields :id, :created_at, :updated_at available_actions_on_duplicate SkipRecordAction.new, UpdateExistingRecordAction.new, ChangeOffendingFieldAction.new has_many :signed_up_teams, dependent: :destroy diff --git a/app/models/question_advice.rb b/app/models/question_advice.rb index 2ed2de08a..ce594931d 100644 --- a/app/models/question_advice.rb +++ b/app/models/question_advice.rb @@ -3,6 +3,7 @@ class QuestionAdvice < ApplicationRecord extend ImportableExportableHelper mandatory_fields :score, :advice + hidden_fields :id, :created_at, :updated_at external_classes ExternalClass.new(Item, true, false, :txt) filter nil @@ -26,4 +27,4 @@ def self.to_json_by_question_id(question_id) { score: advice.score, advice: advice.advice } end end -end \ No newline at end of file +end diff --git a/app/models/questionnaire.rb b/app/models/questionnaire.rb index ee6091ba6..3121d4c5d 100644 --- a/app/models/questionnaire.rb +++ b/app/models/questionnaire.rb @@ -3,6 +3,7 @@ class Questionnaire < ApplicationRecord extend ImportableExportableHelper mandatory_fields :name, :min_question_score, :max_question_score, :questionnaire_type, :display_type, :instruction_loc + hidden_fields :id, :created_at, :updated_at external_classes ExternalClass.new(Instructor, true, false, :name) available_actions_on_duplicate SkipRecordAction.new, UpdateExistingRecordAction.new, ChangeOffendingFieldAction.new filter nil diff --git a/app/models/quiz_item.rb b/app/models/quiz_item.rb index c020caf48..4bfce92c0 100644 --- a/app/models/quiz_item.rb +++ b/app/models/quiz_item.rb @@ -4,6 +4,7 @@ class QuizItem < Item 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 diff --git a/app/models/team.rb b/app/models/team.rb index 591a716e4..77f596569 100644 --- a/app/models/team.rb +++ b/app/models/team.rb @@ -5,6 +5,7 @@ class Team < ApplicationRecord TEAM_PARTICIPANT_COLUMN_PREFIX = 'participant_' DEFAULT_TEAM_IMPORT_EXPORT_PARTICIPANT_COLUMNS = 10 mandatory_fields :name + hidden_fields :id, :created_at, :updated_at filter -> { export_rows } TeamExportRow = Struct.new(:team, :participants) do diff --git a/app/models/user.rb b/app/models/user.rb index 6cee9f159..971a1729f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -3,6 +3,7 @@ class User < ApplicationRecord extend ImportableExportableHelper mandatory_fields :name, :email, :password, :full_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 From de1aaa529b3197505d93fbf8048e13688bb553b3 Mon Sep 17 00:00:00 2001 From: Mihir Kamat Date: Mon, 30 Mar 2026 15:27:45 -0400 Subject: [PATCH 54/80] Refine grades export + Add tests for grades export --- spec/models/grades_export_spec.rb | 235 ++++++++++++++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 spec/models/grades_export_spec.rb diff --git a/spec/models/grades_export_spec.rb b/spec/models/grades_export_spec.rb new file mode 100644 index 000000000..64ed0f453 --- /dev/null +++ b/spec/models/grades_export_spec.rb @@ -0,0 +1,235 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'csv' + +RSpec.describe Grades, type: :model do + describe 'grade export' do + def create_scored_response(map:, item:, score:, comments:, round: 1) + response = Response.create!( + map_id: map.id, + additional_comment: comments, + is_submitted: true, + round: round + ) + + Answer.create!( + response: response, + item: item, + answer: score, + comments: comments + ) + + response + end + + it 'exports one csv row per participant with the expected grades columns' do + instructor = Instructor.create!( + name: 'gradesinstructor', + email: 'gradesinstructor@example.com', + full_name: 'Grades Instructor', + password: 'password', + role: create(:role, :instructor), + institution: create(:institution) + ) + assignment = create(:assignment, instructor: instructor, name: 'Grades Export Assignment') + non_clashing_assignment_id = [ + Assignment.maximum(:id), + ResponseMap.maximum(:id), + Response.maximum(:id) + ].compact.max.to_i + 10_000 + assignment.update_column(:id, non_clashing_assignment_id) + assignment.reload + + questionnaire = Questionnaire.create!( + name: 'Grades Export 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 strong was the work?', + weight: 1, + seq: 1, + question_type: 'Scale', + break_before: true + ) + + AssignmentQuestionnaire.create!( + assignment: assignment, + questionnaire: questionnaire, + used_in_round: 1, + notification_limit: 5, + questionnaire_weight: 100 + ) + + alice = create(:user, :student, name: 'alice_export', email: 'alice_export@example.com', full_name: 'Alice Export') + aaron = create(:user, :student, name: 'aaron_export', email: 'aaron_export@example.com', full_name: 'Aaron Export') + bella = create(:user, :student, name: 'bella_export', email: 'bella_export@example.com', full_name: 'Bella Export') + ben = create(:user, :student, name: 'ben_export', email: 'ben_export@example.com', full_name: 'Ben Export') + + participant_a1 = create(:assignment_participant, assignment: assignment, user: alice, handle: alice.name) + participant_a2 = create(:assignment_participant, assignment: assignment, user: aaron, handle: aaron.name) + participant_b1 = create(:assignment_participant, assignment: assignment, user: bella, handle: bella.name) + participant_b2 = create(:assignment_participant, assignment: assignment, user: ben, handle: ben.name) + + team_one = AssignmentTeam.create!( + name: 'Team One', + parent_id: assignment.id, + type: 'AssignmentTeam', + grade_for_submission: 88 + ) + team_two = AssignmentTeam.create!( + name: 'Team Two', + parent_id: assignment.id, + type: 'AssignmentTeam', + grade_for_submission: 93 + ) + + expect(team_one.add_member(participant_a1)[:success]).to be(true) + expect(team_one.add_member(participant_a2)[:success]).to be(true) + expect(team_two.add_member(participant_b1)[:success]).to be(true) + expect(team_two.add_member(participant_b2)[:success]).to be(true) + + 3.times do |index| + ReviewResponseMap.create!( + reviewed_object_id: assignment.id, + reviewer_id: participant_a1.id, + reviewee_id: team_two.id + ) + Response.create!( + map_id: ReviewResponseMap.last.id, + additional_comment: "throwaway review #{index}", + is_submitted: true, + round: 1 + ) + end + + poor_review_map = ReviewResponseMap.create!( + reviewed_object_id: assignment.id, + reviewer_id: participant_a1.id, + reviewee_id: team_two.id + ) + good_review_map = ReviewResponseMap.create!( + reviewed_object_id: assignment.id, + reviewer_id: participant_b1.id, + reviewee_id: team_one.id + ) + + poor_review_response = create_scored_response( + map: poor_review_map, + item: item, + score: 2, + comments: 'Poor review from Team One to Team Two' + ) + good_review_response = create_scored_response( + map: good_review_map, + item: item, + score: 9, + comments: 'Strong review from Team Two to Team One' + ) + + expect(assignment.id).not_to eq(poor_review_map.id) + expect(assignment.id).not_to eq(good_review_map.id) + expect(assignment.id).not_to eq(poor_review_response.id) + expect(assignment.id).not_to eq(good_review_response.id) + + teammate_map_a1 = TeammateReviewResponseMap.create!( + reviewed_object_id: assignment.id, + reviewer_id: participant_a1.id, + reviewee_id: participant_a2.id + ) + teammate_map_a2 = TeammateReviewResponseMap.create!( + reviewed_object_id: assignment.id, + reviewer_id: participant_a2.id, + reviewee_id: participant_a1.id + ) + teammate_map_b1 = TeammateReviewResponseMap.create!( + reviewed_object_id: assignment.id, + reviewer_id: participant_b1.id, + reviewee_id: participant_b2.id + ) + teammate_map_b2 = TeammateReviewResponseMap.create!( + reviewed_object_id: assignment.id, + reviewer_id: participant_b2.id, + reviewee_id: participant_b1.id + ) + + create_scored_response(map: teammate_map_a1, item: item, score: 7, comments: 'A1 reviewing A2') + create_scored_response(map: teammate_map_a2, item: item, score: 8, comments: 'A2 reviewing A1') + create_scored_response(map: teammate_map_b1, item: item, score: 6, comments: 'B1 reviewing B2') + create_scored_response(map: teammate_map_b2, item: item, score: 9, comments: 'B2 reviewing B1') + + feedback_map_for_a1 = FeedbackResponseMap.create!( + reviewed_object_id: poor_review_map.id, + reviewer_id: participant_b1.id, + reviewee_id: participant_a1.id + ) + feedback_map_for_b1 = FeedbackResponseMap.create!( + reviewed_object_id: good_review_map.id, + reviewer_id: participant_a1.id, + reviewee_id: participant_b1.id + ) + + create_scored_response(map: feedback_map_for_a1, item: item, score: 3, comments: 'Feedback for Team One reviewer') + create_scored_response(map: feedback_map_for_b1, item: item, score: 8, comments: 'Feedback for Team Two reviewer') + + export_payload = Export.perform(Grades) + csv_text = export_payload.first[:contents] + + puts "\nGrades export CSV:" + puts csv_text + + rows = CSV.parse(csv_text, headers: true) + + expect(rows.headers).to eq(Grades::COLUMN_NAMES) + expect(rows.size).to eq(4) + + row_by_participant = rows.index_by { |row| row['participant_name'] } + + expect(row_by_participant.keys).to contain_exactly( + participant_a1.user_name, + participant_a2.user_name, + participant_b1.user_name, + participant_b2.user_name + ) + + expect(row_by_participant[participant_a1.user_name]['assignment_id']).to eq(assignment.id.to_s) + expect(row_by_participant[participant_a1.user_name]['assignment_name']).to eq(assignment.name) + expect(row_by_participant[participant_a1.user_name]['team_name']).to eq(team_one.name) + expect(row_by_participant[participant_a1.user_name]['participant_email']).to eq(alice.email) + + expect(row_by_participant[participant_a2.user_name]['assignment_id']).to eq(assignment.id.to_s) + expect(row_by_participant[participant_a2.user_name]['team_name']).to eq(team_one.name) + expect(row_by_participant[participant_a2.user_name]['participant_email']).to eq(aaron.email) + + expect(row_by_participant[participant_b1.user_name]['assignment_id']).to eq(assignment.id.to_s) + expect(row_by_participant[participant_b1.user_name]['team_name']).to eq(team_two.name) + expect(row_by_participant[participant_b1.user_name]['participant_email']).to eq(bella.email) + + expect(row_by_participant[participant_b2.user_name]['assignment_id']).to eq(assignment.id.to_s) + expect(row_by_participant[participant_b2.user_name]['team_name']).to eq(team_two.name) + expect(row_by_participant[participant_b2.user_name]['participant_email']).to eq(ben.email) + + rows.each do |row| + expect(row['team_id']).to be_present + expect(row['participant_id']).to be_present + expect(row['submission_grade']).to be_present + expect(row['review_grade']).to be_present + end + + expect(row_by_participant[participant_a1.user_name]['author_feedback_grade']).to be_present + expect(row_by_participant[participant_b1.user_name]['author_feedback_grade']).to be_present + expect(row_by_participant[participant_a1.user_name]['teammate_review_grade']).to be_present + expect(row_by_participant[participant_a2.user_name]['teammate_review_grade']).to be_present + expect(row_by_participant[participant_b1.user_name]['teammate_review_grade']).to be_present + expect(row_by_participant[participant_b2.user_name]['teammate_review_grade']).to be_present + end + end +end From 0d9e750f2f86c39d0e16fa7b99fc6ffd620cf09f Mon Sep 17 00:00:00 2001 From: Mihir Kamat Date: Mon, 30 Mar 2026 16:02:36 -0400 Subject: [PATCH 55/80] grade export changes --- app/models/grades.rb | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/models/grades.rb b/app/models/grades.rb index eeecb6f83..e31146fdd 100644 --- a/app/models/grades.rb +++ b/app/models/grades.rb @@ -10,7 +10,6 @@ class Grades participant_name participant_email submission_grade - submission_comment review_grade teammate_review_grade author_feedback_grade @@ -36,9 +35,12 @@ def self.aggregate_grades team = participant.team next if assignment.nil? || team.nil? + teammate_ids = team.participants.where.not(id: participant.id).pluck(:id) + reviews_of_me_maps = TeammateReviewResponseMap.where( reviewed_object_id: assignment.id, - reviewee_id: participant.id + reviewee_id: participant.id, + reviewer_id: teammate_ids ).to_a reviews_by_me_maps = TeammateReviewResponseMap.where( @@ -64,7 +66,6 @@ def self.aggregate_grades participant_name: participant.user_name, participant_email: participant.user&.email, submission_grade: team.grade_for_submission, - submission_comment: team.comment_for_submission, review_grade: team.aggregate_review_grade, teammate_review_grade: participant.aggregate_teammate_review_grade(reviews_of_me_maps), author_feedback_grade: participant.aggregate_teammate_review_grade(feedback_from_my_reviewees_maps) From f498be027602769665594ca1ff89abec0d67806c Mon Sep 17 00:00:00 2001 From: Mihir Kamat Date: Mon, 30 Mar 2026 20:25:44 -0400 Subject: [PATCH 56/80] Filter nonexistent headers from child models when graph exporting --- app/helpers/export_helper.rb | 19 ++++++++++++++++++- spec/helpers/export_helper_spec.rb | 14 +++++++++++--- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/app/helpers/export_helper.rb b/app/helpers/export_helper.rb index 81b1ed033..8e59ca56d 100644 --- a/app/helpers/export_helper.rb +++ b/app/helpers/export_helper.rb @@ -101,7 +101,10 @@ def each_graph_node_for_export(graph, inherited_headers = [], seen = Set.new, &b return if seen.include?(graph[:class_name]) seen.add(graph[:class_name]) - headers_for_export = remove_identifier_fields(Array(graph[:headers]) + Array(inherited_headers)) + headers_for_export = filter_headers_for_class( + graph[:class_name], + remove_identifier_fields(Array(graph[:headers]) + Array(inherited_headers)) + ) block.call(graph, headers_for_export) prefixed_parent_headers = prefix_headers_with_class_name(graph[:headers], graph[:class_name]) @@ -136,6 +139,20 @@ def mandatory_headers_for(klass) end private_class_method :mandatory_headers_for + def filter_headers_for_class(class_name, headers) + klass = normalize_class(class_name) + exportable_headers = if klass.respond_to?(:internal_and_external_fields) + remove_identifier_fields(klass.internal_and_external_fields.map(&:to_s)) + elsif klass.respond_to?(:column_names) + remove_identifier_fields(klass.column_names) + else + [] + end + + Array(headers).select { |header| exportable_headers.include?(header) } + end + private_class_method :filter_headers_for_class + def remove_identifier_fields(fields) Array(fields).map(&:to_s).uniq.reject { |field| field == 'id' || field.end_with?('_id') } end diff --git a/spec/helpers/export_helper_spec.rb b/spec/helpers/export_helper_spec.rb index 42cf793cc..a89f1db69 100644 --- a/spec/helpers/export_helper_spec.rb +++ b/spec/helpers/export_helper_spec.rb @@ -4,7 +4,7 @@ require 'csv' RSpec.describe ExportHelper, type: :helper do - describe '.export_has_many_graph' do + describe 'graph export via Export.perform' do it 'returns export payloads for each class in the graph with real db records' do role = create(:role, :instructor) institution = create(:institution) @@ -68,7 +68,7 @@ questionnaire_external = Item.external_classes.find { |ext| ext.ref_class == Questionnaire } allow(Item).to receive(:external_classes).and_return([questionnaire_external].compact) - result = described_class.export_has_many_graph(Questionnaire) + result = Export.perform(Questionnaire, nil, graph_export: true) exports_by_class = result.index_by { |entry| entry[:name] } expect(result).to all(include(:name, :contents)) @@ -84,6 +84,9 @@ expect(advice_rows.map { |row| row['advice'] }).to include(advice_record.advice) expect(answer_rows.map { |row| row['comments'] }).to include(answer_record.comments) expect(answer_rows.map { |row| row['answer'] }).to include(answer_record.answer.to_s) + expect(answer_rows.headers.length).to eq( + answer_rows.first&.fields&.length || answer_rows.headers.length + ) end it 'exports csv and prints each class csv output' do @@ -146,7 +149,7 @@ questionnaire_external = Item.external_classes.find { |ext| ext.ref_class == Questionnaire } allow(Item).to receive(:external_classes).and_return([questionnaire_external].compact) - result = described_class.export_has_many_graph(Questionnaire) + result = Export.perform(Questionnaire, nil, graph_export: true) exports_by_class = result.index_by { |entry| entry[:name] } puts "\nCSV Exports:" @@ -160,6 +163,11 @@ expect(exports_by_class['Item'][:contents]).to include('txt') expect(exports_by_class['QuestionAdvice'][:contents]).to include('advice') expect(exports_by_class['Answer'][:contents]).to include('comments') + + answer_rows = CSV.parse(exports_by_class['Answer'][:contents], headers: true) + expect(answer_rows.headers.length).to eq( + answer_rows.first&.fields&.length || answer_rows.headers.length + ) end end end From 6616b66f8bcff42fdf17fe01e1d79a2935b957ac Mon Sep 17 00:00:00 2001 From: Mihir Kamat Date: Mon, 30 Mar 2026 20:59:09 -0400 Subject: [PATCH 57/80] reset database yml username passwd --- config/database.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/database.yml b/config/database.yml index a6c49bd92..8a714597f 100644 --- a/config/database.yml +++ b/config/database.yml @@ -3,8 +3,8 @@ default: &default encoding: utf8mb4 pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> port: 3306 - username: mihir - password: mihir + username: root + password: expertiza socket: /var/run/mysqld/mysqld.sock development: From 4d45dc6d7948235d93b1f3b2b5b42121b055de10 Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Mon, 30 Mar 2026 20:36:11 -0500 Subject: [PATCH 58/80] updating schema to newest per migrations --- db/schema.rb | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/db/schema.rb b/db/schema.rb index 6cdef4336..fd74f89f5 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -349,22 +349,6 @@ t.index ["parent_id"], name: "fk_rails_4404228d2f" end - create_table "sign_up_topics", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.text "topic_name", null: false - t.bigint "assignment_id", null: false - t.integer "max_choosers", default: 0, null: false - t.text "category" - t.string "topic_identifier", limit: 10 - t.integer "micropayment", default: 0 - t.integer "private_to" - t.text "description" - 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_sign_up_topics_on_assignment_id" - end - create_table "signed_up_teams", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "project_topic_id", null: false t.bigint "team_id", null: false @@ -479,8 +463,7 @@ add_foreign_key "project_topics", "assignments" add_foreign_key "question_advices", "items" add_foreign_key "roles", "roles", column: "parent_id", on_delete: :cascade - add_foreign_key "sign_up_topics", "assignments" - add_foreign_key "signed_up_teams", "sign_up_topics", column: "project_topic_id" + add_foreign_key "signed_up_teams", "project_topics" add_foreign_key "signed_up_teams", "teams" add_foreign_key "ta_mappings", "courses" add_foreign_key "ta_mappings", "users" From 50f8eba31a3e3a4bc171e163edf4d18233cd2a8f Mon Sep 17 00:00:00 2001 From: Mihir Kamat Date: Thu, 2 Apr 2026 21:06:40 -0400 Subject: [PATCH 59/80] Add mandatory fields of external class to import-export mandatory fields --- app/helpers/importable_exportable_helper.rb | 3 ++- app/models/Item.rb | 2 +- app/models/answer.rb | 4 ++-- app/models/question_advice.rb | 4 ++-- app/models/questionnaire.rb | 2 +- app/models/user.rb | 2 +- 6 files changed, 9 insertions(+), 8 deletions(-) diff --git a/app/helpers/importable_exportable_helper.rb b/app/helpers/importable_exportable_helper.rb index b614383e8..91c6a385a 100644 --- a/app/helpers/importable_exportable_helper.rb +++ b/app/helpers/importable_exportable_helper.rb @@ -303,7 +303,8 @@ def try_import_records(file, headers, use_header, defaults = {}) duplicate_records << dup if dup && dup != true end - rescue StandardError + rescue StandardError => e + puts e.message raise ActiveRecord::Rollback end diff --git a/app/models/Item.rb b/app/models/Item.rb index 6c17d2100..caf772c2c 100644 --- a/app/models/Item.rb +++ b/app/models/Item.rb @@ -2,7 +2,7 @@ class Item < ApplicationRecord extend ImportableExportableHelper - mandatory_fields :txt, :weight, :seq, :question_type, :break_before + 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 diff --git a/app/models/answer.rb b/app/models/answer.rb index 5f16bb7d0..98dc95ac7 100644 --- a/app/models/answer.rb +++ b/app/models/answer.rb @@ -2,9 +2,9 @@ class Answer < ApplicationRecord extend ImportableExportableHelper - mandatory_fields :answer, :comments + mandatory_fields :answer, :comments, :item_seq hidden_fields :id, :created_at, :updated_at - external_classes ExternalClass.new(Item, true, false, :txt), + external_classes ExternalClass.new(Item, true, false, :seq), ExternalClass.new(Response, true, false, :additional_comment) filter nil diff --git a/app/models/question_advice.rb b/app/models/question_advice.rb index ce594931d..5831600ce 100644 --- a/app/models/question_advice.rb +++ b/app/models/question_advice.rb @@ -2,10 +2,10 @@ class QuestionAdvice < ApplicationRecord extend ImportableExportableHelper - mandatory_fields :score, :advice + mandatory_fields :score, :advice, :item_seq hidden_fields :id, :created_at, :updated_at - external_classes ExternalClass.new(Item, true, false, :txt) + external_classes ExternalClass.new(Item, true, false, :seq) filter nil belongs_to :item def self.export_fields(_options) diff --git a/app/models/questionnaire.rb b/app/models/questionnaire.rb index 3121d4c5d..4066ef3fa 100644 --- a/app/models/questionnaire.rb +++ b/app/models/questionnaire.rb @@ -2,7 +2,7 @@ class Questionnaire < ApplicationRecord extend ImportableExportableHelper - mandatory_fields :name, :min_question_score, :max_question_score, :questionnaire_type, :display_type, :instruction_loc + mandatory_fields :name, :min_question_score, :max_question_score, :questionnaire_type, :display_type, :instruction_loc, :instructor_name hidden_fields :id, :created_at, :updated_at external_classes ExternalClass.new(Instructor, true, false, :name) available_actions_on_duplicate SkipRecordAction.new, UpdateExistingRecordAction.new, ChangeOffendingFieldAction.new diff --git a/app/models/user.rb b/app/models/user.rb index 971a1729f..53a42f345 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -2,7 +2,7 @@ class User < ApplicationRecord extend ImportableExportableHelper - mandatory_fields :name, :email, :password, :full_name + 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) From a8f3b836025b7464e680249a307bba1fe7bcae7b Mon Sep 17 00:00:00 2001 From: Mihir Kamat Date: Fri, 3 Apr 2026 18:32:47 -0400 Subject: [PATCH 60/80] Branch start for incorporating final changes From 0037058a951def91103a90a74f582a46dcb7d5ae Mon Sep 17 00:00:00 2001 From: Mihir Kamat Date: Fri, 3 Apr 2026 18:37:06 -0400 Subject: [PATCH 61/80] Add brief descriptions for method names in export helper --- app/helpers/export_helper.rb | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/helpers/export_helper.rb b/app/helpers/export_helper.rb index 8e59ca56d..12a280b39 100644 --- a/app/helpers/export_helper.rb +++ b/app/helpers/export_helper.rb @@ -7,6 +7,7 @@ module ExportHelper # Builds a minimal recursive graph with only class names and headers, # then returns one export payload per unique class in that graph. + # traverses all the has_many and belongs_to descendants of the model provided. def export_has_many_graph(root_class) graph = build_export_graph(root_class) @@ -25,6 +26,7 @@ def export_has_many_graph(root_class) exports end + # Recursively builds an export graph for the class and its related children. def build_export_graph(root_class, visited = Set.new) klass = normalize_class(root_class) class_name = klass.name @@ -42,6 +44,7 @@ def build_export_graph(root_class, visited = Set.new) children = [] + # get has_many models klass.reflect_on_all_associations(:has_many).each do |association| begin child_klass = association.klass @@ -52,6 +55,7 @@ def build_export_graph(root_class, visited = Set.new) children << build_export_graph(child_klass, visited) end + # get belongs_to models descendants_with_belongs_to_parent(klass).each do |child_klass| children << build_export_graph(child_klass, visited) end @@ -63,6 +67,7 @@ def build_export_graph(root_class, visited = Set.new) } end + # Finds descendant models that point back to the parent through a belongs_to association. def descendants_with_belongs_to_parent(parent_klass) descendants = ActiveRecord::Base.descendants.select { |model| model < ApplicationRecord } @@ -80,11 +85,13 @@ def descendants_with_belongs_to_parent(parent_klass) end private_class_method :descendants_with_belongs_to_parent + # Removes duplicate child nodes so each class appears only once per level. def dedupe_children(children) children.uniq { |child| child[:class_name] } end private_class_method :dedupe_children + # Walks each unique node in the graph once and yields it to the caller. def each_graph_node(graph, seen = Set.new, &block) return if seen.include?(graph[:class_name]) @@ -97,6 +104,7 @@ def each_graph_node(graph, seen = Set.new, &block) end private_class_method :each_graph_node + # Traverses the graph while carrying inherited headers needed for child exports. def each_graph_node_for_export(graph, inherited_headers = [], seen = Set.new, &block) return if seen.include?(graph[:class_name]) @@ -116,6 +124,7 @@ def each_graph_node_for_export(graph, inherited_headers = [], seen = Set.new, &b end private_class_method :each_graph_node_for_export + # Prefixes headers with the underscored class name to preserve parent context. def prefix_headers_with_class_name(headers, class_name) klass = class_name.is_a?(Class) ? class_name : class_name.constantize prefix = klass.name.underscore @@ -123,6 +132,7 @@ def prefix_headers_with_class_name(headers, class_name) end private_class_method :prefix_headers_with_class_name + # Returns the required exportable headers for the given class. def mandatory_headers_for(klass) if klass.respond_to?(:mandatory_fields) mandatory = Array(klass.mandatory_fields).map(&:to_s) @@ -139,6 +149,7 @@ def mandatory_headers_for(klass) end private_class_method :mandatory_headers_for + # Keeps only headers that can actually be exported for the target class. def filter_headers_for_class(class_name, headers) klass = normalize_class(class_name) exportable_headers = if klass.respond_to?(:internal_and_external_fields) @@ -153,11 +164,13 @@ def filter_headers_for_class(class_name, headers) end private_class_method :filter_headers_for_class + # Strips primary and foreign key identifiers from a list of field names. def remove_identifier_fields(fields) Array(fields).map(&:to_s).uniq.reject { |field| field == 'id' || field.end_with?('_id') } end private_class_method :remove_identifier_fields + # Normalizes a class reference so callers can pass either a class or its name. def normalize_class(root_class) root_class.is_a?(String) ? root_class.constantize : root_class end From 344bd840ea62b1308852b9a7b186754d2e2a5455 Mon Sep 17 00:00:00 2001 From: Mihir Kamat Date: Fri, 3 Apr 2026 19:02:53 -0400 Subject: [PATCH 62/80] Make graph export model-side variable --- app/controllers/export_controller.rb | 7 +++---- app/helpers/export_helper.rb | 2 +- app/helpers/importable_exportable_helper.rb | 9 +++++++++ app/models/Item.rb | 2 ++ app/models/answer.rb | 1 + app/models/grades.rb | 2 ++ app/models/project_topic.rb | 2 ++ app/models/question_advice.rb | 1 + app/models/questionnaire.rb | 2 +- app/models/quiz_item.rb | 2 ++ app/models/team.rb | 1 + app/models/user.rb | 1 + app/services/export.rb | 4 ++-- spec/helpers/export_helper_spec.rb | 4 ++-- spec/integration/export_controller_spec.rb | 2 +- 15 files changed, 31 insertions(+), 11 deletions(-) diff --git a/app/controllers/export_controller.rb b/app/controllers/export_controller.rb index b469e177a..ee25ddff2 100644 --- a/app/controllers/export_controller.rb +++ b/app/controllers/export_controller.rb @@ -21,14 +21,13 @@ def export end klass = params[:class].constantize - graph_export = ActiveModel::Type::Boolean.new.cast(params[:graph_export]) csv_file = if klass == Team Team.with_assignment_context(params[:assignment_id]) do - Export.perform(klass, ordered_fields, graph_export: graph_export) + Export.perform(klass, ordered_fields) end else - Export.perform(klass, ordered_fields, graph_export: graph_export) + Export.perform(klass, ordered_fields) end render json: { @@ -43,7 +42,7 @@ def export private def export_params - params.permit(:class, :ordered_fields, :assignment_id, :graph_export) + params.permit(:class, :ordered_fields, :assignment_id) end def export_metadata_for(klass) diff --git a/app/helpers/export_helper.rb b/app/helpers/export_helper.rb index 12a280b39..828e04c86 100644 --- a/app/helpers/export_helper.rb +++ b/app/helpers/export_helper.rb @@ -20,7 +20,7 @@ def export_has_many_graph(root_class) exports = [] headers_by_class.each do |class_name, headers| - exports.concat(Export.perform(class_name.constantize, headers, graph_export: false)) + exports.concat(Export.export_csv(class_name.constantize, headers)) end exports diff --git a/app/helpers/importable_exportable_helper.rb b/app/helpers/importable_exportable_helper.rb index 91c6a385a..1ba4c9c77 100644 --- a/app/helpers/importable_exportable_helper.rb +++ b/app/helpers/importable_exportable_helper.rb @@ -147,6 +147,15 @@ def self.extended(base) end end + # set submodel export + def export_submodels(bool_export = nil) + if bool_export.nil? + @class_bool_export + else + @class_bool_export = bool_export + end + end + ## Provide filter_proc with a custom method to aggregare records and spoof nonexistent models. ## def filter(filter_proc = nil) diff --git a/app/models/Item.rb b/app/models/Item.rb index caf772c2c..7170a38d4 100644 --- a/app/models/Item.rb +++ b/app/models/Item.rb @@ -6,6 +6,8 @@ class Item < ApplicationRecord hidden_fields :id, :created_at, :updated_at external_classes ExternalClass.new(Questionnaire, true, false, :name) filter nil + export_submodels false + before_create :set_seq belongs_to :questionnaire # each item belongs to a specific questionnaire has_many :answers, dependent: :destroy, foreign_key: 'item_id' diff --git a/app/models/answer.rb b/app/models/answer.rb index 98dc95ac7..4ae7beaf3 100644 --- a/app/models/answer.rb +++ b/app/models/answer.rb @@ -7,6 +7,7 @@ class Answer < ApplicationRecord external_classes ExternalClass.new(Item, true, false, :seq), ExternalClass.new(Response, true, false, :additional_comment) + export_submodels nil filter nil belongs_to :response belongs_to :item diff --git a/app/models/grades.rb b/app/models/grades.rb index e31146fdd..ccceaa571 100644 --- a/app/models/grades.rb +++ b/app/models/grades.rb @@ -19,6 +19,8 @@ class Grades extend ImportableExportableHelper + export_submodels false + mandatory_fields :assignment_name, :team_name, :participant_name # hidden_fields :id, :created_at, :updated_at filter -> { aggregate_grades } diff --git a/app/models/project_topic.rb b/app/models/project_topic.rb index 8547d3890..096e42ec2 100644 --- a/app/models/project_topic.rb +++ b/app/models/project_topic.rb @@ -9,6 +9,8 @@ class ProjectTopic < ApplicationRecord 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 + export_submodels false + # Ensures the number of max choosers is non-negative validates :max_choosers, numericality: { diff --git a/app/models/question_advice.rb b/app/models/question_advice.rb index 5831600ce..0147b22c9 100644 --- a/app/models/question_advice.rb +++ b/app/models/question_advice.rb @@ -4,6 +4,7 @@ class QuestionAdvice < ApplicationRecord extend ImportableExportableHelper mandatory_fields :score, :advice, :item_seq hidden_fields :id, :created_at, :updated_at + export_submodels false external_classes ExternalClass.new(Item, true, false, :seq) filter nil diff --git a/app/models/questionnaire.rb b/app/models/questionnaire.rb index 4066ef3fa..6767ac841 100644 --- a/app/models/questionnaire.rb +++ b/app/models/questionnaire.rb @@ -10,7 +10,7 @@ class Questionnaire < ApplicationRecord 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 - + export_submodels true 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 4bfce92c0..90a887339 100644 --- a/app/models/quiz_item.rb +++ b/app/models/quiz_item.rb @@ -7,6 +7,8 @@ class QuizItem < Item 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 + export_submodels false + def edit end diff --git a/app/models/team.rb b/app/models/team.rb index 77f596569..eb082c0c1 100644 --- a/app/models/team.rb +++ b/app/models/team.rb @@ -7,6 +7,7 @@ class Team < ApplicationRecord mandatory_fields :name hidden_fields :id, :created_at, :updated_at filter -> { export_rows } + export_submodels false TeamExportRow = Struct.new(:team, :participants) do def initialize(team, participants) diff --git a/app/models/user.rb b/app/models/user.rb index 53a42f345..edf4b4155 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -8,6 +8,7 @@ class User < ApplicationRecord ExternalClass.new(Institution, true, false, :name) available_actions_on_duplicate SkipRecordAction.new, UpdateExistingRecordAction.new, ChangeOffendingFieldAction.new filter nil + export_submodels false has_secure_password after_initialize :set_defaults diff --git a/app/services/export.rb b/app/services/export.rb index f74618bdb..f2f25b6ea 100644 --- a/app/services/export.rb +++ b/app/services/export.rb @@ -61,8 +61,8 @@ def self.export_csv(export_class, ordered_headers) [{ name: export_class.name, contents: csv_contents }] end - def self.perform(export_class, ordered_headers = nil, graph_export: false) - return ExportHelper.export_has_many_graph(export_class) if graph_export + def self.perform(export_class, ordered_headers = nil) + return ExportHelper.export_has_many_graph(export_class) if export_class.export_submodels export_csv(export_class, ordered_headers) end diff --git a/spec/helpers/export_helper_spec.rb b/spec/helpers/export_helper_spec.rb index a89f1db69..7758e85e8 100644 --- a/spec/helpers/export_helper_spec.rb +++ b/spec/helpers/export_helper_spec.rb @@ -68,7 +68,7 @@ questionnaire_external = Item.external_classes.find { |ext| ext.ref_class == Questionnaire } allow(Item).to receive(:external_classes).and_return([questionnaire_external].compact) - result = Export.perform(Questionnaire, nil, graph_export: true) + result = Export.perform(Questionnaire, nil) exports_by_class = result.index_by { |entry| entry[:name] } expect(result).to all(include(:name, :contents)) @@ -149,7 +149,7 @@ questionnaire_external = Item.external_classes.find { |ext| ext.ref_class == Questionnaire } allow(Item).to receive(:external_classes).and_return([questionnaire_external].compact) - result = Export.perform(Questionnaire, nil, graph_export: true) + result = Export.perform(Questionnaire, nil) exports_by_class = result.index_by { |entry| entry[:name] } puts "\nCSV Exports:" diff --git a/spec/integration/export_controller_spec.rb b/spec/integration/export_controller_spec.rb index 6d95cb39e..a8675d080 100644 --- a/spec/integration/export_controller_spec.rb +++ b/spec/integration/export_controller_spec.rb @@ -44,7 +44,7 @@ def self.external_fields; ["institution"]; end export_return = [{ name: "FakeModel", contents: "fake_csv_data" }] expect(Export).to receive(:perform) - .with(FakeModel, ordered_fields, graph_export: false) + .with(FakeModel, ordered_fields) .and_return(export_return) post "/export/FakeModel", params: { From 750c4fd2a28d83d7f9944efad218a3b04b655611 Mon Sep 17 00:00:00 2001 From: Mihir Kamat Date: Fri, 3 Apr 2026 19:39:01 -0400 Subject: [PATCH 63/80] Change grades model path destination --- app/controllers/export_controller.rb | 20 ++++++++++++++++---- app/models/answer.rb | 2 +- app/models/{ => pseudo}/grades.rb | 2 ++ spec/models/grades_export_spec.rb | 6 +++--- 4 files changed, 22 insertions(+), 8 deletions(-) rename app/models/{ => pseudo}/grades.rb (99%) diff --git a/app/controllers/export_controller.rb b/app/controllers/export_controller.rb index ee25ddff2..fb8da2fd5 100644 --- a/app/controllers/export_controller.rb +++ b/app/controllers/export_controller.rb @@ -2,9 +2,21 @@ class ExportController < ApplicationController before_action :export_params - def index - klass = params[:class].constantize + def resolve_export_class(name) + # Try top-level first + return name.constantize + rescue NameError + # Try Pseudo namespace + begin + "Pseudo::#{name}".constantize + rescue NameError + nil + end + end + def index + klass = resolve_export_class(params[:class]) + render json: export_metadata_for(klass), status: :ok rescue StandardError => e render json: { error: e.message }, status: :unprocessable_entity @@ -20,8 +32,8 @@ def export return end - klass = params[:class].constantize - + klass = resolve_export_class(params[:class]) + csv_file = if klass == Team Team.with_assignment_context(params[:assignment_id]) do Export.perform(klass, ordered_fields) diff --git a/app/models/answer.rb b/app/models/answer.rb index 4ae7beaf3..e7a19a18d 100644 --- a/app/models/answer.rb +++ b/app/models/answer.rb @@ -7,7 +7,7 @@ class Answer < ApplicationRecord external_classes ExternalClass.new(Item, true, false, :seq), ExternalClass.new(Response, true, false, :additional_comment) - export_submodels nil + export_submodels false filter nil belongs_to :response belongs_to :item diff --git a/app/models/grades.rb b/app/models/pseudo/grades.rb similarity index 99% rename from app/models/grades.rb rename to app/models/pseudo/grades.rb index ccceaa571..c7b542960 100644 --- a/app/models/grades.rb +++ b/app/models/pseudo/grades.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +module Pseudo class Grades COLUMN_NAMES = %w[ assignment_id @@ -75,3 +76,4 @@ def self.aggregate_grades end.compact end end +end \ No newline at end of file diff --git a/spec/models/grades_export_spec.rb b/spec/models/grades_export_spec.rb index 64ed0f453..4e271f7b6 100644 --- a/spec/models/grades_export_spec.rb +++ b/spec/models/grades_export_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' require 'csv' -RSpec.describe Grades, type: :model do +RSpec.describe Pseudo::Grades, type: :model do describe 'grade export' do def create_scored_response(map:, item:, score:, comments:, round: 1) response = Response.create!( @@ -180,7 +180,7 @@ def create_scored_response(map:, item:, score:, comments:, round: 1) create_scored_response(map: feedback_map_for_a1, item: item, score: 3, comments: 'Feedback for Team One reviewer') create_scored_response(map: feedback_map_for_b1, item: item, score: 8, comments: 'Feedback for Team Two reviewer') - export_payload = Export.perform(Grades) + export_payload = Export.perform(Pseudo::Grades) csv_text = export_payload.first[:contents] puts "\nGrades export CSV:" @@ -188,7 +188,7 @@ def create_scored_response(map:, item:, score:, comments:, round: 1) rows = CSV.parse(csv_text, headers: true) - expect(rows.headers).to eq(Grades::COLUMN_NAMES) + expect(rows.headers).to eq(Pseudo::Grades::COLUMN_NAMES) expect(rows.size).to eq(4) row_by_participant = rows.index_by { |row| row['participant_name'] } From d682d0b737c6bd32545b0e93bed918d979953d2d Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Sun, 19 Apr 2026 12:14:09 -0500 Subject: [PATCH 64/80] added backend implementation for course import/export --- app/models/course.rb | 35 ++++++++++++ spec/requests/import_export_requests_spec.rb | 57 ++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/app/models/course.rb b/app/models/course.rb index f33f1875f..6df9ccd07 100644 --- a/app/models/course.rb +++ b/app/models/course.rb @@ -1,17 +1,27 @@ # frozen_string_literal: true class Course < ApplicationRecord + extend ImportableExportableHelper + attr_accessor :institution_name, :instructor_name + + mandatory_fields :name, :directory_path, :institution_name, :instructor_name + hidden_fields :id, :created_at, :updated_at, :institution_id, :instructor_id + export_submodels false + belongs_to :instructor, class_name: 'User', foreign_key: 'instructor_id' belongs_to :institution, foreign_key: 'institution_id' has_many :assignments, dependent: :destroy validates :name, presence: true validates :directory_path, presence: true + validate :import_references_must_exist 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 has_many :tas, through: :ta_mappings, source: :ta has_many :teams, class_name: 'CourseTeam', foreign_key: 'parent_id', dependent: :destroy, inverse_of: :course + before_validation :assign_import_references + # Returns the submission directory for the course def path raise 'Path can not be created as the course must be associated with an instructor.' if instructor_id.nil? @@ -56,4 +66,29 @@ def copy_course new_course.name += '_copy' new_course.save end + + def institution_name + @institution_name.presence || institution&.name + end + + def instructor_name + @instructor_name.presence || instructor&.name + end + + private + + def assign_import_references + self.institution = Institution.find_by(name: institution_name) if institution.blank? && institution_name.present? + self.instructor = User.find_by(name: instructor_name) if instructor.blank? && instructor_name.present? + end + + def import_references_must_exist + if institution.blank? && institution_name.present? + errors.add(:institution_name, 'could not be found') + end + + if instructor.blank? && instructor_name.present? + errors.add(:instructor_name, 'could not be found') + end + end end diff --git a/spec/requests/import_export_requests_spec.rb b/spec/requests/import_export_requests_spec.rb index 3bb07dea5..701e374bc 100644 --- a/spec/requests/import_export_requests_spec.rb +++ b/spec/requests/import_export_requests_spec.rb @@ -60,6 +60,16 @@ def uploaded_csv(contents) 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 import metadata with instructor_name and institution_name" do + get "/import/Course" + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + expect(json["mandatory_fields"]).to include("name", "directory_path", "instructor_name", "institution_name") + expect(json["mandatory_fields"]).not_to include("instructor_id", "institution_id") + end end end @@ -184,6 +194,29 @@ def uploaded_csv(contents) expect(imported_user.role_id).to eq(student_role.id) end end + + context "course imports" do + it "imports courses using instructor_name and institution_name" do + other_institution = Institution.create!(name: "Other School") + file = uploaded_csv("name,directory_path,info,private,instructor_name,institution_name\nImported Course,imported_course,Imported info,true,teacher,Other School\n") + + post "/import/Course", + params: { + csv_file: file, + use_headers: true + } + + expect(response).to have_http_status(:created) + + imported_course = Course.find_by(name: "Imported Course") + expect(imported_course).to be_present + expect(imported_course.directory_path).to eq("imported_course") + expect(imported_course.info).to eq("Imported info") + expect(imported_course.private).to eq(true) + expect(imported_course.instructor_id).to eq(instructor.id) + expect(imported_course.institution_id).to eq(other_institution.id) + end + end end describe "POST /export/:class" do @@ -264,5 +297,29 @@ def uploaded_csv(contents) expect(json["file"]).to include("Export Topic") end end + + context "course exports" do + it "exports courses with instructor_name and institution_name" do + course = Course.create!( + name: "Export Course", + directory_path: "export_course", + info: "Export info", + private: true, + instructor: instructor, + institution: institution + ) + + post "/export/Course", params: { ordered_fields: %w[name directory_path private instructor_name institution_name].to_json } + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + exported_file = Array(json["file"]).first + + expect(exported_file["name"]).to eq("Course") + expect(exported_file["contents"]).to include("name,directory_path,private,instructor_name,institution_name") + expect(exported_file["contents"]).to include("#{course.name},#{course.directory_path},true,#{instructor.name},#{institution.name}") + end + end end end From a996ddee3bc21fdea6e5f9870966655e53f41944 Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Sun, 19 Apr 2026 13:42:53 -0500 Subject: [PATCH 65/80] added comments to teams and topics files to newly added methods for a readability improvement. --- app/controllers/teams_controller.rb | 1 + app/models/team.rb | 23 +++++++++++++++++++++ app/serializers/project_topic_serializer.rb | 4 ++++ app/serializers/team_serializer.rb | 7 ++++++- 4 files changed, 34 insertions(+), 1 deletion(-) diff --git a/app/controllers/teams_controller.rb b/app/controllers/teams_controller.rb index b827f7086..98f0de23b 100644 --- a/app/controllers/teams_controller.rb +++ b/app/controllers/teams_controller.rb @@ -117,6 +117,7 @@ def team_params 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) diff --git a/app/models/team.rb b/app/models/team.rb index eb082c0c1..8e06da89f 100644 --- a/app/models/team.rb +++ b/app/models/team.rb @@ -10,15 +10,18 @@ class Team < ApplicationRecord export_submodels false 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 ids by position. def method_missing(method_name, *_args) method = method_name.to_s return super unless method.start_with?(TEAM_PARTICIPANT_COLUMN_PREFIX) @@ -27,6 +30,7 @@ def method_missing(method_name, *_args) participants[index]&.id 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 @@ -86,6 +90,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 @@ -191,38 +196,46 @@ def can_participant_join_team?(participant) 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 participant columns as optional so CSVs can omit unused slots. def optional_fields participant_field_names 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 ids in stable column order. def export_rows export_scope.includes(:participants).map do |team| TeamExportRow.new(team, team.participants.order(:id).to_a) end end + # Imports teams from CSV rows and attaches participants by exported participant id columns. def try_import_records(file, headers, use_header, defaults = {}) csv_table = CSV.read(file, headers: use_header) normalized_headers = @@ -254,6 +267,7 @@ def with_assignment_context(assignment_id) 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| @@ -275,6 +289,7 @@ def import_team_row(row, mapping, defaults) end 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? @@ -285,19 +300,23 @@ def find_or_build_import_team(row_hash, defaults) find_or_initialize_by(name: name, type: 'AssignmentTeam', parent_id: assignment_id) end + # Resolves a participant id from the CSV into the correct participant subtype for the team. def find_import_participant(team, participant_id) participant_class = participant_class_for(team.type) participant_class.find_by(id: participant_id, parent_id: team.parent_id) 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 @@ -307,6 +326,7 @@ def participant_column_count DEFAULT_TEAM_IMPORT_EXPORT_PARTICIPANT_COLUMNS end + # Extracts non-blank participant ids from the current imported row. def participant_ids_from_row(row_hash) row_hash .slice(*participant_field_names) @@ -315,15 +335,18 @@ def participant_ids_from_row(row_hash) .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 diff --git a/app/serializers/project_topic_serializer.rb b/app/serializers/project_topic_serializer.rb index 05c8f1fb2..5c830e359 100644 --- a/app/serializers/project_topic_serializer.rb +++ b/app/serializers/project_topic_serializer.rb @@ -5,20 +5,24 @@ class ProjectTopicSerializer < ActiveModel::Serializer :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| { diff --git a/app/serializers/team_serializer.rb b/app/serializers/team_serializer.rb index b00ec6036..ae0a79045 100644 --- a/app/serializers/team_serializer.rb +++ b/app/serializers/team_serializer.rb @@ -5,25 +5,30 @@ class TeamSerializer < ActiveModel::Serializer 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 From 4e79f2bb459677a1d8ad8bb6e0f0a5a72ceb7ebe Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Sun, 19 Apr 2026 20:01:24 -0500 Subject: [PATCH 66/80] changed import and export controllers to explicitly list out the supported classes. Additionally removed references to Sign_up_topic shim. --- app/controllers/export_controller.rb | 27 +++++++++++++------- app/controllers/import_controller.rb | 24 +++++++++++++++-- spec/requests/import_export_requests_spec.rb | 12 ++++----- 3 files changed, 46 insertions(+), 17 deletions(-) diff --git a/app/controllers/export_controller.rb b/app/controllers/export_controller.rb index fb8da2fd5..f314c1224 100644 --- a/app/controllers/export_controller.rb +++ b/app/controllers/export_controller.rb @@ -1,21 +1,29 @@ # This controller handles exporting data from the application to various formats. class ExportController < ApplicationController + SUPPORTED_EXPORT_CLASSES = { + "User" => User, + "Team" => Team, + "Course" => Course, + "Assignment" => Assignment, + "ProjectTopic" => ProjectTopic, + "Questionnaire" => Questionnaire, + "Item" => Item, + "QuestionAdvice" => QuestionAdvice, + "Answer" => Answer, + "QuizItem" => QuizItem, + "Grades" => Pseudo::Grades, + "Pseudo::Grades" => Pseudo::Grades + }.freeze + before_action :export_params def resolve_export_class(name) - # Try top-level first - return name.constantize - rescue NameError - # Try Pseudo namespace - begin - "Pseudo::#{name}".constantize - rescue NameError - nil - end + 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 @@ -33,6 +41,7 @@ def export 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 diff --git a/app/controllers/import_controller.rb b/app/controllers/import_controller.rb index 71e63e167..887de8f8f 100644 --- a/app/controllers/import_controller.rb +++ b/app/controllers/import_controller.rb @@ -14,6 +14,19 @@ # class ImportController < ApplicationController + SUPPORTED_IMPORT_CLASSES = { + "User" => User, + "Team" => Team, + "Course" => Course, + "Assignment" => Assignment, + "ProjectTopic" => ProjectTopic, + "Questionnaire" => Questionnaire, + "Item" => Item, + "QuestionAdvice" => QuestionAdvice, + "Answer" => Answer, + "QuizItem" => QuizItem + }.freeze + # Ensure strong parameters are processed before each action before_action :import_params @@ -24,9 +37,11 @@ class ImportController < ApplicationController # The frontend uses this to build the mapping UI (drag/drop field matching). # def index - imported_class = params[:class].constantize + 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 ## @@ -48,7 +63,7 @@ def import ordered_fields = JSON.parse(params[:ordered_fields]) if params[:ordered_fields] # Dynamically load the model class (e.g., "User", "Team", etc.) - klass = params[:class].constantize + klass = resolve_import_class!(params[:class]) defaults = import_defaults_for(klass) # Load the chosen duplicate action (Skip, Update, Change) @@ -113,4 +128,9 @@ def team_import_defaults { 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/spec/requests/import_export_requests_spec.rb b/spec/requests/import_export_requests_spec.rb index 3bb07dea5..ca4daa202 100644 --- a/spec/requests/import_export_requests_spec.rb +++ b/spec/requests/import_export_requests_spec.rb @@ -36,8 +36,8 @@ def uploaded_csv(contents) ) end - it "returns metadata for SignUpTopic" do - get "/import/SignUpTopic" + it "returns metadata for ProjectTopic" do + get "/import/ProjectTopic" expect(response).to have_http_status(:ok) @@ -132,7 +132,7 @@ def uploaded_csv(contents) it "imports topics" do file = uploaded_csv("topic_name,assignment_id\nTopic A,#{assignment.id}\n") - post "/import/SignUpTopic", + post "/import/ProjectTopic", params: { csv_file: file, use_headers: true, @@ -140,7 +140,7 @@ def uploaded_csv(contents) } expect(response).to have_http_status(:created) - expect(SignUpTopic.find_by(topic_name: "Topic A", assignment_id: assignment.id)).to be_present + expect(ProjectTopic.find_by(topic_name: "Topic A", assignment_id: assignment.id)).to be_present end end @@ -222,7 +222,7 @@ def uploaded_csv(contents) end let!(:topic) do - SignUpTopic.create!( + ProjectTopic.create!( topic_name: "Export Topic", assignment_id: assignment.id ) @@ -255,7 +255,7 @@ def uploaded_csv(contents) context "topic exports" do it "exports topics" do - post "/export/SignUpTopic", params: { ordered_fields: %w[topic_name assignment_id].to_json } + post "/export/ProjectTopic", params: { ordered_fields: %w[topic_name assignment_id].to_json } expect(response).to have_http_status(:ok) From 80f28cea565e3732c76a565e391119513ad11a5b Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Thu, 23 Apr 2026 20:49:32 -0500 Subject: [PATCH 67/80] reworked grade export to live in the controller with minimal fields. Currently scoped to assignment. --- app/controllers/export_controller.rb | 10 +- app/controllers/grades_controller.rb | 47 +++- app/models/pseudo/grades.rb | 79 ------ config/routes.rb | 11 +- ...ment_for_submission_to_teams_if_missing.rb | 5 + db/schema.rb | 3 +- spec/models/grades_export_spec.rb | 235 ------------------ .../requests/api/v1/grades_controller_spec.rb | 74 +++++- 8 files changed, 127 insertions(+), 337 deletions(-) delete mode 100644 app/models/pseudo/grades.rb create mode 100644 db/migrate/20260424000000_add_comment_for_submission_to_teams_if_missing.rb delete mode 100644 spec/models/grades_export_spec.rb diff --git a/app/controllers/export_controller.rb b/app/controllers/export_controller.rb index fb8da2fd5..9b5a04185 100644 --- a/app/controllers/export_controller.rb +++ b/app/controllers/export_controller.rb @@ -3,15 +3,7 @@ class ExportController < ApplicationController before_action :export_params def resolve_export_class(name) - # Try top-level first - return name.constantize - rescue NameError - # Try Pseudo namespace - begin - "Pseudo::#{name}".constantize - rescue NameError - nil - end + name.constantize end def index diff --git a/app/controllers/grades_controller.rb b/app/controllers/grades_controller.rb index 2687d23a6..ac5818ecd 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,30 @@ def set_participant_and_team_via_assignment @assignment = @participant.assignment end + 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 +417,4 @@ def get_answer(score, index) reviewee_name: reviewee_name } end -end \ No newline at end of file +end diff --git a/app/models/pseudo/grades.rb b/app/models/pseudo/grades.rb deleted file mode 100644 index c7b542960..000000000 --- a/app/models/pseudo/grades.rb +++ /dev/null @@ -1,79 +0,0 @@ -# frozen_string_literal: true - -module Pseudo -class Grades - COLUMN_NAMES = %w[ - assignment_id - assignment_name - team_id - team_name - participant_id - participant_name - participant_email - submission_grade - review_grade - teammate_review_grade - author_feedback_grade - ].freeze - - Row = Struct.new(*COLUMN_NAMES.map(&:to_sym), keyword_init: true) - - extend ImportableExportableHelper - - export_submodels false - - mandatory_fields :assignment_name, :team_name, :participant_name - # hidden_fields :id, :created_at, :updated_at - filter -> { aggregate_grades } - - # Spoof a table-backed model so ImportableExportableHelper can treat - # Grades like a normal exportable class. - def self.column_names - COLUMN_NAMES - end - - def self.aggregate_grades - AssignmentParticipant.includes(:user).map do |participant| - assignment = participant.assignment - team = participant.team - next if assignment.nil? || team.nil? - - teammate_ids = team.participants.where.not(id: participant.id).pluck(:id) - - reviews_of_me_maps = TeammateReviewResponseMap.where( - reviewed_object_id: assignment.id, - reviewee_id: participant.id, - reviewer_id: teammate_ids - ).to_a - - reviews_by_me_maps = TeammateReviewResponseMap.where( - reviewed_object_id: assignment.id, - reviewer_id: participant.id - ).to_a - - my_reviews_of_other_teams_maps = ReviewResponseMap.where( - reviewed_object_id: assignment.id, - reviewer_id: participant.id - ) - - feedback_from_my_reviewees_maps = my_reviews_of_other_teams_maps.filter_map do |map| - FeedbackResponseMap.find_by(reviewed_object_id: map.id, reviewee_id: participant.id) - end - - Row.new( - assignment_id: assignment.id, - assignment_name: assignment.name, - team_id: team.id, - team_name: team.name, - participant_id: participant.id, - participant_name: participant.user_name, - participant_email: participant.user&.email, - submission_grade: team.grade_for_submission, - review_grade: team.aggregate_review_grade, - teammate_review_grade: participant.aggregate_teammate_review_grade(reviews_of_me_maps), - author_feedback_grade: participant.aggregate_teammate_review_grade(feedback_from_my_reviewees_maps) - ) - end.compact - end -end -end \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 22b158c75..c7cffa391 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -195,11 +195,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' 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 fd74f89f5..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_28_170000) 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" @@ -392,6 +392,7 @@ 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 diff --git a/spec/models/grades_export_spec.rb b/spec/models/grades_export_spec.rb deleted file mode 100644 index 4e271f7b6..000000000 --- a/spec/models/grades_export_spec.rb +++ /dev/null @@ -1,235 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' -require 'csv' - -RSpec.describe Pseudo::Grades, type: :model do - describe 'grade export' do - def create_scored_response(map:, item:, score:, comments:, round: 1) - response = Response.create!( - map_id: map.id, - additional_comment: comments, - is_submitted: true, - round: round - ) - - Answer.create!( - response: response, - item: item, - answer: score, - comments: comments - ) - - response - end - - it 'exports one csv row per participant with the expected grades columns' do - instructor = Instructor.create!( - name: 'gradesinstructor', - email: 'gradesinstructor@example.com', - full_name: 'Grades Instructor', - password: 'password', - role: create(:role, :instructor), - institution: create(:institution) - ) - assignment = create(:assignment, instructor: instructor, name: 'Grades Export Assignment') - non_clashing_assignment_id = [ - Assignment.maximum(:id), - ResponseMap.maximum(:id), - Response.maximum(:id) - ].compact.max.to_i + 10_000 - assignment.update_column(:id, non_clashing_assignment_id) - assignment.reload - - questionnaire = Questionnaire.create!( - name: 'Grades Export 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 strong was the work?', - weight: 1, - seq: 1, - question_type: 'Scale', - break_before: true - ) - - AssignmentQuestionnaire.create!( - assignment: assignment, - questionnaire: questionnaire, - used_in_round: 1, - notification_limit: 5, - questionnaire_weight: 100 - ) - - alice = create(:user, :student, name: 'alice_export', email: 'alice_export@example.com', full_name: 'Alice Export') - aaron = create(:user, :student, name: 'aaron_export', email: 'aaron_export@example.com', full_name: 'Aaron Export') - bella = create(:user, :student, name: 'bella_export', email: 'bella_export@example.com', full_name: 'Bella Export') - ben = create(:user, :student, name: 'ben_export', email: 'ben_export@example.com', full_name: 'Ben Export') - - participant_a1 = create(:assignment_participant, assignment: assignment, user: alice, handle: alice.name) - participant_a2 = create(:assignment_participant, assignment: assignment, user: aaron, handle: aaron.name) - participant_b1 = create(:assignment_participant, assignment: assignment, user: bella, handle: bella.name) - participant_b2 = create(:assignment_participant, assignment: assignment, user: ben, handle: ben.name) - - team_one = AssignmentTeam.create!( - name: 'Team One', - parent_id: assignment.id, - type: 'AssignmentTeam', - grade_for_submission: 88 - ) - team_two = AssignmentTeam.create!( - name: 'Team Two', - parent_id: assignment.id, - type: 'AssignmentTeam', - grade_for_submission: 93 - ) - - expect(team_one.add_member(participant_a1)[:success]).to be(true) - expect(team_one.add_member(participant_a2)[:success]).to be(true) - expect(team_two.add_member(participant_b1)[:success]).to be(true) - expect(team_two.add_member(participant_b2)[:success]).to be(true) - - 3.times do |index| - ReviewResponseMap.create!( - reviewed_object_id: assignment.id, - reviewer_id: participant_a1.id, - reviewee_id: team_two.id - ) - Response.create!( - map_id: ReviewResponseMap.last.id, - additional_comment: "throwaway review #{index}", - is_submitted: true, - round: 1 - ) - end - - poor_review_map = ReviewResponseMap.create!( - reviewed_object_id: assignment.id, - reviewer_id: participant_a1.id, - reviewee_id: team_two.id - ) - good_review_map = ReviewResponseMap.create!( - reviewed_object_id: assignment.id, - reviewer_id: participant_b1.id, - reviewee_id: team_one.id - ) - - poor_review_response = create_scored_response( - map: poor_review_map, - item: item, - score: 2, - comments: 'Poor review from Team One to Team Two' - ) - good_review_response = create_scored_response( - map: good_review_map, - item: item, - score: 9, - comments: 'Strong review from Team Two to Team One' - ) - - expect(assignment.id).not_to eq(poor_review_map.id) - expect(assignment.id).not_to eq(good_review_map.id) - expect(assignment.id).not_to eq(poor_review_response.id) - expect(assignment.id).not_to eq(good_review_response.id) - - teammate_map_a1 = TeammateReviewResponseMap.create!( - reviewed_object_id: assignment.id, - reviewer_id: participant_a1.id, - reviewee_id: participant_a2.id - ) - teammate_map_a2 = TeammateReviewResponseMap.create!( - reviewed_object_id: assignment.id, - reviewer_id: participant_a2.id, - reviewee_id: participant_a1.id - ) - teammate_map_b1 = TeammateReviewResponseMap.create!( - reviewed_object_id: assignment.id, - reviewer_id: participant_b1.id, - reviewee_id: participant_b2.id - ) - teammate_map_b2 = TeammateReviewResponseMap.create!( - reviewed_object_id: assignment.id, - reviewer_id: participant_b2.id, - reviewee_id: participant_b1.id - ) - - create_scored_response(map: teammate_map_a1, item: item, score: 7, comments: 'A1 reviewing A2') - create_scored_response(map: teammate_map_a2, item: item, score: 8, comments: 'A2 reviewing A1') - create_scored_response(map: teammate_map_b1, item: item, score: 6, comments: 'B1 reviewing B2') - create_scored_response(map: teammate_map_b2, item: item, score: 9, comments: 'B2 reviewing B1') - - feedback_map_for_a1 = FeedbackResponseMap.create!( - reviewed_object_id: poor_review_map.id, - reviewer_id: participant_b1.id, - reviewee_id: participant_a1.id - ) - feedback_map_for_b1 = FeedbackResponseMap.create!( - reviewed_object_id: good_review_map.id, - reviewer_id: participant_a1.id, - reviewee_id: participant_b1.id - ) - - create_scored_response(map: feedback_map_for_a1, item: item, score: 3, comments: 'Feedback for Team One reviewer') - create_scored_response(map: feedback_map_for_b1, item: item, score: 8, comments: 'Feedback for Team Two reviewer') - - export_payload = Export.perform(Pseudo::Grades) - csv_text = export_payload.first[:contents] - - puts "\nGrades export CSV:" - puts csv_text - - rows = CSV.parse(csv_text, headers: true) - - expect(rows.headers).to eq(Pseudo::Grades::COLUMN_NAMES) - expect(rows.size).to eq(4) - - row_by_participant = rows.index_by { |row| row['participant_name'] } - - expect(row_by_participant.keys).to contain_exactly( - participant_a1.user_name, - participant_a2.user_name, - participant_b1.user_name, - participant_b2.user_name - ) - - expect(row_by_participant[participant_a1.user_name]['assignment_id']).to eq(assignment.id.to_s) - expect(row_by_participant[participant_a1.user_name]['assignment_name']).to eq(assignment.name) - expect(row_by_participant[participant_a1.user_name]['team_name']).to eq(team_one.name) - expect(row_by_participant[participant_a1.user_name]['participant_email']).to eq(alice.email) - - expect(row_by_participant[participant_a2.user_name]['assignment_id']).to eq(assignment.id.to_s) - expect(row_by_participant[participant_a2.user_name]['team_name']).to eq(team_one.name) - expect(row_by_participant[participant_a2.user_name]['participant_email']).to eq(aaron.email) - - expect(row_by_participant[participant_b1.user_name]['assignment_id']).to eq(assignment.id.to_s) - expect(row_by_participant[participant_b1.user_name]['team_name']).to eq(team_two.name) - expect(row_by_participant[participant_b1.user_name]['participant_email']).to eq(bella.email) - - expect(row_by_participant[participant_b2.user_name]['assignment_id']).to eq(assignment.id.to_s) - expect(row_by_participant[participant_b2.user_name]['team_name']).to eq(team_two.name) - expect(row_by_participant[participant_b2.user_name]['participant_email']).to eq(ben.email) - - rows.each do |row| - expect(row['team_id']).to be_present - expect(row['participant_id']).to be_present - expect(row['submission_grade']).to be_present - expect(row['review_grade']).to be_present - end - - expect(row_by_participant[participant_a1.user_name]['author_feedback_grade']).to be_present - expect(row_by_participant[participant_b1.user_name]['author_feedback_grade']).to be_present - expect(row_by_participant[participant_a1.user_name]['teammate_review_grade']).to be_present - expect(row_by_participant[participant_a2.user_name]['teammate_review_grade']).to be_present - expect(row_by_participant[participant_b1.user_name]['teammate_review_grade']).to be_present - expect(row_by_participant[participant_b2.user_name]['teammate_review_grade']).to be_present - 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 From 96ca219d42efc4335d89686ddabe1d59ebbe09e3 Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Sat, 25 Apr 2026 10:08:19 -0500 Subject: [PATCH 68/80] committing initial draft of changes. --- .../questionnaire_packages_controller.rb | 79 +++++ app/controllers/questionnaires_controller.rb | 11 +- app/models/Item.rb | 1 + .../questionnaire_package_export_service.rb | 172 ++++++++++ .../questionnaire_package_import_service.rb | 279 ++++++++++++++++ config/routes.rb | 32 +- db/schema.rb | 3 +- .../api/v1/questionnaires_controller_spec.rb | 61 +++- spec/requests/questionnaire_packages_spec.rb | 305 ++++++++++++++++++ 9 files changed, 929 insertions(+), 14 deletions(-) create mode 100644 app/controllers/questionnaire_packages_controller.rb create mode 100644 app/services/questionnaire_package_export_service.rb create mode 100644 app/services/questionnaire_package_import_service.rb create mode 100644 spec/requests/questionnaire_packages_spec.rb diff --git a/app/controllers/questionnaire_packages_controller.rb b/app/controllers/questionnaire_packages_controller.rb new file mode 100644 index 000000000..d53e8a89d --- /dev/null +++ b/app/controllers/questionnaire_packages_controller.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'base64' + +class QuestionnairePackagesController < ApplicationController + ALLOWED_DUPLICATE_ACTIONS = { + 'SkipRecordAction' => SkipRecordAction, + 'UpdateExistingRecordAction' => UpdateExistingRecordAction, + 'ChangeOffendingFieldAction' => ChangeOffendingFieldAction + }.freeze + + before_action :questionnaire_package_params + + def package_config + render json: { + required_files: QuestionnairePackageImportService::REQUIRED_FILES, + package_type: QuestionnairePackageImportService::PACKAGE_TYPE, + version: QuestionnairePackageImportService::VERSION, + available_actions_on_dup: ALLOWED_DUPLICATE_ACTIONS.keys + }, status: :ok + end + + 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).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 + + def import + uploaded_file = params[:package_file] + dup_action = duplicate_action_for(params[:dup_action]) + result = QuestionnairePackageImportService.new(file: uploaded_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 + + private + + def questionnaire_package_params + params.permit(:package_file, :dup_action, :export_all, questionnaire_ids: []) + end + + 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 + + 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..5fed116bc 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 + + # Items method returns the items belonging to questionnaire with id = {:id} + # 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/models/Item.rb b/app/models/Item.rb index 7170a38d4..1b317605b 100644 --- a/app/models/Item.rb +++ b/app/models/Item.rb @@ -11,6 +11,7 @@ class Item < ApplicationRecord before_create :set_seq belongs_to :questionnaire # each item belongs to a specific questionnaire has_many :answers, dependent: :destroy, foreign_key: 'item_id' + has_many :question_advices, dependent: :destroy, foreign_key: 'item_id' attr_accessor :choice_strategy validates :seq, presence: true, numericality: true # sequence must be numeric diff --git a/app/services/questionnaire_package_export_service.rb b/app/services/questionnaire_package_export_service.rb new file mode 100644 index 000000000..2f65cc18a --- /dev/null +++ b/app/services/questionnaire_package_export_service.rb @@ -0,0 +1,172 @@ +# frozen_string_literal: true + +require 'csv' +require 'json' +require 'zip' + +class QuestionnairePackageExportService + PACKAGE_TYPE = 'questionnaire_template_export' + VERSION = 1 + FILES = %w[questionnaires.csv items.csv question_advices.csv].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 + instruction_loc + 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) + @questionnaires = questionnaires + end + + 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)) + + 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: 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) + + zip.put_next_entry('question_advices.csv') + zip.write(question_advice_csv) + 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: exportable_questionnaires.sum do |questionnaire| + exportable_items_for(questionnaire).sum { |item| item.question_advices.size } + end + } + } + end + + private + + def questionnaire_scope + scope = @questionnaires || Questionnaire.all + scope.where.not(questionnaire_type: 'QuizQuestionnaire') + end + + 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.instruction_loc, + questionnaire.instructor&.name + ] + end + end + + 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 + + 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 + + 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..9db77039c --- /dev/null +++ b/app/services/questionnaire_package_import_service.rb @@ -0,0 +1,279 @@ +# frozen_string_literal: true + +require 'csv' +require 'json' +require 'set' +require 'zip' + +class QuestionnairePackageImportService + PACKAGE_TYPE = QuestionnairePackageExportService::PACKAGE_TYPE + VERSION = QuestionnairePackageExportService::VERSION + REQUIRED_FILES = %w[manifest.json questionnaires.csv items.csv question_advices.csv].freeze + DEFAULT_DUPLICATE_ACTION = ChangeOffendingFieldAction.new + + def initialize(file:, dup_action: nil) + @file = file + @duplicate_action = dup_action || DEFAULT_DUPLICATE_ACTION + end + + def perform + raise StandardError, 'A questionnaire package zip file is required.' if @file.blank? + + entries = read_zip_entries + validate_package!(entries) + + questionnaire_rows = parse_csv(entries['questionnaires.csv']) + item_rows = parse_csv(entries['items.csv']) + question_advice_rows = parse_csv(entries['question_advices.csv']) + + 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 + + private + + def read_zip_entries + entries = {} + + Zip::File.open(@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 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 + + def parse_csv(contents) + CSV.parse(contents, headers: true).map do |row| + row.to_h.transform_keys { |key| normalize_header(key) } + end + end + + def normalize_header(header) + header.to_s.parameterize.underscore + end + + 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 + + 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 + + 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 + + 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 + + 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 + + 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 + + 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 + + def update_existing_questionnaire(existing, incoming) + existing.assign_attributes( + incoming.attributes.slice( + 'questionnaire_type', + 'display_type', + 'private', + 'min_question_score', + 'max_question_score', + 'instruction_loc' + ) + ) + existing + end + + 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 + + def questionnaire_source_key(name, instructor_name) + "#{instructor_name}::#{name}" + end + + def item_source_key(questionnaire_name, instructor_name, seq, txt) + "#{questionnaire_source_key(questionnaire_name, instructor_name)}::#{seq}::#{txt}" + end + + def normalize_boolean(value) + ActiveRecord::Type::Boolean.new.deserialize(value) + end +end diff --git a/config/routes.rb b/config/routes.rb index 22b158c75..67fde8aa1 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 @@ -220,10 +223,17 @@ 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 -end + resources :export, path: :export, only: [] do + collection do + get "/:class", to: "export#index" + post "/:class", to: "export#export" + end + end + resources :questionnaire_packages, only: [] do + collection do + get :config, action: :package_config + post :export + post :import + end + end +end diff --git a/db/schema.rb b/db/schema.rb index fd74f89f5..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_28_170000) 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" @@ -392,6 +392,7 @@ 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 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/questionnaire_packages_spec.rb b/spec/requests/questionnaire_packages_spec.rb new file mode 100644 index 000000000..ba09e02d0 --- /dev/null +++ b/spec/requests/questionnaire_packages_spec.rb @@ -0,0 +1,305 @@ +# 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', 'question_advices.csv') + 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 '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 + ) + response = Response.create!( + map_id: response_map.id, + additional_comment: 'Do not export this response' + ) + Answer.create!( + item: item, + response: 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.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 + end + + describe 'POST /questionnaire_packages/import' do + 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,instruction_loc,instructor_name + Imported Questionnaire,ReviewQuestionnaire,Likert,false,0,5,instructions,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,instruction_loc,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 '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,instruction_loc,instructor_name + Duplicate Questionnaire,ReviewQuestionnaire,Likert,false,0,5,new instructions,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 +end From 4c6a4c0d0ed53dcf99901d2aac2972c522ca7f94 Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Sat, 25 Apr 2026 16:29:35 -0500 Subject: [PATCH 69/80] split up CSV questionnaire, item, advices imports. Added additional tests to support this functionality. --- .../questionnaire_packages_controller.rb | 30 +++- app/controllers/questionnaires_controller.rb | 2 +- app/models/Item.rb | 1 + .../questionnaire_package_export_service.rb | 8 ++ .../questionnaire_package_import_service.rb | 129 ++++++++++++++++-- config/routes.rb | 2 + spec/requests/questionnaire_packages_spec.rb | 81 ++++++++++- 7 files changed, 235 insertions(+), 18 deletions(-) diff --git a/app/controllers/questionnaire_packages_controller.rb b/app/controllers/questionnaire_packages_controller.rb index d53e8a89d..872b2715a 100644 --- a/app/controllers/questionnaire_packages_controller.rb +++ b/app/controllers/questionnaire_packages_controller.rb @@ -2,6 +2,9 @@ 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, @@ -11,15 +14,18 @@ class QuestionnairePackagesController < ApplicationController 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, package_type: QuestionnairePackageImportService::PACKAGE_TYPE, version: QuestionnairePackageImportService::VERSION, available_actions_on_dup: ALLOWED_DUPLICATE_ACTIONS.keys }, status: :ok 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]) @@ -42,10 +48,17 @@ def export 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 - uploaded_file = params[:package_file] dup_action = duplicate_action_for(params[:dup_action]) - result = QuestionnairePackageImportService.new(file: uploaded_file, dup_action: dup_action).perform + 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 @@ -54,10 +67,20 @@ def import private + # Permit package-only fields without mixing them into questionnaire params. def questionnaire_package_params - params.permit(:package_file, :dup_action, :export_all, questionnaire_ids: []) + params.permit( + :package_file, + :questionnaire_file, + :items_file, + :question_advices_file, + :dup_action, + :export_all, + questionnaire_ids: [] + ) 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) @@ -68,6 +91,7 @@ def parse_questionnaire_ids [] end + # Reuse duplicate-action classes through a package-specific allowlist. def duplicate_action_for(action_name) return nil if action_name.blank? diff --git a/app/controllers/questionnaires_controller.rb b/app/controllers/questionnaires_controller.rb index 5fed116bc..c4630a99c 100644 --- a/app/controllers/questionnaires_controller.rb +++ b/app/controllers/questionnaires_controller.rb @@ -18,7 +18,7 @@ def show end end - # Items method returns the items belonging to questionnaire with id = {:id} + # Lightweight item list for the package export modal. # GET on /questionnaires/:id/items def items @questionnaire = Questionnaire.find(params[:id]) diff --git a/app/models/Item.rb b/app/models/Item.rb index 1b317605b..80b698aab 100644 --- a/app/models/Item.rb +++ b/app/models/Item.rb @@ -11,6 +11,7 @@ class Item < ApplicationRecord 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 diff --git a/app/services/questionnaire_package_export_service.rb b/app/services/questionnaire_package_export_service.rb index 2f65cc18a..df1deabe0 100644 --- a/app/services/questionnaire_package_export_service.rb +++ b/app/services/questionnaire_package_export_service.rb @@ -4,6 +4,8 @@ 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 @@ -49,6 +51,7 @@ def initialize(questionnaires: nil) @questionnaires = questionnaires end + # Builds the manifest and ordered CSVs used by the matching import service. def perform exportable_questionnaires = questionnaire_scope .includes(items: :question_advices) @@ -100,11 +103,13 @@ def perform private + # 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| [ @@ -120,6 +125,7 @@ def questionnaire_rows(questionnaires) 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| @@ -140,6 +146,7 @@ def item_rows(questionnaires) 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| @@ -157,6 +164,7 @@ def question_advice_rows(questionnaires) 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) diff --git a/app/services/questionnaire_package_import_service.rb b/app/services/questionnaire_package_import_service.rb index 9db77039c..817e743c2 100644 --- a/app/services/questionnaire_package_import_service.rb +++ b/app/services/questionnaire_package_import_service.rb @@ -5,26 +5,61 @@ 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 question_advices.csv].freeze + QUESTIONNAIRE_REQUIRED_HEADERS = %w[ + name + questionnaire_type + display_type + private + min_question_score + max_question_score + instruction_loc + 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(file:, dup_action: nil) - @file = file + 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 - raise StandardError, 'A questionnaire package zip file is required.' if @file.blank? + csv_sources = resolve_csv_sources - entries = read_zip_entries - validate_package!(entries) - - questionnaire_rows = parse_csv(entries['questionnaires.csv']) - item_rows = parse_csv(entries['items.csv']) - question_advice_rows = parse_csv(entries['question_advices.csv']) + questionnaire_rows = parse_csv(csv_sources.fetch(:questionnaires), :questionnaires) + item_rows = parse_csv(csv_sources[:items], :items) + question_advice_rows = parse_csv(csv_sources[:question_advices], :question_advices) imported_counts = { questionnaires: 0, @@ -61,10 +96,32 @@ def perform private + # 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(@file.path) do |zip_file| + Zip::File.open(@package_file.path) do |zip_file| zip_file.each do |entry| next if entry.directory? @@ -77,6 +134,15 @@ def read_zip_entries 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? @@ -93,16 +159,44 @@ def validate_package!(entries) raise StandardError, "Invalid questionnaire package manifest: #{e.message}" end - def parse_csv(contents) - CSV.parse(contents, headers: true).map do |row| + # 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 + + # 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 = {} @@ -127,6 +221,7 @@ def import_questionnaires(rows, imported_counts, duplicate_counts) [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) @@ -143,6 +238,7 @@ def import_questionnaire_row(row, mapping) [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 = {} @@ -177,6 +273,7 @@ def import_items(rows, imported_questionnaires, skipped_questionnaire_keys, impo 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']) @@ -200,6 +297,7 @@ def import_question_advices(rows, imported_questionnaires, imported_items, skipp 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? @@ -207,6 +305,7 @@ def find_questionnaire_record(row) 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 = {} @@ -226,6 +325,7 @@ def build_questionnaire(row, mapping) questionnaire end + # Translate selected duplicate action into package-level behavior. def resolve_duplicate_questionnaire(existing, incoming) case @duplicate_action when SkipRecordAction @@ -238,6 +338,7 @@ def resolve_duplicate_questionnaire(existing, incoming) end end + # Update only template fields represented in the package CSV. def update_existing_questionnaire(existing, incoming) existing.assign_attributes( incoming.attributes.slice( @@ -252,6 +353,7 @@ def update_existing_questionnaire(existing, incoming) 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 @@ -265,14 +367,17 @@ def unique_questionnaire_name(name, instructor_id) 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 diff --git a/config/routes.rb b/config/routes.rb index 67fde8aa1..33bf56e6d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -229,6 +229,8 @@ 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 diff --git a/spec/requests/questionnaire_packages_spec.rb b/spec/requests/questionnaire_packages_spec.rb index ba09e02d0..11494f264 100644 --- a/spec/requests/questionnaire_packages_spec.rb +++ b/spec/requests/questionnaire_packages_spec.rb @@ -24,6 +24,9 @@ json = JSON.parse(response.body) expect(json['required_files']).to include('manifest.json', 'questionnaires.csv', 'items.csv', 'question_advices.csv') + expect(json['csv_header_requirements']['questionnaires']).to include('name', 'questionnaire_type', 'instructor_name') + 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['package_type']).to eq('questionnaire_template_export') expect(json['version']).to eq(1) expect(json['available_actions_on_dup']).to include('SkipRecordAction', 'UpdateExistingRecordAction', 'ChangeOffendingFieldAction') @@ -73,13 +76,13 @@ reviewee_id: reviewee.id, reviewed_object_id: assignment.id ) - response = Response.create!( + review_response = Response.create!( map_id: response_map.id, additional_comment: 'Do not export this response' ) Answer.create!( item: item, - response: response, + response: review_response, answer: 3, comments: 'Do not export this answer' ) @@ -219,6 +222,72 @@ 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,instruction_loc,instructor_name + Role Field Questionnaire,ReviewQuestionnaire,Likert,false,0,5,instructions,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) @@ -302,4 +371,12 @@ def build_package_upload(manifest:, questionnaires_csv:, items_csv:, question_ad 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 From 2d0d45a09a62c0b2c673b9034b46c6573e4b1652 Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Sat, 25 Apr 2026 21:20:00 -0500 Subject: [PATCH 70/80] Implemented template download for CSV and ZIP formats in QuestionnairePackagesController. Added preview functionality for questionnaire imports in QuestionnairePackageImportService. Created QuestionnairePackageTemplateService to generate blank templates. Updated routes to include new template and preview actions. --- .../questionnaire_packages_controller.rb | 34 ++++ .../questionnaire_package_import_service.rb | 183 +++++++++++++++++- .../questionnaire_package_template_service.rb | 120 ++++++++++++ config/routes.rb | 2 + spec/requests/questionnaire_packages_spec.rb | 167 ++++++++++++++++ 5 files changed, 501 insertions(+), 5 deletions(-) create mode 100644 app/services/questionnaire_package_template_service.rb diff --git a/app/controllers/questionnaire_packages_controller.rb b/app/controllers/questionnaire_packages_controller.rb index 872b2715a..e258ea611 100644 --- a/app/controllers/questionnaire_packages_controller.rb +++ b/app/controllers/questionnaire_packages_controller.rb @@ -19,12 +19,26 @@ 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 @@ -65,8 +79,28 @@ def import 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( diff --git a/app/services/questionnaire_package_import_service.rb b/app/services/questionnaire_package_import_service.rb index 817e743c2..bb39a0125 100644 --- a/app/services/questionnaire_package_import_service.rb +++ b/app/services/questionnaire_package_import_service.rb @@ -55,11 +55,7 @@ def initialize(package_file: nil, questionnaire_file: nil, items_file: nil, ques # Import parents before dependent rows, using package keys instead of DB IDs. def perform - csv_sources = resolve_csv_sources - - questionnaire_rows = parse_csv(csv_sources.fetch(:questionnaires), :questionnaires) - item_rows = parse_csv(csv_sources[:items], :items) - question_advice_rows = parse_csv(csv_sources[:question_advices], :question_advices) + questionnaire_rows, item_rows, question_advice_rows = parsed_rows imported_counts = { questionnaires: 0, @@ -94,8 +90,34 @@ def perform } 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? @@ -196,6 +218,157 @@ 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 || []) diff --git a/app/services/questionnaire_package_template_service.rb b/app/services/questionnaire_package_template_service.rb new file mode 100644 index 000000000..9f89617d8 --- /dev/null +++ b/app/services/questionnaire_package_template_service.rb @@ -0,0 +1,120 @@ +# 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', + 'seed/review_instructions', + '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/config/routes.rb b/config/routes.rb index 33bf56e6d..b4c1c9ce9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -234,7 +234,9 @@ 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 diff --git a/spec/requests/questionnaire_packages_spec.rb b/spec/requests/questionnaire_packages_spec.rb index 11494f264..38ffb07b0 100644 --- a/spec/requests/questionnaire_packages_spec.rb +++ b/spec/requests/questionnaire_packages_spec.rb @@ -27,12 +27,61 @@ expect(json['csv_header_requirements']['questionnaires']).to include('name', 'questionnaire_type', 'instructor_name') 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 + ) + expect(CSV.parse(contents['questionnaires.csv'], headers: true).headers).to include('name', 'questionnaire_type', 'instructor_name') + 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) @@ -150,6 +199,124 @@ 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,instruction_loc,instructor_name + Preview Questionnaire,ReviewQuestionnaire,Likert,false,0,5,instructions,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,instruction_loc,instructor_name + Preview Duplicate Questionnaire,ReviewQuestionnaire,Likert,false,0,5,instructions,previewduplicate + Missing Instructor Questionnaire,ReviewQuestionnaire,Likert,false,0,5,instructions,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) From 21596c46625e016c10054ddfb27c93996fe9c96f Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Sun, 26 Apr 2026 18:13:46 -0500 Subject: [PATCH 71/80] updated ap import/export functionality with context handling and reduced CSV to only require usernames --- app/controllers/export_controller.rb | 18 ++++ app/controllers/import_controller.rb | 24 ++++- app/models/assignment_participant.rb | 141 +++++++++++++++++++++++++++ 3 files changed, 181 insertions(+), 2 deletions(-) diff --git a/app/controllers/export_controller.rb b/app/controllers/export_controller.rb index fb8da2fd5..c03aded63 100644 --- a/app/controllers/export_controller.rb +++ b/app/controllers/export_controller.rb @@ -38,6 +38,12 @@ def export 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 + Export.perform(klass, ordered_fields) + end else Export.perform(klass, ordered_fields) end @@ -58,6 +64,18 @@ def export_params 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 == Team Team.with_assignment_context(params[:assignment_id]) do return { diff --git a/app/controllers/import_controller.rb b/app/controllers/import_controller.rb index 71e63e167..7a2006fea 100644 --- a/app/controllers/import_controller.rb +++ b/app/controllers/import_controller.rb @@ -56,8 +56,15 @@ def import pp dup_action - importService = Import.new(klass: klass, file: uploaded_file, headers: ordered_fields, dup_action: dup_action&.new, defaults: defaults) - result = importService.perform(use_headers) + # 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 + 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 @@ -89,6 +96,19 @@ def import_defaults_for(klass) 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 == Team Team.with_assignment_context(params[:assignment_id]) do return { diff --git a/app/models/assignment_participant.rb b/app/models/assignment_participant.rb index f3f1f38b2..22d5d6f43 100644 --- a/app/models/assignment_participant.rb +++ b/app/models/assignment_participant.rb @@ -1,7 +1,19 @@ # 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 } + export_submodels false + 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 +65,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 From cb40fbfb654e480399de21710f8139c9d8b1523f Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Sun, 26 Apr 2026 20:26:37 -0500 Subject: [PATCH 72/80] removed graph based export code traces as alternative system will be utilized. --- app/helpers/export_helper.rb | 178 -------------------- app/helpers/importable_exportable_helper.rb | 9 - app/models/Item.rb | 2 - app/models/answer.rb | 1 - app/models/project_topic.rb | 3 - app/models/pseudo/grades.rb | 4 +- app/models/question_advice.rb | 2 - app/models/questionnaire.rb | 1 - app/models/quiz_item.rb | 2 - app/models/team.rb | 2 - app/models/user.rb | 2 - app/services/export.rb | 2 - spec/helpers/export_helper_spec.rb | 173 ------------------- spec/integration/export_controller_spec.rb | 19 +-- 14 files changed, 2 insertions(+), 398 deletions(-) delete mode 100644 app/helpers/export_helper.rb delete mode 100644 spec/helpers/export_helper_spec.rb diff --git a/app/helpers/export_helper.rb b/app/helpers/export_helper.rb deleted file mode 100644 index 828e04c86..000000000 --- a/app/helpers/export_helper.rb +++ /dev/null @@ -1,178 +0,0 @@ -# frozen_string_literal: true - -require 'set' - -module ExportHelper - module_function - - # Builds a minimal recursive graph with only class names and headers, - # then returns one export payload per unique class in that graph. - # traverses all the has_many and belongs_to descendants of the model provided. - def export_has_many_graph(root_class) - graph = build_export_graph(root_class) - - headers_by_class = {} - each_graph_node_for_export(graph) do |node, headers_for_export| - class_name = node[:class_name] - headers_by_class[class_name] ||= [] - headers_by_class[class_name] |= headers_for_export - end - - exports = [] - headers_by_class.each do |class_name, headers| - exports.concat(Export.export_csv(class_name.constantize, headers)) - end - - exports - end - - # Recursively builds an export graph for the class and its related children. - def build_export_graph(root_class, visited = Set.new) - klass = normalize_class(root_class) - class_name = klass.name - - if visited.include?(class_name) - return { - class_name: class_name, - headers: mandatory_headers_for(klass), - cyclic_reference: true, - has_many: [] - } - end - - visited.add(class_name) - - children = [] - - # get has_many models - klass.reflect_on_all_associations(:has_many).each do |association| - begin - child_klass = association.klass - rescue StandardError - next - end - - children << build_export_graph(child_klass, visited) - end - - # get belongs_to models - descendants_with_belongs_to_parent(klass).each do |child_klass| - children << build_export_graph(child_klass, visited) - end - - { - class_name: class_name, - headers: mandatory_headers_for(klass), - has_many: dedupe_children(children) - } - end - - # Finds descendant models that point back to the parent through a belongs_to association. - def descendants_with_belongs_to_parent(parent_klass) - descendants = ActiveRecord::Base.descendants.select { |model| model < ApplicationRecord } - - descendants.select do |candidate| - next false if candidate == parent_klass - - candidate.reflect_on_all_associations(:belongs_to).any? do |belongs_to_association| - begin - belongs_to_association.klass == parent_klass || parent_klass <= belongs_to_association.klass - rescue StandardError - false - end - end - end - end - private_class_method :descendants_with_belongs_to_parent - - # Removes duplicate child nodes so each class appears only once per level. - def dedupe_children(children) - children.uniq { |child| child[:class_name] } - end - private_class_method :dedupe_children - - # Walks each unique node in the graph once and yields it to the caller. - def each_graph_node(graph, seen = Set.new, &block) - return if seen.include?(graph[:class_name]) - - seen.add(graph[:class_name]) - block.call(graph) - - graph[:has_many].each do |child| - each_graph_node(child, seen, &block) - end - end - private_class_method :each_graph_node - - # Traverses the graph while carrying inherited headers needed for child exports. - def each_graph_node_for_export(graph, inherited_headers = [], seen = Set.new, &block) - return if seen.include?(graph[:class_name]) - - seen.add(graph[:class_name]) - headers_for_export = filter_headers_for_class( - graph[:class_name], - remove_identifier_fields(Array(graph[:headers]) + Array(inherited_headers)) - ) - block.call(graph, headers_for_export) - - prefixed_parent_headers = prefix_headers_with_class_name(graph[:headers], graph[:class_name]) - child_inherited_headers = remove_identifier_fields(Array(inherited_headers) + Array(prefixed_parent_headers)) - - graph[:has_many].each do |child| - each_graph_node_for_export(child, child_inherited_headers, seen, &block) - end - end - private_class_method :each_graph_node_for_export - - # Prefixes headers with the underscored class name to preserve parent context. - def prefix_headers_with_class_name(headers, class_name) - klass = class_name.is_a?(Class) ? class_name : class_name.constantize - prefix = klass.name.underscore - Array(headers).map { |header| "#{prefix}_#{header}" } - end - private_class_method :prefix_headers_with_class_name - - # Returns the required exportable headers for the given class. - def mandatory_headers_for(klass) - if klass.respond_to?(:mandatory_fields) - mandatory = Array(klass.mandatory_fields).map(&:to_s) - return remove_identifier_fields(mandatory) unless mandatory.empty? - end - - if klass.respond_to?(:internal_and_external_fields) - return remove_identifier_fields(klass.internal_and_external_fields.map(&:to_s)) - end - - return remove_identifier_fields(klass.column_names) if klass.respond_to?(:column_names) - - raise ArgumentError, "No export headers available for #{klass.name}" - end - private_class_method :mandatory_headers_for - - # Keeps only headers that can actually be exported for the target class. - def filter_headers_for_class(class_name, headers) - klass = normalize_class(class_name) - exportable_headers = if klass.respond_to?(:internal_and_external_fields) - remove_identifier_fields(klass.internal_and_external_fields.map(&:to_s)) - elsif klass.respond_to?(:column_names) - remove_identifier_fields(klass.column_names) - else - [] - end - - Array(headers).select { |header| exportable_headers.include?(header) } - end - private_class_method :filter_headers_for_class - - # Strips primary and foreign key identifiers from a list of field names. - def remove_identifier_fields(fields) - Array(fields).map(&:to_s).uniq.reject { |field| field == 'id' || field.end_with?('_id') } - end - private_class_method :remove_identifier_fields - - # Normalizes a class reference so callers can pass either a class or its name. - def normalize_class(root_class) - root_class.is_a?(String) ? root_class.constantize : root_class - end - private_class_method :normalize_class -end diff --git a/app/helpers/importable_exportable_helper.rb b/app/helpers/importable_exportable_helper.rb index 1ba4c9c77..91c6a385a 100644 --- a/app/helpers/importable_exportable_helper.rb +++ b/app/helpers/importable_exportable_helper.rb @@ -147,15 +147,6 @@ def self.extended(base) end end - # set submodel export - def export_submodels(bool_export = nil) - if bool_export.nil? - @class_bool_export - else - @class_bool_export = bool_export - end - end - ## Provide filter_proc with a custom method to aggregare records and spoof nonexistent models. ## def filter(filter_proc = nil) diff --git a/app/models/Item.rb b/app/models/Item.rb index 80b698aab..def6484b2 100644 --- a/app/models/Item.rb +++ b/app/models/Item.rb @@ -6,8 +6,6 @@ class Item < ApplicationRecord hidden_fields :id, :created_at, :updated_at external_classes ExternalClass.new(Questionnaire, true, false, :name) filter nil - export_submodels false - before_create :set_seq belongs_to :questionnaire # each item belongs to a specific questionnaire has_many :answers, dependent: :destroy, foreign_key: 'item_id' diff --git a/app/models/answer.rb b/app/models/answer.rb index e7a19a18d..98dc95ac7 100644 --- a/app/models/answer.rb +++ b/app/models/answer.rb @@ -7,7 +7,6 @@ class Answer < ApplicationRecord external_classes ExternalClass.new(Item, true, false, :seq), ExternalClass.new(Response, true, false, :additional_comment) - export_submodels false filter nil belongs_to :response belongs_to :item diff --git a/app/models/project_topic.rb b/app/models/project_topic.rb index 096e42ec2..92f1d4003 100644 --- a/app/models/project_topic.rb +++ b/app/models/project_topic.rb @@ -9,9 +9,6 @@ class ProjectTopic < ApplicationRecord 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 - export_submodels false - - # Ensures the number of max choosers is non-negative validates :max_choosers, numericality: { only_integer: true, diff --git a/app/models/pseudo/grades.rb b/app/models/pseudo/grades.rb index c7b542960..d23c0ead6 100644 --- a/app/models/pseudo/grades.rb +++ b/app/models/pseudo/grades.rb @@ -20,8 +20,6 @@ class Grades extend ImportableExportableHelper - export_submodels false - mandatory_fields :assignment_name, :team_name, :participant_name # hidden_fields :id, :created_at, :updated_at filter -> { aggregate_grades } @@ -76,4 +74,4 @@ def self.aggregate_grades end.compact end end -end \ No newline at end of file +end diff --git a/app/models/question_advice.rb b/app/models/question_advice.rb index 0147b22c9..0ba81e909 100644 --- a/app/models/question_advice.rb +++ b/app/models/question_advice.rb @@ -4,8 +4,6 @@ class QuestionAdvice < ApplicationRecord extend ImportableExportableHelper mandatory_fields :score, :advice, :item_seq hidden_fields :id, :created_at, :updated_at - export_submodels false - external_classes ExternalClass.new(Item, true, false, :seq) filter nil belongs_to :item diff --git a/app/models/questionnaire.rb b/app/models/questionnaire.rb index 6767ac841..d6c177bd0 100644 --- a/app/models/questionnaire.rb +++ b/app/models/questionnaire.rb @@ -10,7 +10,6 @@ class Questionnaire < ApplicationRecord 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 - export_submodels true 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 90a887339..4bfce92c0 100644 --- a/app/models/quiz_item.rb +++ b/app/models/quiz_item.rb @@ -7,8 +7,6 @@ class QuizItem < Item 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 - export_submodels false - def edit end diff --git a/app/models/team.rb b/app/models/team.rb index eb082c0c1..37e213e53 100644 --- a/app/models/team.rb +++ b/app/models/team.rb @@ -7,8 +7,6 @@ class Team < ApplicationRecord mandatory_fields :name hidden_fields :id, :created_at, :updated_at filter -> { export_rows } - export_submodels false - TeamExportRow = Struct.new(:team, :participants) do def initialize(team, participants) super(team, participants) diff --git a/app/models/user.rb b/app/models/user.rb index edf4b4155..0af55f920 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -8,8 +8,6 @@ class User < ApplicationRecord ExternalClass.new(Institution, true, false, :name) available_actions_on_duplicate SkipRecordAction.new, UpdateExistingRecordAction.new, ChangeOffendingFieldAction.new filter nil - export_submodels false - has_secure_password after_initialize :set_defaults diff --git a/app/services/export.rb b/app/services/export.rb index f2f25b6ea..20726125b 100644 --- a/app/services/export.rb +++ b/app/services/export.rb @@ -62,8 +62,6 @@ def self.export_csv(export_class, ordered_headers) end def self.perform(export_class, ordered_headers = nil) - return ExportHelper.export_has_many_graph(export_class) if export_class.export_submodels - export_csv(export_class, ordered_headers) end diff --git a/spec/helpers/export_helper_spec.rb b/spec/helpers/export_helper_spec.rb deleted file mode 100644 index 7758e85e8..000000000 --- a/spec/helpers/export_helper_spec.rb +++ /dev/null @@ -1,173 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' -require 'csv' - -RSpec.describe ExportHelper, type: :helper do - describe 'graph export via Export.perform' do - it 'returns export payloads for each class in the graph with real db records' do - role = create(:role, :instructor) - institution = create(:institution) - instructor = Instructor.create!( - name: 'exportinstructor', - email: 'exportinstructor@example.com', - full_name: 'Export Instructor', - password: 'password', - role: role, - institution: institution - ) - - questionnaire_record = Questionnaire.create!( - name: 'Graph Export Questionnaire', - instructor: instructor, - private: false, - min_question_score: 0, - max_question_score: 10, - questionnaire_type: 'ReviewQuestionnaire', - display_type: 'Likert', - instruction_loc: 'instructions' - ) - - item_record = Item.create!( - questionnaire: questionnaire_record, - txt: 'How clear was the design?', - weight: 5, - seq: 1, - question_type: 'Scale', - break_before: true - ) - - advice_record = QuestionAdvice.create!( - item: item_record, - score: 4, - advice: 'Add concrete examples to improve clarity.' - ) - - assignment_record = create(:assignment, instructor: instructor) - reviewer_participant = create(:assignment_participant, assignment: assignment_record) - reviewee_participant = create(:assignment_participant, assignment: assignment_record) - - response_map_record = ResponseMap.create!( - reviewer_id: reviewer_participant.id, - reviewee_id: reviewee_participant.id, - reviewed_object_id: assignment_record.id - ) - - response_record = Response.create!( - map_id: response_map_record.id, - additional_comment: 'response comment' - ) - - answer_record = Answer.create!( - item: item_record, - response: response_record, - answer: 3, - comments: 'Strong rationale.' - ) - - questionnaire_external = Item.external_classes.find { |ext| ext.ref_class == Questionnaire } - allow(Item).to receive(:external_classes).and_return([questionnaire_external].compact) - - result = Export.perform(Questionnaire, nil) - exports_by_class = result.index_by { |entry| entry[:name] } - - expect(result).to all(include(:name, :contents)) - expect(exports_by_class.keys).to include('Questionnaire', 'Item', 'QuestionAdvice', 'Answer') - - questionnaire_rows = CSV.parse(exports_by_class['Questionnaire'][:contents], headers: true) - item_rows = CSV.parse(exports_by_class['Item'][:contents], headers: true) - advice_rows = CSV.parse(exports_by_class['QuestionAdvice'][:contents], headers: true) - answer_rows = CSV.parse(exports_by_class['Answer'][:contents], headers: true) - - expect(questionnaire_rows.map { |row| row['name'] }).to include(questionnaire_record.name) - expect(item_rows.map { |row| row['txt'] }).to include(item_record.txt) - expect(advice_rows.map { |row| row['advice'] }).to include(advice_record.advice) - expect(answer_rows.map { |row| row['comments'] }).to include(answer_record.comments) - expect(answer_rows.map { |row| row['answer'] }).to include(answer_record.answer.to_s) - expect(answer_rows.headers.length).to eq( - answer_rows.first&.fields&.length || answer_rows.headers.length - ) - end - - it 'exports csv and prints each class csv output' do - role = create(:role, :instructor) - institution = create(:institution) - instructor = Instructor.create!( - name: 'csvprintinstructor', - email: 'csvprintinstructor@example.com', - full_name: 'CSV Print Instructor', - password: 'password', - role: role, - institution: institution - ) - - questionnaire_record = Questionnaire.create!( - name: 'CSV Print Questionnaire', - instructor: instructor, - private: false, - min_question_score: 0, - max_question_score: 10, - questionnaire_type: 'ReviewQuestionnaire', - display_type: 'Likert', - instruction_loc: 'instructions' - ) - - item_record = Item.create!( - questionnaire: questionnaire_record, - txt: 'What should improve?', - weight: 2, - seq: 1, - question_type: 'Scale', - break_before: true - ) - - QuestionAdvice.create!( - item: item_record, - score: 2, - advice: 'Consider edge cases.' - ) - - assignment_record = create(:assignment, instructor: instructor) - reviewer_participant = create(:assignment_participant, assignment: assignment_record) - reviewee_participant = create(:assignment_participant, assignment: assignment_record) - response_map_record = ResponseMap.create!( - reviewer_id: reviewer_participant.id, - reviewee_id: reviewee_participant.id, - reviewed_object_id: assignment_record.id - ) - response_record = Response.create!( - map_id: response_map_record.id, - additional_comment: 'print response comment' - ) - Answer.create!( - item: item_record, - response: response_record, - answer: 1, - comments: 'Needs work.' - ) - - questionnaire_external = Item.external_classes.find { |ext| ext.ref_class == Questionnaire } - allow(Item).to receive(:external_classes).and_return([questionnaire_external].compact) - - result = Export.perform(Questionnaire, nil) - exports_by_class = result.index_by { |entry| entry[:name] } - - puts "\nCSV Exports:" - result.each do |export_entry| - puts "--- #{export_entry[:name]} ---" - puts export_entry[:contents] - end - - expect(exports_by_class.keys).to include('Questionnaire', 'Item', 'QuestionAdvice', 'Answer') - expect(exports_by_class['Questionnaire'][:contents]).to include('name') - expect(exports_by_class['Item'][:contents]).to include('txt') - expect(exports_by_class['QuestionAdvice'][:contents]).to include('advice') - expect(exports_by_class['Answer'][:contents]).to include('comments') - - answer_rows = CSV.parse(exports_by_class['Answer'][:contents], headers: true) - expect(answer_rows.headers.length).to eq( - answer_rows.first&.fields&.length || answer_rows.headers.length - ) - end - end -end diff --git a/spec/integration/export_controller_spec.rb b/spec/integration/export_controller_spec.rb index a8675d080..11479f4a0 100644 --- a/spec/integration/export_controller_spec.rb +++ b/spec/integration/export_controller_spec.rb @@ -62,7 +62,7 @@ def self.external_fields; ["institution"]; end export_return = [{ name: "FakeModel", contents: "csv_without_ordering" }] expect(Export).to receive(:perform) - .with(FakeModel, nil, graph_export: false) + .with(FakeModel, nil) .and_return(export_return) post "/export/FakeModel" @@ -72,23 +72,6 @@ def self.external_fields; ["institution"]; end expect(json["file"]).to eq([{ "name" => "FakeModel", "contents" => "csv_without_ordering" }]) end - it "passes graph_export when requested" do - export_return = [{ name: "FakeModel", contents: "graph_csv_data" }] - - expect(Export).to receive(:perform) - .with(FakeModel, ["id"], graph_export: true) - .and_return(export_return) - - post "/export/FakeModel", params: { - ordered_fields: ["id"].to_json, - graph_export: "true" - } - - expect(response).to have_http_status(:ok) - json = JSON.parse(response.body) - expect(json["file"]).to eq([{ "name" => "FakeModel", "contents" => "graph_csv_data" }]) - end - it "returns 422 if constantize fails" do post "/export/DoesNotExist" From c4182636d5a398a2ef472cbec27c17f49c7ba508 Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Mon, 27 Apr 2026 20:41:22 -0500 Subject: [PATCH 73/80] deleted grades model --- app/models/pseudo/grades.rb | 77 ------------------------------------- 1 file changed, 77 deletions(-) delete mode 100644 app/models/pseudo/grades.rb diff --git a/app/models/pseudo/grades.rb b/app/models/pseudo/grades.rb deleted file mode 100644 index d23c0ead6..000000000 --- a/app/models/pseudo/grades.rb +++ /dev/null @@ -1,77 +0,0 @@ -# frozen_string_literal: true - -module Pseudo -class Grades - COLUMN_NAMES = %w[ - assignment_id - assignment_name - team_id - team_name - participant_id - participant_name - participant_email - submission_grade - review_grade - teammate_review_grade - author_feedback_grade - ].freeze - - Row = Struct.new(*COLUMN_NAMES.map(&:to_sym), keyword_init: true) - - extend ImportableExportableHelper - - mandatory_fields :assignment_name, :team_name, :participant_name - # hidden_fields :id, :created_at, :updated_at - filter -> { aggregate_grades } - - # Spoof a table-backed model so ImportableExportableHelper can treat - # Grades like a normal exportable class. - def self.column_names - COLUMN_NAMES - end - - def self.aggregate_grades - AssignmentParticipant.includes(:user).map do |participant| - assignment = participant.assignment - team = participant.team - next if assignment.nil? || team.nil? - - teammate_ids = team.participants.where.not(id: participant.id).pluck(:id) - - reviews_of_me_maps = TeammateReviewResponseMap.where( - reviewed_object_id: assignment.id, - reviewee_id: participant.id, - reviewer_id: teammate_ids - ).to_a - - reviews_by_me_maps = TeammateReviewResponseMap.where( - reviewed_object_id: assignment.id, - reviewer_id: participant.id - ).to_a - - my_reviews_of_other_teams_maps = ReviewResponseMap.where( - reviewed_object_id: assignment.id, - reviewer_id: participant.id - ) - - feedback_from_my_reviewees_maps = my_reviews_of_other_teams_maps.filter_map do |map| - FeedbackResponseMap.find_by(reviewed_object_id: map.id, reviewee_id: participant.id) - end - - Row.new( - assignment_id: assignment.id, - assignment_name: assignment.name, - team_id: team.id, - team_name: team.name, - participant_id: participant.id, - participant_name: participant.user_name, - participant_email: participant.user&.email, - submission_grade: team.grade_for_submission, - review_grade: team.aggregate_review_grade, - teammate_review_grade: participant.aggregate_teammate_review_grade(reviews_of_me_maps), - author_feedback_grade: participant.aggregate_teammate_review_grade(feedback_from_my_reviewees_maps) - ) - end.compact - end -end -end From 543c8d2af52fa117dc044d6556eaba709b24ce03 Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Mon, 27 Apr 2026 21:31:07 -0500 Subject: [PATCH 74/80] updating import/export functionality to support ProjectTopic and AssignmentParticipant with context. --- app/controllers/export_controller.rb | 22 +++- app/controllers/import_controller.rb | 32 ++++- app/models/assignment_participant.rb | 1 - app/models/course.rb | 1 - app/models/project_topic.rb | 25 ++++ app/models/team.rb | 1 + spec/requests/import_export_requests_spec.rb | 123 ++++++++++++++++++- 7 files changed, 185 insertions(+), 20 deletions(-) diff --git a/app/controllers/export_controller.rb b/app/controllers/export_controller.rb index b851fe0f9..71b9431b9 100644 --- a/app/controllers/export_controller.rb +++ b/app/controllers/export_controller.rb @@ -4,15 +4,11 @@ class ExportController < ApplicationController "User" => User, "Team" => Team, "Course" => Course, - "Assignment" => Assignment, + "AssignmentParticipant" => AssignmentParticipant, "ProjectTopic" => ProjectTopic, "Questionnaire" => Questionnaire, "Item" => Item, - "QuestionAdvice" => QuestionAdvice, - "Answer" => Answer, - "QuizItem" => QuizItem, - "Grades" => Pseudo::Grades, - "Pseudo::Grades" => Pseudo::Grades + "QuestionAdvice" => QuestionAdvice }.freeze before_action :export_params @@ -53,6 +49,10 @@ def export AssignmentParticipant.with_assignment_context(params[:assignment_id], current_user) do Export.perform(klass, ordered_fields) 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 @@ -95,6 +95,16 @@ def export_metadata_for(klass) 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, diff --git a/app/controllers/import_controller.rb b/app/controllers/import_controller.rb index 76b339ee2..86d8a1189 100644 --- a/app/controllers/import_controller.rb +++ b/app/controllers/import_controller.rb @@ -18,13 +18,11 @@ class ImportController < ApplicationController "User" => User, "Team" => Team, "Course" => Course, - "Assignment" => Assignment, + "AssignmentParticipant" => AssignmentParticipant, "ProjectTopic" => ProjectTopic, "Questionnaire" => Questionnaire, "Item" => Item, - "QuestionAdvice" => QuestionAdvice, - "Answer" => Answer, - "QuizItem" => QuizItem + "QuestionAdvice" => QuestionAdvice }.freeze # Ensure strong parameters are processed before each action @@ -69,14 +67,16 @@ def import # Load the chosen duplicate action (Skip, Update, Change) dup_action = params[:dup_action]&.constantize - pp dup_action - # 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 == 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 @@ -84,6 +84,8 @@ def import # 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}" @@ -102,6 +104,7 @@ def import_params 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? { @@ -135,6 +138,17 @@ def import_metadata_for(imported_class) 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, @@ -149,6 +163,12 @@ def team_import_defaults { 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}") diff --git a/app/models/assignment_participant.rb b/app/models/assignment_participant.rb index 22d5d6f43..c585ad532 100644 --- a/app/models/assignment_participant.rb +++ b/app/models/assignment_participant.rb @@ -12,7 +12,6 @@ class AssignmentParticipant < Participant mandatory_fields :user_name available_actions_on_duplicate SkipRecordAction.new, UpdateExistingRecordAction.new filter -> { export_scope } - export_submodels false has_many :sent_invitations, class_name: 'Invitation', foreign_key: 'from_id' has_many :review_mappings, class_name: 'ReviewResponseMap', foreign_key: 'reviewee_id' diff --git a/app/models/course.rb b/app/models/course.rb index 6df9ccd07..381d6a292 100644 --- a/app/models/course.rb +++ b/app/models/course.rb @@ -6,7 +6,6 @@ class Course < ApplicationRecord mandatory_fields :name, :directory_path, :institution_name, :instructor_name hidden_fields :id, :created_at, :updated_at, :institution_id, :instructor_id - export_submodels false belongs_to :instructor, class_name: 'User', foreign_key: 'instructor_id' belongs_to :institution, foreign_key: 'institution_id' diff --git a/app/models/project_topic.rb b/app/models/project_topic.rb index 92f1d4003..57b2ae41e 100644 --- a/app/models/project_topic.rb +++ b/app/models/project_topic.rb @@ -3,6 +3,7 @@ class ProjectTopic < ApplicationRecord mandatory_fields :topic_name, :assignment_id 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 @@ -92,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/team.rb b/app/models/team.rb index 706f7c290..8242507a2 100644 --- a/app/models/team.rb +++ b/app/models/team.rb @@ -6,6 +6,7 @@ class Team < ApplicationRecord DEFAULT_TEAM_IMPORT_EXPORT_PARTICIPANT_COLUMNS = 10 mandatory_fields :name 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. diff --git a/spec/requests/import_export_requests_spec.rb b/spec/requests/import_export_requests_spec.rb index 1a64489af..4494e960e 100644 --- a/spec/requests/import_export_requests_spec.rb +++ b/spec/requests/import_export_requests_spec.rb @@ -48,6 +48,19 @@ def uploaded_csv(contents) ) 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) @@ -120,7 +133,7 @@ def uploaded_csv(contents) role: student_role, institution: institution ) - participant = AssignmentParticipant.create!(user: student, parent_id: assignment.id) + participant = AssignmentParticipant.create!(user: student, parent_id: assignment.id, handle: student.name) file = uploaded_csv("name,participant_1\nTeam Alpha,#{participant.id}\n") post "/import/Team", @@ -152,6 +165,46 @@ def uploaded_csv(contents) 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 @@ -273,7 +326,7 @@ def uploaded_csv(contents) ) 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) + 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 } @@ -281,8 +334,9 @@ def uploaded_csv(contents) expect(response).to have_http_status(:ok) json = JSON.parse(response.body) - expect(json["file"]).to include("name,participant_1") - expect(json["file"]).to include("Export Team,#{participant.id}") + exported_file = Array(json["file"]).first + expect(exported_file["contents"]).to include("name,participant_1") + expect(exported_file["contents"]).to include("Export Team,#{participant.id}") end end @@ -293,8 +347,65 @@ def uploaded_csv(contents) expect(response).to have_http_status(:ok) json = JSON.parse(response.body) - expect(json["file"]).to include("topic_name,assignment_id") - expect(json["file"]).to include("Export Topic") + 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 "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 + expect(exported_file["contents"]).to include("student_participant_export") + expect(exported_file["contents"]).not_to include("other_student_participant_export") end end From 5921a2d0e7b67151f0481ae46f527508572e4ec8 Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Mon, 27 Apr 2026 22:38:14 -0500 Subject: [PATCH 75/80] backend changes to switch import and export from course to course participants --- app/controllers/export_controller.rb | 20 ++- app/controllers/import_controller.rb | 19 ++- app/controllers/participants_controller.rb | 34 ++++- app/models/course.rb | 34 +---- app/models/course_participant.rb | 133 ++++++++++++++++++- config/routes.rb | 7 +- spec/requests/import_export_requests_spec.rb | 93 +++++++++---- 7 files changed, 267 insertions(+), 73 deletions(-) diff --git a/app/controllers/export_controller.rb b/app/controllers/export_controller.rb index 71b9431b9..295ab6b2d 100644 --- a/app/controllers/export_controller.rb +++ b/app/controllers/export_controller.rb @@ -3,7 +3,7 @@ class ExportController < ApplicationController SUPPORTED_EXPORT_CLASSES = { "User" => User, "Team" => Team, - "Course" => Course, + "CourseParticipant" => CourseParticipant, "AssignmentParticipant" => AssignmentParticipant, "ProjectTopic" => ProjectTopic, "Questionnaire" => Questionnaire, @@ -49,6 +49,12 @@ def export AssignmentParticipant.with_assignment_context(params[:assignment_id], current_user) do Export.perform(klass, ordered_fields) 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 + Export.perform(klass, ordered_fields) + end elsif klass == ProjectTopic ProjectTopic.with_assignment_context(params[:assignment_id]) do Export.perform(klass, ordered_fields) @@ -69,7 +75,7 @@ def export private def export_params - params.permit(:class, :ordered_fields, :assignment_id) + params.permit(:class, :ordered_fields, :assignment_id, :course_id) end def export_metadata_for(klass) @@ -85,6 +91,16 @@ def export_metadata_for(klass) 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 { diff --git a/app/controllers/import_controller.rb b/app/controllers/import_controller.rb index 86d8a1189..31c875abd 100644 --- a/app/controllers/import_controller.rb +++ b/app/controllers/import_controller.rb @@ -17,7 +17,7 @@ class ImportController < ApplicationController SUPPORTED_IMPORT_CLASSES = { "User" => User, "Team" => Team, - "Course" => Course, + "CourseParticipant" => CourseParticipant, "AssignmentParticipant" => AssignmentParticipant, "ProjectTopic" => ProjectTopic, "Questionnaire" => Questionnaire, @@ -73,6 +73,10 @@ def import 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) @@ -99,7 +103,7 @@ def import # Strong parameters for import operations # def import_params - params.permit(:csv_file, :use_headers, :class, :ordered_fields, :dup_action, :assignment_id) + params.permit(:csv_file, :use_headers, :class, :ordered_fields, :dup_action, :assignment_id, :course_id) end def import_defaults_for(klass) @@ -127,6 +131,17 @@ def import_metadata_for(imported_class) 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 { 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/models/course.rb b/app/models/course.rb index 381d6a292..bdd1fccc6 100644 --- a/app/models/course.rb +++ b/app/models/course.rb @@ -1,26 +1,18 @@ # frozen_string_literal: true class Course < ApplicationRecord - extend ImportableExportableHelper - attr_accessor :institution_name, :instructor_name - - mandatory_fields :name, :directory_path, :institution_name, :instructor_name - hidden_fields :id, :created_at, :updated_at, :institution_id, :instructor_id - belongs_to :instructor, class_name: 'User', foreign_key: 'instructor_id' belongs_to :institution, foreign_key: 'institution_id' has_many :assignments, dependent: :destroy validates :name, presence: true validates :directory_path, presence: true - validate :import_references_must_exist + 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 has_many :tas, through: :ta_mappings, source: :ta has_many :teams, class_name: 'CourseTeam', foreign_key: 'parent_id', dependent: :destroy, inverse_of: :course - before_validation :assign_import_references - # Returns the submission directory for the course def path raise 'Path can not be created as the course must be associated with an instructor.' if instructor_id.nil? @@ -66,28 +58,4 @@ def copy_course new_course.save end - def institution_name - @institution_name.presence || institution&.name - end - - def instructor_name - @instructor_name.presence || instructor&.name - end - - private - - def assign_import_references - self.institution = Institution.find_by(name: institution_name) if institution.blank? && institution_name.present? - self.instructor = User.find_by(name: instructor_name) if instructor.blank? && instructor_name.present? - end - - def import_references_must_exist - if institution.blank? && institution_name.present? - errors.add(:institution_name, 'could not be found') - end - - if instructor.blank? && instructor_name.present? - errors.add(:instructor_name, 'could not be found') - end - 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/config/routes.rb b/config/routes.rb index 4369600c8..364fd9991 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -159,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' diff --git a/spec/requests/import_export_requests_spec.rb b/spec/requests/import_export_requests_spec.rb index 4494e960e..a46742507 100644 --- a/spec/requests/import_export_requests_spec.rb +++ b/spec/requests/import_export_requests_spec.rb @@ -74,14 +74,17 @@ def uploaded_csv(contents) expect(json["external_fields"]).to include("role_name", "institution_name") end - it "returns course import metadata with instructor_name and institution_name" do - get "/import/Course" + 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 include("name", "directory_path", "instructor_name", "institution_name") - expect(json["mandatory_fields"]).not_to include("instructor_id", "institution_id") + 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 end end @@ -123,6 +126,15 @@ def uploaded_csv(contents) ) 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!( @@ -248,26 +260,28 @@ def uploaded_csv(contents) end end - context "course imports" do - it "imports courses using instructor_name and institution_name" do - other_institution = Institution.create!(name: "Other School") - file = uploaded_csv("name,directory_path,info,private,instructor_name,institution_name\nImported Course,imported_course,Imported info,true,teacher,Other School\n") + 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/Course", + post "/import/CourseParticipant", params: { csv_file: file, - use_headers: true + use_headers: true, + dup_action: "SkipRecordAction", + course_id: course.id } expect(response).to have_http_status(:created) - - imported_course = Course.find_by(name: "Imported Course") - expect(imported_course).to be_present - expect(imported_course.directory_path).to eq("imported_course") - expect(imported_course.info).to eq("Imported info") - expect(imported_course.private).to eq(true) - expect(imported_course.instructor_id).to eq(instructor.id) - expect(imported_course.institution_id).to eq(other_institution.id) + expect(CourseParticipant.find_by(user: student, parent_id: course.id)).to be_present end end end @@ -409,27 +423,50 @@ def uploaded_csv(contents) end end - context "course exports" do - it "exports courses with instructor_name and institution_name" do + context "course participant exports" do + it "exports only participants for the provided course_id" do course = Course.create!( - name: "Export Course", - directory_path: "export_course", - info: "Export info", - private: true, + 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/Course", params: { ordered_fields: %w[name directory_path private instructor_name institution_name].to_json } + 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 - expect(exported_file["name"]).to eq("Course") - expect(exported_file["contents"]).to include("name,directory_path,private,instructor_name,institution_name") - expect(exported_file["contents"]).to include("#{course.name},#{course.directory_path},true,#{instructor.name},#{institution.name}") + expect(exported_file["name"]).to eq("CourseParticipant") + 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 From 1c448bf06ca24e87ba53b4cbbcd9708c86e6d531 Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Mon, 27 Apr 2026 22:51:47 -0500 Subject: [PATCH 76/80] added seeds for course participants and solidified round trip behavior of course and assignment participant import-export behavior using filename of export for context. --- app/controllers/export_controller.rb | 23 ++++++++++++++++++-- db/seeds.rb | 17 +++++++++++++++ spec/requests/import_export_requests_spec.rb | 5 ++++- 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/app/controllers/export_controller.rb b/app/controllers/export_controller.rb index 295ab6b2d..ed6ffcb2b 100644 --- a/app/controllers/export_controller.rb +++ b/app/controllers/export_controller.rb @@ -47,13 +47,13 @@ def export # AssignmentParticipant export should include only the # participants for the selected assignment. AssignmentParticipant.with_assignment_context(params[:assignment_id], current_user) do - Export.perform(klass, ordered_fields) + 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 - Export.perform(klass, ordered_fields) + 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 @@ -127,4 +127,23 @@ def export_metadata_for(klass) external_fields: klass.external_fields } end + + 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/db/seeds.rb b/db/seeds.rb index 505f223fc..f536f0215 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -114,6 +114,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| diff --git a/spec/requests/import_export_requests_spec.rb b/spec/requests/import_export_requests_spec.rb index a46742507..3beb60116 100644 --- a/spec/requests/import_export_requests_spec.rb +++ b/spec/requests/import_export_requests_spec.rb @@ -418,6 +418,8 @@ def uploaded_csv(contents) 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 @@ -462,8 +464,9 @@ def uploaded_csv(contents) 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("CourseParticipant") + 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") From 512199e50fd91cf71ff797b181acf59bf84f1fc3 Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Mon, 27 Apr 2026 23:05:02 -0500 Subject: [PATCH 77/80] added backend seed data for grades --- db/seeds.rb | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/db/seeds.rb b/db/seeds.rb index f536f0215..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!( @@ -176,6 +194,8 @@ end end + seed_assignment_grades + puts "creating questionnaires with items, question advices, and answers" questionnaire_blueprints = [ { @@ -334,4 +354,5 @@ rescue ActiveRecord::RecordInvalid => e puts e, 'The db has already been seeded' + seed_assignment_grades end From c05523aba5ad8f5d614cc9405b2feb8eaf9a5263 Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Mon, 27 Apr 2026 23:40:58 -0500 Subject: [PATCH 78/80] added and modified comments to methods I touched --- app/controllers/export_controller.rb | 2 ++ app/controllers/grades_controller.rb | 2 ++ app/services/export.rb | 38 ++++++++++------------------ 3 files changed, 17 insertions(+), 25 deletions(-) diff --git a/app/controllers/export_controller.rb b/app/controllers/export_controller.rb index ed6ffcb2b..c4dc8a0a4 100644 --- a/app/controllers/export_controller.rb +++ b/app/controllers/export_controller.rb @@ -128,6 +128,8 @@ def export_metadata_for(klass) } 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 diff --git a/app/controllers/grades_controller.rb b/app/controllers/grades_controller.rb index ac5818ecd..7aafa4064 100644 --- a/app/controllers/grades_controller.rb +++ b/app/controllers/grades_controller.rb @@ -264,6 +264,8 @@ 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 : []) diff --git a/app/services/export.rb b/app/services/export.rb index 20726125b..af4d1bec3 100644 --- a/app/services/export.rb +++ b/app/services/export.rb @@ -3,51 +3,39 @@ ## # Export # -# This service provides simple, consistent export functionality for any -# array of hashes. Each hash represents one “row” of data, and the keys -# represent column names. The Export class can convert these rows -# into CSV, JSON, or XML. -# -# Example input format: -# [ -# { id: 1, name: "Team 1", members: "Alice,Bob" }, -# { id: 2, name: "Team 2", members: "Carol,Dan" } -# ] -# -# The class intentionally does NOT perform queries itself — it expects -# the controller or the caller to assemble the dataset. +# 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 the dataset into CSV format. + # Convert model records into CSV format. # # This generates: - # • A header row using the keys of the first hash - # • One CSV row for each hash using its values + # * A header row using the requested export headers + # * One CSV row for each record in the model's export scope # # Example output: - # id,name,members - # 1,Team 1,Alice; Bob - # 2,Team 2,Carol; Dan + # 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) } - + class_fields = mapping.ordered_fields.select { |ele| export_class.internal_fields.include?(ele) } - # Extract column headers from the first row's keys + # Preserve the selected frontend field order in the CSV header. csv << ordered_headers - # Insert each row in order, using the values of the hash + # 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)} + 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) } + 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 From a2329edfbeb0216c52833ea96400764416fd7dba Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Wed, 29 Apr 2026 19:32:22 -0500 Subject: [PATCH 79/80] using participants as mandatory fields for teams and removing assignment ID as mandatory for topics import and export --- app/models/project_topic.rb | 2 +- app/models/team.rb | 65 ++++++++++++++------ spec/models/team_import_export_spec.rb | 33 ++++++++-- spec/requests/import_export_requests_spec.rb | 25 ++++++-- 4 files changed, 96 insertions(+), 29 deletions(-) diff --git a/app/models/project_topic.rb b/app/models/project_topic.rb index 57b2ae41e..f350cba7d 100644 --- a/app/models/project_topic.rb +++ b/app/models/project_topic.rb @@ -1,6 +1,6 @@ class ProjectTopic < ApplicationRecord extend ImportableExportableHelper - mandatory_fields :topic_name, :assignment_id + mandatory_fields :topic_name hidden_fields :id, :created_at, :updated_at available_actions_on_duplicate SkipRecordAction.new, UpdateExistingRecordAction.new, ChangeOffendingFieldAction.new filter -> { export_scope } diff --git a/app/models/team.rb b/app/models/team.rb index 8242507a2..cc79bf21e 100644 --- a/app/models/team.rb +++ b/app/models/team.rb @@ -4,7 +4,7 @@ class Team < ApplicationRecord extend ImportableExportableHelper TEAM_PARTICIPANT_COLUMN_PREFIX = 'participant_' DEFAULT_TEAM_IMPORT_EXPORT_PARTICIPANT_COLUMNS = 10 - mandatory_fields :name + mandatory_fields :participant_1 hidden_fields :id, :created_at, :updated_at available_actions_on_duplicate SkipRecordAction.new, UpdateExistingRecordAction.new, ChangeOffendingFieldAction.new filter -> { export_rows } @@ -20,13 +20,13 @@ def name team.name end - # Dynamically resolves participant_N export columns to participant ids by position. + # 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]&.id + participants[index]&.user&.name end # Advertises support for participant_N dynamic columns during export. @@ -212,9 +212,9 @@ def internal_fields ['name'] + participant_field_names end - # Treats participant columns as optional so CSVs can omit unused slots. + # Treats team name and extra participant columns as optional CSV fields. def optional_fields - participant_field_names + (['name'] + participant_field_names) - mandatory_fields end # Team import/export relies only on internal fields, with no external lookup columns. @@ -227,14 +227,14 @@ def internal_and_external_fields internal_fields end - # Builds lightweight export rows that expose participant ids in stable column order. + # Builds lightweight export rows that expose participant usernames in stable column order. def export_rows - export_scope.includes(:participants).map do |team| + 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 participant id columns. + # 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 = @@ -242,9 +242,10 @@ def try_import_records(file, headers, use_header, defaults = {}) csv_table.headers.map { |header| header.to_s.parameterize.underscore } else Array(headers).map { |header| header.to_s.parameterize.underscore } - end + 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 @@ -272,12 +273,13 @@ def import_team_row(row, mapping, defaults) 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_ids_from_row(row_hash).each do |participant_id| - participant = find_import_participant(team, participant_id) + 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) @@ -288,21 +290,48 @@ def import_team_row(row, mapping, defaults) 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 + + 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 - raise StandardError, 'name is required for team import' if name.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 - # Resolves a participant id from the CSV into the correct participant subtype for the team. - def find_import_participant(team, participant_id) + # 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) - participant_class.find_by(id: participant_id, parent_id: team.parent_id) + 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. @@ -325,8 +354,8 @@ def participant_column_count DEFAULT_TEAM_IMPORT_EXPORT_PARTICIPANT_COLUMNS end - # Extracts non-blank participant ids from the current imported row. - def participant_ids_from_row(row_hash) + # Extracts non-blank participant usernames from the current imported row. + def participant_values_from_row(row_hash) row_hash .slice(*participant_field_names) .values diff --git a/spec/models/team_import_export_spec.rb b/spec/models/team_import_export_spec.rb index fa5f142c3..c28ea9f9e 100644 --- a/spec/models/team_import_export_spec.rb +++ b/spec/models/team_import_export_spec.rb @@ -6,7 +6,7 @@ RSpec.describe Team, type: :model do describe 'team import/export' do - it 'exports assignment teams with participant id columns' 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') @@ -26,12 +26,12 @@ exported_row = rows.find { |row| row['name'] == 'Export Team' } expect(exported_row).not_to be_nil - expect(exported_row['participant_1']).to eq(participant_one.id.to_s) - expect(exported_row['participant_2']).to eq(participant_two.id.to_s) + 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 id columns' do + 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') @@ -40,7 +40,7 @@ file = Tempfile.new(['team-import', '.csv']) file.write("name,participant_1,participant_2\n") - file.write("Imported Team,#{participant_one.id},#{participant_two.id}\n") + file.write("Imported Team,#{user_one.name},#{user_two.name}\n") file.rewind expect do @@ -54,5 +54,28 @@ 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/import_export_requests_spec.rb b/spec/requests/import_export_requests_spec.rb index 3beb60116..844ffe4a9 100644 --- a/spec/requests/import_export_requests_spec.rb +++ b/spec/requests/import_export_requests_spec.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "rails_helper" +require "csv" require "tempfile" RSpec.describe "Import/export requests", type: :request do @@ -29,8 +30,8 @@ def uploaded_csv(contents) expect(response).to have_http_status(:ok) json = JSON.parse(response.body) - expect(json["mandatory_fields"]).to eq(["name"]) - expect(json["optional_fields"]).to include("participant_1") + 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] ) @@ -42,7 +43,8 @@ def uploaded_csv(contents) expect(response).to have_http_status(:ok) json = JSON.parse(response.body) - expect(json["mandatory_fields"]).to include("topic_name", "assignment_id") + 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] ) @@ -146,7 +148,7 @@ def uploaded_csv(contents) institution: institution ) participant = AssignmentParticipant.create!(user: student, parent_id: assignment.id, handle: student.name) - file = uploaded_csv("name,participant_1\nTeam Alpha,#{participant.id}\n") + file = uploaded_csv("name,participant_1\nTeam Alpha,#{student.name}\n") post "/import/Team", params: { @@ -350,7 +352,7 @@ def uploaded_csv(contents) 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.id}") + expect(exported_file["contents"]).to include("Export Team,#{participant_user.name}") end end @@ -366,6 +368,19 @@ def uploaded_csv(contents) 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", From dfa0a88a2fa1a48694fbdcefa880e62fef0ec01a Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Wed, 29 Apr 2026 20:06:01 -0500 Subject: [PATCH 80/80] modified questionnaire export service to conditionally include question advices and update related metadata; adjust import/export specs to reflect changes in required fields. --- .../questionnaire_packages_controller.rb | 13 ++- app/helpers/importable_exportable_helper.rb | 2 +- app/models/questionnaire.rb | 4 +- app/models/team.rb | 1 + .../questionnaire_package_export_service.rb | 49 ++++++++--- .../questionnaire_package_import_service.rb | 7 +- .../questionnaire_package_template_service.rb | 1 - spec/requests/import_export_requests_spec.rb | 22 +++++ spec/requests/questionnaire_packages_spec.rb | 82 +++++++++++++++---- 9 files changed, 146 insertions(+), 35 deletions(-) diff --git a/app/controllers/questionnaire_packages_controller.rb b/app/controllers/questionnaire_packages_controller.rb index e258ea611..5de828aaf 100644 --- a/app/controllers/questionnaire_packages_controller.rb +++ b/app/controllers/questionnaire_packages_controller.rb @@ -49,7 +49,10 @@ def export end scope = questionnaire_ids.present? ? Questionnaire.where(id: questionnaire_ids) : Questionnaire.all - package = QuestionnairePackageExportService.new(questionnaires: scope).perform + package = QuestionnairePackageExportService.new( + questionnaires: scope, + include_question_advices: include_question_advices? + ).perform render json: { message: 'Questionnaire template package has been exported!', @@ -110,10 +113,18 @@ def questionnaire_package_params :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] diff --git a/app/helpers/importable_exportable_helper.rb b/app/helpers/importable_exportable_helper.rb index 91c6a385a..5c94152cf 100644 --- a/app/helpers/importable_exportable_helper.rb +++ b/app/helpers/importable_exportable_helper.rb @@ -223,7 +223,7 @@ def available_actions_on_duplicate(*fields) # Then external fields are removed (to prevent duplication). # -------------------------------------------------------------- def internal_fields - (column_names + (mandatory_fields || [])).uniq - external_fields + (column_names + (mandatory_fields || [])).uniq - external_fields - hidden_fields end # -------------------------------------------------------------- diff --git a/app/models/questionnaire.rb b/app/models/questionnaire.rb index d6c177bd0..98a4b4d1e 100644 --- a/app/models/questionnaire.rb +++ b/app/models/questionnaire.rb @@ -2,8 +2,8 @@ class Questionnaire < ApplicationRecord extend ImportableExportableHelper - mandatory_fields :name, :min_question_score, :max_question_score, :questionnaire_type, :display_type, :instruction_loc, :instructor_name - hidden_fields :id, :created_at, :updated_at + 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 diff --git a/app/models/team.rb b/app/models/team.rb index cc79bf21e..fde14595d 100644 --- a/app/models/team.rb +++ b/app/models/team.rb @@ -298,6 +298,7 @@ def validate_import_mapping!(mapping) 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? diff --git a/app/services/questionnaire_package_export_service.rb b/app/services/questionnaire_package_export_service.rb index df1deabe0..c8c026b5e 100644 --- a/app/services/questionnaire_package_export_service.rb +++ b/app/services/questionnaire_package_export_service.rb @@ -9,7 +9,9 @@ class QuestionnairePackageExportService PACKAGE_TYPE = 'questionnaire_template_export' VERSION = 1 - FILES = %w[questionnaires.csv items.csv question_advices.csv].freeze + 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 @@ -20,7 +22,6 @@ class QuestionnairePackageExportService private min_question_score max_question_score - instruction_loc instructor_name ].freeze @@ -47,8 +48,9 @@ class QuestionnairePackageExportService advice ].freeze - def initialize(questionnaires: nil) + 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. @@ -59,7 +61,7 @@ def perform 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)) + 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') @@ -68,8 +70,8 @@ def perform { package_type: PACKAGE_TYPE, version: VERSION, - files: FILES, - includes: INCLUDED_RESOURCES, + files: package_files, + includes: included_resources, excludes: EXCLUDED_RESOURCES, exported_at: Time.zone.now.iso8601, questionnaire_count: exportable_questionnaires.size @@ -83,8 +85,10 @@ def perform zip.put_next_entry('items.csv') zip.write(item_csv) - zip.put_next_entry('question_advices.csv') - zip.write(question_advice_csv) + if include_question_advices? + zip.put_next_entry('question_advices.csv') + zip.write(question_advice_csv) + end end { @@ -94,15 +98,37 @@ def perform counts: { questionnaires: exportable_questionnaires.size, items: exportable_questionnaires.sum { |questionnaire| exportable_items_for(questionnaire).size }, - question_advices: exportable_questionnaires.sum do |questionnaire| - exportable_items_for(questionnaire).sum { |item| item.question_advices.size } - end + 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 @@ -119,7 +145,6 @@ def questionnaire_rows(questionnaires) questionnaire.private, questionnaire.min_question_score, questionnaire.max_question_score, - questionnaire.instruction_loc, questionnaire.instructor&.name ] end diff --git a/app/services/questionnaire_package_import_service.rb b/app/services/questionnaire_package_import_service.rb index bb39a0125..34fc96f9a 100644 --- a/app/services/questionnaire_package_import_service.rb +++ b/app/services/questionnaire_package_import_service.rb @@ -10,7 +10,8 @@ class QuestionnairePackageImportService PACKAGE_TYPE = QuestionnairePackageExportService::PACKAGE_TYPE VERSION = QuestionnairePackageExportService::VERSION - REQUIRED_FILES = %w[manifest.json questionnaires.csv items.csv question_advices.csv].freeze + REQUIRED_FILES = %w[manifest.json questionnaires.csv items.csv].freeze + OPTIONAL_FILES = %w[question_advices.csv].freeze QUESTIONNAIRE_REQUIRED_HEADERS = %w[ name questionnaire_type @@ -18,7 +19,6 @@ class QuestionnairePackageImportService private min_question_score max_question_score - instruction_loc instructor_name ].freeze ITEM_REQUIRED_HEADERS = %w[ @@ -519,8 +519,7 @@ def update_existing_questionnaire(existing, incoming) 'display_type', 'private', 'min_question_score', - 'max_question_score', - 'instruction_loc' + 'max_question_score' ) ) existing diff --git a/app/services/questionnaire_package_template_service.rb b/app/services/questionnaire_package_template_service.rb index 9f89617d8..b83213414 100644 --- a/app/services/questionnaire_package_template_service.rb +++ b/app/services/questionnaire_package_template_service.rb @@ -17,7 +17,6 @@ class QuestionnairePackageTemplateService 'false', '0', '5', - 'seed/review_instructions', 'instructor_username' ] }, diff --git a/spec/requests/import_export_requests_spec.rb b/spec/requests/import_export_requests_spec.rb index 844ffe4a9..927045918 100644 --- a/spec/requests/import_export_requests_spec.rb +++ b/spec/requests/import_export_requests_spec.rb @@ -88,6 +88,16 @@ def uploaded_csv(contents) %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 @@ -356,6 +366,18 @@ def uploaded_csv(contents) 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 } diff --git a/spec/requests/questionnaire_packages_spec.rb b/spec/requests/questionnaire_packages_spec.rb index 38ffb07b0..f754f9c45 100644 --- a/spec/requests/questionnaire_packages_spec.rb +++ b/spec/requests/questionnaire_packages_spec.rb @@ -23,8 +23,10 @@ expect(response).to have_http_status(:ok) json = JSON.parse(response.body) - expect(json['required_files']).to include('manifest.json', 'questionnaires.csv', 'items.csv', 'question_advices.csv') + 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') @@ -66,7 +68,9 @@ 'package_type' => 'questionnaire_template_export', 'version' => 1 ) - expect(CSV.parse(contents['questionnaires.csv'], headers: true).headers).to include('name', 'questionnaire_type', 'instructor_name') + 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') @@ -178,6 +182,7 @@ 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.') @@ -196,6 +201,55 @@ 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 @@ -214,8 +268,8 @@ questionnaire_file = build_csv_upload( filename: 'preview questionnaires.csv', contents: <<~CSV - name,questionnaire_type,display_type,private,min_question_score,max_question_score,instruction_loc,instructor_name - Preview Questionnaire,ReviewQuestionnaire,Likert,false,0,5,instructions,previewimporter + 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( @@ -283,9 +337,9 @@ questionnaire_file = build_csv_upload( filename: 'preview duplicate questionnaires.csv', contents: <<~CSV - name,questionnaire_type,display_type,private,min_question_score,max_question_score,instruction_loc,instructor_name - Preview Duplicate Questionnaire,ReviewQuestionnaire,Likert,false,0,5,instructions,previewduplicate - Missing Instructor Questionnaire,ReviewQuestionnaire,Likert,false,0,5,instructions,missingpreviewinstructor + 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( @@ -336,8 +390,8 @@ files: %w[questionnaires.csv items.csv question_advices.csv] }, questionnaires_csv: <<~CSV, - name,questionnaire_type,display_type,private,min_question_score,max_question_score,instruction_loc,instructor_name - Imported Questionnaire,ReviewQuestionnaire,Likert,false,0,5,instructions,packageimporter + 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 @@ -376,7 +430,7 @@ 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,instruction_loc,instructor_name\n", + 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" ) @@ -404,8 +458,8 @@ questionnaire_file = build_csv_upload( filename: 'my rubric list.csv', contents: <<~CSV - name,questionnaire_type,display_type,private,min_question_score,max_question_score,instruction_loc,instructor_name - Role Field Questionnaire,ReviewQuestionnaire,Likert,false,0,5,instructions,csvroleimporter + 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( @@ -484,8 +538,8 @@ files: %w[questionnaires.csv items.csv question_advices.csv] }, questionnaires_csv: <<~CSV, - name,questionnaire_type,display_type,private,min_question_score,max_question_score,instruction_loc,instructor_name - Duplicate Questionnaire,ReviewQuestionnaire,Likert,false,0,5,new instructions,duplicatedpackageimporter + 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