add customer fields option with agent, display_custnum, status and name, RT#73721
[freeside.git] / FS / FS / tower_sector.pm
1 package FS::tower_sector;
2 use base qw( FS::Record );
3
4 use FS::Record qw(dbh qsearch);
5 use Class::Load qw(load_class);
6 use File::Path qw(make_path);
7 use Data::Dumper;
8 use Cpanel::JSON::XS;
9
10 use strict;
11
12 our $noexport_hack = 0;
13
14 =head1 NAME
15
16 FS::tower_sector - Object methods for tower_sector records
17
18 =head1 SYNOPSIS
19
20   use FS::tower_sector;
21
22   $record = new FS::tower_sector \%hash;
23   $record = new FS::tower_sector { 'column' => 'value' };
24
25   $error = $record->insert;
26
27   $error = $new_record->replace($old_record);
28
29   $error = $record->delete;
30
31   $error = $record->check;
32
33 =head1 DESCRIPTION
34
35 An FS::tower_sector object represents a tower sector.  FS::tower_sector
36 inherits from FS::Record.  The following fields are currently supported:
37
38 =over 4
39
40 =item sectornum
41
42 primary key
43
44 =item towernum
45
46 towernum
47
48 =item sectorname
49
50 sectorname
51
52 =item ip_addr
53
54 ip_addr
55
56 =item height
57
58 The height of this antenna on the tower, measured from ground level. This
59 plus the tower's altitude should equal the height of the antenna above sea
60 level.
61
62 =item freq_mhz
63
64 The band center frequency in MHz.
65
66 =item direction
67
68 The antenna beam direction in degrees from north.
69
70 =item width
71
72 The -3dB horizontal beamwidth in degrees.
73
74 =item downtilt
75
76 The antenna beam elevation in degrees below horizontal.
77
78 =item v_width
79
80 The -3dB vertical beamwidth in degrees.
81
82 =item db_high
83
84 The signal loss margin to treat as "high quality".
85
86 =item db_low
87
88 The signal loss margin to treat as "low quality".
89
90 =item image 
91
92 The coverage map, as a PNG.
93
94 =item west, east, south, north
95
96 The coordinate boundaries of the coverage map.
97
98 =back
99
100 =head1 METHODS
101
102 =over 4
103
104 =item new HASHREF
105
106 Creates a new sector.  To add the sector to the database, see L<"insert">.
107
108 Note that this stores the hash reference, not a distinct copy of the hash it
109 points to.  You can ask the object for a copy with the I<hash> method.
110
111 =cut
112
113 sub table { 'tower_sector'; }
114
115 =item insert
116
117 Adds this record to the database.  If there is an error, returns the error,
118 otherwise returns false.
119
120 =cut
121
122 sub insert {
123   my $self = shift;
124
125   my $oldAutoCommit = $FS::UID::AutoCommit;
126   local $FS::UID::AutoCommit = 0;
127   my $dbh = dbh;
128
129   my $error = $self->SUPER::insert;
130   return $error if $error;
131
132   unless ($noexport_hack) {
133     foreach my $part_export ($self->part_export) {
134       my $error = $part_export->export_insert($self);
135       if ( $error ) {
136         $dbh->rollback if $oldAutoCommit;
137         return "exporting to ".$part_export->exporttype.
138                " (transaction rolled back): $error";
139       }
140     }
141   }
142
143   # XXX exportify
144   if (scalar($self->need_fields_for_coverage) == 0) {
145     $self->queue_generate_coverage;
146   }
147 }
148
149 sub replace {
150   my $self = shift;
151
152   my $oldAutoCommit = $FS::UID::AutoCommit;
153   local $FS::UID::AutoCommit = 0;
154   my $dbh = dbh;
155
156   my $old = shift || $self->replace_old;
157   my $error = $self->SUPER::replace($old);
158   return $error if $error;
159
160   unless ( $noexport_hack ) {
161     foreach my $part_export ($self->part_export) {
162       my $error = $part_export->export_replace($self, $old);
163       if ( $error ) {
164         $dbh->rollback if $oldAutoCommit;
165         return "exporting to ".$part_export->exporttype.
166                " (transaction rolled back): $error";
167       }
168     }
169   }
170
171   #XXX exportify
172   my $regen_coverage = 0;
173   if ( !$self->get('no_regen') ) {
174     foreach (qw(height freq_mhz direction width downtilt
175                 v_width db_high db_low))
176     {
177       $regen_coverage = 1 if ($self->get($_) ne $old->get($_));
178     }
179   }
180
181
182   if ($regen_coverage) {
183     $self->queue_generate_coverage;
184   }
185 }
186
187 =item delete
188
189 Delete this record from the database.
190
191 =cut
192
193 sub delete {
194   my $self = shift;
195
196   my $oldAutoCommit = $FS::UID::AutoCommit;
197   local $FS::UID::AutoCommit = 0;
198   my $dbh = dbh;
199
200   #not the most efficient, not not awful, and its not like deleting a sector
201   # with customers is a common operation
202   return "Can't delete a sector with customers" if $self->svc_broadband;
203
204   unless ($noexport_hack) {
205     foreach my $part_export ($self->part_export) {
206       my $error = $part_export->export_delete($self);
207       if ( $error ) {
208         $dbh->rollback if $oldAutoCommit;
209         return "exporting to ".$part_export->exporttype.
210                " (transaction rolled back): $error";
211       }
212     }
213   }
214
215   my $error = $self->SUPER::delete;
216   if ( $error ) {
217     $dbh->rollback if $oldAutoCommit;
218     return $error;
219   }
220
221 }
222
223 =item check
224
225 Checks all fields to make sure this is a valid sector.  If there is
226 an error, returns the error, otherwise returns false.  Called by the insert
227 and replace methods.
228
229 =cut
230
231 sub check {
232   my $self = shift;
233
234   my $error = 
235     $self->ut_numbern('sectornum')
236     || $self->ut_number('towernum', 'tower', 'towernum')
237     || $self->ut_text('sectorname')
238     || $self->ut_textn('ip_addr')
239     || $self->ut_floatn('height')
240     || $self->ut_numbern('freq_mhz')
241     || $self->ut_numbern('direction')
242     || $self->ut_numbern('width')
243     || $self->ut_numbern('v_width')
244     || $self->ut_numbern('downtilt')
245     || $self->ut_floatn('sector_range')
246     || $self->ut_decimaln('power')
247     || $self->ut_decimaln('line_loss')
248     || $self->ut_decimaln('antenna_gain')
249     || $self->ut_numbern('hardware_typenum')
250     || $self->ut_textn('title')
251     # all of these might get relocated as part of coverage refactoring
252     || $self->ut_anything('image')
253     || $self->ut_sfloatn('west')
254     || $self->ut_sfloatn('east')
255     || $self->ut_sfloatn('south')
256     || $self->ut_sfloatn('north')
257     || $self->ut_numbern('db_high')
258     || $self->ut_numbern('db_low')
259   ;
260   return $error if $error;
261
262   $self->SUPER::check;
263 }
264
265 =item tower
266
267 Returns the tower for this sector, as an FS::tower object (see L<FS::tower>).
268
269 =item description
270
271 Returns a description for this sector including tower name.
272
273 =cut
274
275 sub description {
276   my $self = shift;
277   if ( $self->sectorname eq '_default' ) {
278     $self->tower->towername
279   }
280   else {
281     $self->tower->towername. ' sector '. $self->sectorname
282   }
283 }
284
285 =item svc_broadband
286
287 Returns the services on this tower sector.
288
289 =item need_fields_for_coverage
290
291 Returns a list of required fields for the coverage map that aren't yet filled.
292
293 =cut
294
295 sub need_fields_for_coverage {
296   # for now assume exports require all of this
297   my $self = shift;
298   my $tower = $self->tower;
299   my %fields = (
300     height    => 'Height',
301     freq_mhz  => 'Frequency',
302     direction => 'Direction',
303     downtilt  => 'Downtilt',
304     width     => 'Horiz. width',
305     v_width   => 'Vert. width',
306     db_high   => 'High quality signal margin',
307     db_low    => 'Low quality signal margin',
308     latitude  => 'Latitude',
309     longitude => 'Longitude',
310   );
311   my @need;
312   foreach (keys %fields) {
313     if ($self->get($_) eq '' and $tower->get($_) eq '') {
314       push @need, $fields{$_};
315     }
316   }
317   @need;
318 }
319
320 =item queue_generate_coverage
321
322 Starts a job to recalculate the coverage map.
323
324 =cut
325
326 # XXX move to an export
327
328 sub queue_generate_coverage {
329   my $self = shift;
330   my $need_fields = join(',', $self->need_fields_for_coverage);
331   return "$need_fields required" if $need_fields;
332   $self->set('no_regen', 1); # avoid recursion
333   if ( length($self->image) > 0 ) {
334     foreach (qw(image west south east north)) {
335       $self->set($_, '');
336     }
337     my $error = $self->replace;
338     return $error if $error;
339   }
340   my $job = FS::queue->new({
341       job => 'FS::tower_sector::process_generate_coverage',
342   });
343   $job->insert('_JOB', { sectornum => $self->sectornum});
344 }
345
346 =back
347
348 =head1 CLASS METHODS
349
350 =over 4
351
352 =item part_export
353
354 Returns all sector exports. Eventually this may be refined to the level
355 of enabling exports on specific sectors.
356
357 =cut
358
359 sub part_export {
360   my $info = $FS::part_export::exports{'tower_sector'} or return;
361   my @exporttypes = map { dbh->quote($_) } keys %$info or return;
362   qsearch({
363     'table'     => 'part_export',
364     'extra_sql' => 'WHERE exporttype IN(' . join(',', @exporttypes) . ')'
365   });
366 }
367
368 =back
369
370 =head1 SUBROUTINES
371
372 =over 4
373
374 =item process_generate_coverage JOB, PARAMS
375
376 Queueable routine to fetch the sector coverage map from the tower mapping
377 server and store it. Highly experimental. Requires L<Map::Splat> to be
378 installed.
379
380 PARAMS must include 'sectornum'.
381
382 =cut
383
384 sub process_generate_coverage {
385   my $job = shift;
386   my $param = shift;
387   $job->update_statustext('0,generating map') if $job;
388   my $sectornum = $param->{sectornum};
389   my $sector = FS::tower_sector->by_key($sectornum)
390     or die "sector $sectornum does not exist";
391   $sector->set('no_regen', 1); # avoid recursion
392   my $tower = $sector->tower;
393
394   load_class('Map::Splat');
395
396   # since this is still experimental, put it somewhere we can find later
397   my $workdir = "$FS::UID::cache_dir/cache.$FS::UID::datasrc/" .
398                 "generate_coverage/sector$sectornum-". time;
399   make_path($workdir);
400   my $splat = Map::Splat->new(
401     lon         => $tower->longitude,
402     lat         => $tower->latitude,
403     height      => ($sector->height || $tower->height || 0),
404     freq        => $sector->freq_mhz,
405     azimuth     => $sector->direction,
406     h_width     => $sector->width,
407     tilt        => $sector->downtilt,
408     v_width     => $sector->v_width,
409     db_levels   => [ $sector->db_low, $sector->db_high ],
410     dir         => $workdir,
411     #simplify    => 0.0004, # remove stairstepping in SRTM3 data?
412   );
413   $splat->calculate;
414
415   my $box = $splat->box;
416   foreach (qw(west east south north)) {
417     $sector->set($_, $box->{$_});
418   }
419   $sector->set('image', $splat->png);
420   my $error = $sector->replace;
421   die $error if $error;
422
423   foreach ($sector->sector_coverage) {
424     $error = $_->delete;
425     die $error if $error;
426   }
427   # XXX undecided whether Map::Splat should even do this operation
428   # or how to store it
429   # or anything else
430   $DB::single = 1;
431   my $data = decode_json( $splat->polygonize_json );
432   for my $feature (@{ $data->{features} }) {
433     my $db = $feature->{properties}{level};
434     my $coverage = FS::sector_coverage->new({
435       sectornum => $sectornum,
436       db_loss   => $db,
437       geometry  => encode_json($feature->{geometry})
438     });
439     $error = $coverage->insert;
440   }
441
442   die $error if $error;
443 }
444
445 =head1 BUGS
446
447 =head1 SEE ALSO
448
449 L<FS::tower>, L<FS::Record>, schema.html from the base documentation.
450
451 =cut
452
453 1;
454