Define model and endpoints to store affinity between group pairs
This commit is contained in:
parent
3b2f52da9b
commit
6c6ae62e5a
36
app/controllers/affinities_controller.rb
Normal file
36
app/controllers/affinities_controller.rb
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# Copyright (C) 2024 Manuel Bustillo
|
||||||
|
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AffinitiesController < ApplicationController
|
||||||
|
before_action :set_group
|
||||||
|
|
||||||
|
def index
|
||||||
|
overridden_affinities = @group.affinities
|
||||||
|
.each_with_object({}) { |affinity, acc| acc[affinity.another_group(@group).id] = affinity.discomfort }
|
||||||
|
Group.pluck(:id).index_with { |group_id| overridden_affinities[group_id] || GroupAffinity::NEUTRAL }
|
||||||
|
.then { |affinities| render json: affinities }
|
||||||
|
end
|
||||||
|
|
||||||
|
def bulk_update
|
||||||
|
params.expect(affinities: [[:group_id, :discomfort]]).map(&:to_h).map do |affinity|
|
||||||
|
{
|
||||||
|
group_a_id: @group.id,
|
||||||
|
group_b_id: affinity[:group_id],
|
||||||
|
discomfort: affinity[:discomfort]
|
||||||
|
}
|
||||||
|
end.then { |affinities| GroupAffinity.upsert_all(affinities) }
|
||||||
|
render json: {}, status: :ok
|
||||||
|
|
||||||
|
rescue ActiveRecord::InvalidForeignKey
|
||||||
|
render json: { error: 'At least one of the group IDs provided does not exist.' }, status: :bad_request
|
||||||
|
rescue ActiveRecord::StatementInvalid
|
||||||
|
render json: { error: 'Invalid group ID or discomfort provided.' }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_group
|
||||||
|
@group = Group.find(params[:group_id])
|
||||||
|
end
|
||||||
|
end
|
@ -53,6 +53,10 @@ class Group < ApplicationRecord
|
|||||||
child.colorize_children(generation + 1)
|
child.colorize_children(generation + 1)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def affinities
|
||||||
|
GroupAffinity.where(group_a_id: id).or(GroupAffinity.where(group_b_id: id))
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
38
app/models/group_affinity.rb
Normal file
38
app/models/group_affinity.rb
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
# == Schema Information
|
||||||
|
#
|
||||||
|
# Table name: group_affinities
|
||||||
|
#
|
||||||
|
# id :bigint not null, primary key
|
||||||
|
# discomfort :float not null
|
||||||
|
# created_at :datetime not null
|
||||||
|
# updated_at :datetime not null
|
||||||
|
# group_a_id :uuid not null
|
||||||
|
# group_b_id :uuid not null
|
||||||
|
#
|
||||||
|
# Indexes
|
||||||
|
#
|
||||||
|
# index_group_affinities_on_group_a_id (group_a_id)
|
||||||
|
# index_group_affinities_on_group_b_id (group_b_id)
|
||||||
|
# uindex_group_pair (LEAST(group_a_id, group_b_id), GREATEST(group_a_id, group_b_id)) UNIQUE
|
||||||
|
#
|
||||||
|
# Foreign Keys
|
||||||
|
#
|
||||||
|
# fk_rails_... (group_a_id => groups.id)
|
||||||
|
# fk_rails_... (group_b_id => groups.id)
|
||||||
|
#
|
||||||
|
class GroupAffinity < ApplicationRecord
|
||||||
|
NEUTRAL = 1
|
||||||
|
MIN_DISCOMFORT = 0
|
||||||
|
MAX_DISCOMFORT = 2
|
||||||
|
|
||||||
|
belongs_to :group_a, class_name: 'Group'
|
||||||
|
belongs_to :group_b, class_name: 'Group'
|
||||||
|
|
||||||
|
validates :discomfort, numericality: { greater_than_or_equal_to: MIN_DISCOMFORT, less_than_or_equal_to: MAX_DISCOMFORT }
|
||||||
|
|
||||||
|
def another_group(group)
|
||||||
|
return nil if group != group_a && group != group_b
|
||||||
|
|
||||||
|
group == group_a ? group_b : group_a
|
||||||
|
end
|
||||||
|
end
|
@ -2,6 +2,15 @@
|
|||||||
|
|
||||||
module Tables
|
module Tables
|
||||||
class DiscomfortCalculator
|
class DiscomfortCalculator
|
||||||
|
class << self
|
||||||
|
def cohesion_discomfort(id_a:, id_b:)
|
||||||
|
distance = AffinityGroupsHierarchy.instance.distance(id_a, id_b)
|
||||||
|
|
||||||
|
return 1 if distance.nil?
|
||||||
|
Rational(distance, distance + 1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
private attr_reader :table
|
private attr_reader :table
|
||||||
def initialize(table:)
|
def initialize(table:)
|
||||||
@table = table
|
@table = table
|
||||||
@ -45,12 +54,7 @@ module Tables
|
|||||||
#
|
#
|
||||||
def cohesion_discomfort
|
def cohesion_discomfort
|
||||||
table.map(&:group_id).tally.to_a.combination(2).sum do |(a, count_a), (b, count_b)|
|
table.map(&:group_id).tally.to_a.combination(2).sum do |(a, count_a), (b, count_b)|
|
||||||
distance = AffinityGroupsHierarchy.instance.distance(a, b)
|
count_a * count_b * self.class.cohesion_discomfort(id_a: a, id_b: b)
|
||||||
|
|
||||||
next count_a * count_b if distance.nil?
|
|
||||||
next 0 if distance.zero?
|
|
||||||
|
|
||||||
count_a * count_b * Rational(distance, distance + 1)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -23,7 +23,12 @@ Rails.application.routes.draw do
|
|||||||
get '/users/confirmation', to: 'users/confirmations#show', as: :confirmation
|
get '/users/confirmation', to: 'users/confirmations#show', as: :confirmation
|
||||||
end
|
end
|
||||||
|
|
||||||
resources :groups, only: %i[index create update destroy]
|
resources :groups, only: %i[index create update destroy] do
|
||||||
|
resources :affinities, only: %i[index] do
|
||||||
|
put :bulk_update, on: :collection
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
resources :guests, only: %i[index create update destroy] do
|
resources :guests, only: %i[index create update destroy] do
|
||||||
post :bulk_update, on: :collection
|
post :bulk_update, on: :collection
|
||||||
end
|
end
|
||||||
|
27
db/migrate/20241216231415_create_group_affinities.rb
Normal file
27
db/migrate/20241216231415_create_group_affinities.rb
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
class CreateGroupAffinities < ActiveRecord::Migration[8.0]
|
||||||
|
disable_ddl_transaction!
|
||||||
|
|
||||||
|
def change
|
||||||
|
create_table :group_affinities, if_not_exists: true do |t|
|
||||||
|
t.references :group_a, type: :uuid, null: false, foreign_key: { to_table: :groups }
|
||||||
|
t.references :group_b, type: :uuid, null: false, foreign_key: { to_table: :groups }
|
||||||
|
t.float :discomfort, null: false
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
add_check_constraint :group_affinities, 'group_a_id != group_b_id', name: :check_distinct_groups, if_not_exists: true
|
||||||
|
add_check_constraint :group_affinities, 'discomfort >= 0 AND discomfort <= 2', if_not_exists: true
|
||||||
|
|
||||||
|
reversible do |dir|
|
||||||
|
dir.up do
|
||||||
|
execute <<~SQL
|
||||||
|
CREATE UNIQUE INDEX CONCURRENTLY uindex_group_pair ON group_affinities (least(group_a_id, group_b_id), greatest(group_a_id, group_b_id));
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
dir.down do
|
||||||
|
remove_index :group_affinities, name: :uindex_group_pair, if_exists: true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
19
db/schema.rb
generated
19
db/schema.rb
generated
@ -1,5 +1,3 @@
|
|||||||
# Copyright (C) 2024 Manuel Bustillo
|
|
||||||
|
|
||||||
# This file is auto-generated from the current state of the database. Instead
|
# This file is auto-generated from the current state of the database. Instead
|
||||||
# of editing this file, please use the migrations feature of Active Record to
|
# of editing this file, please use the migrations feature of Active Record to
|
||||||
# incrementally modify your database, and then regenerate this schema definition.
|
# incrementally modify your database, and then regenerate this schema definition.
|
||||||
@ -12,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.0].define(version: 2024_12_08_102932) do
|
ActiveRecord::Schema[8.0].define(version: 2024_12_16_231415) 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"
|
||||||
|
|
||||||
@ -30,6 +28,19 @@ ActiveRecord::Schema[8.0].define(version: 2024_12_08_102932) do
|
|||||||
t.index ["wedding_id"], name: "index_expenses_on_wedding_id"
|
t.index ["wedding_id"], name: "index_expenses_on_wedding_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "group_affinities", force: :cascade do |t|
|
||||||
|
t.uuid "group_a_id", null: false
|
||||||
|
t.uuid "group_b_id", null: false
|
||||||
|
t.float "discomfort", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index "LEAST(group_a_id, group_b_id), GREATEST(group_a_id, group_b_id)", name: "uindex_group_pair", unique: true
|
||||||
|
t.index ["group_a_id"], name: "index_group_affinities_on_group_a_id"
|
||||||
|
t.index ["group_b_id"], name: "index_group_affinities_on_group_b_id"
|
||||||
|
t.check_constraint "discomfort >= 0::double precision AND discomfort <= 2::double precision"
|
||||||
|
t.check_constraint "group_a_id <> group_b_id", name: "check_distinct_groups"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "groups", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
create_table "groups", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
t.string "icon"
|
t.string "icon"
|
||||||
@ -228,6 +239,8 @@ ActiveRecord::Schema[8.0].define(version: 2024_12_08_102932) do
|
|||||||
end
|
end
|
||||||
|
|
||||||
add_foreign_key "expenses", "weddings"
|
add_foreign_key "expenses", "weddings"
|
||||||
|
add_foreign_key "group_affinities", "groups", column: "group_a_id"
|
||||||
|
add_foreign_key "group_affinities", "groups", column: "group_b_id"
|
||||||
add_foreign_key "groups", "groups", column: "parent_id"
|
add_foreign_key "groups", "groups", column: "parent_id"
|
||||||
add_foreign_key "groups", "weddings"
|
add_foreign_key "groups", "weddings"
|
||||||
add_foreign_key "guests", "groups"
|
add_foreign_key "guests", "groups"
|
||||||
|
@ -11,6 +11,13 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgres://postgres:postgres@db:5432/postgres
|
DATABASE_URL: postgres://postgres:postgres@db:5432/postgres
|
||||||
RAILS_ENV: development
|
RAILS_ENV: development
|
||||||
|
tty: true
|
||||||
|
stdin_open: true
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:3000/up"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
volumes:
|
volumes:
|
||||||
- .:/rails
|
- .:/rails
|
||||||
workers:
|
workers:
|
||||||
@ -32,6 +39,11 @@ services:
|
|||||||
dockerfile: Dockerfile.dev
|
dockerfile: Dockerfile.dev
|
||||||
ports:
|
ports:
|
||||||
- 3000
|
- 3000
|
||||||
|
healthcheck:
|
||||||
|
test: wget -qO - http://localhost:3000/api/health || exit 1
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
volumes:
|
volumes:
|
||||||
@ -50,8 +62,10 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
- frontend
|
frontend:
|
||||||
- backend
|
condition: service_healthy
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
db:
|
db:
|
||||||
image: postgres:17
|
image: postgres:17
|
||||||
ports:
|
ports:
|
||||||
|
7
spec/factories/group_affinities.rb
Normal file
7
spec/factories/group_affinities.rb
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
FactoryBot.define do
|
||||||
|
factory :group_affinity do
|
||||||
|
association :group_a, factory: :group
|
||||||
|
association :group_b, factory: :group
|
||||||
|
discomfort { GroupAffinity::NEUTRAL }
|
||||||
|
end
|
||||||
|
end
|
40
spec/models/group_affinity_spec.rb
Normal file
40
spec/models/group_affinity_spec.rb
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe GroupAffinity, type: :model do
|
||||||
|
let(:wedding) { create(:wedding) }
|
||||||
|
let(:group_a) { create(:group, wedding:) }
|
||||||
|
let(:group_b) { create(:group, wedding:) }
|
||||||
|
let(:group_c) { create(:group, wedding:) }
|
||||||
|
|
||||||
|
subject { build(:group_affinity, group_a:, group_b: ) }
|
||||||
|
|
||||||
|
describe 'validations' do
|
||||||
|
it { should validate_numericality_of(:discomfort).is_greater_than_or_equal_to(0).is_less_than_or_equal_to(2) }
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.create' do
|
||||||
|
before do
|
||||||
|
create(:group_affinity, group_a: group_a, group_b: group_b)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'disallows the creation of a group affinity with the same group on both sides' do
|
||||||
|
expect do
|
||||||
|
create(:group_affinity, group_a: group_c, group_b: group_c)
|
||||||
|
end.to raise_error(ActiveRecord::StatementInvalid)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'disallows the creation of a group affinity that already exists' do
|
||||||
|
expect do
|
||||||
|
create(:group_affinity, group_a: group_a, group_b: group_b)
|
||||||
|
end.to raise_error(ActiveRecord::StatementInvalid)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'disallows the creation of a group affinity with the same groups in reverse order' do
|
||||||
|
expect do
|
||||||
|
create(:group_affinity, group_a: group_b, group_b: group_a)
|
||||||
|
end.to raise_error(ActiveRecord::StatementInvalid)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
50
spec/requests/affinities_spec.rb
Normal file
50
spec/requests/affinities_spec.rb
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'swagger_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'affinities', type: :request do
|
||||||
|
path '/{slug}/groups/{group_id}/affinities' do
|
||||||
|
parameter Swagger::Schema::SLUG
|
||||||
|
parameter name: 'group_id', in: :path, type: :string, format: :uuid, description: 'group_id'
|
||||||
|
|
||||||
|
get('list affinities') do
|
||||||
|
tags 'Affinities'
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
schema type: :object, additionalProperties: { type: :integer, minimum: 0, maximum: 2 }
|
||||||
|
xit
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/{slug}/groups/{group_id}/affinities/bulk_update' do
|
||||||
|
parameter Swagger::Schema::SLUG
|
||||||
|
parameter name: 'group_id', in: :path, type: :string, format: :uuid, description: 'group_id'
|
||||||
|
|
||||||
|
put('bulk update affinities') do
|
||||||
|
tags 'Affinities'
|
||||||
|
produces 'application/json'
|
||||||
|
consumes 'application/json'
|
||||||
|
parameter name: :body, in: :body, schema: {
|
||||||
|
type: :object,
|
||||||
|
required: [:affinities],
|
||||||
|
properties: {
|
||||||
|
affinities: {
|
||||||
|
type: :array,
|
||||||
|
items: {
|
||||||
|
type: :object,
|
||||||
|
required: %i[group_id discomfort],
|
||||||
|
properties: {
|
||||||
|
group_id: { type: :string, format: :uuid, description: 'ID of the associated group' },
|
||||||
|
discomfort: { type: :integer, minimum: 0, maximum: 2 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response_empty_200
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
Loading…
x
Reference in New Issue
Block a user