From 108705c842b991ddac3c892b43189052a80f8ba4 Mon Sep 17 00:00:00 2001 From: user Date: Thu, 16 Feb 2023 10:03:19 +0000 Subject: [PATCH 1/9] rebase of earlier feature/worktime-for-org branch --- .../organization/org_time/org_times_test.go | 308 ++++++++++++++++++ models/organization/org_times.go | 88 +++++ modules/templates/helper.go | 1 + modules/util/sec_to_hour.go | 38 +++ modules/util/sec_to_hour_test.go | 34 ++ options/locale/locale_en-US.ini | 11 + routers/web/org/times.go | 147 +++++++++ routers/web/web.go | 6 + templates/org/menu.tmpl | 5 + templates/org/times/daterange.tmpl | 15 + templates/org/times/submenu.tmpl | 7 + templates/org/times/times_by_members.tmpl | 40 +++ templates/org/times/times_by_milestones.tmpl | 48 +++ templates/org/times/times_by_repos.tmpl | 37 +++ web_src/js/features/org-times.js | 8 + web_src/js/index.js | 3 + 16 files changed, 796 insertions(+) create mode 100644 models/organization/org_time/org_times_test.go create mode 100644 models/organization/org_times.go create mode 100644 modules/util/sec_to_hour.go create mode 100644 modules/util/sec_to_hour_test.go create mode 100644 routers/web/org/times.go create mode 100644 templates/org/times/daterange.tmpl create mode 100644 templates/org/times/submenu.tmpl create mode 100644 templates/org/times/times_by_members.tmpl create mode 100644 templates/org/times/times_by_milestones.tmpl create mode 100644 templates/org/times/times_by_repos.tmpl create mode 100644 web_src/js/features/org-times.js diff --git a/models/organization/org_time/org_times_test.go b/models/organization/org_time/org_times_test.go new file mode 100644 index 0000000000000..45ed76d8f2687 --- /dev/null +++ b/models/organization/org_time/org_times_test.go @@ -0,0 +1,308 @@ +// Copyright 2022 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. +// SPDX-License-Identifier: MIT + +package orgtime_test + +import ( + "path/filepath" + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/models/unittest" + + _ "code.gitea.io/gitea/models/issues" + + "github.com/stretchr/testify/assert" +) + +// TestMain sets up the testing environment specifically for testing org times. +func TestMain(m *testing.M) { + unittest.MainTest(m, &unittest.TestOptions{ + GiteaRootPath: filepath.Join("..", "..", ".."), + FixtureFiles: []string{ + "user.yml", + "org_user.yml", + "repository.yml", + "issue.yml", + "milestone.yml", + "tracked_time.yml", + }, + }) +} + +// TestTimesPrepareDB prepares the database for the following tests. +func TestTimesPrepareDB(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) +} + +// TestTimesByRepos tests TimesByRepos functionality +func TestTimesByRepos(t *testing.T) { + kases := []struct { + name string + unixfrom int64 + unixto int64 + orgname int64 + expected []organization.ResultTimesByRepos + }{ + { + name: "Full sum for org 1", + unixfrom: 0, + unixto: 9223372036854775807, + orgname: 1, + expected: []organization.ResultTimesByRepos(nil), + }, + { + name: "Full sum for org 2", + unixfrom: 0, + unixto: 9223372036854775807, + orgname: 2, + expected: []organization.ResultTimesByRepos{ + { + Name: "repo1", + SumTime: 4083, + }, + { + Name: "repo2", + SumTime: 75, + }, + }, + }, + { + name: "Simple time bound", + unixfrom: 946684801, + unixto: 946684802, + orgname: 2, + expected: []organization.ResultTimesByRepos{ + { + Name: "repo1", + SumTime: 3662, + }, + }, + }, + { + name: "Both times inclusive", + unixfrom: 946684801, + unixto: 946684801, + orgname: 2, + expected: []organization.ResultTimesByRepos{ + { + Name: "repo1", + SumTime: 3661, + }, + }, + }, + { + name: "Should ignore deleted", + unixfrom: 947688814, + unixto: 947688815, + orgname: 2, + expected: []organization.ResultTimesByRepos{ + { + Name: "repo2", + SumTime: 71, + }, + }, + }, + } + + // Run test kases + for _, kase := range kases { + t.Run(kase.name, func(t *testing.T) { + org, err := organization.GetOrgByID(db.DefaultContext, kase.orgname) + assert.NoError(t, err) + results, err := org.GetTimesByRepos(kase.unixfrom, kase.unixto) + assert.NoError(t, err) + assert.Equal(t, kase.expected, results) + }) + } +} + +// TestTimesByMilestones tests TimesByMilestones functionality +func TestTimesByMilestones(t *testing.T) { + kases := []struct { + name string + unixfrom int64 + unixto int64 + orgname int64 + expected []organization.ResultTimesByMilestones + }{ + { + name: "Full sum for org 1", + unixfrom: 0, + unixto: 9223372036854775807, + orgname: 1, + expected: []organization.ResultTimesByMilestones(nil), + }, + { + name: "Full sum for org 2", + unixfrom: 0, + unixto: 9223372036854775807, + orgname: 2, + expected: []organization.ResultTimesByMilestones{ + { + RepoName: "repo1", + Name: "", + ID: "", + SumTime: 401, + HideRepoName: false, + }, + { + RepoName: "repo1", + Name: "milestone1", + ID: "1", + SumTime: 3682, + HideRepoName: false, + }, + { + RepoName: "repo2", + Name: "", + ID: "", + SumTime: 75, + HideRepoName: false, + }, + }, + }, + { + name: "Simple time bound", + unixfrom: 946684801, + unixto: 946684802, + orgname: 2, + expected: []organization.ResultTimesByMilestones{ + { + RepoName: "repo1", + Name: "milestone1", + ID: "1", + SumTime: 3662, + HideRepoName: false, + }, + }, + }, + { + name: "Both times inclusive", + unixfrom: 946684801, + unixto: 946684801, + orgname: 2, + expected: []organization.ResultTimesByMilestones{ + { + RepoName: "repo1", + Name: "milestone1", + ID: "1", + SumTime: 3661, + HideRepoName: false, + }, + }, + }, + { + name: "Should ignore deleted", + unixfrom: 947688814, + unixto: 947688815, + orgname: 2, + expected: []organization.ResultTimesByMilestones{ + { + RepoName: "repo2", + Name: "", + ID: "", + SumTime: 71, + HideRepoName: false, + }, + }, + }, + } + + // Run test kases + for _, kase := range kases { + t.Run(kase.name, func(t *testing.T) { + org, err := organization.GetOrgByID(db.DefaultContext, kase.orgname) + assert.NoError(t, err) + results, err := org.GetTimesByMilestones(kase.unixfrom, kase.unixto) + assert.NoError(t, err) + assert.Equal(t, kase.expected, results) + }) + } +} + +// TestTimesByMembers tests TimesByMembers functionality +func TestTimesByMembers(t *testing.T) { + kases := []struct { + name string + unixfrom int64 + unixto int64 + orgname int64 + expected []organization.ResultTimesByMembers + }{ + { + name: "Full sum for org 1", + unixfrom: 0, + unixto: 9223372036854775807, + orgname: 1, + expected: []organization.ResultTimesByMembers(nil), + }, + { + // Test case: Sum of times forever in org no. 2 + name: "Full sum for org 2", + unixfrom: 0, + unixto: 9223372036854775807, + orgname: 2, + expected: []organization.ResultTimesByMembers{ + { + Name: "user2", + SumTime: 3666, + }, + { + Name: "user1", + SumTime: 491, + }, + }, + }, + { + name: "Simple time bound", + unixfrom: 946684801, + unixto: 946684802, + orgname: 2, + expected: []organization.ResultTimesByMembers{ + { + Name: "user2", + SumTime: 3662, + }, + }, + }, + { + name: "Both times inclusive", + unixfrom: 946684801, + unixto: 946684801, + orgname: 2, + expected: []organization.ResultTimesByMembers{ + { + Name: "user2", + SumTime: 3661, + }, + }, + }, + { + name: "Should ignore deleted", + unixfrom: 947688814, + unixto: 947688815, + orgname: 2, + expected: []organization.ResultTimesByMembers{ + { + Name: "user1", + SumTime: 71, + }, + }, + }, + } + + // Run test kases + for _, kase := range kases { + t.Run(kase.name, func(t *testing.T) { + org, err := organization.GetOrgByID(db.DefaultContext, kase.orgname) + assert.NoError(t, err) + results, err := org.GetTimesByMembers(kase.unixfrom, kase.unixto) + assert.NoError(t, err) + assert.Equal(t, kase.expected, results) + }) + } +} diff --git a/models/organization/org_times.go b/models/organization/org_times.go new file mode 100644 index 0000000000000..68f373910b5d5 --- /dev/null +++ b/models/organization/org_times.go @@ -0,0 +1,88 @@ +// Copyright 2022 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. +// SPDX-License-Identifier: MIT + +package organization + +import ( + "code.gitea.io/gitea/models/db" + + "xorm.io/builder" +) + +// ResultTimesByRepos is a struct for DB query results +type ResultTimesByRepos struct { + Name string + SumTime int64 +} + +// ResultTimesByMilestones is a struct for DB query results +type ResultTimesByMilestones struct { + RepoName string + Name string + ID string + SumTime int64 + HideRepoName bool +} + +// ResultTimesByMembers is a struct for DB query results +type ResultTimesByMembers struct { + Name string + SumTime int64 +} + +// GetTimesByRepos fetches data from DB to serve TimesByRepos. +func (org *Organization) GetTimesByRepos(unixfrom, unixto int64) (results []ResultTimesByRepos, err error) { + // Get the data from the DB + err = db.GetEngine(db.DefaultContext). + Select("repository.name, SUM(tracked_time.time) AS sum_time"). + Table("tracked_time"). + Join("INNER", "issue", "tracked_time.issue_id = issue.id"). + Join("INNER", "repository", "issue.repo_id = repository.id"). + Where(builder.Eq{"repository.owner_id": org.ID}). + And(builder.Eq{"tracked_time.deleted": false}). + And(builder.Gte{"tracked_time.created_unix": unixfrom}). + And(builder.Lte{"tracked_time.created_unix": unixto}). + GroupBy("repository.id"). + OrderBy("repository.name"). + Find(&results) + return results, err +} + +// GetTimesByMilestones gets the actual data from the DB to serve TimesByMilestones. +func (org *Organization) GetTimesByMilestones(unixfrom, unixto int64) (results []ResultTimesByMilestones, err error) { + err = db.GetEngine(db.DefaultContext). + Select("repository.name AS repo_name, milestone.name, milestone.id, SUM(tracked_time.time) AS sum_time"). + Table("tracked_time"). + Join("INNER", "issue", "tracked_time.issue_id = issue.id"). + Join("INNER", "repository", "issue.repo_id = repository.id"). + Join("LEFT", "milestone", "issue.milestone_id = milestone.id"). + Where(builder.Eq{"repository.owner_id": org.ID}). + And(builder.Eq{"tracked_time.deleted": false}). + And(builder.Gte{"tracked_time.created_unix": unixfrom}). + And(builder.Lte{"tracked_time.created_unix": unixto}). + GroupBy("repository.id, milestone.id"). + OrderBy("repository.name, milestone.deadline_unix, milestone.id"). + Find(&results) + + return results, err +} + +// getTimesByMembers gets the actual data from the DB to serve TimesByMembers. +func (org *Organization) GetTimesByMembers(unixfrom, unixto int64) (results []ResultTimesByMembers, err error) { + err = db.GetEngine(db.DefaultContext). + Select("user.name, SUM(tracked_time.time) AS sum_time"). + Table("tracked_time"). + Join("INNER", "issue", "tracked_time.issue_id = issue.id"). + Join("INNER", "repository", "issue.repo_id = repository.id"). + Join("INNER", "user", "tracked_time.user_id = user.id"). + Where(builder.Eq{"repository.owner_id": org.ID}). + And(builder.Eq{"tracked_time.deleted": false}). + And(builder.Gte{"tracked_time.created_unix": unixfrom}). + And(builder.Lte{"tracked_time.created_unix": unixto}). + GroupBy("user.id"). + OrderBy("sum_time DESC"). + Find(&results) + return results, err +} diff --git a/modules/templates/helper.go b/modules/templates/helper.go index 8f8f565c1f141..a13e47d095fc9 100644 --- a/modules/templates/helper.go +++ b/modules/templates/helper.go @@ -278,6 +278,7 @@ func NewFuncMap() []template.FuncMap { "Printf": fmt.Sprintf, "Escape": Escape, "Sec2Time": util.SecToTime, + "Sec2Hour": util.SecToHour, "ParseDeadline": func(deadline string) []string { return strings.Split(deadline, "|") }, diff --git a/modules/util/sec_to_hour.go b/modules/util/sec_to_hour.go new file mode 100644 index 0000000000000..ee75f7b2fd348 --- /dev/null +++ b/modules/util/sec_to_hour.go @@ -0,0 +1,38 @@ +// Copyright 2022 Gitea. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. +// SPDX-License-Identifier: MIT + +package util + +import ( + "strings" +) + +// SecToHour works similarly to SecToTime (i.e. converts an amount of seconds +// to a human-readable string), but works with units that work in a timesheet, +// namely: only hours and minutes. +// +// If somebody worked 8 hours on 4 workdays on an issue (4 days * 8 hours), we +// need to see "32 hours", not "1 day 8 hours". When dealing with worktime, no +// project manager calculates like that. +// +// For example: +// 66 -> 1 minute +// 52410 -> 14 hours 33 minutes +// 563418 -> 156 hours 30 minutes (NOT "6 days 12 hours") +func SecToHour(duration int64) string { + formattedTime := "" + hours := (duration / 3600) + minutes := (duration / 60) % 60 + + // Show hours if any + if hours > 0 { + formattedTime = formatTime(hours, "hour", formattedTime) + } + // Show minutes always + formattedTime = formatTime(minutes, "minute", formattedTime) + + // The formatTime() function always appends a space at the end. This will be trimmed + return strings.TrimRight(formattedTime, " ") +} diff --git a/modules/util/sec_to_hour_test.go b/modules/util/sec_to_hour_test.go new file mode 100644 index 0000000000000..016a0bde1990d --- /dev/null +++ b/modules/util/sec_to_hour_test.go @@ -0,0 +1,34 @@ +// Copyright 2022 Gitea. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. +// SPDX-License-Identifier: MIT + +package util + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSecToHour(t *testing.T) { + // Less than a minute + assert.Equal(t, SecToHour(56), "") + // Singular minute + assert.Equal(t, SecToHour(66), "1 minute") + // Plural minutes + assert.Equal(t, SecToHour(300), "5 minutes") + // Singular hour + assert.Equal(t, SecToHour(3600), "1 hour") + // Singular hour + minute + assert.Equal(t, SecToHour(3660), "1 hour 1 minute") + // Singular hour, plural minute + assert.Equal(t, SecToHour(4199), "1 hour 9 minutes") + // Rounding (lack of) + assert.Equal(t, SecToHour(4200), "1 hour 10 minutes") + assert.Equal(t, SecToHour(4259), "1 hour 10 minutes") + // Going over days, weeks and still showing only hours + assert.Equal(t, SecToHour(52410), "14 hours 33 minutes") + assert.Equal(t, SecToHour(563418), "156 hours 30 minutes") + assert.Equal(t, SecToHour(1563418), "434 hours 16 minutes") +} diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 59089fd39bb2a..f4bcc4b9567c5 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -2367,6 +2367,7 @@ repo_updated = Updated people = People teams = Teams code = Code +worktime = Worktime lower_members = members lower_repositories = repositories create_new_team = New Team @@ -2481,6 +2482,16 @@ teams.invite.title = You've been invited to join team %s in org teams.invite.by = Invited by %s teams.invite.description = Please click the button below to join the team. +times.start_date = Start date +times.end_date = End date +times.update = Update +times.member = Member +times.milestone = Milestone +times.time = Time +times.by_repositories = By repositories +times.by_milestones = By milestones +times.by_members = By members + [admin] dashboard = Dashboard users = User Accounts diff --git a/routers/web/org/times.go b/routers/web/org/times.go new file mode 100644 index 0000000000000..9e10e7615d554 --- /dev/null +++ b/routers/web/org/times.go @@ -0,0 +1,147 @@ +// Copyright 2022 The Gitea Authors. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. +// SPDX-License-Identifier: MIT + +package org + +import ( + "net/http" + "time" + + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/setting" +) + +const ( + tplByRepos base.TplName = "org/times/times_by_repos" + tplByMembers base.TplName = "org/times/times_by_members" + tplByMilestones base.TplName = "org/times/times_by_milestones" +) + +// parseOrgTimes contains functionality that is required in all these functions, +// like parsing the date from the request, setting default dates, etc. +func parseOrgTimes(ctx *context.Context) (unixfrom, unixto int64, err error) { + // Time range from request, if any + from := ctx.FormString("from") + to := ctx.FormString("to") + // Defaults for "from" and "to" dates, if not in request + if from == "" { + // DEFAULT of "from": start of current month + from = time.Now().Format("2006-01") + "-01" + } + if to == "" { + // DEFAULT of "to": today + to = time.Now().Format("2006-01-02") + } + + // Prepare Form values + ctx.Data["RangeFrom"] = from + ctx.Data["RangeTo"] = to + + // Prepare unix time values for SQL + from2, err := time.Parse("2006-01-02", from) + if err != nil { + ctx.ServerError("time.Parse", err) + } + unixfrom = from2.Unix() + to2, err := time.Parse("2006-01-02", to) + if err != nil { + ctx.ServerError("time.Parse", err) + } + // Humans expect that we include the ending day too + unixto = to2.Add(1440*time.Minute - 1*time.Second).Unix() + return unixfrom, unixto, err +} + +// TimesByRepos renders worktime by repositories. +func TimesByRepos(ctx *context.Context) { + // Run common functionality + unixfrom, unixto, err := parseOrgTimes(ctx) + if err != nil { + return + } + + // View variables + ctx.Data["PageIsOrgTimes"] = true + ctx.Data["AppSubURL"] = setting.AppSubURL + + // Set submenu tab + ctx.Data["TabIsByRepos"] = true + + results, err := ctx.Org.Organization.GetTimesByRepos(unixfrom, unixto) + if err != nil { + ctx.ServerError("getTimesByRepos", err) + return + } + ctx.Data["results"] = results + + // Reply with view + ctx.HTML(http.StatusOK, tplByRepos) +} + +// TimesByMilestones renders work time by milestones. +func TimesByMilestones(ctx *context.Context) { + // Run common functionality + unixfrom, unixto, err := parseOrgTimes(ctx) + if err != nil { + return + } + + // View variables + ctx.Data["PageIsOrgTimes"] = true + ctx.Data["AppSubURL"] = setting.AppSubURL + + // Set submenu tab + ctx.Data["TabIsByMilestones"] = true + + // Get the data from the DB + results, err := ctx.Org.Organization.GetTimesByMilestones(unixfrom, unixto) + if err != nil { + ctx.ServerError("getTimesByMilestones", err) + return + } + + // Show only the first RepoName, for nicer output. + prevreponame := "" + for i := 0; i < len(results); i++ { + res := &results[i] + if prevreponame == res.RepoName { + res.HideRepoName = true + } + prevreponame = res.RepoName + } + + // Send results to view + ctx.Data["results"] = results + + // Reply with view + ctx.HTML(http.StatusOK, tplByMilestones) +} + +// TimesByMembers renders worktime by project member persons. +func TimesByMembers(ctx *context.Context) { + // Run common functionality + unixfrom, unixto, err := parseOrgTimes(ctx) + if err != nil { + return + } + + // View variables + ctx.Data["PageIsOrgTimes"] = true + ctx.Data["AppSubURL"] = setting.AppSubURL + + // Set submenu tab + ctx.Data["TabIsByMembers"] = true + + // Get the data from the DB + results, err := ctx.Org.Organization.GetTimesByMembers(unixfrom, unixto) + if err != nil { + ctx.ServerError("getTimesByMembers", err) + return + } + ctx.Data["results"] = results + + ctx.HTML(http.StatusOK, tplByMembers) +} diff --git a/routers/web/web.go b/routers/web/web.go index 88e27ad678992..7ec7def7e3d43 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -736,6 +736,12 @@ func RegisterRoutes(m *web.Route) { m.Post("/teams/{team}/edit", web.Bind(forms.CreateTeamForm{}), org.EditTeamPost) m.Post("/teams/{team}/delete", org.DeleteTeam) + m.Group("/times", func() { + m.Get("/by_repos", org.TimesByRepos) + m.Get("/by_members", org.TimesByMembers) + m.Get("/by_milestones", org.TimesByMilestones) + }, context.OrgAssignment(false, true)) + m.Group("/settings", func() { m.Combo("").Get(org.Settings). Post(web.Bind(forms.UpdateOrgSettingForm{}), org.SettingsPost) diff --git a/templates/org/menu.tmpl b/templates/org/menu.tmpl index 5f543424fce32..cf3220a55a55d 100644 --- a/templates/org/menu.tmpl +++ b/templates/org/menu.tmpl @@ -29,6 +29,11 @@
{{.NumTeams}}
{{end}} + {{if .IsOrganizationOwner}} + + {{svg "octicon-clock"}} {{$.locale.Tr "org.worktime"}} + + {{end}} {{end}} {{if .IsOrganizationOwner}} diff --git a/templates/org/times/daterange.tmpl b/templates/org/times/daterange.tmpl new file mode 100644 index 0000000000000..f71b4a6690dc7 --- /dev/null +++ b/templates/org/times/daterange.tmpl @@ -0,0 +1,15 @@ +
+
+ +
+ +
+
+
+ +
+ +
+
+ +
diff --git a/templates/org/times/submenu.tmpl b/templates/org/times/submenu.tmpl new file mode 100644 index 0000000000000..7397bdb8759e4 --- /dev/null +++ b/templates/org/times/submenu.tmpl @@ -0,0 +1,7 @@ +
+ +
diff --git a/templates/org/times/times_by_members.tmpl b/templates/org/times/times_by_members.tmpl new file mode 100644 index 0000000000000..f0dfb8156a6a5 --- /dev/null +++ b/templates/org/times/times_by_members.tmpl @@ -0,0 +1,40 @@ +{{template "base/head" .}} +
+ {{template "org/header" .}} +
+ {{template "base/alert" .}} +
+
+
+ {{template "org/times/daterange" .}} +
+
+ {{template "org/times/submenu" .}} + + + + + + + + + {{range $.results}} + + + + + {{end}} + +
{{$.locale.Tr "org.times.member"}}{{$.locale.Tr "org.times.time"}}
+ + {{.Name}} + + {{svg "octicon-clock" 16 "mr-2"}} + {{.SumTime | Sec2Hour}} +
+
+
+
+
+
+{{template "base/footer" .}} diff --git a/templates/org/times/times_by_milestones.tmpl b/templates/org/times/times_by_milestones.tmpl new file mode 100644 index 0000000000000..2d6086e5a8246 --- /dev/null +++ b/templates/org/times/times_by_milestones.tmpl @@ -0,0 +1,48 @@ +{{template "base/head" .}} +
+ {{template "org/header" .}} +
+ {{template "base/alert" .}} +
+
+
+ {{template "org/times/daterange" .}} +
+
+ {{template "org/times/submenu" .}} + + + + + + + + + + {{range $.results}} + + + + {{else}} + — + {{end}} + + + {{end}} + +
{{$.locale.Tr "repository"}}{{$.locale.Tr "org.times.milestone"}}{{$.locale.Tr "org.times.time"}}
+ {{if not .HideRepoName}} + {{svg "octicon-repo" 16 "mr-2"}}{{.RepoName}} + {{end}} + + {{if .Name}} + {{svg "octicon-milestone" 16 "mr-2"}}{{.Name}} + {{svg "octicon-clock" 16 "mr-2"}} + {{.SumTime | Sec2Hour}} +
+
+
+
+
+
+{{template "base/footer" .}} diff --git a/templates/org/times/times_by_repos.tmpl b/templates/org/times/times_by_repos.tmpl new file mode 100644 index 0000000000000..ac4170c6c0d09 --- /dev/null +++ b/templates/org/times/times_by_repos.tmpl @@ -0,0 +1,37 @@ +{{template "base/head" .}} +
+ {{template "org/header" .}} +
+ {{template "base/alert" .}} +
+
+
+ {{template "org/times/daterange" .}} +
+
+ {{template "org/times/submenu" .}} + + + + + + + + + {{range $.results}} + + + + + {{end}} + +
{{$.locale.Tr "repository"}}{{$.locale.Tr "org.times.time"}}
{{svg "octicon-repo" 16 "mr-2"}}{{.Name}} + {{svg "octicon-clock" 16 "mr-2"}} + {{.SumTime | Sec2Hour}} +
+
+
+
+
+
+{{template "base/footer" .}} diff --git a/web_src/js/features/org-times.js b/web_src/js/features/org-times.js new file mode 100644 index 0000000000000..688e8c19dd8f3 --- /dev/null +++ b/web_src/js/features/org-times.js @@ -0,0 +1,8 @@ +import $ from 'jquery'; + +export function initOrgTimes() { + // Comfort function to auto-open 2nd date picker of range picker, modeled on Formantic UI's behaviour + $('#rangefrom').on('change', () => { + document.getElementById('rangeto').showPicker(); + }); +} diff --git a/web_src/js/index.js b/web_src/js/index.js index 611c09d2b8c16..20054d96f6b15 100644 --- a/web_src/js/index.js +++ b/web_src/js/index.js @@ -75,6 +75,7 @@ import { } from './features/repo-settings.js'; import {initViewedCheckboxListenerFor} from './features/pull-view-file.js'; import {initOrgTeamSearchRepoBox, initOrgTeamSettings} from './features/org-team.js'; +import {initOrgTimes} from './features/org-times.js'; import {initUserAuthWebAuthn, initUserAuthWebAuthnRegister} from './features/user-auth-webauthn.js'; import {initRepoRelease, initRepoReleaseEditor} from './features/repo-release.js'; import {initRepoEditor} from './features/repo-editor.js'; @@ -152,6 +153,8 @@ $(document).ready(() => { initOrgTeamSearchRepoBox(); initOrgTeamSettings(); + initOrgTimes(); + initRepoActivityTopAuthorsChart(); initRepoArchiveLinks(); initRepoBranchButton(); From 851fd08783d63714badd2b0281ea97e527c02bf7 Mon Sep 17 00:00:00 2001 From: user Date: Thu, 16 Feb 2023 13:14:10 +0000 Subject: [PATCH 2/9] Code review change implemented: The Organization object could be the first parameter of the function. --- models/organization/org_times.go | 6 ++--- .../{org_time => }/org_times_test.go | 24 ++++--------------- routers/web/org/times.go | 7 +++--- 3 files changed, 11 insertions(+), 26 deletions(-) rename models/organization/{org_time => }/org_times_test.go (90%) diff --git a/models/organization/org_times.go b/models/organization/org_times.go index 68f373910b5d5..9d2c82c056523 100644 --- a/models/organization/org_times.go +++ b/models/organization/org_times.go @@ -33,7 +33,7 @@ type ResultTimesByMembers struct { } // GetTimesByRepos fetches data from DB to serve TimesByRepos. -func (org *Organization) GetTimesByRepos(unixfrom, unixto int64) (results []ResultTimesByRepos, err error) { +func GetTimesByRepos(org *Organization, unixfrom, unixto int64) (results []ResultTimesByRepos, err error) { // Get the data from the DB err = db.GetEngine(db.DefaultContext). Select("repository.name, SUM(tracked_time.time) AS sum_time"). @@ -51,7 +51,7 @@ func (org *Organization) GetTimesByRepos(unixfrom, unixto int64) (results []Resu } // GetTimesByMilestones gets the actual data from the DB to serve TimesByMilestones. -func (org *Organization) GetTimesByMilestones(unixfrom, unixto int64) (results []ResultTimesByMilestones, err error) { +func GetTimesByMilestones(org *Organization, unixfrom, unixto int64) (results []ResultTimesByMilestones, err error) { err = db.GetEngine(db.DefaultContext). Select("repository.name AS repo_name, milestone.name, milestone.id, SUM(tracked_time.time) AS sum_time"). Table("tracked_time"). @@ -70,7 +70,7 @@ func (org *Organization) GetTimesByMilestones(unixfrom, unixto int64) (results [ } // getTimesByMembers gets the actual data from the DB to serve TimesByMembers. -func (org *Organization) GetTimesByMembers(unixfrom, unixto int64) (results []ResultTimesByMembers, err error) { +func GetTimesByMembers(org *Organization, unixfrom, unixto int64) (results []ResultTimesByMembers, err error) { err = db.GetEngine(db.DefaultContext). Select("user.name, SUM(tracked_time.time) AS sum_time"). Table("tracked_time"). diff --git a/models/organization/org_time/org_times_test.go b/models/organization/org_times_test.go similarity index 90% rename from models/organization/org_time/org_times_test.go rename to models/organization/org_times_test.go index 45ed76d8f2687..f624bfb927c2e 100644 --- a/models/organization/org_time/org_times_test.go +++ b/models/organization/org_times_test.go @@ -3,10 +3,9 @@ // license that can be found in the LICENSE file. // SPDX-License-Identifier: MIT -package orgtime_test +package organization_test import ( - "path/filepath" "testing" "code.gitea.io/gitea/models/db" @@ -18,21 +17,6 @@ import ( "github.com/stretchr/testify/assert" ) -// TestMain sets up the testing environment specifically for testing org times. -func TestMain(m *testing.M) { - unittest.MainTest(m, &unittest.TestOptions{ - GiteaRootPath: filepath.Join("..", "..", ".."), - FixtureFiles: []string{ - "user.yml", - "org_user.yml", - "repository.yml", - "issue.yml", - "milestone.yml", - "tracked_time.yml", - }, - }) -} - // TestTimesPrepareDB prepares the database for the following tests. func TestTimesPrepareDB(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) @@ -113,7 +97,7 @@ func TestTimesByRepos(t *testing.T) { t.Run(kase.name, func(t *testing.T) { org, err := organization.GetOrgByID(db.DefaultContext, kase.orgname) assert.NoError(t, err) - results, err := org.GetTimesByRepos(kase.unixfrom, kase.unixto) + results, err := organization.GetTimesByRepos(org, kase.unixfrom, kase.unixto) assert.NoError(t, err) assert.Equal(t, kase.expected, results) }) @@ -217,7 +201,7 @@ func TestTimesByMilestones(t *testing.T) { t.Run(kase.name, func(t *testing.T) { org, err := organization.GetOrgByID(db.DefaultContext, kase.orgname) assert.NoError(t, err) - results, err := org.GetTimesByMilestones(kase.unixfrom, kase.unixto) + results, err := organization.GetTimesByMilestones(org, kase.unixfrom, kase.unixto) assert.NoError(t, err) assert.Equal(t, kase.expected, results) }) @@ -300,7 +284,7 @@ func TestTimesByMembers(t *testing.T) { t.Run(kase.name, func(t *testing.T) { org, err := organization.GetOrgByID(db.DefaultContext, kase.orgname) assert.NoError(t, err) - results, err := org.GetTimesByMembers(kase.unixfrom, kase.unixto) + results, err := organization.GetTimesByMembers(org, kase.unixfrom, kase.unixto) assert.NoError(t, err) assert.Equal(t, kase.expected, results) }) diff --git a/routers/web/org/times.go b/routers/web/org/times.go index 9e10e7615d554..85cdc6cc1c69a 100644 --- a/routers/web/org/times.go +++ b/routers/web/org/times.go @@ -9,6 +9,7 @@ import ( "net/http" "time" + "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/setting" @@ -70,7 +71,7 @@ func TimesByRepos(ctx *context.Context) { // Set submenu tab ctx.Data["TabIsByRepos"] = true - results, err := ctx.Org.Organization.GetTimesByRepos(unixfrom, unixto) + results, err := organization.GetTimesByRepos(ctx.Org.Organization, unixfrom, unixto) if err != nil { ctx.ServerError("getTimesByRepos", err) return @@ -97,7 +98,7 @@ func TimesByMilestones(ctx *context.Context) { ctx.Data["TabIsByMilestones"] = true // Get the data from the DB - results, err := ctx.Org.Organization.GetTimesByMilestones(unixfrom, unixto) + results, err := organization.GetTimesByMilestones(ctx.Org.Organization, unixfrom, unixto) if err != nil { ctx.ServerError("getTimesByMilestones", err) return @@ -136,7 +137,7 @@ func TimesByMembers(ctx *context.Context) { ctx.Data["TabIsByMembers"] = true // Get the data from the DB - results, err := ctx.Org.Organization.GetTimesByMembers(unixfrom, unixto) + results, err := organization.GetTimesByMembers(ctx.Org.Organization, unixfrom, unixto) if err != nil { ctx.ServerError("getTimesByMembers", err) return From bd60f6322df3b8f547935b97cc487023791c648c Mon Sep 17 00:00:00 2001 From: user Date: Thu, 16 Feb 2023 13:15:17 +0000 Subject: [PATCH 3/9] Updated CSS class on elements to get back the right look. --- templates/org/times/submenu.tmpl | 6 +++--- templates/org/times/times_by_members.tmpl | 4 ++-- templates/org/times/times_by_milestones.tmpl | 6 +++--- templates/org/times/times_by_repos.tmpl | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/templates/org/times/submenu.tmpl b/templates/org/times/submenu.tmpl index 7397bdb8759e4..2837c29c02666 100644 --- a/templates/org/times/submenu.tmpl +++ b/templates/org/times/submenu.tmpl @@ -1,7 +1,7 @@ diff --git a/templates/org/times/times_by_members.tmpl b/templates/org/times/times_by_members.tmpl index f0dfb8156a6a5..6120b70ecbf47 100644 --- a/templates/org/times/times_by_members.tmpl +++ b/templates/org/times/times_by_members.tmpl @@ -21,11 +21,11 @@ {{range $.results}} - + {{svg "octicon-person" 16 "gt-mr-2"}} {{.Name}} - {{svg "octicon-clock" 16 "mr-2"}} + {{svg "octicon-clock" 16 "gt-mr-2"}} {{.SumTime | Sec2Hour}} diff --git a/templates/org/times/times_by_milestones.tmpl b/templates/org/times/times_by_milestones.tmpl index 2d6086e5a8246..c0b52b3dce2a6 100644 --- a/templates/org/times/times_by_milestones.tmpl +++ b/templates/org/times/times_by_milestones.tmpl @@ -23,17 +23,17 @@ {{if not .HideRepoName}} - {{svg "octicon-repo" 16 "mr-2"}}{{.RepoName}} + {{svg "octicon-repo" 16 "gt-mr-2"}}{{.RepoName}} {{end}} {{if .Name}} - {{svg "octicon-milestone" 16 "mr-2"}}{{.Name}} + {{svg "octicon-milestone" 16 "gt-mr-2"}}{{.Name}} {{else}} — {{end}} - {{svg "octicon-clock" 16 "mr-2"}} + {{svg "octicon-clock" 16 "gt-mr-2"}} {{.SumTime | Sec2Hour}} diff --git a/templates/org/times/times_by_repos.tmpl b/templates/org/times/times_by_repos.tmpl index ac4170c6c0d09..6dea968d8f3fe 100644 --- a/templates/org/times/times_by_repos.tmpl +++ b/templates/org/times/times_by_repos.tmpl @@ -20,9 +20,9 @@ {{range $.results}} - {{svg "octicon-repo" 16 "mr-2"}}{{.Name}} + {{svg "octicon-repo" 16 "gt-mr-2"}}{{.Name}} - {{svg "octicon-clock" 16 "mr-2"}} + {{svg "octicon-clock" 16 "gt-mr-2"}} {{.SumTime | Sec2Hour}} From 855f45d19c3f9e87dc6cc6df0066e2dc36834fb1 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sun, 2 Feb 2025 20:43:37 +0800 Subject: [PATCH 4/9] fix --- models/organization/org_times.go | 88 --------- models/organization/org_worktime.go | 87 +++++++++ modules/templates/helper.go | 2 +- modules/util/sec_to_time.go | 10 +- modules/util/sec_to_time_test.go | 2 + options/locale/locale_en-US.ini | 11 ++ routers/web/org/times.go | 148 --------------- routers/web/org/worktime.go | 72 ++++++++ routers/web/web.go | 6 +- services/context/org.go | 2 + templates/org/menu.tmpl | 5 + templates/org/times/daterange.tmpl | 15 -- templates/org/times/submenu.tmpl | 7 - templates/org/times/times_by_members.tmpl | 40 ----- templates/org/times/times_by_milestones.tmpl | 48 ----- templates/org/times/times_by_repos.tmpl | 37 ---- templates/org/worktime.tmpl | 43 +++++ templates/org/worktime/table_members.tmpl | 16 ++ templates/org/worktime/table_milestones.tmpl | 28 +++ templates/org/worktime/table_repos.tmpl | 16 ++ templates/repo/issue/filters.tmpl | 2 +- templates/repo/issue/list.tmpl | 2 +- templates/repo/issue/milestone_issues.tmpl | 2 +- templates/repo/issue/milestones.tmpl | 2 +- .../issue/sidebar/stopwatch_timetracker.tmpl | 4 +- .../repo/issue/view_content/comments.tmpl | 6 +- templates/shared/issuelist.tmpl | 2 +- templates/user/dashboard/milestones.tmpl | 2 +- .../integration/org_worktime_test.go | 170 +++++++++--------- 29 files changed, 387 insertions(+), 488 deletions(-) delete mode 100644 models/organization/org_times.go create mode 100644 models/organization/org_worktime.go delete mode 100644 routers/web/org/times.go create mode 100644 routers/web/org/worktime.go delete mode 100644 templates/org/times/daterange.tmpl delete mode 100644 templates/org/times/submenu.tmpl delete mode 100644 templates/org/times/times_by_members.tmpl delete mode 100644 templates/org/times/times_by_milestones.tmpl delete mode 100644 templates/org/times/times_by_repos.tmpl create mode 100644 templates/org/worktime.tmpl create mode 100644 templates/org/worktime/table_members.tmpl create mode 100644 templates/org/worktime/table_milestones.tmpl create mode 100644 templates/org/worktime/table_repos.tmpl rename models/organization/org_times_test.go => tests/integration/org_worktime_test.go (56%) diff --git a/models/organization/org_times.go b/models/organization/org_times.go deleted file mode 100644 index 9d2c82c056523..0000000000000 --- a/models/organization/org_times.go +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright 2022 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. -// SPDX-License-Identifier: MIT - -package organization - -import ( - "code.gitea.io/gitea/models/db" - - "xorm.io/builder" -) - -// ResultTimesByRepos is a struct for DB query results -type ResultTimesByRepos struct { - Name string - SumTime int64 -} - -// ResultTimesByMilestones is a struct for DB query results -type ResultTimesByMilestones struct { - RepoName string - Name string - ID string - SumTime int64 - HideRepoName bool -} - -// ResultTimesByMembers is a struct for DB query results -type ResultTimesByMembers struct { - Name string - SumTime int64 -} - -// GetTimesByRepos fetches data from DB to serve TimesByRepos. -func GetTimesByRepos(org *Organization, unixfrom, unixto int64) (results []ResultTimesByRepos, err error) { - // Get the data from the DB - err = db.GetEngine(db.DefaultContext). - Select("repository.name, SUM(tracked_time.time) AS sum_time"). - Table("tracked_time"). - Join("INNER", "issue", "tracked_time.issue_id = issue.id"). - Join("INNER", "repository", "issue.repo_id = repository.id"). - Where(builder.Eq{"repository.owner_id": org.ID}). - And(builder.Eq{"tracked_time.deleted": false}). - And(builder.Gte{"tracked_time.created_unix": unixfrom}). - And(builder.Lte{"tracked_time.created_unix": unixto}). - GroupBy("repository.id"). - OrderBy("repository.name"). - Find(&results) - return results, err -} - -// GetTimesByMilestones gets the actual data from the DB to serve TimesByMilestones. -func GetTimesByMilestones(org *Organization, unixfrom, unixto int64) (results []ResultTimesByMilestones, err error) { - err = db.GetEngine(db.DefaultContext). - Select("repository.name AS repo_name, milestone.name, milestone.id, SUM(tracked_time.time) AS sum_time"). - Table("tracked_time"). - Join("INNER", "issue", "tracked_time.issue_id = issue.id"). - Join("INNER", "repository", "issue.repo_id = repository.id"). - Join("LEFT", "milestone", "issue.milestone_id = milestone.id"). - Where(builder.Eq{"repository.owner_id": org.ID}). - And(builder.Eq{"tracked_time.deleted": false}). - And(builder.Gte{"tracked_time.created_unix": unixfrom}). - And(builder.Lte{"tracked_time.created_unix": unixto}). - GroupBy("repository.id, milestone.id"). - OrderBy("repository.name, milestone.deadline_unix, milestone.id"). - Find(&results) - - return results, err -} - -// getTimesByMembers gets the actual data from the DB to serve TimesByMembers. -func GetTimesByMembers(org *Organization, unixfrom, unixto int64) (results []ResultTimesByMembers, err error) { - err = db.GetEngine(db.DefaultContext). - Select("user.name, SUM(tracked_time.time) AS sum_time"). - Table("tracked_time"). - Join("INNER", "issue", "tracked_time.issue_id = issue.id"). - Join("INNER", "repository", "issue.repo_id = repository.id"). - Join("INNER", "user", "tracked_time.user_id = user.id"). - Where(builder.Eq{"repository.owner_id": org.ID}). - And(builder.Eq{"tracked_time.deleted": false}). - And(builder.Gte{"tracked_time.created_unix": unixfrom}). - And(builder.Lte{"tracked_time.created_unix": unixto}). - GroupBy("user.id"). - OrderBy("sum_time DESC"). - Find(&results) - return results, err -} diff --git a/models/organization/org_worktime.go b/models/organization/org_worktime.go new file mode 100644 index 0000000000000..0201a50188b5e --- /dev/null +++ b/models/organization/org_worktime.go @@ -0,0 +1,87 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package organization + +import ( + "code.gitea.io/gitea/models/db" + + "xorm.io/builder" +) + +type WorktimeSumByRepos struct { + RepoName string + SumTime int64 +} + +func GetWorktimeByRepos(org *Organization, unitFrom, unixTo int64) (results []WorktimeSumByRepos, err error) { + err = db.GetEngine(db.DefaultContext). + Select("repository.name AS repo_name, SUM(tracked_time.time) AS sum_time"). + Table("tracked_time"). + Join("INNER", "issue", "tracked_time.issue_id = issue.id"). + Join("INNER", "repository", "issue.repo_id = repository.id"). + Where(builder.Eq{"repository.owner_id": org.ID}). + And(builder.Eq{"tracked_time.deleted": false}). + And(builder.Gte{"tracked_time.created_unix": unitFrom}). + And(builder.Lte{"tracked_time.created_unix": unixTo}). + GroupBy("repository.id"). + OrderBy("repository.name"). + Find(&results) + return results, err +} + +type WorktimeSumByMilestones struct { + RepoName string + MilestoneName string + MilestoneID int64 + SumTime int64 + HideRepoName bool +} + +func GetWorktimeByMilestones(org *Organization, unitFrom, unixTo int64) (results []WorktimeSumByMilestones, err error) { + err = db.GetEngine(db.DefaultContext). + Select("repository.name AS repo_name, milestone.name AS milestone_name, milestone.id AS milestone_id, SUM(tracked_time.time) AS sum_time"). + Table("tracked_time"). + Join("INNER", "issue", "tracked_time.issue_id = issue.id"). + Join("INNER", "repository", "issue.repo_id = repository.id"). + Join("LEFT", "milestone", "issue.milestone_id = milestone.id"). + Where(builder.Eq{"repository.owner_id": org.ID}). + And(builder.Eq{"tracked_time.deleted": false}). + And(builder.Gte{"tracked_time.created_unix": unitFrom}). + And(builder.Lte{"tracked_time.created_unix": unixTo}). + GroupBy("repository.id, milestone.id"). + OrderBy("repository.name, milestone.deadline_unix, milestone.id"). + Find(&results) + // Show only the first RepoName, for nicer output. + prevRepoName := "" + for i := 0; i < len(results); i++ { + res := &results[i] + if prevRepoName == res.RepoName { + res.HideRepoName = true + } + prevRepoName = res.RepoName + } + return results, err +} + +type WorktimeSumByMembers struct { + UserName string + SumTime int64 +} + +func GetWorktimeByMembers(org *Organization, unitFrom, unixTo int64) (results []WorktimeSumByMembers, err error) { + err = db.GetEngine(db.DefaultContext). + Select("user.name AS user_name, SUM(tracked_time.time) AS sum_time"). + Table("tracked_time"). + Join("INNER", "issue", "tracked_time.issue_id = issue.id"). + Join("INNER", "repository", "issue.repo_id = repository.id"). + Join("INNER", "user", "tracked_time.user_id = user.id"). + Where(builder.Eq{"repository.owner_id": org.ID}). + And(builder.Eq{"tracked_time.deleted": false}). + And(builder.Gte{"tracked_time.created_unix": unitFrom}). + And(builder.Lte{"tracked_time.created_unix": unixTo}). + GroupBy("user.id"). + OrderBy("sum_time DESC"). + Find(&results) + return results, err +} diff --git a/modules/templates/helper.go b/modules/templates/helper.go index a2cc166de9755..c0b0ddc97dd18 100644 --- a/modules/templates/helper.go +++ b/modules/templates/helper.go @@ -69,7 +69,7 @@ func NewFuncMap() template.FuncMap { // time / number / format "FileSize": base.FileSize, "CountFmt": countFmt, - "Sec2Time": util.SecToHours, + "Sec2Hour": util.SecToHours, "TimeEstimateString": timeEstimateString, diff --git a/modules/util/sec_to_time.go b/modules/util/sec_to_time.go index 73667d723ef76..51c51fe6d8802 100644 --- a/modules/util/sec_to_time.go +++ b/modules/util/sec_to_time.go @@ -11,16 +11,20 @@ import ( // SecToHours converts an amount of seconds to a human-readable hours string. // This is stable for planning and managing timesheets. // Here it only supports hours and minutes, because a work day could contain 6 or 7 or 8 hours. +// If the duration is less than 1 minute, it will be shown as seconds. func SecToHours(durationVal any) string { - duration, _ := ToInt64(durationVal) - hours := duration / 3600 - minutes := (duration / 60) % 60 + seconds, _ := ToInt64(durationVal) + hours := seconds / 3600 + minutes := (seconds / 60) % 60 formattedTime := "" formattedTime = formatTime(hours, "hour", formattedTime) formattedTime = formatTime(minutes, "minute", formattedTime) // The formatTime() function always appends a space at the end. This will be trimmed + if formattedTime == "" { + formattedTime = formatTime(seconds, "second", "") + } return strings.TrimRight(formattedTime, " ") } diff --git a/modules/util/sec_to_time_test.go b/modules/util/sec_to_time_test.go index 71a8801d4f4aa..d623f9cbf83e4 100644 --- a/modules/util/sec_to_time_test.go +++ b/modules/util/sec_to_time_test.go @@ -22,4 +22,6 @@ func TestSecToHours(t *testing.T) { assert.Equal(t, "156 hours 30 minutes", SecToHours(6*day+12*hour+30*minute+18*second)) assert.Equal(t, "98 hours 16 minutes", SecToHours(4*day+2*hour+16*minute+58*second)) assert.Equal(t, "672 hours", SecToHours(4*7*day)) + assert.Equal(t, "1 second", SecToHours(1)) + assert.Equal(t, "2 seconds", SecToHours(2)) } diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 68b7fa2f9fd97..886628e4ff29a 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -54,6 +54,7 @@ webauthn_reload = Reload repository = Repository organization = Organization mirror = Mirror +issue_milestone = Milestone new_repo = New Repository new_migrate = New Migration new_mirror = New Mirror @@ -1253,6 +1254,7 @@ labels = Labels org_labels_desc = Organization level labels that can be used with all repositories under this organization org_labels_desc_manage = manage +milestone = Milestone milestones = Milestones commits = Commits commit = Commit @@ -2876,6 +2878,15 @@ view_as_role = View as: %s view_as_public_hint = You are viewing the README as a public user. view_as_member_hint = You are viewing the README as a member of this organization. +worktime = Worktime +worktime.date_range_start = Start date +worktime.date_range_end = End date +worktime.query = Query +worktime.time = Time +worktime.by_repositories = By repositories +worktime.by_milestones = By milestones +worktime.by_members = By members + [admin] maintenance = Maintenance dashboard = Dashboard diff --git a/routers/web/org/times.go b/routers/web/org/times.go deleted file mode 100644 index 85cdc6cc1c69a..0000000000000 --- a/routers/web/org/times.go +++ /dev/null @@ -1,148 +0,0 @@ -// Copyright 2022 The Gitea Authors. -// Use of this source code is governed by a MIT-style -// license that can be found in the LICENSE file. -// SPDX-License-Identifier: MIT - -package org - -import ( - "net/http" - "time" - - "code.gitea.io/gitea/models/organization" - "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" - "code.gitea.io/gitea/modules/setting" -) - -const ( - tplByRepos base.TplName = "org/times/times_by_repos" - tplByMembers base.TplName = "org/times/times_by_members" - tplByMilestones base.TplName = "org/times/times_by_milestones" -) - -// parseOrgTimes contains functionality that is required in all these functions, -// like parsing the date from the request, setting default dates, etc. -func parseOrgTimes(ctx *context.Context) (unixfrom, unixto int64, err error) { - // Time range from request, if any - from := ctx.FormString("from") - to := ctx.FormString("to") - // Defaults for "from" and "to" dates, if not in request - if from == "" { - // DEFAULT of "from": start of current month - from = time.Now().Format("2006-01") + "-01" - } - if to == "" { - // DEFAULT of "to": today - to = time.Now().Format("2006-01-02") - } - - // Prepare Form values - ctx.Data["RangeFrom"] = from - ctx.Data["RangeTo"] = to - - // Prepare unix time values for SQL - from2, err := time.Parse("2006-01-02", from) - if err != nil { - ctx.ServerError("time.Parse", err) - } - unixfrom = from2.Unix() - to2, err := time.Parse("2006-01-02", to) - if err != nil { - ctx.ServerError("time.Parse", err) - } - // Humans expect that we include the ending day too - unixto = to2.Add(1440*time.Minute - 1*time.Second).Unix() - return unixfrom, unixto, err -} - -// TimesByRepos renders worktime by repositories. -func TimesByRepos(ctx *context.Context) { - // Run common functionality - unixfrom, unixto, err := parseOrgTimes(ctx) - if err != nil { - return - } - - // View variables - ctx.Data["PageIsOrgTimes"] = true - ctx.Data["AppSubURL"] = setting.AppSubURL - - // Set submenu tab - ctx.Data["TabIsByRepos"] = true - - results, err := organization.GetTimesByRepos(ctx.Org.Organization, unixfrom, unixto) - if err != nil { - ctx.ServerError("getTimesByRepos", err) - return - } - ctx.Data["results"] = results - - // Reply with view - ctx.HTML(http.StatusOK, tplByRepos) -} - -// TimesByMilestones renders work time by milestones. -func TimesByMilestones(ctx *context.Context) { - // Run common functionality - unixfrom, unixto, err := parseOrgTimes(ctx) - if err != nil { - return - } - - // View variables - ctx.Data["PageIsOrgTimes"] = true - ctx.Data["AppSubURL"] = setting.AppSubURL - - // Set submenu tab - ctx.Data["TabIsByMilestones"] = true - - // Get the data from the DB - results, err := organization.GetTimesByMilestones(ctx.Org.Organization, unixfrom, unixto) - if err != nil { - ctx.ServerError("getTimesByMilestones", err) - return - } - - // Show only the first RepoName, for nicer output. - prevreponame := "" - for i := 0; i < len(results); i++ { - res := &results[i] - if prevreponame == res.RepoName { - res.HideRepoName = true - } - prevreponame = res.RepoName - } - - // Send results to view - ctx.Data["results"] = results - - // Reply with view - ctx.HTML(http.StatusOK, tplByMilestones) -} - -// TimesByMembers renders worktime by project member persons. -func TimesByMembers(ctx *context.Context) { - // Run common functionality - unixfrom, unixto, err := parseOrgTimes(ctx) - if err != nil { - return - } - - // View variables - ctx.Data["PageIsOrgTimes"] = true - ctx.Data["AppSubURL"] = setting.AppSubURL - - // Set submenu tab - ctx.Data["TabIsByMembers"] = true - - // Get the data from the DB - results, err := organization.GetTimesByMembers(ctx.Org.Organization, unixfrom, unixto) - if err != nil { - ctx.ServerError("getTimesByMembers", err) - return - } - ctx.Data["results"] = results - - ctx.HTML(http.StatusOK, tplByMembers) -} diff --git a/routers/web/org/worktime.go b/routers/web/org/worktime.go new file mode 100644 index 0000000000000..41cc8a94fba35 --- /dev/null +++ b/routers/web/org/worktime.go @@ -0,0 +1,72 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org + +import ( + "net/http" + "time" + + "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/services/context" +) + +const tplByRepos templates.TplName = "org/worktime" + +// parseOrgTimes contains functionality that is required in all these functions, +// like parsing the date from the request, setting default dates, etc. +func parseOrgTimes(ctx *context.Context) (unixFrom, unixTo int64) { + rangeFrom := ctx.FormString("range_from") + rangeTo := ctx.FormString("range_to") + if rangeFrom == "" { + rangeFrom = time.Now().Format("2006-01") + "-01" // defaults to start of current month + } + if rangeTo == "" { + rangeTo = time.Now().Format("2006-01-02") // defaults to today + } + + ctx.Data["RangeFrom"] = rangeFrom + ctx.Data["RangeTo"] = rangeTo + + timeFrom, err := time.Parse("2006-01-02", rangeFrom) + if err != nil { + ctx.ServerError("time.Parse", err) + } + timeTo, err := time.Parse("2006-01-02", rangeTo) + if err != nil { + ctx.ServerError("time.Parse", err) + } + unixFrom = timeFrom.Unix() + unixTo = timeTo.Add(1440*time.Minute - 1*time.Second).Unix() // humans expect that we include the ending day too + return unixFrom, unixTo +} + +func Worktime(ctx *context.Context) { + ctx.Data["PageIsOrgTimes"] = true + + unixFrom, unixTo := parseOrgTimes(ctx) + if ctx.Written() { + return + } + + worktimeBy := ctx.FormString("by") + var worktimeSumResult any + var err error + if worktimeBy == "milestones" { + worktimeSumResult, err = organization.GetWorktimeByMilestones(ctx.Org.Organization, unixFrom, unixTo) + ctx.Data["WorktimeByMilestones"] = true + } else if worktimeBy == "members" { + worktimeSumResult, err = organization.GetWorktimeByMembers(ctx.Org.Organization, unixFrom, unixTo) + ctx.Data["WorktimeByMembers"] = true + } else /* by repos */ { + worktimeSumResult, err = organization.GetWorktimeByRepos(ctx.Org.Organization, unixFrom, unixTo) + ctx.Data["WorktimeByRepos"] = true + } + if err != nil { + ctx.ServerError("GetWorktime", err) + return + } + ctx.Data["WorktimeSumResult"] = worktimeSumResult + ctx.HTML(http.StatusOK, tplByRepos) +} diff --git a/routers/web/web.go b/routers/web/web.go index 8627f06c326dd..daba9887e8f14 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -913,11 +913,7 @@ func registerRoutes(m *web.Router) { m.Post("/teams/{team}/edit", web.Bind(forms.CreateTeamForm{}), org.EditTeamPost) m.Post("/teams/{team}/delete", org.DeleteTeam) - m.Group("/times", func() { - m.Get("/by_repos", org.TimesByRepos) - m.Get("/by_members", org.TimesByMembers) - m.Get("/by_milestones", org.TimesByMilestones) - }, context.OrgAssignment(false, true)) + m.Get("/worktime", context.OrgAssignment(false, true), org.Worktime) m.Group("/settings", func() { m.Combo("").Get(org.Settings). diff --git a/services/context/org.go b/services/context/org.go index be87cef7a316f..f4597a4ce1572 100644 --- a/services/context/org.go +++ b/services/context/org.go @@ -63,6 +63,7 @@ func GetOrganizationByParams(ctx *Context) { } // HandleOrgAssignment handles organization assignment +// args: requireMember, requireOwner, requireTeamMember, requireTeamAdmin func HandleOrgAssignment(ctx *Context, args ...bool) { var ( requireMember bool @@ -269,6 +270,7 @@ func HandleOrgAssignment(ctx *Context, args ...bool) { } // OrgAssignment returns a middleware to handle organization assignment +// args: requireMember, requireOwner, requireTeamMember, requireTeamAdmin func OrgAssignment(args ...bool) func(ctx *Context) { return func(ctx *Context) { HandleOrgAssignment(ctx, args...) diff --git a/templates/org/menu.tmpl b/templates/org/menu.tmpl index 4a8aee68a7d37..2d3af2d559791 100644 --- a/templates/org/menu.tmpl +++ b/templates/org/menu.tmpl @@ -45,6 +45,11 @@ {{end}} {{if .IsOrganizationOwner}} + + {{svg "octicon-clock"}} {{ctx.Locale.Tr "org.worktime"}} + + {{end}} + {{if .IsOrganizationOwner}} {{svg "octicon-tools"}} {{ctx.Locale.Tr "repo.settings"}} diff --git a/templates/org/times/daterange.tmpl b/templates/org/times/daterange.tmpl deleted file mode 100644 index f71b4a6690dc7..0000000000000 --- a/templates/org/times/daterange.tmpl +++ /dev/null @@ -1,15 +0,0 @@ -
-
- -
- -
-
-
- -
- -
-
- -
diff --git a/templates/org/times/submenu.tmpl b/templates/org/times/submenu.tmpl deleted file mode 100644 index 2837c29c02666..0000000000000 --- a/templates/org/times/submenu.tmpl +++ /dev/null @@ -1,7 +0,0 @@ -
diff --git a/templates/org/times/times_by_members.tmpl b/templates/org/times/times_by_members.tmpl deleted file mode 100644 index 6120b70ecbf47..0000000000000 --- a/templates/org/times/times_by_members.tmpl +++ /dev/null @@ -1,40 +0,0 @@ -{{template "base/head" .}} -
- {{template "org/header" .}} -
- {{template "base/alert" .}} -
-
-
- {{template "org/times/daterange" .}} -
-
- {{template "org/times/submenu" .}} - - - - - - - - - {{range $.results}} - - - - - {{end}} - -
{{$.locale.Tr "org.times.member"}}{{$.locale.Tr "org.times.time"}}
- {{svg "octicon-person" 16 "gt-mr-2"}} - {{.Name}} - - {{svg "octicon-clock" 16 "gt-mr-2"}} - {{.SumTime | Sec2Hour}} -
-
-
-
-
-
-{{template "base/footer" .}} diff --git a/templates/org/times/times_by_milestones.tmpl b/templates/org/times/times_by_milestones.tmpl deleted file mode 100644 index c0b52b3dce2a6..0000000000000 --- a/templates/org/times/times_by_milestones.tmpl +++ /dev/null @@ -1,48 +0,0 @@ -{{template "base/head" .}} -
- {{template "org/header" .}} -
- {{template "base/alert" .}} -
-
-
- {{template "org/times/daterange" .}} -
-
- {{template "org/times/submenu" .}} - - - - - - - - - - {{range $.results}} - - - - {{else}} - — - {{end}} - - - {{end}} - -
{{$.locale.Tr "repository"}}{{$.locale.Tr "org.times.milestone"}}{{$.locale.Tr "org.times.time"}}
- {{if not .HideRepoName}} - {{svg "octicon-repo" 16 "gt-mr-2"}}{{.RepoName}} - {{end}} - - {{if .Name}} - {{svg "octicon-milestone" 16 "gt-mr-2"}}{{.Name}} - {{svg "octicon-clock" 16 "gt-mr-2"}} - {{.SumTime | Sec2Hour}} -
-
-
-
-
-
-{{template "base/footer" .}} diff --git a/templates/org/times/times_by_repos.tmpl b/templates/org/times/times_by_repos.tmpl deleted file mode 100644 index 6dea968d8f3fe..0000000000000 --- a/templates/org/times/times_by_repos.tmpl +++ /dev/null @@ -1,37 +0,0 @@ -{{template "base/head" .}} -
- {{template "org/header" .}} -
- {{template "base/alert" .}} -
-
-
- {{template "org/times/daterange" .}} -
-
- {{template "org/times/submenu" .}} - - - - - - - - - {{range $.results}} - - - - - {{end}} - -
{{$.locale.Tr "repository"}}{{$.locale.Tr "org.times.time"}}
{{svg "octicon-repo" 16 "gt-mr-2"}}{{.Name}} - {{svg "octicon-clock" 16 "gt-mr-2"}} - {{.SumTime | Sec2Hour}} -
-
-
-
-
-
-{{template "base/footer" .}} diff --git a/templates/org/worktime.tmpl b/templates/org/worktime.tmpl new file mode 100644 index 0000000000000..b9e3783884cad --- /dev/null +++ b/templates/org/worktime.tmpl @@ -0,0 +1,43 @@ +{{template "base/head" .}} +
+ {{template "org/header" .}} +
+
+
+
+
+ +
+ +
+
+
+ +
+ +
+
+ +
+
+
+ + {{if .WorktimeByRepos}} + {{template "org/worktime/table_repos" dict "Org" .Org "WorktimeSumResult" .WorktimeSumResult}} + {{else if .WorktimeByMilestones}} + {{template "org/worktime/table_milestones" dict "Org" .Org "WorktimeSumResult" .WorktimeSumResult}} + {{else if .WorktimeByMembers}} + {{template "org/worktime/table_members" dict "Org" .Org "WorktimeSumResult" .WorktimeSumResult}} + {{end}} +
+
+
+
+{{template "base/footer" .}} diff --git a/templates/org/worktime/table_members.tmpl b/templates/org/worktime/table_members.tmpl new file mode 100644 index 0000000000000..a59d1941d8490 --- /dev/null +++ b/templates/org/worktime/table_members.tmpl @@ -0,0 +1,16 @@ + + + + + + + + + {{range $.WorktimeSumResult}} + + + + + {{end}} + +
{{ctx.Locale.Tr "org.members.member"}}{{ctx.Locale.Tr "org.worktime.time"}}
{{svg "octicon-person"}} {{.UserName}}{{svg "octicon-clock"}} {{.SumTime | Sec2Hour}}
diff --git a/templates/org/worktime/table_milestones.tmpl b/templates/org/worktime/table_milestones.tmpl new file mode 100644 index 0000000000000..6ef9289e5601e --- /dev/null +++ b/templates/org/worktime/table_milestones.tmpl @@ -0,0 +1,28 @@ + + + + + + + + + + {{range $.WorktimeSumResult}} + + + + + + {{end}} + +
{{ctx.Locale.Tr "repository"}}{{ctx.Locale.Tr "repo.milestone"}}{{ctx.Locale.Tr "org.worktime.time"}}
+ {{if not .HideRepoName}} + {{svg "octicon-repo"}} {{.RepoName}} + {{end}} + + {{if .MilestoneName}} + {{svg "octicon-milestone"}} {{.MilestoneName}} + {{else}} + - + {{end}} + {{svg "octicon-clock"}} {{.SumTime | Sec2Hour}}
diff --git a/templates/org/worktime/table_repos.tmpl b/templates/org/worktime/table_repos.tmpl new file mode 100644 index 0000000000000..eaa085df0c7e6 --- /dev/null +++ b/templates/org/worktime/table_repos.tmpl @@ -0,0 +1,16 @@ + + + + + + + + + {{range $.WorktimeSumResult}} + + + + + {{end}} + +
{{ctx.Locale.Tr "repository"}}{{ctx.Locale.Tr "org.worktime.time"}}
{{svg "octicon-repo"}} {{.RepoName}}{{svg "octicon-clock"}} {{.SumTime | Sec2Hour}}
diff --git a/templates/repo/issue/filters.tmpl b/templates/repo/issue/filters.tmpl index 06e7c1aa6c101..409ec876e6c2c 100644 --- a/templates/repo/issue/filters.tmpl +++ b/templates/repo/issue/filters.tmpl @@ -9,7 +9,7 @@ {{end}} diff --git a/templates/repo/issue/list.tmpl b/templates/repo/issue/list.tmpl index 01b610b39db0b..53d0eca171fee 100644 --- a/templates/repo/issue/list.tmpl +++ b/templates/repo/issue/list.tmpl @@ -40,7 +40,7 @@ {{end}} diff --git a/templates/repo/issue/milestone_issues.tmpl b/templates/repo/issue/milestone_issues.tmpl index 4fc60571173d8..abb4e3290da24 100644 --- a/templates/repo/issue/milestone_issues.tmpl +++ b/templates/repo/issue/milestone_issues.tmpl @@ -50,7 +50,7 @@ {{if .TotalTrackedTime}}
{{svg "octicon-clock"}} - {{.TotalTrackedTime | Sec2Time}} + {{.TotalTrackedTime | Sec2Hour}}
{{end}} diff --git a/templates/repo/issue/milestones.tmpl b/templates/repo/issue/milestones.tmpl index 9515acfb8e26f..e7dfe08ee0592 100644 --- a/templates/repo/issue/milestones.tmpl +++ b/templates/repo/issue/milestones.tmpl @@ -41,7 +41,7 @@ {{if .TotalTrackedTime}}
{{svg "octicon-clock"}} - {{.TotalTrackedTime|Sec2Time}} + {{.TotalTrackedTime|Sec2Hour}}
{{end}} {{if .UpdatedUnix}} diff --git a/templates/repo/issue/sidebar/stopwatch_timetracker.tmpl b/templates/repo/issue/sidebar/stopwatch_timetracker.tmpl index f107dc5ef5a7a..d5ac6827ba09b 100644 --- a/templates/repo/issue/sidebar/stopwatch_timetracker.tmpl +++ b/templates/repo/issue/sidebar/stopwatch_timetracker.tmpl @@ -72,7 +72,7 @@ {{end}} {{if .WorkingUsers}}
- {{ctx.Locale.Tr "repo.issues.time_spent_from_all_authors" ($.Issue.TotalTrackedTime | Sec2Time)}} + {{ctx.Locale.Tr "repo.issues.time_spent_from_all_authors" ($.Issue.TotalTrackedTime | Sec2Hour)}}
{{range $user, $trackedtime := .WorkingUsers}}
@@ -82,7 +82,7 @@
{{template "shared/user/authorlink" $user}}
- {{$trackedtime|Sec2Time}} + {{$trackedtime|Sec2Hour}}
diff --git a/templates/repo/issue/view_content/comments.tmpl b/templates/repo/issue/view_content/comments.tmpl index c1ad64a118751..f2f3d1c9ccbe1 100644 --- a/templates/repo/issue/view_content/comments.tmpl +++ b/templates/repo/issue/view_content/comments.tmpl @@ -252,7 +252,7 @@ {{template "shared/user/authorlink" .Poster}} {{$timeStr := .RenderedContent}} {{/* compatibility with time comments made before v1.21 */}} - {{if not $timeStr}}{{$timeStr = .Content|Sec2Time}}{{end}} + {{if not $timeStr}}{{$timeStr = .Content|Sec2Hour}}{{end}} {{ctx.Locale.Tr "repo.issues.stop_tracking_history" $timeStr $createdStr}} {{template "repo/issue/view_content/comments_delete_time" dict "ctxData" $ "comment" .}} @@ -264,7 +264,7 @@ {{template "shared/user/authorlink" .Poster}} {{$timeStr := .RenderedContent}} {{/* compatibility with time comments made before v1.21 */}} - {{if not $timeStr}}{{$timeStr = .Content|Sec2Time}}{{end}} + {{if not $timeStr}}{{$timeStr = .Content|Sec2Hour}}{{end}} {{ctx.Locale.Tr "repo.issues.add_time_history" $timeStr $createdStr}} {{template "repo/issue/view_content/comments_delete_time" dict "ctxData" $ "comment" .}} @@ -506,7 +506,7 @@ {{/* compatibility with time comments made before v1.21 */}} {{.RenderedContent}} {{else}} - - {{.Content|Sec2Time}} + - {{.Content|Sec2Hour}} {{end}}
diff --git a/templates/shared/issuelist.tmpl b/templates/shared/issuelist.tmpl index e8015b40eacb5..fe7f2fd8bfd61 100644 --- a/templates/shared/issuelist.tmpl +++ b/templates/shared/issuelist.tmpl @@ -28,7 +28,7 @@ {{if .TotalTrackedTime}}
{{svg "octicon-clock" 16}} - {{.TotalTrackedTime | Sec2Time}} + {{.TotalTrackedTime | Sec2Hour}}
{{end}} diff --git a/templates/user/dashboard/milestones.tmpl b/templates/user/dashboard/milestones.tmpl index c0059d3cd4cae..7c1a69a6f592a 100644 --- a/templates/user/dashboard/milestones.tmpl +++ b/templates/user/dashboard/milestones.tmpl @@ -100,7 +100,7 @@ {{if .TotalTrackedTime}}
{{svg "octicon-clock"}} - {{.TotalTrackedTime|Sec2Time}} + {{.TotalTrackedTime|Sec2Hour}}
{{end}} {{if .UpdatedUnix}} diff --git a/models/organization/org_times_test.go b/tests/integration/org_worktime_test.go similarity index 56% rename from models/organization/org_times_test.go rename to tests/integration/org_worktime_test.go index f624bfb927c2e..b828d3f07a26a 100644 --- a/models/organization/org_times_test.go +++ b/tests/integration/org_worktime_test.go @@ -1,56 +1,48 @@ -// Copyright 2022 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. +// Copyright 2025 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT -package organization_test +package integration_test import ( "testing" "code.gitea.io/gitea/models/db" + _ "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/unittest" - _ "code.gitea.io/gitea/models/issues" - "github.com/stretchr/testify/assert" ) -// TestTimesPrepareDB prepares the database for the following tests. -func TestTimesPrepareDB(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) -} - // TestTimesByRepos tests TimesByRepos functionality -func TestTimesByRepos(t *testing.T) { +func testTimesByRepos(t *testing.T) { kases := []struct { name string unixfrom int64 unixto int64 orgname int64 - expected []organization.ResultTimesByRepos + expected []organization.WorktimeSumByRepos }{ { name: "Full sum for org 1", unixfrom: 0, unixto: 9223372036854775807, orgname: 1, - expected: []organization.ResultTimesByRepos(nil), + expected: []organization.WorktimeSumByRepos(nil), }, { name: "Full sum for org 2", unixfrom: 0, unixto: 9223372036854775807, orgname: 2, - expected: []organization.ResultTimesByRepos{ + expected: []organization.WorktimeSumByRepos{ { - Name: "repo1", - SumTime: 4083, + RepoName: "repo1", + SumTime: 4083, }, { - Name: "repo2", - SumTime: 75, + RepoName: "repo2", + SumTime: 75, }, }, }, @@ -59,10 +51,10 @@ func TestTimesByRepos(t *testing.T) { unixfrom: 946684801, unixto: 946684802, orgname: 2, - expected: []organization.ResultTimesByRepos{ + expected: []organization.WorktimeSumByRepos{ { - Name: "repo1", - SumTime: 3662, + RepoName: "repo1", + SumTime: 3662, }, }, }, @@ -71,10 +63,10 @@ func TestTimesByRepos(t *testing.T) { unixfrom: 946684801, unixto: 946684801, orgname: 2, - expected: []organization.ResultTimesByRepos{ + expected: []organization.WorktimeSumByRepos{ { - Name: "repo1", - SumTime: 3661, + RepoName: "repo1", + SumTime: 3661, }, }, }, @@ -83,10 +75,10 @@ func TestTimesByRepos(t *testing.T) { unixfrom: 947688814, unixto: 947688815, orgname: 2, - expected: []organization.ResultTimesByRepos{ + expected: []organization.WorktimeSumByRepos{ { - Name: "repo2", - SumTime: 71, + RepoName: "repo2", + SumTime: 71, }, }, }, @@ -97,7 +89,7 @@ func TestTimesByRepos(t *testing.T) { t.Run(kase.name, func(t *testing.T) { org, err := organization.GetOrgByID(db.DefaultContext, kase.orgname) assert.NoError(t, err) - results, err := organization.GetTimesByRepos(org, kase.unixfrom, kase.unixto) + results, err := organization.GetWorktimeByRepos(org, kase.unixfrom, kase.unixto) assert.NoError(t, err) assert.Equal(t, kase.expected, results) }) @@ -105,47 +97,47 @@ func TestTimesByRepos(t *testing.T) { } // TestTimesByMilestones tests TimesByMilestones functionality -func TestTimesByMilestones(t *testing.T) { +func testTimesByMilestones(t *testing.T) { kases := []struct { name string unixfrom int64 unixto int64 orgname int64 - expected []organization.ResultTimesByMilestones + expected []organization.WorktimeSumByMilestones }{ { name: "Full sum for org 1", unixfrom: 0, unixto: 9223372036854775807, orgname: 1, - expected: []organization.ResultTimesByMilestones(nil), + expected: []organization.WorktimeSumByMilestones(nil), }, { name: "Full sum for org 2", unixfrom: 0, unixto: 9223372036854775807, orgname: 2, - expected: []organization.ResultTimesByMilestones{ + expected: []organization.WorktimeSumByMilestones{ { - RepoName: "repo1", - Name: "", - ID: "", - SumTime: 401, - HideRepoName: false, + RepoName: "repo1", + MilestoneName: "", + MilestoneID: 0, + SumTime: 401, + HideRepoName: false, }, { - RepoName: "repo1", - Name: "milestone1", - ID: "1", - SumTime: 3682, - HideRepoName: false, + RepoName: "repo1", + MilestoneName: "milestone1", + MilestoneID: 1, + SumTime: 3682, + HideRepoName: true, }, { - RepoName: "repo2", - Name: "", - ID: "", - SumTime: 75, - HideRepoName: false, + RepoName: "repo2", + MilestoneName: "", + MilestoneID: 0, + SumTime: 75, + HideRepoName: false, }, }, }, @@ -154,13 +146,13 @@ func TestTimesByMilestones(t *testing.T) { unixfrom: 946684801, unixto: 946684802, orgname: 2, - expected: []organization.ResultTimesByMilestones{ + expected: []organization.WorktimeSumByMilestones{ { - RepoName: "repo1", - Name: "milestone1", - ID: "1", - SumTime: 3662, - HideRepoName: false, + RepoName: "repo1", + MilestoneName: "milestone1", + MilestoneID: 1, + SumTime: 3662, + HideRepoName: false, }, }, }, @@ -169,13 +161,13 @@ func TestTimesByMilestones(t *testing.T) { unixfrom: 946684801, unixto: 946684801, orgname: 2, - expected: []organization.ResultTimesByMilestones{ + expected: []organization.WorktimeSumByMilestones{ { - RepoName: "repo1", - Name: "milestone1", - ID: "1", - SumTime: 3661, - HideRepoName: false, + RepoName: "repo1", + MilestoneName: "milestone1", + MilestoneID: 1, + SumTime: 3661, + HideRepoName: false, }, }, }, @@ -184,13 +176,13 @@ func TestTimesByMilestones(t *testing.T) { unixfrom: 947688814, unixto: 947688815, orgname: 2, - expected: []organization.ResultTimesByMilestones{ + expected: []organization.WorktimeSumByMilestones{ { - RepoName: "repo2", - Name: "", - ID: "", - SumTime: 71, - HideRepoName: false, + RepoName: "repo2", + MilestoneName: "", + MilestoneID: 0, + SumTime: 71, + HideRepoName: false, }, }, }, @@ -201,7 +193,7 @@ func TestTimesByMilestones(t *testing.T) { t.Run(kase.name, func(t *testing.T) { org, err := organization.GetOrgByID(db.DefaultContext, kase.orgname) assert.NoError(t, err) - results, err := organization.GetTimesByMilestones(org, kase.unixfrom, kase.unixto) + results, err := organization.GetWorktimeByMilestones(org, kase.unixfrom, kase.unixto) assert.NoError(t, err) assert.Equal(t, kase.expected, results) }) @@ -209,20 +201,20 @@ func TestTimesByMilestones(t *testing.T) { } // TestTimesByMembers tests TimesByMembers functionality -func TestTimesByMembers(t *testing.T) { +func testTimesByMembers(t *testing.T) { kases := []struct { name string unixfrom int64 unixto int64 orgname int64 - expected []organization.ResultTimesByMembers + expected []organization.WorktimeSumByMembers }{ { name: "Full sum for org 1", unixfrom: 0, unixto: 9223372036854775807, orgname: 1, - expected: []organization.ResultTimesByMembers(nil), + expected: []organization.WorktimeSumByMembers(nil), }, { // Test case: Sum of times forever in org no. 2 @@ -230,14 +222,14 @@ func TestTimesByMembers(t *testing.T) { unixfrom: 0, unixto: 9223372036854775807, orgname: 2, - expected: []organization.ResultTimesByMembers{ + expected: []organization.WorktimeSumByMembers{ { - Name: "user2", - SumTime: 3666, + UserName: "user2", + SumTime: 3666, }, { - Name: "user1", - SumTime: 491, + UserName: "user1", + SumTime: 491, }, }, }, @@ -246,10 +238,10 @@ func TestTimesByMembers(t *testing.T) { unixfrom: 946684801, unixto: 946684802, orgname: 2, - expected: []organization.ResultTimesByMembers{ + expected: []organization.WorktimeSumByMembers{ { - Name: "user2", - SumTime: 3662, + UserName: "user2", + SumTime: 3662, }, }, }, @@ -258,10 +250,10 @@ func TestTimesByMembers(t *testing.T) { unixfrom: 946684801, unixto: 946684801, orgname: 2, - expected: []organization.ResultTimesByMembers{ + expected: []organization.WorktimeSumByMembers{ { - Name: "user2", - SumTime: 3661, + UserName: "user2", + SumTime: 3661, }, }, }, @@ -270,10 +262,10 @@ func TestTimesByMembers(t *testing.T) { unixfrom: 947688814, unixto: 947688815, orgname: 2, - expected: []organization.ResultTimesByMembers{ + expected: []organization.WorktimeSumByMembers{ { - Name: "user1", - SumTime: 71, + UserName: "user1", + SumTime: 71, }, }, }, @@ -284,9 +276,17 @@ func TestTimesByMembers(t *testing.T) { t.Run(kase.name, func(t *testing.T) { org, err := organization.GetOrgByID(db.DefaultContext, kase.orgname) assert.NoError(t, err) - results, err := organization.GetTimesByMembers(org, kase.unixfrom, kase.unixto) + results, err := organization.GetWorktimeByMembers(org, kase.unixfrom, kase.unixto) assert.NoError(t, err) assert.Equal(t, kase.expected, results) }) } } + +func TestOrgWorktime(t *testing.T) { + // we need to run these tests in integration test because there are complex SQL queries + assert.NoError(t, unittest.PrepareTestDatabase()) + testTimesByRepos(t) + testTimesByMilestones(t) + testTimesByMembers(t) +} From 0e1b9a3f6e26b7c7e4b29311faa1e1e0a3d3f0b3 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sun, 2 Feb 2025 20:59:17 +0800 Subject: [PATCH 5/9] fix lint --- tests/integration/org_worktime_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/integration/org_worktime_test.go b/tests/integration/org_worktime_test.go index b828d3f07a26a..90306d8b947fa 100644 --- a/tests/integration/org_worktime_test.go +++ b/tests/integration/org_worktime_test.go @@ -7,7 +7,6 @@ import ( "testing" "code.gitea.io/gitea/models/db" - _ "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/unittest" From d924de18f2a951fd7860da84ce187fe1ed6103f1 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sun, 2 Feb 2025 21:07:45 +0800 Subject: [PATCH 6/9] fix test --- modules/util/sec_to_time.go | 2 +- modules/util/sec_to_time_test.go | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/util/sec_to_time.go b/modules/util/sec_to_time.go index 51c51fe6d8802..646f33c82a520 100644 --- a/modules/util/sec_to_time.go +++ b/modules/util/sec_to_time.go @@ -22,7 +22,7 @@ func SecToHours(durationVal any) string { formattedTime = formatTime(minutes, "minute", formattedTime) // The formatTime() function always appends a space at the end. This will be trimmed - if formattedTime == "" { + if formattedTime == "" && seconds > 0 { formattedTime = formatTime(seconds, "second", "") } return strings.TrimRight(formattedTime, " ") diff --git a/modules/util/sec_to_time_test.go b/modules/util/sec_to_time_test.go index d623f9cbf83e4..b67926bbcff9d 100644 --- a/modules/util/sec_to_time_test.go +++ b/modules/util/sec_to_time_test.go @@ -24,4 +24,5 @@ func TestSecToHours(t *testing.T) { assert.Equal(t, "672 hours", SecToHours(4*7*day)) assert.Equal(t, "1 second", SecToHours(1)) assert.Equal(t, "2 seconds", SecToHours(2)) + assert.Equal(t, "", SecToHours(nil)) // old behavior, empty means no output } From 7843cbb9133101476cd6a032c675c9cbe5e2c570 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sun, 2 Feb 2025 21:36:38 +0800 Subject: [PATCH 7/9] fix sql --- models/organization/org_worktime.go | 10 +++++----- tests/integration/org_worktime_test.go | 14 ++++++++------ 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/models/organization/org_worktime.go b/models/organization/org_worktime.go index 0201a50188b5e..9fecf8d8259e4 100644 --- a/models/organization/org_worktime.go +++ b/models/organization/org_worktime.go @@ -24,7 +24,7 @@ func GetWorktimeByRepos(org *Organization, unitFrom, unixTo int64) (results []Wo And(builder.Eq{"tracked_time.deleted": false}). And(builder.Gte{"tracked_time.created_unix": unitFrom}). And(builder.Lte{"tracked_time.created_unix": unixTo}). - GroupBy("repository.id"). + GroupBy("repository.name"). OrderBy("repository.name"). Find(&results) return results, err @@ -49,7 +49,7 @@ func GetWorktimeByMilestones(org *Organization, unitFrom, unixTo int64) (results And(builder.Eq{"tracked_time.deleted": false}). And(builder.Gte{"tracked_time.created_unix": unitFrom}). And(builder.Lte{"tracked_time.created_unix": unixTo}). - GroupBy("repository.id, milestone.id"). + GroupBy("repository.name, milestone.name, milestone.deadline_unix, milestone.id"). OrderBy("repository.name, milestone.deadline_unix, milestone.id"). Find(&results) // Show only the first RepoName, for nicer output. @@ -71,16 +71,16 @@ type WorktimeSumByMembers struct { func GetWorktimeByMembers(org *Organization, unitFrom, unixTo int64) (results []WorktimeSumByMembers, err error) { err = db.GetEngine(db.DefaultContext). - Select("user.name AS user_name, SUM(tracked_time.time) AS sum_time"). + Select("`user`.name AS user_name, SUM(tracked_time.time) AS sum_time"). Table("tracked_time"). Join("INNER", "issue", "tracked_time.issue_id = issue.id"). Join("INNER", "repository", "issue.repo_id = repository.id"). - Join("INNER", "user", "tracked_time.user_id = user.id"). + Join("INNER", "`user`", "tracked_time.user_id = `user`.id"). Where(builder.Eq{"repository.owner_id": org.ID}). And(builder.Eq{"tracked_time.deleted": false}). And(builder.Gte{"tracked_time.created_unix": unitFrom}). And(builder.Lte{"tracked_time.created_unix": unixTo}). - GroupBy("user.id"). + GroupBy("`user`.name"). OrderBy("sum_time DESC"). Find(&results) return results, err diff --git a/tests/integration/org_worktime_test.go b/tests/integration/org_worktime_test.go index 90306d8b947fa..fb5216be8d318 100644 --- a/tests/integration/org_worktime_test.go +++ b/tests/integration/org_worktime_test.go @@ -11,6 +11,7 @@ import ( "code.gitea.io/gitea/models/unittest" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // TestTimesByRepos tests TimesByRepos functionality @@ -191,10 +192,11 @@ func testTimesByMilestones(t *testing.T) { for _, kase := range kases { t.Run(kase.name, func(t *testing.T) { org, err := organization.GetOrgByID(db.DefaultContext, kase.orgname) - assert.NoError(t, err) + require.NoError(t, err) results, err := organization.GetWorktimeByMilestones(org, kase.unixfrom, kase.unixto) - assert.NoError(t, err) - assert.Equal(t, kase.expected, results) + if assert.NoError(t, err) { + assert.Equal(t, kase.expected, results) + } }) } } @@ -285,7 +287,7 @@ func testTimesByMembers(t *testing.T) { func TestOrgWorktime(t *testing.T) { // we need to run these tests in integration test because there are complex SQL queries assert.NoError(t, unittest.PrepareTestDatabase()) - testTimesByRepos(t) - testTimesByMilestones(t) - testTimesByMembers(t) + t.Run("ByRepos", testTimesByRepos) + t.Run("ByMilestones", testTimesByMilestones) + t.Run("ByMembers", testTimesByMembers) } From 92b7102880dbb9271cd776982bcb09d837153a14 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sun, 2 Feb 2025 22:37:32 +0800 Subject: [PATCH 8/9] fix form --- routers/web/org/worktime.go | 6 ++++-- templates/org/worktime.tmpl | 11 ++++------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/routers/web/org/worktime.go b/routers/web/org/worktime.go index 41cc8a94fba35..23369848254ba 100644 --- a/routers/web/org/worktime.go +++ b/routers/web/org/worktime.go @@ -17,8 +17,8 @@ const tplByRepos templates.TplName = "org/worktime" // parseOrgTimes contains functionality that is required in all these functions, // like parsing the date from the request, setting default dates, etc. func parseOrgTimes(ctx *context.Context) (unixFrom, unixTo int64) { - rangeFrom := ctx.FormString("range_from") - rangeTo := ctx.FormString("range_to") + rangeFrom := ctx.FormString("from") + rangeTo := ctx.FormString("to") if rangeFrom == "" { rangeFrom = time.Now().Format("2006-01") + "-01" // defaults to start of current month } @@ -51,6 +51,8 @@ func Worktime(ctx *context.Context) { } worktimeBy := ctx.FormString("by") + ctx.Data["WorktimeBy"] = worktimeBy + var worktimeSumResult any var err error if worktimeBy == "milestones" { diff --git a/templates/org/worktime.tmpl b/templates/org/worktime.tmpl index b9e3783884cad..5d99998129271 100644 --- a/templates/org/worktime.tmpl +++ b/templates/org/worktime.tmpl @@ -4,18 +4,15 @@
-
+ +
-
- -
+
-
- -
+
From 28aacc29d1166485564f6d8cb34d9fc0e466da04 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sun, 2 Feb 2025 23:24:25 +0800 Subject: [PATCH 9/9] fix sql --- models/organization/org_worktime.go | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/models/organization/org_worktime.go b/models/organization/org_worktime.go index 9fecf8d8259e4..7b57182a8a748 100644 --- a/models/organization/org_worktime.go +++ b/models/organization/org_worktime.go @@ -4,6 +4,8 @@ package organization import ( + "sort" + "code.gitea.io/gitea/models/db" "xorm.io/builder" @@ -31,16 +33,17 @@ func GetWorktimeByRepos(org *Organization, unitFrom, unixTo int64) (results []Wo } type WorktimeSumByMilestones struct { - RepoName string - MilestoneName string - MilestoneID int64 - SumTime int64 - HideRepoName bool + RepoName string + MilestoneName string + MilestoneID int64 + MilestoneDeadline int64 + SumTime int64 + HideRepoName bool } func GetWorktimeByMilestones(org *Organization, unitFrom, unixTo int64) (results []WorktimeSumByMilestones, err error) { err = db.GetEngine(db.DefaultContext). - Select("repository.name AS repo_name, milestone.name AS milestone_name, milestone.id AS milestone_id, SUM(tracked_time.time) AS sum_time"). + Select("repository.name AS repo_name, milestone.name AS milestone_name, milestone.id AS milestone_id, milestone.deadline_unix as milestone_deadline, SUM(tracked_time.time) AS sum_time"). Table("tracked_time"). Join("INNER", "issue", "tracked_time.issue_id = issue.id"). Join("INNER", "repository", "issue.repo_id = repository.id"). @@ -52,10 +55,23 @@ func GetWorktimeByMilestones(org *Organization, unitFrom, unixTo int64) (results GroupBy("repository.name, milestone.name, milestone.deadline_unix, milestone.id"). OrderBy("repository.name, milestone.deadline_unix, milestone.id"). Find(&results) + + // TODO: pgsql: NULL values are sorted last in default ascending order, so we need to sort them manually again. + sort.Slice(results, func(i, j int) bool { + if results[i].RepoName != results[j].RepoName { + return results[i].RepoName < results[j].RepoName + } + if results[i].MilestoneDeadline != results[j].MilestoneDeadline { + return results[i].MilestoneDeadline < results[j].MilestoneDeadline + } + return results[i].MilestoneID < results[j].MilestoneID + }) + // Show only the first RepoName, for nicer output. prevRepoName := "" for i := 0; i < len(results); i++ { res := &results[i] + res.MilestoneDeadline = 0 // clear the deadline because we do not really need it if prevRepoName == res.RepoName { res.HideRepoName = true }