260 lines
7.9 KiB
Ruby
260 lines
7.9 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
module DesignManagement
|
|
class Design < ApplicationRecord
|
|
include AtomicInternalId
|
|
include Importable
|
|
include Noteable
|
|
include Gitlab::FileTypeDetection
|
|
include Gitlab::Utils::StrongMemoize
|
|
include Referable
|
|
include Mentionable
|
|
include WhereComposite
|
|
include RelativePositioning
|
|
include Todoable
|
|
include Participable
|
|
|
|
belongs_to :project, inverse_of: :designs
|
|
belongs_to :issue
|
|
|
|
has_many :actions
|
|
has_many :versions, through: :actions, class_name: 'DesignManagement::Version', inverse_of: :designs
|
|
has_many :authors, -> { distinct }, through: :versions, class_name: 'User'
|
|
# This is a polymorphic association, so we can't count on FK's to delete the
|
|
# data
|
|
has_many :notes, as: :noteable, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
|
|
has_many :user_mentions, class_name: 'DesignUserMention', dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
|
|
|
|
has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
|
|
|
|
has_internal_id :iid, scope: :project, presence: true,
|
|
hook_names: %i[create update], # Deal with old records
|
|
track_if: -> { !importing? }
|
|
|
|
validates :project, :filename, presence: true
|
|
validates :issue, presence: true, unless: :importing?
|
|
validates :filename, uniqueness: { scope: :issue_id }, length: { maximum: 255 }
|
|
validate :validate_file_is_image
|
|
|
|
alias_attribute :title, :filename
|
|
|
|
participant :authors
|
|
participant :notes_with_associations
|
|
|
|
# Pre-fetching scope to include the data necessary to construct a
|
|
# reference using `to_reference`.
|
|
scope :for_reference, -> { includes(issue: [{ project: [:route, :namespace] }]) }
|
|
|
|
# A design can be uniquely identified by issue_id and filename
|
|
# Takes one or more sets of composite IDs of the form:
|
|
# `{issue_id: Integer, filename: String}`.
|
|
#
|
|
# @see WhereComposite::where_composite
|
|
#
|
|
# e.g:
|
|
#
|
|
# by_issue_id_and_filename(issue_id: 1, filename: 'homescreen.jpg')
|
|
# by_issue_id_and_filename([]) # returns ActiveRecord::NullRelation
|
|
# by_issue_id_and_filename([
|
|
# { issue_id: 1, filename: 'homescreen.jpg' },
|
|
# { issue_id: 2, filename: 'homescreen.jpg' },
|
|
# { issue_id: 1, filename: 'menu.png' }
|
|
# ])
|
|
#
|
|
scope :by_issue_id_and_filename, ->(composites) do
|
|
where_composite(%i[issue_id filename], composites)
|
|
end
|
|
|
|
# Find designs visible at the given version
|
|
#
|
|
# @param version [nil, DesignManagement::Version]:
|
|
# the version at which the designs must be visible
|
|
# Passing `nil` is the same as passing the most current version
|
|
#
|
|
# Restricts to designs
|
|
# - created at least *before* the given version
|
|
# - not deleted as of the given version.
|
|
#
|
|
# As a query, we ascertain this by finding the last event prior to
|
|
# (or equal to) the cut-off, and seeing whether that version was a deletion.
|
|
scope :visible_at_version, -> (version) do
|
|
deletion = ::DesignManagement::Action.events[:deletion]
|
|
designs = arel_table
|
|
actions = ::DesignManagement::Action
|
|
.most_recent.up_to_version(version)
|
|
.arel.as('most_recent_actions')
|
|
|
|
join = designs.join(actions)
|
|
.on(actions[:design_id].eq(designs[:id]))
|
|
|
|
joins(join.join_sources).where(actions[:event].not_eq(deletion))
|
|
end
|
|
|
|
scope :ordered, -> do
|
|
# We need to additionally sort by `id` to support keyset pagination.
|
|
# See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/17788/diffs#note_230875678
|
|
order(:relative_position, :id)
|
|
end
|
|
|
|
scope :in_creation_order, -> { reorder(:id) }
|
|
|
|
scope :with_filename, -> (filenames) { where(filename: filenames) }
|
|
scope :on_issue, ->(issue) { where(issue_id: issue) }
|
|
|
|
# Scope called by our REST API to avoid N+1 problems
|
|
scope :with_api_entity_associations, -> { preload(:issue) }
|
|
|
|
# A design is current if the most recent event is not a deletion
|
|
scope :current, -> { visible_at_version(nil) }
|
|
|
|
def self.relative_positioning_query_base(design)
|
|
default_scoped.on_issue(design.issue_id)
|
|
end
|
|
|
|
def self.relative_positioning_parent_column
|
|
:issue_id
|
|
end
|
|
|
|
def status
|
|
if new_design?
|
|
:new
|
|
elsif deleted?
|
|
:deleted
|
|
else
|
|
:current
|
|
end
|
|
end
|
|
|
|
def deleted?
|
|
most_recent_action&.deletion?
|
|
end
|
|
|
|
# A design is visible_in? a version if:
|
|
# * it was created before that version
|
|
# * the most recent action before the version was not a deletion
|
|
def visible_in?(version)
|
|
map = strong_memoize(:visible_in) do
|
|
Hash.new do |h, k|
|
|
h[k] = self.class.visible_at_version(k).where(id: id).exists?
|
|
end
|
|
end
|
|
|
|
map[version]
|
|
end
|
|
|
|
def most_recent_action
|
|
strong_memoize(:most_recent_action) { actions.ordered.last }
|
|
end
|
|
|
|
# A reference for a design is the issue reference, indexed by the filename
|
|
# with an optional infix when full.
|
|
#
|
|
# e.g.
|
|
# #123[homescreen.png]
|
|
# other-project#72[sidebar.jpg]
|
|
# #38/designs[transition.gif]
|
|
# #12["filename with [] in it.jpg"]
|
|
def to_reference(from = nil, full: false)
|
|
infix = full ? '/designs' : ''
|
|
safe_name = Sanitize.fragment(filename)
|
|
|
|
"#{issue.to_reference(from, full: full)}#{infix}[#{safe_name}]"
|
|
end
|
|
|
|
def self.reference_pattern
|
|
# no-op: We only support link_reference_pattern parsing
|
|
end
|
|
|
|
def self.link_reference_pattern
|
|
@link_reference_pattern ||= begin
|
|
path_segment = %r{issues/#{Gitlab::Regex.issue}/designs}
|
|
ext = Regexp.new(Regexp.union(SAFE_IMAGE_EXT + DANGEROUS_IMAGE_EXT).source, Regexp::IGNORECASE)
|
|
valid_char = %r{[[:word:]\.\-\+]}
|
|
filename_pattern = %r{
|
|
(?<url_filename> #{valid_char}+ \. #{ext})
|
|
}x
|
|
|
|
super(path_segment, filename_pattern)
|
|
end
|
|
end
|
|
|
|
def self.build_full_path(issue, design)
|
|
File.join(DesignManagement.designs_directory, "issue-#{issue.iid}", design.filename)
|
|
end
|
|
|
|
def description
|
|
''
|
|
end
|
|
|
|
def new_design?
|
|
strong_memoize(:new_design) { actions.none? }
|
|
end
|
|
|
|
def full_path
|
|
@full_path ||= self.class.build_full_path(issue, self)
|
|
end
|
|
|
|
def diff_refs
|
|
strong_memoize(:diff_refs) { head_version&.diff_refs }
|
|
end
|
|
|
|
def clear_version_cache
|
|
[versions, actions].each(&:reset)
|
|
%i[new_design diff_refs head_sha visible_in most_recent_action].each do |key|
|
|
clear_memoization(key)
|
|
end
|
|
end
|
|
|
|
def repository
|
|
project.design_repository
|
|
end
|
|
|
|
def user_notes_count
|
|
user_notes_count_service.count
|
|
end
|
|
|
|
def after_note_changed(note)
|
|
user_notes_count_service.delete_cache unless note.system?
|
|
end
|
|
alias_method :after_note_created, :after_note_changed
|
|
alias_method :after_note_destroyed, :after_note_changed
|
|
|
|
# Part of the interface of objects we can create events about
|
|
def resource_parent
|
|
project
|
|
end
|
|
|
|
def notes_with_associations
|
|
notes.includes(:author)
|
|
end
|
|
|
|
private
|
|
|
|
def head_version
|
|
strong_memoize(:head_sha) { versions.ordered.first }
|
|
end
|
|
|
|
def allow_dangerous_images?
|
|
Feature.enabled?(:design_management_allow_dangerous_images, project)
|
|
end
|
|
|
|
def valid_file_extensions
|
|
allow_dangerous_images? ? (SAFE_IMAGE_EXT + DANGEROUS_IMAGE_EXT) : SAFE_IMAGE_EXT
|
|
end
|
|
|
|
def validate_file_is_image
|
|
unless image? || (dangerous_image? && allow_dangerous_images?)
|
|
message = _('does not have a supported extension. Only %{extension_list} are supported') % {
|
|
extension_list: valid_file_extensions.to_sentence
|
|
}
|
|
errors.add(:filename, message)
|
|
end
|
|
end
|
|
|
|
def user_notes_count_service
|
|
strong_memoize(:user_notes_count_service) do
|
|
::DesignManagement::DesignUserNotesCountService.new(self) # rubocop: disable CodeReuse/ServiceClass
|
|
end
|
|
end
|
|
end
|
|
end
|