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
ulferts 9 years ago committed by Oliver Günther
parent caca4d1554
commit eae5321c4e
  1. 6
      app/controllers/admin_controller.rb
  2. 1
      app/models/project.rb
  3. 97
      app/models/project/activity.rb
  4. 3
      app/views/admin/projects.html.erb
  5. 21
      config/initializers/activity.rb
  6. 7
      config/locales/en.yml
  7. 10
      db/migrate/20160503150449_add_indexes_for_latest_activity.rb
  8. 1
      lib/open_project/journal_formatter/attachment.rb
  9. 247
      spec/models/project/activity_spec.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)

@ -33,6 +33,7 @@ class Project < ActiveRecord::Base
include Project::Copy
include Project::Storage
include Project::Activity
# Project statuses
STATUS_ACTIVE = 1

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

@ -71,6 +71,7 @@ See doc/COPYRIGHT.rdoc for more details.
<col highlight-col>
<col highlight-col>
<col highlight-col>
<col highlight-col>
<col>
</colgroup>
<thead>
@ -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)) %>
<th></th>
</tr>
</thead>
@ -89,6 +91,7 @@ See doc/COPYRIGHT.rdoc for more details.
<td><%= checked_image project.is_public? %></td>
<td><%= number_to_human_size(project.required_disk_space, precision: 2) if project.required_disk_space.to_i > 0 %></td>
<td><%= format_date(project.created_on) %></td>
<td><%= format_date(project.latest_activity_at) %></td>
<td class="buttons">
<%= link_to('',
archive_project_path(project, status: params[:status]),

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

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

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

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

@ -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…
Cancel
Save