Skip to content
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions app/assets/stylesheets/projects.scss
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,39 @@
}
}

.related_project {
summary {
margin-bottom: 8px;
}

dl {
dt {
display: inline;
}

dt::after {
content: ':'
}

dd {
display: inline;
}
}
}

.project [data-controller="pdf-import"] {
margin-bottom: 20px;
}

.project {
td.no-overflow {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
max-width: 200px;
}
}

#projects-panel [data-controller="pdf-import"] {
margin-bottom: 0px;
}
Expand Down
81 changes: 81 additions & 0 deletions app/controllers/project_relationships_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# RESTfully manages creating/deleting `ProjectRelationship`s
class ProjectRelationshipsController < ApplicationController
load_and_authorize_resource :project
load_and_authorize_resource :team, through: :project, singleton: true
load_and_authorize_resource through: :project, only: %i[create destroy]

def index
authorize!(:read, ProjectRelationship)

@projects = @team.projects.
accessible_by(current_ability).
where.not(id: @project).
search(search_params).
order_by_reference.
includes(:project_type, :owner, :current_state).
paginate(page: params[:page], per_page: 10)
end

def create
@other_project = @team.projects.find_by(id: resource_params[:right_project_id])

@project_relationship.assign_attributes(
left_project: @project,
right_project: @other_project
)

if @project_relationship.save
respond_to do |format|
format.html { redirect_to after_action_path, notice: t('.success') }
format.js
end
else
respond_to do |format|
format.html { redirect_to after_action_path, alert: t('.failure') }
format.js
end
end
end

def destroy
@other_project = @project_relationship.projects.detect { |project| project != @project }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More of a question than anything - would where.not(id: @project.id) be more efficient?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good spot, and you're absolutely right.

It's still one DB hit, but the difference is in loading one record into memory rather than two. There are times when relying on the preloaded/cached association would be beneficial but this isn't one of those instances :D

This code was a remnant from when associations weren't as fleshed out as they are now and project_relationships.projects was just an enumerable wrapper around the two belongs_to associations.

I'll push a commit with your suggested change 👍


if @project_relationship.destroy
respond_to do |format|
format.html { redirect_to after_action_path, notice: t('.success') }
format.js
end
else
respond_to do |format|
format.html { redirect_to after_action_path, alert: t('.failure') }
format.js
end
end
end

private

def resource_params
params.require(:project_relationship).permit(:right_project_id)
end

def search_params
params.fetch(:search, {}).permit(
:name,
:application_log,
project_type_id: [],
owner: %i[
first_name
last_name
],
current_project_state: {
state_id: []
}
)
end
helper_method :search_params

def after_action_path
project_project_relationships_path(@project, search: search_params)
end
end
20 changes: 20 additions & 0 deletions app/controllers/related_projects_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Endpoint for fetching the related projects for a given resource.
class RelatedProjectsController < ApplicationController
load_and_authorize_resource :project

def index
edges = @project.project_edges.transitive_closure
projects = Project.from(Project.accessible_by(current_ability), :projects).
where(id: edges.select(:related_project_id)).
order_by_reference.
includes(:project_type, :current_state)

locals = {
projects: projects
}

respond_to do |format|
format.html { render partial: 'projects', locals: locals, content_type: :html }
end
end
end
1 change: 1 addition & 0 deletions app/models/ability.rb
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ def odr_grants(user)
]

can %i[assign import], Project
can :manage, ProjectRelationship

# Senior ODR users have additonal powers...
return unless user.odr?
Expand Down
45 changes: 45 additions & 0 deletions app/models/project.rb
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,32 @@ class Project < ApplicationRecord
belongs_to :team, optional: true
belongs_to :closure_reason, class_name: 'Lookups::ClosureReason', optional: true

# These two relationships are merely used to track the source of a cloned project.
# Actual relationships between projects are tracked via other sets of associations.
belongs_to :parent, class_name: 'Project', foreign_key: :clone_of, optional: true
has_many :children, class_name: 'Project', foreign_key: :clone_of

# Contrary to the above, projects may actually be arbitrarily related to other projects.
# This takes the form of an undirected graph and the following associations are the underlying
# records/models for linking projects together.
with_options class_name: 'ProjectRelationship', dependent: :destroy do
has_many :left_relationships, foreign_key: :left_project_id, inverse_of: :left_project
has_many :right_relationships, foreign_key: :right_project_id, inverse_of: :right_project
end

# These associations make navigating related projects easier. They should not be used to
# actually link projects together; that's down to the `ProjectRelationship` based associations.
has_many :project_edges, inverse_of: :project do # rubocop:disable Rails/HasManyOrHasOneDependent
def transitive_closure(**options)
transitive_closure_for(proxy_association.owner, **options)
end
end

has_many :related_projects, through: :project_edges, source: :related_project

# If you really want them... :shrug:
has_many :project_relationships, through: :project_edges

# The `assigned_user` will generally be an ODR representative responsible for the project
belongs_to :assigned_user, class_name: 'User', inverse_of: :assigned_projects, optional: true

Expand Down Expand Up @@ -171,6 +194,19 @@ class Project < ApplicationRecord
joins(:project_type).merge(ProjectType.cas)
}

scope :order_by_reference, lambda {
year, month = %i[year month].map do |part|
Arel::Nodes::NamedFunction.new(
'date_part', [Arel::Nodes.build_quoted(part), arel_table[:first_contact_date]]
)
end

offset = Arel::Nodes::Case.new.when(month.gt(3)).then(1).else(0)

# TODO: `.nulls_last` added to Arel in Rails 6.1
order(Arel.sql((year + offset).desc.to_sql << ' NULLS LAST'), id: :desc)
}

accepts_nested_attributes_for :project_attachments

after_transition_to :status_change_notifier
Expand Down Expand Up @@ -245,6 +281,15 @@ def application_log_filter(_field, value)
end
end

# NOTE: Taking the not-so-obvious route here so that we take advantage of using in-memory
# associations rather than hitting the db each time.
def relationship_to(other)
project_relationships.find do |relationship|
(relationship.left_project_id == id && relationship.right_project_id == other.id) ||
(relationship.right_project_id == id && relationship.left_project_id == other.id)
end
end

def organisation_name
super || organisation&.name
end
Expand Down
76 changes: 76 additions & 0 deletions app/models/project_edge.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Helper class built upon a view over the `project_relationships` table, which pivots those records
# into something a little easier to query because:
# - managing a join model is easy.
# - querying both sides of a join table because a resource may exist on either FK is hard.
# - querying an adjaceny list is easy.
# - managing adjaceny lists for inverse pairs of records via ActiveRecord callbacks is like
# sticking your head inside an aligator's mouth; it's all good until it inevitably bites.
class ProjectEdge < ApplicationRecord
belongs_to :project_relationship
belongs_to :project
belongs_to :related_project, class_name: 'Project'

class << self
# Returns an expanded set of `ProjectEdge`s for `project`, containing edges for both directly
# related projects and those that are indirectly related through one or more intermediates.
# Related projects may be returned multiple times if it can be reached via many different
# paths. Passing the `distinct` option with a truthy value will limit the returned set to only
# include unique related projects (selected by the shortest path).
#
# NOTE: This can be a costly query and as such may be a performance problem area. Will probably
# want to see/capture some stats from usage in the wild...
def transitive_closure_for(project, **options)
from(<<~SQL.squish)
(
#{transitive_closure_cte(project)}
#{transitive_closure_query(distinct: options[:distinct])}
) #{quoted_table_name}
SQL
end

private

def transitive_closure_cte(project)
sanitize_sql([<<~SQL.squish, project.id])
WITH RECURSIVE transitive_closure(project_id, related_project_id, distance, path) AS (
SELECT project_id
, related_project_id
, 1 AS distance
, ARRAY[project_id, related_project_id] AS path
FROM project_edges
WHERE project_id = ?

UNION ALL

SELECT t.project_id
, e.related_project_id
, t.distance + 1
, t.path || e.related_project_id AS path
FROM project_edges e
JOIN transitive_closure t on e.project_id = t.related_project_id
WHERE NOT t.path && ARRAY[e.related_project_id]
)
SQL
end

def transitive_closure_query(distinct: false)
if distinct
<<~SQL.squish
SELECT DISTINCT ON (related_project_id) *
FROM transitive_closure
ORDER BY related_project_id, distance
SQL
else
<<~SQL.squish
SELECT *
FROM transitive_closure
SQL
end
end
end

# This model is backed by a database view.
def readonly?
true
end
end
41 changes: 41 additions & 0 deletions app/models/project_relationship.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Join model representing some relationship between two `Project`s.
class ProjectRelationship < ApplicationRecord
with_options class_name: 'Project' do
belongs_to :left_project
belongs_to :right_project
end

has_many :project_edges, inverse_of: :project_relationship # rubocop:disable Rails/HasManyOrHasOneDependent
has_many :projects, through: :project_edges, source: :related_project

validate :no_inverse_pairs
validate :no_self_referential_relationships

private

def no_inverse_pairs
return unless left_project_id && right_project_id

return unless
self.class.where(
left_project_id: left_project_id,
right_project_id: right_project_id
).
or(
self.class.where(
left_project_id: right_project_id,
right_project_id: left_project_id
)
).
exists?

errors.add(:base, :taken)
end

def no_self_referential_relationships
return unless left_project_id && right_project_id
return if left_project_id != right_project_id

errors.add(:base, :self_referential)
end
end
24 changes: 24 additions & 0 deletions app/views/project_relationships/_project.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<%
relationship = subject.relationship_to(project)
%>

<%= content_tag(:tr, class: dom_class(project), id: dom_id(project)) do %>
<td><%= project_type_label(project) %></td>
<td><%= project.application_log %></td>
<%= tag.td(project.name, class: :'no-overflow', title: project.name) %>
<%= tag.td(project.owner_full_name, class: :'no-overflow', title: project.owner_full_name) %>
<td><%= project_status_label(project) %></td>
<td>
<% if relationship %>
<%= delete_link(relationship, path: polymorphic_path([subject, relationship], search: search_params), title: 'Remove', text: true, icon: :'minus-sign', class: 'btn btn-danger btn-xs btn-block', remote: true, data: { disable: true }) %>
<% else %>
<%= form_with(url: project_project_relationships_path(subject, search: search_params), scope: :project_relationship, local: false) do |form| %>
<%= form.hidden_field(:right_project_id, value: project.id) %>
<button type="submit" class="btn btn-primary btn-xs btn-block" data-disable="true">
<%= bootstrap_icon_tag(:'plus-sign') %>
Add
</button>
<% end %>
<% end %>
</td>
<% end %>
5 changes: 5 additions & 0 deletions app/views/project_relationships/create.js.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<%
html = render 'project', project: @other_project, subject: @project
%>

$('tr#<%= j(dom_id(@other_project)) %>').replaceWith('<%= j(html) %>');
5 changes: 5 additions & 0 deletions app/views/project_relationships/destroy.js.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<%
html = render 'project', project: @other_project, subject: @project
%>

$('tr#<%= j(dom_id(@other_project)) %>').replaceWith('<%= j(html) %>');
Loading