Skip to content
This repository has been archived by the owner on Jul 12, 2023. It is now read-only.

Commit

Permalink
Return CSV if requested from realm stats.
Browse files Browse the repository at this point in the history
Supports 4 modes:

${SERVER}/realm/stats.csv
${SERVER}/realm/stats.csv?user
${SERVER}/realm/stats.json
${SERVER}/realm/stats.json?user

Fixes #916
  • Loading branch information
jeremyfaller committed Oct 28, 2020
1 parent 34bf132 commit f5fa290
Show file tree
Hide file tree
Showing 5 changed files with 159 additions and 28 deletions.
11 changes: 10 additions & 1 deletion cmd/server/assets/realmadmin/show.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,16 @@ <h1>Realm stats</h1>
</p>

<div class="card mb-3 shadow-sm">
<div class="card-header">Statistics</div>
<div class="card-header">Statistics
<div style="padding-left: 50px;">
Realm Stats
(<a href="/realm/stats.csv">CSV</a>/<a href="/realm/stats.json">JSON</a>)
</div>
<div style="padding-left: 50px;">
Realm Per-User Stats
(<a href="/realm/stats.csv?user">CSV</a>/<a href="/realm/stats.json?user">JSON</a>)
</div>
</div>
<div class="card-body table-responsive">
{{if $stats}}
<div id="realm_chart" class="mb-3" style="height: 300px;" align="center">
Expand Down
4 changes: 3 additions & 1 deletion internal/routes/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
145 changes: 119 additions & 26 deletions pkg/controller/realmadmin/show.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ package realmadmin

import (
"context"
"encoding/csv"
"errors"
"net/http"
"strconv"
"time"
Expand All @@ -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)
}
})
}

Expand Down Expand Up @@ -107,12 +153,59 @@ 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
m["stats"] = stats
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
}
13 changes: 13 additions & 0 deletions pkg/database/realm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions pkg/database/realm_stats.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
package database

import (
"fmt"
"time"
)

Expand All @@ -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"
Expand Down

0 comments on commit f5fa290

Please sign in to comment.