Skip to content
Snippets Groups Projects
Commit 840a2830 authored by Bob Van Landuyt :neckbeard:'s avatar Bob Van Landuyt :neckbeard: :sunglasses:
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
No related branches found
No related tags found
No related merge requests found
Showing
with 801 additions and 5 deletions
Loading
Loading
@@ -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
Loading
Loading
# 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
Loading
Loading
@@ -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
 
Loading
Loading
Loading
Loading
@@ -2156,6 +2156,10 @@ type Group {
"""
state: EpicState
): EpicConnection
"""
Indicates if Epics are enabled for namespace
"""
epicsEnabled: Boolean
 
"""
Loading
Loading
@@ -2168,6 +2172,11 @@ type Group {
"""
fullPath: ID!
 
"""
Indicates if Group timelogs are enabled for namespace
"""
groupTimelogsEnabled: Boolean
"""
ID of the namespace
"""
Loading
Loading
@@ -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
"""
Loading
Loading
@@ -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
"""
Loading
Loading
Loading
Loading
@@ -3223,7 +3223,7 @@
},
{
"name": "epicsEnabled",
"description": null,
"description": "Indicates if Epics are enabled for namespace",
"args": [
 
],
Loading
Loading
@@ -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",
Loading
Loading
@@ -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",
Loading
Loading
@@ -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",
Loading
Loading
Loading
Loading
@@ -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
Loading
Loading
@@ -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 |
Loading
Loading
Loading
Loading
@@ -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
Loading
Loading
@@ -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
Loading
Loading
# 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
raise_argument_error('The date range period cannot contain more than 60 days')
end
def raise_argument_error(message)
raise Gitlab::Graphql::Errors::ArgumentError, message
end
def group
@group ||= object.respond_to?(:sync) ? object.sync : object
end
end
end
# frozen_string_literal: true
module Types
class TimelogType < BaseObject
graphql_name 'Timelog'
authorize :read_group_timelogs
field :date,
Types::TimeType,
null: false,
method: :spent_at,
description: 'The date when the time tracked was spent at'
field :time_spent,
GraphQL::INT_TYPE,
null: false,
description: 'The time spent displayed in seconds'
field :user,
Types::UserType,
null: false,
resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, obj.user_id).find },
description: 'The user that logged the time'
field :issue,
Types::IssueType,
null: true,
resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Issue, obj.issue_id).find },
description: 'The issue that logged time was added to'
end
end
# frozen_string_literal: true
module HasTimelogsReport
extend ActiveSupport::Concern
def timelogs(start_date, end_date)
@timelogs ||= timelogs_for(start_date, end_date)
end
def user_can_access_group_timelogs?(current_user)
return unless feature_available?(:group_timelogs)
Ability.allowed?(current_user, :read_group_timelogs, self)
end
private
def timelogs_for(start_date, end_date)
Timelog.between_dates(start_date, end_date).for_issues_in_group(self)
end
end
Loading
Loading
@@ -13,6 +13,7 @@ module EE
include Vulnerable
include TokenAuthenticatable
include InsightsFeature
include HasTimelogsReport
 
add_authentication_token_field :saml_discovery_token, unique: false, token_generator: -> { Devise.friendly_token(8) }
 
Loading
Loading
Loading
Loading
@@ -96,6 +96,7 @@ class License < ApplicationRecord
scoped_labels
service_desk
smartcard_auth
group_timelogs
type_of_work_analytics
unprotection_restrictions
ci_project_subscriptions
Loading
Loading
Loading
Loading
@@ -49,6 +49,10 @@ module EE
@subject.saml_provider&.enabled?
end
 
condition(:group_timelogs_available) do
@subject.feature_available?(:group_timelogs)
end
rule { reporter }.policy do
enable :admin_list
enable :admin_board
Loading
Loading
@@ -56,6 +60,7 @@ module EE
enable :view_code_analytics
enable :view_productivity_analytics
enable :view_type_of_work_charts
enable :read_group_timelogs
end
 
rule { maintainer }.policy do
Loading
Loading
@@ -143,6 +148,8 @@ module EE
rule { owner & group_saml_enabled }.policy do
enable :read_group_saml_identity
end
rule { ~group_timelogs_available }.prevent :read_group_timelogs
end
 
override :lookup_access_level!
Loading
Loading
Loading
Loading
@@ -95,6 +95,10 @@ module EE
!@subject.design_management_enabled?
end
 
condition(:group_timelogs_available) do
@subject.feature_available?(:group_timelogs)
end
rule { admin }.enable :change_repository_storage
 
rule { support_bot }.enable :guest_access
Loading
Loading
@@ -120,6 +124,8 @@ module EE
prevent :admin_issue_link
end
 
rule { ~group_timelogs_available }.prevent :read_group_timelogs
rule { can?(:read_issue) }.policy do
enable :read_issue_link
enable :read_design
Loading
Loading
@@ -131,6 +137,7 @@ module EE
enable :admin_issue_link
enable :admin_epic_issue
enable :read_package
enable :read_group_timelogs
end
 
rule { can?(:developer_access) }.policy do
Loading
Loading
# frozen_string_literal: true
class TimelogPolicy < BasePolicy
delegate { @subject.issuable.resource_parent }
end
---
title: Expose time logs for group issues via the GraphQL API
merge_request: 18689
author:
type: added
Loading
Loading
@@ -8,4 +8,17 @@ describe GitlabSchema.types['Group'] do
it { expect(described_class).to have_graphql_field(:epics) }
it { expect(described_class).to have_graphql_field(:epic) }
end
it { expect(described_class).to have_graphql_field(:groupTimelogsEnabled) }
it { expect(described_class).to have_graphql_field(:timelogs, complexity: 5) }
describe 'timelogs field' do
subject { described_class.fields['timelogs'] }
it 'finds timelogs between start date and end date' do
is_expected.to have_graphql_arguments(:start_date, :end_date, :after, :before, :first, :last)
is_expected.to have_graphql_resolver(Resolvers::TimelogResolver)
is_expected.to have_non_null_graphql_type(Types::TimelogType.connection_type)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Resolvers::TimelogResolver do
include GraphqlHelpers
context "within a group" do
let(:current_user) { create(:user) }
let(:user) { create(:user) }
let(:group) { create(:group) }
let(:project) { create(:project, :public, group: group) }
before do
group.add_users([current_user, user], :developer)
project.add_developer(user)
stub_licensed_features(group_timelogs: true)
end
describe '#resolve' do
let(:issue) { create(:issue, project: project) }
let(:issue2) { create(:issue, project: project) }
let!(:timelog1) { create(:timelog, issue: issue, user: user, spent_at: 5.days.ago) }
let!(:timelog2) { create(:timelog, issue: issue2, user: user, spent_at: 10.days.ago) }
let(:start_date) { 6.days.ago }
let(:end_date) { 2.days.ago }
shared_examples 'validation fails with error' do
it 'raises error with correct message' do
expect { resolve_timelogs(start_date: start_date, end_date: end_date) }
.to raise_error(
error_type,
message
)
end
end
it 'finds all timelogs within given dates' do
timelogs = resolve_timelogs(start_date: start_date, end_date: end_date)
expect(timelogs).to contain_exactly(timelog1)
end
context 'when arguments are invalid' do
let(:error_type) { Gitlab::Graphql::Errors::ArgumentError }
context 'when only start_date is present' do
let(:end_date) { nil }
let(:message) { 'Both start_date and end_date must be present.' }
it_behaves_like 'validation fails with error'
end
context 'when only end_date is present' do
let(:start_date) { nil }
let(:message) { 'Both start_date and end_date must be present.' }
it_behaves_like 'validation fails with error'
end
context 'when start_date is later than end_date' do
let(:start_date) { 3.days.ago }
let(:end_date) { 5.days.ago }
let(:message) { 'start_date must be earlier than end_date.' }
it_behaves_like 'validation fails with error'
end
context 'when time range is more than 60 days' do
let(:start_date) { 3.months.ago }
let(:end_date) { 1.day.ago }
let(:message) { 'The date range period cannot contain more than 60 days' }
it_behaves_like 'validation fails with error'
end
end
context 'when resource is not available' do
let(:error_type) { Gitlab::Graphql::Errors::ResourceNotAvailable }
let(:message) { "The resource is not available or you don't have permission to perform this action" }
context 'when feature is disabled' do
before do
stub_licensed_features(group_timelogs: false)
end
it_behaves_like 'validation fails with error'
end
context "when user has insufficient permissions" do
before do
group.add_guest(current_user)
end
it_behaves_like 'validation fails with error'
end
end
end
end
def resolve_timelogs(args = {}, context = { current_user: current_user })
resolve(described_class, obj: group, args: args, ctx: context)
end
end
# frozen_string_literal: true
require 'spec_helper'
describe GitlabSchema.types['Timelog'] do
let(:fields) { %i[date time_spent user issue] }
it { expect(described_class.graphql_name).to eq('Timelog') }
it { expect(described_class).to have_graphql_fields(fields) }
it { expect(described_class).to require_graphql_authorizations(:read_group_timelogs) }
describe 'user field' do
subject { described_class.fields['user'] }
it 'returns user' do
is_expected.to have_non_null_graphql_type(Types::UserType)
end
end
describe 'issue field' do
subject { described_class.fields['issue'] }
it 'returns issue' do
is_expected.to have_graphql_type(Types::IssueType)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe HasTimelogsReport do
let(:user) { create(:user) }
let(:group) { create(:group) }
let(:issue) { create(:issue, project: create(:project, :public, group: group)) }
context '#timelogs' do
let!(:timelog1) { create_timelog(15.days.ago) }
let!(:timelog2) { create_timelog(10.days.ago) }
let!(:timelog3) { create_timelog(5.days.ago) }
let(:start_date) { 20.days.ago }
let(:end_date) { 8.days.ago }
before do
group.add_developer(user)
end
it 'returns collection of timelogs between given dates' do
expect(group.timelogs(start_date, end_date).to_a).to match_array([timelog1, timelog2])
end
it 'returns empty collection if dates are not present' do
expect(group.timelogs(nil, nil)).to be_empty
end
it 'returns empty collection if date range is invalid' do
expect(group.timelogs(end_date, start_date)).to be_empty
end
end
context '#user_can_access_group_timelogs?' do
before do
group.add_developer(user)
stub_licensed_features(group_timelogs: true)
end
it 'returns true if user can access group timelogs' do
expect(group.user_can_access_group_timelogs?(user)).to be_truthy
end
it 'returns false if feature group_timelogs is disabled' do
stub_licensed_features(group_timelogs: false)
expect(group.user_can_access_group_timelogs?(user)).to be_falsey
end
it 'returns false if user has insufficient permissions' do
group.add_guest(user)
expect(group.user_can_access_group_timelogs?(user)).to be_falsey
end
end
def create_timelog(date)
create(:timelog, issue: issue, user: user, spent_at: date)
end
end
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment