feat(time-tracking): add the time_entries model
This commit is contained in:
parent
26c067cc38
commit
42795518e9
12
pkg/db/db.go
12
pkg/db/db.go
|
|
@ -525,5 +525,17 @@ func CreateParadeDBIndexes() error {
|
||||||
return fmt.Errorf("could not ensure paradedb project index: %w", err)
|
return fmt.Errorf("could not ensure paradedb project index: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create ParadeDB index for time_entries (comment search via MultiFieldSearch)
|
||||||
|
timeEntriesIndexSQL := `CREATE INDEX IF NOT EXISTS idx_time_entries_paradedb ON time_entries USING bm25 (id, comment)
|
||||||
|
WITH (
|
||||||
|
key_field='id',
|
||||||
|
text_fields='{
|
||||||
|
"comment": {"fast": true, "record": "freq"}
|
||||||
|
}'
|
||||||
|
)`
|
||||||
|
if _, err := x.Exec(timeEntriesIndexSQL); err != nil {
|
||||||
|
return fmt.Errorf("could not ensure paradedb time entry index: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2398,3 +2398,173 @@ func (err *ErrOAuthInvalidGrantType) HTTPError() web.HTTPError {
|
||||||
Message: "The grant_type is not supported. Use 'authorization_code' or 'refresh_token'.",
|
Message: "The grant_type is not supported. Use 'authorization_code' or 'refresh_token'.",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ====================
|
||||||
|
// Time Tracking Errors
|
||||||
|
// ====================
|
||||||
|
|
||||||
|
// ErrTimeEntryDoesNotExist represents an error where a time entry does not exist
|
||||||
|
type ErrTimeEntryDoesNotExist struct {
|
||||||
|
TimeEntryID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsErrTimeEntryDoesNotExist checks if an error is ErrTimeEntryDoesNotExist.
|
||||||
|
func IsErrTimeEntryDoesNotExist(err error) bool {
|
||||||
|
_, ok := err.(ErrTimeEntryDoesNotExist)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err ErrTimeEntryDoesNotExist) Error() string {
|
||||||
|
return fmt.Sprintf("Time entry does not exist [TimeEntryID: %v]", err.TimeEntryID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrCodeTimeEntryDoesNotExist holds the unique world-error code of this error
|
||||||
|
const ErrCodeTimeEntryDoesNotExist = 18001
|
||||||
|
|
||||||
|
// HTTPError holds the http error description
|
||||||
|
func (err ErrTimeEntryDoesNotExist) HTTPError() web.HTTPError {
|
||||||
|
return web.HTTPError{
|
||||||
|
HTTPCode: http.StatusNotFound,
|
||||||
|
Code: ErrCodeTimeEntryDoesNotExist,
|
||||||
|
Message: "This time entry does not exist.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrTimeEntryInvalidContainer represents an error where a time entry is attached
|
||||||
|
// to both a task and a project, or to neither (violating the XOR invariant).
|
||||||
|
type ErrTimeEntryInvalidContainer struct {
|
||||||
|
TaskID int64
|
||||||
|
ProjectID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsErrTimeEntryInvalidContainer checks if an error is ErrTimeEntryInvalidContainer.
|
||||||
|
func IsErrTimeEntryInvalidContainer(err error) bool {
|
||||||
|
_, ok := err.(ErrTimeEntryInvalidContainer)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err ErrTimeEntryInvalidContainer) Error() string {
|
||||||
|
return fmt.Sprintf("Time entry must be attached to exactly one of task or project [TaskID: %v, ProjectID: %v]", err.TaskID, err.ProjectID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrCodeTimeEntryInvalidContainer holds the unique world-error code of this error
|
||||||
|
const ErrCodeTimeEntryInvalidContainer = 18002
|
||||||
|
|
||||||
|
// HTTPError holds the http error description
|
||||||
|
func (err ErrTimeEntryInvalidContainer) HTTPError() web.HTTPError {
|
||||||
|
return web.HTTPError{
|
||||||
|
HTTPCode: http.StatusBadRequest,
|
||||||
|
Code: ErrCodeTimeEntryInvalidContainer,
|
||||||
|
Message: "A time entry must be attached to exactly one of a task or a project.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrInvalidTimeEntryFilterField represents an error where a time entry filter references a non-filterable field
|
||||||
|
type ErrInvalidTimeEntryFilterField struct {
|
||||||
|
Field string
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsErrInvalidTimeEntryFilterField checks if an error is ErrInvalidTimeEntryFilterField.
|
||||||
|
func IsErrInvalidTimeEntryFilterField(err error) bool {
|
||||||
|
_, ok := err.(ErrInvalidTimeEntryFilterField)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err ErrInvalidTimeEntryFilterField) Error() string {
|
||||||
|
return fmt.Sprintf("Time entry filter field is invalid [Field: %s]", err.Field)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrCodeInvalidTimeEntryFilterField holds the unique world-error code of this error
|
||||||
|
const ErrCodeInvalidTimeEntryFilterField = 18003
|
||||||
|
|
||||||
|
// HTTPError holds the http error description
|
||||||
|
func (err ErrInvalidTimeEntryFilterField) HTTPError() web.HTTPError {
|
||||||
|
return web.HTTPError{
|
||||||
|
HTTPCode: http.StatusBadRequest,
|
||||||
|
Code: ErrCodeInvalidTimeEntryFilterField,
|
||||||
|
Message: fmt.Sprintf("The time entry filter field '%s' is invalid. Filterable fields are user_id, task_id, project_id, start_time and end_time.", err.Field),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrInvalidTimeEntryFilterValue represents an error where a time entry filter value cannot be parsed for its field
|
||||||
|
type ErrInvalidTimeEntryFilterValue struct {
|
||||||
|
Field string
|
||||||
|
Value string
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsErrInvalidTimeEntryFilterValue checks if an error is ErrInvalidTimeEntryFilterValue.
|
||||||
|
func IsErrInvalidTimeEntryFilterValue(err error) bool {
|
||||||
|
_, ok := err.(ErrInvalidTimeEntryFilterValue)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err ErrInvalidTimeEntryFilterValue) Error() string {
|
||||||
|
return fmt.Sprintf("Time entry filter value is invalid [Field: %s, Value: %s]", err.Field, err.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrCodeInvalidTimeEntryFilterValue holds the unique world-error code of this error
|
||||||
|
const ErrCodeInvalidTimeEntryFilterValue = 18004
|
||||||
|
|
||||||
|
// HTTPError holds the http error description
|
||||||
|
func (err ErrInvalidTimeEntryFilterValue) HTTPError() web.HTTPError {
|
||||||
|
return web.HTTPError{
|
||||||
|
HTTPCode: http.StatusBadRequest,
|
||||||
|
Code: ErrCodeInvalidTimeEntryFilterValue,
|
||||||
|
Message: fmt.Sprintf("The value '%s' is not valid for the time entry filter field '%s'.", err.Value, err.Field),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrNoRunningTimer represents an error where a user has no running timer to act on
|
||||||
|
type ErrNoRunningTimer struct {
|
||||||
|
UserID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsErrNoRunningTimer checks if an error is ErrNoRunningTimer.
|
||||||
|
func IsErrNoRunningTimer(err error) bool {
|
||||||
|
_, ok := err.(ErrNoRunningTimer)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err ErrNoRunningTimer) Error() string {
|
||||||
|
return fmt.Sprintf("No running timer [UserID: %d]", err.UserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrCodeNoRunningTimer holds the unique world-error code of this error
|
||||||
|
const ErrCodeNoRunningTimer = 18005
|
||||||
|
|
||||||
|
// HTTPError holds the http error description
|
||||||
|
func (err ErrNoRunningTimer) HTTPError() web.HTTPError {
|
||||||
|
return web.HTTPError{
|
||||||
|
HTTPCode: http.StatusNotFound,
|
||||||
|
Code: ErrCodeNoRunningTimer,
|
||||||
|
Message: "You do not have a running timer.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrTimeEntryAlreadyEnded represents an error where an update tries to clear the
|
||||||
|
// end time of an entry that has already ended (reopening it as a running timer).
|
||||||
|
type ErrTimeEntryAlreadyEnded struct {
|
||||||
|
TimeEntryID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsErrTimeEntryAlreadyEnded checks if an error is ErrTimeEntryAlreadyEnded.
|
||||||
|
func IsErrTimeEntryAlreadyEnded(err error) bool {
|
||||||
|
_, ok := err.(ErrTimeEntryAlreadyEnded)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err ErrTimeEntryAlreadyEnded) Error() string {
|
||||||
|
return fmt.Sprintf("Time entry has already ended and cannot be reopened [TimeEntryID: %v]", err.TimeEntryID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrCodeTimeEntryAlreadyEnded holds the unique world-error code of this error
|
||||||
|
const ErrCodeTimeEntryAlreadyEnded = 18006
|
||||||
|
|
||||||
|
// HTTPError holds the http error description
|
||||||
|
func (err ErrTimeEntryAlreadyEnded) HTTPError() web.HTTPError {
|
||||||
|
return web.HTTPError{
|
||||||
|
HTTPCode: http.StatusBadRequest,
|
||||||
|
Code: ErrCodeTimeEntryAlreadyEnded,
|
||||||
|
Message: "A time entry that has already ended cannot be reopened into a running timer. Start a new timer instead.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,423 @@
|
||||||
|
// Vikunja is a to-do list application to facilitate your life.
|
||||||
|
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public 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 Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.vikunja.io/api/pkg/db"
|
||||||
|
"code.vikunja.io/api/pkg/events"
|
||||||
|
"code.vikunja.io/api/pkg/license"
|
||||||
|
"code.vikunja.io/api/pkg/user"
|
||||||
|
"code.vikunja.io/api/pkg/web"
|
||||||
|
"xorm.io/builder"
|
||||||
|
"xorm.io/xorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TimeEntry is a single tracked time span attached to either a task or a
|
||||||
|
// project — exactly one of TaskID / ProjectID is set (XOR). A running live
|
||||||
|
// timer is just an entry whose EndTime is still null.
|
||||||
|
//
|
||||||
|
// v2-only: doc: tags are the schema's source of truth (no v1 swaggo), and it
|
||||||
|
// implements CRUDable + Permissions because the shared handler.Do* pipeline needs them.
|
||||||
|
type TimeEntry struct {
|
||||||
|
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"timeentry" readOnly:"true" doc:"The unique, numeric id of this time entry."`
|
||||||
|
|
||||||
|
UserID int64 `xorm:"bigint not null INDEX" json:"user_id" readOnly:"true" doc:"The id of the user who logged this time entry. Set by the server."`
|
||||||
|
|
||||||
|
TaskID int64 `xorm:"bigint null INDEX" json:"task_id" doc:"The task this entry is attached to. Exactly one of task_id / project_id must be set."`
|
||||||
|
ProjectID int64 `xorm:"bigint null INDEX" json:"project_id" doc:"The project this entry is attached to directly. Exactly one of task_id / project_id must be set."`
|
||||||
|
|
||||||
|
StartTime time.Time `xorm:"not null INDEX" json:"start_time" doc:"When the tracked time started."`
|
||||||
|
EndTime *time.Time `xorm:"null" json:"end_time" doc:"When the tracked time ended. Null means a live timer is still running."`
|
||||||
|
|
||||||
|
Comment string `xorm:"text null" json:"comment" doc:"An optional comment describing the logged time."`
|
||||||
|
|
||||||
|
Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"A timestamp when this time entry was created. You cannot change this value."`
|
||||||
|
Updated time.Time `xorm:"updated not null" json:"updated" readOnly:"true" doc:"A timestamp when this time entry was last updated. You cannot change this value."`
|
||||||
|
|
||||||
|
// Filter-only fields (not persisted): set by the v2 list route, read by ReadAll.
|
||||||
|
Filter string `xorm:"-" json:"-"`
|
||||||
|
FilterTimezone string `xorm:"-" json:"-"`
|
||||||
|
|
||||||
|
web.CRUDable `xorm:"-" json:"-"`
|
||||||
|
web.Permissions `xorm:"-" json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName is time_entries, not the xorm-default time_entry.
|
||||||
|
func (*TimeEntry) TableName() string {
|
||||||
|
return "time_entries"
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CRUDable ---
|
||||||
|
|
||||||
|
func (te *TimeEntry) Create(s *xorm.Session, a web.Auth) (err error) {
|
||||||
|
te.UserID = a.GetID()
|
||||||
|
|
||||||
|
// Starting a new running timer auto-stops the previous one; a completed
|
||||||
|
// manual entry (EndTime set) must leave the running timer alone.
|
||||||
|
if te.EndTime == nil {
|
||||||
|
if _, err = stopRunningTimerForUser(s, te.UserID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if te.StartTime.IsZero() {
|
||||||
|
te.StartTime = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err = s.Insert(te); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
doer, err := user.GetFromAuth(a)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
events.DispatchOnCommit(s, &TimeEntryCreatedEvent{TimeEntry: te, Doer: doer})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (te *TimeEntry) ReadOne(_ *xorm.Session, _ web.Auth) (err error) {
|
||||||
|
// entry got already fetched in CanRead, nothing left to do here
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stopRunningTimerForUser stops the user's active timer (end_time = now) and
|
||||||
|
// returns it, or nil if no timer is running.
|
||||||
|
func stopRunningTimerForUser(s *xorm.Session, userID int64) (*TimeEntry, error) {
|
||||||
|
running := &TimeEntry{}
|
||||||
|
exists, err := s.Where("user_id = ? AND end_time IS NULL", userID).Get(running)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err := running.stop(s); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
doer, err := user.GetUserByID(s, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
events.DispatchOnCommit(s, &TimeEntryUpdatedEvent{TimeEntry: running, Doer: doer})
|
||||||
|
return running, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopRunningTimer stops the authenticated user's active timer and returns it,
|
||||||
|
// or ErrNoRunningTimer when none is running. The stop time is the server's now.
|
||||||
|
func StopRunningTimer(s *xorm.Session, a web.Auth) (*TimeEntry, error) {
|
||||||
|
// Link shares have no time tracking (mirrors the Can* methods). Their id is a
|
||||||
|
// share id, not a user id, so without this a share whose id collides with a
|
||||||
|
// user's would stop and read that user's running timer.
|
||||||
|
if _, isShare := a.(*LinkSharing); isShare {
|
||||||
|
return nil, ErrGenericForbidden{}
|
||||||
|
}
|
||||||
|
|
||||||
|
running, err := stopRunningTimerForUser(s, a.GetID())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if running == nil {
|
||||||
|
return nil, ErrNoRunningTimer{UserID: a.GetID()}
|
||||||
|
}
|
||||||
|
return running, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readableTimeEntriesCond restricts a query to entries the auth can read: a
|
||||||
|
// standalone entry on an accessible project, or one on a task in such a project.
|
||||||
|
func readableTimeEntriesCond(a web.Auth) builder.Cond {
|
||||||
|
return entriesForProjectCond(accessibleProjectIDsSubquery(a, "project_id"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (te *TimeEntry) ReadAll(s *xorm.Session, a web.Auth, search string, page int, perPage int) (result any, resultCount int, numberOfTotalItems int64, err error) {
|
||||||
|
// Link shares have no time-tracking access (mirrors the Can* methods);
|
||||||
|
// DoReadAll skips the permission check, so it must be guarded here too.
|
||||||
|
if _, isShareAuth := a.(*LinkSharing); isShareAuth {
|
||||||
|
return []*TimeEntry{}, 0, 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cond := readableTimeEntriesCond(a)
|
||||||
|
if te.TaskID > 0 {
|
||||||
|
cond = cond.And(builder.Eq{"task_id": te.TaskID})
|
||||||
|
}
|
||||||
|
if te.ProjectID > 0 {
|
||||||
|
cond = cond.And(entriesForProjectCond(builder.Eq{"project_id": te.ProjectID}))
|
||||||
|
}
|
||||||
|
|
||||||
|
filterCond, err := timeEntryFilterCond(te.Filter, te.FilterTimezone)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, 0, err
|
||||||
|
}
|
||||||
|
if filterCond != nil {
|
||||||
|
cond = cond.And(filterCond)
|
||||||
|
}
|
||||||
|
|
||||||
|
if search != "" {
|
||||||
|
cond = cond.And(db.MultiFieldSearch([]string{"comment"}, search))
|
||||||
|
}
|
||||||
|
|
||||||
|
total, err := s.Where(cond).
|
||||||
|
Count(&TimeEntry{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := []*TimeEntry{}
|
||||||
|
err = s.Where(cond).
|
||||||
|
OrderBy("start_time ASC").
|
||||||
|
Limit(getLimitFromPageIndex(page, perPage)).
|
||||||
|
Find(&entries)
|
||||||
|
return entries, len(entries), total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (te *TimeEntry) Update(s *xorm.Session, a web.Auth) (err error) {
|
||||||
|
// A completed entry can't be reopened into a running timer via update — that
|
||||||
|
// would sidestep Create's single-active-timer rule; start a new one instead.
|
||||||
|
existing, err := getTimeEntryByID(s, te.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if existing.EndTime != nil && te.EndTime == nil {
|
||||||
|
return ErrTimeEntryAlreadyEnded{TimeEntryID: te.ID}
|
||||||
|
}
|
||||||
|
|
||||||
|
// task_id / project_id are listed so a reassignment (and the zero value of
|
||||||
|
// the side being cleared) is written; the XOR was validated in CanUpdate.
|
||||||
|
_, err = s.
|
||||||
|
Where("id = ?", te.ID).
|
||||||
|
Cols("task_id", "project_id", "start_time", "end_time", "comment").
|
||||||
|
Update(te)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// reload: Update wrote only the editable columns
|
||||||
|
updated, err := getTimeEntryByID(s, te.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*te = *updated
|
||||||
|
|
||||||
|
doer, err := user.GetFromAuth(a)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
events.DispatchOnCommit(s, &TimeEntryUpdatedEvent{TimeEntry: te, Doer: doer})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (te *TimeEntry) Delete(s *xorm.Session, a web.Auth) (err error) {
|
||||||
|
entry, err := getTimeEntryByID(s, te.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err = s.Where("id = ?", te.ID).Delete(&TimeEntry{}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
doer, err := user.GetFromAuth(a)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
events.DispatchOnCommit(s, &TimeEntryDeletedEvent{TimeEntry: entry, Doer: doer})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTimeEntryByID(s *xorm.Session, id int64) (*TimeEntry, error) {
|
||||||
|
entry := &TimeEntry{}
|
||||||
|
exists, err := s.Where("id = ?", id).Get(entry)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return nil, ErrTimeEntryDoesNotExist{TimeEntryID: id}
|
||||||
|
}
|
||||||
|
return entry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (te *TimeEntry) stop(s *xorm.Session) (err error) {
|
||||||
|
now := time.Now()
|
||||||
|
te.EndTime = &now
|
||||||
|
_, err = s.ID(te.ID).Update(te)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Permissions ---
|
||||||
|
|
||||||
|
// Returns the loaded entry rather than mutating te, so Update keeps its payload.
|
||||||
|
func (te *TimeEntry) canDoTimeEntry(s *xorm.Session, a web.Auth, fetch bool) (*TimeEntry, bool, int, error) {
|
||||||
|
entry := &TimeEntry{TaskID: te.TaskID, ProjectID: te.ProjectID}
|
||||||
|
if fetch {
|
||||||
|
var err error
|
||||||
|
entry, err = getTimeEntryByID(s, te.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, -1, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case entry.TaskID != 0:
|
||||||
|
task, err := GetTaskByIDSimple(s, entry.TaskID)
|
||||||
|
if err != nil {
|
||||||
|
return entry, false, -1, err
|
||||||
|
}
|
||||||
|
can, maxPerm, err := task.CanRead(s, a)
|
||||||
|
return entry, can, maxPerm, err
|
||||||
|
case entry.ProjectID != 0:
|
||||||
|
project, _, err := getProjectSimple(s, builder.Eq{"id": entry.ProjectID})
|
||||||
|
if err != nil {
|
||||||
|
return entry, false, -1, err
|
||||||
|
}
|
||||||
|
can, maxPerm, err := project.CanRead(s, a)
|
||||||
|
return entry, can, maxPerm, err
|
||||||
|
default:
|
||||||
|
return entry, false, 0, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (te *TimeEntry) CanRead(s *xorm.Session, a web.Auth) (bool, int, error) {
|
||||||
|
if _, isShareAuth := a.(*LinkSharing); isShareAuth {
|
||||||
|
return false, 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
entry, can, maxPerm, err := te.canDoTimeEntry(s, a, true)
|
||||||
|
if err != nil {
|
||||||
|
return false, maxPerm, err
|
||||||
|
}
|
||||||
|
*te = *entry // ReadOne is a no-op; populate te here
|
||||||
|
return can, maxPerm, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateContainer enforces the XOR invariant: exactly one of task or project.
|
||||||
|
func (te *TimeEntry) validateContainer() error {
|
||||||
|
if (te.TaskID == 0) == (te.ProjectID == 0) {
|
||||||
|
return ErrTimeEntryInvalidContainer{TaskID: te.TaskID, ProjectID: te.ProjectID}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (te *TimeEntry) CanCreate(s *xorm.Session, a web.Auth) (bool, error) {
|
||||||
|
if _, isShareAuth := a.(*LinkSharing); isShareAuth {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := te.validateContainer(); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, can, _, err := te.canDoTimeEntry(s, a, false)
|
||||||
|
return can, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanUpdate allows the author to edit their entry, including moving it between
|
||||||
|
// task / project: on top of the author check it validates the (possibly new)
|
||||||
|
// container (XOR) and requires read access to it, mirroring create.
|
||||||
|
func (te *TimeEntry) CanUpdate(s *xorm.Session, a web.Auth) (bool, error) {
|
||||||
|
if _, isShareAuth := a.(*LinkSharing); isShareAuth {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, err := getTimeEntryByID(s, te.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if existing.UserID != a.GetID() {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// A request that omits the container keeps the existing one — an entry
|
||||||
|
// always has exactly one, so "clearing" it is never valid.
|
||||||
|
if te.TaskID == 0 && te.ProjectID == 0 {
|
||||||
|
te.TaskID = existing.TaskID
|
||||||
|
te.ProjectID = existing.ProjectID
|
||||||
|
}
|
||||||
|
if err := te.validateContainer(); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, canReadContainer, _, err := te.canDoTimeEntry(s, a, false)
|
||||||
|
return canReadContainer, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (te *TimeEntry) CanDelete(s *xorm.Session, a web.Auth) (bool, error) {
|
||||||
|
return te.canModify(s, a)
|
||||||
|
}
|
||||||
|
|
||||||
|
// canModify gates delete: read access to the container plus being the author.
|
||||||
|
func (te *TimeEntry) canModify(s *xorm.Session, a web.Auth) (bool, error) {
|
||||||
|
if _, isShareAuth := a.(*LinkSharing); isShareAuth {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
entry, canRead, _, err := te.canDoTimeEntry(s, a, true)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !canRead {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return entry.UserID == a.GetID(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// addTimeEntriesCountToTasks attaches each task's time-entry count for the
|
||||||
|
// `time_entries_count` expand. Mirrors addCommentCountToTasks, but follows the
|
||||||
|
// same gates as the time-entry endpoints: the count is left unset (absent) for
|
||||||
|
// link shares or when the feature is unlicensed, so it can't leak that way.
|
||||||
|
func addTimeEntriesCountToTasks(s *xorm.Session, a web.Auth, taskIDs []int64, taskMap map[int64]*Task) error {
|
||||||
|
if _, isShare := a.(*LinkSharing); isShare {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !license.IsFeatureEnabled(license.FeatureTimeTracking) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(taskIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
zero := int64(0)
|
||||||
|
for _, taskID := range taskIDs {
|
||||||
|
if task, ok := taskMap[taskID]; ok {
|
||||||
|
task.TimeEntriesCount = &zero
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type timeEntriesCount struct {
|
||||||
|
TaskID int64 `xorm:"task_id"`
|
||||||
|
Count int64 `xorm:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
counts := []timeEntriesCount{}
|
||||||
|
if err := s.
|
||||||
|
Select("task_id, COUNT(*) as count").
|
||||||
|
Where(builder.In("task_id", taskIDs)).
|
||||||
|
GroupBy("task_id").
|
||||||
|
Table("time_entries").
|
||||||
|
Find(&counts); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range counts {
|
||||||
|
if task, ok := taskMap[c.TaskID]; ok {
|
||||||
|
task.TimeEntriesCount = &c.Count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue