Add per-user maximum number of collections (#38769)

This commit is contained in:
David Roetzel 2026-04-22 14:34:08 +02:00 committed by GitHub
parent bc09d3c5f2
commit 1cae543e8f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 103 additions and 10 deletions

View File

@ -53,6 +53,7 @@ class Collection < ApplicationRecord
validates :language, language: { if: :local?, allow_nil: true } validates :language, language: { if: :local?, allow_nil: true }
validate :tag_is_usable validate :tag_is_usable
validate :items_do_not_exceed_limit validate :items_do_not_exceed_limit
validate :user_does_not_exceed_limit, on: :create
scope :with_items, -> { includes(:collection_items).merge(CollectionItem.with_accounts) } scope :with_items, -> { includes(:collection_items).merge(CollectionItem.with_accounts) }
scope :with_tag, -> { includes(:tag) } scope :with_tag, -> { includes(:tag) }
@ -105,4 +106,11 @@ class Collection < ApplicationRecord
def items_do_not_exceed_limit def items_do_not_exceed_limit
errors.add(:collection_items, :too_many, count: MAX_ITEMS) if pending_or_accepted_items.size > MAX_ITEMS errors.add(:collection_items, :too_many, count: MAX_ITEMS) if pending_or_accepted_items.size > MAX_ITEMS
end end
def user_does_not_exceed_limit
return unless local?
limit = account.user.role.collection_limit
errors.add(:base, :too_many, count: limit) if account.collections.count >= limit
end
end end

View File

@ -4,15 +4,16 @@
# #
# Table name: user_roles # Table name: user_roles
# #
# id :bigint(8) not null, primary key # id :bigint(8) not null, primary key
# color :string default(""), not null # collection_limit :integer default(10), not null
# highlighted :boolean default(FALSE), not null # color :string default(""), not null
# name :string default(""), not null # highlighted :boolean default(FALSE), not null
# permissions :bigint(8) default(0), not null # name :string default(""), not null
# position :integer default(0), not null # permissions :bigint(8) default(0), not null
# require_2fa :boolean default(FALSE), not null # position :integer default(0), not null
# created_at :datetime not null # require_2fa :boolean default(FALSE), not null
# updated_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null
# #
class UserRole < ApplicationRecord class UserRole < ApplicationRecord
@ -104,6 +105,7 @@ class UserRole < ApplicationRecord
validates :name, presence: true, unless: :everyone? validates :name, presence: true, unless: :everyone?
validates :color, format: { with: CSS_COLORS }, if: :color? validates :color, format: { with: CSS_COLORS }, if: :color?
validates :position, numericality: { in: (-POSITION_LIMIT..POSITION_LIMIT) } validates :position, numericality: { in: (-POSITION_LIMIT..POSITION_LIMIT) }
validates :collection_limit, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validate :validate_permissions_elevation validate :validate_permissions_elevation
validate :validate_position_elevation validate :validate_position_elevation

View File

@ -3,6 +3,8 @@
class REST::RoleSerializer < ActiveModel::Serializer class REST::RoleSerializer < ActiveModel::Serializer
attributes :id, :name, :permissions, :color, :highlighted attributes :id, :name, :permissions, :color, :highlighted
attribute :collection_limit, if: -> { Mastodon::Feature.collections_enabled? }
def id def id
object.id.to_s object.id.to_s
end end

View File

@ -32,6 +32,14 @@
%hr.spacer/ %hr.spacer/
- if Mastodon::Feature.collections_enabled?
.fields-group
= form.input :collection_limit,
wrapper: :with_label
%hr.spacer/
- unless current_user.role == form.object - unless current_user.role == form.object
.field-group .field-group

View File

@ -160,6 +160,7 @@ en:
other: We have to make sure you're at least %{count} to use %{domain}. We won't store this. other: We have to make sure you're at least %{count} to use %{domain}. We won't store this.
role: The role controls which permissions the user has. role: The role controls which permissions the user has.
user_role: user_role:
collection_limit: Limits the number of Collections that a single user with this role can create. Please note that when you decrease this number, users who are already at this limit will not lose any Collections. But they will not be able to create additional ones.
color: Color to be used for the role throughout the UI, as RGB in hex format color: Color to be used for the role throughout the UI, as RGB in hex format
highlighted: This makes the role publicly visible highlighted: This makes the role publicly visible
name: Public name of the role, if role is set to be displayed as a badge name: Public name of the role, if role is set to be displayed as a badge
@ -386,6 +387,7 @@ en:
role: Role role: Role
time_zone: Time zone time_zone: Time zone
user_role: user_role:
collection_limit: Maximum number of Collections per user
color: Badge color color: Badge color
highlighted: Display role as badge on user profiles highlighted: Display role as badge on user profiles
name: Name name: Name

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddCollectionLimitToUserRoles < ActiveRecord::Migration[8.1]
def change
add_column :user_roles, :collection_limit, :integer, null: false, default: 10
end
end

View File

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_04_15_133505) do ActiveRecord::Schema[8.1].define(version: 2026_04_20_124030) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql" enable_extension "pg_catalog.plpgsql"
@ -1337,6 +1337,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_15_133505) do
end end
create_table "user_roles", force: :cascade do |t| create_table "user_roles", force: :cascade do |t|
t.integer "collection_limit", default: 10, null: false
t.string "color", default: "", null: false t.string "color", default: "", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.boolean "highlighted", default: false, null: false t.boolean "highlighted", default: false, null: false

View File

@ -71,6 +71,20 @@ RSpec.describe Collection do
it { is_expected.to be_valid } it { is_expected.to be_valid }
end end
end end
context 'when the user is already at the per-user limit of collections' do
subject { Fabricate.build(:collection, account:) }
let(:role) { Fabricate(:user_role, collection_limit: 2) }
let(:user) { Fabricate(:user, role:) }
let(:account) { user.account }
before do
Fabricate.times(2, :collection, account:)
end
it { is_expected.to_not be_valid }
end
end end
describe '#item_for' do describe '#item_for' do

View File

@ -31,6 +31,12 @@ RSpec.describe UserRole do
it { is_expected.to_not allow_values('x', '112233445566', '#xxyyzz').for(:color) } it { is_expected.to_not allow_values('x', '112233445566', '#xxyyzz').for(:color) }
end end
describe 'collection_limit' do
subject { Fabricate.build :user_role }
it { is_expected.to validate_numericality_of(:collection_limit).only_integer.is_greater_than_or_equal_to(0) }
end
context 'when current_account is set' do context 'when current_account is set' do
subject { Fabricate :user_role } subject { Fabricate :user_role }

View File

@ -0,0 +1,43 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe REST::RoleSerializer do
subject { serialized_record_json(role, described_class) }
let(:everyone) do
Fabricate.build(:user_role, permissions: 0)
end
let(:role) do
Fabricate.build(:user_role, id: 2342, name: 'test role', color: '#ABC', highlighted: true, permissions: 2300, collection_limit: 11)
end
before do
allow(UserRole).to receive(:everyone).and_return(everyone)
end
it 'includes the relevant attributes' do
expect(subject)
.to include({
'id' => '2342',
'name' => 'test role',
'color' => '#ABC',
'highlighted' => true,
'permissions' => '2300',
})
end
context 'when collections are enabled', feature: :collections do
it 'includes the relevant attributes' do
expect(subject)
.to include({
'id' => '2342',
'name' => 'test role',
'color' => '#ABC',
'highlighted' => true,
'permissions' => '2300',
'collection_limit' => 11,
})
end
end
end