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 | ||||
| @ -54,6 +54,10 @@ class Group < ApplicationRecord | ||||
|     end | ||||
|   end | ||||
|    | ||||
|   def affinities | ||||
|     GroupAffinity.where(group_a_id: id).or(GroupAffinity.where(group_b_id: id)) | ||||
|   end | ||||
| 
 | ||||
|   private | ||||
| 
 | ||||
|   def set_color | ||||
|  | ||||
							
								
								
									
										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