app/models/project.rb
class Project < ApplicationRecord
attr_writer :suppress_notifications
POINT_SCALES = {
'fibonacci' => [1, 2, 3, 5, 8].freeze,
'powers_of_two' => [1, 2, 4, 8].freeze,
'linear' => [1, 2, 3, 4, 5].freeze
}.freeze
ITERATION_LENGTH_RANGE = (1..4).freeze
MAX_MEMBERS_PER_CARD = 4
JSON_ATTRIBUTES = %w[
id name iteration_length iteration_start_day start_date
default_velocity
].freeze
JSON_METHODS = %w[last_changeset_id point_values].freeze
extend FriendlyId
friendly_id :name, use: :slugged, reserved_words: %w[new edit].freeze
belongs_to :tag_group
has_attachment :import, accept: [:raw]
has_many :integrations, dependent: :destroy
has_many :changesets, dependent: :destroy
has_many :integrations, dependent: :destroy
has_many :ownerships, dependent: :destroy
has_many :teams, through: :ownerships
has_many :memberships, dependent: :destroy
has_many :users, -> { distinct }, through: :memberships
has_many :stories, dependent: :destroy do
def with_dependencies
includes(:notes, :tasks)
end
# Populates the stories collection from a CSV string.
def from_csv(csv_string)
# Eager load this so that we don't have to make multiple db calls when
# searching for users by full name from the CSV.
users = proxy_association.owner.users
csv = ::CSV.parse(csv_string, headers: true)
csv.map do |row|
row_attrs = row.to_hash
story = build(
title: (row_attrs['Title'] || row_attrs['Story'] || '').truncate(255, omission: '...'),
story_type: (row_attrs['Type'] || row_attrs['Story Type']).downcase,
requested_by: users.detect { |u| u.name == row['Requested By'] },
owned_by: users.detect { |u| u.name == row['Owned By'] },
estimate: row_attrs['Estimate'],
labels: row_attrs['Labels'],
description: row_attrs['Description']
)
story.requested_by_name = (row['Requested By'] || '').truncate(255)
story.owned_by_name = (row['Owned By'] || '').truncate(255)
story.owned_by_initials = (row['Owned By'] || '')
.split(' ')
.map { |n| n[0].upcase }
.join('')
tasks = []
row.each_with_index do |(header, value), index|
next if value.blank?
case header
when 'Task'
next_value = row[index + 1].presence
next if next_value.blank?
tasks.unshift(Task.new(name: value, done: next_value == 'completed'))
end
end
story.project.suppress_notifications = true # otherwise the import will generate massive notifications!
story.tasks = tasks
story.notes.from_csv_row(row)
story.save
row_state = (row_attrs['Current State'] || 'unstarted').downcase
story.state = row_state if Story.available_states.include?(row_state.to_sym)
story.accepted_at = row_attrs['Accepted at']
story.save
story
end
end
end
scope :joinable, -> { where(disallow_join: false) }
scope :joinable_except, ->(project_ids) { joinable.where.not(id: project_ids) }
accepts_nested_attributes_for :users, reject_if: :all_blank
# These are the valid point scales for a project. These represent
# the set of valid points estimate values for a story in this project.
validates :point_scale, inclusion: { in: POINT_SCALES.keys,
message: '%{value} is not a valid estimation scheme' }
validates :iteration_length,
numericality: { greater_than_or_equal_to: ITERATION_LENGTH_RANGE.min,
less_than_or_equal_to: ITERATION_LENGTH_RANGE.max, only_integer: true,
message: 'must be between 1 and 4 weeks' }
validates :iteration_start_day,
numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 6,
only_integer: true, message: 'must be an integer between 0 and 6' }
validates :name, presence: true
validates :default_velocity, numericality: { greater_than: 0,
only_integer: true }
scope :not_archived, -> { where(archived_at: nil) }
scope :archived, -> { where.not(archived_at: nil) }
def csv_filename
"#{name}-#{Time.current.strftime('%Y%m%d_%I%M')}.csv"
end
def last_changeset_id
changesets.last&.id
end
def as_json(options = {})
super(**options, only: JSON_ATTRIBUTES, methods: JSON_METHODS)
end
def to_param
::FriendlyId::Disabler.disabled? ? (id&.to_s) : super
end
def suppress_notifications
@suppress_notifications || false
end
def to_s
name
end
def iteration_service(since: nil, current_time: Time.current)
@iteration_service ||= IterationService.new(self, since: since, current_time: current_time)
end
def point_values
POINT_SCALES[point_scale]
end
def archived
!!archived_at
end
def archived=(value)
self.archived_at = if !value || value == '0'
nil
else
Time.current
end
end
end