From e6d66c4d3b8bdaa4abc85f3f35e0b06b785008da Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Sun, 12 Jun 2016 15:15:58 +0200 Subject: [PATCH 01/36] Don't fail builds for projects that are deleted when they are stuck --- CHANGELOG | 1 + app/workers/stuck_ci_builds_worker.rb | 2 +- spec/workers/stuck_ci_builds_worker_spec.rb | 19 ++++++++++++++++--- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 8f897b4a34..59a335e9d8 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -21,6 +21,7 @@ v 8.9.0 (unreleased) - Redesign navigation for project pages - Fix groups API to list only user's accessible projects - Redesign account and email confirmation emails + - Don't fail builds for projects that are deleted - `git clone https://host/namespace/project` now works, in addition to using the `.git` suffix - Bump nokogiri to 1.6.8 - Use gitlab-shell v3.0.0 diff --git a/app/workers/stuck_ci_builds_worker.rb b/app/workers/stuck_ci_builds_worker.rb index ca594e77e7..6828013b37 100644 --- a/app/workers/stuck_ci_builds_worker.rb +++ b/app/workers/stuck_ci_builds_worker.rb @@ -6,7 +6,7 @@ class StuckCiBuildsWorker def perform Rails.logger.info 'Cleaning stuck builds' - builds = Ci::Build.running_or_pending.where('updated_at < ?', BUILD_STUCK_TIMEOUT.ago) + builds = Ci::Build.joins(:project).running_or_pending.where('ci_builds.updated_at < ?', BUILD_STUCK_TIMEOUT.ago) builds.find_each(batch_size: 50).each do |build| Rails.logger.debug "Dropping stuck #{build.status} build #{build.id} for runner #{build.runner_id}" build.drop diff --git a/spec/workers/stuck_ci_builds_worker_spec.rb b/spec/workers/stuck_ci_builds_worker_spec.rb index 665ec20f22..801fa31b45 100644 --- a/spec/workers/stuck_ci_builds_worker_spec.rb +++ b/spec/workers/stuck_ci_builds_worker_spec.rb @@ -2,6 +2,7 @@ require "spec_helper" describe StuckCiBuildsWorker do let!(:build) { create :ci_build } + let(:worker) { described_class.new } subject do build.reload @@ -16,13 +17,13 @@ describe StuckCiBuildsWorker do it 'gets dropped if it was updated over 2 days ago' do build.update!(updated_at: 2.days.ago) - StuckCiBuildsWorker.new.perform + worker.perform is_expected.to eq('failed') end it "is still #{status}" do build.update!(updated_at: 1.minute.ago) - StuckCiBuildsWorker.new.perform + worker.perform is_expected.to eq(status) end end @@ -36,9 +37,21 @@ describe StuckCiBuildsWorker do it "is still #{status}" do build.update!(updated_at: 2.days.ago) - StuckCiBuildsWorker.new.perform + worker.perform is_expected.to eq(status) end end end + + context "for deleted project" do + before do + build.update!(status: :running, updated_at: 2.days.ago) + build.project.update(pending_delete: true) + end + + it "does not drop build" do + expect_any_instance_of(Ci::Build).not_to receive(:drop) + worker.perform + end + end end From ec7cdc18c875a06686ff575d0d3b1dcb0a0e6d35 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Sun, 12 Jun 2016 19:13:14 -0700 Subject: [PATCH 02/36] Fix typo in obtaining a backtrace from all threads in gdb Also add command to turn off pagination [ci skip] --- doc/administration/troubleshooting/sidekiq.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/doc/administration/troubleshooting/sidekiq.md b/doc/administration/troubleshooting/sidekiq.md index a776cd3f05..b71f8fabbc 100644 --- a/doc/administration/troubleshooting/sidekiq.md +++ b/doc/administration/troubleshooting/sidekiq.md @@ -147,7 +147,8 @@ bt To output a backtrace from all threads at once: ``` -apply all thread bt +set pagination off +thread apply all bt ``` Once you're done debugging with `gdb`, be sure to detach from the process and From 5e955d2cba4b3f0508874ef49fa549a638d4944c Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Mon, 13 Jun 2016 10:20:59 +0100 Subject: [PATCH 03/36] Aligned the two navs horizontally Closes #18513 --- app/assets/stylesheets/framework/nav.scss | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/app/assets/stylesheets/framework/nav.scss b/app/assets/stylesheets/framework/nav.scss index 4de89daeb3..71fd75b61f 100644 --- a/app/assets/stylesheets/framework/nav.scss +++ b/app/assets/stylesheets/framework/nav.scss @@ -280,11 +280,9 @@ } .dropdown { - margin-left: 7px; - - @media (max-width: $screen-xs-min) { - margin-left: 0; - } + position: absolute; + top: 7px; + right: 15px; li.active { font-weight: bold; From 29130f37ad15bf5aa4ac2cf62d0ea8249218dcd6 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Mon, 13 Jun 2016 15:58:09 +0100 Subject: [PATCH 04/36] Hides the fade right unless required --- app/assets/javascripts/layout_nav.js.coffee | 42 ++++++++++++++------- app/assets/stylesheets/framework/nav.scss | 1 + 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/app/assets/javascripts/layout_nav.js.coffee b/app/assets/javascripts/layout_nav.js.coffee index 6adac6dac9..f02292dd4f 100644 --- a/app/assets/javascripts/layout_nav.js.coffee +++ b/app/assets/javascripts/layout_nav.js.coffee @@ -1,14 +1,30 @@ -class @LayoutNav - $ -> - $('.fade-left').addClass('end-scroll') - $('.scrolling-tabs').on 'scroll', (event) -> - $this = $(this) - $el = $(event.target) - currentPosition = $this.scrollLeft() - size = bp.getBreakpointSize() - controlBtnWidth = $('.controls').width() - maxPosition = $this.get(0).scrollWidth - $this.parent().width() - maxPosition += controlBtnWidth if size isnt 'xs' and $('.nav-control').length +hideEndFade = ($scrollingTabs) -> + $scrollingTabs.each -> + $this = $(@) - $el.find('.fade-left').toggleClass('end-scroll', currentPosition is 0) - $el.find('.fade-right').toggleClass('end-scroll', currentPosition is maxPosition) + $this + .find('.fade-right') + .toggleClass('end-scroll', $this.width() is $this.prop('scrollWidth')) + +$ -> + $('.fade-left').addClass('end-scroll') + + hideEndFade($('.scrolling-tabs')) + + $(window) + .off 'resize.nav' + .on 'resize.nav', -> + hideEndFade($('.scrolling-tabs')) + + $('.scrolling-tabs').on 'scroll', (event) -> + $this = $(this) + $el = $(event.target) + currentPosition = $this.scrollLeft() + size = bp.getBreakpointSize() + controlBtnWidth = $('.controls').width() + maxPosition = ($this.get(0).scrollWidth - $this.parent().width()) - 1 + # maxPosition += controlBtnWidth if size isnt 'xs' and $('.nav-control').length + console.log maxPosition, currentPosition + + $el.find('.fade-left').toggleClass('end-scroll', currentPosition is 0) + $el.find('.fade-right').toggleClass('end-scroll', currentPosition is maxPosition) diff --git a/app/assets/stylesheets/framework/nav.scss b/app/assets/stylesheets/framework/nav.scss index 71fd75b61f..1222dc9047 100644 --- a/app/assets/stylesheets/framework/nav.scss +++ b/app/assets/stylesheets/framework/nav.scss @@ -283,6 +283,7 @@ position: absolute; top: 7px; right: 15px; + z-index: 2; li.active { font-weight: bold; From e20aa4581b0d17eae36d9722ee8789af47f57727 Mon Sep 17 00:00:00 2001 From: Robert Speicher Date: Mon, 13 Jun 2016 16:23:38 -0400 Subject: [PATCH 05/36] Fix note polling when a window has been hidden `refresh` was called, `refreshing` was set to true, but then because `document.hidden` was true, `getContent` was never called, and `refreshing` never got reset to `false`, which stopped polling entirely until refresh. --- CHANGELOG | 2 ++ app/assets/javascripts/notes.js.coffee | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 7a6a14919d..a5e5c5c5c8 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -8,6 +8,8 @@ v 8.9.0 (unreleased) - Allow enabling wiki page events from Webhook management UI - Bump rouge to 1.11.0 - Fix issue with arrow keys not working in search autocomplete dropdown + - Fix an issue where note polling stopped working if a window was in the + background during a refresh. - Make EmailsOnPushWorker use Sidekiq mailers queue - Fix wiki page events' webhook to point to the wiki repository - Don't show tags for revert and cherry-pick operations diff --git a/app/assets/javascripts/notes.js.coffee b/app/assets/javascripts/notes.js.coffee index ad216910c8..e2d3241437 100644 --- a/app/assets/javascripts/notes.js.coffee +++ b/app/assets/javascripts/notes.js.coffee @@ -115,12 +115,14 @@ class @Notes , @pollingInterval refresh: => - return if @refreshing is true - @refreshing = true if not document.hidden and document.URL.indexOf(@noteable_url) is 0 @getContent() getContent: -> + return if @refreshing + + @refreshing = true + $.ajax url: @notes_url data: "last_fetched_at=" + @last_fetched_at From f67b06ada016915211e84a7d12a063aa25e422f3 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 7 Jun 2016 09:44:01 +0100 Subject: [PATCH 06/36] Manually create todo for issuable Added a button into the sidebar for issues & merge requests to allow users to manually create todo items Closes #15045 --- .../javascripts/right_sidebar.js.coffee | 41 ++++++++++++++++++- app/assets/stylesheets/pages/issuable.scss | 12 +++--- app/controllers/projects/issues_controller.rb | 14 +++++++ .../projects/merge_requests_controller.rb | 14 +++++++ app/finders/todos_finder.rb | 2 +- app/helpers/issuables_helper.rb | 14 +++++++ app/models/todo.rb | 1 + app/services/todo_service.rb | 6 +++ app/views/layouts/header/_default.html.haml | 5 +-- app/views/shared/issuable/_sidebar.html.haml | 13 +++++- config/routes.rb | 2 + 11 files changed, 111 insertions(+), 13 deletions(-) diff --git a/app/assets/javascripts/right_sidebar.js.coffee b/app/assets/javascripts/right_sidebar.js.coffee index c9cb0f4bb3..3ee943fe78 100644 --- a/app/assets/javascripts/right_sidebar.js.coffee +++ b/app/assets/javascripts/right_sidebar.js.coffee @@ -43,6 +43,45 @@ class @Sidebar $('.right-sidebar') .hasClass('right-sidebar-collapsed'), { path: '/' }) + $(document) + .off 'click', '.js-issuable-todo' + .on 'click', '.js-issuable-todo', @toggleTodo + + toggleTodo: (e) -> + $this = $(@) + $btnText = $this.find('span') + data = { + todo_id: $this.attr('data-id') + } + + $.ajax( + url: $this.data('url') + type: 'POST' + dataType: 'json' + data: data + beforeSend: -> + $this.disable() + $('.js-issuable-todo-loading').removeClass 'hidden' + ).done (data) -> + $todoPendingCount = $('.todos-pending-count') + $todoPendingCount.text data.count + + $this.enable() + $('.js-issuable-todo-loading').addClass 'hidden' + + if data.count is 0 + $this.removeAttr 'data-id' + $btnText.text $this.data('todo-text') + + $todoPendingCount + .addClass 'hidden' + else + $btnText.text $this.data('mark-text') + $todoPendingCount + .removeClass 'hidden' + + if data.todo? + $this.attr 'data-id', data.todo.id sidebarDropdownLoading: (e) -> $sidebarCollapsedIcon = $(@).closest('.block').find('.sidebar-collapsed-icon') @@ -117,5 +156,3 @@ class @Sidebar getBlock: (name) -> @sidebar.find(".block.#{name}") - - diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index ea453ce356..acbb7e7f71 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -34,6 +34,10 @@ color: inherit; } + .issuable-header-text { + margin-top: 7px; + } + .block { @include clearfix; padding: $gl-padding 0; @@ -60,10 +64,6 @@ margin-top: 0; } - .issuable-count { - margin-top: 7px; - } - .gutter-toggle { margin-left: 20px; padding-left: 10px; @@ -250,7 +250,7 @@ } } - .issuable-pager { + .issuable-header-btn { background: $gray-normal; border: 1px solid $border-gray-normal; &:hover { @@ -263,7 +263,7 @@ } } - a:not(.issuable-pager) { + a:not(.issuable-header-btn) { &:hover { color: $md-link-color; text-decoration: none; diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 4e2d3bebb2..5678d584d4 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -164,6 +164,20 @@ class Projects::IssuesController < Projects::ApplicationController end end + def todo + json_data = Hash.new + + if params[:todo_id].nil? + TodoService.new.mark_todo(issue, current_user) + + json_data[:todo] = current_user.todos.find_by(state: :pending, action: Todo::MARKED, target_id: issue.id) + else + current_user.todos.find_by_id(params[:todo_id]).update(state: :done) + end + + render json: json_data.merge({ count: current_user.todos.pending.count }) + end + protected def issue diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 67e7187c10..f0eba453ca 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -260,6 +260,20 @@ class Projects::MergeRequestsController < Projects::ApplicationController render json: response end + def todo + json_data = Hash.new + + if params[:todo_id].nil? + TodoService.new.mark_todo(merge_request, current_user) + + json_data[:todo] = current_user.todos.find_by(state: :pending, action: Todo::MARKED, target_id: merge_request.id) + else + current_user.todos.find_by_id(params[:todo_id]).update(state: :done) + end + + render json: json_data.merge({ count: current_user.todos.pending.count }) + end + protected def selected_target_project diff --git a/app/finders/todos_finder.rb b/app/finders/todos_finder.rb index 1d88116d7d..aa47c6c157 100644 --- a/app/finders/todos_finder.rb +++ b/app/finders/todos_finder.rb @@ -36,7 +36,7 @@ class TodosFinder private def action_id? - action_id.present? && [Todo::ASSIGNED, Todo::MENTIONED, Todo::BUILD_FAILED].include?(action_id.to_i) + action_id.present? && [Todo::ASSIGNED, Todo::MENTIONED, Todo::BUILD_FAILED, Todo::MARKED].include?(action_id.to_i) end def action_id diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 40d8ce8a1d..88ef1a6468 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -67,6 +67,20 @@ module IssuablesHelper end end + def issuable_todo_path(issuable) + project = issuable.project + + if issuable.kind_of?(MergeRequest) + todo_namespace_project_merge_request_path(project.namespace, project, issuable.iid, :json) + else + todo_namespace_project_issue_path(project.namespace, project, issuable.iid, :json) + end + end + + def has_todo(issuable) + current_user.todos.find_by(target_id: issuable.id, state: :pending) + end + private def sidebar_gutter_collapsed? diff --git a/app/models/todo.rb b/app/models/todo.rb index 3a09137332..2792fa9b9a 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -2,6 +2,7 @@ class Todo < ActiveRecord::Base ASSIGNED = 1 MENTIONED = 2 BUILD_FAILED = 3 + MARKED = 4 belongs_to :author, class_name: "User" belongs_to :note diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb index 8e03ff8ddd..5a192e54f2 100644 --- a/app/services/todo_service.rb +++ b/app/services/todo_service.rb @@ -139,6 +139,12 @@ class TodoService pending_todos(user, attributes).update_all(state: :done) end + # When user marks an issue as todo + def mark_todo(issuable, current_user) + attributes = attributes_for_todo(issuable.project, issuable, current_user, Todo::MARKED) + create_todos(current_user, attributes) + end + private def create_todos(users, attributes) diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index ad30a367fc..ebc9f01675 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -27,9 +27,8 @@ %li = link_to dashboard_todos_path, title: 'Todos', data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = icon('bell fw') - - unless todos_pending_count == 0 - %span.badge.todos-pending-count - = todos_pending_count + %span.badge.todos-pending-count{ class: ("hidden" if todos_pending_count == 0)} + = todos_pending_count - if current_user.can_create_project? %li = link_to new_project_path, title: 'New project', data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index fb906de829..25d830b6e4 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -1,9 +1,20 @@ +- todo = has_todo(issuable) %aside.right-sidebar{ class: sidebar_gutter_collapsed_class } .issuable-sidebar - can_edit_issuable = can?(current_user, :"admin_#{issuable.to_ability_name}", @project) .block.issuable-sidebar-header - %a.gutter-toggle.pull-right.js-sidebar-toggle{href: '#'} + %span.issuable-header-text.hide-collapsed.pull-left + Todo + %button.gutter-toggle.pull-right.js-sidebar-toggle{ type: "button", aria: { label: "Toggle sidebar" } } = sidebar_gutter_toggle_icon + %button.btn.btn-default.issuable-header-btn.pull-right.js-issuable-todo{ type: "button", data: { todo_text: "Add Todo", mark_text: "Mark Done", id: (todo.id unless todo.nil?), url: issuable_todo_path(issuable) } } + - if todo.nil? + %span + Add Todo + - else + %span + Mark Done + = icon('spin spinner', class: 'hidden js-issuable-todo-loading') = form_for [@project.namespace.becomes(Namespace), @project, issuable], remote: true, format: :json, html: {class: 'issuable-context-form inline-update js-issuable-update'} do |f| .block.assignee diff --git a/config/routes.rb b/config/routes.rb index 95fbe7dd9d..d018fa742c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -679,6 +679,7 @@ Rails.application.routes.draw do post :toggle_subscription post :toggle_award_emoji post :remove_wip + post :todo end collection do @@ -759,6 +760,7 @@ Rails.application.routes.draw do get :referenced_merge_requests get :related_branches get :can_create_branch + post :todo end collection do post :bulk_update From 1e762c0609d31942c05101ca7d38fa1572ec35a2 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 7 Jun 2016 09:51:07 +0100 Subject: [PATCH 07/36] todo title text update for manual todos --- app/helpers/todos_helper.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb index b4923fbb13..6cfc86dfb9 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -12,6 +12,7 @@ module TodosHelper when Todo::ASSIGNED then 'assigned you' when Todo::MENTIONED then 'mentioned you on' when Todo::BUILD_FAILED then 'The build failed for your' + when Todo::MARKED then 'todo' end end From 82be673bec39f626cc97bdaa24007684404fc25e Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 7 Jun 2016 09:52:03 +0100 Subject: [PATCH 08/36] Fixed issue with sidebar button styling --- app/views/shared/issuable/_sidebar.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 25d830b6e4..baeee7f57e 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -5,7 +5,7 @@ .block.issuable-sidebar-header %span.issuable-header-text.hide-collapsed.pull-left Todo - %button.gutter-toggle.pull-right.js-sidebar-toggle{ type: "button", aria: { label: "Toggle sidebar" } } + %a.gutter-toggle.pull-right.js-sidebar-toggle{ role: "button", href: "#", aria: { label: "Toggle sidebar" } } = sidebar_gutter_toggle_icon %button.btn.btn-default.issuable-header-btn.pull-right.js-issuable-todo{ type: "button", data: { todo_text: "Add Todo", mark_text: "Mark Done", id: (todo.id unless todo.nil?), url: issuable_todo_path(issuable) } } - if todo.nil? From 05525b5531f570e144341faad7428a6099a82710 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 7 Jun 2016 09:55:53 +0100 Subject: [PATCH 09/36] Fixed issue with todo button not updating state This would happen when a todo already exists, the state of the button wouldn't update after the ajax call --- app/assets/javascripts/right_sidebar.js.coffee | 8 ++++---- app/assets/stylesheets/pages/issuable.scss | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/right_sidebar.js.coffee b/app/assets/javascripts/right_sidebar.js.coffee index 3ee943fe78..def735d3b4 100644 --- a/app/assets/javascripts/right_sidebar.js.coffee +++ b/app/assets/javascripts/right_sidebar.js.coffee @@ -70,18 +70,18 @@ class @Sidebar $('.js-issuable-todo-loading').addClass 'hidden' if data.count is 0 - $this.removeAttr 'data-id' - $btnText.text $this.data('todo-text') - $todoPendingCount .addClass 'hidden' else - $btnText.text $this.data('mark-text') $todoPendingCount .removeClass 'hidden' if data.todo? + $btnText.text $this.data('mark-text') $this.attr 'data-id', data.todo.id + else + $this.removeAttr 'data-id' + $btnText.text $this.data('todo-text') sidebarDropdownLoading: (e) -> $sidebarCollapsedIcon = $(@).closest('.block').find('.sidebar-collapsed-icon') diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index acbb7e7f71..f57845ad9c 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -263,7 +263,7 @@ } } - a:not(.issuable-header-btn) { + a { &:hover { color: $md-link-color; text-decoration: none; From a1be3241ec1f91182435a10615beac15fcfe235a Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 7 Jun 2016 10:18:57 +0100 Subject: [PATCH 10/36] Todo tests and CHANGELOG --- CHANGELOG | 1 + spec/features/issues/todo_spec.rb | 33 +++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 spec/features/issues/todo_spec.rb diff --git a/CHANGELOG b/CHANGELOG index 3387394de5..ae62b6b4c4 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -73,6 +73,7 @@ v 8.9.0 (unreleased) - Put project Labels and Milestones pages links under Issues and Merge Requests tabs as subnav - All classes in the Banzai::ReferenceParser namespace are now instrumented - Remove deprecated issues_tracker and issues_tracker_id from project model + - Manually mark a issue or merge request as a todo v 8.8.5 (unreleased) - Ensure branch cleanup regardless of whether the GitHub import process succeeds diff --git a/spec/features/issues/todo_spec.rb b/spec/features/issues/todo_spec.rb new file mode 100644 index 0000000000..b69cce3e7d --- /dev/null +++ b/spec/features/issues/todo_spec.rb @@ -0,0 +1,33 @@ +require 'rails_helper' + +feature 'Manually create a todo item from issue', feature: true, js: true do + let!(:project) { create(:project) } + let!(:issue) { create(:issue, project: project) } + let!(:user) { create(:user)} + + before do + project.team << [user, :master] + login_as(user) + visit namespace_project_issue_path(project.namespace, project, issue) + end + + it 'should create todo when clicking button' do + page.within '.issuable-sidebar' do + click_button 'Add Todo' + expect(page).to have_content 'Mark Done' + end + + page.within '.header-content .todos-pending-count' do + expect(page).to have_content '1' + end + end + + it 'should mark a todo as done' do + page.within '.issuable-sidebar' do + click_button 'Add Todo' + click_button 'Mark Done' + end + + expect(page).to have_selector('.todos-pending-count', visible: false) + end +end From 04c199a0ab2db012e8c5a190ce2836f22e846305 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 7 Jun 2016 10:54:02 +0100 Subject: [PATCH 11/36] Fixed bug with sidebar when user is not logged in --- app/helpers/issuables_helper.rb | 4 +++- app/views/shared/issuable/_sidebar.html.haml | 22 +++++++++++--------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 88ef1a6468..2ae7f5c5f3 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -78,7 +78,9 @@ module IssuablesHelper end def has_todo(issuable) - current_user.todos.find_by(target_id: issuable.id, state: :pending) + unless current_user.nil? + current_user.todos.find_by(target_id: issuable.id, state: :pending) + end end private diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index baeee7f57e..e3aacb50c9 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -3,18 +3,20 @@ .issuable-sidebar - can_edit_issuable = can?(current_user, :"admin_#{issuable.to_ability_name}", @project) .block.issuable-sidebar-header - %span.issuable-header-text.hide-collapsed.pull-left - Todo + - if current_user + %span.issuable-header-text.hide-collapsed.pull-left + Todo %a.gutter-toggle.pull-right.js-sidebar-toggle{ role: "button", href: "#", aria: { label: "Toggle sidebar" } } = sidebar_gutter_toggle_icon - %button.btn.btn-default.issuable-header-btn.pull-right.js-issuable-todo{ type: "button", data: { todo_text: "Add Todo", mark_text: "Mark Done", id: (todo.id unless todo.nil?), url: issuable_todo_path(issuable) } } - - if todo.nil? - %span - Add Todo - - else - %span - Mark Done - = icon('spin spinner', class: 'hidden js-issuable-todo-loading') + - if current_user + %button.btn.btn-default.issuable-header-btn.pull-right.js-issuable-todo{ type: "button", data: { todo_text: "Add Todo", mark_text: "Mark Done", id: (todo.id unless todo.nil?), url: issuable_todo_path(issuable) } } + - if todo.nil? + %span + Add Todo + - else + %span + Mark Done + = icon('spin spinner', class: 'hidden js-issuable-todo-loading') = form_for [@project.namespace.becomes(Namespace), @project, issuable], remote: true, format: :json, html: {class: 'issuable-context-form inline-update js-issuable-update'} do |f| .block.assignee From f8a8999a2069dedd9ca21bde2b726a077c057576 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 7 Jun 2016 12:49:56 +0100 Subject: [PATCH 12/36] Cached jQuery selectors --- .../javascripts/right_sidebar.js.coffee | 29 ++++++++++--------- app/views/shared/issuable/_sidebar.html.haml | 9 +++--- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/app/assets/javascripts/right_sidebar.js.coffee b/app/assets/javascripts/right_sidebar.js.coffee index def735d3b4..ce8de7515d 100644 --- a/app/assets/javascripts/right_sidebar.js.coffee +++ b/app/assets/javascripts/right_sidebar.js.coffee @@ -47,40 +47,41 @@ class @Sidebar .off 'click', '.js-issuable-todo' .on 'click', '.js-issuable-todo', @toggleTodo - toggleTodo: (e) -> + toggleTodo: -> $this = $(@) - $btnText = $this.find('span') - data = { - todo_id: $this.attr('data-id') - } + $todoLoading = $('.js-issuable-todo-loading') + $btnText = $('.js-issuable-todo-text', $this) $.ajax( url: $this.data('url') type: 'POST' dataType: 'json' - data: data + data: + todo_id: $this.attr('data-id') beforeSend: -> $this.disable() - $('.js-issuable-todo-loading').removeClass 'hidden' + $todoLoading.removeClass 'hidden' ).done (data) -> $todoPendingCount = $('.todos-pending-count') $todoPendingCount.text data.count $this.enable() - $('.js-issuable-todo-loading').addClass 'hidden' + $todoLoading.addClass 'hidden' if data.count is 0 - $todoPendingCount - .addClass 'hidden' + $todoPendingCount.addClass 'hidden' else - $todoPendingCount - .removeClass 'hidden' + $todoPendingCount.removeClass 'hidden' if data.todo? + $this + .attr 'aria-label', $this.data('mark-text') + .attr 'data-id', data.todo.id $btnText.text $this.data('mark-text') - $this.attr 'data-id', data.todo.id else - $this.removeAttr 'data-id' + $this + .attr 'aria-label', $this.data('todo-text') + .removeAttr 'data-id' $btnText.text $this.data('todo-text') sidebarDropdownLoading: (e) -> diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index e3aacb50c9..26052c47b0 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -9,12 +9,11 @@ %a.gutter-toggle.pull-right.js-sidebar-toggle{ role: "button", href: "#", aria: { label: "Toggle sidebar" } } = sidebar_gutter_toggle_icon - if current_user - %button.btn.btn-default.issuable-header-btn.pull-right.js-issuable-todo{ type: "button", data: { todo_text: "Add Todo", mark_text: "Mark Done", id: (todo.id unless todo.nil?), url: issuable_todo_path(issuable) } } - - if todo.nil? - %span + %button.btn.btn-default.issuable-header-btn.pull-right.js-issuable-todo{ type: "button", aria: { label: (todo.nil? ? "Add Todo" : "Mark Done") }, data: { todo_text: "Add Todo", mark_text: "Mark Done", id: (todo.id unless todo.nil?), url: issuable_todo_path(issuable) } } + %span.js-issuable-todo-text + - if todo.nil? Add Todo - - else - %span + - else Mark Done = icon('spin spinner', class: 'hidden js-issuable-todo-loading') From 20d382a891d92197620eb4e72526577a916292d7 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Wed, 8 Jun 2016 09:29:19 +0100 Subject: [PATCH 13/36] Moved todo creation over to project todos controller --- .../javascripts/right_sidebar.js.coffee | 2 ++ app/controllers/projects/issues_controller.rb | 14 ---------- .../projects/merge_requests_controller.rb | 14 ---------- app/controllers/projects/todos_controller.rb | 28 +++++++++++++++++++ app/helpers/issuables_helper.rb | 10 ------- app/views/shared/issuable/_sidebar.html.haml | 2 +- config/routes.rb | 4 +-- 7 files changed, 33 insertions(+), 41 deletions(-) create mode 100644 app/controllers/projects/todos_controller.rb diff --git a/app/assets/javascripts/right_sidebar.js.coffee b/app/assets/javascripts/right_sidebar.js.coffee index ce8de7515d..18abec4f51 100644 --- a/app/assets/javascripts/right_sidebar.js.coffee +++ b/app/assets/javascripts/right_sidebar.js.coffee @@ -58,6 +58,8 @@ class @Sidebar dataType: 'json' data: todo_id: $this.attr('data-id') + issuable_id: $this.data('issuable') + issuable_type: $this.data('issuable-type') beforeSend: -> $this.disable() $todoLoading.removeClass 'hidden' diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 5678d584d4..4e2d3bebb2 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -164,20 +164,6 @@ class Projects::IssuesController < Projects::ApplicationController end end - def todo - json_data = Hash.new - - if params[:todo_id].nil? - TodoService.new.mark_todo(issue, current_user) - - json_data[:todo] = current_user.todos.find_by(state: :pending, action: Todo::MARKED, target_id: issue.id) - else - current_user.todos.find_by_id(params[:todo_id]).update(state: :done) - end - - render json: json_data.merge({ count: current_user.todos.pending.count }) - end - protected def issue diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index f0eba453ca..67e7187c10 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -260,20 +260,6 @@ class Projects::MergeRequestsController < Projects::ApplicationController render json: response end - def todo - json_data = Hash.new - - if params[:todo_id].nil? - TodoService.new.mark_todo(merge_request, current_user) - - json_data[:todo] = current_user.todos.find_by(state: :pending, action: Todo::MARKED, target_id: merge_request.id) - else - current_user.todos.find_by_id(params[:todo_id]).update(state: :done) - end - - render json: json_data.merge({ count: current_user.todos.pending.count }) - end - protected def selected_target_project diff --git a/app/controllers/projects/todos_controller.rb b/app/controllers/projects/todos_controller.rb new file mode 100644 index 0000000000..2174597786 --- /dev/null +++ b/app/controllers/projects/todos_controller.rb @@ -0,0 +1,28 @@ +class Projects::TodosController < Projects::ApplicationController + def create + json_data = Hash.new + + if params[:todo_id].nil? + TodoService.new.mark_todo(issuable, current_user) + + json_data[:todo] = current_user.todos.find_by(state: :pending, action: Todo::MARKED, target_id: issuable.id) + else + current_user.todos.find_by_id(params[:todo_id]).update(state: :done) + end + + render json: json_data.merge({ count: current_user.todos.pending.count }) + end + + private + + def issuable + @issuable ||= begin + case params[:issuable_type] + when "issue" + @project.issues.find(params[:issuable_id]) + when "merge_request" + @project.merge_requests.find(params[:issuable_id]) + end + end + end +end diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 2ae7f5c5f3..8dbc51a689 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -67,16 +67,6 @@ module IssuablesHelper end end - def issuable_todo_path(issuable) - project = issuable.project - - if issuable.kind_of?(MergeRequest) - todo_namespace_project_merge_request_path(project.namespace, project, issuable.iid, :json) - else - todo_namespace_project_issue_path(project.namespace, project, issuable.iid, :json) - end - end - def has_todo(issuable) unless current_user.nil? current_user.todos.find_by(target_id: issuable.id, state: :pending) diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 26052c47b0..17f623b346 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -9,7 +9,7 @@ %a.gutter-toggle.pull-right.js-sidebar-toggle{ role: "button", href: "#", aria: { label: "Toggle sidebar" } } = sidebar_gutter_toggle_icon - if current_user - %button.btn.btn-default.issuable-header-btn.pull-right.js-issuable-todo{ type: "button", aria: { label: (todo.nil? ? "Add Todo" : "Mark Done") }, data: { todo_text: "Add Todo", mark_text: "Mark Done", id: (todo.id unless todo.nil?), url: issuable_todo_path(issuable) } } + %button.btn.btn-default.issuable-header-btn.pull-right.js-issuable-todo{ type: "button", aria: { label: (todo.nil? ? "Add Todo" : "Mark Done") }, data: { todo_text: "Add Todo", mark_text: "Mark Done", id: (todo.id unless todo.nil?), issuable: issuable.id, issuable_type: issuable.class.name.underscore, url: namespace_project_todos_path(@project.namespace, @project, :json) } } %span.js-issuable-todo-text - if todo.nil? Add Todo diff --git a/config/routes.rb b/config/routes.rb index d018fa742c..ef198a5e87 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -679,7 +679,6 @@ Rails.application.routes.draw do post :toggle_subscription post :toggle_award_emoji post :remove_wip - post :todo end collection do @@ -760,7 +759,6 @@ Rails.application.routes.draw do get :referenced_merge_requests get :related_branches get :can_create_branch - post :todo end collection do post :bulk_update @@ -791,6 +789,8 @@ Rails.application.routes.draw do end end + resources :todos, only: [:create], constraints: { id: /\d+/ } + resources :uploads, only: [:create] do collection do get ":secret/:filename", action: :show, as: :show, constraints: { filename: /[^\/]+/ } From 330e91368195e182cbfa9b41a1d5304f67d07334 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 9 Jun 2016 08:50:18 +0100 Subject: [PATCH 14/36] Uses update URL to update the status of a todo --- .../javascripts/right_sidebar.js.coffee | 61 +++++++++++-------- app/controllers/projects/todos_controller.rb | 21 ++++--- app/views/shared/issuable/_sidebar.html.haml | 2 +- config/routes.rb | 2 +- 4 files changed, 48 insertions(+), 38 deletions(-) diff --git a/app/assets/javascripts/right_sidebar.js.coffee b/app/assets/javascripts/right_sidebar.js.coffee index 18abec4f51..8eb005b0a2 100644 --- a/app/assets/javascripts/right_sidebar.js.coffee +++ b/app/assets/javascripts/right_sidebar.js.coffee @@ -47,44 +47,51 @@ class @Sidebar .off 'click', '.js-issuable-todo' .on 'click', '.js-issuable-todo', @toggleTodo - toggleTodo: -> - $this = $(@) + toggleTodo: (e) => + $this = $(e.currentTarget) $todoLoading = $('.js-issuable-todo-loading') $btnText = $('.js-issuable-todo-text', $this) + ajaxType = if $this.attr('data-id') then 'PATCH' else 'POST' + ajaxUrlExtra = if $this.attr('data-id') then "/#{$this.attr('data-id')}" else '' $.ajax( - url: $this.data('url') - type: 'POST' + url: "#{$this.data('url')}#{ajaxUrlExtra}" + type: ajaxType dataType: 'json' data: - todo_id: $this.attr('data-id') issuable_id: $this.data('issuable') issuable_type: $this.data('issuable-type') - beforeSend: -> - $this.disable() - $todoLoading.removeClass 'hidden' - ).done (data) -> - $todoPendingCount = $('.todos-pending-count') - $todoPendingCount.text data.count + beforeSend: => + @beforeTodoSend($this, $todoLoading) + ).done (data) => + @todoUpdateDone(data, $this, $btnText, $todoLoading) - $this.enable() - $todoLoading.addClass 'hidden' + beforeTodoSend: ($btn, $todoLoading) -> + $btn.disable() + $todoLoading.removeClass 'hidden' - if data.count is 0 - $todoPendingCount.addClass 'hidden' - else - $todoPendingCount.removeClass 'hidden' + todoUpdateDone: (data, $btn, $btnText, $todoLoading) -> + $todoPendingCount = $('.todos-pending-count') + $todoPendingCount.text data.count - if data.todo? - $this - .attr 'aria-label', $this.data('mark-text') - .attr 'data-id', data.todo.id - $btnText.text $this.data('mark-text') - else - $this - .attr 'aria-label', $this.data('todo-text') - .removeAttr 'data-id' - $btnText.text $this.data('todo-text') + $btn.enable() + $todoLoading.addClass 'hidden' + + if data.count is 0 + $todoPendingCount.addClass 'hidden' + else + $todoPendingCount.removeClass 'hidden' + + if data.todo? + $btn + .attr 'aria-label', $btn.data('mark-text') + .attr 'data-id', data.todo.id + $btnText.text $btn.data('mark-text') + else + $btn + .attr 'aria-label', $btn.data('todo-text') + .removeAttr 'data-id' + $btnText.text $btn.data('todo-text') sidebarDropdownLoading: (e) -> $sidebarCollapsedIcon = $(@).closest('.block').find('.sidebar-collapsed-icon') diff --git a/app/controllers/projects/todos_controller.rb b/app/controllers/projects/todos_controller.rb index 2174597786..64e70a5bcc 100644 --- a/app/controllers/projects/todos_controller.rb +++ b/app/controllers/projects/todos_controller.rb @@ -1,20 +1,23 @@ class Projects::TodosController < Projects::ApplicationController def create - json_data = Hash.new + TodoService.new.mark_todo(issuable, current_user) - if params[:todo_id].nil? - TodoService.new.mark_todo(issuable, current_user) + render json: { + todo: current_user.todos.find_by(state: :pending, action: Todo::MARKED, target_id: issuable.id), + count: current_user.todos.pending.count, + } + end - json_data[:todo] = current_user.todos.find_by(state: :pending, action: Todo::MARKED, target_id: issuable.id) - else - current_user.todos.find_by_id(params[:todo_id]).update(state: :done) - end + def update + current_user.todos.find_by_id(params[:id]).update(state: :done) - render json: json_data.merge({ count: current_user.todos.pending.count }) + render json: { + count: current_user.todos.pending.count, + } end private - + def issuable @issuable ||= begin case params[:issuable_type] diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 17f623b346..539c4f3630 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -9,7 +9,7 @@ %a.gutter-toggle.pull-right.js-sidebar-toggle{ role: "button", href: "#", aria: { label: "Toggle sidebar" } } = sidebar_gutter_toggle_icon - if current_user - %button.btn.btn-default.issuable-header-btn.pull-right.js-issuable-todo{ type: "button", aria: { label: (todo.nil? ? "Add Todo" : "Mark Done") }, data: { todo_text: "Add Todo", mark_text: "Mark Done", id: (todo.id unless todo.nil?), issuable: issuable.id, issuable_type: issuable.class.name.underscore, url: namespace_project_todos_path(@project.namespace, @project, :json) } } + %button.btn.btn-default.issuable-header-btn.pull-right.js-issuable-todo{ type: "button", aria: { label: (todo.nil? ? "Add Todo" : "Mark Done") }, data: { todo_text: "Add Todo", mark_text: "Mark Done", id: (todo.id unless todo.nil?), issuable: issuable.id, issuable_type: issuable.class.name.underscore, url: namespace_project_todos_path(@project.namespace, @project) } } %span.js-issuable-todo-text - if todo.nil? Add Todo diff --git a/config/routes.rb b/config/routes.rb index ef198a5e87..93dd3c938d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -789,7 +789,7 @@ Rails.application.routes.draw do end end - resources :todos, only: [:create], constraints: { id: /\d+/ } + resources :todos, only: [:create, :update], constraints: { id: /\d+/ } resources :uploads, only: [:create] do collection do From 8abd7b35ff20214c072658a4e92e0418ae9e936a Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 9 Jun 2016 08:51:40 +0100 Subject: [PATCH 15/36] Updated TODO description --- app/helpers/todos_helper.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb index 6cfc86dfb9..9adf5ef29f 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -12,7 +12,7 @@ module TodosHelper when Todo::ASSIGNED then 'assigned you' when Todo::MENTIONED then 'mentioned you on' when Todo::BUILD_FAILED then 'The build failed for your' - when Todo::MARKED then 'todo' + when Todo::MARKED then 'marked this as a Todo for' end end From 16970d07e84f5967eccd928c9f9d9d7b027e91ac Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 9 Jun 2016 16:12:59 +0100 Subject: [PATCH 16/36] Returns created todos to control rather than re-query --- app/controllers/projects/todos_controller.rb | 4 ++-- app/services/todo_service.rb | 2 +- app/views/layouts/header/_default.html.haml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/controllers/projects/todos_controller.rb b/app/controllers/projects/todos_controller.rb index 64e70a5bcc..a51bd5e2b4 100644 --- a/app/controllers/projects/todos_controller.rb +++ b/app/controllers/projects/todos_controller.rb @@ -1,9 +1,9 @@ class Projects::TodosController < Projects::ApplicationController def create - TodoService.new.mark_todo(issuable, current_user) + todos = TodoService.new.mark_todo(issuable, current_user) render json: { - todo: current_user.todos.find_by(state: :pending, action: Todo::MARKED, target_id: issuable.id), + todo: todos, count: current_user.todos.pending.count, } end diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb index 5a192e54f2..e1f9ea64dc 100644 --- a/app/services/todo_service.rb +++ b/app/services/todo_service.rb @@ -148,7 +148,7 @@ class TodoService private def create_todos(users, attributes) - Array(users).each do |user| + Array(users).map do |user| next if pending_todos(user, attributes).exists? Todo.create(attributes.merge(user_id: user.id)) end diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index ebc9f01675..a0f560a13e 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -27,7 +27,7 @@ %li = link_to dashboard_todos_path, title: 'Todos', data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = icon('bell fw') - %span.badge.todos-pending-count{ class: ("hidden" if todos_pending_count == 0)} + %span.badge.todos-pending-count{ class: ("hidden" if todos_pending_count == 0) } = todos_pending_count - if current_user.can_create_project? %li From e737ffc48a4794d4dc8f58f20c973154eadff11b Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Mon, 13 Jun 2016 14:25:58 +0100 Subject: [PATCH 17/36] Todo service tests --- spec/services/todo_service_spec.rb | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/spec/services/todo_service_spec.rb b/spec/services/todo_service_spec.rb index 489c920f19..5e46bfeebd 100644 --- a/spec/services/todo_service_spec.rb +++ b/spec/services/todo_service_spec.rb @@ -220,6 +220,14 @@ describe TodoService, services: true do should_not_create_any_todo { service.new_note(note_on_project_snippet, john_doe) } end end + + describe '#mark_todo' do + it 'creates a todo from a issue' do + service.mark_todo(unassigned_issue, author) + + should_create_todo(user: author, target: unassigned_issue, action: Todo::MARKED) + end + end end describe 'Merge Requests' do @@ -351,6 +359,14 @@ describe TodoService, services: true do expect(second_todo.reload).not_to be_done end end + + describe '#mark_todo' do + it 'creates a todo from a merge request' do + service.mark_todo(mr_unassigned, author) + + should_create_todo(user: author, target: mr_unassigned, action: Todo::MARKED) + end + end end def should_create_todo(attributes = {}) From b22ba26caa233bc6cb56bc0b82f493713f657909 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 14 Jun 2016 08:36:34 +0100 Subject: [PATCH 18/36] CHANGELOG --- CHANGELOG | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index ae62b6b4c4..96b27d9748 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -54,6 +54,7 @@ v 8.9.0 (unreleased) - Use Knapsack only in CI environment - Cache project build count in sidebar nav - Add milestone expire date to the right sidebar + - Manually mark a issue or merge request as a todo - Fix markdown_spec to use before instead of before(:all) to properly cleanup database after testing - Reduce number of queries needed to render issue labels in the sidebar - Improve error handling importing projects @@ -73,7 +74,6 @@ v 8.9.0 (unreleased) - Put project Labels and Milestones pages links under Issues and Merge Requests tabs as subnav - All classes in the Banzai::ReferenceParser namespace are now instrumented - Remove deprecated issues_tracker and issues_tracker_id from project model - - Manually mark a issue or merge request as a todo v 8.8.5 (unreleased) - Ensure branch cleanup regardless of whether the GitHub import process succeeds From 17c22156c5fa5663aae65178ed38cbeef9a80b7e Mon Sep 17 00:00:00 2001 From: David Alexander Date: Mon, 14 Mar 2016 09:13:35 -0400 Subject: [PATCH 19/36] Initial implementation of user access request to projects --- .../projects/project_members_controller.rb | 31 +++++++++- app/helpers/projects_helper.rb | 12 +++- app/mailers/emails/projects.rb | 42 +++++++++++++ app/models/ability.rb | 2 +- app/models/member.rb | 60 +++++++++++++++++-- app/models/members/project_member.rb | 18 ++++++ app/models/project_team.rb | 6 ++ app/services/notification_service.rb | 12 ++++ app/views/layouts/nav/_project.html.haml | 18 ++++++ ...ct_request_access_accepted_email.html.haml | 4 ++ ...ect_request_access_accepted_email.text.erb | 3 + ...ject_request_access_denied_email.html.haml | 4 ++ ...oject_request_access_denied_email.text.erb | 3 + .../project_members/_pending.html.haml | 21 +++++++ .../project_members/_project_member.html.haml | 15 ++++- .../projects/project_members/index.html.haml | 3 +- config/routes.rb | 2 + .../20160314114439_add_membership_request.rb | 5 ++ db/schema.rb | 1 + 19 files changed, 248 insertions(+), 14 deletions(-) create mode 100644 app/views/notify/project_request_access_accepted_email.html.haml create mode 100644 app/views/notify/project_request_access_accepted_email.text.erb create mode 100644 app/views/notify/project_request_access_denied_email.html.haml create mode 100644 app/views/notify/project_request_access_denied_email.text.erb create mode 100644 app/views/projects/project_members/_pending.html.haml create mode 100644 db/migrate/20160314114439_add_membership_request.rb diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index cdea5f0b77..ba5ef30be3 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -1,10 +1,10 @@ class Projects::ProjectMembersController < Projects::ApplicationController # Authorize - before_action :authorize_admin_project_member!, except: [:leave, :index] + before_action :authorize_admin_project_member!, except: [:index, :leave, :request_access] def index @project_members = @project.project_members - @project_members = @project_members.non_invite unless can?(current_user, :admin_project, @project) + @project_members = @project_members.non_pending unless can?(current_user, :admin_project, @project) if params[:search].present? users = @project.users.search(params[:search]).to_a @@ -93,6 +93,33 @@ class Projects::ProjectMembersController < Projects::ApplicationController end end + def request_access + redirect_path = namespace_project_path(@project.namespace, @project) + # current_user + # @project + @project_member = ProjectMember.new(source: @project, access_level: ProjectMember::DEVELOPER, user_id: current_user.id, created_by_id: current_user.id, requested: true) + @project_member.save! + + + redirect_to redirect_path, notice: 'Your request for access has been queued for review.' + end + + def approval + @project_member = @project.project_members.find(params[:id]) + + return render_403 unless can?(current_user, :update_project_member, @project_member) + + @project_member.requested = nil + @project_member.save! + + respond_to do |format| + format.html do + redirect_to namespace_project_project_members_path(@project.namespace, @project) + end + format.js { render nothing: true } + end + end + def apply_import source_project = Project.find(params[:source_project_id]) diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 5e5d170a9f..a015b5e6a0 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -1,12 +1,18 @@ module ProjectsHelper def remove_from_project_team_message(project, member) - if member.user - "You are going to remove #{member.user.name} from #{project.name} project team. Are you sure?" - else + if !member.user "You are going to revoke the invitation for #{member.invite_email} to join #{project.name} project team. Are you sure?" + elsif member.request? + "You are going to deny #{member.user.name}'s request to join #{project.name} project team. Are you sure?" + else + "You are going to remove #{member.user.name} from #{project.name} project team. Are you sure?" end end + def approve_for_project_team_message(project, member) + "You are going to approve #{member.user.name}'s request for #{member.human_access} access to the #{project.name} project team. Are you sure?" + end + def link_to_project(project) link_to [project.namespace.becomes(Namespace), project], title: h(project.name) do title = content_tag(:span, project.name, class: 'project-name') diff --git a/app/mailers/emails/projects.rb b/app/mailers/emails/projects.rb index fdf1e9f5af..6662c407c2 100644 --- a/app/mailers/emails/projects.rb +++ b/app/mailers/emails/projects.rb @@ -11,6 +11,48 @@ module Emails subject: subject("Access to project was granted")) end + def project_member_requested_access(project_member_id) + @project_member = ProjectMember.find project_member_id + @project = @project_member.project + @target_url = namespace_project_url(@project.namespace, @project) + + project_admins = ProjectMember.in_project(@project) + .where(access_level: [Gitlab::Access::OWNER, Gitlab::Access::MASTER]) + .pluck(:notification_email) + + project_admins.each do |address| + mail(to: address, + subject: subject("Request to join project: #{@project.name_with_namespace}")) + end + end + + def project_request_access_accepted_email(project_member_id) + @project_member = ProjectMember.find project_member_id + return if @project_member.created_by.nil? + + @project = @project_member.project + + @target_url = namespace_project_url(@project.namespace, @project) + @current_user = @project_member.created_by + + mail(to: @project_member.created_by.notification_email, + subject: subject('Request for access granted')) + end + + def project_request_access_declined_email(project_member_id) + @project_member = ProjectMember.find project_member_id + return if @project_member.created_by.nil? + + @project = @project_member.project + + @target_url = namespace_project_url(@project.namespace, @project) + @current_user = @project_member.created_by + + mail(to: @project_member.created_by.notification_email, + subject: subject('Request for access declined')) + end + + def project_member_invited_email(project_member_id, token) @project_member = ProjectMember.find project_member_id @project = @project_member.project diff --git a/app/models/ability.rb b/app/models/ability.rb index aea946f922..b3db26f989 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -153,7 +153,7 @@ class Ability RequestStore.store[key] ||= begin # Push abilities on the users team role - rules.push(*project_team_rules(project.team, user)) + rules.push(*project_team_rules(project.team, user)) unless project.team.pending?(user) if project.owner == user || (project.group && project.group.has_owner?(user)) || diff --git a/app/models/member.rb b/app/models/member.rb index d3060f07fc..2210e7dd66 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -27,7 +27,12 @@ class Member < ActiveRecord::Base } scope :invite, -> { where(user_id: nil) } - scope :non_invite, -> { where("user_id IS NOT NULL") } + scope :non_invite, -> { where('user_id IS NOT NULL') } + scope :request, -> { where(requested: true) } + scope :non_request, -> { where(requested: nil) } + scope :pending, -> { where("user_id IS NULL OR requested") } + scope :non_pending, -> { self.non_invite.non_request } + scope :guests, -> { where(access_level: GUEST) } scope :reporters, -> { where(access_level: REPORTER) } scope :developers, -> { where(access_level: DEVELOPER) } @@ -35,11 +40,16 @@ class Member < ActiveRecord::Base scope :owners, -> { where(access_level: OWNER) } before_validation :generate_invite_token, on: :create, if: -> (member) { member.invite_email.present? } + after_create :send_invite, if: :invite? - after_create :create_notification_setting, unless: :invite? - after_create :post_create_hook, unless: :invite? - after_update :post_update_hook, unless: :invite? - after_destroy :post_destroy_hook, unless: :invite? + after_create :send_request_access, if: :request? + + after_create :create_notification_setting, unless: :pending? + after_create :post_create_hook, unless: :pending? + + after_update :post_update_hook, unless: :pending? + + after_destroy :post_destroy_hook, unless: :pending? delegate :name, :username, :email, to: :user, prefix: true @@ -96,10 +106,38 @@ class Member < ActiveRecord::Base end end + def pending? + request? || invite? + end + + def request? + self.requested + end + def invite? self.invite_token.present? end + def accept_request_access! + return false unless request? + + self.request = false + saved = self.save + + after_accept_request_access if saved + + saved + end + + def decline_request_access! + return false unless request? + + destroyed = self.destroy + after_decline_request_access if destroyed + + destroyed + end + def accept_invite!(new_user) return false unless invite? @@ -153,6 +191,10 @@ class Member < ActiveRecord::Base private + def send_request_access + # override in subclass + end + def send_invite # override in subclass end @@ -169,6 +211,14 @@ class Member < ActiveRecord::Base system_hook_service.execute_hooks_for(self, :destroy) end + def after_accept_request_access + post_create_hook + end + + def after_decline_request_access + # override in subclass + end + def after_accept_invite post_create_hook end diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index 46955b430f..9db8db8450 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -107,6 +107,12 @@ class ProjectMember < Member user.todos.where(project_id: source_id).destroy_all if user end + def send_request_access + notification_service.request_access_project_member(self) + + super + end + def send_invite notification_service.invite_project_member(self, @raw_invite_token) @@ -136,6 +142,18 @@ class ProjectMember < Member super end + def after_accept_request_access + notification_service.accept_project_request_access(self) + + super + end + + def after_decline_request_access + notification_service.decline_project_request_access(self) + + super + end + def after_accept_invite notification_service.accept_project_invite(self) diff --git a/app/models/project_team.rb b/app/models/project_team.rb index e29e854860..769b73666c 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -115,6 +115,12 @@ class ProjectTeam false end + def pending?(user) + project.project_members.each do |member| + return member.pending? if member.user_id == user.id + end + end + def guest?(user) max_member_access(user.id) == Gitlab::Access::GUEST end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 875a3f4fab..e7676861e9 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -173,6 +173,18 @@ class NotificationService end end + def request_access_project_member(project_member) + mailer.project_member_requested_access(project_member.id).deliver_later + end + + def accept_project_request_access(project_member) + mailer.project_request_access_accepted_email(project_member.id).deliver_later + end + + def decline_project_request_access(project_member) + mailer.project_request_access_declined_email(project_member.id).deliver_later + end + def invite_project_member(project_member, token) mailer.project_member_invited_email(project_member.id, token).deliver_later end diff --git a/app/views/layouts/nav/_project.html.haml b/app/views/layouts/nav/_project.html.haml index 53d1fcc30a..1336191bc5 100644 --- a/app/views/layouts/nav/_project.html.haml +++ b/app/views/layouts/nav/_project.html.haml @@ -8,6 +8,19 @@ = icon('caret-down') %ul.dropdown-menu.dropdown-menu-align-right = render 'layouts/nav/project_settings' + + - if access + %li + = link_to leave_namespace_project_project_members_path(@project.namespace, @project), + data: { confirm: leave_project_message(@project) }, method: :delete, title: 'Leave project' do + Leave Project + - else + = link_to request_access_namespace_project_project_members_path(@project.namespace, @project), + class: 'btn btn-gray', style: 'margin-left: 10px', method: :post, title: 'Request access' do + Request Access + + + %li.divider - if can_edit %li @@ -18,6 +31,11 @@ = link_to leave_namespace_project_project_members_path(@project.namespace, @project), data: { confirm: leave_project_message(@project) }, method: :delete, title: 'Leave project' do Leave Project + - else + %li + = link_to request_access_namespace_project_project_members_path(@project.namespace, @project), + class: 'btn btn-gray', style: 'margin-left: 10px', method: :post, title: 'Request access' do + Request Access %div{ class: nav_control_class } %ul.nav-links.scrolling-tabs diff --git a/app/views/notify/project_request_access_accepted_email.html.haml b/app/views/notify/project_request_access_accepted_email.html.haml new file mode 100644 index 0000000000..dfdf82e70a --- /dev/null +++ b/app/views/notify/project_request_access_accepted_email.html.haml @@ -0,0 +1,4 @@ +%p + Your request to join project + #{link_to @project.name_with_namespace, namespace_project_url(@project.namespace, @project)} + has been granted with #{@project_member.human_access} access. diff --git a/app/views/notify/project_request_access_accepted_email.text.erb b/app/views/notify/project_request_access_accepted_email.text.erb new file mode 100644 index 0000000000..9fb6887449 --- /dev/null +++ b/app/views/notify/project_request_access_accepted_email.text.erb @@ -0,0 +1,3 @@ +Your request to join project <%= @project.name_with_namespace %> has been granted with <%= @project_member.human_access %> access. + +<%= namespace_project_url(@project.namespace, @project) %> diff --git a/app/views/notify/project_request_access_denied_email.html.haml b/app/views/notify/project_request_access_denied_email.html.haml new file mode 100644 index 0000000000..8ad75b96cf --- /dev/null +++ b/app/views/notify/project_request_access_denied_email.html.haml @@ -0,0 +1,4 @@ +%p + Your request to join project + #{link_to @project.name_with_namespace, namespace_project_url(@project.namespace, @project)} + has been denied. diff --git a/app/views/notify/project_request_access_denied_email.text.erb b/app/views/notify/project_request_access_denied_email.text.erb new file mode 100644 index 0000000000..a9c57e4cab --- /dev/null +++ b/app/views/notify/project_request_access_denied_email.text.erb @@ -0,0 +1,3 @@ +Your request to join project <%= @project.name_with_namespace %> has been denied. + +<%= namespace_project_url(@project.namespace, @project) %> diff --git a/app/views/projects/project_members/_pending.html.haml b/app/views/projects/project_members/_pending.html.haml new file mode 100644 index 0000000000..88ac36937a --- /dev/null +++ b/app/views/projects/project_members/_pending.html.haml @@ -0,0 +1,21 @@ +.panel.panel-default + .panel-heading + %strong #{@project.name} + candidates + %small + (#{members.count}) + .controls + = form_tag namespace_project_project_members_path(@project.namespace, @project), method: :get, class: 'form-inline member-search-form' do + .form-group + = search_field_tag :search, params[:search], { placeholder: 'Find existing member by name', class: 'form-control', spellcheck: false } + = button_tag class: 'btn', title: 'Search' do + = icon("search") + %ul.content-list + - members.each do |project_member| + = render 'project_member', member: project_member + +:javascript + $('form.member-search-form').on('submit', function (event) { + event.preventDefault(); + Turbolinks.visit(this.action + '?' + $(this).serialize()); + }); diff --git a/app/views/projects/project_members/_project_member.html.haml b/app/views/projects/project_members/_project_member.html.haml index 268f140d7d..3faf5dba8a 100644 --- a/app/views/projects/project_members/_project_member.html.haml +++ b/app/views/projects/project_members/_project_member.html.haml @@ -13,6 +13,9 @@ - if user.blocked? %label.label.label-danger %strong Blocked + - if member.request? + %span.label.label-info + Pending Approval - else = image_tag avatar_icon(member.invite_email, 24), class: "avatar s24", alt: '' %strong @@ -27,7 +30,6 @@ - if can?(current_user, :admin_project_member, @project) = link_to resend_invite_namespace_project_project_member_path(@project.namespace, @project, member), method: :post, class: "btn-xs btn", title: 'Resend invite' do Resend invite - - if can?(current_user, :admin_project_member, @project) .pull-right %strong= member.human_access @@ -35,10 +37,19 @@ = button_tag class: "btn-xs btn-grouped inline btn js-toggle-button", title: 'Edit access level', type: 'button' do = icon('pencil') + - if member.request? +   + = link_to approval_namespace_project_project_member_path(@project.namespace, @project, member), + class: "btn-xs btn btn-success", + title: 'Grant access', type: 'button' do + %i.fa.fa-check.fa-inverse - if can?(current_user, :destroy_project_member, member)   - - if current_user == user + - if member.request? + = link_to namespace_project_project_member_path(@project.namespace, @project, member), data: { confirm: remove_from_project_team_message(@project, member) }, method: :delete, class: "btn-xs btn btn-remove", title: 'Deny access' do + %i.fa.fa-times.fa-inverse + - elsif current_user == user = link_to leave_namespace_project_project_members_path(@project.namespace, @project), data: { confirm: leave_project_message(@project) }, method: :delete, class: "btn-xs btn btn-remove", title: 'Leave project' do = icon("sign-out") Leave diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml index 15dc064e7e..d5a19799c4 100644 --- a/app/views/projects/project_members/index.html.haml +++ b/app/views/projects/project_members/index.html.haml @@ -12,8 +12,9 @@ %p.light Users with access to this project are listed below. = render "new_project_member" + = render "pending", members: @project_members.request - = render "team", members: @project_members + = render "team", members: @project_members.non_request - if @group = render "group_members", members: @group_members diff --git a/config/routes.rb b/config/routes.rb index 95fbe7dd9d..fb35bf9dcf 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -768,6 +768,7 @@ Rails.application.routes.draw do resources :project_members, except: [:new, :edit], constraints: { id: /[a-zA-Z.\/0-9_\-#%+]+/ } do collection do delete :leave + post :request_access # Used for import team # from another project @@ -777,6 +778,7 @@ Rails.application.routes.draw do member do post :resend_invite + post :approval end end diff --git a/db/migrate/20160314114439_add_membership_request.rb b/db/migrate/20160314114439_add_membership_request.rb new file mode 100644 index 0000000000..319b750e6c --- /dev/null +++ b/db/migrate/20160314114439_add_membership_request.rb @@ -0,0 +1,5 @@ +class AddMembershipRequest < ActiveRecord::Migration + def change + add_column :members, :requested, :boolean + end +end diff --git a/db/schema.rb b/db/schema.rb index 3dccbbd50b..b59552fbbe 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -536,6 +536,7 @@ ActiveRecord::Schema.define(version: 20160610301627) do t.string "invite_email" t.string "invite_token" t.datetime "invite_accepted_at" + t.boolean "requested" end add_index "members", ["access_level"], name: "index_members_on_access_level", using: :btree From d26f81239a33b80694783ee35f0da0e2ed082c9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= Date: Mon, 18 Apr 2016 18:53:32 +0200 Subject: [PATCH 20/36] Add request access for groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rémy Coutable --- app/assets/stylesheets/pages/projects.scss | 4 - .../groups/group_members_controller.rb | 34 ++- .../projects/project_members_controller.rb | 44 ++-- app/helpers/groups_helper.rb | 20 -- app/helpers/members_helper.rb | 117 ++++++++ app/helpers/projects_helper.rb | 26 +- app/mailers/emails/groups.rb | 51 ++-- app/mailers/emails/projects.rb | 81 +++--- app/models/ability.rb | 8 +- app/models/concerns/access_requestable.rb | 27 ++ app/models/group.rb | 1 + app/models/member.rb | 58 ++-- app/models/members/group_member.rb | 15 +- app/models/members/project_member.rb | 30 +-- app/models/project.rb | 13 +- app/models/project_team.rb | 28 +- app/models/user.rb | 5 +- app/services/notification_service.rb | 22 +- app/views/admin/groups/show.html.haml | 2 +- app/views/admin/projects/show.html.haml | 4 +- app/views/admin/users/groups.html.haml | 2 +- app/views/admin/users/projects.html.haml | 3 +- .../group_members/_group_member.html.haml | 57 ---- .../groups/group_members/index.html.haml | 12 +- app/views/groups/group_members/update.js.haml | 2 +- .../layouts/nav/_group_settings.html.haml | 21 +- app/views/layouts/nav/_project.html.haml | 26 +- .../group_access_denied_email.html.haml | 2 + .../notify/group_access_denied_email.text.erb | 3 + .../group_access_granted_email.html.haml | 5 +- .../group_access_granted_email.text.erb | 5 +- .../group_access_requested_email.html.haml | 3 + .../group_access_requested_email.text.erb | 3 + .../project_access_denied_email.html.haml | 3 + ...b => project_access_denied_email.text.erb} | 2 +- .../project_access_granted_email.html.haml | 6 +- .../project_access_granted_email.text.erb | 5 +- .../project_access_requested_email.html.haml | 3 + .../project_access_requested_email.text.erb | 3 + ...ct_request_access_accepted_email.html.haml | 4 - ...ect_request_access_accepted_email.text.erb | 3 - ...ject_request_access_denied_email.html.haml | 4 - app/views/projects/notes/_note.html.haml | 2 +- .../project_members/_group_members.html.haml | 13 +- .../_new_project_member.html.haml | 2 +- .../project_members/_pending.html.haml | 21 -- .../project_members/_project_member.html.haml | 66 ----- .../_shared_group_members.html.haml | 6 +- .../projects/project_members/_team.html.haml | 3 +- .../projects/project_members/index.html.haml | 3 +- .../_group_or_project_home_dropdown.html.haml | 30 +++ app/views/shared/groups/_group.html.haml | 2 +- app/views/shared/members/_member.html.haml | 77 ++++++ app/views/shared/members/_requests.html.haml | 10 + config/routes.rb | 13 +- .../20160314114439_add_membership_request.rb | 5 - ...60314114439_add_requested_at_to_members.rb | 5 + db/schema.rb | 2 +- features/steps/group/members.rb | 4 +- lib/api/project_members.rb | 2 +- .../groups/group_members_controller_spec.rb | 198 +++++++++++++- .../project_members_controller_spec.rb | 249 ++++++++++++++++-- .../owner_manages_access_requests_spec.rb | 52 ++++ .../members/user_requests_access_spec.rb | 54 ++++ .../master_manages_access_requests_spec.rb | 51 ++++ .../members/user_requests_access_spec.rb | 54 ++++ spec/helpers/members_helper_spec.rb | 139 ++++++++++ spec/helpers/projects_helper_spec.rb | 29 +- spec/mailers/notify_spec.rb | 100 ++++++- .../concerns/access_requestable_spec.rb | 41 +++ spec/models/group_spec.rb | 59 ++++- spec/models/member_spec.rb | 89 +++++++ spec/models/members/group_member_spec.rb | 22 +- spec/models/members/project_member_spec.rb | 22 ++ spec/models/project_spec.rb | 11 + spec/models/project_team_spec.rb | 134 ++++++---- 76 files changed, 1766 insertions(+), 571 deletions(-) create mode 100644 app/helpers/members_helper.rb create mode 100644 app/models/concerns/access_requestable.rb delete mode 100644 app/views/groups/group_members/_group_member.html.haml create mode 100644 app/views/notify/group_access_denied_email.html.haml create mode 100644 app/views/notify/group_access_denied_email.text.erb create mode 100644 app/views/notify/group_access_requested_email.html.haml create mode 100644 app/views/notify/group_access_requested_email.text.erb create mode 100644 app/views/notify/project_access_denied_email.html.haml rename app/views/notify/{project_request_access_denied_email.text.erb => project_access_denied_email.text.erb} (58%) create mode 100644 app/views/notify/project_access_requested_email.html.haml create mode 100644 app/views/notify/project_access_requested_email.text.erb delete mode 100644 app/views/notify/project_request_access_accepted_email.html.haml delete mode 100644 app/views/notify/project_request_access_accepted_email.text.erb delete mode 100644 app/views/notify/project_request_access_denied_email.html.haml delete mode 100644 app/views/projects/project_members/_pending.html.haml delete mode 100644 app/views/projects/project_members/_project_member.html.haml create mode 100644 app/views/shared/_group_or_project_home_dropdown.html.haml create mode 100644 app/views/shared/members/_member.html.haml create mode 100644 app/views/shared/members/_requests.html.haml delete mode 100644 db/migrate/20160314114439_add_membership_request.rb create mode 100644 db/migrate/20160314114439_add_requested_at_to_members.rb create mode 100644 spec/features/groups/members/owner_manages_access_requests_spec.rb create mode 100644 spec/features/groups/members/user_requests_access_spec.rb create mode 100644 spec/features/projects/members/master_manages_access_requests_spec.rb create mode 100644 spec/features/projects/members/user_requests_access_spec.rb create mode 100644 spec/helpers/members_helper_spec.rb create mode 100644 spec/models/concerns/access_requestable_spec.rb diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index bb25090425..2505deaf75 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -286,10 +286,6 @@ color: #555; } -.project_member_row form { - margin: 0; -} - .transfer-project .select2-container { min-width: 200px; } diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index 48dbf656e8..2ebc506040 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -1,11 +1,11 @@ class Groups::GroupMembersController < Groups::ApplicationController # Authorize - before_action :authorize_admin_group_member!, except: [:index, :leave] + before_action :authorize_admin_group_member!, except: [:index, :leave, :request_access] def index @project = @group.projects.find(params[:project_id]) if params[:project_id] @members = @group.group_members - @members = @members.non_invite unless can?(current_user, :admin_group, @group) + @members = @members.non_pending unless can?(current_user, :admin_group, @group) if params[:search].present? users = @group.users.search(params[:search]).to_a @@ -36,7 +36,7 @@ class Groups::GroupMembersController < Groups::ApplicationController return render_403 unless can?(current_user, :destroy_group_member, @group_member) - @group_member.destroy + @group_member.request? ? @group_member.decline_request : @group_member.destroy respond_to do |format| format.html { redirect_to group_group_members_path(@group), notice: 'User was successfully removed from group.' } @@ -59,12 +59,20 @@ class Groups::GroupMembersController < Groups::ApplicationController end def leave - @group_member = @group.group_members.find_by(user_id: current_user) + @group_member = + @group.group_members.find_by(user_id: current_user.id) || + @group.group_members.find_by(created_by_id: current_user.id) if can?(current_user, :destroy_group_member, @group_member) + notice = + if @group_member.request? + 'You withdrawn your access request to the group.' + else + "You left #{@group.name} group." + end @group_member.destroy - redirect_to(dashboard_groups_path, notice: "You left #{group.name} group.") + redirect_to dashboard_groups_path, notice: notice else if @group.last_owner?(current_user) redirect_to(dashboard_groups_path, alert: "You can not leave #{group.name} group because you're the last owner. Transfer or delete the group.") @@ -74,6 +82,22 @@ class Groups::GroupMembersController < Groups::ApplicationController end end + def request_access + @group.request_access(current_user) + + redirect_to group_path(@group), notice: 'Your request for access has been queued for review.' + end + + def approve + @group_member = @group.group_members.request.find(params[:id]) + + return render_403 unless can?(current_user, :update_group_member, @group_member) + + @group_member.accept_request + + redirect_to group_group_members_path(@group) + end + protected def member_params diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index ba5ef30be3..c979c5e9fa 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -14,9 +14,10 @@ class Projects::ProjectMembersController < Projects::ApplicationController @project_members = @project_members.order('access_level DESC') @group = @project.group + if @group @group_members = @group.group_members - @group_members = @group_members.non_invite unless can?(current_user, :admin_group, @group) + @group_members = @group_members.non_pending unless can?(current_user, :admin_group, @group) if params[:search].present? users = @group.users.search(params[:search]).to_a @@ -49,7 +50,7 @@ class Projects::ProjectMembersController < Projects::ApplicationController return render_403 unless can?(current_user, :destroy_project_member, @project_member) - @project_member.destroy + @project_member.request? ? @project_member.decline_request : @project_member.destroy respond_to do |format| format.html do @@ -74,15 +75,20 @@ class Projects::ProjectMembersController < Projects::ApplicationController end def leave - @project_member = @project.project_members.find_by(user_id: current_user) + @project_member = + @project.project_members.find_by(user_id: current_user.id) || + @project.project_members.find_by(created_by_id: current_user.id) if can?(current_user, :destroy_project_member, @project_member) + notice = + if @project_member.request? + 'You withdrawn your access request to the project.' + else + 'You left the project.' + end @project_member.destroy - respond_to do |format| - format.html { redirect_to dashboard_projects_path, notice: "You left the project." } - format.js { head :ok } - end + redirect_to dashboard_projects_path, notice: notice else if current_user == @project.owner message = 'You can not leave your own project. Transfer or delete the project.' @@ -94,30 +100,20 @@ class Projects::ProjectMembersController < Projects::ApplicationController end def request_access - redirect_path = namespace_project_path(@project.namespace, @project) - # current_user - # @project - @project_member = ProjectMember.new(source: @project, access_level: ProjectMember::DEVELOPER, user_id: current_user.id, created_by_id: current_user.id, requested: true) - @project_member.save! + @project.request_access(current_user) - - redirect_to redirect_path, notice: 'Your request for access has been queued for review.' + redirect_to namespace_project_path(@project.namespace, @project), + notice: 'Your request for access has been queued for review.' end - def approval - @project_member = @project.project_members.find(params[:id]) + def approve + @project_member = @project.project_members.request.find(params[:id]) return render_403 unless can?(current_user, :update_project_member, @project_member) - @project_member.requested = nil - @project_member.save! + @project_member.accept_request - respond_to do |format| - format.html do - redirect_to namespace_project_project_members_path(@project.namespace, @project) - end - format.js { render nothing: true } - end + redirect_to namespace_project_project_members_path(@project.namespace, @project) end def apply_import diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 4cac69c679..b9211e8847 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -1,24 +1,4 @@ module GroupsHelper - def remove_user_from_group_message(group, member) - if member.user - "Are you sure you want to remove \"#{member.user.name}\" from \"#{group.name}\"?" - else - "Are you sure you want to revoke the invitation for \"#{member.invite_email}\" to join \"#{group.name}\"?" - end - end - - def leave_group_message(group) - "Are you sure you want to leave \"#{group}\" group?" - end - - def should_user_see_group_roles?(user, group) - if user - user.is_admin? || group.members.exists?(user_id: user.id) - else - false - end - end - def can_change_group_visibility_level?(group) can?(current_user, :change_visibility_level, group) end diff --git a/app/helpers/members_helper.rb b/app/helpers/members_helper.rb new file mode 100644 index 0000000000..6599c59d1c --- /dev/null +++ b/app/helpers/members_helper.rb @@ -0,0 +1,117 @@ +module MembersHelper + def member_class(member) + "#{member.source.class.to_s}Member".constantize + end + + def members_association(entity) + "#{entity.class.to_s.underscore}_members".to_sym + end + + def action_member_permission(action, member) + "#{action}_#{member.source.class.to_s.underscore}_member".to_sym + end + + def can_see_entity_roles?(user, entity) + return false unless user + + user.is_admin? || entity.send(members_association(entity)).exists?(user_id: user.id) + end + + def member_path(member) + case member.source + when Project + namespace_project_project_member_path(member.source.namespace, member.source, member) + when Group + group_group_member_path(member.source, member) + else + raise ArgumentError.new('Unknown object class') + end + end + + def resend_invite_member_path(member) + case member.source + when Project + resend_invite_namespace_project_project_member_path(member.source.namespace, member.source, member) + when Group + resend_invite_group_group_member_path(member.source, member) + else + raise ArgumentError.new('Unknown object class') + end + end + + def request_access_path(entity) + case entity + when Project + request_access_namespace_project_project_members_path(entity.namespace, entity) + when Group + request_access_group_group_members_path(entity) + else + raise ArgumentError.new('Unknown object class') + end + end + + def approve_request_member_path(member) + case member.source + when Project + approve_namespace_project_project_member_path(member.source.namespace, member.source, member) + when Group + approve_group_group_member_path(member.source, member) + else + raise ArgumentError.new('Unknown object class') + end + end + + def leave_path(entity) + case entity + when Project + leave_namespace_project_project_members_path(entity.namespace, entity) + when Group + leave_group_group_members_path(entity) + else + raise ArgumentError.new('Unknown object class') + end + end + + def withdraw_request_message(entity) + "Are you sure you want to withdraw your access request for the \"#{entity_name(entity)}\" #{entity_type(entity)}?" + end + + def remove_member_message(member) + entity = member.source + entity_type = entity_type(entity) + entity_name = entity_name(entity) + + if member.request? + "You are going to deny #{member.created_by.name}'s request to join the #{entity_name} #{entity_type}. Are you sure?" + elsif member.invite? + "You are going to revoke the invitation for #{member.invite_email} to join the #{entity_name} #{entity_type}. Are you sure?" + else + "You are going to remove #{member.user.name} from the #{entity_name} #{entity_type}. Are you sure?" + end + end + + def remove_member_title(member) + member.request? ? 'Deny access request' : 'Remove user' + end + + def leave_confirmation_message(entity) + "Are you sure you want to leave \"#{entity_name(entity)}\" #{entity_type(entity)}?" + end + + private + + def entity_type(entity) + entity.class.to_s.underscore + end + + def entity_name(entity) + case entity + when Project + entity.name_with_namespace + when Group + entity.name + else + raise ArgumentError.new('Unknown object class') + end + end +end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index a015b5e6a0..03941f87b1 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -1,16 +1,6 @@ module ProjectsHelper - def remove_from_project_team_message(project, member) - if !member.user - "You are going to revoke the invitation for #{member.invite_email} to join #{project.name} project team. Are you sure?" - elsif member.request? - "You are going to deny #{member.user.name}'s request to join #{project.name} project team. Are you sure?" - else - "You are going to remove #{member.user.name} from #{project.name} project team. Are you sure?" - end - end - - def approve_for_project_team_message(project, member) - "You are going to approve #{member.user.name}'s request for #{member.human_access} access to the #{project.name} project team. Are you sure?" + def max_access_level(project, user) + Gitlab::Access.options_with_owner.key(project.team.max_member_access(user.id)) end def link_to_project(project) @@ -121,14 +111,6 @@ module ProjectsHelper end end - def user_max_access_in_project(user_id, project) - level = project.team.max_member_access(user_id) - - if level - Gitlab::Access.options_with_owner.key(level) - end - end - def license_short_name(project) return 'LICENSE' if project.repository.license_key.nil? @@ -292,10 +274,6 @@ module ProjectsHelper end end - def leave_project_message(project) - "Are you sure you want to leave \"#{project.name}\" project?" - end - def new_readme_path ref = @repository.root_ref if @repository ref ||= 'master' diff --git a/app/mailers/emails/groups.rb b/app/mailers/emails/groups.rb index 1c43f95dc8..fe218bfbe0 100644 --- a/app/mailers/emails/groups.rb +++ b/app/mailers/emails/groups.rb @@ -1,22 +1,38 @@ module Emails module Groups + def group_access_requested_email(group_member_id) + setup_group_member_mail(group_member_id) + + @requester = @group_member.created_by + + group_admins = User.where(id: @group.group_members.admins.pluck(:user_id)).pluck(:notification_email) + + mail(to: group_admins, + subject: subject("Request to join #{@group.name} group")) + end + def group_access_granted_email(group_member_id) - @group_member = GroupMember.find(group_member_id) - @group = @group_member.group + setup_group_member_mail(group_member_id) - @target_url = group_url(@group) @current_user = @group_member.user - mail(to: @group_member.user.notification_email, - subject: subject("Access to group was granted")) + mail(to: @current_user.notification_email, + subject: subject("Access to #{@group.name} group was granted")) + end + + def group_access_denied_email(group_id, user_id) + @group = Group.find(group_id) + @current_user = User.find(user_id) + @target_url = group_url(@group) + + mail(to: @current_user.notification_email, + subject: subject("Access to #{@group.name} group was denied")) end def group_member_invited_email(group_member_id, token) - @group_member = GroupMember.find group_member_id - @group = @group_member.group - @token = token + setup_group_member_mail(group_member_id) - @target_url = group_url(@group) + @token = token @current_user = @group_member.user mail(to: @group_member.invite_email, @@ -24,15 +40,12 @@ module Emails end def group_invite_accepted_email(group_member_id) - @group_member = GroupMember.find group_member_id + setup_group_member_mail(group_member_id) return if @group_member.created_by.nil? - @group = @group_member.group - - @target_url = group_url(@group) @current_user = @group_member.created_by - mail(to: @group_member.created_by.notification_email, + mail(to: @current_user.notification_email, subject: subject("Invitation accepted")) end @@ -43,10 +56,18 @@ module Emails @current_user = @created_by = User.find(created_by_id) @access_level = access_level @invite_email = invite_email - + @target_url = group_url(@group) mail(to: @created_by.notification_email, subject: subject("Invitation declined")) end + + private + + def setup_group_member_mail(group_member_id) + @group_member = GroupMember.find(group_member_id) + @group = @group_member.group + @target_url = group_url(@group) + end end end diff --git a/app/mailers/emails/projects.rb b/app/mailers/emails/projects.rb index 6662c407c2..43a2a7e80a 100644 --- a/app/mailers/emails/projects.rb +++ b/app/mailers/emails/projects.rb @@ -1,64 +1,38 @@ module Emails module Projects + def project_access_requested_email(project_member_id) + setup_project_member_mail(project_member_id) + + @requester = @project_member.created_by + + project_admins = User.where(id: @project.project_members.admins.pluck(:user_id)).pluck(:notification_email) + + mail(to: project_admins, + subject: subject("Request to join #{@project.name_with_namespace} project")) + end + def project_access_granted_email(project_member_id) - @project_member = ProjectMember.find project_member_id - @project = @project_member.project + setup_project_member_mail(project_member_id) - @target_url = namespace_project_url(@project.namespace, @project) @current_user = @project_member.user - mail(to: @project_member.user.notification_email, - subject: subject("Access to project was granted")) + mail(to: @current_user.notification_email, + subject: subject("Access to #{@project.name_with_namespace} project was granted")) end - def project_member_requested_access(project_member_id) - @project_member = ProjectMember.find project_member_id - @project = @project_member.project + def project_access_denied_email(project_id, user_id) + @project = Project.find(project_id) + @current_user = User.find(user_id) @target_url = namespace_project_url(@project.namespace, @project) - project_admins = ProjectMember.in_project(@project) - .where(access_level: [Gitlab::Access::OWNER, Gitlab::Access::MASTER]) - .pluck(:notification_email) - - project_admins.each do |address| - mail(to: address, - subject: subject("Request to join project: #{@project.name_with_namespace}")) - end + mail(to: @current_user.notification_email, + subject: subject("Access to #{@project.name_with_namespace} project was denied")) end - def project_request_access_accepted_email(project_member_id) - @project_member = ProjectMember.find project_member_id - return if @project_member.created_by.nil? - - @project = @project_member.project - - @target_url = namespace_project_url(@project.namespace, @project) - @current_user = @project_member.created_by - - mail(to: @project_member.created_by.notification_email, - subject: subject('Request for access granted')) - end - - def project_request_access_declined_email(project_member_id) - @project_member = ProjectMember.find project_member_id - return if @project_member.created_by.nil? - - @project = @project_member.project - - @target_url = namespace_project_url(@project.namespace, @project) - @current_user = @project_member.created_by - - mail(to: @project_member.created_by.notification_email, - subject: subject('Request for access declined')) - end - - def project_member_invited_email(project_member_id, token) - @project_member = ProjectMember.find project_member_id - @project = @project_member.project - @token = token + setup_project_member_mail(project_member_id) - @target_url = namespace_project_url(@project.namespace, @project) + @token = token @current_user = @project_member.user mail(to: @project_member.invite_email, @@ -66,12 +40,9 @@ module Emails end def project_invite_accepted_email(project_member_id) - @project_member = ProjectMember.find project_member_id + setup_project_member_mail(project_member_id) return if @project_member.created_by.nil? - @project = @project_member.project - - @target_url = namespace_project_url(@project.namespace, @project) @current_user = @project_member.created_by mail(to: @project_member.created_by.notification_email, @@ -117,5 +88,13 @@ module Emails reply_to: @message.reply_to, subject: @message.subject) end + + private + + def setup_project_member_mail(project_member_id) + @project_member = ProjectMember.find(project_member_id) + @project = @project_member.project + @target_url = namespace_project_url(@project.namespace, @project) + end end end diff --git a/app/models/ability.rb b/app/models/ability.rb index b3db26f989..90156bf130 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -153,7 +153,7 @@ class Ability RequestStore.store[key] ||= begin # Push abilities on the users team role - rules.push(*project_team_rules(project.team, user)) unless project.team.pending?(user) + rules.push(*project_team_rules(project.team, user)) if project.owner == user || (project.group && project.group.has_owner?(user)) || @@ -187,6 +187,8 @@ class Ability project_report_rules elsif team.guest?(user) project_guest_rules + else + [] end end @@ -458,6 +460,8 @@ class Ability rules << :destroy_group_member elsif user == target_user rules << :destroy_group_member + elsif subject.request? && user == subject.created_by + rules << :destroy_group_member end end @@ -477,6 +481,8 @@ class Ability rules << :destroy_project_member elsif user == target_user rules << :destroy_project_member + elsif subject.request? && user == subject.created_by + rules << :destroy_project_member end end diff --git a/app/models/concerns/access_requestable.rb b/app/models/concerns/access_requestable.rb new file mode 100644 index 0000000000..cf37284e31 --- /dev/null +++ b/app/models/concerns/access_requestable.rb @@ -0,0 +1,27 @@ +# == AccessRequestable concern +# +# Contains functionality related to objects that can receive request for access. +# +# Used by Project, and Group. +# +module AccessRequestable + extend ActiveSupport::Concern + + def request_access(user) + members.create( + access_level: Gitlab::Access::DEVELOPER, + created_by: user, + requested_at: Time.now.utc) + end + + def access_requested?(user) + members.where(created_by_id: user.id).where.not(requested_at: nil).any? + end + + private + + # Returns a `_members` association, e.g.: project_members, group_members + def members + @members ||= send("#{self.class.to_s.underscore}_members".to_sym) + end +end diff --git a/app/models/group.rb b/app/models/group.rb index aec92e335e..b6929112cb 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -3,6 +3,7 @@ require 'carrierwave/orm/activerecord' class Group < Namespace include Gitlab::ConfigHelper include Gitlab::VisibilityLevel + include AccessRequestable include Referable has_many :group_members, dependent: :destroy, as: :source, class_name: 'GroupMember' diff --git a/app/models/member.rb b/app/models/member.rb index 2210e7dd66..5c3a5eab40 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -8,7 +8,7 @@ class Member < ActiveRecord::Base belongs_to :user belongs_to :source, polymorphic: true - validates :user, presence: true, unless: :invite? + validates :user, presence: true, unless: :pending? validates :source, presence: true validates :user_id, uniqueness: { scope: [:source_type, :source_id], message: "already exists in source", @@ -26,29 +26,25 @@ class Member < ActiveRecord::Base allow_nil: true } - scope :invite, -> { where(user_id: nil) } - scope :non_invite, -> { where('user_id IS NOT NULL') } - scope :request, -> { where(requested: true) } - scope :non_request, -> { where(requested: nil) } - scope :pending, -> { where("user_id IS NULL OR requested") } - scope :non_pending, -> { self.non_invite.non_request } + scope :invite, -> { where.not(invite_token: nil) } + scope :request, -> { where.not(requested_at: nil) } + scope :non_request, -> { where(requested_at: nil) } + scope :non_pending, -> { where.not(user_id: nil) } scope :guests, -> { where(access_level: GUEST) } scope :reporters, -> { where(access_level: REPORTER) } scope :developers, -> { where(access_level: DEVELOPER) } scope :masters, -> { where(access_level: MASTER) } scope :owners, -> { where(access_level: OWNER) } + scope :admins, -> { where(access_level: [OWNER, MASTER]) } before_validation :generate_invite_token, on: :create, if: -> (member) { member.invite_email.present? } after_create :send_invite, if: :invite? - after_create :send_request_access, if: :request? - + after_create :send_request, if: :request? after_create :create_notification_setting, unless: :pending? after_create :post_create_hook, unless: :pending? - after_update :post_update_hook, unless: :pending? - after_destroy :post_destroy_hook, unless: :pending? delegate :name, :username, :email, to: :user, prefix: true @@ -111,31 +107,29 @@ class Member < ActiveRecord::Base end def request? - self.requested + user.nil? && created_by.present? && requested_at.present? end def invite? self.invite_token.present? end - def accept_request_access! + def accept_request return false unless request? - self.request = false - saved = self.save + updated = self.update(user: created_by, requested_at: nil) + after_accept_request if updated - after_accept_request_access if saved - - saved + updated end - def decline_request_access! + def decline_request return false unless request? - destroyed = self.destroy - after_decline_request_access if destroyed + self.destroy + after_decline_request if destroyed? - destroyed + destroyed? end def accept_invite!(new_user) @@ -191,11 +185,11 @@ class Member < ActiveRecord::Base private - def send_request_access + def send_invite # override in subclass end - def send_invite + def send_request # override in subclass end @@ -211,14 +205,6 @@ class Member < ActiveRecord::Base system_hook_service.execute_hooks_for(self, :destroy) end - def after_accept_request_access - post_create_hook - end - - def after_decline_request_access - # override in subclass - end - def after_accept_invite post_create_hook end @@ -227,6 +213,14 @@ class Member < ActiveRecord::Base # override in subclass end + def after_accept_request + post_create_hook + end + + def after_decline_request + # override in subclass + end + def system_hook_service SystemHooksService.new end diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb index f63a0debf1..476b4816b9 100644 --- a/app/models/members/group_member.rb +++ b/app/models/members/group_member.rb @@ -8,9 +8,6 @@ class GroupMember < Member validates_format_of :source_type, with: /\ANamespace\z/ default_scope { where(source_type: SOURCE_TYPE) } - scope :with_group, ->(group) { where(source_id: group.id) } - scope :with_user, ->(user) { where(user_id: user.id) } - def self.access_level_roles Gitlab::Access.options_with_owner end @@ -31,6 +28,12 @@ class GroupMember < Member super end + def send_request + notification_service.new_group_access_request(self) + + super + end + def post_create_hook notification_service.new_group_member(self) @@ -56,4 +59,10 @@ class GroupMember < Member super end + + def after_decline_request + notification_service.decline_group_access_request(group, created_by) + + super + end end diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index 9db8db8450..c6fd1a5c3d 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -11,8 +11,6 @@ class ProjectMember < Member default_scope { where(source_type: SOURCE_TYPE) } scope :in_project, ->(project) { where(source_id: project.id) } - scope :in_projects, ->(projects) { where(source_id: projects.pluck(:id)) } - scope :with_user, ->(user) { where(user_id: user.id) } before_destroy :delete_member_todos @@ -84,7 +82,7 @@ class ProjectMember < Member Gitlab::Access.sym_options end - def access_roles + def access_level_roles Gitlab::Access.options end end @@ -107,14 +105,14 @@ class ProjectMember < Member user.todos.where(project_id: source_id).destroy_all if user end - def send_request_access - notification_service.request_access_project_member(self) + def send_invite + notification_service.invite_project_member(self, @raw_invite_token) super end - def send_invite - notification_service.invite_project_member(self, @raw_invite_token) + def send_request + notification_service.new_project_access_request(self) super end @@ -142,18 +140,6 @@ class ProjectMember < Member super end - def after_accept_request_access - notification_service.accept_project_request_access(self) - - super - end - - def after_decline_request_access - notification_service.decline_project_request_access(self) - - super - end - def after_accept_invite notification_service.accept_project_invite(self) @@ -166,6 +152,12 @@ class ProjectMember < Member super end + def after_decline_request + notification_service.decline_project_access_request(project, created_by) + + super + end + def event_service EventCreateService.new end diff --git a/app/models/project.rb b/app/models/project.rb index dfa99fe0df..ef66537349 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -5,6 +5,7 @@ class Project < ActiveRecord::Base include Gitlab::ShellAdapter include Gitlab::VisibilityLevel include Gitlab::CurrentSettings + include AccessRequestable include Referable include Sortable include AfterCommitQueue @@ -102,7 +103,7 @@ class Project < ActiveRecord::Base has_many :snippets, dependent: :destroy, class_name: 'ProjectSnippet' has_many :hooks, dependent: :destroy, class_name: 'ProjectHook' has_many :protected_branches, dependent: :destroy - has_many :project_members, dependent: :destroy, as: :source, class_name: 'ProjectMember' + has_many :project_members, dependent: :destroy, as: :source, class_name: 'ProjectMember' has_many :users, through: :project_members has_many :deploy_keys_projects, dependent: :destroy has_many :deploy_keys, through: :deploy_keys_projects @@ -680,16 +681,6 @@ class Project < ActiveRecord::Base end end - def project_member_by_name_or_email(name = nil, email = nil) - user = users.find_by('name like ? or email like ?', name, email) - project_members.where(user: user) if user - end - - # Get Team Member record by user id - def project_member_by_id(user_id) - project_members.find_by(user_id: user_id) - end - def name_with_namespace @name_with_namespace ||= begin if namespace diff --git a/app/models/project_team.rb b/app/models/project_team.rb index 769b73666c..7fb17df0e9 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -21,16 +21,6 @@ class ProjectTeam end end - def find(user_id) - user = project.users.find_by(id: user_id) - - if group - user ||= group.users.find_by(id: user_id) - end - - user - end - def find_member(user_id) member = project.project_members.find_by(user_id: user_id) @@ -61,13 +51,10 @@ class ProjectTeam ProjectMember.truncate_team(project) end - def users - members - end - def members @members ||= fetch_members end + alias_method :users, :members def guests @guests ||= fetch_members(:guests) @@ -115,12 +102,6 @@ class ProjectTeam false end - def pending?(user) - project.project_members.each do |member| - return member.pending? if member.user_id == user.id - end - end - def guest?(user) max_member_access(user.id) == Gitlab::Access::GUEST end @@ -147,10 +128,6 @@ class ProjectTeam end end - def human_max_access(user_id) - Gitlab::Access.options_with_owner.key(max_member_access(user_id)) - end - # This method assumes project and group members are eager loaded for optimal # performance. def max_member_access(user_id) @@ -179,6 +156,7 @@ class ProjectTeam access.compact.max end + private def max_invited_level(user_id) project.project_group_links.map do |group_link| @@ -195,8 +173,6 @@ class ProjectTeam end.compact.max end - private - def fetch_members(level = nil) project_members = project.project_members group_members = group ? group.group_members : [] diff --git a/app/models/user.rb b/app/models/user.rb index a5b3c8afe5..8d0427da5a 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -56,8 +56,7 @@ class User < ActiveRecord::Base # Groups has_many :members, dependent: :destroy - has_many :project_members, source: 'ProjectMember' - has_many :group_members, source: 'GroupMember' + has_many :group_members, dependent: :destroy, source: 'GroupMember' has_many :groups, through: :group_members has_many :owned_groups, -> { where members: { access_level: Gitlab::Access::OWNER } }, through: :group_members, source: :group has_many :masters_groups, -> { where members: { access_level: Gitlab::Access::MASTER } }, through: :group_members, source: :group @@ -65,13 +64,13 @@ class User < ActiveRecord::Base # Projects has_many :groups_projects, through: :groups, source: :projects has_many :personal_projects, through: :namespace, source: :projects + has_many :project_members, dependent: :destroy, class_name: 'ProjectMember' has_many :projects, through: :project_members has_many :created_projects, foreign_key: :creator_id, class_name: 'Project' has_many :users_star_projects, dependent: :destroy has_many :starred_projects, through: :users_star_projects, source: :project has_many :snippets, dependent: :destroy, foreign_key: :author_id, class_name: "Snippet" - has_many :project_members, dependent: :destroy, class_name: 'ProjectMember' has_many :issues, dependent: :destroy, foreign_key: :author_id has_many :notes, dependent: :destroy, foreign_key: :author_id has_many :merge_requests, dependent: :destroy, foreign_key: :author_id diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index e7676861e9..cd11feb9d7 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -173,16 +173,13 @@ class NotificationService end end - def request_access_project_member(project_member) - mailer.project_member_requested_access(project_member.id).deliver_later + # Project access request + def new_project_access_request(project_member) + mailer.project_access_requested_email(project_member.id).deliver_later end - def accept_project_request_access(project_member) - mailer.project_request_access_accepted_email(project_member.id).deliver_later - end - - def decline_project_request_access(project_member) - mailer.project_request_access_declined_email(project_member.id).deliver_later + def decline_project_access_request(project, user) + mailer.project_access_denied_email(project.id, user.id).deliver_later end def invite_project_member(project_member, token) @@ -210,6 +207,15 @@ class NotificationService mailer.project_access_granted_email(project_member.id).deliver_later end + # Group access request + def new_group_access_request(group_member) + mailer.group_access_requested_email(group_member.id).deliver_later + end + + def decline_group_access_request(group, user) + mailer.group_access_denied_email(group.id, user.id).deliver_later + end + def invite_group_member(group_member, token) mailer.group_member_invited_email(group_member.id, token).deliver_later end diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml index f309e80a39..5b8a0262ea 100644 --- a/app/views/admin/groups/show.html.haml +++ b/app/views/admin/groups/show.html.haml @@ -109,7 +109,7 @@ %span.pull-right.light = member.human_access - if can?(current_user, :destroy_group_member, member) - = link_to group_group_member_path(@group, member), data: { confirm: remove_user_from_group_message(@group, member) }, method: :delete, remote: true, class: "btn-xs btn btn-remove", title: 'Remove user from group' do + = link_to group_group_member_path(@group, member), data: { confirm: remove_member_message(member) }, method: :delete, remote: true, class: "btn-xs btn btn-remove", title: 'Remove user from group' do %i.fa.fa-minus.fa-inverse .panel-footer = paginate @members, param_name: 'members_page', theme: 'gitlab' diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml index 73986d21bc..9e55a562e1 100644 --- a/app/views/admin/projects/show.html.haml +++ b/app/views/admin/projects/show.html.haml @@ -142,7 +142,7 @@ %i.fa.fa-pencil-square-o %ul.well-list - @group_members.each do |member| - = render 'groups/group_members/group_member', member: member, show_controls: false + = render 'shared/members/member', member: member, show_controls: false .panel-footer = paginate @group_members, param_name: 'group_members_page', theme: 'gitlab' @@ -172,7 +172,7 @@ %span.light Owner - else %span.light= project_member.human_access - = link_to namespace_project_project_member_path(@project.namespace, @project, project_member), data: { confirm: remove_from_project_team_message(@project, project_member)}, method: :delete, remote: true, class: "btn btn-sm btn-remove" do + = link_to namespace_project_project_member_path(@project.namespace, @project, project_member), data: { confirm: remove_member_message(project_member)}, method: :delete, remote: true, class: "btn btn-sm btn-remove" do %i.fa.fa-times .panel-footer = paginate @project_members, param_name: 'project_members_page', theme: 'gitlab' diff --git a/app/views/admin/users/groups.html.haml b/app/views/admin/users/groups.html.haml index dbecb7bbfd..b0a709a568 100644 --- a/app/views/admin/users/groups.html.haml +++ b/app/views/admin/users/groups.html.haml @@ -13,7 +13,7 @@ .pull-right %span.light= group_member.human_access - unless group_member.owner? - = link_to group_group_member_path(group, group_member), data: { confirm: remove_user_from_group_message(group, group_member) }, method: :delete, remote: true, class: "btn-xs btn btn-remove", title: 'Remove user from group' do + = link_to group_group_member_path(group, group_member), data: { confirm: remove_member_message(group_member) }, method: :delete, remote: true, class: "btn-xs btn btn-remove", title: 'Remove user from group' do %i.fa.fa-times.fa-inverse - else .nothing-here-block This user has no groups. diff --git a/app/views/admin/users/projects.html.haml b/app/views/admin/users/projects.html.haml index b655b2a15f..84b9ceb23b 100644 --- a/app/views/admin/users/projects.html.haml +++ b/app/views/admin/users/projects.html.haml @@ -38,6 +38,5 @@ %span.light= member.human_access - if member.respond_to? :project - = link_to namespace_project_project_member_path(project.namespace, project, member), data: { confirm: remove_from_project_team_message(project, member) }, remote: true, method: :delete, class: "btn-xs btn btn-remove", title: 'Remove user from project' do + = link_to namespace_project_project_member_path(project.namespace, project, member), data: { confirm: remove_member_message(member) }, remote: true, method: :delete, class: "btn-xs btn btn-remove", title: 'Remove user from project' do %i.fa.fa-times - diff --git a/app/views/groups/group_members/_group_member.html.haml b/app/views/groups/group_members/_group_member.html.haml deleted file mode 100644 index 6bb542e658..0000000000 --- a/app/views/groups/group_members/_group_member.html.haml +++ /dev/null @@ -1,57 +0,0 @@ -- user = member.user -- return unless user || member.invite? -- show_roles = local_assigns.fetch(:show_roles, true) - -%li{class: "#{dom_class(member)} js-toggle-container", id: dom_id(member)} - %span{class: ("list-item-name" if show_controls)} - - if member.user - = image_tag avatar_icon(user, 24), class: "avatar s24", alt: '' - %strong - = link_to user.name, user_path(user) - %span.cgray= user.username - - if user == current_user - %span.label.label-success It's you - - if user.blocked? - %label.label.label-danger - %strong Blocked - - else - = image_tag avatar_icon(member.invite_email, 24), class: "avatar s24", alt: '' - %strong - = member.invite_email - %span.cgray - invited - - if member.created_by - by - = link_to member.created_by.name, user_path(member.created_by) - = time_ago_with_tooltip(member.created_at) - - - if show_controls && can?(current_user, :admin_group_member, @group) - = link_to resend_invite_group_group_member_path(@group, member), method: :post, class: "btn-xs btn", title: 'Resend invite' do - Resend invite - - - if show_roles && should_user_see_group_roles?(current_user, @group) - %span.pull-right - %strong.member-access-level= member.human_access - - if show_controls - - if can?(current_user, :update_group_member, member) - = button_tag class: "btn-xs btn btn-grouped inline js-toggle-button", - title: 'Edit access level', type: 'button' do - = icon('pencil') - - - if can?(current_user, :destroy_group_member, member) -   - - if current_user == user - = link_to leave_group_group_members_path(@group), data: { confirm: leave_group_message(@group.name)}, method: :delete, class: "btn-xs btn btn-remove", title: 'Remove user from group' do - = icon("sign-out") - Leave - - else - = link_to group_group_member_path(@group, member), data: { confirm: remove_user_from_group_message(@group, member) }, method: :delete, remote: true, class: "btn-xs btn btn-remove", title: 'Remove user from group' do - = icon('trash') - - .edit-member.hide.js-toggle-content - %br - = form_for [@group, member], remote: true do |f| - .prepend-top-10 - = f.select :access_level, options_for_select(GroupMember.access_level_roles, member.access_level), {}, class: 'form-control' - .prepend-top-10 - = f.submit 'Save', class: 'btn btn-save btn-sm' diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml index 0eb6bbd442..a39d5d3d0f 100644 --- a/app/views/groups/group_members/index.html.haml +++ b/app/views/groups/group_members/index.html.haml @@ -6,12 +6,13 @@ .panel-heading Add new user to group .panel-body - - if should_user_see_group_roles?(current_user, @group) - %p.light - Members of group have access to all group projects. + %p.light + Members of group have access to all group projects. .new-group-member-holder = render "new_group_member" + = render "shared/members/requests", entity: @group, members: @members + .panel.panel-default .panel-heading %strong #{@group.name} @@ -25,9 +26,8 @@ = button_tag class: 'btn', title: 'Search' do = icon("search") %ul.content-list - - @members.each do |member| - = render 'groups/group_members/group_member', member: member, show_controls: true - = paginate @members, theme: 'gitlab' + = render partial: 'shared/members/member', collection: @members.non_request, as: :member + = paginate @members.non_request, theme: 'gitlab' :javascript $('form.member-search-form').on('submit', function(event) { diff --git a/app/views/groups/group_members/update.js.haml b/app/views/groups/group_members/update.js.haml index df726e2b2b..b0b3a51ce5 100644 --- a/app/views/groups/group_members/update.js.haml +++ b/app/views/groups/group_members/update.js.haml @@ -1,2 +1,2 @@ :plain - $("##{dom_id(@group_member)}").replaceWith('#{escape_javascript(render(@group_member, member: @group_member, show_controls: true))}'); + $("##{dom_id(@group_member)}").replaceWith('#{escape_javascript(render(@group_member, member: @group_member))}'); diff --git a/app/views/layouts/nav/_group_settings.html.haml b/app/views/layouts/nav/_group_settings.html.haml index 0b2673f1a8..b461772b87 100644 --- a/app/views/layouts/nav/_group_settings.html.haml +++ b/app/views/layouts/nav/_group_settings.html.haml @@ -1,20 +1,3 @@ - if current_user - - if access = @group.users.find_by(id: current_user.id) - .controls - .dropdown.group-settings-dropdown - %a.dropdown-new.btn.btn-default#group-settings-button{href: '#', 'data-toggle' => 'dropdown'} - = icon('cog') - = icon('caret-down') - %ul.dropdown-menu.dropdown-menu-align-right - - if can?(current_user, :admin_group, @group) - = nav_link(path: 'groups#projects') do - = link_to projects_group_path(@group), title: 'Projects' do - Projects - %li.divider - %li - = link_to edit_group_path(@group) do - Edit Group - %li - = link_to leave_group_group_members_path(@group), - data: { confirm: leave_group_message(@group.name) }, method: :delete, title: 'Leave group' do - Leave Group + .controls + = render 'shared/group_or_project_home_dropdown', entity: @group diff --git a/app/views/layouts/nav/_project.html.haml b/app/views/layouts/nav/_project.html.haml index 1336191bc5..3398794302 100644 --- a/app/views/layouts/nav/_project.html.haml +++ b/app/views/layouts/nav/_project.html.haml @@ -8,19 +8,6 @@ = icon('caret-down') %ul.dropdown-menu.dropdown-menu-align-right = render 'layouts/nav/project_settings' - - - if access - %li - = link_to leave_namespace_project_project_members_path(@project.namespace, @project), - data: { confirm: leave_project_message(@project) }, method: :delete, title: 'Leave project' do - Leave Project - - else - = link_to request_access_namespace_project_project_members_path(@project.namespace, @project), - class: 'btn btn-gray', style: 'margin-left: 10px', method: :post, title: 'Request access' do - Request Access - - - %li.divider - if can_edit %li @@ -28,13 +15,18 @@ Edit Project - if access %li - = link_to leave_namespace_project_project_members_path(@project.namespace, @project), - data: { confirm: leave_project_message(@project) }, method: :delete, title: 'Leave project' do + = link_to leave_path(@project), + data: { confirm: leave_confirmation_message(@project) }, method: :delete do Leave Project + - elsif @project.access_requested?(current_user) + %li + = link_to leave_path(@project), + data: { confirm: withdraw_request_message(@project) }, method: :delete do + Withdraw Request - else %li - = link_to request_access_namespace_project_project_members_path(@project.namespace, @project), - class: 'btn btn-gray', style: 'margin-left: 10px', method: :post, title: 'Request access' do + = link_to request_access_path(@project), + class: 'btn btn-gray', style: 'margin-left: 10px', method: :post do Request Access %div{ class: nav_control_class } diff --git a/app/views/notify/group_access_denied_email.html.haml b/app/views/notify/group_access_denied_email.html.haml new file mode 100644 index 0000000000..4edfd4e479 --- /dev/null +++ b/app/views/notify/group_access_denied_email.html.haml @@ -0,0 +1,2 @@ +%p + Your request to join group #{link_to @group.name, @target_url} has been denied. diff --git a/app/views/notify/group_access_denied_email.text.erb b/app/views/notify/group_access_denied_email.text.erb new file mode 100644 index 0000000000..cb32177e82 --- /dev/null +++ b/app/views/notify/group_access_denied_email.text.erb @@ -0,0 +1,3 @@ +Your request to join group <%= @group.name %> has been denied. + +<%= @target_url %> diff --git a/app/views/notify/group_access_granted_email.html.haml b/app/views/notify/group_access_granted_email.html.haml index f1916d624b..1283758c57 100644 --- a/app/views/notify/group_access_granted_email.html.haml +++ b/app/views/notify/group_access_granted_email.html.haml @@ -1,4 +1,3 @@ %p - = "You have been granted #{@group_member.human_access} access to group" - = link_to group_url(@group) do - = @group.name + You have been granted #{@group_member.human_access} access to group + #{link_to @group.name, @target_url}. diff --git a/app/views/notify/group_access_granted_email.text.erb b/app/views/notify/group_access_granted_email.text.erb index ef9617bfc1..c756835007 100644 --- a/app/views/notify/group_access_granted_email.text.erb +++ b/app/views/notify/group_access_granted_email.text.erb @@ -1,4 +1,3 @@ +You have been granted <%= @group_member.human_access %> access to group <%= @group.name %>. -You have been granted <%= @group_member.human_access %> access to group <%= @group.name %> - -<%= url_for(group_url(@group)) %> +<%= @target_url %> diff --git a/app/views/notify/group_access_requested_email.html.haml b/app/views/notify/group_access_requested_email.html.haml new file mode 100644 index 0000000000..4fbcedabae --- /dev/null +++ b/app/views/notify/group_access_requested_email.html.haml @@ -0,0 +1,3 @@ +%p + #{link_to @requester.name, @requester} requested #{@group_member.human_access} + access to group #{link_to @group.name, @target_url}. diff --git a/app/views/notify/group_access_requested_email.text.erb b/app/views/notify/group_access_requested_email.text.erb new file mode 100644 index 0000000000..2f9d293a79 --- /dev/null +++ b/app/views/notify/group_access_requested_email.text.erb @@ -0,0 +1,3 @@ +<%= @requester.name %> (<%= user_url(@requester) %>) requested <%= @group_member.human_access %> access to group <%= @group.name %> + +<%= @target_url %> diff --git a/app/views/notify/project_access_denied_email.html.haml b/app/views/notify/project_access_denied_email.html.haml new file mode 100644 index 0000000000..cecdaf24f3 --- /dev/null +++ b/app/views/notify/project_access_denied_email.html.haml @@ -0,0 +1,3 @@ +%p + Your request to join project #{link_to @project.name_with_namespace, @target_url} + has been denied. diff --git a/app/views/notify/project_request_access_denied_email.text.erb b/app/views/notify/project_access_denied_email.text.erb similarity index 58% rename from app/views/notify/project_request_access_denied_email.text.erb rename to app/views/notify/project_access_denied_email.text.erb index a9c57e4cab..24357e059d 100644 --- a/app/views/notify/project_request_access_denied_email.text.erb +++ b/app/views/notify/project_access_denied_email.text.erb @@ -1,3 +1,3 @@ Your request to join project <%= @project.name_with_namespace %> has been denied. -<%= namespace_project_url(@project.namespace, @project) %> +<%= @target_url %> diff --git a/app/views/notify/project_access_granted_email.html.haml b/app/views/notify/project_access_granted_email.html.haml index dfc30a2d36..88873e7fe5 100644 --- a/app/views/notify/project_access_granted_email.html.haml +++ b/app/views/notify/project_access_granted_email.html.haml @@ -1,5 +1,3 @@ %p - = "You have been granted #{@project_member.human_access} access to project" -%p - = link_to namespace_project_url(@project.namespace, @project) do - = @project.name_with_namespace + You have been granted #{@project_member.human_access} access to project + #{link_to @project.name_with_namespace, @target_url}. diff --git a/app/views/notify/project_access_granted_email.text.erb b/app/views/notify/project_access_granted_email.text.erb index 68eb1611ba..f5e4b31385 100644 --- a/app/views/notify/project_access_granted_email.text.erb +++ b/app/views/notify/project_access_granted_email.text.erb @@ -1,4 +1,3 @@ +You have been granted <%= @project_member.human_access %> access to project <%= @project.name_with_namespace %>. -You have been granted <%= @project_member.human_access %> access to project <%= @project.name_with_namespace %> - -<%= url_for(namespace_project_url(@project.namespace, @project)) %> +<%= @target_url %> diff --git a/app/views/notify/project_access_requested_email.html.haml b/app/views/notify/project_access_requested_email.html.haml new file mode 100644 index 0000000000..2a705ad3b0 --- /dev/null +++ b/app/views/notify/project_access_requested_email.html.haml @@ -0,0 +1,3 @@ +%p + #{link_to @requester.name, @requester} requested #{@project_member.human_access} + access to project #{link_to @project.name_with_namespace, @target_url}. diff --git a/app/views/notify/project_access_requested_email.text.erb b/app/views/notify/project_access_requested_email.text.erb new file mode 100644 index 0000000000..2437fa4ee8 --- /dev/null +++ b/app/views/notify/project_access_requested_email.text.erb @@ -0,0 +1,3 @@ +<%= @requester.name %> (<%= user_url(@requester) %>) requested <%= @project_member.human_access %> access to project <%= @project.name_with_namespace %>. + +<%= @target_url %> diff --git a/app/views/notify/project_request_access_accepted_email.html.haml b/app/views/notify/project_request_access_accepted_email.html.haml deleted file mode 100644 index dfdf82e70a..0000000000 --- a/app/views/notify/project_request_access_accepted_email.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -%p - Your request to join project - #{link_to @project.name_with_namespace, namespace_project_url(@project.namespace, @project)} - has been granted with #{@project_member.human_access} access. diff --git a/app/views/notify/project_request_access_accepted_email.text.erb b/app/views/notify/project_request_access_accepted_email.text.erb deleted file mode 100644 index 9fb6887449..0000000000 --- a/app/views/notify/project_request_access_accepted_email.text.erb +++ /dev/null @@ -1,3 +0,0 @@ -Your request to join project <%= @project.name_with_namespace %> has been granted with <%= @project_member.human_access %> access. - -<%= namespace_project_url(@project.namespace, @project) %> diff --git a/app/views/notify/project_request_access_denied_email.html.haml b/app/views/notify/project_request_access_denied_email.html.haml deleted file mode 100644 index 8ad75b96cf..0000000000 --- a/app/views/notify/project_request_access_denied_email.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -%p - Your request to join project - #{link_to @project.name_with_namespace, namespace_project_url(@project.namespace, @project)} - has been denied. diff --git a/app/views/projects/notes/_note.html.haml b/app/views/projects/notes/_note.html.haml index bcdbff0801..112a532f9d 100644 --- a/app/views/projects/notes/_note.html.haml +++ b/app/views/projects/notes/_note.html.haml @@ -17,7 +17,7 @@ %a{ href: "##{dom_id(note)}" } = time_ago_with_tooltip(note.created_at, placement: 'bottom', html_class: 'note-created-ago') .note-actions - - access = note.project.team.human_max_access(note.author.id) + - access = max_access_level(note.project, note.author) - if access %span.note-role.hidden-xs= access - if current_user diff --git a/app/views/projects/project_members/_group_members.html.haml b/app/views/projects/project_members/_group_members.html.haml index 6671ee2c6d..78c12d52a7 100644 --- a/app/views/projects/project_members/_group_members.html.haml +++ b/app/views/projects/project_members/_group_members.html.haml @@ -9,8 +9,13 @@ = link_to group_group_members_path(@group), class: 'btn' do Manage group members %ul.content-list - - members.limit(20).each do |member| - = render 'groups/group_members/group_member', member: member, show_controls: false - - if members.count > 20 + = render partial: 'shared/members/member', + collection: members.limit(20), + as: :member, + locals: { show_controls: false } + - if members.size > 20 %li - and #{members.count - 20} more. For full list visit #{link_to 'group members page', group_group_members_path(@group)} + and + = members.size - 20 + more. For full list visit + = link_to 'group members page', group_group_members_path(@group) diff --git a/app/views/projects/project_members/_new_project_member.html.haml b/app/views/projects/project_members/_new_project_member.html.haml index f0f3bb3c17..82892a3335 100644 --- a/app/views/projects/project_members/_new_project_member.html.haml +++ b/app/views/projects/project_members/_new_project_member.html.haml @@ -9,7 +9,7 @@ .form-group = f.label :access_level, "Project Access", class: 'control-label' .col-sm-10 - = select_tag :access_level, options_for_select(ProjectMember.access_roles, @project_member.access_level), class: "project-access-select select2" + = select_tag :access_level, options_for_select(ProjectMember.access_level_roles, @project_member.access_level), class: "project-access-select select2" .help-block Read more about role permissions %strong= link_to "here", help_page_path("permissions", "permissions"), class: "vlink" diff --git a/app/views/projects/project_members/_pending.html.haml b/app/views/projects/project_members/_pending.html.haml deleted file mode 100644 index 88ac36937a..0000000000 --- a/app/views/projects/project_members/_pending.html.haml +++ /dev/null @@ -1,21 +0,0 @@ -.panel.panel-default - .panel-heading - %strong #{@project.name} - candidates - %small - (#{members.count}) - .controls - = form_tag namespace_project_project_members_path(@project.namespace, @project), method: :get, class: 'form-inline member-search-form' do - .form-group - = search_field_tag :search, params[:search], { placeholder: 'Find existing member by name', class: 'form-control', spellcheck: false } - = button_tag class: 'btn', title: 'Search' do - = icon("search") - %ul.content-list - - members.each do |project_member| - = render 'project_member', member: project_member - -:javascript - $('form.member-search-form').on('submit', function (event) { - event.preventDefault(); - Turbolinks.visit(this.action + '?' + $(this).serialize()); - }); diff --git a/app/views/projects/project_members/_project_member.html.haml b/app/views/projects/project_members/_project_member.html.haml deleted file mode 100644 index 3faf5dba8a..0000000000 --- a/app/views/projects/project_members/_project_member.html.haml +++ /dev/null @@ -1,66 +0,0 @@ -- user = member.user -- return unless user || member.invite? - -%li{class: "#{dom_class(member)} js-toggle-container project_member_row access-#{member.human_access.downcase}", id: dom_id(member)} - %span.list-item-name - - if member.user - = image_tag avatar_icon(user, 24), class: "avatar s24", alt: '' - %strong - = link_to user.name, user_path(user) - %span.cgray= user.username - - if user == current_user - %span.label.label-success It's you - - if user.blocked? - %label.label.label-danger - %strong Blocked - - if member.request? - %span.label.label-info - Pending Approval - - else - = image_tag avatar_icon(member.invite_email, 24), class: "avatar s24", alt: '' - %strong - = member.invite_email - %span.cgray - invited - - if member.created_by - by - = link_to member.created_by.name, user_path(member.created_by) - = time_ago_with_tooltip(member.created_at) - - - if can?(current_user, :admin_project_member, @project) - = link_to resend_invite_namespace_project_project_member_path(@project.namespace, @project, member), method: :post, class: "btn-xs btn", title: 'Resend invite' do - Resend invite - - if can?(current_user, :admin_project_member, @project) - .pull-right - %strong= member.human_access - - if can?(current_user, :update_project_member, member) - = button_tag class: "btn-xs btn-grouped inline btn js-toggle-button", - title: 'Edit access level', type: 'button' do - = icon('pencil') - - if member.request? -   - = link_to approval_namespace_project_project_member_path(@project.namespace, @project, member), - class: "btn-xs btn btn-success", - title: 'Grant access', type: 'button' do - %i.fa.fa-check.fa-inverse - - - if can?(current_user, :destroy_project_member, member) -   - - if member.request? - = link_to namespace_project_project_member_path(@project.namespace, @project, member), data: { confirm: remove_from_project_team_message(@project, member) }, method: :delete, class: "btn-xs btn btn-remove", title: 'Deny access' do - %i.fa.fa-times.fa-inverse - - elsif current_user == user - = link_to leave_namespace_project_project_members_path(@project.namespace, @project), data: { confirm: leave_project_message(@project) }, method: :delete, class: "btn-xs btn btn-remove", title: 'Leave project' do - = icon("sign-out") - Leave - - else - = link_to namespace_project_project_member_path(@project.namespace, @project, member), data: { confirm: remove_from_project_team_message(@project, member) }, method: :delete, remote: true, class: "btn-xs btn btn-remove", title: 'Remove user from team' do - = icon('trash') - - .edit-member.hide.js-toggle-content - %br - = form_for member, as: :project_member, url: namespace_project_project_member_path(@project.namespace, @project, member), remote: true do |f| - .prepend-top-10 - = f.select :access_level, options_for_select(ProjectMember.access_roles, member.access_level), {}, class: 'form-control' - .prepend-top-10 - = f.submit 'Save', class: 'btn btn-save' diff --git a/app/views/projects/project_members/_shared_group_members.html.haml b/app/views/projects/project_members/_shared_group_members.html.haml index ae13f8428f..952844acef 100644 --- a/app/views/projects/project_members/_shared_group_members.html.haml +++ b/app/views/projects/project_members/_shared_group_members.html.haml @@ -14,8 +14,10 @@ %i.fa.fa-pencil-square-o Edit group members %ul.content-list - - shared_group.group_members.order('access_level DESC').limit(20).each do |member| - = render 'groups/group_members/group_member', member: member, show_controls: false, show_roles: false + = render partial: 'shared/members/member', + collection: shared_group.group_members.order(access_level: :desc).limit(20), + as: :member, + locals: { show_controls: false, show_roles: false } - if shared_group_users_count > 20 %li and #{shared_group_users_count - 20} more. For full list visit #{link_to 'group members page', group_group_members_path(shared_group)} diff --git a/app/views/projects/project_members/_team.html.haml b/app/views/projects/project_members/_team.html.haml index e8dce30425..0320761425 100644 --- a/app/views/projects/project_members/_team.html.haml +++ b/app/views/projects/project_members/_team.html.haml @@ -11,8 +11,7 @@ = button_tag class: 'btn', title: 'Search' do = icon("search") %ul.content-list - - members.each do |project_member| - = render 'project_member', member: project_member + = render partial: 'shared/members/member', collection: members, as: :member :javascript $('form.member-search-form').on('submit', function (event) { diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml index d5a19799c4..61a82724d6 100644 --- a/app/views/projects/project_members/index.html.haml +++ b/app/views/projects/project_members/index.html.haml @@ -12,7 +12,8 @@ %p.light Users with access to this project are listed below. = render "new_project_member" - = render "pending", members: @project_members.request + + = render "shared/members/requests", entity: @project, members: @project_members = render "team", members: @project_members.non_request diff --git a/app/views/shared/_group_or_project_home_dropdown.html.haml b/app/views/shared/_group_or_project_home_dropdown.html.haml new file mode 100644 index 0000000000..fb9e63f2bd --- /dev/null +++ b/app/views/shared/_group_or_project_home_dropdown.html.haml @@ -0,0 +1,30 @@ +- member = entity.send(members_association(entity)).find_by(user_id: current_user.id) +- can_edit = can?(current_user, "admin_#{entity.class.to_s.underscore}".to_sym, entity) + +- if member || can_edit + .dropdown.project-settings-dropdown + %a.dropdown-new.btn.btn-gray{ href: '#', id: "#{entity.class.to_s.underscore}-settings-button", data: { toggle: 'dropdown' } } + = icon('cog') + = icon('caret-down') + %ul.dropdown-menu.dropdown-menu-align-right + - if can_edit + %li + = link_to "Edit #{entity.class.to_s}", [:edit, entity] + + - if member + %li + = link_to "Leave #{entity.class.to_s}", + leave_path(entity), + method: :delete, + data: { confirm: leave_confirmation_message(entity) } +- elsif entity.access_requested?(current_user) + = link_to 'Withdraw Request', + leave_path(entity), + data: { confirm: withdraw_request_message(entity) }, + method: :delete, + class: 'btn btn-grouped btn-gray' +- else + = link_to 'Request Access', + request_access_path(entity), + method: :post, + class: 'btn btn-grouped btn-gray' diff --git a/app/views/shared/groups/_group.html.haml b/app/views/shared/groups/_group.html.haml index a25365a94f..1ad9535100 100644 --- a/app/views/shared/groups/_group.html.haml +++ b/app/views/shared/groups/_group.html.haml @@ -9,7 +9,7 @@ = link_to edit_group_path(group), class: "btn" do = icon('cogs') - = link_to leave_group_group_members_path(group), data: { confirm: leave_group_message(group.name) }, method: :delete, class: "btn", title: 'Leave this group' do + = link_to leave_group_group_members_path(group), data: { confirm: leave_confirmation_message(group) }, method: :delete, class: "btn", title: 'Leave this group' do = icon('sign-out') .stats diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml new file mode 100644 index 0000000000..7e119155a6 --- /dev/null +++ b/app/views/shared/members/_member.html.haml @@ -0,0 +1,77 @@ +- show_roles = local_assigns.fetch(:show_roles, true) +- show_controls = local_assigns.fetch(:show_controls, true) +- user = member.request? ? member.created_by : member.user + +%li.js-toggle-container{ class: dom_class(member), id: dom_id(member) } + %span{ class: ("list-item-name" if show_controls) } + - if user + = image_tag avatar_icon(user, 24), class: "avatar s24", alt: '' + %strong + = link_to user.name, user_path(user) + %span.cgray= user.username + + - if user == current_user + %span.label.label-success It's you + + - if user.blocked? + %label.label.label-danger + %strong Blocked + + - if member.request? + %small + – Requested + = time_ago_with_tooltip(member.requested_at) + - else + = image_tag avatar_icon(member.invite_email, 24), class: "avatar s24", alt: '' + %strong= member.invite_email + %span.cgray + invited + - if member.created_by + by + = link_to member.created_by.name, user_path(member.created_by) + = time_ago_with_tooltip(member.created_at) + + - if show_controls && can?(current_user, action_member_permission(:admin, member), member.source) + = link_to 'Resend invite', resend_invite_member_path(member), + method: :post, + class: 'btn-xs btn' + + - if show_roles && can_see_entity_roles?(current_user, member.source) + %span.pull-right + %strong= member.human_access + - if show_controls + - if can?(current_user, action_member_permission(:update, member), member) + = button_tag icon('pencil'), + type: 'button', + class: 'btn-xs btn btn-grouped inline js-toggle-button', + title: 'Edit access level' + + - if member.request? +   + = link_to icon('check inverse'), approve_request_member_path(member), + method: :post, + type: 'button', + class: 'btn-xs btn btn-success', + title: 'Grant access' + + - if can?(current_user, action_member_permission(:destroy, member), member) +   + - if current_user == user + = link_to leave_path(member.source), data: { confirm: leave_confirmation_message(member.source)}, method: :delete, class: "btn-xs btn btn-remove", title: 'Remove user from group' do + = icon("sign-out") + Leave + - else + = link_to icon('trash'), member_path(member), + method: :delete, + remote: true, + data: { confirm: remove_member_message(member) }, + class: 'btn-xs btn btn-remove', + title: remove_member_title(member) + + .edit-member.hide.js-toggle-content + %br + = form_for member_path(member), as: "#{member.source.class.to_s.underscore}_member".to_sym, remote: true do |f| + .prepend-top-10 + = f.select :access_level, options_for_select(member_class(member).access_level_roles, member.access_level), {}, class: 'form-control' + .prepend-top-10 + = f.submit 'Save', class: 'btn btn-save btn-sm' diff --git a/app/views/shared/members/_requests.html.haml b/app/views/shared/members/_requests.html.haml new file mode 100644 index 0000000000..ffbb380f79 --- /dev/null +++ b/app/views/shared/members/_requests.html.haml @@ -0,0 +1,10 @@ +- requesters = members.request + +- if requesters.any? + .panel.panel-default + .panel-heading + %strong= entity.name + access requests + %small= "(#{requesters.size})" + %ul.content-list + = render partial: 'shared/members/member', collection: requesters, as: :member diff --git a/config/routes.rb b/config/routes.rb index fb35bf9dcf..62c892ee9f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -410,8 +410,15 @@ Rails.application.routes.draw do scope module: :groups do resources :group_members, only: [:index, :create, :update, :destroy] do - post :resend_invite, on: :member - delete :leave, on: :collection + collection do + delete :leave + post :request_access + end + + member do + post :resend_invite + post :approve + end end resource :avatar, only: [:destroy] @@ -778,7 +785,7 @@ Rails.application.routes.draw do member do post :resend_invite - post :approval + post :approve end end diff --git a/db/migrate/20160314114439_add_membership_request.rb b/db/migrate/20160314114439_add_membership_request.rb deleted file mode 100644 index 319b750e6c..0000000000 --- a/db/migrate/20160314114439_add_membership_request.rb +++ /dev/null @@ -1,5 +0,0 @@ -class AddMembershipRequest < ActiveRecord::Migration - def change - add_column :members, :requested, :boolean - end -end diff --git a/db/migrate/20160314114439_add_requested_at_to_members.rb b/db/migrate/20160314114439_add_requested_at_to_members.rb new file mode 100644 index 0000000000..273819d4cd --- /dev/null +++ b/db/migrate/20160314114439_add_requested_at_to_members.rb @@ -0,0 +1,5 @@ +class AddRequestedAtToMembers < ActiveRecord::Migration + def change + add_column :members, :requested_at, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index b59552fbbe..f425479da1 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -536,7 +536,7 @@ ActiveRecord::Schema.define(version: 20160610301627) do t.string "invite_email" t.string "invite_token" t.datetime "invite_accepted_at" - t.boolean "requested" + t.datetime "requested_at" end add_index "members", ["access_level"], name: "index_members_on_access_level", using: :btree diff --git a/features/steps/group/members.rb b/features/steps/group/members.rb index 0706df3aec..9de82765df 100644 --- a/features/steps/group/members.rb +++ b/features/steps/group/members.rb @@ -128,9 +128,7 @@ class Spinach::Features::GroupMembers < Spinach::FeatureSteps member = mary_jane_member page.within "#group_member_#{member.id}" do - page.within '.member-access-level' do - expect(page).to have_content "Developer" - end + expect(page).to have_content "Developer" end end diff --git a/lib/api/project_members.rb b/lib/api/project_members.rb index 4aefdf319c..b703da0557 100644 --- a/lib/api/project_members.rb +++ b/lib/api/project_members.rb @@ -46,7 +46,7 @@ module API required_attributes! [:user_id, :access_level] # either the user is already a team member or a new one - project_member = user_project.project_member_by_id(params[:user_id]) + project_member = user_project.project_member(params[:user_id]) if project_member.nil? project_member = user_project.project_members.new( user_id: params[:user_id], diff --git a/spec/controllers/groups/group_members_controller_spec.rb b/spec/controllers/groups/group_members_controller_spec.rb index a598659871..aea809f890 100644 --- a/spec/controllers/groups/group_members_controller_spec.rb +++ b/spec/controllers/groups/group_members_controller_spec.rb @@ -4,17 +4,211 @@ describe Groups::GroupMembersController do let(:user) { create(:user) } let(:group) { create(:group) } - context "index" do + describe '#index' do before do group.add_owner(user) stub_application_setting(restricted_visibility_levels: [Gitlab::VisibilityLevel::PUBLIC]) end it 'renders index with group members' do - get :index, group_id: group.path + get :index, group_id: group expect(response.status).to eq(200) expect(response).to render_template(:index) end end + + describe '#destroy' do + let(:group) { create(:group, :public) } + + context 'when member is not found' do + it 'returns 403' do + delete :destroy, group_id: group, + id: 42 + + expect(response.status).to eq(403) + end + end + + context 'when member is found' do + let(:user) { create(:user) } + let(:group_user) { create(:user) } + let(:member) do + group.add_developer(group_user) + group.group_members.find_by(user_id: group_user.id) + end + + context 'when user does not have enough rights' do + before do + group.add_developer(user) + sign_in(user) + end + + it 'returns 403' do + delete :destroy, group_id: group, + id: member + + expect(response.status).to eq(403) + expect(group.users).to include group_user + end + end + + context 'when user has enough rights' do + before do + group.add_owner(user) + sign_in(user) + end + + it '[HTML] removes user from members' do + delete :destroy, group_id: group, + id: member + + expect(response).to set_flash.to 'User was successfully removed from group.' + expect(response).to redirect_to(group_group_members_path(group)) + expect(group.users).not_to include group_user + end + + it '[JS] removes user from members' do + xhr :delete, :destroy, group_id: group, + id: member + + expect(response).to be_success + expect(group.users).not_to include group_user + end + end + end + end + + describe '#leave' do + let(:group) { create(:group, :public) } + let(:user) { create(:user) } + + context 'when member is not found' do + before { sign_in(user) } + + it 'returns 403' do + delete :leave, group_id: group + + expect(response.status).to eq(403) + end + end + + context 'when member is found' do + context 'and is not an owner' do + before do + group.add_developer(user) + sign_in(user) + end + + it 'removes user from members' do + delete :leave, group_id: group + + expect(response).to set_flash.to "You left #{group.name} group." + expect(response).to redirect_to(dashboard_groups_path) + expect(group.users).not_to include user + end + end + + context 'and is an owner' do + before do + group.add_owner(user) + sign_in(user) + end + + it 'cannot removes himself from the group' do + delete :leave, group_id: group + + expect(response).to redirect_to(dashboard_groups_path) + expect(response).to set_flash[:alert].to "You can not leave #{group.name} group because you're the last owner. Transfer or delete the group." + expect(group.users).to include user + end + end + + context 'and is a requester' do + before do + group.request_access(user) + sign_in(user) + end + + it 'removes user from members' do + delete :leave, group_id: group + + expect(response).to set_flash.to 'You withdrawn your access request to the group.' + expect(response).to redirect_to(dashboard_groups_path) + expect(group.group_members.request).to be_empty + expect(group.users).not_to include user + end + end + end + end + + describe '#request_access' do + let(:group) { create(:group, :public) } + let(:user) { create(:user) } + + before do + sign_in(user) + end + + it 'creates a new GroupMember that is not a team member' do + post :request_access, group_id: group + + expect(response).to set_flash.to 'Your request for access has been queued for review.' + expect(response).to redirect_to(group_path(group)) + expect(group.group_members.request.find_by(created_by_id: user.id).created_by).to eq user + expect(group.users).not_to include user + end + end + + describe '#approve' do + let(:group) { create(:group, :public) } + + context 'when member is not found' do + it 'returns 403' do + post :approve, group_id: group, + id: 42 + + expect(response.status).to eq(403) + end + end + + context 'when member is found' do + let(:user) { create(:user) } + let(:group_requester) { create(:user) } + let(:member) do + group.request_access(group_requester) + group.group_members.request.find_by(created_by_id: group_requester.id) + end + + context 'when user does not have enough rights' do + before do + group.add_developer(user) + sign_in(user) + end + + it 'returns 403' do + post :approve, group_id: group, + id: member + + expect(response.status).to eq(403) + expect(group.users).not_to include group_requester + end + end + + context 'when user has enough rights' do + before do + group.add_owner(user) + sign_in(user) + end + + it 'adds user to members' do + post :approve, group_id: group, + id: member + + expect(response).to redirect_to(group_group_members_path(group)) + expect(group.users).to include group_requester + end + end + end + end end diff --git a/spec/controllers/projects/project_members_controller_spec.rb b/spec/controllers/projects/project_members_controller_spec.rb index 750fbecdd0..2ea09f43f2 100644 --- a/spec/controllers/projects/project_members_controller_spec.rb +++ b/spec/controllers/projects/project_members_controller_spec.rb @@ -1,22 +1,22 @@ require('spec_helper') describe Projects::ProjectMembersController do - let(:project) { create(:project) } - let(:another_project) { create(:project, :private) } - let(:user) { create(:user) } - let(:member) { create(:user) } - - before do - project.team << [user, :master] - another_project.team << [member, :guest] - sign_in(user) - end - describe '#apply_import' do + let(:project) { create(:project) } + let(:another_project) { create(:project, :private) } + let(:user) { create(:user) } + let(:member) { create(:user) } + + before do + project.team << [user, :master] + another_project.team << [member, :guest] + sign_in(user) + end + shared_context 'import applied' do before do - post(:apply_import, namespace_id: project.namespace.to_param, - project_id: project.to_param, + post(:apply_import, namespace_id: project.namespace, + project_id: project, source_project_id: another_project.id) end end @@ -48,18 +48,231 @@ describe Projects::ProjectMembersController do end describe '#index' do - let(:project) { create(:project, :private) } - context 'when user is member' do - let(:member) { create(:user) } - before do + project = create(:project, :private) + member = create(:user) project.team << [member, :guest] sign_in(member) - get :index, namespace_id: project.namespace.to_param, project_id: project.to_param + + get :index, namespace_id: project.namespace, project_id: project end it { expect(response.status).to eq(200) } end end + + describe '#destroy' do + let(:project) { create(:project, :public) } + + context 'when member is not found' do + it 'returns 404' do + delete :destroy, namespace_id: project.namespace, + project_id: project, + id: 42 + + expect(response.status).to eq(404) + end + end + + context 'when member is found' do + let(:user) { create(:user) } + let(:team_user) { create(:user) } + let(:member) do + project.team << [team_user, :developer] + project.project_members.find_by(user_id: team_user.id) + end + + context 'when user does not have enough rights' do + before do + project.team << [user, :developer] + sign_in(user) + end + + it 'returns 404' do + delete :destroy, namespace_id: project.namespace, + project_id: project, + id: member + + expect(response.status).to eq(404) + expect(project.users).to include team_user + end + end + + context 'when user has enough rights' do + before do + project.team << [user, :master] + sign_in(user) + end + + it '[HTML] removes user from members' do + delete :destroy, namespace_id: project.namespace, + project_id: project, + id: member + + expect(response).to redirect_to( + namespace_project_project_members_path(project.namespace, project) + ) + expect(project.users).not_to include team_user + end + + it '[JS] removes user from members' do + xhr :delete, :destroy, namespace_id: project.namespace, + project_id: project, + id: member + + expect(response).to be_success + expect(project.users).not_to include team_user + end + end + end + end + + describe '#leave' do + let(:project) { create(:project, :public) } + let(:user) { create(:user) } + + context 'when member is not found' do + before { sign_in(user) } + + it 'returns 403' do + delete :leave, namespace_id: project.namespace, + project_id: project + + expect(response.status).to eq(403) + end + end + + context 'when member is found' do + context 'and is not an owner' do + before do + project.team << [user, :developer] + sign_in(user) + end + + it 'removes user from members' do + delete :leave, namespace_id: project.namespace, + project_id: project + + expect(response).to set_flash.to 'You left the project.' + expect(response).to redirect_to(dashboard_projects_path) + expect(project.users).not_to include user + end + end + + context 'and is an owner' do + before do + project.update(namespace_id: user.namespace_id) + project.team << [user, :master, user] + sign_in(user) + end + + it 'cannot removes himself from the project' do + delete :leave, namespace_id: project.namespace, + project_id: project + + expect(response).to redirect_to( + namespace_project_project_members_path(project.namespace, project) + ) + expect(response).to set_flash[:alert].to 'You can not leave your own project. Transfer or delete the project.' + expect(project.users).to include user + end + end + + context 'and is a requester' do + before do + project.request_access(user) + sign_in(user) + end + + it 'removes user from members' do + delete :leave, namespace_id: project.namespace, + project_id: project + + expect(response).to set_flash.to 'You withdrawn your access request to the project.' + expect(response).to redirect_to(dashboard_projects_path) + expect(project.project_members.request).to be_empty + expect(project.users).not_to include user + end + end + end + end + + describe '#request_access' do + let(:project) { create(:project, :public) } + let(:user) { create(:user) } + + before do + sign_in(user) + end + + it 'creates a new ProjectMember that is not a team member' do + post :request_access, namespace_id: project.namespace, + project_id: project + + expect(response).to set_flash.to 'Your request for access has been queued for review.' + expect(response).to redirect_to( + namespace_project_path(project.namespace, project) + ) + expect(project.project_members.request.find_by(created_by_id: user.id).created_by).to eq user + expect(project.users).not_to include user + end + end + + describe '#approve' do + let(:project) { create(:project, :public) } + + context 'when member is not found' do + it 'returns 404' do + post :approve, namespace_id: project.namespace, + project_id: project, + id: 42 + + expect(response.status).to eq(404) + end + end + + context 'when member is found' do + let(:user) { create(:user) } + let(:team_requester) { create(:user) } + let(:member) do + project.request_access(team_requester) + project.project_members.request.find_by(created_by_id: team_requester.id) + end + + context 'when user does not have enough rights' do + before do + project.team << [user, :developer] + sign_in(user) + end + + it 'returns 404' do + post :approve, namespace_id: project.namespace, + project_id: project, + id: member + + expect(response.status).to eq(404) + expect(project.users).not_to include team_requester + end + end + + context 'when user has enough rights' do + before do + project.team << [user, :master] + sign_in(user) + end + + it 'adds user to members' do + post :approve, namespace_id: project.namespace, + project_id: project, + id: member + + expect(response).to redirect_to( + namespace_project_project_members_path(project.namespace, project) + ) + expect(project.users).to include team_requester + end + end + end + end end diff --git a/spec/features/groups/members/owner_manages_access_requests_spec.rb b/spec/features/groups/members/owner_manages_access_requests_spec.rb new file mode 100644 index 0000000000..d5b5e0e35e --- /dev/null +++ b/spec/features/groups/members/owner_manages_access_requests_spec.rb @@ -0,0 +1,52 @@ +require 'spec_helper' + +feature 'Groups > Members > Owner manages access requests', feature: true do + let(:user) { create(:user) } + let(:owner) { create(:user) } + let(:group) { create(:group, :public) } + + background do + group.request_access(user) + group.add_owner(owner) + login_as(owner) + end + + scenario 'owner can see access requests' do + visit group_group_members_path(group) + + expect_visible_access_request(group, user) + end + + scenario 'master can grant access' do + visit group_group_members_path(group) + + expect_visible_access_request(group, user) + + perform_enqueued_jobs do + click_on 'Grant access' + end + + expect(ActionMailer::Base.deliveries.last.to).to eq [user.notification_email] + expect(ActionMailer::Base.deliveries.last.subject).to match /Access to #{group.name} group was granted/ + end + + scenario 'master can deny access' do + visit group_group_members_path(group) + + expect_visible_access_request(group, user) + + perform_enqueued_jobs do + click_on 'Deny access' + end + + expect(ActionMailer::Base.deliveries.last.to).to eq [user.notification_email] + expect(ActionMailer::Base.deliveries.last.subject).to match /Access to #{group.name} group was denied/ + end + + + def expect_visible_access_request(group, user) + expect(group.access_requested?(user)).to be_truthy + expect(page).to have_content "#{group.name} access requests (1)" + expect(page).to have_content user.name + end +end diff --git a/spec/features/groups/members/user_requests_access_spec.rb b/spec/features/groups/members/user_requests_access_spec.rb new file mode 100644 index 0000000000..9b8492807f --- /dev/null +++ b/spec/features/groups/members/user_requests_access_spec.rb @@ -0,0 +1,54 @@ +require 'spec_helper' + +feature 'Groups > Members > User requests access', feature: true do + let(:user) { create(:user) } + let(:owner) { create(:user) } + let(:group) { create(:group, :public) } + + background do + group.add_owner(owner) + login_as(user) + end + + scenario 'user can request access to a group' do + visit group_path(group) + + perform_enqueued_jobs do + click_link 'Request Access' + end + + expect(ActionMailer::Base.deliveries.last.to).to eq [owner.notification_email] + expect(ActionMailer::Base.deliveries.last.subject).to match /Request to join #{group.name} group/ + + expect(group.access_requested?(user)).to be_truthy + expect(page).to have_content 'Your request for access has been queued for review.' + expect(page).to have_content 'Withdraw Request' + end + + scenario 'user is not listed in the group members page' do + visit group_path(group) + + click_link 'Request Access' + + expect(group.access_requested?(user)).to be_truthy + + click_link 'Members' + + visit group_group_members_path(group) + page.within('.content') do + expect(page).not_to have_content(user.name) + end + end + + scenario 'user can withdraw its request for access' do + visit group_path(group) + click_link 'Request Access' + + expect(group.access_requested?(user)).to be_truthy + + click_link 'Withdraw Request' + + expect(group.access_requested?(user)).to be_falsey + expect(page).to have_content 'You withdrawn your access request to the group.' + end +end diff --git a/spec/features/projects/members/master_manages_access_requests_spec.rb b/spec/features/projects/members/master_manages_access_requests_spec.rb new file mode 100644 index 0000000000..1b5490ba97 --- /dev/null +++ b/spec/features/projects/members/master_manages_access_requests_spec.rb @@ -0,0 +1,51 @@ +require 'spec_helper' + +feature 'Projects > Members > Master manages access requests', feature: true do + let(:user) { create(:user) } + let(:master) { create(:user) } + let(:project) { create(:project, :public) } + + background do + project.request_access(user) + project.team << [master, :master] + login_as(master) + end + + scenario 'master can see access requests' do + visit namespace_project_project_members_path(project.namespace, project) + + expect_visible_access_request(project, user) + end + + scenario 'master can grant access' do + visit namespace_project_project_members_path(project.namespace, project) + + expect_visible_access_request(project, user) + + perform_enqueued_jobs do + click_on 'Grant access' + end + + expect(ActionMailer::Base.deliveries.last.to).to eq [user.notification_email] + expect(ActionMailer::Base.deliveries.last.subject).to match /Access to #{project.name_with_namespace} project was granted/ + end + + scenario 'master can deny access' do + visit namespace_project_project_members_path(project.namespace, project) + + expect_visible_access_request(project, user) + + perform_enqueued_jobs do + click_on 'Deny access' + end + + expect(ActionMailer::Base.deliveries.last.to).to eq [user.notification_email] + expect(ActionMailer::Base.deliveries.last.subject).to match /Access to #{project.name_with_namespace} project was denied/ + end + + def expect_visible_access_request(project, user) + expect(project.access_requested?(user)).to be_truthy + expect(page).to have_content "#{project.name} access requests (1)" + expect(page).to have_content user.name + end +end diff --git a/spec/features/projects/members/user_requests_access_spec.rb b/spec/features/projects/members/user_requests_access_spec.rb new file mode 100644 index 0000000000..58a7ec1880 --- /dev/null +++ b/spec/features/projects/members/user_requests_access_spec.rb @@ -0,0 +1,54 @@ +require 'spec_helper' + +feature 'Projects > Members > User requests access', feature: true do + let(:user) { create(:user) } + let(:master) { create(:user) } + let(:project) { create(:project, :public) } + + background do + project.team << [master, :master] + login_as(user) + end + + scenario 'user can request access to a project' do + visit namespace_project_path(project.namespace, project) + + perform_enqueued_jobs do + click_link 'Request Access' + end + + expect(ActionMailer::Base.deliveries.last.to).to eq [master.notification_email] + expect(ActionMailer::Base.deliveries.last.subject).to match /Request to join #{project.name_with_namespace} project/ + + expect(project.access_requested?(user)).to be_truthy + expect(page).to have_content 'Your request for access has been queued for review.' + expect(page).to have_content 'Withdraw Request' + end + + scenario 'user is not listed in the project members page' do + visit namespace_project_path(project.namespace, project) + + click_link 'Request Access' + + expect(project.access_requested?(user)).to be_truthy + + click_link 'Members' + + visit namespace_project_project_members_path(project.namespace, project) + page.within('.content') do + expect(page).not_to have_content(user.name) + end + end + + scenario 'user can withdraw its request for access' do + visit namespace_project_path(project.namespace, project) + click_link 'Request Access' + + expect(project.access_requested?(user)).to be_truthy + + click_link 'Withdraw Request' + + expect(project.access_requested?(user)).to be_falsey + expect(page).to have_content 'You withdrawn your access request to the project.' + end +end diff --git a/spec/helpers/members_helper_spec.rb b/spec/helpers/members_helper_spec.rb new file mode 100644 index 0000000000..f178214624 --- /dev/null +++ b/spec/helpers/members_helper_spec.rb @@ -0,0 +1,139 @@ +require 'spec_helper' + +describe MembersHelper do + describe '#member_class' do + let(:project_member) { build(:project_member) } + let(:group_member) { build(:group_member) } + + it { expect(member_class(project_member)).to eq ProjectMember } + it { expect(member_class(group_member)).to eq GroupMember } + end + + describe '#members_association' do + let(:project) { build_stubbed(:project) } + let(:group) { build_stubbed(:group) } + + it { expect(members_association(project)).to eq :project_members } + it { expect(members_association(group)).to eq :group_members } + end + + describe '#action_member_permission' do + let(:project_member) { build(:project_member) } + let(:group_member) { build(:group_member) } + + it { expect(action_member_permission(:admin, project_member)).to eq :admin_project_member } + it { expect(action_member_permission(:admin, group_member)).to eq :admin_group_member } + end + + describe '#can_see_entity_roles?' do + let(:project) { create(:project) } + let(:group) { create(:group) } + let(:user) { build(:user) } + let(:admin) { build(:user, :admin) } + let(:project_member) { create(:project_member, project: project) } + let(:group_member) { create(:group_member, group: group) } + + it { expect(can_see_entity_roles?(nil, project)).to be_falsy } + it { expect(can_see_entity_roles?(nil, group)).to be_falsy } + it { expect(can_see_entity_roles?(admin, project)).to be_truthy } + it { expect(can_see_entity_roles?(admin, group)).to be_truthy } + it { expect(can_see_entity_roles?(project_member.user, project)).to be_truthy } + it { expect(can_see_entity_roles?(group_member.user, group)).to be_truthy } + end + + describe '#member_path' do + let(:project_member) { create(:project_member) } + let(:group_member) { create(:group_member) } + + it { expect(member_path(project_member)).to eq namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member) } + it { expect(member_path(group_member)).to eq group_group_member_path(group_member.source, group_member) } + it { expect { member_path(double(:member, source: 'foo')) }.to raise_error ArgumentError, 'Unknown object class' } + end + + describe '#resend_invite_member_path' do + let(:project_member) { create(:project_member) } + let(:group_member) { create(:group_member) } + + it { expect(resend_invite_member_path(project_member)).to eq resend_invite_namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member) } + it { expect(resend_invite_member_path(group_member)).to eq resend_invite_group_group_member_path(group_member.source, group_member) } + it { expect { resend_invite_member_path(double(:member, source: 'foo')) }.to raise_error ArgumentError, 'Unknown object class' } + end + + describe '#request_access_path' do + let(:project) { build_stubbed(:project) } + let(:group) { build_stubbed(:group) } + + it { expect(request_access_path(project)).to eq request_access_namespace_project_project_members_path(project.namespace, project) } + it { expect(request_access_path(group)).to eq request_access_group_group_members_path(group) } + it { expect { request_access_path(double(:member, source: 'foo')) }.to raise_error ArgumentError, 'Unknown object class' } + end + + describe '#approve_request_member_path' do + let(:project_member) { create(:project_member) } + let(:group_member) { create(:group_member) } + + it { expect(approve_request_member_path(project_member)).to eq approve_namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member) } + it { expect(approve_request_member_path(group_member)).to eq approve_group_group_member_path(group_member.source, group_member) } + it { expect { approve_request_member_path(double(:member, source: 'foo')) }.to raise_error ArgumentError, 'Unknown object class' } + end + + describe '#leave_path' do + let(:project) { build_stubbed(:project) } + let(:group) { build_stubbed(:group) } + + it { expect(leave_path(project)).to eq leave_namespace_project_project_members_path(project.namespace, project) } + it { expect(leave_path(group)).to eq leave_group_group_members_path(group) } + it { expect { leave_path(double(:member, source: 'foo')) }.to raise_error ArgumentError, 'Unknown object class' } + end + + describe '#withdraw_request_message' do + let(:project) { build_stubbed(:project) } + let(:group) { build_stubbed(:group) } + + it { expect(withdraw_request_message(project)).to eq "Are you sure you want to withdraw your access request for the \"#{project.name_with_namespace}\" project?" } + it { expect(withdraw_request_message(group)).to eq "Are you sure you want to withdraw your access request for the \"#{group.name}\" group?" } + end + + describe '#remove_member_message' do + let(:requester) { build(:user) } + let(:project) { create(:project) } + let(:project_member) { build(:project_member, project: project) } + let(:project_member_invite) { build(:project_member, project: project).tap { |m| m.generate_invite_token! } } + let(:project_member_request) { project.request_access(requester) } + let(:group) { create(:group) } + let(:group_member) { build(:group_member, group: group) } + let(:group_member_invite) { build(:group_member, group: group).tap { |m| m.generate_invite_token! } } + let(:group_member_request) { group.request_access(requester) } + + it { expect(remove_member_message(project_member)).to eq "You are going to remove #{project_member.user.name} from the #{project.name_with_namespace} project. Are you sure?" } + it { expect(remove_member_message(project_member_invite)).to eq "You are going to revoke the invitation for #{project_member_invite.invite_email} to join the #{project.name_with_namespace} project. Are you sure?" } + it { expect(remove_member_message(project_member_request)).to eq "You are going to deny #{requester.name}'s request to join the #{project.name_with_namespace} project. Are you sure?" } + it { expect(remove_member_message(group_member)).to eq "You are going to remove #{group_member.user.name} from the #{group.name} group. Are you sure?" } + it { expect(remove_member_message(group_member_invite)).to eq "You are going to revoke the invitation for #{group_member_invite.invite_email} to join the #{group.name} group. Are you sure?" } + it { expect(remove_member_message(group_member_request)).to eq "You are going to deny #{requester.name}'s request to join the #{group.name} group. Are you sure?" } + end + + describe '#remove_member_title' do + let(:requester) { build(:user) } + let(:project) { create(:project) } + let(:project_member) { build(:project_member, project: project) } + let(:project_member_request) { project.request_access(requester) } + let(:group) { create(:group) } + let(:group_member) { build(:group_member, group: group) } + let(:group_member_request) { group.request_access(requester) } + + it { expect(remove_member_title(project_member)).to eq 'Remove user' } + it { expect(remove_member_title(project_member_request)).to eq 'Deny access request' } + it { expect(remove_member_title(group_member)).to eq 'Remove user' } + it { expect(remove_member_title(group_member_request)).to eq 'Deny access request' } + end + + describe '#leave_confirmation_message' do + let(:project) { build_stubbed(:project) } + let(:group) { build_stubbed(:group) } + let(:user) { build_stubbed(:user) } + + it { expect(leave_confirmation_message(project)).to eq "Are you sure you want to leave \"#{project.name_with_namespace}\" project?" } + it { expect(leave_confirmation_message(group)).to eq "Are you sure you want to leave \"#{group.name}\" group?" } + end +end diff --git a/spec/helpers/projects_helper_spec.rb b/spec/helpers/projects_helper_spec.rb index ac5af8740d..fa81c28849 100644 --- a/spec/helpers/projects_helper_spec.rb +++ b/spec/helpers/projects_helper_spec.rb @@ -1,6 +1,25 @@ require 'spec_helper' describe ProjectsHelper do + describe '#max_access_level' do + let(:master) { create(:user) } + let(:owner) { create(:user) } + let(:reporter) { create(:user) } + let(:group) { create(:group) } + let(:project) { build_stubbed(:empty_project, namespace: group) } + + before do + group.add_master(master) + group.add_owner(owner) + group.add_reporter(reporter) + end + + it { expect(max_access_level(project, master)).to eq 'Master' } + it { expect(max_access_level(project, owner)).to eq 'Owner' } + it { expect(max_access_level(project, reporter)).to eq 'Reporter' } + it { expect(max_access_level(project, build_stubbed(:user))).to be_nil } + end + describe "#project_status_css_class" do it "returns appropriate class" do expect(project_status_css_class("started")).to eq("active") @@ -45,16 +64,6 @@ describe ProjectsHelper do end end - describe 'user_max_access_in_project' do - let(:project) { create(:project) } - let(:user) { create(:user) } - before do - project.team.add_user(user, Gitlab::Access::MASTER) - end - - it { expect(helper.user_max_access_in_project(user.id, project)).to eq('Master') } - end - describe "readme_cache_key" do let(:project) { create(:project) } diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index 818825b147..2d86038030 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -400,6 +400,54 @@ describe Notify do end end + describe 'project access requested' do + let(:project) { create(:project) } + let(:user) { create(:user) } + let(:project_member) do + project.request_access(user) + project.project_members.find_by(created_by_id: user.id) + end + subject { Notify.project_access_requested_email(project_member.id) } + + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like "a user cannot unsubscribe through footer link" + + it 'has the correct subject' do + is_expected.to have_subject /Request to join #{project.name_with_namespace} project/ + end + + it 'contains name of project' do + is_expected.to have_body_text /#{project.name}/ + end + + it 'contains new user role' do + is_expected.to have_body_text /#{project_member.human_access}/ + end + end + + describe 'project access denied' do + let(:project) { create(:project) } + let(:user) { create(:user) } + let(:project_member) do + project.request_access(user) + project.project_members.find_by(created_by_id: user.id) + end + subject { Notify.project_access_denied_email(project.id, user.id) } + + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like "a user cannot unsubscribe through footer link" + + it 'has the correct subject' do + is_expected.to have_subject /Access to #{project.name_with_namespace} project was denied/ + end + + it 'contains name of project' do + is_expected.to have_body_text /#{project.name}/ + end + end + describe 'project access changed' do let(:project) { create(:project) } let(:user) { create(:user) } @@ -411,7 +459,7 @@ describe Notify do it_behaves_like "a user cannot unsubscribe through footer link" it 'has the correct subject' do - is_expected.to have_subject /Access to project was granted/ + is_expected.to have_subject /Access to #{project.name_with_namespace} project was granted/ end it 'contains name of project' do @@ -535,6 +583,54 @@ describe Notify do end end + describe 'group access requested' do + let(:group) { create(:group) } + let(:user) { create(:user) } + let(:group_member) do + group.request_access(user) + group.group_members.find_by(created_by_id: user.id) + end + subject { Notify.group_access_requested_email(group_member.id) } + + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like "a user cannot unsubscribe through footer link" + + it 'has the correct subject' do + is_expected.to have_subject /Request to join #{group.name} group/ + end + + it 'contains name of group' do + is_expected.to have_body_text /#{group.name}/ + end + + it 'contains new user role' do + is_expected.to have_body_text /#{group_member.human_access}/ + end + end + + describe 'group access denied' do + let(:group) { create(:group) } + let(:user) { create(:user) } + let(:group_member) do + group.request_access(user) + group.group_members.find_by(created_by_id: user.id) + end + subject { Notify.group_access_denied_email(group.id, user.id) } + + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like "a user cannot unsubscribe through footer link" + + it 'has the correct subject' do + is_expected.to have_subject /Access to #{group.name} group was denied/ + end + + it 'contains name of group' do + is_expected.to have_body_text /#{group.name}/ + end + end + describe 'group access changed' do let(:group) { create(:group) } let(:user) { create(:user) } @@ -547,7 +643,7 @@ describe Notify do it_behaves_like "a user cannot unsubscribe through footer link" it 'has the correct subject' do - is_expected.to have_subject /Access to group was granted/ + is_expected.to have_subject /Access to #{group.name} group was granted/ end it 'contains name of project' do diff --git a/spec/models/concerns/access_requestable_spec.rb b/spec/models/concerns/access_requestable_spec.rb new file mode 100644 index 0000000000..2dfed1eb4c --- /dev/null +++ b/spec/models/concerns/access_requestable_spec.rb @@ -0,0 +1,41 @@ +require 'spec_helper' + +describe AccessRequestable do + describe 'Group' do + describe '#request_access' do + let(:group) { create(:group, :public) } + let(:user) { create(:user) } + + it { expect(group.request_access(user)).to be_a(GroupMember) } + it { expect(group.request_access(user).user).to be_nil } + it { expect(group.request_access(user).created_by).to eq(user) } + end + + describe '#access_requested?' do + let(:group) { create(:group, :public) } + let(:user) { create(:user) } + + before { group.request_access(user) } + + it { expect(group.access_requested?(user)).to be_truthy } + end + end + + describe 'Project' do + describe '#request_access' do + let(:project) { create(:empty_project, :public) } + let(:user) { create(:user) } + + it { expect(project.request_access(user)).to be_a(ProjectMember) } + end + + describe '#access_requested?' do + let(:project) { create(:empty_project, :public) } + let(:user) { create(:user) } + + before { project.request_access(user) } + + it { expect(project.access_requested?(user)).to be_truthy } + end + end +end diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 6fa16be7f0..52f9d57bc0 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -5,7 +5,22 @@ describe Group, models: true do describe 'associations' do it { is_expected.to have_many :projects } - it { is_expected.to have_many :group_members } + it { is_expected.to have_many(:group_members).dependent(:destroy) } + it { is_expected.to have_many(:users).through(:group_members) } + it { is_expected.to have_many(:project_group_links).dependent(:destroy) } + it { is_expected.to have_many(:shared_projects).through(:project_group_links) } + it { is_expected.to have_many(:notification_settings).dependent(:destroy) } + + describe '#group_members' do + let(:user) { create(:user) } + let(:group) { create(:group) } + + before { group.request_access(user) } + + it 'does not includes membership requests' do + expect(user.group_members).to be_empty + end + end end describe 'modules' do @@ -131,4 +146,46 @@ describe Group, models: true do expect(described_class.search(group.path.upcase)).to eq([group]) end end + + describe '#has_owner?' do + before { @members = setup_group_members(group) } + + it { expect(group.has_owner?(@members[:owner])).to be_truthy } + it { expect(group.has_owner?(@members[:master])).to be_falsey } + it { expect(group.has_owner?(@members[:developer])).to be_falsey } + it { expect(group.has_owner?(@members[:reporter])).to be_falsey } + it { expect(group.has_owner?(@members[:guest])).to be_falsey } + it { expect(group.has_owner?(@members[:requester])).to be_falsey } + end + + describe '#has_master?' do + before { @members = setup_group_members(group) } + + it { expect(group.has_master?(@members[:owner])).to be_falsey } + it { expect(group.has_master?(@members[:master])).to be_truthy } + it { expect(group.has_master?(@members[:developer])).to be_falsey } + it { expect(group.has_master?(@members[:reporter])).to be_falsey } + it { expect(group.has_master?(@members[:guest])).to be_falsey } + it { expect(group.has_master?(@members[:requester])).to be_falsey } + end + + def setup_group_members(group) + members = { + owner: create(:user), + master: create(:user), + developer: create(:user), + reporter: create(:user), + guest: create(:user), + requester: create(:user) + } + + group.add_user(members[:owner], GroupMember::OWNER) + group.add_user(members[:master], GroupMember::MASTER) + group.add_user(members[:developer], GroupMember::DEVELOPER) + group.add_user(members[:reporter], GroupMember::REPORTER) + group.add_user(members[:guest], GroupMember::GUEST) + group.request_access(members[:requester]) + + members + end end diff --git a/spec/models/member_spec.rb b/spec/models/member_spec.rb index 6e51730eec..a3d525d8d5 100644 --- a/spec/models/member_spec.rb +++ b/spec/models/member_spec.rb @@ -55,6 +55,47 @@ describe Member, models: true do end end + describe 'Scopes' do + before do + project = create(:project) + @invited_member = build(:project_member, user: nil).tap { |m| m.generate_invite_token! } + @accepted_invite_member = build(:project_member, user: nil).tap { |m| m.generate_invite_token! && m.accept_invite!(build(:user)) } + + requested_user = create(:user).tap { |u| project.request_access(u) } + @requested_member = project.project_members.find_by(created_by_id: requested_user.id) + accepted_request_user = create(:user).tap { |u| project.request_access(u) } + @accepted_request_member = project.project_members.find_by(created_by_id: accepted_request_user.id).tap { |m| m.accept_request } + end + + describe '#invite' do + it { expect(described_class.invite).to include @invited_member } + it { expect(described_class.invite).not_to include @accepted_invite_member } + it { expect(described_class.invite).not_to include @requested_member } + it { expect(described_class.invite).not_to include @accepted_request_member } + end + + describe '#request' do + it { expect(described_class.request).not_to include @invited_member } + it { expect(described_class.request).not_to include @accepted_invite_member } + it { expect(described_class.request).to include @requested_member } + it { expect(described_class.request).not_to include @accepted_request_member } + end + + describe '#non_request' do + it { expect(described_class.non_request).to include @invited_member } + it { expect(described_class.non_request).to include @accepted_invite_member } + it { expect(described_class.non_request).not_to include @requested_member } + it { expect(described_class.non_request).to include @accepted_request_member } + end + + describe '#non_pending' do + it { expect(described_class.non_pending).not_to include @invited_member } + it { expect(described_class.non_pending).to include @accepted_invite_member } + it { expect(described_class.non_pending).not_to include @requested_member } + it { expect(described_class.non_pending).to include @accepted_request_member } + end + end + describe "Delegate methods" do it { is_expected.to respond_to(:user_name) } it { is_expected.to respond_to(:user_email) } @@ -97,6 +138,54 @@ describe Member, models: true do end end + describe '#accept_request' do + let(:user) { create(:user) } + let(:member) { create(:project_member, requested_at: Time.now.utc, user: nil, created_by: user) } + + it 'returns true' do + expect(member.accept_request).to be_truthy + end + + it 'sets the user' do + member.accept_request + + expect(member.user).to eq(user) + end + + it 'clears requested_at' do + member.accept_request + + expect(member.requested_at).to be_nil + end + + it 'calls #after_accept_request' do + expect(member).to receive(:after_accept_request) + + member.accept_request + end + end + + describe '#decline_request' do + let(:user) { create(:user) } + let(:member) { create(:project_member, requested_at: Time.now.utc, user: nil, created_by: user) } + + it 'returns true' do + expect(member.decline_request).to be_truthy + end + + it 'destroys the member' do + member.decline_request + + expect(member).to be_destroyed + end + + it 'calls #after_decline_request' do + expect(member).to receive(:after_decline_request) + + member.decline_request + end + end + describe "#accept_invite!" do let!(:member) { create(:project_member, invite_email: "user@example.com", user: nil) } let(:user) { create(:user) } diff --git a/spec/models/members/group_member_spec.rb b/spec/models/members/group_member_spec.rb index 5424c9b9cb..c3070d4cb7 100644 --- a/spec/models/members/group_member_spec.rb +++ b/spec/models/members/group_member_spec.rb @@ -20,7 +20,7 @@ require 'spec_helper' describe GroupMember, models: true do - context 'notification' do + describe 'notifications' do describe "#after_create" do it "should send email to user" do membership = build(:group_member) @@ -50,5 +50,25 @@ describe GroupMember, models: true do @group_member.update_attribute(:access_level, GroupMember::OWNER) end end + + describe 'after accept_request' do + let(:member) { create(:group_member, user: nil, created_by: build_stubbed(:user), requested_at: Time.now) } + + it "calls #accept_group_access_request" do + expect_any_instance_of(NotificationService).to receive(:new_group_member) + + member.accept_request + end + end + + describe 'after decline_request' do + let(:member) { create(:group_member, user: nil, created_by: build_stubbed(:user), requested_at: Time.now) } + + it "calls #decline_group_access_request" do + expect_any_instance_of(NotificationService).to receive(:decline_group_access_request) + + member.decline_request + end + end end end diff --git a/spec/models/members/project_member_spec.rb b/spec/models/members/project_member_spec.rb index 9f13874b53..99b3c77c6c 100644 --- a/spec/models/members/project_member_spec.rb +++ b/spec/models/members/project_member_spec.rb @@ -135,4 +135,26 @@ describe ProjectMember, models: true do it { expect(@project_1.users).to be_empty } it { expect(@project_2.users).to be_empty } end + + describe 'notifications' do + describe 'after accept_request' do + let(:member) { create(:project_member, user: nil, created_by: build_stubbed(:user), requested_at: Time.now) } + + it 'calls #accept_project_access_request' do + expect_any_instance_of(NotificationService).to receive(:new_project_member) + + member.accept_request + end + end + + describe 'after decline_request' do + let(:member) { create(:project_member, user: nil, created_by: build_stubbed(:user), requested_at: Time.now) } + + it 'calls #decline_project_access_request' do + expect_any_instance_of(NotificationService).to receive(:decline_project_access_request) + + member.decline_request + end + end + end end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index de8815f5a3..d5a4b73aff 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -29,6 +29,17 @@ describe Project, models: true do it { is_expected.to have_many(:variables) } it { is_expected.to have_many(:triggers) } it { is_expected.to have_many(:todos).dependent(:destroy) } + + describe '#project_members' do + let(:user) { create(:user) } + let(:project) { create(:project) } + + before { project.request_access(user) } + + it 'does not includes membership requests' do + expect(user.project_members).to be_empty + end + end end describe 'modules' do diff --git a/spec/models/project_team_spec.rb b/spec/models/project_team_spec.rb index 8bebd6a944..36b1f43995 100644 --- a/spec/models/project_team_spec.rb +++ b/spec/models/project_team_spec.rb @@ -73,69 +73,107 @@ describe ProjectTeam, models: true do end end - describe :max_invited_level do - let(:group) { create(:group) } - let(:project) { create(:empty_project) } + describe '#find_member' do + context 'personal project' do + let(:project) { create(:empty_project) } + let(:requester) { create(:user) } - before do - project.project_group_links.create( - group: group, - group_access: Gitlab::Access::DEVELOPER - ) + before do + project.team << [master, :master] + project.team << [reporter, :reporter] + project.team << [guest, :guest] + project.request_access(requester) + end - group.add_user(master, Gitlab::Access::MASTER) - group.add_user(reporter, Gitlab::Access::REPORTER) + it { expect(project.team.find_member(master.id)).to be_a(ProjectMember) } + it { expect(project.team.find_member(reporter.id)).to be_a(ProjectMember) } + it { expect(project.team.find_member(guest.id)).to be_a(ProjectMember) } + it { expect(project.team.find_member(nonmember.id)).to be_nil } + it { expect(project.team.find_member(requester.id)).to be_nil } end - it { expect(project.team.max_invited_level(master.id)).to eq(Gitlab::Access::DEVELOPER) } - it { expect(project.team.max_invited_level(reporter.id)).to eq(Gitlab::Access::REPORTER) } - it { expect(project.team.max_invited_level(nonmember.id)).to be_nil } - end + context 'group project' do + let(:group) { create(:group) } + let(:project) { create(:empty_project, group: group) } + let(:requester) { create(:user) } - describe :max_member_access do - let(:group) { create(:group) } - let(:project) { create(:empty_project) } + before do + group.add_master(master) + group.add_reporter(reporter) + group.add_guest(guest) + group.request_access(requester) + end - before do - project.project_group_links.create( - group: group, - group_access: Gitlab::Access::DEVELOPER - ) - - group.add_user(master, Gitlab::Access::MASTER) - group.add_user(reporter, Gitlab::Access::REPORTER) - end - - it { expect(project.team.max_member_access(master.id)).to eq(Gitlab::Access::DEVELOPER) } - it { expect(project.team.max_member_access(reporter.id)).to eq(Gitlab::Access::REPORTER) } - it { expect(project.team.max_member_access(nonmember.id)).to be_nil } - - it "does not have an access" do - project.namespace.update(share_with_group_lock: true) - expect(project.team.max_member_access(master.id)).to be_nil - expect(project.team.max_member_access(reporter.id)).to be_nil + it { expect(project.team.find_member(master.id)).to be_a(GroupMember) } + it { expect(project.team.find_member(reporter.id)).to be_a(GroupMember) } + it { expect(project.team.find_member(guest.id)).to be_a(GroupMember) } + it { expect(project.team.find_member(nonmember.id)).to be_nil } + it { expect(project.team.find_member(requester.id)).to be_nil } end end - describe "#human_max_access" do - it 'returns Master role' do - user = create(:user) - group = create(:group) - group.add_master(user) + describe '#max_member_access' do + let(:requester) { create(:user) } - project = build_stubbed(:empty_project, namespace: group) + context 'personal project' do + let(:project) { create(:empty_project) } - expect(project.team.human_max_access(user.id)).to eq 'Master' + context 'when project is not shared with group' do + before do + project.team << [master, :master] + project.team << [reporter, :reporter] + project.team << [guest, :guest] + project.request_access(requester) + end + + it { expect(project.team.max_member_access(master.id)).to eq(Gitlab::Access::MASTER) } + it { expect(project.team.max_member_access(reporter.id)).to eq(Gitlab::Access::REPORTER) } + it { expect(project.team.max_member_access(guest.id)).to eq(Gitlab::Access::GUEST) } + it { expect(project.team.max_member_access(nonmember.id)).to be_nil } + it { expect(project.team.max_member_access(requester.id)).to be_nil } + end + + context 'when project is shared with group' do + before do + group = create(:group) + project.project_group_links.create( + group: group, + group_access: Gitlab::Access::DEVELOPER) + + group.add_master(master) + group.add_reporter(reporter) + end + + it { expect(project.team.max_member_access(master.id)).to eq(Gitlab::Access::DEVELOPER) } + it { expect(project.team.max_member_access(reporter.id)).to eq(Gitlab::Access::REPORTER) } + it { expect(project.team.max_member_access(nonmember.id)).to be_nil } + it { expect(project.team.max_member_access(requester.id)).to be_nil } + + context 'but share_with_group_lock is true' do + before { project.namespace.update(share_with_group_lock: true) } + + it { expect(project.team.max_member_access(master.id)).to be_nil } + it { expect(project.team.max_member_access(reporter.id)).to be_nil } + end + end end - it 'returns Owner role' do - user = create(:user) - group = create(:group) - group.add_owner(user) + context 'group project' do + let(:group) { create(:group) } + let(:project) { create(:empty_project, group: group) } - project = build_stubbed(:empty_project, namespace: group) + before do + group.add_master(master) + group.add_reporter(reporter) + group.add_guest(guest) + group.request_access(requester) + end - expect(project.team.human_max_access(user.id)).to eq 'Owner' + it { expect(project.team.max_member_access(master.id)).to eq(Gitlab::Access::MASTER) } + it { expect(project.team.max_member_access(reporter.id)).to eq(Gitlab::Access::REPORTER) } + it { expect(project.team.max_member_access(guest.id)).to eq(Gitlab::Access::GUEST) } + it { expect(project.team.max_member_access(nonmember.id)).to be_nil } + it { expect(project.team.max_member_access(requester.id)).to be_nil } end end end From d71fbe0dbdb3b7aba6f71e6d3d50daaa890769e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= Date: Wed, 1 Jun 2016 18:07:23 +0200 Subject: [PATCH 21/36] Factorize #request_access and #approve_access_request into a new AccessRequestActions controller concern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rémy Coutable --- .../concerns/access_request_actions.rb | 38 +++++++++++++++++++ .../groups/group_members_controller.rb | 31 ++++++++------- .../projects/project_members_controller.rb | 32 ++++++++-------- config/routes.rb | 5 +++ .../groups/group_members_controller_spec.rb | 6 +-- .../project_members_controller_spec.rb | 6 +-- 6 files changed, 79 insertions(+), 39 deletions(-) create mode 100644 app/controllers/concerns/access_request_actions.rb diff --git a/app/controllers/concerns/access_request_actions.rb b/app/controllers/concerns/access_request_actions.rb new file mode 100644 index 0000000000..1b0a1fe308 --- /dev/null +++ b/app/controllers/concerns/access_request_actions.rb @@ -0,0 +1,38 @@ +module AccessRequestActions + extend ActiveSupport::Concern + + def request_access + access_requestable_resource.request_access(current_user) + + redirect_to access_requestable_resource_path, + notice: 'Your request for access has been queued for review.' + end + + def approve + @member = access_requestable_resource.public_send(member_entity_name.pluralize).request.find(params[:id]) + + return render_403 unless can?(current_user, :"update_#{member_entity_name}", @member) + + @member.accept_request + + redirect_to access_requestable_resource_members_path + end + + protected + + def access_requestable_resource + raise NotImplementedError + end + + def access_requestable_resource_path + access_requestable_resource + end + + def access_requestable_resource_members_path + [access_requestable_resource, 'members'] + end + + def member_entity_name + "#{access_requestable_resource.class.to_s.underscore}_member" + end +end diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index 2ebc506040..a37129062f 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -1,4 +1,6 @@ class Groups::GroupMembersController < Groups::ApplicationController + include AccessRequestActions + # Authorize before_action :authorize_admin_group_member!, except: [:index, :leave, :request_access] @@ -82,25 +84,22 @@ class Groups::GroupMembersController < Groups::ApplicationController end end - def request_access - @group.request_access(current_user) - - redirect_to group_path(@group), notice: 'Your request for access has been queued for review.' - end - - def approve - @group_member = @group.group_members.request.find(params[:id]) - - return render_403 unless can?(current_user, :update_group_member, @group_member) - - @group_member.accept_request - - redirect_to group_group_members_path(@group) - end - protected def member_params params.require(:group_member).permit(:access_level, :user_id) end + + # AccessRequestActions concern + def access_requestable_resource + @group + end + + def access_requestable_resource_path + group_path(@group) + end + + def access_requestable_resource_members_path + group_group_members_path(@group) + end end diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index c979c5e9fa..c61eda95bc 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -1,4 +1,6 @@ class Projects::ProjectMembersController < Projects::ApplicationController + include AccessRequestActions + # Authorize before_action :authorize_admin_project_member!, except: [:index, :leave, :request_access] @@ -99,23 +101,6 @@ class Projects::ProjectMembersController < Projects::ApplicationController end end - def request_access - @project.request_access(current_user) - - redirect_to namespace_project_path(@project.namespace, @project), - notice: 'Your request for access has been queued for review.' - end - - def approve - @project_member = @project.project_members.request.find(params[:id]) - - return render_403 unless can?(current_user, :update_project_member, @project_member) - - @project_member.accept_request - - redirect_to namespace_project_project_members_path(@project.namespace, @project) - end - def apply_import source_project = Project.find(params[:source_project_id]) @@ -135,4 +120,17 @@ class Projects::ProjectMembersController < Projects::ApplicationController def member_params params.require(:project_member).permit(:user_id, :access_level) end + + # AccessRequestActions concern + def access_requestable_resource + @project + end + + def access_requestable_resource_path + namespace_project_path(@project.namespace, @project) + end + + def access_requestable_resource_members_path + namespace_project_project_members_path(@project.namespace, @project) + end end diff --git a/config/routes.rb b/config/routes.rb index 62c892ee9f..2eccb19def 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -30,6 +30,11 @@ Rails.application.routes.draw do mount LetterOpenerWeb::Engine, at: '/rails/letter_opener' end + concern :access_requestable do + post :request_access, on: :collection + post :approve_access_request_access_request, on: :member + end + namespace :ci do # CI API Ci::API::API.logger Rails.logger diff --git a/spec/controllers/groups/group_members_controller_spec.rb b/spec/controllers/groups/group_members_controller_spec.rb index aea809f890..0ca8a656f6 100644 --- a/spec/controllers/groups/group_members_controller_spec.rb +++ b/spec/controllers/groups/group_members_controller_spec.rb @@ -165,7 +165,7 @@ describe Groups::GroupMembersController do context 'when member is not found' do it 'returns 403' do - post :approve, group_id: group, + post :approve_access_request, group_id: group, id: 42 expect(response.status).to eq(403) @@ -187,7 +187,7 @@ describe Groups::GroupMembersController do end it 'returns 403' do - post :approve, group_id: group, + post :approve_access_request, group_id: group, id: member expect(response.status).to eq(403) @@ -202,7 +202,7 @@ describe Groups::GroupMembersController do end it 'adds user to members' do - post :approve, group_id: group, + post :approve_access_request, group_id: group, id: member expect(response).to redirect_to(group_group_members_path(group)) diff --git a/spec/controllers/projects/project_members_controller_spec.rb b/spec/controllers/projects/project_members_controller_spec.rb index 2ea09f43f2..d3bd2d0bbb 100644 --- a/spec/controllers/projects/project_members_controller_spec.rb +++ b/spec/controllers/projects/project_members_controller_spec.rb @@ -224,7 +224,7 @@ describe Projects::ProjectMembersController do context 'when member is not found' do it 'returns 404' do - post :approve, namespace_id: project.namespace, + post :approve_access_request, namespace_id: project.namespace, project_id: project, id: 42 @@ -247,7 +247,7 @@ describe Projects::ProjectMembersController do end it 'returns 404' do - post :approve, namespace_id: project.namespace, + post :approve_access_request, namespace_id: project.namespace, project_id: project, id: member @@ -263,7 +263,7 @@ describe Projects::ProjectMembersController do end it 'adds user to members' do - post :approve, namespace_id: project.namespace, + post :approve_access_request, namespace_id: project.namespace, project_id: project, id: member From d75edf1a9854b2ab609c7d3acf5eee1ca89e8db9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= Date: Wed, 1 Jun 2016 18:17:03 +0200 Subject: [PATCH 22/36] Factorize access request routes into a new :access_requestable route concern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rémy Coutable --- .../concerns/access_request_actions.rb | 2 +- app/helpers/members_helper.rb | 4 ++-- config/routes.rb | 19 +++++-------------- spec/helpers/members_helper_spec.rb | 4 ++-- 4 files changed, 10 insertions(+), 19 deletions(-) diff --git a/app/controllers/concerns/access_request_actions.rb b/app/controllers/concerns/access_request_actions.rb index 1b0a1fe308..c4d22749d6 100644 --- a/app/controllers/concerns/access_request_actions.rb +++ b/app/controllers/concerns/access_request_actions.rb @@ -8,7 +8,7 @@ module AccessRequestActions notice: 'Your request for access has been queued for review.' end - def approve + def approve_access_request @member = access_requestable_resource.public_send(member_entity_name.pluralize).request.find(params[:id]) return render_403 unless can?(current_user, :"update_#{member_entity_name}", @member) diff --git a/app/helpers/members_helper.rb b/app/helpers/members_helper.rb index 6599c59d1c..bd84b8b239 100644 --- a/app/helpers/members_helper.rb +++ b/app/helpers/members_helper.rb @@ -53,9 +53,9 @@ module MembersHelper def approve_request_member_path(member) case member.source when Project - approve_namespace_project_project_member_path(member.source.namespace, member.source, member) + approve_access_request_namespace_project_project_member_path(member.source.namespace, member.source, member) when Group - approve_group_group_member_path(member.source, member) + approve_access_request_group_group_member_path(member.source, member) else raise ArgumentError.new('Unknown object class') end diff --git a/config/routes.rb b/config/routes.rb index 2eccb19def..f5574fb99a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -32,7 +32,7 @@ Rails.application.routes.draw do concern :access_requestable do post :request_access, on: :collection - post :approve_access_request_access_request, on: :member + post :approve_access_request, on: :member end namespace :ci do @@ -414,16 +414,9 @@ Rails.application.routes.draw do end scope module: :groups do - resources :group_members, only: [:index, :create, :update, :destroy] do - collection do - delete :leave - post :request_access - end - - member do - post :resend_invite - post :approve - end + resources :group_members, only: [:index, :create, :update, :destroy], concerns: :access_requestable do + post :resend_invite, on: :member + delete :leave, on: :collection end resource :avatar, only: [:destroy] @@ -777,10 +770,9 @@ Rails.application.routes.draw do end end - resources :project_members, except: [:new, :edit], constraints: { id: /[a-zA-Z.\/0-9_\-#%+]+/ } do + resources :project_members, except: [:new, :edit], constraints: { id: /[a-zA-Z.\/0-9_\-#%+]+/ }, concerns: :access_requestable do collection do delete :leave - post :request_access # Used for import team # from another project @@ -790,7 +782,6 @@ Rails.application.routes.draw do member do post :resend_invite - post :approve end end diff --git a/spec/helpers/members_helper_spec.rb b/spec/helpers/members_helper_spec.rb index f178214624..c2f10e1db7 100644 --- a/spec/helpers/members_helper_spec.rb +++ b/spec/helpers/members_helper_spec.rb @@ -72,8 +72,8 @@ describe MembersHelper do let(:project_member) { create(:project_member) } let(:group_member) { create(:group_member) } - it { expect(approve_request_member_path(project_member)).to eq approve_namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member) } - it { expect(approve_request_member_path(group_member)).to eq approve_group_group_member_path(group_member.source, group_member) } + it { expect(approve_request_member_path(project_member)).to eq approve_access_request_namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member) } + it { expect(approve_request_member_path(group_member)).to eq approve_access_request_group_group_member_path(group_member.source, group_member) } it { expect { approve_request_member_path(double(:member, source: 'foo')) }.to raise_error ArgumentError, 'Unknown object class' } end From 6d103a2f4764441b1650ba6d790732056c9a8516 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= Date: Thu, 2 Jun 2016 16:14:02 +0200 Subject: [PATCH 23/36] Factorize members mails into a new Emails::Members module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rémy Coutable --- app/mailers/emails/groups.rb | 73 ----- app/mailers/emails/members.rb | 104 +++++++ app/mailers/emails/projects.rb | 71 ----- app/mailers/notify.rb | 2 +- app/models/group.rb | 4 + app/services/notification_service.rb | 30 +- .../group_access_denied_email.html.haml | 2 - .../notify/group_access_denied_email.text.erb | 3 - .../group_access_requested_email.html.haml | 3 - .../group_access_requested_email.text.erb | 3 - .../member_access_denied_email.html.haml | 4 + .../member_access_denied_email.text.erb | 3 + .../member_access_granted_email.html.haml | 3 + .../member_access_granted_email.text.erb | 3 + .../member_access_requested_email.html.haml | 3 + .../member_access_requested_email.text.erb | 3 + .../member_invite_accepted_email.html.haml | 5 + .../member_invite_accepted_email.text.erb | 3 + .../member_invite_declined_email.html.haml | 4 + .../member_invite_declined_email.text.erb | 3 + .../notify/member_invited_email.html.haml | 13 + .../notify/member_invited_email.text.erb | 4 + .../project_access_denied_email.html.haml | 3 - .../project_access_denied_email.text.erb | 3 - .../project_access_granted_email.html.haml | 3 - .../project_access_granted_email.text.erb | 3 - .../project_access_requested_email.html.haml | 3 - .../project_access_requested_email.text.erb | 3 - .../project_invite_accepted_email.html.haml | 6 - .../project_invite_accepted_email.text.erb | 3 - .../project_invite_declined_email.html.haml | 5 - .../project_invite_declined_email.text.erb | 3 - .../project_member_invited_email.html.haml | 13 - .../project_member_invited_email.text.erb | 4 - spec/mailers/notify_spec.rb | 264 ++++++++++++------ 35 files changed, 359 insertions(+), 303 deletions(-) delete mode 100644 app/mailers/emails/groups.rb create mode 100644 app/mailers/emails/members.rb delete mode 100644 app/views/notify/group_access_denied_email.html.haml delete mode 100644 app/views/notify/group_access_denied_email.text.erb delete mode 100644 app/views/notify/group_access_requested_email.html.haml delete mode 100644 app/views/notify/group_access_requested_email.text.erb create mode 100644 app/views/notify/member_access_denied_email.html.haml create mode 100644 app/views/notify/member_access_denied_email.text.erb create mode 100644 app/views/notify/member_access_granted_email.html.haml create mode 100644 app/views/notify/member_access_granted_email.text.erb create mode 100644 app/views/notify/member_access_requested_email.html.haml create mode 100644 app/views/notify/member_access_requested_email.text.erb create mode 100644 app/views/notify/member_invite_accepted_email.html.haml create mode 100644 app/views/notify/member_invite_accepted_email.text.erb create mode 100644 app/views/notify/member_invite_declined_email.html.haml create mode 100644 app/views/notify/member_invite_declined_email.text.erb create mode 100644 app/views/notify/member_invited_email.html.haml create mode 100644 app/views/notify/member_invited_email.text.erb delete mode 100644 app/views/notify/project_access_denied_email.html.haml delete mode 100644 app/views/notify/project_access_denied_email.text.erb delete mode 100644 app/views/notify/project_access_granted_email.html.haml delete mode 100644 app/views/notify/project_access_granted_email.text.erb delete mode 100644 app/views/notify/project_access_requested_email.html.haml delete mode 100644 app/views/notify/project_access_requested_email.text.erb delete mode 100644 app/views/notify/project_invite_accepted_email.html.haml delete mode 100644 app/views/notify/project_invite_accepted_email.text.erb delete mode 100644 app/views/notify/project_invite_declined_email.html.haml delete mode 100644 app/views/notify/project_invite_declined_email.text.erb delete mode 100644 app/views/notify/project_member_invited_email.html.haml delete mode 100644 app/views/notify/project_member_invited_email.text.erb diff --git a/app/mailers/emails/groups.rb b/app/mailers/emails/groups.rb deleted file mode 100644 index fe218bfbe0..0000000000 --- a/app/mailers/emails/groups.rb +++ /dev/null @@ -1,73 +0,0 @@ -module Emails - module Groups - def group_access_requested_email(group_member_id) - setup_group_member_mail(group_member_id) - - @requester = @group_member.created_by - - group_admins = User.where(id: @group.group_members.admins.pluck(:user_id)).pluck(:notification_email) - - mail(to: group_admins, - subject: subject("Request to join #{@group.name} group")) - end - - def group_access_granted_email(group_member_id) - setup_group_member_mail(group_member_id) - - @current_user = @group_member.user - - mail(to: @current_user.notification_email, - subject: subject("Access to #{@group.name} group was granted")) - end - - def group_access_denied_email(group_id, user_id) - @group = Group.find(group_id) - @current_user = User.find(user_id) - @target_url = group_url(@group) - - mail(to: @current_user.notification_email, - subject: subject("Access to #{@group.name} group was denied")) - end - - def group_member_invited_email(group_member_id, token) - setup_group_member_mail(group_member_id) - - @token = token - @current_user = @group_member.user - - mail(to: @group_member.invite_email, - subject: "Invitation to join group #{@group.name}") - end - - def group_invite_accepted_email(group_member_id) - setup_group_member_mail(group_member_id) - return if @group_member.created_by.nil? - - @current_user = @group_member.created_by - - mail(to: @current_user.notification_email, - subject: subject("Invitation accepted")) - end - - def group_invite_declined_email(group_id, invite_email, access_level, created_by_id) - return if created_by_id.nil? - - @group = Group.find(group_id) - @current_user = @created_by = User.find(created_by_id) - @access_level = access_level - @invite_email = invite_email - - @target_url = group_url(@group) - mail(to: @created_by.notification_email, - subject: subject("Invitation declined")) - end - - private - - def setup_group_member_mail(group_member_id) - @group_member = GroupMember.find(group_member_id) - @group = @group_member.group - @target_url = group_url(@group) - end - end -end diff --git a/app/mailers/emails/members.rb b/app/mailers/emails/members.rb new file mode 100644 index 0000000000..5fd55c149d --- /dev/null +++ b/app/mailers/emails/members.rb @@ -0,0 +1,104 @@ +module Emails + module Members + extend ActiveSupport::Concern + + included do + attr_reader :member_target_type + helper_method :member, :access_requester, :member_target_type, :member_target_name, :member_target_url + end + + def member_access_requested_email(member_target_type, member_id) + @member_target_type = member_target_type + @member_id = member_id + + admins = User.where(id: target.public_send(members_association).admins.pluck(:user_id)).pluck(:notification_email) + + mail(to: admins, + subject: subject("Request to join the #{member_target_name} #{member_target_type}")) + end + + def member_access_granted_email(member_target_type, member_id) + @member_target_type = member_target_type + @member_id = member_id + + mail(to: member.user.notification_email, + subject: subject("Access to the #{member_target_name} #{member_target_type} was granted")) + end + + def member_access_denied_email(member_target_type, target_id, user_id) + @member_target_type = member_target_type + @target = target_class.find(target_id) + + mail(to: User.find(user_id).notification_email, + subject: subject("Access to the #{member_target_name} #{member_target_type} was denied")) + end + + def member_invited_email(member_target_type, member_id, token) + @member_target_type = member_target_type + @member_id = member_id + @token = token + + mail(to: member.invite_email, + subject: "Invitation to join the #{member_target_name} #{member_target_type}") + end + + def member_invite_accepted_email(member_target_type, member_id) + @member_target_type = member_target_type + @member_id = member_id + return if access_requester.nil? + + mail(to: access_requester.notification_email, + subject: subject('Invitation accepted')) + end + + def member_invite_declined_email(member_target_type, target_id, invite_email, created_by_id) + return if created_by_id.nil? + + @member_target_type = member_target_type + @target = target_class.find(target_id) + @invite_email = invite_email + + mail(to: User.find(created_by_id).notification_email, + subject: subject('Invitation declined')) + end + + def member + @member ||= member_class.find(@member_id) + end + + def access_requester + @access_requester ||= member.created_by + end + + def member_target_name + case member_target_type + when 'project' + target.name_with_namespace + else + target.name + end + end + + def member_target_url + @member_target_url ||= target.web_url + end + + private + + def target + @target ||= member.public_send(member_target_type) + end + + def target_class + @target_class ||= member_target_type.classify.constantize + end + + def member_class + @member_class ||= "#{member_target_type.classify}Member".constantize + end + + def members_association + @members_association ||= member_class.to_s.tableize + end + end +end diff --git a/app/mailers/emails/projects.rb b/app/mailers/emails/projects.rb index 43a2a7e80a..689fb3e0ff 100644 --- a/app/mailers/emails/projects.rb +++ b/app/mailers/emails/projects.rb @@ -1,68 +1,5 @@ module Emails module Projects - def project_access_requested_email(project_member_id) - setup_project_member_mail(project_member_id) - - @requester = @project_member.created_by - - project_admins = User.where(id: @project.project_members.admins.pluck(:user_id)).pluck(:notification_email) - - mail(to: project_admins, - subject: subject("Request to join #{@project.name_with_namespace} project")) - end - - def project_access_granted_email(project_member_id) - setup_project_member_mail(project_member_id) - - @current_user = @project_member.user - - mail(to: @current_user.notification_email, - subject: subject("Access to #{@project.name_with_namespace} project was granted")) - end - - def project_access_denied_email(project_id, user_id) - @project = Project.find(project_id) - @current_user = User.find(user_id) - @target_url = namespace_project_url(@project.namespace, @project) - - mail(to: @current_user.notification_email, - subject: subject("Access to #{@project.name_with_namespace} project was denied")) - end - - def project_member_invited_email(project_member_id, token) - setup_project_member_mail(project_member_id) - - @token = token - @current_user = @project_member.user - - mail(to: @project_member.invite_email, - subject: "Invitation to join project #{@project.name_with_namespace}") - end - - def project_invite_accepted_email(project_member_id) - setup_project_member_mail(project_member_id) - return if @project_member.created_by.nil? - - @current_user = @project_member.created_by - - mail(to: @project_member.created_by.notification_email, - subject: subject("Invitation accepted")) - end - - def project_invite_declined_email(project_id, invite_email, access_level, created_by_id) - return if created_by_id.nil? - - @project = Project.find(project_id) - @current_user = @created_by = User.find(created_by_id) - @access_level = access_level - @invite_email = invite_email - - @target_url = namespace_project_url(@project.namespace, @project) - - mail(to: @created_by.notification_email, - subject: subject("Invitation declined")) - end - def project_was_moved_email(project_id, user_id, old_path_with_namespace) @current_user = @user = User.find user_id @project = Project.find project_id @@ -88,13 +25,5 @@ module Emails reply_to: @message.reply_to, subject: @message.subject) end - - private - - def setup_project_member_mail(project_member_id) - @project_member = ProjectMember.find(project_member_id) - @project = @project_member.project - @target_url = namespace_project_url(@project.namespace, @project) - end end end diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb index 1c663bdd52..bd5c6788cc 100644 --- a/app/mailers/notify.rb +++ b/app/mailers/notify.rb @@ -6,8 +6,8 @@ class Notify < BaseMailer include Emails::Notes include Emails::Projects include Emails::Profile - include Emails::Groups include Emails::Builds + include Emails::Members add_template_helper MergeRequestsHelper add_template_helper DiffHelper diff --git a/app/models/group.rb b/app/models/group.rb index b6929112cb..520cbd0283 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -59,6 +59,10 @@ class Group < Namespace "#{self.class.reference_prefix}#{name}" end + def web_url + Gitlab::Routing.url_helpers.group_url(self) + end + def human_name name end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index cd11feb9d7..259199f6e2 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -175,23 +175,24 @@ class NotificationService # Project access request def new_project_access_request(project_member) - mailer.project_access_requested_email(project_member.id).deliver_later + mailer.member_access_requested_email('project', project_member.id).deliver_later end def decline_project_access_request(project, user) - mailer.project_access_denied_email(project.id, user.id).deliver_later + mailer.member_access_denied_email('project', project.id, user.id).deliver_later end def invite_project_member(project_member, token) - mailer.project_member_invited_email(project_member.id, token).deliver_later + mailer.member_invited_email('project', project_member.id, token).deliver_later end def accept_project_invite(project_member) - mailer.project_invite_accepted_email(project_member.id).deliver_later + mailer.member_invite_accepted_email('project', project_member.id).deliver_later end def decline_project_invite(project_member) - mailer.project_invite_declined_email( + mailer.member_invite_declined_email( + 'project', project_member.project.id, project_member.invite_email, project_member.access_level, @@ -200,32 +201,33 @@ class NotificationService end def new_project_member(project_member) - mailer.project_access_granted_email(project_member.id).deliver_later + mailer.member_access_granted_email('project', project_member.id).deliver_later end def update_project_member(project_member) - mailer.project_access_granted_email(project_member.id).deliver_later + mailer.member_access_granted_email('project', project_member.id).deliver_later end # Group access request def new_group_access_request(group_member) - mailer.group_access_requested_email(group_member.id).deliver_later + mailer.member_access_requested_email('group', group_member.id).deliver_later end def decline_group_access_request(group, user) - mailer.group_access_denied_email(group.id, user.id).deliver_later + mailer.member_access_denied_email('group', group.id, user.id).deliver_later end def invite_group_member(group_member, token) - mailer.group_member_invited_email(group_member.id, token).deliver_later + mailer.member_invited_email('group', group_member.id, token).deliver_later end def accept_group_invite(group_member) - mailer.group_invite_accepted_email(group_member.id).deliver_later + mailer.member_invite_accepted_email(group_member.id).deliver_later end def decline_group_invite(group_member) - mailer.group_invite_declined_email( + mailer.member_invite_declined_email( + 'group', group_member.group.id, group_member.invite_email, group_member.access_level, @@ -234,11 +236,11 @@ class NotificationService end def new_group_member(group_member) - mailer.group_access_granted_email(group_member.id).deliver_later + mailer.member_access_granted_email('group', group_member.id).deliver_later end def update_group_member(group_member) - mailer.group_access_granted_email(group_member.id).deliver_later + mailer.member_access_granted_email('group', group_member.id).deliver_later end def project_was_moved(project, old_path_with_namespace) diff --git a/app/views/notify/group_access_denied_email.html.haml b/app/views/notify/group_access_denied_email.html.haml deleted file mode 100644 index 4edfd4e479..0000000000 --- a/app/views/notify/group_access_denied_email.html.haml +++ /dev/null @@ -1,2 +0,0 @@ -%p - Your request to join group #{link_to @group.name, @target_url} has been denied. diff --git a/app/views/notify/group_access_denied_email.text.erb b/app/views/notify/group_access_denied_email.text.erb deleted file mode 100644 index cb32177e82..0000000000 --- a/app/views/notify/group_access_denied_email.text.erb +++ /dev/null @@ -1,3 +0,0 @@ -Your request to join group <%= @group.name %> has been denied. - -<%= @target_url %> diff --git a/app/views/notify/group_access_requested_email.html.haml b/app/views/notify/group_access_requested_email.html.haml deleted file mode 100644 index 4fbcedabae..0000000000 --- a/app/views/notify/group_access_requested_email.html.haml +++ /dev/null @@ -1,3 +0,0 @@ -%p - #{link_to @requester.name, @requester} requested #{@group_member.human_access} - access to group #{link_to @group.name, @target_url}. diff --git a/app/views/notify/group_access_requested_email.text.erb b/app/views/notify/group_access_requested_email.text.erb deleted file mode 100644 index 2f9d293a79..0000000000 --- a/app/views/notify/group_access_requested_email.text.erb +++ /dev/null @@ -1,3 +0,0 @@ -<%= @requester.name %> (<%= user_url(@requester) %>) requested <%= @group_member.human_access %> access to group <%= @group.name %> - -<%= @target_url %> diff --git a/app/views/notify/member_access_denied_email.html.haml b/app/views/notify/member_access_denied_email.html.haml new file mode 100644 index 0000000000..a25af24d78 --- /dev/null +++ b/app/views/notify/member_access_denied_email.html.haml @@ -0,0 +1,4 @@ +%p + Your request to join the + #{link_to member_target_name, member_target_url} #{member_target_type} + has been denied. diff --git a/app/views/notify/member_access_denied_email.text.erb b/app/views/notify/member_access_denied_email.text.erb new file mode 100644 index 0000000000..eb204458d9 --- /dev/null +++ b/app/views/notify/member_access_denied_email.text.erb @@ -0,0 +1,3 @@ +Your request to join the <%= member_target_name %> <%= member_target_type %> has been denied. + +<%= member_target_url %> diff --git a/app/views/notify/member_access_granted_email.html.haml b/app/views/notify/member_access_granted_email.html.haml new file mode 100644 index 0000000000..62837d7455 --- /dev/null +++ b/app/views/notify/member_access_granted_email.html.haml @@ -0,0 +1,3 @@ +%p + You have been granted #{member.human_access} access to the + #{link_to member_target_name, member_target_url} #{member_target_type}. diff --git a/app/views/notify/member_access_granted_email.text.erb b/app/views/notify/member_access_granted_email.text.erb new file mode 100644 index 0000000000..be9bb5ee94 --- /dev/null +++ b/app/views/notify/member_access_granted_email.text.erb @@ -0,0 +1,3 @@ +You have been granted <%= member.human_access %> access to the <%= member_target_name %> <%= member_target_type %>. + +<%= member_target_url %> diff --git a/app/views/notify/member_access_requested_email.html.haml b/app/views/notify/member_access_requested_email.html.haml new file mode 100644 index 0000000000..96e92a069f --- /dev/null +++ b/app/views/notify/member_access_requested_email.html.haml @@ -0,0 +1,3 @@ +%p + #{link_to access_requester.name, access_requester} requested #{member.human_access} + access to the #{link_to member_target_name, member_target_url} #{member_target_type}. diff --git a/app/views/notify/member_access_requested_email.text.erb b/app/views/notify/member_access_requested_email.text.erb new file mode 100644 index 0000000000..3b5de8c2ab --- /dev/null +++ b/app/views/notify/member_access_requested_email.text.erb @@ -0,0 +1,3 @@ +<%= access_requester.name %> (<%= user_url(access_requester) %>) requested <%= member.human_access %> access to the <%= member_target_name %> <%= member_target_type %>. + +<%= member_target_url %> diff --git a/app/views/notify/member_invite_accepted_email.html.haml b/app/views/notify/member_invite_accepted_email.html.haml new file mode 100644 index 0000000000..c420a8a7b3 --- /dev/null +++ b/app/views/notify/member_invite_accepted_email.html.haml @@ -0,0 +1,5 @@ +%p + #{member.invite_email}, now known as + #{link_to member.user.name, user_url(member.user)}, + has accepted your invitation to join the + #{link_to member_target_name, member_target_url} #{member_target_type}. diff --git a/app/views/notify/member_invite_accepted_email.text.erb b/app/views/notify/member_invite_accepted_email.text.erb new file mode 100644 index 0000000000..a1616163ce --- /dev/null +++ b/app/views/notify/member_invite_accepted_email.text.erb @@ -0,0 +1,3 @@ +<%= member.invite_email %>, now known as <%= member.user.name %>, has accepted your invitation to join the <%= member_target_name %> <%= member_target_type %>. + +<%= member_target_url %> diff --git a/app/views/notify/member_invite_declined_email.html.haml b/app/views/notify/member_invite_declined_email.html.haml new file mode 100644 index 0000000000..5a30ac31b3 --- /dev/null +++ b/app/views/notify/member_invite_declined_email.html.haml @@ -0,0 +1,4 @@ +%p + #{@invite_email} + has declined your invitation to join the + #{link_to member_target_name, member_target_url} #{member_target_type}. diff --git a/app/views/notify/member_invite_declined_email.text.erb b/app/views/notify/member_invite_declined_email.text.erb new file mode 100644 index 0000000000..301287946d --- /dev/null +++ b/app/views/notify/member_invite_declined_email.text.erb @@ -0,0 +1,3 @@ +<%= @invite_email %> has declined your invitation to join the <%= member_target_name %> <%= member_target_type %>. + +<%= member_target_url %> diff --git a/app/views/notify/member_invited_email.html.haml b/app/views/notify/member_invited_email.html.haml new file mode 100644 index 0000000000..a8e58df9ac --- /dev/null +++ b/app/views/notify/member_invited_email.html.haml @@ -0,0 +1,13 @@ +%p + You have been invited + - if access_requester + by + = link_to access_requester.name, user_url(access_requester) + to join the + = link_to member_target_name, member_target_url + #{member_target_type} as #{member.human_access}. + +%p + = link_to 'Accept invitation', invite_url(@token) + or + = link_to 'decline', decline_invite_url(@token) diff --git a/app/views/notify/member_invited_email.text.erb b/app/views/notify/member_invited_email.text.erb new file mode 100644 index 0000000000..1b6897ee2e --- /dev/null +++ b/app/views/notify/member_invited_email.text.erb @@ -0,0 +1,4 @@ +You have been invited <%= "by #{access_requester.name} " if access_requester %>to join the <%= member_target_name %> <%= member_target_type %> as <%= member.human_access %>. + +Accept invitation: <%= invite_url(@token) %> +Decline invitation: <%= decline_invite_url(@token) %> diff --git a/app/views/notify/project_access_denied_email.html.haml b/app/views/notify/project_access_denied_email.html.haml deleted file mode 100644 index cecdaf24f3..0000000000 --- a/app/views/notify/project_access_denied_email.html.haml +++ /dev/null @@ -1,3 +0,0 @@ -%p - Your request to join project #{link_to @project.name_with_namespace, @target_url} - has been denied. diff --git a/app/views/notify/project_access_denied_email.text.erb b/app/views/notify/project_access_denied_email.text.erb deleted file mode 100644 index 24357e059d..0000000000 --- a/app/views/notify/project_access_denied_email.text.erb +++ /dev/null @@ -1,3 +0,0 @@ -Your request to join project <%= @project.name_with_namespace %> has been denied. - -<%= @target_url %> diff --git a/app/views/notify/project_access_granted_email.html.haml b/app/views/notify/project_access_granted_email.html.haml deleted file mode 100644 index 88873e7fe5..0000000000 --- a/app/views/notify/project_access_granted_email.html.haml +++ /dev/null @@ -1,3 +0,0 @@ -%p - You have been granted #{@project_member.human_access} access to project - #{link_to @project.name_with_namespace, @target_url}. diff --git a/app/views/notify/project_access_granted_email.text.erb b/app/views/notify/project_access_granted_email.text.erb deleted file mode 100644 index f5e4b31385..0000000000 --- a/app/views/notify/project_access_granted_email.text.erb +++ /dev/null @@ -1,3 +0,0 @@ -You have been granted <%= @project_member.human_access %> access to project <%= @project.name_with_namespace %>. - -<%= @target_url %> diff --git a/app/views/notify/project_access_requested_email.html.haml b/app/views/notify/project_access_requested_email.html.haml deleted file mode 100644 index 2a705ad3b0..0000000000 --- a/app/views/notify/project_access_requested_email.html.haml +++ /dev/null @@ -1,3 +0,0 @@ -%p - #{link_to @requester.name, @requester} requested #{@project_member.human_access} - access to project #{link_to @project.name_with_namespace, @target_url}. diff --git a/app/views/notify/project_access_requested_email.text.erb b/app/views/notify/project_access_requested_email.text.erb deleted file mode 100644 index 2437fa4ee8..0000000000 --- a/app/views/notify/project_access_requested_email.text.erb +++ /dev/null @@ -1,3 +0,0 @@ -<%= @requester.name %> (<%= user_url(@requester) %>) requested <%= @project_member.human_access %> access to project <%= @project.name_with_namespace %>. - -<%= @target_url %> diff --git a/app/views/notify/project_invite_accepted_email.html.haml b/app/views/notify/project_invite_accepted_email.html.haml deleted file mode 100644 index 7e58d30b10..0000000000 --- a/app/views/notify/project_invite_accepted_email.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -%p - #{@project_member.invite_email}, now known as - #{link_to @project_member.user.name, user_url(@project_member.user)}, - has accepted your invitation to join project - #{link_to @project.name_with_namespace, namespace_project_url(@project.namespace, @project)}. - diff --git a/app/views/notify/project_invite_accepted_email.text.erb b/app/views/notify/project_invite_accepted_email.text.erb deleted file mode 100644 index fcbe752114..0000000000 --- a/app/views/notify/project_invite_accepted_email.text.erb +++ /dev/null @@ -1,3 +0,0 @@ -<%= @project_member.invite_email %>, now known as <%= @project_member.user.name %>, has accepted your invitation to join project <%= @project.name_with_namespace %>. - -<%= namespace_project_url(@project.namespace, @project) %> diff --git a/app/views/notify/project_invite_declined_email.html.haml b/app/views/notify/project_invite_declined_email.html.haml deleted file mode 100644 index c2d7e6f6e3..0000000000 --- a/app/views/notify/project_invite_declined_email.html.haml +++ /dev/null @@ -1,5 +0,0 @@ -%p - #{@invite_email} - has declined your invitation to join project - #{link_to @project.name_with_namespace, namespace_project_url(@project.namespace, @project)}. - diff --git a/app/views/notify/project_invite_declined_email.text.erb b/app/views/notify/project_invite_declined_email.text.erb deleted file mode 100644 index 484687fa51..0000000000 --- a/app/views/notify/project_invite_declined_email.text.erb +++ /dev/null @@ -1,3 +0,0 @@ -<%= @invite_email %> has declined your invitation to join project <%= @project.name_with_namespace %>. - -<%= namespace_project_url(@project.namespace, @project) %> diff --git a/app/views/notify/project_member_invited_email.html.haml b/app/views/notify/project_member_invited_email.html.haml deleted file mode 100644 index 79eb89616d..0000000000 --- a/app/views/notify/project_member_invited_email.html.haml +++ /dev/null @@ -1,13 +0,0 @@ -%p - You have been invited - - if inviter = @project_member.created_by - by - = link_to inviter.name, user_url(inviter) - to join project - = link_to @project.name_with_namespace, namespace_project_url(@project.namespace, @project) - as #{@project_member.human_access}. - -%p - = link_to 'Accept invitation', invite_url(@token) - or - = link_to 'decline', decline_invite_url(@token) diff --git a/app/views/notify/project_member_invited_email.text.erb b/app/views/notify/project_member_invited_email.text.erb deleted file mode 100644 index e070627211..0000000000 --- a/app/views/notify/project_member_invited_email.text.erb +++ /dev/null @@ -1,4 +0,0 @@ -You have been invited <%= "by #{@project_member.created_by.name} " if @project_member.created_by %>to join project <%= @project.name_with_namespace %> as <%= @project_member.human_access %>. - -Accept invitation: <%= invite_url(@token) %> -Decline invitation: <%= decline_invite_url(@token) %> diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index 2d86038030..a86ec865b5 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -407,23 +407,16 @@ describe Notify do project.request_access(user) project.project_members.find_by(created_by_id: user.id) end - subject { Notify.project_access_requested_email(project_member.id) } + subject { Notify.member_access_requested_email('project', project_member.id) } it_behaves_like 'an email sent from GitLab' it_behaves_like 'it should not have Gmail Actions links' it_behaves_like "a user cannot unsubscribe through footer link" - it 'has the correct subject' do - is_expected.to have_subject /Request to join #{project.name_with_namespace} project/ - end - - it 'contains name of project' do - is_expected.to have_body_text /#{project.name}/ - end - - it 'contains new user role' do - is_expected.to have_body_text /#{project_member.human_access}/ - end + it { is_expected.to have_subject "Request to join the #{project.name_with_namespace} project" } + it { is_expected.to have_body_text /#{project.name_with_namespace}/ } + it { is_expected.to have_body_text /#{project.web_url}/ } + it { is_expected.to have_body_text /#{project_member.human_access}/ } end describe 'project access denied' do @@ -433,42 +426,99 @@ describe Notify do project.request_access(user) project.project_members.find_by(created_by_id: user.id) end - subject { Notify.project_access_denied_email(project.id, user.id) } + subject { Notify.member_access_denied_email('project', project.id, user.id) } it_behaves_like 'an email sent from GitLab' it_behaves_like 'it should not have Gmail Actions links' it_behaves_like "a user cannot unsubscribe through footer link" - it 'has the correct subject' do - is_expected.to have_subject /Access to #{project.name_with_namespace} project was denied/ - end - - it 'contains name of project' do - is_expected.to have_body_text /#{project.name}/ - end + it { is_expected.to have_subject "Access to the #{project.name_with_namespace} project was denied" } + it { is_expected.to have_body_text /#{project.name_with_namespace}/ } + it { is_expected.to have_body_text /#{project.web_url}/ } end describe 'project access changed' do let(:project) { create(:project) } let(:user) { create(:user) } let(:project_member) { create(:project_member, project: project, user: user) } - subject { Notify.project_access_granted_email(project_member.id) } + subject { Notify.member_access_granted_email('project', project_member.id) } it_behaves_like 'an email sent from GitLab' it_behaves_like 'it should not have Gmail Actions links' it_behaves_like "a user cannot unsubscribe through footer link" - it 'has the correct subject' do - is_expected.to have_subject /Access to #{project.name_with_namespace} project was granted/ + it { is_expected.to have_subject "Access to the #{project.name_with_namespace} project was granted" } + it { is_expected.to have_body_text /#{project.name_with_namespace}/ } + it { is_expected.to have_body_text /#{project.web_url}/ } + it { is_expected.to have_body_text /#{project_member.human_access}/ } + end + + def invite_to_project(project:, email:, inviter:) + ProjectMember.add_user(project.project_members, 'toto@example.com', Gitlab::Access::DEVELOPER, inviter) + + project.project_members.invite.last + end + + describe 'project invitation' do + let(:project) { create(:project) } + let(:master) { create(:user).tap { |u| project.team << [u, :master] } } + let(:project_member) { invite_to_project(project: project, email: 'toto@example.com', inviter: master) } + + subject { Notify.member_invited_email('project', project_member.id, project_member.invite_token) } + + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like "a user cannot unsubscribe through footer link" + + it { is_expected.to have_subject "Invitation to join the #{project.name_with_namespace} project" } + it { is_expected.to have_body_text /#{project.name_with_namespace}/ } + it { is_expected.to have_body_text /#{project.web_url}/ } + it { is_expected.to have_body_text /#{project_member.human_access}/ } + it { is_expected.to have_body_text /#{project_member.invite_token}/ } + end + + describe 'project invitation accepted' do + let(:project) { create(:project) } + let(:invited_user) { create(:user) } + let(:master) { create(:user).tap { |u| project.team << [u, :master] } } + let(:project_member) do + invitee = invite_to_project(project: project, email: 'toto@example.com', inviter: master) + invitee.accept_invite!(invited_user) + invitee end - it 'contains name of project' do - is_expected.to have_body_text /#{project.name}/ + subject { Notify.member_invite_accepted_email('project', project_member.id) } + + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like "a user cannot unsubscribe through footer link" + + it { is_expected.to have_subject 'Invitation accepted' } + it { is_expected.to have_body_text /#{project.name_with_namespace}/ } + it { is_expected.to have_body_text /#{project.web_url}/ } + it { is_expected.to have_body_text /#{project_member.invite_email}/ } + it { is_expected.to have_body_text /#{invited_user.name}/ } + end + + describe 'project invitation declined' do + let(:project) { create(:project) } + let(:master) { create(:user).tap { |u| project.team << [u, :master] } } + let(:project_member) do + invitee = invite_to_project(project: project, email: 'toto@example.com', inviter: master) + invitee.decline_invite! + invitee end - it 'contains new user role' do - is_expected.to have_body_text /#{project_member.human_access}/ - end + subject { Notify.member_invite_declined_email('project', project.id, project_member.invite_email, master.id) } + + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like "a user cannot unsubscribe through footer link" + + it { is_expected.to have_subject 'Invitation declined' } + it { is_expected.to have_body_text /#{project.name_with_namespace}/ } + it { is_expected.to have_body_text /#{project.web_url}/ } + it { is_expected.to have_body_text /#{project_member.invite_email}/ } end context 'items that are noteable, the email for a note' do @@ -583,75 +633,127 @@ describe Notify do end end - describe 'group access requested' do - let(:group) { create(:group) } - let(:user) { create(:user) } - let(:group_member) do - group.request_access(user) - group.group_members.find_by(created_by_id: user.id) - end - subject { Notify.group_access_requested_email(group_member.id) } + context 'for a group' do + describe 'group access requested' do + let(:group) { create(:group) } + let(:user) { create(:user) } + let(:group_member) do + group.request_access(user) + group.group_members.find_by(created_by_id: user.id) + end + subject { Notify.member_access_requested_email('group', group_member.id) } - it_behaves_like 'an email sent from GitLab' - it_behaves_like 'it should not have Gmail Actions links' - it_behaves_like "a user cannot unsubscribe through footer link" + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like "a user cannot unsubscribe through footer link" - it 'has the correct subject' do - is_expected.to have_subject /Request to join #{group.name} group/ + it { is_expected.to have_subject "Request to join the #{group.name} group" } + it { is_expected.to have_body_text /#{group.name}/ } + it { is_expected.to have_body_text /#{group.web_url}/ } + it { is_expected.to have_body_text /#{group_member.human_access}/ } end - it 'contains name of group' do - is_expected.to have_body_text /#{group.name}/ + describe 'group access denied' do + let(:group) { create(:group) } + let(:user) { create(:user) } + let(:group_member) do + group.request_access(user) + group.group_members.find_by(created_by_id: user.id) + end + subject { Notify.member_access_denied_email('group', group.id, user.id) } + + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like "a user cannot unsubscribe through footer link" + + it { is_expected.to have_subject "Access to the #{group.name} group was denied" } + it { is_expected.to have_body_text /#{group.name}/ } + it { is_expected.to have_body_text /#{group.web_url}/ } end - it 'contains new user role' do - is_expected.to have_body_text /#{group_member.human_access}/ - end - end + describe 'group access changed' do + let(:group) { create(:group) } + let(:user) { create(:user) } + let(:group_member) { create(:group_member, group: group, user: user) } - describe 'group access denied' do - let(:group) { create(:group) } - let(:user) { create(:user) } - let(:group_member) do - group.request_access(user) - group.group_members.find_by(created_by_id: user.id) - end - subject { Notify.group_access_denied_email(group.id, user.id) } + subject { Notify.member_access_granted_email('group', group_member.id) } - it_behaves_like 'an email sent from GitLab' - it_behaves_like 'it should not have Gmail Actions links' - it_behaves_like "a user cannot unsubscribe through footer link" + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like "a user cannot unsubscribe through footer link" - it 'has the correct subject' do - is_expected.to have_subject /Access to #{group.name} group was denied/ + it { is_expected.to have_subject "Access to the #{group.name} group was granted" } + it { is_expected.to have_body_text /#{group.name}/ } + it { is_expected.to have_body_text /#{group.web_url}/ } + it { is_expected.to have_body_text /#{group_member.human_access}/ } end - it 'contains name of group' do - is_expected.to have_body_text /#{group.name}/ - end - end + def invite_to_group(group:, email:, inviter:) + GroupMember.add_user(group.group_members, 'toto@example.com', Gitlab::Access::DEVELOPER, inviter) - describe 'group access changed' do - let(:group) { create(:group) } - let(:user) { create(:user) } - let(:membership) { create(:group_member, group: group, user: user) } - - subject { Notify.group_access_granted_email(membership.id) } - - it_behaves_like 'an email sent from GitLab' - it_behaves_like 'it should not have Gmail Actions links' - it_behaves_like "a user cannot unsubscribe through footer link" - - it 'has the correct subject' do - is_expected.to have_subject /Access to #{group.name} group was granted/ + group.group_members.invite.last end - it 'contains name of project' do - is_expected.to have_body_text /#{group.name}/ + describe 'group invitation' do + let(:group) { create(:group) } + let(:owner) { create(:user).tap { |u| group.add_user(u, Gitlab::Access::OWNER) } } + let(:group_member) { invite_to_group(group: group, email: 'toto@example.com', inviter: owner) } + + subject { Notify.member_invited_email('group', group_member.id, group_member.invite_token) } + + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like "a user cannot unsubscribe through footer link" + + it { is_expected.to have_subject "Invitation to join the #{group.name} group" } + it { is_expected.to have_body_text /#{group.name}/ } + it { is_expected.to have_body_text /#{group.web_url}/ } + it { is_expected.to have_body_text /#{group_member.human_access}/ } + it { is_expected.to have_body_text /#{group_member.invite_token}/ } end - it 'contains new user role' do - is_expected.to have_body_text /#{membership.human_access}/ + describe 'group invitation accepted' do + let(:group) { create(:group) } + let(:invited_user) { create(:user) } + let(:owner) { create(:user).tap { |u| group.add_user(u, Gitlab::Access::OWNER) } } + let(:group_member) do + invitee = invite_to_group(group: group, email: 'toto@example.com', inviter: owner) + invitee.accept_invite!(invited_user) + invitee + end + + subject { Notify.member_invite_accepted_email('group', group_member.id) } + + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like "a user cannot unsubscribe through footer link" + + it { is_expected.to have_subject 'Invitation accepted' } + it { is_expected.to have_body_text /#{group.name}/ } + it { is_expected.to have_body_text /#{group.web_url}/ } + it { is_expected.to have_body_text /#{group_member.invite_email}/ } + it { is_expected.to have_body_text /#{invited_user.name}/ } + end + + describe 'group invitation declined' do + let(:group) { create(:group) } + let(:owner) { create(:user).tap { |u| group.add_user(u, Gitlab::Access::OWNER) } } + let(:group_member) do + invitee = invite_to_group(group: group, email: 'toto@example.com', inviter: owner) + invitee.decline_invite! + invitee + end + + subject { Notify.member_invite_declined_email('group', group.id, group_member.invite_email, owner.id) } + + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like "a user cannot unsubscribe through footer link" + + it { is_expected.to have_subject 'Invitation declined' } + it { is_expected.to have_body_text /#{group.name}/ } + it { is_expected.to have_body_text /#{group.web_url}/ } + it { is_expected.to have_body_text /#{group_member.invite_email}/ } end end From 515205d3c1c6655302ed0ae44cc5954dead7ae79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= Date: Thu, 2 Jun 2016 18:05:06 +0200 Subject: [PATCH 24/36] UI and copywriting improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit + Move 'Edit Project/Group' out of membership-related partial + Show the access request buttons only to logged-in users + Put the request access buttons out of in a more visible button + Improve the copy in the #remove_member_message helper Signed-off-by: Rémy Coutable --- app/assets/stylesheets/pages/groups.scss | 17 +++ app/assets/stylesheets/pages/projects.scss | 17 ++- .../concerns/access_request_actions.rb | 38 ----- .../concerns/membership_actions.rb | 58 ++++++++ .../groups/group_members_controller.rb | 42 +----- .../projects/project_members_controller.rb | 43 +----- app/helpers/gitlab_routing_helper.rb | 77 ++++++++-- app/helpers/members_helper.rb | 128 ++++------------- app/helpers/projects_helper.rb | 4 - app/mailers/emails/members.rb | 89 +++++------- app/mailers/notify.rb | 2 + app/models/ability.rb | 4 - app/models/concerns/access_requestable.rb | 13 +- app/models/group.rb | 2 +- app/models/member.rb | 37 +++-- app/models/members/group_member.rb | 9 +- app/models/members/project_member.rb | 4 +- app/models/project.rb | 4 +- app/models/project_team.rb | 18 ++- app/services/notification_service.rb | 30 ++-- .../groups/group_members/index.html.haml | 2 +- app/views/groups/show.html.haml | 3 + .../layouts/nav/_group_settings.html.haml | 17 ++- app/views/layouts/nav/_project.html.haml | 36 ++--- .../group_access_granted_email.html.haml | 3 - .../group_access_granted_email.text.erb | 3 - .../group_invite_accepted_email.html.haml | 6 - .../group_invite_accepted_email.text.erb | 3 - .../group_invite_declined_email.html.haml | 5 - .../group_invite_declined_email.text.erb | 3 - .../group_member_invited_email.html.haml | 14 -- .../group_member_invited_email.text.erb | 4 - .../member_access_denied_email.html.haml | 2 +- .../member_access_denied_email.text.erb | 4 +- .../member_access_granted_email.html.haml | 2 +- .../member_access_granted_email.text.erb | 4 +- .../member_access_requested_email.html.haml | 4 +- .../member_access_requested_email.text.erb | 4 +- .../member_invite_accepted_email.html.haml | 2 +- .../member_invite_accepted_email.text.erb | 4 +- .../member_invite_declined_email.html.haml | 2 +- .../member_invite_declined_email.text.erb | 4 +- .../notify/member_invited_email.html.haml | 8 +- .../notify/member_invited_email.text.erb | 2 +- app/views/projects/_home_panel.html.haml | 11 +- .../projects/buttons/_notifications.html.haml | 2 +- app/views/projects/notes/_note.html.haml | 2 +- .../project_members/_group_members.html.haml | 10 +- .../projects/project_members/index.html.haml | 4 +- .../_group_or_project_home_dropdown.html.haml | 30 ---- .../members/_access_request_buttons.html.haml | 12 ++ app/views/shared/members/_member.html.haml | 28 ++-- app/views/shared/members/_requests.html.haml | 10 +- features/steps/dashboard/group.rb | 2 +- features/steps/group/members.rb | 10 +- features/steps/project/team_management.rb | 26 ++-- lib/api/entities.rb | 5 +- .../groups/group_members_controller_spec.rb | 24 ++-- .../project_members_controller_spec.rb | 30 ++-- .../owner_manages_access_requests_spec.rb | 14 +- .../members/user_requests_access_spec.rb | 28 ++-- .../master_manages_access_requests_spec.rb | 14 +- .../members/user_requests_access_spec.rb | 32 ++--- spec/helpers/gitlab_routing_helper_spec.rb | 79 +++++++++++ spec/helpers/members_helper_spec.rb | 111 +++------------ spec/helpers/projects_helper_spec.rb | 19 --- spec/mailers/notify_spec.rb | 132 +++++++++++------- .../concerns/access_requestable_spec.rb | 7 +- spec/models/group_spec.rb | 11 -- spec/models/member_spec.rb | 103 +++++++++----- spec/models/members/group_member_spec.rb | 22 +-- spec/models/members/project_member_spec.rb | 22 +-- spec/models/project_spec.rb | 19 +-- spec/models/project_team_spec.rb | 22 +++ 74 files changed, 777 insertions(+), 841 deletions(-) delete mode 100644 app/controllers/concerns/access_request_actions.rb create mode 100644 app/controllers/concerns/membership_actions.rb delete mode 100644 app/views/notify/group_access_granted_email.html.haml delete mode 100644 app/views/notify/group_access_granted_email.text.erb delete mode 100644 app/views/notify/group_invite_accepted_email.html.haml delete mode 100644 app/views/notify/group_invite_accepted_email.text.erb delete mode 100644 app/views/notify/group_invite_declined_email.html.haml delete mode 100644 app/views/notify/group_invite_declined_email.text.erb delete mode 100644 app/views/notify/group_member_invited_email.html.haml delete mode 100644 app/views/notify/group_member_invited_email.text.erb delete mode 100644 app/views/shared/_group_or_project_home_dropdown.html.haml create mode 100644 app/views/shared/members/_access_request_buttons.html.haml create mode 100644 spec/helpers/gitlab_routing_helper_spec.rb diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss index ec6c099df5..ac7721cbe1 100644 --- a/app/assets/stylesheets/pages/groups.scss +++ b/app/assets/stylesheets/pages/groups.scss @@ -39,3 +39,20 @@ } } } + +.groups-cover-block { + + .container-fluid { + position: relative; + } + + .access-request-button { + @include btn-gray; + position: absolute; + right: 16px; + bottom: 32px; + padding: 3px 10px; + text-transform: none; + background-color: $background-color; + } +} diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 2505deaf75..0e4cefc55c 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -229,13 +229,20 @@ right: 16px; bottom: 0; - .btn { - padding: 3px 10px; - background-color: $background-color; + @media (max-width: $screen-lg-min) { + top: 0; } - @media (max-width: 1304px) { - top: 0; + .access-request-button { + position: absolute; + right: 0; + bottom: 61px; + + @media (max-width: $screen-lg-min) { + position: relative; + bottom: 0; + margin-right: 10px; + } } } diff --git a/app/controllers/concerns/access_request_actions.rb b/app/controllers/concerns/access_request_actions.rb deleted file mode 100644 index c4d22749d6..0000000000 --- a/app/controllers/concerns/access_request_actions.rb +++ /dev/null @@ -1,38 +0,0 @@ -module AccessRequestActions - extend ActiveSupport::Concern - - def request_access - access_requestable_resource.request_access(current_user) - - redirect_to access_requestable_resource_path, - notice: 'Your request for access has been queued for review.' - end - - def approve_access_request - @member = access_requestable_resource.public_send(member_entity_name.pluralize).request.find(params[:id]) - - return render_403 unless can?(current_user, :"update_#{member_entity_name}", @member) - - @member.accept_request - - redirect_to access_requestable_resource_members_path - end - - protected - - def access_requestable_resource - raise NotImplementedError - end - - def access_requestable_resource_path - access_requestable_resource - end - - def access_requestable_resource_members_path - [access_requestable_resource, 'members'] - end - - def member_entity_name - "#{access_requestable_resource.class.to_s.underscore}_member" - end -end diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb new file mode 100644 index 0000000000..a24273fad0 --- /dev/null +++ b/app/controllers/concerns/membership_actions.rb @@ -0,0 +1,58 @@ +module MembershipActions + extend ActiveSupport::Concern + include MembersHelper + + def request_access + membershipable.request_access(current_user) + + redirect_to polymorphic_path(membershipable), + notice: 'Your request for access has been queued for review.' + end + + def approve_access_request + @member = membershipable.members.request.find(params[:id]) + + return render_403 unless can?(current_user, action_member_permission(:update, @member), @member) + + @member.accept_request + + redirect_to polymorphic_url([membershipable, :members]) + end + + def leave + @member = membershipable.members.find_by(user_id: current_user) + return render_403 unless @member + + source_type = @member.real_source_type.humanize(capitalize: false) + + if can?(current_user, action_member_permission(:destroy, @member), @member) + notice = + if @member.request? + "Your access request to the #{source_type} has been withdrawn." + else + "You left the \"#{@member.source.human_name}\" #{source_type}." + end + @member.destroy + + redirect_to [:dashboard, @member.real_source_type.tableize], notice: notice + else + if cannot_leave? + alert = "You can not leave the \"#{@member.source.human_name}\" #{source_type}." + alert << " Transfer or delete the #{source_type}." + redirect_to polymorphic_url(membershipable), alert: alert + else + render_403 + end + end + end + + protected + + def membershipable + raise NotImplementedError + end + + def cannot_leave? + raise NotImplementedError + end +end diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index a37129062f..d0f2e2949f 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -1,5 +1,5 @@ class Groups::GroupMembersController < Groups::ApplicationController - include AccessRequestActions + include MembershipActions # Authorize before_action :authorize_admin_group_member!, except: [:index, :leave, :request_access] @@ -38,7 +38,7 @@ class Groups::GroupMembersController < Groups::ApplicationController return render_403 unless can?(current_user, :destroy_group_member, @group_member) - @group_member.request? ? @group_member.decline_request : @group_member.destroy + @group_member.destroy respond_to do |format| format.html { redirect_to group_group_members_path(@group), notice: 'User was successfully removed from group.' } @@ -60,46 +60,16 @@ class Groups::GroupMembersController < Groups::ApplicationController end end - def leave - @group_member = - @group.group_members.find_by(user_id: current_user.id) || - @group.group_members.find_by(created_by_id: current_user.id) - - if can?(current_user, :destroy_group_member, @group_member) - notice = - if @group_member.request? - 'You withdrawn your access request to the group.' - else - "You left #{@group.name} group." - end - @group_member.destroy - - redirect_to dashboard_groups_path, notice: notice - else - if @group.last_owner?(current_user) - redirect_to(dashboard_groups_path, alert: "You can not leave #{group.name} group because you're the last owner. Transfer or delete the group.") - else - return render_403 - end - end - end - protected def member_params params.require(:group_member).permit(:access_level, :user_id) end - # AccessRequestActions concern - def access_requestable_resource - @group - end + # MembershipActions concern + alias_method :membershipable, :group - def access_requestable_resource_path - group_path(@group) - end - - def access_requestable_resource_members_path - group_group_members_path(@group) + def cannot_leave? + @group.last_owner?(current_user) end end diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index c61eda95bc..35d067cd02 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -1,5 +1,5 @@ class Projects::ProjectMembersController < Projects::ApplicationController - include AccessRequestActions + include MembershipActions # Authorize before_action :authorize_admin_project_member!, except: [:index, :leave, :request_access] @@ -52,7 +52,7 @@ class Projects::ProjectMembersController < Projects::ApplicationController return render_403 unless can?(current_user, :destroy_project_member, @project_member) - @project_member.request? ? @project_member.decline_request : @project_member.destroy + @project_member.destroy respond_to do |format| format.html do @@ -76,31 +76,6 @@ class Projects::ProjectMembersController < Projects::ApplicationController end end - def leave - @project_member = - @project.project_members.find_by(user_id: current_user.id) || - @project.project_members.find_by(created_by_id: current_user.id) - - if can?(current_user, :destroy_project_member, @project_member) - notice = - if @project_member.request? - 'You withdrawn your access request to the project.' - else - 'You left the project.' - end - @project_member.destroy - - redirect_to dashboard_projects_path, notice: notice - else - if current_user == @project.owner - message = 'You can not leave your own project. Transfer or delete the project.' - redirect_back_or_default(default: { action: 'index' }, options: { alert: message }) - else - render_403 - end - end - end - def apply_import source_project = Project.find(params[:source_project_id]) @@ -121,16 +96,10 @@ class Projects::ProjectMembersController < Projects::ApplicationController params.require(:project_member).permit(:user_id, :access_level) end - # AccessRequestActions concern - def access_requestable_resource - @project - end + # MembershipActions concern + alias_method :membershipable, :project - def access_requestable_resource_path - namespace_project_path(@project.namespace, @project) - end - - def access_requestable_resource_members_path - namespace_project_project_members_path(@project.namespace, @project) + def cannot_leave? + current_user == @project.owner end end diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb index 2ce2d4e694..3a43e936ae 100644 --- a/app/helpers/gitlab_routing_helper.rb +++ b/app/helpers/gitlab_routing_helper.rb @@ -13,10 +13,23 @@ # merge_request_path(merge_request) # module GitlabRoutingHelper + # Project def project_path(project, *args) namespace_project_path(project.namespace, project, *args) end + def project_url(project, *args) + namespace_project_url(project.namespace, project, *args) + end + + def edit_project_path(project, *args) + edit_namespace_project_path(project.namespace, project, *args) + end + + def edit_project_url(project, *args) + edit_namespace_project_url(project.namespace, project, *args) + end + def project_files_path(project, *args) namespace_project_tree_path(project.namespace, project, @ref || project.repository.root_ref) end @@ -41,10 +54,6 @@ module GitlabRoutingHelper activity_namespace_project_path(project.namespace, project, *args) end - def edit_project_path(project, *args) - edit_namespace_project_path(project.namespace, project, *args) - end - def runners_path(project, *args) namespace_project_runners_path(project.namespace, project, *args) end @@ -65,14 +74,6 @@ module GitlabRoutingHelper namespace_project_milestone_path(entity.project.namespace, entity.project, entity, *args) end - def project_url(project, *args) - namespace_project_url(project.namespace, project, *args) - end - - def edit_project_url(project, *args) - edit_namespace_project_url(project.namespace, project, *args) - end - def issue_url(entity, *args) namespace_project_issue_url(entity.project.namespace, entity.project, entity, *args) end @@ -92,4 +93,56 @@ module GitlabRoutingHelper toggle_subscription_namespace_project_merge_request_path(entity.project.namespace, entity.project, entity) end end + + ## Members + def project_members_url(project, *args) + namespace_project_project_members_url(project.namespace, project) + end + + def project_member_path(project_member, *args) + namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member) + end + + def request_access_project_members_path(project, *args) + request_access_namespace_project_project_members_path(project.namespace, project) + end + + def leave_project_members_path(project, *args) + leave_namespace_project_project_members_path(project.namespace, project) + end + + def approve_access_request_project_member_path(project_member, *args) + approve_access_request_namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member) + end + + def resend_invite_project_member_path(project_member, *args) + resend_invite_namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member) + end + + # Groups + + ## Members + def group_members_url(group, *args) + group_group_members_url(group, *args) + end + + def group_member_path(group_member, *args) + group_group_member_path(group_member.source, group_member) + end + + def request_access_group_members_path(group, *args) + request_access_group_group_members_path(group) + end + + def leave_group_members_path(group, *args) + leave_group_group_members_path(group) + end + + def approve_access_request_group_member_path(group_member, *args) + approve_access_request_group_group_member_path(group_member.source, group_member) + end + + def resend_invite_group_member_path(group_member, *args) + resend_invite_group_group_member_path(group_member.source, group_member) + end end diff --git a/app/helpers/members_helper.rb b/app/helpers/members_helper.rb index bd84b8b239..a53828ef4e 100644 --- a/app/helpers/members_helper.rb +++ b/app/helpers/members_helper.rb @@ -1,117 +1,45 @@ module MembersHelper - def member_class(member) - "#{member.source.class.to_s}Member".constantize - end - - def members_association(entity) - "#{entity.class.to_s.underscore}_members".to_sym - end - + # Returns a `__member` association, e.g.: + # - admin_project_member, update_project_member, destroy_project_member + # - admin_group_member, update_group_member, destroy_group_member def action_member_permission(action, member) - "#{action}_#{member.source.class.to_s.underscore}_member".to_sym + "#{action}_#{member.type.underscore}".to_sym end - def can_see_entity_roles?(user, entity) + def can_see_member_roles?(source:, user: nil) return false unless user - user.is_admin? || entity.send(members_association(entity)).exists?(user_id: user.id) + user.is_admin? || source.members.exists?(user_id: user.id) end - def member_path(member) - case member.source - when Project - namespace_project_project_member_path(member.source.namespace, member.source, member) - when Group - group_group_member_path(member.source, member) - else - raise ArgumentError.new('Unknown object class') - end - end + def remove_member_message(member, user: nil) + user = current_user if defined?(current_user) - def resend_invite_member_path(member) - case member.source - when Project - resend_invite_namespace_project_project_member_path(member.source.namespace, member.source, member) - when Group - resend_invite_group_group_member_path(member.source, member) - else - raise ArgumentError.new('Unknown object class') - end - end + text = 'Are you sure you want to ' + action = + if member.request? + if member.user == user + 'withdraw your access request for' + else + "deny #{member.user.name}'s request to join" + end + elsif member.invite? + "revoke the invitation for #{member.invite_email} to join" + else + "remove #{member.user.name} from" + end - def request_access_path(entity) - case entity - when Project - request_access_namespace_project_project_members_path(entity.namespace, entity) - when Group - request_access_group_group_members_path(entity) - else - raise ArgumentError.new('Unknown object class') - end - end - - def approve_request_member_path(member) - case member.source - when Project - approve_access_request_namespace_project_project_member_path(member.source.namespace, member.source, member) - when Group - approve_access_request_group_group_member_path(member.source, member) - else - raise ArgumentError.new('Unknown object class') - end - end - - def leave_path(entity) - case entity - when Project - leave_namespace_project_project_members_path(entity.namespace, entity) - when Group - leave_group_group_members_path(entity) - else - raise ArgumentError.new('Unknown object class') - end - end - - def withdraw_request_message(entity) - "Are you sure you want to withdraw your access request for the \"#{entity_name(entity)}\" #{entity_type(entity)}?" - end - - def remove_member_message(member) - entity = member.source - entity_type = entity_type(entity) - entity_name = entity_name(entity) - - if member.request? - "You are going to deny #{member.created_by.name}'s request to join the #{entity_name} #{entity_type}. Are you sure?" - elsif member.invite? - "You are going to revoke the invitation for #{member.invite_email} to join the #{entity_name} #{entity_type}. Are you sure?" - else - "You are going to remove #{member.user.name} from the #{entity_name} #{entity_type}. Are you sure?" - end + text << action << " the #{member.source.human_name} #{member.real_source_type.humanize(capitalize: false)}?" end def remove_member_title(member) - member.request? ? 'Deny access request' : 'Remove user' + text = " from #{member.real_source_type.humanize(capitalize: false)}" + + text.prepend(member.request? ? 'Deny access request' : 'Remove user') end - def leave_confirmation_message(entity) - "Are you sure you want to leave \"#{entity_name(entity)}\" #{entity_type(entity)}?" - end - - private - - def entity_type(entity) - entity.class.to_s.underscore - end - - def entity_name(entity) - case entity - when Project - entity.name_with_namespace - when Group - entity.name - else - raise ArgumentError.new('Unknown object class') - end + def leave_confirmation_message(member_source) + "Are you sure you want to leave the " \ + "\"#{member_source.human_name}\" #{member_source.class.to_s.humanize(capitalize: false)}?" end end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 03941f87b1..d30dd66202 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -1,8 +1,4 @@ module ProjectsHelper - def max_access_level(project, user) - Gitlab::Access.options_with_owner.key(project.team.max_member_access(user.id)) - end - def link_to_project(project) link_to [project.namespace.becomes(Namespace), project], title: h(project.name) do title = content_tag(:span, project.name, class: 'project-name') diff --git a/app/mailers/emails/members.rb b/app/mailers/emails/members.rb index 5fd55c149d..6dde2e9847 100644 --- a/app/mailers/emails/members.rb +++ b/app/mailers/emails/members.rb @@ -1,104 +1,81 @@ module Emails module Members extend ActiveSupport::Concern + include MembersHelper included do - attr_reader :member_target_type - helper_method :member, :access_requester, :member_target_type, :member_target_name, :member_target_url + helper_method :member_source, :member end - def member_access_requested_email(member_target_type, member_id) - @member_target_type = member_target_type + def member_access_requested_email(member_source_type, member_id) + @member_source_type = member_source_type @member_id = member_id - admins = User.where(id: target.public_send(members_association).admins.pluck(:user_id)).pluck(:notification_email) + admins = member_source.members.owners_and_masters.includes(:user).pluck(:notification_email) mail(to: admins, - subject: subject("Request to join the #{member_target_name} #{member_target_type}")) + subject: subject("Request to join the #{member_source.human_name} #{member_source.model_name.singular}")) end - def member_access_granted_email(member_target_type, member_id) - @member_target_type = member_target_type + def member_access_granted_email(member_source_type, member_id) + @member_source_type = member_source_type @member_id = member_id mail(to: member.user.notification_email, - subject: subject("Access to the #{member_target_name} #{member_target_type} was granted")) + subject: subject("Access to the #{member_source.human_name} #{member_source.model_name.singular} was granted")) end - def member_access_denied_email(member_target_type, target_id, user_id) - @member_target_type = member_target_type - @target = target_class.find(target_id) + def member_access_denied_email(member_source_type, source_id, user_id) + @member_source_type = member_source_type + @member_source = member_source_class.find(source_id) + requester = User.find(user_id) - mail(to: User.find(user_id).notification_email, - subject: subject("Access to the #{member_target_name} #{member_target_type} was denied")) + mail(to: requester.notification_email, + subject: subject("Access to the #{member_source.human_name} #{member_source.model_name.singular} was denied")) end - def member_invited_email(member_target_type, member_id, token) - @member_target_type = member_target_type + def member_invited_email(member_source_type, member_id, token) + @member_source_type = member_source_type @member_id = member_id @token = token mail(to: member.invite_email, - subject: "Invitation to join the #{member_target_name} #{member_target_type}") + subject: "Invitation to join the #{member_source.human_name} #{member_source.model_name.singular}") end - def member_invite_accepted_email(member_target_type, member_id) - @member_target_type = member_target_type + def member_invite_accepted_email(member_source_type, member_id) + @member_source_type = member_source_type @member_id = member_id - return if access_requester.nil? + return unless member.created_by - mail(to: access_requester.notification_email, + mail(to: member.created_by.notification_email, subject: subject('Invitation accepted')) end - def member_invite_declined_email(member_target_type, target_id, invite_email, created_by_id) - return if created_by_id.nil? + def member_invite_declined_email(member_source_type, source_id, invite_email, created_by_id) + return unless created_by_id - @member_target_type = member_target_type - @target = target_class.find(target_id) + @member_source_type = member_source_type + @member_source = member_source_class.find(source_id) @invite_email = invite_email + inviter = User.find(created_by_id) - mail(to: User.find(created_by_id).notification_email, + mail(to: inviter.notification_email, subject: subject('Invitation declined')) end def member - @member ||= member_class.find(@member_id) + @member ||= Member.find(@member_id) end - def access_requester - @access_requester ||= member.created_by - end - - def member_target_name - case member_target_type - when 'project' - target.name_with_namespace - else - target.name - end - end - - def member_target_url - @member_target_url ||= target.web_url + def member_source + @member_source ||= member.source end private - def target - @target ||= member.public_send(member_target_type) - end - - def target_class - @target_class ||= member_target_type.classify.constantize - end - - def member_class - @member_class ||= "#{member_target_type.classify}Member".constantize - end - - def members_association - @members_association ||= member_class.to_s.tableize + def member_source_class + @member_source_type.classify.constantize end end end diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb index bd5c6788cc..0cc709f68e 100644 --- a/app/mailers/notify.rb +++ b/app/mailers/notify.rb @@ -13,6 +13,8 @@ class Notify < BaseMailer add_template_helper DiffHelper add_template_helper BlobHelper add_template_helper EmailsHelper + add_template_helper MembersHelper + add_template_helper GitlabRoutingHelper def test_email(recipient_email, subject, body) mail(to: recipient_email, diff --git a/app/models/ability.rb b/app/models/ability.rb index 90156bf130..647a73aa1c 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -460,8 +460,6 @@ class Ability rules << :destroy_group_member elsif user == target_user rules << :destroy_group_member - elsif subject.request? && user == subject.created_by - rules << :destroy_group_member end end @@ -481,8 +479,6 @@ class Ability rules << :destroy_project_member elsif user == target_user rules << :destroy_project_member - elsif subject.request? && user == subject.created_by - rules << :destroy_project_member end end diff --git a/app/models/concerns/access_requestable.rb b/app/models/concerns/access_requestable.rb index cf37284e31..eedd32a729 100644 --- a/app/models/concerns/access_requestable.rb +++ b/app/models/concerns/access_requestable.rb @@ -10,18 +10,7 @@ module AccessRequestable def request_access(user) members.create( access_level: Gitlab::Access::DEVELOPER, - created_by: user, + user: user, requested_at: Time.now.utc) end - - def access_requested?(user) - members.where(created_by_id: user.id).where.not(requested_at: nil).any? - end - - private - - # Returns a `_members` association, e.g.: project_members, group_members - def members - @members ||= send("#{self.class.to_s.underscore}_members".to_sym) - end end diff --git a/app/models/group.rb b/app/models/group.rb index 520cbd0283..b8dffe9f5b 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -8,7 +8,7 @@ class Group < Namespace has_many :group_members, dependent: :destroy, as: :source, class_name: 'GroupMember' alias_method :members, :group_members - has_many :users, through: :group_members + has_many :users, -> { where(members: { requested_at: nil }) }, through: :group_members has_many :project_group_links, dependent: :destroy has_many :shared_projects, through: :project_group_links, source: :project has_many :notification_settings, dependent: :destroy, as: :source diff --git a/app/models/member.rb b/app/models/member.rb index 5c3a5eab40..cea6d25976 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -8,7 +8,7 @@ class Member < ActiveRecord::Base belongs_to :user belongs_to :source, polymorphic: true - validates :user, presence: true, unless: :pending? + validates :user, presence: true, unless: :invite? validates :source, presence: true validates :user_id, uniqueness: { scope: [:source_type, :source_id], message: "already exists in source", @@ -27,16 +27,17 @@ class Member < ActiveRecord::Base } scope :invite, -> { where.not(invite_token: nil) } + scope :non_invite, -> { where(invite_token: nil) } scope :request, -> { where.not(requested_at: nil) } scope :non_request, -> { where(requested_at: nil) } - scope :non_pending, -> { where.not(user_id: nil) } + scope :non_pending, -> { non_request.non_invite } scope :guests, -> { where(access_level: GUEST) } scope :reporters, -> { where(access_level: REPORTER) } scope :developers, -> { where(access_level: DEVELOPER) } scope :masters, -> { where(access_level: MASTER) } scope :owners, -> { where(access_level: OWNER) } - scope :admins, -> { where(access_level: [OWNER, MASTER]) } + scope :owners_and_masters, -> { where(access_level: [OWNER, MASTER]) } before_validation :generate_invite_token, on: :create, if: -> (member) { member.invite_email.present? } @@ -46,6 +47,7 @@ class Member < ActiveRecord::Base after_create :post_create_hook, unless: :pending? after_update :post_update_hook, unless: :pending? after_destroy :post_destroy_hook, unless: :pending? + after_destroy :post_decline_request, if: :request? delegate :name, :username, :email, to: :user, prefix: true @@ -102,36 +104,31 @@ class Member < ActiveRecord::Base end end - def pending? - request? || invite? - end - - def request? - user.nil? && created_by.present? && requested_at.present? + def real_source_type + source_type end def invite? self.invite_token.present? end + def request? + requested_at.present? + end + + def pending? + invite? || request? + end + def accept_request return false unless request? - updated = self.update(user: created_by, requested_at: nil) + updated = self.update(requested_at: nil) after_accept_request if updated updated end - def decline_request - return false unless request? - - self.destroy - after_decline_request if destroyed? - - destroyed? - end - def accept_invite!(new_user) return false unless invite? @@ -217,7 +214,7 @@ class Member < ActiveRecord::Base post_create_hook end - def after_decline_request + def post_decline_request # override in subclass end diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb index 476b4816b9..363db87796 100644 --- a/app/models/members/group_member.rb +++ b/app/models/members/group_member.rb @@ -20,6 +20,11 @@ class GroupMember < Member access_level end + # Because source_type is `Namespace`... + def real_source_type + 'Group' + end + private def send_invite @@ -60,8 +65,8 @@ class GroupMember < Member super end - def after_decline_request - notification_service.decline_group_access_request(group, created_by) + def post_decline_request + notification_service.decline_group_access_request(self) super end diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index c6fd1a5c3d..250ee04fd1 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -152,8 +152,8 @@ class ProjectMember < Member super end - def after_decline_request - notification_service.decline_project_access_request(project, created_by) + def post_decline_request + notification_service.decline_project_access_request(self) super end diff --git a/app/models/project.rb b/app/models/project.rb index ef66537349..0d2e612436 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -104,7 +104,8 @@ class Project < ActiveRecord::Base has_many :hooks, dependent: :destroy, class_name: 'ProjectHook' has_many :protected_branches, dependent: :destroy has_many :project_members, dependent: :destroy, as: :source, class_name: 'ProjectMember' - has_many :users, through: :project_members + alias_method :members, :project_members + has_many :users, -> { where(members: { requested_at: nil }) }, through: :project_members has_many :deploy_keys_projects, dependent: :destroy has_many :deploy_keys, through: :deploy_keys_projects has_many :users_star_projects, dependent: :destroy @@ -690,6 +691,7 @@ class Project < ActiveRecord::Base end end end + alias_method :human_name, :name_with_namespace def path_with_namespace if namespace diff --git a/app/models/project_team.rb b/app/models/project_team.rb index 7fb17df0e9..73e736820a 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -22,12 +22,12 @@ class ProjectTeam end def find_member(user_id) - member = project.project_members.find_by(user_id: user_id) + member = project.members.non_request.find_by(user_id: user_id) # If user is not in project members # we should check for group membership if group && !member - member = group.group_members.find_by(user_id: user_id) + member = group.members.non_request.find_by(user_id: user_id) end member @@ -128,12 +128,16 @@ class ProjectTeam end end + def human_max_access(user_id) + Gitlab::Access.options_with_owner.key(max_member_access(user_id)) + end + # This method assumes project and group members are eager loaded for optimal # performance. def max_member_access(user_id) access = [] - project.project_members.each do |member| + project.members.non_request.each do |member| if member.user_id == user_id access << member.access_field if member.access_field break @@ -141,7 +145,7 @@ class ProjectTeam end if group - group.group_members.each do |member| + group.members.non_request.each do |member| if member.user_id == user_id access << member.access_field if member.access_field break @@ -174,14 +178,14 @@ class ProjectTeam end def fetch_members(level = nil) - project_members = project.project_members - group_members = group ? group.group_members : [] + project_members = project.members.non_request + group_members = group ? group.members.non_request : [] invited_members = [] if project.invited_groups.any? && project.allowed_to_share_with_group? project.project_group_links.each do |group_link| invited_group = group_link.group - im = invited_group.group_members + im = invited_group.members.non_request if level int_level = GroupMember.access_level_roles[level.to_s.singularize.titleize] diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 259199f6e2..f804ac171c 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -175,24 +175,24 @@ class NotificationService # Project access request def new_project_access_request(project_member) - mailer.member_access_requested_email('project', project_member.id).deliver_later + mailer.member_access_requested_email(project_member.real_source_type, project_member.id).deliver_later end - def decline_project_access_request(project, user) - mailer.member_access_denied_email('project', project.id, user.id).deliver_later + def decline_project_access_request(project_member) + mailer.member_access_denied_email(project_member.real_source_type, project_member.project.id, project_member.user.id).deliver_later end def invite_project_member(project_member, token) - mailer.member_invited_email('project', project_member.id, token).deliver_later + mailer.member_invited_email(project_member.real_source_type, project_member.id, token).deliver_later end def accept_project_invite(project_member) - mailer.member_invite_accepted_email('project', project_member.id).deliver_later + mailer.member_invite_accepted_email(project_member.real_source_type, project_member.id).deliver_later end def decline_project_invite(project_member) mailer.member_invite_declined_email( - 'project', + project_member.real_source_type, project_member.project.id, project_member.invite_email, project_member.access_level, @@ -201,24 +201,24 @@ class NotificationService end def new_project_member(project_member) - mailer.member_access_granted_email('project', project_member.id).deliver_later + mailer.member_access_granted_email(project_member.real_source_type, project_member.id).deliver_later end def update_project_member(project_member) - mailer.member_access_granted_email('project', project_member.id).deliver_later + mailer.member_access_granted_email(project_member.real_source_type, project_member.id).deliver_later end # Group access request def new_group_access_request(group_member) - mailer.member_access_requested_email('group', group_member.id).deliver_later + mailer.member_access_requested_email(group_member.real_source_type, group_member.id).deliver_later end - def decline_group_access_request(group, user) - mailer.member_access_denied_email('group', group.id, user.id).deliver_later + def decline_group_access_request(group_member) + mailer.member_access_denied_email(group_member.real_source_type, group_member.group.id, group_member.user.id).deliver_later end def invite_group_member(group_member, token) - mailer.member_invited_email('group', group_member.id, token).deliver_later + mailer.member_invited_email(group_member.real_source_type, group_member.id, token).deliver_later end def accept_group_invite(group_member) @@ -227,7 +227,7 @@ class NotificationService def decline_group_invite(group_member) mailer.member_invite_declined_email( - 'group', + group_member.real_source_type, group_member.group.id, group_member.invite_email, group_member.access_level, @@ -236,11 +236,11 @@ class NotificationService end def new_group_member(group_member) - mailer.member_access_granted_email('group', group_member.id).deliver_later + mailer.member_access_granted_email(group_member.real_source_type, group_member.id).deliver_later end def update_group_member(group_member) - mailer.member_access_granted_email('group', group_member.id).deliver_later + mailer.member_access_granted_email(group_member.real_source_type, group_member.id).deliver_later end def project_was_moved(project, old_path_with_namespace) diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml index a39d5d3d0f..a36531e095 100644 --- a/app/views/groups/group_members/index.html.haml +++ b/app/views/groups/group_members/index.html.haml @@ -11,7 +11,7 @@ .new-group-member-holder = render "new_group_member" - = render "shared/members/requests", entity: @group, members: @members + = render 'shared/members/requests', membership_source: @group, members: @members.request .panel.panel-default .panel-heading diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index 85635bc461..62ebd69485 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -19,6 +19,9 @@ .cover-desc.description = markdown(@group.description, pipeline: :description) + - if current_user + = render 'shared/members/access_request_buttons', source: @group + %div{ class: container_class } .top-area %ul.nav-links diff --git a/app/views/layouts/nav/_group_settings.html.haml b/app/views/layouts/nav/_group_settings.html.haml index b461772b87..dac46648b9 100644 --- a/app/views/layouts/nav/_group_settings.html.haml +++ b/app/views/layouts/nav/_group_settings.html.haml @@ -1,3 +1,16 @@ - if current_user - .controls - = render 'shared/group_or_project_home_dropdown', entity: @group + - if access = @group.users.find_by(id: current_user.id) + .controls + .dropdown.group-settings-dropdown + %a.dropdown-new.btn.btn-default#group-settings-button{href: '#', 'data-toggle' => 'dropdown'} + = icon('cog') + = icon('caret-down') + %ul.dropdown-menu.dropdown-menu-align-right + - if can?(current_user, :admin_group, @group) + = nav_link(path: 'groups#projects') do + = link_to projects_group_path(@group), title: 'Projects' do + Projects + %li.divider + %li + = link_to edit_group_path(@group) do + Edit Group diff --git a/app/views/layouts/nav/_project.html.haml b/app/views/layouts/nav/_project.html.haml index 3398794302..ad01971083 100644 --- a/app/views/layouts/nav/_project.html.haml +++ b/app/views/layouts/nav/_project.html.haml @@ -1,33 +1,25 @@ - if current_user .controls - - access = user_max_access_in_project(current_user.id, @project) - - can_edit = can?(current_user, :admin_project, @project) .dropdown.project-settings-dropdown %a.dropdown-new.btn.btn-default#project-settings-button{href: '#', 'data-toggle' => 'dropdown'} = icon('cog') = icon('caret-down') %ul.dropdown-menu.dropdown-menu-align-right = render 'layouts/nav/project_settings' - %li.divider - - if can_edit - %li - = link_to edit_project_path(@project) do - Edit Project - - if access - %li - = link_to leave_path(@project), - data: { confirm: leave_confirmation_message(@project) }, method: :delete do - Leave Project - - elsif @project.access_requested?(current_user) - %li - = link_to leave_path(@project), - data: { confirm: withdraw_request_message(@project) }, method: :delete do - Withdraw Request - - else - %li - = link_to request_access_path(@project), - class: 'btn btn-gray', style: 'margin-left: 10px', method: :post do - Request Access + + - access = @project.team.max_member_access(current_user.id) + - can_edit = can?(current_user, :admin_project, @project) + - if can_edit || access + %li.divider + - if can_edit + %li + = link_to edit_project_path(@project) do + Edit Project + - if access + %li + = link_to polymorphic_path([:leave, @project, :members]), + data: { confirm: leave_confirmation_message(@project) }, method: :delete do + Leave Project %div{ class: nav_control_class } %ul.nav-links.scrolling-tabs diff --git a/app/views/notify/group_access_granted_email.html.haml b/app/views/notify/group_access_granted_email.html.haml deleted file mode 100644 index 1283758c57..0000000000 --- a/app/views/notify/group_access_granted_email.html.haml +++ /dev/null @@ -1,3 +0,0 @@ -%p - You have been granted #{@group_member.human_access} access to group - #{link_to @group.name, @target_url}. diff --git a/app/views/notify/group_access_granted_email.text.erb b/app/views/notify/group_access_granted_email.text.erb deleted file mode 100644 index c756835007..0000000000 --- a/app/views/notify/group_access_granted_email.text.erb +++ /dev/null @@ -1,3 +0,0 @@ -You have been granted <%= @group_member.human_access %> access to group <%= @group.name %>. - -<%= @target_url %> diff --git a/app/views/notify/group_invite_accepted_email.html.haml b/app/views/notify/group_invite_accepted_email.html.haml deleted file mode 100644 index 55efad384a..0000000000 --- a/app/views/notify/group_invite_accepted_email.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -%p - #{@group_member.invite_email}, now known as - #{link_to @group_member.user.name, user_url(@group_member.user)}, - has accepted your invitation to join group - #{link_to @group.name, group_url(@group)}. - diff --git a/app/views/notify/group_invite_accepted_email.text.erb b/app/views/notify/group_invite_accepted_email.text.erb deleted file mode 100644 index f8b70f7a5a..0000000000 --- a/app/views/notify/group_invite_accepted_email.text.erb +++ /dev/null @@ -1,3 +0,0 @@ -<%= @group_member.invite_email %>, now known as <%= @group_member.user.name %>, has accepted your invitation to join group <%= @group.name %>. - -<%= group_url(@group) %> diff --git a/app/views/notify/group_invite_declined_email.html.haml b/app/views/notify/group_invite_declined_email.html.haml deleted file mode 100644 index f9525d84fa..0000000000 --- a/app/views/notify/group_invite_declined_email.html.haml +++ /dev/null @@ -1,5 +0,0 @@ -%p - #{@invite_email} - has declined your invitation to join group - #{link_to @group.name, group_url(@group)}. - diff --git a/app/views/notify/group_invite_declined_email.text.erb b/app/views/notify/group_invite_declined_email.text.erb deleted file mode 100644 index 6c19a288d1..0000000000 --- a/app/views/notify/group_invite_declined_email.text.erb +++ /dev/null @@ -1,3 +0,0 @@ -<%= @invite_email %> has declined your invitation to join group <%= @group.name %>. - -<%= group_url(@group) %> diff --git a/app/views/notify/group_member_invited_email.html.haml b/app/views/notify/group_member_invited_email.html.haml deleted file mode 100644 index 163e88bfea..0000000000 --- a/app/views/notify/group_member_invited_email.html.haml +++ /dev/null @@ -1,14 +0,0 @@ -%p - You have been invited - - if inviter = @group_member.created_by - by - = link_to inviter.name, user_url(inviter) - to join group - = link_to @group.name, group_url(@group) - as #{@group_member.human_access}. - -%p - = link_to 'Accept invitation', invite_url(@token) - or - = link_to 'decline', decline_invite_url(@token) - diff --git a/app/views/notify/group_member_invited_email.text.erb b/app/views/notify/group_member_invited_email.text.erb deleted file mode 100644 index 28ce4819b1..0000000000 --- a/app/views/notify/group_member_invited_email.text.erb +++ /dev/null @@ -1,4 +0,0 @@ -You have been invited <%= "by #{@group_member.created_by.name} " if @group_member.created_by %>to join group <%= @group.name %> as <%= @group_member.human_access %>. - -Accept invitation: <%= invite_url(@token) %> -Decline invitation: <%= decline_invite_url(@token) %> diff --git a/app/views/notify/member_access_denied_email.html.haml b/app/views/notify/member_access_denied_email.html.haml index a25af24d78..71c9c50071 100644 --- a/app/views/notify/member_access_denied_email.html.haml +++ b/app/views/notify/member_access_denied_email.html.haml @@ -1,4 +1,4 @@ %p Your request to join the - #{link_to member_target_name, member_target_url} #{member_target_type} + #{link_to member_source.human_name, member_source.web_url} #{member_source.model_name.singular} has been denied. diff --git a/app/views/notify/member_access_denied_email.text.erb b/app/views/notify/member_access_denied_email.text.erb index eb204458d9..87f2ef817e 100644 --- a/app/views/notify/member_access_denied_email.text.erb +++ b/app/views/notify/member_access_denied_email.text.erb @@ -1,3 +1,3 @@ -Your request to join the <%= member_target_name %> <%= member_target_type %> has been denied. +Your request to join the <%= member_source.human_name %> <%= member_source.model_name.singular %> has been denied. -<%= member_target_url %> +<%= member_source.web_url %> diff --git a/app/views/notify/member_access_granted_email.html.haml b/app/views/notify/member_access_granted_email.html.haml index 62837d7455..18dec80653 100644 --- a/app/views/notify/member_access_granted_email.html.haml +++ b/app/views/notify/member_access_granted_email.html.haml @@ -1,3 +1,3 @@ %p You have been granted #{member.human_access} access to the - #{link_to member_target_name, member_target_url} #{member_target_type}. + #{link_to member_source.human_name, member_source.web_url} #{member_source.model_name.singular}. diff --git a/app/views/notify/member_access_granted_email.text.erb b/app/views/notify/member_access_granted_email.text.erb index be9bb5ee94..a9fb3a589a 100644 --- a/app/views/notify/member_access_granted_email.text.erb +++ b/app/views/notify/member_access_granted_email.text.erb @@ -1,3 +1,3 @@ -You have been granted <%= member.human_access %> access to the <%= member_target_name %> <%= member_target_type %>. +You have been granted <%= member.human_access %> access to the <%= member_source.human_name %> <%= member_source.model_name.singular %>. -<%= member_target_url %> +<%= member_source.web_url %> diff --git a/app/views/notify/member_access_requested_email.html.haml b/app/views/notify/member_access_requested_email.html.haml index 96e92a069f..76f1f08a0c 100644 --- a/app/views/notify/member_access_requested_email.html.haml +++ b/app/views/notify/member_access_requested_email.html.haml @@ -1,3 +1,3 @@ %p - #{link_to access_requester.name, access_requester} requested #{member.human_access} - access to the #{link_to member_target_name, member_target_url} #{member_target_type}. + #{link_to member.user.name, member.user} requested #{member.human_access} + access to the #{link_to member_source.human_name, polymorphic_url([member_source, :members])} #{member_source.model_name.singular}. diff --git a/app/views/notify/member_access_requested_email.text.erb b/app/views/notify/member_access_requested_email.text.erb index 3b5de8c2ab..9c5ee0eaf2 100644 --- a/app/views/notify/member_access_requested_email.text.erb +++ b/app/views/notify/member_access_requested_email.text.erb @@ -1,3 +1,3 @@ -<%= access_requester.name %> (<%= user_url(access_requester) %>) requested <%= member.human_access %> access to the <%= member_target_name %> <%= member_target_type %>. +<%= member.user.name %> (<%= user_url(member.user) %>) requested <%= member.human_access %> access to the <%= member_source.human_name %> <%= member_source.model_name.singular %>. -<%= member_target_url %> +<%= polymorphic_url([member_source, :members]) %> diff --git a/app/views/notify/member_invite_accepted_email.html.haml b/app/views/notify/member_invite_accepted_email.html.haml index c420a8a7b3..2d1d40881e 100644 --- a/app/views/notify/member_invite_accepted_email.html.haml +++ b/app/views/notify/member_invite_accepted_email.html.haml @@ -2,4 +2,4 @@ #{member.invite_email}, now known as #{link_to member.user.name, user_url(member.user)}, has accepted your invitation to join the - #{link_to member_target_name, member_target_url} #{member_target_type}. + #{link_to member_source.human_name, member_source.web_url} #{member_source.model_name.singular}. diff --git a/app/views/notify/member_invite_accepted_email.text.erb b/app/views/notify/member_invite_accepted_email.text.erb index a1616163ce..cef8710142 100644 --- a/app/views/notify/member_invite_accepted_email.text.erb +++ b/app/views/notify/member_invite_accepted_email.text.erb @@ -1,3 +1,3 @@ -<%= member.invite_email %>, now known as <%= member.user.name %>, has accepted your invitation to join the <%= member_target_name %> <%= member_target_type %>. +<%= member.invite_email %>, now known as <%= member.user.name %>, has accepted your invitation to join the <%= member_source.human_name %> <%= member_source.model_name.singular %>. -<%= member_target_url %> +<%= member_source.web_url %> diff --git a/app/views/notify/member_invite_declined_email.html.haml b/app/views/notify/member_invite_declined_email.html.haml index 5a30ac31b3..aa1b373d1a 100644 --- a/app/views/notify/member_invite_declined_email.html.haml +++ b/app/views/notify/member_invite_declined_email.html.haml @@ -1,4 +1,4 @@ %p #{@invite_email} has declined your invitation to join the - #{link_to member_target_name, member_target_url} #{member_target_type}. + #{link_to member_source.human_name, member_source.web_url} #{member_source.model_name.singular}. diff --git a/app/views/notify/member_invite_declined_email.text.erb b/app/views/notify/member_invite_declined_email.text.erb index 301287946d..8bc305910c 100644 --- a/app/views/notify/member_invite_declined_email.text.erb +++ b/app/views/notify/member_invite_declined_email.text.erb @@ -1,3 +1,3 @@ -<%= @invite_email %> has declined your invitation to join the <%= member_target_name %> <%= member_target_type %>. +<%= @invite_email %> has declined your invitation to join the <%= member_source.human_name %> <%= member_source.model_name.singular %>. -<%= member_target_url %> +<%= member_source.web_url %> diff --git a/app/views/notify/member_invited_email.html.haml b/app/views/notify/member_invited_email.html.haml index a8e58df9ac..b8b75da3f2 100644 --- a/app/views/notify/member_invited_email.html.haml +++ b/app/views/notify/member_invited_email.html.haml @@ -1,11 +1,11 @@ %p You have been invited - - if access_requester + - if member.created_by by - = link_to access_requester.name, user_url(access_requester) + = link_to member.created_by.name, user_url(member.created_by) to join the - = link_to member_target_name, member_target_url - #{member_target_type} as #{member.human_access}. + = link_to member_source.human_name, member_source.web_url + #{member_source.model_name.singular} as #{member.human_access}. %p = link_to 'Accept invitation', invite_url(@token) diff --git a/app/views/notify/member_invited_email.text.erb b/app/views/notify/member_invited_email.text.erb index 1b6897ee2e..0a6393355b 100644 --- a/app/views/notify/member_invited_email.text.erb +++ b/app/views/notify/member_invited_email.text.erb @@ -1,4 +1,4 @@ -You have been invited <%= "by #{access_requester.name} " if access_requester %>to join the <%= member_target_name %> <%= member_target_type %> as <%= member.human_access %>. +You have been invited <%= "by #{member.created_by.name} " if member.created_by %>to join the <%= member_source.human_name %> <%= member_source.model_name.singular %> as <%= member.human_access %>. Accept invitation: <%= invite_url(@token) %> Decline invitation: <%= decline_invite_url(@token) %> diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index f5bc1b4e40..2b19ee93ee 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -29,10 +29,13 @@ .project-clone-holder = render "shared/clone_panel" - .project-repo-buttons.btn-group.project-right-buttons - = render "projects/buttons/download" - = render 'projects/buttons/dropdown' - = render 'projects/buttons/notifications' + .project-repo-buttons.project-right-buttons + - if current_user + = render 'shared/members/access_request_buttons', source: @project + .btn-group + = render "projects/buttons/download" + = render 'projects/buttons/dropdown' + = render 'projects/buttons/notifications' :javascript new Star(); diff --git a/app/views/projects/buttons/_notifications.html.haml b/app/views/projects/buttons/_notifications.html.haml index 3b97dc9328..a7a9718109 100644 --- a/app/views/projects/buttons/_notifications.html.haml +++ b/app/views/projects/buttons/_notifications.html.haml @@ -1,7 +1,7 @@ - if @notification_setting = form_for @notification_setting, url: namespace_project_notification_setting_path(@project.namespace.becomes(Namespace), @project), method: :patch, remote: true, html: { class: 'inline', id: 'notification-form' } do |f| = f.hidden_field :level - .dropdown + .dropdown.hidden-sm %button.btn.btn-default.notifications-btn#notifications-button{ data: { toggle: "dropdown" }, aria: { haspopup: "true", expanded: "false" } } = icon('bell') = notification_title(@notification_setting.level) diff --git a/app/views/projects/notes/_note.html.haml b/app/views/projects/notes/_note.html.haml index 112a532f9d..bcdbff0801 100644 --- a/app/views/projects/notes/_note.html.haml +++ b/app/views/projects/notes/_note.html.haml @@ -17,7 +17,7 @@ %a{ href: "##{dom_id(note)}" } = time_ago_with_tooltip(note.created_at, placement: 'bottom', html_class: 'note-created-ago') .note-actions - - access = max_access_level(note.project, note.author) + - access = note.project.team.human_max_access(note.author.id) - if access %span.note-role.hidden-xs= access - if current_user diff --git a/app/views/projects/project_members/_group_members.html.haml b/app/views/projects/project_members/_group_members.html.haml index 78c12d52a7..cb6136c215 100644 --- a/app/views/projects/project_members/_group_members.html.haml +++ b/app/views/projects/project_members/_group_members.html.haml @@ -6,8 +6,9 @@ (#{members.count}) - if can?(current_user, :admin_group_member, @group) .controls - = link_to group_group_members_path(@group), class: 'btn' do - Manage group members + = link_to 'Manage group members', + group_group_members_path(@group), + class: 'btn' %ul.content-list = render partial: 'shared/members/member', collection: members.limit(20), @@ -15,7 +16,4 @@ locals: { show_controls: false } - if members.size > 20 %li - and - = members.size - 20 - more. For full list visit - = link_to 'group members page', group_group_members_path(@group) + and #{members.count - 20} more. For full list visit #{link_to 'group members page', group_group_members_path(@group)} diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml index 61a82724d6..357ccccaf1 100644 --- a/app/views/projects/project_members/index.html.haml +++ b/app/views/projects/project_members/index.html.haml @@ -13,9 +13,9 @@ Users with access to this project are listed below. = render "new_project_member" - = render "shared/members/requests", entity: @project, members: @project_members + = render 'shared/members/requests', membership_source: @project, members: @project_members.request - = render "team", members: @project_members.non_request + = render 'team', members: @project_members.non_request - if @group = render "group_members", members: @group_members diff --git a/app/views/shared/_group_or_project_home_dropdown.html.haml b/app/views/shared/_group_or_project_home_dropdown.html.haml deleted file mode 100644 index fb9e63f2bd..0000000000 --- a/app/views/shared/_group_or_project_home_dropdown.html.haml +++ /dev/null @@ -1,30 +0,0 @@ -- member = entity.send(members_association(entity)).find_by(user_id: current_user.id) -- can_edit = can?(current_user, "admin_#{entity.class.to_s.underscore}".to_sym, entity) - -- if member || can_edit - .dropdown.project-settings-dropdown - %a.dropdown-new.btn.btn-gray{ href: '#', id: "#{entity.class.to_s.underscore}-settings-button", data: { toggle: 'dropdown' } } - = icon('cog') - = icon('caret-down') - %ul.dropdown-menu.dropdown-menu-align-right - - if can_edit - %li - = link_to "Edit #{entity.class.to_s}", [:edit, entity] - - - if member - %li - = link_to "Leave #{entity.class.to_s}", - leave_path(entity), - method: :delete, - data: { confirm: leave_confirmation_message(entity) } -- elsif entity.access_requested?(current_user) - = link_to 'Withdraw Request', - leave_path(entity), - data: { confirm: withdraw_request_message(entity) }, - method: :delete, - class: 'btn btn-grouped btn-gray' -- else - = link_to 'Request Access', - request_access_path(entity), - method: :post, - class: 'btn btn-grouped btn-gray' diff --git a/app/views/shared/members/_access_request_buttons.html.haml b/app/views/shared/members/_access_request_buttons.html.haml new file mode 100644 index 0000000000..ed0a6ebcf8 --- /dev/null +++ b/app/views/shared/members/_access_request_buttons.html.haml @@ -0,0 +1,12 @@ +- member = source.members.find_by(user_id: current_user.id) + +- if member + - if member.request? + = link_to 'Withdraw Access Request', polymorphic_path([:leave, source, :members]), + method: :delete, + data: { confirm: remove_member_message(member) }, + class: 'btn access-request-button hidden-xs' +- else + = link_to 'Request Access', polymorphic_path([:request_access, source, :members]), + method: :post, + class: 'btn access-request-button hidden-xs' diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml index 7e119155a6..c69d4cbfbe 100644 --- a/app/views/shared/members/_member.html.haml +++ b/app/views/shared/members/_member.html.haml @@ -1,6 +1,6 @@ - show_roles = local_assigns.fetch(:show_roles, true) - show_controls = local_assigns.fetch(:show_controls, true) -- user = member.request? ? member.created_by : member.user +- user = member.user %li.js-toggle-container{ class: dom_class(member), id: dom_id(member) } %span{ class: ("list-item-name" if show_controls) } @@ -18,25 +18,25 @@ %strong Blocked - if member.request? - %small + %span.cgray – Requested = time_ago_with_tooltip(member.requested_at) - else = image_tag avatar_icon(member.invite_email, 24), class: "avatar s24", alt: '' %strong= member.invite_email %span.cgray - invited + – Invited - if member.created_by by = link_to member.created_by.name, user_path(member.created_by) = time_ago_with_tooltip(member.created_at) - if show_controls && can?(current_user, action_member_permission(:admin, member), member.source) - = link_to 'Resend invite', resend_invite_member_path(member), + = link_to 'Resend invite', polymorphic_path([:resend_invite, member]), method: :post, class: 'btn-xs btn' - - if show_roles && can_see_entity_roles?(current_user, member.source) + - if show_roles && can_see_member_roles?(source: member.source, user: current_user) %span.pull-right %strong= member.human_access - if show_controls @@ -48,30 +48,30 @@ - if member.request?   - = link_to icon('check inverse'), approve_request_member_path(member), + = link_to icon('check inverse'), polymorphic_path([:approve_access_request, member]), method: :post, - type: 'button', class: 'btn-xs btn btn-success', title: 'Grant access' - if can?(current_user, action_member_permission(:destroy, member), member)   - if current_user == user - = link_to leave_path(member.source), data: { confirm: leave_confirmation_message(member.source)}, method: :delete, class: "btn-xs btn btn-remove", title: 'Remove user from group' do - = icon("sign-out") - Leave - - else - = link_to icon('trash'), member_path(member), + = link_to icon('sign-out', text: 'Leave'), polymorphic_path([:leave, member.source, :members]), method: :delete, + data: { confirm: leave_confirmation_message(member.source) }, + class: 'btn-xs btn btn-remove' + - else + = link_to icon('trash'), member, remote: true, + method: :delete, data: { confirm: remove_member_message(member) }, class: 'btn-xs btn btn-remove', title: remove_member_title(member) .edit-member.hide.js-toggle-content %br - = form_for member_path(member), as: "#{member.source.class.to_s.underscore}_member".to_sym, remote: true do |f| + = form_for member, remote: true do |f| .prepend-top-10 - = f.select :access_level, options_for_select(member_class(member).access_level_roles, member.access_level), {}, class: 'form-control' + = f.select :access_level, options_for_select(member.class.access_level_roles, member.access_level), {}, class: 'form-control' .prepend-top-10 = f.submit 'Save', class: 'btn btn-save btn-sm' diff --git a/app/views/shared/members/_requests.html.haml b/app/views/shared/members/_requests.html.haml index ffbb380f79..b596387603 100644 --- a/app/views/shared/members/_requests.html.haml +++ b/app/views/shared/members/_requests.html.haml @@ -1,10 +1,8 @@ -- requesters = members.request - -- if requesters.any? +- if members.any? .panel.panel-default .panel-heading - %strong= entity.name + %strong= membership_source.name access requests - %small= "(#{requesters.size})" + %small= "(#{members.size})" %ul.content-list - = render partial: 'shared/members/member', collection: requesters, as: :member + = render partial: 'shared/members/member', collection: members, as: :member diff --git a/features/steps/dashboard/group.rb b/features/steps/dashboard/group.rb index 0c6a0ae372..9b79a3be49 100644 --- a/features/steps/dashboard/group.rb +++ b/features/steps/dashboard/group.rb @@ -62,6 +62,6 @@ class Spinach::Features::DashboardGroup < Spinach::FeatureSteps end step 'I should see the "Can not leave message"' do - expect(page).to have_content "You can not leave Owned group because you're the last owner" + expect(page).to have_content "You can not leave the \"Owned\" group." end end diff --git a/features/steps/group/members.rb b/features/steps/group/members.rb index 9de82765df..dfa2fa75de 100644 --- a/features/steps/group/members.rb +++ b/features/steps/group/members.rb @@ -53,7 +53,7 @@ class Spinach::Features::GroupMembers < Spinach::FeatureSteps step 'I should see "sjobs@apple.com" in team list as invited "Reporter"' do page.within '.content-list' do expect(page).to have_content('sjobs@apple.com') - expect(page).to have_content('invited') + expect(page).to have_content('Invited') expect(page).to have_content('Reporter') end end @@ -116,11 +116,9 @@ class Spinach::Features::GroupMembers < Spinach::FeatureSteps member = mary_jane_member page.within "#group_member_#{member.id}" do - find(".js-toggle-button").click - page.within "#edit_group_member_#{member.id}" do - select 'Developer', from: 'group_member_access_level' - click_on 'Save' - end + click_button "Edit access level" + select 'Developer', from: 'group_member_access_level' + click_on 'Save' end end diff --git a/features/steps/project/team_management.rb b/features/steps/project/team_management.rb index c6ced74737..f32576d2cb 100644 --- a/features/steps/project/team_management.rb +++ b/features/steps/project/team_management.rb @@ -26,8 +26,11 @@ class Spinach::Features::ProjectTeamManagement < Spinach::FeatureSteps end step 'I should see "Mike" in team list as "Reporter"' do - page.within ".access-reporter" do + user = User.find_by(name: 'Mike') + project_member = project.project_members.find_by(user_id: user.id) + page.within "#project_member_#{project_member.id}" do expect(page).to have_content('Mike') + expect(page).to have_content('Reporter') end end @@ -40,16 +43,20 @@ class Spinach::Features::ProjectTeamManagement < Spinach::FeatureSteps end step 'I should see "sjobs@apple.com" in team list as invited "Reporter"' do - page.within ".access-reporter" do + project_member = project.project_members.find_by(invite_email: 'sjobs@apple.com') + page.within "#project_member_#{project_member.id}" do expect(page).to have_content('sjobs@apple.com') - expect(page).to have_content('invited') + expect(page).to have_content('Invited') expect(page).to have_content('Reporter') end end step 'I should see "Dmitriy" in team list as "Developer"' do - page.within ".access-developer" do + user = User.find_by(name: 'Dmitriy') + project_member = project.project_members.find_by(user_id: user.id) + page.within "#project_member_#{project_member.id}" do expect(page).to have_content('Dmitriy') + expect(page).to have_content('Developer') end end @@ -65,15 +72,14 @@ class Spinach::Features::ProjectTeamManagement < Spinach::FeatureSteps end step 'I should see "Dmitriy" in team list as "Reporter"' do - page.within ".access-reporter" do + user = User.find_by(name: 'Dmitriy') + project_member = project.project_members.find_by(user_id: user.id) + page.within "#project_member_#{project_member.id}" do expect(page).to have_content('Dmitriy') + expect(page).to have_content('Reporter') end end - step 'I click link "Remove from team"' do - click_link "Remove from team" - end - step 'I should not see "Dmitriy" in team list' do user = User.find_by(name: "Dmitriy") expect(page).not_to have_content(user.name) @@ -120,7 +126,7 @@ class Spinach::Features::ProjectTeamManagement < Spinach::FeatureSteps user = User.find_by(name: 'Dmitriy') project_member = project.project_members.find_by(user_id: user.id) page.within "#project_member_#{project_member.id}" do - click_link('Remove user from team') + click_link('Remove user from project') end end diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 14370ac218..cc29c7ef42 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -88,10 +88,7 @@ module API class Group < Grape::Entity expose :id, :name, :path, :description, :visibility_level expose :avatar_url - - expose :web_url do |group, options| - Gitlab::Routing.url_helpers.group_url(group) - end + expose :web_url end class GroupDetail < Group diff --git a/spec/controllers/groups/group_members_controller_spec.rb b/spec/controllers/groups/group_members_controller_spec.rb index 0ca8a656f6..89c2c26a36 100644 --- a/spec/controllers/groups/group_members_controller_spec.rb +++ b/spec/controllers/groups/group_members_controller_spec.rb @@ -35,7 +35,7 @@ describe Groups::GroupMembersController do let(:group_user) { create(:user) } let(:member) do group.add_developer(group_user) - group.group_members.find_by(user_id: group_user.id) + group.members.find_by(user_id: group_user) end context 'when user does not have enough rights' do @@ -103,7 +103,7 @@ describe Groups::GroupMembersController do it 'removes user from members' do delete :leave, group_id: group - expect(response).to set_flash.to "You left #{group.name} group." + expect(response).to set_flash.to "You left the \"#{group.name}\" group." expect(response).to redirect_to(dashboard_groups_path) expect(group.users).not_to include user end @@ -118,8 +118,8 @@ describe Groups::GroupMembersController do it 'cannot removes himself from the group' do delete :leave, group_id: group - expect(response).to redirect_to(dashboard_groups_path) - expect(response).to set_flash[:alert].to "You can not leave #{group.name} group because you're the last owner. Transfer or delete the group." + expect(response).to redirect_to(group_path(group)) + expect(response).to set_flash[:alert].to "You can not leave the \"#{group.name}\" group. Transfer or delete the group." expect(group.users).to include user end end @@ -133,9 +133,9 @@ describe Groups::GroupMembersController do it 'removes user from members' do delete :leave, group_id: group - expect(response).to set_flash.to 'You withdrawn your access request to the group.' + expect(response).to set_flash.to 'Your access request to the group has been withdrawn.' expect(response).to redirect_to(dashboard_groups_path) - expect(group.group_members.request).to be_empty + expect(group.members.request).to be_empty expect(group.users).not_to include user end end @@ -155,18 +155,18 @@ describe Groups::GroupMembersController do expect(response).to set_flash.to 'Your request for access has been queued for review.' expect(response).to redirect_to(group_path(group)) - expect(group.group_members.request.find_by(created_by_id: user.id).created_by).to eq user + expect(group.members.request.exists?(user_id: user)).to be_truthy expect(group.users).not_to include user end end - describe '#approve' do + describe '#approve_access_request' do let(:group) { create(:group, :public) } context 'when member is not found' do it 'returns 403' do post :approve_access_request, group_id: group, - id: 42 + id: 42 expect(response.status).to eq(403) end @@ -177,7 +177,7 @@ describe Groups::GroupMembersController do let(:group_requester) { create(:user) } let(:member) do group.request_access(group_requester) - group.group_members.request.find_by(created_by_id: group_requester.id) + group.members.request.find_by(user_id: group_requester) end context 'when user does not have enough rights' do @@ -188,7 +188,7 @@ describe Groups::GroupMembersController do it 'returns 403' do post :approve_access_request, group_id: group, - id: member + id: member expect(response.status).to eq(403) expect(group.users).not_to include group_requester @@ -203,7 +203,7 @@ describe Groups::GroupMembersController do it 'adds user to members' do post :approve_access_request, group_id: group, - id: member + id: member expect(response).to redirect_to(group_group_members_path(group)) expect(group.users).to include group_requester diff --git a/spec/controllers/projects/project_members_controller_spec.rb b/spec/controllers/projects/project_members_controller_spec.rb index d3bd2d0bbb..fc5f458e79 100644 --- a/spec/controllers/projects/project_members_controller_spec.rb +++ b/spec/controllers/projects/project_members_controller_spec.rb @@ -80,7 +80,7 @@ describe Projects::ProjectMembersController do let(:team_user) { create(:user) } let(:member) do project.team << [team_user, :developer] - project.project_members.find_by(user_id: team_user.id) + project.members.find_by(user_id: team_user.id) end context 'when user does not have enough rights' do @@ -154,7 +154,7 @@ describe Projects::ProjectMembersController do delete :leave, namespace_id: project.namespace, project_id: project - expect(response).to set_flash.to 'You left the project.' + expect(response).to set_flash.to "You left the \"#{project.human_name}\" project." expect(response).to redirect_to(dashboard_projects_path) expect(project.users).not_to include user end @@ -167,14 +167,14 @@ describe Projects::ProjectMembersController do sign_in(user) end - it 'cannot removes himself from the project' do + it 'cannot remove himself from the project' do delete :leave, namespace_id: project.namespace, project_id: project expect(response).to redirect_to( - namespace_project_project_members_path(project.namespace, project) + namespace_project_path(project.namespace, project) ) - expect(response).to set_flash[:alert].to 'You can not leave your own project. Transfer or delete the project.' + expect(response).to set_flash[:alert].to "You can not leave the \"#{project.human_name}\" project. Transfer or delete the project." expect(project.users).to include user end end @@ -189,9 +189,9 @@ describe Projects::ProjectMembersController do delete :leave, namespace_id: project.namespace, project_id: project - expect(response).to set_flash.to 'You withdrawn your access request to the project.' + expect(response).to set_flash.to 'Your access request to the project has been withdrawn.' expect(response).to redirect_to(dashboard_projects_path) - expect(project.project_members.request).to be_empty + expect(project.members.request).to be_empty expect(project.users).not_to include user end end @@ -214,7 +214,7 @@ describe Projects::ProjectMembersController do expect(response).to redirect_to( namespace_project_path(project.namespace, project) ) - expect(project.project_members.request.find_by(created_by_id: user.id).created_by).to eq user + expect(project.members.request.exists?(user_id: user)).to be_truthy expect(project.users).not_to include user end end @@ -225,8 +225,8 @@ describe Projects::ProjectMembersController do context 'when member is not found' do it 'returns 404' do post :approve_access_request, namespace_id: project.namespace, - project_id: project, - id: 42 + project_id: project, + id: 42 expect(response.status).to eq(404) end @@ -237,7 +237,7 @@ describe Projects::ProjectMembersController do let(:team_requester) { create(:user) } let(:member) do project.request_access(team_requester) - project.project_members.request.find_by(created_by_id: team_requester.id) + project.members.request.find_by(user_id: team_requester.id) end context 'when user does not have enough rights' do @@ -248,8 +248,8 @@ describe Projects::ProjectMembersController do it 'returns 404' do post :approve_access_request, namespace_id: project.namespace, - project_id: project, - id: member + project_id: project, + id: member expect(response.status).to eq(404) expect(project.users).not_to include team_requester @@ -264,8 +264,8 @@ describe Projects::ProjectMembersController do it 'adds user to members' do post :approve_access_request, namespace_id: project.namespace, - project_id: project, - id: member + project_id: project, + id: member expect(response).to redirect_to( namespace_project_project_members_path(project.namespace, project) diff --git a/spec/features/groups/members/owner_manages_access_requests_spec.rb b/spec/features/groups/members/owner_manages_access_requests_spec.rb index d5b5e0e35e..22525ce530 100644 --- a/spec/features/groups/members/owner_manages_access_requests_spec.rb +++ b/spec/features/groups/members/owner_manages_access_requests_spec.rb @@ -22,12 +22,10 @@ feature 'Groups > Members > Owner manages access requests', feature: true do expect_visible_access_request(group, user) - perform_enqueued_jobs do - click_on 'Grant access' - end + perform_enqueued_jobs { click_on 'Grant access' } expect(ActionMailer::Base.deliveries.last.to).to eq [user.notification_email] - expect(ActionMailer::Base.deliveries.last.subject).to match /Access to #{group.name} group was granted/ + expect(ActionMailer::Base.deliveries.last.subject).to match "Access to the #{group.name} group was granted" end scenario 'master can deny access' do @@ -35,17 +33,15 @@ feature 'Groups > Members > Owner manages access requests', feature: true do expect_visible_access_request(group, user) - perform_enqueued_jobs do - click_on 'Deny access' - end + perform_enqueued_jobs { click_on 'Deny access' } expect(ActionMailer::Base.deliveries.last.to).to eq [user.notification_email] - expect(ActionMailer::Base.deliveries.last.subject).to match /Access to #{group.name} group was denied/ + expect(ActionMailer::Base.deliveries.last.subject).to match "Access to the #{group.name} group was denied" end def expect_visible_access_request(group, user) - expect(group.access_requested?(user)).to be_truthy + expect(group.members.request.exists?(user_id: user)).to be_truthy expect(page).to have_content "#{group.name} access requests (1)" expect(page).to have_content user.name end diff --git a/spec/features/groups/members/user_requests_access_spec.rb b/spec/features/groups/members/user_requests_access_spec.rb index 9b8492807f..a878a96b6e 100644 --- a/spec/features/groups/members/user_requests_access_spec.rb +++ b/spec/features/groups/members/user_requests_access_spec.rb @@ -8,47 +8,41 @@ feature 'Groups > Members > User requests access', feature: true do background do group.add_owner(owner) login_as(user) + visit group_path(group) end scenario 'user can request access to a group' do - visit group_path(group) - - perform_enqueued_jobs do - click_link 'Request Access' - end + perform_enqueued_jobs { click_link 'Request Access' } expect(ActionMailer::Base.deliveries.last.to).to eq [owner.notification_email] - expect(ActionMailer::Base.deliveries.last.subject).to match /Request to join #{group.name} group/ + expect(ActionMailer::Base.deliveries.last.subject).to match "Request to join the #{group.name} group" - expect(group.access_requested?(user)).to be_truthy + expect(group.members.request.exists?(user_id: user)).to be_truthy expect(page).to have_content 'Your request for access has been queued for review.' - expect(page).to have_content 'Withdraw Request' + + expect(page).to have_content 'Withdraw Access Request' end scenario 'user is not listed in the group members page' do - visit group_path(group) - click_link 'Request Access' - expect(group.access_requested?(user)).to be_truthy + expect(group.members.request.exists?(user_id: user)).to be_truthy click_link 'Members' - visit group_group_members_path(group) page.within('.content') do expect(page).not_to have_content(user.name) end end scenario 'user can withdraw its request for access' do - visit group_path(group) click_link 'Request Access' - expect(group.access_requested?(user)).to be_truthy + expect(group.members.request.exists?(user_id: user)).to be_truthy - click_link 'Withdraw Request' + click_link 'Withdraw Access Request' - expect(group.access_requested?(user)).to be_falsey - expect(page).to have_content 'You withdrawn your access request to the group.' + expect(group.members.request.exists?(user_id: user)).to be_falsey + expect(page).to have_content 'Your access request to the group has been withdrawn.' end end diff --git a/spec/features/projects/members/master_manages_access_requests_spec.rb b/spec/features/projects/members/master_manages_access_requests_spec.rb index 1b5490ba97..5fe4caa12f 100644 --- a/spec/features/projects/members/master_manages_access_requests_spec.rb +++ b/spec/features/projects/members/master_manages_access_requests_spec.rb @@ -22,12 +22,10 @@ feature 'Projects > Members > Master manages access requests', feature: true do expect_visible_access_request(project, user) - perform_enqueued_jobs do - click_on 'Grant access' - end + perform_enqueued_jobs { click_on 'Grant access' } expect(ActionMailer::Base.deliveries.last.to).to eq [user.notification_email] - expect(ActionMailer::Base.deliveries.last.subject).to match /Access to #{project.name_with_namespace} project was granted/ + expect(ActionMailer::Base.deliveries.last.subject).to match "Access to the #{project.name_with_namespace} project was granted" end scenario 'master can deny access' do @@ -35,16 +33,14 @@ feature 'Projects > Members > Master manages access requests', feature: true do expect_visible_access_request(project, user) - perform_enqueued_jobs do - click_on 'Deny access' - end + perform_enqueued_jobs { click_on 'Deny access' } expect(ActionMailer::Base.deliveries.last.to).to eq [user.notification_email] - expect(ActionMailer::Base.deliveries.last.subject).to match /Access to #{project.name_with_namespace} project was denied/ + expect(ActionMailer::Base.deliveries.last.subject).to match "Access to the #{project.name_with_namespace} project was denied" end def expect_visible_access_request(project, user) - expect(project.access_requested?(user)).to be_truthy + expect(project.members.request.exists?(user_id: user)).to be_truthy expect(page).to have_content "#{project.name} access requests (1)" expect(page).to have_content user.name end diff --git a/spec/features/projects/members/user_requests_access_spec.rb b/spec/features/projects/members/user_requests_access_spec.rb index 58a7ec1880..fd92a3a2f0 100644 --- a/spec/features/projects/members/user_requests_access_spec.rb +++ b/spec/features/projects/members/user_requests_access_spec.rb @@ -8,30 +8,27 @@ feature 'Projects > Members > User requests access', feature: true do background do project.team << [master, :master] login_as(user) + visit namespace_project_path(project.namespace, project) end scenario 'user can request access to a project' do - visit namespace_project_path(project.namespace, project) - - perform_enqueued_jobs do - click_link 'Request Access' - end + perform_enqueued_jobs { click_link 'Request Access' } expect(ActionMailer::Base.deliveries.last.to).to eq [master.notification_email] - expect(ActionMailer::Base.deliveries.last.subject).to match /Request to join #{project.name_with_namespace} project/ + expect(ActionMailer::Base.deliveries.last.subject).to eq "Request to join the #{project.name_with_namespace} project" - expect(project.access_requested?(user)).to be_truthy + expect(project.members.request.exists?(user_id: user)).to be_truthy expect(page).to have_content 'Your request for access has been queued for review.' - expect(page).to have_content 'Withdraw Request' + + expect(page).to have_content 'Withdraw Access Request' end scenario 'user is not listed in the project members page' do - visit namespace_project_path(project.namespace, project) - click_link 'Request Access' - expect(project.access_requested?(user)).to be_truthy + expect(project.members.request.exists?(user_id: user)).to be_truthy + open_project_settings_menu click_link 'Members' visit namespace_project_project_members_path(project.namespace, project) @@ -41,14 +38,17 @@ feature 'Projects > Members > User requests access', feature: true do end scenario 'user can withdraw its request for access' do - visit namespace_project_path(project.namespace, project) click_link 'Request Access' - expect(project.access_requested?(user)).to be_truthy + expect(project.members.request.exists?(user_id: user)).to be_truthy - click_link 'Withdraw Request' + click_link 'Withdraw Access Request' - expect(project.access_requested?(user)).to be_falsey - expect(page).to have_content 'You withdrawn your access request to the project.' + expect(project.members.request.exists?(user_id: user)).to be_falsey + expect(page).to have_content 'Your access request to the project has been withdrawn.' + end + + def open_project_settings_menu + find('#project-settings-button').click end end diff --git a/spec/helpers/gitlab_routing_helper_spec.rb b/spec/helpers/gitlab_routing_helper_spec.rb new file mode 100644 index 0000000000..14847d0a49 --- /dev/null +++ b/spec/helpers/gitlab_routing_helper_spec.rb @@ -0,0 +1,79 @@ +require 'spec_helper' + +describe GitlabRoutingHelper do + describe 'Project URL helpers' do + describe '#project_members_url' do + let(:project) { build_stubbed(:empty_project) } + + it { expect(project_members_url(project)).to eq namespace_project_project_members_url(project.namespace, project) } + end + + describe '#project_member_path' do + let(:project_member) { create(:project_member) } + + it { expect(project_member_path(project_member)).to eq namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member) } + end + + describe '#request_access_project_members_path' do + let(:project) { build_stubbed(:empty_project) } + + it { expect(request_access_project_members_path(project)).to eq request_access_namespace_project_project_members_path(project.namespace, project) } + end + + describe '#leave_project_members_path' do + let(:project) { build_stubbed(:empty_project) } + + it { expect(leave_project_members_path(project)).to eq leave_namespace_project_project_members_path(project.namespace, project) } + end + + describe '#approve_access_request_project_member_path' do + let(:project_member) { create(:project_member) } + + it { expect(approve_access_request_project_member_path(project_member)).to eq approve_access_request_namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member) } + end + + describe '#resend_invite_project_member_path' do + let(:project_member) { create(:project_member) } + + it { expect(resend_invite_project_member_path(project_member)).to eq resend_invite_namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member) } + end + end + + describe 'Group URL helpers' do + describe '#group_members_url' do + let(:group) { build_stubbed(:group) } + + it { expect(group_members_url(group)).to eq group_group_members_url(group) } + end + + describe '#group_member_path' do + let(:group_member) { create(:group_member) } + + it { expect(group_member_path(group_member)).to eq group_group_member_path(group_member.source, group_member) } + end + + describe '#request_access_group_members_path' do + let(:group) { build_stubbed(:group) } + + it { expect(request_access_group_members_path(group)).to eq request_access_group_group_members_path(group) } + end + + describe '#leave_group_members_path' do + let(:group) { build_stubbed(:group) } + + it { expect(leave_group_members_path(group)).to eq leave_group_group_members_path(group) } + end + + describe '#approve_access_request_group_member_path' do + let(:group_member) { create(:group_member) } + + it { expect(approve_access_request_group_member_path(group_member)).to eq approve_access_request_group_group_member_path(group_member.source, group_member) } + end + + describe '#resend_invite_group_member_path' do + let(:group_member) { create(:group_member) } + + it { expect(resend_invite_group_member_path(group_member)).to eq resend_invite_group_group_member_path(group_member.source, group_member) } + end + end +end diff --git a/spec/helpers/members_helper_spec.rb b/spec/helpers/members_helper_spec.rb index c2f10e1db7..0b1a76156e 100644 --- a/spec/helpers/members_helper_spec.rb +++ b/spec/helpers/members_helper_spec.rb @@ -1,22 +1,6 @@ require 'spec_helper' describe MembersHelper do - describe '#member_class' do - let(:project_member) { build(:project_member) } - let(:group_member) { build(:group_member) } - - it { expect(member_class(project_member)).to eq ProjectMember } - it { expect(member_class(group_member)).to eq GroupMember } - end - - describe '#members_association' do - let(:project) { build_stubbed(:project) } - let(:group) { build_stubbed(:group) } - - it { expect(members_association(project)).to eq :project_members } - it { expect(members_association(group)).to eq :group_members } - end - describe '#action_member_permission' do let(:project_member) { build(:project_member) } let(:group_member) { build(:group_member) } @@ -25,73 +9,20 @@ describe MembersHelper do it { expect(action_member_permission(:admin, group_member)).to eq :admin_group_member } end - describe '#can_see_entity_roles?' do - let(:project) { create(:project) } + describe '#can_see_member_roles?' do + let(:project) { create(:empty_project) } let(:group) { create(:group) } let(:user) { build(:user) } let(:admin) { build(:user, :admin) } let(:project_member) { create(:project_member, project: project) } let(:group_member) { create(:group_member, group: group) } - it { expect(can_see_entity_roles?(nil, project)).to be_falsy } - it { expect(can_see_entity_roles?(nil, group)).to be_falsy } - it { expect(can_see_entity_roles?(admin, project)).to be_truthy } - it { expect(can_see_entity_roles?(admin, group)).to be_truthy } - it { expect(can_see_entity_roles?(project_member.user, project)).to be_truthy } - it { expect(can_see_entity_roles?(group_member.user, group)).to be_truthy } - end - - describe '#member_path' do - let(:project_member) { create(:project_member) } - let(:group_member) { create(:group_member) } - - it { expect(member_path(project_member)).to eq namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member) } - it { expect(member_path(group_member)).to eq group_group_member_path(group_member.source, group_member) } - it { expect { member_path(double(:member, source: 'foo')) }.to raise_error ArgumentError, 'Unknown object class' } - end - - describe '#resend_invite_member_path' do - let(:project_member) { create(:project_member) } - let(:group_member) { create(:group_member) } - - it { expect(resend_invite_member_path(project_member)).to eq resend_invite_namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member) } - it { expect(resend_invite_member_path(group_member)).to eq resend_invite_group_group_member_path(group_member.source, group_member) } - it { expect { resend_invite_member_path(double(:member, source: 'foo')) }.to raise_error ArgumentError, 'Unknown object class' } - end - - describe '#request_access_path' do - let(:project) { build_stubbed(:project) } - let(:group) { build_stubbed(:group) } - - it { expect(request_access_path(project)).to eq request_access_namespace_project_project_members_path(project.namespace, project) } - it { expect(request_access_path(group)).to eq request_access_group_group_members_path(group) } - it { expect { request_access_path(double(:member, source: 'foo')) }.to raise_error ArgumentError, 'Unknown object class' } - end - - describe '#approve_request_member_path' do - let(:project_member) { create(:project_member) } - let(:group_member) { create(:group_member) } - - it { expect(approve_request_member_path(project_member)).to eq approve_access_request_namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member) } - it { expect(approve_request_member_path(group_member)).to eq approve_access_request_group_group_member_path(group_member.source, group_member) } - it { expect { approve_request_member_path(double(:member, source: 'foo')) }.to raise_error ArgumentError, 'Unknown object class' } - end - - describe '#leave_path' do - let(:project) { build_stubbed(:project) } - let(:group) { build_stubbed(:group) } - - it { expect(leave_path(project)).to eq leave_namespace_project_project_members_path(project.namespace, project) } - it { expect(leave_path(group)).to eq leave_group_group_members_path(group) } - it { expect { leave_path(double(:member, source: 'foo')) }.to raise_error ArgumentError, 'Unknown object class' } - end - - describe '#withdraw_request_message' do - let(:project) { build_stubbed(:project) } - let(:group) { build_stubbed(:group) } - - it { expect(withdraw_request_message(project)).to eq "Are you sure you want to withdraw your access request for the \"#{project.name_with_namespace}\" project?" } - it { expect(withdraw_request_message(group)).to eq "Are you sure you want to withdraw your access request for the \"#{group.name}\" group?" } + it { expect(can_see_member_roles?(source: project, user: nil)).to be_falsy } + it { expect(can_see_member_roles?(source: group, user: nil)).to be_falsy } + it { expect(can_see_member_roles?(source: project, user: admin)).to be_truthy } + it { expect(can_see_member_roles?(source: group, user: admin)).to be_truthy } + it { expect(can_see_member_roles?(source: project, user: project_member.user)).to be_truthy } + it { expect(can_see_member_roles?(source: group, user: group_member.user)).to be_truthy } end describe '#remove_member_message' do @@ -105,12 +36,14 @@ describe MembersHelper do let(:group_member_invite) { build(:group_member, group: group).tap { |m| m.generate_invite_token! } } let(:group_member_request) { group.request_access(requester) } - it { expect(remove_member_message(project_member)).to eq "You are going to remove #{project_member.user.name} from the #{project.name_with_namespace} project. Are you sure?" } - it { expect(remove_member_message(project_member_invite)).to eq "You are going to revoke the invitation for #{project_member_invite.invite_email} to join the #{project.name_with_namespace} project. Are you sure?" } - it { expect(remove_member_message(project_member_request)).to eq "You are going to deny #{requester.name}'s request to join the #{project.name_with_namespace} project. Are you sure?" } - it { expect(remove_member_message(group_member)).to eq "You are going to remove #{group_member.user.name} from the #{group.name} group. Are you sure?" } - it { expect(remove_member_message(group_member_invite)).to eq "You are going to revoke the invitation for #{group_member_invite.invite_email} to join the #{group.name} group. Are you sure?" } - it { expect(remove_member_message(group_member_request)).to eq "You are going to deny #{requester.name}'s request to join the #{group.name} group. Are you sure?" } + it { expect(remove_member_message(project_member)).to eq "Are you sure you want to remove #{project_member.user.name} from the #{project.name_with_namespace} project?" } + it { expect(remove_member_message(project_member_invite)).to eq "Are you sure you want to revoke the invitation for #{project_member_invite.invite_email} to join the #{project.name_with_namespace} project?" } + it { expect(remove_member_message(project_member_request)).to eq "Are you sure you want to deny #{requester.name}'s request to join the #{project.name_with_namespace} project?" } + it { expect(remove_member_message(project_member_request, user: requester)).to eq "Are you sure you want to withdraw your access request for the #{project.name_with_namespace} project?" } + it { expect(remove_member_message(group_member)).to eq "Are you sure you want to remove #{group_member.user.name} from the #{group.name} group?" } + it { expect(remove_member_message(group_member_invite)).to eq "Are you sure you want to revoke the invitation for #{group_member_invite.invite_email} to join the #{group.name} group?" } + it { expect(remove_member_message(group_member_request)).to eq "Are you sure you want to deny #{requester.name}'s request to join the #{group.name} group?" } + it { expect(remove_member_message(group_member_request, user: requester)).to eq "Are you sure you want to withdraw your access request for the #{group.name} group?" } end describe '#remove_member_title' do @@ -122,10 +55,10 @@ describe MembersHelper do let(:group_member) { build(:group_member, group: group) } let(:group_member_request) { group.request_access(requester) } - it { expect(remove_member_title(project_member)).to eq 'Remove user' } - it { expect(remove_member_title(project_member_request)).to eq 'Deny access request' } - it { expect(remove_member_title(group_member)).to eq 'Remove user' } - it { expect(remove_member_title(group_member_request)).to eq 'Deny access request' } + it { expect(remove_member_title(project_member)).to eq 'Remove user from project' } + it { expect(remove_member_title(project_member_request)).to eq 'Deny access request from project' } + it { expect(remove_member_title(group_member)).to eq 'Remove user from group' } + it { expect(remove_member_title(group_member_request)).to eq 'Deny access request from group' } end describe '#leave_confirmation_message' do @@ -133,7 +66,7 @@ describe MembersHelper do let(:group) { build_stubbed(:group) } let(:user) { build_stubbed(:user) } - it { expect(leave_confirmation_message(project)).to eq "Are you sure you want to leave \"#{project.name_with_namespace}\" project?" } - it { expect(leave_confirmation_message(group)).to eq "Are you sure you want to leave \"#{group.name}\" group?" } + it { expect(leave_confirmation_message(project)).to eq "Are you sure you want to leave the \"#{project.name_with_namespace}\" project?" } + it { expect(leave_confirmation_message(group)).to eq "Are you sure you want to leave the \"#{group.name}\" group?" } end end diff --git a/spec/helpers/projects_helper_spec.rb b/spec/helpers/projects_helper_spec.rb index fa81c28849..09e0bbfd00 100644 --- a/spec/helpers/projects_helper_spec.rb +++ b/spec/helpers/projects_helper_spec.rb @@ -1,25 +1,6 @@ require 'spec_helper' describe ProjectsHelper do - describe '#max_access_level' do - let(:master) { create(:user) } - let(:owner) { create(:user) } - let(:reporter) { create(:user) } - let(:group) { create(:group) } - let(:project) { build_stubbed(:empty_project, namespace: group) } - - before do - group.add_master(master) - group.add_owner(owner) - group.add_reporter(reporter) - end - - it { expect(max_access_level(project, master)).to eq 'Master' } - it { expect(max_access_level(project, owner)).to eq 'Owner' } - it { expect(max_access_level(project, reporter)).to eq 'Reporter' } - it { expect(max_access_level(project, build_stubbed(:user))).to be_nil } - end - describe "#project_status_css_class" do it "returns appropriate class" do expect(project_status_css_class("started")).to eq("active") diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index a86ec865b5..1e6eb20ab3 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -405,7 +405,7 @@ describe Notify do let(:user) { create(:user) } let(:project_member) do project.request_access(user) - project.project_members.find_by(created_by_id: user.id) + project.members.request.find_by(user_id: user.id) end subject { Notify.member_access_requested_email('project', project_member.id) } @@ -413,10 +413,12 @@ describe Notify do it_behaves_like 'it should not have Gmail Actions links' it_behaves_like "a user cannot unsubscribe through footer link" - it { is_expected.to have_subject "Request to join the #{project.name_with_namespace} project" } - it { is_expected.to have_body_text /#{project.name_with_namespace}/ } - it { is_expected.to have_body_text /#{project.web_url}/ } - it { is_expected.to have_body_text /#{project_member.human_access}/ } + it 'contains all the useful information' do + is_expected.to have_subject "Request to join the #{project.name_with_namespace} project" + is_expected.to have_body_text /#{project.name_with_namespace}/ + is_expected.to have_body_text /#{namespace_project_project_members_url(project.namespace, project)}/ + is_expected.to have_body_text /#{project_member.human_access}/ + end end describe 'project access denied' do @@ -424,7 +426,7 @@ describe Notify do let(:user) { create(:user) } let(:project_member) do project.request_access(user) - project.project_members.find_by(created_by_id: user.id) + project.members.request.find_by(user_id: user.id) end subject { Notify.member_access_denied_email('project', project.id, user.id) } @@ -432,9 +434,11 @@ describe Notify do it_behaves_like 'it should not have Gmail Actions links' it_behaves_like "a user cannot unsubscribe through footer link" - it { is_expected.to have_subject "Access to the #{project.name_with_namespace} project was denied" } - it { is_expected.to have_body_text /#{project.name_with_namespace}/ } - it { is_expected.to have_body_text /#{project.web_url}/ } + it 'contains all the useful information' do + is_expected.to have_subject "Access to the #{project.name_with_namespace} project was denied" + is_expected.to have_body_text /#{project.name_with_namespace}/ + is_expected.to have_body_text /#{project.web_url}/ + end end describe 'project access changed' do @@ -447,10 +451,12 @@ describe Notify do it_behaves_like 'it should not have Gmail Actions links' it_behaves_like "a user cannot unsubscribe through footer link" - it { is_expected.to have_subject "Access to the #{project.name_with_namespace} project was granted" } - it { is_expected.to have_body_text /#{project.name_with_namespace}/ } - it { is_expected.to have_body_text /#{project.web_url}/ } - it { is_expected.to have_body_text /#{project_member.human_access}/ } + it 'contains all the useful information' do + is_expected.to have_subject "Access to the #{project.name_with_namespace} project was granted" + is_expected.to have_body_text /#{project.name_with_namespace}/ + is_expected.to have_body_text /#{project.web_url}/ + is_expected.to have_body_text /#{project_member.human_access}/ + end end def invite_to_project(project:, email:, inviter:) @@ -470,11 +476,13 @@ describe Notify do it_behaves_like 'it should not have Gmail Actions links' it_behaves_like "a user cannot unsubscribe through footer link" - it { is_expected.to have_subject "Invitation to join the #{project.name_with_namespace} project" } - it { is_expected.to have_body_text /#{project.name_with_namespace}/ } - it { is_expected.to have_body_text /#{project.web_url}/ } - it { is_expected.to have_body_text /#{project_member.human_access}/ } - it { is_expected.to have_body_text /#{project_member.invite_token}/ } + it 'contains all the useful information' do + is_expected.to have_subject "Invitation to join the #{project.name_with_namespace} project" + is_expected.to have_body_text /#{project.name_with_namespace}/ + is_expected.to have_body_text /#{project.web_url}/ + is_expected.to have_body_text /#{project_member.human_access}/ + is_expected.to have_body_text /#{project_member.invite_token}/ + end end describe 'project invitation accepted' do @@ -493,11 +501,13 @@ describe Notify do it_behaves_like 'it should not have Gmail Actions links' it_behaves_like "a user cannot unsubscribe through footer link" - it { is_expected.to have_subject 'Invitation accepted' } - it { is_expected.to have_body_text /#{project.name_with_namespace}/ } - it { is_expected.to have_body_text /#{project.web_url}/ } - it { is_expected.to have_body_text /#{project_member.invite_email}/ } - it { is_expected.to have_body_text /#{invited_user.name}/ } + it 'contains all the useful information' do + is_expected.to have_subject 'Invitation accepted' + is_expected.to have_body_text /#{project.name_with_namespace}/ + is_expected.to have_body_text /#{project.web_url}/ + is_expected.to have_body_text /#{project_member.invite_email}/ + is_expected.to have_body_text /#{invited_user.name}/ + end end describe 'project invitation declined' do @@ -515,10 +525,12 @@ describe Notify do it_behaves_like 'it should not have Gmail Actions links' it_behaves_like "a user cannot unsubscribe through footer link" - it { is_expected.to have_subject 'Invitation declined' } - it { is_expected.to have_body_text /#{project.name_with_namespace}/ } - it { is_expected.to have_body_text /#{project.web_url}/ } - it { is_expected.to have_body_text /#{project_member.invite_email}/ } + it 'contains all the useful information' do + is_expected.to have_subject 'Invitation declined' + is_expected.to have_body_text /#{project.name_with_namespace}/ + is_expected.to have_body_text /#{project.web_url}/ + is_expected.to have_body_text /#{project_member.invite_email}/ + end end context 'items that are noteable, the email for a note' do @@ -639,7 +651,7 @@ describe Notify do let(:user) { create(:user) } let(:group_member) do group.request_access(user) - group.group_members.find_by(created_by_id: user.id) + group.members.request.find_by(user_id: user.id) end subject { Notify.member_access_requested_email('group', group_member.id) } @@ -647,10 +659,12 @@ describe Notify do it_behaves_like 'it should not have Gmail Actions links' it_behaves_like "a user cannot unsubscribe through footer link" - it { is_expected.to have_subject "Request to join the #{group.name} group" } - it { is_expected.to have_body_text /#{group.name}/ } - it { is_expected.to have_body_text /#{group.web_url}/ } - it { is_expected.to have_body_text /#{group_member.human_access}/ } + it 'contains all the useful information' do + is_expected.to have_subject "Request to join the #{group.name} group" + is_expected.to have_body_text /#{group.name}/ + is_expected.to have_body_text /#{group_group_members_url(group)}/ + is_expected.to have_body_text /#{group_member.human_access}/ + end end describe 'group access denied' do @@ -658,7 +672,7 @@ describe Notify do let(:user) { create(:user) } let(:group_member) do group.request_access(user) - group.group_members.find_by(created_by_id: user.id) + group.members.request.find_by(user_id: user.id) end subject { Notify.member_access_denied_email('group', group.id, user.id) } @@ -666,9 +680,11 @@ describe Notify do it_behaves_like 'it should not have Gmail Actions links' it_behaves_like "a user cannot unsubscribe through footer link" - it { is_expected.to have_subject "Access to the #{group.name} group was denied" } - it { is_expected.to have_body_text /#{group.name}/ } - it { is_expected.to have_body_text /#{group.web_url}/ } + it 'contains all the useful information' do + is_expected.to have_subject "Access to the #{group.name} group was denied" + is_expected.to have_body_text /#{group.name}/ + is_expected.to have_body_text /#{group.web_url}/ + end end describe 'group access changed' do @@ -682,10 +698,12 @@ describe Notify do it_behaves_like 'it should not have Gmail Actions links' it_behaves_like "a user cannot unsubscribe through footer link" - it { is_expected.to have_subject "Access to the #{group.name} group was granted" } - it { is_expected.to have_body_text /#{group.name}/ } - it { is_expected.to have_body_text /#{group.web_url}/ } - it { is_expected.to have_body_text /#{group_member.human_access}/ } + it 'contains all the useful information' do + is_expected.to have_subject "Access to the #{group.name} group was granted" + is_expected.to have_body_text /#{group.name}/ + is_expected.to have_body_text /#{group.web_url}/ + is_expected.to have_body_text /#{group_member.human_access}/ + end end def invite_to_group(group:, email:, inviter:) @@ -705,11 +723,13 @@ describe Notify do it_behaves_like 'it should not have Gmail Actions links' it_behaves_like "a user cannot unsubscribe through footer link" - it { is_expected.to have_subject "Invitation to join the #{group.name} group" } - it { is_expected.to have_body_text /#{group.name}/ } - it { is_expected.to have_body_text /#{group.web_url}/ } - it { is_expected.to have_body_text /#{group_member.human_access}/ } - it { is_expected.to have_body_text /#{group_member.invite_token}/ } + it 'contains all the useful information' do + is_expected.to have_subject "Invitation to join the #{group.name} group" + is_expected.to have_body_text /#{group.name}/ + is_expected.to have_body_text /#{group.web_url}/ + is_expected.to have_body_text /#{group_member.human_access}/ + is_expected.to have_body_text /#{group_member.invite_token}/ + end end describe 'group invitation accepted' do @@ -728,11 +748,13 @@ describe Notify do it_behaves_like 'it should not have Gmail Actions links' it_behaves_like "a user cannot unsubscribe through footer link" - it { is_expected.to have_subject 'Invitation accepted' } - it { is_expected.to have_body_text /#{group.name}/ } - it { is_expected.to have_body_text /#{group.web_url}/ } - it { is_expected.to have_body_text /#{group_member.invite_email}/ } - it { is_expected.to have_body_text /#{invited_user.name}/ } + it 'contains all the useful information' do + is_expected.to have_subject 'Invitation accepted' + is_expected.to have_body_text /#{group.name}/ + is_expected.to have_body_text /#{group.web_url}/ + is_expected.to have_body_text /#{group_member.invite_email}/ + is_expected.to have_body_text /#{invited_user.name}/ + end end describe 'group invitation declined' do @@ -750,10 +772,12 @@ describe Notify do it_behaves_like 'it should not have Gmail Actions links' it_behaves_like "a user cannot unsubscribe through footer link" - it { is_expected.to have_subject 'Invitation declined' } - it { is_expected.to have_body_text /#{group.name}/ } - it { is_expected.to have_body_text /#{group.web_url}/ } - it { is_expected.to have_body_text /#{group_member.invite_email}/ } + it 'contains all the useful information' do + is_expected.to have_subject 'Invitation declined' + is_expected.to have_body_text /#{group.name}/ + is_expected.to have_body_text /#{group.web_url}/ + is_expected.to have_body_text /#{group_member.invite_email}/ + end end end diff --git a/spec/models/concerns/access_requestable_spec.rb b/spec/models/concerns/access_requestable_spec.rb index 2dfed1eb4c..9830787696 100644 --- a/spec/models/concerns/access_requestable_spec.rb +++ b/spec/models/concerns/access_requestable_spec.rb @@ -7,8 +7,7 @@ describe AccessRequestable do let(:user) { create(:user) } it { expect(group.request_access(user)).to be_a(GroupMember) } - it { expect(group.request_access(user).user).to be_nil } - it { expect(group.request_access(user).created_by).to eq(user) } + it { expect(group.request_access(user).user).to eq(user) } end describe '#access_requested?' do @@ -17,7 +16,7 @@ describe AccessRequestable do before { group.request_access(user) } - it { expect(group.access_requested?(user)).to be_truthy } + it { expect(group.members.request.exists?(user_id: user)).to be_truthy } end end @@ -35,7 +34,7 @@ describe AccessRequestable do before { project.request_access(user) } - it { expect(project.access_requested?(user)).to be_truthy } + it { expect(project.members.request.exists?(user_id: user)).to be_truthy } end end end diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 52f9d57bc0..ccdcb29f77 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -10,17 +10,6 @@ describe Group, models: true do it { is_expected.to have_many(:project_group_links).dependent(:destroy) } it { is_expected.to have_many(:shared_projects).through(:project_group_links) } it { is_expected.to have_many(:notification_settings).dependent(:destroy) } - - describe '#group_members' do - let(:user) { create(:user) } - let(:group) { create(:group) } - - before { group.request_access(user) } - - it 'does not includes membership requests' do - expect(user.group_members).to be_empty - end - end end describe 'modules' do diff --git a/spec/models/member_spec.rb b/spec/models/member_spec.rb index a3d525d8d5..3ed3202ac6 100644 --- a/spec/models/member_spec.rb +++ b/spec/models/member_spec.rb @@ -55,45 +55,78 @@ describe Member, models: true do end end - describe 'Scopes' do + describe 'Scopes & finders' do before do project = create(:project) - @invited_member = build(:project_member, user: nil).tap { |m| m.generate_invite_token! } - @accepted_invite_member = build(:project_member, user: nil).tap { |m| m.generate_invite_token! && m.accept_invite!(build(:user)) } + group = create(:group) + @owner_user = create(:user).tap { |u| group.add_owner(u) } + @owner = group.members.find_by(user_id: @owner_user.id) + + @master_user = create(:user).tap { |u| project.team << [u, :master] } + @master = project.members.find_by(user_id: @master_user.id) + + ProjectMember.add_user(project.members, 'toto1@example.com', Gitlab::Access::DEVELOPER, @master_user) + @invited_member = project.members.invite.find_by_invite_email('toto1@example.com') + + accepted_invite_user = build(:user) + ProjectMember.add_user(project.members, 'toto2@example.com', Gitlab::Access::DEVELOPER, @master_user) + @accepted_invite_member = project.members.invite.find_by_invite_email('toto2@example.com').tap { |u| u.accept_invite!(accepted_invite_user) } requested_user = create(:user).tap { |u| project.request_access(u) } - @requested_member = project.project_members.find_by(created_by_id: requested_user.id) + @requested_member = project.members.request.find_by(user_id: requested_user.id) + accepted_request_user = create(:user).tap { |u| project.request_access(u) } - @accepted_request_member = project.project_members.find_by(created_by_id: accepted_request_user.id).tap { |m| m.accept_request } + @accepted_request_member = project.members.request.find_by(user_id: accepted_request_user.id).tap { |m| m.accept_request } end - describe '#invite' do + describe '.invite' do + it { expect(described_class.invite).not_to include @master } it { expect(described_class.invite).to include @invited_member } it { expect(described_class.invite).not_to include @accepted_invite_member } it { expect(described_class.invite).not_to include @requested_member } it { expect(described_class.invite).not_to include @accepted_request_member } end - describe '#request' do + describe '.non_invite' do + it { expect(described_class.non_invite).to include @master } + it { expect(described_class.non_invite).not_to include @invited_member } + it { expect(described_class.non_invite).to include @accepted_invite_member } + it { expect(described_class.non_invite).to include @requested_member } + it { expect(described_class.non_invite).to include @accepted_request_member } + end + + describe '.request' do + it { expect(described_class.request).not_to include @master } it { expect(described_class.request).not_to include @invited_member } it { expect(described_class.request).not_to include @accepted_invite_member } it { expect(described_class.request).to include @requested_member } it { expect(described_class.request).not_to include @accepted_request_member } end - describe '#non_request' do + describe '.non_request' do + it { expect(described_class.non_request).to include @master } it { expect(described_class.non_request).to include @invited_member } it { expect(described_class.non_request).to include @accepted_invite_member } it { expect(described_class.non_request).not_to include @requested_member } it { expect(described_class.non_request).to include @accepted_request_member } end - describe '#non_pending' do + describe '.non_pending' do + it { expect(described_class.non_pending).to include @master } it { expect(described_class.non_pending).not_to include @invited_member } it { expect(described_class.non_pending).to include @accepted_invite_member } it { expect(described_class.non_pending).not_to include @requested_member } it { expect(described_class.non_pending).to include @accepted_request_member } end + + describe '.owners_and_masters' do + it { expect(described_class.owners_and_masters).to include @owner } + it { expect(described_class.owners_and_masters).to include @master } + it { expect(described_class.owners_and_masters).not_to include @invited_member } + it { expect(described_class.owners_and_masters).not_to include @accepted_invite_member } + it { expect(described_class.owners_and_masters).not_to include @requested_member } + it { expect(described_class.owners_and_masters).not_to include @accepted_request_member } + end end describe "Delegate methods" do @@ -101,6 +134,18 @@ describe Member, models: true do it { is_expected.to respond_to(:user_email) } end + describe 'Callbacks' do + describe 'after_destroy :post_decline_request, if: :request?' do + let(:member) { create(:project_member, requested_at: Time.now.utc) } + + it 'calls #post_decline_request' do + expect(member).to receive(:post_decline_request) + + member.destroy + end + end + end + describe ".add_user" do let!(:user) { create(:user) } let(:project) { create(:project) } @@ -139,18 +184,9 @@ describe Member, models: true do end describe '#accept_request' do - let(:user) { create(:user) } - let(:member) { create(:project_member, requested_at: Time.now.utc, user: nil, created_by: user) } + let(:member) { create(:project_member, requested_at: Time.now.utc) } - it 'returns true' do - expect(member.accept_request).to be_truthy - end - - it 'sets the user' do - member.accept_request - - expect(member.user).to eq(user) - end + it { expect(member.accept_request).to be_truthy } it 'clears requested_at' do member.accept_request @@ -165,25 +201,24 @@ describe Member, models: true do end end - describe '#decline_request' do - let(:user) { create(:user) } - let(:member) { create(:project_member, requested_at: Time.now.utc, user: nil, created_by: user) } + describe '#invite?' do + subject { create(:project_member, invite_email: "user@example.com", user: nil) } - it 'returns true' do - expect(member.decline_request).to be_truthy - end + it { is_expected.to be_invite } + end - it 'destroys the member' do - member.decline_request + describe '#request?' do + subject { create(:project_member, requested_at: Time.now.utc) } - expect(member).to be_destroyed - end + it { is_expected.to be_request } + end - it 'calls #after_decline_request' do - expect(member).to receive(:after_decline_request) + describe '#pending?' do + let(:invited_member) { create(:project_member, invite_email: "user@example.com", user: nil) } + let(:requester) { create(:project_member, requested_at: Time.now.utc) } - member.decline_request - end + it { expect(invited_member).to be_invite } + it { expect(requester).to be_pending } end describe "#accept_invite!" do diff --git a/spec/models/members/group_member_spec.rb b/spec/models/members/group_member_spec.rb index c3070d4cb7..eeb74a462a 100644 --- a/spec/models/members/group_member_spec.rb +++ b/spec/models/members/group_member_spec.rb @@ -51,24 +51,30 @@ describe GroupMember, models: true do end end - describe 'after accept_request' do - let(:member) { create(:group_member, user: nil, created_by: build_stubbed(:user), requested_at: Time.now) } + describe '#after_accept_request' do + it 'calls NotificationService.accept_group_access_request' do + member = create(:group_member, user: build_stubbed(:user), requested_at: Time.now) - it "calls #accept_group_access_request" do expect_any_instance_of(NotificationService).to receive(:new_group_member) - member.accept_request + member.__send__(:after_accept_request) end end - describe 'after decline_request' do - let(:member) { create(:group_member, user: nil, created_by: build_stubbed(:user), requested_at: Time.now) } + describe '#post_decline_request' do + it 'calls NotificationService.decline_group_access_request' do + member = create(:group_member, user: build_stubbed(:user), requested_at: Time.now) - it "calls #decline_group_access_request" do expect_any_instance_of(NotificationService).to receive(:decline_group_access_request) - member.decline_request + member.__send__(:post_decline_request) end end + + describe '#real_source_type' do + subject { create(:group_member).real_source_type } + + it { is_expected.to eq 'Group' } + end end end diff --git a/spec/models/members/project_member_spec.rb b/spec/models/members/project_member_spec.rb index 99b3c77c6c..1e466f9c62 100644 --- a/spec/models/members/project_member_spec.rb +++ b/spec/models/members/project_member_spec.rb @@ -33,6 +33,12 @@ describe ProjectMember, models: true do it { is_expected.to include_module(Gitlab::ShellAdapter) } end + describe '#real_source_type' do + subject { create(:project_member).real_source_type } + + it { is_expected.to eq 'Project' } + end + describe "#destroy" do let(:owner) { create(:project_member, access_level: ProjectMember::OWNER) } let(:project) { owner.project } @@ -137,23 +143,23 @@ describe ProjectMember, models: true do end describe 'notifications' do - describe 'after accept_request' do - let(:member) { create(:project_member, user: nil, created_by: build_stubbed(:user), requested_at: Time.now) } + describe '#after_accept_request' do + it 'calls NotificationService.new_project_member' do + member = create(:project_member, user: build_stubbed(:user), requested_at: Time.now) - it 'calls #accept_project_access_request' do expect_any_instance_of(NotificationService).to receive(:new_project_member) - member.accept_request + member.__send__(:after_accept_request) end end - describe 'after decline_request' do - let(:member) { create(:project_member, user: nil, created_by: build_stubbed(:user), requested_at: Time.now) } + describe '#post_decline_request' do + it 'calls NotificationService.decline_project_access_request' do + member = create(:project_member, user: build_stubbed(:user), requested_at: Time.now) - it 'calls #decline_project_access_request' do expect_any_instance_of(NotificationService).to receive(:decline_project_access_request) - member.decline_request + member.__send__(:post_decline_request) end end end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index d5a4b73aff..30aa2b70c8 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -29,17 +29,6 @@ describe Project, models: true do it { is_expected.to have_many(:variables) } it { is_expected.to have_many(:triggers) } it { is_expected.to have_many(:todos).dependent(:destroy) } - - describe '#project_members' do - let(:user) { create(:user) } - let(:project) { create(:project) } - - before { project.request_access(user) } - - it 'does not includes membership requests' do - expect(user.project_members).to be_empty - end - end end describe 'modules' do @@ -100,11 +89,17 @@ describe Project, models: true do it { is_expected.to respond_to(:repo_exists?) } it { is_expected.to respond_to(:update_merge_requests) } it { is_expected.to respond_to(:execute_hooks) } - it { is_expected.to respond_to(:name_with_namespace) } it { is_expected.to respond_to(:owner) } it { is_expected.to respond_to(:path_with_namespace) } end + describe '#name_with_namespace' do + let(:project) { build_stubbed(:empty_project) } + + it { expect(project.name_with_namespace).to eq "#{project.namespace.human_name} / #{project.name}" } + it { expect(project.human_name).to eq project.name_with_namespace } + end + describe '#to_reference' do let(:project) { create(:empty_project) } diff --git a/spec/models/project_team_spec.rb b/spec/models/project_team_spec.rb index 36b1f43995..9262aeb6ed 100644 --- a/spec/models/project_team_spec.rb +++ b/spec/models/project_team_spec.rb @@ -112,6 +112,28 @@ describe ProjectTeam, models: true do end end + describe "#human_max_access" do + it 'returns Master role' do + user = create(:user) + group = create(:group) + group.add_master(user) + + project = build_stubbed(:empty_project, namespace: group) + + expect(project.team.human_max_access(user.id)).to eq 'Master' + end + + it 'returns Owner role' do + user = create(:user) + group = create(:group) + group.add_owner(user) + + project = build_stubbed(:empty_project, namespace: group) + + expect(project.team.human_max_access(user.id)).to eq 'Owner' + end + end + describe '#max_member_access' do let(:requester) { create(:user) } From d183e27e5c75bce80fc3b9e8297b69f1007e6819 Mon Sep 17 00:00:00 2001 From: Annabel Dunstone Date: Tue, 14 Jun 2016 09:58:54 -0500 Subject: [PATCH 25/36] Put all sidebar icons in fixed width container --- app/assets/stylesheets/framework/sidebar.scss | 6 ++++ app/views/layouts/nav/_dashboard.html.haml | 30 ++++++++++++------- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index b7ec3f70bf..4668e7e911 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -83,6 +83,12 @@ margin-top: 10px; } + .icon-container { + width: 34px; + display: inline-block; + text-align: center; + } + a { width: $sidebar_width; padding: 7px 15px 7px 23px; diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml index 18cae5bf87..52e41b1a85 100644 --- a/app/views/layouts/nav/_dashboard.html.haml +++ b/app/views/layouts/nav/_dashboard.html.haml @@ -1,54 +1,64 @@ %ul.nav.nav-sidebar = nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: {class: "#{project_tab_class} home"}) do = link_to dashboard_projects_path, title: 'Projects', class: 'dashboard-shortcuts-projects' do - = navbar_icon('project') + .icon-container + = navbar_icon('project') %span Projects = nav_link(controller: :todos) do = link_to dashboard_todos_path, title: 'Todos' do - = icon('bell fw') + .icon-container + = icon('bell fw') %span Todos %span.count= number_with_delimiter(todos_pending_count) = nav_link(path: 'dashboard#activity') do = link_to activity_dashboard_path, class: 'dashboard-shortcuts-activity', title: 'Activity' do - = navbar_icon('activity') + .icon-container + = navbar_icon('activity') %span Activity = nav_link(controller: [:groups, 'groups/milestones', 'groups/group_members']) do = link_to dashboard_groups_path, title: 'Groups' do - = navbar_icon('group') + .icon-container + = navbar_icon('group') %span Groups = nav_link(controller: 'dashboard/milestones') do = link_to dashboard_milestones_path, title: 'Milestones' do - = navbar_icon('milestones') + .icon-container + = navbar_icon('milestones') %span Milestones = nav_link(path: 'dashboard#issues') do = link_to assigned_issues_dashboard_path, title: 'Issues', class: 'dashboard-shortcuts-issues' do - = navbar_icon('issues') + .icon-container + = navbar_icon('issues') %span Issues %span.count= number_with_delimiter(current_user.assigned_issues.opened.count) = nav_link(path: 'dashboard#merge_requests') do = link_to assigned_mrs_dashboard_path, title: 'Merge Requests', class: 'dashboard-shortcuts-merge_requests' do - = navbar_icon('mr') + .icon-container + = navbar_icon('mr') %span Merge Requests %span.count= number_with_delimiter(current_user.assigned_merge_requests.opened.count) = nav_link(controller: :snippets) do = link_to dashboard_snippets_path, title: 'Snippets' do - = icon('clipboard fw') + .icon-container + = icon('clipboard fw') %span Snippets = nav_link(controller: :help) do = link_to help_path, title: 'Help' do - = icon('question-circle fw') + .icon-container + = icon('question-circle fw') %span Help = nav_link(html_options: {class: profile_tab_class}) do = link_to profile_path, title: 'Profile Settings', data: {placement: 'bottom'} do - = icon('user fw') + .icon-container + = icon('user fw') %span Profile Settings From 82090d291fa56e11e5be24102fa651273ac28d4b Mon Sep 17 00:00:00 2001 From: Yorick Peterse Date: Tue, 14 Jun 2016 14:35:10 +0200 Subject: [PATCH 26/36] Update the allocations Gem to 1.0.5 This allows it to be used on Ruby 2.3 without it crashing all the time. --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 209f29de1e..d517fcb8ed 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -50,7 +50,7 @@ GEM after_commit_queue (1.3.0) activerecord (>= 3.0) akismet (2.0.0) - allocations (1.0.4) + allocations (1.0.5) arel (6.0.3) asana (0.4.0) faraday (~> 0.9) From ab91f1226f9dc99725e10323c0ea319f335204b3 Mon Sep 17 00:00:00 2001 From: Yorick Peterse Date: Tue, 14 Jun 2016 14:35:25 +0200 Subject: [PATCH 27/36] Filter out classes without names in the sampler We can't do a lot with classes without names as we can't filter by them, have no idea where they come from, etc. As such it's best to just ignore these. --- lib/gitlab/metrics/sampler.rb | 6 +++++- spec/lib/gitlab/metrics/sampler_spec.rb | 25 ++++++++++++++++++------- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/lib/gitlab/metrics/sampler.rb b/lib/gitlab/metrics/sampler.rb index fc709222a9..0000450d9b 100644 --- a/lib/gitlab/metrics/sampler.rb +++ b/lib/gitlab/metrics/sampler.rb @@ -66,7 +66,11 @@ module Gitlab def sample_objects sample = Allocations.to_hash counts = sample.each_with_object({}) do |(klass, count), hash| - hash[klass.name] = count + name = klass.name + + next unless name + + hash[name] = count end # Symbols aren't allocated so we'll need to add those manually. diff --git a/spec/lib/gitlab/metrics/sampler_spec.rb b/spec/lib/gitlab/metrics/sampler_spec.rb index 59db127674..1ab923b58c 100644 --- a/spec/lib/gitlab/metrics/sampler_spec.rb +++ b/spec/lib/gitlab/metrics/sampler_spec.rb @@ -72,14 +72,25 @@ describe Gitlab::Metrics::Sampler do end end - describe '#sample_objects' do - it 'adds a metric containing the amount of allocated objects' do - expect(sampler).to receive(:add_metric). - with(/object_counts/, an_instance_of(Hash), an_instance_of(Hash)). - at_least(:once). - and_call_original + if Gitlab::Metrics.mri? + describe '#sample_objects' do + it 'adds a metric containing the amount of allocated objects' do + expect(sampler).to receive(:add_metric). + with(/object_counts/, an_instance_of(Hash), an_instance_of(Hash)). + at_least(:once). + and_call_original - sampler.sample_objects + sampler.sample_objects + end + + it 'ignores classes without a name' do + expect(Allocations).to receive(:to_hash).and_return({ Class.new => 4 }) + + expect(sampler).not_to receive(:add_metric). + with('object_counts', an_instance_of(Hash), type: nil) + + sampler.sample_objects + end end end From 7eabc67efeda871fdff345c4d9723db577f8b58e Mon Sep 17 00:00:00 2001 From: Yorick Peterse Date: Tue, 14 Jun 2016 14:41:06 +0200 Subject: [PATCH 28/36] Added CHANGELOG entry for allocations Gem/name fix --- CHANGELOG | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index 74fb52d3ae..162c6723dd 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -79,6 +79,8 @@ v 8.9.0 (unreleased) - Allow users to create confidential issues in private projects - Measure CPU time for instrumented methods - Instrument private methods and private instance methods by default instead just public methods + - Updated the allocations Gem to version 1.0.5 + - The background sampler now ignores classes without names v 8.8.5 (unreleased) - Ensure branch cleanup regardless of whether the GitHub import process succeeds From 06e0ef07bcb92925e6819cbe1e33cdcf645b736b Mon Sep 17 00:00:00 2001 From: Patricio Cano Date: Tue, 14 Jun 2016 11:45:45 -0500 Subject: [PATCH 29/36] Added API endpoint for Sidekiq Metrics --- doc/api/README.md | 1 + doc/api/sidekiq_metrics.md | 152 ++++++++++++++++++++++ lib/api/api.rb | 1 + lib/api/sidekiq_metrics.rb | 90 +++++++++++++ spec/requests/api/sidekiq_metrics_spec.rb | 40 ++++++ 5 files changed, 284 insertions(+) create mode 100644 doc/api/sidekiq_metrics.md create mode 100644 lib/api/sidekiq_metrics.rb create mode 100644 spec/requests/api/sidekiq_metrics_spec.rb diff --git a/doc/api/README.md b/doc/api/README.md index e3fc5a09f2..6042ef7637 100644 --- a/doc/api/README.md +++ b/doc/api/README.md @@ -31,6 +31,7 @@ following locations: - [Services](services.md) - [Session](session.md) - [Settings](settings.md) +- [Sidekiq Metrics](sidekiq_metrics.md) - [System Hooks](system_hooks.md) - [Tags](tags.md) - [Users](users.md) diff --git a/doc/api/sidekiq_metrics.md b/doc/api/sidekiq_metrics.md new file mode 100644 index 0000000000..de627c1a96 --- /dev/null +++ b/doc/api/sidekiq_metrics.md @@ -0,0 +1,152 @@ +# Sidekiq Metrics + +>**Note:** This endpoint is only available on GitLab 8.9 and above. + +This API endpoint allows you to retrieve some information about the current state +of Sidekiq, it's jobs, queues, and processes. + +## Get the current Queue Metrics + +List information about all the registered queues, their backlog and their +latency. + +``` +GET /sidekiq/queue_metrics +``` + +```bash +curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/sidekiq/queue_metrics +``` + +Example response: + +```json +{ + "queues": { + "default": { + "backlog": 0, + "latency": 0 + } + } +} +``` + +## Get the current Process Metrics + +List information about all the Sidekiq workers registered to process your queues. + +``` +GET /sidekiq/process_metrics +``` + +```bash +curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/sidekiq/process_metrics +``` + +Example response: + +```json +{ + "processes": [ + { + "hostname": "local.host", + "pid": 5649, + "tag": "gitlab", + "started_at": "2016-06-14T10:45:07.159-05:00", + "queues": [ + "post_receive", + "mailers", + "archive_repo", + "system_hook", + "project_web_hook", + "gitlab_shell", + "incoming_email", + "runner", + "common", + "default" + ], + "labels": [], + "concurrency": 25, + "busy": 0 + } + ] +} +``` + +## Get the current Job Statistics + +List information about the jobs that Sidekiq has performed. + +``` +GET /sidekiq/job_stats +``` + +```bash +curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/sidekiq/job_stats +``` + +Example response: + +```json +{ + "jobs": { + "processed": 2, + "failed": 0, + "enqueued": 0 + } +} +``` + +## Get a compound response of all the previously mentioned metrics + +List all the currently available information about Sidekiq. + +``` +GET /sidekiq/compound_metrics +``` + +```bash +curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/sidekiq/compound_metrics +``` + +Example response: + +```json +{ + "queues": { + "default": { + "backlog": 0, + "latency": 0 + } + }, + "processes": [ + { + "hostname": "local.host", + "pid": 5649, + "tag": "gitlab", + "started_at": "2016-06-14T10:45:07.159-05:00", + "queues": [ + "post_receive", + "mailers", + "archive_repo", + "system_hook", + "project_web_hook", + "gitlab_shell", + "incoming_email", + "runner", + "common", + "default" + ], + "labels": [], + "concurrency": 25, + "busy": 0 + } + ], + "jobs": { + "processed": 2, + "failed": 0, + "enqueued": 0 + } +} +``` + diff --git a/lib/api/api.rb b/lib/api/api.rb index 6cd909f611..51ddd0dbfc 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -59,5 +59,6 @@ module API mount ::API::Licenses mount ::API::Subscriptions mount ::API::Gitignores + mount ::API::SidekiqMetrics end end diff --git a/lib/api/sidekiq_metrics.rb b/lib/api/sidekiq_metrics.rb new file mode 100644 index 0000000000..f99cdb7a94 --- /dev/null +++ b/lib/api/sidekiq_metrics.rb @@ -0,0 +1,90 @@ +require 'sidekiq/api' + +module API + class SidekiqMetrics < Grape::API + before { authenticated_as_admin! } + + helpers do + def queue_metrics + Sidekiq::Queue.all.each_with_object({}) do |queue, hash| + hash[queue.name] = { + backlog: queue.size, + latency: queue.latency.to_i + } + end + end + + def process_metrics + Sidekiq::ProcessSet.new.map do |process| + { + hostname: process['hostname'], + pid: process['pid'], + tag: process['tag'], + started_at: Time.at(process['started_at']), + queues: process['queues'], + labels: process['labels'], + concurrency: process['concurrency'], + busy: process['busy'] + } + end + end + + def job_stats + stats = Sidekiq::Stats.new + { + processed: stats.processed, + failed: stats.failed, + enqueued: stats.enqueued + } + end + end + + # Get Sidekiq Queue metrics + # + # Parameters: + # None + # + # Example: + # GET /sidekiq/queue_metrics + # + get 'sidekiq/queue_metrics' do + { queues: queue_metrics } + end + + # Get Sidekiq Process metrics + # + # Parameters: + # None + # + # Example: + # GET /sidekiq/process_metrics + # + get 'sidekiq/process_metrics' do + { processes: process_metrics } + end + + # Get Sidekiq Job statistics + # + # Parameters: + # None + # + # Example: + # GET /sidekiq/job_stats + # + get 'sidekiq/job_stats' do + { jobs: job_stats } + end + + # Get Sidekiq Compound metrics. Includes all previous metrics + # + # Parameters: + # None + # + # Example: + # GET /sidekiq/compound_metrics + # + get 'sidekiq/compound_metrics' do + { queues: queue_metrics, processes: process_metrics, jobs: job_stats } + end + end +end \ No newline at end of file diff --git a/spec/requests/api/sidekiq_metrics_spec.rb b/spec/requests/api/sidekiq_metrics_spec.rb new file mode 100644 index 0000000000..e65890167b --- /dev/null +++ b/spec/requests/api/sidekiq_metrics_spec.rb @@ -0,0 +1,40 @@ +require 'spec_helper' + +describe API::SidekiqMetrics, api: true do + include ApiHelpers + + let(:admin) { create(:user, :admin) } + + describe 'GET sidekiq/*' do + it 'defines the `queue_metrics` endpoint' do + get api('/sidekiq/queue_metrics', admin) + + expect(response.status).to eq(200) + expect(json_response).to be_a Hash + end + + it 'defines the `process_metrics` endpoint' do + get api('/sidekiq/process_metrics', admin) + + expect(response.status).to eq(200) + expect(json_response['processes']).to be_an Array + end + + it 'defines the `job_stats` endpoint' do + get api('/sidekiq/job_stats', admin) + + expect(response.status).to eq(200) + expect(json_response).to be_a Hash + end + + it 'defines the `compound_metrics` endpoint' do + get api('/sidekiq/compound_metrics', admin) + + expect(response.status).to eq(200) + expect(json_response).to be_a Hash + expect(json_response['queues']).to be_a Hash + expect(json_response['processes']).to be_an Array + expect(json_response['jobs']).to be_a Hash + end + end +end \ No newline at end of file From 6023dc356a421462a1d00333b5893116e64cfd33 Mon Sep 17 00:00:00 2001 From: Patricio Cano Date: Tue, 14 Jun 2016 16:46:53 +0000 Subject: [PATCH 30/36] Revert "Added API endpoint for Sidekiq Metrics" This reverts commit 06e0ef07bcb92925e6819cbe1e33cdcf645b736b --- doc/api/README.md | 1 - doc/api/sidekiq_metrics.md | 152 ---------------------- lib/api/api.rb | 1 - lib/api/sidekiq_metrics.rb | 90 ------------- spec/requests/api/sidekiq_metrics_spec.rb | 40 ------ 5 files changed, 284 deletions(-) delete mode 100644 doc/api/sidekiq_metrics.md delete mode 100644 lib/api/sidekiq_metrics.rb delete mode 100644 spec/requests/api/sidekiq_metrics_spec.rb diff --git a/doc/api/README.md b/doc/api/README.md index 6042ef7637..e3fc5a09f2 100644 --- a/doc/api/README.md +++ b/doc/api/README.md @@ -31,7 +31,6 @@ following locations: - [Services](services.md) - [Session](session.md) - [Settings](settings.md) -- [Sidekiq Metrics](sidekiq_metrics.md) - [System Hooks](system_hooks.md) - [Tags](tags.md) - [Users](users.md) diff --git a/doc/api/sidekiq_metrics.md b/doc/api/sidekiq_metrics.md deleted file mode 100644 index de627c1a96..0000000000 --- a/doc/api/sidekiq_metrics.md +++ /dev/null @@ -1,152 +0,0 @@ -# Sidekiq Metrics - ->**Note:** This endpoint is only available on GitLab 8.9 and above. - -This API endpoint allows you to retrieve some information about the current state -of Sidekiq, it's jobs, queues, and processes. - -## Get the current Queue Metrics - -List information about all the registered queues, their backlog and their -latency. - -``` -GET /sidekiq/queue_metrics -``` - -```bash -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/sidekiq/queue_metrics -``` - -Example response: - -```json -{ - "queues": { - "default": { - "backlog": 0, - "latency": 0 - } - } -} -``` - -## Get the current Process Metrics - -List information about all the Sidekiq workers registered to process your queues. - -``` -GET /sidekiq/process_metrics -``` - -```bash -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/sidekiq/process_metrics -``` - -Example response: - -```json -{ - "processes": [ - { - "hostname": "local.host", - "pid": 5649, - "tag": "gitlab", - "started_at": "2016-06-14T10:45:07.159-05:00", - "queues": [ - "post_receive", - "mailers", - "archive_repo", - "system_hook", - "project_web_hook", - "gitlab_shell", - "incoming_email", - "runner", - "common", - "default" - ], - "labels": [], - "concurrency": 25, - "busy": 0 - } - ] -} -``` - -## Get the current Job Statistics - -List information about the jobs that Sidekiq has performed. - -``` -GET /sidekiq/job_stats -``` - -```bash -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/sidekiq/job_stats -``` - -Example response: - -```json -{ - "jobs": { - "processed": 2, - "failed": 0, - "enqueued": 0 - } -} -``` - -## Get a compound response of all the previously mentioned metrics - -List all the currently available information about Sidekiq. - -``` -GET /sidekiq/compound_metrics -``` - -```bash -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/sidekiq/compound_metrics -``` - -Example response: - -```json -{ - "queues": { - "default": { - "backlog": 0, - "latency": 0 - } - }, - "processes": [ - { - "hostname": "local.host", - "pid": 5649, - "tag": "gitlab", - "started_at": "2016-06-14T10:45:07.159-05:00", - "queues": [ - "post_receive", - "mailers", - "archive_repo", - "system_hook", - "project_web_hook", - "gitlab_shell", - "incoming_email", - "runner", - "common", - "default" - ], - "labels": [], - "concurrency": 25, - "busy": 0 - } - ], - "jobs": { - "processed": 2, - "failed": 0, - "enqueued": 0 - } -} -``` - diff --git a/lib/api/api.rb b/lib/api/api.rb index 51ddd0dbfc..6cd909f611 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -59,6 +59,5 @@ module API mount ::API::Licenses mount ::API::Subscriptions mount ::API::Gitignores - mount ::API::SidekiqMetrics end end diff --git a/lib/api/sidekiq_metrics.rb b/lib/api/sidekiq_metrics.rb deleted file mode 100644 index f99cdb7a94..0000000000 --- a/lib/api/sidekiq_metrics.rb +++ /dev/null @@ -1,90 +0,0 @@ -require 'sidekiq/api' - -module API - class SidekiqMetrics < Grape::API - before { authenticated_as_admin! } - - helpers do - def queue_metrics - Sidekiq::Queue.all.each_with_object({}) do |queue, hash| - hash[queue.name] = { - backlog: queue.size, - latency: queue.latency.to_i - } - end - end - - def process_metrics - Sidekiq::ProcessSet.new.map do |process| - { - hostname: process['hostname'], - pid: process['pid'], - tag: process['tag'], - started_at: Time.at(process['started_at']), - queues: process['queues'], - labels: process['labels'], - concurrency: process['concurrency'], - busy: process['busy'] - } - end - end - - def job_stats - stats = Sidekiq::Stats.new - { - processed: stats.processed, - failed: stats.failed, - enqueued: stats.enqueued - } - end - end - - # Get Sidekiq Queue metrics - # - # Parameters: - # None - # - # Example: - # GET /sidekiq/queue_metrics - # - get 'sidekiq/queue_metrics' do - { queues: queue_metrics } - end - - # Get Sidekiq Process metrics - # - # Parameters: - # None - # - # Example: - # GET /sidekiq/process_metrics - # - get 'sidekiq/process_metrics' do - { processes: process_metrics } - end - - # Get Sidekiq Job statistics - # - # Parameters: - # None - # - # Example: - # GET /sidekiq/job_stats - # - get 'sidekiq/job_stats' do - { jobs: job_stats } - end - - # Get Sidekiq Compound metrics. Includes all previous metrics - # - # Parameters: - # None - # - # Example: - # GET /sidekiq/compound_metrics - # - get 'sidekiq/compound_metrics' do - { queues: queue_metrics, processes: process_metrics, jobs: job_stats } - end - end -end \ No newline at end of file diff --git a/spec/requests/api/sidekiq_metrics_spec.rb b/spec/requests/api/sidekiq_metrics_spec.rb deleted file mode 100644 index e65890167b..0000000000 --- a/spec/requests/api/sidekiq_metrics_spec.rb +++ /dev/null @@ -1,40 +0,0 @@ -require 'spec_helper' - -describe API::SidekiqMetrics, api: true do - include ApiHelpers - - let(:admin) { create(:user, :admin) } - - describe 'GET sidekiq/*' do - it 'defines the `queue_metrics` endpoint' do - get api('/sidekiq/queue_metrics', admin) - - expect(response.status).to eq(200) - expect(json_response).to be_a Hash - end - - it 'defines the `process_metrics` endpoint' do - get api('/sidekiq/process_metrics', admin) - - expect(response.status).to eq(200) - expect(json_response['processes']).to be_an Array - end - - it 'defines the `job_stats` endpoint' do - get api('/sidekiq/job_stats', admin) - - expect(response.status).to eq(200) - expect(json_response).to be_a Hash - end - - it 'defines the `compound_metrics` endpoint' do - get api('/sidekiq/compound_metrics', admin) - - expect(response.status).to eq(200) - expect(json_response).to be_a Hash - expect(json_response['queues']).to be_a Hash - expect(json_response['processes']).to be_an Array - expect(json_response['jobs']).to be_a Hash - end - end -end \ No newline at end of file From 0b1eea8f970e170cd4314ec75aba9707ffa98127 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 14 Jun 2016 17:53:26 +0100 Subject: [PATCH 31/36] Removed console.log Uses outerWidth instead of width --- app/assets/javascripts/layout_nav.js.coffee | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/app/assets/javascripts/layout_nav.js.coffee b/app/assets/javascripts/layout_nav.js.coffee index f02292dd4f..f8f0aea427 100644 --- a/app/assets/javascripts/layout_nav.js.coffee +++ b/app/assets/javascripts/layout_nav.js.coffee @@ -18,13 +18,8 @@ $ -> $('.scrolling-tabs').on 'scroll', (event) -> $this = $(this) - $el = $(event.target) currentPosition = $this.scrollLeft() - size = bp.getBreakpointSize() - controlBtnWidth = $('.controls').width() - maxPosition = ($this.get(0).scrollWidth - $this.parent().width()) - 1 - # maxPosition += controlBtnWidth if size isnt 'xs' and $('.nav-control').length - console.log maxPosition, currentPosition + maxPosition = $this.prop('scrollWidth') - $this.outerWidth() - $el.find('.fade-left').toggleClass('end-scroll', currentPosition is 0) - $el.find('.fade-right').toggleClass('end-scroll', currentPosition is maxPosition) + $this.find('.fade-left').toggleClass('end-scroll', currentPosition is 0) + $this.find('.fade-right').toggleClass('end-scroll', currentPosition is maxPosition) From 72d87d6c16fc2bea4bef7a8ec246db598f4d19cf Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran Date: Mon, 13 Jun 2016 13:09:50 -0500 Subject: [PATCH 32/36] Turn off handlers before binding events --- app/assets/javascripts/issuable.js.coffee | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/issuable.js.coffee b/app/assets/javascripts/issuable.js.coffee index c244712003..edeb43eae1 100644 --- a/app/assets/javascripts/issuable.js.coffee +++ b/app/assets/javascripts/issuable.js.coffee @@ -99,11 +99,11 @@ issuable_created = false $('#filter_issue_search').val($('#issue_search').val()) initChecks: -> - $('.check_all_issues').on 'click', -> + $('.check_all_issues').off('click').on 'click', -> $('.selected_issue').prop('checked', @checked) Issuable.checkChanged() - $('.selected_issue').on 'change', Issuable.checkChanged + $('.selected_issue').off('change').on 'change', Issuable.checkChanged updateStateFilters: -> stateFilters = $('.issues-state-filters, .dropdown-menu-sort') From a0d58a83e0f1980defe560991648c6f5f1ba0d0b Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran Date: Mon, 13 Jun 2016 13:30:14 -0500 Subject: [PATCH 33/36] Reinitialize checkboxes to toggle event bindings --- app/assets/javascripts/issuable.js.coffee | 5 +++-- app/assets/javascripts/issues-bulk-assignment.js.coffee | 3 +++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/issuable.js.coffee b/app/assets/javascripts/issuable.js.coffee index edeb43eae1..31a6bb15d5 100644 --- a/app/assets/javascripts/issuable.js.coffee +++ b/app/assets/javascripts/issuable.js.coffee @@ -99,11 +99,12 @@ issuable_created = false $('#filter_issue_search').val($('#issue_search').val()) initChecks: -> - $('.check_all_issues').off('click').on 'click', -> + $('.check_all_issues').off('click').on('click', -> $('.selected_issue').prop('checked', @checked) Issuable.checkChanged() + ) - $('.selected_issue').off('change').on 'change', Issuable.checkChanged + $('.selected_issue').off('change').on('change', Issuable.checkChanged) updateStateFilters: -> stateFilters = $('.issues-state-filters, .dropdown-menu-sort') diff --git a/app/assets/javascripts/issues-bulk-assignment.js.coffee b/app/assets/javascripts/issues-bulk-assignment.js.coffee index 9dc3529a17..b454f9389d 100644 --- a/app/assets/javascripts/issues-bulk-assignment.js.coffee +++ b/app/assets/javascripts/issues-bulk-assignment.js.coffee @@ -9,6 +9,9 @@ class @IssuableBulkActions @bindEvents() + # Fixes bulk-assign not working when navigating through pages + Issuable.initChecks(); + getElement: (selector) -> @container.find selector From fef47d234a283d400c73e0581a00a59a0c770e2c Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran Date: Tue, 14 Jun 2016 00:46:56 -0500 Subject: [PATCH 34/36] Use Turbolink instead of ajax --- app/assets/javascripts/issuable.js.coffee | 52 +---------------------- 1 file changed, 1 insertion(+), 51 deletions(-) diff --git a/app/assets/javascripts/issuable.js.coffee b/app/assets/javascripts/issuable.js.coffee index 31a6bb15d5..d0901be150 100644 --- a/app/assets/javascripts/issuable.js.coffee +++ b/app/assets/javascripts/issuable.js.coffee @@ -56,13 +56,6 @@ issuable_created = false Issuable.filterResults $('.filter-form') $('.js-label-select').trigger('update.label') - toggleLabelFilters: -> - $filteredLabels = $('.filtered-labels') - if $filteredLabels.find('.label-row').length > 0 - $filteredLabels.removeClass('hidden') - else - $filteredLabels.addClass('hidden') - filterResults: (form) => formData = form.serialize() @@ -71,32 +64,8 @@ issuable_created = false issuesUrl = formAction issuesUrl += ("#{if formAction.indexOf('?') < 0 then '?' else '&'}") issuesUrl += formData - $.ajax - type: 'GET' - url: formAction - data: formData - complete: -> - $('.issues-holder, .merge-requests-holder').css('opacity', '1.0') - success: (data) -> - $('.issues-holder, .merge-requests-holder').html(data.html) - # Change url so if user reload a page - search results are saved - history.replaceState {page: issuesUrl}, document.title, issuesUrl - Issuable.reload() - Issuable.updateStateFilters() - $filteredLabels = $('.filtered-labels') - if typeof Issuable.labelRow is 'function' - $filteredLabels.html(Issuable.labelRow(data)) - - Issuable.toggleLabelFilters() - - dataType: "json" - - reload: -> - if Issuable.created - Issuable.initChecks() - - $('#filter_issue_search').val($('#issue_search').val()) + Turbolinks.visit(issuesUrl); initChecks: -> $('.check_all_issues').off('click').on('click', -> @@ -106,25 +75,6 @@ issuable_created = false $('.selected_issue').off('change').on('change', Issuable.checkChanged) - updateStateFilters: -> - stateFilters = $('.issues-state-filters, .dropdown-menu-sort') - newParams = {} - paramKeys = ['author_id', 'milestone_title', 'assignee_id', 'issue_search', 'issue_search'] - - for paramKey in paramKeys - newParams[paramKey] = gl.utils.getParameterValues(paramKey)[0] or '' - - if stateFilters.length - stateFilters.find('a').each -> - initialUrl = gl.utils.removeParamQueryString($(this).attr('href'), 'label_name[]') - labelNameValues = gl.utils.getParameterValues('label_name[]') - if labelNameValues - labelNameQueryString = ("label_name[]=#{value}" for value in labelNameValues).join('&') - newUrl = "#{gl.utils.mergeUrlParams(newParams, initialUrl)}&#{labelNameQueryString}" - else - newUrl = gl.utils.mergeUrlParams(newParams, initialUrl) - $(this).attr 'href', newUrl - checkChanged: -> checked_issues = $('.selected_issue:checked') if checked_issues.length > 0 From 363fa59712ae58bc032f24496b49398ea9a65e87 Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran Date: Tue, 14 Jun 2016 06:21:24 -0500 Subject: [PATCH 35/36] Update tests to make it work with Turbolinks approach --- spec/features/issues/filter_by_labels_spec.rb | 20 ++++++++-------- spec/features/issues/filter_issues_spec.rb | 23 +++++++++++-------- 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/spec/features/issues/filter_by_labels_spec.rb b/spec/features/issues/filter_by_labels_spec.rb index 16c619c928..5ea02b8d39 100644 --- a/spec/features/issues/filter_by_labels_spec.rb +++ b/spec/features/issues/filter_by_labels_spec.rb @@ -56,8 +56,9 @@ feature 'Issue filtering by Labels', feature: true do end it 'should remove label "bug"' do - first('.js-label-filter-remove').click - expect(find('.filtered-labels')).to have_no_content "bug" + find('.js-label-filter-remove').click + wait_for_ajax + expect(find('.filtered-labels', visible: false)).to have_no_content "bug" end end @@ -142,7 +143,8 @@ feature 'Issue filtering by Labels', feature: true do end it 'should remove label "enhancement"' do - first('.js-label-filter-remove').click + find('.js-label-filter-remove', match: :first).click + wait_for_ajax expect(find('.filtered-labels')).to have_no_content "enhancement" end end @@ -179,6 +181,7 @@ feature 'Issue filtering by Labels', feature: true do before do page.within '.labels-filter' do click_button 'Label' + wait_for_ajax click_link 'bug' find('.dropdown-menu-close').click end @@ -189,14 +192,11 @@ feature 'Issue filtering by Labels', feature: true do end it 'should allow user to remove filtered labels' do - page.within '.filtered-labels' do - first('.js-label-filter-remove').click - expect(page).not_to have_content 'bug' - end + first('.js-label-filter-remove').click + wait_for_ajax - page.within '.labels-filter' do - expect(page).not_to have_content 'bug' - end + expect(find('.filtered-labels', visible: false)).not_to have_content 'bug' + expect(find('.labels-filter')).not_to have_content 'bug' end end diff --git a/spec/features/issues/filter_issues_spec.rb b/spec/features/issues/filter_issues_spec.rb index 1f0594e6b0..4bcb105b17 100644 --- a/spec/features/issues/filter_issues_spec.rb +++ b/spec/features/issues/filter_issues_spec.rb @@ -1,6 +1,7 @@ require 'rails_helper' describe 'Filter issues', feature: true do + include WaitForAjax let!(:project) { create(:project) } let!(:user) { create(:user)} @@ -21,7 +22,7 @@ describe 'Filter issues', feature: true do find('.dropdown-menu-user-link', text: user.username).click - sleep 2 + wait_for_ajax end context 'assignee', js: true do @@ -53,7 +54,7 @@ describe 'Filter issues', feature: true do find('.milestone-filter .dropdown-content a', text: milestone.title).click - sleep 2 + wait_for_ajax end context 'milestone', js: true do @@ -80,23 +81,21 @@ describe 'Filter issues', feature: true do before do visit namespace_project_issues_path(project.namespace, project) find('.js-label-select').click + wait_for_ajax end it 'should filter by any label' do find('.dropdown-menu-labels a', text: 'Any Label').click page.first('.labels-filter .dropdown-title .dropdown-menu-close-icon').click - sleep 2 + wait_for_ajax - page.within '.labels-filter' do - expect(page).to have_content 'Any Label' - end - expect(find('.js-label-select .dropdown-toggle-text')).to have_content('Any Label') + expect(find('.labels-filter')).to have_content 'Label' end it 'should filter by no label' do find('.dropdown-menu-labels a', text: 'No Label').click page.first('.labels-filter .dropdown-title .dropdown-menu-close-icon').click - sleep 2 + wait_for_ajax page.within '.labels-filter' do expect(page).to have_content 'No Label' @@ -122,14 +121,14 @@ describe 'Filter issues', feature: true do find('.dropdown-menu-user-link', text: user.username).click - sleep 2 + wait_for_ajax find('.js-label-select').click find('.dropdown-menu-labels .dropdown-content a', text: label.title).click page.first('.labels-filter .dropdown-title .dropdown-menu-close-icon').click - sleep 2 + wait_for_ajax end context 'assignee and label', js: true do @@ -276,9 +275,12 @@ describe 'Filter issues', feature: true do it 'should be able to filter and sort issues' do click_button 'Label' + wait_for_ajax page.within '.labels-filter' do click_link 'bug' end + find('.dropdown-menu-close-icon').click + wait_for_ajax page.within '.issues-list' do expect(page).to have_selector('.issue', count: 2) @@ -288,6 +290,7 @@ describe 'Filter issues', feature: true do page.within '.dropdown-menu-sort' do click_link 'Oldest created' end + wait_for_ajax page.within '.issues-list' do expect(first('.issue')).to have_content('Frontend') From de3a9d7ef1c1c9bfdfe1cf7e984ed20c3dcce3ba Mon Sep 17 00:00:00 2001 From: Annabel Dunstone Date: Wed, 8 Jun 2016 18:46:17 -0500 Subject: [PATCH 36/36] Update CHANGELOG --- CHANGELOG | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index 162c6723dd..780cd8e91e 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -81,6 +81,11 @@ v 8.9.0 (unreleased) - Instrument private methods and private instance methods by default instead just public methods - Updated the allocations Gem to version 1.0.5 - The background sampler now ignores classes without names + - Update design for `Close` buttons + - New custom icons for navigation + - Horizontally scrolling navigation on project, group, and profile settings pages + - Hide global side navigation by default + - Remove tanuki logo from side navigation; center on top nav v 8.8.5 (unreleased) - Ensure branch cleanup regardless of whether the GitHub import process succeeds