Define model and endpoints to store affinity between group pairs
All checks were successful
Check usage of free licenses / check-licenses (pull_request) Successful in 1m7s
Add copyright notice / copyright_notice (pull_request) Successful in 1m18s
Run unit tests / unit_tests (pull_request) Successful in 2m16s

This commit is contained in:
Manuel Bustillo 2024-12-17 00:46:01 +01:00
parent 3b2f52da9b
commit 6c6ae62e5a
11 changed files with 250 additions and 12 deletions

View 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

View File

@ -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

View 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

View File

@ -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

View File

@ -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

View 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
View File

@ -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"

View File

@ -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:

View 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

View 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

View 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