Skip to content

Commit 64b6058

Browse files
committed
Feat: Report as a CSV all licenses generated by month or day.
1 parent ecec020 commit 64b6058

5 files changed

Lines changed: 125 additions & 11 deletions

File tree

cmd/lcpserver/router.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ func (s *Server) setRoutes() *chi.Mux {
146146
r.Delete("/publications/{publicationID}", a.DeletePublication) // DELETE /dashdata/publication/publication123
147147
r.With(paginate).Get("/user-licenses/{userID}", a.ListUserLicenses) // GET /dashdata/user-licenses/user123
148148
r.Get("/license-events/{licenseID}", a.ListLicenseEvents) // GET /dashdata/license-events/license123
149+
r.Get("/report-licenses", a.ReportGeneratedLicenses) // GET /dashdata/report-licenses
149150
})
150151
})
151152

pkg/api/licenseinfo_handler.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,10 @@ func (a *APICtrl) SearchLicenses(w http.ResponseWriter, r *http.Request) {
7878
licenses, err = a.Store.License().FindByDeviceCount(min, max)
7979
// by month (format: YYYY-MM)
8080
} else if month := r.URL.Query().Get("month"); month != "" {
81-
licenses, err = a.Store.License().FindByDate(month)
81+
licenses, err = a.Store.License().FindByDate(month, stor.ExcludePubInfo)
8282
// by date (format: YYYY-MM-DD)
8383
} else if date := r.URL.Query().Get("date"); date != "" {
84-
licenses, err = a.Store.License().FindByDate(date)
84+
licenses, err = a.Store.License().FindByDate(date, stor.ExcludePubInfo)
8585
} else {
8686
render.Render(w, r, ErrNotFound)
8787
return
@@ -97,7 +97,7 @@ func (a *APICtrl) SearchLicenses(w http.ResponseWriter, r *http.Request) {
9797
}
9898

9999
// ListUserLicenses returns licenses for a specific user.
100-
// This is a similar but more direct way to get licenses for a user, compared to the search by user in SearchLicenses.
100+
// This is a similar but more direct way to get licenses for a user, compared to the search by user in SearchLicenses. It returns the title of the publication as well, which is useful for display in a user interface.
101101
func (a *APICtrl) ListUserLicenses(w http.ResponseWriter, r *http.Request) {
102102
var licenses *[]stor.LicenseInfo
103103
var err error

pkg/api/report_handler.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
// Copyright 2026 European Digital Reading Lab. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license
3+
// specified in the Github project LICENSE file.
4+
5+
package api
6+
7+
import (
8+
"encoding/csv"
9+
"errors"
10+
"fmt"
11+
"net/http"
12+
"net/url"
13+
"time"
14+
15+
"github.com/edrlab/lcp-server/pkg/stor"
16+
"github.com/go-chi/render"
17+
log "github.com/sirupsen/logrus"
18+
)
19+
20+
// ReportGeneratedLicenses generates a CSV report of licenses for a specific month or date
21+
func (a *APICtrl) ReportGeneratedLicenses(w http.ResponseWriter, r *http.Request) {
22+
log.Debug("Report Generated Licenses, monthly or daily")
23+
24+
var licenses *[]stor.LicenseInfo
25+
var err error
26+
var period string
27+
28+
// Check for month parameter
29+
if month := r.URL.Query().Get("month"); month != "" {
30+
if date := r.URL.Query().Get("date"); date != "" {
31+
render.Render(w, r, ErrInvalidRequest(errors.New("cannot specify both month and date parameters")))
32+
return
33+
}
34+
licenses, err = a.Store.License().FindByDate(month, stor.IncludePubInfo)
35+
period = month
36+
} else if date := r.URL.Query().Get("date"); date != "" {
37+
licenses, err = a.Store.License().FindByDate(date, stor.IncludePubInfo)
38+
period = date
39+
} else {
40+
render.Render(w, r, ErrInvalidRequest(errors.New("missing required parameter: either month (YYYY-MM) or date (YYYY-MM-DD)")))
41+
return
42+
}
43+
44+
if err != nil {
45+
render.Render(w, r, ErrServer(err))
46+
return
47+
}
48+
49+
// Set CSV headers
50+
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
51+
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"licenses-report-%s.csv\"", url.QueryEscape(period)))
52+
53+
// Create CSV writer
54+
csvWriter := csv.NewWriter(w)
55+
defer csvWriter.Flush()
56+
57+
// Write CSV header
58+
header := []string{"CreatedAt", "PublicationAltID", "PublicationTitle", "UserID", "Status", "Start", "End", "MaxEnd", "DeviceCount"}
59+
if err := csvWriter.Write(header); err != nil {
60+
log.Errorf("Error writing CSV header: %v", err)
61+
render.Render(w, r, ErrServer(err))
62+
return
63+
}
64+
65+
// Write license data
66+
for _, license := range *licenses {
67+
record := []string{
68+
formatTimePtr(&license.CreatedAt),
69+
license.Publication.AltID,
70+
license.Publication.Title,
71+
license.UserID,
72+
license.Status,
73+
formatTimePtr(license.Start),
74+
formatTimePtr(license.End),
75+
formatTimePtr(license.MaxEnd),
76+
fmt.Sprintf("%d", license.DeviceCount),
77+
}
78+
79+
if err := csvWriter.Write(record); err != nil {
80+
log.Errorf("Error writing CSV record: %v", err)
81+
render.Render(w, r, ErrServer(err))
82+
return
83+
}
84+
}
85+
}
86+
87+
// formatTimePtr formats a time pointer to ISO 8601 string, returns empty string if nil
88+
func formatTimePtr(t *time.Time) string {
89+
if t == nil {
90+
return ""
91+
}
92+
return t.Format("2006-01-02T15:04:05Z07:00")
93+
}

pkg/stor/licenseinfo.go

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ func (s licenseStore) FindByDeviceCount(min int, max int) (*[]LicenseInfo, error
8686
// dateStr can be:
8787
// - a specific date: "2024-02-15" (YYYY-MM-DD)
8888
// - a month: "2024-02" (YYYY-MM)
89-
func (s licenseStore) FindByDate(dateStr string) (*[]LicenseInfo, error) {
89+
func (s licenseStore) FindByDate(dateStr string, pubInfo bool) (*[]LicenseInfo, error) {
9090
licenses := []LicenseInfo{}
9191

9292
// Check if it's a month (format: YYYY-MM) or a specific date (format: YYYY-MM-DD)
@@ -99,9 +99,16 @@ func (s licenseStore) FindByDate(dateStr string) (*[]LicenseInfo, error) {
9999
// Get the start of next month
100100
startOfNextMonth := startOfMonth.AddDate(0, 1, 0)
101101

102-
return &licenses, s.db.Limit(1000).
103-
Where("created_at >= ? AND created_at < ?", startOfMonth, startOfNextMonth).
104-
Order("id DESC").Find(&licenses).Error
102+
query := s.db.Limit(1000).
103+
Where("license_infos.created_at >= ? AND license_infos.created_at < ?", startOfMonth, startOfNextMonth).
104+
Order("license_infos.id DESC")
105+
106+
if pubInfo {
107+
// Join with the publication table to get publication info
108+
query = query.Joins("Publication")
109+
}
110+
111+
return &licenses, query.Find(&licenses).Error
105112
} else if len(dateStr) == 10 { // Date format: YYYY-MM-DD
106113
// Parse the specific date
107114
specificDate, err := time.Parse("2006-01-02", dateStr)
@@ -111,9 +118,16 @@ func (s licenseStore) FindByDate(dateStr string) (*[]LicenseInfo, error) {
111118
// Get the start of next day
112119
startOfNextDay := specificDate.AddDate(0, 0, 1)
113120

114-
return &licenses, s.db.Limit(1000).
115-
Where("created_at >= ? AND created_at < ?", specificDate, startOfNextDay).
116-
Order("id DESC").Find(&licenses).Error
121+
query := s.db.Limit(1000).
122+
Where("license_infos.created_at >= ? AND license_infos.created_at < ?", specificDate, startOfNextDay).
123+
Order("license_infos.id DESC")
124+
125+
if pubInfo {
126+
// Join with the publication table to get publication info
127+
query = query.Joins("Publication")
128+
}
129+
130+
return &licenses, query.Find(&licenses).Error
117131
} else {
118132
return &licenses, errors.New("invalid date format: use YYYY-MM for month or YYYY-MM-DD for specific date")
119133
}

pkg/stor/store.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ type (
5959
FindByPublication(publicationID string) (*[]LicenseInfo, error)
6060
FindByStatus(status string) (*[]LicenseInfo, error)
6161
FindByDeviceCount(min int, max int) (*[]LicenseInfo, error)
62-
FindByDate(dateStr string) (*[]LicenseInfo, error)
62+
FindByDate(dateStr string, pubInfo bool) (*[]LicenseInfo, error)
6363
Count() (int64, error)
6464
Get(uuid string) (*LicenseInfo, error)
6565
Create(p *LicenseInfo) error
@@ -118,6 +118,12 @@ const (
118118
EVENT_CANCEL = "cancel"
119119
)
120120

121+
// Publication info constants
122+
const (
123+
IncludePubInfo = true
124+
ExcludePubInfo = false
125+
)
126+
121127
// Init initializes the database
122128
func Init(dsn string) (Store, error) {
123129
var err error

0 commit comments

Comments
 (0)