code.gitea.io/gitea@v1.22.3/models/issues/tracked_time.go (about)

     1  // Copyright 2017 The Gitea Authors. All rights reserved.
     2  // SPDX-License-Identifier: MIT
     3  
     4  package issues
     5  
     6  import (
     7  	"context"
     8  	"errors"
     9  	"fmt"
    10  	"time"
    11  
    12  	"code.gitea.io/gitea/models/db"
    13  	user_model "code.gitea.io/gitea/models/user"
    14  	"code.gitea.io/gitea/modules/optional"
    15  	"code.gitea.io/gitea/modules/setting"
    16  	"code.gitea.io/gitea/modules/util"
    17  
    18  	"xorm.io/builder"
    19  	"xorm.io/xorm"
    20  )
    21  
    22  // TrackedTime represents a time that was spent for a specific issue.
    23  type TrackedTime struct {
    24  	ID          int64            `xorm:"pk autoincr"`
    25  	IssueID     int64            `xorm:"INDEX"`
    26  	Issue       *Issue           `xorm:"-"`
    27  	UserID      int64            `xorm:"INDEX"`
    28  	User        *user_model.User `xorm:"-"`
    29  	Created     time.Time        `xorm:"-"`
    30  	CreatedUnix int64            `xorm:"created"`
    31  	Time        int64            `xorm:"NOT NULL"`
    32  	Deleted     bool             `xorm:"NOT NULL DEFAULT false"`
    33  }
    34  
    35  func init() {
    36  	db.RegisterModel(new(TrackedTime))
    37  }
    38  
    39  // TrackedTimeList is a List of TrackedTime's
    40  type TrackedTimeList []*TrackedTime
    41  
    42  // AfterLoad is invoked from XORM after setting the values of all fields of this object.
    43  func (t *TrackedTime) AfterLoad() {
    44  	t.Created = time.Unix(t.CreatedUnix, 0).In(setting.DefaultUILocation)
    45  }
    46  
    47  // LoadAttributes load Issue, User
    48  func (t *TrackedTime) LoadAttributes(ctx context.Context) (err error) {
    49  	// Load the issue
    50  	if t.Issue == nil {
    51  		t.Issue, err = GetIssueByID(ctx, t.IssueID)
    52  		if err != nil && !errors.Is(err, util.ErrNotExist) {
    53  			return err
    54  		}
    55  	}
    56  	// Now load the repo for the issue (which we may have just loaded)
    57  	if t.Issue != nil {
    58  		err = t.Issue.LoadRepo(ctx)
    59  		if err != nil && !errors.Is(err, util.ErrNotExist) {
    60  			return err
    61  		}
    62  	}
    63  	// Load the user
    64  	if t.User == nil {
    65  		t.User, err = user_model.GetUserByID(ctx, t.UserID)
    66  		if err != nil {
    67  			if !errors.Is(err, util.ErrNotExist) {
    68  				return err
    69  			}
    70  			t.User = user_model.NewGhostUser()
    71  		}
    72  	}
    73  	return nil
    74  }
    75  
    76  // LoadAttributes load Issue, User
    77  func (tl TrackedTimeList) LoadAttributes(ctx context.Context) error {
    78  	for _, t := range tl {
    79  		if err := t.LoadAttributes(ctx); err != nil {
    80  			return err
    81  		}
    82  	}
    83  	return nil
    84  }
    85  
    86  // FindTrackedTimesOptions represent the filters for tracked times. If an ID is 0 it will be ignored.
    87  type FindTrackedTimesOptions struct {
    88  	db.ListOptions
    89  	IssueID           int64
    90  	UserID            int64
    91  	RepositoryID      int64
    92  	MilestoneID       int64
    93  	CreatedAfterUnix  int64
    94  	CreatedBeforeUnix int64
    95  }
    96  
    97  // toCond will convert each condition into a xorm-Cond
    98  func (opts *FindTrackedTimesOptions) ToConds() builder.Cond {
    99  	cond := builder.NewCond().And(builder.Eq{"tracked_time.deleted": false})
   100  	if opts.IssueID != 0 {
   101  		cond = cond.And(builder.Eq{"issue_id": opts.IssueID})
   102  	}
   103  	if opts.UserID != 0 {
   104  		cond = cond.And(builder.Eq{"user_id": opts.UserID})
   105  	}
   106  	if opts.RepositoryID != 0 {
   107  		cond = cond.And(builder.Eq{"issue.repo_id": opts.RepositoryID})
   108  	}
   109  	if opts.MilestoneID != 0 {
   110  		cond = cond.And(builder.Eq{"issue.milestone_id": opts.MilestoneID})
   111  	}
   112  	if opts.CreatedAfterUnix != 0 {
   113  		cond = cond.And(builder.Gte{"tracked_time.created_unix": opts.CreatedAfterUnix})
   114  	}
   115  	if opts.CreatedBeforeUnix != 0 {
   116  		cond = cond.And(builder.Lte{"tracked_time.created_unix": opts.CreatedBeforeUnix})
   117  	}
   118  	return cond
   119  }
   120  
   121  func (opts *FindTrackedTimesOptions) ToJoins() []db.JoinFunc {
   122  	if opts.RepositoryID > 0 || opts.MilestoneID > 0 {
   123  		return []db.JoinFunc{
   124  			func(e db.Engine) error {
   125  				e.Join("INNER", "issue", "issue.id = tracked_time.issue_id")
   126  				return nil
   127  			},
   128  		}
   129  	}
   130  	return nil
   131  }
   132  
   133  // toSession will convert the given options to a xorm Session by using the conditions from toCond and joining with issue table if required
   134  func (opts *FindTrackedTimesOptions) toSession(e db.Engine) db.Engine {
   135  	sess := e
   136  	if opts.RepositoryID > 0 || opts.MilestoneID > 0 {
   137  		sess = e.Join("INNER", "issue", "issue.id = tracked_time.issue_id")
   138  	}
   139  
   140  	sess = sess.Where(opts.ToConds())
   141  
   142  	if opts.Page != 0 {
   143  		sess = db.SetSessionPagination(sess, opts)
   144  	}
   145  
   146  	return sess
   147  }
   148  
   149  // GetTrackedTimes returns all tracked times that fit to the given options.
   150  func GetTrackedTimes(ctx context.Context, options *FindTrackedTimesOptions) (trackedTimes TrackedTimeList, err error) {
   151  	err = options.toSession(db.GetEngine(ctx)).Find(&trackedTimes)
   152  	return trackedTimes, err
   153  }
   154  
   155  // CountTrackedTimes returns count of tracked times that fit to the given options.
   156  func CountTrackedTimes(ctx context.Context, opts *FindTrackedTimesOptions) (int64, error) {
   157  	sess := db.GetEngine(ctx).Where(opts.ToConds())
   158  	if opts.RepositoryID > 0 || opts.MilestoneID > 0 {
   159  		sess = sess.Join("INNER", "issue", "issue.id = tracked_time.issue_id")
   160  	}
   161  	return sess.Count(&TrackedTime{})
   162  }
   163  
   164  // GetTrackedSeconds return sum of seconds
   165  func GetTrackedSeconds(ctx context.Context, opts FindTrackedTimesOptions) (trackedSeconds int64, err error) {
   166  	return opts.toSession(db.GetEngine(ctx)).SumInt(&TrackedTime{}, "time")
   167  }
   168  
   169  // AddTime will add the given time (in seconds) to the issue
   170  func AddTime(ctx context.Context, user *user_model.User, issue *Issue, amount int64, created time.Time) (*TrackedTime, error) {
   171  	ctx, committer, err := db.TxContext(ctx)
   172  	if err != nil {
   173  		return nil, err
   174  	}
   175  	defer committer.Close()
   176  
   177  	t, err := addTime(ctx, user, issue, amount, created)
   178  	if err != nil {
   179  		return nil, err
   180  	}
   181  
   182  	if err := issue.LoadRepo(ctx); err != nil {
   183  		return nil, err
   184  	}
   185  
   186  	if _, err := CreateComment(ctx, &CreateCommentOptions{
   187  		Issue: issue,
   188  		Repo:  issue.Repo,
   189  		Doer:  user,
   190  		// Content before v1.21 did store the formatted string instead of seconds,
   191  		// so use "|" as delimiter to mark the new format
   192  		Content: fmt.Sprintf("|%d", amount),
   193  		Type:    CommentTypeAddTimeManual,
   194  		TimeID:  t.ID,
   195  	}); err != nil {
   196  		return nil, err
   197  	}
   198  
   199  	return t, committer.Commit()
   200  }
   201  
   202  func addTime(ctx context.Context, user *user_model.User, issue *Issue, amount int64, created time.Time) (*TrackedTime, error) {
   203  	if created.IsZero() {
   204  		created = time.Now()
   205  	}
   206  	tt := &TrackedTime{
   207  		IssueID: issue.ID,
   208  		UserID:  user.ID,
   209  		Time:    amount,
   210  		Created: created,
   211  	}
   212  	return tt, db.Insert(ctx, tt)
   213  }
   214  
   215  // TotalTimesForEachUser returns the spent time in seconds for each user by an issue
   216  func TotalTimesForEachUser(ctx context.Context, options *FindTrackedTimesOptions) (map[*user_model.User]int64, error) {
   217  	trackedTimes, err := GetTrackedTimes(ctx, options)
   218  	if err != nil {
   219  		return nil, err
   220  	}
   221  	// Adding total time per user ID
   222  	totalTimesByUser := make(map[int64]int64)
   223  	for _, t := range trackedTimes {
   224  		totalTimesByUser[t.UserID] += t.Time
   225  	}
   226  
   227  	totalTimes := make(map[*user_model.User]int64)
   228  	// Fetching User and making time human readable
   229  	for userID, total := range totalTimesByUser {
   230  		user, err := user_model.GetUserByID(ctx, userID)
   231  		if err != nil {
   232  			if user_model.IsErrUserNotExist(err) {
   233  				continue
   234  			}
   235  			return nil, err
   236  		}
   237  		totalTimes[user] = total
   238  	}
   239  	return totalTimes, nil
   240  }
   241  
   242  // DeleteIssueUserTimes deletes times for issue
   243  func DeleteIssueUserTimes(ctx context.Context, issue *Issue, user *user_model.User) error {
   244  	ctx, committer, err := db.TxContext(ctx)
   245  	if err != nil {
   246  		return err
   247  	}
   248  	defer committer.Close()
   249  
   250  	opts := FindTrackedTimesOptions{
   251  		IssueID: issue.ID,
   252  		UserID:  user.ID,
   253  	}
   254  
   255  	removedTime, err := deleteTimes(ctx, opts)
   256  	if err != nil {
   257  		return err
   258  	}
   259  	if removedTime == 0 {
   260  		return db.ErrNotExist{Resource: "tracked_time"}
   261  	}
   262  
   263  	if err := issue.LoadRepo(ctx); err != nil {
   264  		return err
   265  	}
   266  	if _, err := CreateComment(ctx, &CreateCommentOptions{
   267  		Issue: issue,
   268  		Repo:  issue.Repo,
   269  		Doer:  user,
   270  		// Content before v1.21 did store the formatted string instead of seconds,
   271  		// so use "|" as delimiter to mark the new format
   272  		Content: fmt.Sprintf("|%d", removedTime),
   273  		Type:    CommentTypeDeleteTimeManual,
   274  	}); err != nil {
   275  		return err
   276  	}
   277  
   278  	return committer.Commit()
   279  }
   280  
   281  // DeleteTime delete a specific Time
   282  func DeleteTime(ctx context.Context, t *TrackedTime) error {
   283  	ctx, committer, err := db.TxContext(ctx)
   284  	if err != nil {
   285  		return err
   286  	}
   287  	defer committer.Close()
   288  
   289  	if err := t.LoadAttributes(ctx); err != nil {
   290  		return err
   291  	}
   292  
   293  	if err := deleteTime(ctx, t); err != nil {
   294  		return err
   295  	}
   296  
   297  	if _, err := CreateComment(ctx, &CreateCommentOptions{
   298  		Issue: t.Issue,
   299  		Repo:  t.Issue.Repo,
   300  		Doer:  t.User,
   301  		// Content before v1.21 did store the formatted string instead of seconds,
   302  		// so use "|" as delimiter to mark the new format
   303  		Content: fmt.Sprintf("|%d", t.Time),
   304  		Type:    CommentTypeDeleteTimeManual,
   305  	}); err != nil {
   306  		return err
   307  	}
   308  
   309  	return committer.Commit()
   310  }
   311  
   312  func deleteTimes(ctx context.Context, opts FindTrackedTimesOptions) (removedTime int64, err error) {
   313  	removedTime, err = GetTrackedSeconds(ctx, opts)
   314  	if err != nil || removedTime == 0 {
   315  		return removedTime, err
   316  	}
   317  
   318  	_, err = opts.toSession(db.GetEngine(ctx)).Table("tracked_time").Cols("deleted").Update(&TrackedTime{Deleted: true})
   319  	return removedTime, err
   320  }
   321  
   322  func deleteTime(ctx context.Context, t *TrackedTime) error {
   323  	if t.Deleted {
   324  		return db.ErrNotExist{Resource: "tracked_time", ID: t.ID}
   325  	}
   326  	t.Deleted = true
   327  	_, err := db.GetEngine(ctx).ID(t.ID).Cols("deleted").Update(t)
   328  	return err
   329  }
   330  
   331  // GetTrackedTimeByID returns raw TrackedTime without loading attributes by id
   332  func GetTrackedTimeByID(ctx context.Context, id int64) (*TrackedTime, error) {
   333  	time := new(TrackedTime)
   334  	has, err := db.GetEngine(ctx).ID(id).Get(time)
   335  	if err != nil {
   336  		return nil, err
   337  	} else if !has {
   338  		return nil, db.ErrNotExist{Resource: "tracked_time", ID: id}
   339  	}
   340  	return time, nil
   341  }
   342  
   343  // GetIssueTotalTrackedTime returns the total tracked time for issues by given conditions.
   344  func GetIssueTotalTrackedTime(ctx context.Context, opts *IssuesOptions, isClosed optional.Option[bool]) (int64, error) {
   345  	if len(opts.IssueIDs) <= MaxQueryParameters {
   346  		return getIssueTotalTrackedTimeChunk(ctx, opts, isClosed, opts.IssueIDs)
   347  	}
   348  
   349  	// If too long a list of IDs is provided,
   350  	// we get the statistics in smaller chunks and get accumulates
   351  	var accum int64
   352  	for i := 0; i < len(opts.IssueIDs); {
   353  		chunk := i + MaxQueryParameters
   354  		if chunk > len(opts.IssueIDs) {
   355  			chunk = len(opts.IssueIDs)
   356  		}
   357  		time, err := getIssueTotalTrackedTimeChunk(ctx, opts, isClosed, opts.IssueIDs[i:chunk])
   358  		if err != nil {
   359  			return 0, err
   360  		}
   361  		accum += time
   362  		i = chunk
   363  	}
   364  	return accum, nil
   365  }
   366  
   367  func getIssueTotalTrackedTimeChunk(ctx context.Context, opts *IssuesOptions, isClosed optional.Option[bool], issueIDs []int64) (int64, error) {
   368  	sumSession := func(opts *IssuesOptions, issueIDs []int64) *xorm.Session {
   369  		sess := db.GetEngine(ctx).
   370  			Table("tracked_time").
   371  			Where("tracked_time.deleted = ?", false).
   372  			Join("INNER", "issue", "tracked_time.issue_id = issue.id")
   373  
   374  		return applyIssuesOptions(sess, opts, issueIDs)
   375  	}
   376  
   377  	type trackedTime struct {
   378  		Time int64
   379  	}
   380  
   381  	session := sumSession(opts, issueIDs)
   382  	if isClosed.Has() {
   383  		session = session.And("issue.is_closed = ?", isClosed.Value())
   384  	}
   385  	return session.SumInt(new(trackedTime), "tracked_time.time")
   386  }