Implement basic tagging functionality for places, allowing users to categorize and label places with custom tags.

This commit is contained in:
Eugene Burmakin
2025-11-16 15:01:54 +01:00
parent 284f763be4
commit 69c8779164
14 changed files with 236 additions and 5 deletions

View File

@@ -0,0 +1,35 @@
# frozen_string_literal: true
module Taggable
extend ActiveSupport::Concern
included do
has_many :taggings, as: :taggable, dependent: :destroy
has_many :tags, through: :taggings
scope :with_tags, ->(tag_ids) { joins(:taggings).where(taggings: { tag_id: tag_ids }).distinct }
scope :tagged_with, ->(tag_name, user) {
joins(:tags).where(tags: { name: tag_name, user: user }).distinct
}
end
# Add a tag to this taggable record
def add_tag(tag)
tags << tag unless tags.include?(tag)
end
# Remove a tag from this taggable record
def remove_tag(tag)
tags.delete(tag)
end
# Get all tag names for this taggable record
def tag_names
tags.pluck(:name)
end
# Check if tagged with specific tag
def tagged_with?(tag)
tags.include?(tag)
end
end

View File

@@ -3,17 +3,22 @@
class Place < ApplicationRecord
include Nearable
include Distanceable
include Taggable
DEFAULT_NAME = 'Suggested place'
validates :name, :lonlat, presence: true
belongs_to :user, optional: true # Optional during migration period
has_many :visits, dependent: :destroy
has_many :place_visits, dependent: :destroy
has_many :suggested_visits, -> { distinct }, through: :place_visits, source: :visit
validates :name, :lonlat, presence: true
enum :source, { manual: 0, photon: 1 }
scope :for_user, ->(user) { where(user: user) }
scope :ordered, -> { order(:name) }
def lon
lonlat.x
end

14
app/models/tag.rb Normal file
View File

@@ -0,0 +1,14 @@
# frozen_string_literal: true
class Tag < ApplicationRecord
belongs_to :user
has_many :taggings, dependent: :destroy
has_many :places, through: :taggings, source: :taggable, source_type: 'Place'
validates :name, presence: true, uniqueness: { scope: :user_id }
validates :user, presence: true
validates :color, format: { with: /\A#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})\z/, allow_blank: true }
scope :for_user, ->(user) { where(user: user) }
scope :ordered, -> { order(:name) }
end

10
app/models/tagging.rb Normal file
View File

@@ -0,0 +1,10 @@
# frozen_string_literal: true
class Tagging < ApplicationRecord
belongs_to :taggable, polymorphic: true
belongs_to :tag
validates :taggable, presence: true
validates :tag, presence: true
validates :tag_id, uniqueness: { scope: [:taggable_type, :taggable_id] }
end

View File

@@ -15,7 +15,9 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
has_many :notifications, dependent: :destroy
has_many :areas, dependent: :destroy
has_many :visits, dependent: :destroy
has_many :places, through: :visits
has_many :visited_places, through: :visits, source: :place
has_many :places, dependent: :destroy
has_many :tags, dependent: :destroy
has_many :trips, dependent: :destroy
has_many :tracks, dependent: :destroy

View File

@@ -0,0 +1,6 @@
class AddUserIdToPlaces < ActiveRecord::Migration[8.0]
def change
# Add nullable for backward compatibility, will enforce later via data migration
add_reference :places, :user, null: true, foreign_key: true, index: true
end
end

View File

@@ -0,0 +1,14 @@
class CreateTags < ActiveRecord::Migration[8.0]
def change
create_table :tags do |t|
t.string :name, null: false
t.string :icon
t.string :color
t.references :user, null: false, foreign_key: true, index: true
t.timestamps
end
add_index :tags, [:user_id, :name], unique: true
end
end

View File

@@ -0,0 +1,12 @@
class CreateTaggings < ActiveRecord::Migration[8.0]
def change
create_table :taggings do |t|
t.references :taggable, polymorphic: true, null: false, index: true
t.references :tag, null: false, foreign_key: true, index: true
t.timestamps
end
add_index :taggings, [:taggable_type, :taggable_id, :tag_id], unique: true, name: 'index_taggings_on_taggable_and_tag'
end
end

29
db/schema.rb generated
View File

@@ -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_30_190924) do
ActiveRecord::Schema[8.0].define(version: 2025_11_16_134520) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
enable_extension "postgis"
@@ -180,8 +180,10 @@ ActiveRecord::Schema[8.0].define(version: 2025_10_30_190924) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.geography "lonlat", limit: {srid: 4326, type: "st_point", geographic: true}
t.bigint "user_id"
t.index "(((geodata -> 'properties'::text) ->> 'osm_id'::text))", name: "index_places_on_geodata_osm_id"
t.index ["lonlat"], name: "index_places_on_lonlat", using: :gist
t.index ["user_id"], name: "index_places_on_user_id"
end
create_table "points", force: :cascade do |t|
@@ -265,6 +267,28 @@ ActiveRecord::Schema[8.0].define(version: 2025_10_30_190924) do
t.index ["year"], name: "index_stats_on_year"
end
create_table "taggings", force: :cascade do |t|
t.string "taggable_type", null: false
t.bigint "taggable_id", null: false
t.bigint "tag_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["tag_id"], name: "index_taggings_on_tag_id"
t.index ["taggable_type", "taggable_id", "tag_id"], name: "index_taggings_on_taggable_and_tag", unique: true
t.index ["taggable_type", "taggable_id"], name: "index_taggings_on_taggable"
end
create_table "tags", force: :cascade do |t|
t.string "name", null: false
t.string "icon"
t.string "color"
t.bigint "user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["user_id", "name"], name: "index_tags_on_user_id_and_name", unique: true
t.index ["user_id"], name: "index_tags_on_user_id"
end
create_table "tracks", force: :cascade do |t|
t.datetime "start_at", null: false
t.datetime "end_at", null: false
@@ -359,9 +383,12 @@ ActiveRecord::Schema[8.0].define(version: 2025_10_30_190924) do
add_foreign_key "notifications", "users"
add_foreign_key "place_visits", "places"
add_foreign_key "place_visits", "visits"
add_foreign_key "places", "users"
add_foreign_key "points", "users"
add_foreign_key "points", "visits"
add_foreign_key "stats", "users"
add_foreign_key "taggings", "tags"
add_foreign_key "tags", "users"
add_foreign_key "tracks", "users"
add_foreign_key "trips", "users"
add_foreign_key "visits", "areas"

View File

@@ -2,10 +2,11 @@
FactoryBot.define do
factory :place do
name { 'MyString' }
sequence(:name) { |n| "Place #{n}" }
latitude { 54.2905245 }
longitude { 13.0948638 }
lonlat { "SRID=4326;POINT(#{longitude} #{latitude})" }
association :user
trait :with_geodata do
geodata do

View File

@@ -0,0 +1,8 @@
# frozen_string_literal: true
FactoryBot.define do
factory :tagging do
association :taggable, factory: :place
association :tag
end
end

36
spec/factories/tags.rb Normal file
View File

@@ -0,0 +1,36 @@
# frozen_string_literal: true
FactoryBot.define do
factory :tag do
sequence(:name) { |n| "Tag #{n}" }
icon { %w[📍 🏠 🏢 🍴 ☕ 🏨 🎭 🏛️ 🌳 ⛰️].sample }
color { "##{SecureRandom.hex(3)}" }
association :user
trait :home do
name { 'Home' }
icon { '🏠' }
color { '#4CAF50' }
end
trait :work do
name { 'Work' }
icon { '🏢' }
color { '#2196F3' }
end
trait :restaurant do
name { 'Restaurant' }
icon { '🍴' }
color { '#FF9800' }
end
trait :without_color do
color { nil }
end
trait :without_icon do
icon { nil }
end
end
end

37
spec/models/tag_spec.rb Normal file
View File

@@ -0,0 +1,37 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Tag, type: :model do
it { is_expected.to belong_to(:user) }
it { is_expected.to have_many(:taggings).dependent(:destroy) }
it { is_expected.to have_many(:places).through(:taggings) }
it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_presence_of(:user) }
describe 'validations' do
subject { create(:tag) }
it { is_expected.to validate_uniqueness_of(:name).scoped_to(:user_id) }
it 'validates hex color' do
expect(build(:tag, color: '#FF5733')).to be_valid
expect(build(:tag, color: 'invalid')).not_to be_valid
expect(build(:tag, color: nil)).to be_valid
end
end
describe 'scopes' do
let!(:tag1) { create(:tag, name: 'A') }
let!(:tag2) { create(:tag, name: 'B', user: tag1.user) }
it '.for_user' do
expect(Tag.for_user(tag1.user)).to contain_exactly(tag1, tag2)
end
it '.ordered' do
expect(Tag.for_user(tag1.user).ordered).to eq([tag1, tag2])
end
end
end

View File

@@ -0,0 +1,24 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Tagging, type: :model do
it { is_expected.to belong_to(:taggable) }
it { is_expected.to belong_to(:tag) }
it { is_expected.to validate_presence_of(:taggable) }
it { is_expected.to validate_presence_of(:tag) }
describe 'uniqueness' do
subject { create(:tagging) }
it { is_expected.to validate_uniqueness_of(:tag_id).scoped_to([:taggable_type, :taggable_id]) }
end
it 'prevents duplicate taggings' do
tagging = create(:tagging)
duplicate = build(:tagging, taggable: tagging.taggable, tag: tagging.tag)
expect(duplicate).not_to be_valid
end
end