mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-31 21:28:11 +09: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:
		
							
								
								
									
										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 | ||||
| 		"FileSize": base.FileSize, | ||||
| 		"CountFmt": countFmt, | ||||
| 		"Sec2Time": util.SecToHours, | ||||
| 		"Sec2Hour": util.SecToHours, | ||||
|  | ||||
| 		"TimeEstimateString": timeEstimateString, | ||||
|  | ||||
|   | ||||
| @@ -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 == "" && seconds > 0 { | ||||
| 		formattedTime = formatTime(seconds, "second", "") | ||||
| 	} | ||||
| 	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, "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)) | ||||
| 	assert.Equal(t, "", SecToHours(nil)) // old behavior, empty means no output | ||||
| } | ||||
|   | ||||
| @@ -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 <strong>all repositories</strong> 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 | ||||
|   | ||||
							
								
								
									
										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}/delete", org.DeleteTeam) | ||||
|  | ||||
| 			m.Get("/worktime", context.OrgAssignment(false, true), org.Worktime) | ||||
|  | ||||
| 			m.Group("/settings", func() { | ||||
| 				m.Combo("").Get(org.Settings). | ||||
| 					Post(web.Bind(forms.UpdateOrgSettingForm{}), org.SettingsPost) | ||||
|   | ||||
| @@ -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...) | ||||
|   | ||||
| @@ -45,6 +45,11 @@ | ||||
| 			</a> | ||||
| 			{{end}} | ||||
| 			{{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> | ||||
| 			<a class="{{if .PageIsOrgSettings}}active {{end}}item" href="{{.OrgLink}}/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"> | ||||
| 				<span class="item" data-tooltip-content='{{ctx.Locale.Tr "tracked_time_summary"}}'> | ||||
| 					{{svg "octicon-clock"}} | ||||
| 					{{.TotalTrackedTime | Sec2Time}} | ||||
| 					{{.TotalTrackedTime | Sec2Hour}} | ||||
| 				</span> | ||||
| 			</div> | ||||
| 		{{end}} | ||||
|   | ||||
| @@ -40,7 +40,7 @@ | ||||
| 					<div class="ui compact tiny secondary menu"> | ||||
| 						<span class="item" data-tooltip-content='{{ctx.Locale.Tr "tracked_time_summary"}}'> | ||||
| 							{{svg "octicon-clock"}} | ||||
| 							{{.TotalTrackedTime | Sec2Time}} | ||||
| 							{{.TotalTrackedTime | Sec2Hour}} | ||||
| 						</span> | ||||
| 					</div> | ||||
| 				{{end}} | ||||
|   | ||||
| @@ -50,7 +50,7 @@ | ||||
| 				{{if .TotalTrackedTime}} | ||||
| 					<div data-tooltip-content='{{ctx.Locale.Tr "tracked_time_summary"}}'> | ||||
| 						{{svg "octicon-clock"}} | ||||
| 						{{.TotalTrackedTime | Sec2Time}} | ||||
| 						{{.TotalTrackedTime | Sec2Hour}} | ||||
| 					</div> | ||||
| 				{{end}} | ||||
| 			</div> | ||||
|   | ||||
| @@ -41,7 +41,7 @@ | ||||
| 							{{if .TotalTrackedTime}} | ||||
| 								<div class="flex-text-block"> | ||||
| 									{{svg "octicon-clock"}} | ||||
| 									{{.TotalTrackedTime|Sec2Time}} | ||||
| 									{{.TotalTrackedTime|Sec2Hour}} | ||||
| 								</div> | ||||
| 							{{end}} | ||||
| 							{{if .UpdatedUnix}} | ||||
|   | ||||
| @@ -72,7 +72,7 @@ | ||||
| 	{{end}} | ||||
| 	{{if .WorkingUsers}} | ||||
| 		<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> | ||||
| 				{{range $user, $trackedtime := .WorkingUsers}} | ||||
| 					<div class="comment tw-mt-2"> | ||||
| @@ -82,7 +82,7 @@ | ||||
| 						<div class="content"> | ||||
| 							{{template "shared/user/authorlink" $user}} | ||||
| 							<div class="text"> | ||||
| 								{{$trackedtime|Sec2Time}} | ||||
| 								{{$trackedtime|Sec2Hour}} | ||||
| 							</div> | ||||
| 						</div> | ||||
| 					</div> | ||||
|   | ||||
| @@ -252,7 +252,7 @@ | ||||
| 				<span class="text grey muted-links"> | ||||
| 					{{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}} | ||||
| 				</span> | ||||
| 				{{template "repo/issue/view_content/comments_delete_time" dict "ctxData" $ "comment" .}} | ||||
| @@ -264,7 +264,7 @@ | ||||
| 				<span class="text grey muted-links"> | ||||
| 					{{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}} | ||||
| 				</span> | ||||
| 				{{template "repo/issue/view_content/comments_delete_time" dict "ctxData" $ "comment" .}} | ||||
| @@ -506,7 +506,7 @@ | ||||
| 						{{/* compatibility with time comments made before v1.21 */}} | ||||
| 						<span class="text grey muted-links">{{.RenderedContent}}</span> | ||||
| 					{{else}} | ||||
| 						<span class="text grey muted-links">- {{.Content|Sec2Time}}</span> | ||||
| 						<span class="text grey muted-links">- {{.Content|Sec2Hour}}</span> | ||||
| 					{{end}} | ||||
| 				</div> | ||||
| 			</div> | ||||
|   | ||||
| @@ -28,7 +28,7 @@ | ||||
| 					{{if .TotalTrackedTime}} | ||||
| 					<div class="text grey flex-text-block"> | ||||
| 							{{svg "octicon-clock" 16}} | ||||
| 							{{.TotalTrackedTime | Sec2Time}} | ||||
| 							{{.TotalTrackedTime | Sec2Hour}} | ||||
| 					</div> | ||||
| 					{{end}} | ||||
| 				</div> | ||||
|   | ||||
| @@ -100,7 +100,7 @@ | ||||
| 									{{if .TotalTrackedTime}} | ||||
| 										<div class="flex-text-block"> | ||||
| 											{{svg "octicon-clock"}} | ||||
| 											{{.TotalTrackedTime|Sec2Time}} | ||||
| 											{{.TotalTrackedTime|Sec2Hour}} | ||||
| 										</div> | ||||
| 									{{end}} | ||||
| 									{{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) | ||||
| } | ||||
		Reference in New Issue
	
	Block a user