RT# 83450 - added fields interface and map_location to export
[freeside.git] / FS / FS / part_export / saisei.pm
1 package FS::part_export::saisei;
2
3 use strict;
4 use vars qw( @ISA %info );
5 use base qw( FS::part_export );
6 use Date::Format 'time2str';
7 use Cpanel::JSON::XS;
8 use MIME::Base64;
9 use REST::Client;
10 use Data::Dumper;
11 use FS::Conf;
12 use Carp qw(carp);
13
14 =pod
15
16 =head1 NAME
17
18 FS::part_export::saisei
19
20 =head1 SYNOPSIS
21
22 Saisei integration for Freeside
23
24 =head1 DESCRIPTION
25
26 This export offers basic svc_broadband provisioning for Saisei.
27
28 This is a customer integration with Saisei.  This will set up a rate plan and tie
29 the rate plan to a host and the access point via the Saisei API when the broadband service is provisioned.
30 It will also untie the host from the rate plan, setting it to the default rate plan via the API upon unprovisioning of the broadband service.
31
32 This will create and modify the rate plans at Saisei as soon as the broadband service attached to this export is created or modified.
33 This will also create and modify an access point at Saisei as soon as the tower is created or modified.
34
35 To use this export, follow the below instructions:
36
37 Create a new service definition and set the table to svc_broadband.  The service name will become the Saisei rate plan name.
38 Set the upload and download speed for the service. This is required to be able to export the service to Saisei.
39 Attach this Saisei export to this service.
40
41 Create a tower and add a sector to that tower.  The sector name will be the name of the access point,
42 Make sure you have set the up and down rate limit for the tower and the sector.  This is required to be able to export the access point.
43 The tower and sector will be set up as access points at Saisei upon the creation of the tower or sector.  They will be modified at Saisei when modified in freeside.
44 Each sector will be attached to its tower access point using the Saisei uplink field.
45 Each access point will be attached to the interface set in the export config.  If left blank access point will be attached to the default interface.  Most setups can leave this blank.
46
47 Create a package for the above created service, and order this package for a customer.
48
49 Provision the service, making sure to enter the IP address associated with this service and select the tower and sector for it's access point.
50 This provisioned service will then be exported as a host to Saisei.
51
52 Unprovisioning this service will set the host entry at Saisei to the default rate plan with the user and access point set to <none>.
53
54 After this export is set up and attached to a service, you can export the already provisioned services by clicking the link Export provisioned services attached to this export.
55 Clicking on this link will export all services attached to this export not currently exported to Saisei.
56
57 This module also provides generic methods for working through the L</Saisei API>.
58
59 =cut
60
61 tie my %scripts, 'Tie::IxHash',
62   'export_provisioned_services'  => { component => '/elements/popup_link.html',
63                                       label     => 'Export provisioned services',
64                                       description => 'will export provisioned services of part service with Saisei export attached.',
65                                       html_label => '<b>Export provisioned services attached to this export.</b>',
66                                       error_url  => '/edit/part_export.cgi?',
67                                       success_message => 'Saisei export of provisioned services successful',
68                                     },
69   'export_all_towers_sectors'    => { component => '/elements/popup_link.html',
70                                       label     => 'Export of all towers and sectors',
71                                       description => 'Will force an export of all towers and sectors to Saisei as access points.',
72                                       html_label => '<b>Export all towers and sectors.</b>',
73                                       error_url  => '/edit/part_export.cgi?',
74                                       success_message => 'Saisei export of towers and sectors as access points successful',
75                                     },
76 ;
77
78 tie my %options, 'Tie::IxHash',
79   'port'             => { label => 'Port',
80                           default => 5000 },
81   'username'         => { label => 'Saisei API User Name',
82                           default => '' },
83   'password'         => { label => 'Saisei API Password',
84                           default => '' },
85   'interface'        => { label => 'Saisei Access Point Interface',
86                           default => '' },
87   'debug'            => { type => 'checkbox',
88                           label => 'Enable debug warnings' },
89 ;
90
91 %info = (
92   'svc'             => 'svc_broadband',
93   'desc'            => 'Export broadband service/account to Saisei',
94   'options'         => \%options,
95   'scripts'         => \%scripts,
96   'notes'           => <<'END',
97 This is a customer integration with Saisei.  This will set up a rate plan and tie
98 the rate plan to a host and the access point via the Saisei API when the broadband service is provisioned.
99 It will also untie the host from the rate plan, setting it to the default rate plan via the API upon unprovisioning of the broadband service.
100 <P>
101 This will create and modify the rate plans at Saisei as soon as the broadband service attached to this export is created or modified.
102 This will also create and modify an access point at Saisei as soon as the tower is created or modified.
103 <P>
104 To use this export, follow the below instructions:
105 <P>
106 <OL>
107 <LI>
108 Create a new service definition and set the table to svc_broadband.  The service name will become the Saisei rate plan name.
109 Set the upload speed, download speed, and tower to be required for the service. This is required to be able to export the service to Saisei.
110 Attach this Saisei export to this service.
111 </LI>
112 <P>
113 <LI>
114 Create a tower and add a sector to that tower.  The sector name will be the name of the access point,
115 Make sure you have set the up and down rate limit for the tower and the sector.  This is required to be able to export the access point.
116 The tower and sector will be set up as access points at Saisei upon the creation of the tower or sector.  They will be modified at Saisei when modified in freeside.
117 Each sector will be attached to its tower access point using the Saisei uplink field.
118 Each access point will be attached to the interface set in the export config.  If left blank access point will be attached to the default interface.  Most setups can leave this blank.
119 </LI>
120 <P>
121 <LI>
122 Create a package for the above created service, and order this package for a customer.
123 </LI>
124 <P>
125 <LI>
126 Provision the service, making sure to enter the IP address associated with this service, the upload and download speed are correct, and select the tower and sector for it's access point.
127 This provisioned service will then be exported as a host to Saisei.
128 <P>
129 Unprovisioning this service will set the host entry at Saisei to the default rate plan with the user and access point set to <i>none</i>.
130 </LI>
131 <P>
132 <LI>
133 After this export is set up and attached to a service, you can export the already provisioned services by clicking the link <b>Export provisioned services attached to this export</b>.
134 Clicking on this link will export all services attached to this export not currently exported to Saisei.
135 </LI>
136 </OL>
137 <P>
138 <A HREF="http://www.freeside.biz/mediawiki/index.php/Saisei_provisioning_export" target="_new">Documentation</a>
139 END
140 );
141
142 sub _export_insert {
143   my ($self, $svc_broadband) = @_;
144
145   my $rateplan_name = $self->get_rateplan_name($svc_broadband);
146
147   # check for existing rate plan
148   my $existing_rateplan;
149   $existing_rateplan = $self->api_get_rateplan($rateplan_name) unless $self->{'__saisei_error'};
150
151   # if no existing rate plan create one and modify it.
152   $self->api_create_rateplan($svc_broadband, $rateplan_name) unless $existing_rateplan;
153   $self->api_modify_rateplan($svc_broadband, $rateplan_name) unless ($self->{'__saisei_error'} || $existing_rateplan);
154   return $self->api_error if $self->{'__saisei_error'};
155
156   # set rateplan to existing one or newly created one.
157   my $rateplan = $existing_rateplan ? $existing_rateplan : $self->api_get_rateplan($rateplan_name);
158
159   my $username = $svc_broadband->{Hash}->{svcnum};
160   my $description = $svc_broadband->{Hash}->{description};
161   my $svc_location;
162   $svc_location = $svc_broadband->{Hash}->{latitude}.','.$svc_broadband->{Hash}->{longitude} if ($svc_broadband->{Hash}->{latitude} && $svc_broadband->{Hash}->{longitude});
163
164   if (!$username) {
165     $self->{'__saisei_error'} = 'no username - can not export';
166     return $self->api_error;
167   }
168   else {
169     # check for existing user.
170     my $existing_user;
171     $existing_user = $self->api_get_user($username) unless $self->{'__saisei_error'};
172  
173     # if no existing user create one.
174     $self->api_create_user($username, $description, $svc_location) unless $existing_user;
175     return $self->api_error if $self->{'__saisei_error'};
176
177     # set user to existing one or newly created one.
178     my $user = $existing_user ? $existing_user : $self->api_get_user($username);
179
180     ## add access point
181     my $tower_sector = FS::Record::qsearchs({
182       'table'     => 'tower_sector',
183       'select'    => 'tower.towername,
184                       tower.up_rate_limit as tower_upratelimit,
185                       tower.down_rate_limit as tower_downratelimit,
186                       tower_sector.sectorname,
187                       tower_sector.towernum,
188                       tower_sector.up_rate_limit as sector_upratelimit,
189                       tower_sector.down_rate_limit as sector_downratelimit,
190                       tower.latitude,
191                       tower.longitude',
192       'addl_from' => 'LEFT JOIN tower USING ( towernum )',
193       'hashref'   => {
194                         'sectornum' => $svc_broadband->{Hash}->{sectornum},
195                      },
196     });
197
198     my $tower_location;
199     $tower_location = $tower_sector->{Hash}->{latitude}.','.$tower_sector->{Hash}->{longitude} if ($tower_sector->{Hash}->{latitude} && $tower_sector->{Hash}->{longitude});
200
201     my $tower_name = $tower_sector->{Hash}->{towername};
202     $tower_name =~ s/\s/_/g;
203
204     my $tower_opt = {
205       'tower_name'           => $tower_name,
206       'tower_num'            => $tower_sector->{Hash}->{towernum},
207       'tower_uprate_limit'   => $tower_sector->{Hash}->{tower_upratelimit},
208       'tower_downrate_limit' => $tower_sector->{Hash}->{tower_downratelimit},
209     };
210     $tower_opt->{'location'} = $tower_location if $tower_location;
211
212     my $tower_ap = process_tower($self, $tower_opt);
213     return $self->api_error if $self->{'__saisei_error'};
214
215     my $sector_name = $tower_sector->{Hash}->{sectorname};
216     $sector_name =~ s/\s/_/g;
217
218     my $sector_opt = {
219       'tower_name'            => $tower_name,
220       'tower_num'             => $tower_sector->{Hash}->{towernum},
221       'sector_name'           => $sector_name,
222       'sector_uprate_limit'   => $tower_sector->{Hash}->{sector_upratelimit},
223       'sector_downrate_limit' => $tower_sector->{Hash}->{sector_downratelimit},
224       'rateplan'              => $rateplan_name,
225     };
226     $sector_opt->{'location'} = $tower_location if $tower_location;
227
228     my $accesspoint = process_sector($self, $sector_opt);
229     return $self->api_error if $self->{'__saisei_error'};
230
231 ## get custnum and pkgpart from cust_pkg for virtual access point
232     my $cust_pkg = FS::Record::qsearchs({
233       'table'     => 'cust_pkg',
234       'hashref'   => { 'pkgnum' => $svc_broadband->{Hash}->{pkgnum}, },
235     });
236     my $virtual_ap_name = $cust_pkg->{Hash}->{custnum}.'_'.$cust_pkg->{Hash}->{pkgpart}.'_'.$svc_broadband->{Hash}->{speed_down}.'_'.$svc_broadband->{Hash}->{speed_up};
237
238     my $virtual_ap_opt = {
239       'virtual_name'           => $virtual_ap_name,
240       'sector_name'            => $sector_name,
241       'virtual_uprate_limit'   => $svc_broadband->{Hash}->{speed_up},
242       'virtual_downrate_limit' => $svc_broadband->{Hash}->{speed_down},
243     };
244     my $virtual_ap = process_virtual_ap($self, $virtual_ap_opt);
245     return $self->api_error if $self->{'__saisei_error'};
246
247     ## tie host to user add sector name as access point.
248     my $host_opt = {
249       'user'        => $user->{collection}->[0]->{name},
250       'rateplan'    => $rateplan->{collection}->[0]->{name},
251       'ip'          => $svc_broadband->{Hash}->{ip_addr},
252       'accesspoint' => $virtual_ap->{collection}->[0]->{name},
253       'location'    => $svc_location,
254     };
255     $self->api_add_host_to_user($host_opt)
256       unless $self->{'__saisei_error'};
257   }
258
259   return $self->api_error;
260
261 }
262
263 sub _export_replace {
264   my ($self, $svc_broadband) = @_;
265   my $error = $self->_export_insert($svc_broadband);
266   return $error;
267 }
268
269 sub _export_delete {
270   my ($self, $svc_broadband) = @_;
271
272   my $rateplan_name = $self->get_rateplan_name($svc_broadband);
273
274   my $username = $svc_broadband->{Hash}->{svcnum};
275
276   ## untie host to user
277   $self->api_delete_host_to_user($username, $rateplan_name, $svc_broadband->{Hash}->{ip_addr}) unless $self->{'__saisei_error'};
278
279   return '';
280 }
281
282 sub _export_suspend {
283   my ($self, $svc_broadband) = @_;
284   return '';
285 }
286
287 sub _export_unsuspend {
288   my ($self, $svc_broadband) = @_;
289   return '';
290 }
291
292 sub export_partsvc {
293   my ($self, $svc_part) = @_;
294
295   if ( $FS::svc_Common::noexport_hack ) {
296     carp 'export_partsvc() suppressed by noexport_hack'
297       if $self->option('debug');
298     return;
299   }
300
301   my $fcc_477_speeds;
302   if ($svc_part->{Hash}->{svc_broadband__speed_down} eq "down" || $svc_part->{Hash}->{svc_broadband__speed_up} eq "up") {
303     for my $type (qw( down up )) {
304       my $speed_type = "broadband_".$type."stream";
305       foreach my $pkg_svc (FS::Record::qsearch({
306         'table'     => 'pkg_svc',
307         'select'    => 'pkg_svc.*, part_pkg_fcc_option.fccoptionname, part_pkg_fcc_option.optionvalue',
308         'addl_from' => ' LEFT JOIN part_pkg_fcc_option USING (pkgpart) ',
309         'extra_sql' => " WHERE pkg_svc.svcpart = ".$svc_part->{Hash}->{svcpart}." AND pkg_svc.quantity > 0 AND part_pkg_fcc_option.fccoptionname = '".$speed_type."'",
310       })) { $fcc_477_speeds->{
311         $pkg_svc->{Hash}->{pkgpart}}->{$speed_type} = $pkg_svc->{Hash}->{optionvalue} * 1000 unless !$pkg_svc->{Hash}->{optionvalue}; }
312     }
313   }
314   else {
315     $fcc_477_speeds->{1}->{broadband_downstream} = $svc_part->{Hash}->{"svc_broadband__speed_down"};
316     $fcc_477_speeds->{1}->{broadband_upstream} = $svc_part->{Hash}->{"svc_broadband__speed_up"};
317   }
318
319   foreach my $key (keys %$fcc_477_speeds) {
320
321     $svc_part->{Hash}->{speed_down} = $fcc_477_speeds->{$key}->{broadband_downstream};
322     $svc_part->{Hash}->{speed_up} = $fcc_477_speeds->{$key}->{broadband_upstream};
323     $svc_part->{Hash}->{svc_broadband__speed_down} = $fcc_477_speeds->{$key}->{broadband_downstream};
324     $svc_part->{Hash}->{svc_broadband__speed_up} = $fcc_477_speeds->{$key}->{broadband_upstream};
325
326     my $temp_svc = $svc_part->{Hash};
327     my $svc_broadband = {};
328     map { if ($_ =~ /^svc_broadband__(.*)$/) { $svc_broadband->{Hash}->{$1} = $temp_svc->{$_}; }  } keys %$temp_svc;
329
330     my $rateplan_name = $self->get_rateplan_name($svc_broadband, $svc_part->{Hash}->{svc});
331
332     # check for existing rate plan
333     my $existing_rateplan;
334     $existing_rateplan = $self->api_get_rateplan($rateplan_name) unless $self->{'__saisei_error'};
335
336     # Modify the existing rate plan with new service data.
337     $self->api_modify_existing_rateplan($svc_broadband, $rateplan_name) unless ($self->{'__saisei_error'} || !$existing_rateplan);
338
339     # if no existing rate plan create one and modify it.
340     $self->api_create_rateplan($svc_broadband, $rateplan_name) unless $existing_rateplan;
341     $self->api_modify_rateplan($svc_part, $rateplan_name) unless ($self->{'__saisei_error'} || $existing_rateplan);
342
343   }
344
345   return $self->api_error;
346
347 }
348
349 sub export_tower_sector {
350   my ($self, $tower) = @_;
351
352   if ( $FS::svc_Common::noexport_hack ) {
353     carp 'export_tower_sector() suppressed by noexport_hack'
354       if $self->option('debug');
355     return;
356   }
357
358   my $tower_location;
359   $tower_location = $tower->{Hash}->{latitude}.','.$tower->{Hash}->{longitude} if ($tower->{Hash}->{latitude} && $tower->{Hash}->{longitude});
360
361   #modify tower or create it.
362   my $tower_name = $tower->{Hash}->{towername};
363   $tower_name =~ s/\s/_/g;
364   my $tower_opt = {
365     'tower_name'           => $tower_name,
366     'tower_num'            => $tower->{Hash}->{towernum},
367     'tower_uprate_limit'   => $tower->{Hash}->{up_rate_limit},
368     'tower_downrate_limit' => $tower->{Hash}->{down_rate_limit},
369     'modify_existing'      => '1', # modify an existing access point with this info
370   };
371   $tower_opt->{'location'} = $tower_location if $tower_location;
372
373   my $tower_access_point = process_tower($self, $tower_opt);
374     return $tower_access_point if $tower_access_point->{error};
375
376   #get list of all access points
377   my $hash_opt = {
378       'table'     => 'tower_sector',
379       'select'    => '*',
380       'hashref'   => { 'towernum' => $tower->{Hash}->{towernum}, },
381   };
382
383   #for each one modify or create it.
384   foreach my $tower_sector ( FS::Record::qsearch($hash_opt) ) {
385     next if $tower_sector->{Hash}->{sectorname} eq "_default";
386     my $sector_name = $tower_sector->{Hash}->{sectorname};
387     $sector_name =~ s/\s/_/g;
388     my $sector_opt = {
389       'tower_name'            => $tower_name,
390       'tower_num'             => $tower_sector->{Hash}->{towernum},
391       'sector_name'           => $sector_name,
392       'sector_uprate_limit'   => $tower_sector->{Hash}->{up_rate_limit},
393       'sector_downrate_limit' => $tower_sector->{Hash}->{down_rate_limit},
394       'modify_existing'       => '1', # modify an existing access point with this info
395     };
396     $sector_opt->{'location'} = $tower_location if $tower_location;
397
398     my $sector_access_point = process_sector($self, $sector_opt) unless ($sector_name eq "_default");
399       return $sector_access_point if $sector_access_point->{error};
400   }
401
402   return { error => $self->api_error, };
403 }
404
405 ## creates the rateplan name
406 sub get_rateplan_name {
407   my ($self, $svc_broadband, $svc_name) = @_;
408
409   my $service_part = FS::Record::qsearchs( 'part_svc', { 'svcpart' => $svc_broadband->{Hash}->{svcpart} } ) unless $svc_name;
410   my $service_name = $svc_name ? $svc_name : $service_part->{Hash}->{svc};
411
412   my $rateplan_name = $service_name . " " . $svc_broadband->{Hash}->{speed_down} . "-" . $svc_broadband->{Hash}->{speed_up};
413   $rateplan_name =~ s/\s/_/g; $rateplan_name =~ s/[^A-Za-z0-9\-_]//g;
414
415   return $rateplan_name;
416 }
417
418 =head1 Saisei API
419
420 These methods allow access to the Saisei API using the credentials
421 set in the export options.
422
423 =cut
424
425 =head2 api_call
426
427 Accepts I<$method>, I<$path>, I<$params> hashref and optional.
428 Places an api call to the specified path and method with the specified params.
429 Returns the decoded json object returned by the api call.
430 Returns empty on failure;  retrieve error messages using L</api_error>.
431
432 =cut
433
434 sub api_call {
435   my ($self,$method,$path,$params) = @_;
436
437   $self->{'__saisei_error'} = '';
438   my $auth_info = $self->option('username') . ':' . $self->option('password');
439   $params ||= {};
440
441   warn "Calling $method on http://"
442     .$self->{Hash}->{machine}.':'.$self->option('port')
443     ."/rest/top/configurations/running/$path\n" if $self->option('debug');
444
445   my $data = encode_json($params) if keys %{ $params };
446
447   my $client = REST::Client->new();
448   $client->addHeader("Authorization", "Basic ".encode_base64($auth_info));
449   $client->setHost('http://'.$self->{Hash}->{machine}.':'.$self->option('port'));
450   $client->$method('/rest/top/configurations/running'.$path, $data, { "Content-type" => 'application/json'});
451
452   warn "Saisei Response Code is ".$client->responseCode()."\n" if $self->option('debug');
453
454   my $result;
455
456   if ($client->responseCode() eq '200' || $client->responseCode() eq '201') {
457     eval { $result = decode_json($client->responseContent()) };
458     unless ($result) {
459       $self->{'__saisei_error'} = "There was an error decoding the JSON data from Saisei.  Bad JSON data logged in error log if debug option was set.";
460       warn "Saisei RC 201 Response Content is not json\n".$client->responseContent()."\n" if $self->option('debug');
461       return;
462     }
463   }
464   elsif ($client->responseCode() eq '404') {
465     eval { $result = decode_json($client->responseContent()) };
466     unless ($result) {
467       $self->{'__saisei_error'} = "There was an error decoding the JSON data from Saisei.  Bad JSON data logged in error log if debug option was set.";
468       warn "Saisei RC 404 Response Content is not json\n".$client->responseContent()."\n" if $self->option('debug');
469       return;
470     }
471     ## check if message is for empty hash.
472     my($does_not_exist) = $result->{message} =~ /'(.*)' does not exist$/;
473     $self->{'__saisei_error'} = "Saisei Error: ".$result->{message} unless $does_not_exist;
474     warn "Saisei Response Content is\n".$client->responseContent."\n" if ($self->option('debug') && !$does_not_exist);
475     return;
476   }
477   elsif ($client->responseCode() eq '500') {
478     $self->{'__saisei_error'} = "Could not connect to the Saisei export host machine (".$self->{Hash}->{machine}.':'.$self->option('port').") during $method , we received the responce code: " . $client->responseCode();
479     warn "Saisei Response Content is\n".$client->responseContent."\n" if $self->option('debug');
480     return;
481   }
482   else {
483     $self->{'__saisei_error'} = "Received Bad response from server during $method $path $data, we received responce code: " . $client->responseCode() . " " . $client->responseContent;
484     warn "Saisei Response Content is\n".$client->responseContent."\n" if $self->option('debug');
485     return; 
486   }
487
488   return $result;
489   
490 }
491
492 =head2 api_error
493
494 Returns the error string set by L</Saisei API> methods,
495 or a blank string if most recent call produced no errors.
496
497 =cut
498
499 sub api_error {
500   my $self = shift;
501   return $self->{'__saisei_error'} || '';
502 }
503
504 =head2 api_get_policies
505
506 Gets a list of global policies.
507
508 =cut
509
510 sub api_get_policies {
511   my $self = shift;
512
513   my $get_policies = $self->api_call("GET", '/policies/?token=1&order=name&start=0&limit=20&select=name%2Cpercent_rate%2Cassured%2C');
514   return if $self->api_error;
515   $self->{'__saisei_error'} = "Did not receive any global policies from Saisei."
516     unless $get_policies;
517
518   return $get_policies->{collection};
519 }
520
521 =head2 api_get_rateplan
522
523 Gets rateplan info for specific rateplan.
524
525 =cut
526
527 sub api_get_rateplan {
528   my $self = shift;
529   my $rateplan = shift;
530
531   my $get_rateplan = $self->api_call("GET", "/rate_plans/$rateplan");
532   return if $self->api_error;
533
534   return $get_rateplan;
535 }
536
537 =head2 api_get_user
538
539 Gets user info for specific user.
540
541 =cut
542
543 sub api_get_user {
544   my $self = shift;
545   my $user = shift;
546
547   my $get_user = $self->api_call("GET", "/users/$user");
548   return if $self->api_error;
549
550   return $get_user;
551 }
552
553 =head2 api_get_accesspoint
554
555 Gets user info for specific access point.
556
557 =cut
558
559 sub api_get_accesspoint {
560   my $self = shift;
561   my $accesspoint = shift;
562
563   my $get_accesspoint = $self->api_call("GET", "/access_points/$accesspoint");
564   return if $self->api_error;
565
566   return $get_accesspoint;
567 }
568
569 =head2 api_get_host
570
571 Gets user info for specific host.
572
573 =cut
574
575 sub api_get_host {
576   my $self = shift;
577   my $ip = shift;
578
579   my $get_host = $self->api_call("GET", "/hosts/$ip");
580
581   return { message => $self->api_error, } if $self->api_error;
582
583   return $get_host;
584 }
585
586 =head2 api_create_rateplan
587
588 Creates a rateplan.
589
590 =cut
591
592 sub api_create_rateplan {
593   my ($self, $svc, $rateplan) = @_;
594
595   $self->{'__saisei_error'} = "There is no download speed set for the service !--service,".$svc->{Hash}->{svcnum}.",".$rateplan."--! with host (".$svc->{Hash}->{ip_addr}."). All services that are to be exported to Saisei need to have a download speed set for them." if !$svc->{Hash}->{speed_down};
596   $self->{'__saisei_error'} = "There is no upload speed set for the service !--service,".$svc->{Hash}->{svcnum}.",".$rateplan."--! with host (".$svc->{Hash}->{ip_addr}."). All services that are to be exported to Saisei need to have a upload speed set for them." if !$svc->{Hash}->{speed_up};
597
598   my $new_rateplan = $self->api_call(
599       "PUT", 
600       "/rate_plans/$rateplan",
601       {
602         'downstream_rate' => $svc->{Hash}->{speed_down},
603         'upstream_rate' => $svc->{Hash}->{speed_up},
604       },
605   ) unless $self->{'__saisei_error'};
606
607   $self->{'__saisei_error'} = "Saisei could not create the rate plan $rateplan."
608     unless ($new_rateplan || $self->{'__saisei_error'});
609
610   return $new_rateplan;
611
612 }
613
614 =head2 api_modify_rateplan
615
616 Modify a new rateplan.
617
618 =cut
619
620 sub api_modify_rateplan {
621   my ($self,$svc,$rateplan_name) = @_;
622
623   # get policy list
624   my $policies = $self->api_get_policies();
625
626   foreach my $policy (@$policies) {
627     my $policyname = $policy->{name};
628     my $rate_multiplier = '';
629     if ($policy->{background}) { $rate_multiplier = ".01"; }
630     my $modified_rateplan = $self->api_call(
631       "PUT", 
632       "/rate_plans/$rateplan_name/partitions/$policyname",
633       {
634         'restricted'      =>  $policy->{assured},         # policy_assured_flag
635         'rate_multiplier' => $rate_multiplier,           # policy_background 0.1
636         'rate'            =>  $policy->{percent_rate}, # policy_percent_rate
637       },
638     );
639
640     $self->{'__saisei_error'} = "Saisei could not modify the rate plan $rateplan_name after it was created."
641       unless ($modified_rateplan || $self->{'__saisei_error'}); # should never happen
642     
643   }
644
645   return;
646  
647 }
648
649 =head2 api_modify_existing_rateplan
650
651 Modify a existing rateplan.
652
653 =cut
654
655 sub api_modify_existing_rateplan {
656   my ($self,$svc,$rateplan_name) = @_;
657
658   $self->{'__saisei_error'} = "There is no download speed set for the service !--service,".$svc->{Hash}->{svcnum}.",".$rateplan_name."--! with host (".$svc->{Hash}->{ip_addr}."). All services that are to be exported to Saisei need to have a download speed set for them." if !$svc->{Hash}->{speed_down};
659   $self->{'__saisei_error'} = "There is no upload speed set for the service !--service,".$svc->{Hash}->{svcnum}.",".$rateplan_name."--! with host (".$svc->{Hash}->{ip_addr}."). All services that are to be exported to Saisei need to have a upload speed set for them." if !$svc->{Hash}->{speed_up};
660
661   my $modified_rateplan = $self->api_call(
662     "PUT",
663     "/rate_plans/$rateplan_name",
664     {
665       'downstream_rate' => $svc->{Hash}->{speed_down},
666       'upstream_rate' => $svc->{Hash}->{speed_up},
667     },
668   );
669
670     $self->{'__saisei_error'} = "Saisei could not modify the rate plan $rateplan_name."
671       unless ($modified_rateplan || $self->{'__saisei_error'}); # should never happen
672
673   return;
674
675 }
676
677 =head2 api_create_user
678
679 Creates a user.
680
681 =cut
682
683 sub api_create_user {
684   my ($self,$user, $description, $location) = @_;
685
686   my $user_hash = {
687     'description' => $description,
688   };
689   $user_hash->{'map_location'} = $location if $location;
690
691   my $new_user = $self->api_call(
692       "PUT", 
693       "/users/$user",
694       $user_hash,
695   );
696
697   $self->{'__saisei_error'} = "Saisei could not create the user $user"
698     unless ($new_user || $self->{'__saisei_error'}); # should never happen
699
700   return $new_user;
701
702 }
703
704 =head2 api_create_accesspoint
705
706 Creates a access point.
707
708 =cut
709
710 sub api_create_accesspoint {
711   my ($self,$accesspoint, $upratelimit, $downratelimit, $location) = @_;
712
713   my $ap_hash = {
714     'downstream_rate_limit' => $downratelimit,
715     'upstream_rate_limit'   => $upratelimit,
716     'interface'             => $self->option('interface'),
717   };
718   $ap_hash->{'map_location'} = $location if $location;
719
720   my $new_accesspoint = $self->api_call(
721       "PUT",
722       "/access_points/$accesspoint",
723       $ap_hash,
724   );
725
726   $self->{'__saisei_error'} = "Saisei could not create the access point $accesspoint"
727     unless ($new_accesspoint || $self->{'__saisei_error'}); # should never happen
728   return;
729
730 }
731
732 =head2 api_modify_accesspoint
733
734 Modify a new access point.
735
736 =cut
737
738 sub api_modify_accesspoint {
739   my ($self, $accesspoint, $uplink, $location) = @_;
740
741   my $ap_hash = {
742     'uplink'    => $uplink,
743     'interface' => $self->option('interface'),
744   };
745   $ap_hash->{'map_location'} = $location if $location;
746
747   my $modified_accesspoint = $self->api_call(
748     "PUT",
749     "/access_points/$accesspoint",
750     $ap_hash,
751   );
752
753   $self->{'__saisei_error'} = "Saisei could not modify the access point $accesspoint after it was created."
754     unless ($modified_accesspoint || $self->{'__saisei_error'}); # should never happen
755
756   return;
757
758 }
759
760 =head2 api_modify_existing_accesspoint
761
762 Modify a existing accesspoint.
763
764 =cut
765
766 sub api_modify_existing_accesspoint {
767   my ($self, $accesspoint, $uplink, $upratelimit, $downratelimit, $location) = @_;
768
769   my $ap_hash = {
770     'downstream_rate_limit' => $downratelimit,
771     'upstream_rate_limit'   => $upratelimit,
772     'interface'             => $self->option('interface'),
773 #   'uplink'                => $uplink, # name of attached access point
774   };
775   $ap_hash->{'map_location'} = $location if $location;
776
777   my $modified_accesspoint = $self->api_call(
778     "PUT",
779     "/access_points/$accesspoint",
780     $ap_hash,
781   );
782
783   $self->{'__saisei_error'} = "Saisei could not modify the access point $accesspoint."
784     unless ($modified_accesspoint || $self->{'__saisei_error'}); # should never happen
785
786   return;
787
788 }
789
790 =head2 api_add_host_to_user
791
792 ties host to user, rateplan and default access point.
793
794 =cut
795
796 sub api_add_host_to_user {
797 #  my ($self,$user, $rateplan, $ip, $accesspoint, $location) = @_;
798   my ($self,$opt) = @_;
799   my $ip = $opt->{'ip'};
800   my $location = $opt->{'location'};
801
802   my $newhost_hash = {
803     'user'         => $opt->{'user'},
804     'rate_plan'    => $opt->{'rateplan'},
805     'access_point' => $opt->{'accesspoint'},
806   };
807   $newhost_hash->{'map_location'} = $location if $location;
808
809   my $new_host = $self->api_call(
810       "PUT", 
811       "/hosts/$ip",
812       $newhost_hash,
813   );
814
815   $self->{'__saisei_error'} = "Saisei could not create the host $ip"
816     unless ($new_host || $self->{'__saisei_error'}); # should never happen
817
818   return $new_host;
819
820 }
821
822 =head2 api_delete_host_to_user
823
824 unties host from user and rateplan.
825 this will set the host entry at Saisei to the default rate plan with the user and access point set to <none>.
826
827 =cut
828
829 sub api_delete_host_to_user {
830   my ($self,$user, $rateplan, $ip) = @_;
831
832   my $default_rate_plan = $self->api_call("GET", '?token=1&select=default_rate_plan');
833     return if $self->api_error;
834   $self->{'__saisei_error'} = "Can not delete the host as Saisei did not return a default rate plan. Please make sure Saisei has a default rateplan setup."
835     unless $default_rate_plan;
836
837   my $default_rateplan_name = $default_rate_plan->{collection}->[0]->{default_rate_plan}->{link}->{name};
838
839   my $delete_host = $self->api_call(
840       "PUT",
841       "/hosts/$ip",
842       {
843         'user'          => '<none>',
844         'access_point'  => '<none>',
845         'rate_plan'     => $default_rateplan_name,
846       },
847   );
848
849   $self->{'__saisei_error'} = "Saisei could not delete the host $ip"
850     unless ($delete_host || $self->{'__saisei_error'}); # should never happen
851
852   return $delete_host;
853
854 }
855
856 sub process_tower {
857   my ($self, $opt) = @_;
858
859   if (!$opt->{tower_uprate_limit} || !$opt->{tower_downrate_limit}) {
860     $self->{'__saisei_error'} = "Could not export tower !--tower,".$opt->{tower_num}.",".$opt->{tower_name}."--! because there was no up or down rates attached to the tower.  Saisei requires a up and down rate be attached to each tower.";
861     return { error => $self->api_error, };
862   }
863
864   my $existing_tower_ap;
865   my $tower_name = $opt->{tower_name};
866   my $location = $opt->{location};
867
868   #check if tower has been set up as an access point.
869   $existing_tower_ap = $self->api_get_accesspoint($tower_name) unless $self->{'__saisei_error'};
870
871   # modify the existing accesspoint if changing tower .
872   $self->api_modify_existing_accesspoint (
873     $tower_name,
874     '', # tower does not have a uplink on sectors.
875     $opt->{tower_uprate_limit},
876     $opt->{tower_downrate_limit},
877     $location,
878   ) if $existing_tower_ap->{collection} && $opt->{modify_existing};
879
880   #if tower does not exist as an access point create it.
881   $self->api_create_accesspoint(
882       $tower_name,
883       $opt->{tower_uprate_limit},
884       $opt->{tower_downrate_limit},
885       $location,
886   ) unless $existing_tower_ap->{collection};
887
888   my $accesspoint = $self->api_get_accesspoint($tower_name);
889
890   return { error => $self->api_error, } if $self->api_error;
891   return $accesspoint;
892 }
893
894 sub process_sector {
895   my ($self, $opt) = @_;
896
897   if (!$opt->{sector_name} || $opt->{sector_name} eq '_default') {
898     $self->{'__saisei_error'} = "No sector attached to Tower (".$opt->{tower_name}.") for service ".$opt->{'rateplan'}.".  Saisei requires a tower sector to be attached to each service that is exported to Saisei.";
899     return { error => $self->api_error, };
900   }
901
902   if (!$opt->{sector_uprate_limit} || !$opt->{sector_downrate_limit}) {
903     $self->{'__saisei_error'} = "Could not export sector !--tower,".$opt->{tower_num}.",".$opt->{sector_name}."--! because there was no up or down rates attached to the sector.  Saisei requires a up and down rate be attached to each sector.";
904     return { error => $self->api_error, };
905   }
906
907   my $existing_sector_ap;
908   my $sector_name = $opt->{sector_name};
909   my $location = $opt->{location};
910
911   #check if sector has been set up as an access point.
912   $existing_sector_ap = $self->api_get_accesspoint($sector_name);
913
914   # modify the existing accesspoint if changing sector .
915   $self->api_modify_existing_accesspoint (
916     $sector_name,
917     $opt->{tower_name},
918     $opt->{sector_uprate_limit},
919     $opt->{sector_downrate_limit},
920     $location,
921   ) if $existing_sector_ap && $opt->{modify_existing};
922
923   #if sector does not exist as an access point create it.
924   $self->api_create_accesspoint(
925     $sector_name,
926     $opt->{sector_uprate_limit},
927     $opt->{sector_downrate_limit},
928     $location,
929   ) unless $existing_sector_ap;
930
931   # Attach newly created sector to it's tower.
932   $self->api_modify_accesspoint($sector_name, $opt->{tower_name}, $location) unless ($self->{'__saisei_error'} || $existing_sector_ap);
933
934   # set access point to existing one or newly created one.
935   my $accesspoint = $existing_sector_ap ? $existing_sector_ap : $self->api_get_accesspoint($sector_name);
936
937   return { error => $self->api_error, } if $self->api_error;
938   return $accesspoint;
939 }
940
941 =head2 require_tower_and_sector
942
943 sets whether the service export requires a sector with it's tower.
944
945 =cut
946
947 sub require_tower_and_sector {
948   1;
949 }
950
951 =head2 tower_sector_required_fields
952
953 required fields needed for tower and sector export.
954
955 =cut
956
957 sub tower_sector_required_fields {
958   my $fields = {
959     'tower' => {
960       'up_rate_limit'   => '1',
961       'down_rate_limit' => '1',
962     },
963     'sector' => {
964       'up_rate_limit'   => '1',
965       'down_rate_limit' => '1',
966       'ip_addr'         => '1',
967     },
968   };
969   return $fields;
970 }
971
972 sub required_fields {
973   my @fields = ('svc_broadband__ip_addr_required', 'svc_broadband__speed_up_required', 'svc_broadband__speed_down_required', 'svc_broadband__sectornum_required');
974   return @fields;
975 }
976
977 sub process_virtual_ap {
978   my ($self, $opt) = @_;
979
980   my $existing_virtual_ap;
981   my $virtual_name = $opt->{virtual_name};
982
983   #check if virtual_ap has been set up as an access point.
984   $existing_virtual_ap = $self->api_get_accesspoint($virtual_name);
985
986   # modify the existing virtual accesspoint if changing it. this should never happen
987   $self->api_modify_existing_accesspoint (
988     $virtual_name,
989     $opt->{sector_name},
990     $opt->{virtual_uprate_limit},
991     $opt->{virtual_downrate_limit},
992   ) if $existing_virtual_ap && $opt->{modify_existing};
993
994   #if virtual ap does not exist as an access point create it.
995   $self->api_create_accesspoint(
996     $virtual_name,
997     $opt->{virtual_uprate_limit},
998     $opt->{virtual_downrate_limit},
999   ) unless $existing_virtual_ap;
1000
1001   my $update_sector;
1002   if ($existing_virtual_ap && (ref $existing_virtual_ap->{collection}->[0]->{uplink} eq "HASH") && ($existing_virtual_ap->{collection}->[0]->{uplink}->{link}->{name} ne $opt->{sector_name})) {
1003     $update_sector = 1;
1004   }
1005
1006   # Attach newly created virtual ap to tower sector ap or if sector has changed.
1007   $self->api_modify_accesspoint($virtual_name, $opt->{sector_name}) unless ($self->{'__saisei_error'} || ($existing_virtual_ap && !$update_sector));
1008
1009   # set access point to existing one or newly created one.
1010   my $accesspoint = $existing_virtual_ap ? $existing_virtual_ap : $self->api_get_accesspoint($virtual_name);
1011
1012   return $accesspoint;
1013 }
1014
1015 sub export_provisioned_services {
1016   my $job = shift;
1017   my $param = shift;
1018
1019   my $part_export = FS::Record::qsearchs('part_export', { 'exportnum' => $param->{export_provisioned_services_exportnum}, } )
1020   or die "You are trying to use an unknown exportnum $param->{export_provisioned_services_exportnum}.  This export does not exist.\n";
1021   bless $part_export;
1022
1023   my @svcparts = FS::Record::qsearch({
1024     'table' => 'export_svc',
1025     'addl_from' => 'LEFT JOIN part_svc USING ( svcpart  ) ',
1026     'hashref'   => { 'exportnum' => $param->{export_provisioned_services_exportnum}, },
1027   });
1028   my $part_count = scalar @svcparts;
1029
1030   my $parts = join "', '", map { $_->{Hash}->{svcpart} } @svcparts;
1031
1032   my @svcs = FS::Record::qsearch({
1033     'table' => 'cust_svc',
1034     'addl_from' => 'LEFT JOIN svc_broadband USING ( svcnum  ) ',
1035     'extra_sql' => " WHERE svcpart in ('".$parts."')",
1036   }) unless !$parts;
1037
1038   my $svc_count = scalar @svcs;
1039
1040   my %status = {};
1041   for (my $c=1; $c <=100; $c=$c+1) { $status{int($svc_count * ($c/100))} = $c; }
1042
1043   my $process_count=0;
1044   foreach my $svc (@svcs) {
1045     if ($status{$process_count}) { my $s = $status{$process_count}; $job->update_statustext($s); }
1046     ## check if service exists as host if not export it.
1047     my $host = api_get_host($part_export, $svc->{Hash}->{ip_addr});
1048     die ("Please double check your credentials as ".$host->{message}."\n") if $host->{message};
1049     warn "Exporting service ".$svc->{Hash}->{ip_addr}."\n" if ($part_export->option('debug'));
1050     my $export_error = _export_insert($part_export,$svc) unless $host->{collection};
1051     if ($export_error) {
1052       warn "Error exporting service ".$svc->{Hash}->{ip_addr}."\n" if ($part_export->option('debug'));
1053       die ("$export_error\n");
1054     }
1055     $process_count++;
1056   }
1057
1058   return;
1059
1060 }
1061
1062 sub export_all_towers_sectors {
1063   my $job = shift;
1064   my $param = shift;
1065
1066   my $part_export = FS::Record::qsearchs('part_export', { 'exportnum' => $param->{export_provisioned_services_exportnum}, } )
1067   or die "You are trying to use an unknown exportnum $param->{export_provisioned_services_exportnum}.  This export does not exist.\n";
1068   bless $part_export;
1069
1070   my @towers = FS::Record::qsearch({
1071     'table' => 'tower',
1072   });
1073   my $tower_count = scalar @towers;
1074
1075   my %status = {};
1076   for (my $c=1; $c <=100; $c=$c+1) { $status{int($tower_count * ($c/100))} = $c; }
1077
1078   my $process_count=0;
1079   foreach my $tower (@towers) {
1080     if ($status{$process_count}) { my $s = $status{$process_count}; $job->update_statustext($s); }
1081     my $export_error = export_tower_sector($part_export,$tower);
1082     if ($export_error->{'error'}) {
1083       warn "Error exporting tower/sector (".$tower->{Hash}->{towername}.")\n" if ($part_export->option('debug'));
1084       die ($export_error->{'error'}."\n");
1085     }
1086     $process_count++;
1087   }
1088
1089   return;
1090
1091 }
1092
1093 sub test_export_report {
1094   my ($self, $opts) = @_;
1095   my @export_error;
1096
1097   ##  check all part services for export errors
1098   my @exports = FS::Record::qsearch('part_export', { 'exporttype' => "saisei", } );
1099   my $export_nums = join "', '", map { $_->{Hash}->{exportnum} } @exports;
1100
1101   my $svc_part_export_error;
1102   my @svcparts = FS::Record::qsearch({
1103     'table' => 'part_svc',
1104     'addl_from' => 'LEFT JOIN export_svc USING ( svcpart  ) ',
1105     'extra_sql' => " WHERE export_svc.exportnum in ('".$export_nums."')",
1106   });
1107   my $part_count = scalar @svcparts;
1108
1109   my $svc_part_error;
1110   foreach (@svcparts) {
1111     my $part_error->{'description'} = $_->svc;
1112     $part_error->{'link'} = $opts->{'fsurl'}."/edit/part_svc.cgi?".$_->svcpart;
1113
1114     foreach my $s ('speed_up', 'speed_down') {
1115       my $speed = $_->part_svc_column($s);
1116       if ($speed->columnflag eq "" || $speed->columnflag eq "D") {
1117         $part_error->{'errors'}->{$speed->columnname} = "Field ".$speed->columnname." is not set to be required and can be set while provisioning the service." unless $speed->required eq "Y";
1118       }
1119       elsif ($speed->columnflag eq "F" || $speed->columnflag eq "S") {
1120         $part_error->{'errors'}->{$speed->columnname} = "Field ".$speed->columnname." is set to auto fill while provisioning the service but there is no value set." unless $speed->columnvalue;
1121       }
1122       elsif ($speed->columnflag eq "P") {
1123         my $fcc_speed_name = "broadband_".$speed->columnvalue."stream";
1124         foreach my $part_pkg ( FS::Record::qsearchs({
1125                                  'table'   => 'part_pkg',
1126                                  'addl_from' => 'LEFT JOIN pkg_svc USING ( pkgpart  ) ',
1127                                  'extra_sql' => " WHERE pkg_svc.svcpart = ".$_->svcpart,
1128                               })) {
1129           my $pkglink = '<a href="'.$opts->{'fsurl'}.'/edit/part_pkg.cgi?'.$part_pkg->pkgpart.'"><FONT COLOR="red"><B>'.$part_pkg->pkg.'</B></FONT></a>';
1130           $part_error->{'errors'}->{$speed->columnname} = "Field ".$speed->columnname." is set to package FCC 477 information, but package ".$pkglink." does not have FCC ".$fcc_speed_name." set."
1131             unless $part_pkg->fcc_option($fcc_speed_name);
1132         }
1133       }
1134     }
1135     $part_error->{'errors'}->{'ip_addr'}    = "Field IP Address is not set to required" if $_->part_svc_column("ip_addr")->required ne "Y";
1136     $svc_part_error->{$_->svcpart} = $part_error if $part_error->{'errors'};
1137   }
1138
1139   $svc_part_export_error->{"services"}->{'description'} = "Service definitions";
1140   $svc_part_export_error->{"services"}->{'count'} = $part_count;
1141   $svc_part_export_error->{"services"}->{'errors'} = $svc_part_error if $svc_part_error;
1142
1143   push @export_error, $svc_part_export_error;
1144
1145   ##  check all provisioned cust services for export errors
1146   my $parts = join "', '", map { $_->{Hash}->{svcpart} } @svcparts;
1147   my $cust_svc_export_error;
1148   my @svcs = FS::Record::qsearch({
1149     'table' => 'cust_svc',
1150     'addl_from' => 'LEFT JOIN svc_broadband USING ( svcnum  ) ',
1151     'extra_sql' => " WHERE svcpart in ('".$parts."')",
1152   }) unless !$parts;
1153   my $svc_count = scalar @svcs;
1154
1155   my $cust_svc_error;
1156   foreach (@svcs) {
1157     my $svc_error->{'description'} = $_->description;
1158     $svc_error->{'link'} = $opts->{'fsurl'}."/edit/svc_broadband.cgi?".$_->svcnum;
1159
1160     foreach my $s ('speed_up', 'speed_down', 'ip_addr') {
1161         $svc_error->{'errors'}->{$s} = "Field ".$s." is not set and is required for this service to be exported to Saisei." unless $_->$s;
1162     }
1163
1164     my $sector = FS::Record::qsearchs({
1165         'table' => 'tower_sector',
1166         'extra_sql' => " WHERE sectornum = ".$_->sectornum." AND sectorname != '_default'",
1167     }) if $_->sectornum;
1168     if (!$sector) {
1169       $svc_error->{'errors'}->{'sectornum'} = "No tower sector is set for this service. There needs to be a tower and sector set to be exported to Saisei.";
1170     }
1171     else {
1172       foreach my $s ('up_rate_limit', 'down_rate_limit') {
1173         $svc_error->{'errors'}->{'sectornum'} = "The sector ".$sector->description." does not have a ".$s." set. The sector needs a ".$s." set to be exported to Saisei."
1174           unless $sector->$s;
1175       }
1176     }
1177     $cust_svc_error->{$_->svcnum} = $svc_error if $svc_error->{'errors'};
1178   }
1179
1180   $cust_svc_export_error->{"provisioned_services"}->{'description'} = "Provisioned services";
1181   $cust_svc_export_error->{"provisioned_services"}->{'count'} = $svc_count;
1182   $cust_svc_export_error->{"provisioned_services"}->{'errors'} = $cust_svc_error if $cust_svc_error;
1183
1184   push @export_error, $cust_svc_export_error;
1185
1186
1187   ##  check all towers and sectors for export errors
1188   my $tower_sector_export_error;
1189   my @towers = FS::Record::qsearch({
1190     'table' => 'tower',
1191   });
1192   my $tower_count = scalar @towers;
1193
1194   my $towers_error;
1195   foreach (@towers) {
1196     my $tower_error->{'description'} = $_->towername;
1197     $tower_error->{'link'} = $opts->{'fsurl'}."/edit/tower.html?".$_->towernum;
1198
1199     foreach my $s ('up_rate_limit', 'down_rate_limit') {
1200         $tower_error->{'errors'}->{$s} = "Field ".$s." is not set for the tower, this is required for this tower to be exported to Saisei." unless $_->$s;
1201     }
1202
1203     my @sectors = FS::Record::qsearch({
1204         'table' => 'tower_sector',
1205         'extra_sql' => " WHERE towernum = ".$_->towernum." AND sectorname != '_default' AND (up_rate_limit IS NULL OR down_rate_limit IS NULL)",
1206     }) if $_->towernum;
1207     foreach my $sector (@sectors) {
1208       foreach my $s ('up_rate_limit', 'down_rate_limit') {
1209         $tower_error->{'errors'}->{'sector_'.$s} = "The sector ".$sector->description." does not have a ".$s." set. The sector needs a ".$s." set to be exported to Saisei."
1210           if !$sector->$s;
1211       }
1212     }
1213     $towers_error->{$_->towernum} = $tower_error if $tower_error->{'errors'};
1214   }
1215
1216   $tower_sector_export_error->{"tower_sector"}->{'description'} = "Tower / Sector";
1217   $tower_sector_export_error->{"tower_sector"}->{'count'} = $tower_count;
1218   $tower_sector_export_error->{"tower_sector"}->{'errors'} = $towers_error if $towers_error;
1219
1220   push @export_error, $tower_sector_export_error;
1221
1222   return [@export_error];
1223
1224 }
1225
1226 =head1 SEE ALSO
1227
1228 L<FS::part_export>
1229
1230 =cut
1231
1232 1;