diff options
-rw-r--r-- | CHANGELOG.md | 2 | ||||
-rw-r--r-- | perllib/FixMyStreet/App/Controller/Admin.pm | 11 | ||||
-rw-r--r-- | perllib/FixMyStreet/App/Controller/Admin/AreaStats.pm | 218 | ||||
-rw-r--r-- | perllib/FixMyStreet/Cobrand/Default.pm | 1 | ||||
-rw-r--r-- | perllib/FixMyStreet/TestMech.pm | 14 | ||||
-rw-r--r-- | t/Mock/MapIt.pm | 10 | ||||
-rw-r--r-- | t/app/controller/area_stats.t | 211 | ||||
-rw-r--r-- | templates/web/base/admin/areastats/area.html | 72 | ||||
-rw-r--r-- | templates/web/base/admin/areastats/index.html | 11 |
9 files changed, 546 insertions, 4 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 176f52460..d2333a85e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ ## Releases * Unreleased + - New features: + - Area summary statistics page in admin #1834 * v2.2 (13th September 2017) - New features: diff --git a/perllib/FixMyStreet/App/Controller/Admin.pm b/perllib/FixMyStreet/App/Controller/Admin.pm index ed40f4565..a47e74f19 100644 --- a/perllib/FixMyStreet/App/Controller/Admin.pm +++ b/perllib/FixMyStreet/App/Controller/Admin.pm @@ -553,7 +553,7 @@ sub fetch_translations : Private { $c->stash->{translations} = $translations; } -sub body : Chained('/') : PathPart('admin/body') : CaptureArgs(1) { +sub lookup_body : Private { my ( $self, $c, $body_id ) = @_; $c->stash->{body_id} = $body_id; @@ -561,7 +561,14 @@ sub body : Chained('/') : PathPart('admin/body') : CaptureArgs(1) { $c->detach( '/page_error_404_not_found', [] ) unless $body; $c->stash->{body} = $body; - +} + +sub body : Chained('/') : PathPart('admin/body') : CaptureArgs(1) { + my ( $self, $c, $body_id ) = @_; + + $c->forward('lookup_body'); + my $body = $c->stash->{body}; + if ($body->body_areas->first) { my $example_postcode = mySociety::MaPit::call('area/example_postcode', $body->body_areas->first->area_id); if ($example_postcode && ! ref $example_postcode) { diff --git a/perllib/FixMyStreet/App/Controller/Admin/AreaStats.pm b/perllib/FixMyStreet/App/Controller/Admin/AreaStats.pm new file mode 100644 index 000000000..932631cba --- /dev/null +++ b/perllib/FixMyStreet/App/Controller/Admin/AreaStats.pm @@ -0,0 +1,218 @@ +package FixMyStreet::App::Controller::Admin::AreaStats; +use Moose; +use namespace::autoclean; +use List::Util qw(sum); + +BEGIN { extends 'Catalyst::Controller'; } + +sub index : Path : Args(0) { + my ( $self, $c ) = @_; + + my $user = $c->user; + + if ($user->is_superuser) { + $c->forward('/admin/fetch_all_bodies'); + } elsif ( $user->from_body ) { + $c->forward('load_user_body', [ $user->from_body->id ]); + $c->stash->{body_id} = $user->from_body->id; + if ($user->area_id) { + $c->stash->{area_id} = $user->area_id; + $c->forward('setup_area'); + $c->visit( 'stats' ); + } else { + # visit body_stats so we load the list of child areas + $c->visit( 'body_stats', [ $user->from_body->id], [] ); + } + } else { + $c->detach( '/page_error_404_not_found' ); + } +} + +sub check_user : Private { + my ( $self, $c, $body_id, $area_id ) = @_; + + my $user = $c->user; + + return if $user->is_superuser; + + if ($body_id and $user->from_body->id eq $body_id) { + if (not $user->area_id) { + return; + } elsif ($area_id and $user->area_id eq $area_id) { + return; + } + } + + $c->detach( '/page_error_404_not_found' ); +} + +sub setup_area : Private { + my ($self, $c) = @_; + + my $area = mySociety::MaPit::call('area', $c->stash->{area_id} ); + $c->detach( '/page_error_404_not_found' ) if $area->{error}; + $c->stash->{area} = $area; +} + +sub body_base : Chained('/') : PathPart('admin/areastats') : CaptureArgs(1) { + my ($self, $c, $body_id) = @_; + + $c->forward('/admin/lookup_body', $body_id); + $c->stash->{areas} = mySociety::MaPit::call('area/children', [ $c->stash->{body_id} ] ); +} + +sub body_stats : Chained('body_base') : PathPart('') : Args(0) { + my ($self, $c) = @_; + + if ($c->get_param('area')) { + $c->forward('check_user', [$c->stash->{body_id}, $c->get_param('area')]); + $c->stash->{area_id} = $c->get_param('area'); + $c->forward('setup_area'); + } else { + $c->forward('check_user', [$c->stash->{body_id}]); + } + $c->forward('stats'); +} + +sub stats : Private { + my ($self, $c) = @_; + + my $date = DateTime->now->subtract(days => 30); + + $c->forward('/admin/fetch_contacts'); + + $c->stash->{template} = 'admin/areastats/area.html'; + + my $dtf = $c->model('DB')->storage->datetime_parser; + my $time = $dtf->format_datetime($date); + + my $params = { + 'me.confirmed' => { '>=', $time }, + }; + + my %area_param = (); + if ($c->stash->{area}) { + my $area_id = $c->stash->{area_id}; + $c->stash->{area_name} = $c->stash->{area}->{name}; + $params->{'problem.areas'} = { like => "%,$area_id,%" }; + %area_param = ( + areas => { like => "%,$area_id,%" }, + ); + } else { + $c->stash->{area_name} = $c->stash->{body}->name; + } + + my %by_category = map { $_->category => {} } $c->stash->{contacts}->all; + my %recent_by_category = map { $_->category => 0 } $c->stash->{contacts}->all; + + my $state_map = {}; + + $state_map->{$_} = 'open' foreach FixMyStreet::DB::Result::Problem->open_states; + $state_map->{$_} = 'closed' foreach FixMyStreet::DB::Result::Problem->closed_states; + $state_map->{$_} = 'fixed' foreach FixMyStreet::DB::Result::Problem->fixed_states; + $state_map->{$_} = 'scheduled' foreach ('planned', 'action scheduled'); + + # current problems by category and state + my $problems = $c->model('DB::Problem')->to_body( + $c->stash->{body} + )->search( + \%area_param, + { + group_by => [ 'category', 'state' ], + select => [ 'category', 'state', { count => 'me.id' } ], + as => [ qw/category state state_count/ ], + } + ); + + while (my $p = $problems->next) { + my $meta_state = $state_map->{$p->state}; + $by_category{$p->category}->{$meta_state} += $p->get_column('state_count'); + } + $c->stash->{by_category} = \%by_category; + + # problems this month by state + $c->stash->{$_} = 0 for values %$state_map; + + $c->stash->{open} = $c->model('DB::Problem')->to_body( + $c->stash->{body} + )->search( + { + %area_param, + confirmed => { '>=' => $time }, + } + )->count; + + my $comments = $c->model('DB::Comment')->to_body( + $c->stash->{body} + )->search( + $params, + { + join => 'problem' + } + ); + + # you can have multiple comments with the same problem state so need to only count + # one instance. + my %state_seen = (); + while (my $comment = $comments->next) { + my $meta_state = $state_map->{$comment->problem_state}; + my $key = $comment->problem->id . "-$meta_state"; + next if $state_seen{$key}; + $c->stash->{$meta_state} += 1; + $state_seen{$key} = 1; + } + + $params = { + %area_param, + 'me.confirmed' => { '>=', $time }, + }; + + # problems this month by category + my $recent_problems = $c->model('DB::Problem')->to_body( + $c->stash->{body} + )->search( + $params, + { + group_by => [ 'category' ], + select => [ 'category', { count => 'me.id' } ], + as => [ qw/category category_count/ ], + } + ); + + while (my $p = $recent_problems->next) { + $recent_by_category{$p->category} += $p->get_column('category_count'); + } + $c->stash->{recent_by_category} = \%recent_by_category; + + # average time to state change in last month + $params = { + %area_param, + 'problem.confirmed' => { '>=', $time }, + }; + + $comments = $c->model('DB::Comment')->to_body( + $c->stash->{body} + )->search( + { %$params, + 'me.id' => \"= (select min(id) from comment where me.problem_id=comment.problem_id)", + 'me.problem_state' => { '!=' => 'confirmed' }, + }, + { + select => [ + { avg => { extract => "epoch from me.confirmed-problem.confirmed" } }, + ], + as => [ qw/time/ ], + join => 'problem' + } + )->first; + $c->stash->{average} = int( ($comments->get_column('time')||0)/ 60 / 60 / 24 + 0.5 ); +} + +sub load_user_body : Private { + my ($self, $c, $body_id) = @_; + + $c->stash->{body} = $c->model('DB::Body')->find($body_id) + or $c->detach( '/page_error_404_not_found' ); +} + +1; diff --git a/perllib/FixMyStreet/Cobrand/Default.pm b/perllib/FixMyStreet/Cobrand/Default.pm index 250919d09..002d8e3da 100644 --- a/perllib/FixMyStreet/Cobrand/Default.pm +++ b/perllib/FixMyStreet/Cobrand/Default.pm @@ -637,6 +637,7 @@ sub admin_pages { 'summary' => [_('Summary'), 0], 'timeline' => [_('Timeline'), 5], 'stats' => [_('Stats'), 8], + 'areastats' => [_('Area Stats'), 14], }; # There are some pages that only super users can see diff --git a/perllib/FixMyStreet/TestMech.pm b/perllib/FixMyStreet/TestMech.pm index 46f5344e2..4bad1d17b 100644 --- a/perllib/FixMyStreet/TestMech.pm +++ b/perllib/FixMyStreet/TestMech.pm @@ -720,4 +720,18 @@ sub get_photo_data { }; } +sub create_comment_for_problem { + my ( $mech, $problem, $user, $name, $text, $anonymous, $state, $problem_state, $params ) = @_; + $params ||= {}; + $params->{problem_id} = $problem->id; + $params->{user_id} = $user->id; + $params->{name} = $name; + $params->{text} = $text; + $params->{anonymous} = $anonymous; + $params->{problem_state} = $problem_state; + $params->{state} = $state; + $params->{mark_fixed} = $problem_state && FixMyStreet::DB::Result::Problem->fixed_states()->{$problem_state} ? 1 : 0; + + FixMyStreet::App->model('DB::Comment')->create($params); +} 1; diff --git a/t/Mock/MapIt.pm b/t/Mock/MapIt.pm index 926d94b1e..6e3c5d673 100644 --- a/t/Mock/MapIt.pm +++ b/t/Mock/MapIt.pm @@ -92,8 +92,14 @@ sub dispatch_request { sub (GET + /area/*) { my ($self, $area) = @_; - my $response = { "id" => $area, "name" => "Area $area", "type" => "UTA" }; - return $self->output($response); + my $response; + if ($area eq '999') { + $response = { code => 404, error => "No Area matches the given query." }; + return [ 404, [ 'Content-Type' => 'application/json' ], [ $self->json->encode($response) ] ]; + } else { + $response = { "id" => $area, "name" => "Area $area", "type" => "UTA" }; + return $self->output($response); + } }, sub (GET + /area/*/children) { diff --git a/t/app/controller/area_stats.t b/t/app/controller/area_stats.t new file mode 100644 index 000000000..6ea03cabf --- /dev/null +++ b/t/app/controller/area_stats.t @@ -0,0 +1,211 @@ +use strict; +use warnings; +use Test::More; + +use FixMyStreet::TestMech; + +my $mech = FixMyStreet::TestMech->new; + +my $oxfordshire = $mech->create_body_ok(2237, 'Oxfordshire County Council'); +my $superuser = $mech->create_user_ok('superuser@example.com', name => 'Super User', is_superuser => 1); +my $oxfordshireuser = $mech->create_user_ok('counciluser@example.com', name => 'Council User', from_body => $oxfordshire); + +$mech->create_contact_ok( body_id => $oxfordshire->id, category => 'Potholes', email => 'potholes@example.com' ); +$mech->create_contact_ok( body_id => $oxfordshire->id, category => 'Traffic lights', email => 'lights@example.com' ); +$mech->create_contact_ok( body_id => $oxfordshire->id, category => 'Litter', email => 'litter@example.com' ); + +my $body_id = $oxfordshire->id; +my $area_id = '20720'; +my $alt_area_id = '20721'; + +$mech->create_problems_for_body(2, $oxfordshire->id, 'Title', { areas => ",$area_id,6753,2237,", created => \'current_timestamp', category => 'Potholes' }); +$mech->create_problems_for_body(3, $oxfordshire->id, 'Title', { areas => ",$area_id,6753,2237,", created => \'current_timestamp', category => 'Traffic lights' }); +$mech->create_problems_for_body(1, $oxfordshire->id, 'Title', { areas => ",$alt_area_id,6753,2237,", created => \'current_timestamp', category => 'Litter' }); + +my @scheduled_problems = $mech->create_problems_for_body(7, $oxfordshire->id, 'Title', { areas => ",$area_id,6753,2237,", created => \'current_timestamp', category => 'Traffic lights' }); +my @fixed_problems = $mech->create_problems_for_body(4, $oxfordshire->id, 'Title', { areas => ",$area_id,6753,2237,", created => \'current_timestamp', category => 'Potholes' }); +my @closed_problems = $mech->create_problems_for_body(3, $oxfordshire->id, 'Title', { areas => ",$area_id,6753,2237,", created => \'current_timestamp', category => 'Traffic lights' }); + +foreach my $problem (@scheduled_problems) { + $problem->update({ state => 'planned' }); + $mech->create_comment_for_problem($problem, $oxfordshireuser, 'Title', 'text', 0, 'confirmed', 'planned', { confirmed => \'current_timestamp' }); +} + +foreach my $problem (@fixed_problems) { + $problem->update({ state => 'fixed - council' }); + $mech->create_comment_for_problem($problem, $oxfordshireuser, 'Title', 'text', 0, 'confirmed', 'fixed', { confirmed => \'current_timestamp' }); +} + +foreach my $problem (@closed_problems) { + $problem->update({ state => 'closed' }); + $mech->create_comment_for_problem($problem, $oxfordshireuser, 'Title', 'text', 0, 'confirmed', 'closed', { confirmed => \'current_timestamp' }); +} + +$mech->log_in_ok( $superuser->email ); + +FixMyStreet::override_config { + MAPIT_URL => 'http://mapit.uk/', + ALLOWED_COBRANDS => [ 'oxfordshire' ], +}, sub { + subtest 'superuser gets areas listed' => sub { + $mech->create_body_ok(1234, 'Some Other Council'); + $mech->get_ok('/admin/areastats'); + $mech->content_contains('Oxfordshire County Council', 'Oxfordshire is shown on the page'); + $mech->content_contains('Some Other Council', 'Some other council is shown on the page'); + }; + + subtest 'body user sees whole body stats page' => sub { + $mech->log_in_ok( $oxfordshireuser->email ); + $mech->get_ok('/admin/areastats'); + $mech->content_contains("Area stats for Oxfordshire County Council"); + $mech->content_contains('Trowbridge'); + $mech->content_contains('Bradford-on-Avon'); + }; + + subtest 'area user can only see their area' => sub { + $oxfordshireuser->update({area_id => 20720}); + + $mech->get("/admin/areastats/$body_id"); + is $mech->status, 404, 'area user cannot see parent area'; + + $mech->get("/admin/areastats/$body_id?area=20721"); + is $mech->status, 404, 'area user cannot see another area'; + + $mech->get_ok('/admin/areastats'); + $mech->text_contains('Area 20720', 'index page displays their area to area user'); + + $oxfordshireuser->update({area_id => undef}); + }; + + subtest 'gets an area' => sub { + $mech->log_in_ok( $oxfordshireuser->email ); + $mech->get_ok("/admin/areastats/$body_id?area=20720"); + $mech->content_contains('Area 20720', 'Area name is shown on the page'); + + $mech->get('/admin/areastats/999'); + is $mech->status, 404, 'Getting a non-existent body returns 404'; + + $mech->get("/admin/areastats/$body_id/999"); + is $mech->status, 404, 'Getting a non-existent area returns 404'; + }; + + subtest 'shows correct stats for ward' => sub { + $mech->get_ok("/admin/areastats/$body_id?area=20720"); + $mech->content_contains('19 opened, 7 scheduled, 3 closed, 4 fixed'); + $mech->text_contains('Potholes2004'); + $mech->text_contains('Traffic lights3730'); + $mech->text_contains('Litter0000'); + + $mech->text_contains('Potholes6'); + $mech->text_contains('Traffic lights13'); + }; + + subtest 'shows correct stats to area user' => sub { + $oxfordshireuser->update({area_id => 20720}); + + $mech->get_ok("/admin/areastats"); + $mech->content_contains('19 opened, 7 scheduled, 3 closed, 4 fixed'); + $mech->text_contains('Potholes2004'); + $mech->text_contains('Traffic lights3730'); + $mech->text_contains('Litter0000'); + + $mech->text_contains('Potholes6'); + $mech->text_contains('Traffic lights13'); + + $oxfordshireuser->update({area_id => undef}); + }; + + subtest 'shows correct stats for ward using area param' => sub { + $mech->get_ok("/admin/areastats/$body_id?area=20720"); + $mech->content_contains('19 opened, 7 scheduled, 3 closed, 4 fixed'); + $mech->text_contains('Potholes2004'); + $mech->text_contains('Traffic lights3730'); + $mech->text_contains('Litter0000'); + + $mech->text_contains('Potholes6'); + $mech->text_contains('Traffic lights13'); + }; + + subtest 'shows correct stats for council' => sub { + $mech->get_ok("/admin/areastats/$body_id"); + $mech->content_contains('20 opened, 7 scheduled, 3 closed, 4 fixed'); + $mech->text_contains('Potholes2004'); + $mech->text_contains('Traffic lights3730'); + $mech->text_contains('Litter1000'); + + $mech->text_contains('Potholes6'); + $mech->text_contains('Traffic lights13'); + }; + + subtest 'shows average correctly' => sub { + $fixed_problems[0]->update({ confirmed => DateTime->now->subtract(days => 2) }); + $fixed_problems[1]->update({ confirmed => DateTime->now->subtract(days => 3) }); + $fixed_problems[2]->update({ confirmed => DateTime->now->subtract(days => 7) }); + $fixed_problems[3]->update({ confirmed => DateTime->now->subtract(days => 4) }); + $scheduled_problems[0]->update({ confirmed => DateTime->now->subtract(days => 2) }); + $scheduled_problems[1]->update({ confirmed => DateTime->now->subtract(days => 4) }); + $scheduled_problems[2]->update({ confirmed => DateTime->now->subtract(days => 6) }); + $scheduled_problems[3]->update({ confirmed => DateTime->now->subtract(days => 7) }); + $scheduled_problems[4]->update({ confirmed => DateTime->now->subtract(days => 1) }); + $scheduled_problems[6]->update({ confirmed => DateTime->now->subtract(days => 1) }); + $closed_problems[0]->update({ confirmed => DateTime->now->subtract(days => 6) }); + $closed_problems[1]->update({ confirmed => DateTime->now->subtract(days => 9) }); + $closed_problems[2]->update({ confirmed => DateTime->now->subtract(days => 12) }); + + $mech->get_ok("/admin/areastats/$body_id?area=20720"); + $mech->text_contains('average time between issue being opened and set to another status was 5 days'); + }; + + subtest 'shows this month stats correctly' => sub { + $fixed_problems[0]->update({ confirmed => DateTime->now->subtract(days => 50) }); + $fixed_problems[1]->update({ confirmed => DateTime->now->subtract(days => 50) }); + $scheduled_problems[1]->update({ confirmed => DateTime->now->subtract(days => 50) }); + $scheduled_problems[2]->update({ confirmed => DateTime->now->subtract(days => 50) }); + + $mech->get_ok("/admin/areastats/$body_id?area=20720"); + + $mech->content_contains('15 opened, 7 scheduled, 3 closed, 4 fixed'); + $mech->text_contains('Potholes2004'); + $mech->text_contains('Traffic lights3730'); + + $mech->text_contains('Potholes4'); + $mech->text_contains('Traffic lights11'); + + $mech->text_contains('average time between issue being opened and set to another status was 5 days'); + }; + + subtest 'ignores multiple comments with the same state' => sub { + $mech->create_comment_for_problem($scheduled_problems[0], $oxfordshireuser, 'Title', 'text', 0, 'confirmed', 'planned', { confirmed => \'current_timestamp' }); + $mech->get_ok("/admin/areastats/$body_id?area=20720"); + + $mech->content_contains('15 opened, 7 scheduled, 3 closed, 4 fixed'); + $mech->text_contains('Potholes2004'); + $mech->text_contains('Traffic lights3730'); + }; + + subtest 'average is only to first state change' => sub { + for my $i (0..4) { + $scheduled_problems[$i]->comments->first->update({ confirmed => $scheduled_problems[$i]->confirmed }); + $mech->create_comment_for_problem($scheduled_problems[$i], $oxfordshireuser, 'Title', 'text', 0, 'confirmed', 'fixed', { confirmed => \'current_timestamp' }); + } + + $mech->get_ok("/admin/areastats/$body_id?area=20720"); + $mech->text_contains('average time between issue being opened and set to another status was 4 days'); + }; + + subtest 'average only includes actual state changes' => sub { + my @probs = $mech->create_problems_for_body(2, $oxfordshire->id, 'Title', + { areas => ",$area_id,6753,2237,", created => DateTime->now->subtract(days => 12), confirmed => DateTime->now->subtract(days => 12), category => 'Potholes' }); + $mech->create_comment_for_problem($probs[0], $oxfordshireuser, 'Title', 'text', 0, 'confirmed', 'confirmed', { confirmed => \'current_timestamp' }); + + $mech->get_ok("/admin/areastats/$body_id?area=20720"); + $mech->text_contains('average time between issue being opened and set to another status was 4 days'); + }; +}; + +END { + FixMyStreet::DB->resultset('UserPlannedReport')->delete_all; + $mech->delete_user( $superuser ); + $mech->delete_user( $oxfordshireuser ); + done_testing(); +} diff --git a/templates/web/base/admin/areastats/area.html b/templates/web/base/admin/areastats/area.html new file mode 100644 index 000000000..1b88b5885 --- /dev/null +++ b/templates/web/base/admin/areastats/area.html @@ -0,0 +1,72 @@ +[% INCLUDE 'admin/header.html' title=tprintf(('Area stats for %s'), area_name) -%] +<p> +[% loc('There are currently:') %] +</p> + +<table> + <tr> + <th></th> + <th>[% loc('Open') %]</th> + <th>[% loc('Scheduled') %]</th> + <th>[% loc('Closed') %]</th> + <th>[% loc('Fixed') %]</th> + </tr> + [% FOR k IN by_category.keys.sort %] + <tr> + <td>[% k %]</td> + <td>[% by_category.$k.open OR 0 %]</td> + <td>[% by_category.$k.scheduled OR 0 %]</td> + <td>[% by_category.$k.closed OR 0 %]</td> + <td>[% by_category.$k.fixed OR 0 %]</td> + </tr> + [% END %] + +</table> + +<p> +[% loc('Issues in the last month:') %] +</p> + +<p> +[% tprintf( + loc('%d opened, %d scheduled, %d closed, %d fixed'), + open, + scheduled, + closed, + fixed + ); +%] +</p> + +<table> + [% FOR k IN recent_by_category.keys.sort %] + <tr> + <td>[% k %]</td> + <td>[% recent_by_category.$k OR 0 %]</td> + </tr> + [% END %] +</table> + +[% IF average > 0 %] +<p>[% tprintf(loc('In the last month – average time between issue being opened and set to another status was %s days'), average) %]</p> +[% ELSE %] +<p>[% loc('In the last month no problems changed state') %]</p> +[% END %] + + +[% IF NOT c.user.area_id %] +<p> +<form action="" method="GET"> +[% loc('Show stats for:') %] + <select name="area"> + <option value="">[% loc('Whole council') %]</option> + [% FOR area IN areas.values.sort('name')%] + <option value="[% area.id %]">[% area.name %]</option> + [% END %] + </select> + <input type="submit" value="Go"> +</form> +</p> +[% END %] + +[% INCLUDE 'admin/footer.html' %] diff --git a/templates/web/base/admin/areastats/index.html b/templates/web/base/admin/areastats/index.html new file mode 100644 index 000000000..1ebde20e7 --- /dev/null +++ b/templates/web/base/admin/areastats/index.html @@ -0,0 +1,11 @@ +[% INCLUDE 'admin/header.html' title=('Area Stats') -%] + +<ul> + [% FOR body IN bodies %] + <li> + <a href="[% c.uri_for('', body.id) %]">[% body.name %]</a> + </li> + [% END %] +</ul> + +[% INCLUDE 'admin/footer.html' %] |