mirror of
https://github.com/absmach/supermq.git
synced 2026-06-23 06:20:18 +00:00
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:
+42
-11
@@ -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>
|
||||
@@ -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=./
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user