[prev in list] [next in list] [prev in thread] [next in thread] 

List:       rt-commit
Subject:    [Rt-commit] rt branch 5.0/create-chart-images-for-dashboard-emails-2 created.
From:       BPS Git Server via rt-commit <rt-commit () lists ! bestpractical ! com>
Date:       2023-12-18 21:39:49
Message-ID: 20231218213949.5187A4063F () bps-git ! bestpractical ! com
[Download RAW message or body]

This is an automated email from the git hooks/post-receive script. It was
generated because a ref change was pushed to the repository containing
the project "rt".

The branch, 5.0/create-chart-images-for-dashboard-emails-2 has been created
        at  21edf1f55f80d0f0624ef71da359331a8825d8db (commit)

- Log -----------------------------------------------------------------
commit 21edf1f55f80d0f0624ef71da359331a8825d8db
Author: sunnavy <sunnavy@bestpractical.com>
Date:   Mon Dec 18 12:13:20 2023 -0500

    Test JSChart images in dashboard emails

diff --git a/t/mail/dashboard-jschart.t b/t/mail/dashboard-jschart.t
new file mode 100644
index 0000000000..1a39078a3c
--- /dev/null
+++ b/t/mail/dashboard-jschart.t
@@ -0,0 +1,107 @@
+use strict;
+use warnings;
+
+use RT::Test
+    tests  => undef,
+    config => qq{Set(\$EmailDashboardJSChartImages, 1);
+Set(\@ChromeLaunchArguments, '--no-sandbox');
+Set(\$ChromePath, '@{[$ENV{RT_TEST_CHROME_PATH} // '']}');
+};
+
+plan skip_all => 'Need WWW::Mechanize::Chrome and a chrome-based browser'
+    unless RT::StaticUtil::RequireModule("WWW::Mechanize::Chrome")
+    && ( $ENV{RT_TEST_CHROME_PATH} || \
WWW::Mechanize::Chrome->find_executable('chromium') ); +
+my $root = RT::Test->load_or_create_user( Name => 'root' );
+
+my ( $baseurl, $m ) = RT::Test->started_ok;
+ok( $m->login, 'logged in' );
+my $ticket = RT::Test->create_ticket(
+    Queue   => 'General',
+    Subject => 'Test JSChart',
+);
+
+$m->get_ok(q{/Search/Chart.html?Query=Subject LIKE 'test JSChart'});
+$m->submit_form(
+    form_name => 'SaveSearch',
+    fields    => {
+        SavedSearchDescription => 'chart foo',
+        SavedSearchOwner       => 'RT::User-' . $root->id,
+        ChartStyle             => 'bar',
+    },
+    button => 'SavedSearchSave',
+);
+
+# first, create and populate a dashboard
+$m->get_ok('/Dashboards/Modify.html?Create=1');
+$m->form_name('ModifyDashboard');
+$m->field( 'Name' => 'dashboard foo' );
+$m->click_button( value => 'Create' );
+
+my ($dashboard_id) = ( $m->uri =~ /id=(\d+)/ );
+ok( $dashboard_id, "Got an ID for the dashboard, $dashboard_id" );
+
+$m->follow_link_ok( { text => 'Content' } );
+
+# add content, Chart: chart foo, to dashboard body
+# we need to get the saved search id from the content before submitting the form.
+my $regex = qr/data-type="(\w+)" data-name="RT::User-/ . $root->id . \
qr/-SavedSearch-(\d+)"/; +my ( $saved_search_type, $saved_search_id ) = $m->content \
=~ /$regex/; +ok( $saved_search_type, "Got a type for the saved search, \
$saved_search_type" ); +ok( $saved_search_id,   "Got an ID for the saved search, \
$saved_search_id" ); +
+$m->submit_form_ok(
+    {
+        form_name => 'UpdateSearches',
+        fields    => {
+            dashboard_id => $dashboard_id,
+            body         => $saved_search_type . "-" . "RT::User-" . $root->id . \
"-SavedSearch-" . $saved_search_id, +        },
+        button => 'UpdateSearches',
+    },
+    "Add content 'Chart: chart foo' to dashboard body"
+);
+
+like( $m->uri, qr/results=[A-Za-z0-9]{32}/, 'URL redirected for results' );
+$m->content_contains('Dashboard updated');
+
+$m->follow_link_ok( { text => 'Subscription' } );
+$m->form_name('SubscribeDashboard');
+$m->field( 'Frequency' => 'daily' );
+$m->field( 'Hour'      => '06:00' );
+$m->click_button( name => 'Save' );
+$m->content_contains('Subscribed to dashboard dashboard foo');
+
+RT::Test->run_and_capture(
+    command => $RT::SbinPath . '/rt-email-dashboards',
+    all     => 1,
+);
+
+my @mails = RT::Test->fetch_caught_mails;
+is @mails, 1, "Got a dashboard mail";
+
+
+# can't use parse_mail here is because it deletes all attachments
+# before we can call bodyhandle :/
+use RT::EmailParser;
+my $parser = RT::EmailParser->new;
+my $mail   = $parser->ParseMIMEEntityFromScalar( $mails[0] );
+like( $mail->head->get('Subject'), qr/Daily Dashboard: dashboard foo/, 'Mail \
subject' ); +
+my ($mail_image) = grep { $_->mime_type eq 'image/png' } $mail->parts;
+ok( $mail_image, 'Mail contains image attachment' );
+require Imager;                                    # Imager is a dependency of \
WWW::Mechanize::Chrome +my $imager = Imager->new();
+$imager->open( data => $mail_image->bodyhandle->as_string, type => 'png' );
+is( $imager->bits, 8, 'Image bit depth is 8' );    # Images created by GD::Graph \
have 4-bit color depth +
+
+# The first bar's color is #a6cee3, which is (166, 206, 227),
+# on Apple Display preset, it's converted to (174, 205, 225).
+my $bar_color  = $imager->getpixel( x => 300, y => 200 );
+my $srgb_color = Imager::Color->new( 166, 206, 227 );
+my $p3_color   = Imager::Color->new( 174, 205, 225 );
+ok( $bar_color->equals( other => $srgb_color ) || $bar_color->equals( other => \
$p3_color ), +    'Image bar color is #a6cee3' );
+
+done_testing;

commit f8801fb2de1cd844db04c2f55b9682fcf7ce89c8
Author: Brian Conry <bconry@bestpractical.com>
Date:   Thu Oct 6 13:01:35 2022 -0500

    Email JSChart images with WWW::Mechanize::Chrome
    
    This change allows the dashboard emails to contain image versions of
    JSChart graphs obtained using WWW::Mechanize::Chrome.
    
    Previously it was only possible to generate graph images for emails
    using the GD module.
    
    This Feature has been tested with Chrome, Chromium, Microsoft Edge, and
    Opera.

diff --git a/docs/UPGRADING-5.0 b/docs/UPGRADING-5.0
index d12cb26e19..5686369fd9 100644
--- a/docs/UPGRADING-5.0
+++ b/docs/UPGRADING-5.0
@@ -634,4 +634,27 @@ messages, you may need to update your system to match the new \
format.  
 =back
 
+=head1 UPGRADING FROM 5.0.5 AND EARLIER
+
+=over 4
+
+=item * Additional options for charts in dashboard emails
+
+While it has been possible to use JSChart to generate chart images in the RT UI,
+because these images are generated client-side it hasn't been possible to include
+them in dashboard emails, so the GD-generated images have been the only option.
+
+It is now possible to use the optional Perl module L<WWW::Mechanize::Chrome> and
+a compatible server-side web brwoser to create images of the JSChart graphs for
+inclusion in emails.
+
+This is accomplished by setting C<$EmailDashboardJSChartImages> to '1' and
+maybe also setting C<$ChromePath> to the path of the executable for your
+chosen Chrome-based browser.
+
+This feature has been tested with Chrome, Chromium, Microsoft Edge, and Opera.
+Other Chrome-based browsers may also work.
+
+=back
+
 =cut
diff --git a/etc/RT_Config.pm.in b/etc/RT_Config.pm.in
index 6433198a36..d20dcb1f55 100644
--- a/etc/RT_Config.pm.in
+++ b/etc/RT_Config.pm.in
@@ -971,6 +971,44 @@ With this enabled, some parts of the email won't look exactly \
like RT.  
 Set($EmailDashboardInlineCSS, 0);
 
+=item C<$EmailDashboardJSChartImages>
+
+To use the JSChart-generated images in emailed dashboards, install the
+optional module L<WWW::Mechanize::Chrome> and enable this option.
+
+=cut
+
+Set($EmailDashboardJSChartImages, 0);
+
+=item C<$ChromePath>
+
+This option contains the path for a compatible Chrome-based browser
+executable that will be used to generate static images for JSChart
+graphs for dashboard emails.
+
+See also L<WWW::Mechanize::Chrome/launch_exe>
+
+=cut
+
+Set($ChromePath, 'chromium');
+
+=item C<@ChromeLaunchArguments>
+
+This option contains the launch arguments when initializing
+L<WWW::Mechanize::Chrome>.
+
+If you need to run L<rt-email-dashboards> as root, you probably need to add
+C<--no-sandbox> to get around Chrome's restriction:
+
+    Set(@ChromeLaunchArguments, '--no-sandbox');
+
+See also L<WWW::Mechanize::Chrome/launch_arg>
+
+=cut
+
+Set(@ChromeLaunchArguments, () );
+
+
 =back
 
 
diff --git a/lib/RT/Config.pm b/lib/RT/Config.pm
index 04fbe3dcd5..36c7b547dc 100644
--- a/lib/RT/Config.pm
+++ b/lib/RT/Config.pm
@@ -1994,6 +1994,15 @@ our %META;
     EmailDashboardInlineCSS => {
         Widget => '/Widgets/Form/Boolean',
     },
+    EmailDashboardJSChartImages => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    ChromePath => {
+        Widget => '/Widgets/Form/String',
+    },
+    ChromeLaunchArguments => {
+        Type => 'ARRAY',
+    },
     DefaultErrorMailPrecedence => {
         Widget => '/Widgets/Form/String',
     },
diff --git a/lib/RT/Dashboard/Mailer.pm b/lib/RT/Dashboard/Mailer.pm
index 84ce29e171..9781e31d08 100644
--- a/lib/RT/Dashboard/Mailer.pm
+++ b/lib/RT/Dashboard/Mailer.pm
@@ -423,8 +423,6 @@ SUMMARY
         }
     }
 
-    $content = ScrubContent($content);
-
     $RT::Logger->debug("Got ".length($content)." characters of output.");
 
     $content = HTML::RewriteAttributes::Links->rewrite(
@@ -536,6 +534,8 @@ sub EmailDashboard {
     $RT::Logger->debug("Done sending dashboard to ".$currentuser->Name." <$email>");
 }
 
+my $chrome;
+
 sub BuildEmail {
     my $self = shift;
     my %args = (
@@ -592,6 +592,141 @@ sub BuildEmail {
         inline_imports => 1,
     );
 
+    # This needs to be done after all of the CSS has been imported (by
+    # inline_css above, which is distinct from the work done by CSS::Inliner
+    # below) and before all of the scripts are scrubbed away.
+    if ( RT->Config->Get('EmailDashboardJSChartImages') ) {
+        if ( RT::StaticUtil::RequireModule("WWW::Mechanize::Chrome") ) {
+
+            # WWW::Mechanize::Chrome uses Log::Log4perl and calls trace sometimes.
+            # Here we merge trace to debug.
+            my $is_debug;
+            for my $type ( qw/LogToSyslog LogToSTDERR LogToFile/ ) {
+                my $log_level = RT->Config->Get($type) or next;
+                if ( $log_level eq 'debug' ) {
+                    $is_debug = 1;
+                    last;
+                }
+            }
+
+            local *Log::Dispatch::is_trace = sub { $is_debug || 0 };
+            local *Log::Dispatch::trace    = sub {
+                my $self = shift;
+                return $self->debug(@_);
+            };
+
+            my ( $width, $height );
+            my @launch_arguments = RT->Config->Get('ChromeLaunchArguments');
+
+            for my $arg (@launch_arguments) {
+                if ( $arg =~ /^--window-size=(\d+)x(\d+)$/ ) {
+                    $width  = $1;
+                    $height = $2;
+                    last;
+                }
+            }
+
+            $width  ||= 2560;
+            $height ||= 1440;
+
+            $chrome ||= WWW::Mechanize::Chrome->new(
+                autodie          => 0,
+                headless         => 1,
+                autoclose        => 1,
+                separate_session => 1,
+                log              => RT->Logger,
+                launch_arg       => \@launch_arguments,
+                launch_exe       => RT->Config->Get('ChromePath') || 'chromium',
+            );
+
+            # copy the content
+            my $content_with_script = $content;
+
+            # copy in the text of the linked js
+            $content_with_script
+                =~ s{<script type="text/javascript" src="([^"]+)"></script>}{<script \
type="text/javascript">@{ [(GetResource( $1 ))[0]] }</script>}g; +
+            # write the complete content to a temp file
+            my $temp_fh = File::Temp->new(
+                UNLINK   => 1,
+                TEMPLATE => 'email-dashboard-XXXXXX',
+                SUFFIX   => '.html',
+                DIR      => $RT::VarPath,               # $chrome can't get the file \
if saved to /tmp +            );
+            print $temp_fh Encode::encode( 'UTF-8', $content_with_script );
+            close $temp_fh;
+
+            $chrome->viewport_size( { width => $width, height => $height } );
+            $chrome->get_local( $temp_fh->filename );
+            $chrome->wait_until_visible( selector => 'div.dashboard' );
+
+            # grab the list of canvas elements
+            my @canvases = $chrome->selector('div.chart canvas');
+            if (@canvases) {
+
+                my $max_extent = 0;
+
+                # ... and their coordinates
+                foreach my $canvas_data (@canvases) {
+                    my $coords = $canvas_data->{coords} = \
$chrome->element_coordinates($canvas_data); +                    if ( $max_extent < \
$coords->{top} + $coords->{height} ) { +                        $max_extent = int( \
$coords->{top} + $coords->{height} ) + 1; +                    }
+                }
+
+                # make sure that all of them are "visible" in the headless instance
+                if ( $height < $max_extent ) {
+                    $chrome->viewport_size( { width => $width, height => $max_extent \
} ); +                }
+
+                # capture the entire page as an image
+                my $page_image = $chrome->_content_as_png( undef, { width => $width, \
height => $height } )->get; +
+                my $cid = time() . $$;
+                foreach my $canvas_data (@canvases) {
+                    $cid++;
+
+                    my $coords       = $canvas_data->{coords};
+                    my $canvas_image = $page_image->crop(
+                        left   => $coords->{left},
+                        top    => $coords->{top},
+                        width  => $coords->{width},
+                        height => $coords->{height},
+                    );
+                    my $canvas_data;
+                    $canvas_image->write( data => \$canvas_data, type => 'png' );
+
+                    # replace each canvas in the original content with an image tag
+                    $content =~ s{<canvas [^>]+>}{<img src="cid:$cid"/>};
+
+                    push @parts,
+                        MIME::Entity->build(
+                            Top          => 0,
+                            Data         => $canvas_data,
+                            Type         => 'image/png',
+                            Encoding     => 'base64',
+                            Disposition  => 'inline',
+                            'Content-Id' => "<$cid>",
+                        );
+                }
+            }
+
+            # Shut down chrome if it's a test email from web UI, to reduce memory \
usage. +            # Unset $chrome so next time it can re-create a new one.
+            if ( $args{Test} ) {
+                $chrome->close;
+                undef $chrome;
+            }
+        }
+        else {
+            RT->Logger->warn(
+                'EmailDashboardJSChartImages is enabled but WWW::Mechanize::Chrome \
is not installed. Install WWW::Mechanize::Chrome to use this feature.' +            \
); +        }
+    }
+
+    $content =~ s{<link rel="shortcut icon"[^>]+/>}{};
+
     # Inline the CSS if CSS::Inliner is installed and can be loaded
     if ( RT->Config->Get('EmailDashboardInlineCSS') ) {
         if ( RT::StaticUtil::RequireModule('CSS::Inliner') ) {
@@ -609,6 +744,8 @@ sub BuildEmail {
         }
     }
 
+    $content = ScrubContent($content);
+
     my $entity = MIME::Entity->build(
         From    => Encode::encode("UTF-8", $args{From}),
         To      => Encode::encode("UTF-8", $args{To}),
diff --git a/sbin/rt-email-dashboards.in b/sbin/rt-email-dashboards.in
index 4b3b008782..8cfbdef3dd 100644
--- a/sbin/rt-email-dashboards.in
+++ b/sbin/rt-email-dashboards.in
@@ -89,8 +89,8 @@ RT::LoadConfig();
 # adjust logging to the screen according to options
 RT->Config->Set( LogToSTDERR => $opts{log} ) if $opts{log};
 
-# Disable JS chart as email clients don't support it
-RT->Config->Set( EnableJSChart => 0 );
+# Disable JS chart unless EmailDashboardJSChartImages is true
+RT->Config->Set( EnableJSChart => RT->Config->Get( 'EmailDashboardJSChartImages' ) \
);  
 # Disable inline editing as email clients don't support it
 RT->Config->Set( InlineEdit => 0 );

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


hooks/post-receive
-- 
rt
_______________________________________________
rt-commit mailing list
rt-commit@lists.bestpractical.com
https://lists.bestpractical.com/mailman/listinfo/rt-commit


[prev in list] [next in list] [prev in thread] [next in thread] 

Configure | About | News | Add a list | Sponsored by KoreLogic