Commit 5fabac0a authored by Aishwarya Subramanian's avatar Aishwarya Subramanian

List removable Projects to admins

Admins can now view Projects that are about to
be deleted (delayed deletion) in the Project
Overview Dashboard.
They can also choose to restore any of the
Projects listed.
The Projects will be listed until they are
permanently deleted.
parent f3caeb39
...@@ -9,7 +9,7 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController ...@@ -9,7 +9,7 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
include FiltersEvents include FiltersEvents
prepend_before_action(only: [:index]) { authenticate_sessionless_user!(:rss) } prepend_before_action(only: [:index]) { authenticate_sessionless_user!(:rss) }
before_action :set_non_archived_param before_action :set_non_archived_param, only: [:index, :starred]
before_action :set_sorting before_action :set_sorting
before_action :projects, only: [:index] before_action :projects, only: [:index]
skip_cross_project_access_check :index, :starred skip_cross_project_access_check :index, :starred
......
...@@ -26,6 +26,7 @@ ...@@ -26,6 +26,7 @@
= nav_link(page: [explore_root_path, trending_explore_projects_path, starred_explore_projects_path, explore_projects_path]) do = nav_link(page: [explore_root_path, trending_explore_projects_path, starred_explore_projects_path, explore_projects_path]) do
= link_to explore_root_path, data: {placement: 'right'} do = link_to explore_root_path, data: {placement: 'right'} do
= _("Explore projects") = _("Explore projects")
= render_if_exists "dashboard/removed_projects_tab", removed_projects_count: @removed_projects_count
- unless feature_project_list_filter_bar - unless feature_project_list_filter_bar
.nav-controls .nav-controls
= render 'shared/projects/search_form' = render 'shared/projects/search_form'
......
- @hide_top_links = true
- breadcrumb_title _("Projects")
- header_title _("Projects"), dashboard_projects_path
= render_dashboard_gold_trial(current_user)
= render "projects/last_push"
= render 'dashboard/projects_head', project_tab_filter: :starred
- if params[:filter_projects] || any_projects?(@projects)
= render 'projects'
- else
= render empty_page
- @hide_top_links = true = render partial: 'dashboard/projects/shared/common', locals: {page_title: _('Starred Projects'), empty_page: 'starred_empty_state'}
- breadcrumb_title _("Projects")
- page_title _("Starred Projects")
- header_title _("Projects"), dashboard_projects_path
= render_dashboard_gold_trial(current_user)
= render "projects/last_push"
= render 'dashboard/projects_head', project_tab_filter: :starred
- if params[:filter_projects] || any_projects?(@projects)
= render 'projects'
- else
= render 'starred_empty_state'
...@@ -64,6 +64,8 @@ ...@@ -64,6 +64,8 @@
.description.d-none.d-sm-block.gl-mr-3 .description.d-none.d-sm-block.gl-mr-3
= markdown_field(project, :description) = markdown_field(project, :description)
= render_if_exists 'shared/projects/removed', project: project
.controls.d-flex.flex-sm-column.align-items-center.align-items-sm-end.flex-wrap.flex-shrink-0.text-secondary{ class: css_controls_class.join(" ") } .controls.d-flex.flex-sm-column.align-items-center.align-items-sm-end.flex-wrap.flex-shrink-0.text-secondary{ class: css_controls_class.join(" ") }
.icon-container.d-flex.align-items-center .icon-container.d-flex.align-items-center
- if show_pipeline_status_icon - if show_pipeline_status_icon
......
# frozen_string_literal: true
class AddIndexToProjectsAimedForDeletion < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
PROJECTS_AIMED_FOR_DELETION_INDEX_NAME = "index_projects_aimed_for_deletion"
MARKED_FOR_DELETION_PROJECTS_INDEX_NAME = "index_projects_on_marked_for_deletion_at"
disable_ddl_transaction!
def up
add_concurrent_index :projects,
:marked_for_deletion_at,
where: "marked_for_deletion_at IS NOT NULL AND pending_delete = false",
name: PROJECTS_AIMED_FOR_DELETION_INDEX_NAME
remove_concurrent_index_by_name :projects, MARKED_FOR_DELETION_PROJECTS_INDEX_NAME
end
def down
remove_concurrent_index_by_name :projects, PROJECTS_AIMED_FOR_DELETION_INDEX_NAME
add_concurrent_index :projects, :marked_for_deletion_at, where: 'marked_for_deletion_at IS NOT NULL'
end
end
7409688836e7375423b45d69e6c7b82c6a946c0306435ec341bf216e3f97190f
\ No newline at end of file
...@@ -20220,6 +20220,8 @@ CREATE INDEX index_project_statistics_on_wiki_size_and_project_id ON public.proj ...@@ -20220,6 +20220,8 @@ CREATE INDEX index_project_statistics_on_wiki_size_and_project_id ON public.proj
CREATE UNIQUE INDEX index_project_tracing_settings_on_project_id ON public.project_tracing_settings USING btree (project_id); CREATE UNIQUE INDEX index_project_tracing_settings_on_project_id ON public.project_tracing_settings USING btree (project_id);
CREATE INDEX index_projects_aimed_for_deletion ON public.projects USING btree (marked_for_deletion_at) WHERE ((marked_for_deletion_at IS NOT NULL) AND (pending_delete = false));
CREATE INDEX index_projects_api_created_at_id_desc ON public.projects USING btree (created_at, id DESC); CREATE INDEX index_projects_api_created_at_id_desc ON public.projects USING btree (created_at, id DESC);
CREATE INDEX index_projects_api_created_at_id_for_archived ON public.projects USING btree (created_at, id) WHERE ((archived = true) AND (pending_delete = false)); CREATE INDEX index_projects_api_created_at_id_for_archived ON public.projects USING btree (created_at, id) WHERE ((archived = true) AND (pending_delete = false));
...@@ -20270,8 +20272,6 @@ CREATE INDEX index_projects_on_last_repository_updated_at ON public.projects USI ...@@ -20270,8 +20272,6 @@ CREATE INDEX index_projects_on_last_repository_updated_at ON public.projects USI
CREATE INDEX index_projects_on_lower_name ON public.projects USING btree (lower((name)::text)); CREATE INDEX index_projects_on_lower_name ON public.projects USING btree (lower((name)::text));
CREATE INDEX index_projects_on_marked_for_deletion_at ON public.projects USING btree (marked_for_deletion_at) WHERE (marked_for_deletion_at IS NOT NULL);
CREATE INDEX index_projects_on_marked_for_deletion_by_user_id ON public.projects USING btree (marked_for_deletion_by_user_id) WHERE (marked_for_deletion_by_user_id IS NOT NULL); CREATE INDEX index_projects_on_marked_for_deletion_by_user_id ON public.projects USING btree (marked_for_deletion_by_user_id) WHERE (marked_for_deletion_by_user_id IS NOT NULL);
CREATE INDEX index_projects_on_mirror_creator_id_created_at ON public.projects USING btree (creator_id, created_at) WHERE ((mirror = true) AND (mirror_trigger_builds = true)); CREATE INDEX index_projects_on_mirror_creator_id_created_at ON public.projects USING btree (creator_id, created_at) WHERE ((mirror = true) AND (mirror_trigger_builds = true));
......
...@@ -6,6 +6,25 @@ module EE ...@@ -6,6 +6,25 @@ module EE
extend ActiveSupport::Concern extend ActiveSupport::Concern
extend ::Gitlab::Utils::Override extend ::Gitlab::Utils::Override
prepended do
before_action :check_adjourned_deletion_listing_availability, only: [:removed]
end
# rubocop:disable Gitlab/ModuleWithInstanceVariables
def removed
@projects = load_projects(params.merge(aimed_for_deletion: true))
respond_to do |format|
format.html
format.json do
render json: {
html: view_to_html_string("dashboard/projects/_projects", projects: @projects)
}
end
end
end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
private private
override :preload_associations override :preload_associations
...@@ -13,6 +32,17 @@ module EE ...@@ -13,6 +32,17 @@ module EE
super.with_compliance_framework_settings super.with_compliance_framework_settings
.with_group_saml_provider .with_group_saml_provider
end end
override :load_projects
def load_projects(finder_params)
@removed_projects_count = ::ProjectsFinder.new(params: { aimed_for_deletion: true }, current_user: current_user).execute # rubocop:disable Gitlab/ModuleWithInstanceVariables
super
end
def check_adjourned_deletion_listing_availability
return render_404 unless can?(current_user, :list_removable_projects)
end
end end
end end
end end
...@@ -11,6 +11,13 @@ module EE ...@@ -11,6 +11,13 @@ module EE
def preload_associations(projects) def preload_associations(projects)
super.with_compliance_framework_settings super.with_compliance_framework_settings
end end
override :load_project_counts
def load_project_counts
@removed_projects_count = ::ProjectsFinder.new(params: { aimed_for_deletion: true }, current_user: current_user).execute # rubocop:disable Gitlab/ModuleWithInstanceVariables
super
end
end end
end end
end end
...@@ -17,6 +17,7 @@ module EE ...@@ -17,6 +17,7 @@ module EE
def filter_projects(collection) def filter_projects(collection)
collection = super(collection) collection = super(collection)
collection = by_plans(collection) collection = by_plans(collection)
collection = by_aimed_for_deletion(collection)
collection collection
end end
...@@ -27,5 +28,13 @@ module EE ...@@ -27,5 +28,13 @@ module EE
collection collection
end end
end end
def by_aimed_for_deletion(items)
if ::Gitlab::Utils.to_boolean(params[:aimed_for_deletion])
items.aimed_for_deletion(Date.current)
else
items
end
end
end end
end end
...@@ -246,6 +246,10 @@ module EE ...@@ -246,6 +246,10 @@ module EE
project&.compliance_framework_setting&.present? project&.compliance_framework_setting&.present?
end end
def scheduled_for_deletion?(project)
project.marked_for_deletion_at.present?
end
private private
def get_project_security_nav_tabs(project, current_user) def get_project_security_nav_tabs(project, current_user)
......
...@@ -13,6 +13,10 @@ module EE ...@@ -13,6 +13,10 @@ module EE
License.feature_available?(:pages_size_limit) License.feature_available?(:pages_size_limit)
end end
condition(:adjourned_project_deletion_available) do
License.feature_available?(:adjourned_deletion_for_projects_and_groups)
end
rule { ~anonymous & operations_dashboard_available }.enable :read_operations_dashboard rule { ~anonymous & operations_dashboard_available }.enable :read_operations_dashboard
rule { admin }.policy do rule { admin }.policy do
...@@ -30,6 +34,10 @@ module EE ...@@ -30,6 +34,10 @@ module EE
rule { ~(admin | allow_to_manage_default_branch_protection) }.policy do rule { ~(admin | allow_to_manage_default_branch_protection) }.policy do
prevent :create_group_with_default_branch_protection prevent :create_group_with_default_branch_protection
end end
rule { admin & adjourned_project_deletion_available }.policy do
enable :list_removable_projects
end
end end
end end
end end
- if can?(current_user, :list_removable_projects)
= nav_link(page: removed_dashboard_projects_path) do
= link_to removed_dashboard_projects_path, data: {placement: 'right'} do
= _("Removed projects")
%span.badge.badge-pill= limited_counter_with_delimiter(removed_projects_count)
.row.empty-state
.col-12
.svg-content.svg-250
= image_tag 'illustrations/erased-log_empty.svg'
.text-content
%h4.gl-text-center
= s_("RemovedProjects|You haven’t removed any projects.")
%p.gl-text-gray-700
= s_("RemovedProjects|Projects which are removed and are yet to be permanently removed are visible here.")
= render partial: 'dashboard/projects/shared/common', locals: { page_title: _('Removed Projects'), empty_page: 'removed_empty_state' }
- if current_user&.admin? && scheduled_for_deletion?(project)
.gl-display-flex.gl-align-items-center.gl-flex-wrap.project-title
%span.small
= _("Marked For Deletion At - %{deletion_time}") % { deletion_time: project.marked_for_deletion_at.strftime(Date::DATE_FORMATS[:medium]) }
.gl-display-flex.gl-align-items-center.gl-flex-wrap.project-title
%p.small
= _("Scheduled Deletion At - %{permanent_deletion_time}") % { permanent_deletion_time: DateTime.parse(permanent_deletion_date(project.marked_for_deletion_at)).strftime(Date::DATE_FORMATS[:medium]) }
.gl-display-flex.gl-align-items-center.gl-flex-wrap.project-title
%span.small
= link_to project_restore_path(project),
class: "gl-display-flex gl-align-items-center icon-wrapper stars has-tooltip",
title: _('Restore'), data: { container: 'body', placement: 'top' },
method: :post do
= _("Restore")
---
title: Removed Projects Page in Admin UI with restoration button
merge_request: 37014
author: Ashesh Vidyut
type: added
# frozen_string_literal: true
resource :dashboard, controller: 'dashboard', only: [] do
scope module: :dashboard do
resources :projects, only: [:index] do
collection do
get :removed
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Dashboard::ProjectsController do
let_it_be(:user) { create(:user) }
describe '#removed' do
render_views
subject { get :removed, format: :json }
before do
sign_in(user)
allow(Kaminari.config).to receive(:default_per_page).and_return(1)
end
shared_examples 'returns not found' do
it do
subject
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'when licensed' do
before do
stub_licensed_features(adjourned_deletion_for_projects_and_groups: true)
end
context 'for admin users', :enable_admin_mode do
let_it_be(:user) { create(:admin) }
let_it_be(:projects) { create_list(:project, 2, :archived, creator: user, marked_for_deletion_at: 3.days.ago) }
it 'returns success' do
subject
expect(response).to have_gitlab_http_status(:ok)
end
it 'paginates the records' do
subject
expect(assigns(:projects).count).to eq(1)
end
it 'accounts total removable projects' do
subject
expect(assigns(:removed_projects_count).count).to eq(2)
end
end
context 'for non-admin users' do
it_behaves_like 'returns not found'
end
end
context 'when not licensed' do
before do
stub_licensed_features(adjourned_deletion_for_projects_and_groups: false)
end
it_behaves_like 'returns not found'
end
end
end
...@@ -48,6 +48,14 @@ RSpec.describe ProjectsFinder do ...@@ -48,6 +48,14 @@ RSpec.describe ProjectsFinder do
it { is_expected.to contain_exactly(gold_project, gold_project2, silver_project, no_plan_project) } it { is_expected.to contain_exactly(gold_project, gold_project2, silver_project, no_plan_project) }
end end
context 'filter by aimed for deletion' do
let_it_be(:params) { { aimed_for_deletion: true } }
let_it_be(:aimed_for_deletion_project) { create(:project, :public, marked_for_deletion_at: 2.days.ago, pending_delete: false) }
let_it_be(:deleted_project) { create(:project, :public, marked_for_deletion_at: 1.month.ago, pending_delete: true) }
it { is_expected.to contain_exactly(aimed_for_deletion_project) }
end
private private
def create_project(plan) def create_project(plan)
......
...@@ -263,4 +263,16 @@ RSpec.describe ProjectsHelper do ...@@ -263,4 +263,16 @@ RSpec.describe ProjectsHelper do
end end
end end
end end
describe '#scheduled_for_deletion?' do
context 'when project is NOT scheduled for deletion' do
it { expect(helper.scheduled_for_deletion?(project)).to be false }
end
context 'when project is scheduled for deletion' do
let_it_be(:archived_project) { create(:project, :archived, marked_for_deletion_at: 10.minutes.ago) }
it { expect(helper.scheduled_for_deletion?(archived_project)).to be true }
end
end
end end
...@@ -195,4 +195,46 @@ RSpec.describe GlobalPolicy do ...@@ -195,4 +195,46 @@ RSpec.describe GlobalPolicy do
end end
end end
end end
describe 'list_removable_projects' do
context 'when user is an admin', :enable_admin_mode do
let_it_be(:current_user) { admin }
before do
stub_licensed_features(adjourned_deletion_for_projects_and_groups: licensed?)
end
context 'when licensed feature is enabled' do
let(:licensed?) { true }
it { is_expected.to be_allowed(:list_removable_projects) }
end
context 'when licensed feature is enabled' do
let(:licensed?) { false }
it { is_expected.to be_disallowed(:list_removable_projects) }
end
end
context 'when user is a normal user' do
let_it_be(:current_user) { create(:user) }
before do
stub_licensed_features(adjourned_deletion_for_projects_and_groups: licensed?)
end
context 'when licensed feature is enabled' do
let(:licensed?) { true }
it { is_expected.to be_disallowed(:list_removable_projects) }
end
context 'when licensed feature is enabled' do
let(:licensed?) { false }
it { is_expected.to be_disallowed(:list_removable_projects) }
end
end
end
end end
...@@ -14444,6 +14444,9 @@ msgstr "" ...@@ -14444,6 +14444,9 @@ msgstr ""
msgid "Markdown is supported" msgid "Markdown is supported"
msgstr "" msgstr ""
msgid "Marked For Deletion At - %{deletion_time}"
msgstr ""
msgid "Marked To Do as done." msgid "Marked To Do as done."
msgstr "" msgstr ""
...@@ -19850,6 +19853,9 @@ msgstr "" ...@@ -19850,6 +19853,9 @@ msgstr ""
msgid "Removed %{type} with id %{id}" msgid "Removed %{type} with id %{id}"
msgstr "" msgstr ""
msgid "Removed Projects"
msgstr ""
msgid "Removed all labels." msgid "Removed all labels."
msgstr "" msgstr ""
...@@ -19862,6 +19868,9 @@ msgstr "" ...@@ -19862,6 +19868,9 @@ msgstr ""
msgid "Removed parent epic %{epic_ref}." msgid "Removed parent epic %{epic_ref}."
msgstr "" msgstr ""
msgid "Removed projects"
msgstr ""
msgid "Removed projects cannot be restored!" msgid "Removed projects cannot be restored!"
msgstr "" msgstr ""
...@@ -19874,6 +19883,12 @@ msgstr "" ...@@ -19874,6 +19883,12 @@ msgstr ""
msgid "Removed time estimate." msgid "Removed time estimate."
msgstr "" msgstr ""
msgid "RemovedProjects|Projects which are removed and are yet to be permanently removed are visible here."
msgstr ""
msgid "RemovedProjects|You haven’t removed any projects."
msgstr ""
msgid "Removes %{assignee_text} %{assignee_references}." msgid "Removes %{assignee_text} %{assignee_references}."
msgstr "" msgstr ""
...@@ -20354,6 +20369,9 @@ msgstr "" ...@@ -20354,6 +20369,9 @@ msgstr ""
msgid "Restart Terminal" msgid "Restart Terminal"
msgstr "" msgstr ""
msgid "Restore"
msgstr ""
msgid "Restore group" msgid "Restore group"
msgstr "" msgstr ""
...@@ -20653,6 +20671,9 @@ msgstr "" ...@@ -20653,6 +20671,9 @@ msgstr ""
msgid "Scheduled" msgid "Scheduled"
msgstr "" msgstr ""
msgid "Scheduled Deletion At - %{permanent_deletion_time}"
msgstr ""
msgid "Scheduled to merge this merge request (%{strategy})." msgid "Scheduled to merge this merge request (%{strategy})."
msgstr "" msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment