From 6c6ae62e5a4367a7ce8e02e8adeac93d5d835dc6 Mon Sep 17 00:00:00 2001 From: Manuel Bustillo Date: Tue, 17 Dec 2024 00:46:01 +0100 Subject: [PATCH] Define model and endpoints to store affinity between group pairs --- app/controllers/affinities_controller.rb | 36 +++++++++++++ app/models/group.rb | 4 ++ app/models/group_affinity.rb | 38 ++++++++++++++ app/services/tables/discomfort_calculator.rb | 16 +++--- config/routes.rb | 7 ++- .../20241216231415_create_group_affinities.rb | 27 ++++++++++ db/schema.rb | 19 +++++-- docker-compose.yml | 18 ++++++- spec/factories/group_affinities.rb | 7 +++ spec/models/group_affinity_spec.rb | 40 +++++++++++++++ spec/requests/affinities_spec.rb | 50 +++++++++++++++++++ 11 files changed, 250 insertions(+), 12 deletions(-) create mode 100644 app/controllers/affinities_controller.rb create mode 100644 app/models/group_affinity.rb create mode 100644 db/migrate/20241216231415_create_group_affinities.rb create mode 100644 spec/factories/group_affinities.rb create mode 100644 spec/models/group_affinity_spec.rb create mode 100644 spec/requests/affinities_spec.rb diff --git a/app/controllers/affinities_controller.rb b/app/controllers/affinities_controller.rb new file mode 100644 index 0000000..9a2122c --- /dev/null +++ b/app/controllers/affinities_controller.rb @@ -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 diff --git a/app/models/group.rb b/app/models/group.rb index a00a671..6552214 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -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 diff --git a/app/models/group_affinity.rb b/app/models/group_affinity.rb new file mode 100644 index 0000000..c33364c --- /dev/null +++ b/app/models/group_affinity.rb @@ -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 diff --git a/app/services/tables/discomfort_calculator.rb b/app/services/tables/discomfort_calculator.rb index 35da8ce..c4ef54a 100644 --- a/app/services/tables/discomfort_calculator.rb +++ b/app/services/tables/discomfort_calculator.rb @@ -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 diff --git a/config/routes.rb b/config/routes.rb index c3bc335..9e2b004 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/db/migrate/20241216231415_create_group_affinities.rb b/db/migrate/20241216231415_create_group_affinities.rb new file mode 100644 index 0000000..1c6789d --- /dev/null +++ b/db/migrate/20241216231415_create_group_affinities.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 0684e19..5e4178c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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" diff --git a/docker-compose.yml b/docker-compose.yml index df2c035..93cff59 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/spec/factories/group_affinities.rb b/spec/factories/group_affinities.rb new file mode 100644 index 0000000..6877218 --- /dev/null +++ b/spec/factories/group_affinities.rb @@ -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 diff --git a/spec/models/group_affinity_spec.rb b/spec/models/group_affinity_spec.rb new file mode 100644 index 0000000..d346110 --- /dev/null +++ b/spec/models/group_affinity_spec.rb @@ -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 diff --git a/spec/requests/affinities_spec.rb b/spec/requests/affinities_spec.rb new file mode 100644 index 0000000..a78d569 --- /dev/null +++ b/spec/requests/affinities_spec.rb @@ -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