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

Serve user statistics via javascript #1496

Merged
merged 4 commits into from
Dec 30, 2020
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
6 changes: 3 additions & 3 deletions cmd/server/assets/realmadmin/_stats_external_issuers.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
<a href="#" data-toggle="modal" data-target="#per-external-issuer-table-modal">Learn more about this table</a>
<span>
<span class="mr-1">Export as:</span>
<a href="/stats/realm-external-issuer.csv" class="mr-1">CSV</a>
<a href="/stats/realm-external-issuer.json">JSON</a>
<a href="/stats/realm/external-issuers.csv" class="mr-1">CSV</a>
<a href="/stats/realm/external-issuers.json">JSON</a>
</span>
</small>
</div>
Expand Down Expand Up @@ -68,7 +68,7 @@ <h5 class="modal-title">Codes issued by external issuers by day</h5>

function drawExternalIssuersTable() {
$.ajax({
url: '/stats/realm-external-issuer.json',
url: '/stats/realm/external-issuers.json',
data: { scope: 'external' },
dataType: 'json',
})
Expand Down
6 changes: 3 additions & 3 deletions cmd/server/assets/realmadmin/_stats_users.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
<a href="#" data-toggle="modal" data-target="#per-user-table-modal">Learn more about this table</a>
<span>
<span class="mr-1">Export as:</span>
<a href="/stats/realm-user.csv" class="mr-1">CSV</a>
<a href="/stats/realm-user.json">JSON</a>
<a href="/stats/realm/users.csv" class="mr-1">CSV</a>
<a href="/stats/realm/users.json">JSON</a>
</span>
</small>
</div>
Expand Down Expand Up @@ -60,7 +60,7 @@ <h5 class="modal-title">Codes issued by user by day</h5>

function drawUsersTable() {
$.ajax({
url: '/stats/realm-user.json',
url: '/stats/realm/users.json',
data: { scope: 'user' },
dataType: 'json',
})
Expand Down
58 changes: 25 additions & 33 deletions cmd/server/assets/users/show.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
<html lang="en">
<head>
{{template "head" .}}
<script src="https://www.gstatic.com/charts/loader.js"></script>
</head>

<body id="users-show" class="tab-content">
Expand Down Expand Up @@ -79,15 +80,13 @@ <h6 class="card-title">Permissions</h6>
</div>
</div>

<div class="card mb-3 shadow-sm">
<div class="card mb-3 shadow-sm mb-3">
<div class="card-header">
<span class="oi oi-bar-chart mr-2 ml-n1"></span>
Statistics
</div>
<div class="card-body">
<div id="user_stats_chart" class="container d-flex h-100 w-100" style="min-height:300px;">
<p class="justify-content-center align-self-center text-center font-italic w-100">Loading chart...</p>
</div>
<div id="user_stats_chart" class="container d-flex h-100 w-100" style="min-height:300px;">
<p class="justify-content-center align-self-center text-center font-italic w-100">Loading chart...</p>
</div>
<small class="card-footer d-flex justify-content-between text-muted">
<span>
Expand All @@ -96,8 +95,8 @@ <h6 class="card-title">Permissions</h6>
</span>
<span>
<span class="mr-1">Export as:</span>
<a href="/realm/users/{{$user.ID}}/stats.csv" class="mr-1">CSV</a>
<a href="/realm/users/{{$user.ID}}/stats.json">JSON</a>
<a href="/stats/realm/users/{{$user.ID}}.csv" class="mr-1">CSV</a>
<a href="/stats/realm/users/{{$user.ID}}.json">JSON</a>
</span>
</small>
</div>
Expand All @@ -113,9 +112,8 @@ <h5 class="modal-title">Codes issued by {{$user.Name}}</h5>
</div>
<div class="modal-body mb-n3">
<p>
This chart reflects the number of codes issued each by
{{$user.Name}} ({{$user.Email}}) for
{{$currentMembership.Realm.Name}}.
This chart reflects the number of codes issued by {{$user.Name}}
({{$user.Email}}) for {{$currentMembership.Realm.Name}}.
</p>
<p>
This chart does <u>not</u> include codes that were issued via the
Expand All @@ -128,30 +126,22 @@ <h5 class="modal-title">Codes issued by {{$user.Name}}</h5>
</div>
</main>

<script src="https://www.gstatic.com/charts/loader.js"></script>
<script>
let userStatsChartDiv = document.getElementById('user_stats_chart');
let dateFormatter;

<script type="text/javascript">
google.charts.load('current', {
packages: ['corechart'],
callback: function() {
dateFormatter = new google.visualization.DateFormat({
pattern: 'MMM dd',
});

drawStats();
},
callback: drawRealmCharts,
});

function drawStats() {
function drawRealmCharts() {
$.ajax({
url: '/realm/users/{{$user.ID}}/stats.json',
url: '/stats/realm/users/{{$user.ID}}.json',
dataType: 'json',
})
.done(function(data, status, xhr) {
let $userStatsChart = $('#user_stats_chart');

if (!data.statistics) {
$(userStatsChartDiv).find('p').text('This user has not yet issued any codes.');
$userStatsChart.find('p').text('No data yet.');
return;
}

Expand All @@ -163,26 +153,28 @@ <h5 class="modal-title">Codes issued by {{$user.Name}}</h5>
dataTable.addRow([utcDate(row.date), row.data.codes_issued]);
});

let dateFormatter = new google.visualization.DateFormat({
pattern: 'MMM dd',
whaught marked this conversation as resolved.
Show resolved Hide resolved
});
dateFormatter.format(dataTable, 0);

let options = {
colors: ['#007bff'],
colors: ['#007bff', '#ff7b00'],
chartArea: {
left: 60, // leave room for y-axis labels
top: 20,
bottom: 20,
width: '100%',
height: '100%',
left: 40, // leave room for y-axis labels
right: 20,
width: '100%'
},
hAxis: { format: 'M/d' },
legend: 'none',
width: '100%'
};

let chart = new google.visualization.LineChart(userStatsChartDiv);
let chart = new google.visualization.LineChart($userStatsChart.get(0));
chart.draw(dataTable, options);
})
.fail(function(xhr, status, err) {
flash.error('Failed to render user stats: ' + err);
flash.error('Failed to render realm stats: ' + err);
});
}
</script>
Expand Down
16 changes: 8 additions & 8 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,6 @@
- [Client provided UUID to prevent duplicate SMS](#client-provided-uuid-to-prevent-duplicate-sms)
- [`/api/batch-issue`](#apibatch-issue)
- [Handling batch partial success/failure](#handling-batch-partial-successfailure)
- [`/api/checkcodestatus`](#apicheckcodestatus)
- [`/api/expirecode`](#apiexpirecode)
- [`/api/stats/*` (preview)](#apistats-preview)
- [Chaffing requests](#chaffing-requests)
- [Response codes overview](#response-codes-overview)

<!-- /TOC -->

Expand Down Expand Up @@ -156,7 +151,7 @@ Exchange a verification token for a verification certificate (for sending to a k

**VerificationCertificateResponse**

```json
```json
{
"certificate": "<JWT verification certificate>",
"error": "",
Expand Down Expand Up @@ -469,11 +464,16 @@ This path includes realm-level statistics for the past 30 days.
- `/api/stats/realm.{csv,json}` - Daily statistics for the realm, including
codes issued, codes claimed, and daily active users (if enabled).

- `/api/stats/realm-user.{csv,json}` - Daily statistics for codes issued by
- `/api/stats/realm/users.{csv,json}` - Daily statistics for codes issued by
realm user. These statistics only include codes issued by humans logged into
the verification system.

- `/api/stats/realm-external-issuser.{csv,json}` - Daily statistics for codes
- `/api/stats/realm/users/:id.{csv,json}` - Daily statistics for codes issued
by the user with the given ID. These statistics only include codes issued by
that human user logged into the verification system for the currently
authorized realm.

- `/api/stats/realm/external-issusers.{csv,json}` - Daily statistics for codes
issued by external issuers. These statistics only include codes issued by
the API where an `externalIssuer` field was provided.

Expand Down
13 changes: 9 additions & 4 deletions internal/routes/adminapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,15 @@ func AdminAPI(
statsController := stats.New(ctx, cacher, db, h)
sub.Handle("/realm.csv", statsController.HandleRealmStats(stats.StatsTypeCSV)).Methods("GET")
sub.Handle("/realm.json", statsController.HandleRealmStats(stats.StatsTypeJSON)).Methods("GET")
sub.Handle("/realm-user.csv", statsController.HandleRealmUserStats(stats.StatsTypeCSV)).Methods("GET")
sub.Handle("/realm-user.json", statsController.HandleRealmUserStats(stats.StatsTypeJSON)).Methods("GET")
sub.Handle("/realm-external-issuer.csv", statsController.HandleRealmExternalIssuerStats(stats.StatsTypeCSV)).Methods("GET")
sub.Handle("/realm-external-issuer.json", statsController.HandleRealmExternalIssuerStats(stats.StatsTypeJSON)).Methods("GET")

sub.Handle("/realm/users.csv", statsController.HandleRealmUsersStats(stats.StatsTypeCSV)).Methods("GET")
sub.Handle("/realm/users.json", statsController.HandleRealmUsersStats(stats.StatsTypeJSON)).Methods("GET")

sub.Handle("/realm/users/{id}.csv", statsController.HandleRealmUserStats(stats.StatsTypeCSV)).Methods("GET")
sub.Handle("/realm/users/{id}.json", statsController.HandleRealmUserStats(stats.StatsTypeJSON)).Methods("GET")

sub.Handle("/realm/external-issuers.csv", statsController.HandleRealmExternalIssuersStats(stats.StatsTypeCSV)).Methods("GET")
sub.Handle("/realm/external-issuers.json", statsController.HandleRealmExternalIssuersStats(stats.StatsTypeJSON)).Methods("GET")
}

// Wrap the main router in the mutating middleware method. This cannot be
Expand Down
15 changes: 9 additions & 6 deletions internal/routes/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -377,8 +377,6 @@ func userRoutes(r *mux.Router, c *user.Controller) {
r.Handle("/{id:[0-9]+}", c.HandleShow()).Methods("GET")
r.Handle("/{id:[0-9]+}", c.HandleUpdate()).Methods("PATCH")
r.Handle("/{id:[0-9]+}", c.HandleDelete()).Methods("DELETE")
r.Handle("/{id:[0-9]+}/stats.json", c.HandleUserStats()).Methods("GET")
r.Handle("/{id:[0-9]+}/stats.csv", c.HandleUserStats()).Methods("GET")
r.Handle("/{id:[0-9]+}/reset-password", c.HandleResetPassword()).Methods("POST")
}

Expand All @@ -396,10 +394,15 @@ func realmkeysRoutes(r *mux.Router, c *realmkeys.Controller) {
func statsRoutes(r *mux.Router, c *stats.Controller) {
r.Handle("/realm.csv", c.HandleRealmStats(stats.StatsTypeCSV)).Methods("GET")
r.Handle("/realm.json", c.HandleRealmStats(stats.StatsTypeJSON)).Methods("GET")
r.Handle("/realm-user.csv", c.HandleRealmUserStats(stats.StatsTypeCSV)).Methods("GET")
r.Handle("/realm-user.json", c.HandleRealmUserStats(stats.StatsTypeJSON)).Methods("GET")
r.Handle("/realm-external-issuer.csv", c.HandleRealmExternalIssuerStats(stats.StatsTypeCSV)).Methods("GET")
r.Handle("/realm-external-issuer.json", c.HandleRealmExternalIssuerStats(stats.StatsTypeJSON)).Methods("GET")

r.Handle("/realm/users.csv", c.HandleRealmUsersStats(stats.StatsTypeCSV)).Methods("GET")
r.Handle("/realm/users.json", c.HandleRealmUsersStats(stats.StatsTypeJSON)).Methods("GET")

r.Handle("/realm/users/{id}.csv", c.HandleRealmUserStats(stats.StatsTypeCSV)).Methods("GET")
r.Handle("/realm/users/{id}.json", c.HandleRealmUserStats(stats.StatsTypeJSON)).Methods("GET")

r.Handle("/realm/external-issuers.csv", c.HandleRealmExternalIssuersStats(stats.StatsTypeCSV)).Methods("GET")
r.Handle("/realm/external-issuers.json", c.HandleRealmExternalIssuersStats(stats.StatsTypeJSON)).Methods("GET")
}

// realmadminRoutes are the realm admin routes.
Expand Down
20 changes: 10 additions & 10 deletions internal/routes/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,12 +163,6 @@ func TestRoutes_userRoutes(t *testing.T) {
{
req: httptest.NewRequest("DELETE", "/12345", nil),
},
{
req: httptest.NewRequest("GET", "/12345/stats.json", nil),
},
{
req: httptest.NewRequest("GET", "/12345/stats.csv", nil),
},
{
req: httptest.NewRequest("POST", "/12345/reset-password", nil),
},
Expand Down Expand Up @@ -232,16 +226,22 @@ func TestRoutes_statsRoutes(t *testing.T) {
req: httptest.NewRequest("GET", "/realm.json", nil),
},
{
req: httptest.NewRequest("GET", "/realm-user.csv", nil),
req: httptest.NewRequest("GET", "/realm/users.csv", nil),
},
{
req: httptest.NewRequest("GET", "/realm/users.json", nil),
},
{
req: httptest.NewRequest("GET", "/realm/users/12345.csv", nil),
},
{
req: httptest.NewRequest("GET", "/realm-user.json", nil),
req: httptest.NewRequest("GET", "/realm/users/12345.json", nil),
},
{
req: httptest.NewRequest("GET", "/realm-external-issuer.csv", nil),
req: httptest.NewRequest("GET", "/realm/external-issuers.csv", nil),
},
{
req: httptest.NewRequest("GET", "/realm-external-issuer.json", nil),
req: httptest.NewRequest("GET", "/realm/external-issuers.json", nil),
},
}

Expand Down
3 changes: 2 additions & 1 deletion pkg/controller/stats/realm.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,15 @@ import (
"net/http"

"github.com/google/exposure-notifications-verification-server/pkg/controller"
"github.com/google/exposure-notifications-verification-server/pkg/rbac"
)

// HandleRealmStats renders statistics for the current realm.
func (c *Controller) HandleRealmStats(typ StatsType) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

currentRealm, ok := authorizeFromContext(ctx)
currentRealm, ok := authorizeFromContext(ctx, rbac.StatsRead)
if !ok {
controller.Unauthorized(w, r, c.h)
return
Expand Down
7 changes: 4 additions & 3 deletions pkg/controller/stats/realm_external_issuer.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,15 @@ import (
"net/http"

"github.com/google/exposure-notifications-verification-server/pkg/controller"
"github.com/google/exposure-notifications-verification-server/pkg/rbac"
)

// HandleRealmExternalIssuerStats renders statistics for the current realm.
func (c *Controller) HandleRealmExternalIssuerStats(typ StatsType) http.Handler {
// HandleRealmExternalIssuersStats renders statistics for the current realm.
func (c *Controller) HandleRealmExternalIssuersStats(typ StatsType) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

currentRealm, ok := authorizeFromContext(ctx)
currentRealm, ok := authorizeFromContext(ctx, rbac.StatsRead)
if !ok {
controller.Unauthorized(w, r, c.h)
return
Expand Down
29 changes: 25 additions & 4 deletions pkg/controller/stats/realm_user.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,30 +17,51 @@ package stats

import (
"net/http"
"regexp"
"strings"

"github.com/google/exposure-notifications-verification-server/pkg/controller"
"github.com/google/exposure-notifications-verification-server/pkg/database"
"github.com/google/exposure-notifications-verification-server/pkg/rbac"
"github.com/gorilla/mux"
)

// HandleRealmUserStats renders statistics for the current realm.
// HandleRealmUserStats renders statistics for a single user in the current
// realm.
func (c *Controller) HandleRealmUserStats(typ StatsType) http.Handler {
re := regexp.MustCompile(`[^A-Za-z0-9]`)

return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
vars := mux.Vars(r)

currentRealm, ok := authorizeFromContext(ctx)
currentRealm, ok := authorizeFromContext(ctx, rbac.StatsRead, rbac.UserRead)
if !ok {
controller.Unauthorized(w, r, c.h)
return
}

stats, err := currentRealm.UserStatsCached(ctx, c.db, c.cacher)
user, err := currentRealm.FindUser(c.db, vars["id"])
if err != nil {
if database.IsNotFound(err) {
controller.Unauthorized(w, r, c.h)
return
}

controller.InternalError(w, r, c.h, err)
return
}

stats, err := user.StatsCached(ctx, c.db, c.cacher, currentRealm)
if err != nil {
controller.InternalError(w, r, c.h, err)
return
}

switch typ {
case StatsTypeCSV:
c.h.RenderCSV(w, http.StatusOK, csvFilename("user-stats"), stats)
filename := re.ReplaceAllString(strings.ToLower(user.Name), "-")
c.h.RenderCSV(w, http.StatusOK, csvFilename(filename), stats)
return
case StatsTypeJSON:
c.h.RenderJSON(w, http.StatusOK, stats)
Expand Down
Loading