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 }