From f5fa29035303f3c808ecbb52d19ada31050e5966 Mon Sep 17 00:00:00 2001 From: Jeremy Faller Date: Wed, 28 Oct 2020 16:36:22 -0400 Subject: [PATCH] Return CSV if requested from realm stats. Supports 4 modes: ${SERVER}/realm/stats.csv ${SERVER}/realm/stats.csv?user ${SERVER}/realm/stats.json ${SERVER}/realm/stats.json?user Fixes #916 --- cmd/server/assets/realmadmin/show.html | 11 +- internal/routes/server.go | 4 +- pkg/controller/realmadmin/show.go | 145 ++++++++++++++++++++----- pkg/database/realm.go | 13 +++ pkg/database/realm_stats.go | 14 +++ 5 files changed, 159 insertions(+), 28 deletions(-) diff --git a/cmd/server/assets/realmadmin/show.html b/cmd/server/assets/realmadmin/show.html index 85781792a..fb9df7d86 100644 --- a/cmd/server/assets/realmadmin/show.html +++ b/cmd/server/assets/realmadmin/show.html @@ -23,7 +23,16 @@

Realm stats

-
Statistics
+
Statistics +
+ Realm Stats + (CSV/JSON) +
+
+ Realm Per-User Stats + (CSV/JSON) +
+
{{if $stats}}
diff --git a/internal/routes/server.go b/internal/routes/server.go index a56d5a690..0ce6ee4c0 100644 --- a/internal/routes/server.go +++ b/internal/routes/server.go @@ -302,7 +302,9 @@ func Server( realmSub.Handle("/settings", realmadminController.HandleSettings()).Methods("GET", "POST") realmSub.Handle("/settings/enable-express", realmadminController.HandleEnableExpress()).Methods("POST") realmSub.Handle("/settings/disable-express", realmadminController.HandleDisableExpress()).Methods("POST") - realmSub.Handle("/stats", realmadminController.HandleShow()).Methods("GET") + realmSub.Handle("/stats", realmadminController.HandleShow(realmadmin.HTML)).Methods("GET") + realmSub.Handle("/stats.json", realmadminController.HandleShow(realmadmin.JSON)).Methods("GET") + realmSub.Handle("/stats.csv", realmadminController.HandleShow(realmadmin.CSV)).Methods("GET") realmSub.Handle("/stats/{date}", realmadminController.HandleStats()).Methods("GET") realmSub.Handle("/events", realmadminController.HandleEvents()).Methods("GET") diff --git a/pkg/controller/realmadmin/show.go b/pkg/controller/realmadmin/show.go index fc0d40727..15fd46919 100644 --- a/pkg/controller/realmadmin/show.go +++ b/pkg/controller/realmadmin/show.go @@ -16,6 +16,8 @@ package realmadmin import ( "context" + "encoding/csv" + "errors" "net/http" "strconv" "time" @@ -27,46 +29,90 @@ import ( var cacheTimeout = 5 * time.Minute -func (c *Controller) HandleShow() http.Handler { +var errMissingRealm = errors.New("missing realm") + +// ResultType specfies which type of renderer you want. +type ResultType int + +const ( + HTML ResultType = iota + JSON + CSV +) + +// wantUser returns true if we want per-user requests. +func wantUser(r *http.Request) bool { + _, has := r.URL.Query()["user"] + return has +} + +// getRealmStats returns the realm stats for a given date range. +func (c *Controller) getRealmStats(ctx context.Context, realm *database.Realm, now, past time.Time) ([]*database.RealmStats, error) { + var stats []*database.RealmStats + cacheKey := &cache.Key{ + Namespace: "stats:realm", + Key: strconv.FormatUint(uint64(realm.ID), 10), + } + if err := c.cacher.Fetch(ctx, cacheKey, &stats, cacheTimeout, func() (interface{}, error) { + return realm.Stats(c.db, past, now) + }); err != nil { + return nil, err + } + + return stats, nil +} + +// getUserStats gets the per-user realm stats for a given date range. +func (c *Controller) getUserStats(ctx context.Context, realm *database.Realm, now, past time.Time) ([]*database.RealmUserStats, error) { + var userStats []*database.RealmUserStats + cacheKey := &cache.Key{ + Namespace: "stats:realm:per_user", + Key: strconv.FormatUint(uint64(realm.ID), 10), + } + if err := c.cacher.Fetch(ctx, cacheKey, &userStats, cacheTimeout, func() (interface{}, error) { + return realm.CodesPerUser(c.db, past, now) + }); err != nil { + return nil, err + } + return userStats, nil +} + +func (c *Controller) HandleShow(result ResultType) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() + now := time.Now().UTC() + past := now.Add(-30 * 24 * time.Hour) + realm := controller.RealmFromContext(ctx) if realm == nil { controller.MissingRealm(w, r, c.h) - return } - now := time.Now().UTC() - past := now.Add(-30 * 24 * time.Hour) - - // Get and cache the stats for this realm. - var stats []*database.RealmStats - cacheKey := &cache.Key{ - Namespace: "stats:realm", - Key: strconv.FormatUint(uint64(realm.ID), 10), - } - if err := c.cacher.Fetch(ctx, cacheKey, &stats, cacheTimeout, func() (interface{}, error) { - return realm.Stats(c.db, past, now) - }); err != nil { + // Get the realm stats. + stats, err := c.getRealmStats(ctx, realm, now, past) + if err != nil { controller.InternalError(w, r, c.h, err) - return } // Also get the per-user stats. - var userStats []*database.RealmUserStats - cacheKey = &cache.Key{ - Namespace: "stats:realm:per_user", - Key: strconv.FormatUint(uint64(realm.ID), 10), - } - if err := c.cacher.Fetch(ctx, cacheKey, &userStats, cacheTimeout, func() (interface{}, error) { - return realm.CodesPerUser(c.db, past, now) - }); err != nil { + userStats, err := c.getUserStats(ctx, realm, now, past) + if err != nil { controller.InternalError(w, r, c.h, err) - return } - c.renderShow(ctx, w, realm, stats, userStats) + // If the user's requested CSV, return it as such. + switch result { + case CSV: + err = c.renderCSV(r, w, stats, userStats) + case JSON: + err = c.renderJSON(r, w, stats, userStats) + case HTML: + err = c.renderHTML(ctx, w, realm, stats, userStats) + } + if err != nil { + controller.InternalError(w, r, c.h, err) + } }) } @@ -107,7 +153,7 @@ func formatData(userStats []*database.RealmUserStats) ([]string, [][]interface{} return names, data } -func (c *Controller) renderShow(ctx context.Context, w http.ResponseWriter, realm *database.Realm, stats []*database.RealmStats, userStats []*database.RealmUserStats) { +func (c *Controller) renderHTML(ctx context.Context, w http.ResponseWriter, realm *database.Realm, stats []*database.RealmStats, userStats []*database.RealmUserStats) error { names, format := formatData(userStats) m := controller.TemplateMapFromContext(ctx) m["user"] = realm @@ -115,4 +161,51 @@ func (c *Controller) renderShow(ctx context.Context, w http.ResponseWriter, real m["names"] = names m["userStats"] = format c.h.RenderHTML(w, "realmadmin/show", m) + + return nil +} + +// render CSV renders a CSV response. +func (c *Controller) renderCSV(r *http.Request, w http.ResponseWriter, stats []*database.RealmStats, userStats []*database.RealmUserStats) error { + wr := csv.NewWriter(w) + defer wr.Flush() + + // Check if we want the realm stats or the per-user stats. We + // default to realm stats. + if wantUser(r) { + if err := wr.Write(database.RealmUserStatsCSVHeader); err != nil { + return err + } + + for _, u := range userStats { + if err := wr.Write(u.CSV()); err != nil { + return err + } + } + } else { + if err := wr.Write(database.RealmStatsCSVHeader); err != nil { + return err + } + + for _, s := range stats { + if err := wr.Write(s.CSV()); err != nil { + return err + } + } + } + + w.Header().Set("Content-Type", "text/csv") + w.Header().Set("Content-Disposition", "attachment;filename=stats.csv") + return nil +} + +// renderJSON renders a JSON response. +func (c *Controller) renderJSON(r *http.Request, w http.ResponseWriter, stats []*database.RealmStats, userStats []*database.RealmUserStats) error { + if wantUser(r) { + c.h.RenderJSON(w, http.StatusOK, userStats) + } else { + c.h.RenderJSON(w, http.StatusOK, stats) + } + w.Header().Set("Content-Disposition", "attachment;filename=stats.json") + return nil } diff --git a/pkg/database/realm.go b/pkg/database/realm.go index daca35307..100fa4ed9 100644 --- a/pkg/database/realm.go +++ b/pkg/database/realm.go @@ -1316,6 +1316,19 @@ type RealmUserStats struct { Date time.Time `json:"date"` } +// RealmUserStatsCSVHeader is a header for CSV stats +var RealmUserStatsCSVHeader = []string{"User ID", "Name", "Codes Issued", "Date"} + +// CSV returns a slice of the data from a RealmUserStats for CSV writing. +func (s *RealmUserStats) CSV() []string { + ret := make([]string, 4) + ret[0] = fmt.Sprintf("%d", s.UserID) + ret[1] = s.Name + ret[2] = fmt.Sprintf("%d", s.CodesIssued) + ret[3] = s.Date.Format("2006-01-02") + return ret +} + // CodesPerUser returns a set of UserStats for a given date range. func (r *Realm) CodesPerUser(db *Database, start, stop time.Time) ([]*RealmUserStats, error) { start = timeutils.UTCMidnight(start) diff --git a/pkg/database/realm_stats.go b/pkg/database/realm_stats.go index cbd704ac7..145e658cf 100644 --- a/pkg/database/realm_stats.go +++ b/pkg/database/realm_stats.go @@ -15,6 +15,7 @@ package database import ( + "fmt" "time" ) @@ -26,6 +27,19 @@ type RealmStats struct { CodesClaimed uint `gorm:"codes_claimed; default: 0"` } +// RealmStatsCSVHeader is a header for CSV files for RealmStats. +var RealmStatsCSVHeader = []string{"Date", "Realm ID", "Codes Issued", "Codes Claimed"} + +// CSV returns the CSV encoded values for a RealmStats. +func (r *RealmStats) CSV() []string { + ret := make([]string, 4) + ret[0] = r.Date.Format("2006-01-02") + ret[1] = fmt.Sprintf("%d", r.RealmID) + ret[2] = fmt.Sprintf("%d", r.CodesIssued) + ret[3] = fmt.Sprintf("%d", r.CodesClaimed) + return ret +} + // TableName sets the RealmStats table name func (RealmStats) TableName() string { return "realm_stats"