RT# 78131 - added documentation for new method.
[freeside.git] / FS / FS / saved_search.pm
1 package FS::saved_search;
2 use base qw( FS::Record );
3
4 use strict;
5 use FS::Record qw( qsearch qsearchs );
6 use FS::Conf;
7 use FS::Log;
8 use FS::Misc qw(send_email);
9 use MIME::Entity;
10 use Class::Load 'load_class';
11 use URI::Escape;
12 use DateTime;
13
14 =head1 NAME
15
16 FS::saved_search - Object methods for saved_search records
17
18 =head1 SYNOPSIS
19
20   use FS::saved_search;
21
22   $record = new FS::saved_search \%hash;
23   $record = new FS::saved_search { 'column' => 'value' };
24
25   $error = $record->insert;
26
27   $error = $new_record->replace($old_record);
28
29   $error = $record->delete;
30
31   $error = $record->check;
32
33 =head1 DESCRIPTION
34
35 An FS::saved_search object represents a search (a page in the backoffice
36 UI, typically under search/ or browse/) which a user has saved for future
37 use or periodic email delivery.
38
39 FS::saved_search inherits from FS::Record.  The following fields are
40 currently supported:
41
42 =over 4
43
44 =item searchnum
45
46 primary key
47
48 =item usernum
49
50 usernum of the L<FS::access_user> that created the search. Currently, email
51 reports will only be sent to this user.
52
53 =item searchname
54
55 A descriptive name.
56
57 =item path
58
59 The path to the page within the Mason document space.
60
61 =item params
62
63 The query string for the search.
64
65 =item disabled
66
67 'Y' to hide the search from the user's Reports / Saved menu.
68
69 =item freq
70
71 A frequency for email delivery of this report: daily, weekly, or
72 monthly, or null to disable it.
73
74 =item last_sent
75
76 The timestamp of the last time this report was sent.
77
78 =item format
79
80 'html', 'xls', or 'csv'. Not all reports support all of these.
81
82 =back
83
84 =head1 METHODS
85
86 =over 4
87
88 =item new HASHREF
89
90 Creates a new saved search.  To add it to the database, see L<"insert">.
91
92 Note that this stores the hash reference, not a distinct copy of the hash it
93 points to.  You can ask the object for a copy with the I<hash> method.
94
95 =cut
96
97 sub table { 'saved_search'; }
98
99 =item insert
100
101 Adds this record to the database.  If there is an error, returns the error,
102 otherwise returns false.
103
104 =item delete
105
106 Delete this record from the database.
107
108 =item replace OLD_RECORD
109
110 Replaces the OLD_RECORD with this one in the database.  If there is an error,
111 returns the error, otherwise returns false.
112
113 =cut
114
115 # the replace method can be inherited from FS::Record
116
117 =item check
118
119 Checks all fields to make sure this is a valid example.  If there is
120 an error, returns the error, otherwise returns false.  Called by the insert
121 and replace methods.
122
123 =cut
124
125 # the check method should currently be supplied - FS::Record contains some
126 # data checking routines
127
128 sub check {
129   my $self = shift;
130
131   my $error = 
132     $self->ut_numbern('searchnum')
133     || $self->ut_number('usernum')
134     #|| $self->ut_foreign_keyn('usernum', 'access_user', 'usernum')
135     || $self->ut_text('searchname')
136     || $self->ut_text('path')
137     || $self->ut_textn('params') # URL-escaped, so ut_textn
138     || $self->ut_flag('disabled')
139     || $self->ut_enum('freq', [ '', 'daily', 'weekly', 'monthly' ])
140     || $self->ut_numbern('last_sent')
141     || $self->ut_enum('format', [ '', 'html', 'csv', 'xls' ])
142   ;
143   return $error if $error;
144
145   $self->SUPER::check;
146 }
147
148 sub replace_check {
149   my ($new, $old) = @_;
150   if ($new->usernum != $old->usernum) {
151     return "can't change owner of a saved search";
152   }
153   '';
154 }
155
156 =item next_send_date
157
158 Returns the next date this report should be sent next. If it's not set for
159 periodic email sending, returns undef. If it is set up but has never been
160 sent before, returns zero.
161
162 =cut
163
164 sub next_send_date {
165   my $self = shift;
166   my $freq = $self->freq or return undef;
167   return 0 unless $self->last_sent;
168   my $dt = DateTime->from_epoch(epoch => $self->last_sent);
169   $dt->truncate(to => 'day');
170   if ($freq eq 'daily') {
171     $dt->add(days => 1);
172   } elsif ($freq eq 'weekly') {
173     $dt->add(weeks => 1);
174   } elsif ($freq eq 'monthly') {
175     $dt->add(months => 1);
176   }
177   $dt->epoch;
178 }
179
180 =item query_string
181
182 Returns the CGI query string for the parameters to this report.
183
184 =cut
185
186 sub query_string {
187   my $self = shift;
188
189   my $type = $self->format;
190   $type = 'html-print' if $type eq '' || $type eq 'html';
191   $type = '.xls' if $type eq 'xls';
192   my $query = "_type=$type";
193   $query .= ';' . $self->params if $self->params;
194   $query;
195 }
196
197 =item render
198
199 Returns the report content as an HTML or Excel file.
200
201 =cut
202
203 sub render {
204   my $self = shift;
205   my $log = FS::Log->new('FS::saved_search::render');
206   my $outbuf;
207
208   # delayed loading
209   load_class('FS::Mason');
210   RT::LoadConfig();
211   RT::Init();
212
213   # do this before setting QUERY_STRING/FSURL
214   my ($fs_interp) = FS::Mason::mason_interps('standalone',
215     outbuf => \$outbuf
216   );
217   $fs_interp->error_mode('fatal');
218   $fs_interp->error_format('text');
219
220   local $FS::CurrentUser::CurrentUser = $self->access_user;
221   local $FS::Mason::Request::QUERY_STRING = $self->query_string;
222   local $FS::Mason::Request::FSURL = $self->access_user->option('rooturl');
223
224   my $mason_request = $fs_interp->make_request(comp => '/' . $self->path);
225   $mason_request->notes('inline_stylesheet', 1);
226
227   local $@;
228   eval { $mason_request->exec(); };
229   if ($@) {
230     my $error = $@;
231     if ( ref($error) eq 'HTML::Mason::Exception' ) {
232       $error = $error->message;
233     }
234
235     $log->error("Error rendering " . $self->path .
236          " for " . $self->access_user->username .
237          ":\n$error\n");
238     # send it to the user anyway, so there's a way to diagnose the error
239     $outbuf = '<h3>Error</h3>
240   <p>There was an error generating the report "'.$self->searchname.'".</p>
241   <p>' . $self->path . '?' . $self->query_string . '</p>
242   <p>' . $_ . '</p>';
243   }
244
245   my %mime = (
246     Data        => $outbuf,
247     Type        => $mason_request->notes('header-content-type')
248                    || 'text/html',
249     Disposition => 'inline',
250   );
251   if (my $disp = $mason_request->notes('header-content-disposition') ) {
252     $disp =~ /^(attachment|inline)\s*;\s*filename=(.*)$/;
253     $mime{Disposition} = $1;
254     my $filename = $2;
255     $filename =~ s/^"(.*)"$/$1/;
256     $mime{Filename} = $filename;
257   }
258   if ($mime{Type} =~ /^text/) {
259     $mime{Encoding} = 'quoted-printable';
260   } else {
261     $mime{Encoding} = 'base64';
262   }
263   return MIME::Entity->build(%mime);
264 }
265
266 =item send
267
268 Sends the search by email. If anything fails, logs and returns an error.
269
270 =cut
271
272 sub send {
273   my $self = shift;
274   my $log = FS::Log->new('FS::saved_search::send');
275   my $conf = FS::Conf->new;
276   my $user = $self->access_user;
277   my $username = $user->username;
278   my $user_email = $user->option('email_address');
279   my $error;
280   if (!$user_email) {
281     $error = "User '$username' has no email address.";
282     $log->error($error);
283     return $error;
284   }
285   $log->debug('Rendering saved search');
286   my $part = $self->render;
287
288   my %email_param = (
289     'from'      => $conf->config('invoice_from'),
290     'to'        => $user_email,
291     'subject'   => $self->searchname,
292     'nobody'    => 1,
293     'mimeparts' => [ $part ],
294   );
295
296   $log->debug('Sending to '.$user_email);
297   $error = send_email(%email_param);
298
299   # update the timestamp
300   $self->set('last_sent', time);
301   $error ||= $self->replace;
302   if ($error) {
303     $log->error($error);
304     return $error;
305   }
306
307 }
308
309 sub queueable_send {
310   my $searchnum = shift;
311   my $self = FS::saved_search->by_key($searchnum)
312     or die "searchnum $searchnum not found\n";
313   $self->send;
314 }
315
316 #3.x
317 sub access_user {
318   my $self = shift;
319   qsearchs('access_user', { 'usernum' => $self->usernum });
320 }
321
322 =back
323
324 =head1 SEE ALSO
325
326 L<FS::Record>
327
328 =cut
329
330 1;
331