From 36b9d7ae54a55baab3a34ba56a22aa9bcbfe5304 Mon Sep 17 00:00:00 2001 From: Pablo Brasero Date: Wed, 27 May 2026 15:37:46 +0100 Subject: [PATCH] CRUD interface for moderation zones --- app/abilities/ability.rb | 1 + .../moderation_zones_controller.rb | 55 +++++ app/helpers/moderation_zones_helper.rb | 25 ++ app/models/moderation_zone.rb | 2 + app/views/moderation_zones/_form.html.erb | 17 ++ .../moderation_zones/_navigation.html.erb | 12 + app/views/moderation_zones/_row.html.erb | 16 ++ app/views/moderation_zones/edit.html.erb | 8 + app/views/moderation_zones/index.html.erb | 25 ++ app/views/moderation_zones/new.html.erb | 8 + config/locales/en.yml | 29 +++ config/routes.rb | 3 + .../moderation_zones_controller_test.rb | 224 ++++++++++++++++++ test/factories/moderation_zones.rb | 12 + 14 files changed, 437 insertions(+) create mode 100644 app/controllers/moderation_zones_controller.rb create mode 100644 app/helpers/moderation_zones_helper.rb create mode 100644 app/views/moderation_zones/_form.html.erb create mode 100644 app/views/moderation_zones/_navigation.html.erb create mode 100644 app/views/moderation_zones/_row.html.erb create mode 100644 app/views/moderation_zones/edit.html.erb create mode 100644 app/views/moderation_zones/index.html.erb create mode 100644 app/views/moderation_zones/new.html.erb create mode 100644 test/controllers/moderation_zones_controller_test.rb diff --git a/app/abilities/ability.rb b/app/abilities/ability.rb index 1d747110a..01d0de6b3 100644 --- a/app/abilities/ability.rb +++ b/app/abilities/ability.rb @@ -64,6 +64,7 @@ class Ability can :update, UserBlock, :creator => user can :update, UserBlock, :revoker => user can :update, UserBlock, :active? => true + can :manage, ModerationZone end if user.administrator? diff --git a/app/controllers/moderation_zones_controller.rb b/app/controllers/moderation_zones_controller.rb new file mode 100644 index 000000000..552832d78 --- /dev/null +++ b/app/controllers/moderation_zones_controller.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +class ModerationZonesController < ApplicationController + layout :site_layout + + before_action :authorize_web + before_action :set_locale + + authorize_resource + + before_action :check_database_readable + before_action :check_database_writable, :except => [:index] + before_action :set_moderation_zone, :only => [:edit, :update] + + def index + @moderation_zones = ModerationZone.all + end + + def new + @moderation_zone = ModerationZone.new + end + + def edit; end + + def create + @moderation_zone = ModerationZone.new(moderation_zone_params) + @moderation_zone.creator = current_user + + if @moderation_zone.save + redirect_to moderation_zones_url, :notice => t(".success") + else + render :new, :status => :unprocessable_content + end + end + + def update + if @moderation_zone.update(moderation_zone_params) + redirect_to moderation_zones_url, :notice => t(".success"), :status => :see_other + else + render :edit, :status => :unprocessable_content + end + end + + private + + def set_moderation_zone + @moderation_zone = ModerationZone.find(params.expect(:id)) + end + + def moderation_zone_params + params.expect(:moderation_zone => [:name, :reason, :zone, :period]).tap do |safe_params| + safe_params[:ends_at] = safe_params.delete("period").to_i.hours.from_now + end + end +end diff --git a/app/helpers/moderation_zones_helper.rb b/app/helpers/moderation_zones_helper.rb new file mode 100644 index 000000000..c5e02f34a --- /dev/null +++ b/app/helpers/moderation_zones_helper.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module ModerationZonesHelper + def options_for_moderation_zone_period + ModerationZone::PERIODS.collect do |h| + [block_duration_in_words(h.hours), h.to_s] + end + end + + def selected_option_for_moderation_zone_period(moderation_zone) + param_value = params.dig(:moderation_zone, :period) + value_to_compare = + if param_value + param_value.to_i + elsif moderation_zone.ends_at + ((moderation_zone.ends_at - Time.now.utc) / 1.hour).ceil + end + + if value_to_compare + ModerationZone::PERIODS.min_by do |h| + (value_to_compare - h).abs + end + end + end +end diff --git a/app/models/moderation_zone.rb b/app/models/moderation_zone.rb index f3c5c0edd..14198a1f6 100644 --- a/app/models/moderation_zone.rb +++ b/app/models/moderation_zone.rb @@ -26,6 +26,8 @@ # fk_rails_... (revoker_id => users.id) # class ModerationZone < ApplicationRecord + PERIODS = Settings.user_block_periods.excluding(0).freeze + belongs_to :creator, :class_name => "User" belongs_to :revoker, :class_name => "User", :optional => true diff --git a/app/views/moderation_zones/_form.html.erb b/app/views/moderation_zones/_form.html.erb new file mode 100644 index 000000000..3b06f9097 --- /dev/null +++ b/app/views/moderation_zones/_form.html.erb @@ -0,0 +1,17 @@ +<%# locals: (moderation_zone:) %> + +<%= bootstrap_form_with(:model => moderation_zone) do |form| %> + <%= form.text_field :name %> + + <%= form.richtext_field :reason, :cols => 80, :rows => 20, :format => moderation_zone.reason_format %> + + <%= form.text_area :zone %> + + <%= form.select( + :period, + options_for_moderation_zone_period, + :selected => selected_option_for_moderation_zone_period(moderation_zone) + ) %> + + <%= form.primary %> +<% end %> diff --git a/app/views/moderation_zones/_navigation.html.erb b/app/views/moderation_zones/_navigation.html.erb new file mode 100644 index 000000000..f79c6cb33 --- /dev/null +++ b/app/views/moderation_zones/_navigation.html.erb @@ -0,0 +1,12 @@ +<%# locals: () %> + + diff --git a/app/views/moderation_zones/_row.html.erb b/app/views/moderation_zones/_row.html.erb new file mode 100644 index 000000000..2fa6a8d0d --- /dev/null +++ b/app/views/moderation_zones/_row.html.erb @@ -0,0 +1,16 @@ +<%# locals: (moderation_zone:) %> + + + <%= moderation_zone.name %> + <%= moderation_zone.reason %> + <%= moderation_zone.creator.display_name %> + <%= block_short_time_in_past(moderation_zone.created_at) %> + + <% if moderation_zone.ends_at.future? %> + <%= block_short_time_in_future(moderation_zone.ends_at) %> + <% else %> + <%= block_short_time_in_past(moderation_zone.ends_at) %> + <% end %> + + <%= link_to t(".edit"), edit_moderation_zone_path(moderation_zone) %> + diff --git a/app/views/moderation_zones/edit.html.erb b/app/views/moderation_zones/edit.html.erb new file mode 100644 index 000000000..20b0e4047 --- /dev/null +++ b/app/views/moderation_zones/edit.html.erb @@ -0,0 +1,8 @@ +<% @title = t ".title", :name => @moderation_zone.name %> + +<% content_for :heading_class, "pb-0" %> +<% content_for :heading do %> +

<%= t(".heading", :name => @moderation_zone.name) %>

+<% end %> + +<%= render "form", :moderation_zone => @moderation_zone %> diff --git a/app/views/moderation_zones/index.html.erb b/app/views/moderation_zones/index.html.erb new file mode 100644 index 000000000..dd683cc47 --- /dev/null +++ b/app/views/moderation_zones/index.html.erb @@ -0,0 +1,25 @@ +<% @title = t(".title") %> + +<% content_for :heading_class, "pb-0" %> +<% content_for :heading do %> +

<%= t(".heading") %>

+ <%= render "navigation" %> +<% end %> + + + + + + + + + + + + + + <% @moderation_zones.each do |moderation_zone| %> + <%= render "row", :moderation_zone => moderation_zone %> + <% end %> + +
<%= t(".name") %><%= t(".reason") %><%= t(".creator") %><%= t(".start") %><%= t(".end") %>
diff --git a/app/views/moderation_zones/new.html.erb b/app/views/moderation_zones/new.html.erb new file mode 100644 index 000000000..20b0e4047 --- /dev/null +++ b/app/views/moderation_zones/new.html.erb @@ -0,0 +1,8 @@ +<% @title = t ".title", :name => @moderation_zone.name %> + +<% content_for :heading_class, "pb-0" %> +<% content_for :heading do %> +

<%= t(".heading", :name => @moderation_zone.name) %>

+<% end %> + +<%= render "form", :moderation_zone => @moderation_zone %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 6bdb37549..c6a8eef39 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -118,6 +118,11 @@ en: gpx_file: Choose GPS Trace File visibility: Visibility tagstring: Tags + moderation_zone: + name: Name + reason: Reason + zone: Zone + period: Period message: sender: "Sender" title: "Subject" @@ -3846,3 +3851,27 @@ en: trailing_whitespace: "has trailing whitespace" invalid_characters: "contains invalid characters" url_characters: "contains special URL characters (%{characters})" + moderation_zones: + navigation: + new_title: Define a new moderation zone + new: New Moderation Zone + index: + title: Moderation Zones + heading: Moderation Zones + name: Name + reason: Reason + creator: Creator + start: Start + end: End + row: + edit: Edit + new: + title: New Moderation Zone + heading: New Moderation Zone + create: + success: "Moderation zone created." + edit: + title: "Editing moderation zone %{name}" + heading: "Editing moderation zone %{name}" + update: + success: "Moderation zone updated." diff --git a/config/routes.rb b/config/routes.rb index 57a4cd89f..60cd94f0a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -432,6 +432,9 @@ OpenStreetMap::Application.routes.draw do # redactions resources :redactions + # moderation zones + resources :moderation_zones, :only => [:index, :new, :create, :edit, :update] + # errors match "/400", :to => "errors#bad_request", :via => :all match "/403", :to => "errors#forbidden", :via => :all diff --git a/test/controllers/moderation_zones_controller_test.rb b/test/controllers/moderation_zones_controller_test.rb new file mode 100644 index 000000000..4c7f2dbfe --- /dev/null +++ b/test/controllers/moderation_zones_controller_test.rb @@ -0,0 +1,224 @@ +# frozen_string_literal: true + +require "test_helper" + +class ModerationZonesControllerTest < ActionDispatch::IntegrationTest + test "routes" do + assert_routing( + { :path => "/moderation_zones", :method => :get }, + { :controller => "moderation_zones", :action => "index" } + ) + assert_routing( + { :path => "/moderation_zones/new", :method => :get }, + { :controller => "moderation_zones", :action => "new" } + ) + assert_routing( + { :path => "/moderation_zones/123/edit", :method => :get }, + { :controller => "moderation_zones", :action => "edit", :id => "123" } + ) + assert_routing( + { :path => "/moderation_zones", :method => :post }, + { :controller => "moderation_zones", :action => "create" } + ) + assert_routing( + { :path => "/moderation_zones/123", :method => :put }, + { :controller => "moderation_zones", :action => "update", :id => "123" } + ) + end + + test "index, unauthenticated" do + get moderation_zones_url + assert_redirected_to login_url(:referer => moderation_zones_path) + end + + test "index, as normal user" do + session_for(create(:user)) + get moderation_zones_url + assert_redirected_to "/403" + end + + test "index, as moderator" do + session_for(create(:moderator_user)) + get moderation_zones_url + assert_response :success + end + + test "new, unauthenticated" do + get new_moderation_zone_url + assert_redirected_to login_url(:referer => new_moderation_zone_path) + end + + test "new, as normal user" do + session_for(create(:user)) + get new_moderation_zone_url + assert_redirected_to "/403" + end + + test "new, as moderator" do + session_for(create(:moderator_user)) + get new_moderation_zone_url + assert_response :success + end + + test "create, unauthenticated" do + post( + moderation_zones_url, + :params => { :moderation_zone => {} } + ) + assert_response :forbidden + end + + test "create, as normal user" do + session_for(create(:user)) + post( + moderation_zones_url, + :params => { + :moderation_zone => { + **attributes_for(:moderation_zone) + .slice(:name, :reason, :zone), + :period => 2.days.in_hours + } + } + ) + assert_redirected_to "/403" + end + + test "create, as moderator" do + moderator = create(:moderator_user) + session_for(moderator) + + assert_difference("ModerationZone.count") do + post( + moderation_zones_url, + :params => { + :moderation_zone => { + **attributes_for(:moderation_zone) + .slice(:name, :reason, :zone), + :period => 2.days.in_hours + } + } + ) + end + + moderation_zone = ModerationZone.last + assert_redirected_to moderation_zones_url + + assert_in_delta moderation_zone.ends_at, 2.days.from_now, 10.seconds + end + + test "create, with errors" do + moderator = create(:moderator_user) + session_for(moderator) + + assert_no_difference("ModerationZone.count") do + post( + moderation_zones_url, + :params => { + :moderation_zone => { + **attributes_for(:moderation_zone) + .slice(:name, :zone), + :period => 6.months.in_hours + } + } + ) + end + + assert_response :unprocessable_content + assert_dom "option[selected]", :text => "6 months" + end + + test "edit, unauthenticated" do + get edit_moderation_zone_url(123) + assert_redirected_to login_path(:referer => edit_moderation_zone_path(123)) + end + + test "edit, as normal user" do + session_for(create(:user)) + moderation_zone = create(:moderation_zone, :ends_at => 1.year.from_now) + get edit_moderation_zone_url(moderation_zone) + assert_redirected_to "/403" + end + + test "edit, as moderator" do + session_for(create(:moderator_user)) + moderation_zone = create(:moderation_zone, :ends_at => 1.year.from_now) + get edit_moderation_zone_url(moderation_zone) + assert_response :success + assert_dom "option[selected]", :text => "1 year" + end + + test "update, unauthenticated" do + patch( + moderation_zone_url(123), + :params => { :moderation_zone => {} } + ) + assert_response :forbidden + end + + test "update, as normal user" do + # This is an edge case: a normal user has a moderation zone to their name. + # Perhaps they used to be a moderator, but no longer. The important + # bit is that they shouldn't be able to update it any more. + # + # Instead of this we could just have a test for "normal user can't update" + # with a simpler request (eg: with empty params) but, for the sake of + # doing it properly, let's have everyting in place except for the only detail + # that the user is not a moderator. + session_for(create(:user)) + moderation_zone = create(:moderation_zone, :ends_at => 1.week.from_now) + + patch( + moderation_zone_url(moderation_zone), + :params => { + :moderation_zone => { + :name => moderation_zone.name, + :reason => moderation_zone.reason, + :zone => moderation_zone.zone, + :period => 2.weeks.in_hours + } + } + ) + assert_redirected_to "/403" + end + + test "update, as moderator" do + session_for(create(:moderator_user)) + moderation_zone = create(:moderation_zone, :ends_at => 1.week.from_now) + + patch( + moderation_zone_url(moderation_zone), + :params => { + :moderation_zone => { + :name => moderation_zone.name, + :reason => moderation_zone.reason, + :zone => moderation_zone.zone, + :period => 2.weeks.in_hours + } + } + ) + assert_redirected_to moderation_zones_url + + moderation_zone.reload + assert_in_delta moderation_zone.ends_at, 2.weeks.from_now, 10.seconds + end + + test "update, with errors" do + session_for(create(:moderator_user)) + moderation_zone = create(:moderation_zone, :ends_at => 1.week.from_now) + + patch( + moderation_zone_url(moderation_zone), + :params => { + :moderation_zone => { + :name => moderation_zone.name, + :reason => "", + :zone => moderation_zone.zone, + :period => 4.days.in_hours + } + } + ) + + assert_response :unprocessable_content + assert_dom "option[selected]", :text => "4 days" + end +end diff --git a/test/factories/moderation_zones.rb b/test/factories/moderation_zones.rb index 8382a284e..48156ac25 100644 --- a/test/factories/moderation_zones.rb +++ b/test/factories/moderation_zones.rb @@ -7,6 +7,18 @@ FactoryBot.define do creator { association :user } ends_at { 1.day.from_now } + zone do + <<~GEOMETRY + POLYGON(( + -1 -1, + -1 1, + 1 1, + 1 -1, + -1 -1 + )) + GEOMETRY + end + trait :seville_cathedral do zone do <<~GEOMETRY -- 2.47.3