Browse Source

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>
Jonas Franz 1 year ago
parent
commit
5ccecb44ad
42 changed files with 1522 additions and 71 deletions
  1. 6 0
      conf/app.ini
  2. 10 0
      integrations/html_helper.go
  3. 74 0
      integrations/timetracking_test.go
  4. 44 0
      models/error.go
  5. 3 0
      models/fixtures/comment.yml
  6. 13 0
      models/fixtures/issue.yml
  7. 2 2
      models/fixtures/repo_unit.yml
  8. 1 1
      models/fixtures/repository.yml
  9. 11 0
      models/fixtures/stopwatch.yml
  10. 34 0
      models/fixtures/tracked_time.yml
  11. 8 1
      models/issue_comment.go
  12. 170 0
      models/issue_stopwatch.go
  13. 70 0
      models/issue_stopwatch_test.go
  14. 117 0
      models/issue_tracked_time.go
  15. 103 0
      models/issue_tracked_time_test.go
  16. 2 0
      models/migrations/migrations.go
  17. 4 4
      models/migrations/v16.go
  18. 65 0
      models/migrations/v39.go
  19. 2 0
      models/models.go
  20. 17 7
      models/repo.go
  21. 34 0
      models/repo_issue.go
  22. 24 6
      models/repo_unit.go
  23. 30 10
      modules/auth/repo_form.go
  24. 1 0
      modules/auth/user_form.go
  25. 12 2
      modules/context/repo.go
  26. 18 14
      modules/setting/setting.go
  27. 23 0
      options/locale/locale_en-US.ini
  28. 21 0
      public/js/index.js
  29. 125 0
      public/swagger.v1.json
  30. 11 0
      routers/api/v1/api.go
  31. 158 0
      routers/api/v1/repo/issue_tracked_time.go
  32. 2 0
      routers/install.go
  33. 36 3
      routers/repo/issue.go
  34. 50 0
      routers/repo/issue_stopwatch.go
  35. 50 0
      routers/repo/issue_timetrack.go
  36. 4 1
      routers/repo/setting.go
  37. 13 0
      routers/routes/routes.go
  38. 4 0
      templates/admin/config.tmpl
  39. 6 0
      templates/install.tmpl
  40. 43 1
      templates/repo/issue/view_content/comments.tmpl
  41. 84 17
      templates/repo/issue/view_content/sidebar.tmpl
  42. 17 2
      templates/repo/settings/options.tmpl

+ 6 - 0
conf/app.ini

@@ -265,6 +265,12 @@ DEFAULT_KEEP_EMAIL_PRIVATE = false
265 265
 ; Default value for AllowCreateOrganization
266 266
 ; New user will have rights set to create organizations depending on this setting
267 267
 DEFAULT_ALLOW_CREATE_ORGANIZATION = true
268
+; Default value for EnableTimetracking
269
+; Repositories will use timetracking by default depending on this setting
270
+DEFAULT_ENABLE_TIMETRACKING = true
271
+; Default value for AllowOnlyContributorsToTrackTime
272
+; Only users with write permissions could track time if this is true
273
+DEFAULT_ALLOW_ONLY_CONTRIBUTORS_TO_TRACK_TIME = true
268 274
 ; Default value for the domain part of the user's email address in the git log
269 275
 ; if he has set KeepEmailPrivate true. The user's email replaced with a
270 276
 ; concatenation of the user name in lower case, "@" and NO_REPLY_ADDRESS.

+ 10 - 0
integrations/html_helper.go

@@ -40,3 +40,13 @@ func (doc *HTMLDoc) GetInputValueByName(name string) string {
40 40
 func (doc *HTMLDoc) GetCSRF() string {
41 41
 	return doc.GetInputValueByName("_csrf")
42 42
 }
43
+
44
+// AssertElement check if element by selector exists or does not exist depending on checkExists
45
+func (doc *HTMLDoc) AssertElement(t testing.TB, selector string, checkExists bool) {
46
+	sel := doc.doc.Find(selector)
47
+	if checkExists {
48
+		assert.Equal(t, 1, sel.Length())
49
+	} else {
50
+		assert.Equal(t, 0, sel.Length())
51
+	}
52
+}

+ 74 - 0
integrations/timetracking_test.go

@@ -0,0 +1,74 @@
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
+
5
+package integrations
6
+
7
+import (
8
+	"net/http"
9
+	"path"
10
+	"testing"
11
+
12
+	"github.com/stretchr/testify/assert"
13
+)
14
+
15
+func TestViewTimetrackingControls(t *testing.T) {
16
+	prepareTestEnv(t)
17
+	session := loginUser(t, "user2")
18
+	testViewTimetrackingControls(t, session, "user2", "repo1", "1", true)
19
+	//user2/repo1
20
+}
21
+
22
+func TestNotViewTimetrackingControls(t *testing.T) {
23
+	prepareTestEnv(t)
24
+	session := loginUser(t, "user5")
25
+	testViewTimetrackingControls(t, session, "user2", "repo1", "1", false)
26
+	//user2/repo1
27
+}
28
+func TestViewTimetrackingControlsDisabled(t *testing.T) {
29
+	prepareTestEnv(t)
30
+	session := loginUser(t, "user2")
31
+	testViewTimetrackingControls(t, session, "user3", "repo3", "1", false)
32
+}
33
+
34
+func testViewTimetrackingControls(t *testing.T, session *TestSession, user, repo, issue string, canTrackTime bool) {
35
+	req := NewRequest(t, "GET", path.Join(user, repo, "issues", issue))
36
+	resp := session.MakeRequest(t, req, http.StatusOK)
37
+
38
+	htmlDoc := NewHTMLParser(t, resp.Body)
39
+
40
+	htmlDoc.AssertElement(t, ".timetrack .start-add .start", canTrackTime)
41
+	htmlDoc.AssertElement(t, ".timetrack .start-add .add-time", canTrackTime)
42
+
43
+	req = NewRequestWithValues(t, "POST", path.Join(user, repo, "issues", issue, "times", "stopwatch", "toggle"), map[string]string{
44
+		"_csrf": htmlDoc.GetCSRF(),
45
+	})
46
+	if canTrackTime {
47
+		resp = session.MakeRequest(t, req, http.StatusSeeOther)
48
+
49
+		req = NewRequest(t, "GET", RedirectURL(t, resp))
50
+		resp = session.MakeRequest(t, req, http.StatusOK)
51
+		htmlDoc = NewHTMLParser(t, resp.Body)
52
+
53
+		events := htmlDoc.doc.Find(".event > span.text")
54
+		assert.Contains(t, events.Last().Text(), "started working")
55
+
56
+		htmlDoc.AssertElement(t, ".timetrack .stop-cancel .stop", true)
57
+		htmlDoc.AssertElement(t, ".timetrack .stop-cancel .cancel", true)
58
+
59
+		req = NewRequestWithValues(t, "POST", path.Join(user, repo, "issues", issue, "times", "stopwatch", "toggle"), map[string]string{
60
+			"_csrf": htmlDoc.GetCSRF(),
61
+		})
62
+		resp = session.MakeRequest(t, req, http.StatusSeeOther)
63
+
64
+		req = NewRequest(t, "GET", RedirectURL(t, resp))
65
+		resp = session.MakeRequest(t, req, http.StatusOK)
66
+		htmlDoc = NewHTMLParser(t, resp.Body)
67
+
68
+		events = htmlDoc.doc.Find(".event > span.text")
69
+		assert.Contains(t, events.Last().Text(), "stopped working")
70
+		htmlDoc.AssertElement(t, ".event .detail .octicon-clock", true)
71
+	} else {
72
+		session.MakeRequest(t, req, http.StatusNotFound)
73
+	}
74
+}

+ 44 - 0
models/error.go

@@ -768,6 +768,50 @@ func (err ErrCommentNotExist) Error() string {
768 768
 	return fmt.Sprintf("comment does not exist [id: %d, issue_id: %d]", err.ID, err.IssueID)
769 769
 }
770 770
 
771
+//  _________ __                                __         .__
772
+//  /   _____//  |_  ____ ________  _  _______ _/  |_  ____ |  |__
773
+//  \_____  \\   __\/  _ \\____ \ \/ \/ /\__  \\   __\/ ___\|  |  \
774
+//  /        \|  | (  <_> )  |_> >     /  / __ \|  | \  \___|   Y  \
775
+//  /_______  /|__|  \____/|   __/ \/\_/  (____  /__|  \___  >___|  /
776
+// \/             |__|                \/          \/     \/
777
+
778
+// ErrStopwatchNotExist represents a "Stopwatch Not Exist" kind of error.
779
+type ErrStopwatchNotExist struct {
780
+	ID int64
781
+}
782
+
783
+// IsErrStopwatchNotExist checks if an error is a ErrStopwatchNotExist.
784
+func IsErrStopwatchNotExist(err error) bool {
785
+	_, ok := err.(ErrStopwatchNotExist)
786
+	return ok
787
+}
788
+
789
+func (err ErrStopwatchNotExist) Error() string {
790
+	return fmt.Sprintf("stopwatch does not exist [id: %d]", err.ID)
791
+}
792
+
793
+// ___________                     __              .______________.__
794
+// \__    ___/___________    ____ |  | __ ____   __| _/\__    ___/|__| _____   ____
795
+// |    |  \_  __ \__  \ _/ ___\|  |/ // __ \ / __ |   |    |   |  |/     \_/ __ \
796
+// |    |   |  | \// __ \\  \___|    <\  ___// /_/ |   |    |   |  |  Y Y  \  ___/
797
+// |____|   |__|  (____  /\___  >__|_ \\___  >____ |   |____|   |__|__|_|  /\___  >
798
+// \/     \/     \/    \/     \/                     \/     \/
799
+
800
+// ErrTrackedTimeNotExist represents a "TrackedTime Not Exist" kind of error.
801
+type ErrTrackedTimeNotExist struct {
802
+	ID int64
803
+}
804
+
805
+// IsErrTrackedTimeNotExist checks if an error is a ErrTrackedTimeNotExist.
806
+func IsErrTrackedTimeNotExist(err error) bool {
807
+	_, ok := err.(ErrTrackedTimeNotExist)
808
+	return ok
809
+}
810
+
811
+func (err ErrTrackedTimeNotExist) Error() string {
812
+	return fmt.Sprintf("tracked time does not exist [id: %d]", err.ID)
813
+}
814
+
771 815
 // .____          ___.          .__
772 816
 // |    |   _____ \_ |__   ____ |  |
773 817
 // |    |   \__  \ | __ \_/ __ \|  |

+ 3 - 0
models/fixtures/comment.yml

@@ -5,15 +5,18 @@
5 5
   issue_id: 1 # in repo_id 1
6 6
   label_id: 1
7 7
   content: "1"
8
+  created_unix: 946684810
8 9
 -
9 10
   id: 2
10 11
   type: 0 # comment
11 12
   poster_id: 3 # user not watching (see watch.yml)
12 13
   issue_id: 1 # in repo_id 1
13 14
   content: "good work!"
15
+  created_unix: 946684811
14 16
 -
15 17
   id: 3
16 18
   type: 0 # comment
17 19
   poster_id: 5 # user not watching (see watch.yml)
18 20
   issue_id: 1 # in repo_id 1
19 21
   content: "meh..."
22
+  created_unix: 946684812

+ 13 - 0
models/fixtures/issue.yml

@@ -57,3 +57,16 @@
57 57
   content: content5
58 58
   is_closed: true
59 59
   is_pull: false
60
+-
61
+  id: 6
62
+  repo_id: 3
63
+  index: 1
64
+  poster_id: 1
65
+  assignee_id: 1
66
+  name: issue6
67
+  content: content6
68
+  is_closed: false
69
+  is_pull: false
70
+  num_comments: 0
71
+  created_unix: 946684800
72
+  updated_unix: 978307200

+ 2 - 2
models/fixtures/repo_unit.yml

@@ -11,7 +11,7 @@
11 11
   repo_id: 1
12 12
   type: 2
13 13
   index: 1
14
-  config: "{}"
14
+  config: "{\"EnableTimetracker\":true,\"AllowOnlyContributorsToTrackTime\":true}"
15 15
   created_unix: 946684810
16 16
 
17 17
 -
@@ -51,7 +51,7 @@
51 51
   repo_id: 3
52 52
   type: 2
53 53
   index: 1
54
-  config: "{}"
54
+  config: "{\"EnableTimetracker\":false,\"AllowOnlyContributorsToTrackTime\":false}"
55 55
   created_unix: 946684810
56 56
 
57 57
 -

+ 1 - 1
models/fixtures/repository.yml

@@ -29,7 +29,7 @@
29 29
   lower_name: repo3
30 30
   name: repo3
31 31
   is_private: true
32
-  num_issues: 0
32
+  num_issues: 1
33 33
   num_closed_issues: 0
34 34
   num_pulls: 0
35 35
   num_closed_pulls: 0

+ 11 - 0
models/fixtures/stopwatch.yml

@@ -0,0 +1,11 @@
1
+-
2
+  id: 1
3
+  user_id: 1
4
+  issue_id: 1
5
+  created_unix: 1500988502
6
+
7
+-
8
+  id: 2
9
+  user_id: 2
10
+  issue_id: 2
11
+  created_unix: 1500988502

+ 34 - 0
models/fixtures/tracked_time.yml

@@ -0,0 +1,34 @@
1
+-
2
+  id: 1
3
+  user_id: 1
4
+  issue_id: 1
5
+  time: 400
6
+  created_unix: 946684800
7
+
8
+-
9
+  id: 2
10
+  user_id: 2
11
+  issue_id: 2
12
+  time: 3661
13
+  created_unix: 946684801
14
+
15
+-
16
+  id: 3
17
+  user_id: 2
18
+  issue_id: 2
19
+  time: 1
20
+  created_unix: 946684802
21
+
22
+-
23
+  id: 4
24
+  user_id: -1
25
+  issue_id: 4
26
+  time: 1
27
+  created_unix: 946684802
28
+
29
+-
30
+  id: 5
31
+  user_id: 2
32
+  issue_id: 5
33
+  time: 1
34
+  created_unix: 946684802

+ 8 - 1
models/issue_comment.go

@@ -52,6 +52,14 @@ const (
52 52
 	CommentTypeChangeTitle
53 53
 	// Delete Branch
54 54
 	CommentTypeDeleteBranch
55
+	// Start a stopwatch for time tracking
56
+	CommentTypeStartTracking
57
+	// Stop a stopwatch for time tracking
58
+	CommentTypeStopTracking
59
+	// Add time manual for time tracking
60
+	CommentTypeAddTimeManual
61
+	// Cancel a stopwatch for time tracking
62
+	CommentTypeCancelTracking
55 63
 )
56 64
 
57 65
 // CommentTag defines comment tag type
@@ -672,7 +680,6 @@ func DeleteComment(comment *Comment) error {
672 680
 			return err
673 681
 		}
674 682
 	}
675
-
676 683
 	if _, err := sess.Where("comment_id = ?", comment.ID).Cols("is_deleted").Update(&Action{IsDeleted: true}); err != nil {
677 684
 		return err
678 685
 	}

+ 170 - 0
models/issue_stopwatch.go

@@ -0,0 +1,170 @@
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
+
5
+package models
6
+
7
+import (
8
+	"fmt"
9
+	"time"
10
+
11
+	"github.com/go-xorm/xorm"
12
+)
13
+
14
+// Stopwatch represents a stopwatch for time tracking.
15
+type Stopwatch struct {
16
+	ID          int64     `xorm:"pk autoincr"`
17
+	IssueID     int64     `xorm:"INDEX"`
18
+	UserID      int64     `xorm:"INDEX"`
19
+	Created     time.Time `xorm:"-"`
20
+	CreatedUnix int64
21
+}
22
+
23
+// BeforeInsert will be invoked by XORM before inserting a record
24
+// representing this object.
25
+func (s *Stopwatch) BeforeInsert() {
26
+	s.CreatedUnix = time.Now().Unix()
27
+}
28
+
29
+// AfterSet is invoked from XORM after setting the value of a field of this object.
30
+func (s *Stopwatch) AfterSet(colName string, _ xorm.Cell) {
31
+	switch colName {
32
+
33
+	case "created_unix":
34
+		s.Created = time.Unix(s.CreatedUnix, 0).Local()
35
+	}
36
+}
37
+
38
+func getStopwatch(e Engine, userID, issueID int64) (sw *Stopwatch, exists bool, err error) {
39
+	sw = new(Stopwatch)
40
+	exists, err = e.
41
+		Where("user_id = ?", userID).
42
+		And("issue_id = ?", issueID).
43
+		Get(sw)
44
+	return
45
+}
46
+
47
+// StopwatchExists returns true if the stopwatch exists
48
+func StopwatchExists(userID int64, issueID int64) bool {
49
+	_, exists, _ := getStopwatch(x, userID, issueID)
50
+	return exists
51
+}
52
+
53
+// HasUserStopwatch returns true if the user has a stopwatch
54
+func HasUserStopwatch(userID int64) (exists bool, sw *Stopwatch, err error) {
55
+	sw = new(Stopwatch)
56
+	exists, err = x.
57
+		Where("user_id = ?", userID).
58
+		Get(sw)
59
+	return
60
+}
61
+
62
+// CreateOrStopIssueStopwatch will create or remove a stopwatch and will log it into issue's timeline.
63
+func CreateOrStopIssueStopwatch(user *User, issue *Issue) error {
64
+	sw, exists, err := getStopwatch(x, user.ID, issue.ID)
65
+	if err != nil {
66
+		return err
67
+	}
68
+	if exists {
69
+		// Create tracked time out of the time difference between start date and actual date
70
+		timediff := time.Now().Unix() - sw.CreatedUnix
71
+
72
+		// Create TrackedTime
73
+		tt := &TrackedTime{
74
+			Created: time.Now(),
75
+			IssueID: issue.ID,
76
+			UserID:  user.ID,
77
+			Time:    timediff,
78
+		}
79
+
80
+		if _, err := x.Insert(tt); err != nil {
81
+			return err
82
+		}
83
+
84
+		if _, err := CreateComment(&CreateCommentOptions{
85
+			Doer:    user,
86
+			Issue:   issue,
87
+			Repo:    issue.Repo,
88
+			Content: secToTime(timediff),
89
+			Type:    CommentTypeStopTracking,
90
+		}); err != nil {
91
+			return err
92
+		}
93
+		if _, err := x.Delete(sw); err != nil {
94
+			return err
95
+		}
96
+	} else {
97
+		// Create stopwatch
98
+		sw = &Stopwatch{
99
+			UserID:  user.ID,
100
+			IssueID: issue.ID,
101
+			Created: time.Now(),
102
+		}
103
+
104
+		if _, err := x.Insert(sw); err != nil {
105
+			return err
106
+		}
107
+
108
+		if _, err := CreateComment(&CreateCommentOptions{
109
+			Doer:  user,
110
+			Issue: issue,
111
+			Repo:  issue.Repo,
112
+			Type:  CommentTypeStartTracking,
113
+		}); err != nil {
114
+			return err
115
+		}
116
+	}
117
+	return nil
118
+}
119
+
120
+// CancelStopwatch removes the given stopwatch and logs it into issue's timeline.
121
+func CancelStopwatch(user *User, issue *Issue) error {
122
+	sw, exists, err := getStopwatch(x, user.ID, issue.ID)
123
+	if err != nil {
124
+		return err
125
+	}
126
+
127
+	if exists {
128
+		if _, err := x.Delete(sw); err != nil {
129
+			return err
130
+		}
131
+
132
+		if _, err := CreateComment(&CreateCommentOptions{
133
+			Doer:  user,
134
+			Issue: issue,
135
+			Repo:  issue.Repo,
136
+			Type:  CommentTypeCancelTracking,
137
+		}); err != nil {
138
+			return err
139
+		}
140
+	}
141
+	return nil
142
+}
143
+
144
+func secToTime(duration int64) string {
145
+	seconds := duration % 60
146
+	minutes := (duration / (60)) % 60
147
+	hours := duration / (60 * 60)
148
+
149
+	var hrs string
150
+
151
+	if hours > 0 {
152
+		hrs = fmt.Sprintf("%dh", hours)
153
+	}
154
+	if minutes > 0 {
155
+		if hours == 0 {
156
+			hrs = fmt.Sprintf("%dmin", minutes)
157
+		} else {
158
+			hrs = fmt.Sprintf("%s %dmin", hrs, minutes)
159
+		}
160
+	}
161
+	if seconds > 0 {
162
+		if hours == 0 && minutes == 0 {
163
+			hrs = fmt.Sprintf("%ds", seconds)
164
+		} else {
165
+			hrs = fmt.Sprintf("%s %ds", hrs, seconds)
166
+		}
167
+	}
168
+
169
+	return hrs
170
+}

+ 70 - 0
models/issue_stopwatch_test.go

@@ -0,0 +1,70 @@
1
+package models
2
+
3
+import (
4
+	"testing"
5
+	"time"
6
+
7
+	"github.com/stretchr/testify/assert"
8
+)
9
+
10
+func TestCancelStopwatch(t *testing.T) {
11
+	assert.NoError(t, PrepareTestDatabase())
12
+
13
+	user1, err := GetUserByID(1)
14
+	assert.NoError(t, err)
15
+
16
+	issue1, err := GetIssueByID(1)
17
+	assert.NoError(t, err)
18
+	issue2, err := GetIssueByID(2)
19
+	assert.NoError(t, err)
20
+
21
+	err = CancelStopwatch(user1, issue1)
22
+	assert.NoError(t, err)
23
+	AssertNotExistsBean(t, &Stopwatch{UserID: user1.ID, IssueID: issue1.ID})
24
+
25
+	_ = AssertExistsAndLoadBean(t, &Comment{Type: CommentTypeCancelTracking, PosterID: user1.ID, IssueID: issue1.ID})
26
+
27
+	assert.Nil(t, CancelStopwatch(user1, issue2))
28
+}
29
+
30
+func TestStopwatchExists(t *testing.T) {
31
+	assert.NoError(t, PrepareTestDatabase())
32
+
33
+	assert.True(t, StopwatchExists(1, 1))
34
+	assert.False(t, StopwatchExists(1, 2))
35
+}
36
+
37
+func TestHasUserStopwatch(t *testing.T) {
38
+	assert.NoError(t, PrepareTestDatabase())
39
+
40
+	exists, sw, err := HasUserStopwatch(1)
41
+	assert.NoError(t, err)
42
+	assert.True(t, exists)
43
+	assert.Equal(t, int64(1), sw.ID)
44
+
45
+	exists, _, err = HasUserStopwatch(3)
46
+	assert.NoError(t, err)
47
+	assert.False(t, exists)
48
+}
49
+
50
+func TestCreateOrStopIssueStopwatch(t *testing.T) {
51
+	assert.NoError(t, PrepareTestDatabase())
52
+
53
+	user2, err := GetUserByID(2)
54
+	assert.NoError(t, err)
55
+	user3, err := GetUserByID(3)
56
+	assert.NoError(t, err)
57
+
58
+	issue1, err := GetIssueByID(1)
59
+	assert.NoError(t, err)
60
+	issue2, err := GetIssueByID(2)
61
+	assert.NoError(t, err)
62
+
63
+	assert.NoError(t, CreateOrStopIssueStopwatch(user3, issue1))
64
+	sw := AssertExistsAndLoadBean(t, &Stopwatch{UserID: 3, IssueID: 1}).(*Stopwatch)
65
+	assert.Equal(t, true, sw.Created.Before(time.Now()))
66
+
67
+	assert.NoError(t, CreateOrStopIssueStopwatch(user2, issue2))
68
+	AssertNotExistsBean(t, &Stopwatch{UserID: 2, IssueID: 2})
69
+	AssertExistsAndLoadBean(t, &TrackedTime{UserID: 2, IssueID: 2})
70
+}

+ 117 - 0
models/issue_tracked_time.go

@@ -0,0 +1,117 @@
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
+
5
+package models
6
+
7
+import (
8
+	"time"
9
+
10
+	"github.com/go-xorm/builder"
11
+	"github.com/go-xorm/xorm"
12
+)
13
+
14
+// TrackedTime represents a time that was spent for a specific issue.
15
+type TrackedTime struct {
16
+	ID          int64     `xorm:"pk autoincr" json:"id"`
17
+	IssueID     int64     `xorm:"INDEX" json:"issue_id"`
18
+	UserID      int64     `xorm:"INDEX" json:"user_id"`
19
+	Created     time.Time `xorm:"-" json:"created"`
20
+	CreatedUnix int64     `json:"-"`
21
+	Time        int64     `json:"time"`
22
+}
23
+
24
+// BeforeInsert will be invoked by XORM before inserting a record
25
+// representing this object.
26
+func (t *TrackedTime) BeforeInsert() {
27
+	t.CreatedUnix = time.Now().Unix()
28
+}
29
+
30
+// AfterSet is invoked from XORM after setting the value of a field of this object.
31
+func (t *TrackedTime) AfterSet(colName string, _ xorm.Cell) {
32
+	switch colName {
33
+	case "created_unix":
34
+		t.Created = time.Unix(t.CreatedUnix, 0).Local()
35
+	}
36
+}
37
+
38
+// FindTrackedTimesOptions represent the filters for tracked times. If an ID is 0 it will be ignored.
39
+type FindTrackedTimesOptions struct {
40
+	IssueID      int64
41
+	UserID       int64
42
+	RepositoryID int64
43
+}
44
+
45
+// ToCond will convert each condition into a xorm-Cond
46
+func (opts *FindTrackedTimesOptions) ToCond() builder.Cond {
47
+	cond := builder.NewCond()
48
+	if opts.IssueID != 0 {
49
+		cond = cond.And(builder.Eq{"issue_id": opts.IssueID})
50
+	}
51
+	if opts.UserID != 0 {
52
+		cond = cond.And(builder.Eq{"user_id": opts.UserID})
53
+	}
54
+	if opts.RepositoryID != 0 {
55
+		cond = cond.And(builder.Eq{"issue.repo_id": opts.RepositoryID})
56
+	}
57
+	return cond
58
+}
59
+
60
+// GetTrackedTimes returns all tracked times that fit to the given options.
61
+func GetTrackedTimes(options FindTrackedTimesOptions) (trackedTimes []*TrackedTime, err error) {
62
+	if options.RepositoryID > 0 {
63
+		err = x.Join("INNER", "issue", "issue.id = tracked_time.issue_id").Where(options.ToCond()).Find(&trackedTimes)
64
+		return
65
+	}
66
+	err = x.Where(options.ToCond()).Find(&trackedTimes)
67
+	return
68
+}
69
+
70
+// AddTime will add the given time (in seconds) to the issue
71
+func AddTime(user *User, issue *Issue, time int64) (*TrackedTime, error) {
72
+	tt := &TrackedTime{
73
+		IssueID: issue.ID,
74
+		UserID:  user.ID,
75
+		Time:    time,
76
+	}
77
+	if _, err := x.Insert(tt); err != nil {
78
+		return nil, err
79
+	}
80
+	if _, err := CreateComment(&CreateCommentOptions{
81
+		Issue:   issue,
82
+		Repo:    issue.Repo,
83
+		Doer:    user,
84
+		Content: secToTime(time),
85
+		Type:    CommentTypeAddTimeManual,
86
+	}); err != nil {
87
+		return nil, err
88
+	}
89
+	return tt, nil
90
+}
91
+
92
+// TotalTimes returns the spent time for each user by an issue
93
+func TotalTimes(options FindTrackedTimesOptions) (map[*User]string, error) {
94
+	trackedTimes, err := GetTrackedTimes(options)
95
+	if err != nil {
96
+		return nil, err
97
+	}
98
+	//Adding total time per user ID
99
+	totalTimesByUser := make(map[int64]int64)
100
+	for _, t := range trackedTimes {
101
+		totalTimesByUser[t.UserID] += t.Time
102
+	}
103
+
104
+	totalTimes := make(map[*User]string)
105
+	//Fetching User and making time human readable
106
+	for userID, total := range totalTimesByUser {
107
+		user, err := GetUserByID(userID)
108
+		if err != nil {
109
+			if IsErrUserNotExist(err) {
110
+				continue
111
+			}
112
+			return nil, err
113
+		}
114
+		totalTimes[user] = secToTime(total)
115
+	}
116
+	return totalTimes, nil
117
+}

+ 103 - 0
models/issue_tracked_time_test.go

@@ -0,0 +1,103 @@
1
+package models
2
+
3
+import (
4
+	"testing"
5
+
6
+	"github.com/stretchr/testify/assert"
7
+)
8
+
9
+func TestAddTime(t *testing.T) {
10
+	assert.NoError(t, PrepareTestDatabase())
11
+
12
+	user3, err := GetUserByID(3)
13
+	assert.NoError(t, err)
14
+
15
+	issue1, err := GetIssueByID(1)
16
+	assert.NoError(t, err)
17
+
18
+	//3661 = 1h 1min 1s
19
+	trackedTime, err := AddTime(user3, issue1, 3661)
20
+	assert.NoError(t, err)
21
+	assert.Equal(t, int64(3), trackedTime.UserID)
22
+	assert.Equal(t, int64(1), trackedTime.IssueID)
23
+	assert.Equal(t, int64(3661), trackedTime.Time)
24
+
25
+	tt := AssertExistsAndLoadBean(t, &TrackedTime{UserID: 3, IssueID: 1}).(*TrackedTime)
26
+	assert.Equal(t, tt.Time, int64(3661))
27
+
28
+	comment := AssertExistsAndLoadBean(t, &Comment{Type: CommentTypeAddTimeManual, PosterID: 3, IssueID: 1}).(*Comment)
29
+	assert.Equal(t, comment.Content, "1h 1min 1s")
30
+}
31
+
32
+func TestGetTrackedTimes(t *testing.T) {
33
+	assert.NoError(t, PrepareTestDatabase())
34
+
35
+	// by Issue
36
+	times, err := GetTrackedTimes(FindTrackedTimesOptions{IssueID: 1})
37
+	assert.NoError(t, err)
38
+	assert.Len(t, times, 1)
39
+	assert.Equal(t, times[0].Time, int64(400))
40
+
41
+	times, err = GetTrackedTimes(FindTrackedTimesOptions{IssueID: -1})
42
+	assert.NoError(t, err)
43
+	assert.Len(t, times, 0)
44
+
45
+	// by User
46
+	times, err = GetTrackedTimes(FindTrackedTimesOptions{UserID: 1})
47
+	assert.NoError(t, err)
48
+	assert.Len(t, times, 1)
49
+	assert.Equal(t, times[0].Time, int64(400))
50
+
51
+	times, err = GetTrackedTimes(FindTrackedTimesOptions{UserID: 3})
52
+	assert.NoError(t, err)
53
+	assert.Len(t, times, 0)
54
+
55
+	// by Repo
56
+	times, err = GetTrackedTimes(FindTrackedTimesOptions{RepositoryID: 2})
57
+	assert.NoError(t, err)
58
+	assert.Len(t, times, 1)
59
+	assert.Equal(t, times[0].Time, int64(1))
60
+	issue, err := GetIssueByID(times[0].IssueID)
61
+	assert.NoError(t, err)
62
+	assert.Equal(t, issue.RepoID, int64(2))
63
+
64
+	times, err = GetTrackedTimes(FindTrackedTimesOptions{RepositoryID: 1})
65
+	assert.NoError(t, err)
66
+	assert.Len(t, times, 4)
67
+
68
+	times, err = GetTrackedTimes(FindTrackedTimesOptions{RepositoryID: 10})
69
+	assert.NoError(t, err)
70
+	assert.Len(t, times, 0)
71
+}
72
+
73
+func TestTotalTimes(t *testing.T) {
74
+	assert.NoError(t, PrepareTestDatabase())
75
+
76
+	total, err := TotalTimes(FindTrackedTimesOptions{IssueID: 1})
77
+	assert.NoError(t, err)
78
+	assert.Len(t, total, 1)
79
+	for user, time := range total {
80
+		assert.Equal(t, int64(1), user.ID)
81
+		assert.Equal(t, "6min 40s", time)
82
+	}
83
+
84
+	total, err = TotalTimes(FindTrackedTimesOptions{IssueID: 2})
85
+	assert.NoError(t, err)
86
+	assert.Len(t, total, 1)
87
+	for user, time := range total {
88
+		assert.Equal(t, int64(2), user.ID)
89
+		assert.Equal(t, "1h 1min 2s", time)
90
+	}
91
+
92
+	total, err = TotalTimes(FindTrackedTimesOptions{IssueID: 5})
93
+	assert.NoError(t, err)
94
+	assert.Len(t, total, 1)
95
+	for user, time := range total {
96
+		assert.Equal(t, int64(2), user.ID)
97
+		assert.Equal(t, "1s", time)
98
+	}
99
+
100
+	total, err = TotalTimes(FindTrackedTimesOptions{IssueID: 4})
101
+	assert.NoError(t, err)
102
+	assert.Len(t, total, 0)
103
+}

+ 2 - 0
models/migrations/migrations.go

@@ -126,6 +126,8 @@ var migrations = []Migration{
126 126
 	NewMigration("unescape user full names", unescapeUserFullNames),
127 127
 	// v38 -> v39
128 128
 	NewMigration("remove commits and settings unit types", removeCommitsUnitType),
129
+	// v39 -> v40
130
+	NewMigration("adds time tracking and stopwatches", addTimetracking),
129 131
 }
130 132
 
131 133
 // Migrate database to current version

+ 4 - 4
models/migrations/v16.go

@@ -19,9 +19,9 @@ type RepoUnit struct {
19 19
 	RepoID      int64 `xorm:"INDEX(s)"`
20 20
 	Type        int   `xorm:"INDEX(s)"`
21 21
 	Index       int
22
-	Config      map[string]string `xorm:"JSON"`
23
-	CreatedUnix int64             `xorm:"INDEX CREATED"`
24
-	Created     time.Time         `xorm:"-"`
22
+	Config      map[string]interface{} `xorm:"JSON"`
23
+	CreatedUnix int64                  `xorm:"INDEX CREATED"`
24
+	Created     time.Time              `xorm:"-"`
25 25
 }
26 26
 
27 27
 // Enumerate all the unit types
@@ -95,7 +95,7 @@ func addUnitsToTables(x *xorm.Engine) error {
95 95
 				continue
96 96
 			}
97 97
 
98
-			var config = make(map[string]string)
98
+			var config = make(map[string]interface{})
99 99
 			switch i {
100 100
 			case V16UnitTypeExternalTracker:
101 101
 				config["ExternalTrackerURL"] = repo.ExternalTrackerURL

+ 65 - 0
models/migrations/v39.go

@@ -0,0 +1,65 @@
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
+
5
+package migrations
6
+
7
+import (
8
+	"fmt"
9
+	"time"
10
+
11
+	"code.gitea.io/gitea/modules/setting"
12
+
13
+	"github.com/go-xorm/xorm"
14
+)
15
+
16
+// Stopwatch see models/issue_stopwatch.go
17
+type Stopwatch struct {
18
+	ID          int64     `xorm:"pk autoincr"`
19
+	IssueID     int64     `xorm:"INDEX"`
20
+	UserID      int64     `xorm:"INDEX"`
21
+	Created     time.Time `xorm:"-"`
22
+	CreatedUnix int64
23
+}
24
+
25
+// TrackedTime see models/issue_tracked_time.go
26
+type TrackedTime struct {
27
+	ID          int64     `xorm:"pk autoincr" json:"id"`
28
+	IssueID     int64     `xorm:"INDEX" json:"issue_id"`
29
+	UserID      int64     `xorm:"INDEX" json:"user_id"`
30
+	Created     time.Time `xorm:"-" json:"created"`
31
+	CreatedUnix int64     `json:"-"`
32
+	Time        int64     `json:"time"`
33
+}
34
+
35
+func addTimetracking(x *xorm.Engine) error {
36
+	if err := x.Sync2(new(Stopwatch)); err != nil {
37
+		return fmt.Errorf("Sync2: %v", err)
38
+	}
39
+	if err := x.Sync2(new(TrackedTime)); err != nil {
40
+		return fmt.Errorf("Sync2: %v", err)
41
+	}
42
+	//Updating existing issue units
43
+	var units []*RepoUnit
44
+	x.Where("type = ?", V16UnitTypeIssues).Find(&units)
45
+	for _, unit := range units {
46
+		if unit.Config == nil {
47
+			unit.Config = make(map[string]interface{})
48
+		}
49
+		changes := false
50
+		if _, ok := unit.Config["EnableTimetracker"]; !ok {
51
+			unit.Config["EnableTimetracker"] = setting.Service.DefaultEnableTimetracking
52
+			changes = true
53
+		}
54
+		if _, ok := unit.Config["AllowOnlyContributorsToTrackTime"]; !ok {
55
+			unit.Config["AllowOnlyContributorsToTrackTime"] = setting.Service.DefaultAllowOnlyContributorsToTrackTime
56
+			changes = true
57
+		}
58
+		if changes {
59
+			if _, err := x.Id(unit.ID).Cols("config").Update(unit); err != nil {
60
+				return err
61
+			}
62
+		}
63
+	}
64
+	return nil
65
+}

+ 2 - 0
models/models.go

@@ -112,6 +112,8 @@ func init() {
112 112
 		new(UserOpenID),
113 113
 		new(IssueWatch),
114 114
 		new(CommitStatus),
115
+		new(Stopwatch),
116
+		new(TrackedTime),
115 117
 	)
116 118
 
117 119
 	gonicNames := []string{"SSL", "UID"}

+ 17 - 7
models/repo.go

@@ -32,8 +32,8 @@ import (
32 32
 	"github.com/Unknwon/cae/zip"
33 33
 	"github.com/Unknwon/com"
34 34
 	"github.com/go-xorm/xorm"
35
-	version "github.com/mcuadros/go-version"
36
-	ini "gopkg.in/ini.v1"
35
+	"github.com/mcuadros/go-version"
36
+	"gopkg.in/ini.v1"
37 37
 )
38 38
 
39 39
 const (
@@ -1224,11 +1224,21 @@ func createRepository(e *xorm.Session, doer, u *User, repo *Repository) (err err
1224 1224
 	// insert units for repo
1225 1225
 	var units = make([]RepoUnit, 0, len(defaultRepoUnits))
1226 1226
 	for i, tp := range defaultRepoUnits {
1227
-		units = append(units, RepoUnit{
1228
-			RepoID: repo.ID,
1229
-			Type:   tp,
1230
-			Index:  i,
1231
-		})
1227
+		if tp == UnitTypeIssues {
1228
+			units = append(units, RepoUnit{
1229
+				RepoID: repo.ID,
1230
+				Type:   tp,
1231
+				Index:  i,
1232
+				Config: &IssuesConfig{EnableTimetracker: setting.Service.DefaultEnableTimetracking, AllowOnlyContributorsToTrackTime: setting.Service.DefaultAllowOnlyContributorsToTrackTime},
1233
+			})
1234
+		} else {
1235
+			units = append(units, RepoUnit{
1236
+				RepoID: repo.ID,
1237
+				Type:   tp,
1238
+				Index:  i,
1239
+			})
1240
+		}
1241
+
1232 1242
 	}
1233 1243
 
1234 1244
 	if _, err = e.Insert(&units); err != nil {

+ 34 - 0
models/repo_issue.go

@@ -0,0 +1,34 @@
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
+
5
+package models
6
+
7
+import "code.gitea.io/gitea/modules/setting"
8
+
9
+// ___________.__             ___________                     __
10
+// \__    ___/|__| _____   ___\__    ___/___________    ____ |  | __ ___________
11
+// |    |   |  |/     \_/ __ \|    |  \_  __ \__  \ _/ ___\|  |/ // __ \_  __ \
12
+// |    |   |  |  Y Y  \  ___/|    |   |  | \// __ \\  \___|    <\  ___/|  | \/
13
+// |____|   |__|__|_|  /\___  >____|   |__|  (____  /\___  >__|_ \\___  >__|
14
+// \/     \/                    \/     \/     \/    \/
15
+
16
+// IsTimetrackerEnabled returns whether or not the timetracker is enabled. It returns the default value from config if an error occurs.
17
+func (repo *Repository) IsTimetrackerEnabled() bool {
18
+	var u *RepoUnit
19
+	var err error
20
+	if u, err = repo.GetUnit(UnitTypeIssues); err != nil {
21
+		return setting.Service.DefaultEnableTimetracking
22
+	}
23
+	return u.IssuesConfig().EnableTimetracker
24
+}
25
+
26
+// AllowOnlyContributorsToTrackTime returns value of IssuesConfig or the default value
27
+func (repo *Repository) AllowOnlyContributorsToTrackTime() bool {
28
+	var u *RepoUnit
29
+	var err error
30
+	if u, err = repo.GetUnit(UnitTypeIssues); err != nil {
31
+		return setting.Service.DefaultAllowOnlyContributorsToTrackTime
32
+	}
33
+	return u.IssuesConfig().AllowOnlyContributorsToTrackTime
34
+}

+ 24 - 6
models/repo_unit.go

@@ -70,18 +70,36 @@ func (cfg *ExternalTrackerConfig) ToDB() ([]byte, error) {
70 70
 	return json.Marshal(cfg)
71 71
 }
72 72
 
73
+// IssuesConfig describes issues config
74
+type IssuesConfig struct {
75
+	EnableTimetracker                bool
76
+	AllowOnlyContributorsToTrackTime bool
77
+}
78
+
79
+// FromDB fills up a IssuesConfig from serialized format.
80
+func (cfg *IssuesConfig) FromDB(bs []byte) error {
81
+	return json.Unmarshal(bs, &cfg)
82
+}
83
+
84
+// ToDB exports a IssuesConfig to a serialized format.
85
+func (cfg *IssuesConfig) ToDB() ([]byte, error) {
86
+	return json.Marshal(cfg)
87
+}
88
+
73 89
 // BeforeSet is invoked from XORM before setting the value of a field of this object.
74 90
 func (r *RepoUnit) BeforeSet(colName string, val xorm.Cell) {
75 91
 	switch colName {
76 92
 	case "type":
77 93
 		switch UnitType(Cell2Int64(val)) {
78
-		case UnitTypeCode, UnitTypeIssues, UnitTypePullRequests, UnitTypeReleases,
94
+		case UnitTypeCode, UnitTypePullRequests, UnitTypeReleases,
79 95
 			UnitTypeWiki:
80 96
 			r.Config = new(UnitConfig)
81 97
 		case UnitTypeExternalWiki:
82 98
 			r.Config = new(ExternalWikiConfig)
83 99
 		case UnitTypeExternalTracker:
84 100
 			r.Config = new(ExternalTrackerConfig)
101
+		case UnitTypeIssues:
102
+			r.Config = new(IssuesConfig)
85 103
 		default:
86 104
 			panic("unrecognized repo unit type: " + com.ToStr(*val))
87 105
 		}
@@ -106,11 +124,6 @@ func (r *RepoUnit) CodeConfig() *UnitConfig {
106 124
 	return r.Config.(*UnitConfig)
107 125
 }
108 126
 
109
-// IssuesConfig returns config for UnitTypeIssues
110
-func (r *RepoUnit) IssuesConfig() *UnitConfig {
111
-	return r.Config.(*UnitConfig)
112
-}
113
-
114 127
 // PullRequestsConfig returns config for UnitTypePullRequests
115 128
 func (r *RepoUnit) PullRequestsConfig() *UnitConfig {
116 129
 	return r.Config.(*UnitConfig)
@@ -126,6 +139,11 @@ func (r *RepoUnit) ExternalWikiConfig() *ExternalWikiConfig {
126 139
 	return r.Config.(*ExternalWikiConfig)
127 140
 }
128 141
 
142
+// IssuesConfig returns config for UnitTypeIssues
143
+func (r *RepoUnit) IssuesConfig() *IssuesConfig {
144
+	return r.Config.(*IssuesConfig)
145
+}
146
+
129 147
 // ExternalTrackerConfig returns config for UnitTypeExternalTracker
130 148
 func (r *RepoUnit) ExternalTrackerConfig() *ExternalTrackerConfig {
131 149
 	return r.Config.(*ExternalTrackerConfig)

+ 30 - 10
modules/auth/repo_form.go

@@ -12,7 +12,7 @@ import (
12 12
 	"code.gitea.io/gitea/models"
13 13
 	"github.com/Unknwon/com"
14 14
 	"github.com/go-macaron/binding"
15
-	macaron "gopkg.in/macaron.v1"
15
+	"gopkg.in/macaron.v1"
16 16
 )
17 17
 
18 18
 // _______________________________________    _________.______________________ _______________.___.
@@ -95,15 +95,17 @@ type RepoSettingForm struct {
95 95
 	EnablePrune   bool
96 96
 
97 97
 	// Advanced settings
98
-	EnableWiki            bool
99
-	EnableExternalWiki    bool
100
-	ExternalWikiURL       string
101
-	EnableIssues          bool
102
-	EnableExternalTracker bool
103
-	ExternalTrackerURL    string
104
-	TrackerURLFormat      string
105
-	TrackerIssueStyle     string
106
-	EnablePulls           bool
98
+	EnableWiki                       bool
99
+	EnableExternalWiki               bool
100
+	ExternalWikiURL                  string
101
+	EnableIssues                     bool
102
+	EnableExternalTracker            bool
103
+	ExternalTrackerURL               string
104
+	TrackerURLFormat                 string
105
+	TrackerIssueStyle                string
106
+	EnablePulls                      bool
107
+	EnableTimetracker                bool
108
+	AllowOnlyContributorsToTrackTime bool
107 109
 }
108 110
 
109 111
 // Validate validates the fields
@@ -423,3 +425,21 @@ type DeleteRepoFileForm struct {
423 425
 func (f *DeleteRepoFileForm) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors {
424 426
 	return validate(errs, ctx.Data, f, ctx.Locale)
425 427
 }
428
+
429
+// ___________.__                 ___________                     __
430
+// \__    ___/|__| _____   ____   \__    ___/___________    ____ |  | __ ___________
431
+// |    |   |  |/     \_/ __ \    |    |  \_  __ \__  \ _/ ___\|  |/ // __ \_  __ \
432
+// |    |   |  |  Y Y  \  ___/    |    |   |  | \// __ \\  \___|    <\  ___/|  | \/
433
+// |____|   |__|__|_|  /\___  >   |____|   |__|  (____  /\___  >__|_ \\___  >__|
434
+// \/     \/                        \/     \/     \/    \/
435
+
436
+// AddTimeManuallyForm form that adds spent time manually.
437
+type AddTimeManuallyForm struct {
438
+	Hours   int `binding:"Range(0,1000)"`
439
+	Minutes int `binding:"Range(0,1000)"`
440
+}
441
+
442
+// Validate validates the fields
443
+func (f *AddTimeManuallyForm) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors {
444
+	return validate(errs, ctx.Data, f, ctx.Locale)
445
+}

+ 1 - 0
modules/auth/user_form.go

@@ -48,6 +48,7 @@ type InstallForm struct {
48 48
 	RequireSignInView              bool
49 49
 	DefaultKeepEmailPrivate        bool
50 50
 	DefaultAllowCreateOrganization bool
51
+	DefaultEnableTimetracking      bool
51 52
 	NoReplyAddress                 string
52 53
 
53 54
 	AdminName          string `binding:"OmitEmpty;AlphaDashDot;MaxSize(30)" locale:"install.admin_name"`

+ 12 - 2
modules/context/repo.go

@@ -1,4 +1,5 @@
1 1
 // Copyright 2014 The Gogs Authors. All rights reserved.
2
+// Copyright 2017 The Gitea Authors. All rights reserved.
2 3
 // Use of this source code is governed by a MIT-style
3 4
 // license that can be found in the LICENSE file.
4 5
 
@@ -14,8 +15,8 @@ import (
14 15
 	"code.gitea.io/gitea/models"
15 16
 	"code.gitea.io/gitea/modules/setting"
16 17
 	"github.com/Unknwon/com"
17
-	editorconfig "gopkg.in/editorconfig/editorconfig-core-go.v1"
18
-	macaron "gopkg.in/macaron.v1"
18
+	"gopkg.in/editorconfig/editorconfig-core-go.v1"
19
+	"gopkg.in/macaron.v1"
19 20
 )
20 21
 
21 22
 // PullRequest contains informations to make a pull request
@@ -85,6 +86,15 @@ func (r *Repository) CanCommitToBranch() (bool, error) {
85 86
 	return r.CanEnableEditor() && !protectedBranch, nil
86 87
 }
87 88
 
89
+// CanUseTimetracker returns whether or not a user can use the timetracker.
90
+func (r *Repository) CanUseTimetracker(issue *models.Issue, user *models.User) bool {
91
+	// Checking for following:
92
+	// 1. Is timetracker enabled
93
+	// 2. Is the user a contributor, admin, poster or assignee and do the repository policies require this?
94
+	return r.Repository.IsTimetrackerEnabled() && (!r.Repository.AllowOnlyContributorsToTrackTime() ||
95
+		r.IsWriter() || issue.IsPoster(user.ID) || issue.AssigneeID == user.ID)
96
+}
97
+
88 98
 // GetEditorconfig returns the .editorconfig definition if found in the
89 99
 // HEAD of the default repo branch.
90 100
 func (r *Repository) GetEditorconfig() (*editorconfig.Editorconfig, error) {

+ 18 - 14
modules/setting/setting.go

@@ -34,7 +34,7 @@ import (
34 34
 	"github.com/go-macaron/session"
35 35
 	_ "github.com/go-macaron/session/redis" // redis plugin for store session
36 36
 	"github.com/go-xorm/core"
37
-	ini "gopkg.in/ini.v1"
37
+	"gopkg.in/ini.v1"
38 38
 	"strk.kbt.io/projects/go/libravatar"
39 39
 )
40 40
 
@@ -1016,19 +1016,21 @@ func NewContext() {
1016 1016
 
1017 1017
 // Service settings
1018 1018
 var Service struct {
1019
-	ActiveCodeLives                int
1020
-	ResetPwdCodeLives              int
1021
-	RegisterEmailConfirm           bool
1022
-	DisableRegistration            bool
1023
-	ShowRegistrationButton         bool
1024
-	RequireSignInView              bool
1025
-	EnableNotifyMail               bool
1026
-	EnableReverseProxyAuth         bool
1027
-	EnableReverseProxyAutoRegister bool
1028
-	EnableCaptcha                  bool
1029
-	DefaultKeepEmailPrivate        bool
1030
-	DefaultAllowCreateOrganization bool
1031
-	NoReplyAddress                 string
1019
+	ActiveCodeLives                         int
1020
+	ResetPwdCodeLives                       int
1021
+	RegisterEmailConfirm                    bool
1022
+	DisableRegistration                     bool
1023
+	ShowRegistrationButton                  bool
1024
+	RequireSignInView                       bool
1025
+	EnableNotifyMail                        bool
1026
+	EnableReverseProxyAuth                  bool
1027
+	EnableReverseProxyAutoRegister          bool
1028
+	EnableCaptcha                           bool
1029
+	DefaultKeepEmailPrivate                 bool
1030
+	DefaultAllowCreateOrganization          bool
1031
+	DefaultEnableTimetracking               bool
1032
+	DefaultAllowOnlyContributorsToTrackTime bool
1033
+	NoReplyAddress                          string
1032 1034
 
1033 1035
 	// OpenID settings
1034 1036
 	EnableOpenIDSignIn bool
@@ -1049,6 +1051,8 @@ func newService() {
1049 1051
 	Service.EnableCaptcha = sec.Key("ENABLE_CAPTCHA").MustBool()
1050 1052
 	Service.DefaultKeepEmailPrivate = sec.Key("DEFAULT_KEEP_EMAIL_PRIVATE").MustBool()
1051 1053
 	Service.DefaultAllowCreateOrganization = sec.Key("DEFAULT_ALLOW_CREATE_ORGANIZATION").MustBool(true)
1054
+	Service.DefaultEnableTimetracking = sec.Key("DEFAULT_ENABLE_TIMETRACKING").MustBool(true)
1055
+	Service.DefaultAllowOnlyContributorsToTrackTime = sec.Key("DEFAULT_ALLOW_ONLY_CONTRIBUTORS_TO_TRACK_TIME").MustBool(true)
1052 1056
 	Service.NoReplyAddress = sec.Key("NO_REPLY_ADDRESS").MustString("noreply.example.org")
1053 1057
 
1054 1058
 	sec = Cfg.Section("openid")

+ 23 - 0
options/locale/locale_en-US.ini

@@ -144,6 +144,8 @@ default_keep_email_private = Default Value for Keep Email Private
144 144
 default_keep_email_private_popup = This is the default value for the visibility of the user's email address. If set to true the email address of all new users will be hidden until the user changes his setting.
145 145
 default_allow_create_organization = Default permission value for new users to create organizations
146 146
 default_allow_create_organization_popup = This is default permission value that will be assigned for new users. If set to true new users will be allowed to create Organizations.
147
+default_enable_timetracking = Enable time tracking by default
148
+default_enable_timetracking_popup = Repositories will have time tracking enabled by default depending on this setting
147 149
 no_reply_address = No-reply Address
148 150
 no_reply_address_helper = Domain for the user's email address in git logs if he keeps his email address private. E.g. user 'joe' and 'noreply.example.org' will be 'joe@noreply.example.org'
149 151
 
@@ -704,6 +706,23 @@ issues.attachment.open_tab = `Click to see "%s" in a new tab`
704 706
 issues.attachment.download = `Click to download "%s"`
705 707
 issues.subscribe = Subscribe
706 708
 issues.unsubscribe = Unsubscribe
709
+issues.tracker = Time tracker
710
+issues.start_tracking_short = Start
711
+issues.start_tracking = Start time tracking
712
+issues.start_tracking_history = `started working %s`
713
+issues.tracking_already_started = `You have already started time tracking on this <a href="%s">issue</a>!`
714
+issues.stop_tracking = Stop
715
+issues.stop_tracking_history = `stopped working %s`
716
+issues.add_time = Add time manually
717
+issues.add_time_short = Add
718
+issues.add_time_cancel = Cancel
719
+issues.add_time_history = `added spent time %s`
720
+issues.add_time_hours = Hours
721
+issues.add_time_minutes = Minutes
722
+issues.add_time_sum_to_small = No time was entered
723
+issues.cancel_tracking = Cancel
724
+issues.cancel_tracking_history = `cancelled time tracking %s`
725
+issues.time_spent_total = Total time spent
707 726
 
708 727
 pulls.desc = Pulls management your code review and merge requests
709 728
 pulls.new = New Pull Request
@@ -818,6 +837,8 @@ settings.tracker_issue_style = External Issue Tracker Naming Style:
818 837
 settings.tracker_issue_style.numeric = Numeric
819 838
 settings.tracker_issue_style.alphanumeric = Alphanumeric
820 839
 settings.tracker_url_format_desc = You can use placeholder <code>{user} {repo} {index}</code> for user name, repository name and issue index.
840
+settings.enable_timetracker = Enable time tracker
841
+settings.allow_only_contributors_to_track_time = Allow only contributors to track time
821 842
 settings.pulls_desc = Enable pull requests to accept public contributions
822 843
 settings.danger_zone = Danger Zone
823 844
 settings.new_owner_has_same_repo = The new owner already has a repository with same name. Please choose another name.
@@ -1308,6 +1329,8 @@ config.active_code_lives = Active Code Lives
1308 1329
 config.reset_password_code_lives = Reset Password Code Expiry Time
1309 1330
 config.default_keep_email_private = Default Value for Keep Email Private
1310 1331
 config.default_allow_create_organization = Default permission to create organizations
1332
+config.default_enable_timetracking = Enable time tracking by default
1333
+config.default_allow_only_contributors_to_track_time = Allow only contributors to track time by default
1311 1334
 config.no_reply_address = No-reply Address
1312 1335
 
1313 1336
 config.webhook_config = Webhook Configuration

+ 21 - 0
public/js/index.js

@@ -404,15 +404,19 @@ function initRepository() {
404 404
         $('.enable-system').change(function () {
405 405
             if (this.checked) {
406 406
                 $($(this).data('target')).removeClass('disabled');
407
+                if (!$(this).data('context')) $($(this).data('context')).addClass('disabled');
407 408
             } else {
408 409
                 $($(this).data('target')).addClass('disabled');
410
+                if (!$(this).data('context')) $($(this).data('context')).removeClass('disabled');
409 411
             }
410 412
         });
411 413
         $('.enable-system-radio').change(function () {
412 414
             if (this.value == 'false') {
413 415
                 $($(this).data('target')).addClass('disabled');
416
+                if (typeof $(this).data('context') !== 'undefined') $($(this).data('context')).removeClass('disabled');
414 417
             } else if (this.value == 'true') {
415 418
                 $($(this).data('target')).removeClass('disabled');
419
+                if (typeof $(this).data('context') !== 'undefined')  $($(this).data('context')).addClass('disabled');
416 420
             }
417 421
         });
418 422
     }
@@ -1826,3 +1830,20 @@ function initVueApp() {
1826 1830
         },
1827 1831
     });
1828 1832
 }
1833
+function timeAddManual() {
1834
+    $('.mini.modal')
1835
+        .modal({
1836
+            duration: 200,
1837
+            onApprove: function() {
1838
+                $('#add_time_manual_form').submit();
1839
+            }
1840
+        }).modal('show')
1841
+    ;
1842
+}
1843
+
1844
+function toggleStopwatch() {
1845
+    $("#toggle_stopwatch_form").submit();
1846
+}
1847
+function cancelStopwatch() {
1848
+    $("#cancel_stopwatch_form").submit();
1849
+}

+ 125 - 0
public/swagger.v1.json

@@ -1372,6 +1372,65 @@
1372 1372
         }
1373 1373
       }
1374 1374
     },
1375
+    "/repos/{username}/{reponame}/issues/{issue}/times": {
1376
+      "get": {
1377
+        "produces": [
1378
+          "application/json"
1379
+        ],
1380
+        "tags": [
1381
+          "repository"
1382
+        ],
1383
+        "operationId": "issueTrackedTimes",
1384
+        "responses": {
1385
+          "200": {
1386
+            "$ref": "#/responses/TrackedTimes"
1387
+          },
1388
+          "404": {
1389
+            "$ref": "#/responses/error"
1390
+          },
1391
+          "500": {
1392
+            "$ref": "#/responses/error"
1393
+          }
1394
+        }
1395
+      },
1396
+      "post": {
1397
+        "produces": [
1398
+          "application/json"
1399
+        ],
1400
+        "tags": [
1401
+          "repository"
1402
+        ],
1403
+        "operationId": "addTime",
1404
+        "parameters": [
1405
+          {
1406
+            "x-go-name": "Time",
1407
+            "name": "time",
1408
+            "in": "body",
1409
+            "schema": {
1410
+              "type": "integer",
1411
+              "format": "int64"
1412
+            }
1413
+          }
1414
+        ],
1415
+        "responses": {
1416
+          "200": {
1417
+            "$ref": "#/responses/TrackedTime"
1418
+          },
1419
+          "400": {
1420
+            "$ref": "#/responses/error"
1421
+          },
1422
+          "403": {
1423
+            "$ref": "#/responses/error"
1424
+          },
1425
+          "404": {
1426
+            "$ref": "#/responses/error"
1427
+          },
1428
+          "500": {
1429
+            "$ref": "#/responses/error"
1430
+          }
1431
+        }
1432
+      }
1433
+    },
1375 1434
     "/repos/{username}/{reponame}/mirror-sync": {
1376 1435
       "post": {
1377 1436
         "produces": [
@@ -1435,6 +1494,53 @@
1435 1494
         }
1436 1495
       }
1437 1496
     },
1497
+    "/repos/{username}/{reponame}/times": {
1498
+      "get": {
1499
+        "produces": [
1500
+          "application/json"
1501
+        ],
1502
+        "tags": [
1503
+          "repository"
1504
+        ],
1505
+        "operationId": "repoTrackedTimes",
1506
+        "responses": {
1507
+          "200": {
1508
+            "$ref": "#/responses/TrackedTimes"
1509
+          },
1510
+          "400": {
1511
+            "$ref": "#/responses/error"
1512
+          },
1513
+          "500": {
1514
+            "$ref": "#/responses/error"
1515
+          }
1516
+        }
1517
+      }
1518
+    },
1519
+    "/repos/{username}/{reponame}/times/{timetrackingusername}": {
1520
+      "get": {
1521
+        "produces": [
1522
+          "application/json"
1523
+        ],
1524
+        "tags": [
1525
+          "user"
1526
+        ],
1527
+        "operationId": "userTrackedTimes",
1528
+        "responses": {
1529
+          "200": {
1530
+            "$ref": "#/responses/TrackedTimes"
1531
+          },
1532
+          "400": {
1533
+            "$ref": "#/responses/error"
1534
+          },
1535
+          "404": {
1536
+            "$ref": "#/responses/error"
1537
+          },
1538
+          "500": {
1539
+            "$ref": "#/responses/error"
1540
+          }
1541
+        }
1542
+      }
1543
+    },
1438 1544
     "/repositories/{id}": {
1439 1545
       "get": {
1440 1546
         "produces": [
@@ -1951,6 +2057,25 @@
1951 2057
         }
1952 2058
       }
1953 2059
     },
2060
+    "/user/times": {
2061
+      "get": {
2062
+        "produces": [
2063
+          "application/json"
2064
+        ],
2065
+        "tags": [
2066
+          "user"
2067
+        ],
2068
+        "operationId": "userTrackedTimes",
2069
+        "responses": {
2070
+          "200": {
2071
+            "$ref": "#/responses/TrackedTimes"
2072
+          },
2073
+          "500": {
2074
+            "$ref": "#/responses/error"
2075
+          }
2076
+        }
2077
+      }
2078
+    },
1954 2079
     "/users/:username/followers": {
1955 2080
       "get": {
1956 2081
         "produces": [

+ 11 - 0
routers/api/v1/api.go

@@ -350,6 +350,7 @@ func RegisterRoutes(m *macaron.Macaron) {
350 350
 					m.Delete("", user.Unstar)
351 351
 				}, repoAssignment())
352 352
 			})
353
+			m.Get("/times", repo.ListMyTrackedTimes)
353 354
 
354 355
 			m.Get("/subscriptions", user.GetMyWatchedRepos)
355 356
 		}, reqToken())
@@ -395,6 +396,11 @@ func RegisterRoutes(m *macaron.Macaron) {
395 396
 					m.Combo("/:id").Get(repo.GetDeployKey).
396 397
 						Delete(repo.DeleteDeploykey)
397 398
 				}, reqToken(), reqRepoWriter())
399
+				m.Group("/times", func() {
400
+					m.Combo("").Get(repo.ListTrackedTimesByRepository)
401
+					m.Combo("/:timetrackingusername").Get(repo.ListTrackedTimesByUser)
402
+
403
+				}, mustEnableIssues)
398 404
 				m.Group("/issues", func() {
399 405
 					m.Combo("").Get(repo.ListIssues).
400 406
 						Post(reqToken(), bind(api.CreateIssueOption{}), repo.CreateIssue)
@@ -422,6 +428,11 @@ func RegisterRoutes(m *macaron.Macaron) {
422 428
 							m.Delete("/:id", reqToken(), repo.DeleteIssueLabel)
423 429
 						})
424 430
 
431
+						m.Group("/times", func() {
432
+							m.Combo("").Get(repo.ListTrackedTimes).
433
+								Post(reqToken(), bind(api.AddTimeOption{}), repo.AddTime)
434
+						})
435
+
425 436
 					})
426 437
 				}, mustEnableIssues)
427 438
 				m.Group("/labels", func() {

+ 158 - 0
routers/api/v1/repo/issue_tracked_time.go

@@ -0,0 +1,158 @@
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
+
5
+package repo
6
+
7
+import (
8
+	"code.gitea.io/gitea/models"
9
+	"code.gitea.io/gitea/modules/context"
10
+	api "code.gitea.io/sdk/gitea"
11
+)
12
+
13
+// ListTrackedTimes list all the tracked times of an issue
14
+func ListTrackedTimes(ctx *context.APIContext) {
15
+	// swagger:route GET /repos/{username}/{reponame}/issues/{issue}/times repository issueTrackedTimes
16
+	//
17
+	//     Produces:
18
+	//     - application/json
19
+	//
20
+	//     Responses:
21
+	//       200: TrackedTimes
22
+	//	 404: error
23
+	//       500: error
24
+	if !ctx.Repo.Repository.IsTimetrackerEnabled() {
25
+		ctx.Error(404, "IsTimetrackerEnabled", "Timetracker is diabled")
26
+		return
27
+	}
28
+	issue, err := models.GetIssueByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
29
+	if err != nil {
30
+		if models.IsErrIssueNotExist(err) {
31
+			ctx.Error(404, "GetIssueByIndex", err)
32
+		} else {
33
+			ctx.Error(500, "GetIssueByIndex", err)
34
+		}
35
+		return
36
+	}
37
+
38
+	if trackedTimes, err := models.GetTrackedTimes(models.FindTrackedTimesOptions{IssueID: issue.ID}); err != nil {
39
+		ctx.Error(500, "GetTrackedTimesByIssue", err)
40
+	} else {
41
+		ctx.JSON(200, &trackedTimes)
42
+	}
43
+}
44
+
45
+// AddTime adds time manual to the given issue
46
+func AddTime(ctx *context.APIContext, form api.AddTimeOption) {
47
+	// swagger:route Post /repos/{username}/{reponame}/issues/{issue}/times repository addTime
48
+	//
49
+	//     Produces:
50
+	//     - application/json
51
+	//
52
+	//     Responses:
53
+	//       200: TrackedTime
54
+	//       400: error
55
+	//       403: error
56
+	//	 404: error
57
+	//       500: error
58
+	issue, err := models.GetIssueByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
59
+	if err != nil {
60
+		if models.IsErrIssueNotExist(err) {
61
+			ctx.Error(404, "GetIssueByIndex", err)
62
+		} else {
63
+			ctx.Error(500, "GetIssueByIndex", err)
64
+		}
65
+		return
66
+	}
67
+
68
+	if !ctx.Repo.CanUseTimetracker(issue, ctx.User) {
69
+		if !ctx.Repo.Repository.IsTimetrackerEnabled() {
70
+			ctx.JSON(400, struct{ Message string }{Message: "time tracking disabled"})
71
+			return
72
+		}
73
+		ctx.Status(403)
74
+		return
75
+	}
76
+	var tt *models.TrackedTime
77
+	if tt, err = models.AddTime(ctx.User, issue, form.Time); err != nil {
78
+		ctx.Error(500, "AddTime", err)
79
+		return
80
+	}
81
+	ctx.JSON(200, tt)
82
+
83
+}
84
+
85
+// ListTrackedTimesByUser  lists all tracked times of the user
86
+func ListTrackedTimesByUser(ctx *context.APIContext) {
87
+	// swagger:route GET /repos/{username}/{reponame}/times/{timetrackingusername} user userTrackedTimes
88
+	//
89
+	//     Produces:
90
+	//     - application/json
91
+	//
92
+	//     Responses:
93
+	//       200: TrackedTimes
94
+	//       400: error
95
+	//	 404: error
96
+	//       500: error
97
+	if !ctx.Repo.Repository.IsTimetrackerEnabled() {
98
+		ctx.JSON(400, struct{ Message string }{Message: "time tracking disabled"})
99
+		return
100
+	}
101
+	user, err := models.GetUserByName(ctx.Params(":timetrackingusername"))
102
+	if err != nil {
103
+		if models.IsErrUserNotExist(err) {
104
+			ctx.Error(404, "GetUserByName", err)
105
+		} else {
106
+			ctx.Error(500, "GetUserByName", err)
107
+		}
108
+		return
109
+	}
110
+	if user == nil {
111
+		ctx.Status(404)
112
+		return
113
+	}
114
+	if trackedTimes, err := models.GetTrackedTimes(models.FindTrackedTimesOptions{UserID: user.ID, RepositoryID: ctx.Repo.Repository.ID}); err != nil {
115
+		ctx.Error(500, "GetTrackedTimesByUser", err)
116
+	} else {
117
+		ctx.JSON(200, &trackedTimes)
118
+	}
119
+}
120
+
121
+// ListTrackedTimesByRepository lists all tracked times of the user
122
+func ListTrackedTimesByRepository(ctx *context.APIContext) {
123
+	// swagger:route GET /repos/{username}/{reponame}/times repository repoTrackedTimes
124
+	//
125
+	//     Produces:
126
+	//     - application/json
127
+	//
128
+	//     Responses:
129
+	//       200: TrackedTimes
130
+	//       400: error
131
+	//       500: error
132
+	if !ctx.Repo.Repository.IsTimetrackerEnabled() {
133
+		ctx.JSON(400, struct{ Message string }{Message: "time tracking disabled"})
134
+		return
135
+	}
136
+	if trackedTimes, err := models.GetTrackedTimes(models.FindTrackedTimesOptions{RepositoryID: ctx.Repo.Repository.ID}); err != nil {
137
+		ctx.Error(500, "GetTrackedTimesByUser", err)
138
+	} else {
139
+		ctx.JSON(200, &trackedTimes)
140
+	}
141
+}
142
+
143
+// ListMyTrackedTimes lists all tracked times of the current user
144
+func ListMyTrackedTimes(ctx *context.APIContext) {
145
+	// swagger:route GET /user/times user userTrackedTimes
146
+	//
147
+	//     Produces:
148
+	//     - application/json
149
+	//
150
+	//     Responses:
151
+	//       200: TrackedTimes
152
+	//       500: error
153
+	if trackedTimes, err := models.GetTrackedTimes(models.FindTrackedTimesOptions{UserID: ctx.User.ID}); err != nil {
154
+		ctx.Error(500, "GetTrackedTimesByUser", err)
155
+	} else {
156
+		ctx.JSON(200, &trackedTimes)
157
+	}
158
+}

+ 2 - 0
routers/install.go

@@ -115,6 +115,7 @@ func Install(ctx *context.Context) {
115 115
 	form.RequireSignInView = setting.Service.RequireSignInView
116 116
 	form.DefaultKeepEmailPrivate = setting.Service.DefaultKeepEmailPrivate
117 117
 	form.DefaultAllowCreateOrganization = setting.Service.DefaultAllowCreateOrganization
118
+	form.DefaultEnableTimetracking = setting.Service.DefaultEnableTimetracking
118 119
 	form.NoReplyAddress = setting.Service.NoReplyAddress
119 120
 
120 121
 	auth.AssignForm(form, ctx.Data)
@@ -301,6 +302,7 @@ func InstallPost(ctx *context.Context, form auth.InstallForm) {
301 302
 	cfg.Section("service").Key("REQUIRE_SIGNIN_VIEW").SetValue(com.ToStr(form.RequireSignInView))
302 303
 	cfg.Section("service").Key("DEFAULT_KEEP_EMAIL_PRIVATE").SetValue(com.ToStr(form.DefaultKeepEmailPrivate))
303 304
 	cfg.Section("service").Key("DEFAULT_ALLOW_CREATE_ORGANIZATION").SetValue(com.ToStr(form.DefaultAllowCreateOrganization))
305
+	cfg.Section("service").Key("DEFAULT_ENABLE_TIMETRACKING").SetValue(com.ToStr(form.DefaultEnableTimetracking))
304 306
 	cfg.Section("service").Key("NO_REPLY_ADDRESS").SetValue(com.ToStr(form.NoReplyAddress))
305 307
 
306 308
 	cfg.Section("").Key("RUN_MODE").SetValue("prod")

+ 36 - 3
routers/repo/issue.go

@@ -589,6 +589,38 @@ func ViewIssue(ctx *context.Context) {
589 589
 		comment      *models.Comment
590 590
 		participants = make([]*models.User, 1, 10)
591 591
 	)
592
+	if ctx.Repo.Repository.IsTimetrackerEnabled() {
593
+		if ctx.IsSigned {
594
+			// Deal with the stopwatch
595
+			ctx.Data["IsStopwatchRunning"] = models.StopwatchExists(ctx.User.ID, issue.ID)
596
+			if !ctx.Data["IsStopwatchRunning"].(bool) {
597
+				var exists bool
598
+				var sw *models.Stopwatch
599
+				if exists, sw, err = models.HasUserStopwatch(ctx.User.ID); err != nil {
600
+					ctx.Handle(500, "HasUserStopwatch", err)
601
+					return
602
+				}
603
+				ctx.Data["HasUserStopwatch"] = exists
604
+				if exists {
605
+					// Add warning if the user has already a stopwatch
606
+					var otherIssue *models.Issue
607
+					if otherIssue, err = models.GetIssueByID(sw.IssueID); err != nil {
608
+						ctx.Handle(500, "GetIssueByID", err)
609
+						return
610
+					}
611
+					// Add link to the issue of the already running stopwatch
612
+					ctx.Data["OtherStopwatchURL"] = otherIssue.HTMLURL()
613
+				}
614
+			}
615
+			ctx.Data["CanUseTimetracker"] = ctx.Repo.CanUseTimetracker(issue, ctx.User)
616
+		} else {
617
+			ctx.Data["CanUseTimetracker"] = false
618
+		}
619
+		if ctx.Data["WorkingUsers"], err = models.TotalTimes(models.FindTrackedTimesOptions{IssueID: issue.ID}); err != nil {
620
+			ctx.Handle(500, "TotalTimes", err)
621
+			return
622
+		}
623
+	}
592 624
 
593 625
 	// Render comments and and fetch participants.
594 626
 	participants[0] = issue.Poster
@@ -683,7 +715,8 @@ func ViewIssue(ctx *context.Context) {
683 715
 	ctx.HTML(200, tplIssueView)
684 716
 }
685 717
 
686
-func getActionIssue(ctx *context.Context) *models.Issue {
718
+// GetActionIssue will return the issue which is used in the context.
719
+func GetActionIssue(ctx *context.Context) *models.Issue {
687 720
 	issue, err := models.GetIssueByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
688 721
 	if err != nil {
689 722
 		if models.IsErrIssueNotExist(err) {
@@ -720,7 +753,7 @@ func getActionIssues(ctx *context.Context) []*models.Issue {
720 753
 
721 754
 // UpdateIssueTitle change issue's title
722 755
 func UpdateIssueTitle(ctx *context.Context) {
723
-	issue := getActionIssue(ctx)
756
+	issue := GetActionIssue(ctx)
724 757
 	if ctx.Written() {
725 758
 		return
726 759
 	}
@@ -748,7 +781,7 @@ func UpdateIssueTitle(ctx *context.Context) {
748 781
 
749 782
 // UpdateIssueContent change issue's content
750 783
 func UpdateIssueContent(ctx *context.Context) {
751
-	issue := getActionIssue(ctx)
784
+	issue := GetActionIssue(ctx)
752 785
 	if ctx.Written() {
753 786
 		return
754 787
 	}

+ 50 - 0
routers/repo/issue_stopwatch.go

@@ -0,0 +1,50 @@
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
+
5
+package repo
6
+
7
+import (
8
+	"net/http"
9
+
10
+	"code.gitea.io/gitea/models"
11
+	"code.gitea.io/gitea/modules/context"
12
+)
13
+
14
+// IssueStopwatch creates or stops a stopwatch for the given issue.
15
+func IssueStopwatch(c *context.Context) {
16
+	issueIndex := c.ParamsInt64("index")
17
+	issue, err := models.GetIssueByIndex(c.Repo.Repository.ID, issueIndex)
18
+
19
+	if err != nil {
20
+		c.Handle(http.StatusInternalServerError, "GetIssueByIndex", err)
21
+		return
22
+	}
23
+
24
+	if err := models.CreateOrStopIssueStopwatch(c.User, issue); err != nil {
25
+		c.Handle(http.StatusInternalServerError, "CreateOrStopIssueStopwatch", err)
26
+		return
27
+	}
28
+
29
+	url := issue.HTMLURL()
30
+	c.Redirect(url, http.StatusSeeOther)
31
+}
32
+
33
+// CancelStopwatch cancel the stopwatch
34
+func CancelStopwatch(c *context.Context) {
35
+	issueIndex := c.ParamsInt64("index")
36
+	issue, err := models.GetIssueByIndex(c.Repo.Repository.ID, issueIndex)
37
+
38
+	if err != nil {
39
+		c.Handle(http.StatusInternalServerError, "GetIssueByIndex", err)
40
+		return
41
+	}
42
+
43
+	if err := models.CancelStopwatch(c.User, issue); err != nil {
44
+		c.Handle(http.StatusInternalServerError, "CancelStopwatch", err)
45
+		return
46
+	}
47
+
48
+	url := issue.HTMLURL()
49
+	c.Redirect(url, http.StatusSeeOther)
50
+}

+ 50 - 0
routers/repo/issue_timetrack.go

@@ -0,0 +1,50 @@
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
+
5
+package repo
6
+
7
+import (
8
+	"net/http"
9
+	"time"
10
+
11
+	"code.gitea.io/gitea/models"
12
+	"code.gitea.io/gitea/modules/auth"
13
+	"code.gitea.io/gitea/modules/context"
14
+)
15
+
16
+// AddTimeManually tracks time manually
17
+func AddTimeManually(c *context.Context, form auth.AddTimeManuallyForm) {
18
+	issueIndex := c.ParamsInt64("index")
19
+	issue, err := models.GetIssueByIndex(c.Repo.Repository.ID, issueIndex)
20
+	if err != nil {
21
+		if models.IsErrIssueNotExist(err) {
22
+			c.Handle(http.StatusNotFound, "GetIssueByIndex", err)
23
+			return
24
+		}
25
+		c.Handle(http.StatusInternalServerError, "GetIssueByIndex", err)
26
+		return
27
+	}
28
+	url := issue.HTMLURL()
29
+
30
+	if c.HasError() {
31
+		c.Flash.Error(c.GetErrMsg())
32
+		c.Redirect(url)
33
+		return
34
+	}
35
+
36
+	total := time.Duration(form.Hours)*time.Hour + time.Duration(form.Minutes)*time.Minute
37
+
38
+	if total <= 0 {
39
+		c.Flash.Error(c.Tr("repo.issues.add_time_sum_to_small"))
40
+		c.Redirect(url, http.StatusSeeOther)
41
+		return
42
+	}
43
+
44
+	if _, err := models.AddTime(c.User, issue, int64(total)); err != nil {
45
+		c.Handle(http.StatusInternalServerError, "AddTime", err)
46
+		return
47
+	}
48
+
49
+	c.Redirect(url, http.StatusSeeOther)
50
+}

+ 4 - 1
routers/repo/setting.go

@@ -201,7 +201,10 @@ func SettingsPost(ctx *context.Context, form auth.RepoSettingForm) {
201 201
 					RepoID: repo.ID,
202 202
 					Type:   models.UnitTypeIssues,
203 203
 					Index:  int(models.UnitTypeIssues),
204
-					Config: new(models.UnitConfig),
204
+					Config: &models.IssuesConfig{
205
+						EnableTimetracker:                form.EnableTimetracker,
206
+						AllowOnlyContributorsToTrackTime: form.AllowOnlyContributorsToTrackTime,
207
+					},
205 208
 				})
206 209
 			}
207 210
 		}

+ 13 - 0
routers/routes/routes.go

@@ -484,6 +484,19 @@ func RegisterRoutes(m *macaron.Macaron) {
484 484
 				m.Post("/content", repo.UpdateIssueContent)
485 485
 				m.Post("/watch", repo.IssueWatch)
486 486
 				m.Combo("/comments").Post(bindIgnErr(auth.CreateCommentForm{}), repo.NewComment)
487
+				m.Group("/times", func() {
488
+					m.Post("/add", bindIgnErr(auth.AddTimeManuallyForm{}), repo.AddTimeManually)
489
+					m.Group("/stopwatch", func() {
490
+						m.Post("/toggle", repo.IssueStopwatch)
491
+						m.Post("/cancel", repo.CancelStopwatch)
492
+					})
493
+
494
+				}, func(ctx *context.Context) {
495
+					if !ctx.Repo.CanUseTimetracker(repo.GetActionIssue(ctx), ctx.User) {
496
+						ctx.Handle(404, ctx.Req.RequestURI, nil)
497
+						return
498
+					}
499
+				})
487 500
 			})
488 501
 
489 502
 			m.Post("/labels", repo.UpdateIssueLabel, reqRepoWriter)

+ 4 - 0
templates/admin/config.tmpl

@@ -132,6 +132,10 @@
132 132
 				<dd><i class="fa fa{{if .Service.DefaultKeepEmailPrivate}}-check{{end}}-square-o"></i></dd>
133 133
 				<dt>{{.i18n.Tr "admin.config.default_allow_create_organization"}}</dt>
134 134
 				<dd><i class="fa fa{{if .Service.DefaultAllowCreateOrganization}}-check{{end}}-square-o"></i></dd>
135
+				<dt>{{.i18n.Tr "admin.config.default_enable_timetracking"}}</dt>
136
+				<dd><i class="fa fa{{if .Service.DefaultEnableTimetracking}}-check{{end}}-square-o"></i></dd>
137
+				<dt>{{.i18n.Tr "admin.config.default_allow_only_contributors_to_track_time"}}</dt>
138
+				<dd><i class="fa fa{{if .Service.DefaultAllowOnlyContributorsToTrackTime}}-check{{end}}-square-o"></i></dd>
135 139
 				<dt>{{.i18n.Tr "admin.config.no_reply_address"}}</dt>
136 140
 				<dd>{{if .Service.NoReplyAddress}}{{.Service.NoReplyAddress}}{{else}}-{{end}}</dd>
137 141
 				<div class="ui divider"></div>

+ 6 - 0
templates/install.tmpl

@@ -231,6 +231,12 @@
231 231
 								</div>
232 232
 							</div>
233 233
 							<div class="inline field">
234
+								<div class="ui checkbox">
235
+									<label class="poping up" data-content="{{.i18n.Tr "install.default_enable_timetracking_popup"}}"><strong>{{.i18n.Tr "install.default_enable_timetracking"}}</strong></label>
236
+									<input name="default_enable_timetracking" type="checkbox" {{if .default_enable_timetracking}}checked{{end}}>
237
+								</div>
238
+							</div>
239
+							<div class="inline field">
234 240
 								<label for="no_reply_address">{{.i18n.Tr "install.no_reply_address"}}</label>
235 241
 								<input id="_no_reply_address" name="no_reply_address" value="{{.no_reply_address}}">
236 242
 								<span class="help">{{.i18n.Tr "install.no_reply_address_helper"}}</span>

+ 43 - 1
templates/repo/issue/view_content/comments.tmpl

@@ -1,7 +1,7 @@
1 1
 {{range .Issue.Comments}}
2 2
 	{{ $createdStr:= TimeSince .Created $.Lang }}
3 3
 
4
-	<!-- 0 = COMMENT, 1 = REOPEN, 2 = CLOSE, 3 = ISSUE_REF, 4 = COMMIT_REF, 5 = COMMENT_REF, 6 = PULL_REF, 7 = COMMENT_LABEL -->
4
+	<!-- 0 = COMMENT, 1 = REOPEN, 2 = CLOSE, 3 = ISSUE_REF, 4 = COMMIT_REF, 5 = COMMENT_REF, 6 = PULL_REF, 7 = COMMENT_LABEL, 12 = START_TRACKING, 13 = STOP_TRACKING, 14 = ADD_TIME_MANUAL -->
5 5
 	{{if eq .Type 0}}
6 6
 		<div class="comment" id="{{.HashTag}}">
7 7
 			<a class="avatar" {{if gt .Poster.ID 0}}href="{{.Poster.HomeLink}}"{{end}}>
@@ -58,6 +58,7 @@
58 58
 				{{end}}
59 59
 			</div>
60 60
 		</div>
61
+
61 62
 	{{else if eq .Type 1}}
62 63
 		<div class="event">
63 64
 			<span class="octicon octicon-primitive-dot"></span>
@@ -140,5 +141,46 @@
140 141
 		<span class="text grey"><a href="{{.Poster.HomeLink}}">{{.Poster.Name}}</a>
141 142
 		{{$.i18n.Tr "repo.issues.delete_branch_at" .CommitSHA $createdStr | Safe}}
142 143
 		</span>
144
+    {{else if eq .Type 12}}
145
+		<div class="event">
146
+			<span class="octicon octicon-primitive-dot"></span>
147
+			<a class="ui avatar image" href="{{.Poster.HomeLink}}">
148
+				<img src="{{.Poster.RelAvatarLink}}">
149
+			</a>
150
+			<span class="text grey"><a href="{{.Poster.HomeLink}}">{{.Poster.Name}}</a> {{$.i18n.Tr "repo.issues.start_tracking_history"  $createdStr | Safe}}</span>
151
+		</div>
152
+	{{else if eq .Type 13}}
153
+		<div class="event">
154
+			<span class="octicon octicon-primitive-dot"></span>
155
+			<a class="ui avatar image" href="{{.Poster.HomeLink}}">
156
+				<img src="{{.Poster.RelAvatarLink}}">
157
+			</a>
158
+			<span class="text grey"><a href="{{.Poster.HomeLink}}">{{.Poster.Name}}</a> {{$.i18n.Tr "repo.issues.stop_tracking_history"  $createdStr | Safe}}</span>
159
+
160
+			<div class="detail">
161
+				<span class="octicon octicon-clock"></span>
162
+				<span class="text grey">{{.Content}}</span>
163
+			</div>
164
+		</div>
165
+	{{else if eq .Type 14}}
166
+		<div class="event">
167
+			<span class="octicon octicon-primitive-dot"></span>
168
+			<a class="ui avatar image" href="{{.Poster.HomeLink}}">
169
+				<img src="{{.Poster.RelAvatarLink}}">
170
+			</a>
171
+			<span class="text grey"><a href="{{.Poster.HomeLink}}">{{.Poster.Name}}</a> {{$.i18n.Tr "repo.issues.add_time_history"  $createdStr | Safe}}</span>
172
+			<div class="detail">
173
+				<span class="octicon octicon-clock"></span>
174
+				<span class="text grey">{{.Content}}</span>
175
+			</div>
176
+		</div>
177
+	{{else if eq .Type 15}}
178
+		<div class="event">
179
+			<span class="octicon octicon-primitive-dot"></span>
180
+			<a class="ui avatar image" href="{{.Poster.HomeLink}}">
181
+				<img src="{{.Poster.RelAvatarLink}}">
182
+			</a>
183
+			<span class="text grey"><a href="{{.Poster.HomeLink}}">{{.Poster.Name}}</a> {{$.i18n.Tr "repo.issues.cancel_tracking_history"  $createdStr | Safe}}</span>
184
+		</div>
143 185
 	{{end}}
144 186
 {{end}}

+ 84 - 17
templates/repo/issue/view_content/sidebar.tmpl

@@ -102,26 +102,93 @@
102 102
 		</div>
103 103
 
104 104
 		{{if $.IssueWatch}}
105
-		<div class="ui divider"></div>
105
+			<div class="ui divider"></div>
106 106
 
107
-		<div class="ui watching">
108
-			<span class="text"><strong>{{.i18n.Tr "notification.notifications"}}</strong></span>
109
-			<div>
110
-				<form method="POST" action="{{$.RepoLink}}/issues/{{.Issue.Index}}/watch">
111
-					<input type="hidden" name="watch" value="{{if $.IssueWatch.IsWatching}}0{{else}}1{{end}}" />
112
-					{{$.CsrfTokenHtml}}
113
-					<button class="fluid ui button">
114
-						{{if $.IssueWatch.IsWatching}}
115
-							<i class="octicon octicon-mute"></i>
116
-							{{.i18n.Tr "repo.issues.unsubscribe"}}
107
+			<div class="ui watching">
108
+				<span class="text"><strong>{{.i18n.Tr "notification.notifications"}}</strong></span>
109
+				<div>
110
+					<form method="POST" action="{{$.RepoLink}}/issues/{{.Issue.Index}}/watch">
111
+						<input type="hidden" name="watch" value="{{if $.IssueWatch.IsWatching}}0{{else}}1{{end}}" />
112
+						{{$.CsrfTokenHtml}}
113
+						<button class="fluid ui button">
114
+							{{if $.IssueWatch.IsWatching}}
115
+								<i class="octicon octicon-mute"></i>
116
+								{{.i18n.Tr "repo.issues.unsubscribe"}}
117
+							{{else}}
118
+								<i class="octicon octicon-unmute"></i>
119
+								{{.i18n.Tr "repo.issues.subscribe"}}
120
+							{{end}}
121
+						</button>
122
+					</form>
123
+				</div>
124
+			</div>
125
+		{{end}}
126
+		{{if .Repository.IsTimetrackerEnabled }}
127
+			{{if .CanUseTimetracker }}
128
+				<div class="ui divider"></div>
129
+				<div class="ui timetrack">
130
+					<span class="text"><strong>{{.i18n.Tr "repo.issues.tracker"}}</strong></span>
131
+					<div>
132
+						<form method="POST" action="{{$.RepoLink}}/issues/{{.Issue.Index}}/times/stopwatch/toggle" id="toggle_stopwatch_form">
133
+							{{$.CsrfTokenHtml}}
134
+						</form>
135
+						<form method="POST" action="{{$.RepoLink}}/issues/{{.Issue.Index}}/times/stopwatch/cancel" id="cancel_stopwatch_form">
136
+							{{$.CsrfTokenHtml}}
137
+						</form>
138
+						{{if  $.IsStopwatchRunning}}
139
+							<div class="ui buttons fluid stop-cancel">
140
+								<button onclick="this.disabled=true;toggleStopwatch()" class="ui button stop">{{.i18n.Tr "repo.issues.stop_tracking"}}</button>
141
+								<button onclick="this.disabled=true;cancelStopwatch()" class="ui negative button cancel">{{.i18n.Tr "repo.issues.cancel_tracking"}}</button>
142
+							</div>
117 143
 						{{else}}
118
-							<i class="octicon octicon-unmute"></i>
119
-							{{.i18n.Tr "repo.issues.subscribe"}}
144
+							{{if .HasUserStopwatch}}
145
+								<div class="ui warning message">
146
+									{{.i18n.Tr "repo.issues.tracking_already_started" .OtherStopwatchURL | Safe}}
147
+								</div>
148
+							{{end}}
149
+							<div class="ui buttons two fluid start-add">
150
+								<button onclick="this.disabled=true;toggleStopwatch()" class="ui button poping up start" data-content='{{.i18n.Tr "repo.issues.start_tracking"}}' data-position="top center" data-variation="small inverted">{{.i18n.Tr "repo.issues.start_tracking_short"}}</button>
151
+								<button onclick="timeAddManual()" class="ui button green poping up add-time" data-content='{{.i18n.Tr "repo.issues.add_time"}}' data-position="top center" data-variation="small inverted">{{.i18n.Tr "repo.issues.add_time_short"}}</button>
152
+								<div class="ui mini modal">
153
+									<div class="header">{{.i18n.Tr "repo.issues.add_time"}}</div>
154
+									<div class="content">
155
+										<form method="POST" id="add_time_manual_form" action="{{$.RepoLink}}/issues/{{.Issue.Index}}/times/add" class="ui action input fluid">
156
+											{{$.CsrfTokenHtml}}
157
+											<input placeholder='{{.i18n.Tr "repo.issues.add_time_hours"}}' type="number" name="hours">
158
+											<input placeholder='{{.i18n.Tr "repo.issues.add_time_minutes"}}' type="number" name="minutes" class="ui compact">
159
+										</form>
160
+									</div>
161
+									<div class="actions">
162
+										<div class="ui green approve button">{{.i18n.Tr "repo.issues.add_time_short"}}</div>
163
+										<div class="ui red cancel button">{{.i18n.Tr "repo.issues.add_time_cancel"}}</div>
164
+									</div>
165
+								</div>
166
+							</div>
120 167
 						{{end}}
121
-					</button>
122
-				</form>
123
-			</div>
124
-		</div>
168
+					</div>
169
+				</div>
170
+			{{end}}
171
+			{{if gt (len .WorkingUsers) 0}}
172
+				<div class="ui divider"></div>
173
+				<div class="ui participants comments">
174
+					<span class="text"><strong>{{.i18n.Tr "repo.issues.time_spent_total"}}</strong></span>
175
+					<div>
176
+						{{range $user, $trackedtime := .WorkingUsers}}
177
+							<div class="comment">
178
+								<a class="avatar">
179
+									<img src="{{$user.RelAvatarLink}}">
180
+								</a>
181
+								<div class="content">
182
+									<a class="author">{{$user.DisplayName}}</a>
183
+									<div class="text">
184
+										{{$trackedtime}}
185
+									</div>
186
+								</div>
187
+							</div>
188
+						{{end}}
189
+					</div>
190
+				</div>
191
+			{{end}}
125 192
 		{{end}}
126 193
 	</div>
127 194
 </div>

+ 17 - 2
templates/repo/settings/options.tmpl

@@ -134,13 +134,28 @@
134 134
 				<div class="field {{if not $isIssuesEnabled}}disabled{{end}}" id="issue_box">
135 135
 					<div class="field">
136 136
 						<div class="ui radio checkbox">
137
-							<input class="hidden enable-system-radio" tabindex="0" name="enable_external_tracker" type="radio" value="false" data-target="#external_issue_box" {{if not (.Repository.UnitEnabled $.UnitTypeExternalTracker)}}checked{{end}}/>
137
+							<input class="hidden enable-system-radio" tabindex="0" name="enable_external_tracker" type="radio" value="false" data-context="#internal_issue_box" data-target="#external_issue_box" {{if not (.Repository.UnitEnabled $.UnitTypeExternalTracker)}}checked{{end}}/>
138 138
 							<label>{{.i18n.Tr "repo.settings.use_internal_issue_tracker"}}</label>
139 139
 						</div>
140 140
 					</div>
141
+					<div class="field {{if (.Repository.UnitEnabled $.UnitTypeExternalTracker)}}disabled{{end}}" id="internal_issue_box">
142
+						<div class="field">
143
+							<div class="ui checkbox">
144
+								<input name="enable_timetracker" class="enable-system" data-target="#only_contributors" type="checkbox" {{if .Repository.IsTimetrackerEnabled}}checked{{end}}>
145
+								<label>{{.i18n.Tr "repo.settings.enable_timetracker"}}</label>
146
+							</div>
147
+						</div>
148
+						<div class="field {{if not .Repository.IsTimetrackerEnabled}}disabled{{end}}" id="only_contributors">
149
+							<div class="ui checkbox">
150
+
151
+								<input name="allow_only_contributors_to_track_time" type="checkbox" {{if .Repository.AllowOnlyContributorsToTrackTime}}checked{{end}}>
152
+								<label>{{.i18n.Tr "repo.settings.allow_only_contributors_to_track_time"}}</label>
153
+							</div>
154
+						</div>
155
+					</div>
141 156
 					<div class="field">
142 157
 						<div class="ui radio checkbox">
143
-							<input class="hidden enable-system-radio" tabindex="0" name="enable_external_tracker" type="radio" value="true" data-target="#external_issue_box" {{if .Repository.UnitEnabled $.UnitTypeExternalTracker}}checked{{end}}/>
158
+							<input class="hidden enable-system-radio" tabindex="0" name="enable_external_tracker" type="radio" value="true" data-context="#internal_issue_box" data-target="#external_issue_box" {{if .Repository.UnitEnabled $.UnitTypeExternalTracker}}checked{{end}}/>
144 159
 							<label>{{.i18n.Tr "repo.settings.use_external_issue_tracker"}}</label>
145 160
 						</div>
146 161
 					</div>