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
include FiltersEvents
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 :projects, only: [:index]
skip_cross_project_access_check :index, :starred
......
......@@ -26,6 +26,7 @@
= 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
= _("Explore projects")
= render_if_exists "dashboard/removed_projects_tab", removed_projects_count: @removed_projects_count
- unless feature_project_list_filter_bar
.nav-controls
= 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
- 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'
= render partial: 'dashboard/projects/shared/common', locals: {page_title: _('Starred Projects'), empty_page: 'starred_empty_state'}
......@@ -64,6 +64,8 @@
.description.d-none.d-sm-block.gl-mr-3
= 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(" ") }
.icon-container.d-flex.align-items-center
- 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
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_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
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_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
extend ActiveSupport::Concern
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
override :preload_associations
......@@ -13,6 +32,17 @@ module EE
super.with_compliance_framework_settings
.with_group_saml_provider
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
......@@ -11,6 +11,13 @@ module EE
def preload_associations(projects)
super.with_compliance_framework_settings
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
......@@ -17,6 +17,7 @@ module EE
def filter_projects(collection)
collection = super(collection)
collection = by_plans(collection)
collection = by_aimed_for_deletion(collection)
collection
end
......@@ -27,5 +28,13 @@ module EE
collection
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
......@@ -246,6 +246,10 @@ module EE
project&.compliance_framework_setting&.present?
end
def scheduled_for_deletion?(project)
project.marked_for_deletion_at.present?
end
private
def get_project_security_nav_tabs(project, current_user)
......
......@@ -13,6 +13,10 @@ module EE
License.feature_available?(:pages_size_limit)
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 { admin }.policy do
......@@ -30,6 +34,10 @@ module EE
rule { ~(admin | allow_to_manage_default_branch_protection) }.policy do
prevent :create_group_with_default_branch_protection
end
rule { admin & adjourned_project_deletion_available }.policy do
enable :list_removable_projects
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
it { is_expected.to contain_exactly(gold_project, gold_project2, silver_project, no_plan_project) }
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
def create_project(plan)
......
......@@ -263,4 +263,16 @@ RSpec.describe ProjectsHelper do
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
......@@ -195,4 +195,46 @@ RSpec.describe GlobalPolicy do
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
......@@ -14444,6 +14444,9 @@ msgstr ""
msgid "Markdown is supported"
msgstr ""
msgid "Marked For Deletion At - %{deletion_time}"
msgstr ""
msgid "Marked To Do as done."
msgstr ""
......@@ -19850,6 +19853,9 @@ msgstr ""
msgid "Removed %{type} with id %{id}"
msgstr ""
msgid "Removed Projects"
msgstr ""
msgid "Removed all labels."
msgstr ""
......@@ -19862,6 +19868,9 @@ msgstr ""
msgid "Removed parent epic %{epic_ref}."
msgstr ""
msgid "Removed projects"
msgstr ""
msgid "Removed projects cannot be restored!"
msgstr ""
......@@ -19874,6 +19883,12 @@ msgstr ""
msgid "Removed time estimate."
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}."
msgstr ""
......@@ -20354,6 +20369,9 @@ msgstr ""
msgid "Restart Terminal"
msgstr ""
msgid "Restore"
msgstr ""
msgid "Restore group"
msgstr ""
......@@ -20653,6 +20671,9 @@ msgstr ""
msgid "Scheduled"
msgstr ""
msgid "Scheduled Deletion At - %{permanent_deletion_time}"
msgstr ""
msgid "Scheduled to merge this merge request (%{strategy})."
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