aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorHakim Cassimally <hakim@mysociety.org>2014-05-27 16:08:50 +0000
committerHakim Cassimally <hakim@mysociety.org>2014-08-13 15:11:29 +0000
commit382a782c16a998776176de37037dd029fab4e3fe (patch)
treec425b24e3ce2c396214fd463e0fc0bde21f792be
parent68a486f190aa5054b3bcf1be2a63ad0c7c6aef26 (diff)
Report moderation
- redaction marked with [...] - of report and comments - stores original data - uses a single form, on the report/_main view - requires additional permissions (user_body_permissions) - Hide report functionality - Moderation notification/contact form - Moderation writes to admin_log
-rw-r--r--cpanfile3
-rw-r--r--db/schema.sql32
-rw-r--r--db/schema_0032-moderation.sql36
-rw-r--r--perllib/FixMyStreet/App/Controller/Contact.pm20
-rw-r--r--perllib/FixMyStreet/App/Controller/Moderate.pm365
-rw-r--r--perllib/FixMyStreet/DB/Result/AdminLog.pm21
-rw-r--r--perllib/FixMyStreet/DB/Result/Body.pm14
-rw-r--r--perllib/FixMyStreet/DB/Result/Comment.pm48
-rw-r--r--perllib/FixMyStreet/DB/Result/ModerationOriginalData.pm66
-rw-r--r--perllib/FixMyStreet/DB/Result/Problem.pm39
-rw-r--r--perllib/FixMyStreet/DB/Result/User.pm33
-rw-r--r--perllib/FixMyStreet/DB/Result/UserBodyPermission.pm52
-rw-r--r--perllib/Utils.pm1
-rw-r--r--templates/email/default/problem-moderated.txt38
-rw-r--r--templates/web/base/common_header_tags.html5
-rw-r--r--templates/web/base/contact/index.html4
-rw-r--r--templates/web/base/report/_main.html89
-rw-r--r--templates/web/fixmystreet/contact/index.html4
-rw-r--r--templates/web/fixmystreet/report/update.html46
-rw-r--r--web/js/moderate.js42
20 files changed, 936 insertions, 22 deletions
diff --git a/cpanfile b/cpanfile
index e43d74614..54fd8066e 100644
--- a/cpanfile
+++ b/cpanfile
@@ -97,6 +97,9 @@ feature 'uk', 'FixMyStreet.com specific requirements' => sub {
# requires 'SOAP::Lite';
#};
+# Moderation by from_body user
+requires 'Algorithm::Diff';
+
# Modules used by css watcher
requires 'File::ChangeNotify';
requires 'Path::Tiny';
diff --git a/db/schema.sql b/db/schema.sql
index 6ddb7bae6..5bac02bce 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -450,5 +450,35 @@ create table admin_log (
),
object_id integer not null,
action text not null,
- whenedited timestamp not null default ms_current_timestamp()
+ whenedited timestamp not null default ms_current_timestamp(),
+ user_id int references users(id) null,
+ reason text not null default ''
);
+
+create table moderation_original_data (
+ id serial not null primary key,
+
+ -- Problem details
+ problem_id int references problem(id) ON DELETE CASCADE not null,
+ comment_id int references comment(id) ON DELETE CASCADE unique,
+
+ title text null,
+ detail text null, -- or text for comment
+ photo bytea,
+ anonymous bool not null,
+
+ -- Metadata
+ created timestamp not null default ms_current_timestamp()
+);
+
+create table user_body_permissions (
+ id serial not null primary key,
+ user_id int references users(id) not null,
+ body_id int references body(id) not null,
+ permission_type text not null check(
+ permission_type='moderate' or
+ -- for future expansion --
+ permission_type='admin'
+ ),
+ unique(user_id, body_id, permission_type)
+);
diff --git a/db/schema_0032-moderation.sql b/db/schema_0032-moderation.sql
new file mode 100644
index 000000000..b3caded1e
--- /dev/null
+++ b/db/schema_0032-moderation.sql
@@ -0,0 +1,36 @@
+-- was created in previous versions of this branch
+DROP TABLE IF EXISTS moderation_log;
+
+alter table admin_log add column
+ user_id int references users(id) null;
+
+alter table admin_log add column
+ reason text not null default '';
+
+create table moderation_original_data (
+ id serial not null primary key,
+
+ -- Problem details
+ problem_id int references problem(id) ON DELETE CASCADE not null,
+ comment_id int references comment(id) ON DELETE CASCADE unique,
+
+ title text null,
+ detail text null, -- or text for comment
+ photo bytea,
+ anonymous bool not null,
+
+ -- Metadata
+ created timestamp not null default ms_current_timestamp()
+);
+
+create table user_body_permissions (
+ id serial not null primary key,
+ user_id int references users(id) not null,
+ body_id int references body(id) not null,
+ permission_type text not null check(
+ permission_type='moderate' or
+ -- for future expansion --
+ permission_type='admin'
+ ),
+ unique(user_id, body_id, permission_type)
+);
diff --git a/perllib/FixMyStreet/App/Controller/Contact.pm b/perllib/FixMyStreet/App/Controller/Contact.pm
index 6bc6e90ef..3ff824691 100644
--- a/perllib/FixMyStreet/App/Controller/Contact.pm
+++ b/perllib/FixMyStreet/App/Controller/Contact.pm
@@ -66,7 +66,25 @@ sub determine_contact_type : Private {
if ($id) {
- $c->forward( '/report/load_problem_or_display_error', [ $id ] );
+ # if we're moderating, then we don't show errors in every case, e.g.
+ # for hidden reports
+ if ($c->req->param('m')) {
+ my $problem
+ = ( !$id || $id =~ m{\D} ) # is id non-numeric?
+ ? undef # ...don't even search
+ : $c->cobrand->problems->find( { id => $id } );
+
+ if ($problem) {
+ $c->stash->{problem} = $problem;
+ $c->stash->{moderation_complaint} = 1;
+ }
+ else {
+ $c->forward( '/report/load_problem_or_display_error', [ $id ] );
+ }
+ }
+ else {
+ $c->forward( '/report/load_problem_or_display_error', [ $id ] );
+ }
if ($update_id) {
my $update = $c->model('DB::Comment')->find(
diff --git a/perllib/FixMyStreet/App/Controller/Moderate.pm b/perllib/FixMyStreet/App/Controller/Moderate.pm
new file mode 100644
index 000000000..bf70d52c5
--- /dev/null
+++ b/perllib/FixMyStreet/App/Controller/Moderate.pm
@@ -0,0 +1,365 @@
+package FixMyStreet::App::Controller::Moderate;
+
+use Moose;
+use namespace::autoclean;
+use Algorithm::Diff;
+BEGIN { extends 'Catalyst::Controller'; }
+
+=head1 NAME
+
+FixMyStreet::App::Controller::Moderate - process a moderation event
+
+=head1 DESCRIPTION
+
+The intent of this is that council users will be able to moderate reports
+by themselves, but without requiring access to the full admin panel.
+
+From a given report page, an authenticated user will be able to press
+the "moderate" button on report and any updates to bring up a form with
+data to change.
+
+(Authentication requires:
+
+ - user to be from_body
+ - user to have a "moderate" record in user_body_permissions (there is
+ currently no admin interface for this. Should be added, but
+ while we're trialing this, it's a simple case of adding a DB record
+ manually)
+
+The original data of the report is stored in moderation_original_data, so
+that it can be reverted/consulted if required. All moderation events are
+stored in moderation_log. (NB: In future, this could be combined with
+admin_log).
+
+=head1 SEE ALSO
+
+DB tables:
+
+ AdminLog
+ ModerationOriginalData
+ UserBodyPermissions
+
+=cut
+
+sub moderate : Chained('/') : PathPart('moderate') : CaptureArgs(0) { }
+
+sub report : Chained('moderate') : PathPart('report') : CaptureArgs(1) {
+ my ($self, $c, $id) = @_;
+ my $problem = $c->model('DB::Problem')->find($id);
+
+ my $cobrand_base = $c->cobrand->base_url_for_report( $problem );
+ my $report_uri = $cobrand_base . $problem->url;
+ $c->stash->{cobrand_base} = $cobrand_base;
+ $c->stash->{report_uri} = $report_uri;
+ $c->res->redirect( $report_uri ); # this will be the final endpoint after all processing...
+
+ # ... and immediately, if the user isn't authorized
+ $c->detach unless $c->user_exists;
+ $c->detach unless $c->user->has_permission_to(moderate => $problem->bodies_str);
+
+ my $original = $problem->find_or_new_related( moderation_original_data => {
+ title => $problem->title,
+ detail => $problem->detail,
+ photo => $problem->photo,
+ anonymous => $problem->anonymous,
+ });
+ $c->stash->{problem} = $problem;
+ $c->stash->{problem_original} = $original;
+ $c->stash->{moderation_reason} = $c->req->param('moderation_reason') // '';
+}
+
+sub moderate_report : Chained('report') : PathPart('') : Args(0) {
+ my ($self, $c) = @_;
+
+ $c->forward('report_moderate_hide');
+
+ my @types = grep $_,
+ $c->forward('report_moderate_title'),
+ $c->forward('report_moderate_detail'),
+ $c->forward('report_moderate_anon'),
+ $c->forward('report_moderate_photo');
+
+ $c->detach( 'report_moderate_audit', \@types )
+}
+
+sub report_moderate_audit : Private {
+ my ($self, $c, @types) = @_;
+
+ my $user = $c->user->obj;
+ my $reason = $c->stash->{'moderation_reason'};
+ my $problem = $c->stash->{problem} or die;
+
+ my $types_csv = join ', ' => @types;
+
+ $c->model('DB::AdminLog')->create({
+ action => 'moderation',
+ user => $user,
+ admin_user => $user->name,
+ object_id => $problem->id,
+ object_type => 'problem',
+ reason => (sprintf '%s (%s)', $reason, $types_csv),
+ });
+
+ my $cobrand = FixMyStreet::Cobrand->get_class_for_moniker($problem->cobrand)->new();
+
+ my $sender = FixMyStreet->config('DO_NOT_REPLY_EMAIL');
+ my $sender_name = _($cobrand->contact_name);
+
+ $c->send_email( 'problem-moderated.txt', {
+
+ to => [ [ $user->email, $user->name ] ],
+ from => [ $sender, $sender_name ],
+ types => $types_csv,
+ user => $user,
+ problem => $problem,
+ report_uri => $c->stash->{report_uri},
+ report_complain_uri => $c->stash->{cobrand_base} . '/contact?m=1&id=' . $problem->id,
+ });
+}
+
+sub report_moderate_hide : Private {
+ my ( $self, $c ) = @_;
+
+ my $problem = $c->stash->{problem} or die;
+
+ if ($c->req->param('problem_hide')) {
+
+
+ $problem->update({ state => 'hidden' });
+
+ $c->detach( 'report_moderate_audit', ['hide'] ); # break chain here.
+ }
+}
+
+sub report_moderate_title : Private {
+ my ( $self, $c ) = @_;
+
+ my $problem = $c->stash->{problem} or die;
+ my $original = $c->stash->{problem_original};
+
+ my $old_title = $problem->title;
+ my $original_title = $original->title;
+
+ my $title = $c->req->param('problem_revert_title') ?
+ $original_title
+ : $self->diff($original_title, $c->req->param('problem_title'));
+
+ if ($title ne $old_title) {
+ $original->insert unless $original->in_storage;
+ $problem->update({ title => $title });
+ return 'title';
+ }
+
+ return;
+}
+
+sub report_moderate_detail : Private {
+ my ( $self, $c ) = @_;
+
+ my $problem = $c->stash->{problem} or die;
+ my $original = $c->stash->{problem_original};
+
+ my $old_detail = $problem->detail;
+ my $original_detail = $original->detail;
+ my $detail = $c->req->param('problem_revert_detail') ?
+ $original_detail
+ : $self->diff($original_detail, $c->req->param('problem_detail'));
+
+ if ($detail ne $old_detail) {
+ $original->insert unless $original->in_storage;
+ $problem->update({ detail => $detail });
+ return 'detail';
+ }
+ return;
+}
+
+sub report_moderate_anon : Private {
+ my ( $self, $c ) = @_;
+
+ my $problem = $c->stash->{problem} or die;
+ my $original = $c->stash->{problem_original};
+
+ my $show_user = $c->req->param('problem_show_name') ? 1 : 0;
+ my $anonymous = $show_user ? 0 : 1;
+ my $old_anonymous = $problem->anonymous ? 1 : 0;
+
+ if ($anonymous != $old_anonymous) {
+
+ $original->insert unless $original->in_storage;
+ $problem->update({ anonymous => $anonymous });
+ return 'anonymous';
+ }
+ return;
+}
+
+sub report_moderate_photo : Private {
+ my ( $self, $c ) = @_;
+
+ my $problem = $c->stash->{problem} or die;
+ my $original = $c->stash->{problem_original};
+
+ return unless $original->photo;
+
+ my $show_photo = $c->req->param('problem_show_photo') ? 1 : 0;
+ my $old_show_photo = $problem->photo ? 1 : 0;
+
+ if ($show_photo != $old_show_photo) {
+ $original->insert unless $original->in_storage;
+ $problem->update({ photo => $show_photo ? $original->photo : undef });
+ return 'photo';
+ }
+ return;
+}
+
+sub update : Chained('report') : PathPart('update') : CaptureArgs(1) {
+ my ($self, $c, $id) = @_;
+ my $comment = $c->stash->{problem}->comments->find($id);
+
+ my $original = $comment->find_or_new_related( moderation_original_data => {
+ detail => $comment->text,
+ photo => $comment->photo,
+ anonymous => $comment->anonymous,
+ });
+ $c->stash->{comment} = $comment;
+ $c->stash->{comment_original} = $original;
+}
+
+sub moderate_update : Chained('update') : PathPart('') : Args(0) {
+ my ($self, $c) = @_;
+
+ $c->forward('update_moderate_hide');
+
+ my @types = grep $_,
+ $c->forward('update_moderate_detail'),
+ $c->forward('update_moderate_anon'),
+ $c->forward('update_moderate_photo');
+
+ $c->detach( 'update_moderate_audit', \@types )
+}
+
+sub update_moderate_audit : Private {
+ my ($self, $c, @types) = @_;
+
+ my $user = $c->user->obj;
+ my $reason = $c->stash->{'moderation_reason'};
+ my $problem = $c->stash->{problem} or die;
+ my $comment = $c->stash->{comment} or die;
+
+ my $types_csv = join ', ' => @types;
+
+ $c->model('DB::AdminLog')->create({
+ action => 'moderation',
+ user => $user,
+ admin_user => $user->name,
+ object_id => $comment->id,
+ object_type => 'update',
+ reason => (sprintf '%s (%s)', $reason, $types_csv),
+ });
+}
+
+sub update_moderate_hide : Private {
+ my ( $self, $c ) = @_;
+
+ my $problem = $c->stash->{problem} or die;
+ my $comment = $c->stash->{comment} or die;
+
+ if ($c->req->param('update_hide')) {
+ $comment->update({ state => 'hidden' });
+ $c->detach( 'update_moderate_audit', ['hide'] ); # break chain here.
+ }
+ return;
+}
+
+sub update_moderate_detail : Private {
+ my ( $self, $c ) = @_;
+
+ my $problem = $c->stash->{problem} or die;
+ my $comment = $c->stash->{comment} or die;
+ my $original = $c->stash->{comment_original};
+
+ my $old_detail = $comment->text;
+ my $original_detail = $original->detail;
+ my $detail = $c->req->param('update_revert_detail') ?
+ $original_detail
+ : $self->diff($original_detail, $c->req->param('update_detail'));
+
+ if ($detail ne $old_detail) {
+ $original->insert unless $original->in_storage;
+ $comment->update({ text => $detail });
+ return 'detail';
+ }
+ return;
+}
+
+sub update_moderate_anon : Private {
+ my ( $self, $c ) = @_;
+
+ my $problem = $c->stash->{problem} or die;
+ my $comment = $c->stash->{comment} or die;
+ my $original = $c->stash->{comment_original};
+
+ my $show_user = $c->req->param('update_show_name') ? 1 : 0;
+ my $anonymous = $show_user ? 0 : 1;
+ my $old_anonymous = $comment->anonymous ? 1 : 0;
+
+ if ($anonymous != $old_anonymous) {
+ $original->insert unless $original->in_storage;
+ $comment->update({ anonymous => $anonymous });
+ return 'anonymous';
+ }
+ return;
+}
+
+sub update_moderate_photo : Private {
+ my ( $self, $c ) = @_;
+
+ my $problem = $c->stash->{problem} or die;
+ my $comment = $c->stash->{comment} or die;
+ my $original = $c->stash->{comment_original};
+
+ return unless $original->photo;
+
+ my $show_photo = $c->req->param('update_show_photo') ? 1 : 0;
+ my $old_show_photo = $comment->photo ? 1 : 0;
+
+ if ($show_photo != $old_show_photo) {
+ $original->insert unless $original->in_storage;
+ $comment->update({ photo => $show_photo ? $original->photo : undef });
+ return 'photo';
+ }
+}
+
+sub return_text : Private {
+ my ($self, $c, $text) = @_;
+
+ $c->res->content_type('text/plain; charset=utf-8');
+ $c->res->body( $text // '' );
+}
+
+sub diff {
+ my ($self, $old, $new) = @_;
+
+ $new =~s/\[\.{3}\]//g;
+
+ my $diff = Algorithm::Diff->new( [ split //, $old ], [ split //, $new ] );
+ my $string;
+ while ($diff->Next) {
+ my $d = $diff->Diff;
+ if ($d & 1) {
+ my $deleted = join '', $diff->Items(1);
+ unless ($deleted =~/^\s*$/) {
+ $string .= ' ' if $deleted =~/^ /;
+ my $letters = ($deleted=~s/\W//r);
+ $string .= '[...]';
+ $string .= ' ' if $deleted =~/ $/;
+ }
+ }
+ $string .= join '', $diff->Items(2);
+ }
+ return $string;
+}
+
+
+__PACKAGE__->meta->make_immutable;
+
+1;
diff --git a/perllib/FixMyStreet/DB/Result/AdminLog.pm b/perllib/FixMyStreet/DB/Result/AdminLog.pm
index ede786871..41bc3100a 100644
--- a/perllib/FixMyStreet/DB/Result/AdminLog.pm
+++ b/perllib/FixMyStreet/DB/Result/AdminLog.pm
@@ -32,13 +32,26 @@ __PACKAGE__->add_columns(
default_value => \"ms_current_timestamp()",
is_nullable => 0,
},
+ "user_id",
+ { data_type => "integer", is_foreign_key => 1, is_nullable => 1 },
+ "reason",
+ { data_type => "text", default_value => "", is_nullable => 0 },
);
__PACKAGE__->set_primary_key("id");
+__PACKAGE__->belongs_to(
+ "user",
+ "FixMyStreet::DB::Result::User",
+ { id => "user_id" },
+ {
+ is_deferrable => 0,
+ join_type => "LEFT",
+ on_delete => "NO ACTION",
+ on_update => "NO ACTION",
+ },
+);
-# Created by DBIx::Class::Schema::Loader v0.07017 @ 2012-03-08 17:19:55
-# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:+NlSH8U+beRjBZl8CpqK9A
-
+# Created by DBIx::Class::Schema::Loader v0.07035 @ 2014-07-31 15:58:54
+# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:okGiaKaVYaTrlz0LCV01vA
-# You can replace this text with custom code or comments, and it will be preserved on regeneration
1;
diff --git a/perllib/FixMyStreet/DB/Result/Body.pm b/perllib/FixMyStreet/DB/Result/Body.pm
index 6b529735d..0c1046cd8 100644
--- a/perllib/FixMyStreet/DB/Result/Body.pm
+++ b/perllib/FixMyStreet/DB/Result/Body.pm
@@ -20,6 +20,8 @@ __PACKAGE__->add_columns(
},
"name",
{ data_type => "text", is_nullable => 0 },
+ "external_url",
+ { data_type => "text", is_nullable => 1 },
"parent",
{ data_type => "integer", is_foreign_key => 1, is_nullable => 1 },
"endpoint",
@@ -42,8 +44,6 @@ __PACKAGE__->add_columns(
{ data_type => "boolean", default_value => \"false", is_nullable => 0 },
"deleted",
{ data_type => "boolean", default_value => \"false", is_nullable => 0 },
- "external_url",
- { data_type => "text", is_nullable => 0 },
);
__PACKAGE__->set_primary_key("id");
__PACKAGE__->has_many(
@@ -87,6 +87,12 @@ __PACKAGE__->belongs_to(
},
);
__PACKAGE__->has_many(
+ "user_body_permissions",
+ "FixMyStreet::DB::Result::UserBodyPermission",
+ { "foreign.body_id" => "self.id" },
+ { cascade_copy => 0, cascade_delete => 0 },
+);
+__PACKAGE__->has_many(
"users",
"FixMyStreet::DB::Result::User",
{ "foreign.from_body" => "self.id" },
@@ -94,8 +100,8 @@ __PACKAGE__->has_many(
);
-# Created by DBIx::Class::Schema::Loader v0.07035 @ 2013-09-10 18:11:23
-# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:hTOxxiiHmC8nmQK/p8dXhQ
+# Created by DBIx::Class::Schema::Loader v0.07035 @ 2014-07-29 13:54:07
+# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:PhUeFDRLSQVMk7Sts5K6MQ
sub url {
my ( $self, $c, $args ) = @_;
diff --git a/perllib/FixMyStreet/DB/Result/Comment.pm b/perllib/FixMyStreet/DB/Result/Comment.pm
index e170a5655..526fbff90 100644
--- a/perllib/FixMyStreet/DB/Result/Comment.pm
+++ b/perllib/FixMyStreet/DB/Result/Comment.pm
@@ -68,6 +68,12 @@ __PACKAGE__->add_columns(
{ data_type => "timestamp", is_nullable => 1 },
);
__PACKAGE__->set_primary_key("id");
+__PACKAGE__->might_have(
+ "moderation_original_data",
+ "FixMyStreet::DB::Result::ModerationOriginalData",
+ { "foreign.comment_id" => "self.id" },
+ { cascade_copy => 0, cascade_delete => 0 },
+);
__PACKAGE__->belongs_to(
"problem",
"FixMyStreet::DB::Result::Problem",
@@ -82,8 +88,9 @@ __PACKAGE__->belongs_to(
);
-# Created by DBIx::Class::Schema::Loader v0.07035 @ 2013-09-10 17:11:54
-# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:D/+UWcF7JO/EkCiJaAHUOw
+# Created by DBIx::Class::Schema::Loader v0.07035 @ 2014-07-31 15:59:43
+# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:08AtJ6CZFyUe7qKMF50MHg
+#
__PACKAGE__->load_components("+FixMyStreet::DB::RABXColumn");
__PACKAGE__->rabx_column('extra');
@@ -175,6 +182,43 @@ sub meta_problem_state {
return $state;
}
+=head2 latest_moderation_log_entry
+
+Return most recent ModerationLog object
+
+=cut
+
+sub latest_moderation_log_entry {
+ my $self = shift;
+ return $self->admin_log_entries->search({ action => 'moderation' }, { order_by => 'id desc' })->first;
+}
+
+__PACKAGE__->has_many(
+ "admin_log_entries",
+ "FixMyStreet::DB::Result::AdminLog",
+ { "foreign.object_id" => "self.id" },
+ {
+ cascade_copy => 0, cascade_delete => 0,
+ where => { 'object_type' => 'update' },
+ }
+);
+
+# we already had the `moderation_original_data` rel above, as inferred by
+# Schema::Loader, but that doesn't know about the problem_id mapping, so we now
+# (slightly hackishly) redefine here:
+#
+# TODO: should add FK on moderation_original_data field for this, to get S::L to
+# pick up without hacks.
+
+__PACKAGE__->might_have(
+ "moderation_original_data",
+ "FixMyStreet::DB::Result::ModerationOriginalData",
+ { "foreign.comment_id" => "self.id",
+ "foreign.problem_id" => "self.problem_id",
+ },
+ { cascade_copy => 0, cascade_delete => 0 },
+);
+
# we need the inline_constructor bit as we don't inherit from Moose
__PACKAGE__->meta->make_immutable( inline_constructor => 0 );
diff --git a/perllib/FixMyStreet/DB/Result/ModerationOriginalData.pm b/perllib/FixMyStreet/DB/Result/ModerationOriginalData.pm
new file mode 100644
index 000000000..08d03f94b
--- /dev/null
+++ b/perllib/FixMyStreet/DB/Result/ModerationOriginalData.pm
@@ -0,0 +1,66 @@
+use utf8;
+package FixMyStreet::DB::Result::ModerationOriginalData;
+
+# Created by DBIx::Class::Schema::Loader
+# DO NOT MODIFY THE FIRST PART OF THIS FILE
+
+use strict;
+use warnings;
+
+use base 'DBIx::Class::Core';
+__PACKAGE__->load_components("FilterColumn", "InflateColumn::DateTime", "EncodedColumn");
+__PACKAGE__->table("moderation_original_data");
+__PACKAGE__->add_columns(
+ "id",
+ {
+ data_type => "integer",
+ is_auto_increment => 1,
+ is_nullable => 0,
+ sequence => "moderation_original_data_id_seq",
+ },
+ "problem_id",
+ { data_type => "integer", is_foreign_key => 1, is_nullable => 0 },
+ "comment_id",
+ { data_type => "integer", is_foreign_key => 1, is_nullable => 1 },
+ "title",
+ { data_type => "text", is_nullable => 1 },
+ "detail",
+ { data_type => "text", is_nullable => 1 },
+ "photo",
+ { data_type => "bytea", is_nullable => 1 },
+ "anonymous",
+ { data_type => "boolean", is_nullable => 0 },
+ "created",
+ {
+ data_type => "timestamp",
+ default_value => \"ms_current_timestamp()",
+ is_nullable => 0,
+ },
+);
+__PACKAGE__->set_primary_key("id");
+__PACKAGE__->add_unique_constraint("moderation_original_data_comment_id_key", ["comment_id"]);
+__PACKAGE__->belongs_to(
+ "comment",
+ "FixMyStreet::DB::Result::Comment",
+ { id => "comment_id" },
+ {
+ is_deferrable => 0,
+ join_type => "LEFT",
+ on_delete => "NO ACTION",
+ on_update => "NO ACTION",
+ },
+);
+__PACKAGE__->belongs_to(
+ "problem",
+ "FixMyStreet::DB::Result::Problem",
+ { id => "problem_id" },
+ { is_deferrable => 0, on_delete => "NO ACTION", on_update => "NO ACTION" },
+);
+
+
+# Created by DBIx::Class::Schema::Loader v0.07035 @ 2014-07-31 15:59:43
+# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:yR1Vi7cJQrX67dFwAcJW6w
+
+
+# You can replace this text with custom code or comments, and it will be preserved on regeneration
+1;
diff --git a/perllib/FixMyStreet/DB/Result/Problem.pm b/perllib/FixMyStreet/DB/Result/Problem.pm
index 56a915c89..34d740912 100644
--- a/perllib/FixMyStreet/DB/Result/Problem.pm
+++ b/perllib/FixMyStreet/DB/Result/Problem.pm
@@ -111,6 +111,12 @@ __PACKAGE__->has_many(
{ cascade_copy => 0, cascade_delete => 0 },
);
__PACKAGE__->has_many(
+ "moderation_original_datas",
+ "FixMyStreet::DB::Result::ModerationOriginalData",
+ { "foreign.problem_id" => "self.id" },
+ { cascade_copy => 0, cascade_delete => 0 },
+);
+__PACKAGE__->has_many(
"questionnaires",
"FixMyStreet::DB::Result::Questionnaire",
{ "foreign.problem_id" => "self.id" },
@@ -124,8 +130,8 @@ __PACKAGE__->belongs_to(
);
-# Created by DBIx::Class::Schema::Loader v0.07035 @ 2013-09-10 17:11:54
-# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:U/4BT8EGfcCLKA/7LX+qyQ
+# Created by DBIx::Class::Schema::Loader v0.07035 @ 2014-07-31 15:57:02
+# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:EvD4sS1mdJJyI1muZ4TrCw
# Add fake relationship to stored procedure table
__PACKAGE__->has_one(
@@ -135,6 +141,14 @@ __PACKAGE__->has_one(
{ cascade_copy => 0, cascade_delete => 0 },
);
+__PACKAGE__->might_have(
+ "moderation_original_data",
+ "FixMyStreet::DB::Result::ModerationOriginalData",
+ { "foreign.problem_id" => "self.id" },
+ { where => { 'comment_id' => undef },
+ cascade_copy => 0, cascade_delete => 1 },
+);
+
__PACKAGE__->load_components("+FixMyStreet::DB::RABXColumn");
__PACKAGE__->rabx_column('extra');
__PACKAGE__->rabx_column('geocode');
@@ -794,6 +808,27 @@ sub as_hashref {
};
}
+=head2 latest_moderation_log_entry
+
+Return most recent ModerationLog object
+
+=cut
+
+sub latest_moderation_log_entry {
+ my $self = shift;
+ return $self->admin_log_entries->search({ action => 'moderation' }, { order_by => 'id desc' })->first;
+}
+
+__PACKAGE__->has_many(
+ "admin_log_entries",
+ "FixMyStreet::DB::Result::AdminLog",
+ { "foreign.object_id" => "self.id" },
+ {
+ cascade_copy => 0, cascade_delete => 0,
+ where => { 'object_type' => 'problem' },
+ }
+);
+
# we need the inline_constructor bit as we don't inherit from Moose
__PACKAGE__->meta->make_immutable( inline_constructor => 0 );
diff --git a/perllib/FixMyStreet/DB/Result/User.pm b/perllib/FixMyStreet/DB/Result/User.pm
index 523382670..6a93f97ec 100644
--- a/perllib/FixMyStreet/DB/Result/User.pm
+++ b/perllib/FixMyStreet/DB/Result/User.pm
@@ -36,6 +36,12 @@ __PACKAGE__->add_columns(
__PACKAGE__->set_primary_key("id");
__PACKAGE__->add_unique_constraint("users_email_key", ["email"]);
__PACKAGE__->has_many(
+ "admin_logs",
+ "FixMyStreet::DB::Result::AdminLog",
+ { "foreign.user_id" => "self.id" },
+ { cascade_copy => 0, cascade_delete => 0 },
+);
+__PACKAGE__->has_many(
"alerts",
"FixMyStreet::DB::Result::Alert",
{ "foreign.user_id" => "self.id" },
@@ -70,10 +76,16 @@ __PACKAGE__->has_many(
{ "foreign.user_id" => "self.id" },
{ cascade_copy => 0, cascade_delete => 0 },
);
+__PACKAGE__->has_many(
+ "user_body_permissions",
+ "FixMyStreet::DB::Result::UserBodyPermission",
+ { "foreign.user_id" => "self.id" },
+ { cascade_copy => 0, cascade_delete => 0 },
+);
-# Created by DBIx::Class::Schema::Loader v0.07035 @ 2013-09-10 17:11:54
-# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:jRAtXRLRNozCmthAg9p0dA
+# Created by DBIx::Class::Schema::Loader v0.07035 @ 2014-07-29 13:54:07
+# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:Y41/jGp93IxSpyJ/o6Q1gQ
__PACKAGE__->add_columns(
"password" => {
@@ -198,4 +210,21 @@ sub split_name {
return { first => $first || '', last => $last || '' };
}
+sub has_permission_to {
+ my ($self, $permission_type, $body_id) = @_;
+
+ return unless $self->belongs_to_body($body_id);
+
+ my $permission = $self->user_body_permissions->find({
+ permission_type => $permission_type,
+ body_id => $self->from_body->id,
+ });
+ return $permission ? 1 : undef;
+}
+
+sub print {
+ my $self = shift;
+ return '[' . (join '-', @_) . ']';
+}
+
1;
diff --git a/perllib/FixMyStreet/DB/Result/UserBodyPermission.pm b/perllib/FixMyStreet/DB/Result/UserBodyPermission.pm
new file mode 100644
index 000000000..a118a1996
--- /dev/null
+++ b/perllib/FixMyStreet/DB/Result/UserBodyPermission.pm
@@ -0,0 +1,52 @@
+use utf8;
+package FixMyStreet::DB::Result::UserBodyPermission;
+
+# Created by DBIx::Class::Schema::Loader
+# DO NOT MODIFY THE FIRST PART OF THIS FILE
+
+use strict;
+use warnings;
+
+use base 'DBIx::Class::Core';
+__PACKAGE__->load_components("FilterColumn", "InflateColumn::DateTime", "EncodedColumn");
+__PACKAGE__->table("user_body_permissions");
+__PACKAGE__->add_columns(
+ "id",
+ {
+ data_type => "integer",
+ is_auto_increment => 1,
+ is_nullable => 0,
+ sequence => "user_body_permissions_id_seq",
+ },
+ "user_id",
+ { data_type => "integer", is_foreign_key => 1, is_nullable => 0 },
+ "body_id",
+ { data_type => "integer", is_foreign_key => 1, is_nullable => 0 },
+ "permission_type",
+ { data_type => "text", is_nullable => 0 },
+);
+__PACKAGE__->set_primary_key("id");
+__PACKAGE__->add_unique_constraint(
+ "user_body_permissions_user_id_body_id_permission_type_key",
+ ["user_id", "body_id", "permission_type"],
+);
+__PACKAGE__->belongs_to(
+ "body",
+ "FixMyStreet::DB::Result::Body",
+ { id => "body_id" },
+ { is_deferrable => 0, on_delete => "NO ACTION", on_update => "NO ACTION" },
+);
+__PACKAGE__->belongs_to(
+ "user",
+ "FixMyStreet::DB::Result::User",
+ { id => "user_id" },
+ { is_deferrable => 0, on_delete => "NO ACTION", on_update => "NO ACTION" },
+);
+
+
+# Created by DBIx::Class::Schema::Loader v0.07035 @ 2014-06-05 15:46:02
+# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:IWy2rYBU7WP6MyIkLYsc9Q
+
+
+# You can replace this text with custom code or comments, and it will be preserved on regeneration
+1;
diff --git a/perllib/Utils.pm b/perllib/Utils.pm
index 7a22c888d..8f0ac1820 100644
--- a/perllib/Utils.pm
+++ b/perllib/Utils.pm
@@ -231,6 +231,7 @@ sub prettify_dt {
my $now = DateTime->now( time_zone => FixMyStreet->config('TIME_ZONE') || 'local' );
my $tt = '';
+ return "[unknown time]" unless ref $dt;
$tt = $dt->strftime('%H:%M') unless $type eq 'date';
if ($dt->strftime('%Y%m%d') eq $now->strftime('%Y%m%d')) {
diff --git a/templates/email/default/problem-moderated.txt b/templates/email/default/problem-moderated.txt
new file mode 100644
index 000000000..70dc5ad11
--- /dev/null
+++ b/templates/email/default/problem-moderated.txt
@@ -0,0 +1,38 @@
+Subject: Your report on [% INCLUDE 'site-name.txt' | trim %] has been moderated
+
+Hello [% user.name %],
+
+Your report on [% INCLUDE 'site-name.txt' | trim %] has been moderated.
+
+[% IF types == 'hide' -%]
+The report has been hidden from the site.
+[% ELSE %]
+The following data has been changed:
+
+ [% types %]
+
+[% END -%]
+
+Your report had the title:
+
+[% problem.moderation_original_data.title %]
+
+And details:
+
+[% problem.moderation_original_data.detail %]
+
+[% UNLESS types == 'hide' %]
+You can see the report at [% report_uri %]
+[% END %]
+
+If you do not think that this report should have been moderated, you may contact
+the team at [% report_complain_uri %]
+
+Thank you for submitting a report through [% INCLUDE 'site-name.txt' | trim %].
+
+
+
+[% INCLUDE 'signature.txt' %]
+
+This email was sent automatically, from an unmonitored email account - so
+please do not reply to it.
diff --git a/templates/web/base/common_header_tags.html b/templates/web/base/common_header_tags.html
index cd6b4ab3a..49b649eca 100644
--- a/templates/web/base/common_header_tags.html
+++ b/templates/web/base/common_header_tags.html
@@ -19,6 +19,11 @@
<script type="text/javascript" src="[% start %][% version('/js/fixmystreet-admin.js') %]"></script>
[% END %]
+[% moderating = c.user && c.user.has_permission_to('moderate', problem.bodies_str) %]
+[% IF moderating %]
+ <script type="text/javascript" src="[% start %][% version('/js/moderate.js') %]"></script>
+[% END %]
+
[% map_js %]
[% IF category_extras_json && category_extras_json != '{}' %]
diff --git a/templates/web/base/contact/index.html b/templates/web/base/contact/index.html
index 8789fd03e..439091f88 100644
--- a/templates/web/base/contact/index.html
+++ b/templates/web/base/contact/index.html
@@ -34,7 +34,11 @@
[% ELSIF problem %]
<p>
+ [% IF moderation_complaint %]
+ [% loc('You are complaining that this problem report was unnecessarily moderated:') %]
+ [% ELSE %]
[% loc('You are reporting the following problem report for being abusive, containing personal information, or similar:') %]
+ [% END %]
</p>
<blockquote>
diff --git a/templates/web/base/report/_main.html b/templates/web/base/report/_main.html
index 87fef5408..6ae96f97c 100644
--- a/templates/web/base/report/_main.html
+++ b/templates/web/base/report/_main.html
@@ -1,5 +1,56 @@
-<div class="problem-header cf">
- <h1>[% problem.title | html %]</h1>
+[% moderating = c.user && c.user.has_permission_to('moderate', problem.bodies_str) %]
+
+[% IF moderating %]
+[%# TODO: extract stylesheet! %]
+<style>
+ .moderate-edit label {
+ display: inline-block;
+ height: 1em;
+ margin-top: 0;
+ }
+
+ .moderate-edit input {
+ display: inline-block;
+ }
+
+ .moderate-edit { display: none }
+ .moderate-edit :disabled {
+ background: #ddd;
+ }
+ br {
+ line-height: 0.5em;
+ }
+</style>
+[% END %]
+
+<div class="problem-header cf" problem-id="[% problem.id %]">
+ [% IF moderating %]
+ [% original = problem.moderation_original_data %]
+ <form method="post" action="/moderate/report/[% problem.id %]">
+ <input type="button" class="btn moderate moderate-display" value="moderate">
+ <div class="moderate-edit">
+ <input type="checkbox" class="hide-document" name="problem_hide">
+ <label for="problem_hide">Hide report completely?</label>
+ <br />
+ <input type="checkbox" name="problem_show_name" [% problem.anonymous ? '' : 'checked' %]>
+ <label for="problem_show_name">Show name publicly?</label>
+ [% IF problem.photo or original.photo %]
+ <br />
+ <input type="checkbox" name="problem_show_photo" [% problem.photo ? 'checked' : '' %]>
+ <label for="problem_show_photo">Show Photo?</label>
+ [% END %]
+ </div>
+ [% END %]
+ <h1 class="moderate-display">[% problem.title | html %]</h1>
+ [% IF moderating %]
+ <div class="moderate-edit">
+ [% IF problem.title != original.title %]
+ <input type="checkbox" name="problem_revert_title" class="revert-title">
+ <label for="problem_revert_title">Revert to original title</label>
+ [% END %]
+ <h1><input type="text" name="problem_title" value="[% problem.title | html %]"></h1>
+ </div>
+ [% END %]
<p><em>
[% problem.meta_line(c) | html %]
@@ -9,10 +60,40 @@
[% ELSE %]
<br><small>[% loc('Not reported to council') %]</small>
[% END %]
+ [% mlog = problem.latest_moderation_log_entry(); IF mlog %]
+ <br /> Moderated by [% mlog.user.from_body.name %] at [% prettify_dt(mlog.whenedited) %]
+ [% END %]
</em></p>
[% INCLUDE 'report/_support.html' %]
- [% INCLUDE 'report/photo.html' object=problem %]
- [% add_links( problem.detail ) | html_para %]
+ [% IF c.cobrand.moniker != 'southampton' %]
+ [% INCLUDE 'report/photo.html' object=problem %]
+ [% END %]
+
+ <div class="moderate-display">
+ [% add_links( problem.detail ) | html_para %]
+ </div>
+ [% IF moderating %]
+ <div class="moderate-edit">
+ [% IF problem.detail != original.detail %]
+ <input type="checkbox" name="problem_revert_detail" class="revert-textarea">
+ <label for="problem_revert_detail">Revert to original text</label>
+ [% END %]
+ <textarea name="problem_detail">[% add_links( problem.detail ) %]</textarea>
+ </div>
+ [% END %]
+
+ [% IF c.cobrand.moniker == 'southampton' %]
+ [% INCLUDE 'report/photo.html' object=problem %]
+ [% END %]
+ [% IF moderating %]
+ <div class="moderate-edit">
+ <label for="moderation_reason">Moderation reason:</label>
+ <input type="text" name="moderation_reason" placeholder="Describe why you are moderating this">
+ <input type="submit" class="red-btn" value="Moderate it">
+ <input type="button" class="btn cancel" value="cancel">
+ </div>
+ </form>
+ [% END %]
</div>
diff --git a/templates/web/fixmystreet/contact/index.html b/templates/web/fixmystreet/contact/index.html
index 44aa5c2e4..24d11d400 100644
--- a/templates/web/fixmystreet/contact/index.html
+++ b/templates/web/fixmystreet/contact/index.html
@@ -51,7 +51,11 @@
[% ELSIF problem %]
<p>
+ [% IF moderation_complaint %]
+ [% loc('You are complaining that this problem report was unnecessarily moderated:') %]
+ [% ELSE %]
[% loc('You are reporting the following problem report for being abusive, containing personal information, or similar:') %]
+ [% END %]
</p>
<blockquote>
diff --git a/templates/web/fixmystreet/report/update.html b/templates/web/fixmystreet/report/update.html
index 0803ac758..a400b2416 100644
--- a/templates/web/fixmystreet/report/update.html
+++ b/templates/web/fixmystreet/report/update.html
@@ -1,9 +1,27 @@
+[% moderating = c.user && c.user.has_permission_to('moderate', problem.bodies_str) %]
+
[% IF loop.first %]
<section class="full-width">
<h4 class="static-with-rule">[% loc('Updates') %]</h4>
<ul class="issue-list">
[% END %]
- <li>
+ <li class="issue">
+ [% IF moderating; original_update = update.moderation_original_data %]
+ <form method="post" action="/moderate/report/[% problem.id %]/update/[% update.id %]">
+ <input type="button" class="btn moderate moderate-display" value="moderate">
+ <div class="moderate-edit">
+ <input type="checkbox" class="hide-document" name="update_hide">
+ <label for="update_hide">Hide update completely?</label>
+ <br />
+ <input type="checkbox" name="update_show_name" [% update.anonymous ? '' : 'checked' %]>
+ <label for="update_show_name">Show name publicly?</label>
+ [% IF update.photo or original_update.photo %]
+ <br />
+ <input type="checkbox" name="update_show_photo" [% update.photo ? 'checked' : '' %]>
+ <label for="update_show_photo">Show Photo?</label>
+ [% END %]
+ </div>
+ [% END %]
<div class="update-wrap">
[% IF update.whenanswered %]
<div class="update-text">
@@ -11,16 +29,40 @@
</div>
[% ELSE %]
<div class="update-text">
- [% add_links( update.text ) | html_para %]
+ <div class="moderate-display">
+ [% add_links( update.text ) | html_para %]
+ </div>
+ [% IF moderating %]
+ <div class="moderate-edit">
+ [% IF update.text != original.detail %]
+ <input type="checkbox" name="update_revert_detail" class="revert-textarea">
+ <label for="update_revert_detail">Revert to original</label>
+ [% END %]
+ <textarea name="update_detail">[% add_links( update.text ) %]</textarea>
+ </div>
+ [% END %]
<p class="meta-2">
<a name="update_[% update.id %]"></a>
[% INCLUDE meta_line %]
+ [% mlog = update.latest_moderation_log_entry(); IF mlog %]
+ <br /> Moderated by [% mlog.user.from_body.name %] at [% prettify_dt(mlog.whenedited) %]
+ [% END %]
</p>
</div>
[% INCLUDE 'report/photo.html' object=update %]
[% END %]
</div>
+ [% IF moderating %]
+ <div class="moderate-edit">
+ <label for="moderation_reason">Moderation reason:</label>
+ <input type="text" name="moderation_reason"
+ placeholder="Describe why you are moderating this">
+ <input type="submit" class="red-btn" value="moderate it">
+ <input type="button" class="btn cancel" value="cancel">
+ </div>
+ </form>
+ [% END %]
</li>
[% IF loop.last %]
</ul>
diff --git a/web/js/moderate.js b/web/js/moderate.js
new file mode 100644
index 000000000..075766d0b
--- /dev/null
+++ b/web/js/moderate.js
@@ -0,0 +1,42 @@
+$(function () {
+ setup_moderation( $('.problem-header'), 'problem' );
+ setup_moderation( $('.issue-list .issue'), 'update' );
+});
+
+function setup_moderation (elem, word) {
+
+ elem.each( function () {
+ var $elem = $(this)
+ $elem.find('.moderate').click( function () {
+ $elem.find('.moderate-display').hide();
+ $elem.find('.moderate-edit').show();
+ });
+
+ $elem.find('.revert-title').change( function () {
+ $elem.find('input[name=problem_title]').prop('disabled', $(this).prop('checked'));
+ });
+ $elem.find('.revert-textarea').change( function () {
+ $elem.find('textarea').prop('disabled', $(this).prop('checked'));
+ });
+
+ var hide_document = $elem.find('.hide-document');
+ hide_document.change( function () {
+ $elem.find('input[name=problem_title]').prop('disabled', $(this).prop('checked'));
+ $elem.find('textarea').prop('disabled', $(this).prop('checked'));
+ $elem.find('input[type=checkbox]').prop('disabled', $(this).prop('checked'));
+ $(this).prop('disabled', false); // in case disabled above
+ });
+
+ $elem.find('.cancel').click( function () {
+ $elem.find('.moderate-display').show();
+ $elem.find('.moderate-edit').hide();
+ });
+
+ $elem.find('form').submit( function () {
+ if (hide_document.prop('checked')) {
+ return confirm('This will hide the ' + word + ' completely! (You will not be able to undo this without contacting support.)');
+ }
+ return true;
+ });
+ });
+}