diff --git a/.gitignore b/.gitignore index 8f89fd1..96a7618 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,5 @@ spec/examples.txt .envrc .irb_history .DS_Store -CLAUDE.md \ No newline at end of file +CLAUDE.md +/docs/superpowers/ \ No newline at end of file diff --git a/app/importers/committee_data/committee_xml_builder.rb b/app/importers/committee_data/committee_xml_builder.rb index 5d66223..549ada4 100644 --- a/app/importers/committee_data/committee_xml_builder.rb +++ b/app/importers/committee_data/committee_xml_builder.rb @@ -24,16 +24,27 @@ def build_xml(batch) xml.Record(username: faculty.access_id) do committees.each do |committee| xml.DSL do - xml.ROLE committee.role - xml.TYPE committee.type_of_work - xml.COMPSTAGE committee.stage_of_completion - xml.DTY_START committee.start_year - xml.DTY_END committee.completion_year if committee.completion_year + xml.ROLE committee.role, access: 'READ_ONLY' + xml.ROLE_OTHER committee.role_other, access: 'READ_ONLY' if committee.role_other.present? + xml.TYPE committee.type_of_work, access: 'READ_ONLY' + xml.COMPSTAGE committee.stage_of_completion, access: 'READ_ONLY' + + same_date = committee.start_year == committee.completion_year && + committee.start_month == committee.completion_month + + unless same_date + xml.DTM_START Date::MONTHNAMES[committee.start_month], access: 'READ_ONLY' if committee.start_month + xml.DTY_START committee.start_year, access: 'READ_ONLY' if committee.start_year + end + + xml.DTM_END Date::MONTHNAMES[committee.completion_month], access: 'READ_ONLY' if committee.completion_month + xml.DTY_END committee.completion_year, access: 'READ_ONLY' if committee.completion_year xml.DSL_STUDENT do - xml.FNAME committee.student_fname - xml.LNAME committee.student_lname - xml.TITLE committee.thesis_title + xml.FNAME committee.student_fname, access: 'READ_ONLY' + xml.LNAME committee.student_lname, access: 'READ_ONLY' + xml.DEG committee.degree_name, access: 'READ_ONLY' if committee.degree_name.present? + xml.TITLE committee.thesis_title, access: 'READ_ONLY' end end end diff --git a/app/importers/committee_data/etda_importer.rb b/app/importers/committee_data/etda_importer.rb index 4c14be8..3f83fd9 100644 --- a/app/importers/committee_data/etda_importer.rb +++ b/app/importers/committee_data/etda_importer.rb @@ -21,19 +21,23 @@ def import_for_faculty(faculty) committees_data.each do |committee| next unless within_last_six_months?(committee['approval_started_at']) - normalized_role = CommitteeRoleNormalizer.normalize(committee['role']) + role, role_other = CommitteeRoleNormalizer.normalize(committee['role']) faculty.committees.create!( student_fname: committee['student_fname'], student_lname: committee['student_lname'], - role: normalized_role, + role: role, + role_other: role_other, thesis_title: committee['title'], type_of_work: map_type_of_work(committee['degree_type']), + degree_name: committee['degree_name'], stage_of_completion: determine_completion_stage( committee['final_submission_approved_at'] ), start_year: extract_year(committee['approval_started_at']), - completion_year: extract_year(committee['final_submission_approved_at']) + start_month: extract_month(committee['approval_started_at']), + completion_year: extract_year(committee['final_submission_approved_at']), + completion_month: extract_month(committee['final_submission_approved_at']) ) end @@ -66,6 +70,14 @@ def extract_year(date_string) nil end + def extract_month(date_string) + return nil if date_string.blank? + + Date.parse(date_string).month + rescue ArgumentError + nil + end + def within_last_six_months?(date_string) return false if date_string.blank? diff --git a/app/services/committee_role_normalizer.rb b/app/services/committee_role_normalizer.rb index 9df716c..9e8e5e9 100644 --- a/app/services/committee_role_normalizer.rb +++ b/app/services/committee_role_normalizer.rb @@ -14,12 +14,12 @@ class CommitteeRoleNormalizer def self.normalize(raw_name) text = raw_name.to_s.strip - return 'Other' if text.empty? + return ['Other', nil] if text.empty? PRIORITY_REGEX.each do |label, regex| - return label if text.match?(regex) + return [label, nil] if text.match?(regex) end - 'Other' + ['Other', text] end end diff --git a/db/migrate/20260423000000_add_months_to_committees.rb b/db/migrate/20260423000000_add_months_to_committees.rb new file mode 100644 index 0000000..c067d30 --- /dev/null +++ b/db/migrate/20260423000000_add_months_to_committees.rb @@ -0,0 +1,8 @@ +class AddMonthsToCommittees < ActiveRecord::Migration[7.2] + def change + change_table :committees, bulk: true do |t| + t.integer :start_month + t.integer :completion_month + end + end +end diff --git a/db/migrate/20260428000000_add_role_other_to_committees.rb b/db/migrate/20260428000000_add_role_other_to_committees.rb new file mode 100644 index 0000000..898e2fe --- /dev/null +++ b/db/migrate/20260428000000_add_role_other_to_committees.rb @@ -0,0 +1,5 @@ +class AddRoleOtherToCommittees < ActiveRecord::Migration[7.2] + def change + add_column :committees, :role_other, :string + end +end diff --git a/db/migrate/20260428120000_add_degree_name_to_committees.rb b/db/migrate/20260428120000_add_degree_name_to_committees.rb new file mode 100644 index 0000000..832fba7 --- /dev/null +++ b/db/migrate/20260428120000_add_degree_name_to_committees.rb @@ -0,0 +1,5 @@ +class AddDegreeNameToCommittees < ActiveRecord::Migration[7.2] + def change + add_column :committees, :degree_name, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index acc3a25..f82564d 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.2].define(version: 2026_03_26_000000) do - create_table "authors", charset: "utf8mb4", force: :cascade do |t| +ActiveRecord::Schema[7.2].define(version: 2026_04_28_120000) do + create_table "authors", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.string "f_name" t.string "m_name" t.string "l_name" @@ -19,7 +19,7 @@ t.index ["work_id"], name: "fk_rails_ef7807179c" end - create_table "com_efforts", charset: "utf8mb4", force: :cascade do |t| + create_table "com_efforts", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false t.string "com_id" @@ -35,7 +35,7 @@ t.index ["faculty_id"], name: "fk_rails_c1c0816923" end - create_table "com_qualities", charset: "utf8mb4", force: :cascade do |t| + create_table "com_qualities", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false t.string "com_id" @@ -51,7 +51,7 @@ t.index ["faculty_id"], name: "fk_rails_5da34f5b2e" end - create_table "committees", charset: "utf8mb4", force: :cascade do |t| + create_table "committees", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.bigint "faculty_id", null: false t.string "student_fname" t.string "student_mname" @@ -64,10 +64,14 @@ t.string "stage_of_completion" t.integer "start_year" t.integer "completion_year" + t.integer "start_month" + t.integer "completion_month" + t.string "role_other" + t.string "degree_name" t.index ["faculty_id"], name: "index_committees_on_faculty_id" end - create_table "contract_faculty_links", charset: "utf8mb4", force: :cascade do |t| + create_table "contract_faculty_links", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.string "role" t.integer "pct_credit" t.bigint "contract_id" @@ -76,7 +80,7 @@ t.index ["faculty_id"], name: "fk_rails_7f7c136a9d" end - create_table "contracts", charset: "utf8mb4", force: :cascade do |t| + create_table "contracts", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.integer "osp_key" t.string "title" t.bigint "sponsor_id" @@ -98,7 +102,7 @@ t.index ["sponsor_id"], name: "fk_rails_918599a14c" end - create_table "courses", charset: "utf8mb4", force: :cascade do |t| + create_table "courses", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.integer "academic_course_id" t.string "term" t.integer "calendar_year" @@ -107,7 +111,7 @@ t.index ["academic_course_id", "term", "calendar_year"], name: "index_courses_on_academic_course_id_and_term_and_calendar_year", unique: true end - create_table "editors", charset: "utf8mb4", force: :cascade do |t| + create_table "editors", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.string "f_name" t.string "m_name" t.string "l_name" @@ -115,7 +119,7 @@ t.index ["work_id"], name: "fk_rails_6c877ed7df" end - create_table "external_authors", charset: "utf8mb4", force: :cascade do |t| + create_table "external_authors", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.bigint "publication_id" t.string "f_name" t.string "m_name" @@ -127,7 +131,7 @@ t.index ["publication_id"], name: "fk_rails_eb03e1acd5" end - create_table "faculties", charset: "utf8mb4", force: :cascade do |t| + create_table "faculties", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.string "access_id" t.bigint "user_id" t.string "f_name" @@ -141,14 +145,14 @@ t.index ["access_id"], name: "index_faculties_on_access_id", unique: true end - create_table "integrations", charset: "utf8mb4", force: :cascade do |t| + create_table "integrations", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.string "process_type" t.boolean "is_active" t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false end - create_table "personal_contacts", charset: "utf8mb4", force: :cascade do |t| + create_table "personal_contacts", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.bigint "faculty_id", null: false t.string "telephone_number" t.string "postal_address" @@ -167,7 +171,7 @@ t.index ["faculty_id"], name: "index_personal_contacts_on_faculty_id", unique: true end - create_table "presentation_contributors", charset: "utf8mb4", force: :cascade do |t| + create_table "presentation_contributors", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.bigint "presentation_id", null: false t.string "f_name" t.string "m_name" @@ -175,7 +179,7 @@ t.index ["presentation_id"], name: "index_presentation_contributors_on_presentation_id" end - create_table "presentations", charset: "utf8mb4", force: :cascade do |t| + create_table "presentations", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.bigint "faculty_id", null: false t.string "title" t.string "dty_date" @@ -185,7 +189,7 @@ t.index ["faculty_id"], name: "index_presentations_on_faculty_id" end - create_table "publication_faculty_links", charset: "utf8mb4", force: :cascade do |t| + create_table "publication_faculty_links", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.bigint "faculty_id" t.bigint "publication_id" t.string "category" @@ -197,14 +201,14 @@ t.index ["publication_id"], name: "fk_rails_7abcf28acb" end - create_table "publication_listings", charset: "utf8mb4", force: :cascade do |t| + create_table "publication_listings", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.string "name" t.string "type" t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false end - create_table "publications", charset: "utf8mb4", force: :cascade do |t| + create_table "publications", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.text "title" t.integer "volume" t.integer "dty" @@ -231,7 +235,7 @@ t.bigint "rmd_id" end - create_table "sections", charset: "utf8mb4", force: :cascade do |t| + create_table "sections", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.string "class_campus_code" t.string "cross_listed_flag" t.string "course_number" @@ -253,13 +257,13 @@ t.index ["faculty_id", "course_id", "class_campus_code", "subject_code", "course_number", "course_suffix", "class_section_code", "course_component"], name: "pkey", unique: true, length: { class_campus_code: 50, subject_code: 50, course_suffix: 50, class_section_code: 50, course_component: 50 } end - create_table "sponsors", charset: "utf8mb4", force: :cascade do |t| + create_table "sponsors", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.string "sponsor_name" t.string "sponsor_type" t.index ["sponsor_name"], name: "index_sponsors_on_sponsor_name", unique: true end - create_table "works", charset: "utf8mb4", force: :cascade do |t| + create_table "works", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.bigint "publication_listing_id" t.text "title" t.string "journal" @@ -290,7 +294,7 @@ t.index ["publication_listing_id"], name: "index_works_on_publication_listing_id" end - create_table "yearlies", charset: "utf8mb4", force: :cascade do |t| + create_table "yearlies", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.bigint "faculty_id" t.string "academic_year" t.string "campus" diff --git a/spec/importers/committee_data/committee_xml_builder_spec.rb b/spec/importers/committee_data/committee_xml_builder_spec.rb index 0e4cfa5..a30e0b4 100644 --- a/spec/importers/committee_data/committee_xml_builder_spec.rb +++ b/spec/importers/committee_data/committee_xml_builder_spec.rb @@ -14,9 +14,12 @@ role: 'Mentor', thesis_title: 'Test Title', type_of_work: 'Ph.D. Dissertation Committee', + degree_name: 'PhD', stage_of_completion: 'Completed', start_year: 2024, - completion_year: 2026 + start_month: 8, + completion_year: 2026, + completion_month: 1 ) expect(xml_builder_obj.xmls_enumerator.first).to eq( @@ -24,15 +27,18 @@ - Mentor - Ph.D. Dissertation Committee - Completed - 2024 - 2026 + Mentor + Ph.D. Dissertation Committee + Completed + August + 2024 + January + 2026 - Test - User - Test Title + Test + User + PhD + Test Title @@ -41,6 +47,38 @@ ) end + it 'emits DEG when degree_name is present' do + faculty = FactoryBot.create(:faculty, access_id: 'test123') + + Committee.create!( + faculty: faculty, + student_fname: 'Jane', + student_lname: 'Doe', + role: 'Mentor', + thesis_title: 'A Title', + degree_name: 'MS' + ) + + xml = xml_builder_obj.xmls_enumerator.first + expect(xml).to include('MS') + end + + it 'omits DEG when degree_name is nil' do + faculty = FactoryBot.create(:faculty, access_id: 'test123') + + Committee.create!( + faculty: faculty, + student_fname: 'Jane', + student_lname: 'Doe', + role: 'Mentor', + thesis_title: 'A Title', + degree_name: nil + ) + + xml = xml_builder_obj.xmls_enumerator.first + expect(xml).not_to include('') - expect(xml).not_to include('') - expect(xml).to include('In Process') + expect(xml).to include('') + expect(xml).not_to include('In Process') + end + + it 'emits only end-date tags when start and end dates are the same year and month' do + faculty = FactoryBot.create(:faculty, access_id: 'test123') + + Committee.create!( + faculty: faculty, + student_fname: 'Test', + student_lname: 'User', + role: 'Mentor', + thesis_title: 'Test Title', + start_year: 2025, + start_month: 3, + completion_year: 2025, + completion_month: 3 + ) + + xml = xml_builder_obj.xmls_enumerator.first + expect(xml).not_to include('March') + expect(xml).to include('2025') + end + + it 'emits all four date tags when year matches but months differ' do + faculty = FactoryBot.create(:faculty, access_id: 'test123') + + Committee.create!( + faculty: faculty, + student_fname: 'Test', + student_lname: 'User', + role: 'Mentor', + thesis_title: 'Test Title', + start_year: 2025, + start_month: 3, + completion_year: 2025, + completion_month: 11 + ) + + xml = xml_builder_obj.xmls_enumerator.first + expect(xml).to include('March') + expect(xml).to include('2025') + expect(xml).to include('November') + expect(xml).to include('2025') + end + + it 'emits all four date tags when years differ' do + faculty = FactoryBot.create(:faculty, access_id: 'test123') + + Committee.create!( + faculty: faculty, + student_fname: 'Test', + student_lname: 'User', + role: 'Mentor', + thesis_title: 'Test Title', + start_year: 2024, + start_month: 8, + completion_year: 2026, + completion_month: 1 + ) + + xml = xml_builder_obj.xmls_enumerator.first + expect(xml).to include('August') + expect(xml).to include('2024') + expect(xml).to include('January') + expect(xml).to include('2026') + end + + it 'emits only DTY_END when months are nil and years are the same' do + faculty = FactoryBot.create(:faculty, access_id: 'test123') + + Committee.create!( + faculty: faculty, + student_fname: 'Test', + student_lname: 'User', + role: 'Mentor', + thesis_title: 'Test Title', + start_year: 2025, + start_month: nil, + completion_year: 2025, + completion_month: nil + ) + + xml = xml_builder_obj.xmls_enumerator.first + expect(xml).not_to include('2025') + end + + it 'emits both years and no month tags when months are nil and years differ' do + faculty = FactoryBot.create(:faculty, access_id: 'test123') + + Committee.create!( + faculty: faculty, + student_fname: 'Test', + student_lname: 'User', + role: 'Mentor', + thesis_title: 'Test Title', + start_year: 2024, + start_month: nil, + completion_year: 2026, + completion_month: nil + ) + + xml = xml_builder_obj.xmls_enumerator.first + expect(xml).to include('2024') + expect(xml).to include('2026') + expect(xml).not_to include('').count).to eq(2) end + + it 'emits ROLE_OTHER when role is Other and role_other is present' do + faculty = FactoryBot.create(:faculty, access_id: 'test123') + + Committee.create!( + faculty: faculty, + student_fname: 'Jane', + student_lname: 'Doe', + role: 'Other', + role_other: 'Support Faculty', + thesis_title: 'Some Title', + type_of_work: 'Dissertation Committee', + stage_of_completion: 'In Process', + start_year: 2025, + start_month: 1 + ) + + xml = xml_builder_obj.xmls_enumerator.first + expect(xml).to include('Other') + expect(xml).to include('Support Faculty') + end + + it 'does not emit ROLE_OTHER when role_other is blank' do + faculty = FactoryBot.create(:faculty, access_id: 'test123') + + Committee.create!( + faculty: faculty, + student_fname: 'Jane', + student_lname: 'Doe', + role: 'Mentor', + role_other: nil, + thesis_title: 'Some Title', + type_of_work: 'Dissertation Committee', + stage_of_completion: 'In Process', + start_year: 2025, + start_month: 1 + ) + + xml = xml_builder_obj.xmls_enumerator.first + expect(xml).not_to include(' [ { 'student_fname' => 'Spider', 'student_lname' => 'Man', 'role' => 'advisor', 'title' => 'My Thesis', 'degree_type' => 'Dissertation', + 'degree_name' => 'PhD', 'approval_started_at' => 1.month.ago.iso8601, 'final_submission_approved_at' => '2026-01-15T10:30:00Z', 'submission_status' => 'released for publication' } @@ -36,9 +37,52 @@ expect(Committee.last.role).to eq('Advisor') expect(Committee.last.thesis_title).to eq('My Thesis') expect(Committee.last.type_of_work).to eq('Dissertation Committee') + expect(Committee.last.degree_name).to eq('PhD') expect(Committee.last.stage_of_completion).to eq('Completed') expect(Committee.last.start_year).to eq(1.month.ago.year) + expect(Committee.last.start_month).to eq(1.month.ago.month) expect(Committee.last.completion_year).to eq(2026) + expect(Committee.last.completion_month).to eq(1) + end + end + + context 'when degree_name is nil in the API response' do + let(:api_response) do + { data: { 'committees' => [ + { 'student_fname' => 'Spider', 'student_lname' => 'Man', + 'role' => 'advisor', 'title' => 'My Thesis', 'degree_type' => 'Dissertation', + 'degree_name' => nil, + 'approval_started_at' => 1.month.ago.iso8601, + 'final_submission_approved_at' => '2026-01-15T10:30:00Z', + 'submission_status' => 'released for publication' } + ] } } + end + + before { allow(client).to receive(:faculty_committees).and_return(api_response) } + + it 'stores nil for degree_name' do + importer.import_all + expect(Committee.last.degree_name).to be_nil + end + end + + context 'when final_submission_approved_at is nil' do + let(:api_response) do + { data: { 'committees' => [ + { 'student_fname' => 'Spider', 'student_lname' => 'Man', + 'role' => 'advisor', 'title' => 'My Thesis', 'degree_type' => 'Dissertation', + 'approval_started_at' => 1.month.ago.iso8601, + 'final_submission_approved_at' => nil, + 'submission_status' => 'waiting for publication release' } + ] } } + end + + before { allow(client).to receive(:faculty_committees).and_return(api_response) } + + it 'stores nil for completion_year and completion_month' do + importer.import_all + expect(Committee.last.completion_year).to be_nil + expect(Committee.last.completion_month).to be_nil end end end @@ -98,6 +142,34 @@ end end + describe '#extract_month' do + subject(:extract) { importer.send(:extract_month, date_string) } + + context 'with a valid ISO8601 date string' do + let(:date_string) { '2026-01-15T10:30:00Z' } + + it { is_expected.to eq(1) } + end + + context 'with nil' do + let(:date_string) { nil } + + it { is_expected.to be_nil } + end + + context 'with an empty string' do + let(:date_string) { '' } + + it { is_expected.to be_nil } + end + + context 'with an invalid date string' do + let(:date_string) { 'not-a-date' } + + it { is_expected.to be_nil } + end + end + describe '#within_last_six_months?' do it 'returns true for a date within the last 6 months' do recent_date = 1.month.ago.iso8601 diff --git a/spec/services/committee_role_normalizer_spec.rb b/spec/services/committee_role_normalizer_spec.rb index e02833c..23827da 100644 --- a/spec/services/committee_role_normalizer_spec.rb +++ b/spec/services/committee_role_normalizer_spec.rb @@ -5,43 +5,43 @@ it 'maps co-chair roles correctly' do expect( described_class.normalize('Co-Chair & Dissertation Advisor') - ).to eq('Co-Chairperson') + ).to eq(['Co-Chairperson', nil]) end it 'maps chair roles correctly' do expect( described_class.normalize('Chair of Committee') - ).to eq('Chairperson') + ).to eq(['Chairperson', nil]) end it 'prioritizes chair over advisor when both appear' do expect( described_class.normalize('Chair & Dissertation Advisor') - ).to eq('Chairperson') + ).to eq(['Chairperson', nil]) end it 'maps advisor roles' do expect( described_class.normalize('Dissertation Advisr') - ).to eq('Advisor') + ).to eq(['Advisor', nil]) end it 'maps member and representative roles' do expect( described_class.normalize('Committee Member & Dean Grad Sch Rep') - ).to eq('Member') + ).to eq(['Member', nil]) end - it 'returns Other for unknown roles' do + it 'returns Other with original text for unknown roles' do expect( described_class.normalize('Some Weird ETDA Thing') - ).to eq('Other') + ).to eq(['Other', 'Some Weird ETDA Thing']) end - it 'returns Other for blank values' do + it 'returns Other with nil text for blank values' do expect( described_class.normalize(nil) - ).to eq('Other') + ).to eq(['Other', nil]) end end end