Commit 8d54d970 authored by Jean-Philippe Lang's avatar Jean-Philippe Lang

Simple time tracking functionality added. Time can be logged at issue or project level.

There's no aggregation reports for now, it's just possible to see all time entries for a project or an issue.
A new "activities" enumeration is added.
Permission for a role to log time must be set (new "Time tracking" section in role permissions screen).

git-svn-id: http://redmine.rubyforge.org/svn/trunk@368 e93f8b46-1217-0410-a6f0-8f06a7374b81
parent 7cf2d889
......@@ -57,6 +57,7 @@ class ReportsController < ApplicationController
issues_by_priority
issues_by_category
issues_by_author
@total_hours = @project.time_entries.sum(:hours)
render :template => "reports/issue_report"
end
end
......
class TimelogController < ApplicationController
layout 'base'
before_filter :find_project
before_filter :authorize, :only => :edit
before_filter :check_project_privacy, :only => :details
helper :sort
include SortHelper
def details
sort_init 'spent_on', 'desc'
sort_update
@entries = (@issue ? @issue : @project).time_entries.find(:all, :include => [:activity, :user, {:issue => [:tracker, :assigned_to, :priority]}], :order => sort_clause)
@total_hours = @entries.inject(0) { |sum,entry| sum + entry.hours }
@owner_id = logged_in_user ? logged_in_user.id : 0
send_csv and return if 'csv' == params[:export]
render :action => 'details', :layout => false if request.xhr?
end
def edit
render_404 and return if @time_entry && @time_entry.user != logged_in_user
@time_entry ||= TimeEntry.new(:project => @project, :issue => @issue, :user => logged_in_user, :spent_on => Date.today)
@time_entry.attributes = params[:time_entry]
if request.post? and @time_entry.save
flash[:notice] = l(:notice_successful_update)
redirect_to :action => 'details', :project_id => @time_entry.project, :issue_id => @time_entry.issue
return
end
@activities = Enumeration::get_values('ACTI')
end
private
def find_project
if params[:id]
@time_entry = TimeEntry.find(params[:id])
@project = @time_entry.project
elsif params[:issue_id]
@issue = Issue.find(params[:issue_id])
@project = @issue.project
elsif params[:project_id]
@project = Project.find(params[:project_id])
else
render_404
return false
end
end
def send_csv
ic = Iconv.new(l(:general_csv_encoding), 'UTF-8')
export = StringIO.new
CSV::Writer.generate(export, l(:general_csv_separator)) do |csv|
# csv header fields
headers = [l(:field_spent_on),
l(:field_user),
l(:field_activity),
l(:field_issue),
l(:field_hours),
l(:field_comment)
]
csv << headers.collect {|c| ic.iconv(c) }
# csv lines
@entries.each do |entry|
fields = [l_date(entry.spent_on),
entry.user.name,
entry.activity.name,
(entry.issue ? entry.issue.id : nil),
entry.hours,
entry.comment
]
csv << fields.collect {|c| ic.iconv(c.to_s) }
end
end
export.rewind
send_data(export.read, :type => 'text/csv; header=present', :filename => 'export.csv')
end
end
......@@ -107,10 +107,10 @@ module SortHelper
order = 'desc' # changed for desc order by default
end
caption = titleize(Inflector::humanize(column)) unless caption
params = {:params => {:sort_key => column, :sort_order => order}}
#params = {:params => {:sort_key => column, :sort_order => order}}
link_to_remote(caption,
{:update => "content", :url => { :sort_key => column, :sort_order => order}},
{:href => url_for(:params => { :sort_key => column, :sort_order => order})}) +
{:update => "content", :url => params.update( :sort_key => column, :sort_order => order)},
{:href => url_for(:params => params.update(:sort_key => column, :sort_order => order))}) +
(icon ? nbsp(2) + image_tag(icon) : '')
end
......
module TimelogHelper
end
......@@ -24,7 +24,8 @@ class Enumeration < ActiveRecord::Base
OPTIONS = {
"IPRI" => :enumeration_issue_priorities,
"DCAT" => :enumeration_doc_categories
"DCAT" => :enumeration_doc_categories,
"ACTI" => :enumeration_activities
}.freeze
def self.get_values(option)
......@@ -42,6 +43,8 @@ private
raise "Can't delete enumeration" if Issue.find(:first, :conditions => ["priority_id=?", self.id])
when "DCAT"
raise "Can't delete enumeration" if Document.find(:first, :conditions => ["category_id=?", self.id])
when "ACTI"
raise "Can't delete enumeration" if TimeEntry.find(:first, :conditions => ["activity_id=?", self.id])
end
end
end
......@@ -28,7 +28,7 @@ class Issue < ActiveRecord::Base
has_many :journals, :as => :journalized, :dependent => :destroy
has_many :attachments, :as => :container, :dependent => :destroy
has_many :time_entries
has_many :custom_values, :dependent => :delete_all, :as => :customized
has_many :custom_fields, :through => :custom_values
......@@ -91,6 +91,10 @@ class Issue < ActiveRecord::Base
self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
@current_journal
end
def spent_hours
@spent_hours ||= time_entries.sum(:hours) || 0
end
private
# Creates an history for the issue
......
......@@ -30,7 +30,8 @@ class Permission < ActiveRecord::Base
1100 => :label_news_plural,
1200 => :label_document_plural,
1300 => :label_attachment_plural,
1400 => :label_repository
1400 => :label_repository,
1500 => :label_time_tracking
}.freeze
@@cached_perms_for_public = nil
......
......@@ -21,6 +21,7 @@ class Project < ActiveRecord::Base
has_many :users, :through => :members
has_many :custom_values, :dependent => :delete_all, :as => :customized
has_many :issues, :dependent => :destroy, :order => "#{Issue.table_name}.created_on DESC", :include => [:status, :tracker]
has_many :time_entries, :dependent => :delete_all
has_many :queries, :dependent => :delete_all
has_many :documents, :dependent => :destroy
has_many :news, :dependent => :delete_all, :include => :author
......
class TimeEntry < ActiveRecord::Base
# could have used polymorphic association
# project association here allows easy loading of time entries at project level with one database trip
belongs_to :project
belongs_to :issue
belongs_to :user
belongs_to :activity, :class_name => 'Enumeration', :foreign_key => :activity_id
attr_protected :project_id, :user_id, :tyear, :tmonth, :tweek
validates_presence_of :user_id, :activity_id, :project_id, :hours, :spent_on
validates_numericality_of :hours, :allow_nil => true
validates_length_of :comment, :maximum => 255
def before_validation
self.project = issue.project if issue && project.nil?
end
def validate
errors.add :hours, :activerecord_error_invalid if hours && hours < 0
errors.add :project_id, :activerecord_error_invalid if project.nil?
errors.add :issue_id, :activerecord_error_invalid if (issue_id && !issue) || (issue && project!=issue.project)
end
# tyear, tmonth, tweek assigned where setting spent_on attributes
# these attributes make time aggregations easier
def spent_on=(date)
super
self.tyear = spent_on ? spent_on.year : nil
self.tmonth = spent_on ? spent_on.month : nil
self.tweek = spent_on ? spent_on.cweek : nil
end
end
......@@ -28,7 +28,8 @@
</tr>
<tr>
<td><b><%=l(:field_fixed_version)%> :</b></td><td><%= @issue.fixed_version ? @issue.fixed_version.name : "-" %></td>
<td></td><td></td>
<td><b><%=l(:label_spent_time)%> :</b></td>
<td><%= @issue.spent_hours > 0 ? (link_to lwr(:label_f_hour, @issue.spent_hours), {:controller => 'timelog', :action => 'details', :issue_id => @issue}, :class => 'icon icon-time') : "-" %></td>
</tr>
<tr>
<% n = 0
......@@ -51,6 +52,7 @@ end %>
<div class="contextual">
<%= link_to_if_authorized l(:button_edit), {:controller => 'issues', :action => 'edit', :id => @issue}, :class => 'icon icon-edit' %>
<%= link_to_if_authorized l(:button_log_time), {:controller => 'timelog', :action => 'edit', :issue_id => @issue}, :class => 'icon icon-time' %>
<%= link_to_if_authorized l(:button_move), {:controller => 'projects', :action => 'move_issues', :id => @project, "issue_ids[]" => @issue.id }, :class => 'icon icon-move' %>
<%= link_to_if_authorized l(:button_delete), {:controller => 'issues', :action => 'destroy', :id => @issue}, :confirm => l(:text_are_you_sure), :method => :post, :class => 'icon icon-del' %>
</div>
......
<h2><%=l(:label_report_plural)%></h2>
<div class="splitcontentleft">
<div class="contextual">
<%= link_to_if_authorized l(:label_query_new), {:controller => 'projects', :action => 'add_query', :id => @project}, :class => 'icon icon-add' %>
</div>
......@@ -11,6 +12,16 @@
<li><%= link_to query.name, :controller => 'projects', :action => 'list_issues', :id => @project, :query_id => query %></li>
<% end %>
</ul>
</div>
<div class="splitcontentright">
<% if @total_hours %>
<h3 class="textright"><%= l(:label_spent_time) %>:
<%= link_to(lwr(:label_f_hour, @total_hours), {:controller => 'timelog', :action => 'details', :project_id => @project}, :class => 'icon icon-time') %>
</h3>
<% end %>
</div>
<div class="clear"></div>
<div class="splitcontentleft">
<h3><%=l(:field_tracker)%>&nbsp;&nbsp;<%= link_to image_tag('zoom_in.png'), :detail => 'tracker' %></h3>
......
<div class="contextual">
<%= link_to_if_authorized l(:button_log_time), {:controller => 'timelog', :action => 'edit', :project_id => @project, :issue_id => @issue}, :class => 'icon icon-time' %>
</div>
<h2><%= l(:label_spent_time) %></h2>
<h3><%= link_to(@project.name, {:action => 'details', :project_id => @project}) if @project %>
<%= "/ " + link_to("#{@issue.tracker.name} ##{@issue.id}", {:action => 'details', :issue_id => @issue }) + ": #{h(@issue.subject)}" if @issue %></h3>
<h3 class="textright"><%= l(:label_total) %>: <%= lwr(:label_f_hour, @total_hours) %></h3>
<% unless @entries.empty? %>
<table class="list">
<thead>
<%= sort_header_tag('spent_on', :caption => l(:label_date)) %>
<%= sort_header_tag('user_id', :caption => l(:label_member)) %>
<%= sort_header_tag('activity_id', :caption => l(:label_activity)) %>
<%= sort_header_tag('issue_id', :caption => l(:label_issue)) %>
<th><%= l(:label_comment) %></th>
<%= sort_header_tag('hours', :caption => l(:field_hours)) %>
<th></th>
</thead>
<tbody>
<% @entries.each do |entry| %>
<tr class="<%= cycle("odd", "even") %>">
<td align="center"><%= format_date(entry.spent_on) %></td>
<td align="center"><%= entry.user.name %></td>
<td align="center"><%= entry.activity.name %></td>
<td align="center">
<% if entry.issue %>
<div class="tooltip">
<%= link_to "#{entry.issue.tracker.name} ##{entry.issue.id}", {:action => 'details', :issue_id => entry.issue } %>
<span class="tip">
<%= render :partial => "issues/tooltip", :locals => { :issue => entry.issue }%>
</span>
</div>
<% end %>
</td>
<td><%=h entry.comment %></td>
<td align="center"><strong><%= entry.hours %></strong></td>
<td align="center"><%= link_to_if_authorized(l(:button_edit), {:controller => 'timelog', :action => 'edit', :id => entry}, :class => "icon icon-edit") if entry.user_id == @owner_id %></td>
</tr>
<% end %>
</tbdoy>
</table>
<div class="contextual">
<%= l(:label_export_to) %>
<%= link_to 'CSV', params.update(:export => 'csv'), :class => 'icon icon-csv' %>
</div>
<% end %>
\ No newline at end of file
<h2><%= l(:label_spent_time) %></h2>
<% labelled_tabular_form_for :time_entry, @time_entry, :url => {:action => 'edit', :project_id => @time_entry.project} do |f| %>
<%= error_messages_for 'time_entry' %>
<div class="box">
<p><%= f.text_field :issue_id, :size => 6 %> <em><%= h("#{@time_entry.issue.tracker.name} ##{@time_entry.issue.id}: #{@time_entry.issue.subject}") if @time_entry.issue %></em></p>
<p><%= f.text_field :spent_on, :size => 10, :required => true %><%= calendar_for('time_entry_spent_on') %></p>
<p><%= f.text_field :hours, :size => 6, :required => true %></p>
<p><%= f.text_field :comment, :size => 100 %></p>
<p><%= f.select :activity_id, (@activities.collect {|p| [p.name, p.id]}), :required => true %></p>
</div>
<%= submit_tag l(:button_save) %>
<% end %>
<% content_for :header_tags do %>
<%= javascript_include_tag 'calendar/calendar' %>
<%= javascript_include_tag "calendar/lang/calendar-#{current_language}.js" %>
<%= javascript_include_tag 'calendar/calendar-setup' %>
<%= stylesheet_link_tag 'calendar' %>
<% end %>
\ No newline at end of file
class CreateTimeEntries < ActiveRecord::Migration
def self.up
create_table :time_entries do |t|
t.column :project_id, :integer, :null => false
t.column :user_id, :integer, :null => false
t.column :issue_id, :integer
t.column :hours, :float, :null => false
t.column :comment, :string, :limit => 255
t.column :activity_id, :integer, :null => false
t.column :spent_on, :date, :null => false
t.column :tyear, :integer, :null => false
t.column :tmonth, :integer, :null => false
t.column :tweek, :integer, :null => false
t.column :created_on, :datetime, :null => false
t.column :updated_on, :datetime, :null => false
end
add_index :time_entries, [:project_id], :name => :time_entries_project_id
add_index :time_entries, [:issue_id], :name => :time_entries_issue_id
end
def self.down
drop_table :time_entries
end
end
class AddTimelogPermissions < ActiveRecord::Migration
def self.up
Permission.create :controller => "timelog", :action => "edit", :description => "button_log_time", :sort => 1520, :is_public => false, :mail_option => 0, :mail_enabled => 0
end
def self.down
Permission.find_by_controller_and_action('timelog', 'edit').destroy
end
end
......@@ -143,6 +143,9 @@ field_comment: Kommentar
field_url: URL
field_start_page: Hauptseite
field_subproject: Subprojekt von
field_hours: Hours
field_activity: Activity
field_spent_on: Datum
setting_app_title: Applikation Titel
setting_app_subtitle: Applikation Untertitel
......@@ -331,6 +334,10 @@ label_preview: Preview
label_feed_plural: Feeds
label_changes_details: Details aller Änderungen
label_issue_tracking: Tickets
label_spent_time: Spent time
label_f_hour: %.2f hour
label_f_hour_plural: %.2f hours
label_time_tracking: Time tracking
button_login: Einloggen
button_submit: OK
......@@ -355,6 +362,7 @@ button_back: Zurück
button_cancel: Abbrechen
button_activate: Aktivieren
button_sort: Sortieren
button_log_time: Log time
status_active: aktiv
status_registered: angemeldet
......@@ -392,6 +400,9 @@ default_priority_normal: Normal
default_priority_high: Hoch
default_priority_urgent: Dringend
default_priority_immediate: Sofort
default_activity_design: Design
default_activity_development: Development
enumeration_issue_priorities: Ticket-Prioritäten
enumeration_doc_categories: Dokumentenkategorien
enumeration_activities: Activities (time tracking)
......@@ -143,6 +143,9 @@ field_comment: Comment
field_url: URL
field_start_page: Start page
field_subproject: Subproject
field_hours: Hours
field_activity: Activity
field_spent_on: Date
setting_app_title: Application title
setting_app_subtitle: Application subtitle
......@@ -331,6 +334,10 @@ label_preview: Preview
label_feed_plural: Feeds
label_changes_details: Details of all changes
label_issue_tracking: Issue tracking
label_spent_time: Spent time
label_f_hour: %.2f hour
label_f_hour_plural: %.2f hours
label_time_tracking: Time tracking
button_login: Login
button_submit: Submit
......@@ -355,6 +362,7 @@ button_back: Back
button_cancel: Cancel
button_activate: Activate
button_sort: Sort
button_log_time: Log time
status_active: active
status_registered: registered
......@@ -392,6 +400,9 @@ default_priority_normal: Normal
default_priority_high: High
default_priority_urgent: Urgent
default_priority_immediate: Immediate
default_activity_design: Design
default_activity_development: Development
enumeration_issue_priorities: Issue priorities
enumeration_doc_categories: Document categories
enumeration_activities: Activities (time tracking)
......@@ -143,6 +143,9 @@ field_comment: Comentario
field_url: URL
field_start_page: Página principal
field_subproject: Proyecto secundario
field_hours: Hours
field_activity: Activity
field_spent_on: Fecha
setting_app_title: Título del aplicación
setting_app_subtitle: Subtítulo del aplicación
......@@ -331,6 +334,10 @@ label_preview: Previo
label_feed_plural: Feeds
label_changes_details: Detalles de todos los cambios
label_issue_tracking: Issue tracking
label_spent_time: Spent time
label_f_hour: %.2f hour
label_f_hour_plural: %.2f hours
label_time_tracking: Time tracking
button_login: Conexión
button_submit: Someter
......@@ -355,6 +362,7 @@ button_back: Atrás
button_cancel: Cancelar
button_activate: Activar
button_sort: Clasificar
button_log_time: Log time
status_active: active
status_registered: registered
......@@ -392,6 +400,9 @@ default_priority_normal: Normal
default_priority_high: Alto
default_priority_urgent: Urgente
default_priority_immediate: Ahora
default_activity_design: Design
default_activity_development: Development
enumeration_issue_priorities: Prioridad de las peticiones
enumeration_doc_categories: Categorías del documento
enumeration_activities: Activities (time tracking)
......@@ -143,6 +143,9 @@ field_comment: Commentaire
field_url: URL
field_start_page: Page de démarrage
field_subproject: Sous-projet
field_hours: Heures
field_activity: Activité
field_spent_on: Date
setting_app_title: Titre de l'application
setting_app_subtitle: Sous-titre de l'application
......@@ -331,6 +334,10 @@ label_preview: Prévisualisation
label_feed_plural: Flux RSS
label_changes_details: Détails de tous les changements
label_issue_tracking: Suivi des demandes
label_spent_time: Temps passé
label_f_hour: %.2f heure
label_f_hour_plural: %.2f heures
label_time_tracking: Suivi du temps
button_login: Connexion
button_submit: Soumettre
......@@ -355,6 +362,7 @@ button_back: Retour
button_cancel: Annuler
button_activate: Activer
button_sort: Trier
button_log_time: Saisir temps
status_active: actif
status_registered: enregistré
......@@ -392,6 +400,9 @@ default_priority_normal: Normal
default_priority_high: Haut
default_priority_urgent: Urgent
default_priority_immediate: Immédiat
default_activity_design: Conception
default_activity_development: Développement
enumeration_issue_priorities: Priorités des demandes
enumeration_doc_categories: Catégories des documents
enumeration_activities: Activités (suivi du temps)
......@@ -143,6 +143,9 @@ field_comment: Commento
field_url: URL
field_start_page: Pagina principale
field_subproject: Sottoprogetto
field_hours: Hours
field_activity: Activity
field_spent_on: Data
setting_app_title: Titolo applicazione
setting_app_subtitle: Sottotitolo applicazione
......@@ -331,6 +334,10 @@ label_preview: Previsione
label_feed_plural: Feeds
label_changes_details: Particolari di tutti i cambiamenti
label_issue_tracking: Issue tracking
label_spent_time: Spent time
label_f_hour: %.2f hour
label_f_hour_plural: %.2f hours
label_time_tracking: Time tracking
button_login: Login
button_submit: Invia
......@@ -355,6 +362,7 @@ button_back: Indietro
button_cancel: Annulla
button_activate: Attiva
button_sort: Ordina
button_log_time: Log time
status_active: active
status_registered: registered
......@@ -392,6 +400,9 @@ default_priority_normal: Normale
default_priority_high: Alta
default_priority_urgent: Urgente
default_priority_immediate: Immediata
default_activity_design: Design
default_activity_development: Development
enumeration_issue_priorities: Priorità contesti
enumeration_doc_categories: Categorie di documenti
enumeration_activities: Activities (time tracking)
......@@ -144,6 +144,9 @@ field_comment: コメント
field_url: URL
field_start_page: メインページ
field_subproject: サブプロジェクト
field_hours: Hours
field_activity: Activity
field_spent_on: 日付
setting_app_title: アプリケーションのタイトル
setting_app_subtitle: アプリケーションのサブタイトル
......@@ -332,6 +335,10 @@ label_preview: 下検分
label_feed_plural: Feeds
label_changes_details: Details of all changes
label_issue_tracking: Issue tracking
label_spent_time: Spent time
label_f_hour: %.2f hour
label_f_hour_plural: %.2f hours
label_time_tracking: Time tracking
button_login: ログイン
button_submit: 変更
......@@ -356,6 +363,7 @@ button_back: 戻る
button_cancel: キャンセル
button_activate: 有効にする
button_sort: ソート
button_log_time: Log time
status_active: active
status_registered: registered
......@@ -393,6 +401,9 @@ default_priority_normal: 通常
default_priority_high: 高め
default_priority_urgent: 急いで
default_priority_immediate: 今すぐ
default_activity_design: Design
default_activity_development: Development
enumeration_issue_priorities: 問題の優先度
enumeration_doc_categories: 文書カテゴリ
enumeration_activities: Activities (time tracking)
......@@ -39,7 +39,7 @@ begin
manager.permissions = Permission.find(:all, :conditions => ["is_public=?", false])
developper = Role.create :name => l(:default_role_developper), :position => 2
perms = [150, 320, 321, 322, 420, 421, 422, 1050, 1060, 1070, 1075, 1130, 1220, 1221, 1222, 1223, 1224, 1320, 1322, 1061, 1057]
perms = [150, 320, 321, 322, 420, 421, 422, 1050, 1060, 1070, 1075, 1130, 1220, 1221, 1222, 1223, 1224, 1320, 1322, 1061, 1057, 1520]
developper.permissions = Permission.find(:all, :conditions => ["sort IN (#{perms.join(',')})"])
reporter = Role.create :name => l(:default_role_reporter), :position => 3
......@@ -88,12 +88,16 @@ begin
# enumerations
Enumeration.create(:opt => "DCAT", :name => l(:default_doc_category_user))
Enumeration.create(:opt => "DCAT", :name => l(:default_doc_category_tech))
Enumeration.create(:opt => "IPRI", :name => l(:default_priority_low))
Enumeration.create(:opt => "IPRI", :name => l(:default_priority_normal))
Enumeration.create(:opt => "IPRI", :name => l(:default_priority_high))
Enumeration.create(:opt => "IPRI", :name => l(:default_priority_urgent))
Enumeration.create(:opt => "IPRI", :name => l(:default_priority_immediate))
Enumeration.create(:opt => "ACTI", :name => l(:default_activity_design))
Enumeration.create(:opt => "ACTI", :name => l(:default_activity_development))
rescue => error
puts "Error: " + error
puts "Default configuration data can't be loaded."
......
......@@ -155,6 +155,7 @@ vertical-align: middle;
.icon-index { background-image: url(../images/index.png); }
.icon-history { background-image: url(../images/history.png); }
.icon-feed { background-image: url(../images/feed.png); }
.icon-time { background-image: url(../images/time.png); }
.icon22-projects { background-image: url(../images/22x22/projects.png); }
.icon22-users { background-image: url(../images/22x22/users.png); }
......@@ -542,7 +543,7 @@ font-size: 1em;
/***** Tooltips ******/
.tooltip{position:relative;z-index:24;}
.tooltip:hover{z-index:25;color:#000;}
.tooltip span.tip{display: none}
.tooltip span.tip{display: none; text-align:left;}
div.tooltip:hover span.tip{
display:block;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment