[freeside-commits] branch FREESIDE_3_BRANCH updated. fc71c5a7a2b5595ef38621ead753c6b261c534ba

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


The branch, FREESIDE_3_BRANCH has been updated
       via  fc71c5a7a2b5595ef38621ead753c6b261c534ba (commit)
       via  310427abc396e793a887b467c51964efb22424a1 (commit)
       via  95eca0cb6162ff4cb38ab946ed550eb27159690d (commit)
       via  8006d5ba1f153396869e11eacf24eda6e61a4840 (commit)
       via  f2bc3af56683f64e44fbbb97b543e6d58e99ce28 (commit)
       via  9cb01b1aed97bcbe897e779dc268800a2b1538ae (commit)
       via  4496a23e58b830db4c2d10ad17dfb0d531b15859 (commit)
       via  f8586b58f8913b133809556958b49a083454a962 (commit)
       via  78aa70d55d6f9a05e5bb841b719899039133c107 (commit)
      from  7f6f94356b0a252da15d70f29693f015e2c1fb81 (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 fc71c5a7a2b5595ef38621ead753c6b261c534ba
Merge: 310427a 7f6f943
Author: Mark Wells <mark at freeside.biz>
Date:   Thu Sep 8 14:54:58 2016 -0700

    Merge branch 'FREESIDE_3_BRANCH' of git.freeside.biz:/home/git/freeside into 3.x


commit 310427abc396e793a887b467c51964efb22424a1
Author: Mark Wells <mark at freeside.biz>
Date:   Thu Sep 8 14:54:48 2016 -0700

    one more backport fix, #72101

diff --git a/httemplate/edit/process/elements/process.html b/httemplate/edit/process/elements/process.html
index 89a38f4..60aaf74 100644
--- a/httemplate/edit/process/elements/process.html
+++ b/httemplate/edit/process/elements/process.html
@@ -79,6 +79,9 @@ Example:
    #return an error string or empty for no error
    'precheck_callback' => sub { my( $cgi ) = @_; },
 
+   #after the new object is created
+   'post_new_object_callback' => sub { my( $cgi, $object ) = @_; },
+
    #after everything's inserted
    'noerror_callback' => sub { my( $cgi, $object ) = @_; },
 
@@ -269,6 +272,10 @@ foreach my $value ( @values ) {
       }
     }
 
+    if ( $opt{'post_new_object_callback'} ) {
+      &{ $opt{'post_new_object_callback'} }( $cgi, $new );
+    }
+
     if ( $opt{'agent_virt'} ) {
 
       if ( ! $new->agentnum

commit 95eca0cb6162ff4cb38ab946ed550eb27159690d
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 e1645f0..8be823a 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 a1722cc..ec090a9 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 8006d5ba1f153396869e11eacf24eda6e61a4840
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 f2bc3af56683f64e44fbbb97b543e6d58e99ce28
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 b008594..a1722cc 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 04709ce..906b1ee 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 9cb01b1aed97bcbe897e779dc268800a2b1538ae
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 cdb1d73..621165d 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';
@@ -412,6 +427,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 4496a23e58b830db4c2d10ad17dfb0d531b15859
Author: Mark Wells <mark at freeside.biz>
Date:   Thu Sep 8 11:30:58 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 4d910a3..bdae393 100644
--- a/FS/FS/Mason.pm
+++ b/FS/FS/Mason.pm
@@ -394,7 +394,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 76a370b..57f3475 100644
--- a/FS/FS/Schema.pm
+++ b/FS/FS/Schema.pm
@@ -5226,6 +5226,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',      '', '', '',
@@ -5241,23 +5242,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 d7ea26b..0d62209 100644
--- a/FS/FS/log_context.pm
+++ b/FS/FS/log_context.pm
@@ -12,6 +12,8 @@ my @contexts = ( qw(
   FS::cust_main::Billing_Realtime::realtime_verify_bop
   FS::part_pkg
   FS::Misc::Geo::standardize_uscensus
+  FS::saved_search::send
+  FS::saved_search::render
   Cron::bill
   Cron::upload
   spool_upload
diff --git a/FS/FS/saved_search.pm b/FS/FS/saved_search.pm
index ffcd06b..b008594 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;
+}
+
 #3.x
 sub access_user {
   my $self = shift;
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 8bee83b..9383593 100644
--- a/FS/MANIFEST
+++ b/FS/MANIFEST
@@ -812,5 +812,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 1162e79..4d432ef 100755
--- a/FS/bin/freeside-daily
+++ b/FS/bin/freeside-daily
@@ -74,6 +74,10 @@ export_batch_submit(%opt);
 use FS::Cron::agent_email qw(agent_email);
 agent_email(%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 f8586b58f8913b133809556958b49a083454a962
Author: Mark Wells <mark at freeside.biz>
Date:   Thu Sep 8 11:28:42 2016 -0700

    backporting, #72101

diff --git a/FS/FS/access_user.pm b/FS/FS/access_user.pm
index d13549d..366ae7e 100644
--- a/FS/FS/access_user.pm
+++ b/FS/FS/access_user.pm
@@ -831,6 +831,13 @@ sub set_page_pref {
   return $error;
 }
 
+#3.x
+
+sub saved_search {
+  my $self = shift;
+  qsearch('saved_search', { 'usernum' => $self->usernum });
+}
+
 =back
 
 =head1 BUGS
diff --git a/FS/FS/saved_search.pm b/FS/FS/saved_search.pm
index 075d759..ffcd06b 100644
--- a/FS/FS/saved_search.pm
+++ b/FS/FS/saved_search.pm
@@ -237,6 +237,12 @@ sub render {
   return $outbuf;
 }
 
+#3.x
+sub access_user {
+  my $self = shift;
+  qsearchs('access_user', { 'usernum' => $self->usernum });
+}
+
 =back
 
 =head1 SEE ALSO

commit 78aa70d55d6f9a05e5bb841b719899039133c107
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 6fc4bf0..4d910a3 100644
--- a/FS/FS/Mason.pm
+++ b/FS/FS/Mason.pm
@@ -393,6 +393,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 b7ec7df..76a370b 100644
--- a/FS/FS/Schema.pm
+++ b/FS/FS/Schema.pm
@@ -5220,6 +5220,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 c060c14..8bee83b 100644
--- a/FS/MANIFEST
+++ b/FS/MANIFEST
@@ -810,3 +810,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/access_user.pm                          |    7 +
 FS/FS/cdr/callplus.pm                         |   60 +++++
 FS/FS/log_context.pm                          |    2 +
 FS/FS/saved_search.pm                         |  331 +++++++++++++++++++++++++
 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/elements/process.html |    7 +
 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 +
 22 files changed, 754 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