summaryrefslogtreecommitdiff
path: root/app/models/concerns/timebox.rb
diff options
context:
space:
mode:
Diffstat (limited to 'app/models/concerns/timebox.rb')
-rw-r--r--app/models/concerns/timebox.rb204
1 files changed, 204 insertions, 0 deletions
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