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)
|
||||
end
|
||||
end
|
||||
|
||||
def affinities
|
||||
GroupAffinity.where(group_a_id: id).or(GroupAffinity.where(group_b_id: id))
|
||||
end
|
||||
|
||||
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
|
||||
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
|
||||
def initialize(table:)
|
||||
@table = table
|
||||
@ -45,12 +54,7 @@ module Tables
|
||||
#
|
||||
def cohesion_discomfort
|
||||
table.map(&:group_id).tally.to_a.combination(2).sum do |(a, count_a), (b, count_b)|
|
||||
distance = AffinityGroupsHierarchy.instance.distance(a, b)
|
||||
|
||||
next count_a * count_b if distance.nil?
|
||||
next 0 if distance.zero?
|
||||
|
||||
count_a * count_b * Rational(distance, distance + 1)
|
||||
count_a * count_b * self.class.cohesion_discomfort(id_a: a, id_b: b)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -23,7 +23,12 @@ Rails.application.routes.draw do
|
||||
get '/users/confirmation', to: 'users/confirmations#show', as: :confirmation
|
||||
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
|
||||
post :bulk_update, on: :collection
|
||||
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
|
||||
# of editing this file, please use the migrations feature of Active Record to
|
||||
# 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.
|
||||
|
||||
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
|
||||
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"
|
||||
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|
|
||||
t.string "name", null: false
|
||||
t.string "icon"
|
||||
@ -228,6 +239,8 @@ ActiveRecord::Schema[8.0].define(version: 2024_12_08_102932) do
|
||||
end
|
||||
|
||||
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", "weddings"
|
||||
add_foreign_key "guests", "groups"
|
||||
|
@ -11,6 +11,13 @@ services:
|
||||
environment:
|
||||
DATABASE_URL: postgres://postgres:postgres@db:5432/postgres
|
||||
RAILS_ENV: development
|
||||
tty: true
|
||||
stdin_open: true
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/up"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
volumes:
|
||||
- .:/rails
|
||||
workers:
|
||||
@ -32,6 +39,11 @@ services:
|
||||
dockerfile: Dockerfile.dev
|
||||
ports:
|
||||
- 3000
|
||||
healthcheck:
|
||||
test: wget -qO - http://localhost:3000/api/health || exit 1
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
depends_on:
|
||||
- backend
|
||||
volumes:
|
||||
@ -50,8 +62,10 @@ services:
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
depends_on:
|
||||
- frontend
|
||||
- backend
|
||||
frontend:
|
||||
condition: service_healthy
|
||||
backend:
|
||||
condition: service_healthy
|
||||
db:
|
||||
image: postgres:17
|
||||
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