From 639f7daa459478819a7f1f9088e757db0c390f69 Mon Sep 17 00:00:00 2001 From: Veeraraghavan Narasimhan Date: Wed, 25 Mar 2026 17:27:57 -0700 Subject: [PATCH 1/8] Added eager fetch for assignments to courses index --- Dockerfile | 2 +- app/controllers/courses_controller.rb | 4 ++-- config/application.rb | 2 +- db/schema.rb | 12 +++--------- docker-compose.yml | 2 +- 5 files changed, 8 insertions(+), 14 deletions(-) diff --git a/Dockerfile b/Dockerfile index 687c70771..a937ef541 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,7 +15,7 @@ WORKDIR /app COPY . . # Install Ruby dependencies -RUN gem update --system && gem install bundler:2.4.7 +RUN gem update --system && gem install bundler: RUN bundle install EXPOSE 3002 diff --git a/app/controllers/courses_controller.rb b/app/controllers/courses_controller.rb index d414d5d25..f9111401a 100644 --- a/app/controllers/courses_controller.rb +++ b/app/controllers/courses_controller.rb @@ -10,8 +10,8 @@ def action_allowed? # GET /courses # List all the courses def index - courses = Course.all - render json: courses, status: :ok + courses = Course.includes(:assignments).all + render json: courses.as_json(include: :assignments), status: :ok end # GET /courses/1 diff --git a/config/application.rb b/config/application.rb index 798f8702b..f78d01563 100644 --- a/config/application.rb +++ b/config/application.rb @@ -17,7 +17,7 @@ def self.preview_path=(_) module Reimplementation class Application < Rails::Application # Initialize configuration defaults for originally generated Rails version. - config.load_defaults 7.0 + config.load_defaults 8.0 config.active_record.schema_format = :ruby # Configuration for the application, engines, and railties goes here. diff --git a/db/schema.rb b/db/schema.rb index cddbe12c6..0c1a880eb 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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" @@ -229,7 +224,7 @@ t.integer "participant_id" t.integer "team_id" t.text "comments" - t.string "reply_status" + t.string "status" end create_table "nodes", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -279,6 +274,7 @@ t.string "link" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.index ["assignment_id"], name: "fk_sign_up_categories_sign_up_topics" t.index ["assignment_id"], name: "index_project_topics_on_assignment_id" end @@ -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 @@ -417,6 +412,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,8 +452,6 @@ add_foreign_key "assignments_duties", "duties" add_foreign_key "courses", "institutions" add_foreign_key "courses", "users", column: "instructor_id" - add_foreign_key "invitations", "participants", column: "from_id" - add_foreign_key "invitations", "participants", column: "to_id" add_foreign_key "duties", "users", column: "instructor_id" add_foreign_key "items", "questionnaires" add_foreign_key "participants", "duties" diff --git a/docker-compose.yml b/docker-compose.yml index f22dc27ef..2413a65fd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ services: command: tail -f /dev/null environment: RAILS_ENV: development - DATABASE_URL: mysql2://root:expertiza@db:3306/reimplementation? + DATABASE_URL: mysql2://root:expertiza@db:3306/reimplementation CACHE_STORE: redis://redis:6380/0 ports: - "3002:3002" From cfe8d8803087d94ffdc60892d8a19785cbdec181 Mon Sep 17 00:00:00 2001 From: s-poorna Date: Fri, 27 Mar 2026 21:22:36 -0400 Subject: [PATCH 2/8] feat(teams): add hierarchy-aware team filtering and parent_id serialization --- app/controllers/teams_controller.rb | 22 +++++++++++++++++++--- app/serializers/team_serializer.rb | 2 +- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/app/controllers/teams_controller.rb b/app/controllers/teams_controller.rb index a24cd23f2..4b21da6ee 100644 --- a/app/controllers/teams_controller.rb +++ b/app/controllers/teams_controller.rb @@ -6,9 +6,21 @@ class TeamsController < ApplicationController before_action :validate_team_type, only: [:create] # GET /teams - # Fetches all teams and renders them using TeamSerializer + # Fetches teams for a hierarchy context (assignment/course) and renders them using TeamSerializer def index - @teams = Team.all + @teams = Team.includes(:users, { teams_participants: :participant }) + + if params[:parent_id].present? + @teams = @teams.where(parent_id: params[:parent_id]) + end + + if params[:types].present? + requested_types = params[:types].is_a?(Array) ? params[:types] : params[:types].to_s.split(',') + normalized_types = requested_types.map(&:strip).reject(&:blank?) + @teams = @teams.where(type: normalized_types) if normalized_types.any? + end + + @teams = @teams.order(:name, :id) render json: @teams, each_serializer: TeamSerializer end @@ -94,7 +106,11 @@ def set_team # Whitelists the parameters allowed for team creation/updation def team_params - params.require(:team).permit(:name, :type, :assignment_id) + permitted = params.require(:team).permit(:name, :type, :parent_id, :assignment_id) + + # Backward compatibility for clients still sending assignment_id. + permitted[:parent_id] = permitted[:assignment_id] if permitted[:parent_id].blank? && permitted[:assignment_id].present? + permitted.except(:assignment_id) end # Whitelists parameters required to add a team member diff --git a/app/serializers/team_serializer.rb b/app/serializers/team_serializer.rb index cbc797761..0c7913261 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, :parent_id, :team_size has_many :members, serializer: ParticipantSerializer has_many :users, serializer: UserSerializer From fcbbd4b92e89b030df9b478c0bceed7778d88179 Mon Sep 17 00:00:00 2001 From: kmthoms2_ncstate Date: Mon, 30 Mar 2026 21:23:14 -0400 Subject: [PATCH 3/8] [Imp] Questionnaire Type & Questionnaire Hierarchy --- .gitignore | 5 + .ruby-version | 2 +- app/controllers/questionnaires_controller.rb | 191 ++++++++++++++++++- app/controllers/questions_controller.rb | 6 +- app/models/Item.rb | 4 +- app/models/question_type.rb | 2 + app/models/questionnaire.rb | 8 - config/routes.rb | 16 +- 8 files changed, 206 insertions(+), 28 deletions(-) create mode 100644 app/models/question_type.rb diff --git a/.gitignore b/.gitignore index b5973ad4c..6b95f5388 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,8 @@ coverage/ rsa_keys.yml pg_data/ + +# Ignore ruby version +.ruby-version +Gemfile +Gemfile.lock \ No newline at end of file diff --git a/.ruby-version b/.ruby-version index 54978911c..4f5e69734 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -ruby-3.4.5 +3.4.5 diff --git a/app/controllers/questionnaires_controller.rb b/app/controllers/questionnaires_controller.rb index 278f70c07..94a10c82a 100644 --- a/app/controllers/questionnaires_controller.rb +++ b/app/controllers/questionnaires_controller.rb @@ -1,4 +1,35 @@ class QuestionnairesController < ApplicationController + DISPLAY_TYPES = [ + 'Review', + 'Metareview', + 'Author feedback', + 'Teammate Review', + 'Survey', + 'Assignment survey', + 'Global survey', + 'Course survey', + 'Bookmark rating', + 'Quiz' + ].freeze + + TYPE_DISPLAY_MAP = { + 'ReviewQuestionnaire' => 'Review', + 'MetareviewQuestionnaire' => 'Metareview', + 'Author FeedbackQuestionnaire' => 'Author feedback', + 'AuthorFeedbackQuestionnaire' => 'Author feedback', + 'Teammate ReviewQuestionnaire' => 'Teammate Review', + 'TeammateReviewQuestionnaire' => 'Teammate Review', + 'SurveyQuestionnaire' => 'Survey', + 'AssignmentSurveyQuestionnaire' => 'Assignment survey', + 'Assignment SurveyQuestionnaire' => 'Assignment survey', + 'Global SurveyQuestionnaire' => 'Global survey', + 'GlobalSurveyQuestionnaire' => 'Global survey', + 'Course SurveyQuestionnaire' => 'Course survey', + 'CourseSurveyQuestionnaire' => 'Course survey', + 'Bookmark RatingQuestionnaire' => 'Bookmark rating', + 'BookmarkRatingQuestionnaire' => 'Bookmark rating', + 'QuizQuestionnaire' => 'Quiz' + }.freeze # Index method returns the list of JSON objects of the questionnaire # GET on /questionnaires @@ -6,6 +37,28 @@ def index @questionnaires = Questionnaire.order(:id) render json: @questionnaires, status: :ok and return end + + # Hierarchical list of questionnaire types and questionnaires available to the current user. + # GET on /questionnaires/hierarchical + def hierarchical + questionnaires = Questionnaire + .includes(:instructor) + .where(private: false) + .or(Questionnaire.where(instructor_id: current_user.id)) + .order(:name) + .distinct + + grouped_questionnaires = questionnaires.group_by do |questionnaire| + display_type_for(questionnaire.questionnaire_type) + end + + render json: DISPLAY_TYPES.map { |display_type| + { + type: display_type, + questionnaires: (grouped_questionnaires[display_type] || []).map(&:as_json) + } + }, status: :ok and return + end # Show method returns the JSON object of questionnaire with id = {:id} # GET on /questionnaires/:id @@ -17,18 +70,30 @@ def show render json: $ERROR_INFO.to_s, status: :not_found and return end end + + # GET /questionnaires/:id/items + def items + questionnaire = Questionnaire.find(params[:id]) + render json: questionnaire.items.order(:seq), status: :ok and return + rescue ActiveRecord::RecordNotFound + render json: { error: "Questionnaire not found" }, status: :not_found and return + end # Create method creates a questionnaire and returns the JSON object of the created questionnaire # POST on /questionnaires # Instructor Id statically defined since implementation of Instructor model is out of scope of E2345. def create begin - @questionnaire = Questionnaire.new(questionnaire_params) + questionnaire_attributes, item_attributes = split_questionnaire_params + @questionnaire = Questionnaire.new(questionnaire_attributes) @questionnaire.display_type = sanitize_display_type(@questionnaire.questionnaire_type) - @questionnaire.save! + Questionnaire.transaction do + @questionnaire.save! + sync_items!(@questionnaire, item_attributes) + end render json: @questionnaire, status: :created and return rescue ActiveRecord::RecordInvalid - render json: $ERROR_INFO.to_s, status: :unprocessable_entity + render json: { errors: $ERROR_INFO.record.errors.full_messages }, status: :unprocessable_entity end end @@ -37,9 +102,12 @@ def create def destroy begin @questionnaire = Questionnaire.find(params[:id]) - @questionnaire.delete + @questionnaire.destroy! + render json: { message: 'Questionnaire deleted successfully' }, status: :ok and return rescue ActiveRecord::RecordNotFound render json: $ERROR_INFO.to_s, status: :not_found and return + rescue ActiveRecord::RecordNotDestroyed, ActiveRecord::InvalidForeignKey + render json: { error: $ERROR_INFO.message }, status: :unprocessable_entity and return end end @@ -48,11 +116,16 @@ def destroy def update @questionnaire = Questionnaire.find(params[:id]) - if @questionnaire.update(questionnaire_params) - render json: @questionnaire, status: :ok - else - render json: @questionnaire.errors.full_messages, status: :unprocessable_entity + questionnaire_attributes, item_attributes = split_questionnaire_params + + Questionnaire.transaction do + @questionnaire.update!(questionnaire_attributes) + sync_items!(@questionnaire, item_attributes) end + + render json: @questionnaire, status: :ok + rescue ActiveRecord::RecordInvalid + render json: { errors: $ERROR_INFO.record.errors.full_messages }, status: :unprocessable_entity end # Copy method creates a copy of questionnaire with id - {:id} and return its JSON object # POST on /questionnaires/copy/:id @@ -87,7 +160,31 @@ def toggle_access private def questionnaire_params - params.require(:questionnaire).permit(:name, :questionnaire_type, :private, :min_question_score, :max_question_score, :instructor_id) + params.require(:questionnaire).permit( + :name, + :questionnaire_type, + :private, + :min_question_score, + :max_question_score, + :instructor_id, + items_attributes: [ + :id, + :txt, + :question_type, + :weight, + :alternatives, + :min_label, + :max_label, + :seq, + :break_before, + :textarea_width, + :textarea_height, + :textbox_width, + :row_names, + :col_names, + :_destroy + ] + ) end def sanitize_display_type(type) @@ -98,4 +195,78 @@ def sanitize_display_type(type) display_type end -end \ No newline at end of file + def display_type_for(questionnaire_type) + TYPE_DISPLAY_MAP.fetch(questionnaire_type, questionnaire_type.to_s.delete_suffix('Questionnaire')) + end + + def split_questionnaire_params + permitted_params = questionnaire_params.to_h.deep_symbolize_keys + items_attributes = permitted_params.delete(:items_attributes) || [] + [permitted_params, items_attributes] + end + + def sync_items!(questionnaire, item_attributes) + item_attributes.each_with_index do |item_data, index| + destroy_item = ActiveModel::Type::Boolean.new.cast(item_data[:_destroy]) + + if destroy_item && item_data[:id].present? + questionnaire.items.find(item_data[:id]).destroy! + next + end + + next if destroy_item + + if item_data[:id].present? + existing_item = questionnaire.items.find(item_data[:id]) + attributes = build_item_attributes(item_data, index, existing_item) + existing_item.update!(attributes) + else + attributes = build_item_attributes(item_data, index) + questionnaire.items.create!(attributes) + end + end + end + + def build_item_attributes(item_data, index, existing_item = nil) + question_type = canonical_question_type(item_data[:question_type]) + { + txt: item_data[:txt].presence || existing_item&.txt, + question_type: question_type, + weight: item_data[:weight].presence || existing_item&.weight, + seq: item_data[:seq].presence || index + 1, + alternatives: normalize_alternatives(item_data[:alternatives]) || existing_item&.alternatives, + min_label: item_data[:min_label].presence || existing_item&.min_label, + max_label: item_data[:max_label].presence || existing_item&.max_label, + break_before: item_data.key?(:break_before) ? ActiveModel::Type::Boolean.new.cast(item_data[:break_before]) : true, + size: build_item_size(question_type, item_data, existing_item) + }.compact + end + + def canonical_question_type(question_type) + { + 'Text area' => 'TextArea', + 'Text field' => 'TextField', + 'Multiple choice' => 'MultipleChoiceRadio' + }.fetch(question_type, question_type) + end + + def normalize_alternatives(alternatives) + return nil if alternatives.blank? + + alternatives.to_s.split(',').map(&:strip).reject(&:empty?).join('|') + end + + def build_item_size(question_type, item_data, existing_item = nil) + case question_type + when 'Criterion', 'TextArea' + width = item_data[:textarea_width].presence + height = item_data[:textarea_height].presence + return "#{width},#{height}" if width && height + when 'TextField' + return item_data[:textbox_width].to_s if item_data[:textbox_width].present? + end + + existing_item&.size + end + +end diff --git a/app/controllers/questions_controller.rb b/app/controllers/questions_controller.rb index f49157861..eac527f63 100644 --- a/app/controllers/questions_controller.rb +++ b/app/controllers/questions_controller.rb @@ -103,7 +103,11 @@ def delete_all end def types - types = Item.pluck(:question_type).uniq + types = if QuestionType.exists? + QuestionType.order(:name).pluck(:name) + else + %w[Criterion Scale Dropdown TextArea TextField MultipleChoiceRadio] + end render json: types, status: :ok end diff --git a/app/models/Item.rb b/app/models/Item.rb index 0b6535228..a1972179c 100644 --- a/app/models/Item.rb +++ b/app/models/Item.rb @@ -25,7 +25,7 @@ def set_seq def as_json(options = {}) super(options.merge({ - only: %i[txt weight seq question_type size alternatives break_before min_label max_label created_at updated_at], + only: %i[id txt weight seq question_type size alternatives break_before min_label max_label created_at updated_at], include: { questionnaire: { only: %i[name id] } } @@ -91,4 +91,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/question_type.rb b/app/models/question_type.rb new file mode 100644 index 000000000..81a4c1711 --- /dev/null +++ b/app/models/question_type.rb @@ -0,0 +1,2 @@ +class QuestionType < ApplicationRecord +end diff --git a/app/models/questionnaire.rb b/app/models/questionnaire.rb index b7eeee017..2b7ace54e 100644 --- a/app/models/questionnaire.rb +++ b/app/models/questionnaire.rb @@ -3,7 +3,6 @@ class Questionnaire < ApplicationRecord belongs_to :instructor has_many :items, class_name: "Item", foreign_key: "questionnaire_id", dependent: :destroy # the collection of items associated with this Questionnaire - before_destroy :check_for_question_associations validate :validate_questionnaire validates :name, presence: true @@ -64,13 +63,6 @@ def self.copy_questionnaire_details(params) questionnaire end - # Check_for_question_associations checks if questionnaire has associated items or not - def check_for_question_associations - if items.any? - raise ActiveRecord::DeleteRestrictionError.new( "Cannot delete record because dependent items exist") - end - end - def as_json(options = {}) super(options.merge({ only: %i[id name private min_question_score max_question_score created_at updated_at questionnaire_type instructor_id], diff --git a/config/routes.rb b/config/routes.rb index 57559d007..f1ed1f21b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -62,12 +62,16 @@ end end - resources :questionnaires do - collection do - post 'copy/:id', to: 'questionnaires#copy', as: 'copy' - get 'toggle_access/:id', to: 'questionnaires#toggle_access', as: 'toggle_access' - end - end + resources :questionnaires do + collection do + get 'hierarchical', to: 'questionnaires#hierarchical', as: 'hierarchical' + post 'copy/:id', to: 'questionnaires#copy', as: 'copy' + get 'toggle_access/:id', to: 'questionnaires#toggle_access', as: 'toggle_access' + end + member do + get :items + end + end resources :questions do collection do From f60611bf9d3530075c4b7ee7154f4071e9d72f44 Mon Sep 17 00:00:00 2001 From: Veeraraghavan Narasimhan Date: Mon, 30 Mar 2026 19:22:23 -0700 Subject: [PATCH 4/8] Docker related changes --- Gemfile | 2 +- config/application.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile b/Gemfile index 540f19cb5..03c0ddde2 100644 --- a/Gemfile +++ b/Gemfile @@ -32,7 +32,7 @@ gem 'psych', '~> 5.2' # Ensure compatible psych version for Ruby 3.4.5 # gem "jbuilder" # Use Redis adapter to run Action Cable in production -# gem "redis", "~> 4.0" +gem "redis", "~> 5.0" # Use Kredis to get higher-level data types in Redis [https://github.com/rails/kredis] # gem "kredis" diff --git a/config/application.rb b/config/application.rb index f78d01563..346d32e5e 100644 --- a/config/application.rb +++ b/config/application.rb @@ -32,6 +32,6 @@ class Application < Rails::Application # Middleware like session, flash, cookies can be added back manually. # Skip views, helpers and assets when generating a new resource. config.api_only = true - config.cache_store = :redis_store, ENV['CACHE_STORE'], { expires_in: 3.days, raise_errors: false } + config.cache_store = :redis_cache_store, { url: ENV['CACHE_STORE'], expires_in: 3.days, raise_errors: false } end end From d2d0e4182503d2242dabccc13b10ce54891f9c6e Mon Sep 17 00:00:00 2001 From: Veeraraghavan Narasimhan Date: Mon, 30 Mar 2026 22:30:06 -0400 Subject: [PATCH 5/8] More docker changes --- Gemfile | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/Gemfile b/Gemfile index 03c0ddde2..d23bb0aef 100644 --- a/Gemfile +++ b/Gemfile @@ -57,13 +57,9 @@ gem 'find_with_order' # For handling zip file uploads and extraction gem 'rubyzip' - +gem 'faker' group :development, :test do - gem 'debug', platforms: %i[mri mingw x64_mingw] - gem 'factory_bot_rails' - gem 'database_cleaner-active_record' - gem 'faker' gem 'rspec-rails' gem 'rswag-specs' gem 'rubocop' From 36d538351bbfa76437c3cf33fbb56f5cb1ca9cd7 Mon Sep 17 00:00:00 2001 From: kmthoms2_ncstate Date: Mon, 30 Mar 2026 23:00:43 -0400 Subject: [PATCH 6/8] [Test] Courses, Questionnaire, Teams RSpec tests --- spec/requests/courses_spec.rb | 56 +++++++++++ spec/requests/questionnaires_spec.rb | 141 +++++++++++++++++++++++++++ spec/requests/teams_spec.rb | 59 +++++++++++ 3 files changed, 256 insertions(+) create mode 100644 spec/requests/courses_spec.rb create mode 100644 spec/requests/questionnaires_spec.rb create mode 100644 spec/requests/teams_spec.rb diff --git a/spec/requests/courses_spec.rb b/spec/requests/courses_spec.rb new file mode 100644 index 000000000..2a68a467b --- /dev/null +++ b/spec/requests/courses_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'json_web_token' + +RSpec.describe 'Courses API', type: :request do + include RolesHelper + + def auth_headers_for(user) + token = JsonWebToken.encode( + { + id: user.id, + name: user.name, + full_name: user.full_name, + role: user.role.name, + institution_id: user.institution_id + } + ) + + { + 'Authorization' => "Bearer #{token}", + 'Accept' => 'application/json' + } + end + + let!(:roles) { create_roles_hierarchy } + let!(:institution) { create(:institution) } + let!(:instructor) do + User.create!( + name: 'coursespecuser', + email: 'coursespec@example.com', + password: 'password', + full_name: 'Course Spec User', + institution: institution, + role: roles[:instructor] + ) + end + let!(:course) { create(:course, name: 'CSC 517', instructor: instructor, institution: institution) } + let!(:assignment) { create(:assignment, name: 'Assignment 1', instructor: instructor, course: course) } + + describe 'GET /courses' do + it 'returns courses with nested assignments' do + get '/courses', headers: auth_headers_for(instructor) + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + course_json = json.find { |record| record['id'] == course.id } + + expect(course_json).to be_present + expect(course_json['name']).to eq('CSC 517') + expect(course_json['assignments']).to be_an(Array) + expect(course_json['assignments'].map { |record| record['id'] }).to include(assignment.id) + end + end +end diff --git a/spec/requests/questionnaires_spec.rb b/spec/requests/questionnaires_spec.rb new file mode 100644 index 000000000..d61612e43 --- /dev/null +++ b/spec/requests/questionnaires_spec.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'json_web_token' + +RSpec.describe 'Questionnaires API', type: :request do + include RolesHelper + + def auth_headers_for(user) + token = JsonWebToken.encode( + { + id: user.id, + name: user.name, + full_name: user.full_name, + role: user.role.name, + institution_id: user.institution_id + } + ) + + { + 'Authorization' => "Bearer #{token}", + 'Accept' => 'application/json' + } + end + + let!(:roles) { create_roles_hierarchy } + let!(:institution) { create(:institution) } + let!(:instructor) do + User.create!( + name: 'questspecuser', + email: 'questspec@example.com', + password: 'password', + full_name: 'Questionnaire Spec User', + institution: institution, + role: roles[:instructor] + ) + end + let!(:other_instructor) do + User.create!( + name: 'otherquestspecuser', + email: 'otherquestspec@example.com', + password: 'password', + full_name: 'Other Questionnaire Spec User', + institution: institution, + role: roles[:instructor] + ) + end + let!(:review_questionnaire) do + Questionnaire.create!( + name: 'Review Rubric', + instructor: instructor, + private: false, + min_question_score: 0, + max_question_score: 5, + questionnaire_type: 'ReviewQuestionnaire', + display_type: 'Review' + ) + end + let!(:private_questionnaire) do + Questionnaire.create!( + name: 'Private Review Rubric', + instructor: instructor, + private: true, + min_question_score: 0, + max_question_score: 5, + questionnaire_type: 'ReviewQuestionnaire', + display_type: 'Review' + ) + end + let!(:public_quiz_questionnaire) do + Questionnaire.create!( + name: 'Quiz Rubric', + instructor: other_instructor, + private: false, + min_question_score: 0, + max_question_score: 5, + questionnaire_type: 'QuizQuestionnaire', + display_type: 'Quiz' + ) + end + let!(:item_two) do + Item.create!( + questionnaire: review_questionnaire, + txt: 'Second item', + weight: 2, + seq: 2, + question_type: 'Scale', + break_before: true + ) + end + let!(:item_one) do + Item.create!( + questionnaire: review_questionnaire, + txt: 'First item', + weight: 1, + seq: 1, + question_type: 'Criterion', + break_before: true, + size: '60,5' + ) + end + + describe 'GET /questionnaires' do + it 'returns questionnaires' do + get '/questionnaires', headers: auth_headers_for(instructor) + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + expect(json.map { |record| record['id'] }).to include(review_questionnaire.id, private_questionnaire.id, public_quiz_questionnaire.id) + end + end + + describe 'GET /questionnaires/hierarchical' do + it 'returns questionnaires grouped by display type for the current user' do + get '/questionnaires/hierarchical', headers: auth_headers_for(instructor) + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + review_group = json.find { |group| group['type'] == 'Review' } + quiz_group = json.find { |group| group['type'] == 'Quiz' } + + expect(review_group).to be_present + expect(review_group['questionnaires'].map { |record| record['id'] }).to include(review_questionnaire.id, private_questionnaire.id) + expect(quiz_group['questionnaires'].map { |record| record['id'] }).to include(public_quiz_questionnaire.id) + end + end + + describe 'GET /questionnaires/:id/items' do + it 'returns questionnaire items ordered by seq' do + get "/questionnaires/#{review_questionnaire.id}/items", headers: auth_headers_for(instructor) + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + expect(json.map { |record| record['id'] }).to eq([item_one.id, item_two.id]) + expect(json.map { |record| record['txt'] }).to eq(['First item', 'Second item']) + end + end +end diff --git a/spec/requests/teams_spec.rb b/spec/requests/teams_spec.rb new file mode 100644 index 000000000..7e2ac2621 --- /dev/null +++ b/spec/requests/teams_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'json_web_token' + +RSpec.describe 'Teams API', type: :request do + include RolesHelper + + def auth_headers_for(user) + token = JsonWebToken.encode( + { + id: user.id, + name: user.name, + full_name: user.full_name, + role: user.role.name, + institution_id: user.institution_id + } + ) + + { + 'Authorization' => "Bearer #{token}", + 'Accept' => 'application/json' + } + end + + let!(:roles) { create_roles_hierarchy } + let!(:institution) { create(:institution) } + let!(:instructor) do + User.create!( + name: 'teamspecuser', + email: 'teamspec@example.com', + password: 'password', + full_name: 'Team Spec User', + institution: institution, + role: roles[:instructor] + ) + end + let!(:course) { create(:course, instructor: instructor, institution: institution) } + let!(:assignment) { create(:assignment, instructor: instructor, course: course) } + let!(:assignment_team) { AssignmentTeam.create!(name: 'Alpha Team', parent_id: assignment.id) } + let!(:course_team) { CourseTeam.create!(name: 'Beta Team', parent_id: course.id) } + + describe 'GET /teams' do + it 'returns teams filtered by parent_id and types' do + get '/teams', + params: { parent_id: assignment.id, types: 'AssignmentTeam' }, + headers: auth_headers_for(instructor) + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + expect(json.length).to eq(1) + expect(json.first['id']).to eq(assignment_team.id) + expect(json.first['name']).to eq('Alpha Team') + expect(json.first['type']).to eq('AssignmentTeam') + expect(json.first['parent_id']).to eq(assignment.id) + end + end +end From 96c940ad63612e00a78e456446ae1d6fe5d7d127 Mon Sep 17 00:00:00 2001 From: Veeraraghavan Narasimhan Date: Fri, 3 Apr 2026 01:42:00 -0400 Subject: [PATCH 7/8] Bug fix with role names --- app/models/role.rb | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/models/role.rb b/app/models/role.rb index 32c3221d0..35c8ee0a3 100644 --- a/app/models/role.rb +++ b/app/models/role.rb @@ -5,6 +5,13 @@ class Role < ApplicationRecord belongs_to :parent, class_name: 'Role', optional: true has_many :users, dependent: :nullify + # Role Names + STUDENT = 'Student' + TEACHING_ASSISTANT = 'Teaching Assistant' + INSTRUCTOR = 'Instructor' + ADMINISTRATOR = 'Administrator' + SUPER_ADMINISTRATOR = 'Super Administrator' + # Role IDs STUDENT_ID = 1 TEACHING_ASSISTANT_ID = 2 From 6418df389d1f5a440eb40fca9faa8a4d5a669f64 Mon Sep 17 00:00:00 2001 From: Veeraraghavan Narasimhan Date: Sun, 5 Apr 2026 21:21:07 -0400 Subject: [PATCH 8/8] Removed all the extra changes made to get VCL working --- Dockerfile | 4 ++-- Gemfile | 7 +++++-- app/models/role.rb | 7 ------- config/application.rb | 4 ++-- docker-compose.yml | 2 +- 5 files changed, 10 insertions(+), 14 deletions(-) diff --git a/Dockerfile b/Dockerfile index a937ef541..ff57d5dd5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,10 +15,10 @@ WORKDIR /app COPY . . # Install Ruby dependencies -RUN gem update --system && gem install bundler: +RUN gem update --system && gem install bundler:2.4.7 RUN bundle install EXPOSE 3002 # Set the entry point -ENTRYPOINT ["/app/setup.sh"] \ No newline at end of file +ENTRYPOINT ["/app/setup.sh"] diff --git a/Gemfile b/Gemfile index d23bb0aef..5447920b8 100644 --- a/Gemfile +++ b/Gemfile @@ -32,7 +32,7 @@ gem 'psych', '~> 5.2' # Ensure compatible psych version for Ruby 3.4.5 # gem "jbuilder" # Use Redis adapter to run Action Cable in production -gem "redis", "~> 5.0" +# gem "redis", "~> 4.0" # Use Kredis to get higher-level data types in Redis [https://github.com/rails/kredis] # gem "kredis" @@ -57,9 +57,12 @@ gem 'find_with_order' # For handling zip file uploads and extraction gem 'rubyzip' -gem 'faker' group :development, :test do + gem 'debug', platforms: %i[mri mingw x64_mingw] + gem 'factory_bot_rails' + gem 'database_cleaner-active_record' + gem 'faker' gem 'rspec-rails' gem 'rswag-specs' gem 'rubocop' diff --git a/app/models/role.rb b/app/models/role.rb index 35c8ee0a3..32c3221d0 100644 --- a/app/models/role.rb +++ b/app/models/role.rb @@ -5,13 +5,6 @@ class Role < ApplicationRecord belongs_to :parent, class_name: 'Role', optional: true has_many :users, dependent: :nullify - # Role Names - STUDENT = 'Student' - TEACHING_ASSISTANT = 'Teaching Assistant' - INSTRUCTOR = 'Instructor' - ADMINISTRATOR = 'Administrator' - SUPER_ADMINISTRATOR = 'Super Administrator' - # Role IDs STUDENT_ID = 1 TEACHING_ASSISTANT_ID = 2 diff --git a/config/application.rb b/config/application.rb index 346d32e5e..798f8702b 100644 --- a/config/application.rb +++ b/config/application.rb @@ -17,7 +17,7 @@ def self.preview_path=(_) module Reimplementation class Application < Rails::Application # Initialize configuration defaults for originally generated Rails version. - config.load_defaults 8.0 + config.load_defaults 7.0 config.active_record.schema_format = :ruby # Configuration for the application, engines, and railties goes here. @@ -32,6 +32,6 @@ class Application < Rails::Application # Middleware like session, flash, cookies can be added back manually. # Skip views, helpers and assets when generating a new resource. config.api_only = true - config.cache_store = :redis_cache_store, { url: ENV['CACHE_STORE'], expires_in: 3.days, raise_errors: false } + config.cache_store = :redis_store, ENV['CACHE_STORE'], { expires_in: 3.days, raise_errors: false } end end diff --git a/docker-compose.yml b/docker-compose.yml index 2413a65fd..f22dc27ef 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ services: command: tail -f /dev/null environment: RAILS_ENV: development - DATABASE_URL: mysql2://root:expertiza@db:3306/reimplementation + DATABASE_URL: mysql2://root:expertiza@db:3306/reimplementation? CACHE_STORE: redis://redis:6380/0 ports: - "3002:3002"