Commit b1772d19 authored by Jan Provaznik's avatar Jan Provaznik

Merge branch 'issue_32894' into 'master'

Allow to soft delete issuables description history

See merge request gitlab-org/gitlab!21439
parents 8cdd821d 3acea8b0
...@@ -124,7 +124,7 @@ class Note < ApplicationRecord ...@@ -124,7 +124,7 @@ class Note < ApplicationRecord
scope :inc_author, -> { includes(:author) } scope :inc_author, -> { includes(:author) }
scope :inc_relations_for_view, -> do scope :inc_relations_for_view, -> do
includes(:project, { author: :status }, :updated_by, :resolved_by, :award_emoji, includes(:project, { author: :status }, :updated_by, :resolved_by, :award_emoji,
:system_note_metadata, :note_diff_file, :suggestions) { system_note_metadata: :description_version }, :note_diff_file, :suggestions)
end end
scope :with_notes_filter, -> (notes_filter) do scope :with_notes_filter, -> (notes_filter) do
......
# frozen_string_literal: true
class AddDeletedAtToDescriptionVersions < ActiveRecord::Migration[5.2]
DOWNTIME = false
def change
add_column :description_versions, :deleted_at, :datetime_with_timezone
end
end
...@@ -1410,6 +1410,7 @@ ActiveRecord::Schema.define(version: 2020_01_21_132641) do ...@@ -1410,6 +1410,7 @@ ActiveRecord::Schema.define(version: 2020_01_21_132641) do
t.integer "merge_request_id" t.integer "merge_request_id"
t.integer "epic_id" t.integer "epic_id"
t.text "description" t.text "description"
t.datetime_with_timezone "deleted_at"
t.index ["epic_id"], name: "index_description_versions_on_epic_id", where: "(epic_id IS NOT NULL)" t.index ["epic_id"], name: "index_description_versions_on_epic_id", where: "(epic_id IS NOT NULL)"
t.index ["issue_id"], name: "index_description_versions_on_issue_id", where: "(issue_id IS NOT NULL)" t.index ["issue_id"], name: "index_description_versions_on_issue_id", where: "(issue_id IS NOT NULL)"
t.index ["merge_request_id"], name: "index_description_versions_on_merge_request_id", where: "(merge_request_id IS NOT NULL)" t.index ["merge_request_id"], name: "index_description_versions_on_merge_request_id", where: "(merge_request_id IS NOT NULL)"
......
...@@ -2,22 +2,55 @@ ...@@ -2,22 +2,55 @@
module DescriptionDiffActions module DescriptionDiffActions
extend ActiveSupport::Concern extend ActiveSupport::Concern
include Gitlab::Utils::StrongMemoize
included do
before_action :verify_description_diffs_enabled!, only: [:description_diff, :delete_description_version]
before_action :authorize_delete_description_version!, only: :delete_description_version
end
def description_diff def description_diff
return render_404 unless issuable.resource_parent.feature_available?(:description_diffs) return render_404 if previous_description_version.nil?
diff = Gitlab::Diff::CharDiff.new(previous_description_version.description, description_version.description)
diff.generate_diff
render html: diff.to_html
end
def delete_description_version
description_version.delete!(start_id: params[:start_version_id])
head :ok
rescue ActiveRecord::RecordNotFound
render_404
end
current_version = issuable.description_versions.find(params[:version_id]) private
previous_version = if params[:start_version_id].present?
issuable.description_versions.find(params[:start_version_id]).previous_version def previous_description_version
strong_memoize(:previous_description_version) do
if params[:start_version_id].present?
issuable.description_versions.visible.find(params[:start_version_id]).previous_version
else else
current_version.previous_version description_version.previous_version
end
end
end
def description_version
strong_memoize(:description_version) do
issuable.description_versions.visible.find(params[:version_id])
end
end end
return render_404 if previous_version.nil? def verify_description_diffs_enabled!
return render_404 unless issuable.resource_parent.feature_available?(:description_diffs)
end
diff = Gitlab::Diff::CharDiff.new(previous_version.description, current_version.description) def authorize_delete_description_version!
diff.generate_diff rule = "admin_#{issuable.class.to_ability_name}"
render html: diff.to_html return render_404 unless can?(current_user, rule, issuable)
end end
end end
...@@ -43,5 +43,16 @@ module EE ...@@ -43,5 +43,16 @@ module EE
description_diff_group_epic_path(issuable.group, issuable, version_id) description_diff_group_epic_path(issuable.group, issuable, version_id)
end end
end end
def delete_description_version_path(issuable, version_id)
case issuable
when Issue
delete_description_version_project_issue_path(issuable.project, issuable, version_id)
when MergeRequest
delete_description_version_project_merge_request_path(issuable.project, issuable, version_id)
when Epic
delete_description_version_group_epic_path(issuable.group, issuable, version_id)
end
end
end end
end end
...@@ -6,6 +6,10 @@ module EE ...@@ -6,6 +6,10 @@ module EE
prepended do prepended do
belongs_to :epic belongs_to :epic
# This scope is using `deleted_at` column which is not indexed.
# Prevent using it in not scoped contexts.
scope :visible, -> { where(deleted_at: nil) }
end end
class_methods do class_methods do
...@@ -19,13 +23,36 @@ module EE ...@@ -19,13 +23,36 @@ module EE
end end
def previous_version def previous_version
issuable_description_versions
.where('created_at < ?', created_at)
.order(created_at: :desc, id: :desc)
.first
end
# Soft deletes a description version.
# If start_id is given it soft deletes current version
# up to start_id of the same issuable.
def delete!(start_id: nil)
start_id ||= self.id
description_versions =
issuable_description_versions.where('id BETWEEN ? AND ?', start_id, self.id)
description_versions.update_all(deleted_at: Time.now)
end
def deleted?
self.deleted_at.present?
end
private
def issuable_description_versions
self.class.where( self.class.where(
issue_id: issue_id, issue_id: issue_id,
merge_request_id: merge_request_id, merge_request_id: merge_request_id,
epic_id: epic_id epic_id: epic_id
).where('created_at < ?', created_at) )
.order(created_at: :desc, id: :desc)
.first
end end
end end
end end
...@@ -7,9 +7,22 @@ module EE ...@@ -7,9 +7,22 @@ module EE
prepended do prepended do
with_options if: -> (note, _) { note.system? && note.resource_parent.feature_available?(:description_diffs) } do with_options if: -> (note, _) { note.system? && note.resource_parent.feature_available?(:description_diffs) } do
expose :description_version_id expose :description_version_id
expose :description_diff_path, if: -> (_) { description_version_id } do |note| expose :description_diff_path, if: -> (_) { description_version_id } do |note|
description_diff_path(note.noteable, description_version_id) description_diff_path(note.noteable, description_version_id)
end end
expose :delete_description_version_path, if: -> (_) { description_version_id } do |note|
delete_description_version_path(note.noteable, description_version_id)
end
expose :can_delete_description_version do |note|
rule = "admin_#{object.noteable.class.to_ability_name}"
Ability.allowed?(current_user, rule, object.noteable.resource_parent)
end
expose :description_version_deleted
end end
private private
...@@ -17,6 +30,10 @@ module EE ...@@ -17,6 +30,10 @@ module EE
def description_version_id def description_version_id
object.system_note_metadata&.description_version_id object.system_note_metadata&.description_version_id
end end
def description_version_deleted
object.system_note_metadata&.description_version&.deleted?
end
end end
end end
end end
---
title: Allow to soft delete issuables description history
merge_request: 21439
author:
type: added
...@@ -74,6 +74,7 @@ constraints(::Constraints::GroupUrlConstrainer.new) do ...@@ -74,6 +74,7 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
resources :epics, concerns: :awardable, constraints: { id: /\d+/ } do resources :epics, concerns: :awardable, constraints: { id: /\d+/ } do
member do member do
get '/descriptions/:version_id/diff', action: :description_diff, as: :description_diff get '/descriptions/:version_id/diff', action: :description_diff, as: :description_diff
delete '/descriptions/:version_id', action: :delete_description_version, as: :delete_description_version
get :discussions, format: :json get :discussions, format: :json
get :realtime_changes get :realtime_changes
post :toggle_subscription post :toggle_subscription
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
resources :merge_requests, only: [], constraints: { id: /\d+/ } do resources :merge_requests, only: [], constraints: { id: /\d+/ } do
member do member do
get '/descriptions/:version_id/diff', action: :description_diff, as: :description_diff get '/descriptions/:version_id/diff', action: :description_diff, as: :description_diff
delete '/descriptions/:version_id', action: :delete_description_version, as: :delete_description_version
get :metrics_reports get :metrics_reports
get :license_management_reports get :license_management_reports
get :container_scanning_reports get :container_scanning_reports
......
...@@ -126,6 +126,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do ...@@ -126,6 +126,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
resources :issues, only: [], constraints: { id: /\d+/ } do resources :issues, only: [], constraints: { id: /\d+/ } do
member do member do
get '/descriptions/:version_id/diff', action: :description_diff, as: :description_diff get '/descriptions/:version_id/diff', action: :description_diff, as: :description_diff
delete '/descriptions/:version_id', action: :delete_description_version, as: :delete_description_version
get '/designs(/*vueroute)', to: 'issues#designs', as: :designs, format: false get '/designs(/*vueroute)', to: 'issues#designs', as: :designs, format: false
end end
......
...@@ -32,4 +32,51 @@ describe DescriptionVersion do ...@@ -32,4 +32,51 @@ describe DescriptionVersion do
expect(current_version.previous_version).to eq(previous_version) expect(current_version.previous_version).to eq(previous_version)
end end
end end
describe '#delete!' do
let_it_be(:issue) { create(:issue) }
let_it_be(:merge_request) { create(:merge_request) }
let_it_be(:epic) { create(:epic) }
before_all do
2.times do
create(:description_version, issue: issue)
create_list(:description_version, 2, epic: epic)
create(:description_version, merge_request: merge_request)
end
end
def deleted_count
DescriptionVersion
.where('issue_id = ? or epic_id = ? or merge_request_id = ?', issue.id, epic.id, merge_request.id)
.where('deleted_at IS NOT NULL')
.count
end
context 'when start_id is not present' do
it 'only soft deletes description_version' do
version = epic.description_versions.last
version.delete!
expect(version.reload.deleted_at).to be_present
expect(deleted_count).to eq(1)
end
end
context 'when start_id is present' do
it 'soft deletes description versions of same issuable up to start_id' do
description_version = epic.description_versions.last.previous_version
starting_version = epic.description_versions.second
description_version.delete!(start_id: starting_version.id)
expect(epic.description_versions.first.deleted_at).to be_nil
expect(epic.description_versions.second.deleted_at).to be_present
expect(epic.description_versions.third.deleted_at).to be_present
expect(epic.description_versions.fourth.deleted_at).to be_nil
expect(deleted_count).to eq(2)
end
end
end
end end
...@@ -19,9 +19,11 @@ describe NoteEntity do ...@@ -19,9 +19,11 @@ describe NoteEntity do
stub_licensed_features(description_diffs: true) stub_licensed_features(description_diffs: true)
end end
it 'includes version id and diff path' do it 'includes description versions attributes' do
expect(subject[:description_version_id]).to eq(description_version.id) expect(subject[:description_version_id]).to eq(description_version.id)
expect(subject[:description_diff_path]).to eq(description_diff_project_issue_path(issue.project, issue, description_version.id)) expect(subject[:description_diff_path]).to eq(description_diff_project_issue_path(issue.project, issue, description_version.id))
expect(subject[:delete_description_version_path]).to eq(delete_description_version_project_issue_path(issue.project, issue, description_version.id))
expect(subject[:can_delete_description_version]).to eq(true)
end end
end end
...@@ -30,9 +32,11 @@ describe NoteEntity do ...@@ -30,9 +32,11 @@ describe NoteEntity do
stub_licensed_features(description_diffs: false) stub_licensed_features(description_diffs: false)
end end
it 'does not include version id and diff path' do it 'does not include description versions attributes' do
expect(subject[:description_version_id]).to be_nil expect(subject[:description_version_id]).to be_nil
expect(subject[:description_diff_path]).to be_nil expect(subject[:description_diff_path]).to be_nil
expect(subject[:delete_description_version_path]).to be_nil
expect(subject[:can_delete_description_version]).to be_nil
end end
end end
end end
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
RSpec.shared_examples DescriptionDiffActions do RSpec.shared_examples DescriptionDiffActions do
let(:base_params) { { namespace_id: project.namespace, project_id: project, id: issuable } } let(:base_params) { { namespace_id: project.namespace, project_id: project, id: issuable } }
describe 'GET description_diff' do describe do
let_it_be(:version_1) { create(:description_version, issuable.class.name.underscore => issuable) } let_it_be(:version_1) { create(:description_version, issuable.class.name.underscore => issuable) }
let_it_be(:version_2) { create(:description_version, issuable.class.name.underscore => issuable) } let_it_be(:version_2) { create(:description_version, issuable.class.name.underscore => issuable) }
let_it_be(:version_3) { create(:description_version, issuable.class.name.underscore => issuable) } let_it_be(:version_3) { create(:description_version, issuable.class.name.underscore => issuable) }
...@@ -12,11 +12,16 @@ RSpec.shared_examples DescriptionDiffActions do ...@@ -12,11 +12,16 @@ RSpec.shared_examples DescriptionDiffActions do
get :description_diff, params: base_params.merge(extra_params) get :description_diff, params: base_params.merge(extra_params)
end end
def delete_description_version(extra_params = {})
delete :delete_description_version, params: base_params.merge(extra_params)
end
context 'when license is available' do context 'when license is available' do
before do before do
stub_licensed_features(epics: true, description_diffs: true) stub_licensed_features(epics: true, description_diffs: true)
end end
context 'GET description_diff' do
it 'returns the diff with the previous version' do it 'returns the diff with the previous version' do
expect(Gitlab::Diff::CharDiff).to receive(:new).with(version_2.description, version_3.description).and_call_original expect(Gitlab::Diff::CharDiff).to receive(:new).with(version_2.description, version_3.description).and_call_original
...@@ -52,6 +57,76 @@ RSpec.shared_examples DescriptionDiffActions do ...@@ -52,6 +57,76 @@ RSpec.shared_examples DescriptionDiffActions do
expect(response.status).to eq(404) expect(response.status).to eq(404)
end end
end end
context 'when start_version_id is deleted' do
it 'returns 404' do
version_2.delete!
get_description_diff(version_id: version_3, start_version_id: version_2)
expect(response.status).to eq(404)
end
end
context 'when description version is deleted' do
it 'returns 404' do
version_3.delete!
delete_description_version(version_id: version_3)
expect(response.status).to eq(404)
end
end
end
context 'DELETE description_diff' do
before do
developer_user = create(:user)
issuable.resource_parent.add_developer(developer_user)
sign_in(developer_user)
end
it 'returns 200' do
delete_description_version(version_id: version_3)
expect(response.status).to eq(200)
expect(version_3.reload.deleted_at).to be_present
end
context 'when start_version_id is present' do
it 'returns 200' do
delete_description_version(version_id: version_3, start_version_id: version_1)
expect(response.status).to eq(200)
expect(version_1.reload.deleted_at).to be_present
expect(version_2.reload.deleted_at).to be_present
expect(version_3.reload.deleted_at).to be_present
end
end
context 'when version is already deleted' do
it 'returns 404' do
version_3.delete!
delete_description_version(version_id: version_3)
expect(response.status).to eq(404)
end
end
context 'when user cannot admin issuable' do
it 'returns 404' do
guest_user = create(:user)
issuable.resource_parent.add_guest(guest_user)
sign_in(guest_user)
delete_description_version(version_id: version_3)
expect(response.status).to eq(404)
expect(version_3.reload.deleted_at).to be_nil
end
end
end
end end
context 'when license is not available' do context 'when license is not available' do
...@@ -59,11 +134,21 @@ RSpec.shared_examples DescriptionDiffActions do ...@@ -59,11 +134,21 @@ RSpec.shared_examples DescriptionDiffActions do
stub_licensed_features(epics: true, description_diffs: false) stub_licensed_features(epics: true, description_diffs: false)
end end
context 'GET description_diff' do
it 'returns 404' do it 'returns 404' do
get_description_diff(version_id: version_3) get_description_diff(version_id: version_3)
expect(response.status).to eq(404) expect(response.status).to eq(404)
end end
end end
context 'DELETE description_diff' do
it 'returns 404' do
delete_description_version(version_id: version_3)
expect(response.status).to eq(404)
end
end
end
end end
end end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment