diff --git a/app/controllers/organization_alliances_controller.rb b/app/controllers/organization_alliances_controller.rb
new file mode 100644
index 00000000..2078158f
--- /dev/null
+++ b/app/controllers/organization_alliances_controller.rb
@@ -0,0 +1,74 @@
+class OrganizationAlliancesController < ApplicationController
+ before_action :authenticate_user!
+ before_action :member_should_exist_and_be_active
+ before_action :authorize_admin
+ before_action :find_alliance, only: [:update, :destroy]
+
+ def index
+ @status = params[:status] || "pending"
+
+ @alliances = case @status
+ when "pending"
+ current_organization.pending_alliances.includes(:source_organization, :target_organization)
+ when "accepted"
+ current_organization.accepted_alliances.includes(:source_organization, :target_organization)
+ when "rejected"
+ current_organization.rejected_alliances.includes(:source_organization, :target_organization)
+ else
+ []
+ end
+ end
+
+ def create
+ alliance = OrganizationAlliance.new(
+ source_organization: current_organization,
+ target_organization_id: alliance_params[:target_organization_id],
+ status: "pending"
+ )
+
+ if alliance.save
+ flash[:notice] = t("organization_alliances.created")
+ else
+ flash[:error] = alliance.errors.full_messages.to_sentence
+ end
+
+ redirect_back fallback_location: organizations_path
+ end
+
+ def update
+ if @alliance.update(status: params[:status])
+ flash[:notice] = t("organization_alliances.updated")
+ else
+ flash[:error] = @alliance.errors.full_messages.to_sentence
+ end
+
+ redirect_to organization_alliances_path
+ end
+
+ def destroy
+ if @alliance.destroy
+ flash[:notice] = t("organization_alliances.destroyed")
+ else
+ flash[:error] = t("organization_alliances.error_destroying")
+ end
+
+ redirect_to organization_alliances_path
+ end
+
+ private
+
+ def find_alliance
+ @alliance = OrganizationAlliance.find(params[:id])
+ end
+
+ def authorize_admin
+ return if current_user.manages?(current_organization)
+
+ flash[:error] = t("organization_alliances.not_authorized")
+ redirect_to root_path
+ end
+
+ def alliance_params
+ params.require(:organization_alliance).permit(:target_organization_id)
+ end
+end
diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb
index 5a8e3616..23ff7ecc 100644
--- a/app/controllers/posts_controller.rb
+++ b/app/controllers/posts_controller.rb
@@ -6,10 +6,14 @@ class PostsController < ApplicationController
def index
context = model.active.of_active_members
- if current_organization.present?
- context = context.where(
- organization_id: current_organization.id
- )
+ if current_user.present? && current_organization.present?
+ if params[:show_allied].present?
+ allied_org_ids = current_organization.allied_organizations.pluck(:id)
+ org_ids = [current_organization.id] + allied_org_ids
+ context = context.by_organizations(org_ids)
+ elsif !params[:org].present?
+ context = context.by_organization(current_organization.id)
+ end
end
posts = apply_scopes(context)
@@ -98,15 +102,6 @@ def post_params
end
end
- # TODO: remove this horrible hack ASAP
- #
- # This hack set the current organization to the post's
- # organization, both in session and controller instance variable.
- #
- # Before changing the current organization it's important to check that
- # the current_user is an active member of the organization.
- #
- # @param organization [Organization]
def update_current_organization!(organization)
return unless current_user && current_user.active?(organization)
diff --git a/app/helpers/organizations_helper.rb b/app/helpers/organizations_helper.rb
new file mode 100644
index 00000000..59b8decd
--- /dev/null
+++ b/app/helpers/organizations_helper.rb
@@ -0,0 +1,25 @@
+module OrganizationsHelper
+ def filterable_organizations
+ Organization.all.order(:name)
+ end
+
+ def allied_organizations
+ return [] unless current_organization
+
+ allied_org_ids = current_organization.accepted_alliances.map do |alliance|
+ alliance.source_organization_id == current_organization.id ?
+ alliance.target_organization_id : alliance.source_organization_id
+ end
+
+ organizations = Organization.where(id: allied_org_ids + [current_organization.id])
+ organizations.order(:name)
+ end
+
+ def alliance_initiator?(alliance)
+ alliance.source_organization_id == current_organization.id
+ end
+
+ def alliance_recipient(alliance)
+ alliance_initiator?(alliance) ? alliance.target_organization : alliance.source_organization
+ end
+end
diff --git a/app/models/organization.rb b/app/models/organization.rb
index 90847ee3..56280e84 100644
--- a/app/models/organization.rb
+++ b/app/models/organization.rb
@@ -24,6 +24,8 @@ class Organization < ApplicationRecord
has_many :inquiries
has_many :documents, as: :documentable, dependent: :destroy
has_many :petitions, dependent: :delete_all
+ has_many :initiated_alliances, class_name: "OrganizationAlliance", foreign_key: "source_organization_id", dependent: :destroy
+ has_many :received_alliances, class_name: "OrganizationAlliance", foreign_key: "target_organization_id", dependent: :destroy
validates :name, presence: true, uniqueness: true
@@ -52,15 +54,33 @@ def display_name_with_uid
self
end
- # Returns the id to be displayed in the :new transfer page with the given
- # destination_accountable
- #
- # @params destination_accountable [Organization | Object] target of a transfer
- # @return [Integer | String]
def display_id
account.accountable_id
end
+ def alliance_with(organization)
+ initiated_alliances.find_by(target_organization: organization) ||
+ received_alliances.find_by(source_organization: organization)
+ end
+
+ def pending_alliances
+ initiated_alliances.pending.or(received_alliances.pending)
+ end
+
+ def accepted_alliances
+ initiated_alliances.accepted.or(received_alliances.accepted)
+ end
+
+ def rejected_alliances
+ initiated_alliances.rejected.or(received_alliances.rejected)
+ end
+
+ def allied_organizations
+ source_org_ids = initiated_alliances.accepted.pluck(:target_organization_id)
+ target_org_ids = received_alliances.accepted.pluck(:source_organization_id)
+ Organization.where(id: source_org_ids + target_org_ids)
+ end
+
def ensure_reg_number_seq!
update_column(:reg_number_seq, members.maximum(:member_uid))
end
diff --git a/app/models/organization_alliance.rb b/app/models/organization_alliance.rb
new file mode 100644
index 00000000..6d242358
--- /dev/null
+++ b/app/models/organization_alliance.rb
@@ -0,0 +1,23 @@
+class OrganizationAlliance < ApplicationRecord
+ belongs_to :source_organization, class_name: "Organization"
+ belongs_to :target_organization, class_name: "Organization"
+
+ enum status: { pending: 0, accepted: 1, rejected: 2 }
+
+ validates :source_organization_id, presence: true
+ validates :target_organization_id, presence: true
+ validates :target_organization_id, uniqueness: { scope: :source_organization_id }
+ validate :cannot_ally_with_self
+
+ scope :pending, -> { where(status: "pending") }
+ scope :accepted, -> { where(status: "accepted") }
+ scope :rejected, -> { where(status: "rejected") }
+
+ private
+
+ def cannot_ally_with_self
+ if source_organization_id == target_organization_id
+ errors.add(:base, "Cannot create an alliance with yourself")
+ end
+ end
+end
diff --git a/app/views/application/menus/_organization_listings_menu.html.erb b/app/views/application/menus/_organization_listings_menu.html.erb
index aeb701c5..20f246ca 100644
--- a/app/views/application/menus/_organization_listings_menu.html.erb
+++ b/app/views/application/menus/_organization_listings_menu.html.erb
@@ -16,6 +16,12 @@
<%= t('petitions.applications') %>
<% end %>
+
+ <%= link_to organization_alliances_path, class: "dropdown-item" do %>
+ <%= glyph :globe %>
+ <%= t "application.navbar.organization_alliances" %>
+ <% end %>
+
<%= link_to offers_path(org: current_organization), class: "dropdown-item" do %>
<%= glyph :link %>
diff --git a/app/views/organization_alliances/index.html.erb b/app/views/organization_alliances/index.html.erb
new file mode 100644
index 00000000..6d4d722e
--- /dev/null
+++ b/app/views/organization_alliances/index.html.erb
@@ -0,0 +1,97 @@
+<%= t('organization_alliances.title') %>
+
+
+
+
+
+
+
+
+
+
+ <%= t('organization_alliances.organization') %> |
+ <%= t('organization_alliances.city') %> |
+ <%= t('organization_alliances.members') %> |
+ <%= t('organization_alliances.type') %> |
+ <% if @status != 'rejected' %>
+ <%= t('organization_alliances.actions') %> |
+ <% end %>
+
+
+
+ <% @alliances.each do |alliance| %>
+ <% other_org = alliance_recipient(alliance) %>
+
+ <%= link_to other_org.name, other_org %> |
+ <%= other_org.city %> |
+ <%= other_org.members.count %> |
+
+ <%= t("organization_alliances.#{alliance_initiator?(alliance) ? 'sent' : 'received'}") %>
+ |
+ <% if @status == 'pending' %>
+
+ <% if alliance_initiator?(alliance) %>
+ <%= link_to t('organization_alliances.cancel_request'),
+ organization_alliance_path(alliance),
+ method: :delete,
+ class: 'btn btn-danger',
+ data: { confirm: t('organization_alliances.confirm_cancel') } %>
+ <% else %>
+
+ <%= link_to t('organization_alliances.accept'),
+ organization_alliance_path(alliance, status: 'accepted'),
+ method: :put,
+ class: 'btn btn-primary' %>
+ <%= link_to t('organization_alliances.reject'),
+ organization_alliance_path(alliance, status: 'rejected'),
+ method: :put,
+ class: 'btn btn-danger' %>
+
+ <% end %>
+ |
+ <% elsif @status == 'accepted' %>
+
+ <%= link_to t('organization_alliances.end_alliance'),
+ organization_alliance_path(alliance),
+ method: :delete,
+ class: 'btn btn-danger',
+ data: { confirm: t('organization_alliances.confirm_end') } %>
+ |
+ <% end %>
+
+ <% end %>
+
+
+
+
+
+
diff --git a/app/views/organizations/_alliance_button.html.erb b/app/views/organizations/_alliance_button.html.erb
new file mode 100644
index 00000000..6be3d44e
--- /dev/null
+++ b/app/views/organizations/_alliance_button.html.erb
@@ -0,0 +1,16 @@
+<% if current_user&.manages?(current_organization) && organization != current_organization %>
+ <% alliance = current_organization.alliance_with(organization) %>
+ <% if alliance.nil? %>
+ <%= link_to t('organization_alliances.request_alliance'),
+ organization_alliances_path(organization_alliance: { target_organization_id: organization.id }),
+ method: :post,
+ class: 'btn btn-secondary',
+ aria: { label: t('organization_alliances.request_alliance_for', org: organization.name) } %>
+ <% elsif alliance.pending? %>
+ <%= t('organization_alliances.pending') %>
+ <% elsif alliance.accepted? %>
+ <%= t('organization_alliances.active') %>
+ <% elsif alliance.rejected? %>
+ <%= t('organization_alliances.rejected') %>
+ <% end %>
+<% end %>
diff --git a/app/views/organizations/_organizations_row.html.erb b/app/views/organizations/_organizations_row.html.erb
index f68caa55..30b5a6c5 100644
--- a/app/views/organizations/_organizations_row.html.erb
+++ b/app/views/organizations/_organizations_row.html.erb
@@ -7,4 +7,9 @@
<%= render "organizations/petition_button", organization: org %>
|
+ <% if current_user&.manages?(current_organization) %>
+
+ <%= render "organizations/alliance_button", organization: org %>
+ |
+ <% end %>
diff --git a/app/views/organizations/index.html.erb b/app/views/organizations/index.html.erb
index a6954fa5..f750148d 100644
--- a/app/views/organizations/index.html.erb
+++ b/app/views/organizations/index.html.erb
@@ -25,7 +25,10 @@
<%= t '.neighborhood' %> |
<%= t '.web' %> |
<%= t '.member_count' %> |
- |
+ <%= t '.membership' %> |
+ <% if current_user&.manages?(current_organization) %>
+ <%= t '.alliance' %> |
+ <% end %>
diff --git a/app/views/shared/_post_filters.html.erb b/app/views/shared/_post_filters.html.erb
index d471cb0c..4cdf22db 100644
--- a/app/views/shared/_post_filters.html.erb
+++ b/app/views/shared/_post_filters.html.erb
@@ -1,4 +1,5 @@
<% @category = Category.find_by(id: params[:cat]) %>
+<% selected_org = Organization.find_by(id: params[:org]) %>
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 36c5f5f5..4a3c7e2d 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -141,6 +141,7 @@ en:
last_login: Last login
offer_public_link: Offers public link
organizations: Organizations
+ organization_alliances: Organizations
reports: Reports
sign_out: Logout
statistics: Statistics
@@ -370,6 +371,35 @@ en:
show:
give_time_for: Time transfer for this offer
offered_by: Offered by
+ organization_alliances:
+ title: "Organization Alliances"
+ created: "Alliance request sent"
+ updated: "Alliance status updated"
+ destroyed: "Alliance has been ended"
+ error_destroying: "Could not end alliance"
+ not_authorized: "You are not authorized to manage alliances"
+ organization: "Organization"
+ city: "City"
+ members: "Members"
+ type: "Type"
+ actions: "Actions"
+ sent: "Sent"
+ received: "Received"
+ pending: "Pending"
+ active: "Active"
+ rejected: "Rejected"
+ request_alliance: "Request alliance"
+ cancel_request: "Cancel request"
+ accept: "Accept"
+ reject: "Reject"
+ end_alliance: "End alliance"
+ confirm_cancel: "Are you sure you want to cancel this alliance request?"
+ confirm_end: "Are you sure you want to end this alliance?"
+ search_organizations: "Search organizations"
+ status:
+ pending: "Pending Requests"
+ accepted: "Active Alliances"
+ rejected: "Rejected Requests"
organization_notifier:
member_deleted:
body: User %{username} has unsubscribed from the organization.
@@ -382,6 +412,8 @@ en:
give_time: Give time to
index:
member_count: Number of users
+ membership: "Membership"
+ alliance: "Alliance"
new:
new: New bank
show:
diff --git a/config/locales/es.yml b/config/locales/es.yml
index 50ee7b64..5de719c6 100644
--- a/config/locales/es.yml
+++ b/config/locales/es.yml
@@ -141,6 +141,7 @@ es:
last_login: Último login
offer_public_link: Enlace público a ofertas
organizations: Organizaciones
+ organization_alliances: Organizaciones
reports: Informes
sign_out: Desconectar
statistics: Estadísticas
@@ -370,6 +371,35 @@ es:
show:
give_time_for: Transferir tiempo por esta oferta
offered_by: Ofertantes
+ organization_alliances:
+ title: "Alianzas"
+ created: "Solicitud de alianza enviada"
+ updated: "Estado de alianza actualizado"
+ destroyed: "La alianza ha finalizado"
+ error_destroying: "No se pudo finalizar la alianza"
+ not_authorized: "No estás autorizado para gestionar alianzas"
+ organization: "Organización"
+ city: "Ciudad"
+ members: "Miembros"
+ type: "Tipo"
+ actions: "Acciones"
+ sent: "Enviadas"
+ received: "Recibidas"
+ pending: "Pendientes"
+ active: "Activa"
+ rejected: "Rechazada"
+ request_alliance: "Solicitar alianza"
+ cancel_request: "Cancelar solicitud"
+ accept: "Aceptar"
+ reject: "Rechazar"
+ end_alliance: "Finalizar alianza"
+ confirm_cancel: "¿Estás seguro de que quieres cancelar esta solicitud de alianza?"
+ confirm_end: "¿Estás seguro de que quieres finalizar esta alianza?"
+ search_organizations: "Buscar organizaciones"
+ status:
+ pending: "Solicitudes Pendientes"
+ accepted: "Alianzas Activas"
+ rejected: "Solicitudes Rechazadas"
organization_notifier:
member_deleted:
body: El usuario %{username} se ha dado de baja de la organización.
@@ -382,6 +412,8 @@ es:
give_time: Dar Tiempo a
index:
member_count: Número de usuarios
+ membership: "Membresía"
+ alliance: "Alianza"
new:
new: Nuevo banco
show:
diff --git a/config/routes.rb b/config/routes.rb
index 500c4581..2eba7fd1 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -35,6 +35,8 @@
end
get :select_organization, to: 'organizations#select_organization'
+ resources :organization_alliances, only: [:index, :create, :update, :destroy]
+
resources :users, concerns: :accountable, except: :destroy, :path => "members" do
collection do
get 'signup'
diff --git a/db/migrate/20250412110249_create_organization_alliances.rb b/db/migrate/20250412110249_create_organization_alliances.rb
new file mode 100644
index 00000000..320a702f
--- /dev/null
+++ b/db/migrate/20250412110249_create_organization_alliances.rb
@@ -0,0 +1,14 @@
+class CreateOrganizationAlliances < ActiveRecord::Migration[7.2]
+ def change
+ create_table :organization_alliances do |t|
+ t.references :source_organization, foreign_key: { to_table: :organizations }
+ t.references :target_organization, foreign_key: { to_table: :organizations }
+ t.integer :status, default: 0
+
+ t.timestamps
+ end
+
+ add_index :organization_alliances, [:source_organization_id, :target_organization_id],
+ unique: true, name: 'index_org_alliances_on_source_and_target'
+ end
+end
\ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index b51e5fae..4388a527 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -479,6 +479,39 @@ CREATE SEQUENCE public.movements_id_seq
ALTER SEQUENCE public.movements_id_seq OWNED BY public.movements.id;
+--
+-- Name: organization_alliances; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE public.organization_alliances (
+ id bigint NOT NULL,
+ source_organization_id bigint,
+ target_organization_id bigint,
+ status integer DEFAULT 0,
+ created_at timestamp(6) without time zone NOT NULL,
+ updated_at timestamp(6) without time zone NOT NULL
+);
+
+
+--
+-- Name: organization_alliances_id_seq; Type: SEQUENCE; Schema: public; Owner: -
+--
+
+CREATE SEQUENCE public.organization_alliances_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+--
+-- Name: organization_alliances_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
+--
+
+ALTER SEQUENCE public.organization_alliances_id_seq OWNED BY public.organization_alliances.id;
+
+
--
-- Name: organizations; Type: TABLE; Schema: public; Owner: -
--
@@ -818,6 +851,13 @@ ALTER TABLE ONLY public.members ALTER COLUMN id SET DEFAULT nextval('public.memb
ALTER TABLE ONLY public.movements ALTER COLUMN id SET DEFAULT nextval('public.movements_id_seq'::regclass);
+--
+-- Name: organization_alliances id; Type: DEFAULT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.organization_alliances ALTER COLUMN id SET DEFAULT nextval('public.organization_alliances_id_seq'::regclass);
+
+
--
-- Name: organizations id; Type: DEFAULT; Schema: public; Owner: -
--
@@ -956,6 +996,14 @@ ALTER TABLE ONLY public.movements
ADD CONSTRAINT movements_pkey PRIMARY KEY (id);
+--
+-- Name: organization_alliances organization_alliances_pkey; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.organization_alliances
+ ADD CONSTRAINT organization_alliances_pkey PRIMARY KEY (id);
+
+
--
-- Name: organizations organizations_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
@@ -1144,6 +1192,27 @@ CREATE INDEX index_movements_on_account_id ON public.movements USING btree (acco
CREATE INDEX index_movements_on_transfer_id ON public.movements USING btree (transfer_id);
+--
+-- Name: index_org_alliances_on_source_and_target; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE UNIQUE INDEX index_org_alliances_on_source_and_target ON public.organization_alliances USING btree (source_organization_id, target_organization_id);
+
+
+--
+-- Name: index_organization_alliances_on_source_organization_id; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_organization_alliances_on_source_organization_id ON public.organization_alliances USING btree (source_organization_id);
+
+
+--
+-- Name: index_organization_alliances_on_target_organization_id; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_organization_alliances_on_target_organization_id ON public.organization_alliances USING btree (target_organization_id);
+
+
--
-- Name: index_organizations_on_name; Type: INDEX; Schema: public; Owner: -
--
@@ -1299,6 +1368,14 @@ ALTER TABLE ONLY public.push_notifications
ADD CONSTRAINT fk_rails_79a395b2d7 FOREIGN KEY (event_id) REFERENCES public.events(id);
+--
+-- Name: organization_alliances fk_rails_7c459bc8e7; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.organization_alliances
+ ADD CONSTRAINT fk_rails_7c459bc8e7 FOREIGN KEY (source_organization_id) REFERENCES public.organizations(id);
+
+
--
-- Name: active_storage_variant_records fk_rails_993965df05; Type: FK CONSTRAINT; Schema: public; Owner: -
--
@@ -1315,6 +1392,14 @@ ALTER TABLE ONLY public.active_storage_attachments
ADD CONSTRAINT fk_rails_c3b3935057 FOREIGN KEY (blob_id) REFERENCES public.active_storage_blobs(id);
+--
+-- Name: organization_alliances fk_rails_da452c7bdc; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.organization_alliances
+ ADD CONSTRAINT fk_rails_da452c7bdc FOREIGN KEY (target_organization_id) REFERENCES public.organizations(id);
+
+
--
-- PostgreSQL database dump complete
--
@@ -1395,4 +1480,5 @@ INSERT INTO "schema_migrations" (version) VALUES
('20241230170753'),
('20250215163404'),
('20250215163405'),
-('20250215163406');
+('20250215163406'),
+('20250412110249');
diff --git a/spec/controllers/organization_alliances_controller_spec.rb b/spec/controllers/organization_alliances_controller_spec.rb
new file mode 100644
index 00000000..58ff0b40
--- /dev/null
+++ b/spec/controllers/organization_alliances_controller_spec.rb
@@ -0,0 +1,155 @@
+RSpec.describe OrganizationAlliancesController do
+ let(:organization) { Fabricate(:organization) }
+ let(:other_organization) { Fabricate(:organization) }
+ let(:member) { Fabricate(:member, organization: organization, manager: true) }
+ let(:user) { member.user }
+
+ before do
+ login(user)
+ end
+
+ describe "GET #index" do
+ let!(:pending_sent) {
+ OrganizationAlliance.create!(
+ source_organization: organization,
+ target_organization: other_organization,
+ status: "pending"
+ )
+ }
+
+ let!(:pending_received) {
+ OrganizationAlliance.create!(
+ source_organization: Fabricate(:organization),
+ target_organization: organization,
+ status: "pending"
+ )
+ }
+
+ let!(:accepted) {
+ OrganizationAlliance.create!(
+ source_organization: organization,
+ target_organization: Fabricate(:organization),
+ status: "accepted"
+ )
+ }
+
+ let!(:rejected) {
+ OrganizationAlliance.create!(
+ source_organization: organization,
+ target_organization: Fabricate(:organization),
+ status: "rejected"
+ )
+ }
+
+ it "lists pending alliances by default" do
+ get :index
+
+ expect(assigns(:status)).to eq("pending")
+ expect(assigns(:alliances)).to include(pending_sent, pending_received)
+ expect(assigns(:alliances)).not_to include(accepted, rejected)
+ end
+
+ it "lists accepted alliances when status is accepted" do
+ get :index, params: { status: "accepted" }
+
+ expect(assigns(:status)).to eq("accepted")
+ expect(assigns(:alliances)).to include(accepted)
+ expect(assigns(:alliances)).not_to include(pending_sent, pending_received, rejected)
+ end
+
+ it "lists rejected alliances when status is rejected" do
+ get :index, params: { status: "rejected" }
+
+ expect(assigns(:status)).to eq("rejected")
+ expect(assigns(:alliances)).to include(rejected)
+ expect(assigns(:alliances)).not_to include(pending_sent, pending_received, accepted)
+ end
+ end
+
+ describe "POST #create" do
+ it "creates a new alliance" do
+ expect {
+ post :create, params: { organization_alliance: { target_organization_id: other_organization.id } }
+ }.to change(OrganizationAlliance, :count).by(1)
+
+ expect(flash[:notice]).to eq(I18n.t("organization_alliances.created"))
+ expect(response).to redirect_to(organizations_path)
+ end
+
+ it "sets flash error if alliance cannot be created" do
+ # Try to create alliance with self which is invalid
+ allow_any_instance_of(OrganizationAlliance).to receive(:save).and_return(false)
+ allow_any_instance_of(OrganizationAlliance).to receive_message_chain(:errors, :full_messages, :to_sentence).and_return("Error message")
+
+ post :create, params: { organization_alliance: { target_organization_id: organization.id } }
+
+ expect(flash[:error]).to eq("Error message")
+ expect(response).to redirect_to(organizations_path)
+ end
+ end
+
+ describe "PUT #update" do
+ let!(:alliance) {
+ OrganizationAlliance.create!(
+ source_organization: Fabricate(:organization),
+ target_organization: organization,
+ status: "pending"
+ )
+ }
+
+ it "updates alliance status to accepted" do
+ put :update, params: { id: alliance.id, status: "accepted" }
+
+ alliance.reload
+ expect(alliance).to be_accepted
+ expect(flash[:notice]).to eq(I18n.t("organization_alliances.updated"))
+ expect(response).to redirect_to(organization_alliances_path)
+ end
+
+ it "updates alliance status to rejected" do
+ put :update, params: { id: alliance.id, status: "rejected" }
+
+ alliance.reload
+ expect(alliance).to be_rejected
+ expect(flash[:notice]).to eq(I18n.t("organization_alliances.updated"))
+ expect(response).to redirect_to(organization_alliances_path)
+ end
+
+ it "sets flash error if alliance cannot be updated" do
+ allow_any_instance_of(OrganizationAlliance).to receive(:update).and_return(false)
+ allow_any_instance_of(OrganizationAlliance).to receive_message_chain(:errors, :full_messages, :to_sentence).and_return("Error message")
+
+ put :update, params: { id: alliance.id, status: "accepted" }
+
+ expect(flash[:error]).to eq("Error message")
+ expect(response).to redirect_to(organization_alliances_path)
+ end
+ end
+
+ describe "DELETE #destroy" do
+ let!(:alliance) {
+ OrganizationAlliance.create!(
+ source_organization: organization,
+ target_organization: other_organization
+ )
+ }
+
+ it "destroys the alliance" do
+ expect {
+ delete :destroy, params: { id: alliance.id }
+ }.to change(OrganizationAlliance, :count).by(-1)
+
+ expect(flash[:notice]).to eq(I18n.t("organization_alliances.destroyed"))
+ expect(response).to redirect_to(organization_alliances_path)
+ end
+
+ it "sets flash error if alliance cannot be destroyed" do
+ allow_any_instance_of(OrganizationAlliance).to receive(:destroy).and_return(false)
+
+ delete :destroy, params: { id: alliance.id }
+
+ expect(flash[:error]).to eq(I18n.t("organization_alliances.error_destroying"))
+ expect(response).to redirect_to(organization_alliances_path)
+ end
+ end
+end
diff --git a/spec/models/organization_alliance_spec.rb b/spec/models/organization_alliance_spec.rb
new file mode 100644
index 00000000..eb4da19e
--- /dev/null
+++ b/spec/models/organization_alliance_spec.rb
@@ -0,0 +1,121 @@
+RSpec.describe OrganizationAlliance do
+ let(:organization) { Fabricate(:organization) }
+ let(:other_organization) { Fabricate(:organization) }
+
+ around do |example|
+ I18n.with_locale(:en) do
+ example.run
+ end
+ end
+
+ describe "validations" do
+ it "is valid with valid attributes" do
+ alliance = OrganizationAlliance.new(
+ source_organization: organization,
+ target_organization: other_organization
+ )
+ expect(alliance).to be_valid
+ end
+
+ it "is not valid without a source organization" do
+ alliance = OrganizationAlliance.new(
+ source_organization: nil,
+ target_organization: other_organization
+ )
+ expect(alliance).not_to be_valid
+ expect(alliance.errors[:source_organization_id]).to include("can't be blank")
+ end
+
+ it "is not valid without a target organization" do
+ alliance = OrganizationAlliance.new(
+ source_organization: organization,
+ target_organization: nil
+ )
+ expect(alliance).not_to be_valid
+ expect(alliance.errors[:target_organization_id]).to include("can't be blank")
+ end
+
+ it "is not valid if creating an alliance with self" do
+ alliance = OrganizationAlliance.new(
+ source_organization: organization,
+ target_organization: organization
+ )
+ expect(alliance).not_to be_valid
+ expect(alliance.errors[:base]).to include("Cannot create an alliance with yourself")
+ end
+
+ it "is not valid if alliance already exists" do
+ OrganizationAlliance.create!(
+ source_organization: organization,
+ target_organization: other_organization
+ )
+
+ alliance = OrganizationAlliance.new(
+ source_organization: organization,
+ target_organization: other_organization
+ )
+ expect(alliance).not_to be_valid
+ expect(alliance.errors[:target_organization_id]).to include("has already been taken")
+ end
+ end
+
+ describe "status enum" do
+ let(:alliance) {
+ OrganizationAlliance.create!(
+ source_organization: organization,
+ target_organization: other_organization
+ )
+ }
+
+ it "defaults to pending" do
+ expect(alliance).to be_pending
+ end
+
+ it "can be set to accepted" do
+ alliance.accepted!
+ expect(alliance).to be_accepted
+ end
+
+ it "can be set to rejected" do
+ alliance.rejected!
+ expect(alliance).to be_rejected
+ end
+ end
+
+ describe "scopes" do
+ before do
+ @pending_alliance = OrganizationAlliance.create!(
+ source_organization: organization,
+ target_organization: other_organization,
+ status: "pending"
+ )
+
+ @accepted_alliance = OrganizationAlliance.create!(
+ source_organization: Fabricate(:organization),
+ target_organization: Fabricate(:organization),
+ status: "accepted"
+ )
+
+ @rejected_alliance = OrganizationAlliance.create!(
+ source_organization: Fabricate(:organization),
+ target_organization: Fabricate(:organization),
+ status: "rejected"
+ )
+ end
+
+ it "returns pending alliances" do
+ expect(OrganizationAlliance.pending).to include(@pending_alliance)
+ expect(OrganizationAlliance.pending).not_to include(@accepted_alliance, @rejected_alliance)
+ end
+
+ it "returns accepted alliances" do
+ expect(OrganizationAlliance.accepted).to include(@accepted_alliance)
+ expect(OrganizationAlliance.accepted).not_to include(@pending_alliance, @rejected_alliance)
+ end
+
+ it "returns rejected alliances" do
+ expect(OrganizationAlliance.rejected).to include(@rejected_alliance)
+ expect(OrganizationAlliance.rejected).not_to include(@pending_alliance, @accepted_alliance)
+ end
+ end
+end
diff --git a/spec/models/organization_spec.rb b/spec/models/organization_spec.rb
index 557c48ea..dfbbd0c3 100644
--- a/spec/models/organization_spec.rb
+++ b/spec/models/organization_spec.rb
@@ -5,24 +5,20 @@
it "validates content_type" do
temp_file = Tempfile.new('test.txt')
organization.logo.attach(io: File.open(temp_file.path), filename: 'test.txt')
-
expect(organization).to be_invalid
temp_file = Tempfile.new('test.svg')
organization.logo.attach(io: File.open(temp_file.path), filename: 'test.svg')
-
expect(organization).to be_invalid
temp_file = Tempfile.new('test.png')
organization.logo.attach(io: File.open(temp_file.path), filename: 'test.png')
-
expect(organization).to be_valid
end
end
describe '#display_id' do
subject { organization.display_id }
-
it { is_expected.to eq(organization.account.accountable_id) }
end
@@ -70,4 +66,96 @@
organization.save
expect(organization.errors[:name]).to include(I18n.t('errors.messages.blank'))
end
+
+ describe "alliance methods" do
+ let(:organization) { Fabricate(:organization) }
+ let(:other_organization) { Fabricate(:organization) }
+
+ describe "#alliance_with" do
+ it "returns nil if no alliance exists" do
+ expect(organization.alliance_with(other_organization)).to be_nil
+ end
+
+ it "returns alliance when organization is source" do
+ alliance = OrganizationAlliance.create!(
+ source_organization: organization,
+ target_organization: other_organization
+ )
+
+ expect(organization.alliance_with(other_organization)).to eq(alliance)
+ end
+
+ it "returns alliance when organization is target" do
+ alliance = OrganizationAlliance.create!(
+ source_organization: other_organization,
+ target_organization: organization
+ )
+
+ expect(organization.alliance_with(other_organization)).to eq(alliance)
+ end
+ end
+
+ describe "alliance status methods" do
+ let(:third_organization) { Fabricate(:organization) }
+
+ before do
+ @pending_sent = OrganizationAlliance.create!(
+ source_organization: organization,
+ target_organization: other_organization,
+ status: "pending"
+ )
+
+ @pending_received = OrganizationAlliance.create!(
+ source_organization: third_organization,
+ target_organization: organization,
+ status: "pending"
+ )
+
+ @accepted_sent = OrganizationAlliance.create!(
+ source_organization: organization,
+ target_organization: Fabricate(:organization),
+ status: "accepted"
+ )
+
+ @accepted_received = OrganizationAlliance.create!(
+ source_organization: Fabricate(:organization),
+ target_organization: organization,
+ status: "accepted"
+ )
+
+ @rejected_sent = OrganizationAlliance.create!(
+ source_organization: organization,
+ target_organization: Fabricate(:organization),
+ status: "rejected"
+ )
+
+ @rejected_received = OrganizationAlliance.create!(
+ source_organization: Fabricate(:organization),
+ target_organization: organization,
+ status: "rejected"
+ )
+ end
+
+ it "returns pending alliances" do
+ expect(organization.pending_alliances).to include(@pending_sent, @pending_received)
+ expect(organization.pending_alliances).not_to include(
+ @accepted_sent, @accepted_received, @rejected_sent, @rejected_received
+ )
+ end
+
+ it "returns accepted alliances" do
+ expect(organization.accepted_alliances).to include(@accepted_sent, @accepted_received)
+ expect(organization.accepted_alliances).not_to include(
+ @pending_sent, @pending_received, @rejected_sent, @rejected_received
+ )
+ end
+
+ it "returns rejected alliances" do
+ expect(organization.rejected_alliances).to include(@rejected_sent, @rejected_received)
+ expect(organization.rejected_alliances).not_to include(
+ @pending_sent, @pending_received, @accepted_sent, @accepted_received
+ )
+ end
+ end
+ end
end