diff --git a/.travis.yml b/.travis.yml
index 13bb3b9..8e969a4 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -19,6 +19,7 @@
apt:
packages:
- nodejs
+ - wkhtmltopdf
matrix:
fast_finish: true
@@ -37,6 +38,7 @@
# Main test script
script:
+ - export WICKED_PDF_PATH=./vendor/bundle/ruby/2.4.0/bin/wkhtmltopdf
# Copy over config files needed for setup, and create DB
- bin/setup
# Precompile the assets
diff --git a/app/controllers/plan_exports_controller.rb b/app/controllers/plan_exports_controller.rb
new file mode 100644
index 0000000..d2e8a3a
--- /dev/null
+++ b/app/controllers/plan_exports_controller.rb
@@ -0,0 +1,100 @@
+class PlanExportsController < ApplicationController
+
+ after_action :verify_authorized
+
+ def show
+ @plan = Plan.includes(:answers).find(params[:plan_id])
+
+ if publicly_authorized?
+ skip_authorization
+ @show_coversheet = true
+ @show_sections_questions = true
+ @show_unanswered = true
+ @show_custom_sections = true
+ @public_plan = true
+
+ elsif privately_authorized?
+ @show_coversheet = export_params[:project_details].present?
+ @show_sections_questions = export_params[:question_headings].present?
+ @show_unanswered = export_params[:unanswered_questions].present?
+ @show_custom_sections = export_params[:custom_sections].present?
+ @public_plan = false
+
+ else
+ raise Pundit::NotAuthorizedError
+ end
+
+ @hash = @plan.as_pdf(@show_coversheet)
+ @formatting = export_params[:formatting] || @plan.settings(:export).formatting
+ if params.key?(:phase_id)
+ @selected_phase = @plan.phases.find(params[:phase_id])
+ else
+ @selected_phase = @plan.phases.order("phases.updated_at DESC")
+ .detect { |p| p.visibility_allowed?(@plan) }
+ end
+
+ respond_to do |format|
+ format.html { show_html }
+ format.csv { show_csv }
+ format.text { show_text }
+ format.docx { show_docx }
+ format.pdf { show_pdf }
+ end
+ end
+
+ private
+
+ def show_html
+ render layout: false
+ end
+
+ def show_csv
+ send_data @plan.as_csv(@show_sections_questions,
+ @show_unanswered,
+ @selected_phase,
+ @show_custom_sections,
+ @show_coversheet),
+ filename: "#{file_name}.csv"
+ end
+
+ def show_text
+ send_data render_to_string(partial: 'shared/export/plan_txt'),
+ filename: "#{file_name}.txt"
+ end
+
+ def show_docx
+ render docx: "#{file_name}.docx",
+ content: render_to_string(partial: 'shared/export/plan')
+ end
+
+ def show_pdf
+ render pdf: file_name,
+ margin: @formatting[:margin],
+ footer: {
+ center: _("Created using the %{application_name}. Last modified %{date}") % {
+ application_name: Rails.configuration.branding[:application][:name],
+ date: l(@plan.updated_at.to_date, formats: :short)
+ },
+ font_size: 8,
+ spacing: (Integer(@formatting[:margin][:bottom]) / 2) - 4,
+ right: "[page] of [topage]"
+ }
+ end
+
+ def file_name
+ @plan.title.gsub(/ /, "_")
+ end
+
+ def publicly_authorized?
+ PublicPagePolicy.new(@plan, current_user).plan_organisationally_exportable? ||
+ PublicPagePolicy.new(@plan).plan_export?
+ end
+
+ def privately_authorized?
+ authorize @plan, :export?
+ end
+
+ def export_params
+ params.fetch(:export, {})
+ end
+end
diff --git a/app/controllers/plans_controller.rb b/app/controllers/plans_controller.rb
index 6875978..4891ce7 100644
--- a/app/controllers/plans_controller.rb
+++ b/app/controllers/plans_controller.rb
@@ -297,44 +297,6 @@
render "download"
end
- def export
- @plan = Plan.includes(:answers).find(params[:id])
- authorize @plan
- @selected_phase = @plan.phases.find(params[:phase_id])
- @show_coversheet = params[:export][:project_details].present?
- @show_sections_questions = params[:export][:question_headings].present?
- @show_unanswered = params[:export][:unanswered_questions].present?
- @show_custom_sections = params[:export][:custom_sections].present?
- @public_plan = false
- @hash = @plan.as_pdf(@show_coversheet)
- @formatting = params[:export][:formatting] || @plan.settings(:export).formatting
- file_name = @plan.title.gsub(/ /, "_")
-
- # rubocop:disable Metrics/BlockLength
- respond_to do |format|
- format.html { render layout: false }
- format.csv { send_data @plan.as_csv(@show_sections_questions, @show_unanswered, @selected_phase, @show_custom_sections, @show_coversheet), filename: "#{file_name}.csv" }
- format.text { send_data render_to_string(partial: 'shared/export/plan_txt'), filename: "#{file_name}.txt" }
- format.docx { render docx: "#{file_name}.docx", content: render_to_string(partial: 'shared/export/plan') }
- format.pdf do
- # rubocop:disable Metrics/LineLength
- render pdf: file_name,
- margin: @formatting[:margin],
- footer: {
- center: _("Created using the %{application_name}. Last modified %{date}") % {
- application_name: Rails.configuration.branding[:application][:name],
- date: l(@plan.updated_at.to_date, formats: :short)
- },
- font_size: 8,
- spacing: (Integer(@formatting[:margin][:bottom]) / 2) - 4,
- right: "[page] of [topage]"
- }
- # rubocop:enable Metrics/LineLength
- end
- end
- # rubocop:enable Metrics/BlockLength
- end
-
def duplicate
plan = Plan.find(params[:id])
authorize plan
diff --git a/app/controllers/public_pages_controller.rb b/app/controllers/public_pages_controller.rb
index 021b7bf..3cc7ed8 100644
--- a/app/controllers/public_pages_controller.rb
+++ b/app/controllers/public_pages_controller.rb
@@ -47,14 +47,16 @@
# -------------------------------------------------------------
def plan_export
@plan = Plan.includes(:answers).find(params[:id])
- # covers authorization for this action. Pundit dosent support passing objects into scoped policies
- raise Pundit::NotAuthorizedError unless PublicPagePolicy.new(@plan, current_user).plan_organisationally_exportable? || PublicPagePolicy.new(@plan).plan_export?
- skip_authorization
+ # covers authorization for this action. Pundit dosent support passing objects into
+ # scoped policies
+ if PublicPagePolicy.new(@plan, current_user).plan_organisationally_exportable? ||
+ PublicPagePolicy.new(@plan).plan_export?
+ skip_authorization
+ else
+ raise Pundit::NotAuthorizedError
+ end
- @show_coversheet = true
- @show_sections_questions = true
- @show_unanswered = true
- @public_plan = true
+
@hash = @plan.as_pdf(@show_coversheet)
@formatting = @plan.settings(:export).formatting
diff --git a/app/controllers/super_admin/org_swaps_controller.rb b/app/controllers/super_admin/org_swaps_controller.rb
new file mode 100644
index 0000000..0b258dd
--- /dev/null
+++ b/app/controllers/super_admin/org_swaps_controller.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+class SuperAdmin::OrgSwapsController < ApplicationController
+
+ after_action :verify_authorized
+
+ def create
+ # Allows the user to swap their org affiliation on the fly
+ authorize current_user, :org_swap?
+ begin
+ @org = Org.find(org_swap_params[:org_id])
+ rescue ActiveRecord::RecordNotFound
+ redirect_to(:back, alert: _("Please select an organisation from the list"))
+ return
+ end
+ # rubocop:disable Metrics/LineLength
+ if @org.present?
+ current_user.org = @org
+ if current_user.save
+ redirect_to :back,
+ notice: _("Your organisation affiliation has been changed. You may now edit templates for %{org_name}.") % { org_name: current_user.org.name }
+ else
+ redirect_to :back,
+ alert: _("Unable to change your organisation affiliation at this time.")
+ end
+ else
+ redirect_to :back, alert: _("Unknown organisation.")
+ end
+ # rubocop:enable Metrics/LineLength
+ end
+
+ private
+
+ def org_swap_params
+ params.require(:user).permit(:org_id, :org_name)
+ end
+end
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 2091f6b..b94276b 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -111,33 +111,6 @@
notice: success_message(_("preferences"), _("saved"))
end
- # PUT /users/:id/org_swap
- # -----------------------------------------------------
- def org_swap
- # Allows the user to swap their org affiliation on the fly
- authorize current_user
- begin
- org = Org.find(org_swap_params[:org_id])
- rescue ActiveRecord::RecordNotFound
- redirect_to(request.referer,
- alert: _("Please select an organisation from the list")) and return
- end
- # rubocop:disable Metrics/LineLength
- if org.present?
- current_user.org = org
- if current_user.save
- redirect_to request.referer,
- notice: _("Your organisation affiliation has been changed. You may now edit templates for %{org_name}.") % { org_name: current_user.org.name }
- else
- redirect_to request.referer,
- alert: _("Unable to change your organisation affiliation at this time.")
- end
- else
- redirect_to request.referer, alert: _("Unknown organisation.")
- end
- # rubocop:enable Metrics/LineLength
- end
-
# PUT /users/:id/activate
# -----------------------------------------------------
def activate
@@ -177,10 +150,6 @@
private
- def org_swap_params
- params.require(:user).permit(:org_id, :org_name)
- end
-
##
# html forms return our boolean values as strings, this converts them to true/false
def booleanize_hash(node)
diff --git a/app/helpers/exports_helper.rb b/app/helpers/exports_helper.rb
new file mode 100644
index 0000000..d60364b
--- /dev/null
+++ b/app/helpers/exports_helper.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+module ExportsHelper
+
+ PAGE_MARGINS = {
+ top: '5',
+ bottom: "10",
+ left: "12",
+ right: "12",
+ }
+
+ def font_face
+ @formatting[:font_face].presence || 'Arial, Helvetica, Sans-Serif'
+ end
+
+ def font_size
+ @formatting[:font_size].presence || '12'
+ end
+
+ def margin_top
+ get_margin_value_for_side(:top)
+ end
+
+ def margin_bottom
+ get_margin_value_for_side(:bottom)
+ end
+
+ def margin_left
+ get_margin_value_for_side(:left)
+ end
+
+ def margin_right
+ get_margin_value_for_side(:right)
+ end
+
+ def plan_attribution(attribution)
+ attribution = Array(attribution)
+ prefix = attribution.many? ? _("Creators:") : _("Creator:")
+ "#{prefix} #{attribution.join(', ')}"
+ end
+
+ private
+
+ def get_margin_value_for_side(side)
+ side = side.to_sym
+ if @formatting.dig(:margin, side).is_a?(Integer)
+ @formatting[:margin][side] * 4
+ else
+ @formatting.dig(:margin, side).presence || PAGE_MARGINS[side]
+ end
+ end
+end
\ No newline at end of file
diff --git a/app/helpers/plans_helper.rb b/app/helpers/plans_helper.rb
index f761b11..a3db40d 100644
--- a/app/helpers/plans_helper.rb
+++ b/app/helpers/plans_helper.rb
@@ -47,7 +47,7 @@
# If there is more than one phase show the plan title and phase title
return hash[:phases].many? ? "#{plan.title} - #{phase[:title]}" : plan.title
end
-
+
def display_questions_and_section_headings(section, show_sections_questions, show_custom_sections)
# Return true if show_sections_questions is true and either section not customised, or section is customised
# and show_custom_sections is true
diff --git a/app/models/answer.rb b/app/models/answer.rb
index 738dc5d..1ee61fd 100644
--- a/app/models/answer.rb
+++ b/app/models/answer.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
# == Schema Information
#
# Table name: answers
@@ -24,27 +26,22 @@
#
class Answer < ActiveRecord::Base
+
include ValidationMessages
- after_save do |answer|
- if answer.plan_id.present?
- plan = answer.plan
- complete = plan.no_questions_matches_no_answers?
- if plan.complete != complete
- plan.complete = complete
- plan.save!
- else
- plan.touch # Force updated_at changes if nothing changed since save only saves if changes were made to the record
- end
- end
- end
- ##
- # Associations
- belongs_to :question
- belongs_to :user
- belongs_to :plan
+ # ================
+ # = Associations =
+ # ================
+
+ belongs_to :question
+
+ belongs_to :user
+
+ belongs_to :plan
+
has_many :notes, dependent: :destroy
+
has_and_belongs_to_many :question_options, join_table: "answers_question_options"
has_many :notes
@@ -59,8 +56,15 @@
validates :user, presence: { message: PRESENCE_MESSAGE }
validates :question, presence: { message: PRESENCE_MESSAGE },
- uniqueness: { message: UNIQUENESS_MESSAGE,
- scope: :plan_id }
+ uniqueness: { message: UNIQUENESS_MESSAGE,
+ scope: :plan_id }
+
+ # =============
+ # = Callbacks =
+ # =============
+
+ after_save :set_plan_complete
+
##
# deep copy the given answer
@@ -81,7 +85,7 @@
#
# Returns Boolean
def has_question_option(option_id)
- self.question_option_ids.include?(option_id)
+ question_option_ids.include?(option_id)
end
# If the answer's question is option_based, it is checked if exist any question_option
@@ -90,21 +94,21 @@
#
# Returns Boolean
def is_valid?
- if self.question.present?
- if self.question.question_format.option_based?
- return !self.question_options.empty?
+ if question.present?
+ if question.question_format.option_based?
+ return question_options.any?
else # (e.g. textarea or textfield question formats)
- return self.text.present?
+ return text.present?
end
end
- return false
+ false
end
# Answer notes whose archived is blank sorted by updated_at in descending order
#
# Returns Array
def non_archived_notes
- return notes.select{ |n| n.archived.blank? }.sort!{ |x,y| y.updated_at <=> x.updated_at }
+ notes.select { |n| n.archived.blank? }.sort! { |x, y| y.updated_at <=> x.updated_at }
end
# Returns True if answer text is blank, false otherwise specificly we want to remove
@@ -112,11 +116,11 @@
#
# Returns Boolean
def is_blank?
- if self.text.present?
- return self.text.gsub(/<\/?p>/, '').gsub(/
/, '').chomp.blank?
+ if text.present?
+ return text.gsub(/<\/?p>/, "").gsub(/
/, "").chomp.blank?
end
# no text so blank
- return true
+ true
end
# The parsed JSON hash for the current answer object. Generates a new hash if none
@@ -124,13 +128,13 @@
#
# Returns Hash
def answer_hash
- default = {'standards' => {}, 'text' => ''}
+ default = { "standards" => {}, "text" => "" }
begin
- h = self.text.nil? ? default : JSON.parse(self.text)
+ h = text.nil? ? default : JSON.parse(text)
rescue JSON::ParserError => e
h = default
end
- return h
+ h
end
##
@@ -141,10 +145,23 @@
# text - A String with option comment text
#
# Returns String
- def update_answer_hash(standards={},text="")
+ def update_answer_hash(standards = {}, text = "")
h = {}
- h['standards'] = standards
- h['text'] = text
+ h["standards"] = standards
+ h["text"] = text
self.text = h.to_json
end
+
+ def set_plan_complete
+ return unless plan_id?
+ complete = plan.no_questions_matches_no_answers?
+ if plan.complete != complete
+ plan.update!(complete: complete)
+ else
+ # Force updated_at changes if nothing changed since save only saves if changes
+ # were made to the record
+ plan.touch
+ end
+ end
+
end
diff --git a/app/models/concerns/exportable_plan.rb b/app/models/concerns/exportable_plan.rb
index 1701b83..dac15c3 100644
--- a/app/models/concerns/exportable_plan.rb
+++ b/app/models/concerns/exportable_plan.rb
@@ -71,9 +71,10 @@
def prepare(coversheet = false)
hash = coversheet ? prepare_coversheet : {}
- template = Template.includes(phases: { sections: {questions: :question_format } }).
- joins(phases: { sections: { questions: :question_format } }).
- where(id: self.template_id).order('sections.number', 'questions.number').first
+ template = Template.includes(phases: { sections: {questions: :question_format } })
+ .joins(phases: { sections: { questions: :question_format } })
+ .where(id: self.template_id)
+ .order('sections.number', 'questions.number').first
hash[:title] = self.title
hash[:answers] = self.answers
@@ -83,10 +84,17 @@
template.phases.each do |phase|
phs = { title: phase.title, number: phase.number, sections: [] }
phase.sections.each do |section|
- sctn = { title: section.title, number: section.number, questions: [], modifiable: section.modifiable }
+ sctn = { title: section.title,
+ number: section.number,
+ questions: [],
+ modifiable: section.modifiable }
section.questions.each do |question|
txt = question.text
- sctn[:questions] << { id: question.id, text: txt, format: question.question_format }
+ sctn[:questions] << {
+ id: question.id,
+ text: txt,
+ format: question.question_format
+ }
end
phs[:sections] << sctn
end
diff --git a/app/models/phase.rb b/app/models/phase.rb
index 58a466d..f56edf6 100644
--- a/app/models/phase.rb
+++ b/app/models/phase.rb
@@ -98,8 +98,7 @@
# TODO: Move this to Plan model as `num_answered_questions(phase=nil)`
# Returns the number of answered question for the phase.
def num_answered_questions(plan)
- return 0 if plan.nil?
- sections.to_a.sum { |s| s.num_answered_questions(plan) }
+ plan&.num_answered_questions.to_i
end
# Returns the number of questions for a phase. Note, this method becomes useful
@@ -111,4 +110,9 @@
end
n
end
+
+ def visibility_allowed?(plan)
+ value = Rational(num_answered_questions(plan), plan.num_questions) * 100
+ value >= Rails.application.config.default_plan_percentage_answered.to_f
+ end
end
diff --git a/app/views/org_admin/templates/index.html.erb b/app/views/org_admin/templates/index.html.erb
index 45fa55b..de858a9 100644
--- a/app/views/org_admin/templates/index.html.erb
+++ b/app/views/org_admin/templates/index.html.erb
@@ -11,8 +11,9 @@
| <%= _('Project Title') %> <%= paginable_sort_link('plans.title') %> | -<%= _('Template') %> <%= paginable_sort_link('templates.title') %> | -<%= _('Owner') %> | -<%= _('Updated') %> <%= paginable_sort_link('plans.updated_at') %> | -<%= _('Download') %> | -
|---|---|---|---|---|
| <%= plan.title.length > 40 ? "#{plan.title[0..39]} ..." : plan.title %> | -<%= plan.template.title %> | -<%= plan.owner.present? ? plan.owner.name : _('Unknown') %> | -<%= l(plan.updated_at.to_date, formats: :short) %> | -- <%= link_to _('PDF'), plan_export_path(plan, format: :pdf), target: '_blank' %> - | -
| + <%= _('Project Title') %> + <%= paginable_sort_link('plans.title') %> + | ++ <%= _('Template') %> + <%= paginable_sort_link('templates.title') %> + | +<%= _('Owner') %> | ++ <%= _('Updated') %> + <%= paginable_sort_link('plans.updated_at') %> + | +<%= _('Download') %> | +
|---|---|---|---|---|
| + <%= truncate plan.title, length: 40 %> + | +<%= plan.template.title %> | +<%= plan.owner.present? ? plan.owner.name : _('Unknown') %> | +<%= l(plan.updated_at.to_date, formats: :short) %> | ++ <%= link_to _('PDF'), plan_export_path(plan, format: :pdf), + target: '_blank' %> + | +
@@ -19,7 +19,7 @@ <%= paginable_renderise( partial: '/paginable/plans/privately_visible', controller: 'paginable/plans', - action: 'privately_visible', + action: 'privately_visible', scope: @plans, query_params: { sort_field: 'plans.updated_at', sort_direction: 'desc' }) %>
<%= _('The table below lists the plans that users at your organisation have created and shared within your organisation. This allows you to download a PDF and view their plans as samples or to discover new research data.') %>
<%= paginable_renderise( partial: '/paginable/plans/organisationally_or_publicly_visible', diff --git a/app/views/shared/export/_plan.erb b/app/views/shared/export/_plan.erb index 38b551c..43c7022 100644 --- a/app/views/shared/export/_plan.erb +++ b/app/views/shared/export/_plan.erb @@ -1,25 +1,10 @@ -<% - font_face = (@formatting[:font_face].present? ? "#{@formatting[:font_face]}" : 'Arial, Helvetica, Sans-Serif') - font_size = (@formatting[:font_size].present? ? "#{@formatting[:font_size]}" : '12') - margin_top = '5' - margin_bottom = '10' - margin_left = '12' - margin_right = '12' - - if @formatting[:margin].present? - margin_top = (@formatting[:margin][:top].is_a?(Integer) ? @formatting[:margin][:top] * 4 : @formatting[:margin][:top]) if @formatting[:margin][:top].present? - margin_right = (@formatting[:margin][:right].is_a?(Integer) ? @formatting[:margin][:right] * 4 : @formatting[:margin][:right]) if @formatting[:margin][:right].present? - margin_bottom = (@formatting[:margin][:bottom].is_a?(Integer) ? @formatting[:margin][:bottom] * 4 : @formatting[:margin][:bottom]) if @formatting[:margin][:bottom].present? - margin_left = (@formatting[:margin][:left].is_a?(Integer) ? @formatting[:margin][:left] * 4 : @formatting[:margin][:left]) if @formatting[:margin][:left].present? - end -%> -<%= _('Question not answered.') -%>
<% else %> + <%# case where Question has options %> <% if options.any? %><%= @hash[:attribution].length > 1 ? _("Creators: ") : _('Creator:') %> <%= @hash[:attribution].join(', ') %>
+ <%= plan_attribution(@hash[:attribution]) %> +
<%= _("Affiliation: ") + @hash[:affiliation] %>
<%= _("Copyright information:") %>
-<%= _(" The above plan creator(s) have agreed that others may use as much of the text of this plan as they would like in their own plans, and customise it as necessary. You do not need to credit the creator(s) as the source of the language used, but using any of the plan's text does not imply that the creator(s) endorse, or have any relationship to, your project or proposal") %>
+ <%= _(" The above plan creator(s) have agreed that others may use as much of the text of this plan as they would like in their own plans, and customise it as necessary. You do not need to credit the creator(s) as the source of the language used, but using any of the plan's text does not imply that the creator(s) endorse, or have any relationship to, your project or proposal") %> +
+