From 8f207bf356c699a365fe03bf559edf26dc92caae Mon Sep 17 00:00:00 2001 From: Evan Chen Date: Fri, 24 Dec 2021 16:56:16 +0800 Subject: [PATCH] feat: add calendar --- calendar/api_cal.go | 117 +++++++++++++++++++++++++++++++++++++ calendar/api_event.go | 120 ++++++++++++++++++++++++++++++++++++++ calendar/calendar.go | 40 +++++++++++++ calendar/calendar_test.go | 16 +++++ calendar/errors.go | 13 +++++ calendar/event.go | 42 ++++++++++++- calendar/event_group.go | 31 ++++++++++ calendar/ics.go | 74 +++++++++++++++++++++++ calendar/service.go | 18 +++++- calendar/util.go | 35 +++++++++++ helper.go | 2 + 11 files changed, 505 insertions(+), 3 deletions(-) create mode 100644 calendar/api_cal.go create mode 100644 calendar/api_event.go create mode 100644 calendar/calendar_test.go create mode 100644 calendar/errors.go create mode 100644 calendar/ics.go create mode 100644 calendar/util.go diff --git a/calendar/api_cal.go b/calendar/api_cal.go new file mode 100644 index 0000000..1ac2da4 --- /dev/null +++ b/calendar/api_cal.go @@ -0,0 +1,117 @@ +package calendar + +import ( + "github.com/gin-gonic/gin" + "gorm.io/gorm" + "kumoly.io/kumoly/app/auth" + "kumoly.io/kumoly/app/errors" + "kumoly.io/kumoly/app/history" + "kumoly.io/kumoly/app/server" +) + +func ApiCalQuery(c *gin.Context) { + id := c.Query("id") + if id != "" { + cal := &Calendar{} + err := HasCalAccess(c, cal, id) + if err != nil { + panic(err) + } + server.OK(c, cal) + } else { + grp := c.Query("grp") + cals := []Calendar{} + cl, err := auth.GetContextClaims(c) + if err != nil { + panic(err) + } + var result *gorm.DB + + if grp != "" && auth.ACHas(c, auth.ADMIN, auth.SYSTEM, grp) { + var grp_id uint + db.Raw("select id from groups where name = ?", grp).Scan(&grp_id) + if grp_id == 0 { + panic(errors.ErrorNotFound) + } + result = db.Find(&cals, "`group_id` = ? ", grp_id) + } else if !auth.ACHas(c, auth.ADMIN, auth.SYSTEM) { + result = db. + Find(&cals, "`group_id` in (?) or group_id = 0", + db.Table("groups").Select("id").Where("name in ?", cl.Groups)) + } else { + result = db.Find(&cals) + } + + if result.Error != nil { + panic(result.Error) + } + server.OK(c, cals) + } +} + +func ApiCalNew(c *gin.Context) { + cal := &Calendar{} + if err := c.ShouldBindJSON(cal); err != nil { + panic(err) + } + if cal.ID != "" { + panic(errors.ErrorBadRequest) + } + if !auth.ACHas(c, auth.ADMIN, auth.SYSTEM, cal.GroupName) { + panic(errors.ErrorForbidden) + } + if err := db.Create(cal).Error; err != nil { + panic(err) + } + history.Send(history.Info(). + Nm("Create"). + Grp(cal.GroupName).Bd(cal). + Iss(c.GetString(auth.GinUserKey)). + Msg("Calendar created")) + server.OK(c, cal) +} + +func ApiCalUpdate(c *gin.Context) { + cal := &Calendar{} + if err := c.ShouldBindJSON(cal); err != nil { + panic(err) + } + if cal.ID == "" { + panic(errors.ErrorBadRequest) + } + if err := HasCalAccess(c, &Calendar{}, cal.ID); err != nil { + panic(errors.ErrorForbidden) + } + if err := db.Save(cal).Error; err != nil { + panic(err) + } + history.Send(history.Info(). + Nm("Update"). + Grp(cal.GroupName).Bd(cal). + Iss(c.GetString(auth.GinUserKey)). + Msg("Calendar Updated")) + server.OK(c, cal) +} + +func ApiCalDelete(c *gin.Context) { + id := c.Query("id") + if id == "" { + panic(errors.ErrorBadRequest) + } + cal := &Calendar{} + err := HasCalAccess(c, cal, id) + if err != nil { + panic(err) + } + + err = db.Delete(&Calendar{}, "id = ?", id).Error + if err != nil { + panic(errors.NewError(404, err)) + } + history.Send(history.Info(). + Nm("Delete"). + Grp(cal.GroupName).Bd(cal). + Iss(c.GetString(auth.GinUserKey)). + Msg("Calendar Deleted")) + server.OK(c, "ok") +} diff --git a/calendar/api_event.go b/calendar/api_event.go new file mode 100644 index 0000000..dbb551b --- /dev/null +++ b/calendar/api_event.go @@ -0,0 +1,120 @@ +package calendar + +import ( + "github.com/gin-gonic/gin" + "gorm.io/gorm" + "kumoly.io/kumoly/app/auth" + "kumoly.io/kumoly/app/errors" + "kumoly.io/kumoly/app/history" + "kumoly.io/kumoly/app/server" +) + +func ApiEventQuery(c *gin.Context) { + id := c.Query("id") + if id != "" { + e := &Event{} + err := HasEventAccess(c, e, id) + if err != nil { + panic(err) + } + server.OK(c, e) + } else { + grp := c.Query("grp") + events := []Event{} + cl, err := auth.GetContextClaims(c) + if err != nil { + panic(err) + } + var result *gorm.DB + + if grp != "" && auth.ACHas(c, auth.ADMIN, auth.SYSTEM, grp) { + var grp_id uint + db.Raw("select id from groups where name = ?", grp).Scan(&grp_id) + if grp_id == 0 { + panic(errors.ErrorNotFound) + } + result = db.Find(&events, "`group_id` = ? ", grp_id) + } else if !auth.ACHas(c, auth.ADMIN, auth.SYSTEM) { + result = db. + Find(&events, "`group_id` in (?) or group_id = 0", + db.Table("groups").Select("id").Where("name in ?", cl.Groups)) + } else { + result = db.Find(&events) + } + + if result.Error != nil { + panic(result.Error) + } + server.OK(c, events) + } +} + +func ApiEventNew(c *gin.Context) { + e := &Event{} + if err := c.ShouldBindJSON(e); err != nil { + panic(err) + } + if e.ID != "" { + panic(errors.ErrorBadRequest) + } + if e.Start.IsZero() || e.End.IsZero() || e.Start.Before(e.End) { + panic(ErrorInvalidTime) + } + if !auth.ACHas(c, auth.ADMIN, auth.SYSTEM, e.GroupName) { + panic(errors.ErrorForbidden) + } + if err := db.Create(e).Error; err != nil { + panic(err) + } + history.Send(history.Info(). + Nm("Create"). + Grp(e.GroupName).Bd(e). + Iss(c.GetString(auth.GinUserKey)). + Msg("Event created")) + server.OK(c, e) +} + +func ApiEventUpdate(c *gin.Context) { + e := &Event{} + if err := c.ShouldBindJSON(e); err != nil { + panic(err) + } + if e.ID == "" { + panic(errors.ErrorBadRequest) + } + if err := HasEventAccess(c, &Event{}, e.ID); err != nil { + panic(errors.ErrorForbidden) + } + if err := db.Save(e).Error; err != nil { + panic(err) + } + history.Send(history.Info(). + Nm("Update"). + Grp(e.GroupName).Bd(e). + Iss(c.GetString(auth.GinUserKey)). + Msg("Event Updated")) + server.OK(c, e) +} + +func ApiEventDelete(c *gin.Context) { + id := c.Query("id") + if id == "" { + panic(errors.ErrorBadRequest) + } + e := &Event{} + err := HasEventAccess(c, e, id) + if err != nil { + panic(err) + } + + err = db.Delete(&Calendar{}, "id = ?", id).Error + if err != nil { + panic(errors.NewError(404, err)) + } + history.Send(history.Info(). + Nm("Delete"). + Grp(e.GroupName).Bd(e). + Iss(c.GetString(auth.GinUserKey)). + Msg("Event Deleted")) + server.OK(c, "ok") +} diff --git a/calendar/calendar.go b/calendar/calendar.go index b703941..7ee03ac 100644 --- a/calendar/calendar.go +++ b/calendar/calendar.go @@ -6,22 +6,62 @@ import ( "github.com/rs/xid" "github.com/rs/zerolog" "gorm.io/gorm" + "kumoly.io/kumoly/app/auth" + "kumoly.io/kumoly/app/errors" ) var l zerolog.Logger +var db *gorm.DB + type Calendar struct { ID string `gorm:"primaryKey"` + Name string `gorm:"index:idx_cal,unique"` + Description string + ExtLink string + + Timezone string + Events []Event + Group auth.Group `json:"-"` + GroupName string `gorm:"-" json:"Group"` + GroupID uint `gorm:"index:idx_cal,unique" json:"-"` + CreatedAt time.Time UpdatedAt time.Time } +func (c *Calendar) BeforeSave(tx *gorm.DB) (err error) { + if c.GroupName != "" { + var grp_id uint + db.Raw("select id from groups where name = ?", c.GroupName).Scan(&grp_id) + if grp_id == 0 { + return errors.ErrorNotFound + } + c.GroupID = grp_id + } else { + c.GroupID = 0 + } + return +} + func (c *Calendar) BeforeCreate(tx *gorm.DB) (err error) { if c.ID == "" { c.ID = xid.New().String() } + if c.Timezone == "" { + c.Timezone = "Asia/Taipei" + } + return +} + +func (c *Calendar) AfterFind(tx *gorm.DB) (err error) { + if c.GroupID != 0 { + var name string + db.Raw("select name from groups where id = ?", c.GroupID).Scan(&name) + c.GroupName = name + } return } diff --git a/calendar/calendar_test.go b/calendar/calendar_test.go new file mode 100644 index 0000000..9ff0f6f --- /dev/null +++ b/calendar/calendar_test.go @@ -0,0 +1,16 @@ +package calendar + +import ( + "fmt" + "testing" + "time" +) + +func TestOffsetStr(t *testing.T) { + fmt.Println(offsetStr(-28800)) + fmt.Println(offsetStr(28800)) +} + +func TestT(t *testing.T) { + fmt.Println(icstime(time.Now())) +} diff --git a/calendar/errors.go b/calendar/errors.go new file mode 100644 index 0000000..945d971 --- /dev/null +++ b/calendar/errors.go @@ -0,0 +1,13 @@ +package calendar + +import ( + "net/http" + + "kumoly.io/kumoly/app/errors" +) + +var ErrorInvalidTime = errors.Error{ + Code: http.StatusBadRequest, + ID: "ErrorInvalidTime", + Message: "time is not valid", +} diff --git a/calendar/event.go b/calendar/event.go index 22ee078..186ba13 100644 --- a/calendar/event.go +++ b/calendar/event.go @@ -5,24 +5,62 @@ import ( "github.com/rs/xid" "gorm.io/gorm" + "kumoly.io/kumoly/app/auth" + "kumoly.io/kumoly/app/errors" ) type Event struct { ID string `gorm:"primaryKey"` - Start time.Time - End time.Time + Start time.Time + End time.Time + Name string `gorm:"not null"` + Hosts string + Description string + Location string + + // ntu + Class string + CourseID string + Semester string CalendarID string EventGroupID string + Group auth.Group `json:"-"` + GroupName string `gorm:"-" json:"Group"` + GroupID uint `json:"-"` + CreatedAt time.Time UpdatedAt time.Time } +func (e *Event) BeforeSave(tx *gorm.DB) (err error) { + if e.GroupName != "" { + var grp_id uint + db.Raw("select id from groups where name = ?", e.GroupName).Scan(&grp_id) + if grp_id == 0 { + return errors.ErrorNotFound + } + e.GroupID = grp_id + } else { + e.GroupID = 0 + } + return +} + func (e *Event) BeforeCreate(tx *gorm.DB) (err error) { if e.ID == "" { e.ID = xid.New().String() } return } + +func (e *Event) AfterFind(tx *gorm.DB) (err error) { + if e.GroupID != 0 { + var name string + db.Raw("select name from groups where id = ?", e.GroupID).Scan(&name) + e.GroupName = name + } + return +} diff --git a/calendar/event_group.go b/calendar/event_group.go index 536fcd0..a383b97 100644 --- a/calendar/event_group.go +++ b/calendar/event_group.go @@ -5,20 +5,51 @@ import ( "github.com/rs/xid" "gorm.io/gorm" + "kumoly.io/kumoly/app/auth" + "kumoly.io/kumoly/app/errors" ) type EventGroup struct { ID string `gorm:"primaryKey"` + Name string + Rrule string + Events []Event + Group auth.Group `json:"-"` + GroupName string `gorm:"-" json:"Group"` + GroupID uint `json:"-"` + CreatedAt time.Time UpdatedAt time.Time } +func (eg *EventGroup) BeforeSave(tx *gorm.DB) (err error) { + if eg.GroupName != "" { + var grp_id uint + db.Raw("select id from groups where name = ?", eg.GroupName).Scan(&grp_id) + if grp_id == 0 { + return errors.ErrorNotFound + } + eg.GroupID = grp_id + } else { + eg.GroupID = 0 + } + return +} func (eg *EventGroup) BeforeCreate(tx *gorm.DB) (err error) { if eg.ID == "" { eg.ID = xid.New().String() } return } + +func (eg *EventGroup) AfterFind(tx *gorm.DB) (err error) { + if eg.GroupID != 0 { + var name string + db.Raw("select name from groups where id = ?", eg.GroupID).Scan(&name) + eg.GroupName = name + } + return +} diff --git a/calendar/ics.go b/calendar/ics.go new file mode 100644 index 0000000..e56f53f --- /dev/null +++ b/calendar/ics.go @@ -0,0 +1,74 @@ +package calendar + +import ( + "bytes" + "fmt" + "text/template" + "time" + + "kumoly.io/kumoly/app/store" +) + +const ics_base = `BEGIN:VCALENDAR +PRODID:-//Kumoly//Kumoly App v0.0.1//EN +VERSION:2.0 +CALSCALE:GREGORIAN +METHOD:PUBLISH +X-WR-CALNAME:{{.Name}} +X-WR-TIMEZONE:Asia/Taipei +X-WR-CALDESC:{{.Name}} +BEGIN:VTIMEZONE +TZID:{{.Timezone}} +X-LIC-LOCATION:{{.Timezone}} +BEGIN:STANDARD +TZOFFSETFROM:+0800 +TZOFFSETTO:+0800 +TZNAME:CST +DTSTART:19700101T000000 +END:STANDARD +END:VTIMEZONE +{{with .Events}}{{ if gt (len .) 0 }}{{ range $event := . }} +BEGIN:VEVENT +DTSTART:{{$event.Start|icstime}} +DTEND:{{$event.End|icstime}} +DTSTAMP:{{now|icstime}} +UID:{{$event.ID}} +CREATED:{{$event.CreatedAt|icstime}} +DESCRIPTION:{{$event.ID}} +LAST-MODIFIED:{{$event.UpdatedAt|icstime}} +LOCATION:{{$event.Location}} +SEQUENCE:0 +STATUS:CONFIRMED +SUMMARY:{{$event.Name}} +TRANSP:TRANSPARENT +END:VEVENT +{{end}}{{end}}{{end}} +END:VCALENDAR` + +var tmpl *template.Template + +func init() { + tmpl = template.Must(template.New("").Funcs(template.FuncMap{ + "icstime": icstime, + "now": func() time.Time { return time.Now() }, + }).Parse(ics_base)) +} + +func ICS(cal_id string, from time.Time, to time.Time) (string, error) { + cal := &Calendar{} + store.DB.Preload("Events", "start > ? and end < ?", from, to). + First(cal, "id = ?", cal_id) + buf := &bytes.Buffer{} + if err := tmpl.Execute(buf, cal); err != nil { + return "", err + } + return buf.String(), nil +} + +func icstime(t time.Time) string { + return t.UTC().Format("20060102T150405Z") +} + +func offsetStr(offset int) string { + return fmt.Sprintf("%+05d", offset/36) +} diff --git a/calendar/service.go b/calendar/service.go index ac3e559..b969f5e 100644 --- a/calendar/service.go +++ b/calendar/service.go @@ -1,6 +1,7 @@ package calendar import ( + "kumoly.io/kumoly/app/server" "kumoly.io/kumoly/app/store" "kumoly.io/kumoly/app/util" ) @@ -15,6 +16,7 @@ func (srv Service) Del() {} func (srv Service) Health() error { return nil } func (srv Service) Init() error { + db = store.DB l = util.Klog.With().Str("mod", "auth").Logger() l.Debug().Msg("Migrating database for calendar.Service ...") @@ -25,4 +27,18 @@ func (srv Service) Init() error { return nil } -func (srv Service) Load() error { return nil } +func (srv Service) Load() error { + calApi := server.API.Group("cal") + calApi.GET("", ApiCalQuery) + calApi.POST("", ApiCalNew) + calApi.PUT("", ApiCalUpdate) + calApi.DELETE("", ApiCalDelete) + + eventApi := server.API.Group("event") + eventApi.GET("", ApiEventQuery) + eventApi.POST("", ApiEventNew) + eventApi.PUT("", ApiEventUpdate) + eventApi.DELETE("", ApiEventDelete) + + return nil +} diff --git a/calendar/util.go b/calendar/util.go new file mode 100644 index 0000000..1ba8a3a --- /dev/null +++ b/calendar/util.go @@ -0,0 +1,35 @@ +package calendar + +import ( + "github.com/gin-gonic/gin" + "kumoly.io/kumoly/app/auth" + "kumoly.io/kumoly/app/errors" +) + +func HasCalAccess(c *gin.Context, cal *Calendar, cid string) error { + err := db.First(cal, "id = ?", cid).Error + if err != nil { + return errors.NewError(404, err) + } + if cal.GroupName == "" { + return nil + } + if !auth.ACHas(c, auth.ADMIN, auth.SYSTEM, cal.GroupName) { + return errors.ErrorForbidden + } + return nil +} + +func HasEventAccess(c *gin.Context, e *Event, cid string) error { + err := db.First(e, "id = ?", cid).Error + if err != nil { + return errors.NewError(404, err) + } + if e.GroupName == "" { + return nil + } + if !auth.ACHas(c, auth.ADMIN, auth.SYSTEM, e.GroupName) { + return errors.ErrorForbidden + } + return nil +} diff --git a/helper.go b/helper.go index 4b1bace..f58bb72 100644 --- a/helper.go +++ b/helper.go @@ -4,6 +4,7 @@ import ( "github.com/spf13/viper" "kumoly.io/kumoly/app/attribute" "kumoly.io/kumoly/app/auth" + "kumoly.io/kumoly/app/calendar" "kumoly.io/kumoly/app/control" "kumoly.io/kumoly/app/email" "kumoly.io/kumoly/app/history" @@ -30,6 +31,7 @@ func Default() *system.System { &history.Service{}, &email.Service{}, &control.Service{}, + &calendar.Service{}, ) return sys }