Commit 840a2830 authored by Bob Van Landuyt :neckbeard:'s avatar Bob Van Landuyt :neckbeard: 😎
Browse files

Merge branch '10741-time-tracking-report-per-person-in-a-given-group' into 'master'

Add endpoint for tracking report per person in a given group

See merge request gitlab-org/gitlab!18689
parents c96c3e54 05f5212d
......@@ -8,6 +8,18 @@ class Timelog < ApplicationRecord
belongs_to :merge_request, touch: true
belongs_to :user
 
scope :for_issues_in_group, -> (group) do
joins(:issue).where(
'EXISTS (?)',
Project.select(1).where(namespace: group.self_and_descendants)
.where('issues.project_id = projects.id')
)
end
scope :between_dates, -> (start_date, end_date) do
where('spent_at BETWEEN ? AND ?', start_date, end_date)
end
def issuable
issue || merge_request
end
......
# frozen_string_literal: true
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class AddTimelogSpentAtIndex < ActiveRecord::Migration[5.2]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_concurrent_index :timelogs, :spent_at, where: 'spent_at IS NOT NULL'
end
def down
remove_concurrent_index :timelogs, :spent_at, where: 'spent_at IS NOT NULL'
end
end
......@@ -3818,6 +3818,7 @@ ActiveRecord::Schema.define(version: 2019_12_06_122926) do
t.datetime "spent_at"
t.index ["issue_id"], name: "index_timelogs_on_issue_id"
t.index ["merge_request_id"], name: "index_timelogs_on_merge_request_id"
t.index ["spent_at"], name: "index_timelogs_on_spent_at", where: "(spent_at IS NOT NULL)"
t.index ["user_id"], name: "index_timelogs_on_user_id"
end
 
......
......@@ -2156,6 +2156,10 @@ type Group {
"""
state: EpicState
): EpicConnection
"""
Indicates if Epics are enabled for namespace
"""
epicsEnabled: Boolean
 
"""
......@@ -2168,6 +2172,11 @@ type Group {
"""
fullPath: ID!
 
"""
Indicates if Group timelogs are enabled for namespace
"""
groupTimelogsEnabled: Boolean
"""
ID of the namespace
"""
......@@ -2233,6 +2242,41 @@ type Group {
"""
rootStorageStatistics: RootStorageStatistics
 
"""
Time logged in issues by group members
"""
timelogs(
"""
Returns the elements in the list that come after the specified cursor.
"""
after: String
"""
Returns the elements in the list that come before the specified cursor.
"""
before: String
"""
List time logs within a time range where the logged date is before end_date parameter.
"""
endDate: Time!
"""
Returns the first _n_ elements from the list.
"""
first: Int
"""
Returns the last _n_ elements from the list.
"""
last: Int
"""
List time logs within a time range where the logged date is after start_date parameter.
"""
startDate: Time!
): TimelogConnection!
"""
Permissions for the current user on the resource
"""
......@@ -5484,6 +5528,63 @@ Time represented in ISO 8601
"""
scalar Time
 
type Timelog {
"""
The date when the time tracked was spent at
"""
date: Time!
"""
The issue that logged time was added to
"""
issue: Issue
"""
The time spent displayed in seconds
"""
timeSpent: Int!
"""
The user that logged the time
"""
user: User!
}
"""
The connection type for Timelog.
"""
type TimelogConnection {
"""
A list of edges.
"""
edges: [TimelogEdge]
"""
A list of nodes.
"""
nodes: [Timelog]
"""
Information to aid in pagination.
"""
pageInfo: PageInfo!
}
"""
An edge in a connection.
"""
type TimelogEdge {
"""
A cursor for use in pagination.
"""
cursor: String!
"""
The item at the end of the edge.
"""
node: Timelog
}
"""
Representing a todo entry
"""
......
......@@ -3223,7 +3223,7 @@
},
{
"name": "epicsEnabled",
"description": null,
"description": "Indicates if Epics are enabled for namespace",
"args": [
 
],
......@@ -3271,6 +3271,20 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "groupTimelogsEnabled",
"description": "Indicates if Group timelogs are enabled for namespace",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "id",
"description": "ID of the namespace",
......@@ -3448,6 +3462,91 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "timelogs",
"description": "Time logged in issues by group members",
"args": [
{
"name": "startDate",
"description": "List time logs within a time range where the logged date is after start_date parameter.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "endDate",
"description": "List time logs within a time range where the logged date is before end_date parameter.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "after",
"description": "Returns the elements in the list that come after the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "before",
"description": "Returns the elements in the list that come before the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "first",
"description": "Returns the first _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
},
{
"name": "last",
"description": "Returns the last _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
}
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "TimelogConnection",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "userPermissions",
"description": "Permissions for the current user on the resource",
......@@ -10813,6 +10912,199 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "TimelogConnection",
"description": "The connection type for Timelog.",
"fields": [
{
"name": "edges",
"description": "A list of edges.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "TimelogEdge",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "nodes",
"description": "A list of nodes.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "Timelog",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "pageInfo",
"description": "Information to aid in pagination.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "PageInfo",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "TimelogEdge",
"description": "An edge in a connection.",
"fields": [
{
"name": "cursor",
"description": "A cursor for use in pagination.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "node",
"description": "The item at the end of the edge.",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "Timelog",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "Timelog",
"description": null,
"fields": [
{
"name": "date",
"description": "The date when the time tracked was spent at",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "issue",
"description": "The issue that logged time was added to",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "Issue",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "timeSpent",
"description": "The time spent displayed in seconds",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "user",
"description": "The user that logged the time",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "User",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "ProjectStatistics",
......
......@@ -320,7 +320,8 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph
| `webUrl` | String! | Web URL of the group |
| `avatarUrl` | String | Avatar URL of the group |
| `parent` | Group | Parent group |
| `epicsEnabled` | Boolean | |
| `epicsEnabled` | Boolean | Indicates if Epics are enabled for namespace |
| `groupTimelogsEnabled` | Boolean | Indicates if Group timelogs are enabled for namespace |
| `epic` | Epic | |
 
### GroupPermissions
......@@ -836,6 +837,15 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph
| `count` | Int! | Number of total tasks |
| `completedCount` | Int! | Number of completed tasks |
 
### Timelog
| Name | Type | Description |
| --- | ---- | ---------- |
| `date` | Time! | The date when the time tracked was spent at |
| `timeSpent` | Int! | The time spent displayed in seconds |
| `user` | User! | The user that logged the time |
| `issue` | Issue | The issue that logged time was added to |
### Todo
 
| Name | Type | Description |
......
......@@ -6,10 +6,10 @@ module EE
extend ActiveSupport::Concern
 
prepended do
%i[epics].each do |feature|
field "#{feature}_enabled", GraphQL::BOOLEAN_TYPE, null: true, resolve: -> (group, args, ctx) do # rubocop:disable Graphql/Descriptions
%i[epics group_timelogs].each do |feature|
field "#{feature}_enabled", GraphQL::BOOLEAN_TYPE, null: true, resolve: -> (group, args, ctx) do
group.feature_available?(feature)
end
end, description: "Indicates if #{feature.to_s.humanize} are enabled for namespace"
end
 
field :epic, # rubocop:disable Graphql/Descriptions
......@@ -22,6 +22,12 @@ module EE
null: true,
max_page_size: 2000,
resolver: ::Resolvers::EpicResolver
field :timelogs,
::Types::TimelogType.connection_type,
null: false, complexity: 5,
resolver: ::Resolvers::TimelogResolver,
description: 'Time logged in issues by group members'
end
end
end
......
# frozen_string_literal: true
module Resolvers
class TimelogResolver < BaseResolver
argument :start_date, Types::TimeType,
required: true,
description: 'List time logs within a time range where the logged date is after start_date parameter.'
argument :end_date, Types::TimeType,
required: true,
description: 'List time logs within a time range where the logged date is before end_date parameter.'
def resolve(**args)
validate_date_params!(args)
authorize_group_timelogs!
find_timelogs(args)
end
private
def find_timelogs(args)
group.timelogs(args[:start_date], args[:end_date])
end
def validate_date_params!(args)
validate_dates_present!(args[:start_date], args[:end_date])
validate_dates_difference!(args[:start_date], args[:end_date])
validate_date_range!(args[:start_date], args[:end_date])
end
def valid_object?
group.present? &&
group&.feature_available?(:group_timelogs) &&
group&.user_can_access_group_timelogs?(context[:current_user])
end
def authorize_group_timelogs!
unless valid_object?
raise Gitlab::Graphql::Errors::ResourceNotAvailable,
"The resource is not available or you don't have permission to perform this action"
end
end
def validate_dates_present!(start_date, end_date)
return if start_date.present? && end_date.present?
raise_argument_error('Both start_date and end_date must be present.')
end
def validate_dates_difference!(start_date, end_date)
return if end_date > start_date
raise_argument_error('start_date must be earlier than end_date.')
end
def validate_date_range!(start_date, end_date)
return if end_date - start_date <= 60.days