summaryrefslogtreecommitdiff
path: root/app/models/concerns
diff options
context:
space:
mode:
Diffstat (limited to 'app/models/concerns')
-rw-r--r--app/models/concerns/async_devise_email.rb14
-rw-r--r--app/models/concerns/awardable.rb43
-rw-r--r--app/models/concerns/cache_markdown_field.rb1
-rw-r--r--app/models/concerns/ci/contextable.rb8
-rw-r--r--app/models/concerns/diff_positionable_note.rb4
-rw-r--r--app/models/concerns/has_repository.rb1
-rw-r--r--app/models/concerns/has_user_type.rb45
-rw-r--r--app/models/concerns/has_wiki.rb44
-rw-r--r--app/models/concerns/issuable.rb29
-rw-r--r--app/models/concerns/issue_resource_event.rb13
-rw-r--r--app/models/concerns/limitable.rb27
-rw-r--r--app/models/concerns/merge_request_resource_event.rb11
-rw-r--r--app/models/concerns/milestoneable.rb2
-rw-r--r--app/models/concerns/noteable.rb14
-rw-r--r--app/models/concerns/prometheus_adapter.rb1
-rw-r--r--app/models/concerns/protected_ref_access.rb4
-rw-r--r--app/models/concerns/reactive_caching.rb15
-rw-r--r--app/models/concerns/redis_cacheable.rb6
-rw-r--r--app/models/concerns/spammable.rb33
-rw-r--r--app/models/concerns/state_eventable.rb9
-rw-r--r--app/models/concerns/storage/legacy_project_wiki.rb11
-rw-r--r--app/models/concerns/timebox.rb204
-rw-r--r--app/models/concerns/update_project_statistics.rb14
23 files changed, 482 insertions, 71 deletions
diff --git a/app/models/concerns/async_devise_email.rb b/app/models/concerns/async_devise_email.rb
new file mode 100644
index 00000000000..38c99dc7e71
--- /dev/null
+++ b/app/models/concerns/async_devise_email.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module AsyncDeviseEmail
+ extend ActiveSupport::Concern
+
+ private
+
+ # Added according to https://github.com/plataformatec/devise/blob/7df57d5081f9884849ca15e4fde179ef164a575f/README.md#activejob-integration
+ def send_devise_notification(notification, *args)
+ return true unless can?(:receive_notifications)
+
+ devise_mailer.__send__(notification, self, *args).deliver_later # rubocop:disable GitlabSecurity/PublicSend
+ end
+end
diff --git a/app/models/concerns/awardable.rb b/app/models/concerns/awardable.rb
index 0f2a389f0a3..896f0916d8c 100644
--- a/app/models/concerns/awardable.rb
+++ b/app/models/concerns/awardable.rb
@@ -14,32 +14,29 @@ module Awardable
class_methods do
def awarded(user, name = nil)
- sql = <<~EOL
- EXISTS (
- SELECT TRUE
- FROM award_emoji
- WHERE user_id = :user_id AND
- #{"name = :name AND" if name.present?}
- awardable_type = :awardable_type AND
- awardable_id = #{self.arel_table.name}.id
- )
- EOL
+ award_emoji_table = Arel::Table.new('award_emoji')
+ inner_query = award_emoji_table
+ .project('true')
+ .where(award_emoji_table[:user_id].eq(user.id))
+ .where(award_emoji_table[:awardable_type].eq(self.name))
+ .where(award_emoji_table[:awardable_id].eq(self.arel_table[:id]))
+
+ inner_query = inner_query.where(award_emoji_table[:name].eq(name)) if name.present?
- where(sql, user_id: user.id, name: name, awardable_type: self.name)
+ where(inner_query.exists)
end
- def not_awarded(user)
- sql = <<~EOL
- NOT EXISTS (
- SELECT TRUE
- FROM award_emoji
- WHERE user_id = :user_id AND
- awardable_type = :awardable_type AND
- awardable_id = #{self.arel_table.name}.id
- )
- EOL
+ def not_awarded(user, name = nil)
+ award_emoji_table = Arel::Table.new('award_emoji')
+ inner_query = award_emoji_table
+ .project('true')
+ .where(award_emoji_table[:user_id].eq(user.id))
+ .where(award_emoji_table[:awardable_type].eq(self.name))
+ .where(award_emoji_table[:awardable_id].eq(self.arel_table[:id]))
+
+ inner_query = inner_query.where(award_emoji_table[:name].eq(name)) if name.present?
- where(sql, user_id: user.id, awardable_type: self.name)
+ where(inner_query.exists.not)
end
def order_upvotes_desc
@@ -77,7 +74,7 @@ module Awardable
# By default we always load award_emoji user association
awards = award_emoji.group_by(&:name)
- if with_thumbs
+ if with_thumbs && (!project || project.show_default_award_emojis?)
awards[AwardEmoji::UPVOTE_NAME] ||= []
awards[AwardEmoji::DOWNVOTE_NAME] ||= []
end
diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb
index cc13f279c4d..e4e0f55d5f4 100644
--- a/app/models/concerns/cache_markdown_field.rb
+++ b/app/models/concerns/cache_markdown_field.rb
@@ -161,7 +161,6 @@ module CacheMarkdownField
define_method(invalidation_method) do
changed_fields = changed_attributes.keys
invalidations = changed_fields & [markdown_field.to_s, *INVALIDATED_BY]
- invalidations.delete(markdown_field.to_s) if changed_fields.include?("#{markdown_field}_html")
!invalidations.empty? || !cached_html_up_to_date?(markdown_field)
end
end
diff --git a/app/models/concerns/ci/contextable.rb b/app/models/concerns/ci/contextable.rb
index 5ff537a7837..ccd90ea5900 100644
--- a/app/models/concerns/ci/contextable.rb
+++ b/app/models/concerns/ci/contextable.rb
@@ -18,6 +18,8 @@ module Ci
variables.concat(deployment_variables(environment: environment))
variables.concat(yaml_variables)
variables.concat(user_variables)
+ variables.concat(dependency_variables) if Feature.enabled?(:ci_dependency_variables, project)
+ variables.concat(secret_instance_variables)
variables.concat(secret_group_variables)
variables.concat(secret_project_variables(environment: environment))
variables.concat(trigger_request.user_variables) if trigger_request
@@ -81,6 +83,12 @@ module Ci
)
end
+ def secret_instance_variables
+ return [] unless ::Feature.enabled?(:ci_instance_level_variables, project, default_enabled: true)
+
+ project.ci_instance_variables_for(ref: git_ref)
+ end
+
def secret_group_variables
return [] unless project.group
diff --git a/app/models/concerns/diff_positionable_note.rb b/app/models/concerns/diff_positionable_note.rb
index 6484a3157b1..cea3c7d119c 100644
--- a/app/models/concerns/diff_positionable_note.rb
+++ b/app/models/concerns/diff_positionable_note.rb
@@ -17,12 +17,14 @@ module DiffPositionableNote
%i(original_position position change_position).each do |meth|
define_method "#{meth}=" do |new_position|
if new_position.is_a?(String)
- new_position = JSON.parse(new_position) rescue nil
+ new_position = Gitlab::Json.parse(new_position) rescue nil
end
if new_position.is_a?(Hash)
new_position = new_position.with_indifferent_access
new_position = Gitlab::Diff::Position.new(new_position)
+ elsif !new_position.is_a?(Gitlab::Diff::Position)
+ new_position = nil
end
return if new_position == read_attribute(meth)
diff --git a/app/models/concerns/has_repository.rb b/app/models/concerns/has_repository.rb
index af7afd6604a..29d31b8bb4f 100644
--- a/app/models/concerns/has_repository.rb
+++ b/app/models/concerns/has_repository.rb
@@ -9,7 +9,6 @@
# needs any special behavior.
module HasRepository
extend ActiveSupport::Concern
- include AfterCommitQueue
include Referable
include Gitlab::ShellAdapter
include Gitlab::Utils::StrongMemoize
diff --git a/app/models/concerns/has_user_type.rb b/app/models/concerns/has_user_type.rb
new file mode 100644
index 00000000000..8a238dc736c
--- /dev/null
+++ b/app/models/concerns/has_user_type.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module HasUserType
+ extend ActiveSupport::Concern
+
+ USER_TYPES = {
+ human: nil,
+ support_bot: 1,
+ alert_bot: 2,
+ visual_review_bot: 3,
+ service_user: 4,
+ ghost: 5,
+ project_bot: 6,
+ migration_bot: 7
+ }.with_indifferent_access.freeze
+
+ BOT_USER_TYPES = %w[alert_bot project_bot support_bot visual_review_bot migration_bot].freeze
+ NON_INTERNAL_USER_TYPES = %w[human project_bot service_user].freeze
+ INTERNAL_USER_TYPES = (USER_TYPES.keys - NON_INTERNAL_USER_TYPES).freeze
+
+ included do
+ scope :humans, -> { where(user_type: :human) }
+ scope :bots, -> { where(user_type: BOT_USER_TYPES) }
+ scope :bots_without_project_bot, -> { where(user_type: BOT_USER_TYPES - ['project_bot']) }
+ scope :non_internal, -> { humans.or(where(user_type: NON_INTERNAL_USER_TYPES)) }
+ scope :without_ghosts, -> { humans.or(where.not(user_type: :ghost)) }
+ scope :without_project_bot, -> { humans.or(where.not(user_type: :project_bot)) }
+
+ enum user_type: USER_TYPES
+
+ def human?
+ super || user_type.nil?
+ end
+ end
+
+ def bot?
+ BOT_USER_TYPES.include?(user_type)
+ end
+
+ # The explicit check for project_bot will be removed with Bot Categorization
+ # Ref: https://gitlab.com/gitlab-org/gitlab/-/issues/213945
+ def internal?
+ ghost? || (bot? && !project_bot?)
+ end
+end
diff --git a/app/models/concerns/has_wiki.rb b/app/models/concerns/has_wiki.rb
new file mode 100644
index 00000000000..4dd72216e77
--- /dev/null
+++ b/app/models/concerns/has_wiki.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module HasWiki
+ extend ActiveSupport::Concern
+
+ included do
+ validate :check_wiki_path_conflict
+ end
+
+ def create_wiki
+ wiki.wiki
+ true
+ rescue Wiki::CouldNotCreateWikiError
+ errors.add(:base, _('Failed to create wiki'))
+ false
+ end
+
+ def wiki
+ strong_memoize(:wiki) do
+ Wiki.for_container(self, self.owner)
+ end
+ end
+
+ def wiki_repository_exists?
+ wiki.repository_exists?
+ end
+
+ def after_wiki_activity
+ true
+ end
+
+ private
+
+ def check_wiki_path_conflict
+ return if path.blank?
+
+ path_to_check = path.ends_with?('.wiki') ? path.chomp('.wiki') : "#{path}.wiki"
+
+ if Project.in_namespace(parent_id).where(path: path_to_check).exists? ||
+ GroupsFinder.new(nil, parent: parent_id).execute.where(path: path_to_check).exists?
+ errors.add(:name, _('has already been taken'))
+ end
+ end
+end
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index 37f2209b9d2..a1b14dca4ac 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -115,9 +115,31 @@ module Issuable
end
# rubocop:enable GitlabSecurity/SqlInjection
+ scope :not_assigned_to, ->(users) do
+ assignees_table = Arel::Table.new("#{to_ability_name}_assignees")
+ sql = assignees_table.project('true')
+ .where(assignees_table[:user_id].in(users))
+ .where(Arel::Nodes::SqlLiteral.new("#{to_ability_name}_id = #{to_ability_name}s.id"))
+ where(sql.exists.not)
+ end
+
+ scope :without_particular_labels, ->(label_names) do
+ labels_table = Label.arel_table
+ label_links_table = LabelLink.arel_table
+ issuables_table = klass.arel_table
+ inner_query = label_links_table.project('true')
+ .join(labels_table, Arel::Nodes::InnerJoin).on(labels_table[:id].eq(label_links_table[:label_id]))
+ .where(label_links_table[:target_type].eq(name)
+ .and(label_links_table[:target_id].eq(issuables_table[:id]))
+ .and(labels_table[:title].in(label_names)))
+ .exists.not
+
+ where(inner_query)
+ end
+
scope :without_label, -> { joins("LEFT OUTER JOIN label_links ON label_links.target_type = '#{name}' AND label_links.target_id = #{table_name}.id").where(label_links: { id: nil }) }
scope :with_label_ids, ->(label_ids) { joins(:label_links).where(label_links: { label_id: label_ids }) }
- scope :any_label, -> { joins(:label_links).group(:id) }
+ scope :any_label, -> { joins(:label_links).distinct }
scope :join_project, -> { joins(:project) }
scope :inc_notes_with_associations, -> { includes(notes: [:project, :author, :award_emoji]) }
scope :references_project, -> { references(:project) }
@@ -286,9 +308,8 @@ module Issuable
.reorder(Gitlab::Database.nulls_last_order('highest_priority', direction))
end
- def with_label(title, sort = nil, not_query: false)
- multiple_labels = title.is_a?(Array) && title.size > 1
- if multiple_labels && !not_query
+ def with_label(title, sort = nil)
+ if title.is_a?(Array) && title.size > 1
joins(:labels).where(labels: { title: title }).group(*grouping_columns(sort)).having("COUNT(DISTINCT labels.title) = #{title.size}")
else
joins(:labels).where(labels: { title: title })
diff --git a/app/models/concerns/issue_resource_event.rb b/app/models/concerns/issue_resource_event.rb
new file mode 100644
index 00000000000..1c24032dbbb
--- /dev/null
+++ b/app/models/concerns/issue_resource_event.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module IssueResourceEvent
+ extend ActiveSupport::Concern
+
+ included do
+ belongs_to :issue
+
+ scope :by_issue, ->(issue) { where(issue_id: issue.id) }
+
+ scope :by_issue_ids_and_created_at_earlier_or_equal_to, ->(issue_ids, time) { where(issue_id: issue_ids).where('created_at <= ?', time) }
+ end
+end
diff --git a/app/models/concerns/limitable.rb b/app/models/concerns/limitable.rb
new file mode 100644
index 00000000000..f320f54bb82
--- /dev/null
+++ b/app/models/concerns/limitable.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Limitable
+ extend ActiveSupport::Concern
+
+ included do
+ class_attribute :limit_scope
+ class_attribute :limit_name
+ self.limit_name = self.name.demodulize.tableize
+
+ validate :validate_plan_limit_not_exceeded, on: :create
+ end
+
+ private
+
+ def validate_plan_limit_not_exceeded
+ scope_relation = self.public_send(limit_scope) # rubocop:disable GitlabSecurity/PublicSend
+ return unless scope_relation
+
+ relation = self.class.where(limit_scope => scope_relation)
+
+ if scope_relation.actual_limits.exceeded?(limit_name, relation)
+ errors.add(:base, _("Maximum number of %{name} (%{count}) exceeded") %
+ { name: limit_name.humanize(capitalize: false), count: scope_relation.actual_limits.public_send(limit_name) }) # rubocop:disable GitlabSecurity/PublicSend
+ end
+ end
+end
diff --git a/app/models/concerns/merge_request_resource_event.rb b/app/models/concerns/merge_request_resource_event.rb
new file mode 100644
index 00000000000..7fb7fb4ec62
--- /dev/null
+++ b/app/models/concerns/merge_request_resource_event.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module MergeRequestResourceEvent
+ extend ActiveSupport::Concern
+
+ included do
+ belongs_to :merge_request
+
+ scope :by_merge_request, ->(merge_request) { where(merge_request_id: merge_request.id) }
+ end
+end
diff --git a/app/models/concerns/milestoneable.rb b/app/models/concerns/milestoneable.rb
index 3ffb32f94fc..8f8494a9678 100644
--- a/app/models/concerns/milestoneable.rb
+++ b/app/models/concerns/milestoneable.rb
@@ -17,8 +17,10 @@ module Milestoneable
scope :of_milestones, ->(ids) { where(milestone_id: ids) }
scope :any_milestone, -> { where('milestone_id IS NOT NULL') }
scope :with_milestone, ->(title) { left_joins_milestones.where(milestones: { title: title }) }
+ scope :without_particular_milestone, ->(title) { left_outer_joins(:milestone).where("milestones.title != ? OR milestone_id IS NULL", title) }
scope :any_release, -> { joins_milestone_releases }
scope :with_release, -> (tag, project_id) { joins_milestone_releases.where( milestones: { releases: { tag: tag, project_id: project_id } } ) }
+ scope :without_particular_release, -> (tag, project_id) { joins_milestone_releases.where.not( milestones: { releases: { tag: tag, project_id: project_id } } ) }
scope :left_joins_milestones, -> { joins("LEFT OUTER JOIN milestones ON #{table_name}.milestone_id = milestones.id") }
scope :order_milestone_due_desc, -> { left_joins_milestones.reorder(Arel.sql('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date DESC')) }
diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb
index a7f1fb66a88..933a0b167e2 100644
--- a/app/models/concerns/noteable.rb
+++ b/app/models/concerns/noteable.rb
@@ -17,7 +17,7 @@ module Noteable
# `Noteable` class names that support resolvable notes.
def resolvable_types
- %w(MergeRequest)
+ %w(MergeRequest DesignManagement::Design)
end
end
@@ -138,15 +138,25 @@ module Noteable
end
def note_etag_key
+ return Gitlab::Routing.url_helpers.designs_project_issue_path(project, issue, { vueroute: filename }) if self.is_a?(DesignManagement::Design)
+
Gitlab::Routing.url_helpers.project_noteable_notes_path(
project,
target_type: self.class.name.underscore,
target_id: id
)
end
+
+ def after_note_created(_note)
+ # no-op
+ end
+
+ def after_note_destroyed(_note)
+ # no-op
+ end
end
Noteable.extend(Noteable::ClassMethods)
-Noteable::ClassMethods.prepend_if_ee('EE::Noteable::ClassMethods') # rubocop: disable Cop/InjectEnterpriseEditionModule
+Noteable::ClassMethods.prepend_if_ee('EE::Noteable::ClassMethods')
Noteable.prepend_if_ee('EE::Noteable')
diff --git a/app/models/concerns/prometheus_adapter.rb b/app/models/concerns/prometheus_adapter.rb
index abc41a1c476..761a151a474 100644
--- a/app/models/concerns/prometheus_adapter.rb
+++ b/app/models/concerns/prometheus_adapter.rb
@@ -9,6 +9,7 @@ module PrometheusAdapter
self.reactive_cache_lease_timeout = 30.seconds
self.reactive_cache_refresh_interval = 30.seconds
self.reactive_cache_lifetime = 1.minute
+ self.reactive_cache_work_type = :external_dependency
def prometheus_client
raise NotImplementedError
diff --git a/app/models/concerns/protected_ref_access.rb b/app/models/concerns/protected_ref_access.rb
index 7373f006d64..d1e3d9b2aff 100644
--- a/app/models/concerns/protected_ref_access.rb
+++ b/app/models/concerns/protected_ref_access.rb
@@ -50,8 +50,8 @@ module ProtectedRefAccess
end
end
-ProtectedRefAccess.include_if_ee('EE::ProtectedRefAccess::Scopes') # rubocop: disable Cop/InjectEnterpriseEditionModule
-ProtectedRefAccess.prepend_if_ee('EE::ProtectedRefAccess') # rubocop: disable Cop/InjectEnterpriseEditionModule
+ProtectedRefAccess.include_if_ee('EE::ProtectedRefAccess::Scopes')
+ProtectedRefAccess.prepend_if_ee('EE::ProtectedRefAccess')
# When using `prepend` (or `include` for that matter), the `ClassMethods`
# constants are not merged. This means that `class_methods` in
diff --git a/app/models/concerns/reactive_caching.rb b/app/models/concerns/reactive_caching.rb
index 4b472cfdf45..d294563139c 100644
--- a/app/models/concerns/reactive_caching.rb
+++ b/app/models/concerns/reactive_caching.rb
@@ -8,6 +8,11 @@ module ReactiveCaching
InvalidateReactiveCache = Class.new(StandardError)
ExceededReactiveCacheLimit = Class.new(StandardError)
+ WORK_TYPE = {
+ default: ReactiveCachingWorker,
+ external_dependency: ExternalServiceReactiveCachingWorker
+ }.freeze
+
included do
extend ActiveModel::Naming
@@ -16,6 +21,7 @@ module ReactiveCaching
class_attribute :reactive_cache_refresh_interval
class_attribute :reactive_cache_lifetime
class_attribute :reactive_cache_hard_limit
+ class_attribute :reactive_cache_work_type
class_attribute :reactive_cache_worker_finder
# defaults
@@ -24,6 +30,7 @@ module ReactiveCaching
self.reactive_cache_refresh_interval = 1.minute
self.reactive_cache_lifetime = 10.minutes
self.reactive_cache_hard_limit = 1.megabyte
+ self.reactive_cache_work_type = :default
self.reactive_cache_worker_finder = ->(id, *_args) do
find_by(primary_key => id)
end
@@ -112,7 +119,7 @@ module ReactiveCaching
def refresh_reactive_cache!(*args)
clear_reactive_cache!(*args)
keep_alive_reactive_cache!(*args)
- ReactiveCachingWorker.perform_async(self.class, id, *args)
+ worker_class.perform_async(self.class, id, *args)
end
def keep_alive_reactive_cache!(*args)
@@ -145,7 +152,11 @@ module ReactiveCaching
def enqueuing_update(*args)
yield
- ReactiveCachingWorker.perform_in(self.class.reactive_cache_refresh_interval, self.class, id, *args)
+ worker_class.perform_in(self.class.reactive_cache_refresh_interval, self.class, id, *args)
+ end
+
+ def worker_class
+ WORK_TYPE.fetch(self.class.reactive_cache_work_type.to_sym)
end
def check_exceeded_reactive_cache_limit!(data)
diff --git a/app/models/concerns/redis_cacheable.rb b/app/models/concerns/redis_cacheable.rb
index 4bb4ffe2a8e..2d4ed51ce3b 100644
--- a/app/models/concerns/redis_cacheable.rb
+++ b/app/models/concerns/redis_cacheable.rb
@@ -26,7 +26,7 @@ module RedisCacheable
end
def cache_attributes(values)
- Gitlab::Redis::SharedState.with do |redis|
+ Gitlab::Redis::Cache.with do |redis|
redis.set(cache_attribute_key, values.to_json, ex: CACHED_ATTRIBUTES_EXPIRY_TIME)
end
@@ -41,9 +41,9 @@ module RedisCacheable
def cached_attributes
strong_memoize(:cached_attributes) do
- Gitlab::Redis::SharedState.with do |redis|
+ Gitlab::Redis::Cache.with do |redis|
data = redis.get(cache_attribute_key)
- JSON.parse(data, symbolize_names: true) if data
+ Gitlab::Json.parse(data, symbolize_names: true) if data
end
end
end
diff --git a/app/models/concerns/spammable.rb b/app/models/concerns/spammable.rb
index 4fbb5dcb649..9cd1a22b203 100644
--- a/app/models/concerns/spammable.rb
+++ b/app/models/concerns/spammable.rb
@@ -13,9 +13,13 @@ module Spammable
has_one :user_agent_detail, as: :subject, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
attr_accessor :spam
+ attr_accessor :needs_recaptcha
attr_accessor :spam_log
+
alias_method :spam?, :spam
+ alias_method :needs_recaptcha?, :needs_recaptcha
+ # if spam errors are added before validation, they will be wiped
after_validation :invalidate_if_spam, on: [:create, :update]
cattr_accessor :spammable_attrs, instance_accessor: false do
@@ -38,24 +42,35 @@ module Spammable
end
def needs_recaptcha!
- self.errors.add(:base, "Your #{spammable_entity_type} has been recognized as spam. "\
- "Please, change the content or solve the reCAPTCHA to proceed.")
+ self.needs_recaptcha = true
end
- def unrecoverable_spam_error!
- self.errors.add(:base, "Your #{spammable_entity_type} has been recognized as spam and has been discarded.")
+ def spam!
+ self.spam = true
end
- def invalidate_if_spam
- return unless spam?
+ def clear_spam_flags!
+ self.spam = false
+ self.needs_recaptcha = false
+ end
- if Gitlab::Recaptcha.enabled?
- needs_recaptcha!
- else
+ def invalidate_if_spam
+ if needs_recaptcha? && Gitlab::Recaptcha.enabled?
+ recaptcha_error!
+ elsif needs_recaptcha? || spam?
unrecoverable_spam_error!
end
end
+ def recaptcha_error!
+ self.errors.add(:base, "Your #{spammable_entity_type} has been recognized as spam. "\
+ "Please, change the content or solve the reCAPTCHA to proceed.")
+ end
+
+ def unrecoverable_spam_error!
+ self.errors.add(:base, "Your #{spammable_entity_type} has been recognized as spam and has been discarded.")
+ end
+
def spammable_entity_type
self.class.name.underscore
end
diff --git a/app/models/concerns/state_eventable.rb b/app/models/concerns/state_eventable.rb
new file mode 100644
index 00000000000..68129798543
--- /dev/null
+++ b/app/models/concerns/state_eventable.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module StateEventable
+ extend ActiveSupport::Concern
+
+ included do
+ has_many :resource_state_events
+ end
+end
diff --git a/app/models/concerns/storage/legacy_project_wiki.rb b/app/models/concerns/storage/legacy_project_wiki.rb
deleted file mode 100644
index a377fa1e5de..00000000000
--- a/app/models/concerns/storage/legacy_project_wiki.rb
+++ /dev/null
@@ -1,11 +0,0 @@
-# frozen_string_literal: true
-
-module Storage
- module LegacyProjectWiki
- extend ActiveSupport::Concern
-
- def disk_path
- project.disk_path + '.wiki'
- end
- end
-end
diff --git a/app/models/concerns/timebox.rb b/app/models/concerns/timebox.rb
new file mode 100644
index 00000000000..d29e6a01c56
--- /dev/null
+++ b/app/models/concerns/timebox.rb
@@ -0,0 +1,204 @@
+# frozen_string_literal: true
+
+module Timebox
+ extend ActiveSupport::Concern
+
+ include AtomicInternalId
+ include CacheMarkdownField
+ include Gitlab::SQL::Pattern
+ include IidRoutes
+ include StripAttribute
+
+ TimeboxStruct = Struct.new(:title, :name, :id) do
+ # Ensure these models match the interface required for exporting
+ def serializable_hash(_opts = {})
+ { title: title, name: name, id: id }
+ end
+ end
+
+ # Represents a "No Timebox" state used for filtering Issues and Merge
+ # Requests that have no timeboxes assigned.
+ None = TimeboxStruct.new('No Timebox', 'No Timebox', 0)
+ Any = TimeboxStruct.new('Any Timebox', '', -1)
+ Upcoming = TimeboxStruct.new('Upcoming', '#upcoming', -2)
+ Started = TimeboxStruct.new('Started', '#started', -3)
+
+ included do
+ # Defines the same constants above, but inside the including class.
+ const_set :None, TimeboxStruct.new("No #{self.name}", "No #{self.name}", 0)
+ const_set :Any, TimeboxStruct.new("Any #{self.name}", '', -1)
+ const_set :Upcoming, TimeboxStruct.new('Upcoming', '#upcoming', -2)
+ const_set :Started, TimeboxStruct.new('Started', '#started', -3)
+
+ alias_method :timebox_id, :id
+
+ validates :group, presence: true, unless: :project
+ validates :project, presence: true, unless: :group
+ validates :title, presence: true
+
+ validate :uniqueness_of_title, if: :title_changed?
+ validate :timebox_type_check
+ validate :start_date_should_be_less_than_due_date, if: proc { |m| m.start_date.present? && m.due_date.present? }
+ validate :dates_within_4_digits
+
+ cache_markdown_field :title, pipeline: :single_line
+ cache_markdown_field :description
+
+ belongs_to :project
+ belongs_to :group
+
+ has_many :issues
+ has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues
+ has_many :merge_requests
+
+ scope :of_projects, ->(ids) { where(project_id: ids) }
+ scope :of_groups, ->(ids) { where(group_id: ids) }
+ scope :closed, -> { with_state(:closed) }
+ scope :for_projects, -> { where(group: nil).includes(:project) }
+ scope :with_title, -> (title) { where(title: title) }
+
+ scope :for_projects_and_groups, -> (projects, groups) do
+ projects = projects.compact if projects.is_a? Array
+ projects = [] if projects.nil?
+
+ groups = groups.compact if groups.is_a? Array
+ groups = [] if groups.nil?
+
+ where(project_id: projects).or(where(group_id: groups))
+ end
+
+ scope :within_timeframe, -> (start_date, end_date) do
+ where('start_date is not NULL or due_date is not NULL')
+ .where('start_date is NULL or start_date <= ?', end_date)
+ .where('due_date is NULL or due_date >= ?', start_date)
+ end
+
+ strip_attributes :title
+
+ alias_attribute :name, :title
+ end
+
+ class_methods do
+ # Searches for timeboxes with a matching title or description.
+ #
+ # This method uses ILIKE on PostgreSQL
+ #
+ # query - The search query as a String
+ #
+ # Returns an ActiveRecord::Relation.
+ def search(query)
+ fuzzy_search(query, [:title, :description])
+ end
+
+ # Searches for timeboxes with a matching title.
+ #
+ # This method uses ILIKE on PostgreSQL
+ #
+ # query - The search query as a String
+ #
+ # Returns an ActiveRecord::Relation.
+ def search_title(query)
+ fuzzy_search(query, [:title])
+ end
+
+ def filter_by_state(timeboxes, state)
+ case state
+ when 'closed' then timeboxes.closed
+ when 'all' then timeboxes
+ else timeboxes.active
+ end
+ end
+
+ def count_by_state
+ reorder(nil).group(:state).count
+ end
+
+ def predefined_id?(id)
+ [Any.id, None.id, Upcoming.id, Started.id].include?(id)
+ end
+
+ def predefined?(timebox)
+ predefined_id?(timebox&.id)
+ end
+ end
+
+ def title=(value)
+ write_attribute(:title, sanitize_title(value)) if value.present?
+ end
+
+ def timebox_name
+ model_name.singular
+ end
+
+ def group_timebox?
+ group_id.present?
+ end
+
+ def project_timebox?
+ project_id.present?
+ end
+
+ def safe_title
+ title.to_slug.normalize.to_s
+ end
+
+ def resource_parent
+ group || project
+ end
+
+ def to_ability_name
+ model_name.singular
+ end
+
+ def merge_requests_enabled?
+ if group_timebox?
+ # Assume that groups have at least one project with merge requests enabled.
+ # Otherwise, we would need to load all of the projects from the database.
+ true
+ elsif project_timebox?
+ project&.merge_requests_enabled?
+ end
+ end
+
+ private
+
+ # Timebox titles must be unique across project and group timeboxes
+ def uniqueness_of_title
+ if project
+ relation = self.class.for_projects_and_groups([project_id], [project.group&.id])
+ elsif group
+ relation = self.class.for_projects_and_groups(group.projects.select(:id), [group.id])
+ end
+
+ title_exists = relation.find_by_title(title)
+ errors.add(:title, _("already being used for another group or project %{timebox_name}.") % { timebox_name: timebox_name }) if title_exists
+ end
+
+ # Timebox should be either a project timebox or a group timebox
+ def timebox_type_check
+ if group_id && project_id
+ field = project_id_changed? ? :project_id : :group_id
+ errors.add(field, _("%{timebox_name} should belong either to a project or a group.") % { timebox_name: timebox_name })
+ end
+ end
+
+ def start_date_should_be_less_than_due_date
+ if due_date <= start_date
+ errors.add(:due_date, _("must be greater than start date"))
+ end
+ end
+
+ def dates_within_4_digits
+ if start_date && start_date > Date.new(9999, 12, 31)
+ errors.add(:start_date, _("date must not be after 9999-12-31"))
+ end
+
+ if due_date && due_date > Date.new(9999, 12, 31)
+ errors.add(:due_date, _("date must not be after 9999-12-31"))
+ end
+ end
+
+ def sanitize_title(value)
+ CGI.unescape_html(Sanitize.clean(value.to_s))
+ end
+end
diff --git a/app/models/concerns/update_project_statistics.rb b/app/models/concerns/update_project_statistics.rb
index a84fb1cf56d..6cf012680d8 100644
--- a/app/models/concerns/update_project_statistics.rb
+++ b/app/models/concerns/update_project_statistics.rb
@@ -68,21 +68,11 @@ module UpdateProjectStatistics
def schedule_update_project_statistic(delta)
return if delta.zero?
+ return if project.nil?
- if Feature.enabled?(:update_project_statistics_after_commit, default_enabled: true)
- # Update ProjectStatistics after the transaction
- run_after_commit do
- ProjectStatistics.increment_statistic(
- project_id, self.class.project_statistics_name, delta)
- end
- else
- # Use legacy-way to update within transaction
+ run_after_commit do
ProjectStatistics.increment_statistic(
project_id, self.class.project_statistics_name, delta)
- end
-
- run_after_commit do
- next if project.nil?
Namespaces::ScheduleAggregationWorker.perform_async(
project.namespace_id)