MG-134 - Add support for Report templates (#180)

* initial implementation

Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>

* initial implementation

Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>

* add remove report from nats handler

Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>

* fix failing linter

Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>

* address comments

Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>

* move runinfo to pkg

Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>

* update report handler

Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>

* update reports handler

Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>

* update handler in reports

Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>

* update go.mod and go.sum

Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>

* update package to chromedp

Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>

* change update reorts to use chromium

Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>

* update report template

Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>

* add endpoint and repo methods

Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>

* add template validation

Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>

* remove repeated code

Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>

* fix failing linter

Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>

* fix failing linter

Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>

* fix template formatting

Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>

* update report template

Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>

* fix mocks and tests

Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>

* remove debug logs

Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>

* fix api docs

Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>

* remove pointers

Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>

* fix template path

Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>

* fix template path

Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>

* address comments

Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>

* fix failing validation

Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>

* address comments

Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>

* failing linter

Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>

* fix logic

Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>

* address comments

Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>

* revert ui variable changes

Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>

* rename method

Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>

* update to browserless service

Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>

* fix failing linter

Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>

* fix go mod file

Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>

* address comments

Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>

---------

Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>
This commit is contained in:
Steve Munene
2025-07-09 19:44:59 +03:00
committed by GitHub
parent 148e2fbb7f
commit 2e0432bdb5
26 changed files with 1491 additions and 389 deletions
+42 -11
View File
@@ -1,11 +1,12 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
// Package main contains rule engine main function to start the service.
// Package main contains reports main function to start the service.
package main
import (
"context"
"embed"
"fmt"
"log"
"log/slog"
@@ -50,19 +51,25 @@ const (
defSvcHTTPPort = "9017"
envPrefixGrpc = "MG_TIMESCALE_READER_GRPC_"
envPrefixDomains = "SMQ_DOMAINS_GRPC_"
templatePath = "template/reports_default_template.html"
)
// We use a buffered channel to prevent blocking, as logging is an expensive operation.
const channBuffer = 256
//go:embed template/reports_default_template.html
var templateFS embed.FS
type config struct {
LogLevel string `env:"MG_REPORTS_LOG_LEVEL" envDefault:"info"`
InstanceID string `env:"MG_REPORTS_INSTANCE_ID" envDefault:""`
JaegerURL url.URL `env:"SMQ_JAEGER_URL" envDefault:"http://localhost:4318/v1/traces"`
SendTelemetry bool `env:"SMQ_SEND_TELEMETRY" envDefault:"true"`
ESURL string `env:"SMQ_ES_URL" envDefault:"nats://localhost:4222"`
TraceRatio float64 `env:"SMQ_JAEGER_TRACE_RATIO" envDefault:"1.0"`
BrokerURL string `env:"SMQ_MESSAGE_BROKER_URL" envDefault:"nats://localhost:4222"`
LogLevel string `env:"MG_REPORTS_LOG_LEVEL" envDefault:"info"`
InstanceID string `env:"MG_REPORTS_INSTANCE_ID" envDefault:""`
JaegerURL url.URL `env:"SMQ_JAEGER_URL" envDefault:"http://localhost:4318/v1/traces"`
SendTelemetry bool `env:"SMQ_SEND_TELEMETRY" envDefault:"true"`
ESURL string `env:"SMQ_ES_URL" envDefault:"nats://localhost:4222"`
TraceRatio float64 `env:"SMQ_JAEGER_TRACE_RATIO" envDefault:"1.0"`
BrokerURL string `env:"SMQ_MESSAGE_BROKER_URL" envDefault:"nats://localhost:4222"`
DefaultTemplatePath string `env:"MG_REPORTS_DEFAULT_TEMPLATE" envDefault:""`
ConverterURL string `env:"MG_REPORT_BROWSERLESS_URL" envDefault:"http://localhost:4000/pdf"`
}
func main() {
@@ -91,6 +98,30 @@ func main() {
}
}
var templateData []byte
switch cfg.DefaultTemplatePath {
case "":
templateData, err = templateFS.ReadFile(templatePath)
default:
templateData, err = os.ReadFile(templatePath)
}
if err != nil {
logger.Error(fmt.Sprintf("failed to read report template: %s", err))
exitCode = 1
return
}
template := reports.ReportTemplate(string(templateData))
if err := template.Validate(); err != nil {
logger.Error(fmt.Sprintf("failed to validate report template: %s", err))
exitCode = 1
return
}
logger.Info("Report template validated successfully")
ec := email.Config{}
if err := env.Parse(&ec); err != nil {
logger.Error(fmt.Sprintf("failed to load email configuration : %s", err))
@@ -198,7 +229,7 @@ func main() {
runInfo := make(chan pkglog.RunInfo, channBuffer)
svc, err := newService(database, runInfo, authz, ec, logger, readersClient)
svc, err := newService(database, runInfo, authz, ec, logger, readersClient, template, cfg.ConverterURL)
if err != nil {
logger.Error(fmt.Sprintf("failed to create services: %s", err))
exitCode = 1
@@ -238,7 +269,7 @@ func main() {
}
}
func newService(db pgclient.Database, runInfo chan pkglog.RunInfo, authz mgauthz.Authorization, ec email.Config, logger *slog.Logger, readersClient grpcReadersV1.ReadersServiceClient) (reports.Service, error) {
func newService(db pgclient.Database, runInfo chan pkglog.RunInfo, authz mgauthz.Authorization, ec email.Config, logger *slog.Logger, readersClient grpcReadersV1.ReadersServiceClient, template reports.ReportTemplate, converterURL string) (reports.Service, error) {
repo := repg.NewRepository(db)
idp := uuid.New()
@@ -247,7 +278,7 @@ func newService(db pgclient.Database, runInfo chan pkglog.RunInfo, authz mgauthz
logger.Error(fmt.Sprintf("failed to configure e-mailing util: %s", err.Error()))
}
csvc := reports.NewService(repo, runInfo, idp, ticker.NewTicker(time.Second*30), emailerClient, readersClient)
csvc := reports.NewService(repo, runInfo, idp, ticker.NewTicker(time.Second*30), emailerClient, readersClient, template, converterURL)
csvc, err = middleware.AuthorizationMiddleware(csvc, authz)
if err != nil {
return nil, err
@@ -0,0 +1,389 @@
<!-- Copyright (c) Abstract Machines -->
<!-- SPDX-License-Identifier: Apache-2.0 -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}}</title>
<style>
:root {
--primary-color: rgb(41, 128, 185);
--secondary-color: rgb(26, 82, 118);
--subtle-color: rgb(189, 195, 199);
--table-header-bg: rgb(236, 240, 241);
--alternate-row: rgb(245, 247, 249);
--text-primary: rgb(44, 62, 80);
--text-secondary: rgb(127, 140, 141);
--white: #ffffff;
--header-height: 35mm;
--footer-height: 20mm;
--page-padding: 15mm;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
background-color: var(--white);
color: var(--text-primary);
line-height: 1.4;
}
.page {
max-width: 210mm;
min-height: 297mm;
padding: var(--page-padding) 10mm;
margin: 5mm auto 0 auto;
background: var(--white);
box-shadow: 0 0 10px rgba(0,0,0,0.1);
position: relative;
display: flex;
flex-direction: column;
}
.header {
height: var(--header-height);
min-height: var(--header-height);
max-height: var(--header-height);
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: column;
}
.header-top-bar {
height: 8px;
background-color: var(--primary-color);
margin: 0 -10mm 15px -10mm;
flex-shrink: 0;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
flex-shrink: 0;
}
.header-title {
font-size: 20px;
font-weight: bold;
color: var(--primary-color);
text-align: center;
flex-grow: 1;
}
.header-date {
font-size: 10px;
font-style: italic;
color: var(--text-secondary);
text-align: right;
width: 100px;
}
.header-separator {
height: 2px;
background-color: var(--subtle-color);
margin: 5px 0 10px 0;
position: relative;
flex-shrink: 0;
}
.header-separator::after {
content: '';
position: absolute;
top: 3px;
left: 0;
right: 0;
height: 1px;
background-color: var(--subtle-color);
}
.content-area {
flex-grow: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.metrics-section {
margin-bottom: 15px;
flex-shrink: 0;
}
.metrics-title {
font-size: 16px;
font-weight: bold;
color: var(--secondary-color);
margin-bottom: 10px;
}
.metrics-info {
background-color: var(--alternate-row);
padding: 12px;
border-radius: 4px;
margin-bottom: 10px;
}
.metric-row {
display: flex;
margin-bottom: 8px;
}
.metric-row:last-child {
margin-bottom: 0;
}
.metric-label {
font-weight: bold;
color: var(--text-primary);
width: 120px;
font-size: 11px;
}
.metric-value {
font-style: italic;
color: var(--text-primary);
font-size: 11px;
flex-grow: 1;
}
.record-count {
text-align: right;
font-size: 10px;
font-style: italic;
color: var(--text-secondary);
margin-bottom: 10px;
flex-shrink: 0;
}
.table-container {
flex-grow: 1;
overflow: auto;
min-height: 0;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.table-header-bar {
height: 4px;
background-color: var(--primary-color);
}
.data-table th {
background-color: var(--table-header-bg);
color: var(--secondary-color);
font-weight: bold;
font-size: 11px;
padding: 8px;
text-align: center;
border-bottom: 2px solid var(--subtle-color);
position: sticky;
top: 0;
}
.data-table td {
padding: 6px 8px;
font-size: 10px;
text-align: center;
border-bottom: 1px solid #eee;
}
.data-table tr:nth-child(even) {
background-color: var(--alternate-row);
}
.data-table tr:hover {
background-color: rgba(41, 128, 185, 0.05);
}
.col-time {
width: 25%;
color: var(--text-primary);
}
.col-value {
width: 17%;
color: var(--text-primary);
font-weight: normal;
}
.col-unit {
width: 17%;
color: var(--text-secondary);
font-style: italic;
}
.col-protocol {
width: 17%;
color: var(--text-primary);
}
.col-subtopic {
width: 24%;
color: var(--secondary-color);
}
.footer {
height: var(--footer-height);
min-height: var(--footer-height);
max-height: var(--footer-height);
border-top: 2px solid var(--subtle-color);
padding-top: 8px;
flex-shrink: 0;
display: flex;
flex-direction: column;
justify-content: flex-start;
}
.footer-separator {
height: 1px;
background-color: var(--subtle-color);
margin-bottom: 6px;
position: relative;
flex-shrink: 0;
}
.footer-separator::after {
content: '';
position: absolute;
top: 1px;
left: 0;
right: 0;
height: 1px;
background-color: var(--subtle-color);
}
.footer-content {
display: flex;
justify-content: space-between;
align-items: center;
margin: 0;
padding: 0;
flex-shrink: 0;
}
.footer-generated {
font-size: 8px;
font-style: italic;
color: var(--text-secondary);
margin: 0;
padding: 0;
}
.footer-page {
font-size: 9px;
font-weight: bold;
color: var(--text-primary);
margin: 0;
padding: 0;
}
@media print {
.page {
box-shadow: none;
margin: 0;
max-width: none;
height: 297mm;
min-height: auto;
page-break-after: always;
}
.page:last-child {
page-break-after: auto;
}
}
</style>
</head>
<body>
{{$totalPages := len .Reports}}
{{$globalPage := 0}}
{{range $index, $report := .Reports}}
{{$globalPage = add $globalPage 1}}
<div class="page">
<div class="header">
<div class="header-top-bar"></div>
<div class="header-content">
<div style="width: 100px;"></div>
<div class="header-title">{{$.Title}}</div>
<div class="header-date">{{$.GeneratedDate}}</div>
</div>
<div class="header-separator"></div>
</div>
<div class="content-area">
<div class="metrics-section">
<div class="metrics-title">Metrics</div>
<div class="metrics-info">
<div class="metric-row">
<div class="metric-label">Name:</div>
<div class="metric-value">{{.Metric.Name}}</div>
</div>
{{if .Metric.ClientID}}
<div class="metric-row">
<div class="metric-label">Device ID:</div>
<div class="metric-value">{{.Metric.ClientID}}</div>
</div>
{{end}}
<div class="metric-row">
<div class="metric-label">Channel ID:</div>
<div class="metric-value">{{.Metric.ChannelID}}</div>
</div>
</div>
</div>
<div class="record-count">
Total Records: {{len .Messages}}
</div>
<div class="table-container">
<div class="table-header-bar"></div>
<table class="data-table">
<thead>
<tr>
<th class="col-time">Time</th>
<th class="col-value">Value</th>
<th class="col-unit">Unit</th>
<th class="col-protocol">Protocol</th>
<th class="col-subtopic">Subtopic</th>
</tr>
</thead>
<tbody>
{{range .Messages}}
<tr>
<td class="col-time">{{formatTime .Time}}</td>
<td class="col-value">{{formatValue .}}</td>
<td class="col-unit">{{.Unit}}</td>
<td class="col-protocol">{{.Protocol}}</td>
<td class="col-subtopic">{{.Subtopic}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
<div class="footer">
<div class="footer-separator"></div>
<div class="footer-content">
<div class="footer-generated">Generated: {{$.GeneratedTime}}</div>
<div class="footer-page">Page {{$globalPage}} of {{$totalPages}}</div>
</div>
</div>
</div>
{{end}}
</body>
</html>
+2
View File
@@ -153,6 +153,8 @@ MG_REPORTS_DB_SSL_KEY=
MG_REPORTS_DB_SSL_ROOT_CERT=
MG_REPORTS_INSTANCE_ID=
MG_REPORTS_EMAIL_TEMPLATE=reports.tmpl
MG_REPORTS_DEFAULT_TEMPLATE=
MG_REPORT_BROWSERLESS_URL=http://browserless:3000/pdf
### Certs
SMQ_ADDONS_CERTS_PATH_PREFIX=./
+10
View File
@@ -395,6 +395,8 @@ services:
MG_REPORTS_DB_SSL_CERT: ${MG_REPORTS_DB_SSL_CERT}
MG_REPORTS_DB_SSL_KEY: ${MG_REPORTS_DB_SSL_KEY}
MG_REPORTS_DB_SSL_ROOT_CERT: ${MG_REPORTS_DB_SSL_ROOT_CERT}
MG_REPORT_TEMPLATE_PATH: ${MG_REPORT_TEMPLATE_PATH}
MG_REPORT_BROWSERLESS_URL: ${MG_REPORT_BROWSERLESS_URL}
SMQ_MESSAGE_BROKER_URL: ${SMQ_MESSAGE_BROKER_URL}
SMQ_JAEGER_URL: ${SMQ_JAEGER_URL}
SMQ_JAEGER_TRACE_RATIO: ${SMQ_JAEGER_TRACE_RATIO}
@@ -447,3 +449,11 @@ services:
target: /auth-grpc-server-ca${SMQ_AUTH_GRPC_SERVER_CA_CERTS:+.crt}
bind:
create_host_path: true
browserless:
image: browserless/chrome
container_name: magistrala-browserless
ports:
- "4000:3000"
networks:
- magistrala-base-net
-4
View File
@@ -23,7 +23,6 @@ require (
github.com/jackc/pgtype v1.14.4
github.com/jackc/pgx/v5 v5.7.5
github.com/jmoiron/sqlx v1.4.0
github.com/johnfercher/maroto v1.0.0
github.com/ory/dockertest/v3 v3.12.0
github.com/pelletier/go-toml v1.9.5
github.com/prometheus/client_golang v1.22.0
@@ -48,11 +47,8 @@ require (
require (
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250613105001-9f2d3c737feb.1 // indirect
cloud.google.com/go/compute/metadata v0.7.0 // indirect
github.com/boombuler/barcode v1.0.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.2 // indirect
github.com/jung-kurt/gofpdf v1.16.2 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/ruudk/golang-pdf417 v0.0.0-20201230142125-a7e3863a1245 // indirect
)
require (
-16
View File
@@ -49,9 +49,6 @@ github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs=
github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
@@ -191,7 +188,6 @@ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0=
@@ -273,8 +269,6 @@ github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9Y
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/johnfercher/maroto v1.0.0 h1:yo26a/Mxj2YbHCzpIW7FypKtdvv9BdeLNHaApHwLCXU=
github.com/johnfercher/maroto v1.0.0/go.mod h1:qeujdhKT+677jMjGWlIa5OCgR04GgIHvByJ6pSC+hOw=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
@@ -283,9 +277,6 @@ github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc=
github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0=
github.com/jzelinskie/stringz v0.0.3 h1:0GhG3lVMYrYtIvRbxvQI6zqRTT1P1xyQlpa0FhfUXas=
github.com/jzelinskie/stringz v0.0.3/go.mod h1:hHYbgxJuNLRw91CmpuFsYEOyQqpDVFg8pvEh23vy4P0=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
@@ -382,9 +373,6 @@ github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3v
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/phpdave11/gofpdf v1.4.2/go.mod h1:zpO6xFn9yxo3YLyMvW8HcKWVdbNqgIfOOp2dXMnm1mY=
github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
github.com/phpdave11/gofpdi v1.0.12/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -433,9 +421,6 @@ github.com/rubenv/sql-migrate v1.8.0 h1:dXnYiJk9k3wetp7GfQbKJcPHjVJL6YK19tKj8t2N
github.com/rubenv/sql-migrate v1.8.0/go.mod h1:F2bGFBwCU+pnmbtNYDeKvSuvL6lBVtXDXUUv5t+u1qw=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
github.com/ruudk/golang-pdf417 v0.0.0-20201230142125-a7e3863a1245 h1:K1Xf3bKttbF+koVGaX5xngRIZ5bVjbmPnaxE/dR08uY=
github.com/ruudk/golang-pdf417 v0.0.0-20201230142125-a7e3863a1245/go.mod h1:pQAZKsJ8yyVxGRWYNEm9oFB8ieLgKFnamEyDmSA0BRk=
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
github.com/samber/lo v1.51.0 h1:kysRYLbHy/MB7kQZf5DSN50JHmMsNEdeY24VzJFu7wI=
@@ -577,7 +562,6 @@ golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 h1:bsqhLWFR6G6xiQcb+JoGqdKdRU6WzPWmK8E0jxTjzo4=
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+5 -3
View File
@@ -11,8 +11,10 @@ import (
"github.com/0x6flab/namegenerator"
"github.com/absmach/magistrala/internal/testsutil"
emocks "github.com/absmach/magistrala/pkg/emailer/mocks"
pkglog "github.com/absmach/magistrala/pkg/logger"
pkgSch "github.com/absmach/magistrala/pkg/schedule"
tmocks "github.com/absmach/magistrala/pkg/ticker/mocks"
"github.com/absmach/magistrala/re"
"github.com/absmach/magistrala/re/mocks"
"github.com/absmach/magistrala/re/outputs"
@@ -45,13 +47,13 @@ var (
}
)
func newService(t *testing.T, runInfo chan pkglog.RunInfo) (re.Service, *mocks.Repository, *pubsubmocks.PubSub, *mocks.Ticker) {
func newService(t *testing.T, runInfo chan pkglog.RunInfo) (re.Service, *mocks.Repository, *pubsubmocks.PubSub, *tmocks.Ticker) {
repo := new(mocks.Repository)
mockTicker := new(mocks.Ticker)
mockTicker := new(tmocks.Ticker)
idProvider := uuid.NewMock()
pubsub := pubsubmocks.NewPubSub(t)
readersSvc := new(readmocks.ReadersServiceClient)
e := new(mocks.Emailer)
e := new(emocks.Emailer)
return re.NewService(repo, runInfo, idProvider, pubsub, pubsub, pubsub, mockTicker, e, readersSvc), repo, pubsub, mockTicker
}
+64 -7
View File
@@ -25,13 +25,7 @@ func generateReportEndpoint(svc reports.Service) endpoint.Endpoint {
return generateReportResp{}, err
}
res, err := svc.GenerateReport(ctx, session, reports.ReportConfig{
Name: req.Name,
DomainID: req.DomainID,
Config: req.Config,
Metrics: req.Metrics,
Email: req.Email,
}, req.action)
res, err := svc.GenerateReport(ctx, session, req.ReportConfig, req.action)
if err != nil {
return generateReportResp{}, err
}
@@ -236,3 +230,66 @@ func disableReportConfigEndpoint(svc reports.Service) endpoint.Endpoint {
return updateReportConfigRes{ReportConfig: cfg}, nil
}
}
func updateReportTemplateEndpoint(svc reports.Service) endpoint.Endpoint {
return func(ctx context.Context, request interface{}) (interface{}, error) {
session, ok := ctx.Value(api.SessionKey).(authn.Session)
if !ok {
return nil, svcerr.ErrAuthorization
}
req := request.(updateReportTemplateReq)
if err := req.validate(); err != nil {
return updateReportTemplateRes{false}, err
}
err := svc.UpdateReportTemplate(ctx, session, req.ReportConfig)
if err != nil {
return updateReportTemplateRes{false}, err
}
return updateReportTemplateRes{true}, nil
}
}
func viewReportTemplateEndpoint(svc reports.Service) endpoint.Endpoint {
return func(ctx context.Context, request interface{}) (interface{}, error) {
session, ok := ctx.Value(api.SessionKey).(authn.Session)
if !ok {
return nil, svcerr.ErrAuthorization
}
req := request.(getReportTemplateReq)
if err := req.validate(); err != nil {
return viewReportTemplateRes{}, err
}
template, err := svc.ViewReportTemplate(ctx, session, req.ID)
if err != nil {
return viewReportTemplateRes{}, err
}
return viewReportTemplateRes{Template: template}, nil
}
}
func deleteReportTemplateEndpoint(svc reports.Service) endpoint.Endpoint {
return func(ctx context.Context, request interface{}) (interface{}, error) {
session, ok := ctx.Value(api.SessionKey).(authn.Session)
if !ok {
return nil, svcerr.ErrAuthorization
}
req := request.(deleteReportTemplateReq)
if err := req.validate(); err != nil {
return deleteReportTemplateRes{false}, err
}
err := svc.DeleteReportTemplate(ctx, session, req.ID)
if err != nil {
return deleteReportTemplateRes{false}, err
}
return deleteReportTemplateRes{true}, nil
}
}
+52 -1
View File
@@ -27,6 +27,7 @@ var (
errMissingReportConfig = errors.New("missing report config")
errMissingReportEmailConfig = errors.New("missing report email config")
errInvalidRecurringPeriod = errors.New("invalid recurring period")
errMissingReportTemplate = errors.New("missing report template")
errTitleSize = errors.New("invalid title size")
)
@@ -41,6 +42,11 @@ func (req addReportConfigReq) validate() error {
if err := req.Schedule.Validate(); err != nil {
return errors.Wrap(err, apiutil.ErrValidation)
}
if req.ReportTemplate.String() != "" {
if err := req.ReportTemplate.Validate(); err != nil {
return errors.Wrap(err, apiutil.ErrValidation)
}
}
return validateReportConfig(req.ReportConfig, false, false)
}
@@ -115,6 +121,12 @@ func (req generateReportReq) validate() error {
return errors.Wrap(apiutil.ErrValidation, errTitleSize)
}
if req.ReportTemplate.String() != "" {
if err := req.ReportTemplate.Validate(); err != nil {
return errors.Wrap(err, apiutil.ErrValidation)
}
}
switch req.action {
case reports.ViewReport, reports.DownloadReport:
return validateReportConfig(req.ReportConfig, true, true)
@@ -172,7 +184,46 @@ func validateReportConfig(req reports.ReportConfig, skipEmailValidation bool, sk
func validateScheduler(sch schedule.Schedule) error {
if sch.Recurring != schedule.None && sch.RecurringPeriod < 1 {
return errors.Wrap(apiutil.ErrValidation, errInvalidRecurringPeriod)
return errInvalidRecurringPeriod
}
return nil
}
type updateReportTemplateReq struct {
reports.ReportConfig `json:",inline"`
}
func (req updateReportTemplateReq) validate() error {
if req.ID == "" {
return apiutil.ErrMissingID
}
if req.ReportTemplate == "" {
return errors.Wrap(apiutil.ErrValidation, errMissingReportTemplate)
}
if err := req.ReportTemplate.Validate(); err != nil {
return errors.Wrap(err, apiutil.ErrValidation)
}
return nil
}
type getReportTemplateReq struct {
ID string `json:"id"`
}
func (req getReportTemplateReq) validate() error {
if req.ID == "" {
return apiutil.ErrMissingID
}
return nil
}
type deleteReportTemplateReq struct {
ID string `json:"id"`
}
func (req deleteReportTemplateReq) validate() error {
if req.ID == "" {
return apiutil.ErrMissingID
}
return nil
}
+54
View File
@@ -165,3 +165,57 @@ func (res emailReportResp) Headers() map[string]string {
func (res emailReportResp) Empty() bool {
return true
}
type viewReportTemplateRes struct {
Template string `json:"html_template"`
}
func (res viewReportTemplateRes) Code() int {
return http.StatusOK
}
func (res viewReportTemplateRes) Headers() map[string]string {
return map[string]string{}
}
func (res viewReportTemplateRes) Empty() bool {
return false
}
type updateReportTemplateRes struct {
updated bool
}
func (res updateReportTemplateRes) Code() int {
if res.updated {
return http.StatusNoContent
}
return http.StatusOK
}
func (res updateReportTemplateRes) Headers() map[string]string {
return map[string]string{}
}
func (res updateReportTemplateRes) Empty() bool {
return true
}
type deleteReportTemplateRes struct {
deleted bool
}
func (res deleteReportTemplateRes) Code() int {
if res.deleted {
return http.StatusNoContent
}
return http.StatusOK
}
func (res deleteReportTemplateRes) Headers() map[string]string {
return map[string]string{}
}
func (res deleteReportTemplateRes) Empty() bool {
return true
}
+44
View File
@@ -102,6 +102,27 @@ func MakeHandler(svc reports.Service, authn mgauthn.Authentication, mux *chi.Mux
api.EncodeResponse,
opts...,
), "disable_report_config").ServeHTTP)
r.Put("/{reportID}/template", otelhttp.NewHandler(kithttp.NewServer(
updateReportTemplateEndpoint(svc),
decodeUpdateReportTemplateRequest,
api.EncodeResponse,
opts...,
), "update_report_template").ServeHTTP)
r.Get("/{reportID}/template", otelhttp.NewHandler(kithttp.NewServer(
viewReportTemplateEndpoint(svc),
decodeGetReportTemplateRequest,
api.EncodeResponse,
opts...,
), "get_report_template").ServeHTTP)
r.Delete("/{reportID}/template", otelhttp.NewHandler(kithttp.NewServer(
deleteReportTemplateEndpoint(svc),
decodeDeleteReportTemplateRequest,
api.EncodeResponse,
opts...,
), "delete_report_template").ServeHTTP)
})
})
})
@@ -192,6 +213,29 @@ func decodeDeleteReportConfigRequest(_ context.Context, r *http.Request) (interf
return deleteReportConfigReq{ID: id}, nil
}
func decodeUpdateReportTemplateRequest(_ context.Context, r *http.Request) (interface{}, error) {
if !strings.Contains(r.Header.Get("Content-Type"), api.ContentType) {
return nil, errors.Wrap(apiutil.ErrValidation, apiutil.ErrUnsupportedContentType)
}
req := updateReportTemplateReq{}
req.ID = chi.URLParam(r, reportIdKey)
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return nil, errors.Wrap(err, apiutil.ErrValidation)
}
return req, nil
}
func decodeGetReportTemplateRequest(_ context.Context, r *http.Request) (interface{}, error) {
return getReportTemplateReq{ID: chi.URLParam(r, reportIdKey)}, nil
}
func decodeDeleteReportTemplateRequest(_ context.Context, r *http.Request) (interface{}, error) {
return deleteReportTemplateReq{ID: chi.URLParam(r, reportIdKey)}, nil
}
func decodeListReportsConfigRequest(_ context.Context, r *http.Request) (interface{}, error) {
offset, err := apiutil.ReadNumQuery[uint64](r, api.OffsetKey, api.DefOffset)
if err != nil {
+90 -284
View File
@@ -5,307 +5,113 @@ package reports
import (
"bytes"
"context"
"encoding/csv"
"encoding/json"
"fmt"
"html/template"
"io"
"net/http"
"sort"
"time"
"github.com/absmach/supermq/pkg/errors"
svcerr "github.com/absmach/supermq/pkg/errors/service"
"github.com/absmach/supermq/pkg/transformers/senml"
"github.com/johnfercher/maroto/pkg/color"
"github.com/johnfercher/maroto/pkg/consts"
"github.com/johnfercher/maroto/pkg/pdf"
"github.com/johnfercher/maroto/pkg/props"
)
func generatePDFReport(title string, reports []Report) ([]byte, error) {
m := pdf.NewMaroto(consts.Portrait, consts.A4)
m.SetPageMargins(10, 15, 10)
type ReportData struct {
Title string
GeneratedTime string
GeneratedDate string
Reports []Report
}
primaryColor := color.Color{Red: 41, Green: 128, Blue: 185} // Blue
secondaryColor := color.Color{Red: 26, Green: 82, Blue: 118} // Darker blue
subtleColor := color.Color{Red: 189, Green: 195, Blue: 199} // Light gray
tableHeaderBg := color.Color{Red: 236, Green: 240, Blue: 241} // Very light gray
alternateRow := color.Color{Red: 245, Green: 247, Blue: 249} // Even lighter gray
textPrimary := color.Color{Red: 44, Green: 62, Blue: 80} // Dark blue-gray
textSecondary := color.Color{Red: 127, Green: 140, Blue: 141} // Medium gray
white := color.NewWhite()
m.RegisterHeader(func() {
m.SetBackgroundColor(primaryColor)
m.Row(2, func() { m.Col(12, func() {}) })
m.SetBackgroundColor(white)
m.Row(20, func() {
m.Col(2, func() {})
m.Col(8, func() {
m.Text(title, props.Text{
Size: 20,
Style: consts.Bold,
Color: primaryColor,
Align: consts.Center,
Top: 6,
})
})
m.Col(2, func() {
m.Text(time.Now().Format("02 Jan 2006"), props.Text{
Size: 10,
Style: consts.Italic,
Align: consts.Right,
Color: textSecondary,
Top: 8,
})
})
func (r *report) generatePDFReport(ctx context.Context, title string, reports []Report, template ReportTemplate) ([]byte, error) {
for i := range reports {
sort.Slice(reports[i].Messages, func(j, k int) bool {
return reports[i].Messages[j].Time < reports[i].Messages[k].Time
})
m.SetBackgroundColor(subtleColor)
m.Row(0.5, func() { m.Col(12, func() {}) })
m.SetBackgroundColor(white)
m.Row(0.25, func() {})
m.SetBackgroundColor(subtleColor)
m.Row(0.25, func() { m.Col(12, func() {}) })
m.SetBackgroundColor(white)
m.Row(5, func() {})
})
m.RegisterFooter(func() {
currentPage := m.GetCurrentPage()
m.Row(5, func() {})
m.SetBackgroundColor(subtleColor)
m.Row(0.25, func() { m.Col(12, func() {}) })
m.SetBackgroundColor(white)
m.Row(0.25, func() {})
m.SetBackgroundColor(subtleColor)
m.Row(0.5, func() { m.Col(12, func() {}) })
m.SetBackgroundColor(white)
m.Row(10, func() {
m.Col(4, func() {
m.Text("Generated: "+time.Now().Format("15:04:05"), props.Text{
Size: 8,
Style: consts.Italic,
Align: consts.Left,
Color: textSecondary,
Top: 3,
})
})
m.Col(4, func() {
m.Text(fmt.Sprintf("Page %d", currentPage+1), props.Text{
Size: 9,
Style: consts.Bold,
Align: consts.Center,
Color: textPrimary,
Top: 3,
})
})
})
})
headers := []string{"Time", "Value", "Unit", "Protocol", "Subtopic"}
widths := []uint{3, 2, 2, 2, 3}
for i, report := range reports {
if i > 0 {
m.AddPage()
}
m.Row(0.5, func() {
m.Col(1, func() {})
})
m.SetBackgroundColor(white)
m.Row(10, func() {
m.Col(12, func() {
m.Text("Metrics", props.Text{
Size: 16,
Style: consts.Bold,
Color: secondaryColor,
Top: 2,
})
})
})
m.SetBackgroundColor(alternateRow)
m.Row(0.5, func() { m.Col(12, func() {}) })
m.Row(8, func() {
m.Col(2, func() {
m.Text("Name: ", props.Text{
Size: 11,
Style: consts.Bold,
Align: consts.Left,
Color: textPrimary,
Top: 1,
})
})
m.Col(10, func() {
m.Text(report.Metric.Name, props.Text{
Size: 11,
Style: consts.Italic,
Color: textPrimary,
Top: 1,
})
})
})
if report.Metric.ClientID != "" {
m.Row(8, func() {
m.Col(2, func() {
m.Text("Device ID: ", props.Text{
Size: 11,
Style: consts.Bold,
Align: consts.Left,
Color: textPrimary,
Top: 1,
})
})
m.Col(10, func() {
m.Text(report.Metric.ClientID, props.Text{
Size: 11,
Style: consts.Italic,
Color: textPrimary,
Top: 1,
})
})
})
}
m.Row(8, func() {
m.Col(2, func() {
m.Text("Channel ID: ", props.Text{
Size: 11,
Style: consts.Bold,
Align: consts.Left,
Color: textPrimary,
Top: 1,
})
})
m.Col(10, func() {
m.Text(report.Metric.ChannelID, props.Text{
Size: 11,
Style: consts.Italic,
Color: textPrimary,
Top: 1,
})
})
})
m.SetBackgroundColor(alternateRow)
m.Row(0.5, func() { m.Col(12, func() {}) })
m.SetBackgroundColor(white)
m.Row(10, func() {
m.Col(12, func() {
m.Text(fmt.Sprintf("Total Records: %d", len(report.Messages)), props.Text{
Size: 10,
Style: consts.Italic,
Align: consts.Right,
Color: textSecondary,
Top: 2,
})
})
})
m.SetBackgroundColor(primaryColor)
m.Row(1, func() { m.Col(12, func() {}) })
m.SetBackgroundColor(tableHeaderBg)
m.Row(10, func() {
for i, header := range headers {
m.Col(widths[i], func() {
m.Text(header, props.Text{
Size: 11,
Style: consts.Bold,
Align: consts.Center,
Top: 2,
Color: secondaryColor,
})
})
}
})
m.SetBackgroundColor(subtleColor)
m.Row(0.5, func() { m.Col(12, func() {}) })
m.SetBackgroundColor(white)
useAlternateColor := false
for _, msg := range report.Messages {
if useAlternateColor {
m.SetBackgroundColor(alternateRow)
}
m.Row(9, func() {
m.Col(widths[0], func() {
m.Text(formatTime(msg.Time), props.Text{
Size: 10,
Align: consts.Center,
Top: 2,
Color: textPrimary,
})
})
m.Col(widths[1], func() {
m.Text(formatValue(msg), props.Text{
Size: 10,
Style: consts.Normal,
Align: consts.Center,
Top: 2,
Color: textPrimary,
})
})
m.Col(widths[2], func() {
m.Text(msg.Unit, props.Text{
Size: 10,
Style: consts.Italic,
Align: consts.Center,
Top: 2,
Color: textSecondary,
})
})
m.Col(widths[3], func() {
m.Text(msg.Protocol, props.Text{
Size: 10,
Align: consts.Center,
Top: 2,
Color: textPrimary,
})
})
m.Col(widths[4], func() {
m.Text(msg.Subtopic, props.Text{
Size: 10,
Align: consts.Center,
Top: 2,
Color: secondaryColor,
})
})
})
if !useAlternateColor {
m.Row(0.2, func() {
m.Col(12, func() {})
})
}
useAlternateColor = !useAlternateColor
m.SetBackgroundColor(white)
}
}
buf, err := m.Output()
now := time.Now().UTC()
data := ReportData{
Title: title,
GeneratedTime: now.Format("15:04:05"),
GeneratedDate: now.Format("02 Jan 2006"),
Reports: reports,
}
templateContent := r.defaultTemplate.String()
if template.String() != "" {
templateContent = template.String()
}
return r.generate(ctx, templateContent, data)
}
func (r *report) generate(ctx context.Context, templateContent string, data ReportData) ([]byte, error) {
tmpl := template.New("report").Funcs(template.FuncMap{
"formatTime": formatTime,
"formatValue": formatValue,
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
})
tmpl, err := tmpl.Parse(templateContent)
if err != nil {
return nil, errors.Wrap(svcerr.ErrCreateEntity, err)
}
return buf.Bytes(), nil
var htmlBuf bytes.Buffer
if err := tmpl.Execute(&htmlBuf, data); err != nil {
return nil, errors.Wrap(svcerr.ErrCreateEntity, err)
}
htmlContent := htmlBuf.String()
pdfBytes, err := r.htmlToPDF(ctx, htmlContent)
if err != nil {
return nil, errors.Wrap(svcerr.ErrCreateEntity, err)
}
return pdfBytes, nil
}
func (r *report) htmlToPDF(ctx context.Context, htmlContent string) ([]byte, error) {
payload := map[string]interface{}{
"html": htmlContent,
"options": map[string]interface{}{
"printBackground": true,
"margin": map[string]string{
"top": "0",
"bottom": "0",
"left": "0",
"right": "0",
},
"preferCSSPageSize": true,
},
}
jsonPayload, err := json.Marshal(payload)
if err != nil {
return nil, errors.Wrap(svcerr.ErrCreateEntity, err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, r.converterURL, bytes.NewReader(jsonPayload))
if err != nil {
return nil, errors.Wrap(svcerr.ErrCreateEntity, err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, errors.Wrap(svcerr.ErrCreateEntity, err)
}
defer resp.Body.Close()
pdfBytes, err := io.ReadAll(resp.Body)
if err != nil || resp.StatusCode != http.StatusOK {
return nil, errors.Wrap(svcerr.ErrCreateEntity, err)
}
return pdfBytes, nil
}
func formatTime(t float64) string {
@@ -330,7 +136,7 @@ func formatValue(msg senml.Message) string {
}
}
func generateCSVReport(title string, reports []Report) ([]byte, error) {
func (r *report) generateCSVReport(_ context.Context, title string, reports []Report) ([]byte, error) {
var buf bytes.Buffer
writer := csv.NewWriter(&buf)
+52
View File
@@ -19,6 +19,10 @@ var (
errDomainUpdateConfigs = errors.New("not authorized to update report configs in domain")
errDomainDeleteConfigs = errors.New("not authorized to delete report configs in domain")
errDomainGenerateReports = errors.New("not authorized to generate reports in domain")
errDomainUpdateTemplates = errors.New("not authorized to update report templates in domain")
errDomainRemoveTemplates = errors.New("not authorized to delete report templates in domain")
errDomainViewTemplates = errors.New("not authorized to view report templates in domain")
)
type authorizationMiddleware struct {
@@ -178,6 +182,54 @@ func (am *authorizationMiddleware) GenerateReport(ctx context.Context, session a
return am.svc.GenerateReport(ctx, session, config, action)
}
func (am *authorizationMiddleware) UpdateReportTemplate(ctx context.Context, session authn.Session, cfg reports.ReportConfig) error {
if err := am.authorize(ctx, smqauthz.PolicyReq{
Domain: session.DomainID,
SubjectType: policies.UserType,
SubjectKind: policies.UsersKind,
Subject: session.DomainUserID,
Object: session.DomainID,
ObjectType: policies.DomainType,
Permission: policies.MembershipPermission,
}); err != nil {
return errors.Wrap(errDomainUpdateTemplates, err)
}
return am.svc.UpdateReportTemplate(ctx, session, cfg)
}
func (am *authorizationMiddleware) ViewReportTemplate(ctx context.Context, session authn.Session, id string) (string, error) {
if err := am.authorize(ctx, smqauthz.PolicyReq{
Domain: session.DomainID,
SubjectType: policies.UserType,
SubjectKind: policies.UsersKind,
Subject: session.DomainUserID,
Object: session.DomainID,
ObjectType: policies.DomainType,
Permission: policies.MembershipPermission,
}); err != nil {
return "", errors.Wrap(errDomainViewTemplates, err)
}
return am.svc.ViewReportTemplate(ctx, session, id)
}
func (am *authorizationMiddleware) DeleteReportTemplate(ctx context.Context, session authn.Session, id string) error {
if err := am.authorize(ctx, smqauthz.PolicyReq{
Domain: session.DomainID,
SubjectType: policies.UserType,
SubjectKind: policies.UsersKind,
Subject: session.DomainUserID,
Object: session.DomainID,
ObjectType: policies.DomainType,
Permission: policies.MembershipPermission,
}); err != nil {
return errors.Wrap(errDomainRemoveTemplates, err)
}
return am.svc.DeleteReportTemplate(ctx, session, id)
}
func (am *authorizationMiddleware) StartScheduler(ctx context.Context) error {
return am.svc.StartScheduler(ctx)
}
+54
View File
@@ -208,3 +208,57 @@ func (lm *loggingMiddleware) RemoveReportConfig(ctx context.Context, session aut
}(time.Now())
return lm.svc.RemoveReportConfig(ctx, session, id)
}
func (lm *loggingMiddleware) UpdateReportTemplate(ctx context.Context, session authn.Session, cfg reports.ReportConfig) (err error) {
defer func(begin time.Time) {
args := []any{
slog.String("duration", time.Since(begin).String()),
slog.String("domain_id", session.DomainID),
slog.String("report_config_id", cfg.ID),
}
if err != nil {
args = append(args, slog.String("error", err.Error()))
lm.logger.Warn("Update report template failed", args...)
return
}
lm.logger.Info("Update report template completed successfully", args...)
}(time.Now())
return lm.svc.UpdateReportTemplate(ctx, session, cfg)
}
func (lm *loggingMiddleware) ViewReportTemplate(ctx context.Context, session authn.Session, id string) (t string, err error) {
defer func(begin time.Time) {
args := []any{
slog.String("duration", time.Since(begin).String()),
slog.String("domain_id", session.DomainID),
slog.String("report_config_id", id),
}
if err != nil {
args = append(args, slog.String("error", err.Error()))
lm.logger.Warn("View report template failed", args...)
return
}
lm.logger.Info("View report template completed successfully", args...)
}(time.Now())
return lm.svc.ViewReportTemplate(ctx, session, id)
}
func (lm *loggingMiddleware) DeleteReportTemplate(ctx context.Context, session authn.Session, id string) (err error) {
defer func(begin time.Time) {
args := []any{
slog.String("duration", time.Since(begin).String()),
slog.String("domain_id", session.DomainID),
slog.String("report_config_id", id),
}
if err != nil {
args = append(args, slog.String("error", err.Error()))
lm.logger.Warn("Delete report template failed", args...)
return
}
lm.logger.Info("Delete report template completed successfully", args...)
}(time.Now())
return lm.svc.DeleteReportTemplate(ctx, session, id)
}
+151
View File
@@ -97,6 +97,53 @@ func (_c *Repository_AddReportConfig_Call) RunAndReturn(run func(ctx context.Con
return _c
}
// DeleteReportTemplate provides a mock function for the type Repository
func (_mock *Repository) DeleteReportTemplate(ctx context.Context, domainID string, reportID string) error {
ret := _mock.Called(ctx, domainID, reportID)
if len(ret) == 0 {
panic("no return value specified for DeleteReportTemplate")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok {
r0 = returnFunc(ctx, domainID, reportID)
} else {
r0 = ret.Error(0)
}
return r0
}
// Repository_DeleteReportTemplate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteReportTemplate'
type Repository_DeleteReportTemplate_Call struct {
*mock.Call
}
// DeleteReportTemplate is a helper method to define mock.On call
// - ctx
// - domainID
// - reportID
func (_e *Repository_Expecter) DeleteReportTemplate(ctx interface{}, domainID interface{}, reportID interface{}) *Repository_DeleteReportTemplate_Call {
return &Repository_DeleteReportTemplate_Call{Call: _e.mock.On("DeleteReportTemplate", ctx, domainID, reportID)}
}
func (_c *Repository_DeleteReportTemplate_Call) Run(run func(ctx context.Context, domainID string, reportID string)) *Repository_DeleteReportTemplate_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(string))
})
return _c
}
func (_c *Repository_DeleteReportTemplate_Call) Return(err error) *Repository_DeleteReportTemplate_Call {
_c.Call.Return(err)
return _c
}
func (_c *Repository_DeleteReportTemplate_Call) RunAndReturn(run func(ctx context.Context, domainID string, reportID string) error) *Repository_DeleteReportTemplate_Call {
_c.Call.Return(run)
return _c
}
// ListReportsConfig provides a mock function for the type Repository
func (_mock *Repository) ListReportsConfig(ctx context.Context, pm reports.PageMeta) (reports.ReportConfigPage, error) {
ret := _mock.Called(ctx, pm)
@@ -419,6 +466,54 @@ func (_c *Repository_UpdateReportSchedule_Call) RunAndReturn(run func(ctx contex
return _c
}
// UpdateReportTemplate provides a mock function for the type Repository
func (_mock *Repository) UpdateReportTemplate(ctx context.Context, domainID string, reportID string, template reports.ReportTemplate) error {
ret := _mock.Called(ctx, domainID, reportID, template)
if len(ret) == 0 {
panic("no return value specified for UpdateReportTemplate")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, reports.ReportTemplate) error); ok {
r0 = returnFunc(ctx, domainID, reportID, template)
} else {
r0 = ret.Error(0)
}
return r0
}
// Repository_UpdateReportTemplate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateReportTemplate'
type Repository_UpdateReportTemplate_Call struct {
*mock.Call
}
// UpdateReportTemplate is a helper method to define mock.On call
// - ctx
// - domainID
// - reportID
// - template
func (_e *Repository_Expecter) UpdateReportTemplate(ctx interface{}, domainID interface{}, reportID interface{}, template interface{}) *Repository_UpdateReportTemplate_Call {
return &Repository_UpdateReportTemplate_Call{Call: _e.mock.On("UpdateReportTemplate", ctx, domainID, reportID, template)}
}
func (_c *Repository_UpdateReportTemplate_Call) Run(run func(ctx context.Context, domainID string, reportID string, template reports.ReportTemplate)) *Repository_UpdateReportTemplate_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(reports.ReportTemplate))
})
return _c
}
func (_c *Repository_UpdateReportTemplate_Call) Return(err error) *Repository_UpdateReportTemplate_Call {
_c.Call.Return(err)
return _c
}
func (_c *Repository_UpdateReportTemplate_Call) RunAndReturn(run func(ctx context.Context, domainID string, reportID string, template reports.ReportTemplate) error) *Repository_UpdateReportTemplate_Call {
_c.Call.Return(run)
return _c
}
// ViewReportConfig provides a mock function for the type Repository
func (_mock *Repository) ViewReportConfig(ctx context.Context, id string) (reports.ReportConfig, error) {
ret := _mock.Called(ctx, id)
@@ -473,3 +568,59 @@ func (_c *Repository_ViewReportConfig_Call) RunAndReturn(run func(ctx context.Co
_c.Call.Return(run)
return _c
}
// ViewReportTemplate provides a mock function for the type Repository
func (_mock *Repository) ViewReportTemplate(ctx context.Context, domainID string, reportID string) (string, error) {
ret := _mock.Called(ctx, domainID, reportID)
if len(ret) == 0 {
panic("no return value specified for ViewReportTemplate")
}
var r0 string
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (string, error)); ok {
return returnFunc(ctx, domainID, reportID)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) string); ok {
r0 = returnFunc(ctx, domainID, reportID)
} else {
r0 = ret.Get(0).(string)
}
if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
r1 = returnFunc(ctx, domainID, reportID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Repository_ViewReportTemplate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ViewReportTemplate'
type Repository_ViewReportTemplate_Call struct {
*mock.Call
}
// ViewReportTemplate is a helper method to define mock.On call
// - ctx
// - domainID
// - reportID
func (_e *Repository_Expecter) ViewReportTemplate(ctx interface{}, domainID interface{}, reportID interface{}) *Repository_ViewReportTemplate_Call {
return &Repository_ViewReportTemplate_Call{Call: _e.mock.On("ViewReportTemplate", ctx, domainID, reportID)}
}
func (_c *Repository_ViewReportTemplate_Call) Run(run func(ctx context.Context, domainID string, reportID string)) *Repository_ViewReportTemplate_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(string))
})
return _c
}
func (_c *Repository_ViewReportTemplate_Call) Return(s string, err error) *Repository_ViewReportTemplate_Call {
_c.Call.Return(s, err)
return _c
}
func (_c *Repository_ViewReportTemplate_Call) RunAndReturn(run func(ctx context.Context, domainID string, reportID string) (string, error)) *Repository_ViewReportTemplate_Call {
_c.Call.Return(run)
return _c
}
+150
View File
@@ -98,6 +98,53 @@ func (_c *Service_AddReportConfig_Call) RunAndReturn(run func(ctx context.Contex
return _c
}
// DeleteReportTemplate provides a mock function for the type Service
func (_mock *Service) DeleteReportTemplate(ctx context.Context, session authn.Session, id string) error {
ret := _mock.Called(ctx, session, id)
if len(ret) == 0 {
panic("no return value specified for DeleteReportTemplate")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(context.Context, authn.Session, string) error); ok {
r0 = returnFunc(ctx, session, id)
} else {
r0 = ret.Error(0)
}
return r0
}
// Service_DeleteReportTemplate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteReportTemplate'
type Service_DeleteReportTemplate_Call struct {
*mock.Call
}
// DeleteReportTemplate is a helper method to define mock.On call
// - ctx
// - session
// - id
func (_e *Service_Expecter) DeleteReportTemplate(ctx interface{}, session interface{}, id interface{}) *Service_DeleteReportTemplate_Call {
return &Service_DeleteReportTemplate_Call{Call: _e.mock.On("DeleteReportTemplate", ctx, session, id)}
}
func (_c *Service_DeleteReportTemplate_Call) Run(run func(ctx context.Context, session authn.Session, id string)) *Service_DeleteReportTemplate_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(authn.Session), args[2].(string))
})
return _c
}
func (_c *Service_DeleteReportTemplate_Call) Return(err error) *Service_DeleteReportTemplate_Call {
_c.Call.Return(err)
return _c
}
func (_c *Service_DeleteReportTemplate_Call) RunAndReturn(run func(ctx context.Context, session authn.Session, id string) error) *Service_DeleteReportTemplate_Call {
_c.Call.Return(run)
return _c
}
// DisableReportConfig provides a mock function for the type Service
func (_mock *Service) DisableReportConfig(ctx context.Context, session authn.Session, id string) (reports.ReportConfig, error) {
ret := _mock.Called(ctx, session, id)
@@ -527,6 +574,53 @@ func (_c *Service_UpdateReportSchedule_Call) RunAndReturn(run func(ctx context.C
return _c
}
// UpdateReportTemplate provides a mock function for the type Service
func (_mock *Service) UpdateReportTemplate(ctx context.Context, session authn.Session, cfg reports.ReportConfig) error {
ret := _mock.Called(ctx, session, cfg)
if len(ret) == 0 {
panic("no return value specified for UpdateReportTemplate")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(context.Context, authn.Session, reports.ReportConfig) error); ok {
r0 = returnFunc(ctx, session, cfg)
} else {
r0 = ret.Error(0)
}
return r0
}
// Service_UpdateReportTemplate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateReportTemplate'
type Service_UpdateReportTemplate_Call struct {
*mock.Call
}
// UpdateReportTemplate is a helper method to define mock.On call
// - ctx
// - session
// - cfg
func (_e *Service_Expecter) UpdateReportTemplate(ctx interface{}, session interface{}, cfg interface{}) *Service_UpdateReportTemplate_Call {
return &Service_UpdateReportTemplate_Call{Call: _e.mock.On("UpdateReportTemplate", ctx, session, cfg)}
}
func (_c *Service_UpdateReportTemplate_Call) Run(run func(ctx context.Context, session authn.Session, cfg reports.ReportConfig)) *Service_UpdateReportTemplate_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(authn.Session), args[2].(reports.ReportConfig))
})
return _c
}
func (_c *Service_UpdateReportTemplate_Call) Return(err error) *Service_UpdateReportTemplate_Call {
_c.Call.Return(err)
return _c
}
func (_c *Service_UpdateReportTemplate_Call) RunAndReturn(run func(ctx context.Context, session authn.Session, cfg reports.ReportConfig) error) *Service_UpdateReportTemplate_Call {
_c.Call.Return(run)
return _c
}
// ViewReportConfig provides a mock function for the type Service
func (_mock *Service) ViewReportConfig(ctx context.Context, session authn.Session, id string) (reports.ReportConfig, error) {
ret := _mock.Called(ctx, session, id)
@@ -582,3 +676,59 @@ func (_c *Service_ViewReportConfig_Call) RunAndReturn(run func(ctx context.Conte
_c.Call.Return(run)
return _c
}
// ViewReportTemplate provides a mock function for the type Service
func (_mock *Service) ViewReportTemplate(ctx context.Context, session authn.Session, id string) (string, error) {
ret := _mock.Called(ctx, session, id)
if len(ret) == 0 {
panic("no return value specified for ViewReportTemplate")
}
var r0 string
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, authn.Session, string) (string, error)); ok {
return returnFunc(ctx, session, id)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, authn.Session, string) string); ok {
r0 = returnFunc(ctx, session, id)
} else {
r0 = ret.Get(0).(string)
}
if returnFunc, ok := ret.Get(1).(func(context.Context, authn.Session, string) error); ok {
r1 = returnFunc(ctx, session, id)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Service_ViewReportTemplate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ViewReportTemplate'
type Service_ViewReportTemplate_Call struct {
*mock.Call
}
// ViewReportTemplate is a helper method to define mock.On call
// - ctx
// - session
// - id
func (_e *Service_Expecter) ViewReportTemplate(ctx interface{}, session interface{}, id interface{}) *Service_ViewReportTemplate_Call {
return &Service_ViewReportTemplate_Call{Call: _e.mock.On("ViewReportTemplate", ctx, session, id)}
}
func (_c *Service_ViewReportTemplate_Call) Run(run func(ctx context.Context, session authn.Session, id string)) *Service_ViewReportTemplate_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(authn.Session), args[2].(string))
})
return _c
}
func (_c *Service_ViewReportTemplate_Call) Return(s string, err error) *Service_ViewReportTemplate_Call {
_c.Call.Return(s, err)
return _c
}
func (_c *Service_ViewReportTemplate_Call) RunAndReturn(run func(ctx context.Context, session authn.Session, id string) (string, error)) *Service_ViewReportTemplate_Call {
_c.Call.Return(run)
return _c
}
+9
View File
@@ -37,6 +37,15 @@ func Migration() *migrate.MemoryMigrationSource {
`DROP TABLE IF EXISTS report_config;`,
},
},
{
Id: "reports_02",
Up: []string{
`ALTER TABLE report_config ADD COLUMN report_template TEXT;`,
},
Down: []string{
`ALTER TABLE report_config DROP COLUMN report_template;`,
},
},
},
}
}
+25 -22
View File
@@ -15,22 +15,23 @@ import (
// dbReport represents the database structure for a Report.
type dbReport struct {
ID string `db:"id"`
Name string `db:"name"`
Description string `db:"description"`
DomainID string `db:"domain_id"`
StartDateTime sql.NullTime `db:"start_datetime"`
Due sql.NullTime `db:"due"`
Recurring schedule.Recurring `db:"recurring"`
RecurringPeriod uint `db:"recurring_period"`
Status reports.Status `db:"status"`
CreatedAt time.Time `db:"created_at"`
CreatedBy string `db:"created_by"`
UpdatedAt time.Time `db:"updated_at"`
UpdatedBy string `db:"updated_by"`
Config []byte `db:"config,omitempty"`
Metrics []byte `db:"metrics"`
Email []byte `db:"email"`
ID string `db:"id"`
Name string `db:"name"`
Description string `db:"description"`
DomainID string `db:"domain_id"`
StartDateTime sql.NullTime `db:"start_datetime"`
Due sql.NullTime `db:"due"`
Recurring schedule.Recurring `db:"recurring"`
RecurringPeriod uint `db:"recurring_period"`
Status reports.Status `db:"status"`
CreatedAt time.Time `db:"created_at"`
CreatedBy string `db:"created_by"`
UpdatedAt time.Time `db:"updated_at"`
UpdatedBy string `db:"updated_by"`
Config []byte `db:"config,omitempty"`
Metrics []byte `db:"metrics"`
Email []byte `db:"email"`
ReportTemplate reports.ReportTemplate `db:"report_template"`
}
func reportToDb(r reports.ReportConfig) (dbReport, error) {
@@ -86,6 +87,7 @@ func reportToDb(r reports.ReportConfig) (dbReport, error) {
Config: config,
Metrics: metrics,
Email: email,
ReportTemplate: r.ReportTemplate,
}, nil
}
@@ -124,12 +126,13 @@ func dbToReport(dto dbReport) (reports.ReportConfig, error) {
Recurring: dto.Recurring,
RecurringPeriod: dto.RecurringPeriod,
},
Email: &email,
Status: dto.Status,
CreatedAt: dto.CreatedAt,
CreatedBy: dto.CreatedBy,
UpdatedAt: dto.UpdatedAt,
UpdatedBy: dto.UpdatedBy,
Email: &email,
Status: dto.Status,
CreatedAt: dto.CreatedAt,
CreatedBy: dto.CreatedBy,
UpdatedAt: dto.UpdatedAt,
UpdatedBy: dto.UpdatedBy,
ReportTemplate: dto.ReportTemplate,
}
return rpt, nil
+72 -4
View File
@@ -27,11 +27,11 @@ func NewRepository(db postgres.Database) reports.Repository {
func (repo *PostgresRepository) AddReportConfig(ctx context.Context, cfg reports.ReportConfig) (reports.ReportConfig, error) {
q := `
INSERT INTO report_config (id, name, description, domain_id, config, metrics,
email, start_datetime, due, recurring, recurring_period, created_at, created_by, updated_at, updated_by, status)
email, start_datetime, due, recurring, recurring_period, created_at, created_by, updated_at, updated_by, status, report_template)
VALUES (:id, :name, :description, :domain_id, :config, :metrics,
:email, :start_datetime, :due, :recurring, :recurring_period, :created_at, :created_by, :updated_at, :updated_by, :status)
:email, :start_datetime, :due, :recurring, :recurring_period, :created_at, :created_by, :updated_at, :updated_by, :status, :report_template)
RETURNING id, name, description, domain_id, config, metrics,
email, start_datetime, due, recurring, recurring_period, created_at, created_by, updated_at, updated_by, status;
email, start_datetime, due, recurring, recurring_period, created_at, created_by, updated_at, updated_by, status, report_template;
`
dbr, err := reportToDb(cfg)
if err != nil {
@@ -60,7 +60,7 @@ func (repo *PostgresRepository) AddReportConfig(ctx context.Context, cfg reports
func (repo *PostgresRepository) ViewReportConfig(ctx context.Context, id string) (reports.ReportConfig, error) {
q := `
SELECT id, name, description, domain_id, config, metrics,
SELECT id, name, description, domain_id, config, metrics, report_template,
email, start_datetime, due, recurring, recurring_period, created_at, created_by, updated_at, updated_by, status
FROM report_config
WHERE id = $1;
@@ -313,6 +313,74 @@ func (repo *PostgresRepository) UpdateReportDue(ctx context.Context, id string,
return report, nil
}
func (repo *PostgresRepository) UpdateReportTemplate(ctx context.Context, domainID, reportID string, template reports.ReportTemplate) error {
q := `
UPDATE report_configs
SET report_template = :report_template, updated_at = :updated_at
WHERE id = :id AND domain_id = :domain_id`
dbr := dbReport{
ID: reportID,
DomainID: domainID,
UpdatedAt: time.Now().UTC(),
ReportTemplate: template,
}
row, err := repo.DB.NamedQueryContext(ctx, q, dbr)
if err != nil {
return errors.Wrap(repoerr.ErrUpdateEntity, err)
}
defer row.Close()
return nil
}
func (repo *PostgresRepository) ViewReportTemplate(ctx context.Context, domainID, reportID string) (string, error) {
q := `
SELECT COALESCE(report_template, '') as report_template
FROM report_configs
WHERE id = $1 AND domain_id = $2`
var template string
err := repo.DB.QueryRowxContext(ctx, q, reportID, domainID).Scan(&template)
if err != nil {
if err == sql.ErrNoRows {
return "", repoerr.ErrNotFound
}
return "", errors.Wrap(repoerr.ErrViewEntity, err)
}
return template, nil
}
func (repo *PostgresRepository) DeleteReportTemplate(ctx context.Context, domainID, reportID string) error {
q := `
UPDATE report_configs
SET custom_template = NULL, updated_at = :updated_at
WHERE id = :id AND domain_id = :domain_id`
dbr := dbReport{
ID: reportID,
DomainID: domainID,
UpdatedAt: time.Now().UTC(),
}
result, err := repo.DB.ExecContext(ctx, q, dbr)
if err != nil {
return errors.Wrap(repoerr.ErrUpdateEntity, err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return errors.Wrap(repoerr.ErrUpdateEntity, err)
}
if rowsAffected == 0 {
return repoerr.ErrNotFound
}
return nil
}
func pageReportQuery(pm reports.PageMeta) string {
var query []string
if pm.Status != reports.AllStatus {
+22 -13
View File
@@ -145,19 +145,20 @@ func (rm ReqMetric) Validate() error {
}
type ReportConfig struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
DomainID string `json:"domain_id"`
Schedule schedule.Schedule `json:"schedule,omitempty"`
Config *MetricConfig `json:"config,omitempty"`
Email *EmailSetting `json:"email,omitempty"`
Metrics []ReqMetric `json:"metrics,omitempty"`
Status Status `json:"status"`
CreatedAt time.Time `json:"created_at"`
CreatedBy string `json:"created_by,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
UpdatedBy string `json:"updated_by,omitempty"`
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
DomainID string `json:"domain_id"`
Schedule schedule.Schedule `json:"schedule,omitempty"`
Config *MetricConfig `json:"config,omitempty"`
Email *EmailSetting `json:"email,omitempty"`
Metrics []ReqMetric `json:"metrics,omitempty"`
ReportTemplate ReportTemplate `json:"report_template,omitempty"`
Status Status `json:"status"`
CreatedAt time.Time `json:"created_at"`
CreatedBy string `json:"created_by,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
UpdatedBy string `json:"updated_by,omitempty"`
}
type ReportConfigPage struct {
@@ -394,6 +395,10 @@ type Repository interface {
UpdateReportConfigStatus(ctx context.Context, cfg ReportConfig) (ReportConfig, error)
ListReportsConfig(ctx context.Context, pm PageMeta) (ReportConfigPage, error)
UpdateReportDue(ctx context.Context, id string, due time.Time) (ReportConfig, error)
UpdateReportTemplate(ctx context.Context, domainID, reportID string, template ReportTemplate) error
ViewReportTemplate(ctx context.Context, domainID, reportID string) (string, error)
DeleteReportTemplate(ctx context.Context, domainID, reportID string) error
}
type Service interface {
@@ -406,6 +411,10 @@ type Service interface {
EnableReportConfig(ctx context.Context, session authn.Session, id string) (ReportConfig, error)
DisableReportConfig(ctx context.Context, session authn.Session, id string) (ReportConfig, error)
UpdateReportTemplate(ctx context.Context, session authn.Session, cfg ReportConfig) error
ViewReportTemplate(ctx context.Context, session authn.Session, id string) (string, error)
DeleteReportTemplate(ctx context.Context, session authn.Session, id string) error
GenerateReport(ctx context.Context, session authn.Session, config ReportConfig, action ReportAction) (ReportPage, error)
StartScheduler(ctx context.Context) error
}
+53 -18
View File
@@ -24,22 +24,26 @@ import (
const limit = 1000
type report struct {
repo Repository
runInfo chan pkglog.RunInfo
idp supermq.IDProvider
email emailer.Emailer
ticker ticker.Ticker
readers grpcReadersV1.ReadersServiceClient
repo Repository
runInfo chan pkglog.RunInfo
idp supermq.IDProvider
email emailer.Emailer
ticker ticker.Ticker
readers grpcReadersV1.ReadersServiceClient
defaultTemplate ReportTemplate
converterURL string
}
func NewService(repo Repository, runInfo chan pkglog.RunInfo, idp supermq.IDProvider, tck ticker.Ticker, emailer emailer.Emailer, readers grpcReadersV1.ReadersServiceClient) Service {
func NewService(repo Repository, runInfo chan pkglog.RunInfo, idp supermq.IDProvider, tck ticker.Ticker, emailer emailer.Emailer, readers grpcReadersV1.ReadersServiceClient, template ReportTemplate, converterURL string) Service {
return &report{
repo: repo,
idp: idp,
runInfo: runInfo,
email: emailer,
ticker: tck,
readers: readers,
repo: repo,
idp: idp,
runInfo: runInfo,
email: emailer,
ticker: tck,
readers: readers,
defaultTemplate: template,
converterURL: converterURL,
}
}
@@ -171,7 +175,7 @@ func (r *report) GenerateReport(ctx context.Context, session authn.Session, conf
}
func (r *report) generateReport(ctx context.Context, cfg ReportConfig, action ReportAction) (ReportPage, error) {
genReportFile, err := generateFileFunc(action, cfg.Config.FileFormat)
genReportFile, err := r.generateFileFunc(ctx, action, cfg.Config.FileFormat, cfg.ReportTemplate)
if err != nil {
return ReportPage{}, err
}
@@ -282,7 +286,7 @@ func (r *report) generateReport(ctx context.Context, cfg ReportConfig, action Re
switch {
case genReportFile != nil:
data, err := genReportFile(cfg.Config.Title, reports)
data, err := genReportFile(ctx, cfg.Config.Title, reports)
if err != nil {
return ReportPage{}, err
}
@@ -323,14 +327,18 @@ func (r *report) generateReport(ctx context.Context, cfg ReportConfig, action Re
}
}
func generateFileFunc(action ReportAction, format Format) (func(string, []Report) ([]byte, error), error) {
func (r *report) generateFileFunc(ctx context.Context, action ReportAction, format Format, customTemplate ReportTemplate) (func(context.Context, string, []Report) ([]byte, error), error) {
switch action {
case DownloadReport, EmailReport:
switch format {
case PDF:
return generatePDFReport, nil
return func(ctx context.Context, title string, reports []Report) ([]byte, error) {
return r.generatePDFReport(ctx, title, reports, customTemplate)
}, nil
case CSV:
return generateCSVReport, nil
return func(ctx context.Context, title string, reports []Report) ([]byte, error) {
return r.generateCSVReport(ctx, title, reports)
}, nil
default:
return nil, errors.New("file format not supported")
}
@@ -416,3 +424,30 @@ func groupReportsByPublisher(metric Metric, sMsgs []senml.Message) []Report {
return groupedReports
}
func (r *report) UpdateReportTemplate(ctx context.Context, session authn.Session, cfg ReportConfig) error {
err := r.repo.UpdateReportTemplate(ctx, session.DomainID, cfg.ID, cfg.ReportTemplate)
if err != nil {
return errors.Wrap(svcerr.ErrUpdateEntity, err)
}
return nil
}
func (r *report) ViewReportTemplate(ctx context.Context, session authn.Session, id string) (string, error) {
template, err := r.repo.ViewReportTemplate(ctx, session.DomainID, id)
if err != nil {
return "", errors.Wrap(svcerr.ErrCreateEntity, err)
}
return template, nil
}
func (r *report) DeleteReportTemplate(ctx context.Context, session authn.Session, id string) error {
err := r.repo.DeleteReportTemplate(ctx, session.DomainID, id)
if err != nil {
return errors.Wrap(svcerr.ErrRemoveEntity, err)
}
return nil
}
+7 -5
View File
@@ -11,9 +11,10 @@ import (
"github.com/0x6flab/namegenerator"
"github.com/absmach/magistrala/internal/testsutil"
emocks "github.com/absmach/magistrala/pkg/emailer/mocks"
pkglog "github.com/absmach/magistrala/pkg/logger"
pkgSch "github.com/absmach/magistrala/pkg/schedule"
remocks "github.com/absmach/magistrala/re/mocks"
tmocks "github.com/absmach/magistrala/pkg/ticker/mocks"
readmocks "github.com/absmach/magistrala/readers/mocks"
"github.com/absmach/magistrala/reports"
"github.com/absmach/magistrala/reports/mocks"
@@ -31,6 +32,7 @@ var (
userID = testsutil.GenerateUUID(&testing.T{})
domainID = testsutil.GenerateUUID(&testing.T{})
now = time.Now().UTC()
template = reports.ReportTemplate("")
schedule = pkgSch.Schedule{
StartDateTime: now,
Recurring: pkgSch.Daily,
@@ -50,13 +52,13 @@ var (
}
)
func newService(runInfo chan pkglog.RunInfo) (reports.Service, *mocks.Repository, *remocks.Ticker) {
func newService(runInfo chan pkglog.RunInfo) (reports.Service, *mocks.Repository, *tmocks.Ticker) {
repo := new(mocks.Repository)
mockTicker := new(remocks.Ticker)
mockTicker := new(tmocks.Ticker)
idProvider := uuid.NewMock()
readersSvc := new(readmocks.ReadersServiceClient)
e := new(remocks.Emailer)
return reports.NewService(repo, runInfo, idProvider, mockTicker, e, readersSvc), repo, mockTicker
e := new(emocks.Emailer)
return reports.NewService(repo, runInfo, idProvider, mockTicker, e, readersSvc, template, ""), repo, mockTicker
}
func TestAddReportConfig(t *testing.T) {
+137
View File
@@ -0,0 +1,137 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package reports
import (
"encoding/json"
"fmt"
"regexp"
"strings"
)
var (
requiredFields = []string{
"{{$.Title}}",
"{{$.GeneratedDate}}",
"{{$.GeneratedTime}}",
"{{.Metric.Name}}",
"{{.Metric.ClientID}}",
"{{.Metric.ChannelID}}",
"{{len .Messages}}",
"{{range .Messages}}",
"{{formatTime .Time}}",
"{{formatValue .}}",
"{{.Unit}}",
"{{.Protocol}}",
"{{.Subtopic}}",
"{{end}}",
}
requiredStructure = []string{
"<!DOCTYPE html>",
"<html",
"<head>",
"<body>",
"<style>",
"</style>",
"</head>",
"</body>",
"</html>",
}
requiredCSS = []string{
".page",
".header",
".content-area",
".metrics-section",
".data-table",
".footer",
}
)
type ReportTemplate string
func (temp ReportTemplate) String() string {
return string(temp)
}
func (temp ReportTemplate) MarshalJSON() ([]byte, error) {
return json.Marshal(string(temp))
}
func (temp *ReportTemplate) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err != nil {
return err
}
*temp = ReportTemplate(s)
return nil
}
func (temp ReportTemplate) Validate() error {
template := string(temp)
for _, required := range requiredStructure {
if !strings.Contains(template, required) {
return fmt.Errorf("missing required HTML element: %s", required)
}
}
cleaned := strings.TrimSpace(template)
commentPattern := regexp.MustCompile(`^(?s:<!--.*?-->\s*)*`)
cleaned = commentPattern.ReplaceAllString(cleaned, "")
if !strings.HasPrefix(cleaned, "<!DOCTYPE html>") {
return fmt.Errorf("template must start with <!DOCTYPE html>")
}
for _, field := range requiredFields {
if !strings.Contains(template, field) {
return fmt.Errorf("missing required template field: %s", field)
}
}
blockStartPattern := regexp.MustCompile(`\{\{\s*(range|if|with)\b[^{}]*\}\}`)
blockEndPattern := regexp.MustCompile(`\{\{\s*end\s*\}\}`)
blockStarts := blockStartPattern.FindAllString(template, -1)
blockEnds := blockEndPattern.FindAllString(template, -1)
if len(blockStarts) != len(blockEnds) {
return fmt.Errorf("unmatched template blocks: found %d block start(s) (range/if/with) and %d end(s)",
len(blockStarts), len(blockEnds))
}
for _, class := range requiredCSS {
pattern := fmt.Sprintf(`\.%s\s*\{`, strings.TrimPrefix(class, "."))
matched, _ := regexp.MatchString(pattern, template)
if !matched {
return fmt.Errorf("missing required CSS class: %s", class)
}
}
requiredTableElements := []string{
"<table",
"<thead>",
"<tbody>",
"<th",
"<td",
}
for _, element := range requiredTableElements {
if !strings.Contains(template, element) {
return fmt.Errorf("missing required table element: %s", element)
}
}
expectedHeaders := []string{"Time", "Value", "Unit", "Protocol", "Subtopic"}
for _, header := range expectedHeaders {
if !strings.Contains(template, header) {
return fmt.Errorf("missing expected table header: %s", header)
}
}
return nil
}
+7 -1
View File
@@ -39,7 +39,13 @@ packages:
github.com/absmach/magistrala/reports:
interfaces:
Service:
Repository:
Repository:
github.com/absmach/magistrala/pkg/emailer:
interfaces:
Emailer:
github.com/absmach/magistrala/pkg/ticker:
interfaces:
Ticker:
github.com/absmach/magistrala/api/grpc/readers/v1:
interfaces:
ReadersServiceClient: