Rename Section to Crew, stage the load pipeline, dedupe lookups and token minting, drop burned tools
This commit is contained in:
+1
-1
@@ -133,7 +133,7 @@ The loader hard-fails — no fallbacks, server refuses to start — on:
|
|||||||
- conflicting values for the same adult across import rows
|
- conflicting values for the same adult across import rows
|
||||||
- an invalid canonical value from any layer
|
- 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
|
## Sourcing
|
||||||
|
|
||||||
|
|||||||
+17
@@ -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.
|
- **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.
|
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/<name>`. 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`
|
||||||
|
|||||||
@@ -5,3 +5,7 @@ What remains to build. Current behavior is documented in `docs/dev.md`, `docs/da
|
|||||||
## Hosting and deployment
|
## 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.
|
- 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`.
|
||||||
|
|||||||
+18
-15
@@ -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) {
|
func (a *Auth) Register(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("POST /auth/login", a.login)
|
mux.HandleFunc("POST /auth/login", a.login)
|
||||||
mux.HandleFunc("POST /auth/logout", a.logout)
|
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 {
|
func (a *Auth) Wrap(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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)
|
next.ServeHTTP(w, r)
|
||||||
return
|
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)
|
http.Error(w, "account is not in the school domain", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
expiry := time.Now().Add(sessionLength).Unix()
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: cookieName,
|
Name: cookieName,
|
||||||
Value: a.token(email, expiry),
|
Value: Token(a.key, email, time.Now().Add(sessionLength)),
|
||||||
Path: "/",
|
Path: "/",
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https",
|
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)
|
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 {
|
func (a *Auth) sessionEmail(r *http.Request) string {
|
||||||
cookie, err := r.Cookie(cookieName)
|
cookie, err := r.Cookie(cookieName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -151,7 +154,7 @@ func (a *Auth) sessionEmail(r *http.Request) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
payload := string(decoded)
|
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 ""
|
return ""
|
||||||
}
|
}
|
||||||
fields := strings.Split(payload, "|")
|
fields := strings.Split(payload, "|")
|
||||||
|
|||||||
@@ -19,12 +19,13 @@ type Cache struct {
|
|||||||
source data.Source
|
source data.Source
|
||||||
geocoder Geocoder
|
geocoder Geocoder
|
||||||
blobs BlobChecker
|
blobs BlobChecker
|
||||||
|
static BlobChecker
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
model *Model
|
model *Model
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCache(source data.Source, geocoder Geocoder, blobs BlobChecker) (*Cache, error) {
|
func NewCache(source data.Source, geocoder Geocoder, blobs, static BlobChecker) (*Cache, error) {
|
||||||
c := &Cache{source: source, geocoder: geocoder, blobs: blobs}
|
c := &Cache{source: source, geocoder: geocoder, blobs: blobs, static: static}
|
||||||
if err := c.refresh(); err != nil {
|
if err := c.refresh(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -52,7 +53,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, c.blobs)
|
model, err := LoadModel(c.source, c.blobs, c.static)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -60,8 +61,8 @@ func (c *Cache) refresh() error {
|
|||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
c.model = model
|
c.model = model
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
log.Printf("loaded directory model: %d people, %d families, %d classrooms, %d sections in %s",
|
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.Sections),
|
len(model.People), len(model.Families), len(model.Classrooms), len(model.Crews),
|
||||||
time.Since(start).Round(time.Millisecond))
|
time.Since(start).Round(time.Millisecond))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ type app struct {
|
|||||||
|
|
||||||
func MemberGate(cache *Cache, next http.Handler) http.Handler {
|
func MemberGate(cache *Cache, next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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)
|
next.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -60,14 +60,12 @@ func Register(mux *http.ServeMux, cache *Cache, mapsKey string) {
|
|||||||
func (a app) myFamily(w http.ResponseWriter, r *http.Request) {
|
func (a app) myFamily(w http.ResponseWriter, r *http.Request) {
|
||||||
model := a.cache.Model()
|
model := a.cache.Model()
|
||||||
email := auth.Email(r)
|
email := auth.Email(r)
|
||||||
for _, p := range model.People {
|
if p := model.Person(email); p != nil && p.FamilyKey != "" {
|
||||||
if p.Email == email && p.FamilyKey != "" {
|
|
||||||
if _, ok := model.Families[p.FamilyKey]; ok {
|
if _, ok := model.Families[p.FamilyKey]; ok {
|
||||||
http.Redirect(w, r, "/families/"+url.PathEscape(p.FamilyKey), http.StatusFound)
|
http.Redirect(w, r, "/families/"+url.PathEscape(p.FamilyKey), http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
http.Error(w, "no family record for "+email, http.StatusNotFound)
|
http.Error(w, "no family record for "+email, http.StatusNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+219
-149
@@ -5,7 +5,6 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -88,6 +87,11 @@ type household struct {
|
|||||||
phone string
|
phone string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type familyCells struct {
|
||||||
|
address, phone, caption string
|
||||||
|
hasAddress, hasPhone, hasCaption bool
|
||||||
|
}
|
||||||
|
|
||||||
func requireColumns(table string, header, wanted []string) error {
|
func requireColumns(table string, header, wanted []string) error {
|
||||||
present := map[string]bool{}
|
present := map[string]bool{}
|
||||||
for _, h := range header {
|
for _, h := range header {
|
||||||
@@ -108,51 +112,101 @@ func familyHash(members []string) string {
|
|||||||
return hex.EncodeToString(sum[:])[:16]
|
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")
|
importHeader, importRows, err := source.Table(appName, "Veracross Import")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
if err := requireColumns("Veracross Import", importHeader, importColumns); err != nil {
|
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")
|
mapHeader, mapRows, err := source.Table(appName, "Name to Email")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
if err := requireColumns("Name to Email", mapHeader, []string{"Name", "Email"}); err != nil {
|
if err := requireColumns("Name to Email", mapHeader, []string{"Name", "Email"}); err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
overrideHeader, overrideRows, err := source.Table(appName, "Overrides")
|
overrideHeader, overrideRows, err := source.Table(appName, "Overrides")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
if err := requireColumns("Overrides", overrideHeader, overrideColumns); err != nil {
|
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 {
|
for _, row := range mapRows {
|
||||||
name, email := normName(row["Name"]), strings.ToLower(row["Email"])
|
name, email := normName(row["Name"]), strings.ToLower(row["Email"])
|
||||||
if name == "" || 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 {
|
if _, ok := l.nameToEmail[name]; ok {
|
||||||
return nil, fmt.Errorf("name to email has duplicate name %q", row["Name"])
|
return fmt.Errorf("name to email has duplicate name %q", row["Name"])
|
||||||
}
|
}
|
||||||
nameToEmail[name] = email
|
l.nameToEmail[name] = email
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
mappingUses := map[string]int{}
|
|
||||||
|
|
||||||
people := map[string]*Person{}
|
func (l *loader) addAdult(rawName, email, phone string) error {
|
||||||
order := []string{}
|
|
||||||
households := map[string]*household{}
|
|
||||||
householdOrder := []string{}
|
|
||||||
personHouseholds := map[string][]string{}
|
|
||||||
|
|
||||||
addAdult := func(rawName, email, phone string) error {
|
|
||||||
n := parseName(rawName)
|
n := parseName(rawName)
|
||||||
if p, ok := people[email]; ok {
|
if p, ok := l.people[email]; ok {
|
||||||
if p.FullName != n.display || p.LegalName != n.legal || p.PreferredName != n.preferred {
|
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)
|
return fmt.Errorf("adult %s has conflicting names %q and %q", email, p.FullName, rawName)
|
||||||
}
|
}
|
||||||
@@ -165,53 +219,55 @@ func LoadModel(source data.Source, blobs BlobChecker) (*Model, error) {
|
|||||||
p.IsParent = true
|
p.IsParent = true
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
people[email] = &Person{
|
l.people[email] = &Person{
|
||||||
Email: email, FullName: n.display, LegalName: n.legal, PreferredName: n.preferred,
|
Email: email, FullName: n.display, LegalName: n.legal, PreferredName: n.preferred,
|
||||||
Phone: phone, IsParent: true,
|
Phone: phone, IsParent: true,
|
||||||
}
|
}
|
||||||
order = append(order, email)
|
l.order = append(l.order, email)
|
||||||
return nil
|
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"]
|
rawName := row["student_full_name"]
|
||||||
if rawName == "" {
|
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 {
|
var classifications struct {
|
||||||
GradeLevel string `json:"grade_level"`
|
GradeLevel string `json:"grade_level"`
|
||||||
Homeroom string `json:"homeroom"`
|
Homeroom string `json:"homeroom"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal([]byte(row["student_classifications"]), &classifications); err != nil {
|
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] == "" {
|
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 == "" {
|
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)
|
classroom, crew := splitHomeroom(classifications.Homeroom)
|
||||||
|
|
||||||
email := strings.ToLower(row["student_email"])
|
email := strings.ToLower(row["student_email"])
|
||||||
if mapped, ok := nameToEmail[normName(rawName)]; ok {
|
if mapped, ok := l.nameToEmail[normName(rawName)]; ok {
|
||||||
email = mapped
|
email = mapped
|
||||||
mappingUses[normName(rawName)]++
|
mappingUses[normName(rawName)]++
|
||||||
}
|
}
|
||||||
if email == "" {
|
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 {
|
if _, ok := l.people[email]; ok {
|
||||||
return nil, fmt.Errorf("student email %s appears twice", email)
|
return fmt.Errorf("student email %s appears twice", email)
|
||||||
}
|
}
|
||||||
n := parseName(rawName)
|
n := parseName(rawName)
|
||||||
student := &Person{
|
student := &Person{
|
||||||
Email: email, FullName: n.display, LegalName: n.legal, PreferredName: n.preferred,
|
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"],
|
Phone: row["student_phone_mobile"],
|
||||||
}
|
}
|
||||||
people[email] = student
|
l.people[email] = student
|
||||||
order = append(order, email)
|
l.order = append(l.order, email)
|
||||||
|
|
||||||
for _, hn := range []string{"1", "2"} {
|
for _, hn := range []string{"1", "2"} {
|
||||||
adults := []string{}
|
adults := []string{}
|
||||||
@@ -222,14 +278,14 @@ func LoadModel(source data.Source, blobs BlobChecker) (*Model, error) {
|
|||||||
}
|
}
|
||||||
adultEmail := strings.ToLower(row[prefix+"email"])
|
adultEmail := strings.ToLower(row[prefix+"email"])
|
||||||
if adultEmail == "" {
|
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"]
|
phone := row[prefix+"phone_mobile"]
|
||||||
if phone == "" {
|
if phone == "" {
|
||||||
phone = row[prefix+"phone_business"]
|
phone = row[prefix+"phone_business"]
|
||||||
}
|
}
|
||||||
if err := addAdult(row[prefix+"full_name"], adultEmail, phone); err != nil {
|
if err := l.addAdult(row[prefix+"full_name"], adultEmail, phone); err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
adults = append(adults, adultEmail)
|
adults = append(adults, adultEmail)
|
||||||
}
|
}
|
||||||
@@ -242,70 +298,65 @@ func LoadModel(source data.Source, blobs BlobChecker) (*Model, error) {
|
|||||||
sort.Strings(s)
|
sort.Strings(s)
|
||||||
return s
|
return s
|
||||||
}(), "\n")
|
}(), "\n")
|
||||||
hh, ok := households[setKey]
|
hh, ok := l.households[setKey]
|
||||||
if !ok {
|
if !ok {
|
||||||
hh = &household{adults: adults, address: address, phone: phone}
|
hh = &household{adults: adults, address: address, phone: phone}
|
||||||
households[setKey] = hh
|
l.households[setKey] = hh
|
||||||
householdOrder = append(householdOrder, setKey)
|
l.householdOrder = append(l.householdOrder, setKey)
|
||||||
for _, a := range adults {
|
for _, a := range adults {
|
||||||
if len(personHouseholds[a]) > 0 {
|
if len(l.personHouseholds[a]) > 0 {
|
||||||
return nil, fmt.Errorf("adult %s belongs to more than one household", a)
|
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 {
|
} 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)
|
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...)
|
student.ParentContactEmails = append(student.ParentContactEmails, adults...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for name := range nameToEmail {
|
for name := range l.nameToEmail {
|
||||||
switch mappingUses[name] {
|
switch mappingUses[name] {
|
||||||
case 0:
|
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:
|
case 1:
|
||||||
default:
|
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 {
|
func (l *loader) applyOverrides() error {
|
||||||
address, phone, caption string
|
|
||||||
hasAddress, hasPhone, hasCaption bool
|
|
||||||
}
|
|
||||||
familyOverrides := map[string]familyCells{}
|
|
||||||
bandSet := map[string]bool{}
|
bandSet := map[string]bool{}
|
||||||
for _, band := range gradeBands {
|
for _, band := range gradeBands {
|
||||||
bandSet[band] = true
|
bandSet[band] = true
|
||||||
}
|
}
|
||||||
roomParents := map[string][]string{}
|
seen := map[string]bool{}
|
||||||
optedOut := map[string]bool{}
|
for _, row := range l.overrideRows {
|
||||||
|
|
||||||
seenOverride := map[string]bool{}
|
|
||||||
for _, row := range overrideRows {
|
|
||||||
email := strings.ToLower(row["Email"])
|
email := strings.ToLower(row["Email"])
|
||||||
if 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] {
|
if seen[email] {
|
||||||
return nil, fmt.Errorf("overrides has duplicate email %s", email)
|
return fmt.Errorf("overrides has duplicate email %s", email)
|
||||||
}
|
}
|
||||||
seenOverride[email] = true
|
seen[email] = true
|
||||||
added := row["Added"] == "TRUE"
|
added := row["Added"] == "TRUE"
|
||||||
p, exists := people[email]
|
p, exists := l.people[email]
|
||||||
if added && exists {
|
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 {
|
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 {
|
if added {
|
||||||
p = &Person{Email: email}
|
p = &Person{Email: email}
|
||||||
people[email] = p
|
l.people[email] = p
|
||||||
order = append(order, email)
|
l.order = append(l.order, email)
|
||||||
}
|
}
|
||||||
|
|
||||||
apply := func(cell string, field *string) {
|
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,
|
"Is Student": &p.IsStudent, "Is Parent": &p.IsParent, "Is Staff": &p.IsStaff, "New to Helios": &p.IsNew,
|
||||||
} {
|
} {
|
||||||
if err := applyBool(column, field); err != nil {
|
if err := applyBool(column, field); err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
apply(row["Pronouns"], &p.Pronouns)
|
apply(row["Pronouns"], &p.Pronouns)
|
||||||
apply(row["Facts"], &p.Facts)
|
apply(row["Facts"], &p.Facts)
|
||||||
if cell := row["Grade"]; cell != "" && cell != "-" && !added && gradeBands[cell] == "" {
|
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["Grade"], &p.Grade)
|
||||||
apply(row["Classroom"], &p.Classroom)
|
apply(row["Classroom"], &p.Classroom)
|
||||||
apply(row["Crew"], &p.Section)
|
apply(row["Crew"], &p.Crew)
|
||||||
apply(row["Phone"], &p.Phone)
|
apply(row["Phone"], &p.Phone)
|
||||||
apply(row["Job Title"], &p.JobTitle)
|
apply(row["Job Title"], &p.JobTitle)
|
||||||
apply(row["Department"], &p.Department)
|
apply(row["Department"], &p.Department)
|
||||||
if cell := row["Grade Band"]; cell != "" && cell != "-" && !bandSet[cell] {
|
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)
|
apply(row["Grade Band"], &p.GradeBand)
|
||||||
if cell := row["Room Parent"]; cell != "" && cell != "-" {
|
if cell := row["Room Parent"]; cell != "" && cell != "-" {
|
||||||
if !bandSet[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{}
|
cells := familyCells{}
|
||||||
@@ -381,36 +432,36 @@ func LoadModel(source data.Source, blobs BlobChecker) (*Model, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if cells.hasAddress || cells.hasPhone || cells.hasCaption {
|
if cells.hasAddress || cells.hasPhone || cells.hasCaption {
|
||||||
familyOverrides[email] = cells
|
l.familyOverrides[email] = cells
|
||||||
}
|
}
|
||||||
|
|
||||||
switch row["Opted Out"] {
|
switch row["Opted Out"] {
|
||||||
case "", "-", "FALSE":
|
case "", "-", "FALSE":
|
||||||
case "TRUE":
|
case "TRUE":
|
||||||
optedOut[email] = true
|
l.optedOut[email] = true
|
||||||
default:
|
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 added {
|
||||||
if p.FullName == "" {
|
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 {
|
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{}}
|
func (l *loader) buildFamilies() error {
|
||||||
|
for _, setKey := range l.householdOrder {
|
||||||
familyKeys := map[string]string{}
|
hh := l.households[setKey]
|
||||||
for _, setKey := range householdOrder {
|
|
||||||
hh := households[setKey]
|
|
||||||
members := append(append([]string{}, hh.adults...), hh.kids...)
|
members := append(append([]string{}, hh.adults...), hh.kids...)
|
||||||
key := familyHash(members)
|
key := familyHash(members)
|
||||||
familyKeys[setKey] = key
|
l.familyKeys[setKey] = key
|
||||||
model.Families[key] = Family{
|
l.model.Families[key] = Family{
|
||||||
Key: key,
|
Key: key,
|
||||||
Address: hh.address,
|
Address: hh.address,
|
||||||
Phone: hh.phone,
|
Phone: hh.phone,
|
||||||
@@ -418,22 +469,22 @@ func LoadModel(source data.Source, blobs BlobChecker) (*Model, error) {
|
|||||||
KidEmails: hh.kids,
|
KidEmails: hh.kids,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for email, sets := range personHouseholds {
|
for email, sets := range l.personHouseholds {
|
||||||
if p := people[email]; p.FamilyKey == "" {
|
if p := l.people[email]; p.FamilyKey == "" {
|
||||||
p.FamilyKey = familyKeys[sets[0]]
|
p.FamilyKey = l.familyKeys[sets[0]]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for email, cells := range familyOverrides {
|
for email, cells := range l.familyOverrides {
|
||||||
p := people[email]
|
p := l.people[email]
|
||||||
if !p.IsParent {
|
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 {
|
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]]
|
key := l.familyKeys[sets[0]]
|
||||||
family := model.Families[key]
|
family := l.model.Families[key]
|
||||||
if cells.hasAddress {
|
if cells.hasAddress {
|
||||||
family.Address = cells.address
|
family.Address = cells.address
|
||||||
}
|
}
|
||||||
@@ -443,73 +494,92 @@ func LoadModel(source data.Source, blobs BlobChecker) (*Model, error) {
|
|||||||
if cells.hasCaption {
|
if cells.hasCaption {
|
||||||
family.PhotoCaption = cells.caption
|
family.PhotoCaption = cells.caption
|
||||||
}
|
}
|
||||||
model.Families[key] = family
|
l.model.Families[key] = family
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
for email := range optedOut {
|
func (l *loader) removeOptedOut() error {
|
||||||
delete(people, email)
|
for email := range l.optedOut {
|
||||||
|
delete(l.people, email)
|
||||||
}
|
}
|
||||||
kept := []string{}
|
kept := []string{}
|
||||||
for _, email := range order {
|
for _, email := range l.order {
|
||||||
if !optedOut[email] {
|
if !l.optedOut[email] {
|
||||||
kept = append(kept, email)
|
kept = append(kept, email)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
order = kept
|
l.order = kept
|
||||||
for key, family := range model.Families {
|
for key, family := range l.model.Families {
|
||||||
family.AdultEmails = without(family.AdultEmails, optedOut)
|
family.AdultEmails = without(family.AdultEmails, l.optedOut)
|
||||||
family.KidEmails = without(family.KidEmails, optedOut)
|
family.KidEmails = without(family.KidEmails, l.optedOut)
|
||||||
if len(family.AdultEmails)+len(family.KidEmails) == 0 {
|
if len(family.AdultEmails)+len(family.KidEmails) == 0 {
|
||||||
delete(model.Families, key)
|
delete(l.model.Families, key)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
family.Name = familyNameFor(family, people)
|
family.Name = familyNameFor(family, l.people)
|
||||||
model.Families[key] = family
|
l.model.Families[key] = family
|
||||||
}
|
}
|
||||||
for _, p := range people {
|
for _, p := range l.people {
|
||||||
p.ParentContactEmails = without(p.ParentContactEmails, optedOut)
|
p.ParentContactEmails = without(p.ParentContactEmails, l.optedOut)
|
||||||
}
|
}
|
||||||
for band, emails := range roomParents {
|
for band, emails := range l.roomParents {
|
||||||
roomParents[band] = without(emails, optedOut)
|
l.roomParents[band] = without(emails, l.optedOut)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if blobs != nil {
|
func (l *loader) attachBlobs() error {
|
||||||
for _, p := range people {
|
if l.blobs == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, p := range l.people {
|
||||||
local, _, _ := strings.Cut(p.Email, "@")
|
local, _, _ := strings.Cut(p.Email, "@")
|
||||||
if blobs.Has("people/" + local + "-photo") {
|
if l.blobs.Has("people/" + local + "-photo") {
|
||||||
p.PhotoURL = "/blob/people/" + local + "-photo"
|
p.PhotoURL = "/blob/people/" + local + "-photo"
|
||||||
}
|
}
|
||||||
if blobs.Has("people/" + local + "-pronunciation") {
|
if l.blobs.Has("people/" + local + "-pronunciation") {
|
||||||
p.PronunciationURL = "/blob/people/" + local + "-pronunciation"
|
p.PronunciationURL = "/blob/people/" + local + "-pronunciation"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for key, family := range model.Families {
|
for key, family := range l.model.Families {
|
||||||
if blobs.Has("families/" + key + "-photo") {
|
if l.blobs.Has("families/" + key + "-photo") {
|
||||||
family.PhotoURL = "/blob/families/" + key + "-photo"
|
family.PhotoURL = "/blob/families/" + key + "-photo"
|
||||||
}
|
}
|
||||||
if blobs.Has("families/" + key + "-pronunciation") {
|
if l.blobs.Has("families/" + key + "-pronunciation") {
|
||||||
family.PronunciationURL = "/blob/families/" + key + "-pronunciation"
|
family.PronunciationURL = "/blob/families/" + key + "-pronunciation"
|
||||||
}
|
}
|
||||||
model.Families[key] = family
|
l.model.Families[key] = family
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, email := range order {
|
func (l *loader) sortPeople() error {
|
||||||
model.People = append(model.People, *people[email])
|
for _, email := range l.order {
|
||||||
|
l.model.People = append(l.model.People, *l.people[email])
|
||||||
}
|
}
|
||||||
sort.Slice(model.People, func(i, j int) bool {
|
sort.Slice(l.model.People, func(i, j int) bool {
|
||||||
si, sj := surname(model.People[i].FullName), surname(model.People[j].FullName)
|
si, sj := surname(l.model.People[i].FullName), surname(l.model.People[j].FullName)
|
||||||
if si != sj {
|
if si != sj {
|
||||||
return 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
|
||||||
})
|
})
|
||||||
|
l.model.byEmail = map[string]int{}
|
||||||
|
for i, p := range l.model.People {
|
||||||
|
l.model.byEmail[p.Email] = i
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type classroomInfo struct {
|
type classroomInfo struct {
|
||||||
crews map[string]bool
|
crews map[string]bool
|
||||||
minGrade int
|
minGrade int
|
||||||
bands map[string]bool
|
bands map[string]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *loader) deriveClassrooms() error {
|
||||||
|
model := l.model
|
||||||
classrooms := map[string]*classroomInfo{}
|
classrooms := map[string]*classroomInfo{}
|
||||||
for _, p := range model.People {
|
for _, p := range model.People {
|
||||||
if p.Classroom == "" || !p.IsStudent || gradeBands[p.Grade] == "" {
|
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{}}
|
info = &classroomInfo{crews: map[string]bool{}, minGrade: len(gradeOrder), bands: map[string]bool{}}
|
||||||
classrooms[p.Classroom] = info
|
classrooms[p.Classroom] = info
|
||||||
}
|
}
|
||||||
if p.Section != "" {
|
if p.Crew != "" {
|
||||||
info.crews[p.Section] = true
|
info.crews[p.Crew] = true
|
||||||
}
|
}
|
||||||
for i, g := range gradeOrder {
|
for i, g := range gradeOrder {
|
||||||
if g == p.Grade && i < info.minGrade {
|
if g == p.Grade && i < info.minGrade {
|
||||||
@@ -544,17 +614,17 @@ func LoadModel(source data.Source, blobs BlobChecker) (*Model, error) {
|
|||||||
for _, name := range classroomNames {
|
for _, name := range classroomNames {
|
||||||
info := classrooms[name]
|
info := classrooms[name]
|
||||||
if len(info.bands) != 1 {
|
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 := ""
|
imageURL := ""
|
||||||
imagePath := "web/static/brand/classrooms/classroom-" + strings.ToLower(name) + ".jpg"
|
imageKey := "brand/classrooms/classroom-" + strings.ToLower(name) + ".jpg"
|
||||||
if _, err := os.Stat(imagePath); err == nil {
|
if l.static.Has(imageKey) {
|
||||||
imageURL = "/static/brand/classrooms/classroom-" + strings.ToLower(name) + ".jpg"
|
imageURL = "/static/" + imageKey
|
||||||
}
|
}
|
||||||
model.Classrooms = append(model.Classrooms, Classroom{
|
model.Classrooms = append(model.Classrooms, Classroom{
|
||||||
Name: name,
|
Name: name,
|
||||||
ImageURL: imageURL,
|
ImageURL: imageURL,
|
||||||
HasSections: len(info.crews) > 0,
|
HasCrews: len(info.crews) > 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -564,10 +634,10 @@ func LoadModel(source data.Source, blobs BlobChecker) (*Model, error) {
|
|||||||
}
|
}
|
||||||
info, ok := classrooms[p.Classroom]
|
info, ok := classrooms[p.Classroom]
|
||||||
if !ok {
|
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] {
|
if p.Crew != "" && !info.crews[p.Crew] {
|
||||||
return nil, fmt.Errorf("staff %s is assigned to unknown crew %q of %s", p.Email, p.Section, p.Classroom)
|
return fmt.Errorf("staff %s is assigned to unknown crew %q of %s", p.Email, p.Crew, p.Classroom)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, name := range classroomNames {
|
for _, name := range classroomNames {
|
||||||
@@ -584,33 +654,33 @@ func LoadModel(source data.Source, blobs BlobChecker) (*Model, error) {
|
|||||||
if len(crews) == 0 {
|
if len(crews) == 0 {
|
||||||
crews = []string{""}
|
crews = []string{""}
|
||||||
}
|
}
|
||||||
for _, crew := range crews {
|
for _, crewName := range crews {
|
||||||
section := Section{Classroom: name, Name: crew, GradeBand: band}
|
crew := Crew{Classroom: name, Name: crewName, GradeBand: band}
|
||||||
for _, p := range model.People {
|
for _, p := range model.People {
|
||||||
if p.IsStaff && p.Classroom == name && p.Section == crew {
|
if p.IsStaff && p.Classroom == name && p.Crew == crewName {
|
||||||
section.Teachers = append(section.Teachers, p.Email)
|
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 {
|
for i, grade := range gradeOrder {
|
||||||
g := Grade{Name: grade, Band: gradeBands[grade]}
|
g := Grade{Name: grade, Band: gradeBands[grade]}
|
||||||
if i+1 < len(gradeOrder) {
|
if i+1 < len(gradeOrder) {
|
||||||
g.NextName = gradeOrder[i+1]
|
g.NextName = gradeOrder[i+1]
|
||||||
g.NextBand = gradeBands[g.NextName]
|
g.NextBand = gradeBands[g.NextName]
|
||||||
}
|
}
|
||||||
model.Grades = append(model.Grades, g)
|
l.model.Grades = append(l.model.Grades, g)
|
||||||
}
|
}
|
||||||
|
for band, emails := range l.roomParents {
|
||||||
for band, emails := range roomParents {
|
l.model.RoomParents[bandLabel(band)] = emails
|
||||||
model.RoomParents[bandLabel(band)] = emails
|
|
||||||
}
|
}
|
||||||
|
l.model.Departments = append(l.model.Departments, departmentOrder...)
|
||||||
model.Departments = append(model.Departments, departmentOrder...)
|
return nil
|
||||||
|
|
||||||
return model, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func bandLabel(band string) string {
|
func bandLabel(band string) string {
|
||||||
|
|||||||
+24
-22
@@ -15,7 +15,7 @@ type Person struct {
|
|||||||
PhotoURL string `json:"photoUrl,omitempty"`
|
PhotoURL string `json:"photoUrl,omitempty"`
|
||||||
Grade string `json:"grade,omitempty"`
|
Grade string `json:"grade,omitempty"`
|
||||||
Classroom string `json:"classroom,omitempty"`
|
Classroom string `json:"classroom,omitempty"`
|
||||||
Section string `json:"section,omitempty"`
|
Crew string `json:"crew,omitempty"`
|
||||||
Phone string `json:"phone,omitempty"`
|
Phone string `json:"phone,omitempty"`
|
||||||
FamilyKey string `json:"familyKey,omitempty"`
|
FamilyKey string `json:"familyKey,omitempty"`
|
||||||
ParentContactEmails []string `json:"parentContactEmails,omitempty"`
|
ParentContactEmails []string `json:"parentContactEmails,omitempty"`
|
||||||
@@ -41,10 +41,10 @@ type Family struct {
|
|||||||
type Classroom struct {
|
type Classroom struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
ImageURL string `json:"imageUrl,omitempty"`
|
ImageURL string `json:"imageUrl,omitempty"`
|
||||||
HasSections bool `json:"hasSections"`
|
HasCrews bool `json:"hasCrews"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Section struct {
|
type Crew struct {
|
||||||
Classroom string `json:"classroom"`
|
Classroom string `json:"classroom"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
Teachers []string `json:"teachers,omitempty"`
|
Teachers []string `json:"teachers,omitempty"`
|
||||||
@@ -58,30 +58,32 @@ type Grade struct {
|
|||||||
NextBand string `json:"nextBand,omitempty"`
|
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 {
|
type Model struct {
|
||||||
People []Person `json:"people"`
|
People []Person `json:"people"`
|
||||||
Families map[string]Family `json:"families"`
|
Families map[string]Family `json:"families"`
|
||||||
Classrooms []Classroom `json:"classrooms"`
|
Classrooms []Classroom `json:"classrooms"`
|
||||||
Sections []Section `json:"sections"`
|
Crews []Crew `json:"crews"`
|
||||||
Grades []Grade `json:"grades"`
|
Grades []Grade `json:"grades"`
|
||||||
RoomParents map[string][]string `json:"roomParents"`
|
RoomParents map[string][]string `json:"roomParents"`
|
||||||
Departments []string `json:"departments"`
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,25 @@ func clearable(value string) string {
|
|||||||
return value
|
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) {
|
func (u uploader) edit(w http.ResponseWriter, r *http.Request) {
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
|
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
|
||||||
key := strings.ToLower(strings.TrimSpace(r.FormValue("key")))
|
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"))
|
value := strings.TrimSpace(r.FormValue("value"))
|
||||||
me := auth.Email(r)
|
me := auth.Email(r)
|
||||||
model := u.cache.Model()
|
model := u.cache.Model()
|
||||||
var person *Person
|
person := model.Person(key)
|
||||||
for i := range model.People {
|
|
||||||
if model.People[i].Email == key {
|
|
||||||
person = &model.People[i]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if person == nil {
|
if person == nil {
|
||||||
http.Error(w, "no such person", http.StatusBadRequest)
|
http.Error(w, "no such person", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
@@ -144,19 +157,7 @@ func (u uploader) edit(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for column, cell := range cells {
|
if !u.applyOverride(w, me, key, field+" edit", cells, previous) {
|
||||||
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))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("edit: %s set %s on %s", me, field, key)
|
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)
|
http.Error(w, "not allowed to edit this record", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := u.sheet.Upsert(appName, "Overrides", "Email", key, "Opted Out", "TRUE"); err != nil {
|
if !u.applyOverride(w, me, key, "opt out", map[string]string{"Opted Out": "TRUE"}, map[string]string{"Opted Out": ""}) {
|
||||||
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))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("optout: %s removed %s from the directory", me, key)
|
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
|
return
|
||||||
}
|
}
|
||||||
old := ""
|
old := ""
|
||||||
for _, p := range model.People {
|
if p := model.Person(key); p != nil {
|
||||||
if p.Email == key {
|
|
||||||
old = p.Facts
|
old = p.Facts
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
if !u.applyOverride(w, me, key, "facts update", map[string]string{"Facts": facts}, map[string]string{"Facts": old}) {
|
||||||
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))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("facts: %s set %s (%d chars)", me, key, len(facts))
|
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 {
|
func mayEdit(model *Model, me, target, key string) bool {
|
||||||
var mine *Person
|
mine := model.Person(me)
|
||||||
for i := range model.People {
|
|
||||||
if model.People[i].Email == me {
|
|
||||||
mine = &model.People[i]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if mine == nil {
|
if mine == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"mime"
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"heliosian/internal/auth"
|
"heliosian/internal/auth"
|
||||||
@@ -50,6 +51,13 @@ func clientID() string {
|
|||||||
return parsed.Web.ClientID
|
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 {
|
func noCache(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
@@ -99,7 +107,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
blobs = store
|
blobs = store
|
||||||
}
|
}
|
||||||
cache, err := directory.NewCache(source, geocoder, blobs)
|
cache, err := directory.NewCache(source, geocoder, blobs, staticFiles{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("[ERROR] load directory data: %v", err)
|
log.Fatalf("[ERROR] load directory data: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
@@ -2,14 +2,13 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/hmac"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"heliosian/internal/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -19,9 +18,5 @@ func main() {
|
|||||||
if key == "" || *email == "" {
|
if key == "" || *email == "" {
|
||||||
log.Fatal("[ERROR] SESSION_KEY and -email are required")
|
log.Fatal("[ERROR] SESSION_KEY and -email are required")
|
||||||
}
|
}
|
||||||
payload := fmt.Sprintf("%s|%d", *email, time.Now().Add(24*time.Hour).Unix())
|
fmt.Println(auth.Token([]byte(key), *email, time.Now().Add(24*time.Hour)))
|
||||||
mac := hmac.New(sha256.New, []byte(key))
|
|
||||||
mac.Write([]byte(payload))
|
|
||||||
fmt.Println(base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." +
|
|
||||||
base64.RawURLEncoding.EncodeToString(mac.Sum(nil)))
|
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-13
@@ -5,12 +5,21 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
"heliosian/internal/data"
|
"heliosian/internal/data"
|
||||||
"heliosian/internal/directory"
|
"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() {
|
func main() {
|
||||||
sheet := flag.String("sheet", "", "spreadsheet id")
|
sheet := flag.String("sheet", "", "spreadsheet id")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
@@ -21,7 +30,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, nil)
|
model, err := directory.LoadModel(source, nil, staticFiles{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("[ERROR] load model: %v", err)
|
log.Fatalf("[ERROR] load model: %v", err)
|
||||||
}
|
}
|
||||||
@@ -56,11 +65,11 @@ func main() {
|
|||||||
}
|
}
|
||||||
fmt.Println("classrooms:")
|
fmt.Println("classrooms:")
|
||||||
for _, c := range model.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:")
|
fmt.Println("crews:")
|
||||||
for _, s := range model.Sections {
|
for _, c := range model.Crews {
|
||||||
fmt.Printf(" %s | %s | %s | teachers %v\n", s.Classroom, s.Name, s.GradeBand, s.Teachers)
|
fmt.Printf(" %s | %s | %s | teachers %v\n", c.Classroom, c.Name, c.GradeBand, c.Teachers)
|
||||||
}
|
}
|
||||||
bands := []string{}
|
bands := []string{}
|
||||||
for band := range model.RoomParents {
|
for band := range model.RoomParents {
|
||||||
@@ -76,13 +85,4 @@ func main() {
|
|||||||
for _, g := range model.Grades {
|
for _, g := range model.Grades {
|
||||||
fmt.Printf(" %s -> %s (%s -> %s)\n", g.Name, g.NextName, g.Band, g.NextBand)
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 <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,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
|
|
||||||
}
|
|
||||||
@@ -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 <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)
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
@@ -2,9 +2,6 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/hmac"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
@@ -12,6 +9,8 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"heliosian/internal/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -56,11 +55,7 @@ func main() {
|
|||||||
time.Sleep(time.Second)
|
time.Sleep(time.Second)
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := fmt.Sprintf("%s|%d", *email, time.Now().Add(24*time.Hour).Unix())
|
cookie := auth.Token([]byte(key), *email, time.Now().Add(24*time.Hour))
|
||||||
mac := hmac.New(sha256.New, []byte(key))
|
|
||||||
mac.Write([]byte(payload))
|
|
||||||
cookie := base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." +
|
|
||||||
base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
|
||||||
|
|
||||||
content, _ := os.ReadFile("/tmp/heliosian-server.log")
|
content, _ := os.ReadFile("/tmp/heliosian-server.log")
|
||||||
for _, line := range strings.Split(strings.TrimSpace(string(content)), "\n") {
|
for _, line := range strings.Split(strings.TrimSpace(string(content)), "\n") {
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ function roleLabel(p) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function gradeChain(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) {
|
function personContext(p) {
|
||||||
@@ -1397,11 +1397,11 @@ function parentsOf(students) {
|
|||||||
function teachersOf(classroomNames) {
|
function teachersOf(classroomNames) {
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
const teachers = [];
|
const teachers = [];
|
||||||
for (const section of state.model.sections) {
|
for (const crew of state.model.crews) {
|
||||||
if (!classroomNames.includes(section.classroom)) {
|
if (!classroomNames.includes(crew.classroom)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for (const name of section.teachers || []) {
|
for (const name of crew.teachers || []) {
|
||||||
if (!seen.has(name)) {
|
if (!seen.has(name)) {
|
||||||
seen.add(name);
|
seen.add(name);
|
||||||
teachers.push(name);
|
teachers.push(name);
|
||||||
@@ -1512,9 +1512,9 @@ function renderClassroomDetail(slug) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const students = studentsOf(p => p.classroom === classroom.name);
|
const students = studentsOf(p => p.classroom === classroom.name);
|
||||||
const sections = [...new Set(students.map(s => s.section).filter(Boolean))].sort();
|
const crews = [...new Set(students.map(s => s.crew).filter(Boolean))].sort();
|
||||||
const groups = sections.length
|
const groups = crews.length
|
||||||
? sections.map(name => ({header: name, students: students.filter(s => s.section === name)}))
|
? crews.map(name => ({header: name, students: students.filter(s => s.crew === name)}))
|
||||||
: [{header: classroom.name, students}];
|
: [{header: classroom.name, students}];
|
||||||
renderRoster(classroom.name, classroom.imageUrl, groups);
|
renderRoster(classroom.name, classroom.imageUrl, groups);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user