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..bb929b28a 100644 --- a/pkg/controller/realmadmin/show.go +++ b/pkg/controller/realmadmin/show.go @@ -16,6 +16,7 @@ package realmadmin import ( "context" + "encoding/csv" "net/http" "strconv" "time" @@ -27,46 +28,87 @@ import ( var cacheTimeout = 5 * time.Minute -func (c *Controller) HandleShow() http.Handler { +// 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) + 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 +149,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 +157,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 +} + +// renderCSV 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..9e298fd35 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 { + return []string{ + fmt.Sprintf("%d", s.UserID), + s.Name, + fmt.Sprintf("%d", s.CodesIssued), + s.Date.Format("2006-01-02"), + } +} + // 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..07e8870d6 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 { + return []string{ + r.Date.Format("2006-01-02"), + fmt.Sprintf("%d", r.RealmID), + fmt.Sprintf("%d", r.CodesIssued), + fmt.Sprintf("%d", r.CodesClaimed), + } +} + // TableName sets the RealmStats table name func (RealmStats) TableName() string { return "realm_stats"