mirror of
https://github.com/go-gitea/gitea.git
synced 2025-02-20 11:43:57 +08:00
Worktime tracking for the organization level (#19808)
Dear Gitea team, first of all, thanks for the great work you're doing with this project. I'm planning to introduce Gitea at a client site, and noticed that while there is time recording, there are no project-manager-friendly reports to actually make use of that data, as were also mentioned by others in #4870 #8684 and #13531. Since I had a little time last weekend, I had put together something that I hope to be a useful contribution to this great project (while of course useful for me too). This PR adds a new "Worktime" tab to the Organisation level. There is a date range selector (by default set to the current month), and there are three possible views: - by repository, - by milestone, and - by team member. Happy to receive any feedback! There are several possible future improvements of course (predefined date ranges, charts, a member time sheet, matrix of repos/members, etc) but I hope that even in this relatively simple state this would be useful to lots of people. <img width="1161" alt="Screen Shot 2022-05-25 at 22 12 58" src="https://user-images.githubusercontent.com/118010/170366976-af00c7af-c4f3-4117-86d7-00356d6797a5.png"> Keep up the good work! Kristof --------- Co-authored-by: user <user@kk-git1> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
parent
869f8fdbe4
commit
34692a20b1
103
models/organization/org_worktime.go
Normal file
103
models/organization/org_worktime.go
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package organization
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"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.name").
|
||||||
|
OrderBy("repository.name").
|
||||||
|
Find(&results)
|
||||||
|
return results, err
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorktimeSumByMilestones struct {
|
||||||
|
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, 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").
|
||||||
|
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.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
|
||||||
|
}
|
||||||
|
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`.name").
|
||||||
|
OrderBy("sum_time DESC").
|
||||||
|
Find(&results)
|
||||||
|
return results, err
|
||||||
|
}
|
@ -69,7 +69,7 @@ func NewFuncMap() template.FuncMap {
|
|||||||
// time / number / format
|
// time / number / format
|
||||||
"FileSize": base.FileSize,
|
"FileSize": base.FileSize,
|
||||||
"CountFmt": countFmt,
|
"CountFmt": countFmt,
|
||||||
"Sec2Time": util.SecToHours,
|
"Sec2Hour": util.SecToHours,
|
||||||
|
|
||||||
"TimeEstimateString": timeEstimateString,
|
"TimeEstimateString": timeEstimateString,
|
||||||
|
|
||||||
|
@ -11,16 +11,20 @@ import (
|
|||||||
// SecToHours converts an amount of seconds to a human-readable hours string.
|
// SecToHours converts an amount of seconds to a human-readable hours string.
|
||||||
// This is stable for planning and managing timesheets.
|
// 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.
|
// 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 {
|
func SecToHours(durationVal any) string {
|
||||||
duration, _ := ToInt64(durationVal)
|
seconds, _ := ToInt64(durationVal)
|
||||||
hours := duration / 3600
|
hours := seconds / 3600
|
||||||
minutes := (duration / 60) % 60
|
minutes := (seconds / 60) % 60
|
||||||
|
|
||||||
formattedTime := ""
|
formattedTime := ""
|
||||||
formattedTime = formatTime(hours, "hour", formattedTime)
|
formattedTime = formatTime(hours, "hour", formattedTime)
|
||||||
formattedTime = formatTime(minutes, "minute", formattedTime)
|
formattedTime = formatTime(minutes, "minute", formattedTime)
|
||||||
|
|
||||||
// The formatTime() function always appends a space at the end. This will be trimmed
|
// The formatTime() function always appends a space at the end. This will be trimmed
|
||||||
|
if formattedTime == "" && seconds > 0 {
|
||||||
|
formattedTime = formatTime(seconds, "second", "")
|
||||||
|
}
|
||||||
return strings.TrimRight(formattedTime, " ")
|
return strings.TrimRight(formattedTime, " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -22,4 +22,7 @@ func TestSecToHours(t *testing.T) {
|
|||||||
assert.Equal(t, "156 hours 30 minutes", SecToHours(6*day+12*hour+30*minute+18*second))
|
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, "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, "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
|
||||||
}
|
}
|
||||||
|
@ -54,6 +54,7 @@ webauthn_reload = Reload
|
|||||||
repository = Repository
|
repository = Repository
|
||||||
organization = Organization
|
organization = Organization
|
||||||
mirror = Mirror
|
mirror = Mirror
|
||||||
|
issue_milestone = Milestone
|
||||||
new_repo = New Repository
|
new_repo = New Repository
|
||||||
new_migrate = New Migration
|
new_migrate = New Migration
|
||||||
new_mirror = New Mirror
|
new_mirror = New Mirror
|
||||||
@ -1253,6 +1254,7 @@ labels = Labels
|
|||||||
org_labels_desc = Organization level labels that can be used with <strong>all repositories</strong> under this organization
|
org_labels_desc = Organization level labels that can be used with <strong>all repositories</strong> under this organization
|
||||||
org_labels_desc_manage = manage
|
org_labels_desc_manage = manage
|
||||||
|
|
||||||
|
milestone = Milestone
|
||||||
milestones = Milestones
|
milestones = Milestones
|
||||||
commits = Commits
|
commits = Commits
|
||||||
commit = Commit
|
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_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.
|
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]
|
[admin]
|
||||||
maintenance = Maintenance
|
maintenance = Maintenance
|
||||||
dashboard = Dashboard
|
dashboard = Dashboard
|
||||||
|
74
routers/web/org/worktime.go
Normal file
74
routers/web/org/worktime.go
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
// 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("from")
|
||||||
|
rangeTo := ctx.FormString("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")
|
||||||
|
ctx.Data["WorktimeBy"] = worktimeBy
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
@ -913,6 +913,8 @@ func registerRoutes(m *web.Router) {
|
|||||||
m.Post("/teams/{team}/edit", web.Bind(forms.CreateTeamForm{}), org.EditTeamPost)
|
m.Post("/teams/{team}/edit", web.Bind(forms.CreateTeamForm{}), org.EditTeamPost)
|
||||||
m.Post("/teams/{team}/delete", org.DeleteTeam)
|
m.Post("/teams/{team}/delete", org.DeleteTeam)
|
||||||
|
|
||||||
|
m.Get("/worktime", context.OrgAssignment(false, true), org.Worktime)
|
||||||
|
|
||||||
m.Group("/settings", func() {
|
m.Group("/settings", func() {
|
||||||
m.Combo("").Get(org.Settings).
|
m.Combo("").Get(org.Settings).
|
||||||
Post(web.Bind(forms.UpdateOrgSettingForm{}), org.SettingsPost)
|
Post(web.Bind(forms.UpdateOrgSettingForm{}), org.SettingsPost)
|
||||||
|
@ -63,6 +63,7 @@ func GetOrganizationByParams(ctx *Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// HandleOrgAssignment handles organization assignment
|
// HandleOrgAssignment handles organization assignment
|
||||||
|
// args: requireMember, requireOwner, requireTeamMember, requireTeamAdmin
|
||||||
func HandleOrgAssignment(ctx *Context, args ...bool) {
|
func HandleOrgAssignment(ctx *Context, args ...bool) {
|
||||||
var (
|
var (
|
||||||
requireMember bool
|
requireMember bool
|
||||||
@ -269,6 +270,7 @@ func HandleOrgAssignment(ctx *Context, args ...bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// OrgAssignment returns a middleware to handle organization assignment
|
// OrgAssignment returns a middleware to handle organization assignment
|
||||||
|
// args: requireMember, requireOwner, requireTeamMember, requireTeamAdmin
|
||||||
func OrgAssignment(args ...bool) func(ctx *Context) {
|
func OrgAssignment(args ...bool) func(ctx *Context) {
|
||||||
return func(ctx *Context) {
|
return func(ctx *Context) {
|
||||||
HandleOrgAssignment(ctx, args...)
|
HandleOrgAssignment(ctx, args...)
|
||||||
|
@ -45,6 +45,11 @@
|
|||||||
</a>
|
</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .IsOrganizationOwner}}
|
{{if .IsOrganizationOwner}}
|
||||||
|
<a class="{{if $.PageIsOrgTimes}}active{{end}} item" href="{{$.OrgLink}}/worktime">
|
||||||
|
{{svg "octicon-clock"}} {{ctx.Locale.Tr "org.worktime"}}
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
{{if .IsOrganizationOwner}}
|
||||||
<span class="item-flex-space"></span>
|
<span class="item-flex-space"></span>
|
||||||
<a class="{{if .PageIsOrgSettings}}active {{end}}item" href="{{.OrgLink}}/settings">
|
<a class="{{if .PageIsOrgSettings}}active {{end}}item" href="{{.OrgLink}}/settings">
|
||||||
{{svg "octicon-tools"}} {{ctx.Locale.Tr "repo.settings"}}
|
{{svg "octicon-tools"}} {{ctx.Locale.Tr "repo.settings"}}
|
||||||
|
40
templates/org/worktime.tmpl
Normal file
40
templates/org/worktime.tmpl
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
{{template "base/head" .}}
|
||||||
|
<div class="page-content organization times">
|
||||||
|
{{template "org/header" .}}
|
||||||
|
<div class="ui container">
|
||||||
|
<div class="ui grid">
|
||||||
|
<div class="three wide column">
|
||||||
|
<form class="ui form" method="get">
|
||||||
|
<input type="hidden" name="by" value="{{$.WorktimeBy}}">
|
||||||
|
<div class="field">
|
||||||
|
<label>{{ctx.Locale.Tr "org.worktime.date_range_start"}}</label>
|
||||||
|
<input type="date" name="from" value="{{.RangeFrom}}">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>{{ctx.Locale.Tr "org.worktime.date_range_end"}}</label>
|
||||||
|
<input type="date" name="to" value="{{.RangeTo}}">
|
||||||
|
</div>
|
||||||
|
<button class="ui primary button">{{ctx.Locale.Tr "org.worktime.query"}}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="thirteen wide column">
|
||||||
|
<div class="ui column">
|
||||||
|
<div class="ui compact small menu">
|
||||||
|
{{$queryParams := QueryBuild "from" .RangeFrom "to" .RangeTo}}
|
||||||
|
<a class="{{Iif .WorktimeByRepos "active"}} item" href="{{$.Org.OrganisationLink}}/worktime?by=repos&{{$queryParams}}">{{svg "octicon-repo"}} {{ctx.Locale.Tr "org.worktime.by_repositories"}}</a>
|
||||||
|
<a class="{{Iif .WorktimeByMilestones "active"}} item" href="{{$.Org.OrganisationLink}}/worktime?by=milestones&{{$queryParams}}">{{svg "octicon-milestone"}} {{ctx.Locale.Tr "org.worktime.by_milestones"}}</a>
|
||||||
|
<a class="{{Iif .WorktimeByMembers "active"}} item" href="{{$.Org.OrganisationLink}}/worktime?by=members&{{$queryParams}}">{{svg "octicon-people"}} {{ctx.Locale.Tr "org.worktime.by_members"}}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{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}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{template "base/footer" .}}
|
16
templates/org/worktime/table_members.tmpl
Normal file
16
templates/org/worktime/table_members.tmpl
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<table class="ui table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ctx.Locale.Tr "org.members.member"}}</th>
|
||||||
|
<th>{{ctx.Locale.Tr "org.worktime.time"}}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range $.WorktimeSumResult}}
|
||||||
|
<tr>
|
||||||
|
<td>{{svg "octicon-person"}} <a href="{{AppSubUrl}}/{{PathEscape .UserName}}">{{.UserName}}</a></td>
|
||||||
|
<td>{{svg "octicon-clock"}} {{.SumTime | Sec2Hour}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
28
templates/org/worktime/table_milestones.tmpl
Normal file
28
templates/org/worktime/table_milestones.tmpl
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<table class="ui table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ctx.Locale.Tr "repository"}}</th>
|
||||||
|
<th>{{ctx.Locale.Tr "repo.milestone"}}</th>
|
||||||
|
<th>{{ctx.Locale.Tr "org.worktime.time"}}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range $.WorktimeSumResult}}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{{if not .HideRepoName}}
|
||||||
|
{{svg "octicon-repo"}} <a href="{{$.Org.HomeLink}}/{{PathEscape .RepoName}}/issues">{{.RepoName}}</a>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{if .MilestoneName}}
|
||||||
|
{{svg "octicon-milestone"}} <a href="{{$.Org.HomeLink}}/{{PathEscape .RepoName}}/milestone/{{.MilestoneID}}">{{.MilestoneName}}</a>
|
||||||
|
{{else}}
|
||||||
|
-
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
<td>{{svg "octicon-clock"}} {{.SumTime | Sec2Hour}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
16
templates/org/worktime/table_repos.tmpl
Normal file
16
templates/org/worktime/table_repos.tmpl
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<table class="ui table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ctx.Locale.Tr "repository"}}</th>
|
||||||
|
<th>{{ctx.Locale.Tr "org.worktime.time"}}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range $.WorktimeSumResult}}
|
||||||
|
<tr>
|
||||||
|
<td>{{svg "octicon-repo"}} <a href="{{$.Org.HomeLink}}/{{PathEscape .RepoName}}/issues">{{.RepoName}}</a></td>
|
||||||
|
<td>{{svg "octicon-clock"}} {{.SumTime | Sec2Hour}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
@ -9,7 +9,7 @@
|
|||||||
<div class="ui compact tiny secondary menu">
|
<div class="ui compact tiny secondary menu">
|
||||||
<span class="item" data-tooltip-content='{{ctx.Locale.Tr "tracked_time_summary"}}'>
|
<span class="item" data-tooltip-content='{{ctx.Locale.Tr "tracked_time_summary"}}'>
|
||||||
{{svg "octicon-clock"}}
|
{{svg "octicon-clock"}}
|
||||||
{{.TotalTrackedTime | Sec2Time}}
|
{{.TotalTrackedTime | Sec2Hour}}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
@ -40,7 +40,7 @@
|
|||||||
<div class="ui compact tiny secondary menu">
|
<div class="ui compact tiny secondary menu">
|
||||||
<span class="item" data-tooltip-content='{{ctx.Locale.Tr "tracked_time_summary"}}'>
|
<span class="item" data-tooltip-content='{{ctx.Locale.Tr "tracked_time_summary"}}'>
|
||||||
{{svg "octicon-clock"}}
|
{{svg "octicon-clock"}}
|
||||||
{{.TotalTrackedTime | Sec2Time}}
|
{{.TotalTrackedTime | Sec2Hour}}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
@ -50,7 +50,7 @@
|
|||||||
{{if .TotalTrackedTime}}
|
{{if .TotalTrackedTime}}
|
||||||
<div data-tooltip-content='{{ctx.Locale.Tr "tracked_time_summary"}}'>
|
<div data-tooltip-content='{{ctx.Locale.Tr "tracked_time_summary"}}'>
|
||||||
{{svg "octicon-clock"}}
|
{{svg "octicon-clock"}}
|
||||||
{{.TotalTrackedTime | Sec2Time}}
|
{{.TotalTrackedTime | Sec2Hour}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
@ -41,7 +41,7 @@
|
|||||||
{{if .TotalTrackedTime}}
|
{{if .TotalTrackedTime}}
|
||||||
<div class="flex-text-block">
|
<div class="flex-text-block">
|
||||||
{{svg "octicon-clock"}}
|
{{svg "octicon-clock"}}
|
||||||
{{.TotalTrackedTime|Sec2Time}}
|
{{.TotalTrackedTime|Sec2Hour}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .UpdatedUnix}}
|
{{if .UpdatedUnix}}
|
||||||
|
@ -72,7 +72,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
{{if .WorkingUsers}}
|
{{if .WorkingUsers}}
|
||||||
<div class="ui comments tw-mt-2">
|
<div class="ui comments tw-mt-2">
|
||||||
{{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)}}
|
||||||
<div>
|
<div>
|
||||||
{{range $user, $trackedtime := .WorkingUsers}}
|
{{range $user, $trackedtime := .WorkingUsers}}
|
||||||
<div class="comment tw-mt-2">
|
<div class="comment tw-mt-2">
|
||||||
@ -82,7 +82,7 @@
|
|||||||
<div class="content">
|
<div class="content">
|
||||||
{{template "shared/user/authorlink" $user}}
|
{{template "shared/user/authorlink" $user}}
|
||||||
<div class="text">
|
<div class="text">
|
||||||
{{$trackedtime|Sec2Time}}
|
{{$trackedtime|Sec2Hour}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -252,7 +252,7 @@
|
|||||||
<span class="text grey muted-links">
|
<span class="text grey muted-links">
|
||||||
{{template "shared/user/authorlink" .Poster}}
|
{{template "shared/user/authorlink" .Poster}}
|
||||||
{{$timeStr := .RenderedContent}} {{/* compatibility with time comments made before v1.21 */}}
|
{{$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}}
|
{{ctx.Locale.Tr "repo.issues.stop_tracking_history" $timeStr $createdStr}}
|
||||||
</span>
|
</span>
|
||||||
{{template "repo/issue/view_content/comments_delete_time" dict "ctxData" $ "comment" .}}
|
{{template "repo/issue/view_content/comments_delete_time" dict "ctxData" $ "comment" .}}
|
||||||
@ -264,7 +264,7 @@
|
|||||||
<span class="text grey muted-links">
|
<span class="text grey muted-links">
|
||||||
{{template "shared/user/authorlink" .Poster}}
|
{{template "shared/user/authorlink" .Poster}}
|
||||||
{{$timeStr := .RenderedContent}} {{/* compatibility with time comments made before v1.21 */}}
|
{{$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}}
|
{{ctx.Locale.Tr "repo.issues.add_time_history" $timeStr $createdStr}}
|
||||||
</span>
|
</span>
|
||||||
{{template "repo/issue/view_content/comments_delete_time" dict "ctxData" $ "comment" .}}
|
{{template "repo/issue/view_content/comments_delete_time" dict "ctxData" $ "comment" .}}
|
||||||
@ -506,7 +506,7 @@
|
|||||||
{{/* compatibility with time comments made before v1.21 */}}
|
{{/* compatibility with time comments made before v1.21 */}}
|
||||||
<span class="text grey muted-links">{{.RenderedContent}}</span>
|
<span class="text grey muted-links">{{.RenderedContent}}</span>
|
||||||
{{else}}
|
{{else}}
|
||||||
<span class="text grey muted-links">- {{.Content|Sec2Time}}</span>
|
<span class="text grey muted-links">- {{.Content|Sec2Hour}}</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -28,7 +28,7 @@
|
|||||||
{{if .TotalTrackedTime}}
|
{{if .TotalTrackedTime}}
|
||||||
<div class="text grey flex-text-block">
|
<div class="text grey flex-text-block">
|
||||||
{{svg "octicon-clock" 16}}
|
{{svg "octicon-clock" 16}}
|
||||||
{{.TotalTrackedTime | Sec2Time}}
|
{{.TotalTrackedTime | Sec2Hour}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
@ -100,7 +100,7 @@
|
|||||||
{{if .TotalTrackedTime}}
|
{{if .TotalTrackedTime}}
|
||||||
<div class="flex-text-block">
|
<div class="flex-text-block">
|
||||||
{{svg "octicon-clock"}}
|
{{svg "octicon-clock"}}
|
||||||
{{.TotalTrackedTime|Sec2Time}}
|
{{.TotalTrackedTime|Sec2Hour}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .UpdatedUnix}}
|
{{if .UpdatedUnix}}
|
||||||
|
293
tests/integration/org_worktime_test.go
Normal file
293
tests/integration/org_worktime_test.go
Normal file
@ -0,0 +1,293 @@
|
|||||||
|
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package integration_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/models/db"
|
||||||
|
"code.gitea.io/gitea/models/organization"
|
||||||
|
"code.gitea.io/gitea/models/unittest"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestTimesByRepos tests TimesByRepos functionality
|
||||||
|
func testTimesByRepos(t *testing.T) {
|
||||||
|
kases := []struct {
|
||||||
|
name string
|
||||||
|
unixfrom int64
|
||||||
|
unixto int64
|
||||||
|
orgname int64
|
||||||
|
expected []organization.WorktimeSumByRepos
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Full sum for org 1",
|
||||||
|
unixfrom: 0,
|
||||||
|
unixto: 9223372036854775807,
|
||||||
|
orgname: 1,
|
||||||
|
expected: []organization.WorktimeSumByRepos(nil),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Full sum for org 2",
|
||||||
|
unixfrom: 0,
|
||||||
|
unixto: 9223372036854775807,
|
||||||
|
orgname: 2,
|
||||||
|
expected: []organization.WorktimeSumByRepos{
|
||||||
|
{
|
||||||
|
RepoName: "repo1",
|
||||||
|
SumTime: 4083,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
RepoName: "repo2",
|
||||||
|
SumTime: 75,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Simple time bound",
|
||||||
|
unixfrom: 946684801,
|
||||||
|
unixto: 946684802,
|
||||||
|
orgname: 2,
|
||||||
|
expected: []organization.WorktimeSumByRepos{
|
||||||
|
{
|
||||||
|
RepoName: "repo1",
|
||||||
|
SumTime: 3662,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Both times inclusive",
|
||||||
|
unixfrom: 946684801,
|
||||||
|
unixto: 946684801,
|
||||||
|
orgname: 2,
|
||||||
|
expected: []organization.WorktimeSumByRepos{
|
||||||
|
{
|
||||||
|
RepoName: "repo1",
|
||||||
|
SumTime: 3661,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Should ignore deleted",
|
||||||
|
unixfrom: 947688814,
|
||||||
|
unixto: 947688815,
|
||||||
|
orgname: 2,
|
||||||
|
expected: []organization.WorktimeSumByRepos{
|
||||||
|
{
|
||||||
|
RepoName: "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 := organization.GetWorktimeByRepos(org, 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.WorktimeSumByMilestones
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Full sum for org 1",
|
||||||
|
unixfrom: 0,
|
||||||
|
unixto: 9223372036854775807,
|
||||||
|
orgname: 1,
|
||||||
|
expected: []organization.WorktimeSumByMilestones(nil),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Full sum for org 2",
|
||||||
|
unixfrom: 0,
|
||||||
|
unixto: 9223372036854775807,
|
||||||
|
orgname: 2,
|
||||||
|
expected: []organization.WorktimeSumByMilestones{
|
||||||
|
{
|
||||||
|
RepoName: "repo1",
|
||||||
|
MilestoneName: "",
|
||||||
|
MilestoneID: 0,
|
||||||
|
SumTime: 401,
|
||||||
|
HideRepoName: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
RepoName: "repo1",
|
||||||
|
MilestoneName: "milestone1",
|
||||||
|
MilestoneID: 1,
|
||||||
|
SumTime: 3682,
|
||||||
|
HideRepoName: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
RepoName: "repo2",
|
||||||
|
MilestoneName: "",
|
||||||
|
MilestoneID: 0,
|
||||||
|
SumTime: 75,
|
||||||
|
HideRepoName: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Simple time bound",
|
||||||
|
unixfrom: 946684801,
|
||||||
|
unixto: 946684802,
|
||||||
|
orgname: 2,
|
||||||
|
expected: []organization.WorktimeSumByMilestones{
|
||||||
|
{
|
||||||
|
RepoName: "repo1",
|
||||||
|
MilestoneName: "milestone1",
|
||||||
|
MilestoneID: 1,
|
||||||
|
SumTime: 3662,
|
||||||
|
HideRepoName: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Both times inclusive",
|
||||||
|
unixfrom: 946684801,
|
||||||
|
unixto: 946684801,
|
||||||
|
orgname: 2,
|
||||||
|
expected: []organization.WorktimeSumByMilestones{
|
||||||
|
{
|
||||||
|
RepoName: "repo1",
|
||||||
|
MilestoneName: "milestone1",
|
||||||
|
MilestoneID: 1,
|
||||||
|
SumTime: 3661,
|
||||||
|
HideRepoName: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Should ignore deleted",
|
||||||
|
unixfrom: 947688814,
|
||||||
|
unixto: 947688815,
|
||||||
|
orgname: 2,
|
||||||
|
expected: []organization.WorktimeSumByMilestones{
|
||||||
|
{
|
||||||
|
RepoName: "repo2",
|
||||||
|
MilestoneName: "",
|
||||||
|
MilestoneID: 0,
|
||||||
|
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)
|
||||||
|
require.NoError(t, err)
|
||||||
|
results, err := organization.GetWorktimeByMilestones(org, kase.unixfrom, kase.unixto)
|
||||||
|
if 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.WorktimeSumByMembers
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Full sum for org 1",
|
||||||
|
unixfrom: 0,
|
||||||
|
unixto: 9223372036854775807,
|
||||||
|
orgname: 1,
|
||||||
|
expected: []organization.WorktimeSumByMembers(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.WorktimeSumByMembers{
|
||||||
|
{
|
||||||
|
UserName: "user2",
|
||||||
|
SumTime: 3666,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
UserName: "user1",
|
||||||
|
SumTime: 491,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Simple time bound",
|
||||||
|
unixfrom: 946684801,
|
||||||
|
unixto: 946684802,
|
||||||
|
orgname: 2,
|
||||||
|
expected: []organization.WorktimeSumByMembers{
|
||||||
|
{
|
||||||
|
UserName: "user2",
|
||||||
|
SumTime: 3662,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Both times inclusive",
|
||||||
|
unixfrom: 946684801,
|
||||||
|
unixto: 946684801,
|
||||||
|
orgname: 2,
|
||||||
|
expected: []organization.WorktimeSumByMembers{
|
||||||
|
{
|
||||||
|
UserName: "user2",
|
||||||
|
SumTime: 3661,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Should ignore deleted",
|
||||||
|
unixfrom: 947688814,
|
||||||
|
unixto: 947688815,
|
||||||
|
orgname: 2,
|
||||||
|
expected: []organization.WorktimeSumByMembers{
|
||||||
|
{
|
||||||
|
UserName: "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 := 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())
|
||||||
|
t.Run("ByRepos", testTimesByRepos)
|
||||||
|
t.Run("ByMilestones", testTimesByMilestones)
|
||||||
|
t.Run("ByMembers", testTimesByMembers)
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user