From 0a1d8c940410b03a78016ac6110883ca05484816 Mon Sep 17 00:00:00 2001 From: konrad Date: Sun, 27 Mar 2022 20:35:04 +0000 Subject: [PATCH] feat: add date math for filters (#1086) This adds support for relative dates in filters, similar to the ones from [grafana](https://grafana.com/docs/grafana/latest/dashboards/time-range-controls) or [elasticsearch](https://www.elastic.co/guide/en/elasticsearch/reference/7.3/common-options.html#date-math). In short, it allows you to filter for due dates by passing in dates like "now - 7d" to get a date from 7 days ago. This is a very powerful addition for saved filters as they will allow you to create filters for all kinds of stuff where you previously only could use fixed dates. Now you can for example create a saved filter for "all tasks this week". Frontend PR: https://kolaente.dev/vikunja/frontend/pulls/1342 Co-authored-by: kolaente Reviewed-on: https://kolaente.dev/vikunja/api/pulls/1086 --- go.mod | 1 + go.sum | 2 ++ pkg/integrations/task_collection_test.go | 29 ++++++++++++++++++++++-- pkg/models/task_collection.go | 2 +- pkg/models/task_collection_filter.go | 11 +++++++-- pkg/swagger/docs.go | 2 +- pkg/swagger/swagger.json | 2 +- pkg/swagger/swagger.yaml | 5 +++- 8 files changed, 46 insertions(+), 8 deletions(-) diff --git a/go.mod b/go.mod index dedf2a2a6ee..01adc737701 100644 --- a/go.mod +++ b/go.mod @@ -59,6 +59,7 @@ require ( github.com/swaggo/swag v1.8.0 github.com/tkuchiki/go-timezone v0.2.2 github.com/ulule/limiter/v3 v3.10.0 + github.com/vectordotdev/go-datemath v0.1.1-0.20211214182920-0a4ac8742b93 github.com/yuin/goldmark v1.4.8 golang.org/x/crypto v0.0.0-20220307211146-efcb8507fb70 golang.org/x/image v0.0.0-20220302094943-723b81ca9867 diff --git a/go.sum b/go.sum index a2d331815df..0d53dc01a39 100644 --- a/go.sum +++ b/go.sum @@ -769,6 +769,8 @@ github.com/valyala/fasttemplate v1.2.1 h1:TVEnxayobAdVkhQfrfes2IzOB6o+z4roRkPF52 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/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/vectordotdev/go-datemath v0.1.1-0.20211214182920-0a4ac8742b93 h1:bT0ZMfsMi2Xh8dopgxhFT+OJH88QITHpdppdkG1rXJQ= +github.com/vectordotdev/go-datemath v0.1.1-0.20211214182920-0a4ac8742b93/go.mod h1:PnwzbSst7KD3vpBzzlntZU5gjVa455Uqa5QPiKSYJzQ= 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= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= diff --git a/pkg/integrations/task_collection_test.go b/pkg/integrations/task_collection_test.go index bbd64c25590..28eaef0041c 100644 --- a/pkg/integrations/task_collection_test.go +++ b/pkg/integrations/task_collection_test.go @@ -244,12 +244,37 @@ func TestTaskCollection(t *testing.T) { // the current date. assert.Equal(t, "[]\n", rec.Body.String()) }) + t.Run("unix timestamps", func(t *testing.T) { + rec, err := testHandler.testReadAllWithUser( + url.Values{ + "filter_by": []string{"start_date", "end_date", "due_date"}, + "filter_value": []string{"1544500000", "1513164001", "1543500000"}, + "filter_comparator": []string{"greater", "less", "greater"}, + }, + urlParams, + ) + 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.NotContains(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("invalid date", func(t *testing.T) { _, err := testHandler.testReadAllWithUser( url.Values{ "filter_by": []string{"due_date"}, - "filter_value": []string{"1540000000"}, + "filter_value": []string{"invalid"}, "filter_comparator": []string{"greater"}, }, nil, @@ -451,7 +476,7 @@ func TestTaskCollection(t *testing.T) { _, err := testHandler.testReadAllWithUser( url.Values{ "filter_by": []string{"due_date"}, - "filter_value": []string{"1540000000"}, + "filter_value": []string{"invalid"}, "filter_comparator": []string{"greater"}, }, nil, diff --git a/pkg/models/task_collection.go b/pkg/models/task_collection.go index 70be8d744b3..f6a22a121a4 100644 --- a/pkg/models/task_collection.go +++ b/pkg/models/task_collection.go @@ -131,7 +131,7 @@ func getTaskFilterOptsFromCollection(tf *TaskCollection) (opts *taskOptions, err // @Param sort_by query string false "The sorting parameter. You can pass this multiple times to get the tasks ordered by multiple different parametes, along with `order_by`. Possible values to sort by are `id`, `title`, `description`, `done`, `done_at`, `due_date`, `created_by_id`, `list_id`, `repeat_after`, `priority`, `start_date`, `end_date`, `hex_color`, `percent_done`, `uid`, `created`, `updated`. Default is `id`." // @Param order_by query string false "The ordering parameter. Possible values to order by are `asc` or `desc`. Default is `asc`." // @Param filter_by query string false "The name of the field to filter by. Allowed values are all task properties. Task properties which are their own object require passing in the id of that entity. Accepts an array for multiple filters which will be chanied together, all supplied filter must match." -// @Param filter_value query string false "The value to filter for." +// @Param filter_value query string false "The value to filter for. You can use [grafana](https://grafana.com/docs/grafana/latest/dashboards/time-range-controls)- or [elasticsearch](https://www.elastic.co/guide/en/elasticsearch/reference/7.3/common-options.html#date-math)-style relative dates for all date fields like `due_date`, `start_date`, `end_date`, etc." // @Param filter_comparator query string false "The comparator to use for a filter. Available values are `equals`, `greater`, `greater_equals`, `less`, `less_equals`, `like` and `in`. `in` expects comma-separated values in `filter_value`. Defaults to `equals`" // @Param filter_concat query string false "The concatinator to use for filters. Available values are `and` or `or`. Defaults to `or`." // @Param filter_include_nulls query string false "If set to true the result will include filtered fields whose value is set to `null`. Available values are `true` or `false`. Defaults to `false`." diff --git a/pkg/models/task_collection_filter.go b/pkg/models/task_collection_filter.go index 4b8ed1421de..0e86a683be1 100644 --- a/pkg/models/task_collection_filter.go +++ b/pkg/models/task_collection_filter.go @@ -25,6 +25,7 @@ import ( "code.vikunja.io/api/pkg/config" "github.com/iancoleman/strcase" + "github.com/vectordotdev/go-datemath" "xorm.io/xorm/schemas" ) @@ -159,8 +160,14 @@ func getValueForField(field reflect.StructField, rawValue string) (value interfa value, err = strconv.ParseBool(rawValue) case reflect.Struct: if field.Type == schemas.TimeType { - value, err = time.Parse(time.RFC3339, rawValue) - value = value.(time.Time).In(config.GetTimeZone()) + var t datemath.Expression + t, err = datemath.Parse(rawValue) + if err == nil { + value = t.Time(datemath.WithLocation(config.GetTimeZone())) + } else { + value, err = time.Parse(time.RFC3339, rawValue) + value = value.(time.Time).In(config.GetTimeZone()) + } } case reflect.Slice: // If this is a slice of pointers we're dealing with some property which is a relation diff --git a/pkg/swagger/docs.go b/pkg/swagger/docs.go index ca64c34a2ed..ab411d3ff80 100644 --- a/pkg/swagger/docs.go +++ b/pkg/swagger/docs.go @@ -1995,7 +1995,7 @@ var doc = `{ }, { "type": "string", - "description": "The value to filter for.", + "description": "The value to filter for. You can use [grafana](https://grafana.com/docs/grafana/latest/dashboards/time-range-controls)- or [elasticsearch](https://www.elastic.co/guide/en/elasticsearch/reference/7.3/common-options.html#date-math)-style relative dates for all date fields like ` + "`" + `due_date` + "`" + `, ` + "`" + `start_date` + "`" + `, ` + "`" + `end_date` + "`" + `, etc.", "name": "filter_value", "in": "query" }, diff --git a/pkg/swagger/swagger.json b/pkg/swagger/swagger.json index 2e5d604a3b7..01a96a8a0ee 100644 --- a/pkg/swagger/swagger.json +++ b/pkg/swagger/swagger.json @@ -1979,7 +1979,7 @@ }, { "type": "string", - "description": "The value to filter for.", + "description": "The value to filter for. You can use [grafana](https://grafana.com/docs/grafana/latest/dashboards/time-range-controls)- or [elasticsearch](https://www.elastic.co/guide/en/elasticsearch/reference/7.3/common-options.html#date-math)-style relative dates for all date fields like `due_date`, `start_date`, `end_date`, etc.", "name": "filter_value", "in": "query" }, diff --git a/pkg/swagger/swagger.yaml b/pkg/swagger/swagger.yaml index 5f72c4ce616..b3e1849a593 100644 --- a/pkg/swagger/swagger.yaml +++ b/pkg/swagger/swagger.yaml @@ -2898,7 +2898,10 @@ paths: in: query name: filter_by type: string - - description: The value to filter for. + - description: The value to filter for. You can use [grafana](https://grafana.com/docs/grafana/latest/dashboards/time-range-controls)- + or [elasticsearch](https://www.elastic.co/guide/en/elasticsearch/reference/7.3/common-options.html#date-math)-style + relative dates for all date fields like `due_date`, `start_date`, `end_date`, + etc. in: query name: filter_value type: string