svc_hardware: better error messages for bad hw_addr when not validating as a MAC...
[freeside.git] / FS / FS / tower_sector.pm
index 3605190..eb00d33 100644 (file)
@@ -1,10 +1,15 @@
 package FS::tower_sector;
 package FS::tower_sector;
+use base qw( FS::Record );
+
+use FS::Record qw(dbh qsearch);
+use Class::Load qw(load_class);
+use File::Path qw(make_path);
+use Data::Dumper;
+use Cpanel::JSON::XS;
 
 use strict;
 
 use strict;
-use base qw( FS::Record );
-use FS::Record qw( qsearch qsearchs );
-use FS::tower;
-use FS::svc_broadband;
+
+our $noexport_hack = 0;
 
 =head1 NAME
 
 
 =head1 NAME
 
@@ -27,7 +32,7 @@ FS::tower_sector - Object methods for tower_sector records
 
 =head1 DESCRIPTION
 
 
 =head1 DESCRIPTION
 
-An FS::tower_sector object represents an tower sector.  FS::tower_sector
+An FS::tower_sector object represents a tower sector.  FS::tower_sector
 inherits from FS::Record.  The following fields are currently supported:
 
 =over 4
 inherits from FS::Record.  The following fields are currently supported:
 
 =over 4
@@ -48,6 +53,59 @@ sectorname
 
 ip_addr
 
 
 ip_addr
 
+=item height
+
+The height of this antenna on the tower, measured from ground level. This
+plus the tower's altitude should equal the height of the antenna above sea
+level.
+
+=item freq_mhz
+
+The band center frequency in MHz.
+
+=item direction
+
+The antenna beam direction in degrees from north.
+
+=item width
+
+The -3dB horizontal beamwidth in degrees.
+
+=item downtilt
+
+The antenna beam elevation in degrees below horizontal.
+
+=item v_width
+
+The -3dB vertical beamwidth in degrees.
+
+=item db_high
+
+The signal loss margin to treat as "high quality".
+
+=item db_low
+
+The signal loss margin to treat as "low quality".
+
+=item image 
+
+The coverage map, as a PNG.
+
+=item west, east, south, north
+
+The coordinate boundaries of the coverage map.
+
+=item title
+
+The sector title.
+
+=item up_rate_limit
+
+Up rate limit for sector.
+
+=item down_rate_limit
+
+down rate limit for sector.
 
 =back
 
 
 =back
 
@@ -71,6 +129,73 @@ sub table { 'tower_sector'; }
 Adds this record to the database.  If there is an error, returns the error,
 otherwise returns false.
 
 Adds this record to the database.  If there is an error, returns the error,
 otherwise returns false.
 
+=cut
+
+sub insert {
+  my $self = shift;
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
+  my $error = $self->SUPER::insert;
+  return $error if $error;
+
+  unless ($noexport_hack) {
+    foreach my $part_export ($self->part_export) {
+      my $error = $part_export->export_insert($self);
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return "exporting to ".$part_export->exporttype.
+               " (transaction rolled back): $error";
+      }
+    }
+  }
+
+  # XXX exportify
+  if (scalar($self->need_fields_for_coverage) == 0) {
+    $self->queue_generate_coverage;
+  }
+}
+
+sub replace {
+  my $self = shift;
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
+  my $old = shift || $self->replace_old;
+  my $error = $self->SUPER::replace($old);
+  return $error if $error;
+
+  unless ( $noexport_hack ) {
+    foreach my $part_export ($self->part_export) {
+      my $error = $part_export->export_replace($self, $old);
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return "exporting to ".$part_export->exporttype.
+               " (transaction rolled back): $error";
+      }
+    }
+  }
+
+  #XXX exportify
+  my $regen_coverage = 0;
+  if ( !$self->get('no_regen') ) {
+    foreach (qw(height freq_mhz direction width downtilt
+                v_width db_high db_low))
+    {
+      $regen_coverage = 1 if ($self->get($_) ne $old->get($_));
+    }
+  }
+
+
+  if ($regen_coverage) {
+    $self->queue_generate_coverage;
+  }
+}
+
 =item delete
 
 Delete this record from the database.
 =item delete
 
 Delete this record from the database.
@@ -80,17 +205,32 @@ Delete this record from the database.
 sub delete {
   my $self = shift;
 
 sub delete {
   my $self = shift;
 
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
   #not the most efficient, not not awful, and its not like deleting a sector
   # with customers is a common operation
   return "Can't delete a sector with customers" if $self->svc_broadband;
 
   #not the most efficient, not not awful, and its not like deleting a sector
   # with customers is a common operation
   return "Can't delete a sector with customers" if $self->svc_broadband;
 
-  $self->SUPER::delete;
-}
+  unless ($noexport_hack) {
+    foreach my $part_export ($self->part_export) {
+      my $error = $part_export->export_delete($self);
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return "exporting to ".$part_export->exporttype.
+               " (transaction rolled back): $error";
+      }
+    }
+  }
 
 
-=item replace OLD_RECORD
+  my $error = $self->SUPER::delete;
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
 
 
-Replaces the OLD_RECORD with this one in the database.  If there is an error,
-returns the error, otherwise returns false.
+}
 
 =item check
 
 
 =item check
 
@@ -107,7 +247,29 @@ sub check {
     $self->ut_numbern('sectornum')
     || $self->ut_number('towernum', 'tower', 'towernum')
     || $self->ut_text('sectorname')
     $self->ut_numbern('sectornum')
     || $self->ut_number('towernum', 'tower', 'towernum')
     || $self->ut_text('sectorname')
-    || $self->ut_textn('ip_addr')
+    || $self->ut_ip46n('ip_addr')
+    || $self->ut_floatn('height')
+    || $self->ut_numbern('freq_mhz')
+    || $self->ut_numbern('direction')
+    || $self->ut_numbern('width')
+    || $self->ut_numbern('v_width')
+    || $self->ut_numbern('downtilt')
+    || $self->ut_floatn('sector_range')
+    || $self->ut_decimaln('power')
+    || $self->ut_decimaln('line_loss')
+    || $self->ut_decimaln('antenna_gain')
+    || $self->ut_numbern('hardware_typenum')
+    || $self->ut_textn('title')
+    || $self->ut_numbern('up_rate_limit')
+    || $self->ut_numbern('down_rate_limit')
+    # all of these might get relocated as part of coverage refactoring
+    || $self->ut_anything('image')
+    || $self->ut_sfloatn('west')
+    || $self->ut_sfloatn('east')
+    || $self->ut_sfloatn('south')
+    || $self->ut_sfloatn('north')
+    || $self->ut_numbern('db_high')
+    || $self->ut_numbern('db_low')
   ;
   return $error if $error;
 
   ;
   return $error if $error;
 
@@ -118,13 +280,6 @@ sub check {
 
 Returns the tower for this sector, as an FS::tower object (see L<FS::tower>).
 
 
 Returns the tower for this sector, as an FS::tower object (see L<FS::tower>).
 
-=cut
-
-sub tower {
-  my $self = shift;
-  qsearchs('tower', { 'towernum'=>$self->towernum } );
-}
-
 =item description
 
 Returns a description for this sector including tower name.
 =item description
 
 Returns a description for this sector including tower name.
@@ -145,15 +300,188 @@ sub description {
 
 Returns the services on this tower sector.
 
 
 Returns the services on this tower sector.
 
+=item need_fields_for_coverage
+
+Returns a list of required fields for the coverage map that aren't yet filled.
+
 =cut
 
 =cut
 
-sub svc_broadband {
+sub need_fields_for_coverage {
+  # for now assume exports require all of this
   my $self = shift;
   my $self = shift;
-  qsearch('svc_broadband', { 'sectornum' => $self->sectornum });
+  my $tower = $self->tower;
+  my %fields = (
+    height    => 'Height',
+    freq_mhz  => 'Frequency',
+    direction => 'Direction',
+    downtilt  => 'Downtilt',
+    width     => 'Horiz. width',
+    v_width   => 'Vert. width',
+    db_high   => 'High quality signal margin',
+    db_low    => 'Low quality signal margin',
+    latitude  => 'Latitude',
+    longitude => 'Longitude',
+  );
+  my @need;
+  foreach (keys %fields) {
+    if ($self->get($_) eq '' and $tower->get($_) eq '') {
+      push @need, $fields{$_};
+    }
+  }
+  @need;
+}
+
+=item queue_generate_coverage
+
+Starts a job to recalculate the coverage map.
+
+=cut
+
+# XXX move to an export
+
+sub queue_generate_coverage {
+  my $self = shift;
+  my $need_fields = join(',', $self->need_fields_for_coverage);
+  return "$need_fields required" if $need_fields;
+  $self->set('no_regen', 1); # avoid recursion
+  if ( length($self->image) > 0 ) {
+    foreach (qw(image west south east north)) {
+      $self->set($_, '');
+    }
+    my $error = $self->replace;
+    return $error if $error;
+  }
+  my $job = FS::queue->new({
+      job => 'FS::tower_sector::process_generate_coverage',
+  });
+  $job->insert('_JOB', { sectornum => $self->sectornum});
 }
 
 =back
 
 }
 
 =back
 
+=head1 CLASS METHODS
+
+=over 4
+
+=item part_export
+
+Returns all sector exports. Eventually this may be refined to the level
+of enabling exports on specific sectors.
+
+=cut
+
+sub part_export {
+  my $info = $FS::part_export::exports{'tower_sector'} or return;
+  my @exporttypes = map { dbh->quote($_) } keys %$info or return;
+  qsearch({
+    'table'     => 'part_export',
+    'extra_sql' => 'WHERE exporttype IN(' . join(',', @exporttypes) . ')'
+  });
+}
+
+=item part_export_svc_broadband
+
+Returns all svc_broadband exports.
+
+=cut
+
+sub part_export_svc_broadband {
+  my $info = $FS::part_export::exports{'svc_broadband'} or return;
+  my @exporttypes = map { dbh->quote($_) } keys %$info or return;
+  qsearch({
+    'table'     => 'part_export',
+    'extra_sql' => 'WHERE exporttype IN(' . join(',', @exporttypes) . ')'
+  });
+}
+
+=back
+
+=head1 SUBROUTINES
+
+=over 4
+
+=item process_generate_coverage JOB, PARAMS
+
+Queueable routine to fetch the sector coverage map from the tower mapping
+server and store it. Highly experimental. Requires L<Map::Splat> to be
+installed.
+
+PARAMS must include 'sectornum'.
+
+=cut
+
+sub process_generate_coverage {
+  my $job = shift;
+  my $param = shift;
+  $job->update_statustext('0,generating map') if $job;
+  my $sectornum = $param->{sectornum};
+  my $sector = FS::tower_sector->by_key($sectornum)
+    or die "sector $sectornum does not exist";
+  $sector->set('no_regen', 1); # avoid recursion
+  my $tower = $sector->tower;
+
+  load_class('Map::Splat');
+
+  # since this is still experimental, put it somewhere we can find later
+  my $workdir = "$FS::UID::cache_dir/cache.$FS::UID::datasrc/" .
+                "generate_coverage/sector$sectornum-". time;
+  make_path($workdir);
+  my $splat = Map::Splat->new(
+    lon         => $tower->longitude,
+    lat         => $tower->latitude,
+    height      => ($sector->height || $tower->height || 0),
+    freq        => $sector->freq_mhz,
+    azimuth     => $sector->direction,
+    h_width     => $sector->width,
+    tilt        => $sector->downtilt,
+    v_width     => $sector->v_width,
+    db_levels   => [ $sector->db_low, $sector->db_high ],
+    dir         => $workdir,
+    #simplify    => 0.0004, # remove stairstepping in SRTM3 data?
+  );
+  $splat->calculate;
+
+  my $box = $splat->box;
+  foreach (qw(west east south north)) {
+    $sector->set($_, $box->{$_});
+  }
+  $sector->set('image', $splat->png);
+  my $error = $sector->replace;
+  die $error if $error;
+
+  foreach ($sector->sector_coverage) {
+    $error = $_->delete;
+    die $error if $error;
+  }
+  # XXX undecided whether Map::Splat should even do this operation
+  # or how to store it
+  # or anything else
+  $DB::single = 1;
+  my $data = decode_json( $splat->polygonize_json );
+  for my $feature (@{ $data->{features} }) {
+    my $db = $feature->{properties}{level};
+    my $coverage = FS::sector_coverage->new({
+      sectornum => $sectornum,
+      db_loss   => $db,
+      geometry  => encode_json($feature->{geometry})
+    });
+    $error = $coverage->insert;
+  }
+
+  die $error if $error;
+}
+
+sub _upgrade_data {
+
+  require FS::Misc::FixIPFormat;
+  FS::Misc::FixIPFormat::fix_bad_addresses_in_table(
+      'tower_sector', 'sectornum', 'ip_addr',
+  );
+
+  '';
+
+}
+
 =head1 BUGS
 
 =head1 SEE ALSO
 =head1 BUGS
 
 =head1 SEE ALSO
@@ -163,4 +491,3 @@ L<FS::tower>, L<FS::Record>, schema.html from the base documentation.
 =cut
 
 1;
 =cut
 
 1;
-