diff options
Diffstat (limited to 'app/models/concerns')
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) |