diff --git a/app/api/api_root.rb b/app/api/api_root.rb index f3d1904fa..7de6c8105 100644 --- a/app/api/api_root.rb +++ b/app/api/api_root.rb @@ -97,6 +97,7 @@ class ApiRoot < Grape::API mount Courseflow::CourseMapUnitApi mount Courseflow::SpecializationApi mount Courseflow::RequirementSetApi + mount Courseflow::RequirementApi mount UnitDefinitionApi # diff --git a/app/api/courseflow/course_map_api.rb b/app/api/courseflow/course_map_api.rb index fb4c03db7..868210488 100644 --- a/app/api/courseflow/course_map_api.rb +++ b/app/api/courseflow/course_map_api.rb @@ -14,12 +14,13 @@ class CourseMapApi < Grape::API params do requires :userId, type: Integer, desc: "User ID" end - get '/coursemap/userId/:userId' do - course_map = CourseMap.find_by(userId: params[:userId]) + get '/coursemap/userId/:userId' do + course_map = CourseMap.find_by(userId: params[:userId]) || CourseMap.find_by(userId: nil) + if course_map present course_map, with: Entities::CourseMapEntity else - error!({ error: "Course map #{params[:userId]} not found" }, 404) + error!({ error: "Course map for user #{params[:userId]} not found" }, 404) end end diff --git a/app/api/courseflow/entities/requirement_entity.rb b/app/api/courseflow/entities/requirement_entity.rb new file mode 100644 index 000000000..b31d0ec4a --- /dev/null +++ b/app/api/courseflow/entities/requirement_entity.rb @@ -0,0 +1,15 @@ +module Courseflow + module Entities + class RequirementEntity < Grape::Entity + expose :id + expose :unitId + expose :courseId + expose :type + expose :category + expose :description + expose :minimum + expose :maximum + expose :requirementSetGroupId + end + end +end diff --git a/app/api/courseflow/requirement_api.rb b/app/api/courseflow/requirement_api.rb new file mode 100644 index 000000000..63dbb0cc3 --- /dev/null +++ b/app/api/courseflow/requirement_api.rb @@ -0,0 +1,90 @@ +require 'grape' +module Courseflow + class RequirementApi < Grape::API + format :json + helpers AuthenticationHelpers + helpers AuthorisationHelpers + + before do + authenticated? + end + + desc 'Get all requirements' + get '/requirement' do + present Requirement.all, with: Entities::RequirementEntity + end + + desc 'Get requirements by unit ID' + params do + requires :unitId, type: Integer, desc: 'Unit ID' + end + get '/requirement/unitId/:unitId' do + present Requirement.where(unitId: params[:unitId]), with: Entities::RequirementEntity + end + + desc 'Get requirements by course ID' + params do + requires :courseId, type: Integer, desc: 'Course ID' + end + get '/requirement/courseId/:courseId' do + present Requirement.where(courseId: params[:courseId]), with: Entities::RequirementEntity + end + + desc 'Create a new requirement' + params do + requires :unitId, type: Integer + requires :courseId, type: Integer + requires :type, type: String + requires :category, type: String + requires :description, type: String + optional :minimum, type: Integer + optional :maximum, type: Integer + requires :requirementSetGroupId, type: Integer + end + post '/requirement' do + error!({ error: 'Not authorised' }, 403) unless authorise?(current_user, User, :handle_courseflow) + req = Requirement.new(declared(params, include_missing: false)) + if req.save + status 201 + present req, with: Entities::RequirementEntity + else + error!({ error: 'Failed to create requirement', details: req.errors.full_messages }, 400) + end + end + + desc 'Update a requirement' + params do + requires :id, type: Integer, desc: 'Requirement ID' + optional :unitId, type: Integer + optional :courseId, type: Integer + optional :type, type: String + optional :category, type: String + optional :description, type: String + optional :minimum, type: Integer + optional :maximum, type: Integer + optional :requirementSetGroupId, type: Integer + end + put '/requirement/:id' do + error!({ error: 'Not authorised' }, 403) unless authorise?(current_user, User, :handle_courseflow) + req = Requirement.find_by(id: params[:id]) + error!({ error: 'Requirement not found' }, 404) unless req + if req.update(declared(params, include_missing: false).except(:id)) + present req, with: Entities::RequirementEntity + else + error!({ error: 'Failed to update requirement', details: req.errors.full_messages }, 400) + end + end + + desc 'Delete a requirement' + params do + requires :id, type: Integer, desc: 'Requirement ID' + end + delete '/requirement/:id' do + error!({ error: 'Not authorised' }, 403) unless authorise?(current_user, User, :handle_courseflow) + req = Requirement.find_by(id: params[:id]) + error!({ error: 'Requirement not found' }, 404) unless req + req.destroy + status 204 + end + end +end diff --git a/app/models/courseflow/course_map.rb b/app/models/courseflow/course_map.rb index b2f149495..b784df759 100644 --- a/app/models/courseflow/course_map.rb +++ b/app/models/courseflow/course_map.rb @@ -1,7 +1,11 @@ module Courseflow class CourseMap < ApplicationRecord # Validation rules for attributes in the course map model - validates :userId, presence: true + validates :userId, presence: true, unless: :template? validates :courseId, presence: true + + def template? + userId.nil? + end end end diff --git a/app/models/courseflow/requirement.rb b/app/models/courseflow/requirement.rb new file mode 100644 index 000000000..4a4101c9b --- /dev/null +++ b/app/models/courseflow/requirement.rb @@ -0,0 +1,11 @@ +module Courseflow + class Requirement < ApplicationRecord + self.inheritance_column = :_type_disabled + + validates :unitId, presence: true + validates :courseId, presence: true + validates :category, presence: true + validates :description, presence: true + validates :requirementSetGroupId, presence: true + end +end diff --git a/db/migrate/20250515025346_create_requirement.rb b/db/migrate/20250515025346_create_requirement.rb new file mode 100644 index 000000000..3dda33887 --- /dev/null +++ b/db/migrate/20250515025346_create_requirement.rb @@ -0,0 +1,16 @@ +class CreateRequirement < ActiveRecord::Migration[7.1] + def change + create_table :requirements do |t| + t.integer :unitId + t.integer :courseId + t.string :type + t.string :category + t.string :description + t.integer :minimum + t.integer :maximum + t.integer :requirementSetGroupId + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 13783ff85..45edac32a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,8 +10,8 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2024_09_10_063917) do - create_table "activity_types", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| +ActiveRecord::Schema[7.1].define(version: 2025_05_15_025346) do + create_table "activity_types", charset: "utf8", collation: "utf8_unicode_ci", force: :cascade do |t| t.string "name", null: false t.string "abbreviation", null: false t.datetime "created_at", null: false @@ -20,21 +20,21 @@ t.index ["name"], name: "index_activity_types_on_name", unique: true end - create_table "auth_tokens", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + create_table "auth_tokens", charset: "utf8", collation: "utf8_unicode_ci", force: :cascade do |t| t.datetime "auth_token_expiry", null: false t.bigint "user_id" t.string "authentication_token", null: false t.index ["user_id"], name: "index_auth_tokens_on_user_id" end - create_table "breaks", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + create_table "breaks", charset: "utf8", collation: "utf8_unicode_ci", force: :cascade do |t| t.datetime "start_date", null: false t.integer "number_of_weeks", null: false t.bigint "teaching_period_id" t.index ["teaching_period_id"], name: "index_breaks_on_teaching_period_id" end - create_table "campuses", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + create_table "campuses", charset: "utf8", collation: "utf8_unicode_ci", force: :cascade do |t| t.string "name", null: false t.integer "mode", null: false t.string "abbreviation", null: false @@ -44,7 +44,7 @@ t.index ["name"], name: "index_campuses_on_name", unique: true end - create_table "comments_read_receipts", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + create_table "comments_read_receipts", charset: "utf8", collation: "utf8_unicode_ci", force: :cascade do |t| t.bigint "task_comment_id", null: false t.bigint "user_id", null: false t.datetime "created_at", null: false @@ -81,7 +81,7 @@ t.datetime "updated_at", null: false end - create_table "discussion_comments", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + create_table "discussion_comments", charset: "utf8", collation: "utf8_unicode_ci", force: :cascade do |t| t.datetime "time_started" t.datetime "time_completed" t.integer "number_of_prompts" @@ -89,7 +89,7 @@ t.datetime "updated_at", null: false end - create_table "group_memberships", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + create_table "group_memberships", charset: "utf8", collation: "utf8_unicode_ci", force: :cascade do |t| t.bigint "group_id" t.bigint "project_id" t.boolean "active", default: true @@ -99,7 +99,7 @@ t.index ["project_id"], name: "index_group_memberships_on_project_id" end - create_table "group_sets", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + create_table "group_sets", charset: "utf8", collation: "utf8_unicode_ci", force: :cascade do |t| t.bigint "unit_id" t.string "name" t.boolean "allow_students_to_create_groups", default: true @@ -113,7 +113,7 @@ t.index ["unit_id"], name: "index_group_sets_on_unit_id" end - create_table "group_submissions", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + create_table "group_submissions", charset: "utf8", collation: "utf8_unicode_ci", force: :cascade do |t| t.bigint "group_id" t.string "notes" t.bigint "submitted_by_project_id" @@ -125,7 +125,7 @@ t.index ["task_definition_id"], name: "index_group_submissions_on_task_definition_id" end - create_table "groups", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + create_table "groups", charset: "utf8", collation: "utf8_unicode_ci", force: :cascade do |t| t.bigint "group_set_id" t.bigint "tutorial_id" t.string "name" @@ -138,7 +138,7 @@ t.index ["tutorial_id"], name: "index_groups_on_tutorial_id" end - create_table "learning_outcome_task_links", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + create_table "learning_outcome_task_links", charset: "utf8", collation: "utf8_unicode_ci", force: :cascade do |t| t.text "description" t.integer "rating" t.bigint "task_definition_id" @@ -151,7 +151,7 @@ t.index ["task_id"], name: "index_learning_outcome_task_links_on_task_id" end - create_table "learning_outcomes", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + create_table "learning_outcomes", charset: "utf8", collation: "utf8_unicode_ci", force: :cascade do |t| t.bigint "unit_id" t.integer "ilo_number" t.string "name" @@ -161,7 +161,7 @@ t.index ["unit_id"], name: "index_learning_outcomes_on_unit_id" end - create_table "logins", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + create_table "logins", charset: "utf8", collation: "utf8_unicode_ci", force: :cascade do |t| t.datetime "timestamp" t.bigint "user_id" t.datetime "created_at", null: false @@ -169,7 +169,7 @@ t.index ["user_id"], name: "index_logins_on_user_id" end - create_table "overseer_assessments", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + create_table "overseer_assessments", charset: "utf8", collation: "utf8_unicode_ci", force: :cascade do |t| t.bigint "task_id", null: false t.string "submission_timestamp", null: false t.string "result_task_status" @@ -180,7 +180,7 @@ t.index ["task_id"], name: "index_overseer_assessments_on_task_id" end - create_table "overseer_images", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + create_table "overseer_images", charset: "utf8", collation: "utf8_unicode_ci", force: :cascade do |t| t.string "name", null: false t.string "tag", null: false t.datetime "created_at", null: false @@ -192,7 +192,7 @@ t.index ["tag"], name: "index_overseer_images_on_tag", unique: true end - create_table "projects", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + create_table "projects", charset: "utf8", collation: "utf8_unicode_ci", force: :cascade do |t| t.bigint "unit_id" t.string "project_role" t.datetime "created_at", null: false @@ -228,7 +228,20 @@ t.datetime "updated_at", null: false end - create_table "roles", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + create_table "requirements", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.integer "unitId" + t.integer "courseId" + t.string "type" + t.string "category" + t.string "description" + t.integer "minimum" + t.integer "maximum" + t.integer "requirementSetGroupId" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + create_table "roles", charset: "utf8", collation: "utf8_unicode_ci", force: :cascade do |t| t.string "name" t.text "description" t.datetime "created_at", null: false @@ -241,7 +254,7 @@ t.datetime "updated_at", null: false end - create_table "task_comments", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + create_table "task_comments", charset: "utf8", collation: "utf8_unicode_ci", force: :cascade do |t| t.bigint "task_id", null: false t.bigint "user_id", null: false t.string "comment", limit: 4096 @@ -272,7 +285,7 @@ t.index ["user_id"], name: "index_task_comments_on_user_id" end - create_table "task_definitions", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + create_table "task_definitions", charset: "utf8", collation: "utf8_unicode_ci", force: :cascade do |t| t.bigint "unit_id" t.string "name" t.string "description", limit: 4096 @@ -305,7 +318,7 @@ t.index ["unit_id"], name: "index_task_definitions_on_unit_id" end - create_table "task_engagements", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + create_table "task_engagements", charset: "utf8", collation: "utf8_unicode_ci", force: :cascade do |t| t.datetime "engagement_time" t.string "engagement" t.bigint "task_id" @@ -314,7 +327,7 @@ t.index ["task_id"], name: "index_task_engagements_on_task_id" end - create_table "task_pins", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + create_table "task_pins", charset: "utf8", collation: "utf8_unicode_ci", force: :cascade do |t| t.bigint "task_id", null: false t.bigint "user_id", null: false t.datetime "created_at", null: false @@ -324,7 +337,7 @@ t.index ["user_id"], name: "fk_rails_915df186ed" end - create_table "task_similarities", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + create_table "task_similarities", charset: "utf8", collation: "utf8_unicode_ci", force: :cascade do |t| t.bigint "task_id" t.bigint "other_task_id" t.integer "pct" @@ -339,14 +352,14 @@ t.index ["tii_submission_id"], name: "index_task_similarities_on_tii_submission_id" end - create_table "task_statuses", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + create_table "task_statuses", charset: "utf8", collation: "utf8_unicode_ci", force: :cascade do |t| t.string "name" t.string "description" t.datetime "created_at", null: false t.datetime "updated_at", null: false end - create_table "task_submissions", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + create_table "task_submissions", charset: "utf8", collation: "utf8_unicode_ci", force: :cascade do |t| t.datetime "submission_time" t.datetime "assessment_time" t.string "outcome" @@ -358,7 +371,7 @@ t.index ["task_id"], name: "index_task_submissions_on_task_id" end - create_table "tasks", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + create_table "tasks", charset: "utf8", collation: "utf8_unicode_ci", force: :cascade do |t| t.bigint "task_definition_id" t.bigint "project_id" t.bigint "task_status_id" @@ -384,7 +397,7 @@ t.index ["task_status_id"], name: "index_tasks_on_task_status_id" end - create_table "teaching_periods", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + create_table "teaching_periods", charset: "utf8", collation: "utf8_unicode_ci", force: :cascade do |t| t.string "period", null: false t.datetime "start_date", null: false t.datetime "end_date", null: false @@ -443,7 +456,7 @@ t.index ["tii_task_similarity_id"], name: "index_tii_submissions_on_tii_task_similarity_id" end - create_table "tutorial_enrolments", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + create_table "tutorial_enrolments", charset: "utf8", collation: "utf8_unicode_ci", force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false t.bigint "project_id", null: false @@ -453,7 +466,7 @@ t.index ["tutorial_id"], name: "index_tutorial_enrolments_on_tutorial_id" end - create_table "tutorial_streams", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + create_table "tutorial_streams", charset: "utf8", collation: "utf8_unicode_ci", force: :cascade do |t| t.string "name", null: false t.string "abbreviation", null: false t.datetime "created_at", null: false @@ -467,7 +480,7 @@ t.index ["unit_id"], name: "index_tutorial_streams_on_unit_id" end - create_table "tutorials", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + create_table "tutorials", charset: "utf8", collation: "utf8_unicode_ci", force: :cascade do |t| t.bigint "unit_id" t.string "meeting_day" t.string "meeting_time" @@ -496,7 +509,7 @@ t.datetime "updated_at", null: false end - create_table "unit_roles", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + create_table "unit_roles", charset: "utf8", collation: "utf8_unicode_ci", force: :cascade do |t| t.bigint "user_id" t.bigint "tutorial_id" t.datetime "created_at", null: false @@ -509,7 +522,7 @@ t.index ["user_id"], name: "index_unit_roles_on_user_id" end - create_table "units", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + create_table "units", charset: "utf8", collation: "utf8_unicode_ci", force: :cascade do |t| t.string "name" t.string "description", limit: 4096 t.datetime "start_date" @@ -533,8 +546,8 @@ t.bigint "overseer_image_id" t.datetime "portfolio_auto_generation_date" t.string "tii_group_context_id" - t.bigint "unit_definition_id" t.boolean "archived", default: false + t.bigint "unit_definition_id" t.index ["draft_task_definition_id"], name: "index_units_on_draft_task_definition_id" t.index ["main_convenor_id"], name: "index_units_on_main_convenor_id" t.index ["overseer_image_id"], name: "index_units_on_overseer_image_id" @@ -542,7 +555,7 @@ t.index ["unit_definition_id"], name: "index_units_on_unit_definition_id" end - create_table "users", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + create_table "users", charset: "utf8", collation: "utf8_unicode_ci", force: :cascade do |t| t.string "email", default: "", null: false t.string "encrypted_password", default: "", null: false t.string "reset_password_token" @@ -578,7 +591,7 @@ t.index ["username"], name: "index_users_on_username", unique: true end - create_table "webcal_unit_exclusions", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + create_table "webcal_unit_exclusions", charset: "utf8", collation: "utf8_unicode_ci", force: :cascade do |t| t.bigint "webcal_id", null: false t.bigint "unit_id", null: false t.index ["unit_id", "webcal_id"], name: "index_webcal_unit_exclusions_on_unit_id_and_webcal_id", unique: true @@ -586,7 +599,7 @@ t.index ["webcal_id"], name: "fk_rails_d5fab02cb7" end - create_table "webcals", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + create_table "webcals", charset: "utf8", collation: "utf8_unicode_ci", force: :cascade do |t| t.string "guid", limit: 36, null: false t.boolean "include_start_dates", default: false, null: false t.bigint "user_id" diff --git a/test/api/courseflow/requirement_api_test.rb b/test/api/courseflow/requirement_api_test.rb new file mode 100644 index 000000000..136385b93 --- /dev/null +++ b/test/api/courseflow/requirement_api_test.rb @@ -0,0 +1,85 @@ +require 'test_helper' + +class RequirementApiTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + include TestHelpers::JsonHelper + + def app + Rails.application + end + + setup do + @unit = FactoryBot.create(:unit) + @course = FactoryBot.create(:course) + @attrs = { + unitId: @unit.id, + courseId: @course.id, + type: 'count', + category: 'prerequisite', + description: 'Must complete SIT102 first', + minimum: 1, + maximum: 1, + requirementSetGroupId: 1 + } + add_auth_header_for user: User.first + end + + def teardown + Courseflow::Requirement.destroy_all + @unit.destroy + @course.destroy + end + + def test_get_all_requirements + req = FactoryBot.create(:requirement, @attrs) + get "/api/requirement" + assert_equal 200, last_response.status + ensure + req.destroy + end + + def test_get_requirements_by_unit + req = FactoryBot.create(:requirement, @attrs) + get "/api/requirement/unitId/#{@unit.id}" + assert_equal 200, last_response.status + ensure + req.destroy + end + + def test_get_requirements_by_course + req = FactoryBot.create(:requirement, @attrs) + get "/api/requirement/courseId/#{@course.id}" + assert_equal 200, last_response.status + ensure + req.destroy + end + + def test_create_requirement + post_json "/api/requirement", @attrs + assert_equal 201, last_response.status + assert_equal 'prerequisite', last_response_body['category'] + end + + def test_update_requirement + req = FactoryBot.create(:requirement, @attrs) + put_json "/api/requirement/#{req.id}", description: 'Updated' + assert_equal 200, last_response.status + assert_equal 'Updated', last_response_body['description'] + ensure + req.destroy + end + + def test_delete_requirement + req = FactoryBot.create(:requirement, @attrs) + delete "/api/requirement/#{req.id}" + assert_equal 204, last_response.status + assert_not Courseflow::Requirement.exists?(req.id) + end + + def test_unauthorized_create_requirement + clear_auth_header + post_json "/api/requirement", @attrs + assert_equal 419, last_response.status + end +end diff --git a/test/api/courseflow/template_course_map_test.rb b/test/api/courseflow/template_course_map_test.rb new file mode 100644 index 000000000..5264a71a6 --- /dev/null +++ b/test/api/courseflow/template_course_map_test.rb @@ -0,0 +1,204 @@ +require 'test_helper' + +class TemplateCourseMapTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::JsonHelper + include TestHelpers::AuthHelper + + def app + Rails.application + end + + def setup + # Create teaching period if needed + @teaching_period = TeachingPeriod.first || FactoryBot.create(:teaching_period) + + # Create users needed for testing + @convenor_user1 = User.find_by(username: 'acain') || FactoryBot.create(:user, username: 'acain', nickname: 'Macite') + @convenor_user2 = User.find_by(username: 'aconvenor') || FactoryBot.create(:user, username: 'aconvenor', nickname: 'The Giant') + @tutor_user = User.find_by(username: 'cliff') || FactoryBot.create(:user, username: 'cliff', nickname: 'Cliff') + + # Create units required for course map + create_sit_units + + # Create a template course map (userId=nil) + @template_course_map = Courseflow::CourseMap.create(courseId: 1, userId: nil) + + # Create the course map units based on your seeds.rb structure + create_course_map_units + + # Create a test student user that has no course map + @student_without_course_map = FactoryBot.create(:user, :student) + end + + def teardown + # Clean up course map units + Courseflow::CourseMapUnit.where(courseMapId: @template_course_map.id).destroy_all + # Clean up course map + @template_course_map.destroy if @template_course_map + + end + + def create_sit_units + # Define the SIT units + @sit_units = { + sit111: { code: "SIT111", name: "Computer Systems" }, + sit192: { code: "SIT192", name: "Discrete Mathematics" }, + sit112: { code: "SIT112", name: "Introduction to Data Science and Artificial Intelligence" }, + sit102: { code: "SIT102", name: "Introduction to Programming" }, + sit232: { code: "SIT232", name: "Object-Oriented Development" }, + sit103: { code: "SIT103", name: "Database Fundamentals" }, + sit292: { code: "SIT292", name: "Linear Algebra for Data Analysis" }, + sit202: { code: "SIT202", name: "Computer Networks and Communication" }, + sit221: { code: "SIT221", name: "Data Structures and Algorithms" }, + sit215: { code: "SIT215", name: "Computational Intelligence" }, + sit223: { code: "SIT223", name: "Professional Practice in Information Technology" }, + sit320: { code: "SIT320", name: "Advanced Algorithms" }, + sit344: { code: "SIT344", name: "Professional Practice" }, + sit378: { code: "SIT378", name: "Team Project (B)" }, + sit315: { code: "SIT315", name: "Concurrent and Distributed Programming" } + } + + # Store the created units for later reference + @created_units = {} + + # Create each unit if it doesn't exist + @sit_units.each do |key, unit_data| + # Skip if the unit already exists + existing_unit = Unit.find_by(code: unit_data[:code]) + if existing_unit + @created_units[key] = existing_unit + next + end + + # Create the unit + unit = Unit.create!( + code: unit_data[:code], + name: unit_data[:name], + description: "#{unit_data[:name]} unit description", + teaching_period: @teaching_period + ) + + # Employ staff + unit.employ_staff(@convenor_user1, Role.convenor) + unit.employ_staff(@convenor_user2, Role.convenor) + unit.employ_staff(@tutor_user, Role.tutor) + + # Store for later use + @created_units[key] = unit + end + end + + def create_course_map_units + units_data = [ + { unit: :sit111, year: 2024, teaching_period: 1, slot: 1 }, + { unit: :sit192, year: 2024, teaching_period: 1, slot: 2 }, + { unit: :sit112, year: 2024, teaching_period: 1, slot: 3 }, + { unit: :sit102, year: 2024, teaching_period: 1, slot: 4 }, + + { unit: :sit232, year: 2024, teaching_period: 2, slot: 1 }, + { unit: :sit103, year: 2024, teaching_period: 2, slot: 2 }, + { unit: :sit292, year: 2024, teaching_period: 2, slot: 3 }, + { unit: :sit202, year: 2024, teaching_period: 2, slot: 4 }, + + { unit: :sit221, year: 2025, teaching_period: 1, slot: 1 }, + { unit: :sit215, year: 2025, teaching_period: 1, slot: 2 }, + + { unit: :sit223, year: 2025, teaching_period: 2, slot: 1 }, + { unit: :sit320, year: 2025, teaching_period: 2, slot: 2 }, + { unit: :sit315, year: 2025, teaching_period: 2, slot: 3 }, + + { unit: :sit344, year: 2026, teaching_period: 1, slot: 1 }, + + { unit: :sit378, year: 2026, teaching_period: 2, slot: 1 } + ] + + units_data.each do |unit_data| + Courseflow::CourseMapUnit.create( + courseMapId: @template_course_map.id, + unitId: @created_units[unit_data[:unit]].id, + yearSlot: unit_data[:year], + teachingPeriodSlot: unit_data[:teaching_period], + unitSlot: unit_data[:slot] + ) + end + end + + # + # Tests + # + + def test_get_template_course_map_when_user_has_no_course_map + add_auth_header_for user: @student_without_course_map + + get "/api/coursemap/userId/#{@student_without_course_map.id}" + + assert_equal 200, last_response.status + + response_data = JSON.parse(last_response.body) + + assert_equal @template_course_map.id, response_data['id'] + assert_equal @template_course_map.courseId, response_data['courseId'] + assert_nil response_data['userId'] # Template has nil userId + end + + def test_user_course_map_prioritized_over_template + student_with_map = FactoryBot.create(:user, :student) + personal_course_map = Courseflow::CourseMap.create(courseId: 1, userId: student_with_map.id) + + Courseflow::CourseMapUnit.create( + courseMapId: personal_course_map.id, + unitId: @created_units[:sit111].id, + yearSlot: 2030, + teachingPeriodSlot: 3, + unitSlot: 5 + ) + + add_auth_header_for user: student_with_map + + get "/api/coursemap/userId/#{student_with_map.id}" + + assert_equal 200, last_response.status + + response_data = JSON.parse(last_response.body) + + # Verify that the personal course map was returned, not the template + assert_equal personal_course_map.id, response_data['id'] + assert_equal personal_course_map.courseId, response_data['courseId'] + assert_equal student_with_map.id, response_data['userId'] + ensure + Courseflow::CourseMapUnit.where(courseMapId: personal_course_map.id).destroy_all if personal_course_map + personal_course_map.destroy if personal_course_map + end + + def test_course_map_units_for_template + add_auth_header_for user: @student_without_course_map + + # Request the course map units for the template + get "/api/coursemapunit/courseMapId/#{@template_course_map.id}" + + assert_equal 200, last_response.status + + response_data = JSON.parse(last_response.body) + + # Verify that we got all course map units + assert_equal 15, response_data.length + + # Verify first unit is SIT111 in the first year and period + first_unit = response_data.find { |unit| unit['unitSlot'] == 1 && unit['yearSlot'] == 2024 && unit['teachingPeriodSlot'] == 1 } + assert_not_nil first_unit + assert_equal @created_units[:sit111].id, first_unit['unitId'] + end + + def test_template_course_map_structure + course_map_units = Courseflow::CourseMapUnit.where(courseMapId: @template_course_map.id) + + # Verify we have units in each expected year and period + assert_equal 4, course_map_units.where(yearSlot: 2024, teachingPeriodSlot: 1).count + assert_equal 4, course_map_units.where(yearSlot: 2024, teachingPeriodSlot: 2).count + assert_equal 2, course_map_units.where(yearSlot: 2025, teachingPeriodSlot: 1).count + assert_equal 3, course_map_units.where(yearSlot: 2025, teachingPeriodSlot: 2).count + assert_equal 1, course_map_units.where(yearSlot: 2026, teachingPeriodSlot: 1).count + assert_equal 1, course_map_units.where(yearSlot: 2026, teachingPeriodSlot: 2).count + end +end diff --git a/test/factories/requirement_factory.rb b/test/factories/requirement_factory.rb new file mode 100644 index 000000000..b9da0983e --- /dev/null +++ b/test/factories/requirement_factory.rb @@ -0,0 +1,12 @@ +FactoryBot.define do + factory :requirement, class: 'Courseflow::Requirement' do + unitId { FactoryBot.create(:unit).id } + courseId { FactoryBot.create(:course).id } + type { 'count' } + category { 'prerequisite' } + description { 'Must complete SIT102 first' } + minimum { 1 } + maximum { 1 } + requirementSetGroupId { 1 } + end +end