class Plan < ActiveRecord::Base attr_accessible :locked, :project_id, :version_id, :version, :plan_sections, :as => [:default, :admin] A4_PAGE_HEIGHT = 297 #(in mm) A4_PAGE_WIDTH = 210 #(in mm) ROUNDING = 5 #round estimate up to nearest 5% FONT_HEIGHT_CONVERSION_FACTOR = 0.35278 #convert font point size to mm FONT_WIDTH_HEIGHT_RATIO = 0.4 #Assume glyph width averages 2/5 the height #associations between tables belongs_to :project belongs_to :version has_many :answers has_many :plan_sections # accepts_nested_attributes_for :project accepts_nested_attributes_for :answers # accepts_nested_attributes_for :version has_settings :export, class_name: 'Settings::Dmptemplate' do |s| s.key :export, defaults: Settings::Dmptemplate::DEFAULT_SETTINGS end alias_method :super_settings, :settings ## # 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. # `key` is required by rails-settings, so it's required here, too. # # @param key [Key] a key required by rails # @return [Settings] settings for this plan's template def settings(key) self_settings = self.super_settings(key) return self_settings if self_settings.value? self.dmptemplate.settings(key) end ## # returns the template for this plan, or generates an empty template and returns that # # @return [Dmptemplate] the template associated with this plan def dmptemplate self.project.try(:dmptemplate) || Dmptemplate.new end ## # returns the title for this project as defined by the settings # # @return [String] the title for this project def title logger.debug "Title in settings: #{self.settings(:export).title}" if self.settings(:export).title == "" if !self.version.nil? && !self.version.phase.nil? && !self.version.phase.title? then return self.version.phase.title else return I18n.t('tool_title2') end else return self.settings(:export).title end end ## # returns the most recent answer to the given question id # optionally can create an answer if none exists # # @param qid [Integer] the id for the question to find the answer for # @param create_if_missing [Boolean] if true, will genereate a default answer to the question # @return [Answer,nil] the most recent answer to the question, or a new question with default value, or 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 then answer = Answer.new answer.plan_id = id answer.question_id = qid answer.text = question.default_value default_options = Array.new question.options.each do |option| if option.is_default default_options << option end end answer.options = default_options end return answer end ## # returns all of the sections for this version of the plan, and for the project's organisation # # @return [Array<Section>,nil] either a list of sections, or nil if none were found def sections unless project.organisation.nil? then sections = version.global_sections + project.organisation.all_sections(version_id) else sections = version.global_sections end return sections.uniq.sort_by &:number end ## # returns the guidances associated with the project's organisation, for a specified question # # @params question [Question] the question to find guidance for # @return [Array<Guidance>] the list of guidances which pretain to the specified question def guidance_for_question(question) guidances = {} # If project org isn't nil, get guidance by theme from any "non-subset" groups belonging to project org unless project.organisation.nil? then project.organisation.guidance_groups.each do |group| if !group.optional_subset && (group.dmptemplates.pluck(:id).include?(project.dmptemplate_id) || group.dmptemplates.count == 0) then group.guidances.each do |guidance| guidance.themes.where("id IN (?)", question.theme_ids).each do |theme| guidances = self.add_guidance_to_array(guidances, group, theme, guidance) end end end end end # Get guidance by theme from any guidance groups selected on creation project.guidance_groups.each do |group| if group.dmptemplates.pluck(:id).include?(project.dmptemplate_id) || group.dmptemplates.count == 0 then group.guidances.each do |guidance| guidance.themes.where("id IN (?)", question.theme_ids).each do |theme| guidances = self.add_guidance_to_array(guidances, group, theme, guidance) end end end end # Get guidance by question where guidance group was selected on creation or if group is organisation default question.guidances.each do |guidance| guidance.guidance_groups.each do |group| if (group.organisation == project.organisation && !group.optional_subset) || project.guidance_groups.include?(group) then guidances = self.add_guidance_to_array(guidances, group, nil, guidance) end end end return guidances end ## # adds the given guidance to a hash indexed by a passed guidance group and theme # # @param guidance_array [{GuidanceGroup => {Theme => Array<Gudiance>}}] the passed hash of arrays of guidances. Indexed by GuidanceGroup and Theme. # @param guidance_group [GuidanceGroup] the guidance_group index of the hash # @param theme [Theme] the theme object for the GuidanceGroup # @param guidance [Guidance] the guidance object to be appended to the correct section of the array # @return [{GuidanceGroup => {Theme => Array<Guidance>}}] the updated object which was passed in def add_guidance_to_array(guidance_array, guidance_group, theme, guidance) if guidance_array[guidance_group].nil? then guidance_array[guidance_group] = {} end if theme.nil? then if guidance_array[guidance_group]["no_theme"].nil? then guidance_array[guidance_group]["no_theme"] = [] end if !guidance_array[guidance_group]["no_theme"].include?(guidance) then guidance_array[guidance_group]["no_theme"].push(guidance) end else if guidance_array[guidance_group][theme].nil? then guidance_array[guidance_group][theme] = [] end if !guidance_array[guidance_group][theme].include?(guidance) then guidance_array[guidance_group][theme].push(guidance) end end return guidance_array end ## # finds the specified warning for the plan's project's organisation # # @param option_id [Integer] the id to find the OptionWarning associated # @return [OptionWarning] the desired OptionWarning def warning(option_id) if project.organisation.nil? return nil else return project.organisation.warning(option_id) end end ## # determines if the plan is editable by the specified user # NOTE: This should be renamed to editable_by? # # @param user_id [Integer] the id for a user # @return [Boolean] true if user can edit the plan def editable_by(user_id) return project.editable_by(user_id) end ## # determines if the plan is readable by the specified user # NOTE: This shoudl be renamed to readable_by? # # @param user_id [Integer] the id for a user # @return [Boolean] true if the user can read the plan def readable_by(user_id) if project.nil? return false else return project.readable_by(user_id) end end ## # determines if the plan is administerable by the specified user # NOTE: This should be renamed to administerable_by? # # @param user_id [Integer] the id for the user # @return [Boolean] true if the user can administer the plan def administerable_by(user_id) return project.readable_by(user_id) end ## # defines and returns the status of the plan # status consists of a hash of the num_questions, num_answers, sections, questions, and spaced used. # For each section, it contains theid's of each of the questions # for each question, it contains the answer_id, answer_created_by, answer_text, answer_options_id, aand answered_by # # @return [Status] def status status = { "num_questions" => 0, "num_answers" => 0, "sections" => {}, "questions" => {}, "space_used" => 0 # percentage of available space in pdf used } space_used = height_of_text(self.project.title, 2, 2) sections.each do |s| space_used += height_of_text(s.title, 1, 1) section_questions = 0 section_answers = 0 status["sections"][s.id] = {} status["sections"][s.id]["questions"] = Array.new s.questions.each do |q| status["num_questions"] += 1 section_questions += 1 status["sections"][s.id]["questions"] << q.id status["questions"][q.id] = {} answer = answer(q.id, false) space_used += height_of_text(q.text) unless q.text == s.title space_used += height_of_text(answer.try(:text) || I18n.t('helpers.plan.export.pdf.question_not_answered')) if ! answer.nil? then status["questions"][q.id] = { "answer_id" => answer.id, "answer_created_at" => answer.created_at.to_i, "answer_text" => answer.text, "answer_option_ids" => answer.option_ids, "answered_by" => answer.user.name } q_format = q.question_format status["num_answers"] += 1 if (q_format.title == I18n.t("helpers.checkbox") || q_format.title == I18n.t("helpers.multi_select_box") || q_format.title == I18n.t("helpers.radio_buttons") || q_format.title == I18n.t("helpers.dropdown")) || answer.text.present? section_answers += 1 #TODO: include selected options in space estimate else status["questions"][q.id] = { "answer_id" => nil, "answer_created_at" => nil, "answer_text" => nil, "answer_option_ids" => nil, "answered_by" => nil } end status["sections"][s.id]["num_questions"] = section_questions status["sections"][s.id]["num_answers"] = section_answers end end status['space_used'] = estimate_space_used(space_used) return status end ## # defines and returns the details for the plan # details consists of a hash of: project_title, phase_title, and for each section, # section: title, question text for each question, answer type and answer value # # @return [Details] def details details = { "project_title" => project.title, "phase_title" => version.phase.title, "sections" => {} } sections.sort_by(&:"number").each do |s| details["sections"][s.number] = {} details["sections"][s.number]["title"] = s.title details["sections"][s.number]["questions"] = {} s.questions.order("number").each do |q| details["sections"][s.number]["questions"][q.number] = {} details["sections"][s.number]["questions"][q.number]["question_text"] = q.text answer = answer(q.id, false) if ! answer.nil? then q_format = q.question_format if (q_format.title == t("helpers.checkbox") || q_format.title == t("helpers.multi_select_box") || q_format.title == t("helpers.radio_buttons") || q_format.title == t("helpers.dropdown")) then details["sections"][s.number]["questions"][q.number]["selections"] = {} answer.options.each do |o| details["sections"][s.number]["questions"][q.number]["selections"][o.number] = o.text end end details["sections"][s.number]["questions"][q.number]["answer_text"] = answer.text end end end return details end ## # determines wether or not a specified section of a plan is locked to a specified user and returns a status hash # # @param section_id [Integer] the setion to determine if locked # @param user_id [Integer] the user to determine if locked for # @return [Hash{String => Hash{String => Boolean, nil, String, Integer}}] def locked(section_id, user_id) plan_section = plan_sections.where("section_id = ? AND user_id != ? AND release_time > ?", section_id, user_id, Time.now).last if plan_section.nil? then status = { "locked" => false, "locked_by" => nil, "timestamp" => nil, "id" => nil } else status = { "locked" => true, "locked_by" => plan_section.user.name, "timestamp" => plan_section.updated_at, "id" => plan_section.id } end end ## # for each section, lock the section with the given user_id # # @param user_id [Integer] the id for the user who can use the sections def lock_all_sections(user_id) sections.each do |s| lock_section(s.id, user_id, 1800) end end ## # for each section, unlock the section # # @param user_id [Integer] the id for the user to unlock the sections for def unlock_all_sections(user_id) plan_sections.where(:user_id => user_id).order("created_at DESC").each do |lock| lock.delete end end ## # for each section, unlock the section # Not sure how this is different from unlock_all_sections # # @param user_id [Integer] def delete_recent_locks(user_id) plan_sections.where(:user_id => user_id).each do |lock| lock.delete end end ## # Locks the specified section to only be used by the specified user, for the number of secconds specified # # @param section_id [Integer] the id of the section to be locked # @param user_id [Integer] the id of the user who can use the section # @param release_time [Integer] the number of secconds the section will be locked for, defaults to 60 # @return [Boolean] wether or not the section was locked def lock_section(section_id, user_id, release_time = 60) status = locked(section_id, user_id) if ! status["locked"] then plan_section = PlanSection.new plan_section.plan_id = id plan_section.section_id = section_id plan_section.release_time = Time.now + release_time.seconds plan_section.user_id = user_id plan_section.save elsif status["current_user"] then plan_section = PlanSection.find(status["id"]) plan_section.release_time = Time.now + release_time.seconds plan_section.save else return false end end ## # unlocks the specified section for the specified user # # @param section_id [Integer] the id for the section to be unlocked # @param user_id [Integer] the id for the user for whom the section was previously locked # @return [Boolean] wether or not the lock was removed def unlock_section(section_id, user_id) plan_sections.where(:section_id => section_id, :user_id => user_id).order("created_at DESC").each do |lock| lock.delete end end ## # returns the time of either the latest answer to any question, or the latest update to the model # # @return [DateTime] the time at which the plan was last changed def latest_update if answers.any? then last_answered = answers.order("updated_at DESC").first.updated_at if last_answered > updated_at then return last_answered else return updated_at end else return updated_at end end ## # returns an array of hashes. Each hash contains the question's id, the answer_id, # the answer_text, the answer_timestamp, and the answer_options # # @params section_id [Integer] the section to find answers of # @return [Array<Hash{String => nil,String,Integer,DateTime}] def section_answers(section_id) section = Section.find(section_id) section_questions = Array.new counter = 0 section.questions.each do |q| section_questions[counter] = {} section_questions[counter]["id"] = q.id #section_questions[counter]["multiple_choice"] = q.multiple_choice q_answer = answer(q.id, false) if q_answer.nil? then section_questions[counter]["answer_id"] = nil if q.suggested_answers.find_by_organisation_id(project.organisation_id).nil? then section_questions[counter]["answer_text"] = "" else section_questions[counter]["answer_text"] = q.default_value end section_questions[counter]["answer_timestamp"] = nil section_questions[counter]["answer_options"] = Array.new else section_questions[counter]["answer_id"] = q_answer.id section_questions[counter]["answer_text"] = q_answer.text section_questions[counter]["answer_timestamp"] = q_answer.created_at section_questions[counter]["answer_options"] = q_answer.options.pluck(:id) end counter = counter + 1 end return section_questions end private ## # Based on the height of the text gathered so far and the available vertical # space of the pdf, estimate a percentage of how much space has been used. # This is highly dependent on the layout in the pdf. A more accurate approach # would be to render the pdf and check how much space had been used, but that # could be very slow. # NOTE: This is only an estimate, rounded up to the nearest 5%; it is intended # for guidance when editing plan data, not to be 100% accurate. # # @params used_height [Integer] an estimate of the height used so far # @return [Integer] the estimate of space used of an A4 portrain def estimate_space_used(used_height) @formatting ||= self.settings(:export).formatting return 0 unless @formatting[:font_size] > 0 margin_height = @formatting[:margin][:top].to_i + @formatting[:margin][:bottom].to_i page_height = A4_PAGE_HEIGHT - margin_height # 297mm for A4 portrait available_height = page_height * self.dmptemplate.settings(:export).max_pages percentage = (used_height / available_height) * 100 (percentage / ROUNDING).ceil * ROUNDING # round up to nearest five end ## # Take a guess at the vertical height (in mm) of the given text based on the # font-size and left/right margins stored in the plan's settings. # This assumes a fixed-width for each glyph, which is obviously # incorrect for the font-face choices available; the idea is that # they'll hopefully average out to that in the long-run. # Allows for hinting different font sizes (offset from base via font_size_inc) # and vertical margins (i.e. for heading text) # # @params text [String] the text to estimate size of # @params font_size_inc [Integer] the size of the font of the text, defaults to 0 # @params vertical_margin [Integer] the top margin above the text, defaults to 0 def height_of_text(text, font_size_inc = 0, vertical_margin = 0) @formatting ||= self.settings(:export).formatting @margin_width ||= @formatting[:margin][:left].to_i + @formatting[:margin][:right].to_i @base_font_size ||= @formatting[:font_size] return 0 unless @base_font_size > 0 font_height = FONT_HEIGHT_CONVERSION_FACTOR * (@base_font_size + font_size_inc) font_width = font_height * FONT_WIDTH_HEIGHT_RATIO # Assume glyph width averages at 2/5s the height leading = font_height / 2 chars_in_line = (A4_PAGE_WIDTH - @margin_width) / font_width # 210mm for A4 portrait num_lines = (text.length / chars_in_line).ceil (num_lines * font_height) + vertical_margin + leading end end