adding export to read mailbox status information, RT#15987
[freeside.git] / FS / FS / part_export / sqlradius.pm
index 20475c5..752bf12 100644 (file)
@@ -1,5 +1,6 @@
 package FS::part_export::sqlradius;
 
+use strict;
 use vars qw(@ISA @EXPORT_OK $DEBUG %info %options $notes1 $notes2);
 use Exporter;
 use Tie::IxHash;
@@ -14,14 +15,28 @@ use Carp qw( cluck );
 
 $DEBUG = 0;
 
+my %groups;
 tie %options, 'Tie::IxHash',
   'datasrc'  => { label=>'DBI data source ' },
   'username' => { label=>'Database username' },
   'password' => { label=>'Database password' },
+  'usergroup' => { label   => 'Group table',
+                   type    => 'select',
+                   options => [qw( usergroup radusergroup ) ],
+                 },
   'ignore_accounting' => {
     type  => 'checkbox',
     label => 'Ignore accounting records from this database'
   },
+  'process_single_realm' => {
+    type  => 'checkbox',
+    label => 'Only process one realm of accounting records',
+  },
+  'realm' => { label => 'The realm of of accounting records to be processed' },
+  'ignore_long_sessions' => {
+    type  => 'checkbox',
+    label => 'Ignore sessions which span billing periods',
+  },
   'hide_ip' => {
     type  => 'checkbox',
     label => 'Hide IP address information on session reports',
@@ -34,16 +49,33 @@ tie %options, 'Tie::IxHash',
     type  => 'checkbox',
     label => 'Show the Called-Station-ID on session reports',
   },
-  'overlimit_groups' => { label => 'Radius groups to assign to svc_acct which has exceeded its bandwidth or time limit', } ,
+  'overlimit_groups' => {
+      label => 'Radius groups to assign to svc_acct which has exceeded its bandwidth or time limit (if not overridden by overlimit_groups global or per-agent config)', 
+      type  => 'select',
+      multi => 1,
+      option_label  => sub {
+        $groups{$_[0]};
+      },
+      option_values => sub {
+        %groups = (
+              map { $_->groupnum, $_->long_description } 
+                  qsearch('radius_group', {}),
+            );
+            sort keys (%groups);
+      },
+   } ,
   'groups_susp_reason' => { label =>
                              'Radius group mapping to reason (via template user) (svcnum|username|username@domain  reasonnum|reason)',
                             type  => 'textarea',
                           },
-
+  'export_attrs' => {
+    type => 'checkbox',
+    label => 'Export RADIUS group attributes to this database',
+  },
 ;
 
 $notes1 = <<'END';
-Real-time export of <b>radcheck</b>, <b>radreply</b> and <b>usergroup</b>
+Real-time export of <b>radcheck</b>, <b>radreply</b> and <b>usergroup</b>/<b>radusergroup</b>
 tables to any SQL database for
 <a href="http://www.freeradius.org/">FreeRADIUS</a>
 or <a href="http://radius.innercite.com/">ICRADIUS</a>.
@@ -78,6 +110,7 @@ END
   'desc'     => 'Real-time export to SQL-backed RADIUS (FreeRADIUS, ICRADIUS)',
   'options'  => \%options,
   'nodomain' => 'Y',
+  'nas'      => 'Y', # show export_nas selection in UI
   'notes'    => $notes1.
                 'This export does not export RADIUS realms (see also '.
                 'sqlradius_withdomain).  '.
@@ -90,31 +123,42 @@ sub _groups_susp_reason_map { map { reverse( /^\s*(\S+)\s*(.*)$/ ) }
 
 sub rebless { shift; }
 
-sub export_username {
+sub export_username { # override for other svcdb
   my($self, $svc_acct) = (shift, shift);
   warn "export_username called on $self with arg $svc_acct" if $DEBUG > 1;
   $svc_acct->username;
 }
 
+sub radius_reply { #override for other svcdb
+  my($self, $svc_acct) = (shift, shift);
+  $svc_acct->radius_reply;
+}
+
+sub radius_check { #override for other svcdb
+  my($self, $svc_acct) = (shift, shift);
+  $svc_acct->radius_check;
+}
+
 sub _export_insert {
   my($self, $svc_x) = (shift, shift);
 
   foreach my $table (qw(reply check)) {
     my $method = "radius_$table";
-    my %attrib = $svc_x->$method();
+    my %attrib = $self->$method($svc_x);
     next unless keys %attrib;
     my $err_or_queue = $self->sqlradius_queue( $svc_x->svcnum, 'insert',
       $table, $self->export_username($svc_x), %attrib );
     return $err_or_queue unless ref($err_or_queue);
   }
-  my @groups = $svc_x->radius_groups;
+  my @groups = $svc_x->radius_groups('hashref');
   if ( @groups ) {
     cluck localtime(). ": queuing usergroup_insert for ". $svc_x->svcnum.
           " (". $self->export_username($svc_x). " with ". join(", ", @groups)
       if $DEBUG;
+    my $usergroup = $self->option('usergroup') || 'usergroup';
     my $err_or_queue = $self->sqlradius_queue(
       $svc_x->svcnum, 'usergroup_insert',
-      $self->export_username($svc_x), @groups );
+      $self->export_username($svc_x), $usergroup, @groups );
     return $err_or_queue unless ref($err_or_queue);
   }
   '';
@@ -136,8 +180,9 @@ sub _export_replace {
 
   my $jobnum = '';
   if ( $self->export_username($old) ne $self->export_username($new) ) {
+    my $usergroup = $self->option('usergroup') || 'usergroup';
     my $err_or_queue = $self->sqlradius_queue( $new->svcnum, 'rename',
-      $self->export_username($new), $self->export_username($old) );
+      $self->export_username($new), $self->export_username($old), $usergroup );
     unless ( ref($err_or_queue) ) {
       $dbh->rollback if $oldAutoCommit;
       return $err_or_queue;
@@ -187,8 +232,8 @@ sub _export_replace {
   }
 
   my $error;
-  my (@oldgroups) = $old->radius_groups;
-  my (@newgroups) = $new->radius_groups;
+  my (@oldgroups) = $old->radius_groups('hashref');
+  my (@newgroups) = $new->radius_groups('hashref');
   $error = $self->sqlreplace_usergroups( $new->svcnum,
                                          $self->export_username($new),
                                          $jobnum ? $jobnum : '',
@@ -221,22 +266,27 @@ sub _export_suspend {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
-  my $err_or_queue = $self->sqlradius_queue( $new->svcnum, 'insert',
-    'check', $self->export_username($new), $new->radius_check );
-  unless ( ref($err_or_queue) ) {
-    $dbh->rollback if $oldAutoCommit;
-    return $err_or_queue;
+  my @newgroups = $self->suspended_usergroups($svc_acct);
+
+  unless (@newgroups) { #don't change password if assigning to a suspended group
+
+    my $err_or_queue = $self->sqlradius_queue( $new->svcnum, 'insert',
+      'check', $self->export_username($new), $new->radius_check );
+    unless ( ref($err_or_queue) ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $err_or_queue;
+    }
+
   }
 
-  my $error;
-  my (@newgroups) = $self->suspended_usergroups($svc_acct);
-  $error =
-    $self->sqlreplace_usergroups( $new->svcnum,
-                                  $self->export_username($new),
-                                 '',
-                                  $svc_acct->usergroup,
-                                 \@newgroups,
-                               );
+  my $error =
+    $self->sqlreplace_usergroups(
+      $new->svcnum,
+      $self->export_username($new),
+      '',
+      [ $svc_acct->radius_groups('hashref') ],
+      \@newgroups,
+    );
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return $error;
@@ -269,12 +319,13 @@ sub _export_unsuspend {
 
   my $error;
   my (@oldgroups) = $self->suspended_usergroups($svc_acct);
-  $error = $self->sqlreplace_usergroups( $svc_acct->svcnum,
-                                         $self->export_username($svc_acct),
-                                         '',
-                                        \@oldgroups,
-                                        $svc_acct->usergroup,
-                                      );
+  $error = $self->sqlreplace_usergroups(
+    $svc_acct->svcnum,
+    $self->export_username($svc_acct),
+    '',
+    \@oldgroups,
+    [ $svc_acct->radius_groups('hashref') ],
+  );
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return $error;
@@ -286,8 +337,9 @@ sub _export_unsuspend {
 
 sub _export_delete {
   my( $self, $svc_x ) = (shift, shift);
+  my $usergroup = $self->option('usergroup') || 'usergroup';
   my $err_or_queue = $self->sqlradius_queue( $svc_x->svcnum, 'delete',
-    $self->export_username($svc_x) );
+    $self->export_username($svc_x), $usergroup );
   ref($err_or_queue) ? '' : $err_or_queue;
 }
 
@@ -323,7 +375,7 @@ sub suspended_usergroups {
       if (!$userspec && exists($reasonmap{$r->reason}));
   }
   my $suspend_user;
-  if ($userspec =~ /^d+$/ ){
+  if ($userspec =~ /^\d+$/ ){
     $suspend_user = qsearchs( 'svc_acct', { 'svcnum' => $userspec } );
   }elsif ($userspec =~ /^\S+\@\S+$/){
     my ($username,$domain) = split(/\@/, $userspec);
@@ -334,7 +386,7 @@ sub suspended_usergroups {
     $suspend_user = qsearchs( 'svc_acct', { 'username' => $userspec } );
   }
   #esalf
-  return $suspend_user->radius_groups if $suspend_user;
+  return $suspend_user->radius_groups('hashref') if $suspend_user;
   ();
 }
 
@@ -378,17 +430,21 @@ sub sqlradius_insert { #subroutine, not method
 
 sub sqlradius_usergroup_insert { #subroutine, not method
   my $dbh = sqlradius_connect(shift, shift, shift);
-  my( $username, @groups ) = @_;
+  my $username = shift;
+  my $usergroup = ( $_[0] =~ /^(rad)?usergroup/i ) ? shift : 'usergroup';
+  my @groups = @_;
 
   my $s_sth = $dbh->prepare(
-    "SELECT COUNT(*) FROM usergroup WHERE UserName = ? AND GroupName = ?"
+    "SELECT COUNT(*) FROM $usergroup WHERE UserName = ? AND GroupName = ?"
   ) or die $dbh->errstr;
 
   my $sth = $dbh->prepare( 
-    "INSERT INTO usergroup ( UserName, GroupName ) VALUES ( ?, ? )"
+    "INSERT INTO $usergroup ( UserName, GroupName, Priority ) VALUES ( ?, ?, ? )"
   ) or die $dbh->errstr;
 
-  foreach my $group ( @groups ) {
+  foreach ( @groups ) {
+    my $group = $_->{'groupname'};
+    my $priority = $_->{'priority'};
     $s_sth->execute( $username, $group ) or die $s_sth->errstr;
     if ($s_sth->fetchrow_arrayref->[0]) {
       warn localtime() . ": sqlradius_usergroup_insert attempted to reinsert " .
@@ -396,20 +452,31 @@ sub sqlradius_usergroup_insert { #subroutine, not method
         if $DEBUG;
       next;
     }
-    $sth->execute( $username, $group )
+    $sth->execute( $username, $group, $priority )
       or die "can't insert into groupname table: ". $sth->errstr;
   }
+  if ( $s_sth->{Active} ) {
+    warn "sqlradius s_sth still active; calling ->finish()";
+    $s_sth->finish;
+  }
+  if ( $sth->{Active} ) {
+    warn "sqlradius sth still active; calling ->finish()";
+    $sth->finish;
+  }
   $dbh->disconnect;
 }
 
 sub sqlradius_usergroup_delete { #subroutine, not method
   my $dbh = sqlradius_connect(shift, shift, shift);
-  my( $username, @groups ) = @_;
+  my $username = shift;
+  my $usergroup = ( $_[0] =~ /^(rad)?usergroup/i ) ? shift : 'usergroup';
+  my @groups = @_;
 
   my $sth = $dbh->prepare( 
-    "DELETE FROM usergroup WHERE UserName = ? AND GroupName = ?"
+    "DELETE FROM $usergroup WHERE UserName = ? AND GroupName = ?"
   ) or die $dbh->errstr;
-  foreach my $group ( @groups ) {
+  foreach ( @groups ) {
+    my $group = $_->{'groupname'};
     $sth->execute( $username, $group )
       or die "can't delete from groupname table: ". $sth->errstr;
   }
@@ -418,8 +485,9 @@ sub sqlradius_usergroup_delete { #subroutine, not method
 
 sub sqlradius_rename { #subroutine, not method
   my $dbh = sqlradius_connect(shift, shift, shift);
-  my($new_username, $old_username) = @_;
-  foreach my $table (qw(radreply radcheck usergroup )) {
+  my($new_username, $old_username) = (shift, shift);
+  my $usergroup = ( $_[0] =~ /^(rad)?usergroup/i ) ? shift : 'usergroup';
+  foreach my $table (qw(radreply radcheck), $usergroup ) {
     my $sth = $dbh->prepare("UPDATE $table SET Username = ? WHERE UserName = ?")
       or die $dbh->errstr;
     $sth->execute($new_username, $old_username)
@@ -445,8 +513,9 @@ sub sqlradius_attrib_delete { #subroutine, not method
 sub sqlradius_delete { #subroutine, not method
   my $dbh = sqlradius_connect(shift, shift, shift);
   my $username = shift;
+  my $usergroup = ( $_[0] =~ /^(rad)?usergroup/i ) ? shift : 'usergroup';
 
-  foreach my $table (qw( radcheck radreply usergroup )) {
+  foreach my $table (qw( radcheck radreply), $usergroup ) {
     my $sth = $dbh->prepare( "DELETE FROM $table WHERE UserName = ?" );
     $sth->execute($username)
       or die "can't delete from $table table: ". $sth->errstr;
@@ -475,9 +544,11 @@ sub sqlreplace_usergroups {
     push @delgroups, $oldgroup;
   }
 
+  my $usergroup = $self->option('usergroup') || 'usergroup';
+
   if ( @delgroups ) {
     my $err_or_queue = $self->sqlradius_queue( $svcnum, 'usergroup_delete',
-      $username, @delgroups );
+      $username, $usergroup, @delgroups );
     return $err_or_queue
       unless ref($err_or_queue);
     if ( $jobnum ) {
@@ -491,7 +562,7 @@ sub sqlreplace_usergroups {
           "with ".  join(", ", @newgroups)
       if $DEBUG;
     my $err_or_queue = $self->sqlradius_queue( $svcnum, 'usergroup_insert',
-      $username, @newgroups );
+      $username, $usergroup, @newgroups );
     return $err_or_queue
       unless ref($err_or_queue);
     if ( $jobnum ) {
@@ -578,6 +649,7 @@ sub usage_sessions {
 
   my $opt = {};
   my($start, $end, $svc_acct, $ip, $prefix) = ( '', '', '', '', '');
+  my $summarize = 0;
   if ( ref($_[0]) ) {
     $opt = shift;
     $start    = $opt->{stoptime_start};
@@ -585,6 +657,7 @@ sub usage_sessions {
     $svc_acct = $opt->{svc_acct};
     $ip       = $opt->{ip};
     $prefix   = $opt->{prefix};
+    $summarize   = $opt->{summarize};
   } else {
     ( $start, $end ) = splice(@_, 0, 2);
     $svc_acct = @_ ? shift : '';
@@ -612,12 +685,16 @@ sub usage_sessions {
                  "$str2time acctstoptime ) as acctstoptime",
                );
 
+  @fields = ( 'username', 'sum(acctsessiontime) as acctsessiontime', 'sum(acctinputoctets) as acctinputoctets',
+              'sum(acctoutputoctets) as acctoutputoctets',
+            ) if $summarize;
+
   my @param = ();
   my @where = ();
 
   if ( $svc_acct ) {
     my $username = $self->export_username($svc_acct);
-    if ( $svc_acct =~ /^([^@]+)\@([^@]+)$/ ) {
+    if ( $username =~ /^([^@]+)\@([^@]+)$/ ) {
       push @where, '( UserName = ? OR ( UserName = ? AND Realm = ? ) )';
       push @param, $username, $1, $2;
     } else {
@@ -626,6 +703,11 @@ sub usage_sessions {
     }
   }
 
+  if ($self->option('process_single_realm')) {
+    push @where, 'Realm = ?';
+    push @param, $self->option('realm');
+  }
+
   if ( length($ip) ) {
     push @where, ' FramedIPAddress = ?';
     push @param, $ip;
@@ -659,11 +741,15 @@ sub usage_sessions {
   my $where = join(' AND ', @where);
   $where = "WHERE $where" if $where;
 
+  my $groupby = '';
+  $groupby = 'GROUP BY username' if $summarize;
+
+  my $orderby = 'ORDER BY AcctStartTime DESC';
+  $orderby = '' if $summarize;
+
   my $sth = $dbh->prepare('SELECT '. join(', ', @fields).
-                          "  FROM radacct
-                             $where
-                             ORDER BY AcctStartTime DESC
-  ") or die $dbh->errstr;                                 
+                          "  FROM radacct $where $groupby $orderby
+                        ") or die $dbh->errstr;                                 
   $sth->execute(@param) or die $sth->errstr;
 
   [ map { { %$_ } } @{ $sth->fetchall_arrayref({}) } ];
@@ -695,7 +781,7 @@ sub update_svc {
            AcctInputOctets, AcctOutputOctets
       FROM radacct
       WHERE FreesideStatus IS NULL
-        AND AcctStopTime != 0
+        AND AcctStopTime IS NOT NULL
   ") or die $dbh->errstr;
   $sth->execute() or die $sth->errstr;
 
@@ -719,43 +805,53 @@ sub update_svc {
     my $oldAutoCommit = $FS::UID::AutoCommit; # can't undo side effects, but at
     local $FS::UID::AutoCommit = 0;           # least we can avoid over counting
 
-    my @svc_acct =
-      grep { qsearch( 'export_svc', { 'exportnum' => $self->exportnum,
-                                      'svcpart'   => $_->cust_svc->svcpart, } )
-           }
-      qsearch( 'svc_acct',
-                 { 'username' => $UserName },
-                 '',
-                 $extra_sql
-               );
-
+    my $status = 'skipped';
     my $errinfo = "for RADIUS detail RadAcctID $RadAcctId ".
                   "(UserName $UserName, Realm $Realm)";
-    my $status = 'skipped';
-    if ( !@svc_acct ) {
-      warn "WARNING: no svc_acct record found $errinfo - skipping\n";
-    } elsif ( scalar(@svc_acct) > 1 ) {
-      warn "WARNING: multiple svc_acct records found $errinfo - skipping\n";
-    } else {
-
-      my $svc_acct = $svc_acct[0];
-      warn "found svc_acct ". $svc_acct->svcnum. " $errinfo\n" if $DEBUG;
 
-      $svc_acct->last_login($AcctStartTime);
-      $svc_acct->last_logout($AcctStopTime);
-
-      my $cust_pkg = $svc_acct->cust_svc->cust_pkg;
-      if ( $cust_pkg && $AcctStopTime < (    $cust_pkg->last_bill
-                                          || $cust_pkg->setup     )  ) {
-        $status = 'skipped (too old)';
+    if (    $self->option('process_single_realm')
+         && $self->option('realm') ne $Realm )
+    {
+      warn "WARNING: wrong realm $errinfo - skipping\n" if $DEBUG;
+    } else {
+      my @svc_acct =
+        grep { qsearch( 'export_svc', { 'exportnum' => $self->exportnum,
+                                        'svcpart'   => $_->cust_svc->svcpart, } )
+             }
+        qsearch( 'svc_acct',
+                   { 'username' => $UserName },
+                   '',
+                   $extra_sql
+                 );
+
+      if ( !@svc_acct ) {
+        warn "WARNING: no svc_acct record found $errinfo - skipping\n";
+      } elsif ( scalar(@svc_acct) > 1 ) {
+        warn "WARNING: multiple svc_acct records found $errinfo - skipping\n";
       } else {
-        my @st;
-        push @st, _try_decrement($svc_acct, 'seconds',    $AcctSessionTime   );
-        push @st, _try_decrement($svc_acct, 'upbytes',    $AcctInputOctets   );
-        push @st, _try_decrement($svc_acct, 'downbytes',  $AcctOutputOctets  );
-        push @st, _try_decrement($svc_acct, 'totalbytes', $AcctInputOctets
+
+        my $svc_acct = $svc_acct[0];
+        warn "found svc_acct ". $svc_acct->svcnum. " $errinfo\n" if $DEBUG;
+
+        $svc_acct->last_login($AcctStartTime);
+        $svc_acct->last_logout($AcctStopTime);
+
+        my $session_time = $AcctStopTime;
+        $session_time = $AcctStartTime if $self->option('ignore_long_sessions');
+
+        my $cust_pkg = $svc_acct->cust_svc->cust_pkg;
+        if ( $cust_pkg && $session_time < (    $cust_pkg->last_bill
+                                            || $cust_pkg->setup     )  ) {
+          $status = 'skipped (too old)';
+        } else {
+          my @st;
+          push @st, _try_decrement($svc_acct, 'seconds',    $AcctSessionTime);
+          push @st, _try_decrement($svc_acct, 'upbytes',    $AcctInputOctets);
+          push @st, _try_decrement($svc_acct, 'downbytes',  $AcctOutputOctets);
+          push @st, _try_decrement($svc_acct, 'totalbytes', $AcctInputOctets
                                                           + $AcctOutputOctets);
-        $status=join(' ', @st);
+          $status=join(' ', @st);
+        }
       }
     }
 
@@ -788,6 +884,342 @@ sub _try_decrement {
   return 'skipped';
 }
 
+=item export_nas_insert NAS
+
+=item export_nas_delete NAS
+
+=item export_nas_replace NEW_NAS OLD_NAS
+
+Update the NAS table (allowed RADIUS clients) on the attached RADIUS 
+server.  Currently requires the table to be named 'nas' and to follow 
+the stock schema (/etc/freeradius/nas.sql).
+
+=cut
+
+sub export_nas_insert {  shift->export_nas_action('insert', @_); }
+sub export_nas_delete {  shift->export_nas_action('delete', @_); }
+sub export_nas_replace { shift->export_nas_action('replace', @_); }
+
+sub export_nas_action {
+  my $self = shift;
+  my ($action, $new, $old) = @_;
+  # find the NAS in the target table by its name
+  my $nasname = ($action eq 'replace') ? $old->nasname : $new->nasname;
+  my $nasnum = $new->nasnum;
+
+  my $err_or_queue = $self->sqlradius_queue('', "nas_$action", 
+    nasname => $nasname,
+    nasnum => $nasnum
+  );
+  return $err_or_queue unless ref $err_or_queue;
+  '';
+}
+
+sub sqlradius_nas_insert {
+  my $dbh = sqlradius_connect(shift, shift, shift);
+  my %opt = @_;
+  my $nas = qsearchs('nas', { nasnum => $opt{'nasnum'} })
+    or die "nasnum ".$opt{'nasnum'}.' not found';
+  # insert actual NULLs where FS::Record has translated to empty strings
+  my @values = map { length($nas->$_) ? $nas->$_ : undef }
+    qw( nasname shortname type secret server community description );
+  my $sth = $dbh->prepare('INSERT INTO nas 
+(nasname, shortname, type, secret, server, community, description)
+VALUES (?, ?, ?, ?, ?, ?, ?)');
+  $sth->execute(@values) or die $dbh->errstr;
+}
+
+sub sqlradius_nas_delete {
+  my $dbh = sqlradius_connect(shift, shift, shift);
+  my %opt = @_;
+  my $sth = $dbh->prepare('DELETE FROM nas WHERE nasname = ?');
+  $sth->execute($opt{'nasname'}) or die $dbh->errstr;
+}
+
+sub sqlradius_nas_replace {
+  my $dbh = sqlradius_connect(shift, shift, shift);
+  my %opt = @_;
+  my $nas = qsearchs('nas', { nasnum => $opt{'nasnum'} })
+    or die "nasnum ".$opt{'nasnum'}.' not found';
+  my @values = map {$nas->$_} 
+    qw( nasname shortname type secret server community description );
+  my $sth = $dbh->prepare('UPDATE nas SET
+    nasname = ?, shortname = ?, type = ?, secret = ?,
+    server = ?, community = ?, description = ?
+    WHERE nasname = ?');
+  $sth->execute(@values, $opt{'nasname'}) or die $dbh->errstr;
+}
+
+=item export_attr_insert RADIUS_ATTR
+
+=item export_attr_delete RADIUS_ATTR
+
+=item export_attr_replace NEW_RADIUS_ATTR OLD_RADIUS_ATTR
+
+Update the group attribute tables (radgroupcheck and radgroupreply) on
+the RADIUS server.  In delete and replace actions, the existing records
+are identified by the combination of group name and attribute name.
+
+In the special case where attributes are being replaced because a group 
+name (L<FS::radius_group>->groupname) is changing, the pseudo-field 
+'groupname' must be set in OLD_RADIUS_ATTR.  It's probably best to do this
+
+
+=cut
+
+# some false laziness with NAS export stuff...
+
+sub export_attr_insert  { shift->export_attr_action('insert', @_); }
+
+sub export_attr_delete  { shift->export_attr_action('delete', @_); }
+
+sub export_attr_replace { shift->export_attr_action('replace', @_); }
+
+sub export_attr_action {
+  my $self = shift;
+  my ($action, $new, $old) = @_;
+  my ($attrname, $attrtype, $groupname) = 
+    ($new->attrname, $new->attrtype, $new->radius_group->groupname);
+  if ( $action eq 'replace' ) {
+
+    if ( $new->attrtype ne $old->attrtype ) {
+      # they're in separate tables in the target
+      return $self->export_attr_action('delete', $old) 
+          || $self->export_attr_action('insert', $new)
+      ;
+    }
+
+    # otherwise, just make sure we know the old attribute/group names 
+    # so we can find the existing record
+    $attrname = $old->attrname;
+    $groupname = $old->groupname || $old->radius_group->groupname;
+    # maybe this should be enforced more strictly
+    warn "WARNING: attribute replace without 'groupname' set; assuming '$groupname'\n"
+      if !defined($old->groupname);
+  }
+
+  my $err_or_queue = $self->sqlradius_queue('', "attr_$action",
+    attrnum => $new->attrnum,
+    attrname => $attrname,
+    attrtype => $attrtype,
+    groupname => $groupname,
+  );
+  return $err_or_queue unless ref $err_or_queue;
+  '';
+}
+
+sub sqlradius_attr_insert {
+  my $dbh = sqlradius_connect(shift, shift, shift);
+  my %opt = @_;
+  my $radius_attr = qsearchs('radius_attr', { attrnum => $opt{'attrnum'} })
+    or die 'attrnum '.$opt{'attrnum'}.' not found';
+
+  my $table;
+  # make sure $table is completely safe
+  if ( $opt{'attrtype'} eq 'C' ) {
+    $table = 'radgroupcheck';
+  }
+  elsif ( $opt{'attrtype'} eq 'R' ) {
+    $table = 'radgroupreply';
+  }
+  else {
+    die "unknown attribute type '".$radius_attr->attrtype."'";
+  }
+
+  my @values = ( 
+    $opt{'groupname'}, map { $radius_attr->$_ } qw(attrname op value)
+  );
+  my $sth = $dbh->prepare(
+    'INSERT INTO '.$table.' (groupname, attribute, op, value) VALUES (?,?,?,?)'
+  );
+  $sth->execute(@values) or die $dbh->errstr;
+}
+
+sub sqlradius_attr_delete {
+  my $dbh = sqlradius_connect(shift, shift, shift);
+  my %opt = @_;
+
+  my $table;
+  if ( $opt{'attrtype'} eq 'C' ) {
+    $table = 'radgroupcheck';
+  }
+  elsif ( $opt{'attrtype'} eq 'R' ) {
+    $table = 'radgroupreply';
+  }
+  else {
+    die "unknown attribute type '".$opt{'attrtype'}."'";
+  }
+
+  my $sth = $dbh->prepare(
+    'DELETE FROM '.$table.' WHERE groupname = ? AND attribute = ?'
+  );
+  $sth->execute( @opt{'groupname', 'attrname'} ) or die $dbh->errstr;
+}
+
+sub sqlradius_attr_replace {
+  my $dbh = sqlradius_connect(shift, shift, shift);
+  my %opt = @_;
+  my $radius_attr = qsearchs('radius_attr', { attrnum => $opt{'attrnum'} })
+    or die 'attrnum '.$opt{'attrnum'}.' not found';
+
+  my $table;
+  if ( $opt{'attrtype'} eq 'C' ) {
+    $table = 'radgroupcheck';
+  }
+  elsif ( $opt{'attrtype'} eq 'R' ) {
+    $table = 'radgroupreply';
+  }
+  else {
+    die "unknown attribute type '".$opt{'attrtype'}."'";
+  }
+
+  my $sth = $dbh->prepare(
+    'UPDATE '.$table.' SET groupname = ?, attribute = ?, op = ?, value = ?
+     WHERE groupname = ? AND attribute = ?'
+  );
+
+  my $new_groupname = $radius_attr->radius_group->groupname;
+  my @new_values = ( 
+    $new_groupname, map { $radius_attr->$_ } qw(attrname op value) 
+  );
+  $sth->execute( @new_values, @opt{'groupname', 'attrname'} )
+    or die $dbh->errstr;
+}
+
+=item export_group_replace NEW OLD
+
+Replace the L<FS::radius_group> object OLD with NEW.  This will change
+the group name and priority in all radusergroup records, and the group 
+name in radgroupcheck and radgroupreply.
+
+=cut
+
+sub export_group_replace {
+  my $self = shift;
+  my ($new, $old) = @_;
+  return '' if $new->groupname eq $old->groupname
+           and $new->priority  == $old->priority;
+
+  my $err_or_queue = $self->sqlradius_queue(
+    '',
+    'group_replace',
+    ($self->option('usergroup') || 'usergroup'),
+    $new->hashref,
+    $old->hashref,
+  );
+  return $err_or_queue unless ref $err_or_queue;
+  '';
+}
+
+sub sqlradius_group_replace {
+  my $dbh = sqlradius_connect(shift, shift, shift);
+  my $usergroup = shift;
+  $usergroup =~ /^(rad)?usergroup$/
+    or die "bad usergroup table name: $usergroup";
+  my ($new, $old) = (shift, shift);
+  # apply renames to check/reply attribute tables
+  if ( $new->{'groupname'} ne $old->{'groupname'} ) {
+    foreach my $table (qw(radgroupcheck radgroupreply)) {
+      my $sth = $dbh->prepare(
+        'UPDATE '.$table.' SET groupname = ? WHERE groupname = ?'
+      );
+      $sth->execute($new->{'groupname'}, $old->{'groupname'})
+        or die $dbh->errstr;
+    }
+  }
+  # apply renames and priority changes to usergroup table
+  my $sth = $dbh->prepare(
+    'UPDATE '.$usergroup.' SET groupname = ?, priority = ? WHERE groupname = ?'
+  );
+  $sth->execute($new->{'groupname'}, $new->{'priority'}, $old->{'groupname'})
+    or die $dbh->errstr;
+}
+
+###
+# class method to fetch groups/attributes from the sqlradius install on upgrade
+###
+
+sub _upgrade_exporttype {
+  # do this only if the radius_attr table is empty
+  local $FS::radius_attr::noexport_hack = 1;
+  my $class = shift;
+  return if qsearch('radius_attr', {});
+
+  foreach my $self ($class->all_sqlradius) {
+    my $error = $self->import_attrs;
+    die "exportnum ".$self->exportnum.":\n$error\n" if $error;
+  }
+  return;
+}
+
+sub import_attrs {
+  my $self = shift;
+  my $dbh = sqlradius_connect( map $self->option($_),
+                                   qw( datasrc username password ) );
+  my $usergroup = $self->option('usergroup') || 'usergroup';
+  my $error;
+  warn "Importing RADIUS groups and attributes from ".$self->option('datasrc').
+    "\n";
+
+  # map out existing groups and attrs
+  my %attrs_of;
+  my %groupnum_of;
+  foreach my $radius_group ( qsearch('radius_group', {}) ) {
+    $attrs_of{$radius_group->groupname} = +{
+      map { $_->attrname => $_ } $radius_group->radius_attr
+    };
+    $groupnum_of{$radius_group->groupname} = $radius_group->groupnum;
+  }
+
+  # get groupnames from radgroupcheck and radgroupreply
+  my $sql = '
+SELECT groupname, attribute, op, value, \'C\' FROM radgroupcheck
+UNION
+SELECT groupname, attribute, op, value, \'R\' FROM radgroupreply';
+  foreach my $row ( @{ $dbh->selectall_arrayref($sql) } ) {
+    my ($groupname, $attrname, $op, $value, $attrtype) = @$row;
+    warn "$groupname.$attrname\n";
+    if ( !exists($groupnum_of{$groupname}) ) {
+      my $radius_group = new FS::radius_group {
+        'groupname' => $groupname,
+        'priority'  => 1,
+      };
+      $error = $radius_group->insert;
+      return "error inserting group $groupname: $error" if $error;
+      $attrs_of{$groupname} = {};
+      $groupnum_of{$groupname} = $radius_group->groupnum;
+    }
+
+    my $a = $attrs_of{$groupname};
+    my $old = $a->{$attrname};
+    my $new;
+
+    if ( defined $old ) {
+      # replace
+      $new = new FS::radius_attr {
+        $old->hash,
+        'op'    => $op,
+        'value' => $value,
+      };
+      $error = $new->replace($old);
+      return "error modifying attr $attrname: $error" if $error;
+    }
+    else {
+      $new = new FS::radius_attr {
+        'groupnum' => $groupnum_of{$groupname},
+        'attrname' => $attrname,
+        'attrtype' => $attrtype,
+        'op'       => $op,
+        'value'    => $value,
+      };
+      $error = $new->insert;
+      return "error inserting attr $attrname: $error" if $error;
+    }
+    $attrs_of{$groupname}->{$attrname} = $new;
+  } #foreach $row
+  return;
+}
+
 ###
 #class methods
 ###
@@ -801,7 +1233,8 @@ sub all_sqlradius {
 
   my @part_export = ();
   push @part_export, qsearch('part_export', { 'exporttype' => $_ } )
-    foreach qw( sqlradius sqlradius_withdomain radiator phone_sqlradius );
+    foreach qw( sqlradius sqlradius_withdomain radiator phone_sqlradius
+                broadband_sqlradius );
   @part_export;
 }