1
0

Task Attachments (#104)

This commit is contained in:
konrad
2019-10-16 20:52:29 +00:00
committed by Gitea
parent e2f481a6e5
commit 2169464983
349 changed files with 22540 additions and 5267 deletions

View File

@ -17,6 +17,7 @@
package models
import (
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/web"
"fmt"
"net/http"
@ -731,6 +732,62 @@ func (err ErrRelationTasksCannotBeTheSame) HTTPError() web.HTTPError {
}
}
// ErrTaskAttachmentDoesNotExist represents an error where the user tries to relate a task with itself
type ErrTaskAttachmentDoesNotExist struct {
TaskID int64
AttachmentID int64
FileID int64
}
// IsErrTaskAttachmentDoesNotExist checks if an error is ErrTaskAttachmentDoesNotExist.
func IsErrTaskAttachmentDoesNotExist(err error) bool {
_, ok := err.(ErrTaskAttachmentDoesNotExist)
return ok
}
func (err ErrTaskAttachmentDoesNotExist) Error() string {
return fmt.Sprintf("Task attachment does not exist [TaskID: %d, AttachmentID: %d, FileID: %d]", err.TaskID, err.AttachmentID, err.FileID)
}
// ErrCodeTaskAttachmentDoesNotExist holds the unique world-error code of this error
const ErrCodeTaskAttachmentDoesNotExist = 4011
// HTTPError holds the http error description
func (err ErrTaskAttachmentDoesNotExist) HTTPError() web.HTTPError {
return web.HTTPError{
HTTPCode: http.StatusNotFound,
Code: ErrCodeTaskAttachmentDoesNotExist,
Message: "This task attachment does not exist.",
}
}
// ErrTaskAttachmentIsTooLarge represents an error where the user tries to relate a task with itself
type ErrTaskAttachmentIsTooLarge struct {
Size int64
}
// IsErrTaskAttachmentIsTooLarge checks if an error is ErrTaskAttachmentIsTooLarge.
func IsErrTaskAttachmentIsTooLarge(err error) bool {
_, ok := err.(ErrTaskAttachmentIsTooLarge)
return ok
}
func (err ErrTaskAttachmentIsTooLarge) Error() string {
return fmt.Sprintf("Task attachment is too large [Size: %d]", err.Size)
}
// ErrCodeTaskAttachmentIsTooLarge holds the unique world-error code of this error
const ErrCodeTaskAttachmentIsTooLarge = 4012
// HTTPError holds the http error description
func (err ErrTaskAttachmentIsTooLarge) HTTPError() web.HTTPError {
return web.HTTPError{
HTTPCode: http.StatusBadRequest,
Code: ErrCodeTaskAttachmentIsTooLarge,
Message: fmt.Sprintf("The task attachment exceeds the configured file size of %d bytes, filesize was %d", config.FilesMaxSize.GetInt64(), err.Size),
}
}
// =================
// Namespace errors
// =================

View File

@ -0,0 +1 @@
../../files/fixtures/files.yml

View File

@ -0,0 +1,11 @@
- id: 1
task_id: 1
file_id: 1
created_by_id: 1
created: 0
# The file for this attachment does not exist
- id: 2
task_id: 1
file_id: 9999
created_by_id: 1
created: 0

View File

@ -18,10 +18,22 @@ package models
import (
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/files"
"os"
"testing"
)
func TestMain(m *testing.M) {
config.InitConfig()
MainTest(m, config.ServiceRootpath.GetString())
// Set default config
config.InitDefaultConfig()
// We need to set the root path even if we're not using the config, otherwise fixtures are not loaded correctly
config.ServiceRootpath.Set(os.Getenv("VIKUNJA_SERVICE_ROOTPATH"))
// Some tests use the file engine, so we'll need to initialize that
files.InitTests()
SetupTests(config.ServiceRootpath.GetString())
os.Exit(m.Run())
}

View File

@ -20,10 +20,9 @@ import (
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/log"
"encoding/gob"
_ "github.com/go-sql-driver/mysql" // Because.
"github.com/go-xorm/xorm"
xrc "github.com/go-xorm/xorm-redis-cache"
_ "github.com/mattn/go-sqlite3" // Because.
)
@ -50,6 +49,7 @@ func GetTables() []interface{} {
&TaskReminder{},
&LinkSharing{},
&TaskRelation{},
&TaskAttachment{},
}
}
@ -62,19 +62,8 @@ func SetEngine() (err error) {
}
// Cache
// We have to initialize the cache here to avoid import cycles
if config.CacheEnabled.GetBool() {
switch config.CacheType.GetString() {
case "memory":
cacher := xorm.NewLRUCacher(xorm.NewMemoryStore(), config.CacheMaxElementSize.GetInt())
x.SetDefaultCacher(cacher)
case "redis":
cacher := xrc.NewRedisCacher(config.RedisEnabled.GetString(), config.RedisPassword.GetString(), xrc.DEFAULT_EXPIRATION, x.Logger())
x.SetDefaultCacher(cacher)
gob.Register(GetTables())
default:
log.Info("Did not find a valid cache type. Caching disabled. Please refer to the docs for poosible cache types.")
}
if config.CacheEnabled.GetBool() && config.CacheType.GetString() == "redis" {
db.RegisterTableStructsForCache(GetTables())
}
return nil

View File

@ -0,0 +1,186 @@
// Vikunja is a todo-list application to facilitate your life.
// Copyright 2019 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/files"
"code.vikunja.io/web"
"io"
"time"
)
// TaskAttachment is the definition of a task attachment
type TaskAttachment struct {
ID int64 `xorm:"int(11) autoincr not null unique pk" json:"id" param:"attachment"`
TaskID int64 `xorm:"int(11) not null" json:"task_id" param:"task"`
FileID int64 `xorm:"int(11) not null" json:"-"`
CreatedByID int64 `xorm:"int(11) not null" json:"-"`
CreatedBy *User `xorm:"-" json:"created_by"`
File *files.File `xorm:"-" json:"file"`
Created int64 `xorm:"created" json:"created"`
web.CRUDable `xorm:"-" json:"-"`
web.Rights `xorm:"-" json:"-"`
}
// TableName returns the table name for task attachments
func (TaskAttachment) TableName() string {
return "task_attachments"
}
// NewAttachment creates a new task attachment
// Note: I'm not sure if only accepting an io.ReadCloser and not an afero.File or os.File instead is a good way of doing things.
func (ta *TaskAttachment) NewAttachment(f io.ReadCloser, realname string, realsize int64, a web.Auth) error {
// Store the file
file, err := files.Create(f, realname, realsize, a)
if err != nil {
if files.IsErrFileIsTooLarge(err) {
return ErrTaskAttachmentIsTooLarge{Size: realsize}
}
return err
}
ta.File = file
// Add an entry to the db
ta.FileID = file.ID
ta.CreatedByID = a.GetID()
_, err = x.Insert(ta)
if err != nil {
// remove the uploaded file if adding it to the db fails
if err2 := file.Delete(); err2 != nil {
return err2
}
return err
}
return nil
}
// ReadOne returns a task attachment
func (ta *TaskAttachment) ReadOne() (err error) {
exists, err := x.Where("id = ?", ta.ID).Get(ta)
if err != nil {
return
}
if !exists {
return ErrTaskAttachmentDoesNotExist{
TaskID: ta.TaskID,
AttachmentID: ta.ID,
}
}
// Get the file
ta.File = &files.File{ID: ta.FileID}
err = ta.File.LoadFileMetaByID()
return
}
// ReadAll returns a list with all attachments
// @Summary Get all attachments for one task.
// @Description Get all task attachments for one task.
// @tags task
// @Accept json
// @Produce json
// @Param id path int true "Task ID"
// @Security JWTKeyAuth
// @Success 200 {array} models.TaskAttachment "All attachments for this task"
// @Failure 403 {object} models.Message "No access to this task."
// @Failure 404 {object} models.Message "The task does not exist."
// @Failure 500 {object} models.Message "Internal error"
// @Router /tasks/{id}/attachments [get]
func (ta *TaskAttachment) ReadAll(s string, a web.Auth, page int) (interface{}, error) {
attachments := []*TaskAttachment{}
err := x.
Limit(getLimitFromPageIndex(page)).
Where("task_id = ?", ta.TaskID).
Find(&attachments)
if err != nil {
return nil, err
}
fileIDs := make([]int64, 0, len(attachments))
userIDs := make([]int64, 0, len(attachments))
for _, r := range attachments {
fileIDs = append(fileIDs, r.FileID)
userIDs = append(userIDs, r.CreatedByID)
}
fs := make(map[int64]*files.File)
err = x.In("id", fileIDs).Find(&fs)
if err != nil {
return nil, err
}
us := make(map[int64]*User)
err = x.In("id", userIDs).Find(&us)
if err != nil {
return nil, err
}
for _, r := range attachments {
// If the actual file does not exist, don't try to load it as that would fail with nil panic
if _, exists := fs[r.FileID]; !exists {
continue
}
r.File = fs[r.FileID]
r.File.Created = time.Unix(r.File.CreatedUnix, 0)
r.CreatedBy = us[r.CreatedByID]
}
return attachments, err
}
// Delete removes an attachment
// @Summary Delete an attachment
// @Description Delete an attachment.
// @tags task
// @Accept json
// @Produce json
// @Param id path int true "Task ID"
// @Param attachmentID path int true "Attachment ID"
// @Security JWTKeyAuth
// @Success 200 {object} models.Message "The attachment was deleted successfully."
// @Failure 403 {object} models.Message "No access to this task."
// @Failure 404 {object} models.Message "The task does not exist."
// @Failure 500 {object} models.Message "Internal error"
// @Router /tasks/{id}/attachments/{attachmentID} [delete]
func (ta *TaskAttachment) Delete() error {
// Load the attachment
err := ta.ReadOne()
if err != nil && !files.IsErrFileDoesNotExist(err) {
return err
}
// Delete it
_, err = x.Where("task_id = ? AND id = ?", ta.TaskID, ta.ID).Delete(ta)
if err != nil {
return err
}
// Delete the underlying file
err = ta.File.Delete()
// If the file does not exist, we don't want to error out
if err != nil && files.IsErrFileDoesNotExist(err) {
return nil
}
return err
}

View File

@ -0,0 +1,46 @@
// Vikunja is a todo-list application to facilitate your life.
// Copyright 2019 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 the user can see an attachment
func (ta *TaskAttachment) CanRead(a web.Auth) (bool, error) {
t, err := GetTaskByIDSimple(ta.TaskID)
if err != nil {
return false, err
}
return t.CanRead(a)
}
// CanDelete checks if the user can delete an attachment
func (ta *TaskAttachment) CanDelete(a web.Auth) (bool, error) {
t, err := GetTaskByIDSimple(ta.TaskID)
if err != nil {
return false, err
}
return t.CanWrite(a)
}
// CanCreate checks if the user can create an attachment
func (ta *TaskAttachment) CanCreate(a web.Auth) (bool, error) {
t, err := GetTaskByIDSimple(ta.TaskID)
if err != nil {
return false, err
}
return t.CanCreate(a)
}

View File

@ -0,0 +1,152 @@
// Copyright 2019 Vikunja and contriubtors. All rights reserved.
//
// This file is part of Vikunja.
//
// Vikunja 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.
//
// Vikunja 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 Vikunja. If not, see <https://www.gnu.org/licenses/>.
package models
import (
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/files"
"github.com/stretchr/testify/assert"
"io"
"os"
"strconv"
"testing"
)
func TestTaskAttachment_ReadOne(t *testing.T) {
t.Run("Normal File", func(t *testing.T) {
files.InitTestFileFixtures(t)
ta := &TaskAttachment{
ID: 1,
}
err := ta.ReadOne()
assert.NoError(t, err)
assert.NotNil(t, ta.File)
assert.True(t, ta.File.ID == ta.FileID && ta.FileID != 0)
// Load the actual attachment file and check its content
err = ta.File.LoadFileByID()
assert.NoError(t, err)
assert.Equal(t, config.FilesBasePath.GetString()+"/1", ta.File.File.Name())
content := make([]byte, 9)
read, err := ta.File.File.Read(content)
assert.NoError(t, err)
assert.Equal(t, 9, read)
assert.Equal(t, []byte("testfile1"), content)
})
t.Run("Nonexisting Attachment", func(t *testing.T) {
files.InitTestFileFixtures(t)
ta := &TaskAttachment{
ID: 9999,
}
err := ta.ReadOne()
assert.Error(t, err)
assert.True(t, IsErrTaskAttachmentDoesNotExist(err))
})
t.Run("Existing Attachment, Nonexisting File", func(t *testing.T) {
files.InitTestFileFixtures(t)
ta := &TaskAttachment{
ID: 2,
}
err := ta.ReadOne()
assert.Error(t, err)
assert.EqualError(t, err, "file 9999 does not exist")
})
}
type testfile struct {
content []byte
done bool
}
func (t *testfile) Read(p []byte) (n int, err error) {
if t.done {
return 0, io.EOF
}
copy(p, t.content)
t.done = true
return len(p), nil
}
func (t *testfile) Close() error {
return nil
}
func TestTaskAttachment_NewAttachment(t *testing.T) {
files.InitTestFileFixtures(t)
// Assert the file is being stored correctly
ta := TaskAttachment{
TaskID: 1,
}
tf := &testfile{
content: []byte("testingstuff"),
}
testuser := &User{ID: 1}
err := ta.NewAttachment(tf, "testfile", 100, testuser)
assert.NoError(t, err)
assert.NotEqual(t, 0, ta.FileID)
_, err = files.FileStat("files/" + strconv.FormatInt(ta.FileID, 10))
assert.NoError(t, err)
assert.False(t, os.IsNotExist(err))
assert.Equal(t, testuser.ID, ta.CreatedByID)
// Check the file was inserted correctly
ta.File = &files.File{ID: ta.FileID}
err = ta.File.LoadFileMetaByID()
assert.NoError(t, err)
assert.Equal(t, testuser.ID, ta.File.CreatedByID)
assert.Equal(t, "testfile", ta.File.Name)
assert.Equal(t, int64(100), ta.File.Size)
// Extra test for max size test
}
func TestTaskAttachment_ReadAll(t *testing.T) {
files.InitTestFileFixtures(t)
ta := &TaskAttachment{TaskID: 1}
as, err := ta.ReadAll("", &User{ID: 1}, 0)
attachments, _ := as.([]*TaskAttachment)
assert.NoError(t, err)
assert.Len(t, attachments, 3)
assert.Equal(t, "test", attachments[0].File.Name)
}
func TestTaskAttachment_Delete(t *testing.T) {
files.InitTestFileFixtures(t)
t.Run("Normal", func(t *testing.T) {
ta := &TaskAttachment{ID: 1}
err := ta.Delete()
assert.NoError(t, err)
// Check if the file itself was deleted
_, err = files.FileStat("/1") // The new file has the id 2 since it's the second attachment
assert.True(t, os.IsNotExist(err))
})
t.Run("Nonexisting", func(t *testing.T) {
files.InitTestFileFixtures(t)
ta := &TaskAttachment{ID: 9999}
err := ta.Delete()
assert.Error(t, err)
assert.True(t, IsErrTaskAttachmentDoesNotExist(err))
})
t.Run("Existing attachment, nonexisting file", func(t *testing.T) {
files.InitTestFileFixtures(t)
ta := &TaskAttachment{ID: 2}
err := ta.Delete()
assert.NoError(t, err)
})
}

View File

@ -7,6 +7,8 @@
package models
import (
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/files"
"github.com/stretchr/testify/assert"
"gopkg.in/d4l3k/messagediff.v1"
"sort"
@ -67,6 +69,29 @@ func sortTasksForTesting(by SortBy) (tasks []*Task) {
},
},
},
Attachments: []*TaskAttachment{
{
ID: 1,
TaskID: 1,
FileID: 1,
CreatedByID: 1,
CreatedBy: user1,
File: &files.File{
ID: 1,
Name: "test",
Size: 100,
CreatedUnix: 1570998791,
CreatedByID: 1,
},
},
{
ID: 2,
TaskID: 1,
FileID: 9999,
CreatedByID: 1,
CreatedBy: user1,
},
},
Created: 1543626724,
Updated: 1543626724,
},
@ -434,7 +459,7 @@ func sortTasksForTesting(by SortBy) (tasks []*Task) {
}
func TestTask_ReadAll(t *testing.T) {
assert.NoError(t, LoadFixtures())
assert.NoError(t, db.LoadFixtures())
// Dummy users
user1 := &User{

View File

@ -17,6 +17,7 @@
package models
import (
"code.vikunja.io/api/pkg/files"
"code.vikunja.io/api/pkg/metrics"
"code.vikunja.io/api/pkg/utils"
"code.vikunja.io/web"
@ -71,6 +72,9 @@ type Task struct {
// All related tasks, grouped by their relation kind
RelatedTasks RelatedTaskMap `xorm:"-" json:"related_tasks"`
// All attachments this task has
Attachments []*TaskAttachment `xorm:"-" json:"attachments"`
// A unix timestamp when this task was created. You cannot change this value.
Created int64 `xorm:"created not null" json:"created"`
// A unix timestamp when this task was last updated. You cannot change this value.
@ -409,6 +413,28 @@ func addMoreInfoToTasks(taskMap map[int64]*Task) (tasks []*Task, err error) {
}
}
// Get task attachments
attachments := []*TaskAttachment{}
err = x.
In("task_id", taskIDs).
Find(&attachments)
if err != nil {
return nil, err
}
fileIDs := []int64{}
for _, a := range attachments {
userIDs = append(userIDs, a.CreatedByID)
fileIDs = append(fileIDs, a.FileID)
}
// Get all files
fs := make(map[int64]*files.File)
err = x.In("id", fileIDs).Find(&fs)
if err != nil {
return
}
// Get all users of a task
// aka the ones who created a task
users := make(map[int64]*User)
@ -422,6 +448,13 @@ func addMoreInfoToTasks(taskMap map[int64]*Task) (tasks []*Task, err error) {
u.Email = ""
}
// Put the users and files in task attachments
for _, a := range attachments {
a.CreatedBy = users[a.CreatedByID]
a.File = fs[a.FileID]
taskMap[a.TaskID].Attachments = append(taskMap[a.TaskID].Attachments, a)
}
// Get all reminders and put them in a map to have it easier later
reminders := []*TaskReminder{}
err = x.Table("task_reminders").In("task_id", taskIDs).Find(&reminders)

View File

@ -1,35 +0,0 @@
// Vikunja is a todo-list application to facilitate your life.
// Copyright 2018 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 (
"gopkg.in/testfixtures.v2"
)
var fixtures *testfixtures.Context
// InitFixtures initialize test fixtures for a test database
func InitFixtures(helper testfixtures.Helper, dir string) (err error) {
testfixtures.SkipDatabaseNameCheck(true)
fixtures, err = testfixtures.NewFolder(x.DB().DB, helper, dir)
return err
}
// LoadFixtures load fixtures for a test database
func LoadFixtures() error {
return fixtures.Load()
}

View File

@ -19,23 +19,16 @@ package models
import (
"code.vikunja.io/api/pkg/config"
_ "code.vikunja.io/api/pkg/config" // To trigger its init() which initializes the config
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/mail"
"fmt"
"github.com/go-xorm/core"
"github.com/go-xorm/xorm"
"gopkg.in/testfixtures.v2"
"os"
"path/filepath"
"testing"
)
// MainTest creates the test engine
func MainTest(m *testing.M, pathToRoot string) {
SetupTests(pathToRoot)
os.Exit(m.Run())
}
// SetupTests takes care of seting up the db, fixtures etc.
// This is an extra function to be able to call the fixtures setup from the integration tests.
func SetupTests(pathToRoot string) {
@ -49,7 +42,7 @@ func SetupTests(pathToRoot string) {
mail.StartMailDaemon()
// Create test database
if err = LoadFixtures(); err != nil {
if err = db.LoadFixtures(); err != nil {
log.Fatalf("Error preparing test database: %v", err.Error())
}
}
@ -59,13 +52,12 @@ func createTestEngine(fixturesDir string) error {
var fixturesHelper testfixtures.Helper = &testfixtures.SQLite{}
// If set, use the config we provided instead of normal
if os.Getenv("VIKUNJA_TESTS_USE_CONFIG") == "1" {
config.InitConfig()
err = SetEngine()
x, err = db.CreateTestEngine()
if err != nil {
return err
return fmt.Errorf("error getting test engine: %v", err)
}
err = x.Sync2(GetTables()...)
err = initSchema(x)
if err != nil {
return err
}
@ -74,23 +66,21 @@ func createTestEngine(fixturesDir string) error {
fixturesHelper = &testfixtures.MySQL{}
}
} else {
x, err = xorm.NewEngine("sqlite3", "file::memory:?cache=shared")
x, err = db.CreateTestEngine()
if err != nil {
return err
return fmt.Errorf("error getting test engine: %v", err)
}
x.SetMapper(core.GonicMapper{})
// Sync dat shit
if err := x.Sync2(GetTables()...); err != nil {
err = initSchema(x)
if err != nil {
return fmt.Errorf("sync database struct error: %v", err)
}
// Show SQL-Queries if necessary
if os.Getenv("UNIT_TESTS_VERBOSE") == "1" {
x.ShowSQL(true)
}
}
return InitFixtures(fixturesHelper, fixturesDir)
return db.InitFixtures(fixturesHelper, fixturesDir)
}
func initSchema(tx *xorm.Engine) error {
return tx.Sync2(GetTables()...)
}

View File

@ -1,6 +1,7 @@
package models
import (
"code.vikunja.io/api/pkg/db"
"github.com/stretchr/testify/assert"
"gopkg.in/d4l3k/messagediff.v1"
"testing"
@ -8,7 +9,7 @@ import (
func TestListUsersFromList(t *testing.T) {
err := LoadFixtures()
err := db.LoadFixtures()
assert.NoError(t, err)
testuser1 := &User{