From eae5321c4e8cba7f1c1485c1952ec4da1c391159 Mon Sep 17 00:00:00 2001 From: ulferts Date: Fri, 6 May 2016 09:40:57 +0200 Subject: [PATCH] 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. --- app/controllers/admin_controller.rb | 6 +- app/models/project.rb | 1 + app/models/project/activity.rb | 97 +++++++ app/views/admin/projects.html.erb | 3 + config/initializers/activity.rb | 21 ++ config/locales/en.yml | 7 +- ...3150449_add_indexes_for_latest_activity.rb | 10 + .../journal_formatter/attachment.rb | 1 + spec/models/project/activity_spec.rb | 247 ++++++++++++++++++ 9 files changed, 388 insertions(+), 5 deletions(-) create mode 100644 app/models/project/activity.rb create mode 100644 db/migrate/20160503150449_add_indexes_for_latest_activity.rb create mode 100644 spec/models/project/activity_spec.rb diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb index 8b97ab02e8..df16b23479 100644 --- a/app/controllers/admin_controller.rb +++ b/app/controllers/admin_controller.rb @@ -49,7 +49,7 @@ class AdminController < ApplicationController # after once sorting the list sort_clear sort_init 'lft' - sort_update %w(lft name is_public created_on required_disk_space) + sort_update %w(lft name is_public created_on required_disk_space latest_activity_at) @status = params[:status] ? params[:status].to_i : 1 c = ARCondition.new(@status == 0 ? 'status <> 0' : ['status = ?', @status]) @@ -58,7 +58,9 @@ class AdminController < ApplicationController c << ['LOWER(identifier) LIKE ? OR LOWER(name) LIKE ?', name, name] end - @projects = Project.with_required_storage + @projects = Project + .with_required_storage + .with_latest_activity .order(sort_clause) .where(c.conditions) .page(page_param) diff --git a/app/models/project.rb b/app/models/project.rb index 453bb32b7b..32834706e1 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -33,6 +33,7 @@ class Project < ActiveRecord::Base include Project::Copy include Project::Storage + include Project::Activity # Project statuses STATUS_ACTIVE = 1 diff --git a/app/models/project/activity.rb b/app/models/project/activity.rb new file mode 100644 index 0000000000..d29d4246cd --- /dev/null +++ b/app/models/project/activity.rb @@ -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 diff --git a/app/views/admin/projects.html.erb b/app/views/admin/projects.html.erb index 62538a9460..3d4552f16d 100644 --- a/app/views/admin/projects.html.erb +++ b/app/views/admin/projects.html.erb @@ -71,6 +71,7 @@ See doc/COPYRIGHT.rdoc for more details. + @@ -79,6 +80,7 @@ See doc/COPYRIGHT.rdoc for more details. <%= sort_header_tag('is_public', caption:Project.human_attribute_name(:is_public)) %> <%= sort_header_tag('required_disk_space', caption: I18n.t(:label_required_disk_storage)) %> <%= sort_header_tag('created_on', caption: Project.human_attribute_name(:created_on)) %> + <%= sort_header_tag('latest_activity_at', caption: Project.human_attribute_name(:latest_activity_at)) %> @@ -89,6 +91,7 @@ See doc/COPYRIGHT.rdoc for more details. <%= checked_image project.is_public? %> <%= number_to_human_size(project.required_disk_space, precision: 2) if project.required_disk_space.to_i > 0 %> <%= format_date(project.created_on) %> + <%= format_date(project.latest_activity_at) %> <%= link_to('', archive_project_path(project, status: params[:status]), diff --git a/config/initializers/activity.rb b/config/initializers/activity.rb index 26c4e3c864..e16ad64863 100644 --- a/config/initializers/activity.rb +++ b/config/initializers/activity.rb @@ -39,3 +39,24 @@ Redmine::Activity.map do |activity| activity.register :time_entries, class_name: 'Activity::TimeEntryActivityProvider', default: false end + +Project.register_latest_project_activity on: WorkPackage, + attribute: :updated_at + +Project.register_latest_project_activity on: News, + attribute: :created_on + +Project.register_latest_project_activity on: Changeset, + chain: Repository, + attribute: :committed_on + +Project.register_latest_project_activity on: WikiContent, + chain: [Wiki, WikiPage], + attribute: :updated_on + +Project.register_latest_project_activity on: Message, + chain: Board, + attribute: :updated_on + +Project.register_latest_project_activity on: TimeEntry, + attribute: :updated_on diff --git a/config/locales/en.yml b/config/locales/en.yml index fb7d73aa50..b8dbab7483 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -261,13 +261,14 @@ en: roles: "Roles" project: identifier: "Identifier" - work_packages: "Work Packages" + latest_activity_at: "Latest activity at" parent: "Subproject of" - versions: "Versions" project_type: "Project type" + queries: "Queries" responsible: "Responsible" types: "Types" - queries: "Queries" + versions: "Versions" + work_packages: "Work Packages" query: column_names: "Columns" group_by: "Group results by" diff --git a/db/migrate/20160503150449_add_indexes_for_latest_activity.rb b/db/migrate/20160503150449_add_indexes_for_latest_activity.rb new file mode 100644 index 0000000000..3e43309d8e --- /dev/null +++ b/db/migrate/20160503150449_add_indexes_for_latest_activity.rb @@ -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 diff --git a/lib/open_project/journal_formatter/attachment.rb b/lib/open_project/journal_formatter/attachment.rb index c19044ffc4..88705c7e43 100644 --- a/lib/open_project/journal_formatter/attachment.rb +++ b/lib/open_project/journal_formatter/attachment.rb @@ -29,6 +29,7 @@ class OpenProject::JournalFormatter::Attachment < ::JournalFormatter::Base include ApplicationHelper include OpenProject::StaticRouting::UrlHelpers + include OpenProject::ObjectLinking def self.default_url_options { only_path: true } diff --git a/spec/models/project/activity_spec.rb b/spec/models/project/activity_spec.rb new file mode 100644 index 0000000000..ad064f06b7 --- /dev/null +++ b/spec/models/project/activity_spec.rb @@ -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