Files

749 lines
20 KiB
Go

package directory
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"regexp"
"sort"
"strings"
"heliosian/internal/data"
)
const appName = "directory"
var gradeOrder = []string{
"Kindergarten", "Grade 1", "Grade 2", "Grade 3", "Grade 4",
"Grade 5", "Grade 6", "Grade 7", "Grade 8",
}
var gradeBands = map[string]string{
"Kindergarten": "Hummingbirds",
"Grade 1": "Halcons",
"Grade 2": "Halcons",
"Grade 3": "Jayvens",
"Grade 4": "Jayvens",
"Grade 5": "Cospreys",
"Grade 6": "Cospreys",
"Grade 7": "Hegrets",
"Grade 8": "Hegrets",
}
var departmentOrder = []string{
"Admin and Office Staff",
"Co-Curriculars and Specialists",
"Classroom Teachers",
"Facilities Staff",
}
var importColumns = []string{
"entry_sort_name", "student_full_name", "student_classifications", "student_email", "student_phone_mobile",
}
var overrideColumns = []string{
"Email", "Added", "Full Name", "Legal Name", "Preferred Name",
"Is Student", "Is Parent", "Is Staff", "New to Helios", "Pronouns", "Facts",
"Grade", "Classroom", "Crew", "Phone", "Job Title", "Department", "Grade Band", "Room Parent",
"Address", "Family Phone", "Family Photo Caption", "Opted Out",
}
type BlobChecker interface {
Has(key string) bool
}
type parsedName struct {
display, legal, preferred string
}
var nameForm = regexp.MustCompile(`^(.+?) \((.+?)\) (.+)$`)
func parseName(raw string) parsedName {
raw = strings.Join(strings.Fields(raw), " ")
m := nameForm.FindStringSubmatch(raw)
if m == nil {
return parsedName{display: raw, legal: raw}
}
return parsedName{display: m[1] + " " + m[3], legal: m[2] + " " + m[3], preferred: m[1]}
}
func splitHomeroom(homeroom string) (classroom, crew string) {
fields := strings.Fields(homeroom)
if len(fields) == 1 {
return homeroom, ""
}
return fields[len(fields)-1], strings.Join(fields[:len(fields)-1], " ")
}
func normName(raw string) string {
return strings.ToLower(strings.Join(strings.Fields(raw), " "))
}
type household struct {
adults []string
kids []string
address string
phone string
}
type familyCells struct {
address, phone, caption string
hasAddress, hasPhone, hasCaption bool
}
func requireColumns(table string, header, wanted []string) error {
present := map[string]bool{}
for _, h := range header {
present[h] = true
}
for _, w := range wanted {
if !present[w] {
return fmt.Errorf("table %s is missing column %q", table, w)
}
}
return nil
}
func familyHash(members []string) string {
sorted := append([]string{}, members...)
sort.Strings(sorted)
sum := sha256.Sum256([]byte(strings.Join(sorted, "\n")))
return hex.EncodeToString(sum[:])[:16]
}
type loader struct {
blobs BlobChecker
static BlobChecker
importRows []map[string]string
overrideRows []map[string]string
nameToEmail map[string]string
people map[string]*Person
order []string
households map[string]*household
householdOrder []string
personHouseholds map[string][]string
familyKeys map[string]string
familyOverrides map[string]familyCells
roomParents map[string][]string
optedOut map[string]bool
model *Model
}
func LoadModel(source data.Source, blobs, static BlobChecker) (*Model, error) {
l := &loader{
blobs: blobs,
static: static,
people: map[string]*Person{},
households: map[string]*household{},
personHouseholds: map[string][]string{},
familyKeys: map[string]string{},
familyOverrides: map[string]familyCells{},
roomParents: map[string][]string{},
optedOut: map[string]bool{},
model: &Model{Families: map[string]Family{}, RoomParents: map[string][]string{}},
}
steps := []func() error{
func() error { return l.readTables(source) },
l.transformImport,
l.applyOverrides,
l.buildFamilies,
l.removeOptedOut,
l.attachBlobs,
l.sortPeople,
l.deriveClassrooms,
l.deriveStructure,
}
for _, step := range steps {
if err := step(); err != nil {
return nil, err
}
}
return l.model, nil
}
func (l *loader) readTables(source data.Source) error {
importHeader, importRows, err := source.Table(appName, "Veracross Import")
if err != nil {
return err
}
if err := requireColumns("Veracross Import", importHeader, importColumns); err != nil {
return err
}
l.importRows = importRows
mapHeader, mapRows, err := source.Table(appName, "Name to Email")
if err != nil {
return err
}
if err := requireColumns("Name to Email", mapHeader, []string{"Name", "Email"}); err != nil {
return err
}
overrideHeader, overrideRows, err := source.Table(appName, "Overrides")
if err != nil {
return err
}
if err := requireColumns("Overrides", overrideHeader, overrideColumns); err != nil {
return err
}
l.overrideRows = overrideRows
l.nameToEmail = map[string]string{}
for _, row := range mapRows {
name, email := normName(row["Name"]), strings.ToLower(row["Email"])
if name == "" || email == "" {
return fmt.Errorf("name to email row %v is incomplete", row)
}
if _, ok := l.nameToEmail[name]; ok {
return fmt.Errorf("name to email has duplicate name %q", row["Name"])
}
l.nameToEmail[name] = email
}
return nil
}
func (l *loader) addAdult(rawName, email, phone string) error {
n := parseName(rawName)
if p, ok := l.people[email]; ok {
if p.FullName != n.display || p.LegalName != n.legal || p.PreferredName != n.preferred {
return fmt.Errorf("adult %s has conflicting names %q and %q", email, p.FullName, rawName)
}
if p.Phone != "" && phone != "" && p.Phone != phone {
return fmt.Errorf("adult %s has conflicting phones %q and %q", email, p.Phone, phone)
}
if p.Phone == "" {
p.Phone = phone
}
p.IsParent = true
return nil
}
l.people[email] = &Person{
Email: email, FullName: n.display, LegalName: n.legal, PreferredName: n.preferred,
Phone: phone, IsParent: true,
}
l.order = append(l.order, email)
return nil
}
func (l *loader) transformImport() error {
mappingUses := map[string]int{}
for _, row := range l.importRows {
rawName := row["student_full_name"]
if rawName == "" {
return fmt.Errorf("import row %v has no student name", row)
}
var classifications struct {
GradeLevel string `json:"grade_level"`
Homeroom string `json:"homeroom"`
}
if err := json.Unmarshal([]byte(row["student_classifications"]), &classifications); err != nil {
return fmt.Errorf("student %s classifications: %w", rawName, err)
}
if gradeBands[classifications.GradeLevel] == "" {
return fmt.Errorf("student %s has unknown grade %q", rawName, classifications.GradeLevel)
}
if classifications.Homeroom == "" {
return fmt.Errorf("student %s has no homeroom", rawName)
}
classroom, crew := splitHomeroom(classifications.Homeroom)
email := strings.ToLower(row["student_email"])
if mapped, ok := l.nameToEmail[normName(rawName)]; ok {
email = mapped
mappingUses[normName(rawName)]++
}
if email == "" {
return fmt.Errorf("student %s has no email and no name to email entry", rawName)
}
if _, ok := l.people[email]; ok {
return fmt.Errorf("student email %s appears twice", email)
}
n := parseName(rawName)
student := &Person{
Email: email, FullName: n.display, LegalName: n.legal, PreferredName: n.preferred,
IsStudent: true, Grade: classifications.GradeLevel, Classroom: classroom, Crew: crew,
Phone: row["student_phone_mobile"],
}
l.people[email] = student
l.order = append(l.order, email)
for _, hn := range []string{"1", "2"} {
adults := []string{}
for _, pn := range []string{"1", "2"} {
prefix := "household_" + hn + "_person_" + pn + "_"
if row[prefix+"full_name"] == "" {
continue
}
adultEmail := strings.ToLower(row[prefix+"email"])
if adultEmail == "" {
return fmt.Errorf("adult %q of student %s has no email", row[prefix+"full_name"], rawName)
}
phone := row[prefix+"phone_mobile"]
if phone == "" {
phone = row[prefix+"phone_business"]
}
if err := l.addAdult(row[prefix+"full_name"], adultEmail, phone); err != nil {
return err
}
adults = append(adults, adultEmail)
}
if len(adults) == 0 {
continue
}
address, phone := row["household_"+hn+"_address"], row["household_"+hn+"_phone"]
setKey := strings.Join(func() []string {
s := append([]string{}, adults...)
sort.Strings(s)
return s
}(), "\n")
hh, ok := l.households[setKey]
if !ok {
hh = &household{adults: adults, address: address, phone: phone}
l.households[setKey] = hh
l.householdOrder = append(l.householdOrder, setKey)
for _, a := range adults {
if len(l.personHouseholds[a]) > 0 {
return fmt.Errorf("adult %s belongs to more than one household", a)
}
l.personHouseholds[a] = append(l.personHouseholds[a], setKey)
}
} else if hh.address != address || hh.phone != phone {
return fmt.Errorf("household of %v has conflicting address or phone across rows", adults)
}
hh.kids = append(hh.kids, email)
l.personHouseholds[email] = append(l.personHouseholds[email], setKey)
student.ParentContactEmails = append(student.ParentContactEmails, adults...)
}
}
for name := range l.nameToEmail {
switch mappingUses[name] {
case 0:
return fmt.Errorf("name to email entry %q matches no import row", name)
case 1:
default:
return fmt.Errorf("name to email entry %q matches %d import rows", name, mappingUses[name])
}
}
return nil
}
func (l *loader) applyOverrides() error {
bandSet := map[string]bool{}
for _, band := range gradeBands {
bandSet[band] = true
}
seen := map[string]bool{}
for _, row := range l.overrideRows {
email := strings.ToLower(row["Email"])
if email == "" {
return fmt.Errorf("overrides row %v has no email", row)
}
if seen[email] {
return fmt.Errorf("overrides has duplicate email %s", email)
}
seen[email] = true
added := row["Added"] == "TRUE"
p, exists := l.people[email]
if added && exists {
return fmt.Errorf("overrides row %s is flagged added but the import covers this person", email)
}
if !added && !exists {
return fmt.Errorf("overrides row %s matches no imported person", email)
}
if added {
p = &Person{Email: email}
l.people[email] = p
l.order = append(l.order, email)
}
apply := func(cell string, field *string) {
switch cell {
case "":
case "-":
*field = ""
default:
*field = cell
}
}
applyBool := func(column string, field *bool) error {
switch row[column] {
case "":
case "-", "FALSE":
*field = false
case "TRUE":
*field = true
default:
return fmt.Errorf("overrides row %s has invalid %s %q", email, column, row[column])
}
return nil
}
apply(row["Full Name"], &p.FullName)
apply(row["Legal Name"], &p.LegalName)
apply(row["Preferred Name"], &p.PreferredName)
for column, field := range map[string]*bool{
"Is Student": &p.IsStudent, "Is Parent": &p.IsParent, "Is Staff": &p.IsStaff, "New to Helios": &p.IsNew,
} {
if err := applyBool(column, field); err != nil {
return err
}
}
apply(row["Pronouns"], &p.Pronouns)
apply(row["Facts"], &p.Facts)
if cell := row["Grade"]; cell != "" && cell != "-" && !added && gradeBands[cell] == "" {
return fmt.Errorf("overrides row %s has unknown grade %q", email, cell)
}
apply(row["Grade"], &p.Grade)
apply(row["Classroom"], &p.Classroom)
apply(row["Crew"], &p.Crew)
apply(row["Phone"], &p.Phone)
apply(row["Job Title"], &p.JobTitle)
apply(row["Department"], &p.Department)
if cell := row["Grade Band"]; cell != "" && cell != "-" && !bandSet[cell] {
return fmt.Errorf("overrides row %s has unknown grade band %q", email, cell)
}
apply(row["Grade Band"], &p.GradeBand)
if cell := row["Room Parent"]; cell != "" && cell != "-" {
if !bandSet[cell] {
return fmt.Errorf("overrides row %s has unknown room parent band %q", email, cell)
}
l.roomParents[cell] = append(l.roomParents[cell], email)
}
cells := familyCells{}
if cell := row["Address"]; cell != "" {
cells.hasAddress = true
if cell != "-" {
cells.address = cell
}
}
if cell := row["Family Phone"]; cell != "" {
cells.hasPhone = true
if cell != "-" {
cells.phone = cell
}
}
if cell := row["Family Photo Caption"]; cell != "" {
cells.hasCaption = true
if cell != "-" {
cells.caption = cell
}
}
if cells.hasAddress || cells.hasPhone || cells.hasCaption {
l.familyOverrides[email] = cells
}
switch row["Opted Out"] {
case "", "-", "FALSE":
case "TRUE":
l.optedOut[email] = true
default:
return fmt.Errorf("overrides row %s has invalid Opted Out %q", email, row["Opted Out"])
}
if added {
if p.FullName == "" {
return fmt.Errorf("added row %s has no full name", email)
}
if !p.IsStudent && !p.IsParent && !p.IsStaff {
return fmt.Errorf("added row %s has no role", email)
}
}
}
return nil
}
func (l *loader) buildFamilies() error {
for _, setKey := range l.householdOrder {
hh := l.households[setKey]
members := append(append([]string{}, hh.adults...), hh.kids...)
key := familyHash(members)
l.familyKeys[setKey] = key
l.model.Families[key] = Family{
Key: key,
Address: hh.address,
Phone: hh.phone,
AdultEmails: hh.adults,
KidEmails: hh.kids,
}
}
for email, sets := range l.personHouseholds {
if p := l.people[email]; p.FamilyKey == "" {
p.FamilyKey = l.familyKeys[sets[0]]
}
}
for email, cells := range l.familyOverrides {
p := l.people[email]
if !p.IsParent {
return fmt.Errorf("overrides row %s has family cells but %s is not a parent", email, email)
}
sets := l.personHouseholds[email]
if len(sets) != 1 {
return fmt.Errorf("overrides row %s has family cells but %s has no household", email, email)
}
key := l.familyKeys[sets[0]]
family := l.model.Families[key]
if cells.hasAddress {
family.Address = cells.address
}
if cells.hasPhone {
family.Phone = cells.phone
}
if cells.hasCaption {
family.PhotoCaption = cells.caption
}
l.model.Families[key] = family
}
return nil
}
func (l *loader) removeOptedOut() error {
for email := range l.optedOut {
delete(l.people, email)
}
kept := []string{}
for _, email := range l.order {
if !l.optedOut[email] {
kept = append(kept, email)
}
}
l.order = kept
for key, family := range l.model.Families {
family.AdultEmails = without(family.AdultEmails, l.optedOut)
family.KidEmails = without(family.KidEmails, l.optedOut)
if len(family.AdultEmails)+len(family.KidEmails) == 0 {
delete(l.model.Families, key)
continue
}
family.Name = familyNameFor(family, l.people)
l.model.Families[key] = family
}
for _, p := range l.people {
p.ParentContactEmails = without(p.ParentContactEmails, l.optedOut)
}
for band, emails := range l.roomParents {
l.roomParents[band] = without(emails, l.optedOut)
}
return nil
}
func (l *loader) attachBlobs() error {
if l.blobs == nil {
return nil
}
for _, p := range l.people {
local, _, _ := strings.Cut(p.Email, "@")
if l.blobs.Has("people/" + local + "-photo") {
p.PhotoURL = "/blob/people/" + local + "-photo"
}
if l.blobs.Has("people/" + local + "-pronunciation") {
p.PronunciationURL = "/blob/people/" + local + "-pronunciation"
}
}
for key, family := range l.model.Families {
if l.blobs.Has("families/" + key + "-photo") {
family.PhotoURL = "/blob/families/" + key + "-photo"
}
if l.blobs.Has("families/" + key + "-pronunciation") {
family.PronunciationURL = "/blob/families/" + key + "-pronunciation"
}
l.model.Families[key] = family
}
return nil
}
func (l *loader) sortPeople() error {
for _, email := range l.order {
l.model.People = append(l.model.People, *l.people[email])
}
sort.Slice(l.model.People, func(i, j int) bool {
si, sj := surname(l.model.People[i].FullName), surname(l.model.People[j].FullName)
if si != sj {
return si < sj
}
return l.model.People[i].FullName < l.model.People[j].FullName
})
l.model.byEmail = map[string]int{}
for i, p := range l.model.People {
l.model.byEmail[p.Email] = i
}
return nil
}
type classroomInfo struct {
crews map[string]bool
minGrade int
bands map[string]bool
}
func (l *loader) deriveClassrooms() error {
model := l.model
classrooms := map[string]*classroomInfo{}
for _, p := range model.People {
if p.Classroom == "" || !p.IsStudent || gradeBands[p.Grade] == "" {
continue
}
info, ok := classrooms[p.Classroom]
if !ok {
info = &classroomInfo{crews: map[string]bool{}, minGrade: len(gradeOrder), bands: map[string]bool{}}
classrooms[p.Classroom] = info
}
if p.Crew != "" {
info.crews[p.Crew] = true
}
for i, g := range gradeOrder {
if g == p.Grade && i < info.minGrade {
info.minGrade = i
}
}
info.bands[gradeBands[p.Grade]] = true
}
classroomNames := []string{}
for name := range classrooms {
classroomNames = append(classroomNames, name)
}
sort.Slice(classroomNames, func(i, j int) bool {
ci, cj := classrooms[classroomNames[i]], classrooms[classroomNames[j]]
if ci.minGrade != cj.minGrade {
return ci.minGrade < cj.minGrade
}
return classroomNames[i] < classroomNames[j]
})
for _, name := range classroomNames {
info := classrooms[name]
if len(info.bands) != 1 {
return fmt.Errorf("classroom %s spans multiple grade bands", name)
}
imageURL := ""
imageKey := "brand/classrooms/classroom-" + strings.ToLower(name) + ".jpg"
if l.static.Has(imageKey) {
imageURL = "/static/" + imageKey
}
model.Classrooms = append(model.Classrooms, Classroom{
Name: name,
ImageURL: imageURL,
HasCrews: len(info.crews) > 0,
})
}
for _, p := range model.People {
if !p.IsStaff || p.Classroom == "" {
continue
}
info, ok := classrooms[p.Classroom]
if !ok {
return fmt.Errorf("staff %s is assigned to unknown classroom %q", p.Email, p.Classroom)
}
if p.Crew != "" && !info.crews[p.Crew] {
return fmt.Errorf("staff %s is assigned to unknown crew %q of %s", p.Email, p.Crew, p.Classroom)
}
}
for _, name := range classroomNames {
info := classrooms[name]
band := ""
for b := range info.bands {
band = b
}
crews := []string{}
for crew := range info.crews {
crews = append(crews, crew)
}
sort.Strings(crews)
if len(crews) == 0 {
crews = []string{""}
}
for _, crewName := range crews {
crew := Crew{Classroom: name, Name: crewName, GradeBand: band}
for _, p := range model.People {
if p.IsStaff && p.Classroom == name && p.Crew == crewName {
crew.Teachers = append(crew.Teachers, p.Email)
}
}
model.Crews = append(model.Crews, crew)
}
}
return nil
}
func (l *loader) deriveStructure() error {
for i, grade := range gradeOrder {
g := Grade{Name: grade, Band: gradeBands[grade]}
if i+1 < len(gradeOrder) {
g.NextName = gradeOrder[i+1]
g.NextBand = gradeBands[g.NextName]
}
l.model.Grades = append(l.model.Grades, g)
}
for band, emails := range l.roomParents {
l.model.RoomParents[bandLabel(band)] = emails
}
l.model.Departments = append(l.model.Departments, departmentOrder...)
return nil
}
func bandLabel(band string) string {
if band == "Hummingbirds" {
return "K"
}
labels := []string{}
for _, grade := range gradeOrder {
if gradeBands[grade] != band {
continue
}
number := strings.TrimPrefix(grade, "Grade ")
switch number {
case "1":
labels = append(labels, "1st")
case "2":
labels = append(labels, "2nd")
case "3":
labels = append(labels, "3rd")
default:
labels = append(labels, number+"th")
}
}
return strings.Join(labels, " / ")
}
func without(list []string, drop map[string]bool) []string {
kept := []string{}
for _, item := range list {
if !drop[item] {
kept = append(kept, item)
}
}
return kept
}
func surname(fullName string) string {
fields := strings.Fields(fullName)
if len(fields) == 0 {
return ""
}
return fields[len(fields)-1]
}
func familyNameFor(f Family, people map[string]*Person) string {
members := append(append([]string{}, f.KidEmails...), f.AdultEmails...)
seen := map[string]bool{}
names := []string{}
for _, email := range members {
p, ok := people[email]
if !ok {
continue
}
s := surname(p.FullName)
if s == "" || seen[s] {
continue
}
seen[s] = true
names = append(names, s)
}
if len(names) == 0 {
return ""
}
return strings.Join(names, " & ") + " Family"
}