Merge branch 'FREESIDE_2_3_BRANCH' of git.freeside.biz:/home/git/freeside into FREESI...
[freeside.git] / FS / FS / svc_broadband.pm
1 package FS::svc_broadband;
2
3 use strict;
4 use vars qw(@ISA $conf);
5
6 use base qw(FS::svc_Radius_Mixin FS::svc_Tower_Mixin FS::svc_Common);
7 { no warnings 'redefine'; use NetAddr::IP; }
8 use FS::Record qw( qsearchs qsearch dbh );
9 use FS::svc_Common;
10 use FS::cust_svc;
11 use FS::addr_block;
12 use FS::part_svc_router;
13 use FS::tower_sector;
14
15 $FS::UID::callback{'FS::svc_broadband'} = sub { 
16   $conf = new FS::Conf;
17 };
18
19 =head1 NAME
20
21 FS::svc_broadband - Object methods for svc_broadband records
22
23 =head1 SYNOPSIS
24
25   use FS::svc_broadband;
26
27   $record = new FS::svc_broadband \%hash;
28   $record = new FS::svc_broadband { 'column' => 'value' };
29
30   $error = $record->insert;
31
32   $error = $new_record->replace($old_record);
33
34   $error = $record->delete;
35
36   $error = $record->check;
37
38   $error = $record->suspend;
39
40   $error = $record->unsuspend;
41
42   $error = $record->cancel;
43
44 =head1 DESCRIPTION
45
46 An FS::svc_broadband object represents a 'broadband' Internet connection, such
47 as a DSL, cable modem, or fixed wireless link.  These services are assumed to
48 have the following properties:
49
50 FS::svc_broadband inherits from FS::svc_Common.  The following fields are
51 currently supported:
52
53 =over 4
54
55 =item svcnum - primary key
56
57 =item blocknum - see FS::addr_block
58
59 =item
60 speed_up - maximum upload speed, in bits per second.  If set to zero, upload
61 speed will be unlimited.  Exports that do traffic shaping should handle this
62 correctly, and not blindly set the upload speed to zero and kill the customer's
63 connection.
64
65 =item
66 speed_down - maximum download speed, as above
67
68 =item ip_addr - the customer's IP address.  If the customer needs more than one
69 IP address, set this to the address of the customer's router.  As a result, the
70 customer's router will have the same address for both its internal and external
71 interfaces thus saving address space.  This has been found to work on most NAT
72 routers available.
73
74 =item plan_id
75
76 =back
77
78 =head1 METHODS
79
80 =over 4
81
82 =item new HASHREF
83
84 Creates a new svc_broadband.  To add the record to the database, see
85 "insert".
86
87 Note that this stores the hash reference, not a distinct copy of the hash it
88 points to.  You can ask the object for a copy with the I<hash> method.
89
90 =cut
91
92 sub table_info {
93   {
94     'name' => 'Broadband',
95     'name_plural' => 'Broadband services',
96     'longname_plural' => 'Fixed (username-less) broadband services',
97     'display_weight' => 50,
98     'cancel_weight'  => 70,
99     'ip_field' => 'ip_addr',
100     'fields' => {
101       'svcnum'      => 'Service',
102       'description' => 'Descriptive label for this particular device',
103       'speed_down'  => 'Maximum download speed for this service in Kbps.  0 denotes unlimited.',
104       'speed_up'    => 'Maximum upload speed for this service in Kbps.  0 denotes unlimited.',
105       'ip_addr'     => 'IP address.  Leave blank for automatic assignment.',
106       'blocknum'    => 
107       { 'label' => 'Address block',
108                          'type'  => 'select',
109                          'select_table' => 'addr_block',
110                           'select_key'   => 'blocknum',
111                          'select_label' => 'cidr',
112                          'disable_inventory' => 1,
113                        },
114      'plan_id' => 'Service Plan Id',
115      'performance_profile' => 'Peformance Profile',
116      'authkey'      => 'Authentication key',
117      'mac_addr'     => 'MAC address',
118      'latitude'     => 'Latitude',
119      'longitude'    => 'Longitude',
120      'altitude'     => 'Altitude',
121      'vlan_profile' => 'VLAN profile',
122      'sectornum'    => 'Tower/sector',
123      'routernum'    => 'Router/block',
124      'usergroup'    => { 
125                          label => 'RADIUS groups',
126                          type  => 'select-radius_group.html',
127                          #select_table => 'radius_group',
128                          #select_key   => 'groupnum',
129                          #select_label => 'groupname',
130                          disable_inventory => 1,
131                          multiple => 1,
132                        },
133     },
134   };
135 }
136
137 sub table { 'svc_broadband'; }
138
139 sub table_dupcheck_fields { ( 'ip_addr', 'mac_addr' ); }
140
141 =item search HASHREF
142
143 Class method which returns a qsearch hash expression to search for parameters
144 specified in HASHREF.
145
146 Parameters:
147
148 =over 4
149
150 =item unlinked - set to search for all unlinked services.  Overrides all other options.
151
152 =item agentnum
153
154 =item custnum
155
156 =item svcpart
157
158 =item ip_addr
159
160 =item pkgpart - arrayref
161
162 =item routernum - arrayref
163
164 =item sectornum - arrayref
165
166 =item towernum - arrayref
167
168 =item order_by
169
170 =back
171
172 =cut
173
174 sub search {
175   my ($class, $params) = @_;
176   my @where = ();
177   my @from = (
178     'LEFT JOIN cust_svc  USING ( svcnum  )',
179     'LEFT JOIN part_svc  USING ( svcpart )',
180     'LEFT JOIN cust_pkg  USING ( pkgnum  )',
181     'LEFT JOIN cust_main USING ( custnum )',
182   );
183
184   # based on FS::svc_acct::search, probably the most mature of the bunch
185   #unlinked
186   push @where, 'pkgnum IS NULL' if $params->{'unlinked'};
187   
188   #agentnum
189   if ( $params->{'agentnum'} =~ /^(\d+)$/ and $1 ) {
190     push @where, "cust_main.agentnum = $1";
191   }
192   push @where, $FS::CurrentUser::CurrentUser->agentnums_sql(
193     'null_right' => 'View/link unlinked services',
194     'table' => 'cust_main'
195   );
196
197   #custnum
198   if ( $params->{'custnum'} =~ /^(\d+)$/ and $1 ) {
199     push @where, "custnum = $1";
200   }
201
202   #pkgpart, now properly untainted, can be arrayref
203   for my $pkgpart ( $params->{'pkgpart'} ) {
204     if ( ref $pkgpart ) {
205       my $where = join(',', map { /^(\d+)$/ ? $1 : () } @$pkgpart );
206       push @where, "cust_pkg.pkgpart IN ($where)" if $where;
207     }
208     elsif ( $pkgpart =~ /^(\d+)$/ ) {
209       push @where, "cust_pkg.pkgpart = $1";
210     }
211   }
212
213   #routernum, can be arrayref
214   for my $routernum ( $params->{'routernum'} ) {
215     # this no longer uses addr_block
216     if ( ref $routernum and grep { $_ } @$routernum ) {
217       my $in = join(',', map { /^(\d+)$/ ? $1 : () } @$routernum );
218       my @orwhere;
219       push @orwhere, "svc_broadband.routernum IN ($in)" if $in;
220       push @orwhere, "svc_broadband.routernum IS NULL" 
221         if grep /^none$/, @$routernum;
222       push @where, '( '.join(' OR ', @orwhere).' )';
223     }
224     elsif ( $routernum =~ /^(\d+)$/ ) {
225       push @where, "svc_broadband.routernum = $1";
226     }
227     elsif ( $routernum eq 'none' ) {
228       push @where, "svc_broadband.routernum IS NULL";
229     }
230   }
231
232   #sector and tower, as above
233   my @where_sector = $class->tower_sector_sql($params);
234   if ( @where_sector ) {
235     push @where, @where_sector;
236     push @from, 'LEFT JOIN tower_sector USING ( sectornum )';
237   }
238  
239   #svcnum
240   if ( $params->{'svcnum'} =~ /^(\d+)$/ ) {
241     push @where, "svcnum = $1";
242   }
243
244   #svcpart
245   if ( $params->{'svcpart'} =~ /^(\d+)$/ ) {
246     push @where, "svcpart = $1";
247   }
248
249   #ip_addr
250   if ( $params->{'ip_addr'} =~ /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/ ) {
251     push @where, "ip_addr = '$1'";
252   }
253
254   #custnum
255   if ( $params->{'custnum'} =~ /^(\d+)$/ and $1) {
256     push @where, "custnum = $1";
257   }
258   
259   my $addl_from = join(' ', @from);
260   my $extra_sql = '';
261   $extra_sql = 'WHERE '.join(' AND ', @where) if @where;
262   my $count_query = "SELECT COUNT(*) FROM svc_broadband $addl_from $extra_sql";
263   return( {
264       'table'   => 'svc_broadband',
265       'hashref' => {},
266       'select'  => join(', ',
267         'svc_broadband.*',
268         'part_svc.svc',
269         'cust_main.custnum',
270         FS::UI::Web::cust_sql_fields($params->{'cust_fields'}),
271       ),
272       'extra_sql' => $extra_sql,
273       'addl_from' => $addl_from,
274       'order_by'  => "ORDER BY ".($params->{'order_by'} || 'svcnum'),
275       'count_query' => $count_query,
276     } );
277 }
278
279 =item search_sql STRING
280
281 Class method which returns an SQL fragment to search for the given string.
282
283 =cut
284
285 sub search_sql {
286   my( $class, $string ) = @_;
287   if ( $string =~ /^(\d{1,3}\.){3}\d{1,3}$/ ) {
288     $class->search_sql_field('ip_addr', $string );
289   }elsif ( $string =~ /^([a-fA-F0-9]{12})$/ ) {
290     $class->search_sql_field('mac_addr', uc($string));
291   }elsif ( $string =~ /^(([a-fA-F0-9]{1,2}:){5}([a-fA-F0-9]{1,2}))$/ ) {
292     $class->search_sql_field('mac_addr', uc("$2$3$4$5$6$7") );
293   } else {
294     '1 = 0'; #false
295   }
296 }
297
298 =item label
299
300 Returns the IP address.
301
302 =cut
303
304 sub label {
305   my $self = shift;
306   $self->ip_addr;
307 }
308
309 =item insert [ , OPTION => VALUE ... ]
310
311 Adds this record to the database.  If there is an error, returns the error,
312 otherwise returns false.
313
314 The additional fields pkgnum and svcpart (see FS::cust_svc) should be 
315 defined.  An FS::cust_svc record will be created and inserted.
316
317 Currently available options are: I<depend_jobnum>
318
319 If I<depend_jobnum> is set (to a scalar jobnum or an array reference of
320 jobnums), all provisioning jobs will have a dependancy on the supplied
321 jobnum(s) (they will not run until the specific job(s) complete(s)).
322
323 # Standard FS::svc_Common::insert
324
325 =item delete
326
327 Delete this record from the database.
328
329 =cut
330
331 # Standard FS::svc_Common::delete
332
333 =item replace OLD_RECORD
334
335 Replaces the OLD_RECORD with this one in the database.  If there is an error,
336 returns the error, otherwise returns false.
337
338 # Standard FS::svc_Common::replace
339
340 =item suspend
341
342 Called by the suspend method of FS::cust_pkg (see FS::cust_pkg).
343
344 =item unsuspend
345
346 Called by the unsuspend method of FS::cust_pkg (see FS::cust_pkg).
347
348 =item cancel
349
350 Called by the cancel method of FS::cust_pkg (see FS::cust_pkg).
351
352 =item check
353
354 Checks all fields to make sure this is a valid broadband service.  If there is
355 an error, returns the error, otherwise returns false.  Called by the insert
356 and replace methods.
357
358 =cut
359
360 sub check {
361   my $self = shift;
362   my $x = $self->setfixed;
363
364   return $x unless ref($x);
365
366   # remove delimiters
367   my $mac_addr = uc($self->get('mac_addr'));
368   $mac_addr =~ s/[-: ]//g;
369   $self->set('mac_addr', $mac_addr);
370
371   my $error =
372     $self->ut_numbern('svcnum')
373     || $self->ut_numbern('blocknum')
374     || $self->ut_foreign_keyn('routernum', 'router', 'routernum')
375     || $self->ut_foreign_keyn('sectornum', 'tower_sector', 'sectornum')
376     || $self->ut_textn('description')
377     || $self->ut_numbern('speed_up')
378     || $self->ut_numbern('speed_down')
379     || $self->ut_ipn('ip_addr')
380     || $self->ut_hexn('mac_addr')
381     || $self->ut_hexn('auth_key')
382     || $self->ut_coordn('latitude')
383     || $self->ut_coordn('longitude')
384     || $self->ut_sfloatn('altitude')
385     || $self->ut_textn('vlan_profile')
386     || $self->ut_textn('plan_id')
387   ;
388   return $error if $error;
389
390   if(($self->speed_up || 0) < 0) { return 'speed_up must be positive'; }
391   if(($self->speed_down || 0) < 0) { return 'speed_down must be positive'; }
392
393   my $cust_svc = $self->svcnum
394                  ? qsearchs('cust_svc', { 'svcnum' => $self->svcnum } )
395                  : '';
396   my $cust_pkg;
397   my $svcpart;
398   if ($cust_svc) {
399     $cust_pkg = $cust_svc->cust_pkg;
400     $svcpart = $cust_svc->svcpart;
401   }else{
402     $cust_pkg = qsearchs('cust_pkg', { 'pkgnum' => $self->pkgnum } );
403     return "Invalid pkgnum" unless $cust_pkg;
404     $svcpart = $self->svcpart;
405   }
406   my $agentnum = $cust_pkg->cust_main->agentnum if $cust_pkg;
407
408   if ( $conf->exists('auto_router') and $self->ip_addr and !$self->routernum ) {
409     # assign_router is guaranteed to provide a router that's legal
410     # for this agent and svcpart
411     my $error = $self->_check_ip_addr || $self->assign_router;
412     return $error if $error;
413   }
414   elsif ($self->routernum) {
415     return "Router ".$self->routernum." does not provide this service"
416       unless qsearchs('part_svc_router', { 
417         svcpart => $svcpart,
418         routernum => $self->routernum
419     });
420   
421     my $router = $self->router;
422     return "Router ".$self->routernum." does not serve this customer"
423       if $router->agentnum and $agentnum and $router->agentnum != $agentnum;
424
425     if ( $router->manual_addr ) {
426       $self->blocknum('');
427     }
428     else {
429       my $addr_block = $self->addr_block;
430       unless ( $addr_block and $addr_block->manual_flag ) {
431         my $error = $self->assign_ip_addr;
432         return $error if $error;
433       }
434     }
435  
436     my $error = $self->_check_ip_addr;
437     return $error if $error;
438   } # if $self->routernum
439
440   if ( $cust_pkg && ! $self->latitude && ! $self->longitude ) {
441     my $l = $cust_pkg->cust_location_or_main;
442     if ( $l->ship_latitude && $l->ship_longitude ) {
443       $self->latitude(  $l->ship_latitude  );
444       $self->longitude( $l->ship_longitude );
445     } elsif ( $l->latitude && $l->longitude ) {
446       $self->latitude(  $l->latitude  );
447       $self->longitude( $l->longitude );
448     }
449   }
450
451   $self->SUPER::check;
452 }
453
454 =item assign_ip_addr
455
456 Assign an IP address matching the selected router, and the selected block
457 if there is one.
458
459 =cut
460
461 sub assign_ip_addr {
462   my $self = shift;
463   my @blocks;
464   my $ip_addr;
465
466   if ( $self->addr_block and $self->addr_block->routernum == $self->routernum ) {
467     # simple case: user chose a block, find an address in that block
468     # (this overrides an existing IP address if it's not in the block)
469     @blocks = ($self->addr_block);
470   }
471   elsif ( $self->routernum ) {
472     @blocks = $self->router->auto_addr_block;
473   }
474   else { 
475     return '';
476   }
477 #warn "assigning ip address in blocks\n".join("\n",map{$_->cidr} @blocks)."\n";
478
479   foreach my $block ( @blocks ) {
480     if ( $self->ip_addr and $block->NetAddr->contains($self->NetAddr) ) {
481       # don't change anything
482       return '';
483     }
484     $ip_addr = $block->next_free_addr;
485     if ( $ip_addr ) {
486       $self->set(ip_addr => $ip_addr->addr);
487       $self->set(blocknum => $block->blocknum);
488       return '';
489     }
490   }
491   return 'No IP address available on this router';
492 }
493
494 =item assign_router
495
496 Assign an address block and router matching the selected IP address.
497 Does nothing if IP address is null.
498
499 =cut
500
501 sub assign_router {
502   my $self = shift;
503   return '' if !$self->ip_addr;
504   #warn "assigning router/block for ".$self->ip_addr."\n";
505   foreach my $router ($self->allowed_routers) {
506     foreach my $block ($router->addr_block) {
507       if ( $block->NetAddr->contains($self->NetAddr) ) {
508         $self->blocknum($block->blocknum);
509         $self->routernum($block->routernum);
510         return '';
511       }
512     }
513   }
514   return $self->ip_addr.' is not in an allowed block.';
515 }
516
517 sub _check_ip_addr {
518   my $self = shift;
519
520   if (not($self->ip_addr) or $self->ip_addr eq '0.0.0.0') {
521     return '' if $conf->exists('svc_broadband-allow_null_ip_addr'); 
522     return 'IP address required';
523   }
524   else {
525     return 'Cannot parse address: '.$self->ip_addr unless $self->NetAddr;
526   }
527 #  if (my $dup = qsearchs('svc_broadband', {
528 #        ip_addr => $self->ip_addr,
529 #        svcnum  => {op=>'!=', value => $self->svcnum}
530 #      }) ) {
531 #    return 'IP address conflicts with svcnum '.$dup->svcnum;
532 #  }
533   '';
534 }
535
536 sub _check_duplicate {
537   my $self = shift;
538
539   $self->lock_table;
540
541   my @dup;
542   @dup = $self->find_duplicates('global', 'ip_addr');
543   if ( @dup ) {
544     return "IP address in use (svcnum ".$dup[0]->svcnum.")";
545   }
546   @dup = $self->find_duplicates('global', 'mac_addr');
547   if ( @dup ) {
548     return "MAC address in use (svcnum ".$dup[0]->svcnum.")";
549   }
550
551   '';
552 }
553
554
555 =item NetAddr
556
557 Returns a NetAddr::IP object containing the IP address of this service.  The netmask 
558 is /32.
559
560 =cut
561
562 sub NetAddr {
563   my $self = shift;
564   new NetAddr::IP ($self->ip_addr);
565 }
566
567 =item addr_block
568
569 Returns the FS::addr_block record (i.e. the address block) for this broadband service.
570
571 =cut
572
573 sub addr_block {
574   my $self = shift;
575   qsearchs('addr_block', { blocknum => $self->blocknum });
576 }
577
578 =item router
579
580 Returns the FS::router record for this service.
581
582 =cut
583
584 sub router {
585   my $self = shift;
586   qsearchs('router', { routernum => $self->routernum });
587 }
588
589 =item allowed_routers
590
591 Returns a list of allowed FS::router objects.
592
593 =cut
594
595 sub allowed_routers {
596   my $self = shift;
597   my $svcpart = $self->svcnum ? $self->cust_svc->svcpart : $self->svcpart;
598   my @r = map { $_->router } qsearch('part_svc_router', 
599     { svcpart => $svcpart });
600   if ( $self->cust_main ) {
601     my $agentnum = $self->cust_main->agentnum;
602     return grep { !$_->agentnum or $_->agentnum == $agentnum } @r;
603   }
604   else {
605     return @r;
606   }
607 }
608
609 =back
610
611
612 =item mac_addr_formatted CASE DELIMITER
613
614 Format the MAC address (for use by exports).  If CASE starts with "l"
615 (for "lowercase"), it's returned in lowercase.  DELIMITER is inserted
616 between octets.
617
618 =cut
619
620 sub mac_addr_formatted {
621   my $self = shift;
622   my ($case, $delim) = @_;
623   my $addr = $self->mac_addr;
624   $addr = lc($addr) if $case =~ /^l/i;
625   join( $delim || '', $addr =~ /../g );
626 }
627
628 #class method
629 sub _upgrade_data {
630   my $class = shift;
631
632   local($FS::svc_Common::noexport_hack) = 1;
633
634   # set routernum to addr_block.routernum
635   foreach my $self (qsearch('svc_broadband', {
636       blocknum => {op => '!=', value => ''},
637       routernum => ''
638     })) {
639     my $addr_block = $self->addr_block;
640     if ( !$addr_block ) {
641       # super paranoid mode
642       warn "WARNING: svcnum ".$self->svcnum." is assigned to addr_block ".$self->blocknum.", which does not exist; skipped.\n";
643       next;
644     }
645     my $ip_addr = $self->ip_addr;
646     my $routernum = $addr_block->routernum;
647     if ( $routernum ) {
648       $self->set(routernum => $routernum);
649       my $error = $self->check;
650       # sanity check: don't allow this to change IP address or block
651       # (other than setting blocknum to null for a non-auto-assigned router)
652       if ( $self->ip_addr ne $ip_addr 
653         or ($self->blocknum and $self->blocknum != $addr_block->blocknum)) {
654         warn "WARNING: Upgrading service ".$self->svcnum." would change its block/address; skipped.\n";
655         next;
656       }
657
658       $error ||= $self->replace;
659       warn "WARNING: error assigning routernum $routernum to service ".$self->svcnum.
660           ":\n$error; skipped\n"
661         if $error;
662     }
663     else {
664       warn "svcnum ".$self->svcnum.
665         ": no routernum in address block ".$addr_block->cidr.", skipped\n";
666     }
667   }
668
669   # assign blocknums to services that should have them
670   my @all_blocks = qsearch('addr_block', { });
671   SVC: foreach my $self ( 
672     qsearch({
673         'select' => 'svc_broadband.*',
674         'table' => 'svc_broadband',
675         'addl_from' => 'JOIN router USING (routernum)',
676         'hashref' => {},
677         'extra_sql' => 'WHERE svc_broadband.blocknum IS NULL '.
678                        'AND router.manual_addr IS NULL',
679     }) 
680   ) {
681    
682     next SVC if $self->ip_addr eq '';
683     my $NetAddr = $self->NetAddr;
684     # inefficient, but should only need to run once
685     foreach my $block (@all_blocks) {
686       if ($block->NetAddr->contains($NetAddr)) {
687         $self->set(blocknum => $block->blocknum);
688         my $error = $self->replace;
689         warn "WARNING: error assigning blocknum ".$block->blocknum.
690         " to service ".$self->svcnum."\n$error; skipped\n"
691           if $error;
692         next SVC;
693       }
694     }
695     warn "WARNING: no block found containing ".$NetAddr->addr." for service ".
696       $self->svcnum;
697     #next SVC;
698   }
699
700   '';
701 }
702
703 =back
704
705 =head1 BUGS
706
707 The business with sb_field has been 'fixed', in a manner of speaking.
708
709 allowed_routers isn't agent virtualized because part_svc isn't agent
710 virtualized
711
712 Having both routernum and blocknum as foreign keys is somewhat dubious.
713
714 =head1 SEE ALSO
715
716 FS::svc_Common, FS::Record, FS::addr_block,
717 FS::part_svc, schema.html from the base documentation.
718
719 =cut
720
721 1;
722