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