scinote-web/app/models/experiment.rb
2018-09-27 10:43:44 +02:00

759 lines
24 KiB
Ruby

class Experiment < ApplicationRecord
include ArchivableModel
include SearchableModel
belongs_to :project, inverse_of: :experiments, touch: true, optional: true
belongs_to :created_by,
foreign_key: :created_by_id,
class_name: 'User',
optional: true
belongs_to :last_modified_by,
foreign_key: :last_modified_by_id,
class_name: 'User',
optional: true
belongs_to :archived_by,
foreign_key: :archived_by_id, class_name: 'User', optional: true
belongs_to :restored_by,
foreign_key: :restored_by_id,
class_name: 'User',
optional: true
has_many :my_modules, inverse_of: :experiment, dependent: :destroy
has_many :active_my_modules, -> { where(archived: false) },
class_name: 'MyModule'
has_many :my_module_groups, inverse_of: :experiment, dependent: :destroy
has_many :report_elements, inverse_of: :experiment, dependent: :destroy
has_many :activities, inverse_of: :experiment
has_attached_file :workflowimg
validates_attachment :workflowimg,
content_type: { content_type: ['image/png'] },
if: :workflowimg_check
auto_strip_attributes :name, :description, nullify: false
validates :name,
length: { minimum: Constants::NAME_MIN_LENGTH,
maximum: Constants::NAME_MAX_LENGTH },
uniqueness: { scope: :project, case_sensitive: false }
validates :description, length: { maximum: Constants::TEXT_MAX_LENGTH }
validates :project, presence: true
validates :created_by, presence: true
validates :last_modified_by, presence: true
with_options if: :archived do |experiment|
experiment.validates :archived_by, presence: true
experiment.validates :archived_on, presence: true
end
scope :is_archived, ->(is_archived) { where("archived = ?", is_archived) }
def self.search(
user,
include_archived,
query = nil,
page = 1,
current_team = nil,
options = {}
)
project_ids =
Project
.search(user, include_archived, nil, Constants::SEARCH_NO_LIMIT)
.pluck(:id)
if current_team
projects_ids =
Project
.search(user,
include_archived,
nil,
1,
current_team)
.select('id')
new_query =
Experiment
.where('experiments.project_id IN (?)', projects_ids)
.where_attributes_like([:name, :description], query, options)
return include_archived ? new_query : new_query.is_archived(false)
elsif include_archived
new_query =
Experiment
.where(project: project_ids)
.where_attributes_like([:name, :description], query, options)
else
new_query =
Experiment
.is_archived(false)
.where(project: project_ids)
.where_attributes_like([:name, :description], query, options)
end
# Show all results if needed
if page == Constants::SEARCH_NO_LIMIT
new_query
else
new_query
.limit(Constants::SEARCH_LIMIT)
.offset((page - 1) * Constants::SEARCH_LIMIT)
end
end
def modules_without_group
MyModule.where(experiment_id: id)
.where(my_module_group: nil)
.where(archived: false)
end
def active_module_groups
my_module_groups.joins(:my_modules)
.where('my_modules.archived = ?', false)
.distinct
end
def active_modules
my_modules.where(archived: false)
end
def archived_modules
my_modules.where(archived: true)
end
def assigned_samples
Sample.joins(:my_modules).where(my_modules: { id: my_modules })
end
def unassigned_samples(assigned_samples)
Sample.where(team_id: team).where.not(id: assigned_samples)
end
def update_canvas(
to_archive,
to_add,
to_rename,
to_move,
to_move_groups,
to_clone,
connections,
positions,
current_user
)
cloned_modules = []
begin
with_lock do
# First, add new modules
new_ids, cloned_pairs, originals = add_modules(
to_add, to_clone, current_user
)
cloned_modules = cloned_pairs.collect { |mn, _| mn }
# Rename modules
rename_modules(to_rename)
# Add activities that modules were created
originals.each do |m|
Activity.create(type_of: :create_module,
user: current_user,
project: project,
experiment: m.experiment,
my_module: m,
message: I18n.t('activities.create_module',
user: current_user.full_name,
module: m.name))
end
# Add activities that modules were cloned
cloned_pairs.each do |mn, mo|
Activity.create(type_of: :clone_module,
project: mn.experiment.project,
experiment: mn.experiment,
my_module: mn,
user: current_user,
message: I18n.t('activities.clone_module',
user: current_user.full_name,
module_new: mn.name,
module_original: mo.name))
end
# Then, archive modules that need to be archived
archive_modules(to_archive, current_user) if to_archive.any?
# Update connections, positions & module group variables
# with actual IDs retrieved from the new modules creation
updated_to_move = {}
to_move.each do |id, value|
updated_to_move[new_ids.fetch(id, id)] = value
end
updated_to_move_groups = {}
to_move_groups.each do |ids, value|
mapped = []
ids.each do |id|
mapped << new_ids.fetch(id, id)
end
updated_to_move_groups[mapped] = value
end
updated_connections = []
connections.each do |a, b|
updated_connections << [new_ids.fetch(a, a), new_ids.fetch(b, b)]
end
updated_positions = {}
positions.each do |id, pos|
updated_positions[new_ids.fetch(id, id)] = pos
end
# Update connections
update_module_connections(updated_connections)
# Update module positions (no validation needed here)
update_module_positions(updated_positions)
# Normalize module positions
normalize_module_positions
# Finally, update module groups
update_module_groups(current_user)
# Finally move any modules to another experiment
move_modules(updated_to_move)
# Everyhing is set, now we can move any module groups
move_module_groups(updated_to_move_groups)
end
rescue ActiveRecord::ActiveRecordError,
ArgumentError,
ActiveRecord::RecordNotSaved => ex
logger.error ex.message
return false
end
true
end
# This method generate the workflow image and saves it as
# experiment attachment
def generate_workflow_img
require 'graphviz'
graph = GraphViz.new(:G,
type: :digraph,
use: :neato)
graph[:size] = '4,4'
graph.node[color: Constants::COLOR_ALTO,
style: :filled,
fontcolor: Constants::COLOR_EMPEROR,
shape: 'circle',
fontname: 'Arial',
fontsize: '16.0']
graph.edge[color: Constants::COLOR_ALTO]
label = ''
subg = {}
# Draw orphan modules
if modules_without_group
modules_without_group.each do |my_module|
graph
.subgraph(rank: 'same')
.add_nodes("Orphan-#{my_module.id}",
label: label,
pos: "#{my_module.x / 10},-#{my_module.y / 10}!")
end
end
# Draw grouped modules
if my_module_groups.many?
my_module_groups.each_with_index do |group, gindex|
subgraph_name = "cluster-#{gindex}"
subg[subgraph_name] = graph.subgraph(rank: 'same')
group.ordered_modules.each_with_index do |my_module, index|
if my_module.outputs.any?
parent = subg[subgraph_name]
.add_nodes("#{subgraph_name}-#{index}",
label: label,
pos: "#{my_module.x / 10},-#{my_module.y / 10}!")
my_module.outputs.each_with_index do |output, i|
child_mod = MyModule.find_by_id(output.input_id)
child_node = subg[subgraph_name]
.add_nodes("#{subgraph_name}-O#{child_mod.id}-#{i}",
label: label,
pos: "#{child_mod.x / 10},-#{child_mod.y / 10}!")
subg[subgraph_name].add_edges(parent, child_node)
end
elsif my_module.inputs.any?
parent = subg[subgraph_name]
.add_nodes("#{subgraph_name}-#{index}",
label: label,
pos: "#{my_module.x / 10},-#{my_module.y / 10}!")
my_module.inputs.each_with_index do |input, i|
child_mod = MyModule.find_by_id(input.output_id)
child_node = subg[subgraph_name]
.add_nodes("#{subgraph_name}-I#{child_mod.id}-#{i}",
label: label,
pos: "#{child_mod.x / 10},-#{child_mod.y / 10}!")
subg[subgraph_name].add_edges(child_node, parent)
end
end
end
end
else
my_module_groups.each do |group|
group.ordered_modules.each_with_index do |my_module, index|
if my_module.outputs.any?
parent = graph.add_nodes("N-#{index}",
label: label,
pos: "#{my_module.x / 10},-#{ my_module.y / 10}!")
my_module.outputs.each_with_index do |output, i|
child_mod = MyModule.find_by_id(output.input_id)
child_node = graph
.add_nodes("N-O#{child_mod.id}-#{i}",
label: label,
pos: "#{child_mod.x / 10},-#{child_mod.y / 10}!")
graph.add_edges(parent, child_node)
end
elsif my_module.inputs.any?
parent = graph.add_nodes("N-#{index}",
label: label,
pos: "#{my_module.x / 10},-#{my_module.y / 10}!")
my_module.inputs.each_with_index do |input, i|
child_mod = MyModule.find_by_id(input.output_id)
child_node = graph
.add_nodes("N-I#{child_mod.id}-#{i}",
label: label,
pos: "#{child_mod.x / 10},-#{child_mod.y / 10}!")
graph.add_edges(child_node, parent)
end
end
end
end
end
file_location = Tempfile.open(['wimg', '.png'],
Rails.root.join('tmp'))
graph.output(png: file_location.path)
begin
file = File.open(file_location)
self.workflowimg = file
file.close
save
touch(:workflowimg_updated_at)
rescue => ex
logger.error ex.message
end
end
# Clone this experiment to given project
def deep_clone_to_project(current_user, project)
# First we have to find unique name for our little experiment
experiment_names = project.experiments.map(&:name)
format = 'Clone %d - %s'
i = 1
i += 1 while experiment_names.include?(format(format, i, name))
clone = Experiment.new(
name: format(format, i, name).truncate(Constants::NAME_MAX_LENGTH),
description: description,
created_by: current_user,
last_modified_by: current_user,
project: project
)
# Copy all workflows
my_module_groups.each do |g|
clone.my_module_groups << g.deep_clone_to_experiment(current_user, clone)
end
# Copy modules without group
clone.my_modules << modules_without_group.map do |m|
m.deep_clone_to_experiment(current_user, clone)
end
clone.save
clone
end
def move_to_project(project)
self.project = project
my_modules.each do |m|
new_tags = []
m.tags.each do |t|
new_tags << t.deep_clone_to_project(project)
end
m.my_module_tags.destroy_all
project.tags << new_tags
m.tags << new_tags
end
result = save
touch(:workflowimg_updated_at) if result
result
end
# Get projects where user is either owner or user in the same team
# as this experiment
def projects_with_role_above_user(current_user)
team = project.team
projects = team.projects.where(archived: false)
current_user.user_projects
.where(project: projects)
.where('role < 2')
.map(&:project)
end
# Projects to which this experiment can be moved (inside the same
# team and not archived), all users assigned on experiment.project has
# to be assigned on such project
def moveable_projects(current_user)
projects = projects_with_role_above_user(current_user)
projects = projects.each_with_object([]) do |p, arr|
arr << p if (project.users - p.users).empty?
arr
end
projects - [project]
end
private
# Archive all modules. Receives an array of module integer IDs.
def archive_modules(module_ids)
my_modules.where(id: module_ids).each(&:archive!)
my_modules.reload
end
# Archive all modules. Receives an array of module integer IDs
# and current user.
def archive_modules(module_ids, current_user)
my_modules.where(id: module_ids).each do |m|
m.archive!(current_user)
end
my_modules.reload
end
# Add modules, and returns a map of "virtual" IDs with
# actual IDs of saved modules.
# to_add is an array of hashes, each containing 'name',
# 'x', 'y' and 'id'.
# to_clone is a hash, storing new cloned modules as keys,
# and original modules as values.
def add_modules(to_add, to_clone, current_user)
originals = []
cloned_pairs = {}
ids_map = {}
to_add.each do |m|
original = MyModule.find_by_id(to_clone.fetch(m[:id], nil))
if original.present?
my_module = original.deep_clone(current_user)
cloned_pairs[my_module] = original
else
my_module = MyModule.new(experiment: self)
originals << my_module
end
my_module.name = m[:name]
my_module.x = m[:x]
my_module.y = m[:y]
my_module.created_by = current_user
my_module.last_modified_by = current_user
my_module.save!
ids_map[m[:id]] = my_module.id.to_s
end
my_modules.reload
return ids_map, cloned_pairs, originals
end
# Rename modules; this method accepts a map where keys
# represent IDs of modules, and values new names for
# such modules. If a module with given ID doesn't exist,
# it's obviously not updated.
def rename_modules(to_rename)
to_rename.each do |id, new_name|
my_module = MyModule.find_by_id(id)
if my_module.present?
my_module.name = new_name
my_module.save!
end
end
end
# Move modules; this method accepts a map where keys
# represent IDs of modules, and values represent experiment
# IDs of new names to which the given modules should be moved.
# If a module with given ID doesn't exist (or experiment ID)
# it's obviously not updated. Any connection on module is destroyed.
def move_modules(to_move)
to_move.each do |id, experiment_id|
my_module = my_modules.find_by_id(id)
experiment = project.experiments.find_by_id(experiment_id)
next unless my_module.present? && experiment.present?
my_module.experiment = experiment
# Calculate new module position
new_pos = my_module.get_new_position
my_module.x = new_pos[:x]
my_module.y = new_pos[:y]
unless my_module.outputs.destroy_all && my_module.inputs.destroy_all
raise ActiveRecord::ActiveRecordError
end
my_module.save
end
# Generate workflow image for the experiment in which we moved the task
generate_workflow_img_for_moved_modules(to_move)
end
# Move module groups; this method accepts a map where keys
# represent IDs of modules which are in module group,
# and values represent experiment
# IDs of new names to which the given module group should be moved.
# If a module with given ID doesn't exist (or experiment ID)
# it's obviously not updated. Position for entire module group is updated
# to bottom left corner.
def move_module_groups(to_move)
to_move.each do |ids, experiment_id|
modules = my_modules.find(ids)
groups = Set.new(modules.map(&:my_module_group))
experiment = project.experiments.find_by_id(experiment_id)
groups.each do |group|
next unless group && experiment.present?
# Find the lowest point for current modules(max_y) and the leftmost
# module(min_x)
if experiment.active_modules.empty?
max_y = 0
min_x = 0
else
max_y = experiment.active_modules.maximum(:y) + MyModule::HEIGHT
min_x = experiment.active_modules.minimum(:x)
end
# Set new positions
curr_min_x = modules.min_by(&:x).x
curr_min_y = modules.min_by(&:y).y
modules.each { |m| m.x += -curr_min_x + min_x }
modules.each { |m| m.y += -curr_min_y + max_y }
modules.each do |m|
m.experiment = experiment
m.save!
end
group.experiment = experiment
group.save!
end
end
# Generate workflow image for the experiment in which we moved the workflow
generate_workflow_img_for_moved_modules(to_move)
end
# Generates workflow img when the workflow or module is moved
# to other experiment
def generate_workflow_img_for_moved_modules(to_move)
Experiment.where(id: to_move.values.uniq).each do |exp|
exp.delay.generate_workflow_img
end
end
# Update connections for all modules in this project.
# Input is an array of arrays, where first element represents
# source node, and second element represents target node.
# Example input: [ [1, 2], [2, 3], [4, 5], [2, 5] ]
def update_module_connections(connections)
require 'rgl/base'
require 'rgl/adjacency'
require 'rgl/topsort'
dg = RGL::DirectedAdjacencyGraph.new
connections.each do |a, b|
# Check if both vertices exist
if (my_modules.find_all { |m| [a.to_i, b.to_i].include? m.id }).count == 2
dg.add_edge(a, b)
end
end
# Check if cycles exist!
topsort = dg.topsort_iterator.to_a
if topsort.length.zero? && dg.edges.size > 1
raise ArgumentError, 'Cycles exist.'
end
# First, delete existing connections
# but keep a copy of previous state
previous_sources = {}
previous_sources.default = []
my_modules.includes(inputs: { from: [:inputs, outputs: :to] }).each do |m|
previous_sources[m.id] = []
m.inputs.each do |c|
previous_sources[m.id] << c.from
end
end
# There are no callbacks in Connection, so delete_all should be safe
Connection.where(output_id: my_modules).delete_all
# Add new connections
filtered_edges = dg.edges.collect { |e| [e.source, e.target] }
filtered_edges.each do |a, b|
Connection.create!(input_id: b, output_id: a)
end
# Unassign samples from former downstream modules
# for all destroyed connections
unassign_samples_from_old_downstream_modules(previous_sources)
visited = []
# Assign samples to all new downstream modules
filtered_edges.each do |a, b|
source = my_modules.includes({ inputs: :from }, :samples).find(a.to_i)
target = my_modules.find(b.to_i)
# Do this only for new edges
next unless previous_sources[target.id].exclude?(source)
# Go as high upstream as new edges take us
# and then assign samples to all downsteam samples
assign_samples_to_new_downstream_modules(previous_sources,
visited,
source)
end
# Save topological order of modules (for modules without workflow,
# leave them unordered)
my_modules.includes(:my_module_group).each do |m|
m.workflow_order =
if topsort.include? m.id.to_s
topsort.find_index(m.id.to_s)
else
-1
end
m.save!
end
# Make sure to reload my modules, which now have updated connections
# and samples
my_modules.reload
true
end
# When connections are deleted, unassign samples that
# are not inherited anymore
def unassign_samples_from_old_downstream_modules(sources)
my_modules.each do |my_module|
sources[my_module.id].each do |src|
# Only do this for newly deleted connections
next unless src.outputs.map(&:to).exclude? my_module
my_module.downstream_modules.each do |dm|
# Get unique samples for all upstream modules
um = dm.upstream_modules
um.shift # remove current module
ums = um.map(&:samples).flatten.uniq
src.samples.find_each do |sample|
dm.samples.destroy(sample) if ums.exclude? sample
end
end
end
end
end
# Assign samples to new connections recursively
def assign_samples_to_new_downstream_modules(sources, visited, my_module)
# If samples are already assigned for this module, stop going upstream
return if visited.include?(my_module)
visited << my_module
# Edge case, when module is source or it doesn't have any new input
# connections
if my_module.inputs.blank? ||
(my_module.inputs.map(&:from) - sources[my_module.id]).empty?
my_module.downstream_modules.each do |dm|
new_samples = my_module.samples.where.not(id: dm.samples)
dm.samples << new_samples
end
else
my_module.inputs.each do |input|
# Go upstream for new in connections
if sources[my_module.id].exclude?(input.from)
assign_samples_to_new_downstream_modules(sources, visited, input.from)
end
end
end
end
# Updates positions of modules.
# Input is a map where keys are module IDs, and values are
# hashes like { x: <x>, y: <y> }.
def update_module_positions(positions)
modules = my_modules.where(id: positions.keys)
modules.each do |m|
m.update_columns(x: positions[m.id.to_s][:x], y: positions[m.id.to_s][:y])
end
my_modules.reload
end
# Normalize module positions in this project.
def normalize_module_positions
# This method normalizes module positions so x-s and y-s
# are all positive
x_diff = my_modules.pluck(:x).min
y_diff = my_modules.pluck(:y).min
my_modules.each do |m|
m.update_columns(x: m.x - x_diff, y: m.y - y_diff)
end
end
# Recalculate module groups in this project. Input is
# a hash of module ids and their corresponding module names.
def update_module_groups(current_user)
require 'rgl/base'
require 'rgl/adjacency'
require 'rgl/connected_components'
dg = RGL::DirectedAdjacencyGraph[]
group_ids = Set.new
active_modules.includes(:my_module_group, outputs: :to).each do |m|
group_ids << m.my_module_group.id unless m.my_module_group.blank?
dg.add_vertex m.id unless dg.has_vertex? m.id
m.outputs.each do |o|
dg.add_edge m.id, o.to.id
end
end
workflows = []
dg.to_undirected.each_connected_component do |w|
workflows << my_modules.find(w)
end
# Remove any existing module groups from modules
unless MyModuleGroup.where(id: group_ids.to_a).destroy_all
raise ActiveRecord::ActiveRecordError
end
# Second, create new groups
workflows.each do |modules|
# Single modules are not considered part of any workflow
next unless modules.length > 1
MyModuleGroup.create!(experiment: self,
my_modules: modules,
created_by: current_user)
end
my_module_groups.reload
true
end
def workflowimg_check
workflowimg_content_type
rescue
false
end
end