# frozen_string_literal: true
# The central model object within this domain. Represents a Data Management
# Plan for a research project.
#
# == Schema Information
#
# Table name: plans
#
# id :integer not null, primary key
# complete :boolean default(FALSE)
# data_contact :string
# data_contact_email :string
# data_contact_phone :string
# description :text
# feedback_requested :boolean default(FALSE)
# funder_name :string
# grant_number :string
# identifier :string
# principal_investigator :string
# principal_investigator_email :string
# principal_investigator_identifier :string
# principal_investigator_phone :string
# title :string
# visibility :integer default(3), not null
# created_at :datetime
# updated_at :datetime
# template_id :integer
#
# Indexes
#
# index_plans_on_template_id (template_id)
#
# Foreign Keys
#
# fk_rails_... (template_id => templates.id)
#
class Plan < ActiveRecord::Base
include ConditionalUserMailer
include ExportablePlan
include ValidationMessages
include ValidationValues
# =============
# = Constants =
# =============
# Returns visibility message given a Symbol type visibility passed, otherwise
# nil
VISIBILITY_MESSAGE = {
organisationally_visible: _('organisational'),
publicly_visible: _('public'),
is_test: _('test'),
privately_visible: _('private')
}
# ==============
# = Attributes =
# ==============
# public is a Ruby keyword so using publicly
enum visibility: %i[organisationally_visible publicly_visible
is_test privately_visible]
alias_attribute :name, :title
# ================
# = Associations =
# ================
belongs_to :template
has_many :phases, through: :template
has_many :sections, through: :phases
has_many :questions, through: :sections
has_many :themes, through: :questions
has_many :guidances, through: :themes
has_many :guidance_group_options, -> { GuidanceGroup.published },
through: :guidances,
source: :guidance_group,
class_name: "GuidanceGroup"
has_many :answers, dependent: :destroy
has_many :notes, through: :answers
has_many :roles, dependent: :destroy
has_many :users, through: :roles
has_and_belongs_to_many :guidance_groups, join_table: :plans_guidance_groups
has_many :exported_plans
has_many :roles
# =====================
# = Nested Attributes =
# =====================
accepts_nested_attributes_for :template
accepts_nested_attributes_for :roles
# ===============
# = Validations =
# ===============
validates :title, presence: { message: PRESENCE_MESSAGE }
validates :template, presence: { message: PRESENCE_MESSAGE }
validates :feedback_requested, inclusion: { in: BOOLEAN_VALUES }
validates :complete, inclusion: { in: BOOLEAN_VALUES }
# =============
# = Callbacks =
# =============
before_validation :set_creation_defaults
# ==========
# = Scopes =
# ==========
# Retrieves any plan in which the user has an active role and it is not a
# reviewer
scope :active, lambda { |user|
includes(%i[template roles])
.where("roles.active": true, "roles.user_id": user.id)
.where(Role.not_reviewer_condition)
}
# Retrieves any plan organisationally or publicly visible for a given org id
scope :organisationally_or_publicly_visible, -> (user) {
includes(:template, {roles: :user})
.where({
visibility: [visibilities[:organisationally_visible], visibilities[:publicly_visible]],
"roles.access": Role.access_values_for(:creator, :administrator, :editor, :commenter).min,
"users.org_id": user.org_id})
.where(['NOT EXISTS (SELECT 1 FROM roles WHERE plan_id = plans.id AND user_id = ?)', user.id])
}
scope :search, lambda { |term|
search_pattern = "%#{term}%"
joins(:template)
.where("plans.title LIKE ? OR templates.title LIKE ?",
search_pattern, search_pattern)
}
# Retrieves plan, template, org, phases, sections and questions
scope :overview, lambda { |id|
includes(:phases, :sections, :questions, template: [:org]).find(id)
}
##
# Settings for the template
has_settings :export, class_name: 'Settings::Template' do |s|
s.key :export, defaults: Settings::Template::DEFAULT_SETTINGS
end
alias super_settings settings
# =================
# = Class methods =
# =================
# Pre-fetched a plan phase together with its sections and questions
# associated. It also pre-fetches the answers and notes associated to the plan
def self.load_for_phase(plan_id, phase_id)
# Preserves the default order defined in the model relationships
plan = Plan.joins(template: { phases: { sections: :questions } })
.preload(template: { phases: { sections: :questions } })
.where(id: plan_id, phases: { id: phase_id })
.merge(Plan.includes(answers: :notes)).first
phase = plan.template.phases.find { |p| p.id == phase_id.to_i }
[plan, phase]
end
# deep copy the given plan and all of it's associations
#
# plan - Plan to be deep copied
#
# Returns Plan
def self.deep_copy(plan)
plan_copy = plan.dup
plan_copy.title = "Copy of " + plan.title
plan_copy.save!
plan.answers.each do |answer|
answer_copy = Answer.deep_copy(answer)
answer_copy.plan_id = plan_copy.id
answer_copy.save!
end
plan.guidance_groups.each do |guidance_group|
plan_copy.guidance_groups << guidance_group if guidance_group.present?
end
plan_copy
end
# ===========================
# = Public instance methods =
# ===========================
##
# Proxy through to the template settings (or defaults if this plan doesn't
# have an associated template) if there are no settings stored for this plan.
#
# TODO: Update this comment below. AFAIK `key` has nothing to do with Rails.
# key - Is required by rails-settings, so it's required here, too.
#
# Returns Hash
def settings(key)
self_settings = super_settings(key)
return self_settings if self_settings.value?
template&.settings(key)
end
# The most recent answer to the given question id optionally can create an answer if
# none exists.
#
# qid - The id for the question to find the answer for
# create_if_missing - If true, will genereate a default answer
# to the question (defaults: true).
#
# Returns Answer
# Returns nil
def answer(qid, create_if_missing = true)
answer = answers.where(question_id: qid).order("created_at DESC").first
question = Question.find(qid)
if answer.nil? && create_if_missing
answer = Answer.new
answer.plan_id = id
answer.question_id = qid
answer.text = question.default_value
default_options = []
question.question_options.each do |option|
default_options << option if option.is_default
end
answer.question_options = default_options
end
answer
end
alias get_guidance_group_options guidance_group_options
deprecate :get_guidance_group_options,
deprecator: Cleanup::Deprecators::GetDeprecator.new
##
# Sets up the plan for feedback:
# emails confirmation messages to owners
# emails org admins and org contact
# adds org admins to plan with the 'reviewer' Role
def request_feedback(user)
Plan.transaction do
begin
val = Role.access_values_for(:reviewer, :commenter).min
self.feedback_requested = true
# Share the plan with each org admin as the reviewer role
admins = user.org.org_admins
admins.each do |admin|
roles << Role.new(user: admin, access: val)
end
if save!
# Send an email to the org-admin contact
if user.org.contact_email.present?
contact = User.new(email: user.org.contact_email,
firstname: user.org.contact_name)
UserMailer.feedback_notification(contact, self, user).deliver_now
end
true
else
puts "save was false"
false
end
rescue Exception => e
Rails.logger.error e
false
end
end
end
##
# Finalizes the feedback for the plan: Emails confirmation messages to owners
# sets flag on plans.feedback_requested to false removes org admins from the
# 'reviewer' Role for the Plan.
def complete_feedback(org_admin)
Plan.transaction do
begin
self.feedback_requested = false
# Remove the org admins reviewer role from the plan
vals = Role.access_values_for(:reviewer)
roles.delete(Role.where(plan: self, access: vals))
if save!
# Send an email confirmation to the owners and co-owners
owners = User.joins(:roles)
.where('roles.plan_id =? AND roles.access IN (?)',
id, Role.access_values_for(:administrator))
deliver_if(recipients: owners, key: 'users.feedback_provided') do |r|
UserMailer.feedback_complete(r, self, org_admin).deliver_now
end
true
else
false
end
rescue ArgumentError => e
Rails.logger.error e
false
end
end
end
##
# determines if the plan is editable by the specified user
#
# user_id - The id for a user
#
# Returns Boolean
def editable_by?(user_id)
user_id = user_id.id if user_id.is_a?(User)
role?(user_id, :editor)
end
##
# determines if the plan is readable by the specified user
#
# user_id - The Integer id for a user
#
# Returns Boolean
def readable_by?(user_id)
user = user_id.is_a?(User) ? user_id : User.find(user_id)
owner_orgs = owner_and_coowners.collect(&:org)
sys_permission = Branding.fetch(:service_configuration, :plans,
:org_admins_read_all)
# Super Admins can view plans read-only
return true if user.can_super_admin?
# Org Admins can view their Org's plans if system permission allows
return true if user.can_org_admin? && owner_orgs.include?(user.org) &&
sys_permission
# ...otherwise the user must have the commenter role.
return true if role?(user.id, :commenter)
# Else
false
end
# determines if the plan is readable by the specified user.
#
# user_id - The Integer id for a user
#
# Returns Boolean
def commentable_by?(user_id)
user_id = user_id.id if user_id.is_a?(User)
role?(user_id, :commenter)
end
# determines if the plan is administerable by the specified user
#
# user_id - The Integer id for the user
#
# Returns Boolean
def administerable_by?(user_id)
user_id = user_id.id if user_id.is_a?(User)
role?(user_id, :administrator)
end
# determines if the plan is reviewable by the specified user
#
# user_id - The Integer id for the user
#
# Returns Boolean
def reviewable_by?(user_id)
user_id = user_id.id if user_id.is_a?(User)
role?(user_id, :reviewer)
end
# Assigns the passed user_id to the creater_role for the project gives the
# user rights to read, edit, administrate, and defines them as creator
#
# user_id - The Integer user to be given priveleges' id
#
def assign_creator(user_id)
user_id = user_id.id if user_id.is_a?(User)
add_user(user_id, true, true, true)
end
# the datetime for the latest update of this plan
#
# Returns DateTime
def latest_update
(phases.pluck(:updated_at) + [updated_at]).max
end
# the owner of the project
#
# Returns User
# Returns nil
def owner
vals = Role.access_values_for(:creator)
User.joins(:roles)
.where('roles.plan_id = ? AND roles.access IN (?)', id, vals).first
end
##
# TODO: Rewrite this description
#
# Returns Boolean
def shared?
roles.where(Role.not_creator_condition).any?
end
alias shared shared?
deprecate :shared, deprecator: Cleanup::Deprecators::PredicateDeprecator.new
# the owner and co-owners of the project
#
# Returns ActiveRecord::Relation
def owner_and_coowners
vals = Role.access_values_for(:creator)
.concat(Role.access_values_for(:administrator))
User.joins(:roles)
.where(roles: { plan_id: id, access: vals })
end
# The number of answered questions from the entire plan
#
# Returns Integer
def num_answered_questions
Answer.where(id: answers.map(&:id))
.includes(:question_options, { question: :question_format })
.to_a
.sum { |answer| answer.is_valid? ? 1 : 0 }
end
# The number of questions for a plan.
#
# Returns Integer
def num_questions
questions.count
end
# Determines whether or not visibility changes are permitted according to the
# percentage of the plan answered in respect to a threshold defined at
# application.config
#
# Returns Boolean
def visibility_allowed?
value=(num_answered_questions.to_f/num_questions*100).round(2)
!is_test? && value >= Rails.application
.config
.default_plan_percentage_answered
end
# Determines whether or not a question (given its id) exists for the self plan
#
# Returns Boolean
def question_exists?(question_id)
Plan.joins(:questions).exists?(id: id, "questions.id": question_id)
end
# Checks whether or not the number of questions matches the number of valid
# answers
#
# Returns Boolean
def no_questions_matches_no_answers?
num_questions = question_ids.length
pre_fetched_answers = Answer.includes(:question_options,
question: :question_format)
.where(id: answer_ids)
num_answers = pre_fetched_answers.reduce(0) do |m, a|
m+=1 if a.is_valid?
m
end
num_questions == num_answers
end
private
# Returns whether or not the user has the specified role for the plan
def role?(user_id, role_as_sym)
if user_id.is_a?(Integer) && role_as_sym.is_a?(Symbol)
vals = Role.access_values_for(role_as_sym)
roles.where(user_id: user_id, access: vals, active: true).any?
else
false
end
end
alias has_role role?
deprecate :has_role, deprecator: Cleanup::Deprecators::PredicateDeprecator.new
# Adds a user to the project if no flags are specified, the user is given read privleges
# TODO: change this to specifying uniqueness of user/plan association and
# handle that way.
#
#
# user_id - The Integer user ID to be given privleges
# is_editor - Whether or not the user can edit the project (defaults: false)
# is_administrator - Whether or not the user can administrate the project
# (defaults: false)
# is_creator - Wheter or not the user created the project (defaults: false)
#
# Returns Boolean
#
def add_user(user_id, is_editor = false,
is_administrator = false,
is_creator = false)
Role.where(plan_id: id, user_id: user_id).find_each(&:destroy)
role = Role.new
role.user_id = user_id
role.plan_id = id
# if you get assigned a role you can comment
role.commenter = true
# the rest of the roles are inclusing so creator => administrator => editor
if is_creator
role.creator = true
role.administrator = true
role.editor = true
end
if is_administrator
role.administrator = true
role.editor = true
end
role.editor = true if is_editor
role.save
end
# Initialize the title for new templates
#
# Returns nil
# Returns String
def set_creation_defaults
# Only run this before_validation because rails fires this before
# save/create
return if id?
self.title = "My plan (#{template.title})" if title.nil? && !template.nil?
end
end