Feature/aggregated activities (#8221)
* use cte for aggregated journal
* Revert "use cte for aggregated journal"
This reverts commit 5fedefefdd
.
* add another subselect that could later on be provided from the outside
* allow passing a nukleous sql to aggregated journals
* wip - using aggregated journal for activity
* new sql for aggregated journals
* start implementing new aggregated query
* additional documentation
* consolidate activity functionality
* simplify by turing into instance methods
* move activity fetcher out of redmine
* remove verb verification made obsolete
Without catchall routes, the dispatching handles it
* remove duplicate authorize check
* refactor activities controller
* refactory activity fetcher
* cache avatar file
* sort choosable events
* remove legacy spec covered by contemporary
* speed up aggregated journals via CTE
* instance var might never have been set
* ensure the event_types are always transmitted
* correctly reset the avatar cache
* fix avatar fetcher expectation regarding wiki pages
* adapt spec
[ci skip]
pull/8317/head
parent
13a05b7823
commit
64d0f57d85
@ -0,0 +1,16 @@ |
||||
module Activities |
||||
Event = Struct.new(:provider, |
||||
:event_name, |
||||
:event_title, |
||||
:event_description, |
||||
:author_id, |
||||
:event_author, |
||||
:event_datetime, |
||||
:journable_id, |
||||
:project_id, |
||||
:project, |
||||
:event_type, |
||||
:event_path, |
||||
:event_url, |
||||
keyword_init: true) |
||||
end |
@ -0,0 +1,144 @@ |
||||
#-- encoding: UTF-8 |
||||
|
||||
#-- copyright |
||||
# OpenProject is an open source project management software. |
||||
# Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details. |
||||
#++ |
||||
|
||||
module Activities |
||||
# Class used to retrieve activity events |
||||
class Fetcher |
||||
attr_reader :user, :project, :scope |
||||
|
||||
def self.constantized_providers |
||||
@constantized_providers ||= Hash.new { |h, k| h[k] = OpenProject::Activity.providers[k].map(&:constantize) } |
||||
end |
||||
|
||||
def initialize(user, options = {}) |
||||
options.assert_valid_keys(:project, :with_subprojects, :author, :scope) |
||||
@user = user |
||||
@project = options[:project] |
||||
@options = options |
||||
|
||||
self.scope = options[:scope] || :all |
||||
end |
||||
|
||||
# Returns an array of available event types |
||||
def event_types |
||||
@event_types ||= begin |
||||
if @project |
||||
OpenProject::Activity.available_event_types.select do |o| |
||||
@project.self_and_descendants.detect do |_p| |
||||
permissions = constantized_providers(o).map do |p| |
||||
p.activity_provider_options[:permission] |
||||
end.compact |
||||
|
||||
permissions.all? { |p| @user.allowed_to?(p, @project) } |
||||
end |
||||
end |
||||
else |
||||
OpenProject::Activity.available_event_types |
||||
end |
||||
end |
||||
end |
||||
|
||||
# Returns an array of events for the given date range |
||||
# sorted in reverse chronological order |
||||
def events(from = nil, to = nil, limit: nil) |
||||
events = events_from_providers(from, to, limit) |
||||
|
||||
eager_load_associations(events) |
||||
|
||||
sort_by_date(events) |
||||
end |
||||
|
||||
protected |
||||
|
||||
# Sets the scope |
||||
# Argument can be :all, :default or an array of event types |
||||
def scope=(scope) |
||||
case scope |
||||
when :all |
||||
@scope = event_types |
||||
when :default |
||||
default_scope! |
||||
else |
||||
@scope = scope & event_types |
||||
end |
||||
end |
||||
|
||||
# Resets the scope to the default scope |
||||
def default_scope! |
||||
@scope = OpenProject::Activity.default_event_types |
||||
end |
||||
|
||||
def events_from_providers(from, to, limit) |
||||
events = [] |
||||
|
||||
@scope.each do |event_type| |
||||
constantized_providers(event_type).each do |provider| |
||||
events += provider.find_events(event_type, @user, from, to, @options.merge(limit: limit)) |
||||
end |
||||
end |
||||
|
||||
events |
||||
end |
||||
|
||||
def eager_load_associations(events) |
||||
projects = projects_of_event_set(events) |
||||
users = users_of_event_set(events) |
||||
|
||||
events.each do |e| |
||||
e.event_author = users[e.author_id]&.first |
||||
e.project = projects[e.project_id]&.first |
||||
end |
||||
end |
||||
|
||||
def projects_of_event_set(events) |
||||
project_ids = events.map(&:project_id).compact.uniq |
||||
|
||||
if project_ids.any? |
||||
Project.find(project_ids).group_by(&:id) |
||||
else |
||||
{} |
||||
end |
||||
end |
||||
|
||||
def users_of_event_set(events) |
||||
user_ids = events.map(&:author_id).compact.uniq |
||||
|
||||
User.where(id: user_ids).group_by(&:id) |
||||
end |
||||
|
||||
def sort_by_date(events) |
||||
events.sort { |a, b| b.event_datetime <=> a.event_datetime } |
||||
end |
||||
|
||||
def constantized_providers(event_type) |
||||
self.class.constantized_providers[event_type] |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,359 @@ |
||||
#-- encoding: UTF-8 |
||||
|
||||
#-- copyright |
||||
# OpenProject is an open source project management software. |
||||
# Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details. |
||||
#++ |
||||
|
||||
# Scope to fetch all fields necessary to populated AggregatedJournal collections. |
||||
# See the AggregatedJournal model class for a description. |
||||
module Journal::Scopes |
||||
class AggregatedJournal |
||||
class << self |
||||
def fetch(journable: nil, sql: nil, until_version: nil) |
||||
journals_preselection = raw_journals_subselect(journable, sql, until_version) |
||||
|
||||
# We wrap the sql with a subselect so that outside of this class, |
||||
# The fields native to journals (e.g. id, version) can be referenced, without |
||||
# having to also use a CASE/COALESCE statement. |
||||
Journal |
||||
.from(select_sql(journals_preselection)) |
||||
end |
||||
|
||||
private |
||||
|
||||
# The sql used to query the database for the aggregated journals. |
||||
# The query makes use of 4 parts (which are selected from/joined): |
||||
# * The minimum journal version that starts an aggregation group. |
||||
# * The maximum journal version that ends an aggregation group. |
||||
# * Journals with notes that are within the bounds of minimum version/maximum version. |
||||
# * Journals with notes that are within the bounds of the before mentioned journal notes and the maximum journal version. |
||||
# |
||||
# The maximum version are those journals, whose successor: |
||||
# * Where created by a different user |
||||
# * Where created after the configured aggregation period had expired (always relative to the journal under consideration). |
||||
# |
||||
# The minimum version then is the maximum version of the group before - 1. |
||||
# |
||||
# e.g. a group of 10 sequential journals might break into the following groups |
||||
# |
||||
# Version 10 (User A, 6 minutes after 9) |
||||
# Version 9 (User A, 2 minutes after 8) |
||||
# Version 8 (User A, 4 minutes after 7) |
||||
# Version 7 (User A, 1 minute after 6) |
||||
# Version 6 (User A, 3 minutes after 5) |
||||
# Version 5 (User A, 1 minute after 4) |
||||
# Version 4 (User B, 1 minute after 3) |
||||
# Version 3 (User B, 4 minutes after 2) |
||||
# Version 2 (User A, 1 minute after 1) |
||||
# Version 1 (User A) |
||||
# |
||||
# would have the following maximum journals if the aggregation period where 5 minutes: |
||||
# |
||||
# Version 10 (User A, 6 minutes after 9) |
||||
# Version 9 (User A, 2 minutes after 8) |
||||
# Version 4 (User B, 1 minute after 3) |
||||
# Version 2 (User A, 1 minute after 1) |
||||
# |
||||
# The last journal (one without a successor) of a journable will obviously also always be a maximum journal. |
||||
# |
||||
# If the aggregation period where to be expanded to 7 minutes, the maximum journals would be slightly different: |
||||
# |
||||
# Version 10 (User A, 6 minutes after 9) |
||||
# Version 4 (User B, 1 minute after 3) |
||||
# Version 2 (User A, 1 minute after 1) |
||||
# |
||||
# As we do not store the aggregated journals, and rather calculate them on reading, the aggregated journals might be tuned |
||||
# by a user. |
||||
# |
||||
# The minimum version in the example with the 5 minute aggregation period would then be calculated from the maximum version: |
||||
# |
||||
# Version 10 |
||||
# Version 5 |
||||
# Version 3 |
||||
# Version 1 |
||||
# |
||||
# The first version will always be included. |
||||
# |
||||
# Without a journal with notes (the user commented on the journable) in between, the maximum journal is returned |
||||
# as the representation of every aggregation group. This is possible as the journals (together with their data and their |
||||
# customizable_journals/attachable_journals) represent the complete state of the journable at the given time. |
||||
# |
||||
# e.g. a group of 5 sequential journals without notes, belonging to the same user and created within the configured |
||||
# time difference between one journal and its succcessor |
||||
# |
||||
# Version 9 |
||||
# Version 8 |
||||
# Version 7 |
||||
# Version 6 |
||||
# Version 5 |
||||
# |
||||
# would only return the last journal, Version 9. |
||||
# |
||||
# In case the group has one journal with notes in it, the last journal is also returned. But as we also want the note |
||||
# to be returned, we return the note as if it would belong to the maximum journal version. This explicitly means |
||||
# that all journals of the same group that are after the notes journal are also returned. |
||||
# |
||||
# e.g. a group of 5 sequential journals with only one note, belonging to the same user and created within the configured |
||||
# time difference between one journal and its succcessor |
||||
# |
||||
# Version 9 |
||||
# Version 8 |
||||
# Version 7 |
||||
# Version 6 (note) |
||||
# Version 5 |
||||
# |
||||
# would only return the last journal, Version 9, but would also return the note and the id of the journal the note |
||||
# belongs to natively. |
||||
# |
||||
# But as we do not want to aggregate notes, the behaviour above can no longer work if there is more than one note in the |
||||
# same group. In such a case, a group is cut into subsets. The journals returned will then only contain all the changes |
||||
# up until a journal with notes. The only exception to this is the last journal note which might also contain changes |
||||
# after it up to and including the maximum journal version of the group. |
||||
|
||||
# e.g. a group of 5 sequential journals with only one note, belonging to the same user and created within the configured |
||||
# time difference between one journal and its succcessor |
||||
# |
||||
# Version 9 |
||||
# Version 8 (note) |
||||
# Version 7 |
||||
# Version 6 (note) |
||||
# Version 5 |
||||
# |
||||
# would return the last journal, Version 9, but with the note of Version 8 and also a reference in the form of |
||||
# note_id pointing to Version 8. It would also return Version 6, with its note and a reference in the form of note_id |
||||
# this time pointing to the native journal, Version 6. |
||||
# |
||||
# The journals that are considered for aggregation can also be reduced by providing a subselect. Doing so, one |
||||
# can e.g. consider only the journals created after a certain time. |
||||
def select_sql(journals_preselection) |
||||
<<~SQL |
||||
(#{Journal |
||||
.from(start_group_journals_select(journals_preselection)) |
||||
.joins(end_group_journals_join(journals_preselection)) |
||||
.joins(notes_in_group_join(journals_preselection)) |
||||
.joins(additional_notes_in_group_join(journals_preselection)) |
||||
.select(projection_list).to_sql}) journals |
||||
SQL |
||||
end |
||||
|
||||
def user_or_time_group_breaking_journals_subselect(journals_preselection) |
||||
<<~SQL |
||||
SELECT |
||||
predecessor.*, |
||||
row_number() OVER (ORDER BY predecessor.journable_type, predecessor.journable_id, predecessor.version ASC) #{group_number_alias} |
||||
FROM #{journals_preselection} predecessor |
||||
LEFT OUTER JOIN #{journals_preselection} successor |
||||
ON predecessor.version + 1 = successor.version |
||||
AND predecessor.journable_type = successor.journable_type |
||||
AND predecessor.journable_id = successor.journable_id |
||||
WHERE (predecessor.user_id != successor.user_id |
||||
OR #{beyond_aggregation_time_condition}) |
||||
OR successor.id IS NULL |
||||
SQL |
||||
end |
||||
|
||||
def notes_journals_subselect(journals_preselection) |
||||
<<~SQL |
||||
(SELECT |
||||
notes_journals.* |
||||
FROM #{journals_preselection} notes_journals |
||||
WHERE notes_journals.notes != '' AND notes_journals.notes IS NOT NULL) |
||||
SQL |
||||
end |
||||
|
||||
def start_group_journals_select(journals_preselection) |
||||
"(#{user_or_time_group_breaking_journals_subselect(journals_preselection)}) #{start_group_journals_alias}" |
||||
end |
||||
|
||||
def end_group_journals_join(journals_preselection) |
||||
group_journals_join_condition = <<~SQL |
||||
#{start_group_journals_alias}.#{group_number_alias} = #{end_group_journals_alias}.#{group_number_alias} - 1 |
||||
AND #{start_group_journals_alias}.journable_type = #{end_group_journals_alias}.journable_type |
||||
AND #{start_group_journals_alias}.journable_id = #{end_group_journals_alias}.journable_id |
||||
SQL |
||||
|
||||
end_group_journals = <<~SQL |
||||
RIGHT OUTER JOIN |
||||
(#{user_or_time_group_breaking_journals_subselect(journals_preselection)}) #{end_group_journals_alias} |
||||
ON #{group_journals_join_condition} |
||||
SQL |
||||
|
||||
Arel.sql(end_group_journals) |
||||
end |
||||
|
||||
def notes_in_group_join(journals_preselection) |
||||
# As we right join on the minimum journal version, the minimum might be empty. We thus have to coalesce in such |
||||
# case as <= will not interpret NULL as 0. |
||||
# This also works if we do not fetch the whole set of journals starting from the first journal but rather |
||||
# start somewhere within the set. This might take place e.g. when fetching only the journals that are |
||||
# created after a certain point in time which is done when displaying of the last month in the activity module. |
||||
breaking_journals_notes_join_condition = <<~SQL |
||||
COALESCE(#{start_group_journals_alias}.version, 0) + 1 <= #{notes_in_group_alias}.version |
||||
AND #{end_group_journals_alias}.version >= #{notes_in_group_alias}.version |
||||
AND #{end_group_journals_alias}.journable_type = #{notes_in_group_alias}.journable_type |
||||
AND #{end_group_journals_alias}.journable_id = #{notes_in_group_alias}.journable_id |
||||
SQL |
||||
|
||||
breaking_journals_notes = <<~SQL |
||||
LEFT OUTER JOIN |
||||
#{notes_journals_subselect(journals_preselection)} #{notes_in_group_alias} |
||||
ON #{breaking_journals_notes_join_condition} |
||||
SQL |
||||
|
||||
Arel.sql(breaking_journals_notes) |
||||
end |
||||
|
||||
def additional_notes_in_group_join(journals_preselection) |
||||
successor_journals_notes_join_condition = <<~SQL |
||||
#{notes_in_group_alias}.version < successor_notes.version |
||||
AND #{end_group_journals_alias}.version >= successor_notes.version |
||||
AND #{end_group_journals_alias}.journable_type = successor_notes.journable_type |
||||
AND #{end_group_journals_alias}.journable_id = successor_notes.journable_id |
||||
SQL |
||||
|
||||
successor_journals_notes = <<~SQL |
||||
LEFT OUTER JOIN |
||||
#{notes_journals_subselect(journals_preselection)} successor_notes |
||||
ON #{successor_journals_notes_join_condition} |
||||
SQL |
||||
|
||||
Arel.sql(successor_journals_notes) |
||||
end |
||||
|
||||
def projection_list |
||||
projections = <<~SQL |
||||
#{end_group_journals_alias}.journable_type, |
||||
#{end_group_journals_alias}.journable_id, |
||||
#{end_group_journals_alias}.user_id, |
||||
#{end_group_journals_alias}.activity_type, |
||||
#{notes_projection} notes, |
||||
#{notes_id_projection} notes_id, |
||||
#{notes_in_group_alias}.version notes_version, |
||||
#{version_projection} AS version, |
||||
#{created_at_projection} created_at, |
||||
#{id_projection} id |
||||
SQL |
||||
|
||||
Arel.sql(projections) |
||||
end |
||||
|
||||
def id_projection |
||||
<<~SQL |
||||
CASE |
||||
WHEN successor_notes.version IS NOT NULL THEN #{notes_in_group_alias}.id |
||||
ELSE #{end_group_journals_alias}.id END |
||||
SQL |
||||
end |
||||
|
||||
def version_projection |
||||
<<~SQL |
||||
CASE |
||||
WHEN successor_notes.version IS NOT NULL THEN #{notes_in_group_alias}.version |
||||
ELSE #{end_group_journals_alias}.version END |
||||
SQL |
||||
end |
||||
|
||||
def created_at_projection |
||||
<<~SQL |
||||
CASE |
||||
WHEN successor_notes.version IS NOT NULL THEN #{notes_in_group_alias}.created_at |
||||
ELSE #{end_group_journals_alias}.created_at END |
||||
SQL |
||||
end |
||||
|
||||
def notes_id_projection |
||||
<<~SQL |
||||
COALESCE(#{notes_in_group_alias}.id, #{end_group_journals_alias}.id) |
||||
SQL |
||||
end |
||||
|
||||
def notes_projection |
||||
<<~SQL |
||||
COALESCE(#{notes_in_group_alias}.notes, '') |
||||
SQL |
||||
end |
||||
|
||||
def raw_journals_subselect(journable, sql, until_version) |
||||
if sql |
||||
raise 'until_version used together with sql' if until_version |
||||
|
||||
"(#{sql})" |
||||
elsif journable |
||||
limit = until_version ? "AND journals.version <= #{until_version}" : '' |
||||
|
||||
<<~SQL |
||||
( |
||||
SELECT * from journals |
||||
WHERE journals.journable_id = #{journable.id} |
||||
AND journals.journable_type = '#{journable.class.name}' |
||||
#{limit} |
||||
) |
||||
SQL |
||||
else |
||||
where = until_version ? "WHERE journals.version <= #{until_version}" : '' |
||||
|
||||
<<~SQL |
||||
(SELECT * from journals #{where}) |
||||
SQL |
||||
end |
||||
end |
||||
|
||||
# Returns a SQL condition that will determine whether two entries are too far apart (temporal) |
||||
# to be considered for aggregation. This takes the current instance settings for temporal |
||||
# proximity into account. |
||||
def beyond_aggregation_time_condition |
||||
aggregation_time_seconds = Setting.journal_aggregation_time_minutes.to_i.minutes |
||||
if aggregation_time_seconds == 0 |
||||
# if aggregation is disabled, we consider everything to be beyond aggregation time |
||||
# even if creation dates are exactly equal |
||||
return '(true = true)' |
||||
end |
||||
|
||||
difference = "(successor.created_at - predecessor.created_at)" |
||||
threshold = "interval '#{aggregation_time_seconds} second'" |
||||
|
||||
"(#{difference} > #{threshold})" |
||||
end |
||||
|
||||
def start_group_journals_alias |
||||
"start_groups_journals" |
||||
end |
||||
|
||||
def end_group_journals_alias |
||||
"end_groups_journals" |
||||
end |
||||
|
||||
def group_number_alias |
||||
"group_number" |
||||
end |
||||
|
||||
def notes_in_group_alias |
||||
"notes_in_group_journals" |
||||
end |
||||
end |
||||
end |
||||
end |
@ -1,31 +0,0 @@ |
||||
#-- copyright |
||||
# OpenProject is an open source project management software. |
||||
# Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details. |
||||
#++ |
||||
|
||||
#-- encoding: UTF-8 |
||||
require File.dirname(__FILE__) + '/lib/acts_as_activity_provider' |
||||
ActiveRecord::Base.send(:include, Redmine::Acts::ActivityProvider) |
@ -1,236 +0,0 @@ |
||||
#-- copyright |
||||
# OpenProject is an open source project management software. |
||||
# Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details. |
||||
#++ |
||||
|
||||
module Redmine |
||||
module Acts |
||||
module ActivityProvider |
||||
def self.included(base) |
||||
base.extend ClassMethods |
||||
end |
||||
|
||||
module ClassMethods |
||||
def acts_as_activity_provider(options = {}) |
||||
unless included_modules.include?(Redmine::Acts::ActivityProvider::InstanceMethods) |
||||
cattr_accessor :activity_provider_options |
||||
send :include, Redmine::Acts::ActivityProvider::InstanceMethods |
||||
end |
||||
|
||||
options.assert_valid_keys(:type, :permission, :activities) |
||||
self.activity_provider_options ||= {} |
||||
|
||||
# One model can provide different event types |
||||
# We store these options in activity_provider_options hash |
||||
event_type = options.delete(:type) || name.underscore.pluralize |
||||
|
||||
options[:activities] = options.delete(:activities) || [:activity] |
||||
options[:permission] = "view_#{name.underscore.pluralize}".to_sym unless options.has_key?(:permission) |
||||
self.activity_provider_options[event_type] = options |
||||
end |
||||
end |
||||
|
||||
Event = Struct.new(:provider, |
||||
:event_name, |
||||
:event_title, |
||||
:event_description, |
||||
:author_id, |
||||
:event_author, |
||||
:event_datetime, |
||||
:journable_id, |
||||
:project_id, |
||||
:project, |
||||
:event_type, |
||||
:event_path, |
||||
:event_url) |
||||
|
||||
def self.event_projection(journals_table) |
||||
[ |
||||
journals_table[:id].as('event_id'), |
||||
journals_table[:created_at].as('event_datetime'), |
||||
journals_table[:user_id].as('event_author'), |
||||
journals_table[:notes].as('event_description'), |
||||
journals_table[:version].as('version'), |
||||
journals_table[:journable_id].as('journable_id') |
||||
] |
||||
end |
||||
|
||||
module InstanceMethods |
||||
def self.included(base) |
||||
base.extend ClassMethods |
||||
end |
||||
|
||||
module ClassMethods |
||||
# Returns events of type event_type visible by user that occured between from and to |
||||
def find_events(event_type, user, from, to, options) |
||||
raise "#{name} can not provide #{event_type} events." if activity_provider_options[event_type].nil? |
||||
|
||||
result = [] |
||||
|
||||
provider_options = activity_provider_options[event_type].dup |
||||
|
||||
provider_options[:activities].each do |activity| |
||||
result << find_events_for_class(new, activity, provider_options, user, from, to, options) |
||||
end |
||||
|
||||
result.flatten! |
||||
result.each do |e| e.event_type = event_type.dup.singularize unless e.event_type end |
||||
result |
||||
end |
||||
|
||||
private |
||||
|
||||
def find_events_for_class(provider, activity, provider_options, user, from, to, options) |
||||
activity_journals_table = provider.activity_journals_table activity |
||||
|
||||
query = journals_table.join(activity_journals_table).on(journals_table[:id].eq(activity_journals_table[:journal_id])) |
||||
query = query.where(journals_table[:journable_type].eq(provider.activitied_type(activity).name)) |
||||
|
||||
provider.filter_for_event_datetime query, journals_table, activity_journals_table, from, to |
||||
|
||||
query = query.where(journals_table[:user_id].eq(options[:author].id)) if options[:author] |
||||
|
||||
provider.extend_event_query(query, activity) if provider.respond_to?(:extend_event_query) |
||||
|
||||
query = join_with_projects_table(query, provider.projects_reference_table(activity)) |
||||
query = restrict_projects_by_selection(options, query) |
||||
query = restrict_projects_by_permission(provider_options[:permission], query) |
||||
query = restrict_projects_by_user(provider_options, user, query) |
||||
|
||||
return [] if query.nil? |
||||
|
||||
Redmine::Hook.call_hook(:activity_fetcher_find_events_for_class, |
||||
activity: activity, |
||||
query: query, |
||||
user: user) |
||||
|
||||
query = query.order(journals_table[:id].desc) |
||||
query = query.take(options[:limit]) if options[:limit] |
||||
|
||||
projection = Redmine::Acts::ActivityProvider.event_projection(journals_table) |
||||
projection << provider.event_query_projection(activity) if provider.respond_to?(:event_query_projection) |
||||
|
||||
query.project(projection) |
||||
|
||||
fill_events(provider, activity, ActiveRecord::Base.connection.select_all(query.to_sql)) |
||||
end |
||||
|
||||
def join_with_projects_table(query, project_ref_table) |
||||
query.join(projects_table).on(projects_table[:id].eq(project_ref_table['project_id'])) |
||||
end |
||||
|
||||
def restrict_projects_by_selection(options, query) |
||||
if project = options[:project] |
||||
stmt = projects_table[:id].eq(project.id) |
||||
stmt = stmt.or(projects_table[:lft].gt(project.lft).and(projects_table[:rgt].lt(project.rgt))) if options[:with_subprojects] |
||||
|
||||
query = query.where(stmt) |
||||
end |
||||
|
||||
query |
||||
end |
||||
|
||||
def restrict_projects_by_permission(permission, query) |
||||
perm = OpenProject::AccessControl.permission(permission) |
||||
|
||||
query = query.where(projects_table[:active].eq(true)) |
||||
|
||||
if perm && perm.project_module |
||||
m = EnabledModule.arel_table |
||||
subquery = m.where(m[:name].eq(perm.project_module)) |
||||
.project(m[:project_id]) |
||||
|
||||
query = query.where(projects_table[:id].in(subquery)) |
||||
end |
||||
|
||||
query |
||||
end |
||||
|
||||
def restrict_projects_by_user(options, user, query) |
||||
return query if user.admin? |
||||
|
||||
stmt = nil |
||||
perm = OpenProject::AccessControl.permission(options[:permission]) |
||||
is_member = options[:member] |
||||
|
||||
if user.logged? |
||||
allowed_projects = [] |
||||
|
||||
user.projects_by_role.each do |role, projects| |
||||
allowed_projects << projects.map(&:id) if perm && role.allowed_to?(perm.name) |
||||
end |
||||
|
||||
stmt = projects_table[:id].in(allowed_projects.flatten.uniq) |
||||
end |
||||
|
||||
if perm && (Role.anonymous.allowed_to?(perm.name) || Role.non_member.allowed_to?(perm.name)) && !is_member |
||||
public_project = projects_table[:public].eq(true) |
||||
|
||||
stmt = stmt ? stmt.or(public_project) : public_project |
||||
end |
||||
|
||||
query = query.where(stmt) |
||||
|
||||
stmt ? query : nil |
||||
end |
||||
|
||||
def fill_events(provider, activity, events) |
||||
events.each_with_object([]) do |e, result| |
||||
datetime = e['event_datetime'].is_a?(String) ? DateTime.parse(e['event_datetime']) : e['event_datetime'] |
||||
event = Redmine::Acts::ActivityProvider::Event.new(self, |
||||
nil, |
||||
nil, |
||||
e['event_description'], |
||||
e['event_author'].to_i, |
||||
nil, |
||||
datetime, |
||||
e['journable_id'], |
||||
e['project_id'].to_i, |
||||
nil, |
||||
nil, |
||||
nil, |
||||
nil) |
||||
|
||||
begin |
||||
result << ((provider.respond_to?(:format_event)) ? provider.format_event(event, e, activity) : event) |
||||
rescue => e |
||||
Rails.logger.error "Failed to format_event for #{event.inspect}: #{e}" |
||||
end |
||||
end |
||||
end |
||||
|
||||
def projects_table |
||||
Project.arel_table |
||||
end |
||||
|
||||
def journals_table |
||||
Journal.arel_table |
||||
end |
||||
end |
||||
end |
||||
end |
||||
end |
||||
end |
@ -1,121 +0,0 @@ |
||||
#-- encoding: UTF-8 |
||||
#-- copyright |
||||
# OpenProject is an open source project management software. |
||||
# Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details. |
||||
#++ |
||||
|
||||
module Redmine |
||||
module Activity |
||||
# Class used to retrieve activity events |
||||
class Fetcher |
||||
attr_reader :user, :project, :scope |
||||
|
||||
# Needs to be unloaded in development mode |
||||
@@constantized_providers = Hash.new { |h, k| h[k] = Redmine::Activity.providers[k].map(&:constantize) } |
||||
|
||||
def initialize(user, options = {}) |
||||
options.assert_valid_keys(:project, :with_subprojects, :author) |
||||
@user = user |
||||
@project = options[:project] |
||||
@options = options |
||||
|
||||
@scope = event_types |
||||
end |
||||
|
||||
# Returns an array of available event types |
||||
def event_types |
||||
return @event_types unless @event_types.nil? |
||||
|
||||
@event_types = Redmine::Activity.available_event_types |
||||
if @project |
||||
@event_types = @event_types.select { |o| |
||||
@project.self_and_descendants.detect do |_p| |
||||
permissions = constantized_providers(o).map { |p| |
||||
p.activity_provider_options[o].try(:[], :permission) |
||||
}.compact |
||||
return @user.allowed_to?("view_#{o}".to_sym, @project) if permissions.blank? |
||||
permissions.all? { |p| @user.allowed_to?(p, @project) } |
||||
end |
||||
} |
||||
end |
||||
@event_types |
||||
end |
||||
|
||||
# Yields to filter the activity scope |
||||
def scope_select(&_block) |
||||
@scope = @scope.select { |t| yield t } |
||||
end |
||||
|
||||
# Sets the scope |
||||
# Argument can be :all, :default or an array of event types |
||||
def scope=(s) |
||||
case s |
||||
when :all |
||||
@scope = event_types |
||||
when :default |
||||
default_scope! |
||||
else |
||||
@scope = s & event_types |
||||
end |
||||
end |
||||
|
||||
# Resets the scope to the default scope |
||||
def default_scope! |
||||
@scope = Redmine::Activity.default_event_types |
||||
end |
||||
|
||||
# Returns an array of events for the given date range |
||||
# sorted in reverse chronological order |
||||
def events(from = nil, to = nil, options = {}) |
||||
e = [] |
||||
@options[:limit] = options[:limit] |
||||
|
||||
@scope.each do |event_type| |
||||
constantized_providers(event_type).each do |provider| |
||||
e += provider.find_events(event_type, @user, from, to, @options) |
||||
end |
||||
end |
||||
|
||||
projects = Project.find(e.map(&:project_id).compact) if e.select { |e| !e.project_id.nil? } |
||||
users = User.where(id: e.map(&:author_id).compact).to_a |
||||
|
||||
e.each do |e| |
||||
e.event_author = users.detect { |u| u.id == e.author_id } if e.author_id |
||||
e.project = projects.detect { |p| p.id == e.project_id } if e.project_id |
||||
end |
||||
|
||||
e.sort! do |a, b| b.event_datetime <=> a.event_datetime end |
||||
e |
||||
end |
||||
|
||||
private |
||||
|
||||
def constantized_providers(event_type) |
||||
@@constantized_providers[event_type] |
||||
end |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,264 @@ |
||||
#-- encoding: UTF-8 |
||||
#-- copyright |
||||
# OpenProject is an open source project management software. |
||||
# Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details. |
||||
#++ |
||||
|
||||
require 'spec_helper' |
||||
|
||||
describe Activities::Fetcher, 'integration', type: :model do |
||||
let(:project) { FactoryBot.create(:project) } |
||||
let(:permissions) { %i[view_work_packages view_time_entries view_changesets view_wiki_edits] } |
||||
|
||||
let(:user) do |
||||
FactoryBot.create(:user, |
||||
member_in_project: project, |
||||
member_with_permissions: permissions) |
||||
end |
||||
|
||||
let(:instance) { described_class.new(user, options) } |
||||
let(:options) { {} } |
||||
|
||||
describe '#events' do |
||||
let(:event_user) { user } |
||||
let(:work_package) { FactoryBot.create(:work_package, project: project, author: event_user) } |
||||
let(:forum) { FactoryBot.create(:forum, project: project) } |
||||
let(:message) { FactoryBot.create(:message, forum: forum, author: event_user) } |
||||
let(:news) { FactoryBot.create(:news, project: project, author: event_user) } |
||||
let(:time_entry) { FactoryBot.create(:time_entry, project: project, work_package: work_package, user: event_user) } |
||||
let(:repository) { FactoryBot.create(:repository_subversion, project: project) } |
||||
let(:changeset) { FactoryBot.create(:changeset, committer: event_user.login, repository: repository) } |
||||
let(:wiki) { FactoryBot.create(:wiki, project: project) } |
||||
let(:wiki_page) do |
||||
content = FactoryBot.build(:wiki_content, page: nil, author: event_user, text: 'some text') |
||||
FactoryBot.create(:wiki_page, wiki: wiki, content: content) |
||||
end |
||||
|
||||
subject { instance.events(Date.today - 30, Date.today + 1) } |
||||
|
||||
context 'activities globally' do |
||||
let!(:activities) { [work_package, message, news, time_entry, changeset, wiki_page.content] } |
||||
|
||||
it 'finds events of all type' do |
||||
expect(subject.map(&:journable_id)) |
||||
.to match_array(activities.map(&:id)) |
||||
end |
||||
|
||||
context 'if lacking permissions' do |
||||
let(:permissions) { %i[] } |
||||
|
||||
it 'finds only events for which permissions are present' do |
||||
# news and message only requires the user to be member |
||||
expect(subject.map(&:journable_id)) |
||||
.to match_array([message.id, news.id]) |
||||
end |
||||
end |
||||
|
||||
context 'if project has activity disabled' do |
||||
before do |
||||
project.enabled_module_names = project.enabled_module_names - ['activity'] |
||||
end |
||||
|
||||
it 'finds no events' do |
||||
expect(subject.map(&:journable_id)) |
||||
.to be_empty |
||||
end |
||||
end |
||||
|
||||
context 'if restricting the scope' do |
||||
before do |
||||
options[:scope] = %w(time_entries messages) |
||||
end |
||||
|
||||
it 'finds only events matching the scope' do |
||||
expect(subject.map(&:journable_id)) |
||||
.to match_array([message.id, time_entry.id]) |
||||
end |
||||
end |
||||
end |
||||
|
||||
context 'activities in a project' do |
||||
let(:options) { { project: project } } |
||||
let!(:activities) { [work_package, message, news, time_entry, changeset, wiki_page.content] } |
||||
|
||||
it 'finds events of all type' do |
||||
expect(subject.map(&:journable_id)) |
||||
.to match_array(activities.map(&:id)) |
||||
end |
||||
|
||||
context 'if lacking permissions' do |
||||
let(:permissions) { %i[] } |
||||
|
||||
it 'finds only events for which permissions are present' do |
||||
# news and message only requires the user to be member |
||||
expect(subject.map(&:journable_id)) |
||||
.to match_array([message.id, news.id]) |
||||
end |
||||
end |
||||
|
||||
context 'if project has activity disabled' do |
||||
before do |
||||
project.enabled_module_names = project.enabled_module_names - ['activity'] |
||||
end |
||||
|
||||
it 'finds no events' do |
||||
expect(subject.map(&:journable_id)) |
||||
.to be_empty |
||||
end |
||||
end |
||||
|
||||
context 'if restricting the scope' do |
||||
before do |
||||
options[:scope] = %w(time_entries messages) |
||||
end |
||||
|
||||
it 'finds only events matching the scope' do |
||||
expect(subject.map(&:journable_id)) |
||||
.to match_array([message.id, time_entry.id]) |
||||
end |
||||
end |
||||
end |
||||
|
||||
context 'activities in a subproject' do |
||||
let(:subproject) do |
||||
FactoryBot.create(:project, parent: project).tap do |
||||
project.reload |
||||
end |
||||
end |
||||
let(:subproject_news) { FactoryBot.create(:news, project: subproject) } |
||||
let(:subproject_member) do |
||||
FactoryBot.create(:member, |
||||
user: user, |
||||
project: subproject, |
||||
roles: [FactoryBot.create(:role, permissions: permissions)]) |
||||
end |
||||
|
||||
let!(:activities) { [news, subproject_news] } |
||||
|
||||
context 'if including subprojects' do |
||||
before do |
||||
subproject_member |
||||
end |
||||
|
||||
let(:options) { { project: project, with_subprojects: 1 } } |
||||
|
||||
it 'finds events in the subproject' do |
||||
expect(subject.map(&:journable_id)) |
||||
.to match_array(activities.map(&:id)) |
||||
end |
||||
end |
||||
|
||||
context 'if the subproject has activity disabled' do |
||||
before do |
||||
subproject.enabled_module_names = subproject.enabled_module_names - ['activity'] |
||||
end |
||||
|
||||
it 'lacks events from subproject' do |
||||
expect(subject.map(&:journable_id)) |
||||
.to match_array [news.id] |
||||
end |
||||
end |
||||
|
||||
context 'if lacking permissions for the subproject' do |
||||
let(:options) { { project: project, with_subprojects: 1 } } |
||||
|
||||
it 'lacks events from subproject' do |
||||
expect(subject.map(&:journable_id)) |
||||
.to match_array [news.id] |
||||
end |
||||
end |
||||
|
||||
context 'if excluding subprojects' do |
||||
before do |
||||
subproject_member |
||||
end |
||||
|
||||
let(:options) { { project: project } } |
||||
|
||||
it 'lacks events from subproject' do |
||||
expect(subject.map(&:journable_id)) |
||||
.to match_array [news.id] |
||||
end |
||||
end |
||||
end |
||||
|
||||
context 'activities of a user' do |
||||
let(:options) { { author: user } } |
||||
let!(:activities) do |
||||
# Login to have all the journals created as the user |
||||
login_as(user) |
||||
[work_package, message, news, time_entry, changeset, wiki_page.content] |
||||
end |
||||
|
||||
it 'finds events of all type' do |
||||
expect(subject.map(&:journable_id)) |
||||
.to match_array(activities.map(&:id)) |
||||
end |
||||
|
||||
context 'for a different user' do |
||||
let(:other_user) { FactoryBot.create(:user) } |
||||
let(:options) { { author: other_user } } |
||||
|
||||
it 'does not return the events made by the non queried for user' do |
||||
expect(subject.map(&:journable_id)) |
||||
.to be_empty |
||||
end |
||||
end |
||||
|
||||
context 'if project has activity disabled' do |
||||
before do |
||||
project.enabled_module_names = project.enabled_module_names - ['activity'] |
||||
end |
||||
|
||||
it 'finds no events' do |
||||
expect(subject.map(&:journable_id)) |
||||
.to be_empty |
||||
end |
||||
end |
||||
|
||||
context 'if lacking permissions' do |
||||
let(:permissions) { %i[] } |
||||
|
||||
it 'finds only events for which permissions are present' do |
||||
# news and message only requires the user to be member |
||||
expect(subject.map(&:journable_id)) |
||||
.to match_array([message.id, news.id]) |
||||
end |
||||
end |
||||
|
||||
context 'if restricting the scope' do |
||||
before do |
||||
options[:scope] = %w(time_entries messages) |
||||
end |
||||
|
||||
it 'finds only events matching the scope' do |
||||
expect(subject.map(&:journable_id)) |
||||
.to match_array([message.id, time_entry.id]) |
||||
end |
||||
end |
||||
end |
||||
end |
||||
end |
@ -1,147 +0,0 @@ |
||||
#-- encoding: UTF-8 |
||||
|
||||
#-- copyright |
||||
# OpenProject is an open source project management software. |
||||
# Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details. |
||||
#++ |
||||
|
||||
require_relative '../legacy_spec_helper' |
||||
|
||||
describe ActivitiesController, type: :controller do |
||||
fixtures :all |
||||
|
||||
render_views |
||||
|
||||
it 'project index' do |
||||
Journal.delete_all |
||||
public_project = FactoryBot.create :public_project |
||||
issue = FactoryBot.create :work_package, |
||||
project: public_project, |
||||
status_id: 2, |
||||
priority_id: 4, |
||||
author_id: 2, |
||||
start_date: 1.day.ago.to_date.to_s(:db), |
||||
due_date: 10.day.from_now.to_date.to_s(:db) |
||||
|
||||
FactoryBot.create :work_package_journal, |
||||
journable_id: issue.id, |
||||
created_at: 3.days.ago.to_date.to_s(:db), |
||||
data: FactoryBot.build(:journal_work_package_journal, |
||||
project_id: issue.project_id, |
||||
status_id: 1) |
||||
FactoryBot.create :work_package_journal, |
||||
journable_id: issue.id, |
||||
notes: 'Some notes with Redmine links: #2, r2.', |
||||
created_at: 1.days.ago.to_date.to_s(:db), |
||||
data: FactoryBot.build(:journal_work_package_journal, |
||||
subject: issue.subject, |
||||
status_id: 2, |
||||
type_id: issue.type_id, |
||||
project_id: issue.project_id) |
||||
|
||||
get :index, params: { id: 1, with_subprojects: 0 } |
||||
assert_response :success |
||||
assert_template 'index' |
||||
refute_nil assigns(:events_by_day) |
||||
|
||||
assert_select 'h3', |
||||
content: /#{1.day.ago.to_date.day}/, |
||||
sibling: { |
||||
tag: 'dl', |
||||
child: { |
||||
tag: 'dt', |
||||
attributes: { class: /work_package/ }, |
||||
child: { |
||||
tag: 'a', |
||||
content: /#{ERB::Util.h(Status.find(2).name)}/ |
||||
} |
||||
} |
||||
} |
||||
end |
||||
|
||||
it 'previous project index' do |
||||
issue = WorkPackage.find(1) |
||||
FactoryBot.create :work_package_journal, |
||||
journable_id: issue.id, |
||||
created_at: 3.days.ago.to_date.to_s(:db), |
||||
data: FactoryBot.build(:journal_work_package_journal, |
||||
subject: issue.subject, |
||||
status_id: issue.status_id, |
||||
type_id: issue.type_id, |
||||
project_id: issue.project_id) |
||||
|
||||
get :index, params: { id: 1, from: 3.days.ago.to_date } |
||||
assert_response :success |
||||
assert_template 'index' |
||||
refute_nil assigns(:events_by_day) |
||||
|
||||
assert_select 'h3', |
||||
content: /#{3.day.ago.to_date.day}/, |
||||
sibling: { |
||||
tag: 'dl', |
||||
child: { |
||||
tag: 'dt', |
||||
attributes: { class: /work_package/ }, |
||||
child: { |
||||
tag: 'a', |
||||
content: /#{ERB::Util.h(issue.subject)}/ |
||||
} |
||||
} |
||||
} |
||||
end |
||||
|
||||
it 'user index' do |
||||
issue = WorkPackage.find(1) |
||||
FactoryBot.create :work_package_journal, |
||||
journable_id: issue.id, |
||||
user_id: 2, |
||||
created_at: 3.days.ago.to_date.to_s(:db), |
||||
data: FactoryBot.build(:journal_work_package_journal, |
||||
subject: issue.subject, |
||||
status_id: issue.status_id, |
||||
type_id: issue.type_id, |
||||
project_id: issue.project_id) |
||||
|
||||
get :index, params: { user_id: 2 } |
||||
assert_response :success |
||||
assert_template 'index' |
||||
refute_nil assigns(:events_by_day) |
||||
|
||||
assert_select 'h3', |
||||
content: /#{3.day.ago.to_date.day}/, |
||||
sibling: { |
||||
tag: 'dl', |
||||
child: { |
||||
tag: 'dt', |
||||
attributes: { class: /work_package/ }, |
||||
child: { |
||||
tag: 'a', |
||||
content: /#{ERB::Util.h(WorkPackage.find(1).subject)}/ |
||||
} |
||||
} |
||||
} |
||||
end |
||||
end |
@ -1,107 +0,0 @@ |
||||
#-- encoding: UTF-8 |
||||
#-- copyright |
||||
# OpenProject is an open source project management software. |
||||
# Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details. |
||||
#++ |
||||
require_relative '../legacy_spec_helper' |
||||
|
||||
describe Redmine::Activity, type: :model do |
||||
fixtures :all |
||||
|
||||
before do |
||||
@project = Project.find(1) |
||||
[1, 4, 5, 6].each do |issue_id| |
||||
i = WorkPackage.find(issue_id) |
||||
i.add_journal(User.current, 'A journal to find') |
||||
i.save! |
||||
end |
||||
|
||||
WorkPackage.all.each(&:recreate_initial_journal!) |
||||
Message.all.each(&:recreate_initial_journal!) |
||||
end |
||||
|
||||
after do |
||||
Journal.delete_all |
||||
end |
||||
|
||||
it 'should activity without subprojects' do |
||||
events = find_events(User.anonymous, project: @project) |
||||
refute_nil events |
||||
|
||||
assert events.include?(WorkPackage.find(1)) |
||||
assert !events.include?(WorkPackage.find(4)) |
||||
# subproject issue |
||||
assert !events.include?(WorkPackage.find(5)) |
||||
end |
||||
|
||||
it 'should activity with subprojects' do |
||||
events = find_events(User.anonymous, project: @project, with_subprojects: 1) |
||||
refute_nil events |
||||
|
||||
assert events.include?(WorkPackage.find(1)) |
||||
# subproject issue |
||||
assert events.include?(WorkPackage.find(5)) |
||||
end |
||||
|
||||
it 'should global activity anonymous' do |
||||
events = find_events(User.anonymous) |
||||
refute_nil events |
||||
|
||||
assert events.include?(WorkPackage.find(1)) |
||||
assert events.include?(Message.find(5)) |
||||
# Issue of a private project |
||||
assert !events.include?(WorkPackage.find(6)) |
||||
end |
||||
|
||||
it 'should global activity logged user' do |
||||
events = find_events(User.find(2)) # manager |
||||
refute_nil events |
||||
|
||||
assert events.include?(WorkPackage.find(1)) |
||||
# Issue of a private project the user belongs to |
||||
assert events.include?(WorkPackage.find(6)) |
||||
end |
||||
|
||||
it 'should user activity' do |
||||
user = User.find(2) |
||||
events = Redmine::Activity::Fetcher.new(User.anonymous, author: user).events(nil, nil, limit: 10) |
||||
|
||||
assert(events.size > 0) |
||||
assert(events.size <= 10) |
||||
assert_nil(events.detect { |e| e.event_author != user }) |
||||
end |
||||
|
||||
private |
||||
|
||||
def find_events(user, options = {}) |
||||
events = Redmine::Activity::Fetcher.new(user, options).events(Date.today - 30, Date.today + 1) |
||||
# Because events are provided by the journals, but we want to test for |
||||
# their targets here, transform that |
||||
events.map do |e| |
||||
e.provider.new.activitied_type.find(e.journable_id) |
||||
end |
||||
end |
||||
end |
Loading…
Reference in new issue