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
ulferts 5 years ago committed by GitHub
parent 13a05b7823
commit 64d0f57d85
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 110
      app/controllers/activities_controller.rb
  2. 8
      app/controllers/statuses_controller.rb
  3. 2
      app/controllers/time_entries/reports_controller.rb
  4. 2
      app/controllers/timelog_controller.rb
  5. 4
      app/controllers/users_controller.rb
  6. 5
      app/controllers/wiki_controller.rb
  7. 243
      app/models/activities/base_activity_provider.rb
  8. 39
      app/models/activities/changeset_activity_provider.rb
  9. 16
      app/models/activities/event.rb
  10. 144
      app/models/activities/fetcher.rb
  11. 29
      app/models/activities/message_activity_provider.rb
  12. 21
      app/models/activities/news_activity_provider.rb
  13. 53
      app/models/activities/time_entry_activity_provider.rb
  14. 21
      app/models/activities/wiki_content_activity_provider.rb
  15. 41
      app/models/activities/work_package_activity_provider.rb
  16. 225
      app/models/journal/aggregated_journal.rb
  17. 359
      app/models/journal/scopes/aggregated_journal.rb
  18. 1
      app/models/journal/wiki_content_journal.rb
  19. 8
      app/models/project.rb
  20. 29
      app/views/activities/index.html.erb
  21. 28
      app/views/common/feed.atom.builder
  22. 25
      config/constants/open_project/activity.rb
  23. 3
      config/initializers/activity.rb
  24. 1
      lib/open_project.rb
  25. 16
      lib/open_project/plugins/acts_as_op_engine.rb
  26. 31
      lib/plugins/acts_as_activity_provider/init.rb
  27. 236
      lib/plugins/acts_as_activity_provider/lib/acts_as_activity_provider.rb
  28. 1
      lib/plugins/acts_as_event/lib/acts_as_event.rb
  29. 121
      lib/redmine/activity/fetcher.rb
  30. 6
      lib/redmine/plugin.rb
  31. 7
      modules/avatars/app/services/avatars/update_service.rb
  32. 6
      modules/avatars/lib/open_project/avatars/patches/avatar_helper_patch.rb
  33. 33
      modules/avatars/lib/open_project/avatars/patches/user_patch.rb
  34. 1
      modules/costs/app/controllers/costlog_controller.rb
  35. 18
      modules/costs/app/models/activities/cost_object_activity_provider.rb
  36. 6
      modules/costs/lib/open_project/costs/engine.rb
  37. 28
      modules/costs/spec/features/time_entries_spec.rb
  38. 18
      modules/documents/app/models/activities/document_activity_provider.rb
  39. 6
      modules/documents/lib/open_project/documents/engine.rb
  40. 68
      modules/meeting/app/models/activities/meeting_activity_provider.rb
  41. 7
      modules/meeting/lib/open_project/meeting/engine.rb
  42. 3
      modules/reporting/app/controllers/cost_reports_controller.rb
  43. 103
      spec/controllers/activities_controller_spec.rb
  44. 2
      spec/factories/changeset_factory.rb
  45. 264
      spec/models/activities/fetcher_integration_spec.rb
  46. 135
      spec/models/activities/work_package_activity_provider_spec.rb
  47. 87
      spec/models/journal/aggregated_journal_spec.rb
  48. 4
      spec/models/repository/git_spec.rb
  49. 4
      spec/models/repository/subversion_spec.rb
  50. 147
      spec_legacy/functional/activities_controller_spec.rb
  51. 107
      spec_legacy/unit/activity_spec.rb

@ -1,4 +1,5 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 the OpenProject GmbH
@ -29,46 +30,33 @@
class ActivitiesController < ApplicationController
menu_item :activity
before_action :find_optional_project, :verify_activities_module_activated
accept_key_auth :index
before_action :find_optional_project,
:verify_activities_module_activated,
:determine_date_range,
:determine_subprojects,
:determine_author
def index
@days = Setting.activity_days_default.to_i
after_action :set_session
if params[:from]
begin; @date_to = params[:from].to_date + 1.day; rescue; end
end
@date_to ||= User.current.today + 1.day
@date_from = @date_to - @days
@with_subprojects = params[:with_subprojects].nil? ? Setting.display_subprojects_work_packages? : (params[:with_subprojects] == '1')
@author = (params[:user_id].blank? ? nil : User.active.find(params[:user_id]))
@activity = Redmine::Activity::Fetcher.new(User.current, project: @project,
with_subprojects: @with_subprojects,
author: @author)
accept_key_auth :index
set_activity_scope
def index
@activity = Activities::Fetcher.new(User.current,
project: @project,
with_subprojects: @with_subprojects,
author: @author,
scope: activity_scope)
events = @activity.events(@date_from, @date_to)
censor_events_from_projects_with_disabled_activity!(events) unless @project
respond_to do |format|
format.html do
@events_by_day = events.group_by { |e| e.event_datetime.in_time_zone(User.current.time_zone).to_date }
render layout: false if request.xhr?
respond_html(events)
end
format.atom do
title = l(:label_activity)
if @author
title = @author.name
elsif @activity.scope.size == 1
title = l("label_#{@activity.scope.first.singularize}_plural")
end
render_feed(events, title: "#{@project || Setting.app_title}: #{title}")
respond_atom(events)
end
end
rescue ActiveRecord::RecordNotFound => e
op_handle_warning "Failed to find all resources in activities: #{e.message}"
render_404 I18n.t(:error_can_not_find_all_resources)
@ -76,39 +64,61 @@ class ActivitiesController < ApplicationController
private
# TODO: this should now be functionally identical to the implementation in application_controller
# double check and remove
def find_optional_project
return true unless params[:project_id]
@project = Project.find(params[:project_id])
authorize
rescue ActiveRecord::RecordNotFound
render_404
end
def verify_activities_module_activated
render_403 if @project && !@project.module_enabled?('activity')
end
# Do not show events, which are associated with projects where activities are disabled.
# In a better world this would be implemented (with better performance) in SQL.
# TODO: make the world a better place.
def censor_events_from_projects_with_disabled_activity!(events)
allowed_project_ids = EnabledModule.where(name: 'activity').map(&:project_id)
events.select! do |event|
event.project_id.nil? || allowed_project_ids.include?(event.project_id)
def determine_date_range
@days = Setting.activity_days_default.to_i
if params[:from]
begin; @date_to = params[:from].to_date + 1.day; rescue; end
end
@date_to ||= User.current.today + 1.day
@date_from = @date_to - @days
end
def determine_subprojects
@with_subprojects = if params[:with_subprojects].nil?
Setting.display_subprojects_work_packages?
else
params[:with_subprojects] == '1'
end
end
def determine_author
@author = params[:user_id].blank? ? nil : User.active.find(params[:user_id])
end
def respond_html(events)
@events_by_day = events.group_by { |e| e.event_datetime.in_time_zone(User.current.time_zone).to_date }
render layout: !request.xhr?
end
def respond_atom(events)
title = t(:label_activity)
if @author
title = @author.name
elsif @activity.scope.size == 1
title = t("label_#{@activity.scope.first.singularize}_plural")
end
render_feed(events, title: "#{@project || Setting.app_title}: #{title}")
end
def set_activity_scope
if params[:apply]
@activity.scope_select { |t| !params["show_#{t}"].nil? }
def activity_scope
if params[:event_types]
params[:event_types]
elsif session[:activity]
@activity.scope = session[:activity]
session[:activity]
elsif @author.nil?
:default
else
@activity.scope = (@author.nil? ? :default : :all)
:all
end
end
def set_session
session[:activity] = @activity.scope
end
end

@ -34,7 +34,6 @@ class StatusesController < ApplicationController
before_action :require_admin
verify method: :get, only: :index, render: { nothing: true, status: :method_not_allowed }
def index
@statuses = Status.page(page_param)
.per_page(per_page_param)
@ -42,12 +41,10 @@ class StatusesController < ApplicationController
render action: 'index', layout: false if request.xhr?
end
verify method: :get, only: :new, render: { nothing: true, status: :method_not_allowed }
def new
@status = Status.new
end
verify method: :post, only: :create, render: { nothing: true, status: :method_not_allowed }
def create
@status = Status.new(permitted_params.status)
if @status.save
@ -58,12 +55,10 @@ class StatusesController < ApplicationController
end
end
verify method: :get, only: :edit, render: { nothing: true, status: :method_not_allowed }
def edit
@status = Status.find(params[:id])
end
verify method: :patch, only: :update, render: { nothing: true, status: :method_not_allowed }
def update
@status = Status.find(params[:id])
if @status.update(permitted_params.status)
@ -74,7 +69,6 @@ class StatusesController < ApplicationController
end
end
verify method: :delete, only: :destroy, render: { nothing: true, status: :method_not_allowed }
def destroy
status = Status.find(params[:id])
if status.is_default?
@ -89,8 +83,6 @@ class StatusesController < ApplicationController
redirect_to action: 'index'
end
verify method: :post, only: :update_work_package_done_ratio,
render: { nothing: true, status: 405 }
def update_work_package_done_ratio
if Status.update_work_package_done_ratios
flash[:notice] = l(:notice_work_package_done_ratios_updated)

@ -193,7 +193,7 @@ class TimeEntries::ReportsController < ApplicationController
if @project.nil?
project_context_sql_condition
elsif @issue.nil?
@project.project_condition(Setting.display_subprojects_work_packages?)
@project.project_condition(Setting.display_subprojects_work_packages?).to_sql
else
WorkPackage.self_and_descendants_of_condition(@issue)
end

@ -62,7 +62,7 @@ class TimelogController < ApplicationController
if @issue
cond << WorkPackage.self_and_descendants_of_condition(@issue)
elsif @project
cond << @project.project_condition(Setting.display_subprojects_work_packages?)
cond << @project.project_condition(Setting.display_subprojects_work_packages?).to_sql
end
retrieve_date_range allow_nil: true

@ -76,7 +76,7 @@ class UsersController < ApplicationController
@memberships = @user.memberships
.visible(current_user)
events = Redmine::Activity::Fetcher.new(User.current, author: @user).events(nil, nil, limit: 10)
events = Activities::Fetcher.new(User.current, author: @user).events(nil, nil, limit: 10)
@events_by_day = events.group_by { |e| e.event_datetime.to_date }
unless User.current.admin?
@ -99,7 +99,6 @@ class UsersController < ApplicationController
@auth_sources = AuthSource.all
end
verify method: :post, only: :create, render: { nothing: true, status: :method_not_allowed }
def create
@user = User.new(language: Setting.default_language,
mail_notification: Setting.default_notification_option)
@ -128,7 +127,6 @@ class UsersController < ApplicationController
@membership ||= Member.new
end
verify method: :put, only: :update, render: { nothing: true, status: :method_not_allowed }
def update
@user.attributes = permitted_params.user_update_as_admin(@user.uses_external_authentication?,
@user.change_password_allowed?)

@ -55,10 +55,6 @@ class WikiController < ApplicationController
destroy]
before_action :build_wiki_page_and_content, only: %i[new create]
verify method: :post, only: [:protect], redirect_to: { action: :show }
verify method: :get, only: %i[new new_child], render: { nothing: true, status: :method_not_allowed }
verify method: :post, only: :create, render: { nothing: true, status: :method_not_allowed }
include AttachmentsHelper
include PaginationHelper
include Redmine::MenuManager::WikiMenuHelper
@ -298,7 +294,6 @@ class WikiController < ApplicationController
render_404 unless @annotate
end
verify method: :delete, only: [:destroy], redirect_to: { action: :show }
# Removes a wiki page and its history
# Children can be either set as root pages, removed or reassigned to another parent page
def destroy

@ -1,4 +1,5 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 the OpenProject GmbH
@ -42,17 +43,64 @@
# See the comments on the methods to get additional information. #
###############################################################################
class Activities::BaseActivityProvider
include Redmine::Acts::ActivityProvider
include I18n
include Redmine::I18n
include OpenProject::StaticRouting
class_attribute :activity_provider_options
# Returns events of type event_type visible by user that occured between from and to
def self.find_events(event_type, user, from, to, options)
raise "#{name} can not provide #{event_type} events." if activity_provider_options[:type] != event_type
activity_provider_options[:activities]
.map { |activity| new(activity).find_events(user, from, to, options) }
.flatten
end
def initialize(activity)
self.activity = activity
end
def self.activity_provider_for(options = {})
options.assert_valid_keys(:type, :permission, :activities, :aggregated)
self.activity_provider_options = {
type: name.underscore.pluralize,
activities: [:activity],
aggregated: false,
permission: "view_#{name.underscore.pluralize}".to_sym
}.merge(options)
end
def find_events(user, from, to, options)
query = if aggregated?
aggregated_event_selection_query(user, from, to, options)
else
event_selection_query(user, from, to, options)
end
query = apply_order(query)
query = apply_limit(query, options)
query = apply_event_projection(query)
fill_events(query)
end
def fill_events(events_query)
ActiveRecord::Base.connection.select_all(events_query.to_sql).map do |e|
params = event_params(e)
Activities::Event.new(**params) if params
end
end
#############################################################################
# Activities may need information not available in the journal table. Thus, #
# if you need further information from different tables (e.g., projects #
# table) you may extend the query in this method. #
#############################################################################
def extend_event_query(_query, _activity)
def extend_event_query(query)
query
end
#############################################################################
@ -61,90 +109,207 @@ class Activities::BaseActivityProvider
# You must at least return the column containing the project reference with #
# the alias 'project_id'. #
#############################################################################
def event_query_projection(_activity)
def event_query_projection
[]
end
def event_datetime(event)
event['event_datetime'].is_a?(String) ? DateTime.parse(event['event_datetime']) : event['event_datetime']
end
def event_type(_event_data)
activity_provider_options[:type]
end
#############################################################################
# Override this method if the journal table does not contain a reference to #
# the 'projects' table. #
#############################################################################
def projects_reference_table(activity)
activity_journals_table(activity)
def projects_reference_table
activity_journals_table
end
def filter_for_event_datetime(query, journals_table, typed_journals_table, from, to)
if from
query = query.where(journals_table[:created_at].gteq(from))
end
def activitied_type
activity_type = self.class.name
class_name = activity_type.demodulize
class_name.gsub('ActivityProvider', '').constantize
end
protected
if to
query = query.where(journals_table[:created_at].lteq(to))
def event_selection_query(user, from, to, options)
query = journals_with_data_query
query = extend_event_query(query) unless aggregated?
query = filter_for_event_datetime(query, from, to)
query = restrict_user(query, options)
restrict_projects(query, user, options)
end
def apply_event_projection(query)
projection = event_projection
projection << event_query_projection if respond_to?(:event_query_projection)
query.project(projection)
end
# When aggregating, we add the query that actually fetches the journals,
# restricted by from, to, user permission, etc. as a CTE. That way,
# that query has only to be executed once inside the aggregated journal query which
# considerably reduces execution time.
def aggregated_event_selection_query(user, from, to, options)
query = aggregated_journal_query
query = add_event_selection_query_as_cte(query, user, from, to, options)
query = join_activity_journals_table(query)
query = extend_event_query(query)
join_with_projects_table(query)
end
def apply_limit(query, options)
if options[:limit]
query.take(options[:limit])
else
query
end
end
def filter_for_event_datetime(query, from, to)
query = query.where(journals_table[:created_at].gteq(from)) if from
query = query.where(journals_table[:created_at].lteq(to)) if to
query
end
def activity_journals_table(_activity)
@activity_journals_table ||= JournalManager.journal_class(activitied_type).arel_table
def apply_order(query)
query.order(journals_table[:id].desc)
end
def activitied_type(_activity = nil)
activity_type = self.class.name
def event_params(event_data)
params = { provider: self,
event_description: event_data['event_description'],
author_id: event_data['event_author'].to_i,
journable_id: event_data['journable_id'],
project_id: event_data['project_id'].to_i }
class_name = activity_type.demodulize
class_name.gsub('ActivityProvider', '').constantize
%i[event_name event_title event_type event_description event_datetime event_path event_url].each do |a|
params[a] = send(a, event_data) if self.class.method_defined? a
end
params
rescue StandardError => e
Rails.logger.error "Failed to deduce event params for #{event_data.inspect}: #{e}"
end
def format_event(event, event_data, activity)
[:event_name, :event_title, :event_type, :event_description, :event_datetime, :event_path, :event_url].each do |a|
event[a] = send(a, event_data, activity) if self.class.method_defined? a
def event_projection
[[:id, 'event_id'],
[:created_at, 'event_datetime'],
[:user_id, 'event_author'],
[:notes, 'event_description'],
[:version, 'version'],
[:journable_id, 'journable_id']].map do |column, alias_name|
journals_table[column].as(alias_name)
end
end
event
def join_with_projects_table(query)
query.join(projects_table).on(projects_table[:id].eq(projects_reference_table['project_id']))
end
protected
def restrict_user(query, options)
query = query.where(journals_table[:user_id].eq(options[:author].id)) if options[:author]
query
end
def journal_table
@journal_table ||= Journal.arel_table
def restrict_projects(query, user, options)
query = join_with_projects_table(query)
query = restrict_projects_by_selection(options, query)
query = restrict_projects_by_activity_module(query)
restrict_projects_by_permission(query, user)
end
def activitied_table
@activitied_table ||= activitied_type.arel_table
def restrict_projects_by_selection(options, query)
if (project = options[:project])
query = query.where(project.project_condition(options[:with_subprojects]))
end
query
end
def restrict_projects_by_activity_module(query)
# Have to use the string based where here as the resulting
# sql would otherwise expect a parameter for the prepared statement.
query.where(projects_table[:id].in(EnabledModule.where("name = 'activity'").select(:project_id).arel))
end
def restrict_projects_by_permission(query, user)
perm = activity_provider_options[:permission]
query.where(projects_table[:id].in(Project.allowed_to(user, perm).select(:id).arel))
end
def aggregated_journal_query
# As AggregatedJournal wraps the provided sql statement inside brackets we
# need to provide a fully valid statement and not only the alias string.
Journal::Scopes::AggregatedJournal.fetch(sql: "SELECT * FROM #{aggregated_journals_alias}").arel
end
def add_event_selection_query_as_cte(query, user, from, to, options)
cte_query = event_selection_query(user, from, to, options).project('journals.*')
cte = Arel::Nodes::As.new(Arel::Table.new(aggregated_journals_alias), cte_query)
query.with(cte)
end
def work_packages_table
@work_packages_table ||= WorkPackage.arel_table
attr_accessor :activity
def aggregated?
activity_provider_options[:aggregated]
end
def journals_with_data_query
join_activity_journals_table(journals_table)
.where(journals_table[:journable_type].eq(activitied_type.name))
end
def join_activity_journals_table(query)
query
.join(activity_journals_table).on(journals_table[:id].eq(activity_journals_table[:journal_id]))
end
def journals_table
Journal.arel_table
end
def activitied_table
@activitied_table ||= activitied_type.arel_table
end
def projects_table
@projects_table ||= Project.arel_table
end
def types_table
@types_table = Type.arel_table
def enabled_modules_table
@enabled_modules_table ||= EnabledModule.arel_table
end
def statuses_table
@statuses_table = Status.arel_table
def activity_journals_table
@activity_journals_table ||= JournalManager.journal_class(activitied_type).arel_table
end
def activity_journal_projection_statement(column, name, activity)
projection_statement(activity_journals_table(activity), column, name)
def activity_journal_projection_statement(column, name)
projection_statement(activity_journals_table, column, name)
end
def projection_statement(table, column, name)
table[column].as(name)
end
class UndefinedEventTypeError < StandardError; end
def event_type(_event, _activity)
raise UndefinedEventTypeError.new('Abstract method event_type called')
def event_name(event)
I18n.t(event_type(event).underscore, scope: 'events')
end
def event_name(event, activity)
I18n.t(event_type(event, activity).underscore, scope: 'events')
def aggregated_journals_alias
:relevant_journals
end
def url_helpers

@ -1,4 +1,5 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 the OpenProject GmbH
@ -28,37 +29,37 @@
#++
class Activities::ChangesetActivityProvider < Activities::BaseActivityProvider
acts_as_activity_provider type: 'changesets',
permission: :view_changesets
activity_provider_for type: 'changesets',
permission: :view_changesets
def extend_event_query(query, activity)
query.join(repositories_table).on(activity_journals_table(activity)[:repository_id].eq(repositories_table[:id]))
def extend_event_query(query)
query.join(repositories_table).on(activity_journals_table[:repository_id].eq(repositories_table[:id]))
end
def event_query_projection(activity)
def event_query_projection
[
activity_journal_projection_statement(:revision, 'revision', activity),
activity_journal_projection_statement(:comments, 'comments', activity),
activity_journal_projection_statement(:committed_on, 'committed_on', activity),
activity_journal_projection_statement(:revision, 'revision'),
activity_journal_projection_statement(:comments, 'comments'),
activity_journal_projection_statement(:committed_on, 'committed_on'),
projection_statement(repositories_table, :project_id, 'project_id'),
projection_statement(repositories_table, :type, 'repository_type')
]
end
def projects_reference_table(_activity)
def projects_reference_table
repositories_table
end
##
# Override this method if not the journal created_at datetime, but another column
# value is the actual relevant time event. (e..g., commit date)
def filter_for_event_datetime(query, journals_table, typed_journals_table, from, to)
def filter_for_event_datetime(query, from, to)
if from
query = query.where(typed_journals_table[:committed_on].gteq(from))
query = query.where(activity_journals_table[:committed_on].gteq(from))
end
if to
query = query.where(typed_journals_table[:committed_on].lteq(to))
query = query.where(activity_journals_table[:committed_on].lteq(to))
end
query
@ -66,11 +67,11 @@ class Activities::ChangesetActivityProvider < Activities::BaseActivityProvider
protected
def event_type(_event, _activity)
def event_type(_event)
'changeset'
end
def event_title(event, _activity)
def event_title(event)
revision = format_revision(event)
short_comment = split_comment(event['comments']).first
@ -79,20 +80,20 @@ class Activities::ChangesetActivityProvider < Activities::BaseActivityProvider
title << (short_comment.blank? ? '' : (': ' + short_comment))
end
def event_description(event, _activity)
def event_description(event)
split_comment(event['comments']).last
end
def event_datetime(event, _activity)
def event_datetime(event)
committed_on = event['committed_on']
committed_date = committed_on.is_a?(String) ? DateTime.parse(committed_on) : committed_on
committed_on.is_a?(String) ? DateTime.parse(committed_on) : committed_on
end
def event_path(event, _activity)
def event_path(event)
url_helpers.revisions_project_repository_path(url_helper_parameter(event))
end
def event_url(event, _activity)
def event_url(event)
url_helpers.revisions_project_repository_url(url_helper_parameter(event))
end

@ -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

@ -1,4 +1,5 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 the OpenProject GmbH
@ -28,47 +29,47 @@
#++
class Activities::MessageActivityProvider < Activities::BaseActivityProvider
acts_as_activity_provider type: 'messages',
permission: :view_messages
activity_provider_for type: 'messages',
permission: :view_messages
def extend_event_query(query, activity)
query.join(forums_table).on(activity_journals_table(activity)[:forum_id].eq(forums_table[:id]))
def extend_event_query(query)
query.join(forums_table).on(activity_journals_table[:forum_id].eq(forums_table[:id]))
end
def event_query_projection(activity)
def event_query_projection
[
activity_journal_projection_statement(:subject, 'message_subject', activity),
activity_journal_projection_statement(:content, 'message_content', activity),
activity_journal_projection_statement(:parent_id, 'message_parent_id', activity),
activity_journal_projection_statement(:subject, 'message_subject'),
activity_journal_projection_statement(:content, 'message_content'),
activity_journal_projection_statement(:parent_id, 'message_parent_id'),
projection_statement(forums_table, :id, 'forum_id'),
projection_statement(forums_table, :name, 'forum_name'),
projection_statement(forums_table, :project_id, 'project_id')
]
end
def projects_reference_table(_activity)
def projects_reference_table
forums_table
end
protected
def event_title(event, _activity)
def event_title(event)
"#{event['forum_name']}: #{event['message_subject']}"
end
def event_description(event, _activity)
def event_description(event)
event['message_content']
end
def event_type(event, _activity)
def event_type(event)
event['parent_id'].blank? ? 'message' : 'reply'
end
def event_path(event, _activity)
def event_path(event)
url_helpers.topic_path(*url_helper_parameter(event))
end
def event_url(event, _activity)
def event_url(event)
url_helpers.topic_url(*url_helper_parameter(event))
end

@ -28,34 +28,31 @@
#++
class Activities::NewsActivityProvider < Activities::BaseActivityProvider
acts_as_activity_provider type: 'news',
permission: :view_news
activity_provider_for type: 'news',
permission: :view_news
def extend_event_query(_query, _activity)
end
def event_query_projection(activity)
def event_query_projection
[
activity_journal_projection_statement(:title, 'title', activity),
activity_journal_projection_statement(:project_id, 'project_id', activity)
activity_journal_projection_statement(:title, 'title'),
activity_journal_projection_statement(:project_id, 'project_id')
]
end
protected
def event_title(event, _activity)
def event_title(event)
event['title']
end
def event_type(_event, _activity)
def event_type(_event)
'news'
end
def event_path(event, _activity)
def event_path(event)
url_helpers.news_path(url_helper_parameter(event))
end
def event_url(event, _activity)
def event_url(event)
url_helpers.news_url(url_helper_parameter(event))
end

@ -1,4 +1,5 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 the OpenProject GmbH
@ -28,21 +29,21 @@
#++
class Activities::TimeEntryActivityProvider < Activities::BaseActivityProvider
acts_as_activity_provider type: 'time_entries',
permission: :view_time_entries
activity_provider_for type: 'time_entries',
permission: :view_time_entries
def extend_event_query(query, activity)
query.join(work_packages_table).on(activity_journals_table(activity)[:work_package_id].eq(work_packages_table[:id]))
def extend_event_query(query)
query.join(work_packages_table).on(activity_journals_table[:work_package_id].eq(work_packages_table[:id]))
query.join(types_table).on(work_packages_table[:type_id].eq(types_table[:id]))
query.join(statuses_table).on(work_packages_table[:status_id].eq(statuses_table[:id]))
end
def event_query_projection(activity)
def event_query_projection
[
activity_journal_projection_statement(:hours, 'time_entry_hours', activity),
activity_journal_projection_statement(:comments, 'time_entry_comments', activity),
activity_journal_projection_statement(:project_id, 'project_id', activity),
activity_journal_projection_statement(:work_package_id, 'work_package_id', activity),
activity_journal_projection_statement(:hours, 'time_entry_hours'),
activity_journal_projection_statement(:comments, 'time_entry_comments'),
activity_journal_projection_statement(:project_id, 'project_id'),
activity_journal_projection_statement(:work_package_id, 'work_package_id'),
projection_statement(projects_table, :name, 'project_name'),
projection_statement(work_packages_table, :subject, 'work_package_subject'),
projection_statement(statuses_table, :name, 'status_name'),
@ -53,12 +54,12 @@ class Activities::TimeEntryActivityProvider < Activities::BaseActivityProvider
protected
def event_title(event, _activity)
def event_title(event)
time_entry_object_name = event['work_package_id'].blank? ? event['project_name'] : work_package_title(event)
"#{l_hours(event['time_entry_hours'])} (#{time_entry_object_name})"
end
def event_type(_event, _activity)
def event_type(_event)
'time-entry'
end
@ -70,23 +71,35 @@ class Activities::TimeEntryActivityProvider < Activities::BaseActivityProvider
event['is_standard'])
end
def event_description(event, _activity)
def event_description(event)
event['time_entry_description']
end
def event_path(event, _activity)
unless event['work_package_id'].blank?
url_helpers.work_package_time_entries_path(event['work_package_id'])
else
def event_path(event)
if event['work_package_id'].present?
url_helpers.project_time_entries_path(event['project_id'])
else
url_helpers.work_package_time_entries_path(event['work_package_id'])
end
end
def event_url(event, _activity)
unless event['work_package_id'].blank?
url_helpers.work_package_time_entries_url(event['work_package_id'])
else
def event_url(event)
if event['work_package_id'].present?
url_helpers.project_time_entries_url(event['project_id'])
else
url_helpers.work_package_time_entries_url(event['work_package_id'])
end
end
def types_table
@types_table = Type.arel_table
end
def statuses_table
@statuses_table = Status.arel_table
end
def work_packages_table
@work_packages_table ||= WorkPackage.arel_table
end
end

@ -1,4 +1,5 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 the OpenProject GmbH
@ -28,40 +29,40 @@
#++
class Activities::WikiContentActivityProvider < Activities::BaseActivityProvider
acts_as_activity_provider type: 'wiki_edits',
permission: :view_wiki_edits
activity_provider_for type: 'wiki_edits',
permission: :view_wiki_edits
def extend_event_query(query, activity)
query.join(wiki_pages_table).on(activity_journals_table(activity)[:page_id].eq(wiki_pages_table[:id]))
def extend_event_query(query)
query.join(wiki_pages_table).on(activity_journals_table[:page_id].eq(wiki_pages_table[:id]))
query.join(wikis_table).on(wiki_pages_table[:wiki_id].eq(wikis_table[:id]))
end
def event_query_projection(_activity)
def event_query_projection
[
projection_statement(wikis_table, :project_id, 'project_id'),
projection_statement(wiki_pages_table, :title, 'wiki_title')
]
end
def projects_reference_table(_activity)
def projects_reference_table
wikis_table
end
protected
def event_title(event, _activity)
def event_title(event)
"#{l(:label_wiki_edit)}: #{event['wiki_title']} (##{event['version']})"
end
def event_type(_event, _activity)
def event_type(_event)
'wiki-page'
end
def event_path(event, _activity)
def event_path(event)
url_helpers.project_wiki_path(*url_helper_parameter(event))
end
def event_url(event, _activity)
def event_url(event)
url_helpers.project_wiki_url(*url_helper_parameter(event))
end

@ -28,18 +28,19 @@
#++
class Activities::WorkPackageActivityProvider < Activities::BaseActivityProvider
acts_as_activity_provider type: 'work_packages',
permission: :view_work_packages
activity_provider_for type: 'work_packages',
aggregated: true,
permission: :view_work_packages
def extend_event_query(query, activity)
query.join(types_table).on(activity_journals_table(activity)[:type_id].eq(types_table[:id]))
query.join(statuses_table).on(activity_journals_table(activity)[:status_id].eq(statuses_table[:id]))
def extend_event_query(query)
query.join(types_table).on(activity_journals_table[:type_id].eq(types_table[:id]))
query.join(statuses_table).on(activity_journals_table[:status_id].eq(statuses_table[:id]))
end
def event_query_projection(activity)
def event_query_projection
[
activity_journal_projection_statement(:subject, 'subject', activity),
activity_journal_projection_statement(:project_id, 'project_id', activity),
activity_journal_projection_statement(:subject, 'subject'),
activity_journal_projection_statement(:project_id, 'project_id'),
projection_statement(statuses_table, :name, 'status_name'),
projection_statement(statuses_table, :is_closed, 'status_closed'),
projection_statement(types_table, :name, 'type_name')
@ -47,13 +48,13 @@ class Activities::WorkPackageActivityProvider < Activities::BaseActivityProvider
end
def self.work_package_title(id, subject, type_name, status_name, is_standard)
title = "#{(is_standard) ? '' : "#{type_name}"} ##{id}: #{subject}"
title = "#{is_standard ? '' : "#{type_name}"} ##{id}: #{subject}"
title << " (#{status_name})" unless status_name.blank?
end
protected
def event_title(event, _activity)
def event_title(event)
self.class.work_package_title(event['journable_id'],
event['subject'],
event['type_name'],
@ -61,17 +62,17 @@ class Activities::WorkPackageActivityProvider < Activities::BaseActivityProvider
event['is_standard'])
end
def event_type(event, _activity)
def event_type(event)
state = ActiveRecord::Type::Boolean.new.cast(event['status_closed']) ? '-closed' : '-edit'
"work_package#{state}"
end
def event_path(event, _activity)
def event_path(event)
url_helpers.work_package_path(event['journable_id'])
end
def event_url(event, _activity)
def event_url(event)
url_helpers.work_package_url(event['journable_id'],
anchor: notes_anchor(event))
end
@ -81,6 +82,18 @@ class Activities::WorkPackageActivityProvider < Activities::BaseActivityProvider
def notes_anchor(event)
version = event['version'].to_i
(version > 1) ? "note-#{version - 1}" : ''
version > 1 ? "note-#{version - 1}" : ''
end
def types_table
@types_table = Type.arel_table
end
def statuses_table
@statuses_table = Status.arel_table
end
def work_packages_table
@work_packages_table ||= WorkPackage.arel_table
end
end

@ -28,13 +28,14 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
# Similar to regular Journals, but under the following circumstances journals are aggregated:
# Similar to regular Journals, but under the following circumstances a set of individual journals is aggregated to
# a single logical journal:
# * they are in temporal proximity
# * they belong to the same resource
# * they were created by the same user (i.e. the same user edited the journable)
# * no other user has an own journal on the same object between the aggregated ones
# When a user commented (added a note) twice within a short time, the second comment will
# "open" a new aggregation, since we do not want to merge comments in any way.
# When a user commented (added a note) twice within a short time, the first comment will
# finish the aggregation, since we do not want to merge comments in any way.
# The term "aggregation" means the following when applied to our journaling:
# * ignore/hide old journal rows (since every journal row contains a full copy of the journaled
# object, dropping intermediate rows will just increase the diff of the following journal)
@ -43,14 +44,14 @@
class Journal::AggregatedJournal
class << self
def with_version(pure_journal)
wp_journals = Journal::AggregatedJournal.aggregated_journals(journable: pure_journal.journable)
wp_journals = aggregated_journals(journable: pure_journal.journable)
wp_journals.detect { |journal| journal.version == pure_journal.version }
end
# Returns the aggregated journal that contains the specified (vanilla/pure) journal.
def containing_journal(pure_journal)
raw = Journal::AggregatedJournal.query_aggregated_journals(journable: pure_journal.journable)
.where("#{version_projection} >= ?", pure_journal.version)
raw = Journal::Scopes::AggregatedJournal.fetch(journable: pure_journal.journable)
.where("version >= ?", pure_journal.version)
.first
raw ? Journal::AggregatedJournal.new(raw) : nil
@ -61,8 +62,8 @@ class Journal::AggregatedJournal
# We need to limit the journal aggregation as soon as possible for performance reasons.
# Therefore we have to provide the notes_id to the aggregation on top of it being used
# in the where clause to pick the desired AggregatedJournal.
raw_journal = query_aggregated_journals(journal_id: notes_id)
.where("#{table_name}.id = ?", notes_id)
raw_journal = Journal::Scopes::AggregatedJournal.fetch
.where(id: notes_id)
.first
raw_journal ? Journal::AggregatedJournal.new(raw_journal) : nil
@ -73,201 +74,50 @@ class Journal::AggregatedJournal
#
# The +until_version+ parameter can be used in conjunction with the +journable+ parameter
# to see the aggregated journals as if no versions were known after the specified version.
def aggregated_journals(journable: nil, until_version: nil, includes: [])
raw_journals = query_aggregated_journals(journable: journable, until_version: until_version)
predecessors = {}
raw_journals.each do |journal|
journable_key = [journal.journable_type, journal.journable_id]
predecessors[journable_key] = [nil] unless predecessors[journable_key]
predecessors[journable_key] << journal
end
aggregated_journals = raw_journals.map { |journal|
journable_key = [journal.journable_type, journal.journable_id]
Journal::AggregatedJournal.new(journal, predecessor: predecessors[journable_key].shift)
}
def aggregated_journals(journable: nil, sql: nil, until_version: nil, includes: [])
raw_journals = Journal::Scopes::AggregatedJournal.fetch(journable: journable, sql: sql, until_version: until_version)
aggregated_journals = map_to_aggregated_journals(raw_journals)
preload_associations(journable, aggregated_journals, includes)
aggregated_journals
end
def query_aggregated_journals(journable: nil, until_version: nil, journal_id: nil)
# Using the roughly aggregated groups from :sql_rough_group we need to merge journals
# where an entry with empty notes follows an entry containing notes, so that the notes
# from the main entry are taken, while the remaining information is taken from the
# more recent entry. We therefore join the rough groups with itself
# _wherever a merge would be valid_.
# Since the results are already pre-merged, this can only happen if Our first entry (master)
# had a comment and its successor (addition) had no comment, but can be merged.
# This alone would, however, leave the addition in the result set, leaving a "no change"
# journal entry back. By an additional self-join towards the predecessor, we can make sure
# that our own row (master) would not already have been merged by its predecessor. If it is
# (that means if we can find a valid predecessor), we drop our current row, because it will
# already be present (in a merged form) in the row of our predecessor.
Journal.from("(#{sql_rough_group(journable, until_version, journal_id)}) #{table_name}")
.joins(Arel.sql("LEFT OUTER JOIN (#{sql_rough_group(journable, until_version, journal_id)}) addition
ON #{sql_on_groups_belong_condition(table_name, 'addition')}"))
.joins(Arel.sql("LEFT OUTER JOIN (#{sql_rough_group(journable, until_version, journal_id)}) predecessor
ON #{sql_on_groups_belong_condition('predecessor', table_name)}"))
.where(Arel.sql('predecessor.id IS NULL'))
.order(Arel.sql("COALESCE(addition.created_at, #{table_name}.created_at) ASC"))
.order(Arel.sql("#{version_projection} ASC"))
.select(Arel.sql("#{table_name}.journable_id,
#{table_name}.journable_type,
#{table_name}.user_id,
#{table_name}.notes,
#{table_name}.id \"notes_id\",
#{table_name}.version \"notes_version\",
#{table_name}.activity_type,
COALESCE(addition.created_at, #{table_name}.created_at) \"created_at\",
COALESCE(addition.id, #{table_name}.id) \"id\",
#{version_projection} \"version\""))
end
# Returns whether "notification-hiding" should be assumed for the given journal pair.
# This leads to an aggregated journal effectively blocking notifications of an earlier journal,
# because it "steals" the addition from its predecessor. See the specs section under
# "mail suppressing aggregation" (for EnqueueWorkPackageNotificationJob) for more details
def hides_notifications?(successor, predecessor)
return false unless successor && predecessor
timeout = Setting.journal_aggregation_time_minutes.to_i.minutes
if successor.journable_type != predecessor.journable_type ||
successor.journable_id != predecessor.journable_id ||
successor.user_id != predecessor.user_id ||
(successor.created_at - predecessor.created_at) <= timeout
return false
end
return false if belong_to_different_groups?(predecessor, successor)
# imaginary state in which the successor never existed
# if this makes the predecessor disappear, the successor must have taken journals
# from it (that now became part of the predecessor again).
!Journal::AggregatedJournal
.query_aggregated_journals(
!Journal::Scopes::AggregatedJournal
.fetch(
journable: successor.journable,
until_version: successor.version - 1)
.where("#{version_projection} = ?", predecessor.version)
until_version: successor.version - 1
)
.where(version: predecessor.version)
.exists?
end
def table_name
Journal.table_name
end
def version_projection
"COALESCE(addition.version, #{table_name}.version)"
end
private
# Provides a full SQL statement that returns journals that are aggregated on a basic level:
# * a row is dropped as soon as its successor is eligible to be merged with it
# * rows with a comment are never dropped (we _might_ need the comment later)
# Thereby the result already has aggregation performed, but will still have too many rows:
# Changes without notes after changes containing notes (even if both were performed by
# the same user). Those need to be filtered out later.
# To be able to self-join results of this statement, we add an additional column called
# "group_number" to the result. This allows to compare a group resulting from this query with
# its predecessor and successor.
def sql_rough_group(journable, until_version, journal_id)
if until_version && !journable
raise 'need to provide a journable, when specifying a version limit'
elsif journable && journable.id.nil?
raise 'journable has no id'
end
conditions = additional_conditions(journable, until_version, journal_id)
"SELECT predecessor.*, #{sql_group_counter} AS group_number
FROM journals predecessor
#{sql_rough_group_join(conditions[:join_conditions])}
#{sql_rough_group_where(conditions[:where_conditions])}
#{sql_rough_group_order}"
end
def additional_conditions(journable, until_version, journal_id)
where_conditions = ''
join_conditions = ''
if journable
where_conditions += " AND predecessor.journable_type = '#{journable.class.name}' AND
predecessor.journable_id = #{journable.id}"
if until_version
where_conditions += " AND predecessor.version <= #{until_version}"
join_conditions += "AND successor.version <= #{until_version}"
end
end
if journal_id
where_conditions += "AND predecessor.id IN (
SELECT id_key.id
FROM #{table_name} id_key JOIN #{table_name} journable_key
ON id_key.journable_id = journable_key.journable_id
AND id_key.journable_type = journable_key.journable_type
AND journable_key.id = #{journal_id})"
def map_to_aggregated_journals(raw_journals)
predecessors = {}
raw_journals.each do |journal|
journable_key = [journal.journable_type, journal.journable_id]
predecessors[journable_key] = [nil] unless predecessors[journable_key]
predecessors[journable_key] << journal
end
{ where_conditions: where_conditions,
join_conditions: join_conditions }
end
def sql_rough_group_join(additional_conditions)
"LEFT OUTER JOIN #{table_name} successor
ON predecessor.version + 1 = successor.version AND
predecessor.journable_type = successor.journable_type AND
predecessor.journable_id = successor.journable_id
#{additional_conditions}"
end
def sql_rough_group_where(additional_conditions)
"WHERE (predecessor.user_id != successor.user_id OR
(predecessor.notes != '' AND predecessor.notes IS NOT NULL) OR
#{sql_beyond_aggregation_time?('predecessor', 'successor')} OR
successor.id IS NULL)
#{additional_conditions}"
end
def sql_rough_group_order
"ORDER BY predecessor.created_at"
end
# This method returns the appropriate statement to be used inside a SELECT to
# obtain the current group number.
def sql_group_counter
'row_number() OVER (ORDER BY predecessor.version ASC)'
end
# Similar to the WHERE statement used in :sql_rough_group. However, this condition will
# match (return true) for all pairs where a merge/aggregation IS possible.
def sql_on_groups_belong_condition(predecessor, successor)
"#{predecessor}.group_number + 1 = #{successor}.group_number AND
(NOT #{sql_beyond_aggregation_time?(predecessor, successor)} AND
#{predecessor}.user_id = #{successor}.user_id AND
#{successor}.journable_type = #{predecessor}.journable_type AND
#{successor}.journable_id = #{predecessor}.journable_id AND
NOT ((#{predecessor}.notes != '' AND #{predecessor}.notes IS NOT NULL) AND
(#{successor}.notes != '' AND #{successor}.notes IS NOT NULL)))"
end
raw_journals.map do |journal|
journable_key = [journal.journable_type, journal.journable_id]
# 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 sql_beyond_aggregation_time?(predecessor, successor)
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)'
Journal::AggregatedJournal.new(journal, predecessor: predecessors[journable_key].shift)
end
difference = "(#{successor}.created_at - #{predecessor}.created_at)"
threshold = "interval '#{aggregation_time_seconds} second'"
"(#{difference} > #{threshold})"
end
def preload_associations(journable, aggregated_journals, includes)
@ -311,6 +161,15 @@ class Journal::AggregatedJournal
end
end
end
def belong_to_different_groups?(predecessor, successor)
timeout = Setting.journal_aggregation_time_minutes.to_i.minutes
successor.journable_type != predecessor.journable_type ||
successor.journable_id != predecessor.journable_id ||
successor.user_id != predecessor.user_id ||
(successor.created_at - predecessor.created_at) <= timeout
end
end
include JournalChanges
@ -372,10 +231,10 @@ class Journal::AggregatedJournal
def predecessor
unless defined? @predecessor
raw_journal = self.class.query_aggregated_journals(journable: journable)
.where("#{self.class.version_projection} < ?", version)
raw_journal = Journal::Scopes::AggregatedJournal.fetch(journable: journable)
.where("version < ?", version)
.except(:order)
.order(Arel.sql("#{self.class.version_projection} DESC"))
.order(version: :desc)
.first
@predecessor = raw_journal ? Journal::AggregatedJournal.new(raw_journal) : nil
@ -386,10 +245,10 @@ class Journal::AggregatedJournal
def successor
unless defined? @successor
raw_journal = self.class.query_aggregated_journals(journable: journable)
.where("#{self.class.version_projection} > ?", version)
raw_journal = Journal::Scopes::AggregatedJournal.fetch(journable: journable)
.where("version > ?", version)
.except(:order)
.order(Arel.sql("#{self.class.version_projection} ASC"))
.order(version: :asc)
.first
@successor = raw_journal ? Journal::AggregatedJournal.new(raw_journal) : nil

@ -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,4 +1,5 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 the OpenProject GmbH

@ -250,9 +250,11 @@ class Project < ApplicationRecord
# 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)
cond = "#{Project.table_name}.id = #{id}"
cond = "(#{cond} OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt}))" if with_subprojects
cond
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

@ -29,15 +29,15 @@ See docs/COPYRIGHT.rdoc for more details.
<%= call_hook :activity_index_head %>
<%= toolbar title: (@author.nil? ? l(:label_activity) : l(:label_user_activity, link_to_user(@author))).html_safe,
subtitle: l(:label_date_from_to, start: format_date(@date_to - @days), end: format_date(@date_to-1))
<%= toolbar title: (@author.nil? ? t(:label_activity) : l(:label_user_activity, link_to_user(@author))).html_safe,
subtitle: t(:label_date_from_to, start: format_date(@date_to - @days), end: format_date(@date_to-1))
%>
<div id="activity">
<% @events_by_day.keys.sort.reverse.each do |day| %>
<h3><%= format_activity_day(day) %></h3>
<ul class="generic-list">
<% @events_by_day[day].sort {|x,y| y.event_datetime <=> x.event_datetime }.each do |e| -%>
<% @events_by_day[day].sort { |x, y| y.event_datetime <=> x.event_datetime }.each do |e| -%>
<li class="<%= e.event_type %> <%= User.current.logged? && e.respond_to?(:event_author) && User.current == e.event_author ? 'me' : nil %>">
<div class="title">
<% event_type = e.event_type.start_with?('meeting') ? 'meetings' : e.event_type %>
@ -67,15 +67,15 @@ See docs/COPYRIGHT.rdoc for more details.
<% end %>
<div style="float:left;">
<%= link_to_content_update(l(:label_previous),
<%= link_to_content_update(t(:label_previous),
{ from: (@date_to - @days - 1) },
{title: l(:label_date_from_to, start: format_date(@date_to - 2*@days), end: format_date(@date_to - @days - 1)),
{title: t(:label_date_from_to, start: format_date(@date_to - 2*@days), end: format_date(@date_to - @days - 1)),
class: 'navigate-left'}) %>
</div>
<div style="float:right;">
<%= link_to_content_update(l(:label_next),
<%= link_to_content_update(t(:label_next),
{ from: (@date_to + @days - 1) },
{title: l(:label_date_from_to, start: format_date(@date_to), end: format_date(@date_to + @days - 1)),
{title: t(:label_date_from_to, start: format_date(@date_to), end: format_date(@date_to + @days - 1)),
class: 'navigate-right'}) unless @date_to >= Date.today %>
</div>
&nbsp;
@ -90,23 +90,24 @@ See docs/COPYRIGHT.rdoc for more details.
<% content_for :sidebar do %>
<%= form_tag({}, method: :get) do %>
<fieldset class="form--fieldset">
<legend class="form--fieldset-legend"><%= l(:description_filter) %></legend>
<legend class="form--fieldset-legend"><%= t(:description_filter) %></legend>
<p>
<% @activity.event_types.each do |t| %>
<%= check_box_tag "show_#{t}", 1, @activity.scope.include?(t) %>
<label for="show_<%=t%>"><%=l("label_#{t.singularize}_plural")%></label>
<%= hidden_field_tag "event_types[]" %>
<% @activity.event_types.sort_by { |type| t("label_#{type.singularize}_plural") }.each do |t| %>
<%= check_box_tag "event_types[]", t, @activity.scope.include?(t), id: "event_types_#{t}" %>
<label for="event_types_<%=t%>"><%=t("label_#{t.singularize}_plural")%></label>
<br />
<% end %>
</p>
<% if @project && @project.descendants.active.any? %>
<%= hidden_field_tag 'with_subprojects', 0 %>
<p><label><%= check_box_tag 'with_subprojects', 1, @with_subprojects %> <%=l(:label_subproject_plural)%></label></p>
<p><label><%= check_box_tag 'with_subprojects', 1, @with_subprojects %> <%=t(:label_subproject_plural)%></label></p>
<% end %>
<%= hidden_field_tag('user_id', params[:user_id]) unless params[:user_id].blank? %>
<%= hidden_field_tag('apply', true) %>
<p><%= submit_tag l(:button_apply), class: 'button -small -highlight', name: nil %></p>
<p><%= submit_tag t(:button_apply), class: 'button -small -highlight', name: nil %></p>
</fieldset>
<% end %>
<% end %>
<% html_title(l(:label_activity), @author) -%>
<% html_title(t(:label_activity), @author) -%>

@ -29,29 +29,29 @@
xml.instruct!
xml.feed "xmlns" => "http://www.w3.org/2005/Atom" do
first_item = @items.first
first_item_event = (!first_item.nil? && first_item.respond_to?(:data)) ? first_item.data : first_item
updated_time = (first_item_event.nil?) ? Time.now : first_item_event.event_datetime
first_item_event = !first_item.nil? && first_item.respond_to?(:data) ? first_item.data : first_item
updated_time = first_item_event.nil? ? Time.now : first_item_event.event_datetime
xml.title truncate_single_line(@title, :length => 100)
xml.title truncate_single_line(@title, length: 100)
xml.link "rel" => "self", "href" => url_for(only_path: false)
xml.link "rel" => "alternate", "href" => url_for(only_path: false, format: nil, key: nil)
xml.id url_for(controller: '/homescreen', action: :index, only_path: false)
xml.updated(updated_time.xmlschema)
xml.author { xml.name "#{Setting.app_title}" }
xml.generator(:uri => OpenProject::Info.url) { xml.text! OpenProject::Info.app_name; }
xml.author { xml.name Setting.app_title }
xml.generator(uri: OpenProject::Info.url) { xml.text! OpenProject::Info.app_name; }
@items.each do |item|
item_event = (not first_item.nil? and first_item.respond_to?(:data)) ? item.data : item
item_event = !first_item.nil? && first_item.respond_to?(:data) ? item.data : item
xml.entry do
if item_event.is_a? Redmine::Acts::ActivityProvider::Event
url = item_event.event_url
else
url = url_for(item_event.event_url(:only_path => false))
end
url = if item_event.is_a? Activities::Event
item_event.event_url
else
url_for(item_event.event_url(only_path: false))
end
if @project
xml.title truncate_single_line(item_event.event_title, :length => 100)
xml.title truncate_single_line(item_event.event_title, length: 100)
else
xml.title truncate_single_line("#{item.project} - #{item_event.event_title}", :length => 100)
xml.title truncate_single_line("#{item.project} - #{item_event.event_title}", length: 100)
end
xml.link "rel" => "alternate", "href" => url
xml.id url
@ -62,7 +62,7 @@ xml.feed "xmlns" => "http://www.w3.org/2005/Atom" do
xml.email(author.mail) if author.is_a?(User) && !author.mail.blank? && !author.pref.hide_mail
end if author
xml.content "type" => "html" do
xml.text! format_text(item_event, :event_description, :only_path => false)
xml.text! format_text(item_event, :event_description, only_path: false)
end
end
end

@ -1,4 +1,5 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 the OpenProject GmbH
@ -27,15 +28,21 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
module Redmine
module OpenProject
module Activity
mattr_accessor :available_event_types, :default_event_types, :providers
class << self
def available_event_types
@available_event_types ||= []
end
@@available_event_types = []
@@default_event_types = []
@@providers = Hash.new { |h, k| h[k] = [] }
def default_event_types
@default_event_types ||= []
end
def providers
@providers ||= Hash.new { |h, k| h[k] = [] }
end
class << self
def map(&_block)
yield self
end
@ -48,9 +55,9 @@ module Redmine
providers = options[:class_name] || event_type.classify
providers = ([] << providers) unless providers.is_a?(Array)
@@available_event_types << event_type unless @@available_event_types.include?(event_type)
@@default_event_types << event_type unless @@default_event_types.include?(event_type) || options[:default] == false
@@providers[event_type] += providers
available_event_types << event_type unless available_event_types.include?(event_type)
default_event_types << event_type unless default_event_types.include?(event_type) || options[:default] == false
self.providers[event_type] += providers
end
end
end

@ -1,4 +1,5 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 the OpenProject GmbH
@ -27,7 +28,7 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
Redmine::Activity.map do |activity|
OpenProject::Activity.map do |activity|
activity.register :work_packages, class_name: '::Activities::WorkPackageActivityProvider'
activity.register :changesets, class_name: 'Activities::ChangesetActivityProvider'
activity.register :news, class_name: 'Activities::NewsActivityProvider',

@ -29,7 +29,6 @@
#++
require 'redmine/menu_manager'
require 'redmine/activity'
require 'redmine/search'
require 'open_project/custom_field_format'
require 'open_project/logging/log_delegator'

@ -28,6 +28,7 @@
require_dependency 'open_project/ui/extensible_tabs'
require_dependency 'config/constants/api_patch_registry'
require_dependency 'config/constants/open_project/activity'
module OpenProject::Plugins
module ActsAsOpEngine
@ -290,6 +291,21 @@ module OpenProject::Plugins
end
end
# Registers an activity provider.
#
# @param event_type [Symbol]
#
# Options:
# * <tt>:class_name</tt> - one or more model(s) that provide these events, those need to inherit from Activities::BaseActivityProvider
# * <tt>:default</tt> - setting this option to false will make the events not displayed by default
#
# Example
# activity_provider :meetings, class_name: 'Activities::MeetingActivityProvider', default: false
#
def activity_provider(event_type, options = {})
OpenProject::Activity.register(event_type, options)
end
##
# Register a "cron"-like background job
def add_cron_jobs(&block)

@ -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

@ -36,6 +36,7 @@ module Redmine
module ClassMethods
def acts_as_event(options = {})
return if included_modules.include?(Redmine::Acts::Event::InstanceMethods)
default_options = { datetime: :created_at,
title: :title,
description: :description,

@ -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

@ -27,6 +27,8 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
require Rails.root.join('config/constants/open_project/activity')
module Redmine #:nodoc:
class PluginError < StandardError
attr_reader :plugin_id
@ -350,7 +352,6 @@ module Redmine #:nodoc:
#
# Retrieving events:
# Associated model(s) must implement the find_events class method.
# ActiveRecord models can use acts_as_activity_provider as a way to implement this class method.
#
# The following call should return all the scrum events visible by current user that occurred in the 5 last days:
# Meeting.find_events('scrums', User.current, 5.days.ago, Date.today)
@ -358,7 +359,8 @@ module Redmine #:nodoc:
#
# Note that :view_scrums permission is required to view these events in the activity view.
def activity_provider(*args)
Redmine::Activity.register(*args)
ActiveSupport::Deprecation.warn('Use ActsAsOpEngine#activity_provider instead.')
OpenProject::Activity.register(*args)
end
# Registers a wiki formatter.

@ -36,19 +36,20 @@ module ::Avatars
ServiceResult.new(success: true, result: I18n.t(:message_avatar_uploaded))
rescue StandardError => e
Rails.logger.error "Failed to update avatar of user##{user.id}: #{e}"
return error_result(I18n.t(:error_image_upload))
error_result(I18n.t(:error_image_upload))
end
def destroy
current_attachment = @user.local_avatar_attachment
if current_attachment && current_attachment.destroy
@user.reload
ServiceResult.new(success: true, result: I18n.t(:avatar_deleted))
else
return error_result(I18n.t(:unable_to_delete_avatar))
error_result(I18n.t(:unable_to_delete_avatar))
end
rescue StandardError => e
Rails.logger.error "Failed to delete avatar of user##{user.id}: #{e}"
return error_result(e.message)
error_result(e.message)
end
private

@ -39,7 +39,7 @@ AvatarHelper.class_eval do
end
rescue StandardError => e
Rails.logger.error "Failed to create avatar for #{user}: #{e}"
return ''.html_safe
''.html_safe
end
def avatar_url(user, options = {})
@ -52,7 +52,7 @@ AvatarHelper.class_eval do
end
rescue StandardError => e
Rails.logger.error "Failed to create avatar url for #{user}: #{e}"
return ''.html_safe
''.html_safe
end
def any_avatar?(user)
@ -61,6 +61,7 @@ AvatarHelper.class_eval do
def local_avatar?(user)
return false unless avatar_manager.local_avatars_enabled?
user.respond_to?(:local_avatar_attachment) && user.local_avatar_attachment
end
@ -87,6 +88,7 @@ AvatarHelper.class_eval do
def build_gravatar_image_url(user, options = {})
mail = extract_email_address(user)
raise ArgumentError.new('Invalid Mail') unless mail.present?
opts = options.merge(gravatar: default_gravatar_options)
# gravatar_image_url expects grvatar options as second arg
if opts[:gravatar]

@ -34,14 +34,39 @@ module OpenProject::Avatars
end
module InstanceMethods
def reload(*args)
reset_avatar_attachment_cache!
super
end
def local_avatar_attachment
attachments.find_by_description('avatar')
# @local_avatar_attachment can legitimately be nil which is why the
# typical
# inst_var ||= calculation
# pattern does not work for caching
return @local_avatar_attachment if @local_avatar_attachment_calculated
@local_avatar_attachment_calculated ||= begin
@local_avatar_attachment = attachments.find_by_description('avatar')
true
end
@local_avatar_attachment
end
def local_avatar_attachment=(file)
local_avatar_attachment.destroy if local_avatar_attachment
self.attach_files('first' => { 'file' => file, 'description' => 'avatar' })
self.save
local_avatar_attachment&.destroy
reset_avatar_attachment_cache!
attach_files('first' => { 'file' => file, 'description' => 'avatar' })
save
end
def reset_avatar_attachment_cache!
@local_avatar_attachment = nil
@local_avatar_attachment_calculated = nil
end
end
end

@ -79,7 +79,6 @@ class CostlogController < ApplicationController
end
end
verify method: :delete, only: :destroy, render: { nothing: true, status: :method_not_allowed }
def destroy
render_404 and return unless @cost_entry
render_403 and return unless @cost_entry.editable_by?(User.current)

@ -27,29 +27,29 @@
#++
class Activities::CostObjectActivityProvider < Activities::BaseActivityProvider
acts_as_activity_provider type: 'cost_objects',
permission: :view_cost_objects
activity_provider_for type: 'cost_objects',
permission: :view_cost_objects
def event_query_projection(activity)
def event_query_projection
[
activity_journal_projection_statement(:subject, 'cost_object_subject', activity),
activity_journal_projection_statement(:project_id, 'project_id', activity)
activity_journal_projection_statement(:subject, 'cost_object_subject'),
activity_journal_projection_statement(:project_id, 'project_id')
]
end
def event_type(_event, _activity)
def event_type(_event)
'cost_object'
end
def event_title(event, _activity)
def event_title(event)
"#{I18n.t(:label_cost_object)} ##{event['journable_id']}: #{event['cost_object_subject']}"
end
def event_path(event, _activity)
def event_path(event)
url_helpers.cost_object_path(url_helper_parameter(event))
end
def event_url(event, _activity)
def event_url(event)
url_helpers.cost_object_url(url_helper_parameter(event))
end

@ -85,12 +85,10 @@ module OpenProject::Costs
before: :settings,
caption: :cost_objects_title,
icon: 'icon2 icon-budget'
Redmine::Activity.map do |activity|
activity.register :cost_objects, class_name: 'Activities::CostObjectActivityProvider', default: false
end
end
activity_provider :cost_objects, class_name: 'Activities::CostObjectActivityProvider', default: false
patches %i[Project User TimeEntry PermittedParams ProjectsController ApplicationHelper]
patch_with_namespace :WorkPackages, :BaseContract
patch_with_namespace :API, :V3, :WorkPackages, :Schema, :SpecificWorkPackageSchema

@ -36,21 +36,21 @@ describe 'Work Package table cost entries', type: :feature, js: true do
let(:work_package) { FactoryBot.create :work_package, project: project, parent: parent }
let(:hourly_rate) { FactoryBot.create :default_hourly_rate, user: user, rate: 1.00 }
let!(:time_entry1) {
let!(:time_entry1) do
FactoryBot.create :time_entry,
user: user,
work_package: parent,
project: project,
hours: 10
}
user: user,
work_package: parent,
project: project,
hours: 10
end
let!(:time_entry2) {
let!(:time_entry2) do
FactoryBot.create :time_entry,
user: user,
work_package: work_package,
project: project,
hours: 2.50
}
user: user,
work_package: work_package,
project: project,
hours: 2.50
end
let(:wp_table) { ::Pages::WorkPackagesTable.new(project) }
let!(:query) do
@ -81,8 +81,8 @@ describe 'Work Package table cost entries', type: :feature, js: true do
visit project_activities_path project
# Activate budget filter
find('#show_time_entries').set true
find('#show_cost_objects').set true
check('Spent time')
check('Budgets')
click_on 'Apply'
expect(page).to have_selector('.time-entry a', text: '10.00 h')

@ -27,29 +27,29 @@
#++
class Activities::DocumentActivityProvider < Activities::BaseActivityProvider
acts_as_activity_provider type: 'documents',
permission: :view_documents
activity_provider_for type: 'documents',
permission: :view_documents
def event_query_projection(activity)
def event_query_projection
[
activity_journal_projection_statement(:title, 'document_title', activity),
activity_journal_projection_statement(:project_id, 'project_id', activity)
activity_journal_projection_statement(:title, 'document_title'),
activity_journal_projection_statement(:project_id, 'project_id')
]
end
def event_title(event, _activity)
def event_title(event)
"#{Document.model_name.human}: #{event['document_title']}"
end
def event_type(_event, _activity)
def event_type(_event)
'document'
end
def event_path(event, _activity)
def event_path(event)
url_helpers.document_url(url_helper_parameter(event))
end
def event_url(event, _activity)
def event_url(event)
url_helpers.document_url(url_helper_parameter(event))
end

@ -52,13 +52,11 @@ module OpenProject::Documents
Redmine::Notifiable.all << Redmine::Notifiable.new('document_added')
Redmine::Activity.map do |activity|
activity.register :documents, class_name: 'Activities::DocumentActivityProvider', default: false
end
Redmine::Search.register :documents
end
activity_provider :documents, class_name: 'Activities::DocumentActivityProvider', default: false
patches [:CustomFieldsHelper, :Project]
add_api_path :documents do

@ -28,27 +28,29 @@
#++
class Activities::MeetingActivityProvider < Activities::BaseActivityProvider
acts_as_activity_provider type: 'meetings',
activities: [:meeting, :meeting_content],
permission: :view_meetings
activity_provider_for type: 'meetings',
activities: %i[meeting meeting_content],
permission: :view_meetings
def extend_event_query(query, activity)
def extend_event_query(query)
case activity
when :meeting_content
query.join(meetings_table).on(activity_journals_table(activity)[:meeting_id].eq(meetings_table[:id]))
join_cond = journal_table[:journable_type].eq('MeetingContent')
query.join(meeting_contents_table).on(journal_table[:journable_id].eq(meeting_contents_table[:id]).and(join_cond))
query.join(meetings_table).on(activity_journals_table[:meeting_id].eq(meetings_table[:id]))
join_cond = journals_table[:journable_type].eq('MeetingContent')
query.join(meeting_contents_table).on(journals_table[:journable_id].eq(meeting_contents_table[:id]).and(join_cond))
else
super
end
end
def event_query_projection(activity)
def event_query_projection
case activity
when :meeting
[
activity_journal_projection_statement(:title, 'meeting_title', activity),
activity_journal_projection_statement(:start_time, 'meeting_start_time', activity),
activity_journal_projection_statement(:duration, 'meeting_duration', activity),
activity_journal_projection_statement(:project_id, 'project_id', activity)
activity_journal_projection_statement(:title, 'meeting_title'),
activity_journal_projection_statement(:start_time, 'meeting_start_time'),
activity_journal_projection_statement(:duration, 'meeting_duration'),
activity_journal_projection_statement(:project_id, 'project_id')
]
else
[
@ -60,31 +62,31 @@ class Activities::MeetingActivityProvider < Activities::BaseActivityProvider
end
end
def activitied_type(activity)
(activity == :meeting) ? Meeting : MeetingContent
def activitied_type
activity == :meeting ? Meeting : MeetingContent
end
def projects_reference_table(activity)
def projects_reference_table
case activity
when :meeting
activity_journals_table(activity)
activity_journals_table
else
meetings_table
end
end
def activity_journals_table(activity)
case activity
when :meeting
@activity_journals_table = JournalManager.journal_class(Meeting).arel_table
else
@activity_journals_table = JournalManager.journal_class(MeetingContent).arel_table
end
def activity_journals_table
@activity_journals_table ||= case activity
when :meeting
JournalManager.journal_class(Meeting).arel_table
else
JournalManager.journal_class(MeetingContent).arel_table
end
end
protected
def event_name(event, activity)
def event_name(event)
case event['event_description']
when 'Agenda closed'
I18n.t('meeting_agenda_closed', scope: 'events')
@ -97,7 +99,7 @@ class Activities::MeetingActivityProvider < Activities::BaseActivityProvider
end
end
def event_title(event, activity)
def event_title(event)
case activity
when :meeting
start_time = event['meeting_start_time'].is_a?(String) ? DateTime.parse(event['meeting_start_time'])
@ -110,23 +112,23 @@ class Activities::MeetingActivityProvider < Activities::BaseActivityProvider
end
end
def event_type(event, activity)
def event_type(event)
case activity
when :meeting
'meeting'
else
(event['meeting_content_type'].include?('Agenda')) ? 'meeting-agenda' : 'meeting-minutes'
event['meeting_content_type'].include?('Agenda') ? 'meeting-agenda' : 'meeting-minutes'
end
end
def event_path(event, activity)
id = activity_id(event, activity)
def event_path(event)
id = activity_id(event)
url_helpers.meeting_path(id)
end
def event_url(event, activity)
id = activity_id(event, activity)
def event_url(event)
id = activity_id(event)
url_helpers.meeting_url(id)
end
@ -141,7 +143,7 @@ class Activities::MeetingActivityProvider < Activities::BaseActivityProvider
@meeting_contents_table ||= MeetingContent.arel_table
end
def activity_id(event, activity)
(activity == :meeting) ? event['journable_id'] : event['meeting_id']
def activity_id(event)
activity == :meeting ? event['journable_id'] : event['meeting_id']
end
end

@ -37,7 +37,6 @@ module OpenProject::Meeting
register 'openproject-meeting',
author_url: 'http://finn.de',
bundled: true do
project_module :meetings do
permission :view_meetings, meetings: [:index, :show], meeting_agendas: [:history, :show, :diff], meeting_minutes: [:history, :show, :diff]
permission :create_meetings, { meetings: [:new, :create, :copy] }, require: :member
@ -65,12 +64,10 @@ module OpenProject::Meeting
ActiveSupport::Inflector.inflections do |inflect|
inflect.uncountable 'meeting_minutes'
end
Redmine::Activity.map do |activity|
activity.register :meetings, class_name: 'Activities::MeetingActivityProvider', default: false
end
end
activity_provider :meetings, class_name: 'Activities::MeetingActivityProvider', default: false
patches [:Project]
patch_with_namespace :BasicData, :RoleSeeder
patch_with_namespace :BasicData, :SettingSeeder

@ -63,9 +63,6 @@ class CostReportsController < ApplicationController
before_action :set_cost_types # has to be set AFTER the Report::Controller filters run
verify method: :delete, only: %w[destroy]
verify method: :post, only: %w[create, update, rename]
helper_method :cost_types
helper_method :cost_type
helper_method :unit_id

@ -47,19 +47,19 @@ describe ActivitiesController, type: :controller do
describe 'global' do
let(:work_package) { FactoryBot.create(:work_package) }
let!(:journal) {
let!(:journal) do
FactoryBot.create(:work_package_journal,
journable_id: work_package.id,
created_at: 3.days.ago.to_date.to_s(:db),
version: Journal.maximum(:version) + 1,
data: FactoryBot.build(:journal_work_package_journal,
subject: work_package.subject,
status_id: work_package.status_id,
type_id: work_package.type_id,
project_id: work_package.project_id))
}
before do get 'index' end
journable_id: work_package.id,
created_at: 3.days.ago.to_date.to_s(:db),
version: Journal.maximum(:version) + 1,
data: FactoryBot.build(:journal_work_package_journal,
subject: work_package.subject,
status_id: work_package.status_id,
type_id: work_package.type_id,
project_id: work_package.project_id))
end
before { get 'index' }
it_behaves_like 'valid index response'
@ -70,18 +70,18 @@ describe ActivitiesController, type: :controller do
it do
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.html_escape(work_package.subject)}/ } } }
content: /#{3.day.ago.to_date.day}/,
sibling: { tag: 'dl',
child: { tag: 'dt',
attributes: { class: /work_package/ },
child: { tag: 'a',
content: /#{ERB::Util.html_escape(work_package.subject)}/ } } }
end
end
describe 'empty filter selection' do
before do
get 'index', params: { apply: true }
get 'index', params: { event_types: [''] }
end
it_behaves_like 'valid index response'
@ -91,10 +91,10 @@ describe ActivitiesController, type: :controller do
end
describe 'with activated activity module' do
let(:project) {
let(:project) do
FactoryBot.create(:project,
enabled_module_names: %w[activity wiki])
}
enabled_module_names: %w[activity wiki])
end
it 'renders activity' do
get 'index', params: { project_id: project.id }
@ -104,10 +104,10 @@ describe ActivitiesController, type: :controller do
end
describe 'without activated activity module' do
let(:project) {
let(:project) do
FactoryBot.create(:project,
enabled_module_names: %w[wiki])
}
enabled_module_names: %w[wiki])
end
it 'renders 403' do
get 'index', params: { project_id: project.id }
@ -127,35 +127,35 @@ describe ActivitiesController, type: :controller do
let(:project) { FactoryBot.create(:project) }
context 'work_package' do
let!(:wp_1) {
let!(:wp_1) do
FactoryBot.create(:work_package,
project: project,
author: user)
}
project: project,
author: user)
end
describe 'global' do
render_views
before do get 'index', format: 'atom' end
before { get 'index', format: 'atom' }
it do
assert_select 'entry',
child: { tag: 'link',
attributes: { href: Regexp.new("/work_packages/#{wp_1.id}#") } }
child: { tag: 'link',
attributes: { href: Regexp.new("/work_packages/#{wp_1.id}#") } }
end
end
describe 'list' do
let!(:wp_2) {
let!(:wp_2) do
FactoryBot.create(:work_package,
project: project,
author: user)
}
project: project,
author: user)
end
let(:params) {
let(:params) do
{ project_id: project.id,
format: :atom }
}
end
include_context 'index with params'
@ -166,24 +166,23 @@ describe ActivitiesController, type: :controller do
end
context 'forums' do
let(:forum) {
let(:forum) do
FactoryBot.create(:forum,
project: project)
}
let!(:message_1) {
project: project)
end
let!(:message_1) do
FactoryBot.create(:message,
forum: forum)
}
let!(:message_2) {
forum: forum)
end
let!(:message_2) do
FactoryBot.create(:message,
forum: forum)
}
let(:params) {
forum: forum)
end
let(:params) do
{ project_id: project.id,
apply: true,
show_messages: 1,
event_types: [:messages],
format: :atom }
}
end
include_context 'index with params'
@ -219,7 +218,7 @@ describe ActivitiesController, type: :controller do
describe 'selection with apply' do
let(:scope) { [] }
let(:params) { { apply: true } }
let(:params) { { event_types: [''] } }
include_context 'index with params'

@ -28,7 +28,7 @@
FactoryBot.define do
factory :changeset do
sequence(:revision) do |n| "#{n}" end
sequence(:revision) { |n| "#{n}" }
committed_on { Time.now }
commit_date { Date.today }
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

@ -36,58 +36,113 @@ describe Activities::WorkPackageActivityProvider, type: :model do
let(:user) { FactoryBot.create :admin }
let(:role) { FactoryBot.create :role }
let(:status_closed) { FactoryBot.create :closed_status }
let!(:work_package) { FactoryBot.create :work_package }
let!(:workflow) do
FactoryBot.create :workflow,
old_status: work_package.status,
new_status: status_closed,
type_id: work_package.type_id,
role: role
let(:work_package) do
User.execute_as(user) do
FactoryBot.create :work_package
end
end
let!(:work_packages) { [work_package] }
before do
allow(ActionMailer::Base).to receive(:perform_deliveries).and_return(false)
end
describe '.find_events' do
context 'when a work package has been created' do
let(:subject) do
Activities::WorkPackageActivityProvider
.find_events(event_scope, user, Date.yesterday, Date.tomorrow, {})
end
describe '#event_type' do
describe 'latest events' do
context 'when a work package has been created' do
let(:subject) do
Activities::WorkPackageActivityProvider
.find_events(event_scope, user, Date.yesterday, Date.tomorrow, {})
.last
.try :event_type
end
it 'has the edited event type' do
expect(subject[0].event_type)
.to eql(work_package_edit_event)
end
it { is_expected.to eq(work_package_edit_event) }
it 'has an id to the author stored' do
expect(subject[0].author_id)
.to eql(user.id)
end
end
context 'should be selected and ordered correctly' do
let!(:work_packages) { (1..20).map { (FactoryBot.create :work_package, author: user).id.to_s } }
let(:subject) do
Activities::WorkPackageActivityProvider
.find_events(event_scope, user, Date.yesterday, Date.tomorrow, limit: 10)
.map { |a| a.journable_id.to_s }
end
it { is_expected.to eq(work_packages.reverse.first(10)) }
context 'should be selected and ordered correctly' do
let!(:work_packages) { (1..5).map { (FactoryBot.create :work_package, author: user).id.to_s } }
let(:subject) do
Activities::WorkPackageActivityProvider
.find_events(event_scope, user, Date.yesterday, Date.tomorrow, limit: 3)
.map { |a| a.journable_id.to_s }
end
it { is_expected.to eq(work_packages.reverse.first(3)) }
end
context 'when a work package has been created and then closed' do
let(:subject) do
Activities::WorkPackageActivityProvider
.find_events(event_scope, user, Date.yesterday, Date.tomorrow, limit: 10)
.first
.try :event_type
end
context 'when a work package has been created and then closed' do
let(:subject) do
Activities::WorkPackageActivityProvider
.find_events(event_scope, user, Date.yesterday, Date.tomorrow, limit: 10)
end
before do
login_as(user)
before do
login_as(user)
work_package.status = status_closed
work_package.save!
work_package.status = status_closed
work_package.save(validate: false)
end
it 'only returns a single event (as it is aggregated)' do
expect(subject.count)
.to eql(1)
end
it 'has the closed event type' do
expect(subject[0].event_type)
.to eql(work_package_closed_event)
end
end
context 'for a non admin user' do
let(:project) { FactoryBot.create(:project) }
let(:child_project1) { FactoryBot.create(:project, parent: project) }
let(:child_project2) { FactoryBot.create(:project, parent: project) }
let(:child_project3) { FactoryBot.create(:project, parent: project) }
let(:child_project4) { FactoryBot.create(:project, parent: project, public: true) }
let(:parent_work_package) { FactoryBot.create(:work_package, project: project) }
let(:child1_work_package) { FactoryBot.create(:work_package, project: child_project1) }
let(:child2_work_package) { FactoryBot.create(:work_package, project: child_project2) }
let(:child3_work_package) { FactoryBot.create(:work_package, project: child_project3) }
let(:child4_work_package) { FactoryBot.create(:work_package, project: child_project4) }
let!(:work_packages) do
[parent_work_package, child1_work_package, child2_work_package, child3_work_package, child4_work_package]
end
let(:user) do
FactoryBot.create(:user).tap do |u|
FactoryBot.create(:member,
user: u,
project: project,
roles: [FactoryBot.create(:role, permissions: [:view_work_packages])])
FactoryBot.create(:member,
user: u,
project: child_project1,
roles: [FactoryBot.create(:role, permissions: [:view_work_packages])])
FactoryBot.create(:member,
user: u,
project: child_project2,
roles: [FactoryBot.create(:role, permissions: [])])
FactoryBot.create(:non_member, permissions: [:view_work_packages])
end
end
let(:subject) do
# lft and rgt need to be updated
project.reload
Activities::WorkPackageActivityProvider
.find_events(event_scope, user, Date.yesterday, Date.tomorrow, project: project, with_subprojects: true)
end
it { is_expected.to eq(work_package_closed_event) }
it 'returns only visible work packages' do
expect(subject.map(&:journable_id))
.to match_array([parent_work_package, child1_work_package, child4_work_package].map(&:id))
end
end
end

@ -1,4 +1,5 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 the OpenProject GmbH
@ -63,8 +64,9 @@ RSpec::Matchers.define :be_equivalent_to_journal do |expected|
end
describe Journal::AggregatedJournal, type: :model do
let(:project) { FactoryBot.create(:project) }
let(:work_package) do
FactoryBot.build(:work_package)
FactoryBot.build(:work_package, project: project)
end
let(:user1) { FactoryBot.create(:user) }
let(:user2) { FactoryBot.create(:user) }
@ -73,7 +75,7 @@ describe Journal::AggregatedJournal, type: :model do
subject { described_class.aggregated_journals }
before do
allow(User).to receive(:current).and_return(initial_author)
login_as(initial_author)
work_package.save!
end
@ -341,4 +343,85 @@ describe Journal::AggregatedJournal, type: :model do
end
end
end
context 'passing a filtering sql' do
let!(:other_work_package) { FactoryBot.create(:work_package) }
let(:sql) do
<<~SQL
SELECT journals.*
FROM journals
JOIN work_package_journals
ON work_package_journals.journal_id = journals.id
AND work_package_journals.project_id = #{project.id}
SQL
end
subject { described_class.aggregated_journals(sql: sql) }
it 'returns the journal of the work package in the project filtered for' do
expect(subject.count).to eql 1
expect(subject.first).to be_equivalent_to_journal work_package.journals.first
end
context 'with a sql filtering out every journal' do
let(:sql) do
<<~SQL
SELECT journals.*
FROM journals
JOIN work_package_journals
ON work_package_journals.journal_id = journals.id
AND work_package_journals.project_id = #{project.id}
WHERE journals.created_at < '#{Date.yesterday}'
SQL
end
it 'returns no journal' do
expect(subject.count).to eql 0
end
end
context 'with a sql filtering out the first journal and having 3 journals' do
let(:sql) do
<<~SQL
SELECT journals.*
FROM journals
JOIN work_package_journals
ON work_package_journals.journal_id = journals.id
WHERE journals.version > 1
SQL
end
context 'with the first of the remaining journals having a comment' do
before do
other_work_package.add_journal(initial_author, 'some other notes')
other_work_package.save!
work_package.add_journal(initial_author, 'some notes')
work_package.save!
work_package.subject = 'A new subject'
work_package.save!
end
it 'returns one journal per work package' do
expect(subject.count).to eql 2
end
end
end
context 'with an sql filtering for both projects' do
let(:sql) do
<<~SQL
SELECT journals.*
FROM journals
JOIN work_package_journals
ON work_package_journals.journal_id = journals.id
AND work_package_journals.project_id IN (#{project.id}, #{other_work_package.project_id})
SQL
end
it 'returns no journal' do
expect(subject.count).to eql 2
expect(subject.first).to be_equivalent_to_journal work_package.journals.first
expect(subject.last).to be_equivalent_to_journal other_work_package.journals.first
end
end
end
end

@ -409,8 +409,8 @@ describe Repository::Git, type: :model do
let(:project) { FactoryBot.create(:project) }
def find_events(user, options = {})
fetcher = Redmine::Activity::Fetcher.new(user, options)
fetcher.scope = ['changesets']
options[:scope] = ['changesets']
fetcher = Activities::Fetcher.new(user, options)
fetcher.events(Date.today - 30, Date.today + 1)
end

@ -310,8 +310,8 @@ describe Repository::Subversion, type: :model do
let(:project) { FactoryBot.create(:project) }
def find_events(user, options = {})
fetcher = Redmine::Activity::Fetcher.new(user, options)
fetcher.scope = ['changesets']
options[:scope] = ['changesets']
fetcher = Activities::Fetcher.new(user, options)
fetcher.events(Date.today - 30, Date.today + 1)
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…
Cancel
Save