mirror of
https://github.com/fleetdm/fleet
synced 2026-05-23 00:49:03 +00:00
Calendar interface updates and mock calendar (#17701)
- Updated calendar interface to use updated `genBodyFn` - The mock calendar is enabled by specifying `calendar-mock@example.com` as the service account email.
This commit is contained in:
parent
9a8ac02bc1
commit
196d8ce5b7
5 changed files with 166 additions and 38 deletions
|
|
@ -231,9 +231,10 @@ func processFailingHostExistingCalendarEvent(
|
|||
calendarEvent *fleet.CalendarEvent,
|
||||
host fleet.HostPolicyMembershipData,
|
||||
) error {
|
||||
updatedEvent, updated, err := calendar.GetAndUpdateEvent(calendarEvent, func() string {
|
||||
return generateCalendarEventBody(orgName, host.HostDisplayName)
|
||||
})
|
||||
updatedEvent, updated, err := calendar.GetAndUpdateEvent(
|
||||
calendarEvent, func(bool) string {
|
||||
return generateCalendarEventBody(orgName, host.HostDisplayName)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("get event calendar on db: %w", err)
|
||||
}
|
||||
|
|
@ -325,9 +326,12 @@ func attemptCreatingEventOnUserCalendar(
|
|||
// - If it’s the 3rd Tuesday, Weds, Thurs, etc. of the month and it’s past the last slot, schedule the call for the next business day.
|
||||
year, month, today := time.Now().Date()
|
||||
preferredDate := getPreferredCalendarEventDate(year, month, today)
|
||||
body := generateCalendarEventBody(orgName, host.HostDisplayName)
|
||||
for {
|
||||
calendarEvent, err := userCalendar.CreateEvent(preferredDate, body)
|
||||
calendarEvent, err := userCalendar.CreateEvent(
|
||||
preferredDate, func(bool) string {
|
||||
return generateCalendarEventBody(orgName, host.HostDisplayName)
|
||||
},
|
||||
)
|
||||
var dee fleet.DayEndedError
|
||||
switch {
|
||||
case err == nil:
|
||||
|
|
|
|||
|
|
@ -50,8 +50,12 @@ type GoogleCalendar struct {
|
|||
|
||||
func NewGoogleCalendar(config *GoogleCalendarConfig) *GoogleCalendar {
|
||||
if config.API == nil {
|
||||
var lowLevelAPI GoogleCalendarAPI = &GoogleCalendarLowLevelAPI{}
|
||||
config.API = lowLevelAPI
|
||||
if config.IntegrationConfig.Email == "calendar-mock@example.com" {
|
||||
// Assumes that only 1 Fleet server accesses the calendar, since all mock events are held in memory
|
||||
config.API = &GoogleCalendarMockAPI{}
|
||||
} else {
|
||||
config.API = &GoogleCalendarLowLevelAPI{}
|
||||
}
|
||||
}
|
||||
return &GoogleCalendar{
|
||||
config: config,
|
||||
|
|
@ -136,7 +140,9 @@ func (c *GoogleCalendar) Configure(userEmail string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (c *GoogleCalendar) GetAndUpdateEvent(event *fleet.CalendarEvent, genBodyFn func() string) (*fleet.CalendarEvent, bool, error) {
|
||||
func (c *GoogleCalendar) GetAndUpdateEvent(event *fleet.CalendarEvent, genBodyFn func(conflict bool) string) (
|
||||
*fleet.CalendarEvent, bool, error,
|
||||
) {
|
||||
// We assume that the Fleet event has not already ended. We will simply return it if it has not been modified.
|
||||
details, err := c.unmarshalDetails(event)
|
||||
if err != nil {
|
||||
|
|
@ -167,6 +173,10 @@ func (c *GoogleCalendar) GetAndUpdateEvent(event *fleet.CalendarEvent, genBodyFn
|
|||
if gEvent.End.DateTime == "" {
|
||||
// User has modified the event to be an all-day event. All-day events are problematic because they depend on the user's timezone.
|
||||
// We won't handle all-day events at this time, and treat the event as deleted.
|
||||
err = c.DeleteEvent(event)
|
||||
if err != nil {
|
||||
level.Warn(c.config.Logger).Log("msg", "deleting Google calendar event which was changed to all-day event", "err", err)
|
||||
}
|
||||
deleted = true
|
||||
}
|
||||
|
||||
|
|
@ -194,6 +204,10 @@ func (c *GoogleCalendar) GetAndUpdateEvent(event *fleet.CalendarEvent, genBodyFn
|
|||
if gEvent.Start.DateTime == "" {
|
||||
// User has modified the event to be an all-day event. All-day events are problematic because they depend on the user's timezone.
|
||||
// We won't handle all-day events at this time, and treat the event as deleted.
|
||||
err = c.DeleteEvent(event)
|
||||
if err != nil {
|
||||
level.Warn(c.config.Logger).Log("msg", "deleting Google calendar event which was changed to all-day event", "err", err)
|
||||
}
|
||||
deleted = true
|
||||
}
|
||||
}
|
||||
|
|
@ -212,7 +226,7 @@ func (c *GoogleCalendar) GetAndUpdateEvent(event *fleet.CalendarEvent, genBodyFn
|
|||
|
||||
newStartDate := calculateNewEventDate(event.StartTime)
|
||||
|
||||
fleetEvent, err := c.CreateEvent(newStartDate, genBodyFn())
|
||||
fleetEvent, err := c.CreateEvent(newStartDate, genBodyFn)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
|
@ -269,13 +283,15 @@ func (c *GoogleCalendar) unmarshalDetails(event *fleet.CalendarEvent) (*eventDet
|
|||
return &details, nil
|
||||
}
|
||||
|
||||
func (c *GoogleCalendar) CreateEvent(dayOfEvent time.Time, body string) (*fleet.CalendarEvent, error) {
|
||||
return c.createEvent(dayOfEvent, body, time.Now)
|
||||
func (c *GoogleCalendar) CreateEvent(dayOfEvent time.Time, genBodyFn func(conflict bool) string) (*fleet.CalendarEvent, error) {
|
||||
return c.createEvent(dayOfEvent, genBodyFn, time.Now)
|
||||
}
|
||||
|
||||
// createEvent creates a new event on the calendar on the given date. timeNow is a function that returns the current time.
|
||||
// timeNow can be overwritten for testing
|
||||
func (c *GoogleCalendar) createEvent(dayOfEvent time.Time, body string, timeNow func() time.Time) (*fleet.CalendarEvent, error) {
|
||||
func (c *GoogleCalendar) createEvent(
|
||||
dayOfEvent time.Time, genBodyFn func(conflict bool) string, timeNow func() time.Time,
|
||||
) (*fleet.CalendarEvent, error) {
|
||||
if c.timezoneOffset == nil {
|
||||
err := getTimezone(c)
|
||||
if err != nil {
|
||||
|
|
@ -310,6 +326,7 @@ func (c *GoogleCalendar) createEvent(dayOfEvent time.Time, body string, timeNow
|
|||
if err != nil {
|
||||
return nil, ctxerr.Wrap(c.config.Context, err, "listing Google calendar events")
|
||||
}
|
||||
var conflict bool
|
||||
for _, gEvent := range events.Items {
|
||||
// Ignore cancelled events
|
||||
if gEvent.Status == "cancelled" {
|
||||
|
|
@ -353,7 +370,7 @@ func (c *GoogleCalendar) createEvent(dayOfEvent time.Time, body string, timeNow
|
|||
if startTime.Before(eventEnd) {
|
||||
// Event occurs during our event, so we need to adjust.
|
||||
var isLastSlot bool
|
||||
eventStart, eventEnd, isLastSlot = adjustEventTimes(*endTime, dayEnd)
|
||||
eventStart, eventEnd, isLastSlot, conflict = adjustEventTimes(*endTime, dayEnd)
|
||||
if isLastSlot {
|
||||
break
|
||||
}
|
||||
|
|
@ -367,7 +384,7 @@ func (c *GoogleCalendar) createEvent(dayOfEvent time.Time, body string, timeNow
|
|||
event.Start = &calendar.EventDateTime{DateTime: eventStart.Format(time.RFC3339)}
|
||||
event.End = &calendar.EventDateTime{DateTime: eventEnd.Format(time.RFC3339)}
|
||||
event.Summary = eventTitle
|
||||
event.Description = body
|
||||
event.Description = genBodyFn(conflict)
|
||||
event, err = c.config.API.CreateEvent(event)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(c.config.Context, err, "creating Google calendar event")
|
||||
|
|
@ -383,7 +400,7 @@ func (c *GoogleCalendar) createEvent(dayOfEvent time.Time, body string, timeNow
|
|||
return fleetEvent, nil
|
||||
}
|
||||
|
||||
func adjustEventTimes(endTime time.Time, dayEnd time.Time) (eventStart time.Time, eventEnd time.Time, isLastSlot bool) {
|
||||
func adjustEventTimes(endTime time.Time, dayEnd time.Time) (eventStart time.Time, eventEnd time.Time, isLastSlot bool, conflict bool) {
|
||||
eventStart = endTime.Truncate(eventLength)
|
||||
if eventStart.Before(endTime) {
|
||||
eventStart = eventStart.Add(eventLength)
|
||||
|
|
@ -394,11 +411,11 @@ func adjustEventTimes(endTime time.Time, dayEnd time.Time) (eventStart time.Time
|
|||
eventEnd = dayEnd
|
||||
eventStart = eventEnd.Add(-eventLength)
|
||||
isLastSlot = true
|
||||
}
|
||||
if eventEnd.Equal(dayEnd) {
|
||||
conflict = true
|
||||
} else if eventEnd.Equal(dayEnd) {
|
||||
isLastSlot = true
|
||||
}
|
||||
return eventStart, eventEnd, isLastSlot
|
||||
return eventStart, eventEnd, isLastSlot, conflict
|
||||
}
|
||||
|
||||
func getTimezone(gCal *GoogleCalendar) error {
|
||||
|
|
@ -444,9 +461,6 @@ func (c *GoogleCalendar) googleEventToFleetEvent(startTime time.Time, endTime ti
|
|||
}
|
||||
|
||||
func (c *GoogleCalendar) DeleteEvent(event *fleet.CalendarEvent) error {
|
||||
if c.config == nil {
|
||||
return errors.New("the Google calendar is not connected. Please call Configure first")
|
||||
}
|
||||
details, err := c.unmarshalDetails(event)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
|||
81
ee/server/calendar/google_calendar_mock.go
Normal file
81
ee/server/calendar/google_calendar_mock.go
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
kitlog "github.com/go-kit/log"
|
||||
"google.golang.org/api/calendar/v3"
|
||||
"google.golang.org/api/googleapi"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type GoogleCalendarMockAPI struct {
|
||||
logger kitlog.Logger
|
||||
}
|
||||
|
||||
var events = make(map[string]*calendar.Event)
|
||||
var mu sync.Mutex
|
||||
var id uint64
|
||||
|
||||
const latency = 500 * time.Millisecond
|
||||
|
||||
// Configure creates a new Google Calendar service using the provided credentials.
|
||||
func (lowLevelAPI *GoogleCalendarMockAPI) Configure(_ context.Context, _ string, _ string, userToImpersonate string) error {
|
||||
if lowLevelAPI.logger == nil {
|
||||
lowLevelAPI.logger = kitlog.With(kitlog.NewLogfmtLogger(os.Stderr), "mock", "GoogleCalendarMockAPI", "user", userToImpersonate)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (lowLevelAPI *GoogleCalendarMockAPI) GetSetting(name string) (*calendar.Setting, error) {
|
||||
time.Sleep(latency)
|
||||
lowLevelAPI.logger.Log("msg", "GetSetting", "name", name)
|
||||
if name == "timezone" {
|
||||
return &calendar.Setting{
|
||||
Id: "timezone",
|
||||
Value: "America/Chicago",
|
||||
}, nil
|
||||
}
|
||||
return nil, errors.New("setting not supported")
|
||||
}
|
||||
|
||||
func (lowLevelAPI *GoogleCalendarMockAPI) CreateEvent(event *calendar.Event) (*calendar.Event, error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
id += 1
|
||||
event.Id = strconv.FormatUint(id, 10)
|
||||
lowLevelAPI.logger.Log("msg", "CreateEvent", "id", event.Id, "start", event.Start.DateTime)
|
||||
events[event.Id] = event
|
||||
return event, nil
|
||||
}
|
||||
|
||||
func (lowLevelAPI *GoogleCalendarMockAPI) GetEvent(id, _ string) (*calendar.Event, error) {
|
||||
time.Sleep(latency)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
event, ok := events[id]
|
||||
if !ok {
|
||||
return nil, &googleapi.Error{Code: http.StatusNotFound}
|
||||
}
|
||||
lowLevelAPI.logger.Log("msg", "GetEvent", "id", id, "start", event.Start.DateTime)
|
||||
return event, nil
|
||||
}
|
||||
|
||||
func (lowLevelAPI *GoogleCalendarMockAPI) ListEvents(string, string) (*calendar.Events, error) {
|
||||
time.Sleep(latency)
|
||||
lowLevelAPI.logger.Log("msg", "ListEvents")
|
||||
return &calendar.Events{}, nil
|
||||
}
|
||||
|
||||
func (lowLevelAPI *GoogleCalendarMockAPI) DeleteEvent(id string) error {
|
||||
time.Sleep(latency)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
lowLevelAPI.logger.Log("msg", "DeleteEvent", "id", id)
|
||||
delete(events, id)
|
||||
return nil
|
||||
}
|
||||
|
|
@ -162,7 +162,7 @@ func TestGoogleCalendar_GetAndUpdateEvent(t *testing.T) {
|
|||
Etag: baseETag, // ETag matches -- no modifications to event
|
||||
}, nil
|
||||
}
|
||||
genBodyFn := func() string {
|
||||
genBodyFn := func(bool) string {
|
||||
t.Error("genBodyFn should not be called")
|
||||
return "event-body"
|
||||
}
|
||||
|
|
@ -300,13 +300,14 @@ func TestGoogleCalendar_GetAndUpdateEvent(t *testing.T) {
|
|||
mockAPI.ListEventsFunc = func(timeMin, timeMax string) (*calendar.Events, error) {
|
||||
return &calendar.Events{}, nil
|
||||
}
|
||||
genBodyFn = func() string {
|
||||
genBodyFn = func(conflict bool) string {
|
||||
assert.False(t, conflict)
|
||||
return "event-body"
|
||||
}
|
||||
eventCreated := false
|
||||
mockAPI.CreateEventFunc = func(event *calendar.Event) (*calendar.Event, error) {
|
||||
assert.Equal(t, eventTitle, event.Summary)
|
||||
assert.Equal(t, genBodyFn(), event.Description)
|
||||
assert.Equal(t, genBodyFn(false), event.Description)
|
||||
event.Id = baseEventID
|
||||
event.Etag = baseETag
|
||||
eventCreated = true
|
||||
|
|
@ -345,6 +346,10 @@ func TestGoogleCalendar_GetAndUpdateEvent(t *testing.T) {
|
|||
assert.True(t, eventCreated)
|
||||
|
||||
// all day event (deleted)
|
||||
mockAPI.DeleteEventFunc = func(id string) error {
|
||||
assert.Equal(t, baseEventID, id)
|
||||
return nil
|
||||
}
|
||||
mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) {
|
||||
return &calendar.Event{
|
||||
Id: baseEventID,
|
||||
|
|
@ -373,10 +378,6 @@ func TestGoogleCalendar_GetAndUpdateEvent(t *testing.T) {
|
|||
}, nil
|
||||
}
|
||||
eventCreated = false
|
||||
mockAPI.DeleteEventFunc = func(id string) error {
|
||||
assert.Equal(t, baseEventID, id)
|
||||
return nil
|
||||
}
|
||||
retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, updated)
|
||||
|
|
@ -411,13 +412,21 @@ func TestGoogleCalendar_CreateEvent(t *testing.T) {
|
|||
event.Etag = baseETag
|
||||
return event, nil
|
||||
}
|
||||
genBodyFn := func(conflict bool) string {
|
||||
assert.False(t, conflict)
|
||||
return eventBody
|
||||
}
|
||||
genBodyConflictFn := func(conflict bool) string {
|
||||
assert.True(t, conflict)
|
||||
return eventBody
|
||||
}
|
||||
|
||||
// Happy path test -- empty calendar
|
||||
date := time.Now().Add(48 * time.Hour)
|
||||
location, _ := time.LoadLocation(tzId)
|
||||
expectedStartTime := time.Date(date.Year(), date.Month(), date.Day(), startHour, 0, 0, 0, location)
|
||||
_, expectedOffset := expectedStartTime.Zone()
|
||||
event, err := cal.CreateEvent(date, eventBody)
|
||||
event, err := cal.CreateEvent(date, genBodyFn)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, baseUserEmail, event.Email)
|
||||
assert.Equal(t, expectedStartTime.UTC(), event.StartTime.UTC())
|
||||
|
|
@ -434,7 +443,7 @@ func TestGoogleCalendar_CreateEvent(t *testing.T) {
|
|||
|
||||
// Workday already ended
|
||||
date = time.Now().Add(-48 * time.Hour)
|
||||
_, err = cal.CreateEvent(date, eventBody)
|
||||
_, err = cal.CreateEvent(date, genBodyFn)
|
||||
assert.ErrorAs(t, err, &fleet.DayEndedError{})
|
||||
|
||||
// There is no time left in the day to schedule an event
|
||||
|
|
@ -443,7 +452,7 @@ func TestGoogleCalendar_CreateEvent(t *testing.T) {
|
|||
now := time.Date(date.Year(), date.Month(), date.Day(), endHour-1, 45, 0, 0, location)
|
||||
return now
|
||||
}
|
||||
_, err = gCal.createEvent(date, eventBody, timeNow)
|
||||
_, err = gCal.createEvent(date, genBodyFn, timeNow)
|
||||
assert.ErrorAs(t, err, &fleet.DayEndedError{})
|
||||
|
||||
// Workday already started
|
||||
|
|
@ -452,7 +461,7 @@ func TestGoogleCalendar_CreateEvent(t *testing.T) {
|
|||
timeNow = func() time.Time {
|
||||
return expectedStartTime
|
||||
}
|
||||
event, err = gCal.createEvent(date, eventBody, timeNow)
|
||||
event, err = gCal.createEvent(date, genBodyFn, timeNow)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, expectedStartTime.UTC(), event.StartTime.UTC())
|
||||
assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), event.EndTime.UTC())
|
||||
|
|
@ -545,7 +554,7 @@ func TestGoogleCalendar_CreateEvent(t *testing.T) {
|
|||
return gEvents, nil
|
||||
}
|
||||
expectedStartTime = time.Date(date.Year(), date.Month(), date.Day(), 12, 0, 0, 0, location)
|
||||
event, err = gCal.CreateEvent(date, eventBody)
|
||||
event, err = gCal.CreateEvent(date, genBodyFn)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, expectedStartTime.UTC(), event.StartTime.UTC())
|
||||
assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), event.EndTime.UTC())
|
||||
|
|
@ -565,7 +574,27 @@ func TestGoogleCalendar_CreateEvent(t *testing.T) {
|
|||
return gEvents, nil
|
||||
}
|
||||
expectedStartTime = time.Date(date.Year(), date.Month(), date.Day(), endHour-1, 30, 0, 0, location)
|
||||
event, err = gCal.CreateEvent(date, eventBody)
|
||||
event, err = gCal.CreateEvent(date, genBodyConflictFn)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, expectedStartTime.UTC(), event.StartTime.UTC())
|
||||
assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), event.EndTime.UTC())
|
||||
|
||||
// Almost full schedule -- pick the last slot
|
||||
date = time.Now().Add(48 * time.Hour)
|
||||
dayStart = time.Date(date.Year(), date.Month(), date.Day(), startHour, 0, 0, 0, location)
|
||||
dayEnd = time.Date(date.Year(), date.Month(), date.Day(), endHour-1, 30, 0, 0, location)
|
||||
gEvents = &calendar.Events{}
|
||||
gEvent = &calendar.Event{
|
||||
Id: "9-to-4-30-event-id",
|
||||
Start: &calendar.EventDateTime{DateTime: dayStart.Format(time.RFC3339)},
|
||||
End: &calendar.EventDateTime{DateTime: dayEnd.Format(time.RFC3339)},
|
||||
}
|
||||
gEvents.Items = append(gEvents.Items, gEvent)
|
||||
mockAPI.ListEventsFunc = func(timeMin, timeMax string) (*calendar.Events, error) {
|
||||
return gEvents, nil
|
||||
}
|
||||
expectedStartTime = dayEnd
|
||||
event, err = gCal.CreateEvent(date, genBodyFn)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, expectedStartTime.UTC(), event.StartTime.UTC())
|
||||
assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), event.EndTime.UTC())
|
||||
|
|
@ -574,7 +603,7 @@ func TestGoogleCalendar_CreateEvent(t *testing.T) {
|
|||
mockAPI.ListEventsFunc = func(timeMin, timeMax string) (*calendar.Events, error) {
|
||||
return nil, assert.AnError
|
||||
}
|
||||
_, err = gCal.CreateEvent(date, eventBody)
|
||||
_, err = gCal.CreateEvent(date, genBodyFn)
|
||||
assert.ErrorIs(t, err, assert.AnError)
|
||||
|
||||
// API error in CreateEvent
|
||||
|
|
@ -584,6 +613,6 @@ func TestGoogleCalendar_CreateEvent(t *testing.T) {
|
|||
mockAPI.CreateEventFunc = func(event *calendar.Event) (*calendar.Event, error) {
|
||||
return nil, assert.AnError
|
||||
}
|
||||
_, err = gCal.CreateEvent(date, eventBody)
|
||||
_, err = gCal.CreateEvent(date, genBodyFn)
|
||||
assert.ErrorIs(t, err, assert.AnError)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,11 +21,11 @@ type UserCalendar interface {
|
|||
// CreateEvent, GetAndUpdateEvent and DeleteEvent reference the user's calendar.
|
||||
Configure(userEmail string) error
|
||||
// CreateEvent creates a new event on the calendar on the given date. DayEndedError is returned if there is no time left on the given date to schedule event.
|
||||
CreateEvent(dateOfEvent time.Time, body string) (event *CalendarEvent, err error)
|
||||
CreateEvent(dateOfEvent time.Time, genBodyFn func(conflict bool) string) (event *CalendarEvent, err error)
|
||||
// GetAndUpdateEvent retrieves the event from the calendar.
|
||||
// If the event has been modified, it returns the updated event.
|
||||
// If the event has been deleted, it schedules a new event with given body callback and returns the new event.
|
||||
GetAndUpdateEvent(event *CalendarEvent, genBodyFn func() string) (updatedEvent *CalendarEvent, updated bool, err error)
|
||||
GetAndUpdateEvent(event *CalendarEvent, genBodyFn func(conflict bool) string) (updatedEvent *CalendarEvent, updated bool, err error)
|
||||
// DeleteEvent deletes the event with the given ID.
|
||||
DeleteEvent(event *CalendarEvent) error
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue