feat: comprehensive summary-level filtering (resolve #262)

This commit is contained in:
Ferdinand Mütsch 2021-12-26 17:02:14 +01:00
parent 8a3e6f0179
commit a279548c89
25 changed files with 333 additions and 183 deletions

View File

@ -10,7 +10,7 @@ type DurationServiceMock struct {
mock.Mock
}
func (m *DurationServiceMock) Get(time time.Time, time2 time.Time, user *models.User) (models.Durations, error) {
args := m.Called(time, time2, user)
func (m *DurationServiceMock) Get(time time.Time, time2 time.Time, user *models.User, f *models.Filters) (models.Durations, error) {
args := m.Called(time, time2, user, f)
return args.Get(0).(models.Durations), args.Error(1)
}

View File

@ -1,5 +1,11 @@
package models
// AliasResolver returns the alias of an entity, given its original name. I.e., it returns Alias.Key, given an Alias.Value
type AliasResolver func(t uint8, k string) string
// AliasReverseResolver returns all original names, which have the given alias as mapping target. I.e., it returns a list of Alias.Value, given an Alias.Key
type AliasReverseResolver func(t uint8, k string) []string
type Alias struct {
ID uint `gorm:"primary_key"`
Type uint8 `gorm:"not null; index:idx_alias_type_key"`

View File

@ -3,7 +3,6 @@ package v1
import (
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/utils"
"time"
)
// https://shields.io/endpoint
@ -20,18 +19,11 @@ type BadgeData struct {
Color string `json:"color"`
}
func NewBadgeDataFrom(summary *models.Summary, filters *models.Filters) *BadgeData {
var total time.Duration
if hasFilter, _, _ := filters.One(); hasFilter {
total = summary.TotalTimeByFilters(filters)
} else {
total = summary.TotalTime()
}
func NewBadgeDataFrom(summary *models.Summary) *BadgeData {
return &BadgeData{
SchemaVersion: 1,
Label: defaultLabel,
Message: utils.FmtWakatimeDuration(total),
Message: utils.FmtWakatimeDuration(summary.TotalTime()),
Color: defaultColor,
}
}

View File

@ -3,7 +3,6 @@ package v1
import (
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/utils"
"time"
)
// https://wakatime.com/developers#all_time_since_today
@ -27,14 +26,8 @@ type AllTimeRange struct {
Timezone string `json:"timezone"`
}
func NewAllTimeFrom(summary *models.Summary, filters *models.Filters) *AllTimeViewModel {
var total time.Duration
if key := filters.Project; key != "" {
total = summary.TotalTimeByFilters(filters)
} else {
total = summary.TotalTime()
}
func NewAllTimeFrom(summary *models.Summary) *AllTimeViewModel {
total := summary.TotalTime()
return &AllTimeViewModel{
Data: &AllTimeData{
TotalSeconds: float32(total.Seconds()),

View File

@ -57,8 +57,7 @@ type SummariesRange struct {
Timezone string `json:"timezone"`
}
func NewSummariesFrom(summaries []*models.Summary, filters *models.Filters) *SummariesViewModel {
// TODO: implement filtering (https://github.com/muety/wakapi/issues/58)
func NewSummariesFrom(summaries []*models.Summary) *SummariesViewModel {
data := make([]*SummariesData, len(summaries))
minDate, maxDate := time.Now().Add(1*time.Second), time.Time{}

View File

@ -1,50 +1,158 @@
package models
import (
"fmt"
"github.com/emvi/logbuch"
"github.com/mitchellh/hashstructure/v2"
)
type Filters struct {
Project string
OS string
Language string
Editor string
Machine string
Label string
Project OrFilter
OS OrFilter
Language OrFilter
Editor OrFilter
Machine OrFilter
Label OrFilter
}
type OrFilter []string
func (f OrFilter) Exists() bool {
return len(f) > 0 && f[0] != ""
}
func (f OrFilter) MatchAny(search string) bool {
for _, s := range f {
if s == search {
return true
}
}
return false
}
type FilterElement struct {
Type uint8
Key string
entity uint8
filter OrFilter
}
func NewFiltersWith(entity uint8, key string) *Filters {
switch entity {
case SummaryProject:
return &Filters{Project: key}
case SummaryOS:
return &Filters{OS: key}
case SummaryLanguage:
return &Filters{Language: key}
case SummaryEditor:
return &Filters{Editor: key}
case SummaryMachine:
return &Filters{Machine: key}
case SummaryLabel:
return &Filters{Label: key}
}
return &Filters{}
return NewFilterWithMultiple(entity, []string{key})
}
func (f *Filters) One() (bool, uint8, string) {
if f.Project != "" {
func NewFilterWithMultiple(entity uint8, keys []string) *Filters {
filters := &Filters{}
return filters.WithMultiple(entity, keys)
}
func (f *Filters) With(entity uint8, key string) *Filters {
return f.WithMultiple(entity, []string{key})
}
func (f *Filters) WithMultiple(entity uint8, keys []string) *Filters {
switch entity {
case SummaryProject:
f.Project = append(f.Project, keys...)
case SummaryOS:
f.OS = append(f.OS, keys...)
case SummaryLanguage:
f.Language = append(f.Language, keys...)
case SummaryEditor:
f.Editor = append(f.Editor, keys...)
case SummaryMachine:
f.Machine = append(f.Machine, keys...)
case SummaryLabel:
f.Label = append(f.Label, keys...)
}
return f
}
func (f *Filters) One() (bool, uint8, OrFilter) {
if f.Project != nil && f.Project.Exists() {
return true, SummaryProject, f.Project
} else if f.OS != "" {
} else if f.OS != nil && f.OS.Exists() {
return true, SummaryOS, f.OS
} else if f.Language != "" {
} else if f.Language != nil && f.Language.Exists() {
return true, SummaryLanguage, f.Language
} else if f.Editor != "" {
} else if f.Editor != nil && f.Editor.Exists() {
return true, SummaryEditor, f.Editor
} else if f.Machine != "" {
} else if f.Machine != nil && f.Machine.Exists() {
return true, SummaryMachine, f.Machine
} else if f.Label != "" {
} else if f.Label != nil && f.Label.Exists() {
return true, SummaryLabel, f.Label
}
return false, 0, ""
return false, 0, OrFilter{}
}
func (f *Filters) OneOrEmpty() FilterElement {
if ok, t, of := f.One(); ok {
return FilterElement{entity: t, filter: of}
}
return FilterElement{}
}
func (f *Filters) IsEmpty() bool {
nonEmpty, _, _ := f.One()
return !nonEmpty
}
func (f *Filters) Hash() string {
hash, err := hashstructure.Hash(f, hashstructure.FormatV2, nil)
if err != nil {
logbuch.Error("CRITICAL ERROR: failed to hash struct %v", err)
}
return fmt.Sprintf("%x", hash) // "uint64 values with high bit set are not supported"
}
func (f *Filters) Match(h *Heartbeat) bool {
// TODO: labels?
return (f.Project == nil || f.Project.MatchAny(h.Project)) &&
(f.OS == nil || f.OS.MatchAny(h.OperatingSystem)) &&
(f.Language == nil || f.Language.MatchAny(h.Language)) &&
(f.Editor == nil || f.Editor.MatchAny(h.Editor)) &&
(f.Machine == nil || f.Machine.MatchAny(h.Machine))
}
// WithAliases adds OR-conditions for every alias of a filter key as additional filter keys
func (f *Filters) WithAliases(resolver AliasReverseResolver) *Filters {
if f.Project != nil {
updated := OrFilter(make([]string, 0, len(f.Project)))
for _, e := range f.Project {
updated = append(updated, e)
updated = append(updated, resolver(SummaryProject, e)...)
}
f.Project = updated
}
if f.OS != nil {
updated := OrFilter(make([]string, 0, len(f.OS)))
for _, e := range f.OS {
updated = append(updated, e)
updated = append(updated, resolver(SummaryOS, e)...)
}
f.OS = updated
}
if f.Language != nil {
updated := OrFilter(make([]string, 0, len(f.Language)))
for _, e := range f.Language {
updated = append(updated, e)
updated = append(updated, resolver(SummaryLanguage, e)...)
}
f.Language = updated
}
if f.Editor != nil {
updated := OrFilter(make([]string, 0, len(f.Editor)))
for _, e := range f.Editor {
updated = append(updated, e)
updated = append(updated, resolver(SummaryEditor, e)...)
}
f.Editor = updated
}
if f.Machine != nil {
updated := OrFilter(make([]string, 0, len(f.Machine)))
for _, e := range f.Machine {
updated = append(updated, e)
updated = append(updated, resolver(SummaryMachine, e)...)
}
f.Machine = updated
}
return f
}

View File

@ -57,8 +57,6 @@ type SummaryParams struct {
Recompute bool
}
type AliasResolver func(t uint8, k string) string
func SummaryTypes() []uint8 {
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine, SummaryLabel}
}
@ -204,12 +202,12 @@ func (s *Summary) TotalTimeByKey(entityType uint8, key string) (timeSum time.Dur
return timeSum
}
func (s *Summary) TotalTimeByFilters(filters *Filters) time.Duration {
do, typeId, key := filters.One()
if do {
return s.TotalTimeByKey(typeId, key)
func (s *Summary) TotalTimeByFilter(filter FilterElement) time.Duration {
var total time.Duration
for _, f := range filter.filter {
total += s.TotalTimeByKey(filter.entity, f)
}
return 0
return total
}
func (s *Summary) MaxBy(entityType uint8) *SummaryItem {

View File

@ -98,20 +98,13 @@ func TestSummary_TotalTimeByFilters(t *testing.T) {
},
}
// Specifying filters about multiple entites is not supported at the moment
// as the current, very rudimentary, time calculation logic wouldn't make sense then.
// Evaluating a filter like (project="wakapi", language="go") can only be realized
// before computing the summary in the first place, because afterwards we can't know
// what time coded in "Go" was in the "Wakapi" project
// See https://github.com/muety/wakapi/issues/108
filters1 := NewFiltersWith(SummaryProject, "wakapi").OneOrEmpty()
filters2 := NewFiltersWith(SummaryLanguage, "Go").OneOrEmpty()
filters3 := FilterElement{}
filters1 := &Filters{Project: "wakapi"}
filters2 := &Filters{Language: "Go"}
filters3 := &Filters{}
assert.Equal(t, testDuration1, sut.TotalTimeByFilters(filters1))
assert.Equal(t, testDuration3, sut.TotalTimeByFilters(filters2))
assert.Zero(t, sut.TotalTimeByFilters(filters3))
assert.Equal(t, testDuration1, sut.TotalTimeByFilter(filters1))
assert.Equal(t, testDuration3, sut.TotalTimeByFilter(filters2))
assert.Zero(t, sut.TotalTimeByFilter(filters3))
}
func TestSummary_WithResolvedAliases(t *testing.T) {

View File

@ -116,7 +116,7 @@ func (h *MetricsHandler) Get(w http.ResponseWriter, r *http.Request) {
func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error) {
var metrics mm.Metrics
summaryAllTime, err := h.summarySrvc.Aliased(time.Time{}, time.Now(), user, h.summarySrvc.Retrieve, false)
summaryAllTime, err := h.summarySrvc.Aliased(time.Time{}, time.Now(), user, h.summarySrvc.Retrieve, nil, false)
if err != nil {
logbuch.Error("failed to retrieve all time summary for user '%s' for metric", user.ID)
return nil, err
@ -124,7 +124,7 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
from, to := utils.MustResolveIntervalRawTZ("today", user.TZ())
summaryToday, err := h.summarySrvc.Aliased(from, to, user, h.summarySrvc.Retrieve, false)
summaryToday, err := h.summarySrvc.Aliased(from, to, user, h.summarySrvc.Retrieve, nil, false)
if err != nil {
logbuch.Error("failed to retrieve today's summary for user '%s' for metric", user.ID)
return nil, err
@ -141,7 +141,7 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_cumulative_seconds_total",
Desc: DescAllTime,
Value: int(v1.NewAllTimeFrom(summaryAllTime, &models.Filters{}).Data.TotalSeconds),
Value: int(v1.NewAllTimeFrom(summaryAllTime).Data.TotalSeconds),
Labels: []mm.Label{},
})

View File

@ -122,19 +122,19 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
return
}
summary, err, status := h.loadUserSummary(user, interval)
summary, err, status := h.loadUserSummary(user, interval, filters)
if err != nil {
w.WriteHeader(status)
w.Write([]byte(err.Error()))
return
}
vm := v1.NewBadgeDataFrom(summary, filters)
vm := v1.NewBadgeDataFrom(summary)
h.cache.SetDefault(cacheKey, vm)
utils.RespondJSON(w, r, http.StatusOK, vm)
}
func (h *BadgeHandler) loadUserSummary(user *models.User, interval *models.IntervalKey) (*models.Summary, error, int) {
func (h *BadgeHandler) loadUserSummary(user *models.User, interval *models.IntervalKey, filters *models.Filters) (*models.Summary, error, int) {
err, from, to := utils.ResolveIntervalTZ(interval, user.TZ())
if err != nil {
return nil, err, http.StatusBadRequest
@ -151,7 +151,14 @@ func (h *BadgeHandler) loadUserSummary(user *models.User, interval *models.Inter
retrieveSummary = h.summarySrvc.Summarize
}
summary, err := h.summarySrvc.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary, summaryParams.Recompute)
summary, err := h.summarySrvc.Aliased(
summaryParams.From,
summaryParams.To,
summaryParams.User,
retrieveSummary,
filters,
summaryParams.Recompute,
)
if err != nil {
return nil, err, http.StatusInternalServerError
}

View File

@ -1,10 +1,6 @@
package v1
import (
"net/http"
"net/url"
"time"
"github.com/gorilla/mux"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
@ -13,6 +9,8 @@ import (
routeutils "github.com/muety/wakapi/routes/utils"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
"time"
)
type AllTimeHandler struct {
@ -47,25 +45,23 @@ func (h *AllTimeHandler) RegisterRoutes(router *mux.Router) {
// @Success 200 {object} v1.AllTimeViewModel
// @Router /compat/wakatime/v1/users/{user}/all_time_since_today [get]
func (h *AllTimeHandler) Get(w http.ResponseWriter, r *http.Request) {
values, _ := url.ParseQuery(r.URL.RawQuery)
user, err := routeutils.CheckEffectiveUser(w, r, h.userSrvc, "current")
if err != nil {
return // response was already sent by util function
}
summary, err, status := h.loadUserSummary(user)
summary, err, status := h.loadUserSummary(user, routeutils.ParseFilters(r))
if err != nil {
w.WriteHeader(status)
w.Write([]byte(err.Error()))
return
}
vm := v1.NewAllTimeFrom(summary, models.NewFiltersWith(models.SummaryProject, values.Get("project")))
vm := v1.NewAllTimeFrom(summary)
utils.RespondJSON(w, r, http.StatusOK, vm)
}
func (h *AllTimeHandler) loadUserSummary(user *models.User) (*models.Summary, error, int) {
func (h *AllTimeHandler) loadUserSummary(user *models.User, filters *models.Filters) (*models.Summary, error, int) {
summaryParams := &models.SummaryParams{
From: time.Time{},
To: time.Now(),
@ -78,7 +74,14 @@ func (h *AllTimeHandler) loadUserSummary(user *models.User) (*models.Summary, er
retrieveSummary = h.summarySrvc.Summarize
}
summary, err := h.summarySrvc.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary, summaryParams.Recompute)
summary, err := h.summarySrvc.Aliased(
summaryParams.From,
summaryParams.To,
summaryParams.User,
retrieveSummary,
filters,
summaryParams.Recompute,
)
if err != nil {
return nil, err, http.StatusInternalServerError
}

View File

@ -9,6 +9,7 @@ import (
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
routeutils "github.com/muety/wakapi/routes/utils"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
)
@ -88,7 +89,7 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
return
}
summary, err, status := h.loadUserSummary(requestedUser, rangeFrom, rangeTo)
summary, err, status := h.loadUserSummary(requestedUser, rangeFrom, rangeTo, routeutils.ParseFilters(r))
if err != nil {
w.WriteHeader(status)
w.Write([]byte(err.Error()))
@ -117,7 +118,7 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
utils.RespondJSON(w, r, http.StatusOK, stats)
}
func (h *StatsHandler) loadUserSummary(user *models.User, start, end time.Time) (*models.Summary, error, int) {
func (h *StatsHandler) loadUserSummary(user *models.User, start, end time.Time, filters *models.Filters) (*models.Summary, error, int) {
overallParams := &models.SummaryParams{
From: start,
To: end,
@ -125,7 +126,7 @@ func (h *StatsHandler) loadUserSummary(user *models.User, start, end time.Time)
Recompute: false,
}
summary, err := h.summarySrvc.Aliased(overallParams.From, overallParams.To, user, h.summarySrvc.Retrieve, false)
summary, err := h.summarySrvc.Aliased(overallParams.From, overallParams.To, user, h.summarySrvc.Retrieve, filters, false)
if err != nil {
return nil, err, http.StatusInternalServerError
}

View File

@ -78,7 +78,7 @@ func (h *StatusBarHandler) Get(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(err.Error()))
return
}
summariesView := v1.NewSummariesFrom([]*models.Summary{summary}, &models.Filters{})
summariesView := v1.NewSummariesFrom([]*models.Summary{summary})
utils.RespondJSON(w, r, http.StatusOK, StatusBarViewModel{
CachedAt: time.Now(),
Data: *summariesView.Data[0],
@ -98,7 +98,7 @@ func (h *StatusBarHandler) loadUserSummary(user *models.User, start, end time.Ti
retrieveSummary = h.summarySrvc.Summarize
}
summary, err := h.summarySrvc.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary, summaryParams.Recompute)
summary, err := h.summarySrvc.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary, nil, summaryParams.Recompute)
if err != nil {
return nil, http.StatusInternalServerError, err
}

View File

@ -68,12 +68,7 @@ func (h *SummariesHandler) Get(w http.ResponseWriter, r *http.Request) {
return
}
filters := &models.Filters{}
if projectQuery := r.URL.Query().Get("project"); projectQuery != "" {
filters.Project = projectQuery
}
vm := v1.NewSummariesFrom(summaries, filters)
vm := v1.NewSummariesFrom(summaries)
utils.RespondJSON(w, r, http.StatusOK, vm)
}
@ -130,8 +125,11 @@ func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary
intervals := utils.SplitRangeByDays(overallParams.From, overallParams.To)
summaries := make([]*models.Summary, len(intervals))
// filtering
filters := routeutils.ParseFilters(r)
for i, interval := range intervals {
summary, err := h.summarySrvc.Aliased(interval[0], interval[1], user, h.summarySrvc.Retrieve, end.After(time.Now()))
summary, err := h.summarySrvc.Aliased(interval[0], interval[1], user, h.summarySrvc.Retrieve, filters, end.After(time.Now()))
if err != nil {
return nil, err, http.StatusInternalServerError
}

View File

@ -20,7 +20,7 @@ func LoadUserSummary(ss services.ISummaryService, r *http.Request) (*models.Summ
retrieveSummary = ss.Summarize
}
summary, err := ss.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary, summaryParams.Recompute)
summary, err := ss.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary, ParseFilters(r), summaryParams.Recompute)
if err != nil {
return nil, err, http.StatusInternalServerError
}
@ -30,3 +30,26 @@ func LoadUserSummary(ss services.ISummaryService, r *http.Request) (*models.Summ
return summary, nil, http.StatusOK
}
func ParseFilters(r *http.Request) *models.Filters {
filters := &models.Filters{}
if q := r.URL.Query().Get("project"); q != "" {
filters.With(models.SummaryProject, q)
}
if q := r.URL.Query().Get("language"); q != "" {
filters.With(models.SummaryLanguage, q)
}
if q := r.URL.Query().Get("editor"); q != "" {
filters.With(models.SummaryEditor, q)
}
if q := r.URL.Query().Get("machine"); q != "" {
filters.With(models.SummaryMachine, q)
}
if q := r.URL.Query().Get("operating_system"); q != "" {
filters.With(models.SummaryOS, q)
}
if q := r.URL.Query().Get("label"); q != "" {
filters.With(models.SummaryLabel, q)
}
return filters
}

View File

@ -83,7 +83,7 @@ func (srv *AggregationService) Run(userIds map[string]bool) error {
func (srv *AggregationService) summaryWorker(jobs <-chan *AggregationJob, summaries chan<- *models.Summary) {
for job := range jobs {
if summary, err := srv.summaryService.Summarize(job.From, job.To, &models.User{ID: job.UserID}); err != nil {
if summary, err := srv.summaryService.Summarize(job.From, job.To, &models.User{ID: job.UserID}, nil); err != nil {
config.Log().Error("failed to generate summary (%v, %v, %s) %v", job.From, job.To, job.UserID, err)
} else {
logbuch.Info("successfully generated summary (%v, %v, %s)", job.From, job.To, job.UserID)

View File

@ -2,6 +2,7 @@ package services
import (
"errors"
"fmt"
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
@ -38,35 +39,53 @@ func (srv *AliasService) InitializeUser(userId string) error {
return err
}
func (srv *AliasService) GetByUser(userId string) ([]*models.Alias, error) {
aliases, err := srv.repository.GetByUser(userId)
if err != nil {
return nil, err
func (srv *AliasService) MayInitializeUser(userId string) {
if err := srv.InitializeUser(userId); err != nil {
logbuch.Error("failed to initialize user alias map for user %s", userId)
}
}
func (srv *AliasService) GetByUser(userId string) ([]*models.Alias, error) {
if !srv.IsInitialized(userId) {
srv.MayInitializeUser(userId)
}
if aliases, ok := userAliases.Load(userId); ok {
return aliases.([]*models.Alias), nil
} else {
return nil, errors.New(fmt.Sprintf("no user aliases loaded for user %s", userId))
}
return aliases, nil
}
func (srv *AliasService) GetByUserAndKeyAndType(userId, key string, summaryType uint8) ([]*models.Alias, error) {
aliases, err := srv.repository.GetByUserAndKeyAndType(userId, key, summaryType)
if err != nil {
return nil, err
if !srv.IsInitialized(userId) {
srv.MayInitializeUser(userId)
}
if aliases, ok := userAliases.Load(userId); ok {
filteredAliases := make([]*models.Alias, 0, len(aliases.([]*models.Alias)))
for _, a := range aliases.([]*models.Alias) {
if a.Key == key && a.Type == summaryType {
filteredAliases = append(filteredAliases, a)
}
}
return filteredAliases, nil
} else {
return nil, errors.New(fmt.Sprintf("no user aliases loaded for user %s", userId))
}
return aliases, nil
}
func (srv *AliasService) GetAliasOrDefault(userId string, summaryType uint8, value string) (string, error) {
if !srv.IsInitialized(userId) {
if err := srv.InitializeUser(userId); err != nil {
return "", err
srv.MayInitializeUser(userId)
}
if aliases, ok := userAliases.Load(userId); ok {
for _, a := range aliases.([]*models.Alias) {
if a.Type == summaryType && a.Value == value {
return a.Key, nil
}
}
}
aliases, _ := userAliases.Load(userId)
for _, a := range aliases.([]*models.Alias) {
if a.Type == summaryType && a.Value == value {
return a.Key, nil
}
}
return value, nil
}
@ -75,7 +94,7 @@ func (srv *AliasService) Create(alias *models.Alias) (*models.Alias, error) {
if err != nil {
return nil, err
}
go srv.reinitUser(alias.UserID)
go srv.MayInitializeUser(alias.UserID)
return result, nil
}
@ -84,7 +103,7 @@ func (srv *AliasService) Delete(alias *models.Alias) error {
return errors.New("no user id specified")
}
err := srv.repository.Delete(alias.ID)
go srv.reinitUser(alias.UserID)
go srv.MayInitializeUser(alias.UserID)
return err
}
@ -102,14 +121,8 @@ func (srv *AliasService) DeleteMulti(aliases []*models.Alias) error {
err := srv.repository.DeleteBatch(ids)
for k := range affectedUsers {
go srv.reinitUser(k)
go srv.MayInitializeUser(k)
}
return err
}
func (srv *AliasService) reinitUser(userId string) {
if err := srv.InitializeUser(userId); err != nil {
logbuch.Error("error initializing user aliases %v", err)
}
}

View File

@ -52,12 +52,3 @@ func (suite *AliasServiceTestSuite) TestAliasService_GetAliasOrDefault() {
assert.Equal(suite.T(), "anchr", result3)
assert.Nil(suite.T(), err3)
}
func (suite *AliasServiceTestSuite) TestAliasService_GetAliasOrDefault_ErrorOnNonExistingUser() {
sut := NewAliasService(suite.AliasRepository)
result, err := sut.GetAliasOrDefault("nonexisting", models.SummaryProject, "wakapi-mobile")
assert.Empty(suite.T(), result)
assert.Error(suite.T(), err)
}

View File

@ -21,7 +21,7 @@ func NewDurationService(heartbeatService IHeartbeatService) *DurationService {
return srv
}
func (srv *DurationService) Get(from, to time.Time, user *models.User) (models.Durations, error) {
func (srv *DurationService) Get(from, to time.Time, user *models.User, filters *models.Filters) (models.Durations, error) {
heartbeats, err := srv.heartbeatService.GetAllWithin(from, to, user)
if err != nil {
return nil, err
@ -34,6 +34,10 @@ func (srv *DurationService) Get(from, to time.Time, user *models.User) (models.D
mapping := make(map[string][]*models.Duration)
for _, h := range heartbeats {
if filters != nil && !filters.Match(h) {
continue
}
d1 := models.NewDurationFromHeartbeat(h)
if list, ok := mapping[d1.GroupHash]; !ok || len(list) < 1 {

View File

@ -126,7 +126,7 @@ func (suite *DurationServiceTestSuite) TestDurationService_Get() {
from, to = suite.TestStartTime.Add(-1*time.Hour), suite.TestStartTime.Add(-1*time.Minute)
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filterHeartbeats(from, to, suite.TestHeartbeats), nil)
durations, err = sut.Get(from, to, suite.TestUser)
durations, err = sut.Get(from, to, suite.TestUser, nil)
assert.Nil(suite.T(), err)
assert.Empty(suite.T(), durations)
@ -135,7 +135,7 @@ func (suite *DurationServiceTestSuite) TestDurationService_Get() {
from, to = suite.TestStartTime.Add(-1*time.Hour), suite.TestStartTime.Add(1*time.Second)
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filterHeartbeats(from, to, suite.TestHeartbeats), nil)
durations, err = sut.Get(from, to, suite.TestUser)
durations, err = sut.Get(from, to, suite.TestUser, nil)
assert.Nil(suite.T(), err)
assert.Len(suite.T(), durations, 1)
@ -146,7 +146,7 @@ func (suite *DurationServiceTestSuite) TestDurationService_Get() {
from, to = suite.TestStartTime, suite.TestStartTime.Add(1*time.Hour)
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filterHeartbeats(from, to, suite.TestHeartbeats), nil)
durations, err = sut.Get(from, to, suite.TestUser)
durations, err = sut.Get(from, to, suite.TestUser, nil)
assert.Nil(suite.T(), err)
assert.Len(suite.T(), durations, 3)

View File

@ -97,7 +97,7 @@ func (srv *MiscService) runCountTotalTime() error {
func (srv *MiscService) countTotalTimeWorker(jobs <-chan *CountTotalTimeJob, results chan<- *CountTotalTimeResult) {
for job := range jobs {
if result, err := srv.summaryService.Aliased(time.Time{}, time.Now(), &models.User{ID: job.UserID}, srv.summaryService.Retrieve, false); err != nil {
if result, err := srv.summaryService.Aliased(time.Time{}, time.Now(), &models.User{ID: job.UserID}, srv.summaryService.Retrieve, nil, false); err != nil {
config.Log().Error("failed to count total for user %s: %v", job.UserID, err)
} else {
results <- &CountTotalTimeResult{

View File

@ -112,7 +112,7 @@ func (srv *ReportService) Run(user *models.User, duration time.Duration) error {
end := time.Now().In(user.TZ())
start := time.Now().Add(-1 * duration)
summary, err := srv.summaryService.Aliased(start, end, user, srv.summaryService.Retrieve, false)
summary, err := srv.summaryService.Aliased(start, end, user, srv.summaryService.Retrieve, nil, false)
if err != nil {
config.Log().Error("failed to generate report for '%s' %v", user.ID, err)
return err

View File

@ -75,13 +75,13 @@ type IMailService interface {
}
type IDurationService interface {
Get(time.Time, time.Time, *models.User) (models.Durations, error)
Get(time.Time, time.Time, *models.User, *models.Filters) (models.Durations, error)
}
type ISummaryService interface {
Aliased(time.Time, time.Time, *models.User, SummaryRetriever, bool) (*models.Summary, error)
Retrieve(time.Time, time.Time, *models.User) (*models.Summary, error)
Summarize(time.Time, time.Time, *models.User) (*models.Summary, error)
Aliased(time.Time, time.Time, *models.User, SummaryRetriever, *models.Filters, bool) (*models.Summary, error)
Retrieve(time.Time, time.Time, *models.User, *models.Filters) (*models.Summary, error)
Summarize(time.Time, time.Time, *models.User, *models.Filters) (*models.Summary, error)
GetLatestByUser() ([]*models.TimeByUser, error)
DeleteByUser(string) error
Insert(*models.Summary) error

View File

@ -24,7 +24,7 @@ type SummaryService struct {
projectLabelService IProjectLabelService
}
type SummaryRetriever func(f, t time.Time, u *models.User) (*models.Summary, error)
type SummaryRetriever func(f, t time.Time, u *models.User, filters *models.Filters) (*models.Summary, error)
func NewSummaryService(summaryRepo repositories.ISummaryRepository, durationService IDurationService, aliasService IAliasService, projectLabelService IProjectLabelService) *SummaryService {
srv := &SummaryService{
@ -55,10 +55,10 @@ func NewSummaryService(summaryRepo repositories.ISummaryRepository, durationServ
// Public summary generation methods
// Aliased retrieves or computes a new summary based on the given SummaryRetriever and augments it with entity aliases and project labels
func (srv *SummaryService) Aliased(from, to time.Time, user *models.User, f SummaryRetriever, skipCache bool) (*models.Summary, error) {
func (srv *SummaryService) Aliased(from, to time.Time, user *models.User, f SummaryRetriever, filters *models.Filters, skipCache bool) (*models.Summary, error) {
// Check cache
cacheKey := srv.getHash(from.String(), to.String(), user.ID, "--aliased")
if cacheResult, ok := srv.cache.Get(cacheKey); ok && !skipCache && false {
cacheKey := srv.getHash(from.String(), to.String(), user.ID, filters.Hash(), "--aliased")
if cacheResult, ok := srv.cache.Get(cacheKey); ok && !skipCache {
return cacheResult.(*models.Summary), nil
}
@ -67,6 +67,19 @@ func (srv *SummaryService) Aliased(from, to time.Time, user *models.User, f Summ
s, _ := srv.aliasService.GetAliasOrDefault(user.ID, t, k)
return s
}
resolveReverse := func(t uint8, k string) []string {
aliases, _ := srv.aliasService.GetByUserAndKeyAndType(user.ID, k, t)
aliasStrings := make([]string, 0, len(aliases))
for _, a := range aliases {
aliasStrings = append(aliasStrings, a.Value)
}
return aliasStrings
}
// Post-process filters
if filters != nil {
filters = filters.WithAliases(resolveReverse)
}
// Initialize alias resolver service
if err := srv.aliasService.InitializeUser(user.ID); err != nil {
@ -74,7 +87,7 @@ func (srv *SummaryService) Aliased(from, to time.Time, user *models.User, f Summ
}
// Get actual summary
s, err := f(from, to, user)
s, err := f(from, to, user, filters)
if err != nil {
return nil, err
}
@ -89,17 +102,24 @@ func (srv *SummaryService) Aliased(from, to time.Time, user *models.User, f Summ
return summary.Sorted(), nil
}
func (srv *SummaryService) Retrieve(from, to time.Time, user *models.User) (*models.Summary, error) {
// Get all already existing, pre-generated summaries that fall into the requested interval
summaries, err := srv.repository.GetByUserWithin(user, from, to)
if err != nil {
return nil, err
func (srv *SummaryService) Retrieve(from, to time.Time, user *models.User, filters *models.Filters) (*models.Summary, error) {
summaries := make([]*models.Summary, 0)
// Filtered summaries are not persisted currently
if filters == nil || filters.IsEmpty() {
// Get all already existing, pre-generated summaries that fall into the requested interval
result, err := srv.repository.GetByUserWithin(user, from, to)
if err == nil {
summaries = result
} else {
return nil, err
}
}
// Generate missing slots (especially before and after existing summaries) from durations (formerly raw heartbeats)
missingIntervals := srv.getMissingIntervals(from, to, summaries)
for _, interval := range missingIntervals {
if s, err := srv.Summarize(interval.Start, interval.End, user); err == nil {
if s, err := srv.Summarize(interval.Start, interval.End, user, filters); err == nil {
summaries = append(summaries, s)
} else {
return nil, err
@ -115,9 +135,9 @@ func (srv *SummaryService) Retrieve(from, to time.Time, user *models.User) (*mod
return summary.Sorted(), nil
}
func (srv *SummaryService) Summarize(from, to time.Time, user *models.User) (*models.Summary, error) {
func (srv *SummaryService) Summarize(from, to time.Time, user *models.User, filters *models.Filters) (*models.Summary, error) {
// Initialize and fetch data
durations, err := srv.durationService.Get(from, to, user)
durations, err := srv.durationService.Get(from, to, user, filters)
if err != nil {
return nil, err
}

View File

@ -108,9 +108,9 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
/* TEST 1 */
from, to = suite.TestStartTime.Add(-1*time.Hour), suite.TestStartTime.Add(-1*time.Minute)
suite.DurationService.On("Get", from, to, suite.TestUser).Return(filterDurations(from, to, suite.TestDurations), nil)
suite.DurationService.On("Get", from, to, suite.TestUser, mock.Anything).Return(filterDurations(from, to, suite.TestDurations), nil)
result, err = sut.Summarize(from, to, suite.TestUser)
result, err = sut.Summarize(from, to, suite.TestUser, nil)
assert.Nil(suite.T(), err)
assert.NotNil(suite.T(), result)
@ -122,9 +122,9 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
/* TEST 2 */
from, to = suite.TestStartTime.Add(-1*time.Hour), suite.TestStartTime.Add(1*time.Second)
suite.DurationService.On("Get", from, to, suite.TestUser).Return(filterDurations(from, to, suite.TestDurations), nil)
suite.DurationService.On("Get", from, to, suite.TestUser, mock.Anything).Return(filterDurations(from, to, suite.TestDurations), nil)
result, err = sut.Summarize(from, to, suite.TestUser)
result, err = sut.Summarize(from, to, suite.TestUser, nil)
assert.Nil(suite.T(), err)
assert.NotNil(suite.T(), result)
@ -136,9 +136,9 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
/* TEST 3 */
from, to = suite.TestStartTime, suite.TestStartTime.Add(1*time.Hour)
suite.DurationService.On("Get", from, to, suite.TestUser).Return(filterDurations(from, to, suite.TestDurations), nil)
suite.DurationService.On("Get", from, to, suite.TestUser, mock.Anything).Return(filterDurations(from, to, suite.TestDurations), nil)
result, err = sut.Summarize(from, to, suite.TestUser)
result, err = sut.Summarize(from, to, suite.TestUser, nil)
assert.Nil(suite.T(), err)
assert.NotNil(suite.T(), result)
@ -187,10 +187,10 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
}
suite.SummaryRepository.On("GetByUserWithin", suite.TestUser, from, to).Return(summaries, nil)
suite.DurationService.On("Get", from, summaries[0].FromTime.T(), suite.TestUser).Return(models.Durations{}, nil)
suite.DurationService.On("Get", summaries[0].ToTime.T(), to, suite.TestUser).Return(models.Durations{}, nil)
suite.DurationService.On("Get", from, summaries[0].FromTime.T(), suite.TestUser, mock.Anything).Return(models.Durations{}, nil)
suite.DurationService.On("Get", summaries[0].ToTime.T(), to, suite.TestUser, mock.Anything).Return(models.Durations{}, nil)
result, err = sut.Retrieve(from, to, suite.TestUser)
result, err = sut.Retrieve(from, to, suite.TestUser, nil)
assert.Nil(suite.T(), err)
assert.NotNil(suite.T(), result)
@ -241,9 +241,9 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
}
suite.SummaryRepository.On("GetByUserWithin", suite.TestUser, from, to).Return(summaries, nil)
suite.DurationService.On("Get", from, summaries[0].FromTime.T(), suite.TestUser).Return(filterDurations(from, summaries[0].FromTime.T(), suite.TestDurations), nil)
suite.DurationService.On("Get", from, summaries[0].FromTime.T(), suite.TestUser, mock.Anything).Return(filterDurations(from, summaries[0].FromTime.T(), suite.TestDurations), nil)
result, err = sut.Retrieve(from, to, suite.TestUser)
result, err = sut.Retrieve(from, to, suite.TestUser, nil)
assert.Nil(suite.T(), err)
assert.NotNil(suite.T(), result)
@ -297,9 +297,9 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
}
suite.SummaryRepository.On("GetByUserWithin", suite.TestUser, from, to).Return(summaries, nil)
suite.DurationService.On("Get", summaries[0].ToTime.T(), summaries[1].FromTime.T(), suite.TestUser).Return(filterDurations(summaries[0].ToTime.T(), summaries[1].FromTime.T(), suite.TestDurations), nil)
suite.DurationService.On("Get", summaries[0].ToTime.T(), summaries[1].FromTime.T(), suite.TestUser, mock.Anything).Return(filterDurations(summaries[0].ToTime.T(), summaries[1].FromTime.T(), suite.TestDurations), nil)
result, err = sut.Retrieve(from, to, suite.TestUser)
result, err = sut.Retrieve(from, to, suite.TestUser, nil)
assert.Nil(suite.T(), err)
assert.NotNil(suite.T(), result)
@ -347,10 +347,10 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve_DuplicateSumma
summaries = append(summaries, &(*summaries[0])) // add same summary again -> mustn't be counted twice!
suite.SummaryRepository.On("GetByUserWithin", suite.TestUser, from, to).Return(summaries, nil)
suite.DurationService.On("Get", from, summaries[0].FromTime.T(), suite.TestUser).Return(models.Durations{}, nil)
suite.DurationService.On("Get", summaries[0].ToTime.T(), to, suite.TestUser).Return(models.Durations{}, nil)
suite.DurationService.On("Get", from, summaries[0].FromTime.T(), suite.TestUser, mock.Anything).Return(models.Durations{}, nil)
suite.DurationService.On("Get", summaries[0].ToTime.T(), to, suite.TestUser, mock.Anything).Return(models.Durations{}, nil)
result, err = sut.Retrieve(from, to, suite.TestUser)
result, err = sut.Retrieve(from, to, suite.TestUser, nil)
assert.Nil(suite.T(), err)
assert.NotNil(suite.T(), result)
@ -362,6 +362,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve_DuplicateSumma
func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased() {
sut := NewSummaryService(suite.SummaryRepository, suite.DurationService, suite.AliasService, suite.ProjectLabelService)
suite.AliasService.On("InitializeUser", suite.TestUser.ID).Return(nil)
suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return([]*models.ProjectLabel{}, nil)
var (
@ -385,14 +386,14 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased() {
Duration: 0, // not relevant here
})
suite.DurationService.On("Get", from, to, suite.TestUser).Return(models.Durations(durations), nil)
suite.DurationService.On("Get", from, to, suite.TestUser, mock.Anything).Return(models.Durations(durations), nil)
suite.AliasService.On("InitializeUser", TestUserId).Return(nil)
suite.AliasService.On("GetAliasOrDefault", TestUserId, mock.Anything, TestProject1).Return(TestProject2, nil)
suite.AliasService.On("GetAliasOrDefault", TestUserId, mock.Anything, TestProject2).Return(TestProject2, nil)
suite.AliasService.On("GetAliasOrDefault", TestUserId, mock.Anything, mock.Anything).Return("", nil)
suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return(suite.TestLabels, nil).Once()
result, err = sut.Aliased(from, to, suite.TestUser, sut.Summarize, false)
result, err = sut.Aliased(from, to, suite.TestUser, sut.Summarize, nil, false)
assert.Nil(suite.T(), err)
assert.NotNil(suite.T(), result)
@ -426,13 +427,13 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased_ProjectLabels()
})
suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return(suite.TestLabels, nil).Once()
suite.DurationService.On("Get", from, to, suite.TestUser).Return(models.Durations(durations), nil)
suite.DurationService.On("Get", from, to, suite.TestUser, mock.Anything).Return(models.Durations(durations), nil)
suite.AliasService.On("InitializeUser", TestUserId).Return(nil)
suite.AliasService.On("GetAliasOrDefault", TestUserId, mock.Anything, TestProject1).Return(TestProject1, nil)
suite.AliasService.On("GetAliasOrDefault", TestUserId, mock.Anything, TestProject2).Return(TestProject1, nil)
suite.AliasService.On("GetAliasOrDefault", TestUserId, mock.Anything, mock.Anything).Return("", nil)
result, err = sut.Aliased(from, to, suite.TestUser, sut.Summarize, false)
result, err = sut.Aliased(from, to, suite.TestUser, sut.Summarize, nil, false)
assert.Nil(suite.T(), err)
assert.NotNil(suite.T(), result)