mirror of
https://github.com/mastodon/mastodon.git
synced 2025-07-15 08:48:15 +00:00
Implement Instance Moderation Notes (#31529)
This commit is contained in:
parent
0f9f27972d
commit
72f2f35bfb
|
@ -0,0 +1,44 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Admin::Instances::ModerationNotesController < Admin::BaseController
|
||||
before_action :set_instance, only: [:create]
|
||||
before_action :set_instance_note, only: [:destroy]
|
||||
|
||||
def create
|
||||
authorize :instance_moderation_note, :create?
|
||||
|
||||
@instance_moderation_note = current_account.instance_moderation_notes.new(content: resource_params[:content], domain: @instance.domain)
|
||||
|
||||
if @instance_moderation_note.save
|
||||
redirect_to admin_instance_path(@instance.domain, anchor: helpers.dom_id(@instance_moderation_note)), notice: I18n.t('admin.instances.moderation_notes.created_msg')
|
||||
else
|
||||
@instance_moderation_notes = @instance.moderation_notes.includes(:account).chronological
|
||||
@time_period = (6.days.ago.to_date...Time.now.utc.to_date)
|
||||
@action_logs = Admin::ActionLogFilter.new(target_domain: @instance.domain).results.limit(5)
|
||||
|
||||
render 'admin/instances/show'
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize @instance_moderation_note, :destroy?
|
||||
@instance_moderation_note.destroy!
|
||||
redirect_to admin_instance_path(@instance_moderation_note.domain, anchor: 'instance-notes'), notice: I18n.t('admin.instances.moderation_notes.destroyed_msg')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def resource_params
|
||||
params
|
||||
.expect(instance_moderation_note: [:content])
|
||||
end
|
||||
|
||||
def set_instance
|
||||
domain = params[:instance_id]&.strip
|
||||
@instance = Instance.find_or_initialize_by(domain: TagManager.instance.normalize_domain(domain))
|
||||
end
|
||||
|
||||
def set_instance_note
|
||||
@instance_moderation_note = InstanceModerationNote.find(params[:id])
|
||||
end
|
||||
end
|
|
@ -14,6 +14,9 @@ module Admin
|
|||
|
||||
def show
|
||||
authorize :instance, :show?
|
||||
|
||||
@instance_moderation_note = @instance.moderation_notes.new
|
||||
@instance_moderation_notes = @instance.moderation_notes.includes(:account).chronological
|
||||
@time_period = (6.days.ago.to_date...Time.now.utc.to_date)
|
||||
@action_logs = Admin::ActionLogFilter.new(target_domain: @instance.domain).results.limit(LOGS_LIMIT)
|
||||
end
|
||||
|
@ -52,7 +55,8 @@ module Admin
|
|||
private
|
||||
|
||||
def set_instance
|
||||
@instance = Instance.find_or_initialize_by(domain: TagManager.instance.normalize_domain(params[:id]&.strip))
|
||||
domain = params[:id]&.strip
|
||||
@instance = Instance.find_or_initialize_by(domain: TagManager.instance.normalize_domain(domain))
|
||||
end
|
||||
|
||||
def set_instances
|
||||
|
|
|
@ -1632,6 +1632,17 @@ a.sparkline {
|
|||
}
|
||||
}
|
||||
|
||||
a.timestamp {
|
||||
color: $darker-text-color;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
time {
|
||||
margin-inline-start: 5px;
|
||||
vertical-align: baseline;
|
||||
|
|
|
@ -18,6 +18,7 @@ module Account::Associations
|
|||
has_many :favourites
|
||||
has_many :featured_tags, -> { includes(:tag) }
|
||||
has_many :list_accounts
|
||||
has_many :instance_moderation_notes
|
||||
has_many :media_attachments
|
||||
has_many :mentions
|
||||
has_many :migrations, class_name: 'AccountMigration'
|
||||
|
|
|
@ -21,6 +21,7 @@ class Instance < ApplicationRecord
|
|||
belongs_to :unavailable_domain
|
||||
|
||||
has_many :accounts, dependent: nil
|
||||
has_many :moderation_notes, class_name: 'InstanceModerationNote', dependent: :destroy
|
||||
end
|
||||
|
||||
scope :searchable, -> { where.not(domain: DomainBlock.select(:domain)) }
|
||||
|
|
27
app/models/instance_moderation_note.rb
Normal file
27
app/models/instance_moderation_note.rb
Normal file
|
@ -0,0 +1,27 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: instance_moderation_notes
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# content :text
|
||||
# domain :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint(8) not null
|
||||
#
|
||||
class InstanceModerationNote < ApplicationRecord
|
||||
include DomainNormalizable
|
||||
include DomainMaterializable
|
||||
|
||||
CONTENT_SIZE_LIMIT = 2_000
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :instance, inverse_of: :moderation_notes, foreign_key: :domain, primary_key: :domain, optional: true
|
||||
|
||||
scope :chronological, -> { reorder(id: :asc) }
|
||||
|
||||
validates :content, presence: true, length: { maximum: CONTENT_SIZE_LIMIT }
|
||||
validates :domain, presence: true, domain: true
|
||||
end
|
17
app/policies/instance_moderation_note_policy.rb
Normal file
17
app/policies/instance_moderation_note_policy.rb
Normal file
|
@ -0,0 +1,17 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class InstanceModerationNotePolicy < ApplicationPolicy
|
||||
def create?
|
||||
role.can?(:manage_federation)
|
||||
end
|
||||
|
||||
def destroy?
|
||||
owner? || (role.can?(:manage_federation) && role.overrides?(record.account.user_role))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def owner?
|
||||
record.account_id == current_account&.id
|
||||
end
|
||||
end
|
|
@ -6,7 +6,7 @@
|
|||
= date_range(@time_period)
|
||||
|
||||
- if @instance.persisted?
|
||||
= render 'dashboard', instance_domain: @instance.domain, period_end_at: @time_period.last, period_start_at: @time_period.first
|
||||
= render 'admin/instances/dashboard', instance_domain: @instance.domain, period_end_at: @time_period.last, period_start_at: @time_period.first
|
||||
- else
|
||||
%p
|
||||
= t('admin.instances.unknown_instance')
|
||||
|
@ -55,6 +55,24 @@
|
|||
= render partial: 'admin/action_logs/action_log', collection: @action_logs
|
||||
= link_to t('admin.instances.audit_log.view_all'), admin_action_logs_path(target_domain: @instance.domain), class: 'button'
|
||||
|
||||
%hr.spacer/
|
||||
|
||||
- if @instance.domain.present?
|
||||
%h3#instance-notes= t('admin.instances.moderation_notes.title')
|
||||
%p= t('admin.instances.moderation_notes.description_html')
|
||||
.report-notes
|
||||
= render partial: 'admin/report_notes/report_note', collection: @instance_moderation_notes
|
||||
|
||||
= simple_form_for @instance_moderation_note, url: admin_instance_moderation_notes_path(instance_id: @instance.domain) do |form|
|
||||
= render 'shared/error_messages', object: @instance_moderation_note
|
||||
|
||||
.field-group
|
||||
= form.input :content, input_html: { placeholder: t('admin.instances.moderation_notes.placeholder'), maxlength: InstanceModerationNote::CONTENT_SIZE_LIMIT, rows: 6, autofocus: @instance_moderation_note.errors.any? }
|
||||
|
||||
.actions
|
||||
= form.button :button, t('admin.instances.moderation_notes.create'), type: :submit
|
||||
|
||||
- if @instance.persisted?
|
||||
%hr.spacer/
|
||||
|
||||
%h3= t('admin.instances.availability.title')
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
.report-notes__item
|
||||
.report-notes__item{ id: dom_id(report_note) }
|
||||
= image_tag report_note.account.avatar.url, class: 'report-notes__item__avatar'
|
||||
|
||||
.report-notes__item__header
|
||||
%span.username
|
||||
= link_to report_note.account.username, admin_account_path(report_note.account_id)
|
||||
%time.relative-formatted{ datetime: report_note.created_at.iso8601, title: report_note.created_at }
|
||||
= l report_note.created_at.to_date
|
||||
%a.timestamp{ href: "##{dom_id(report_note)}" }
|
||||
%time.relative-formatted{ datetime: report_note.created_at.iso8601, title: report_note.created_at }
|
||||
= l report_note.created_at.to_date
|
||||
|
||||
.report-notes__item__content
|
||||
= linkify(report_note.content)
|
||||
|
@ -14,5 +15,7 @@
|
|||
.report-notes__item__actions
|
||||
- if report_note.is_a?(AccountModerationNote)
|
||||
= table_link_to 'delete', t('admin.reports.notes.delete'), admin_account_moderation_note_path(report_note), method: :delete
|
||||
- elsif report_note.is_a?(InstanceModerationNote)
|
||||
= table_link_to 'delete', t('admin.reports.notes.delete'), admin_instance_moderation_note_path(instance_id: report_note.domain, id: report_note.id), method: :delete
|
||||
- else
|
||||
= table_link_to 'delete', t('admin.reports.notes.delete'), admin_report_note_path(report_note), method: :delete
|
||||
|
|
|
@ -578,6 +578,13 @@ en:
|
|||
all: All
|
||||
limited: Limited
|
||||
title: Moderation
|
||||
moderation_notes:
|
||||
create: Add Moderation Note
|
||||
created_msg: Instance moderation note successfully created!
|
||||
description_html: View and leave notes for other moderators and your future self
|
||||
destroyed_msg: Instance moderation note successfully deleted!
|
||||
placeholder: Information about this instance, actions taken, or anything else that will help you moderate this instance in the future.
|
||||
title: Moderation Notes
|
||||
private_comment: Private comment
|
||||
public_comment: Public comment
|
||||
purge: Purge
|
||||
|
|
|
@ -91,6 +91,8 @@ namespace :admin do
|
|||
post :restart_delivery
|
||||
post :stop_delivery
|
||||
end
|
||||
|
||||
resources :moderation_notes, controller: 'instances/moderation_notes', only: [:create, :destroy]
|
||||
end
|
||||
|
||||
resources :rules, only: [:index, :new, :create, :edit, :update, :destroy] do
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CreateInstanceModerationNotes < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :instance_moderation_notes do |t|
|
||||
t.string :domain, null: false
|
||||
t.belongs_to :account, foreign_key: { on_delete: :cascade }, index: false, null: false
|
||||
t.text :content
|
||||
|
||||
t.timestamps
|
||||
|
||||
t.index ['domain'], name: 'index_instance_moderation_notes_on_domain'
|
||||
end
|
||||
end
|
||||
end
|
20
db/schema.rb
20
db/schema.rb
|
@ -191,8 +191,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_05_110215) do
|
|||
t.boolean "hide_collections"
|
||||
t.integer "avatar_storage_schema_version"
|
||||
t.integer "header_storage_schema_version"
|
||||
t.datetime "sensitized_at", precision: nil
|
||||
t.integer "suspension_origin"
|
||||
t.datetime "sensitized_at", precision: nil
|
||||
t.boolean "trendable"
|
||||
t.datetime "reviewed_at", precision: nil
|
||||
t.datetime "requested_review_at", precision: nil
|
||||
|
@ -580,6 +580,15 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_05_110215) do
|
|||
t.index ["user_id"], name: "index_identities_on_user_id"
|
||||
end
|
||||
|
||||
create_table "instance_moderation_notes", force: :cascade do |t|
|
||||
t.string "domain", null: false
|
||||
t.bigint "account_id", null: false
|
||||
t.text "content"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["domain"], name: "index_instance_moderation_notes_on_domain"
|
||||
end
|
||||
|
||||
create_table "invites", force: :cascade do |t|
|
||||
t.bigint "user_id", null: false
|
||||
t.string "code", default: "", null: false
|
||||
|
@ -595,12 +604,12 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_05_110215) do
|
|||
end
|
||||
|
||||
create_table "ip_blocks", force: :cascade do |t|
|
||||
t.inet "ip", default: "0.0.0.0", null: false
|
||||
t.integer "severity", default: 0, null: false
|
||||
t.datetime "expires_at", precision: nil
|
||||
t.text "comment", default: "", null: false
|
||||
t.datetime "created_at", precision: nil, null: false
|
||||
t.datetime "updated_at", precision: nil, null: false
|
||||
t.datetime "expires_at", precision: nil
|
||||
t.inet "ip", default: "0.0.0.0", null: false
|
||||
t.integer "severity", default: 0, null: false
|
||||
t.text "comment", default: "", null: false
|
||||
t.index ["ip"], name: "index_ip_blocks_on_ip", unique: true
|
||||
end
|
||||
|
||||
|
@ -1372,6 +1381,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_05_110215) do
|
|||
add_foreign_key "follows", "accounts", name: "fk_32ed1b5560", on_delete: :cascade
|
||||
add_foreign_key "generated_annual_reports", "accounts"
|
||||
add_foreign_key "identities", "users", name: "fk_bea040f377", on_delete: :cascade
|
||||
add_foreign_key "instance_moderation_notes", "accounts", on_delete: :cascade
|
||||
add_foreign_key "invites", "users", on_delete: :cascade
|
||||
add_foreign_key "list_accounts", "accounts", on_delete: :cascade
|
||||
add_foreign_key "list_accounts", "follow_requests", on_delete: :cascade
|
||||
|
|
7
spec/fabricators/instance_moderation_note_fabricator.rb
Normal file
7
spec/fabricators/instance_moderation_note_fabricator.rb
Normal file
|
@ -0,0 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
Fabricator(:instance_moderation_note) do
|
||||
domain { sequence(:domain) { |i| "#{i}#{Faker::Internet.domain_name}" } }
|
||||
account { Fabricate.build(:account) }
|
||||
content { Faker::Lorem.sentence }
|
||||
end
|
37
spec/models/instance_moderation_note_spec.rb
Normal file
37
spec/models/instance_moderation_note_spec.rb
Normal file
|
@ -0,0 +1,37 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe InstanceModerationNote do
|
||||
describe 'chronological' do
|
||||
it 'returns the instance notes sorted by oldest first' do
|
||||
instance = Instance.find_or_initialize_by(domain: TagManager.instance.normalize_domain('mastodon.example'))
|
||||
|
||||
note1 = Fabricate(:instance_moderation_note, domain: instance.domain)
|
||||
note2 = Fabricate(:instance_moderation_note, domain: instance.domain)
|
||||
|
||||
expect(instance.moderation_notes.chronological).to eq [note1, note2]
|
||||
end
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
it 'is invalid if the content is empty' do
|
||||
note = Fabricate.build(:instance_moderation_note, domain: 'mastodon.example', content: '')
|
||||
expect(note.valid?).to be false
|
||||
end
|
||||
|
||||
it 'is invalid if content is longer than character limit' do
|
||||
note = Fabricate.build(:instance_moderation_note, domain: 'mastodon.example', content: comment_over_limit)
|
||||
expect(note.valid?).to be false
|
||||
end
|
||||
|
||||
it 'is valid even if the instance does not exist yet' do
|
||||
note = Fabricate.build(:instance_moderation_note, domain: 'non-existent.example', content: 'test comment')
|
||||
expect(note.valid?).to be true
|
||||
end
|
||||
|
||||
def comment_over_limit
|
||||
Faker::Lorem.paragraph_by_chars(number: described_class::CONTENT_SIZE_LIMIT * 2)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -3,9 +3,9 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Instance do
|
||||
describe 'Scopes' do
|
||||
before { described_class.refresh }
|
||||
before { described_class.refresh }
|
||||
|
||||
describe 'Scopes' do
|
||||
describe '#searchable' do
|
||||
let(:expected_domain) { 'host.example' }
|
||||
let(:blocked_domain) { 'other.example' }
|
||||
|
|
16
spec/requests/admin/instances/moderation_notes_spec.rb
Normal file
16
spec/requests/admin/instances/moderation_notes_spec.rb
Normal file
|
@ -0,0 +1,16 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Admin Report Notes' do
|
||||
describe 'POST /admin/instance/moderation_notes' do
|
||||
before { sign_in Fabricate(:admin_user) }
|
||||
|
||||
it 'gracefully handles invalid nested params' do
|
||||
post admin_instance_moderation_notes_path(instance_id: 'mastodon.test', instance_note: 'invalid')
|
||||
|
||||
expect(response)
|
||||
.to have_http_status(400)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -4,9 +4,9 @@ require 'rails_helper'
|
|||
|
||||
RSpec.describe 'Admin Instances' do
|
||||
describe 'GET /admin/instances/:id' do
|
||||
context 'with an unknown domain' do
|
||||
before { sign_in Fabricate(:admin_user) }
|
||||
before { sign_in Fabricate(:admin_user) }
|
||||
|
||||
context 'with an unknown domain' do
|
||||
it 'returns http success' do
|
||||
get admin_instance_path(id: 'unknown.example')
|
||||
|
||||
|
@ -14,5 +14,14 @@ RSpec.describe 'Admin Instances' do
|
|||
.to have_http_status(200)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with an invalid domain' do
|
||||
it 'returns http success' do
|
||||
get admin_instance_path(id: ' ')
|
||||
|
||||
expect(response)
|
||||
.to have_http_status(200)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -35,7 +35,7 @@ RSpec.describe 'Admin::AccountModerationNotes' do
|
|||
end
|
||||
|
||||
def delete_note
|
||||
within('.report-notes__item__actions') do
|
||||
within('.report-notes__item:first-child .report-notes__item__actions') do
|
||||
click_on I18n.t('admin.reports.notes.delete')
|
||||
end
|
||||
end
|
||||
|
|
51
spec/system/admin/instance/moderation_notes_spec.rb
Normal file
51
spec/system/admin/instance/moderation_notes_spec.rb
Normal file
|
@ -0,0 +1,51 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Admin::Instances::ModerationNotesController' do
|
||||
let(:current_user) { Fabricate(:admin_user) }
|
||||
let(:instance_domain) { 'mastodon.example' }
|
||||
|
||||
before { sign_in current_user }
|
||||
|
||||
describe 'Managing instance moderation notes' do
|
||||
it 'saves and then deletes a record' do
|
||||
visit admin_instance_path(instance_domain)
|
||||
|
||||
fill_in 'instance_moderation_note_content', with: ''
|
||||
expect { submit_form }
|
||||
.to not_change(InstanceModerationNote, :count)
|
||||
expect(page)
|
||||
.to have_content(/error below/)
|
||||
|
||||
fill_in 'instance_moderation_note_content', with: 'Test message ' * InstanceModerationNote::CONTENT_SIZE_LIMIT
|
||||
expect { submit_form }
|
||||
.to not_change(InstanceModerationNote, :count)
|
||||
expect(page)
|
||||
.to have_content(/error below/)
|
||||
|
||||
fill_in 'instance_moderation_note_content', with: 'Test message'
|
||||
expect { submit_form }
|
||||
.to change(InstanceModerationNote, :count).by(1)
|
||||
expect(page)
|
||||
.to have_current_path(admin_instance_path(instance_domain))
|
||||
expect(page)
|
||||
.to have_content(I18n.t('admin.instances.moderation_notes.created_msg'))
|
||||
|
||||
expect { delete_note }
|
||||
.to change(InstanceModerationNote, :count).by(-1)
|
||||
expect(page)
|
||||
.to have_content(I18n.t('admin.instances.moderation_notes.destroyed_msg'))
|
||||
end
|
||||
|
||||
def submit_form
|
||||
click_on I18n.t('admin.instances.moderation_notes.create')
|
||||
end
|
||||
|
||||
def delete_note
|
||||
within('.report-notes__item:first-child .report-notes__item__actions') do
|
||||
click_on I18n.t('admin.reports.notes.delete')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in New Issue
Block a user