From 5014e39ea9780dc4c367f50d732c1398a98ac62b Mon Sep 17 00:00:00 2001 From: Julian Brost Date: Tue, 28 May 2024 11:18:07 +0200 Subject: [PATCH] Define schedules using rotations This is a fundamental and incompatible change to how schedules are defined. Now, a schedule consists of a list of rotations that's ordered by priority. Each rotation contains multiple members where each is either a contact or a contact group. Each member is linked to some timeperiod entries which defines when this member is active in the rotation. This commit already includes code for a feature that was planned but is possible using the web interface at the moment: multiple versions of the same rotation where the handoff time defines when a given version becomes active. With this change, for the time being, the TimePeriod type itself fulfills no real purpose and the timeperiod entries are directly loaded as part of the schedule, bypassing the timeperiod loading code. However, there still is the plan to add standalone timeperiods in the future, thus the timeperiod code is kept. More context for these changes: - https://github.com/Icinga/icinga-notifications-web/issues/177 - https://github.com/Icinga/icinga-notifications/pull/193 --- internal/config/schedule.go | 138 +++++++++++++------- internal/config/timeperiod.go | 4 - internal/config/verify.go | 44 ++++--- internal/recipient/rotations.go | 115 +++++++++++++++++ internal/recipient/rotations_test.go | 169 +++++++++++++++++++++++++ internal/recipient/schedule.go | 79 +++++++----- internal/timeperiod/timeperiod.go | 3 +- internal/timeperiod/timeperiod_test.go | 2 +- 8 files changed, 451 insertions(+), 103 deletions(-) create mode 100644 internal/recipient/rotations.go create mode 100644 internal/recipient/rotations_test.go diff --git a/internal/config/schedule.go b/internal/config/schedule.go index b43af6475..614bc428a 100644 --- a/internal/config/schedule.go +++ b/internal/config/schedule.go @@ -3,6 +3,7 @@ package config import ( "context" "github.com/icinga/icinga-notifications/internal/recipient" + "github.com/icinga/icinga-notifications/internal/timeperiod" "github.com/jmoiron/sqlx" "go.uber.org/zap" ) @@ -27,28 +28,93 @@ func (r *RuntimeConfig) fetchSchedules(ctx context.Context, tx *sqlx.Tx) error { zap.String("name", g.Name)) } - var memberPtr *recipient.ScheduleMemberRow - stmt = r.db.BuildSelectStmt(memberPtr, memberPtr) + var rotationPtr *recipient.Rotation + stmt = r.db.BuildSelectStmt(rotationPtr, rotationPtr) r.logger.Debugf("Executing query %q", stmt) - var members []*recipient.ScheduleMemberRow + var rotations []*recipient.Rotation + if err := tx.SelectContext(ctx, &rotations, stmt); err != nil { + r.logger.Errorln(err) + return err + } + + rotationsById := make(map[int64]*recipient.Rotation) + for _, rotation := range rotations { + rotationLogger := r.logger.With(zap.Object("rotation", rotation)) + + if schedule := schedulesById[rotation.ScheduleID]; schedule == nil { + rotationLogger.Warnw("ignoring schedule rotation for unknown schedule_id") + } else { + rotationsById[rotation.ID] = rotation + schedule.Rotations = append(schedule.Rotations, rotation) + + rotationLogger.Debugw("loaded schedule rotation") + } + } + + var rotationMemberPtr *recipient.RotationMember + stmt = r.db.BuildSelectStmt(rotationMemberPtr, rotationMemberPtr) + r.logger.Debugf("Executing query %q", stmt) + + var members []*recipient.RotationMember if err := tx.SelectContext(ctx, &members, stmt); err != nil { r.logger.Errorln(err) return err } + rotationMembersById := make(map[int64]*recipient.RotationMember) for _, member := range members { - memberLogger := makeScheduleMemberLogger(r.logger.SugaredLogger, member) + memberLogger := r.logger.With(zap.Object("rotation_member", member)) - if s := schedulesById[member.ScheduleID]; s == nil { - memberLogger.Warnw("ignoring schedule member for unknown schedule_id") + if rotation := rotationsById[member.RotationID]; rotation == nil { + memberLogger.Warnw("ignoring rotation member for unknown rotation_member_id") } else { - s.MemberRows = append(s.MemberRows, member) + member.TimePeriodEntries = make(map[int64]*timeperiod.Entry) + rotation.Members = append(rotation.Members, member) + rotationMembersById[member.ID] = member - memberLogger.Debugw("member") + memberLogger.Debugw("loaded schedule rotation member") } } + var entryPtr *timeperiod.Entry + stmt = r.db.BuildSelectStmt(entryPtr, entryPtr) + " WHERE rotation_member_id IS NOT NULL" + r.logger.Debugf("Executing query %q", stmt) + + var entries []*timeperiod.Entry + if err := tx.SelectContext(ctx, &entries, stmt); err != nil { + r.logger.Errorln(err) + return err + } + + for _, entry := range entries { + var member *recipient.RotationMember + if entry.RotationMemberID.Valid { + member = rotationMembersById[entry.RotationMemberID.Int64] + } + + if member == nil { + r.logger.Warnw("ignoring entry for unknown rotation_member_id", + zap.Int64("timeperiod_entry_id", entry.ID), + zap.Int64("timeperiod_id", entry.TimePeriodID)) + continue + } + + err := entry.Init() + if err != nil { + r.logger.Warnw("ignoring time period entry", + zap.Object("entry", entry), + zap.Error(err)) + continue + } + + member.TimePeriodEntries[entry.ID] = entry + } + + for _, schedule := range schedulesById { + schedule.RefreshRotations() + } + if r.Schedules != nil { // mark no longer existing schedules for deletion for id := range r.Schedules { @@ -72,38 +138,29 @@ func (r *RuntimeConfig) applyPendingSchedules() { if pendingSchedule == nil { delete(r.Schedules, id) } else { - for _, memberRow := range pendingSchedule.MemberRows { - memberLogger := makeScheduleMemberLogger(r.logger.SugaredLogger, memberRow) - - period := r.TimePeriods[memberRow.TimePeriodID] - if period == nil { - memberLogger.Warnw("ignoring schedule member for unknown timeperiod_id") - continue - } - - var contact *recipient.Contact - if memberRow.ContactID.Valid { - contact = r.Contacts[memberRow.ContactID.Int64] - if contact == nil { - memberLogger.Warnw("ignoring schedule member for unknown contact_id") - continue + for _, rotation := range pendingSchedule.Rotations { + + for _, member := range rotation.Members { + memberLogger := r.logger.With( + zap.Object("rotation", rotation), + zap.Object("rotation_member", member)) + + if member.ContactID.Valid { + member.Contact = r.Contacts[member.ContactID.Int64] + if member.Contact == nil { + memberLogger.Warnw("ignoring rotation member for unknown contact_id") + continue + } } - } - var group *recipient.Group - if memberRow.GroupID.Valid { - group = r.Groups[memberRow.GroupID.Int64] - if group == nil { - memberLogger.Warnw("ignoring schedule member for unknown contactgroup_id") - continue + if member.ContactGroupID.Valid { + member.ContactGroup = r.Groups[member.ContactGroupID.Int64] + if member.ContactGroup == nil { + memberLogger.Warnw("ignoring rotation member for unknown contactgroup_id") + continue + } } } - - pendingSchedule.Members = append(pendingSchedule.Members, &recipient.Member{ - TimePeriod: period, - Contact: contact, - ContactGroup: group, - }) } if currentSchedule := r.Schedules[id]; currentSchedule != nil { @@ -116,12 +173,3 @@ func (r *RuntimeConfig) applyPendingSchedules() { r.pending.Schedules = nil } - -func makeScheduleMemberLogger(logger *zap.SugaredLogger, member *recipient.ScheduleMemberRow) *zap.SugaredLogger { - return logger.With( - zap.Int64("schedule_id", member.ScheduleID), - zap.Int64("timeperiod_id", member.TimePeriodID), - zap.Int64("contact_id", member.ContactID.Int64), - zap.Int64("contactgroup_id", member.GroupID.Int64), - ) -} diff --git a/internal/config/timeperiod.go b/internal/config/timeperiod.go index 9ed7274ed..9263c52d4 100644 --- a/internal/config/timeperiod.go +++ b/internal/config/timeperiod.go @@ -3,7 +3,6 @@ package config import ( "context" "fmt" - "github.com/icinga/icinga-go-library/types" "github.com/icinga/icinga-notifications/internal/timeperiod" "github.com/jmoiron/sqlx" "go.uber.org/zap" @@ -45,9 +44,6 @@ func (r *RuntimeConfig) fetchTimePeriods(ctx context.Context, tx *sqlx.Tx) error if p.Name == "" { p.Name = fmt.Sprintf("Time Period #%d", entry.TimePeriodID) - if entry.Description.Valid { - p.Name += fmt.Sprintf(" (%s)", entry.Description.String) - } } err := entry.Init() diff --git a/internal/config/verify.go b/internal/config/verify.go index d6b7bf416..9a0662dc8 100644 --- a/internal/config/verify.go +++ b/internal/config/verify.go @@ -199,34 +199,36 @@ func (r *RuntimeConfig) debugVerifySchedule(id int64, schedule *recipient.Schedu return fmt.Errorf("schedule %p is inconsistent with RuntimeConfig.Schedules[%d] = %p", schedule, id, other) } - for i, member := range schedule.Members { - if member == nil { - return fmt.Errorf("Members[%d] is nil", i) + for i, rotation := range schedule.Rotations { + if rotation == nil { + return fmt.Errorf("Rotations[%d] is nil", i) } - if member.TimePeriod == nil { - return fmt.Errorf("Members[%d].TimePeriod is nil", i) - } + for j, member := range rotation.Members { + if member == nil { + return fmt.Errorf("Rotations[%d].Members[%d] is nil", i, j) + } - if member.Contact == nil && member.ContactGroup == nil { - return fmt.Errorf("Members[%d] has neither Contact nor ContactGroup set", i) - } + if member.Contact == nil && member.ContactGroup == nil { + return fmt.Errorf("Rotations[%d].Members[%d] has neither Contact nor ContactGroup set", i, j) + } - if member.Contact != nil && member.ContactGroup != nil { - return fmt.Errorf("Members[%d] has both Contact and ContactGroup set", i) - } + if member.Contact != nil && member.ContactGroup != nil { + return fmt.Errorf("Rotations[%d].Members[%d] has both Contact and ContactGroup set", i, j) + } - if member.Contact != nil { - err := r.debugVerifyContact(member.Contact.ID, member.Contact) - if err != nil { - return fmt.Errorf("Contact: %w", err) + if member.Contact != nil { + err := r.debugVerifyContact(member.ContactID.Int64, member.Contact) + if err != nil { + return fmt.Errorf("Contact: %w", err) + } } - } - if member.ContactGroup != nil { - err := r.debugVerifyGroup(member.ContactGroup.ID, member.ContactGroup) - if err != nil { - return fmt.Errorf("ContactGroup: %w", err) + if member.ContactGroup != nil { + err := r.debugVerifyGroup(member.ContactGroupID.Int64, member.ContactGroup) + if err != nil { + return fmt.Errorf("ContactGroup: %w", err) + } } } } diff --git a/internal/recipient/rotations.go b/internal/recipient/rotations.go new file mode 100644 index 000000000..ea28da6f7 --- /dev/null +++ b/internal/recipient/rotations.go @@ -0,0 +1,115 @@ +package recipient + +import ( + "cmp" + "slices" + "time" +) + +// rotationResolver stores all the rotations from a scheduled in a structured way that's suitable for evaluating them. +type rotationResolver struct { + // sortedByPriority is ordered so that the elements at a smaller index have higher precedence. + sortedByPriority []*rotationsWithPriority +} + +// rotationsWithPriority stores the different versions of the rotations with the same priority within a single schedule. +type rotationsWithPriority struct { + priority int32 + + // sortedByHandoff contains the different version of a specific rotation sorted by their ActualHandoff time. + // This allows using binary search to find the active version. + sortedByHandoff []*Rotation +} + +// update initializes the rotationResolver with the given rotations, resetting any previously existing state. +func (r *rotationResolver) update(rotations []*Rotation) { + // Group sortedByHandoff by priority using a temporary map with the priority as key. + prioMap := make(map[int32]*rotationsWithPriority) + for _, rotation := range rotations { + p := prioMap[rotation.Priority] + if p == nil { + p = &rotationsWithPriority{ + priority: rotation.Priority, + } + prioMap[rotation.Priority] = p + } + + p.sortedByHandoff = append(p.sortedByHandoff, rotation) + } + + // Copy it to a slice and sort it by priority so that these can easily be iterated by priority. + rs := make([]*rotationsWithPriority, 0, len(prioMap)) + for _, rotation := range prioMap { + rs = append(rs, rotation) + } + slices.SortFunc(rs, func(a, b *rotationsWithPriority) int { + return cmp.Compare(a.priority, b.priority) + }) + + // Sort the different versions of the same rotation (i.e. same schedule and priority, differing in their handoff + // time) by the handoff time so that the currently active version can be found with binary search. + for _, rotation := range rs { + slices.SortFunc(rotation.sortedByHandoff, func(a, b *Rotation) int { + return a.ActualHandoff.Time().Compare(b.ActualHandoff.Time()) + }) + } + + r.sortedByPriority = rs +} + +// getRotationsAt returns a slice of active rotations at the given time. +// +// For priority, there may be at most one active rotation version. This function return all rotation versions that +// are active at the given time t, ordered by priority (lower index has higher precedence). +func (r *rotationResolver) getRotationsAt(t time.Time) []*Rotation { + rotations := make([]*Rotation, 0, len(r.sortedByPriority)) + + for _, w := range r.sortedByPriority { + i, found := slices.BinarySearchFunc(w.sortedByHandoff, t, func(rotation *Rotation, t time.Time) int { + return rotation.ActualHandoff.Time().Compare(t) + }) + + // If a rotation version with sortedByHandoff[i].ActualHandoff == t is found, it just became valid and should be + // used. Otherwise, BinarySearchFunc returns the first index i after t so that: + // + // sortedByHandoff[i-1].ActualHandoff < t < sortedByHandoff[i].ActualHandoff + // + // Thus, the version at index i becomes active after t and the preceding one is still active. + if !found { + i-- + } + + // If all rotation versions have ActualHandoff > t, there is none that's currently active and i is negative. + if i >= 0 { + rotations = append(rotations, w.sortedByHandoff[i]) + } + } + + return rotations +} + +// getContactsAt evaluates the rotations by priority and returns all contacts active at the given time. +func (r *rotationResolver) getContactsAt(t time.Time) []*Contact { + rotations := r.getRotationsAt(t) + for _, rotation := range rotations { + for _, member := range rotation.Members { + for _, entry := range member.TimePeriodEntries { + if entry.Contains(t) { + var contacts []*Contact + + if member.Contact != nil { + contacts = append(contacts, member.Contact) + } + + if member.ContactGroup != nil { + contacts = append(contacts, member.ContactGroup.Members...) + } + + return contacts + } + } + } + } + + return nil +} diff --git a/internal/recipient/rotations_test.go b/internal/recipient/rotations_test.go new file mode 100644 index 000000000..bc139fe85 --- /dev/null +++ b/internal/recipient/rotations_test.go @@ -0,0 +1,169 @@ +package recipient + +import ( + "database/sql" + "github.com/icinga/icinga-go-library/types" + "github.com/icinga/icinga-notifications/internal/timeperiod" + "github.com/stretchr/testify/assert" + "testing" + "time" +) + +func Test_rotationResolver_getCurrentRotations(t *testing.T) { + contactWeekday := &Contact{FullName: "Weekday Non-Noon"} + contactWeekdayNoon := &Contact{FullName: "Weekday Noon"} + contactWeekend2024a := &Contact{FullName: "Weekend 2024 A"} + contactWeekend2024b := &Contact{FullName: "Weekend 2024 B"} + contactWeekend2025a := &Contact{FullName: "Weekend 2025 A"} + contactWeekend2025b := &Contact{FullName: "Weekend 2025 B"} + + // Helper function to parse strings into time.Time interpreted as UTC. + // Accepts values like "2006-01-02 15:04:05" and "2006-01-02" (assuming 00:00:00 as time). + parse := func(s string) time.Time { + var format string + + switch len(s) { + case len(time.DateTime): + format = time.DateTime + case len(time.DateOnly): + format = time.DateOnly + } + + t, err := time.ParseInLocation(format, s, time.UTC) + if err != nil { + panic(err) + } + return t + } + + var s rotationResolver + s.update([]*Rotation{ + // Weekend rotation starting 2024, alternating between contacts contactWeekend2024a and contactWeekend2024b + { + ActualHandoff: types.UnixMilli(parse("2024-01-01")), + Priority: 0, + Members: []*RotationMember{ + { + Contact: contactWeekend2024a, + TimePeriodEntries: map[int64]*timeperiod.Entry{ + 1: { + StartTime: types.UnixMilli(parse("2024-01-06")), // Saturday + EndTime: types.UnixMilli(parse("2024-01-07")), // Sunday + Timezone: "UTC", + RRule: sql.NullString{String: "FREQ=WEEKLY;INTERVAL=2;BYDAY=SA,SU", Valid: true}, + }, + }, + }, { + Contact: contactWeekend2024b, + TimePeriodEntries: map[int64]*timeperiod.Entry{ + 2: { + StartTime: types.UnixMilli(parse("2024-01-13")), // Saturday + EndTime: types.UnixMilli(parse("2024-01-14")), // Sunday + Timezone: "UTC", + RRule: sql.NullString{String: "FREQ=WEEKLY;INTERVAL=2;BYDAY=SA,SU", Valid: true}, + }, + }, + }, + }, + }, + + // Weekend rotation starting 2025 and replacing the previous one, + // alternating between contacts contactWeekend2025a and contactWeekend2025b + { + ActualHandoff: types.UnixMilli(parse("2025-01-01")), + Priority: 0, + Members: []*RotationMember{ + { + Contact: contactWeekend2025a, + TimePeriodEntries: map[int64]*timeperiod.Entry{ + 3: { + StartTime: types.UnixMilli(parse("2025-01-04")), // Saturday + EndTime: types.UnixMilli(parse("2025-01-05")), // Sunday + Timezone: "UTC", + RRule: sql.NullString{String: "FREQ=WEEKLY;INTERVAL=2;BYDAY=SA,SU", Valid: true}, + }, + }, + }, { + Contact: contactWeekend2025b, + TimePeriodEntries: map[int64]*timeperiod.Entry{ + 4: { + StartTime: types.UnixMilli(parse("2025-01-11")), // Saturday + EndTime: types.UnixMilli(parse("2025-01-12")), // Sunday + Timezone: "UTC", + RRule: sql.NullString{String: "FREQ=WEEKLY;INTERVAL=2;BYDAY=SA,SU", Valid: true}, + }, + }, + }, + }, + }, + + // Weekday rotations starting 2024, one for contactWeekday every day from 8 to 20 o'clock, + // with an override for 12 to 14 o'clock with contactWeekdayNoon. + { + ActualHandoff: types.UnixMilli(parse("2024-01-01")), + Priority: 1, + Members: []*RotationMember{ + { + Contact: contactWeekdayNoon, + TimePeriodEntries: map[int64]*timeperiod.Entry{ + 5: { + StartTime: types.UnixMilli(parse("2024-01-01 12:00:00")), // Monday + EndTime: types.UnixMilli(parse("2024-01-01 14:00:00")), // Monday + Timezone: "UTC", + RRule: sql.NullString{String: "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", Valid: true}, + }, + }, + }, + }, + }, { + ActualHandoff: types.UnixMilli(parse("2024-01-01")), + Priority: 2, + Members: []*RotationMember{ + { + Contact: contactWeekday, + TimePeriodEntries: map[int64]*timeperiod.Entry{ + 6: { + StartTime: types.UnixMilli(parse("2024-01-01 08:00:00")), // Monday + EndTime: types.UnixMilli(parse("2024-01-01 20:00:00")), // Monday + Timezone: "UTC", + RRule: sql.NullString{String: "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", Valid: true}, + }, + }, + }, + }, + }, + }) + + for ts := parse("2023-01-01"); ts.Before(parse("2027-01-01")); ts = ts.Add(30 * time.Minute) { + got := s.getContactsAt(ts) + + switch ts.Weekday() { + case time.Monday, time.Tuesday, time.Wednesday, time.Thursday, time.Friday: + if y, h := ts.Year(), ts.Hour(); y >= 2024 && 12 <= h && h < 14 { + if assert.Lenf(t, got, 1, "resolving rotations on %v should return one contact", ts) { + assert.Equal(t, contactWeekdayNoon, got[0]) + } + } else if y >= 2024 && 8 <= h && h < 20 { + if assert.Lenf(t, got, 1, "resolving rotations on %v should return one contact", ts) { + assert.Equal(t, contactWeekday, got[0]) + } + } else { + assert.Emptyf(t, got, "resolving rotations on %v should return no contacts", ts) + } + + case time.Saturday, time.Sunday: + switch y := ts.Year(); { + case y == 2024: + if assert.Lenf(t, got, 1, "resolving rotations on %v return one contact", ts) { + assert.Contains(t, []*Contact{contactWeekend2024a, contactWeekend2024b}, got[0]) + } + case y >= 2025: + if assert.Lenf(t, got, 1, "resolving rotations on %v return one contact", ts) { + assert.Contains(t, []*Contact{contactWeekend2025a, contactWeekend2025b}, got[0]) + } + default: + assert.Emptyf(t, got, "resolving rotations on %v should return no contacts", ts) + } + } + } +} diff --git a/internal/recipient/schedule.go b/internal/recipient/schedule.go index 24c30c2af..b3f7421c3 100644 --- a/internal/recipient/schedule.go +++ b/internal/recipient/schedule.go @@ -2,51 +2,70 @@ package recipient import ( "database/sql" + "github.com/icinga/icinga-go-library/types" "github.com/icinga/icinga-notifications/internal/timeperiod" + "go.uber.org/zap/zapcore" "time" ) type Schedule struct { - ID int64 `db:"id"` - Name string `db:"name"` - Members []*Member `db:"-"` - MemberRows []*ScheduleMemberRow `db:"-"` + ID int64 `db:"id"` + Name string `db:"name"` + + Rotations []*Rotation `db:"-"` + rotationResolver rotationResolver } -type Member struct { - TimePeriod *timeperiod.TimePeriod - Contact *Contact - ContactGroup *Group +// RefreshRotations updates the internally cached rotations. +// +// This must be called after the Rotations member was updated for the change to become active. +func (s *Schedule) RefreshRotations() { + s.rotationResolver.update(s.Rotations) } -type ScheduleMemberRow struct { - ScheduleID int64 `db:"schedule_id"` - TimePeriodID int64 `db:"timeperiod_id"` - ContactID sql.NullInt64 `db:"contact_id"` - GroupID sql.NullInt64 `db:"contactgroup_id"` +type Rotation struct { + ID int64 `db:"id"` + ScheduleID int64 `db:"schedule_id"` + ActualHandoff types.UnixMilli `db:"actual_handoff"` + Priority int32 `db:"priority"` + Name string `db:"name"` + Members []*RotationMember `db:"-"` } -func (s *ScheduleMemberRow) TableName() string { - return "schedule_member" +// MarshalLogObject implements the zapcore.ObjectMarshaler interface. +func (r *Rotation) MarshalLogObject(encoder zapcore.ObjectEncoder) error { + encoder.AddInt64("id", r.ID) + encoder.AddInt64("schedule_id", r.ScheduleID) + encoder.AddInt32("priority", r.Priority) + encoder.AddString("name", r.Name) + return nil } -// GetContactsAt returns the contacts that are active in the schedule at the given time. -func (s *Schedule) GetContactsAt(t time.Time) []*Contact { - var contacts []*Contact - - for _, m := range s.Members { - if m.TimePeriod.Contains(t) { - if m.Contact != nil { - contacts = append(contacts, m.Contact) - } - - if m.ContactGroup != nil { - contacts = append(contacts, m.ContactGroup.Members...) - } - } +type RotationMember struct { + ID int64 `db:"id"` + RotationID int64 `db:"rotation_id"` + Contact *Contact `db:"-"` + ContactID sql.NullInt64 `db:"contact_id"` + ContactGroup *Group `db:"-"` + ContactGroupID sql.NullInt64 `db:"contactgroup_id"` + TimePeriodEntries map[int64]*timeperiod.Entry `db:"-"` +} + +func (r *RotationMember) MarshalLogObject(encoder zapcore.ObjectEncoder) error { + encoder.AddInt64("id", r.ID) + encoder.AddInt64("rotation_id", r.RotationID) + if r.ContactID.Valid { + encoder.AddInt64("contact_id", r.ContactID.Int64) } + if r.ContactGroupID.Valid { + encoder.AddInt64("contact_group_id", r.ContactGroupID.Int64) + } + return nil +} - return contacts +// GetContactsAt returns the contacts that are active in the schedule at the given time. +func (s *Schedule) GetContactsAt(t time.Time) []*Contact { + return s.rotationResolver.getContactsAt(t) } func (s *Schedule) String() string { diff --git a/internal/timeperiod/timeperiod.go b/internal/timeperiod/timeperiod.go index 55de311f2..00dcc743e 100644 --- a/internal/timeperiod/timeperiod.go +++ b/internal/timeperiod/timeperiod.go @@ -2,7 +2,7 @@ package timeperiod import ( "database/sql" - "github.com/icinga/icingadb/pkg/types" + "github.com/icinga/icinga-go-library/types" "github.com/pkg/errors" "github.com/teambition/rrule-go" "go.uber.org/zap/zapcore" @@ -64,7 +64,6 @@ type Entry struct { EndTime types.UnixMilli `db:"end_time"` Timezone string `db:"timezone"` RRule sql.NullString `db:"rrule"` // RFC5545 RRULE - Description sql.NullString `db:"description"` RotationMemberID sql.NullInt64 `db:"rotation_member_id"` initialized bool diff --git a/internal/timeperiod/timeperiod_test.go b/internal/timeperiod/timeperiod_test.go index 3a86c6d3e..12c6b0445 100644 --- a/internal/timeperiod/timeperiod_test.go +++ b/internal/timeperiod/timeperiod_test.go @@ -3,8 +3,8 @@ package timeperiod_test import ( "database/sql" "fmt" + "github.com/icinga/icinga-go-library/types" "github.com/icinga/icinga-notifications/internal/timeperiod" - "github.com/icinga/icingadb/pkg/types" "github.com/stretchr/testify/assert" "github.com/teambition/rrule-go" "testing"