strip _ out of MAC address fields, #19114
[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' => 'Wireless broadband',
95     'name_plural' => 'Wireless broadband services',
96     'longname_plural' => 'Fixed wireless 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   #exportnum
250   if ( $params->{'exportnum'} =~ /^(\d+)$/ ) {
251     push @from, 'LEFT JOIN export_svc USING ( svcpart )';
252     push @where, "exportnum = $1";
253   }
254
255   #ip_addr
256   if ( $params->{'ip_addr'} =~ /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/ ) {
257     push @where, "ip_addr = '$1'";
258   }
259
260   #custnum
261   if ( $params->{'custnum'} =~ /^(\d+)$/ and $1) {
262     push @where, "custnum = $1";
263   }
264   
265   my $addl_from = join(' ', @from);
266   my $extra_sql = '';
267   $extra_sql = 'WHERE '.join(' AND ', @where) if @where;
268   my $count_query = "SELECT COUNT(*) FROM svc_broadband $addl_from $extra_sql";
269   return( {
270       'table'   => 'svc_broadband',
271       'hashref' => {},
272       'select'  => join(', ',
273         'svc_broadband.*',
274         'part_svc.svc',
275         'cust_main.custnum',
276         FS::UI::Web::cust_sql_fields($params->{'cust_fields'}),
277       ),
278       'extra_sql' => $extra_sql,
279       'addl_from' => $addl_from,
280       'order_by'  => "ORDER BY ".($params->{'order_by'} || 'svcnum'),
281       'count_query' => $count_query,
282     } );
283 }
284
285 =item search_sql STRING
286
287 Class method which returns an SQL fragment to search for the given string.
288
289 =cut
290
291 sub search_sql {
292   my( $class, $string ) = @_;
293   if ( $string =~ /^(\d{1,3}\.){3}\d{1,3}$/ ) {
294     $class->search_sql_field('ip_addr', $string );
295   }elsif ( $string =~ /^([a-fA-F0-9]{12})$/ ) {
296     $class->search_sql_field('mac_addr', uc($string));
297   }elsif ( $string =~ /^(([a-fA-F0-9]{1,2}:){5}([a-fA-F0-9]{1,2}))$/ ) {
298     $class->search_sql_field('mac_addr', uc("$2$3$4$5$6$7") );
299   } else {
300     '1 = 0'; #false
301   }
302 }
303
304 =item label
305
306 Returns the IP address.
307
308 =cut
309
310 sub label {
311   my $self = shift;
312   my $label = 'IP:'. ($self->ip_addr || 'Unknown');
313   $label .= '", MAC:'. $self->mac_addr
314     if $self->mac_addr;
315   $label .= ' ('. $self->description. ')'
316     if $self->description;
317   return $label;
318 }
319
320 =item insert [ , OPTION => VALUE ... ]
321
322 Adds this record to the database.  If there is an error, returns the error,
323 otherwise returns false.
324
325 The additional fields pkgnum and svcpart (see FS::cust_svc) should be 
326 defined.  An FS::cust_svc record will be created and inserted.
327
328 Currently available options are: I<depend_jobnum>
329
330 If I<depend_jobnum> is set (to a scalar jobnum or an array reference of
331 jobnums), all provisioning jobs will have a dependancy on the supplied
332 jobnum(s) (they will not run until the specific job(s) complete(s)).
333
334 # Standard FS::svc_Common::insert
335
336 =item delete
337
338 Delete this record from the database.
339
340 =cut
341
342 # Standard FS::svc_Common::delete
343
344 =item replace OLD_RECORD
345
346 Replaces the OLD_RECORD with this one in the database.  If there is an error,
347 returns the error, otherwise returns false.
348
349 # Standard FS::svc_Common::replace
350
351 =item suspend
352
353 Called by the suspend method of FS::cust_pkg (see FS::cust_pkg).
354
355 =item unsuspend
356
357 Called by the unsuspend method of FS::cust_pkg (see FS::cust_pkg).
358
359 =item cancel
360
361 Called by the cancel method of FS::cust_pkg (see FS::cust_pkg).
362
363 =item check
364
365 Checks all fields to make sure this is a valid broadband service.  If there is
366 an error, returns the error, otherwise returns false.  Called by the insert
367 and replace methods.
368
369 =cut
370
371 sub check {
372   my $self = shift;
373   my $x = $self->setfixed;
374
375   return $x unless ref($x);
376
377   # remove delimiters
378   my $mac_addr = uc($self->get('mac_addr'));
379   $mac_addr =~ s/[\W_]//g;
380   $self->set('mac_addr', $mac_addr);
381
382   my $error =
383     $self->ut_numbern('svcnum')
384     || $self->ut_numbern('blocknum')
385     || $self->ut_foreign_keyn('routernum', 'router', 'routernum')
386     || $self->ut_foreign_keyn('sectornum', 'tower_sector', 'sectornum')
387     || $self->ut_textn('description')
388     || $self->ut_numbern('speed_up')
389     || $self->ut_numbern('speed_down')
390     || $self->ut_ipn('ip_addr')
391     || $self->ut_hexn('mac_addr')
392     || $self->ut_hexn('auth_key')
393     || $self->ut_coordn('latitude')
394     || $self->ut_coordn('longitude')
395     || $self->ut_sfloatn('altitude')
396     || $self->ut_textn('vlan_profile')
397     || $self->ut_textn('plan_id')
398   ;
399   return $error if $error;
400
401   if(($self->speed_up || 0) < 0) { return 'speed_up must be positive'; }
402   if(($self->speed_down || 0) < 0) { return 'speed_down must be positive'; }
403
404   my $cust_svc = $self->svcnum
405                  ? qsearchs('cust_svc', { 'svcnum' => $self->svcnum } )
406                  : '';
407   my $cust_pkg;
408   my $svcpart;
409   if ($cust_svc) {
410     $cust_pkg = $cust_svc->cust_pkg;
411     $svcpart = $cust_svc->svcpart;
412   }else{
413     $cust_pkg = qsearchs('cust_pkg', { 'pkgnum' => $self->pkgnum } );
414     return "Invalid pkgnum" unless $cust_pkg;
415     $svcpart = $self->svcpart;
416   }
417   my $agentnum = $cust_pkg->cust_main->agentnum if $cust_pkg;
418
419   if ( $conf->exists('auto_router') and $self->ip_addr and !$self->routernum ) {
420     # assign_router is guaranteed to provide a router that's legal
421     # for this agent and svcpart
422     my $error = $self->_check_ip_addr || $self->assign_router;
423     return $error if $error;
424   }
425   elsif ($self->routernum) {
426     return "Router ".$self->routernum." does not provide this service"
427       unless qsearchs('part_svc_router', { 
428         svcpart => $svcpart,
429         routernum => $self->routernum
430     });
431   
432     my $router = $self->router;
433     return "Router ".$self->routernum." does not serve this customer"
434       if $router->agentnum and $agentnum and $router->agentnum != $agentnum;
435
436     if ( $router->manual_addr ) {
437       $self->blocknum('');
438     }
439     else {
440       my $addr_block = $self->addr_block;
441       if ( $self->ip_addr eq '' 
442            and not ( $addr_block and $addr_block->manual_flag ) ) {
443         my $error = $self->assign_ip_addr;
444         return $error if $error;
445       }
446     }
447  
448     my $error = $self->_check_ip_addr;
449     return $error if $error;
450   } # if $self->routernum
451
452   if ( $cust_pkg && ! $self->latitude && ! $self->longitude ) {
453     my $l = $cust_pkg->cust_location_or_main;
454     if ( $l->ship_latitude && $l->ship_longitude ) {
455       $self->latitude(  $l->ship_latitude  );
456       $self->longitude( $l->ship_longitude );
457     } elsif ( $l->latitude && $l->longitude ) {
458       $self->latitude(  $l->latitude  );
459       $self->longitude( $l->longitude );
460     }
461   }
462
463   $self->SUPER::check;
464 }
465
466 =item assign_ip_addr
467
468 Assign an IP address matching the selected router, and the selected block
469 if there is one.
470
471 =cut
472
473 sub assign_ip_addr {
474   my $self = shift;
475   my @blocks;
476   my $ip_addr;
477
478   if ( $self->addr_block and $self->addr_block->routernum == $self->routernum ) {
479     # simple case: user chose a block, find an address in that block
480     # (this overrides an existing IP address if it's not in the block)
481     @blocks = ($self->addr_block);
482   }
483   elsif ( $self->routernum ) {
484     @blocks = $self->router->auto_addr_block;
485   }
486   else { 
487     return '';
488   }
489 #warn "assigning ip address in blocks\n".join("\n",map{$_->cidr} @blocks)."\n";
490
491   foreach my $block ( @blocks ) {
492     if ( $self->ip_addr and $block->NetAddr->contains($self->NetAddr) ) {
493       # don't change anything
494       return '';
495     }
496     $ip_addr = $block->next_free_addr;
497     if ( $ip_addr ) {
498       $self->set(ip_addr => $ip_addr->addr);
499       $self->set(blocknum => $block->blocknum);
500       return '';
501     }
502   }
503   return 'No IP address available on this router';
504 }
505
506 =item assign_router
507
508 Assign an address block and router matching the selected IP address.
509 Does nothing if IP address is null.
510
511 =cut
512
513 sub assign_router {
514   my $self = shift;
515   return '' if !$self->ip_addr;
516   #warn "assigning router/block for ".$self->ip_addr."\n";
517   foreach my $router ($self->allowed_routers) {
518     foreach my $block ($router->addr_block) {
519       if ( $block->NetAddr->contains($self->NetAddr) ) {
520         $self->blocknum($block->blocknum);
521         $self->routernum($block->routernum);
522         return '';
523       }
524     }
525   }
526   return $self->ip_addr.' is not in an allowed block.';
527 }
528
529 sub _check_ip_addr {
530   my $self = shift;
531
532   if (not($self->ip_addr) or $self->ip_addr eq '0.0.0.0') {
533     return '' if $conf->exists('svc_broadband-allow_null_ip_addr'); 
534     return 'IP address required';
535   }
536   else {
537     return 'Cannot parse address: '.$self->ip_addr unless $self->NetAddr;
538   }
539
540   if ( $self->addr_block 
541       and not $self->addr_block->NetAddr->contains($self->NetAddr) ) {
542     return 'Address '.$self->ip_addr.' not in block '.$self->addr_block->cidr;
543   }
544
545 #  if (my $dup = qsearchs('svc_broadband', {
546 #        ip_addr => $self->ip_addr,
547 #        svcnum  => {op=>'!=', value => $self->svcnum}
548 #      }) ) {
549 #    return 'IP address conflicts with svcnum '.$dup->svcnum;
550 #  }
551   '';
552 }
553
554 sub _check_duplicate {
555   my $self = shift;
556   # Not a reliable check because the table isn't locked, but 
557   # that's why we have a unique index.  This is just to give a
558   # friendlier error message.
559   my @dup;
560   @dup = $self->find_duplicates('global', 'ip_addr');
561   if ( @dup ) {
562     return "IP address in use (svcnum ".$dup[0]->svcnum.")";
563   }
564   @dup = $self->find_duplicates('global', 'mac_addr');
565   if ( @dup ) {
566     return "MAC address in use (svcnum ".$dup[0]->svcnum.")";
567   }
568
569   '';
570 }
571
572
573 =item NetAddr
574
575 Returns a NetAddr::IP object containing the IP address of this service.  The netmask 
576 is /32.
577
578 =cut
579
580 sub NetAddr {
581   my $self = shift;
582   new NetAddr::IP ($self->ip_addr);
583 }
584
585 =item addr_block
586
587 Returns the FS::addr_block record (i.e. the address block) for this broadband service.
588
589 =cut
590
591 sub addr_block {
592   my $self = shift;
593   qsearchs('addr_block', { blocknum => $self->blocknum });
594 }
595
596 =item router
597
598 Returns the FS::router record for this service.
599
600 =cut
601
602 sub router {
603   my $self = shift;
604   qsearchs('router', { routernum => $self->routernum });
605 }
606
607 =item allowed_routers
608
609 Returns a list of allowed FS::router objects.
610
611 =cut
612
613 sub allowed_routers {
614   my $self = shift;
615   my $svcpart = $self->svcnum ? $self->cust_svc->svcpart : $self->svcpart;
616   my @r = map { $_->router } qsearch('part_svc_router', 
617     { svcpart => $svcpart });
618   if ( $self->cust_main ) {
619     my $agentnum = $self->cust_main->agentnum;
620     return grep { !$_->agentnum or $_->agentnum == $agentnum } @r;
621   }
622   else {
623     return @r;
624   }
625 }
626
627 =back
628
629
630 =item mac_addr_formatted CASE DELIMITER
631
632 Format the MAC address (for use by exports).  If CASE starts with "l"
633 (for "lowercase"), it's returned in lowercase.  DELIMITER is inserted
634 between octets.
635
636 =cut
637
638 sub mac_addr_formatted {
639   my $self = shift;
640   my ($case, $delim) = @_;
641   my $addr = $self->mac_addr;
642   $addr = lc($addr) if $case =~ /^l/i;
643   join( $delim || '', $addr =~ /../g );
644 }
645
646 #class method
647 sub _upgrade_data {
648   my $class = shift;
649
650   local($FS::svc_Common::noexport_hack) = 1;
651
652   # set routernum to addr_block.routernum
653   foreach my $self (qsearch('svc_broadband', {
654       blocknum => {op => '!=', value => ''},
655       routernum => ''
656     })) {
657     my $addr_block = $self->addr_block;
658     if ( !$addr_block ) {
659       # super paranoid mode
660       warn "WARNING: svcnum ".$self->svcnum." is assigned to addr_block ".$self->blocknum.", which does not exist; skipped.\n";
661       next;
662     }
663     my $ip_addr = $self->ip_addr;
664     my $routernum = $addr_block->routernum;
665     if ( $routernum ) {
666       $self->set(routernum => $routernum);
667       my $error = $self->check;
668       # sanity check: don't allow this to change IP address or block
669       # (other than setting blocknum to null for a non-auto-assigned router)
670       if ( $self->ip_addr ne $ip_addr 
671         or ($self->blocknum and $self->blocknum != $addr_block->blocknum)) {
672         warn "WARNING: Upgrading service ".$self->svcnum." would change its block/address; skipped.\n";
673         next;
674       }
675
676       $error ||= $self->replace;
677       warn "WARNING: error assigning routernum $routernum to service ".$self->svcnum.
678           ":\n$error; skipped\n"
679         if $error;
680     }
681     else {
682       warn "svcnum ".$self->svcnum.
683         ": no routernum in address block ".$addr_block->cidr.", skipped\n";
684     }
685   }
686
687   # assign blocknums to services that should have them
688   my @all_blocks = qsearch('addr_block', { });
689   SVC: foreach my $self ( 
690     qsearch({
691         'select' => 'svc_broadband.*',
692         'table' => 'svc_broadband',
693         'addl_from' => 'JOIN router USING (routernum)',
694         'hashref' => {},
695         'extra_sql' => 'WHERE svc_broadband.blocknum IS NULL '.
696                        'AND router.manual_addr IS NULL',
697     }) 
698   ) {
699    
700     next SVC if $self->ip_addr eq '';
701     my $NetAddr = $self->NetAddr;
702     # inefficient, but should only need to run once
703     foreach my $block (@all_blocks) {
704       if ($block->NetAddr->contains($NetAddr)) {
705         $self->set(blocknum => $block->blocknum);
706         my $error = $self->replace;
707         warn "WARNING: error assigning blocknum ".$block->blocknum.
708         " to service ".$self->svcnum."\n$error; skipped\n"
709           if $error;
710         next SVC;
711       }
712     }
713     warn "WARNING: no block found containing ".$NetAddr->addr." for service ".
714       $self->svcnum;
715     #next SVC;
716   }
717
718   '';
719 }
720
721 =back
722
723 =head1 BUGS
724
725 The business with sb_field has been 'fixed', in a manner of speaking.
726
727 allowed_routers isn't agent virtualized because part_svc isn't agent
728 virtualized
729
730 Having both routernum and blocknum as foreign keys is somewhat dubious.
731
732 =head1 SEE ALSO
733
734 FS::svc_Common, FS::Record, FS::addr_block,
735 FS::part_svc, schema.html from the base documentation.
736
737 =cut
738
739 1;
740