Add events (#777)

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/api#777
Co-authored-by: konrad <konrad@kola-entertainments.de>
Co-committed-by: konrad <konrad@kola-entertainments.de>
This commit is contained in:
konrad 2021-02-02 22:48:37 +00:00
parent a71aa0c898
commit 0ab9ce9ec4
70 changed files with 1636 additions and 283 deletions

View File

@ -1,5 +1,5 @@
run:
timeout: 5m
timeout: 15m
tests: true
linters:

View File

@ -136,6 +136,10 @@ log:
http: "stdout"
# Echo has its own logging which usually is unnessecary, which is why it is disabled by default. Possible values are stdout, stderr, file or off to disable standard logging.
echo: "off"
# Whether or not to log events. Useful for debugging. Possible values are stdout, stderr, file or off to disable events logging.
events: "stdout"
# The log level for event log messages. Possible values (case-insensitive) are ERROR, INFO, DEBUG.
eventslevel: "info"
ratelimit:
# whether or not to enable the rate limit

View File

@ -0,0 +1,195 @@
---
date: 2018-10-13T19:26:34+02:00
title: "Events and Listeners"
draft: false
menu:
sidebar:
parent: "development"
---
# Events and Listeners
Vikunja provides a simple observer pattern mechanism through events and listeners.
The basic principle of events is always the same: Something happens (=An event is fired) and something reacts to it (=A listener is called).
Vikunja supports this principle through the `events` package.
It is built upon the excellent [watermill](https://watermill.io) library.
Currently, it only supports dispatching events through Go Channels which makes it configuration-less.
More methods of dispatching events (like kafka or rabbitmq) are available in watermill and could be enabled with a PR.
This document explains how events and listeners work in Vikunja, how to use them and how to create new ones.
{{< table_of_contents >}}
## Events
### Definition
Each event has to implement this interface:
```golang
type Event interface {
Name() string
}
```
An event can contain whatever data you need.
When an event is dispatched, all of the data it contains will be marshaled into json for dispatching.
You then get the event with all its data back in the listener, see below.
#### Naming Convention
Event names should roughly have the entity they're dealing with on the left and the action on the right of the name, separated by `.`.
There's no limit to how "deep" or specifig an event name can be.
The name should have the most general concept it's describing at the left, getting more specific on the right of it.
#### Location
All events for a package should be declared in the `events.go` file of that package.
### Creating a New Event
The easiest way to create a new event is to generate it with mage:
```
mage dev:make-event <event-name> <package>
```
The function takes the name of the event as the first argument and the package where the event should be created as the second argument.
Events will be appended to the `pkg/<module>/events.go` file.
Both parameters are mandatory.
The event type name is automatically camel-cased and gets the `Event` suffix if the provided name does not already have one.
The event name is derived from the type name and stripped of the `.event` suffix.
The generated event will look something like the example below.
### Dispatching events
To dispatch an event, simply call the `events.Dispatch` method and pass in the event as parameter.
### Example
The `TaskCreatedEvent` is declared in the `pkg/models/events.go` file as follows:
```golang
// TaskCreatedEvent represents an event where a task has been created
type TaskCreatedEvent struct {
Task *Task
Doer web.Auth
}
// Name defines the name for TaskCreatedEvent
func (t *TaskCreatedEvent) Name() string {
return "task.created"
}
```
It is dispatched in the `createTask` function of the `models` package:
```golang
func createTask(s *xorm.Session, t *Task, a web.Auth, updateAssignees bool) (err error) {
// ...
err = events.Dispatch(&TaskCreatedEvent{
Task: t,
Doer: a,
})
// ...
}
```
As you can see, the curent task and doer are injected into it.
### Special Events
#### `BootedEvent`
Once Vikunja is fully initialized, right before the api web server is started, this event is fired.
## Listeners
A listener is a piece of code that gets executed asynchronously when an event is dispatched.
A single event can have multiple listeners who are independent of each other.
### Definition
All listeners must implement this interface:
```golang
// Listener represents something that listens to events
type Listener interface {
Handle(payload message.Payload) error
Name() string
}
```
The `Handle` method is executed when the event this listener listens on is dispatched.
* As the single parameter, it gets the payload of the event, which is the event struct when it was dispatched decoded as json object and passed as a slice of bytes.
To use it you'll need to unmarshal it. Unfortunately there's no way to pass an already populated event object to the function because we would not know what type it has when parsing it.
* If the handler returns an error, the listener is retried 5 times, with an exponentional back-off period in between retries.
If it still fails after the fifth retry, the event is nack'd and it's up to the event dispatcher to resend it.
You can learn more about this mechanism in the [watermill documentation](https://watermill.io/docs/middlewares/#retry).
The `Name` method needs to return a unique listener name for this listener.
It should follow the same convention as event names, see above.
### Creating a New Listener
The easiest way to create a new listener for an event is with mage:
```
mage dev:make-listener <listener-name> <event-name> <package>
```
This will create a new listener type in the `pkg/<package>/listners.go` file and implement the `Handle` and `Name` methods.
It will also pre-generate some boilerplate code to unmarshal the event from the payload.
Furthermore, it will register the listener for its event in the `RegisterListeners()` method of the same file.
This function is called at startup and has to contain all events you want to listen for.
### Listening for Events
To listen for an event, you need to register the listener for the event it should be called for.
This usually happens in the `RegisterListeners()` method in `pkg/<package>/listners.go` which is called at start up.
The listener will never be executed if it hasn't been registered.
See the example below.
### Example
```golang
// RegisterListeners registers all event listeners
func RegisterListeners() {
events.RegisterListener((&ListCreatedEvent{}).Name(), &IncreaseListCounter{})
}
// IncreaseTaskCounter represents a listener
type IncreaseTaskCounter struct {}
// Name defines the name for the IncreaseTaskCounter listener
func (s *IncreaseTaskCounter) Name() string {
return "task.counter.increase"
}
// Hanlde is executed when the event IncreaseTaskCounter listens on is fired
func (s *IncreaseTaskCounter) Handle(payload message.Payload) (err error) {
return keyvalue.IncrBy(metrics.TaskCountKey, 1)
}
```
## Testing
When testing, you should call the `events.Fake()` method in the `TestMain` function of the package you want to test.
This prevents any events from being fired and lets you assert an event has been dispatched like so:
```golang
events.AssertDispatched(t, &TaskCreatedEvent{})
```

View File

@ -446,6 +446,18 @@ Echo has its own logging which usually is unnessecary, which is why it is disabl
Default: `off`
### events
Whether or not to log events. Useful for debugging. Possible values are stdout, stderr, file or off to disable events logging.
Default: `stdout`
### eventslevel
The log level for event log messages. Possible values (case-insensitive) are ERROR, INFO, DEBUG.
Default: `info`
---
## ratelimit

2
docs/themes/vikunja vendored

@ -1 +1 @@
Subproject commit 958219fc84db455ed58d7a4380bbffc8d04fd5cf
Subproject commit 1ebcbbb645ad20ea683feef2804314a6c658799b

14
go.mod
View File

@ -18,9 +18,9 @@ module code.vikunja.io/api
require (
4d63.com/tz v1.2.0
code.vikunja.io/web v0.0.0-20201223143420-588abb73703a
dmitri.shuralyov.com/go/generated v0.0.0-20170818220700-b1254a446363 // indirect
code.vikunja.io/web v0.0.0-20210131201003-26386be9a9ae
gitea.com/xorm/xorm-redis-cache v0.2.0
github.com/ThreeDotsLabs/watermill v1.1.1
github.com/adlio/trello v1.8.0
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751
github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef
@ -28,7 +28,6 @@ require (
github.com/c2h5oh/datasize v0.0.0-20200825124411-48ed595a09d2
github.com/client9/misspell v0.3.4
github.com/coreos/go-oidc v2.2.1+incompatible
github.com/coreos/go-oidc/v3 v3.0.0
github.com/cweill/gotests v1.6.0
github.com/d4l3k/messagediff v1.2.1
github.com/dgrijalva/jwt-go v3.2.0+incompatible
@ -37,7 +36,7 @@ require (
github.com/fzipp/gocyclo v0.3.1
github.com/gabriel-vasile/mimetype v1.1.2
github.com/getsentry/sentry-go v0.9.0
github.com/go-errors/errors v1.1.1
github.com/go-errors/errors v1.1.1 // indirect
github.com/go-redis/redis/v8 v8.4.11
github.com/go-sql-driver/mysql v1.5.0
github.com/go-testfixtures/testfixtures/v3 v3.5.0
@ -82,8 +81,9 @@ require (
golang.org/x/net v0.0.0-20201216054612-986b41b23924 // indirect
golang.org/x/oauth2 v0.0.0-20210201163806-010130855d6c
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a
golang.org/x/sys v0.0.0-20201223074533-0d417f636930 // indirect
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c // indirect
golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf
golang.org/x/text v0.3.5 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
gopkg.in/d4l3k/messagediff.v1 v1.2.1
@ -94,9 +94,9 @@ require (
honnef.co/go/tools v0.0.1-2020.1.5
src.techknowlogick.com/xgo v1.2.1-0.20201205054505-b97762e7a76b
src.techknowlogick.com/xormigrate v1.4.0
xorm.io/builder v0.3.7
xorm.io/builder v0.3.8
xorm.io/core v0.7.3
xorm.io/xorm v1.0.5
xorm.io/xorm v1.0.7
)
replace (

80
go.sum
View File

@ -38,14 +38,8 @@ cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0Zeo
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
code.vikunja.io/web v0.0.0-20201218134444-505d0e77fac7 h1:iS3TFA+y1If6DEbqzad5Ge7TI1NxZr9BevC/dU4ygEo=
code.vikunja.io/web v0.0.0-20201218134444-505d0e77fac7/go.mod h1:vDWiCtftF6LNCCrem7mjstPWMgzLUvMW/L4YwIQ1Voo=
code.vikunja.io/web v0.0.0-20201222144643-6fa2fb587215 h1:O5zMWgcnVDVLaQUawgdsv/jX/4SUUAvSedvRR+5+x2o=
code.vikunja.io/web v0.0.0-20201222144643-6fa2fb587215/go.mod h1:OgFO06HN1KpA4S7Dw/QAIeygiUPSeGJJn1ykz/sjZdU=
code.vikunja.io/web v0.0.0-20201223143420-588abb73703a h1:LaWCucY5Pp30EIMgGOvdVFNss5OhIAwrAO8PuFVRUfw=
code.vikunja.io/web v0.0.0-20201223143420-588abb73703a/go.mod h1:OgFO06HN1KpA4S7Dw/QAIeygiUPSeGJJn1ykz/sjZdU=
dmitri.shuralyov.com/go/generated v0.0.0-20170818220700-b1254a446363 h1:o4lAkfETerCnr1kF9/qwkwjICnU+YLHNDCM8h2xj7as=
dmitri.shuralyov.com/go/generated v0.0.0-20170818220700-b1254a446363/go.mod h1:WG7q7swWsS2f9PYpt5DoEP/EBYWx8We5UoRltn9vJl8=
code.vikunja.io/web v0.0.0-20210131201003-26386be9a9ae h1:qqgwoWjKrpIOdrIR0FPawiHLZTRYwS9MBgwH1eZJJqA=
code.vikunja.io/web v0.0.0-20210131201003-26386be9a9ae/go.mod h1:OgFO06HN1KpA4S7Dw/QAIeygiUPSeGJJn1ykz/sjZdU=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGqTOXqu2aRi/XEQxDCBwM8yJtE6s=
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU=
@ -72,6 +66,8 @@ github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdko
github.com/Shopify/goreferrer v0.0.0-20181106222321-ec9c9a553398/go.mod h1:a1uqRtAwp2Xwc6WNPJEufxJ7fx3npB4UV/JOLmbu5I0=
github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
github.com/ThreeDotsLabs/watermill v1.1.1 h1:+9NXqWQvplzxBru2CIInvVOZeKUnM+Nysg42fInl5sY=
github.com/ThreeDotsLabs/watermill v1.1.1/go.mod h1:Qd1xNFxolCAHCzcMrm6RnjW0manbvN+DJVWc1MWRFlI=
github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g=
github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c=
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
@ -111,7 +107,10 @@ github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBW
github.com/c2h5oh/datasize v0.0.0-20200825124411-48ed595a09d2 h1:t8KYCwSKsOEZBFELI4Pn/phbp38iJ1RRAkDFNin1aak=
github.com/c2h5oh/datasize v0.0.0-20200825124411-48ed595a09d2/go.mod h1:S/7n9copUssQ56c7aAgHqftWO4LTf4xY6CGWt8Bc+3M=
github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ=
github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
github.com/cenkalti/backoff/v3 v3.0.0 h1:ske+9nBpD9qZsTBoF41nW5L+AIuFBKMeze18XQ3eG1c=
github.com/cenkalti/backoff/v3 v3.0.0/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
@ -134,7 +133,6 @@ github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
github.com/coreos/go-oidc v2.2.1+incompatible h1:mh48q/BqXqgjVHpy2ZY7WnWAbenxRjsz9N1i1YxjHAk=
github.com/coreos/go-oidc v2.2.1+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc=
github.com/coreos/go-oidc/v3 v3.0.0/go.mod h1:rEJ/idjfUyfkBit1eI1fvyr+64/g9dcKpAm8MJMesvo=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-systemd/v22 v22.0.0/go.mod h1:xO0FLkIi5MaZafQlIrOotqXZ90ih+1atmu1JpKERPPk=
@ -147,8 +145,6 @@ github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/cweill/gotests v1.5.3 h1:k3t4wW/x/YNixWZJhUIn+mivmK5iV1tJVOwVYkx0UcU=
github.com/cweill/gotests v1.5.3/go.mod h1:XZYOJkGVkCRoymaIzmp9Wyi3rUgfA3oOnkuljYrjFV8=
github.com/cweill/gotests v1.6.0 h1:KJx+/p4EweijYzqPb4Y/8umDCip1Cv6hEVyOx0mE9W8=
github.com/cweill/gotests v1.6.0/go.mod h1:CaRYbxQZGQOxXDvM9l0XJVV2Tjb2E5H53vq+reR2GrA=
github.com/d4l3k/messagediff v1.2.1 h1:ZcAIMYsUg0EAp9X+tt8/enBE/Q8Yd5kzPynLyKptt9U=
@ -211,6 +207,8 @@ github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm
github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/3rZdM=
github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98=
github.com/go-chi/chi v4.0.2+incompatible h1:maB6vn6FqCxrpz4FqWdh4+lwpyZIQS7YEAUcHlgXVRs=
github.com/go-chi/chi v4.0.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w=
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
github.com/go-errors/errors v1.1.1 h1:ljK/pL5ltg3qoN+OtN6yCv9HWSfMwxSx90GJCZQxYNg=
@ -241,18 +239,6 @@ github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+
github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
github.com/go-redis/redis/v8 v8.4.2 h1:gKRo1KZ+O3kXRfxeRblV5Tr470d2YJZJVIAv2/S8960=
github.com/go-redis/redis/v8 v8.4.2/go.mod h1:A1tbYoHSa1fXwN+//ljcCYYJeLmVrwL9hbQN45Jdy0M=
github.com/go-redis/redis/v8 v8.4.4 h1:fGqgxCTR1sydaKI00oQf3OmkU/DIe/I/fYXvGklCIuc=
github.com/go-redis/redis/v8 v8.4.4/go.mod h1:nA0bQuF0i5JFx4Ta9RZxGKXFrQ8cRWntra97f0196iY=
github.com/go-redis/redis/v8 v8.4.6 h1:a4i+zYK6Mq2A2qAz0R6n6xo9dfnh7HdlRU/QvrlpfPI=
github.com/go-redis/redis/v8 v8.4.6/go.mod h1:nA0bQuF0i5JFx4Ta9RZxGKXFrQ8cRWntra97f0196iY=
github.com/go-redis/redis/v8 v8.4.7 h1:l0/Hkj3HLA46eJSvHGLhnF+KHBrmsyipK84ycJXFZxw=
github.com/go-redis/redis/v8 v8.4.7/go.mod h1:nA0bQuF0i5JFx4Ta9RZxGKXFrQ8cRWntra97f0196iY=
github.com/go-redis/redis/v8 v8.4.8 h1:sEG4g6Jq4hvQzbrNsVDNTDdxFCUnFC0jxuOp6tgALlA=
github.com/go-redis/redis/v8 v8.4.8/go.mod h1:/cTZsrSn1DPqRuOnSDuyH2OSvd9iX0iUGT0s7hYGIAg=
github.com/go-redis/redis/v8 v8.4.9 h1:ixEQSxNnzo6zh/dmoZIHl9DmyX3mHV5a2p6OasPR93k=
github.com/go-redis/redis/v8 v8.4.9/go.mod h1:d5yY/TlkQyYBSBHnXUmnf1OrHbyQere5JV4dLKwvXmo=
github.com/go-redis/redis/v8 v8.4.10 h1:fWdl0RBmVibUDOp8bqz1e2Yy9dShOeIeWsiAifYk06Y=
github.com/go-redis/redis/v8 v8.4.10/go.mod h1:d5yY/TlkQyYBSBHnXUmnf1OrHbyQere5JV4dLKwvXmo=
github.com/go-redis/redis/v8 v8.4.11 h1:t2lToev01VTrqYQcv+QFbxtGgcf64K+VUMgf9Ap6A/E=
github.com/go-redis/redis/v8 v8.4.11/go.mod h1:d5yY/TlkQyYBSBHnXUmnf1OrHbyQere5JV4dLKwvXmo=
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
@ -261,8 +247,6 @@ github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG
github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-testfixtures/testfixtures/v3 v3.4.1 h1:Qz9y0wUOXPHzKhK6C79A/menChtEu/xd0Dn5ngVyMD0=
github.com/go-testfixtures/testfixtures/v3 v3.4.1/go.mod h1:P4L3WxgOsCLbAeUC50qX5rdj1ULZfUMqgCbqah3OH5U=
github.com/go-testfixtures/testfixtures/v3 v3.5.0 h1:fFJGHhFdcwy48oTLHvr0WRQ09rGiZE+as9ElvbRWS+c=
github.com/go-testfixtures/testfixtures/v3 v3.5.0/go.mod h1:P4L3WxgOsCLbAeUC50qX5rdj1ULZfUMqgCbqah3OH5U=
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
@ -346,12 +330,12 @@ github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hf
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gordonklaus/ineffassign v0.0.0-20201107091007-3b93a8888063 h1:dKprcOvlsvqfWn/iGvz+oYuC2axESeSMuF8dDrWMNsE=
github.com/gordonklaus/ineffassign v0.0.0-20201107091007-3b93a8888063/go.mod h1:cuNKsD1zp2v6XfE/orVX2QE1LC+i254ceGcVeDT3pTU=
github.com/gordonklaus/ineffassign v0.0.0-20210104184537-8eed68eb605f h1:wHGrcNkjqm/QJeljJ9bkFWtND9I0ASarwpBlRcwRymk=
github.com/gordonklaus/ineffassign v0.0.0-20210104184537-8eed68eb605f/go.mod h1:cuNKsD1zp2v6XfE/orVX2QE1LC+i254ceGcVeDT3pTU=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
@ -369,10 +353,12 @@ github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBt
github.com/hashicorp/consul/api v1.3.0/go.mod h1:MmDNSzIMUjNpY/mQ398R4bk2FnqQLoPndWW5VkKPlCE=
github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
github.com/hashicorp/consul/sdk v0.3.0/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uPribsnS6o=
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU=
github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
@ -390,8 +376,6 @@ github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0m
github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg=
github.com/iancoleman/strcase v0.1.2 h1:gnomlvw9tnV3ITTAxzKSgTF+8kFWcU/f+TgttpXGz1U=
github.com/iancoleman/strcase v0.1.2/go.mod h1:SK73tn/9oHe+/Y0h39VT4UCxmurVJkR5NA7kMEAOgSE=
github.com/iancoleman/strcase v0.1.3 h1:dJBk1m2/qjL1twPLf68JND55vvivMupZ4wIzE8CTdBw=
github.com/iancoleman/strcase v0.1.3/go.mod h1:SK73tn/9oHe+/Y0h39VT4UCxmurVJkR5NA7kMEAOgSE=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
@ -526,9 +510,9 @@ github.com/lib/pq v1.9.0 h1:L8nSXQQzAYByakOFMTwpjRoHsMJklur4Gi59b6VivR8=
github.com/lib/pq v1.9.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM=
github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4=
github.com/lithammer/shortuuid/v3 v3.0.4 h1:uj4xhotfY92Y1Oa6n6HUiFn87CdoEHYUlTy0+IgbLrs=
github.com/lithammer/shortuuid/v3 v3.0.4/go.mod h1:RviRjexKqIzx/7r1peoAITm6m7gnif/h+0zmolKJjzw=
github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ=
github.com/magefile/mage v1.10.0 h1:3HiXzCUY12kh9bIuyXShaVe529fJfyqoVM42o/uom2g=
github.com/magefile/mage v1.10.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/magefile/mage v1.11.0 h1:C/55Ywp9BpgVVclD3lRnSYCwXTYxmSppIgLeDYlNuls=
github.com/magefile/mage v1.11.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY=
@ -570,8 +554,6 @@ github.com/mattn/go-sqlite3 v1.14.0 h1:mLyGNKR8+Vv9CAU7PphKa2hkEqxxhn8i32J6FPj1/
github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus=
github.com/mattn/go-sqlite3 v1.14.4 h1:4rQjbDxdu9fSgI/r3KN72G3c2goxknAqHHgPWWs8UlI=
github.com/mattn/go-sqlite3 v1.14.4/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI=
github.com/mattn/go-sqlite3 v1.14.5 h1:1IdxlwTNazvbKJQSxoJ5/9ECbEeaTTyeU7sEAZ5KKTQ=
github.com/mattn/go-sqlite3 v1.14.5/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI=
github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg=
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/goveralls v0.0.2/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw=
@ -612,6 +594,7 @@ github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs=
github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA=
github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4=
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
github.com/olekukonko/tablewriter v0.0.4 h1:vHD/YYe1Wolo78koG299f7V/VAS08c6IpCLn+Ejf/w8=
@ -859,8 +842,6 @@ go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opentelemetry.io/otel v0.14.0 h1:YFBEfjCk9MTjaytCNSUkp9Q8lF7QJezA06T71FbQxLQ=
go.opentelemetry.io/otel v0.14.0/go.mod h1:vH5xEuwy7Rts0GNtsCW3HYQoZDY+OmBJ6t1bFGGlxgw=
go.opentelemetry.io/otel v0.15.0 h1:CZFy2lPhxd4HlhZnYK8gRyDotksO3Ip9rBweY1vVYJw=
go.opentelemetry.io/otel v0.15.0/go.mod h1:e4GKElweB8W2gWUqbghw0B8t5MCTccc9212eNHnOHwA=
go.opentelemetry.io/otel v0.16.0 h1:uIWEbdeb4vpKPGITLsRVUS44L5oDbDUCZxn8lkxhmgw=
go.opentelemetry.io/otel v0.16.0/go.mod h1:e4GKElweB8W2gWUqbghw0B8t5MCTccc9212eNHnOHwA=
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
@ -965,7 +946,6 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200505041828-1ed23360d12c/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
@ -990,18 +970,6 @@ golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 h1:SVwTIAaPC2U/AvvLNZ2a7O
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5 h1:Lm4OryKCca1vehdsWogr9N4t7NfZxLbJoc/H0w4K4S4=
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210112200429-01de73cf58bd h1:0n2rzLq6xLtV9OFaT0BF2syUkjOwRrJ1zvXY5hH7Kkc=
golang.org/x/oauth2 v0.0.0-20210112200429-01de73cf58bd/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210113160501-8b1d76fa0423 h1:/hEknzWkMPCjTo7StMHRrBRa8YBbXuBWfck8680k3RE=
golang.org/x/oauth2 v0.0.0-20210113160501-8b1d76fa0423/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210113205817-d3ed898aa8a3 h1:BaN3BAqnopnKjvl+15DYP6LLrbBHfbfmlFYzmFj/Q9Q=
golang.org/x/oauth2 v0.0.0-20210113205817-d3ed898aa8a3/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210125201302-af13f521f196 h1:w0u30BeG/TALEc6xVf1Klaz2+etRR4K6jxhRkWCqt4g=
golang.org/x/oauth2 v0.0.0-20210125201302-af13f521f196/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210126194326-f9ce19ea3013 h1:55H5j7lotzuFCEOKDsMch+fRNUQ9DgtyHOUP31FNqKc=
golang.org/x/oauth2 v0.0.0-20210126194326-f9ce19ea3013/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210201163806-010130855d6c h1:HiAZXo96zOhVhtFHchj/ojzoxCFiPrp9/j0GtS38V3g=
golang.org/x/oauth2 v0.0.0-20210201163806-010130855d6c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -1080,10 +1048,8 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuF
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201214210602-f9fddec55a1e h1:AyodaIpKjppX+cBfTASF2E1US3H2JFBj920Ot3rtDjs=
golang.org/x/sys v0.0.0-20201214210602-f9fddec55a1e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201221093633-bc327ba9c2f0 h1:n+DPcgTwkgWzIFpLmoimYR2K2b0Ga5+Os4kayIN0vGo=
golang.org/x/sys v0.0.0-20201221093633-bc327ba9c2f0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201223074533-0d417f636930 h1:vRgIt+nup/B/BwIS0g2oC0haq0iqbV3ZA+u6+0TlNCo=
golang.org/x/sys v0.0.0-20201223074533-0d417f636930/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c h1:VwygUrnw9jn88c4u8GD3rZQbqrP/tgas88tPUbBxQrk=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221 h1:/ZHdbVpdR/jk3g30/d4yUL0JU9kksj8+F/bnQUVLGDM=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
@ -1099,6 +1065,8 @@ golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4 h1:0YWbFKbhXG/wIiuHDSKpS0Iy7FSA+u45VtBMfQcFTTc=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5 h1:i6eZZ+zk0SOf0xgBpEpPD18qWcJda6q1sxt3S0kzyUQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@ -1122,8 +1090,6 @@ golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBn
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628034336-212fb13d595e h1:ZlQjfVdpDxeqxRfmO30CdqWWzTvgRCj0MxaUVfxEG1k=
golang.org/x/tools v0.0.0-20190628034336-212fb13d595e/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
@ -1336,11 +1302,13 @@ src.techknowlogick.com/xormigrate v1.4.0 h1:gAfLoDwcVfMiFhSXg5Qwm7LNnG1iUbBVDUNf
src.techknowlogick.com/xormigrate v1.4.0/go.mod h1:xCtbAK00lJ0v4zP2O6VBVMG3RHm7W5Yo1Dz0r9kL/ho=
xorm.io/builder v0.3.7 h1:2pETdKRK+2QG4mLX4oODHEhn5Z8j1m8sXa7jfu+/SZI=
xorm.io/builder v0.3.7/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE=
xorm.io/builder v0.3.8 h1:P/wPgRqa9kX5uE0aA1/ukJ23u9KH0aSRpHLwDKXigSE=
xorm.io/builder v0.3.8/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE=
xorm.io/core v0.7.3 h1:W8ws1PlrnkS1CZU1YWaYLMQcQilwAmQXU0BJDJon+H0=
xorm.io/core v0.7.3/go.mod h1:jJfd0UAEzZ4t87nbQYtVjmqpIODugN6PD2D9E+dJvdM=
xorm.io/xorm v1.0.1 h1:/lITxpJtkZauNpdzj+L9CN/3OQxZaABrbergMcJu+Cw=
xorm.io/xorm v1.0.1/go.mod h1:o4vnEsQ5V2F1/WK6w4XTwmiWJeGj82tqjAnHe44wVHY=
xorm.io/xorm v1.0.2 h1:kZlCh9rqd1AzGwWitcrEEqHE1h1eaZE/ujU5/2tWEtg=
xorm.io/xorm v1.0.2/go.mod h1:o4vnEsQ5V2F1/WK6w4XTwmiWJeGj82tqjAnHe44wVHY=
xorm.io/xorm v1.0.5 h1:LRr5PfOUb4ODPR63YwbowkNDwcolT2LnkwP/TUaMaB0=
xorm.io/xorm v1.0.5/go.mod h1:uF9EtbhODq5kNWxMbnBEj8hRRZnlcNSz2t2N7HW/+A4=
xorm.io/xorm v1.0.7 h1:26yBTDVI+CfQpVz2Y88fISh+aiJXIPP4eNoTJlwzsC4=
xorm.io/xorm v1.0.7/go.mod h1:uF9EtbhODq5kNWxMbnBEj8hRRZnlcNSz2t2N7HW/+A4=

View File

@ -24,6 +24,7 @@ import (
"context"
"crypto/sha256"
"fmt"
"github.com/iancoleman/strcase"
"io"
"io/ioutil"
"os"
@ -60,13 +61,15 @@ var (
// Aliases are mage aliases of targets
Aliases = map[string]interface{}{
"build": Build.Build,
"do-the-swag": DoTheSwag,
"check:got-swag": Check.GotSwag,
"release:os-package": Release.OsPackage,
"dev:create-migration": Dev.CreateMigration,
"generate-docs": GenerateDocs,
"check:golangci-fix": Check.GolangciFix,
"build": Build.Build,
"do-the-swag": DoTheSwag,
"check:got-swag": Check.GotSwag,
"release:os-package": Release.OsPackage,
"dev:make-migration": Dev.MakeMigration,
"dev:make-event": Dev.MakeEvent,
"dev:make-listener": Dev.MakeListener,
"generate-docs": GenerateDocs,
"check:golangci-fix": Check.GolangciFix,
}
)
@ -294,6 +297,25 @@ func moveFile(src, dst string) error {
return nil
}
func appendToFile(filename, content string) error {
f, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0600)
if err != nil {
return err
}
defer f.Close()
_, err = f.WriteString(content)
return err
}
const InfoColor = "\033[1;32m%s\033[0m"
func printSuccess(text string, args ...interface{}) {
text = fmt.Sprintf(text, args...)
fmt.Printf(InfoColor+"\n", text)
}
// Formats the code using go fmt
func Fmt() {
mg.Deps(initVars)
@ -695,7 +717,7 @@ func (Release) Packages() error {
type Dev mg.Namespace
// Creates a new bare db migration skeleton in pkg/migration with the current date
func (Dev) CreateMigration() error {
func (Dev) MakeMigration() error {
reader := bufio.NewReader(os.Stdin)
fmt.Print("Enter the name of the struct: ")
@ -747,14 +769,129 @@ func init() {
})
}
`
f, err := os.Create(RootPath + "/pkg/migration/" + date + ".go")
filename := "/pkg/migration/" + date + ".go"
f, err := os.Create(RootPath + filename)
defer f.Close()
if err != nil {
return err
}
_, err = f.WriteString(migration)
return err
if _, err := f.WriteString(migration); err != nil {
return err
}
printSuccess("Migration has been created at %s!", filename)
return nil
}
// Create a new event. Takes the name of the event as the first argument and the module where the event should be created as the second argument. Events will be appended to the pkg/<module>/events.go file.
func (Dev) MakeEvent(name, module string) error {
name = strcase.ToCamel(name)
if !strings.HasSuffix(name, "Event") {
name += "Event"
}
eventName := strings.ReplaceAll(strcase.ToDelimited(name, '.'), ".event", "")
newEventCode := `
// ` + name + ` represents a ` + name + ` event
type ` + name + ` struct {
}
// Name defines the name for ` + name + `
func (t *` + name + `) Name() string {
return "` + eventName + `"
}
`
filename := "./pkg/" + module + "/events.go"
if err := appendToFile(filename, newEventCode); err != nil {
return err
}
printSuccess("The new event has been created successfully! Head over to %s and adjust its content.", filename)
return nil
}
// Create a new listener for an event. Takes the name of the listener, the name of the event to listen to and the module where everything should be placed as parameters.
func (Dev) MakeListener(name, event, module string) error {
name = strcase.ToCamel(name)
listenerName := strcase.ToDelimited(name, '.')
listenerCode := `
// ` + name + ` represents a listener
type ` + name + ` struct {
}
// Name defines the name for the ` + name + ` listener
func (s *` + name + `) Name() string {
return "` + listenerName + `"
}
// Hanlde is executed when the event ` + name + ` listens on is fired
func (s *` + name + `) Handle(payload message.Payload) (err error) {
event := &` + event + `{}
err = json.Unmarshal(payload, event)
if err != nil {
return err
}
return nil
}
`
filename := "./pkg/" + module + "/listeners.go"
//////
// Register the listener
file, err := os.Open(filename)
if err != nil {
return err
}
scanner := bufio.NewScanner(file)
var idx int64 = 0
for scanner.Scan() {
if scanner.Text() == "}" {
//idx -= int64(len(scanner.Text()))
break
}
idx += int64(len(scanner.Bytes()) + 1)
}
file.Close()
registerListenerCode := ` events.RegisterListener((&` + event + `{}).Name(), &` + name + `{})
`
f, err := os.OpenFile(filename, os.O_RDWR, 0600)
if err != nil {
return err
}
defer f.Close()
if _, err := f.Seek(idx, 0); err != nil {
return err
}
remainder, err := ioutil.ReadAll(f)
if err != nil {
return err
}
f.Seek(idx, 0)
f.Write([]byte(registerListenerCode))
f.Write(remainder)
///////
// Append the listener code
if err := appendToFile(filename, listenerCode); err != nil {
return err
}
printSuccess("The new listener has been created successfully! Head over to %s and adjust its content.", filename)
return nil
}
type configOption struct {

View File

@ -101,6 +101,8 @@ const (
LogHTTP Key = `log.http`
LogEcho Key = `log.echo`
LogPath Key = `log.path`
LogEvents Key = `log.events`
LogEventsLevel Key = `log.eventslevel`
RateLimitEnabled Key = `ratelimit.enabled`
RateLimitKind Key = `ratelimit.kind`
@ -281,6 +283,8 @@ func InitDefaultConfig() {
LogHTTP.setDefault("stdout")
LogEcho.setDefault("off")
LogPath.setDefault(ServiceRootpath.GetString() + "/logs")
LogEvents.setDefault("stdout")
LogEventsLevel.setDefault("INFO")
// Rate Limit
RateLimitEnabled.setDefault(false)
RateLimitKind.setDefault("user")

97
pkg/events/events.go Normal file
View File

@ -0,0 +1,97 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2021 Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public Licensee as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package events
import (
"context"
"encoding/json"
"time"
"code.vikunja.io/api/pkg/log"
vmetrics "code.vikunja.io/api/pkg/metrics"
"github.com/ThreeDotsLabs/watermill"
"github.com/ThreeDotsLabs/watermill/components/metrics"
"github.com/ThreeDotsLabs/watermill/message"
"github.com/ThreeDotsLabs/watermill/message/router/middleware"
"github.com/ThreeDotsLabs/watermill/pubsub/gochannel"
)
var pubsub *gochannel.GoChannel
// Event represents the event interface used by all events
type Event interface {
Name() string
}
// InitEvents sets up everything needed to work with events
func InitEvents() (err error) {
logger := log.NewWatermillLogger()
router, err := message.NewRouter(
message.RouterConfig{},
logger,
)
if err != nil {
return err
}
router.AddMiddleware(
middleware.Retry{
MaxRetries: 5,
InitialInterval: time.Millisecond * 100,
Logger: logger,
Multiplier: 2,
}.Middleware,
middleware.Recoverer,
)
metricsBuilder := metrics.NewPrometheusMetricsBuilder(vmetrics.GetRegistry(), "", "")
metricsBuilder.AddPrometheusRouterMetrics(router)
pubsub = gochannel.NewGoChannel(
gochannel.Config{
OutputChannelBuffer: 1024,
},
logger,
)
for topic, funcs := range listeners {
for _, handler := range funcs {
router.AddNoPublisherHandler(topic+"."+handler.Name(), topic, pubsub, func(msg *message.Message) error {
return handler.Handle(msg.Payload)
})
}
}
return router.Run(context.Background())
}
// Dispatch dispatches an event
func Dispatch(event Event) error {
if isUnderTest {
dispatchedTestEvents = append(dispatchedTestEvents, event)
return nil
}
content, err := json.Marshal(event)
if err != nil {
return err
}
msg := message.NewMessage(watermill.NewUUID(), content)
return pubsub.Publish(event.Name(), msg)
}

36
pkg/events/listeners.go Normal file
View File

@ -0,0 +1,36 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2021 Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public Licensee as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package events
import "github.com/ThreeDotsLabs/watermill/message"
// Listener represents something that listens to events
type Listener interface {
Handle(payload message.Payload) error
Name() string
}
var listeners map[string][]Listener
func init() {
listeners = make(map[string][]Listener)
}
// RegisterListener is used to register a listener when a specific event happens
func RegisterListener(name string, listener Listener) {
listeners[name] = append(listeners[name], listener)
}

49
pkg/events/testing.go Normal file
View File

@ -0,0 +1,49 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2021 Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public Licensee as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package events
import (
"testing"
"github.com/stretchr/testify/assert"
)
var (
isUnderTest bool
dispatchedTestEvents []Event
)
// Fake sets up the "test mode" of the events package. Typically you'd call this function in the TestMain function
// in the package you're testing. It will prevent any events from being fired, instead they will be recorded and be
// available for assertions.
func Fake() {
isUnderTest = true
dispatchedTestEvents = nil
}
// AssertDispatched asserts an event has been dispatched.
func AssertDispatched(t *testing.T, event Event) {
var found bool
for _, testEvent := range dispatchedTestEvents {
if event.Name() == testEvent.Name() {
found = true
break
}
}
assert.True(t, found, "Failed to assert "+event.Name()+" has been dispatched.")
}

29
pkg/initialize/events.go Normal file
View File

@ -0,0 +1,29 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2021 Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public Licensee as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package initialize
import "time"
// BootedEvent represents a BootedEvent event
type BootedEvent struct {
BootedAt time.Time
}
// TopicName defines the name for BootedEvent
func (t *BootedEvent) Name() string {
return "booted"
}

View File

@ -17,8 +17,11 @@
package initialize
import (
"time"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/cron"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/files"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/mail"
@ -85,4 +88,21 @@ func FullInit() {
// Start the cron
cron.Init()
models.RegisterReminderCron()
// Start processing events
go func() {
models.RegisterListeners()
user.RegisterListeners()
err := events.InitEvents()
if err != nil {
log.Fatal(err.Error())
}
err = events.Dispatch(&BootedEvent{
BootedAt: time.Now(),
})
if err != nil {
log.Fatal(err)
}
}()
}

View File

@ -24,6 +24,8 @@ import (
"strings"
"testing"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/files"
@ -85,6 +87,7 @@ func setupTestEnv() (e *echo.Echo, err error) {
files.InitTests()
user.InitTests()
models.SetupTests()
events.Fake()
err = db.LoadFixtures()
if err != nil {

View File

@ -49,6 +49,7 @@ func InitLogger() {
config.LogDatabase.Set("off")
config.LogHTTP.Set("off")
config.LogEcho.Set("off")
config.LogEvents.Set("off")
return
}

View File

@ -0,0 +1,91 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2021 Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public Licensee as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package log
import (
"fmt"
"strings"
"time"
"code.vikunja.io/api/pkg/config"
"github.com/ThreeDotsLabs/watermill"
"github.com/op/go-logging"
)
const watermillFmt = `%{color}%{time:` + time.RFC3339Nano + `}: %{level}` + "\t" + `▶ [EVENTS] %{id:03x}%{color:reset} %{message}`
const watermillLogModule = `vikunja_events`
type WatermillLogger struct {
logger *logging.Logger
}
func NewWatermillLogger() *WatermillLogger {
lvl := strings.ToUpper(config.LogEventsLevel.GetString())
level, err := logging.LogLevel(lvl)
if err != nil {
Criticalf("Error setting events log level %s: %s", lvl, err.Error())
}
watermillLogger := &WatermillLogger{
logger: logging.MustGetLogger(watermillLogModule),
}
logBackend := logging.NewLogBackend(GetLogWriter("events"), "", 0)
backend := logging.NewBackendFormatter(logBackend, logging.MustStringFormatter(watermillFmt+"\n"))
backendLeveled := logging.AddModuleLevel(backend)
backendLeveled.SetLevel(level, watermillLogModule)
watermillLogger.logger.SetBackend(backendLeveled)
return watermillLogger
}
func concatFields(fields watermill.LogFields) string {
full := ""
for key, val := range fields {
full += fmt.Sprintf("%s=%s, ", key, val)
}
if full != "" {
full = full[:len(full)-2]
}
return full
}
func (w *WatermillLogger) Error(msg string, err error, fields watermill.LogFields) {
w.logger.Errorf("%s: %s, %s", msg, err, concatFields(fields))
}
func (w *WatermillLogger) Info(msg string, fields watermill.LogFields) {
w.logger.Infof("%s, %s", msg, concatFields(fields))
}
func (w *WatermillLogger) Debug(msg string, fields watermill.LogFields) {
w.logger.Debugf("%s, %s", msg, concatFields(fields))
}
func (w *WatermillLogger) Trace(msg string, fields watermill.LogFields) {
w.logger.Debugf("%s, %s", msg, concatFields(fields))
}
func (w *WatermillLogger) With(fields watermill.LogFields) watermill.LoggerAdapter {
return w
}

View File

@ -44,7 +44,7 @@ func NewXormLogger(lvl string) *XormLogger {
}
level, err := logging.LogLevel(lvl)
if err != nil {
Critical("Error setting database log level: %s", err.Error())
Criticalf("Error setting database log level: %s", err.Error())
}
xormLogger := &XormLogger{

View File

@ -17,7 +17,6 @@
package metrics
import (
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/modules/keyvalue"
"github.com/prometheus/client_golang/prometheus"
@ -41,6 +40,18 @@ const (
TeamCountKey = `teamcount`
)
var registry *prometheus.Registry
func GetRegistry() *prometheus.Registry {
if registry == nil {
registry = prometheus.NewRegistry()
registry.MustRegister(prometheus.NewProcessCollector(prometheus.ProcessCollectorOpts{}))
registry.MustRegister(prometheus.NewGoCollector())
}
return registry
}
// InitMetrics Initializes the metrics
func InitMetrics() {
// init active users, sometimes we'll have garbage from previous runs in redis instead
@ -48,50 +59,67 @@ func InitMetrics() {
log.Fatalf("Could not set initial count for active users, error was %s", err)
}
GetRegistry()
// Register total list count metric
promauto.NewGaugeFunc(prometheus.GaugeOpts{
err := registry.Register(promauto.NewGaugeFunc(prometheus.GaugeOpts{
Name: "vikunja_list_count",
Help: "The number of lists on this instance",
}, func() float64 {
count, _ := GetCount(ListCountKey)
return float64(count)
})
}))
if err != nil {
log.Criticalf("Could not register metrics for %s: %s", ListCountKey, err)
}
// Register total user count metric
promauto.NewGaugeFunc(prometheus.GaugeOpts{
err = registry.Register(promauto.NewGaugeFunc(prometheus.GaugeOpts{
Name: "vikunja_user_count",
Help: "The total number of users on this instance",
}, func() float64 {
count, _ := GetCount(UserCountKey)
return float64(count)
})
}))
if err != nil {
log.Criticalf("Could not register metrics for %s: %s", UserCountKey, err)
}
// Register total Namespaces count metric
promauto.NewGaugeFunc(prometheus.GaugeOpts{
Name: "vikunja_namespcae_count",
err = registry.Register(promauto.NewGaugeFunc(prometheus.GaugeOpts{
Name: "vikunja_namespace_count",
Help: "The total number of namespaces on this instance",
}, func() float64 {
count, _ := GetCount(NamespaceCountKey)
return float64(count)
})
}))
if err != nil {
log.Criticalf("Could not register metrics for %s: %s", NamespaceCountKey, err)
}
// Register total Tasks count metric
promauto.NewGaugeFunc(prometheus.GaugeOpts{
err = registry.Register(promauto.NewGaugeFunc(prometheus.GaugeOpts{
Name: "vikunja_task_count",
Help: "The total number of tasks on this instance",
}, func() float64 {
count, _ := GetCount(TaskCountKey)
return float64(count)
})
}))
if err != nil {
log.Criticalf("Could not register metrics for %s: %s", TaskCountKey, err)
}
// Register total user count metric
promauto.NewGaugeFunc(prometheus.GaugeOpts{
err = registry.Register(promauto.NewGaugeFunc(prometheus.GaugeOpts{
Name: "vikunja_team_count",
Help: "The total number of teams on this instance",
}, func() float64 {
count, _ := GetCount(TeamCountKey)
return float64(count)
})
}))
if err != nil {
log.Criticalf("Could not register metrics for %s: %s", TeamCountKey, err)
}
}
// GetCount returns the current count from redis
@ -113,22 +141,3 @@ func GetCount(key string) (count int64, err error) {
func SetCount(count int64, key string) error {
return keyvalue.Put(key, count)
}
// UpdateCount updates a count with a given amount
func UpdateCount(update int64, key string) {
if !config.ServiceEnableMetrics.GetBool() {
return
}
if update > 0 {
err := keyvalue.IncrBy(key, update)
if err != nil {
log.Error(err.Error())
}
}
if update < 0 {
err := keyvalue.DecrBy(key, update)
if err != nil {
log.Error(err.Error())
}
}
}

View File

@ -78,14 +78,14 @@ func (bt *BulkTask) CanUpdate(s *xorm.Session, a web.Auth) (bool, error) {
// @Failure 403 {object} web.HTTPError "The user does not have access to the task (aka its list)"
// @Failure 500 {object} models.Message "Internal error"
// @Router /tasks/bulk [post]
func (bt *BulkTask) Update(s *xorm.Session) (err error) {
func (bt *BulkTask) Update(s *xorm.Session, a web.Auth) (err error) {
for _, oldtask := range bt.Tasks {
// When a repeating task is marked as done, we update all deadlines and reminders and set it as undone
updateDone(oldtask, &bt.Task)
// Update the assignees
if err := oldtask.updateTaskAssignees(s, bt.Assignees); err != nil {
if err := oldtask.updateTaskAssignees(s, bt.Assignees, a); err != nil {
return err
}

View File

@ -84,7 +84,7 @@ func TestBulkTask_Update(t *testing.T) {
if !allowed != tt.wantForbidden {
t.Errorf("BulkTask.Update() want forbidden, got %v, want %v", allowed, tt.wantForbidden)
}
if err := bt.Update(s); (err != nil) != tt.wantErr {
if err := bt.Update(s, tt.fields.User); (err != nil) != tt.wantErr {
t.Errorf("BulkTask.Update() error = %v, wantErr %v", err, tt.wantErr)
}

247
pkg/models/events.go Normal file
View File

@ -0,0 +1,247 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2021 Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public Licensee as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package models
import (
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/web"
)
/////////////////
// Task Events //
/////////////////
// TaskCreatedEvent represents an event where a task has been created
type TaskCreatedEvent struct {
Task *Task
Doer web.Auth
}
// Name defines the name for TaskCreatedEvent
func (t *TaskCreatedEvent) Name() string {
return "task.created"
}
// TaskUpdatedEvent represents an event where a task has been updated
type TaskUpdatedEvent struct {
Task *Task
Doer web.Auth
}
// Name defines the name for TaskUpdatedEvent
func (t *TaskUpdatedEvent) Name() string {
return "task.updated"
}
// TaskDeletedEvent represents a TaskDeletedEvent event
type TaskDeletedEvent struct {
Task *Task
Doer web.Auth
}
// Name defines the name for TaskDeletedEvent
func (t *TaskDeletedEvent) Name() string {
return "task.deleted"
}
// TaskAssigneeCreatedEvent represents an event where a task has been assigned to a user
type TaskAssigneeCreatedEvent struct {
Task *Task
Assignee *user.User
Doer web.Auth
}
// Name defines the name for TaskAssigneeCreatedEvent
func (t *TaskAssigneeCreatedEvent) Name() string {
return "task.assignee.created"
}
// TaskCommentCreatedEvent represents an event where a task comment has been created
type TaskCommentCreatedEvent struct {
Task *Task
Comment *TaskComment
Doer web.Auth
}
// Name defines the name for TaskCommentCreatedEvent
func (t *TaskCommentCreatedEvent) Name() string {
return "task.comment.created"
}
//////////////////////
// Namespace Events //
//////////////////////
// NamespaceCreatedEvent represents an event where a namespace has been created
type NamespaceCreatedEvent struct {
Namespace *Namespace
Doer web.Auth
}
// Name defines the name for NamespaceCreatedEvent
func (n *NamespaceCreatedEvent) Name() string {
return "namespace.created"
}
// NamespaceUpdatedEvent represents an event where a namespace has been updated
type NamespaceUpdatedEvent struct {
Namespace *Namespace
Doer web.Auth
}
// Name defines the name for NamespaceUpdatedEvent
func (n *NamespaceUpdatedEvent) Name() string {
return "namespace.updated"
}
// NamespaceDeletedEvent represents a NamespaceDeletedEvent event
type NamespaceDeletedEvent struct {
Namespace *Namespace
Doer web.Auth
}
// TopicName defines the name for NamespaceDeletedEvent
func (t *NamespaceDeletedEvent) Name() string {
return "namespace.deleted"
}
/////////////////
// List Events //
/////////////////
// ListCreatedEvent represents an event where a list has been created
type ListCreatedEvent struct {
List *List
Doer web.Auth
}
// Name defines the name for ListCreatedEvent
func (l *ListCreatedEvent) Name() string {
return "list.created"
}
// ListUpdatedEvent represents an event where a list has been updated
type ListUpdatedEvent struct {
List *List
Doer web.Auth
}
// Name defines the name for ListUpdatedEvent
func (l *ListUpdatedEvent) Name() string {
return "list.updated"
}
// ListDeletedEvent represents an event where a list has been deleted
type ListDeletedEvent struct {
List *List
Doer web.Auth
}
// Name defines the name for ListDeletedEvent
func (t *ListDeletedEvent) Name() string {
return "list.deleted"
}
////////////////////
// Sharing Events //
////////////////////
// ListSharedWithUserEvent represents an event where a list has been shared with a user
type ListSharedWithUserEvent struct {
List *List
User *user.User
Doer web.Auth
}
// Name defines the name for ListSharedWithUserEvent
func (l *ListSharedWithUserEvent) Name() string {
return "list.shared.user"
}
// ListSharedWithTeamEvent represents an event where a list has been shared with a team
type ListSharedWithTeamEvent struct {
List *List
Team *Team
Doer web.Auth
}
// Name defines the name for ListSharedWithTeamEvent
func (l *ListSharedWithTeamEvent) Name() string {
return "list.shared.team"
}
// NamespaceSharedWithUserEvent represents an event where a namespace has been shared with a user
type NamespaceSharedWithUserEvent struct {
Namespace *Namespace
User *user.User
Doer web.Auth
}
// Name defines the name for NamespaceSharedWithUserEvent
func (n *NamespaceSharedWithUserEvent) Name() string {
return "namespace.shared.user"
}
// NamespaceSharedWithTeamEvent represents an event where a namespace has been shared with a team
type NamespaceSharedWithTeamEvent struct {
Namespace *Namespace
Team *Team
Doer web.Auth
}
// Name defines the name for NamespaceSharedWithTeamEvent
func (n *NamespaceSharedWithTeamEvent) Name() string {
return "namespace.shared.team"
}
/////////////////
// Team Events //
/////////////////
// TeamMemberAddedEvent defines an event where a user is added to a team
type TeamMemberAddedEvent struct {
Team *Team
Member *user.User
Doer web.Auth
}
// Name defines the name for TeamMemberAddedEvent
func (t *TeamMemberAddedEvent) Name() string {
return "team.member.added"
}
// TeamCreatedEvent represents a TeamCreatedEvent event
type TeamCreatedEvent struct {
Team *Team
Doer web.Auth
}
// Name defines the name for TeamCreatedEvent
func (t *TeamCreatedEvent) Name() string {
return "team.created"
}
// TeamDeletedEvent represents a TeamDeletedEvent event
type TeamDeletedEvent struct {
Team *Team
Doer web.Auth
}
// Name defines the name for TeamDeletedEvent
func (t *TeamDeletedEvent) Name() string {
return "team.deleted"
}

View File

@ -190,7 +190,7 @@ func (b *Bucket) Create(s *xorm.Session, a web.Auth) (err error) {
// @Failure 404 {object} web.HTTPError "The bucket does not exist."
// @Failure 500 {object} models.Message "Internal error"
// @Router /lists/{listID}/buckets/{bucketID} [post]
func (b *Bucket) Update(s *xorm.Session) (err error) {
func (b *Bucket) Update(s *xorm.Session, a web.Auth) (err error) {
_, err = s.
Where("id = ?", b.ID).
Cols("title", "limit").
@ -211,7 +211,7 @@ func (b *Bucket) Update(s *xorm.Session) (err error) {
// @Failure 404 {object} web.HTTPError "The bucket does not exist."
// @Failure 500 {object} models.Message "Internal error"
// @Router /lists/{listID}/buckets/{bucketID} [delete]
func (b *Bucket) Delete(s *xorm.Session) (err error) {
func (b *Bucket) Delete(s *xorm.Session, a web.Auth) (err error) {
// Prevent removing the last bucket
total, err := s.Where("list_id = ?", b.ListID).Count(&Bucket{})

View File

@ -92,6 +92,8 @@ func TestBucket_ReadAll(t *testing.T) {
}
func TestBucket_Delete(t *testing.T) {
user := &user.User{ID: 1}
t.Run("normal", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
@ -101,7 +103,7 @@ func TestBucket_Delete(t *testing.T) {
ID: 2, // The second bucket only has 3 tasks
ListID: 1,
}
err := b.Delete(s)
err := b.Delete(s, user)
assert.NoError(t, err)
err = s.Commit()
assert.NoError(t, err)
@ -125,7 +127,7 @@ func TestBucket_Delete(t *testing.T) {
ID: 34,
ListID: 18,
}
err := b.Delete(s)
err := b.Delete(s, user)
assert.Error(t, err)
assert.True(t, IsErrCannotRemoveLastBucket(err))
err = s.Commit()
@ -141,7 +143,7 @@ func TestBucket_Delete(t *testing.T) {
func TestBucket_Update(t *testing.T) {
testAndAssertBucketUpdate := func(t *testing.T, b *Bucket, s *xorm.Session) {
err := b.Update(s)
err := b.Update(s, &user.User{ID: 1})
assert.NoError(t, err)
err = s.Commit()

View File

@ -93,7 +93,7 @@ func (l *Label) Create(s *xorm.Session, a web.Auth) (err error) {
// @Failure 404 {object} web.HTTPError "Label not found."
// @Failure 500 {object} models.Message "Internal error"
// @Router /labels/{id} [put]
func (l *Label) Update(s *xorm.Session) (err error) {
func (l *Label) Update(s *xorm.Session, a web.Auth) (err error) {
_, err = s.
ID(l.ID).
Cols(
@ -106,7 +106,7 @@ func (l *Label) Update(s *xorm.Session) (err error) {
return
}
err = l.ReadOne(s)
err = l.ReadOne(s, a)
return
}
@ -123,7 +123,7 @@ func (l *Label) Update(s *xorm.Session) (err error) {
// @Failure 404 {object} web.HTTPError "Label not found."
// @Failure 500 {object} models.Message "Internal error"
// @Router /labels/{id} [delete]
func (l *Label) Delete(s *xorm.Session) (err error) {
func (l *Label) Delete(s *xorm.Session, a web.Auth) (err error) {
_, err = s.ID(l.ID).Delete(&Label{})
return err
}
@ -178,7 +178,7 @@ func (l *Label) ReadAll(s *xorm.Session, a web.Auth, search string, page int, pe
// @Failure 404 {object} web.HTTPError "Label not found"
// @Failure 500 {object} models.Message "Internal error"
// @Router /labels/{id} [get]
func (l *Label) ReadOne(s *xorm.Session) (err error) {
func (l *Label) ReadOne(s *xorm.Session, a web.Auth) (err error) {
label, err := getLabelByIDSimple(s, l.ID)
if err != nil {
return err

View File

@ -61,7 +61,7 @@ func (LabelTask) TableName() string {
// @Failure 404 {object} web.HTTPError "Label not found."
// @Failure 500 {object} models.Message "Internal error"
// @Router /tasks/{task}/labels/{label} [delete]
func (lt *LabelTask) Delete(s *xorm.Session) (err error) {
func (lt *LabelTask) Delete(s *xorm.Session, a web.Auth) (err error) {
_, err = s.Delete(&LabelTask{LabelID: lt.LabelID, TaskID: lt.TaskID})
return err
}
@ -208,6 +208,10 @@ func getLabelsByTaskIDs(s *xorm.Session, opts *LabelByTaskIDsOptions) (ls []*lab
return nil, 0, 0, err
}
if len(labels) == 0 {
return nil, 0, 0, nil
}
// Get all created by users
var userids []int64
for _, l := range labels {

View File

@ -318,7 +318,7 @@ func TestLabelTask_Delete(t *testing.T) {
if !allowed && !tt.wantForbidden {
t.Errorf("LabelTask.CanDelete() forbidden, want %v", tt.wantForbidden)
}
err := l.Delete(s)
err := l.Delete(s, tt.auth)
if (err != nil) != tt.wantErr {
t.Errorf("LabelTask.Delete() error = %v, wantErr %v", err, tt.wantErr)
}

View File

@ -257,7 +257,7 @@ func TestLabel_ReadOne(t *testing.T) {
if !allowed && !tt.wantForbidden {
t.Errorf("Label.CanRead() forbidden, want %v", tt.wantForbidden)
}
err := l.ReadOne(s)
err := l.ReadOne(s, tt.auth)
if (err != nil) != tt.wantErr {
t.Errorf("Label.ReadOne() error = %v, wantErr %v", err, tt.wantErr)
}
@ -419,7 +419,7 @@ func TestLabel_Update(t *testing.T) {
if !allowed && !tt.wantForbidden {
t.Errorf("Label.CanUpdate() forbidden, want %v", tt.wantForbidden)
}
if err := l.Update(s); (err != nil) != tt.wantErr {
if err := l.Update(s, tt.auth); (err != nil) != tt.wantErr {
t.Errorf("Label.Update() error = %v, wantErr %v", err, tt.wantErr)
}
if !tt.wantErr && !tt.wantForbidden {
@ -505,7 +505,7 @@ func TestLabel_Delete(t *testing.T) {
if !allowed && !tt.wantForbidden {
t.Errorf("Label.CanDelete() forbidden, want %v", tt.wantForbidden)
}
if err := l.Delete(s); (err != nil) != tt.wantErr {
if err := l.Delete(s, tt.auth); (err != nil) != tt.wantErr {
t.Errorf("Label.Delete() error = %v, wantErr %v", err, tt.wantErr)
}
if !tt.wantErr && !tt.wantForbidden {

View File

@ -127,7 +127,7 @@ func (share *LinkSharing) Create(s *xorm.Session, a web.Auth) (err error) {
// @Failure 404 {object} web.HTTPError "Share Link not found."
// @Failure 500 {object} models.Message "Internal error"
// @Router /lists/{list}/shares/{share} [get]
func (share *LinkSharing) ReadOne(s *xorm.Session) (err error) {
func (share *LinkSharing) ReadOne(s *xorm.Session, a web.Auth) (err error) {
exists, err := s.Where("id = ?", share.ID).Get(share)
if err != nil {
return err
@ -216,7 +216,7 @@ func (share *LinkSharing) ReadAll(s *xorm.Session, a web.Auth, search string, pa
// @Failure 404 {object} web.HTTPError "Share Link not found."
// @Failure 500 {object} models.Message "Internal error"
// @Router /lists/{list}/shares/{share} [delete]
func (share *LinkSharing) Delete(s *xorm.Session) (err error) {
func (share *LinkSharing) Delete(s *xorm.Session, a web.Auth) (err error) {
_, err = s.Where("id = ?", share.ID).Delete(share)
return
}

View File

@ -21,10 +21,11 @@ import (
"strings"
"time"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/files"
"code.vikunja.io/api/pkg/metrics"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/web"
"xorm.io/builder"
@ -186,7 +187,7 @@ func (l *List) ReadAll(s *xorm.Session, a web.Auth, search string, page int, per
// @Failure 403 {object} web.HTTPError "The user does not have access to the list"
// @Failure 500 {object} models.Message "Internal error"
// @Router /lists/{id} [get]
func (l *List) ReadOne(s *xorm.Session) (err error) {
func (l *List) ReadOne(s *xorm.Session, a web.Auth) (err error) {
if l.ID == FavoritesPseudoList.ID {
// Already "built" the list in CanRead
@ -388,6 +389,10 @@ func getRawListsForUser(s *xorm.Session, opts *listOptions) (lists []*List, resu
// addListDetails adds owner user objects and list tasks to all lists in the slice
func addListDetails(s *xorm.Session, lists []*List) (err error) {
if len(lists) == 0 {
return
}
var ownerIDs []int64
for _, l := range lists {
ownerIDs = append(ownerIDs, l.OwnerID)
@ -411,6 +416,10 @@ func addListDetails(s *xorm.Session, lists []*List) (err error) {
fileIDs = append(fileIDs, l.BackgroundFileID)
}
if len(fileIDs) == 0 {
return
}
// Unsplash background file info
us := []*UnsplashPhoto{}
err = s.In("file_id", fileIDs).Find(&us)
@ -466,7 +475,7 @@ func (l *List) CheckIsArchived(s *xorm.Session) (err error) {
}
// CreateOrUpdateList updates a list or creates it if it doesn't exist
func CreateOrUpdateList(s *xorm.Session, list *List) (err error) {
func CreateOrUpdateList(s *xorm.Session, list *List, auth web.Auth) (err error) {
// Check if the namespace exists
if list.NamespaceID != 0 && list.NamespaceID != FavoritesPseudoNamespace.ID {
@ -492,7 +501,6 @@ func CreateOrUpdateList(s *xorm.Session, list *List) (err error) {
if list.ID == 0 {
_, err = s.Insert(list)
metrics.UpdateCount(1, metrics.ListCountKey)
} else {
// We need to specify the cols we want to update here to be able to un-archive lists
colsToUpdate := []string{
@ -522,7 +530,7 @@ func CreateOrUpdateList(s *xorm.Session, list *List) (err error) {
}
*list = *l
err = list.ReadOne(s)
err = list.ReadOne(s, auth)
return
}
@ -541,8 +549,16 @@ func CreateOrUpdateList(s *xorm.Session, list *List) (err error) {
// @Failure 403 {object} web.HTTPError "The user does not have access to the list"
// @Failure 500 {object} models.Message "Internal error"
// @Router /lists/{id} [post]
func (l *List) Update(s *xorm.Session) (err error) {
return CreateOrUpdateList(s, l)
func (l *List) Update(s *xorm.Session, a web.Auth) (err error) {
err = CreateOrUpdateList(s, l, a)
if err != nil {
return err
}
return events.Dispatch(&ListUpdatedEvent{
List: l,
Doer: a,
})
}
func updateListLastUpdated(s *xorm.Session, list *List) error {
@ -589,7 +605,7 @@ func (l *List) Create(s *xorm.Session, a web.Auth) (err error) {
l.Owner = doer
l.ID = 0 // Otherwise only the first time a new list would be created
err = CreateOrUpdateList(s, l)
err = CreateOrUpdateList(s, l, a)
if err != nil {
return
}
@ -599,7 +615,15 @@ func (l *List) Create(s *xorm.Session, a web.Auth) (err error) {
ListID: l.ID,
Title: "New Bucket",
}
return b.Create(s, a)
err = b.Create(s, a)
if err != nil {
return
}
return events.Dispatch(&ListCreatedEvent{
List: l,
Doer: a,
})
}
// Delete implements the delete method of CRUDable
@ -614,18 +638,24 @@ func (l *List) Create(s *xorm.Session, a web.Auth) (err error) {
// @Failure 403 {object} web.HTTPError "The user does not have access to the list"
// @Failure 500 {object} models.Message "Internal error"
// @Router /lists/{id} [delete]
func (l *List) Delete(s *xorm.Session) (err error) {
func (l *List) Delete(s *xorm.Session, a web.Auth) (err error) {
// Delete the list
_, err = s.ID(l.ID).Delete(&List{})
if err != nil {
return
}
metrics.UpdateCount(-1, metrics.ListCountKey)
// Delete all todotasks on that list
// Delete all tasks on that list
_, err = s.Where("list_id = ?", l.ID).Delete(&Task{})
return
if err != nil {
return
}
return events.Dispatch(&ListDeletedEvent{
List: l,
Doer: a,
})
}
// SetListBackground sets a background file as list background in the db

View File

@ -67,15 +67,15 @@ func (ld *ListDuplicate) CanCreate(s *xorm.Session, a web.Auth) (canCreate bool,
// @Failure 500 {object} models.Message "Internal error"
// @Router /lists/{listID}/duplicate [put]
//nolint:gocyclo
func (ld *ListDuplicate) Create(s *xorm.Session, a web.Auth) (err error) {
func (ld *ListDuplicate) Create(s *xorm.Session, doer web.Auth) (err error) {
log.Debugf("Duplicating list %d", ld.ListID)
ld.List.ID = 0
ld.List.Identifier = "" // Reset the identifier to trigger regenerating a new one
// Set the owner to the current user
ld.List.OwnerID = a.GetID()
if err := CreateOrUpdateList(s, ld.List); err != nil {
ld.List.OwnerID = doer.GetID()
if err := CreateOrUpdateList(s, ld.List, doer); err != nil {
// If there is no available unique list identifier, just reset it.
if IsErrListIdentifierIsNotUnique(err) {
ld.List.Identifier = ""
@ -99,7 +99,7 @@ func (ld *ListDuplicate) Create(s *xorm.Session, a web.Auth) (err error) {
oldID := b.ID
b.ID = 0
b.ListID = ld.List.ID
if err := b.Create(s, a); err != nil {
if err := b.Create(s, doer); err != nil {
return err
}
bucketMap[oldID] = b.ID
@ -108,7 +108,7 @@ func (ld *ListDuplicate) Create(s *xorm.Session, a web.Auth) (err error) {
log.Debugf("Duplicated all buckets from list %d into %d", ld.ListID, ld.List.ID)
// Get all tasks + all task details
tasks, _, _, err := getTasksForLists(s, []*List{{ID: ld.ListID}}, a, &taskOptions{})
tasks, _, _, err := getTasksForLists(s, []*List{{ID: ld.ListID}}, doer, &taskOptions{})
if err != nil {
return err
}
@ -124,7 +124,7 @@ func (ld *ListDuplicate) Create(s *xorm.Session, a web.Auth) (err error) {
t.ListID = ld.List.ID
t.BucketID = bucketMap[t.BucketID]
t.UID = ""
err := createTask(s, t, a, false)
err := createTask(s, t, doer, false)
if err != nil {
return err
}
@ -163,7 +163,7 @@ func (ld *ListDuplicate) Create(s *xorm.Session, a web.Auth) (err error) {
return err
}
err := attachment.NewAttachment(s, attachment.File.File, attachment.File.Name, attachment.File.Size, a)
err := attachment.NewAttachment(s, attachment.File.File, attachment.File.Name, attachment.File.Size, doer)
if err != nil {
return err
}
@ -206,7 +206,7 @@ func (ld *ListDuplicate) Create(s *xorm.Session, a web.Auth) (err error) {
ID: taskMap[a.TaskID],
ListID: ld.List.ID,
}
if err := t.addNewAssigneeByID(s, a.UserID, ld.List); err != nil {
if err := t.addNewAssigneeByID(s, a.UserID, ld.List, doer); err != nil {
if IsErrUserDoesNotHaveAccessToList(err) {
continue
}
@ -269,7 +269,7 @@ func (ld *ListDuplicate) Create(s *xorm.Session, a web.Auth) (err error) {
}
defer f.File.Close()
file, err := files.Create(f.File, f.Name, f.Size, a)
file, err := files.Create(f.File, f.Name, f.Size, doer)
if err != nil {
return err
}

View File

@ -19,6 +19,8 @@ package models
import (
"time"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/web"
"xorm.io/xorm"
)
@ -77,9 +79,9 @@ func (tl *TeamList) Create(s *xorm.Session, a web.Auth) (err error) {
}
// Check if the team exists
_, err = GetTeamByID(s, tl.TeamID)
team, err := GetTeamByID(s, tl.TeamID)
if err != nil {
return
return err
}
// Check if the list exists
@ -105,6 +107,15 @@ func (tl *TeamList) Create(s *xorm.Session, a web.Auth) (err error) {
return err
}
err = events.Dispatch(&ListSharedWithTeamEvent{
List: l,
Team: team,
Doer: a,
})
if err != nil {
return err
}
err = updateListLastUpdated(s, l)
return
}
@ -122,7 +133,7 @@ func (tl *TeamList) Create(s *xorm.Session, a web.Auth) (err error) {
// @Failure 404 {object} web.HTTPError "Team or list does not exist."
// @Failure 500 {object} models.Message "Internal error"
// @Router /lists/{listID}/teams/{teamID} [delete]
func (tl *TeamList) Delete(s *xorm.Session) (err error) {
func (tl *TeamList) Delete(s *xorm.Session, a web.Auth) (err error) {
// Check if the team exists
_, err = GetTeamByID(s, tl.TeamID)
@ -234,7 +245,7 @@ func (tl *TeamList) ReadAll(s *xorm.Session, a web.Auth, search string, page int
// @Failure 404 {object} web.HTTPError "Team or list does not exist."
// @Failure 500 {object} models.Message "Internal error"
// @Router /lists/{listID}/teams/{teamID} [post]
func (tl *TeamList) Update(s *xorm.Session) (err error) {
func (tl *TeamList) Update(s *xorm.Session, a web.Auth) (err error) {
// Check if the right is valid
if err := tl.Right.isValid(); err != nil {

View File

@ -158,6 +158,8 @@ func TestTeamList_Create(t *testing.T) {
}
func TestTeamList_Delete(t *testing.T) {
user := &user.User{ID: 1}
t.Run("normal", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
@ -165,7 +167,7 @@ func TestTeamList_Delete(t *testing.T) {
TeamID: 1,
ListID: 3,
}
err := tl.Delete(s)
err := tl.Delete(s, user)
assert.NoError(t, err)
err = s.Commit()
assert.NoError(t, err)
@ -181,7 +183,7 @@ func TestTeamList_Delete(t *testing.T) {
TeamID: 9999,
ListID: 1,
}
err := tl.Delete(s)
err := tl.Delete(s, user)
assert.Error(t, err)
assert.True(t, IsErrTeamDoesNotExist(err))
_ = s.Close()
@ -193,7 +195,7 @@ func TestTeamList_Delete(t *testing.T) {
TeamID: 1,
ListID: 9999,
}
err := tl.Delete(s)
err := tl.Delete(s, user)
assert.Error(t, err)
assert.True(t, IsErrTeamDoesNotHaveAccessToList(err))
_ = s.Close()
@ -267,7 +269,7 @@ func TestTeamList_Update(t *testing.T) {
CRUDable: tt.fields.CRUDable,
Rights: tt.fields.Rights,
}
err := tl.Update(s)
err := tl.Update(s, &user.User{ID: 1})
if (err != nil) != tt.wantErr {
t.Errorf("TeamList.Update() error = %v, wantErr %v", err, tt.wantErr)
}

View File

@ -125,7 +125,7 @@ func TestList_CreateOrUpdate(t *testing.T) {
NamespaceID: 1,
}
list.Description = "Lorem Ipsum dolor sit amet."
err := list.Update(s)
err := list.Update(s, usr)
assert.NoError(t, err)
err = s.Commit()
assert.NoError(t, err)
@ -143,7 +143,7 @@ func TestList_CreateOrUpdate(t *testing.T) {
ID: 99999999,
Title: "test",
}
err := list.Update(s)
err := list.Update(s, usr)
assert.Error(t, err)
assert.True(t, IsErrListDoesNotExist(err))
_ = s.Close()
@ -172,7 +172,7 @@ func TestList_Delete(t *testing.T) {
list := List{
ID: 1,
}
err := list.Delete(s)
err := list.Delete(s, &user.User{ID: 1})
assert.NoError(t, err)
err = s.Commit()
assert.NoError(t, err)

View File

@ -19,6 +19,8 @@ package models
import (
"time"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/web"
"xorm.io/xorm"
@ -112,6 +114,15 @@ func (lu *ListUser) Create(s *xorm.Session, a web.Auth) (err error) {
return err
}
err = events.Dispatch(&ListSharedWithUserEvent{
List: l,
User: u,
Doer: a,
})
if err != nil {
return err
}
err = updateListLastUpdated(s, l)
return
}
@ -129,7 +140,7 @@ func (lu *ListUser) Create(s *xorm.Session, a web.Auth) (err error) {
// @Failure 404 {object} web.HTTPError "user or list does not exist."
// @Failure 500 {object} models.Message "Internal error"
// @Router /lists/{listID}/users/{userID} [delete]
func (lu *ListUser) Delete(s *xorm.Session) (err error) {
func (lu *ListUser) Delete(s *xorm.Session, a web.Auth) (err error) {
// Check if the user exists
u, err := user.GetUserByUsername(s, lu.Username)
@ -231,7 +242,7 @@ func (lu *ListUser) ReadAll(s *xorm.Session, a web.Auth, search string, page int
// @Failure 404 {object} web.HTTPError "User or list does not exist."
// @Failure 500 {object} models.Message "Internal error"
// @Router /lists/{listID}/users/{userID} [post]
func (lu *ListUser) Update(s *xorm.Session) (err error) {
func (lu *ListUser) Update(s *xorm.Session, a web.Auth) (err error) {
// Check if the right is valid
if err := lu.Right.isValid(); err != nil {

View File

@ -311,7 +311,7 @@ func TestListUser_Update(t *testing.T) {
CRUDable: tt.fields.CRUDable,
Rights: tt.fields.Rights,
}
err := lu.Update(s)
err := lu.Update(s, &user.User{ID: 1})
if (err != nil) != tt.wantErr {
t.Errorf("ListUser.Update() error = %v, wantErr %v", err, tt.wantErr)
}
@ -393,7 +393,7 @@ func TestListUser_Delete(t *testing.T) {
CRUDable: tt.fields.CRUDable,
Rights: tt.fields.Rights,
}
err := lu.Delete(s)
err := lu.Delete(s, &user.User{ID: 1})
if (err != nil) != tt.wantErr {
t.Errorf("ListUser.Delete() error = %v, wantErr %v", err, tt.wantErr)
}

154
pkg/models/listeners.go Normal file
View File

@ -0,0 +1,154 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2021 Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public Licensee as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package models
import (
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/metrics"
"code.vikunja.io/api/pkg/modules/keyvalue"
"github.com/ThreeDotsLabs/watermill/message"
)
// RegisterListeners registers all event listeners
func RegisterListeners() {
events.RegisterListener((&ListCreatedEvent{}).Name(), &IncreaseListCounter{})
events.RegisterListener((&ListDeletedEvent{}).Name(), &DecreaseListCounter{})
events.RegisterListener((&NamespaceCreatedEvent{}).Name(), &IncreaseNamespaceCounter{})
events.RegisterListener((&NamespaceDeletedEvent{}).Name(), &DecreaseNamespaceCounter{})
events.RegisterListener((&TaskCreatedEvent{}).Name(), &IncreaseTaskCounter{})
events.RegisterListener((&TaskDeletedEvent{}).Name(), &DecreaseTaskCounter{})
events.RegisterListener((&TeamDeletedEvent{}).Name(), &DecreaseTeamCounter{})
events.RegisterListener((&TeamCreatedEvent{}).Name(), &IncreaseTeamCounter{})
}
//////
// Task Events
// IncreaseTaskCounter represents a listener
type IncreaseTaskCounter struct {
}
// Name defines the name for the IncreaseTaskCounter listener
func (s *IncreaseTaskCounter) Name() string {
return "task.counter.increase"
}
// Hanlde is executed when the event IncreaseTaskCounter listens on is fired
func (s *IncreaseTaskCounter) Handle(payload message.Payload) (err error) {
return keyvalue.IncrBy(metrics.TaskCountKey, 1)
}
// DecreaseTaskCounter represents a listener
type DecreaseTaskCounter struct {
}
// Name defines the name for the DecreaseTaskCounter listener
func (s *DecreaseTaskCounter) Name() string {
return "task.counter.decrease"
}
// Hanlde is executed when the event DecreaseTaskCounter listens on is fired
func (s *DecreaseTaskCounter) Handle(payload message.Payload) (err error) {
return keyvalue.DecrBy(metrics.TaskCountKey, 1)
}
///////
// List Event Listeners
type IncreaseListCounter struct {
}
func (s *IncreaseListCounter) Name() string {
return "list.counter.increase"
}
func (s *IncreaseListCounter) Handle(payload message.Payload) (err error) {
return keyvalue.IncrBy(metrics.ListCountKey, 1)
}
type DecreaseListCounter struct {
}
func (s *DecreaseListCounter) Name() string {
return "list.counter.decrease"
}
func (s *DecreaseListCounter) Handle(payload message.Payload) (err error) {
return keyvalue.DecrBy(metrics.ListCountKey, 1)
}
//////
// Namespace events
// IncreaseNamespaceCounter represents a listener
type IncreaseNamespaceCounter struct {
}
// Name defines the name for the IncreaseNamespaceCounter listener
func (s *IncreaseNamespaceCounter) Name() string {
return "namespace.counter.increase"
}
// Hanlde is executed when the event IncreaseNamespaceCounter listens on is fired
func (s *IncreaseNamespaceCounter) Handle(payload message.Payload) (err error) {
return keyvalue.IncrBy(metrics.NamespaceCountKey, 1)
}
// DecreaseNamespaceCounter represents a listener
type DecreaseNamespaceCounter struct {
}
// Name defines the name for the DecreaseNamespaceCounter listener
func (s *DecreaseNamespaceCounter) Name() string {
return "namespace.counter.decrease"
}
// Hanlde is executed when the event DecreaseNamespaceCounter listens on is fired
func (s *DecreaseNamespaceCounter) Handle(payload message.Payload) (err error) {
return keyvalue.DecrBy(metrics.NamespaceCountKey, 1)
}
///////
// Team Events
// IncreaseTeamCounter represents a listener
type IncreaseTeamCounter struct {
}
// Name defines the name for the IncreaseTeamCounter listener
func (s *IncreaseTeamCounter) Name() string {
return "team.counter.increase"
}
// Hanlde is executed when the event IncreaseTeamCounter listens on is fired
func (s *IncreaseTeamCounter) Handle(payload message.Payload) (err error) {
return keyvalue.IncrBy(metrics.TeamCountKey, 1)
}
// DecreaseTeamCounter represents a listener
type DecreaseTeamCounter struct {
}
// Name defines the name for the DecreaseTeamCounter listener
func (s *DecreaseTeamCounter) Name() string {
return "team.counter.decrease"
}
// Hanlde is executed when the event DecreaseTeamCounter listens on is fired
func (s *DecreaseTeamCounter) Handle(payload message.Payload) (err error) {
return keyvalue.DecrBy(metrics.TeamCountKey, 1)
}

View File

@ -22,6 +22,8 @@ import (
"testing"
"time"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/files"
"code.vikunja.io/api/pkg/user"
@ -64,5 +66,7 @@ func TestMain(m *testing.M) {
SetupTests()
events.Fake()
os.Exit(m.Run())
}

View File

@ -22,8 +22,9 @@ import (
"strings"
"time"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/metrics"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/web"
"xorm.io/builder"
@ -159,7 +160,7 @@ func (n *Namespace) CheckIsArchived(s *xorm.Session) error {
// @Failure 403 {object} web.HTTPError "The user does not have access to that namespace."
// @Failure 500 {object} models.Message "Internal error"
// @Router /namespaces/{id} [get]
func (n *Namespace) ReadOne(s *xorm.Session) (err error) {
func (n *Namespace) ReadOne(s *xorm.Session, a web.Auth) (err error) {
nn, err := GetNamespaceByID(s, n.ID)
if err != nil {
return err
@ -478,7 +479,14 @@ func (n *Namespace) Create(s *xorm.Session, a web.Auth) (err error) {
return err
}
metrics.UpdateCount(1, metrics.NamespaceCountKey)
err = events.Dispatch(&NamespaceCreatedEvent{
Namespace: n,
Doer: a,
})
if err != nil {
return err
}
return
}
@ -504,7 +512,7 @@ func CreateNewNamespaceForUser(s *xorm.Session, user *user.User) (err error) {
// @Failure 403 {object} web.HTTPError "The user does not have access to the namespace"
// @Failure 500 {object} models.Message "Internal error"
// @Router /namespaces/{id} [delete]
func (n *Namespace) Delete(s *xorm.Session) (err error) {
func (n *Namespace) Delete(s *xorm.Session, a web.Auth) (err error) {
// Check if the namespace exists
_, err = GetNamespaceByID(s, n.ID)
@ -523,6 +531,14 @@ func (n *Namespace) Delete(s *xorm.Session) (err error) {
if err != nil {
return
}
if len(lists) == 0 {
return events.Dispatch(&NamespaceDeletedEvent{
Namespace: n,
Doer: a,
})
}
var listIDs []int64
// We need to do that for here because we need the list ids to delete two times:
// 1) to delete the lists itself
@ -543,9 +559,10 @@ func (n *Namespace) Delete(s *xorm.Session) (err error) {
return
}
metrics.UpdateCount(-1, metrics.NamespaceCountKey)
return
return events.Dispatch(&NamespaceDeletedEvent{
Namespace: n,
Doer: a,
})
}
// Update implements the update method via the interface
@ -562,7 +579,7 @@ func (n *Namespace) Delete(s *xorm.Session) (err error) {
// @Failure 403 {object} web.HTTPError "The user does not have access to the namespace"
// @Failure 500 {object} models.Message "Internal error"
// @Router /namespace/{id} [post]
func (n *Namespace) Update(s *xorm.Session) (err error) {
func (n *Namespace) Update(s *xorm.Session, a web.Auth) (err error) {
// Check if we have at least a name
if n.Title == "" {
return ErrNamespaceNameCannotBeEmpty{NamespaceID: n.ID}
@ -605,5 +622,12 @@ func (n *Namespace) Update(s *xorm.Session) (err error) {
ID(currentNamespace.ID).
Cols(colsToUpdate...).
Update(n)
return
if err != nil {
return err
}
return events.Dispatch(&NamespaceUpdatedEvent{
Namespace: n,
Doer: a,
})
}

View File

@ -19,6 +19,8 @@ package models
import (
"time"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/web"
"xorm.io/xorm"
)
@ -71,15 +73,15 @@ func (tn *TeamNamespace) Create(s *xorm.Session, a web.Auth) (err error) {
}
// Check if the team exists
_, err = GetTeamByID(s, tn.TeamID)
team, err := GetTeamByID(s, tn.TeamID)
if err != nil {
return
return err
}
// Check if the namespace exists
_, err = GetNamespaceByID(s, tn.NamespaceID)
namespace, err := GetNamespaceByID(s, tn.NamespaceID)
if err != nil {
return
return err
}
// Check if the team already has access to the namespace
@ -96,7 +98,15 @@ func (tn *TeamNamespace) Create(s *xorm.Session, a web.Auth) (err error) {
// Insert the new team
_, err = s.Insert(tn)
return
if err != nil {
return err
}
return events.Dispatch(&NamespaceSharedWithTeamEvent{
Namespace: namespace,
Team: team,
Doer: a,
})
}
// Delete deletes a team <-> namespace relation based on the namespace & team id
@ -112,7 +122,7 @@ func (tn *TeamNamespace) Create(s *xorm.Session, a web.Auth) (err error) {
// @Failure 404 {object} web.HTTPError "team or namespace does not exist."
// @Failure 500 {object} models.Message "Internal error"
// @Router /namespaces/{namespaceID}/teams/{teamID} [delete]
func (tn *TeamNamespace) Delete(s *xorm.Session) (err error) {
func (tn *TeamNamespace) Delete(s *xorm.Session, a web.Auth) (err error) {
// Check if the team exists
_, err = GetTeamByID(s, tn.TeamID)
@ -219,7 +229,7 @@ func (tn *TeamNamespace) ReadAll(s *xorm.Session, a web.Auth, search string, pag
// @Failure 404 {object} web.HTTPError "Team or namespace does not exist."
// @Failure 500 {object} models.Message "Internal error"
// @Router /namespaces/{namespaceID}/teams/{teamID} [post]
func (tn *TeamNamespace) Update(s *xorm.Session) (err error) {
func (tn *TeamNamespace) Update(s *xorm.Session, a web.Auth) (err error) {
// Check if the right is valid
if err := tn.Right.isValid(); err != nil {

View File

@ -157,7 +157,7 @@ func TestTeamNamespace_Delete(t *testing.T) {
s := db.NewSession()
allowed, _ := tn.CanDelete(s, u)
assert.True(t, allowed)
err := tn.Delete(s)
err := tn.Delete(s, u)
assert.NoError(t, err)
err = s.Commit()
assert.NoError(t, err)
@ -174,7 +174,7 @@ func TestTeamNamespace_Delete(t *testing.T) {
}
db.LoadAndAssertFixtures(t)
s := db.NewSession()
err := tn.Delete(s)
err := tn.Delete(s, u)
assert.Error(t, err)
assert.True(t, IsErrTeamDoesNotExist(err))
_ = s.Close()
@ -186,7 +186,7 @@ func TestTeamNamespace_Delete(t *testing.T) {
}
db.LoadAndAssertFixtures(t)
s := db.NewSession()
err := tn.Delete(s)
err := tn.Delete(s, u)
assert.Error(t, err)
assert.True(t, IsErrTeamDoesNotHaveAccessToNamespace(err))
_ = s.Close()
@ -260,7 +260,7 @@ func TestTeamNamespace_Update(t *testing.T) {
CRUDable: tt.fields.CRUDable,
Rights: tt.fields.Rights,
}
err := tl.Update(s)
err := tl.Update(s, &user.User{ID: 1})
if (err != nil) != tt.wantErr {
t.Errorf("TeamNamespace.Update() error = %v, wantErr %v", err, tt.wantErr)
}

View File

@ -69,11 +69,13 @@ func TestNamespace_Create(t *testing.T) {
}
func TestNamespace_ReadOne(t *testing.T) {
u := &user.User{ID: 1}
t.Run("normal", func(t *testing.T) {
n := &Namespace{ID: 1}
db.LoadAndAssertFixtures(t)
s := db.NewSession()
err := n.ReadOne(s)
err := n.ReadOne(s, u)
assert.NoError(t, err)
assert.Equal(t, n.Title, "testnamespace")
_ = s.Close()
@ -82,7 +84,7 @@ func TestNamespace_ReadOne(t *testing.T) {
n := &Namespace{ID: 99999}
db.LoadAndAssertFixtures(t)
s := db.NewSession()
err := n.ReadOne(s)
err := n.ReadOne(s, u)
assert.Error(t, err)
assert.True(t, IsErrNamespaceDoesNotExist(err))
_ = s.Close()
@ -90,6 +92,8 @@ func TestNamespace_ReadOne(t *testing.T) {
}
func TestNamespace_Update(t *testing.T) {
u := &user.User{ID: 1}
t.Run("normal", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
@ -97,7 +101,7 @@ func TestNamespace_Update(t *testing.T) {
ID: 1,
Title: "Lorem Ipsum",
}
err := n.Update(s)
err := n.Update(s, u)
assert.NoError(t, err)
err = s.Commit()
assert.NoError(t, err)
@ -114,7 +118,7 @@ func TestNamespace_Update(t *testing.T) {
ID: 99999,
Title: "Lorem Ipsum",
}
err := n.Update(s)
err := n.Update(s, u)
assert.Error(t, err)
assert.True(t, IsErrNamespaceDoesNotExist(err))
_ = s.Close()
@ -127,7 +131,7 @@ func TestNamespace_Update(t *testing.T) {
Title: "Lorem Ipsum",
Owner: &user.User{ID: 99999},
}
err := n.Update(s)
err := n.Update(s, u)
assert.Error(t, err)
assert.True(t, user.IsErrUserDoesNotExist(err))
_ = s.Close()
@ -138,7 +142,7 @@ func TestNamespace_Update(t *testing.T) {
n := &Namespace{
ID: 1,
}
err := n.Update(s)
err := n.Update(s, u)
assert.Error(t, err)
assert.True(t, IsErrNamespaceNameCannotBeEmpty(err))
_ = s.Close()
@ -146,13 +150,15 @@ func TestNamespace_Update(t *testing.T) {
}
func TestNamespace_Delete(t *testing.T) {
u := &user.User{ID: 1}
t.Run("normal", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
n := &Namespace{
ID: 1,
}
err := n.Delete(s)
err := n.Delete(s, u)
assert.NoError(t, err)
err = s.Commit()
assert.NoError(t, err)
@ -167,7 +173,7 @@ func TestNamespace_Delete(t *testing.T) {
n := &Namespace{
ID: 9999,
}
err := n.Delete(s)
err := n.Delete(s, u)
assert.Error(t, err)
assert.True(t, IsErrNamespaceDoesNotExist(err))
_ = s.Close()

View File

@ -19,6 +19,8 @@ package models
import (
"time"
"code.vikunja.io/api/pkg/events"
user2 "code.vikunja.io/api/pkg/user"
"code.vikunja.io/web"
"xorm.io/xorm"
@ -75,7 +77,7 @@ func (nu *NamespaceUser) Create(s *xorm.Session, a web.Auth) (err error) {
}
// Check if the namespace exists
l, err := GetNamespaceByID(s, nu.NamespaceID)
n, err := GetNamespaceByID(s, nu.NamespaceID)
if err != nil {
return
}
@ -89,7 +91,7 @@ func (nu *NamespaceUser) Create(s *xorm.Session, a web.Auth) (err error) {
// Check if the user already has access or is owner of that namespace
// We explicitly DO NOT check for teams here
if l.OwnerID == nu.UserID {
if n.OwnerID == nu.UserID {
return ErrUserAlreadyHasNamespaceAccess{UserID: nu.UserID, NamespaceID: nu.NamespaceID}
}
@ -105,8 +107,15 @@ func (nu *NamespaceUser) Create(s *xorm.Session, a web.Auth) (err error) {
// Insert user <-> namespace relation
_, err = s.Insert(nu)
if err != nil {
return err
}
return
return events.Dispatch(&NamespaceSharedWithUserEvent{
Namespace: n,
User: user,
Doer: a,
})
}
// Delete deletes a namespace <-> user relation
@ -122,7 +131,7 @@ func (nu *NamespaceUser) Create(s *xorm.Session, a web.Auth) (err error) {
// @Failure 404 {object} web.HTTPError "user or namespace does not exist."
// @Failure 500 {object} models.Message "Internal error"
// @Router /namespaces/{namespaceID}/users/{userID} [delete]
func (nu *NamespaceUser) Delete(s *xorm.Session) (err error) {
func (nu *NamespaceUser) Delete(s *xorm.Session, a web.Auth) (err error) {
// Check if the user exists
user, err := user2.GetUserByUsername(s, nu.Username)
@ -220,7 +229,7 @@ func (nu *NamespaceUser) ReadAll(s *xorm.Session, a web.Auth, search string, pag
// @Failure 404 {object} web.HTTPError "User or namespace does not exist."
// @Failure 500 {object} models.Message "Internal error"
// @Router /namespaces/{namespaceID}/users/{userID} [post]
func (nu *NamespaceUser) Update(s *xorm.Session) (err error) {
func (nu *NamespaceUser) Update(s *xorm.Session, a web.Auth) (err error) {
// Check if the right is valid
if err := nu.Right.isValid(); err != nil {

View File

@ -315,7 +315,7 @@ func TestNamespaceUser_Update(t *testing.T) {
CRUDable: tt.fields.CRUDable,
Rights: tt.fields.Rights,
}
err := nu.Update(s)
err := nu.Update(s, &user.User{ID: 1})
if (err != nil) != tt.wantErr {
t.Errorf("NamespaceUser.Update() error = %v, wantErr %v", err, tt.wantErr)
}
@ -396,7 +396,7 @@ func TestNamespaceUser_Delete(t *testing.T) {
CRUDable: tt.fields.CRUDable,
Rights: tt.fields.Rights,
}
err := nu.Delete(s)
err := nu.Delete(s, &user.User{ID: 1})
if (err != nil) != tt.wantErr {
t.Errorf("NamespaceUser.Delete() error = %v, wantErr %v", err, tt.wantErr)
}

View File

@ -133,7 +133,7 @@ func getSavedFilterSimpleByID(s *xorm.Session, id int64) (sf *SavedFilter, err e
// @Failure 403 {object} web.HTTPError "The user does not have access to that saved filter."
// @Failure 500 {object} models.Message "Internal error"
// @Router /filters/{id} [get]
func (sf *SavedFilter) ReadOne(s *xorm.Session) error {
func (sf *SavedFilter) ReadOne(s *xorm.Session, a web.Auth) error {
// s already contains almost the full saved filter from the rights check, we only need to add the user
u, err := user.GetUserByID(s, sf.OwnerID)
sf.Owner = u
@ -153,7 +153,7 @@ func (sf *SavedFilter) ReadOne(s *xorm.Session) error {
// @Failure 404 {object} web.HTTPError "The saved filter does not exist."
// @Failure 500 {object} models.Message "Internal error"
// @Router /filters/{id} [post]
func (sf *SavedFilter) Update(s *xorm.Session) error {
func (sf *SavedFilter) Update(s *xorm.Session, a web.Auth) error {
_, err := s.
Where("id = ?", sf.ID).
Cols(
@ -178,7 +178,7 @@ func (sf *SavedFilter) Update(s *xorm.Session) error {
// @Failure 404 {object} web.HTTPError "The saved filter does not exist."
// @Failure 500 {object} models.Message "Internal error"
// @Router /filters/{id} [delete]
func (sf *SavedFilter) Delete(s *xorm.Session) error {
func (sf *SavedFilter) Delete(s *xorm.Session, a web.Auth) error {
_, err := s.
Where("id = ?", sf.ID).
Delete(sf)

View File

@ -86,7 +86,7 @@ func TestSavedFilter_ReadOne(t *testing.T) {
// canRead pre-populates the struct
_, _, err := sf.CanRead(s, user1)
assert.NoError(t, err)
err = sf.ReadOne(s)
err = sf.ReadOne(s, user1)
assert.NoError(t, err)
assert.NotNil(t, sf.Owner)
}
@ -102,7 +102,7 @@ func TestSavedFilter_Update(t *testing.T) {
Description: "", // Explicitly reset the description
Filters: &TaskCollection{},
}
err := sf.Update(s)
err := sf.Update(s, &user.User{ID: 1})
assert.NoError(t, err)
err = s.Commit()
assert.NoError(t, err)
@ -121,7 +121,7 @@ func TestSavedFilter_Delete(t *testing.T) {
sf := &SavedFilter{
ID: 1,
}
err := sf.Delete(s)
err := sf.Delete(s, &user.User{ID: 1})
assert.NoError(t, err)
err = s.Commit()
assert.NoError(t, err)

View File

@ -19,6 +19,8 @@ package models
import (
"time"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/web"
"xorm.io/xorm"
@ -57,7 +59,7 @@ func getRawTaskAssigneesForTasks(s *xorm.Session, taskIDs []int64) (taskAssignee
}
// Create or update a bunch of task assignees
func (t *Task) updateTaskAssignees(s *xorm.Session, assignees []*user.User) (err error) {
func (t *Task) updateTaskAssignees(s *xorm.Session, assignees []*user.User, doer web.Auth) (err error) {
// Load the current assignees
currentAssignees, err := getRawTaskAssigneesForTasks(s, []int64{t.ID})
@ -132,7 +134,7 @@ func (t *Task) updateTaskAssignees(s *xorm.Session, assignees []*user.User) (err
}
// Add the new assignee
err = t.addNewAssigneeByID(s, u.ID, list)
err = t.addNewAssigneeByID(s, u.ID, list, doer)
if err != nil {
return err
}
@ -166,7 +168,7 @@ func (t *Task) setTaskAssignees(assignees []*user.User) {
// @Failure 403 {object} web.HTTPError "Not allowed to delete the assignee."
// @Failure 500 {object} models.Message "Internal error"
// @Router /tasks/{taskID}/assignees/{userID} [delete]
func (la *TaskAssginee) Delete(s *xorm.Session) (err error) {
func (la *TaskAssginee) Delete(s *xorm.Session, a web.Auth) (err error) {
_, err = s.Delete(&TaskAssginee{TaskID: la.TaskID, UserID: la.UserID})
if err != nil {
return err
@ -198,10 +200,10 @@ func (la *TaskAssginee) Create(s *xorm.Session, a web.Auth) (err error) {
}
task := &Task{ID: la.TaskID}
return task.addNewAssigneeByID(s, la.UserID, list)
return task.addNewAssigneeByID(s, la.UserID, list, a)
}
func (t *Task) addNewAssigneeByID(s *xorm.Session, newAssigneeID int64, list *List) (err error) {
func (t *Task) addNewAssigneeByID(s *xorm.Session, newAssigneeID int64, list *List, auth web.Auth) (err error) {
// Check if the user exists and has access to the list
newAssignee, err := user.GetUserByID(s, newAssigneeID)
if err != nil {
@ -223,6 +225,15 @@ func (t *Task) addNewAssigneeByID(s *xorm.Session, newAssigneeID int64, list *Li
return err
}
err = events.Dispatch(&TaskAssigneeCreatedEvent{
Task: t,
Assignee: newAssignee,
Doer: auth,
})
if err != nil {
return err
}
err = updateListLastUpdated(s, &List{ID: t.ListID})
return
}
@ -313,6 +324,6 @@ func (ba *BulkAssignees) Create(s *xorm.Session, a web.Auth) (err error) {
task.Assignees = append(task.Assignees, &a.User)
}
err = task.updateTaskAssignees(s, ba.Assignees)
err = task.updateTaskAssignees(s, ba.Assignees, a)
return
}

View File

@ -80,7 +80,7 @@ func (ta *TaskAttachment) NewAttachment(s *xorm.Session, f io.ReadCloser, realna
}
// ReadOne returns a task attachment
func (ta *TaskAttachment) ReadOne(s *xorm.Session) (err error) {
func (ta *TaskAttachment) ReadOne(s *xorm.Session, a web.Auth) (err error) {
exists, err := s.Where("id = ?", ta.ID).Get(ta)
if err != nil {
return
@ -176,9 +176,9 @@ func (ta *TaskAttachment) ReadAll(s *xorm.Session, a web.Auth, search string, pa
// @Failure 404 {object} models.Message "The task does not exist."
// @Failure 500 {object} models.Message "Internal error"
// @Router /tasks/{id}/attachments/{attachmentID} [delete]
func (ta *TaskAttachment) Delete(s *xorm.Session) error {
func (ta *TaskAttachment) Delete(s *xorm.Session, a web.Auth) error {
// Load the attachment
err := ta.ReadOne(s)
err := ta.ReadOne(s, a)
if err != nil && !files.IsErrFileDoesNotExist(err) {
return err
}
@ -209,6 +209,10 @@ func getTaskAttachmentsByTaskIDs(s *xorm.Session, taskIDs []int64) (attachments
return
}
if len(attachments) == 0 {
return
}
fileIDs := []int64{}
userIDs := []int64{}
for _, a := range attachments {

View File

@ -30,6 +30,8 @@ import (
)
func TestTaskAttachment_ReadOne(t *testing.T) {
u := &user.User{ID: 1}
t.Run("Normal File", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
@ -39,7 +41,7 @@ func TestTaskAttachment_ReadOne(t *testing.T) {
ta := &TaskAttachment{
ID: 1,
}
err := ta.ReadOne(s)
err := ta.ReadOne(s, u)
assert.NoError(t, err)
assert.NotNil(t, ta.File)
assert.True(t, ta.File.ID == ta.FileID && ta.FileID != 0)
@ -63,7 +65,7 @@ func TestTaskAttachment_ReadOne(t *testing.T) {
ta := &TaskAttachment{
ID: 9999,
}
err := ta.ReadOne(s)
err := ta.ReadOne(s, u)
assert.Error(t, err)
assert.True(t, IsErrTaskAttachmentDoesNotExist(err))
})
@ -76,7 +78,7 @@ func TestTaskAttachment_ReadOne(t *testing.T) {
ta := &TaskAttachment{
ID: 2,
}
err := ta.ReadOne(s)
err := ta.ReadOne(s, u)
assert.Error(t, err)
assert.EqualError(t, err, "file 9999 does not exist")
})
@ -153,10 +155,12 @@ func TestTaskAttachment_Delete(t *testing.T) {
s := db.NewSession()
defer s.Close()
u := &user.User{ID: 1}
files.InitTestFileFixtures(t)
t.Run("Normal", func(t *testing.T) {
ta := &TaskAttachment{ID: 1}
err := ta.Delete(s)
err := ta.Delete(s, u)
assert.NoError(t, err)
// Check if the file itself was deleted
_, err = files.FileStat("/1") // The new file has the id 2 since it's the second attachment
@ -165,14 +169,14 @@ func TestTaskAttachment_Delete(t *testing.T) {
t.Run("Nonexisting", func(t *testing.T) {
files.InitTestFileFixtures(t)
ta := &TaskAttachment{ID: 9999}
err := ta.Delete(s)
err := ta.Delete(s, u)
assert.Error(t, err)
assert.True(t, IsErrTaskAttachmentDoesNotExist(err))
})
t.Run("Existing attachment, nonexisting file", func(t *testing.T) {
files.InitTestFileFixtures(t)
ta := &TaskAttachment{ID: 2}
err := ta.Delete(s)
err := ta.Delete(s, u)
assert.NoError(t, err)
})
}

View File

@ -19,6 +19,8 @@ package models
import (
"time"
"code.vikunja.io/api/pkg/events"
"xorm.io/xorm"
"code.vikunja.io/api/pkg/user"
@ -60,7 +62,7 @@ func (tc *TaskComment) TableName() string {
// @Router /tasks/{taskID}/comments [put]
func (tc *TaskComment) Create(s *xorm.Session, a web.Auth) (err error) {
// Check if the task exists
_, err = GetTaskSimple(s, &Task{ID: tc.TaskID})
task, err := GetTaskSimple(s, &Task{ID: tc.TaskID})
if err != nil {
return err
}
@ -70,6 +72,16 @@ func (tc *TaskComment) Create(s *xorm.Session, a web.Auth) (err error) {
if err != nil {
return
}
err = events.Dispatch(&TaskCommentCreatedEvent{
Task: &task,
Comment: tc,
Doer: a,
})
if err != nil {
return err
}
tc.Author, err = user.GetUserByID(s, a.GetID())
return
}
@ -88,7 +100,7 @@ func (tc *TaskComment) Create(s *xorm.Session, a web.Auth) (err error) {
// @Failure 404 {object} web.HTTPError "The task comment was not found."
// @Failure 500 {object} models.Message "Internal error"
// @Router /tasks/{taskID}/comments/{commentID} [delete]
func (tc *TaskComment) Delete(s *xorm.Session) error {
func (tc *TaskComment) Delete(s *xorm.Session, a web.Auth) error {
deleted, err := s.
ID(tc.ID).
NoAutoCondition().
@ -113,7 +125,7 @@ func (tc *TaskComment) Delete(s *xorm.Session) error {
// @Failure 404 {object} web.HTTPError "The task comment was not found."
// @Failure 500 {object} models.Message "Internal error"
// @Router /tasks/{taskID}/comments/{commentID} [post]
func (tc *TaskComment) Update(s *xorm.Session) error {
func (tc *TaskComment) Update(s *xorm.Session, a web.Auth) error {
updated, err := s.
ID(tc.ID).
Cols("comment").
@ -138,7 +150,7 @@ func (tc *TaskComment) Update(s *xorm.Session) error {
// @Failure 404 {object} web.HTTPError "The task comment was not found."
// @Failure 500 {object} models.Message "Internal error"
// @Router /tasks/{taskID}/comments/{commentID} [get]
func (tc *TaskComment) ReadOne(s *xorm.Session) (err error) {
func (tc *TaskComment) ReadOne(s *xorm.Session, a web.Auth) (err error) {
exists, err := s.Get(tc)
if err != nil {
return

View File

@ -65,13 +65,15 @@ func TestTaskComment_Create(t *testing.T) {
}
func TestTaskComment_Delete(t *testing.T) {
u := &user.User{ID: 1}
t.Run("normal", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
tc := &TaskComment{ID: 1}
err := tc.Delete(s)
err := tc.Delete(s, u)
assert.NoError(t, err)
err = s.Commit()
assert.NoError(t, err)
@ -86,13 +88,15 @@ func TestTaskComment_Delete(t *testing.T) {
defer s.Close()
tc := &TaskComment{ID: 9999}
err := tc.Delete(s)
err := tc.Delete(s, u)
assert.Error(t, err)
assert.True(t, IsErrTaskCommentDoesNotExist(err))
})
}
func TestTaskComment_Update(t *testing.T) {
u := &user.User{ID: 1}
t.Run("normal", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
@ -102,7 +106,7 @@ func TestTaskComment_Update(t *testing.T) {
ID: 1,
Comment: "testing",
}
err := tc.Update(s)
err := tc.Update(s, u)
assert.NoError(t, err)
err = s.Commit()
assert.NoError(t, err)
@ -120,20 +124,22 @@ func TestTaskComment_Update(t *testing.T) {
tc := &TaskComment{
ID: 9999,
}
err := tc.Update(s)
err := tc.Update(s, u)
assert.Error(t, err)
assert.True(t, IsErrTaskCommentDoesNotExist(err))
})
}
func TestTaskComment_ReadOne(t *testing.T) {
u := &user.User{ID: 1}
t.Run("normal", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
tc := &TaskComment{ID: 1}
err := tc.ReadOne(s)
err := tc.ReadOne(s, u)
assert.NoError(t, err)
assert.Equal(t, "Lorem Ipsum Dolor Sit Amet", tc.Comment)
assert.NotEmpty(t, tc.Author.ID)
@ -144,7 +150,7 @@ func TestTaskComment_ReadOne(t *testing.T) {
defer s.Close()
tc := &TaskComment{ID: 9999}
err := tc.ReadOne(s)
err := tc.ReadOne(s, u)
assert.Error(t, err)
assert.True(t, IsErrTaskCommentDoesNotExist(err))
})

View File

@ -201,7 +201,7 @@ func (rel *TaskRelation) Create(s *xorm.Session, a web.Auth) error {
// @Failure 404 {object} web.HTTPError "The task relation was not found."
// @Failure 500 {object} models.Message "Internal error"
// @Router /tasks/{taskID}/relations [delete]
func (rel *TaskRelation) Delete(s *xorm.Session) error {
func (rel *TaskRelation) Delete(s *xorm.Session, a web.Auth) error {
// Check if the relation exists
exists, err := s.
Cols("task_id", "other_task_id", "relation_kind").

View File

@ -97,6 +97,8 @@ func TestTaskRelation_Create(t *testing.T) {
}
func TestTaskRelation_Delete(t *testing.T) {
u := &user.User{ID: 1}
t.Run("Normal", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
@ -107,7 +109,7 @@ func TestTaskRelation_Delete(t *testing.T) {
OtherTaskID: 29,
RelationKind: RelationKindSubtask,
}
err := rel.Delete(s)
err := rel.Delete(s, u)
assert.NoError(t, err)
err = s.Commit()
assert.NoError(t, err)
@ -127,7 +129,7 @@ func TestTaskRelation_Delete(t *testing.T) {
OtherTaskID: 3,
RelationKind: RelationKindSubtask,
}
err := rel.Delete(s)
err := rel.Delete(s, u)
assert.Error(t, err)
assert.True(t, IsErrRelationDoesNotExist(err))
})

View File

@ -22,10 +22,11 @@ import (
"strconv"
"time"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/metrics"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/api/pkg/utils"
"code.vikunja.io/web"
@ -596,6 +597,11 @@ func addRelatedTasksToTasks(s *xorm.Session, taskIDs []int64, taskMap map[int64]
for _, rt := range relatedTasks {
relatedTaskIDs = append(relatedTaskIDs, rt.OtherTaskID)
}
if len(relatedTaskIDs) == 0 {
return
}
fullRelatedTasks := make(map[int64]*Task)
err = s.In("id", relatedTaskIDs).Find(&fullRelatedTasks)
if err != nil {
@ -814,7 +820,7 @@ func createTask(s *xorm.Session, t *Task, a web.Auth, updateAssignees bool) (err
// Update the assignees
if updateAssignees {
if err := t.updateTaskAssignees(s, t.Assignees); err != nil {
if err := t.updateTaskAssignees(s, t.Assignees, a); err != nil {
return err
}
}
@ -824,10 +830,16 @@ func createTask(s *xorm.Session, t *Task, a web.Auth, updateAssignees bool) (err
return err
}
metrics.UpdateCount(1, metrics.TaskCountKey)
t.setIdentifier(l)
err = events.Dispatch(&TaskCreatedEvent{
Task: t,
Doer: a,
})
if err != nil {
return err
}
err = updateListLastUpdated(s, &List{ID: t.ListID})
return
}
@ -847,7 +859,7 @@ func createTask(s *xorm.Session, t *Task, a web.Auth, updateAssignees bool) (err
// @Failure 500 {object} models.Message "Internal error"
// @Router /tasks/{id} [post]
//nolint:gocyclo
func (t *Task) Update(s *xorm.Session) (err error) {
func (t *Task) Update(s *xorm.Session, a web.Auth) (err error) {
// Check if the task exists and get the old values
ot, err := GetTaskByIDSimple(s, t.ID)
@ -870,7 +882,7 @@ func (t *Task) Update(s *xorm.Session) (err error) {
updateDone(&ot, t)
// Update the assignees
if err := ot.updateTaskAssignees(s, t.Assignees); err != nil {
if err := ot.updateTaskAssignees(s, t.Assignees, a); err != nil {
return err
}
@ -1028,6 +1040,14 @@ func (t *Task) Update(s *xorm.Session) (err error) {
}
t.Updated = nt.Updated
err = events.Dispatch(&TaskUpdatedEvent{
Task: t,
Doer: a,
})
if err != nil {
return err
}
return updateListLastUpdated(s, &List{ID: t.ListID})
}
@ -1166,7 +1186,7 @@ func (t *Task) updateReminders(s *xorm.Session, reminders []time.Time) (err erro
// @Failure 403 {object} web.HTTPError "The user does not have access to the list"
// @Failure 500 {object} models.Message "Internal error"
// @Router /tasks/{id} [delete]
func (t *Task) Delete(s *xorm.Session) (err error) {
func (t *Task) Delete(s *xorm.Session, a web.Auth) (err error) {
if _, err = s.ID(t.ID).Delete(Task{}); err != nil {
return err
@ -1177,7 +1197,13 @@ func (t *Task) Delete(s *xorm.Session) (err error) {
return err
}
metrics.UpdateCount(-1, metrics.TaskCountKey)
err = events.Dispatch(&TaskDeletedEvent{
Task: t,
Doer: a,
})
if err != nil {
return
}
err = updateListLastUpdated(s, &List{ID: t.ListID})
return
@ -1195,7 +1221,7 @@ func (t *Task) Delete(s *xorm.Session) (err error) {
// @Failure 404 {object} models.Message "Task not found"
// @Failure 500 {object} models.Message "Internal error"
// @Router /tasks/{ID} [get]
func (t *Task) ReadOne(s *xorm.Session) (err error) {
func (t *Task) ReadOne(s *xorm.Session, a web.Auth) (err error) {
taskMap := make(map[int64]*Task, 1)
taskMap[t.ID] = &Task{}

View File

@ -20,6 +20,8 @@ import (
"testing"
"time"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/user"
"github.com/stretchr/testify/assert"
@ -65,6 +67,7 @@ func TestTask_Create(t *testing.T) {
"bucket_id": 1,
}, false)
events.AssertDispatched(t, &TaskCreatedEvent{})
})
t.Run("empty title", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
@ -127,6 +130,8 @@ func TestTask_Create(t *testing.T) {
}
func TestTask_Update(t *testing.T) {
u := &user.User{ID: 1}
t.Run("normal", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
@ -138,7 +143,7 @@ func TestTask_Update(t *testing.T) {
Description: "Lorem Ipsum Dolor",
ListID: 1,
}
err := task.Update(s)
err := task.Update(s, u)
assert.NoError(t, err)
err = s.Commit()
assert.NoError(t, err)
@ -161,7 +166,7 @@ func TestTask_Update(t *testing.T) {
Description: "Lorem Ipsum Dolor",
ListID: 1,
}
err := task.Update(s)
err := task.Update(s, u)
assert.Error(t, err)
assert.True(t, IsErrTaskDoesNotExist(err))
})
@ -177,7 +182,7 @@ func TestTask_Update(t *testing.T) {
ListID: 1,
BucketID: 2, // Bucket 2 already has 3 tasks and a limit of 3
}
err := task.Update(s)
err := task.Update(s, u)
assert.Error(t, err)
assert.True(t, IsErrBucketLimitExceeded(err))
})
@ -194,7 +199,7 @@ func TestTask_Update(t *testing.T) {
ListID: 1,
BucketID: 2, // Bucket 2 already has 3 tasks and a limit of 3
}
err := task.Update(s)
err := task.Update(s, u)
assert.NoError(t, err)
})
}
@ -208,7 +213,7 @@ func TestTask_Delete(t *testing.T) {
task := &Task{
ID: 1,
}
err := task.Delete(s)
err := task.Delete(s, &user.User{ID: 1})
assert.NoError(t, err)
err = s.Commit()
assert.NoError(t, err)
@ -440,13 +445,15 @@ func TestUpdateDone(t *testing.T) {
}
func TestTask_ReadOne(t *testing.T) {
u := &user.User{ID: 1}
t.Run("default", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
task := &Task{ID: 1}
err := task.ReadOne(s)
err := task.ReadOne(s, u)
assert.NoError(t, err)
assert.Equal(t, "task #1", task.Title)
})
@ -456,7 +463,7 @@ func TestTask_ReadOne(t *testing.T) {
defer s.Close()
task := &Task{ID: 99999}
err := task.ReadOne(s)
err := task.ReadOne(s, u)
assert.Error(t, err)
assert.True(t, IsErrTaskDoesNotExist(err))
})

View File

@ -17,6 +17,7 @@
package models
import (
"code.vikunja.io/api/pkg/events"
user2 "code.vikunja.io/api/pkg/user"
"code.vikunja.io/web"
"xorm.io/xorm"
@ -39,9 +40,9 @@ import (
func (tm *TeamMember) Create(s *xorm.Session, a web.Auth) (err error) {
// Check if the team extst
_, err = GetTeamByID(s, tm.TeamID)
team, err := GetTeamByID(s, tm.TeamID)
if err != nil {
return
return err
}
// Check if the user exists
@ -64,7 +65,15 @@ func (tm *TeamMember) Create(s *xorm.Session, a web.Auth) (err error) {
// Insert the user
_, err = s.Insert(tm)
return
if err != nil {
return err
}
return events.Dispatch(&TeamMemberAddedEvent{
Team: team,
Member: user,
Doer: a,
})
}
// Delete deletes a user from a team
@ -78,7 +87,7 @@ func (tm *TeamMember) Create(s *xorm.Session, a web.Auth) (err error) {
// @Success 200 {object} models.Message "The user was successfully removed from the team."
// @Failure 500 {object} models.Message "Internal error"
// @Router /teams/{id}/members/{userID} [delete]
func (tm *TeamMember) Delete(s *xorm.Session) (err error) {
func (tm *TeamMember) Delete(s *xorm.Session, a web.Auth) (err error) {
total, err := s.Where("team_id = ?", tm.TeamID).Count(&TeamMember{})
if err != nil {
@ -110,7 +119,7 @@ func (tm *TeamMember) Delete(s *xorm.Session) (err error) {
// @Success 200 {object} models.Message "The member right was successfully changed."
// @Failure 500 {object} models.Message "Internal error"
// @Router /teams/{id}/members/{userID}/admin [post]
func (tm *TeamMember) Update(s *xorm.Session) (err error) {
func (tm *TeamMember) Update(s *xorm.Session, a web.Auth) (err error) {
// Find the numeric user id
user, err := user2.GetUserByUsername(s, tm.Username)
if err != nil {

View File

@ -101,7 +101,7 @@ func TestTeamMember_Delete(t *testing.T) {
TeamID: 1,
Username: "user1",
}
err := tm.Delete(s)
err := tm.Delete(s, &user.User{ID: 1})
assert.NoError(t, err)
err = s.Commit()
assert.NoError(t, err)
@ -114,6 +114,8 @@ func TestTeamMember_Delete(t *testing.T) {
}
func TestTeamMember_Update(t *testing.T) {
u := &user.User{ID: 1}
t.Run("normal", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
@ -124,7 +126,7 @@ func TestTeamMember_Update(t *testing.T) {
Username: "user1",
Admin: true,
}
err := tm.Update(s)
err := tm.Update(s, u)
assert.NoError(t, err)
assert.False(t, tm.Admin) // Since this endpoint toggles the right, we should get a false for admin back.
err = s.Commit()
@ -148,7 +150,7 @@ func TestTeamMember_Update(t *testing.T) {
Username: "user1",
Admin: true,
}
err := tm.Update(s)
err := tm.Update(s, u)
assert.NoError(t, err)
assert.False(t, tm.Admin)
err = s.Commit()

View File

@ -19,9 +19,10 @@ package models
import (
"time"
"code.vikunja.io/api/pkg/events"
"xorm.io/xorm"
"code.vikunja.io/api/pkg/metrics"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/web"
"xorm.io/builder"
@ -119,6 +120,11 @@ func GetTeamByID(s *xorm.Session, id int64) (team *Team, err error) {
}
func addMoreInfoToTeams(s *xorm.Session, teams []*Team) (err error) {
if len(teams) == 0 {
return nil
}
// Put the teams in a map to make assigning more info to it more efficient
teamMap := make(map[int64]*Team, len(teams))
var teamIDs []int64
@ -177,7 +183,7 @@ func addMoreInfoToTeams(s *xorm.Session, teams []*Team) (err error) {
// @Failure 403 {object} web.HTTPError "The user does not have access to the team"
// @Failure 500 {object} models.Message "Internal error"
// @Router /teams/{id} [get]
func (t *Team) ReadOne(s *xorm.Session) (err error) {
func (t *Team) ReadOne(s *xorm.Session, a web.Auth) (err error) {
team, err := GetTeamByID(s, t.ID)
if team != nil {
*t = *team
@ -270,8 +276,10 @@ func (t *Team) Create(s *xorm.Session, a web.Auth) (err error) {
return err
}
metrics.UpdateCount(1, metrics.TeamCountKey)
return
return events.Dispatch(&TeamCreatedEvent{
Team: t,
Doer: a,
})
}
// Delete deletes a team
@ -285,7 +293,7 @@ func (t *Team) Create(s *xorm.Session, a web.Auth) (err error) {
// @Failure 400 {object} web.HTTPError "Invalid team object provided."
// @Failure 500 {object} models.Message "Internal error"
// @Router /teams/{id} [delete]
func (t *Team) Delete(s *xorm.Session) (err error) {
func (t *Team) Delete(s *xorm.Session, a web.Auth) (err error) {
// Delete the team
_, err = s.ID(t.ID).Delete(&Team{})
@ -311,8 +319,10 @@ func (t *Team) Delete(s *xorm.Session) (err error) {
return
}
metrics.UpdateCount(-1, metrics.TeamCountKey)
return
return events.Dispatch(&TeamDeletedEvent{
Team: t,
Doer: a,
})
}
// Update is the handler to create a team
@ -328,7 +338,7 @@ func (t *Team) Delete(s *xorm.Session) (err error) {
// @Failure 400 {object} web.HTTPError "Invalid team object provided."
// @Failure 500 {object} models.Message "Internal error"
// @Router /teams/{id} [post]
func (t *Team) Update(s *xorm.Session) (err error) {
func (t *Team) Update(s *xorm.Session, a web.Auth) (err error) {
// Check if we have a name
if t.Name == "" {
return ErrTeamNameCannotBeEmpty{}

View File

@ -62,13 +62,15 @@ func TestTeam_Create(t *testing.T) {
}
func TestTeam_ReadOne(t *testing.T) {
u := &user.User{ID: 1}
t.Run("normal", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
team := &Team{ID: 1}
err := team.ReadOne(s)
err := team.ReadOne(s, u)
assert.NoError(t, err)
assert.Equal(t, "testteam1", team.Name)
assert.Equal(t, "Lorem Ipsum", team.Description)
@ -81,7 +83,7 @@ func TestTeam_ReadOne(t *testing.T) {
defer s.Close()
team := &Team{ID: -1}
err := team.ReadOne(s)
err := team.ReadOne(s, u)
assert.Error(t, err)
assert.True(t, IsErrTeamDoesNotExist(err))
})
@ -91,7 +93,7 @@ func TestTeam_ReadOne(t *testing.T) {
defer s.Close()
team := &Team{ID: 99999}
err := team.ReadOne(s)
err := team.ReadOne(s, u)
assert.Error(t, err)
assert.True(t, IsErrTeamDoesNotExist(err))
})
@ -113,6 +115,8 @@ func TestTeam_ReadAll(t *testing.T) {
}
func TestTeam_Update(t *testing.T) {
u := &user.User{ID: 1}
t.Run("normal", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
@ -122,7 +126,7 @@ func TestTeam_Update(t *testing.T) {
ID: 1,
Name: "SomethingNew",
}
err := team.Update(s)
err := team.Update(s, u)
assert.NoError(t, err)
err = s.Commit()
assert.NoError(t, err)
@ -140,7 +144,7 @@ func TestTeam_Update(t *testing.T) {
ID: 1,
Name: "",
}
err := team.Update(s)
err := team.Update(s, u)
assert.Error(t, err)
assert.True(t, IsErrTeamNameCannotBeEmpty(err))
})
@ -153,13 +157,15 @@ func TestTeam_Update(t *testing.T) {
ID: 9999,
Name: "SomethingNew",
}
err := team.Update(s)
err := team.Update(s, u)
assert.Error(t, err)
assert.True(t, IsErrTeamDoesNotExist(err))
})
}
func TestTeam_Delete(t *testing.T) {
u := &user.User{ID: 1}
t.Run("normal", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
@ -168,7 +174,7 @@ func TestTeam_Delete(t *testing.T) {
team := &Team{
ID: 1,
}
err := team.Delete(s)
err := team.Delete(s, u)
assert.NoError(t, err)
err = s.Commit()
assert.NoError(t, err)

View File

@ -20,6 +20,8 @@ import (
"os"
"testing"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/files"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/user"
@ -30,5 +32,6 @@ func TestMain(m *testing.M) {
user.InitTests()
files.InitTests()
models.SetupTests()
events.Fake()
os.Exit(m.Run())
}

View File

@ -226,7 +226,7 @@ func InsertFromStructure(str []*models.NamespaceWithLists, user *user.User) (err
return err
}
buckets := bucketsIn.([]*models.Bucket)
err = buckets[0].Delete(s)
err = buckets[0].Delete(s, user)
if err != nil {
_ = s.Rollback()
return err

View File

@ -20,6 +20,8 @@ import (
"os"
"testing"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/files"
"code.vikunja.io/api/pkg/models"
@ -37,5 +39,6 @@ func TestMain(m *testing.M) {
files.InitTests()
user.InitTests()
models.SetupTests()
events.Fake()
os.Exit(m.Run())
}

View File

@ -104,7 +104,7 @@ func RenewToken(c echo.Context) (err error) {
if typ == auth.AuthTypeLinkShare {
share := &models.LinkSharing{}
share.ID = int64(claims["id"].(float64))
err := share.ReadOne(s)
err := share.ReadOne(s, share)
if err != nil {
_ = s.Rollback()
return handler.HandleHTTPError(err, c)

View File

@ -147,7 +147,7 @@ func GetTaskAttachment(c echo.Context) error {
}
// Get the attachment incl file
err = taskAttachment.ReadOne(s)
err = taskAttachment.ReadOne(s, auth)
if err != nil {
_ = s.Rollback()
return handler.HandleHTTPError(err, c)

View File

@ -318,7 +318,7 @@ func (vcls *VikunjaCaldavListStorage) UpdateResource(rpath, content string) (*da
}
// Update the task
err = vTask.Update(s)
err = vTask.Update(s, vcls.user)
if err != nil {
_ = s.Rollback()
return nil, err
@ -354,7 +354,7 @@ func (vcls *VikunjaCaldavListStorage) DeleteResource(rpath string) error {
}
// Delete it
err = vcls.task.Delete(s)
err = vcls.task.Delete(s, vcls.user)
if err != nil {
_ = s.Rollback()
return err
@ -458,7 +458,7 @@ func (vcls *VikunjaCaldavListStorage) getListRessource(isCollection bool) (rr Vi
log.Errorf("User %v tried to access a caldav resource (List %v) which they are not allowed to access", vcls.user.Username, vcls.list.ID)
return rr, models.ErrUserDoesNotHaveAccessToList{ListID: vcls.list.ID}
}
err = vcls.list.ReadOne(s)
err = vcls.list.ReadOne(s, vcls.user)
if err != nil {
_ = s.Rollback()
return

View File

@ -71,7 +71,7 @@ func setupMetrics(a *echo.Group) {
}
}
a.GET("/metrics", echo.WrapHandler(promhttp.Handler()))
a.GET("/metrics", echo.WrapHandler(promhttp.HandlerFor(metrics.GetRegistry(), promhttp.HandlerOpts{})))
}
func setupMetricsMiddleware(a *echo.Group) {

27
pkg/user/events.go Normal file
View File

@ -0,0 +1,27 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2021 Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public Licensee as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package user
// CreatedEvent represents a CreatedEvent event
type CreatedEvent struct {
User *User
}
// TopicName defines the name for CreatedEvent
func (t *CreatedEvent) Name() string {
return "user.created"
}

45
pkg/user/listeners.go Normal file
View File

@ -0,0 +1,45 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2021 Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public Licensee as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package user
import (
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/metrics"
"code.vikunja.io/api/pkg/modules/keyvalue"
"github.com/ThreeDotsLabs/watermill/message"
)
func RegisterListeners() {
events.RegisterListener((&CreatedEvent{}).Name(), &IncreaseUserCounter{})
}
///////
// User Events
// IncreaseUserCounter represents a listener
type IncreaseUserCounter struct {
}
// Name defines the name for the IncreaseUserCounter listener
func (s *IncreaseUserCounter) Name() string {
return "increase.user.counter"
}
// Hanlde is executed when the event IncreaseUserCounter listens on is fired
func (s *IncreaseUserCounter) Handle(payload message.Payload) (err error) {
return keyvalue.IncrBy(metrics.UserCountKey, 1)
}

View File

@ -18,6 +18,7 @@ package user
import (
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/log"
)
@ -37,4 +38,6 @@ func InitTests() {
if err != nil {
log.Fatal(err)
}
events.Fake()
}

View File

@ -18,8 +18,8 @@ package user
import (
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/mail"
"code.vikunja.io/api/pkg/metrics"
"code.vikunja.io/api/pkg/utils"
"golang.org/x/crypto/bcrypt"
"xorm.io/xorm"
@ -70,15 +70,19 @@ func CreateUser(s *xorm.Session, user *User) (newUser *User, err error) {
return nil, err
}
// Update the metrics
metrics.UpdateCount(1, metrics.ActiveUsersKey)
// Get the full new User
newUserOut, err := GetUserByID(s, user.ID)
if err != nil {
return nil, err
}
err = events.Dispatch(&CreatedEvent{
User: newUserOut,
})
if err != nil {
return nil, err
}
sendConfirmEmail(user)
return newUserOut, err