diff --git a/app/controllers/export_controller.rb b/app/controllers/export_controller.rb new file mode 100644 index 000000000..fb8da2fd5 --- /dev/null +++ b/app/controllers/export_controller.rb @@ -0,0 +1,77 @@ +# This controller handles exporting data from the application to various formats. +class ExportController < ApplicationController + before_action :export_params + + def resolve_export_class(name) + # Try top-level first + return name.constantize + rescue NameError + # Try Pseudo namespace + begin + "Pseudo::#{name}".constantize + rescue NameError + nil + end + 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 + end + + def export + # Parse ordered fields from JSON, if provided + ordered_fields = + begin + JSON.parse(params[:ordered_fields]) if params[:ordered_fields] + rescue JSON::ParserError + render json: { error: "Invalid JSON for ordered_fields" }, status: :unprocessable_entity + return + end + + klass = resolve_export_class(params[:class]) + + 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!", + file: csv_file + }, status: :ok + + rescue StandardError => e + render json: { error: e.message }, status: :unprocessable_entity + end + + private + + def export_params + params.permit(:class, :ordered_fields, :assignment_id) + 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 new file mode 100644 index 000000000..71e63e167 --- /dev/null +++ b/app/controllers/import_controller.rb @@ -0,0 +1,116 @@ +# 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 + + render json: import_metadata_for(imported_class), 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] + + # Convert use_headers ("true"/"false") into actual boolean + use_headers = ActiveRecord::Type::Boolean.new.deserialize(params[:use_headers]) + + # If the user provided a custom field ordering, load it from JSON + ordered_fields = JSON.parse(params[:ordered_fields]) if params[:ordered_fields] + + # Dynamically load the model class (e.g., "User", "Team", etc.) + klass = 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, defaults: defaults) + result = importService.perform(use_headers) + + # If no exceptions occur, return success + render json: { message: "#{klass.name} has been imported!", **result }, status: :created + + rescue StandardError => e + # Catch any unexpected runtime errors + puts "An unexpected error occurred during import: #{e.message}" + + render json: { error: e.message }, status: :unprocessable_entity + end + + private + + ## + # Strong parameters for import operations + # + def import_params + params.permit(:csv_file, :use_headers, :class, :ordered_fields, :dup_action, :assignment_id) + end + + def import_defaults_for(klass) + return team_import_defaults if klass == Team + return {} unless klass == User && current_user.present? + + { + parent_id: current_user.id, + institution_id: current_user.institution_id + } + end + + def import_metadata_for(imported_class) + 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/controllers/project_topics_controller.rb b/app/controllers/project_topics_controller.rb index 4b5fbb2f7..6dc8d56bf 100644 --- a/app/controllers/project_topics_controller.rb +++ b/app/controllers/project_topics_controller.rb @@ -8,9 +8,11 @@ def index render json: { message: 'Assignment ID is required!' }, status: :unprocessable_entity elsif params[:topic_ids].nil? @project_topics = ProjectTopic.where(assignment_id: params[:assignment_id]) + .includes(signed_up_teams: { team: :users }) render json: @project_topics, status: :ok else @project_topics = ProjectTopic.where(assignment_id: params[:assignment_id], topic_identifier: params[:topic_ids]) + .includes(signed_up_teams: { team: :users }) render json: @project_topics, status: :ok end # render json: {message: 'All selected topics have been loaded successfully.', project_topics: @stopics}, status: 200 @@ -85,11 +87,11 @@ def destroy # Use callbacks to share common setup or constraints between actions. def set_project_topic - @project_topic = ProjectTopic.find(params[:id]) + @project_topic = ProjectTopic.includes(signed_up_teams: { team: :users }).find(params[:id]) end # Only allow a list of trusted parameters through. def project_topic_params - params.require(:project_topic).permit(:topic_identifier, :category, :topic_name, :max_choosers, :assignment_id) + params.require(:project_topic).permit(:topic_identifier, :category, :topic_name, :max_choosers, :assignment_id, :description, :link) end end diff --git a/app/controllers/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/helpers/export_helper.rb b/app/helpers/export_helper.rb new file mode 100644 index 000000000..828e04c86 --- /dev/null +++ b/app/helpers/export_helper.rb @@ -0,0 +1,178 @@ +# frozen_string_literal: true + +require 'set' + +module ExportHelper + module_function + + # Builds a minimal recursive graph with only class names and headers, + # then returns one export payload per unique class in that graph. + # traverses all the has_many and belongs_to descendants of the model provided. + def export_has_many_graph(root_class) + graph = build_export_graph(root_class) + + headers_by_class = {} + each_graph_node_for_export(graph) do |node, headers_for_export| + class_name = node[:class_name] + headers_by_class[class_name] ||= [] + headers_by_class[class_name] |= headers_for_export + end + + exports = [] + headers_by_class.each do |class_name, headers| + exports.concat(Export.export_csv(class_name.constantize, headers)) + end + + exports + end + + # Recursively builds an export graph for the class and its related children. + def build_export_graph(root_class, visited = Set.new) + klass = normalize_class(root_class) + class_name = klass.name + + if visited.include?(class_name) + return { + class_name: class_name, + headers: mandatory_headers_for(klass), + cyclic_reference: true, + has_many: [] + } + end + + visited.add(class_name) + + children = [] + + # get has_many models + klass.reflect_on_all_associations(:has_many).each do |association| + begin + child_klass = association.klass + rescue StandardError + next + end + + children << build_export_graph(child_klass, visited) + end + + # get belongs_to models + descendants_with_belongs_to_parent(klass).each do |child_klass| + children << build_export_graph(child_klass, visited) + end + + { + class_name: class_name, + headers: mandatory_headers_for(klass), + has_many: dedupe_children(children) + } + end + + # Finds descendant models that point back to the parent through a belongs_to association. + def descendants_with_belongs_to_parent(parent_klass) + descendants = ActiveRecord::Base.descendants.select { |model| model < ApplicationRecord } + + descendants.select do |candidate| + next false if candidate == parent_klass + + candidate.reflect_on_all_associations(:belongs_to).any? do |belongs_to_association| + begin + belongs_to_association.klass == parent_klass || parent_klass <= belongs_to_association.klass + rescue StandardError + false + end + end + end + end + private_class_method :descendants_with_belongs_to_parent + + # Removes duplicate child nodes so each class appears only once per level. + def dedupe_children(children) + children.uniq { |child| child[:class_name] } + end + private_class_method :dedupe_children + + # Walks each unique node in the graph once and yields it to the caller. + def each_graph_node(graph, seen = Set.new, &block) + return if seen.include?(graph[:class_name]) + + seen.add(graph[:class_name]) + block.call(graph) + + graph[:has_many].each do |child| + each_graph_node(child, seen, &block) + end + end + private_class_method :each_graph_node + + # Traverses the graph while carrying inherited headers needed for child exports. + def each_graph_node_for_export(graph, inherited_headers = [], seen = Set.new, &block) + return if seen.include?(graph[:class_name]) + + seen.add(graph[:class_name]) + headers_for_export = filter_headers_for_class( + graph[:class_name], + remove_identifier_fields(Array(graph[:headers]) + Array(inherited_headers)) + ) + block.call(graph, headers_for_export) + + prefixed_parent_headers = prefix_headers_with_class_name(graph[:headers], graph[:class_name]) + child_inherited_headers = remove_identifier_fields(Array(inherited_headers) + Array(prefixed_parent_headers)) + + graph[:has_many].each do |child| + each_graph_node_for_export(child, child_inherited_headers, seen, &block) + end + end + private_class_method :each_graph_node_for_export + + # Prefixes headers with the underscored class name to preserve parent context. + def prefix_headers_with_class_name(headers, class_name) + klass = class_name.is_a?(Class) ? class_name : class_name.constantize + prefix = klass.name.underscore + Array(headers).map { |header| "#{prefix}_#{header}" } + end + private_class_method :prefix_headers_with_class_name + + # Returns the required exportable headers for the given class. + def mandatory_headers_for(klass) + if klass.respond_to?(:mandatory_fields) + mandatory = Array(klass.mandatory_fields).map(&:to_s) + return remove_identifier_fields(mandatory) unless mandatory.empty? + end + + if klass.respond_to?(:internal_and_external_fields) + return remove_identifier_fields(klass.internal_and_external_fields.map(&:to_s)) + end + + return remove_identifier_fields(klass.column_names) if klass.respond_to?(:column_names) + + raise ArgumentError, "No export headers available for #{klass.name}" + end + private_class_method :mandatory_headers_for + + # Keeps only headers that can actually be exported for the target class. + def filter_headers_for_class(class_name, headers) + klass = normalize_class(class_name) + exportable_headers = if klass.respond_to?(:internal_and_external_fields) + remove_identifier_fields(klass.internal_and_external_fields.map(&:to_s)) + elsif klass.respond_to?(:column_names) + remove_identifier_fields(klass.column_names) + else + [] + end + + Array(headers).select { |header| exportable_headers.include?(header) } + end + private_class_method :filter_headers_for_class + + # Strips primary and foreign key identifiers from a list of field names. + def remove_identifier_fields(fields) + Array(fields).map(&:to_s).uniq.reject { |field| field == 'id' || field.end_with?('_id') } + end + private_class_method :remove_identifier_fields + + # Normalizes a class reference so callers can pass either a class or its name. + def normalize_class(root_class) + root_class.is_a?(String) ? root_class.constantize : root_class + end + private_class_method :normalize_class +end diff --git a/app/helpers/importable_exportable_helper.rb b/app/helpers/importable_exportable_helper.rb new file mode 100644 index 000000000..1ba4c9c77 --- /dev/null +++ b/app/helpers/importable_exportable_helper.rb @@ -0,0 +1,446 @@ +# importable_exportable_helper.rb +# +# =============================================================== +# ExternalClass +# +# Represents a class referenced by another class during import. +# For example: +# - Importing Teams may also need to create Users or Roles +# - Importing Assignments may need to create Topics +# +# This object encodes: +# • Which class is referenced +# • Whether it should be LOOKED UP or CREATED +# • What field should be used to perform look_ups +# +# The importer uses this information to: +# • Map CSV fields to the external class +# • Attempt to find existing referenced objects +# • Create new referenced objects when required +# +# Example: +# ExternalClass.new(User, should_look_up: true, should_create: false, look_up_field: :email) +# =============================================================== +class ExternalClass + attr_accessor :ref_class, :should_look_up, :should_create + + def initialize(ref_class, should_look_up = false, should_create = true, look_up_field = nil) + @ref_class = ref_class # The class being referenced (e.g., User) + @should_look_up = should_look_up # Whether existing objects should be searched for + @should_create = should_create # Whether new objects should be created if no match found + @look_up_field = look_up_field # Column used to identify existing objects + end + + # -------------------------------------------------------------- + # Resolve what fields belong to the external class. + # + # If the class itself includes ImportableExportable, we use its + # internal_fields. Otherwise, we fall back to: + # - the look_up field, or + # - the primary key + # + # All returned fields are namespaced (role_name, user_email, etc.) + # -------------------------------------------------------------- + def fields + if @ref_class.respond_to?(:internal_fields) + @ref_class.internal_fields.map { |field| self.class.append_class_name(@ref_class, field) } + else + [self.class.append_class_name(@ref_class, @look_up_field.to_s), self.class.append_class_name(@ref_class, @ref_class.primary_key)] + end + end + + # -------------------------------------------------------------- + # look_up an external object in the database. + # + # Uses either: + # • a look_up field, or + # • the primary key + # + # It will try both the namespaced version (e.g. role_name) + # and the raw version (name) depending on what exists in the model. + # -------------------------------------------------------------- + def look_up(class_values) + class_name_look_up_field = self.class.append_class_name(@ref_class, @look_up_field.to_s) + class_name_primary_field = self.class.append_class_name(@ref_class, @ref_class.primary_key) + + value = nil + + # ---------- Try look_up field ---------- + if @look_up_field && class_values[class_name_look_up_field] + if @ref_class.attribute_method?(@look_up_field) + value = @ref_class.find_by(@look_up_field => class_values[class_name_look_up_field]) + elsif @ref_class.attribute_method?(class_name_look_up_field) + value = @ref_class.find_by(class_name_look_up_field => class_values[class_name_look_up_field]) + end + + # ---------- Try primary key ---------- + elsif class_values[class_name_primary_field] + if @ref_class.attribute_method?(@ref_class.primary_key) + value = @ref_class.find_by(@ref_class.primary_key => class_values[class_name_primary_field]) + elsif @ref_class.attribute_method?(class_name_primary_field) + value = @ref_class.find_by(class_name_primary_field => class_values[class_name_primary_field]) + end + end + + value + end + + # -------------------------------------------------------------- + # Convert CSV attributes (namespaced) into attributes that match + # the external class (un-namespaced). + # -------------------------------------------------------------- + def from_hash(attrs) + fixed = {} + attrs.each { |k, v| fixed[self.class.unappended_class_name(@ref_class, k)] = v } + @ref_class.new(fixed) + end + + # Prefix column with the class name ("role_name", "user_email") + def self.append_class_name(ref_class, field) + "#{ref_class.name.underscore}_#{field}" + end + + # Remove class name prefix + def self.unappended_class_name(ref_class, name) + name.delete_prefix("#{ref_class.name.underscore}_") + end +end + +# =============================================================== +# ImportableExportableHelper +# +# This module adds import/export metadata and behavior to models. +# +# It supports: +# • mandatory fields +# • optional fields +# • external class definitions +# • combining internal and external fields +# • row-level import logic +# +# Any model including this module becomes import/export capable. +# +# Example: +# +# class Team < ApplicationRecord +# extend ImportableExportableHelper +# mandatory_fields :name +# external_classes ExternalClass.new(User, true, true, :email) +# end +# +# =============================================================== +module ImportableExportableHelper + + # -------------------------------------------------------------- + # When extended by a class, inherit parent import settings. + # + # This allows STI or subclassed models to reuse configuration. + # -------------------------------------------------------------- + def self.extended(base) + if base.superclass.respond_to?(:mandatory_fields) + base.instance_variable_set(:@mandatory_fields, base.superclass.mandatory_fields) + base.instance_variable_set(:@external_classes, base.superclass.external_classes) + base.instance_variable_set(:@class_name, base.superclass.name) + base.instance_variable_set(:@available_actions_on_duplicate, base.superclass.available_actions_on_duplicate) + else + base.instance_variable_set(:@class_name, base.name) + end + end + + # 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) + @filter_method = filter_proc if filter_proc + @filter_method || -> { all } + end + + # -------------------------------------------------------------- + # Define or retrieve mandatory fields. + # These must be present in the CSV. + # -------------------------------------------------------------- + def mandatory_fields(*fields) + if fields.any? + @mandatory_fields = fields.map(&:to_s) - hidden_fields + else + @mandatory_fields + end + end + + def hidden_fields(*fields) + if fields.any? + @hidden_fields = fields.map(&:to_s) + else + @hidden_fields || [] + end + end + + # -------------------------------------------------------------- + # Optional = internal fields - mandatory + # -------------------------------------------------------------- + def optional_fields + unhidden_fields - (mandatory_fields || []) + end + + def unhidden_fields + internal_fields - (hidden_fields || []) + end + + # -------------------------------------------------------------- + # Define or retrieve external classes. + # + # Example: + # external_classes ExternalClass.new(Role, true, false, :name) + # -------------------------------------------------------------- + def external_classes(*fields) + if fields.any? + @external_classes = fields + else + @external_classes || [] + end + end + + # -------------------------------------------------------------- + # Define or retrieve available duplicate actions. + # + # Example: + # available_actions_on_duplicate DuplicateAction, SkipRecordAction + # -------------------------------------------------------------- + def available_actions_on_duplicate(*fields) + if fields.any? + @available_actions_on_duplicate = fields + else + @available_actions_on_duplicate || [] + end + end + + # -------------------------------------------------------------- + # INTERNAL FIELDS + # + # Internal fields come from: + # • database column names + # • mandatory_fields + # + # Then external fields are removed (to prevent duplication). + # -------------------------------------------------------------- + def internal_fields + (column_names + (mandatory_fields || [])).uniq - external_fields + end + + # -------------------------------------------------------------- + # EXTERNAL FIELDS + # + # Flatten all internal fields from all external class definitions. + # -------------------------------------------------------------- + def external_fields + fields = [] + external_classes&.each { |external_class| fields += external_class.fields } + + fields + end + + # Combined fields for full CSV mapping + def internal_and_external_fields + internal_fields + external_fields + end + + # -------------------------------------------------------------- + # Construct an object from a CSV row hash. + # + # For internal fields, the value is stored as an array during + # parsing, so we take the first element. + # -------------------------------------------------------------- + def from_hash(attrs) + cleaned = {} + attrs.each { |k, v| cleaned[k] = v[0] } + new(cleaned) + end + + # -------------------------------------------------------------- + # Export helper + # Returns a hash of internal fields → values + # -------------------------------------------------------------- + def to_hash(fields = self.class.internal_fields) + fields.to_h { |f| [f, send(f)] } + end + + + # -------------------------------------------------------------- + # MAIN IMPORT WORKFLOW + # + # Creates a temporary file with normalized headers, + # then iterates through rows, importing them one by one. + # + # Duplicate objects are collected and returned. + # -------------------------------------------------------------- + def try_import_records(file, headers, use_header, defaults = {}) + temp_file = 'output.csv' + csv_file = CSV.read(file) + + mapping = [] + + # ---- Normalize header row ---- + CSV.open(temp_file, "w") do |csv| + if use_header + headers = csv_file.shift.map { |h| h.parameterize.underscore } + else + headers = headers.map { |header| header.parameterize.underscore } + end + + mapping = FieldMapping.from_header(self, headers) + + csv << headers + csv_file.each { |row| csv << row } + end + + temp_contents = CSV.read(temp_file) + temp_contents.shift # drop header + + duplicate_records = [] + + ActiveRecord::Base.transaction do + temp_contents.each do |row| + dup = import_row(row, mapping, defaults) + duplicate_records << dup if dup && dup != true + end + + rescue StandardError => e + puts e.message + raise ActiveRecord::Rollback + end + + File.delete(temp_file) + duplicate_records + end + + # -------------------------------------------------------------- + # Import a single row into the current model. + # + # Handles: + # • mapping values + # • building internal object + # • external object look_up/creation + # • save + duplicate capture + # + # Returns: + # • true if saved successfully + # • duplicate object if duplicate occurred + # -------------------------------------------------------------- + def import_row(row, mapping, defaults = {}) + + # Build row_hash where each key maps to all found values + row_hash = {} + mapping.ordered_fields.zip(row).each do |key, value| + row_hash[key] ||= [] + row_hash[key] << value + end + + # Create object for this class + current_class_attrs = row_hash.slice(*internal_fields) + created_object = from_hash(current_class_attrs) + defaults.compact.each do |field, value| + next unless created_object.respond_to?(field) + next if created_object.public_send(field).present? + + created_object.public_send("#{field}=", value) + end + + # for each external class, try to look them up + external_classes&.each do |external_class| + look_up_external_class(row_hash, external_class, created_object) + end + + duplicate = save_object(created_object) + return duplicate if duplicate && duplicate != true + + return true if external_classes.empty? + + external_classes.each do |external_class| + create_external_class(row_hash, external_class, created_object) + end + + true + + end + + private + + # -------------------------------------------------------------- + # Attempt to find an external object via look_up rules. + # If found, attach it to the parent object. + # -------------------------------------------------------------- + def look_up_external_class(row_hash, external_class, parent_obj) + if external_class.should_look_up && (found = external_class.look_up(row_hash)) + parent_obj.send("#{external_class.ref_class.name.downcase}=", found) + nil + end + end + + # -------------------------------------------------------------- + # When look_ups fail AND the external class allows creation, + # build and save new external objects. + # + # Handles multi-row data such as: + # field1: ["A", "B"] + # field2: ["X", "Y"] + # + # Which turns into: + # [{field1: "A", field2: "X"}, {field1: "B", field2: "Y"}] + # -------------------------------------------------------------- + def create_external_class(row_hash, external_class, parent_obj) + return unless external_class.should_create + + current_class_attrs = row_hash.slice(*external_class.fields) + + object_sets = current_class_attrs.values.transpose + object_sets_with_keys = object_sets.map do |row_values| + Hash[current_class_attrs.keys.zip(row_values)] + end + + object_sets_with_keys.each do |attrs| + created_object = external_class.from_hash(attrs) + + # Set relationship to parent + created_object.send("#{@class_name.underscore}=", parent_obj) + + save_object(created_object) + end + end + + # -------------------------------------------------------------- + # Save an object safely, detecting: + # • Validation errors + # • Uniqueness violations + # + # Returns: + # • created_object on uniqueness error (for duplicate workflow) + # • true if saved + # -------------------------------------------------------------- + def save_object(created_object) + created_object.save! + rescue ActiveRecord::RecordInvalid => e + # Check if a specific attribute has a :uniqueness error + is_taken = created_object.errors.details.any? { |attribute, error_details_array| error_details_array.any? { |detail_hash| detail_hash[:error] == :taken } } + multiple_field_errors = created_object.errors.details.size > 1 + single_field_errors = created_object.errors.details.any? { |attribute, error_details_array| error_details_array.size > 1} + + if is_taken && !multiple_field_errors && !single_field_errors + return created_object + end + + raise StandardError.new(e.message) + + + rescue ActiveRecord::RecordNotUnique => e + puts "Unique constraint violation: #{e.message}" + created_object + end +end diff --git a/app/models/Item.rb b/app/models/Item.rb index 0b6535228..7170a38d4 100644 --- a/app/models/Item.rb +++ b/app/models/Item.rb @@ -1,6 +1,13 @@ # frozen_string_literal: true class Item < ApplicationRecord + extend ImportableExportableHelper + mandatory_fields :txt, :weight, :seq, :question_type, :break_before, :questionnaire_name + hidden_fields :id, :created_at, :updated_at + external_classes ExternalClass.new(Questionnaire, true, false, :name) + filter nil + 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' @@ -91,4 +98,4 @@ def self.for(record) # Cast the existing record to the desired subclass klass.new(record.attributes) end -end \ No newline at end of file +end diff --git a/app/models/answer.rb b/app/models/answer.rb index 3c3320afe..e7a19a18d 100644 --- a/app/models/answer.rb +++ b/app/models/answer.rb @@ -1,6 +1,14 @@ # frozen_string_literal: true class Answer < ApplicationRecord + extend ImportableExportableHelper + mandatory_fields :answer, :comments, :item_seq + hidden_fields :id, :created_at, :updated_at + external_classes ExternalClass.new(Item, true, false, :seq), + ExternalClass.new(Response, true, false, :additional_comment) + + export_submodels false + filter nil belongs_to :response belongs_to :item end diff --git a/app/models/multiple_choice_checkbox.rb b/app/models/multiple_choice_checkbox.rb index a3ec77e99..e31ebddfb 100644 --- a/app/models/multiple_choice_checkbox.rb +++ b/app/models/multiple_choice_checkbox.rb @@ -4,7 +4,7 @@ class MultipleChoiceCheckbox < QuizItem def edit - quiz_question_choices = QuizQuestionChoice.where(question_id: id) + quiz_question_choices = QuizQuestionChoice.where(item_id: id) data = { id: id, @@ -24,7 +24,7 @@ def edit end def complete - quiz_question_choices = QuizQuestionChoice.where(question_id: id) + quiz_question_choices = QuizQuestionChoice.where(item_id: id) data = { id: id, @@ -38,7 +38,7 @@ def complete end def view_completed_item(user_answer) - quiz_question_choices = QuizQuestionChoice.where(question_id: id) + quiz_question_choices = QuizQuestionChoice.where(item_id: id) data = { question_choices: quiz_question_choices.map do |choice| diff --git a/app/models/multiple_choice_radio.rb b/app/models/multiple_choice_radio.rb index 910659074..0c5fdbfbd 100644 --- a/app/models/multiple_choice_radio.rb +++ b/app/models/multiple_choice_radio.rb @@ -4,7 +4,7 @@ class MultipleChoiceRadio < QuizItem def edit - quiz_question_choices = QuizQuestionChoice.where(question_id: id) + quiz_question_choices = QuizQuestionChoice.where(item_id: id) choices = quiz_question_choices.map.with_index(1) do |choice, index| { @@ -24,7 +24,7 @@ def edit end def complete - quiz_question_choices = QuizQuestionChoice.where(question_id: id) + quiz_question_choices = QuizQuestionChoice.where(item_id: id) choices = quiz_question_choices.map.with_index(1) do |choice, index| { @@ -35,14 +35,14 @@ def complete end { - question_id: id, + item_id: id, question_text: txt, choices: choices }.to_json end def view_completed_item(user_answer) - quiz_question_choices = QuizQuestionChoice.where(question_id: id) + quiz_question_choices = QuizQuestionChoice.where(item_id: id) choices = quiz_question_choices.map do |choice| { diff --git a/app/models/project_topic.rb b/app/models/project_topic.rb index 3627be58b..096e42ec2 100644 --- a/app/models/project_topic.rb +++ b/app/models/project_topic.rb @@ -1,7 +1,16 @@ 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 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 + export_submodels false + # Ensures the number of max choosers is non-negative validates :max_choosers, numericality: { diff --git a/app/models/pseudo/grades.rb b/app/models/pseudo/grades.rb new file mode 100644 index 000000000..c7b542960 --- /dev/null +++ b/app/models/pseudo/grades.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +module Pseudo +class Grades + COLUMN_NAMES = %w[ + assignment_id + assignment_name + team_id + team_name + participant_id + participant_name + participant_email + submission_grade + review_grade + teammate_review_grade + author_feedback_grade + ].freeze + + Row = Struct.new(*COLUMN_NAMES.map(&:to_sym), keyword_init: true) + + extend ImportableExportableHelper + + export_submodels false + + mandatory_fields :assignment_name, :team_name, :participant_name + # hidden_fields :id, :created_at, :updated_at + filter -> { aggregate_grades } + + # Spoof a table-backed model so ImportableExportableHelper can treat + # Grades like a normal exportable class. + def self.column_names + COLUMN_NAMES + end + + def self.aggregate_grades + AssignmentParticipant.includes(:user).map do |participant| + assignment = participant.assignment + team = participant.team + next if assignment.nil? || team.nil? + + teammate_ids = team.participants.where.not(id: participant.id).pluck(:id) + + reviews_of_me_maps = TeammateReviewResponseMap.where( + reviewed_object_id: assignment.id, + reviewee_id: participant.id, + reviewer_id: teammate_ids + ).to_a + + reviews_by_me_maps = TeammateReviewResponseMap.where( + reviewed_object_id: assignment.id, + reviewer_id: participant.id + ).to_a + + my_reviews_of_other_teams_maps = ReviewResponseMap.where( + reviewed_object_id: assignment.id, + reviewer_id: participant.id + ) + + feedback_from_my_reviewees_maps = my_reviews_of_other_teams_maps.filter_map do |map| + FeedbackResponseMap.find_by(reviewed_object_id: map.id, reviewee_id: participant.id) + end + + Row.new( + assignment_id: assignment.id, + assignment_name: assignment.name, + team_id: team.id, + team_name: team.name, + participant_id: participant.id, + participant_name: participant.user_name, + participant_email: participant.user&.email, + submission_grade: team.grade_for_submission, + review_grade: team.aggregate_review_grade, + teammate_review_grade: participant.aggregate_teammate_review_grade(reviews_of_me_maps), + author_feedback_grade: participant.aggregate_teammate_review_grade(feedback_from_my_reviewees_maps) + ) + end.compact + end +end +end \ No newline at end of file diff --git a/app/models/question_advice.rb b/app/models/question_advice.rb index 76b54c56d..0147b22c9 100644 --- a/app/models/question_advice.rb +++ b/app/models/question_advice.rb @@ -1,24 +1,31 @@ # frozen_string_literal: true class QuestionAdvice < ApplicationRecord - belongs_to :item - def self.export_fields(_options) - QuestionAdvice.columns.map(&:name) - end - - def self.export(csv, parent_id, _options) - questionnaire = Questionnaire.find(parent_id) - questionnaire.items.each do |item| - QuestionAdvice.where(question_id: item.id).each do |advice| - csv << advice.attributes.values - end + extend ImportableExportableHelper + mandatory_fields :score, :advice, :item_seq + hidden_fields :id, :created_at, :updated_at + export_submodels false + + external_classes ExternalClass.new(Item, true, false, :seq) + filter nil + belongs_to :item + def self.export_fields(_options) + QuestionAdvice.columns.map(&:name) + end + + def self.export(csv, parent_id, _options) + questionnaire = Questionnaire.find(parent_id) + questionnaire.items.each do |item| + QuestionAdvice.where(item_id: item.id).each do |advice| + csv << advice.attributes.values end end - - def self.to_json_by_question_id(question_id) - question_advices = QuestionAdvice.where(question_id: question_id).order(:id) - question_advices.map do |advice| - { score: advice.score, advice: advice.advice } - end + end + + def self.to_json_by_question_id(question_id) + question_advices = QuestionAdvice.where(item_id: question_id).order(:id) + question_advices.map do |advice| + { score: advice.score, advice: advice.advice } end - end \ No newline at end of file + end +end diff --git a/app/models/questionnaire.rb b/app/models/questionnaire.rb index b7eeee017..6767ac841 100644 --- a/app/models/questionnaire.rb +++ b/app/models/questionnaire.rb @@ -1,10 +1,16 @@ # frozen_string_literal: true class Questionnaire < ApplicationRecord + extend ImportableExportableHelper + mandatory_fields :name, :min_question_score, :max_question_score, :questionnaire_type, :display_type, :instruction_loc, :instructor_name + hidden_fields :id, :created_at, :updated_at + external_classes ExternalClass.new(Instructor, true, false, :name) + available_actions_on_duplicate SkipRecordAction.new, UpdateExistingRecordAction.new, ChangeOffendingFieldAction.new + filter nil belongs_to :instructor has_many :items, class_name: "Item", foreign_key: "questionnaire_id", dependent: :destroy # the collection of items associated with this Questionnaire before_destroy :check_for_question_associations - + 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 8367ca20e..90a887339 100644 --- a/app/models/quiz_item.rb +++ b/app/models/quiz_item.rb @@ -3,7 +3,11 @@ require 'json' class QuizItem < Item - has_many :quiz_question_choices, class_name: 'QuizQuestionChoice', foreign_key: 'question_id', inverse_of: false, dependent: :nullify + extend ImportableExportableHelper + hidden_fields :id, :created_at, :updated_at + has_many :quiz_question_choices, class_name: 'QuizQuestionChoice', foreign_key: 'item_id', inverse_of: false, dependent: :nullify + filter nil + export_submodels false def edit end diff --git a/app/models/role.rb b/app/models/role.rb index 32c3221d0..8897ec192 100644 --- a/app/models/role.rb +++ b/app/models/role.rb @@ -6,11 +6,11 @@ class Role < ApplicationRecord has_many :users, dependent: :nullify # Role IDs - STUDENT_ID = 1 - TEACHING_ASSISTANT_ID = 2 + STUDENT_ID = 5 + TEACHING_ASSISTANT_ID = 4 INSTRUCTOR_ID = 3 - ADMINISTRATOR_ID = 4 - SUPER_ADMINISTRATOR_ID = 5 + ADMINISTRATOR_ID = 2 + SUPER_ADMINISTRATOR_ID = 1 def super_administrator? name['Super Administrator'] diff --git a/app/models/team.rb b/app/models/team.rb index e2c39fbaa..eb082c0c1 100644 --- a/app/models/team.rb +++ b/app/models/team.rb @@ -1,6 +1,36 @@ # frozen_string_literal: true class Team < ApplicationRecord + extend ImportableExportableHelper + 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 } + export_submodels false + + 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 @@ -10,6 +40,7 @@ class Team < ApplicationRecord has_many :users, through: :teams_participants has_many :participants, through: :teams_participants has_many :join_team_requests, dependent: :destroy + has_many :sent_invitations, class_name: 'Invitation', foreign_key: 'from_id', dependent: :destroy # The team is either an AssignmentTeam or a CourseTeam belongs_to :assignment, class_name: 'Assignment', foreign_key: 'parent_id', optional: true @@ -21,16 +52,17 @@ class Team < ApplicationRecord validates :type, presence: true, inclusion: { in: %w[AssignmentTeam CourseTeam MentoredTeam], message: "must be 'Assignment' or 'Course' or 'Mentor'" } after_update :release_topics_if_empty + before_destroy :clear_participant_team_references def has_member?(user) participants.exists?(user_id: user.id) end - + # Returns the current number of team members def team_size users.count end - + # Returns the maximum allowed team size def max_size if is_a?(AssignmentTeam) && assignment&.max_team_size @@ -41,7 +73,7 @@ def max_size nil end end - + def full? current_size = participants.count @@ -115,10 +147,10 @@ def remove_member(participant) # Remove the join record if it exists tp = TeamsParticipant.find_by(team_id: id, participant_id: participant.id) tp&.destroy - + # Update the participant's team_id column - will remove the team reference inside participants table later. keeping it for now # this will remove the reference only if the participant's current team is the same team removing the participant - if participant.team_id==id + if participant.team_id == id participant.update!(team_id: nil) end @@ -155,13 +187,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/app/models/user.rb b/app/models/user.rb index 0e77e25dc..edf4b4155 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,9 +1,19 @@ # frozen_string_literal: true class User < ApplicationRecord + extend ImportableExportableHelper + mandatory_fields :name, :email, :password, :full_name, :role_name, :institution_name + hidden_fields :id, :created_at, :updated_at + external_classes ExternalClass.new(Role, true, false, :name), + ExternalClass.new(Institution, true, false, :name) + available_actions_on_duplicate SkipRecordAction.new, UpdateExistingRecordAction.new, ChangeOffendingFieldAction.new + filter nil + export_submodels false + has_secure_password after_initialize :set_defaults + # name must be lowercase and unique validates :name, presence: true, uniqueness: true, allow_blank: false # format: { with: /\A[a-z]+\z/, message: 'must be in lowercase' } @@ -34,14 +44,14 @@ class User < ApplicationRecord delegate :super_administrator?, to: :role def self.instantiate(record) - case record.role - when Role::TEACHING_ASSISTANT + case record.role.id + when Role::TEACHING_ASSISTANT_ID record.becomes(Ta) - when Role::INSTRUCTOR + when Role::INSTRUCTOR_ID record.becomes(Instructor) - when Role::ADMINISTRATOR + when Role::ADMINISTRATOR_ID record.becomes(Administrator) - when Role::SUPER_ADMINISTRATOR + when Role::SUPER_ADMINISTRATOR_ID record.becomes(SuperAdministrator) else super diff --git a/app/serializers/project_topic_serializer.rb b/app/serializers/project_topic_serializer.rb new file mode 100644 index 000000000..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/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 diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb index a1a7e28ee..3204221a4 100644 --- a/app/serializers/user_serializer.rb +++ b/app/serializers/user_serializer.rb @@ -1,13 +1,38 @@ # frozen_string_literal: true class UserSerializer < ActiveModel::Serializer - attributes :id, :username, :email, :fullName + attributes :id, :name, :email, :full_name, :email_on_review, :email_on_submission, + :email_on_review_of_review, :date_format_pref, :created_at, :updated_at, + :role, :parent, :institution - def username - object.name + def role + return nil unless object.role + + { + id: object.role.id, + name: object.role.name + } + end + + def parent + return { id: nil, name: nil } unless object.parent + + { + id: object.parent.id, + name: object.parent.name + } + end + + def institution + return { id: nil, name: nil } unless object.institution + + { + id: object.institution.id, + name: object.institution.name + } end - def fullName - object.full_name + def date_format_pref + nil end end diff --git a/app/services/change_offending_field_action.rb b/app/services/change_offending_field_action.rb new file mode 100644 index 000000000..73b2480bb --- /dev/null +++ b/app/services/change_offending_field_action.rb @@ -0,0 +1,85 @@ +# =============================================================== +# ChangeOffendingFieldAction +# +# Strategy: **Automatically adjust the offending (unique) fields** +# so the imported row can still be inserted. +# +# This is the default strategy used by Import unless overridden. +# +# Example: +# existing.name = "Alice" +# incoming.name = "Alice" +# +# → incoming.name becomes "Alice_copy" +# +# If still not unique: +# "Alice_copy2", "Alice_copy3", etc. +# +# How it works: +# 1. Collect all fields with uniqueness validators +# 2. If incoming[field] == existing[field], mutate it +# 3. Keep incrementing until the value no longer exists in the DB +# +# =============================================================== +class ChangeOffendingFieldAction + def on_duplicate_record(klass, records) + # Normalize both existing and incoming row formats + existing = records[:existing] + incoming = records[:incoming].dup + + # Determine fields with uniqueness validators + unique_fields = unique_constraint_fields(klass) + + # For each unique field, adjust if conflict detected + unique_fields.each do |field| + next unless incoming[field] == existing[field] + + incoming[field] = generate_unique_value( + klass: klass, + field: field, + base: incoming[field] + ) + end + + incoming # Returning one resolved record + end + + private + + # Standardize input into a symbolized hash + def normalize(record) + return record.symbolize_keys if record.is_a?(Hash) + record.attributes.symbolize_keys + end + + # Extract all attributes validated as unique via ActiveRecord + def unique_constraint_fields(klass) + klass.validators + .select { |v| v.is_a?(ActiveRecord::Validations::UniquenessValidator) } + .flat_map(&:attributes) + .map(&:to_sym) + end + + # Generate a new unique value by appending suffixes until unique + # + # Example: + # base = "Alice" + # → "Alice_copy" + # → "Alice_copy2" + # → "Alice_copy3" + # + def generate_unique_value(klass:, field:, base:) + candidate = base.to_s + counter = 1 + + # Keep generating values until one does not exist in DB + while klass.exists?(field => candidate) + candidate = + "#{base}_copy#{counter == 1 ? '' : counter}" + + counter += 1 + end + + candidate + end +end diff --git a/app/services/export.rb b/app/services/export.rb new file mode 100644 index 000000000..f2f25b6ea --- /dev/null +++ b/app/services/export.rb @@ -0,0 +1,70 @@ +# 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 + + ## + # 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 self.export_csv(export_class, ordered_headers) + ordered_headers ||= export_class.internal_and_external_fields + mapping = FieldMapping.from_header(export_class, ordered_headers) + + csv_contents = CSV.generate do |csv| + class_fields = mapping.ordered_fields.select{ |ele| export_class.internal_fields.include?(ele) } + + + # Extract column headers from the first row's keys + csv << ordered_headers + + # Insert each row in order, using the values of the hash + export_class.filter.call.each do |record| + row = class_fields.map{|f| record.send(f)} + + Array(export_class.external_classes).each do |external_class| + ext_class_fields = mapping.ordered_fields.select{ |ele| external_class.fields.include?(ele) } + found_record = record.send(external_class.ref_class.name.underscore) + row += ext_class_fields.map do |f| + found_record&.send(ExternalClass.unappended_class_name(external_class.ref_class, f)) if f + end + end + + csv << row + end + end + + [{ name: export_class.name, contents: csv_contents }] + end + + def self.perform(export_class, ordered_headers = nil) + return ExportHelper.export_has_many_graph(export_class) if export_class.export_submodels + + export_csv(export_class, ordered_headers) + end + +end diff --git a/app/services/field_mapping.rb b/app/services/field_mapping.rb new file mode 100644 index 000000000..8df4a8975 --- /dev/null +++ b/app/services/field_mapping.rb @@ -0,0 +1,140 @@ +# app/services/field_mapping.rb +# +# =============================================================== +# FieldMapping +# +# This class defines how CSV fields are mapped to an internal +# ActiveRecord model’s attributes. It is used by the import/export +# service layer to: +# +# • Determine the order of fields in an exported CSV +# • Interpret CSV rows and produce attribute hashes +# • Build mappings based on CSV headers, if the import uses headers +# +# The mapping is intentionally simple: it stores an array of field +# names (strings) in the order that the import/export process should +# follow. +# +# =============================================================== +class FieldMapping + attr_reader :model_class, :ordered_fields + + # -------------------------------------------------------------- + # Initialize a new mapping. + # + # model_class: + # An ActiveRecord model class (e.g., User, Team, Assignment) + # + # ordered_fields: + # Array of field names that define the order CSV fields appear + # in. We convert everything to strings to ensure consistent + # look_ups (symbols vs strings cause unnecessary mismatches). + # + # Example: + # FieldMapping.new(User, [:email, "first_name", :last_name]) + # + # Output: + # @ordered_fields = ["email", "first_name", "last_name"] + # -------------------------------------------------------------- + def initialize(model_class, ordered_fields) + @model_class = model_class + @ordered_fields = ordered_fields.map(&:to_s) + end + + # -------------------------------------------------------------- + # Build a mapping using the header row from a CSV file. + # + # header_row: + # Array of strings taken from the first row of a CSV file: + # ["Email", "Last Name", "First Name"] + # + # How matching works: + # - Normalize headers (strip whitespace, lowercase comparison) + # - Compare headers case-insensitively against all internal + external + # fields allowed by the model. + # - Only headers that match valid model fields are kept. + # + # Example: + # model_class.internal_and_external_fields = [:email, :first_name, :last_name] + # + # headers = ["EMAIL", "First Name", "Ignored Column"] + # + # matched = ["email", "first_name"] + # + # -------------------------------------------------------------- + def self.from_header(model_class, header_row) + # Normalize header strings + header_row = header_row.map { |h| h.to_s.strip } + + # Retrieve valid model fields (convert to strings for comparison) + valid_fields = model_class.internal_and_external_fields.map(&:to_s) + + # Match CSV headers to valid model fields (case-insensitive) + matched = header_row.filter_map do |h| + valid_fields.find { |f| f.casecmp?(h) } + end + + new(model_class, matched) + end + + # -------------------------------------------------------------- + # Returns the internal CSV header row for export. + # + # This is simply the list of ordered fields. + # -------------------------------------------------------------- + def headers + ordered_fields + end + + # -------------------------------------------------------------- + # Detect duplicate headers in the mapping. + # + # Useful for import validation, e.g., if a CSV contains: + # ["name", "email", "email"] + # + # Returns: + # ["email"] + # + # -------------------------------------------------------------- + def duplicate_headers + ordered_fields + .group_by { |h| h } + .select { |_k, v| v.size > 1 } + .keys + end + + # -------------------------------------------------------------- + # Given an ActiveRecord instance, extract values in the order needed + # for CSV export. + # + # Example: + # ordered_fields = ["email", "first_name"] + # record.email → "bob@example.com" + # record.first_name → "Bob" + # + # Output: + # ["bob@example.com", "Bob"] + # + # -------------------------------------------------------------- + def values_for(record) + ordered_fields.map { |f| record.public_send(f) } + end + + # -------------------------------------------------------------- + # Convert mapping to a JSON-friendly structure. + # + # Used by APIs or import UI to remember mapping preferences. + # + # Example output: + # { + # model_class: "User", + # ordered_fields: ["email", "first_name"] + # } + # -------------------------------------------------------------- + def to_h + { + model_class: model_class.name, + ordered_fields: ordered_fields + } + end +end diff --git a/app/services/import.rb b/app/services/import.rb new file mode 100644 index 000000000..94178693f --- /dev/null +++ b/app/services/import.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +require 'csv' + +# By default, if the caller does not specify a duplicate action, +# we use ChangeOffendingFieldAction. This ensures the importer +# always has a duplicate-resolution strategy. +DEFAULT_DUPLICATE_ACTION = ChangeOffendingFieldAction.new + +## +# Import class +# +# This class handles end-to-end CSV importing for any model that includes +# the ImportableExportable mixin. Its responsibilities include: +# +# • Loading CSV data +# • Mapping CSV columns into model attributes +# • Attempting to save each record +# • Collecting duplicate rows +# • Handling duplicates through a DuplicateAction strategy object +# +# The importer does NOT save the duplicates immediately. Instead it delegates +# conflict resolution to DuplicateAction subclasses. +# +class Import + ## + # Initializes an Import instance + # + # @param klass [Class] ActiveRecord model to import into + # @param file [String] path to CSV file + # @param mapping [FieldMapping, nil] optional mapping override + # @param dup_action [DuplicateAction, nil] optional duplicate handler override + # + def initialize(klass:, file:, headers: nil, dup_action: nil, defaults: {}) + @klass = klass + @file = file + @headers = headers + @duplicate_action = dup_action || DEFAULT_DUPLICATE_ACTION + @defaults = defaults || {} + end + + # -------------------------------------------------------------- + # MAIN IMPORT PROCESS + # -------------------------------------------------------------- + + ## + # Runs the full import: + # 1. Builds or uses existing field mapping + # 2. Parses the CSV into attribute hashes + # 3. Attempts to insert each row + # 4. On failure, collects duplicates into groups + # 5. Processes duplicate groups with assigned DuplicateAction + # + # Returns a summary with :imported and :duplicates count + # + def perform(use_headers) + duplicate_groups = [] # Will hold duplicate row sets + successful_inserts = 0 # Counter for successful saves + + # Call the model-level importer (defined in each model using the import mixin) + dups = @klass.try_import_records( + @file, + @headers, + use_headers, + @defaults + ) + + dups.each {|dup| duplicate_groups << normalize_duplicate(dup)} + + + # Let the duplicate action process all collected conflicts + process_duplicates(@klass, duplicate_groups) + + + # Return summary of import results + { + imported: successful_inserts, + duplicates: duplicate_groups.length + } + end + + private + + # -------------------------------------------------------------- + # DUPLICATE PROCESSING + # -------------------------------------------------------------- + + ## + # Normalizes duplicate information into a two-element array: + # + # [ existing_record_hash, incoming_record_hash ] + # + # Where: + # • existing_record_hash may be {} if not found in DB + # • incoming_record_hash is always the failed attributes + # + # This format is used by DuplicateAction subclasses to determine + # how the conflict should be resolved. + # + def normalize_duplicate(incoming_obj) + # Try to find the existing record using the primary key value + field = find_offending_field(incoming_obj) + + value = {} + value[field] = incoming_obj.as_json()[field.to_s] + + existing = @klass.find_by(value) + { + existing: existing, # Existing row (maybe empty) + incoming: incoming_obj # Incoming row + } + end + + def find_offending_field(incoming_obj) + incoming_obj.validate + incoming_obj.errors.details.each do |attribute, error_details_array| + return attribute if error_details_array.any? { |detail_hash| detail_hash[:error] == :taken } + end + end + + ## + # For every duplicate group (existing, incoming), call the provided duplicate + # action strategy. If the strategy returns an array of cleaned/merged rows, + # reinsert them into the DB. + # + # A duplicate action is expected to implement: + # + # on_duplicate_record(klass:, records:) + # + def process_duplicates(klass, groups) + groups.each do |records| + processed = @duplicate_action.on_duplicate_record( + klass, + records + ) + + # If the duplicate action returns nil, it means “skip insertion” + next if processed.nil? + + processed.save! + end + end + +end diff --git a/app/services/skip_record_action.rb b/app/services/skip_record_action.rb new file mode 100644 index 000000000..e231c93f5 --- /dev/null +++ b/app/services/skip_record_action.rb @@ -0,0 +1,18 @@ +# =============================================================== +# SkipRecordAction +# +# Strategy: **Ignore the incoming row entirely.** +# +# Usage example: +# - User chooses "Skip duplicates" on import +# - Any row that violates uniqueness constraints is dropped +# +# Behavior: +# Returning `nil` instructs Import.perform to do nothing. +# =============================================================== +class SkipRecordAction + def on_duplicate_record(klass, + records) + nil + end +end diff --git a/app/services/update_existing_record_action.rb b/app/services/update_existing_record_action.rb new file mode 100644 index 000000000..d079b76bb --- /dev/null +++ b/app/services/update_existing_record_action.rb @@ -0,0 +1,37 @@ +# =============================================================== +# UpdateExistingRecordAction +# +# Strategy: **Merge all duplicates into a single updated record.** +# +# Meaning: +# - If both existing and incoming records have data, +# incoming values overwrite existing ones (unless nil). +# +# Example: +# existing = { id: 5, name: "Alice", score: 80 } +# incoming = { id: 5, name: "Alice B.", score: nil } +# +# result = { id: 5, name: "Alice B.", score: 80 } +# +# Use case: +# "Update existing records with imported values" +# +# Importer will delete the original conflicting record and replace it +# with the merged one. +# =============================================================== +class UpdateExistingRecordAction + + def on_duplicate_record(klass, records) + merged = {} + + existing = records[:existing] + + klass.mandatory_fields.each do |field| + value = {} + value[field] = records[:incoming].send(field) + existing.send(:assign_attributes, value) + end + + existing + end +end diff --git a/config/database.yml b/config/database.yml index b9f5aa055..8a714597f 100644 --- a/config/database.yml +++ b/config/database.yml @@ -3,16 +3,18 @@ default: &default encoding: utf8mb4 pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> port: 3306 + username: root + password: expertiza socket: /var/run/mysqld/mysqld.sock development: <<: *default - url: <%= ENV['DATABASE_URL'].gsub('?', '_development?') %> + database: reimplementation_development test: <<: *default - url: <%= ENV['DATABASE_URL'].gsub('?', '_test?') %> + database: reimplementation_test production: <<: *default - url: <%= ENV['DATABASE_URL'].gsub('?', '_production?') %> \ No newline at end of file + database: reimplementation_production diff --git a/config/routes.rb b/config/routes.rb index 57559d007..22b158c75 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -214,4 +214,16 @@ resources :assignments do resources :duties, controller: 'assignments_duties', only: [:index, :create, :destroy] 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 diff --git a/db/migrate/20250321222753_rename_sign_up_topic_to_project_topic_in_signed_up_teams.rb b/db/migrate/20250321222753_rename_sign_up_topic_to_project_topic_in_signed_up_teams.rb index 1d9305315..e6f27f221 100644 --- a/db/migrate/20250321222753_rename_sign_up_topic_to_project_topic_in_signed_up_teams.rb +++ b/db/migrate/20250321222753_rename_sign_up_topic_to_project_topic_in_signed_up_teams.rb @@ -1,8 +1,13 @@ class RenameSignUpTopicToProjectTopicInSignedUpTeams < ActiveRecord::Migration[8.0] def change - rename_column :signed_up_teams, :sign_up_topic_id, :project_topic_id - rename_index :signed_up_teams, - :index_signed_up_teams_on_sign_up_topic_id, - :index_signed_up_teams_on_project_topic_id + if column_exists?(:signed_up_teams, :sign_up_topic_id) + rename_column :signed_up_teams, :sign_up_topic_id, :project_topic_id + end + + if index_exists?(:signed_up_teams, :index_signed_up_teams_on_sign_up_topic_id) + rename_index :signed_up_teams, + :index_signed_up_teams_on_sign_up_topic_id, + :index_signed_up_teams_on_project_topic_id + end end end diff --git a/db/migrate/20250418004442_change_to_polymorphic_association_in_teams.rb b/db/migrate/20250418004442_change_to_polymorphic_association_in_teams.rb index cd2fb7094..ae84636c8 100644 --- a/db/migrate/20250418004442_change_to_polymorphic_association_in_teams.rb +++ b/db/migrate/20250418004442_change_to_polymorphic_association_in_teams.rb @@ -3,9 +3,13 @@ class ChangeToPolymorphicAssociationInTeams < ActiveRecord::Migration[8.0] def change # Remove old assignment reference (course reference doesn't exist) - remove_reference :teams, :assignment, foreign_key: true + if column_exists?(:teams, :assignment_id) + remove_reference :teams, :assignment, foreign_key: true + end # Add polymorphic association fields (type column already exists) - add_column :teams, :parent_id, :integer, null: false + unless column_exists?(:teams, :parent_id) + add_column :teams, :parent_id, :integer, null: false + end end end diff --git a/db/migrate/20250629190818_add_comment_for_submission_to_team.rb b/db/migrate/20250629190818_add_comment_for_submission_to_team.rb index ac7e5dd44..053d24519 100644 --- a/db/migrate/20250629190818_add_comment_for_submission_to_team.rb +++ b/db/migrate/20250629190818_add_comment_for_submission_to_team.rb @@ -1,5 +1,7 @@ class AddCommentForSubmissionToTeam < ActiveRecord::Migration[8.0] def change - add_column :teams, :comment_for_submission, :string + unless column_exists?(:teams, :name) + add_column :teams, :comment_for_submission, :string + end end end diff --git a/db/migrate/20251022160053_change_invitation_from_id_foreign_key.rb b/db/migrate/20251022160053_change_invitation_from_id_foreign_key.rb index 9f5ab7f09..7ab99a427 100644 --- a/db/migrate/20251022160053_change_invitation_from_id_foreign_key.rb +++ b/db/migrate/20251022160053_change_invitation_from_id_foreign_key.rb @@ -1,9 +1,14 @@ class ChangeInvitationFromIdForeignKey < ActiveRecord::Migration[7.0] def change - # Remove old foreign key to participants - remove_foreign_key :invitations, column: :from_id + change_column :invitations, :from_id, :bigint + # Remove old foreign key to participants + if foreign_key_exists?(:invitations, column: :from_id) + remove_foreign_key :invitations, column: :from_id + end # Add new foreign key to teams - add_foreign_key :invitations, :teams, column: :from_id + unless foreign_key_exists?(:invitations, column: :from_id) + add_foreign_key :invitations, :teams, column: :from_id + end end end \ No newline at end of file diff --git a/db/migrate/20251126161701_remove_participant_ref_from_invitations.rb b/db/migrate/20251126161701_remove_participant_ref_from_invitations.rb index f6b0d2e68..ef7550e22 100644 --- a/db/migrate/20251126161701_remove_participant_ref_from_invitations.rb +++ b/db/migrate/20251126161701_remove_participant_ref_from_invitations.rb @@ -1,6 +1,10 @@ class RemoveParticipantRefFromInvitations < ActiveRecord::Migration[8.0] def change - remove_reference :invitations, :participant - remove_column :invitations, :participant_id, :integer + if column_exists?(:invitations, :participant_id) + remove_reference :invitations, :participant + if column_exists?(:invitations, :participant_id) + remove_column :invitations, :participant_id, :integer + end + end end end diff --git a/db/migrate/20251129040855_rename_item_id_in_question_tables.rb b/db/migrate/20251129040855_rename_item_id_in_question_tables.rb new file mode 100644 index 000000000..d8321be93 --- /dev/null +++ b/db/migrate/20251129040855_rename_item_id_in_question_tables.rb @@ -0,0 +1,16 @@ +class RenameItemIdInQuestionTables < ActiveRecord::Migration[8.0] + def change + rename_column_if_needed :answers + rename_column_if_needed :question_advices + rename_column_if_needed :quiz_question_choices + end + + private + + def rename_column_if_needed(table_name) + return unless column_exists?(table_name, :question_id) + return if column_exists?(table_name, :item_id) + + rename_column table_name, :question_id, :item_id + end +end \ No newline at end of file diff --git a/db/migrate/20260313064334_add_submission_fields_to_teams.rb b/db/migrate/20260313064334_add_submission_fields_to_teams.rb index d73ffe4f0..399d6a5c8 100644 --- a/db/migrate/20260313064334_add_submission_fields_to_teams.rb +++ b/db/migrate/20260313064334_add_submission_fields_to_teams.rb @@ -1,6 +1,10 @@ class AddSubmissionFieldsToTeams < ActiveRecord::Migration[8.0] def change - add_column :teams, :submitted_hyperlinks, :text - add_column :teams, :directory_num, :integer + unless column_exists?(:teams, :submitted_hyperlinks) + add_column :teams, :submitted_hyperlinks, :text + end + unless column_exists?(:teams, :directory_num) + add_column :teams, :directory_num, :integer + end end end diff --git a/db/migrate/20260328170000_rename_sign_up_topics_to_project_topics.rb b/db/migrate/20260328170000_rename_sign_up_topics_to_project_topics.rb new file mode 100644 index 000000000..6e9a8016e --- /dev/null +++ b/db/migrate/20260328170000_rename_sign_up_topics_to_project_topics.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +class RenameSignUpTopicsToProjectTopics < ActiveRecord::Migration[8.0] + def up + return if table_exists?(:project_topics) + return unless table_exists?(:sign_up_topics) + + if foreign_key_exists?(:signed_up_teams, :sign_up_topics) + remove_foreign_key :signed_up_teams, :sign_up_topics + end + + rename_table :sign_up_topics, :project_topics + + if index_name_exists?(:project_topics, "fk_sign_up_categories_sign_up_topics") && + !index_name_exists?(:project_topics, "index_project_topics_on_assignment_id") + rename_index :project_topics, + "fk_sign_up_categories_sign_up_topics", + "index_project_topics_on_assignment_id" + end + + if column_exists?(:signed_up_teams, :project_topic_id) && + !foreign_key_exists?(:signed_up_teams, :project_topics) + add_foreign_key :signed_up_teams, :project_topics + end + end + + def down + return if table_exists?(:sign_up_topics) + return unless table_exists?(:project_topics) + + remove_foreign_key :signed_up_teams, :project_topics if foreign_key_exists?(:signed_up_teams, :project_topics) + + if index_name_exists?(:project_topics, "index_project_topics_on_assignment_id") && + !index_name_exists?(:project_topics, "fk_sign_up_categories_sign_up_topics") + rename_index :project_topics, + "index_project_topics_on_assignment_id", + "fk_sign_up_categories_sign_up_topics" + end + + rename_table :project_topics, :sign_up_topics + + if column_exists?(:signed_up_teams, :project_topic_id) && + !foreign_key_exists?(:signed_up_teams, :sign_up_topics) + add_foreign_key :signed_up_teams, :sign_up_topics, column: :project_topic_id + end + end +end diff --git a/db/schema.rb b/db/schema.rb index cddbe12c6..fd74f89f5 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_03_13_064334) do +ActiveRecord::Schema[8.0].define(version: 2026_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" @@ -135,11 +135,6 @@ t.datetime "updated_at", null: false end - create_table "cakes", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - end - create_table "courses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name" t.string "directory_path" @@ -196,14 +191,14 @@ create_table "invitations", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.integer "assignment_id" + t.bigint "from_id" + t.integer "to_id" t.string "reply_status", limit: 1 t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.bigint "from_id", null: false - t.bigint "to_id", null: false t.index ["assignment_id"], name: "fk_invitation_assignments" - t.index ["from_id"], name: "index_invitations_on_from_id" - t.index ["to_id"], name: "index_invitations_on_to_id" + t.index ["from_id"], name: "fk_invitationfrom_users" + t.index ["to_id"], name: "fk_invitationto_users" end create_table "items", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -279,16 +274,17 @@ t.string "link" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.index ["assignment_id"], name: "fk_sign_up_categories_sign_up_topics" t.index ["assignment_id"], name: "index_project_topics_on_assignment_id" end create_table "question_advices", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.bigint "question_id", null: false + t.bigint "item_id", null: false t.integer "score" t.text "advice" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["question_id"], name: "index_question_advices_on_question_id" + t.index ["item_id"], name: "index_question_advices_on_item_id" end create_table "question_types", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -317,7 +313,7 @@ end create_table "quiz_question_choices", id: :integer, charset: "latin1", force: :cascade do |t| - t.integer "question_id" + t.integer "item_id" t.text "txt" t.boolean "iscorrect", default: false t.datetime "created_at", null: false @@ -330,7 +326,6 @@ t.integer "reviewee_id", default: 0, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.string "type" t.index ["reviewer_id"], name: "fk_response_map_reviewer" end @@ -389,15 +384,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| @@ -417,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 @@ -456,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/db/seeds.rb b/db/seeds.rb index d49c80f33..505f223fc 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| @@ -135,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 diff --git a/spec/fixtures/files/empty.csv b/spec/fixtures/files/empty.csv new file mode 100644 index 000000000..e69de29bb diff --git a/spec/fixtures/files/empty_with_headers.csv b/spec/fixtures/files/empty_with_headers.csv new file mode 100644 index 000000000..ed444a5d4 --- /dev/null +++ b/spec/fixtures/files/empty_with_headers.csv @@ -0,0 +1 @@ +Name,Email,Password,Full Name,Role ID \ No newline at end of file diff --git a/spec/fixtures/files/import_test.csv b/spec/fixtures/files/import_test.csv new file mode 100644 index 000000000..896171415 --- /dev/null +++ b/spec/fixtures/files/import_test.csv @@ -0,0 +1,2 @@ +id,name +1,Alice \ No newline at end of file diff --git a/spec/fixtures/files/multiple_users_no_headers.csv b/spec/fixtures/files/multiple_users_no_headers.csv new file mode 100644 index 000000000..5c4114233 --- /dev/null +++ b/spec/fixtures/files/multiple_users_no_headers.csv @@ -0,0 +1,2 @@ +John,jdoe@email.com,password,John Doe,Student +Jane,jndoe@email.com,password,Jane Doe,Teaching Assistant diff --git a/spec/fixtures/files/multiple_users_with_headers.csv b/spec/fixtures/files/multiple_users_with_headers.csv new file mode 100644 index 000000000..22e7a7172 --- /dev/null +++ b/spec/fixtures/files/multiple_users_with_headers.csv @@ -0,0 +1,3 @@ +Name,Email,Password,Full Name,Role ID +John,jdoe@email.com,password,John Doe,4 +Jane,jndoe@email.com,password,Jane Doe,5 diff --git a/spec/fixtures/files/questionnaire_item_with_headers.csv b/spec/fixtures/files/questionnaire_item_with_headers.csv new file mode 100644 index 000000000..6c4d21c72 --- /dev/null +++ b/spec/fixtures/files/questionnaire_item_with_headers.csv @@ -0,0 +1,2 @@ +txt,weight,seq,Question type,Break before,Questionnaire Name,Question Advice Advice,Question Advice Score,Question Advice Advice,Question Advice Score +test,10,2,dropdown,TRUE,Test Questionnaire,okay,1,good,2 diff --git a/spec/fixtures/files/single_user_email_invalid.csv b/spec/fixtures/files/single_user_email_invalid.csv new file mode 100644 index 000000000..3e63bb2ff --- /dev/null +++ b/spec/fixtures/files/single_user_email_invalid.csv @@ -0,0 +1,2 @@ +Name,Email,Password,Full Name,Role ID +John,wrong,password,John Doe,4 diff --git a/spec/fixtures/files/single_user_no_headers.csv b/spec/fixtures/files/single_user_no_headers.csv new file mode 100644 index 000000000..525ea96d4 --- /dev/null +++ b/spec/fixtures/files/single_user_no_headers.csv @@ -0,0 +1 @@ +John,jdoe@email.com,password,John Doe,Student diff --git a/spec/fixtures/files/single_user_role_doe_not_exist.csv b/spec/fixtures/files/single_user_role_doe_not_exist.csv new file mode 100644 index 000000000..2c1ace5b3 --- /dev/null +++ b/spec/fixtures/files/single_user_role_doe_not_exist.csv @@ -0,0 +1,2 @@ +Name,Email,Password,Full Name,Role ID +John,jdoe@email.com,password,John Doe,10 diff --git a/spec/fixtures/files/single_user_with_headers.csv b/spec/fixtures/files/single_user_with_headers.csv new file mode 100644 index 000000000..687abaaac --- /dev/null +++ b/spec/fixtures/files/single_user_with_headers.csv @@ -0,0 +1,2 @@ +Name,Email,Password,Full Name,Role ID +John,jdoe@email.com,password,John Doe,4 diff --git a/spec/fixtures/files/single_user_with_headers_changed.csv b/spec/fixtures/files/single_user_with_headers_changed.csv new file mode 100644 index 000000000..f9765a73a --- /dev/null +++ b/spec/fixtures/files/single_user_with_headers_changed.csv @@ -0,0 +1,2 @@ +Name,Email,Password,Full Name,Role ID +John,jdoe@email.com,password,John Mulch,4 diff --git a/spec/fixtures/files/users_duplicate_records.csv b/spec/fixtures/files/users_duplicate_records.csv new file mode 100644 index 000000000..374fe5079 --- /dev/null +++ b/spec/fixtures/files/users_duplicate_records.csv @@ -0,0 +1,6 @@ +Name,Email,Password,Full Name,Role ID +John,jdoe@email.com,password,John Doe,4 +John,jdoe@email.com,password,John Doe,4 +John,jdoe@email.com,password,John Doe,4 +Jane,jndoe@email.com,password,Jane Doe,5 +Jane,jndoe@email.com,password,Jane Doe,5 diff --git a/spec/helpers/export_helper_spec.rb b/spec/helpers/export_helper_spec.rb new file mode 100644 index 000000000..7758e85e8 --- /dev/null +++ b/spec/helpers/export_helper_spec.rb @@ -0,0 +1,173 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'csv' + +RSpec.describe ExportHelper, type: :helper do + describe 'graph export via Export.perform' do + it 'returns export payloads for each class in the graph with real db records' do + role = create(:role, :instructor) + institution = create(:institution) + instructor = Instructor.create!( + name: 'exportinstructor', + email: 'exportinstructor@example.com', + full_name: 'Export Instructor', + password: 'password', + role: role, + institution: institution + ) + + questionnaire_record = Questionnaire.create!( + name: 'Graph Export Questionnaire', + instructor: instructor, + private: false, + min_question_score: 0, + max_question_score: 10, + questionnaire_type: 'ReviewQuestionnaire', + display_type: 'Likert', + instruction_loc: 'instructions' + ) + + item_record = Item.create!( + questionnaire: questionnaire_record, + txt: 'How clear was the design?', + weight: 5, + seq: 1, + question_type: 'Scale', + break_before: true + ) + + advice_record = QuestionAdvice.create!( + item: item_record, + score: 4, + advice: 'Add concrete examples to improve clarity.' + ) + + assignment_record = create(:assignment, instructor: instructor) + reviewer_participant = create(:assignment_participant, assignment: assignment_record) + reviewee_participant = create(:assignment_participant, assignment: assignment_record) + + response_map_record = ResponseMap.create!( + reviewer_id: reviewer_participant.id, + reviewee_id: reviewee_participant.id, + reviewed_object_id: assignment_record.id + ) + + response_record = Response.create!( + map_id: response_map_record.id, + additional_comment: 'response comment' + ) + + answer_record = Answer.create!( + item: item_record, + response: response_record, + answer: 3, + comments: 'Strong rationale.' + ) + + questionnaire_external = Item.external_classes.find { |ext| ext.ref_class == Questionnaire } + allow(Item).to receive(:external_classes).and_return([questionnaire_external].compact) + + result = Export.perform(Questionnaire, nil) + exports_by_class = result.index_by { |entry| entry[:name] } + + expect(result).to all(include(:name, :contents)) + expect(exports_by_class.keys).to include('Questionnaire', 'Item', 'QuestionAdvice', 'Answer') + + questionnaire_rows = CSV.parse(exports_by_class['Questionnaire'][:contents], headers: true) + item_rows = CSV.parse(exports_by_class['Item'][:contents], headers: true) + advice_rows = CSV.parse(exports_by_class['QuestionAdvice'][:contents], headers: true) + answer_rows = CSV.parse(exports_by_class['Answer'][:contents], headers: true) + + expect(questionnaire_rows.map { |row| row['name'] }).to include(questionnaire_record.name) + expect(item_rows.map { |row| row['txt'] }).to include(item_record.txt) + expect(advice_rows.map { |row| row['advice'] }).to include(advice_record.advice) + expect(answer_rows.map { |row| row['comments'] }).to include(answer_record.comments) + expect(answer_rows.map { |row| row['answer'] }).to include(answer_record.answer.to_s) + expect(answer_rows.headers.length).to eq( + answer_rows.first&.fields&.length || answer_rows.headers.length + ) + end + + it 'exports csv and prints each class csv output' do + role = create(:role, :instructor) + institution = create(:institution) + instructor = Instructor.create!( + name: 'csvprintinstructor', + email: 'csvprintinstructor@example.com', + full_name: 'CSV Print Instructor', + password: 'password', + role: role, + institution: institution + ) + + questionnaire_record = Questionnaire.create!( + name: 'CSV Print Questionnaire', + instructor: instructor, + private: false, + min_question_score: 0, + max_question_score: 10, + questionnaire_type: 'ReviewQuestionnaire', + display_type: 'Likert', + instruction_loc: 'instructions' + ) + + item_record = Item.create!( + questionnaire: questionnaire_record, + txt: 'What should improve?', + weight: 2, + seq: 1, + question_type: 'Scale', + break_before: true + ) + + QuestionAdvice.create!( + item: item_record, + score: 2, + advice: 'Consider edge cases.' + ) + + assignment_record = create(:assignment, instructor: instructor) + reviewer_participant = create(:assignment_participant, assignment: assignment_record) + reviewee_participant = create(:assignment_participant, assignment: assignment_record) + response_map_record = ResponseMap.create!( + reviewer_id: reviewer_participant.id, + reviewee_id: reviewee_participant.id, + reviewed_object_id: assignment_record.id + ) + response_record = Response.create!( + map_id: response_map_record.id, + additional_comment: 'print response comment' + ) + Answer.create!( + item: item_record, + response: response_record, + answer: 1, + comments: 'Needs work.' + ) + + questionnaire_external = Item.external_classes.find { |ext| ext.ref_class == Questionnaire } + allow(Item).to receive(:external_classes).and_return([questionnaire_external].compact) + + result = Export.perform(Questionnaire, nil) + exports_by_class = result.index_by { |entry| entry[:name] } + + puts "\nCSV Exports:" + result.each do |export_entry| + puts "--- #{export_entry[:name]} ---" + puts export_entry[:contents] + end + + expect(exports_by_class.keys).to include('Questionnaire', 'Item', 'QuestionAdvice', 'Answer') + expect(exports_by_class['Questionnaire'][:contents]).to include('name') + expect(exports_by_class['Item'][:contents]).to include('txt') + expect(exports_by_class['QuestionAdvice'][:contents]).to include('advice') + expect(exports_by_class['Answer'][:contents]).to include('comments') + + answer_rows = CSV.parse(exports_by_class['Answer'][:contents], headers: true) + expect(answer_rows.headers.length).to eq( + answer_rows.first&.fields&.length || answer_rows.headers.length + ) + end + end +end diff --git a/spec/helpers/import_export_spec.rb b/spec/helpers/import_export_spec.rb new file mode 100644 index 000000000..1ae3bec77 --- /dev/null +++ b/spec/helpers/import_export_spec.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'csv' + +RSpec.describe ImportableExportableHelper, type: :helper do + include RolesHelper + + + before(:all) do + @roles = create_roles_hierarchy + + @institution = Institution.create!(name: 'NC State') + + + @instructor = Instructor.create!( + name: 'instructor', + full_name: 'Instructor User', + email: 'instructor@example.com', + password_digest: 'password', + role: @roles[:instructor], + institution: @institution + ) + + @questionnaire = Questionnaire.create!( + name: 'Test Questionnaire', + questionnaire_type: '', + private: true, + min_question_score: 1, + max_question_score: 10, + instructor: @instructor + ) + end + + + describe 'Create tests for each of the different importable classes' do + it 'Import a class with no headers' do + expect(User.count).to eq(1) + + csv_file = file_fixture('single_user_no_headers.csv') + headers = ['Name', 'Email', 'Password', 'Full Name', 'Role Name'] + + pp User.try_import_records(csv_file, headers, false) + + expect(User.count).to eq(2) + expect(User.find_by(email: 'jdoe@email.com')).to be_present + end + + it 'Import a class with headers' do + expect(User.count).to eq(1) + + csv_file = file_fixture('single_user_with_headers.csv') + + User.try_import_records(csv_file, nil, use_header: true) + + expect(User.count).to eq(2) + expect(User.find_by(email: 'jdoe@email.com')).to be_present + end + + it 'Import a file with multiple records' do + expect(User.count).to eq(1) + + csv_file = file_fixture('multiple_users_with_headers.csv') + + User.try_import_records(csv_file, nil, use_header: true) + + expect(User.count).to eq(3) + expect(User.find_by(email: 'jdoe@email.com')).to be_present + expect(User.find_by(email: 'jndoe@email.com')).to be_present + end + + it 'Import a class with external lookup and create classes, and can take duplicate headers' do + expect(Questionnaire.count).to eq(1) + expect(Questionnaire.find_by(name: 'Test Questionnaire')).to be_present + expect(QuizItem.count).to eq(0) + expect(QuestionAdvice.count).to eq(0) + + csv_file = file_fixture('questionnaire_item_with_headers.csv') + QuizItem.try_import_records(csv_file, nil, true) + + expect(QuizItem.count).to eq(1) + expect(QuizItem.find_by(txt: 'test')).to be_present + + expect(QuestionAdvice.count).to eq(2) + + advice_one = QuestionAdvice.find_by(advice: 'okay') + expect(advice_one).to be_present + expect(advice_one.score).to eq(1) + expect(advice_one.item.txt).to eq('test') + + advice_two = QuestionAdvice.find_by(advice: 'good') + expect(advice_two).to be_present + expect(advice_two.score).to eq(2) + expect(advice_two.item.txt).to eq('test') + end + end + + + # * Create a test with external lookup class that doesn't exist + # * Create a test with an empty CSV (With Headers) + # * Create a test with an empty CSV (Without Headers) + describe 'Create Tests to test Errors/Edge Cases' do + it 'Import a class with an invalid field (User with invalid email)' do + csv_file = file_fixture('single_user_email_invalid.csv') + + expect {User.try_import_records(csv_file, nil, true)}.not_to change(User, :count) + end + + it 'Import a class with external lookup class that does not exist' do + expect(User.count).to eq(1) + + csv_file = file_fixture('single_user_role_doe_not_exist.csv') + + expect { User.try_import_records(csv_file, nil, true) }.not_to change(User, :count) + + expect(User.count).to eq(1) + expect(User.find_by(email: 'jdoe@email.com')).not_to be_present + end + + it 'Import an empty CSV (With Headers)' do + csv_file = file_fixture('empty_with_headers.csv') + + expect{User.try_import_records(csv_file, nil, true)}.not_to change(User, :count) + end + + it 'Import an empty CSV (Without Headers)' do + csv_file = file_fixture('empty.csv') + + expect{User.try_import_records(csv_file, [], false)}.not_to change(User, :count) + end + end +end diff --git a/spec/integration/export_controller_spec.rb b/spec/integration/export_controller_spec.rb new file mode 100644 index 000000000..a8675d080 --- /dev/null +++ b/spec/integration/export_controller_spec.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true +require "rails_helper" + +RSpec.describe "Export API", type: :request do + # + # Authentication + authorization bypass + # + before do + allow_any_instance_of(JwtToken) + .to receive(:authenticate_request!) + .and_return(true) + + allow_any_instance_of(Authorization) + .to receive(:authorize) + .and_return(true) + end + + # + # Fake model used for constantize + # + class FakeModel + def self.mandatory_fields; ["id", "name"]; end + def self.optional_fields; ["email"]; end + def self.external_fields; ["institution"]; end + end + + describe "GET /export/:class" do + it "returns mandatory, optional, and external fields with status 200" do + get "/export/FakeModel" + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + + expect(json["mandatory_fields"]).to eq(["id", "name"]) + expect(json["optional_fields"]).to eq(["email"]) + expect(json["external_fields"]).to eq(["institution"]) + end + end + + describe "POST /export/:class" do + it "returns 200 and calls Export.perform with ordered fields" do + ordered_fields = ["id", "name"] + export_return = [{ name: "FakeModel", contents: "fake_csv_data" }] + + expect(Export).to receive(:perform) + .with(FakeModel, ordered_fields) + .and_return(export_return) + + post "/export/FakeModel", params: { + ordered_fields: ordered_fields.to_json + } + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + + expect(json["message"]).to eq("FakeModel has been exported!") + expect(json["file"]).to eq([{ "name" => "FakeModel", "contents" => "fake_csv_data" }]) + end + + it "passes nil ordered_fields when none are provided" do + export_return = [{ name: "FakeModel", contents: "csv_without_ordering" }] + + expect(Export).to receive(:perform) + .with(FakeModel, nil, 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([{ "name" => "FakeModel", "contents" => "csv_without_ordering" }]) + end + + it "passes graph_export when requested" do + export_return = [{ name: "FakeModel", contents: "graph_csv_data" }] + + expect(Export).to receive(:perform) + .with(FakeModel, ["id"], graph_export: true) + .and_return(export_return) + + post "/export/FakeModel", params: { + ordered_fields: ["id"].to_json, + graph_export: "true" + } + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json["file"]).to eq([{ "name" => "FakeModel", "contents" => "graph_csv_data" }]) + end + + it "returns 422 if constantize fails" do + post "/export/DoesNotExist" + + expect(response.status).to eq(422) + end + + it "returns 422 if Export.perform raises an error" do + allow(Export).to receive(:perform) + .and_raise(StandardError.new("Boom!")) + + post "/export/FakeModel", params: { ordered_fields: ["id"].to_json } + + expect(response.status).to eq(422) + json = JSON.parse(response.body) + expect(json["error"]).to eq("Boom!") + end + + it "returns 422 if ordered_fields is invalid JSON" do + post "/export/FakeModel", params: { + ordered_fields: "not-json" + } + + expect(response.status).to eq(422) + end + end +end diff --git a/spec/integration/import_controller_spec.rb b/spec/integration/import_controller_spec.rb new file mode 100644 index 000000000..151e18323 --- /dev/null +++ b/spec/integration/import_controller_spec.rb @@ -0,0 +1,151 @@ +require "rails_helper" + +RSpec.describe "Import API", type: :request do + # + # Disable BOTH authentication layers: + # • JwtToken.authenticate_request! + # • Authorization.authorize + # + before do + allow_any_instance_of(JwtToken) + .to receive(:authenticate_request!) + .and_return(true) + + allow_any_instance_of(Authorization) + .to receive(:authorize) + .and_return(true) + end + + # + # Stub a fake model class for import operations + # + before do + stub_const("FakeModel", Class.new do + class << self + attr_accessor :mandatory_fields, :optional_fields, :external_fields, :available_actions_on_duplicate + end + + def self.try_import_records(*args); end + end) + + allow(FakeModel).to receive(:mandatory_fields).and_return(["id", "name"]) + allow(FakeModel).to receive(:optional_fields).and_return(["email"]) + allow(FakeModel).to receive(:external_fields).and_return(["mentor_id"]) + allow(FakeModel).to receive(:available_actions_on_duplicate).and_return([]) + allow(FakeModel).to receive(:try_import_records).and_return([]) + end + + # + # Fixture file used for import + # + let(:file_path) { Rails.root.join("spec/fixtures/files/import_test.csv") } + let(:uploaded_file) { Rack::Test::UploadedFile.new(file_path, "text/csv") } + + # ------------------------------------------------------------ + # BASIC TESTS + # ------------------------------------------------------------ + + describe "GET /import/:class" do + it "returns metadata with status 200" do + get "/import/FakeModel" + + expect(response.status).to eq(200) + + json = JSON.parse(response.body) + expect(json["mandatory_fields"]).to eq(["id", "name"]) + expect(json["optional_fields"]).to eq(["email"]) + expect(json["external_fields"]).to eq(["mentor_id"]) + expect(json["available_actions_on_dup"]).to eq([]) + end + end + + describe "POST /import/:class" do + it "returns 201 when import succeeds" do + post "/import/FakeModel", + params: { + csv_file: uploaded_file, + use_headers: true, + ordered_fields: ["id", "name"].to_json + } + + expect(response.status).to eq(201) + expect(JSON.parse(response.body)["message"]) + .to eq("FakeModel has been imported!") + end + + it "returns 422 when import raises an error" do + allow(FakeModel).to receive(:try_import_records) + .and_raise(StandardError.new("BOOM")) + + post "/import/FakeModel", + params: { + csv_file: uploaded_file, + use_headers: true + } + + expect(response.status).to eq(422) + expect(JSON.parse(response.body)["error"]).to eq("BOOM") + end + end + + # ------------------------------------------------------------ + # ADDITIONAL EDGE CASE TESTS + # ------------------------------------------------------------ + + describe "Additional ImportController tests" do + it "returns 500 if class constantization fails" do + get "/import/ThisModelDoesNotExist" + + expect(response.status).to eq(500) + expect(response.body).to include("uninitialized constant") + end + + it "returns 201 even if csv_file is missing (controller allows nil file)" do + post "/import/FakeModel", params: { use_headers: true } + + expect(response.status).to eq(201) + expect(JSON.parse(response.body)["message"]) + .to eq("FakeModel has been imported!") + end + + it "allows POST without ordered_fields" do + post "/import/FakeModel", + params: { + csv_file: uploaded_file, + use_headers: "false" + } + + expect(response.status).to eq(201) + end + + it "correctly passes use_headers as boolean" do + post "/import/FakeModel", + params: { + csv_file: uploaded_file, + use_headers: "false", + ordered_fields: ["id"].to_json + } + + expect(FakeModel) + .to have_received(:try_import_records) + .with( + kind_of(ActionDispatch::Http::UploadedFile), + ["id"], + false, + {} + ) + end + + it "returns 422 for malformed ordered_fields JSON" do + post "/import/FakeModel", + params: { + csv_file: uploaded_file, + use_headers: true, + ordered_fields: "{ this is invalid json" + } + + expect(response.status).to eq(422) + expect(JSON.parse(response.body)["error"]).to be_present + end + end +end diff --git a/spec/models/grades_export_spec.rb b/spec/models/grades_export_spec.rb new file mode 100644 index 000000000..4e271f7b6 --- /dev/null +++ b/spec/models/grades_export_spec.rb @@ -0,0 +1,235 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'csv' + +RSpec.describe Pseudo::Grades, type: :model do + describe 'grade export' do + def create_scored_response(map:, item:, score:, comments:, round: 1) + response = Response.create!( + map_id: map.id, + additional_comment: comments, + is_submitted: true, + round: round + ) + + Answer.create!( + response: response, + item: item, + answer: score, + comments: comments + ) + + response + end + + it 'exports one csv row per participant with the expected grades columns' do + instructor = Instructor.create!( + name: 'gradesinstructor', + email: 'gradesinstructor@example.com', + full_name: 'Grades Instructor', + password: 'password', + role: create(:role, :instructor), + institution: create(:institution) + ) + assignment = create(:assignment, instructor: instructor, name: 'Grades Export Assignment') + non_clashing_assignment_id = [ + Assignment.maximum(:id), + ResponseMap.maximum(:id), + Response.maximum(:id) + ].compact.max.to_i + 10_000 + assignment.update_column(:id, non_clashing_assignment_id) + assignment.reload + + questionnaire = Questionnaire.create!( + name: 'Grades Export Questionnaire', + instructor: instructor, + private: false, + min_question_score: 0, + max_question_score: 10, + questionnaire_type: 'ReviewQuestionnaire', + display_type: 'Likert', + instruction_loc: 'instructions' + ) + + item = Item.create!( + questionnaire: questionnaire, + txt: 'How strong was the work?', + weight: 1, + seq: 1, + question_type: 'Scale', + break_before: true + ) + + AssignmentQuestionnaire.create!( + assignment: assignment, + questionnaire: questionnaire, + used_in_round: 1, + notification_limit: 5, + questionnaire_weight: 100 + ) + + alice = create(:user, :student, name: 'alice_export', email: 'alice_export@example.com', full_name: 'Alice Export') + aaron = create(:user, :student, name: 'aaron_export', email: 'aaron_export@example.com', full_name: 'Aaron Export') + bella = create(:user, :student, name: 'bella_export', email: 'bella_export@example.com', full_name: 'Bella Export') + ben = create(:user, :student, name: 'ben_export', email: 'ben_export@example.com', full_name: 'Ben Export') + + participant_a1 = create(:assignment_participant, assignment: assignment, user: alice, handle: alice.name) + participant_a2 = create(:assignment_participant, assignment: assignment, user: aaron, handle: aaron.name) + participant_b1 = create(:assignment_participant, assignment: assignment, user: bella, handle: bella.name) + participant_b2 = create(:assignment_participant, assignment: assignment, user: ben, handle: ben.name) + + team_one = AssignmentTeam.create!( + name: 'Team One', + parent_id: assignment.id, + type: 'AssignmentTeam', + grade_for_submission: 88 + ) + team_two = AssignmentTeam.create!( + name: 'Team Two', + parent_id: assignment.id, + type: 'AssignmentTeam', + grade_for_submission: 93 + ) + + expect(team_one.add_member(participant_a1)[:success]).to be(true) + expect(team_one.add_member(participant_a2)[:success]).to be(true) + expect(team_two.add_member(participant_b1)[:success]).to be(true) + expect(team_two.add_member(participant_b2)[:success]).to be(true) + + 3.times do |index| + ReviewResponseMap.create!( + reviewed_object_id: assignment.id, + reviewer_id: participant_a1.id, + reviewee_id: team_two.id + ) + Response.create!( + map_id: ReviewResponseMap.last.id, + additional_comment: "throwaway review #{index}", + is_submitted: true, + round: 1 + ) + end + + poor_review_map = ReviewResponseMap.create!( + reviewed_object_id: assignment.id, + reviewer_id: participant_a1.id, + reviewee_id: team_two.id + ) + good_review_map = ReviewResponseMap.create!( + reviewed_object_id: assignment.id, + reviewer_id: participant_b1.id, + reviewee_id: team_one.id + ) + + poor_review_response = create_scored_response( + map: poor_review_map, + item: item, + score: 2, + comments: 'Poor review from Team One to Team Two' + ) + good_review_response = create_scored_response( + map: good_review_map, + item: item, + score: 9, + comments: 'Strong review from Team Two to Team One' + ) + + expect(assignment.id).not_to eq(poor_review_map.id) + expect(assignment.id).not_to eq(good_review_map.id) + expect(assignment.id).not_to eq(poor_review_response.id) + expect(assignment.id).not_to eq(good_review_response.id) + + teammate_map_a1 = TeammateReviewResponseMap.create!( + reviewed_object_id: assignment.id, + reviewer_id: participant_a1.id, + reviewee_id: participant_a2.id + ) + teammate_map_a2 = TeammateReviewResponseMap.create!( + reviewed_object_id: assignment.id, + reviewer_id: participant_a2.id, + reviewee_id: participant_a1.id + ) + teammate_map_b1 = TeammateReviewResponseMap.create!( + reviewed_object_id: assignment.id, + reviewer_id: participant_b1.id, + reviewee_id: participant_b2.id + ) + teammate_map_b2 = TeammateReviewResponseMap.create!( + reviewed_object_id: assignment.id, + reviewer_id: participant_b2.id, + reviewee_id: participant_b1.id + ) + + create_scored_response(map: teammate_map_a1, item: item, score: 7, comments: 'A1 reviewing A2') + create_scored_response(map: teammate_map_a2, item: item, score: 8, comments: 'A2 reviewing A1') + create_scored_response(map: teammate_map_b1, item: item, score: 6, comments: 'B1 reviewing B2') + create_scored_response(map: teammate_map_b2, item: item, score: 9, comments: 'B2 reviewing B1') + + feedback_map_for_a1 = FeedbackResponseMap.create!( + reviewed_object_id: poor_review_map.id, + reviewer_id: participant_b1.id, + reviewee_id: participant_a1.id + ) + feedback_map_for_b1 = FeedbackResponseMap.create!( + reviewed_object_id: good_review_map.id, + reviewer_id: participant_a1.id, + reviewee_id: participant_b1.id + ) + + create_scored_response(map: feedback_map_for_a1, item: item, score: 3, comments: 'Feedback for Team One reviewer') + create_scored_response(map: feedback_map_for_b1, item: item, score: 8, comments: 'Feedback for Team Two reviewer') + + export_payload = Export.perform(Pseudo::Grades) + csv_text = export_payload.first[:contents] + + puts "\nGrades export CSV:" + puts csv_text + + rows = CSV.parse(csv_text, headers: true) + + expect(rows.headers).to eq(Pseudo::Grades::COLUMN_NAMES) + expect(rows.size).to eq(4) + + row_by_participant = rows.index_by { |row| row['participant_name'] } + + expect(row_by_participant.keys).to contain_exactly( + participant_a1.user_name, + participant_a2.user_name, + participant_b1.user_name, + participant_b2.user_name + ) + + expect(row_by_participant[participant_a1.user_name]['assignment_id']).to eq(assignment.id.to_s) + expect(row_by_participant[participant_a1.user_name]['assignment_name']).to eq(assignment.name) + expect(row_by_participant[participant_a1.user_name]['team_name']).to eq(team_one.name) + expect(row_by_participant[participant_a1.user_name]['participant_email']).to eq(alice.email) + + expect(row_by_participant[participant_a2.user_name]['assignment_id']).to eq(assignment.id.to_s) + expect(row_by_participant[participant_a2.user_name]['team_name']).to eq(team_one.name) + expect(row_by_participant[participant_a2.user_name]['participant_email']).to eq(aaron.email) + + expect(row_by_participant[participant_b1.user_name]['assignment_id']).to eq(assignment.id.to_s) + expect(row_by_participant[participant_b1.user_name]['team_name']).to eq(team_two.name) + expect(row_by_participant[participant_b1.user_name]['participant_email']).to eq(bella.email) + + expect(row_by_participant[participant_b2.user_name]['assignment_id']).to eq(assignment.id.to_s) + expect(row_by_participant[participant_b2.user_name]['team_name']).to eq(team_two.name) + expect(row_by_participant[participant_b2.user_name]['participant_email']).to eq(ben.email) + + rows.each do |row| + expect(row['team_id']).to be_present + expect(row['participant_id']).to be_present + expect(row['submission_grade']).to be_present + expect(row['review_grade']).to be_present + end + + expect(row_by_participant[participant_a1.user_name]['author_feedback_grade']).to be_present + expect(row_by_participant[participant_b1.user_name]['author_feedback_grade']).to be_present + expect(row_by_participant[participant_a1.user_name]['teammate_review_grade']).to be_present + expect(row_by_participant[participant_a2.user_name]['teammate_review_grade']).to be_present + expect(row_by_participant[participant_b1.user_name]['teammate_review_grade']).to be_present + expect(row_by_participant[participant_b2.user_name]['teammate_review_grade']).to be_present + end + end +end diff --git a/spec/models/team_import_export_spec.rb b/spec/models/team_import_export_spec.rb new file mode 100644 index 000000000..fa5f142c3 --- /dev/null +++ b/spec/models/team_import_export_spec.rb @@ -0,0 +1,58 @@ +# 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) + + export_payload = Team.with_assignment_context(assignment.id) do + Export.perform(Team, %w[name participant_1 participant_2 participant_3]) + end + csv_text = export_payload.first[:contents] + + rows = CSV.parse(csv_text, headers: true) + exported_row = rows.find { |row| row['name'] == 'Export Team' } + + expect(exported_row).not_to be_nil + expect(exported_row['participant_1']).to eq(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/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 diff --git a/spec/requests/import_export_requests_spec.rb b/spec/requests/import_export_requests_spec.rb new file mode 100644 index 000000000..3bb07dea5 --- /dev/null +++ b/spec/requests/import_export_requests_spec.rb @@ -0,0 +1,268 @@ +# 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", params: { assignment_id: 1 } + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + expect(json["mandatory_fields"]).to eq(["name"]) + expect(json["optional_fields"]).to include("participant_1") + expect(json["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 + 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", + assignment_id: assignment.id + } + + expect(response).to have_http_status(:created) + imported_team = AssignmentTeam.find_by(name: "Team Alpha", parent_id: assignment.id) + expect(imported_team).to be_present + expect(imported_team.participants).to include(participant) + end + end + + context "topic imports" do + it "imports topics" do + file = uploaded_csv("topic_name,assignment_id\nTopic A,#{assignment.id}\n") + + post "/import/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 + 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,participant_1") + expect(json["file"]).to include("Export Team,#{participant.id}") + 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