rt 4.2.15
[freeside.git] / rt / lib / RT / Graph / Tickets.pm
1 # BEGIN BPS TAGGED BLOCK {{{
2 #
3 # COPYRIGHT:
4 #
5 # This software is Copyright (c) 1996-2018 Best Practical Solutions, LLC
6 #                                          <sales@bestpractical.com>
7 #
8 # (Except where explicitly superseded by other copyright notices)
9 #
10 #
11 # LICENSE:
12 #
13 # This work is made available to you under the terms of Version 2 of
14 # the GNU General Public License. A copy of that license should have
15 # been provided with this software, but in any event can be snarfed
16 # from www.gnu.org.
17 #
18 # This work is distributed in the hope that it will be useful, but
19 # WITHOUT ANY WARRANTY; without even the implied warranty of
20 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
21 # General Public License for more details.
22 #
23 # You should have received a copy of the GNU General Public License
24 # along with this program; if not, write to the Free Software
25 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
26 # 02110-1301 or visit their web page on the internet at
27 # http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
28 #
29 #
30 # CONTRIBUTION SUBMISSION POLICY:
31 #
32 # (The following paragraph is not intended to limit the rights granted
33 # to you to modify and distribute this software under the terms of
34 # the GNU General Public License and is only of importance to you if
35 # you choose to contribute your changes and enhancements to the
36 # community by submitting them to Best Practical Solutions, LLC.)
37 #
38 # By intentionally submitting any modifications, corrections or
39 # derivatives to this work, or any other work intended for use with
40 # Request Tracker, to Best Practical Solutions, LLC, you confirm that
41 # you are the copyright holder for those contributions and you grant
42 # Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
43 # royalty-free, perpetual, license to use, copy, create derivative
44 # works based on those contributions, and sublicense and distribute
45 # those contributions and any derivatives thereof.
46 #
47 # END BPS TAGGED BLOCK }}}
48
49 package RT::Graph::Tickets;
50
51 use strict;
52 use warnings;
53
54 =head1 NAME
55
56 RT::Graph::Tickets - view relations between tickets as graphs
57
58 =cut
59
60 unless ($RT::DisableGraphViz) {
61     require GraphViz;
62     GraphViz->import;
63 }
64
65 our %ticket_status_style = (
66     new      => { fontcolor => '#FF0000', fontsize => 10 },
67     open     => { fontcolor => '#000000', fontsize => 10 },
68     stalled  => { fontcolor => '#DAA520', fontsize => 10 },
69     resolved => { fontcolor => '#00FF00', fontsize => 10 },
70     rejected => { fontcolor => '#808080', fontsize => 10 },
71     deleted  => { fontcolor => '#A9A9A9', fontsize => 10 },
72 );
73
74 our %link_style = (
75     MemberOf  => { style => 'solid' },
76     DependsOn => { style => 'dashed' },
77     RefersTo  => { style => 'dotted' },
78 );
79
80 # We don't use qw() because perl complains about "possible attempt to put comments in qw() list"
81 our @fill_colors = split ' ',<<EOT;
82     #0000FF #8A2BE2 #A52A2A #DEB887 #5F9EA0 #7FFF00 #D2691E #FF7F50
83     #6495ED #FFF8DC #DC143C #00FFFF #00008B #008B8B #B8860B #A9A9A9
84     #A9A9A9 #006400 #BDB76B #8B008B #556B2F #FF8C00 #9932CC #8B0000
85     #E9967A #8FBC8F #483D8B #2F4F4F #2F4F4F #00CED1 #9400D3 #FF1493
86     #00BFFF #696969 #696969 #1E90FF #B22222 #FFFAF0 #228B22 #FF00FF
87     #DCDCDC #F8F8FF #FFD700 #DAA520 #808080 #808080 #008000 #ADFF2F
88     #F0FFF0 #FF69B4 #CD5C5C #4B0082 #FFFFF0 #F0E68C #E6E6FA #FFF0F5
89     #7CFC00 #FFFACD #ADD8E6 #F08080 #E0FFFF #FAFAD2 #D3D3D3 #D3D3D3
90     #90EE90 #FFB6C1 #FFA07A #20B2AA #87CEFA #778899 #778899 #B0C4DE
91     #FFFFE0 #00FF00 #32CD32 #FAF0E6 #FF00FF #800000 #66CDAA #0000CD
92     #BA55D3 #9370D8 #3CB371 #7B68EE #00FA9A #48D1CC #C71585 #191970
93     #F5FFFA #FFE4E1 #FFE4B5 #FFDEAD #000080 #FDF5E6 #808000 #6B8E23
94     #FFA500 #FF4500 #DA70D6 #EEE8AA #98FB98 #AFEEEE #D87093 #FFEFD5
95     #FFDAB9 #CD853F #FFC0CB #DDA0DD #B0E0E6 #800080 #FF0000 #BC8F8F
96     #4169E1 #8B4513 #FA8072 #F4A460 #2E8B57 #FFF5EE #A0522D #C0C0C0
97     #87CEEB #6A5ACD #708090 #708090 #FFFAFA #00FF7F #4682B4 #D2B48C
98     #008080 #D8BFD8 #FF6347 #40E0D0 #EE82EE #F5DEB3 #FFFF00 #9ACD32
99 EOT
100
101 sub gv_escape($) {
102     my $value = shift;
103     $value =~ s{(?=["\\])}{\\}g;
104     return $value;
105 }
106
107 sub loc { return HTML::Mason::Commands::loc(@_) };
108
109 our (%fill_cache, @available_colors) = ();
110
111 our %property_cb = (
112     Queue  => sub { return $_[0]->QueueObj->Name || $_[0]->Queue },
113     Status => sub { return loc($_[0]->Status) },
114     CF     => sub {
115         my $values = $_[0]->CustomFieldValues( $_[1] );
116         return join ', ', map $_->Content, @{ $values->ItemsArrayRef };
117     },
118 );
119 foreach my $field (qw(Subject TimeLeft TimeWorked TimeEstimated)) {
120     $property_cb{ $field } = sub { return $_[0]->$field },
121 }
122 foreach my $field (qw(Creator LastUpdatedBy Owner)) {
123     $property_cb{ $field } = sub {
124         my $method = $field .'Obj';
125         return $_[0]->$method->Name;
126     };
127 }
128 foreach my $field (qw(Requestor Cc AdminCc)) {
129     $property_cb{ $field."s" } = sub {
130         my $method = $field .'Addresses';
131         return $_[0]->$method;
132     };
133 }
134 foreach my $field (qw(Told Starts Started Due Resolved LastUpdated Created)) {
135     $property_cb{ $field } = sub {
136         my $method = $field .'Obj';
137         return $_[0]->$method->AsString;
138     };
139 }
140 foreach my $field (qw(Members DependedOnBy ReferredToBy)) {
141     $property_cb{ $field } = sub {
142         return join ', ', map $_->BaseObj->id, @{ $_[0]->$field->ItemsArrayRef };
143     };
144 }
145 foreach my $field (qw(MemberOf DependsOn RefersTo)) {
146     $property_cb{ $field } = sub {
147         return join ', ', map $_->TargetObj->id, @{ $_[0]->$field->ItemsArrayRef };
148     };
149 }
150
151
152 sub TicketProperties {
153     my $self = shift;
154     my $user = shift;
155     my @res = (
156         Basics => [qw(Subject Status Queue TimeLeft TimeWorked TimeEstimated)], # loc_qw
157         People => [qw(Owner Requestors Ccs AdminCcs Creator LastUpdatedBy)], # loc_qw
158         Dates  => [qw(Created Starts Started Due Resolved Told LastUpdated)], # loc_qw
159         Links  => [qw(MemberOf Members DependsOn DependedOnBy RefersTo ReferredToBy)], # loc_qw
160     );
161     my $cfs = RT::CustomFields->new( $user );
162     $cfs->LimitToLookupType('RT::Queue-RT::Ticket');
163     $cfs->OrderBy( FIELD => 'Name' );
164     my ($first, %seen) = (1);
165     while ( my $cf = $cfs->Next ) {
166         next if $seen{ lc $cf->Name }++;
167         next if $cf->Type eq 'Image';
168         if ( $first ) {
169             push @res, 'Custom Fields', # loc
170                 [];
171             $first = 0;
172         }
173         push @{ $res[-1] }, 'CF.{'. $cf->Name .'}';
174     }
175     return @res;
176 }
177
178 sub _SplitProperty {
179     my $self = shift;
180     my $property = shift;
181     my ($key, @subkeys) = split /\./, $property;
182     foreach ( grep /^{.*}$/, @subkeys ) {
183         s/^{//;
184         s/}$//;
185     }
186     return $key, @subkeys;
187 }
188
189 sub _PropertiesToFields {
190     my $self = shift;
191     my %args = (
192         Ticket       => undef,
193         Graph        => undef,
194         CurrentDepth => 1,
195         @_
196     );
197
198     my @properties;
199     if ( my $tmp = $args{ 'Level-'. $args{'CurrentDepth'} .'-Properties' } ) {
200         @properties = ref $tmp? @$tmp : ($tmp);
201     }
202
203     my @fields;
204     foreach my $property( @properties ) {
205         my ($key, @subkeys) = $self->_SplitProperty( $property );
206         unless ( $property_cb{ $key } ) {
207             $RT::Logger->error("Couldn't find property handler for '$key' and '@subkeys' subkeys");
208             next;
209         }
210         my $label = $key eq 'CF' ? $subkeys[0] : loc($key);
211         push @fields, $label .': '. $property_cb{ $key }->( $args{'Ticket'}, @subkeys );
212     }
213
214     return @fields;
215 }
216
217 sub AddTicket {
218     my $self = shift;
219     my %args = (
220         Ticket       => undef,
221         Properties   => [],
222         Graph        => undef,
223         CurrentDepth => 1,
224         @_
225     );
226
227     my %node_style = (
228         style => 'filled,rounded',
229         %{ $ticket_status_style{ $args{'Ticket'}->Status } || {} },
230         URL   => $RT::WebPath .'/Ticket/Display.html?id='. $args{'Ticket'}->id,
231         tooltip => gv_escape( $args{'Ticket'}->Subject || '#'. $args{'Ticket'}->id ),
232     );
233
234     my @fields = $self->_PropertiesToFields( %args );
235     if ( @fields ) {
236         unshift @fields, $args{'Ticket'}->id;
237         my $label = join ' | ', map { s/(?=[{}|><])/\\/g; $_ } @fields;
238         $label = "{ $label }" if ($args{'Direction'} || 'TB') =~ /^(?:TB|BT)$/;
239         $node_style{'label'} = gv_escape( $label );
240         $node_style{'shape'} = 'record';
241     }
242     
243     if ( $args{'FillUsing'} ) {
244         my ($key, @subkeys) = $self->_SplitProperty( $args{'FillUsing'} );
245         my $value;
246         if ( $property_cb{ $key } ) {
247             $value = $property_cb{ $key }->( $args{'Ticket'}, @subkeys );
248         } else {
249             $RT::Logger->error("Couldn't find property callback for '$key'");
250         }
251         if ( defined $value && length $value && $value =~ /\S/ ) {
252             my $fill = $fill_cache{ $value };
253             $fill = $fill_cache{ $value } = shift @available_colors
254                 unless $fill;
255             if ( $fill ) {
256                 $node_style{'fillcolor'} = $fill;
257                 $node_style{'style'} ||= '';
258                 $node_style{'style'} = join ',', split( ',', $node_style{'style'} ), 'filled'
259                     unless $node_style{'style'} =~ /\bfilled\b/;
260             }
261         }
262     }
263
264     $args{'Graph'}->add_node( $args{'Ticket'}->id, %node_style );
265 }
266
267 sub TicketLinks {
268     my $self = shift;
269     my %args = (
270         Ticket               => undef,
271
272         Graph                => undef,
273         Direction            => 'TB',
274         Seen                 => undef,
275         SeenEdge             => undef,
276
277         LeadingLink          => 'Members',
278         ShowLinks            => [],
279
280         MaxDepth             => 0,
281         CurrentDepth         => 1,
282
283         ShowLinkDescriptions => 0,
284         @_
285     );
286
287     my %valid_links = map { $_ => 1 }
288         qw(Members MemberOf RefersTo ReferredToBy DependsOn DependedOnBy);
289
290     # Validate our link types
291     $args{ShowLinks}   = [ grep { $valid_links{$_} } @{$args{ShowLinks}} ];
292     $args{LeadingLink} = 'Members' unless $valid_links{ $args{LeadingLink} };
293
294     unless ( $args{'Graph'} ) {
295         $args{'Graph'} = GraphViz->new(
296             name    => 'ticket_links_'. $args{'Ticket'}->id,
297             bgcolor => "transparent",
298 # TODO: patch GraphViz to support all posible RDs
299             rankdir => ($args{'Direction'} || "TB") eq "LR",
300             node => { shape => 'box', style => 'filled,rounded', fillcolor => 'white' },
301         );
302         %fill_cache = ();
303         @available_colors = @fill_colors;
304     }
305
306     $args{'Seen'} ||= {};
307     if ( $args{'Seen'}{ $args{'Ticket'}->id } && $args{'Seen'}{ $args{'Ticket'}->id } <= $args{'CurrentDepth'} ) {
308       return $args{'Graph'};
309     } elsif ( ! defined $args{'Seen'}{ $args{'Ticket'}->id } ) {
310       $self->AddTicket( %args );
311     }
312     $args{'Seen'}{ $args{'Ticket'}->id } = $args{'CurrentDepth'};
313
314     return $args{'Graph'} if $args{'MaxDepth'} && $args{'CurrentDepth'} >= $args{'MaxDepth'};
315
316     $args{'SeenEdge'} ||= {};
317
318     my $show_link_descriptions = $args{'ShowLinkDescriptions'}
319         && RT::Link->can('Description');
320
321     foreach my $type ( $args{'LeadingLink'}, @{ $args{'ShowLinks'} } ) {
322         my $links = $args{'Ticket'}->$type();
323         $links->GotoFirstItem;
324         while ( my $link = $links->Next ) {
325             next if $args{'SeenEdge'}{ $link->id }++;
326
327             my $target = $link->TargetObj;
328             next unless $target && $target->isa('RT::Ticket');
329
330             my $base = $link->BaseObj;
331             next unless $base && $base->isa('RT::Ticket');
332
333             my $next = $target->id == $args{'Ticket'}->id? $base : $target;
334
335             $self->TicketLinks(
336                 %args,
337                 Ticket => $next,
338                 $type eq $args{'LeadingLink'}
339                     ? ( CurrentDepth => $args{'CurrentDepth'} + 1 )
340                     : ( MaxDepth => $args{'CurrentDepth'} + 1,
341                         CurrentDepth => $args{'CurrentDepth'} + 1 ),
342             );
343
344             my $desc;
345             $desc = $link->Description if $show_link_descriptions;
346             $args{'Graph'}->add_edge(
347                 # we revers order of member links to get better layout
348                 $link->Type eq 'MemberOf'
349                     ? ($target->id => $base->id, dir => 'back')
350                     : ($base->id => $target->id),
351                 %{ $link_style{ $link->Type } || {} },
352                 $desc? (label => gv_escape $desc): (),
353             );
354         }
355     }
356
357     return $args{'Graph'};
358 }
359
360 RT::Base->_ImportOverlays();
361
362 1;