From 9b0ab9f1f83ef1c06ac3e54ea396d08480ace456 Mon Sep 17 00:00:00 2001 From: TaylorBrown96 Date: Wed, 19 Nov 2025 03:00:09 -0500 Subject: [PATCH 01/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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'] }