diff options
-rw-r--r-- | cpanfile | 3 | ||||
-rw-r--r-- | db/schema.sql | 32 | ||||
-rw-r--r-- | db/schema_0032-moderation.sql | 36 | ||||
-rw-r--r-- | perllib/FixMyStreet/App/Controller/Contact.pm | 20 | ||||
-rw-r--r-- | perllib/FixMyStreet/App/Controller/Moderate.pm | 365 | ||||
-rw-r--r-- | perllib/FixMyStreet/DB/Result/AdminLog.pm | 21 | ||||
-rw-r--r-- | perllib/FixMyStreet/DB/Result/Body.pm | 14 | ||||
-rw-r--r-- | perllib/FixMyStreet/DB/Result/Comment.pm | 48 | ||||
-rw-r--r-- | perllib/FixMyStreet/DB/Result/ModerationOriginalData.pm | 66 | ||||
-rw-r--r-- | perllib/FixMyStreet/DB/Result/Problem.pm | 39 | ||||
-rw-r--r-- | perllib/FixMyStreet/DB/Result/User.pm | 33 | ||||
-rw-r--r-- | perllib/FixMyStreet/DB/Result/UserBodyPermission.pm | 52 | ||||
-rw-r--r-- | perllib/Utils.pm | 1 | ||||
-rw-r--r-- | templates/email/default/problem-moderated.txt | 38 | ||||
-rw-r--r-- | templates/web/base/common_header_tags.html | 5 | ||||
-rw-r--r-- | templates/web/base/contact/index.html | 4 | ||||
-rw-r--r-- | templates/web/base/report/_main.html | 89 | ||||
-rw-r--r-- | templates/web/fixmystreet/contact/index.html | 4 | ||||
-rw-r--r-- | templates/web/fixmystreet/report/update.html | 46 | ||||
-rw-r--r-- | web/js/moderate.js | 42 |
20 files changed, 936 insertions, 22 deletions
@@ -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; + }); + }); +} |