[freeside-commits] branch FREESIDE_4_BRANCH updated. 162097d4e2bd08efd71d998406c14cbfeb108d0b

Mark Wells mark at 420.am
Thu Sep 8 14:55:21 PDT 2016


The branch, FREESIDE_4_BRANCH has been updated
       via  162097d4e2bd08efd71d998406c14cbfeb108d0b (commit)
       via  cda0cfbe3c69fa260ba7e5ce9b50853dfba7cc97 (commit)
       via  9c0ba3e16a066851dec5488b4a95565e271ae325 (commit)
       via  91bcf35460fb2b33c71ab6b078ba049875a18cd5 (commit)
       via  87194c546606205e78e36f99ec1f2ae6f0a9cafc (commit)
       via  4b75f33fd6c2d0fecf8714f588f2d6d200aa4d47 (commit)
       via  9a9f45c82bc7901cfa94b6e64a2e3e4d5b15abff (commit)
      from  9530530d8075813c6f5c5c18048ae0f12fe80305 (commit)

Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.

- Log -----------------------------------------------------------------
commit 162097d4e2bd08efd71d998406c14cbfeb108d0b
Merge: cda0cfb 9530530
Author: Mark Wells <mark at freeside.biz>
Date:   Thu Sep 8 14:40:39 2016 -0700

    Merge branch 'FREESIDE_4_BRANCH' of git.freeside.biz:/home/git/freeside into 4.x


commit cda0cfbe3c69fa260ba7e5ce9b50853dfba7cc97
Author: Mark Wells <mark at freeside.biz>
Date:   Thu Sep 8 14:40:13 2016 -0700

    add delivery of spreadsheet/CSV reports, #72101

diff --git a/FS/FS/CGI.pm b/FS/FS/CGI.pm
index fc0f652..098cdf0 100644
--- a/FS/FS/CGI.pm
+++ b/FS/FS/CGI.pm
@@ -78,21 +78,17 @@ Sets an http header.
 
 sub http_header {
   my ( $header, $value ) = @_;
-  if (exists $ENV{MOD_PERL}) {
-    if ( defined $HTML::Mason::Commands::r  ) { #Mason
-      ## is this the correct pacakge for $r ???  for 1.0x and 1.1x ?
-      if ( $header =~ /^Content-Type$/ ) {
-        $HTML::Mason::Commands::r->content_type($value);
-      } else {
-        $HTML::Mason::Commands::r->header_out( $header => $value );
-      }
+  if ( defined $HTML::Mason::Commands::r  ) { #Mason + apache
+    if ( $header =~ /^Content-Type$/ ) {
+      $HTML::Mason::Commands::r->content_type($value);
     } else {
-      die "http_header called in unknown environment";
+      $HTML::Mason::Commands::r->header_out( $header => $value );
     }
+  } elsif ( defined $HTML::Mason::Commands::m ) {
+    $HTML::Mason::Commands::m->notes(lc("header-$header"), $value);
   } else {
-    die "http_header called not running under mod_perl";
+    warn "http_header($header, $value) called with no way to set headers\n";
   }
-
 }
 
 =item menubar ITEM, URL, ...
diff --git a/FS/FS/saved_search.pm b/FS/FS/saved_search.pm
index caaf7fe..fd82439 100644
--- a/FS/FS/saved_search.pm
+++ b/FS/FS/saved_search.pm
@@ -242,7 +242,25 @@ sub render {
   <p>' . $_ . '</p>';
   }
 
-  return $outbuf;
+  my %mime = (
+    Data        => $outbuf,
+    Type        => $mason_request->notes('header-content-type')
+                   || 'text/html',
+    Disposition => 'inline',
+  );
+  if (my $disp = $mason_request->notes('header-content-disposition') ) {
+    $disp =~ /^(attachment|inline)\s*;\s*filename=(.*)$/;
+    $mime{Disposition} = $1;
+    my $filename = $2;
+    $filename =~ s/^"(.*)"$/$1/;
+    $mime{Filename} = $filename;
+  }
+  if ($mime{Type} =~ /^text/) {
+    $mime{Encoding} = 'quoted-printable';
+  } else {
+    $mime{Encoding} = 'base64';
+  }
+  return MIME::Entity->build(%mime);
 }
 
 =item send
@@ -265,14 +283,7 @@ sub send {
     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 $part = $self->render;
 
   my %email_param = (
     'from'      => $conf->config('invoice_from'),
diff --git a/httemplate/edit/saved_search.html b/httemplate/edit/saved_search.html
index cb6aa45..f8f0333 100644
--- a/httemplate/edit/saved_search.html
+++ b/httemplate/edit/saved_search.html
@@ -23,14 +23,13 @@
       type    => 'fixed-date',
     },
     { field   => 'format',
-      type    => 'hidden', # revisit this later
-#      type    => 'select',
-#      options => [ 'html', 'xls', 'csv' ],
-#      labels => {
-#        'html' => 'webpage',
-#        'xls'  => 'spreadsheet',
-#        'csv'  => 'CSV',
-#      },
+      type    => 'select',
+      options => [ 'html', 'xls', 'csv' ],
+      labels => {
+        'html' => 'webpage',
+        'xls'  => 'spreadsheet',
+        'csv'  => 'CSV',
+      },
     },
     { field => 'disabled', # currently unused
       type  => 'hidden',
diff --git a/httemplate/search/elements/search-xls.html b/httemplate/search/elements/search-xls.html
index c4265e8..f2b0bad 100644
--- a/httemplate/search/elements/search-xls.html
+++ b/httemplate/search/elements/search-xls.html
@@ -22,7 +22,7 @@ http_header('Content-Disposition' => qq!attachment;filename="$filename"! );
  
 #http://support.microsoft.com/kb/812935
 #http://support.microsoft.com/kb/323308
-$HTML::Mason::Commands::r->headers_out->{'Cache-control'} = 'max-age=0';
+http_header('Cache-control' => 'max-age=0');
 
 my $data = '';
 my $XLS = new IO::Scalar \$data;

commit 9c0ba3e16a066851dec5488b4a95565e271ae325
Author: Mark Wells <mark at freeside.biz>
Date:   Thu Sep 8 14:00:19 2016 -0700

    CallPlus CDR format, #72264

diff --git a/FS/FS/cdr/callplus.pm b/FS/FS/cdr/callplus.pm
new file mode 100644
index 0000000..fa6c799
--- /dev/null
+++ b/FS/FS/cdr/callplus.pm
@@ -0,0 +1,60 @@
+package FS::cdr::callplus;
+use base qw( FS::cdr );
+
+use strict;
+use vars qw( %info );
+use FS::Record qw( qsearchs );
+use Time::Local 'timelocal';
+
+# Date format in the Date/Time col: "13/07/2016 2:40:32 p.m."
+# d/m/y H:M:S, leading zeroes stripped, 12-hour with "a.m." or "p.m.".
+# There are also separate d/m/y and 24-hour time columns, but parsing
+# those separately is hard (DST issues).
+
+%info = (
+  'name'          => 'CallPlus',
+  'weight'        => 610,
+  'header'        => 1,
+  'type'          => 'csv',
+  'import_fields' => [
+    'uniqueid',           # ID
+    '',                   # Billing Group (charged_party?)
+    'src',                # Origin Number
+    'dst',                # Destination Number
+    '',                   # Description (seems to be dest caller id?)
+    '',                   # Status
+    '',                   # Terminated
+    '',                   # Date
+    '',                   # Time
+    sub {                 # Date/Time
+      # this format overlaps one of the existing parser cases, so give it
+      # its own special parser
+      my ($cdr, $value) = @_;
+      $value =~ m[^(\d{1,2})/(\d{1,2})/(\d{4}) (\d{1,2}):(\d{2}):(\d{2}) (a\.m\.|p\.m\.)$]
+        or die "unparseable date: $value";
+      my ($day, $mon, $year, $hour, $min, $sec) = ( $1, $2, $3, $4, $5, $6 );
+      $hour = $hour % 12;
+      if ($7 eq 'p.m.') {
+        $hour = 12;
+      }
+      $cdr->set('startdate',
+                timelocal($sec, $min, $hour, $day, $mon-1, $year)
+               );
+    },
+    sub {                 # Call Length (seconds)
+      my ($cdr, $value) = @_;
+      $cdr->set('duration', $value);
+      $cdr->set('billsec', $value);
+    },
+    sub {                 # Call Cost (NZD)
+      my ($cdr,$value) = @_;
+      $value =~ s/^\$//;
+      $cdr->upstream_price($value);
+    },
+    skip(4),              # Smartcode, Smartcode Description, Type, SubType
+  ],
+);
+
+sub skip { map {''} (1..$_[0]) }
+
+1;

commit 91bcf35460fb2b33c71ab6b078ba049875a18cd5
Author: Mark Wells <mark at freeside.biz>
Date:   Thu Sep 8 11:26:12 2016 -0700

    fix UI links and CSS in searches delivered by email, #72101

diff --git a/FS/FS/saved_search.pm b/FS/FS/saved_search.pm
index 252dc71..caaf7fe 100644
--- a/FS/FS/saved_search.pm
+++ b/FS/FS/saved_search.pm
@@ -219,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);
+  $mason_request->notes('inline_stylesheet', 1);
 
   local $@;
   eval { $mason_request->exec(); };
diff --git a/httemplate/edit/saved_search.html b/httemplate/edit/saved_search.html
index 3039aed..cb6aa45 100644
--- a/httemplate/edit/saved_search.html
+++ b/httemplate/edit/saved_search.html
@@ -61,6 +61,28 @@
 <%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;
diff --git a/httemplate/elements/header-popup.html b/httemplate/elements/header-popup.html
index 6c0f80b..7ddb53c 100644
--- a/httemplate/elements/header-popup.html
+++ b/httemplate/elements/header-popup.html
@@ -37,7 +37,13 @@ Example:
     <% $head |n %>
   </HEAD>
   <BODY <% $etc |n %>>
+%   if ($m->notes('inline_stylesheet')) { # for email delivery
+    <style type="text/css">
+    <& /elements/freeside.css &>
+    </style>
+%   } else {
     <link href="<%$fsurl%>elements/freeside.css" type="text/css" rel="stylesheet">
+%   }
 % if ( $title || $title_noescape ) {
     <FONT SIZE=6>
       <CENTER><% encode_entities($title) || $title_noescape |n %></CENTER>

commit 87194c546606205e78e36f99ec1f2ae6f0a9cafc
Author: Mark Wells <mark at freeside.biz>
Date:   Thu Sep 8 11:25:59 2016 -0700

    add UI to manage saved searches, #72101

diff --git a/httemplate/browse/saved_search.html b/httemplate/browse/saved_search.html
new file mode 100644
index 0000000..d2efa6e
--- /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('<BR>',
+                             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,
+  );
+};
+
+</%init>
diff --git a/httemplate/edit/process/saved_search.html b/httemplate/edit/process/saved_search.html
new file mode 100644
index 0000000..7ae7e0d
--- /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
+};
+
+</%init>
diff --git a/httemplate/edit/saved_search.html b/httemplate/edit/saved_search.html
new file mode 100644
index 0000000..3039aed
--- /dev/null
+++ b/httemplate/edit/saved_search.html
@@ -0,0 +1,91 @@
+<& 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;
+
+# 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,
+  });
+};
+
+</%init>
diff --git a/httemplate/elements/menu.html b/httemplate/elements/menu.html
index 62e0269..38509c6 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 ef59979..731a3ca 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) : '';
 
 </%init>
diff --git a/httemplate/misc/delete-saved_search.html b/httemplate/misc/delete-saved_search.html
new file mode 100644
index 0000000..34567ec
--- /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' &>
+  <script type="text/javascript">
+  topreload();
+  </script>
+</body>
+</html>
+% }
+<%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;
+
+</%init>
diff --git a/httemplate/search/elements/search-html.html b/httemplate/search/elements/search-html.html
index 12f6c1e..3ea38ae 100644
--- a/httemplate/search/elements/search-html.html
+++ b/httemplate/search/elements/search-html.html
@@ -136,22 +136,36 @@
 
               <TD ALIGN="right" CLASS="noprint">
 
-                <% $opt{'download_label'} || 'Download full results' %><BR>
+                <% $opt{'download_label'} || 'Download results:' %>
 
 %               $cgi->param('_type', "$xlsname.xls" ); 
-                as <A HREF="<% "$self_url?". $cgi->query_string %>">Excel spreadsheet</A><BR>
+                <A HREF="<% "$self_url?". $cgi->query_string %>">Spreadsheet</A> | 
 
 %               $cgi->param('_type', 'csv'); 
-                as <A HREF="<% "$self_url?". $cgi->query_string %>">CSV file</A><BR>
+                <A HREF="<% "$self_url?". $cgi->query_string %>">CSV</A> | 
 
 %             if ( defined($opt{xml_elements}) ) {
 %               $cgi->param('_type', 'xml'); 
-                as <A HREF="<% "$self_url?". $cgi->query_string %>">XML file</A><BR>
+                <A HREF="<% "$self_url?". $cgi->query_string %>">XML</A> | 
 %             }
 
 %               $cgi->param('_type', 'html-print'); 
-                as <A HREF="<% "$self_url?". $cgi->query_string %>">printable copy</A>
+                <A HREF="<% "$self_url?". $cgi->query_string %>">webpage</A>
 
+%# "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.
+                <BR>
+%# 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,
+                &>
               </TD>
 %             $cgi->param('_type', "html" ); 
 %           } 
diff --git a/httemplate/search/elements/search.html b/httemplate/search/elements/search.html
index 8b85324..4ef8c25 100644
--- a/httemplate/search/elements/search.html
+++ b/httemplate/search/elements/search.html
@@ -179,6 +179,7 @@ Example:
   &>
 
 </%doc>
+% # if changing this, also update saved search behavior to match!
 % if ( $type eq 'csv' ) {
 %
 <% include('search-csv.html',  header=>$header, rows=>$rows, opt=>\%opt ) %>

commit 4b75f33fd6c2d0fecf8714f588f2d6d200aa4d47
Author: Mark Wells <mark at freeside.biz>
Date:   Thu Sep 8 11:25:47 2016 -0700

    add email delivery of saved searches, #72101

diff --git a/FS/FS/Cron/send_subscribed.pm b/FS/FS/Cron/send_subscribed.pm
new file mode 100644
index 0000000..2b1f662
--- /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 8c9e6b4..9d9292e 100644
--- a/FS/FS/Mason.pm
+++ b/FS/FS/Mason.pm
@@ -414,7 +414,6 @@ if ( -e $addl_handler_use_file ) {
   use FS::access_user_page_pref;
   use FS::part_svc_msgcat;
   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 a00e90f..65c1c01 100644
--- a/FS/FS/Schema.pm
+++ b/FS/FS/Schema.pm
@@ -7468,6 +7468,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',      '', '', '',
@@ -7483,23 +7484,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 83414a6..284a780 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 075d759..252dc71 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
@@ -214,7 +223,7 @@ sub render {
 #  local $ENV{SERVER_NAME} = 'localhost'; #?
 #  local $ENV{SCRIPT_NAME} = '/freeside'. $self->path;
 
-  my $mason_request = $fs_interp->make_request(comp => $self->path);
+  my $mason_request = $fs_interp->make_request(comp => '/' . $self->path);
 
   local $@;
   eval { $mason_request->exec(); };
@@ -224,9 +233,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 = '<h3>Error</h3>
   <p>There was an error generating the report "'.$self->searchname.'".</p>
@@ -237,6 +246,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 f349af3..0000000
--- 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>.  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<hash> 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<FS::Record>
-
-=cut
-
-1;
-
diff --git a/FS/MANIFEST b/FS/MANIFEST
index ea33789..1440b4a 100644
--- a/FS/MANIFEST
+++ b/FS/MANIFEST
@@ -872,5 +872,3 @@ FS/access_user_page_pref.pm
 t/access_user_page_pref.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 6a2daf9..03d2350 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 f30bfb8..0000000
--- 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";

commit 9a9f45c82bc7901cfa94b6e64a2e3e4d5b15abff
Author: Mark Wells <mark at freeside.biz>
Date:   Thu Sep 8 11:25:07 2016 -0700

    saved searches, core stuff, #72101

diff --git a/FS/FS/Mason.pm b/FS/FS/Mason.pm
index 1008fd5..8c9e6b4 100644
--- a/FS/FS/Mason.pm
+++ b/FS/FS/Mason.pm
@@ -413,6 +413,8 @@ if ( -e $addl_handler_use_file ) {
   use FS::olt_site;
   use FS::access_user_page_pref;
   use FS::part_svc_msgcat;
+  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 bb62fef..a00e90f 100644
--- a/FS/FS/Schema.pm
+++ b/FS/FS/Schema.pm
@@ -7462,6 +7462,44 @@ sub tables_hashref {
                         ],
     },
 
+    'saved_search' => {
+      'columns' => [
+        'searchnum',    'serial',  '',          '', '', '',
+        'usernum',      'int',     'NULL',      '', '', '',
+        'searchname',   'varchar', '',     $char_d, '', '',
+        'path',         'varchar', '',     $char_d, '', '',
+        'disabled',     'char',    'NULL',       1, '', '',
+        'freq',         'varchar', 'NULL',      16, '', '',
+        'last_sent',    'int',     'NULL',      '', '', '',
+        'format',       'varchar', 'NULL',      32, '', '',
+      ],
+      'primary_key'   => 'searchnum',
+      'unique'        => [],
+      'index'         => [],
+      'foreign_keys'  => [
+                           { columns => [ 'usernum' ],
+                             table   => 'access_user',
+                           },
+                         ],
+    },
+
+    '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/saved_search.pm b/FS/FS/saved_search.pm
new file mode 100644
index 0000000..075d759
--- /dev/null
+++ b/FS/FS/saved_search.pm
@@ -0,0 +1,249 @@
+package FS::saved_search;
+use base qw( FS::option_Common FS::Record );
+
+use strict;
+use FS::Record qw( qsearch qsearchs );
+use FS::Conf;
+use Class::Load 'load_class';
+use URI::Escape;
+use DateTime;
+use Try::Tiny;
+
+=head1 NAME
+
+FS::saved_search - Object methods for saved_search records
+
+=head1 SYNOPSIS
+
+  use FS::saved_search;
+
+  $record = new FS::saved_search \%hash;
+  $record = new FS::saved_search { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::saved_search object represents a search (a page in the backoffice
+UI, typically under search/ or browse/) which a user has saved for future
+use or periodic email delivery.
+
+FS::saved_search inherits from FS::Record.  The following fields are
+currently supported:
+
+=over 4
+
+=item searchnum
+
+primary key
+
+=item usernum
+
+usernum of the L<FS::access_user> that created the search. Currently, email
+reports will only be sent to this user.
+
+=item searchname
+
+A descriptive name.
+
+=item path
+
+The path to the page within the Mason document space.
+
+=item disabled
+
+'Y' to hide the search from the user's Reports / Saved menu.
+
+=item freq
+
+A frequency for email delivery of this report: daily, weekly, or
+monthly, or null to disable it.
+
+=item last_sent
+
+The timestamp of the last time this report was sent.
+
+=item format
+
+'html', 'xls', or 'csv'. Not all reports support all of these.
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new saved search.  To add it 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<hash> method.
+
+=cut
+
+sub table { 'saved_search'; }
+
+=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.
+
+=cut
+
+# the replace method can be inherited from FS::Record
+
+=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;
+
+  my $error = 
+    $self->ut_numbern('searchnum')
+    || $self->ut_number('usernum')
+    #|| $self->ut_foreign_keyn('usernum', 'access_user', 'usernum')
+    || $self->ut_text('searchname')
+    || $self->ut_text('path')
+    || $self->ut_flag('disabled')
+    || $self->ut_enum('freq', [ '', 'daily', 'weekly', 'monthly' ])
+    || $self->ut_numbern('last_sent')
+    || $self->ut_enum('format', [ '', 'html', 'csv', 'xls' ])
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=item next_send_date
+
+Returns the next date this report should be sent next. If it's not set for
+periodic email sending, returns undef. If it is set up but has never been
+sent before, returns zero.
+
+=cut
+
+sub next_send_date {
+  my $self = shift;
+  my $freq = $self->freq or return undef;
+  return 0 unless $self->last_sent;
+  my $dt = DateTime->from_epoch(epoch => $self->last_sent);
+  $dt->truncate(to => 'day');
+  if ($freq eq 'daily') {
+    $dt->add(days => 1);
+  } elsif ($freq eq 'weekly') {
+    $dt->add(weeks => 1);
+  } elsif ($freq eq 'monthly') {
+    $dt->add(months => 1);
+  }
+  $dt->epoch;
+}
+
+=item query_string
+
+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;
+
+  my $type = $self->format;
+  $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;
+}
+
+=item render
+
+Returns the report content as an HTML or Excel file.
+
+=cut
+
+sub render {
+  my $self = shift;
+  my $outbuf;
+
+  # delayed loading
+  load_class('FS::Mason');
+  RT::LoadConfig();
+  RT::Init();
+
+  # do this before setting QUERY_STRING/FSURL
+  my ($fs_interp) = FS::Mason::mason_interps('standalone',
+    outbuf => \$outbuf
+  );
+  $fs_interp->error_mode('fatal');
+  $fs_interp->error_format('text');
+
+  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;
+
+  my $mason_request = $fs_interp->make_request(comp => $self->path);
+
+  local $@;
+  eval { $mason_request->exec(); };
+  if ($@) {
+    my $error = $@;
+    if ( ref($error) eq 'HTML::Mason::Exception' ) {
+      $error = $error->message;
+    }
+
+    warn "Error rendering " . $self->path .
+         " for " . $self->access_user->username .
+         ":\n$error\n";
+    # send it to the user anyway, so there's a way to diagnose the error
+    $outbuf = '<h3>Error</h3>
+  <p>There was an error generating the report "'.$self->searchname.'".</p>
+  <p>' . $self->path . '?' . $self->query_string . '</p>
+  <p>' . $_ . '</p>';
+  }
+
+  return $outbuf;
+}
+
+=back
+
+=head1 SEE ALSO
+
+L<FS::Record>
+
+=cut
+
+1;
+
diff --git a/FS/FS/saved_search_option.pm b/FS/FS/saved_search_option.pm
new file mode 100644
index 0000000..f349af3
--- /dev/null
+++ b/FS/FS/saved_search_option.pm
@@ -0,0 +1,124 @@
+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>.  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<hash> 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<FS::Record>
+
+=cut
+
+1;
+
diff --git a/FS/MANIFEST b/FS/MANIFEST
index 83359f1..ea33789 100644
--- a/FS/MANIFEST
+++ b/FS/MANIFEST
@@ -870,3 +870,7 @@ FS/webservice_log.pm
 t/webservice_log.t
 FS/access_user_page_pref.pm
 t/access_user_page_pref.t
+FS/saved_search.pm
+t/saved_search.t
+FS/saved_search_option.pm
+t/saved_search_option.t
diff --git a/FS/t/saved_search.t b/FS/t/saved_search.t
new file mode 100644
index 0000000..8155c6d
--- /dev/null
+++ b/FS/t/saved_search.t
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::saved_search;
+$loaded=1;
+print "ok 1\n";
diff --git a/FS/t/saved_search_option.t b/FS/t/saved_search_option.t
new file mode 100644
index 0000000..f30bfb8
--- /dev/null
+++ b/FS/t/saved_search_option.t
@@ -0,0 +1,5 @@
+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";

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

Summary of changes:
 FS/FS/CGI.pm                                |   18 +-
 FS/FS/Cron/send_subscribed.pm               |   32 +++
 FS/FS/Mason.pm                              |    1 +
 FS/FS/Schema.pm                             |   22 ++
 FS/FS/cdr/callplus.pm                       |   60 +++++
 FS/FS/log_context.pm                        |    2 +
 FS/FS/saved_search.pm                       |  325 +++++++++++++++++++++++++++
 FS/MANIFEST                                 |    2 +
 FS/bin/freeside-daily                       |    4 +
 FS/t/{AccessRight.t => saved_search.t}      |    2 +-
 httemplate/browse/saved_search.html         |   81 +++++++
 httemplate/edit/process/saved_search.html   |   15 ++
 httemplate/edit/saved_search.html           |  112 +++++++++
 httemplate/elements/header-popup.html       |    6 +
 httemplate/elements/menu.html               |   17 ++
 httemplate/elements/tr-fixed-date.html      |    2 +-
 httemplate/misc/delete-saved_search.html    |   25 +++
 httemplate/search/elements/search-html.html |   24 +-
 httemplate/search/elements/search-xls.html  |    2 +-
 httemplate/search/elements/search.html      |    1 +
 20 files changed, 734 insertions(+), 19 deletions(-)
 create mode 100644 FS/FS/Cron/send_subscribed.pm
 create mode 100644 FS/FS/cdr/callplus.pm
 create mode 100644 FS/FS/saved_search.pm
 copy FS/t/{AccessRight.t => saved_search.t} (82%)
 create mode 100644 httemplate/browse/saved_search.html
 create mode 100644 httemplate/edit/process/saved_search.html
 create mode 100644 httemplate/edit/saved_search.html
 create mode 100644 httemplate/misc/delete-saved_search.html




More information about the freeside-commits mailing list