Commit 7458ca8e authored by Jan Provaznik's avatar Jan Provaznik Committed by Jarka Kadlecová

[backend] Addressed review comments

* Group filtering now includes also issues/MRs from
subgroups/subprojects
* fixed due_date
* Also DRYed todo controller specs
parent 57a44f2d
module TodosActions
include Gitlab::Utils::StrongMemoize
extend ActiveSupport::Concern
def create
todo = TodoService.new.mark_todo(issuable, current_user)
render json: {
count: TodosFinder.new(current_user, state: :pending).execute.count,
delete_path: dashboard_todo_path(todo)
}
end
end
...@@ -70,7 +70,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController ...@@ -70,7 +70,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController
end end
def todo_params def todo_params
params.permit(:action_id, :author_id, :project_id, :type, :sort, :state) params.permit(:action_id, :author_id, :project_id, :type, :sort, :state, :group_id)
end end
def redirect_out_of_range(todos) def redirect_out_of_range(todos)
......
class Projects::TodosController < Projects::ApplicationController class Projects::TodosController < Projects::ApplicationController
before_action :authenticate_user!, only: [:create] include TodosActions
def create
todo = TodoService.new.mark_todo(issuable, current_user)
render json: { before_action :authenticate_user!, only: [:create]
count: TodosFinder.new(current_user, state: :pending).execute.count,
delete_path: dashboard_todo_path(todo)
}
end
private private
def issuable def issuable
@issuable ||= begin strong_memoize(:issuable) do
case params[:issuable_type] case params[:issuable_type]
when "issue" when "issue"
IssuesFinder.new(current_user, project_id: @project.id).find(params[:issuable_id]) IssuesFinder.new(current_user, project_id: @project.id).find(params[:issuable_id])
......
...@@ -113,16 +113,6 @@ class TodosFinder ...@@ -113,16 +113,6 @@ class TodosFinder
end end
end end
def project_ids(items)
ids = items.except(:order).select(:project_id)
if Gitlab::Database.mysql?
# To make UPDATE work on MySQL, wrap it in a SELECT with an alias
ids = Todo.except(:order).select('*').from("(#{ids.to_sql}) AS t")
end
ids
end
def type? def type?
type.present? && %w(Issue MergeRequest Epic).include?(type) type.present? && %w(Issue MergeRequest Epic).include?(type)
end end
...@@ -169,7 +159,12 @@ class TodosFinder ...@@ -169,7 +159,12 @@ class TodosFinder
def by_group(items) def by_group(items)
if group? if group?
items = items.where(group: group) groups = group.self_and_descendants
items = items.where(
'project_id IN (?) OR group_id IN (?)',
Project.where(group: groups).select(:id),
groups.select(:id)
)
end end
items items
...@@ -184,8 +179,8 @@ class TodosFinder ...@@ -184,8 +179,8 @@ class TodosFinder
.joins('LEFT JOIN projects ON projects.id = todos.project_id') .joins('LEFT JOIN projects ON projects.id = todos.project_id')
.where( .where(
'project_id IN (?) OR group_id IN (?)', 'project_id IN (?) OR group_id IN (?)',
projects.map(&:id), projects.select(:id),
groups.map(&:id) groups.select(:id)
) )
end end
......
...@@ -243,6 +243,12 @@ module Issuable ...@@ -243,6 +243,12 @@ module Issuable
opened? opened?
end end
def overdue?
return false unless respond_to?(:due_date)
due_date.try(:past?) || false
end
def user_notes_count def user_notes_count
if notes.loaded? if notes.loaded?
# Use the in-memory association to select and count to avoid hitting the db # Use the in-memory association to select and count to avoid hitting the db
......
...@@ -275,10 +275,6 @@ class Issue < ActiveRecord::Base ...@@ -275,10 +275,6 @@ class Issue < ActiveRecord::Base
user ? readable_by?(user) : publicly_visible? user ? readable_by?(user) : publicly_visible?
end end
def overdue?
due_date.try(:past?) || false
end
def check_for_spam? def check_for_spam?
project.public? && (title_changed? || description_changed?) project.public? && (title_changed? || description_changed?)
end end
......
...@@ -229,6 +229,10 @@ class Note < ActiveRecord::Base ...@@ -229,6 +229,10 @@ class Note < ActiveRecord::Base
!for_personal_snippet? !for_personal_snippet?
end end
def for_issuable_with_ability?
for_issue? || for_merge_request?
end
def skip_project_check? def skip_project_check?
!for_project_noteable? !for_project_noteable?
end end
......
...@@ -32,8 +32,8 @@ class Todo < ActiveRecord::Base ...@@ -32,8 +32,8 @@ class Todo < ActiveRecord::Base
validates :author, presence: true validates :author, presence: true
validates :target_id, presence: true, unless: :for_commit? validates :target_id, presence: true, unless: :for_commit?
validates :commit_id, presence: true, if: :for_commit? validates :commit_id, presence: true, if: :for_commit?
validates :project, presence: true, unless: :group validates :project, presence: true, unless: :group_id
validates :group, presence: true, unless: :project validates :group, presence: true, unless: :project_id
scope :pending, -> { with_state(:pending) } scope :pending, -> { with_state(:pending) }
scope :done, -> { with_state(:done) } scope :done, -> { with_state(:done) }
......
...@@ -285,6 +285,7 @@ class TodoService ...@@ -285,6 +285,7 @@ class TodoService
def attributes_for_target(target) def attributes_for_target(target)
attributes = { attributes = {
project_id: target&.project&.id, project_id: target&.project&.id,
group_id: target.respond_to?(:group) ? target.group_id : nil,
target_id: target.id, target_id: target.id,
target_type: target.class.name, target_type: target.class.name,
commit_id: nil commit_id: nil
...@@ -300,7 +301,6 @@ class TodoService ...@@ -300,7 +301,6 @@ class TodoService
def attributes_for_todo(project, target, author, action, note = nil) def attributes_for_todo(project, target, author, action, note = nil)
attributes_for_target(target).merge!( attributes_for_target(target).merge!(
project_id: project&.id, project_id: project&.id,
group_id: target.respond_to?(:group) ? target.group.id : nil,
author_id: author.id, author_id: author.id,
action: action, action: action,
note: note note: note
...@@ -322,7 +322,7 @@ class TodoService ...@@ -322,7 +322,7 @@ class TodoService
end end
def reject_users_without_access(users, parent, target) def reject_users_without_access(users, parent, target)
if target.is_a?(Note) && (target.for_issue? || target.for_merge_request? || target.for_epic?) if target.is_a?(Note) && target.for_issuable_with_ability?
target = target.noteable target = target.noteable
end end
......
...@@ -7,7 +7,7 @@ class AddGroupToTodos < ActiveRecord::Migration ...@@ -7,7 +7,7 @@ class AddGroupToTodos < ActiveRecord::Migration
def up def up
add_column :todos, :group_id, :integer add_column :todos, :group_id, :integer
add_foreign_key :todos, :namespaces, column: :group_id, on_delete: :cascade add_concurrent_foreign_key :todos, :namespaces, column: :group_id, on_delete: :cascade
add_concurrent_index :todos, :group_id add_concurrent_index :todos, :group_id
change_column_null :todos, :project_id, true change_column_null :todos, :project_id, true
......
...@@ -18,6 +18,7 @@ Parameters: ...@@ -18,6 +18,7 @@ Parameters:
| `action` | string | no | The action to be filtered. Can be `assigned`, `mentioned`, `build_failed`, `marked`, `approval_required`, `unmergeable` or `directly_addressed`. | | `action` | string | no | The action to be filtered. Can be `assigned`, `mentioned`, `build_failed`, `marked`, `approval_required`, `unmergeable` or `directly_addressed`. |
| `author_id` | integer | no | The ID of an author | | `author_id` | integer | no | The ID of an author |
| `project_id` | integer | no | The ID of a project | | `project_id` | integer | no | The ID of a project |
| `group_id` | integer | no | The ID of a group |
| `state` | string | no | The state of the todo. Can be either `pending` or `done` | | `state` | string | no | The state of the todo. Can be either `pending` or `done` |
| `type` | string | no | The type of a todo. Can be either `Issue` or `MergeRequest` | | `type` | string | no | The type of a todo. Can be either `Issue` or `MergeRequest` |
......
...@@ -769,14 +769,14 @@ module API ...@@ -769,14 +769,14 @@ module API
class Todo < Grape::Entity class Todo < Grape::Entity
expose :id expose :id
expose :project, using: Entities::ProjectIdentity, if: -> (todo, _) { todo.project } expose :project, using: Entities::ProjectIdentity, if: -> (todo, _) { todo.project_id }
expose :group, using: 'API::Entities::NamespaceBasic', if: -> (todo, _) { todo.group } expose :group, using: 'API::Entities::NamespaceBasic', if: -> (todo, _) { todo.group_id }
expose :author, using: Entities::UserBasic expose :author, using: Entities::UserBasic
expose :action_name expose :action_name
expose :target_type expose :target_type
expose :target do |todo, options| expose :target do |todo, options|
Entities.const_get(todo.target_type).represent(todo.target, options) todo_target_class(todo.target_type).represent(todo.target, options)
end end
expose :target_url do |todo, options| expose :target_url do |todo, options|
...@@ -792,6 +792,10 @@ module API ...@@ -792,6 +792,10 @@ module API
expose :body expose :body
expose :state expose :state
expose :created_at expose :created_at
def todo_target_class(target_type)
::API::Entities.const_get(target_type)
end
end end
class NamespaceBasic < Grape::Entity class NamespaceBasic < Grape::Entity
......
...@@ -5,140 +5,53 @@ describe Projects::TodosController do ...@@ -5,140 +5,53 @@ describe Projects::TodosController do
let(:project) { create(:project) } let(:project) { create(:project) }
let(:issue) { create(:issue, project: project) } let(:issue) { create(:issue, project: project) }
let(:merge_request) { create(:merge_request, source_project: project) } let(:merge_request) { create(:merge_request, source_project: project) }
let(:parent) { project }
context 'Issues' do shared_examples 'project todos actions' do
describe 'POST create' do it_behaves_like 'todos actions'
def go
post :create,
namespace_id: project.namespace,
project_id: project,
issuable_id: issue.id,
issuable_type: 'issue',
format: 'html'
end
context 'when authorized' do context 'when not authorized for resource' do
before do
sign_in(user)
project.add_developer(user)
end
it 'creates todo for issue' do
expect do
go
end.to change { user.todos.count }.by(1)
expect(response).to have_gitlab_http_status(200)
end
it 'returns todo path and pending count' do
go
expect(response).to have_gitlab_http_status(200)
expect(json_response['count']).to eq 1
expect(json_response['delete_path']).to match(%r{/dashboard/todos/\d{1}})
end
end
context 'when not authorized for project' do
it 'does not create todo for issue that user has no access to' do
sign_in(user)
expect do
go
end.to change { user.todos.count }.by(0)
expect(response).to have_gitlab_http_status(404)
end
it 'does not create todo for issue when user not logged in' do
expect do
go
end.to change { user.todos.count }.by(0)
expect(response).to have_gitlab_http_status(302)
end
end
context 'when not authorized for issue' do
before do before do
project.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC) project.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
project.project_feature.update!(issues_access_level: ProjectFeature::PRIVATE) project.project_feature.update!(issues_access_level: ProjectFeature::PRIVATE)
project.project_feature.update!(merge_requests_access_level: ProjectFeature::PRIVATE)
sign_in(user) sign_in(user)
end end
it "doesn't create todo" do it "doesn't create todo" do
expect { go }.not_to change { user.todos.count } expect { post_create }.not_to change { user.todos.count }
expect(response).to have_gitlab_http_status(404) expect(response).to have_gitlab_http_status(404)
end end
end end
end end
end
context 'Merge Requests' do context 'Issues' do
describe 'POST create' do describe 'POST create' do
def go def post_create
post :create, post :create,
namespace_id: project.namespace, namespace_id: project.namespace,
project_id: project, project_id: project,
issuable_id: merge_request.id, issuable_id: issue.id,
issuable_type: 'merge_request', issuable_type: 'issue',
format: 'html' format: 'html'
end end
context 'when authorized' do it_behaves_like 'project todos actions'
before do
sign_in(user)
project.add_developer(user)
end
it 'creates todo for merge request' do
expect do
go
end.to change { user.todos.count }.by(1)
expect(response).to have_gitlab_http_status(200)
end
it 'returns todo path and pending count' do
go
expect(response).to have_gitlab_http_status(200)
expect(json_response['count']).to eq 1
expect(json_response['delete_path']).to match(%r{/dashboard/todos/\d{1}})
end end
end end
context 'when not authorized for project' do context 'Merge Requests' do
it 'does not create todo for merge request user has no access to' do describe 'POST create' do
sign_in(user) def post_create
expect do post :create,
go namespace_id: project.namespace,
end.to change { user.todos.count }.by(0) project_id: project,
issuable_id: merge_request.id,
expect(response).to have_gitlab_http_status(404) issuable_type: 'merge_request',
end format: 'html'
it 'does not create todo for merge request user has no access to' do
expect do
go
end.to change { user.todos.count }.by(0)
expect(response).to have_gitlab_http_status(302)
end
end
context 'when not authorized for merge_request' do
before do
project.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
project.project_feature.update!(merge_requests_access_level: ProjectFeature::PRIVATE)
sign_in(user)
end end
it "doesn't create todo" do it_behaves_like 'project todos actions'
expect { go }.not_to change { user.todos.count }
expect(response).to have_gitlab_http_status(404)
end
end
end end
end end
end end
FactoryBot.define do FactoryBot.define do
factory :todo do factory :todo do
project project
group
author { project&.creator || user } author { project&.creator || user }
user { project&.creator || user } user { project&.creator || user }
target factory: :issue target factory: :issue
......
...@@ -53,7 +53,7 @@ describe TodosFinder do ...@@ -53,7 +53,7 @@ describe TodosFinder do
it 'returns correct todos when filtered by a group' do it 'returns correct todos when filtered by a group' do
todos = finder.new(user, { group_id: group.id }).execute todos = finder.new(user, { group_id: group.id }).execute
expect(todos).to match_array([todo2]) expect(todos).to match_array([todo1, todo2])
end end
it 'returns correct todos when filtered by a type' do it 'returns correct todos when filtered by a type' do
...@@ -61,6 +61,17 @@ describe TodosFinder do ...@@ -61,6 +61,17 @@ describe TodosFinder do
expect(todos).to match_array([todo1]) expect(todos).to match_array([todo1])
end end
context 'with subgroups', :nested_groups do
let(:subgroup) { create(:group, parent: group) }
let!(:todo3) { create(:todo, user: user, group: subgroup, target: issue) }
it 'returns todos from subgroups when filtered by a group' do
todos = finder.new(user, { group_id: group.id }).execute
expect(todos).to match_array([todo1, todo2, todo3])
end
end
end end
end end
......
shared_examples 'todos actions' do
context 'when authorized' do
before do
sign_in(user)
parent.add_developer(user)
end
it 'creates todo' do
expect do
post_create
end.to change { user.todos.count }.by(1)
expect(response).to have_gitlab_http_status(200)
end
it 'returns todo path and pending count' do
post_create
expect(response).to have_gitlab_http_status(200)
expect(json_response['count']).to eq 1
expect(json_response['delete_path']).to match(%r{/dashboard/todos/\d{1}})
end
end
context 'when not authorized for project/group' do
it 'does not create todo for resource that user has no access to' do
sign_in(user)
expect do
post_create
end.to change { user.todos.count }.by(0)
expect(response).to have_gitlab_http_status(404)
end
it 'does not create todo when user is not logged in' do
expect do
post_create
end.to change { user.todos.count }.by(0)
expect(response).to have_gitlab_http_status(parent.is_a?(Group) ? 401 : 302)
end
end
end
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