From: Mark Wells Date: Thu, 8 Sep 2016 18:05:38 +0000 (-0700) Subject: Merge branch 'master' of git.freeside.biz:/home/git/freeside X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=commitdiff_plain;h=0929e7aec153632691a64ae8755c082090fd1de5;hp=6df492990ea195513430f3a56d537e57e50b6913 Merge branch 'master' of git.freeside.biz:/home/git/freeside --- diff --git a/FS/FS/Cron/send_subscribed.pm b/FS/FS/Cron/send_subscribed.pm new file mode 100644 index 000000000..2b1f662e6 --- /dev/null +++ b/FS/FS/Cron/send_subscribed.pm @@ -0,0 +1,32 @@ +package FS::Cron::send_subscribed; + +use strict; +use base 'Exporter'; +use FS::saved_search; +use FS::Record qw(qsearch); +use FS::queue; + +our @EXPORT_OK = qw( send_subscribed ); +our $DEBUG = 1; + +sub send_subscribed { + + my @subs = qsearch('saved_search', { + 'disabled' => '', + 'freq' => { op => '!=', value => '' }, + }); + foreach my $saved_search (@subs) { + my $date = $saved_search->next_send_date; + warn "checking '".$saved_search->searchname."' with date $date\n" + if $DEBUG; + + if ( $^T > $saved_search->next_send_date ) { + warn "queueing delivery\n"; + my $job = FS::queue->new({ job => 'FS::saved_search::queueable_send' }); + $job->insert( $saved_search->searchnum ); + } + } + +} + +1; diff --git a/FS/FS/Mason.pm b/FS/FS/Mason.pm index e8a1af6c6..ee87b2de4 100644 --- a/FS/FS/Mason.pm +++ b/FS/FS/Mason.pm @@ -416,7 +416,6 @@ if ( -e $addl_handler_use_file ) { use FS::commission_schedule; use FS::commission_rate; use FS::saved_search; - use FS::saved_search_option; # Sammath Naur if ( $FS::Mason::addl_handler_use ) { diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm index 4ff9db211..df987ffc7 100644 --- a/FS/FS/Schema.pm +++ b/FS/FS/Schema.pm @@ -7492,6 +7492,7 @@ sub tables_hashref { 'usernum', 'int', 'NULL', '', '', '', 'searchname', 'varchar', '', $char_d, '', '', 'path', 'varchar', '', $char_d, '', '', + 'params', 'text', 'NULL', '', '', '', 'disabled', 'char', 'NULL', 1, '', '', 'freq', 'varchar', 'NULL', 16, '', '', 'last_sent', 'int', 'NULL', '', '', '', @@ -7507,23 +7508,6 @@ sub tables_hashref { ], }, - 'saved_search_option' => { - 'columns' => [ - 'optionnum', 'serial', '', '', '', '', - 'searchnum', 'int', '', '', '', '', - 'optionname', 'varchar', '', $char_d, '', '', - 'optionvalue', 'text', 'NULL', '', '', '', - ], - 'primary_key' => 'optionnum', - 'unique' => [ [ 'searchnum', 'optionname' ] ], - 'index' => [], - 'foreign_keys' => [ - { columns => [ 'searchnum' ], - table => 'saved_search', - }, - ], - }, - # name type nullability length default local #'new_table' => { diff --git a/FS/FS/log_context.pm b/FS/FS/log_context.pm index 37befb515..ee3e413ee 100644 --- a/FS/FS/log_context.pm +++ b/FS/FS/log_context.pm @@ -13,6 +13,8 @@ my @contexts = ( qw( FS::pay_batch::import_from_gateway FS::part_pkg FS::Misc::Geo::standardize_uscensus + FS::saved_search::send + FS::saved_search::render Cron::bill Cron::backup Cron::upload diff --git a/FS/FS/saved_search.pm b/FS/FS/saved_search.pm index 075d759f6..caaf7fea8 100644 --- a/FS/FS/saved_search.pm +++ b/FS/FS/saved_search.pm @@ -1,13 +1,15 @@ package FS::saved_search; -use base qw( FS::option_Common FS::Record ); +use base qw( FS::Record ); use strict; use FS::Record qw( qsearch qsearchs ); use FS::Conf; +use FS::Log; +use FS::Misc qw(send_email); +use MIME::Entity; use Class::Load 'load_class'; use URI::Escape; use DateTime; -use Try::Tiny; =head1 NAME @@ -56,6 +58,10 @@ A descriptive name. The path to the page within the Mason document space. +=item params + +The query string for the search. + =item disabled 'Y' to hide the search from the user's Reports / Saved menu. @@ -128,6 +134,7 @@ sub check { #|| $self->ut_foreign_keyn('usernum', 'access_user', 'usernum') || $self->ut_text('searchname') || $self->ut_text('path') + || $self->ut_textn('params') # URL-escaped, so ut_textn || $self->ut_flag('disabled') || $self->ut_enum('freq', [ '', 'daily', 'weekly', 'monthly' ]) || $self->ut_numbern('last_sent') @@ -138,6 +145,14 @@ sub check { $self->SUPER::check; } +sub replace_check { + my ($new, $old) = @_; + if ($new->usernum != $old->usernum) { + return "can't change owner of a saved search"; + } + ''; +} + =item next_send_date Returns the next date this report should be sent next. If it's not set for @@ -168,8 +183,6 @@ Returns the CGI query string for the parameters to this report. =cut -# multivalued options are newline-separated in the database - sub query_string { my $self = shift; @@ -177,12 +190,7 @@ sub query_string { $type = 'html-print' if $type eq '' || $type eq 'html'; $type = '.xls' if $type eq 'xls'; my $query = "_type=$type"; - my %options = $self->options; - foreach my $k (keys %options) { - foreach my $v (split("\n", $options{$k})) { - $query .= ';' . uri_escape($k) . '=' . uri_escape($v); - } - } + $query .= ';' . $self->params if $self->params; $query; } @@ -194,6 +202,7 @@ Returns the report content as an HTML or Excel file. sub render { my $self = shift; + my $log = FS::Log->new('FS::saved_search::render'); my $outbuf; # delayed loading @@ -210,11 +219,10 @@ sub render { local $FS::CurrentUser::CurrentUser = $self->access_user; local $FS::Mason::Request::QUERY_STRING = $self->query_string; - local $FS::Mason::Request::FSURL = ''; #? -# local $ENV{SERVER_NAME} = 'localhost'; #? -# local $ENV{SCRIPT_NAME} = '/freeside'. $self->path; + local $FS::Mason::Request::FSURL = $self->access_user->option('rooturl'); - my $mason_request = $fs_interp->make_request(comp => $self->path); + my $mason_request = $fs_interp->make_request(comp => '/' . $self->path); + $mason_request->notes('inline_stylesheet', 1); local $@; eval { $mason_request->exec(); }; @@ -224,9 +232,9 @@ sub render { $error = $error->message; } - warn "Error rendering " . $self->path . + $log->error("Error rendering " . $self->path . " for " . $self->access_user->username . - ":\n$error\n"; + ":\n$error\n"); # send it to the user anyway, so there's a way to diagnose the error $outbuf = '

Error

There was an error generating the report "'.$self->searchname.'".

@@ -237,6 +245,63 @@ sub render { return $outbuf; } +=item send + +Sends the search by email. If anything fails, logs and returns an error. + +=cut + +sub send { + my $self = shift; + my $log = FS::Log->new('FS::saved_search::send'); + my $conf = FS::Conf->new; + my $user = $self->access_user; + my $username = $user->username; + my $user_email = $user->option('email_address'); + my $error; + if (!$user_email) { + $error = "User '$username' has no email address."; + $log->error($error); + return $error; + } + $log->debug('Rendering saved search'); + my $content = $self->render; + # XXX come back to this for content-type options + my $part = MIME::Entity->build( + 'Type' => 'text/html', + 'Encoding' => 'quoted-printable', # change this for spreadsheet + 'Disposition' => 'inline', + 'Data' => $content, + ); + + my %email_param = ( + 'from' => $conf->config('invoice_from'), + 'to' => $user_email, + 'subject' => $self->searchname, + 'nobody' => 1, + 'mimeparts' => [ $part ], + ); + + $log->debug('Sending to '.$user_email); + $error = send_email(%email_param); + + # update the timestamp + $self->set('last_sent', time); + $error ||= $self->replace; + if ($error) { + $log->error($error); + return $error; + } + +} + +sub queueable_send { + my $searchnum = shift; + my $self = FS::saved_search->by_key($searchnum) + or die "searchnum $searchnum not found\n"; + $self->send; +} + =back =head1 SEE ALSO diff --git a/FS/FS/saved_search_option.pm b/FS/FS/saved_search_option.pm deleted file mode 100644 index f349af393..000000000 --- a/FS/FS/saved_search_option.pm +++ /dev/null @@ -1,124 +0,0 @@ -package FS::saved_search_option; -use base qw( FS::Record ); - -use strict; -use FS::Record qw( qsearch qsearchs ); - -=head1 NAME - -FS::saved_search_option - Object methods for saved_search_option records - -=head1 SYNOPSIS - - use FS::saved_search_option; - - $record = new FS::saved_search_option \%hash; - $record = new FS::saved_search_option { 'column' => 'value' }; - - $error = $record->insert; - - $error = $new_record->replace($old_record); - - $error = $record->delete; - - $error = $record->check; - -=head1 DESCRIPTION - -An FS::saved_search_option object represents a CGI parameter for a report -saved in L. FS::saved_search_option inherits from -FS::Record. The following fields are currently supported: - -=over 4 - -=item optionnum - -primary key - -=item searchnum - -searchnum - -=item optionname - -optionname - -=item optionvalue - -optionvalue - - -=back - -=head1 METHODS - -=over 4 - -=item new HASHREF - -Creates a new parameter. To add the record to the database, see -L<"insert">. - -Note that this stores the hash reference, not a distinct copy of the hash it -points to. You can ask the object for a copy with the I method. - -=cut - -# the new method can be inherited from FS::Record, if a table method is defined - -sub table { 'saved_search_option'; } - -=item insert - -Adds this record to the database. If there is an error, returns the error, -otherwise returns false. - -=item delete - -Delete this record from the database. - -=item replace OLD_RECORD - -Replaces the OLD_RECORD with this one in the database. If there is an error, -returns the error, otherwise returns false. - -=item check - -Checks all fields to make sure this is a valid example. If there is -an error, returns the error, otherwise returns false. Called by the insert -and replace methods. - -=cut - -# the check method should currently be supplied - FS::Record contains some -# data checking routines - -sub check { - my $self = shift; - -# unpack these from the format used by CGI - my $optionvalue = $self->optionvalue; - $optionvalue =~ s/\0/\n/g; - - my $error = - $self->ut_numbern('optionnum') - || $self->ut_number('searchnum') -# || $self->ut_foreign_key('searchnum', 'saved_search', 'searchnum') - || $self->ut_text('optionname') - || $self->ut_textn('optionvalue') - ; - return $error if $error; - - $self->SUPER::check; -} - -=back - -=head1 SEE ALSO - -L - -=cut - -1; - diff --git a/FS/MANIFEST b/FS/MANIFEST index d06f2637f..73a740f63 100644 --- a/FS/MANIFEST +++ b/FS/MANIFEST @@ -876,5 +876,3 @@ FS/commission_rate.pm t/commission_rate.t FS/saved_search.pm t/saved_search.t -FS/saved_search_option.pm -t/saved_search_option.t diff --git a/FS/bin/freeside-daily b/FS/bin/freeside-daily index 6a2daf934..03d235061 100755 --- a/FS/bin/freeside-daily +++ b/FS/bin/freeside-daily @@ -79,6 +79,10 @@ pay_batch_receive(%opt); use FS::Cron::export_batch qw(export_batch_submit); export_batch_submit(%opt); +#does nothing unless there are users with subscribed searches +use FS::Cron::send_subscribed qw(send_subscribed); +send_subscribed(%opt); + #clears out cacti imports & deletes select database cache files use FS::Cron::cleanup qw( cleanup cleanup_before_backup ); cleanup_before_backup(); diff --git a/FS/t/saved_search_option.t b/FS/t/saved_search_option.t deleted file mode 100644 index f30bfb806..000000000 --- a/FS/t/saved_search_option.t +++ /dev/null @@ -1,5 +0,0 @@ -BEGIN { $| = 1; print "1..1\n" } -END {print "not ok 1\n" unless $loaded;} -use FS::saved_search_option; -$loaded=1; -print "ok 1\n"; diff --git a/httemplate/browse/saved_search.html b/httemplate/browse/saved_search.html new file mode 100644 index 000000000..d2efa6ed9 --- /dev/null +++ b/httemplate/browse/saved_search.html @@ -0,0 +1,81 @@ +<& elements/browse.html, + 'title' => 'My saved searches', + 'name' => 'saved searches', + 'query' => { 'table' => 'saved_search', + 'hashref' => { usernum => $curuser->usernum }, + }, + 'count_query' => $count_query, + 'header' => [ '#', + 'Name', + 'Subscription', + 'Last sent', + 'Format', + 'Path', + 'Parameters', + ], + 'sort_fields' => [ 'searchnum', + 'searchname', + 'freq', + 'last_sent', + 'format', + "path || '?' || 'params'", + '', + ], + 'fields' => [ 'searchnum', + 'searchname', + 'freq', + sub { my $date = shift->get('last_sent'); + $date ? time2str('%b %o, %Y', $date) : ''; + }, + sub { $format_label{ shift->get('format') } + }, + 'path', + sub { join('
', + sort + map { encode_entities(uri_unescape($_)) } + split(/[;&]/, shift->get('params') ) + ) + }, + ], + 'size' => [ '', + '', + '', + '', + '', + '', + '-1', + ], + 'links' => [ '', '' ], + 'link_onclicks' => [ '', $edit_popup ], +# 'disableable' => 1, # currrently unused +# 'disabled_statuspos' => 2, + 'really_disable_download' => 1 +&> +<%init> + +my $curuser = $FS::CurrentUser::CurrentUser; + +my $query = { + 'table' => 'saved_search', + 'hashref' => { 'usernum' => $curuser->usernum }, +}; +my $count_query = "SELECT COUNT(*) FROM saved_search WHERE usernum = ". + $curuser->usernum; + +my %format_label = ( + 'html' => 'webpage', + 'csv' => 'CSV', + 'xls' => 'spreadsheet', +); + +my $edit_popup = sub { + my $searchnum = shift->searchnum; + include('/elements/popup_link_onclick.html', + 'action' => $fsurl.'/edit/saved_search.html?'.$searchnum, + 'actionlabel' => 'Save this search', + 'width' => 650, + 'height' => 500, + ); +}; + + diff --git a/httemplate/edit/process/saved_search.html b/httemplate/edit/process/saved_search.html new file mode 100644 index 000000000..7ae7e0d78 --- /dev/null +++ b/httemplate/edit/process/saved_search.html @@ -0,0 +1,15 @@ +<& elements/process.html, + 'table' => 'saved_search', + 'popup_reload' => 'Saving', + 'post_new_object_callback' => $callback, +&> +<%init> + +my $callback = sub { + my ($cgi, $obj) = @_; + $obj->usernum( $FS::CurrentUser::CurrentUser->usernum ); + # if this would change it from its existing owner, replace_check + # will refuse +}; + + diff --git a/httemplate/edit/saved_search.html b/httemplate/edit/saved_search.html new file mode 100644 index 000000000..cb6aa45d1 --- /dev/null +++ b/httemplate/edit/saved_search.html @@ -0,0 +1,113 @@ +<& elements/edit.html, + 'name' => 'saved search', + 'table' => 'saved_search', + 'popup' => 1, + 'fields' => [ + { field => 'searchname', + type => 'text', + size => 40, + }, + { field => 'freq', + type => 'select', + options => [ '', 'daily', 'weekly', 'monthly' ], + labels => { '' => 'no' }, + }, + { field => 'emailaddress', + type => 'fixed', + curr_value_callback => sub { + $curuser->option('email_address') + || 'no email address configured' + }, + }, + { field => 'last_sent', + type => 'fixed-date', + }, + { field => 'format', + type => 'hidden', # revisit this later +# type => 'select', +# options => [ 'html', 'xls', 'csv' ], +# labels => { +# 'html' => 'webpage', +# 'xls' => 'spreadsheet', +# 'csv' => 'CSV', +# }, + }, + { field => 'disabled', # currently unused + type => 'hidden', + }, + { type => 'tablebreak-tr-title' }, + { field => 'path', + type => 'fixed', + cell_style => 'font-size: small', + }, + { field => 'params', + type => 'fixed', + cell_style => 'font-size: small', + }, + ], + 'labels' => { + 'searchnum' => 'Saved search', + 'searchname' => 'Name this search', + 'path' => 'Search page', + 'params' => 'Parameters', + 'freq' => 'Subscribe by email', + 'last_sent' => 'Last sent on', + 'emailaddress' => 'Will be sent to', + 'format' => 'Report format', + }, + 'new_object_callback' => $new_object, + 'delete_url' => $fsurl.'misc/delete-saved_search.html', +&> +<%init> + +my $curuser = $FS::CurrentUser::CurrentUser; +# remember the user's rooturl() when accessing the UI. this will be the +# base URL for sending email reports to that user so that links work. +my $rooturl_pref = qsearchs('access_user_pref', { + usernum => $curuser->usernum, + prefname => 'rooturl', +}); +my $error; +if ($rooturl_pref) { + if ($rooturl_pref->prefvalue ne rooturl()) { + $rooturl_pref->set('prefvalue', rooturl()); + $error = $rooturl_pref->replace; + } # else don't update it +} else { + $rooturl_pref = FS::access_user_pref->new({ + usernum => $curuser->usernum, + prefname => 'rooturl', + prefvalue => rooturl(), + }); + $error = $rooturl_pref->insert; +} + +warn "error updating rooturl pref: $error" if $error; + +# prefix to the freeside document root (usually '/freeside/') +my $root = URI->new($fsurl)->path; + +# alternatively, could do all this on the client using window.top.location +my $new_object = sub { + my $cgi = shift; + my $hashref = shift; + my $fields = shift; + for (grep { $_->{field} eq 'last_sent' } @$fields) { + $_->{type} = 'hidden'; + } + my $url = $r->header_in('Referer') + or die "no referring page found"; + $url = URI->new($url); + my $path = $url->path; + $path =~ s/^$root//; # path should not have a leading slash + my $title = $cgi->param('title'); + return FS::saved_search->new({ + 'usernum' => $curuser->usernum, + 'path' => $path, + 'params' => $url->query, + 'format' => 'html', + 'searchname' => $title, + }); +}; + + diff --git a/httemplate/elements/header-popup.html b/httemplate/elements/header-popup.html index 839a63676..327673bc6 100644 --- a/httemplate/elements/header-popup.html +++ b/httemplate/elements/header-popup.html @@ -38,7 +38,13 @@ Example: <% $head |n %> > +% if ($m->notes('inline_stylesheet')) { # for email delivery + +% } else { +% } % if ( $title || $title_noescape ) {
<% encode_entities($title) || $title_noescape |n %>
diff --git a/httemplate/elements/menu.html b/httemplate/elements/menu.html index fcfc9fb74..d6ea06891 100644 --- a/httemplate/elements/menu.html +++ b/httemplate/elements/menu.html @@ -87,6 +87,21 @@ my $mobile = $opt{'mobile'} || 0; my $curuser = $FS::CurrentUser::CurrentUser; +# saved searches +tie my %report_saved_searches, 'Tie::IxHash'; +if ( my @searches = grep { $_->disabled eq '' } $curuser->saved_search ) { + foreach my $search (@searches) { + $report_saved_searches{ $search->searchname } = [ + # don't use query_string here; we don't want to override the format + $fsurl . $search->path . '?' . $search->params , '' + ]; + } + $report_saved_searches{'separator'} = ''; + $report_saved_searches{'My saved searches'} = + [ $fsurl. 'browse/saved_search.html', + 'Manage saved searches and subscriptions' ]; +} + #XXX Active tickets not assigned to a customer tie my %report_prospects, 'Tie::IxHash'; @@ -419,6 +434,8 @@ $report_logs{'Outgoing messages'} = [ $fsurl.'search/cust_msg.html', 'View outgo || $curuser->access_right('Configuration'); tie my %report_menu, 'Tie::IxHash'; +$report_menu{'Saved searches'} = [ \%report_saved_searches, 'My saved searches' ] + if keys(%report_saved_searches); $report_menu{'Prospects'} = [ \%report_prospects, 'Prospect reports' ] if $curuser->access_right('List prospects') || $curuser->access_right('List contacts'); diff --git a/httemplate/elements/tr-fixed-date.html b/httemplate/elements/tr-fixed-date.html index ef599796d..731a3caa7 100644 --- a/httemplate/elements/tr-fixed-date.html +++ b/httemplate/elements/tr-fixed-date.html @@ -14,6 +14,6 @@ my $value = $opt{'curr_value'} || $opt{'value'}; my $conf = new FS::Conf; my $date_format = $opt{'format'} || $conf->config('date_format') || '%m/%d/%Y'; -$opt{'formatted_value'} = time2str($date_format, $value); +$opt{'formatted_value'} = $value > 0 ? time2str($date_format, $value) : ''; diff --git a/httemplate/misc/delete-saved_search.html b/httemplate/misc/delete-saved_search.html new file mode 100644 index 000000000..34567ec1c --- /dev/null +++ b/httemplate/misc/delete-saved_search.html @@ -0,0 +1,25 @@ +% if ( $error ) { +<& /elements/errorpage-popup.html, $error &> +% } else { +<& /elements/header-popup.html, 'Saved search deleted' &> + + + +% } +<%init> + +my $curuser = $FS::CurrentUser::CurrentUser; + +my($query) = $cgi->keywords; +$query =~ /^(\d+)$/ || die "Illegal searchnum"; +my $searchnum = $1; + +my $search = qsearchs('saved_search', { + 'searchnum' => $searchnum, + 'usernum' => $curuser->usernum, +}); +my $error = $search->delete; + + diff --git a/httemplate/search/elements/search-html.html b/httemplate/search/elements/search-html.html index 12f6c1e04..3ea38aee8 100644 --- a/httemplate/search/elements/search-html.html +++ b/httemplate/search/elements/search-html.html @@ -136,22 +136,36 @@ - <% $opt{'download_label'} || 'Download full results' %>
+ <% $opt{'download_label'} || 'Download results:' %> % $cgi->param('_type', "$xlsname.xls" ); - as query_string %>">Excel spreadsheet
+ query_string %>">Spreadsheet |  % $cgi->param('_type', 'csv'); - as query_string %>">CSV file
+ query_string %>">CSV |  % if ( defined($opt{xml_elements}) ) { % $cgi->param('_type', 'xml'); - as query_string %>">XML file
+ query_string %>">XML |  % } % $cgi->param('_type', 'html-print'); - as query_string %>">printable copy + query_string %>">webpage +%# "save search" -- for now, obey disable_download and the 'Download +%# report data' ACL, because saving a search allows the user to receive +%# copies of the data. +
+%# XXX should do a check here on whether the user already has this +%# search saved... + <& /elements/popup_link.html, + 'action' => $fsurl.'/edit/saved_search.html?title='. + uri_escape($opt{title}), + 'label' => 'Save this search', + 'actionlabel' => 'Save this search', + 'width' => 650, + 'height' => 500, + &> % $cgi->param('_type', "html" ); % } diff --git a/httemplate/search/elements/search.html b/httemplate/search/elements/search.html index b6ee7b373..0f71218c2 100644 --- a/httemplate/search/elements/search.html +++ b/httemplate/search/elements/search.html @@ -179,6 +179,7 @@ Example: &> +% # if changing this, also update saved search behavior to match! % if ( $type eq 'csv' ) { % <% include('search-csv.html', header=>$header, rows=>$rows, opt=>\%opt ) %>