Phase 18.2 introduces the new education data foundation used by the migration from the legacy Degree -> Major -> College chain to Education -> EducationAreaOfStudy.
This is a backend/schema release only. No user-facing read paths were migrated in 18.2.
educationseducation_areas_of_studyEducationEducationAreaOfStudyAlumni has_many :educations (ordered by date_issued desc)Alumni has_many :education_areas_of_study, through: :educationseducations.buid references alumni.buideducations (buid, source_education_id)education_areas_of_study.person_area_of_study_idEducation#degree_level auto-derived from degree_code:
A*, B* -> undergraduateM* -> mastersD*, J*, P* -> doctorateunknownEducationAreaOfStudy#area_of_study_name_normalized auto-derived by lowercasing, trimming, and collapsing whitespaceEducationAreaOfStudy#concentration_level normalized to lowercaseEducation during migration:
graduate -> mastersprofessional -> doctorate20260422093100_create_educations20260422093200_create_education_areas_of_study20260422100500_remap_education_degree_levelsModel tests added:
test/models/education_test.rbtest/models/education_area_of_study_test.rbCoverage includes:
docs/planning/champion-portal/phases/phase-18/README.mddocs/planning/champion-portal/qa/PHASE_18_LAUNCH_GUIDE.mddocs/planning/champion-portal/phases/phase-18/samples/README.mdeducationsexpected_graduation_year (integer) — populated from the “Preferred Year” CRM column when date_issued is NULL (in-progress row)student_status (string) — per-record CRM status: awarded, pending, withdrawn, or nilEducation.in_progress ScopeThe canonical single source of truth for current enrollment:
scope :in_progress, -> {
where(date_issued: nil)
.where("educations.student_status IS NULL OR educations.student_status = 'pending'")
}
The column is table-qualified (educations.student_status) to avoid PG::AmbiguousColumn when joined to alumni, which also has student_status. Never use where(student_status: ...) in a scope that may be composed with an alumni join.
Education::ENROLLMENT_COLLEGE_CODE_SQLCanonical COALESCE SQL constant resolving an in-progress row’s college code:
ENROLLMENT_COLLEGE_CODE_SQL = <<~SQL.squish
COALESCE(
NULLIF(NULLIF(educations.current_school_code, ''), '00'),
educations.granting_school_code
)
SQL
Use this constant anywhere a query needs the “effective college” for an in-progress education row.
All Alumni enrollment methods now delegate to Education.in_progress rather than reading alumni.current_school_code and siblings:
# Scopes
scope :currently_enrolled, -> { joins(:educations).merge(Education.in_progress).distinct }
scope :current_students, -> { currently_enrolled.where.not(buid: Education::AggregateScope.buid_subquery) }
# Instance methods
def currently_enrolled? = educations.merge(Education.in_progress).exists?
alias current_student? currently_enrolled?
def current_enrollment_education = educations.merge(Education.in_progress).order(created_at: :desc).first
Csv::EducationImporter Phase 18.8 AdditionsTwo new private methods called after each education save:
apply_student_status_ratchet!(buid, student_status) — enforces one-way awarded rule on alumni.student_statusclear_legacy_enrollment_fields!(buid) — when an awarded row lands (date_issued present), blanks the 4 legacy alumni enrollment columns| Migration | Purpose |
|---|---|
20260528191923_add_honorary_degree_college |
Seeds the honorary-degree college row so education.college always resolves |
20260528221509_add_current_student_fields_to_educations |
Adds expected_graduation_year (integer) + student_status (string) with indexes |
The following are intentionally deferred to a second deploy to allow safe rollout:
current_school_code, current_program_desc, intended_degree_code, expected_graduation_year from alumniCsv::AffinaquestContactImporter#clear_enrollment_fields_if_awarded! (only safe to drop after the alumni columns are gone)Csv::CurrentStudentImporter — fully deleted (service file, controller action, background jobs, views, rake tasks)EducationImportBatch variants that drove the current-student workflow