[rotang] Add a handler for Deleting rotations.
Bug: 845549
Change-Id: I002a74b4d01a439b1658e8d76a67e610203e4268
Reviewed-on: https://chromium-review.googlesource.com/1227863
Reviewed-by: Ola Karlsson <olakar@chromium.org>
Reviewed-by: Tiffany Zhang <zhangtiff@chromium.org>
Commit-Queue: Ola Karlsson <olakar@chromium.org>
Cr-Commit-Position: refs/heads/master@{#17687}diff --git a/cmd/app/app.go b/cmd/app/app.go
index 924e2d7..9d8812e 100644
--- a/cmd/app/app.go
+++ b/cmd/app/app.go
@@ -17,7 +17,9 @@
"golang.org/x/net/context"
"go.chromium.org/gae/service/mail"
+ "go.chromium.org/luci/appengine/gaeauth/server"
"go.chromium.org/luci/appengine/gaemiddleware/standard"
+ "go.chromium.org/luci/server/auth"
"go.chromium.org/luci/server/router"
"go.chromium.org/luci/server/templates"
)
@@ -51,7 +53,7 @@
tmw := middleware.Extend(templates.WithTemplates(&templates.Bundle{
Loader: templates.FileSystemLoader("../handlers/templates"),
- }))
+ }), auth.Authenticate(server.UsersAPIAuthMethod{}))
// Sort out the generators.
gs := algo.New()
@@ -74,8 +76,10 @@
r.GET("/upload", tmw, h.HandleUpload)
r.GET("/list", tmw, h.HandleList)
r.GET("/createrota", tmw, h.HandleCreateRota)
+ r.GET("/managerota", tmw, h.HandleManageRota)
r.POST("/createrota", tmw, h.HandleCreateRota)
+ r.POST("/deleterota", tmw, h.HandleDeleteRota)
r.POST("/upload", tmw, h.HandleUpload)
http.DefaultServeMux.Handle("/", r)
diff --git a/cmd/handlers/handle_createrota.go b/cmd/handlers/handle_createrota.go
index 7a9ac50..fa1cc9d 100644
--- a/cmd/handlers/handle_createrota.go
+++ b/cmd/handlers/handle_createrota.go
@@ -12,8 +12,8 @@
"strings"
"time"
- "go.chromium.org/gae/service/user"
"go.chromium.org/luci/common/logging"
+ "go.chromium.org/luci/server/auth"
"go.chromium.org/luci/server/router"
"go.chromium.org/luci/server/templates"
"google.golang.org/grpc/codes"
@@ -130,7 +130,7 @@
http.Error(ctx.Writer, err.Error(), http.StatusInternalServerError)
return
}
- usr := user.Current(ctx.Context)
+ usr := auth.CurrentUser(ctx.Context)
if usr == nil {
http.Error(ctx.Writer, "login required", http.StatusForbidden)
return
@@ -162,7 +162,7 @@
return
}
- usr := user.Current(ctx.Context)
+ usr := auth.CurrentUser(ctx.Context)
if usr == nil {
http.Error(ctx.Writer, "Login required", http.StatusForbidden)
return
diff --git a/cmd/handlers/handle_createrota_test.go b/cmd/handlers/handle_createrota_test.go
index 0a6b792..6e94b07 100644
--- a/cmd/handlers/handle_createrota_test.go
+++ b/cmd/handlers/handle_createrota_test.go
@@ -11,7 +11,9 @@
"time"
"github.com/kylelemons/godebug/pretty"
- "go.chromium.org/gae/service/user"
+ "go.chromium.org/luci/auth/identity"
+ "go.chromium.org/luci/server/auth"
+ "go.chromium.org/luci/server/auth/authtest"
"go.chromium.org/luci/server/router"
"go.chromium.org/luci/server/templates"
"golang.org/x/net/context"
@@ -380,21 +382,21 @@
t.Fatalf("New failed: %v", err)
}
- tu := user.GetTestable(ctx)
-
for _, tst := range tests {
t.Run(tst.name, func(t *testing.T) {
for _, m := range tst.memberPool {
if err := h.memberStore(ctx).CreateMember(ctx, &m); err != nil {
t.Fatalf("%s: CreateMember(ctx, _) failed: %v", tst.name, err)
}
+ defer h.memberStore(ctx).DeleteMember(ctx, m.Email)
}
tst.ctx.Context = templates.Use(tst.ctx.Context, &templates.Bundle{
Loader: templates.FileSystemLoader(templatesLocation),
}, nil)
if tst.user != "" {
- tu.Login(tst.user, "", false)
- defer tu.Logout()
+ tst.ctx.Context = auth.WithState(tst.ctx.Context, &authtest.FakeState{
+ Identity: identity.Identity("user:" + tst.user),
+ })
}
h.HandleCreateRota(tst.ctx)
@@ -411,8 +413,6 @@
ctxCancel, cancel := context.WithCancel(ctx)
cancel()
- tu := user.GetTestable(ctx)
-
testTime, err := time.Parse("15:04", "13:37")
if err != nil {
t.Fatalf("time.Parse() failed: %v", err)
@@ -573,8 +573,9 @@
Loader: templates.FileSystemLoader(templatesLocation),
}, nil)
if tst.user != "" {
- tu.Login(tst.user, "", false)
- defer tu.Logout()
+ tst.ctx.Context = auth.WithState(tst.ctx.Context, &authtest.FakeState{
+ Identity: identity.Identity("user:" + tst.user),
+ })
}
tst.ctx.Request = httptest.NewRequest("POST", "/createrota", nil)
tst.ctx.Request.Form = tst.values
diff --git a/cmd/handlers/handle_deleterota.go b/cmd/handlers/handle_deleterota.go
new file mode 100644
index 0000000..a344a99
--- /dev/null
+++ b/cmd/handlers/handle_deleterota.go
@@ -0,0 +1,69 @@
+package handlers
+
+import (
+ "net/http"
+
+ "go.chromium.org/luci/server/auth"
+ "go.chromium.org/luci/server/router"
+)
+
+// HandleDeleteRota handles deletion of a rotation configuration
+func (h *State) HandleDeleteRota(ctx *router.Context) {
+ if err := ctx.Context.Err(); err != nil {
+ http.Error(ctx.Writer, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ if ctx.Request.Method != "POST" {
+ http.Error(ctx.Writer, "HandleDeleteRota handles only POST requests", http.StatusBadRequest)
+ return
+ }
+
+ if err := ctx.Request.ParseForm(); err != nil {
+ http.Error(ctx.Writer, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ rotaName := ctx.Request.FormValue("name")
+ if rotaName == "" {
+ http.Error(ctx.Writer, "`name` not set", http.StatusBadRequest)
+ return
+ }
+ rotas, err := h.configStore(ctx.Context).RotaConfig(ctx.Context, rotaName)
+ if err != nil {
+ http.Error(ctx.Writer, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ if len(rotas) != 1 {
+ http.Error(ctx.Writer, "Unexpected number of rotations returned", http.StatusInternalServerError)
+ return
+ }
+ rota := rotas[0]
+
+ usr := auth.CurrentUser(ctx.Context)
+ if usr == nil {
+ http.Error(ctx.Writer, "login required", http.StatusForbidden)
+ return
+ }
+
+ isOwner := false
+ for _, o := range rota.Config.Owners {
+ if o == usr.Email {
+ isOwner = true
+ break
+ }
+ }
+
+ if !isOwner {
+ http.Error(ctx.Writer, "not in the rotation owners", http.StatusForbidden)
+ return
+ }
+
+ if err := h.configStore(ctx.Context).DeleteRotaConfig(ctx.Context, rotaName); err != nil {
+ http.Error(ctx.Writer, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ http.Redirect(ctx.Writer, ctx.Request, h.selfURL+"/managerota", http.StatusOK)
+}
diff --git a/cmd/handlers/handle_deleterota_test.go b/cmd/handlers/handle_deleterota_test.go
new file mode 100644
index 0000000..5e89aec
--- /dev/null
+++ b/cmd/handlers/handle_deleterota_test.go
@@ -0,0 +1,207 @@
+package handlers
+
+import (
+ "infra/appengine/rotang"
+ "infra/appengine/rotang/pkg/algo"
+ "infra/appengine/rotang/pkg/datastore"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "sort"
+ "testing"
+
+ "github.com/kylelemons/godebug/pretty"
+ "go.chromium.org/luci/auth/identity"
+ "go.chromium.org/luci/server/auth"
+ "go.chromium.org/luci/server/auth/authtest"
+ "go.chromium.org/luci/server/router"
+ "go.chromium.org/luci/server/templates"
+ "golang.org/x/net/context"
+)
+
+func TestHandleDeleteRota(t *testing.T) {
+ ctx := newTestContext()
+ ctxCancel, cancel := context.WithCancel(ctx)
+ cancel()
+
+ tests := []struct {
+ name string
+ fail bool
+ rotaName string
+ user string
+ ctx *router.Context
+ cfg []rotang.Configuration
+ want []string
+ }{{
+ name: "Context canceled",
+ fail: true,
+ ctx: &router.Context{
+ Context: ctxCancel,
+ Writer: httptest.NewRecorder(),
+ },
+ }, {
+ name: "Delete success",
+ ctx: &router.Context{
+ Context: ctx,
+ Writer: httptest.NewRecorder(),
+ },
+ rotaName: "Test Rota",
+ user: "test@user.com",
+ cfg: []rotang.Configuration{
+ {
+ Config: rotang.Config{
+ Name: "Test Rota",
+ Owners: []string{"test@user.com", "another@user.com"},
+ },
+ }, {
+ Config: rotang.Config{
+ Name: "Another Rota",
+ Owners: []string{"test@user.com", "another@user.com"},
+ },
+ },
+ },
+ want: []string{"Another Rota"},
+ }, {
+ name: "Not owner",
+ ctx: &router.Context{
+ Context: ctx,
+ Writer: httptest.NewRecorder(),
+ },
+ rotaName: "Test Rota",
+ fail: true,
+ user: "test@user.com",
+ cfg: []rotang.Configuration{
+ {
+ Config: rotang.Config{
+ Name: "Test Rota",
+ Owners: []string{"another@user.com"},
+ },
+ }, {
+ Config: rotang.Config{
+ Name: "Another Rota",
+ Owners: []string{"test@user.com", "another@user.com"},
+ },
+ },
+ },
+ }, {
+ name: "Not logged in",
+ ctx: &router.Context{
+ Context: ctx,
+ Writer: httptest.NewRecorder(),
+ },
+ rotaName: "Test Rota",
+ fail: true,
+ cfg: []rotang.Configuration{
+ {
+ Config: rotang.Config{
+ Name: "Test Rota",
+ Owners: []string{"test@user.com", "another@user.com"},
+ },
+ }, {
+ Config: rotang.Config{
+ Name: "Another Rota",
+ Owners: []string{"test@user.com", "another@user.com"},
+ },
+ },
+ },
+ }, {
+ name: "No rota name set",
+ ctx: &router.Context{
+ Context: ctx,
+ Writer: httptest.NewRecorder(),
+ },
+ fail: true,
+ cfg: []rotang.Configuration{
+ {
+ Config: rotang.Config{
+ Name: "Test Rota",
+ Owners: []string{"test@user.com", "another@user.com"},
+ },
+ }, {
+ Config: rotang.Config{
+ Name: "Another Rota",
+ Owners: []string{"test@user.com", "another@user.com"},
+ },
+ },
+ },
+ }, {
+ name: "Rota does not exist",
+ ctx: &router.Context{
+ Context: ctx,
+ Writer: httptest.NewRecorder(),
+ },
+ rotaName: "Non Existing",
+ fail: true,
+ cfg: []rotang.Configuration{
+ {
+ Config: rotang.Config{
+ Name: "Test Rota",
+ Owners: []string{"test@user.com", "another@user.com"},
+ },
+ }, {
+ Config: rotang.Config{
+ Name: "Another Rota",
+ Owners: []string{"test@user.com", "another@user.com"},
+ },
+ },
+ },
+ },
+ }
+
+ opts := Options{
+ URL: "http://localhost:8080",
+ Generators: &algo.Generators{},
+ }
+ setupStoreHandlers(&opts, datastore.New)
+ h, err := New(&opts)
+ if err != nil {
+ t.Fatalf("New failed: %v", err)
+ }
+
+ for _, tst := range tests {
+ t.Run(tst.name, func(t *testing.T) {
+ for _, c := range tst.cfg {
+ if err := h.configStore(ctx).CreateRotaConfig(ctx, &c); err != nil {
+ t.Fatalf("%s: CreateRotaConfig(ctx, _) failed: %v", tst.name, err)
+ }
+ defer h.configStore(ctx).DeleteRotaConfig(ctx, c.Config.Name)
+ }
+ tst.ctx.Context = templates.Use(tst.ctx.Context, &templates.Bundle{
+ Loader: templates.FileSystemLoader(templatesLocation),
+ }, nil)
+ if tst.user != "" {
+ tst.ctx.Context = auth.WithState(tst.ctx.Context, &authtest.FakeState{
+ Identity: identity.Identity("user:" + tst.user),
+ })
+ }
+ tst.ctx.Request = httptest.NewRequest("POST", "/deleterota", nil)
+ tst.ctx.Request.Form = url.Values{
+ "name": {tst.rotaName},
+ }
+ h.HandleDeleteRota(tst.ctx)
+
+ recorder := tst.ctx.Writer.(*httptest.ResponseRecorder)
+ if got, want := (recorder.Code != http.StatusOK), tst.fail; got != want {
+ t.Fatalf("%s: HandleDeleteRota(ctx) = %t want: %t , res: %v", tst.name, got, want, recorder.Body)
+ }
+ if recorder.Code != http.StatusOK {
+ return
+ }
+
+ rs, err := h.configStore(ctx).RotaConfig(ctx, "")
+ if err != nil {
+ t.Fatalf("%s: RotaConfig(ctx, %q) failed: %v", tst.name, "", err)
+ }
+ var got []string
+ for _, c := range rs {
+ got = append(got, c.Config.Name)
+ }
+ sort.Strings(got)
+ sort.Strings(tst.want)
+ if diff := pretty.Compare(tst.want, got); diff != "" {
+ t.Errorf("%s: HandleDeleteRota(ctx) differ -want +got, %s", tst.name, diff)
+ }
+
+ })
+ }
+}
diff --git a/cmd/handlers/handle_index.go b/cmd/handlers/handle_index.go
index d58b4d3..a3eb718 100644
--- a/cmd/handlers/handle_index.go
+++ b/cmd/handlers/handle_index.go
@@ -8,7 +8,7 @@
"infra/appengine/rotang"
"net/http"
- "go.chromium.org/gae/service/user"
+ "go.chromium.org/luci/server/auth"
"go.chromium.org/luci/server/router"
"go.chromium.org/luci/server/templates"
"google.golang.org/grpc/codes"
@@ -27,7 +27,7 @@
Oncallers []rotang.ShiftMember
}{}
- usr := user.Current(ctx.Context)
+ usr := auth.CurrentUser(ctx.Context)
if usr == nil {
templates.MustRender(ctx.Context, ctx.Writer, "pages/index.html", templates.Args{"Rotas": res})
return
diff --git a/cmd/handlers/handle_index_test.go b/cmd/handlers/handle_index_test.go
index 80c3cc2..ea8ad7e 100644
--- a/cmd/handlers/handle_index_test.go
+++ b/cmd/handlers/handle_index_test.go
@@ -13,8 +13,9 @@
"net/http/httptest"
"testing"
- "go.chromium.org/gae/service/user"
-
+ "go.chromium.org/luci/auth/identity"
+ "go.chromium.org/luci/server/auth"
+ "go.chromium.org/luci/server/auth/authtest"
"go.chromium.org/luci/server/router"
"go.chromium.org/luci/server/templates"
)
@@ -36,8 +37,6 @@
ctxCancel, cancel := context.WithCancel(ctx)
cancel()
- tt := user.GetTestable(ctx)
-
tests := []struct {
name string
fail bool
@@ -136,8 +135,9 @@
Loader: templates.FileSystemLoader(templatesLocation),
}, nil)
if tst.email != "" {
- tt.Login(tst.email, "", false)
- defer tt.Logout()
+ tst.ctx.Context = auth.WithState(tst.ctx.Context, &authtest.FakeState{
+ Identity: identity.Identity("user:" + tst.email),
+ })
}
h.HandleIndex(tst.ctx)
diff --git a/cmd/handlers/handle_managerota.go b/cmd/handlers/handle_managerota.go
new file mode 100644
index 0000000..26a32f6
--- /dev/null
+++ b/cmd/handlers/handle_managerota.go
@@ -0,0 +1,43 @@
+// Copyright 2018 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package handlers
+
+import (
+ "infra/appengine/rotang"
+ "net/http"
+
+ "go.chromium.org/gae/service/user"
+ "go.chromium.org/luci/server/router"
+ "go.chromium.org/luci/server/templates"
+)
+
+// HandleManageRota handles the rota management interface.
+func (h *State) HandleManageRota(ctx *router.Context) {
+ if err := ctx.Context.Err(); err != nil {
+ http.Error(ctx.Writer, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ usr := user.Current(ctx.Context)
+ if usr == nil {
+ http.Error(ctx.Writer, "not logged in", http.StatusProxyAuthRequired)
+ return
+ }
+
+ rotas, err := h.configStore(ctx.Context).RotaConfig(ctx.Context, "")
+ if err != nil {
+ http.Error(ctx.Writer, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ var permRotas []*rotang.Configuration
+ for _, rota := range rotas {
+ for _, m := range rota.Config.Owners {
+ if usr.Email == m {
+ permRotas = append(permRotas, rota)
+ }
+ }
+ }
+
+ templates.MustRender(ctx.Context, ctx.Writer, "pages/managerota.html", templates.Args{"Rotas": permRotas})
+}
diff --git a/cmd/handlers/handle_managerota_test.go b/cmd/handlers/handle_managerota_test.go
new file mode 100644
index 0000000..9a5db87
--- /dev/null
+++ b/cmd/handlers/handle_managerota_test.go
@@ -0,0 +1,151 @@
+package handlers
+
+import (
+ "infra/appengine/rotang"
+ "infra/appengine/rotang/pkg/algo"
+ "infra/appengine/rotang/pkg/datastore"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "go.chromium.org/gae/service/user"
+ "go.chromium.org/luci/server/router"
+ "go.chromium.org/luci/server/templates"
+ "golang.org/x/net/context"
+)
+
+func TestHandleManageRota(t *testing.T) {
+ ctx := newTestContext()
+ ctxCancel, cancel := context.WithCancel(ctx)
+ cancel()
+
+ tests := []struct {
+ name string
+ fail bool
+ ctx *router.Context
+ cfg []rotang.Configuration
+ memberPool []rotang.Member
+ user string
+ }{{
+ name: "Context canceled",
+ fail: true,
+ user: "test@user.com",
+ ctx: &router.Context{
+ Context: ctxCancel,
+ Writer: httptest.NewRecorder(),
+ },
+ }, {
+ name: "Rotation success",
+ user: "test@user.com",
+ ctx: &router.Context{
+ Context: ctx,
+ Writer: httptest.NewRecorder(),
+ },
+ memberPool: []rotang.Member{
+ {
+ Email: "test@user.com",
+ }, {
+ Email: "another@user.com",
+ },
+ },
+ cfg: []rotang.Configuration{
+ {
+ Config: rotang.Config{
+ Name: "Rota one",
+ Owners: []string{"test@user.com"},
+ },
+ }, {
+ Config: rotang.Config{
+ Name: "Rota Two",
+ Owners: []string{"test@user.com"},
+ },
+ },
+ },
+ }, {
+ name: "Not logged in",
+ fail: true,
+ ctx: &router.Context{
+ Context: ctx,
+ Writer: httptest.NewRecorder(),
+ },
+ memberPool: []rotang.Member{
+ {
+ Email: "test@user.com",
+ }, {
+ Email: "another@user.com",
+ },
+ },
+ cfg: []rotang.Configuration{
+ {
+ Config: rotang.Config{
+ Name: "Rota one",
+ Owners: []string{"test@user.com"},
+ },
+ }, {
+ Config: rotang.Config{
+ Name: "Rota Two",
+ Owners: []string{"test@user.com"},
+ },
+ },
+ },
+ }, {
+ name: "No rotations",
+ fail: true,
+ user: "test@user.com",
+ ctx: &router.Context{
+ Context: ctx,
+ Writer: httptest.NewRecorder(),
+ },
+ memberPool: []rotang.Member{
+ {
+ Email: "test@user.com",
+ }, {
+ Email: "another@user.com",
+ },
+ },
+ },
+ }
+
+ opts := Options{
+ URL: "http://localhost:8080",
+ Generators: &algo.Generators{},
+ }
+ setupStoreHandlers(&opts, datastore.New)
+ h, err := New(&opts)
+ if err != nil {
+ t.Fatalf("New failed: %v", err)
+ }
+
+ tu := user.GetTestable(ctx)
+
+ for _, tst := range tests {
+ t.Run(tst.name, func(t *testing.T) {
+ for _, m := range tst.memberPool {
+ if err := h.memberStore(ctx).CreateMember(ctx, &m); err != nil {
+ t.Fatalf("%s: CreateMember(ctx, _) failed: %v", tst.name, err)
+ }
+ defer h.memberStore(ctx).DeleteMember(ctx, m.Email)
+ }
+ for _, c := range tst.cfg {
+ if err := h.configStore(ctx).CreateRotaConfig(ctx, &c); err != nil {
+ t.Fatalf("%s: CreateRotaConfig(ctx, _) failed: %v", tst.name, err)
+ }
+ defer h.configStore(ctx).DeleteRotaConfig(ctx, c.Config.Name)
+ }
+ tst.ctx.Context = templates.Use(tst.ctx.Context, &templates.Bundle{
+ Loader: templates.FileSystemLoader(templatesLocation),
+ }, nil)
+ if tst.user != "" {
+ tu.Login(tst.user, "", false)
+ }
+ defer tu.Logout()
+
+ h.HandleManageRota(tst.ctx)
+
+ recorder := tst.ctx.Writer.(*httptest.ResponseRecorder)
+ if got, want := (recorder.Code != http.StatusOK), tst.fail; got != want {
+ t.Fatalf("%s: HandleManageRota(ctx) = %t want: %t, res: %v", tst.name, got, want, recorder.Body)
+ }
+ })
+ }
+}
diff --git a/cmd/handlers/templates/pages/index.html b/cmd/handlers/templates/pages/index.html
index 81b31a7..bd14a86 100644
--- a/cmd/handlers/templates/pages/index.html
+++ b/cmd/handlers/templates/pages/index.html
@@ -14,7 +14,7 @@
</p>
<a href="upload">Upload Legacy JSON rota configuraton</a><br>
<a href="list">List configurations in backend store</a><br>
-<a href="createrota">Create a rotation</a><br>
+<a href="managerota">Manage rotations</a><br>
{{if .Rotas}}
<h2>Rotations you are a member of</h2>
diff --git a/cmd/handlers/templates/pages/managerota.html b/cmd/handlers/templates/pages/managerota.html
new file mode 100644
index 0000000..ac58183
--- /dev/null
+++ b/cmd/handlers/templates/pages/managerota.html
@@ -0,0 +1,48 @@
+<!doctype html>
+<html>
+<head>
+ <title>Modify Rotation</title>
+</head>
+<body>
+
+<h2>Rotations you manage </h2>
+
+{{if not .Rotas}}
+<i>No rotas managed</i>
+{{end}}
+
+{{with .Rotas}}
+<h3>Rota table</h3>
+<table class="table">
+ <thead>
+ <tr>
+ <th>Name</th>
+ </tr>
+ </thead>
+ <tbody>
+ {{range $c := .}}
+ <tr>
+ <td>{{$c.Config.Name}}</td>
+ <td>
+ <form action="modifyrota" method="post">
+ <button type="submit" name="name" value="{{$c.Config.Name}}">Modify</button>
+ </form>
+ </td>
+ <td>
+ <form action="deleterota" method="post">
+ <button type="submit" name="name" value="{{$c.Config.Name}}">Delete</button>
+ </form>
+ </td>
+ </tr>
+ {{end}}
+ </tbody>
+</table>
+{{end}}
+
+<br>
+<br>
+
+<a href="createrota">Create new rotation</a><br>
+
+</body>
+</html>
diff --git a/rotang.go b/rotang.go
index cf8a042..a44e36d 100644
--- a/rotang.go
+++ b/rotang.go
@@ -33,7 +33,7 @@
Description string
Calendar string
TokenID string
- Owners []string
+ Owners []string // TODO(olakar): Change from email to groups.
Email Email
DaysToSchedule int
Shifts ShiftConfig