summaryrefslogtreecommitdiff
path: root/FS/FS/part_export/broadworks.pm
blob: 611bd00ecd557105d831ad15ea5ee7ff4a7af2dc (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
package FS::part_export::broadworks;

use base qw( FS::part_export );
use strict;

use Tie::IxHash;
use FS::Record qw(dbh qsearch qsearchs);
use Locale::SubCountry;
use Carp qw(carp);

our $me = '[broadworks]';
our %client; # exportnum => client object
our %expire; # exportnum => timestamp on which to refresh the client

tie my %options, 'Tie::IxHash',
  'service_provider'=> { label => 'Service Provider ID' },
  'admin_user'      => { label => 'Administrative user ID' },
  'admin_pass'      => { label => 'Administrative password' },
  'domain'          => { label => 'Domain' },
  'user_limit'      => { label    => 'Maximum users per customer',
                         default  => 100 },
  'debug'           => { label => 'Enable debugging',
                         type  => 'checkbox',
                       },
;

# do we need roles for this?
# no. cust_main -> group, svc_phone -> pilot/single user, 
# phone_device -> access device
#
# phase 2: svc_pbx -> trunk group, pbx_extension -> trunk user

our %info = (
  'svc'      => [qw( svc_phone svc_pbx )], # part_device?
  'desc'     =>
    'Provision phone and PBX services to a Broadworks Application Server',
  'options'  => \%options,
  'notes'    => <<'END'
<P>Export to <b>BroadWorks Application Server</b>.</P>
<P>In the simple case where one IP phone corresponds to one public phone
number, this requires a svc_phone definition and a part_device. The "title"
field ("external name") of the part_device must be one of the access device
type names recognized by BroadWorks, such as "Polycom Soundpoint IP 550",
"SNOM 320", or "Generic SIP Phone".</P>
<P>Each phone service must have a device linked before it will be functional.
Until then, authentication will be denied.</P>
END
);

sub _export_insert {
  my($self, $svc_x) = (shift, shift);

  my $cust_main = $svc_x->cust_main;
  my ($groupId, $error) = $self->set_cust_main_Group($cust_main);
  return $error if $error;

  if ( $svc_x->isa('FS::svc_phone') ) {
    my $userId;
    ($userId, $error) = $self->set_svc_phone_User($svc_x, $groupId);

    $error ||= $self->set_sip_authentication($userId, $userId, $svc_x->sip_password);

    return $error if $error;

  } elsif ( $svc_x->isa('FS::svc_pbx') ) {
    # noop
  }

  '';
}

sub _export_replace {
  my($self, $svc_new, $svc_old) = @_;

  my $cust_main = $svc_new->cust_main;
  my ($groupId, $error) = $self->set_cust_main_Group($cust_main);
  return $error if $error;

  if ( $svc_new->isa('FS::svc_phone') ) {
    my $oldUserId = $self->userId($svc_old);
    my $newUserId = $self->userId($svc_new);

    if ( $oldUserId ne $newUserId ) {
      my ($success, $message) = $self->request(
        User => 'UserModifyUserIdRequest',
        userId    => $oldUserId,
        newUserId => $newUserId
      );
      return $message if !$success;

      if ( my $device = qsearchs('phone_device', { svcnum => $svc_new->svcnum }) ) {
        # there's a Line/Port configured for the device, and it also needs to be renamed.
        $error ||= $self->set_endpoint( $newUserId, $self->deviceName($device) );
      }
    }

    if ( $svc_old->phonenum ne $svc_new->phonenum ) {
      $error ||= $self->release_number($svc_old->phonenum, $groupId);
    }

    my $userId;
    ($userId, $error) = $self->set_svc_phone_User($svc_new, $groupId);
    $error ||= $self->set_sip_authentication($userId, $userId, $svc_new->sip_password);

    if ($error and $oldUserId ne $newUserId) {
      # rename it back, then
      my ($success, $message) = $self->request(
        User => 'UserModifyUserIdRequest',
        userId    => $newUserId,
        newUserId => $oldUserId
      );
      # if it fails, we can't really fix it
      return "$error; unable to reverse user ID change: $message" if !$success;
    }

    return $error if $error;

  } elsif ( $svc_new->isa('FS::svc_pbx') ) {
    # noop
  }

  '';
}

sub _export_delete {
  my ($self, $svc_x) = @_;

  my $cust_main = $svc_x->cust_main;
  my $groupId = $self->groupId($cust_main);

  if ( $svc_x->isa('FS::svc_phone') ) {
    my $userId = $self->userId($svc_x);
    my $error = $self->delete_User($userId)
             || $self->release_number($svc_x->phonenum, $groupId);
    return $error if $error;
  } elsif ( $svc_x->isa('FS::svc_pbx') ) {
    # noop
  }

  # find whether the customer still has any services on this platform
  # (other than the one being deleted)
  my %svcparts = map { $_->svcpart => 1 } $self->export_svc;
  my $svcparts = join(',', keys %svcparts);
  my $num_svcs = FS::cust_svc->count(
    '(select custnum from cust_pkg where cust_pkg.pkgnum = cust_svc.pkgnum) '.
    ' = ? '.
    ' AND svcnum != ?'.
    " AND svcpart IN ($svcparts)",
    $cust_main->custnum,
    $svc_x->svcnum
  );

  if ( $num_svcs == 0 ) {
    warn "$me removed last service for group $groupId; deleting group.\n";
    my $error = $self->delete_Group($groupId);
    warn "$me error deleting group: $error\n" if $error;
    return "$error (removing customer group)" if $error;
  }

  '';
}

sub export_device_insert {
  my ($self, $svc_x, $device) = @_;

  if ( $FS::svc_Common::noexport_hack ) {
    carp 'export_device_insert() suppressed by noexport_hack'
      if $self->option('debug');
    return;
  }

  if ( $device->count('svcnum = ?', $svc_x->svcnum) > 1 ) {
    return "This service already has a device.";
  }

  my $cust_main = $svc_x->cust_main;
  my $groupId = $self->groupId($cust_main);

  my ($deviceName, $error) = $self->set_device_AccessDevice($device, $groupId);
  return $error if $error;

  if ( $device->isa('FS::phone_device') ) {
    return $self->set_endpoint( $self->userId($svc_x), $deviceName);
  } # else pbx_device, extension_device

  '';
}

sub export_device_replace {
  my ($self, $svc_x, $new_device, $old_device) = @_;

  if ( $FS::svc_Common::noexport_hack ) {
    carp 'export_device_replace() suppressed by noexport_hack'
      if $self->option('debug');
    return;
  }

  my $cust_main = $svc_x->cust_main;
  my $groupId = $self->groupId($cust_main);

  my $new_deviceName = $self->deviceName($new_device);
  my $old_deviceName = $self->deviceName($old_device);

  if ($new_deviceName ne $old_deviceName) {

    # do it in this order to switch the service endpoint over to the new 
    # device.
    return $self->export_device_insert($svc_x, $new_device)
        || $self->delete_Device($old_deviceName, $groupId);

  } else { # update in place

    my ($deviceName, $error) = $self->set_device_AccessDevice($new_device, $groupId);
    return $error if $error;

  }
}

sub export_device_delete {
  my ($self, $svc_x, $device) = @_;

  if ( $FS::svc_Common::noexport_hack ) {
    carp 'export_device_delete() suppressed by noexport_hack'
      if $self->option('debug');
    return;
  }

  if ( $device->isa('FS::phone_device') ) {
    my $error = $self->set_endpoint( $self->userId($svc_x), '' );
    return $error if $error;
  } # else...

  return $self->delete_Device($self->deviceName($device));
}


=head2 CREATE-OR-UPDATE METHODS

These take a Freeside object that can be exported to the Broadworks system,
determine if it already has been exported, and if so, update it to match the
Freeside object. If it's not already there, they create it. They return a list
of two objects:
- that object's identifying string or hashref or whatever in Broadworks, and
- an error message, if creating the object failed.

=over 4

=item set_cust_main_Group CUST_MAIN

Takes a L<FS::cust_main>, creates a Group for the customer, and returns a 
GroupId. If the Group exists, it will be updated with the current customer
and export settings.

=cut

sub set_cust_main_Group {
  my $self = shift;
  my $cust_main = shift;
  my $location = $cust_main->ship_location;

  my $LSC = Locale::SubCountry->new($location->country)
    or return(0, "Invalid country code ".$location->country);
  my $state_name;
  if ( $LSC->has_sub_countries ) {
    $state_name = $LSC->full_name( $location->state );
  }

  my $groupId = $self->groupId($cust_main);
  my %group_info = (
    $self->SPID,
    groupId           => $groupId,
    defaultDomain     => $self->option('domain'),
    userLimit         => $self->option('user_limit'),
    groupName         => $cust_main->name_short,
    callingLineIdName => $cust_main->name_short,
    contact => {
      contactName     => $cust_main->contact_firstlast,
      contactNumber   => (   $cust_main->daytime
                          || $cust_main->night
                          || $cust_main->mobile
                          || undef
                         ),
      contactEmail    => ( ($cust_main->all_emails)[0] || undef ),
    },
    address => {
      addressLine1    => $location->address1,
      addressLine2    => ($location->address2 || undef),
      city            => $location->city,
      stateOrProvince => $state_name,
      zipOrPostalCode => $location->zip,
      country         => $location->country,
    },
  );

  my ($success, $message) = $self->request('Group' => 'GroupGetRequest14sp7',
    $self->SPID,
    groupId => $groupId
  );

  if ($success) { # update it with the curent params

    ($success, $message) =
      $self->request('Group' => 'GroupModifyRequest', %group_info);

  } elsif ($message =~ /Group not found/) {

    # create a new group
    ($success, $message) =
      $self->request('Group' => 'GroupAddRequest', %group_info);

    if ($success) {
      # tell the group that its users in general are allowed to use
      # Authentication
      ($success, $message) = $self->request(
        'Group' => 'GroupServiceModifyAuthorizationListRequest',
        $self->SPID,
        groupId => $groupId,
        userServiceAuthorization => {
          serviceName => 'Authentication',
          authorizedQuantity => { unlimited => 'true' },
        },
      );
    }

    if ($success) {
      # tell the group that each new user, specifically, is allowed to 
      # use Authentication
      ($success, $message) = $self->request(
        'Group' => 'GroupNewUserTemplateAssignUserServiceListRequest',
        $self->SPID,
        groupId => $groupId,
        serviceName => 'Authentication',
      );
    }

  } # else we somehow failed to fetch the group; throw an error

  if ($success) {
    return ($groupId, '');
  } else {
    return ('', $message);
  }
}

=item set_svc_phone_User SVC_PHONE, GROUPID

Creates a User object corresponding to this svc_phone, in the specified 
group. If the User already exists, updates the record with the current
customer name (or phone name), phone number, and access device.

=cut

sub set_svc_phone_User {
  my ($self, $svc_phone, $groupId) = @_;

  my $error;

  # make sure the phone number is available
  $error = $self->assign_number( $svc_phone->phonenum, $groupId );

  my $userId = $self->userId($svc_phone);
  my $cust_main = $svc_phone->cust_main;

  my ($first, $last);
  if ($svc_phone->phone_name =~ /,/) {
    ($last, $first) = split(/,\s*/, $svc_phone->phone_name);
  } elsif ($svc_phone->phone_name =~ / /) {
    ($first, $last) = split(/ +/, $svc_phone->phone_name, 2);
  } else {
    $first = $cust_main->first;
    $last = $cust_main->last;
  }

  my %new_user = (
    $self->SPID,
    groupId                 => $groupId,
    userId                  => $userId,
    lastName                => $last,
    firstName               => $first,
    callingLineIdLastName   => $last,
    callingLineIdFirstName  => $first,
    password                => $svc_phone->sip_password,
    # not supported: nameDialingName; Hiragana names
    phoneNumber             => $svc_phone->phonenum,
    callingLinePhoneNumber  => $svc_phone->phonenum,
  );

  # does the user exist?
  my ($success, $message) = $self->request(
    'User' => 'UserGetRequest21',
    userId => $userId
  );

  if ( $success ) { # modify in place

    ($success, $message) = $self->request(
      'User' => 'UserModifyRequest17sp4',
      %new_user
    );

  } elsif ( $message =~ /User not found/ ) { # create new

    ($success, $message) = $self->request(
      'User' => 'UserAddRequest17sp4',
      %new_user
    );

  }

  if ($success) {
    return ($userId, '');
  } else {
    return ('', $message);
  }
}

=item set_device_AccessDevice DEVICE, [ GROUPID ]

Creates/updates an Access Device Profile. This is a record for a 
I<specific physical device> that can send/receive calls. (Not to be confused
with an "Access Device Endpoint", which is a I<port> on such a device.) DEVICE
can be any record with a foreign key to L<FS::part_device>.

If GROUPID is specified, this device profile will be created at the Group
level in that group; otherwise it will be a ServiceProvider level record.

=cut

sub set_device_AccessDevice {
  my $self = shift;
  my $device = shift;
  my $groupId = shift;

  my $deviceName = $self->deviceName($device);

  my $svc_x;
  if ($device->svcnum) {
    $svc_x = FS::cust_svc->by_key($device->svcnum)->svc_x;
  } else {
    $svc_x = FS::svc_phone->new({}); # returns empty for all fields
  }

  my $part_device = $device->part_device
    or return ('', "devicepart ".$device->part_device." not defined" );

  # required fields
  my %new_device = (
    $self->SPID,
    deviceName        => $deviceName,
    deviceType        => $part_device->title,
    description       => ($svc_x->title # svc_pbx
                          || $part_device->devicename), # others
  );

  # optional fields
  $new_device{netAddress} = $svc_x->ip_addr if $svc_x->ip_addr; # svc_pbx only
  $new_device{macAddress} = $device->mac_addr if $device->mac_addr;

  my %find_device = (
    $self->SPID,
    deviceName => $deviceName
  );
  my $level = 'ServiceProvider';

  if ( $groupId ) {
    $level = 'Group';
    $find_device{groupId} = $new_device{groupId} = $groupId;
  } else {
    # shouldn't be used in our current design
    warn "$me creating access device $deviceName at Service Provider level\n";
  }

  my ($success, $message) = $self->request(
    $level, $level.'AccessDeviceGetRequest18sp1',
    %find_device
  );

  if ( $success ) { # modify in place

    ($success, $message) = $self->request(
      $level => $level.'AccessDeviceModifyRequest14',
      %new_device
    );

  } elsif ( $message =~ /Access Device not found/ ) { # create new

    ($success, $message) = $self->request(
      $level => $level.'AccessDeviceAddRequest14',
      %new_device
    );

  }

  if ($success) {
    return ($deviceName, '');
  } else {
    return ('', $message);
  }
}

=back

=head2 PROVISIONING METHODS

These return an error string on failure, and an empty string on success.

=over 4

=item assign_number NUMBER, GROUPID

Assigns a phone number to a group. If it's assigned to a different group or
doesn't belong to the service provider, this will fail. If it's already 
assigned to I<this> group, it will do nothing and return success.

=cut

sub assign_number {
  my ($self, $number, $groupId) = @_;
  # see if it's already assigned
  my ($success, $message) = $self->request(
    Group => 'GroupDnGetAssignmentListRequest18',
    $self->SPID,
    groupId           => $groupId,
    searchCriteriaDn  => {
      mode  => 'Equal To',
      value => $number,
      isCaseInsensitive => 'false',
    },
  );
  return "$message (checking phone number status)" if !$success;
  my $result = $self->oci_table( $message->{dnTable} );
  return '' if @$result > 0;

  ($success, $message) = $self->request(
    Group => 'GroupDnAssignListRequest',
    $self->SPID,
    groupId     => $groupId,
    phoneNumber => $number,
  );

  $success ? '' : $message;
}

=item release_number NUMBER, GROUPID

Unassigns a phone number from a group. If it's assigned to a user in the
group then this will fail. If it's not assigned to the group at all, this
does nothing.

=cut

sub release_number {
  my ($self, $number, $groupId) = @_;
  # see if it's already assigned
  my ($success, $message) = $self->request(
    Group => 'GroupDnGetAssignmentListRequest18',
    $self->SPID,
    groupId           => $groupId,
    searchCriteriaDn  => {
      mode  => 'Equal To',
      value => $number,
      isCaseInsensitive => 'false',
    },
  );
  return "$message (checking phone number status)" if !$success;
  my $result = $self->oci_table( $message->{dnTable} );
  return '' if @$result == 0;

  ($success, $message) = $self->request(
    Group => 'GroupDnUnassignListRequest',
    $self->SPID,
    groupId     => $groupId,
    phoneNumber => $number,
  );

  $success ? '' : $message;
}

=item set_endpoint USERID [, DEVICENAME ]

Sets the endpoint for communicating with USERID to DEVICENAME. For now, this
assumes that all devices are defined at Group level.

If DEVICENAME is null, the user will be set to have no endpoint.

=cut
      
# we only support linePort = userId, and no numbered ports

sub set_endpoint {
  my ($self, $userId, $deviceName) = @_;

  my $endpoint;
  if ( length($deviceName) > 0 ) {
    $endpoint = {
      accessDeviceEndpoint => {
        linePort      => $userId,
        accessDevice  => {
          deviceLevel => 'Group',
          deviceName  => $deviceName,
        },
      }
    };
  } else {
    $endpoint = undef;
  }
  my ($success, $message) = $self->request(
    User => 'UserModifyRequest17sp4',
    userId    => $userId,
    endpoint  => $endpoint,
  );

  $success ? '' : $message;
}

=item set_sip_authentication USERID, NAME, PASSWORD

Sets the SIP authentication credentials for USERID to (NAME, PASSWORD).

=cut

sub set_sip_authentication {
  my ($self, $userId, $userName, $password) = @_;

  my ($success, $message) = $self->request(
    'Services/ServiceAuthentication' => 'UserAuthenticationModifyRequest',
    userId      => $userId,
    userName    => $userName,
    newPassword => $password,
  );

  $success ? '' : $message;
}

=item delete_group GROUPID

Deletes the group GROUPID.

=cut

sub delete_Group {
  my ($self, $groupId) = @_;

  my ($success, $message) = $self->request(
    Group => 'GroupDeleteRequest',
    $self->SPID,
    groupId => $groupId
  );
  if ( $success or $message =~ /Group not found/ ) {
    return '';
  } else {
    return $message;
  }
}

=item delete_User USERID

Deletes the user USERID, and releases its phone number if it has one.

=cut

sub delete_User {
  my ($self, $userId) = @_;

  my ($success, $message) = $self->request(
    User => 'UserDeleteRequest',
    userId => $userId
  );
  if ($success or $message =~ /User not found/) {
    return '';
  } else {
    return $message;
  }
}

=item delete_Device DEVICENAME[, GROUPID ]

Deletes the access device DEVICENAME (from group GROUPID, or from the service
provider if there is no GROUPID).

=cut

sub delete_Device {
  my ($self, $deviceName, $groupId) = @_;

  my ($success, $message);
  if ( $groupId ) {
    ($success, $message) = $self->request(
      Group => 'GroupAccessDeviceDeleteRequest',
      $self->SPID,
      groupId => $groupId,
      deviceName => $deviceName,
    );
  } else {
    ($success, $message) = $self->request(
      ServiceProvider => 'ServiceProviderAccessDeviceDeleteRequest',
      $self->SPID,
      deviceName => $deviceName,
    );
  }
  if ( $success or $message =~ /Access Device not found/ ) {
    return '';
  } else {
    return $message;
  }
}

=back

=head2 CONVENIENCE METHODS

=over 4

=item SPID

Returns 'serviceProviderId' => the service_provider option. This is commonly
needed in request parameters.

=item groupId CUST_MAIN

Returns the groupID that goes with the specified customer.

=item userId SVC_X

Returns the userId (including domain) that should go with the specified
service.

=item deviceName DEVICE

Returns the access device name that should go with the specified phone_device
or pbx_device.

=cut

sub SPID {
  my $self = shift;
  my $id = $self->option('service_provider') or die 'service provider not set';
  'serviceProviderId' => $id
}

sub groupId {
  my $self = shift;
  my $cust_main = shift;
  'cust_main#'.$cust_main->custnum;
}

sub userId {
  my $self = shift;
  my $svc = shift;
  my $userId;
  if ($svc->phonenum) {
    $userId = $svc->phonenum;
  } else { # pbx_extension needs one of these
    die "can't determine userId for non-svc_phone service";
  }
  my $domain = $self->option('domain'); # domsvc?
  $userId .= '@' . $domain if $domain;

  return $userId;
}

sub deviceName {
  my $self = shift;
  my $device = shift;
  $device->mac_addr || ($device->table . '#' . $device->devicenum);
}

=item oci_table HASHREF

Converts the base OCITable type into an arrayref of hashrefs.

=cut

sub oci_table {
  my $self = shift;
  my $oci_table = shift;
  my @colnames = $oci_table->{colHeading};
  my @data;
  foreach my $row (@{ $oci_table->{row} }) {
    my %hash;
    @hash{@colnames} = @{ $row->{col} };
    push @data, \%hash;
  }

  \@data;
}

#################
# DID SELECTION #
#################



################
# CALL DETAILS #
################

=item import_cdrs START, END

Retrieves CDRs for calls in the date range from START to END and inserts them
as a new CDR batch. On success, returns a new cdr_batch object. On failure,
returns an error message. If there are no new CDRs, returns nothing.

=cut

##############
# API ACCESS #
##############

=item request SCOPE, COMMAND, [ ARGUMENTS... ]

Wrapper for L<BroadWorks::OCI/request>. The client object will be cached.
Returns two values: a flag, true or false, indicating success of the request,
and the decoded response message as a hashref.

On failure of the request (or failure to authenticate), the response message
will be a simple scalar containing the error message.

=cut

sub request {
  my $self = shift;

  delete $client{$self->exportnum} if $expire{$self->exportnum} < time;
  my $client = $client{$self->exportnum};
  if (!$client) {
    local $@;
    eval "use BroadWorks::OCI";
    die "$me $@" if $@;

    Log::Report::dispatcher('PERL', 'default',
      mode => ($self->option('debug') ? 'DEBUG' : 'NORMAL')
    );

    $client = BroadWorks::OCI->new(
      userId    => $self->option('admin_user'),
      password  => $self->option('admin_pass'),
    );
    my ($success, $message) = $client->login;
    return ('', $message) if !$success;

    $client{$self->exportnum} = $client; # if login succeeded
    $expire{$self->exportnum} = time + 120; # hardcoded, yeah
  }
  return $client->request(@_);
}

1;