Merge branch 'master' into caldav-tasks
continuous-integration/drone/pr Build is passing Details

This commit is contained in:
konrad 2020-09-27 18:32:13 +00:00
commit 359519f74c
84 changed files with 4118 additions and 1180 deletions

View File

@ -52,13 +52,26 @@ steps:
commands:
- git fetch --tags
- name: build
# We're statically compiling the magefile to avoid race condition issues caused by multiple pipeline steps
# compiling the same magefile at the same time. It's also faster if each step does not need to compile it first.
- name: mage
image: vikunja/golang-build:latest
pull: true
environment:
GOPROXY: 'https://goproxy.kolaente.de'
commands:
- make build
- mage -compile ./mage-static
when:
event: [ push, tag, pull_request ]
- name: build
image: vikunja/golang-build:latest
pull: true
environment:
GOPROXY: 'https://goproxy.kolaente.de'
depends_on: [ mage ]
commands:
- ./mage-static build:build
when:
event: [ push, tag, pull_request ]
@ -69,17 +82,17 @@ steps:
GOPROXY: 'https://goproxy.kolaente.de'
depends_on: [ build ]
commands:
- make generate
- make lint
- make fmt-check
# - make got-swag # Commented out until we figured out how to get this working on drone
- make ineffassign-check
- make misspell-check
- make goconst-check
- make gocyclo-check
- make static-check
- ./mage-static build:generate
- ./mage-static check:lint
- ./mage-static check:fmt
- ./mage-static check:got-swag
- ./mage-static check:ineffassign
- ./mage-static check:misspell
- ./mage-static check:goconst
- ./mage-static check:gocyclo
- ./mage-static check:static
- wget -O - -q https://raw.githubusercontent.com/securego/gosec/master/install.sh | sh -s -- -b $GOPATH/bin v2.2.0 # Need to manually install as it does not support being installed via go modules like the rest.
- make gosec-check
- ./mage-static check:gosec
when:
event: [ push, tag, pull_request ]
@ -152,9 +165,9 @@ steps:
environment:
GOPROXY: 'https://goproxy.kolaente.de'
commands:
- make generate
- make test
depends_on: [ fetch-tags ]
- ./mage-static build:generate
- ./mage-static test:unit
depends_on: [ fetch-tags, mage ]
when:
event: [ push, tag, pull_request ]
@ -166,9 +179,9 @@ steps:
VIKUNJA_TESTS_USE_CONFIG: 1
VIKUNJA_DATABASE_TYPE: sqlite
commands:
- make generate
- make test
depends_on: [ fetch-tags ]
- ./mage-static build:generate
- ./mage-static test:unit
depends_on: [ fetch-tags, mage ]
when:
event: [ push, tag, pull_request ]
@ -184,9 +197,9 @@ steps:
VIKUNJA_DATABASE_PASSWORD: vikunjatest
VIKUNJA_DATABASE_DATABASE: vikunjatest
commands:
- make generate
- make test
depends_on: [ fetch-tags ]
- ./mage-static build:generate
- ./mage-static test:unit
depends_on: [ fetch-tags, mage ]
when:
event: [ push, tag, pull_request ]
@ -203,9 +216,9 @@ steps:
VIKUNJA_DATABASE_DATABASE: vikunjatest
VIKUNJA_DATABASE_SSLMODE: disable
commands:
- make generate
- make test
depends_on: [ fetch-tags ]
- ./mage-static build:generate
- ./mage-static test:unit
depends_on: [ fetch-tags, mage ]
when:
event: [ push, tag, pull_request ]
@ -215,9 +228,9 @@ steps:
environment:
GOPROXY: 'https://goproxy.kolaente.de'
commands:
- make generate
- make integration-test
depends_on: [ fetch-tags ]
- ./mage-static build:generate
- ./mage-static test:integration
depends_on: [ fetch-tags, mage ]
when:
event: [ push, tag, pull_request ]
@ -229,9 +242,9 @@ steps:
VIKUNJA_TESTS_USE_CONFIG: 1
VIKUNJA_DATABASE_TYPE: sqlite
commands:
- make generate
- make integration-test
depends_on: [ fetch-tags ]
- ./mage-static build:generate
- ./mage-static test:integration
depends_on: [ fetch-tags, mage ]
when:
event: [ push, tag, pull_request ]
@ -247,9 +260,9 @@ steps:
VIKUNJA_DATABASE_PASSWORD: vikunjatest
VIKUNJA_DATABASE_DATABASE: vikunjatest
commands:
- make generate
- make integration-test
depends_on: [ fetch-tags ]
- ./mage-static build:generate
- ./mage-static test:integration
depends_on: [ fetch-tags, mage ]
when:
event: [ push, tag, pull_request ]
@ -266,9 +279,9 @@ steps:
VIKUNJA_DATABASE_DATABASE: vikunjatest
VIKUNJA_DATABASE_SSLMODE: disable
commands:
- make generate
- make integration-test
depends_on: [ fetch-tags ]
- ./mage-static build:generate
- ./mage-static test:integration
depends_on: [ fetch-tags, mage ]
when:
event: [ push, tag, pull_request ]
@ -299,14 +312,27 @@ steps:
commands:
- git fetch --tags
# We're statically compiling the magefile to avoid race condition issues caused by multiple pipeline steps
# compiling the same magefile at the same time. It's also faster if each step does not need to compile it first.
- name: mage
image: vikunja/golang-build:latest
pull: true
environment:
GOPROXY: 'https://goproxy.kolaente.de'
commands:
- mage -compile ./mage-static
when:
event: [ push, tag, pull_request ]
- name: before-static-build
image: techknowlogick/xgo:latest
pull: true
commands:
- export PATH=$PATH:$GOPATH/bin
- make generate
- make release-dirs
depends_on: [ fetch-tags ]
- go install github.com/magefile/mage
- ./mage-static build:generate
- ./mage-static release:dirs
depends_on: [ fetch-tags, mage ]
- name: static-build-windows
image: techknowlogick/xgo:latest
@ -317,7 +343,8 @@ steps:
GOPATH: /srv/app
commands:
- export PATH=$PATH:$GOPATH/bin
- make release-windows
- go install github.com/magefile/mage
- ./mage-static release:windows
depends_on: [ before-static-build ]
- name: static-build-linux
@ -329,7 +356,8 @@ steps:
GOPATH: /srv/app
commands:
- export PATH=$PATH:$GOPATH/bin
- make release-linux
- go install github.com/magefile/mage
- ./mage-static release:linux
depends_on: [ before-static-build ]
- name: static-build-darwin
@ -341,7 +369,8 @@ steps:
GOPATH: /srv/app
commands:
- export PATH=$PATH:$GOPATH/bin
- make release-darwin
- go install github.com/magefile/mage
- ./mage-static release:darwin
depends_on: [ before-static-build ]
- name: after-build-compress
@ -352,7 +381,7 @@ steps:
- static-build-linux
- static-build-darwin
commands:
- make release-compress
- ./mage-static release:compress
- name: after-build-static
image: techknowlogick/xgo:latest
@ -360,10 +389,11 @@ steps:
depends_on:
- after-build-compress
commands:
- make release-copy
- make release-check
- make release-os-package
- make release-zip
- go install github.com/magefile/mage
- ./mage-static release:copy
- ./mage-static release:check
- ./mage-static release:os-package
- ./mage-static release:zip
- name: sign-release
image: plugins/gpgsign:1
@ -428,7 +458,7 @@ steps:
image: kolaente/fpm
pull: true
commands:
- make build-deb
- ./mage-static release:deb
depends_on: [ static-build-linux ]
- name: deb-structure
@ -446,7 +476,7 @@ steps:
- gpg --import ~/frederik.gpg
- mkdir debian/conf -p
- cp build/reprepro-dist-conf debian/conf/distributions
- make reprepro
- ./mage-static release:reprepro
depends_on: [ build-deb ]
# Push the releases to our pseudo-s3-bucket

View File

@ -6,6 +6,6 @@
* [ ] I added or improved tests
* [ ] I added or improved docs for my feature
* [ ] Swagger (including `make do-the-swag`)
* [ ] Swagger (including `mage do-the-swag`)
* [ ] Error codes
* [ ] New config options

1
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1 @@
custom: https://www.buymeacoffee.com/kolaente

View File

@ -16,7 +16,8 @@ WORKDIR ${GOPATH}/src/code.vikunja.io/api
# Checkout version if set
RUN if [ -n "${VIKUNJA_VERSION}" ]; then git checkout "${VIKUNJA_VERSION}"; fi \
&& make clean generate build
&& go install github.com/magefile/mage \
&& mage build:clean build:build
###################
# The actual image

247
Makefile
View File

@ -1,247 +0,0 @@
DIST := dist
IMPORT := code.vikunja.io/api
SED_INPLACE := sed -i
ifeq ($(OS), Windows_NT)
EXECUTABLE := vikunja.exe
else
EXECUTABLE := vikunja
UNAME_S := $(shell uname -s)
ifeq ($(UNAME_S),Darwin)
SED_INPLACE := sed -i ''
endif
endif
GOFILES := $(shell find . -name "*.go" -type f ! -path "*/bindata.go")
GOFMT ?= gofmt -s
EXTRA_GOFLAGS ?=
GOFLAGS := -v $(EXTRA_GOFLAGS)
LDFLAGS := -X "code.vikunja.io/api/pkg/version.Version=$(shell git describe --tags --always --abbrev=10 | sed 's/-/+/' | sed 's/^v//' | sed 's/-g/-/')" -X "main.Tags=$(TAGS)"
PACKAGES ?= $(filter-out code.vikunja.io/api/pkg/integrations,$(shell go list all | grep code\.vikunja\.io\/api))
SOURCES ?= $(shell find . -name "*.go" -type f)
TAGS ?=
ifeq ($(OS), Windows_NT)
EXECUTABLE := vikunja.exe
else
EXECUTABLE := vikunja
endif
ifneq ($(DRONE_TAG),)
VERSION ?= $(subst v,,$(DRONE_TAG))
else
ifneq ($(DRONE_BRANCH),)
VERSION ?= $(subst release/v,,$(DRONE_BRANCH))
else
VERSION ?= master
endif
endif
ifeq ($(DRONE_WORKSPACE),'')
BINLOCATION := $(EXECUTABLE)
else
BINLOCATION := $(DIST)/binaries/$(EXECUTABLE)-$(VERSION)-linux-amd64
endif
ifeq ($(VERSION),master)
PKGVERSION := $(shell git describe --tags --always --abbrev=10 | sed 's/-/+/' | sed 's/^v//' | sed 's/-g/-/')
else
PKGVERSION := $(VERSION)
endif
.PHONY: all
all: build
.PHONY: clean
clean:
go clean ./...
rm -rf $(EXECUTABLE) $(DIST) $(BINDATA)
.PHONY: test
test:
# We run everything sequentially and not in parallel to prevent issues with real test databases
VIKUNJA_SERVICE_ROOTPATH=$(shell pwd) go test $(GOFLAGS) -p 1 -cover -coverprofile cover.out $(PACKAGES)
.PHONY: test-coverage
test-coverage: test
go tool cover -html=cover.out -o cover.html
.PHONY: integration-test
integration-test:
# We run everything sequentially and not in parallel to prevent issues with real test databases
VIKUNJA_SERVICE_ROOTPATH=$(shell pwd) go test $(GOFLAGS) -p 1 code.vikunja.io/api/pkg/integrations
.PHONY: lint
lint:
@hash golint > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
go install $(GOFLAGS) golang.org/x/lint/golint; \
fi
for PKG in $(PACKAGES); do golint -set_exit_status $$PKG || exit 1; done;
.PHONY: fmt
fmt:
$(GOFMT) -w $(GOFILES)
.PHONY: fmt-check
fmt-check:
# get all go files and run go fmt on them
@diff=$$($(GOFMT) -d $(GOFILES)); \
if [ -n "$$diff" ]; then \
echo "Please run 'make fmt' and commit the result:"; \
echo "$${diff}"; \
exit 1; \
fi;
.PHONY: build
build: generate $(EXECUTABLE)
.PHONY: generate
generate:
go generate code.vikunja.io/api/pkg/static
$(EXECUTABLE): $(SOURCES)
go build $(GOFLAGS) -tags '$(TAGS)' -ldflags '-s -w $(LDFLAGS)' -o $@
.PHONY: compress-build
compress-build:
upx -9 $(EXECUTABLE)
.PHONY: release
release: release-dirs release-windows release-linux release-darwin release-copy release-check release-os-package release-zip
.PHONY: release-dirs
release-dirs:
mkdir -p $(DIST)/binaries $(DIST)/release $(DIST)/zip
.PHONY: release-windows
release-windows:
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
go install $(GOFLAGS) src.techknowlogick.com/xgo; \
fi
xgo -dest $(DIST)/binaries -tags 'netgo $(TAGS)' -ldflags '-linkmode external -extldflags "-static" $(LDFLAGS)' -targets 'windows/*' -out vikunja-$(VERSION) .
ifneq ($(DRONE_WORKSPACE),'')
mv /build/* $(DIST)/binaries
endif
.PHONY: release-linux
release-linux:
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
go install $(GOFLAGS) src.techknowlogick.com/xgo; \
fi
xgo -dest $(DIST)/binaries -tags 'netgo $(TAGS)' -ldflags '-linkmode external -extldflags "-static" $(LDFLAGS)' -targets 'linux/*' -out vikunja-$(VERSION) .
ifneq ($(DRONE_WORKSPACE),'')
mv /build/* $(DIST)/binaries
endif
.PHONY: release-darwin
release-darwin:
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
go install $(GOFLAGS) src.techknowlogick.com/xgo; \
fi
xgo -dest $(DIST)/binaries -tags 'netgo $(TAGS)' -ldflags '$(LDFLAGS)' -targets 'darwin/*' -out vikunja-$(VERSION) .
ifneq ($(DRONE_WORKSPACE),'')
mv /build/* $(DIST)/binaries
endif
# Compresses all releases made by make release-* but not mips* releases since upx can't handle these.
.PHONY: release-compress
release-compress:
$(foreach file,$(filter-out $(wildcard $(wildcard $(DIST)/binaries/$(EXECUTABLE)-*mips*)),$(wildcard $(DIST)/binaries/$(EXECUTABLE)-*)), upx -9 $(file);)
.PHONY: release-copy
release-copy:
$(foreach file,$(wildcard $(DIST)/binaries/$(EXECUTABLE)-*),cp $(file) $(DIST)/release/$(notdir $(file));)
.PHONY: release-check
release-check:
cd $(DIST)/release; $(foreach file,$(wildcard $(DIST)/release/$(EXECUTABLE)-*),sha256sum $(notdir $(file)) > $(notdir $(file)).sha256;)
.PHONY: release-os-package
release-os-package:
$(foreach file,$(filter-out %.sha256,$(wildcard $(DIST)/release/$(EXECUTABLE)-*)),mkdir $(file)-full;mv $(file) $(file)-full/; mv $(file).sha256 $(file)-full/; cp config.yml.sample $(file)-full/config.yml; cp LICENSE $(file)-full/; )
.PHONY: release-zip
release-zip:
$(foreach file,$(wildcard $(DIST)/release/$(EXECUTABLE)-*),cd $(file); zip -r ../../zip/$(shell basename $(file)).zip *; cd ../../../; )
# Builds a deb package using fpm from a previously created binary (using make build)
.PHONY: build-deb
build-deb:
fpm -s dir -t deb --url https://vikunja.io -n vikunja -v $(PKGVERSION) --license GPLv3 --directories /opt/vikunja --after-install ./build/after-install.sh --description 'Vikunja is an open-source todo application, written in Go. It lets you create lists,tasks and share them via teams or directly between users.' -m maintainers@vikunja.io ./$(BINLOCATION)=/opt/vikunja/vikunja ./config.yml.sample=/etc/vikunja/config.yml;
.PHONY: reprepro
reprepro:
reprepro_expect debian includedeb strech ./$(EXECUTABLE)_$(PKGVERSION)_amd64.deb
.PHONY: got-swag
got-swag: do-the-swag
@diff=$$(git diff docs/swagger/swagger.json); \
if [ -n "$$diff" ]; then \
echo "Please run 'make do-the-swag' and commit the result:"; \
echo "$${diff}"; \
exit 1; \
fi;
.PHONY: do-the-swag
do-the-swag:
@hash swag > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
go install $(GOFLAGS) github.com/swaggo/swag/cmd/swag; \
fi
swag init -g pkg/routes/routes.go --parseDependency -o ./pkg/swagger;
# Fix the generated swagger file, currently a workaround until swaggo can properly use go mod
sed -i '/"definitions": {/a "code.vikunja.io.web.HTTPError": {"type": "object","properties": {"code": {"type": "integer"},"message": {"type": "string"}}},' pkg/swagger/docs.go;
sed -i 's/code.vikunja.io\/web.HTTPError/code.vikunja.io.web.HTTPError/g' pkg/swagger/docs.go;
sed -i 's/package\ docs/package\ swagger/g' pkg/swagger/docs.go;
sed -i 's/` + \\"`\\" + `/` + "`" + `/g' pkg/swagger/docs.go;
.PHONY: misspell-check
misspell-check:
@hash misspell > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
go install $(GOFLAGS) github.com/client9/misspell/cmd/misspell; \
fi
for S in $(GOFILES); do misspell -error $$S || exit 1; done;
.PHONY: ineffassign-check
ineffassign-check:
@hash ineffassign > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
go install $(GOFLAGS) github.com/gordonklaus/ineffassign; \
fi
for S in $(GOFILES); do ineffassign $$S || exit 1; done;
.PHONY: gocyclo-check
gocyclo-check:
@hash gocyclo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
go get -u github.com/fzipp/gocyclo; \
go install $(GOFLAGS) github.com/fzipp/gocyclo; \
fi
for S in $(GOFILES); do gocyclo -over 49 $$S || exit 1; done;
.PHONY: static-check
static-check:
@hash staticcheck > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
go get -u honnef.co/go/tools; \
go install $(GOFLAGS) honnef.co/go/tools/cmd/staticcheck; \
fi
staticcheck $(PACKAGES);
.PHONY: gosec-check
gosec-check:
@hash gosec > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
echo "Please manually install gosec by running"; \
echo "curl -sfL https://raw.githubusercontent.com/securego/gosec/master/install.sh | bash -s -- -b $GOPATH/bin v2.2.0"; \
exit 1; \
fi
gosec ./...
.PHONY: goconst-check
goconst-check:
@hash goconst > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
go get -u github.com/jgautheron/goconst/cmd/goconst; \
go install $(GOFLAGS) github.com/jgautheron/goconst/cmd/goconst; \
fi;
for S in $(PACKAGES); do goconst $$S || exit 1; done;

View File

@ -35,7 +35,7 @@ try it on [try.vikunja.io](https://try.vikunja.io)!
* [Installing](https://vikunja.io/docs/installing/)
* [Build from source](https://vikunja.io/docs/build-from-sources/)
* [Development setup](https://vikunja.io/docs/development/)
* [Makefile](https://vikunja.io/docs/makefile/)
* [Magefile](https://vikunja.io/docs/mage/)
* [Testing](https://vikunja.io/docs/testing/)
All docs can be found on [the vikunja home page](https://vikunja.io/docs/).

View File

@ -16,6 +16,8 @@ Additionally, they can also be run directly by using the `migrate` command.
We use [xormigrate](https://github.com/techknowlogick/xormigrate) to handle migrations,
which is based on gormigrate.
{{< table_of_contents >}}
## Add a new migration
All migrations are stored in `pkg/migrations` and files should have the same name as their id.

View File

@ -17,7 +17,9 @@ If you don't intend to add new dependencies, go `1.9` and above should be fine.
To contribute to Vikunja, fork the project and work on the master branch.
A lot of developing tasks are automated using a Makefile, so make sure to [take a look at it]({{< ref "make.md">}}).
A lot of developing tasks are automated using a Magefile, so make sure to [take a look at it]({{< ref "mage.md">}}).
{{< table_of_contents >}}
## Libraries
@ -50,8 +52,8 @@ git remote add origin git@git.kolaente.de:<USERNAME>/api.git
git fetch --all --prune
{{< /highlight >}}
This should provide a working development environment for Vikunja. Take a look at the Makefile to get an overview about
the available tasks. The most common tasks should be `make test` which will start our test environment and `make build`
This should provide a working development environment for Vikunja. Take a look at the Magefile to get an overview about
the available tasks. The most common tasks should be `mage test:unit` which will start our test environment and `mage build:build`
which will build a vikunja binary into the working directory. Writing test cases is not mandatory to contribute, but it
is highly encouraged and helps developers sleep at night.
@ -62,4 +64,4 @@ Thats it! You are ready to hack on Vikunja. Test changes, push them to the re
Each Vikunja release contains all static assets directly compiled into the binary.
To prevent this during development, use the `dev` tag when developing.
See the [make docs](make.md#statically-compile-all-templates-into-the-binary) about how to compile with static assets for a release.
See the [mage docs](mage.md#statically-compile-all-templates-into-the-binary) about how to compile with static assets for a release.

View File

@ -0,0 +1,193 @@
---
date: "2019-02-12:00:00+02:00"
title: "Magefile"
draft: false
type: "doc"
menu:
sidebar:
parent: "development"
---
# Mage
Vikunja uses [Mage](https://magefile.org/) to script common development tasks and even releasing.
Mage is a pure go solution which allows for greater flexibility and things like better paralelization.
This document explains what taks are available and what they do.
{{< table_of_contents >}}
## Installation
To use mage, you'll need to install the mage cli.
To install it, run the following command:
```
go install github.com/magefile/mage
```
## Categories
There are multiple categories of subcommands in the magefile:
* `build`: Contains commands to build a single binary
* `check`: Contains commands to statically check the source code
* `release`: Contains commands to release Vikunja with everything that's required
* `test`: Contains commands to run all kinds of tests
* `dev`: Contains commands to run development tasks
* `misc`: Commands which do not belong in either of the other categories
## CI
These tasks are automatically run in our CI every time someone pushes to master or you update a pull request:
* `mage check:lint`
* `mage check:fmt`
* `mage check:ineffassign`
* `mage check:misspell`
* `mage check:goconst`
* `mage build:generate`
* `mage build:build`
## Build
### Build Vikunja
{{< highlight bash >}}
mage build:build
{{< /highlight >}}
Builds a `vikunja`-binary in the root directory of the repo for the platform it is run on.
### Statically compile all templates into the binary
{{< highlight bash >}}
mage build:generate
{{< /highlight >}}
This generates static code with all templates, meaning no template need to be referenced at runtime.
### clean
{{< highlight bash >}}
mage build:clean
{{< /highlight >}}
Cleans all build, executable and bindata files
## Check
All check sub-commands exit with a status code of 1 if the check fails.
Various code-checks are available:
* `mage check:all`: Runs fmt-check, lint, got-swag, misspell-check, ineffasign-check, gocyclo-check, static-check, gosec-check, goconst-check all in parallel
* `mage check:fmt`: Checks if the code is properly formatted with go fmt
* `mage check:go-sec`: Checks the source code for potential security issues by scanning the Go AST using the [gosec tool](https://github.com/securego/gosec)
* `mage check:goconst`: Checks for repeated strings that could be replaced by a constant using [goconst](https://github.com/jgautheron/goconst/)
* `mage check:gocyclo`: Checks for the cyclomatic complexity of the source code using [gocyclo](https://github.com/fzipp/gocyclo)
* `mage check:got-swag`: Checks if the swagger docs need to be re-generated from the code annotations
* `mage check:ineffassign`: Checks the source code for ineffectual assigns using [ineffassign](https://github.com/gordonklaus/ineffassign)
* `mage check:lint`: Runs golint on all packages
* `mage check:misspell`: Checks the source code for misspellings
* `mage check:static`: Statically analyzes the source code about a range of different problems using [staticcheck](https://staticcheck.io/docs/)
## Release
### Build Releases
{{< highlight bash >}}
mage release
{{< /highlight >}}
Builds binaries for all platforms and zips them with a copy of the `templates/` folder.
All built zip files are stored into `dist/zips/`. Binaries are stored in `dist/binaries/`,
binaries bundled with `templates` are stored in `dist/releases/`.
All cross-platform binaries built using this series of commands are built with the help of
[xgo](https://github.com/techknowlogick/xgo). The mage command will automatically install the
binary to be able to use it.
`mage release:release` is a shortcut to execute `mage release:dirs release:windows release:linux release:darwin release:copy release:check release:os-package release:zip`.
* `mage release:dirs` creates all directories needed
* `mage release:windows`/`release:linux`/`release:darwin` execute xgo to build for their respective platforms
* `mage release:copy` bundles binaries with a copy of the `LICENSE` and sample config files to then be zipped
* `mage release:check` creates sha256 checksums for each binary which will be included in the zip file
* `mage release:os-package` bundles a binary with the `sha256` checksum file, a sample `config.yml` and a copy of the license in a folder for each architecture
* `mage release:compress` compresses all build binaries with `upx` to save space
* `mage release:zip` paclages a zip file for the files created by `release:os-package`
### Build debian packages
{{< highlight bash >}}
mage release:deb
{{< /highlight >}}
Will build a `.deb` package into the current folder.
You need to have [fpm](https://fpm.readthedocs.io/en/latest/intro.html) installed to be able to do this.
### Make a debian repo
{{< highlight bash >}}
mage release:reprepro
{{< /highlight >}}
Takes an already built debian package and creates a debian repo structure around it.
Used to be run inside a [docker container](https://git.kolaente.de/konrad/reprepro-docker) in the CI process when releasing.
## Test
### unit
{{< highlight bash >}}
mage test:unit
{{< /highlight >}}
Runs all tests except integration tests.
### coverage
{{< highlight bash >}}
mage test:coverage
{{< /highlight >}}
Runs all tests except integration tests and generates a `coverage.html` file to inspect the code coverage.
### integration
{{< highlight bash >}}
mage test:integration
{{< /highlight >}}
Runs all integration tests.
## Dev
### Create a new migration
{{< highlight bash >}}
mage dev:create-migration
{{< /highlight >}}
Creates a new migration with the current date.
Will ask for the name of the struct you want to create a migration for.
## Misc
### Format the code
{{< highlight bash >}}
mage fmt
{{< /highlight >}}
Formats all source code using `go fmt`.
### Generate swagger definitions from code comments
{{< highlight bash >}}
mage do-the-swag
{{< /highlight >}}
Generates swagger definitions from the comment annotations in the code.

View File

@ -1,151 +0,0 @@
---
date: "2019-02-12:00:00+02:00"
title: "Makefile"
draft: false
type: "doc"
menu:
sidebar:
parent: "development"
---
# Makefile
We scripted a lot of tasks used mostly for developing into the makefile. This documents explains what
taks are available and what they do.
## CI
These tasks are automatically run in our CI every time someone pushes to master or you update a pull request:
* `make lint`
* `make fmt-check`
* `make ineffassign-check`
* `make misspell-check`
* `make goconst-check`
* `make generate`
* `make build`
### clean
{{< highlight bash >}}
make clean
{{< /highlight >}}
Clears all builds and binaries.
### test
{{< highlight bash >}}
make test
{{< /highlight >}}
Runs all tests in Vikunja.
### Format the code
{{< highlight bash >}}
make fmt
{{< /highlight >}}
Formats all source code using `go fmt`.
#### Check formatting
{{< highlight bash >}}
make fmt-check
{{< /highlight >}}
Checks if the code needs to be formatted. Fails if it does.
### Build Vikunja
{{< highlight bash >}}
make build
{{< /highlight >}}
Builds a `vikunja`-binary in the root directory of the repo for the platform it is run on.
### Statically compile all templates into the binary
{{< highlight bash >}}
make generate
{{< /highlight >}}
This generates static code with all templates, meaning no template need to be referenced at runtime.
### Compress the built binary
{{< highlight bash >}}
make compress-build
{{< /highlight >}}
Go binaries are very big.
To make the vikunja binary smaller, we can compress it using [upx](https://upx.github.io/).
### Build Releases
{{< highlight bash >}}
make release
{{< /highlight >}}
Builds binaries for all platforms and zips them with a copy of the `templates/` folder.
All built zip files are stored into `dist/zips/`. Binaries are stored in `dist/binaries/`,
binaries bundled with `templates` are stored in `dist/releases/`.
All cross-platform binaries built using this series of commands are built with the help of
[xgo](https://github.com/techknowlogick/xgo). The make command will automatically install the
binary to be able to use it.
`make release` is actually just a shortcut to execute `make release-dirs release-windows release-linux release-darwin release-copy release-check release-os-package release-zip`.
* `release-dirs` creates all directories needed
* `release-windows`/`release-linux`/`release-darwin` execute xgo to build for their respective platforms
* `release-copy` bundles binaries with a copy of `templates/` to then be zipped
* `release-check` creates sha256 checksums for each binary which will be included in the zip file
* `release-os-package` bundles a binary with a copy of the `templates/` folder, the `sha256` checksum file, a sample `config.yml` and a copy of the license in a folder for each architecture
* `release-compress` compresses all build binaries, see `compress-build`
* `release-zip` makes a zip file for the files created by `release-os-package`
### Build debian packages
{{< highlight bash >}}
make build-deb
{{< /highlight >}}
Will build a `.deb` package into the current folder. You need to have [fpm](https://fpm.readthedocs.io/en/latest/intro.html) installed to be able to do this.
#### Make a debian repo
{{< highlight bash >}}
make reprepro
{{< /highlight >}}
Takes an already built debian package and creates a debian repo structure around it.
Used to be run inside a [docker container](https://git.kolaente.de/konrad/reprepro-docker) in the CI process when releasing.
### Generate swagger definitions from code comments
{{< highlight bash >}}
make do-the-swag
{{< /highlight >}}
Generates swagger definitions from the comments in the code.
#### Check if swagger generation is needed
{{< highlight bash >}}
make got-swag
{{< /highlight >}}
This command is currently more an experiment, use it with caution.
It may bring up wrong results.
### Code-Checks
* `misspell-check`: Checks for commonly misspelled words
* `ineffassign-check`: Checks for ineffectual assignments in the code using [ineffassign](https://github.com/gordonklaus/ineffassign).
* `gocyclo-check`: Calculates cyclomatic complexities of functions using [gocyclo](https://github.com/fzipp/gocyclo).
* `static-check`: Analyzes the code for bugs, improvements and more using [staticcheck](https://staticcheck.io/docs/).
* `gosec-check`: Inspects source code for security problems by scanning the Go AST using the [gosec tool](https://github.com/securego/gosec).
* `goconst-check`: Finds repeated strings that could be replaced by a constant using [goconst](https://github.com/jgautheron/goconst/).

View File

@ -16,12 +16,14 @@ To make this easier, we have put together a few helpers which are documented on
In general, each migrator implements a migrator interface which is then called from a client.
The interface makes it possible to use helper methods which handle http an focus only on the implementation of the migrator itself.
### Structure
{{< table_of_contents >}}
## Structure
All migrator implementations live in their own package in `pkg/modules/migration/<name-of-the-service>`.
When creating a new migrator, you should place all related code inside that module.
### Migrator interface
## Migrator interface
The migrator interface is defined as follows:
@ -41,7 +43,7 @@ type Migrator interface {
}
```
### Defining http routes
## Defining http routes
Once your migrator implements the migration interface, it becomes possible to use the helper http handlers.
Their usage is very similar to the [general web handler](https://kolaente.dev/vikunja/web#user-content-defining-routes-using-the-standard-web-handler):
@ -63,7 +65,7 @@ if config.MigrationWunderlistEnable.GetBool() {
You should also document the routes with [swagger annotations]({{< ref "../practical-instructions/swagger-docs.md" >}}).
### Insertion helper method
## Insertion helper method
There is a method available in the `migration` package which takes a fully nested Vikunja structure and creates it with all relations.
This means you start by adding a namespace, then add lists inside of that namespace, then tasks in the lists and so on.
@ -81,7 +83,7 @@ if err != nil {
err = migration.InsertFromStructure(fullVikunjaHierachie, user)
```
### Configuration
## Configuration
You should add at least an option to enable or disable the migration.
Chances are, you'll need some more options for things like client ID and secret
@ -90,7 +92,7 @@ Chances are, you'll need some more options for things like client ID and secret
The easiest way to implement an on/off switch is to check whether your migration service is enabled or not when
registering the routes, and then simply don't registering the routes in the case it is disabled.
#### Making the migrator public in `/info`
### Making the migrator public in `/info`
You should make your migrator available in the `/info` endpoint so that frontends can display options to enable them or not.
To do this, add an entry to `pkg/routes/api/v1/info.go`.

View File

@ -45,9 +45,11 @@ In general, this api repo has the following structure:
This document will explain what these mean and what you can find where.
{{< table_of_contents >}}
## Root level
The root directory is where [the config file]({{< ref "../setup/config.md">}}), [Makefile]({{< ref "make.md">}}), license, drone config,
The root directory is where [the config file]({{< ref "../setup/config.md">}}), [Magefile]({{< ref "mage.md">}}), license, drone config,
application entry point (`main.go`) and so on are located.
## docker
@ -152,11 +154,11 @@ Every handler function which does not use the standard web handler should live h
### static
All static files generated by `make generate` live here.
All static files generated by `mage generate` live here.
### swagger
This is where the [generated]({{< ref "make.md#generate-swagger-definitions-from-code-comments">}} [api docs]({{< ref "../usage/api.md">}}) live.
This is where the [generated]({{< ref "mage.md#generate-swagger-definitions-from-code-comments">}} [api docs]({{< ref "../usage/api.md">}}) live.
You usually don't need to touch this package.
### user
@ -175,7 +177,7 @@ See their function definitions for instructions on how to use them.
### version
The single purpouse of this package is to hold the current vikunja version which gets overridden through build flags
each time `make release` or `make build` is run.
each time `mage release` or `mage build` is run.
It is a seperate package to avoid import cycles with other packages.
## REST-Tests

View File

@ -10,40 +10,42 @@ menu:
# Testing
You can run unit tests with [our `Makefile`]({{< ref "make.md">}}) with
You can run unit tests with [our `Magefile`]({{< ref "mage.md">}}) with
{{< highlight bash >}}
make test
mage test:unit
{{< /highlight >}}
### Running tests with config
{{< table_of_contents >}}
## Running tests with config
You can run tests with all available config variables if you want, enabeling you to run tests for a lot of scenarios.
To use the normal config set the enviroment variable `VIKUNJA_TESTS_USE_CONFIG=1`.
### Show sql queries
## Show sql queries
When `UNIT_TESTS_VERBOSE=1` is set, all sql queries will be shown when tests are run.
### Fixtures
## Fixtures
All tests are run against a set of db fixtures.
These fixtures are defined in `pkg/models/fixtures` in YAML-Files which represent the database structure.
When you add a new test case which requires new database entries to test against, update these files.
# Integration tests
## Integration tests
All integration tests live in `pkg/integrations`.
You can run them by executing `make integration-test`.
You can run them by executing `mage test:integration`.
The integration tests use the same config and fixtures as the unit tests and therefor have the same options available,
see at the beginning of this document.
To run integration tests, use `make integration-test`.
To run integration tests, use `mage test:integration`.
# Initializing db fixtures when writing tests
## Initializing db fixtures when writing tests
All db fixtures for all tests live in the `pkg/db/fixtures/` folder as yaml files.
Each file has the same name as the table the fixtures are for.

View File

@ -18,7 +18,9 @@ This is used whenever you make a call to the database to get or update data.
This xorm instance is set up and initialized every time vikunja is started.
### Adding new database tables
{{< table_of_contents >}}
## Adding new database tables
To add a new table to the database, add a an instance of your struct to the `tables` variable in the
init function in `pkg/models/models.go`. Xorm will sync them automatically.
@ -27,7 +29,7 @@ You also need to add a pointer to the `tablesWithPointer` slice to enable cachin
To learn more about how to configure your struct to create "good" tables, refer to [the xorm documentaion](http://xorm.io/docs/).
### Adding data to test fixtures
## Adding data to test fixtures
Adding data for test fixtures is done in via `yaml` files insinde of `pkg/models/fixtures`.

View File

@ -12,6 +12,8 @@ menu:
This document explains how to use the mailer to send emails and what to do to create a new kind of email to be sent.
{{< table_of_contents >}}
## Sending emails
**Note:** You should use mail templates whenever possible (see below).
@ -30,7 +32,7 @@ type Opts struct {
}
{{< /highlight >}}
## Sending emails based on a template
### Sending emails based on a template
For each mail with a template, there are two email templates: One for plaintext emails, one for html emails.
@ -41,7 +43,7 @@ To send a mail based on a template, use the function `mail.SendMailWithTemplate(
`to` and `subject` are pretty much self-explanatory, `tpl` is the name of the template, without `.html.tmpl` or `.plain.tmpl`.
`data` is a map you can pass additional data to your template.
#### Sending a mail with a template
### Sending a mail with a template
A basic html email template would look like this:

View File

@ -15,6 +15,8 @@ Metrics work by exposing a `/metrics` endpoint which can then be accessed by pro
To keep the load on the database minimal, metrics are stored and updated in redis.
The `metrics` package provides several functions to create and update metrics.
{{< table_of_contents >}}
## New metrics
First, define a `const` with the metric key in redis. This is done in `pkg/metrics/metrics.go`.
@ -41,6 +43,6 @@ Because metrics are stored in redis, you are responsible to increase or decrease
To do this, use `metrics.UpdateCount(value, key)` where `value` is the amount you want to cange it (you can pass
negative values to decrease it) and `key` it the redis key used to define the metric.
# Using it
## Using it
A Prometheus config with a Grafana template is available at [our git repo](https://git.kolaente.de/vikunja/monitoring).

View File

@ -12,7 +12,7 @@ menu:
The api documentation is generated using [swaggo](https://github.com/swaggo/swag) from comments.
### Documenting structs
## Documenting structs
You should always comment every field which will be exposed as a json in the api.
These comments will show up in the documentation, it'll make it easier for developers using the api.

View File

@ -13,6 +13,8 @@ menu:
Vikunja does not store any data outside of the database.
So, all you need to backup are the contents of that database and maybe the config file.
{{< table_of_contents >}}
## MySQL
To create a backup from mysql use the `mysqldump` command:

View File

@ -14,16 +14,16 @@ Vikunja being a go application, has no other dependencies than go itself.
All libraries are bundeled inside the repo in the `vendor/` folder, so all it boils down to are these steps:
1. Make sure [Go](https://golang.org/doc/install) is properly installed on your system. You'll need at least Go `1.9`.
2. Make sure [Make](https://www.gnu.org/software/make/) is properly installed on your system.
2. Make sure [Mage](https://magefile) is properly installed on your system.
3. Clone the repo with `git clone https://code.vikunja.io/api`
3. Run `make build` in the source of this repo. This will build a binary in the root of the repo which will be able to run on your system.
3. Run `mage build:build` in the source of this repo. This will build a binary in the root of the repo which will be able to run on your system.
*Note:* Static ressources such as email templates are built into the binary.
For these to work, you may need to run `make generate` before building the vikunja binary.
When builing entirely with `make`, you dont need to do this, `make generate` will be run automatically when running `make build`.
For these to work, you may need to run `mage build:generate` before building the vikunja binary.
When builing entirely with `mage`, you dont need to do this, `mage build:generate` will be run automatically when running `mage build:build`.
# Build for different architectures
To build for other platforms and architectures than the one you're currently on, simply run `make release` or `make release-{linux|windows|darwin}`.
To build for other platforms and architectures than the one you're currently on, simply run `mage release:release` or `mage release:{linux|windows|darwin}`.
More options are available, please refer to the [makefile docs]({{< ref "../development/make.md">}}) for more details.
More options are available, please refer to the [magefile docs]({{< ref "../development/mage.md">}}) for more details.

View File

@ -17,6 +17,8 @@ We'll use [docker compose](https://docs.docker.com/compose/) to make handling th
> If you have any issues setting up vikunja, please don't hesitate to reach out to us via [matrix](https://riot.im/app/#/room/!dCRiCiLaCCFVNlDnYs:matrix.org?via=matrix.org), the [community forum](https://community.vikunja.io/) or even [email](mailto:hello@vikunja.io).
{{< table_of_contents >}}
## Preparations (optional)
Create a directory for the project where all data and the compose file will live in.

View File

@ -21,7 +21,9 @@ For all available configuration options, see [configuration]({{< ref "config.md"
All examples on this page already reflect this and do not require additional work.
</div>
### Redis
{{< table_of_contents >}}
## Redis
To use redis, you'll need to add this to the config examples below:

View File

@ -15,6 +15,8 @@ menu:
<a href="{{< ref "utf-8.md">}}">make sure your db is utf-8 compatible</a>.
</div>
{{< table_of_contents >}}
## Install from binary
Download a copy of Vikunja from the [download page](https://vikunja.io/en/download/) for your architecture.
@ -176,6 +178,100 @@ dpkg -i vikunja.deb
This will install the backend to `/opt/vikunja`.
To configure it, use the config file in `/etc/vikunja/config.yml`.
## FreeBSD / FreeNAS
Unfortunately, we currently can't provide pre-built binaries for FreeBSD.
As a workaround, it is possible to compile vikunja for FreeBSD directly on a FreeBSD machine, a guide is available below:
*Thanks to HungrySkeleton who originally created this guide [in the forum](https://community.vikunja.io/t/freebsd-support/69/11).*
### Jail Setup
1. Create jail named ```vikunja```
2. Set jail properties to 'auto start'
3. Mount storage (```/mnt``` to ```jailData/vikunja```)
4. Start jail & SSH into it
### Installing packages
{{< highlight bash >}}
pkg update && pkg upgrade -y
pkg install nano git go gmake
go install github.com/magefile/mage
{{< /highlight >}}
### Clone vikunja repo
{{< highlight bash >}}
mkdir /mnt/GO/code.vikunja.io
cd /mnt/GO/code.vikunja.io
git clone https://code.vikunja.io/api
cd /mnt/GO/code.vikunja.io/api
{{< /highlight >}}
### Compile binaries
{{< highlight bash >}}
go install
mage build
{{< /highlight >}}
### Create folder to install backend server into
{{< highlight bash >}}
mkdir /mnt/backend
cp /mnt/GO/code.vikunja.io/api/vikunja /mnt/backend/vikunja
cd /mnt/backend
chmod +x /mnt/backend/vikunja
{{< /highlight >}}
### Set vikunja to boot on startup
{{< highlight bash >}}
nano /etc/rc.d/vikunja
{{< /highlight >}}
Then paste into the file:
{{< highlight bash >}}
#!/bin/sh
. /etc/rc.subr
name=vikunja
rcvar=vikunja_enable
command="/mnt/backend/${name}"
load_rc_config $name
run_rc_command "$1"
{{< /highlight >}}
Save and exit. Then execute:
{{< highlight bash >}}
chmod +x /etc/rc.d/vikunja
nano /etc/rc.conf
{{< /highlight >}}
Then add line to bottom of file:
{{< highlight bash >}}
vikunja_enable="YES"
{{< /highlight >}}
Test vikunja now works with
{{< highlight bash >}}
service vikunja start
{{< /highlight >}}
The API is now available through IP:
```
192.168.1.XXX:3456
```
## Configuration
See [available configuration options]({{< ref "config.md">}}).

View File

@ -17,6 +17,8 @@ Unzip them and store them somewhere your server can access them.
You also need to configure a rewrite condition to internally redirect all requests to `index.html` which handles all urls.
{{< table_of_contents >}}
## API URL configuration
By default, the frontend assumes it can reach the api at `/api/v1` relative to the frontend url.

View File

@ -13,6 +13,8 @@ menu:
These examples assume you have an instance of the backend running on your server listening on port `3456`.
If you've changed this setting, you need to update the server configurations accordingly.
{{< table_of_contents >}}
## NGINX
Below are two example configurations which you can put in your `nginx.conf`:

View File

@ -17,6 +17,8 @@ Vikunja itself will work just fine until you want to use non-latin characters in
On this page, you will find information about how to fully ensure non-latin characters like aüäß or emojis work
with your installation.
{{< table_of_contents >}}
## Postgresql & SQLite
Postgresql and SQLite should handle utf-8 just fine - If you discover any issues nonetheless, please
@ -49,11 +51,11 @@ The charset `latin1` means the db is encoded in the `latin1` encoding which does
(The following guide is based on [this thread from stackoverflow](https://dba.stackexchange.com/a/104866))
#### 0. Backup your database
### 0. Backup your database
Before attempting any conversion, please [back up your database]({{< ref "backups.md">}}).
#### 1. Create a pre-conversion script
### 1. Create a pre-conversion script
Copy the following sql statements in a file called `preAlterTables.sql` and replace all occurences of `vikunja` with
the name of your database:
@ -70,7 +72,7 @@ SELECT concat("ALTER TABLE `",table_schema,"`.`",table_name, "` CHANGE `",column
FROM `COLUMNS` where table_schema like 'vikunja' and data_type in ('text','tinytext','mediumtext','longtext');
{{< /highlight >}}
#### 2. Run the pre-conversion script
### 2. Run the pre-conversion script
Running this will create the actual migration script for your particular database structure and save it in a file called `alterTables.sql`:
@ -78,7 +80,7 @@ Running this will create the actual migration script for your particular databas
mysql -uroot < preAlterTables.sql | egrep '^ALTER' > alterTables.sql
{{< /highlight >}}
#### 3. Convert the database
### 3. Convert the database
At this point converting is just a matter of executing the previously generated sql script:
@ -86,7 +88,7 @@ At this point converting is just a matter of executing the previously generated
mysql -uroot < alterTables.sql
{{< /highlight >}}
#### 4. Verify it was successfully converted
### 4. Verify it was successfully converted
If everything worked as intended, your db collation should now look like this:

View File

@ -16,6 +16,8 @@ menu:
Vikunja supports managing tasks via the [caldav VTODO](https://tools.ietf.org/html/rfc5545#section-3.6.2) extension.
{{< table_of_contents >}}
## URLs
All urls are located under the `/dav` subspace.
@ -64,11 +66,11 @@ Vikunja **currently does not** support these properties:
## Tested Clients
#### Working
### Working
* [Evolution](https://wiki.gnome.org/Apps/Evolution/)
#### Not working
### Not working
* [Tasks (Android)](https://tasks.org/)

View File

@ -12,13 +12,15 @@ menu:
This document describes the different errors Vikunja can return.
### Generic
{{< table_of_contents >}}
## Generic
| ErrorCode | HTTP Status Code | Description |
|-----------|------------------|-------------|
| 0001 | 403 | Generic forbidden error. |
### User
## User
| ErrorCode | HTTP Status Code | Description |
|-----------|------------------|-------------|
@ -39,14 +41,14 @@ This document describes the different errors Vikunja can return.
| 1017 | 412 | The provided Totp passcode is invalid. |
| 1018 | 412 | The provided user avatar provider type setting is invalid. |
### Validation
## Validation
| ErrorCode | HTTP Status Code | Description |
|-----------|------------------|-------------|
| 2001 | 400 | ID cannot be empty or 0. |
| 2002 | 400 | Some of the request data was invalid. The response contains an aditional array with all invalid fields. |
### List
## List
| ErrorCode | HTTP Status Code | Description |
|-----------|------------------|-------------|
@ -57,7 +59,7 @@ This document describes the different errors Vikunja can return.
| 3007 | 400 | A list with this identifier already exists. |
| 3008 | 412 | The list is archived and can therefore only be accessed read only. This is also true for all tasks associated with this list. |
### Task
## Task
| ErrorCode | HTTP Status Code | Description |
|-----------|------------------|-------------|
@ -81,7 +83,7 @@ This document describes the different errors Vikunja can return.
| 4018 | 403 | Invalid task filter concatinator. |
| 4019 | 403 | Invalid task filter value. |
### Namespace
## Namespace
| ErrorCode | HTTP Status Code | Description |
|-----------|------------------|-------------|
@ -93,7 +95,7 @@ This document describes the different errors Vikunja can return.
| 5011 | 409 | This user has already access to that namespace. |
| 5012 | 412 | The namespace is archived and can therefore only be accessed read only. |
### Team
## Team
| ErrorCode | HTTP Status Code | Description |
|-----------|------------------|-------------|
@ -104,14 +106,14 @@ This document describes the different errors Vikunja can return.
| 6006 | 400 | Cannot delete the last team member. |
| 6007 | 403 | The team does not have access to the list to perform that action. |
### User List Access
## User List Access
| ErrorCode | HTTP Status Code | Description |
|-----------|------------------|-------------|
| 7002 | 409 | The user already has access to that list. |
| 7003 | 403 | The user does not have access to that list. |
### Label
## Label
| ErrorCode | HTTP Status Code | Description |
|-----------|------------------|-------------|
@ -119,16 +121,24 @@ This document describes the different errors Vikunja can return.
| 8002 | 404 | The label does not exist. |
| 8003 | 403 | The user does not have access to this label. |
### Right
## Right
| ErrorCode | HTTP Status Code | Description |
|-----------|------------------|-------------|
| 9001 | 403 | The right is invalid. |
### Kanban
## Kanban
| ErrorCode | HTTP Status Code | Description |
|-----------|------------------|-------------|
| 10001 | 404 | The bucket does not exist. |
| 10002 | 400 | The bucket does not belong to that list. |
| 10003 | 412 | You cannot remove the last bucket on a list. |
| 10004 | 412 | You cannot add the task to this bucket as it already exceeded the limit of tasks it can hold. |
## Saved Filters
| ErrorCode | HTTP Status Code | Description |
|-----------|------------------|-------------|
| 11001 | 404 | The saved filter does not exist. |
| 11002 | 412 | Saved filters are not available for link shares. |

View File

@ -23,7 +23,7 @@ The following values are possible:
| 1 | Read and write. Namespaces or lists shared with this right can be read and written to by the team or user. |
| 2 | Admin. Can do anything like read and write, but can additionally manage sharing options. |
### Team admins
## Team admins
When adding or querying a team, every member has an additional boolean value stating if it is admin or not.
A team admin can also add and remove team members and also change whether a user in the team is admin or not.

2
docs/themes/vikunja vendored

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

20
go.mod
View File

@ -21,9 +21,9 @@ require (
code.vikunja.io/web v0.0.0-20200809154828-8767618f181f
gitea.com/xorm/xorm-redis-cache v0.2.0
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751
github.com/asaskevich/govalidator v0.0.0-20200819183940-29e1ff8eb0bb
github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef
github.com/beevik/etree v1.1.0 // indirect
github.com/c2h5oh/datasize v0.0.0-20200112174442-28bbd4740fee
github.com/c2h5oh/datasize v0.0.0-20200825124411-48ed595a09d2
github.com/client9/misspell v0.3.4
github.com/cweill/gotests v1.5.3
github.com/d4l3k/messagediff v1.2.1 // indirect
@ -38,16 +38,17 @@ require (
github.com/go-testfixtures/testfixtures/v3 v3.4.0
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0
github.com/gordonklaus/ineffassign v0.0.0-20200809085317-e36bfde3bb78
github.com/iancoleman/strcase v0.1.0
github.com/iancoleman/strcase v0.1.2
github.com/imdario/mergo v0.3.11
github.com/jgautheron/goconst v0.0.0-20200227150835-cda7ea3bf591
github.com/jgautheron/goconst v0.0.0-20200920201509-8f5268ce89d5
github.com/kr/text v0.2.0 // indirect
github.com/labstack/echo/v4 v4.1.16
github.com/labstack/echo/v4 v4.1.17
github.com/labstack/gommon v0.3.0
github.com/laurent22/ical-go v0.1.1-0.20181107184520-7e5d6ade8eef
github.com/lib/pq v1.8.0
github.com/magefile/mage v1.10.0
github.com/mailru/easyjson v0.7.0 // indirect
github.com/mattn/go-sqlite3 v1.14.1
github.com/mattn/go-sqlite3 v1.14.3
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
github.com/olekukonko/tablewriter v0.0.4
github.com/onsi/ginkgo v1.12.0 // indirect
@ -58,8 +59,8 @@ require (
github.com/prometheus/client_golang v1.7.1
github.com/samedi/caldav-go v3.0.0+incompatible
github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749
github.com/shurcooL/vfsgen v0.0.0-20200627165143-92b8a710ab6c
github.com/spf13/afero v1.3.4
github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546
github.com/spf13/afero v1.4.0
github.com/spf13/cobra v1.0.0
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/viper v1.7.1
@ -67,8 +68,9 @@ require (
github.com/swaggo/swag v1.6.7
github.com/ulule/limiter/v3 v3.5.0
golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a
golang.org/x/image v0.0.0-20200801110659-972c09e46d76
golang.org/x/image v0.0.0-20200927104501-e162460cd6b5
golang.org/x/lint v0.0.0-20200302205851-738671d3881b
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208
golang.org/x/tools v0.0.0-20200410194907-79a7a3126eef // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect

59
go.sum
View File

@ -1,7 +1,5 @@
4d63.com/embedfiles v0.0.0-20190311033909-995e0740726f h1:oyYjGRBNq1TxAIG8aHqtxlvqUfzdZf+MbcRb/oweNfY=
4d63.com/embedfiles v0.0.0-20190311033909-995e0740726f/go.mod h1:HxEsUxoVZyRxsZML/S6e2xAuieFMlGO0756ncWx1aXE=
4d63.com/tz v1.1.0 h1:Hi58WbeFjiUH4XOWuCpl5iSzuUuw1axZzTqIfMKPKrg=
4d63.com/tz v1.1.0/go.mod h1:SHGqVdL7hd2ZaX2T9uEiOZ/OFAUfCCLURdLPJsd8ZNs=
4d63.com/tz v1.2.0 h1:EpJt060xY+M+M0Wj8btz+THdOJbSxj4i8jhVQP3Wr0U=
4d63.com/tz v1.2.0/go.mod h1:SHGqVdL7hd2ZaX2T9uEiOZ/OFAUfCCLURdLPJsd8ZNs=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
@ -63,14 +61,10 @@ github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hC
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a h1:idn718Q4B6AGu/h5Sxe66HYVdqdGu2l9Iebqhi/AEoA=
github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY=
github.com/asaskevich/govalidator v0.0.0-20200817114649-df4adffc9d8c h1:W9P/cRMhwMMfrtInPTAn5rcNu/+vb3zKdGeAd+87tFs=
github.com/asaskevich/govalidator v0.0.0-20200817114649-df4adffc9d8c/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/asaskevich/govalidator v0.0.0-20200818142706-50839af6027e h1:6P0tOQaAiB0G+etsknZvSDjNpdYshZ7wFXTqJpl41h0=
github.com/asaskevich/govalidator v0.0.0-20200818142706-50839af6027e/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/asaskevich/govalidator v0.0.0-20200819183940-29e1ff8eb0bb h1:kvlW1qyM1aU3xeyeIVTU2jx5fSvjKpsU3aXvuaCMg3Q=
github.com/asaskevich/govalidator v0.0.0-20200819183940-29e1ff8eb0bb/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef h1:46PFijGLmAjMPwCCCo7Jf0W6f9slllCkkv7vyc1yOSg=
github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/aymerick/raymond v2.0.3-0.20180322193309-b565731e1464+incompatible/go.mod h1:osfaiScAUVup+UC9Nfq76eWqDhXlp+4UYaA8uhTBO6g=
github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs=
github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
@ -84,8 +78,8 @@ github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kB
github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/c2h5oh/datasize v0.0.0-20200112174442-28bbd4740fee h1:BnPxIde0gjtTnc9Er7cxvBk8DHLWhEux0SxayC8dP6I=
github.com/c2h5oh/datasize v0.0.0-20200112174442-28bbd4740fee/go.mod h1:S/7n9copUssQ56c7aAgHqftWO4LTf4xY6CGWt8Bc+3M=
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/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY=
@ -294,10 +288,10 @@ github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO
github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
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/iancoleman/strcase v0.0.0-20191112232945-16388991a334 h1:VHgatEHNcBFEB7inlalqfNqw65aNkM1lGX2yt3NmbS8=
github.com/iancoleman/strcase v0.0.0-20191112232945-16388991a334/go.mod h1:SK73tn/9oHe+/Y0h39VT4UCxmurVJkR5NA7kMEAOgSE=
github.com/iancoleman/strcase v0.1.0 h1:Lar8rut26AXkJUmVOb2bRsFGv//+tJBeJLxXvpZpF1Q=
github.com/iancoleman/strcase v0.1.0/go.mod h1:SK73tn/9oHe+/Y0h39VT4UCxmurVJkR5NA7kMEAOgSE=
github.com/iancoleman/strcase v0.1.1 h1:2I+LRClyCYB7JgZb9U0k75VHUiQe9RfknRqDyUfzp7k=
github.com/iancoleman/strcase v0.1.1/go.mod h1:SK73tn/9oHe+/Y0h39VT4UCxmurVJkR5NA7kMEAOgSE=
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/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA=
github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA=
@ -367,6 +361,8 @@ github.com/jeffbean/tail v1.0.1 h1:mRuCwa9iq5kH1SBFAIWWFsg+NEi5+VVEf2E2ORVkKp8=
github.com/jeffbean/tail v1.0.1/go.mod h1:+MhJ+VPZMpv8Ui6WRzpJFuWFKxBCZgVOo5HAmlw1sFc=
github.com/jgautheron/goconst v0.0.0-20200227150835-cda7ea3bf591 h1:x/BpEhm6aL26o4TLtcU0loJ7B3+69jielrGc70V7Yb4=
github.com/jgautheron/goconst v0.0.0-20200227150835-cda7ea3bf591/go.mod h1:aAosetZ5zaeC/2EfMeRswtxUFBpe2Hr7HzkgX4fanO4=
github.com/jgautheron/goconst v0.0.0-20200920201509-8f5268ce89d5 h1:LlI/THEqi2n5RNtIIplDTGNCQ6m81r2tem36s7XSxtU=
github.com/jgautheron/goconst v0.0.0-20200920201509-8f5268ce89d5/go.mod h1:aAosetZ5zaeC/2EfMeRswtxUFBpe2Hr7HzkgX4fanO4=
github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
@ -412,6 +408,8 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo/v4 v4.1.11/go.mod h1:i541M3Fj6f76NZtHSj7TXnyM8n2gaodfvfxNnFqi74g=
github.com/labstack/echo/v4 v4.1.16 h1:8swiwjE5Jkai3RPfZoahp8kjVCRNq+y7Q0hPji2Kz0o=
github.com/labstack/echo/v4 v4.1.16/go.mod h1:awO+5TzAjvL8XpibdsfXxPgHr+orhtXZJZIQCVjogKI=
github.com/labstack/echo/v4 v4.1.17 h1:PQIBaRplyRy3OjwILGkPg89JRtH2x5bssi59G2EL3fo=
github.com/labstack/echo/v4 v4.1.17/go.mod h1:Tn2yRQL/UclUalpb5rPdXDevbkJ+lp/2svdyFBg6CHQ=
github.com/labstack/gommon v0.3.0 h1:JEeO0bvc78PKdyHxloTKiF8BD5iGrH8T6MSeGvSgob0=
github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k=
github.com/laurent22/ical-go v0.1.1-0.20181107184520-7e5d6ade8eef h1:RZnRnSID1skF35j/15KJ6hKZkdIC/teQClJK5wP5LU4=
@ -427,6 +425,8 @@ github.com/lib/pq v1.6.0 h1:I5DPxhYJChW9KYc66se+oKFFQX6VuQrKiprsX6ivRZc=
github.com/lib/pq v1.6.0/go.mod h1:4vXEAYvW1fRQ2/FhZ78H73A60MHw1geSm145z2mdY1g=
github.com/lib/pq v1.8.0 h1:9xohqzkUwzR4Ga4ivdTcawVS89YSDVxXMa3xJX3cGzg=
github.com/lib/pq v1.8.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
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/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4=
@ -463,8 +463,10 @@ github.com/mattn/go-sqlite3 v1.10.0 h1:jbhqpg7tQe4SupckyijYiy0mJJ/pRyHvXf7JdWK86
github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/go-sqlite3 v1.14.0 h1:mLyGNKR8+Vv9CAU7PphKa2hkEqxxhn8i32J6FPj1/QA=
github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus=
github.com/mattn/go-sqlite3 v1.14.1 h1:AHx9Ra40wIzl+GelgX2X6AWxmT5tfxhI1PL0523HcSw=
github.com/mattn/go-sqlite3 v1.14.1/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus=
github.com/mattn/go-sqlite3 v1.14.2 h1:A2EQLwjYf/hfYaM20FVjs1UewCTTFR7RmjEHkLjldIA=
github.com/mattn/go-sqlite3 v1.14.2/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus=
github.com/mattn/go-sqlite3 v1.14.3 h1:j7a/xn1U6TKA/PHHxqZuzh64CdtRc7rU9M+AvkOl5bA=
github.com/mattn/go-sqlite3 v1.14.3/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI=
github.com/mattn/goveralls v0.0.2/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw=
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
@ -586,8 +588,8 @@ github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749 h1:bUGsEnyNbVPw06B
github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg=
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/shurcooL/vfsgen v0.0.0-20200627165143-92b8a710ab6c h1:XLPw6rny9Vrrvrzhw8pNLrC2+x/kH0a/3gOx5xWDa6Y=
github.com/shurcooL/vfsgen v0.0.0-20200627165143-92b8a710ab6c/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw=
github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546 h1:pXY9qYc/MP5zdvqWEUH6SjNiu7VhSjuVFTFiTcphaLU=
github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
@ -601,6 +603,10 @@ github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI=
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
github.com/spf13/afero v1.3.4 h1:8q6vk3hthlpb2SouZcnBVKboxWQWMDNF38bwholZrJc=
github.com/spf13/afero v1.3.4/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
github.com/spf13/afero v1.3.5 h1:AWZ/w4lcfxuh52NVL78p9Eh8j6r1mCTEGSRFBJyIHAE=
github.com/spf13/afero v1.3.5/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
github.com/spf13/afero v1.4.0 h1:jsLTaI1zwYO3vjrzHalkVcIHXTNmdQFepW4OI8H3+x8=
github.com/spf13/afero v1.4.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
@ -667,6 +673,8 @@ github.com/valyala/fasttemplate v1.1.0 h1:RZqt0yGBsps8NGvLSGW804QQqCUYYLsaOjTVHy
github.com/valyala/fasttemplate v1.1.0/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
github.com/valyala/fasttemplate v1.2.0 h1:y3yXRCoDvC2HTtIHvL2cc7Zd+bqA+zqDO6oQzsJO07E=
github.com/valyala/fasttemplate v1.2.0/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
github.com/valyala/fasttemplate v1.2.1 h1:TVEnxayobAdVkhQfrfes2IzOB6o+z4roRkPF52WA1u4=
github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
@ -723,6 +731,14 @@ golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+o
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20200801110659-972c09e46d76 h1:U7GPaoQyQmX+CBRWXKrvRzWTbd+slqeSh8uARsIyhAw=
golang.org/x/image v0.0.0-20200801110659-972c09e46d76/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20200922025426-e59bae62ef32 h1:E+SEVulmY8U4+i6vSB88YSc2OKAFfvbHPU/uDTdQu7M=
golang.org/x/image v0.0.0-20200922025426-e59bae62ef32/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20200924062109-4578eab98f00 h1:9VSII+GM7HSMYSWsMcnMnDHKcDv2agce+07ISbE3llQ=
golang.org/x/image v0.0.0-20200924062109-4578eab98f00/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20200927005634-a67d67e0935b h1:89/bJY/WM5isgJh5o9bQ78Wu54tN5gZwkC+he5Q7vbM=
golang.org/x/image v0.0.0-20200927005634-a67d67e0935b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20200927104501-e162460cd6b5 h1:QelT11PB4FXiDEXucrfNckHoFxwt8USGY1ajP1ZF5lM=
golang.org/x/image v0.0.0-20200927104501-e162460cd6b5/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@ -774,6 +790,8 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200707034311-ab3426394381 h1:VXak5I6aEWmAXeQjA+QSZzlgNrpq9mjcfDemuexIKsU=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200822124328-c89045814202 h1:VvcQYSHwXgi7W+TpUR6A9g6Up98WAHf3f/ulnJ62IyA=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 h1:SVwTIAaPC2U/AvvLNZ2a7OVsmBpC8L5BlwK1whH3hm0=
@ -784,7 +802,10 @@ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e h1:vcxGaoTs7kV8m5Np9uUNQin4BrLOthgV7252N8V+FwY=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208 h1:qwRHBd0NqMbJxfbotnDhm2ByMI1Shq4Y6oRJo21SGJA=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -825,6 +846,8 @@ golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1 h1:ogLJMz+qpzav7lGMh10LMvAkM
golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200808120158-1030fc2bf1d9 h1:yi1hN8dcqI9l8klZfy4B8mJvFmmAxJEePIQQFNSd7Cs=
golang.org/x/sys v0.0.0-20200808120158-1030fc2bf1d9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6 h1:DvY3Zkh7KabQE/kfzMvYvKirSiguP9Q/veMtkYyf0o8=
golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

794
magefile.go Normal file
View File

@ -0,0 +1,794 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2020 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 General Public License 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 General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// +build mage
package main
import (
"bufio"
"bytes"
"context"
"crypto/sha256"
"fmt"
"github.com/magefile/mage/mg"
"golang.org/x/sync/errgroup"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
)
const (
PACKAGE = `code.vikunja.io/api`
DIST = `dist`
)
var (
Goflags = []string{
"-v",
}
Executable = "vikunja"
Ldflags = ""
Tags = ""
VersionNumber = "dev"
Version = "master" // This holds the built version, master by default, when building from a tag or release branch, their name
BinLocation = ""
PkgVersion = "master"
ApiPackages = []string{}
RootPath = ""
GoFiles = []string{}
// Aliases are mage aliases of targets
Aliases = map[string]interface{}{
"build": Build.Build,
"do-the-swag": DoTheSwag,
"check:go-sec": Check.GoSec,
"check:got-swag": Check.GotSwag,
"release:os-package": Release.OsPackage,
"dev:create-migration": Dev.CreateMigration,
}
)
func setVersion() {
versionCmd := exec.Command("git", "describe", "--tags", "--always", "--abbrev=10")
version, err := versionCmd.Output()
if err != nil {
fmt.Printf("Error getting version: %s\n", err)
os.Exit(1)
}
VersionNumber = strings.Trim(string(version), "\n")
VersionNumber = strings.Replace(VersionNumber, "-", "+", 1)
VersionNumber = strings.Replace(VersionNumber, "-g", "-", 1)
if os.Getenv("DRONE_TAG") != "" {
Version = os.Getenv("DRONE_TAG")
} else if os.Getenv("DRONE_BRANCH") != "" {
Version = strings.Replace(os.Getenv("DRONE_BRANCH"), "release/v", "", 1)
}
}
func setBinLocation() {
if os.Getenv("DRONE_WORKSPACE") != "" {
BinLocation = DIST + `/binaries/` + Executable + `-` + Version + `-linux-amd64`
} else {
BinLocation = Executable
}
}
func setPkgVersion() {
if Version == "master" {
PkgVersion = VersionNumber
}
}
func setExecutable() {
if runtime.GOOS == "windows" {
Executable += ".exe"
}
}
func setApiPackages() {
cmd := exec.Command("go", "list", "all")
pkgs, err := cmd.Output()
if err != nil {
fmt.Printf("Error getting packages: %s\n", err)
os.Exit(1)
}
for _, p := range strings.Split(string(pkgs), "\n") {
if strings.Contains(p, "code.vikunja.io/api") && !strings.Contains(p, "code.vikunja.io/api/pkg/integrations") {
ApiPackages = append(ApiPackages, p)
}
}
}
func setRootPath() {
pwd, err := os.Getwd()
if err != nil {
fmt.Printf("Error getting pwd: %s\n", err)
os.Exit(1)
}
if err := os.Setenv("VIKUNJA_SERVICE_ROOTPATH", pwd); err != nil {
fmt.Printf("Error setting root path: %s\n", err)
os.Exit(1)
}
RootPath = pwd
}
func setGoFiles() {
// GOFILES := $(shell find . -name "*.go" -type f ! -path "*/bindata.go")
cmd := exec.Command("find", ".", "-name", "*.go", "-type", "f", "!", "-path", "*/bindata.go")
files, err := cmd.Output()
if err != nil {
fmt.Printf("Error getting go files: %s\n", err)
os.Exit(1)
}
for _, f := range strings.Split(string(files), "\n") {
if strings.HasSuffix(f, ".go") {
GoFiles = append(GoFiles, RootPath+strings.TrimLeft(f, "."))
}
}
}
// Some variables can always get initialized, so we do just that.
func init() {
setExecutable()
setRootPath()
}
// Some variables have external dependencies (like git) which may not always be available.
func initVars() {
Tags = os.Getenv("TAGS")
setVersion()
setBinLocation()
setPkgVersion()
setApiPackages()
setGoFiles()
Ldflags = `-X "` + PACKAGE + `/pkg/version.Version=` + VersionNumber + `" -X "main.Tags=` + Tags + `"`
}
func runAndStreamOutput(cmd string, args ...string) {
c := exec.Command(cmd, args...)
c.Env = os.Environ()
c.Dir = RootPath
fmt.Printf("%s\n\n", c.String())
stdout, _ := c.StdoutPipe()
errbuf := bytes.Buffer{}
c.Stderr = &errbuf
c.Start()
reader := bufio.NewReader(stdout)
line, err := reader.ReadString('\n')
for err == nil {
fmt.Print(line)
line, err = reader.ReadString('\n')
}
if err := c.Wait(); err != nil {
fmt.Printf(errbuf.String())
fmt.Printf("Error: %s\n", err)
os.Exit(1)
}
}
// Will check if the tool exists and if not install it from the provided import path
// If any errors occur, it will exit with a status code of 1.
func checkAndInstallGoTool(tool, importPath string) {
if err := exec.Command(tool).Run(); err != nil && strings.Contains(err.Error(), "executable file not found") {
fmt.Printf("%s not installed, installing %s...\n", tool, importPath)
if err := exec.Command("go", "install", Goflags[0], importPath).Run(); err != nil {
fmt.Printf("Error installing %s\n", tool)
os.Exit(1)
}
fmt.Println("Installed.")
}
}
// Calculates a hash of a file
func calculateSha256FileHash(path string) (hash string, err error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return fmt.Sprintf("%x", h.Sum(nil)), nil
}
// Copy the src file to dst. Any existing file will be overwritten and will not
// copy file attributes.
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
if err != nil {
return err
}
si, err := os.Stat(src)
if err != nil {
return err
}
if err := os.Chmod(dst, si.Mode()); err != nil {
return err
}
return out.Close()
}
// os.Rename has issues with moving files between docker volumes.
// Because of this limitaion, it fails in drone.
// Source: https://gist.github.com/var23rav/23ae5d0d4d830aff886c3c970b8f6c6b
func moveFile(src, dst string) error {
inputFile, err := os.Open(src)
defer inputFile.Close()
if err != nil {
return fmt.Errorf("couldn't open source file: %s", err)
}
outputFile, err := os.Create(dst)
defer outputFile.Close()
if err != nil {
return fmt.Errorf("couldn't open dest file: %s", err)
}
_, err = io.Copy(outputFile, inputFile)
if err != nil {
return fmt.Errorf("writing to output file failed: %s", err)
}
// Make sure to copy copy the permissions of the original file as well
si, err := os.Stat(src)
if err != nil {
return err
}
if err := os.Chmod(dst, si.Mode()); err != nil {
return err
}
// The copy was successful, so now delete the original file
err = os.Remove(src)
if err != nil {
return fmt.Errorf("failed removing original file: %s", err)
}
return nil
}
// Formats the code using go fmt
func Fmt() {
mg.Deps(initVars)
args := append([]string{"-s", "-w"}, GoFiles...)
runAndStreamOutput("gofmt", args...)
}
// Generates the swagger docs from the code annotations
func DoTheSwag() {
mg.Deps(initVars)
checkAndInstallGoTool("swag", "github.com/swaggo/swag/cmd/swag")
runAndStreamOutput("swag", "init", "-g", "./pkg/routes/routes.go", "--parseDependency", "-d", RootPath, "-o", RootPath+"/pkg/swagger")
}
type Test mg.Namespace
// Runs all tests except integration tests
func (Test) Unit() {
mg.Deps(initVars)
// We run everything sequentially and not in parallel to prevent issues with real test databases
args := append([]string{"test", Goflags[0], "-p", "1"}, ApiPackages...)
runAndStreamOutput("go", args...)
}
// Runs the tests and builds the coverage html file from coverage output
func (Test) Coverage() {
mg.Deps(initVars)
mg.Deps(Test.Unit)
runAndStreamOutput("go", "tool", "cover", "-html=cover.out", "-o", "cover.html")
}
// Runs the integration tests
func (Test) Integration() {
mg.Deps(initVars)
// We run everything sequentially and not in parallel to prevent issues with real test databases
runAndStreamOutput("go", "test", Goflags[0], "-p", "1", PACKAGE+"/pkg/integrations")
}
type Check mg.Namespace
// Checks if the code is properly formatted with go fmt
func (Check) Fmt() error {
mg.Deps(initVars)
args := append([]string{"-s", "-d"}, GoFiles...)
c := exec.Command("gofmt", args...)
out, err := c.Output()
if err != nil {
return err
}
if len(out) > 0 {
fmt.Println("Code is not properly gofmt'ed.")
fmt.Println("Please run 'mage fmt' and commit the result:")
fmt.Print(string(out))
os.Exit(1)
}
return nil
}
// Runs golint on all packages
func (Check) Lint() {
mg.Deps(initVars)
checkAndInstallGoTool("golint", "golang.org/x/lint/golint")
args := append([]string{"-set_exit_status"}, ApiPackages...)
runAndStreamOutput("golint", args...)
}
// Checks if the swagger docs need to be re-generated from the code annotations
func (Check) GotSwag() {
mg.Deps(initVars)
// The check is pretty cheaply done: We take the hash of the swagger.json file, generate the docs,
// hash the file again and compare the two hashes to see if anything changed. If that's the case,
// regenerating the docs is necessary.
// swag is not capable of just outputting the generated docs to stdout, therefore we need to do it this way.
// Another drawback of this is obviously it will only work once - we're not resetting the newly generated
// docs after the check. This behaviour is good enough for ci though.
oldHash, err := calculateSha256FileHash(RootPath + "/pkg/swagger/swagger.json")
if err != nil {
fmt.Printf("Error getting old hash of the swagger docs: %s", err)
os.Exit(1)
}
DoTheSwag()
newHash, err := calculateSha256FileHash(RootPath + "/pkg/swagger/swagger.json")
if err != nil {
fmt.Printf("Error getting new hash of the swagger docs: %s", err)
os.Exit(1)
}
if oldHash != newHash {
fmt.Println("Swagger docs are not up to date.")
fmt.Println("Please run 'mage do-the-swag' and commit the result.")
os.Exit(1)
}
}
// Checks the source code for misspellings
func (Check) Misspell() {
mg.Deps(initVars)
checkAndInstallGoTool("misspell", "github.com/client9/misspell/cmd/misspell")
runAndStreamOutput("misspell", append([]string{"-error"}, GoFiles...)...)
}
// Checks the source code for ineffectual assigns
func (Check) Ineffassign() {
mg.Deps(initVars)
checkAndInstallGoTool("ineffassign", "github.com/gordonklaus/ineffassign")
runAndStreamOutput("ineffassign", GoFiles...)
}
// Checks for the cyclomatic complexity of the source code
func (Check) Gocyclo() {
mg.Deps(initVars)
checkAndInstallGoTool("gocyclo", "github.com/fzipp/gocyclo")
runAndStreamOutput("gocyclo", append([]string{"-over", "49"}, GoFiles...)...)
}
// Statically analyzes the source code about a range of different problems
func (Check) Static() {
mg.Deps(initVars)
checkAndInstallGoTool("staticcheck", "honnef.co/go/tools/cmd/staticcheck")
runAndStreamOutput("staticcheck", ApiPackages...)
}
// Checks the source code for potential security issues
func (Check) GoSec() {
mg.Deps(initVars)
if err := exec.Command("gosec").Run(); err != nil && strings.Contains(err.Error(), "executable file not found") {
fmt.Println("Please manually install gosec by running")
fmt.Println("curl -sfL https://raw.githubusercontent.com/securego/gosec/master/install.sh | bash -s -- -b $GOPATH/bin v2.2.0")
os.Exit(1)
}
runAndStreamOutput("gosec", "./...")
}
// Checks for repeated strings that could be replaced by a constant
func (Check) Goconst() {
mg.Deps(initVars)
checkAndInstallGoTool("goconst", "github.com/jgautheron/goconst/cmd/goconst")
runAndStreamOutput("goconst", ApiPackages...)
}
// Runs fmt-check, lint, got-swag, misspell-check, ineffasign-check, gocyclo-check, static-check, gosec-check, goconst-check all in parallel
func (Check) All() {
mg.Deps(initVars)
mg.Deps(
Check.Fmt,
Check.Lint,
Check.GotSwag,
Check.Misspell,
Check.Ineffassign,
Check.Gocyclo,
Check.Static,
Check.GoSec,
Check.Goconst,
)
}
type Build mg.Namespace
// Cleans all build, executable and bindata files
func (Build) Clean() error {
mg.Deps(initVars)
if err := exec.Command("go", "clean", "./...").Run(); err != nil {
return err
}
if err := os.Remove(Executable); err != nil && !os.IsNotExist(err) {
return err
}
if err := os.RemoveAll(DIST); err != nil && !os.IsNotExist(err) {
return err
}
if err := os.RemoveAll(BinLocation); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// Generates static content into the final binary
func (Build) Generate() {
mg.Deps(initVars)
runAndStreamOutput("go", "generate", PACKAGE+"/pkg/static")
}
// Builds a vikunja binary, ready to run
func (Build) Build() {
mg.Deps(initVars)
mg.Deps(Build.Generate)
runAndStreamOutput("go", "build", Goflags[0], "-tags", Tags, "-ldflags", "-s -w "+Ldflags, "-o", Executable)
}
type Release mg.Namespace
// Runs all steps in the right order to create release packages for various platforms
func (Release) Release(ctx context.Context) error {
mg.Deps(initVars)
mg.Deps(Build.Generate, Release.Dirs)
mg.Deps(Release.Windows, Release.Linux, Release.Darwin)
// Run compiling in parallel to speed it up
errs, _ := errgroup.WithContext(ctx)
errs.Go((Release{}).Windows)
errs.Go((Release{}).Linux)
errs.Go((Release{}).Darwin)
if err := errs.Wait(); err != nil {
return err
}
if err := (Release{}).Compress(ctx); err != nil {
return err
}
if err := (Release{}).Copy(); err != nil {
return err
}
if err := (Release{}).Check(); err != nil {
return err
}
if err := (Release{}).OsPackage(); err != nil {
return err
}
if err := (Release{}).Zip(); err != nil {
return err
}
return nil
}
// Creates all directories needed to release vikunja
func (Release) Dirs() error {
for _, d := range []string{"binaries", "release", "zip"} {
if err := os.MkdirAll(RootPath+"/"+DIST+"/"+d, 0755); err != nil {
return err
}
}
return nil
}
func runXgo(targets string) error {
mg.Deps(initVars)
checkAndInstallGoTool("xgo", "src.techknowlogick.com/xgo")
extraLdflags := `-linkmode external -extldflags "-static" `
// See https://github.com/techknowlogick/xgo/issues/79
if strings.HasPrefix(targets, "darwin") {
extraLdflags = ""
}
runAndStreamOutput("xgo",
"-dest", RootPath+"/"+DIST+"/binaries",
"-tags", "netgo "+Tags,
"-ldflags", extraLdflags+Ldflags,
"-targets", targets,
"-out", Executable+"-"+Version,
RootPath)
if os.Getenv("DRONE_WORKSPACE") != "" {
return filepath.Walk("/build/", func(path string, info os.FileInfo, err error) error {
// Skip directories
if info.IsDir() {
return nil
}
return moveFile(path, RootPath+"/"+DIST+"/binaries/"+info.Name())
})
}
return nil
}
// Builds binaries for windows
func (Release) Windows() error {
return runXgo("windows/*")
}
// Builds binaries for linux
func (Release) Linux() error {
return runXgo("linux/*")
}
// Builds binaries for darwin
func (Release) Darwin() error {
return runXgo("darwin/*")
}
// Compresses the built binaries in dist/binaries/ to reduce their filesize
func (Release) Compress(ctx context.Context) error {
// $(foreach file,$(filter-out $(wildcard $(wildcard $(DIST)/binaries/$(EXECUTABLE)-*mips*)),$(wildcard $(DIST)/binaries/$(EXECUTABLE)-*)), upx -9 $(file);)
errs, _ := errgroup.WithContext(ctx)
filepath.Walk(RootPath+"/"+DIST+"/binaries/", func(path string, info os.FileInfo, err error) error {
// Only executable files
if !strings.Contains(info.Name(), Executable) {
return nil
}
// No mips or s390x for you today
if strings.Contains(info.Name(), "mips") || strings.Contains(info.Name(), "s390x") {
return nil
}
// Runs compressing in parallel since upx is single-threaded
errs.Go(func() error {
runAndStreamOutput("chmod", "+x", path) // Make sure all binaries are executable. Sometimes the CI does weired things and they're not.
runAndStreamOutput("upx", "-9", path)
return nil
})
return nil
})
return errs.Wait()
}
// Copies all built binaries to dist/release/ in preparation for creating the os packages
func (Release) Copy() error {
return filepath.Walk(RootPath+"/"+DIST+"/binaries/", func(path string, info os.FileInfo, err error) error {
// Only executable files
if !strings.Contains(info.Name(), Executable) {
return nil
}
return copyFile(path, RootPath+"/"+DIST+"/release/"+info.Name())
})
}
// Creates sha256 checksum files for each binary in dist/release/
func (Release) Check() error {
p := RootPath + "/" + DIST + "/release/"
return filepath.Walk(p, func(path string, info os.FileInfo, err error) error {
if info.IsDir() {
return nil
}
f, err := os.Create(p + info.Name() + ".sha256")
if err != nil {
return err
}
hash, err := calculateSha256FileHash(path)
if err != nil {
return err
}
_, err = f.WriteString(hash + " " + info.Name())
if err != nil {
return err
}
return f.Close()
})
}
// Creates a folder for each
func (Release) OsPackage() error {
p := RootPath + "/" + DIST + "/release/"
// We first put all files in a map to then iterate over it since the walk function would otherwise also iterate
// over the newly created files, creating some kind of endless loop.
bins := make(map[string]os.FileInfo)
if err := filepath.Walk(p, func(path string, info os.FileInfo, err error) error {
if strings.Contains(info.Name(), ".sha256") || info.IsDir() {
return nil
}
bins[path] = info
return nil
}); err != nil {
return err
}
for path, info := range bins {
folder := p + info.Name() + "-full/"
if err := os.Mkdir(folder, 0755); err != nil {
return err
}
if err := moveFile(p+info.Name()+".sha256", folder+info.Name()+".sha256"); err != nil {
return err
}
if err := moveFile(path, folder+info.Name()); err != nil {
return err
}
if err := copyFile(RootPath+"/config.yml.sample", folder+"config.yml.sample"); err != nil {
return err
}
if err := copyFile(RootPath+"/LICENSE", folder+"LICENSE"); err != nil {
return err
}
}
return nil
}
// Creates a zip file from all os-package folders in dist/release
func (Release) Zip() error {
p := RootPath + "/" + DIST + "/release/"
if err := filepath.Walk(p, func(path string, info os.FileInfo, err error) error {
if !info.IsDir() || info.Name() == "release" {
return nil
}
fmt.Printf("Zipping %s...\n", info.Name())
c := exec.Command("zip", "-r", RootPath+"/"+DIST+"/zip/"+info.Name(), ".", "-i", "*")
c.Dir = path
out, err := c.Output()
fmt.Print(string(out))
return err
}); err != nil {
return err
}
return nil
}
// Creates a debian package from a built binary
func (Release) Deb() {
runAndStreamOutput(
"fpm",
"-s", "dir",
"-t", "deb",
"--url", "https://vikunja.io",
"-n", "vikunja",
"-v", PkgVersion,
"--license", "GPLv3",
"--directories", "/opt/vikunja",
"--after-install", "./build/after-install.sh",
"--description", "'Vikunja is an open-source todo application, written in Go. It lets you create lists,tasks and share them via teams or directly between users.'",
"-m", "maintainers@vikunja.io",
"-p", RootPath+"/"+Executable+"-"+Version+"_amd64.deb",
RootPath+"/"+BinLocation+"=/opt/vikunja/vikunja",
"./config.yml.sample=/etc/vikunja/config.yml",
)
}
// Creates a debian repo structure
func (Release) Reprepro() {
runAndStreamOutput("reprepro_expect", "debian", "includedeb", "strech", RootPath+"/"+Executable+"-"+Version+"_amd64.deb")
}
type Dev mg.Namespace
// Creates a new bare db migration skeleton in pkg/migration with the current date
func (Dev) CreateMigration() error {
reader := bufio.NewReader(os.Stdin)
fmt.Print("Enter the name of the struct: ")
str, _ := reader.ReadString('\n')
str = strings.Trim(str, "\n")
date := time.Now().Format("20060102150405")
migration := `// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2020 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 General Public License 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 General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package migration
import (
"src.techknowlogick.com/xormigrate"
"xorm.io/xorm"
)
type ` + str + date + ` struct {
}
func (` + str + date + `) TableName() string {
return "` + str + `"
}
func init() {
migrations = append(migrations, &xormigrate.Migration{
ID: "` + date + `",
Description: "",
Migrate: func(tx *xorm.Engine) error {
return tx.Sync2(` + str + date + `{})
},
Rollback: func(tx *xorm.Engine) error {
return nil
},
})
}
`
f, err := os.Create(RootPath + "/pkg/migration/" + date + ".go")
defer f.Close()
if err != nil {
return err
}
_, err = f.WriteString(migration)
return err
}

View File

@ -2,12 +2,14 @@
title: testbucket1
list_id: 1
created_by_id: 1
limit: 9999999 # This bucket has a limit we will never exceed in the tests to make sure the logic allows for buckets with limits
created: 2020-04-18 21:13:52
updated: 2020-04-18 21:13:52
- id: 2
title: testbucket2
list_id: 1
created_by_id: 1
limit: 3
created: 2020-04-18 21:13:52
updated: 2020-04-18 21:13:52
- id: 3

View File

@ -199,3 +199,13 @@
is_archived: 1
updated: 2018-12-02 15:13:12
created: 2018-12-01 15:13:12
-
id: 23
title: Test23
description: Lorem Ipsum
identifier: test23
owner_id: 12
namespace_id: 17
is_favorite: true
updated: 2018-12-02 15:13:12
created: 2018-12-01 15:13:12

View File

@ -82,3 +82,9 @@
is_archived: 1
updated: 2018-12-02 15:13:12
created: 2018-12-01 15:13:12
- id: 17
title: testnamespace17
description: Lorem Ipsum
owner_id: 12
updated: 2018-12-02 15:13:12
created: 2018-12-01 15:13:12

View File

@ -0,0 +1,6 @@
- id: 1
filters: '{"sort_by":null,"order_by":null,"filter_by":["start_date","end_date","due_date"],"filter_value":["2018-12-11T03:46:40+00:00","2018-12-13T11:20:01+00:00","2018-11-29T14:00:00+00:00"],"filter_comparator":["greater","less","greater"],"filter_concat":"","filter_include_nulls":false}'
title: testfilter1
owner_id: 1
updated: 2020-09-08 15:13:12
created: 2020-09-08 14:13:12

View File

@ -8,6 +8,7 @@
created: 2018-12-01 01:12:04
updated: 2018-12-01 01:12:04
bucket_id: 1
is_favorite: true
- id: 2
title: 'task #2 done'
done: true
@ -140,6 +141,7 @@
list_id: 6
index: 1
bucket_id: 6
is_favorite: true
created: 2018-12-01 01:12:04
updated: 2018-12-01 01:12:04
- id: 16
@ -315,6 +317,7 @@
list_id: 20
index: 20
bucket_id: 5
is_favorite: true
created: 2018-12-01 01:12:04
updated: 2018-12-01 01:12:04
- id: 35

View File

@ -20,7 +20,10 @@ package db
import (
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/log"
"fmt"
"github.com/stretchr/testify/assert"
"os"
"testing"
"xorm.io/core"
"xorm.io/xorm"
)
@ -69,3 +72,32 @@ func InitTestFixtures(tablenames ...string) (err error) {
return nil
}
// AssertExists checks and asserts the existence of certain entries in the db
func AssertExists(t *testing.T, table string, values map[string]interface{}, custom bool) {
var exists bool
var err error
v := make(map[string]interface{})
// Postgres sometimes needs to build raw sql. Because it won't always need to do this and this isn't fun, it's a flag.
if custom {
//#nosec
sql := "SELECT * FROM " + table + " WHERE "
for col, val := range values {
sql += col + "=" + fmt.Sprintf("%v", val) + " AND "
}
sql = sql[:len(sql)-5]
exists, err = x.SQL(sql).Get(&v)
} else {
exists, err = x.Table(table).Where(values).Get(&v)
}
assert.NoError(t, err, fmt.Sprintf("Failed to assert entries exist in db, error was: %s", err))
assert.True(t, exists, fmt.Sprintf("Entries %v do not exist in table %s", values, table))
}
// AssertMissing checks and asserts the nonexiste nce of certain entries in the db
func AssertMissing(t *testing.T, table string, values map[string]interface{}) {
v := make(map[string]interface{})
exists, err := x.Table(table).Where(values).Exist(&v)
assert.NoError(t, err, fmt.Sprintf("Failed to assert entries don't exist in db, error was: %s", err))
assert.False(t, exists, fmt.Sprintf("Entries %v exist in table %s", values, table))
}

View File

@ -113,49 +113,49 @@ func TestTaskCollection(t *testing.T) {
t.Run("by priority", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"priority"}}, urlParams)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `{"id":33,"title":"task #33 with percent done","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0.5,"identifier":"test1-17","index":17,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":4,"title":"task #4 low prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":1,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-4","index":4,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":3,"title":"task #3 high prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":100,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-3","index":3,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`)
assert.Contains(t, rec.Body.String(), `{"id":33,"title":"task #33 with percent done","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0.5,"identifier":"test1-17","index":17,"related_tasks":{},"attachments":null,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":4,"title":"task #4 low prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":1,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-4","index":4,"related_tasks":{},"attachments":null,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":3,"title":"task #3 high prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":100,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-3","index":3,"related_tasks":{},"attachments":null,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`)
})
t.Run("by priority desc", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"priority"}, "order_by": []string{"desc"}}, urlParams)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `[{"id":3,"title":"task #3 high prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":100,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-3","index":3,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":4,"title":"task #4 low prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":1`)
assert.Contains(t, rec.Body.String(), `[{"id":3,"title":"task #3 high prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":100,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-3","index":3,"related_tasks":{},"attachments":null,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":4,"title":"task #4 low prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":1`)
})
t.Run("by priority asc", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"priority"}, "order_by": []string{"asc"}}, urlParams)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `{"id":33,"title":"task #33 with percent done","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0.5,"identifier":"test1-17","index":17,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":4,"title":"task #4 low prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":1,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-4","index":4,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":3,"title":"task #3 high prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":100,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-3","index":3,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`)
assert.Contains(t, rec.Body.String(), `{"id":33,"title":"task #33 with percent done","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0.5,"identifier":"test1-17","index":17,"related_tasks":{},"attachments":null,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":4,"title":"task #4 low prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":1,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-4","index":4,"related_tasks":{},"attachments":null,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":3,"title":"task #3 high prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":100,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-3","index":3,"related_tasks":{},"attachments":null,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`)
})
// should equal duedate asc
t.Run("by due_date", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"due_date"}}, urlParams)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`)
assert.Contains(t, rec.Body.String(), `{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`)
})
t.Run("by duedate desc", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"due_date"}, "order_by": []string{"desc"}}, urlParams)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `[{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":6,"title":"task #6 lower due date`)
assert.Contains(t, rec.Body.String(), `[{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":6,"title":"task #6 lower due date`)
})
// Due date without unix suffix
t.Run("by duedate asc without suffix", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"due_date"}, "order_by": []string{"asc"}}, urlParams)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`)
assert.Contains(t, rec.Body.String(), `{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`)
})
t.Run("by due_date without suffix", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"due_date"}}, urlParams)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`)
assert.Contains(t, rec.Body.String(), `{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`)
})
t.Run("by duedate desc without suffix", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"due_date"}, "order_by": []string{"desc"}}, urlParams)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `[{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":6,"title":"task #6 lower due date`)
assert.Contains(t, rec.Body.String(), `[{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":6,"title":"task #6 lower due date`)
})
t.Run("by duedate asc", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"due_date"}, "order_by": []string{"asc"}}, urlParams)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`)
assert.Contains(t, rec.Body.String(), `{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`)
})
t.Run("invalid sort parameter", func(t *testing.T) {
_, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"loremipsum"}}, urlParams)
@ -258,6 +258,29 @@ func TestTaskCollection(t *testing.T) {
assertHandlerErrorCode(t, err, models.ErrCodeInvalidTaskFilterValue)
})
})
t.Run("saved filter", func(t *testing.T) {
t.Run("date range", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(
nil,
map[string]string{"list": "-2"}, // Actually a saved filter - contains the same filter arguments as the start and end date filter from above
)
assert.NoError(t, err)
assert.NotContains(t, rec.Body.String(), `task #1`)
assert.NotContains(t, rec.Body.String(), `task #2`)
assert.NotContains(t, rec.Body.String(), `task #3`)
assert.NotContains(t, rec.Body.String(), `task #4`)
assert.Contains(t, rec.Body.String(), `task #5`)
assert.Contains(t, rec.Body.String(), `task #6`)
assert.Contains(t, rec.Body.String(), `task #7`)
assert.Contains(t, rec.Body.String(), `task #8`)
assert.Contains(t, rec.Body.String(), `task #9`)
assert.NotContains(t, rec.Body.String(), `task #10`)
assert.NotContains(t, rec.Body.String(), `task #11`)
assert.NotContains(t, rec.Body.String(), `task #12`)
assert.NotContains(t, rec.Body.String(), `task #13`)
assert.NotContains(t, rec.Body.String(), `task #14`)
})
})
})
t.Run("ReadAll for all tasks", func(t *testing.T) {
@ -318,33 +341,33 @@ func TestTaskCollection(t *testing.T) {
t.Run("by priority", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"priority"}}, nil)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `{"id":33,"title":"task #33 with percent done","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0.5,"identifier":"test1-17","index":17,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":4,"title":"task #4 low prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":1,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-4","index":4,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":3,"title":"task #3 high prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":100,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-3","index":3,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`)
assert.Contains(t, rec.Body.String(), `{"id":33,"title":"task #33 with percent done","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0.5,"identifier":"test1-17","index":17,"related_tasks":{},"attachments":null,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":4,"title":"task #4 low prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":1,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-4","index":4,"related_tasks":{},"attachments":null,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":3,"title":"task #3 high prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":100,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-3","index":3,"related_tasks":{},"attachments":null,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`)
})
t.Run("by priority desc", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"priority"}, "order_by": []string{"desc"}}, nil)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `[{"id":3,"title":"task #3 high prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":100,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-3","index":3,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":4,"title":"task #4 low prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":1`)
assert.Contains(t, rec.Body.String(), `[{"id":3,"title":"task #3 high prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":100,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-3","index":3,"related_tasks":{},"attachments":null,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":4,"title":"task #4 low prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":1`)
})
t.Run("by priority asc", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"priority"}, "order_by": []string{"asc"}}, nil)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `{"id":33,"title":"task #33 with percent done","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0.5,"identifier":"test1-17","index":17,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":4,"title":"task #4 low prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":1,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-4","index":4,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":3,"title":"task #3 high prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":100,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-3","index":3,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`)
assert.Contains(t, rec.Body.String(), `{"id":33,"title":"task #33 with percent done","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0.5,"identifier":"test1-17","index":17,"related_tasks":{},"attachments":null,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":4,"title":"task #4 low prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":1,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-4","index":4,"related_tasks":{},"attachments":null,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":3,"title":"task #3 high prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":100,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-3","index":3,"related_tasks":{},"attachments":null,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`)
})
// should equal duedate asc
t.Run("by due_date", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"due_date"}}, nil)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`)
assert.Contains(t, rec.Body.String(), `{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`)
})
t.Run("by duedate desc", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"due_date"}, "order_by": []string{"desc"}}, nil)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `[{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":6,"title":"task #6 lower due date`)
assert.Contains(t, rec.Body.String(), `[{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":6,"title":"task #6 lower due date`)
})
t.Run("by duedate asc", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"due_date"}, "order_by": []string{"asc"}}, nil)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`)
assert.Contains(t, rec.Body.String(), `{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminder_dates":null,"list_id":1,"repeat_after":0,"repeat_from_current_date":false,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`)
})
t.Run("invalid parameter", func(t *testing.T) {
// Invalid parameter should not sort at all

View File

@ -289,9 +289,9 @@ func TestTask(t *testing.T) {
})
t.Run("Bucket", func(t *testing.T) {
t.Run("Normal", func(t *testing.T) {
rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"listtask": "1"}, `{"bucket_id":2}`)
rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"listtask": "1"}, `{"bucket_id":3}`)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"bucket_id":2`)
assert.Contains(t, rec.Body.String(), `"bucket_id":3`)
assert.NotContains(t, rec.Body.String(), `"bucket_id":1`)
})
t.Run("Different List", func(t *testing.T) {
@ -472,9 +472,9 @@ func TestTask(t *testing.T) {
})
t.Run("Bucket", func(t *testing.T) {
t.Run("Normal", func(t *testing.T) {
rec, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "1"}, `{"title":"Lorem Ipsum","bucket_id":2}`)
rec, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "1"}, `{"title":"Lorem Ipsum","bucket_id":3}`)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"bucket_id":2`)
assert.Contains(t, rec.Body.String(), `"bucket_id":3`)
assert.NotContains(t, rec.Body.String(), `"bucket_id":1`)
})
t.Run("Different List", func(t *testing.T) {

View File

@ -0,0 +1,43 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2020 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 General Public License 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 General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package migration
import (
"src.techknowlogick.com/xormigrate"
"xorm.io/xorm"
)
type buckets20200904101559 struct {
Limit int64 `xorm:"default 0" json:"limit"`
}
func (buckets20200904101559) TableName() string {
return "buckets"
}
func init() {
migrations = append(migrations, &xormigrate.Migration{
ID: "20200904101559",
Description: "Add limit field to kanban",
Migrate: func(tx *xorm.Engine) error {
return tx.Sync2(buckets20200904101559{})
},
Rollback: func(tx *xorm.Engine) error {
return nil
},
})
}

View File

@ -0,0 +1,43 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2020 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 General Public License 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 General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package migration
import (
"src.techknowlogick.com/xormigrate"
"xorm.io/xorm"
)
type tasks20200905151040 struct {
IsFavorite bool `xorm:"default false" json:"is_favorite"`
}
func (tasks20200905151040) TableName() string {
return "tasks"
}
func init() {
migrations = append(migrations, &xormigrate.Migration{
ID: "20200905151040",
Description: "Add favorite field to tasks",
Migrate: func(tx *xorm.Engine) error {
return tx.Sync2(tasks20200905151040{})
},
Rollback: func(tx *xorm.Engine) error {
return nil
},
})
}

View File

@ -0,0 +1,43 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2020 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 General Public License 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 General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package migration
import (
"src.techknowlogick.com/xormigrate"
"xorm.io/xorm"
)
type list20200905232458 struct {
IsFavorite bool `xorm:"default false" json:"is_favorite"`
}
func (list20200905232458) TableName() string {
return "list"
}
func init() {
migrations = append(migrations, &xormigrate.Migration{
ID: "20200905232458",
Description: "Add is_favorite field to lists",
Migrate: func(tx *xorm.Engine) error {
return tx.Sync2(list20200905232458{})
},
Rollback: func(tx *xorm.Engine) error {
return nil
},
})
}

View File

@ -0,0 +1,51 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2020 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 General Public License 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 General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package migration
import (
"code.vikunja.io/api/pkg/models"
"src.techknowlogick.com/xormigrate"
"time"
"xorm.io/xorm"
)
type savedFilters20200906184746 struct {
ID int64 `xorm:"autoincr not null unique pk" json:"id"`
Filters *models.TaskCollection `xorm:"JSON not null" json:"filters"`
Title string `xorm:"varchar(250) not null" json:"title" valid:"required,runelength(1|250)" minLength:"1" maxLength:"250"`
Description string `xorm:"longtext null" json:"description"`
OwnerID int64 `xorm:"int(11) not null INDEX" json:"-"`
Created time.Time `xorm:"created not null" json:"created"`
Updated time.Time `xorm:"updated not null" json:"updated"`
}
func (savedFilters20200906184746) TableName() string {
return "saved_filters"
}
func init() {
migrations = append(migrations, &xormigrate.Migration{
ID: "20200906184746",
Description: "Add the saved filters column",
Migrate: func(tx *xorm.Engine) error {
return tx.Sync2(savedFilters20200906184746{})
},
Rollback: func(tx *xorm.Engine) error {
return nil
},
})
}

View File

@ -1334,3 +1334,91 @@ func (err ErrCannotRemoveLastBucket) HTTPError() web.HTTPError {
Message: "You cannot remove the last bucket on this list.",
}
}
// ErrBucketLimitExceeded represents an error where a task is being created or moved to a bucket which has its limit already exceeded.
type ErrBucketLimitExceeded struct {
BucketID int64
Limit int64
TaskID int64 // may be 0
}
// IsErrBucketLimitExceeded checks if an error is ErrBucketLimitExceeded.
func IsErrBucketLimitExceeded(err error) bool {
_, ok := err.(ErrBucketLimitExceeded)
return ok
}
func (err ErrBucketLimitExceeded) Error() string {
return fmt.Sprintf("Cannot add a task to this bucket because it would exceed the limit [BucketID: %d, Limit: %d, TaskID: %d]", err.BucketID, err.Limit, err.TaskID)
}
// ErrCodeBucketLimitExceeded holds the unique world-error code of this error
const ErrCodeBucketLimitExceeded = 10004
// HTTPError holds the http error description
func (err ErrBucketLimitExceeded) HTTPError() web.HTTPError {
return web.HTTPError{
HTTPCode: http.StatusPreconditionFailed,
Code: ErrCodeBucketLimitExceeded,
Message: "You cannot add the task to this bucket as it already exceeded the limit of tasks it can hold.",
}
}
// =============
// Saved Filters
// =============
// ErrSavedFilterDoesNotExist represents an error where a kanban bucket does not exist
type ErrSavedFilterDoesNotExist struct {
SavedFilterID int64
}
// IsErrSavedFilterDoesNotExist checks if an error is ErrSavedFilterDoesNotExist.
func IsErrSavedFilterDoesNotExist(err error) bool {
_, ok := err.(ErrSavedFilterDoesNotExist)
return ok
}
func (err ErrSavedFilterDoesNotExist) Error() string {
return fmt.Sprintf("Saved filter does not exist [SavedFilterID: %d]", err.SavedFilterID)
}
// ErrCodeSavedFilterDoesNotExist holds the unique world-error code of this error
const ErrCodeSavedFilterDoesNotExist = 11001
// HTTPError holds the http error description
func (err ErrSavedFilterDoesNotExist) HTTPError() web.HTTPError {
return web.HTTPError{
HTTPCode: http.StatusNotFound,
Code: ErrCodeSavedFilterDoesNotExist,
Message: "This saved filter does not exist.",
}
}
// ErrSavedFilterNotAvailableForLinkShare represents an error where a kanban bucket does not exist
type ErrSavedFilterNotAvailableForLinkShare struct {
SavedFilterID int64
LinkShareID int64
}
// IsErrSavedFilterNotAvailableForLinkShare checks if an error is ErrSavedFilterNotAvailableForLinkShare.
func IsErrSavedFilterNotAvailableForLinkShare(err error) bool {
_, ok := err.(ErrSavedFilterNotAvailableForLinkShare)
return ok
}
func (err ErrSavedFilterNotAvailableForLinkShare) Error() string {
return fmt.Sprintf("Saved filters are not available for link shares [SavedFilterID: %d, LinkShareID: %d]", err.SavedFilterID, err.LinkShareID)
}
// ErrCodeSavedFilterNotAvailableForLinkShare holds the unique world-error code of this error
const ErrCodeSavedFilterNotAvailableForLinkShare = 11002
// HTTPError holds the http error description
func (err ErrSavedFilterNotAvailableForLinkShare) HTTPError() web.HTTPError {
return web.HTTPError{
HTTPCode: http.StatusPreconditionFailed,
Code: ErrCodeSavedFilterNotAvailableForLinkShare,
Message: "Saved filters are not available for link shares.",
}
}

View File

@ -35,6 +35,9 @@ type Bucket struct {
// All tasks which belong to this bucket.
Tasks []*Task `xorm:"-" json:"tasks"`
// How many tasks can be at the same time on this board max
Limit int64 `xorm:"default 0" json:"limit"`
// A timestamp when this bucket was created. You cannot change this value.
Created time.Time `xorm:"created not null" json:"created"`
// A timestamp when this bucket was last updated. You cannot change this value.
@ -125,7 +128,7 @@ func (b *Bucket) ReadAll(auth web.Auth, search string, page int, perPage int) (r
},
},
}
tasks, _, _, err := getTasksForLists([]*List{{ID: b.ListID}}, opts)
tasks, _, _, err := getTasksForLists([]*List{{ID: b.ListID}}, auth, opts)
if err != nil {
return
}

View File

@ -78,6 +78,10 @@ func TestBucket_Delete(t *testing.T) {
err = x.Where("bucket_id = ?", 1).Find(&tasks)
assert.NoError(t, err)
assert.Len(t, tasks, 15)
db.AssertMissing(t, "buckets", map[string]interface{}{
"id": 2,
"list_id": 1,
})
})
t.Run("last bucket in list", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
@ -88,5 +92,9 @@ func TestBucket_Delete(t *testing.T) {
err := b.Delete()
assert.Error(t, err)
assert.True(t, IsErrCannotRemoveLastBucket(err))
db.AssertExists(t, "buckets", map[string]interface{}{
"id": 34,
"list_id": 18,
}, false)
})
}

View File

@ -27,7 +27,7 @@ type Label struct {
// The unique, numeric id of this label.
ID int64 `xorm:"int(11) autoincr not null unique pk" json:"id" param:"label"`
// The title of the lable. You'll see this one on tasks associated with it.
Title string `xorm:"varchar(250) not null" json:"title" valid:"runelength(1|250)" minLength:"3" maxLength:"250"`
Title string `xorm:"varchar(250) not null" json:"title" valid:"runelength(1|250)" minLength:"1" maxLength:"250"`
// The label description.
Description string `xorm:"longtext null" json:"description"`
// The color this label has
@ -217,7 +217,7 @@ func getUserTaskIDs(u *user.User) (taskIDs []int64, err error) {
return nil, err
}
tasks, _, _, err := getRawTasksForLists(lists, &taskOptions{
tasks, _, _, err := getRawTasksForLists(lists, u, &taskOptions{
page: -1,
perPage: 0,
})

View File

@ -202,6 +202,13 @@ func TestLabelTask_Create(t *testing.T) {
if (err != nil) && tt.wantErr && !tt.errType(err) {
t.Errorf("LabelTask.Create() Wrong error type! Error = %v, want = %v", err, runtime.FuncForPC(reflect.ValueOf(tt.errType).Pointer()).Name())
}
if !tt.wantErr {
db.AssertExists(t, "label_task", map[string]interface{}{
"id": l.ID,
"task_id": l.TaskID,
"label_id": l.LabelID,
}, false)
}
})
}
}
@ -291,6 +298,12 @@ func TestLabelTask_Delete(t *testing.T) {
if (err != nil) && tt.wantErr && !tt.errType(err) {
t.Errorf("LabelTask.Delete() Wrong error type! Error = %v, want = %v", err, runtime.FuncForPC(reflect.ValueOf(tt.errType).Pointer()).Name())
}
if !tt.wantForbidden {
db.AssertMissing(t, "label_task", map[string]interface{}{
"label_id": l.LabelID,
"task_id": l.TaskID,
})
}
})
}
}

View File

@ -314,6 +314,14 @@ func TestLabel_Create(t *testing.T) {
if err := l.Create(tt.args.a); (err != nil) != tt.wantErr {
t.Errorf("Label.Create() error = %v, wantErr %v", err, tt.wantErr)
}
if !tt.wantErr {
db.AssertExists(t, "labels", map[string]interface{}{
"id": l.ID,
"title": l.Title,
"description": l.Description,
"hex_color": l.HexColor,
}, false)
}
})
}
}
@ -396,6 +404,12 @@ func TestLabel_Update(t *testing.T) {
if err := l.Update(); (err != nil) != tt.wantErr {
t.Errorf("Label.Update() error = %v, wantErr %v", err, tt.wantErr)
}
if !tt.wantErr && !tt.wantForbidden {
db.AssertExists(t, "labels", map[string]interface{}{
"id": tt.fields.ID,
"title": tt.fields.Title,
}, false)
}
})
}
}
@ -474,6 +488,11 @@ func TestLabel_Delete(t *testing.T) {
if err := l.Delete(); (err != nil) != tt.wantErr {
t.Errorf("Label.Delete() error = %v, wantErr %v", err, tt.wantErr)
}
if !tt.wantErr && !tt.wantForbidden {
db.AssertMissing(t, "labels", map[string]interface{}{
"id": l.ID,
})
}
})
}
}

View File

@ -32,7 +32,7 @@ type List struct {
// The unique, numeric id of this list.
ID int64 `xorm:"int(11) autoincr not null unique pk" json:"id" param:"list"`
// The title of the list. You'll see this in the namespace overview.
Title string `xorm:"varchar(250) not null" json:"title" valid:"required,runelength(1|250)" minLength:"3" maxLength:"250"`
Title string `xorm:"varchar(250) not null" json:"title" valid:"required,runelength(1|250)" minLength:"1" maxLength:"250"`
// The description of the list.
Description string `xorm:"longtext null" json:"description"`
// The unique list short identifier. Used to build task identifiers.
@ -57,6 +57,9 @@ type List struct {
// Holds extra information about the background set since some background providers require attribution or similar. If not null, the background can be accessed at /lists/{listID}/background
BackgroundInformation interface{} `xorm:"-" json:"background_information"`
// True if a list is a favorite. Favorite lists show up in a separate namespace.
IsFavorite bool `xorm:"default false" json:"is_favorite"`
// A timestamp when this list was created. You cannot change this value.
Created time.Time `xorm:"created not null" json:"created"`
// A timestamp when this list was last updated. You cannot change this value.
@ -74,6 +77,17 @@ type ListBackgroundType struct {
// ListBackgroundUpload represents the list upload background type
const ListBackgroundUpload string = "upload"
// FavoritesPseudoList holds all tasks marked as favorites
var FavoritesPseudoList = List{
ID: -1,
Title: "Favorites",
Description: "This list has all tasks marked as favorites.",
NamespaceID: FavoritesPseudoNamespace.ID,
IsFavorite: true,
Created: time.Now(),
Updated: time.Now(),
}
// GetListsByNamespaceID gets all lists in a namespace
func GetListsByNamespaceID(nID int64, doer *user.User) (lists []*List, err error) {
if nID == -1 {
@ -165,6 +179,25 @@ func (l *List) ReadAll(a web.Auth, search string, page int, perPage int) (result
// @Failure 500 {object} models.Message "Internal error"
// @Router /lists/{id} [get]
func (l *List) ReadOne() (err error) {
if l.ID == FavoritesPseudoList.ID {
// Already "built" the list in CanRead
return nil
}
// Check for saved filters
if getSavedFilterIDFromListID(l.ID) > 0 {
sf, err := getSavedFilterSimpleByID(getSavedFilterIDFromListID(l.ID))
if err != nil {
return err
}
l.Title = sf.Title
l.Description = sf.Description
l.Created = sf.Created
l.Updated = sf.Updated
l.OwnerID = sf.OwnerID
}
// Get list owner
l.Owner, err = user.GetUserByID(l.OwnerID)
if err != nil {
@ -448,7 +481,7 @@ func GenerateListIdentifier(l *List, sess *xorm.Engine) (err error) {
func CreateOrUpdateList(list *List) (err error) {
// Check if the namespace exists
if list.NamespaceID != 0 {
if list.NamespaceID != 0 && list.NamespaceID != FavoritesPseudoNamespace.ID {
_, err = GetNamespaceByID(list.NamespaceID)
if err != nil {
return err
@ -487,6 +520,7 @@ func CreateOrUpdateList(list *List) (err error) {
"is_archived",
"identifier",
"hex_color",
"is_favorite",
}
if list.Description != "" {
colsToUpdate = append(colsToUpdate, "description")

View File

@ -106,7 +106,7 @@ func (ld *ListDuplicate) Create(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([]*List{{ID: ld.ListID}}, &taskOptions{})
tasks, _, _, err := getTasksForLists([]*List{{ID: ld.ListID}}, a, &taskOptions{})
if err != nil {
return err
}

View File

@ -25,6 +25,11 @@ import (
// CanWrite return whether the user can write on that list or not
func (l *List) CanWrite(a web.Auth) (bool, error) {
// The favorite list can't be edited
if l.ID == FavoritesPseudoList.ID {
return false, nil
}
// Get the list and check the right
originalList := &List{ID: l.ID}
err := originalList.GetSimpleByID()
@ -63,6 +68,25 @@ func (l *List) CanWrite(a web.Auth) (bool, error) {
// CanRead checks if a user has read access to a list
func (l *List) CanRead(a web.Auth) (bool, int, error) {
// The favorite list needs a special treatment
if l.ID == FavoritesPseudoList.ID {
owner, err := user.GetFromAuth(a)
if err != nil {
return false, 0, err
}
*l = FavoritesPseudoList
l.Owner = owner
return true, int(RightRead), nil
}
// Saved Filter Lists need a special case
if getSavedFilterIDFromListID(l.ID) > 0 {
sf := &SavedFilter{ID: getSavedFilterIDFromListID(l.ID)}
return sf.CanRead(a)
}
// Check if the user is either owner or can read
if err := l.GetSimpleByID(); err != nil {
return false, 0, err
@ -83,6 +107,10 @@ func (l *List) CanRead(a web.Auth) (bool, int, error) {
// CanUpdate checks if the user can update a list
func (l *List) CanUpdate(a web.Auth) (canUpdate bool, err error) {
// The favorite list can't be edited
if l.ID == FavoritesPseudoList.ID {
return false, nil
}
canUpdate, err = l.CanWrite(a)
// If the list is archived and the user tries to un-archive it, let the request through
if IsErrListIsArchived(err) && !l.IsArchived {
@ -105,6 +133,11 @@ func (l *List) CanCreate(a web.Auth) (bool, error) {
// IsAdmin returns whether the user has admin rights on the list or not
func (l *List) IsAdmin(a web.Auth) (bool, error) {
// The favorite list can't be edited
if l.ID == FavoritesPseudoList.ID {
return false, nil
}
originalList := &List{ID: l.ID}
err := originalList.GetSimpleByID()
if err != nil {

View File

@ -27,92 +27,150 @@ import (
"time"
)
func TestTeamList(t *testing.T) {
db.LoadAndAssertFixtures(t)
func TestTeamList_ReadAll(t *testing.T) {
u := &user.User{ID: 1}
// Dummy relation
tl := TeamList{
TeamID: 1,
ListID: 1,
Right: RightAdmin,
}
t.Run("normal", func(t *testing.T) {
tl := TeamList{
TeamID: 1,
ListID: 3,
}
db.LoadAndAssertFixtures(t)
teams, _, _, err := tl.ReadAll(u, "", 1, 50)
assert.NoError(t, err)
assert.Equal(t, reflect.TypeOf(teams).Kind(), reflect.Slice)
s := reflect.ValueOf(teams)
assert.Equal(t, s.Len(), 1)
})
t.Run("nonexistant list", func(t *testing.T) {
tl := TeamList{
ListID: 99999,
}
db.LoadAndAssertFixtures(t)
_, _, _, err := tl.ReadAll(u, "", 1, 50)
assert.Error(t, err)
assert.True(t, IsErrListDoesNotExist(err))
})
t.Run("namespace owner", func(t *testing.T) {
tl := TeamList{
TeamID: 1,
ListID: 2,
Right: RightAdmin,
}
db.LoadAndAssertFixtures(t)
_, _, _, err := tl.ReadAll(u, "", 1, 50)
assert.NoError(t, err)
})
t.Run("no access", func(t *testing.T) {
tl := TeamList{
TeamID: 1,
ListID: 5,
Right: RightAdmin,
}
db.LoadAndAssertFixtures(t)
_, _, _, err := tl.ReadAll(u, "", 1, 50)
assert.Error(t, err)
assert.True(t, IsErrNeedToHaveListReadAccess(err))
})
}
// Dummyuser
u, err := user.GetUserByID(1)
assert.NoError(t, err)
func TestTeamList_Create(t *testing.T) {
u := &user.User{ID: 1}
t.Run("normal", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
tl := TeamList{
TeamID: 1,
ListID: 1,
Right: RightAdmin,
}
allowed, _ := tl.CanCreate(u)
assert.True(t, allowed)
err := tl.Create(u)
assert.NoError(t, err)
db.AssertExists(t, "team_list", map[string]interface{}{
"team_id": 1,
"list_id": 1,
"right": RightAdmin,
}, false)
})
t.Run("team already has access", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
tl := TeamList{
TeamID: 1,
ListID: 3,
Right: RightAdmin,
}
err := tl.Create(u)
assert.Error(t, err)
assert.True(t, IsErrTeamAlreadyHasAccess(err))
})
t.Run("wrong rights", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
tl := TeamList{
TeamID: 1,
ListID: 1,
Right: RightUnknown,
}
err := tl.Create(u)
assert.Error(t, err)
assert.True(t, IsErrInvalidRight(err))
})
t.Run("nonexistant team", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
tl := TeamList{
TeamID: 9999,
ListID: 1,
}
err := tl.Create(u)
assert.Error(t, err)
assert.True(t, IsErrTeamDoesNotExist(err))
})
t.Run("nonexistant list", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
tl := TeamList{
TeamID: 1,
ListID: 9999,
}
err := tl.Create(u)
assert.Error(t, err)
assert.True(t, IsErrListDoesNotExist(err))
})
}
// Check normal creation
allowed, _ := tl.CanCreate(u)
assert.True(t, allowed)
err = tl.Create(u)
assert.NoError(t, err)
// Check again
err = tl.Create(u)
assert.Error(t, err)
assert.True(t, IsErrTeamAlreadyHasAccess(err))
// Check with wrong rights
tl2 := tl
tl2.Right = RightUnknown
err = tl2.Create(u)
assert.Error(t, err)
assert.True(t, IsErrInvalidRight(err))
// Check with inexistant team
tl3 := tl
tl3.TeamID = 3253
err = tl3.Create(u)
assert.Error(t, err)
assert.True(t, IsErrTeamDoesNotExist(err))
// Check with inexistant list
tl4 := tl
tl4.ListID = 3252
err = tl4.Create(u)
assert.Error(t, err)
assert.True(t, IsErrListDoesNotExist(err))
// Test Read all
teams, _, _, err := tl.ReadAll(u, "", 1, 50)
assert.NoError(t, err)
assert.Equal(t, reflect.TypeOf(teams).Kind(), reflect.Slice)
s := reflect.ValueOf(teams)
assert.Equal(t, s.Len(), 1)
// Test Read all for nonexistant list
_, _, _, err = tl4.ReadAll(u, "", 1, 50)
assert.Error(t, err)
assert.True(t, IsErrListDoesNotExist(err))
// Test Read all for a list where the user is owner of the namespace this list belongs to
tl5 := tl
tl5.ListID = 2
_, _, _, err = tl5.ReadAll(u, "", 1, 50)
assert.NoError(t, err)
// Test read all for a list where the user not has access
tl6 := tl
tl6.ListID = 5
_, _, _, err = tl6.ReadAll(u, "", 1, 50)
assert.Error(t, err)
assert.True(t, IsErrNeedToHaveListReadAccess(err))
// Delete
allowed, _ = tl.CanDelete(u)
assert.True(t, allowed)
err = tl.Delete()
assert.NoError(t, err)
// Delete a nonexistant team
err = tl3.Delete()
assert.Error(t, err)
assert.True(t, IsErrTeamDoesNotExist(err))
// Delete with a nonexistant list
err = tl4.Delete()
assert.Error(t, err)
assert.True(t, IsErrTeamDoesNotHaveAccessToList(err))
func TestTeamList_Delete(t *testing.T) {
t.Run("normal", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
tl := TeamList{
TeamID: 1,
ListID: 3,
}
err := tl.Delete()
assert.NoError(t, err)
db.AssertMissing(t, "team_list", map[string]interface{}{
"team_id": 1,
"list_id": 3,
})
})
t.Run("nonexistant team", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
tl := TeamList{
TeamID: 9999,
ListID: 1,
}
err := tl.Delete()
assert.Error(t, err)
assert.True(t, IsErrTeamDoesNotExist(err))
})
t.Run("nonexistant list", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
tl := TeamList{
TeamID: 1,
ListID: 9999,
}
err := tl.Delete()
assert.Error(t, err)
assert.True(t, IsErrTeamDoesNotHaveAccessToList(err))
})
}
func TestTeamList_Update(t *testing.T) {
@ -188,6 +246,13 @@ func TestTeamList_Update(t *testing.T) {
if (err != nil) && tt.wantErr && !tt.errType(err) {
t.Errorf("TeamList.Update() Wrong error type! Error = %v, want = %v", err, runtime.FuncForPC(reflect.ValueOf(tt.errType).Pointer()).Name())
}
if !tt.wantErr {
db.AssertExists(t, "team_list", map[string]interface{}{
"list_id": tt.fields.ListID,
"team_id": tt.fields.TeamID,
"right": tt.fields.Right,
}, false)
}
})
}
}

View File

@ -41,6 +41,12 @@ func TestList_CreateOrUpdate(t *testing.T) {
}
err := list.Create(usr)
assert.NoError(t, err)
db.AssertExists(t, "list", map[string]interface{}{
"id": list.ID,
"title": list.Title,
"description": list.Description,
"namespace_id": list.NamespaceID,
}, false)
})
t.Run("nonexistant namespace", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
@ -88,6 +94,12 @@ func TestList_CreateOrUpdate(t *testing.T) {
}
err := list.Create(usr)
assert.NoError(t, err)
db.AssertExists(t, "list", map[string]interface{}{
"id": list.ID,
"title": list.Title,
"description": list.Description,
"namespace_id": list.NamespaceID,
}, false)
})
})
@ -103,7 +115,12 @@ func TestList_CreateOrUpdate(t *testing.T) {
list.Description = "Lorem Ipsum dolor sit amet."
err := list.Update()
assert.NoError(t, err)
db.AssertExists(t, "list", map[string]interface{}{
"id": list.ID,
"title": list.Title,
"description": list.Description,
"namespace_id": list.NamespaceID,
}, false)
})
t.Run("nonexistant", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
@ -139,6 +156,9 @@ func TestList_Delete(t *testing.T) {
}
err := list.Delete()
assert.NoError(t, err)
db.AssertMissing(t, "list", map[string]interface{}{
"id": 1,
})
}
func TestList_ReadAll(t *testing.T) {

View File

@ -126,6 +126,12 @@ func TestListUser_Create(t *testing.T) {
if (err != nil) && tt.wantErr && !tt.errType(err) {
t.Errorf("ListUser.Create() Wrong error type! Error = %v, want = %v", err, runtime.FuncForPC(reflect.ValueOf(tt.errType).Pointer()).Name())
}
if !tt.wantErr {
db.AssertExists(t, "users_list", map[string]interface{}{
"user_id": ul.UserID,
"list_id": tt.fields.ListID,
}, false)
}
})
}
}
@ -299,6 +305,13 @@ func TestListUser_Update(t *testing.T) {
if (err != nil) && tt.wantErr && !tt.errType(err) {
t.Errorf("ListUser.Update() Wrong error type! Error = %v, want = %v", err, runtime.FuncForPC(reflect.ValueOf(tt.errType).Pointer()).Name())
}
if !tt.wantErr {
db.AssertExists(t, "users_list", map[string]interface{}{
"list_id": tt.fields.ListID,
"user_id": lu.UserID,
"right": tt.fields.Right,
}, false)
}
})
}
}
@ -307,6 +320,7 @@ func TestListUser_Delete(t *testing.T) {
type fields struct {
ID int64
Username string
UserID int64
ListID int64
Right Right
Created time.Time
@ -342,6 +356,7 @@ func TestListUser_Delete(t *testing.T) {
name: "Try deleting normally",
fields: fields{
Username: "user1",
UserID: 1,
ListID: 3,
},
},
@ -367,6 +382,12 @@ func TestListUser_Delete(t *testing.T) {
if (err != nil) && tt.wantErr && !tt.errType(err) {
t.Errorf("ListUser.Delete() Wrong error type! Error = %v, want = %v", err, runtime.FuncForPC(reflect.ValueOf(tt.errType).Pointer()).Name())
}
if !tt.wantErr {
db.AssertMissing(t, "users_list", map[string]interface{}{
"user_id": tt.fields.UserID,
"list_id": tt.fields.ListID,
})
}
})
}
}

View File

@ -57,6 +57,7 @@ func GetTables() []interface{} {
&TaskComment{},
&Bucket{},
&UnsplashPhoto{},
&SavedFilter{},
}
}

View File

@ -21,6 +21,7 @@ import (
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/web"
"github.com/imdario/mergo"
"sort"
"time"
"xorm.io/builder"
)
@ -30,7 +31,7 @@ type Namespace struct {
// The unique, numeric id of this namespace.
ID int64 `xorm:"int(11) autoincr not null unique pk" json:"id" param:"namespace"`
// The name of this namespace.
Title string `xorm:"varchar(250) not null" json:"title" valid:"required,runelength(1|250)" minLength:"5" maxLength:"250"`
Title string `xorm:"varchar(250) not null" json:"title" valid:"required,runelength(1|250)" minLength:"1" maxLength:"250"`
// The description of the namespace
Description string `xorm:"longtext null" json:"description"`
OwnerID int64 `xorm:"int(11) not null INDEX" json:"-"`
@ -53,8 +54,8 @@ type Namespace struct {
web.Rights `xorm:"-" json:"-"`
}
// PseudoNamespace is a pseudo namespace used to hold shared lists
var PseudoNamespace = Namespace{
// SharedListsPseudoNamespace is a pseudo namespace used to hold shared lists
var SharedListsPseudoNamespace = Namespace{
ID: -1,
Title: "Shared Lists",
Description: "Lists of other users shared with you via teams or directly.",
@ -62,6 +63,24 @@ var PseudoNamespace = Namespace{
Updated: time.Now(),
}
// FavoritesPseudoNamespace is a pseudo namespace used to hold favorited lists and tasks
var FavoritesPseudoNamespace = Namespace{
ID: -2,
Title: "Favorites",
Description: "Favorite lists and tasks.",
Created: time.Now(),
Updated: time.Now(),
}
// SavedFiltersPseudoNamespace is a pseudo namespace used to hold saved filters
var SavedFiltersPseudoNamespace = Namespace{
ID: -3,
Title: "Filters",
Description: "Saved filters.",
Created: time.Now(),
Updated: time.Now(),
}
// TableName makes beautiful table names
func (Namespace) TableName() string {
return "namespaces"
@ -75,7 +94,12 @@ func (n *Namespace) GetSimpleByID() (err error) {
// Get the namesapce with shared lists
if n.ID == -1 {
*n = PseudoNamespace
*n = SharedListsPseudoNamespace
return
}
if n.ID == FavoritesPseudoNamespace.ID {
*n = FavoritesPseudoNamespace
return
}
@ -165,13 +189,18 @@ func (n *Namespace) ReadAll(a web.Auth, search string, page int, perPage int) (r
return nil, 0, 0, ErrGenericForbidden{}
}
// This map will hold all namespaces and their lists. The key is usually the id of the namespace.
// We're using a map here because it makes a few things like adding lists or removing pseudo namespaces easier.
namespaces := make(map[int64]*NamespaceWithLists)
//////////////////////////////
// Lists with their namespaces
doer, err := user.GetFromAuth(a)
if err != nil {
return nil, 0, 0, err
}
all := []*NamespaceWithLists{}
// Adding a 1=1 condition by default here because xorm always needs a condition and cannot handle nil conditions
var isArchivedCond builder.Cond = builder.Eq{"1": 1}
if !n.IsArchived {
@ -180,17 +209,7 @@ func (n *Namespace) ReadAll(a web.Auth, search string, page int, perPage int) (r
)
}
// Create our pseudo-namespace to hold the shared lists
// We want this one at the beginning, which is why we create it here
pseudonamespace := PseudoNamespace
pseudonamespace.Owner = doer
all = append(all, &NamespaceWithLists{
pseudonamespace,
[]*List{},
})
limit, start := getLimitFromPageIndex(page, perPage)
query := x.Select("namespaces.*").
Table("namespaces").
Join("LEFT", "team_namespaces", "namespaces.id = team_namespaces.namespace_id").
@ -205,15 +224,15 @@ func (n *Namespace) ReadAll(a web.Auth, search string, page int, perPage int) (r
if limit > 0 {
query = query.Limit(limit, start)
}
err = query.Find(&all)
err = query.Find(&namespaces)
if err != nil {
return all, 0, 0, err
return nil, 0, 0, err
}
// Make a list of namespace ids
var namespaceids []int64
var userIDs []int64
for _, nsp := range all {
for _, nsp := range namespaces {
namespaceids = append(namespaceids, nsp.ID)
userIDs = append(userIDs, nsp.OwnerID)
}
@ -222,7 +241,7 @@ func (n *Namespace) ReadAll(a web.Auth, search string, page int, perPage int) (r
userMap := make(map[int64]*user.User)
err = x.In("id", userIDs).Find(&userMap)
if err != nil {
return all, 0, 0, err
return nil, 0, 0, err
}
// Get all lists
@ -235,7 +254,34 @@ func (n *Namespace) ReadAll(a web.Auth, search string, page int, perPage int) (r
}
err = listQuery.Find(&lists)
if err != nil {
return all, 0, 0, err
return nil, 0, 0, err
}
numberOfTotalItems, err = x.
Table("namespaces").
Join("LEFT", "team_namespaces", "namespaces.id = team_namespaces.namespace_id").
Join("LEFT", "team_members", "team_members.team_id = team_namespaces.team_id").
Join("LEFT", "users_namespace", "users_namespace.namespace_id = namespaces.id").
Where("team_members.user_id = ?", doer.ID).
Or("namespaces.owner_id = ?", doer.ID).
Or("users_namespace.user_id = ?", doer.ID).
And("namespaces.is_archived = false").
GroupBy("namespaces.id").
Where("namespaces.title LIKE ?", "%"+search+"%").
Count(&NamespaceWithLists{})
if err != nil {
return nil, 0, 0, err
}
///////////////
// Shared Lists
// Create our pseudo namespace to hold the shared lists
sharedListsPseudonamespace := SharedListsPseudoNamespace
sharedListsPseudonamespace.Owner = doer
namespaces[sharedListsPseudonamespace.ID] = &NamespaceWithLists{
sharedListsPseudonamespace,
[]*List{},
}
// Get all lists individually shared with our user (not via a namespace)
@ -264,9 +310,9 @@ func (n *Namespace) ReadAll(a web.Auth, search string, page int, perPage int) (r
lists = append(lists, l)
}
// Remove the pseudonamespace if we don't have any shared lists
// Remove the sharedListsPseudonamespace if we don't have any shared lists
if len(individualLists) == 0 {
all = append(all[:0], all[1:]...)
delete(namespaces, sharedListsPseudonamespace.ID)
}
// More details for the lists
@ -275,37 +321,90 @@ func (n *Namespace) ReadAll(a web.Auth, search string, page int, perPage int) (r
return nil, 0, 0, err
}
nMap := make(map[int64]*NamespaceWithLists, len(all))
/////////////////
// Favorite lists
// Put objects in our namespace list
for _, n := range all {
// Users
n.Owner = userMap[n.OwnerID]
nMap[n.ID] = n
// Create our pseudo namespace with favorite lists
pseudoFavoriteNamespace := FavoritesPseudoNamespace
pseudoFavoriteNamespace.Owner = doer
namespaces[pseudoFavoriteNamespace.ID] = &NamespaceWithLists{
Namespace: pseudoFavoriteNamespace,
Lists: []*List{{}},
}
*namespaces[pseudoFavoriteNamespace.ID].Lists[0] = FavoritesPseudoList // Copying the list to be able to modify it later
for _, list := range lists {
nMap[list.NamespaceID].Lists = append(nMap[list.NamespaceID].Lists, list)
if list.IsFavorite {
namespaces[pseudoFavoriteNamespace.ID].Lists = append(namespaces[pseudoFavoriteNamespace.ID].Lists, list)
}
namespaces[list.NamespaceID].Lists = append(namespaces[list.NamespaceID].Lists, list)
}
numberOfTotalItems, err = x.
Table("namespaces").
Join("LEFT", "team_namespaces", "namespaces.id = team_namespaces.namespace_id").
Join("LEFT", "team_members", "team_members.team_id = team_namespaces.team_id").
Join("LEFT", "users_namespace", "users_namespace.namespace_id = namespaces.id").
Where("team_members.user_id = ?", doer.ID).
Or("namespaces.owner_id = ?", doer.ID).
Or("users_namespace.user_id = ?", doer.ID).
And("namespaces.is_archived = false").
GroupBy("namespaces.id").
Where("namespaces.title LIKE ?", "%"+search+"%").
Count(&NamespaceWithLists{})
// Check if we have any favorites or favorited lists and remove the favorites namespace from the list if not
var favoriteCount int64
favoriteCount, err = x.
Join("INNER", "list", "tasks.list_id = list.id").
Join("INNER", "namespaces", "list.namespace_id = namespaces.id").
Where(builder.And(builder.Eq{"tasks.is_favorite": true}, builder.In("namespaces.id", namespaceids))).
Count(&Task{})
if err != nil {
return all, 0, 0, err
return nil, 0, 0, err
}
// If we don't have any favorites in the favorites pseudo list, remove that pseudo list from the namespace
if favoriteCount == 0 {
for in, l := range namespaces[pseudoFavoriteNamespace.ID].Lists {
if l.ID == FavoritesPseudoList.ID {
namespaces[pseudoFavoriteNamespace.ID].Lists = append(namespaces[pseudoFavoriteNamespace.ID].Lists[:in], namespaces[pseudoFavoriteNamespace.ID].Lists[in+1:]...)
break
}
}
}
// If we don't have any favorites in the namespace, remove it
if len(namespaces[pseudoFavoriteNamespace.ID].Lists) == 0 {
delete(namespaces, pseudoFavoriteNamespace.ID)
}
/////////////////
// Saved Filters
savedFilters, err := getSavedFiltersForUser(a)
if err != nil {
return nil, 0, 0, err
}
if len(savedFilters) > 0 {
savedFiltersPseudoNamespace := SavedFiltersPseudoNamespace
savedFiltersPseudoNamespace.Owner = doer
namespaces[savedFiltersPseudoNamespace.ID] = &NamespaceWithLists{
Namespace: savedFiltersPseudoNamespace,
Lists: make([]*List, 0, len(savedFilters)),
}
for _, filter := range savedFilters {
namespaces[savedFiltersPseudoNamespace.ID].Lists = append(namespaces[savedFiltersPseudoNamespace.ID].Lists, &List{
ID: getListIDFromSavedFilterID(filter.ID),
Title: filter.Title,
Description: filter.Description,
Created: filter.Created,
Updated: filter.Updated,
Owner: doer,
})
}
}
//////////////////////
// Put it all together (and sort it)
all := make([]*NamespaceWithLists, 0, len(namespaces))
for _, n := range namespaces {
n.Owner = userMap[n.OwnerID]
all = append(all, n)
}
sort.Slice(all, func(i, j int) bool {
return all[i].ID < all[j].ID
})
return all, len(all), numberOfTotalItems, nil
}

View File

@ -27,84 +27,142 @@ import (
"time"
)
func TestTeamNamespace(t *testing.T) {
db.LoadAndAssertFixtures(t)
func TestTeamNamespace_ReadAll(t *testing.T) {
u := &user.User{ID: 1}
// Dummy team <-> namespace relation
tn := TeamNamespace{
TeamID: 1,
NamespaceID: 1,
Right: RightAdmin,
}
t.Run("normal", func(t *testing.T) {
tn := TeamNamespace{
NamespaceID: 3,
}
db.LoadAndAssertFixtures(t)
teams, _, _, err := tn.ReadAll(u, "", 1, 50)
assert.NoError(t, err)
assert.Equal(t, reflect.TypeOf(teams).Kind(), reflect.Slice)
s := reflect.ValueOf(teams)
assert.Equal(t, s.Len(), 2)
})
t.Run("nonexistant namespace", func(t *testing.T) {
tn := TeamNamespace{
NamespaceID: 9999,
}
db.LoadAndAssertFixtures(t)
_, _, _, err := tn.ReadAll(u, "", 1, 50)
assert.Error(t, err)
assert.True(t, IsErrNamespaceDoesNotExist(err))
})
t.Run("no right for namespace", func(t *testing.T) {
tn := TeamNamespace{
NamespaceID: 17,
}
db.LoadAndAssertFixtures(t)
_, _, _, err := tn.ReadAll(u, "", 1, 50)
assert.Error(t, err)
assert.True(t, IsErrNeedToHaveNamespaceReadAccess(err))
})
}
dummyuser, err := user.GetUserByID(1)
assert.NoError(t, err)
func TestTeamNamespace_Create(t *testing.T) {
u := &user.User{ID: 1}
// Test normal creation
allowed, _ := tn.CanCreate(dummyuser)
assert.True(t, allowed)
err = tn.Create(dummyuser)
assert.NoError(t, err)
t.Run("normal", func(t *testing.T) {
tn := TeamNamespace{
TeamID: 1,
NamespaceID: 1,
Right: RightAdmin,
}
db.LoadAndAssertFixtures(t)
allowed, _ := tn.CanCreate(u)
assert.True(t, allowed)
err := tn.Create(u)
assert.NoError(t, err)
db.AssertExists(t, "team_namespaces", map[string]interface{}{
"team_id": 1,
"namespace_id": 1,
"right": RightAdmin,
}, false)
})
t.Run("team already has access", func(t *testing.T) {
tn := TeamNamespace{
TeamID: 1,
NamespaceID: 3,
Right: RightRead,
}
db.LoadAndAssertFixtures(t)
err := tn.Create(u)
assert.Error(t, err)
assert.True(t, IsErrTeamAlreadyHasAccess(err))
})
t.Run("invalid team right", func(t *testing.T) {
tn := TeamNamespace{
TeamID: 1,
NamespaceID: 3,
Right: RightUnknown,
}
db.LoadAndAssertFixtures(t)
err := tn.Create(u)
assert.Error(t, err)
assert.True(t, IsErrInvalidRight(err))
})
t.Run("nonexistant team", func(t *testing.T) {
tn := TeamNamespace{
TeamID: 9999,
NamespaceID: 1,
}
db.LoadAndAssertFixtures(t)
err := tn.Create(u)
assert.Error(t, err)
assert.True(t, IsErrTeamDoesNotExist(err))
})
t.Run("nonexistant namespace", func(t *testing.T) {
tn := TeamNamespace{
TeamID: 1,
NamespaceID: 9999,
}
db.LoadAndAssertFixtures(t)
err := tn.Create(u)
assert.Error(t, err)
assert.True(t, IsErrNamespaceDoesNotExist(err))
})
}
// Test again (should fail)
err = tn.Create(dummyuser)
assert.Error(t, err)
assert.True(t, IsErrTeamAlreadyHasAccess(err))
func TestTeamNamespace_Delete(t *testing.T) {
u := &user.User{ID: 1}
// Test with invalid team right
tn2 := tn
tn2.Right = RightUnknown
err = tn2.Create(dummyuser)
assert.Error(t, err)
assert.True(t, IsErrInvalidRight(err))
// Check with inexistant team
tn3 := tn
tn3.TeamID = 324
err = tn3.Create(dummyuser)
assert.Error(t, err)
assert.True(t, IsErrTeamDoesNotExist(err))
// Check with a namespace which does not exist
tn4 := tn
tn4.NamespaceID = 423
err = tn4.Create(dummyuser)
assert.Error(t, err)
assert.True(t, IsErrNamespaceDoesNotExist(err))
// Check readall
teams, _, _, err := tn.ReadAll(dummyuser, "", 1, 50)
assert.NoError(t, err)
assert.Equal(t, reflect.TypeOf(teams).Kind(), reflect.Slice)
s := reflect.ValueOf(teams)
assert.Equal(t, s.Len(), 1)
// Check readall for a nonexistant namespace
_, _, _, err = tn4.ReadAll(dummyuser, "", 1, 50)
assert.Error(t, err)
assert.True(t, IsErrNamespaceDoesNotExist(err))
// Check with no right to read the namespace
nouser := &user.User{ID: 393}
_, _, _, err = tn.ReadAll(nouser, "", 1, 50)
assert.Error(t, err)
assert.True(t, IsErrNeedToHaveNamespaceReadAccess(err))
// Delete it
allowed, _ = tn.CanDelete(dummyuser)
assert.True(t, allowed)
err = tn.Delete()
assert.NoError(t, err)
// Try deleting with a nonexisting team
err = tn3.Delete()
assert.Error(t, err)
assert.True(t, IsErrTeamDoesNotExist(err))
// Try deleting with a nonexistant namespace
err = tn4.Delete()
assert.Error(t, err)
assert.True(t, IsErrTeamDoesNotHaveAccessToNamespace(err))
t.Run("normal", func(t *testing.T) {
tn := TeamNamespace{
TeamID: 7,
NamespaceID: 9,
}
db.LoadAndAssertFixtures(t)
allowed, _ := tn.CanDelete(u)
assert.True(t, allowed)
err := tn.Delete()
assert.NoError(t, err)
db.AssertMissing(t, "team_namespaces", map[string]interface{}{
"team_id": 7,
"namespace_id": 9,
})
})
t.Run("nonexistant team", func(t *testing.T) {
tn := TeamNamespace{
TeamID: 9999,
NamespaceID: 3,
}
db.LoadAndAssertFixtures(t)
err := tn.Delete()
assert.Error(t, err)
assert.True(t, IsErrTeamDoesNotExist(err))
})
t.Run("nonexistant namespace", func(t *testing.T) {
tn := TeamNamespace{
TeamID: 1,
NamespaceID: 9999,
}
db.LoadAndAssertFixtures(t)
err := tn.Delete()
assert.Error(t, err)
assert.True(t, IsErrTeamDoesNotHaveAccessToNamespace(err))
})
}
func TestTeamNamespace_Update(t *testing.T) {
@ -180,6 +238,13 @@ func TestTeamNamespace_Update(t *testing.T) {
if (err != nil) && tt.wantErr && !tt.errType(err) {
t.Errorf("TeamNamespace.Update() Wrong error type! Error = %v, want = %v", err, runtime.FuncForPC(reflect.ValueOf(tt.errType).Pointer()).Name())
}
if !tt.wantErr {
db.AssertExists(t, "team_namespaces", map[string]interface{}{
"team_id": tt.fields.TeamID,
"namespace_id": tt.fields.NamespaceID,
"right": tt.fields.Right,
}, false)
}
})
}
}

View File

@ -37,6 +37,10 @@ func TestNamespace_Create(t *testing.T) {
db.LoadAndAssertFixtures(t)
err := dummynamespace.Create(user1)
assert.NoError(t, err)
db.AssertExists(t, "namespaces", map[string]interface{}{
"title": "Test",
"description": "Lorem Ipsum",
}, false)
})
t.Run("no title", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
@ -81,6 +85,10 @@ func TestNamespace_Update(t *testing.T) {
}
err := n.Update()
assert.NoError(t, err)
db.AssertExists(t, "namespaces", map[string]interface{}{
"id": 1,
"title": "Lorem Ipsum",
}, false)
})
t.Run("nonexisting", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
@ -122,6 +130,9 @@ func TestNamespace_Delete(t *testing.T) {
}
err := n.Delete()
assert.NoError(t, err)
db.AssertMissing(t, "namespaces", map[string]interface{}{
"id": 1,
})
})
t.Run("nonexisting", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
@ -136,15 +147,19 @@ func TestNamespace_Delete(t *testing.T) {
func TestNamespace_ReadAll(t *testing.T) {
user1 := &user.User{ID: 1}
user11 := &user.User{ID: 11}
user12 := &user.User{ID: 12}
t.Run("normal", func(t *testing.T) {
n := &Namespace{}
nn, _, _, err := n.ReadAll(user1, "", 1, -1)
namespaces := nn.([]*NamespaceWithLists)
assert.NoError(t, err)
namespaces := nn.([]*NamespaceWithLists)
assert.NotNil(t, namespaces)
assert.Len(t, namespaces, 9) // Total of 9 including shared
assert.Equal(t, int64(-1), namespaces[0].ID) // The first one should be the one with the shared namespaces
assert.Len(t, namespaces, 11) // Total of 10 including shared, favorites and saved filters
assert.Equal(t, int64(-3), namespaces[0].ID) // The first one should be the one with shared filters
assert.Equal(t, int64(-2), namespaces[1].ID) // The second one should be the one with favorites
assert.Equal(t, int64(-1), namespaces[2].ID) // The third one should be the one with the shared namespaces
// Ensure every list and namespace are not archived
for _, namespace := range namespaces {
assert.False(t, namespace.IsArchived)
@ -161,7 +176,34 @@ func TestNamespace_ReadAll(t *testing.T) {
namespaces := nn.([]*NamespaceWithLists)
assert.NoError(t, err)
assert.NotNil(t, namespaces)
assert.Len(t, namespaces, 10) // Total of 10 including shared, one is archived
assert.Equal(t, int64(-1), namespaces[0].ID) // The first one should be the one with the shared namespaces
assert.Len(t, namespaces, 12) // Total of 12 including shared & favorites, one is archived
assert.Equal(t, int64(-3), namespaces[0].ID) // The first one should be the one with shared filters
assert.Equal(t, int64(-2), namespaces[1].ID) // The second one should be the one with favorites
assert.Equal(t, int64(-1), namespaces[2].ID) // The third one should be the one with the shared namespaces
})
t.Run("no favorites", func(t *testing.T) {
n := &Namespace{}
nn, _, _, err := n.ReadAll(user11, "", 1, -1)
namespaces := nn.([]*NamespaceWithLists)
assert.NoError(t, err)
// Assert the first namespace is not the favorites namespace
assert.NotEqual(t, FavoritesPseudoNamespace.ID, namespaces[0].ID)
})
t.Run("no favorite tasks but namespace", func(t *testing.T) {
n := &Namespace{}
nn, _, _, err := n.ReadAll(user12, "", 1, -1)
namespaces := nn.([]*NamespaceWithLists)
assert.NoError(t, err)
// Assert the first namespace is the favorites namespace and contains lists
assert.Equal(t, FavoritesPseudoNamespace.ID, namespaces[0].ID)
assert.NotEqual(t, 0, namespaces[0].Lists)
})
t.Run("no saved filters", func(t *testing.T) {
n := &Namespace{}
nn, _, _, err := n.ReadAll(user11, "", 1, -1)
namespaces := nn.([]*NamespaceWithLists)
assert.NoError(t, err)
// Assert the first namespace is not the favorites namespace
assert.NotEqual(t, SavedFiltersPseudoNamespace.ID, namespaces[0].ID)
})
}

View File

@ -31,6 +31,7 @@ func TestNamespaceUser_Create(t *testing.T) {
type fields struct {
ID int64
Username string
UserID int64
NamespaceID int64
Right Right
Created time.Time
@ -52,6 +53,7 @@ func TestNamespaceUser_Create(t *testing.T) {
name: "NamespaceUsers Create normally",
fields: fields{
Username: "user1",
UserID: 1,
NamespaceID: 2,
},
},
@ -123,6 +125,12 @@ func TestNamespaceUser_Create(t *testing.T) {
if (err != nil) && tt.wantErr && !tt.errType(err) {
t.Errorf("NamespaceUser.Create() Wrong error type! Error = %v, want = %v", err, runtime.FuncForPC(reflect.ValueOf(tt.errType).Pointer()).Name())
}
if !tt.wantErr {
db.AssertExists(t, "users_namespace", map[string]interface{}{
"user_id": tt.fields.UserID,
"namespace_id": tt.fields.NamespaceID,
}, false)
}
})
}
}
@ -228,6 +236,7 @@ func TestNamespaceUser_Update(t *testing.T) {
type fields struct {
ID int64
Username string
UserID int64
NamespaceID int64
Right Right
Created time.Time
@ -246,6 +255,7 @@ func TestNamespaceUser_Update(t *testing.T) {
fields: fields{
NamespaceID: 3,
Username: "user1",
UserID: 1,
Right: RightAdmin,
},
},
@ -254,6 +264,7 @@ func TestNamespaceUser_Update(t *testing.T) {
fields: fields{
NamespaceID: 3,
Username: "user1",
UserID: 1,
Right: RightWrite,
},
},
@ -262,6 +273,7 @@ func TestNamespaceUser_Update(t *testing.T) {
fields: fields{
NamespaceID: 3,
Username: "user1",
UserID: 1,
Right: RightRead,
},
},
@ -297,6 +309,13 @@ func TestNamespaceUser_Update(t *testing.T) {
if (err != nil) && tt.wantErr && !tt.errType(err) {
t.Errorf("NamespaceUser.Update() Wrong error type! Error = %v, want = %v", err, runtime.FuncForPC(reflect.ValueOf(tt.errType).Pointer()).Name())
}
if !tt.wantErr {
db.AssertExists(t, "users_namespace", map[string]interface{}{
"user_id": tt.fields.UserID,
"namespace_id": tt.fields.NamespaceID,
"right": tt.fields.Right,
}, false)
}
})
}
}
@ -305,6 +324,7 @@ func TestNamespaceUser_Delete(t *testing.T) {
type fields struct {
ID int64
Username string
UserID int64
NamespaceID int64
Right Right
Created time.Time
@ -340,6 +360,7 @@ func TestNamespaceUser_Delete(t *testing.T) {
name: "Try deleting normally",
fields: fields{
Username: "user1",
UserID: 1,
NamespaceID: 3,
},
},
@ -365,6 +386,12 @@ func TestNamespaceUser_Delete(t *testing.T) {
if (err != nil) && tt.wantErr && !tt.errType(err) {
t.Errorf("NamespaceUser.Delete() Wrong error type! Error = %v, want = %v", err, runtime.FuncForPC(reflect.ValueOf(tt.errType).Pointer()).Name())
}
if !tt.wantErr {
db.AssertMissing(t, "users_namespace", map[string]interface{}{
"user_id": tt.fields.UserID,
"namespace_id": tt.fields.NamespaceID,
})
}
})
}
}

182
pkg/models/saved_filters.go Normal file
View File

@ -0,0 +1,182 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2020 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 General Public License 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 General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// 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"
"time"
)
// SavedFilter represents a saved bunch of filters
type SavedFilter struct {
// The unique numeric id of this saved filter
ID int64 `xorm:"autoincr not null unique pk" json:"id" param:"filter"`
// The actual filters this filter contains
Filters *TaskCollection `xorm:"JSON not null" json:"filters"`
// The title of the filter.
Title string `xorm:"varchar(250) not null" json:"title" valid:"required,runelength(1|250)" minLength:"1" maxLength:"250"`
// The description of the filter
Description string `xorm:"longtext null" json:"description"`
OwnerID int64 `xorm:"int(11) not null INDEX" json:"-"`
// The user who owns this filter
Owner *user.User `xorm:"-" json:"owner" valid:"-"`
// A timestamp when this filter was created. You cannot change this value.
Created time.Time `xorm:"created not null" json:"created"`
// A timestamp when this filter was last updated. You cannot change this value.
Updated time.Time `xorm:"updated not null" json:"updated"`
web.CRUDable `xorm:"-" json:"-"`
web.Rights `xorm:"-" json:"-"`
}
// TableName returns a better table name for saved filters
func (s *SavedFilter) TableName() string {
return "saved_filters"
}
func (s *SavedFilter) getTaskCollection() *TaskCollection {
// We're resetting the listID to return tasks from all lists
s.Filters.ListID = 0
return s.Filters
}
// Returns the saved filter ID from a list ID. Will not check if the filter actually exists.
// If the returned ID is zero, means that it is probably invalid.
func getSavedFilterIDFromListID(listID int64) (filterID int64) {
// We get the id of the saved filter by multiplying the ListID with -1 and subtracting one
filterID = listID*-1 - 1
// FilterIDs from listIDs are always positive
if filterID < 0 {
filterID = 0
}
return
}
func getListIDFromSavedFilterID(filterID int64) (listID int64) {
listID = filterID*-1 - 1
// ListIDs from saved filters are always negative
if listID > 0 {
listID = 0
}
return
}
func getSavedFiltersForUser(auth web.Auth) (filters []*SavedFilter, err error) {
// Link shares can't view or modify saved filters, therefore we can error out right away
if _, is := auth.(*LinkSharing); is {
return nil, ErrSavedFilterNotAvailableForLinkShare{LinkShareID: auth.GetID()}
}
err = x.Where("owner_id = ?", auth.GetID()).Find(&filters)
return
}
// Create creates a new saved filter
// @Summary Creates a new saved filter
// @Description Creates a new saved filter
// @tags filter
// @Accept json
// @Produce json
// @Security JWTKeyAuth
// @Success 200 {object} models.SavedFilter "The Saved Filter"
// @Failure 403 {object} web.HTTPError "The user does not have access to that saved filter."
// @Failure 500 {object} models.Message "Internal error"
// @Router /filters [put]
func (s *SavedFilter) Create(auth web.Auth) error {
s.OwnerID = auth.GetID()
_, err := x.Insert(s)
return err
}
func getSavedFilterSimpleByID(id int64) (s *SavedFilter, err error) {
s = &SavedFilter{}
exists, err := x.
Where("id = ?", id).
Get(s)
if err != nil {
return nil, err
}
if !exists {
return nil, ErrSavedFilterDoesNotExist{SavedFilterID: id}
}
return
}
// ReadOne returns one saved filter
// @Summary Gets one saved filter
// @Description Returns a saved filter by its ID.
// @tags filter
// @Accept json
// @Produce json
// @Security JWTKeyAuth
// @Param id path int true "Filter ID"
// @Success 200 {object} models.SavedFilter "The Saved Filter"
// @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 (s *SavedFilter) ReadOne() 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.OwnerID)
s.Owner = u
return err
}
// Update updates an existing filter
// @Summary Updates a saved filter
// @Description Updates a saved filter by its ID.
// @tags filter
// @Accept json
// @Produce json
// @Security JWTKeyAuth
// @Param id path int true "Filter ID"
// @Success 200 {object} models.SavedFilter "The Saved Filter"
// @Failure 403 {object} web.HTTPError "The user does not have access to that saved filter."
// @Failure 404 {object} web.HTTPError "The saved filter does not exist."
// @Failure 500 {object} models.Message "Internal error"
// @Router /filters/{id} [post]
func (s *SavedFilter) Update() error {
_, err := x.
Where("id = ?", s.ID).
Cols(
"title",
"description",
"filters",
).
Update(s)
return err
}
// Delete removes a saved filter
// @Summary Removes a saved filter
// @Description Removes a saved filter by its ID.
// @tags filter
// @Accept json
// @Produce json
// @Security JWTKeyAuth
// @Param id path int true "Filter ID"
// @Success 200 {object} models.SavedFilter "The Saved Filter"
// @Failure 403 {object} web.HTTPError "The user does not have access to that saved filter."
// @Failure 404 {object} web.HTTPError "The saved filter does not exist."
// @Failure 500 {object} models.Message "Internal error"
// @Router /filters/{id} [delete]
func (s *SavedFilter) Delete() error {
_, err := x.Where("id = ?", s.ID).Delete(s)
return err
}

View File

@ -0,0 +1,68 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2020 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 General Public License 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 General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package models
import "code.vikunja.io/web"
// CanRead checks if a user has the right to read a saved filter
func (s *SavedFilter) CanRead(auth web.Auth) (bool, int, error) {
can, err := s.canDoFilter(auth)
return can, int(RightAdmin), err
}
// CanDelete checks if a user has the right to delete a saved filter
func (s *SavedFilter) CanDelete(auth web.Auth) (bool, error) {
return s.canDoFilter(auth)
}
// CanUpdate checks if a user has the right to update a saved filter
func (s *SavedFilter) CanUpdate(auth web.Auth) (bool, error) {
// A normal check would replace the passed struct which in our case would override the values we want to update.
sf := &SavedFilter{ID: s.ID}
return sf.canDoFilter(auth)
}
// CanCreate checks if a user has the right to update a saved filter
func (s *SavedFilter) CanCreate(auth web.Auth) (bool, error) {
if _, is := auth.(*LinkSharing); is {
return false, nil
}
return true, nil
}
// Helper function to check saved filter rights sind they all have the same logic
func (s *SavedFilter) canDoFilter(auth web.Auth) (can bool, err error) {
// Link shares can't view or modify saved filters, therefore we can error out right away
if _, is := auth.(*LinkSharing); is {
return false, ErrSavedFilterNotAvailableForLinkShare{LinkShareID: auth.GetID(), SavedFilterID: s.ID}
}
sf, err := getSavedFilterSimpleByID(s.ID)
if err != nil {
return false, err
}
// Only owners are allowed to do something with a saved filter
if sf.OwnerID != auth.GetID() {
return false, nil
}
*s = *sf
return true, nil
}

View File

@ -0,0 +1,257 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2020 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 General Public License 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 General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package models
import (
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/user"
"github.com/stretchr/testify/assert"
"testing"
"xorm.io/xorm/schemas"
)
func TestSavedFilter_getListIDFromFilter(t *testing.T) {
t.Run("normal", func(t *testing.T) {
assert.Equal(t, int64(-2), getListIDFromSavedFilterID(1))
})
t.Run("invalid", func(t *testing.T) {
assert.Equal(t, int64(0), getListIDFromSavedFilterID(-1))
})
}
func TestSavedFilter_getFilterIDFromListID(t *testing.T) {
t.Run("normal", func(t *testing.T) {
assert.Equal(t, int64(1), getSavedFilterIDFromListID(-2))
})
t.Run("invalid", func(t *testing.T) {
assert.Equal(t, int64(0), getSavedFilterIDFromListID(2))
})
}
func TestSavedFilter_Create(t *testing.T) {
db.LoadAndAssertFixtures(t)
sf := &SavedFilter{
Title: "test",
Description: "Lorem Ipsum dolor sit amet",
Filters: &TaskCollection{}, // Empty filter
}
u := &user.User{ID: 1}
err := sf.Create(u)
assert.NoError(t, err)
assert.Equal(t, u.ID, sf.OwnerID)
vals := map[string]interface{}{
"title": "'test'",
"description": "'Lorem Ipsum dolor sit amet'",
"filters": "'{\"sort_by\":null,\"order_by\":null,\"filter_by\":null,\"filter_value\":null,\"filter_comparator\":null,\"filter_concat\":\"\",\"filter_include_nulls\":false}'",
"owner_id": 1,
}
// Postgres can't compare json values directly, see https://dba.stackexchange.com/a/106290/210721
if x.Dialect().URI().DBType == schemas.POSTGRES {
vals["filters::jsonb"] = vals["filters"].(string) + "::jsonb"
delete(vals, "filters")
}
db.AssertExists(t, "saved_filters", vals, true)
}
func TestSavedFilter_ReadOne(t *testing.T) {
user1 := &user.User{ID: 1}
db.LoadAndAssertFixtures(t)
sf := &SavedFilter{
ID: 1,
}
// canRead pre-populates the struct
_, _, err := sf.CanRead(user1)
assert.NoError(t, err)
err = sf.ReadOne()
assert.NoError(t, err)
assert.NotNil(t, sf.Owner)
}
func TestSavedFilter_Update(t *testing.T) {
db.LoadAndAssertFixtures(t)
sf := &SavedFilter{
ID: 1,
Title: "NewTitle",
Description: "", // Explicitly reset the description
Filters: &TaskCollection{},
}
err := sf.Update()
assert.NoError(t, err)
db.AssertExists(t, "saved_filters", map[string]interface{}{
"id": 1,
"title": "NewTitle",
"description": "",
}, false)
}
func TestSavedFilter_Delete(t *testing.T) {
db.LoadAndAssertFixtures(t)
sf := &SavedFilter{
ID: 1,
}
err := sf.Delete()
assert.NoError(t, err)
db.AssertMissing(t, "saved_filters", map[string]interface{}{
"id": 1,
})
}
func TestSavedFilter_Rights(t *testing.T) {
user1 := &user.User{ID: 1}
user2 := &user.User{ID: 2}
ls := &LinkSharing{ID: 1}
t.Run("create", func(t *testing.T) {
// Should always be true
db.LoadAndAssertFixtures(t)
can, err := (&SavedFilter{}).CanCreate(user1)
assert.NoError(t, err)
assert.True(t, can)
})
t.Run("read", func(t *testing.T) {
t.Run("owner", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
sf := &SavedFilter{
ID: 1,
Title: "Lorem",
}
can, max, err := sf.CanRead(user1)
assert.NoError(t, err)
assert.Equal(t, int(RightAdmin), max)
assert.True(t, can)
})
t.Run("not owner", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
sf := &SavedFilter{
ID: 1,
Title: "Lorem",
}
can, _, err := sf.CanRead(user2)
assert.NoError(t, err)
assert.False(t, can)
})
t.Run("nonexisting", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
sf := &SavedFilter{
ID: 9999,
Title: "Lorem",
}
can, _, err := sf.CanRead(user1)
assert.Error(t, err)
assert.True(t, IsErrSavedFilterDoesNotExist(err))
assert.False(t, can)
})
t.Run("link share", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
sf := &SavedFilter{
ID: 1,
Title: "Lorem",
}
can, _, err := sf.CanRead(ls)
assert.Error(t, err)
assert.True(t, IsErrSavedFilterNotAvailableForLinkShare(err))
assert.False(t, can)
})
})
t.Run("update", func(t *testing.T) {
t.Run("owner", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
sf := &SavedFilter{
ID: 1,
Title: "Lorem",
}
can, err := sf.CanUpdate(user1)
assert.NoError(t, err)
assert.True(t, can)
})
t.Run("not owner", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
sf := &SavedFilter{
ID: 1,
Title: "Lorem",
}
can, err := sf.CanUpdate(user2)
assert.NoError(t, err)
assert.False(t, can)
})
t.Run("nonexisting", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
sf := &SavedFilter{
ID: 9999,
Title: "Lorem",
}
can, err := sf.CanUpdate(user1)
assert.Error(t, err)
assert.True(t, IsErrSavedFilterDoesNotExist(err))
assert.False(t, can)
})
t.Run("link share", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
sf := &SavedFilter{
ID: 1,
Title: "Lorem",
}
can, err := sf.CanUpdate(ls)
assert.Error(t, err)
assert.True(t, IsErrSavedFilterNotAvailableForLinkShare(err))
assert.False(t, can)
})
})
t.Run("delete", func(t *testing.T) {
t.Run("owner", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
sf := &SavedFilter{
ID: 1,
}
can, err := sf.CanDelete(user1)
assert.NoError(t, err)
assert.True(t, can)
})
t.Run("not owner", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
sf := &SavedFilter{
ID: 1,
}
can, err := sf.CanDelete(user2)
assert.NoError(t, err)
assert.False(t, can)
})
t.Run("nonexisting", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
sf := &SavedFilter{
ID: 9999,
Title: "Lorem",
}
can, err := sf.CanDelete(user1)
assert.Error(t, err)
assert.True(t, IsErrSavedFilterDoesNotExist(err))
assert.False(t, can)
})
t.Run("link share", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
sf := &SavedFilter{
ID: 1,
Title: "Lorem",
}
can, err := sf.CanDelete(ls)
assert.Error(t, err)
assert.True(t, IsErrSavedFilterNotAvailableForLinkShare(err))
assert.False(t, can)
})
})
}

View File

@ -24,29 +24,29 @@ import (
// TaskCollection is a struct used to hold filter details and not clutter the Task struct with information not related to actual tasks.
type TaskCollection struct {
ListID int64 `param:"list"`
Lists []*List
ListID int64 `param:"list" json:"-"`
Lists []*List `json:"-"`
// The query parameter to sort by. This is for ex. done, priority, etc.
SortBy []string `query:"sort_by"`
SortByArr []string `query:"sort_by[]"`
SortBy []string `query:"sort_by" json:"sort_by"`
SortByArr []string `query:"sort_by[]" json:"-"`
// The query parameter to order the items by. This can be either asc or desc, with asc being the default.
OrderBy []string `query:"order_by"`
OrderByArr []string `query:"order_by[]"`
OrderBy []string `query:"order_by" json:"order_by"`
OrderByArr []string `query:"order_by[]" json:"-"`
// The field name of the field to filter by
FilterBy []string `query:"filter_by"`
FilterByArr []string `query:"filter_by[]"`
FilterBy []string `query:"filter_by" json:"filter_by"`
FilterByArr []string `query:"filter_by[]" json:"-"`
// The value of the field name to filter by
FilterValue []string `query:"filter_value"`
FilterValueArr []string `query:"filter_value[]"`
FilterValue []string `query:"filter_value" json:"filter_value"`
FilterValueArr []string `query:"filter_value[]" json:"-"`
// The comparator for field and value
FilterComparator []string `query:"filter_comparator"`
FilterComparatorArr []string `query:"filter_comparator[]"`
FilterComparator []string `query:"filter_comparator" json:"filter_comparator"`
FilterComparatorArr []string `query:"filter_comparator[]" json:"-"`
// The way all filter conditions are concatenated together, can be either "and" or "or".,
FilterConcat string `query:"filter_concat"`
FilterConcat string `query:"filter_concat" json:"filter_concat"`
// If set to true, the result will also include null values
FilterIncludeNulls bool `query:"filter_include_nulls"`
FilterIncludeNulls bool `query:"filter_include_nulls" json:"filter_include_nulls"`
web.CRUDable `xorm:"-" json:"-"`
web.Rights `xorm:"-" json:"-"`
@ -102,6 +102,17 @@ func validateTaskField(fieldName string) error {
// @Router /lists/{listID}/tasks [get]
func (tf *TaskCollection) ReadAll(a web.Auth, search string, page int, perPage int) (result interface{}, resultCount int, totalItems int64, err error) {
// If the list id is < -1 this means we're dealing with a saved filter - in that case we get and populate the filter
// -1 is the favorites list which works as intended
if tf.ListID < -1 {
s, err := getSavedFilterSimpleByID(getSavedFilterIDFromListID(tf.ListID))
if err != nil {
return nil, 0, 0, err
}
return s.getTaskCollection().ReadAll(a, search, page, perPage)
}
if len(tf.SortByArr) > 0 {
tf.SortBy = append(tf.SortBy, tf.SortByArr...)
}
@ -150,7 +161,7 @@ func (tf *TaskCollection) ReadAll(a web.Auth, search string, page int, perPage i
if err != nil {
return nil, 0, 0, err
}
return getTasksForLists([]*List{list}, taskopts)
return getTasksForLists([]*List{list}, a, taskopts)
}
// If the list ID is not set, we get all tasks for the user.
@ -176,5 +187,5 @@ func (tf *TaskCollection) ReadAll(a web.Auth, search string, page int, perPage i
tf.Lists = []*List{{ID: tf.ListID}}
}
return getTasksForLists(tf.Lists, taskopts)
return getTasksForLists(tf.Lists, a, taskopts)
}

View File

@ -66,6 +66,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
CreatedBy: user1,
ListID: 1,
BucketID: 1,
IsFavorite: true,
Labels: []*Label{
{
ID: 4,
@ -288,6 +289,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
CreatedByID: 6,
CreatedBy: user6,
ListID: 6,
IsFavorite: true,
RelatedTasks: map[RelationKind][]*Task{},
BucketID: 6,
Created: time.Unix(1543626724, 0).In(loc),
@ -484,6 +486,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
Index: 1,
CreatedByID: 1,
ListID: 1,
IsFavorite: true,
Created: time.Unix(1543626724, 0).In(loc),
Updated: time.Unix(1543626724, 0).In(loc),
BucketID: 1,
@ -836,6 +839,18 @@ func TestTaskCollection_ReadAll(t *testing.T) {
},
wantErr: false,
},
{
name: "favorited tasks",
args: defaultArgs,
fields: fields{
ListID: FavoritesPseudoList.ID,
},
want: []*Task{
task1,
task15,
// Task 34 is also a favorite, but on a list user 1 has no access to.
},
},
}
for _, tt := range tests {

View File

@ -35,6 +35,12 @@ func TestTaskComment_Create(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, "test", tc.Comment)
assert.Equal(t, int64(1), tc.Author.ID)
db.AssertExists(t, "task_comments", map[string]interface{}{
"id": tc.ID,
"author_id": u.ID,
"comment": "test",
"task_id": 1,
}, false)
})
t.Run("nonexisting task", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
@ -54,6 +60,9 @@ func TestTaskComment_Delete(t *testing.T) {
tc := &TaskComment{ID: 1}
err := tc.Delete()
assert.NoError(t, err)
db.AssertMissing(t, "task_comments", map[string]interface{}{
"id": 1,
})
})
t.Run("nonexisting comment", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
@ -73,6 +82,10 @@ func TestTaskComment_Update(t *testing.T) {
}
err := tc.Update()
assert.NoError(t, err)
db.AssertExists(t, "task_comments", map[string]interface{}{
"id": 1,
"comment": "testing",
}, false)
})
t.Run("nonexisting comment", func(t *testing.T) {
db.LoadAndAssertFixtures(t)

View File

@ -35,6 +35,12 @@ func TestTaskRelation_Create(t *testing.T) {
}
err := rel.Create(&user.User{ID: 1})
assert.NoError(t, err)
db.AssertExists(t, "task_relations", map[string]interface{}{
"task_id": 1,
"other_task_id": 2,
"relation_kind": RelationKindSubtask,
"created_by_id": 1,
}, false)
})
t.Run("Two Tasks In Different Lists", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
@ -46,6 +52,12 @@ func TestTaskRelation_Create(t *testing.T) {
}
err := rel.Create(&user.User{ID: 1})
assert.NoError(t, err)
db.AssertExists(t, "task_relations", map[string]interface{}{
"task_id": 1,
"other_task_id": 13,
"relation_kind": RelationKindSubtask,
"created_by_id": 1,
}, false)
})
t.Run("Already Existing", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
@ -83,6 +95,11 @@ func TestTaskRelation_Delete(t *testing.T) {
}
err := rel.Delete()
assert.NoError(t, err)
db.AssertMissing(t, "task_relations", map[string]interface{}{
"task_id": 1,
"other_task_id": 29,
"relation_kind": RelationKindSubtask,
})
})
t.Run("Not existing", func(t *testing.T) {
db.LoadAndAssertFixtures(t)

View File

@ -37,7 +37,7 @@ type Task struct {
// The unique, numeric id of this task.
ID int64 `xorm:"int(11) autoincr not null unique pk" json:"id" param:"listtask"`
// The task text. This is what you'll see in the list.
Title string `xorm:"varchar(250) not null" json:"title" valid:"runelength(1|250)" minLength:"3" maxLength:"250"`
Title string `xorm:"varchar(250) not null" json:"title" valid:"runelength(1|250)" minLength:"1" maxLength:"250"`
// The task description.
Description string `xorm:"longtext null" json:"description"`
// Whether a task is done or not.
@ -84,6 +84,9 @@ type Task struct {
// All attachments this task has
Attachments []*TaskAttachment `xorm:"-" json:"attachments"`
// True if a task is a favorite task. Favorite tasks show up in a separate "Important" list
IsFavorite bool `xorm:"default false" json:"is_favorite"`
// A timestamp when this task was created. You cannot change this value.
Created time.Time `xorm:"created not null" json:"created"`
// A timestamp when this task was last updated. You cannot change this value.
@ -165,7 +168,7 @@ func (t *Task) ReadAll(a web.Auth, search string, page int, perPage int) (result
return nil, 0, 0, nil
}
func getRawTasksForLists(lists []*List, opts *taskOptions) (tasks []*Task, resultCount int, totalItems int64, err error) {
func getRawTasksForLists(lists []*List, a web.Auth, opts *taskOptions) (tasks []*Task, resultCount int, totalItems int64, err error) {
// If the user does not have any lists, don't try to get any tasks
if len(lists) == 0 {
@ -179,7 +182,11 @@ func getRawTasksForLists(lists []*List, opts *taskOptions) (tasks []*Task, resul
// Get all list IDs and get the tasks
var listIDs []int64
var hasFavoriteLists bool
for _, l := range lists {
if l.ID == FavoritesPseudoList.ID {
hasFavoriteLists = true
}
listIDs = append(listIDs, l.ID)
}
@ -274,11 +281,34 @@ func getRawTasksForLists(lists []*List, opts *taskOptions) (tasks []*Task, resul
}
}
var listIDCond builder.Cond
var listCond builder.Cond
if len(listIDs) > 0 {
query = query.In("list_id", listIDs)
queryCount = queryCount.In("list_id", listIDs)
listIDCond = builder.In("list_id", listIDs)
listCond = listIDCond
}
if hasFavoriteLists {
// Make sure users can only see their favorites
userLists, _, _, err := getRawListsForUser(&listOptions{
user: &user.User{ID: a.GetID()},
page: -1,
})
if err != nil {
return nil, 0, 0, err
}
userListIDs := make([]int64, len(userLists))
for _, l := range userLists {
userListIDs = append(userListIDs, l.ID)
}
listCond = builder.Or(listIDCond, builder.And(builder.Eq{"is_favorite": true}, builder.In("list_id", userListIDs)))
}
query = query.Where(listCond)
queryCount = queryCount.Where(listCond)
if len(filters) > 0 {
if opts.filterConcat == filterConcatOr {
query = query.Where(builder.Or(filters...))
@ -311,9 +341,9 @@ func getRawTasksForLists(lists []*List, opts *taskOptions) (tasks []*Task, resul
return tasks, len(tasks), totalItems, nil
}
func getTasksForLists(lists []*List, opts *taskOptions) (tasks []*Task, resultCount int, totalItems int64, err error) {
func getTasksForLists(lists []*List, a web.Auth, opts *taskOptions) (tasks []*Task, resultCount int, totalItems int64, err error) {
tasks, resultCount, totalItems, err = getRawTasksForLists(lists, opts)
tasks, resultCount, totalItems, err = getRawTasksForLists(lists, a, opts)
if err != nil {
return nil, 0, 0, err
}
@ -545,6 +575,32 @@ func checkBucketAndTaskBelongToSameList(s *xorm.Session, fullTask *Task, bucketI
return
}
// Checks if adding a new task would exceed the bucket limit
func checkBucketLimit(s *xorm.Session, t *Task, bucket *Bucket) (err error) {
// We need the bucket to check if it has more tasks than the limit allows
if bucket == nil {
bucket, err = getBucketByID(s, t.BucketID)
if err != nil {
return err
}
}
// Check the limit
if bucket.Limit > 0 {
taskCount, err := s.
Where("bucket_id = ?", bucket.ID).
Count(&Task{})
if err != nil {
return err
}
if taskCount >= bucket.Limit {
return ErrBucketLimitExceeded{TaskID: t.ID, BucketID: bucket.ID, Limit: bucket.Limit}
}
}
return nil
}
// Create is the implementation to create a list task
// @Summary Create a task
// @Description Inserts a task into a list.
@ -608,12 +664,18 @@ func createTask(s *xorm.Session, t *Task, a web.Auth, updateAssignees bool) (err
}
// Get the default bucket and move the task there
var bucket *Bucket
if t.BucketID == 0 {
defaultBucket, err := getDefaultBucket(s, t.ListID)
bucket, err = getDefaultBucket(s, t.ListID)
if err != nil {
return err
}
t.BucketID = defaultBucket.ID
t.BucketID = bucket.ID
}
// Bucket Limit
if err := checkBucketLimit(s, t, bucket); err != nil {
return err
}
// Get the index for this task
@ -728,17 +790,22 @@ func (t *Task) Update() (err error) {
"bucket_id",
"position",
"repeat_from_current_date",
"is_favorite",
}
// If the task is being moved between lists, make sure to move the bucket + index as well
if t.ListID != 0 && ot.ListID != t.ListID {
b, err := getDefaultBucket(s, t.ListID)
// Make sure we have a bucket
var bucket *Bucket
if t.BucketID == 0 || (t.ListID != 0 && ot.ListID != t.ListID) {
bucket, err = getDefaultBucket(s, t.ListID)
if err != nil {
_ = s.Rollback()
return err
}
t.BucketID = b.ID
t.BucketID = bucket.ID
}
// If the task is being moved between lists, make sure to move the bucket + index as well
if t.ListID != 0 && ot.ListID != t.ListID {
latestTask := &Task{}
_, err = s.Where("list_id = ?", t.ListID).OrderBy("id desc").Get(latestTask)
if err != nil {
@ -750,6 +817,15 @@ func (t *Task) Update() (err error) {
colsToUpdate = append(colsToUpdate, "index")
}
// Check the bucket limit
// Only check the bucket limit if the task is being moved between buckets, allow reordering the task within a bucket
if t.BucketID != ot.BucketID {
if err := checkBucketLimit(s, t, bucket); err != nil {
_ = s.Rollback()
return err
}
}
// Update the labels
//
// Maybe FIXME:
@ -823,6 +899,10 @@ func (t *Task) Update() (err error) {
if !t.RepeatFromCurrentDate {
ot.RepeatFromCurrentDate = false
}
// Is Favorite
if !t.IsFavorite {
ot.IsFavorite = false
}
_, err = s.ID(t.ID).
Cols(colsToUpdate...).

View File

@ -49,6 +49,14 @@ func TestTask_Create(t *testing.T) {
assert.Equal(t, int64(18), task.Index)
// Assert moving it into the default bucket
assert.Equal(t, int64(1), task.BucketID)
db.AssertExists(t, "tasks", map[string]interface{}{
"id": task.ID,
"title": "Lorem",
"description": "Lorem Ipsum Dolor",
"list_id": 1,
"created_by_id": 1,
"bucket_id": 1,
}, false)
})
t.Run("empty title", func(t *testing.T) {
@ -85,6 +93,18 @@ func TestTask_Create(t *testing.T) {
assert.Error(t, err)
assert.True(t, user.IsErrUserDoesNotExist(err))
})
t.Run("full bucket", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
task := &Task{
Title: "Lorem",
Description: "Lorem Ipsum Dolor",
ListID: 1,
BucketID: 2, // Bucket 2 already has 3 tasks and a limit of 3
}
err := task.Create(usr)
assert.Error(t, err)
assert.True(t, IsErrBucketLimitExceeded(err))
})
}
func TestTask_Update(t *testing.T) {
@ -98,6 +118,12 @@ func TestTask_Update(t *testing.T) {
}
err := task.Update()
assert.NoError(t, err)
db.AssertExists(t, "tasks", map[string]interface{}{
"id": 1,
"title": "test10000",
"description": "Lorem Ipsum Dolor",
"list_id": 1,
}, false)
})
t.Run("nonexistant task", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
@ -111,6 +137,32 @@ func TestTask_Update(t *testing.T) {
assert.Error(t, err)
assert.True(t, IsErrTaskDoesNotExist(err))
})
t.Run("full bucket", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
task := &Task{
ID: 1,
Title: "test10000",
Description: "Lorem Ipsum Dolor",
ListID: 1,
BucketID: 2, // Bucket 2 already has 3 tasks and a limit of 3
}
err := task.Update()
assert.Error(t, err)
assert.True(t, IsErrBucketLimitExceeded(err))
})
t.Run("full bucket but not changing the bucket", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
task := &Task{
ID: 4,
Title: "test10000",
Description: "Lorem Ipsum Dolor",
Position: 10,
ListID: 1,
BucketID: 2, // Bucket 2 already has 3 tasks and a limit of 3
}
err := task.Update()
assert.NoError(t, err)
})
}
func TestTask_Delete(t *testing.T) {
@ -121,6 +173,9 @@ func TestTask_Delete(t *testing.T) {
}
err := task.Delete()
assert.NoError(t, err)
db.AssertMissing(t, "tasks", map[string]interface{}{
"id": 1,
})
})
}

View File

@ -37,6 +37,11 @@ func TestTeamMember_Create(t *testing.T) {
}
err := tm.Create(doer)
assert.NoError(t, err)
db.AssertExists(t, "team_members", map[string]interface{}{
"id": tm.ID,
"team_id": 1,
"user_id": 3,
}, false)
})
t.Run("already existing", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
@ -79,6 +84,10 @@ func TestTeamMember_Delete(t *testing.T) {
}
err := tm.Delete()
assert.NoError(t, err)
db.AssertMissing(t, "team_members", map[string]interface{}{
"team_id": 1,
"user_id": 1,
})
})
}
@ -92,7 +101,12 @@ func TestTeamMember_Update(t *testing.T) {
}
err := tm.Update()
assert.NoError(t, err)
assert.False(t, tm.Admin)
assert.False(t, tm.Admin) // Since this endpoint toggles the right, we should get a false for admin back.
db.AssertExists(t, "team_members", map[string]interface{}{
"team_id": 1,
"user_id": 1,
"admin": false,
}, false)
})
// This should have the same result as the normal run as the update function
// should ignore what was passed.
@ -106,5 +120,10 @@ func TestTeamMember_Update(t *testing.T) {
err := tm.Update()
assert.NoError(t, err)
assert.False(t, tm.Admin)
db.AssertExists(t, "team_members", map[string]interface{}{
"team_id": 1,
"user_id": 1,
"admin": false,
}, false)
})
}

View File

@ -29,7 +29,7 @@ type Team struct {
// The unique, numeric id of this team.
ID int64 `xorm:"int(11) autoincr not null unique pk" json:"id" param:"team"`
// The name of this team.
Name string `xorm:"varchar(250) not null" json:"name" valid:"required,runelength(1|250)" minLength:"5" maxLength:"250"`
Name string `xorm:"varchar(250) not null" json:"name" valid:"required,runelength(1|250)" minLength:"1" maxLength:"250"`
// The team's description.
Description string `xorm:"longtext null" json:"description"`
CreatedByID int64 `xorm:"int(11) not null INDEX" json:"-"`

View File

@ -37,6 +37,11 @@ func TestTeam_Create(t *testing.T) {
}
err := team.Create(doer)
assert.NoError(t, err)
db.AssertExists(t, "teams", map[string]interface{}{
"id": team.ID,
"name": "Testteam293",
"description": "Lorem Ispum",
}, false)
})
t.Run("empty name", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
@ -95,6 +100,10 @@ func TestTeam_Update(t *testing.T) {
}
err := team.Update()
assert.NoError(t, err)
db.AssertExists(t, "teams", map[string]interface{}{
"id": team.ID,
"name": "SomethingNew",
}, false)
})
t.Run("empty name", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
@ -126,6 +135,9 @@ func TestTeam_Delete(t *testing.T) {
}
err := team.Delete()
assert.NoError(t, err)
db.AssertMissing(t, "teams", map[string]interface{}{
"id": 1,
})
})
}

View File

@ -58,6 +58,7 @@ func SetupTests() {
"users_list",
"users_namespace",
"buckets",
"saved_filters",
)
if err != nil {
log.Fatal(err)

View File

@ -37,6 +37,7 @@ type vikunjaInfos struct {
EnabledBackgroundProviders []string `json:"enabled_background_providers"`
TotpEnabled bool `json:"totp_enabled"`
Legal legalInfo `json:"legal"`
CaldavEnabled bool `json:"caldav_enabled"`
}
type legalInfo struct {
@ -61,6 +62,7 @@ func Info(c echo.Context) error {
RegistrationEnabled: config.ServiceEnableRegistration.GetBool(),
TaskAttachmentsEnabled: config.ServiceEnableTaskAttachments.GetBool(),
TotpEnabled: config.ServiceEnableTotp.GetBool(),
CaldavEnabled: config.ServiceEnableCaldav.GetBool(),
Legal: legalInfo{
ImprintURL: config.LegalImprintURL.GetString(),
PrivacyPolicyURL: config.LegalPrivacyURL.GetString(),

View File

@ -69,7 +69,7 @@ func getNamespace(c echo.Context) (namespace *models.Namespace, err error) {
}
if namespaceID == -1 {
namespace = &models.PseudoNamespace
namespace = &models.SharedListsPseudoNamespace
return
}

View File

@ -431,6 +431,16 @@ func registerAPIRoutes(a *echo.Group) {
a.DELETE("/lists/:list/users/:user", listUserHandler.DeleteWeb)
a.POST("/lists/:list/users/:user", listUserHandler.UpdateWeb)
savedFiltersHandler := &handler.WebHandler{
EmptyStruct: func() handler.CObject {
return &models.SavedFilter{}
},
}
a.GET("/filters/:filter", savedFiltersHandler.ReadOneWeb)
a.PUT("/filters", savedFiltersHandler.CreateWeb)
a.DELETE("/filters/:filter", savedFiltersHandler.DeleteWeb)
a.POST("/filters/:filter", savedFiltersHandler.UpdateWeb)
namespaceHandler := &handler.WebHandler{
EmptyStruct: func() handler.CObject {
return &models.Namespace{}

View File

@ -172,6 +172,201 @@ var doc = `{
}
}
},
"/filters": {
"put": {
"security": [
{
"JWTKeyAuth": []
}
],
"description": "Creates a new saved filter",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"filter"
],
"summary": "Creates a new saved filter",
"responses": {
"200": {
"description": "The Saved Filter",
"schema": {
"$ref": "#/definitions/models.SavedFilter"
}
},
"403": {
"description": "The user does not have access to that saved filter.",
"schema": {
"$ref": "#/definitions/web.HTTPError"
}
},
"500": {
"description": "Internal error",
"schema": {
"$ref": "#/definitions/models.Message"
}
}
}
}
},
"/filters/{id}": {
"get": {
"security": [
{
"JWTKeyAuth": []
}
],
"description": "Returns a saved filter by its ID.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"filter"
],
"summary": "Gets one saved filter",
"parameters": [
{
"type": "integer",
"description": "Filter ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "The Saved Filter",
"schema": {
"$ref": "#/definitions/models.SavedFilter"
}
},
"403": {
"description": "The user does not have access to that saved filter.",
"schema": {
"$ref": "#/definitions/web.HTTPError"
}
},
"500": {
"description": "Internal error",
"schema": {
"$ref": "#/definitions/models.Message"
}
}
}
},
"post": {
"security": [
{
"JWTKeyAuth": []
}
],
"description": "Updates a saved filter by its ID.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"filter"
],
"summary": "Updates a saved filter",
"parameters": [
{
"type": "integer",
"description": "Filter ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "The Saved Filter",
"schema": {
"$ref": "#/definitions/models.SavedFilter"
}
},
"403": {
"description": "The user does not have access to that saved filter.",
"schema": {
"$ref": "#/definitions/web.HTTPError"
}
},
"404": {
"description": "The saved filter does not exist.",
"schema": {
"$ref": "#/definitions/web.HTTPError"
}
},
"500": {
"description": "Internal error",
"schema": {
"$ref": "#/definitions/models.Message"
}
}
}
},
"delete": {
"security": [
{
"JWTKeyAuth": []
}
],
"description": "Removes a saved filter by its ID.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"filter"
],
"summary": "Removes a saved filter",
"parameters": [
{
"type": "integer",
"description": "Filter ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "The Saved Filter",
"schema": {
"$ref": "#/definitions/models.SavedFilter"
}
},
"403": {
"description": "The user does not have access to that saved filter.",
"schema": {
"$ref": "#/definitions/web.HTTPError"
}
},
"404": {
"description": "The saved filter does not exist.",
"schema": {
"$ref": "#/definitions/web.HTTPError"
}
},
"500": {
"description": "Internal error",
"schema": {
"$ref": "#/definitions/models.Message"
}
}
}
}
},
"/info": {
"get": {
"description": "Returns the version, frontendurl, motd and various settings of Vikunja",
@ -6157,7 +6352,6 @@ var doc = `{
}
},
"definitions": {
"code.vikunja.io.web.HTTPError": {"type": "object","properties": {"code": {"type": "integer"},"message": {"type": "string"}}},
"afero.File": {
"type": "object"
},
@ -6237,6 +6431,10 @@ var doc = `{
"description": "The unique, numeric id of this bucket.",
"type": "integer"
},
"limit": {
"description": "How many tasks can be at the same time on this board max",
"type": "integer"
},
"list_id": {
"description": "The list this bucket belongs to.",
"type": "integer"
@ -6338,6 +6536,10 @@ var doc = `{
"description": "The task index, calculated per list",
"type": "integer"
},
"is_favorite": {
"description": "True if a task is a favorite task. Favorite tasks show up in a separate \"Important\" list",
"type": "boolean"
},
"labels": {
"description": "An array of labels which are associated with this task.",
"type": "array",
@ -6396,7 +6598,7 @@ var doc = `{
"description": "The task text. This is what you'll see in the list.",
"type": "string",
"maxLength": 250,
"minLength": 3
"minLength": 1
},
"updated": {
"description": "A timestamp when this task was last updated. You cannot change this value.",
@ -6433,7 +6635,7 @@ var doc = `{
"description": "The title of the lable. You'll see this one on tasks associated with it.",
"type": "string",
"maxLength": 250,
"minLength": 3
"minLength": 1
},
"updated": {
"description": "A timestamp when this label was last updated. You cannot change this value.",
@ -6538,6 +6740,10 @@ var doc = `{
"description": "Whether or not a list is archived.",
"type": "boolean"
},
"is_favorite": {
"description": "True if a list is a favorite. Favorite lists show up in a separate namespace.",
"type": "boolean"
},
"namespace_id": {
"type": "integer"
},
@ -6550,7 +6756,7 @@ var doc = `{
"description": "The title of the list. You'll see this in the namespace overview.",
"type": "string",
"maxLength": 250,
"minLength": 3
"minLength": 1
},
"updated": {
"description": "A timestamp when this list was last updated. You cannot change this value.",
@ -6641,7 +6847,7 @@ var doc = `{
"description": "The name of this namespace.",
"type": "string",
"maxLength": 250,
"minLength": 5
"minLength": 1
},
"updated": {
"description": "A timestamp when this namespace was last updated. You cannot change this value.",
@ -6715,7 +6921,7 @@ var doc = `{
"description": "The name of this namespace.",
"type": "string",
"maxLength": 250,
"minLength": 5
"minLength": 1
},
"updated": {
"description": "A timestamp when this namespace was last updated. You cannot change this value.",
@ -6732,6 +6938,43 @@ var doc = `{
}
}
},
"models.SavedFilter": {
"type": "object",
"properties": {
"created": {
"description": "A timestamp when this filter was created. You cannot change this value.",
"type": "string"
},
"description": {
"description": "The description of the filter",
"type": "string"
},
"filters": {
"description": "The actual filters this filter contains",
"type": "object",
"$ref": "#/definitions/models.TaskCollection"
},
"id": {
"description": "The unique numeric id of this saved filter",
"type": "integer"
},
"owner": {
"description": "The user who owns this filter",
"type": "object",
"$ref": "#/definitions/user.User"
},
"title": {
"description": "The title of the filter.",
"type": "string",
"maxLength": 250,
"minLength": 1
},
"updated": {
"description": "A timestamp when this filter was last updated. You cannot change this value.",
"type": "string"
}
}
},
"models.Task": {
"type": "object",
"properties": {
@ -6799,6 +7042,10 @@ var doc = `{
"description": "The task index, calculated per list",
"type": "integer"
},
"is_favorite": {
"description": "True if a task is a favorite task. Favorite tasks show up in a separate \"Important\" list",
"type": "boolean"
},
"labels": {
"description": "An array of labels which are associated with this task.",
"type": "array",
@ -6850,7 +7097,7 @@ var doc = `{
"description": "The task text. This is what you'll see in the list.",
"type": "string",
"maxLength": 250,
"minLength": 3
"minLength": 1
},
"updated": {
"description": "A timestamp when this task was last updated. You cannot change this value.",
@ -6891,6 +7138,54 @@ var doc = `{
}
}
},
"models.TaskCollection": {
"type": "object",
"properties": {
"filter_by": {
"description": "The field name of the field to filter by",
"type": "array",
"items": {
"type": "string"
}
},
"filter_comparator": {
"description": "The comparator for field and value",
"type": "array",
"items": {
"type": "string"
}
},
"filter_concat": {
"description": "The way all filter conditions are concatenated together, can be either \"and\" or \"or\".,",
"type": "string"
},
"filter_include_nulls": {
"description": "If set to true, the result will also include null values",
"type": "boolean"
},
"filter_value": {
"description": "The value of the field name to filter by",
"type": "array",
"items": {
"type": "string"
}
},
"order_by": {
"description": "The query parameter to order the items by. This can be either asc or desc, with asc being the default.",
"type": "array",
"items": {
"type": "string"
}
},
"sort_by": {
"description": "The query parameter to sort by. This is for ex. done, priority, etc.",
"type": "array",
"items": {
"type": "string"
}
}
}
},
"models.TaskComment": {
"type": "object",
"properties": {
@ -6969,7 +7264,7 @@ var doc = `{
"description": "The name of this team.",
"type": "string",
"maxLength": 250,
"minLength": 5
"minLength": 1
},
"updated": {
"description": "A timestamp when this relation was last updated. You cannot change this value.",
@ -7080,7 +7375,7 @@ var doc = `{
"description": "The username of the user. Is always unique.",
"type": "string",
"maxLength": 250,
"minLength": 3
"minLength": 1
}
}
},
@ -7115,7 +7410,7 @@ var doc = `{
"description": "The name of this team.",
"type": "string",
"maxLength": 250,
"minLength": 5
"minLength": 1
},
"right": {
"type": "integer"
@ -7153,7 +7448,7 @@ var doc = `{
"description": "The username of the user. Is always unique.",
"type": "string",
"maxLength": 250,
"minLength": 3
"minLength": 1
}
}
},
@ -7300,7 +7595,7 @@ var doc = `{
"description": "The username of the user. Is always unique.",
"type": "string",
"maxLength": 250,
"minLength": 3
"minLength": 1
}
}
},
@ -7351,6 +7646,9 @@ var doc = `{
"type": "string"
}
},
"caldav_enabled": {
"type": "boolean"
},
"enabled_background_providers": {
"type": "array",
"items": {

View File

@ -155,6 +155,201 @@
}
}
},
"/filters": {
"put": {
"security": [
{
"JWTKeyAuth": []
}
],
"description": "Creates a new saved filter",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"filter"
],
"summary": "Creates a new saved filter",
"responses": {
"200": {
"description": "The Saved Filter",
"schema": {
"$ref": "#/definitions/models.SavedFilter"
}
},
"403": {
"description": "The user does not have access to that saved filter.",
"schema": {
"$ref": "#/definitions/web.HTTPError"
}
},
"500": {
"description": "Internal error",
"schema": {
"$ref": "#/definitions/models.Message"
}
}
}
}
},
"/filters/{id}": {
"get": {
"security": [
{
"JWTKeyAuth": []
}
],
"description": "Returns a saved filter by its ID.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"filter"
],
"summary": "Gets one saved filter",
"parameters": [
{
"type": "integer",
"description": "Filter ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "The Saved Filter",
"schema": {
"$ref": "#/definitions/models.SavedFilter"
}
},
"403": {
"description": "The user does not have access to that saved filter.",
"schema": {
"$ref": "#/definitions/web.HTTPError"
}
},
"500": {
"description": "Internal error",
"schema": {
"$ref": "#/definitions/models.Message"
}
}
}
},
"post": {
"security": [
{
"JWTKeyAuth": []
}
],
"description": "Updates a saved filter by its ID.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"filter"
],
"summary": "Updates a saved filter",
"parameters": [
{
"type": "integer",
"description": "Filter ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "The Saved Filter",
"schema": {
"$ref": "#/definitions/models.SavedFilter"
}
},
"403": {
"description": "The user does not have access to that saved filter.",
"schema": {
"$ref": "#/definitions/web.HTTPError"
}
},
"404": {
"description": "The saved filter does not exist.",
"schema": {
"$ref": "#/definitions/web.HTTPError"
}
},
"500": {
"description": "Internal error",
"schema": {
"$ref": "#/definitions/models.Message"
}
}
}
},
"delete": {
"security": [
{
"JWTKeyAuth": []
}
],
"description": "Removes a saved filter by its ID.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"filter"
],
"summary": "Removes a saved filter",
"parameters": [
{
"type": "integer",
"description": "Filter ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "The Saved Filter",
"schema": {
"$ref": "#/definitions/models.SavedFilter"
}
},
"403": {
"description": "The user does not have access to that saved filter.",
"schema": {
"$ref": "#/definitions/web.HTTPError"
}
},
"404": {
"description": "The saved filter does not exist.",
"schema": {
"$ref": "#/definitions/web.HTTPError"
}
},
"500": {
"description": "Internal error",
"schema": {
"$ref": "#/definitions/models.Message"
}
}
}
}
},
"/info": {
"get": {
"description": "Returns the version, frontendurl, motd and various settings of Vikunja",
@ -6219,6 +6414,10 @@
"description": "The unique, numeric id of this bucket.",
"type": "integer"
},
"limit": {
"description": "How many tasks can be at the same time on this board max",
"type": "integer"
},
"list_id": {
"description": "The list this bucket belongs to.",
"type": "integer"
@ -6320,6 +6519,10 @@
"description": "The task index, calculated per list",
"type": "integer"
},
"is_favorite": {
"description": "True if a task is a favorite task. Favorite tasks show up in a separate \"Important\" list",
"type": "boolean"
},
"labels": {
"description": "An array of labels which are associated with this task.",
"type": "array",
@ -6378,7 +6581,7 @@
"description": "The task text. This is what you'll see in the list.",
"type": "string",
"maxLength": 250,
"minLength": 3
"minLength": 1
},
"updated": {
"description": "A timestamp when this task was last updated. You cannot change this value.",
@ -6415,7 +6618,7 @@
"description": "The title of the lable. You'll see this one on tasks associated with it.",
"type": "string",
"maxLength": 250,
"minLength": 3
"minLength": 1
},
"updated": {
"description": "A timestamp when this label was last updated. You cannot change this value.",
@ -6520,6 +6723,10 @@
"description": "Whether or not a list is archived.",
"type": "boolean"
},
"is_favorite": {
"description": "True if a list is a favorite. Favorite lists show up in a separate namespace.",
"type": "boolean"
},
"namespace_id": {
"type": "integer"
},
@ -6532,7 +6739,7 @@
"description": "The title of the list. You'll see this in the namespace overview.",
"type": "string",
"maxLength": 250,
"minLength": 3
"minLength": 1
},
"updated": {
"description": "A timestamp when this list was last updated. You cannot change this value.",
@ -6623,7 +6830,7 @@
"description": "The name of this namespace.",
"type": "string",
"maxLength": 250,
"minLength": 5
"minLength": 1
},
"updated": {
"description": "A timestamp when this namespace was last updated. You cannot change this value.",
@ -6697,7 +6904,7 @@
"description": "The name of this namespace.",
"type": "string",
"maxLength": 250,
"minLength": 5
"minLength": 1
},
"updated": {
"description": "A timestamp when this namespace was last updated. You cannot change this value.",
@ -6714,6 +6921,43 @@
}
}
},
"models.SavedFilter": {
"type": "object",
"properties": {
"created": {
"description": "A timestamp when this filter was created. You cannot change this value.",
"type": "string"
},
"description": {
"description": "The description of the filter",
"type": "string"
},
"filters": {
"description": "The actual filters this filter contains",
"type": "object",
"$ref": "#/definitions/models.TaskCollection"
},
"id": {
"description": "The unique numeric id of this saved filter",
"type": "integer"
},
"owner": {
"description": "The user who owns this filter",
"type": "object",
"$ref": "#/definitions/user.User"
},
"title": {
"description": "The title of the filter.",
"type": "string",
"maxLength": 250,
"minLength": 1
},
"updated": {
"description": "A timestamp when this filter was last updated. You cannot change this value.",
"type": "string"
}
}
},
"models.Task": {
"type": "object",
"properties": {
@ -6781,6 +7025,10 @@
"description": "The task index, calculated per list",
"type": "integer"
},
"is_favorite": {
"description": "True if a task is a favorite task. Favorite tasks show up in a separate \"Important\" list",
"type": "boolean"
},
"labels": {
"description": "An array of labels which are associated with this task.",
"type": "array",
@ -6832,7 +7080,7 @@
"description": "The task text. This is what you'll see in the list.",
"type": "string",
"maxLength": 250,
"minLength": 3
"minLength": 1
},
"updated": {
"description": "A timestamp when this task was last updated. You cannot change this value.",
@ -6873,6 +7121,54 @@
}
}
},
"models.TaskCollection": {
"type": "object",
"properties": {
"filter_by": {
"description": "The field name of the field to filter by",
"type": "array",
"items": {
"type": "string"
}
},
"filter_comparator": {
"description": "The comparator for field and value",
"type": "array",
"items": {
"type": "string"
}
},
"filter_concat": {
"description": "The way all filter conditions are concatenated together, can be either \"and\" or \"or\".,",
"type": "string"
},
"filter_include_nulls": {
"description": "If set to true, the result will also include null values",
"type": "boolean"
},
"filter_value": {
"description": "The value of the field name to filter by",
"type": "array",
"items": {
"type": "string"
}
},
"order_by": {
"description": "The query parameter to order the items by. This can be either asc or desc, with asc being the default.",
"type": "array",
"items": {
"type": "string"
}
},
"sort_by": {
"description": "The query parameter to sort by. This is for ex. done, priority, etc.",
"type": "array",
"items": {
"type": "string"
}
}
}
},
"models.TaskComment": {
"type": "object",
"properties": {
@ -6951,7 +7247,7 @@
"description": "The name of this team.",
"type": "string",
"maxLength": 250,
"minLength": 5
"minLength": 1
},
"updated": {
"description": "A timestamp when this relation was last updated. You cannot change this value.",
@ -7062,7 +7358,7 @@
"description": "The username of the user. Is always unique.",
"type": "string",
"maxLength": 250,
"minLength": 3
"minLength": 1
}
}
},
@ -7097,7 +7393,7 @@
"description": "The name of this team.",
"type": "string",
"maxLength": 250,
"minLength": 5
"minLength": 1
},
"right": {
"type": "integer"
@ -7135,7 +7431,7 @@
"description": "The username of the user. Is always unique.",
"type": "string",
"maxLength": 250,
"minLength": 3
"minLength": 1
}
}
},
@ -7282,7 +7578,7 @@
"description": "The username of the user. Is always unique.",
"type": "string",
"maxLength": 250,
"minLength": 3
"minLength": 1
}
}
},
@ -7333,6 +7629,9 @@
"type": "string"
}
},
"caldav_enabled": {
"type": "boolean"
},
"enabled_background_providers": {
"type": "array",
"items": {

File diff suppressed because it is too large Load Diff

View File

@ -46,7 +46,7 @@ type User struct {
// The unique, numeric id of this user.
ID int64 `xorm:"int(11) autoincr not null unique pk" json:"id"`
// The username of the user. Is always unique.
Username string `xorm:"varchar(250) not null unique" json:"username" valid:"length(1|250)" minLength:"3" maxLength:"250"`
Username string `xorm:"varchar(250) not null unique" json:"username" valid:"length(1|250)" minLength:"1" maxLength:"250"`
Password string `xorm:"varchar(250) not null" json:"-"`
// The user's email address.
Email string `xorm:"varchar(250) null" json:"email,omitempty" valid:"email,length(0|250)" maxLength:"250"`
@ -361,7 +361,12 @@ func UpdateUser(user *User) (updatedUser *User, err error) {
// Update it
_, err = x.
ID(user.ID).
Cols("username", "email", "avatar_provider", "is_active").
Cols(
"username",
"email",
"avatar_provider",
"avatar_file_id",
"is_active").
Update(user)
if err != nil {
return &User{}, err

View File

@ -20,4 +20,4 @@ package version
// It is an own package to avoid import cycles
// Version sets the version to be printed to the user. Gets overwritten by "make release" or "make build" with last git commit or tag.
var Version = "0.7"
var Version = "dev"

View File

@ -1,14 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"labels": ["dependencies"],
"hostRules": [
{
"domainName": "github.com",
"encrypted": {
"token": "jaazUwAHa7jio3jq+UFvUeVR5/fOwt3BNAzEBlhixggSYqooJ7Paq7aJ77mNHYBEYwCWFuUG3+SlQ/uLeMU0AtGNdxk9VQ2mbcZIpSbIPR2YU4NoQ0HrhL0XyrN6eShqLBQYKz47o3gaHd3ltWhVeMCxGjfoAlPw+z0DhUQCfuFWUJu3lYYNIhY+CVeir6r0s0AablpxMJ1kpak6fCQ6BdaOW11rC/bQfW82fAp4Pkv877AolB+fVU7klMXfU6d2Ihk343jOEvltI5g1l5ss0vjiJnGZh4Sxump0ivoc73/P1TnywKTrvEdWs9df42IUAZozJwfqAIOUCtWbZifEXg=="
}
}
],
"extends": [
"config:base"
]

View File

@ -33,4 +33,6 @@ import (
_ "honnef.co/go/tools/cmd/staticcheck"
_ "github.com/shurcooL/vfsgen"
_ "github.com/magefile/mage"
)