4 use vars qw( @ISA $DEBUG $me $conf $skip_fuzzyfiles
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 $username_percent
10 $password_noampersand $password_noexclamation
11 $welcome_template $welcome_from $welcome_subject $welcome_mimetype
13 $radius_password $radius_ip
19 use Crypt::PasswdMD5 1.2;
21 use FS::UID qw( datasrc );
23 use FS::Record qw( qsearch qsearchs fields dbh dbdef );
24 use FS::Msgcat qw(gettext);
29 use FS::cust_main_invoice;
33 use FS::radius_usergroup;
40 @ISA = qw( FS::svc_Common );
43 $me = '[FS::svc_acct]';
45 #ask FS::UID to run this stuff for us later
46 $FS::UID::callback{'FS::svc_acct'} = sub {
48 $dir_prefix = $conf->config('home');
49 @shells = $conf->config('shells');
50 $usernamemin = $conf->config('usernamemin') || 2;
51 $usernamemax = $conf->config('usernamemax');
52 $passwordmin = $conf->config('passwordmin') || 6;
53 $passwordmax = $conf->config('passwordmax') || 8;
54 $username_letter = $conf->exists('username-letter');
55 $username_letterfirst = $conf->exists('username-letterfirst');
56 $username_noperiod = $conf->exists('username-noperiod');
57 $username_nounderscore = $conf->exists('username-nounderscore');
58 $username_nodash = $conf->exists('username-nodash');
59 $username_uppercase = $conf->exists('username-uppercase');
60 $username_ampersand = $conf->exists('username-ampersand');
61 $username_percent = $conf->exists('username-percent');
62 $password_noampersand = $conf->exists('password-noexclamation');
63 $password_noexclamation = $conf->exists('password-noexclamation');
64 $dirhash = $conf->config('dirhash') || 0;
65 if ( $conf->exists('welcome_email') ) {
66 $welcome_template = new Text::Template (
68 SOURCE => [ map "$_\n", $conf->config('welcome_email') ]
69 ) or warn "can't create welcome email template: $Text::Template::ERROR";
70 $welcome_from = $conf->config('welcome_email-from'); # || 'your-isp-is-dum'
71 $welcome_subject = $conf->config('welcome_email-subject') || 'Welcome';
72 $welcome_mimetype = $conf->config('welcome_email-mimetype') || 'text/plain';
74 $welcome_template = '';
76 $welcome_subject = '';
77 $welcome_mimetype = '';
79 $smtpmachine = $conf->config('smtpmachine');
80 $radius_password = $conf->config('radius-password') || 'Password';
81 $radius_ip = $conf->config('radius-ip') || 'Framed-IP-Address';
84 @saltset = ( 'a'..'z' , 'A'..'Z' , '0'..'9' , '.' , '/' );
85 @pw_set = ( 'a'..'z', 'A'..'Z', '0'..'9', '(', ')', '#', '!', '.', ',' );
89 my ( $hashref, $cache ) = @_;
90 if ( $hashref->{'svc_acct_svcnum'} ) {
91 $self->{'_domsvc'} = FS::svc_domain->new( {
92 'svcnum' => $hashref->{'domsvc'},
93 'domain' => $hashref->{'svc_acct_domain'},
94 'catchall' => $hashref->{'svc_acct_catchall'},
101 FS::svc_acct - Object methods for svc_acct records
107 $record = new FS::svc_acct \%hash;
108 $record = new FS::svc_acct { 'column' => 'value' };
110 $error = $record->insert;
112 $error = $new_record->replace($old_record);
114 $error = $record->delete;
116 $error = $record->check;
118 $error = $record->suspend;
120 $error = $record->unsuspend;
122 $error = $record->cancel;
124 %hash = $record->radius;
126 %hash = $record->radius_reply;
128 %hash = $record->radius_check;
130 $domain = $record->domain;
132 $svc_domain = $record->svc_domain;
134 $email = $record->email;
136 $seconds_since = $record->seconds_since($timestamp);
140 An FS::svc_acct object represents an account. FS::svc_acct inherits from
141 FS::svc_Common. The following fields are currently supported:
145 =item svcnum - primary key (assigned automatcially for new accounts)
149 =item _password - generated if blank
151 =item sec_phrase - security phrase
153 =item popnum - Point of presence (see L<FS::svc_acct_pop>)
161 =item dir - set automatically if blank (and uid is not)
165 =item quota - (unimplementd)
167 =item slipip - IP address
171 =item domsvc - svcnum from svc_domain
173 =item radius_I<Radius_Attribute> - I<Radius-Attribute> (reply)
175 =item rc_I<Radius_Attribute> - I<Radius-Attribute> (check)
185 Creates a new account. To add the account to the database, see L<"insert">.
189 sub table { 'svc_acct'; }
192 #false laziness with edit/svc_acct.cgi
194 my $usergroup = shift;
195 if ( ref($usergroup) eq 'ARRAY' ) {
197 } elsif ( length($usergroup) ) {
198 [ split(/\s*,\s*/, $usergroup) ];
205 =item insert [ , OPTION => VALUE ... ]
207 Adds this account to the database. If there is an error, returns the error,
208 otherwise returns false.
210 The additional fields pkgnum and svcpart (see L<FS::cust_svc>) should be
211 defined. An FS::cust_svc record will be created and inserted.
213 The additional field I<usergroup> can optionally be defined; if so it should
214 contain an arrayref of group names. See L<FS::radius_usergroup>.
216 The additional field I<child_objects> can optionally be defined; if so it
217 should contain an arrayref of FS::tablename objects. They will have their
218 svcnum fields set and will be inserted after this record, but before any
219 exports are run. Each element of the array can also optionally be a
220 two-element array reference containing the child object and the name of an
221 alternate field to be filled in with the newly-inserted svcnum, for example
222 C<[ $svc_forward, 'srcsvc' ]>
224 Currently available options are: I<depend_jobnum>
226 If I<depend_jobnum> is set (to a scalar jobnum or an array reference of
227 jobnums), all provisioning jobs will have a dependancy on the supplied
228 jobnum(s) (they will not run until the specific job(s) complete(s)).
230 (TODOC: L<FS::queue> and L<freeside-queued>)
232 (TODOC: new exports!)
241 warn "[$me] insert called on $self: ". Dumper($self).
242 "\nwith options: ". Dumper(%options);
245 local $SIG{HUP} = 'IGNORE';
246 local $SIG{INT} = 'IGNORE';
247 local $SIG{QUIT} = 'IGNORE';
248 local $SIG{TERM} = 'IGNORE';
249 local $SIG{TSTP} = 'IGNORE';
250 local $SIG{PIPE} = 'IGNORE';
252 my $oldAutoCommit = $FS::UID::AutoCommit;
253 local $FS::UID::AutoCommit = 0;
256 my $error = $self->check;
257 return $error if $error;
259 if ( $self->svcnum && qsearchs('cust_svc',{'svcnum'=>$self->svcnum}) ) {
260 my $cust_svc = qsearchs('cust_svc',{'svcnum'=>$self->svcnum});
261 unless ( $cust_svc ) {
262 $dbh->rollback if $oldAutoCommit;
263 return "no cust_svc record found for svcnum ". $self->svcnum;
265 $self->pkgnum($cust_svc->pkgnum);
266 $self->svcpart($cust_svc->svcpart);
269 $error = $self->_check_duplicate;
271 $dbh->rollback if $oldAutoCommit;
276 $error = $self->SUPER::insert(
277 'jobnums' => \@jobnums,
278 'child_objects' => $self->child_objects,
282 $dbh->rollback if $oldAutoCommit;
286 if ( $self->usergroup ) {
287 foreach my $groupname ( @{$self->usergroup} ) {
288 my $radius_usergroup = new FS::radius_usergroup ( {
289 svcnum => $self->svcnum,
290 groupname => $groupname,
292 my $error = $radius_usergroup->insert;
294 $dbh->rollback if $oldAutoCommit;
300 unless ( $skip_fuzzyfiles ) {
301 $error = $self->queue_fuzzyfiles_update;
303 $dbh->rollback if $oldAutoCommit;
304 return "updating fuzzy search cache: $error";
308 my $cust_pkg = $self->cust_svc->cust_pkg;
311 my $cust_main = $cust_pkg->cust_main;
313 if ( $conf->exists('emailinvoiceauto') ) {
314 my @invoicing_list = $cust_main->invoicing_list;
315 push @invoicing_list, $self->email;
316 $cust_main->invoicing_list(\@invoicing_list);
321 if ( $welcome_template && $cust_pkg ) {
322 my $to = join(', ', grep { $_ !~ /^(POST|FAX)$/ } $cust_main->invoicing_list );
324 my $wqueue = new FS::queue {
325 'svcnum' => $self->svcnum,
326 'job' => 'FS::svc_acct::send_email'
328 my $error = $wqueue->insert(
330 'from' => $welcome_from,
331 'subject' => $welcome_subject,
332 'mimetype' => $welcome_mimetype,
333 'body' => $welcome_template->fill_in( HASH => {
334 'custnum' => $self->custnum,
335 'username' => $self->username,
336 'password' => $self->_password,
337 'first' => $cust_main->first,
338 'last' => $cust_main->getfield('last'),
339 'pkg' => $cust_pkg->part_pkg->pkg,
343 $dbh->rollback if $oldAutoCommit;
344 return "error queuing welcome email: $error";
347 if ( $options{'depend_jobnum'} ) {
348 warn "$me depend_jobnum found; adding to welcome email dependancies"
350 if ( ref($options{'depend_jobnum'}) ) {
351 warn "$me adding jobs ". join(', ', @{$options{'depend_jobnum'}} ).
352 "to welcome email dependancies"
354 push @jobnums, @{ $options{'depend_jobnum'} };
356 warn "$me adding job $options{'depend_jobnum'} ".
357 "to welcome email dependancies"
359 push @jobnums, $options{'depend_jobnum'};
363 foreach my $jobnum ( @jobnums ) {
364 my $error = $wqueue->depend_insert($jobnum);
366 $dbh->rollback if $oldAutoCommit;
367 return "error queuing welcome email job dependancy: $error";
377 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
383 Deletes this account from the database. If there is an error, returns the
384 error, otherwise returns false.
386 The corresponding FS::cust_svc record will be deleted as well.
388 (TODOC: new exports!)
395 return "can't delete system account" if $self->_check_system;
397 return "Can't delete an account which is a (svc_forward) source!"
398 if qsearch( 'svc_forward', { 'srcsvc' => $self->svcnum } );
400 return "Can't delete an account which is a (svc_forward) destination!"
401 if qsearch( 'svc_forward', { 'dstsvc' => $self->svcnum } );
403 return "Can't delete an account with (svc_www) web service!"
404 if qsearch( 'svc_www', { 'usersvc' => $self->svcnum } );
406 # what about records in session ? (they should refer to history table)
408 local $SIG{HUP} = 'IGNORE';
409 local $SIG{INT} = 'IGNORE';
410 local $SIG{QUIT} = 'IGNORE';
411 local $SIG{TERM} = 'IGNORE';
412 local $SIG{TSTP} = 'IGNORE';
413 local $SIG{PIPE} = 'IGNORE';
415 my $oldAutoCommit = $FS::UID::AutoCommit;
416 local $FS::UID::AutoCommit = 0;
419 foreach my $cust_main_invoice (
420 qsearch( 'cust_main_invoice', { 'dest' => $self->svcnum } )
422 unless ( defined($cust_main_invoice) ) {
423 warn "WARNING: something's wrong with qsearch";
426 my %hash = $cust_main_invoice->hash;
427 $hash{'dest'} = $self->email;
428 my $new = new FS::cust_main_invoice \%hash;
429 my $error = $new->replace($cust_main_invoice);
431 $dbh->rollback if $oldAutoCommit;
436 foreach my $svc_domain (
437 qsearch( 'svc_domain', { 'catchall' => $self->svcnum } )
439 my %hash = new FS::svc_domain->hash;
440 $hash{'catchall'} = '';
441 my $new = new FS::svc_domain \%hash;
442 my $error = $new->replace($svc_domain);
444 $dbh->rollback if $oldAutoCommit;
449 foreach my $radius_usergroup (
450 qsearch('radius_usergroup', { 'svcnum' => $self->svcnum } )
452 my $error = $radius_usergroup->delete;
454 $dbh->rollback if $oldAutoCommit;
459 my $error = $self->SUPER::delete;
461 $dbh->rollback if $oldAutoCommit;
465 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
469 =item replace OLD_RECORD
471 Replaces OLD_RECORD with this one in the database. If there is an error,
472 returns the error, otherwise returns false.
474 The additional field I<usergroup> can optionally be defined; if so it should
475 contain an arrayref of group names. See L<FS::radius_usergroup>.
481 my ( $new, $old ) = ( shift, shift );
483 warn "$me replacing $old with $new\n" if $DEBUG;
485 return "can't modify system account" if $old->_check_system;
488 #no warnings 'numeric'; #alas, a 5.006-ism
491 foreach my $xid (qw( uid gid )) {
493 return "Can't change $xid!"
494 if ! $conf->exists("svc_acct-edit_$xid")
495 && $old->$xid() != $new->$xid()
496 && $new->cust_svc->part_svc->part_svc_column($xid)->columnflag ne 'F'
501 #change homdir when we change username
502 $new->setfield('dir', '') if $old->username ne $new->username;
504 local $SIG{HUP} = 'IGNORE';
505 local $SIG{INT} = 'IGNORE';
506 local $SIG{QUIT} = 'IGNORE';
507 local $SIG{TERM} = 'IGNORE';
508 local $SIG{TSTP} = 'IGNORE';
509 local $SIG{PIPE} = 'IGNORE';
511 my $oldAutoCommit = $FS::UID::AutoCommit;
512 local $FS::UID::AutoCommit = 0;
515 # redundant, but so $new->usergroup gets set
516 $error = $new->check;
517 return $error if $error;
519 $old->usergroup( [ $old->radius_groups ] );
521 warn $old->email. " old groups: ". join(' ',@{$old->usergroup}). "\n";
522 warn $new->email. "new groups: ". join(' ',@{$new->usergroup}). "\n";
524 if ( $new->usergroup ) {
525 #(sorta) false laziness with FS::part_export::sqlradius::_export_replace
526 my @newgroups = @{$new->usergroup};
527 foreach my $oldgroup ( @{$old->usergroup} ) {
528 if ( grep { $oldgroup eq $_ } @newgroups ) {
529 @newgroups = grep { $oldgroup ne $_ } @newgroups;
532 my $radius_usergroup = qsearchs('radius_usergroup', {
533 svcnum => $old->svcnum,
534 groupname => $oldgroup,
536 my $error = $radius_usergroup->delete;
538 $dbh->rollback if $oldAutoCommit;
539 return "error deleting radius_usergroup $oldgroup: $error";
543 foreach my $newgroup ( @newgroups ) {
544 my $radius_usergroup = new FS::radius_usergroup ( {
545 svcnum => $new->svcnum,
546 groupname => $newgroup,
548 my $error = $radius_usergroup->insert;
550 $dbh->rollback if $oldAutoCommit;
551 return "error adding radius_usergroup $newgroup: $error";
557 if ( $old->username ne $new->username || $old->domsvc != $new->domsvc ) {
558 $new->svcpart( $new->cust_svc->svcpart ) unless $new->svcpart;
559 $error = $new->_check_duplicate;
561 $dbh->rollback if $oldAutoCommit;
566 $error = $new->SUPER::replace($old);
568 $dbh->rollback if $oldAutoCommit;
569 return $error if $error;
572 if ( $new->username ne $old->username && ! $skip_fuzzyfiles ) {
573 $error = $new->queue_fuzzyfiles_update;
575 $dbh->rollback if $oldAutoCommit;
576 return "updating fuzzy search cache: $error";
580 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
584 =item queue_fuzzyfiles_update
586 Used by insert & replace to update the fuzzy search cache
590 sub queue_fuzzyfiles_update {
593 local $SIG{HUP} = 'IGNORE';
594 local $SIG{INT} = 'IGNORE';
595 local $SIG{QUIT} = 'IGNORE';
596 local $SIG{TERM} = 'IGNORE';
597 local $SIG{TSTP} = 'IGNORE';
598 local $SIG{PIPE} = 'IGNORE';
600 my $oldAutoCommit = $FS::UID::AutoCommit;
601 local $FS::UID::AutoCommit = 0;
604 my $queue = new FS::queue {
605 'svcnum' => $self->svcnum,
606 'job' => 'FS::svc_acct::append_fuzzyfiles'
608 my $error = $queue->insert($self->username);
610 $dbh->rollback if $oldAutoCommit;
611 return "queueing job (transaction rolled back): $error";
614 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
622 Suspends this account by calling export-specific suspend hooks. If there is
623 an error, returns the error, otherwise returns false.
625 Called by the suspend method of FS::cust_pkg (see L<FS::cust_pkg>).
631 return "can't suspend system account" if $self->_check_system;
632 $self->SUPER::suspend;
637 Unsuspends this account by by calling export-specific suspend hooks. If there
638 is an error, returns the error, otherwise returns false.
640 Called by the unsuspend method of FS::cust_pkg (see L<FS::cust_pkg>).
646 my %hash = $self->hash;
647 if ( $hash{_password} =~ /^\*SUSPENDED\* (.*)$/ ) {
648 $hash{_password} = $1;
649 my $new = new FS::svc_acct ( \%hash );
650 my $error = $new->replace($self);
651 return $error if $error;
654 $self->SUPER::unsuspend;
659 Called by the cancel method of FS::cust_pkg (see L<FS::cust_pkg>).
661 If the B<auto_unset_catchall> configuration option is set, this method will
662 automatically remove any references to the canceled service in the catchall
663 field of svc_domain. This allows packages that contain both a svc_domain and
664 its catchall svc_acct to be canceled in one step.
669 # Only one thing to do at this level
671 foreach my $svc_domain (
672 qsearch( 'svc_domain', { catchall => $self->svcnum } ) ) {
673 if($conf->exists('auto_unset_catchall')) {
674 my %hash = $svc_domain->hash;
675 $hash{catchall} = '';
676 my $new = new FS::svc_domain ( \%hash );
677 my $error = $new->replace($svc_domain);
678 return $error if $error;
680 return "cannot unprovision svc_acct #".$self->svcnum.
681 " while assigned as catchall for svc_domain #".$svc_domain->svcnum;
685 $self->SUPER::cancel;
691 Checks all fields to make sure this is a valid service. If there is an error,
692 returns the error, otherwise returns false. Called by the insert and replace
695 Sets any fixed values; see L<FS::part_svc>.
702 my($recref) = $self->hashref;
704 my $x = $self->setfixed;
705 return $x unless ref($x);
708 if ( $part_svc->part_svc_column('usergroup')->columnflag eq "F" ) {
710 [ split(',', $part_svc->part_svc_column('usergroup')->columnvalue) ] );
713 my $error = $self->ut_numbern('svcnum')
714 #|| $self->ut_number('domsvc')
715 || $self->ut_foreign_key('domsvc', 'svc_domain', 'svcnum' )
716 || $self->ut_textn('sec_phrase')
718 return $error if $error;
720 my $ulen = $usernamemax || $self->dbdef_table->column('username')->length;
721 if ( $username_uppercase ) {
722 $recref->{username} =~ /^([a-z0-9_\-\.\&\%]{$usernamemin,$ulen})$/i
723 or return gettext('illegal_username'). " ($usernamemin-$ulen): ". $recref->{username};
724 $recref->{username} = $1;
726 $recref->{username} =~ /^([a-z0-9_\-\.\&\%]{$usernamemin,$ulen})$/
727 or return gettext('illegal_username'). " ($usernamemin-$ulen): ". $recref->{username};
728 $recref->{username} = $1;
731 if ( $username_letterfirst ) {
732 $recref->{username} =~ /^[a-z]/ or return gettext('illegal_username');
733 } elsif ( $username_letter ) {
734 $recref->{username} =~ /[a-z]/ or return gettext('illegal_username');
736 if ( $username_noperiod ) {
737 $recref->{username} =~ /\./ and return gettext('illegal_username');
739 if ( $username_nounderscore ) {
740 $recref->{username} =~ /_/ and return gettext('illegal_username');
742 if ( $username_nodash ) {
743 $recref->{username} =~ /\-/ and return gettext('illegal_username');
745 unless ( $username_ampersand ) {
746 $recref->{username} =~ /\&/ and return gettext('illegal_username');
748 if ( $password_noampersand ) {
749 $recref->{_password} =~ /\&/ and return gettext('illegal_password');
751 if ( $password_noexclamation ) {
752 $recref->{_password} =~ /\!/ and return gettext('illegal_password');
754 unless ( $username_percent ) {
755 $recref->{username} =~ /\%/ and return gettext('illegal_username');
758 $recref->{popnum} =~ /^(\d*)$/ or return "Illegal popnum: ".$recref->{popnum};
759 $recref->{popnum} = $1;
760 return "Unknown popnum" unless
761 ! $recref->{popnum} ||
762 qsearchs('svc_acct_pop',{'popnum'=> $recref->{popnum} } );
764 unless ( $part_svc->part_svc_column('uid')->columnflag eq 'F' ) {
766 $recref->{uid} =~ /^(\d*)$/ or return "Illegal uid";
767 $recref->{uid} = $1 eq '' ? $self->unique('uid') : $1;
769 $recref->{gid} =~ /^(\d*)$/ or return "Illegal gid";
770 $recref->{gid} = $1 eq '' ? $recref->{uid} : $1;
771 #not all systems use gid=uid
772 #you can set a fixed gid in part_svc
774 return "Only root can have uid 0"
775 if $recref->{uid} == 0
776 && $recref->{username} !~ /^(root|toor|smtp)$/;
778 unless ( $recref->{username} eq 'sync' ) {
779 if ( grep $_ eq $recref->{shell}, @shells ) {
780 $recref->{shell} = (grep $_ eq $recref->{shell}, @shells)[0];
782 return "Illegal shell \`". $self->shell. "\'; ".
783 $conf->dir. "/shells contains: @shells";
786 $recref->{shell} = '/bin/sync';
790 $recref->{gid} ne '' ?
791 return "Can't have gid without uid" : ( $recref->{gid}='' );
792 #$recref->{dir} ne '' ?
793 # return "Can't have directory without uid" : ( $recref->{dir}='' );
794 $recref->{shell} ne '' ?
795 return "Can't have shell without uid" : ( $recref->{shell}='' );
798 unless ( $part_svc->part_svc_column('dir')->columnflag eq 'F' ) {
800 $recref->{dir} =~ /^([\/\w\-\.\&]*)$/
801 or return "Illegal directory: ". $recref->{dir};
803 return "Illegal directory"
804 if $recref->{dir} =~ /(^|\/)\.+(\/|$)/; #no .. component
805 return "Illegal directory"
806 if $recref->{dir} =~ /\&/ && ! $username_ampersand;
807 unless ( $recref->{dir} ) {
808 $recref->{dir} = $dir_prefix . '/';
809 if ( $dirhash > 0 ) {
810 for my $h ( 1 .. $dirhash ) {
811 $recref->{dir} .= substr($recref->{username}, $h-1, 1). '/';
813 } elsif ( $dirhash < 0 ) {
814 for my $h ( reverse $dirhash .. -1 ) {
815 $recref->{dir} .= substr($recref->{username}, $h, 1). '/';
818 $recref->{dir} .= $recref->{username};
824 # $error = $self->ut_textn('finger');
825 # return $error if $error;
826 if ( $self->getfield('finger') eq '' ) {
827 my $cust_pkg = $self->svcnum
828 ? $self->cust_svc->cust_pkg
829 : qsearchs('cust_pkg', { 'pkgnum' => $self->getfield('pkgnum') } );
831 my $cust_main = $cust_pkg->cust_main;
832 $self->setfield('finger', $cust_main->first.' '.$cust_main->get('last') );
835 $self->getfield('finger') =~
836 /^([\w \t\!\@\#\$\%\&\(\)\-\+\;\'\"\,\.\?\/\*\<\>]*)$/
837 or return "Illegal finger: ". $self->getfield('finger');
838 $self->setfield('finger', $1);
840 $recref->{quota} =~ /^(\w*)$/ or return "Illegal quota";
841 $recref->{quota} = $1;
843 unless ( $part_svc->part_svc_column('slipip')->columnflag eq 'F' ) {
844 if ( $recref->{slipip} eq '' ) {
845 $recref->{slipip} = '';
846 } elsif ( $recref->{slipip} eq '0e0' ) {
847 $recref->{slipip} = '0e0';
849 $recref->{slipip} =~ /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/
850 or return "Illegal slipip: ". $self->slipip;
851 $recref->{slipip} = $1;
856 #arbitrary RADIUS stuff; allow ut_textn for now
857 foreach ( grep /^radius_/, fields('svc_acct') ) {
861 #generate a password if it is blank
862 $recref->{_password} = join('',map($pw_set[ int(rand $#pw_set) ], (0..7) ) )
863 unless ( $recref->{_password} );
865 #if ( $recref->{_password} =~ /^((\*SUSPENDED\* )?)([^\t\n]{4,16})$/ ) {
866 if ( $recref->{_password} =~ /^((\*SUSPENDED\* )?)([^\t\n]{$passwordmin,$passwordmax})$/ ) {
867 $recref->{_password} = $1.$3;
868 #uncomment this to encrypt password immediately upon entry, or run
869 #bin/crypt_pw in cron to give new users a window during which their
870 #password is available to techs, for faxing, etc. (also be aware of
872 #$recref->{password} = $1.
873 # crypt($3,$saltset[int(rand(64))].$saltset[int(rand(64))]
875 } elsif ( $recref->{_password} =~ /^((\*SUSPENDED\* )?)([\w\.\/\$\;\+]{13,60})$/ ) {
876 $recref->{_password} = $1.$3;
877 } elsif ( $recref->{_password} eq '*' ) {
878 $recref->{_password} = '*';
879 } elsif ( $recref->{_password} eq '!' ) {
880 $recref->{_password} = '!';
881 } elsif ( $recref->{_password} eq '!!' ) {
882 $recref->{_password} = '!!';
884 #return "Illegal password";
885 return gettext('illegal_password'). " $passwordmin-$passwordmax ".
886 FS::Msgcat::_gettext('illegal_password_characters').
887 ": ". $recref->{_password};
895 Internal function to check the username against the list of system usernames
896 from the I<system_usernames> configuration value. Returns true if the username
897 is listed on the system username list.
903 scalar( grep { $self->username eq $_ || $self->email eq $_ }
904 $conf->config('system_usernames')
908 =item _check_duplicate
910 Internal function to check for duplicates usernames, username@domain pairs and
913 If the I<global_unique-username> configuration value is set to B<username> or
914 B<username@domain>, enforces global username or username@domain uniqueness.
916 In all cases, check for duplicate uids and usernames or username@domain pairs
917 per export and with identical I<svcpart> values.
921 sub _check_duplicate {
924 my $global_unique = $conf->config('global_unique-username') || 'none';
925 return '' if $global_unique eq 'disabled';
927 #this is Pg-specific. what to do for mysql etc?
928 # ( mysql LOCK TABLES certainly isn't equivalent or useful here :/ )
929 warn "$me locking svc_acct table for duplicate search" if $DEBUG;
930 dbh->do("LOCK TABLE svc_acct IN SHARE ROW EXCLUSIVE MODE")
932 warn "$me acquired svc_acct table lock for duplicate search" if $DEBUG;
934 my $part_svc = qsearchs('part_svc', { 'svcpart' => $self->svcpart } );
935 unless ( $part_svc ) {
936 return 'unknown svcpart '. $self->svcpart;
939 my @dup_user = grep { !$self->svcnum || $_->svcnum != $self->svcnum }
940 qsearch( 'svc_acct', { 'username' => $self->username } );
941 return gettext('username_in_use')
942 if $global_unique eq 'username' && @dup_user;
944 my @dup_userdomain = grep { !$self->svcnum || $_->svcnum != $self->svcnum }
945 qsearch( 'svc_acct', { 'username' => $self->username,
946 'domsvc' => $self->domsvc } );
947 return gettext('username_in_use')
948 if $global_unique eq 'username@domain' && @dup_userdomain;
951 if ( $part_svc->part_svc_column('uid')->columnflag ne 'F'
952 && $self->username !~ /^(toor|(hyla)?fax)$/ ) {
953 @dup_uid = grep { !$self->svcnum || $_->svcnum != $self->svcnum }
954 qsearch( 'svc_acct', { 'uid' => $self->uid } );
959 if ( @dup_user || @dup_userdomain || @dup_uid ) {
960 my $exports = FS::part_export::export_info('svc_acct');
961 my %conflict_user_svcpart;
962 my %conflict_userdomain_svcpart = ( $self->svcpart => 'SELF', );
964 foreach my $part_export ( $part_svc->part_export ) {
966 #this will catch to the same exact export
967 my @svcparts = map { $_->svcpart } $part_export->export_svc;
969 #this will catch to exports w/same exporthost+type ???
970 #my @other_part_export = qsearch('part_export', {
971 # 'machine' => $part_export->machine,
972 # 'exporttype' => $part_export->exporttype,
974 #foreach my $other_part_export ( @other_part_export ) {
975 # push @svcparts, map { $_->svcpart }
976 # qsearch('export_svc', { 'exportnum' => $part_export->exportnum });
979 #my $nodomain = $exports->{$part_export->exporttype}{'nodomain'};
980 #silly kludge to avoid uninitialized value errors
981 my $nodomain = exists( $exports->{$part_export->exporttype}{'nodomain'} )
982 ? $exports->{$part_export->exporttype}{'nodomain'}
984 if ( $nodomain =~ /^Y/i ) {
985 $conflict_user_svcpart{$_} = $part_export->exportnum
988 $conflict_userdomain_svcpart{$_} = $part_export->exportnum
993 foreach my $dup_user ( @dup_user ) {
994 my $dup_svcpart = $dup_user->cust_svc->svcpart;
995 if ( exists($conflict_user_svcpart{$dup_svcpart}) ) {
996 return "duplicate username: conflicts with svcnum ". $dup_user->svcnum.
997 " via exportnum ". $conflict_user_svcpart{$dup_svcpart};
1001 foreach my $dup_userdomain ( @dup_userdomain ) {
1002 my $dup_svcpart = $dup_userdomain->cust_svc->svcpart;
1003 if ( exists($conflict_userdomain_svcpart{$dup_svcpart}) ) {
1004 return "duplicate username\@domain: conflicts with svcnum ".
1005 $dup_userdomain->svcnum. " via exportnum ".
1006 $conflict_userdomain_svcpart{$dup_svcpart};
1010 foreach my $dup_uid ( @dup_uid ) {
1011 my $dup_svcpart = $dup_uid->cust_svc->svcpart;
1012 if ( exists($conflict_user_svcpart{$dup_svcpart})
1013 || exists($conflict_userdomain_svcpart{$dup_svcpart}) ) {
1014 return "duplicate uid: conflicts with svcnum ". $dup_uid->svcnum.
1015 " via exportnum ". $conflict_user_svcpart{$dup_svcpart}
1016 || $conflict_userdomain_svcpart{$dup_svcpart};
1028 Depriciated, use radius_reply instead.
1033 carp "FS::svc_acct::radius depriciated, use radius_reply";
1034 $_[0]->radius_reply;
1039 Returns key/value pairs, suitable for assigning to a hash, for any RADIUS
1040 reply attributes of this record.
1042 Note that this is now the preferred method for reading RADIUS attributes -
1043 accessing the columns directly is discouraged, as the column names are
1044 expected to change in the future.
1051 return %{ $self->{'radius_reply'} }
1052 if exists $self->{'radius_reply'};
1057 my($column, $attrib) = ($1, $2);
1058 #$attrib =~ s/_/\-/g;
1059 ( $FS::raddb::attrib{lc($attrib)}, $self->getfield($column) );
1060 } grep { /^radius_/ && $self->getfield($_) } fields( $self->table );
1062 if ( $self->slipip && $self->slipip ne '0e0' ) {
1063 $reply{$radius_ip} = $self->slipip;
1066 if ( $self->seconds !~ /^$/ ) {
1067 $reply{'Session-Timeout'} = $self->seconds;
1075 Returns key/value pairs, suitable for assigning to a hash, for any RADIUS
1076 check attributes of this record.
1078 Note that this is now the preferred method for reading RADIUS attributes -
1079 accessing the columns directly is discouraged, as the column names are
1080 expected to change in the future.
1087 return %{ $self->{'radius_check'} }
1088 if exists $self->{'radius_check'};
1093 my($column, $attrib) = ($1, $2);
1094 #$attrib =~ s/_/\-/g;
1095 ( $FS::raddb::attrib{lc($attrib)}, $self->getfield($column) );
1096 } grep { /^rc_/ && $self->getfield($_) } fields( $self->table );
1098 my $password = $self->_password;
1099 my $pw_attrib = length($password) <= 12 ? $radius_password : 'Crypt-Password'; $check{$pw_attrib} = $password;
1101 my $cust_svc = $self->cust_svc;
1102 die "FATAL: no cust_svc record for svc_acct.svcnum ". $self->svcnum. "\n"
1104 my $cust_pkg = $cust_svc->cust_pkg;
1105 if ( $cust_pkg && $cust_pkg->part_pkg->is_prepaid && $cust_pkg->bill ) {
1106 $check{'Expiration'} = time2str('%B %e %Y %T', $cust_pkg->bill ); #http://lists.cistron.nl/pipermail/freeradius-users/2005-January/040184.html
1115 This method instructs the object to "snapshot" or freeze RADIUS check and
1116 reply attributes to the current values.
1120 #bah, my english is too broken this morning
1121 #Of note is the "Expiration" attribute, which, for accounts in prepaid packages, is typically defined on-the-fly as the associated packages cust_pkg.bill. (This is used by
1122 #the FS::cust_pkg's replace method to trigger the correct export updates when
1123 #package dates change)
1128 $self->{$_} = { $self->$_() }
1129 foreach qw( radius_reply radius_check );
1133 =item forget_snapshot
1135 This methos instructs the object to forget any previously snapshotted
1136 RADIUS check and reply attributes.
1140 sub forget_snapshot {
1144 foreach qw( radius_reply radius_check );
1150 Returns the domain associated with this account.
1156 die "svc_acct.domsvc is null for svcnum ". $self->svcnum unless $self->domsvc;
1157 my $svc_domain = $self->svc_domain(@_)
1158 or die "no svc_domain.svcnum for svc_acct.domsvc ". $self->domsvc;
1159 $svc_domain->domain;
1164 Returns the FS::svc_domain record for this account's domain (see
1172 ? $self->{'_domsvc'}
1173 : qsearchs( 'svc_domain', { 'svcnum' => $self->domsvc } );
1178 Returns the FS::cust_svc record for this account (see L<FS::cust_svc>).
1182 #inherited from svc_Common
1186 Returns an email address associated with the account.
1192 $self->username. '@'. $self->domain(@_);
1197 Returns an array of FS::acct_snarf records associated with the account.
1198 If the acct_snarf table does not exist or there are no associated records,
1199 an empty list is returned
1205 return () unless dbdef->table('acct_snarf');
1206 eval "use FS::acct_snarf;";
1208 qsearch('acct_snarf', { 'svcnum' => $self->svcnum } );
1211 =item decrement_seconds SECONDS
1213 Decrements the I<seconds> field of this record by the given amount. If there
1214 is an error, returns the error, otherwise returns false.
1218 sub decrement_seconds {
1219 shift->_op_seconds('-', @_);
1222 =item increment_seconds SECONDS
1224 Increments the I<seconds> field of this record by the given amount. If there
1225 is an error, returns the error, otherwise returns false.
1229 sub increment_seconds {
1230 shift->_op_seconds('+', @_);
1238 my %op2condition = (
1239 '-' => sub { my($self, $seconds) = @_;
1240 $self->seconds - $seconds <= 0;
1242 '+' => sub { my($self, $seconds) = @_;
1243 $self->seconds + $seconds > 0;
1248 my( $self, $op, $seconds ) = @_;
1249 warn "$me _op_seconds called for svcnum ". $self->svcnum.
1250 ' ('. $self->email. "): $op $seconds\n"
1253 local $SIG{HUP} = 'IGNORE';
1254 local $SIG{INT} = 'IGNORE';
1255 local $SIG{QUIT} = 'IGNORE';
1256 local $SIG{TERM} = 'IGNORE';
1257 local $SIG{TSTP} = 'IGNORE';
1258 local $SIG{PIPE} = 'IGNORE';
1260 my $oldAutoCommit = $FS::UID::AutoCommit;
1261 local $FS::UID::AutoCommit = 0;
1264 my $sql = "UPDATE svc_acct SET seconds = ".
1265 " CASE WHEN seconds IS NULL THEN 0 ELSE seconds END ". #$seconds||0
1266 " $op ? WHERE svcnum = ?";
1270 my $sth = $dbh->prepare( $sql )
1271 or die "Error preparing $sql: ". $dbh->errstr;
1272 my $rv = $sth->execute($seconds, $self->svcnum);
1273 die "Error executing $sql: ". $sth->errstr
1274 unless defined($rv);
1275 die "Can't update seconds for svcnum". $self->svcnum
1278 my $action = $op2action{$op};
1280 if ( $conf->exists("svc_acct-usage_$action")
1281 && &{$op2condition{$op}}($self, $seconds) ) {
1282 #my $error = $self->$action();
1283 my $error = $self->cust_svc->cust_pkg->$action();
1285 $dbh->rollback if $oldAutoCommit;
1286 return "Error ${action}ing: $error";
1290 warn "$me update successful; committing\n"
1292 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1298 =item seconds_since TIMESTAMP
1300 Returns the number of seconds this account has been online since TIMESTAMP,
1301 according to the session monitor (see L<FS::Session>).
1303 TIMESTAMP is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
1304 L<Time::Local> and L<Date::Parse> for conversion functions.
1308 #note: POD here, implementation in FS::cust_svc
1311 $self->cust_svc->seconds_since(@_);
1314 =item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
1316 Returns the numbers of seconds this account has been online between
1317 TIMESTAMP_START (inclusive) and TIMESTAMP_END (exclusive), according to an
1318 external SQL radacct table, specified via sqlradius export. Sessions which
1319 started in the specified range but are still open are counted from session
1320 start to the end of the range (unless they are over 1 day old, in which case
1321 they are presumed missing their stop record and not counted). Also, sessions
1322 which end in the range but started earlier are counted from the start of the
1323 range to session end. Finally, sessions which start before the range but end
1324 after are counted for the entire range.
1326 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
1327 L<perlfunc/"time">. Also see L<Time::Local> and L<Date::Parse> for conversion
1332 #note: POD here, implementation in FS::cust_svc
1333 sub seconds_since_sqlradacct {
1335 $self->cust_svc->seconds_since_sqlradacct(@_);
1338 =item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
1340 Returns the sum of the given attribute for all accounts (see L<FS::svc_acct>)
1341 in this package for sessions ending between TIMESTAMP_START (inclusive) and
1342 TIMESTAMP_END (exclusive).
1344 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
1345 L<perlfunc/"time">. Also see L<Time::Local> and L<Date::Parse> for conversion
1350 #note: POD here, implementation in FS::cust_svc
1351 sub attribute_since_sqlradacct {
1353 $self->cust_svc->attribute_since_sqlradacct(@_);
1356 =item get_session_history TIMESTAMP_START TIMESTAMP_END
1358 Returns an array of hash references of this customers login history for the
1359 given time range. (document this better)
1363 sub get_session_history {
1365 $self->cust_svc->get_session_history(@_);
1368 =item get_cdrs TIMESTAMP_START TIMESTAMP_END [ 'OPTION' => 'VALUE ... ]
1373 my($self, $start, $end, %opt ) = @_;
1375 my $did = $self->username; #yup
1377 my $prefix = $opt{'default_prefix'}; #convergent.au '+61'
1379 my $for_update = $opt{'for_update'} ? 'FOR UPDATE' : '';
1381 #SELECT $for_update * FROM cdr
1382 # WHERE calldate >= $start #need a conversion
1383 # AND calldate < $end #ditto
1384 # AND ( charged_party = "$did"
1385 # OR charged_party = "$prefix$did" #if length($prefix);
1386 # OR ( ( charged_party IS NULL OR charged_party = '' )
1388 # ( src = "$did" OR src = "$prefix$did" ) # if length($prefix)
1391 # AND ( freesidestatus IS NULL OR freesidestatus = '' )
1394 if ( length($prefix) ) {
1396 " AND ( charged_party = '$did'
1397 OR charged_party = '$prefix$did'
1398 OR ( ( charged_party IS NULL OR charged_party = '' )
1400 ( src = '$did' OR src = '$prefix$did' )
1406 " AND ( charged_party = '$did'
1407 OR ( ( charged_party IS NULL OR charged_party = '' )
1417 'select' => "$for_update *",
1420 #( freesidestatus IS NULL OR freesidestatus = '' )
1421 'freesidestatus' => '',
1423 'extra_sql' => $charged_or_src,
1431 Returns all RADIUS groups for this account (see L<FS::radius_usergroup>).
1437 if ( $self->usergroup ) {
1438 confess "explicitly specified usergroup not an arrayref: ". $self->usergroup
1439 unless ref($self->usergroup) eq 'ARRAY';
1440 #when provisioning records, export callback runs in svc_Common.pm before
1441 #radius_usergroup records can be inserted...
1442 @{$self->usergroup};
1444 map { $_->groupname }
1445 qsearch('radius_usergroup', { 'svcnum' => $self->svcnum } );
1449 =item clone_suspended
1451 Constructor used by FS::part_export::_export_suspend fallback. Document
1456 sub clone_suspended {
1458 my %hash = $self->hash;
1459 $hash{_password} = join('',map($pw_set[ int(rand $#pw_set) ], (0..7) ) );
1460 new FS::svc_acct \%hash;
1463 =item clone_kludge_unsuspend
1465 Constructor used by FS::part_export::_export_unsuspend fallback. Document
1470 sub clone_kludge_unsuspend {
1472 my %hash = $self->hash;
1473 $hash{_password} = '';
1474 new FS::svc_acct \%hash;
1477 =item check_password
1479 Checks the supplied password against the (possibly encrypted) password in the
1480 database. Returns true for a successful authentication, false for no match.
1482 Currently supported encryptions are: classic DES crypt() and MD5
1486 sub check_password {
1487 my($self, $check_password) = @_;
1489 #remove old-style SUSPENDED kludge, they should be allowed to login to
1490 #self-service and pay up
1491 ( my $password = $self->_password ) =~ s/^\*SUSPENDED\* //;
1493 #eventually should check a "password-encoding" field
1494 if ( $password =~ /^(\*|!!?)$/ ) { #no self-service login
1496 } elsif ( length($password) < 13 ) { #plaintext
1497 $check_password eq $password;
1498 } elsif ( length($password) == 13 ) { #traditional DES crypt
1499 crypt($check_password, $password) eq $password;
1500 } elsif ( $password =~ /^\$1\$/ ) { #MD5 crypt
1501 unix_md5_crypt($check_password, $password) eq $password;
1502 } elsif ( $password =~ /^\$2a?\$/ ) { #Blowfish
1503 warn "Can't check password: Blowfish encryption not yet supported, svcnum".
1504 $self->svcnum. "\n";
1507 warn "Can't check password: Unrecognized encryption for svcnum ".
1508 $self->svcnum. "\n";
1514 =item crypt_password [ DEFAULT_ENCRYPTION_TYPE ]
1516 Returns an encrypted password, either by passing through an encrypted password
1517 in the database or by encrypting a plaintext password from the database.
1519 The optional DEFAULT_ENCRYPTION_TYPE parameter can be set to I<crypt> (classic
1520 UNIX DES crypt), I<md5> (md5 crypt supported by most modern Linux and BSD
1521 distrubtions), or (eventually) I<blowfish> (blowfish hashing supported by
1522 OpenBSD, SuSE, other Linux distibutions with pam_unix2, etc.). The default
1523 encryption type is only used if the password is not already encrypted in the
1528 sub crypt_password {
1530 #eventually should check a "password-encoding" field
1531 if ( length($self->_password) == 13
1532 || $self->_password =~ /^\$(1|2a?)\$/
1533 || $self->_password =~ /^(\*|NP|\*LK\*|!!?)$/
1538 my $encryption = ( scalar(@_) && $_[0] ) ? shift : 'crypt';
1539 if ( $encryption eq 'crypt' ) {
1542 $saltset[int(rand(64))].$saltset[int(rand(64))]
1544 } elsif ( $encryption eq 'md5' ) {
1545 unix_md5_crypt( $self->_password );
1546 } elsif ( $encryption eq 'blowfish' ) {
1547 die "unknown encryption method $encryption";
1549 die "unknown encryption method $encryption";
1554 =item virtual_maildir
1556 Returns $domain/maildirs/$username/
1560 sub virtual_maildir {
1562 $self->domain. '/maildirs/'. $self->username. '/';
1573 This is the FS::svc_acct job-queue-able version. It still uses
1574 FS::Misc::send_email under-the-hood.
1581 eval "use FS::Misc qw(send_email)";
1584 $opt{mimetype} ||= 'text/plain';
1585 $opt{mimetype} .= '; charset="iso-8859-1"' unless $opt{mimetype} =~ /charset/;
1587 my $error = send_email(
1588 'from' => $opt{from},
1590 'subject' => $opt{subject},
1591 'content-type' => $opt{mimetype},
1592 'body' => [ map "$_\n", split("\n", $opt{body}) ],
1594 die $error if $error;
1597 =item check_and_rebuild_fuzzyfiles
1601 sub check_and_rebuild_fuzzyfiles {
1602 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
1603 -e "$dir/svc_acct.username"
1604 or &rebuild_fuzzyfiles;
1607 =item rebuild_fuzzyfiles
1611 sub rebuild_fuzzyfiles {
1613 use Fcntl qw(:flock);
1615 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
1619 open(USERNAMELOCK,">>$dir/svc_acct.username")
1620 or die "can't open $dir/svc_acct.username: $!";
1621 flock(USERNAMELOCK,LOCK_EX)
1622 or die "can't lock $dir/svc_acct.username: $!";
1624 my @all_username = map $_->getfield('username'), qsearch('svc_acct', {});
1626 open (USERNAMECACHE,">$dir/svc_acct.username.tmp")
1627 or die "can't open $dir/svc_acct.username.tmp: $!";
1628 print USERNAMECACHE join("\n", @all_username), "\n";
1629 close USERNAMECACHE or die "can't close $dir/svc_acct.username.tmp: $!";
1631 rename "$dir/svc_acct.username.tmp", "$dir/svc_acct.username";
1641 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
1642 open(USERNAMECACHE,"<$dir/svc_acct.username")
1643 or die "can't open $dir/svc_acct.username: $!";
1644 my @array = map { chomp; $_; } <USERNAMECACHE>;
1645 close USERNAMECACHE;
1649 =item append_fuzzyfiles USERNAME
1653 sub append_fuzzyfiles {
1654 my $username = shift;
1656 &check_and_rebuild_fuzzyfiles;
1658 use Fcntl qw(:flock);
1660 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
1662 open(USERNAME,">>$dir/svc_acct.username")
1663 or die "can't open $dir/svc_acct.username: $!";
1664 flock(USERNAME,LOCK_EX)
1665 or die "can't lock $dir/svc_acct.username: $!";
1667 print USERNAME "$username\n";
1669 flock(USERNAME,LOCK_UN)
1670 or die "can't unlock $dir/svc_acct.username: $!";
1678 =item radius_usergroup_selector GROUPS_ARRAYREF [ SELECTNAME ]
1682 sub radius_usergroup_selector {
1683 my $sel_groups = shift;
1684 my %sel_groups = map { $_=>1 } @$sel_groups;
1686 my $selectname = shift || 'radius_usergroup';
1689 my $sth = $dbh->prepare(
1690 'SELECT DISTINCT(groupname) FROM radius_usergroup ORDER BY groupname'
1691 ) or die $dbh->errstr;
1692 $sth->execute() or die $sth->errstr;
1693 my @all_groups = map { $_->[0] } @{$sth->fetchall_arrayref};
1697 function ${selectname}_doadd(object) {
1698 var myvalue = object.${selectname}_add.value;
1699 var optionName = new Option(myvalue,myvalue,false,true);
1700 var length = object.$selectname.length;
1701 object.$selectname.options[length] = optionName;
1702 object.${selectname}_add.value = "";
1705 <SELECT MULTIPLE NAME="$selectname">
1708 foreach my $group ( @all_groups ) {
1709 $html .= qq(<OPTION VALUE="$group");
1710 if ( $sel_groups{$group} ) {
1711 $html .= ' SELECTED';
1712 $sel_groups{$group} = 0;
1714 $html .= ">$group</OPTION>\n";
1716 foreach my $group ( grep { $sel_groups{$_} } keys %sel_groups ) {
1717 $html .= qq(<OPTION VALUE="$group" SELECTED>$group</OPTION>\n);
1719 $html .= '</SELECT>';
1721 $html .= qq!<BR><INPUT TYPE="text" NAME="${selectname}_add">!.
1722 qq!<INPUT TYPE="button" VALUE="Add new group" onClick="${selectname}_doadd(this.form)">!;
1731 The $recref stuff in sub check should be cleaned up.
1733 The suspend, unsuspend and cancel methods update the database, but not the
1734 current object. This is probably a bug as it's unexpected and
1737 radius_usergroup_selector? putting web ui components in here? they should
1738 probably live somewhere else...
1740 insertion of RADIUS group stuff in insert could be done with child_objects now
1741 (would probably clean up export of them too)
1745 L<FS::svc_Common>, edit/part_svc.cgi from an installed web interface,
1746 export.html from the base documentation, L<FS::Record>, L<FS::Conf>,
1747 L<FS::cust_svc>, L<FS::part_svc>, L<FS::cust_pkg>, L<FS::queue>,
1748 L<freeside-queued>), L<FS::svc_acct_pop>,
1749 schema.html from the base documentation.