Define model and endpoints to store affinity between group pairs #200
							
								
								
									
										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