Load the directory model from the layered Veracross import

This commit is contained in:
Ian Gulliver
2026-08-16 10:13:05 -07:00
parent 516ef2dea0
commit bbff936f39
11 changed files with 735 additions and 223 deletions
+7
View File
@@ -231,6 +231,13 @@ func (s *Store) Refresh() error {
return s.refresh() return s.refresh()
} }
func (s *Store) Has(key string) bool {
s.mu.RLock()
defer s.mu.RUnlock()
_, ok := s.entries[key]
return ok
}
func (s *Store) Upload(folder, base, ext, mimeType string, content []byte) (string, error) { func (s *Store) Upload(folder, base, ext, mimeType string, content []byte) (string, error) {
folderID, err := s.subfolder(folder) folderID, err := s.subfolder(folder)
if err != nil { if err != nil {
+11 -16
View File
@@ -3,40 +3,35 @@ package data
import ( import (
"encoding/csv" "encoding/csv"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
) )
type Source interface { type Source interface {
Table(app, name string) ([]map[string]string, error) Table(app, name string) ([]string, []map[string]string, error)
} }
type Dir struct { type Dir struct {
Root string Root string
} }
func (d Dir) Table(app, name string) ([]map[string]string, error) { func (d Dir) Table(app, name string) ([]string, []map[string]string, error) {
f, err := os.Open(filepath.Join(d.Root, app, name+".csv")) f, err := os.Open(filepath.Join(d.Root, app, name+".csv"))
if err != nil { if err != nil {
return nil, err return nil, nil, err
} }
defer f.Close() defer f.Close()
rows, err := csv.NewReader(f).ReadAll() rows, err := csv.NewReader(f).ReadAll()
if err != nil { if err != nil {
return nil, err return nil, nil, err
} }
if len(rows) == 0 { values := make([][]interface{}, len(rows))
return nil, fmt.Errorf("table %s/%s has no header row", app, name) for i, row := range rows {
} cells := make([]interface{}, len(row))
header := rows[0] for j, cell := range row {
records := []map[string]string{} cells[j] = cell
for _, row := range rows[1:] {
record := map[string]string{}
for i, column := range header {
record[column] = row[i]
} }
records = append(records, record) values[i] = cells
} }
return records, nil return parseTable(name, values)
} }
+33 -13
View File
@@ -26,24 +26,24 @@ func NewSheet(spreadsheets map[string]string) (*Sheet, error) {
return &Sheet{service: service, spreadsheets: spreadsheets}, nil return &Sheet{service: service, spreadsheets: spreadsheets}, nil
} }
func (s *Sheet) Table(app, name string) ([]map[string]string, error) { func (s *Sheet) Table(app, name string) ([]string, []map[string]string, error) {
id, ok := s.spreadsheets[app] id, ok := s.spreadsheets[app]
if !ok { if !ok {
return nil, fmt.Errorf("no spreadsheet configured for app %q", app) return nil, nil, fmt.Errorf("no spreadsheet configured for app %q", app)
} }
resp, err := s.service.Spreadsheets.Values.Get(id, "'"+strings.ReplaceAll(name, "'", "''")+"'").Do() resp, err := s.service.Spreadsheets.Values.Get(id, quoteTab(name)).Do()
if err != nil { if err != nil {
return nil, err return nil, nil, err
} }
return toRecords(resp.Values), nil return parseTable(name, resp.Values)
} }
func (s *Sheet) SetColumn(app, table, keyColumn, keyValue, column, value string) error { func (s *Sheet) Upsert(app, table, keyColumn, keyValue, column, value string) error {
id, ok := s.spreadsheets[app] id, ok := s.spreadsheets[app]
if !ok { if !ok {
return fmt.Errorf("no spreadsheet configured for app %q", app) return fmt.Errorf("no spreadsheet configured for app %q", app)
} }
quoted := "'" + strings.ReplaceAll(table, "'", "''") + "'" quoted := quoteTab(table)
resp, err := s.service.Spreadsheets.Values.Get(id, quoted).Do() resp, err := s.service.Spreadsheets.Values.Get(id, quoted).Do()
if err != nil { if err != nil {
return err return err
@@ -74,7 +74,17 @@ func (s *Sheet) SetColumn(app, table, keyColumn, keyValue, column, value string)
}) })
} }
if len(ranges) == 0 { if len(ranges) == 0 {
return fmt.Errorf("no row in %s has %s = %q", table, keyColumn, keyValue) width := max(keyIdx, colIdx) + 1
row := make([]interface{}, width)
for i := range row {
row[i] = ""
}
row[keyIdx] = keyValue
row[colIdx] = value
_, err := s.service.Spreadsheets.Values.Append(id, quoted, &sheets.ValueRange{
Values: [][]interface{}{row},
}).ValueInputOption("RAW").InsertDataOption("INSERT_ROWS").Do()
return err
} }
_, err = s.service.Spreadsheets.Values.BatchUpdate(id, &sheets.BatchUpdateValuesRequest{ _, err = s.service.Spreadsheets.Values.BatchUpdate(id, &sheets.BatchUpdateValuesRequest{
ValueInputOption: "RAW", ValueInputOption: "RAW",
@@ -99,7 +109,7 @@ func (s *Sheet) Append(app, table string, header, row []string) error {
break break
} }
} }
quoted := "'" + strings.ReplaceAll(table, "'", "''") + "'" quoted := quoteTab(table)
if !exists { if !exists {
_, err := s.service.Spreadsheets.BatchUpdate(id, &sheets.BatchUpdateSpreadsheetRequest{ _, err := s.service.Spreadsheets.BatchUpdate(id, &sheets.BatchUpdateSpreadsheetRequest{
Requests: []*sheets.Request{{AddSheet: &sheets.AddSheetRequest{ Requests: []*sheets.Request{{AddSheet: &sheets.AddSheetRequest{
@@ -127,6 +137,10 @@ func (s *Sheet) appendRow(id, quotedTable string, row []string) error {
return err return err
} }
func quoteTab(title string) string {
return "'" + strings.ReplaceAll(title, "'", "''") + "'"
}
func columnName(idx int) string { func columnName(idx int) string {
name := "" name := ""
for idx >= 0 { for idx >= 0 {
@@ -136,13 +150,19 @@ func columnName(idx int) string {
return name return name
} }
func toRecords(values [][]interface{}) []map[string]string { func parseTable(name string, values [][]interface{}) ([]string, []map[string]string, error) {
if len(values) == 0 { if len(values) == 0 {
return nil return nil, nil, fmt.Errorf("table %s has no header row", name)
} }
header := make([]string, len(values[0])) header := make([]string, len(values[0]))
seen := map[string]bool{}
for i, cell := range values[0] { for i, cell := range values[0] {
header[i] = strings.TrimSpace(fmt.Sprint(cell)) h := strings.TrimSpace(fmt.Sprint(cell))
if h != "" && seen[h] {
return nil, nil, fmt.Errorf("table %s has duplicate header %q", name, h)
}
seen[h] = true
header[i] = h
} }
records := []map[string]string{} records := []map[string]string{}
for _, row := range values[1:] { for _, row := range values[1:] {
@@ -161,5 +181,5 @@ func toRecords(values [][]interface{}) []map[string]string {
records = append(records, record) records = append(records, record)
} }
} }
return records return header, records, nil
} }
+4 -3
View File
@@ -14,12 +14,13 @@ const refreshInterval = 5 * time.Minute
type Cache struct { type Cache struct {
source data.Source source data.Source
geocoder *geocode.Client geocoder *geocode.Client
blobs BlobChecker
mu sync.RWMutex mu sync.RWMutex
model *Model model *Model
} }
func NewCache(source data.Source, geocoder *geocode.Client) (*Cache, error) { func NewCache(source data.Source, geocoder *geocode.Client, blobs BlobChecker) (*Cache, error) {
c := &Cache{source: source, geocoder: geocoder} c := &Cache{source: source, geocoder: geocoder, blobs: blobs}
if err := c.refresh(); err != nil { if err := c.refresh(); err != nil {
return nil, err return nil, err
} }
@@ -47,7 +48,7 @@ func (c *Cache) refreshLoop() {
func (c *Cache) refresh() error { func (c *Cache) refresh() error {
start := time.Now() start := time.Now()
model, err := LoadModel(c.source) model, err := LoadModel(c.source, c.blobs)
if err != nil { if err != nil {
return err return err
} }
+554 -148
View File
@@ -1,8 +1,13 @@
package directory package directory
import ( import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"regexp"
"sort" "sort"
"strconv"
"strings" "strings"
"heliosian/internal/data" "heliosian/internal/data"
@@ -10,110 +15,454 @@ import (
const appName = "directory" const appName = "directory"
func LoadModel(source data.Source) (*Model, error) { var gradeOrder = []string{
tables := map[string][]map[string]string{} "Kindergarten", "Grade 1", "Grade 2", "Grade 3", "Grade 4",
for _, name := range []string{"Basic Directory", "Staff Details", "Classrooms", "Schedules", "Grade Lookup", "Room Parents", "Departments"} { "Grade 5", "Grade 6", "Grade 7", "Grade 8",
rows, err := source.Table(appName, name) }
if err != nil {
return nil, err 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",
}
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
}
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]
}
func LoadModel(source data.Source, blobs BlobChecker) (*Model, error) {
importHeader, importRows, err := source.Table(appName, "Veracross Import")
if err != nil {
return nil, err
}
if err := requireColumns("Veracross Import", importHeader, importColumns); err != nil {
return nil, err
}
mapHeader, mapRows, err := source.Table(appName, "Name to Email")
if err != nil {
return nil, err
}
if err := requireColumns("Name to Email", mapHeader, []string{"Name", "Email"}); err != nil {
return nil, err
}
overrideHeader, overrideRows, err := source.Table(appName, "Overrides")
if err != nil {
return nil, err
}
if err := requireColumns("Overrides", overrideHeader, overrideColumns); err != nil {
return nil, err
}
nameToEmail := map[string]string{}
for _, row := range mapRows {
name, email := normName(row["Name"]), strings.ToLower(row["Email"])
if name == "" || email == "" {
return nil, fmt.Errorf("name to email row %v is incomplete", row)
}
if _, ok := nameToEmail[name]; ok {
return nil, fmt.Errorf("name to email has duplicate name %q", row["Name"])
}
nameToEmail[name] = email
}
mappingUses := map[string]int{}
people := map[string]*Person{}
order := []string{}
households := map[string]*household{}
householdOrder := []string{}
personHouseholds := map[string][]string{}
addAdult := func(rawName, email, phone string) error {
n := parseName(rawName)
if p, ok := 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
}
people[email] = &Person{
Email: email, FullName: n.display, LegalName: n.legal, PreferredName: n.preferred,
Phone: phone, IsParent: true,
}
order = append(order, email)
return nil
}
for _, row := range importRows {
rawName := row["student_full_name"]
if rawName == "" {
return nil, 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 nil, fmt.Errorf("student %s classifications: %w", rawName, err)
}
if gradeBands[classifications.GradeLevel] == "" {
return nil, fmt.Errorf("student %s has unknown grade %q", rawName, classifications.GradeLevel)
}
if classifications.Homeroom == "" {
return nil, fmt.Errorf("student %s has no homeroom", rawName)
}
classroom, crew := splitHomeroom(classifications.Homeroom)
email := strings.ToLower(row["student_email"])
if mapped, ok := nameToEmail[normName(rawName)]; ok {
email = mapped
mappingUses[normName(rawName)]++
}
if email == "" {
return nil, fmt.Errorf("student %s has no email and no name to email entry", rawName)
}
if _, ok := people[email]; ok {
return nil, 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, Section: crew,
Phone: row["student_phone_mobile"],
}
people[email] = student
order = append(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 nil, 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 := addAdult(row[prefix+"full_name"], adultEmail, phone); err != nil {
return nil, 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 := households[setKey]
if !ok {
hh = &household{adults: adults, address: address, phone: phone}
households[setKey] = hh
householdOrder = append(householdOrder, setKey)
for _, a := range adults {
if len(personHouseholds[a]) > 0 {
return nil, fmt.Errorf("adult %s belongs to more than one household", a)
}
personHouseholds[a] = append(personHouseholds[a], setKey)
}
} else if hh.address != address || hh.phone != phone {
return nil, fmt.Errorf("household of %v has conflicting address or phone across rows", adults)
}
hh.kids = append(hh.kids, email)
personHouseholds[email] = append(personHouseholds[email], setKey)
student.ParentContactEmails = append(student.ParentContactEmails, adults...)
}
}
for name := range nameToEmail {
switch mappingUses[name] {
case 0:
return nil, fmt.Errorf("name to email entry %q matches no import row", name)
case 1:
default:
return nil, fmt.Errorf("name to email entry %q matches %d import rows", name, mappingUses[name])
}
}
type familyCells struct {
address, phone, caption string
hasAddress, hasPhone, hasCaption bool
}
familyOverrides := map[string]familyCells{}
bandSet := map[string]bool{}
for _, band := range gradeBands {
bandSet[band] = true
}
roomParents := map[string][]string{}
seenOverride := map[string]bool{}
for _, row := range overrideRows {
email := strings.ToLower(row["Email"])
if email == "" {
return nil, fmt.Errorf("overrides row %v has no email", row)
}
if seenOverride[email] {
return nil, fmt.Errorf("overrides has duplicate email %s", email)
}
seenOverride[email] = true
added := row["Added"] == "TRUE"
p, exists := people[email]
if added && exists {
return nil, fmt.Errorf("overrides row %s is flagged added but the import covers this person", email)
}
if !added && !exists {
return nil, fmt.Errorf("overrides row %s matches no imported person", email)
}
if added {
p = &Person{Email: email}
people[email] = p
order = append(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 nil, err
}
}
apply(row["Pronouns"], &p.Pronouns)
apply(row["Facts"], &p.Facts)
if cell := row["Grade"]; cell != "" && cell != "-" && !added && gradeBands[cell] == "" {
return nil, 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.Section)
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 nil, 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 nil, fmt.Errorf("overrides row %s has unknown room parent band %q", email, cell)
}
roomParents[cell] = append(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 {
familyOverrides[email] = cells
}
if added {
if p.FullName == "" {
return nil, fmt.Errorf("added row %s has no full name", email)
}
if !p.IsStudent && !p.IsParent && !p.IsStaff {
return nil, fmt.Errorf("added row %s has no role", email)
}
} }
tables[name] = rows
} }
model := &Model{Families: map[string]Family{}, RoomParents: map[string][]string{}} model := &Model{Families: map[string]Family{}, RoomParents: map[string][]string{}}
staffByEmail := map[string]map[string]string{} familyKeys := map[string]string{}
for _, row := range tables["Staff Details"] { for _, setKey := range householdOrder {
email := strings.ToLower(row["Email Lower"]) hh := households[setKey]
if email != "" { members := append(append([]string{}, hh.adults...), hh.kids...)
staffByEmail[email] = row key := familyHash(members)
familyKeys[setKey] = key
family := Family{
Key: key,
Address: hh.address,
Phone: hh.phone,
AdultEmails: hh.adults,
KidEmails: hh.kids,
} }
family.Name = familyNameFor(family, people)
model.Families[key] = family
}
for email, sets := range personHouseholds {
if p := people[email]; p.FamilyKey == "" {
p.FamilyKey = familyKeys[sets[0]]
}
}
for email, cells := range familyOverrides {
p := people[email]
if !p.IsParent {
return nil, fmt.Errorf("overrides row %s has family cells but %s is not a parent", email, email)
}
sets := personHouseholds[email]
if len(sets) != 1 {
return nil, fmt.Errorf("overrides row %s has family cells but %s has no household", email, email)
}
key := familyKeys[sets[0]]
family := model.Families[key]
if cells.hasAddress {
family.Address = cells.address
}
if cells.hasPhone {
family.Phone = cells.phone
}
if cells.hasCaption {
family.PhotoCaption = cells.caption
}
model.Families[key] = family
} }
type familyAcc struct { if blobs != nil {
family Family for _, p := range people {
hasParent bool local, _, _ := strings.Cut(p.Email, "@")
hasStudent bool if blobs.Has("people/" + local + "-photo") {
} p.PhotoURL = "/blob/people/" + local + "-photo"
families := map[string]*familyAcc{} }
byEmail := map[string]Person{} if blobs.Has("people/" + local + "-pronunciation") {
for _, row := range tables["Basic Directory"] { p.PronunciationURL = "/blob/people/" + local + "-pronunciation"
email := strings.ToLower(row["Email Lower"])
if email == "" {
continue
}
p := Person{
Email: email,
FullName: row["Full Name"],
LegalName: row["Legal Name"],
PreferredName: row["Preferred Name"],
IsStaff: row["Is Staff?"] == "TRUE",
IsParent: row["Is Parent?"] == "TRUE",
IsStudent: row["Is Student?"] == "TRUE",
IsNew: row["Is Totally New"] == "TRUE",
Pronouns: row["Pronouns"],
Facts: row["Facts"],
PronunciationURL: blobURL("people", email, "pronunciation", row["Pronunciation"]),
PhotoURL: blobURL("people", email, "photo", row["Primary Photo"]),
Grade: row["Grade"],
Classroom: row["Class"],
Section: row["Section"],
Phone: row["Phone Number"],
FamilyKey: strings.ToLower(row["Family Key"]),
}
for _, contact := range strings.Split(row["Parent Contact Emails"], ",") {
if contact = strings.ToLower(strings.TrimSpace(contact)); contact != "" {
p.ParentContactEmails = append(p.ParentContactEmails, contact)
} }
} }
if details, ok := staffByEmail[email]; ok { for key, family := range model.Families {
p.JobTitle = details["Job Title"] if blobs.Has("families/" + key + "-photo") {
p.Department = details["Department"] family.PhotoURL = "/blob/families/" + key + "-photo"
p.GradeBand = details["Grade Band"]
}
model.People = append(model.People, p)
byEmail[email] = p
if p.FamilyKey == "" {
continue
}
acc, ok := families[p.FamilyKey]
if !ok {
acc = &familyAcc{family: Family{Key: p.FamilyKey}}
families[p.FamilyKey] = acc
}
if acc.family.Address == "" && row["Address 1"] != "" {
acc.family.Address = row["Address 1"]
if row["Address 2"] != "" {
acc.family.Address += ", " + row["Address 2"]
} }
if blobs.Has("families/" + key + "-pronunciation") {
family.PronunciationURL = "/blob/families/" + key + "-pronunciation"
}
model.Families[key] = family
} }
if acc.family.PhotoURL == "" {
acc.family.PhotoURL = blobURL("families", p.FamilyKey, "photo", row["Family Photo"])
}
if acc.family.PhotoCaption == "" {
acc.family.PhotoCaption = row["Family Photo Description"]
}
if acc.family.PronunciationURL == "" {
acc.family.PronunciationURL = blobURL("families", p.FamilyKey, "pronunciation", row["Family Pronunciation"])
}
if p.IsParent {
acc.hasParent = true
acc.family.AdultEmails = append(acc.family.AdultEmails, email)
}
if p.IsStudent {
acc.hasStudent = true
acc.family.KidEmails = append(acc.family.KidEmails, email)
}
}
for key, acc := range families {
if !acc.hasParent && !acc.hasStudent {
continue
}
acc.family.Name = familyName(acc.family, byEmail)
model.Families[key] = acc.family
} }
for _, email := range order {
model.People = append(model.People, *people[email])
}
sort.Slice(model.People, func(i, j int) bool { sort.Slice(model.People, func(i, j int) bool {
si, sj := surname(model.People[i].FullName), surname(model.People[j].FullName) si, sj := surname(model.People[i].FullName), surname(model.People[j].FullName)
if si != sj { if si != sj {
@@ -122,83 +471,136 @@ func LoadModel(source data.Source) (*Model, error) {
return model.People[i].FullName < model.People[j].FullName return model.People[i].FullName < model.People[j].FullName
}) })
for _, row := range tables["Classrooms"] { type classroomInfo struct {
if row["Class"] == "" { crews map[string]bool
continue minGrade int
} bands map[string]bool
imageURL := ""
if row["Classroom Image"] != "" {
imageURL = "/static/brand/classrooms/classroom-" + strings.ToLower(row["Class"]) + ".jpg"
}
model.Classrooms = append(model.Classrooms, Classroom{
Name: row["Class"],
ImageURL: imageURL,
HasSections: row["Has Sections"] == "TRUE",
})
} }
classrooms := map[string]*classroomInfo{}
for _, row := range tables["Schedules"] { for _, p := range model.People {
if row["Classroom"] == "" { if p.Classroom == "" || !p.IsStudent || gradeBands[p.Grade] == "" {
continue continue
} }
section := Section{Classroom: row["Classroom"], Name: row["Section"], GradeBand: row["Grade Band"]} info, ok := classrooms[p.Classroom]
for _, column := range []string{"Teacher 1", "Teacher 2", "Teacher 3"} { if !ok {
if row[column] != "" { info = &classroomInfo{crews: map[string]bool{}, minGrade: len(gradeOrder), bands: map[string]bool{}}
section.Teachers = append(section.Teachers, row[column]) classrooms[p.Classroom] = info
}
if p.Section != "" {
info.crews[p.Section] = true
}
for i, g := range gradeOrder {
if g == p.Grade && i < info.minGrade {
info.minGrade = i
} }
} }
model.Sections = append(model.Sections, section) info.bands[gradeBands[p.Grade]] = true
} }
classroomNames := []string{}
for _, row := range tables["Grade Lookup"] { for name := range classrooms {
if row["Current Grade"] == "" { classroomNames = append(classroomNames, name)
continue }
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
} }
model.Grades = append(model.Grades, Grade{ return classroomNames[i] < classroomNames[j]
Name: row["Current Grade"], })
NextName: row["Next Grade"], for _, name := range classroomNames {
Band: row["Current Gradeband"], info := classrooms[name]
NextBand: row["Next Gradeband"], if len(info.bands) != 1 {
return nil, fmt.Errorf("classroom %s spans multiple grade bands", name)
}
imageURL := ""
imagePath := "web/static/brand/classrooms/classroom-" + strings.ToLower(name) + ".jpg"
if _, err := os.Stat(imagePath); err == nil {
imageURL = "/static/brand/classrooms/classroom-" + strings.ToLower(name) + ".jpg"
}
model.Classrooms = append(model.Classrooms, Classroom{
Name: name,
ImageURL: imageURL,
HasSections: len(info.crews) > 0,
}) })
} }
for _, row := range tables["Room Parents"] { for _, p := range model.People {
band, email := row["Gradeband"], strings.ToLower(row["Email Address"]) if !p.IsStaff || p.Classroom == "" {
if band == "" || email == "" {
continue continue
} }
model.RoomParents[band] = append(model.RoomParents[band], email) info, ok := classrooms[p.Classroom]
if !ok {
return nil, fmt.Errorf("staff %s is assigned to unknown classroom %q", p.Email, p.Classroom)
}
if p.Section != "" && !info.crews[p.Section] {
return nil, fmt.Errorf("staff %s is assigned to unknown crew %q of %s", p.Email, p.Section, 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 _, crew := range crews {
section := Section{Classroom: name, Name: crew, GradeBand: band}
for _, p := range model.People {
if p.IsStaff && p.Classroom == name && p.Section == crew {
section.Teachers = append(section.Teachers, p.Email)
}
}
model.Sections = append(model.Sections, section)
}
} }
type department struct { for i, grade := range gradeOrder {
name string g := Grade{Name: grade, Band: gradeBands[grade]}
order float64 if i+1 < len(gradeOrder) {
} g.NextName = gradeOrder[i+1]
departments := []department{} g.NextBand = gradeBands[g.NextName]
for _, row := range tables["Departments"] {
if row["Department"] == "" {
continue
} }
order, err := strconv.ParseFloat(row["Order"], 64) model.Grades = append(model.Grades, g)
if err != nil {
order = float64(len(departments))
}
departments = append(departments, department{name: row["Department"], order: order})
} }
sort.SliceStable(departments, func(i, j int) bool { return departments[i].order < departments[j].order })
for _, d := range departments { for band, emails := range roomParents {
model.Departments = append(model.Departments, d.name) model.RoomParents[bandLabel(band)] = emails
} }
model.Departments = append(model.Departments, departmentOrder...)
return model, nil return model, nil
} }
func blobURL(folder, email, kind, source string) string { func bandLabel(band string) string {
if source == "" { if band == "Hummingbirds" {
return "" return "K"
} }
local, _, _ := strings.Cut(email, "@") labels := []string{}
return "/blob/" + folder + "/" + local + "-" + kind 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 surname(fullName string) string { func surname(fullName string) string {
@@ -209,12 +611,16 @@ func surname(fullName string) string {
return fields[len(fields)-1] return fields[len(fields)-1]
} }
func familyName(f Family, byEmail map[string]Person) string { func familyNameFor(f Family, people map[string]*Person) string {
members := append(append([]string{}, f.KidEmails...), f.AdultEmails...) members := append(append([]string{}, f.KidEmails...), f.AdultEmails...)
seen := map[string]bool{} seen := map[string]bool{}
names := []string{} names := []string{}
for _, email := range members { for _, email := range members {
s := surname(byEmail[email].FullName) p, ok := people[email]
if !ok {
continue
}
s := surname(p.FullName)
if s == "" || seen[s] { if s == "" || seen[s] {
continue continue
} }
+1
View File
@@ -28,6 +28,7 @@ type Family struct {
Key string `json:"key"` Key string `json:"key"`
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
Address string `json:"address,omitempty"` Address string `json:"address,omitempty"`
Phone string `json:"phone,omitempty"`
Lat float64 `json:"lat,omitempty"` Lat float64 `json:"lat,omitempty"`
Lng float64 `json:"lng,omitempty"` Lng float64 `json:"lng,omitempty"`
PhotoURL string `json:"photoUrl,omitempty"` PhotoURL string `json:"photoUrl,omitempty"`
+20 -16
View File
@@ -15,7 +15,24 @@ import (
const changeLogTable = "Change Log" const changeLogTable = "Change Log"
var changeLogHeader = []string{"Timestamp", "Actor", "Target", "Kind", "File", "Archived"} var changeLogHeader = append([]string{"Timestamp", "Actor"}, overrideColumns...)
func changeLogRow(actor, email string, previous map[string]string) []string {
row := make([]string, len(changeLogHeader))
row[0] = time.Now().UTC().Format(time.RFC3339)
row[1] = actor
for i, column := range changeLogHeader {
if column == "Email" {
row[i] = email
} else if value, ok := previous[column]; ok {
if value == "" {
value = "-"
}
row[i] = value
}
}
return row
}
var photoExtensions = map[string]string{ var photoExtensions = map[string]string{
"image/jpeg": "jpg", "image/jpeg": "jpg",
@@ -68,11 +85,11 @@ func (u uploader) facts(w http.ResponseWriter, r *http.Request) {
break break
} }
} }
if err := u.sheet.SetColumn(appName, "Basic Directory", "Email Lower", key, "Facts", facts); err != nil { if err := u.sheet.Upsert(appName, "Overrides", "Email", key, "Facts", facts); err != nil {
serverError(w, fmt.Errorf("update facts for %s: %w", key, err)) serverError(w, fmt.Errorf("update facts for %s: %w", key, err))
return return
} }
logRow := []string{time.Now().UTC().Format(time.RFC3339), me, key, "person facts", facts, old} logRow := changeLogRow(me, key, map[string]string{"Facts": old})
if err := u.sheet.Append(appName, changeLogTable, changeLogHeader, logRow); err != nil { if err := u.sheet.Append(appName, changeLogTable, changeLogHeader, logRow); err != nil {
serverError(w, fmt.Errorf("append change log after facts update for %s: %w", key, err)) serverError(w, fmt.Errorf("append change log after facts update for %s: %w", key, err))
return return
@@ -125,12 +142,8 @@ func (u uploader) upload(w http.ResponseWriter, r *http.Request) {
} }
folder := "people" folder := "people"
keyColumn := "Email Lower"
column := map[string]string{"photo": "Primary Photo", "pronunciation": "Pronunciation"}[kind]
if target == "family" { if target == "family" {
folder = "families" folder = "families"
keyColumn = "Family Key"
column = map[string]string{"photo": "Family Photo", "pronunciation": "Family Pronunciation"}[kind]
} }
local, _, _ := strings.Cut(key, "@") local, _, _ := strings.Cut(key, "@")
base := local + "-" + kind base := local + "-" + kind
@@ -141,15 +154,6 @@ func (u uploader) upload(w http.ResponseWriter, r *http.Request) {
serverError(w, err) serverError(w, err)
return return
} }
if err := u.sheet.SetColumn(appName, "Basic Directory", keyColumn, key, column, name); err != nil {
serverError(w, fmt.Errorf("update sheet after upload of %s/%s: %w", folder, name, err))
return
}
logRow := []string{time.Now().UTC().Format(time.RFC3339), me, key, target + " " + kind, name, archived}
if err := u.sheet.Append(appName, changeLogTable, changeLogHeader, logRow); err != nil {
serverError(w, fmt.Errorf("append change log after upload of %s/%s: %w", folder, name, err))
return
}
if err := u.cache.Refresh(); err != nil { if err := u.cache.Refresh(); err != nil {
serverError(w, fmt.Errorf("refresh model after upload: %w", err)) serverError(w, fmt.Errorf("refresh model after upload: %w", err))
return return
+12 -6
View File
@@ -90,18 +90,24 @@ func main() {
serverKey := mapsKey("GOOGLE_MAPS_SERVER_KEY", "creds/geocoding.key") serverKey := mapsKey("GOOGLE_MAPS_SERVER_KEY", "creds/geocoding.key")
browserKey := mapsKey("GOOGLE_MAPS_BROWSER_KEY", "creds/maps.key") browserKey := mapsKey("GOOGLE_MAPS_BROWSER_KEY", "creds/maps.key")
source := directorySource() source := directorySource()
cache, err := directory.NewCache(source, geocode.New(serverKey)) var store *blob.Store
var blobs directory.BlobChecker
if os.Getenv("DIRECTORY_SHEET") != "" {
var err error
store, err = blob.New()
if err != nil {
log.Fatalf("[ERROR] blob store: %v", err)
}
blobs = store
}
cache, err := directory.NewCache(source, geocode.New(serverKey), blobs)
if err != nil { if err != nil {
log.Fatalf("[ERROR] load directory data: %v", err) log.Fatalf("[ERROR] load directory data: %v", err)
} }
mux := http.NewServeMux() mux := http.NewServeMux()
authn.Register(mux) authn.Register(mux)
directory.Register(mux, cache, browserKey) directory.Register(mux, cache, browserKey)
if os.Getenv("DIRECTORY_SHEET") != "" { if store != nil {
store, err := blob.New()
if err != nil {
log.Fatalf("[ERROR] blob store: %v", err)
}
blob.Register(mux, store) blob.Register(mux, store)
directory.RegisterUpload(mux, cache, source.(*data.Sheet), store) directory.RegisterUpload(mux, cache, source.(*data.Sheet), store)
} }
+4 -20
View File
@@ -5,7 +5,6 @@ import (
"fmt" "fmt"
"log" "log"
"os" "os"
"sort"
"heliosian/internal/data" "heliosian/internal/data"
) )
@@ -22,28 +21,13 @@ func main() {
} }
source = s source = s
} }
for _, name := range []string{"Basic Directory", "Staff Details", "Classrooms", "Schedules", "Grade Lookup", "Room Parents", "Departments"} { for _, name := range []string{"Veracross Import", "Name to Email", "Overrides", "Change Log"} {
rows, err := source.Table("directory", name) header, rows, err := source.Table("directory", name)
if err != nil { if err != nil {
log.Fatalf("[ERROR] table %s: %v", name, err) log.Fatalf("[ERROR] table %s: %v", name, err)
} }
if len(rows) == 0 { fmt.Printf("%s (%d rows):\n", name, len(rows))
fmt.Printf("%s: no rows\n", name) for _, column := range header {
continue
}
seen := map[string]bool{}
columns := []string{}
for _, row := range rows {
for column := range row {
if !seen[column] {
seen[column] = true
columns = append(columns, column)
}
}
}
sort.Strings(columns)
fmt.Printf("%s:\n", name)
for _, column := range columns {
fmt.Printf(" %s\n", column) fmt.Printf(" %s\n", column)
} }
} }
+1 -1
View File
@@ -198,7 +198,7 @@ func main() {
if err != nil { if err != nil {
log.Fatalf("[ERROR] sheet source: %v", err) log.Fatalf("[ERROR] sheet source: %v", err)
} }
model, err := directory.LoadModel(source) model, err := directory.LoadModel(source, nil)
if err != nil { if err != nil {
log.Fatalf("[ERROR] load model: %v", err) log.Fatalf("[ERROR] load model: %v", err)
} }
+88
View File
@@ -0,0 +1,88 @@
// Command loadcheck loads the directory model from a sheet and prints a summary.
package main
import (
"flag"
"fmt"
"log"
"sort"
"heliosian/internal/data"
"heliosian/internal/directory"
)
func main() {
sheet := flag.String("sheet", "", "spreadsheet id")
flag.Parse()
if *sheet == "" {
log.Fatal("[ERROR] -sheet <spreadsheet id> is required")
}
source, err := data.NewSheet(map[string]string{"directory": *sheet})
if err != nil {
log.Fatalf("[ERROR] sheet source: %v", err)
}
model, err := directory.LoadModel(source, nil)
if err != nil {
log.Fatalf("[ERROR] load model: %v", err)
}
students, parents, staff, isNew := 0, 0, 0, 0
for _, p := range model.People {
if p.IsStudent {
students++
}
if p.IsParent {
parents++
}
if p.IsStaff {
staff++
}
if p.IsNew {
isNew++
}
}
fmt.Printf("people: %d (students %d, parents %d, staff %d, new %d)\n",
len(model.People), students, parents, staff, isNew)
fmt.Printf("families: %d\n", len(model.Families))
twoHousehold := map[string]int{}
for _, f := range model.Families {
for _, kid := range f.KidEmails {
twoHousehold[kid]++
}
}
for kid, n := range twoHousehold {
if n > 1 {
fmt.Printf(" student in %d households: %s\n", n, kid)
}
}
fmt.Println("classrooms:")
for _, c := range model.Classrooms {
fmt.Printf(" %s (image %v, crews %v)\n", c.Name, c.ImageURL != "", c.HasSections)
}
fmt.Println("crews:")
for _, s := range model.Sections {
fmt.Printf(" %s | %s | %s | teachers %v\n", s.Classroom, s.Name, s.GradeBand, s.Teachers)
}
bands := []string{}
for band := range model.RoomParents {
bands = append(bands, band)
}
sort.Strings(bands)
fmt.Println("room parents:")
for _, band := range bands {
fmt.Printf(" %s: %d\n", band, len(model.RoomParents[band]))
}
fmt.Println("departments:", model.Departments)
fmt.Println("grades:")
for _, g := range model.Grades {
fmt.Printf(" %s -> %s (%s -> %s)\n", g.Name, g.NextName, g.Band, g.NextBand)
}
for _, email := range []string{"lexi.augenbergs@heliosschool.org", "daren.liang@heliosschool.org", "yeada.li@heliosschool.org", "dog@heliosschool.org", "mike.orlando@heliosschool.org", "evie.weiss@heliosschool.org"} {
for _, p := range model.People {
if p.Email == email {
fmt.Printf("spot %s: full=%q legal=%q pref=%q roles=S%v/P%v/T%v grade=%q class=%q crew=%q band=%q dept=%q title=%q family=%s\n",
email, p.FullName, p.LegalName, p.PreferredName, p.IsStudent, p.IsParent, p.IsStaff,
p.Grade, p.Classroom, p.Section, p.GradeBand, p.Department, p.JobTitle, p.FamilyKey)
}
}
}
}