Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions web/app/students.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package app

import (
"context"
"sort"
"strings"
"sync"

"github.com/rs/zerolog/log"
)

// zpaLookupConcurrency bounds how many ZPA lookups run at once, so enriching a
// 40-student roster is one burst of parallel calls rather than 40 sequential ones,
// without hammering ZPA.
const zpaLookupConcurrency = 8

// CourseStudent is one roster entry, enriched with ZPA details when they could be
// found. Found is false (and the detail fields empty) when ZPA is not configured
// or has no unambiguous match — the GUI then shows just the email.
type CourseStudent struct {
Email string
Found bool
FirstName string
LastName string
Gender string
Group string
Mtknr string
}

// CourseStudents returns the course-level roster of one of the caller's courses,
// each email enriched with ZPA details (concurrently). A ZPA failure for one
// student is logged and leaves that entry un-enriched rather than failing the whole
// page. The result is sorted by last name, then email.
func (a *App) CourseStudents(ctx context.Context, course string) ([]*CourseStudent, error) {
stored, err := a.Course(ctx, course)
if err != nil {
return nil, err
}

var emails []string
if stored.Source != nil {
emails = stored.Source.Students
}
students := make([]*CourseStudent, len(emails))
for i, e := range emails {
students[i] = &CourseStudent{Email: e}
}

if a.zpa != nil {
sem := make(chan struct{}, zpaLookupConcurrency)
var wg sync.WaitGroup
for _, cs := range students {
wg.Add(1)
go func(cs *CourseStudent) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
s, err := a.zpa.StudentByEmail(ctx, cs.Email)
if err != nil {
log.Warn().Err(err).Str("email", cs.Email).Msg("ZPA lookup failed")
return
}
if s != nil {
cs.Found = true
cs.FirstName = s.FirstName
cs.LastName = s.LastName
cs.Gender = s.Gender
cs.Group = s.Group
cs.Mtknr = s.Mtknr
}
}(cs)
}
wg.Wait()
}

// Enriched students first (sorted by last name), then the un-enriched ones
// (sorted by email), so a missing ZPA match sinks to the bottom instead of the
// top.
sort.SliceStable(students, func(i, j int) bool {
si, sj := students[i], students[j]
if si.Found != sj.Found {
return si.Found
}
if li, lj := strings.ToLower(si.LastName), strings.ToLower(sj.LastName); li != lj {
return li < lj
}
return strings.ToLower(si.Email) < strings.ToLower(sj.Email)
})
return students, nil
}
82 changes: 82 additions & 0 deletions web/app/students_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package app

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/obcode/glabs/v3/web/zpa"
)

const rosterCourse = `sc:
coursepath: sc/sem
students:
- a.mueller@hm.edu
- z.unknown@hm.edu
blatt01:
per: student
`

func TestCourseStudents_enrichesFromZPA(t *testing.T) {
const owner = "prof@hm.edu"
fs := newFakeStore()
fs.courses[owner+"/sc"] = storedCourse(t, owner, rosterCourse)

// Mock ZPA: knows a.mueller, does not know z.unknown.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var out []*zpa.Student
if r.URL.Query().Get("ask") == "a.mueller@hm.edu" {
out = []*zpa.Student{{Mtknr: "42", FirstName: "Anna", LastName: "Müller", Email: "a.mueller@hm.edu", Gender: "f", Group: "IF1"}}
}
_ = json.NewEncoder(w).Encode(out)
}))
t.Cleanup(srv.Close)

a := &App{db: fs, zpa: zpa.New(zpa.Config{BaseURL: srv.URL, Token: "x"})}
students, err := a.CourseStudents(ctxAs(owner), "sc")
if err != nil {
t.Fatalf("CourseStudents: %v", err)
}
if len(students) != 2 {
t.Fatalf("got %d students, want 2", len(students))
}
// Enriched student sorts first.
if s := students[0]; !s.Found || s.LastName != "Müller" || s.Group != "IF1" || s.Mtknr != "42" {
t.Errorf("students[0] = %+v, want the enriched Müller", s)
}
// Unknown sinks to the bottom, un-enriched.
if s := students[1]; s.Found || s.Email != "z.unknown@hm.edu" {
t.Errorf("students[1] = %+v, want the un-enriched z.unknown", s)
}
}

// Without a ZPA client the roster still comes back, just un-enriched.
func TestCourseStudents_noZPAReturnsRosterOnly(t *testing.T) {
const owner = "prof@hm.edu"
fs := newFakeStore()
fs.courses[owner+"/sc"] = storedCourse(t, owner, rosterCourse)

a := &App{db: fs} // no zpa
students, err := a.CourseStudents(ctxAs(owner), "sc")
if err != nil {
t.Fatalf("CourseStudents: %v", err)
}
if len(students) != 2 {
t.Fatalf("got %d students, want 2", len(students))
}
for _, s := range students {
if s.Found {
t.Errorf("student %s should be un-enriched without ZPA", s.Email)
}
}
}

// The roster read is owner-scoped: another user's course is invisible.
func TestCourseStudents_requiresAuthentication(t *testing.T) {
a := &App{db: newFakeStore()}
if _, err := a.CourseStudents(context.Background(), "sc"); err == nil {
t.Error("CourseStudents without a principal should fail")
}
}
Loading
Loading