Feature/latest project activity (#4390)
The project table is extended to also have the "Latest activity at" column which shows the date of the latest write operation within the project. https://community.openproject.com/work_packages/22858 Models can register for being considered when the latests activity date is calculated. This mechanism can be and is used by core and can also be used by plugins. It is unfortunate, that the mechanisms already in place by acts_as_activity could not be reused, but it would have required a complete rework to make it happen. Another shortcoming of the current implementation are the sql strings generated within Project::Activity. It would have taken more time to rework them into Arel and as the improvable code is limited to one class and a clean interface separates it from the rest of the application I deem it ok to stay that way for now.pull/4405/head
parent
caca4d1554
commit
eae5321c4e
@ -0,0 +1,97 @@ |
||||
#-- encoding: UTF-8 |
||||
#-- copyright |
||||
# OpenProject is a project management system. |
||||
# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) |
||||
# |
||||
# This program is free software; you can redistribute it and/or |
||||
# modify it under the terms of the GNU General Public License version 3. |
||||
# |
||||
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: |
||||
# Copyright (C) 2006-2013 Jean-Philippe Lang |
||||
# Copyright (C) 2010-2013 the ChiliProject Team |
||||
# |
||||
# This program is free software; you can redistribute it and/or |
||||
# modify it under the terms of the GNU General Public License |
||||
# as published by the Free Software Foundation; either version 2 |
||||
# of the License, or (at your option) any later version. |
||||
# |
||||
# This program is distributed in the hope that it will be useful, |
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||||
# GNU General Public License for more details. |
||||
# |
||||
# You should have received a copy of the GNU General Public License |
||||
# along with this program; if not, write to the Free Software |
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||||
# |
||||
# See doc/COPYRIGHT.rdoc for more details. |
||||
#++ |
||||
|
||||
module Project::Activity |
||||
def self.included(base) |
||||
base.send :extend, Scopes |
||||
end |
||||
|
||||
module Scopes |
||||
def with_latest_activity |
||||
Project |
||||
.select('projects.*, activity.latest_activity_at') |
||||
.joins("LEFT JOIN (#{latest_activity_sql}) activity ON projects.id = activity.project_id") |
||||
end |
||||
|
||||
def latest_activity_sql |
||||
<<-SQL |
||||
SELECT project_id, MAX(updated_at) latest_activity_at |
||||
FROM (#{all_activity_provider_union_sql}) activity |
||||
GROUP BY project_id |
||||
SQL |
||||
end |
||||
|
||||
def all_activity_provider_union_sql |
||||
latest_project_activity.join(' UNION ALL ') |
||||
end |
||||
|
||||
def register_latest_project_activity(on:, chain: [], attribute:) |
||||
@latest_project_activity ||= [] |
||||
|
||||
join_chain = Array(chain).push(on) |
||||
from = join_chain.first |
||||
|
||||
joins = build_joins_from_chain(join_chain) |
||||
|
||||
sql = <<-SQL |
||||
SELECT project_id, MAX(#{on.table_name}.#{attribute}) updated_at |
||||
FROM #{from.table_name} |
||||
#{joins.join(' ')} |
||||
WHERE #{on.table_name}.#{attribute} IS NOT NULL |
||||
GROUP BY project_id |
||||
SQL |
||||
|
||||
@latest_project_activity << sql |
||||
end |
||||
|
||||
def build_joins_from_chain(join_chain) |
||||
joins = [] |
||||
|
||||
(0..join_chain.length - 2).each do |i| |
||||
joins << build_join(join_chain[i + 1], |
||||
join_chain[i]) |
||||
end |
||||
|
||||
joins |
||||
end |
||||
|
||||
def build_join(right, left) |
||||
associations = right.reflect_on_all_associations |
||||
association = associations.detect { |a| a.class_name == left.to_s } |
||||
|
||||
<<-SQL |
||||
LEFT OUTER JOIN #{right.table_name} |
||||
ON #{left.table_name}.id = |
||||
#{right.table_name}.#{association.foreign_key} |
||||
SQL |
||||
end |
||||
|
||||
attr_reader :latest_project_activity |
||||
end |
||||
end |
@ -0,0 +1,10 @@ |
||||
class AddIndexesForLatestActivity < ActiveRecord::Migration |
||||
def change |
||||
add_index :work_packages, [:project_id, :updated_at] |
||||
add_index :news, [:project_id, :created_on] |
||||
add_index :changesets, [:repository_id, :committed_on] |
||||
add_index :wiki_contents, [:page_id, :updated_on] |
||||
add_index :messages, [:board_id, :updated_on] |
||||
add_index :time_entries, [:project_id, :updated_on] |
||||
end |
||||
end |
@ -0,0 +1,247 @@ |
||||
#-- copyright |
||||
# OpenProject is a project management system. |
||||
# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) |
||||
# |
||||
# This program is free software; you can redistribute it and/or |
||||
# modify it under the terms of the GNU General Public License version 3. |
||||
# |
||||
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: |
||||
# Copyright (C) 2006-2013 Jean-Philippe Lang |
||||
# Copyright (C) 2010-2013 the ChiliProject Team |
||||
# |
||||
# This program is free software; you can redistribute it and/or |
||||
# modify it under the terms of the GNU General Public License |
||||
# as published by the Free Software Foundation; either version 2 |
||||
# of the License, or (at your option) any later version. |
||||
# |
||||
# This program is distributed in the hope that it will be useful, |
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||||
# GNU General Public License for more details. |
||||
# |
||||
# You should have received a copy of the GNU General Public License |
||||
# along with this program; if not, write to the Free Software |
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||||
# |
||||
# See doc/COPYRIGHT.rdoc for more details. |
||||
#++ |
||||
|
||||
require 'spec_helper' |
||||
|
||||
describe Project::Activity, type: :model do |
||||
let(:project) { |
||||
FactoryGirl.create(:project) |
||||
} |
||||
|
||||
let(:initial_time) { Time.now } |
||||
|
||||
let(:work_package) { |
||||
FactoryGirl.create(:work_package, |
||||
project: project) |
||||
} |
||||
|
||||
let(:work_package2) { |
||||
FactoryGirl.create(:work_package, |
||||
project: project) |
||||
} |
||||
|
||||
let(:wiki_content) { |
||||
project.reload |
||||
|
||||
page = FactoryGirl.create(:wiki_page, |
||||
wiki: project.wiki) |
||||
|
||||
FactoryGirl.create(:wiki_content, |
||||
page: page) |
||||
} |
||||
|
||||
let(:wiki_content2) { |
||||
project.reload |
||||
|
||||
page = FactoryGirl.create(:wiki_page, |
||||
wiki: project.wiki) |
||||
|
||||
FactoryGirl.create(:wiki_content, |
||||
page: page) |
||||
} |
||||
|
||||
let(:news) { |
||||
FactoryGirl.create(:news, |
||||
project: project) |
||||
} |
||||
|
||||
let(:news2) { |
||||
FactoryGirl.create(:news, |
||||
project: project) |
||||
} |
||||
|
||||
let(:repository) { |
||||
FactoryGirl.create(:repository_git, |
||||
project: project) |
||||
} |
||||
|
||||
let(:changeset) { |
||||
FactoryGirl.create(:changeset, |
||||
repository: repository) |
||||
} |
||||
|
||||
let(:changeset2) { |
||||
FactoryGirl.create(:changeset, |
||||
repository: repository) |
||||
} |
||||
|
||||
let(:board) { |
||||
FactoryGirl.create(:board, |
||||
project: project) |
||||
} |
||||
|
||||
let(:message) { |
||||
FactoryGirl.create(:message, |
||||
board: board) |
||||
} |
||||
|
||||
let(:message2) { |
||||
FactoryGirl.create(:message, |
||||
board: board) |
||||
} |
||||
|
||||
let(:time_entry) { |
||||
FactoryGirl.create(:time_entry, |
||||
work_package: work_package, |
||||
project: project) |
||||
} |
||||
|
||||
let(:time_entry2) { |
||||
FactoryGirl.create(:time_entry, |
||||
work_package: work_package, |
||||
project: project) |
||||
} |
||||
|
||||
def latest_activity |
||||
Project.with_latest_activity.find(project.id).latest_activity_at |
||||
end |
||||
|
||||
describe '.with_latest_activity' do |
||||
it 'is the latest work_package update' do |
||||
work_package.update_attribute(:updated_at, initial_time - 10.seconds) |
||||
work_package2.update_attribute(:updated_at, initial_time - 20.seconds) |
||||
work_package.reload |
||||
work_package2.reload |
||||
|
||||
expect(latest_activity).to eql work_package.updated_at |
||||
end |
||||
|
||||
it 'is the latest wiki_contents update' do |
||||
wiki_content.update_attribute(:updated_on, initial_time - 10.seconds) |
||||
wiki_content2.update_attribute(:updated_on, initial_time - 20.seconds) |
||||
wiki_content.reload |
||||
wiki_content2.reload |
||||
|
||||
expect(latest_activity).to eql wiki_content.updated_on |
||||
end |
||||
|
||||
it 'is the latest news update' do |
||||
news.update_attribute(:created_on, initial_time - 10.seconds) |
||||
news2.update_attribute(:created_on, initial_time - 20.seconds) |
||||
news.reload |
||||
news2.reload |
||||
|
||||
expect(latest_activity).to eql news.created_on |
||||
end |
||||
|
||||
it 'is the latest changeset update' do |
||||
changeset.update_attribute(:committed_on, initial_time - 10.seconds) |
||||
changeset2.update_attribute(:committed_on, initial_time - 20.seconds) |
||||
changeset.reload |
||||
changeset2.reload |
||||
|
||||
expect(latest_activity).to eql changeset.committed_on |
||||
end |
||||
|
||||
it 'is the latest message update' do |
||||
message.update_attribute(:updated_on, initial_time - 10.seconds) |
||||
message2.update_attribute(:updated_on, initial_time - 20.seconds) |
||||
message.reload |
||||
message2.reload |
||||
|
||||
expect(latest_activity).to eql message.updated_on |
||||
end |
||||
|
||||
it 'is the latest time_entry update' do |
||||
work_package.update_attribute(:updated_at, initial_time - 60.seconds) |
||||
time_entry.update_attribute(:updated_on, initial_time - 10.seconds) |
||||
time_entry2.update_attribute(:updated_on, initial_time - 20.seconds) |
||||
time_entry.reload |
||||
time_entry2.reload |
||||
|
||||
expect(latest_activity).to eql time_entry.updated_on |
||||
end |
||||
|
||||
it 'takes the time stamp of the latest activity across models' do |
||||
work_package.update_attribute(:updated_at, initial_time - 10.seconds) |
||||
wiki_content.update_attribute(:updated_on, initial_time - 20.seconds) |
||||
news.update_attribute(:created_on, initial_time - 30.seconds) |
||||
changeset.update_attribute(:committed_on, initial_time - 40.seconds) |
||||
message.update_attribute(:updated_on, initial_time - 50.seconds) |
||||
|
||||
work_package.reload |
||||
wiki_content.reload |
||||
news.reload |
||||
changeset.reload |
||||
message.reload |
||||
|
||||
# Order: |
||||
# work_package |
||||
# wiki_content |
||||
# news |
||||
# changeset |
||||
# message |
||||
|
||||
expect(latest_activity).to eql work_package.updated_at |
||||
|
||||
work_package.update_attribute(:updated_at, message.updated_on - 10.seconds) |
||||
|
||||
# Order: |
||||
# wiki_content |
||||
# news |
||||
# changeset |
||||
# message |
||||
# work_package |
||||
|
||||
expect(latest_activity).to eql wiki_content.updated_on |
||||
|
||||
wiki_content.update_attribute(:updated_on, work_package.updated_at - 10.seconds) |
||||
|
||||
# Order: |
||||
# news |
||||
# changeset |
||||
# message |
||||
# work_package |
||||
# wiki_content |
||||
|
||||
expect(latest_activity).to eql news.created_on |
||||
|
||||
news.update_attribute(:created_on, wiki_content.updated_on - 10.seconds) |
||||
|
||||
# Order: |
||||
# changeset |
||||
# message |
||||
# work_package |
||||
# wiki_content |
||||
# news |
||||
|
||||
expect(latest_activity).to eql changeset.committed_on |
||||
|
||||
changeset.update_attribute(:committed_on, news.created_on - 10.seconds) |
||||
|
||||
# Order: |
||||
# message |
||||
# work_package |
||||
# wiki_content |
||||
# news |
||||
# changeset |
||||
|
||||
expect(latest_activity).to eql message.updated_on |
||||
end |
||||
end |
||||
end |
Loading…
Reference in new issue