1// Copyright 2017 The Gitea Authors. All rights reserved.
2// Use of this source code is governed by a MIT-style
3// license that can be found in the LICENSE file.
4
5package models
6
7import (
8	"context"
9	"time"
10
11	"code.gitea.io/gitea/models/db"
12	user_model "code.gitea.io/gitea/models/user"
13	"code.gitea.io/gitea/modules/setting"
14
15	"xorm.io/builder"
16)
17
18// TrackedTime represents a time that was spent for a specific issue.
19type TrackedTime struct {
20	ID          int64            `xorm:"pk autoincr"`
21	IssueID     int64            `xorm:"INDEX"`
22	Issue       *Issue           `xorm:"-"`
23	UserID      int64            `xorm:"INDEX"`
24	User        *user_model.User `xorm:"-"`
25	Created     time.Time        `xorm:"-"`
26	CreatedUnix int64            `xorm:"created"`
27	Time        int64            `xorm:"NOT NULL"`
28	Deleted     bool             `xorm:"NOT NULL DEFAULT false"`
29}
30
31func init() {
32	db.RegisterModel(new(TrackedTime))
33}
34
35// TrackedTimeList is a List of TrackedTime's
36type TrackedTimeList []*TrackedTime
37
38// AfterLoad is invoked from XORM after setting the values of all fields of this object.
39func (t *TrackedTime) AfterLoad() {
40	t.Created = time.Unix(t.CreatedUnix, 0).In(setting.DefaultUILocation)
41}
42
43// LoadAttributes load Issue, User
44func (t *TrackedTime) LoadAttributes() (err error) {
45	return t.loadAttributes(db.DefaultContext)
46}
47
48func (t *TrackedTime) loadAttributes(ctx context.Context) (err error) {
49	e := db.GetEngine(ctx)
50	if t.Issue == nil {
51		t.Issue, err = getIssueByID(e, t.IssueID)
52		if err != nil {
53			return
54		}
55		err = t.Issue.loadRepo(ctx)
56		if err != nil {
57			return
58		}
59	}
60	if t.User == nil {
61		t.User, err = user_model.GetUserByIDEngine(e, t.UserID)
62		if err != nil {
63			return
64		}
65	}
66	return
67}
68
69// LoadAttributes load Issue, User
70func (tl TrackedTimeList) LoadAttributes() (err error) {
71	for _, t := range tl {
72		if err = t.LoadAttributes(); err != nil {
73			return err
74		}
75	}
76	return
77}
78
79// FindTrackedTimesOptions represent the filters for tracked times. If an ID is 0 it will be ignored.
80type FindTrackedTimesOptions struct {
81	db.ListOptions
82	IssueID           int64
83	UserID            int64
84	RepositoryID      int64
85	MilestoneID       int64
86	CreatedAfterUnix  int64
87	CreatedBeforeUnix int64
88}
89
90// toCond will convert each condition into a xorm-Cond
91func (opts *FindTrackedTimesOptions) toCond() builder.Cond {
92	cond := builder.NewCond().And(builder.Eq{"tracked_time.deleted": false})
93	if opts.IssueID != 0 {
94		cond = cond.And(builder.Eq{"issue_id": opts.IssueID})
95	}
96	if opts.UserID != 0 {
97		cond = cond.And(builder.Eq{"user_id": opts.UserID})
98	}
99	if opts.RepositoryID != 0 {
100		cond = cond.And(builder.Eq{"issue.repo_id": opts.RepositoryID})
101	}
102	if opts.MilestoneID != 0 {
103		cond = cond.And(builder.Eq{"issue.milestone_id": opts.MilestoneID})
104	}
105	if opts.CreatedAfterUnix != 0 {
106		cond = cond.And(builder.Gte{"tracked_time.created_unix": opts.CreatedAfterUnix})
107	}
108	if opts.CreatedBeforeUnix != 0 {
109		cond = cond.And(builder.Lte{"tracked_time.created_unix": opts.CreatedBeforeUnix})
110	}
111	return cond
112}
113
114// toSession will convert the given options to a xorm Session by using the conditions from toCond and joining with issue table if required
115func (opts *FindTrackedTimesOptions) toSession(e db.Engine) db.Engine {
116	sess := e
117	if opts.RepositoryID > 0 || opts.MilestoneID > 0 {
118		sess = e.Join("INNER", "issue", "issue.id = tracked_time.issue_id")
119	}
120
121	sess = sess.Where(opts.toCond())
122
123	if opts.Page != 0 {
124		sess = db.SetEnginePagination(sess, opts)
125	}
126
127	return sess
128}
129
130func getTrackedTimes(e db.Engine, options *FindTrackedTimesOptions) (trackedTimes TrackedTimeList, err error) {
131	err = options.toSession(e).Find(&trackedTimes)
132	return
133}
134
135// GetTrackedTimes returns all tracked times that fit to the given options.
136func GetTrackedTimes(opts *FindTrackedTimesOptions) (TrackedTimeList, error) {
137	return getTrackedTimes(db.GetEngine(db.DefaultContext), opts)
138}
139
140// CountTrackedTimes returns count of tracked times that fit to the given options.
141func CountTrackedTimes(opts *FindTrackedTimesOptions) (int64, error) {
142	sess := db.GetEngine(db.DefaultContext).Where(opts.toCond())
143	if opts.RepositoryID > 0 || opts.MilestoneID > 0 {
144		sess = sess.Join("INNER", "issue", "issue.id = tracked_time.issue_id")
145	}
146	return sess.Count(&TrackedTime{})
147}
148
149func getTrackedSeconds(e db.Engine, opts FindTrackedTimesOptions) (trackedSeconds int64, err error) {
150	return opts.toSession(e).SumInt(&TrackedTime{}, "time")
151}
152
153// GetTrackedSeconds return sum of seconds
154func GetTrackedSeconds(opts FindTrackedTimesOptions) (int64, error) {
155	return getTrackedSeconds(db.GetEngine(db.DefaultContext), opts)
156}
157
158// AddTime will add the given time (in seconds) to the issue
159func AddTime(user *user_model.User, issue *Issue, amount int64, created time.Time) (*TrackedTime, error) {
160	ctx, committer, err := db.TxContext()
161	if err != nil {
162		return nil, err
163	}
164	defer committer.Close()
165	sess := db.GetEngine(ctx)
166
167	t, err := addTime(sess, user, issue, amount, created)
168	if err != nil {
169		return nil, err
170	}
171
172	if err := issue.loadRepo(ctx); err != nil {
173		return nil, err
174	}
175
176	if _, err := createComment(ctx, &CreateCommentOptions{
177		Issue:   issue,
178		Repo:    issue.Repo,
179		Doer:    user,
180		Content: SecToTime(amount),
181		Type:    CommentTypeAddTimeManual,
182		TimeID:  t.ID,
183	}); err != nil {
184		return nil, err
185	}
186
187	return t, committer.Commit()
188}
189
190func addTime(e db.Engine, user *user_model.User, issue *Issue, amount int64, created time.Time) (*TrackedTime, error) {
191	if created.IsZero() {
192		created = time.Now()
193	}
194	tt := &TrackedTime{
195		IssueID: issue.ID,
196		UserID:  user.ID,
197		Time:    amount,
198		Created: created,
199	}
200	if _, err := e.Insert(tt); err != nil {
201		return nil, err
202	}
203
204	return tt, nil
205}
206
207// TotalTimes returns the spent time for each user by an issue
208func TotalTimes(options *FindTrackedTimesOptions) (map[*user_model.User]string, error) {
209	trackedTimes, err := GetTrackedTimes(options)
210	if err != nil {
211		return nil, err
212	}
213	// Adding total time per user ID
214	totalTimesByUser := make(map[int64]int64)
215	for _, t := range trackedTimes {
216		totalTimesByUser[t.UserID] += t.Time
217	}
218
219	totalTimes := make(map[*user_model.User]string)
220	// Fetching User and making time human readable
221	for userID, total := range totalTimesByUser {
222		user, err := user_model.GetUserByID(userID)
223		if err != nil {
224			if user_model.IsErrUserNotExist(err) {
225				continue
226			}
227			return nil, err
228		}
229		totalTimes[user] = SecToTime(total)
230	}
231	return totalTimes, nil
232}
233
234// DeleteIssueUserTimes deletes times for issue
235func DeleteIssueUserTimes(issue *Issue, user *user_model.User) error {
236	ctx, committer, err := db.TxContext()
237	if err != nil {
238		return err
239	}
240	defer committer.Close()
241	sess := db.GetEngine(ctx)
242
243	opts := FindTrackedTimesOptions{
244		IssueID: issue.ID,
245		UserID:  user.ID,
246	}
247
248	removedTime, err := deleteTimes(sess, opts)
249	if err != nil {
250		return err
251	}
252	if removedTime == 0 {
253		return ErrNotExist{}
254	}
255
256	if err := issue.loadRepo(ctx); err != nil {
257		return err
258	}
259	if _, err := createComment(ctx, &CreateCommentOptions{
260		Issue:   issue,
261		Repo:    issue.Repo,
262		Doer:    user,
263		Content: "- " + SecToTime(removedTime),
264		Type:    CommentTypeDeleteTimeManual,
265	}); err != nil {
266		return err
267	}
268
269	return committer.Commit()
270}
271
272// DeleteTime delete a specific Time
273func DeleteTime(t *TrackedTime) error {
274	ctx, committer, err := db.TxContext()
275	if err != nil {
276		return err
277	}
278	defer committer.Close()
279
280	if err := t.loadAttributes(ctx); err != nil {
281		return err
282	}
283
284	if err := deleteTime(db.GetEngine(ctx), t); err != nil {
285		return err
286	}
287
288	if _, err := createComment(ctx, &CreateCommentOptions{
289		Issue:   t.Issue,
290		Repo:    t.Issue.Repo,
291		Doer:    t.User,
292		Content: "- " + SecToTime(t.Time),
293		Type:    CommentTypeDeleteTimeManual,
294	}); err != nil {
295		return err
296	}
297
298	return committer.Commit()
299}
300
301func deleteTimes(e db.Engine, opts FindTrackedTimesOptions) (removedTime int64, err error) {
302	removedTime, err = getTrackedSeconds(e, opts)
303	if err != nil || removedTime == 0 {
304		return
305	}
306
307	_, err = opts.toSession(e).Table("tracked_time").Cols("deleted").Update(&TrackedTime{Deleted: true})
308	return
309}
310
311func deleteTime(e db.Engine, t *TrackedTime) error {
312	if t.Deleted {
313		return ErrNotExist{ID: t.ID}
314	}
315	t.Deleted = true
316	_, err := e.ID(t.ID).Cols("deleted").Update(t)
317	return err
318}
319
320// GetTrackedTimeByID returns raw TrackedTime without loading attributes by id
321func GetTrackedTimeByID(id int64) (*TrackedTime, error) {
322	time := new(TrackedTime)
323	has, err := db.GetEngine(db.DefaultContext).ID(id).Get(time)
324	if err != nil {
325		return nil, err
326	} else if !has {
327		return nil, ErrNotExist{ID: id}
328	}
329	return time, nil
330}
331