Merge branch 'patch-6' of https://github.com/gjones2/Freeside (#13854 as this bug...
authorIvan Kohler <ivan@freeside.biz>
Thu, 20 Dec 2012 06:48:59 +0000 (22:48 -0800)
committerIvan Kohler <ivan@freeside.biz>
Thu, 20 Dec 2012 06:48:59 +0000 (22:48 -0800)
141 files changed:
FS/FS/AccessRight.pm
FS/FS/ClientAPI/PrepaidPhone.pm
FS/FS/Conf.pm
FS/FS/Cron/bill.pm
FS/FS/Cron/upload.pm
FS/FS/Log.pm [new file with mode: 0644]
FS/FS/Log/Output.pm [new file with mode: 0644]
FS/FS/Mason.pm
FS/FS/Schema.pm
FS/FS/cust_bill_pkg.pm
FS/FS/cust_credit.pm
FS/FS/cust_location.pm
FS/FS/cust_main/Billing.pm
FS/FS/cust_main/Import.pm
FS/FS/cust_main/Packages.pm
FS/FS/cust_main_county.pm
FS/FS/cust_pkg.pm
FS/FS/cust_svc.pm
FS/FS/log.pm [new file with mode: 0644]
FS/FS/log_context.pm [new file with mode: 0644]
FS/FS/part_event/Condition/pkg_dundate.pm
FS/FS/part_export.pm
FS/FS/part_export/acct_http.pm
FS/FS/part_export/acct_xmlrpc.pm
FS/FS/part_export/broadband_http.pm
FS/FS/part_export/broadband_snmp.pm
FS/FS/part_export/fibernetics_did.pm [new file with mode: 0644]
FS/FS/part_export/http.pm
FS/FS/part_export/sqlradius.pm
FS/FS/part_export/vitelity.pm
FS/FS/pay_batch/eft_canada.pm
FS/FS/svc_IP_Mixin.pm
FS/FS/svc_Radius_Mixin.pm
FS/MANIFEST
FS/bin/freeside-daily
FS/bin/freeside-ipifony-download [new file with mode: 0644]
FS/bin/freeside-monthly
FS/bin/freeside-queued
FS/t/log.t [new file with mode: 0644]
FS/t/log_context.t [new file with mode: 0644]
fs_selfservice/FS-SelfService/cgi/promocode.html
fs_selfservice/FS-SelfService/cgi/selfservice.cgi
htetc/freeside-base2.conf
httemplate/browse/agent_type.cgi
httemplate/browse/part_export.cgi
httemplate/edit/agent_type.cgi
httemplate/edit/cdr_type.cgi
httemplate/edit/credit-cust_bill_pkg.html [new file with mode: 0644]
httemplate/edit/cust_credit.cgi
httemplate/edit/elements/part_export/broadband_snmp.html [new file with mode: 0644]
httemplate/edit/elements/part_export/foot.html [new file with mode: 0644]
httemplate/edit/elements/part_export/head.html [new file with mode: 0644]
httemplate/edit/part_export.cgi
httemplate/edit/part_pkg.cgi
httemplate/edit/process/cdr_type.cgi
httemplate/edit/process/credit-cust_bill_pkg.html [new file with mode: 0644]
httemplate/edit/process/cust_credit.cgi
httemplate/edit/process/part_export.cgi
httemplate/edit/rate_time.cgi
httemplate/elements/auto-table.html
httemplate/elements/menu.html
httemplate/elements/searchbar-cust_svc.html
httemplate/elements/searchbar-prospect.html
httemplate/elements/searchbar-ticket.html
httemplate/elements/select-did.html
httemplate/elements/select-mib-popup.html [new file with mode: 0644]
httemplate/elements/select-phonenum.html
httemplate/elements/select-region.html [new file with mode: 0644]
httemplate/elements/select-terms.html
httemplate/elements/standardize_locations.js
httemplate/elements/xmlhttp.html
httemplate/graph/elements/report.html
httemplate/misc/phonenums.cgi
httemplate/misc/regions.cgi [new file with mode: 0644]
httemplate/misc/xmlhttp-address_standardize.html
httemplate/misc/xmlhttp-cust_bill_pkg-calculate_taxes.html [new file with mode: 0644]
httemplate/misc/xmlhttp-mib-browse.html [new file with mode: 0644]
httemplate/search/cust_bill_pkg.cgi
httemplate/search/cust_credit_bill_pkg.html
httemplate/search/cust_pkg_susp.html
httemplate/search/customer_accounting_summary.html
httemplate/search/elements/search-html.html
httemplate/search/elements/search-xls.html
httemplate/search/elements/search.html
httemplate/search/log.html [new file with mode: 0644]
httemplate/search/report_cust_bill_pkg.html [new file with mode: 0644]
httemplate/search/report_cust_credit_bill_pkg.html [new file with mode: 0644]
httemplate/search/report_tax-xls.cgi
httemplate/view/cust_main/payment_history.html
httemplate/view/part_event-targets.html
rt/Makefile.in
rt/configure
rt/configure.ac
rt/devel/tools/apache.conf [deleted file]
rt/docs/UPGRADING-2.0
rt/docs/UPGRADING-3.0
rt/docs/UPGRADING-3.2
rt/docs/UPGRADING-3.4
rt/docs/UPGRADING-3.6
rt/docs/UPGRADING-3.8
rt/docs/UPGRADING-4.0
rt/docs/UPGRADING.mysql
rt/etc/RT_Config.pm.in
rt/etc/upgrade/3.8.4/content
rt/lib/RT/Action/SendEmail.pm
rt/lib/RT/Approval/Rule/Passed.pm
rt/lib/RT/Article.pm
rt/lib/RT/Attachment.pm
rt/lib/RT/Crypt/GnuPG.pm
rt/lib/RT/Generated.pm
rt/lib/RT/Handle.pm
rt/lib/RT/Interface/Email.pm
rt/lib/RT/Interface/Email/Auth/GnuPG.pm
rt/lib/RT/Interface/Web.pm
rt/lib/RT/Interface/Web/Menu.pm
rt/lib/RT/Pod/HTML.pm [new file with mode: 0644]
rt/lib/RT/Pod/HTMLBatch.pm [new file with mode: 0644]
rt/lib/RT/Pod/Search.pm [new file with mode: 0644]
rt/lib/RT/Queue.pm
rt/lib/RT/Record.pm
rt/lib/RT/Template.pm
rt/lib/RT/Ticket.pm
rt/lib/RT/User.pm
rt/sbin/rt-fulltext-indexer
rt/sbin/rt-fulltext-indexer.in
rt/sbin/rt-test-dependencies.in
rt/sbin/rt-validate-aliases.in [new file with mode: 0644]
rt/share/html/Admin/Groups/Modify.html
rt/share/html/Admin/Queues/Modify.html
rt/share/html/Admin/Users/GnuPG.html
rt/share/html/Elements/CSRF
rt/share/html/Elements/GnuPG/SignEncryptWidget
rt/share/html/Elements/Login
rt/share/html/Elements/LoginRedirectWarning [new file with mode: 0644]
rt/share/html/Elements/Tabs
rt/share/html/NoAuth/css/base/login.css
rt/share/html/NoAuth/iCal/dhandler
rt/share/html/Ticket/Elements/ShowMessageHeaders
rt/t/mail/gnupg-incoming.t
rt/t/web/crypt-gnupg.t
rt/t/web/ticket_forward.t

index b38c267..66624e1 100644 (file)
@@ -277,6 +277,7 @@ tie my %rights, 'Tie::IxHash',
     'Financial reports',
     { rightname=> 'List inventory', global=>1 },
     { rightname=>'View email logs', global=>1 },
+    { rightname=>'View system logs' },
 
     'Download report data',
     'Services: Accounts',
index c346179..c7317ea 100644 (file)
@@ -3,6 +3,7 @@ package FS::ClientAPI::PrepaidPhone;
 use strict;
 use vars qw($DEBUG $me);
 use FS::Record qw(qsearchs);
+use FS::Conf;
 use FS::rate;
 use FS::svc_phone;
 
@@ -156,11 +157,15 @@ sub call_time {
     return \%return;
   }
 
+  my $conf = new FS::Conf;
+  my $balance = $conf->config_bool('pkg-balances') ? $cust_pkg->balance
+                                                   : $cust_main->balance;
+
   #XXX granularity?  included minutes?  another day...
-  if ( $cust_main->balance >= 0 ) {
+  if ( $balance >= 0 ) {
     return { 'error'=>'No balance' };
   } else {
-    $return{'seconds'} = int(60 * abs($cust_main->balance) / $rate_detail->min_charge);
+    $return{'seconds'} = int(60 * abs($balance) / $rate_detail->min_charge);
   }
 
   warn "$me returning seconds: ". $return{'seconds'};
@@ -248,13 +253,18 @@ sub phonenum_balance {
 
   my $cust_pkg = $svc_phone->cust_svc->cust_pkg;
 
-  warn "$me returning ". $cust_pkg->cust_main->balance.
-       " balance for custnum ". $cust_pkg->custnum
+  my $conf = new FS::Conf;
+  my $balance = $conf->config_bool('pkg-balances')
+    ? $cust_pkg->balance
+    : $cust_pkg->cust_main->balance;
+
+  warn "$me returning $balance balance for pkgnum ".  $cust_pkg->pkgnum.
+                                        ", custnum ". $cust_pkg->custnum
     if $DEBUG;
 
   return {
     'custnum' => $cust_pkg->custnum,
-    'balance' => $cust_pkg->cust_main->balance,
+    'balance' => $balance,
   };
 
 }
index 7f3fcaa..d11916f 100644 (file)
@@ -749,6 +749,15 @@ sub reason_type_options {
   },
 
   {
+    'key'         => 'event_log_level',
+    'section'     => 'notification',
+    'description' => 'Store events in the internal log if they are at least this severe.  "info" is the default, "debug" is very detailed and noisy.',
+    'type'        => 'select',
+    'select_enum' => [ '', 'debug', 'info', 'notice', 'warning', 'error', ],
+    # don't bother with higher levels
+  },
+
+  {
     'key'         => 'log_sent_mail',
     'section'     => 'notification',
     'description' => 'Enable logging of template-generated email.',
@@ -1478,7 +1487,7 @@ and customer address. Include units.',
     'section'     => 'invoicing',
     'description' => 'Optional default invoice term, used to calculate a due date printed on invoices.',
     'type'        => 'select',
-    'select_enum' => [ '', 'Payable upon receipt', 'Net 0', 'Net 3', 'Net 9', 'Net 10', 'Net 15', 'Net 20', 'Net 21', 'Net 30', 'Net 45', 'Net 60', 'Net 90' ],
+    'select_enum' => [ '', 'Payable upon receipt', 'Net 0', 'Net 3', 'Net 9', 'Net 10', 'Net 15', 'Net 18', 'Net 20', 'Net 21', 'Net 30', 'Net 45', 'Net 60', 'Net 90' ],
   },
 
   { 
index a9df376..6e110e8 100644 (file)
@@ -13,6 +13,8 @@ use FS::cust_main;
 use FS::part_event;
 use FS::part_event_condition;
 
+use FS::Log;
+
 @ISA = qw( Exporter );
 @EXPORT_OK = qw ( bill bill_where );
 
@@ -27,6 +29,9 @@ use FS::part_event_condition;
 sub bill {
   my %opt = @_;
 
+  my $log = FS::Log->new('Cron::bill');
+  $log->info('start');
+
   my $check_freq = $opt{'check_freq'} || '1d';
 
   my $debug = 0;
@@ -134,6 +139,7 @@ sub bill {
 
   $cursor_dbh->commit or die $cursor_dbh->errstr;
 
+  $log->info('finish');
 }
 
 # freeside-daily %opt:
index 08819fc..628c680 100644 (file)
@@ -9,6 +9,7 @@ use FS::Record qw( qsearch qsearchs );
 use FS::Conf;
 use FS::queue;
 use FS::agent;
+use FS::Log;
 use FS::Misc qw( send_email ); #for bridgestone
 use FS::upload_target;
 use LWP::UserAgent;
@@ -33,6 +34,8 @@ $me = '[FS::Cron::upload]';
 
 sub upload {
   my %opt = @_;
+  my $log = FS::Log->new('Cron::upload');
+  $log->info('start');
 
   my $debug = 0;
   $debug = 1 if $opt{'v'};
@@ -95,6 +98,32 @@ sub upload {
     }
   } # foreach @agents
 
+  # if there's nothing to do, don't hold up the rest of the process
+  if (!@tasks) {
+    $log->info('finish (nothing to upload)');
+    return '';
+  }
+
+  # wait for any ongoing billing jobs to complete
+  if ($opt{m}) {
+    my $dbh = dbh;
+    my $sql = "SELECT count(*) FROM queue LEFT JOIN cust_main USING(custnum) ".
+    "WHERE queue.job='FS::cust_main::queued_bill' AND status != 'failed'";
+    if (@agents) {
+      $sql .= ' AND cust_main.agentnum IN('.
+        join(',', map {$_->agentnum} @agents).
+        ')';
+    }
+    my $sth = $dbh->prepare($sql) or die $dbh->errstr;
+    while (1) {
+      $sth->execute()
+        or die "Unexpected error executing statement $sql: ". $sth->errstr;
+      last if $sth->fetchrow_arrayref->[0] == 0;
+      warn "Waiting 5min for billing to complete...\n" if $DEBUG;
+      sleep 300;
+    }
+  }
+
   foreach (@tasks) {
 
     my $agentnum = $_->{agentnum};
@@ -119,11 +148,13 @@ sub upload {
     }
 
   }
+  $log->info('finish');
 
 }
 
 sub spool_upload {
   my %opt = @_;
+  my $log = FS::Log->new('spool_upload');
 
   warn "$me spool_upload called\n" if $DEBUG;
   my $conf = new FS::Conf;
@@ -143,6 +174,8 @@ sub spool_upload {
   my $dbh = dbh;
 
   my $agentnum = $opt{agentnum};
+  $log->debug('start', agentnum => $agentnum);
+
   my $agent;
   if ( $agentnum ) {
     $agent = qsearchs( 'agent', { agentnum => $agentnum } )
@@ -160,6 +193,8 @@ sub spool_upload {
     {
       warn "$me neither $dir/$file-header.csv nor ".
            "$dir/$file-detail.csv found\n" if $DEBUG > 1;
+      $log->debug("finish (neither $file-header.csv nor ".
+           "$file-detail.csv found)");
       $dbh->commit or die $dbh->errstr if $oldAutoCommit;
       return;
     }
@@ -170,19 +205,6 @@ sub spool_upload {
     my $username = $opt{username} or die "no username for agent $agentnum\n";
     my $password = $opt{password} or die "no password for agent $agentnum\n";
 
-    # a better way?
-    if ($opt{m}) {
-      my $sql = "SELECT count(*) FROM queue LEFT JOIN cust_main USING(custnum) ".
-        "WHERE queue.job='FS::cust_main::queued_bill' AND cust_main.agentnum = ?";
-      my $sth = $dbh->prepare($sql) or die $dbh->errstr;
-      while (1) {
-        $sth->execute( $agentnum )
-          or die "Unexpected error executing statement $sql: ". $sth->errstr;
-        last if $sth->fetchrow_arrayref->[0];
-        sleep 300;
-      }
-    }
-
     foreach ( qw ( header detail ) ) {
       rename "$dir/$file-$_.csv",
              "$dir/$file-$date-$_.csv";
@@ -252,6 +274,7 @@ sub spool_upload {
 
     unless ( -f "$dir/$file.csv" ) {
       warn "$me $dir/$file.csv not found\n" if $DEBUG > 1;
+      $log->debug("finish ($dir/$file.csv not found)");
       $dbh->commit or die $dbh->errstr if $oldAutoCommit;
       return;
     }
@@ -326,14 +349,16 @@ sub spool_upload {
         }
       }
 
-      send_report('bridgestone-confirm_template',
-        {
-          agentnum=> $agentnum,
-          zipfile => $zipfile,
-          prefix  => $prefix,
-          seq     => $seq,
-          rows    => $rows,
-        }
+      send_email(
+        prepare_report('bridgestone-confirm_template',
+          {
+            agentnum=> $agentnum,
+            zipfile => $zipfile,
+            prefix  => $prefix,
+            seq     => $seq,
+            rows    => $rows,
+          }
+        )
       );
 
       $seq++;
@@ -376,16 +401,26 @@ sub spool_upload {
       close $reg;
       close $big;
 
+      # zip up all three files for transport
       my $zipfile = "$basename" . '.zip';
       my $command = "cd $dir; zip $zipfile $regfile $bigfile";
       system($command) and die "'$command' failed\n";
-      my $error = $upload_target->put("$dir/$zipfile");
 
+      # upload them, unless we're using email, in which case 
+      # the zip file will ride along with the report.  yes, this 
+      # kind of defeats the purpose of the upload_target interface,
+      # but at least we have a place to store the configuration.
+      my $error = '';
+      if ( $upload_target->protocol ne 'email' ) {
+        $error = $upload_target->put("$dir/$zipfile");
+      }
+
+      # create the report
       for (values %sum) {
         $_ = sprintf('%.2f', $_);
       }
 
-      send_report('ics-confirm_template',
+      my %report = prepare_report('ics-confirm_template',
         {
           agentnum  => $agentnum,
           count     => \%count,
@@ -393,8 +428,23 @@ sub spool_upload {
           error     => $error,
         }
       );
+      if ( $upload_target->protocol eq 'email' ) {
+        $report{'to'} =
+          join('@', $upload_target->username, $upload_target->hostname);
+        $report{'subject'} = $upload_target->subject;
+        $report{'mimeparts'} = [
+          { Path        => "$dir/$zipfile",
+            Type        => 'application/zip',
+            Encoding    => 'base64',
+            Filename    => $zipfile,
+            Disposition => 'attachment',
+          }
+        ];
+      }
+      $error = send_email(%report);
 
       if ( $error ) {
+        # put the original spool file back
         rename "$dir/$file-$date.csv", "$dir/$file.csv";
         die $error;
       }
@@ -413,6 +463,8 @@ sub spool_upload {
 
   } #opt{handling}
 
+  $log->debug('finish', agentnum => $agentnum);
+
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
   '';
 
@@ -421,7 +473,8 @@ sub spool_upload {
 =item send_report CONFIG PARAMS
 
 Retrieves the config value named CONFIG, parses it as a Text::Template,
-extracts "to" and "subject" headers, and sends it by email.
+extracts "to" and "subject" headers, and returns a hash that can be passed
+to L<FS::Misc::send_email>.
 
 PARAMS is a hashref to be passed to C<fill_in>.  It must contain 
 'agentnum' to look up the per-agent config.
@@ -429,7 +482,8 @@ PARAMS is a hashref to be passed to C<fill_in>.  It must contain
 =cut
 
 # we used it twice, so it's now a subroutine
-sub send_report {
+
+sub prepare_report {
 
   my ($config, $params) = @_;
   my $agentnum = $params->{agentnum};
@@ -452,7 +506,7 @@ sub send_report {
   $head =~ /^to:\s*(.*)$/im;
   my $to = $1;
 
-  send_email(
+  (
     to      => $to,
     from    => $conf->config('invoice_from', $agentnum),
     subject => $subject,
diff --git a/FS/FS/Log.pm b/FS/FS/Log.pm
new file mode 100644 (file)
index 0000000..b11630b
--- /dev/null
@@ -0,0 +1,103 @@
+package FS::Log;
+
+use base 'Log::Dispatch';
+use FS::Record qw(qsearch qsearchs);
+use FS::Conf;
+use FS::Log::Output;
+use FS::log;
+use vars qw(@STACK @LEVELS);
+
+# override the stringification of @_ with something more sensible.
+BEGIN {
+  @LEVELS = qw(debug info notice warning error critical alert emergency);
+
+  foreach my $l (@LEVELS) {
+    my $sub = sub {
+      my $self = shift;
+      $self->log( level => $l, message => @_ );
+    };
+    no strict 'refs';
+    *{$l} = $sub;
+  }
+}
+
+=head1 NAME
+
+FS::Log - Freeside event log
+
+=head1 SYNOPSIS
+
+use FS::Log;
+
+sub do_something {
+  my $log = FS::Log->new('do_something'); # set log context to 'do_something'
+
+  ...
+  if ( $error ) {
+    $log->error('something is wrong: '.$error);
+    return $error;
+  }
+  # at this scope exit, do_something is removed from context
+}
+
+=head1 DESCRIPTION
+
+FS::Log provides an interface for logging errors and profiling information
+to the database.  FS::Log inherits from L<Log::Dispatch>.
+
+=head1 CLASS METHODS
+
+=over 4
+
+new CONTEXT
+
+Constructs and returns a log handle.  CONTEXT must be a known context tag
+indicating what activity is going on, such as the name of the function or
+script that is executing.
+
+Log context is a stack, and each element is removed from the stack when it
+goes out of scope.  So don't keep log handles in persistent places (i.e. 
+package variables or class-scoped lexicals).
+
+=cut
+
+sub new {
+  my $class = shift;
+  my $context = shift;
+
+  my $min_level = FS::Conf->new->config('event_log_level') || 'info';
+
+  my $self = $class->SUPER::new(
+    outputs => [ [ '+FS::Log::Output', min_level => $min_level ] ],
+  );
+  $self->{'index'} = scalar(@STACK);
+  push @STACK, $context;
+  return $self;
+}
+
+=item context
+
+Returns the current context stack.
+
+=cut
+
+sub context { @STACK };
+
+=item log LEVEL, MESSAGE[, OPTIONS ]
+
+Like L<Log::Dispatch::log>, but OPTIONS may include:
+
+- agentnum
+- object (an <FS::Record> object to reference in this log message)
+- tablename and tablenum (an alternate way of specifying 'object')
+
+=cut
+
+# inherited
+
+sub DESTROY {
+  my $self = shift;
+  splice(@STACK, $self->{'index'}, 1); # delete the stack entry
+}
+
+1;
diff --git a/FS/FS/Log/Output.pm b/FS/FS/Log/Output.pm
new file mode 100644 (file)
index 0000000..18d7f1b
--- /dev/null
@@ -0,0 +1,50 @@
+package FS::Log::Output;
+
+use base Log::Dispatch::Output;
+use FS::Record qw( dbdef );
+
+sub new { # exactly by the book
+  my $proto = shift;
+  my $class = ref $proto || $proto;
+
+  my %p = @_;
+
+  my $self = bless {}, $class;
+
+  $self->_basic_init(%p);
+
+  return $self;
+}
+
+sub log_message {
+  my $self = shift;
+  my %m = @_;
+
+  my $object = $m{'object'};
+  my ($tablename, $tablenum) = @m{'tablename', 'tablenum'};
+  if ( $object and $object->isa('FS::Record') ) {
+    $tablename = $object->table;
+    $tablenum = $object->get( dbdef->table($tablename)->primary_key );
+
+    # get the agentnum from the object if it has one
+    $m{'agentnum'} ||= $object->get('agentnum');
+    # maybe FS::cust_main_Mixin objects should use the customer's agentnum?
+    # I'm trying not to do database lookups in here, though.
+  }
+
+  my $entry = FS::log->new({
+      _date     => time,
+      agentnum  => $m{'agentnum'},
+      tablename => ($tablename || ''),
+      tablenum  => ($tablenum || ''),
+      level     => $self->_level_as_number($m{'level'}),
+      message   => $m{'message'},
+  });
+  my $error = $entry->insert( FS::Log->context );
+  if ( $error ) {
+    # guh?
+    warn "Error writing log entry: $error";
+  }
+}
+
+1;
index 944a483..2bc1596 100644 (file)
@@ -56,6 +56,8 @@ if ( -e $addl_handler_use_file ) {
   #use CGI::Carp qw(fatalsToBrowser);
   use CGI::Cookie;
   use List::Util qw( max min sum );
+  use List::MoreUtils qw( first_index uniq );
+  use Scalar::Util qw( blessed );
   use Data::Dumper;
   use Date::Format;
   use Time::Local;
@@ -82,6 +84,7 @@ if ( -e $addl_handler_use_file ) {
   use IO::Handle;
   use IO::File;
   use IO::Scalar;
+  use IO::String;
   #not actually using this yet anyway...# use IPC::Run3 0.036;
   use Net::Whois::Raw qw(whois);
   if ( $] < 5.006 ) {
@@ -327,6 +330,8 @@ if ( -e $addl_handler_use_file ) {
   use FS::agent_pkg_class;
   use FS::svc_export_machine;
   use FS::GeocodeCache;
+  use FS::log;
+  use FS::log_context;
   # Sammath Naur
 
   if ( $FS::Mason::addl_handler_use ) {
index 9eb59a0..172ac82 100644 (file)
@@ -191,6 +191,7 @@ sub dbdef_dist {
   foreach my $table (
     grep {    ! /^clientapi_session/
            && ! /^h_/
+           && ! /^log(_context)?$/
            && ! $tables_hashref_torrus->{$_}
          }
       $dbdef->tables
@@ -3972,6 +3973,32 @@ sub tables_hashref {
       'index' => [],
     },
 
+    'log' => {
+      'columns' => [
+        'lognum',     'serial', '', '', '', '',
+        '_date',      'int', '', '', '', '',
+        'agentnum',   'int', 'NULL', '', '', '',
+        'tablename',  'varchar', 'NULL', $char_d, '', '',
+        'tablenum',   'int',  'NULL', '', '', '', 
+        'level',      'int',  '', '', '', '',
+        'message',    'text', '', '', '', '',
+      ],
+      'primary_key' => 'lognum',
+      'unique'      => [],
+      'index'       => [ ['_date'], ['level'] ],
+    },
+
+    'log_context' => {
+      'columns' => [
+        'logcontextnum', 'serial', '', '', '', '',
+        'lognum', 'int', '', '', '', '',
+        'context', 'varchar', '', 32, '', '',
+      ],
+      'primary_key' => 'logcontextnum',
+      'unique' => [ [ 'lognum', 'context' ] ],
+      'index' => [],
+    },
+
     %{ tables_hashref_torrus() },
 
     # tables of ours for doing torrus virtual port combining
index 826569b..a83af13 100644 (file)
@@ -665,8 +665,9 @@ sub set_display {
 
 =item disintegrate
 
-Returns a list of cust_bill_pkg objects each with no more than a single class
-(including setup or recur) of charge.
+Returns a hash: keys are "setup", "recur" or usage classnum, values are
+FS::cust_bill_pkg objects, each with no more than a single class (setup or
+recur) of charge.
 
 =cut
 
@@ -843,6 +844,18 @@ sub _X_show_zero {
   $self->cust_pkg->_X_show_zero($what);
 }
 
+=item credited [ BEFORE, AFTER, OPTIONS ]
+
+Returns the sum of credits applied to this item.  Arguments are the same as
+owed_sql/paid_sql/credited_sql.
+
+=cut
+
+sub credited {
+  my $self = shift;
+  $self->scalar_sql('SELECT '. $self->credited_sql(@_).' FROM cust_bill_pkg WHERE billpkgnum = ?', $self->billpkgnum);
+}
+
 =back
 
 =head1 CLASS METHODS
index 6185fc4..dfe55fb 100644 (file)
@@ -172,7 +172,7 @@ sub insert {
 
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
 
-  #false laziness w/ cust_credit::insert
+  #false laziness w/ cust_pay::insert
   if ( $unsuspendauto && $old_balance && $cust_main->balance <= 0 ) {
     my @errors = $cust_main->unsuspend;
     #return 
@@ -618,6 +618,262 @@ sub credited_sql {
   unapplied_sql();
 }
 
+=item credit_lineitems
+
+Example:
+
+  my $error = FS::cust_credit->credit_lineitems(
+
+    #the lineitems to credit
+    'billpkgnums'       => \@billpkgnums,
+    'setuprecurs'       => \@setuprecurs,
+    'amounts'           => \@amounts,
+
+    #the credit
+    'newreasonnum'      => scalar($cgi->param('newreasonnum')),
+    'newreasonnum_type' => scalar($cgi->param('newreasonnumT')),
+    map { $_ => scalar($cgi->param($_)) }
+      #fields('cust_credit')  
+      qw( custnum _date amount reason reasonnum addlinfo ), #pkgnum eventnum
+
+  );
+
+=cut
+
+#maybe i should just be an insert with extra args instead of a class method
+use FS::cust_bill_pkg;
+sub credit_lineitems {
+  my( $class, %arg ) = @_;
+
+  my $curuser = $FS::CurrentUser::CurrentUser;
+
+  #some false laziness w/misc/xmlhttp-cust_bill_pkg-calculate_taxes.html
+
+  my $cust_main = qsearchs({
+    'table'     => 'cust_main',
+    'hashref'   => { 'custnum' => $arg{custnum} },
+    'extra_sql' => ' AND '. $curuser->agentnums_sql,
+  }) or return 'unknown customer';
+
+
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE';
+  local $SIG{QUIT} = 'IGNORE';
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE';
+  local $SIG{PIPE} = 'IGNORE';
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
+  #my @cust_bill_pkg = qsearch({
+  #  'select'    => 'cust_bill_pkg.*',
+  #  'table'     => 'cust_bill_pkg',
+  #  'addl_from' => ' LEFT JOIN cust_bill USING (invnum)  '.
+  #                 ' LEFT JOIN cust_main USING (custnum) ',
+  #  'extra_sql' => ' WHERE custnum = $custnum AND billpkgnum IN ('.
+  #                     join( ',', @{$arg{billpkgnums}} ). ')',
+  #  'order_by'  => 'ORDER BY invnum ASC, billpkgnum ASC',
+  #});
+
+  my $error = '';
+  if ($arg{reasonnum} == -1) {
+
+    $error = 'Enter a new reason (or select an existing one)'
+      unless $arg{newreasonnum} !~ /^\s*$/;
+    my $reason = new FS::reason {
+                   'reason'      => $arg{newreasonnum},
+                   'reason_type' => $arg{newreasonnum_type},
+                 };
+    $error ||= $reason->insert;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "Error inserting reason: $error";
+    }
+    $arg{reasonnum} = $reason->reasonnum;
+  }
+
+  my $cust_credit = new FS::cust_credit ( {
+    map { $_ => $arg{$_} }
+      #fields('cust_credit')
+      qw( custnum _date amount reason reasonnum addlinfo ), #pkgnum eventnum
+  } );
+  $error = $cust_credit->insert;
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return "Error inserting credit: $error";
+  }
+
+  #my $subtotal = 0;
+  my $taxlisthash = {};
+  my %cust_credit_bill = ();
+  my %cust_bill_pkg = ();
+  my %cust_credit_bill_pkg = ();
+  foreach my $billpkgnum ( @{$arg{billpkgnums}} ) {
+    my $setuprecur = shift @{$arg{setuprecurs}};
+    my $amount = shift @{$arg{amounts}};
+
+    my $cust_bill_pkg = qsearchs({
+      'table'     => 'cust_bill_pkg',
+      'hashref'   => { 'billpkgnum' => $billpkgnum },
+      'addl_from' => 'LEFT JOIN cust_bill USING (invnum)',
+      'extra_sql' => 'AND custnum = '. $cust_main->custnum,
+    }) or die "unknown billpkgnum $billpkgnum";
+
+    if ( $setuprecur eq 'setup' ) {
+      $cust_bill_pkg->setup($amount);
+      $cust_bill_pkg->recur(0);
+      $cust_bill_pkg->unitrecur(0);
+      $cust_bill_pkg->type('');
+    } else {
+      $setuprecur = 'recur'; #in case its a usage classnum?
+      $cust_bill_pkg->recur($amount);
+      $cust_bill_pkg->setup(0);
+      $cust_bill_pkg->unitsetup(0);
+    }
+
+    push @{$cust_bill_pkg{$cust_bill_pkg->invnum}}, $cust_bill_pkg;
+
+    #unapply any payments applied to this line item (other credits too?)
+    foreach my $cust_bill_pay_pkg ( $cust_bill_pkg->cust_bill_pay_pkg($setuprecur) ) {
+      $error = $cust_bill_pay_pkg->delete;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return "Error unapplying payment: $error";
+      }
+    }
+
+    #$subtotal += $amount;
+    $cust_credit_bill{$cust_bill_pkg->invnum} += $amount;
+    push @{ $cust_credit_bill_pkg{$cust_bill_pkg->invnum} },
+      new FS::cust_credit_bill_pkg {
+        'billpkgnum' => $cust_bill_pkg->billpkgnum,
+        'amount'     => $amount,
+        'setuprecur' => $setuprecur,
+        'sdate'      => $cust_bill_pkg->sdate,
+        'edate'      => $cust_bill_pkg->edate,
+      };
+
+    my $part_pkg = $cust_bill_pkg->part_pkg;
+    $cust_main->_handle_taxes( $part_pkg,
+                               $taxlisthash,
+                               $cust_bill_pkg,
+                               $cust_bill_pkg->cust_pkg,
+                               $cust_bill_pkg->cust_bill->_date,
+                               $cust_bill_pkg->cust_pkg->pkgpart,
+                             );
+  }
+
+  ###
+  # now loop through %cust_credit_bill and insert those
+  ###
+
+  # (hack to prevent cust_credit_bill_pkg insertion)
+  local($FS::cust_bill_ApplicationCommon::skip_apply_to_lineitems_hack) = 1;
+
+  foreach my $invnum ( sort { $a <=> $b } keys %cust_credit_bill ) {
+
+    #taxes
+
+    if ( @{ $cust_bill_pkg{$invnum} } ) {
+
+      my $listref_or_error = 
+        $cust_main->calculate_taxes( $cust_bill_pkg{$invnum}, $taxlisthash, $cust_bill_pkg{$invnum}->[0]->cust_bill->_date );
+
+      unless ( ref( $listref_or_error ) ) {
+        $dbh->rollback if $oldAutoCommit;
+        return "Error calculating taxes: $listref_or_error";
+      }
+
+      # so, loop through the taxlines, apply just that amount to the tax line
+      #  item (save for later insert) & add to $
+
+      #my @taxlines = ();
+      #my $taxtotal = 0;
+      foreach my $taxline ( @$listref_or_error ) {
+
+        #find equivalent tax line items on the existing invoice
+        # (XXX need a more specific/deterministic way to find these than itemdesc..)
+        my $tax_cust_bill_pkg = qsearchs('cust_bill_pkg', {
+          'invnum'   => $invnum,
+          'pkgnum'   => 0, #$taxline->invnum
+          'itemdesc' => $taxline->desc,
+        });
+
+        my $amount = $taxline->setup;
+        my $desc = $taxline->desc;
+
+        foreach my $location ( $tax_cust_bill_pkg->cust_bill_pkg_tax_Xlocation ) {
+
+          $location->cust_bill_pkg_desc($taxline->desc); #ugh @ that kludge
+
+          #$taxtotal += $location->amount;
+          $amount -= $location->amount;
+
+          #push @taxlines,
+          #  #[ $location->desc, $taxline->setup, $taxlocnum, $taxratelocnum ];
+          #  [ $location->desc, $location->amount, $taxlocnum, $taxratelocnum ];
+          $cust_credit_bill{$invnum} += $location->amount;
+          push @{ $cust_credit_bill_pkg{$invnum} },
+            new FS::cust_credit_bill_pkg {
+              'billpkgnum'                => $tax_cust_bill_pkg->billpkgnum,
+              'amount'                    => $location->amount,
+              'setuprecur'                => 'setup',
+              'billpkgtaxlocationnum'     => $location->billpkgtaxlocationnum,
+              'billpkgtaxratelocationnum' => $location->billpkgtaxratelocationnum,
+            };
+
+        }
+        if ($amount > 0) {
+          #$taxtotal += $amount;
+          #push @taxlines,
+          #  [ $taxline->itemdesc. ' (default)', sprintf('%.2f', $amount), '', '' ];
+
+          $cust_credit_bill{$invnum} += $amount;
+          push @{ $cust_credit_bill_pkg{$invnum} },
+            new FS::cust_credit_bill_pkg {
+              'billpkgnum' => $tax_cust_bill_pkg->billpkgnum,
+              'amount'     => $amount,
+              'setuprecur' => 'setup',
+            };
+
+        }
+      }
+
+    }
+
+    #insert cust_credit_bill
+
+    my $cust_credit_bill = new FS::cust_credit_bill {
+      'crednum' => $cust_credit->crednum,
+      'invnum'  => $invnum,
+      'amount'  => $cust_credit_bill{$invnum},
+    };
+    $error = $cust_credit_bill->insert;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "Error applying credit of $cust_credit_bill{$invnum} ".
+             " to invoice $invnum: $error";
+    }
+
+    #and then insert cust_credit_bill_pkg for each cust_bill_pkg
+    foreach my $cust_credit_bill_pkg ( @{$cust_credit_bill_pkg{$invnum}} ) {
+      $cust_credit_bill_pkg->creditbillnum( $cust_credit_bill->creditbillnum );
+      $error = $cust_credit_bill_pkg->insert;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return "Error applying credit to line item: $error";
+      }
+    }
+
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  '';
+
+}
+
 =back
 
 =head1 BUGS
index 1521960..b86529b 100644 (file)
@@ -324,6 +324,9 @@ sub move_to {
   my $dbh = dbh;
   my $error = '';
 
+  # prevent this from failing because of pkg_svc quantity limits
+  local( $FS::cust_svc::ignore_quantity ) = 1;
+
   if ( !$new->locationnum ) {
     $error = $new->insert;
     if ( $error ) {
index 11247a2..3dc8f9c 100644 (file)
@@ -21,6 +21,7 @@ use FS::cust_bill_pkg_tax_rate_location;
 use FS::part_event;
 use FS::part_event_condition;
 use FS::pkg_category;
+use FS::Log;
 
 # 1 is mostly method/subroutine entry and options
 # 2 traces progress of some operations
@@ -104,6 +105,9 @@ options of those methods are also available.
 sub bill_and_collect {
   my( $self, %options ) = @_;
 
+  my $log = FS::Log->new('bill_and_collect');
+  $log->debug('start', object => $self, agentnum => $self->agentnum);
+
   my $error;
 
   #$options{actual_time} not $options{time} because freeside-daily -d is for
@@ -168,6 +172,7 @@ sub bill_and_collect {
     }
   }
   $job->update_statustext('100,finished') if $job;
+  $log->debug('finish', object => $self, agentnum => $self->agentnum);
 
   '';
 
index eadcc1a..e5a4485 100644 (file)
@@ -22,6 +22,8 @@ install_callback FS::UID sub {
   $conf = new FS::Conf;
 };
 
+my %is_location = map { $_ => 1 } FS::cust_main::Location->location_fields;
+
 =head1 NAME
 
 FS::cust_main::Import - Batch customer importing
@@ -316,13 +318,14 @@ sub batch_import {
       custbatch => $custbatch,
       agentnum  => $agentnum,
       refnum    => $refnum,
-      country   => $conf->config('countrydefault') || 'US',
       payby     => $payby, #default
       paydate   => '12/2037', #default
     );
     my $billtime = time;
     my %cust_pkg = ( pkgpart => $pkgpart );
     my %svc_x = ();
+    my %bill_location = ();
+    my %ship_location = ();
     foreach my $field ( @fields ) {
 
       if ( $field =~ /^cust_pkg\.(pkgpart|setup|bill|susp|adjourn|expire|cancel)$/ ) {
@@ -351,6 +354,14 @@ sub batch_import {
 
         $svc_x{$1} = shift @columns;
 
+      } elsif ( $is_location{$field} ) {
+
+        $bill_location{$field} = shift @columns;
+
+      } elsif ( $field =~ /^ship_(.*)$/ and $is_location{$1} ) {
+
+        $ship_location{$1} = shift @columns;
+      
       } else {
 
         #refnum interception
@@ -379,6 +390,16 @@ sub batch_import {
         my $value = shift @columns;
         $cust_main{$field} = $value if length($value);
       }
+    } # foreach my $field
+    # finished importing columns
+
+    $bill_location{'country'} ||= $conf->config('countrydefault') || 'US';
+    $cust_main{'bill_location'} = FS::cust_location->new(\%bill_location);
+    if ( grep $_, values(%ship_location) ) {
+      $ship_location{'country'} ||= $conf->config('countrydefault') || 'US';
+      $cust_main{'ship_location'} = FS::cust_location->new(\%ship_location);
+    } else {
+      $cust_main{'ship_location'} = $cust_main{'bill_location'};
     }
 
     if ( defined $cust_main{'payinfo'} && length $cust_main{'payinfo'} ) {
index 11c13e5..395cce7 100644 (file)
@@ -58,7 +58,7 @@ action completes (such as running the customer's credit card successfully).
 
 Optional subject for a ticket created and attached to this customer
 
-=item ticket_subject
+=item ticket_queue
 
 Optional queue name for ticket additions
 
index 143f62e..87c1ca7 100644 (file)
@@ -472,8 +472,11 @@ sub taxline {
 
     $_->taxnum($self->taxnum) foreach @new_exemptions;
 
-    if ( $cust_bill_pkg->billpkgnum ) {
-      die "tried to calculate tax exemptions on a previously billed line item\n";
+    #if ( $cust_bill_pkg->billpkgnum ) {
+
+      #no, need to do this to e.g. calculate tax credit amounts
+      #die "tried to calculate tax exemptions on a previously billed line item\n";
+
       # this is unnecessary
 #      foreach my $cust_tax_exempt_pkg (@new_exemptions) {
 #        my $error = $cust_tax_exempt_pkg->insert;
@@ -482,7 +485,7 @@ sub taxline {
 #          return "can't insert cust_tax_exempt_pkg: $error";
 #        }
 #      }
-    }
+    #}
 
     # attach them to the line item
     push @{ $cust_bill_pkg->cust_tax_exempt_pkg }, @new_exemptions;
index 16adea3..22a7b2c 100644 (file)
@@ -2650,6 +2650,18 @@ sub cust_main {
   qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
 }
 
+=item balance
+
+Returns the balance for this specific package, when using
+experimental package balance.
+
+=cut
+
+sub balance {
+  my $self = shift;
+  $self->cust_main->balance_pkgnum( $self->pkgnum );
+}
+
 #these subs are in location_Mixin.pm now... unfortunately the POD doesn't mixin
 
 =item cust_location
@@ -2877,7 +2889,8 @@ sub transfer {
   }
 
   foreach my $cust_svc ($self->cust_svc) {
-    if($target{$cust_svc->svcpart} > 0) {
+    if($target{$cust_svc->svcpart} > 0
+       or $FS::cust_svc::ignore_quantity) { # maybe should be a 'force' option
       $target{$cust_svc->svcpart}--;
       my $new = new FS::cust_svc { $cust_svc->hash };
       $new->pkgnum($dest_pkgnum);
index 5206931..b608b23 100644 (file)
@@ -479,7 +479,7 @@ Returns a listref of html elements associated with this service's exports.
 sub export_links {
   my $self = shift;
   my $svc_x = $self->svc_x
-    or return "can't find ". $self->part_svc->svcdb. '.svcnum '. $self->svcnum;
+    or return [ "can't find ". $self->part_svc->svcdb. '.svcnum '. $self->svcnum ];
 
   $svc_x->export_links;
 }
diff --git a/FS/FS/log.pm b/FS/FS/log.pm
new file mode 100644 (file)
index 0000000..a4ad214
--- /dev/null
@@ -0,0 +1,354 @@
+package FS::log;
+
+use strict;
+use base qw( FS::Record );
+use FS::Record qw( qsearch qsearchs dbdef );
+use FS::UID qw( dbh driver_name );
+use FS::log_context;
+
+=head1 NAME
+
+FS::log - Object methods for log records
+
+=head1 SYNOPSIS
+
+  use FS::log;
+
+  $record = new FS::log \%hash;
+  $record = new FS::log { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::log object represents a log entry.  FS::log inherits from
+FS::Record.  The following fields are currently supported:
+
+=over 4
+
+=item lognum - primary key
+
+=item _date - Unix timestamp
+
+=item agentnum - L<FS::agent> to which the log pertains.  If it involves a 
+specific customer, package, service, invoice, or other agent-specific object,
+this will be set to that agentnum.
+
+=item tablename - table name to which the log pertains, if any.
+
+=item tablenum - foreign key to that table.
+
+=item level - log level: 'debug', 'info', 'notice', 'warning', 'error', 
+'critical', 'alert', 'emergency'.
+
+=item message - contents of the log entry
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new log entry.  Use FS::Log instead of calling this directly, 
+please.
+
+=cut
+
+sub table { 'log'; }
+
+=item insert [ CONTEXT... ]
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+CONTEXT may be a list of context tags to attach to this record.
+
+=cut
+
+sub insert {
+  # not using process_o2m for this, because we don't have a web interface
+  my $self = shift;
+  my $error = $self->SUPER::insert;
+  return $error if $error;
+  foreach ( @_ ) {
+    my $context = FS::log_context->new({
+        'lognum'  => $self->lognum,
+        'context' => $_
+    });
+    $error = $context->insert;
+    return $error if $error;
+  }
+  '';
+}
+
+# the insert method can be inherited from FS::Record
+
+sub delete  { die "Log entries can't be modified." };
+
+sub replace { die "Log entries can't be modified." };
+
+=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
+
+sub check {
+  my $self = shift;
+
+  my $error = 
+    $self->ut_numbern('lognum')
+    || $self->ut_number('_date')
+    || $self->ut_numbern('agentnum')
+    || $self->ut_foreign_keyn('agentnum', 'agent', 'agentnum')
+    || $self->ut_textn('tablename')
+    || $self->ut_numbern('tablenum')
+    || $self->ut_number('level')
+    || $self->ut_text('message')
+  ;
+  return $error if $error;
+
+  if ( my $tablename = $self->tablename ) {
+    my $dbdef_table = dbdef->table($tablename)
+      or return "tablename '$tablename' does not exist";
+    $error = $self->ut_foreign_key('tablenum',
+                                   $tablename,
+                                   $dbdef_table->primary_key);
+    return $error if $error;
+  }
+
+  $self->SUPER::check;
+}
+
+=item context
+
+Returns the context for this log entry, as an array, from least to most
+specific.
+
+=cut
+
+sub context {
+  my $self = shift;
+  map { $_->context } qsearch({
+      table     => 'log_context',
+      hashref   => { lognum => $self->lognum },
+      order_by  => 'ORDER BY logcontextnum ASC',
+  });
+}
+
+=back
+
+=head1 CLASS METHODS
+
+=over 4
+
+=item search HASHREF
+
+Returns a qsearch hash expression to search for parameters specified in 
+HASHREF.  Valid parameters are:
+
+=over 4
+
+=item agentnum
+
+=item date - arrayref of start and end date
+
+=item level - either a specific level, or an arrayref of min and max level
+
+=item context - a context string that the log entry must have.  This may 
+change in the future to allow searching for combinations of context strings.
+
+=item object - any database object, to find log entries related to it.
+
+=item tablename, tablenum - alternate way of specifying 'object'.
+
+=item custnum - a customer number, to find log entries related to the customer
+or any of their subordinate objects (invoices, packages, etc.).
+
+=item message - a text string to search in messages.  The search will be 
+a case-insensitive LIKE with % appended at both ends.
+
+=back
+
+=cut
+
+# used for custnum search: all tables with custnums
+my @table_stubs;
+
+sub _setup_table_stubs {
+  foreach my $table (
+    qw( 
+    contact
+    cust_attachment
+    cust_bill
+    cust_credit
+    cust_location
+    cust_main
+    cust_main_exemption
+    cust_main_note
+    cust_msg
+    cust_pay
+    cust_pay_batch
+    cust_pay_pending
+    cust_pay_void
+    cust_pkg
+    cust_refund
+    cust_statement
+    cust_tag
+    cust_tax_adjustment
+    cust_tax_exempt
+    did_order_item
+    qual
+    queue ) )
+  {
+    my $pkey = dbdef->table($table)->primary_key;
+    push @table_stubs,
+      "log.tablename = '$table' AND ".
+      "EXISTS(SELECT 1 FROM $table WHERE log.tablenum = $table.$pkey AND ".
+      "$table.custnum = "; # needs a closing )
+  }
+  # plus this case
+  push @table_stubs,
+      "(log.tablename LIKE 'svc_%' OR log.tablename = 'cust_svc') AND ".
+      "EXISTS(SELECT 1 FROM cust_svc JOIN cust_pkg USING (svcnum) WHERE ".
+      "cust_pkg.custnum = "; # needs a closing )
+}
+
+sub search {
+  my ($class, $params) = @_;
+  my @where;
+
+  ##
+  # parse agent
+  ##
+
+  if ( $params->{'agentnum'} =~ /^(\d+)$/ ) {
+    push @where,
+      "log.agentnum = $1";
+  }
+
+  ##
+  # parse custnum
+  ##
+
+  if ( $params->{'custnum'} =~ /^(\d+)$/ ) {
+    _setup_table_stubs() unless @table_stubs;
+    my $custnum = $1;
+    my @orwhere = map { "( $_ $custnum) )" } @table_stubs;
+    push @where, join(' OR ', @orwhere);
+  }
+
+  ##
+  # parse level
+  ##
+
+  if ( ref $params->{'level'} eq 'ARRAY' ) {
+    my ($min, $max) = @{ $params->{'level'} };
+    if ( $min =~ /^\d+$/ ) {
+      push @where, "log.level >= $min";
+    }
+    if ( $max =~ /^\d+$/ ) {
+      push @where, "log.level <= $max";
+    }
+  } elsif ( $params->{'level'} =~ /^(\d+)$/ ) {
+    push @where, "log.level = $1";
+  }
+
+  ##
+  # parse date
+  ##
+
+  if ( ref $params->{'date'} eq 'ARRAY' ) {
+    my ($beg, $end) = @{ $params->{'date'} };
+    if ( $beg =~ /^\d+$/ ) {
+      push @where, "log._date >= $beg";
+    }
+    if ( $end =~ /^\d+$/ ) {
+      push @where, "log._date <= $end";
+    }
+  }
+
+  ##
+  # parse object
+  ##
+
+  if ( $params->{'object'} and $params->{'object'}->isa('FS::Record') ) {
+    my $table = $params->{'object'}->table;
+    my $pkey = dbdef->table($table)->primary_key;
+    my $tablenum = $params->{'object'}->get($pkey);
+    if ( $table and $tablenum ) {
+      push @where, "log.tablename = '$table'", "log.tablenum = $tablenum";
+    }
+  } elsif ( $params->{'tablename'} =~ /^(\w+)$/ ) {
+    my $table = $1;
+    if ( $params->{'tablenum'} =~ /^(\d+)$/ ) {
+      push @where, "log.tablename = '$table'", "log.tablenum = $1";
+    }
+  }
+
+  ##
+  # parse message
+  ##
+
+  if ( $params->{'message'} ) { # can be anything, really, so escape it
+    my $quoted_message = dbh->quote('%' . $params->{'message'} . '%');
+    my $op = (driver_name eq 'Pg' ? 'ILIKE' : 'LIKE');
+    push @where, "log.message $op $quoted_message";
+  }
+
+  ##
+  # parse context
+  ##
+
+  if ( $params->{'context'} ) {
+    my $quoted = dbh->quote($params->{'context'});
+    push @where, 
+      "EXISTS(SELECT 1 FROM log_context WHERE log.lognum = log_context.lognum ".
+      "AND log_context.context = $quoted)";
+  }
+
+  # agent virtualization
+  my $access_user = $FS::CurrentUser::CurrentUser;
+  push @where, $access_user->agentnums_sql(
+    table => 'log',
+    viewall_right => 'Configuration',
+    null => 1,
+  );
+
+  # put it together
+  my $extra_sql = '';
+  $extra_sql .= 'WHERE ' . join(' AND ', @where) if @where;
+  my $count_query = 'SELECT COUNT(*) FROM log '.$extra_sql;
+  my $sql_query = {
+    'table'         => 'log',
+    'hashref'       => {},
+    'select'        => 'log.*',
+    'extra_sql'     => $extra_sql,
+    'count_query'   => $count_query,
+    'order_by'      => 'ORDER BY _date ASC',
+    #addl_from, not needed
+  };
+}
+
+=back
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
diff --git a/FS/FS/log_context.pm b/FS/FS/log_context.pm
new file mode 100644 (file)
index 0000000..372bdaa
--- /dev/null
@@ -0,0 +1,145 @@
+package FS::log_context;
+
+use strict;
+use base qw( FS::Record );
+use FS::Record qw( qsearch qsearchs );
+
+my @contexts = ( qw(
+  test
+  bill_and_collect
+  Cron::bill
+  Cron::upload
+  spool_upload
+  daily
+  queue
+) );
+
+=head1 NAME
+
+FS::log_context - Object methods for log_context records
+
+=head1 SYNOPSIS
+
+  use FS::log_context;
+
+  $record = new FS::log_context \%hash;
+  $record = new FS::log_context { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::log_context object represents a context tag attached to a log entry
+(L<FS::log>).  FS::log_context inherits from FS::Record.  The following 
+fields are currently supported:
+
+=over 4
+
+=item logcontextnum - primary key
+
+=item lognum - lognum (L<FS::log> foreign key)
+
+=item context - context
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new context tag.  To add the example 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 { 'log_context'; }
+
+=item insert
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=cut
+
+# the insert method can be inherited from FS::Record
+
+=item delete
+
+Delete this record from the database.
+
+=cut
+
+# the delete method can be inherited from FS::Record
+
+=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('logcontextnum')
+    || $self->ut_number('lognum')
+    || $self->ut_enum('context', \@contexts)
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=back
+
+=head1 CLASS METHODS
+
+=over 4
+
+=item contexts
+
+Returns a list of all valid contexts.
+
+=cut
+
+sub contexts { @contexts }
+
+=back
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::Log>, L<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
index f25db2a..fefee20 100644 (file)
@@ -19,7 +19,7 @@ sub condition {
 
   #my $cust_main = $self->cust_main($cust_pkg);
 
-  $cust_pkg->dundate <= $opt{time};
+  ( $cust_pkg->dundate || 0 ) <= $opt{time};
 
 }
 
index b0f708a..5d65062 100644 (file)
@@ -615,6 +615,23 @@ sub weight {
   export_info()->{$self->exporttype}->{'weight'} || 0;
 }
 
+=item info
+
+Returns a reference to (a copy of) the export's %info hash.
+
+=cut
+
+sub info {
+  my $self = shift;
+  $self->{_info} ||= { 
+    %{ export_info()->{$self->exporttype} }
+  };
+}
+
+#default fallbacks... FS::part_export::DID_Common ?
+sub get_dids_can_tollfree { 0; }
+sub get_dids_npa_select   { 1; }
+
 =back
 
 =head1 SUBROUTINES
index 23df7b3..af35899 100644 (file)
@@ -41,6 +41,18 @@ tie %options, 'Tie::IxHash',
       "password \$new->_password",
     ),
   },
+  'suspend_data' => {
+    label   => 'Suspend data',
+    type    => 'textarea',
+    default => join("\n",
+    ),
+  },
+  'unsuspend_data' => {
+    label   => 'Unsuspend data',
+    type    => 'textarea',
+    default => join("\n",
+    ),
+  },
   'success_regexp' => {
     label  => 'Success Regexp',
     default => '',
index 4c896b4..a493f52 100644 (file)
@@ -48,6 +48,8 @@ The following variables are available for interpolation (prefixed with new_ or
 old_ for replace operations):
 <UL>
   <LI><code>$username</code>
+  <LI><code>$domain</code>
+  <LI><code>$email</code> - username@domain
   <LI><code>$_password</code>
   <LI><code>$crypt_password</code> - encrypted password
   <LI><code>$ldap_password</code> - Password in LDAP/RFC2307 format (for example, "{PLAIN}himom", "{CRYPT}94pAVyK/4oIBk" or "{MD5}5426824942db4253f87a1009fd5d2d4")
@@ -196,8 +198,8 @@ sub _export_value {
     } else {
       return Frontier::RPC2::String->new( $svc_acct->$value() );
     }
-  } elsif ( $value eq 'domain' ) {
-    return Frontier::RPC2::String->new( $svc_acct->domain );
+  } elsif ( $value =~ /^(domain|email)$/ ) {
+    return Frontier::RPC2::String->new( $svc_acct->$value() );
   } elsif ( $value eq 'crypt_password' ) {
     return Frontier::RPC2::String->new( $svc_acct->crypt_password( $self->option('crypt') ) );
   } elsif ( $value eq 'ldap_password' ) {
@@ -207,6 +209,7 @@ sub _export_value {
     #XXX
   }
 
+#this is the "cust_main" email, not svc_acct->email
 #  my $cust_pkg = $svc_acct->cust_svc->cust_pkg;
 #  if ( $cust_pkg ) {
 #    no strict 'vars';
index c1ed7fc..5be8b68 100644 (file)
@@ -35,6 +35,18 @@ tie %options, 'Tie::IxHash',
     type    => 'textarea',
     default => '',
   },
+  'suspend_data' => {
+    label   => 'Suspend data',
+    type    => 'textarea',
+    default => join("\n",
+    ),
+  },
+  'unsuspend_data' => {
+    label   => 'Unsuspend data',
+    type    => 'textarea',
+    default => join("\n",
+    ),
+  },
   'success_regexp' => {
     label   => 'Success Regexp',
     default => '',
index 44b4dba..9afca08 100644 (file)
@@ -3,7 +3,7 @@ package FS::part_export::broadband_snmp;
 use strict;
 use vars qw(%info $DEBUG);
 use base 'FS::part_export';
-use Net::SNMP qw(:asn1 :snmp);
+use SNMP;
 use Tie::IxHash;
 
 $DEBUG = 0;
@@ -11,21 +11,21 @@ $DEBUG = 0;
 my $me = '['.__PACKAGE__.']';
 
 tie my %snmp_version, 'Tie::IxHash',
-  v1  => 'snmpv1',
-  v2c => 'snmpv2c',
-  # 3 => 'v3' not implemented
+  v1  => '1',
+  v2c => '2c',
+  # v3 unimplemented
 ;
 
-tie my %snmp_type, 'Tie::IxHash',
-  i => INTEGER,
-  u => UNSIGNED32,
-  s => OCTET_STRING,
-  n => NULL,
-  o => OBJECT_IDENTIFIER,
-  t => TIMETICKS,
-  a => IPADDRESS,
-  # others not implemented yet
-;
+#tie my %snmp_type, 'Tie::IxHash',
+#  i => INTEGER,
+#  u => UNSIGNED32,
+#  s => OCTET_STRING,
+#  n => NULL,
+#  o => OBJECT_IDENTIFIER,
+#  t => TIMETICKS,
+#  a => IPADDRESS,
+#  # others not implemented yet
+#;
 
 tie my %options, 'Tie::IxHash',
   'version' => { label=>'SNMP version', 
@@ -33,14 +33,11 @@ tie my %options, 'Tie::IxHash',
     options => [ keys %snmp_version ],
    },
   'community' => { label=>'Community', default=>'public' },
-  (
-    map { $_.'_command', 
-          { label => ucfirst($_) . ' commands',
-            type  => 'textarea',
-            default => '',
-          }
-    } qw( insert delete replace suspend unsuspend )
-  ),
+
+  'action' => { multiple=>1 },
+  'oid'    => { multiple=>1 },
+  'value'  => { multiple=>1 },
+
   'ip_addr_change_to_new' => { 
     label=>'Send IP address changes to new address',
     type=>'checkbox'
@@ -51,28 +48,14 @@ tie my %options, 'Tie::IxHash',
 %info = (
   'svc'     => 'svc_broadband',
   'desc'    => 'Send SNMP requests to the service IP address',
+  'config_element' => '/edit/elements/part_export/broadband_snmp.html',
   'options' => \%options,
   'no_machine' => 1,
   'weight'  => 10,
   'notes'   => <<'END'
 Send one or more SNMP SET requests to the IP address registered to the service.
-Enter one command per line.  Each command is a target OID, data type flag,
-and value, separated by spaces.
-The data type flag is one of the following:
-<font size="-1"><ul>
-<li><i>i</i> = INTEGER</li>
-<li><i>u</i> = UNSIGNED32</li>
-<li><i>s</i> = OCTET-STRING (as ASCII)</li>
-<li><i>a</i> = IPADDRESS</li>
-<li><i>n</i> = NULL</li></ul>
 The value may interpolate fields from svc_broadband by prefixing the field 
 name with <b>$</b>, or <b>$new_</b> and <b>$old_</b> for replace operations.
-The value may contain whitespace; quotes are not necessary.<br>
-<br>
-For example, to set the SNMPv2-MIB "sysName.0" object to the string 
-"svc_broadband" followed by the service number, use the following 
-command:<br>
-<pre>1.3.6.1.2.1.1.5.0 s svc_broadband$svcnum</pre><br>
 END
 );
 
@@ -105,19 +88,18 @@ sub export_command {
   my $self = shift;
   my ($action, $svc_new, $svc_old) = @_;
 
-  my $command_text = $self->option($action.'_command');
-  return if !length($command_text);
-
-  warn "$me parsing ${action}_command:\n" if $DEBUG;
+  my @a = split("\n", $self->option('action'));
+  my @o = split("\n", $self->option('oid'));
+  my @v = split("\n", $self->option('value'));
   my @commands;
-  foreach (split /\n/, $command_text) {
-    my ($oid, $type, $value) = split /\s/, $_, 3;
-    $oid =~ /^(\d+\.)*\d+$/ or die "invalid OID '$oid'\n";
-    my $typenum = $snmp_type{$type} or die "unknown data type '$type'\n";
-    $value = '' if !defined($value); # allow sending an empty string
+  warn "$me parsing $action commands:\n" if $DEBUG;
+  while (@a) {
+    my $oid = shift @o;
+    my $value = shift @v;
+    next unless shift(@a) eq $action; # ignore commands for other actions
     $value = $self->substitute($value, $svc_new, $svc_old);
-    warn "$me     $oid $type $value\n" if $DEBUG;
-    push @commands, $oid, $typenum, $value;
+    warn "$me     $oid :=$value\n" if $DEBUG;
+    push @commands, $oid, $value;
   }
 
   my $ip_addr = $svc_new->ip_addr;
@@ -128,13 +110,13 @@ sub export_command {
   warn "$me opening session to $ip_addr\n" if $DEBUG;
 
   my %opt = (
-    -hostname => $ip_addr,
-    -community => $self->option('community'),
-    -timeout => $self->option('timeout') || 20,
+    DestHost  => $ip_addr,
+    Community => $self->option('community'),
+    Timeout   => ($self->option('timeout') || 20) * 1000,
   );
   my $version = $self->option('version');
-  $opt{-version} = $snmp_version{$version} or die 'invalid version';
-  $opt{-varbindlist} = \@commands; # just for now
+  $opt{Version} = $snmp_version{$version} or die 'invalid version';
+  $opt{VarList} = \@commands; # for now
 
   $self->snmp_queue( $svc_new->svcnum, %opt );
 }
@@ -151,16 +133,22 @@ sub snmp_queue {
 
 sub snmp_request {
   my %opt = @_;
-  my $varbindlist = delete $opt{-varbindlist};
-  my ($session, $error) = Net::SNMP->session(%opt);
-  die "Couldn't create SNMP session: $error" if !$session;
+  my $flatvarlist = delete $opt{VarList};
+  my $session = SNMP::Session->new(%opt);
 
   warn "$me sending SET request\n" if $DEBUG;
-  my $result = $session->set_request( -varbindlist => $varbindlist );
-  $error = $session->error();
-  $session->close();
 
-  if (!defined $result) {
+  my @varlist;
+  while (@$flatvarlist) {
+    my @this = splice(@$flatvarlist, 0, 2);
+    push @varlist, [ $this[0], 0, $this[1], undef ];
+    # XXX new option to choose the IID (array index) of the object?
+  }
+
+  $session->set(\@varlist);
+  my $error = $session->{ErrorStr};
+
+  if ( $session->{ErrorNum} ) {
     die "SNMP request failed: $error\n";
   }
 }
@@ -181,4 +169,46 @@ sub substitute {
   $value;
 }
 
+sub _upgrade_exporttype {
+  eval 'use FS::Record qw(qsearch qsearchs)';
+  # change from old style with numeric oid, data type flag, and value
+  # on consecutive lines
+  foreach my $export (qsearch('part_export',
+                      { exporttype => 'broadband_snmp' } ))
+  {
+    # for the new options
+    my %new_options = (
+      'action' => [],
+      'oid'    => [],
+      'value'  => [],
+    );
+    foreach my $action (qw(insert replace delete suspend unsuspend)) {
+      my $old_option = qsearchs('part_export_option',
+                      { exportnum   => $export->exportnum,
+                        optionname  => $action.'_command' } );
+      next if !$old_option;
+      my $text = $old_option->optionvalue;
+      my @commands = split("\n", $text);
+      foreach (@commands) {
+        my ($oid, $type, $value) = split /\s/, $_, 3;
+        push @{$new_options{action}}, $action;
+        push @{$new_options{oid}},    $oid;
+        push @{$new_options{value}},   $value;
+      }
+      my $error = $old_option->delete;
+      warn "error migrating ${action}_command option: $error\n" if $error;
+    }
+    foreach (keys(%new_options)) {
+      my $new_option = FS::part_export_option->new({
+          exportnum   => $export->exportnum,
+          optionname  => $_,
+          optionvalue => join("\n", @{ $new_options{$_} })
+      });
+      my $error = $new_option->insert;
+      warn "error inserting '$_' option: $error\n" if $error;
+    }
+  } #foreach $export
+  '';
+}
+
 1;
diff --git a/FS/FS/part_export/fibernetics_did.pm b/FS/FS/part_export/fibernetics_did.pm
new file mode 100644 (file)
index 0000000..fb03785
--- /dev/null
@@ -0,0 +1,177 @@
+package FS::part_export::fibernetics_did;
+use base qw( FS::part_export );
+
+use strict;
+use vars qw( %info $DEBUG );
+use Data::Dumper;
+use URI::Escape;
+#use Locale::SubCountry;
+#use FS::Record qw(qsearch dbh);
+use XML::Simple;
+#use Net::HTTPS::Any qw( 0.10 https_get );
+use LWP::UserAgent;
+use HTTP::Request::Common;
+
+$DEBUG = 0;
+
+tie my %options, 'Tie::IxHash',
+  'country' => { 'label' => 'Country', 'default' => 'CA', size=>2, },
+;
+
+%info = (
+  'svc'        => 'svc_phone',
+  'desc'       => 'Provision phone numbers to Fibernetics web services API',
+  'options'    => \%options,
+  'notes'      => '',
+);
+
+sub rebless { shift; }
+
+sub get_dids_can_tollfree { 0; };
+sub get_dids_npa_select   { 0; };
+
+# i guess we could get em from the API, but since its returning states without
+#  availability, there's no advantage
+    # not really needed, we maintain our own list of provinces, but would
+    #  help to hide the ones without availability (need to fix the selector too)
+our @states = (
+  'Alberta',
+  'British Columbia',
+  'Ontario',
+  'Quebec',
+  #'Saskatchewan',
+  #'The Territories',
+  #'PEI/Nova Scotia',
+  #'Manitoba',
+  #'Newfoundland',
+  #'New Brunswick',
+);
+
+sub get_dids {
+  my $self = shift;
+  my %opt = ref($_[0]) ? %{$_[0]} : @_;
+
+  if ( $opt{'tollfree'} ) {
+    warn 'Fibernetics DID provisioning does not yet support toll-free numbers';
+    return [];
+  }
+
+  my %query_hash = ();
+
+  #ratecenter + state: return numbers (more structured names, npa selection)
+  #areacode + exchange: return numbers
+  #areacode: return city/ratecenter/whatever
+  #state: return areacodes
+
+  #region + state: return numbers (arbitrary names, no npa selection)
+  #state: return regions
+
+#  if ( $opt{'areacode'} && $opt{'exchange'} ) { #return numbers
+#
+#    $query_hash{'region'} = $opt{'exchange'};
+#
+#  } elsif ( $opt{'areacode'} ) {
+#
+#    $query_hash{'npa'} = $opt{'areacode'};
+
+  #if ( $opt{'state'} && $opt{'region'} ) { #return numbers
+  if ( $opt{'region'} ) { #return numbers
+
+    #$query_hash{'province'} = $country->full_name($opt{'state'});
+    $query_hash{'region'}   = $opt{'region'}
+
+  } elsif ( $opt{'state'} ) { #return regions
+
+    #my $country = new Locale::SubCountry( $self->option('country') );
+    #$query_hash{'province'}   = $country->full_name($opt{'state'});
+    $query_hash{'province'}   = $opt{'state'};
+    $query_hash{'listregion'} = 1;
+
+  } else { #nothing passed, return states (provinces)
+
+    return \@states;
+
+  }
+
+
+  my $url = 'http://'. $self->machine. '/porta/cgi-bin/porta_query.cgi';
+  if ( keys %query_hash ) {
+    $url .= '?'. join('&', map "$_=". uri_escape($query_hash{$_}),
+                             keys %query_hash
+                     );
+  }
+  warn $url if $DEBUG;
+
+  #my( $page, $response, %reply_headers) = https_get(
+  #  'host' => $self->machine,
+  #);
+
+  my $ua = LWP::UserAgent->new;
+  #my $response = $ua->$method(
+  #  $url, \%data,
+  #  'Content-Type'=>'application/x-www-form-urlencoded'
+  #);
+  my $req = HTTP::Request::Common::GET( $url );
+  my $response = $ua->request($req);
+
+  die $response->error_as_HTML if $response->is_error;
+
+  my $page = $response->content;
+
+  my $data = XMLin( $page );
+
+  warn Dumper($data) if $DEBUG;
+
+#  if ( $opt{'areacode'} && $opt{'exchange'} ) { #return numbers
+#
+#    [ map $_->{'number'}, @{ $data->{'item'} } ];
+#
+#  } elsif ( $opt{'areacode'} ) {
+#
+#    [ map $_->{'region'}, @{ $data->{'item'} } ];
+#
+#  } elsif ( $opt{'state'} ) { #return areacodes
+#
+#    [ map $_->{'npa'}, @{ $data->{'item'} } ];
+
+  #if ( $opt{'state'} && $opt{'region'} ) { #return numbers
+  if ( $opt{'region'} ) { #return numbers
+
+    [ map { $_ =~ /^(\d?)(\d{3})(\d{3})(\d{4})$/
+              #? ($1 ? "$1 " : ''). "$2 $3 $4"
+              ? "$2 $3 $4"
+              : $_;
+          }
+        sort { $a <=> $b }
+          map $_->{'phone'},
+            @{ $data->{'item'} }
+    ];
+
+  } elsif ( $opt{'state'} ) { #return regions
+
+    #[ map $_->{'region'}, @{ $data->{'item'} } ];
+    my %regions = map { $_ => 1 } map $_->{'region'}, @{ $data->{'item'} };
+    [ sort keys %regions ];
+
+  #} else { #nothing passed, return states (provinces)
+    # not really needed, we maintain our own list of provinces, but would
+    #  help to hide the ones without availability (need to fix the selector too)
+  }
+
+
+}
+
+#insert, delete, etc... handled with shellcommands
+
+sub _export_insert {
+  #my( $self, $svc_phone ) = (shift, shift);
+}
+sub _export_delete {
+  #my( $self, $svc_phone ) = (shift, shift);
+}
+
+sub _export_replace  { ''; }
+sub _export_suspend  { ''; }
+sub _export_unsuspend  { ''; }
+
+1;
index c35c89f..0d62409 100644 (file)
@@ -33,6 +33,18 @@ tie %options, 'Tie::IxHash',
     default => join("\n",
     ),
   },
+  'suspend_data' => {
+    label   => 'Suspend data',
+    type    => 'textarea',
+    default => join("\n",
+    ),
+  },
+  'unsuspend_data' => {
+    label   => 'Unsuspend data',
+    type    => 'textarea',
+    default => join("\n",
+    ),
+  },
   'success_regexp' => {
     label  => 'Success Regexp',
     default => '',
@@ -64,6 +76,16 @@ sub _export_delete {
   $self->_export_command('delete', @_);
 }
 
+sub _export_suspend {
+  my $self = shift;
+  $self->_export_command('suspend', @_);
+}
+
+sub _export_unsuspend {
+  my $self = shift;
+  $self->_export_command('unsuspend', @_);
+}
+
 sub _export_command {
   my( $self, $action, $svc_x ) = ( shift, shift, shift );
 
index 6760d09..58cc5be 100644 (file)
@@ -213,6 +213,7 @@ sub _export_replace {
           return $error;
         }
       }
+      $jobnum = $err_or_queue->jobnum; # chain all of these dependencies
     }
 
     my @del = grep { !exists $new{$_} } keys %old;
@@ -230,6 +231,7 @@ sub _export_replace {
           return $error;
         }
       }
+      $jobnum = $err_or_queue->jobnum; # chain all of these dependencies
     }
   }
 
@@ -348,7 +350,7 @@ sub _export_delete {
 
 sub sqlradius_queue {
   my( $self, $svcnum, $method ) = (shift, shift, shift);
-  my %args = @_;
+  #my %args = @_;
   my $queue = new FS::queue {
     'svcnum' => $svcnum,
     'job'    => "FS::part_export::sqlradius::sqlradius_$method",
@@ -561,6 +563,7 @@ sub sqlreplace_usergroups {
       my $error = $err_or_queue->depend_insert( $jobnum );
       return $error if $error;
     }
+    $jobnum = $err_or_queue->jobnum; # chain all of these dependencies
   }
 
   if ( @newgroups ) {
index 350a5ad..3c0534f 100644 (file)
@@ -39,6 +39,8 @@ END
 
 sub rebless { shift; }
 
+sub get_dids_can_tollfree { 1; };
+
 sub get_dids {
   my $self = shift;
   my %opt = ref($_[0]) ? %{$_[0]} : @_;
index ea9d584..220fecb 100644 (file)
@@ -112,7 +112,7 @@ my %holiday = (
     }
     push @fields, sprintf('%05s', $branch),
                   sprintf('%03s', $bankno),
-                  sprintf('%012s', $account),
+                  $account,
                   sprintf('%.02f', $cust_pay_batch->amount);
     # DB = debit
     push @fields, 'DB', $trans_code, $process_date;
index 7026205..7eda7e0 100644 (file)
@@ -93,7 +93,7 @@ sub _used_addresses {
   # in use, yes? 
 
   my %hash = ( $ip_field => { op => '!=', value => '' } );
-  $hash{'blocknum'} = $block->blocknum if $block;
+  #$hash{'blocknum'} = $block->blocknum if $block;
   $hash{'svcnum'} = { op => '!=', value => $exclude->svcnum } if ref $exclude;
   map { $_->NetAddr->addr } qsearch($class->table, \%hash);
 }
index ac97eab..544c7e9 100644 (file)
@@ -68,7 +68,8 @@ sub replace  {
 
   $old->usergroup; # make sure this is cached for exports
 
-  my $error =  $new->process_m2m(
+  my $error =  $new->check # make sure fixed fields are set before process_m2m
+            || $new->process_m2m(
                                  'link_table'   => 'radius_usergroup',
                                  'target_table' => 'radius_group',
                                  'params'       => $new->usergroup,
index 9c444be..f954fe8 100644 (file)
@@ -675,3 +675,7 @@ FS/svc_export_machine.pm
 t/svc_export_machine.t
 FS/GeocodeCache.pm
 t/GeocodeCache.t
+FS/log.pm
+t/log.t
+FS/log_context.pm
+t/log_context.t
index 8e8ae4f..ac93aaf 100755 (executable)
@@ -4,6 +4,7 @@ use strict;
 use Getopt::Std;
 use FS::UID qw(adminsuidsetup);
 use FS::Conf;
+use FS::Log;
 
 &untaint_argv; #what it sounds like  (eww)
 use vars qw(%opt);
@@ -11,6 +12,8 @@ getopts("p:a:d:vl:sy:nmrkg:o", \%opt);
 
 my $user = shift or die &usage;
 adminsuidsetup $user;
+my $log = FS::Log->new('daily');
+$log->info('start');
 
 #you can skip this by not having a NetworkMonitoringSystem configured
 use FS::Cron::nms_report qw(nms_report);
@@ -74,6 +77,8 @@ unlink <${deldir}.CGItemp*>;
 use FS::Cron::backup qw(backup);
 backup();
 
+$log->info('finish');
+
 ###
 # subroutines
 ###
@@ -138,7 +143,7 @@ the bill and collect methods of a cust_main object.  See L<FS::cust_main>.
 
   -l: debugging level
 
-  -m: Experimental multi-process mode uses the job queue for multi-process and/or multi-machine billing.
+  -m: Multi-process mode uses the job queue for multi-process and/or multi-machine billing.
 
   -r: Multi-process mode dry run option
 
diff --git a/FS/bin/freeside-ipifony-download b/FS/bin/freeside-ipifony-download
new file mode 100644 (file)
index 0000000..e893326
--- /dev/null
@@ -0,0 +1,240 @@
+#!/usr/bin/perl
+
+use strict;
+use Getopt::Std;
+use Date::Format qw(time2str);
+use File::Temp qw(tempdir);
+use Net::SFTP::Foreign;
+use FS::UID qw(adminsuidsetup);
+use FS::Record qw(qsearch qsearchs);
+use FS::cust_main;
+use FS::Conf;
+use Text::CSV;
+
+my %opt;
+getopts('va:P:C:', \%opt);
+
+#$Net::SFTP::Foreign::debug = -1;
+sub HELP_MESSAGE { '
+  Usage:
+      freeside-ipifony-download 
+        [ -v ]
+        [ -a archivedir ]
+        [ -P port ]
+        [ -C category ]
+        freesideuser sftpuser@hostname[:path]
+' }
+
+my @fields = (
+  'custnum',
+  'date_desc',
+  'quantity',
+  'amount',
+  'classname',
+);
+
+my $user = shift or die &HELP_MESSAGE;
+adminsuidsetup $user;
+
+# for statistics
+my $num_charges = 0;
+my $num_errors = 0;
+my $sum_charges = 0;
+# cache classnums
+my %classnum_of;
+
+if ( $opt{a} ) {
+  die "no such directory: $opt{a}\n"
+    unless -d $opt{a};
+  die "archive directory $opt{a} is not writable by the freeside user\n"
+    unless -w $opt{a};
+}
+
+my $categorynum = '';
+if ( $opt{C} ) {
+  # find this category (don't auto-create it, it should exist already)
+  my $category = qsearchs('pkg_category', { categoryname => $opt{C} });
+  if (!defined($category)) {
+    die "Package category '$opt{C}' does not exist.\n";
+  }
+  $categorynum = $category->categorynum;
+}
+
+#my $tmpdir = File::Temp->newdir();
+my $tmpdir = tempdir( CLEANUP => 1 ); #DIR=>somewhere?
+
+my $host = shift
+  or die &HELP_MESSAGE;
+my ($sftpuser, $path);
+$host =~ s/^(.+)\@//;
+$sftpuser = $1 || $ENV{USER};
+$host =~ s/:(.*)//;
+$path = $1;
+
+my $port = 22;
+if ( $opt{P} =~ /^(\d+)$/ ) {
+  $port = $1;
+}
+
+# for now assume SFTP download as the only method
+print STDERR "Connecting to $sftpuser\@$host...\n" if $opt{v};
+
+my $sftp = Net::SFTP::Foreign->new(
+  host      => $host,
+  user      => $sftpuser,
+  port      => $port,
+  # for now we don't support passwords. use authorized_keys.
+  timeout   => 30,
+  more      => ($opt{v} ? '-v' : ''),
+);
+die "failed to connect to '$sftpuser\@$host'\n(".$sftp->error.")\n"
+  if $sftp->error;
+
+$sftp->setcwd($path) if $path;
+
+my $files = $sftp->ls('.', wanted => qr/\.csv$/, names_only => 1);
+if (!@$files) {
+  print STDERR "No charge files found.\n" if $opt{v};
+  exit(-1);
+}
+FILE: foreach my $filename (@$files) {
+  print STDERR "Retrieving $filename\n" if $opt{v};
+  $sftp->get("$filename", "$tmpdir/$filename");
+  if($sftp->error) {
+    warn "failed to download $filename\n";
+    next FILE;
+  }
+
+  # make sure server archive dir exists
+  if ( !$sftp->stat('Archive') ) {
+    print STDERR "Creating $path/Archive\n" if $opt{v};
+    $sftp->mkdir('Archive');
+    if($sftp->error) {
+      # something is seriously wrong
+      die "failed to create archive directory on server:\n".$sftp->error."\n";
+    }
+  }
+  #move to server archive dir
+  $sftp->rename("$filename", "Archive/$filename");
+  if($sftp->error) {
+    warn "failed to archive $filename on server:\n".$sftp->error."\n";
+  } # process it anyway, I guess/
+
+  #copy to local archive dir
+  if ( $opt{a} ) {
+    print STDERR "Copying $tmpdir/$filename to archive dir $opt{a}\n"
+      if $opt{v};
+    copy("$tmpdir/$filename", $opt{a});
+    warn "failed to copy $tmpdir/$filename to $opt{a}: $!" if $!;
+  }
+
+  open my $fh, "<$tmpdir/$filename";
+  my $header = <$fh>;
+  if ($header !~ /^cust_id/) {
+    warn "warning: $filename has incorrect header row:\n$header\n";
+    # but try anyway
+  }
+  my $csv = Text::CSV->new; # orthodox CSV
+  my %hash;
+  while (my $line = <$fh>) {
+    $csv->parse($line) or do {
+      warn "can't parse $filename: ".$csv->error_input."\n";
+      next FILE;
+    };
+    @hash{@fields} = $csv->fields();
+    my $cust_main = FS::cust_main->by_key($hash{custnum});
+    if (!$cust_main) {
+      warn "customer #$hash{custnum} not found\n";
+      next;
+    }
+    print STDERR "Found customer #$hash{custnum}: ".$cust_main->name."\n"
+      if $opt{v};
+
+    # construct arguments for $cust_main->charge
+    my %opt = (
+      amount      => $hash{amount},
+      quantity    => $hash{quantity},
+      start_date  => $cust_main->next_bill_date,
+      pkg         => $hash{date_desc},
+    );
+    if (my $classname = $hash{classname}) {
+      if (!exists($classnum_of{$classname}) ) {
+        # then look it up
+        my $pkg_class = qsearchs('pkg_class', {
+            classname   => $classname,
+            categorynum => $categorynum,
+        });
+        if (!defined($pkg_class)) {
+          # then create it
+          $pkg_class = FS::pkg_class->new({
+              classname   => $classname,
+              categorynum => $categorynum,
+          });
+          my $error = $pkg_class->insert;
+          die "Error creating package class for product code '$classname':\n".
+            "$error\n"
+            if $error;
+        }
+
+        $classnum_of{$classname} = $pkg_class->classnum;
+      }
+      $opt{classnum} = $classnum_of{$classname};
+    }
+    # XXX what's the tax status of these charges?
+    print STDERR "  Charging $hash{amount}\n"
+      if $opt{v};
+    my $error = $cust_main->charge(\%opt);
+    if ($error) {
+      warn "Error creating charge: $error" if $error;
+      $num_errors++;
+    } else {
+      $num_charges++;
+      $sum_charges += $hash{amount};
+    }
+  } #while $line
+  close $fh;
+} #FILE
+
+if ($opt{v}) {
+  print STDERR "
+Finished!
+  Processed files: @$files
+  Created charges: $num_charges
+  Sum of charges: \$".sprintf('%0.2f', $sum_charges)."
+  Errors: $num_errors
+";
+}
+
+=head1 NAME
+
+freeside-eftca-download - Retrieve payment batch responses from EFT Canada.
+
+=head1 SYNOPSIS
+
+  freeside-eftca-download [ -v ] [ -a archivedir ] user
+
+=head1 DESCRIPTION
+
+Command line tool to download returned payment reports from the EFT Canada 
+gateway and void the returned payments.  Uses the login and password from 
+'batchconfig-eft_canada'.
+
+-v: Be verbose.
+
+-a directory: Archive response files in the provided directory.
+
+user: freeside username
+
+=head1 BUGS
+
+You need to manually SFTP to ftp.eftcanada.com from the freeside account 
+and accept their key before running this script.
+
+=head1 SEE ALSO
+
+L<FS::pay_batch>
+
+=cut
+
+1;
+
index 0d6ea14..69502a0 100755 (executable)
@@ -7,7 +7,7 @@ use FS::UID qw(adminsuidsetup);
 &untaint_argv; #what it sounds like  (eww)
 #use vars qw($opt_d $opt_v $opt_p $opt_a $opt_s $opt_y);
 use vars qw(%opt);
-getopts("p:a:d:vsy:", \%opt);
+getopts("p:a:d:vsy:m", \%opt);
 
 my $user = shift or die &usage;
 adminsuidsetup $user;
@@ -72,6 +72,8 @@ the bill and collect methods of a cust_main object.  See L<FS::cust_main>.
 
   -v: enable debugging
 
+  -m: Experimental multi-process mode (delay upload jobs until billing jobs complete)
+
 user: From the mapsecrets file - see config.html from the base documentation
 
 custnum: if one or more customer numbers are specified, only bills those
index 756b699..2fd8025 100644 (file)
@@ -11,6 +11,7 @@ use FS::Conf;
 use FS::Record qw(qsearch);
 use FS::queue;
 use FS::queue_depend;
+use FS::Log;
 
 # no autoloading for non-FS classes...
 use Net::SSH 0.07;
@@ -45,6 +46,7 @@ while ( $@ ) {
   }
 }
 
+my $log = FS::Log->new('queue');
 logfile( "%%%FREESIDE_LOG%%%/queuelog.". $FS::UID::datasrc );
 
 warn "completing daemonization (detaching))\n" if $DEBUG;
@@ -135,6 +137,8 @@ while (1) {
 
   foreach my $job ( @jobs ) {
 
+    $log->debug('locking queue job', object => $job);
+
     my %hash = $job->hash;
     $hash{'status'} = 'locked';
     my $ljob = new FS::queue ( \%hash );
@@ -186,7 +190,7 @@ while (1) {
       dbh->{'private_profile'} = {} if UNIVERSAL::can(dbh, 'sprintProfile');
 
       #auto-use classes...
-      if (    $ljob->job =~ /(FS::(part_export|cust_main|cust_pkg)::\w+)::/
+      if (    $ljob->job =~ /(FS::(part_export|cust_main|cust_pkg|Cron)::\w+)::/
            || $ljob->job =~ /(FS::\w+)::/
          )
       {
@@ -205,6 +209,8 @@ while (1) {
       }
 
       my $eval = "&". $ljob->job. '(@args);';
+      # don't put @args in the log, may expose passwords
+      $log->info('starting job ('.$ljob->job.')');
       warn 'running "&'. $ljob->job. '('. join(', ', @args). ")\n" if $DEBUG;
       eval $eval; #throw away return value?  suppose so
       if ( $@ ) {
diff --git a/FS/t/log.t b/FS/t/log.t
new file mode 100644 (file)
index 0000000..42c604b
--- /dev/null
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::log;
+$loaded=1;
+print "ok 1\n";
diff --git a/FS/t/log_context.t b/FS/t/log_context.t
new file mode 100644 (file)
index 0000000..57c3b34
--- /dev/null
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::log_context;
+$loaded=1;
+print "ok 1\n";
index f8ee7f6..0962d44 100644 (file)
@@ -1,13 +1,8 @@
 <HTML><HEAD><TITLE>ISP Signup</TITLE></HEAD>
 <BODY BGCOLOR="#e8e8e8"><FONT SIZE=7>ISP Signup - promotional code</FONT><BR><BR>
-<SCRIPT>
-function gotoURL(object) {
-    window.location.href =  'signup.cgi?promo_code=' + object.promo_code.value;
-}
-</SCRIPT>
-<FORM>
+<FORM ACTION="signup.cgi" METHOD="GET">
 Enter promotional code <INPUT TYPE="text" NAME="promo_code">
-<INPUT type="submit" VALUE="Signup" onClick="gotoURL(this.form)">
+<INPUT type="submit" VALUE="Signup">
 
 </FORM>
 </BODY>
index 61361b8..de0ab1a 100755 (executable)
@@ -843,7 +843,7 @@ sub real_port_graph {
            'session_id'  => $session_id,
            'svcnum'      => $svcnum,
            'beginning'   => str2time($cgi->param('start')." 00:00:00"),
-           'ending'    => str2time($cgi->param('end')." 23:59:59"),
+           'ending'      => str2time($cgi->param('end')  ." 23:59:59"),
            );
     my @usage = @{$res->{'usage'}};
     my $png = $usage[0]->{'png'};
index dcf5efa..49b4a24 100644 (file)
@@ -6,6 +6,8 @@ PerlModule HTML::Mason
 PerlSetVar MasonArgsMethod CGI
 PerlModule HTML::Mason::ApacheHandler
 
+PerlChildInitHandler "sub { srand }"
+
 PerlRequire "%%%MASON_HANDLER%%%"
 
 #Locale::SubCountry
index 1959302..7711dcc 100755 (executable)
@@ -44,9 +44,9 @@ my $agent_type = shift;
          [
            {
              #'data'  => $part_pkg->pkg. ' - '. $part_pkg->comment,
-             'data'  => $type_pkgs->pkg. ' - '.
+             'data'  => encode_entities($type_pkgs->pkg). ' - '.
                         ( $type_pkgs->custom ? '(CUSTOM) ' : '' ).
-                        $type_pkgs->comment,
+                        encode_entities($type_pkgs->comment),
              'align' => 'left',
              'link'  => $p. 'edit/part_pkg.cgi?'. $type_pkgs->pkgpart,
            },
index b7ecc00..91238a0 100755 (executable)
@@ -43,14 +43,56 @@ function part_export_areyousure(href) {
       <TD CLASS="inv" BGCOLOR="<% $bgcolor %>">
         <% itable() %>
 %         my %opt = $part_export->options;
-%         foreach my $opt ( keys %opt ) { 
+%         my $defs = $part_export->info->{options};
+%         my %multiples;
+%         foreach my $opt (keys %$defs) { # is a Tie::IxHash
+%           my $group = $defs->{$opt}->{multiple};
+%           if ( $group ) {
+%             my @values = split("\n", $opt{$opt});
+%             $multiples{$group} ||= [];
+%             push @{ $multiples{$group} }, [ $opt, @values ] if @values;
+%             delete $opt{$opt};
+%           } elsif (length($opt{$opt})) { # the normal case
+%#         foreach my $opt ( keys %opt ) { 
   
             <TR>
               <TD ALIGN="right" VALIGN="top" WIDTH="33%"><% $opt %>:&nbsp;</TD>
               <TD ALIGN="left" WIDTH="67%"><% encode_entities($opt{$opt}) %></TD>
             </TR>
-%         } 
-  
+%             delete $opt{$opt};
+%           }
+%         }
+%         # now any that are somehow not in the options list
+%         foreach my $opt (keys %opt) {
+%           if ( length($opt{$opt}) ) {
+            <TR>
+              <TD ALIGN="right" VALIGN="top" WIDTH="33%"><% $opt %>:&nbsp;</TD>
+              <TD ALIGN="left" WIDTH="67%"><% encode_entities($opt{$opt}) %></TD>
+            </TR>
+%           }
+%         }
+%         # now show any multiple-option groups
+%         foreach (sort keys %multiples) {
+%           my $set = $multiples{$_};
+            <TR><TD ALIGN="center" COLSPAN=2><TABLE CLASS="grid">
+              <TR>
+%             foreach my $col (@$set) {
+                <TH><% shift @$col %></TH>
+%             }
+              </TR>
+%           while ( 1 ) {
+              <TR>
+%             my $end = 1;
+%             foreach my $col (@$set) {
+                <TD><% shift @$col %></TD>
+%               $end = 0 if @$col;
+%             }
+              </TR>
+%             last if $end;
+%           }
+            </TABLE></TD></TR>
+%         } #foreach keys %multiples
+
         </TABLE>
       </TD>
 
index 8a6fbc2..b75757f 100755 (executable)
@@ -20,7 +20,7 @@ Select which packages agents of this type may sell to customers<BR>
               'source_obj'    => $agent_type,
               'link_table'    => 'type_pkgs',
               'target_table'  => 'part_pkg',
-              'name_callback' => sub { $_[0]->pkg_comment(nopkgpart => 1); },
+              'name_callback' => sub { encode_entities( $_[0]->pkg_comment(nopkgpart => 1) ); },
               'target_link'   => $p.'edit/part_pkg.cgi?',
               'disable-able'  => 1,
 
index 5d2c662..c696106 100644 (file)
@@ -7,11 +7,24 @@ calls and SMS messages.  Each CDR type must have a set of rates
 configured in the rate tables.
 <BR>
 <FORM METHOD="POST" ACTION="<% "${p}edit/process/cdr_type.cgi" %>">
-<% include('/elements/auto-table.html',
-  'header' => [ 'Type#', 'Name' ],
-  'fields' => [ qw( cdrtypenum cdrtypename ) ],
+<TABLE ID="AutoTable" BORDER=0 CELLSPACING=0>
+  <TR>
+    <TH>Type#</TH>
+    <TH>Name</TH>
+  </TR>
+  <TR ID="cdr_template">
+    <TD>
+      <INPUT NAME="cdrtypenum" SIZE=16 MAXLENGTH=16 ALIGN="right">
+    </TD>
+    <TD>
+      <INPUT NAME="cdrtypename" SIZE=16 MAXLENGTH=16>
+    </TD>
+  </TR>
+<&  /elements/auto-table.html,
+  'template_row' => 'cdr_template',
   'data'   => \@data,
-  ) %>
+&>
+</TABLE>
 <INPUT TYPE="submit" VALUE="Apply changes"> </FORM> <BR>
 <% include('/elements/footer.html') %>
 <%init>
@@ -20,7 +33,6 @@ die "access denied"
   unless $FS::CurrentUser::CurrentUser->access_right('Configuration');
 
 my @data = (
-  map { [ $_->cdrtypenum, $_->cdrtypename ] }
   qsearch({ 
     'table' => 'cdr_type',
     'hashref' => {},
diff --git a/httemplate/edit/credit-cust_bill_pkg.html b/httemplate/edit/credit-cust_bill_pkg.html
new file mode 100644 (file)
index 0000000..e317936
--- /dev/null
@@ -0,0 +1,249 @@
+<& /elements/header-popup.html, 'Credit line items' &>
+
+<FORM ACTION="process/credit-cust_bill_pkg.html" METHOD="POST">
+<INPUT TYPE="hidden" NAME="crednum" VALUE="">
+<INPUT TYPE="hidden" NAME="custnum" VALUE="<% $custnum |h %>">
+<INPUT TYPE="hidden" NAME="paybatch" VALUE="">
+<INPUT TYPE="hidden" NAME="_date" VALUE="<% time %>">
+<table>
+
+% my $old_invnum = 0; 
+%# foreach my $cust_bill_pkg ( @cust_bill_pkg ) {
+% foreach my $item ( @items ) {
+%   my( $setuprecur, $cust_bill_pkg ) = @$item;
+
+%   my $method = $setuprecur eq 'setup' ? 'setup' : 'recur';
+%   my $amount = $cust_bill_pkg->$method();
+%   my $credited = $cust_bill_pkg->credited('', '', 'setuprecur'=>$method);
+%   $amount -= $credited;
+%   $amount = sprintf('%.2f', $amount);
+%   next unless $amount > 0;
+
+%   if ( $cust_bill_pkg->invnum ne $old_invnum ) {
+      <TR><TD COLSPAN=3 BGCOLOR="#f8f8f8">&nbsp;</TD></TR>
+      <TR><TH COLSPAN=3 BGCOLOR="#f8f8f8" ALIGN="left">Invoice #<% $cust_bill_pkg->invnum %> - <% time2str($date_format, $cust_bill_pkg->cust_bill->_date) %></TD></TR>
+%     $old_invnum = $cust_bill_pkg->invnum;
+%   }
+
+    <TR>
+      <TD>
+        <INPUT TYPE            = "checkbox"
+               NAME            = "billpkgnum<% $cust_bill_pkg->billpkgnum.'-'. $setuprecur %>"
+               VALUE           = "<% $amount %>"
+               onClick         = "calc_total(this)"
+               data-amount     = "<% $amount %>"
+               data-billpkgnum = "<% $cust_bill_pkg->billpkgnum %>"
+               data-setuprecur = "<% $setuprecur %>"
+        >
+      </TD>
+      <TD BGCOLOR="#ffffff"><% $cust_bill_pkg->desc |h %></TD>
+%#    show one-time/setup vs recur vs usage?
+      <TD BGCOLOR="#ffffff" ALIGN="right"><% $money_char. $amount %></TD>
+    </TR>
+
+% }
+
+<TR><TD COLSPAN=3 BGCOLOR="#f8f8f8">&nbsp;</TD></TR>
+<TR>
+  <TD></TD>
+  <TD ALIGN="right">Subtotal: </TD>
+  <TD ALIGN="right" ID="subtotal_td"><% $money_char %><% sprintf('%.2f', 0) %></TD>
+</TR>
+<TR>
+  <TD></TD>
+  <TD ALIGN="right">Taxes: </TD>
+  <TD ALIGN="right" ID="taxtotal_td"><% $money_char %><% sprintf('%.2f', 0) %></TD>
+</TR>
+<TR>
+  <TD></TD>
+  <TH ALIGN="right">Total credit amount: </TD>
+  <TH ALIGN="right" ID="total_td"><% $money_char %><% sprintf('%.2f', 0) %></TD>
+</TR>
+<INPUT TYPE="hidden" NAME="amount" ID="total_el" VALUE="0.00">
+
+</table>
+
+<table>
+
+<& /elements/tr-select-reason.html,
+              'field'          => 'reasonnum',
+              'reason_class'   => 'R',
+              #XXX reconcile both this and show_taxes wanteding to enable this
+              'control_button' => "document.getElementById('credit_button')",
+              'cgi'            => $cgi,
+&>
+
+<TR>
+  <TD ALIGN="right"><% mt('Additional info') |h %></TD>
+  <TD>
+    <INPUT TYPE="text" NAME="addlinfo" VALUE="<% $cgi->param('addlinfo') |h %>">
+  </TD>
+</TR>
+
+</table>
+
+<BR>
+<INPUT TYPE="submit" ID="credit_button" VALUE="Credit" DISABLED>
+
+</FORM>
+
+<% include( '/elements/xmlhttp.html',
+            'url' =>  $p.'misc/xmlhttp-cust_bill_pkg-calculate_taxes.html',
+            'subs' => [ 'calculate_taxes' ],
+          )
+%>
+<SCRIPT TYPE="text/javascript">
+
+function show_taxes(arg) {
+  var argsHash = eval('(' + arg + ')');
+
+  //XXX add an 'ErrorMessage' section to the HTML and re-enable
+  //var error = argsHash['error'];
+
+  //var paragraph = document.getElementById('ErrorMessage');
+  //if (error) {
+  //  paragraph.innerHTML = 'Error: ' + error;
+  //  paragraph.style.color = '#ff0000';
+  //} else {
+  //  paragraph.innerHTML = '';
+  //}
+
+  var taxlines = argsHash['taxlines'];
+
+//XXX display the tax lines? just a total will do for now
+//
+//  var table = document.getElementById('ApplicationTable');
+//
+//  var aFoundRow = 0;
+//  for (i = 0; taxlines[i]; i++) {
+//    var itemdesc = taxlines[i][0];
+//    var locnum   = taxlines[i][2];
+//    if (taxlines[i][3]) {
+//      locnum  = taxlines[i][3];
+//    }
+//
+//    var found = 0;
+//    for (var row = 2; table.rows[row]; row++) {
+//      var inputs = table.rows[row].getElementsByTagName('input');
+//      if (! inputs.length) {
+//        while ( table.rows[row] ) {
+//           table.deleteRow(row);
+//        }
+//        break;
+//      }
+//      if ( inputs.item(4).value == itemdesc && inputs.item(2).value == locnum )
+//      {
+//        inputs.item(0).value = taxlines[i][1];
+//        aFoundRow = found = row;
+//        break;
+//      }
+//    }
+//    if (! found) {
+//      var row = table.insertRow(table.rows.length);
+//      var warning_cell = document.createElement('TD');
+//      warning_cell.style.color = '#ff0000';
+//      warning_cell.colSpan = 2;
+//      warning_cell.innerHTML = 'Calculated Tax - ' + itemdesc + ' - ' +
+//                               taxlines[i][1] + ' will not be applied';
+//      row.appendChild(warning_cell);
+//    }
+//  }
+//
+//  if (aFoundRow) {
+//    sub_changed(table.rows[aFoundRow].getElementsByTagName('input').item(0));
+//  }
+
+  var subtotal = parseFloat( argsHash['subtotal'] );
+
+  var taxtotal = parseFloat( argsHash['taxtotal'] );
+  document.getElementById('taxtotal_td').innerHTML =
+    '<% $money_char %>' + taxtotal.toFixed(2);
+
+  var total = subtotal + taxtotal;
+  document.getElementById('total_td').innerHTML =
+    '<% $money_char %>' + total.toFixed(2);
+  document.getElementById('total_el').value = total.toFixed(2);
+
+  //XXX reconcile both this and the reason selector wanteding to enable this
+  if ( total > 0 ) {
+    document.getElementById('credit_button').disabled = false;
+  }
+    
+}
+
+function calc_total(what) {
+
+  document.getElementById('credit_button').disabled = true;
+
+  var subtotal = 0;
+  // bah, a pain, just using an attribute var re = /^billpkgnum(\d+)$/;
+
+  var el = what.form.elements;
+  var billpkgnums = [];
+  var setuprecurs = [];
+  var amounts = [];
+  for (var i=0; i<el.length; i++) {
+    if ( el[i].type == 'checkbox' && el[i].checked ) {
+      subtotal += parseFloat( el[i].getAttribute('data-amount') );
+      amounts.push(     el[i].getAttribute('data-amount') );
+      billpkgnums.push( el[i].getAttribute('data-billpkgnum') );
+      setuprecurs.push( el[i].getAttribute('data-setuprecur') );
+    }
+  }
+
+  document.getElementById('subtotal_td').innerHTML =
+    '<% $money_char %>' + subtotal.toFixed(2);
+
+  var args = new Array(
+    'custnum',     '<% $custnum %>',
+    'subtotal',    subtotal,
+    'billpkgnums', billpkgnums.join(),
+    'setuprecurs', setuprecurs.join(),
+    'amounts',     amounts.join()
+  );
+
+  calculate_taxes( args, show_taxes );
+
+}
+</SCRIPT>
+
+<%init>
+
+my $curuser = $FS::CurrentUser::CurrentUser;
+die "access denied" unless $curuser->access_right('Post credit');
+
+#a tiny bit of false laziness w/search/cust_bill_pkg.cgi, but we're pretty
+# specialized and a piece of UI, not a report
+#slightly more false laziness w/httemplate/edit/elements/ApplicationCommon.html
+# show_taxes & calc_total here/do_calculate_tax there
+
+my $conf = new FS::Conf;
+my $money_char = $conf->config('money_char') || '$';
+my $date_format = $conf->config('date_format') || '%m/%d/%Y';
+
+$cgi->param('custnum') =~ /^(\d+)$/ or die 'illegal custnum';
+my $custnum = $1;
+
+my $cust_main = qsearchs({
+  'table'     => 'cust_main',
+  'hashref'   => { 'custnum' => $custnum },
+  'extra_sql' => ' AND '. $curuser->agentnums_sql,
+}) or die 'unknown customer';
+
+my @cust_bill_pkg = qsearch({
+  'select'    => 'cust_bill_pkg.*',
+  'table'     => 'cust_bill_pkg',
+  'addl_from' => 'LEFT JOIN cust_bill USING (invnum)',
+  'extra_sql' => "WHERE custnum = $custnum AND pkgnum != 0",
+  'order_by'  => 'ORDER BY invnum ASC, billpkgnum ASC',
+});
+
+my @items = map { my %hash = $_->disintegrate;
+                  map [ $_, $hash{$_} ],
+                    keys(%hash);
+                }
+              @cust_bill_pkg;
+
+#omit line items which have been previously credited?  would be nice
+
+</%init>
index 6e8a9c9..4dba1e7 100755 (executable)
@@ -34,6 +34,7 @@
     <TD>
       <INPUT TYPE="text" NAME="addlinfo" VALUE="<% $cgi->param('addlinfo') |h %>">
     </TD>
+  </TR>
 
 % if ( $conf->exists('credits-auto-apply-disable') ) {
         <INPUT TYPE="HIDDEN" NAME="apply" VALUE="no">
diff --git a/httemplate/edit/elements/part_export/broadband_snmp.html b/httemplate/edit/elements/part_export/broadband_snmp.html
new file mode 100644 (file)
index 0000000..4c0367c
--- /dev/null
@@ -0,0 +1,101 @@
+<%doc>
+</%doc>
+<& head.html, %opt &>
+<INPUT TYPE="hidden" NAME="options" VALUE="community,version,ip_addr_change_to_new,timeout">
+<& /elements/tr-select.html,
+  label   => 'SNMP version',
+  field   => 'version',
+  options => [ '', 'v1', 'v2c' ],
+  labels  => { v1 => '1', v2c => '2c' },
+  curr_value => $part_export->option('version') &>
+<& /elements/tr-input-text.html,
+  label   => 'Community',
+  field   => 'community',
+  curr_value  => $part_export->option('community'),
+&>
+<& /elements/tr-checkbox.html,
+  label   => 'Send IP address changes to new address',
+  field   => 'ip_addr_change_to_new',
+  value   => 1,
+  curr_value => $part_export->option('ip_addr_change_to_new'),
+&>
+<& /elements/tr-input-text.html,
+  label   => 'Timeout (seconds)',
+  field   => 'timeout',
+  curr_value  => $part_export->option('timeout'),
+&>
+</TABLE>
+<script type="text/javascript">
+function open_select_mib(obj) {
+  nd(1); // if there's already one open, close it
+  var rownum = obj.rownum;
+  var curr_oid = obj.value || '';
+  var url = '<%$fsurl%>/elements/select-mib-popup.html?' +
+            'callback=receive_mib;' +
+            'arg=' + rownum +
+            ';curr_value=' + curr_oid;
+  overlib(
+    OLiframeContent(url, 550, 450, '<% $popup_name %>', 0, 'auto'),
+    CAPTION, 'Select MIB object', STICKY, AUTOSTATUSCAP,
+    MIDX, 0, MIDY, 0, DRAGGABLE, CLOSECLICK,
+    BGCOLOR, '#333399', CGCOLOR, '#333399',
+    CLOSETEXT, 'Close'
+  );
+}
+function receive_mib(obj, rownum) {
+  //console.log(JSON.stringify(obj));
+  // we don't really need the numeric OID or any of the other properties
+  document.getElementById('oid'+rownum).value = obj.fullname;
+  document.getElementById('datatype'+rownum).value = obj.type;
+}
+</script>
+
+<table bgcolor="#cccccc" border=0 cellspacing=3>
+<TR>
+  <TH>Action</TH>
+  <TH>Object</TH>
+  <TH>Type</TH>
+  <TH>Value</TH>
+</TR>
+<TR id="mytemplate">
+  <TD>
+    <SELECT NAME="action">
+%     foreach ('', qw(insert delete replace suspend unsuspend)) {
+      <OPTION VALUE="<%$_%>"><%$_%></OPTION>
+%     }
+    </SELECT>
+  </TD>
+  <TD>
+    <INPUT NAME="oid" ID="oid" SIZE="60" onclick="open_select_mib(this)">
+  </TD>
+  <TD>
+    <INPUT TYPE="text" NAME="datatype" ID="datatype" READONLY=1>
+  </TD>
+  <TD>
+    <INPUT NAME="value" ID="value">
+  </TD>
+</TR>
+<& /elements/auto-table.html,
+  template_row  => 'mytemplate',
+  fieldorder    => ['action', 'oid', 'datatype', 'value'],
+  data          => \@data,
+&>
+<INPUT TYPE="hidden" NAME="multi_options" VALUE="action,oid,datatype,value">
+<& foot.html, %opt &>
+<%init>
+my %opt = @_;
+my $part_export = $opt{part_export} || FS::part_export->new;
+
+my @actions = split("\n", $part_export->option('action'));
+my @oids    = split("\n", $part_export->option('oid'));
+my @types   = split("\n", $part_export->option('datatype'));
+my @values  = split("\n", $part_export->option('value'));
+
+my @data;
+while (@actions or @oids or @values) {
+  my @thisrow = (shift(@actions), shift(@oids), shift(@types), shift(@values));
+  push @data, \@thisrow if grep length($_), @thisrow;
+}
+
+my $popup_name = 'popup-'.time."-$$-".rand() * 2**32;
+</%init>
diff --git a/httemplate/edit/elements/part_export/foot.html b/httemplate/edit/elements/part_export/foot.html
new file mode 100644 (file)
index 0000000..9cb8073
--- /dev/null
@@ -0,0 +1,6 @@
+</TABLE>
+<INPUT TYPE="hidden" NAME="nodomain" VALUE="<% $opt{export_info}{nodomain} %>">
+<INPUT TYPE="submit" VALUE="<% $opt{part_export}->exportnum ? 'Apply changes' : 'Add export' %>">
+<%init>
+my %opt = @_;
+</%init>
diff --git a/httemplate/edit/elements/part_export/head.html b/httemplate/edit/elements/part_export/head.html
new file mode 100644 (file)
index 0000000..cb0ab89
--- /dev/null
@@ -0,0 +1,19 @@
+% if ( $export_info->{no_machine} ) {
+<INPUT TYPE="hidden" NAME="machine" VALUE="">
+<INPUT TYPE="hidden" NAME="svc_machine" VALUE="N">
+% } else {
+% # clone this from edit/part_export.cgi if this case ever gets used
+% }
+<INPUT TYPE="hidden" NAME="exporttype" VALUE="<%$layer |h%>">
+<% ntable('cccccc', 2) %>
+<TR>
+  <TD ALIGN="right" ><% emt('Description') %></TD>
+  <TD BGCOLOR="#ffffff" WIDTH="600"><% $notes %></TD>
+</TR>
+<%init>
+my %opt = @_;
+my $layer = $opt{layer};
+my $part_export = $opt{part_export};
+my $export_info = $opt{export_info};
+my $notes = $opt{notes} || $export_info->{notes};
+</%init>
index 0407ee7..4dd253b 100644 (file)
@@ -62,6 +62,15 @@ my $widget = new HTML::Widgets::SelectLayers(
   'html_between'    => "</TD></TR></TABLE>\n",
   'layer_callback'  => sub {
     my $layer = shift;
+    # create 'config_element' to generate the whole layer with a Mason component
+    if ( my $include = $exports->{$layer}{config_element} ) {
+      # might need to adjust the scope of  this at some point
+      return $m->scomp($include, 
+        part_export => $part_export,
+        layer       => $layer,
+        export_info => $exports->{$layer}
+      );
+    }
     my $html = qq!<INPUT TYPE="hidden" NAME="exporttype" VALUE="$layer">!.
                ntable("#cccccc",2);
 
index f3ad8f5..50aeb45 100755 (executable)
@@ -622,23 +622,23 @@ END
 my $warning =
   'Changing the setup or recurring fee will create a new package definition. '.
   'Continue?';
-              
+
+$javascript .= "function confirm_submit(f) {";
 if ( $conf->exists('part_pkg-lineage') ) {
   $javascript .= "
-    function confirm_submit(f) {
-    
-      var fields = Array('setup_fee','recur_fee');
-      for(var i=0; i < fields.length; i++) {
-          if ( f[fields[i]].value != f[fields[i]].defaultValue ) {
-              return confirm('$warning');
-          }
-      }
-      return true;
+
+    var fields = Array('setup_fee','recur_fee');
+    for(var i=0; i < fields.length; i++) {
+        if ( f[fields[i]].value != f[fields[i]].defaultValue ) {
+            return confirm('$warning');
+        }
     }
 ";
 }
-
-$javascript .= '</SCRIPT>';
+$javascript .= "
+  return true;
+}
+</SCRIPT>";
 
 tie my %plans, 'Tie::IxHash', %{ FS::part_pkg::plan_info() };
 
index b661de7..ba9881d 100644 (file)
@@ -10,7 +10,6 @@ die "access denied"
     unless $FS::CurrentUser::CurrentUser->access_right('Configuration');
 
 my %vars = $cgi->Vars;
-warn Dumper(\%vars)."\n";
 
 my %old = map { $_->cdrtypenum => $_ } qsearch('cdr_type', {});
 
diff --git a/httemplate/edit/process/credit-cust_bill_pkg.html b/httemplate/edit/process/credit-cust_bill_pkg.html
new file mode 100644 (file)
index 0000000..8b2f3f3
--- /dev/null
@@ -0,0 +1,44 @@
+%if ($error) {
+%  errorpage_popup($error); #XXX redirect back for correction...
+%} else {
+<& /elements/header-popup.html, 'Credit successful' &>
+  <SCRIPT TYPE="text/javascript">
+    window.top.location.reload();
+  </SCRIPT>
+  </BODY></HTML>
+% }
+<%init>
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Post credit');
+
+my @billpkgnum_setuprecurs =
+  map { $_ =~ /^billpkgnum(\d+\-\w*)$/ or die 'gm#23'; $1; } 
+  grep { $_ =~ /^billpkgnum\d+\-\w*$/ && $cgi->param($_) } $cgi->param;
+
+my @billpkgnums = ();
+my @setuprecurs = ();
+my @amounts = ();
+foreach my $billpkgnum_setuprecur (@billpkgnum_setuprecurs) {
+  my $amount = $cgi->param("billpkgnum$billpkgnum_setuprecur");
+  my( $billpkgnum, $setuprecur ) = split('-', $billpkgnum_setuprecur);
+  push @billpkgnums, $billpkgnum;
+  push @setuprecurs, $setuprecur;
+  push @amounts,     $amount;
+}
+
+my $error = FS::cust_credit->credit_lineitems(
+  #the lineitems to credit
+  'billpkgnums'       => \@billpkgnums,
+  'setuprecurs'       => \@setuprecurs,
+  'amounts'           => \@amounts,
+
+  #the credit
+  'newreasonnum'      => scalar($cgi->param('newreasonnum')),
+  'newreasonnum_type' => scalar($cgi->param('newreasonnumT')),
+  map { $_ => scalar($cgi->param($_)) }
+    #fields('cust_credit')  
+    qw( custnum _date amount reason reasonnum addlinfo ), #pkgnum eventnum
+);
+
+</%init>
index 776112a..245f31a 100755 (executable)
@@ -15,7 +15,7 @@
 %
 %  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
 %  
-<% header(emt('Credit sucessful')) %>
+<% header(emt('Credit successful')) %>
   <SCRIPT TYPE="text/javascript">
     window.top.location.reload();
   </SCRIPT>
@@ -27,7 +27,7 @@
 die "access denied"
   unless $FS::CurrentUser::CurrentUser->access_right('Post credit');
 
-$cgi->param('custnum') =~ /^(\d*)$/ or die "Illegal custnum!";
+$cgi->param('custnum') =~ /^(\d+)$/ or die "Illegal custnum!";
 my $custnum = $1;
 
 $cgi->param('reasonnum') =~ /^(-?\d+)$/ or die "Illegal reasonnum";
index 6432d6b..bcb9c0d 100644 (file)
@@ -13,15 +13,40 @@ my $exportnum = $cgi->param('exportnum');
 
 my $old = qsearchs('part_export', { 'exportnum'=>$exportnum } ) if $exportnum;
 
+my %vars = $cgi->Vars;
 #fixup options
 #warn join('-', split(',',$cgi->param('options')));
 my %options = map {
-  my @values = $cgi->param($_);
-  my $value = scalar(@values) > 1 ? join (' ', @values) : $values[0];
+  my $value = $vars{$_};
+  $value =~ s/\0/ /g; # deal with multivalued options
   $value =~ s/\r\n/\n/g; #browsers? (textarea)
   $_ => $value;
 } split(',', $cgi->param('options'));
 
+# deal with multiline options
+# %vars should never contain incomplete rows, but just in case it does, 
+# we make a list of all the row indices that contain values, and 
+# then write a line in each option for each row, even if it's empty.
+# This ensures that all values with the same row index line up.
+my %optionrows;
+foreach my $option (split(',', $cgi->param('multi_options'))) {
+  $optionrows{$option} = {};
+  my %values; # bear with me
+  for (keys %vars) {
+    /^$option(\d+)/ or next;
+    $optionrows{$option}{$1} = $vars{$option.$1};
+    $optionrows{_ALL_}{$1} = 1 if length($vars{$option.$1});
+  }
+}
+foreach my $option (split(',', $cgi->param('multi_options'))) {
+  my $value = '';
+  foreach my $row (sort keys %{$optionrows{_ALL_}}) {
+    $value .= ($optionrows{$option}{$row} || '') . "\n";
+  }
+  chomp($value);
+  $options{$option} = $value;
+}
+
 my $new = new FS::part_export ( {
   map {
     $_, scalar($cgi->param($_));
index 7ee39ef..9e6b873 100644 (file)
     <TD><INPUT TYPE="text" NAME="ratetimename" VALUE="<% $rate_time ? $rate_time->ratetimename : '' %>"></TD>
   </TR>
 </TABLE>
-<% include('/elements/auto-table.html', 
-                      'header' => [ '', 'Start','','', '','End','','' ],
-                      'fields' => [ qw(sd sh sm sa ed eh em ea) ],
-                      'select' => [ ($day, $hour, $min, $ampm) x 2 ],
-                      'data'   => \@data,
-   ) %>
+<TABLE>
+  <TR>
+    <TH COLSPAN=4 ALIGN="center">Start</TH>
+    <TH COLSPAN=4 ALIGN="center">End</TH>
+  </TR>
+  <TR id="mytemplate">
+%   for my $pre (qw(s e)) {
+%     for my $f (qw(d h m a)) { # day, hour, minute, am/pm
+        <TD>
+          <SELECT NAME="<%$pre.$f%>">
+%       my $i = 0;
+%       while ($i < @{ $choices{$f} }) {
+            <OPTION VALUE="<%$choices{$f}[$i]%>">
+%         $i++;
+            <%$choices{$f}[$i]%></OPTION>
+%         $i++;
+%       }
+          </SELECT>
+        </TD>
+%     } #$f
+%   } #$pre
+  </TR>
+<& /elements/auto-table.html, 
+    'template_row' => 'mytemplate',
+    'data'   => \@data,
+    'fieldorder' => [qw(sd sh sm sa ed eh em ea)],
+&>
+</TABLE>
 <INPUT TYPE="submit" VALUE="<% $rate_time ? 'Apply changes' : 'Add period'%>">
 </FORM>
 <BR>
@@ -42,7 +64,12 @@ my $day = [ 0 => 'Sun',
 my $hour = [ map( {$_, sprintf('%02d',$_) } 12, 1..11 )];
 my $min  = [ map( {$_, sprintf('%02d',$_) } 0,30  )];
 my $ampm = [ 0 => 'AM', 1 => 'PM' ];
-
+my %choices = (
+  'd' => $day,
+  'h' => $hour,
+  'm' => $min,
+  'a' => $ampm,
+);
 if($ratetimenum) {
   $action = 'Edit';
   $rate_time = qsearchs('rate_time', {ratetimenum => $ratetimenum})
index 4922274..9aff94e 100644 (file)
 <%doc>
-
-Example:
-<% include('/elements/auto-table.html',
-
-              ###
-              # required
-              ###
-
-              'header'        => [ '#',  'Item', 'Amount' ],
-              'fields'        => [ 'id', 'name', 'amount' ],
-
-              ###
-              # highly recommended
-              ###
-
-              'size'          => [ 4, 12, 8 ],
-              'maxl'          => [ 4, 12, 8 ],
-              'align'         => [ 'right', 'left', 'right' ],
-
-              ###
-              # optional
-              ###
-
-              'data'          => [ [ 1,  'Widget',      25 ], 
-                                   [ 12, 'Super Widget, 7  ] ],
-              #or
-              'records'       => [ qsearch('item', { } ) ],
-              # or any other array of FS::Record objects
-
-              'select'        => [ '',
-                                   [ 1 => 'option 1',
-                                     2 => 'option 2', ...
-                                   ], # options for second field
-                                   '' ],
-
-              'prefix'        => 'mytable_',
-) %>
-
-Values will be passed through as "mytable_id1", etc.
+(within a form)
+<table>
+<tr>
+  <th>Field 1</th>
+  <th>Field 2</th>
+</tr>
+<tr id="mytemplate">
+  <td><input type="text" name="field1"></td>
+  <td><select name="field2">...</td>
+  ...
+</tr>
+</table>
+<& /elements/auto-table.html,
+  table => 'mytable',
+  template_row = 'mytemplate',
+  rows => [
+            { field1 => 'foo', field2 => 'CA', ... },
+            { field1 => 'bar', field2 => 'TX', ... }, ...
+          ],
+&>
+
+  or if you prefer:
+...
+  fieldorder => [ 'field1', 'field2', ... ],
+  rows => [
+            [ 'foo', 'CA' ],
+            [ 'bar', 'TX' ],
+          ],
+
+In the process/ handler, something like:
+my @rows;
+my %vars = $cgi->Vars;
+for my $k ( keys %vars ) {
+  $k =~ /^${pre}magic(\d+)$/ or next;
+  my $rownum = $1;
+  # find all submitted names ending in this rownum
+  my %thisrow = 
+    map { $_ => $vars{$_} } 
+    grep /^(.*[\d])$rownum$/, keys %vars;
+  $thisrow->{num} = delete $thisrow{"${pre}magic$rownum"};
+  push @rows, $thisrow;
+}
 </%doc>
-
-<TABLE ID="<% $prefix %>AutoTable" BGCOLOR="#cccccc" BORDER=0 CELLSPACING=0>
-  <TR>
-% foreach (@header) {
-    <TH><% $_ %></TH>
-% }
-  </TR>
-% my $row = 0;
-% for ( $row = 0; $row < scalar @data; $row++ ) {
-  <TR>
-%   my $col = 0;
-%   for ( $col = 0; $col < scalar @fields; $col++ ) {
-%     my $id = $prefix . $fields[$col];
-%     # don't suffix rownum in the final, blank row
-%     $id .= $row if $row < (scalar @data) - 1; 
-    <TD>
-%     my @o = @{ $select[$col] };
-%     if( @o ) {
-      <SELECT NAME="<% $id %>" ID="<% $id %>">
-%       while(@o) {
-%         my $val = shift @o;
-        <OPTION VALUE=<% $val %><% 
-$val eq $data[$row][$col] ? ' SELECTED' : ''%>><% shift @o %></OPTION>
-%       }
-      </SELECT>
-%     }
-%     else {
-      <INPUT TYPE      = "text"
-             NAME      = "<% $id %>"
-             ID        = "<% $id %>"
-             SIZE      = <% $size[$col] %>
-             MAXLENGTH = <% $maxl[$col] %>
-             STYLE     = "text-align:<% $align[$col] %>"
-             VALUE     = "<% $data[$row][$col] %>"
-%       if( $opt{'autoadd'} ) {
-             onchange  = "possiblyAddRow(this);"
-%       }
-      >
-    </TD>
-%     }
-%   }
-    <TD>
-      <IMG SRC     = "<% "${p}images/cross.png" %>" 
-           ALT     = "X" 
-           onclick = "deleteRow(this);"
-           >
-    </TD>
-  </TR>
-% }
-</TABLE>
-% if( !$opt{'autoadd'} ) {
-<INPUT TYPE="button" VALUE="Add" onclick="<% $prefix %>addRow();"><BR>
-% }
-
-<SCRIPT TYPE="text/javascript">
-  var <% $prefix %>rownum = <% $row %>;
-  var <% $prefix %>table = document.getElementById('<% $prefix %>AutoTable');
-  // last row is initially blank, clone it and remove it
-  var <% $prefix %>_blank = 
-    <% $prefix %>table.rows[<% $prefix %>table.rows.length-1].cloneNode(true);
-% if( !$opt{'autoadd'} ) {
-  <% $prefix %>table.deleteRow(<% $prefix %>table.rows.length-1);
-% }
-  
-    
-
-  function rownum_of(obj) {
-    return (obj.parentNode.parentNode.sectionRowIndex);
+<tbody id="<%$pre%>autotable"></tbody>
+<script type="text/javascript">
+var <%$pre%>template;
+var <%$pre%>tbody;
+var <%$pre%>next_rownum;
+var <%$pre%>set_rownum;
+var <%$pre%>addRow;
+var <%$pre%>deleteRow;
+var <%$pre%>fieldorder = <% to_json($fieldorder) %>;
+
+function <%$pre%>possiblyAddRow_factory(obj) {
+  var callback = obj.onchange;
+  return function() {
+    if ( obj.rownum == <%$pre%>tbody.lastChild.rownum ) {
+      // then this is the last row, and it's being changed, so spawn a new row
+      <%$pre%>addRow();
+    }
+    if ( callback ) {
+      callback.apply(obj);
+    }
   }
+}
 
-  function <% $prefix %>possiblyAddRow(obj) {
-    if ( <% $prefix %>rownum == rownum_of(obj) ) {
-      <% $prefix %>addRow();
+function <%$pre%>set_rownum(obj, rownum) {
+  obj.rownum = rownum;
+  if ( obj.id ) {
+    obj.id = obj.id + rownum;
+  }
+  if ( obj.name ) {
+    obj.name = obj.name + rownum;
+    // also, in this case it's a form field that will be part of the record
+    // so set up an onchange handler
+    obj.onchange = <%$pre%>possiblyAddRow_factory(obj);
+  }
+  for (var i = 0; i < obj.children.length; i++) {
+    if ( obj.children[i] instanceof Node ) {
+      <%$pre%>set_rownum(obj.children[i], rownum);
     }
   }
+}
 
-  function <% $prefix %>addRow() {
-    var row = <% $prefix %>table.insertRow(-1);
-    var cells = <% $prefix %>_blank.cells;
-    for (i=0; i<cells.length; i++) {
-      var node = row.appendChild(cells[i].cloneNode(true));
-      var input = node.children[0];
-      input.id = input.id + row.sectionRowIndex;
-      input.name = input.name + row.sectionRowIndex;
+function <%$pre%>addRow(data) {
+  // duplicate the node
+  // warning: cloneNode doesn't clone event handlers that were set through 
+  // the DOM
+  // if 'data' is an object, prepopulate the row's fields with the object's
+  // elements
+  // returns the rownum of the new row
+  var row = <%$pre%>template.cloneNode(true);
+  <%$pre%>tbody.appendChild(row);
+  var this_rownum = <%$pre%>next_rownum;
+  <%$pre%>set_rownum(row, this_rownum);
+  if(data instanceof Array) {
+    for (i = 0; i < data.length && i < <%$pre%>fieldorder.length; i++) {
+      var el = document.getElementsByName(<%$pre%>fieldorder[i] + this_rownum)[0];
+      if (el) {
+        el.value = data[i];
+      }
+    }
+  } else if (data instanceof Object) {
+    for (var field in data) {
+      var el = document.getElementsByName(field + this_rownum)[0];
+      if (el) {
+        el.value = data[field];
+%       # doesn't work for checkbox
+      }
     }
-    <% $prefix %>rownum++;
+  } // else nothing
+  <%$pre%>next_rownum++;
+  return this_rownum;
+}
+
+function <%$pre%>deleteRow(rownum) {
+  if ( rownum == <%$pre%>tbody.lastChild.rownum ) {
+    // if this is the last row, spawn another one after it
+    <%$pre%>addRow();
   }
+  var r = document.getElementById('<%$pre%>row' + rownum);
+  <%$pre%>tbody.removeChild(r);
+}
 
-  function deleteRow(obj) {
-    if(<% $prefix %>rownum == rownum_of(obj))  {
-      <% $prefix %>addRow();
-    }
-    <% $prefix %>table.deleteRow(rownum_of(obj));
-    <% $prefix %>rownum--;
-    return(false);
+function <%$pre%>init() {
+  <%$pre%>template = document.getElementById(<% $template_row |js_string%>);
+  <%$pre%>tbody = document.getElementById('<%$pre%>autotable');
+  <%$pre%>next_rownum = <%$pre%>template.sectionRowIndex;
+  // detach the template row
+  var table = <%$pre%>template.parentNode;
+  table.removeChild(<%$pre%>template);
+  // give it an id
+  <%$pre%>template.id = <%$pre |js_string%> + 'row';
+  // and a magic identifier so we know it's been submitted
+  var magic = document.createElement('INPUT');
+  magic.setAttribute('type', 'hidden');
+  magic.setAttribute('name', '<%$pre%>magic');
+  magic.value = '1';
+  // and a delete button
+%# should this be enclosed in an actual <button> for aesthetics?
+  var delete_button = document.createElement('IMG');
+  delete_button.id = 'delete_button';
+  delete_button.src = '<%$fsurl%>images/cross.png';
+  delete_button.alt = 'X';
+  // use an inline string for this so that it will be cloned properly
+  delete_button.setAttribute('onclick', "<%$pre%>deleteRow(this.rownum);");
+  var delete_cell = document.createElement('TD');
+  delete_cell.appendChild(delete_button);
+  delete_cell.appendChild(magic); // it has to go somewhere
+  <%$pre%>template.appendChild(delete_cell);
+
+  // preload rows
+  var rows = <% to_json(\@rows) %>;
+  for (var i = 0; i < rows.length; i++) {
+    <%$pre%>addRow(rows[i]);
   }
 
-</SCRIPT>
+  <%$pre%>addRow();
+}
 
+<%$pre%>init();
+</script>
 <%init>
 my %opt = @_;
-
-my @header = @{ $opt{'header'} };
-my @fields = @{ $opt{'fields'} };
-my @data = ();
-if($opt{'data'}) {
-  @data = @{ $opt{'data'} };
-}
-elsif($opt{'records'}) {
-  foreach my $rec (@{ $opt{'records'} }) {
-    push @data, [ map { $rec->getfield($_) } @fields ];
+my $pre = '';
+$pre = $opt{'table'} . '_' if $opt{'table'};
+my $template_row = $opt{'template_row'}
+  or die "auto-table requires template_row\n"; # a DOM id
+
+# rows that we will preload, as hashrefs of name => value
+my @rows = @{ $opt{'data'} || [] };
+foreach (@rows) {
+  # allow an array of FS::Record objects to be passed
+  if ( blessed($_) and $_->isa('FS::Record') ) {
+    $_ = $_->hashref;
   }
 }
-# else @data = ();
-push @data, [ map {''} @fields ]; # make a blank row
-
-my $prefix = $opt{'prefix'};
-my @size = $opt{'size'} ? @{ $opt{'size'} } : (map {16} @fields);
-my @maxl = $opt{'maxl'} ? @{ $opt{'maxl'} } : @size;
-my @align = $opt{'align'} ? @{ $opt{'align'} } : (map {'right'} @fields);
-my @select = @{ $opt{'select'} || [] };
-foreach (0..scalar(@fields)-1) {
-  $select[$_] ||= [];
-}
+my $fieldorder = $opt{'fieldorder'} || [];
 </%init>
index bfbc179..4e61096 100644 (file)
@@ -130,6 +130,8 @@ tie my %report_invoices, 'Tie::IxHash',
   'Open invoices' => [ \%report_invoices_open, 'Open invoices' ],
   'All invoices'  => [ $fsurl. 'search/cust_bill.html?date', 'List all invoices' ],
   'Advanced invoice reports' => [ $fsurl.'search/report_cust_bill.html', 'by agent, date range, etc.' ],
+  'separator'     => '',
+  'Line items'    => [ $fsurl. 'search/report_cust_bill_pkg.html', 'Individual line item detail' ],
 ;
 
 tie my %report_discounts, 'Tie::IxHash',
@@ -231,13 +233,13 @@ foreach my $svcdb ( FS::part_svc->svc_tables() ) {
 }
 
 tie my %report_packages, 'Tie::IxHash';
-if (    $curuser->access_right('Edit package definitions')
-     || $curuser->access_right('Edit global package definitions')
-   )
-{
-  $report_packages{'Package definitions (by # active)'} =  [ $fsurl.'browse/part_pkg.cgi?active=1', 'Package definitions by number of active packages' ];
-  $report_packages{'separator'} =  '';
-}
+$report_packages{'Package definitions (by # active)'} =  [ $fsurl.'browse/part_pkg.cgi?active=1', 'Package definitions by number of active packages' ]
+  if    $curuser->access_right('Edit package definitions')
+     || $curuser->access_right('Edit global package definitions');
+$report_packages{'Package Costs Report'} = [ $fsurl.'graph/report_cust_pkg_cost.html', 'Package setup and recurring costs graph' ]
+  if $curuser->access_right('Financial reports');
+$report_packages{'separator'} =  ''
+  if keys %report_packages;
 if ( $curuser->access_right('Financial reports') ) {
   $report_packages{'Package churn'} =  [ $fsurl.'graph/report_cust_pkg.html', 'Orders, suspensions and cancellations summary graph' ];
   $report_packages{'separator2'} =  '';
@@ -292,6 +294,11 @@ tie my %report_ticketing, 'Tie::IxHash',
   'Advanced ticket reports' => [ $fsurl.'rt/Search/Build.html?NewQuery=1', 'List tickets by any criteria' ],
 ;
 
+tie my %report_employees, 'Tie::IxHash',
+  'Employee Commission Report' => [ $fsurl.'search/report_employee_commission.html', '' ],
+  'Employee Audit Report' => [ $fsurl.'search/report_employee_audit.html', 'Employee audit report' ],
+;
+
 tie my %report_bill_event, 'Tie::IxHash',
   'All billing events' => [ $fsurl.'search/report_cust_event.html', 'All billing events for a date range' ],
   'Billing event errors' => [ $fsurl.'search/report_cust_event.html?failed=1', 'Failed credit cards, processor or printer problems, etc.' ],
@@ -313,22 +320,32 @@ $report_payments{'Unapplied Payment Aging'} = [ $fsurl.'search/report_unapplied_
 $report_payments{'Deleted Payments / Payment history table'} = [ $fsurl.'search/report_h_cust_pay.html', 'Deleted payments / payment history table' ]
   if $conf->exists('payment-history-report');
 
+tie my %report_credits, 'Tie::IxHash',
+  'Credit Report' => [ $fsurl.'search/report_cust_credit.html', 'Credit report (by employee and/or date range)' ],
+  'Credit application detail' => [ $fsurl.'search/report_cust_credit_bill_pkg.html', 'Line item application detail' ],
+  'Unapplied Credits' => [ $fsurl.'search/report_cust_credit.html?unapplied=1', 'Unapplied credit report (by type and/or date range)' ],
+;
+
+tie my %report_refunds, 'Tie::IxHash',
+  'Refund Report' => [ $fsurl.'search/report_cust_refund.html', 'Refund report (by type and/or date range)' ],
+  'Unapplied Refunds' => [ $fsurl.'search/report_cust_refund.html?unapplied=1', 'Unapplied refund report (by type and/or date range)' ],
+;
+
+tie my %report_sales, 'Tie::IxHash',
+  'Sales, Credits and Receipts' => [ $fsurl.'graph/report_money_time.html', 'Sales, credits and receipts summary graph' ],
+  'Daily Sales, Credits and Receipts' => [ $fsurl.'graph/report_money_time_daily.html', 'Sales, credits and receipts (broken down by day) summary graph' ],
+  'Sales Report' => [ $fsurl.'graph/report_cust_bill_pkg.html', 'Sales report and graph (by agent, package class and/or date range)' ],
+  'Rated Call Sales Report' => [ $fsurl.'graph/report_cust_bill_pkg_detail.html', 'Sales report and graph (by agent, package class, usage class and/or date range)' ],
+  'Sales With Advertising Source' => [ $fsurl.'search/report_cust_bill_pkg_referral.html' ],
+;
+
 tie my %report_financial, 'Tie::IxHash';
-if($curuser->access_right('Financial reports')) {
+if( $curuser->access_right('Financial reports') ) {
 
   %report_financial = (
-    'Sales, Credits and Receipts' => [ $fsurl.'graph/report_money_time.html', 'Sales, credits and receipts summary graph' ],
-    'Daily Sales, Credits and Receipts' => [ $fsurl.'graph/report_money_time_daily.html', 'Sales, credits and receipts (broken down by day) summary graph' ],
-    'Sales Report' => [ $fsurl.'graph/report_cust_bill_pkg.html', 'Sales report and graph (by agent, package class and/or date range)' ],
-    'Rated Call Sales Report' => [ $fsurl.'graph/report_cust_bill_pkg_detail.html', 'Sales report and graph (by agent, package class, usage class and/or date range)' ],
-    'Sales With Advertising Source' => [ $fsurl.'search/report_cust_bill_pkg_referral.html' ],
-    'Employee Commission Report' => [ $fsurl.'search/report_employee_commission.html', '' ],
-    'Credit Report' => [ $fsurl.'search/report_cust_credit.html', 'Credit report (by employee and/or date range)' ],
-    'Unapplied Credits' => [ $fsurl.'search/report_cust_credit.html?unapplied=1', 'Unapplied credit report (by type and/or date range)' ],
-    'Refund Report' => [ $fsurl.'search/report_cust_refund.html', 'Refund report (by type and/or date range)' ],
-    'Unapplied Refunds' => [ $fsurl.'search/report_cust_refund.html?unapplied=1', 'Unapplied refund report (by type and/or date range)' ],
-    'Package Costs Report' => [ $fsurl.'graph/report_cust_pkg_cost.html', 'Package setup and recurring costs graph' ],
-    'Employee Audit Report' => [ $fsurl.'search/report_employee_audit.html', 'Employee audit report' ],
+    'Sales' => [ \%report_sales, 'Sales reports', ],
+    'Credits' => [ \%report_credits, 'Credit reports', ],
+    'Refunds' => [ \%report_refunds, 'Refund reports', ],
   );
   $report_financial{'A/R Aging'} = [ $fsurl.'search/report_receivables.html', 'Accounts Receivable Aging report' ];
   $report_financial{'Prepaid Income'} = [ $fsurl.'search/report_prepaid_income.html', 'Prepaid income (unearned revenue)  report' ];
@@ -346,36 +363,48 @@ if($curuser->access_right('Financial reports')) {
 
 } # else $report_financial contains nothing.
 
+tie my %report_logs, 'Tie::IxHash';
+  $report_logs{'System log'} = [ $fsurl.'search/log.html', 'View system events and debugging information.' ],
+  if $curuser->access_right('View system logs')
+  || $curuser->access_right('Configuration');
+  $report_logs{'Outgoing messages'} = [ $fsurl.'search/cust_msg.html', 'View outgoing message log' ]
+  if $curuser->access_right('View email logs')
+  || $curuser->access_right('Configuration');
+
 tie my %report_menu, 'Tie::IxHash';
-$report_menu{'Prospects'}   = [ \%report_prospects, 'Prospect reports' ]
+$report_menu{'Prospects'}      = [ \%report_prospects, 'Prospect reports' ]
   if $curuser->access_right('List prospects');
-$report_menu{'Quotations'}  = [ \%report_quotations, 'Quotation reports' ]
+$report_menu{'Quotations'}     = [ \%report_quotations, 'Quotation reports' ]
   if $curuser->access_right('List quotations');
-$report_menu{'Customers'}   = [ \%report_customers, 'Customer reports'  ]
+$report_menu{'Customers'}      = [ \%report_customers, 'Customer reports'  ]
   if $curuser->access_right('List customers');
-$report_menu{'Invoices'}    =  [ \%report_invoices,  'Invoice reports'   ]
+$report_menu{'Invoices'}       =  [ \%report_invoices,  'Invoice reports'   ]
   if $curuser->access_right('List invoices');
-$report_menu{'Discounts'}   =  [ \%report_discounts, 'Discount reports'  ]
+$report_menu{'Discounts'}      =  [ \%report_discounts, 'Discount reports'  ]
   if $curuser->access_right('Financial reports');
-$report_menu{'Payments'}    =  [ \%report_payments,  'Payment reports'   ]
+$report_menu{'Payments'}       =  [ \%report_payments,  'Payment reports'   ]
   if $curuser->access_right('Financial reports');
-$report_menu{'Packages'}    =  [ \%report_packages,  'Package reports'   ]
+$report_menu{'Packages'}       =  [ \%report_packages,  'Package reports'   ]
   if $curuser->access_right('List packages');
-$report_menu{'Services'}    =  [ \%report_services,  'Services reports'  ]
+$report_menu{'Services'}       =  [ \%report_services,  'Services reports'  ]
   if $curuser->access_right('List services');
-$report_menu{'Inventory'}    =  [ \%report_inventory,  'Inventory reports'  ]
+$report_menu{'Inventory'}      =  [ \%report_inventory,  'Inventory reports'  ]
   if $curuser->access_right('Configuration'); #XXX List inventory?
-$report_menu{'Usage'} =  [ \%report_rating,    'Usage reports'  ]
+$report_menu{'Usage'}          =  [ \%report_rating,    'Usage reports'  ]
   if $curuser->access_right('List rating data');
-$report_menu{'Tickets'}   = [ \%report_ticketing, 'Ticket reports' ]
+$report_menu{'Tickets'}        = [ \%report_ticketing, 'Ticket reports' ]
   if $conf->config('ticket_system')
   ;#&& FS::TicketSystem->access_right(\%session, 'Something');
+$report_menu{'Employees'}      =  [ \%report_employees, 'Employee reports'  ]
+  if $curuser->access_right('Financial reports');
 $report_menu{'Billing events'} =  [ \%report_bill_event, 'Billing events' ]
   if $curuser->access_right('Billing event reports');
-$report_menu{'Financial'}  = [ \%report_financial, 'Financial reports' ]
+$report_menu{'Financial'}      = [ \%report_financial, 'Financial reports' ]
   if $curuser->access_right('Financial reports') 
   or $curuser->access_right('Receivables report');
-$report_menu{'SQL Query'}  = [ $fsurl.'search/report_sql.html', 'SQL Query' ]
+$report_menu{'Logs'}           = [ \%report_logs, 'System and email logs' ]
+  if (keys %report_logs); # empty if the user has no rights to it
+$report_menu{'SQL Query'}      = [ $fsurl.'search/report_sql.html', 'SQL Query']
   if $curuser->access_right('Raw SQL');
 
 tie my %tools_importing, 'Tie::IxHash',
@@ -440,8 +469,6 @@ $tools_menu{'Time Queue'} =  [ $fsurl.'search/report_timeworked.html', 'View pen
   if $curuser->access_right('Time queue');
 $tools_menu{'Attachments'} = [ $fsurl.'browse/cust_attachment.html', 'View customer attachments' ]
   if !$conf->config('disable_cust_attachment') and $curuser->access_right('View attachments') and $curuser->access_right('Browse attachments');
-$tools_menu{'Outgoing messages'} = [ $fsurl.'search/cust_msg.html', 'View outgoing message log' ] #shouldn't this be in the reports menu?
-  if $curuser->access_right('View email logs');
 $tools_menu{'Importing'} =  [ \%tools_importing, 'Import tools' ]
   if $curuser->access_right('Import');
 $tools_menu{'Exporting'} =  [ \%tools_exporting, 'Export tools' ]
index 766209d..e4c2dc6 100644 (file)
@@ -5,7 +5,7 @@
     <A NOTYET="<%$fsurl%>search/svc_Smarter.html" STYLE="color: #cccccc; font-size:11px"><% mt('Advanced') |h %></A>
     <INPUT TYPE="submit" VALUE="<% mt('Search services') |h %>" CLASS="fsblackbutton" onMouseOver="this.className='fsblackbuttonselected'; return true;" onMouseOut="this.className='fsblackbutton'; return true;" STYLE="font-size:11px">
   </FORM>
-  <% $menu_position eq 'left' ? '<BR>' : '' %>
+  <% $menu_position eq 'left' ? '<BR>' : '' |n %>
 
 % }
 
index 68b90d4..ac36379 100644 (file)
@@ -5,7 +5,7 @@
     <A HREF="<%$fsurl%>search/report_prospect_main.html" CLASS="fslink" STYLE="font-size: 11px">Adv</A>
     <INPUT TYPE="submit" VALUE="Search prospects" CLASS="fsblackbutton" onMouseOver="this.className='fsblackbuttonselected'; return true;" onMouseOut="this.className='fsblackbutton'; return true;" STYLE="font-size:11px;padding-left:1px;padding-right:1px">
   </FORM>
-  <% $menu_position eq 'left' ? '<BR>' : '' %>
+  <% $menu_position eq 'left' ? '<BR>' : '' |n %>
 
 % }
 
index 30624f7..ae86dbc 100644 (file)
@@ -5,7 +5,7 @@
     <A HREF="<% FS::TicketSystem->baseurl %>Search/Build.html?NewQuery=1" CLASS="fslink" STYLE="font-size:11px"><% mt('Advanced') |h %></A>
     <INPUT TYPE="submit" VALUE="<% mt('Search tickets') |h %>" CLASS="fsblackbutton" onMouseOver="this.className='fsblackbuttonselected'; return true;" onMouseOut="this.className='fsblackbutton'; return true;" STYLE="font-size:11px">
   </FORM>
-  <% $menu_position eq 'left' ? '<BR>' : '' %>
+  <% $menu_position eq 'left' ? '<BR>' : '' |n %>
 
 % }
 
index a69450c..6e205d8 100644 (file)
@@ -16,8 +16,10 @@ Example:
 %   if ( $export->option('restrict_selection') eq 'non-tollfree'
 %                  || !$export->option('restrict_selection') ) {
     <TABLE>
-
       <TR>
+
+%       if ( $export->get_dids_npa_select ) {
+
         <TD VALIGN="top">
           <% include('/elements/select-state.html',
                        'prefix'        => 'phonenum_', #$field.'_',
@@ -29,40 +31,73 @@ Example:
           %>
           <BR><FONT SIZE="-1">State</FONT>
         </TD>
+
+          <TD VALIGN="top">
+            <% include('/elements/select-areacode.html',
+                         'state_prefix' => 'phonenum_', #$field.'_',
+                         'svcpart'      => $svcpart,
+                         'empty'        => 'Select area code',
+                      )
+            %>
+            <BR><FONT SIZE="-1">Area code</FONT>
+          </TD>
+
+          <TD VALIGN="top">
+            <% include('/elements/select-exchange.html',
+                         'svcpart' => $svcpart,
+                         'empty'   => 'Select exchange',
+                      )
+            %>
+            <BR><FONT SIZE="-1">City / Exchange</FONT>
+          </TD>
+
+%       } else {
+
         <TD VALIGN="top">
-          <% include('/elements/select-areacode.html',
-                       'state_prefix' => 'phonenum_', #$field.'_',
-                       'svcpart'      => $svcpart,
-                       'empty'        => 'Select area code',
-                    )
-          %>
-          <BR><FONT SIZE="-1">Area code</FONT>
-        </TD>
-        <TD VALIGN="top">
-          <% include('/elements/select-exchange.html',
-                       'svcpart' => $svcpart,
-                       'empty'   => 'Select exchange',
+          <% include('/elements/select.html',
+                       'field'    => 'phonenum_state',
+                       'id'       => 'phonenum_state',
+                       'options'  => [ '', @{ $export->get_dids } ],
+                       'labels'   => { '' => 'Select province' },
+                       'onchange' => 'phonenum_state_changed(this);',
                     )
           %>
-          <BR><FONT SIZE="-1">City / Exchange</FONT>
+          <BR><FONT SIZE="-1">Province</FONT>
         </TD>
+
+          <TD VALIGN="top">
+            <% include('/elements/select-region.html',
+                         'state_prefix'  => 'phonenum_', #$field.'_',
+                         'svcpart'       => $svcpart,
+                         'empty'         => 'Select region',
+                      )
+            %>
+            <BR><FONT SIZE="-1">Region</FONT>
+          </TD>
+
+%       }
+
         <TD VALIGN="top">
           <% include('/elements/select-phonenum.html',
                        'svcpart'  => $svcpart,
                        'empty'    => 'Select phone number',
                       'bulknum'  => $bulknum,
                        'multiple' => $multiple,
+                       'region'   => ! $export->get_dids_npa_select,
                     )
           %>
           <BR><FONT SIZE="-1">Phone number</FONT>
         </TD>
-      </TR>
 
+      </TR>
     </TABLE>
 
 % } 
-%   if ( $export->option('restrict_selection') eq 'tollfree'
-%                  || !$export->option('restrict_selection') ) {
+%   if (     ( $export->option('restrict_selection') eq 'tollfree'
+%                || !$export->option('restrict_selection')
+%            )
+%        and $export->get_dids_can_tollfree
+%      ) {
            <font size="-1">Toll-free</font>
            <% include('/elements/select-phonenum.html',
                        'svcpart' => $svcpart,
diff --git a/httemplate/elements/select-mib-popup.html b/httemplate/elements/select-mib-popup.html
new file mode 100644 (file)
index 0000000..bd485ef
--- /dev/null
@@ -0,0 +1,186 @@
+<& /elements/header-popup.html &>
+<DIV STYLE="visibility: hidden; position: absolute" ID="measurebox"></DIV>
+<TABLE WIDTH="100%">
+<TR>
+  <TD WIDTH="30%" ALIGN="right">Module:</TD>
+  <TD><SELECT ID="select_module"></SELECT></TD>
+</TR>
+<TR>
+  <TD ALIGN="right">Object:</TD>
+  <TD><INPUT TYPE="text" NAME="path" ID="input_path" WIDTH="100%"></TD>
+</TR>
+<TR>
+  <TD COLSPAN=2>
+    <SELECT STYLE="width:100%" SIZE=12 ID="select_path"></SELECT>
+  </TD>
+</TR>
+<TR>
+  <TH ALIGN="center" COLSPAN=2 ID="mib_objectID"></TH>
+</TR>
+<TR>
+  <TD ALIGN="right">Module: </TD><TD ID="mib_moduleID"></TD>
+</TR>
+<TR>
+  <TD ALIGN="right">Data type: </TD><TD ID="mib_type"></TD>
+</TR>
+<TR>
+  <TH COLSPAN=2>
+    <BUTTON ID="submit_button" onclick="submit()" DISABLED=1>Continue</BUTTON>
+  </TH>
+</TR>
+</TABLE>
+<& /elements/xmlhttp.html,
+  url   => $p.'misc/xmlhttp-mib-browse.html',
+  subs  => [qw( search get_module_list )],
+&>
+<SCRIPT TYPE="text/javascript">
+
+var selected_mib;
+
+function show_info(state) {
+  document.getElementById('mib_objectID').style.display = 
+    document.getElementById('mib_moduleID').style.display = 
+    document.getElementById('mib_type').style.display = 
+    state ? '' : 'none';
+}
+
+function clear_list() {
+  var select_path = document.getElementById('select_path');
+  select_path.options.length = 0;
+}
+
+var measurebox = document.getElementById('measurebox');
+function add_item(value) {
+  var select_path = document.getElementById('select_path');
+  var input_path = document.getElementById('input_path');
+  var opt = document.createElement('option');
+  var v = value;
+  if ( v.match(/-$/) ) {
+    opt.className = 'leaf';
+    v = v.substring(0, v.length - 1);
+  }
+  var optvalue = v; // may not be the name we display
+  // shorten these if they don't fit in the box
+  if ( v.length > 30 ) { // unless they're already really short
+    measurebox.innerHTML = v;
+    while ( measurebox.clientWidth > select_path.clientWidth - 10
+            && v.match(/^\..*\./) ) {
+      v = v.replace(/^\.[^\.]+/, '');
+      measurebox.innerHTML = v;
+    }
+    if ( optvalue != v ) {
+      v = '...' + v;
+    }
+  }
+  opt.value = optvalue;
+  opt.text = v;
+  opt.selected = (input_path.value == v);
+  select_path.add(opt, null);
+}
+
+var timerID = 0;
+
+function populate(json_result) {
+  var result = JSON.parse(json_result);
+  clear_list();
+  for (var x in result['choices']) {
+    opt = document.createElement('option');
+    add_item(result['choices'][x]);
+  }
+  if ( result['objectID'] ) {
+    selected_mib = result;
+    show_info(true);
+    // show details on the selected node
+    document.getElementById('mib_objectID').innerHTML = result.objectID;
+    document.getElementById('mib_moduleID').innerHTML = result.moduleID;
+    document.getElementById('mib_type').innerHTML = result.type;
+    document.getElementById('submit_button').disabled = !result.type;
+  } else {
+    selected_mib = undefined;
+    show_info(false);
+  }
+}
+
+function populate_modules(json_result) {
+  var result = JSON.parse(json_result);
+  var select_module = document.getElementById('select_module');
+  var opt = document.createElement('option');
+  opt.value = 'ANY';
+  opt.text  = '(any)';
+  select_module.add(opt, null);
+  for (var x in result['modules']) {
+    opt = document.createElement('option');
+    opt.value = opt.text = result['modules'][x];
+    select_module.add(opt, null);
+  }
+}
+
+function dispatch_search() {
+  // called from the interval timer
+  var search_string = document.getElementById('select_module').value + ':' +
+                      document.getElementById('input_path').value;
+
+  search(search_string, populate);
+}
+
+function delayed_search() {
+  // onkeyup handler for the text input
+  // 500ms after the user stops typing, send the search request
+  if (timerID != 0) {
+    clearTimeout(timerID);
+  }
+  timerID = setTimeout(dispatch_search, 500);
+}
+
+function handle_choose_object() {
+  // onchange handler for the selector
+  // when the user picks an option, set the text input to that, and then
+  // search for it as though it was entered
+  var input_path = document.getElementById('input_path');
+  input_path.value = this.value;
+  dispatch_search();
+}
+
+function handle_choose_module() {
+  input_path.value = ''; // just to avoid confusion
+  delayed_search();
+}
+
+function submit() {
+% if ( $callback ) {
+  <% $callback %>;
+  parent.nd(1); // close popup
+% } else {
+  alert(document.getElementById('input_path').value);
+% }
+}
+
+var input_path = document.getElementById('input_path');
+input_path.onkeyup = delayed_search;
+var select_path = document.getElementById('select_path');
+select_path.onchange = handle_choose_object;
+var select_module = document.getElementById('select_module');
+select_module.onchange = handle_choose_module;
+% if ( $cgi->param('curr_value') ) {
+input_path.value = <% $cgi->param('curr_value') |js_string %>;
+% }
+dispatch_search();
+get_module_list('', populate_modules);
+
+</SCRIPT>
+<& /elements/footer.html &>
+<%init>
+my $callback = 'alert("(no callback defined)" + selected_mib.stringify)';
+$cgi->param('callback') =~ /^(\w+)$/;
+if ( $1 ) {
+  # construct the JS function call expresssion
+  $callback = 'window.parent.' . $1 . '(selected_mib';
+  foreach ($cgi->param('arg')) {
+    # pass-through arguments
+    /^(\w+)$/ or next;
+    $callback .= ",'$1'";
+  }
+  $callback .= ')';
+}
+
+</%init>
index d555bf4..18abe3d 100644 (file)
@@ -12,7 +12,7 @@
     what.options[length] = optionName;
   }
 
-  function <% $opt{'prefix'} %>exchange_changed(what, callback) {
+  function <% $opt{'prefix'} %><% $previous %>_changed(what, callback) {
 
     what.form.<% $opt{'prefix'} %>phonenum.disabled = 'disabled';
     what.form.<% $opt{'prefix'} %>phonenum.style.display = 'none';
@@ -21,7 +21,7 @@
     var phonenumerror = document.getElementById('<% $opt{'prefix'} %>phonenumerror');
     phonenumerror.style.display = 'none';
 
-    exchange = what.options[what.selectedIndex].value;
+    var thing = "<% $previous eq 'region' ? '_REGION ' : '' %>" + what.options[what.selectedIndex].value;
 
     function <% $opt{'prefix'} %>update_phonenums(phonenums) {
 
@@ -84,7 +84,7 @@
     }
 
     // go get the new phonenums
-    <% $opt{'prefix'} %>get_phonenums( exchange, <% $opt{'svcpart'} %>, <% $opt{'prefix'} %>update_phonenums );
+    <% $opt{'prefix'} %>get_phonenums( thing, <% $opt{'svcpart'} %>, <% $opt{'prefix'} %>update_phonenums );
 
   }
 
 % unless ( $opt{'tollfree'} ) {
 <DIV ID="phonenumwait" STYLE="display:none"><IMG SRC="<%$fsurl%>images/wait-orange.gif"> <B>Finding phone numbers</B></DIV>
 
-<DIV ID="phonenumerror" STYLE="display:none"><IMG SRC="<%$fsurl%>images/cross.png"> <B>Select a different city/exchange</B></DIV>
+<DIV ID="phonenumerror" STYLE="display:none"><IMG SRC="<%$fsurl%>images/cross.png"> <B>Select a different <% $opt{'region'} ? 'region' : 'city/exchange' %></B></DIV>
 % }
 
 <SELECT <% $opt{multiple} ? 'MULTIPLE SIZE=25' : '' %>
@@ -146,4 +146,6 @@ my %opt = @_;
 
 $opt{disabled} = 'disabled' unless exists $opt{disabled};
 
+my $previous = $opt{'region'} ? 'region' : 'exchange';
+
 </%init>
diff --git a/httemplate/elements/select-region.html b/httemplate/elements/select-region.html
new file mode 100644 (file)
index 0000000..9823290
--- /dev/null
@@ -0,0 +1,88 @@
+<% include('/elements/xmlhttp.html',
+              'url'  => $p.'misc/regions.cgi',
+              'subs' => [ $opt{'prefix'}. 'get_regions' ],
+          )
+%>
+
+<SCRIPT TYPE="text/javascript">
+
+  function opt(what,value,text) {
+    var optionName = new Option(text, value, false, false);
+    var length = what.length;
+    what.options[length] = optionName;
+  }
+
+  function <% $opt{'state_prefix'} %>state_changed(what, callback) {
+
+    what.form.<% $opt{'prefix'} %>region.disabled = 'disabled';
+    what.form.<% $opt{'prefix'} %>region.style.display = 'none';
+    var regionwait = document.getElementById('<% $opt{'prefix'} %>regionwait');
+    regionwait.style.display = '';
+    var regionerror = document.getElementById('<% $opt{'prefix'} %>regionerror');
+    regionerror.style.display = 'none';
+
+    what.form.<% $opt{'prefix'} %>phonenum.disabled = 'disabled';
+
+    state = what.options[what.selectedIndex].value;
+
+    function <% $opt{'prefix'} %>update_regions(regions) {
+
+      // blank the current region
+      for ( var i = what.form.<% $opt{'prefix'} %>region.length; i >= 0; i-- )
+          what.form.<% $opt{'prefix'} %>region.options[i] = null;
+      // blank the current phonenum too
+      for ( var i = what.form.<% $opt{'prefix'} %>phonenum.length; i >= 0; i-- )
+          what.form.<% $opt{'prefix'} %>phonenum.options[i] = null;
+      if ( what.form.<% $opt{'prefix'} %>phonenum.type != 'select-multiple' ) {
+        opt(what.form.<% $opt{'prefix'} %>phonenum, '', 'Select phone number');
+      }
+
+%     if ($opt{empty}) {
+        opt(what.form.<% $opt{'prefix'} %>region, '', '<% $opt{empty} %>');
+%     }
+
+      // add the new regions
+      var regionArray = eval('(' + regions + ')' );
+      for ( var s = 0; s < regionArray.length; s++ ) {
+          var regionLabel = regionArray[s];
+          if ( regionLabel == "" )
+              regionLabel = '(n/a)';
+          opt(what.form.<% $opt{'prefix'} %>region, regionArray[s], regionLabel);
+      }
+
+      regionwait.style.display = 'none';
+      if ( regionArray.length >= 1 ) {
+        what.form.<% $opt{'prefix'} %>region.disabled = '';
+        what.form.<% $opt{'prefix'} %>region.style.display = '';
+      } else {
+        var regionerror = document.getElementById('<% $opt{'prefix'} %>regionerror');
+        regionerror.style.display = '';
+      }
+
+      //run the callback
+      if ( callback != null ) 
+        callback();
+    }
+
+    // go get the new regions
+    <% $opt{'prefix'} %>get_regions( state, <% $opt{'svcpart'} %>, <% $opt{'prefix'} %>update_regions );
+
+  }
+
+</SCRIPT>
+
+<DIV ID="<% $opt{'prefix'} %>regionwait" STYLE="display:none"><IMG SRC="<%$fsurl%>images/wait-orange.gif"> <B>Finding regions</B></DIV>
+
+<DIV ID="<% $opt{'prefix'} %>regionerror" STYLE="display:none"><IMG SRC="<%$fsurl%>images/cross.png"> <B>Select a different state</B></DIV>
+
+<SELECT NAME="<% $opt{'prefix'} %>region" onChange="<% $opt{'prefix'} %>region_changed(this); <% $opt{'onchange'} %>" <% $opt{'disabled'} %>>
+  <OPTION VALUE="">Select region</OPTION>
+</SELECT>
+
+<%init>
+
+my %opt = @_;
+
+$opt{disabled} = 'disabled' unless exists $opt{disabled};
+
+</%init>
index d63c492..a66aa29 100644 (file)
@@ -33,7 +33,7 @@ my $empty_label =
 my $empty_value = $opt{'empty_value'} || '';
 
 my @terms = ( emt('Payable upon receipt'),
-              ( map "Net $_", 0, 3, 9, 10, 15, 20, 30, 45, 60, 90 ),
+              ( map "Net $_", 0, 3, 9, 10, 15, 18, 20, 30, 45, 60, 90 ),
             );
 
 my @pre_options = $opt{pre_options} ? @{ $opt{pre_options} } : ();
index d9c1df7..15c5761 100644 (file)
@@ -1,3 +1,9 @@
+function status_message(text, caption) {
+  text = '<P STYLE="position:absolute; top:50%; margin-top:-1em; width:100%; text-align:center"><B><FONT SIZE="+1">' + text + '</FONT></B></P>';
+  caption = caption || 'Please wait...';
+  overlib(text, WIDTH, 444, HEIGHT, 168, CAPTION, caption, STICKY, AUTOSTATUSCAP, CLOSECLICK, MIDX, 0, MIDY, 0);
+}
+
 function form_address_info() {
   var cf = document.<% $formname %>;
 
@@ -87,8 +93,7 @@ function standardize_locations() {
 
 % if ( $conf->config('address_standardize_method') ) {
   if ( changed ) {
-    var startup_msg = '<P STYLE="position:absolute; top:50%; margin-top:-1em; width:100%; text-align:center"><B><FONT SIZE="+1">Verifying address...</FONT></B></P>';
-    overlib(startup_msg, WIDTH, 444, HEIGHT, 168, CAPTION, 'Please wait...', STICKY, AUTOSTATUSCAP, CLOSECLICK, MIDX, 0, MIDY, 0);
+    status_message('Verifying address...');
     address_standardize(JSON.stringify(address_info), confirm_standardize);
   }
   else {
@@ -116,8 +121,14 @@ function confirm_standardize(arg) {
 
     replace_address(); // with the contents of returned['new']
   
-  }
-  else {
+  } else if ( returned['all_same'] ) {
+
+    // then all entered address fields are correct
+    // but we still need to set the lat/long fields and addr_clean
+    status_message('Verified');
+    replace_address();
+
+  } else {
 
     var querystring = encodeURIComponent( JSON.stringify(returned) );
     // confirmation popup: knows to call replace_address(), 
index ac6f991..a9e65c7 100644 (file)
@@ -14,14 +14,15 @@ Example:
   );
 
 </%doc>
-<% include( '/elements/rs_init_object.html' ) %>
+<& /elements/rs_init_object.html &>
+<& /elements/init_overlib.html &>
 <SCRIPT TYPE="text/javascript">
 
 % foreach my $func ( @{$opt{'subs'}} ) { 
 %
 %       my $furl = $url;
 %       $furl =~ s/\"/\\\\\"/; #javascript escape
-%
+%#"
 %  
 
 
@@ -66,15 +67,26 @@ Example:
             } else {
               var data = xmlhttp.responseText;
               //alert('received response: ' + data);
-              a[a.length-1](data);
               if ( data.indexOf("<b>System error</b>") > -1 ) {
-                var w;
-                if ( w = window.open("about:blank") ) {
-                  w.document.write(data);
-                } else {
-                  // popup blocking?  should use an overlib popup instead 
-                  alert("Error popup disabled; try disabling popup blocking to see");
-                }
+                // trim this a little
+                var end = data.indexOf('<a href="#raw">') - 1;
+                data = data.substring(0, end);
+
+                overlib(data,
+                  WIDTH, 480, MIDX, 0, MIDY, 0,
+                  CAPTION, 'Error', STICKY, AUTOSTATUSCAP, DRAGGABLE,
+                  CLOSECLICK, BGCOLOR, '#f00', CGCOLOR, '#f00'
+                );
+                //var w;
+                //if ( w = window.open("about:blank") ) {
+                //  w.document.write(data);
+                //} else {
+                //  // popup blocking?  should use an overlib popup instead 
+                //  alert("Error popup disabled; try disabling popup blocking to see");
+                //}
+              } else {
+                // invoke the callback
+                a[a.length-1](data);
               }
             }
         }
index 98b4778..b7073db 100644 (file)
@@ -126,8 +126,8 @@ any delimiter and linked from the elements in @data.
 %   $workbook->close();# or die "Error creating .xls file: $!";
 %
 %   http_header('Content-Length' => length($output) );
-%   
-<% $output %>
+%   $m->print($output);
+%
 % } elsif ( $cgi->param('_type') eq 'png' ) {
 %   # delete any items that shouldn't be on the graph
 %   if ( my $no_graph = $opt{'no_graph'} ) {
index fd5de2a..5084628 100644 (file)
@@ -21,13 +21,13 @@ if ( $exchangestring ) {
   my %opts = ();
   if ( $exchangestring eq 'tollfree' ) {
       $opts{'tollfree'} = 1;
-  }
-  #elsif ( $exchangestring =~ /^([\w\s\:\,\(\)\-]+), ([A-Z][A-Z])$/ ) {
-  elsif ( $exchangestring =~ /^(.+), ([A-Z][A-Z])$/ ) {
+  } elsif ( $exchangestring =~ /^_REGION (.*)$/ ) {
+      $opts{'region'} = $1;
+  #} elsif ( $exchangestring =~ /^([\w\s\:\,\(\)\-]+), ([A-Z][A-Z])$/ ) {
+  } elsif ( $exchangestring =~ /^(.+), ([A-Z][A-Z])$/ ) {
       $opts{'ratecenter'} = $1;
       $opts{'state'} = $2;
-  }
-  else {
+  } else {
       $exchangestring =~ /\((\d{3})-(\d{3})-XXXX\)\s*$/i
         or die "unparsable exchange: $exchangestring";
       my( $areacode, $exchange ) = ( $1, $2 );
diff --git a/httemplate/misc/regions.cgi b/httemplate/misc/regions.cgi
new file mode 100644 (file)
index 0000000..2450ea3
--- /dev/null
@@ -0,0 +1,26 @@
+<% objToJson(\@regions) %>
+<%init>
+
+my( $state, $svcpart ) = $cgi->param('arg');
+
+my $part_svc = qsearchs('part_svc', { 'svcpart'=>$svcpart } );
+die "unknown svcpart $svcpart" unless $part_svc;
+
+my @regions = ();
+if ( $state ) {
+
+  my @exports = $part_svc->part_export_did;
+  if ( scalar(@exports) > 1 ) {
+    die "more than one DID-providing export attached to svcpart $svcpart";
+  } elsif ( ! @exports ) {
+    die "no DID providing export attached to svcpart $svcpart";
+  }
+  my $export = $exports[0];
+
+  my $something = $export->get_dids('state'=>$state);
+
+  @regions = @{ $something };
+
+}
+
+</%init>
index 1620642..9880571 100644 (file)
@@ -24,6 +24,7 @@ if ($old{onlyship}) {
 } else {
   @prefixes = ('bill_', 'ship_');
 }
+my $all_same = 1;
 foreach my $pre ( @prefixes ) {
 
   my $location = {
@@ -38,8 +39,13 @@ foreach my $pre ( @prefixes ) {
   foreach ( keys(%$cache) ) {
     $new{$pre.$_} = $cache->get($_);
   }
+
+  foreach ( qw(address1 address2 city state zip country) ) {
+    $all_same = 0 if ( $new{$pre.$_} ne $old{$pre.$_} );
+    last if !$all_same;
+  }
 }
 
-my $return = { old => \%old, new => \%new };
+my $return = { old => \%old, new => \%new, all_same => $all_same };
 warn "result:\n".encode_json($return) if $DEBUG;
 </%init>
diff --git a/httemplate/misc/xmlhttp-cust_bill_pkg-calculate_taxes.html b/httemplate/misc/xmlhttp-cust_bill_pkg-calculate_taxes.html
new file mode 100644 (file)
index 0000000..9935046
--- /dev/null
@@ -0,0 +1,123 @@
+<% to_json($return) %>
+<%init>
+
+my $curuser = $FS::CurrentUser::CurrentUser;
+die "access denied" unless $curuser->access_right('Post credit');
+
+my $DEBUG = 0;
+
+my $conf = new FS::Conf;
+
+my $sub = $cgi->param('sub');
+
+my $return = {};
+
+if ( $sub eq 'calculate_taxes' ) {
+
+  {
+
+    my %arg = $cgi->param('arg');
+    $return = \%arg;
+    warn join('', map "$_: $arg{$_}\n", keys %arg )
+      if $DEBUG;
+
+    #some false laziness w/cust_credit::credit_lineitems
+
+    my $cust_main = qsearchs({
+      'table'     => 'cust_main',
+      'hashref'   => { 'custnum' => $arg{custnum} },
+      'extra_sql' => ' AND '. $curuser->agentnums_sql,
+    }) or die 'unknown customer';
+
+    my @billpkgnums = split(',', $arg{billpkgnums});
+    my @setuprecurs = split(',', $arg{setuprecurs});
+    my @amounts =     split(',', $arg{amounts});
+
+    my @cust_bill_pkg = ();
+    my $taxlisthash = {};
+    while ( @billpkgnums ) {
+      my $billpkgnum = shift @billpkgnums;
+      my $setuprecur = shift @setuprecurs;
+      my $amount     = shift @amounts;
+
+      my $cust_bill_pkg = qsearchs({
+        'table'     => 'cust_bill_pkg',
+        'hashref'   => { 'billpkgnum' => $billpkgnum },
+        'addl_from' => 'LEFT JOIN cust_bill USING (invnum)',
+        'extra_sql' => 'AND custnum = '. $cust_main->custnum,
+      }) or die "unknown billpkgnum $billpkgnum";
+
+      #shouldn't be passed# next if $cust_bill_pkg->pkgnum == 0;
+
+      if ( $setuprecur eq 'setup' ) {
+        $cust_bill_pkg->setup($amount);
+        $cust_bill_pkg->recur(0);
+        $cust_bill_pkg->unitrecur(0);
+        $cust_bill_pkg->type('');
+      } else {
+        $cust_bill_pkg->recur($amount);
+        $cust_bill_pkg->setup(0);
+        $cust_bill_pkg->unitsetup(0);
+      }
+
+      push @cust_bill_pkg, $cust_bill_pkg;
+
+      my $part_pkg = $cust_bill_pkg->part_pkg;
+      $cust_main->_handle_taxes( $part_pkg,
+                                 $taxlisthash,
+                                 $cust_bill_pkg,
+                                 $cust_bill_pkg->cust_pkg,
+                                 $cust_bill_pkg->cust_bill->_date,
+                                 $cust_bill_pkg->cust_pkg->pkgpart,
+                               );
+
+    }
+
+    if ( @cust_bill_pkg ) {
+
+      my $listref_or_error = 
+        $cust_main->calculate_taxes( \@cust_bill_pkg, $taxlisthash, $cust_bill_pkg[0]->cust_bill->_date );
+
+      unless ( ref( $listref_or_error ) ) {
+        $return->{error} = $listref_or_error;
+        last;
+      }
+
+      my @taxlines = ();
+      my $taxtotal = 0;
+      $return->{taxlines} = \@taxlines;
+      foreach my $taxline ( @$listref_or_error ) {
+        my $amount = $taxline->setup;
+        my $desc = $taxline->desc;
+        foreach my $location ( @{$taxline->cust_bill_pkg_tax_location}, @{$taxline->cust_bill_pkg_tax_rate_location} ) {
+          my $taxlocnum = $location->locationnum || '';
+          my $taxratelocnum = $location->taxratelocationnum || '';
+          $location->cust_bill_pkg_desc($taxline->desc); #ugh @ that kludge
+          $taxtotal += $location->amount;
+          push @taxlines,
+            #[ $location->desc, $taxline->setup, $taxlocnum, $taxratelocnum ];
+            [ $location->desc, $location->amount, $taxlocnum, $taxratelocnum ];
+          $amount -= $location->amount;
+        }
+        if ($amount > 0) {
+          $taxtotal += $amount;
+          push @taxlines,
+            [ $taxline->itemdesc. ' (default)', sprintf('%.2f', $amount), '', '' ];
+        }
+      }
+
+      $return->{taxlines} = \@taxlines;
+      $return->{taxtotal} = sprintf('%.2f', $taxtotal);
+
+    } else {
+
+      $return->{taxlines} = [];
+      $return->{taxtotal} = '0.00';
+
+    }
+
+  }
+
+}
+
+</%init>
diff --git a/httemplate/misc/xmlhttp-mib-browse.html b/httemplate/misc/xmlhttp-mib-browse.html
new file mode 100644 (file)
index 0000000..f3084ff
--- /dev/null
@@ -0,0 +1,161 @@
+%#<% Data::Format::HTML->new->format($index{by_path}) %>
+% my $json = "JSON"->new->canonical;
+<% $json->encode($result) %>
+<%init>
+#<%once>  #enable me in production
+use SNMP;
+SNMP::initMib();
+my $mib = \%SNMP::MIB;
+
+# make an index of the leaf nodes
+my %index = (
+  by_objectID => {}, # {.1.3.6.1.2.1.1.1}
+  by_fullname => {}, # {iso.org.dod.internet.mgmt.mib-2.system.sysDescr}
+  by_path     => {}, # {iso}{org}{dod}{internet}{mgmt}{mib-2}{system}{sysDescr}
+  module  => {}, #{SNMPv2-MIB}{by_path}{iso}{org}...
+                 #{SNMPv2-MIB}{by_fullname}{iso.org...}
+);
+
+my %name_of_oid = (); # '.1.3.6.1' => 'iso.org.dod.internet'
+
+# build up path names
+my $fullname;
+$fullname = sub {
+  my $oid = shift;
+  return $name_of_oid{$oid} if exists $name_of_oid{$oid};
+
+  my $object = $mib->{$oid};
+  my $myname = '.' . $object->{label};
+  # cut off the last element and recurse
+  $oid =~ /^(\.[\d\.]+)?(\.\d+)$/;
+  if ( length($1) ) {
+    $myname = $fullname->($1) . $myname;
+  }
+  return $name_of_oid{$oid} = $myname
+};
+
+my @oids = keys(%$mib); # dotted numeric OIDs
+foreach my $oid (@oids) {
+  my $object = {};
+  %$object = %{ $mib->{$oid} }; # untie it
+  # and remove references
+  delete $object->{parent};
+  delete $object->{children};
+  delete $object->{nextNode};
+  $index{by_objectID}{$oid} = $object;
+  my $myname = $fullname->($oid);
+  $object->{fullname} = $myname;
+  $index{by_fullname}{$myname} = $object;
+  my $moduleID = $object->{moduleID};
+  $index{module}{$moduleID} ||= { by_fullname => {}, by_path => {} };
+  $index{module}{$moduleID}{by_fullname}{$myname} = $object;
+}
+my @names = sort {$a cmp $b} keys %{ $index{by_fullname} };
+foreach my $myname (@names) {
+  my $obj = $index{by_fullname}{$myname};
+  my $moduleID = $obj->{moduleID};
+  my @parts = split('\.', $myname);
+  shift @parts; # always starts with an empty string
+  for ($index{by_path}, $index{module}{$moduleID}{by_path}) {
+    my $subindex = $_;
+    for my $this_part (@parts) {
+      $subindex = $subindex->{$this_part} ||= {};
+    }
+    # $subindex now = $index{by_path}{foo}{bar}{baz}.
+    # set {''} = the object with that name.
+    # and set object $index{by_path}{foo}{bar}{baz}{''} = 
+    # the object named .foo.bar.baz
+    $subindex->{''} = $obj;
+  }
+}
+
+#</%once>
+#<%init>
+# no ACL for this
+my $sub = $cgi->param('sub');
+my $result = {};
+if ( $sub eq 'search' ) {
+  warn "search: ".$cgi->param('arg')."\n";
+  my ($module, $string) = split(':', $cgi->param('arg'), 2);
+  my $idx; # the branch of the index to use for this search
+  if ( $module eq 'ANY' ) {
+    $idx = \%index;
+  } elsif (exists($index{module}{$module}) ) {
+    $idx = $index{module}{$module};
+  } else {
+    warn "unknown MIB moduleID: $module\n";
+    $idx = {}; # will return nothing, because you've somehow sent a bad moduleID
+  }
+  if ( exists($index{by_fullname}{$string}) ) {
+    warn "exact match\n";
+    # don't make this module-selective--if the path matches an existing 
+    # object, return that object
+    %$result = %{ $index{by_fullname}{$string} }; # put the object info in $result
+    #warn Dumper $result;
+  }
+  my @choices; # menu options to return
+  if ( $string =~ /^[\.\d]+$/ ) {
+    # then this is a numeric path
+    # ignore the module filter, and return everything starting with $string
+    if ( $string =~ /^\./ ) {
+      @choices = grep /^\Q$string\E/, keys %{$index{by_objectID}};
+    } else {
+      # or everything containing it
+      @choices = grep /\Q$string\E/, keys %{$index{by_objectID}};
+    }
+    @choices = map { $index{by_objectID}{$_}->{fullname} } @choices;
+  } elsif ( $string eq '' or $string =~ /^\./ ) {
+    # then this is an absolute path
+    my @parts = split('\.', $string);
+    shift @parts;
+    my $subindex = $idx->{by_path};
+    my $path = '';
+    @choices = keys %$subindex;
+    # walk all the specified path parts
+    foreach my $this_part (@parts) {
+      # stop before walking off the map
+      last if !exists($subindex->{$this_part});
+      $subindex = $subindex->{$this_part};
+      $path .= '.' . $this_part;
+      @choices = grep {$_} keys %$subindex;
+    }
+    # skip uninteresting nodes: those that aren't accessible nodes (have no
+    # data type), and have only one path forward
+    while ( scalar(@choices) == 1
+            and (!exists $subindex->{''} or $subindex->{''}->{type} eq '') ) {
+
+      $subindex = $subindex->{ $choices[0] };
+      $path .= '.' . $choices[0];
+      @choices = grep {$_} keys %$subindex;
+
+    }
+
+    # if we are on an existing node, and the entered path didn't exactly
+    # match another node, return the current node as the result
+    if (!keys %$result and exists($subindex->{''})) {
+      %$result = %{ $subindex->{''} };
+    }
+    # prepend the path up to this point
+    foreach (@choices) {
+      $_ = $path.'.'.$_;
+      # also label accessible nodes for the UI
+      if ( exists($subindex->{$_}{''}) and $subindex->{$_}{''}{'type'} ) {
+        $_ .= '-';
+      }
+    }
+    # also include one level above the originally requested path, 
+    # for tree-like navigation
+    if ( $string =~ /^(.+)\.[^\.]+/ ) {
+      unshift @choices, $1;
+    }
+  } else {
+    # then this is a full-text search
+    warn "/$string/\n";
+    @choices = grep /\Q$string\E/i, keys(%{ $idx->{by_fullname} });
+  }
+  @choices = sort @choices;
+  $result->{choices} = \@choices;
+} elsif ( $sub eq 'get_module_list' ) {
+  $result = { modules => [ sort keys(%{ $index{module} }) ] };
+}
+</%init>
index 4c0fa4a..34c89e5 100644 (file)
@@ -6,9 +6,12 @@
                  'count_addl'  => \@total_desc,
                  'header'      => [
                    emt('Description'),
+                   @post_desc_header,
                    @peritem_desc,
                    emt('Invoice'),
                    emt('Date'),
+                   emt('Paid'),
+                   emt('Credited'),
                    FS::UI::Web::cust_header(),
                  ],
                  'fields'      => [
                            ? $_[0]->get('pkg')      # possibly use override.pkg
                            : $_[0]->get('itemdesc') # but i think this correct
                        },
+                   @post_desc,
                    #strikethrough or "N/A ($amount)" or something these when
                    # they're not applicable to pkg_tax search
                    @peritem_sub,
                    'invnum',
                    sub { time2str('%b %d %Y', shift->_date ) },
+                   sub { sprintf($money_char.'%.2f', shift->get('pay_amount')) },
+                   sub { sprintf($money_char.'%.2f', shift->get('credit_amount')) },
                    \&FS::UI::Web::cust_fields,
                  ],
                  'sort_fields' => [
                    '',
+                   @post_desc_null,
                    @peritem,
                    'invnum',
                    '_date',
+                   #'pay_amount',
+                   #'credit_amount',
                  ],
                  'links'       => [
                    #'',
                    '',
+                   @post_desc_null,
                    @peritem_null,
                    $ilink,
                    $ilink,
+                   $pay_link,
+                   $credit_link,
                    ( map { $_ ne 'Cust. Status' ? $clink : '' }
                          FS::UI::Web::cust_header()
                    ),
                  ],
                  #'align' => 'rlrrrc'.FS::UI::Web::cust_aligns(),
                  'align' => 'l'.
+                            $post_desc_align.
                             $peritem_align.
-                            'rc'.
+                            'rcrr'.
                             FS::UI::Web::cust_aligns(),
                  'color' => [ 
                               #'',
                               '',
+                              @post_desc_null,
                               @peritem_null,
                               '',
                               '',
+                              '',
+                              '',
                               FS::UI::Web::cust_colors(),
                             ],
                  'style' => [ 
                               #'',
                               '',
+                              @post_desc_null,
                               @peritem_null,
                               '',
                               '',
+                              '',
+                              '',
                               FS::UI::Web::cust_styles(),
                             ],
 &>
 <%doc>
 
-Output parameters:
+Output control parameters:
 - distribute: Boolean.  If true, recurring fees will be "prorated" for the 
   portion of the package date range (sdate-edate) that falls within the date
   range of the report.  Line items will be limited to those for which this 
   portion is > 0.  This disables filtering on invoice date.
 
-- use_usage: Separate usage (cust_bill_pkg_detail records) from
+- usage: Separate usage (cust_bill_pkg_detail records) from
   recurring charges.  If set to "usage", will show usage instead of 
   recurring charges.  If set to "recurring", will deduct usage and only
   show the flat rate charge.  If not passed, the "recurring charge" column
@@ -155,13 +174,24 @@ my $money_char = $conf->config('money_char') || '$';
 my @select = ( 'cust_bill_pkg.*', 'cust_bill._date' );
 my @total = ( 'COUNT(*)', 'SUM(cust_bill_pkg.setup + cust_bill_pkg.recur)');
 my @total_desc = ( '%d line items', $money_char.'%.2f total' ); # sprintf strings
+
 my @peritem = ( 'setup', 'recur' );
 my @peritem_desc = ( 'Setup charge', 'Recurring charge' );
-my ($join_cust, $join_pkg ) = ('', '');
-my $use_usage;
+
+my @post_desc_header = ();
+my @post_desc = ();
+my @post_desc_null = ();
+my $post_desc_align = '';
+if ( $conf->exists('enable_taxclasses') ) {
+  push @post_desc_header, 'Tax class';
+  push @post_desc, 'taxclass';
+  push @post_desc_null, '';
+  $post_desc_align .= 'l';
+  push @select, 'part_pkg.taxclass'; # or should this use override?
+}
 
 # valid in both the tax and non-tax cases
-$join_cust = 
+my $join_cust = 
   " LEFT JOIN cust_bill USING (invnum)
     LEFT JOIN cust_main USING (custnum)
   ";
@@ -200,26 +230,31 @@ if ( $cgi->param('refnum') =~ /^(\d+)$/ ) {
   push @where, "cust_main.refnum = $1";
 }
 
-# the non-tax case
-if ( $cgi->param('nottax') ) {
-
-  push @where, 'cust_bill_pkg.pkgnum > 0';
+# custnum
+if ( $cgi->param('custnum') =~ /^(\d+)$/ ) {
+  push @where, "cust_main.custnum = $1";
+}
 
-  # then we want the package and its definition
-  $join_pkg = 
+# we want the package and its definition if available
+my $join_pkg = 
 ' LEFT JOIN cust_pkg      USING (pkgnum) 
   LEFT JOIN part_pkg      USING (pkgpart)';
 
-  my $part_pkg = 'part_pkg';
-  if ( $cgi->param('use_override') ) {
-    # still need the real part_pkg for tax applicability, 
-    # so alias this one
-    $join_pkg .= " LEFT JOIN part_pkg AS override ON (
-    COALESCE(cust_bill_pkg.pkgpart_override, cust_pkg.pkgpart, 0) = part_pkg.pkgpart
-    )";
-    $part_pkg = 'override';
-  }
-  push @select, 'part_pkg.pkg'; # or should this use override?
+my $part_pkg = 'part_pkg';
+if ( $cgi->param('use_override') ) {
+  # still need the real part_pkg for tax applicability, 
+  # so alias this one
+  $join_pkg .= " LEFT JOIN part_pkg AS override ON (
+  COALESCE(cust_bill_pkg.pkgpart_override, cust_pkg.pkgpart, 0) = part_pkg.pkgpart
+  )";
+  $part_pkg = 'override';
+}
+push @select, 'part_pkg.pkg'; # or should this use override?
+
+# the non-tax case
+if ( $cgi->param('nottax') ) {
+
+  push @where, 'cust_bill_pkg.pkgnum > 0';
 
   my @tax_where; # will go into a subquery
   my @exempt_where; # will also go into a subquery
@@ -374,8 +409,7 @@ if ( $cgi->param('nottax') ) {
   }
 
   # recur/usage separation
-  $use_usage = $cgi->param('usage');
-  if ( $use_usage eq 'recurring' ) {
+  if ( $cgi->param('usage') eq 'recurring' ) {
 
     my $recur_no_usage = FS::cust_bill_pkg->charged_sql('', '', no_usage => 1);
     push @select, "($recur_no_usage) AS recur_no_usage";
@@ -383,7 +417,7 @@ if ( $cgi->param('nottax') ) {
     $total[1] = "SUM(cust_bill_pkg.setup + $recur_no_usage)";
     $total_desc[1] .= ' (excluding usage)';
 
-  } elsif ( $use_usage eq 'usage' ) {
+  } elsif ( $cgi->param('usage') eq 'usage' ) {
 
     my $usage = FS::cust_bill_pkg->usage_sql();
     push @select, "($usage) AS _usage";
@@ -486,6 +520,16 @@ if ( $cgi->param('nottax') ) {
 
 } # nottax / istax
 
+
+#total payments
+my $pay_sub = "SELECT SUM(cust_bill_pay_pkg.amount) AS pay_amount,
+    billpkgnum
+  FROM cust_bill_pay_pkg
+  GROUP BY billpkgnum";
+$join_pkg .= " LEFT JOIN ($pay_sub) AS item_pay USING (billpkgnum)";
+push @select, 'item_pay.pay_amount';
+
+
 # credit
 if ( $cgi->param('credit') ) {
 
@@ -544,7 +588,20 @@ if ( $cgi->param('credit') ) {
   push @peritem_desc, 'Credited', 'By', 'Reason';
   push @total,    'SUM(credit_amount)';
   push @total_desc, "$money_char%.2f credited";
-} # if credit
+
+} else {
+
+  #still want a credit total column
+
+  my $credit_sub = "SELECT SUM(cust_credit_bill_pkg.amount) AS credit_amount,
+      billpkgnum
+    FROM cust_credit_bill_pkg
+    GROUP BY billpkgnum";
+  $join_pkg .= " LEFT JOIN ($credit_sub) AS item_credit USING (billpkgnum)";
+
+  push @select,   'item_credit.credit_amount';
+
+}
 
 push @select, 'cust_main.custnum', FS::UI::Web::cust_sql_fields();
 
@@ -582,6 +639,9 @@ my $peritem_align = 'r' x scalar(@peritem);
 my $ilink = [ "${p}view/cust_bill.cgi?", 'invnum' ];
 my $clink = [ "${p}view/cust_main.cgi?", 'custnum' ];
 
+my $pay_link    = ''; #[, 'billpkgnum', ];
+my $credit_link = [ "${p}search/cust_credit_bill_pkg.html?billpkgnum=", 'billpkgnum', ];
+
 warn "\n\nQUERY:\n".Dumper($query)."\n\nCOUNT_QUERY:\n$count_query\n\n"
   if $cgi->param('debug');
 </%init>
index 4612118..06fd881 100644 (file)
@@ -1,10 +1,10 @@
 <% include( 'elements/search.html',
-              'title'       => 'Tax credits', #well, actually application of
-              'name'        => 'tax credits', # credit to line item
-              'query'       => $query,
-              'count_query' => $count_query,
-                 'count_addl'  => [ $money_char. '%.2f total', ],
-                 'header'      => [
+              'title'         => 'Credit application detail', #to line item
+              'name_singular' => 'credit application',
+              'query'         => $query,
+              'count_query'   => $count_query,
+               'count_addl'   => [ $money_char. '%.2f total', ],
+               'header'       => [
                    #'#',
 
                    'Amount',
 
                    # line item
                    'Description',
+                   @post_desc_header,
 
                    #invoice
                    'Invoice',
                    'Date',
                    FS::UI::Web::cust_header(),
-                 ],
-                 'fields'      => [
+               ],
+               'fields'       => [
                    #'creditbillpkgnum',
                    sub { sprintf($money_char.'%.2f', shift->amount ) },
 
                            ? $_[0]->get('pkg')      # possibly use override.pkg
                            : $_[0]->get('itemdesc') # but i think this correct
                        },
+                   @post_desc,
                    'invnum',
                    sub { time2str('%b %d %Y', shift->_date ) },
                    \&FS::UI::Web::cust_fields,
-                 ],
-                 'sort_fields' => [
+               ],
+               'sort_fields'  => [
                    'amount',
                    'cust_credit_date',
                    '', #'otaker',
                    '', #reason
                    '', #line item description
+                   @post_desc_null,
                    'invnum',
                    '_date',
                    #cust fields
-                 ],
-                 'links'       => [
+               ],
+               'links' => [
                    '',
                    '',
                    '',
                    '',
                    '',
+                   @post_desc_null,
                    $ilink,
                    $ilink,
                    ( map { $_ ne 'Cust. Status' ? $clink : '' }
                          FS::UI::Web::cust_header()
                    ),
-                 ],
-                 'align' => 'rrlllrr'.FS::UI::Web::cust_aligns(),
-                 'color' => [ 
+               ],
+               'align' => 'rrlll'.
+                          $post_desc_align.
+                          'rr'.
+                          FS::UI::Web::cust_aligns(),
+               'color' => [ 
                               '',
                               '',
                               '',
                               '',
                               '',
+                              @post_desc_null,
                               '',
                               '',
                               FS::UI::Web::cust_colors(),
                             ],
-                 'style' => [ 
+               'style' => [ 
                               '',
                               '',
                               '',
                               '',
                               '',
+                              @post_desc_null,
                               '',
                               '',
                               FS::UI::Web::cust_styles(),
-                            ],
+                          ],
            )
 %>
 <%init>
 
 #LOTS of false laziness below w/cust_bill_pkg.cgi
+# and a little w/cust_credit.html
 
 die "access denied"
   unless $FS::CurrentUser::CurrentUser->access_right('Financial reports');
@@ -98,14 +108,31 @@ my $agentnums_sql =
 
 my @where = ( $agentnums_sql );
 
+if ( $cgi->param('usernum') =~ /^(\d+)$/ ) {
+  push @where, "cust_credit.usernum = $1";
+}
+
 my($beginning, $ending) = FS::UI::Web::parse_beginning_ending($cgi);
 push @where, "cust_bill._date >= $beginning",
              "cust_bill._date <= $ending";
 
+my($cr_begin, $cr_end) = FS::UI::Web::parse_beginning_ending($cgi, 'credit');
+push @where, "cust_credit._date >= $cr_begin",
+             "cust_credit._date <= $cr_end";
+
+#credit amount?  seems more what is expected than the applied amount
+my @lt_gt = FS::UI::Web::parse_lt_gt($cgi, 'amount' );
+s/amount/cust_credit.amount/g foreach (@lt_gt);
+push @where, @lt_gt;
+
 if ( $cgi->param('agentnum') =~ /^(\d+)$/ ) {
   push @where, "cust_main.agentnum = $1";
 }
 
+if ( $cgi->param('billpkgnum') =~ /^(\d+)$/ ) {
+  push @where, "billpkgnum = $1";
+}
+
 #classnum
 # not specified: all classes
 # 0: empty class
@@ -346,7 +373,7 @@ if ( $cgi->param('cust_tax') ) {
   push @where, $cust_exempt;
 }
 
-my $count_query = "SELECT COUNT(DISTINCT billpkgnum),
+my $count_query = "SELECT COUNT(DISTINCT creditbillpkgnum),
                           SUM(cust_credit_bill_pkg.amount)";
 
 my $join_cust =
@@ -412,8 +439,8 @@ my $join_credit = ' LEFT JOIN cust_credit_bill USING ( creditbillnum )
                     LEFT JOIN cust_credit      USING ( crednum ) ';
 
 $count_query .= " FROM cust_credit_bill_pkg
-                         $join_pkg
                          $join_cust_bill_pkg
+                         $join_pkg
                          $join_credit
                          $join_cust
                        $where";
@@ -428,10 +455,22 @@ push @select, 'part_pkg.pkg' unless $cgi->param('istax');
 push @select, 'cust_main.custnum',
               FS::UI::Web::cust_sql_fields();
 
+my @post_desc_header = ();
+my @post_desc = ();
+my @post_desc_null = ();
+my $post_desc_align = '';
+if ( $conf->exists('enable_taxclasses') ) {
+  push @post_desc_header, 'Tax class';
+  push @post_desc, 'taxclass';
+  push @post_desc_null, '';
+  $post_desc_align .= 'l';
+  push @select, 'part_pkg.taxclass'; # or should this use override?
+}
+
 my $query = {
   'table'     => 'cust_credit_bill_pkg',
-  'addl_from' => "$join_pkg
-                  $join_cust_bill_pkg
+  'addl_from' => "$join_cust_bill_pkg
+                  $join_pkg
                   $join_credit
                   $join_cust",
   'hashref'   => {},
index 2ac6432..bdc3c54 100644 (file)
@@ -23,6 +23,6 @@
 <%init>
 
 die "access denied"
-  unless $curuser->access_right('Summarize packages');
+  unless $FS::CurrentUser::CurrentUser->access_right('Summarize packages');
 
 </%init>
index 5ce2e3a..0e9e24f 100644 (file)
@@ -9,8 +9,7 @@
   http_header('Content-Disposition' => qq!attachment;filename="$filename"!);
 
   my $output = '';
-  use IO::String;
-  my $XLS = IO::String->new($output);;
+  my $XLS = IO::String->new($output);
   my $workbook = $format->{class}->new($XLS)
     or die "Error opening .xls file: $!";
 
   $r++;
   } #$row
   $workbook->close;
+
+  http_header('Content-Length' => length($output));
+  $m->print($output);
 </%perl>
-<% $output %>
-% } else { 
+% } else {
 <& /elements/header.html, $title &>
 % my $myself = $cgi->self_url;
 <P ALIGN="right" CLASS="noprint">
@@ -106,7 +107,7 @@ as <A HREF="<% "$myself;_type=xls" %>">Excel spreadsheet</A><BR>
 %     my $style = '';
 %     $style .= " rowspan=".$cell->{rowspan} if $cell->{rowspan} > 1;
 %     $style .= " colspan=".$cell->{colspan} if $cell->{colspan} > 1;
-      <<%$td%><%$style%>><% $cell->{value} %></<%$td%>>
+      <<%$td%><%$style%>><% $cell->{value} |h %></<%$td%>>
 %   }
   </tr>
 % }
index 5c8001f..7ccf356 100644 (file)
 %
 %                     my $links    = $opt{'links'} ? [ @{$opt{'links'}} ] : '';
 %                     my $onclicks = $opt{'link_onclicks'} ? [ @{$opt{'link_onclicks'}} ] : [];
+%                     my $tooltips = $opt{'tooltips'} ? [ @{$opt{'tooltips'}} ] : [];
 %                     my $aligns   = $opt{'align'} ? [ @{$opt{'align'}} ] : '';
 %                     my $colors   = $opt{'color'} ? [ @{$opt{'color'}} ] : [];
 %                     my $sizes    = $opt{'size'}  ? [ @{$opt{'size'}}  ] : [];
 %                       if ( $links ) {
 %                         my $link = shift @$links;
 %                         my $onclick = shift @$onclicks;
+%                         my $tooltip = shift @$tooltips;
 %
 %                         if (    ! $opt{'agent_virt'}
 %                              || ( $null_link && ! $row->agentnum )
 %                             if ref($onclick) eq 'CODE';
 %                           $onclick = qq( onClick="$onclick") if $onclick;
 %
+%                           $tooltip = &{$tooltip}($row)
+%                             if ref($tooltip) eq 'CODE';
+%                           $tooltip = qq! id="a$id" !.
+%                             qq! onmouseover="return overlib(!.
+%                             $m->interp->apply_escapes($tooltip, 'h', 'js_string').
+%                             qq!, FGCLASS, 'tooltip', REF, 'a$id', !.
+%                             qq!REFC, 'LL', REFP, 'UL')"! if $tooltip;
+%
 %                           if ( $link ) {
 %                             my( $url, $method ) = @{$link};
 %                             if ( ref($method) eq 'CODE' ) {
 %                             } else {
 %                               $a = $url. $row->$method();
 %                             }
-%                             $a = qq(<A HREF="$a"$onclick>);
+%                             $a = qq(<A HREF="$a"$onclick$tooltip>);
 %                           }
 %                           elsif ( $onclick ) {
 %                             $a = qq(<A HREF="javascript:void(0);"$onclick>);
 %                           }
+%                           elsif ( $tooltip ) {
+%                             $a = qq(<A $tooltip>);
+%                           }
+%                           $id++;
+
 %                         }
 %
 %                       }
@@ -499,4 +514,5 @@ $count_sth->execute
 my $count_arrayref = $count_sth->fetchrow_arrayref;
 my $total = $count_arrayref->[0];
 
+my $id = 0;
 </%init>
index 94d88b0..26a51c4 100644 (file)
@@ -1,4 +1,3 @@
-<% $data %>
 <%init>
 
 my %args = @_;
@@ -148,5 +147,6 @@ if ( $opt{'footer'} ) {
 $workbook->close();# or die "Error creating .xls file: $!";
 
 http_header('Content-Length' => length($data) );
+$m->print($data);
 
 </%init>
index eca68a2..5a16a22 100644 (file)
@@ -176,7 +176,9 @@ Example:
 %
 % } elsif ( $type =~ /\.xls$/ ) {
 %
-<% include('search-xls.html',  header=>$header, rows=>$rows, opt=>\%opt ) %>
+<& 'search-xls.html',  header=>$header, rows=>$rows, opt=>\%opt &>\
+% # prevent the caller from polluting our output stream
+% $m->abort;
 %
 % } elsif ( $type eq 'xml' ) {
 %
diff --git a/httemplate/search/log.html b/httemplate/search/log.html
new file mode 100644 (file)
index 0000000..d1bfb6c
--- /dev/null
@@ -0,0 +1,221 @@
+<& elements/search.html, 
+  'title'         => 'System Log',
+  'name_singular' => 'event',
+  'html_init'     => include('.head'),
+  'query'         => $query,
+  'count_query'   => $count_query,
+  'header'        => [ #'#', # lognum, probably not useful
+                       'Date',
+                       'Level',
+                       'Context',
+                       'Applies To',
+                       'Message',
+                     ],
+  'fields'        => [ #'lognum',
+                       $date_sub,
+                       $level_sub,
+                       $context_sub,
+                       $object_sub,
+                       $message_sub,
+                     ],
+  'sort_fields'   => [
+                       '_date',
+                       'level',
+                       '',
+                       'tablename,tablenum',
+                       'message',
+                     ],
+  'links'         => [
+                       '', #date
+                       '', #level
+                       '', #context
+                       $object_link_sub,
+                       '', #message
+                     ],
+  'tooltips'      => [
+                       '', #date
+                       '', #level
+                       $tt_sub,
+                       '', #object
+                       $tt_sub,
+                     ],
+  'color'         => [
+                       $color_sub,
+                       $color_sub,
+                       '',
+                       '',
+                       '',
+                     ],
+  # aligns
+  'download_label' => 'Download this log',
+&>\
+<%def .head>
+<STYLE type="text/css">
+a:link    {text-decoration: none}
+a:visited {text-decoration: none}
+.tooltip {
+  background-color: #ffffff;
+  font-size: 100%;
+  font-weight: bold;
+}
+</STYLE>
+<FORM ACTION="<%$p%>search/log.html" METHOD="GET">
+<TABLE CELLSPACING="10">
+<TR>
+  <TD>From 
+    <& /elements/input-date-field.html, {
+      name => 'beginning',
+      value => $cgi->param('beginning'),
+    } &>
+  </TD>
+  <TD>To 
+    <& /elements/input-date-field.html, {
+      name => 'ending',
+      value => $cgi->param('ending') || '',
+      noinit => 1,
+    } &>
+  </TD>
+</TR>
+<TR>
+  <TD>Level
+    <& /elements/select.html,
+      field => 'min_level',
+      options => [ 0..7 ],
+      labels => { map {$_ => $FS::Log::LEVELS[$_]} 0..7 },
+      curr_value => $cgi->param('min_level'),
+    &>
+     to
+    <& /elements/select.html,
+      field => 'max_level',
+      options => [ 0..7 ],
+      labels => { map {$_ => $FS::Log::LEVELS[$_]} 0..7 },
+      curr_value => $cgi->param('max_level'),
+    &>
+  </TD>
+  <TD>
+    Context
+    <& /elements/select.html,
+      field  => 'context',
+      options => \@contexts,
+      labels => { map {$_, $_} @contexts },
+      curr_value => ($cgi->param('context') || ''),
+    &>
+  </TD>
+</TR>
+<TR>
+  <TD COLSPAN=2>
+    Containing text
+      <& /elements/input-text.html,
+        field => 'message',
+        size => 30,
+        size => 30,
+        curr_value => ($cgi->param('message') || ''),
+      &>
+    <DIV STYLE="display:inline; float:right">
+      <INPUT TYPE="submit" VALUE="Refresh">
+    </DIV>
+  </TD>
+</TR>
+</TABLE>
+</%def>
+<%once>
+my $date_sub = sub { time2str('%Y-%m-%d %T', $_[0]->_date) };
+
+my $level_sub = sub { $FS::Log::LEVELS[$_[0]->level] };
+
+my $context_sub = sub {
+  my $log = shift;
+  ($log->context)[-1] . (scalar($log->context) > 1 ? '...' : '') ;
+  # XXX find a way to make this use less space (dropdown?)
+};
+
+my $tt_sub = sub {
+  my $log = shift;
+  my @context = $log->context;
+  # don't create a tooltip if there's only one context entry and the 
+  # message isn't cut off
+  return '' if @context == 1 and length($log->message) <= 60;
+  my $html = '<DIV CLASS="tooltip">'.(shift @context).'</DIV>';
+  my $pre = '&#8627;';
+  foreach (@context, $log->message) {
+    $html .= "<DIV>$pre$_</DIV>";
+    $pre = '&nbsp;&nbsp;&nbsp;'.$pre;
+  }
+  $html;
+};
+
+my $object_sub = sub {
+  my $log = shift;
+  return '' unless $log->tablename;
+  # this is a sysadmin log; anyone reading it should be able to understand
+  # 'cust_main #2319' with no trouble.
+  $log->tablename . ' #' . $log->tablenum;
+};
+
+my $message_sub = sub {
+  my $log = shift;
+  my $message = $log->message;
+  if ( length($message) > 60 ) { # pretty arbitrary
+    $message = substr($message, 0, 57) . '...';
+  }
+  $message;
+};
+
+my $object_link_sub = sub {
+  my $log = shift;
+  my $table = $log->tablename or return;
+  # sigh
+  if ( grep {$_ eq $table} (qw( cust_bill cust_main cust_pkg cust_svc ))
+       or $table =~ /^svc_/ )
+  {
+
+    return [ $fsurl.'view/'.$table.'.cgi?'. $log->tablenum ];
+
+  } elsif ( grep {$_ eq $table} (qw( cust_msg cust_pay cust_pay_void 
+                                     cust_refund cust_statement )) )
+  {
+
+    return [ $fsurl.'view/'.$table.'.html?', $log->tablenum ];
+
+  } else { # you're on your own
+
+    return '';
+
+  }
+};
+
+my @colors = (
+  '404040', #debug
+  '0000aa', #info
+  '00aa00', #notice
+  'aa0066', #warning
+  '000000', #error
+  'aa0000', #critical
+  'ff0000', #alert
+  'ff0000', #emergency
+);
+
+my $color_sub = sub { $colors[ $_[0]->level ]; };
+
+my @contexts = ('', sort FS::log_context->contexts);
+</%once>
+<%init>
+my $curuser = $FS::CurrentUser::CurrentUser;
+die "access denied"
+  unless $curuser->access_right([ 'View system logs', 'Configuration' ]);
+
+$cgi->param('min_level', 0) unless defined($cgi->param('min_level'));
+$cgi->param('max_level', 7) unless defined($cgi->param('max_level'));
+
+my %search = ();
+$search{'date'} = [ FS::UI::Web::parse_beginning_ending($cgi) ];
+$search{'level'} = [ $cgi->param('min_level'), $cgi->param('max_level') ];
+foreach my $param (qw(agentnum context tablename tablenum custnum message)) {
+  if ( $cgi->param($param) ) {
+    $search{$param} = $cgi->param($param);
+  }
+}
+my $query = FS::log->search(\%search); # validates everything
+my $count_query = delete $query->{'count_query'};
+
+</%init>
diff --git a/httemplate/search/report_cust_bill_pkg.html b/httemplate/search/report_cust_bill_pkg.html
new file mode 100644 (file)
index 0000000..4f6ee78
--- /dev/null
@@ -0,0 +1,118 @@
+<& /elements/header.html, mt('Line item report') &>
+
+<FORM ACTION="cust_bill_pkg.cgi" METHOD="GET">
+<!--<INPUT TYPE="hidden" NAME="magic" VALUE="_date">-->
+
+<TABLE BGCOLOR="#cccccc" CELLSPACING=0
+
+<& /elements/tr-select-agent.html,
+     curr_value    => scalar( $cgi->param('agentnum') ),
+     #label         => emt('Line items for agent: '),
+     disable_empty => 0,
+&>
+
+<& /elements/tr-select-cust_main-status.html,
+     label => emt('Customer status'),
+&>
+
+<!-- customer
+<& /elements/tr-select-cust_class.html,
+     'label'        => emt('Class'),
+     'multiple'     => 1,
+     'pre_options'  => [ '' => emt('(none)') ],
+     'all_selected' => 1,
+&>
+-->
+
+<& /elements/tr-input-beginning_ending.html &>
+
+<!-- needs support in cust_bill_pkg.cgi
+<& /elements/tr-input-lessthan_greaterthan.html,
+     label   => emt('Amount'),
+     field   => 'amount',
+&>
+-->
+
+<!-- customer payment method i guess
+  <& /elements/tr-select-payby.html,
+                label   => emt('Payment method:'),
+                payby_type   => 'cust',
+                multiple     => 1,
+                all_selected => 1,
+  &>
+-->
+
+<TR>
+  <TD ALIGN="right"><INPUT TYPE="checkbox" NAME="nottax" VALUE="Y" onClick="nottax_changed(this)" onChange="nottax_change(thid)"></TD>
+  <TD><% mt('Omit taxes') |h %></TD>
+</TD>
+
+<TR>
+  <TD ALIGN="right"><INPUT TYPE="checkbox" NAME="istax" VALUE="Y" onClick="istax_changed(this)" onChange="istax_change(thid)"></TD>
+  <TD><% mt('Taxes only') |h %></TD>
+</TD>
+
+<!--
+<TR>
+  <TD ALIGN="right"><INPUT TYPE="checkbox" NAME="credit" VALUE="Y"></TD>
+  <TD><% mt("Credit (what's this do?)") |h %></TD>
+</TD>
+-->
+
+</TABLE>
+
+<SCRIPT TYPE="text/javascript">
+  function nottax_changed (what) {
+    if (what.checked && what.form.istax.checked) {
+      what.form.istax.checked = false;
+    }
+  }
+  function istax_changed (what) {
+    if (what.checked && what.form.nottax.checked) {
+      what.form.nottax.checked = false;
+    }
+  }
+</SCRIPT>
+
+<BR>
+<INPUT TYPE="submit" VALUE="<% mt('Get Report') |h %>">
+
+</FORM>
+
+<& /elements/footer.html &>
+<%init>
+
+#Financial reports?
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('List invoices');
+
+my $conf = new FS::Conf;
+
+#other available params (cust_bill_pkg.cgi):
+#
+#distribute = 1
+#
+#(when nottax)
+#  use_override something about part_pkg
+#  classnum package class
+#  taxclass / taxclassNULL
+#  exempt_cust
+#  exempt_pkg
+#  region (country:state:county:city:district)
+#  taxable
+#  out (of taxable region)
+#  usage
+#(when istax)
+#  locationtaxid (& district/city/ciounty/state)
+#  out (of taxable region)
+#  taxclassNULL
+#  report_group (itemdesc)
+#  itemdesc
+#
+#taxname/taxnameNULL cust_main_county
+#taxnum cust_main_county
+#credit (hmm need to look more at what this does)
+
+
+</%init>
+
diff --git a/httemplate/search/report_cust_credit_bill_pkg.html b/httemplate/search/report_cust_credit_bill_pkg.html
new file mode 100644 (file)
index 0000000..2b9e1e6
--- /dev/null
@@ -0,0 +1,104 @@
+<& /elements/header.html, mt('Credit application report') &>
+
+<FORM ACTION="cust_credit_bill_pkg.html" METHOD="GET">
+<!--<INPUT TYPE="hidden" NAME="magic" VALUE="_date">-->
+
+<TABLE BGCOLOR="#cccccc" CELLSPACING=0
+
+<& /elements/tr-select-user.html,
+              'label'       => emt('Employee: '),
+              'access_user' => \%access_user,
+&>
+
+<& /elements/tr-select-agent.html,
+     curr_value    => scalar( $cgi->param('agentnum') ),
+     #label         => emt('Line items for agent: '),
+     disable_empty => 0,
+&>
+
+<!--
+<& /elements/tr-select-cust_main-status.html,
+     label => emt('Customer status'),
+&>
+-->
+
+<!-- customer
+<& /elements/tr-select-cust_class.html,
+     'label'        => emt('Class'),
+     'multiple'     => 1,
+     'pre_options'  => [ '' => emt('(none)') ],
+     'all_selected' => 1,
+&>
+-->
+
+<!-- some sort of label saying this is the credit date... -->
+<& /elements/tr-input-beginning_ending.html,
+     'prefix' => 'credit',
+&>
+
+<& /elements/tr-input-lessthan_greaterthan.html,
+     label   => emt('Amount'),
+     field   => 'amount',
+&>
+
+<!-- customer payment method i guess
+  <& /elements/tr-select-payby.html,
+                label   => emt('Payment method:'),
+                payby_type   => 'cust',
+                multiple     => 1,
+                all_selected => 1,
+  &>
+-->
+
+<!--
+<TR>
+  <TD ALIGN="right"><INPUT TYPE="checkbox" NAME="nottax" VALUE="Y" onClick="nottax_changed(this)" onChange="nottax_change(thid)"></TD>
+  <TD><% mt('Omit taxes') |h %></TD>
+</TD>
+
+<TR>
+  <TD ALIGN="right"><INPUT TYPE="checkbox" NAME="istax" VALUE="Y" onClick="istax_changed(this)" onChange="istax_change(thid)"></TD>
+  <TD><% mt('Taxes only') |h %></TD>
+</TD>
+
+<SCRIPT TYPE="text/javascript">
+  function nottax_changed (what) {
+    if (what.checked && what.form.istax.checked) {
+      what.form.istax.checked = false;
+    }
+  }
+  function istax_changed (what) {
+    if (what.checked && what.form.nottax.checked) {
+      what.form.nottax.checked = false;
+    }
+  }
+</SCRIPT>
+-->
+
+</TABLE>
+
+<BR>
+<INPUT TYPE="submit" VALUE="<% mt('Get Report') |h %>">
+
+</FORM>
+
+<& /elements/footer.html &>
+<%init>
+
+#Financial reports?
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Financial reports');
+
+#false laziness w/report_cust_credit.html
+my $sth = dbh->prepare("SELECT DISTINCT usernum FROM cust_credit")
+  or die dbh->errstr;
+$sth->execute or die $sth->errstr;
+my @usernum = map $_->[0], @{$sth->fetchall_arrayref};
+my %access_user =
+  map { $_ => qsearchs('access_user',{'usernum'=>$_})->username }
+      @usernum;
+
+my $conf = new FS::Conf;
+
+</%init>
+
index f19f85a..bb843a7 100755 (executable)
@@ -1,4 +1,3 @@
-<% $data %>
 <%init>
 my $htmldoc = include('report_tax.cgi');
 
@@ -155,4 +154,6 @@ for my $x (0..scalar(@widths)-1) {
 
 $workbook->close;
 
+http_header('Content-Length' => length($data));
+$m->print($data);
 </%init>
index 166addb..6630d12 100644 (file)
                'actionlabel' => emt('Enter credit'),
                'width'       => 616, #make room for reasons #540 default
   &>
+  |
+  <& /elements/popup_link-cust_main.html,
+               'label'       => emt('Credit line items'),
+               #'action'      => "${p}search/cust_bill_pkg.cgi?nottax=1;type=select",
+               'action'      => "${p}edit/credit-cust_bill_pkg.html",
+               'cust_main'   => $cust_main,
+               'actionlabel' => emt('Credit line items'),
+               'width'       => 884, #763,
+               'height'      => 575,
+  &>
   <BR>
 % } 
 
index 2029fd4..e8b1266 100644 (file)
@@ -65,9 +65,6 @@ When event is run on <& /elements/input-date-field.html, {
 
 %}
 <& /elements/footer.html &>
-<%once>
-use List::MoreUtils qw(uniq);
-</%once>
 <%init>
 
 my $curuser = $FS::CurrentUser::CurrentUser;
index b415a06..fbe3fae 100644 (file)
@@ -157,6 +157,7 @@ SYSTEM_BINARIES             =       rt-attributes-viewer \
                                rt-shredder \
                                rt-test-dependencies \
                                rt-validator \
+                               rt-validate-aliases \
                                standalone_httpd
 
 
index 76ef85b..caf3967 100755 (executable)
@@ -1,7 +1,7 @@
 #! /bin/sh
 # From configure.ac Revision.
 # Guess values for system-dependent variables and create Makefiles.
-# Generated by GNU Autoconf 2.68 for RT rt-4.0.7.
+# Generated by GNU Autoconf 2.68 for RT rt-4.0.8.
 #
 # Report bugs to <rt-bugs@bestpractical.com>.
 #
@@ -560,8 +560,8 @@ MAKEFLAGS=
 # Identity of this package.
 PACKAGE_NAME='RT'
 PACKAGE_TARNAME='rt'
-PACKAGE_VERSION='rt-4.0.7'
-PACKAGE_STRING='RT rt-4.0.7'
+PACKAGE_VERSION='rt-4.0.8'
+PACKAGE_STRING='RT rt-4.0.8'
 PACKAGE_BUGREPORT='rt-bugs@bestpractical.com'
 PACKAGE_URL=''
 
@@ -1311,7 +1311,7 @@ if test "$ac_init_help" = "long"; then
   # Omit some internal or obsolete options to make the list less imposing.
   # This message is too long to be a string in the A/UX 3.1 sh.
   cat <<_ACEOF
-\`configure' configures RT rt-4.0.7 to adapt to many kinds of systems.
+\`configure' configures RT rt-4.0.8 to adapt to many kinds of systems.
 
 Usage: $0 [OPTION]... [VAR=VALUE]...
 
@@ -1372,7 +1372,7 @@ fi
 
 if test -n "$ac_init_help"; then
   case $ac_init_help in
-     short | recursive ) echo "Configuration of RT rt-4.0.7:";;
+     short | recursive ) echo "Configuration of RT rt-4.0.8:";;
    esac
   cat <<\_ACEOF
 
@@ -1496,7 +1496,7 @@ fi
 test -n "$ac_init_help" && exit $ac_status
 if $ac_init_version; then
   cat <<\_ACEOF
-RT configure rt-4.0.7
+RT configure rt-4.0.8
 generated by GNU Autoconf 2.68
 
 Copyright (C) 2010 Free Software Foundation, Inc.
@@ -1597,7 +1597,7 @@ cat >config.log <<_ACEOF
 This file contains any messages produced by compilers while
 running configure, to aid debugging if configure makes a mistake.
 
-It was created by RT $as_me rt-4.0.7, which was
+It was created by RT $as_me rt-4.0.8, which was
 generated by GNU Autoconf 2.68.  Invocation command line was
 
   $ $0 $@
@@ -1954,7 +1954,7 @@ rt_version_major=4
 
 rt_version_minor=0
 
-rt_version_patch=7
+rt_version_patch=8
 
 test "x$rt_version_major" = 'x' && rt_version_major=0
 test "x$rt_version_minor" = 'x' && rt_version_minor=0
@@ -3923,7 +3923,7 @@ RT_LOG_PATH_R=${exp_logfiledir}
 fi
 
 
-ac_config_files="$ac_config_files etc/upgrade/3.8-branded-queues-extension etc/upgrade/3.8-ical-extension etc/upgrade/split-out-cf-categories etc/upgrade/generate-rtaddressregexp etc/upgrade/upgrade-articles etc/upgrade/vulnerable-passwords sbin/rt-attributes-viewer sbin/rt-preferences-viewer sbin/rt-session-viewer sbin/rt-dump-metadata sbin/rt-setup-database sbin/rt-test-dependencies sbin/rt-email-digest sbin/rt-email-dashboards sbin/rt-clean-sessions sbin/rt-shredder sbin/rt-validator sbin/rt-email-group-admin sbin/rt-server sbin/rt-server.fcgi sbin/standalone_httpd sbin/rt-setup-fulltext-index sbin/rt-fulltext-indexer bin/rt-crontool bin/rt-mailgate bin/rt"
+ac_config_files="$ac_config_files etc/upgrade/3.8-branded-queues-extension etc/upgrade/3.8-ical-extension etc/upgrade/split-out-cf-categories etc/upgrade/generate-rtaddressregexp etc/upgrade/upgrade-articles etc/upgrade/vulnerable-passwords sbin/rt-attributes-viewer sbin/rt-preferences-viewer sbin/rt-session-viewer sbin/rt-dump-metadata sbin/rt-setup-database sbin/rt-test-dependencies sbin/rt-email-digest sbin/rt-email-dashboards sbin/rt-clean-sessions sbin/rt-shredder sbin/rt-validator sbin/rt-validate-aliases sbin/rt-email-group-admin sbin/rt-server sbin/rt-server.fcgi sbin/standalone_httpd sbin/rt-setup-fulltext-index sbin/rt-fulltext-indexer bin/rt-crontool bin/rt-mailgate bin/rt"
 
 
 ac_config_files="$ac_config_files Makefile etc/RT_Config.pm lib/RT/Generated.pm t/data/configs/apache2.2+mod_perl.conf t/data/configs/apache2.2+fastcgi.conf"
@@ -4482,7 +4482,7 @@ cat >>$CONFIG_STATUS <<\_ACEOF || ac_write_fail=1
 # report actual input values of CONFIG_FILES etc. instead of their
 # values after options handling.
 ac_log="
-This file was extended by RT $as_me rt-4.0.7, which was
+This file was extended by RT $as_me rt-4.0.8, which was
 generated by GNU Autoconf 2.68.  Invocation command line was
 
   CONFIG_FILES    = $CONFIG_FILES
@@ -4535,7 +4535,7 @@ _ACEOF
 cat >>$CONFIG_STATUS <<_ACEOF || ac_write_fail=1
 ac_cs_config="`$as_echo "$ac_configure_args" | sed 's/^ //; s/[\\""\`\$]/\\\\&/g'`"
 ac_cs_version="\\
-RT config.status rt-4.0.7
+RT config.status rt-4.0.8
 configured by $0, generated by GNU Autoconf 2.68,
   with options \\"\$ac_cs_config\\"
 
@@ -4663,6 +4663,7 @@ do
     "sbin/rt-clean-sessions") CONFIG_FILES="$CONFIG_FILES sbin/rt-clean-sessions" ;;
     "sbin/rt-shredder") CONFIG_FILES="$CONFIG_FILES sbin/rt-shredder" ;;
     "sbin/rt-validator") CONFIG_FILES="$CONFIG_FILES sbin/rt-validator" ;;
+    "sbin/rt-validate-aliases") CONFIG_FILES="$CONFIG_FILES sbin/rt-validate-aliases" ;;
     "sbin/rt-email-group-admin") CONFIG_FILES="$CONFIG_FILES sbin/rt-email-group-admin" ;;
     "sbin/rt-server") CONFIG_FILES="$CONFIG_FILES sbin/rt-server" ;;
     "sbin/rt-server.fcgi") CONFIG_FILES="$CONFIG_FILES sbin/rt-server.fcgi" ;;
@@ -5131,6 +5132,8 @@ which seems to be undefined.  Please make sure it is defined" >&2;}
                 ;;
     "sbin/rt-validator":F) chmod ug+x $ac_file
                 ;;
+    "sbin/rt-validate-aliases":F) chmod ug+x $ac_file
+                ;;
     "sbin/rt-email-group-admin":F) chmod ug+x $ac_file
                 ;;
     "sbin/rt-server":F) chmod ug+x $ac_file
index be02a68..a168e28 100644 (file)
@@ -425,6 +425,7 @@ AC_CONFIG_FILES([
                  sbin/rt-clean-sessions
                  sbin/rt-shredder
                  sbin/rt-validator
+                 sbin/rt-validate-aliases
                  sbin/rt-email-group-admin
                  sbin/rt-server
                  sbin/rt-server.fcgi
diff --git a/rt/devel/tools/apache.conf b/rt/devel/tools/apache.conf
deleted file mode 100644 (file)
index 2ae67c6..0000000
+++ /dev/null
@@ -1,173 +0,0 @@
-# Single-process Apache testing with mod_perl, mod_fcgi, or mod_fastcgi
-#
-# Start this via:
-#     apache2 -f `pwd`/devel/tools/apache.conf -DPERL -k start
-#
-# The full path to the configuration file is needed, or Apache assumes
-# it is under the ServerRoot.  Since the deployment strategies differ
-# between RT 3 and 4, you must either supply -DRT3 if you are attempting
-# to deploy an rt3 instance.  You must also supply one of -DPERL,
-# -DFASTCGI, or -DFCGID.
-#
-# The /opt/rt4/etc/apache_local.conf file should contain:
-#     User chmrr
-#     Group chmrr
-#     Listen 8080
-# ...or the equivilent.
-#
-# Apache access and error logs will be written to /opt/rt4/var/log/.
-#
-<IfDefine !RT3>
-Include /opt/rt4/etc/apache_local.conf
-</IfDefine>
-<IfDefine RT3>
-Include /opt/rt3/etc/apache_local.conf
-</IfDefine>
-
-<IfModule mpm_prefork_module>
-    StartServers          1
-    MinSpareServers       1
-    MaxSpareServers       1
-    MaxClients            1
-    MaxRequestsPerChild   0
-</IfModule>
-
-<IfModule mpm_worker_module>
-    StartServers          1
-    MinSpareThreads       1
-    MaxSpareThreads       1
-    ThreadLimit           1
-    ThreadsPerChild       1
-    MaxClients            1
-    MaxRequestsPerChild   0
-</IfModule>
-
-ServerRoot /etc/apache2
-PidFile /opt/rt4/var/apache2.pid
-LockFile /opt/rt4/var/apache2.lock
-ServerAdmin root@localhost
-
-LoadModule authz_host_module /usr/lib/apache2/modules/mod_authz_host.so
-LoadModule env_module /usr/lib/apache2/modules/mod_env.so
-LoadModule alias_module /usr/lib/apache2/modules/mod_alias.so
-LoadModule mime_module /usr/lib/apache2/modules/mod_mime.so
-<IfDefine PERL>
-    LoadModule perl_module /usr/lib/apache2/modules/mod_perl.so
-</IfDefine>
-<IfDefine FASTCGI>
-    LoadModule fastcgi_module /usr/lib/apache2/modules/mod_fastcgi.so
-</IfDefine>
-<IfDefine FCGID>
-    LoadModule fcgid_module /usr/lib/apache2/modules/mod_fcgid.so
-</IfDefine>
-
-ErrorLog "/opt/rt4/var/log/apache-error.log"
-TransferLog "/opt/rt4/var/log/apache-access.log"
-LogLevel debug
-
-<Directory />
-    Options FollowSymLinks
-    AllowOverride None
-    Order deny,allow
-    Deny from all
-</Directory>
-
-AddDefaultCharset UTF-8
-
-DocumentRoot /var/www
-<Directory /var/www>
-    Order allow,deny
-    Allow from all
-</Directory>
-
-Alias /NoAuth/images/ /opt/rt4/share/html/NoAuth/images/
-<Directory /opt/rt4/share/html/NoAuth/images>
-    Order allow,deny
-    Allow from all
-</Directory>
-
-<IfDefine !RT3>
-########## 4.0 mod_perl
-<IfDefine PERL>
-    PerlSetEnv RT_SITE_CONFIG /opt/rt4/etc/RT_SiteConfig.pm
-    <Location />
-        Order allow,deny
-        Allow from all
-        SetHandler modperl
-        PerlResponseHandler Plack::Handler::Apache2
-        PerlSetVar psgi_app /opt/rt4/sbin/rt-server
-    </Location>
-    <Perl>
-        use Plack::Handler::Apache2;
-        Plack::Handler::Apache2->preload("/opt/rt4/sbin/rt-server");
-    </Perl>
-</IfDefine>
-
-########## 4.0 mod_fastcgi
-<IfDefine FASTCGI>
-    FastCgiIpcDir /opt/rt4/var
-    FastCgiServer /opt/rt4/sbin/rt-server.fcgi -processes 1 -idle-timeout 300
-    ScriptAlias / /opt/rt4/sbin/rt-server.fcgi/
-    <Location />
-        Order allow,deny
-        Allow from all
-        Options +ExecCGI
-        AddHandler fastcgi-script fcgi
-    </Location>
-</IfDefine>
-
-########## 4.0 mod_fcgid
-<IfDefine FCGID>
-    FcgidProcessTableFile /opt/rt4/var/fcgid_shm
-    FcgidIPCDir /opt/rt4/var
-    ScriptAlias / /opt/rt4/sbin/rt-server.fcgi/
-    <Location />
-        Order allow,deny
-        Allow from all
-        Options +ExecCGI
-        AddHandler fcgid-script fcgi
-    </Location>
-</IfDefine>
-</IfDefine>
-
-
-<IfDefine RT3>
-########## 3.8 mod_perl
-<IfDefine PERL>
-    PerlSetEnv RT_SITE_CONFIG /opt/rt3/etc/RT_SiteConfig.pm
-    PerlRequire "/opt/rt3/bin/webmux.pl"
-    <Location /NoAuth/images>
-        SetHandler default
-    </Location>
-    <Location />
-        SetHandler perl-script
-        PerlResponseHandler RT::Mason
-    </Location>
-</IfDefine>
-
-########## 3.8 mod_fastcgi
-<IfDefine FASTCGI>
-    FastCgiIpcDir /opt/rt3/var
-    FastCgiServer /opt/rt3/bin/mason_handler.fcgi -processes 1 -idle-timeout 300
-    ScriptAlias / /opt/rt3/bin/mason_handler.fcgi/
-    <Location />
-       Order allow,deny
-       Allow from all
-       Options +ExecCGI
-       AddHandler fastcgi-script fcgi
-    </Location>
-</IfDefine>
-
-########## 3.8 mod_fcgid
-<IfDefine FCGID>
-    FcgidProcessTableFile /opt/rt3/var/fcgid_shm
-    FcgidIPCDir /opt/rt3/var
-    ScriptAlias / /opt/rt3/bin/mason_handler.fcgi/
-    <Location />
-       Order allow,deny
-       Allow from all
-       Options +ExecCGI
-       AddHandler fcgid-script fcgi
-    </Location>
-</IfDefine>
-</IfDefine>
index a935552..792276f 100644 (file)
@@ -1,7 +1,7 @@
-UPGRADING FROM 2.x:
+=head1 UPGRADING FROM 2.x
 
-The core RT distribution does not contain the tool to upgrade RT from
-version 2.0; the tool, can be downloaded from CPAN at
+The core RT distribution does not contain the tool to upgrade RT from version
+2.0; the tool, can be downloaded from CPAN at
 http://search.cpan.org/dist/RT-Extension-RT2toRT3/
 
 Further instructions may be found in that distribution's README file.
index 625ca4b..1bc1b55 100644 (file)
@@ -1,18 +1,20 @@
-UPGRADING FROM 3.0.x - Changes:
+=head1 UPGRADING FROM 3.0.0 AND EARLIER
 
-= Installation =
+=head2 Installation
 
 We recommend you move your existing /opt/rt3 tree completely out
 of the way before installing the new version of RT, to make sure
 that you don't inadvertently leave old files hanging around.
 
-= Rights changes =
+
+=head2 Rights changes
 
 Now, if you want RT to automatically create new users upon ticket
 submission, you MUST grant 'Everyone' the right to create tickets.
 Granting this right only to "Unprivileged Users" is now insufficient.
 
-= Web server configuration
+
+=head2 Web server configuration
 
 The configuration for RT's web interface has changed.  Please refer to
 docs/web_deployment.pod for instructions.
index c0b8ceb..4641209 100644 (file)
@@ -1,11 +1,10 @@
-UPGRADING FROM 3.2 and earlier - Changes:
+=head1 UPGRADING FROM 3.2.0 AND EARLIER
 
-= Rights changes =
+There have been a number of rights changes.  Now, if you want any user to be
+able to access the Admin tools (a.k.a.  the Configuration tab), you must grant
+that user the "ShowConfigTab" right.  Making the user a privileged user is no
+longer sufficient.
 
-Now, if you want any user to be able to access the Admin tools (a.k.a.
-the Configuration tab), you must grant that user the "ShowConfigTab"
-right.  Making the user a privileged user is no longer sufficient.
-
-"SuperUser" users are no longer automatically added to the list of users
-who can own tickets in a queue. You now need to explicitly give them the
+"SuperUser" users are no longer automatically added to the list of users who
+can own tickets in a queue. You now need to explicitly give them the
 "OwnTicket" right.
index 4dca045..89454bd 100644 (file)
@@ -1,12 +1,11 @@
-UPGRADING FROM 3.3.14 and earlier - Changes:
+=head1 UPGRADING FROM 3.3.14 AND EARLIER
 
 The "ModifyObjectCustomFieldValues" right name was too long. It has been
 changed to "ModifyCustomField"
 
 
-UPGRADING FROM 3.3.11 and earlier - Changes:
+=head1 UPGRADING FROM 3.3.11 AND EARLIER
 
-Custom Fields now have an additional right, "ModifyCustomField".  This
-right governs whether a user can modify an object's custom field values
-for a particular custom field. This includes adding, deleting and
-changing values.
+Custom Fields now have an additional right, "ModifyCustomField".  This right
+governs whether a user can modify an object's custom field values for a
+particular custom field. This includes adding, deleting and changing values.
index 3c27709..da656c9 100644 (file)
@@ -1,29 +1,27 @@
-UPGRADING FROM 3.6.X and earlier - Changes:
+=head1 UPGRADING FROM 3.6.0 AND EARLIER
 
-As there are a large number of code changes, it is highly recommended
-that you install RT into a fresh directory, and then reinstall your
-customizations.
+As there are a large number of code changes, it is highly recommended that you
+install RT into a fresh directory, and then reinstall your customizations.
 
-The database schema has changed significantly for mysql 4.1 and above;
-please read UPGRADING.mysql for more details.
+The database schema has changed significantly for mysql 4.1 and above; please
+read UPGRADING.mysql for more details.
 
-The configuration format has been made stricter. All options MUST be set
-using the Set function; the historical "@XXX = (...) unless @XXX;" is no
-longer allowed.
+The configuration format has been made stricter. All options MUST be set using
+the Set function; the historical "@XXX = (...) unless @XXX;" is no longer
+allowed.
 
 The RTx::Shredder extension has been integrated into core, and several
 features have been added, so you MUST uninstall it before upgrading.
 
-A new interface for making links in text clickable, and doing other
-arbitrary text replacements, has been integrated into RT.  You can read
-more in `perldoc docs/extending/clickable_links.pod`.
+A new interface for making links in text clickable, and doing other arbitrary
+text replacements, has been integrated into RT.  You can read more in `perldoc
+docs/extending/clickable_links.pod`.
 
-A new feature has been added that allows users to forward
-messages. There is a new option in the config ($ForwardFromUser), new
-rights, and a new template.
+A new feature has been added that allows users to forward messages. There is a
+new option in the config ($ForwardFromUser), new rights, and a new template.
 
-New global templates have been added with "Error: " prefixed to the name
-to make it possible to configure error messages sent to users.
+New global templates have been added with "Error: " prefixed to the name to
+make it possible to configure error messages sent to users.
 
 You can read about the new GnuPG integration in `perldoc
 lib/RT/Crypt/GnuPG.pm`.
@@ -31,19 +29,19 @@ lib/RT/Crypt/GnuPG.pm`.
 New scrip conditions 'On Close' and 'On Reopen' have been added.
 
 
-UPGRADING FROM 3.5.7 and earlier - Changes:
+=head1 UPGRADING FROM 3.5.7 AND EARLIER
 
 Scrips are now prepared and committed in order alphanumerically by
-description.  This means that you can prepend a number (00, 07, 15, 24)
-to the beginning of each scrip's description, and they will run in that
-order.  Depending on your database, the old ordering may have been by
-scrip id number -- if that is the case, simply prepend the scrip id
-number to the beginning of its description.
+description.  This means that you can prepend a number (00, 07, 15, 24) to the
+beginning of each scrip's description, and they will run in that order.
+Depending on your database, the old ordering may have been by scrip id number
+-- if that is the case, simply prepend the scrip id number to the beginning of
+its description.
 
 
-UPGRADING FROM 3.5.1 and earlier - Changes:
+=head1 UPGRADING FROM 3.5.1 AND EARLIER
 
 The default for $RedistributeAutoGeneratedMessages has changed to
 'privileged', to make out-of-the-box installations more resistant to
-mail loops. If you rely on the old default of redistributing to all
-watchers, you'll need to set it explicitly now.
+mail loops. If you rely on the old default of redistributing to all watchers,
+you'll need to set it explicitly now.
index cb53030..cfe01df 100644 (file)
-UPGRADING FROM 3.8.8 and earlier - Changes:
+=head1 UPGRADING FROM 3.8.8 AND EARLIER
 
-Previous versions of RT used a password hashing scheme which was too
-easy to reverse, which could allow attackers with read access to the RT
-database to possibly compromise users' passwords.  Even if RT does no
-password authentication itself, it may still store these weak password
-hashes -- using ExternalAuth does not guarantee that you are not
-vulnerable!  To upgrade stored passwords to a stronger hash, run:
+Previous versions of RT used a password hashing scheme which was too easy to
+reverse, which could allow attackers with read access to the RT database to
+possibly compromise users' passwords.  Even if RT does no password
+authentication itself, it may still store these weak password hashes -- using
+ExternalAuth does not guarantee that you are not vulnerable!  To upgrade
+stored passwords to a stronger hash, run:
 
     perl etc/upgrade/vulnerable-passwords
 
-We have also proved that it's possible to delete a notable set of
-records from Transactions table without losing functionality. To delete
-these records, run the following script:
+We have also proved that it's possible to delete a notable set of records from
+Transactions table without losing functionality. To delete these records, run
+the following script:
 
     perl -I /opt/rt4/local/lib -I /opt/rt4/lib etc/upgrade/shrink_transactions_table.pl
 
-If you chose not to run the shrink_cgm_table.pl script when you upgraded
-to 3.8, you should read more about it below and run it at this point.
+If you chose not to run the shrink_cgm_table.pl script when you upgraded to
+3.8, you should read more about it below and run it at this point.
 
-The default for $MessageBoxWrap is now SOFT and $MessageBoxWidth is now
-unset by default.  This means the message box will expand to fill all
-the available width.  $MessageBoxWrap is also overridable by the user
-now.  These changes accommodate the new default two column layout for
-ticket create and update pages.  You may turn this layout off by setting
-$UseSideBySideLayout to 0.  To retain the original behavior, set
-$MessageBoxWrap to HARD and $MessageBoxWidth to 72.
+The default for $MessageBoxWrap is now SOFT and $MessageBoxWidth is now unset
+by default.  This means the message box will expand to fill all the available
+width.  $MessageBoxWrap is also overridable by the user now.  These changes
+accommodate the new default two column layout for ticket create and update
+pages.  You may turn this layout off by setting $UseSideBySideLayout to 0.  To
+retain the original behavior, set $MessageBoxWrap to HARD and $MessageBoxWidth
+to 72.
 
 
-UPGRADING FROM 3.8.7 and earlier - Changes:
+=head1 UPGRADING FROM 3.8.7 AND EARLIER
 
-RT's ChartFont option has been changed from a string to a hash which
-lets you specify per-language fonts. RT now comes with a better default
-font for charts, too.  You should either update your 'ChartFont' option
-to match the new format, or consider trying the new default.
+RT's ChartFont option has been changed from a string to a hash which lets you
+specify per-language fonts. RT now comes with a better default font for
+charts, too.  You should either update your 'ChartFont' option to match the
+new format, or consider trying the new default.
 
-RT now gives you more precise control over the order in which custom
-fields are displayed.  This change requires some small changes to your
-currently saved custom field orders.  RT will automatically clean up
-your existing custom fields when you run the standard database upgrade
-steps.  After that cleanup, you should make sure that custom fields are
-ordered in a way that you and your users find pleasing.
+RT now gives you more precise control over the order in which custom fields
+are displayed.  This change requires some small changes to your currently
+saved custom field orders.  RT will automatically clean up your existing
+custom fields when you run the standard database upgrade steps.  After that
+cleanup, you should make sure that custom fields are ordered in a way that you
+and your users find pleasing.
 
 
-UPGRADING FROM 3.8.6 and earlier - Changes:
+=head1 UPGRADING FROM 3.8.6 AND EARLIER
 
-For MySQL and Oracle users:
-If you upgraded from a version of RT earlier than 3.7.81, you should
-already have a CachedGroupMembers3 index on your CachedGroupMembers
-table.  If you did a clean install of RT somewhere in the 3.8 release
-series, you most likely don't have this index.  You can add it manually
-with:
+For MySQL and Oracle users: if you upgraded from a version of RT earlier than
+3.7.81, you should already have a CachedGroupMembers3 index on your
+CachedGroupMembers table.  If you did a clean install of RT somewhere in the
+3.8 release series, you most likely don't have this index.  You can add it
+manually with:
 
   CREATE INDEX CachedGroupMembers3 on CachedGroupMembers (MemberId, ImmediateParentId);
 
 
-UPGRADING FROM 3.8.5 and earlier - Changes:
+=head1 UPGRADING FROM 3.8.5 AND EARLIER
 
 You can now forward an entire Ticket history (in addition to specific
-transactions) but this requires a new Template called "Forward Ticket".
-This template will be added as part of the standard database upgrade
-step.
+transactions) but this requires a new Template called "Forward Ticket".  This
+template will be added as part of the standard database upgrade step.
 
-Custom fields with categories can optionally be split out into
-hierarchical custom fields.  If you wish to convert your old
-category-based custom fields, run:
+Custom fields with categories can optionally be split out into hierarchical
+custom fields.  If you wish to convert your old category-based custom fields,
+run:
 
     perl etc/upgrade/split-out-cf-categories
 
-It will prompt you for each custom field with categories that it finds,
-and the name of the custom field to create to store the categories.
+It will prompt you for each custom field with categories that it finds, and
+the name of the custom field to create to store the categories.
 
-If you were using the LocalizedDateTime RT::Date formatter from custom
-code, and passing a DateFormat or TimeFormat argument, you need to
-switch from the strftime methods to the cldr methods; that is,
+If you were using the LocalizedDateTime RT::Date formatter from custom code,
+and passing a DateFormat or TimeFormat argument, you need to switch from the
+strftime methods to the cldr methods; that is,
 'full_date_format' becomes 'date_format_full'.
 
 You may also have done this from your RT_SiteConfig.pm, using:
+
     Set($DateTimeFormat, {
         Format => 'LocalizedDateTime',
         DateFormat => 'medium_date_format',
     );
+
 Which would need to be changed to:
+
     Set($DateTimeFormat, {
         Format => 'LocalizedDateTime',
         DateFormat => 'date_format_medium',
     );
 
 
-UPGRADING FROM 3.8.3 and earlier - Changes:
+=head1 UPGRADING FROM 3.8.3 AND EARLIER
 
 Arguments to the NotifyGroup Scrip Action will be updated as part of the
 standard database upgrade process.
 
 
-UPGRADING FROM 3.8.2 and earlier - Changes:
+=head1 UPGRADING FROM 3.8.2 AND EARLIER
 
 A new scrip condition, 'On Reject', has been added.
 
 
-UPGRADING FROM 3.8.1 and earlier - Changes:
+=head1 UPGRADING FROM 3.8.1 AND EARLIER
 
-When using Oracle, $DatabaseName is now used as SID, so RT can connect
-without environment variables or tnsnames.ora file. Because of this
-change, your RT instance may loose its ability to connect to your DB; to
-resolve this, you will need to update RT's configuration and restart
-your web server.  Example configuration:
+When using Oracle, $DatabaseName is now used as SID, so RT can connect without
+environment variables or tnsnames.ora file. Because of this change, your RT
+instance may loose its ability to connect to your DB; to resolve this, you
+will need to update RT's configuration and restart your web server.  Example
+configuration:
 
     Set($DatabaseType, 'Oracle');
     Set($DatabaseHost, '192.168.0.1');
@@ -121,72 +122,70 @@ If you want a user to be able to access the Approvals tools (a.k.a.  the
 Approvals tab), you must grant that user the "ShowApprovalsTab" right.
 
 
-UPGRADING FROM 3.8.0 and earlier - Changes:
+=head1 UPGRADING FROM 3.8.0 AND EARLIER
 
-The TicketSQL syntax for bookmarked tickets has been changed.
-Specifically, the new phrasing is "id = '__Bookmarked__'", rather than
-the old "__Bookmarks__".  The old form will remain, for backwards
-compatibility.  The standard database upgrade process will only
-automatically change the global 'Bookmarked Tickets' search
+The TicketSQL syntax for bookmarked tickets has been changed.  Specifically,
+the new phrasing is "id = '__Bookmarked__'", rather than the old
+"__Bookmarks__".  The old form will remain, for backwards compatibility.  The
+standard database upgrade process will only automatically change the
+global 'Bookmarked Tickets' search
 
 
-UPGRADING FROM 3.7.85 and earlier - Changes:
+=head1 UPGRADING FROM 3.7.85 AND EARLIER
 
-We have proved that it is possible to delete a large set of records from
-the CachedGroupMembers table without losing functionality; in fact,
-failing to do so may result in occasional problems where RT miscounts
-users, particularly in the chart functionality.  To delete these records
-run the following script:
+We have proved that it is possible to delete a large set of records from the
+CachedGroupMembers table without losing functionality; in fact, failing to do
+so may result in occasional problems where RT miscounts users, particularly in
+the chart functionality.  To delete these records run the following script:
 
     perl -I /opt/rt4/local/lib -I /opt/rt4/lib etc/upgrade/shrink_cgm_table.pl
 
-After you run this, you will have significantly reduced the number of
-records in your CachedGroupMembers table, and may need to tell your
-database to refresh indexes/statistics.  Please consult your DBA for
-specific instructions for your database.
+After you run this, you will have significantly reduced the number of records
+in your CachedGroupMembers table, and may need to tell your database to
+refresh indexes/statistics.  Please consult your DBA for specific instructions
+for your database.
 
 
-UPGRADING FROM 3.7.81 and earlier - Changes:
+=head1 UPGRADING FROM 3.7.81 AND EARLIER
 
-RT::Extension::BrandedQueues has been integrated into core, and the
-handling of subject tags has changed as a consequence.  You will need to
-modify any of your email templates which use the $rtname variable, in
-order to make them respect the per-queue subject tags. To edit your
-templates, log into RT as your administrative user, then click:
+RT::Extension::BrandedQueues has been integrated into core, and the handling
+of subject tags has changed as a consequence.  You will need to modify any of
+your email templates which use the $rtname variable, in order to make them
+respect the per-queue subject tags. To edit your templates, log into RT as
+your administrative user, then click:
 
     Configuration -> Global -> Templates -> Select -> <Some template name>
 
-The only template which ships with RT which needs updating is the
-"Autoreply" template, which includes this line:
+The only template which ships with RT which needs updating is the "Autoreply"
+template, which includes this line:
 
-    "There is no need to reply to this message right now.  Your ticket
-    has been assigned an ID of [{$rtname} #{$Ticket->id()}]."
+    "There is no need to reply to this message right now.  Your ticket has
+    been assigned an ID of [{$rtname} #{$Ticket->id()}]."
 
 Change this line to read:
 
-    "There is no need to reply to this message right now.  Your ticket
-    has been assigned an ID of { $Ticket->SubjectTag }."
+    "There is no need to reply to this message right now.  Your ticket has
+    been assigned an ID of { $Ticket->SubjectTag }."
 
-If you were previously using RT::Extension::BrandedQueues, you MUST
-uninstall it before upgrading. In addition, you must run the
+If you were previously using RT::Extension::BrandedQueues, you MUST uninstall
+it before upgrading. In addition, you must run the
 'etc/upgrade/3.8-branded-queues-extension' perl script.  This will
 convert the extension's configuration into the new format.  Finally, in
 templates where you were using the Tag method ($Ticket->QueueObj->Tag),
 you will need to replace it with $Ticket->SubjectTag
 
-RT::Action::LinearEscalate extension has been integrated into core,
-so you MUST uninstall it before upgrading.
+RT::Action::LinearEscalate extension has been integrated into core, so you
+MUST uninstall it before upgrading.
 
-RT::Extension::iCal has been integrated into core, so you MUST uninstall
-it before upgrading. In addition, you must run etc/upgrade/3.8-ical-extension
+RT::Extension::iCal has been integrated into core, so you MUST uninstall it
+before upgrading. In addition, you must run etc/upgrade/3.8-ical-extension
 script to convert old data.
 
 
-UPGRADING FROM 3.7.80 and earlier - Changes:
+=head1 UPGRADING FROM 3.7.80 AND EARLIER
 
-Added indexes to CachedGroupMembers for MySQL and Oracle.
-If you have previously installed RTx-Shredder, you may already
-have these indexes.  You can see the indexes by looking at
-etc/upgrade/3.7.81/schema.*
+Added indexes to CachedGroupMembers for MySQL and Oracle.  If you have
+previously installed RTx-Shredder, you may already have these indexes.  You
+can see the indexes by looking at etc/upgrade/3.7.81/schema.*
 
 These indexes may take a very long time to create.
index 4b64d2e..ad8d87b 100644 (file)
@@ -1,87 +1,99 @@
-Common Issues
+=head1 UPGRADING FROM BEFORE 4.0.0
 
-RT now defaults to a database name of rt4 and an installation root of /opt/rt4.
+=head2 Common issues
 
-If you are upgrading, you will likely want to specify that your database
-is still named rt3 (or import a backup of your database as rt4 so that
-you can feel more confident making the upgrade).
+RT now defaults to a database name of rt4 and an installation root of
+/opt/rt4.
 
-You really shouldn't install RT4 into your RT3 source tree (/opt/rt3)
-and instead should be using make install to set up a clean environment.
-This will allow you to evaluate your local modifications and configuration
-changes as you migrate to 4.0.
+If you are upgrading, you will likely want to specify that your database is
+still named rt3 (or import a backup of your database as rt4 so that you can
+feel more confident making the upgrade).
+
+You really shouldn't install RT4 into your RT3 source tree (/opt/rt3) and
+instead should be using make install to set up a clean environment.  This will
+allow you to evaluate your local modifications and configuration changes as
+you migrate to 4.0.
 
 If you choose to force RT to install into /opt/rt3, or another existing RT 3.x
 install location, you will encounter issues because we removed the _Overlay
-files (such as Ticket_Overlay.pm) and relocated other files.  You will
-need to manually remove these files after the upgrade or RT will fail.
-After making a complete backup of your /opt/rt3 install, you might use a
-command like the following to remove the _Overlay files:
+files (such as Ticket_Overlay.pm) and relocated other files.  You will need to
+manually remove these files after the upgrade or RT will fail.  After making a
+complete backup of your /opt/rt3 install, you might use a command like the
+following to remove the _Overlay files:
 
     find /opt/rt3/lib/ -type f -name '*_Overlay*' -delete
 
 RT has also changed how web deployment works; you will need to review
-docs/web_deployment.pod for current instructions.  The old
-`fastcgi_server`, `webmux.pl`, and `mason_handler.*` files will not
-work with RT 4.0, and should be removed to reduce confusion.
+docs/web_deployment.pod for current instructions.  The old `fastcgi_server`,
+`webmux.pl`, and `mason_handler.*` files will not work with RT 4.0, and should
+be removed to reduce confusion.
+
+
+=head2 RT_SiteConfig.pm
+
+You will need to carefully review your local settings when moving from 3.8 to
+4.0.
 
-*******
-RT_SiteConfig.pm
+If you were adding your own custom statuses in earlier versions of RT, using
+ActiveStatus or InactiveStatus you will need to port these to use the new
+Lifecycles functionality.  You can read more about it in RT_Config.pm.  In
+most cases, you can do this by extending the default active and inactive
+lists.
 
-You will need to carefully review your local settings when moving from
-3.8 to 4.0.
 
-If you were adding your own custom statuses in earlier versions of RT,
-using ActiveStatus or InactiveStatus you will need to port these to use
-the new Lifecycles functionality.  You can read more about it in
-RT_Config.pm.  In most cases, you can do this by extending the default
-active and inactive lists.
+=head2 Upgrading sessions on MySQL
 
-*******
-Upgrading sessions on MySQL
+In 4.0.0rc2, RT began shipping an updated schema for the sesions table that
+specificies a character set as well as making the table InnoDB.  As part of
+the upgrade process, your sessions table will be dropped and recreated with
+the new schema.
 
-In 4.0.0rc2, RT began shipping an updated schema for the sesions table
-that specificies a character set as well as making the table InnoDB.  As
-part of the upgrade process, your sessions table will be dropped and
-recreated with the new schema.
 
-*******
-UPGRADING FROM RT 3.8.x and RTFM 2.1 or greater
+=head2 Upgrading from installs with RTFM
 
-RT4 now includes an Articles functionality, merged from RTFM.
-You should not install and enable the RT::FM plugin separately on RT 4.
-If you have existing data in RTFM, you can use the etc/upgrade/upgrade-articles
-script to upgrade that data.
+RT4 now includes an Articles functionality, merged from RTFM.  You should not
+install and enable the RT::FM plugin separately on RT 4.  If you have existing
+data in RTFM, you can use the etc/upgrade/upgrade-articles script to upgrade
+that data.
 
-When running normal upgrade scripts, RT will warn if it finds existing
-RTFM tables that contain data and point you to the upgrade-articles script.
+When running normal upgrade scripts, RT will warn if it finds existing RTFM
+tables that contain data and point you to the upgrade-articles script.
 
-This script should be run from your RT tarball.  It will immediately
-begin populating your new RT4 tables with data from RTFM.  If you have
-browsed in the RT4 UI and created new classes and articles, this script
-will fail spectacularly.  Do *not* run this except on a fresh upgrade of
-RT.
+This script should be run from your RT tarball.  It will immediately begin
+populating your new RT4 tables with data from RTFM.  If you have browsed in
+the RT4 UI and created new classes and articles, this script will fail
+spectacularly.  Do *not* run this except on a fresh upgrade of RT.
 
 You can run this as
 
   etc/upgrade/upgrade-articles
 
-It will ouput a lot of data about what it is changing.  You should
-review this for errors.
+It will ouput a lot of data about what it is changing.  You should review this
+for errors.
 
-If you are running RTFM 2.0 with a release of RT, there isn't currently an upgrade
-script that can port RTFM's internal CustomField and Transaction data to RT4.
+If you are running RTFM 2.0 with a release of RT, there isn't currently an
+upgrade script that can port RTFM's internal CustomField and Transaction data
+to RT4.
 
 You must also remove RT::FM from your @Plugins line in RT_SiteConfig.pm.
 
-*******
-The deprecated classes RT::Action::Generic, RT::Condition::Generic and RT::Search::Generic
-have been removed, but you shouldn't have been using them anyway. You should have been using
-RT::Action, RT::Condition and RT::Search, respectively.
 
-* The "Rights Delegation" and "Personal Groups" features have been removed.
+=head2 Removals and updates
+
+The deprecated classes RT::Action::Generic, RT::Condition::Generic and
+RT::Search::Generic have been removed, but you shouldn't have been using them
+anyway. You should have been using RT::Action, RT::Condition and RT::Search,
+respectively.
+
+=over
+
+=item *
+
+The "Rights Delegation" and "Personal Groups" features have been removed.
 
-* Replace the following code in templates:
+=item *
+
+Replace the following code in templates:
 
     [{$Ticket->QueueObj->SubjectTag || $rtname} #{$Ticket->id}]
 
@@ -89,38 +101,45 @@ with
 
     { $Ticket->SubjectTag }
 
-* Unique names are now enforced for user defined groups.  New groups cannot be
-  created with a duplicate name and existing groups cannot be renamed to an
-  in-use name.  The admin interface will warn about existing groups with
-  duplicate names.  Although the groups will still function, some parts of the
-  interface (rights management, subgroup membership) may not work as expected
-  with duplicate names.  Running
+=item *
+
+Unique names are now enforced for user defined groups.  New groups cannot be
+created with a duplicate name and existing groups cannot be renamed to an
+in-use name.  The admin interface will warn about existing groups with
+duplicate names.  Although the groups will still function, some parts of the
+interface (rights management, subgroup membership) may not work as expected
+with duplicate names.  Running
 
     /opt/rt4/sbin/rt-validator --check
 
-  will report duplicate group names, and running it with --resolve will fix
-  duplicates by appending the group id to the name.
+will report duplicate group names, and running it with --resolve will fix
+duplicates by appending the group id to the name.
+
+Nota Bene: As a result of differing indexes in the schema files, Postgres and
+SQLite RT databases have enforced group name uniqueness for many years at the
+database level.
+
+=back
 
-  Nota Bene: As a result of differing indexes in the schema files, Postgres and
-  SQLite RT databases have enforced group name uniqueness for many years at the
-  database level.
 
-*******
 
-UPGRADING FROM 4.0.5 and earlier - Changes:
+=head1 UPGRADING FROM 4.0.5 AND EARLIER
+
+=head2 Schema updates
 
 The fix for an attribute truncation bug on MySQL requires a small ALTER TABLE.
 Be sure you run `make upgrade-database` to apply this change automatically.
 The bug primarily manifested when uploading large logos in the theme editor on
-MySQL.  Refer to etc/upgrade/4.0.6/schema.mysql for the actual ALTER TABLE that
-will be run.
+MySQL.  Refer to etc/upgrade/4.0.6/schema.mysql for the actual ALTER TABLE
+that will be run.
+
+
+=head2 Query Builder
 
-*******
 The web-based query builder now uses Queue limits to restrict the set of
 displayed statuses and owners.  As part of this change, the %cfqueues
-parameter was renamed to %Queues; if you have local modifications to any
-of the following Mason templates, this feature will not function
-correctly:
+parameter was renamed to %Queues; if you have local modifications to any of
+the following Mason templates, this feature will not function correctly:
 
     share/html/Elements/SelectOwner
     share/html/Elements/SelectStatus
index 77a6b38..a62dee7 100644 (file)
-If you did not start by reading the README file, please start there;
-these steps do not list the full upgrading process, merely a part which
-is sometimes necessary.
+If you did not start by reading the README file, please start there; these
+steps do not list the full upgrading process, merely a part which is sometimes
+necessary.
 
 This file applies if either:
 
- 1) You are upgrading RT from a version prior to 3.8.0, on any version
-    of MySQL
-............. OR .............
- 2) You are migrating from MySQL 4.0 to MySQL 4.1 or above
+=over
+
+=item 1.
+
+You are upgrading RT from a version prior to 3.8.0, on any version
+of MySQL
+
+=item 2.
+
+You are migrating from MySQL 4.0 to MySQL 4.1 or above
+
+=back
 
 If neither of the above cases apply, your should upgrade as per the
 instructions in the README.
 
-These changes are necessary because MySQL 4.1 and greater changed some
-aspects of character set handling that may result in RT failures; this
-will manifest as multiple login requests, corrupted binary attachments,
-and corrupted image custom fields, among others.  In order to resolve
-this issue, the upgrade process will need to modify the schema.
+These changes are necessary because MySQL 4.1 and greater changed some aspects
+of character set handling that may result in RT failures; this will manifest
+as multiple login requests, corrupted binary attachments, and corrupted image
+custom fields, among others.  In order to resolve this issue, the upgrade
+process will need to modify the schema.
+
+=over
+
+=item 1.
+
+If you are moving the database and/or upgrading MySQL
+
+=over
+
+=item 1a.
+
+Dump the database; with MySQL 4.1 and greater be sure to pass the mysqldump
+command the --default-character-set=binary option.  This is necessary because
+the data was originally encoded in Latin1.
+
+=item 1b.
+
+Configure the new MySQL to use Latin1 as the default character set everywhere,
+not UTF-8.  This is necessary so the import in the next step assumes the data
+is Latin1.
+
+=item 1c.
+
+Import the dump made in step 1a into the new MySQL server, using the
+--default-character-set=binary option on restore.  This will ensure that the
+data is imported as bytes, which will be interpreted as Latin1 thanks to step
+1b above.
+
+=item 1d.
+
+Test that your RT works as expected on this new database.
+
+=back
+
+=item 2.
+
+Backup RT's database using --default-character-set=binary  Furthermore, test
+that you can restore from this backup.
+
+=item 3.
+
+Follow instructions in the README file to step 6b.
+
+=item 4.
+
+Apply changes described in the README's step 6b, but only up to version
+3.7.87.
+
+=item 5.
+
+Apply the RT 3.8 schema upgrades. Included in RT is the script
+etc/upgrade/upgrade-mysql-schema.pl that will generate the appropriate SQL
+queries:
+
+    perl etc/upgrade/upgrade-mysql-schema.pl db user pass > queries.sql
+
+If your mysql database is on a remote host, you can run the script like this
+instead:
+
+    perl etc/upgrade/upgrade-mysql-schema.pl db:host user pass > queries.sql
+
+=item 6.
+
+Check the sanity of the SQL queries in the queries.sql file yourself, or
+consult with your DBA.
+
+=item 7.
+
+Apply the queries. Note that this step can take a while; it may also require
+additional space on your hard drive comparable with size of your tables.
 
- 1) If you are moving the database and/or upgrading MySQL
-   1a) Dump the database; with MySQL 4.1 and greater be sure to pass
-       the mysqldump command the --default-character-set=binary option.
-       This is necessary because the data was originally encoded in
-       Latin1.
+    mysql -u root -p rt3 < queries.sql
 
-   1b) Configure the new MySQL to use Latin1 as the default character
-       set everywhere, not UTF-8.  This is necessary so the import in
-       the next step assumes the data is Latin1.
+NOTE that 'rt3' is the default name of the RT database, change it in the
+command above if your database is named differently.
 
-   1c) Import the dump made in step 1a into the new MySQL server, using
-       the --default-character-set=binary option on restore.  This will
-       ensure that the data is imported as bytes, which will be
-       interpreted as Latin1 thanks to step 1b above.
+This step should not produce any errors or warnings. If you see any, restore
+your database from the backup you made at step 1, and send a report to the
+rt-users@lists.bestpractical.com mailing list.
 
-   1d) Test that your RT works as expected on this new database.
+=item 8.
 
- 2) Backup RT's database using --default-character-set=binary
-    Furthermore, test that you can restore from this backup.
+Re-run the `make upgrade-database` command from step 6b of the README,
+applying the rest of the upgrades, starting with 3.7.87, and follow the
+README's remaining steps.
 
- 3) Follow instructions in the README file to step 6b.
+=item 9.
 
- 4) Apply changes described in the README's step 6b, but only up to
-    version 3.7.87.
+Test everything. The most important parts you have to test:
 
- 5) Apply the RT 3.8 schema upgrades. Included in RT is the script
-    etc/upgrade/upgrade-mysql-schema.pl that will generate the
-    appropriate SQL queries:
+=over
 
-        perl etc/upgrade/upgrade-mysql-schema.pl db user pass > queries.sql
+=item *
 
-    If your mysql database is on a remote host, you can run the script
-    like this instead:
+binary attachments, like docs, PDFs, and images
 
-        perl etc/upgrade/upgrade-mysql-schema.pl db:host user pass > queries.sql
+=item *
 
- 6) Check the sanity of the SQL queries in the queries.sql file
-    yourself, or consult with your DBA.
+binary custom fields
 
- 7) Apply the queries. Note that this step can take a while; it may also
-    require additional space on your hard drive comparable with size of
-    your tables.
+=item *
 
-        mysql -u root -p rt3 < queries.sql
+everything that may contain characters other than ASCII
 
-    NOTE that 'rt3' is the default name of the RT database, change it in
-    the command above if your database is named differently.
+=back
 
-    This step should not produce any errors or warnings. If you see any,
-    restore your database from the backup you made at step 1, and send a
-    report to the rt-users@lists.bestpractical.com mailing list.
 
- 8) Re-run the `make upgrade-database` command from step 6b of the
-    README, applying the rest of the upgrades, starting with 3.7.87, and
-    follow the README's remaining steps.
+=item 10.
 
- 9) Test everything. The most important parts you have to test:
-     * binary attachments, like docs, PDFs, and images
-     * binary custom fields
-     * everything that may contain characters other than ASCII
+If you were upgrading from MySQL 4.0, you may now, if you wish, reconfigure
+your newer MySQL instance to use UTF-8 as the default character set, as step 7
+above adjusted the character sets on all existing tables to contain UTF-8
+encoded data, rather than Latin1.
 
-10) If you were upgrading from MySQL 4.0, you may now, if you wish,
-    reconfigure your newer MySQL instance to use UTF-8 as the default
-    character set, as step 7 above adjusted the character sets on all
-    existing tables to contain UTF-8 encoded data, rather than Latin1.
+=back
index 1691820..5edb54c 100644 (file)
@@ -348,7 +348,8 @@ Set($StoreLoops, undef);
 =item C<$MaxAttachmentSize>
 
 C<$MaxAttachmentSize> sets the maximum size (in bytes) of attachments
-stored in the database.
+stored in the database.  This setting is irrelevant unless one of
+$TruncateLongAttachments or $DropLongAttachments (below) are set.
 
 =cut
 
@@ -1766,12 +1767,12 @@ Set($ForceApprovalsView, 0);
 
 =head1 Extra security
 
-=over 4
-
 This is a list of extra security measures to enable that help keep your RT
 safe.  If you don't know what these mean, you should almost certainly leave the
 defaults alone.
 
+=over 4
+
 =item C<$DisallowExecuteCode>
 
 If set to a true value, the C<ExecuteCode> right will be removed from
@@ -1816,7 +1817,7 @@ backwards compatability.
 
 Set($RestrictLoginReferrer, 0);
 
-=item C<$ReferrerWhitelist>
+=item C<@ReferrerWhitelist>
 
 This is a list of hostname:port combinations that RT will treat as being
 part of RT's domain. This is particularly useful if you access RT as
@@ -2597,7 +2598,7 @@ Set(%AdminSearchResultFormat,
     Queues =>
         q{'<a href="__WebPath__/Admin/Queues/Modify.html?id=__id__">__id__</a>/TITLE:#'}
         .q{,'<a href="__WebPath__/Admin/Queues/Modify.html?id=__id__">__Name__</a>/TITLE:Name'}
-        .q{,__Description__,__Address__,__Priority__,__DefaultDueIn__,'__Disabled__,__Lifecycle__},
+        .q{,__Description__,__Address__,__Priority__,__DefaultDueIn__,__Disabled__,__Lifecycle__},
 
     Groups =>
         q{'<a href="__WebPath__/Admin/Groups/Modify.html?id=__id__">__id__</a>/TITLE:#'}
@@ -2749,6 +2750,8 @@ Set($LinkTransactionsRun1Scrip, 0);
 This option has been deprecated.  You can configure this site-wide
 with L</Lifecycles> (see L</Labeling and defining actions>).
 
+=back
+
 =cut
 
 1;
index 38d5514..14ecba4 100644 (file)
@@ -45,7 +45,7 @@
             if ( my $struct = eval { Storable::thaw( $argument ) } ) {
                 $new = $converter->( $struct );
             } else {
-                $new = join /, /, grep length, split /[^0-9]+/, $argument;
+                $new = join ", ", grep length, split /[^0-9]+/, $argument;
             }
             next if $new eq $argument;
 
index 4ae1a8b..2a7a2e3 100755 (executable)
@@ -99,47 +99,31 @@ activated in the config.
 sub Commit {
     my $self = shift;
 
-    $self->DeferDigestRecipients() if RT->Config->Get('RecordOutgoingEmail');
+    return abs $self->SendMessage( $self->TemplateObj->MIMEObj )
+        unless RT->Config->Get('RecordOutgoingEmail');
+
+    $self->DeferDigestRecipients();
     my $message = $self->TemplateObj->MIMEObj;
 
     my $orig_message;
-    if (   RT->Config->Get('RecordOutgoingEmail')
-        && RT->Config->Get('GnuPG')->{'Enable'} )
-    {
-
-        # it's hacky, but we should know if we're going to crypt things
-        my $attachment = $self->TransactionObj->Attachments->First;
-
-        my %crypt;
-        foreach my $argument (qw(Sign Encrypt)) {
-            if ( $attachment
-                && defined $attachment->GetHeader("X-RT-$argument") )
-            {
-                $crypt{$argument} = $attachment->GetHeader("X-RT-$argument");
-            } else {
-                $crypt{$argument} = $self->TicketObj->QueueObj->$argument();
-            }
-        }
-        if ( $crypt{'Sign'} || $crypt{'Encrypt'} ) {
-            $orig_message = $message->dup;
-        }
-    }
+    $orig_message = $message->dup if RT::Interface::Email::WillSignEncrypt(
+        Attachment => $self->TransactionObj->Attachments->First,
+        Ticket     => $self->TicketObj,
+    );
 
     my ($ret) = $self->SendMessage($message);
-    if ( $ret > 0 && RT->Config->Get('RecordOutgoingEmail') ) {
-        if ($orig_message) {
-            $message->attach(
-                Type        => 'application/x-rt-original-message',
-                Disposition => 'inline',
-                Data        => $orig_message->as_string,
-            );
-        }
-        $self->RecordOutgoingMailTransaction($message);
-        $self->RecordDeferredRecipients();
-    }
-
+    return abs( $ret ) if $ret <= 0;
 
-    return ( abs $ret );
+    if ($orig_message) {
+        $message->attach(
+            Type        => 'application/x-rt-original-message',
+            Disposition => 'inline',
+            Data        => $orig_message->as_string,
+        );
+    }
+    $self->RecordOutgoingMailTransaction($message);
+    $self->RecordDeferredRecipients();
+    return 1;
 }
 
 =head2 Prepare
index f364bc9..000a8dc 100644 (file)
@@ -80,10 +80,8 @@ sub Commit {
             }
 
         }
-        $obj->SetStatus(
-            Status => $obj->QueueObj->Lifecycle->DefaultStatus('approved') || 'open',
-            Force => 1,
-        );
+        $obj->SetStatus( Status => $obj->FirstActiveStatus, Force => 1 )
+            if $obj->FirstActiveStatus;
     }
 
     my $passed = !$top->HasUnresolvedDependencies( Type => 'approval' );
@@ -98,6 +96,11 @@ sub Commit {
     $top->Correspond( MIMEObj => $template->MIMEObj );
 
     if ($passed) {
+        my $new_status = $top->QueueObj->Lifecycle->DefaultStatus('approved') || 'open';
+        if ( $new_status ne $top->Status ) {
+            $top->SetStatus( $new_status );
+        }
+
         $self->RunScripAction('Notify Owner', 'Approval Ready for Owner',
                               TicketObj => $top);
     }
index 24b952a..678aa11 100644 (file)
@@ -102,7 +102,7 @@ sub Create {
         @_
     );
 
-    my $class = RT::Class->new($RT::SystemUser);
+    my $class = RT::Class->new( $self->CurrentUser );
     $class->Load( $args{'Class'} );
     unless ( $class->Id ) {
         return ( 0, $self->loc('Invalid Class') );
index fb17da3..f1d9a63 100755 (executable)
@@ -600,8 +600,8 @@ sub DelHeader {
 
     my $newheader = '';
     foreach my $line ($self->_SplitHeaders) {
-        next if $line =~ /^\Q$tag\E:\s+(.*)$/is;
-       $newheader .= "$line\n";
+        next if $line =~ /^\Q$tag\E:\s+/i;
+        $newheader .= "$line\n";
     }
     return $self->__Set( Field => 'Headers', Value => $newheader);
 }
@@ -617,9 +617,7 @@ sub AddHeader {
 
     my $newheader = $self->__Value( 'Headers' );
     while ( my ($tag, $value) = splice @_, 0, 2 ) {
-        $value = '' unless defined $value;
-        $value =~ s/\s+$//s;
-        $value =~ s/\r+\n/\n /g;
+        $value = $self->_CanonicalizeHeaderValue($value);
         $newheader .= "$tag: $value\n";
     }
     return $self->__Set( Field => 'Headers', Value => $newheader);
@@ -632,24 +630,39 @@ Replace or add a Header to the attachment's headers.
 =cut
 
 sub SetHeader {
-    my $self = shift;
-    my $tag = shift;
+    my $self  = shift;
+    my $tag   = shift;
+    my $value = $self->_CanonicalizeHeaderValue(shift);
 
+    my $replaced  = 0;
     my $newheader = '';
-    foreach my $line ($self->_SplitHeaders) {
-        if (defined $tag and $line =~ /^\Q$tag\E:\s+(.*)$/i) {
-           $newheader .= "$tag: $_[0]\n";
-           undef $tag;
+    foreach my $line ( $self->_SplitHeaders ) {
+        if ( $line =~ /^\Q$tag\E:\s+/i ) {
+            # replace first instance, skip all the rest
+            unless ($replaced) {
+                $newheader .= "$tag: $value\n";
+                $replaced = 1;
+            }
+        } else {
+            $newheader .= "$line\n";
         }
-       else {
-           $newheader .= "$line\n";
-       }
     }
 
-    $newheader .= "$tag: $_[0]\n" if defined $tag;
+    $newheader .= "$tag: $value\n" unless $replaced;
     $self->__Set( Field => 'Headers', Value => $newheader);
 }
 
+sub _CanonicalizeHeaderValue {
+    my $self  = shift;
+    my $value = shift;
+
+    $value = '' unless defined $value;
+    $value =~ s/\s+$//s;
+    $value =~ s/\r*\n/\n /g;
+
+    return $value;
+}
+
 =head2 SplitHeaders
 
 Returns an array of this attachment object's headers, with one header 
@@ -676,6 +689,12 @@ sub _SplitHeaders {
     my $self = shift;
     my $headers = (shift || $self->_Value('Headers'));
     my @headers;
+    # XXX TODO: splitting on \n\w is _wrong_ as it treats \n[ as a valid
+    # continuation, which it isn't.  The correct split pattern, per RFC 2822,
+    # is /\n(?=[^ \t]|\z)/.  That is, only "\n " or "\n\t" is a valid
+    # continuation.  Older values of X-RT-GnuPG-Status contain invalid
+    # continuations and rely on this bogus split pattern, however, so it is
+    # left as-is for now.
     for (split(/\n(?=\w|\z)/,$headers)) {
         push @headers, $_;
 
index c5fb12b..2330478 100644 (file)
@@ -900,6 +900,19 @@ sub FindProtectedParts {
             $RT::Logger->warning( "Entity of type ". $entity->effective_type ." has no body" );
             return ();
         }
+
+        # Deal with "partitioned" PGP mail, which (contrary to common
+        # sense) unnecessarily applies a base64 transfer encoding to PGP
+        # mail (whose content is already base64-encoded).
+        if ( $entity->bodyhandle->is_encoded and $entity->head->mime_encoding ) {
+            pipe( my ($read_decoded, $write_decoded) );
+            my $decoder = MIME::Decoder->new( $entity->head->mime_encoding );
+            if ($decoder) {
+                eval { $decoder->decode($io, $write_decoded) };
+                $io = $read_decoded;
+            }
+        }
+
         while ( defined($_ = $io->getline) ) {
             next unless /^-----BEGIN PGP (SIGNED )?MESSAGE-----/;
             my $type = $1? 'signed': 'encrypted';
@@ -1064,9 +1077,13 @@ sub VerifyDecrypt {
         }
         if ( $args{'SetStatus'} || $args{'AddStatus'} ) {
             my $method = $args{'AddStatus'} ? 'add' : 'set';
+            # Let the header be modified so continuations are handled
+            my $modify = $status_on->head->modify;
+            $status_on->head->modify(1);
             $status_on->head->$method(
                 'X-RT-GnuPG-Status' => $res[-1]->{'status'}
             );
+            $status_on->head->modify($modify);
         }
     }
     foreach my $item( grep $_->{'Type'} eq 'encrypted', @protected ) {
@@ -1083,9 +1100,13 @@ sub VerifyDecrypt {
         }
         if ( $args{'SetStatus'} || $args{'AddStatus'} ) {
             my $method = $args{'AddStatus'} ? 'add' : 'set';
+            # Let the header be modified so continuations are handled
+            my $modify = $status_on->head->modify;
+            $status_on->head->modify(1);
             $status_on->head->$method(
                 'X-RT-GnuPG-Status' => $res[-1]->{'status'}
             );
+            $status_on->head->modify($modify);
         }
     }
     return @res;
@@ -2107,7 +2128,9 @@ sub GetKeysInfo {
     eval {
         local $SIG{'CHLD'} = 'DEFAULT';
         my $method = $type eq 'private'? 'list_secret_keys': 'list_public_keys';
-        my $pid = safe_run_child { $gnupg->$method( handles => $handles, $email? (command_args => $email) : () ) };
+        my $pid = safe_run_child { $gnupg->$method( handles => $handles, $email
+                                                        ? (command_args => [ "--", $email])
+                                                        : () ) };
         close $handle{'stdin'};
         waitpid $pid, 0;
     };
@@ -2301,7 +2324,7 @@ sub DeleteKey {
         my $pid = safe_run_child { $gnupg->wrap_call(
             handles => $handles,
             commands => ['--delete-secret-and-public-key'],
-            command_args => [$key],
+            command_args => ["--", $key],
         ) };
         close $handle{'stdin'};
         while ( my $str = readline $handle{'status'} ) {
index 9fd946f..907ea77 100644 (file)
@@ -50,7 +50,7 @@ package RT;
 use warnings;
 use strict;
 
-our $VERSION = '4.0.7';
+our $VERSION = '4.0.8';
 
 
 
index 99d10e3..03c262b 100644 (file)
@@ -858,26 +858,28 @@ sub InsertData {
                 @queues = @{ delete $item->{'Queue'} };
             }
 
-            my ( $return, $msg ) = $new_entry->Create(%$item);
-            unless( $return ) {
-                $RT::Logger->error( $msg );
-                next;
-            }
-
             if ( $item->{'BasedOn'} ) {
-                my $basedon = RT::CustomField->new($RT::SystemUser);
-                my ($ok, $msg ) = $basedon->LoadByCols( Name => $item->{'BasedOn'},
-                                                        LookupType => $new_entry->LookupType );
-                if ($ok) {
-                    ($ok, $msg) = $new_entry->SetBasedOn( $basedon );
+                if ( $item->{'LookupType'} ) {
+                    my $basedon = RT::CustomField->new($RT::SystemUser);
+                    my ($ok, $msg ) = $basedon->LoadByCols( Name => $item->{'BasedOn'},
+                                                            LookupType => $item->{'LookupType'} );
                     if ($ok) {
-                        $RT::Logger->debug("Added BasedOn $item->{BasedOn}: $msg");
+                        $item->{'BasedOn'} = $basedon->Id;
                     } else {
-                        $RT::Logger->error("Failed to add basedOn $item->{BasedOn}: $msg");
+                        $RT::Logger->error("Unable to load $item->{BasedOn} as a $item->{LookupType} CF.  Skipping BasedOn: $msg");
+                        delete $item->{'BasedOn'};
                     }
                 } else {
-                    $RT::Logger->error("Unable to load $item->{BasedOn} as a $item->{LookupType} CF.  Skipping BasedOn");
+                    $RT::Logger->error("Unable to load CF $item->{BasedOn} because no LookupType was specified.  Skipping BasedOn");
+                    delete $item->{'BasedOn'};
                 }
+
+            } 
+
+            my ( $return, $msg ) = $new_entry->Create(%$item);
+            unless( $return ) {
+                $RT::Logger->error( $msg );
+                next;
             }
 
             foreach my $value ( @{$values} ) {
index 4c3ee99..dda6f70 100755 (executable)
@@ -318,6 +318,35 @@ header field then it's value is used
 
 =cut
 
+sub WillSignEncrypt {
+    my %args = @_;
+    my $attachment = delete $args{Attachment};
+    my $ticket     = delete $args{Ticket};
+
+    if ( not RT->Config->Get('GnuPG')->{'Enable'} ) {
+        $args{Sign} = $args{Encrypt} = 0;
+        return wantarray ? %args : 0;
+    }
+
+    for my $argument ( qw(Sign Encrypt) ) {
+        next if defined $args{ $argument };
+
+        if ( $attachment and defined $attachment->GetHeader("X-RT-$argument") ) {
+            $args{$argument} = $attachment->GetHeader("X-RT-$argument");
+        } elsif ( $ticket and $argument eq "Encrypt" ) {
+            $args{Encrypt} = $ticket->QueueObj->Encrypt();
+        } elsif ( $ticket and $argument eq "Sign" ) {
+            # Note that $queue->Sign is UI-only, and that all
+            # UI-generated messages explicitly set the X-RT-Crypt header
+            # to 0 or 1; thus this path is only taken for messages
+            # generated _not_ via the web UI.
+            $args{Sign} = $ticket->QueueObj->SignAuto();
+        }
+    }
+
+    return wantarray ? %args : ($args{Sign} || $args{Encrypt});
+}
+
 sub SendEmail {
     my (%args) = (
         Entity => undef,
@@ -366,23 +395,12 @@ sub SendEmail {
     }
 
     if ( RT->Config->Get('GnuPG')->{'Enable'} ) {
-        my %crypt;
-
-        my $attachment;
-        $attachment = $TransactionObj->Attachments->First
-            if $TransactionObj;
-
-        foreach my $argument ( qw(Sign Encrypt) ) {
-            next if defined $args{ $argument };
-
-            if ( $attachment && defined $attachment->GetHeader("X-RT-$argument") ) {
-                $crypt{$argument} = $attachment->GetHeader("X-RT-$argument");
-            } elsif ( $TicketObj ) {
-                $crypt{$argument} = $TicketObj->QueueObj->$argument();
-            }
-        }
-
-        my $res = SignEncrypt( %args, %crypt );
+        %args = WillSignEncrypt(
+            %args,
+            Attachment => $TransactionObj ? $TransactionObj->Attachments->First : undef,
+            Ticket     => $TicketObj,
+        );
+        my $res = SignEncrypt( %args );
         return $res unless $res > 0;
     }
 
index e508908..87a523d 100755 (executable)
@@ -77,8 +77,9 @@ sub GetCurrentUser {
 
     foreach my $p ( $args{'Message'}->parts_DFS ) {
         $p->head->delete($_) for qw(
-            X-RT-GnuPG-Status X-RT-Incoming-Encrypton
+            X-RT-GnuPG-Status X-RT-Incoming-Encryption
             X-RT-Incoming-Signature X-RT-Privacy
+            X-RT-Sign X-RT-Encrypt
         );
     }
 
index 1aae758..745a6f1 100644 (file)
@@ -304,12 +304,12 @@ sub HandleRequest {
             }
             # Specially handle /index.html so that we get a nicer URL
             elsif ( $m->request_comp->path eq '/index.html' ) {
-                my $next = SetNextPage(RT->Config->Get('WebURL'));
+                my $next = SetNextPage($ARGS);
                 $m->comp('/NoAuth/Login.html', next => $next, actions => [$msg]);
                 $m->abort;
             }
             else {
-                TangentForLogin(results => ($msg ? LoginError($msg) : undef));
+                TangentForLogin($ARGS, results => ($msg ? LoginError($msg) : undef));
             }
         }
     }
@@ -364,7 +364,7 @@ sub LoginError {
     return $key;
 }
 
-=head2 SetNextPage [PATH]
+=head2 SetNextPage ARGSRef [PATH]
 
 Intuits and stashes the next page in the sesssion hash.  If PATH is
 specified, uses that instead of the value of L<IntuitNextPage()>.  Returns
@@ -373,24 +373,68 @@ the hash value.
 =cut
 
 sub SetNextPage {
-    my $next = shift || IntuitNextPage();
+    my $ARGS = shift;
+    my $next = $_[0] ? $_[0] : IntuitNextPage();
     my $hash = Digest::MD5::md5_hex($next . $$ . rand(1024));
+    my $page = { url => $next };
+
+    # If an explicit URL was passed and we didn't IntuitNextPage, then
+    # IsPossibleCSRF below is almost certainly unrelated to the actual
+    # destination.  Currently explicit next pages aren't used in RT, but the
+    # API is available.
+    if (not $_[0] and RT->Config->Get("RestrictReferrer")) {
+        # This isn't really CSRF, but the CSRF heuristics are useful for catching
+        # requests which may have unintended side-effects.
+        my ($is_csrf, $msg, @loc) = IsPossibleCSRF($ARGS);
+        if ($is_csrf) {
+            RT->Logger->notice(
+                "Marking original destination as having side-effects before redirecting for login.\n"
+               ."Request: $next\n"
+               ."Reason: " . HTML::Mason::Commands::loc($msg, @loc)
+            );
+            $page->{'HasSideEffects'} = [$msg, @loc];
+        }
+    }
 
-    $HTML::Mason::Commands::session{'NextPage'}->{$hash} = $next;
+    $HTML::Mason::Commands::session{'NextPage'}->{$hash} = $page;
     $HTML::Mason::Commands::session{'i'}++;
     return $hash;
 }
 
+=head2 FetchNextPage HASHKEY
+
+Returns the stashed next page hashref for the given hash.
+
+=cut
+
+sub FetchNextPage {
+    my $hash = shift || "";
+    return $HTML::Mason::Commands::session{'NextPage'}->{$hash};
+}
+
+=head2 RemoveNextPage HASHKEY
+
+Removes the stashed next page for the given hash and returns it.
+
+=cut
+
+sub RemoveNextPage {
+    my $hash = shift || "";
+    return delete $HTML::Mason::Commands::session{'NextPage'}->{$hash};
+}
 
-=head2 TangentForLogin [HASH]
+=head2 TangentForLogin ARGSRef [HASH]
 
 Redirects to C</NoAuth/Login.html>, setting the value of L<IntuitNextPage> as
-the next page.  Optionally takes a hash which is dumped into query params.
+the next page.  Takes a hashref of request %ARGS as the first parameter.
+Optionally takes all other parameters as a hash which is dumped into query
+params.
 
 =cut
 
 sub TangentForLogin {
-    my $hash  = SetNextPage();
+    my $ARGS  = shift;
+    my $hash  = SetNextPage($ARGS);
     my %query = (@_, next => $hash);
     my $login = RT->Config->Get('WebURL') . 'NoAuth/Login.html?';
     $login .= $HTML::Mason::Commands::m->comp('/Elements/QueryString', %query);
@@ -405,8 +449,9 @@ calls L<TangentForLogin> with the appropriate results key.
 =cut
 
 sub TangentForLoginWithError {
-    my $key = LoginError(HTML::Mason::Commands::loc(@_));
-    TangentForLogin( results => $key );
+    my $ARGS = shift;
+    my $key  = LoginError(HTML::Mason::Commands::loc(@_));
+    TangentForLogin( $ARGS, results => $key );
 }
 
 =head2 IntuitNextPage
@@ -606,7 +651,8 @@ sub AttemptExternalAuth {
             $user =~ s/^\Q$NodeName\E\\//i;
         }
 
-        my $next = delete $HTML::Mason::Commands::session{'NextPage'}->{$ARGS->{'next'} || ''};
+        my $next = RemoveNextPage($ARGS->{'next'});
+           $next = $next->{'url'} if ref $next;
         InstantiateNewSession() unless _UserLoggedIn;
         $HTML::Mason::Commands::session{'CurrentUser'} = RT::CurrentUser->new();
         $HTML::Mason::Commands::session{'CurrentUser'}->$load_method($user);
@@ -645,7 +691,7 @@ sub AttemptExternalAuth {
                 delete $HTML::Mason::Commands::session{'CurrentUser'};
 
                 if (RT->Config->Get('WebFallbackToInternalAuth')) {
-                    TangentForLoginWithError('Cannot create user: [_1]', $msg);
+                    TangentForLoginWithError($ARGS, 'Cannot create user: [_1]', $msg);
                 } else {
                     $m->abort();
                 }
@@ -668,13 +714,13 @@ sub AttemptExternalAuth {
             $user = $orig_user;
 
             unless ( RT->Config->Get('WebFallbackToInternalAuth') ) {
-                TangentForLoginWithError('You are not an authorized user');
+                TangentForLoginWithError($ARGS, 'You are not an authorized user');
             }
         }
     } elsif ( RT->Config->Get('WebFallbackToInternalAuth') ) {
         unless ( defined $HTML::Mason::Commands::session{'CurrentUser'} ) {
             # XXX unreachable due to prior defaulting in HandleRequest (check c34d108)
-            TangentForLoginWithError('You are not an authorized user');
+            TangentForLoginWithError($ARGS, 'You are not an authorized user');
         }
     } else {
 
@@ -705,7 +751,8 @@ sub AttemptPasswordAuthentication {
 
         # It's important to nab the next page from the session before we blow
         # the session away
-        my $next = delete $HTML::Mason::Commands::session{'NextPage'}->{$ARGS->{'next'} || ''};
+        my $next = RemoveNextPage($ARGS->{'next'});
+           $next = $next->{'url'} if ref $next;
 
         InstantiateNewSession();
         $HTML::Mason::Commands::session{'CurrentUser'} = $user_obj;
@@ -1201,6 +1248,13 @@ our %is_whitelisted_component = (
     '/m/tickets/search'     => 1,
 );
 
+# Components which are blacklisted from automatic, argument-based whitelisting.
+# These pages are not idempotent when called with just an id.
+our %is_blacklisted_component = (
+    # Takes only id and toggles bookmark state
+    '/Helpers/Toggle/TicketBookmark' => 1,
+);
+
 sub IsCompCSRFWhitelisted {
     my $comp = shift;
     my $ARGS = shift;
@@ -1223,6 +1277,10 @@ sub IsCompCSRFWhitelisted {
         delete $args{pass};
     }
 
+    # Some pages aren't idempotent even with safe args like id; blacklist
+    # them from the automatic whitelisting below.
+    return 0 if $is_blacklisted_component{$comp};
+
     # Eliminate arguments that do not indicate an effectful request.
     # For example, "id" is acceptable because that is how RT retrieves a
     # record.
@@ -1419,6 +1477,30 @@ sub MaybeShowInterstitialCSRFPage {
     # Calls abort, never gets here
 }
 
+our @POTENTIAL_PAGE_ACTIONS = (
+    qr'/Ticket/Create.html' => "create a ticket",              # loc
+    qr'/Ticket/'            => "update a ticket",              # loc
+    qr'/Admin/'             => "modify RT's configuration",    # loc
+    qr'/Approval/'          => "update an approval",           # loc
+    qr'/Articles/'          => "update an article",            # loc
+    qr'/Dashboards/'        => "modify a dashboard",           # loc
+    qr'/m/ticket/'          => "update a ticket",              # loc
+    qr'Prefs'               => "modify your preferences",      # loc
+    qr'/Search/'            => "modify or access a search",    # loc
+    qr'/SelfService/Create' => "create a ticket",              # loc
+    qr'/SelfService/'       => "update a ticket",              # loc
+);
+
+sub PotentialPageAction {
+    my $page = shift;
+    my @potentials = @POTENTIAL_PAGE_ACTIONS;
+    while (my ($pattern, $result) = splice @potentials, 0, 2) {
+        return HTML::Mason::Commands::loc($result)
+            if $page =~ $pattern;
+    }
+    return "";
+}
+
 package HTML::Mason::Commands;
 
 use vars qw/$r $m %session/;
@@ -1645,9 +1727,8 @@ sub CreateTicket {
         }
     }
 
-    foreach my $argument (qw(Encrypt Sign)) {
-        $MIMEObj->head->replace( "X-RT-$argument" => $ARGS{$argument} ? 1 : 0 )
-          if defined $ARGS{$argument};
+    for my $argument (qw(Encrypt Sign)) {
+        $MIMEObj->head->replace( "X-RT-$argument" => $ARGS{$argument} ? 1 : 0 );
     }
 
     my %create_args = (
index 6b351e9..045df1f 100644 (file)
@@ -150,10 +150,12 @@ treated as relative to it's parent's path, and made absolute.
 sub path {
     my $self = shift;
     if (@_) {
-        $self->{path} = shift;
-        $self->{path} = URI->new_abs($self->{path}, $self->parent->path . "/")->as_string
-            if defined $self->{path} and $self->parent and $self->parent->path;
-        $self->{path} =~ s!///!/! if $self->{path};
+        if (defined($self->{path} = shift)) {
+            my $base  = ($self->parent and $self->parent->path) ? $self->parent->path : "";
+               $base .= "/" unless $base =~ m{/$};
+            my $uri = URI->new_abs($self->{path}, $base);
+            $self->{path} = $uri->as_string;
+        }
     }
     return $self->{path};
 }
@@ -230,6 +232,7 @@ sub child {
         if ( defined $path and length $path ) {
             my $base_path = $HTML::Mason::Commands::r->path_info;
             my $query     = $HTML::Mason::Commands::m->cgi_object->query_string;
+            $base_path =~ s!/+!/!g;
             $base_path .= "?$query" if defined $query and length $query;
 
             $base_path =~ s/index\.html$//;
diff --git a/rt/lib/RT/Pod/HTML.pm b/rt/lib/RT/Pod/HTML.pm
new file mode 100644 (file)
index 0000000..8ddce42
--- /dev/null
@@ -0,0 +1,66 @@
+use strict;
+use warnings;
+
+package RT::Pod::HTML;
+use base 'Pod::Simple::XHTML';
+
+sub new {
+    my $self = shift->SUPER::new(@_);
+    $self->index(1);
+    $self->anchor_items(1);
+    return $self;
+}
+
+sub perldoc_url_prefix { "http://metacpan.org/module/" }
+
+sub html_header { '' }
+sub html_footer {
+    my $self = shift;
+    my $toc  = "../" x ($self->batch_mode_current_level - 1);
+    return '<a href="./' . $toc . '">&larr; Back to index</a>';
+}
+
+sub start_Verbatim { $_[0]{'scratch'} = "<pre>" }
+sub end_Verbatim   { $_[0]{'scratch'} .= "</pre>"; $_[0]->emit; }
+
+sub _end_head {
+    my $self = shift;
+    $self->{scratch} = '<a href="#___top">' . $self->{scratch} . '</a>';
+    return $self->SUPER::_end_head(@_);
+}
+
+sub resolve_pod_page_link {
+    my $self = shift;
+    my ($name, $section) = @_;
+
+    # Only try to resolve local links if we're in batch mode and are linking
+    # outside the current document.
+    return $self->SUPER::resolve_pod_page_link(@_)
+        unless $self->batch_mode and $name;
+
+    $section = defined $section
+        ? '#' . $self->idify($section, 1)
+        : '';
+
+    my $local;
+    if ($name =~ /^RT::/) {
+        $local = join "/",
+                  map { $self->encode_entities($_) }
+                split /::/, $name;
+    }
+    elsif ($name =~ /^rt-/) {
+        $local = $self->encode_entities($name);
+    }
+
+    if ($local) {
+        # Resolve links correctly by going up
+        my $depth = $self->batch_mode_current_level - 1;
+        return join "/",
+                    ($depth ? ".." x $depth : ()),
+                    "$local.html$section";
+    } else {
+        return $self->SUPER::resolve_pod_page_link(@_)
+    }
+}
+
+1;
diff --git a/rt/lib/RT/Pod/HTMLBatch.pm b/rt/lib/RT/Pod/HTMLBatch.pm
new file mode 100644 (file)
index 0000000..8d1b67f
--- /dev/null
@@ -0,0 +1,131 @@
+use strict;
+use warnings;
+
+package RT::Pod::HTMLBatch;
+use base 'Pod::Simple::HTMLBatch';
+
+use List::MoreUtils qw/all/;
+
+use RT::Pod::Search;
+use RT::Pod::HTML;
+
+sub new {
+    my $self = shift->SUPER::new(@_);
+    $self->verbose(0);
+
+    # Per-page output options
+    $self->css_flurry(0);          # No CSS
+    $self->javascript_flurry(0);   # No JS
+    $self->no_contents_links(1);   # No header/footer "Back to contents" links
+
+    # TOC options
+    $self->index(1);                    # Write a per-page TOC
+    $self->contents_file("index.html"); # Write a global TOC
+
+    $self->html_render_class('RT::Pod::HTML');
+    $self->search_class('RT::Pod::Search');
+
+    return $self;
+}
+
+sub classify {
+    my $self = shift;
+    my %info = (@_);
+
+    my $is_install_doc = sub {
+        my %page = @_;
+        local $_ = $page{name};
+        return 1 if /^(README|UPGRADING)/;
+        return 1 if $_ eq "RT_Config";
+        return 1 if $_ eq "web_deployment";
+        return 1 if $page{infile} =~ m{^configure(\.ac)?$};
+        return 0;
+    };
+
+    my $section = $info{infile} =~ m{/plugins/([^/]+)}      ? "05 Extension: $1"           :
+                  $info{infile} =~ m{/local/}               ? '04 Local Documenation'      :
+                  $is_install_doc->(%info)                  ? '00 Install and Upgrade '.
+                                                                 'Documentation'           :
+                  $info{infile} =~ m{/(docs|etc)/}          ? '01 User Documentation'      :
+                  $info{infile} =~ m{/bin/}                 ? '02 Utilities (bin)'         :
+                  $info{infile} =~ m{/sbin/}                ? '03 Utilities (sbin)'        :
+                  $info{name}   =~ /^RT::Action/            ? '08 Actions'                 :
+                  $info{name}   =~ /^RT::Condition/         ? '09 Conditions'              :
+                  $info{name}   =~ /^RT(::|$)/              ? '07 Developer Documentation' :
+                  $info{infile} =~ m{/devel/tools/}         ? '20 Utilities (devel/tools)' :
+                                                              '06 Miscellaneous'           ;
+
+    if ($info{infile} =~ m{/(docs|etc)/}) {
+        $info{name} =~ s/_/ /g;
+        $info{name} = join "/", map { ucfirst } split /::/, $info{name};
+    }
+
+    return ($info{name}, $section);
+}
+
+sub write_contents_file {
+    my ($self, $to) = @_;
+    return unless $self->contents_file;
+
+    my $file = join "/", $to, $self->contents_file;
+    open my $index, ">", $file
+        or warn "Unable to open index file '$file': $!\n", return;
+
+    my $pages = $self->_contents;
+    return unless @$pages;
+
+    # Classify
+    my %toc;
+    for my $page (@$pages) {
+        my ($name, $infile, $outfile, $pieces) = @$page;
+
+        my ($title, $section) = $self->classify(
+            name    => $name,
+            infile  => $infile,
+        );
+
+        (my $path = $outfile) =~ s{^\Q$to\E/?}{};
+
+        push @{ $toc{$section} }, {
+            name => $title,
+            path => $path,
+        };
+    }
+
+    # Write out index
+    print $index "<dl class='superindex'>\n";
+
+    for my $key (sort keys %toc) {
+        next unless @{ $toc{$key} };
+
+        (my $section = $key) =~ s/^\d+ //;
+        print $index "<dt>", esc($section), "</dt>\n";
+        print $index "<dd>\n";
+
+        my @sorted = sort {
+            my @names = map { $_->{name} } $a, $b;
+
+            # Sort just the upgrading docs descending within everything else
+            @names = reverse @names
+                if all { /^UPGRADING-/ } @names;
+
+            $names[0] cmp $names[1]
+        } @{ $toc{$key} };
+
+        for my $page (@sorted) {
+            print $index "  <a href='", esc($page->{path}), "'>",
+                                esc($page->{name}),
+                           "</a><br>\n";
+        }
+        print $index "</dd>\n";
+    }
+    print $index '</dl>';
+
+    close $index;
+}
+
+sub esc {
+    Pod::Simple::HTMLBatch::esc(@_);
+}
+
+1;
diff --git a/rt/lib/RT/Pod/Search.pm b/rt/lib/RT/Pod/Search.pm
new file mode 100644 (file)
index 0000000..d6ddd2d
--- /dev/null
@@ -0,0 +1,15 @@
+use strict;
+use warnings;
+
+package RT::Pod::Search;
+use base 'Pod::Simple::Search';
+
+sub new {
+    my $self = shift->SUPER::new(@_);
+       $self->laborious(1)              # Find scripts too
+            ->limit_re(qr/(?<!\.in)$/)  # Filter out .in files
+            ->inc(0);                   # Don't look in @INC
+    return $self;
+}
+
+1;
index 406df92..a942bb6 100755 (executable)
@@ -394,6 +394,7 @@ sub Create {
         FinalPriority     => 0,
         DefaultDueIn      => 0,
         Sign              => undef,
+        SignAuto          => undef,
         Encrypt           => undef,
         _RecordTransaction => 1,
         @_
@@ -436,14 +437,11 @@ sub Create {
     }
     $RT::Handle->Commit;
 
-    if ( defined $args{'Sign'} ) {
-        my ($status, $msg) = $self->SetSign( $args{'Sign'} );
-        $RT::Logger->error("Couldn't set attribute 'Sign': $msg")
-            unless $status;
-    }
-    if ( defined $args{'Encrypt'} ) {
-        my ($status, $msg) = $self->SetEncrypt( $args{'Encrypt'} );
-        $RT::Logger->error("Couldn't set attribute 'Encrypt': $msg")
+    for my $attr (qw/Sign SignAuto Encrypt/) {
+        next unless defined $args{$attr};
+        my $set = "Set" . $attr;
+        my ($status, $msg) = $self->$set( $args{$attr} );
+        $RT::Logger->error("Couldn't set attribute '$attr': $msg")
             unless $status;
     }
 
@@ -595,6 +593,32 @@ sub SetSign {
     return ($status, $self->loc('Signing disabled'));
 }
 
+sub SignAuto {
+    my $self = shift;
+    my $value = shift;
+
+    return undef unless $self->CurrentUserHasRight('SeeQueue');
+    my $attr = $self->FirstAttribute('SignAuto') or return 0;
+    return $attr->Content;
+}
+
+sub SetSignAuto {
+    my $self = shift;
+    my $value = shift;
+
+    return ( 0, $self->loc('Permission Denied') )
+        unless $self->CurrentUserHasRight('AdminQueue');
+
+    my ($status, $msg) = $self->SetAttribute(
+        Name        => 'SignAuto',
+        Description => 'Sign auto-generated outgoing messages',
+        Content     => $value,
+    );
+    return ($status, $msg) unless $status;
+    return ($status, $self->loc('Signing enabled')) if $value;
+    return ($status, $self->loc('Signing disabled'));
+}
+
 sub Encrypt {
     my $self = shift;
     my $value = shift;
index fd238de..313888c 100755 (executable)
@@ -1483,8 +1483,35 @@ sub _DeleteLink {
 }
 
 
+=head1 LockForUpdate
 
+In a database transaction, gains an exclusive lock on the row, to
+prevent race conditions.  On SQLite, this is a "RESERVED" lock on the
+entire database.
 
+=cut
+
+sub LockForUpdate {
+    my $self = shift;
+
+    my $pk = $self->_PrimaryKey;
+    my $id = @_ ? $_[0] : $self->$pk;
+    $self->_expire if $self->isa("DBIx::SearchBuilder::Record::Cachable");
+    if (RT->Config->Get('DatabaseType') eq "SQLite") {
+        # SQLite does DB-level locking, upgrading the transaction to
+        # "RESERVED" on the first UPDATE/INSERT/DELETE.  Do a no-op
+        # UPDATE to force the upgade.
+        return RT->DatabaseHandle->dbh->do(
+            "UPDATE " .$self->Table.
+                " SET $pk = $pk WHERE 1 = 0");
+    } else {
+        return $self->_LoadFromSQL(
+            "SELECT * FROM ".$self->Table
+                ." WHERE $pk = ? FOR UPDATE",
+            $id,
+        );
+    }
+}
 
 =head2 _NewTransaction  PARAMHASH
 
@@ -1512,6 +1539,11 @@ sub _NewTransaction {
         @_
     );
 
+    my $in_txn = RT->DatabaseHandle->TransactionDepth;
+    RT->DatabaseHandle->BeginTransaction unless $in_txn;
+
+    $self->LockForUpdate;
+
     my $old_ref = $args{'OldReference'};
     my $new_ref = $args{'NewReference'};
     my $ref_type = $args{'ReferenceType'};
@@ -1559,6 +1591,9 @@ sub _NewTransaction {
     if ( RT->Config->Get('UseTransactionBatch') and $transaction ) {
            push @{$self->{_TransactionBatch}}, $trans if $args{'CommitScrips'};
     }
+
+    RT->DatabaseHandle->Commit unless $in_txn;
+
     return ( $transaction, $msg, $trans );
 }
 
index 117cc3f..e509454 100755 (executable)
@@ -390,6 +390,7 @@ sub _Parse {
 
     # Unfold all headers
     $self->{'MIMEObj'}->head->unfold;
+    $self->{'MIMEObj'}->head->modify(1);
 
     return ( 1, $self->loc("Template parsed") );
 
index 577c444..5f76e05 100755 (executable)
@@ -2199,14 +2199,16 @@ sub Comment {
     }
     $args{'NoteType'} = 'Comment';
 
+    $RT::Handle->BeginTransaction();
     if ($args{'DryRun'}) {
-        $RT::Handle->BeginTransaction();
         $args{'CommitScrips'} = 0;
     }
 
     my @results = $self->_RecordNote(%args);
     if ($args{'DryRun'}) {
         $RT::Handle->Rollback();
+    } else {
+        $RT::Handle->Commit();
     }
 
     return(@results);
@@ -2245,10 +2247,10 @@ sub Correspond {
              or ( $self->CurrentUserHasRight('ModifyTicket') ) ) {
         return ( 0, $self->loc("Permission Denied"), undef );
     }
+    $args{'NoteType'} = 'Correspond';
 
-    $args{'NoteType'} = 'Correspond'; 
+    $RT::Handle->BeginTransaction();
     if ($args{'DryRun'}) {
-        $RT::Handle->BeginTransaction();
         $args{'CommitScrips'} = 0;
     }
 
@@ -2265,6 +2267,8 @@ sub Correspond {
 
     if ($args{'DryRun'}) {
         $RT::Handle->Rollback();
+    } else {
+        $RT::Handle->Commit();
     }
 
     return (@results);
index e7f7c2a..f26ace4 100755 (executable)
@@ -102,6 +102,7 @@ sub _OverlayAccessible {
           AuthSystem            => { public => 1,  admin => 1 },
           Gecos                 => { public => 1,  admin => 1 },
           PGPKey                => { public => 1,  admin => 1 },
+          PrivateKey            => {               admin => 1 },
 
     }
 }
index 2a6b07e..396ef10 100755 (executable)
@@ -217,6 +217,11 @@ sub attachments {
         VALUE => 'deleted'
     );
 
+    # On newer DBIx::SearchBuilder's, indicate that making the query DISTINCT
+    # is unnecessary because the joins won't produce duplicates.  This
+    # drastically improves performance when fetching attachments.
+    $res->{joins_are_distinct} = 1;
+
     return goto_specific(
         suffix => $type,
         error => "Don't know how to find $type attachments",
index 7e31cac..06aa892 100644 (file)
@@ -217,6 +217,11 @@ sub attachments {
         VALUE => 'deleted'
     );
 
+    # On newer DBIx::SearchBuilder's, indicate that making the query DISTINCT
+    # is unnecessary because the joins won't produce duplicates.  This
+    # drastically improves performance when fetching attachments.
+    $res->{joins_are_distinct} = 1;
+
     return goto_specific(
         suffix => $type,
         error => "Don't know how to find $type attachments",
index 960d640..5ce918b 100644 (file)
@@ -75,6 +75,7 @@ GetOptions(
     'with-DASHBOARDS',
     'with-USERLOGO',
     'with-SSL-MAILGATE',
+    'with-HTML-DOC',
 
     'download=s',
     'repository=s',
@@ -104,6 +105,7 @@ my %default = (
     'with-DASHBOARDS' => 1,
     'with-USERLOGO' => 1,
     'with-SSL-MAILGATE' => @RT_SSL_MAILGATE@,
+    'with-HTML-DOC' => @RT_DEVEL_MODE@,
 );
 $args{$_} = $default{$_} foreach grep !exists $args{$_}, keys %default;
 
@@ -358,6 +360,11 @@ $deps{'USERLOGO'} = [ text_to_hash( << '.') ];
 Convert::Color
 .
 
+$deps{'HTML-DOC'} = [ text_to_hash( <<'.') ];
+Pod::Simple 3.17
+HTML::Entities
+.
+
 my %AVOID = (
     'DBD::Oracle' => [qw(1.23)],
     'Email::Address' => [qw(1.893 1.894)],
diff --git a/rt/sbin/rt-validate-aliases.in b/rt/sbin/rt-validate-aliases.in
new file mode 100644 (file)
index 0000000..46ae8aa
--- /dev/null
@@ -0,0 +1,343 @@
+#!@PERL@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2012 Best Practical Solutions, LLC
+#                                          <sales@bestpractical.com>
+#
+# (Except where explicitly superseded by other copyright notices)
+#
+#
+# LICENSE:
+#
+# This work is made available to you under the terms of Version 2 of
+# the GNU General Public License. A copy of that license should have
+# been provided with this software, but in any event can be snarfed
+# from www.gnu.org.
+#
+# This work is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+# 02110-1301 or visit their web page on the internet at
+# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+#
+#
+# CONTRIBUTION SUBMISSION POLICY:
+#
+# (The following paragraph is not intended to limit the rights granted
+# to you to modify and distribute this software under the terms of
+# the GNU General Public License and is only of importance to you if
+# you choose to contribute your changes and enhancements to the
+# community by submitting them to Best Practical Solutions, LLC.)
+#
+# By intentionally submitting any modifications, corrections or
+# derivatives to this work, or any other work intended for use with
+# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+# you are the copyright holder for those contributions and you grant
+# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+# royalty-free, perpetual, license to use, copy, create derivative
+# works based on those contributions, and sublicense and distribute
+# those contributions and any derivatives thereof.
+#
+# END BPS TAGGED BLOCK }}}
+use strict;
+use warnings;
+use Text::ParseWords qw//;
+use Getopt::Long;
+
+BEGIN { # BEGIN RT CMD BOILERPLATE
+    require File::Spec;
+    require Cwd;
+    my @libs = ("@RT_LIB_PATH@", "@LOCAL_LIB_PATH@");
+    my $bin_path;
+
+    for my $lib (@libs) {
+        unless ( File::Spec->file_name_is_absolute($lib) ) {
+            $bin_path ||= ( File::Spec->splitpath(Cwd::abs_path(__FILE__)) )[1];
+            $lib = File::Spec->catfile( $bin_path, File::Spec->updir, $lib );
+        }
+        unshift @INC, $lib;
+    }
+}
+
+require RT;
+RT::LoadConfig();
+RT::Init();
+
+my ($PREFIX, $URL, $HOST) = ("");
+GetOptions(
+    "prefix|p=s" => \$PREFIX,
+    "url|u=s"    => \$URL,
+    "host|h=s"   => \$HOST,
+);
+
+unless (@ARGV) {
+    @ARGV = grep {-f} ("/etc/aliases",
+                       "/etc/mail/aliases",
+                       "/etc/postfix/aliases");
+    die "Can't determine aliases file to parse!"
+        unless @ARGV;
+}
+
+my %aliases = parse_lines();
+unless (%aliases) {
+    warn "No mailgate aliases found in @ARGV";
+    exit;
+}
+
+my %seen;
+my $global_mailgate;
+for my $address (sort keys %aliases) {
+    my ($mailgate, $opts, $extra) = @{$aliases{$address}};
+    my %opts = %{$opts};
+
+    next if $opts{url} and $URL and $opts{url} !~ /\Q$URL\E/;
+
+    if ($mailgate !~ /^\|/) {
+        warn "Missing the leading | on alias $address\n";
+        $mailgate = "|$mailgate";
+    }
+    if (($global_mailgate ||= $mailgate) ne $mailgate) {
+        warn "Unexpected mailgate for alias $address -- expected $global_mailgate, got $mailgate\n";
+    }
+
+    if (not defined $opts{action}) {
+        warn "Missing --action parameter for alias $address\n";
+    } elsif ($opts{action} !~ /^(correspond|comment)$/) {
+        warn "Invalid --action parameter for alias $address: $opts{action}\n"
+    }
+
+    my $queue = RT::Queue->new( RT->SystemUser );
+    if (not defined $opts{queue}) {
+        warn "Missing --queue parameter for alias $address\n";
+    } else {
+        $queue->Load( $opts{queue} );
+        if (not $queue->id) {
+            warn "Invalid --queue parameter for alias $address: $opts{queue}\n";
+        } elsif ($queue->Disabled) {
+            warn "Disabled --queue given for alias $address: $opts{queue}\n";
+        }
+    }
+
+    if (not defined $opts{url}) {
+        warn "Missing --url parameter for alias $address\n";
+    } #XXX: Test connectivity and/or https certs?
+
+    if ($queue->id and $opts{action} =~ /^(correspond|comment)$/) {
+        push @{$seen{lc $queue->Name}{$opts{action}}}, $address;
+    }
+
+    warn "Unknown extra arguments for alias $address: @{$extra}\n"
+        if @{$extra};
+}
+
+# Check the global settings
+my %global;
+for my $action (qw/correspond comment/) {
+    my $setting = ucfirst($action) . "Address";
+    my $value = RT->Config->Get($setting);
+    if (not defined $value) {
+        warn "$setting is not set!\n";
+        next;
+    }
+    my ($local,$host) = lc($value) =~ /(.*?)\@(.*)/;
+    next if $HOST and $host !~ /\Q$HOST\E/;
+    $local = "$PREFIX$local" unless exists $aliases{$local};
+
+    $global{$setting} = $local;
+    if (not exists $aliases{$local}) {
+        warn "$setting $value does not exist in aliases!\n"
+    } elsif ($aliases{$local}[1]{action} ne $action) {
+        warn "$setting $value is a $aliases{$local}[1]{action} in aliases!"
+    }
+}
+warn "CorrespondAddress and CommentAddress are the same!\n"
+    if RT->Config->Get("CorrespondAddress") eq RT->Config->Get("CommentAddress");
+
+
+# Go through the queues, one at a time
+my $queues = RT::Queues->new( RT->SystemUser );
+$queues->UnLimit;
+while (my $q = $queues->Next) {
+    my $qname = $q->Name;
+    for my $action (qw/correspond comment/) {
+        my $setting = ucfirst($action) . "Address";
+        my $value = $q->$setting;
+
+        if (not $value) {
+            my @other = grep {$_ ne $global{$setting}} @{$seen{lc $q->Name}{$action} || []};
+            warn "CorrespondAddress not set on $qname, but in aliases as "
+                .join(" and ", @other) . "\n" if @other;
+            next;
+        }
+
+        if ($action eq "comment" and $q->CorrespondAddress
+                and $q->CorrespondAddress eq $q->CommentAddress) {
+            warn "CorrespondAddress and CommentAddress are set the same on $qname\n";
+            next;
+        }
+
+        my ($local, $host) = lc($value) =~ /(.*?)\@(.*)/;
+        next if $HOST and $host !~ /\Q$HOST\E/;
+        $local = "$PREFIX$local" unless exists $aliases{$local};
+
+        my @other = @{$seen{lc $q->Name}{$action} || []};
+        if (not exists $aliases{$local}) {
+            if (@other) {
+                warn "$setting $value on $qname does not exist in aliases -- typo'd as "
+                    .join(" or ", @other) . "?\n";
+            } else {
+                warn "$setting $value on $qname does not exist in aliases!\n"
+            }
+            next;
+        }
+
+        my %opt = %{$aliases{$local}[1]};
+        if ($opt{action} ne $action) {
+            warn "$setting address $value on $qname is a $opt{action} in aliases!\n"
+        }
+        if (lc $opt{queue} ne lc $q->Name and $action ne "comment") {
+            warn "$setting address $value on $qname points to queue $opt{queue} in aliases!\n";
+        }
+
+        @other = grep {$_ ne $local} @other;
+        warn "Extra aliases for queue $qname: ".join(",",@other)."\n"
+            if @other;
+    }
+}
+
+
+sub parse_lines {
+    local @ARGV = @ARGV;
+
+    my %aliases;
+    my $line = "";
+    for (<>) {
+        next unless /\S/;
+        next if /^#/;
+        chomp;
+        if (/^\s+/) {
+            $line .= $_;
+        } else {
+            add_line($line, \%aliases);
+            $line = $_;
+        }
+    }
+    add_line($line, \%aliases);
+
+    expand(\%aliases);
+    filter_mailgate(\%aliases);
+
+    return %aliases;
+}
+
+sub expand {
+    my ($data) = @_;
+
+    for (1..100) {
+        my $expanded = 0;
+        for my $address (sort keys %{$data}) {
+            my @new;
+            for my $part (@{$data->{$address}}) {
+                if (m!^[|/]! or not $data->{$part}) {
+                    push @new, $part;
+                } else {
+                    $expanded++;
+                    push @new, @{$data->{$part}};
+                }
+            }
+            $data->{$address} = \@new;
+        }
+        return unless $expanded;
+    }
+    warn "Recursion limit exceeded -- cycle in aliases?\n";
+}
+
+sub filter_mailgate {
+    my ($data) = @_;
+
+    for my $address (sort keys %{$data}) {
+        my @parts = @{delete $data->{$address}};
+
+        my @pipes = grep {m!^\|?.*?/rt-mailgate\b!} @parts;
+        next unless @pipes;
+
+        my $pipe = shift @pipes;
+        warn "More than one rt-mailgate pipe for alias: $address\n"
+            if @pipes;
+
+        my @args = Text::ParseWords::shellwords($pipe);
+
+        # We allow "|/random-other-command /opt/rt4/bin/rt-mailgate ...",
+        # we just need to strip off enough
+        my $index = 0;
+        $index++ while $args[$index] !~ m!/rt-mailgate!;
+        my $mailgate = join(' ', splice(@args,0,$index+1));
+
+        my %opts;
+        local @ARGV = @args;
+        Getopt::Long::Configure( "pass_through" ); # Allow unknown options
+        my $ret = eval {
+            GetOptions( \%opts, "queue=s", "action=s", "url=s",
+                        "jar=s", "debug", "extension=s",
+                        "timeout=i", "verify-ssl!", "ca-file=s",
+                    );
+            1;
+        };
+        warn "Failed to parse options for $address: $@" unless $ret;
+        next unless %opts;
+
+        $data->{lc $address} = [$mailgate, \%opts, [@ARGV]];
+    }
+}
+
+sub add_line {
+    my ($line, $data) = @_;
+    return unless $line =~ /\S/;
+
+    my ($name, $parts) = parse_line($line);
+    return unless defined $name;
+
+    if (defined $data->{$name}) {
+        warn "Duplicate definition for alias $name\n";
+        return;
+    }
+
+    $data->{lc $name} = $parts;
+}
+
+sub parse_line {
+    my $re_name      = qr/\S+/;
+    # Intentionally accept pipe-like aliases with a missing | -- we deal with them later
+    my $re_quoted_pipe    = qr/"\|?[^\\"]*(?:\\[\\"][^\\"]*)*"/;
+    my $re_nonquoted_pipe = qr/\|[^\s,]+/;
+    my $re_pipe      = qr/(?:$re_quoted_pipe|$re_nonquoted_pipe)/;
+    my $re_path      = qr!/[^,\s]+!;
+    my $re_address   = qr![^|/,\s][^,\s]*!;
+    my $re_value     = qr/(?:$re_pipe|$re_path|$re_address)/;
+    my $re_values    = qr/(?:$re_value(?:\s*,\s*$re_value)*)/;
+
+    my ($line) = @_;
+    if ($line =~ /^($re_name):\s*($re_values)/) {
+        my ($name, $all_parts) = ($1, $2);
+        my @parts;
+        while ($all_parts =~ s/^(?:\s*,\s*)?($re_value)//) {
+            my $part = $1;
+            if ($part =~ /^"/) {
+                $part =~ s/^"//; $part =~ s/"$//;
+                $part =~ s/\\(.)/$1/g;
+            }
+            push @parts, $part;
+        }
+        return $name, [@parts];
+    } else {
+        warn "Parse failure, line $. of $ARGV: $line\n";
+        return ();
+    }
+}
index 148c98e..4491a71 100755 (executable)
@@ -162,10 +162,7 @@ MaybeRedirectForResults(
 
 push @results, @warnings;
 
-unless ($Group->Disabled()) {
-    $EnabledChecked ='checked="checked"';
-}
-
+$EnabledChecked = ( $Group->Disabled() ? '' : 'checked="checked"' );
 
 </%INIT>
 
index 85cd62f..c2cf094 100755 (executable)
 <td align="right"><input type="checkbox" class="checkbox" name="Encrypt" value="1" <% $QueueObj->Encrypt? 'checked="checked"': '' |n%> /></td>
 <td><&|/l&>Encrypt by default</&></td>
 </tr>
+<tr><td align="right"><input type="checkbox" class="checkbox" name="SignAuto" value="1" <% $QueueObj->SignAuto? 'checked="checked"': '' |n%> /></td>
+<td colspan="3"><&|/l_unsafe, "<b>","</b>","<i>","</i>"&>Sign all auto-generated mail.  [_1]Caution[_2]: Enabling this option alters the signature from providing [_3]authentication[_4] to providing [_3]integrity[_4].</&></td></tr>
 % }
 
 <tr><td align="right"><input type="checkbox" class="checkbox" name="Enabled" value="1" <%$EnabledChecked|n%> /></td>
@@ -181,13 +183,13 @@ unless ($Create) {
 if ( $QueueObj->Id ) {
     $title = loc('Configuration for queue [_1]', $QueueObj->Name );
     my @attribs= qw(Description CorrespondAddress CommentAddress Name
-        InitialPriority FinalPriority DefaultDueIn Sign Encrypt Lifecycle SubjectTag Disabled);
+        InitialPriority FinalPriority DefaultDueIn Sign SignAuto Encrypt Lifecycle SubjectTag Disabled);
 
     # we're asking about enabled on the web page but really care about disabled
     if ( $SetEnabled ) {
         $Disabled = $ARGS{'Disabled'} = $Enabled? 0: 1;
         $ARGS{$_} = 0 foreach grep !defined $ARGS{$_} || !length $ARGS{$_},
-            qw(Sign Encrypt Disabled);
+            qw(Sign SignAuto Encrypt Disabled);
     }
 
     $m->callback(
index 90408e4..ee58c44 100644 (file)
@@ -64,7 +64,7 @@
 <& /Widgets/Form/Select,
     Name         => 'PrivateKey',
     Description  => loc('Private Key'),
-    Values       => [ map $_->{'Key'}, @{ $keys_meta{'info'} } ],
+    Values       => \@potential_keys,
     CurrentValue => $UserObj->PrivateKey,
     DefaultLabel => loc('No private key'),
 &>
@@ -91,7 +91,8 @@ unless ( $UserObj->id ) {
 $id = $ARGS{'id'} = $UserObj->id;
 
 my $email = $UserObj->EmailAddress;
-my %keys_meta = RT::Crypt::GnuPG::GetKeysForSigning( $email, 'force' );
+my %keys_meta = RT::Crypt::GnuPG::GetKeysForSigning( $email );
+my @potential_keys = map $_->{'Key'}, @{ $keys_meta{'info'} || [] };
 
 $ARGS{'PrivateKey'} = $m->comp('/Widgets/Form/Select:Process',
     Name      => 'PrivateKey',
@@ -100,8 +101,14 @@ $ARGS{'PrivateKey'} = $m->comp('/Widgets/Form/Select:Process',
 );
 
 if ( $Update ) {
-    my ($status, $msg) = $UserObj->SetPrivateKey( $ARGS{'PrivateKey'} );
-    push @results, $msg;
+    if (not $ARGS{'PrivateKey'} or grep {$_ eq $ARGS{'PrivateKey'}} @potential_keys) {
+        if (($ARGS{'PrivateKey'}||'') ne ($UserObj->PrivateKey||'')) {
+            my ($status, $msg) = $UserObj->SetPrivateKey( $ARGS{'PrivateKey'} );
+            push @results, $msg;
+        }
+    } else {
+        push @results, loc("Invalid key [_1] for address '[_2]'", $ARGS{'PrivateKey'}, $email);
+    }
 }
 
 my $title = loc("[_1]'s GnuPG keys",$UserObj->Name);
index 4893c12..a3c1943 100644 (file)
 
 % my $strong_start = "<strong>";
 % my $strong_end   = "</strong>";
-<p><&|/l_unsafe, $strong_start, $strong_end, $Reason &>RT has detected a possible [_1]cross-site request forgery[_2] for this request, because [_3].  This is possibly caused by a malicious attacker trying to perform actions against RT on your behalf. If you did not initiate this request, then you should alert your security team.</&></p>
+<p><&|/l_unsafe, $strong_start, $strong_end, $Reason, $action &>RT has detected a possible [_1]cross-site request forgery[_2] for this request, because [_3].  A malicious attacker may be trying to [_1][_4][_2] on your behalf. If you did not initiate this request, then you should alert your security team.</&></p>
 
 % my $start = qq|<strong><a href="$url_with_token">|;
 % my $end   = qq|</a></strong>|;
-<p><&|/l_unsafe, $escaped_path, $start, $end &>If you really intended to visit [_1], then [_2]click here to resume your request[_3].</&></p>
+<p><&|/l_unsafe, $escaped_path, $action, $start, $end &>If you really intended to visit [_1] and [_2], then [_3]click here to resume your request[_4].</&></p>
 
 <& /Elements/Footer, %ARGS &>
 % $m->abort;
@@ -71,4 +71,6 @@ $escaped_path = "<tt>$escaped_path</tt>";
 
 my $url_with_token = URI->new($OriginalURL);
 $url_with_token->query_form([CSRF_Token => $Token]);
+
+my $action = RT::Interface::Web::PotentialPageAction($OriginalURL) || loc("perform actions");
 </%INIT>
index 0ae0f84..2f3f103 100644 (file)
@@ -129,12 +129,16 @@ if ( $self->{'Sign'} ) {
     $QueueObj ||= $TicketObj->QueueObj
         if $TicketObj;
 
-    my $address = $self->{'SignUsing'};
-    $address ||= ($self->{'UpdateType'} && $self->{'UpdateType'} eq "private")
+    my $private = $session{'CurrentUser'}->UserObj->PrivateKey || '';
+    my $queue = ($self->{'UpdateType'} && $self->{'UpdateType'} eq "private")
         ? ( $QueueObj->CommentAddress || RT->Config->Get('CommentAddress') )
         : ( $QueueObj->CorrespondAddress || RT->Config->Get('CorrespondAddress') );
 
-    unless ( RT::Crypt::GnuPG::DrySign( $address ) ) {
+    my $address = $self->{'SignUsing'} || $queue;
+    if ($address ne $private and $address ne $queue) {
+        push @{ $self->{'GnuPGCanNotSignAs'} ||= [] }, $address;
+        $checks_failure = 1;
+    } elsif ( not RT::Crypt::GnuPG::DrySign( $address ) ) {
         push @{ $self->{'GnuPGCanNotSignAs'} ||= [] }, $address;
         $checks_failure = 1;
     } else {
index b86bfef..b3f1a24 100755 (executable)
@@ -61,6 +61,8 @@
 <div id="login-box">
 <&| /Widgets/TitleBox, title => loc('Login'), titleright => $RT::VERSION, hideable => 0 &>
 
+<& LoginRedirectWarning, %ARGS &>
+
 % unless (RT->Config->Get('WebExternalAuth') and !RT->Config->Get('WebFallbackToInternalAuth')) {
 <form id="login" name="login" method="post" action="<% RT->Config->Get('WebPath') %>/NoAuth/Login.html">
 
diff --git a/rt/share/html/Elements/LoginRedirectWarning b/rt/share/html/Elements/LoginRedirectWarning
new file mode 100644 (file)
index 0000000..891e381
--- /dev/null
@@ -0,0 +1,20 @@
+<%args>
+$next => undef
+</%args>
+<%init>
+return unless $next;
+
+my $destination = RT::Interface::Web::FetchNextPage($next);
+return unless ref $destination and $destination->{'HasSideEffects'};
+
+my $consequence = RT::Interface::Web::PotentialPageAction($destination->{'url'}) || loc("perform actions");
+   $consequence = $m->interp->apply_escapes($consequence => "h");
+</%init>
+<div class="redirect-warning">
+  <p>
+    <&|/l&>After logging in you'll be sent to your original destination:</&>
+    <tt title="<% $destination->{'url'} %>"><% $destination->{'url'} %></tt>
+    <&|/l_unsafe, "<strong>$consequence</strong>" &>which may [_1] on your behalf.</&>
+  </p>
+  <p><&|/l&>If this is not what you expect, leave this page now without logging in.</&></p>
+</div>
index 3aac9d8..d899071 100755 (executable)
@@ -51,6 +51,7 @@
 
 #my $request_path = $HTML::Mason::Commands::r->path_info;
 my $request_path = $m->request_comp->path;
+$request_path =~ s!/{2,}!/!g;
 
 my $query_string = sub {
     my %args = @_;
index bd05a28..608ebf8 100644 (file)
@@ -100,3 +100,11 @@ margin-right:auto;margin-left:auto;
     padding-left: 1em;
 }
 
+.redirect-warning tt {
+    display: block;
+    margin: 0.5em 0 0.5em 1em;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    width: 90%;
+}
index c86f4cf..0e9e812 100644 (file)
@@ -94,7 +94,7 @@ while (my $t = $tickets->Next) {
     my $start = Data::ICal::Entry::Event->new;
     my $end   = Data::ICal::Entry::Event->new;
     $_->add_properties(
-        url       => RT->Config->Get('WebURL') . "?q=".$t->id,
+        url       => RT->Config->Get('WebURL') . "Ticket/Display.html?id=".$t->id,
         organizer => $t->OwnerObj->Name,
         dtstamp   => $now->iCal,
         created   => $t->CreatedObj->iCal,
index 3c86162..5a91668 100755 (executable)
@@ -80,6 +80,11 @@ foreach my $f (@headers) {
     $m->comp('/Elements/MakeClicky', content => \$f->{'Value'}, ticket => $ticket, %ARGS);
 }
 
+$m->callback(
+    CallbackName => 'BeforeLocalization',
+    headers      => \@headers,
+);
+
 if ( $Localize ) {
     $_->{'Tag'} = loc($_->{'Tag'}) foreach @headers;
 }
index e591add..6ff4f76 100644 (file)
@@ -11,7 +11,7 @@ BEGIN {
 }
 
 use RT::Test::GnuPG
-  tests         => 41,
+  tests         => 49,
   actual_server => 1,
   gnupg_options => {
     passphrase => 'rt-test',
@@ -20,6 +20,7 @@ use RT::Test::GnuPG
 
 use String::ShellQuote 'shell_quote';
 use IPC::Run3 'run3';
+use MIME::Base64;
 
 my ($baseurl, $m) = RT::Test->started_ok;
 
@@ -196,6 +197,44 @@ RT::Test->close_mailgate_ok($mail);
     ok(index($orig->Content, $buf) != -1, 'found original msg');
 }
 
+
+# test that if it gets base64 transfer-encoded, we still get the content out
+$buf = encode_base64($buf);
+$mail = RT::Test->open_mailgate_ok($baseurl);
+print $mail <<"EOF";
+From: recipient\@example.com
+To: general\@$RT::rtname
+Content-transfer-encoding: base64
+Subject: Encrypted message for queue
+
+$buf
+EOF
+RT::Test->close_mailgate_ok($mail);
+
+{
+    my $tick = RT::Test->last_ticket;
+    is( $tick->Subject, 'Encrypted message for queue',
+        "Created the ticket"
+    );
+
+    my $txn = $tick->Transactions->First;
+    my ($msg, $attach, $orig) = @{$txn->Attachments->ItemsArrayRef};
+
+    is( $msg->GetHeader('X-RT-Incoming-Encryption'),
+        'Success',
+        'recorded incoming mail that is encrypted'
+    );
+    is( $msg->GetHeader('X-RT-Privacy'),
+        'PGP',
+        'recorded incoming mail that is encrypted'
+    );
+    like( $attach->Content, qr/orz/);
+
+    is( $orig->GetHeader('Content-Type'), 'application/x-rt-original-message');
+    ok(index($orig->Content, $buf) != -1, 'found original msg');
+}
+
+
 # test for signed mail by other key
 $buf = '';
 
index 8c0eb57..b30edc3 100644 (file)
@@ -8,6 +8,7 @@ use RT::Test::GnuPG
     'trust-model' => 'always',
 };
 use Test::Warn;
+use MIME::Head;
 
 use RT::Action::SendEmail;
 
@@ -70,8 +71,7 @@ $user->SetEmailAddress('general@example.com');
 for my $mail (@mail) {
     unlike $mail, qr/Some content/, "outgoing mail was encrypted";
 
-    my ($content_type) = $mail =~ /^(Content-Type: .*)/m;
-    my ($mime_version) = $mail =~ /^(MIME-Version: .*)/m;
+    my ($content_type, $mime_version) = get_headers($mail, "Content-Type", "MIME-Version");
     my $body = strip_headers($mail);
 
     $mail = << "MAIL";
@@ -139,8 +139,7 @@ for my $mail (@mail) {
     like $mail, qr/Some other content/, "outgoing mail was not encrypted";
     like $mail, qr/-----BEGIN PGP SIGNATURE-----[\s\S]+-----END PGP SIGNATURE-----/, "data has some kind of signature";
 
-    my ($content_type) = $mail =~ /^(Content-Type: .*)/m;
-    my ($mime_version) = $mail =~ /^(MIME-Version: .*)/m;
+    my ($content_type, $mime_version) = get_headers($mail, "Content-Type", "MIME-Version");
     my $body = strip_headers($mail);
 
     $mail = << "MAIL";
@@ -212,8 +211,7 @@ ok(@mail, "got some mail");
 for my $mail (@mail) {
     unlike $mail, qr/Some other content/, "outgoing mail was encrypted";
 
-    my ($content_type) = $mail =~ /^(Content-Type: .*)/m;
-    my ($mime_version) = $mail =~ /^(MIME-Version: .*)/m;
+    my ($content_type, $mime_version) = get_headers($mail, "Content-Type", "MIME-Version");
     my $body = strip_headers($mail);
 
     $mail = << "MAIL";
@@ -279,8 +277,7 @@ ok(@mail, "got some mail");
 for my $mail (@mail) {
     like $mail, qr/Thought you had me figured out didya/, "outgoing mail was unencrypted";
 
-    my ($content_type) = $mail =~ /^(Content-Type: .*)/m;
-    my ($mime_version) = $mail =~ /^(MIME-Version: .*)/m;
+    my ($content_type, $mime_version) = get_headers($mail, "Content-Type", "MIME-Version");
     my $body = strip_headers($mail);
 
     $mail = << "MAIL";
@@ -326,6 +323,20 @@ MAIL
     like($attachments[0]->Content, qr/$RT::rtname/, "RT's mail includes this instance's name");
 }
 
+sub get_headers {
+    my $mail = shift;
+    open my $fh, "<", \$mail or die $!;
+    my $head = MIME::Head->read($fh);
+    return @{[
+        map {
+            my $hdr = "$_: " . $head->get($_);
+            chomp $hdr;
+            $hdr;
+        }
+        @_
+    ]};
+}
+
 sub strip_headers
 {
     my $mail = shift;
index 1d74673..0c411b9 100644 (file)
@@ -49,7 +49,7 @@ diag "Forward Ticket" if $ENV{TEST_VERBOSE};
     my ($mail) = RT::Test->fetch_caught_mails;
     like( $mail, qr!Subject: test forward!,           'Subject field' );
     like( $mail, qr!To: rt-test, rt-to\@example.com!, 'To field' );
-    like( $mail, qr!Cc: rt-cc\@example.com!         'Cc field' );
+    like( $mail, qr!Cc: rt-cc\@example.com!i,         'Cc field' );
     like( $mail, qr!This is a forward of ticket!,     'content' );
     like( $mail, qr!this is an attachment!,           'att content' );
     like( $mail, qr!$att_name!,                       'att file name' );
@@ -75,8 +75,8 @@ qr/Forwarded Transaction #\d+ to rt-test, rt-to\@example.com, rt-cc\@example.com
     my ($mail) = RT::Test->fetch_caught_mails;
     like( $mail, qr!Subject: test forward!,            'Subject field' );
     like( $mail, qr!To: rt-test, rt-to\@example.com!,  'To field' );
-    like( $mail, qr!Cc: rt-cc\@example.com!          'Cc field' );
-    like( $mail, qr!Bcc: rt-bcc\@example.com!        'Bcc field' );
+    like( $mail, qr!Cc: rt-cc\@example.com!i,          'Cc field' );
+    like( $mail, qr!Bcc: rt-bcc\@example.com!i,        'Bcc field' );
     like( $mail, qr!This is a forward of transaction!, 'content' );
     like( $mail, qr!$att_name!,                        'att file name' );
     like( $mail, qr!this is an attachment!,            'att content' );