[Rt-commit] rt branch, 4.6/custom-date-ranges, created. rt-4.4.4-83-g990fe87b2

? sunnavy sunnavy at bestpractical.com
Wed Jun 12 15:41:13 EDT 2019


The branch, 4.6/custom-date-ranges has been created
        at  990fe87b261bd17a264c84500ac8024c8b4aa382 (commit)

- Log -----------------------------------------------------------------
commit d19d1432f6b5ab4cdefbb1cd5b3cf8304a8cb4d7
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Jun 28 23:23:26 2016 +0000

    RT::Record support for CustomDateRanges

diff --git a/lib/RT/Record.pm b/lib/RT/Record.pm
index 0cc188463..46d1b41ec 100644
--- a/lib/RT/Record.pm
+++ b/lib/RT/Record.pm
@@ -2406,6 +2406,184 @@ sub WikiBase {
     return RT->Config->Get('WebPath'). "/index.html?q=";
 }
 
+# Matches one field in "field - field" style range specs. Subclasses
+# that can participate in custom date ranges should override this method
+# to match their additional date fields. Be sure to call this superclass
+# method to get "now", datetime columns and CF parsing.
+
+sub _CustomDateRangeFieldParser {
+    my $self = shift;
+    my $regex = qr{
+        now
+        | cf\. (?: \{ .*? \} | \S+ )
+    }xi;
+
+    for my $column ( keys %{ $_TABLE_ATTR->{ ref $self || $self} } ) {
+        my $entry = $_TABLE_ATTR->{ ref $self || $self}{$column};
+        next unless $entry->{read} && ( $entry->{type} // '' ) eq 'datetime';
+        $regex .= '|' . qr{$column}i;
+    }
+    return $regex;
+}
+
+# Returns an RT::Date instantiated with this record's value for the parsed
+# field name. Includes the $range_name parameter only for diagnostics.
+# Subclasses should override this to instantiate the fields they added in
+# _CustomDateRangeFieldParser.
+
+sub _DateForCustomDateRangeField {
+    my $self       = shift;
+    my $field      = shift;
+    my $range_name = shift;
+
+    my $date = RT::Date->new($self->CurrentUser);
+
+    if (lc($field) eq 'now') {
+        $date->Set(Format => 'unix', Value => time);
+    }
+    elsif ($field =~ m{^ cf\. (?: \{ (.*?) \} | (\S+) ) $}xi) {
+        my $name = $1 || $2;
+        my $value = $self->FirstCustomFieldValue($name);
+
+        if (!$value) {
+            # no CF value for this record, so bail out
+            return;
+        }
+
+        $date->Set(Format => 'unknown', Value => $value, Timezone => 'UTC');
+    }
+    else {
+        if ( my ($column) = grep { lc $field eq lc $_ } keys %{ $_TABLE_ATTR->{ ref $self || $self } } ) {
+            my $method = $column . 'Obj';
+            if ( $self->can($method) ) {
+                $date = $self->$method;
+            }
+            else {
+                RT->Logger->error( "Missing $method in " . ref $self );
+                return;
+            }
+        }
+        else {
+            RT->Logger->error("Unable to parse '$field' as a field name in CustomDateRanges '$range_name'");
+            return;
+        }
+    }
+
+    return $date;
+}
+
+# Parses "field - field" and returns a four-element list containing the end
+# date field name, the operator (right now always "-" for subtraction), the
+# start date field name, and either a custom duration formatter coderef or
+# undef. Returns the empty list if there's an error.
+
+sub _ParseCustomDateRangeSpec {
+    my $self = shift;
+    my $name = shift;
+    my $spec = shift;
+
+    my $calculation;
+    my $format;
+
+    if (ref($spec)) {
+        $calculation = $spec->{value};
+        $format = $spec->{format};
+    }
+    else {
+        $calculation = $spec;
+    }
+
+    if (!$calculation || ref($calculation)) {
+        RT->Logger->error("CustomDateRanges '$name' 'value' must be a string");
+        return;
+    }
+
+    if ($format && ref($format) ne 'CODE') {
+        RT->Logger->error("CustomDateRanges '$name' 'format' must be a CODE reference");
+        return;
+    }
+
+    # class-specific matcher for now, created, CF.{foo bar}, CF.baz, etc.
+    my $field_parser = $self->_CustomDateRangeFieldParser;
+
+    # regex parses "field - field" (intentionally very strict)
+    my $calculation_parser = qr{
+        ^
+        ($field_parser)   # end field name
+        \s+ (-) \s+       # space, operator, more space
+        ($field_parser)   # start field name
+        $
+    }x;
+
+    my @matches = $calculation =~ $calculation_parser;
+
+    if (!@matches) {
+        RT->Logger->error("Unable to parse '$calculation' as a calculated value in CustomDateRanges '$name'");
+        return;
+    }
+
+    if (@matches != 3) {
+        RT->Logger->error("Unexpected number of submatches for '$calculation' in CustomDateRanges '$name'. Got " . scalar(@matches) . ", expected 3.");
+        return;
+    }
+
+    my ($end, $op, $start) = @matches;
+
+    return ($end, $op, $start, $format);
+}
+
+=head2 CustomDateRange name, spec
+
+Takes a L<RT_Config/%CustomDateRanges>-style spec string and its name (for
+diagnostics). Returns a localized string evaluating the calculation. If either
+date is unset, or anything fails to parse, this returns C<undef>.
+
+=cut
+
+sub CustomDateRange {
+    my $self = shift;
+    my $name = shift;
+    my $spec = shift;
+
+    my ($end, $op, $start, $format) = $self->_ParseCustomDateRangeSpec($name, $spec);
+
+    # parse failed; render no value
+    return unless $start && $end;
+
+    my $end_dt = $self->_DateForCustomDateRangeField($end, $name);
+    my $start_dt = $self->_DateForCustomDateRangeField($start, $name);
+
+    # RT::Date instantiation failed; render no value
+    return unless $start_dt && $start_dt->IsSet
+               && $end_dt && $end_dt->IsSet;
+
+    my $duration;
+    if ($op eq '-') {
+        $duration = $end_dt->Diff($start_dt);
+    }
+    else {
+        RT->Logger->error("Unexpected operator in CustomDateRanges '$name' spec '$spec'. Got '$op', expected '-'.");
+        return;
+    }
+
+    # _ParseCustomDateRangeSpec guarantees $format is a coderef
+    if ($format) {
+        return $format->($duration, $end_dt, $start_dt, $self);
+    }
+    else {
+        # "x days ago" is strongly suggestive of comparing with the current
+        # time; but if we're comparing two arbitrary times, "x days prior"
+        # reads better
+        if ($duration < 0) {
+            $duration *= -1;
+            return $self->loc('[_1] prior', $end_dt->DurationAsString($duration));
+        }
+        else {
+            return $end_dt->DurationAsString($duration);
+        }
+    }
+}
+
 sub UID {
     my $self = shift;
     return undef unless defined $self->Id;

commit a9107a774d9d73f0bce66d3498d93e80750c3a6c
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Jun 28 23:23:41 2016 +0000

    Add "LastContact" alias for CustomDateRanges
    
    As the label of "Told" is "Last Contact" in web UI, it's good to have
    this alias for CustomDateRanges too.  Note that we only support compact
    version "LastContact", to be consistent with real columns like
    "LastUpdated".

diff --git a/lib/RT/Ticket.pm b/lib/RT/Ticket.pm
index 38708af5f..5c400464c 100644
--- a/lib/RT/Ticket.pm
+++ b/lib/RT/Ticket.pm
@@ -68,6 +68,7 @@ package RT::Ticket;
 use strict;
 use warnings;
 use base 'RT::Record';
+use 5.010;
 
 use Role::Basic 'with';
 
@@ -3228,6 +3229,19 @@ sub CurrentUserCanSeeTime {
            !RT->Config->Get('HideTimeFieldsFromUnprivilegedUsers');
 }
 
+sub _DateForCustomDateRangeField {
+    my $self  = shift;
+    my $field = shift or return;
+
+    state $alias = { 'lastcontact' => 'told' };
+    return $self->SUPER::_DateForCustomDateRangeField( $alias->{lc $field} || $field, @_);
+}
+
+sub _CustomDateRangeFieldParser {
+    my $self = shift;
+    return $self->SUPER::_CustomDateRangeFieldParser . '|' . qr{LastContact}i;
+}
+
 sub Table {'Tickets'}
 
 =head2 id

commit 8c804c3618ed5a533b82eebf8948709a9626a7aa
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Jun 28 23:24:10 2016 +0000

    Add RT::Ticket CustomDateRanges to query builder

diff --git a/share/html/Elements/RT__Ticket/ColumnMap b/share/html/Elements/RT__Ticket/ColumnMap
index ae425272e..da64b1915 100644
--- a/share/html/Elements/RT__Ticket/ColumnMap
+++ b/share/html/Elements/RT__Ticket/ColumnMap
@@ -333,6 +333,18 @@ $COLUMN_MAP = {
         },
     },
 };
+
+if (my $config = RT->Config->Get('CustomDateRanges')) {
+    my %ranges = %{ $config->{'RT::Ticket'} || {} };
+    for my $name (keys %ranges) {
+        $COLUMN_MAP->{$name} = {
+            title => $name,
+            value => sub {
+                $_[0]->CustomDateRange($name, $ranges{$name});
+            },
+        };
+    }
+}
 </%ONCE>
 <%init>
 # if no encryption support, then KeyOwnerName and KeyRequestors fall back to the regular
diff --git a/share/html/Search/Elements/BuildFormatString b/share/html/Search/Elements/BuildFormatString
index ce541f889..e0a66168a 100644
--- a/share/html/Search/Elements/BuildFormatString
+++ b/share/html/Search/Elements/BuildFormatString
@@ -132,6 +132,11 @@ while ( my $Role = $CustomRoles->Next ) {
     push @fields, "CustomRole.{" . $Role->Name . "}";
 }
 
+if (RT->Config->Get('CustomDateRanges')) {
+    my %ranges = %{ RT->Config->Get('CustomDateRanges')->{'RT::Ticket'} || {} };
+    push @fields, sort keys %ranges;
+}
+
 $m->callback( Fields => \@fields, ARGSRef => \%ARGS );
 
 my ( @seen);

commit 1875c8b79684191725543b115dbcfa2728ce3d4e
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Jun 30 21:39:10 2016 -0400

    Add Asset ColumnMap entries for CustomDateRanges

diff --git a/share/html/Elements/RT__Asset/ColumnMap b/share/html/Elements/RT__Asset/ColumnMap
index b072eb332..d76ccd213 100644
--- a/share/html/Elements/RT__Asset/ColumnMap
+++ b/share/html/Elements/RT__Asset/ColumnMap
@@ -112,6 +112,18 @@ my $COLUMN_MAP = {
         }
     },
 };
+
+if (my $config = RT->Config->Get('CustomDateRanges')) {
+    my %ranges = %{ $config->{'RT::Asset'} || {} };
+    for my $name (keys %ranges) {
+        $COLUMN_MAP->{$name} = {
+            title => $name,
+            value => sub {
+                $_[0]->CustomDateRange($name, $ranges{$name});
+            },
+        };
+    }
+}
 </%ONCE>
 <%init>
 $m->callback( COLUMN_MAP => $COLUMN_MAP, CallbackName => 'Once', CallbackOnce => 1 );

commit 1082523c60d5d9afab4b255118d511f4a091bfd7
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Jun 29 00:09:10 2016 +0000

    Add config validation for CustomDateRanges

diff --git a/lib/RT/Config.pm b/lib/RT/Config.pm
index 431a12ca2..3d89c29a3 100644
--- a/lib/RT/Config.pm
+++ b/lib/RT/Config.pm
@@ -1051,6 +1051,37 @@ our %META;
             $config->Set( CustomFieldGroupings => %$groups );
         },
     },
+    CustomDateRanges => {
+        Type            => 'HASH',
+        PostLoadCheck   => sub {
+            my $config = shift;
+            # use scalar context intentionally to avoid not a hash error
+            my $ranges = $config->Get('CustomDateRanges') || {};
+
+            unless (ref($ranges) eq 'HASH') {
+                RT->Logger->error("Config option \%CustomDateRanges is a @{[ref $ranges]} not a HASH");
+                return;
+            }
+
+            for my $class (keys %$ranges) {
+                if (ref($ranges->{$class}) eq 'HASH') {
+                    for my $name (keys %{ $ranges->{$class} }) {
+                        my $spec = $ranges->{$class}{$name};
+                        if (!ref($spec) || ref($spec) eq 'HASH') {
+                            # this will produce error messages if parsing fails
+                            $class->require;
+                            $class->_ParseCustomDateRangeSpec($name, $spec);
+                        }
+                        else {
+                            RT->Logger->error("Config option \%CustomDateRanges{$class}{$name} is not a string or HASH");
+                        }
+                    }
+                } else {
+                    RT->Logger->error("Config option \%CustomDateRanges{$class} is not a HASH");
+                }
+            }
+        },
+    },
     ExternalStorage => {
         Type            => 'HASH',
         PostLoadCheck   => sub {

commit 74ab48f4e49857f418e54272081908507a051b70
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Jun 29 00:09:26 2016 +0000

    RT_Config doc for CustomDateRanges

diff --git a/etc/RT_Config.pm.in b/etc/RT_Config.pm.in
index 2559c662b..09e3f6874 100644
--- a/etc/RT_Config.pm.in
+++ b/etc/RT_Config.pm.in
@@ -1013,6 +1013,81 @@ For C<RT::Asset>: C<Basics>, C<Dates>, C<People>, C<Links>
 Extensions may also add their own built-in groupings, refer to the individual
 extension documentation for those.
 
+=item C<%CustomDateRanges>
+
+This option lets you declare additional date ranges to be calculated
+and displayed in search results. Durations between any two core fields,
+as well as custom fields, are supported. Each custom date range is
+added as an additional display column in the search builder.
+
+Set C<%CustomDateRanges> to a nested structure similar to the following:
+
+    Set(%CustomDateRanges,
+        'RT::Ticket' => {
+            'Resolution Time' => 'Resolved - Created',
+
+            'Downtime' => 'CF.Recovered - CF.{First Alert}',
+
+            'Time To Beta' => {
+                value => 'CF.Beta - now',
+
+                format => sub {
+                    my ($duration, $beta, $now, $ticket) = @_;
+                    my $days = int($duration / (24*60*60));
+                    if ($days < 0) {
+                        $ticket->loc('[quant,_1,day,days] ago', -$days);
+                    }
+                    else {
+                        $ticket->loc('in [quant,_1,day,days]', $days);
+                    }
+                },
+            },
+        },
+    );
+
+The first level keys are record types. Each record type's value must be a
+hash reference. Each pair in the second-level hash defines a new range. The
+key is the range's name (which is displayed to users in the UI), and its
+value describes the range and must be either a string or a hashref.
+
+Values that are plain strings simply describe the calculation to be made.
+
+Values that are hashrefs must include the key C<value> which must a string
+that describes the calculation to be made. This hashref may also include the
+key C<format>, which is a code reference that allows customization of how
+the duration is displayed to users. This code reference receives four
+parameters: the duration (a number of seconds), the end time (an L<RT::Date>
+object), the start time (another L<RT::Date>), and the record itself (which
+corresponds to the first-level key; in the example config above, it would be
+the L<RT::Ticket> object). The code reference should return the string to
+be displayed to the user.
+
+The calculation is expected to be of the format C<"field - field"> where each
+field may be:
+
+=over 4
+
+=item * a core field
+
+For example, L<RT::Ticket> supports: created, starts, started, last updated,
+told or last contact, due, resolved.
+
+=item * a custom field
+
+You may use either C<CF.Name> or C<CF.{Longer Name}> syntax.
+
+=item * the word C<now>
+
+=back
+
+Custom date range calculations are defined using typical math operators with
+a space before and after. Subtraction (-) is currently supported.
+
+If either field is unset, nothing will be displayed for that record (and the
+C<format> code reference will not be called). If you need additional control
+over how results are calculated, see
+L<docs/customizing/search_result_columns.pod>.
+
 =item C<$CanonicalizeRedirectURLs>
 
 Set C<$CanonicalizeRedirectURLs> to 1 to use C<$WebURL> when

commit d5c930eaf30a3b773d8987d7ee8365d75bae520f
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Jun 29 12:31:43 2016 -0400

    Switch customization doc from date range to message count
    
        Date ranges are now more easily accomplished with the new
        %CustomDateRanges config.

diff --git a/docs/customizing/search_result_columns.pod b/docs/customizing/search_result_columns.pod
index 7eef416a7..045e18062 100644
--- a/docs/customizing/search_result_columns.pod
+++ b/docs/customizing/search_result_columns.pod
@@ -12,10 +12,12 @@ them you can add and remove data elements to sort by, change the sort order,
 and add and remove which columns you want to see.
 
 Although the Add Columns box has an extensive list of available columns, there
-are times when you need a value not listed. Sometimes what you want is a
-value calculated based on existing ticket values, like finding the difference
-between two date fields. RT provides a way to add this sort of customization
-using something called a Column Map.
+are times when you need a value not listed. If you want to display a custom
+date range, you can configure the L<RT_Config/%CustomDateRanges> setting.
+
+Sometimes what you want is a novel value calculated based on other ticket
+information, like the number of messages on a ticket. RT provides a way
+to add this sort of customization using something called a Column Map.
 
 =head2 Level of Difficulty
 
@@ -43,11 +45,8 @@ making upgrades much easier. As an example, we'll add a Column Map to the
 ticket display and explain the necessary callbacks. You can read more about
 callbacks in general in the L<writing_extensions/Callbacks> documentation.
 
-For our example, let's assume we want to display a response time column that
-shows the difference between when a ticket is created and when someone
-starts working on it (started date). The two initial values are already
-available on the ticket, but it would be convenient to display the
-calculated value in our search.
+For our example, let's assume we want to display the number of messages
+(comments, correspondences) on a ticket.
 
 =head2 Column Map Callback
 
@@ -76,12 +75,15 @@ where F<Once> is the name of the file where we'll put our code.
 In the F<Once> file, we'll put the following code:
 
     <%init>
-    $COLUMN_MAP->{'TimeToFirstResponse'} = {
-            title     => 'First Response', # loc
-            attribute => 'First Response',
+    $COLUMN_MAP->{'NumberOfMessages'} = {
+            title     => 'Messages',
+            attribute => 'Messages',
             value     => sub {
                 my $ticket = shift;
-                return $ticket->StartedObj->DiffAsString($ticket->CreatedObj);
+                my $txns = $ticket->Transactions;
+                $txns->Limit( FIELD => 'Type', VALUE => 'Comment' );
+                $txns->Limit( FIELD => 'Type', VALUE => 'Correspond' );
+                return $txns->Count;
             }
     };
     </%init>
@@ -108,8 +110,6 @@ The parameters in the hashref are as follows:
 =item title
 
 The title is what will be used in the header row to identify this value.
-The C<# loc> is some special markup that allows RT to replace the value
-with translations in other languages, if they are available.
 
 =item attribute
 
@@ -139,9 +139,9 @@ each row in the search results, the ticket object for that ticket is made
 available as the first parameter to our subroutine.
 
 This allows us to then call methods on the L<RT::Ticket> object to access
-and process the value. In our case, we can get the L<RT::Date> objects for
-the two dates and use the L<RT::Date/DiffAsString> method to calculate and
-return the difference.
+and process the value. In our case, we can get the L<RT::Transactions>
+collection, limit it to the types we're interested in, and then use the
+C<Count> method to return the number of messages.
 
 When writing code to calculate values, remember that it will be run for each
 row in search results. You should avoid doing things that are too time
@@ -164,7 +164,7 @@ Create the file:
 And put the following code in the F<Default> file:
 
     <%INIT>
-    push @{$Fields}, 'TimeToFirstResponse';
+    push @{$Fields}, 'NumberOfMessages';
     </%INIT>
     <%ARGS>
     $Fields => undef

commit fdc9b920aae30077d567ae44a26ccc2cdab184d1
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Jun 29 19:03:41 2016 +0000

    Add tests for CustomDateRanges

diff --git a/t/api/custom-date-ranges.t b/t/api/custom-date-ranges.t
new file mode 100644
index 000000000..9c633db05
--- /dev/null
+++ b/t/api/custom-date-ranges.t
@@ -0,0 +1,56 @@
+use warnings;
+use strict;
+use Test::MockTime qw( :all );
+use RT::Test;
+
+set_fixed_time('2016-01-01T00:00:00Z');
+
+my $cf = RT::Test->load_or_create_custom_field(
+    Name => 'Beta Date',
+    Type => 'DateTime',
+    MaxValues => 1,
+    LookupType => RT::Ticket->CustomFieldLookupType,
+    Queue => 'General',
+);
+ok($cf && $cf->Id, 'created Beta Date CF');
+
+my $t = RT::Test->create_ticket(
+    Queue       => 'General',
+    Status      => 'resolved',
+    Created     => '2015-12-10 00:00:00',
+    Starts      => '2015-12-13 00:00:00',
+    Started     => '2015-12-12 12:00:00',
+    Due         => '2015-12-20 00:00:00',
+    Resolved    => '2015-12-15 18:00:00',
+);
+
+# see t/customfields/datetime.t for timezone issues
+$t->AddCustomFieldValue(Field => 'Beta Date', Value => '2015-12-13 19:00:00');
+is($t->FirstCustomFieldValue('Beta Date'), '2015-12-14 00:00:00');
+
+my @tests = (
+    'Starts - Created' => '3 days',
+    'Created   -     Starts' => '3 days prior',
+    'Started - Created' => '3 days', # uses only the most significant unit
+    'Resolved - Due' => '4 days prior',
+    'Due - Resolved' => '4 days',
+    'Due - Told' => undef, # told is unset
+    'now - LastContact' => undef, # told is unset
+    'now - LastUpdated' => '0 seconds',
+    'Due - CF.{Beta Date}' => '6 days',
+    'now - CF.{Beta Date}' => '3 weeks',
+    'CF.{Beta Date} - now' => '3 weeks prior',
+);
+
+while (my ($spec, $expected) = splice @tests, 0, 2) {
+    is($t->CustomDateRange(test => $spec), $expected, $spec);
+}
+
+is($t->CustomDateRange(test => {
+    value => 'Resolved - Created',
+    format => sub {
+        my ($seconds, $end, $start, $ticket) = @_;
+        join '/', $seconds, $end->Unix, $start->Unix, $ticket->Id;
+    },
+}), '496800/1450202400/1449705600/1', 'format');
+

commit 6a4d91832913ca5dc5537c84bed84e8d81b80819
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Wed Jun 5 05:39:03 2019 +0800

    Add business time support for custom date range

diff --git a/etc/RT_Config.pm.in b/etc/RT_Config.pm.in
index 09e3f6874..2a8d0288f 100644
--- a/etc/RT_Config.pm.in
+++ b/etc/RT_Config.pm.in
@@ -1026,7 +1026,10 @@ Set C<%CustomDateRanges> to a nested structure similar to the following:
         'RT::Ticket' => {
             'Resolution Time' => 'Resolved - Created',
 
-            'Downtime' => 'CF.Recovered - CF.{First Alert}',
+            'Downtime' => {
+                value => 'CF.Recovered - CF.{First Alert}',
+                business_time => 1,
+            },
 
             'Time To Beta' => {
                 value => 'CF.Beta - now',
diff --git a/lib/RT/Record.pm b/lib/RT/Record.pm
index 46d1b41ec..dc511987e 100644
--- a/lib/RT/Record.pm
+++ b/lib/RT/Record.pm
@@ -2472,10 +2472,11 @@ sub _DateForCustomDateRangeField {
     return $date;
 }
 
-# Parses "field - field" and returns a four-element list containing the end
+# Parses "field - field" and returns a five-element list containing the end
 # date field name, the operator (right now always "-" for subtraction), the
-# start date field name, and either a custom duration formatter coderef or
-# undef. Returns the empty list if there's an error.
+# start date field name, either a custom duration formatter coderef or undef,
+# and a boolean to indicate if it should calculate duration as business time
+# or not. Returns the empty list if there's an error.
 
 sub _ParseCustomDateRangeSpec {
     my $self = shift;
@@ -2484,10 +2485,12 @@ sub _ParseCustomDateRangeSpec {
 
     my $calculation;
     my $format;
+    my $business_time;
 
     if (ref($spec)) {
         $calculation = $spec->{value};
         $format = $spec->{format};
+        $business_time = $spec->{business_time};
     }
     else {
         $calculation = $spec;
@@ -2529,7 +2532,7 @@ sub _ParseCustomDateRangeSpec {
 
     my ($end, $op, $start) = @matches;
 
-    return ($end, $op, $start, $format);
+    return ($end, $op, $start, $format, $business_time);
 }
 
 =head2 CustomDateRange name, spec
@@ -2545,7 +2548,7 @@ sub CustomDateRange {
     my $name = shift;
     my $spec = shift;
 
-    my ($end, $op, $start, $format) = $self->_ParseCustomDateRangeSpec($name, $spec);
+    my ($end, $op, $start, $format, $business_time) = $self->_ParseCustomDateRangeSpec($name, $spec);
 
     # parse failed; render no value
     return unless $start && $end;
@@ -2559,7 +2562,37 @@ sub CustomDateRange {
 
     my $duration;
     if ($op eq '-') {
-        $duration = $end_dt->Diff($start_dt);
+        if ( $business_time && !$self->QueueObj->SLADisabled && $self->SLA ) {
+            my $config    = RT->Config->Get('ServiceAgreements');
+            my $agreement = $config->{Levels}{ $self->SLA };
+            my $timezone
+                = $config->{QueueDefault}{ $self->QueueObj->Name }{Timezone}
+                || $agreement->{Timezone}
+                || RT->Config->Get('Timezone');
+
+            {
+                local $ENV{'TZ'} = $ENV{'TZ'};
+                if ( $timezone ne ( $ENV{'TZ'} || '' ) ) {
+                    $ENV{'TZ'} = $timezone;
+                    require POSIX;
+                    POSIX::tzset();
+                }
+
+                my $bhours = RT::SLA->BusinessHours( $agreement->{BusinessHours} || 'Default' );
+                $duration = $bhours->between(
+                    $start_dt->Unix <= $end_dt->Unix
+                    ? ( $start_dt->Unix, $end_dt->Unix )
+                    : ( $end_dt->Unix, $start_dt->Unix )
+                );
+                $duration *= -1 if $start_dt->Unix > $end_dt->Unix;
+            }
+
+            if ( $timezone ne ( $ENV{'TZ'} || '' ) ) {
+                POSIX::tzset();
+            }
+        }
+
+        $duration //= $end_dt->Diff($start_dt);
     }
     else {
         RT->Logger->error("Unexpected operator in CustomDateRanges '$name' spec '$spec'. Got '$op', expected '-'.");

commit 990fe87b261bd17a264c84500ac8024c8b4aa382
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Wed Jun 5 05:39:19 2019 +0800

    Test business time for custom date range

diff --git a/t/api/custom-date-ranges.t b/t/api/custom-date-ranges.t
index 9c633db05..9cbcad66e 100644
--- a/t/api/custom-date-ranges.t
+++ b/t/api/custom-date-ranges.t
@@ -54,3 +54,37 @@ is($t->CustomDateRange(test => {
     },
 }), '496800/1450202400/1449705600/1', 'format');
 
+diag 'test business time' if $ENV{'TEST_VERBOSE'};
+{
+    RT->Config->Set(
+        ServiceAgreements => (
+            Default => '2h',
+            Levels  => { '2h' => { Response => 2 * 60, Timezone => 'UTC' }, },
+        )
+    );
+    RT->Config->Set(
+        ServiceBusinessHours => (
+            'Default' => {
+                1 => { Name => 'Monday',    Start => '9:00', End => '18:00' },
+                2 => { Name => 'Tuesday',   Start => '9:00', End => '18:00' },
+                3 => { Name => 'Wednesday', Start => '9:00', End => '18:00' },
+                4 => { Name => 'Thursday',  Start => '9:00', End => '18:00' },
+                5 => { Name => 'Friday',    Start => '9:00', End => '18:00' },
+            },
+        )
+    );
+
+    ok( $t->QueueObj->SetSLADisabled(0), 'Enabled SLA' );
+    ok( $t->SetSLA('2h'), 'Set sla to 2h' );
+
+    # from 2015-12-10 00:00:00 to 2015-12-15 18:00:00, there are 4 work days
+    is( $t->CustomDateRange(
+            test => {
+                value         => 'Resolved - Created',
+                business_time => 1,
+            }
+        ),
+        '36 hours',
+        'Business time of Resolved - Created'
+      );
+}

-----------------------------------------------------------------------


More information about the rt-commit mailing list