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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user