aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDave Arter <davea@mysociety.org>2017-02-15 13:41:38 +0000
committerDave Arter <davea@mysociety.org>2017-02-15 13:41:38 +0000
commit54af489f0fe985dfc433f0b8a3ab226a470a6023 (patch)
tree29a0a89f65016cd0fc4900ea7491da67ddd2389a
parente198a5b8ba63fb1bae68132d2a81fd6cd4ecf69a (diff)
parent8e6f6a1818b4f998d48f157387d2314bb8c86f8a (diff)
Merge branch 'issues/forcouncils/51-close-old-reports'
-rwxr-xr-xbin/oxfordshire/archive-old-enquiries29
-rw-r--r--perllib/FixMyStreet/App/Controller/Auth.pm10
-rw-r--r--perllib/FixMyStreet/DB/Result/Problem.pm40
-rw-r--r--perllib/FixMyStreet/Script/ArchiveOldEnquiries.pm141
-rw-r--r--perllib/Utils.pm33
-rw-r--r--t/app/model/problem.t75
-rw-r--r--t/app/script/archive_old_enquiries.t163
-rw-r--r--t/utils.t8
-rw-r--r--templates/email/oxfordshire/archive.html58
-rw-r--r--templates/email/oxfordshire/archive.txt28
-rw-r--r--templates/web/base/report/update/form_update.html10
11 files changed, 584 insertions, 11 deletions
diff --git a/bin/oxfordshire/archive-old-enquiries b/bin/oxfordshire/archive-old-enquiries
new file mode 100755
index 000000000..7fe66703a
--- /dev/null
+++ b/bin/oxfordshire/archive-old-enquiries
@@ -0,0 +1,29 @@
+#!/usr/bin/env perl
+
+use strict;
+use warnings;
+require 5.8.0;
+
+BEGIN {
+ use File::Basename qw(dirname);
+ use File::Spec;
+ my $d = dirname(File::Spec->rel2abs($0));
+ require "$d/../../setenv.pl";
+}
+
+use FixMyStreet::Script::ArchiveOldEnquiries;
+use Getopt::Long::Descriptive;
+
+my ($opts, $usage) = describe_options(
+ '%c %o',
+ ['commit|c', "actually close reports and send emails. Omitting this flag will do a dry-run"],
+ ['body|b=s', "which body ID to close reports for"],
+ ['cobrand=s', "which cobrand template to use for sent emails"],
+ ['closure-cutoff=s', "Anything before this will be closed with no email"],
+ ['email-cutoff=s', "Anything before this will be closed with an email sent to the reporter"],
+ ['limit|l=s', "limit to a certain number of reports/users to be closed"],
+ ['help|h', "print usage message and exit" ],
+);
+print($usage->text), exit if $opts->help;
+
+FixMyStreet::Script::ArchiveOldEnquiries::archive($opts);
diff --git a/perllib/FixMyStreet/App/Controller/Auth.pm b/perllib/FixMyStreet/App/Controller/Auth.pm
index b41e88209..70821f79d 100644
--- a/perllib/FixMyStreet/App/Controller/Auth.pm
+++ b/perllib/FixMyStreet/App/Controller/Auth.pm
@@ -223,7 +223,7 @@ sub token : Path('/M') : Args(1) {
$c->authenticate( { email => $user->email }, 'no_password' );
# send the user to their page
- $c->detach( 'redirect_on_signin', [ $data->{r} ] );
+ $c->detach( 'redirect_on_signin', [ $data->{r}, $data->{p} ] );
}
=head2 facebook_sign_in
@@ -411,7 +411,7 @@ Used after signing in to take the person back to where they were.
sub redirect_on_signin : Private {
- my ( $self, $c, $redirect ) = @_;
+ my ( $self, $c, $redirect, $params ) = @_;
unless ( $redirect ) {
$c->detach('redirect_to_categories') if $c->user->from_body && scalar @{ $c->user->categories };
$redirect = 'my';
@@ -420,7 +420,11 @@ sub redirect_on_signin : Private {
if ( $c->cobrand->moniker eq 'zurich' ) {
$redirect = 'admin' if $c->user->from_body;
}
- $c->res->redirect( $c->uri_for( "/$redirect" ) );
+ if (defined $params) {
+ $c->res->redirect( $c->uri_for( "/$redirect", $params ) );
+ } else {
+ $c->res->redirect( $c->uri_for( "/$redirect" ) );
+ }
}
=head2 redirect_to_categories
diff --git a/perllib/FixMyStreet/DB/Result/Problem.pm b/perllib/FixMyStreet/DB/Result/Problem.pm
index 0092dd8b5..97cb28fe8 100644
--- a/perllib/FixMyStreet/DB/Result/Problem.pm
+++ b/perllib/FixMyStreet/DB/Result/Problem.pm
@@ -182,6 +182,8 @@ use Utils;
use FixMyStreet::Map::FMS;
use LWP::Simple qw($ua);
use RABX;
+use URI;
+use URI::QueryParam;
my $IM = eval {
require Image::Magick;
@@ -511,6 +513,30 @@ sub admin_url {
return $cobrand->admin_base_url . '/report_edit/' . $self->id;
}
+=head2 tokenised_url
+
+Return a url for this problem report that logs a user in
+
+=cut
+
+sub tokenised_url {
+ my ($self, $user, $params) = @_;
+
+ my $token = FixMyStreet::App->model('DB::Token')->create(
+ {
+ scope => 'email_sign_in',
+ data => {
+ id => $self->id,
+ email => $user->email,
+ r => $self->url,
+ p => $params,
+ }
+ }
+ );
+
+ return "/M/". $token->token;
+}
+
=head2 is_open
Returns 1 if the problem is in a open state otherwise 0.
@@ -659,6 +685,20 @@ sub body {
return $body;
}
+
+=head2 time_ago
+ Returns how long ago a problem was reported in an appropriately
+ prettified duration, depending on the duration.
+=cut
+
+sub time_ago {
+ my ( $self, $date ) = @_;
+ $date ||= 'confirmed';
+ my $duration = time() - $self->$date->epoch;
+
+ return Utils::prettify_duration( $duration );
+}
+
=head2 response_templates
Returns all ResponseTemplates attached to this problem's bodies, in alphabetical
diff --git a/perllib/FixMyStreet/Script/ArchiveOldEnquiries.pm b/perllib/FixMyStreet/Script/ArchiveOldEnquiries.pm
new file mode 100644
index 000000000..5d1d45379
--- /dev/null
+++ b/perllib/FixMyStreet/Script/ArchiveOldEnquiries.pm
@@ -0,0 +1,141 @@
+package FixMyStreet::Script::ArchiveOldEnquiries;
+
+use strict;
+use warnings;
+require 5.8.0;
+
+use FixMyStreet;
+use FixMyStreet::App;
+use FixMyStreet::DB;
+use FixMyStreet::Cobrand;
+use FixMyStreet::Map;
+use FixMyStreet::Email;
+
+
+my $opts = {
+ commit => 0,
+ body => '2237',
+ cobrand => 'oxfordshire',
+ closure_cutoff => "2015-01-01 00:00:00",
+ email_cutoff => "2016-01-01 00:00:00",
+};
+
+sub query {
+ return {
+ bodies_str => { 'LIKE', "%".$opts->{body}."%"},
+ -and => [
+ lastupdate => { '<', $opts->{email_cutoff} },
+ lastupdate => { '>', $opts->{closure_cutoff} },
+ ],
+ state => [ FixMyStreet::DB::Result::Problem->open_states() ],
+ };
+}
+
+sub archive {
+ my $params = shift;
+ if ( $params ) {
+ $opts = {
+ %$opts,
+ %$params,
+ };
+ }
+
+ unless ( $opts->{commit} ) {
+ printf "Doing a dry run; emails won't be sent and reports won't be closed.\n";
+ printf "Re-run with --commit to actually archive reports.\n\n";
+ }
+
+ my @user_ids = FixMyStreet::DB->resultset('Problem')->search(query(),
+ {
+ distinct => 1,
+ columns => ['user_id'],
+ rows => $opts->{limit},
+ })->all;
+
+ @user_ids = map { $_->user_id } @user_ids;
+
+ my $users = FixMyStreet::DB->resultset('User')->search({
+ id => \@user_ids
+ });
+
+ my $user_count = $users->count;
+ my $problem_count = FixMyStreet::DB->resultset('Problem')->search(query(),
+ {
+ columns => ['id'],
+ rows => $opts->{limit},
+ })->count;
+
+ printf("%d users will receive closure emails about %d reports which will be closed.\n", $user_count, $problem_count);
+
+ if ( $opts->{commit} ) {
+ my $i = 0;
+ while ( my $user = $users->next ) {
+ printf("%d/%d: User ID %d\n", ++$i, $user_count, $user->id);
+ send_email_and_close($user);
+ }
+ }
+
+ my $problems_to_close = FixMyStreet::DB->resultset('Problem')->search({
+ bodies_str => { 'LIKE', "%".$opts->{body}."%"},
+ lastupdate => { '<', $opts->{closure_cutoff} },
+ state => [ FixMyStreet::DB::Result::Problem->open_states() ],
+ }, {
+ rows => $opts->{limit},
+ });
+
+ printf("Closing %d old reports, without sending emails: ", $problems_to_close->count);
+
+ if ( $opts->{commit} ) {
+ $problems_to_close->update({ state => 'closed', send_questionnaire => 0 });
+ }
+
+ printf("done.\n")
+}
+
+sub send_email_and_close {
+ my ($user) = @_;
+
+ my $problems = $user->problems->search(query(), {
+ order_by => { -desc => 'confirmed' },
+ });
+
+ my @problems = $problems->all;
+
+ return if scalar(@problems) == 0;
+
+ my $cobrand = FixMyStreet::Cobrand->get_class_for_moniker($opts->{cobrand})->new();
+ $cobrand->set_lang_and_domain($problems[0]->lang, 1);
+ FixMyStreet::Map::set_map_class($cobrand->map_type);
+
+ my %h = (
+ reports => [@problems],
+ report_count => scalar(@problems),
+ site_name => $cobrand->moniker,
+ user => $user,
+ cobrand => $cobrand,
+ );
+
+ # Send email
+ printf(" Sending email about %d reports: ", scalar(@problems));
+ my $email_error = FixMyStreet::Email::send_cron(
+ $problems->result_source->schema,
+ 'archive.txt',
+ \%h,
+ {
+ To => [ [ $user->email, $user->name ] ],
+ },
+ undef,
+ undef,
+ $cobrand,
+ $problems[0]->lang,
+ );
+
+ unless ( $email_error ) {
+ printf("done.\n Closing reports: ");
+
+ $problems->update({ state => 'closed', send_questionnaire => 0 });
+ printf("done.\n");
+ } else {
+ printf("error! Not closing reports for this user.\n")
+ }
+}
diff --git a/perllib/Utils.pm b/perllib/Utils.pm
index 7dd2a3f39..6ba20e9d3 100644
--- a/perllib/Utils.pm
+++ b/perllib/Utils.pm
@@ -99,7 +99,7 @@ sub truncate_coordinate {
Strip leading and trailing white space from a string. Also reduces all
white space to a single space.
-Trim
+Trim
=cut
@@ -195,7 +195,28 @@ sub prettify_dt {
# argument is duration in seconds, rounds to the nearest minute
sub prettify_duration {
my ($s, $nearest) = @_;
- if ($nearest eq 'week') {
+
+ unless ( defined $nearest ) {
+ if ($s < 3600) {
+ $nearest = 'minute';
+ } elsif ($s < 3600*24) {
+ $nearest = 'hour';
+ } elsif ($s < 3600*24*7) {
+ $nearest = 'day';
+ } elsif ($s < 3600*24*7*4) {
+ $nearest = 'week';
+ } elsif ($s < 3600*24*7*4*12) {
+ $nearest = 'month';
+ } else {
+ $nearest = 'year';
+ }
+ }
+
+ if ($nearest eq 'year') {
+ $s = int(($s+60*60*24*3.5)/60/60/24/7/4/12)*60*60*24*7*4*12;
+ } elsif ($nearest eq 'month') {
+ $s = int(($s+60*60*24*3.5)/60/60/24/7/4)*60*60*24*7*4;
+ } elsif ($nearest eq 'week') {
$s = int(($s+60*60*24*3.5)/60/60/24/7)*60*60*24*7;
} elsif ($nearest eq 'day') {
$s = int(($s+60*60*12)/60/60/24)*60*60*24;
@@ -206,6 +227,8 @@ sub prettify_duration {
return _('less than a minute') if $s == 0;
}
my @out = ();
+ _part(\$s, 60*60*24*7*4*12, \@out);
+ _part(\$s, 60*60*24*7*4, \@out);
_part(\$s, 60*60*24*7, \@out);
_part(\$s, 60*60*24, \@out);
_part(\$s, 60*60, \@out);
@@ -217,7 +240,11 @@ sub _part {
if ($$s >= $m) {
my $i = int($$s / $m);
my $str;
- if ($m == 60*60*24*7) {
+ if ($m == 60*60*24*7*4*12) {
+ $str = mySociety::Locale::nget("%d year", "%d years", $i);
+ } elsif ($m == 60*60*24*7*4) {
+ $str = mySociety::Locale::nget("%d month", "%d months", $i);
+ } elsif ($m == 60*60*24*7) {
$str = mySociety::Locale::nget("%d week", "%d weeks", $i);
} elsif ($m == 60*60*24) {
$str = mySociety::Locale::nget("%d day", "%d days", $i);
diff --git a/t/app/model/problem.t b/t/app/model/problem.t
index 1130078c0..52213ed51 100644
--- a/t/app/model/problem.t
+++ b/t/app/model/problem.t
@@ -5,6 +5,7 @@ use Test::More;
use FixMyStreet::TestMech;
use FixMyStreet;
+use FixMyStreet::App;
use FixMyStreet::DB;
use mySociety::Locale;
use Sub::Override;
@@ -53,7 +54,7 @@ is $problem->whensent, undef, 'inflating null confirmed ok';
is $problem->lastupdate, undef, 'inflating null confirmed ok';
is $problem->created, undef, 'inflating null confirmed ok';
-for my $test (
+for my $test (
{
desc => 'more or less empty problem',
changed => {},
@@ -242,7 +243,7 @@ for my $test (
};
}
-for my $test (
+for my $test (
{
state => 'partial',
is_visible => 0,
@@ -774,6 +775,76 @@ subtest 'check duplicate reports' => sub {
is $problem2->duplicates->[0]->title, $problem1->title, 'problem2 includes problem1 in duplicates';
};
+subtest 'generates a tokenised url for a user' => sub {
+ my ($problem) = $mech->create_problems_for_body(1, $body_ids{2651}, 'TITLE');
+ my $url = $problem->tokenised_url($user);
+ (my $token = $url) =~ s/\/M\///g;
+
+ like $url, qr/\/M\//, 'problem generates tokenised url';
+
+ my $token_obj = FixMyStreet::App->model('DB::Token')->find( {
+ scope => 'email_sign_in', token => $token
+ } );
+ is $token, $token_obj->token, 'token is generated in database with correct scope';
+ is $token_obj->data->{r}, $problem->url, 'token has correct redirect data';
+};
+
+subtest 'stores params in a token' => sub {
+ my ($problem) = $mech->create_problems_for_body(1, $body_ids{2651}, 'TITLE');
+ my $url = $problem->tokenised_url($user, { foo => 'bar', baz => 'boo'});
+ (my $token = $url) =~ s/\/M\///g;
+
+ my $token_obj = FixMyStreet::App->model('DB::Token')->find( {
+ scope => 'email_sign_in', token => $token
+ } );
+
+ is_deeply $token_obj->data->{p}, { foo => 'bar', baz => 'boo'}, 'token has correct params';
+};
+
+subtest 'get report time ago in appropriate format' => sub {
+ my ($problem) = $mech->create_problems_for_body(1, $body_ids{2651}, 'TITLE');
+
+ $problem->update( {
+ confirmed => DateTime->now->subtract( minutes => 2)
+ } );
+ is $problem->time_ago, '2 minutes', 'problem returns time ago in minutes';
+
+ $problem->update( {
+ confirmed => DateTime->now->subtract( hours => 18)
+ } );
+ is $problem->time_ago, '18 hours', 'problem returns time ago in hours';
+
+ $problem->update( {
+ confirmed => DateTime->now->subtract( days => 4)
+ } );
+ is $problem->time_ago, '4 days', 'problem returns time ago in days';
+
+ $problem->update( {
+ confirmed => DateTime->now->subtract( weeks => 3 )
+ } );
+ is $problem->time_ago, '3 weeks', 'problem returns time ago in weeks';
+
+ $problem->update( {
+ confirmed => DateTime->now->subtract( months => 4 )
+ } );
+ is $problem->time_ago, '4 months', 'problem returns time ago in months';
+
+ $problem->update( {
+ confirmed => DateTime->now->subtract( years => 2 )
+ } );
+ is $problem->time_ago, '2 years', 'problem returns time ago in years';
+
+};
+
+subtest 'time ago works with other dates' => sub {
+ my ($problem) = $mech->create_problems_for_body(1, $body_ids{2651}, 'TITLE');
+
+ $problem->update( {
+ lastupdate => DateTime->now->subtract( days => 4)
+ } );
+ is $problem->time_ago('lastupdate'), '4 days', 'problem returns last updated time ago in days';
+};
+
END {
$problem->comments->delete if $problem;
$problem->delete if $problem;
diff --git a/t/app/script/archive_old_enquiries.t b/t/app/script/archive_old_enquiries.t
new file mode 100644
index 000000000..e87d6a0f8
--- /dev/null
+++ b/t/app/script/archive_old_enquiries.t
@@ -0,0 +1,163 @@
+use strict;
+use warnings;
+use Test::More;
+use FixMyStreet::TestMech;
+use FixMyStreet::Script::ArchiveOldEnquiries;
+
+mySociety::Locale::gettext_domain( 'FixMyStreet' );
+
+my $mech = FixMyStreet::TestMech->new();
+
+$mech->clear_emails_ok;
+
+my $opts = {
+ commit => 1,
+};
+
+my $user = $mech->create_user_ok('test@example.com', name => 'Test User');
+my $oxfordshire = $mech->create_body_ok(2237, 'Oxfordshire County Council', id => 2237);
+my $west_oxon = $mech->create_body_ok(2420, 'West Oxfordshire District Council', id => 2420);
+
+subtest 'sets reports to the correct status' => sub {
+ FixMyStreet::override_config {
+ ALLOWED_COBRANDS => [ 'oxfordshire' ],
+ }, sub {
+ my ($report) = $mech->create_problems_for_body(1, $oxfordshire->id, 'Test', {
+ areas => ',2237,',
+ user_id => $user->id,
+ });
+
+ my ($report1) = $mech->create_problems_for_body(1, $oxfordshire->id . "," .$west_oxon->id, 'Test', {
+ areas => ',2237,',
+ lastupdate => '2015-12-01 07:00:00',
+ user => $user,
+ });
+
+ my ($report2) = $mech->create_problems_for_body(1, $oxfordshire->id, 'Test 2', {
+ areas => ',2237,',
+ lastupdate => '2015-12-01 08:00:00',
+ user => $user,
+ state => 'investigating',
+ });
+
+ my ($report3, $report4) = $mech->create_problems_for_body(2, $oxfordshire->id, 'Test', {
+ areas => ',2237,',
+ lastupdate => '2014-12-01 07:00:00',
+ user => $user,
+ });
+
+ my ($report5) = $mech->create_problems_for_body(1, $oxfordshire->id . "," .$west_oxon->id, 'Test', {
+ areas => ',2237,',
+ lastupdate => '2014-12-01 07:00:00',
+ user => $user,
+ state => 'in progress'
+ });
+
+ FixMyStreet::Script::ArchiveOldEnquiries::archive($opts);
+
+ $report->discard_changes;
+ $report1->discard_changes;
+ $report2->discard_changes;
+ $report3->discard_changes;
+ $report4->discard_changes;
+ $report5->discard_changes;
+
+ is $report1->state, 'closed', 'Report 1 has been set to closed';
+ is $report2->state, 'closed', 'Report 2 has been set to closed';
+ is $report3->state, 'closed', 'Report 3 has been set to closed';
+ is $report4->state, 'closed', 'Report 4 has been set to closed';
+ is $report5->state, 'closed', 'Report 5 has been set to closed';
+
+ is $report->state, 'confirmed', 'Recent report has been left alone';
+ };
+};
+
+subtest 'sends emails to a user' => sub {
+ FixMyStreet::override_config {
+ ALLOWED_COBRANDS => [ 'oxfordshire' ],
+ }, sub {
+ $mech->clear_emails_ok;
+ $mech->email_count_is(0);
+
+ $mech->create_problems_for_body(1, $oxfordshire->id, 'Shiny new report', {
+ areas => ',2237,',
+ user => $user,
+ });
+
+ $mech->create_problems_for_body(1, $oxfordshire->id, 'Problem the first', {
+ areas => ',2237,',
+ lastupdate => '2015-12-01 07:00:00',
+ user => $user,
+ });
+
+ $mech->create_problems_for_body(1, $oxfordshire->id, 'Problem the second', {
+ areas => ',2237,',
+ lastupdate => '2015-12-01 07:00:00',
+ user => $user,
+ });
+
+ $mech->create_problems_for_body(1, $oxfordshire->id, 'Problem the third', {
+ areas => ',2237,',
+ lastupdate => '2015-12-01 07:00:00',
+ user => $user,
+ });
+
+ $mech->create_problems_for_body(1, $oxfordshire->id, 'Really old report', {
+ areas => ',2237,',
+ lastupdate => '2014-12-01 07:00:00',
+ user => $user,
+ });
+
+ FixMyStreet::Script::ArchiveOldEnquiries::archive($opts);
+
+ my @emails = $mech->get_email;
+ $mech->email_count_is(1);
+
+ my $email = $emails[0];
+ my $body = $mech->get_text_body_from_email($email);
+
+ like $body, qr/Problem the first/, 'Email body matches report name';
+ like $body, qr/Problem the second/, 'Email body matches report name';
+ like $body, qr/Problem the third/, 'Email body matches report name';
+
+ unlike $body, qr/Shiny new report/, 'Email body does not have new report';
+ unlike $body, qr/Really old report/, 'Email body does not have old report';
+ };
+};
+
+subtest 'user with old reports does not get email' => sub {
+ $mech->clear_emails_ok;
+ $mech->email_count_is(0);
+
+ $mech->create_problems_for_body(4, $oxfordshire->id, 'Really old report', {
+ areas => ',2237,',
+ lastupdate => '2014-12-01 07:00:00',
+ user => $user,
+ });
+
+ FixMyStreet::Script::ArchiveOldEnquiries::archive($opts);
+
+ my @emails = $mech->get_email;
+ $mech->email_count_is(0);
+};
+
+subtest 'user with new reports does not get email' => sub {
+ $mech->clear_emails_ok;
+ $mech->email_count_is(0);
+
+ $mech->create_problems_for_body(4, $oxfordshire->id, 'Shiny new report', {
+ areas => ',2237,',
+ user => $user,
+ });
+
+ FixMyStreet::Script::ArchiveOldEnquiries::archive($opts);
+
+ $mech->email_count_is(0);
+};
+
+done_testing();
+
+END {
+ $mech->delete_user($user);
+ $mech->delete_body($oxfordshire);
+}
diff --git a/t/utils.t b/t/utils.t
index d6c56d95a..f989580c8 100644
--- a/t/utils.t
+++ b/t/utils.t
@@ -91,10 +91,18 @@ is Utils::prettify_dt($dt, 1), $dt->strftime("%H:%M, %e %b %Y");
$dt = DateTime->now->subtract(days => 400);
is Utils::prettify_dt($dt), $dt->strftime("%H:%M, %a %e %B %Y");
+is Utils::prettify_duration(12*5*7*86400+3600+60+1, 'year'), '1 year';
+is Utils::prettify_duration(25*5*7*86400+3600+60+1, 'year'), '2 years';
+is Utils::prettify_duration(5*7*86400+3600+60+1, 'month'), '1 month';
is Utils::prettify_duration(7*86400+3600+60+1, 'week'), '1 week';
is Utils::prettify_duration(86400+3600+60+1, 'day'), '1 day';
is Utils::prettify_duration(86400+3600+60+1, 'hour'), '1 day, 1 hour';
is Utils::prettify_duration(86400+3600+60+1, 'minute'), '1 day, 1 hour, 1 minute';
is Utils::prettify_duration(20, 'minute'), 'less than a minute';
+# prettify_duration should choose a $nearest sensibly if it's not given
+is Utils::prettify_duration(12*5*7*86400+3600+60+1), '1 year';
+is Utils::prettify_duration(7*86400+3600+60+1), '1 week';
+is Utils::prettify_duration(14*86400+3600+60+1), '2 weeks';
+is Utils::prettify_duration(1800), '30 minutes';
done_testing();
diff --git a/templates/email/oxfordshire/archive.html b/templates/email/oxfordshire/archive.html
new file mode 100644
index 000000000..ed48456a2
--- /dev/null
+++ b/templates/email/oxfordshire/archive.html
@@ -0,0 +1,58 @@
+[%
+
+email_summary = "Your reports on " _ site_name;
+
+PROCESS '_email_settings.html';
+
+INCLUDE '_email_top.html';
+
+%]
+
+<th style="[% td_style %][% only_column_style %]">
+ <h1 style="[% h1_style %]">Your [% site_name %] reports on FixMyStreet</h1>
+ <p style="[% p_style %]">
+ Hello [% user.name %],
+ </p>
+ <p style="[% p_style %]">
+ FixMyStreet is being updated in Oxfordshire to
+ improve how problems get fixed.
+ </p>
+ <p style="[% p_style %]">
+ As part of these updates, we are closing old reports that appear to be
+ resolved but remain open in the system.
+ </p>
+ <p style="[% p_style %]">
+ We noticed that you have [% report_count %] old [% nget('report', 'reports', report_count) %] on the system,
+ which we've listed below.
+ </p>
+ <p style="[% p_style %]">
+ If your report is no longer an issue, you don't need to do anything.
+ </p>
+ <p style="[% p_style %]">
+ If you believe that your report is still a problem, you can reopen it by
+ clicking the 'reopen' button by a report.
+ </p>
+
+ [% FOR report IN reports %]
+ <div style="[% list_item_style %]">
+ [% IF report.photo %]
+ <a href="[% cobrand.base_url_for_report( report ) %]/report/[% report.id %]">
+ <img style="[% list_item_photo_style %]" src="[% inline_image(report.get_first_image_fp) %]" alt="">
+ </a>
+ [% END %]
+ <h2 style="[% list_item_h2_style %]">
+ [%~ report.title | html ~%]
+ </h2>
+ <p style="[% list_item_p_style %]">[% report.detail | html %]</p>
+ <p style="[% list_item_date_style %]">
+ Reported [% report.time_ago %] ago.
+ </p>
+ <p>
+ <a style="[% button_style %]" href="[% cobrand.base_url_for_report( report ) %][% report.tokenised_url( user, { reopen => 'true' } ) %]#update_form">Reopen report</a>
+ </p>
+ </div>
+ [% END %]
+
+</th>
+
+[% INCLUDE '_email_bottom.html' %]
diff --git a/templates/email/oxfordshire/archive.txt b/templates/email/oxfordshire/archive.txt
new file mode 100644
index 000000000..6ecf5b02f
--- /dev/null
+++ b/templates/email/oxfordshire/archive.txt
@@ -0,0 +1,28 @@
+Subject: Your reports on [% site_name %]
+
+Hello [% user.name %],
+
+FixMyStreet is being updated in Oxfordshire to improve how problems get fixed.
+
+As part of these updates, we are closing old reports that appear to be resolved but remain open in the system.
+
+We noticed that you have [% report_count %] old [% nget('report', 'reports', report_count) %] on the system, which we've listed below.
+
+If your report is no longer an issue, you don't need to do anything.
+
+If you believe that your report is still a problem, you can reopen it by clicking or copying and pasting
+the link marked 'Reopen?' by a report.
+
+[% FOR report IN reports %]
+
+[% report.title %]
+
+Reported [% report.time_ago %] ago.
+
+Reopen? [% cobrand.base_url_for_report( report ) %][% report.tokenised_url( user, { reopen => 'true' } ) %]#update_form
+
+----
+
+[% END %]
+
+The mySociety team and Oxfordshire County Council
diff --git a/templates/web/base/report/update/form_update.html b/templates/web/base/report/update/form_update.html
index e0464eec3..f15a1f74b 100644
--- a/templates/web/base/report/update/form_update.html
+++ b/templates/web/base/report/update/form_update.html
@@ -47,10 +47,14 @@
[% END %]
</select>
[% ELSE %]
- [% IF problem.is_fixed AND ((c.user_exists AND c.user.id == problem.user_id) OR alert_to_reporter) %]
+ [% IF (problem.is_fixed OR problem.state == 'closed') AND ((c.user_exists AND c.user.id == problem.user_id) OR alert_to_reporter) %]
- <input type="checkbox" name="reopen" id="form_reopen" value="1"[% ' checked' IF update.mark_open %]>
- <label class="inline" for="form_reopen">[% loc('This problem has not been fixed') %]</label>
+ <input type="checkbox" name="reopen" id="form_reopen" value="1"[% ' checked' IF (update.mark_open || c.req.params.reopen) %]>
+ [% IF problem.is_closed %]
+ <label class="inline" for="form_reopen">[% loc('This problem is still ongoing') %]</label>
+ [% ELSE %]
+ <label class="inline" for="form_reopen">[% loc('This problem has not been fixed') %]</label>
+ [% END %]
[% ELSIF !problem.is_fixed %]