diff --git a/lib/NGCP/Panel/Controller/SubscriberProfile.pm b/lib/NGCP/Panel/Controller/SubscriberProfile.pm new file mode 100644 index 0000000000..7ddd4a3727 --- /dev/null +++ b/lib/NGCP/Panel/Controller/SubscriberProfile.pm @@ -0,0 +1,594 @@ +package NGCP::Panel::Controller::SubscriberProfile; +use Sipwise::Base; + +BEGIN { extends 'Catalyst::Controller'; } + +use NGCP::Panel::Form::SubscriberProfile::Admin; +use NGCP::Panel::Form::SubscriberProfile::Reseller; +use NGCP::Panel::Form::SubscriberProfile::Clone; +use NGCP::Panel::Utils::Message; +use NGCP::Panel::Utils::Navigation; + +sub auto :Does(ACL) :ACLDetachTo('/denied_page') :AllowedRole(admin) :AllowedRole(reseller) { + my ($self, $c) = @_; + $c->log->debug(__PACKAGE__ . '::auto'); + NGCP::Panel::Utils::Navigation::check_redirect_chain(c => $c); + return 1; +} + +sub profile_list :Chained('/') :PathPart('subscriberprofile') :CaptureArgs(0) { + my ( $self, $c ) = @_; + + $c->stash->{profiles_rs} = $c->model('DB')->resultset('voip_subscriber_profiles'); + if($c->user->roles eq "admin") { + } else { + $c->stash->{profiles_rs} = $c->stash->{profiles_rs}->search({ + reseller_id => $c->user->reseller_id + }); + } + $c->stash->{profile_dt_columns} = NGCP::Panel::Utils::Datatables::set_columns($c, [ + { name => 'id', search => 1, title => $c->loc('#') }, + { name => 'reseller.name', search => 1, title => $c->loc('Reseller') }, + { name => 'name', search => 1, title => $c->loc('Name') }, + { name => 'description', search => 1, title => $c->loc('Description') }, + ]); + + $c->stash(template => 'subprofile/list.tt'); +} + +sub root :Chained('profile_list') :PathPart('') :Args(0) { + my ($self, $c) = @_; +} + +sub ajax :Chained('profile_list') :PathPart('ajax') :Args(0) { + my ($self, $c) = @_; + my $rs = $c->stash->{profiles_rs}; + NGCP::Panel::Utils::Datatables::process($c, $rs, $c->stash->{profile_dt_columns}); + $c->detach( $c->view("JSON") ); +} + +sub base :Chained('profile_list') :PathPart('') :CaptureArgs(1) { + my ($self, $c, $profile_id) = @_; + + unless($profile_id && $profile_id->is_integer) { + NGCP::Panel::Utils::Message->error( + c => $c, + log => 'Invalid subscriber profile id detected', + desc => $c->loc('Invalid subscriber profile id detected'), + ); + NGCP::Panel::Utils::Navigation::back_or($c, $c->uri_for('/rewrite')); + } + + my $res = $c->stash->{profiles_rs}->find($profile_id); + unless(defined($res)) { + NGCP::Panel::Utils::Message->error( + c => $c, + log => 'Subscriber profile does not exist', + desc => $c->loc('Subscriber profile does not exist'), + ); + NGCP::Panel::Utils::Navigation::back_or($c, $c->uri_for('/subscriberprofile')); + } + $c->stash(profile_result => $res); +} + +sub create :Chained('profile_list') :PathPart('create') :Args(0) { + my ($self, $c) = @_; + + my $posted = ($c->request->method eq 'POST'); + my $params = {}; + $params = $params->merge($c->session->{created_objects}); + my $form; + if($c->user->roles eq "admin") { + $form = NGCP::Panel::Form::SubscriberProfile::Admin->new(ctx => $c); + $form->create_structure($form->field_names); + } else { + $form = NGCP::Panel::Form::SubscriberProfile::Reseller->new(ctx => $c); + $form->create_structure($form->field_names); + } + $form->process( + posted => $posted, + params => $c->request->params, + item => $params, + ); + NGCP::Panel::Utils::Navigation::check_form_buttons( + c => $c, + form => $form, + fields => { + 'reseller.create' => $c->uri_for('/reseller/create'), + }, + back_uri => $c->req->uri, + ); + if($posted && $form->validated) { + try { + my $schema = $c->model('DB'); + $schema->txn_do(sub { + my $reseller_id; + if($c->user->roles eq "admin") { + $reseller_id = $form->values->{reseller}{id}; + } else { + $reseller_id = $c->user->reseller_id; + } + delete $form->values->{reseller}; + my $name = delete $form->values->{name}; + my $desc = delete $form->values->{description}; + my $profile = $c->stash->{profiles_rs}->create({ + reseller_id => $reseller_id, + name => $name, + description => $desc, + }); + + # TODO: save profile_attributes here too + + + delete $c->session->{created_objects}->{reseller}; + }); + $c->flash(messages => [{type => 'success', text => $c->loc('Subscriber profile successfully created')}]); + } catch($e) { + NGCP::Panel::Utils::Message->error( + c => $c, + error => $e, + desc => $c->loc('Failed to create subscriber profile.'), + ); + } + NGCP::Panel::Utils::Navigation::back_or($c, $c->uri_for('/subscriberprofile')); + } + + $c->stash(form => $form); + $c->stash(create_flag => 1); +} + +sub edit :Chained('base') :PathPart('edit') { + my ($self, $c) = @_; + + my $posted = ($c->request->method eq 'POST'); + my $params = { $c->stash->{profile_result}->get_inflated_columns }; + $params->{reseller}{id} = delete $params->{reseller_id}; + $params = $params->merge($c->session->{created_objects}); + my $form; + if($c->user->roles eq "admin") { + $form = NGCP::Panel::Form::SubscriberProfile::Admin->new(ctx => $c); + $form->create_structure($form->field_names); + } else { + $form = NGCP::Panel::Form::SubscriberProfile::Reseller->new(ctx => $c); + $form->create_structure($form->field_names); + } + $form->process( + posted => $posted, + params => $c->request->params, + item => $params, + ); + NGCP::Panel::Utils::Navigation::check_form_buttons( + c => $c, + form => $form, + fields => { + 'reseller.create' => $c->uri_for('/reseller/create'), + }, + back_uri => $c->req->uri, + ); + if($posted && $form->validated) { + try { + if($c->user->is_superuser) { + $form->values->{reseller_id} = $form->values->{reseller}{id}; + delete $form->values->{reseller}; + } + $c->stash->{profile_result}->update($form->values); + delete $c->session->{created_objects}->{reseller}; + $c->flash(messages => [{type => 'success', text => $c->loc('Subscriber profile successfully updated')}]); + } catch($e) { + NGCP::Panel::Utils::Message->error( + c => $c, + error => $e, + desc => $c->loc('Failed to update subscriber profile.'), + ); + } + NGCP::Panel::Utils::Navigation::back_or($c, $c->uri_for('/subscriberprofile')); + } + + $c->stash(form => $form); + $c->stash(edit_flag => 1); +} + +sub delete :Chained('base') :PathPart('delete') { + my ($self, $c) = @_; + + try { + $c->stash->{profile_result}->delete; + $c->flash(messages => [{type => 'success', text => $c->loc('Subscriber profile successfully deleted')}]); + } catch($e) { + NGCP::Panel::Utils::Message->error( + c => $c, + error => $e, + desc => $c->loc('Failed to delete subscriber profile.'), + ); + } + NGCP::Panel::Utils::Navigation::back_or($c, $c->uri_for('/subscriberprofile')); +} + +sub clone :Chained('base') :PathPart('clone') { + my ($self, $c) = @_; + + my $posted = ($c->request->method eq 'POST'); + my $params = { $c->stash->{profile_result}->get_inflated_columns }; + $params = $params->merge($c->session->{created_objects}); + my $form = NGCP::Panel::Form::SubscriberProfile::Clone->new; + $form->process( + posted => $posted, + params => $c->request->params, + item => $params, + ); + NGCP::Panel::Utils::Navigation::check_form_buttons( + c => $c, + form => $form, + fields => {}, + back_uri => $c->req->uri, + ); + if($posted && $form->validated) { + try { + my $schema = $c->model('DB'); + $schema->txn_do(sub { + my $new_profile = $c->stash->{profiles_rs}->create({ + %{ $form->values }, + reseller_id => $c->stash->{profile_result}->reseller_id, + }); + + my @old_attributes = $c->stash->{profile_result}->profile_attributes->all; + for my $attr (@old_attributes) { + $new_profile->profile_attributes->create({ + attribute_id => $attr->id, + }); + } + }); + + $c->flash(messages => [{type => 'success', text => $c->loc('Subscriber profile successfully cloned')}]); + } catch($e) { + NGCP::Panel::Utils::Message->error( + c => $c, + error => $e, + desc => $c->loc('Failed to clone subscriber profile.'), + ); + } + NGCP::Panel::Utils::Navigation::back_or($c, $c->uri_for('/subscriberprofile')); + } + + $c->stash(form => $form); + $c->stash(create_flag => 1); + $c->stash(clone_flag => 1); +} + + +=pod +sub rules_list :Chained('set_base') :PathPart('rules') :CaptureArgs(0) { + my ( $self, $c ) = @_; + + my $rules_rs = $c->stash->{profile_result}->voip_rewrite_rules; + $c->stash(rules_rs => $rules_rs); + $c->stash(rules_uri => $c->uri_for_action("/rewrite/rules_root", [$c->req->captures->[0]])); + + $c->stash(template => 'rewrite/rules_list.tt'); +} + +sub rules_root :Chained('rules_list') :PathPart('') :Args(0) { + my ($self, $c) = @_; + + my $rules_rs = $c->stash->{rules_rs}; + my $param_move = $c->req->params->{move}; + my $param_where = $c->req->params->{where}; + + my $elem = $rules_rs->find($param_move) + if ($param_move && $param_move->is_integer && $param_where); + if($elem) { + my $use_next = ($param_where eq "down") ? 1 : 0; + my $swap_elem = $rules_rs->search({ + field => $elem->field, + direction => $elem->direction, + priority => { ($use_next ? '>' : '<') => $elem->priority }, + },{ + order_by => {($use_next ? '-asc' : '-desc') => 'priority'} + })->first; + try { + if ($swap_elem) { + my $tmp_priority = $swap_elem->priority; + $swap_elem->priority($elem->priority); + $elem->priority($tmp_priority); + $swap_elem->update; + $elem->update; + } elsif($use_next) { + my $last_priority = $c->stash->{rules_rs}->get_column('priority')->max() || 49; + $elem->priority(int($last_priority) + 1); + $elem->update; + } else { + my $last_priority = $c->stash->{rules_rs}->get_column('priority')->min() || 1; + $elem->priority(int($last_priority) - 1); + $elem->update; + } + } catch($e) { + NGCP::Panel::Utils::Message->error( + c => $c, + error => $e, + desc => $c->loc('Failed to move rewrite rule.'), + ); + } + } + + my @caller_in = $rules_rs->search({ + field => 'caller', + direction => 'in', + },{ + order_by => { -asc => 'priority' }, + })->all; + + my @callee_in = $rules_rs->search({ + field => 'callee', + direction => 'in', + },{ + order_by => { -asc => 'priority' }, + })->all; + + my @caller_out = $rules_rs->search({ + field => 'caller', + direction => 'out', + },{ + order_by => { -asc => 'priority' }, + })->all; + + my @callee_out = $rules_rs->search({ + field => 'callee', + direction => 'out', + },{ + order_by => { -asc => 'priority' }, + })->all; + + for my $row (@caller_in, @callee_in, @caller_out, @callee_out) { + my $mp = $row->match_pattern; + my $rp = $row->replace_pattern; + $mp =~ s/\$avp\(s\:(\w+)\)/\${$1}/g; + $rp =~ s/\$avp\(s\:(\w+)\)/\${$1}/g; + $row->match_pattern($mp); + $row->replace_pattern($rp); + } + + $c->stash(rules => { + caller_in => \@caller_in, + callee_in => \@callee_in, + caller_out => \@caller_out, + callee_out => \@callee_out, + }); + return; +} + +sub rules_base :Chained('rules_list') :PathPart('') :CaptureArgs(1) { + my ($self, $c, $rule_id) = @_; + + unless($rule_id && $rule_id->is_integer) { + NGCP::Panel::Utils::Message->error( + c => $c, + log => 'Invalid rewrite rule id detected', + desc => $c->loc('Invalid rewrite rule id detected'), + ); + NGCP::Panel::Utils::Navigation::back_or($c, $c->stash->{rules_uri}); + } + + my $res = $c->stash->{rules_rs}->find($rule_id); + unless(defined($res)) { + NGCP::Panel::Utils::Message->error( + c => $c, + log => 'Rewrite rule does not exist', + desc => $c->loc('Rewrite rule does not exist'), + ); + NGCP::Panel::Utils::Navigation::back_or($c, $c->stash->{rules_uri}); + } + $c->stash(rule_result => $res); +} + +sub rules_edit :Chained('rules_base') :PathPart('edit') { + my ($self, $c) = @_; + + my $posted = ($c->request->method eq 'POST'); + my $form = NGCP::Panel::Form::RewriteRule::Rule->new; + $form->process( + posted => $posted, + params => $c->request->params, + item => $c->stash->{rule_result}, + ); + NGCP::Panel::Utils::Navigation::check_form_buttons( + c => $c, + form => $form, + fields => {}, + back_uri => $c->req->uri, + ); + if($posted && $form->validated) { + try { + $c->stash->{rule_result}->update($form->values); + $self->_sip_dialplan_reload(); + $c->flash(messages => [{type => 'success', text => $c->loc('Rewrite rule successfully updated')}]); + } catch($e) { + NGCP::Panel::Utils::Message->error( + c => $c, + error => $e, + desc => $c->loc('Failed to update rewrite rule.'), + ); + } + NGCP::Panel::Utils::Navigation::back_or($c, $c->stash->{rules_uri}); + } + + $c->stash(form => $form); + $c->stash(edit_flag => 1); +} + +sub rules_delete :Chained('rules_base') :PathPart('delete') { + my ($self, $c) = @_; + + try { + $c->stash->{rule_result}->delete; + $self->_sip_dialplan_reload(); + $c->flash(messages => [{type => 'success', text => $c->loc('Rewrite rule successfully deleted') }]); + } catch($e) { + NGCP::Panel::Utils::Message->error( + c => $c, + error => $e, + desc => $c->loc('Failed to delete rewrite rule.'), + ); + }; + NGCP::Panel::Utils::Navigation::back_or($c, $c->stash->{rules_uri}); +} + +sub rules_create :Chained('rules_list') :PathPart('create') :Args(0) { + my ($self, $c) = @_; + + my $posted = ($c->request->method eq 'POST'); + my $form = NGCP::Panel::Form::RewriteRule::Rule->new; + $form->process( + posted => $posted, + params => $c->request->params, + ); + NGCP::Panel::Utils::Navigation::check_form_buttons( + c => $c, + form => $form, + fields => {}, + back_uri => $c->req->uri, + ); + if($posted && $form->validated) { + try { + my $last_priority = $c->stash->{rules_rs}->get_column('priority')->max() || 49; + $form->values->{priority} = int($last_priority) + 1; + $c->stash->{rules_rs}->create($form->values); + $self->_sip_dialplan_reload(); + $c->flash(messages => [{type => 'success', text => $c->loc('Rewrite rule successfully created') }]); + } catch($e) { + NGCP::Panel::Utils::Message->error( + c => $c, + error => $e, + desc => $c->loc('Failed to create rewrite rule.'), + ); + } + NGCP::Panel::Utils::Navigation::back_or($c, $c->stash->{rules_uri}); + } + + $c->stash(form => $form); + $c->stash(create_flag => 1); +} + +sub _sip_dialplan_reload { + my ($self) = @_; + my $dispatcher = NGCP::Panel::Utils::XMLDispatcher->new; + $dispatcher->dispatch("proxy-ng", 1, 1, < + +dialplan.reload + + +EOF + + return 1; +} + +=cut + +__PACKAGE__->meta->make_immutable; + +1; + +=head1 NAME + +NGCP::Panel::Controller::SubscriberProfile - Manage Subscriber Profiles + +=head1 DESCRIPTION + +Show/Edit/Create/Delete Rewrite Rule Sets. + +Show/Edit/Create/Delete Rewrite Rules within Rewrite Rule Sets. + +=head1 METHODS + +=head2 set_list + +Basis for provisioning.voip_rewrite_rule_sets. + +=head2 set_root + +Display rewrite rule sets through F template. + +=head2 set_ajax + +Get provisioning.voip_rewrite_rule_sets from the database and +output them as JSON. +The format is meant for parsing with datatables. + +=head2 set_base + +Fetch a provisioning.voip_rewrite_rule_set from the database by its id. + +=head2 set_edit + +Show a modal to edit a rewrite rule set determined by L. +The form used is L. + +=head2 set_delete + +Delete a rewrite rule set determined by L. + +=head2 set_clone + +Deep copy a rewrite rule set determined by L. The user can enter +a new name and description. The reseller is not configurable, but set by the +original rewrite rule set. The rewrite rules of the original rwrs are then +cloned and assigned to the new rwrs. + +=head2 set_create + +Show a modal to create a new rewrite rule set using the form +L. + +=head2 rules_list + +Basis for provisioning.voip_rewrite_rules. Chained from L and +therefore handles only rules for a certain rewrite rule set. + +=head2 rules_root + +Display rewrite rule sets through F template. +The rules are stashed to rules hashref which contains the keys +"caller_in", "callee_in", "caller_out", "callee_out". + +It swaps priority of two elements if "move" and "where" GET params are set. + +It modifies match_pattern and replace_pattern field to a certain output format +using regex. + +=head2 rules_base + +Fetch a rewrite rule from the database by its id. Will only find rules under +the current rule_set. + +=head2 rules_edit + +Show a modal to edit a rewrite rule determined by L. +The form used is L. + +=head2 rules_delete + +Delete a rewrite rule determined by L. + +=head2 rules_create + +Show a modal to create a new rewrite rule using the form +L. + +=head2 _sip_dialplan_reload + +This is ported from ossbss. + +Reloads dialplan cache of sip proxies. + +=head1 AUTHOR + +Gerhard Jungwirth C<< >> + +=head1 LICENSE + +This library is free software. You can redistribute it and/or modify +it under the same terms as Perl itself. + +=cut + +# vim: set tabstop=4 expandtab: diff --git a/lib/NGCP/Panel/Form/SubscriberProfile/Admin.pm b/lib/NGCP/Panel/Form/SubscriberProfile/Admin.pm new file mode 100644 index 0000000000..52a08d4f5f --- /dev/null +++ b/lib/NGCP/Panel/Form/SubscriberProfile/Admin.pm @@ -0,0 +1,24 @@ +package NGCP::Panel::Form::SubscriberProfile::Admin; + +use HTML::FormHandler::Moose; +extends 'NGCP::Panel::Form::SubscriberProfile::Reseller'; +use Moose::Util::TypeConstraints; + +has_field 'reseller' => ( + type => '+NGCP::Panel::Field::Reseller', + validate_when_empty => 1, + element_attr => { + rel => ['tooltip'], + title => ['The reseller this Subscriber Profile belongs to.'], + }, +); + +has_block 'fields' => ( + tag => 'div', + class => [qw/modal-body/], + render_list => [qw/reseller name description/], +); + +1; + +# vim: set tabstop=4 expandtab: diff --git a/lib/NGCP/Panel/Form/SubscriberProfile/Clone.pm b/lib/NGCP/Panel/Form/SubscriberProfile/Clone.pm new file mode 100644 index 0000000000..34c794e1df --- /dev/null +++ b/lib/NGCP/Panel/Form/SubscriberProfile/Clone.pm @@ -0,0 +1,47 @@ +package NGCP::Panel::Form::SubscriberProfile::Clone; + +use HTML::FormHandler::Moose; +extends 'HTML::FormHandler'; +use Moose::Util::TypeConstraints; + +use HTML::FormHandler::Widget::Block::Bootstrap; + +has '+widget_wrapper' => ( default => 'Bootstrap' ); +has_field 'submitid' => ( type => 'Hidden' ); +sub build_render_list {[qw/submitid fields actions/]} +sub build_form_element_class {[qw(form-horizontal)]} + +has_field 'name' => ( + type => 'Text', + required => 1, + label => 'New Name', +); + +has_field 'description' => ( + type => 'Text', + required => 0, + label => 'New Description', +); + +has_field 'clone' => ( + type => 'Submit', + value => 'Clone', + element_class => [qw/btn btn-primary/], + label => '', +); + +has_block 'fields' => ( + tag => 'div', + class => [qw/modal-body/], + render_list => [qw/name description/], +); + +has_block 'actions' => ( + tag => 'div', + class => [qw/modal-footer/], + render_list => [qw/clone/], +); + +1; + +# vim: set tabstop=4 expandtab: diff --git a/lib/NGCP/Panel/Form/SubscriberProfile/Reseller.pm b/lib/NGCP/Panel/Form/SubscriberProfile/Reseller.pm new file mode 100644 index 0000000000..3767aa3892 --- /dev/null +++ b/lib/NGCP/Panel/Form/SubscriberProfile/Reseller.pm @@ -0,0 +1,96 @@ +package NGCP::Panel::Form::SubscriberProfile::Reseller; + +use HTML::FormHandler::Moose; +extends 'HTML::FormHandler'; +use Moose::Util::TypeConstraints; + +use HTML::FormHandler::Widget::Block::Bootstrap; + +has '+widget_wrapper' => ( default => 'Bootstrap' ); +has_field 'submitid' => ( type => 'Hidden' ); +sub build_render_list {[qw/submitid fields actions/]} +sub build_form_element_class {[qw(form-horizontal)]} + +has_field 'name' => ( + type => 'Text', + required => 1, + element_attr => { + rel => ['tooltip'], + title => ['The name of the Subscriber Profile.'], + }, +); + +has_field 'description' => ( + type => 'Text', + required => 0, + element_attr => { + rel => ['tooltip'], + title => ['Arbitrary text.'], + }, +); + +has_field 'save' => ( + type => 'Submit', + value => 'Save', + element_class => [qw/btn btn-primary/], + label => '', +); + +has_block 'fields' => ( + tag => 'div', + class => [qw/modal-body/], + render_list => [qw/reseller name description/], +); + +has_block 'actions' => ( + tag => 'div', + class => [qw/modal-footer/], + render_list => [qw/save/], +); + +sub field_list { + my $self = shift; + my $c = $self->ctx; + return unless $c; + + my $pref_rs = $c->model('DB')->resultset('voip_preferences')->search({ + usr_pref => 1, + expose_to_customer => 1, + }); + + my $fields = []; + foreach my $pref($pref_rs->all) { + my $desc = $pref->description; + push @{ $fields }, $pref->attribute => { + type => 'Checkbox', + label => $pref->attribute, + checkbox_value => $pref->id, + element_attr => { + # rel => ['tooltip'], + # title => [$pref->description], + # checked => 'checked', + }, + }; + } + + return $fields; +} + +sub field_names { + my $self = shift; + + my %list = @{ $self->field_list }; + return [ sort keys %list ]; +} + +sub create_structure { + my $self = shift; + my $field_list = shift; + + my $list = $self->block('fields')->render_list; + $self->block('fields')->render_list([ @{ $list }, @{ $field_list } ]); +} + +1; + +# vim: set tabstop=4 expandtab: diff --git a/share/templates/subprofile/list.tt b/share/templates/subprofile/list.tt new file mode 100644 index 0000000000..1ad359d76c --- /dev/null +++ b/share/templates/subprofile/list.tt @@ -0,0 +1,32 @@ +[% site_config.title = c.loc('Subscriber Profiles') -%] +[% + helper.name = c.loc('Subscriber Profile'); + helper.identifier = 'subscriber_profile'; + helper.messages = messages; + helper.dt_columns = profile_dt_columns; + helper.length_change = 1; + + helper.close_target = close_target; + helper.create_flag = create_flag; + helper.edit_flag = edit_flag; + helper.form_object = form; + helper.ajax_uri = c.uri_for_action( "/subscriberprofile/ajax" ); + + IF clone_flag; + helper.name = c.loc('cloned Subscriber Profile'); + END; + + UNLESS c.user.read_only; + helper.dt_buttons = [ + { name = c.loc('Edit'), uri = "/subscriberprofile/'+full.id+'/edit", class = 'btn-small btn-primary', icon = 'icon-edit' }, + { name = c.loc('Delete'), uri = "/subscriberprofile/'+full.id+'/delete", class = 'btn-small btn-secondary', icon = 'icon-trash' }, + { name = c.loc('Clone'), uri = "/subscriberprofile/'+full.id+'/clone", class = 'btn-small btn-tertiary', icon = 'icon-retweet' }, + ]; + helper.top_buttons = [ + { name = c.loc('Create Subscriber Profile'), uri = c.uri_for('/subscriberprofile/create'), icon = 'icon-star' }, + ]; + END; + + PROCESS 'helpers/datatables.tt'; +-%] +[% # vim: set tabstop=4 syntax=html expandtab: -%]