Moved new-style export calls to svc_Common.
[freeside.git] / FS / FS / svc_acct.pm
1 package FS::svc_acct;
2
3 use strict;
4 use vars qw( @ISA $noexport_hack $conf
5              $dir_prefix @shells $usernamemin
6              $usernamemax $passwordmin $passwordmax
7              $username_ampersand $username_letter $username_letterfirst
8              $username_noperiod $username_nounderscore $username_nodash
9              $username_uppercase
10              $mydomain
11              $dirhash
12              @saltset @pw_set );
13 use Carp;
14 use Fcntl qw(:flock);
15 use FS::UID qw( datasrc );
16 use FS::Conf;
17 use FS::Record qw( qsearch qsearchs fields dbh );
18 use FS::svc_Common;
19 use Net::SSH;
20 use FS::part_svc;
21 use FS::svc_acct_pop;
22 use FS::svc_acct_sm;
23 use FS::cust_main_invoice;
24 use FS::svc_domain;
25 use FS::raddb;
26 use FS::queue;
27 use FS::radius_usergroup;
28 use FS::Msgcat qw(gettext);
29
30 @ISA = qw( FS::svc_Common );
31
32 #ask FS::UID to run this stuff for us later
33 $FS::UID::callback{'FS::svc_acct'} = sub { 
34   $conf = new FS::Conf;
35   $dir_prefix = $conf->config('home');
36   @shells = $conf->config('shells');
37   $usernamemin = $conf->config('usernamemin') || 2;
38   $usernamemax = $conf->config('usernamemax');
39   $passwordmin = $conf->config('passwordmin') || 6;
40   $passwordmax = $conf->config('passwordmax') || 8;
41   $username_letter = $conf->exists('username-letter');
42   $username_letterfirst = $conf->exists('username-letterfirst');
43   $username_noperiod = $conf->exists('username-noperiod');
44   $username_nounderscore = $conf->exists('username-nounderscore');
45   $username_nodash = $conf->exists('username-nodash');
46   $username_uppercase = $conf->exists('username-uppercase');
47   $username_ampersand = $conf->exists('username-ampersand');
48   $mydomain = $conf->config('domain');
49
50   $dirhash = $conf->config('dirhash') || 0;
51 };
52
53 @saltset = ( 'a'..'z' , 'A'..'Z' , '0'..'9' , '.' , '/' );
54 @pw_set = ( 'a'..'z', 'A'..'Z', '0'..'9', '(', ')', '#', '!', '.', ',' );
55
56 sub _cache {
57   my $self = shift;
58   my ( $hashref, $cache ) = @_;
59   if ( $hashref->{'svc_acct_svcnum'} ) {
60     $self->{'_domsvc'} = FS::svc_domain->new( {
61       'svcnum'   => $hashref->{'domsvc'},
62       'domain'   => $hashref->{'svc_acct_domain'},
63       'catchall' => $hashref->{'svc_acct_catchall'},
64     } );
65   }
66 }
67
68 =head1 NAME
69
70 FS::svc_acct - Object methods for svc_acct records
71
72 =head1 SYNOPSIS
73
74   use FS::svc_acct;
75
76   $record = new FS::svc_acct \%hash;
77   $record = new FS::svc_acct { 'column' => 'value' };
78
79   $error = $record->insert;
80
81   $error = $new_record->replace($old_record);
82
83   $error = $record->delete;
84
85   $error = $record->check;
86
87   $error = $record->suspend;
88
89   $error = $record->unsuspend;
90
91   $error = $record->cancel;
92
93   %hash = $record->radius;
94
95   %hash = $record->radius_reply;
96
97   %hash = $record->radius_check;
98
99   $domain = $record->domain;
100
101   $svc_domain = $record->svc_domain;
102
103   $email = $record->email;
104
105   $seconds_since = $record->seconds_since($timestamp);
106
107 =head1 DESCRIPTION
108
109 An FS::svc_acct object represents an account.  FS::svc_acct inherits from
110 FS::svc_Common.  The following fields are currently supported:
111
112 =over 4
113
114 =item svcnum - primary key (assigned automatcially for new accounts)
115
116 =item username
117
118 =item _password - generated if blank
119
120 =item sec_phrase - security phrase
121
122 =item popnum - Point of presence (see L<FS::svc_acct_pop>)
123
124 =item uid
125
126 =item gid
127
128 =item finger - GECOS
129
130 =item dir - set automatically if blank (and uid is not)
131
132 =item shell
133
134 =item quota - (unimplementd)
135
136 =item slipip - IP address
137
138 =item seconds - 
139
140 =item domsvc - svcnum from svc_domain
141
142 =item radius_I<Radius_Attribute> - I<Radius-Attribute>
143
144 =back
145
146 =head1 METHODS
147
148 =over 4
149
150 =item new HASHREF
151
152 Creates a new account.  To add the account to the database, see L<"insert">.
153
154 =cut
155
156 sub table { 'svc_acct'; }
157
158 =item insert
159
160 Adds this account to the database.  If there is an error, returns the error,
161 otherwise returns false.
162
163 The additional fields pkgnum and svcpart (see L<FS::cust_svc>) should be 
164 defined.  An FS::cust_svc record will be created and inserted.
165
166 The additional field I<usergroup> can optionally be defined; if so it should
167 contain an arrayref of group names.  See L<FS::radius_usergroup>.  (used in
168 sqlradius export only)
169
170 (TODOC: L<FS::queue> and L<freeside-queued>)
171
172 (TODOC: new exports! $noexport_hack)
173
174 =cut
175
176 sub insert {
177   my $self = shift;
178   my $error;
179
180   local $SIG{HUP} = 'IGNORE';
181   local $SIG{INT} = 'IGNORE';
182   local $SIG{QUIT} = 'IGNORE';
183   local $SIG{TERM} = 'IGNORE';
184   local $SIG{TSTP} = 'IGNORE';
185   local $SIG{PIPE} = 'IGNORE';
186
187   my $oldAutoCommit = $FS::UID::AutoCommit;
188   local $FS::UID::AutoCommit = 0;
189   my $dbh = dbh;
190
191   $error = $self->check;
192   return $error if $error;
193
194   return gettext('username_in_use'). ": ". $self->username
195     if qsearchs( 'svc_acct', { 'username' => $self->username,
196                                'domsvc'   => $self->domsvc,
197                              } );
198
199   if ( $self->svcnum ) {
200     my $cust_svc = qsearchs('cust_svc',{'svcnum'=>$self->svcnum});
201     unless ( $cust_svc ) {
202       $dbh->rollback if $oldAutoCommit;
203       return "no cust_svc record found for svcnum ". $self->svcnum;
204     }
205     $self->pkgnum($cust_svc->pkgnum);
206     $self->svcpart($cust_svc->svcpart);
207   }
208
209   my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $self->svcpart } );
210   return "Unknown svcpart" unless $part_svc;
211   return "uid in use"
212     if $part_svc->part_svc_column('uid')->columnflag ne 'F'
213       && qsearchs( 'svc_acct', { 'uid' => $self->uid } )
214       && $self->username !~ /^(hyla)?fax$/
215       && $self->username !~ /^toor$/ #FreeBSD
216     ;
217
218   $error = $self->SUPER::insert;
219   if ( $error ) {
220     $dbh->rollback if $oldAutoCommit;
221     return $error;
222   }
223
224   if ( $self->usergroup ) {
225     foreach my $groupname ( @{$self->usergroup} ) {
226       my $radius_usergroup = new FS::radius_usergroup ( {
227         svcnum    => $self->svcnum,
228         groupname => $groupname,
229       } );
230       my $error = $radius_usergroup->insert;
231       if ( $error ) {
232         $dbh->rollback if $oldAutoCommit;
233         return $error;
234       }
235     }
236   }
237
238   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
239   ''; #no error
240 }
241
242 =item delete
243
244 Deletes this account from the database.  If there is an error, returns the
245 error, otherwise returns false.
246
247 The corresponding FS::cust_svc record will be deleted as well.
248
249 (TODOC: new exports! $noexport_hack)
250
251 =cut
252
253 sub delete {
254   my $self = shift;
255
256   if ( defined( $FS::Record::dbdef->table('svc_acct_sm') ) ) {
257     return "Can't delete an account which has (svc_acct_sm) mail aliases!"
258       if $self->uid && qsearch( 'svc_acct_sm', { 'domuid' => $self->uid } );
259   }
260
261   return "Can't delete an account which is a (svc_forward) source!"
262     if qsearch( 'svc_forward', { 'srcsvc' => $self->svcnum } );
263
264   return "Can't delete an account which is a (svc_forward) destination!"
265     if qsearch( 'svc_forward', { 'dstsvc' => $self->svcnum } );
266
267   return "Can't delete an account with (svc_www) web service!"
268     if qsearch( 'svc_www', { 'usersvc' => $self->usersvc } );
269
270   # what about records in session ? (they should refer to history table)
271
272   local $SIG{HUP} = 'IGNORE';
273   local $SIG{INT} = 'IGNORE';
274   local $SIG{QUIT} = 'IGNORE';
275   local $SIG{TERM} = 'IGNORE';
276   local $SIG{TSTP} = 'IGNORE';
277   local $SIG{PIPE} = 'IGNORE';
278
279   my $oldAutoCommit = $FS::UID::AutoCommit;
280   local $FS::UID::AutoCommit = 0;
281   my $dbh = dbh;
282
283   foreach my $cust_main_invoice (
284     qsearch( 'cust_main_invoice', { 'dest' => $self->svcnum } )
285   ) {
286     unless ( defined($cust_main_invoice) ) {
287       warn "WARNING: something's wrong with qsearch";
288       next;
289     }
290     my %hash = $cust_main_invoice->hash;
291     $hash{'dest'} = $self->email;
292     my $new = new FS::cust_main_invoice \%hash;
293     my $error = $new->replace($cust_main_invoice);
294     if ( $error ) {
295       $dbh->rollback if $oldAutoCommit;
296       return $error;
297     }
298   }
299
300   foreach my $svc_domain (
301     qsearch( 'svc_domain', { 'catchall' => $self->svcnum } )
302   ) {
303     my %hash = new FS::svc_domain->hash;
304     $hash{'catchall'} = '';
305     my $new = new FS::svc_domain \%hash;
306     my $error = $new->replace($svc_domain);
307     if ( $error ) {
308       $dbh->rollback if $oldAutoCommit;
309       return $error;
310     }
311   }
312
313   foreach my $radius_usergroup (
314     qsearch('radius_usergroup', { 'svcnum' => $self->svcnum } )
315   ) {
316     my $error = $radius_usergroup->delete;
317     if ( $error ) {
318       $dbh->rollback if $oldAutoCommit;
319       return $error;
320     }
321   }
322
323   my $part_svc = $self->cust_svc->part_svc;
324
325   my $error = $self->SUPER::delete;
326   if ( $error ) {
327     $dbh->rollback if $oldAutoCommit;
328     return $error;
329   }
330
331   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
332   '';
333 }
334
335 =item replace OLD_RECORD
336
337 Replaces OLD_RECORD with this one in the database.  If there is an error,
338 returns the error, otherwise returns false.
339
340 The additional field I<usergroup> can optionally be defined; if so it should
341 contain an arrayref of group names.  See L<FS::radius_usergroup>.  (used in
342 sqlradius export only)
343
344 =cut
345
346 sub replace {
347   my ( $new, $old ) = ( shift, shift );
348   my $error;
349
350   return "Username in use"
351     if $old->username ne $new->username &&
352       qsearchs( 'svc_acct', { 'username' => $new->username,
353                                'domsvc'   => $new->domsvc,
354                              } );
355   {
356     #no warnings 'numeric';  #alas, a 5.006-ism
357     local($^W) = 0;
358     return "Can't change uid!" if $old->uid != $new->uid;
359   }
360
361   #change homdir when we change username
362   $new->setfield('dir', '') if $old->username ne $new->username;
363
364   local $SIG{HUP} = 'IGNORE';
365   local $SIG{INT} = 'IGNORE';
366   local $SIG{QUIT} = 'IGNORE';
367   local $SIG{TERM} = 'IGNORE';
368   local $SIG{TSTP} = 'IGNORE';
369   local $SIG{PIPE} = 'IGNORE';
370
371   my $oldAutoCommit = $FS::UID::AutoCommit;
372   local $FS::UID::AutoCommit = 0;
373   my $dbh = dbh;
374
375   $error = $new->SUPER::replace($old);
376   if ( $error ) {
377     $dbh->rollback if $oldAutoCommit;
378     return $error if $error;
379   }
380
381   $old->usergroup( [ $old->radius_groups ] );
382   if ( $new->usergroup ) {
383     #(sorta) false laziness with FS::part_export::sqlradius::_export_replace
384     my @newgroups = @{$new->usergroup};
385     foreach my $oldgroup ( @{$old->usergroup} ) {
386       if ( grep { $oldgroup eq $_ } @newgroups ) {
387         @newgroups = grep { $oldgroup ne $_ } @newgroups;
388         next;
389       }
390       my $radius_usergroup = qsearchs('radius_usergroup', {
391         svcnum    => $old->svcnum,
392         groupname => $oldgroup,
393       } );
394       my $error = $radius_usergroup->delete;
395       if ( $error ) {
396         $dbh->rollback if $oldAutoCommit;
397         return "error deleting radius_usergroup $oldgroup: $error";
398       }
399     }
400
401     foreach my $newgroup ( @newgroups ) {
402       my $radius_usergroup = new FS::radius_usergroup ( {
403         svcnum    => $new->svcnum,
404         groupname => $newgroup,
405       } );
406       my $error = $radius_usergroup->insert;
407       if ( $error ) {
408         $dbh->rollback if $oldAutoCommit;
409         return "error adding radius_usergroup $newgroup: $error";
410       }
411     }
412
413   }
414
415   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
416   ''; #no error
417 }
418
419 =item suspend
420
421 Suspends this account by prefixing *SUSPENDED* to the password.  If there is an
422 error, returns the error, otherwise returns false.
423
424 Called by the suspend method of FS::cust_pkg (see L<FS::cust_pkg>).
425
426 =cut
427
428 sub suspend {
429   my $self = shift;
430   my %hash = $self->hash;
431   unless ( $hash{_password} =~ /^\*SUSPENDED\* /
432            || $hash{_password} eq '*'
433          ) {
434     $hash{_password} = '*SUSPENDED* '.$hash{_password};
435     my $new = new FS::svc_acct ( \%hash );
436     $new->replace($self);
437   } else {
438     ''; #no error (already suspended)
439   }
440 }
441
442 =item unsuspend
443
444 Unsuspends this account by removing *SUSPENDED* from the password.  If there is
445 an error, returns the error, otherwise returns false.
446
447 Called by the unsuspend method of FS::cust_pkg (see L<FS::cust_pkg>).
448
449 =cut
450
451 sub unsuspend {
452   my $self = shift;
453   my %hash = $self->hash;
454   if ( $hash{_password} =~ /^\*SUSPENDED\* (.*)$/ ) {
455     $hash{_password} = $1;
456     my $new = new FS::svc_acct ( \%hash );
457     $new->replace($self);
458   } else {
459     ''; #no error (already unsuspended)
460   }
461 }
462
463 =item cancel
464
465 Just returns false (no error) for now.
466
467 Called by the cancel method of FS::cust_pkg (see L<FS::cust_pkg>).
468
469 =item check
470
471 Checks all fields to make sure this is a valid service.  If there is an error,
472 returns the error, otherwise returns false.  Called by the insert and replace
473 methods.
474
475 Sets any fixed values; see L<FS::part_svc>.
476
477 =cut
478
479 sub check {
480   my $self = shift;
481
482   my($recref) = $self->hashref;
483
484   my $x = $self->setfixed;
485   return $x unless ref($x);
486   my $part_svc = $x;
487
488   if ( $part_svc->part_svc_column('usergroup')->columnflag eq "F" ) {
489     $self->usergroup(
490       [ split(',', $part_svc->part_svc_column('usergroup')->columnvalue) ] );
491   }
492
493   my $error = $self->ut_numbern('svcnum')
494               || $self->ut_number('domsvc')
495               || $self->ut_textn('sec_phrase')
496   ;
497   return $error if $error;
498
499   my $ulen = $usernamemax || $self->dbdef_table->column('username')->length;
500   if ( $username_uppercase ) {
501     $recref->{username} =~ /^([a-z0-9_\-\.\&]{$usernamemin,$ulen})$/i
502       or return gettext('illegal_username'). " ($usernamemin-$ulen): ". $recref->{username};
503     $recref->{username} = $1;
504   } else {
505     $recref->{username} =~ /^([a-z0-9_\-\.\&]{$usernamemin,$ulen})$/
506       or return gettext('illegal_username'). " ($usernamemin-$ulen): ". $recref->{username};
507     $recref->{username} = $1;
508   }
509
510   if ( $username_letterfirst ) {
511     $recref->{username} =~ /^[a-z]/ or return gettext('illegal_username');
512   } elsif ( $username_letter ) {
513     $recref->{username} =~ /[a-z]/ or return gettext('illegal_username');
514   }
515   if ( $username_noperiod ) {
516     $recref->{username} =~ /\./ and return gettext('illegal_username');
517   }
518   if ( $username_nounderscore ) {
519     $recref->{username} =~ /_/ and return gettext('illegal_username');
520   }
521   if ( $username_nodash ) {
522     $recref->{username} =~ /\-/ and return gettext('illegal_username');
523   }
524   unless ( $username_ampersand ) {
525     $recref->{username} =~ /\&/ and return gettext('illegal_username');
526   }
527
528   $recref->{popnum} =~ /^(\d*)$/ or return "Illegal popnum: ".$recref->{popnum};
529   $recref->{popnum} = $1;
530   return "Unknown popnum" unless
531     ! $recref->{popnum} ||
532     qsearchs('svc_acct_pop',{'popnum'=> $recref->{popnum} } );
533
534   unless ( $part_svc->part_svc_column('uid')->columnflag eq 'F' ) {
535
536     $recref->{uid} =~ /^(\d*)$/ or return "Illegal uid";
537     $recref->{uid} = $1 eq '' ? $self->unique('uid') : $1;
538
539     $recref->{gid} =~ /^(\d*)$/ or return "Illegal gid";
540     $recref->{gid} = $1 eq '' ? $recref->{uid} : $1;
541     #not all systems use gid=uid
542     #you can set a fixed gid in part_svc
543
544     return "Only root can have uid 0"
545       if $recref->{uid} == 0
546          && $recref->{username} ne 'root'
547          && $recref->{username} ne 'toor';
548
549 #    $error = $self->ut_textn('finger');
550 #    return $error if $error;
551     $self->getfield('finger') =~
552       /^([\w \t\!\@\#\$\%\&\(\)\-\+\;\:\'\"\,\.\?\/\*\<\>]*)$/
553         or return "Illegal finger: ". $self->getfield('finger');
554     $self->setfield('finger', $1);
555
556     $recref->{dir} =~ /^([\/\w\-\.\&]*)$/
557       or return "Illegal directory";
558     $recref->{dir} = $1;
559     return "Illegal directory"
560       if $recref->{dir} =~ /(^|\/)\.+(\/|$)/; #no .. component
561     return "Illegal directory"
562       if $recref->{dir} =~ /\&/ && ! $username_ampersand;
563     unless ( $recref->{dir} ) {
564       $recref->{dir} = $dir_prefix . '/';
565       if ( $dirhash > 0 ) {
566         for my $h ( 1 .. $dirhash ) {
567           $recref->{dir} .= substr($recref->{username}, $h-1, 1). '/';
568         }
569       } elsif ( $dirhash < 0 ) {
570         for my $h ( reverse $dirhash .. -1 ) {
571           $recref->{dir} .= substr($recref->{username}, $h, 1). '/';
572         }
573       }
574       $recref->{dir} .= $recref->{username};
575     ;
576     }
577
578     unless ( $recref->{username} eq 'sync' ) {
579       if ( grep $_ eq $recref->{shell}, @shells ) {
580         $recref->{shell} = (grep $_ eq $recref->{shell}, @shells)[0];
581       } else {
582         return "Illegal shell \`". $self->shell. "\'; ".
583                $conf->dir. "/shells contains: @shells";
584       }
585     } else {
586       $recref->{shell} = '/bin/sync';
587     }
588
589     $recref->{quota} =~ /^(\d*)$/ or return "Illegal quota (unimplemented)";
590     $recref->{quota} = $1;
591
592   } else {
593     $recref->{gid} ne '' ? 
594       return "Can't have gid without uid" : ( $recref->{gid}='' );
595     $recref->{finger} ne '' ? 
596       return "Can't have finger-name without uid" : ( $recref->{finger}='' );
597     $recref->{dir} ne '' ? 
598       return "Can't have directory without uid" : ( $recref->{dir}='' );
599     $recref->{shell} ne '' ? 
600       return "Can't have shell without uid" : ( $recref->{shell}='' );
601     $recref->{quota} ne '' ? 
602       return "Can't have quota without uid" : ( $recref->{quota}='' );
603   }
604
605   unless ( $part_svc->part_svc_column('slipip')->columnflag eq 'F' ) {
606     unless ( $recref->{slipip} eq '0e0' ) {
607       $recref->{slipip} =~ /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/
608         or return "Illegal slipip". $self->slipip;
609       $recref->{slipip} = $1;
610     } else {
611       $recref->{slipip} = '0e0';
612     }
613
614   }
615
616   #arbitrary RADIUS stuff; allow ut_textn for now
617   foreach ( grep /^radius_/, fields('svc_acct') ) {
618     $self->ut_textn($_);
619   }
620
621   #generate a password if it is blank
622   $recref->{_password} = join('',map($pw_set[ int(rand $#pw_set) ], (0..7) ) )
623     unless ( $recref->{_password} );
624
625   #if ( $recref->{_password} =~ /^((\*SUSPENDED\* )?)([^\t\n]{4,16})$/ ) {
626   if ( $recref->{_password} =~ /^((\*SUSPENDED\* )?)([^\t\n]{$passwordmin,$passwordmax})$/ ) {
627     $recref->{_password} = $1.$3;
628     #uncomment this to encrypt password immediately upon entry, or run
629     #bin/crypt_pw in cron to give new users a window during which their
630     #password is available to techs, for faxing, etc.  (also be aware of 
631     #radius issues!)
632     #$recref->{password} = $1.
633     #  crypt($3,$saltset[int(rand(64))].$saltset[int(rand(64))]
634     #;
635   } elsif ( $recref->{_password} =~ /^((\*SUSPENDED\* )?)([\w\.\/\$]{13,34})$/ ) {
636     $recref->{_password} = $1.$3;
637   } elsif ( $recref->{_password} eq '*' ) {
638     $recref->{_password} = '*';
639   } elsif ( $recref->{_password} eq '!!' ) {
640     $recref->{_password} = '!!';
641   } else {
642     #return "Illegal password";
643     return gettext('illegal_password'). "$passwordmin-$passwordmax".
644            FS::Msgcat::_gettext('illegal_password_characters').
645            ": ". $recref->{_password};
646   }
647
648   ''; #no error
649 }
650
651 =item radius
652
653 Depriciated, use radius_reply instead.
654
655 =cut
656
657 sub radius {
658   carp "FS::svc_acct::radius depriciated, use radius_reply";
659   $_[0]->radius_reply;
660 }
661
662 =item radius_reply
663
664 Returns key/value pairs, suitable for assigning to a hash, for any RADIUS
665 reply attributes of this record.
666
667 Note that this is now the preferred method for reading RADIUS attributes - 
668 accessing the columns directly is discouraged, as the column names are
669 expected to change in the future.
670
671 =cut
672
673 sub radius_reply { 
674   my $self = shift;
675   my %reply =
676     map {
677       /^(radius_(.*))$/;
678       my($column, $attrib) = ($1, $2);
679       #$attrib =~ s/_/\-/g;
680       ( $FS::raddb::attrib{lc($attrib)}, $self->getfield($column) );
681     } grep { /^radius_/ && $self->getfield($_) } fields( $self->table );
682   if ( $self->ip && $self->ip ne '0e0' ) {
683     $reply{'Framed-IP-Address'} = $self->ip;
684   }
685   %reply;
686 }
687
688 =item radius_check
689
690 Returns key/value pairs, suitable for assigning to a hash, for any RADIUS
691 check attributes of this record.
692
693 Note that this is now the preferred method for reading RADIUS attributes - 
694 accessing the columns directly is discouraged, as the column names are
695 expected to change in the future.
696
697 =cut
698
699 sub radius_check {
700   my $self = shift;
701   ( 'Password' => $self->_password,
702     map {
703       /^(rc_(.*))$/;
704       my($column, $attrib) = ($1, $2);
705       #$attrib =~ s/_/\-/g;
706       ( $FS::raddb::attrib{lc($attrib)}, $self->getfield($column) );
707     } grep { /^rc_/ && $self->getfield($_) } fields( $self->table )
708   );
709 }
710
711 =item domain
712
713 Returns the domain associated with this account.
714
715 =cut
716
717 sub domain {
718   my $self = shift;
719   if ( $self->domsvc ) {
720     #$self->svc_domain->domain;
721     my $svc_domain = $self->svc_domain
722       or die "no svc_domain.svcnum for svc_acct.domsvc ". $self->domsvc;
723     $svc_domain->domain;
724   } else {
725     $mydomain or die "svc_acct.domsvc is null and no legacy domain config file";
726   }
727 }
728
729 =item svc_domain
730
731 Returns the FS::svc_domain record for this account's domain (see
732 L<FS::svc_domain>.
733
734 =cut
735
736 sub svc_domain {
737   my $self = shift;
738   $self->{'_domsvc'}
739     ? $self->{'_domsvc'}
740     : qsearchs( 'svc_domain', { 'svcnum' => $self->domsvc } );
741 }
742
743 =item cust_svc
744
745 Returns the FS::cust_svc record for this account (see L<FS::cust_svc>).
746
747 sub cust_svc {
748   my $self = shift;
749   qsearchs( 'cust_svc', { 'svcnum' => $self->svcnum } );
750 }
751
752 =item email
753
754 Returns an email address associated with the account.
755
756 =cut
757
758 sub email {
759   my $self = shift;
760   $self->username. '@'. $self->domain;
761 }
762
763 =item seconds_since TIMESTAMP
764
765 Returns the number of seconds this account has been online since TIMESTAMP.
766 See L<FS::session>
767
768 TIMESTAMP is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
769 L<Time::Local> and L<Date::Parse> for conversion functions.
770
771 =cut
772
773 #note: POD here, implementation in FS::cust_svc
774 sub seconds_since {
775   my $self = shift;
776   $self->cust_svc->seconds_since(@_);
777 }
778
779 =item radius_groups
780
781 Returns all RADIUS groups for this account (see L<FS::radius_usergroup>).
782
783 =cut
784
785 sub radius_groups {
786   my $self = shift;
787   map { $_->groupname }
788     qsearch('radius_usergroup', { 'svcnum' => $self->svcnum } );
789 }
790
791 =back
792
793 =head1 SUBROUTINES
794
795 =item radius_usergroup_selector GROUPS_ARRAYREF [ SELECTNAME ]
796
797 =cut
798
799 sub radius_usergroup_selector {
800   my $sel_groups = shift;
801   my %sel_groups = map { $_=>1 } @$sel_groups;
802
803   my $selectname = shift || 'radius_usergroup';
804
805   my $dbh = dbh;
806   my $sth = $dbh->prepare(
807     'SELECT DISTINCT(groupname) FROM radius_usergroup ORDER BY groupname'
808   ) or die $dbh->errstr;
809   $sth->execute() or die $sth->errstr;
810   my @all_groups = map { $_->[0] } @{$sth->fetchall_arrayref};
811
812   my $html = <<END;
813     <SCRIPT>
814     function ${selectname}_doadd(object) {
815       var myvalue = object.${selectname}_add.value;
816       var optionName = new Option(myvalue,myvalue,false,true);
817       var length = object.$selectname.length;
818       object.$selectname.options[length] = optionName;
819       object.${selectname}_add.value = "";
820     }
821     </SCRIPT>
822     <SELECT MULTIPLE NAME="$selectname">
823 END
824
825   foreach my $group ( @all_groups ) {
826     $html .= '<OPTION';
827     if ( $sel_groups{$group} ) {
828       $html .= ' SELECTED';
829       $sel_groups{$group} = 0;
830     }
831     $html .= ">$group</OPTION>\n";
832   }
833   foreach my $group ( grep { $sel_groups{$_} } keys %sel_groups ) {
834     $html .= "<OPTION SELECTED>$group</OPTION>\n";
835   };
836   $html .= '</SELECT>';
837
838   $html .= qq!<BR><INPUT TYPE="text" NAME="${selectname}_add">!.
839            qq!<INPUT TYPE="button" VALUE="Add new group" onClick="${selectname}_doadd(this.form)">!;
840
841   $html;
842 }
843
844 =head1 BUGS
845
846 The $recref stuff in sub check should be cleaned up.
847
848 The suspend, unsuspend and cancel methods update the database, but not the
849 current object.  This is probably a bug as it's unexpected and
850 counterintuitive.
851
852 radius_usergroup_selector?  putting web ui components in here?  they should
853 probably live somewhere else...
854
855 =head1 SEE ALSO
856
857 L<FS::svc_Common>, edit/part_svc.cgi from an installed web interface,
858 export.html from the base documentation, L<FS::Record>, L<FS::Conf>,
859 L<FS::cust_svc>, L<FS::part_svc>, L<FS::cust_pkg>, L<FS::queue>,
860 L<freeside-queued>), L<Net::SSH>, L<ssh>, L<FS::svc_acct_pop>,
861 schema.html from the base documentation.
862
863 =cut
864
865 1;
866