A Report object queries the database for a set of numbers and compiles them into a set of Sections, Rows and Cells which can be queried via a accessors and enumerators provided herein.
Configure the handlers and human-readable labels for the ways in which reports get broken up, in terms of frequency. View code which presents a choice of report frequency should obtain the labels for the list with the ‘label’ method. Use the ‘#column_title’ method for a column “title”, shown alongside or above column headings. Use the ‘#column_heading’ method for per-column headings.
These must stay in the same order!
If you add new entries, you must add them at the end of the list.
Saved reports specify the reporting frequency by reference to this constant and an index into its array of entries. If you produce a new TrackRecord version that changes the meaning of any array index rather than merely adding new entries, you will have to include a migration that maps old indices to new for existing saved report records.
Executive summary: Reports are fast on PostgreSQL and slow on anything else, unless you take steps to make sure they run quickly.
To run report sums in the database quickly, with minimum queries, the database is asked to group its calculations. When we can group using something that matches the columns of a report, we can get the database to in effect calculate the entire body of the report in one query. So for example, a monthly report would group by year and month number; any given date produces a unique year and month.
To do this, the SQL ‘EXTRACT’ function is used by default.
www.postgresql.org/docs/8.3/static/functions-datetime.html#FUNCTIONS-DATETIME-EXTRACT
docs.oracle.com/cd/B19306_01/server.102/b14200/functions050.htm
With the standard report generator implementation, TrackRecord reports rely on being able to extract:
YEAR (must match Ruby Date#year, i.e. numeric, four digit standard calendar year)
QUARTER (must match Ruby Date#quarter, i.e. numeric 1-4, simple 3 full month splits)
MONTH (must match Ruby Date#month, i.e. numeric 1-12)
WEEK - see below - so also ISOYEAR (PostgreSQL v8.3+ specific)
Ruby mirrors ISOYEAR with Date#cwyear. PostgreSQL v8.3 and later also
provide ISODOW (day-of-week), equivalent to Ruby’s Date#cwday, but that is not needed by TrackRecord.
Weekly reports are thorny, because that requires grouping by some idea of a unique numbered week. The commercial week number is the obvious choice. MySQL and PostgreSQL provide a WEEK parameter for this in EXTRACT, but Oracle does not offer this and can’t be used without changing the TrackRecord report generator’s FREQUENCY constant for weekly reports, or disabling weekly reports entirely.
In any case, even that is insufficient. The 31st December 2012 marks the start of commercial week number 1 for the commercial year 2013; but the date itself is in calendar year 2012. January 1st 2006 is actually the last day of commercial week 52 in the commercial year 2005, despite the date being in calendar year 2006. If we were to just use a standard calendar year, we’d get into trouble - 2012 would appear to contain week 1 twice, leading to numbers from entirely different time periods being combined in the report calculations.
Thus <strong>weekly reports will only function if you use PostgreSQL version 8.3 or later</strong> unless you modify the report code. Without modifications, Oracle and MySQL should work (but are not tested). Note that SQLite will <strong>not work at all</strong> as it has no support for EXTRACT at all - you need to use ‘strftime’ instead, and this provides easily enough flexibility to support weekly reports too:
To modify FREQUENCY for a different database:
Change “config/initializers/can_database_do_fast_reports.rb” so that the SLOW_DATABASE_ALTERNATIVE constant value is “false” for your database. Without this, you won’t be able to test your modifications to FREQUENCY as a “safe mode” fallback is active.
Change the “:grouping” key to the equivalent of the EXTRACT already present, noting that it shoudld always be an array, even if there’s only one group. Ensure that whatever you group by will lead to one group for every single unique column for the intended report, e.g. uniquely identifies months across many years, or weeks across awkward end-of-year boundaries.
Change the “:date_to_key” proc so that Ruby generates an array that matches the grouping keys returned by the database when it runs the query that calculates the report data. To find out that bit of the puzzle, search in “lib/track_record_report.rb” (i.e. this file) for the string “*date_based_key”. You’ll see a line of code that assigns this from a variable. Underneath that line, add in: “raise date_based_key.inspect” and generate a report of the column duration you are modifying, while in development mode. The raised exception will show in your browser the form of the value that the date-to-key proc must return.
Also look at the code below the assignment of FREQUENCY to see how the slow “safe mode” is done. You could always create those values inside FREQUENCY by hand for report types you can’t generate in the database.
“Cacheable” range values. If a report’s start or end date is fixed, returns the date. If the start or end date are start/end-of-all-time, one or both returns :all. If the report is being generated for a relative month or week, returns “last_”, “this_” or “two_” with a suffix of “month” or “week”, as a symbol, for both start and end.
“Cacheable” range values. If a report’s start or end date is fixed, returns the date. If the start or end date are start/end-of-all-time, one or both returns :all. If the report is being generated for a relative month or week, returns “last_”, “this_” or “two_” with a suffix of “month” or “week”, as a symbol, for both start and end.
Sort fields for customers, projects and tasks; grouping options.
A row from the FREQUENCY constant and the current index into that array.
A row from the FREQUENCY constant and the current index into that array.
Sort fields for customers, projects and tasks; grouping options.
Complete date range for the whole report; array of user IDs used for per-user breakdowns; array of task IDs the report will represent.
Range data for the ‘new’ view form. Custom attribute writer methods are used to call “rationalise_dates” whenever a range value is altered.
Range data for the ‘new’ view form. Custom attribute writer methods are used to call “rationalise_dates” whenever a range value is altered.
Handle all (“all”), only billable (“billable”) or only non-billable (“non_billable”) tasks?
Sort fields for customers, projects and tasks; grouping options.
Read-only array of actual user and task objects based on the IDs. Not all users or tasks may be included, depending on security settings.
A report’s total date span may be restricted to avoid generating giant reports which would swamp a server (for example, a daily report over hundreds or thousands of days would be a bad idea). If not done, this holds ‘nil’, else the Date giving the original start date (the limited, actual start date used is in the “range” property).
Views/generators can use it to include warnings that the start date was limited, should they so wish.
Optional report title.
Total duration of all tasks in all rows; number of hours remaining (may be negative for overrun) after all hours worked in tasks with non-zero duration. If ‘nil’, all tasks had zero duration. The ‘actual’ value only accounts for committed hours, while the ‘potential’ value includes both committed and not-committed hours (thus, subject to change).
A report that hides zero total rows will not include task durations on those rows. A report that shows them will include task durations on those rows.
Total duration of all tasks in all rows; number of hours remaining (may be negative for overrun) after all hours worked in tasks with non-zero duration. If ‘nil’, all tasks had zero duration. The ‘actual’ value only accounts for committed hours, while the ‘potential’ value includes both committed and not-committed hours (thus, subject to change).
A report that hides zero total rows will not include task durations on those rows. A report that shows them will include task durations on those rows.
Total duration of all tasks in all rows; number of hours remaining (may be negative for overrun) after all hours worked in tasks with non-zero duration. If ‘nil’, all tasks had zero duration. The ‘actual’ value only accounts for committed hours, while the ‘potential’ value includes both committed and not-committed hours (thus, subject to change).
A report that hides zero total rows will not include task durations on those rows. A report that shows them will include task durations on those rows.
Read-only array of actual user and task objects based on the IDs. Not all users or tasks may be included, depending on security settings.
Class method equivalent of “label” above. Returns the label for the given frequency, which must be a valid index into the array defined by the FREQUENCY constant. See also “labels” below.
# File lib/track_record_report.rb, line 901 def self.label( frequency ) return Report::FREQUENCY[ frequency ][ :label ] end
Class method which returns an array of labels for various report frequencies. The index into the array indicates the frequency index.
# File lib/track_record_report.rb, line 908 def self.labels return Report::FREQUENCY.map { | f | f[ :label ] } end
Create a new Report. In the first parameter, pass the current TrackRecord user. In the next parameter pass nothing to use default values for a ‘new report’ view form, or pass “params[ :report ]” (or similar) to create using a params hash from a ‘new report’ form submission.
# File lib/track_record_report.rb, line 496 def initialize( current_user, params = nil ) super() @current_user = current_user @range_start = nil @range_end = nil @range_week_start = nil @range_week_end = nil @range_month_start = nil @range_month_end = nil @frequency = 0 @customer_sort_field = 'title' @project_sort_field = 'title' @task_sort_field = Task::DEFAULT_SORT_COLUMN @task_grouping = :default @task_filter = 'all' @include_totals = true @include_committed = false @include_not_committed = false @exclude_zero_rows = false @exclude_zero_cols = false # Totals only - ignores zero com/non-com columns in CSV exports for total/com/non-com column groups with non-zero totals @rows = {} @column_totals = {} @column_ranges = [] @column_keys = [] @tasks = Task.scoped @filtered_tasks = Task.scoped @task_ids = [] @active_task_ids = [] @inactive_task_ids = [] @users = [] @filtered_users = [] @reportable_user_ids = [] unless ( params.nil? ) # Adapted from ActiveRecord::Base "attributes=", Rails 2.1.0 # on 29-Jun-2008. attributes = params.dup attributes.stringify_keys! attributes.each do | key, value | if ( key.include?( '(' ) ) raise( "Multi-parameter attributes are not supported." ) else begin send( key + "=", value ) rescue # Ignore errors end end end end end
Build the ‘tasks’ array if ‘#active_task_ids’ is updated externally. The result will be the sum of existing inactive and updated active task IDs.
# File lib/track_record_report.rb, line 595 def active_task_ids=( ids ) @provided_active_task_ids = map_raw_ids( ids ) assign_actual_tasks_from_provided_ids() end
Virtual accessor for the active task list, which just filters the master task list and returns the result (less RAM than keeping dual lists and not speed-critical as not used that often in practice).
# File lib/track_record_report.rb, line 562 def active_tasks @tasks.where( :active => true ) end
Only useful for compiled reports - see “compile”.
Returns the number of columns the report generated, taking into account zero total columns and the “hide zero columns” flag.
# File lib/track_record_report.rb, line 813 def column_count @relevant_column_keys.count end
Helper method which returns a user-displayable column heading appropriate for the report type. Pass a column range (see e.g. “#each_column_range”). Optionally pass “true” to replace “<br />” with a space (if present) in the heading, for a plain text alternative.
# File lib/track_record_report.rb, line 925 def column_heading( range, plain_text = false ) heading = send( @frequency_data[ :column ], range ) plain_text ? heading.gsub( "<br />", " " ) : heading end
Helper method which returns a user-displayable column title to be shown once, next to or near per-column headings (see “#column_heading”), appropriate for the report type.
# File lib/track_record_report.rb, line 916 def column_title return @frequency_data[ :title ] end
Return the total value as a Calculator subclass instance for the column identified by the given date-based key.
Will return ‘nil’ if no such non-zero total column exists (yet). Only really useful if the report has been compiled by calling “compile”.
# File lib/track_record_report.rb, line 672 def column_total( date_based_key ) @column_totals[ date_based_key ] end
Return the total value as a Cell instance for the column identified by the given date-based key. A Cell class is used so that per-user column totals can be maintained. Think of each column total as a cell in an extra total-based row of the report.
Will create an empty zero-hour Cell subclass instance in passing if necessary. Usually only useful during the process of report compilation (see “compile”) but may have specialist external uses too.
# File lib/track_record_report.rb, line 686 def column_total!( date_based_key ) @column_totals[ date_based_key ] ||= Cell.new end
Compile the report. Returns ‘self’ for convenience.
# File lib/track_record_report.rb, line 869 def compile apply_filters() return if ( @filtered_tasks.count.zero? ) # Nothing to do... rationalise_dates() set_columns() sort_and_group() initialize_sections( @filtered_tasks, Section ) # TrackRecordSections::SectionsMixin calculate() self end
Helper method which returns a user-displayable range describing the total date range for this report.
# File lib/track_record_report.rb, line 893 def display_range return heading_total( @range ) end
Only useful for compiled reports - see “compile”.
Iterate over the cells in a given Row or Row subclass. Calls a caller-supplied block, passing the cell instance. Will pass ‘nil’ for cells with a zero hour total (it is much faster to check for ‘nil’ many times than instantiate a useless cell object with zero values for its hours).
A ‘nil’ input parameter value is allowed. The caller block will be invoked with ‘nil’ for each cell that would have been on the row if it existed.
# File lib/track_record_report.rb, line 729 def each_cell_for( row ) # :yields: cell # We use zero column total values to indicate that an associated entry # in the column ranges should be omitted. if ( row.nil? ) @relevant_column_keys.each { yield( nil ) } else @relevant_column_keys.each do | date_based_key | yield( row.cell( date_based_key ) ) end end end
Only useful for compiled reports - see “compile”.
Results of calling here are undefined unless the report parameters tell it to include per-user details during compilation.
For a given user and row, return cells for that row giving the column based totals for just that specific user. Zero columns are skipped if “hide zero columns” is enabled.
This is similar to just doing “#each_cell_for( row )” and calling the cell’s “user_total” method manually for whatever your current User of interest happens to be, but calling here takes care of that for you and deals with ‘nil’ cleanly in passing.
The given User instance must be valid. The given Row instance may be ‘nil’. If so, either the block is called with ‘nil’ for each column if “hide zero rows” is disabled, else it isn’t called at all.
# File lib/track_record_report.rb, line 796 def each_cell_for_user_on_row( user, row ) # :yields: cell_for_user if ( row.nil? ) @relevant_column_keys.each { yield( nil ) } unless ( @exclude_zero_rows ) else user_id_str = user.id.to_s @relevant_column_keys.each do | date_based_key | yield( row.cell( date_based_key ).try( :user_total, user_id_str ) ) end end end
Only useful for compiled reports - see “compile”.
Iterate over all columns, calling a caller-supplied block with a Range object describing the Date range for each one, inclusive of column start/end. Always calls with a valid Range, never ‘nil’.
# File lib/track_record_report.rb, line 836 def each_column_range # :yields: column_range # We use zero column total values to indicate that an associated entry # in the column ranges should be omitted. @column_keys.each_with_index do | date_based_key, linear_column_index | next if ( @exclude_zero_cols and not @column_totals.has_key?( date_based_key ) ) yield @column_ranges[ linear_column_index ] end end
Only useful for compiled reports - see “compile”.
Iterate over all column totals, calling the caller-supplied block with a Calculator subclass describing the total for that column. If the total is zero and ‘hide zero columns’ is disabled, your block will be called with ‘nil’ for that column.
# File lib/track_record_report.rb, line 824 def each_column_total # :yields: column_total @relevant_column_keys.each do | date_based_key | yield( @column_totals[ date_based_key ] ) end end
Only useful for compiled reports - see “compile”.
Iterate over all defined rows, yielding a caller supplied block passing it the row and task in task list order. If “hide zero rows” is set, only defined rows and tasks with non-zero totals will be sent. Otherwise, you will be called with a row value of ‘nil’ and the task for which the row total was zero (it is much faster to check for ‘nil’ many times than instantiate a useless row object with zero values for its hours).
# File lib/track_record_report.rb, line 710 def each_row # :yields: row, task @filtered_tasks.each do | task | row = @rows[ task.id.to_s ] yield( row, task ) unless ( @exclude_zero_rows and row.nil? ) end end
Only useful for compiled reports - see “compile”.
Iterate over the list of users passed in the constructor, calling a caller-supplied block with the User instances in the order they were originally given. If “hide zero columns” is set, then only users with a non-zero overall total will be included.
# File lib/track_record_report.rb, line 863 def each_user # :yields: user @filtered_users.each { | user | yield( user ) } end
Only useful for compiled reports - see “compile”.
Results of calling here are undefined unless the report parameters tell it to include per-user details during compilation.
For each user associated with a row, call the caller’s block with the User instance and the Calculator subclass giving the user’s total on that row. Will call with “nil” for each user in the row is “nil”, unless “hide zero rows” is enabled, in which case if given nil it will not call the block at all.
The block is only called for a non-nil row if a user has a non-zero total, or if “hide zero rows” is disabled, in which case the user total instance may be ‘nil’ (but the User is always valid).
See also “#each_cell_for_user_on_row”.
# File lib/track_record_report.rb, line 760 def each_user_on_row( row ) # :yields: user, user_total_for_row # Not 'filtered_users' - those are aimed at the columns in reports # where each *column* represents a user, so hide-zero-columns will # result in hidden users. Here, we're looking at the per-user # breakdown for a single task on a row. If hide-zero-cols is set # but hide-zero-rows is not, we don't want to hide users here. if ( row.nil? ) @users.each { | user | yield( user, nil ) } unless ( @exclude_zero_rows ) else @users.each do | user | user_total_for_row = row.user_total( user.id.to_s ) yield( user, user_total_for_row ) unless ( @exclude_zero_rows and user_total_for_row.nil? ) end end end
Set the ‘#frequency_data’ field when ‘frequency’ is updated externally.
# File lib/track_record_report.rb, line 629 def frequency=( freq ) @frequency = freq.to_i @frequency_data = FREQUENCY[ @frequency ].dup end
Only useful for compiled reports - see “compile”.
Returns ‘true’ if the report has any non-zero hours counted for any of its rows, else ‘false’ (all tasks counted to zero hours within the other report constraints/parameters).
# File lib/track_record_report.rb, line 696 def has_rows? not @rows.empty? end
Build the ‘tasks’ array if ‘#inactive_task_ids’ is updated externally. The result will be the sum of existing active and updated inactive task IDs.
# File lib/track_record_report.rb, line 603 def inactive_task_ids=( ids ) @provided_inactive_task_ids = map_raw_ids( ids ) assign_actual_tasks_from_provided_ids() end
As above, but for inactive tasks.
# File lib/track_record_report.rb, line 568 def inactive_tasks @tasks.where( :active => false ) end
Helper method which returns a user-displayable label describing this report type. There’s a class method equivalent below.
# File lib/track_record_report.rb, line 886 def label return @frequency_data[ :label ] end
Does the column at the given index only contain partial results, because it is the first or last column in the overall range and that range starts or ends somewhere in the middle? Returns ‘true’ if so, else ‘false’.
# File lib/track_record_report.rb, line 934 def partial_column?( range ) if ( range == @column_ranges.first ) @column_first_partial elsif ( range == @column_ranges.last ) @column_last_partial else false end end
# File lib/track_record_report.rb, line 638 def range_end=( value ); @range_end = value; rationalise_dates(); end
# File lib/track_record_report.rb, line 643 def range_month_end=( value ); @range_month_end = value; rationalise_dates(); end
# File lib/track_record_report.rb, line 642 def range_month_start=( value ); @range_month_start = value; rationalise_dates(); end
# File lib/track_record_report.rb, line 644 def range_one_month=( value ); @range_one_month = value; rationalise_dates(); end
# File lib/track_record_report.rb, line 641 def range_one_week=( value ); @range_one_week = value; rationalise_dates(); end
Rationalise overall date ranges whenever a related field is updated externally.
# File lib/track_record_report.rb, line 637 def range_start=( value ); @range_start = value; rationalise_dates(); end
# File lib/track_record_report.rb, line 640 def range_week_end=( value ); @range_week_end = value; rationalise_dates(); end
# File lib/track_record_report.rb, line 639 def range_week_start=( value ); @range_week_start = value; rationalise_dates(); end
Build the ‘user’ array if ‘#reportable_user_ids’ is updated externally.
# File lib/track_record_report.rb, line 610 def reportable_user_ids=( ids ) ids ||= [] ids = ids.values if ( ids.is_a?( Hash ) or ids.is_a?( HashWithIndifferentAccess ) ) @reportable_user_ids = ids.map { | str | str.to_i } # Security - if the current user is restricted they might try and hack # the form to view other user details. if ( @current_user.restricted? ) @reportable_user_ids = [ @current_user.id ] unless( @reportable_user_ids.empty? ) end # Turn the list of (now numeric) user IDs into user objects. @users = User.where( :id => @reportable_user_ids ) end
Return the row defined for the given task ID, specified as a string.
Will return ‘nil’ if no such row exists. Only really useful if the report has been compiled by calling “compile”.
# File lib/track_record_report.rb, line 651 def row( task_id_str ) @rows[ task_id_str ] end
Return the row defined for the given task ID, specified as a string.
Will create an empty Row instance in passing if necessary. Usually only useful during the process of report compilation (see “compile”) but may have specialist external uses too.
# File lib/track_record_report.rb, line 661 def row!( task_id_str ) @rows[ task_id_str ] ||= Row.new end
Build the ‘tasks’ array if ‘#task_ids’ is updated externally.
# File lib/track_record_report.rb, line 587 def task_ids=( ids ) @provided_task_ids = map_raw_ids( ids ) assign_actual_tasks_from_provided_ids() end
Set a task array directly (will always be filtered according to security settings for the current user).
# File lib/track_record_report.rb, line 575 def tasks=( array ) unless array.nil? || array.count.zero? @tasks = Task.where( :id => array ) else @tasks = Task.scoped end update_internal_task_lists() end
Only useful for compiled reports - see “compile”.
Returns the number of users the report considered, taking into account zero total users and the “hide zero columns” flag.
# File lib/track_record_report.rb, line 852 def user_count @filtered_users.count end