NOISSUE - Add timezone support for reports (#329)

* add timezone support

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

* update supermq

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

* revert env variable

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>

* revert env variable

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

* add reports title for context

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

---------

Signed-off-by: nyagamunene <stevenyaga2014@gmail.com>
This commit is contained in:
Steve Munene
2025-10-08 19:27:02 +03:00
committed by GitHub
parent ce5cb76dd4
commit 8d4ead8e86
7 changed files with 201 additions and 21 deletions
@@ -355,7 +355,7 @@
<div class="header-content">
<div style="width: 100px;"></div>
<div class="header-title">{{$.Title}}</div>
<div class="header-date">{{$.GeneratedDate}}</div>
<div class="header-date">{{$.GeneratedDate}}{{if $.Timezone}} ({{$.Timezone}}){{end}}</div>
</div>
<div class="header-separator"></div>
</div>
@@ -415,7 +415,7 @@
<div class="footer">
<div class="footer-separator"></div>
<div class="footer-content">
<div class="footer-generated">Generated: {{$.GeneratedTime}}</div>
<div class="footer-generated">Generated: {{$.GeneratedTime}}{{if $.Timezone}} ({{$.Timezone}}){{end}}</div>
<div class="footer-page">Page {{$globalPage}} of {{$totalPages}}</div>
</div>
</div>
@@ -429,7 +429,7 @@
<div class="header-content">
<div style="width: 100px;"></div>
<div class="header-title">{{.Title}}</div>
<div class="header-date">{{.GeneratedDate}}</div>
<div class="header-date">{{.GeneratedDate}}{{if .Timezone}} ({{.Timezone}}){{end}}</div>
</div>
<div class="header-separator"></div>
</div>
@@ -477,7 +477,7 @@
<div class="footer">
<div class="footer-separator"></div>
<div class="footer-content">
<div class="footer-generated">Generated: {{.GeneratedTime}}</div>
<div class="footer-generated">Generated: {{.GeneratedTime}}{{if .Timezone}} ({{.Timezone}}){{end}}</div>
<div class="footer-page">Page {{$globalPage}} of {{$totalPages}}</div>
</div>
</div>
@@ -270,7 +270,10 @@ if [ ! -f /opt/openbao/data/configured ]; then
key_usage=\"DigitalSignature,KeyEncipherment,KeyAgreement\" \
ext_key_usage=\"ServerAuth,ClientAuth,OCSPSigning\" \
use_csr_common_name=true \
use_csr_sans=false \
use_csr_sans=true \
copy_extensions=true \
allowed_extensions=\"*\" \
basic_constraints_valid_for_non_ca=true \
max_ttl=720h \
ttl=720h"
@@ -284,6 +287,9 @@ path "pki_int/issue/${AM_CERTS_OPENBAO_PKI_ROLE}" {
path "pki_int/sign/${AM_CERTS_OPENBAO_PKI_ROLE}" {
capabilities = ["create", "update"]
}
path "pki_int/sign-verbatim/${AM_CERTS_OPENBAO_PKI_ROLE}" {
capabilities = ["create", "update"]
}
path "pki_int/certs" {
capabilities = ["list"]
}
+48 -9
View File
@@ -10,36 +10,61 @@ import (
"fmt"
"html/template"
"io"
"log/slog"
"mime/multipart"
"net/http"
"sort"
"strings"
"time"
_ "time/tzdata" // Embed timezone database
pkglog "github.com/absmach/magistrala/pkg/logger"
"github.com/absmach/supermq/pkg/errors"
svcerr "github.com/absmach/supermq/pkg/errors/service"
"github.com/absmach/supermq/pkg/transformers/senml"
)
const nanosecondThreshold = float64(10 * time.Second / time.Nanosecond)
type ReportData struct {
Title string
GeneratedTime string
GeneratedDate string
Reports []Report
Timezone string
}
func (r *report) generatePDFReport(ctx context.Context, title string, reports []Report, template ReportTemplate) ([]byte, error) {
func (r *report) generatePDFReport(ctx context.Context, title string, reports []Report, template ReportTemplate, timezone string) ([]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
})
}
now := time.Now().UTC()
loc, err := resolveTimezone(timezone)
if err != nil {
r.runInfo <- pkglog.RunInfo{
Level: slog.LevelWarn,
Message: fmt.Sprintf("failed to resolve timezone '%s', falling back to UTC: %s", timezone, err),
Details: []slog.Attr{
slog.String("report_title", title),
slog.Time("time", time.Now().UTC()),
},
}
}
now := time.Now().In(loc)
displayTZ := timezone
if strings.TrimSpace(displayTZ) == "" {
displayTZ = "UTC"
}
data := ReportData{
Title: title,
GeneratedTime: now.Format("15:04:05"),
GeneratedDate: now.Format("02 Jan 2006"),
Reports: reports,
Timezone: displayTZ,
}
templateContent := r.defaultTemplate.String()
@@ -51,7 +76,7 @@ func (r *report) generatePDFReport(ctx context.Context, title string, reports []
func (r *report) generate(ctx context.Context, templateContent string, data ReportData) ([]byte, error) {
tmpl := template.New("report").Funcs(template.FuncMap{
"formatTime": formatTime,
"formatTime": func(t float64) string { return r.formatTimeWithTimezone(t, data.Timezone) },
"formatValue": formatValue,
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
@@ -140,11 +165,25 @@ func (r *report) htmlToPDF(ctx context.Context, htmlContent string) ([]byte, err
return pdfBytes, nil
}
func formatTime(t float64) string {
if t > 9999999999 {
return time.Unix(0, int64(t)).Format("2006-01-02 15:04:05")
func (r *report) formatTimeWithTimezone(t float64, timezone string) string {
loc, err := resolveTimezone(timezone)
if err != nil {
r.runInfo <- pkglog.RunInfo{
Level: slog.LevelWarn,
Message: fmt.Sprintf("failed to resolve timezone '%s', falling back to UTC: %s", timezone, err),
Details: []slog.Attr{slog.Time("time", time.Now().UTC())},
}
}
return time.Unix(int64(t), 0).Format("2006-01-02 15:04:05")
var timeVal time.Time
switch {
case t > nanosecondThreshold:
timeVal = time.Unix(0, int64(t)).In(loc)
default:
timeVal = time.Unix(int64(t), 0).In(loc)
}
return timeVal.Format("2006-01-02 15:04:05")
}
func formatValue(msg senml.Message) string {
@@ -162,7 +201,7 @@ func formatValue(msg senml.Message) string {
}
}
func (r *report) generateCSVReport(_ context.Context, title string, reports []Report) ([]byte, error) {
func (r *report) generateCSVReport(_ context.Context, title string, reports []Report, timezone string) ([]byte, error) {
var buf bytes.Buffer
writer := csv.NewWriter(&buf)
@@ -217,7 +256,7 @@ func (r *report) generateCSVReport(_ context.Context, title string, reports []Re
})
for _, msg := range report.Messages {
timeStr := formatTime(msg.Time)
timeStr := r.formatTimeWithTimezone(msg.Time, timezone)
var valueStr string
if msg.Value != nil {
+8 -1
View File
@@ -83,7 +83,8 @@ type MetricConfig struct {
To string `json:"to,omitempty"` // Mandatory field
Title string `json:"title,omitempty"` // Mandatory field
FileFormat Format `json:"file_format"` // Optional field
FileFormat Format `json:"file_format"` // Optional field
Timezone string `json:"timezone,omitempty"` // Optional field, defaults to UTC
Aggregation AggConfig `json:"aggregation,omitempty"` // Optional field
}
@@ -113,6 +114,12 @@ func (mc MetricConfig) Validate() error {
return err
}
if tz := strings.TrimSpace(mc.Timezone); tz != "" {
if _, err := time.LoadLocation(tz); err != nil {
return errors.Wrap(fmt.Errorf("invalid timezone: %s", tz), err)
}
}
return nil
}
+8 -6
View File
@@ -162,8 +162,10 @@ func (r *report) DisableReportConfig(ctx context.Context, session authn.Session,
func (r *report) GenerateReport(ctx context.Context, session authn.Session, config ReportConfig, action ReportAction) (ReportPage, error) {
config.DomainID = session.DomainID
if config.Status != EnabledStatus {
return ReportPage{}, svcerr.ErrInvalidStatus
if action != ViewReport && action != DownloadReport && action != EmailReport {
if config.Status != EnabledStatus {
return ReportPage{}, svcerr.ErrInvalidStatus
}
}
reportPage, err := r.generateReport(ctx, config, action)
@@ -175,7 +177,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 := r.generateFileFunc(ctx, action, cfg.Config.FileFormat, cfg.ReportTemplate)
genReportFile, err := r.generateFileFunc(ctx, action, cfg.Config.FileFormat, cfg.ReportTemplate, cfg.Config.Timezone)
if err != nil {
return ReportPage{}, err
}
@@ -327,17 +329,17 @@ func (r *report) generateReport(ctx context.Context, cfg ReportConfig, action Re
}
}
func (r *report) generateFileFunc(_ context.Context, action ReportAction, format Format, customTemplate ReportTemplate) (func(context.Context, string, []Report) ([]byte, error), error) {
func (r *report) generateFileFunc(_ context.Context, action ReportAction, format Format, customTemplate ReportTemplate, timezone string) (func(context.Context, string, []Report) ([]byte, error), error) {
switch action {
case DownloadReport, EmailReport:
switch format {
case PDF:
return func(ctx context.Context, title string, reports []Report) ([]byte, error) {
return r.generatePDFReport(ctx, title, reports, customTemplate)
return r.generatePDFReport(ctx, title, reports, customTemplate, timezone)
}, nil
case CSV:
return func(ctx context.Context, title string, reports []Report) ([]byte, error) {
return r.generateCSVReport(ctx, title, reports)
return r.generateCSVReport(ctx, title, reports, timezone)
}, nil
default:
return nil, errors.New("file format not supported")
+101
View File
@@ -467,3 +467,104 @@ func TestDisableReportConfig(t *testing.T) {
})
}
}
func TestGenerateInstantEmailReport(t *testing.T) {
svc, _, _ := newService(make(chan pkglog.RunInfo))
validEmailConfig := reports.EmailSetting{
To: []string{"test@example.com"},
Subject: "Test Report",
Content: "Please find the attached report.",
}
validConfig := reports.ReportConfig{
ID: testsutil.GenerateUUID(&testing.T{}),
Name: "Test Report",
DomainID: domainID,
Status: reports.DisabledStatus,
Email: &validEmailConfig,
Config: &reports.MetricConfig{
Title: "Test Report",
FileFormat: reports.PDF,
From: "now-1h",
To: "now",
Aggregation: reports.AggConfig{
AggType: reports.AggregationAVG,
Interval: "1h",
},
},
Metrics: []reports.ReqMetric{
{
ChannelID: testsutil.GenerateUUID(&testing.T{}),
Name: "temperature",
ClientIDs: []string{testsutil.GenerateUUID(&testing.T{})},
},
},
ReportTemplate: template,
}
cases := []struct {
desc string
session authn.Session
config reports.ReportConfig
action reports.ReportAction
err error
}{
{
desc: "Generate instant email report with disabled config should succeed",
session: authn.Session{
UserID: userID,
DomainID: domainID,
},
config: validConfig,
action: reports.EmailReport,
err: nil,
},
{
desc: "Generate instant email report with enabled config should succeed",
session: authn.Session{
UserID: userID,
DomainID: domainID,
},
config: func() reports.ReportConfig {
cfg := validConfig
cfg.Status = reports.EnabledStatus
return cfg
}(),
action: reports.EmailReport,
err: nil,
},
{
desc: "Generate view report with disabled config should succeed",
session: authn.Session{
UserID: userID,
DomainID: domainID,
},
config: validConfig,
action: reports.ViewReport,
err: nil,
},
{
desc: "Generate download report with disabled config should succeed",
session: authn.Session{
UserID: userID,
DomainID: domainID,
},
config: validConfig,
action: reports.DownloadReport,
err: nil,
},
}
for _, tc := range cases {
t.Run(tc.desc, func(t *testing.T) {
_, err := svc.GenerateReport(context.Background(), tc.session, tc.config, tc.action)
if tc.err != nil {
assert.True(t, errors.Contains(err, tc.err), fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
} else {
assert.False(t, errors.Contains(err, svcerr.ErrInvalidStatus), fmt.Sprintf("%s: should not get ErrInvalidStatus for instant reports, got %s\n", tc.desc, err))
}
})
}
}
+25
View File
@@ -0,0 +1,25 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package reports
import (
"strings"
"time"
)
// resolveTimezone returns a *time.Location from a user-provided IANA timezone name.
// Supported inputs:
// - IANA names (e.g., "Europe/Paris", "America/New_York").
// - Empty string defaults to UTC.
func resolveTimezone(s string) (*time.Location, error) {
s = strings.TrimSpace(s)
if s == "" {
return time.UTC, nil
}
loc, err := time.LoadLocation(s)
if err != nil {
return time.UTC, err
}
return loc, nil
}