From 1cae543e8ff4c0df3d25eed483af818bfe6428d4 Mon Sep 17 00:00:00 2001 From: David Roetzel Date: Wed, 22 Apr 2026 14:34:08 +0200 Subject: [PATCH] Add per-user maximum number of collections (#38769) --- app/models/collection.rb | 8 ++++ app/models/user_role.rb | 20 +++++---- app/serializers/rest/role_serializer.rb | 2 + app/views/admin/roles/_form.html.haml | 8 ++++ config/locales/simple_form.en.yml | 2 + ...4030_add_collection_limit_to_user_roles.rb | 7 +++ db/schema.rb | 3 +- spec/models/collection_spec.rb | 14 ++++++ spec/models/user_role_spec.rb | 6 +++ spec/serializers/rest/role_serializer_spec.rb | 43 +++++++++++++++++++ 10 files changed, 103 insertions(+), 10 deletions(-) create mode 100644 db/migrate/20260420124030_add_collection_limit_to_user_roles.rb create mode 100644 spec/serializers/rest/role_serializer_spec.rb diff --git a/app/models/collection.rb b/app/models/collection.rb index 39680721d2..c5082269e0 100644 --- a/app/models/collection.rb +++ b/app/models/collection.rb @@ -53,6 +53,7 @@ class Collection < ApplicationRecord validates :language, language: { if: :local?, allow_nil: true } validate :tag_is_usable 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_tag, -> { includes(:tag) } @@ -105,4 +106,11 @@ class Collection < ApplicationRecord def items_do_not_exceed_limit errors.add(:collection_items, :too_many, count: MAX_ITEMS) if pending_or_accepted_items.size > MAX_ITEMS 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 diff --git a/app/models/user_role.rb b/app/models/user_role.rb index e98d9bc479..32e67a0dc2 100644 --- a/app/models/user_role.rb +++ b/app/models/user_role.rb @@ -4,15 +4,16 @@ # # Table name: user_roles # -# id :bigint(8) not null, primary key -# color :string default(""), not null -# highlighted :boolean default(FALSE), not null -# name :string default(""), not null -# permissions :bigint(8) default(0), not null -# position :integer default(0), not null -# require_2fa :boolean default(FALSE), not null -# created_at :datetime not null -# updated_at :datetime not null +# id :bigint(8) not null, primary key +# collection_limit :integer default(10), not null +# color :string default(""), not null +# highlighted :boolean default(FALSE), not null +# name :string default(""), not null +# permissions :bigint(8) default(0), not null +# position :integer default(0), not null +# require_2fa :boolean default(FALSE), not null +# created_at :datetime not null +# updated_at :datetime not null # class UserRole < ApplicationRecord @@ -104,6 +105,7 @@ class UserRole < ApplicationRecord validates :name, presence: true, unless: :everyone? validates :color, format: { with: CSS_COLORS }, if: :color? 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_position_elevation diff --git a/app/serializers/rest/role_serializer.rb b/app/serializers/rest/role_serializer.rb index 5b81c6e048..a3d8af64b2 100644 --- a/app/serializers/rest/role_serializer.rb +++ b/app/serializers/rest/role_serializer.rb @@ -3,6 +3,8 @@ class REST::RoleSerializer < ActiveModel::Serializer attributes :id, :name, :permissions, :color, :highlighted + attribute :collection_limit, if: -> { Mastodon::Feature.collections_enabled? } + def id object.id.to_s end diff --git a/app/views/admin/roles/_form.html.haml b/app/views/admin/roles/_form.html.haml index e447739247..0d24e82d3c 100644 --- a/app/views/admin/roles/_form.html.haml +++ b/app/views/admin/roles/_form.html.haml @@ -32,6 +32,14 @@ %hr.spacer/ +- if Mastodon::Feature.collections_enabled? + + .fields-group + = form.input :collection_limit, + wrapper: :with_label + + %hr.spacer/ + - unless current_user.role == form.object .field-group diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index 72f6548532..249c8202d1 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -160,6 +160,7 @@ en: 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. 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 highlighted: This makes the role publicly visible name: Public name of the role, if role is set to be displayed as a badge @@ -386,6 +387,7 @@ en: role: Role time_zone: Time zone user_role: + collection_limit: Maximum number of Collections per user color: Badge color highlighted: Display role as badge on user profiles name: Name diff --git a/db/migrate/20260420124030_add_collection_limit_to_user_roles.rb b/db/migrate/20260420124030_add_collection_limit_to_user_roles.rb new file mode 100644 index 0000000000..659fd4b0f9 --- /dev/null +++ b/db/migrate/20260420124030_add_collection_limit_to_user_roles.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 677ed7caa9..8523182b5e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # 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 enable_extension "pg_catalog.plpgsql" @@ -1337,6 +1337,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_15_133505) do end create_table "user_roles", force: :cascade do |t| + t.integer "collection_limit", default: 10, null: false t.string "color", default: "", null: false t.datetime "created_at", null: false t.boolean "highlighted", default: false, null: false diff --git a/spec/models/collection_spec.rb b/spec/models/collection_spec.rb index df3a5eca41..b9a5453642 100644 --- a/spec/models/collection_spec.rb +++ b/spec/models/collection_spec.rb @@ -71,6 +71,20 @@ RSpec.describe Collection do it { is_expected.to be_valid } 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 describe '#item_for' do diff --git a/spec/models/user_role_spec.rb b/spec/models/user_role_spec.rb index 547fc718cc..ad2b0864c9 100644 --- a/spec/models/user_role_spec.rb +++ b/spec/models/user_role_spec.rb @@ -31,6 +31,12 @@ RSpec.describe UserRole do it { is_expected.to_not allow_values('x', '112233445566', '#xxyyzz').for(:color) } 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 subject { Fabricate :user_role } diff --git a/spec/serializers/rest/role_serializer_spec.rb b/spec/serializers/rest/role_serializer_spec.rb new file mode 100644 index 0000000000..5b38047587 --- /dev/null +++ b/spec/serializers/rest/role_serializer_spec.rb @@ -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