Feature: Timetracking (#2211)
* Added comment's hashtag to url for mail notifications. * Added explanation to return statement + documentation. * Replacing in-line link generation with HTMLURL. (+gofmt) * Replaced action-based model with nil-based model. (+gofmt) * Replaced mailIssueActionToParticipants with mailIssueCommentToParticipants. * Updating comment for mailIssueCommentToParticipants * Added link to comment in "Dashboard" * Deleting feed entry if a comment is going to be deleted * Added migration * Added improved migration to add a CommentID column to action. * Added improved links to comments in feed entries. * Fixes #1956 by filtering for deleted comments that are referenced in actions. * Introducing "IsDeleted" column to action. * Adding design draft (not functional) * Adding database models for stopwatches and trackedtimes * See go-gitea/gitea#967 * Adding design draft (not functional) * Adding translations and improving design * Implementing stopwatch (for timetracking) * Make UI functional * Add hints in timeline for time tracking events * Implementing timetracking feature * Adding "Add time manual" option * Improved stopwatch * Created report of total spent time by user * Only showing total time spent if theire is something to show. * Adding license headers. * Improved error handling for "Add Time Manual" * Adding @sapks 's changes, refactoring * Adding API for feature tracking * Adding unit test * Adding DISABLE/ENABLE option to Repository settings page * Improving translations * Applying @sapk 's changes * Removing repo_unit and using IssuesSetting for disabling/enabling timetracker * Adding DEFAULT_ENABLE_TIMETRACKER to config, installation and admin menu * Improving documentation * Fixing vendor/ folder * Changing timtracking routes by adding subgroups /times and /times/stopwatch (Proposed by @lafriks ) * Restricting write access to timetracking based on the repo settings (Proposed by @lafriks ) * Fixed minor permissions bug. * Adding CanUseTimetracker and IsTimetrackerEnabled in ctx.Repo * Allow assignees and authors to track there time too. * Fixed some build-time-errors + logical errors. * Removing unused Get...ByID functions * Moving IsTimetrackerEnabled from context.Repository to models.Repository * Adding a seperate file for issue related repo functions * Adding license headers * Fixed GetUserByParams return 404 * Moving /users/:username/times to /repos/:username/:reponame/times/:username for security reasons * Adding /repos/:username/times to get all tracked times of the repo * Updating sdk-dependency * Updating swagger.v1.json * Adding warning if user has already a running stopwatch (auto-timetracker) * Replacing GetTrackedTimesBy... with GetTrackedTimes(options FindTrackedTimesOptions) * Changing code.gitea.io/sdk back to code.gitea.io/sdk * Correcting spelling mistake * Updating vendor.json * Changing GET stopwatch/toggle to POST stopwatch/toggle * Changing GET stopwatch/cancel to POST stopwatch/cancel * Added migration for stopwatches/timetracking * Fixed some access bugs for read-only users * Added default allow only contributors to track time value to config * Fixed migration by chaging x.Iterate to x.Find * Resorted imports * Moved Add Time Manually form to repo_form.go * Removed "Seconds" field from Add Time Manually * Resorted imports * Improved permission checking * Fixed some bugs * Added integration test * gofmt * Adding integration test by @lafriks * Added created_unix to comment fixtures * Using last event instead of a fixed event * Adding another integration test by @lafriks * Fixing bug Timetracker enabled causing error 500 at sidebar.tpl * Fixed a refactoring bug that resulted in hiding "HasUserStopwatch" warning. * Returning TrackedTime instead of AddTimeOption at AddTime. * Updating SDK from go-gitea/go-sdk#69 * Resetting Go-SDK back to default repository * Fixing test-vendor by changing ini back to original repository * Adding "tags" to swagger spec * govendor sync * Removed duplicate * Formatting templates * Adding IsTimetrackingEnabled checks to API * Improving translations / english texts * Improving documentation * Updating swagger spec * Fixing integration test caused be translation-changes * Removed encoding issues in local_en-US.ini. * "Added" copyright line * Moved unit.IssuesConfig().EnableTimetracker into a != nil check * Removed some other encoding issues in local_en-US.ini * Improved javascript by checking if data-context exists * Replaced manual comment creation with CreateComment * Removed unnecessary code * Improved error checking * Small cosmetic changes * Replaced int>string>duration parsing with int>duration parsing * Fixed encoding issues * Removed unused imports Signed-off-by: Jonas Franz <info@jonasfranz.software>tokarchuk/v1.17
parent
69dfe43ffc
commit
5ccecb44ad
@ -0,0 +1,74 @@ |
|||||||
|
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package integrations |
||||||
|
|
||||||
|
import ( |
||||||
|
"net/http" |
||||||
|
"path" |
||||||
|
"testing" |
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert" |
||||||
|
) |
||||||
|
|
||||||
|
func TestViewTimetrackingControls(t *testing.T) { |
||||||
|
prepareTestEnv(t) |
||||||
|
session := loginUser(t, "user2") |
||||||
|
testViewTimetrackingControls(t, session, "user2", "repo1", "1", true) |
||||||
|
//user2/repo1
|
||||||
|
} |
||||||
|
|
||||||
|
func TestNotViewTimetrackingControls(t *testing.T) { |
||||||
|
prepareTestEnv(t) |
||||||
|
session := loginUser(t, "user5") |
||||||
|
testViewTimetrackingControls(t, session, "user2", "repo1", "1", false) |
||||||
|
//user2/repo1
|
||||||
|
} |
||||||
|
func TestViewTimetrackingControlsDisabled(t *testing.T) { |
||||||
|
prepareTestEnv(t) |
||||||
|
session := loginUser(t, "user2") |
||||||
|
testViewTimetrackingControls(t, session, "user3", "repo3", "1", false) |
||||||
|
} |
||||||
|
|
||||||
|
func testViewTimetrackingControls(t *testing.T, session *TestSession, user, repo, issue string, canTrackTime bool) { |
||||||
|
req := NewRequest(t, "GET", path.Join(user, repo, "issues", issue)) |
||||||
|
resp := session.MakeRequest(t, req, http.StatusOK) |
||||||
|
|
||||||
|
htmlDoc := NewHTMLParser(t, resp.Body) |
||||||
|
|
||||||
|
htmlDoc.AssertElement(t, ".timetrack .start-add .start", canTrackTime) |
||||||
|
htmlDoc.AssertElement(t, ".timetrack .start-add .add-time", canTrackTime) |
||||||
|
|
||||||
|
req = NewRequestWithValues(t, "POST", path.Join(user, repo, "issues", issue, "times", "stopwatch", "toggle"), map[string]string{ |
||||||
|
"_csrf": htmlDoc.GetCSRF(), |
||||||
|
}) |
||||||
|
if canTrackTime { |
||||||
|
resp = session.MakeRequest(t, req, http.StatusSeeOther) |
||||||
|
|
||||||
|
req = NewRequest(t, "GET", RedirectURL(t, resp)) |
||||||
|
resp = session.MakeRequest(t, req, http.StatusOK) |
||||||
|
htmlDoc = NewHTMLParser(t, resp.Body) |
||||||
|
|
||||||
|
events := htmlDoc.doc.Find(".event > span.text") |
||||||
|
assert.Contains(t, events.Last().Text(), "started working") |
||||||
|
|
||||||
|
htmlDoc.AssertElement(t, ".timetrack .stop-cancel .stop", true) |
||||||
|
htmlDoc.AssertElement(t, ".timetrack .stop-cancel .cancel", true) |
||||||
|
|
||||||
|
req = NewRequestWithValues(t, "POST", path.Join(user, repo, "issues", issue, "times", "stopwatch", "toggle"), map[string]string{ |
||||||
|
"_csrf": htmlDoc.GetCSRF(), |
||||||
|
}) |
||||||
|
resp = session.MakeRequest(t, req, http.StatusSeeOther) |
||||||
|
|
||||||
|
req = NewRequest(t, "GET", RedirectURL(t, resp)) |
||||||
|
resp = session.MakeRequest(t, req, http.StatusOK) |
||||||
|
htmlDoc = NewHTMLParser(t, resp.Body) |
||||||
|
|
||||||
|
events = htmlDoc.doc.Find(".event > span.text") |
||||||
|
assert.Contains(t, events.Last().Text(), "stopped working") |
||||||
|
htmlDoc.AssertElement(t, ".event .detail .octicon-clock", true) |
||||||
|
} else { |
||||||
|
session.MakeRequest(t, req, http.StatusNotFound) |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,11 @@ |
|||||||
|
- |
||||||
|
id: 1 |
||||||
|
user_id: 1 |
||||||
|
issue_id: 1 |
||||||
|
created_unix: 1500988502 |
||||||
|
|
||||||
|
- |
||||||
|
id: 2 |
||||||
|
user_id: 2 |
||||||
|
issue_id: 2 |
||||||
|
created_unix: 1500988502 |
@ -0,0 +1,34 @@ |
|||||||
|
- |
||||||
|
id: 1 |
||||||
|
user_id: 1 |
||||||
|
issue_id: 1 |
||||||
|
time: 400 |
||||||
|
created_unix: 946684800 |
||||||
|
|
||||||
|
- |
||||||
|
id: 2 |
||||||
|
user_id: 2 |
||||||
|
issue_id: 2 |
||||||
|
time: 3661 |
||||||
|
created_unix: 946684801 |
||||||
|
|
||||||
|
- |
||||||
|
id: 3 |
||||||
|
user_id: 2 |
||||||
|
issue_id: 2 |
||||||
|
time: 1 |
||||||
|
created_unix: 946684802 |
||||||
|
|
||||||
|
- |
||||||
|
id: 4 |
||||||
|
user_id: -1 |
||||||
|
issue_id: 4 |
||||||
|
time: 1 |
||||||
|
created_unix: 946684802 |
||||||
|
|
||||||
|
- |
||||||
|
id: 5 |
||||||
|
user_id: 2 |
||||||
|
issue_id: 5 |
||||||
|
time: 1 |
||||||
|
created_unix: 946684802 |
@ -0,0 +1,170 @@ |
|||||||
|
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package models |
||||||
|
|
||||||
|
import ( |
||||||
|
"fmt" |
||||||
|
"time" |
||||||
|
|
||||||
|
"github.com/go-xorm/xorm" |
||||||
|
) |
||||||
|
|
||||||
|
// Stopwatch represents a stopwatch for time tracking.
|
||||||
|
type Stopwatch struct { |
||||||
|
ID int64 `xorm:"pk autoincr"` |
||||||
|
IssueID int64 `xorm:"INDEX"` |
||||||
|
UserID int64 `xorm:"INDEX"` |
||||||
|
Created time.Time `xorm:"-"` |
||||||
|
CreatedUnix int64 |
||||||
|
} |
||||||
|
|
||||||
|
// BeforeInsert will be invoked by XORM before inserting a record
|
||||||
|
// representing this object.
|
||||||
|
func (s *Stopwatch) BeforeInsert() { |
||||||
|
s.CreatedUnix = time.Now().Unix() |
||||||
|
} |
||||||
|
|
||||||
|
// AfterSet is invoked from XORM after setting the value of a field of this object.
|
||||||
|
func (s *Stopwatch) AfterSet(colName string, _ xorm.Cell) { |
||||||
|
switch colName { |
||||||
|
|
||||||
|
case "created_unix": |
||||||
|
s.Created = time.Unix(s.CreatedUnix, 0).Local() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func getStopwatch(e Engine, userID, issueID int64) (sw *Stopwatch, exists bool, err error) { |
||||||
|
sw = new(Stopwatch) |
||||||
|
exists, err = e. |
||||||
|
Where("user_id = ?", userID). |
||||||
|
And("issue_id = ?", issueID). |
||||||
|
Get(sw) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// StopwatchExists returns true if the stopwatch exists
|
||||||
|
func StopwatchExists(userID int64, issueID int64) bool { |
||||||
|
_, exists, _ := getStopwatch(x, userID, issueID) |
||||||
|
return exists |
||||||
|
} |
||||||
|
|
||||||
|
// HasUserStopwatch returns true if the user has a stopwatch
|
||||||
|
func HasUserStopwatch(userID int64) (exists bool, sw *Stopwatch, err error) { |
||||||
|
sw = new(Stopwatch) |
||||||
|
exists, err = x. |
||||||
|
Where("user_id = ?", userID). |
||||||
|
Get(sw) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// CreateOrStopIssueStopwatch will create or remove a stopwatch and will log it into issue's timeline.
|
||||||
|
func CreateOrStopIssueStopwatch(user *User, issue *Issue) error { |
||||||
|
sw, exists, err := getStopwatch(x, user.ID, issue.ID) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
if exists { |
||||||
|
// Create tracked time out of the time difference between start date and actual date
|
||||||
|
timediff := time.Now().Unix() - sw.CreatedUnix |
||||||
|
|
||||||
|
// Create TrackedTime
|
||||||
|
tt := &TrackedTime{ |
||||||
|
Created: time.Now(), |
||||||
|
IssueID: issue.ID, |
||||||
|
UserID: user.ID, |
||||||
|
Time: timediff, |
||||||
|
} |
||||||
|
|
||||||
|
if _, err := x.Insert(tt); err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
if _, err := CreateComment(&CreateCommentOptions{ |
||||||
|
Doer: user, |
||||||
|
Issue: issue, |
||||||
|
Repo: issue.Repo, |
||||||
|
Content: secToTime(timediff), |
||||||
|
Type: CommentTypeStopTracking, |
||||||
|
}); err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
if _, err := x.Delete(sw); err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
} else { |
||||||
|
// Create stopwatch
|
||||||
|
sw = &Stopwatch{ |
||||||
|
UserID: user.ID, |
||||||
|
IssueID: issue.ID, |
||||||
|
Created: time.Now(), |
||||||
|
} |
||||||
|
|
||||||
|
if _, err := x.Insert(sw); err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
if _, err := CreateComment(&CreateCommentOptions{ |
||||||
|
Doer: user, |
||||||
|
Issue: issue, |
||||||
|
Repo: issue.Repo, |
||||||
|
Type: CommentTypeStartTracking, |
||||||
|
}); err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
} |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
// CancelStopwatch removes the given stopwatch and logs it into issue's timeline.
|
||||||
|
func CancelStopwatch(user *User, issue *Issue) error { |
||||||
|
sw, exists, err := getStopwatch(x, user.ID, issue.ID) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
if exists { |
||||||
|
if _, err := x.Delete(sw); err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
if _, err := CreateComment(&CreateCommentOptions{ |
||||||
|
Doer: user, |
||||||
|
Issue: issue, |
||||||
|
Repo: issue.Repo, |
||||||
|
Type: CommentTypeCancelTracking, |
||||||
|
}); err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
} |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
func secToTime(duration int64) string { |
||||||
|
seconds := duration % 60 |
||||||
|
minutes := (duration / (60)) % 60 |
||||||
|
hours := duration / (60 * 60) |
||||||
|
|
||||||
|
var hrs string |
||||||
|
|
||||||
|
if hours > 0 { |
||||||
|
hrs = fmt.Sprintf("%dh", hours) |
||||||
|
} |
||||||
|
if minutes > 0 { |
||||||
|
if hours == 0 { |
||||||
|
hrs = fmt.Sprintf("%dmin", minutes) |
||||||
|
} else { |
||||||
|
hrs = fmt.Sprintf("%s %dmin", hrs, minutes) |
||||||
|
} |
||||||
|
} |
||||||
|
if seconds > 0 { |
||||||
|
if hours == 0 && minutes == 0 { |
||||||
|
hrs = fmt.Sprintf("%ds", seconds) |
||||||
|
} else { |
||||||
|
hrs = fmt.Sprintf("%s %ds", hrs, seconds) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return hrs |
||||||
|
} |
@ -0,0 +1,70 @@ |
|||||||
|
package models |
||||||
|
|
||||||
|
import ( |
||||||
|
"testing" |
||||||
|
"time" |
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert" |
||||||
|
) |
||||||
|
|
||||||
|
func TestCancelStopwatch(t *testing.T) { |
||||||
|
assert.NoError(t, PrepareTestDatabase()) |
||||||
|
|
||||||
|
user1, err := GetUserByID(1) |
||||||
|
assert.NoError(t, err) |
||||||
|
|
||||||
|
issue1, err := GetIssueByID(1) |
||||||
|
assert.NoError(t, err) |
||||||
|
issue2, err := GetIssueByID(2) |
||||||
|
assert.NoError(t, err) |
||||||
|
|
||||||
|
err = CancelStopwatch(user1, issue1) |
||||||
|
assert.NoError(t, err) |
||||||
|
AssertNotExistsBean(t, &Stopwatch{UserID: user1.ID, IssueID: issue1.ID}) |
||||||
|
|
||||||
|
_ = AssertExistsAndLoadBean(t, &Comment{Type: CommentTypeCancelTracking, PosterID: user1.ID, IssueID: issue1.ID}) |
||||||
|
|
||||||
|
assert.Nil(t, CancelStopwatch(user1, issue2)) |
||||||
|
} |
||||||
|
|
||||||
|
func TestStopwatchExists(t *testing.T) { |
||||||
|
assert.NoError(t, PrepareTestDatabase()) |
||||||
|
|
||||||
|
assert.True(t, StopwatchExists(1, 1)) |
||||||
|
assert.False(t, StopwatchExists(1, 2)) |
||||||
|
} |
||||||
|
|
||||||
|
func TestHasUserStopwatch(t *testing.T) { |
||||||
|
assert.NoError(t, PrepareTestDatabase()) |
||||||
|
|
||||||
|
exists, sw, err := HasUserStopwatch(1) |
||||||
|
assert.NoError(t, err) |
||||||
|
assert.True(t, exists) |
||||||
|
assert.Equal(t, int64(1), sw.ID) |
||||||
|
|
||||||
|
exists, _, err = HasUserStopwatch(3) |
||||||
|
assert.NoError(t, err) |
||||||
|
assert.False(t, exists) |
||||||
|
} |
||||||
|
|
||||||
|
func TestCreateOrStopIssueStopwatch(t *testing.T) { |
||||||
|
assert.NoError(t, PrepareTestDatabase()) |
||||||
|
|
||||||
|
user2, err := GetUserByID(2) |
||||||
|
assert.NoError(t, err) |
||||||
|
user3, err := GetUserByID(3) |
||||||
|
assert.NoError(t, err) |
||||||
|
|
||||||
|
issue1, err := GetIssueByID(1) |
||||||
|
assert.NoError(t, err) |
||||||
|
issue2, err := GetIssueByID(2) |
||||||
|
assert.NoError(t, err) |
||||||
|
|
||||||
|
assert.NoError(t, CreateOrStopIssueStopwatch(user3, issue1)) |
||||||
|
sw := AssertExistsAndLoadBean(t, &Stopwatch{UserID: 3, IssueID: 1}).(*Stopwatch) |
||||||
|
assert.Equal(t, true, sw.Created.Before(time.Now())) |
||||||
|
|
||||||
|
assert.NoError(t, CreateOrStopIssueStopwatch(user2, issue2)) |
||||||
|
AssertNotExistsBean(t, &Stopwatch{UserID: 2, IssueID: 2}) |
||||||
|
AssertExistsAndLoadBean(t, &TrackedTime{UserID: 2, IssueID: 2}) |
||||||
|
} |
@ -0,0 +1,117 @@ |
|||||||
|
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package models |
||||||
|
|
||||||
|
import ( |
||||||
|
"time" |
||||||
|
|
||||||
|
"github.com/go-xorm/builder" |
||||||
|
"github.com/go-xorm/xorm" |
||||||
|
) |
||||||
|
|
||||||
|
// TrackedTime represents a time that was spent for a specific issue.
|
||||||
|
type TrackedTime struct { |
||||||
|
ID int64 `xorm:"pk autoincr" json:"id"` |
||||||
|
IssueID int64 `xorm:"INDEX" json:"issue_id"` |
||||||
|
UserID int64 `xorm:"INDEX" json:"user_id"` |
||||||
|
Created time.Time `xorm:"-" json:"created"` |
||||||
|
CreatedUnix int64 `json:"-"` |
||||||
|
Time int64 `json:"time"` |
||||||
|
} |
||||||
|
|
||||||
|
// BeforeInsert will be invoked by XORM before inserting a record
|
||||||
|
// representing this object.
|
||||||
|
func (t *TrackedTime) BeforeInsert() { |
||||||
|
t.CreatedUnix = time.Now().Unix() |
||||||
|
} |
||||||
|
|
||||||
|
// AfterSet is invoked from XORM after setting the value of a field of this object.
|
||||||
|
func (t *TrackedTime) AfterSet(colName string, _ xorm.Cell) { |
||||||
|
switch colName { |
||||||
|
case "created_unix": |
||||||
|
t.Created = time.Unix(t.CreatedUnix, 0).Local() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// FindTrackedTimesOptions represent the filters for tracked times. If an ID is 0 it will be ignored.
|
||||||
|
type FindTrackedTimesOptions struct { |
||||||
|
IssueID int64 |
||||||
|
UserID int64 |
||||||
|
RepositoryID int64 |
||||||
|
} |
||||||
|
|
||||||
|
// ToCond will convert each condition into a xorm-Cond
|
||||||
|
func (opts *FindTrackedTimesOptions) ToCond() builder.Cond { |
||||||
|
cond := builder.NewCond() |
||||||
|
if opts.IssueID != 0 { |
||||||
|
cond = cond.And(builder.Eq{"issue_id": opts.IssueID}) |
||||||
|
} |
||||||
|
if opts.UserID != 0 { |
||||||
|
cond = cond.And(builder.Eq{"user_id": opts.UserID}) |
||||||
|
} |
||||||
|
if opts.RepositoryID != 0 { |
||||||
|
cond = cond.And(builder.Eq{"issue.repo_id": opts.RepositoryID}) |
||||||
|
} |
||||||
|
return cond |
||||||
|
} |
||||||
|
|
||||||
|
// GetTrackedTimes returns all tracked times that fit to the given options.
|
||||||
|
func GetTrackedTimes(options FindTrackedTimesOptions) (trackedTimes []*TrackedTime, err error) { |
||||||
|
if options.RepositoryID > 0 { |
||||||
|
err = x.Join("INNER", "issue", "issue.id = tracked_time.issue_id").Where(options.ToCond()).Find(&trackedTimes) |
||||||
|
return |
||||||
|
} |
||||||
|
err = x.Where(options.ToCond()).Find(&trackedTimes) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// AddTime will add the given time (in seconds) to the issue
|
||||||
|
func AddTime(user *User, issue *Issue, time int64) (*TrackedTime, error) { |
||||||
|
tt := &TrackedTime{ |
||||||
|
IssueID: issue.ID, |
||||||
|
UserID: user.ID, |
||||||
|
Time: time, |
||||||
|
} |
||||||
|
if _, err := x.Insert(tt); err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
if _, err := CreateComment(&CreateCommentOptions{ |
||||||
|
Issue: issue, |
||||||
|
Repo: issue.Repo, |
||||||
|
Doer: user, |
||||||
|
Content: secToTime(time), |
||||||
|
Type: CommentTypeAddTimeManual, |
||||||
|
}); err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
return tt, nil |
||||||
|
} |
||||||
|
|
||||||
|
// TotalTimes returns the spent time for each user by an issue
|
||||||
|
func TotalTimes(options FindTrackedTimesOptions) (map[*User]string, error) { |
||||||
|
trackedTimes, err := GetTrackedTimes(options) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
//Adding total time per user ID
|
||||||
|
totalTimesByUser := make(map[int64]int64) |
||||||
|
for _, t := range trackedTimes { |
||||||
|
totalTimesByUser[t.UserID] += t.Time |
||||||
|
} |
||||||
|
|
||||||
|
totalTimes := make(map[*User]string) |
||||||
|
//Fetching User and making time human readable
|
||||||
|
for userID, total := range totalTimesByUser { |
||||||
|
user, err := GetUserByID(userID) |
||||||
|
if err != nil { |
||||||
|
if IsErrUserNotExist(err) { |
||||||
|
continue |
||||||
|
} |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
totalTimes[user] = secToTime(total) |
||||||
|
} |
||||||
|
return totalTimes, nil |
||||||
|
} |
@ -0,0 +1,103 @@ |
|||||||
|
package models |
||||||
|
|
||||||
|
import ( |
||||||
|
"testing" |
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert" |
||||||
|
) |
||||||
|
|
||||||
|
func TestAddTime(t *testing.T) { |
||||||
|
assert.NoError(t, PrepareTestDatabase()) |
||||||
|
|
||||||
|
user3, err := GetUserByID(3) |
||||||
|
assert.NoError(t, err) |
||||||
|
|
||||||
|
issue1, err := GetIssueByID(1) |
||||||
|
assert.NoError(t, err) |
||||||
|
|
||||||
|
//3661 = 1h 1min 1s
|
||||||
|
trackedTime, err := AddTime(user3, issue1, 3661) |
||||||
|
assert.NoError(t, err) |
||||||
|
assert.Equal(t, int64(3), trackedTime.UserID) |
||||||
|
assert.Equal(t, int64(1), trackedTime.IssueID) |
||||||
|
assert.Equal(t, int64(3661), trackedTime.Time) |
||||||
|
|
||||||
|
tt := AssertExistsAndLoadBean(t, &TrackedTime{UserID: 3, IssueID: 1}).(*TrackedTime) |
||||||
|
assert.Equal(t, tt.Time, int64(3661)) |
||||||
|
|
||||||
|
comment := AssertExistsAndLoadBean(t, &Comment{Type: CommentTypeAddTimeManual, PosterID: 3, IssueID: 1}).(*Comment) |
||||||
|
assert.Equal(t, comment.Content, "1h 1min 1s") |
||||||
|
} |
||||||
|
|
||||||
|
func TestGetTrackedTimes(t *testing.T) { |
||||||
|
assert.NoError(t, PrepareTestDatabase()) |
||||||
|
|
||||||
|
// by Issue
|
||||||
|
times, err := GetTrackedTimes(FindTrackedTimesOptions{IssueID: 1}) |
||||||
|
assert.NoError(t, err) |
||||||
|
assert.Len(t, times, 1) |
||||||
|
assert.Equal(t, times[0].Time, int64(400)) |
||||||
|
|
||||||
|
times, err = GetTrackedTimes(FindTrackedTimesOptions{IssueID: -1}) |
||||||
|
assert.NoError(t, err) |
||||||
|
assert.Len(t, times, 0) |
||||||
|
|
||||||
|
// by User
|
||||||
|
times, err = GetTrackedTimes(FindTrackedTimesOptions{UserID: 1}) |
||||||
|
assert.NoError(t, err) |
||||||
|
assert.Len(t, times, 1) |
||||||
|
assert.Equal(t, times[0].Time, int64(400)) |
||||||
|
|
||||||
|
times, err = GetTrackedTimes(FindTrackedTimesOptions{UserID: 3}) |
||||||
|
assert.NoError(t, err) |
||||||
|
assert.Len(t, times, 0) |
||||||
|
|
||||||
|
// by Repo
|
||||||
|
times, err = GetTrackedTimes(FindTrackedTimesOptions{RepositoryID: 2}) |
||||||
|
assert.NoError(t, err) |
||||||
|
assert.Len(t, times, 1) |
||||||
|
assert.Equal(t, times[0].Time, int64(1)) |
||||||
|
issue, err := GetIssueByID(times[0].IssueID) |
||||||
|
assert.NoError(t, err) |
||||||
|
assert.Equal(t, issue.RepoID, int64(2)) |
||||||
|
|
||||||
|
times, err = GetTrackedTimes(FindTrackedTimesOptions{RepositoryID: 1}) |
||||||
|
assert.NoError(t, err) |
||||||
|
assert.Len(t, times, 4) |
||||||
|
|
||||||
|
times, err = GetTrackedTimes(FindTrackedTimesOptions{RepositoryID: 10}) |
||||||
|
assert.NoError(t, err) |
||||||
|
assert.Len(t, times, 0) |
||||||
|
} |
||||||
|
|
||||||
|
func TestTotalTimes(t *testing.T) { |
||||||
|
assert.NoError(t, PrepareTestDatabase()) |
||||||
|
|
||||||
|
total, err := TotalTimes(FindTrackedTimesOptions{IssueID: 1}) |
||||||
|
assert.NoError(t, err) |
||||||
|
assert.Len(t, total, 1) |
||||||
|
for user, time := range total { |
||||||
|
assert.Equal(t, int64(1), user.ID) |
||||||
|
assert.Equal(t, "6min 40s", time) |
||||||
|
} |
||||||
|
|
||||||
|
total, err = TotalTimes(FindTrackedTimesOptions{IssueID: 2}) |
||||||
|
assert.NoError(t, err) |
||||||
|
assert.Len(t, total, 1) |
||||||
|
for user, time := range total { |
||||||
|
assert.Equal(t, int64(2), user.ID) |
||||||
|
assert.Equal(t, "1h 1min 2s", time) |
||||||
|
} |
||||||
|
|
||||||
|
total, err = TotalTimes(FindTrackedTimesOptions{IssueID: 5}) |
||||||
|
assert.NoError(t, err) |
||||||
|
assert.Len(t, total, 1) |
||||||
|
for user, time := range total { |
||||||
|
assert.Equal(t, int64(2), user.ID) |
||||||
|
assert.Equal(t, "1s", time) |
||||||
|
} |
||||||
|
|
||||||
|
total, err = TotalTimes(FindTrackedTimesOptions{IssueID: 4}) |
||||||
|
assert.NoError(t, err) |
||||||
|
assert.Len(t, total, 0) |
||||||
|
} |
@ -0,0 +1,65 @@ |
|||||||
|
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package migrations |
||||||
|
|
||||||
|
import ( |
||||||
|
"fmt" |
||||||
|
"time" |
||||||
|
|
||||||
|
"code.gitea.io/gitea/modules/setting" |
||||||
|
|
||||||
|
"github.com/go-xorm/xorm" |
||||||
|
) |
||||||
|
|
||||||
|
// Stopwatch see models/issue_stopwatch.go
|
||||||
|
type Stopwatch struct { |
||||||
|
ID int64 `xorm:"pk autoincr"` |
||||||
|
IssueID int64 `xorm:"INDEX"` |
||||||
|
UserID int64 `xorm:"INDEX"` |
||||||
|
Created time.Time `xorm:"-"` |
||||||
|
CreatedUnix int64 |
||||||
|
} |
||||||
|
|
||||||
|
// TrackedTime see models/issue_tracked_time.go
|
||||||
|
type TrackedTime struct { |
||||||
|
ID int64 `xorm:"pk autoincr" json:"id"` |
||||||
|
IssueID int64 `xorm:"INDEX" json:"issue_id"` |
||||||
|
UserID int64 `xorm:"INDEX" json:"user_id"` |
||||||
|
Created time.Time `xorm:"-" json:"created"` |
||||||
|
CreatedUnix int64 `json:"-"` |
||||||
|
Time int64 `json:"time"` |
||||||
|
} |
||||||
|
|
||||||
|
func addTimetracking(x *xorm.Engine) error { |
||||||
|
if err := x.Sync2(new(Stopwatch)); err != nil { |
||||||
|
return fmt.Errorf("Sync2: %v", err) |
||||||
|
} |
||||||
|
if err := x.Sync2(new(TrackedTime)); err != nil { |
||||||
|
return fmt.Errorf("Sync2: %v", err) |
||||||
|
} |
||||||
|
//Updating existing issue units
|
||||||
|
var units []*RepoUnit |
||||||
|
x.Where("type = ?", V16UnitTypeIssues).Find(&units) |
||||||
|
for _, unit := range units { |
||||||
|
if unit.Config == nil { |
||||||
|
unit.Config = make(map[string]interface{}) |
||||||
|
} |
||||||
|
changes := false |
||||||
|
if _, ok := unit.Config["EnableTimetracker"]; !ok { |
||||||
|
unit.Config["EnableTimetracker"] = setting.Service.DefaultEnableTimetracking |
||||||
|
changes = true |
||||||
|
} |
||||||
|
if _, ok := unit.Config["AllowOnlyContributorsToTrackTime"]; !ok { |
||||||
|
unit.Config["AllowOnlyContributorsToTrackTime"] = setting.Service.DefaultAllowOnlyContributorsToTrackTime |
||||||
|
changes = true |
||||||
|
} |
||||||
|
if changes { |
||||||
|
if _, err := x.Id(unit.ID).Cols("config").Update(unit); err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
return nil |
||||||
|
} |
@ -0,0 +1,34 @@ |
|||||||
|
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package models |
||||||
|
|
||||||
|
import "code.gitea.io/gitea/modules/setting" |
||||||
|
|
||||||
|
// ___________.__ ___________ __
|
||||||
|
// \__ ___/|__| _____ ___\__ ___/___________ ____ | | __ ___________
|
||||||
|
// | | | |/ \_/ __ \| | \_ __ \__ \ _/ ___\| |/ // __ \_ __ \
|
||||||
|
// | | | | Y Y \ ___/| | | | \// __ \\ \___| <\ ___/| | \/
|
||||||
|
// |____| |__|__|_| /\___ >____| |__| (____ /\___ >__|_ \\___ >__|
|
||||||
|
// \/ \/ \/ \/ \/ \/
|
||||||
|
|
||||||
|
// IsTimetrackerEnabled returns whether or not the timetracker is enabled. It returns the default value from config if an error occurs.
|
||||||
|
func (repo *Repository) IsTimetrackerEnabled() bool { |
||||||
|
var u *RepoUnit |
||||||
|
var err error |
||||||
|
if u, err = repo.GetUnit(UnitTypeIssues); err != nil { |
||||||
|
return setting.Service.DefaultEnableTimetracking |
||||||
|
} |
||||||
|
return u.IssuesConfig().EnableTimetracker |
||||||
|
} |
||||||
|
|
||||||
|
// AllowOnlyContributorsToTrackTime returns value of IssuesConfig or the default value
|
||||||
|
func (repo *Repository) AllowOnlyContributorsToTrackTime() bool { |
||||||
|
var u *RepoUnit |
||||||
|
var err error |
||||||
|
if u, err = repo.GetUnit(UnitTypeIssues); err != nil { |
||||||
|
return setting.Service.DefaultAllowOnlyContributorsToTrackTime |
||||||
|
} |
||||||
|
return u.IssuesConfig().AllowOnlyContributorsToTrackTime |
||||||
|
} |
@ -0,0 +1,158 @@ |
|||||||
|
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package repo |
||||||
|
|
||||||
|
import ( |
||||||
|
"code.gitea.io/gitea/models" |
||||||
|
"code.gitea.io/gitea/modules/context" |
||||||
|
api "code.gitea.io/sdk/gitea" |
||||||
|
) |
||||||
|
|
||||||
|
// ListTrackedTimes list all the tracked times of an issue
|
||||||
|
func ListTrackedTimes(ctx *context.APIContext) { |
||||||
|
// swagger:route GET /repos/{username}/{reponame}/issues/{issue}/times repository issueTrackedTimes
|
||||||
|
//
|
||||||
|
// Produces:
|
||||||
|
// - application/json
|
||||||
|
//
|
||||||
|
// Responses:
|
||||||
|
// 200: TrackedTimes
|
||||||
|
// 404: error
|
||||||
|
// 500: error
|
||||||
|
if !ctx.Repo.Repository.IsTimetrackerEnabled() { |
||||||
|
ctx.Error(404, "IsTimetrackerEnabled", "Timetracker is diabled") |
||||||
|
return |
||||||
|
} |
||||||
|
issue, err := models.GetIssueByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) |
||||||
|
if err != nil { |
||||||
|
if models.IsErrIssueNotExist(err) { |
||||||
|
ctx.Error(404, "GetIssueByIndex", err) |
||||||
|
} else { |
||||||
|
ctx.Error(500, "GetIssueByIndex", err) |
||||||
|
} |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
if trackedTimes, err := models.GetTrackedTimes(models.FindTrackedTimesOptions{IssueID: issue.ID}); err != nil { |
||||||
|
ctx.Error(500, "GetTrackedTimesByIssue", err) |
||||||
|
} else { |
||||||
|
ctx.JSON(200, &trackedTimes) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// AddTime adds time manual to the given issue
|
||||||
|
func AddTime(ctx *context.APIContext, form api.AddTimeOption) { |
||||||
|
// swagger:route Post /repos/{username}/{reponame}/issues/{issue}/times repository addTime
|
||||||
|
//
|
||||||
|
// Produces:
|
||||||
|
// - application/json
|
||||||
|
//
|
||||||
|
// Responses:
|
||||||
|
// 200: TrackedTime
|
||||||
|
// 400: error
|
||||||
|
// 403: error
|
||||||
|
// 404: error
|
||||||
|
// 500: error
|
||||||
|
issue, err := models.GetIssueByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) |
||||||
|
if err != nil { |
||||||
|
if models.IsErrIssueNotExist(err) { |
||||||
|
ctx.Error(404, "GetIssueByIndex", err) |
||||||
|
} else { |
||||||
|
ctx.Error(500, "GetIssueByIndex", err) |
||||||
|
} |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
if !ctx.Repo.CanUseTimetracker(issue, ctx.User) { |
||||||
|
if !ctx.Repo.Repository.IsTimetrackerEnabled() { |
||||||
|
ctx.JSON(400, struct{ Message string }{Message: "time tracking disabled"}) |
||||||
|
return |
||||||
|
} |
||||||
|
ctx.Status(403) |
||||||
|
return |
||||||
|
} |
||||||
|
var tt *models.TrackedTime |
||||||
|
if tt, err = models.AddTime(ctx.User, issue, form.Time); err != nil { |
||||||
|
ctx.Error(500, "AddTime", err) |
||||||
|
return |
||||||
|
} |
||||||
|
ctx.JSON(200, tt) |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
// ListTrackedTimesByUser lists all tracked times of the user
|
||||||
|
func ListTrackedTimesByUser(ctx *context.APIContext) { |
||||||
|
// swagger:route GET /repos/{username}/{reponame}/times/{timetrackingusername} user userTrackedTimes
|
||||||
|
//
|
||||||
|
// Produces:
|
||||||
|
// - application/json
|
||||||
|
//
|
||||||
|
// Responses:
|
||||||
|
// 200: TrackedTimes
|
||||||
|
// 400: error
|
||||||
|
// 404: error
|
||||||
|
// 500: error
|
||||||
|
if !ctx.Repo.Repository.IsTimetrackerEnabled() { |
||||||
|
ctx.JSON(400, struct{ Message string }{Message: "time tracking disabled"}) |
||||||
|
return |
||||||
|
} |
||||||
|
user, err := models.GetUserByName(ctx.Params(":timetrackingusername")) |
||||||
|
if err != nil { |
||||||
|
if models.IsErrUserNotExist(err) { |
||||||
|
ctx.Error(404, "GetUserByName", err) |
||||||
|
} else { |
||||||
|
ctx.Error(500, "GetUserByName", err) |
||||||
|
} |
||||||
|
return |
||||||
|
} |
||||||
|
if user == nil { |
||||||
|
ctx.Status(404) |
||||||
|
return |
||||||
|
} |
||||||
|
if trackedTimes, err := models.GetTrackedTimes(models.FindTrackedTimesOptions{UserID: user.ID, RepositoryID: ctx.Repo.Repository.ID}); err != nil { |
||||||
|
ctx.Error(500, "GetTrackedTimesByUser", err) |
||||||
|
} else { |
||||||
|
ctx.JSON(200, &trackedTimes) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// ListTrackedTimesByRepository lists all tracked times of the user
|
||||||
|
func ListTrackedTimesByRepository(ctx *context.APIContext) { |
||||||
|
// swagger:route GET /repos/{username}/{reponame}/times repository repoTrackedTimes
|
||||||
|
//
|
||||||
|
// Produces:
|
||||||
|
// - application/json
|
||||||
|
//
|
||||||
|
// Responses:
|
||||||
|
// 200: TrackedTimes
|
||||||
|
// 400: error
|
||||||
|
// 500: error
|
||||||
|
if !ctx.Repo.Repository.IsTimetrackerEnabled() { |
||||||
|
ctx.JSON(400, struct{ Message string }{Message: "time tracking disabled"}) |
||||||
|
return |
||||||
|
} |
||||||
|
if trackedTimes, err := models.GetTrackedTimes(models.FindTrackedTimesOptions{RepositoryID: ctx.Repo.Repository.ID}); err != nil { |
||||||
|
ctx.Error(500, "GetTrackedTimesByUser", err) |
||||||
|
} else { |
||||||
|
ctx.JSON(200, &trackedTimes) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// ListMyTrackedTimes lists all tracked times of the current user
|
||||||
|
func ListMyTrackedTimes(ctx *context.APIContext) { |
||||||
|
// swagger:route GET /user/times user userTrackedTimes
|
||||||
|
//
|
||||||
|
// Produces:
|
||||||
|
// - application/json
|
||||||
|
//
|
||||||
|
// Responses:
|
||||||
|
// 200: TrackedTimes
|
||||||
|
// 500: error
|
||||||
|
if trackedTimes, err := models.GetTrackedTimes(models.FindTrackedTimesOptions{UserID: ctx.User.ID}); err != nil { |
||||||
|
ctx.Error(500, "GetTrackedTimesByUser", err) |
||||||
|
} else { |
||||||
|
ctx.JSON(200, &trackedTimes) |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,50 @@ |
|||||||
|
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package repo |
||||||
|
|
||||||
|
import ( |
||||||
|
"net/http" |
||||||
|
|
||||||
|
"code.gitea.io/gitea/models" |
||||||
|
"code.gitea.io/gitea/modules/context" |
||||||
|
) |
||||||
|
|
||||||
|
// IssueStopwatch creates or stops a stopwatch for the given issue.
|
||||||
|
func IssueStopwatch(c *context.Context) { |
||||||
|
issueIndex := c.ParamsInt64("index") |
||||||
|
issue, err := models.GetIssueByIndex(c.Repo.Repository.ID, issueIndex) |
||||||
|
|
||||||
|
if err != nil { |
||||||
|
c.Handle(http.StatusInternalServerError, "GetIssueByIndex", err) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
if err := models.CreateOrStopIssueStopwatch(c.User, issue); err != nil { |
||||||
|
c.Handle(http.StatusInternalServerError, "CreateOrStopIssueStopwatch", err) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
url := issue.HTMLURL() |
||||||
|
c.Redirect(url, http.StatusSeeOther) |
||||||
|
} |
||||||
|
|
||||||
|
// CancelStopwatch cancel the stopwatch
|
||||||
|
func CancelStopwatch(c *context.Context) { |
||||||
|
issueIndex := c.ParamsInt64("index") |
||||||
|
issue, err := models.GetIssueByIndex(c.Repo.Repository.ID, issueIndex) |
||||||
|
|
||||||
|
if err != nil { |
||||||
|
c.Handle(http.StatusInternalServerError, "GetIssueByIndex", err) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
if err := models.CancelStopwatch(c.User, issue); err != nil { |
||||||
|
c.Handle(http.StatusInternalServerError, "CancelStopwatch", err) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
url := issue.HTMLURL() |
||||||
|
c.Redirect(url, http.StatusSeeOther) |
||||||
|
} |
@ -0,0 +1,50 @@ |
|||||||
|
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package repo |
||||||
|
|
||||||
|
import ( |
||||||
|
"net/http" |
||||||
|
"time" |
||||||
|
|
||||||
|
"code.gitea.io/gitea/models" |
||||||
|
"code.gitea.io/gitea/modules/auth" |
||||||
|
"code.gitea.io/gitea/modules/context" |
||||||
|
) |
||||||
|
|
||||||
|
// AddTimeManually tracks time manually
|
||||||
|
func AddTimeManually(c *context.Context, form auth.AddTimeManuallyForm) { |
||||||
|
issueIndex := c.ParamsInt64("index") |
||||||
|
issue, err := models.GetIssueByIndex(c.Repo.Repository.ID, issueIndex) |
||||||
|
if err != nil { |
||||||
|
if models.IsErrIssueNotExist(err) { |
||||||
|
c.Handle(http.StatusNotFound, "GetIssueByIndex", err) |
||||||
|
return |
||||||
|
} |
||||||
|
c.Handle(http.StatusInternalServerError, "GetIssueByIndex", err) |
||||||
|
return |
||||||
|
} |
||||||
|
url := issue.HTMLURL() |
||||||
|
|
||||||
|
if c.HasError() { |
||||||
|
c.Flash.Error(c.GetErrMsg()) |
||||||
|
c.Redirect(url) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
total := time.Duration(form.Hours)*time.Hour + time.Duration(form.Minutes)*time.Minute |
||||||
|
|
||||||
|
if total <= 0 { |
||||||
|
c.Flash.Error(c.Tr("repo.issues.add_time_sum_to_small")) |
||||||
|
c.Redirect(url, http.StatusSeeOther) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
if _, err := models.AddTime(c.User, issue, int64(total)); err != nil { |
||||||
|
c.Handle(http.StatusInternalServerError, "AddTime", err) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
c.Redirect(url, http.StatusSeeOther) |
||||||
|
} |
Loading…
Reference in new issue