Commit 840a2830 authored by Bob Van Landuyt's avatar Bob Van Landuyt

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 ...@@ -8,6 +8,18 @@ class Timelog < ApplicationRecord
belongs_to :merge_request, touch: true belongs_to :merge_request, touch: true
belongs_to :user 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 def issuable
issue || merge_request issue || merge_request
end 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 ...@@ -3818,6 +3818,7 @@ ActiveRecord::Schema.define(version: 2019_12_06_122926) do
t.datetime "spent_at" t.datetime "spent_at"
t.index ["issue_id"], name: "index_timelogs_on_issue_id" t.index ["issue_id"], name: "index_timelogs_on_issue_id"
t.index ["merge_request_id"], name: "index_timelogs_on_merge_request_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" t.index ["user_id"], name: "index_timelogs_on_user_id"
end end
......
...@@ -2156,6 +2156,10 @@ type Group { ...@@ -2156,6 +2156,10 @@ type Group {
""" """
state: EpicState state: EpicState
): EpicConnection ): EpicConnection
"""
Indicates if Epics are enabled for namespace
"""
epicsEnabled: Boolean epicsEnabled: Boolean
""" """
...@@ -2168,6 +2172,11 @@ type Group { ...@@ -2168,6 +2172,11 @@ type Group {
""" """
fullPath: ID! fullPath: ID!
"""
Indicates if Group timelogs are enabled for namespace
"""
groupTimelogsEnabled: Boolean
""" """
ID of the namespace ID of the namespace
""" """
...@@ -2233,6 +2242,41 @@ type Group { ...@@ -2233,6 +2242,41 @@ type Group {
""" """
rootStorageStatistics: RootStorageStatistics 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 Permissions for the current user on the resource
""" """
...@@ -5484,6 +5528,63 @@ Time represented in ISO 8601 ...@@ -5484,6 +5528,63 @@ Time represented in ISO 8601
""" """
scalar Time 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 Representing a todo entry
""" """
......
...@@ -3223,7 +3223,7 @@ ...@@ -3223,7 +3223,7 @@
}, },
{ {
"name": "epicsEnabled", "name": "epicsEnabled",
"description": null, "description": "Indicates if Epics are enabled for namespace",
"args": [ "args": [
], ],
...@@ -3271,6 +3271,20 @@ ...@@ -3271,6 +3271,20 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "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", "name": "id",
"description": "ID of the namespace", "description": "ID of the namespace",
...@@ -3448,6 +3462,91 @@ ...@@ -3448,6 +3462,91 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "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", "name": "userPermissions",
"description": "Permissions for the current user on the resource", "description": "Permissions for the current user on the resource",
...@@ -10813,6 +10912,199 @@ ...@@ -10813,6 +10912,199 @@
"enumValues": null, "enumValues": null,
"possibleTypes": 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", "kind": "OBJECT",
"name": "ProjectStatistics", "name": "ProjectStatistics",
......
...@@ -320,7 +320,8 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph ...@@ -320,7 +320,8 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph
| `webUrl` | String! | Web URL of the group | | `webUrl` | String! | Web URL of the group |
| `avatarUrl` | String | Avatar URL of the group | | `avatarUrl` | String | Avatar URL of the group |
| `parent` | Group | Parent 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 | | | `epic` | Epic | |
### GroupPermissions ### GroupPermissions
...@@ -836,6 +837,15 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph ...@@ -836,6 +837,15 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph
| `count` | Int! | Number of total tasks | | `count` | Int! | Number of total tasks |
| `completedCount` | Int! | Number of completed 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 ### Todo
| Name | Type | Description | | Name | Type | Description |
......
...@@ -6,10 +6,10 @@ module EE ...@@ -6,10 +6,10 @@ module EE
extend ActiveSupport::Concern extend ActiveSupport::Concern
prepended do prepended do
%i[epics].each do |feature| %i[epics group_timelogs].each do |feature|
field "#{feature}_enabled", GraphQL::BOOLEAN_TYPE, null: true, resolve: -> (group, args, ctx) do # rubocop:disable Graphql/Descriptions field "#{feature}_enabled", GraphQL::BOOLEAN_TYPE, null: true, resolve: -> (group, args, ctx) do
group.feature_available?(feature) group.feature_available?(feature)
end end, description: "Indicates if #{feature.to_s.humanize} are enabled for namespace"
end end
field :epic, # rubocop:disable Graphql/Descriptions field :epic, # rubocop:disable Graphql/Descriptions
...@@ -22,6 +22,12 @@ module EE ...@@ -22,6 +22,12 @@ module EE
null: true, null: true,
max_page_size: 2000, max_page_size: 2000,
resolver: ::Resolvers::EpicResolver 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 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
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
...@@ -13,6 +13,7 @@ module EE ...@@ -13,6 +13,7 @@ module EE
include Vulnerable include Vulnerable
include TokenAuthenticatable include TokenAuthenticatable
include InsightsFeature include InsightsFeature
include HasTimelogsReport
add_authentication_token_field :saml_discovery_token, unique: false, token_generator: -> { Devise.friendly_token(8) } add_authentication_token_field :saml_discovery_token, unique: false, token_generator: -> { Devise.friendly_token(8) }
......
...@@ -96,6 +96,7 @@ class License < ApplicationRecord ...@@ -96,6 +96,7 @@ class License < ApplicationRecord
scoped_labels scoped_labels
service_desk service_desk
smartcard_auth smartcard_auth
group_timelogs
type_of_work_analytics type_of_work_analytics
unprotection_restrictions unprotection_restrictions
ci_project_subscriptions ci_project_subscriptions
......
...@@ -49,6 +49,10 @@ module EE ...@@ -49,6 +49,10 @@ module EE
@subject.saml_provider&.enabled? @subject.saml_provider&.enabled?
end end
condition(:group_timelogs_available) do
@subject.feature_available?(:group_timelogs)
end
rule { reporter }.policy do rule { reporter }.policy do
enable :admin_list enable :admin_list
enable :admin_board enable :admin_board
...@@ -56,6 +60,7 @@ module EE ...@@ -56,6 +60,7 @@ module EE
enable :view_code_analytics enable :view_code_analytics
enable :view_productivity_analytics enable :view_productivity_analytics
enable :view_type_of_work_charts enable :view_type_of_work_charts
enable :read_group_timelogs
end end
rule { maintainer }.policy do rule { maintainer }.policy do
...@@ -143,6 +148,8 @@ module EE ...@@ -143,6 +148,8 @@ module EE
rule { owner & group_saml_enabled }.policy do rule { owner & group_saml_enabled }.policy do
enable :read_group_saml_identity enable :read_group_saml_identity
end end
rule { ~group_timelogs_available }.prevent :read_group_timelogs
end end
override :lookup_access_level! override :lookup_access_level!
......
...@@ -95,6 +95,10 @@ module EE ...@@ -95,6 +95,10 @@ module EE
!@subject.design_management_enabled? !@subject.design_management_enabled?
end end
condition(:group_timelogs_available) do
@subject.feature_available?(:group_timelogs)
end
rule { admin }.enable :change_repository_storage rule { admin }.enable :change_repository_storage
rule { support_bot }.enable :guest_access rule { support_bot }.enable :guest_access
...@@ -120,6 +124,8 @@ module EE ...@@ -120,6 +124,8 @@ module EE
prevent :admin_issue_link prevent :admin_issue_link
end end
rule { ~group_timelogs_available }.prevent :read_group_timelogs
rule { can?(:read_issue) }.policy do rule { can?(:read_issue) }.policy do
enable :read_issue_link enable :read_issue_link
enable :read_design enable :read_design
...@@ -131,6 +137,7 @@ module EE ...@@ -131,6 +137,7 @@ module EE
enable :admin_issue_link enable :admin_issue_link
enable :admin_epic_issue enable :admin_epic_issue
enable :read_package enable :read_package
enable :read_group_timelogs
end end
rule { can?(:developer_access) }.policy do rule { can?(:developer_access) }.policy do
......
# 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
...@@ -8,4 +8,17 @@ describe GitlabSchema.types['Group'] do ...@@ -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(:epics) }
it { expect(described_class).to have_graphql_field(:epic) } it { expect(described_class).to have_graphql_field(:epic) }
end 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 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
...@@ -61,6 +61,64 @@ describe GroupPolicy do ...@@ -61,6 +61,64 @@ describe GroupPolicy do
it { is_expected.not_to be_allowed(:read_group_contribution_analytics) } it { is_expected.not_to be_allowed(:read_group_contribution_analytics) }
end end
context 'when timelogs report feature is enabled' do
before do
stub_licensed_features(group_timelogs: true)
end
context 'admin' do
let(:current_user) { admin }
it { is_expected.to be_allowed(:read_group_timelogs) }
end
context 'with owner' do
let(:current_user) { owner }
it { is_expected.to be_allowed(:read_group_timelogs) }
end
context 'with maintainer' do
let(:current_user) { maintainer }
it { is_expected.to be_allowed(:read_group_timelogs) }
end
context 'with reporter' do
let(:current_user) { reporter }
it { is_expected.to be_allowed(:read_group_timelogs) }
end
context 'with guest' do
let(:current_user) { guest }
it { is_expected.to be_disallowed(:read_group_timelogs) }
end
context 'with non member' do
let(:current_user) { create(:user) }
it { is_expected.to be_disallowed(:read_group_timelogs) }
end
context 'with anonymous' do
let(:current_user) { nil }
it { is_expected.to be_disallowed(:read_group_timelogs) }
end
end
context 'when timelogs report feature is disabled' do
let(:current_user) { admin }
before do
stub_licensed_features(group_timelogs: false)
end
it { is_expected.to be_disallowed(:read_group_timelogs) }
end
describe 'per group SAML' do describe 'per group SAML' do
let(:current_user) { maintainer } let(:current_user) { maintainer }
......
...@@ -1119,4 +1119,62 @@ describe ProjectPolicy do ...@@ -1119,4 +1119,62 @@ describe ProjectPolicy do
it { is_expected.to be_allowed(:read_reject_unsigned_commits) } it { is_expected.to be_allowed(:read_reject_unsigned_commits) }
end end
end end
context 'when timelogs report feature is enabled' do
before do
stub_licensed_features(group_timelogs: true)
end
context 'admin' do
let(:current_user) { admin }
it { is_expected.to be_allowed(:read_group_timelogs) }
end
context 'with owner' do
let(:current_user) { owner }
it { is_expected.to be_allowed(:read_group_timelogs) }
end
context 'with maintainer' do
let(:current_user) { maintainer }
it { is_expected.to be_allowed(:read_group_timelogs) }
end
context 'with reporter' do
let(:current_user) { reporter }
it { is_expected.to be_allowed(:read_group_timelogs) }
end
context 'with guest' do
let(:current_user) { guest }
it { is_expected.to be_disallowed(:read_group_timelogs) }
end
context 'with non member' do
let(:current_user) { create(:user) }
it { is_expected.to be_disallowed(:read_group_timelogs) }
end
context 'with anonymous' do
let(:current_user) { nil }
it { is_expected.to be_disallowed(:read_group_timelogs) }
end
end
context 'when timelogs report feature is disabled' do
let(:current_user) { admin }
before do
stub_licensed_features(group_timelogs: false)
end
it { is_expected.to be_disallowed(:read_group_timelogs) }
end
end end
# frozen_string_literal: true
require 'spec_helper'
describe 'Timelogs through GroupQuery' do
include GraphqlHelpers
describe 'Get list of timelogs from a group issues' do
let(:user) { create(:user) }
let(:group) { create(:group) }
let(:project) { create(:project, :public, group: group) }
let(:milestone) { create(:milestone, group: group) }
let(:epic) { create(:epic, group: group) }
let(:issue) { create(:issue, project: project, milestone: milestone, epic: epic) }
let!(:timelog1) { create(:timelog, issue: issue, user: user, spent_at: 10.days.ago) }
let!(:timelog2) { create(:timelog, spent_at: 15.days.ago) }
let(:timelogs_data) { graphql_data['group']['timelogs']['edges'] }
let(:query) do
timelog_node = <<~NODE
edges {
node {
date
timeSpent
user {
username
}
issue {
title
milestone {
title
}
epic {
title
}
}
}
}
NODE
graphql_query_for("group", { "fullPath" => group.full_path },
['groupTimelogsEnabled', query_graphql_field(
"timelogs",
{ startDate: "#{13.days.ago.to_date}", endDate: "#{2.days.ago.to_date}" },
timelog_node
)]
)
end
before do
group.add_developer(user)
stub_licensed_features(group_timelogs: true, epics: true)
end
context 'when the request is correct' do
before do
post_graphql(query, current_user: user)
end
it_behaves_like 'a working graphql query'
it 'returns timelogs successfully' do
expect(response).to have_gitlab_http_status(200)
expect(graphql_errors).to be_nil
expect(node_array.size).to eq 1
expect(graphql_data['group']['groupTimelogsEnabled']).to be_truthy
end
it 'contains correct data' do
username = node_array.map {|data| data['user']['username'] }
date = node_array.map { |data| data['date'].to_date.to_s }
time_spent = node_array.map { |data| data['timeSpent'] }
issue_title = node_array.map {|data| data['issue']['title'] }
milestone_title = node_array.map {|data| data['issue']['milestone']['title'] }
epic_title = node_array.map {|data| data['issue']['epic']['title'] }
expect(username).to eq([user.username])
expect(date).to eq([timelog1.spent_at.to_date.to_s])
expect(time_spent).to eq([timelog1.time_spent])
expect(issue_title).to eq([issue.title])
expect(milestone_title).to eq([milestone.title])
expect(epic_title).to eq([epic.title])
end
end
context 'when requests has errors' do
let(:error_message) do
"The resource is not available or you don't have permission to perform this action"
end
context 'when group_timelogs feature is disabled' do
before do
stub_licensed_features(group_timelogs: false)
end
it 'returns empty' do
post_graphql(query, current_user: user)
expect(response).to have_gitlab_http_status(:success)
expect(graphql_errors).to include(a_hash_including('message' => error_message))
expect(graphql_data['group']).to be_nil
end
end
context 'when there are no timelogs present' do
before do
Timelog.delete_all
end
it 'returns empty result' do
post_graphql(query, current_user: user)
expect(response).to have_gitlab_http_status(:success)
expect(graphql_errors).to be_nil
expect(timelogs_data).to be_empty
expect(graphql_data['group']['groupTimelogsEnabled']).to be_truthy
end
end
context 'when user has no permission to read group timelogs' do
it 'returns empty result' do
guest = create(:user)
group.add_guest(guest)
post_graphql(query, current_user: guest)
expect(response).to have_gitlab_http_status(:success)
expect(graphql_errors).to include(a_hash_including('message' => error_message))
expect(graphql_data['group']).to be_nil
end
end
end
end
def node_array(extract_attribute = nil)
timelogs_data.map do |item|
extract_attribute ? item['node'][extract_attribute] : item['node']
end
end
end
...@@ -41,4 +41,30 @@ RSpec.describe Timelog do ...@@ -41,4 +41,30 @@ RSpec.describe Timelog do
expect(subject).to be_valid expect(subject).to be_valid
end end
end end
describe 'scopes' do
describe 'for_issues_in_group' do
it 'return timelogs created for group issues' do
group = create(:group)
subgroup = create(:group, parent: group)
create(:timelog, issue: create(:issue, project: create(:project)))
timelog1 = create(:timelog, issue: create(:issue, project: create(:project, group: group)))
timelog2 = create(:timelog, issue: create(:issue, project: create(:project, group: subgroup)))
expect(described_class.for_issues_in_group(group)).to contain_exactly(timelog1, timelog2)
end
end
describe 'between_dates' do
it 'returns collection of timelogs within given dates' do
create(:timelog, spent_at: 65.days.ago)
timelog1 = create(:timelog, spent_at: 15.days.ago)
timelog2 = create(:timelog, spent_at: 5.days.ago)
timelogs = described_class.between_dates(20.days.ago, 1.day.ago)
expect(timelogs).to contain_exactly(timelog1, timelog2)
end
end
end
end end
...@@ -64,6 +64,12 @@ RSpec::Matchers.define :have_graphql_type do |expected| ...@@ -64,6 +64,12 @@ RSpec::Matchers.define :have_graphql_type do |expected|
end end
end end
RSpec::Matchers.define :have_non_null_graphql_type do |expected|
match do |field|
expect(field.type).to eq(!expected.to_graphql)
end
end
RSpec::Matchers.define :have_graphql_resolver do |expected| RSpec::Matchers.define :have_graphql_resolver do |expected|
match do |field| match do |field|
case expected case expected
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment