Mirror Overrides columns in Change Log with previous values; add tab tooling

This commit is contained in:
Ian Gulliver
2026-08-16 09:52:24 -07:00
parent 77b0401a8b
commit bbdeb3e91e
3 changed files with 185 additions and 1 deletions
+3 -1
View File
@@ -9,7 +9,7 @@ The entities the directory serves, and how they are assembled. Structured data l
| Veracross Import | import tool | The raw Veracross student export, rewritten wholesale by the import tool on each refresh. Read-only to the serving app; no local edit survives here. |
| Name to Email | hand | Maps a student's name to their school email for import rows where the email cell is empty or wrong, so every person can be keyed by email downstream. |
| Overrides | hand + app | The entire local layer: admin corrections, app-written self-service text, and added people, in canonical model columns keyed by email. |
| Change Log | app | Append-only audit trail of self-service changes. |
| Change Log | app | Append-only audit trail mirroring the Overrides columns: one row per change, holding the previous values. |
School structure lives nowhere in the sheet: membership and the classroom and crew names themselves derive from person records, and the remaining fixed structure — band identities, grade progression, department order — is code constants (see Classrooms and grades).
@@ -67,6 +67,8 @@ Flagged rows must supply every field the model requires; unflagged rows can be a
Family-level fields (address, family photo caption, family phone) ride on a parent's row and apply to that parent's household, so a two-household student's families are addressed independently through their respective adults.
Every change to Overrides appends a Change Log row: timestamp, actor, the row's email, then the previous value of each column that changed — `-` marking a previously empty cell, untouched columns left blank. Media uploads are not logged here; the drive archive is their history.
## Media blobs
Photos and pronunciation recordings are files in the media shared drive, named by convention: `<email local part>-photo` and `<email local part>-pronunciation` for people, `<family key hash>-photo` and `<family key hash>-pronunciation` for families. Presence means existence — no sheet cell records a filename — and freshness comes from the file's modified time. Uploads replace the file and archive the previous version; superseded versions stay in an archive folder.
+88
View File
@@ -0,0 +1,88 @@
// Command createtabs creates the directory sheet's local-layer tabs with their header rows.
package main
import (
"context"
"flag"
"log"
"strings"
"heliosian/internal/data"
"google.golang.org/api/option"
"google.golang.org/api/sheets/v4"
)
var tabs = []struct {
title string
header []string
}{
{"Name to Email", []string{"Name", "Email"}},
{"Overrides", []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",
}},
{"Change Log", []string{
"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",
}},
}
func main() {
sheet := flag.String("sheet", "", "spreadsheet id")
flag.Parse()
if *sheet == "" {
log.Fatal("[ERROR] -sheet <spreadsheet id> 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(title))").Do()
if err != nil {
log.Fatalf("[ERROR] get spreadsheet: %v", err)
}
existing := map[string]bool{}
for _, s := range meta.Sheets {
existing[s.Properties.Title] = true
}
for _, t := range tabs {
if existing[t.title] {
log.Fatalf("[ERROR] tab %q already exists", t.title)
}
}
for _, t := range tabs {
_, err := svc.Spreadsheets.BatchUpdate(*sheet, &sheets.BatchUpdateSpreadsheetRequest{
Requests: []*sheets.Request{{AddSheet: &sheets.AddSheetRequest{
Properties: &sheets.SheetProperties{Title: t.title},
}}},
}).Do()
if err != nil {
log.Fatalf("[ERROR] create tab %q: %v", t.title, err)
}
values := make([]interface{}, len(t.header))
for i, h := range t.header {
values[i] = h
}
quoted := "'" + strings.ReplaceAll(t.title, "'", "''") + "'"
_, err = svc.Spreadsheets.Values.Update(*sheet, quoted+"!1:1", &sheets.ValueRange{
Values: [][]interface{}{values},
}).ValueInputOption("RAW").Do()
if err != nil {
log.Fatalf("[ERROR] write header of %q: %v", t.title, err)
}
log.Printf("created tab %q with %d columns", t.title, len(t.header))
}
}
+94
View File
@@ -0,0 +1,94 @@
// Command oneoff drops the duplicated trailing import column and reshapes the Change Log header.
package main
import (
"context"
"flag"
"fmt"
"log"
"heliosian/internal/data"
"google.golang.org/api/option"
"google.golang.org/api/sheets/v4"
)
var changeLogHeader = []string{
"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",
}
func main() {
sheet := flag.String("sheet", "", "spreadsheet id")
flag.Parse()
if *sheet == "" {
log.Fatal("[ERROR] -sheet <spreadsheet id> 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))").Do()
if err != nil {
log.Fatalf("[ERROR] get spreadsheet: %v", err)
}
importID := int64(-1)
for _, s := range meta.Sheets {
if s.Properties.Title == "Veracross Import" {
importID = s.Properties.SheetId
}
}
if importID < 0 {
log.Fatal("[ERROR] no Veracross Import tab")
}
resp, err := svc.Spreadsheets.Values.Get(*sheet, "'Veracross Import'!AD1:AD1000").Do()
if err != nil {
log.Fatalf("[ERROR] read column AD: %v", err)
}
for i, row := range resp.Values {
if i == 0 {
if len(row) == 0 || fmt.Sprint(row[0]) != "household_2_person_2_phone_business" {
log.Fatalf("[ERROR] column AD header is %v, not the expected duplicate", row)
}
continue
}
if len(row) > 0 && fmt.Sprint(row[0]) != "" {
log.Fatalf("[ERROR] column AD row %d has data %q; not deleting", i+1, row[0])
}
}
_, err = svc.Spreadsheets.BatchUpdate(*sheet, &sheets.BatchUpdateSpreadsheetRequest{
Requests: []*sheets.Request{{DeleteDimension: &sheets.DeleteDimensionRequest{
Range: &sheets.DimensionRange{
SheetId: importID,
Dimension: "COLUMNS",
StartIndex: 29,
EndIndex: 30,
},
}}},
}).Do()
if err != nil {
log.Fatalf("[ERROR] delete column AD: %v", err)
}
log.Print("deleted duplicate import column AD")
values := make([]interface{}, len(changeLogHeader))
for i, h := range changeLogHeader {
values[i] = h
}
_, err = svc.Spreadsheets.Values.Update(*sheet, "'Change Log'!1:1", &sheets.ValueRange{
Values: [][]interface{}{values},
}).ValueInputOption("RAW").Do()
if err != nil {
log.Fatalf("[ERROR] rewrite change log header: %v", err)
}
log.Printf("rewrote Change Log header with %d columns", len(changeLogHeader))
}