RT#38217: Send email when logging conditions are met
[freeside.git] / FS / FS / log.pm
1 package FS::log;
2
3 use strict;
4 use base qw( FS::Record );
5 use FS::Record qw( qsearch qsearchs dbdef );
6 use FS::UID qw( dbh driver_name );
7 use FS::log_context;
8 use FS::log_email;
9
10 =head1 NAME
11
12 FS::log - Object methods for log records
13
14 =head1 SYNOPSIS
15
16   use FS::log;
17
18   $record = new FS::log \%hash;
19   $record = new FS::log { 'column' => 'value' };
20
21   $error = $record->insert;
22
23   $error = $new_record->replace($old_record);
24
25   $error = $record->delete;
26
27   $error = $record->check;
28
29 =head1 DESCRIPTION
30
31 An FS::log object represents a log entry.  FS::log inherits from
32 FS::Record.  The following fields are currently supported:
33
34 =over 4
35
36 =item lognum - primary key
37
38 =item _date - Unix timestamp
39
40 =item agentnum - L<FS::agent> to which the log pertains.  If it involves a 
41 specific customer, package, service, invoice, or other agent-specific object,
42 this will be set to that agentnum.
43
44 =item tablename - table name to which the log pertains, if any.
45
46 =item tablenum - foreign key to that table.
47
48 =item level - log level: 'debug', 'info', 'notice', 'warning', 'error', 
49 'critical', 'alert', 'emergency'.
50
51 =item message - contents of the log entry
52
53 =back
54
55 =head1 METHODS
56
57 =over 4
58
59 =item new HASHREF
60
61 Creates a new log entry.  Use FS::Log instead of calling this directly, 
62 please.
63
64 =cut
65
66 sub table { 'log'; }
67
68 =item insert [ CONTEXT... ]
69
70 Adds this record to the database.  If there is an error, returns the error,
71 otherwise returns false.
72
73 CONTEXT may be a list of context tags to attach to this record.
74
75 Will send emails according to the conditions in L<FS::log_email>.
76
77 =cut
78
79 sub insert {
80   # not using process_o2m for this, because we don't have a web interface
81   my $self = shift;
82   my $error = $self->SUPER::insert;
83   return $error if $error;
84   my $contexts = {}; #for quick checks when sending emails
85   foreach ( @_ ) {
86     my $context = FS::log_context->new({
87         'lognum'  => $self->lognum,
88         'context' => $_
89     });
90     $error = $context->insert;
91     return $error if $error;
92     $contexts->{$_} = 1;
93   }
94   foreach my $log_email (
95     qsearch('log_email',
96       {
97         'disabled' => '',
98         'min_level' => {
99           'op' => '<=',
100           'value' => $self->level,
101         },
102       }
103     )
104   ) {
105     # shouldn't be a lot of these, so not packing this into the qsearch
106     next if $log_email->context && !$contexts->{$log_email->context};
107     my $msg_template = qsearchs('msg_template',{ 'msgnum' => $log_email->msgnum });
108     unless ($msg_template) {
109       warn "Could not send email when logging, could not load message template for logemailnum " . $log_email->logemailnum;
110       next;
111     }
112     my $emailerror = $msg_template->send(
113       'to'            => $log_email->to_addr,
114       'substitutions' => {
115         'loglevel'   => $FS::Log::LEVELS[$self->level], # which has hopefully been loaded...
116         'logcontext' => $log_email->context, # use the one that triggered the email
117         'logmessage' => $self->message,
118       },
119     );
120     warn "Could not send email when logging: $emailerror" if $emailerror;
121   }
122   '';
123 }
124
125 # these methods can be inherited from FS::Record
126
127 sub delete  { die "Log entries can't be modified." };
128
129 sub replace { die "Log entries can't be modified." };
130
131 =item check
132
133 Checks all fields to make sure this is a valid example.  If there is
134 an error, returns the error, otherwise returns false.  Called by the insert
135 and replace methods.
136
137 =cut
138
139 sub check {
140   my $self = shift;
141
142   my $error = 
143     $self->ut_numbern('lognum')
144     || $self->ut_number('_date')
145     || $self->ut_numbern('agentnum')
146     || $self->ut_foreign_keyn('agentnum', 'agent', 'agentnum')
147     || $self->ut_textn('tablename')
148     || $self->ut_numbern('tablenum')
149     || $self->ut_number('level')
150     || $self->ut_text('message')
151   ;
152   return $error if $error;
153
154   if ( my $tablename = $self->tablename ) {
155     my $dbdef_table = dbdef->table($tablename)
156       or return "tablename '$tablename' does not exist";
157     $error = $self->ut_foreign_key('tablenum',
158                                    $tablename,
159                                    $dbdef_table->primary_key);
160     return $error if $error;
161   }
162
163   $self->SUPER::check;
164 }
165
166 =item context
167
168 Returns the context for this log entry, as an array, from least to most
169 specific.
170
171 =cut
172
173 sub context {
174   my $self = shift;
175   map { $_->context } qsearch({
176       table     => 'log_context',
177       hashref   => { lognum => $self->lognum },
178       order_by  => 'ORDER BY logcontextnum ASC',
179   });
180 }
181
182 =back
183
184 =head1 CLASS METHODS
185
186 =over 4
187
188 =item search HASHREF
189
190 Returns a qsearch hash expression to search for parameters specified in 
191 HASHREF.  Valid parameters are:
192
193 =over 4
194
195 =item agentnum
196
197 =item date - arrayref of start and end date
198
199 =item level - either a specific level, or an arrayref of min and max level
200
201 =item context - a context string that the log entry must have.  This may 
202 change in the future to allow searching for combinations of context strings.
203
204 =item object - any database object, to find log entries related to it.
205
206 =item tablename, tablenum - alternate way of specifying 'object'.
207
208 =item custnum - a customer number, to find log entries related to the customer
209 or any of their subordinate objects (invoices, packages, etc.).
210
211 =item message - a text string to search in messages.  The search will be 
212 a case-insensitive LIKE with % appended at both ends.
213
214 =back
215
216 =cut
217
218 # used for custnum search: all tables with custnums
219 my @table_stubs;
220
221 sub _setup_table_stubs {
222   foreach my $table (
223     qw( 
224     contact
225     cust_attachment
226     cust_bill
227     cust_credit
228     cust_location
229     cust_main
230     cust_main_exemption
231     cust_main_note
232     cust_msg
233     cust_pay
234     cust_pay_batch
235     cust_pay_pending
236     cust_pay_void
237     cust_pkg
238     cust_refund
239     cust_statement
240     cust_tag
241     cust_tax_adjustment
242     cust_tax_exempt
243     did_order_item
244     qual
245     queue ) )
246   {
247     my $pkey = dbdef->table($table)->primary_key;
248     push @table_stubs,
249       "log.tablename = '$table' AND ".
250       "EXISTS(SELECT 1 FROM $table WHERE log.tablenum = $table.$pkey AND ".
251       "$table.custnum = "; # needs a closing )
252   }
253   # plus this case
254   push @table_stubs,
255       "(log.tablename LIKE 'svc_%' OR log.tablename = 'cust_svc') AND ".
256       "EXISTS(SELECT 1 FROM cust_svc JOIN cust_pkg USING (svcnum) WHERE ".
257       "cust_pkg.custnum = "; # needs a closing )
258 }
259
260 sub search {
261   my ($class, $params) = @_;
262   my @where;
263
264   ##
265   # parse agent
266   ##
267
268   if ( $params->{'agentnum'} =~ /^(\d+)$/ ) {
269     push @where,
270       "log.agentnum = $1";
271   }
272
273   ##
274   # parse custnum
275   ##
276
277   if ( $params->{'custnum'} =~ /^(\d+)$/ ) {
278     _setup_table_stubs() unless @table_stubs;
279     my $custnum = $1;
280     my @orwhere = map { "( $_ $custnum) )" } @table_stubs;
281     push @where, join(' OR ', @orwhere);
282   }
283
284   ##
285   # parse level
286   ##
287
288   if ( ref $params->{'level'} eq 'ARRAY' ) {
289     my ($min, $max) = @{ $params->{'level'} };
290     if ( $min =~ /^\d+$/ ) {
291       push @where, "log.level >= $min";
292     }
293     if ( $max =~ /^\d+$/ ) {
294       push @where, "log.level <= $max";
295     }
296   } elsif ( $params->{'level'} =~ /^(\d+)$/ ) {
297     push @where, "log.level = $1";
298   }
299
300   ##
301   # parse date
302   ##
303
304   if ( ref $params->{'date'} eq 'ARRAY' ) {
305     my ($beg, $end) = @{ $params->{'date'} };
306     if ( $beg =~ /^\d+$/ ) {
307       push @where, "log._date >= $beg";
308     }
309     if ( $end =~ /^\d+$/ ) {
310       push @where, "log._date <= $end";
311     }
312   }
313
314   ##
315   # parse object
316   ##
317
318   if ( $params->{'object'} and $params->{'object'}->isa('FS::Record') ) {
319     my $table = $params->{'object'}->table;
320     my $pkey = dbdef->table($table)->primary_key;
321     my $tablenum = $params->{'object'}->get($pkey);
322     if ( $table and $tablenum ) {
323       push @where, "log.tablename = '$table'", "log.tablenum = $tablenum";
324     }
325   } elsif ( $params->{'tablename'} =~ /^(\w+)$/ ) {
326     my $table = $1;
327     if ( $params->{'tablenum'} =~ /^(\d+)$/ ) {
328       push @where, "log.tablename = '$table'", "log.tablenum = $1";
329     }
330   }
331
332   ##
333   # parse message
334   ##
335
336   if ( $params->{'message'} ) { # can be anything, really, so escape it
337     my $quoted_message = dbh->quote('%' . $params->{'message'} . '%');
338     my $op = (driver_name eq 'Pg' ? 'ILIKE' : 'LIKE');
339     push @where, "log.message $op $quoted_message";
340   }
341
342   ##
343   # parse context
344   ##
345
346   if ( $params->{'context'} ) {
347     my $quoted = dbh->quote($params->{'context'});
348     push @where, 
349       "EXISTS(SELECT 1 FROM log_context WHERE log.lognum = log_context.lognum ".
350       "AND log_context.context = $quoted)";
351   }
352
353   # agent virtualization
354   my $access_user = $FS::CurrentUser::CurrentUser;
355   push @where, $access_user->agentnums_sql(
356     table => 'log',
357     viewall_right => 'Configuration',
358     null => 1,
359   );
360
361   # put it together
362   my $extra_sql = '';
363   $extra_sql .= 'WHERE ' . join(' AND ', @where) if @where;
364   my $count_query = 'SELECT COUNT(*) FROM log '.$extra_sql;
365   my $sql_query = {
366     'table'         => 'log',
367     'hashref'       => {},
368     'select'        => 'log.*',
369     'extra_sql'     => $extra_sql,
370     'count_query'   => $count_query,
371     'order_by'      => 'ORDER BY _date ASC',
372     #addl_from, not needed
373   };
374 }
375
376 =back
377
378 =head1 BUGS
379
380 =head1 SEE ALSO
381
382 L<FS::Record>, schema.html from the base documentation.
383
384 =cut
385
386 1;
387