OpenProject is the leading open source project management software.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
openproject/app/models/project.rb

415 lines
14 KiB

#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2023 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
class Project < ApplicationRecord
extend Pagination::Model
extend FriendlyId
include Projects::Storage
include Projects::Activity
include Projects::Hierarchy
include Projects::AncestorsFromRoot
include ::Scopes::Scoped
# Maximum length for project identifiers
IDENTIFIER_MAX_LENGTH = 100
# reserved identifiers
RESERVED_IDENTIFIERS = %w(new).freeze
has_many :members, -> {
# TODO: check whether this should
# remain to be limited to User only
includes(:principal, :roles)
.merge(Principal.not_locked.user)
.references(:principal, :roles)
}
has_many :memberships, class_name: 'Member'
has_many :member_principals,
-> { not_locked },
class_name: 'Member'
has_many :users, through: :members, source: :principal
has_many :principals, through: :member_principals, source: :principal
has_many :enabled_modules, dependent: :delete_all
has_and_belongs_to_many :types, -> {
order("#{::Type.table_name}.position")
}
has_many :work_packages, -> {
order("#{WorkPackage.table_name}.created_at DESC")
.includes(:status, :type)
}
has_many :work_package_changes, through: :work_packages, source: :journals
has_many :versions, -> {
order("#{Version.table_name}.effective_date DESC, #{Version.table_name}.name DESC")
}, dependent: :destroy
has_many :time_entries, dependent: :delete_all
has_many :time_entry_activities_projects, dependent: :delete_all
has_many :queries, dependent: :destroy
has_many :news, -> { includes(:author) }, dependent: :destroy
has_many :categories, -> { order("#{Category.table_name}.name") }, dependent: :delete_all
has_many :forums, -> { order('position ASC') }, dependent: :destroy
has_one :repository, dependent: :destroy
has_many :changesets, through: :repository
has_one :wiki, dependent: :destroy
# Custom field for the project's work_packages
has_and_belongs_to_many :work_package_custom_fields,
-> { order("#{CustomField.table_name}.position") },
class_name: 'WorkPackageCustomField',
join_table: "#{table_name_prefix}custom_fields_projects#{table_name_suffix}",
association_foreign_key: 'custom_field_id'
has_one :status, class_name: 'Projects::Status', dependent: :destroy
Feature/remove timelog (#8557) * rename costs, introduce budgets * move files from costs to budgets * rename cost_object to budget * remove unused code * move hook - should be turned into standard code in the long run * move type attributes change over to budgets * move patch to work_package proper * move budget menu item up * combine reporting, time and cost module * remove rails based time_entries & reports code * rename cost object filter * adapt menu spec expectations * use cost project module name in administration * include timeline labels in migration * properly place budget linking method * fix permitted params * remove outdated routing spec * adapt budget request specs * ensure order of descendent updates * remove outdated specs * fix checking for reporting to be enabled * fix displaying spent units * fix time entries activity event url * reenable current rate tab * fix path on budget page * allow bulk editing of budgets only in one project scenario * fix sanitizing reference in controller * include module required for format_date * fix reference to correct units from work package spent units * linting * remove outdated spec * remove outdated views and permission references * remove acts_as_event from time_entries There is no atom link for time entries * remove acts_as_event from projects There are no atom links for projects * introduce budget filter for cost reports * remove actions added to removed controller * move time entries to the costs module * factor in view_own permission when calculating time entry visibility * linting * move mounting of time entries * include budgets into api v3 documentation
4 years ago
has_many :budgets, dependent: :destroy
[26688] In-app notifications (#9399) * Add bell icon to icon font * Add in app notification in top menu * Add fullscreen modal * Add notification modal and items * Style items * Toggle details of item * Mark all read * Add no results box * wip specification for event api * Add events table, query and index * Send out events from WP notification mailer job There we have the recipients present * Add cleanup job for older events with a setting * Hide bell notification when not logged * Add specs for events API index/show * Fix setting yml key * remove pry in event creation * Fix before hook in events API to after_validation * Fix polymorphic association raising exception for aggregated journals * Fix typo in read_ian * Fix yml entry for mentioned * Add read/unread post actions to event API and add specs * Wire up API to frontend * Fix order on events * Switch to unread in notification * Add event query * rename WPEventService * route wp mail sending over events * rename spec methods * author becomes watcher * correct message call signature * rename events to notifications * renname parameter to reflect notification nature * create author watcher for existing work packages * Merge unreadCount from store * Take a stab at polymorphic representers * Fix link generation in polymorphic resources For journals, no title is being generated however * Fix frontend model for context * Use timer for polling * add notification_setting data layer * Fix show resource spec * Fix duplicate class in notification bell item * Add minimal feature spec for notification * API for notification settings * Persist notifications * adapt work package notification creation to notification settings * extract notified_on_all * consolidate wp#recipients * concentrate wp notification in journal service * simplify methods * Remove unused patch endpoint * Add specs for rendering and parsing notification settings * Contract spec * Update service spec * adapt specs * Angular notifications frontend commit e29dced64699eb5f2443b9307c78343c9a58d1ee Author: Wieland Lindenthal <w.lindenthal@forkmerge.com> Date: Mon Jun 21 17:34:50 2021 +0200 Create Akita store and query for notification settings commit 1a45c26c1a0c147d15393e49d2625aca4851a64d Author: Wieland Lindenthal <w.lindenthal@forkmerge.com> Date: Mon Jun 21 11:09:25 2021 +0200 Remove tabs from notificaition settings page commit 0ea21e90c13a197f8bf2cfba1b60ddcff4e5e827 Author: Oliver Günther <mail@oliverguenther.de> Date: Sun Jun 20 21:55:48 2021 +0200 WIP in app settings * migrate notification data * add project visible filter to project query * Add inline-create and table display grouped by project * Add notifications under admin/users * Remove notifications partial * Rename notififcations store to user preferences store * Add setting for self_notified and hook that up to the backend * Add aria-label to table checkboxes * Restyle table and toolbar * replace remains of mail_notifications attribute * initialize notification settings for new user * adapt my_preferences references * reenable no self notified for documents * adapt specs * Avoid has_many :notifcation_settings Rails magically autosaves the user's preferences when the user gets saved, which somehow also tries to save the notfifications even when unchanged. This breaks some specs such as the avatar upload spec. As we can't update the assocation through rails anyway, just delegate to the user for reading instead. * Restore update method of notification settings * Restore update spec * fix spec syntax * lint scss * linting * Fix content_tag for bell icon * Add feature specs for notification settings * Disable ContentTag cop * use visible filter to get projects for notification The visible filter will reduce the project list down to the set of projects visible to the user provided as a parameter. This includes public projects. * test for actual mail sending * adapt me resource path this.apiV3Service.users.me changed its type in 0d6c0b6bc7620de94e00e72b36d6cbc1ec4c8db4 * Implement changed migration * Linting * Add actor to notification representer * Fix factory creating a duplicate WP journal * Add work packages loading and journal details to notification entry component * IAN basic facets, keep and expanded states. * Fix notification bell spec * Render body separately and add auto updating relative time * Add fixedTime title * Add actor to notification entry * Fix clicking links on work package and project * Tiny styling changes on entry row * Disable count in notification if larger than 99 (wont fit) * Introduce virtual scrolling to entry table * allow delaying & prevent mail sending if ain read Introduces a setting to delay mail sending after a journal aggregation time has expired. That way, users can confirm a notification in app. If they do before the delay expires, no mail is sent out additionally for that user. * consolidate notifications (in&out) into shared admin menu Co-authored-by: ulferts <jens.ulferts@googlemail.com> Co-authored-by: Wieland Lindenthal <w.lindenthal@forkmerge.com>
3 years ago
has_many :notification_settings, dependent: :destroy
has_many :projects_storages, dependent: :destroy, class_name: 'Storages::ProjectStorage'
has_many :storages, through: :projects_storages
acts_as_customizable
acts_as_searchable columns: %W(#{table_name}.name #{table_name}.identifier #{table_name}.description),
date_column: "#{table_name}.created_at",
project_key: 'id',
permission: nil
acts_as_journalized
# Necessary for acts_as_searchable which depends on the event_datetime method for sorting
acts_as_event title: Proc.new { |o| "#{Project.model_name.human}: #{o.name}" },
url: Proc.new { |o| { controller: 'overviews/overviews', action: 'show', project_id: o } },
author: nil,
datetime: :created_at
has_paper_trail
validates :name,
presence: true,
length: { maximum: 255 }
before_validation :remove_white_spaces_from_project_name
# TODO: we temporarily disable this validation because it leads to failed tests
# it implicitly assumes a db:seed-created standard type to be present and currently
# neither development nor deployment setups are prepared for this
# validates_presence_of :types
acts_as_url :name,
url_attribute: :identifier,
sync_url: false, # Don't update identifier when name changes
only_when_blank: true, # Only generate when identifier not set
limit: IDENTIFIER_MAX_LENGTH,
blacklist: RESERVED_IDENTIFIERS,
adapter: OpenProject::ActsAsUrl::Adapter::OpActiveRecord # use a custom adapter able to handle edge cases
validates :identifier,
presence: true,
uniqueness: { case_sensitive: true },
length: { maximum: IDENTIFIER_MAX_LENGTH },
exclusion: RESERVED_IDENTIFIERS,
if: ->(p) { p.persisted? || p.identifier.present? }
# Contains only a-z, 0-9, dashes and underscores but cannot consist of numbers only as it would clash with the id.
9 years ago
validates :identifier,
format: { with: /\A(?!^\d+\z)[a-z0-9\-_]+\z/ },
if: ->(p) { p.identifier_changed? && p.identifier.present? }
validates_associated :repository, :wiki
friendly_id :identifier, use: :finders
delegate :explanation, to: :status, allow_nil: true, prefix: true
scope :has_module, ->(mod) {
where(["#{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name=?)", mod.to_s])
}
scope :public_projects, -> { where(public: true) }
scope :visible, ->(user = User.current) { where(id: Project.visible_by(user)) }
scope :newest, -> { order(created_at: :desc) }
scope :active, -> { where(active: true) }
scopes :activated_time_activity,
:visible_with_activated_time_activity
def visible?(user = User.current)
active? and (public? or user.admin? or user.member_of?(self))
end
def archived?
!active?
end
def copy_allowed?
User.current.allowed_to?(:copy_projects, self)
end
def self.selectable_projects
Project.visible.select { |p| User.current.member_of? p }.sort_by(&:to_s)
end
def self.search_scope(query)
# overwritten from Pagination::Model
visible.like(query)
end
# Returns all projects the user is allowed to see.
#
# Employs the :view_project permission to perform the
# authorization check as the permission is public, meaning it is granted
# to everybody having at least one role in a project regardless of the
# role's permissions.
def self.visible_by(user = User.current)
allowed_to(user, :view_project)
end
# Returns a ActiveRecord::Relation to find all projects for which
# +user+ has the given +permission+
def self.allowed_to(user, permission)
Authorization.projects(permission, user)
end
def reload(*args)
@all_work_package_custom_fields = nil
super
end
# Returns a :conditions SQL string that can be used to find the issues associated with this project.
#
# Examples:
# project.project_condition(true) => "(projects.id = 1 OR (projects.lft > 1 AND projects.rgt < 10))"
# project.project_condition(false) => "projects.id = 1"
def project_condition(with_subprojects)
projects_table = Project.arel_table
stmt = projects_table[:id].eq(id)
stmt = stmt.or(projects_table[:lft].gt(lft).and(projects_table[:rgt].lt(rgt))) if with_subprojects
stmt
end
def types_used_by_work_packages
::Type.where(id: WorkPackage.where(project_id: project.id)
.select(:type_id)
Fix/bump representable (#5465) * bump reform and roar -> bumps representer * adapt to changed validation interface * disable initializer patch for now * adapt to changed representable attr interface * can no longer have private methods inside a representer * private no longer possible for representer * bump reform * wip - restyle validation * remove commented out patch * apply injection as prescribed * reactivate reform error symbols patch * remove patch to Hash superfluous wit ruby 2.3 * remove outdated human_attribute_name patch * whitespace fixes * adapt filter name after removal of human_attribute_name patch * adapt filter specs to no longer rely on human_attribute_name patch * fix version filter name * remove reliance on no longer existing human_attribute_name patch * use correct key in journal formatter * remove private from representer * adapt to altered setter interface * reenable i18n for error messages in contracts * no private methods in representer * defined model for contracts * fix validaton * instantiate correct Object * define model for contract * circumvent now existing render method on reform * replace deprecated constant * patch correct reform class - not the module - via prepend * refactor too complex method * replace deprations * remove remnants of parentId * prevent error symbols from existing twice * adapt user representer to altered setter interface * adapt watcher representer to altered setter interface * remove now unnessary patch * adapt setter to altered interface * adapt spec * fix custom field setters * remove parentId from wp representer As the parent is a wp resource, clients should use the parent link instead * adapt spec to changed valid? interface * remove parentId from wp schema * replace references of parentId in frontend * remove TODO [ci skip]
8 years ago
.distinct)
end
# Returns a scope of the types used by the project and its active sub projects
def rolled_up_types
::Type
.joins(:projects)
.select("DISTINCT #{::Type.table_name}.*")
.where(projects: { id: self_and_descendants.select(:id) })
.merge(Project.active)
.order("#{::Type.table_name}.position")
end
# Closes open and locked project versions that are completed
def close_completed_versions
Version.transaction do
versions.where(status: %w(open locked)).find_each do |version|
if version.completed?
version.update_attribute(:status, 'closed')
end
end
end
end
# Returns a scope of the Versions on subprojects
def rolled_up_versions
Version.rolled_up(self)
end
# Returns a scope of the Versions used by the project
def shared_versions
Version.shared_with(self)
end
# Returns all versions a work package can be assigned to. Opposed to
# #shared_versions this returns an array of Versions, not a scope.
#
# The main benefit is in scenarios where work packages' projects are eager
# loaded. Because eager loading the project e.g. via
# WorkPackage.includes(:project).where(type: 5) will assign the same instance
# (same object_id) for every work package having the same project this will
# reduce the number of db queries when performing operations including the
# project's versions.
def assignable_versions
@assignable_versions ||= shared_versions.references(:project).with_status_open.order_by_semver_name.to_a
end
# Returns an AR scope of all custom fields enabled for project's work packages
# (explicitly associated custom fields and custom fields enabled for all projects)
def all_work_package_custom_fields
WorkPackageCustomField
.for_all
.or(WorkPackageCustomField.where(id: work_package_custom_fields))
end
def project
self
end
def <=>(other)
name.downcase <=> other.name.downcase
end
def to_s
name
end
# Return true if this project is allowed to do the specified action.
# action can be:
# * a parameter-like Hash (eg. controller: '/projects', action: 'edit')
# * a permission Symbol (eg. :edit_project)
def allows_to?(action)
if action.is_a? Hash
allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
else
allowed_permissions.include? action
end
end
def module_enabled?(module_name)
module_name = module_name.to_s
enabled_modules.any? { |m| m.name == module_name }
end
def enabled_module_names=(module_names)
if module_names&.is_a?(Array)
module_names = module_names.map(&:to_s).compact_blank
self.enabled_modules = module_names.map do |name|
enabled_modules.detect do |mod|
mod.name == name
end || EnabledModule.new(name:)
end
else
enabled_modules.clear
end
end
# Returns an array of the enabled modules names
def enabled_module_names
enabled_modules.map(&:name)
end
# Returns an array of projects that are in this project's hierarchy
#
# Example: parents, children, siblings
def hierarchy
parents = project.self_and_ancestors || []
descendants = project.descendants || []
parents | descendants # Set union
end
class << self
# builds up a project hierarchy helper structure for use with #project_tree_from_hierarchy
#
# it expects a simple list of projects with a #lft column (awesome_nested_set)
# and returns a hierarchy based on #lft
#
# the result is a nested list of root level projects that contain their child projects
# but, each entry is actually a ruby hash wrapping the project and child projects
# the keys are :project and :children where :children is in the same format again
#
# result = [ root_level_project_info_1, root_level_project_info_2, ... ]
#
# where each entry has the form
#
# project_info = { project: the_project, children: [ child_info_1, child_info_2, ... ] }
#
# if a project has no children the :children array is just empty
#
def build_projects_hierarchy(projects)
ancestors = []
result = []
projects.sort_by(&:lft).each do |project|
while ancestors.any? && !project.is_descendant_of?(ancestors.last[:project])
# before we pop back one level, we sort the child projects by name
ancestors.last[:children] = sort_by_name(ancestors.last[:children])
ancestors.pop
end
current_hierarchy = { project:, children: [] }
current_tree = ancestors.any? ? ancestors.last[:children] : result
current_tree << current_hierarchy
ancestors << current_hierarchy
end
# When the last project is deeply nested, we need to sort
# all layers we are in.
ancestors.each do |level|
level[:children] = sort_by_name(level[:children])
end
# we need one extra element to ensure sorting at the end
# at the end the root level must be sorted as well
sort_by_name(result)
end
def project_tree_from_hierarchy(projects_hierarchy, level, &)
projects_hierarchy.each do |hierarchy|
project = hierarchy[:project]
children = hierarchy[:children]
yield project, level
# recursively show children
project_tree_from_hierarchy(children, level + 1, &) if children.any?
end
end
# Yields the given block for each project with its level in the tree
def project_tree(projects, &)
projects_hierarchy = build_projects_hierarchy(projects)
project_tree_from_hierarchy(projects_hierarchy, 0, &)
end
private
def sort_by_name(project_hashes)
project_hashes.sort_by { |h| h[:project].name&.downcase }
end
end
def allowed_permissions
@allowed_permissions ||=
begin
names = enabled_modules.loaded? ? enabled_module_names : enabled_modules.pluck(:name)
OpenProject::AccessControl.modules_permissions(names).map(&:name)
end
end
def allowed_actions
2 years ago
@allowed_actions ||= allowed_permissions.flat_map do |permission|
OpenProject::AccessControl.allowed_actions(permission)
end
end
def remove_white_spaces_from_project_name
self.name = name.squish unless name.nil?
end
end