From 2b836367e127bedc87fa43467be0c907961892d4 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Sun, 16 Aug 2026 13:21:25 -0700 Subject: [PATCH] Rename Section to Crew, stage the load pipeline, dedupe lookups and token minting, drop burned tools --- docs/data.md | 2 +- docs/dev.md | 17 ++ docs/plan.md | 4 + internal/auth/auth.go | 33 ++- internal/directory/cache.go | 11 +- internal/directory/directory.go | 12 +- internal/directory/load.go | 430 ++++++++++++++++------------ internal/directory/model.go | 50 ++-- internal/directory/upload.go | 80 ++---- main.go | 10 +- sampledata/directory/Change Log.csv | 1 + tools/cookie/main.go | 11 +- tools/loadcheck/main.go | 26 +- tools/oneoff/main.go | 81 ------ tools/renamefamilyblobs/main.go | 126 -------- tools/startserver/main.go | 11 +- web/static/directory/app.js | 14 +- 17 files changed, 389 insertions(+), 530 deletions(-) create mode 100644 sampledata/directory/Change Log.csv delete mode 100644 tools/oneoff/main.go delete mode 100644 tools/renamefamilyblobs/main.go diff --git a/docs/data.md b/docs/data.md index d2a2acd..c6d673e 100644 --- a/docs/data.md +++ b/docs/data.md @@ -133,7 +133,7 @@ The loader hard-fails — no fallbacks, server refuses to start — on: - conflicting values for the same adult across import rows - an invalid canonical value from any layer -The import tool ingests a Veracross CSV export, rewrites the Veracross Import tab, re-runs the full pipeline, and reports the local layer's health beyond the fatal checks: useless overrides (value identical to what the record has anyway), `-` on flagged rows, name mappings or additions that Veracross has since made redundant, and media files whose name matches no current person or family — including family blobs orphaned by a membership change. Running it is the whole import procedure. `tools/findsheet` lists the spreadsheets visible to the service account; `tools/sheets` dumps a sheet's tabs, headers, and rows. +The import procedure is manual today: `tools/writetab` writes the Veracross CSV export into the Veracross Import tab (header-checked), and `tools/loadcheck` re-runs the full pipeline against the sheet and prints a model summary. A single import tool that also reports the local layer's health beyond the fatal checks — useless overrides (value identical to what the record has anyway), `-` on flagged rows, name mappings or additions that Veracross has since made redundant, and media files whose name matches no current person or family, including family blobs orphaned by a membership change — is planned (`docs/plan.md`). `tools/findsheet` lists the spreadsheets visible to the service account; `tools/sheets` dumps a sheet's tabs, headers, and rows. ## Sourcing diff --git a/docs/dev.md b/docs/dev.md index 523b6f6..fcd40b3 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -35,3 +35,20 @@ Development happens on macOS. Two Homebrew installs cover everything here and in - **Google Chrome** — launched headless by the screenshot tool from its standard install location; never opened by hand. No Node, no Docker, and no cloud credentials are needed for local development. Repository layout is in the README. + +## Tools + +Each runs as `go run ./tools/`. The sheet and drive tools authenticate with `creds/service-account.json`. + +- `screenshot`, `capturebrowser`, `browse` — page capture and browser driving; see `docs/screenshots.md` +- `startserver` — launch the app detached, wait for it to listen, print the pid, log path, and a minted session cookie (needs `SESSION_KEY` and `DIRECTORY_SHEET`) +- `cookie` — print a signed session cookie for local API testing +- `loadcheck` — run the full load pipeline against a sheet and print a model summary +- `columns` — print each directory table's column names from the configured source +- `findsheet` — list spreadsheets visible to the service account +- `sheets` — dump a sheet's tabs, sizes, and header rows +- `dumptab` / `writetab` — copy one tab to a local CSV / write a local CSV into a tab, header-checked +- `createtabs` — create the directory sheet's local-layer tabs with their header rows +- `setcell` — set one cell in a tab by key column, appending the row if missing +- `probeblob` — time the download of a few drive media files +- `splash` — regenerate the iOS splash battery from the captured original page; see `docs/pwa.md` diff --git a/docs/plan.md b/docs/plan.md index b609a03..8f7d9b3 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -5,3 +5,7 @@ What remains to build. Current behavior is documented in `docs/dev.md`, `docs/da ## Hosting and deployment - Move from `gen-lang-client-0758114984` to the school's project: recreate the OAuth client there (Internal consent screen, only available inside the school's Workspace org, removes unverified-app friction), plus the service account, secrets, and service; re-share the spreadsheet and media drive with the new service account. + +## Import tool + +- One command that ingests a Veracross CSV export, rewrites the Veracross Import tab, re-runs the full pipeline, and reports the local layer's health beyond the fatal checks: useless overrides, `-` on flagged rows, name mappings or additions Veracross has made redundant, and media files matching no current person or family (including family blobs orphaned by a membership change). Replaces the manual writetab + loadcheck procedure in `docs/data.md`. diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 8e124b8..3033923 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -45,6 +45,21 @@ func Fixed(email string, next http.Handler) http.Handler { }) } +func Public(path string) bool { + return path == "/auth/login" || strings.HasPrefix(path, "/static/") +} + +func Token(key []byte, email string, expiry time.Time) string { + payload := fmt.Sprintf("%s|%d", email, expiry.Unix()) + return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + sign(key, payload) +} + +func sign(key []byte, payload string) string { + mac := hmac.New(sha256.New, key) + mac.Write([]byte(payload)) + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) +} + func (a *Auth) Register(mux *http.ServeMux) { mux.HandleFunc("POST /auth/login", a.login) mux.HandleFunc("POST /auth/logout", a.logout) @@ -52,7 +67,7 @@ func (a *Auth) Register(mux *http.ServeMux) { func (a *Auth) Wrap(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/auth/login" || strings.HasPrefix(r.URL.Path, "/static/") { + if Public(r.URL.Path) { next.ServeHTTP(w, r) return } @@ -108,10 +123,9 @@ func (a *Auth) login(w http.ResponseWriter, r *http.Request) { http.Error(w, "account is not in the school domain", http.StatusForbidden) return } - expiry := time.Now().Add(sessionLength).Unix() http.SetCookie(w, &http.Cookie{ Name: cookieName, - Value: a.token(email, expiry), + Value: Token(a.key, email, time.Now().Add(sessionLength)), Path: "/", HttpOnly: true, Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https", @@ -126,17 +140,6 @@ func (a *Auth) logout(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/", http.StatusSeeOther) } -func (a *Auth) token(email string, expiry int64) string { - payload := fmt.Sprintf("%s|%d", email, expiry) - return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + a.sign(payload) -} - -func (a *Auth) sign(payload string) string { - mac := hmac.New(sha256.New, a.key) - mac.Write([]byte(payload)) - return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) -} - func (a *Auth) sessionEmail(r *http.Request) string { cookie, err := r.Cookie(cookieName) if err != nil { @@ -151,7 +154,7 @@ func (a *Auth) sessionEmail(r *http.Request) string { return "" } payload := string(decoded) - if !hmac.Equal([]byte(a.sign(payload)), []byte(parts[1])) { + if !hmac.Equal([]byte(sign(a.key, payload)), []byte(parts[1])) { return "" } fields := strings.Split(payload, "|") diff --git a/internal/directory/cache.go b/internal/directory/cache.go index ee776ac..26cb799 100644 --- a/internal/directory/cache.go +++ b/internal/directory/cache.go @@ -19,12 +19,13 @@ type Cache struct { source data.Source geocoder Geocoder blobs BlobChecker + static BlobChecker mu sync.RWMutex model *Model } -func NewCache(source data.Source, geocoder Geocoder, blobs BlobChecker) (*Cache, error) { - c := &Cache{source: source, geocoder: geocoder, blobs: blobs} +func NewCache(source data.Source, geocoder Geocoder, blobs, static BlobChecker) (*Cache, error) { + c := &Cache{source: source, geocoder: geocoder, blobs: blobs, static: static} if err := c.refresh(); err != nil { return nil, err } @@ -52,7 +53,7 @@ func (c *Cache) refreshLoop() { func (c *Cache) refresh() error { start := time.Now() - model, err := LoadModel(c.source, c.blobs) + model, err := LoadModel(c.source, c.blobs, c.static) if err != nil { return err } @@ -60,8 +61,8 @@ func (c *Cache) refresh() error { c.mu.Lock() c.model = model c.mu.Unlock() - log.Printf("loaded directory model: %d people, %d families, %d classrooms, %d sections in %s", - len(model.People), len(model.Families), len(model.Classrooms), len(model.Sections), + log.Printf("loaded directory model: %d people, %d families, %d classrooms, %d crews in %s", + len(model.People), len(model.Families), len(model.Classrooms), len(model.Crews), time.Since(start).Round(time.Millisecond)) return nil } diff --git a/internal/directory/directory.go b/internal/directory/directory.go index e4c1cb4..0a6111a 100644 --- a/internal/directory/directory.go +++ b/internal/directory/directory.go @@ -30,7 +30,7 @@ type app struct { func MemberGate(cache *Cache, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/auth/login" || r.URL.Path == "/auth/logout" || strings.HasPrefix(r.URL.Path, "/static/") { + if auth.Public(r.URL.Path) || r.URL.Path == "/auth/logout" { next.ServeHTTP(w, r) return } @@ -60,12 +60,10 @@ func Register(mux *http.ServeMux, cache *Cache, mapsKey string) { func (a app) myFamily(w http.ResponseWriter, r *http.Request) { model := a.cache.Model() email := auth.Email(r) - for _, p := range model.People { - if p.Email == email && p.FamilyKey != "" { - if _, ok := model.Families[p.FamilyKey]; ok { - http.Redirect(w, r, "/families/"+url.PathEscape(p.FamilyKey), http.StatusFound) - return - } + if p := model.Person(email); p != nil && p.FamilyKey != "" { + if _, ok := model.Families[p.FamilyKey]; ok { + http.Redirect(w, r, "/families/"+url.PathEscape(p.FamilyKey), http.StatusFound) + return } } http.Error(w, "no family record for "+email, http.StatusNotFound) diff --git a/internal/directory/load.go b/internal/directory/load.go index 880e28f..fb16841 100644 --- a/internal/directory/load.go +++ b/internal/directory/load.go @@ -5,7 +5,6 @@ import ( "encoding/hex" "encoding/json" "fmt" - "os" "regexp" "sort" "strings" @@ -88,6 +87,11 @@ type household struct { 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 { @@ -108,110 +112,162 @@ func familyHash(members []string) string { return hex.EncodeToString(sum[:])[:16] } -func LoadModel(source data.Source, blobs BlobChecker) (*Model, error) { +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 nil, err + return err } if err := requireColumns("Veracross Import", importHeader, importColumns); err != nil { - return nil, err + return err } + l.importRows = importRows mapHeader, mapRows, err := source.Table(appName, "Name to Email") if err != nil { - return nil, err + return err } if err := requireColumns("Name to Email", mapHeader, []string{"Name", "Email"}); err != nil { - return nil, err + return err } overrideHeader, overrideRows, err := source.Table(appName, "Overrides") if err != nil { - return nil, err + return err } if err := requireColumns("Overrides", overrideHeader, overrideColumns); err != nil { - return nil, err + return err } + l.overrideRows = overrideRows - nameToEmail := map[string]string{} + l.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) + return 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"]) + if _, ok := l.nameToEmail[name]; ok { + return fmt.Errorf("name to email has duplicate name %q", row["Name"]) } - nameToEmail[name] = email + l.nameToEmail[name] = email } - mappingUses := map[string]int{} + return nil +} - 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 +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) } - people[email] = &Person{ - Email: email, FullName: n.display, LegalName: n.legal, PreferredName: n.preferred, - Phone: phone, IsParent: true, + if p.Phone != "" && phone != "" && p.Phone != phone { + return fmt.Errorf("adult %s has conflicting phones %q and %q", email, p.Phone, phone) } - order = append(order, email) + 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 +} - for _, row := range importRows { +func (l *loader) transformImport() error { + mappingUses := map[string]int{} + for _, row := range l.importRows { rawName := row["student_full_name"] if rawName == "" { - return nil, fmt.Errorf("import row %v has no student name", row) + 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 nil, fmt.Errorf("student %s classifications: %w", rawName, err) + return 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) + return fmt.Errorf("student %s has unknown grade %q", rawName, classifications.GradeLevel) } if classifications.Homeroom == "" { - return nil, fmt.Errorf("student %s has no homeroom", rawName) + return 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 { + if mapped, ok := l.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) + return 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) + 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, Section: crew, + IsStudent: true, Grade: classifications.GradeLevel, Classroom: classroom, Crew: crew, Phone: row["student_phone_mobile"], } - people[email] = student - order = append(order, email) + l.people[email] = student + l.order = append(l.order, email) for _, hn := range []string{"1", "2"} { adults := []string{} @@ -222,14 +278,14 @@ func LoadModel(source data.Source, blobs BlobChecker) (*Model, error) { } 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) + 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 := addAdult(row[prefix+"full_name"], adultEmail, phone); err != nil { - return nil, err + if err := l.addAdult(row[prefix+"full_name"], adultEmail, phone); err != nil { + return err } adults = append(adults, adultEmail) } @@ -242,70 +298,65 @@ func LoadModel(source data.Source, blobs BlobChecker) (*Model, error) { sort.Strings(s) return s }(), "\n") - hh, ok := households[setKey] + hh, ok := l.households[setKey] if !ok { hh = &household{adults: adults, address: address, phone: phone} - households[setKey] = hh - householdOrder = append(householdOrder, setKey) + l.households[setKey] = hh + l.householdOrder = append(l.householdOrder, setKey) for _, a := range adults { - if len(personHouseholds[a]) > 0 { - return nil, fmt.Errorf("adult %s belongs to more than one household", a) + if len(l.personHouseholds[a]) > 0 { + return fmt.Errorf("adult %s belongs to more than one household", a) } - personHouseholds[a] = append(personHouseholds[a], setKey) + l.personHouseholds[a] = append(l.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) + return 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) + l.personHouseholds[email] = append(l.personHouseholds[email], setKey) student.ParentContactEmails = append(student.ParentContactEmails, adults...) } } - for name := range nameToEmail { + for name := range l.nameToEmail { switch mappingUses[name] { case 0: - return nil, fmt.Errorf("name to email entry %q matches no import row", name) + return 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]) + return fmt.Errorf("name to email entry %q matches %d import rows", name, mappingUses[name]) } } + return nil +} - type familyCells struct { - address, phone, caption string - hasAddress, hasPhone, hasCaption bool - } - familyOverrides := map[string]familyCells{} +func (l *loader) applyOverrides() error { bandSet := map[string]bool{} for _, band := range gradeBands { bandSet[band] = true } - roomParents := map[string][]string{} - optedOut := map[string]bool{} - - seenOverride := map[string]bool{} - for _, row := range overrideRows { + seen := map[string]bool{} + for _, row := range l.overrideRows { email := strings.ToLower(row["Email"]) if email == "" { - return nil, fmt.Errorf("overrides row %v has no email", row) + return fmt.Errorf("overrides row %v has no email", row) } - if seenOverride[email] { - return nil, fmt.Errorf("overrides has duplicate email %s", email) + if seen[email] { + return fmt.Errorf("overrides has duplicate email %s", email) } - seenOverride[email] = true + seen[email] = true added := row["Added"] == "TRUE" - p, exists := people[email] + p, exists := l.people[email] if added && exists { - return nil, fmt.Errorf("overrides row %s is flagged added but the import covers this person", email) + return 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) + return fmt.Errorf("overrides row %s matches no imported person", email) } if added { p = &Person{Email: email} - people[email] = p - order = append(order, email) + l.people[email] = p + l.order = append(l.order, email) } apply := func(cell string, field *string) { @@ -336,29 +387,29 @@ func LoadModel(source data.Source, blobs BlobChecker) (*Model, error) { "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 + return 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) + 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.Section) + 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 nil, fmt.Errorf("overrides row %s has unknown grade band %q", email, 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 nil, fmt.Errorf("overrides row %s has unknown room parent band %q", email, cell) + return fmt.Errorf("overrides row %s has unknown room parent band %q", email, cell) } - roomParents[cell] = append(roomParents[cell], email) + l.roomParents[cell] = append(l.roomParents[cell], email) } cells := familyCells{} @@ -381,36 +432,36 @@ func LoadModel(source data.Source, blobs BlobChecker) (*Model, error) { } } if cells.hasAddress || cells.hasPhone || cells.hasCaption { - familyOverrides[email] = cells + l.familyOverrides[email] = cells } switch row["Opted Out"] { case "", "-", "FALSE": case "TRUE": - optedOut[email] = true + l.optedOut[email] = true default: - return nil, fmt.Errorf("overrides row %s has invalid Opted Out %q", email, row["Opted Out"]) + return fmt.Errorf("overrides row %s has invalid Opted Out %q", email, row["Opted Out"]) } if added { if p.FullName == "" { - return nil, fmt.Errorf("added row %s has no full name", email) + return 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) + return fmt.Errorf("added row %s has no role", email) } } } + return nil +} - model := &Model{Families: map[string]Family{}, RoomParents: map[string][]string{}} - - familyKeys := map[string]string{} - for _, setKey := range householdOrder { - hh := households[setKey] +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) - familyKeys[setKey] = key - model.Families[key] = Family{ + l.familyKeys[setKey] = key + l.model.Families[key] = Family{ Key: key, Address: hh.address, Phone: hh.phone, @@ -418,22 +469,22 @@ func LoadModel(source data.Source, blobs BlobChecker) (*Model, error) { KidEmails: hh.kids, } } - for email, sets := range personHouseholds { - if p := people[email]; p.FamilyKey == "" { - p.FamilyKey = familyKeys[sets[0]] + for email, sets := range l.personHouseholds { + if p := l.people[email]; p.FamilyKey == "" { + p.FamilyKey = l.familyKeys[sets[0]] } } - for email, cells := range familyOverrides { - p := people[email] + for email, cells := range l.familyOverrides { + p := l.people[email] if !p.IsParent { - return nil, fmt.Errorf("overrides row %s has family cells but %s is not a parent", email, email) + return fmt.Errorf("overrides row %s has family cells but %s is not a parent", email, email) } - sets := personHouseholds[email] + sets := l.personHouseholds[email] if len(sets) != 1 { - return nil, fmt.Errorf("overrides row %s has family cells but %s has no household", email, email) + return fmt.Errorf("overrides row %s has family cells but %s has no household", email, email) } - key := familyKeys[sets[0]] - family := model.Families[key] + key := l.familyKeys[sets[0]] + family := l.model.Families[key] if cells.hasAddress { family.Address = cells.address } @@ -443,73 +494,92 @@ func LoadModel(source data.Source, blobs BlobChecker) (*Model, error) { if cells.hasCaption { family.PhotoCaption = cells.caption } - model.Families[key] = family + l.model.Families[key] = family } + return nil +} - for email := range optedOut { - delete(people, email) +func (l *loader) removeOptedOut() error { + for email := range l.optedOut { + delete(l.people, email) } kept := []string{} - for _, email := range order { - if !optedOut[email] { + for _, email := range l.order { + if !l.optedOut[email] { kept = append(kept, email) } } - order = kept - for key, family := range model.Families { - family.AdultEmails = without(family.AdultEmails, optedOut) - family.KidEmails = without(family.KidEmails, optedOut) + 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(model.Families, key) + delete(l.model.Families, key) continue } - family.Name = familyNameFor(family, people) - model.Families[key] = family + family.Name = familyNameFor(family, l.people) + l.model.Families[key] = family } - for _, p := range people { - p.ParentContactEmails = without(p.ParentContactEmails, optedOut) + for _, p := range l.people { + p.ParentContactEmails = without(p.ParentContactEmails, l.optedOut) } - for band, emails := range roomParents { - roomParents[band] = without(emails, optedOut) + for band, emails := range l.roomParents { + l.roomParents[band] = without(emails, l.optedOut) } + return nil +} - if blobs != nil { - for _, p := range people { - local, _, _ := strings.Cut(p.Email, "@") - if blobs.Has("people/" + local + "-photo") { - p.PhotoURL = "/blob/people/" + local + "-photo" - } - if blobs.Has("people/" + local + "-pronunciation") { - p.PronunciationURL = "/blob/people/" + local + "-pronunciation" - } +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" } - for key, family := range model.Families { - if blobs.Has("families/" + key + "-photo") { - family.PhotoURL = "/blob/families/" + key + "-photo" - } - if blobs.Has("families/" + key + "-pronunciation") { - family.PronunciationURL = "/blob/families/" + key + "-pronunciation" - } - model.Families[key] = family + if l.blobs.Has("people/" + local + "-pronunciation") { + p.PronunciationURL = "/blob/people/" + local + "-pronunciation" } } - - for _, email := range order { - model.People = append(model.People, *people[email]) + 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 } - sort.Slice(model.People, func(i, j int) bool { - si, sj := surname(model.People[i].FullName), surname(model.People[j].FullName) + 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 model.People[i].FullName < model.People[j].FullName + return l.model.People[i].FullName < l.model.People[j].FullName }) - - type classroomInfo struct { - crews map[string]bool - minGrade int - bands map[string]bool + 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] == "" { @@ -520,8 +590,8 @@ func LoadModel(source data.Source, blobs BlobChecker) (*Model, error) { info = &classroomInfo{crews: map[string]bool{}, minGrade: len(gradeOrder), bands: map[string]bool{}} classrooms[p.Classroom] = info } - if p.Section != "" { - info.crews[p.Section] = true + if p.Crew != "" { + info.crews[p.Crew] = true } for i, g := range gradeOrder { if g == p.Grade && i < info.minGrade { @@ -544,17 +614,17 @@ func LoadModel(source data.Source, blobs BlobChecker) (*Model, error) { for _, name := range classroomNames { info := classrooms[name] if len(info.bands) != 1 { - return nil, fmt.Errorf("classroom %s spans multiple grade bands", name) + return 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" + 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, - HasSections: len(info.crews) > 0, + Name: name, + ImageURL: imageURL, + HasCrews: len(info.crews) > 0, }) } @@ -564,10 +634,10 @@ func LoadModel(source data.Source, blobs BlobChecker) (*Model, error) { } info, ok := classrooms[p.Classroom] if !ok { - return nil, fmt.Errorf("staff %s is assigned to unknown classroom %q", p.Email, p.Classroom) + return 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) + 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 { @@ -584,33 +654,33 @@ func LoadModel(source data.Source, blobs BlobChecker) (*Model, error) { if len(crews) == 0 { crews = []string{""} } - for _, crew := range crews { - section := Section{Classroom: name, Name: crew, GradeBand: band} + for _, crewName := range crews { + crew := Crew{Classroom: name, Name: crewName, GradeBand: band} for _, p := range model.People { - if p.IsStaff && p.Classroom == name && p.Section == crew { - section.Teachers = append(section.Teachers, p.Email) + if p.IsStaff && p.Classroom == name && p.Crew == crewName { + crew.Teachers = append(crew.Teachers, p.Email) } } - model.Sections = append(model.Sections, section) + 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] } - model.Grades = append(model.Grades, g) + l.model.Grades = append(l.model.Grades, g) } - - for band, emails := range roomParents { - model.RoomParents[bandLabel(band)] = emails + for band, emails := range l.roomParents { + l.model.RoomParents[bandLabel(band)] = emails } - - model.Departments = append(model.Departments, departmentOrder...) - - return model, nil + l.model.Departments = append(l.model.Departments, departmentOrder...) + return nil } func bandLabel(band string) string { diff --git a/internal/directory/model.go b/internal/directory/model.go index 35bb1d7..c120e34 100644 --- a/internal/directory/model.go +++ b/internal/directory/model.go @@ -15,7 +15,7 @@ type Person struct { PhotoURL string `json:"photoUrl,omitempty"` Grade string `json:"grade,omitempty"` Classroom string `json:"classroom,omitempty"` - Section string `json:"section,omitempty"` + Crew string `json:"crew,omitempty"` Phone string `json:"phone,omitempty"` FamilyKey string `json:"familyKey,omitempty"` ParentContactEmails []string `json:"parentContactEmails,omitempty"` @@ -39,12 +39,12 @@ type Family struct { } type Classroom struct { - Name string `json:"name"` - ImageURL string `json:"imageUrl,omitempty"` - HasSections bool `json:"hasSections"` + Name string `json:"name"` + ImageURL string `json:"imageUrl,omitempty"` + HasCrews bool `json:"hasCrews"` } -type Section struct { +type Crew struct { Classroom string `json:"classroom"` Name string `json:"name,omitempty"` Teachers []string `json:"teachers,omitempty"` @@ -58,30 +58,32 @@ type Grade struct { NextBand string `json:"nextBand,omitempty"` } -func (m *Model) Member(email string) bool { - for _, p := range m.People { - if p.Email == email { - return true - } - } - return false -} - -func (m *Model) DisplayName(email string) string { - for _, p := range m.People { - if p.Email == email { - return p.FullName - } - } - return email -} - type Model struct { People []Person `json:"people"` Families map[string]Family `json:"families"` Classrooms []Classroom `json:"classrooms"` - Sections []Section `json:"sections"` + Crews []Crew `json:"crews"` Grades []Grade `json:"grades"` RoomParents map[string][]string `json:"roomParents"` Departments []string `json:"departments"` + byEmail map[string]int +} + +func (m *Model) Person(email string) *Person { + i, ok := m.byEmail[email] + if !ok { + return nil + } + return &m.People[i] +} + +func (m *Model) Member(email string) bool { + return m.Person(email) != nil +} + +func (m *Model) DisplayName(email string) string { + if p := m.Person(email); p != nil { + return p.FullName + } + return email } diff --git a/internal/directory/upload.go b/internal/directory/upload.go index 7c15f32..b3f3f0e 100644 --- a/internal/directory/upload.go +++ b/internal/directory/upload.go @@ -73,6 +73,25 @@ func clearable(value string) string { return value } +func (u uploader) applyOverride(w http.ResponseWriter, actor, email, action string, cells, previous map[string]string) bool { + for column, cell := range cells { + if err := u.sheet.Upsert(appName, "Overrides", "Email", email, column, cell); err != nil { + serverError(w, fmt.Errorf("set %s for %s: %w", column, email, err)) + return false + } + } + logRow := changeLogRow(actor, email, previous) + if err := u.sheet.Append(appName, changeLogTable, changeLogHeader, logRow); err != nil { + serverError(w, fmt.Errorf("append change log after %s for %s: %w", action, email, err)) + return false + } + if err := u.cache.Refresh(); err != nil { + serverError(w, fmt.Errorf("refresh model after %s: %w", action, err)) + return false + } + return true +} + func (u uploader) edit(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, 64<<10) key := strings.ToLower(strings.TrimSpace(r.FormValue("key"))) @@ -80,13 +99,7 @@ func (u uploader) edit(w http.ResponseWriter, r *http.Request) { value := strings.TrimSpace(r.FormValue("value")) me := auth.Email(r) model := u.cache.Model() - var person *Person - for i := range model.People { - if model.People[i].Email == key { - person = &model.People[i] - break - } - } + person := model.Person(key) if person == nil { http.Error(w, "no such person", http.StatusBadRequest) return @@ -144,19 +157,7 @@ func (u uploader) edit(w http.ResponseWriter, r *http.Request) { return } - for column, cell := range cells { - if err := u.sheet.Upsert(appName, "Overrides", "Email", key, column, cell); err != nil { - serverError(w, fmt.Errorf("set %s for %s: %w", column, key, err)) - return - } - } - logRow := changeLogRow(me, key, previous) - if err := u.sheet.Append(appName, changeLogTable, changeLogHeader, logRow); err != nil { - serverError(w, fmt.Errorf("append change log after %s edit for %s: %w", field, key, err)) - return - } - if err := u.cache.Refresh(); err != nil { - serverError(w, fmt.Errorf("refresh model after %s edit: %w", field, err)) + if !u.applyOverride(w, me, key, field+" edit", cells, previous) { return } log.Printf("edit: %s set %s on %s", me, field, key) @@ -175,17 +176,7 @@ func (u uploader) optOut(w http.ResponseWriter, r *http.Request) { http.Error(w, "not allowed to edit this record", http.StatusForbidden) return } - if err := u.sheet.Upsert(appName, "Overrides", "Email", key, "Opted Out", "TRUE"); err != nil { - serverError(w, fmt.Errorf("opt out %s: %w", key, err)) - return - } - logRow := changeLogRow(me, key, map[string]string{"Opted Out": ""}) - if err := u.sheet.Append(appName, changeLogTable, changeLogHeader, logRow); err != nil { - serverError(w, fmt.Errorf("append change log after opt out of %s: %w", key, err)) - return - } - if err := u.cache.Refresh(); err != nil { - serverError(w, fmt.Errorf("refresh model after opt out: %w", err)) + if !u.applyOverride(w, me, key, "opt out", map[string]string{"Opted Out": "TRUE"}, map[string]string{"Opted Out": ""}) { return } log.Printf("optout: %s removed %s from the directory", me, key) @@ -207,23 +198,10 @@ func (u uploader) facts(w http.ResponseWriter, r *http.Request) { return } old := "" - for _, p := range model.People { - if p.Email == key { - old = p.Facts - break - } + if p := model.Person(key); p != nil { + old = p.Facts } - if err := u.sheet.Upsert(appName, "Overrides", "Email", key, "Facts", facts); err != nil { - serverError(w, fmt.Errorf("update facts for %s: %w", key, err)) - return - } - logRow := changeLogRow(me, key, map[string]string{"Facts": old}) - 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)) - return - } - if err := u.cache.Refresh(); err != nil { - serverError(w, fmt.Errorf("refresh model after facts update: %w", err)) + if !u.applyOverride(w, me, key, "facts update", map[string]string{"Facts": facts}, map[string]string{"Facts": old}) { return } log.Printf("facts: %s set %s (%d chars)", me, key, len(facts)) @@ -291,13 +269,7 @@ func (u uploader) upload(w http.ResponseWriter, r *http.Request) { } func mayEdit(model *Model, me, target, key string) bool { - var mine *Person - for i := range model.People { - if model.People[i].Email == me { - mine = &model.People[i] - break - } - } + mine := model.Person(me) if mine == nil { return false } diff --git a/main.go b/main.go index ca5f235..e33a823 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,7 @@ import ( "mime" "net/http" "os" + "path/filepath" "strings" "heliosian/internal/auth" @@ -50,6 +51,13 @@ func clientID() string { return parsed.Web.ClientID } +type staticFiles struct{} + +func (staticFiles) Has(key string) bool { + _, err := os.Stat(filepath.Join("web/static", filepath.FromSlash(key))) + return err == nil +} + func noCache(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-cache") @@ -99,7 +107,7 @@ func main() { } blobs = store } - cache, err := directory.NewCache(source, geocoder, blobs) + cache, err := directory.NewCache(source, geocoder, blobs, staticFiles{}) if err != nil { log.Fatalf("[ERROR] load directory data: %v", err) } diff --git a/sampledata/directory/Change Log.csv b/sampledata/directory/Change Log.csv new file mode 100644 index 0000000..63b059f --- /dev/null +++ b/sampledata/directory/Change Log.csv @@ -0,0 +1 @@ +Timestamp,Actor,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 diff --git a/tools/cookie/main.go b/tools/cookie/main.go index 3b53d68..2c9530b 100644 --- a/tools/cookie/main.go +++ b/tools/cookie/main.go @@ -2,14 +2,13 @@ package main import ( - "crypto/hmac" - "crypto/sha256" - "encoding/base64" "flag" "fmt" "log" "os" "time" + + "heliosian/internal/auth" ) func main() { @@ -19,9 +18,5 @@ func main() { if key == "" || *email == "" { log.Fatal("[ERROR] SESSION_KEY and -email are required") } - payload := fmt.Sprintf("%s|%d", *email, time.Now().Add(24*time.Hour).Unix()) - mac := hmac.New(sha256.New, []byte(key)) - mac.Write([]byte(payload)) - fmt.Println(base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + - base64.RawURLEncoding.EncodeToString(mac.Sum(nil))) + fmt.Println(auth.Token([]byte(key), *email, time.Now().Add(24*time.Hour))) } diff --git a/tools/loadcheck/main.go b/tools/loadcheck/main.go index f665650..ccb1b07 100644 --- a/tools/loadcheck/main.go +++ b/tools/loadcheck/main.go @@ -5,12 +5,21 @@ import ( "flag" "fmt" "log" + "os" + "path/filepath" "sort" "heliosian/internal/data" "heliosian/internal/directory" ) +type staticFiles struct{} + +func (staticFiles) Has(key string) bool { + _, err := os.Stat(filepath.Join("web/static", filepath.FromSlash(key))) + return err == nil +} + func main() { sheet := flag.String("sheet", "", "spreadsheet id") flag.Parse() @@ -21,7 +30,7 @@ func main() { if err != nil { log.Fatalf("[ERROR] sheet source: %v", err) } - model, err := directory.LoadModel(source, nil) + model, err := directory.LoadModel(source, nil, staticFiles{}) if err != nil { log.Fatalf("[ERROR] load model: %v", err) } @@ -56,11 +65,11 @@ func main() { } fmt.Println("classrooms:") for _, c := range model.Classrooms { - fmt.Printf(" %s (image %v, crews %v)\n", c.Name, c.ImageURL != "", c.HasSections) + fmt.Printf(" %s (image %v, crews %v)\n", c.Name, c.ImageURL != "", c.HasCrews) } fmt.Println("crews:") - for _, s := range model.Sections { - fmt.Printf(" %s | %s | %s | teachers %v\n", s.Classroom, s.Name, s.GradeBand, s.Teachers) + for _, c := range model.Crews { + fmt.Printf(" %s | %s | %s | teachers %v\n", c.Classroom, c.Name, c.GradeBand, c.Teachers) } bands := []string{} for band := range model.RoomParents { @@ -76,13 +85,4 @@ func main() { 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) - } - } - } } diff --git a/tools/oneoff/main.go b/tools/oneoff/main.go deleted file mode 100644 index 5d05f7a..0000000 --- a/tools/oneoff/main.go +++ /dev/null @@ -1,81 +0,0 @@ -// Command oneoff appends the Opted Out column to the Overrides and Change Log tabs. -package main - -import ( - "context" - "flag" - "fmt" - "log" - - "heliosian/internal/data" - "google.golang.org/api/option" - "google.golang.org/api/sheets/v4" -) - -func main() { - sheet := flag.String("sheet", "", "spreadsheet id") - flag.Parse() - if *sheet == "" { - log.Fatal("[ERROR] -sheet is required") - } - svc, err := sheets.NewService(context.Background(), - option.WithCredentialsFile(data.KeyFile), - option.WithScopes(sheets.SpreadsheetsScope)) - if err != nil { - log.Fatalf("[ERROR] create sheets client: %v", err) - } - meta, err := svc.Spreadsheets.Get(*sheet).Fields("sheets(properties(sheetId,title,gridProperties(columnCount)))").Do() - if err != nil { - log.Fatalf("[ERROR] get spreadsheet: %v", err) - } - for _, tab := range []string{"Overrides", "Change Log"} { - var props *sheets.SheetProperties - for _, s := range meta.Sheets { - if s.Properties.Title == tab { - props = s.Properties - } - } - if props == nil { - log.Fatalf("[ERROR] no %s tab", tab) - } - resp, err := svc.Spreadsheets.Values.Get(*sheet, fmt.Sprintf("'%s'!1:1", tab)).Do() - if err != nil { - log.Fatalf("[ERROR] read %s header: %v", tab, err) - } - width := len(resp.Values[0]) - for _, cell := range resp.Values[0] { - if fmt.Sprint(cell) == "Opted Out" { - log.Fatalf("[ERROR] %s already has an Opted Out column", tab) - } - } - if int64(width) >= props.GridProperties.ColumnCount { - _, err = svc.Spreadsheets.BatchUpdate(*sheet, &sheets.BatchUpdateSpreadsheetRequest{ - Requests: []*sheets.Request{{AppendDimension: &sheets.AppendDimensionRequest{ - SheetId: props.SheetId, - Dimension: "COLUMNS", - Length: 1, - }}}, - }).Do() - if err != nil { - log.Fatalf("[ERROR] widen %s: %v", tab, err) - } - } - cell := fmt.Sprintf("'%s'!%s1", tab, columnName(width)) - _, err = svc.Spreadsheets.Values.Update(*sheet, cell, &sheets.ValueRange{ - Values: [][]interface{}{{"Opted Out"}}, - }).ValueInputOption("RAW").Do() - if err != nil { - log.Fatalf("[ERROR] write %s header: %v", tab, err) - } - log.Printf("added Opted Out to %s at %s", tab, cell) - } -} - -func columnName(idx int) string { - name := "" - for idx >= 0 { - name = string(rune('A'+idx%26)) + name - idx = idx/26 - 1 - } - return name -} diff --git a/tools/renamefamilyblobs/main.go b/tools/renamefamilyblobs/main.go deleted file mode 100644 index 9c189f8..0000000 --- a/tools/renamefamilyblobs/main.go +++ /dev/null @@ -1,126 +0,0 @@ -// Command renamefamilyblobs renames family media in the drive from parent-email names to family key hashes. -package main - -import ( - "context" - "flag" - "fmt" - "log" - "path" - "strings" - - "heliosian/internal/data" - "heliosian/internal/directory" - "google.golang.org/api/drive/v3" - "google.golang.org/api/option" -) - -const folderMime = "application/vnd.google-apps.folder" - -func main() { - sheet := flag.String("sheet", "", "spreadsheet id") - flag.Parse() - if *sheet == "" { - log.Fatal("[ERROR] -sheet 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) - } - localToHash := map[string]string{} - for _, family := range model.Families { - for _, adult := range family.AdultEmails { - local, _, _ := strings.Cut(adult, "@") - if existing, ok := localToHash[local]; ok && existing != family.Key { - log.Fatalf("[ERROR] adult local part %s maps to two families", local) - } - localToHash[local] = family.Key - } - } - - svc, err := drive.NewService(context.Background(), - option.WithCredentialsFile(data.KeyFile), - option.WithScopes(drive.DriveScope)) - if err != nil { - log.Fatalf("[ERROR] drive client: %v", err) - } - drives, err := svc.Drives.List().Do() - if err != nil { - log.Fatalf("[ERROR] list shared drives: %v", err) - } - if len(drives.Drives) != 1 { - log.Fatalf("[ERROR] expected one shared drive, found %d", len(drives.Drives)) - } - folderList, err := svc.Files.List(). - Q(fmt.Sprintf("name = 'families' and '%s' in parents and mimeType = '%s' and trashed = false", drives.Drives[0].Id, folderMime)). - SupportsAllDrives(true).IncludeItemsFromAllDrives(true).Corpora("allDrives"). - Fields("files(id)").Do() - if err != nil { - log.Fatalf("[ERROR] find families folder: %v", err) - } - if len(folderList.Files) != 1 { - log.Fatalf("[ERROR] expected one families folder, found %d", len(folderList.Files)) - } - folderID := folderList.Files[0].Id - - renamed, kept, unknown := 0, 0, 0 - token := "" - for { - call := svc.Files.List(). - Q(fmt.Sprintf("'%s' in parents and trashed = false", folderID)). - SupportsAllDrives(true).IncludeItemsFromAllDrives(true).Corpora("allDrives"). - Fields("nextPageToken, files(id, name, mimeType)").PageSize(1000) - if token != "" { - call = call.PageToken(token) - } - list, err := call.Do() - if err != nil { - log.Fatalf("[ERROR] list families folder: %v", err) - } - for _, f := range list.Files { - if f.MimeType == folderMime { - continue - } - ext := path.Ext(f.Name) - base := strings.TrimSuffix(f.Name, ext) - kind := "" - for _, k := range []string{"-photo", "-pronunciation"} { - if strings.HasSuffix(base, k) { - kind = k - } - } - if kind == "" { - log.Printf("[ERROR] unrecognized file name %q, leaving it", f.Name) - unknown++ - continue - } - local := strings.TrimSuffix(base, kind) - hash, ok := localToHash[local] - if !ok { - if _, isCurrent := model.Families[local]; isCurrent { - kept++ - continue - } - log.Printf("[ERROR] file %q matches no family adult, leaving it", f.Name) - unknown++ - continue - } - newName := hash + kind + ext - _, err := svc.Files.Update(f.Id, &drive.File{Name: newName}).SupportsAllDrives(true).Do() - if err != nil { - log.Fatalf("[ERROR] rename %q to %q: %v", f.Name, newName, err) - } - log.Printf("renamed %q -> %q", f.Name, newName) - renamed++ - } - if list.NextPageToken == "" { - break - } - token = list.NextPageToken - } - log.Printf("done: %d renamed, %d already keyed by hash, %d left untouched", renamed, kept, unknown) -} diff --git a/tools/startserver/main.go b/tools/startserver/main.go index f5bec7a..d4cb086 100644 --- a/tools/startserver/main.go +++ b/tools/startserver/main.go @@ -2,9 +2,6 @@ package main import ( - "crypto/hmac" - "crypto/sha256" - "encoding/base64" "flag" "fmt" "log" @@ -12,6 +9,8 @@ import ( "os/exec" "strings" "time" + + "heliosian/internal/auth" ) func main() { @@ -56,11 +55,7 @@ func main() { time.Sleep(time.Second) } - payload := fmt.Sprintf("%s|%d", *email, time.Now().Add(24*time.Hour).Unix()) - mac := hmac.New(sha256.New, []byte(key)) - mac.Write([]byte(payload)) - cookie := base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + - base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + cookie := auth.Token([]byte(key), *email, time.Now().Add(24*time.Hour)) content, _ := os.ReadFile("/tmp/heliosian-server.log") for _, line := range strings.Split(strings.TrimSpace(string(content)), "\n") { diff --git a/web/static/directory/app.js b/web/static/directory/app.js index bff1643..3cba86f 100644 --- a/web/static/directory/app.js +++ b/web/static/directory/app.js @@ -189,7 +189,7 @@ function roleLabel(p) { } function gradeChain(p) { - return [p.grade, p.classroom, p.section].filter(Boolean).join(' ▶ '); + return [p.grade, p.classroom, p.crew].filter(Boolean).join(' ▶ '); } function personContext(p) { @@ -1397,11 +1397,11 @@ function parentsOf(students) { function teachersOf(classroomNames) { const seen = new Set(); const teachers = []; - for (const section of state.model.sections) { - if (!classroomNames.includes(section.classroom)) { + for (const crew of state.model.crews) { + if (!classroomNames.includes(crew.classroom)) { continue; } - for (const name of section.teachers || []) { + for (const name of crew.teachers || []) { if (!seen.has(name)) { seen.add(name); teachers.push(name); @@ -1512,9 +1512,9 @@ function renderClassroomDetail(slug) { return; } const students = studentsOf(p => p.classroom === classroom.name); - const sections = [...new Set(students.map(s => s.section).filter(Boolean))].sort(); - const groups = sections.length - ? sections.map(name => ({header: name, students: students.filter(s => s.section === name)})) + const crews = [...new Set(students.map(s => s.crew).filter(Boolean))].sort(); + const groups = crews.length + ? crews.map(name => ({header: name, students: students.filter(s => s.crew === name)})) : [{header: classroom.name, students}]; renderRoster(classroom.name, classroom.imageUrl, groups); }