Phase 6b/7b: dashboard codes editor, decode events, human-readable briefs (AUD-115)
- New /codes dashboard page (behind the session login, linked in the top bar):
lists every mapping by kind with Add, inline Edit+Save and Delete. Sam can put
real teacher surnames in without a rebuild (FR-17).
- Decode timetable EVENTS through the same map as periods, so an ensemble reads
'JAZZ ENSEMBLE - Ensemble Room - Mr KWO' instead of raw codes.
- teacherName splits comma- or space-separated codes ('HHL, OFG').
- SeedCodes is a one-time bootstrap while the table is empty, so a correction or
a delete is never overwritten or restored.
- Tests: decode Label/Event, the /codes page (save, refuse, csrf, delete, auth),
store re-seed behaviour. go test ./internal/... passes.
This commit is contained in:
@@ -36,18 +36,24 @@ hour moves with the day's first event (07:00 on one page, 06:00 on another).
|
||||
## 2. The decode map (FR-17)
|
||||
|
||||
`codes` (migration `005_codes.sql`) is the editable map: `subject`, `room`,
|
||||
`teacher` and `period`. It is seeded on first read and never overwrites a
|
||||
correction. An unknown code renders as the **raw code**, never blank.
|
||||
`teacher` and `period`. It is seeded **once**, while the table is empty, and never
|
||||
overwrites or restores a row after that, so a correction or a delete sticks. An
|
||||
unknown code renders as the **raw code**, never blank.
|
||||
|
||||
The same map decodes the timetable **events**, not only the lesson periods, so
|
||||
an ensemble reads in plain English too:
|
||||
|
||||
```
|
||||
=== FINN ===
|
||||
PERIODS TODAY
|
||||
- Period 1 — Year 7 Science — Room H14LAB — Mr KNI (08:55)
|
||||
...
|
||||
- Period 4 — Year 7 Physical Education — Gym 4 — Mr LTA (13:26)
|
||||
EVENTS TODAY
|
||||
- JAZZ ENSEMBLE — Ensemble Room — Mr KWO (07:45)
|
||||
- JUNIOR GUITAR ENSEMBLE — Theatre — Mr HHL / Mr OFG (07:45)
|
||||
```
|
||||
|
||||
Sam edits it without a rebuild:
|
||||
Sam edits it without a rebuild, **in the dashboard** or by CLI:
|
||||
|
||||
- dashboard: `/codes` (linked as **Codes** in the top bar) lists every mapping by
|
||||
kind with an Add form per kind, an inline Edit + Save per row, and Delete. The
|
||||
page sits behind the same session login as the dashboard.
|
||||
|
||||
```bash
|
||||
docker exec compass-teams-nhs /app/school codes list
|
||||
@@ -56,9 +62,9 @@ docker exec compass-teams-nhs /app/school codes delete --kind room --code Z9
|
||||
```
|
||||
|
||||
Teacher rows are placeholders (`KNI` -> `Mr KNI`) because Compass gives only
|
||||
initials; Sam replaces them with surnames. Real unknown-code fallback from the
|
||||
live brief: Harry's period 2 teacher field is `GT AGE_13`, and `AGE_13` is not
|
||||
mapped, so it renders raw (`Mr GT / AGE_13`).
|
||||
initials; Sam replaces them with surnames in the dashboard. Real unknown-code
|
||||
fallback from the live brief: Harry's period 2 teacher field is `GT AGE_13`, and
|
||||
`AGE_13` is not mapped, so it renders raw (`Mr GT / AGE_13`).
|
||||
|
||||
Seed size: 65 rows — 23 subjects, 22 teachers, 18 rooms, 2 period labels.
|
||||
|
||||
@@ -223,6 +229,8 @@ internal/parse/daywidget.go the per-child "Today" widget
|
||||
internal/parse/parse.go, calendar.go, importance.go per-child hook, week-calendar filter
|
||||
internal/dashboard/dashboard.go brief buckets + the required 4-section renderers
|
||||
internal/brief/brief.go Finn-first order, codes decoder, new renderers
|
||||
internal/web/server.go /codes page + save/delete handlers
|
||||
internal/web/templates/codes.html the dashboard decode-map editor
|
||||
internal/calendar/sync.go periods + due + overdue + events, decoded, once
|
||||
internal/calendar/ha.go Event JSON exposes start/end for evidence
|
||||
cmd/school/{main,parse,calendar,codes,items,notify}.go wiring, codes CLI, purge by page
|
||||
@@ -240,4 +248,5 @@ docker-compose.yml, Makefile image 0.6.2-phase6b7b
|
||||
- The **week calendar** is still not fetched by the daily scan, so a *new*
|
||||
major event is only written if a run fetches that page. The two existing
|
||||
school-wide events remain in the calendar.
|
||||
- **Teacher names** are placeholders; only Sam can supply the surnames.
|
||||
- **Teacher names** are placeholders; Sam supplies the surnames in the new
|
||||
`/codes` dashboard page. Until then they render as initials.
|
||||
|
||||
@@ -138,14 +138,15 @@ func (s *Service) ChildrenText(ctx context.Context, child store.Child) (string,
|
||||
return notice + "\n" + text, false, nil
|
||||
}
|
||||
|
||||
// decoder loads the editable codes map and returns the lesson-title decoder the
|
||||
// brief uses (FR-17). An unknown code renders raw, never blank.
|
||||
// decoder loads the editable codes map and returns the label decoder the brief
|
||||
// uses (FR-17). It decodes lesson periods and timetable events; an unknown code
|
||||
// renders raw, never blank.
|
||||
func (s *Service) decoder(ctx context.Context) (func(string) string, error) {
|
||||
codes, err := s.Store.Codes(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode map: %w", err)
|
||||
}
|
||||
return func(title string) string { return decode.Period(title, codes) }, nil
|
||||
return func(title string) string { return decode.Label(title, codes) }, nil
|
||||
}
|
||||
|
||||
// CombinedText returns the single brief for Jo and Sam — both boys in one
|
||||
|
||||
@@ -419,7 +419,7 @@ func RenderBrief(child string, b Buckets, decode func(string) string, loc *time.
|
||||
fmt.Fprintf(&sb, "=== %s ===\n", strings.ToUpper(strings.TrimSpace(child)))
|
||||
|
||||
writeBriefPeriods(&sb, b.PeriodsToday, decode, loc)
|
||||
writeBriefEvents(&sb, b.EventsToday, loc)
|
||||
writeBriefEvents(&sb, b.EventsToday, decode, loc)
|
||||
writeBriefWork(&sb, "OVERDUE", b.Overdue, loc)
|
||||
writeBriefFuture(&sb, b, loc)
|
||||
return sb.String()
|
||||
@@ -455,19 +455,25 @@ func writeBriefPeriods(sb *strings.Builder, items []store.Item, decode func(stri
|
||||
}
|
||||
}
|
||||
|
||||
// writeBriefEvents lists today's major school events (FR-9b).
|
||||
func writeBriefEvents(sb *strings.Builder, items []store.Item, loc *time.Location) {
|
||||
// writeBriefEvents lists today's major school events (FR-9b), decoded through
|
||||
// the same map as periods so an ensemble or a concert reads as plain English
|
||||
// (FR-17). Unknown codes stay raw.
|
||||
func writeBriefEvents(sb *strings.Builder, items []store.Item, decode func(string) string, loc *time.Location) {
|
||||
sb.WriteString("\nEVENTS TODAY\n")
|
||||
if len(items) == 0 {
|
||||
sb.WriteString("- none\n")
|
||||
return
|
||||
}
|
||||
for _, it := range items {
|
||||
title := strings.TrimSpace(it.Title)
|
||||
if decode != nil {
|
||||
title = decode(title)
|
||||
}
|
||||
when := ""
|
||||
if it.DueAt != nil {
|
||||
when = " (" + it.DueAt.In(loc).Format("15:04") + ")"
|
||||
}
|
||||
fmt.Fprintf(sb, "- %s%s\n", strings.TrimSpace(it.Title), when)
|
||||
fmt.Fprintf(sb, "- %s%s\n", title, when)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,11 +38,12 @@ func Period(title string, m store.CodeMap) string {
|
||||
}
|
||||
|
||||
// teacherName decodes a teacher field. Compass sometimes carries several codes
|
||||
// in the one field ("GT SMH"), so every token is decoded and joined.
|
||||
// in the one field, separated by spaces ("GT SMH") or commas ("HHL, OFG"), so
|
||||
// every token is decoded and joined. An unmapped token stays raw (FR-17).
|
||||
func teacherName(m store.CodeMap, field string) string {
|
||||
tokens := strings.Fields(field)
|
||||
if len(tokens) <= 1 {
|
||||
return codeName(m, store.CodeTeacher, field)
|
||||
tokens := teacherTokens(field)
|
||||
if len(tokens) == 0 {
|
||||
return strings.TrimSpace(field)
|
||||
}
|
||||
out := make([]string, 0, len(tokens))
|
||||
for _, tok := range tokens {
|
||||
@@ -51,6 +52,43 @@ func teacherName(m store.CodeMap, field string) string {
|
||||
return strings.Join(out, " / ")
|
||||
}
|
||||
|
||||
// teacherTokens splits a teacher field on commas and whitespace.
|
||||
func teacherTokens(field string) []string {
|
||||
return strings.Fields(strings.ReplaceAll(field, ",", " "))
|
||||
}
|
||||
|
||||
// Label renders any Compass timetable label as plain English: a lesson label
|
||||
// through Period, anything else (an ensemble, a concert, an excursion) through
|
||||
// Event. It is the decoder the briefs use. An unmapped code stays raw (FR-17).
|
||||
func Label(title string, m store.CodeMap) string {
|
||||
if _, _, _, _, ok := period.Split(strings.TrimSpace(title)); ok {
|
||||
return Period(title, m)
|
||||
}
|
||||
return Event(title, m)
|
||||
}
|
||||
|
||||
// Event renders a timetable event label as plain English:
|
||||
//
|
||||
// "JUNIOR GUITAR ENSEMBLE - THEATRE - HHL, OFG" -> "JUNIOR GUITAR ENSEMBLE — Theatre — Mr HHL / Mr OFG"
|
||||
//
|
||||
// The event name is kept as the school wrote it; the room and teacher codes are
|
||||
// decoded. A label with no room or teacher is returned with just its name, and
|
||||
// an unmapped code stays raw (FR-17).
|
||||
func Event(title string, m store.CodeMap) string {
|
||||
parts := period.SplitDash(strings.TrimSpace(title))
|
||||
if len(parts) <= 1 {
|
||||
return strings.TrimSpace(title)
|
||||
}
|
||||
out := []string{parts[0]}
|
||||
if parts[1] != "" {
|
||||
out = append(out, codeName(m, store.CodeRoom, parts[1]))
|
||||
}
|
||||
if len(parts) >= 3 && parts[2] != "" {
|
||||
out = append(out, teacherName(m, parts[2]))
|
||||
}
|
||||
return strings.Join(out, " — ")
|
||||
}
|
||||
|
||||
// periodName renders the period slot: a word label such as "C" uses the map
|
||||
// ("Connect"), a number becomes "Period 3", and anything unknown stays raw.
|
||||
func periodName(slot string, m store.CodeMap) string {
|
||||
|
||||
@@ -50,3 +50,31 @@ func TestPeriodLeavesEventsAlone(t *testing.T) {
|
||||
t.Errorf("Period = %q, want the event unchanged", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLabelDecodesEventsAndLessons proves FR-17 covers timetable events too: an
|
||||
// ensemble or a concert reads as plain English, its room and teachers decoded,
|
||||
// while the event name is kept as the school wrote it. A single-part label is
|
||||
// returned unchanged and an unmapped code stays raw.
|
||||
func TestLabelDecodesEventsAndLessons(t *testing.T) {
|
||||
codes := testCodes()
|
||||
codes.Room["THEATRE"] = "Theatre"
|
||||
codes.Room["ENS"] = "Ensemble Room"
|
||||
codes.Room["IM3"] = "Room IM3"
|
||||
codes.Teacher["HHL"] = "Mr HHL"
|
||||
codes.Teacher["OFG"] = "Mr OFG"
|
||||
codes.Teacher["KWO"] = "Mr KWO"
|
||||
|
||||
cases := map[string]string{
|
||||
"1 - 7SCF - H14LAB - KNI": "Period 1 — Year 7 Science — Room H14LAB — Mr KNI",
|
||||
"JUNIOR GUITAR ENSEMBLE - THEATRE - HHL, OFG": "JUNIOR GUITAR ENSEMBLE — Theatre — Mr HHL / Mr OFG",
|
||||
"JAZZ ENSEMBLE - ENS - KWO": "JAZZ ENSEMBLE — Ensemble Room — Mr KWO",
|
||||
"HHL GUITAR E - IM3 - HHL": "HHL GUITAR E — Room IM3 — Mr HHL",
|
||||
"Curriculum Day": "Curriculum Day",
|
||||
"Concert - HALL": "Concert — HALL",
|
||||
}
|
||||
for raw, want := range cases {
|
||||
if got := Label(raw, codes); got != want {
|
||||
t.Errorf("Label(%q) = %q, want %q", raw, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,10 +134,18 @@ var seedCodes = []struct {
|
||||
{CodePeriod, "RLE GROUP D", "RLE Group D"},
|
||||
}
|
||||
|
||||
// SeedCodes inserts the shipped mappings that are not present yet. It is
|
||||
// idempotent and never overwrites a value, so Sam's corrections survive every
|
||||
// start and every scan.
|
||||
// SeedCodes inserts the shipped mappings once, when the map is still empty. It
|
||||
// never overwrites a value, so Sam's corrections survive every start and every
|
||||
// scan — and because it is a one-time bootstrap, a mapping Sam deletes in the
|
||||
// dashboard stays deleted instead of reappearing on the next read.
|
||||
func (s *Store) SeedCodes(ctx context.Context) error {
|
||||
var n int
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM codes`).Scan(&n); err != nil {
|
||||
return fmt.Errorf("seed codes: count: %w", err)
|
||||
}
|
||||
if n > 0 {
|
||||
return nil
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("seed codes: begin: %w", err)
|
||||
@@ -157,8 +165,9 @@ func (s *Store) SeedCodes(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Codes returns the whole decode map. It seeds the shipped defaults first, so a
|
||||
// fresh database renders the known captures and a corrected row is kept.
|
||||
// Codes returns the whole decode map. It seeds the shipped defaults first (once)
|
||||
// so a fresh database renders the known captures, and a corrected or deleted row
|
||||
// is kept.
|
||||
func (s *Store) Codes(ctx context.Context) (CodeMap, error) {
|
||||
if err := s.SeedCodes(ctx); err != nil {
|
||||
return CodeMap{}, err
|
||||
|
||||
@@ -79,6 +79,9 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /items/{id}", s.requireAuth(s.handleItemDetail))
|
||||
mux.HandleFunc("POST /items/{id}/delete", s.requireAuth(s.handleItemDelete))
|
||||
mux.HandleFunc("POST /items/{id}/calendar", s.requireAuth(s.handleItemCalendarPush))
|
||||
mux.HandleFunc("GET /codes", s.requireAuth(s.handleCodesPage))
|
||||
mux.HandleFunc("POST /codes", s.requireAuth(s.handleCodeSave))
|
||||
mux.HandleFunc("POST /codes/delete", s.requireAuth(s.handleCodeDelete))
|
||||
mux.HandleFunc("POST /summaries/build", s.requireAuth(s.handleSummaryBuild))
|
||||
mux.HandleFunc("POST /summaries/send", s.requireAuth(s.handleSendBrief))
|
||||
mux.HandleFunc("POST /summaries/{id}/send", s.requireAuth(s.handleSummarySend))
|
||||
@@ -1421,6 +1424,139 @@ func pathID(r *http.Request, name string) (int64, bool) {
|
||||
return v, true
|
||||
}
|
||||
|
||||
// ---------- codes (the editable decode map, FR-17) ----------
|
||||
|
||||
// codeKinds is the page order and the plain-English label for the four decode
|
||||
// groups.
|
||||
var codeKinds = []struct {
|
||||
Kind string
|
||||
Label string
|
||||
Hint string
|
||||
}{
|
||||
{store.CodeSubject, "Subjects", "e.g. 7SCF → Year 7 Science. New subject codes appear each term."},
|
||||
{store.CodeTeacher, "Teachers", "Compass gives initials only. Put the surname here, e.g. KNI → Mr Knight. Unknown initials stay raw."},
|
||||
{store.CodeRoom, "Rooms", "e.g. B11 → Room B11, GYM4 → Gym 4."},
|
||||
{store.CodePeriod, "Period labels", "Word slots only, e.g. C → Connect. A number is always “Period N”."},
|
||||
}
|
||||
|
||||
// codesData renders the editable decode map (AGENTS.md §4 FR-17). The map is
|
||||
// data, not code: a correction here changes the very next brief.
|
||||
type codesData struct {
|
||||
Title string
|
||||
Username string
|
||||
CSRF string
|
||||
Version string
|
||||
AppEnv string
|
||||
Groups []codeGroup
|
||||
ErrMsg string
|
||||
Flash string
|
||||
FlashLevel string
|
||||
}
|
||||
|
||||
type codeGroup struct {
|
||||
Kind string
|
||||
Label string
|
||||
Hint string
|
||||
Codes []codeRow
|
||||
}
|
||||
|
||||
type codeRow struct {
|
||||
Kind string
|
||||
Code string
|
||||
Name string
|
||||
}
|
||||
|
||||
// handleCodesPage lists every mapping so Sam can correct it in the dashboard.
|
||||
func (s *Server) handleCodesPage(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
data := codesData{
|
||||
Title: "Codes",
|
||||
Username: s.cfg.AdminUsername,
|
||||
CSRF: s.auth.EnsureCSRF(w, r),
|
||||
Version: s.cfg.Version,
|
||||
AppEnv: s.cfg.AppEnv,
|
||||
}
|
||||
// Same flash contract as the dashboard: ?msg=… is the text, ?level=error
|
||||
// makes it red.
|
||||
if msg := strings.TrimSpace(r.URL.Query().Get("msg")); msg != "" {
|
||||
data.Flash = msg
|
||||
data.FlashLevel = "ok"
|
||||
}
|
||||
if strings.TrimSpace(r.URL.Query().Get("level")) == "error" {
|
||||
data.FlashLevel = "error"
|
||||
}
|
||||
|
||||
rows, err := s.store.ListCodes(ctx)
|
||||
if err != nil {
|
||||
s.log.Error("list codes", "err", err)
|
||||
data.ErrMsg = "Could not read the codes: " + err.Error()
|
||||
}
|
||||
byKind := map[string][]codeRow{}
|
||||
for _, c := range rows {
|
||||
byKind[c.Kind] = append(byKind[c.Kind], codeRow{Kind: c.Kind, Code: c.Code, Name: c.Name})
|
||||
}
|
||||
for _, k := range codeKinds {
|
||||
data.Groups = append(data.Groups, codeGroup{Kind: k.Kind, Label: k.Label, Hint: k.Hint, Codes: byKind[k.Kind]})
|
||||
}
|
||||
s.render(w, r, "codes.html", data)
|
||||
}
|
||||
|
||||
// handleCodeSave creates or corrects one mapping (FR-17).
|
||||
func (s *Server) handleCodeSave(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !s.auth.CheckCSRF(r) {
|
||||
s.log.Warn("code save rejected: bad csrf token")
|
||||
http.Error(w, "session expired", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
row, err := s.store.SetCode(ctx,
|
||||
strings.TrimSpace(r.PostFormValue("kind")),
|
||||
strings.TrimSpace(r.PostFormValue("code")),
|
||||
strings.TrimSpace(r.PostFormValue("name")))
|
||||
if err != nil {
|
||||
s.redirectAfterWrite(w, r, "/codes?level=error&msg="+url.QueryEscape("Not saved: "+err.Error()))
|
||||
return
|
||||
}
|
||||
s.log.Info("code saved", "kind", row.Kind, "code", row.Code, "name", row.Name)
|
||||
s.redirectAfterWrite(w, r, "/codes?msg="+url.QueryEscape("Saved "+row.Kind+" "+row.Code+" → "+row.Name))
|
||||
}
|
||||
|
||||
// handleCodeDelete removes one mapping, so the code falls back to raw.
|
||||
func (s *Server) handleCodeDelete(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !s.auth.CheckCSRF(r) {
|
||||
s.log.Warn("code delete rejected: bad csrf token")
|
||||
http.Error(w, "session expired", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
kind := strings.TrimSpace(r.PostFormValue("kind"))
|
||||
code := strings.TrimSpace(r.PostFormValue("code"))
|
||||
ok, err := s.store.DeleteCode(ctx, kind, code)
|
||||
switch {
|
||||
case err != nil:
|
||||
s.redirectAfterWrite(w, r, "/codes?level=error&msg="+url.QueryEscape("Not removed: "+err.Error()))
|
||||
case !ok:
|
||||
s.redirectAfterWrite(w, r, "/codes?level=error&msg="+url.QueryEscape("No such code: "+kind+" "+code))
|
||||
default:
|
||||
s.log.Info("code deleted", "kind", kind, "code", code)
|
||||
s.redirectAfterWrite(w, r, "/codes?msg="+url.QueryEscape("Removed "+kind+" "+code+"; it renders raw again."))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- partials ----------
|
||||
|
||||
func (s *Server) handleScanStatusPartial(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -875,3 +875,91 @@ func TestMorningEndpointDryRun(t *testing.T) {
|
||||
t.Errorf("a dry run must not record deliveries: %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCodesPageEditsDecodeMap proves FR-17 in the dashboard: Sam can see the
|
||||
// decode map, correct a teacher initial to a surname, and remove a mapping.
|
||||
// A corrected or deleted mapping must stick, not reappear from the seed.
|
||||
func TestCodesPageEditsDecodeMap(t *testing.T) {
|
||||
ts, st := newTestServer(t)
|
||||
client := login(t, ts)
|
||||
|
||||
page := getBody(t, client, ts.URL+"/codes")
|
||||
if !strings.Contains(page, "KNI") || !strings.Contains(page, "Teachers") {
|
||||
t.Fatalf("codes page does not list the seeded teacher codes")
|
||||
}
|
||||
token := csrfToken(t, page)
|
||||
|
||||
// Correct an initial to a surname.
|
||||
resp, err := client.PostForm(ts.URL+"/codes", url.Values{
|
||||
"csrf": {token}, "kind": {"teacher"}, "code": {"KNI"}, "name": {"Mr Knight"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusSeeOther {
|
||||
t.Fatalf("save status = %d, want 303", resp.StatusCode)
|
||||
}
|
||||
codes, err := st.Codes(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("codes: %v", err)
|
||||
}
|
||||
if codes.Teacher["KNI"] != "Mr Knight" {
|
||||
t.Errorf("teacher KNI = %q, want Mr Knight", codes.Teacher["KNI"])
|
||||
}
|
||||
|
||||
// An empty name is refused and the mapping is unchanged.
|
||||
resp, err = client.PostForm(ts.URL+"/codes", url.Values{
|
||||
"csrf": {token}, "kind": {"teacher"}, "code": {"KNI"}, "name": {""},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("bad save: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if loc := resp.Header.Get("Location"); !strings.Contains(loc, "level=error") {
|
||||
t.Errorf("bad save Location = %q, want an error flash", loc)
|
||||
}
|
||||
if codes, _ := st.Codes(context.Background()); codes.Teacher["KNI"] != "Mr Knight" {
|
||||
t.Errorf("the refused save changed the mapping to %q", codes.Teacher["KNI"])
|
||||
}
|
||||
|
||||
// A bad CSRF token is refused.
|
||||
resp, err = client.PostForm(ts.URL+"/codes", url.Values{
|
||||
"csrf": {"wrong"}, "kind": {"teacher"}, "code": {"KNI"}, "name": {"Mr Nope"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("csrf save: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("bad csrf status = %d, want 403", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Delete returns the code to raw, and it stays deleted.
|
||||
resp, err = client.PostForm(ts.URL+"/codes/delete", url.Values{
|
||||
"csrf": {token}, "kind": {"teacher"}, "code": {"KNI"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusSeeOther {
|
||||
t.Fatalf("delete status = %d, want 303", resp.StatusCode)
|
||||
}
|
||||
if codes, _ := st.Codes(context.Background()); codes.Teacher["KNI"] != "" {
|
||||
t.Errorf("teacher KNI still mapped after delete: %q", codes.Teacher["KNI"])
|
||||
}
|
||||
|
||||
// The page is behind the session login.
|
||||
anon := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}}
|
||||
anonResp, err := anon.Get(ts.URL + "/codes")
|
||||
if err != nil {
|
||||
t.Fatalf("anon get: %v", err)
|
||||
}
|
||||
anonResp.Body.Close()
|
||||
if anonResp.StatusCode != http.StatusSeeOther {
|
||||
t.Errorf("anonymous /codes = %d, want a redirect to login", anonResp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
88
internal/web/templates/codes.html
Normal file
88
internal/web/templates/codes.html
Normal file
@@ -0,0 +1,88 @@
|
||||
{{define "codes.html"}}<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>{{.Title}} — Compass / Teams</title>
|
||||
<link rel="stylesheet" href="/static/tokens.css">
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<script src="/static/theme.js" defer></script>
|
||||
</head>
|
||||
<body class="app">
|
||||
<nav class="nav-bar">
|
||||
<a class="nav-bar__brand" href="/">
|
||||
<span class="nav-bar__mark" aria-hidden="true"></span>
|
||||
Compass / Teams
|
||||
</a>
|
||||
<div class="nav-bar__right">
|
||||
<a class="button-utility" href="/">What is due</a>
|
||||
<button class="button-utility" type="button" data-theme-toggle>Dark</button>
|
||||
<span class="nav-bar__user">{{.Username}}</span>
|
||||
<form method="post" action="/logout" class="nav-bar__logout">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<button class="button-utility" type="submit">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="container">
|
||||
<header class="page-head">
|
||||
<h1 class="heading-1">Codes</h1>
|
||||
<p class="page-head__sub">Plain-English names for the Compass codes in the briefs.
|
||||
A code that is not here renders as the raw code — never blank.</p>
|
||||
</header>
|
||||
|
||||
{{if .ErrMsg}}<p class="flash flash--error" role="alert">{{.ErrMsg}}</p>{{end}}
|
||||
{{if .Flash}}<p class="flash flash--{{.FlashLevel}}" role="status">{{.Flash}}</p>{{end}}
|
||||
|
||||
{{range .Groups}}
|
||||
<section class="section" aria-labelledby="codes-{{.Kind}}-title">
|
||||
<div class="section__head">
|
||||
<h2 id="codes-{{.Kind}}-title" class="heading-2">{{.Label}}</h2>
|
||||
<span class="category-pill">{{len .Codes}}</span>
|
||||
</div>
|
||||
<p class="page-head__sub">{{.Hint}}</p>
|
||||
|
||||
<form method="post" action="/codes" class="inline-form">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="kind" value="{{.Kind}}">
|
||||
<input class="text-input" type="text" name="code" placeholder="code" required>
|
||||
<input class="text-input" type="text" name="name" placeholder="plain English" required>
|
||||
<button class="button-primary" type="submit">Add</button>
|
||||
</form>
|
||||
|
||||
{{if .Codes}}
|
||||
<ul class="item-list">
|
||||
{{range .Codes}}
|
||||
<li class="item">
|
||||
<div class="item__body">
|
||||
<form method="post" action="/codes" class="inline-form">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="kind" value="{{.Kind}}">
|
||||
<input type="hidden" name="code" value="{{.Code}}">
|
||||
<code class="item__subject">{{.Code}}</code>
|
||||
<input class="text-input" type="text" name="name" value="{{.Name}}" required>
|
||||
<button class="button-primary" type="submit">Save</button>
|
||||
</form>
|
||||
</div>
|
||||
<form class="item__actions" method="post" action="/codes/delete"
|
||||
onsubmit="return confirm('Remove this mapping? The code renders raw again.');">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="kind" value="{{.Kind}}">
|
||||
<input type="hidden" name="code" value="{{.Code}}">
|
||||
<button class="button-utility button-utility--danger" type="submit">Delete</button>
|
||||
</form>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p class="section__empty">No codes yet. Add one above.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
</main>
|
||||
|
||||
<footer class="app-foot">Family Home Lab · Compass / Teams</footer>
|
||||
</body>
|
||||
</html>{{end}}
|
||||
@@ -17,6 +17,7 @@
|
||||
Compass / Teams
|
||||
</a>
|
||||
<div class="nav-bar__right">
|
||||
<a class="button-utility" href="/codes">Codes</a>
|
||||
<button class="button-utility" type="button" data-theme-toggle>Dark</button>
|
||||
<span class="nav-bar__user">{{.Username}}</span>
|
||||
<form method="post" action="/logout" class="nav-bar__logout">
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
Compass / Teams
|
||||
</a>
|
||||
<div class="nav-bar__right">
|
||||
<a class="button-utility" href="/codes">Codes</a>
|
||||
<button class="button-utility" type="button" data-theme-toggle>Dark</button>
|
||||
<span class="nav-bar__user">{{.Username}}</span>
|
||||
<form method="post" action="/logout" class="nav-bar__logout">
|
||||
|
||||
Reference in New Issue
Block a user