Files
openproject/app/models/journal.rb
2026-06-09 10:31:37 +02:00

249 lines
9.3 KiB
Ruby

# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 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 Journal < ApplicationRecord
self.table_name = "journals"
self.ignored_columns += ["activity_type"]
include ::JournalChanges
include ::JournalFormatter
include ::Acts::Journalized::FormatHooks
include Journal::Timestamps
include Reactable
# Inline attachments for Journal#notes aka comments
acts_as_attachable view_permission: :view_work_packages,
add_on_new_permission: :add_work_package_comments,
add_on_persisted_permission: :edit_own_work_package_comments,
delete_permission: :edit_own_work_package_comments
register_journal_formatter OpenProject::JournalFormatter::ActiveStatus
register_journal_formatter OpenProject::JournalFormatter::AgendaItemDiff
register_journal_formatter OpenProject::JournalFormatter::AgendaItemDuration
register_journal_formatter OpenProject::JournalFormatter::AgendaItemPosition
register_journal_formatter OpenProject::JournalFormatter::AgendaItemTitle
register_journal_formatter OpenProject::JournalFormatter::AllocatedTime
register_journal_formatter OpenProject::JournalFormatter::Attachment
register_journal_formatter OpenProject::JournalFormatter::Cause
register_journal_formatter OpenProject::JournalFormatter::CustomComment
register_journal_formatter OpenProject::JournalFormatter::CustomField
register_journal_formatter OpenProject::JournalFormatter::Diff
register_journal_formatter OpenProject::JournalFormatter::FileLink
register_journal_formatter OpenProject::JournalFormatter::IgnoreNonWorkingDays
register_journal_formatter OpenProject::JournalFormatter::MeetingStartTime
register_journal_formatter OpenProject::JournalFormatter::MeetingState
register_journal_formatter OpenProject::JournalFormatter::MeetingWorkPackageId
register_journal_formatter OpenProject::JournalFormatter::ParticipantChange
register_journal_formatter OpenProject::JournalFormatter::ProjectPhaseActive
register_journal_formatter OpenProject::JournalFormatter::ProjectPhaseDates
register_journal_formatter OpenProject::JournalFormatter::ProjectPhaseDefinition
register_journal_formatter OpenProject::JournalFormatter::ProjectStatusCode
register_journal_formatter OpenProject::JournalFormatter::ScheduleManually
register_journal_formatter OpenProject::JournalFormatter::SubprojectNamedAssociation
register_journal_formatter OpenProject::JournalFormatter::Template
register_journal_formatter OpenProject::JournalFormatter::TimeEntryHours
register_journal_formatter OpenProject::JournalFormatter::TimeEntryNamedAssociation
register_journal_formatter OpenProject::JournalFormatter::Visibility
register_journal_formatter OpenProject::JournalFormatter::WikiDiff
# Attributes related to the cause are stored in a JSONB column so we can easily add new relations and related
# attributes without a heavy database migration. Fields will be prefixed with `cause_` but are stored in the JSONB
# hash without that prefix
store_accessor :cause,
%i[
type
feature
import_history
work_package_id
changed_days
status_name
status_id
status_changes
],
prefix: true
VALID_CAUSE_TYPES = %w[
default_attribute_written
import
progress_mode_changed_to_status_based
status_changed
system_update
total_percent_complete_mode_changed_to_work_weighted_average
work_package_children_changed_times
work_package_parent_changed_times
work_package_predecessor_changed_times
work_package_related_changed_times
work_package_duplicate_closed
working_days_changed
].freeze
# Make sure each journaled model instance only has unique version ids
validates :version, uniqueness: { scope: %i[journable_id journable_type] }
validates :cause_type, inclusion: { in: VALID_CAUSE_TYPES, allow_blank: true }
belongs_to :user
belongs_to :journable, polymorphic: true
belongs_to :data, polymorphic: true, dependent: :destroy
has_many :agenda_item_journals, class_name: "Journal::MeetingAgendaItemJournal", dependent: :delete_all
has_many :participant_journals, class_name: "Journal::MeetingParticipantJournal", dependent: :delete_all
has_many :attachable_journals, class_name: "Journal::AttachableJournal", dependent: :delete_all
has_many :customizable_journals, class_name: "Journal::CustomizableJournal", dependent: :delete_all
has_many :custom_comment_journals, class_name: "Journal::CustomCommentJournal", dependent: :delete_all
has_many :project_phase_journals, class_name: "Journal::ProjectPhaseJournal", dependent: :delete_all
has_many :storable_journals, class_name: "Journal::StorableJournal", dependent: :delete_all
has_many :notifications, dependent: :destroy
include ::Scopes::Scoped
scopes :with_sequence_version
# Scopes to all journals excluding the initial journal - useful for change
# logs like the history on issue#show
scope :changing, -> { where(["version > 1"]) }
scope :for_wiki_page, -> { where(journable_type: "WikiPage") }
scope :for_work_package, -> { where(journable_type: "WorkPackage") }
scope :for_meeting, -> { where(journable_type: "Meeting") }
alias_attribute :internal, :restricted
# In conjunction with the included Comparable module, allows comparison of journal records
# based on their corresponding version numbers, creation timestamps and IDs.
def <=>(other)
[version, created_at, id].map(&:to_i) <=> [other.version, other.created_at, other.id].map(&:to_i)
end
# Returns whether the version has a version number of 1. Useful when deciding whether to ignore
# the version during reversion, as initial versions have no serialized changes attached. Helps
# maintain backwards compatibility.
def initial?
version < 2
end
# The anchor number for html output
def anchor
version - 1
end
# Possible shortcut to the associated project
def project
if journable.respond_to?(:project)
journable.project
elsif journable.is_a? Project
journable
end
end
def attachments_visible?(user = User.current)
if internal?
journable.attachments_visible?(user) && user.allowed_in_project?(:view_internal_comments, project)
else
journable.attachments_visible?(user)
end
end
def visible?(user = User.current)
if internal?
journable.visible?(user) && user.allowed_in_project?(:view_internal_comments, project)
else
journable.visible?(user)
end
end
def editable_by?(user)
journable.journal_editable_by?(self, user)
end
def details
get_changes
end
def new_value_for(prop)
details[prop].last if details.key? prop
end
def old_value_for(prop)
details[prop].first if details.key? prop
end
def previous
predecessor
end
def successor
return @successor if defined?(@successor)
@successor = self.class
.where(journable_type:, journable_id:)
.where("#{self.class.table_name}.version > ?", version)
.order(version: :asc)
.first
end
def noop?
(!notes || notes&.empty?) && get_changes.empty?
end
def has_cause?
cause_type.present?
end
def has_unread_notifications_for_user?(user)
# we optionally set the instance variable @unread_notifications in the ActivityEagerLoadingWrapper
# in order to avoid N+1 queries
if instance_variable_defined?(:@unread_notifications)
@unread_notifications&.any? { |notification| notification.recipient_id == user.id }
else
notifications.where(read_ian: false, recipient_id: user.id).any?
end
end
def predecessor
return @predecessor if defined?(@predecessor)
@predecessor = if initial?
nil
else
self.class
.where(journable_type:, journable_id:)
.where("#{self.class.table_name}.version < ?", version)
.order(version: :desc)
.first
end
end
private
def has_file_links?
journable.respond_to?(:file_links)
end
end