mirror of
https://github.com/Freika/dawarich.git
synced 2025-12-16 18:26:09 -06:00
Implement basic tagging functionality for places, allowing users to categorize and label places with custom tags.
This commit is contained in:
35
app/models/concerns/taggable.rb
Normal file
35
app/models/concerns/taggable.rb
Normal 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
|
||||
@@ -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
14
app/models/tag.rb
Normal 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
10
app/models/tagging.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
6
db/migrate/20251116134506_add_user_id_to_places.rb
Normal file
6
db/migrate/20251116134506_add_user_id_to_places.rb
Normal 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
|
||||
14
db/migrate/20251116134514_create_tags.rb
Normal file
14
db/migrate/20251116134514_create_tags.rb
Normal 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
|
||||
12
db/migrate/20251116134520_create_taggings.rb
Normal file
12
db/migrate/20251116134520_create_taggings.rb
Normal 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
29
db/schema.rb
generated
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
8
spec/factories/taggings.rb
Normal file
8
spec/factories/taggings.rb
Normal 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
36
spec/factories/tags.rb
Normal 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
37
spec/models/tag_spec.rb
Normal 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
|
||||
24
spec/models/tagging_spec.rb
Normal file
24
spec/models/tagging_spec.rb
Normal 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
|
||||
Reference in New Issue
Block a user