From 7ffa5fa0c4e0e3de712b2d28c48a904cfec96336 Mon Sep 17 00:00:00 2001 From: David Roetzel Date: Fri, 21 Nov 2025 11:28:23 +0100 Subject: [PATCH] Add models to represent "Collections" (#36977) --- app/models/collection.rb | 53 +++++++++++++++++++ app/models/collection_item.rb | 40 ++++++++++++++ app/models/concerns/account/associations.rb | 2 + config/locales/activerecord.en.yml | 6 +++ .../20251118115657_create_collections.rb | 19 +++++++ .../20251119093332_create_collection_items.rb | 18 +++++++ db/schema.rb | 39 +++++++++++++- spec/fabricators/account_fabricator.rb | 4 ++ spec/fabricators/collection_fabricator.rb | 10 ++++ .../fabricators/collection_item_fabricator.rb | 15 ++++++ spec/models/collection_item_spec.rb | 41 ++++++++++++++ spec/models/collection_spec.rb | 45 ++++++++++++++++ 12 files changed, 291 insertions(+), 1 deletion(-) create mode 100644 app/models/collection.rb create mode 100644 app/models/collection_item.rb create mode 100644 db/migrate/20251118115657_create_collections.rb create mode 100644 db/migrate/20251119093332_create_collection_items.rb create mode 100644 spec/fabricators/collection_fabricator.rb create mode 100644 spec/fabricators/collection_item_fabricator.rb create mode 100644 spec/models/collection_item_spec.rb create mode 100644 spec/models/collection_spec.rb diff --git a/app/models/collection.rb b/app/models/collection.rb new file mode 100644 index 0000000000..320933ea60 --- /dev/null +++ b/app/models/collection.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: collections +# +# id :bigint(8) not null, primary key +# description :text not null +# discoverable :boolean not null +# local :boolean not null +# name :string not null +# original_number_of_items :integer +# sensitive :boolean not null +# uri :string +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint(8) not null +# tag_id :bigint(8) +# +class Collection < ApplicationRecord + MAX_ITEMS = 25 + + belongs_to :account + belongs_to :tag, optional: true + + has_many :collection_items, dependent: :delete_all + + validates :name, presence: true + validates :description, presence: true + validates :uri, presence: true, if: :remote? + validates :original_number_of_items, + presence: true, + numericality: { greater_than_or_equal: 0 }, + if: :remote? + validate :tag_is_usable + validate :items_do_not_exceed_limit + + def remote? + !local? + end + + private + + def tag_is_usable + return if tag.blank? + + errors.add(:tag, :unusable) unless tag.usable? + end + + def items_do_not_exceed_limit + errors.add(:collection_items, :too_many, count: MAX_ITEMS) if collection_items.size > MAX_ITEMS + end +end diff --git a/app/models/collection_item.rb b/app/models/collection_item.rb new file mode 100644 index 0000000000..0ea50e6914 --- /dev/null +++ b/app/models/collection_item.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: collection_items +# +# id :bigint(8) not null, primary key +# activity_uri :string +# approval_last_verified_at :datetime +# approval_uri :string +# object_uri :string +# position :integer default(1), not null +# state :integer default("pending"), not null +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint(8) +# collection_id :bigint(8) not null +# +class CollectionItem < ApplicationRecord + belongs_to :collection + belongs_to :account, optional: true + + enum :state, + { pending: 0, accepted: 1, rejected: 2, revoked: 3 }, + validate: true + + delegate :local?, :remote?, to: :collection + + validates :position, numericality: { only_integer: true, greater_than: 0 } + validates :activity_uri, presence: true, if: :local_item_with_remote_account? + validates :approval_uri, absence: true, unless: :local? + validates :account, presence: true, if: :accepted? + validates :object_uri, presence: true, if: -> { account.nil? } + + scope :ordered, -> { order(position: :asc) } + + def local_item_with_remote_account? + local? && account&.remote? + end +end diff --git a/app/models/concerns/account/associations.rb b/app/models/concerns/account/associations.rb index 62c55da5de..e1684d2560 100644 --- a/app/models/concerns/account/associations.rb +++ b/app/models/concerns/account/associations.rb @@ -13,6 +13,8 @@ module Account::Associations has_many :account_warnings has_many :aliases, class_name: 'AccountAlias' has_many :bookmarks + has_many :collections + has_many :collection_items has_many :conversations, class_name: 'AccountConversation' has_many :custom_filters has_many :favourites diff --git a/config/locales/activerecord.en.yml b/config/locales/activerecord.en.yml index 6940d589ca..fb5ccce89e 100644 --- a/config/locales/activerecord.en.yml +++ b/config/locales/activerecord.en.yml @@ -32,6 +32,12 @@ en: attributes: url: invalid: is not a valid URL + collection: + attributes: + collection_items: + too_many: are too many, no more than %{count} are allowed + tag: + unusable: may not be used doorkeeper/application: attributes: website: diff --git a/db/migrate/20251118115657_create_collections.rb b/db/migrate/20251118115657_create_collections.rb new file mode 100644 index 0000000000..299cc7aade --- /dev/null +++ b/db/migrate/20251118115657_create_collections.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class CreateCollections < ActiveRecord::Migration[8.0] + def change + create_table :collections do |t| + t.references :account, null: false, foreign_key: true + t.string :name, null: false + t.text :description, null: false + t.string :uri + t.boolean :local, null: false # rubocop:disable Rails/ThreeStateBooleanColumn + t.boolean :sensitive, null: false # rubocop:disable Rails/ThreeStateBooleanColumn + t.boolean :discoverable, null: false # rubocop:disable Rails/ThreeStateBooleanColumn + t.references :tag, foreign_key: true + t.integer :original_number_of_items + + t.timestamps + end + end +end diff --git a/db/migrate/20251119093332_create_collection_items.rb b/db/migrate/20251119093332_create_collection_items.rb new file mode 100644 index 0000000000..9fc5d99df5 --- /dev/null +++ b/db/migrate/20251119093332_create_collection_items.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class CreateCollectionItems < ActiveRecord::Migration[8.0] + def change + create_table :collection_items do |t| + t.references :collection, null: false, foreign_key: { on_delete: :cascade } + t.references :account, foreign_key: true + t.integer :position, null: false, default: 1 + t.string :object_uri, index: { unique: true, where: 'activity_uri IS NOT NULL' } + t.string :approval_uri, index: { unique: true, where: 'approval_uri IS NOT NULL' } + t.string :activity_uri + t.datetime :approval_last_verified_at + t.integer :state, null: false, default: 0 + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 7bdd6c0ce4..b836267291 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.0].define(version: 2025_10_23_210145) do +ActiveRecord::Schema[8.0].define(version: 2025_11_19_093332) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -351,6 +351,39 @@ ActiveRecord::Schema[8.0].define(version: 2025_10_23_210145) do t.index ["reference_account_id"], name: "index_canonical_email_blocks_on_reference_account_id" end + create_table "collection_items", force: :cascade do |t| + t.bigint "collection_id", null: false + t.bigint "account_id" + t.integer "position", default: 1, null: false + t.string "object_uri" + t.string "approval_uri" + t.string "activity_uri" + t.datetime "approval_last_verified_at" + t.integer "state", default: 0, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_collection_items_on_account_id" + t.index ["approval_uri"], name: "index_collection_items_on_approval_uri", unique: true, where: "(approval_uri IS NOT NULL)" + t.index ["collection_id"], name: "index_collection_items_on_collection_id" + t.index ["object_uri"], name: "index_collection_items_on_object_uri", unique: true, where: "(activity_uri IS NOT NULL)" + end + + create_table "collections", force: :cascade do |t| + t.bigint "account_id", null: false + t.string "name", null: false + t.text "description", null: false + t.string "uri" + t.boolean "local", null: false + t.boolean "sensitive", null: false + t.boolean "discoverable", null: false + t.bigint "tag_id" + t.integer "original_number_of_items" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_collections_on_account_id" + t.index ["tag_id"], name: "index_collections_on_tag_id" + end + create_table "conversation_mutes", force: :cascade do |t| t.bigint "conversation_id", null: false t.bigint "account_id", null: false @@ -1386,6 +1419,10 @@ ActiveRecord::Schema[8.0].define(version: 2025_10_23_210145) do add_foreign_key "bulk_import_rows", "bulk_imports", on_delete: :cascade add_foreign_key "bulk_imports", "accounts", on_delete: :cascade add_foreign_key "canonical_email_blocks", "accounts", column: "reference_account_id", on_delete: :cascade + add_foreign_key "collection_items", "accounts" + add_foreign_key "collection_items", "collections", on_delete: :cascade + add_foreign_key "collections", "accounts" + add_foreign_key "collections", "tags" add_foreign_key "conversation_mutes", "accounts", name: "fk_225b4212bb", on_delete: :cascade add_foreign_key "conversation_mutes", "conversations", on_delete: :cascade add_foreign_key "custom_filter_keywords", "custom_filters", on_delete: :cascade diff --git a/spec/fabricators/account_fabricator.rb b/spec/fabricators/account_fabricator.rb index 6ec89a1cb6..bce8803be7 100644 --- a/spec/fabricators/account_fabricator.rb +++ b/spec/fabricators/account_fabricator.rb @@ -17,3 +17,7 @@ Fabricator(:account) do discoverable true indexable true end + +Fabricator(:remote_account, from: :account) do + domain 'example.com' +end diff --git a/spec/fabricators/collection_fabricator.rb b/spec/fabricators/collection_fabricator.rb new file mode 100644 index 0000000000..a6a8411ba0 --- /dev/null +++ b/spec/fabricators/collection_fabricator.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +Fabricator(:collection) do + account { Fabricate.build(:account) } + name { sequence(:name) { |i| "Collection ##{i}" } } + description 'People to follow' + local true + sensitive false + discoverable true +end diff --git a/spec/fabricators/collection_item_fabricator.rb b/spec/fabricators/collection_item_fabricator.rb new file mode 100644 index 0000000000..011f9ba5b5 --- /dev/null +++ b/spec/fabricators/collection_item_fabricator.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +Fabricator(:collection_item) do + collection { Fabricate.build(:collection) } + account { Fabricate.build(:account) } + position 1 + state :accepted +end + +Fabricator(:unverified_remote_collection_item, from: :collection_item) do + account nil + state :pending + object_uri { Fabricate.build(:remote_account).uri } + approval_uri { sequence(:uri) { |i| "https://example.com/authorizations/#{i}" } } +end diff --git a/spec/models/collection_item_spec.rb b/spec/models/collection_item_spec.rb new file mode 100644 index 0000000000..39464b7a34 --- /dev/null +++ b/spec/models/collection_item_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe CollectionItem do + describe 'Validations' do + subject { Fabricate.build(:collection_item) } + + it { is_expected.to define_enum_for(:state) } + + it { is_expected.to validate_numericality_of(:position).only_integer.is_greater_than(0) } + + context 'when account inclusion is accepted' do + subject { Fabricate.build(:collection_item, state: :accepted) } + + it { is_expected.to validate_presence_of(:account) } + end + + context 'when item is local and account is remote' do + subject { Fabricate.build(:collection_item, account: remote_account) } + + let(:remote_account) { Fabricate.build(:remote_account) } + + it { is_expected.to validate_presence_of(:activity_uri) } + end + + context 'when item is not local' do + subject { Fabricate.build(:collection_item, collection: remote_collection) } + + let(:remote_collection) { Fabricate.build(:collection, local: false) } + + it { is_expected.to validate_absence_of(:approval_uri) } + end + + context 'when account is not present' do + subject { Fabricate.build(:unverified_remote_collection_item) } + + it { is_expected.to validate_presence_of(:object_uri) } + end + end +end diff --git a/spec/models/collection_spec.rb b/spec/models/collection_spec.rb new file mode 100644 index 0000000000..c6a500210f --- /dev/null +++ b/spec/models/collection_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Collection do + describe 'Validations' do + subject { Fabricate.build :collection } + + it { is_expected.to validate_presence_of(:name) } + + it { is_expected.to validate_presence_of(:description) } + + context 'when collection is remote' do + subject { Fabricate.build :collection, local: false } + + it { is_expected.to validate_presence_of(:uri) } + + it { is_expected.to validate_presence_of(:original_number_of_items) } + end + + context 'when using a hashtag as category' do + subject { Fabricate.build(:collection, tag:) } + + context 'when hashtag is usable' do + let(:tag) { Fabricate.build(:tag) } + + it { is_expected.to be_valid } + end + + context 'when hashtag is not usable' do + let(:tag) { Fabricate.build(:tag, usable: false) } + + it { is_expected.to_not be_valid } + end + end + + context 'when there are more items than allowed' do + subject { Fabricate.build(:collection, collection_items:) } + + let(:collection_items) { Fabricate.build_times(described_class::MAX_ITEMS + 1, :collection_item, collection: nil) } + + it { is_expected.to_not be_valid } + end + end +end