pairs
+ # 'text' is the text from the comments box
+ def answer_hash
+ default = {'standards' => {}, 'text' => ''}
+ begin
+ h = self.text.nil? ? default : JSON.parse(self.text)
+ rescue JSON::ParserError => e
+ h = default
+ end
+ return h
+ end
+
+ ##
+ # Given a hash of standards and a comment value, this updates answer
+ # text for rda_questions
+ #
+ # @param [standards] a hash of standards
+ # @param [text] option comment text
+ # nothing returned, but the status of the text field of the answer is changed
+ def update_answer_hash(standards={},text="")
+ h = {}
+ h['standards'] = standards
+ h['text'] = text
+ self.text = h.to_json
+ end
+end
diff --git a/app/models/concerns/acts_as_sortable.rb b/app/models/concerns/acts_as_sortable.rb
new file mode 100644
index 0000000..f5ebc6e
--- /dev/null
+++ b/app/models/concerns/acts_as_sortable.rb
@@ -0,0 +1,44 @@
+module ActsAsSortable
+ extend ActiveSupport::Concern
+
+ module ClassMethods
+
+ def update_numbers!(*ids, parent:)
+ # Ensure only records belonging to this parent are included.
+ ids = ids.map(&:to_i) & parent.public_send("#{model_name.singular}_ids")
+ return if ids.empty?
+ case connection.adapter_name
+ when "PostgreSQL" then update_numbers_postgresql!(ids)
+ when "Mysql2" then update_numbers_mysql2!(ids)
+ else
+ update_numbers_sequentially!(ids)
+ end
+ end
+
+ private
+
+ def update_numbers_postgresql!(ids)
+ # Build an Array with each ID and its relative position in the Array
+ values = ids.each_with_index.map { |id, i| "(#{id}, #{i + 1})" }.join(",")
+ # Run a single UPDATE query for all records.
+ query = <<~SQL
+ UPDATE #{table_name} \
+ SET number = svals.number \
+ FROM (VALUES #{sanitize_sql(values)}) AS svals(id, number) \
+ WHERE svals.id = #{table_name}.id;
+ SQL
+ connection.execute(query)
+ end
+
+ def self.update_numbers_mysql2!(ids)
+ ids_string = ids.map { |id| "'#{id}'" }.join(",")
+ update_all(%Q{ number = FIELD(id, #{sanitize_sql(ids_string)}) })
+ end
+
+ def self.update_numbers_sequentially!(ids)
+ ids.each_with_index.map do |id, number|
+ find(id).update_attribute(:number, number + 1)
+ end
+ end
+ end
+end
diff --git a/app/models/concerns/validation_messages.rb b/app/models/concerns/validation_messages.rb
new file mode 100644
index 0000000..f0f711f
--- /dev/null
+++ b/app/models/concerns/validation_messages.rb
@@ -0,0 +1,10 @@
+module ValidationMessages
+ # frozen_string_literal: true
+
+ PRESENCE_MESSAGE = _("can't be blank")
+
+ UNIQUENESS_MESSAGE = _("must be unique")
+
+ INCLUSION_MESSAGE = _("isn't a valid value")
+
+end
diff --git a/app/models/concerns/validation_values.rb b/app/models/concerns/validation_values.rb
new file mode 100644
index 0000000..dff74c3
--- /dev/null
+++ b/app/models/concerns/validation_values.rb
@@ -0,0 +1,3 @@
+module ValidationValues
+ BOOLEAN_VALUES = [true, false].freeze
+end
\ No newline at end of file
diff --git a/app/models/exported_plan.rb b/app/models/exported_plan.rb
index 24bfaac..24a854a 100644
--- a/app/models/exported_plan.rb
+++ b/app/models/exported_plan.rb
@@ -12,6 +12,7 @@
#
class ExportedPlan < ActiveRecord::Base
+ include ValidationMessages
include GlobalHelpers
include SettingsTemplateHelper
@@ -20,15 +21,9 @@
belongs_to :plan
belongs_to :user
- VALID_FORMATS = ['csv', 'html', 'pdf', 'text', 'docx']
+ validates :plan, presence: { message: PRESENCE_MESSAGE }
- validates :format, inclusion: {
- in: VALID_FORMATS,
- message: -> (object, data) do
- _('%{value} is not a valid format') % { :value => data[:value] }
- end
- }
- validates :plan, :format, presence: {message: _("can't be blank")}
+ validates :format, presence: { message: PRESENCE_MESSAGE }
# Store settings with the exported plan so it can be recreated later
# if necessary (otherwise the settings associated with the plan at a
diff --git a/app/models/guidance.rb b/app/models/guidance.rb
index c98c5cc..5ad8950 100644
--- a/app/models/guidance.rb
+++ b/app/models/guidance.rb
@@ -30,23 +30,30 @@
class Guidance < ActiveRecord::Base
include GlobalHelpers
+ include ValidationMessages
+ include ValidationValues
- ##
- # Associations
+ # ================
+ # = Associations =
+ # ================
belongs_to :guidance_group
has_and_belongs_to_many :themes, join_table: "themes_in_guidance"
-# depricated, but required for migration "single_group_for_guidance"
- #has_and_belongs_to_many :guidance_groups, join_table: "guidance_in_group"
-
# EVALUATE CLASS AND INSTANCE METHODS BELOW
#
# What do they do? do they do it efficiently, and do we need them?
+ # ===============
+ # = Validations =
+ # ===============
+ validates :text, presence: { message: PRESENCE_MESSAGE }
- validates :text, presence: {message: _("can't be blank")}
+ validates :guidance_group, presence: { message: PRESENCE_MESSAGE }
+
+ validates :published, inclusion: { message: INCLUSION_MESSAGE,
+ in: BOOLEAN_VALUES}
# Retrieves every guidance associated to an org
scope :by_org, -> (org) {
diff --git a/app/models/guidance_group.rb b/app/models/guidance_group.rb
index 490c48f..f9520a2 100644
--- a/app/models/guidance_group.rb
+++ b/app/models/guidance_group.rb
@@ -21,6 +21,9 @@
class GuidanceGroup < ActiveRecord::Base
include GlobalHelpers
+ include ValidationValues
+ include ValidationMessages
+
##
# Associations
belongs_to :org
@@ -30,8 +33,20 @@
# has_and_belongs_to_many :guidances, join_table: "guidance_in_group"
+ # ===============
+ # = Validations =
+ # ===============
- validates :name, :org, presence: {message: _("can't be blank")}
+ validates :name, presence: { message: PRESENCE_MESSAGE },
+ uniqueness: { message: UNIQUENESS_MESSAGE, scope: :org_id }
+
+ validates :org, presence: { message: PRESENCE_MESSAGE }
+
+ validates :optional_subset, inclusion: { in: BOOLEAN_VALUES,
+ message: INCLUSION_MESSAGE }
+
+ validates :published, inclusion: { in: BOOLEAN_VALUES,
+ message: INCLUSION_MESSAGE }
# EVALUATE CLASS AND INSTANCE METHODS BELOW
diff --git a/app/models/identifier_scheme.rb b/app/models/identifier_scheme.rb
index f96cd79..9243b11 100644
--- a/app/models/identifier_scheme.rb
+++ b/app/models/identifier_scheme.rb
@@ -13,8 +13,25 @@
#
class IdentifierScheme < ActiveRecord::Base
+ include ValidationMessages
+ include ValidationValues
+
+ ##
+ # The maximum length for a name
+ NAME_MAXIMUM_LENGTH = 30
+
has_many :user_identifiers
has_many :users, through: :user_identifiers
-
- validates :name, uniqueness: {message: _("must be unique")}, presence: {message: _("can't be blank")}
+
+ # ===============
+ # = Validations =
+ # ===============
+
+ validates :name, uniqueness: { message: UNIQUENESS_MESSAGE },
+ presence: { message: PRESENCE_MESSAGE },
+ length: { maximum: NAME_MAXIMUM_LENGTH }
+
+ validates :active, inclusion: { message: INCLUSION_MESSAGE,
+ in: BOOLEAN_VALUES }
+
end
diff --git a/app/models/language.rb b/app/models/language.rb
index 7c52f72..6ee7e9b 100644
--- a/app/models/language.rb
+++ b/app/models/language.rb
@@ -9,19 +9,52 @@
# name :string
#
-class Language < ActiveRecord::Base
- ##
- # Associations
- has_many :users
- has_many :orgs
-
- ##
- # Validations
- # Cannot do FastGettext translations here because we constantize LANGUAGES in initializers/constants.rb
- validates :abbreviation, presence: {message: "can't be blank"}, uniqueness: {message: "must be unique"}
-
- scope :sorted_by_abbreviation, -> { all.order(:abbreviation) }
- scope :default, -> { where(default_language: true).first }
- # Retrieves the id for a given abbreviation of a language
- scope :id_for, -> (abbreviation) { where(abbreviation: abbreviation).pluck(:id).first }
+class Language < ActiveRecord::Base
+ # frozen_string_literal: true
+
+ include ValidationValues
+
+ # =============
+ # = Constants =
+ # =============
+
+ ABBREVIATION_MAXIMUM_LENGTH = 5
+
+ ABBREVIATION_FORMAT = /\A[a-z]{2}(\_[A-Z]{2})?\Z/
+
+ NAME_MAXIMUM_LENGTH = 20
+
+ # ================
+ # = Associations =
+ # ================
+
+ has_many :users
+
+ has_many :orgs
+
+
+ # ===============
+ # = Validations =
+ # ===============
+
+ validates :name, presence: { message: "can't be blank" },
+ length: { maximum: NAME_MAXIMUM_LENGTH }
+
+ validates :abbreviation, presence: { message: "can't be blank" },
+ uniqueness: { message: "must be unique" },
+ length: { maximum: ABBREVIATION_MAXIMUM_LENGTH },
+ format: { with: ABBREVIATION_FORMAT }
+
+ validates :default_language, inclusion: { in: BOOLEAN_VALUES }
+
+ # ==========
+ # = Scopes =
+ # ==========
+
+ scope :sorted_by_abbreviation, -> { all.order(:abbreviation) }
+ scope :default, -> { where(default_language: true).first }
+ # Retrieves the id for a given abbreviation of a language
+ scope :id_for, -> (abbreviation) {
+ where(abbreviation: abbreviation).pluck(:id).first
+ }
end
diff --git a/app/models/note.rb b/app/models/note.rb
index 17f86f0..a78dc00 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -22,12 +22,28 @@
#
class Note < ActiveRecord::Base
- ##
- # Associations
+ include ValidationMessages
+ include ValidationValues
+
+ # ================
+ # = Associations =
+ # ================
+
belongs_to :answer
+
belongs_to :user
+ # ===============
+ # = Validations =
+ # ===============
- validates :text, :answer, :user, presence: {message: _("can't be blank")}
+ validates :text, presence: { message: PRESENCE_MESSAGE }
+
+ validates :answer, presence: { message: PRESENCE_MESSAGE }
+
+ validates :user, presence: { message: PRESENCE_MESSAGE }
+
+ validates :archived, inclusion: { in: BOOLEAN_VALUES,
+ message: INCLUSION_MESSAGE }
end
diff --git a/app/models/notification.rb b/app/models/notification.rb
index e6386e3..cfbe0cb 100644
--- a/app/models/notification.rb
+++ b/app/models/notification.rb
@@ -15,13 +15,44 @@
#
class Notification < ActiveRecord::Base
+ include ValidationMessages
+ include ValidationValues
+
enum level: %i[info warning danger]
enum notification_type: %i[global]
- has_and_belongs_to_many :users, dependent: :destroy, join_table: 'notification_acknowledgements'
+ # ================
+ # = Associations =
+ # ================
- validates :notification_type, :title, :level, :starts_at, :expires_at, :body, presence: true
- validate :valid_dates
+ has_and_belongs_to_many :users, dependent: :destroy,
+ join_table: 'notification_acknowledgements'
+
+
+ # ===============
+ # = Validations =
+ # ===============
+
+ validates :notification_type, presence: { message: PRESENCE_MESSAGE }
+
+ validates :title, presence: { message: PRESENCE_MESSAGE }
+
+ validates :level, presence: { message: PRESENCE_MESSAGE }
+
+ validates :body, presence: { message: PRESENCE_MESSAGE }
+
+ validates :dismissable, inclusion: { in: BOOLEAN_VALUES }
+
+ validates :starts_at, presence: { message: PRESENCE_MESSAGE },
+ after: { date: Date.today, on: :create }
+
+ validates :expires_at, presence: { message: PRESENCE_MESSAGE },
+ after: { date: Date.tomorrow, on: :create }
+
+
+ # ==========
+ # = Scopes =
+ # ==========
scope :active, (lambda do
where('starts_at <= :now and :now < expires_at', now: Time.now)
@@ -43,6 +74,8 @@
users.include?(user) if user.present? && dismissable?
end
+ private
+
# Validate Notification dates
def valid_dates
return false if starts_at.blank? || expires_at.blank?
diff --git a/app/models/org.rb b/app/models/org.rb
index 6f1360e..443c0e3 100644
--- a/app/models/org.rb
+++ b/app/models/org.rb
@@ -33,38 +33,82 @@
#
class Org < ActiveRecord::Base
+ include ValidationMessages
+ include ValidationValues
include GlobalHelpers
include FlagShihTzu
extend Dragonfly::Model::Validations
validates_with OrgLinksValidator
+ LOGO_FORMATS = %w[jpeg png gif jpg bmp].freeze
+
# Stores links as an JSON object: { org: [{"link":"www.example.com","text":"foo"}, ...] }
# The links are validated against custom validator allocated at validators/template_links_validator.rb
serialize :links, JSON
- ##
- # Associations
-# belongs_to :organisation_type # depricated, but cannot be removed until migration run
+
+ # ================
+ # = Associations =
+ # ================
+
belongs_to :language
- has_many :guidance_groups
+
+ belongs_to :region
+
+ has_many :guidance_groups, dependent: :destroy
+
has_many :templates
+
has_many :users
+
has_many :annotations
has_and_belongs_to_many :token_permission_types, join_table: "org_token_permissions", unique: true
has_many :org_identifiers
+
has_many :identifier_schemes, through: :org_identifiers
- ##
- # Validators
- validates :name, presence: {message: _("can't be blank")}, uniqueness: {message: _("must be unique")}
+
+ # ===============
+ # = Validations =
+ # ===============
+
+ validates :name, presence: { message: PRESENCE_MESSAGE },
+ uniqueness: { message: UNIQUENESS_MESSAGE }
+
+ validates :abbreviation, presence: { message: PRESENCE_MESSAGE },
+ uniqueness: { message: UNIQUENESS_MESSAGE }
+
+ validates :is_other, inclusion: { in: BOOLEAN_VALUES,
+ message: INCLUSION_MESSAGE }
+
+ validates :language, presence: { message: PRESENCE_MESSAGE }
+
+ validates :contact_email, email: { allow_nil: true },
+ presence: { message: PRESENCE_MESSAGE,
+ if: :feedback_enabled }
+
+ validates :org_type, presence: { message: PRESENCE_MESSAGE }
+
+ validates :feedback_enabled, inclusion: { in: BOOLEAN_VALUES,
+ message: INCLUSION_MESSAGE }
+
+ validates :feedback_email_subject, presence: { message: PRESENCE_MESSAGE,
+ if: :feedback_enabled }
+
+ validates :feedback_email_msg, presence: { message: PRESENCE_MESSAGE,
+ if: :feedback_enabled }
+
+ validates_property :format, of: :logo, in: LOGO_FORMATS,
+ message: _("must be one of the following formats: jpeg, jpg, png, gif, bmp")
+
+ validates_size_of :logo, maximum: 500.kilobytes, message: _("can't be larger than 500KB")
+
# allow validations for logo upload
dragonfly_accessor :logo do
after_assign :resize_image
end
- validates_property :format, of: :logo, in: ['jpeg', 'png', 'gif','jpg','bmp'], message: _("must be one of the following formats: jpeg, jpg, png, gif, bmp")
- validates_size_of :logo, maximum: 500.kilobytes, message: _("can't be larger than 500KB")
##
# Define Bit Field values
@@ -178,20 +222,25 @@
end
private
- ##
- # checks size of logo and resizes if necessary
- #
- def resize_image
- unless logo.nil?
- if logo.height != 100
- self.logo = logo.thumb('x100') # resize height and maintain aspect ratio
- end
+
+ ##
+ # checks size of logo and resizes if necessary
+ #
+ def resize_image
+ unless logo.nil?
+ if logo.height != 100
+ self.logo = logo.thumb('x100') # resize height and maintain aspect ratio
end
end
+ end
- # creates a dfefault Guidance Group on create on the Org
- def create_guidance_group
- GuidanceGroup.create(name: self.abbreviation? ? self.abbreviation : self.name , org_id: self.id)
- end
-
+ # creates a dfefault Guidance Group on create on the Org
+ def create_guidance_group
+ GuidanceGroup.create!({
+ name: abbreviation? ? self.abbreviation : self.name ,
+ org: self,
+ optional_subset: false,
+ published: false,
+ })
+ end
end
diff --git a/app/models/org_identifier.rb b/app/models/org_identifier.rb
index 4ad113a..618ed2a 100644
--- a/app/models/org_identifier.rb
+++ b/app/models/org_identifier.rb
@@ -17,14 +17,34 @@
#
class OrgIdentifier < ActiveRecord::Base
+ include ValidationMessages
+
+ # ================
+ # = Associations =
+ # ================
+
belongs_to :org
belongs_to :identifier_scheme
-
+
+ # ===============
+ # = Validations =
+ # ===============
+
# Should only be able to have one identifier per scheme!
- validates_uniqueness_of :identifier_scheme, scope: :org
-
- validates :identifier, :org, :identifier_scheme, presence: {message: _("can't be blank")}
-
+ validates :identifier_scheme_id, uniqueness: { scope: :org_id,
+ message: UNIQUENESS_MESSAGE }
+
+ validates :identifier, presence: { message: PRESENCE_MESSAGE },
+ uniqueness: { message: UNIQUENESS_MESSAGE }
+
+ validates :org, presence: { message: PRESENCE_MESSAGE }
+
+ validates :identifier_scheme, presence: { message: PRESENCE_MESSAGE }
+
+ # ===========================
+ # = Public instance methods =
+ # ===========================
+
def attrs=(hash)
write_attribute(:attrs, (hash.is_a?(Hash) ? hash.to_json.to_s : '{}'))
end
diff --git a/app/models/perm.rb b/app/models/perm.rb
index 1cc7c14..4dfde8c 100644
--- a/app/models/perm.rb
+++ b/app/models/perm.rb
@@ -9,18 +9,38 @@
#
class Perm < ActiveRecord::Base
- ##
- # Associations
- has_and_belongs_to_many :users, :join_table => :users_perms
+ include ValidationMessages
- validates :name, presence: {message: _("can't be blank")}, uniqueness: {message: _("must be unique")}
+ # ================
+ # = Associations =
+ # ================
- scope :add_orgs, -> {Perm.find_by(name: 'add_organisations')}
- scope :change_affiliation, -> {Perm.find_by(name: 'change_org_affiliation')}
- scope :grant_permissions, -> {Perm.find_by(name: 'grant_permissions')}
- scope :modify_templates, -> {Perm.find_by(name: 'modify_templates')}
- scope :modify_guidance, -> {Perm.find_by(name: 'modify_guidance')}
- scope :use_api, -> {Perm.find_by(name: 'use_api')}
- scope :change_org_details, -> {Perm.find_by(name: 'change_org_details')}
- scope :grant_api, -> {Perm.find_by(name: 'grant_api_to_orgs')}
+ has_and_belongs_to_many :users, join_table: :users_perms
+
+ # ===============
+ # = Validations =
+ # ===============
+
+ validates :name, presence: { message: PRESENCE_MESSAGE },
+ uniqueness: { message: UNIQUENESS_MESSAGE }
+
+ # ==========
+ # = Scopes =
+ # ==========
+
+ scope :add_orgs, -> { Perm.find_by(name: 'add_organisations') }
+
+ scope :change_affiliation, -> { Perm.find_by(name: 'change_org_affiliation') }
+
+ scope :grant_permissions, -> { Perm.find_by(name: 'grant_permissions') }
+
+ scope :modify_templates, -> { Perm.find_by(name: 'modify_templates') }
+
+ scope :modify_guidance, -> { Perm.find_by(name: 'modify_guidance') }
+
+ scope :use_api, -> { Perm.find_by(name: 'use_api') }
+
+ scope :change_org_details, -> { Perm.find_by(name: 'change_org_details') }
+
+ scope :grant_api, -> { Perm.find_by(name: 'grant_api_to_orgs') }
end
diff --git a/app/models/phase.rb b/app/models/phase.rb
index 68fcbd3..42312b4 100644
--- a/app/models/phase.rb
+++ b/app/models/phase.rb
@@ -27,12 +27,17 @@
# [+Created:+] 03/09/2014
# [+Copyright:+] Digital Curation Centre and University of California Curation Center
class Phase < ActiveRecord::Base
+ include ValidationMessages
+ include ValidationValues
+ include ActsAsSortable
+
##
# Sort order: Number ASC
default_scope { order(number: :asc) }
- ##
- # Associations
+ # ================
+ # = Associations =
+ # ================
belongs_to :template
has_one :prefix_section, -> (phase) {
@@ -56,7 +61,24 @@
}, class_name: "Section"
- validates :title, :number, :template, presence: { message: _("can't be blank") }
+ # ===============
+ # = Validations =
+ # ===============
+
+ validates :title, presence: { message: PRESENCE_MESSAGE }
+
+ validates :number, presence: { message: PRESENCE_MESSAGE },
+ uniqueness: { message: UNIQUENESS_MESSAGE,
+ scope: :template_id }
+
+ validates :template, presence: { message: PRESENCE_MESSAGE }
+
+ validates :modifiable, inclusion: { in: BOOLEAN_VALUES,
+ message: INCLUSION_MESSAGE }
+
+ # ==========
+ # = Scopes =
+ # ==========
scope :titles, -> (template_id) {
Phase.where(template_id: template_id).select(:id, :title)
diff --git a/app/models/plan.rb b/app/models/plan.rb
index 57f874c..7609c70 100644
--- a/app/models/plan.rb
+++ b/app/models/plan.rb
@@ -35,46 +35,93 @@
class Plan < ActiveRecord::Base
include ConditionalUserMailer
include ExportablePlan
- before_validation :set_creation_defaults
+ include ValidationMessages
+ include ValidationValues
+
+ # =============
+ # = Constants =
+ # =============
##
- # 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 :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
+ # (in mm)
+ A4_PAGE_HEIGHT = 297
- accepts_nested_attributes_for :template
- has_many :exported_plans
+ ##
+ # (in mm)
+ A4_PAGE_WIDTH = 210
- has_many :roles
+ ##
+ # Round estimate up to nearest 5%
+ ROUNDING = 5
-# COMMENTED OUT THE DIRECT CONNECTION HERE TO Users to prevent assignment of users without an access_level specified (currently defaults to creator)
-# has_many :users, through: :roles
+ ##
+ # convert font point size to mm
+ FONT_HEIGHT_CONVERSION_FACTOR = 0.35278
+ ##
+ # Assume glyph width averages 2/5 the height
+ FONT_WIDTH_HEIGHT_RATIO = 0.4
- accepts_nested_attributes_for :roles
# public is a Ruby keyword so using publicly
enum visibility: [:organisationally_visible, :publicly_visible, :is_test, :privately_visible]
- #TODO: work out why this messes up plan creation :
- # briley: Removed reliance on :users, its really on :roles (shouldn't have a plan without at least a creator right?) It should be ok like this though now
-# validates :template, :title, presence: true
+ # ================
+ # = Associations =
+ # ================
- ##
- # Constants
- 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
+ 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 :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
+
+ # ==============
+ # = 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 =
+ # ==========
# Scope queries
# Note that in ActiveRecord::Enum the mappings are exposed through a class method with the pluralized attribute name (e.g visibilities rather than visibility)
diff --git a/app/models/pref.rb b/app/models/pref.rb
index ec0b9cb..e0e553e 100644
--- a/app/models/pref.rb
+++ b/app/models/pref.rb
@@ -8,15 +8,26 @@
#
class Pref < ActiveRecord::Base
+ include ValidationMessages
+
##
# Serialize prefs to JSON
# The settings object only stores deviations from the default
serialize :settings, JSON
- ##
- # Associations
+ # ================
+ # = Associations =
+ # ================
belongs_to :user
+ # ===============
+ # = Validations =
+ # ===============
+
+ validates :user, presence: { message: PRESENCE_MESSAGE }
+
+ validates :settings, presence: { message: PRESENCE_MESSAGE }
+
##
# Returns the hash generated from default preferences
#
diff --git a/app/models/question.rb b/app/models/question.rb
index fd1b8b3..180b675 100644
--- a/app/models/question.rb
+++ b/app/models/question.rb
@@ -24,31 +24,68 @@
#
class Question < ActiveRecord::Base
+ include ValidationMessages
+ include ActsAsSortable
+ # include
##
# Sort order: Number ASC
default_scope { order(number: :asc) }
- ##
- # Associations
- has_many :answers, :dependent => :destroy
- has_many :question_options, :dependent => :destroy, :inverse_of => :question # inverse_of needed for nester forms
- has_many :annotations, :dependent => :destroy, :inverse_of => :question
+ # ================
+ # = Associations =
+ # ================
+
+ has_many :answers, dependent: :destroy
+
+ # inverse_of needed for nested forms
+ has_many :question_options, dependent: :destroy, inverse_of: :question
+
+ has_many :annotations, dependent: :destroy, inverse_of: :question
+
has_and_belongs_to_many :themes, join_table: "questions_themes"
+
belongs_to :section
+
belongs_to :question_format
+
has_one :phase, through: :section
+
has_one :template, through: :section
- ##
- # Nested Attributes
+
+ # ===============
+ # = Validations =
+ # ===============
+
+ validates :text, presence: { message: PRESENCE_MESSAGE }
+
+ validates :section, presence: { message: PRESENCE_MESSAGE }
+
+ validates :question_format, presence: { message: PRESENCE_MESSAGE }
+
+ validates :number, presence: { message: PRESENCE_MESSAGE },
+ uniqueness: { scope: :section_id,
+ message: UNIQUENESS_MESSAGE }
+
+
+ # =====================
+ # = Nested Attributes =
+ # =====================
+
# TODO: evaluate if we need this
- accepts_nested_attributes_for :answers, :reject_if => lambda {|a| a[:text].blank? }, :allow_destroy => true
- accepts_nested_attributes_for :question_options, :reject_if => lambda {|a| a[:text].blank? }, :allow_destroy => true
- accepts_nested_attributes_for :annotations, :allow_destroy => true
+ accepts_nested_attributes_for :answers, reject_if: -> (a) { a[:text].blank? },
+ allow_destroy: true
+
+ accepts_nested_attributes_for :question_options, allow_destroy: true,
+ reject_if: -> (a) { a[:text].blank? }
+
+ accepts_nested_attributes_for :annotations, allow_destroy: true
- validates :text, :section, :number, presence: {message: _("can't be blank")}
+ # ===========================
+ # = Public instance methods =
+ # ===========================
##
# returns the text from the question
diff --git a/app/models/question_format.rb b/app/models/question_format.rb
index d450406..a911ce0 100644
--- a/app/models/question_format.rb
+++ b/app/models/question_format.rb
@@ -12,15 +12,23 @@
#
class QuestionFormat < ActiveRecord::Base
+ include ValidationMessages
+ include ValidationValues
##
+ #
+ FORMAT_TYPES = %i[textarea textfield radiobuttons checkbox dropdown
+ multiselectbox date rda_metadata]
+ ##
# Associations
has_many :questions
- enum formattype: [ :textarea, :textfield, :radiobuttons, :checkbox, :dropdown, :multiselectbox, :date, :rda_metadata ]
+ enum formattype: FORMAT_TYPES
- validates :title, presence: {message: _("can't be blank")}, uniqueness: {message: _("must be unique")}
+ validates :title, presence: { message: PRESENCE_MESSAGE },
+ uniqueness: { message: UNIQUENESS_MESSAGE }
+ validates :option_based, inclusion: { in: BOOLEAN_VALUES }
# Retrieves the id for a given formattype passed
scope :id_for, -> (formattype) { where(formattype: formattype).pluck(:id).first }
diff --git a/app/models/question_option.rb b/app/models/question_option.rb
index cac8958..eb02e20 100644
--- a/app/models/question_option.rb
+++ b/app/models/question_option.rb
@@ -20,15 +20,41 @@
#
class QuestionOption < ActiveRecord::Base
- ##
- # Associations
+ include ValidationMessages
+ include ValidationValues
+
+ # ================
+ # = Associations =
+ # ================
+
belongs_to :question
+
has_and_belongs_to_many :answers, join_table: :answers_question_options
- validates :text, :question, :number, presence: {message: _("can't be blank")}
+ # ===============
+ # = Validations =
+ # ===============
+
+ validates :text, presence: { message: PRESENCE_MESSAGE }
+
+ validates :question, presence: { message: PRESENCE_MESSAGE }
+
+ validates :number, presence: { message: PRESENCE_MESSAGE }
+
+ validates :is_default, inclusion: { in: BOOLEAN_VALUES,
+ message: INCLUSION_MESSAGE }
+
+ # ==========
+ # = Scopes =
+ # ==========
scope :by_number, -> { order(:number) }
+
+ # =================
+ # = Class methods =
+ # =================
+
##
# deep copy the given question_option and all it's associations
#
@@ -40,6 +66,10 @@
return question_option_copy
end
+ # ===========================
+ # = Public instance methods =
+ # ===========================
+
def deep_copy(**options)
copy = self.dup
copy.question_id = options.fetch(:question_id, nil)
diff --git a/app/models/region.rb b/app/models/region.rb
index d198285..6dd483f 100644
--- a/app/models/region.rb
+++ b/app/models/region.rb
@@ -9,11 +9,27 @@
# super_region_id :integer
#
-class Region < ActiveRecord::Base
- has_many :sub_regions, class_name: 'Region', foreign_key: 'super_region_id'
-
- belongs_to :super_region, class_name: 'Region'
-
- validates :name, presence: {message: _("can't be blank")}, uniqueness: {message: _("must be unique")}
- validates :abbreviation, uniqueness: {message: _("must be unique")}, allow_nil: true
+class Region < ActiveRecord::Base
+ include ValidationMessages
+
+ # ================
+ # = Associations =
+ # ================
+
+ has_many :sub_regions, class_name: 'Region', foreign_key: 'super_region_id'
+
+ belongs_to :super_region, class_name: 'Region'
+
+ # ===============
+ # = Validations =
+ # ===============
+
+ validates :name, presence: { message: PRESENCE_MESSAGE },
+ uniqueness: { message: UNIQUENESS_MESSAGE }
+
+ validates :description, presence: true
+
+ validates :abbreviation, presence: { message: PRESENCE_MESSAGE },
+ uniqueness: { message: UNIQUENESS_MESSAGE }
+
end
diff --git a/app/models/role.rb b/app/models/role.rb
index bbcfb53..8726a29 100644
--- a/app/models/role.rb
+++ b/app/models/role.rb
@@ -22,8 +22,10 @@
#
class Role < ActiveRecord::Base
- after_initialize :set_defaults
+
include FlagShihTzu
+ include ValidationMessages
+ include ValidationValues
##
# Associationsrequire "role"
@@ -41,8 +43,27 @@
5 => :reviewer, # 16
column: 'access'
- validates :user, :plan, :access, presence: {message: _("can't be blank")}
- validates :access, numericality: {greater_than: 0, message: _("can't be less than zero")}
+ # ===============
+ # = Validations =
+ # ===============
+
+ validates :user, presence: { message: PRESENCE_MESSAGE }
+
+ validates :plan, presence: { message: PRESENCE_MESSAGE }
+
+ validates :active, inclusion: { in: BOOLEAN_VALUES,
+ message: INCLUSION_MESSAGE }
+
+ validates :access, presence: { message: PRESENCE_MESSAGE },
+ numericality: { greater_than: 0, only_integer: true,
+ message: _("can't be less than zero") }
+
+ # =============
+ # = Callbacks =
+ # =============
+
+ # TODO: Push this down to the DB constraints
+ after_initialize :set_defaults
##
# Roles with given FlagShihTzu access flags
diff --git a/app/models/section.rb b/app/models/section.rb
index 116e95f..fe812b6 100644
--- a/app/models/section.rb
+++ b/app/models/section.rb
@@ -22,28 +22,57 @@
#
class Section < ActiveRecord::Base
- ##
- # Associations
+ include ValidationMessages
+ include ValidationValues
+ include ActsAsSortable
+
+ # ================
+ # = Associations =
+ # ================
+
belongs_to :phase
belongs_to :organisation
has_many :questions, dependent: :destroy
has_one :template, through: :phase
- # Link the data
- accepts_nested_attributes_for :questions, reject_if: lambda { |a| a[:text].blank? }, allow_destroy: true
+ # ===============
+ # = Validations =
+ # ===============
+ validates :phase, presence: { message: PRESENCE_MESSAGE }
- ##
- # Validations
- validates :phase, :title, presence: { message: _("can't be blank") }
+ validates :title, presence: { message: PRESENCE_MESSAGE }
- validates :number, presence: { message: _("can't be blank") },
- uniqueness: { scope: :phase_id }
+ # validates :description, presence: { message: PRESENCE_MESSAGE }
+ validates :number, presence: { message: PRESENCE_MESSAGE },
+ uniqueness: { scope: :phase_id,
+ message: UNIQUENESS_MESSAGE }
+
+ validates :published, inclusion: { in: BOOLEAN_VALUES,
+ message: INCLUSION_MESSAGE }
+
+ validates :modifiable, inclusion: { in: BOOLEAN_VALUES,
+ message: INCLUSION_MESSAGE }
+
+ # =============
+ # = Callbacks =
+ # =============
+
+ # TODO: Move this down to DB constraints
before_validation :set_defaults
- ##
- # Scopes
+ # =====================
+ # = Nested Attributes =
+ # =====================
+
+ accepts_nested_attributes_for :questions,
+ reject_if: -> (a) { a[:text].blank? },
+ allow_destroy: true
+
+ # ==========
+ # = Scopes =
+ # ==========
# The sections for this Phase that have been added by the admin
#
@@ -57,22 +86,9 @@
# @return [ActiveRecord::Relation] Returns the sections that aren't modifiable
scope :not_modifiable, -> { where(modifiable: false) }
- # =================
- # = Class methods =
- # =================
-
- def self.update_numbers!(*ids, phase:)
- # Ensure only section ids belonging to this Phase are included.
- ids = ids.map(&:to_i) & phase.section_ids
- return if ids.empty?
- case connection.adapter_name
- when "PostgreSQL" then update_numbers_postgresql!(ids)
- when "Mysql2" then update_numbers_mysql2!(ids)
- else
- update_numbers_sequentially!(ids)
- end
- end
-
+ # ===========================
+ # = Public instance methods =
+ # ===========================
##
# return the title of the section
@@ -106,29 +122,9 @@
private
- def self.update_numbers_postgresql!(ids)
- # Build an Array with each ID and its relative position in the Array
- values = ids.each_with_index.map { |id, i| "(#{id}, #{i + 1})" }.join(", ")
- # Run a single UPDATE query for all records.
- query = <<~SQL
- UPDATE #{table_name} \
- SET number = svals.number \
- FROM (VALUES #{sanitize_sql(values)}) AS svals(id, number) \
- WHERE svals.id = #{table_name}.id;
- SQL
- connection.execute(query)
- end
-
- def self.update_numbers_mysql2!(ids)
- ids_string = ids.map { |id| "'#{id}'" }.join(",")
- update_all(%Q{ number = FIELD(id, #{sanitize_sql(ids_string)}) })
- end
-
- def self.update_numbers_sequentially!(ids)
- ids.each_with_index.map do |id, number|
- find(id).update_attribute(:number, number + 1)
- end
- end
+ # ============================
+ # = Private instance methods =
+ # ============================
def set_defaults
self.modifiable = true if modifiable.nil?
diff --git a/app/models/template.rb b/app/models/template.rb
index 50e2616..ef69476 100644
--- a/app/models/template.rb
+++ b/app/models/template.rb
@@ -33,26 +33,59 @@
class Template < ActiveRecord::Base
include GlobalHelpers
- include ActiveModel::Validations
+ include ValidationMessages
+ include ValidationValues
validates_with TemplateLinksValidator
- before_validation :set_defaults
- after_update :reconcile_published, if: Proc.new { |template| template.published? }
# Stores links as an JSON object: { funder: [{"link":"www.example.com","text":"foo"}, ...], sample_plan: [{"link":"www.example.com","text":"foo"}, ...]}
# The links is validated against custom validator allocated at validators/template_links_validator.rb
serialize :links, JSON
- ##
- # Associations
+ # ================
+ # = Associations =
+ # ================
+
belongs_to :org
+
has_many :plans
+
has_many :phases, dependent: :destroy
+
has_many :sections, through: :phases
+
has_many :questions, through: :sections
+
has_many :annotations, through: :questions
+ # ===============
+ # = Validations =
+ # ===============
+
+ validates :title, presence: { message: PRESENCE_MESSAGE }
+
+ validates :org, presence: { message: PRESENCE_MESSAGE }
+
+ validates :locale, presence: { message: PRESENCE_MESSAGE }
+
+ validates :version, presence: { message: PRESENCE_MESSAGE },
+ uniqueness: { message: UNIQUENESS_MESSAGE,
+ scope: :family_id }
+
+ validates :visibility, presence: { message: PRESENCE_MESSAGE }
+
+ validates :family_id, presence: { message: PRESENCE_MESSAGE }
+
+
+ # =============
+ # = Callbacks =
+ # =============
+ before_validation :set_defaults
+
+ after_update :reconcile_published, if: -> (template) { template.published? }
+
+
# ==========
# = Scopes =
# ==========
diff --git a/app/models/theme.rb b/app/models/theme.rb
index 4a35f38..857ee30 100644
--- a/app/models/theme.rb
+++ b/app/models/theme.rb
@@ -11,19 +11,34 @@
#
class Theme < ActiveRecord::Base
+ include ValidationMessages
- ##
- # Associations
+ # ================
+ # = Associations =
+ # ================
+
has_and_belongs_to_many :questions, join_table: "questions_themes"
has_and_belongs_to_many :guidances, join_table: "themes_in_guidance"
+ # ===============
+ # = Validations =
+ # ===============
- validates :title, presence: {message: _("can't be blank")}
+ validates :title, presence: { message: PRESENCE_MESSAGE }
+
+ # ==========
+ # = Scopes =
+ # ==========
scope :search, -> (term) {
search_pattern = "%#{term}%"
where("title LIKE ? OR description LIKE ?", search_pattern, search_pattern)
}
+
+ # ===========================
+ # = Public instance methods =
+ # ===========================
+
##
# returns the title of the theme
#
@@ -31,5 +46,4 @@
def to_s
title
end
-
end
diff --git a/app/models/token_permission_type.rb b/app/models/token_permission_type.rb
index 71370f6..8c75ee3 100644
--- a/app/models/token_permission_type.rb
+++ b/app/models/token_permission_type.rb
@@ -10,22 +10,42 @@
#
class TokenPermissionType < ActiveRecord::Base
+ include ValidationMessages
+
+ # =============
+ # = Constants =
+ # =============
+
##
- # Associations
- #has_and_belongs_to_many :org_token_permissions, join_table: "org_token_permissions"
-# has_and_belongs_to_many :organisations, join_table: 'org_token_permissions', unique: true
+ #
+ GUIDANCES = TokenPermissionType.where(token_type: 'guidances').first.freeze
+
+ ##
+ #
+ PLANS = TokenPermissionType.where(token_type: 'plans').first.freeze
+
+ ##
+ #
+ TEMPLATES = TokenPermissionType.where(token_type: 'templates').first.freeze
+
+ ##
+ #
+ STATISTICS = TokenPermissionType.where(token_type: 'statistics').first.freeze
+
+
+ # ================
+ # = Associations =
+ # ================
+
has_and_belongs_to_many :orgs, join_table: 'org_token_permissions', unique: true
- ##
- # Validators
- validates :token_type, presence: {message: _("can't be blank")}, uniqueness: {message: _("must be unique")}
- ##
- # Constant Token Permission Types
- GUIDANCES = TokenPermissionType.where(token_type: 'guidances').first.freeze
- PLANS = TokenPermissionType.where(token_type: 'plans').first.freeze
- TEMPLATES = TokenPermissionType.where(token_type: 'templates').first.freeze
- STATISTICS = TokenPermissionType.where(token_type: 'statistics').first.freeze
+ # ==============
+ # = Validators =
+ # ==============
+
+ validates :token_type, presence: { message: PRESENCE_MESSAGE },
+ uniqueness: { message: UNIQUENESS_MESSAGE }
##
diff --git a/app/models/user.rb b/app/models/user.rb
index 7e4157d..4c01e36 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -49,6 +49,9 @@
class User < ActiveRecord::Base
include ConditionalUserMailer
+ include ValidationMessages
+ include ValidationValues
+
##
# Devise
# Include default devise modules. Others available are:
@@ -63,16 +66,27 @@
# User Notification Preferences
serialize :prefs, Hash
- ##
- # Associations
+ # ================
+ # = Associations =
+ # ================
+
+
has_and_belongs_to_many :perms, join_table: :users_perms
+
belongs_to :language
+
belongs_to :org
+
has_one :pref
+
has_many :answers
+
has_many :notes
+
has_many :exported_plans
+
has_many :roles, dependent: :destroy
+
has_many :plans, through: :roles do
def filter(query)
return self unless query.present?
@@ -88,14 +102,26 @@
end
end
+
has_many :user_identifiers
+
has_many :identifier_schemes, through: :user_identifiers
+
has_and_belongs_to_many :notifications, dependent: :destroy, join_table: 'notification_acknowledgements'
- validates :email, email: true, allow_nil: true, uniqueness: {message: _("must be unique")}
- ##
- # Scopes
+ # ===============
+ # = Validations =
+ # ===============
+
+ validates :active, inclusion: { in: BOOLEAN_VALUES,
+ message: INCLUSION_MESSAGE }
+
+
+ # ==========
+ # = Scopes =
+ # ==========
+
default_scope { includes(:org, :perms) }
# Retrieves all of the org_admins for the specified org
@@ -115,8 +141,16 @@
end
}
+ # =============
+ # = Callbacks =
+ # =============
+
after_update :when_org_changes
+ # ===========================
+ # = Public instance methods =
+ # ===========================
+
##
# This method uses Devise's built-in handling for inactive users
def active_for_authentication?
@@ -139,7 +173,6 @@
end
end
-
##
# gives either the name of the user, or the email if name unspecified
#
@@ -162,7 +195,6 @@
self.plans.includes(:template).where("roles.active": true).where(Role.not_reviewer_condition)
end
-
##
# Returns the user's identifier for the specified scheme name
#
@@ -349,6 +381,11 @@
end
private
+
+ # ============================
+ # = Private instance methods =
+ # ============================
+
def when_org_changes
if org_id != org_id_was
unless can_change_org?
diff --git a/app/models/user_identifier.rb b/app/models/user_identifier.rb
index 60d83ee..5a1f5ad 100644
--- a/app/models/user_identifier.rb
+++ b/app/models/user_identifier.rb
@@ -20,11 +20,23 @@
#
class UserIdentifier < ActiveRecord::Base
+ include ValidationMessages
+
+ # ================
+ # = Associations =
+ # ================
+
belongs_to :user
belongs_to :identifier_scheme
-
- # Should only be able to have one identifier per scheme!
- validates_uniqueness_of :identifier_scheme, scope: :user
-
- validates :identifier, :user, :identifier_scheme, presence: {message: _("can't be blank")}
+
+ # ===============
+ # = Validations =
+ # ===============
+
+ validates :user, presence: true
+
+ validates :identifier_scheme, presence: { message: PRESENCE_MESSAGE }
+
+ validates :identifier, presence: { message: PRESENCE_MESSAGE }
+
end
diff --git a/app/validators/after_validator.rb b/app/validators/after_validator.rb
new file mode 100644
index 0000000..57fe579
--- /dev/null
+++ b/app/validators/after_validator.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+class AfterValidator < ActiveModel::EachValidator
+
+ DEFAULT_MESSAGE = _("must be after %{date}")
+
+ def validate_each(record, attribute, value)
+ return if value.nil?
+ return if record.persisted? && options[:on].to_s == 'create'
+ return if record.new_record? && options[:on].to_s == 'update'
+ date = options[:date]
+ msg = options.fetch(:message, DEFAULT_MESSAGE % { date: options[:date] })
+ record.errors.add(attribute, msg) if value.to_date < options[:date]
+ end
+end
diff --git a/db/seeds.rb b/db/seeds.rb
index ec54a12..2450b5e 100644
--- a/db/seeds.rb
+++ b/db/seeds.rb
@@ -2,29 +2,69 @@
# This file should contain all the record creation needed to seed the database with its default values.
# The data can then be loaded with the rake db:seed (or created alongside the db with db:setup).
+require 'factory_bot'
+include FactoryBot::Syntax::Methods
+
# Identifier Schemes
# -------------------------------------------------------
identifier_schemes = [
- {name: 'orcid', description: 'ORCID', active: true,
- logo_url:'http://orcid.org/sites/default/files/images/orcid_16x16.png',
- user_landing_url:'https://orcid.org' },
- {name: 'shibboleth', description: 'Your institutional credentials', active: true,
+ {
+ name: 'orcid',
+ description: 'ORCID',
+ active: true,
+ logo_url:'http://orcid.org/sites/default/files/images/orcid_16x16.png',
+ user_landing_url:'https://orcid.org'
+ },
+ {
+ name: 'shibboleth',
+ description: 'Your institutional credentials',
+ active: true,
+ logo_url: 'http://newsite.shibboleth.net/wp-content/uploads/2017/01/Shibboleth-logo_2000x1200-1.png',
+ user_landing_url: "https://example.com"
},
]
-identifier_schemes.map{ |is| IdentifierScheme.create!(is) if IdentifierScheme.find_by(name: is[:name]).nil? }
+identifier_schemes.map { |is| create(:identifier_scheme, is) }
# Question Formats
# -------------------------------------------------------
question_formats = [
- {title: "Text area", option_based: false, formattype: 0},
- {title: "Text field", option_based: false, formattype: 1},
- {title: "Radio buttons", option_based: true, formattype: 2},
- {title: "Check box", option_based: true, formattype: 3},
- {title: "Dropdown", option_based: true, formattype: 4},
- {title: "Multi select box", option_based: true, formattype: 5},
- {title: "Date", option_based: true, formattype: 6}
+ {
+ title: "Text area",
+ option_based: false,
+ formattype: 0
+ },
+ {
+ title: "Text field",
+ option_based: false,
+ formattype: 1
+ },
+ {
+ title: "Radio buttons",
+ option_based: true,
+ formattype: 2
+ },
+ {
+ title: "Check box",
+ option_based: true,
+ formattype: 3
+ },
+ {
+ title: "Dropdown",
+ option_based: true,
+ formattype: 4
+ },
+ {
+ title: "Multi select box",
+ option_based: true,
+ formattype: 5
+ },
+ {
+ title: "Date",
+ option_based: true,
+ formattype: 6
+ }
]
-question_formats.map{ |qf| QuestionFormat.create!(qf) if QuestionFormat.find_by(title: qf[:title]).nil? }
+question_formats.map{ |qf| create(:question_format, qf) }
# Languages (check config/locales for any ones not defined here)
# -------------------------------------------------------
@@ -50,7 +90,7 @@
name: 'EspaƱol',
default_language: false}
]
-languages.map{ |l| Language.create!(l) if Language.find_by(abbreviation: l[:abbreviation]).nil? }
+languages.map { |l| create(:language, l) }
# Scan through the locale files and add an entry if a file is present but
# not defined in this seed file
@@ -128,7 +168,7 @@
{name: 'grant_api_to_orgs'}
]
-perms.map{ |p| Perm.create!(p) if Perm.find_by(name: p[:name]).nil? }
+perms.map{ |p| create(:perm, p) }
# Guidance Themes
# -------------------------------------------------------
@@ -148,7 +188,7 @@
{title: 'Budget'},
{title: 'Related Policies'}
]
-themes.map{ |t| Theme.create!(t) if Theme.find_by(title: t[:title]).nil? }
+themes.map{ |t| create(:theme, t) }
# Token Permission Types
# -------------------------------------------------------
@@ -158,15 +198,15 @@
{token_type: 'templates', text_description: 'allows a user access to the templates api endpoint'},
{token_type: 'statistics', text_description: 'allows a user access to the statistics api endpoint'}
]
-token_permission_types.map{ |tpt| TokenPermissionType.create!(tpt) if TokenPermissionType.find_by(token_type: tpt[:token_type]).nil? }
+token_permission_types.map{ |tpt| create(:token_permission_type, tpt) }
# Create our generic organisation, a funder and a University
# -------------------------------------------------------
orgs = [
- {name: Rails.configuration.branding[:organisation][:name],
- abbreviation: Rails.configuration.branding[:organisation][:abbreviation],
+ {name: Branding.fetch(:organisation, :name),
+ abbreviation: Branding.fetch(:organisation, :abbreviation),
org_type: 4, links: {"org":[]},
- language_id: Language.find_by(abbreviation: 'en_GB'),
+ language: Language.find_by(abbreviation: 'en_GB'),
token_permission_types: TokenPermissionType.all},
{name: 'Government Agency',
abbreviation: 'GA',
@@ -177,7 +217,7 @@
org_type: 1, links: {"org":[]},
language: Language.find_by(abbreviation: 'en_GB')}
]
-orgs.map{ |o| Org.create!(o) if Org.find_by(abbreviation: o[:abbreviation]).nil? }
+orgs.map{ |o| create(:org, o) }
# Create a Super Admin associated with our generic organisation,
# an Org Admin for our funder and an Org Admin and User for our University
@@ -188,7 +228,7 @@
surname: "Admin",
password: "password123",
password_confirmation: "password123",
- org: Org.find_by(abbreviation: Rails.configuration.branding[:organisation][:abbreviation]),
+ org: Org.find_by(abbreviation: Branding.fetch(:organisation, :abbreviation)),
language: Language.find_by(abbreviation: FastGettext.locale),
perms: Perm.all,
accept_terms: true,
@@ -226,7 +266,7 @@
accept_terms: true,
confirmed_at: Time.zone.now}
]
-users.map{ |u| User.create!(u) if User.find_by(email: u[:email]).nil? }
+users.map{ |u| create(:user, u) }
# Create a Guidance Group for our organisation and the funder
# -------------------------------------------------------
@@ -240,7 +280,7 @@
optional_subset: false,
published: true}
]
-guidance_groups.map{ |gg| GuidanceGroup.create!(gg) if GuidanceGroup.find_by(name: gg[:name]).nil? }
+guidance_groups.map{ |gg| create(:guidance_group, gg) }
# Initialize with the generic Roadmap guidance and a sample funder guidance
# -------------------------------------------------------
@@ -343,7 +383,7 @@
published: true,
themes: [Theme.find_by(title: 'Data Description')]}
]
-guidances.map{ |g| Guidance.create!(g) if Guidance.find_by(text: g[:text]).nil? }
+guidances.map{ |g| create(:guidance, g) }
# Create a default template for the curation centre and one for the example funder
# -------------------------------------------------------
@@ -375,15 +415,7 @@
]
# Template creation calls defaults handler which sets is_default and
# published to false automatically, so update them after creation
-templates.map do |t|
- if Template.find_by(title: t[:title]).nil?
- tmplt = Template.create!(t)
- tmplt.published = t[:published]
- tmplt.is_default = t[:is_default]
- tmplt.visibility = t[:visibility]
- tmplt.save!
- end
-end
+templates.each { |atts| create(:template, atts) }
# Create 2 phases for the funder's template and one for our generic template
# -------------------------------------------------------
@@ -407,11 +439,11 @@
modifiable: false,
template: Template.find_by(title: "Department of Testing Award")}
]
-phases.map{ |p| Phase.create!(p) if Phase.find_by(title: p[:title]).nil? }
+phases.map{ |p| create(:phase, p) }
generic_template_phase_1 = Phase.find_by(title: "Generic Data Management Planning Template")
-funder_template_phase_1 = Phase.find_by(title: "Preliminary Statement of Work")
-funder_template_phase_2 = Phase.find_by(title: "Detailed Overview")
+funder_template_phase_1 = Phase.find_by(title: "Preliminary Statement of Work")
+funder_template_phase_2 = Phase.find_by(title: "Detailed Overview")
# Create sections for the 2 templates and their phases
# -------------------------------------------------------
@@ -499,7 +531,7 @@
modifiable: false,
phase: funder_template_phase_2}
]
-sections.map{ |s| Section.create!(s) if Section.find_by(title: s[:title]).nil? }
+sections.map{ |s| create(:section, s) }
text_area = QuestionFormat.find_by(title: "Text area")
@@ -680,9 +712,9 @@
modifiable: false,
themes: [Theme.find_by(title: "Preservation"), Theme.find_by(title: "Data Sharing")]}
]
-questions.map{ |q| Question.create!(q) if Question.find_by(text: q[:text]).nil? }
+questions.map{ |q| create(:question, q) }
-drop_down_question = Question.find_by(text: "Where will you store your data during the research period?")
+drop_down_question = Question.find_by(text: "Where will you store your data during the research period?")
multi_select_question = Question.find_by(text: "What type(s) of data will you collect?")
radio_button_question = Question.find_by(text: "Please select the appropriate formats.")
diff --git a/lib/data_cleanup.rb b/lib/data_cleanup.rb
new file mode 100644
index 0000000..985768b
--- /dev/null
+++ b/lib/data_cleanup.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+module DataCleanup
+ require_relative "data_cleanup/model_check"
+ require_relative "data_cleanup/instance_check"
+ require_relative "data_cleanup/reporting"
+ require_relative "data_cleanup/rules"
+
+ module_function
+
+ def logger
+ @logger ||= Logger.new(Rails.root.join("log", "validations.log"))
+ end
+
+ COLOR_CODES = { red: 31, green: 32 }
+
+ def logger.info(message, inline: false, color: nil)
+ message = message + "\n" unless inline
+ if color
+ message = "\e[#{COLOR_CODES[color]}m#{message}\e[0m"
+ end
+ super(message) unless inline
+ print message
+ end
+end
diff --git a/lib/data_cleanup/README.md b/lib/data_cleanup/README.md
new file mode 100644
index 0000000..0574bf3
--- /dev/null
+++ b/lib/data_cleanup/README.md
@@ -0,0 +1,80 @@
+# DataCleanups
+
+This module is used to clean database records that have become invalid since data validation rules have changed within the app.
+
+## Rake tasks
+
+This module adds two rake tasks, one for finding invalid records, and one for fixing them.
+
+### Usage
+
+Find invalid records.
+
+#### Warning:
+
+This will iterate over every record on your database. It could easily take over an hour to run.
+
+``` bash
+$ rake data_cleanup:find_invalid_records
+```
+
+Find invalid records for a given list of models.
+
+``` bash
+$ rake data_cleanup:find_invalid_records INCLUDE=Note,User
+```
+
+...or...
+
+``` bash
+$ rake data_cleanup:find_invalid_records EXCLUDE=Annotation
+```
+
+---
+
+Fix invalid records based on the rules defined in `lib/data_cleanup/rules`.
+
+#### Warning:
+
+This will update the records on your database, often using `update_all`. Make sure you:
+
+- **have a backup of your database**
+- **you are comfortable you understand what each of these rules are doing to your data**
+
+``` bash
+$ rake data_cleanup:clean_invalid_records
+```
+
+Or to clean a given table...
+
+``` bash
+$ rake data_cleanup:clean_invalid_records INCLUDE=Question
+```
+
+To avoid a given table...
+
+``` bash
+$ rake data_cleanup:clean_invalid_records EXAMPLE=Plan
+```
+
+## Rules
+
+Each type of data error is fixed separately.
+
+These are defined in `lib/data_cleanup/rules`.
+
+### Creating a new rule
+
+You can create a new rule by running the following genrator:
+
+``` bash
+$ rails g data_cleanup_rule user/fix_missing_emails
+```
+
+This will create a file `lib/data_cleanup/rules/user/fix_missing_emails.rb` which contains the rules for updating users with missing emails.
+
+Feel free to add your own rules where neccesary to fix your own data.
+
+## Logging output
+
+Output from the Rake tasks will be logged to `log/validations.log`.
diff --git a/lib/data_cleanup/instance_check.rb b/lib/data_cleanup/instance_check.rb
new file mode 100644
index 0000000..c7569b8
--- /dev/null
+++ b/lib/data_cleanup/instance_check.rb
@@ -0,0 +1,23 @@
+require_relative "reporting"
+
+module DataCleanup
+ # Check whether a given database record is valid or not
+ class InstanceCheck
+ # frozen_string_literal: true
+
+ def call(instance)
+ Reporting.total_record_count += 1
+ begin
+ if instance.invalid?
+ Reporting.invalid_record_count += 1
+ Reporting.invalid_records << instance
+ DataCleanup.logger.info("F", inline: true)
+ else
+ DataCleanup.logger.info(".", inline: true)
+ end
+ rescue Dragonfly::Job::Fetch::NotFound
+ DataCleanup.logger.info(".", inline: true)
+ end
+ end
+ end
+end
\ No newline at end of file
diff --git a/lib/data_cleanup/model_check.rb b/lib/data_cleanup/model_check.rb
new file mode 100644
index 0000000..1b42446
--- /dev/null
+++ b/lib/data_cleanup/model_check.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+module DataCleanup
+ # Checks whether all records for a given model are valid or not
+ class ModelCheck
+
+ require_relative "reporting"
+
+ attr_reader :model
+
+ def initialize(model)
+ @model = model
+ end
+
+ def call
+ rule, models = ARGV[1].to_s.split("=")
+ case rule
+ when 'INCLUDE'
+ return unless model.model_name.in?(models.split(","))
+ when 'EXCLUDE'
+ return if model.model_name.in?(models.split(","))
+ end
+ DataCleanup.logger.info "Checking #{model.model_name.plural}:"
+ model.find_in_batches do |batch|
+ instance_check = InstanceCheck.new
+ batch.each { |instance| instance_check.(instance) }
+ end
+ DataCleanup.logger.info ""
+ end
+ end
+end
diff --git a/lib/data_cleanup/reporting.rb b/lib/data_cleanup/reporting.rb
new file mode 100644
index 0000000..ebeba3b
--- /dev/null
+++ b/lib/data_cleanup/reporting.rb
@@ -0,0 +1,34 @@
+module DataCleanup
+ # Report the status of the data validations after checks have been run.
+ module Reporting
+ mattr_accessor :total_record_count
+ mattr_accessor :invalid_record_count
+ mattr_accessor :invalid_records
+ mattr_accessor :issues_found
+
+ self.total_record_count = 0
+ self.invalid_record_count = 0
+ self.invalid_records = []
+ self.issues_found = []
+
+ module_function
+
+ # Prepare the report for printing to log and STDOUT
+ def prepare!
+ invalid_records.each do |record|
+ record.errors.full_messages.each do |issue|
+ desc = "#{record.class.model_name} was invalid: #{issue}"
+ issues_found << desc unless issues_found.include?(desc)
+ end
+ end
+ end
+
+ def report
+ issues_found.each { |issue| DataCleanup.logger.info issue }
+ color = invalid_record_count.zero? ? :green : :red
+ DataCleanup.logger.info(<<~TEXT, color: color)
+ Invalid records: #{invalid_record_count} / #{total_record_count}
+ TEXT
+ end
+ end
+end
diff --git a/lib/data_cleanup/rules.rb b/lib/data_cleanup/rules.rb
new file mode 100644
index 0000000..155b86b
--- /dev/null
+++ b/lib/data_cleanup/rules.rb
@@ -0,0 +1,7 @@
+require_relative "rules/base"
+
+module DataCleanup
+ module Rules
+
+ end
+end
diff --git a/lib/data_cleanup/rules/answer/fix_blank_user.rb b/lib/data_cleanup/rules/answer/fix_blank_user.rb
new file mode 100644
index 0000000..987834a
--- /dev/null
+++ b/lib/data_cleanup/rules/answer/fix_blank_user.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+module DataCleanup
+ module Rules
+ # Fix blank user on Answer
+ module Answer
+ class FixBlankUser < Rules::Base
+
+ def description
+ "Fix blank user on Answer"
+ end
+
+ def call
+ ::Answer.where(user: nil).destroy_all
+ end
+ end
+ end
+ end
+end
diff --git a/lib/data_cleanup/rules/answer/fix_duplicate_question.rb b/lib/data_cleanup/rules/answer/fix_duplicate_question.rb
new file mode 100644
index 0000000..300d4a5
--- /dev/null
+++ b/lib/data_cleanup/rules/answer/fix_duplicate_question.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+module DataCleanup
+ module Rules
+ # Fix duplicate question on Answer
+ module Answer
+ class FixDuplicateQuestion < Rules::Base
+
+ def description
+ "Fix duplicate question on Answer"
+ end
+
+ def call
+ # Take all answers that have duplicate question/plan combo...
+ dataset = ::Answer.group(:question_id, :plan_id)
+ .count.select { |k,v| v > 1 }
+ # Values looks like [{ [123, 199] => 2}, ...]
+ dataset.each do |values, count|
+ # ... and destroy all duplicates, keeping the latest record
+ ::Answer.where(question: values.first, plan_id: values.last)
+ .order("created_at DESC")
+ .offset(1)
+ .destroy_all
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/data_cleanup/rules/base.rb b/lib/data_cleanup/rules/base.rb
new file mode 100644
index 0000000..cff2d1d
--- /dev/null
+++ b/lib/data_cleanup/rules/base.rb
@@ -0,0 +1,17 @@
+module DataCleanup
+ module Rules
+ # Base class for rules to clean invalid database records
+ class Base
+
+ # Description of the rule and how it's fixing the data
+ def description
+ self.class.name.humanize
+ end
+
+ # Run this rule and fix data in the database.
+ def call
+ raise NotImplementedError, "Please define call() in #{self}"
+ end
+ end
+ end
+end
diff --git a/lib/data_cleanup/rules/exported_plan/fix_blank_plan.rb b/lib/data_cleanup/rules/exported_plan/fix_blank_plan.rb
new file mode 100644
index 0000000..90d0363
--- /dev/null
+++ b/lib/data_cleanup/rules/exported_plan/fix_blank_plan.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module DataCleanup
+ module Rules
+ # Fix blank plan on ExportedPlan
+ module ExportedPlan
+ class FixBlankPlan < Rules::Base
+
+ def description
+ "Fix blank plan on ExportedPlan"
+ end
+
+ def call
+ ::ExportedPlan.where(plan: nil).delete_all
+ end
+ end
+ end
+ end
+end
diff --git a/lib/data_cleanup/rules/guidance_group/fix_blank_optional_subset.rb b/lib/data_cleanup/rules/guidance_group/fix_blank_optional_subset.rb
new file mode 100644
index 0000000..9cad432
--- /dev/null
+++ b/lib/data_cleanup/rules/guidance_group/fix_blank_optional_subset.rb
@@ -0,0 +1,17 @@
+module DataCleanup
+ module Rules
+ module GuidanceGroup
+ class FixBlankOptionalSubset < Rules::Base
+
+ def description
+ "Fix blank optional subset on GuidanceGroup"
+ end
+
+ def call
+ ::GuidanceGroup.where(optional_subset: nil)
+ .update_all(optional_subset: false)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/data_cleanup/rules/guidance_group/fix_blank_published.rb b/lib/data_cleanup/rules/guidance_group/fix_blank_published.rb
new file mode 100644
index 0000000..f11b95b
--- /dev/null
+++ b/lib/data_cleanup/rules/guidance_group/fix_blank_published.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+module DataCleanup
+ module Rules
+ module GuidanceGroup
+ # Fix blank published on GuidanceGroup
+ class FixBlankPublished < Rules::Base
+
+ def description
+ "Fix blank published on GuidanceGroup"
+ end
+
+ def call
+ ::GuidanceGroup.where(published: nil).update_all(published: false)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/data_cleanup/rules/note/fix_blank_archived.rb b/lib/data_cleanup/rules/note/fix_blank_archived.rb
new file mode 100644
index 0000000..0f12339
--- /dev/null
+++ b/lib/data_cleanup/rules/note/fix_blank_archived.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+module DataCleanup
+ module Rules
+ # Fix blank archived on Note
+ module Note
+ class FixBlankArchived < Rules::Base
+
+ def description
+ "Fix blank archived on Note"
+ end
+
+ def call
+ ::Note.where(archived: nil).update_all(archived: false)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/data_cleanup/rules/org/fix_blank_abbreviation.example.yml b/lib/data_cleanup/rules/org/fix_blank_abbreviation.example.yml
new file mode 100644
index 0000000..1133989
--- /dev/null
+++ b/lib/data_cleanup/rules/org/fix_blank_abbreviation.example.yml
@@ -0,0 +1,9 @@
+-
+ id: 1234
+ name: University of Edinburgh
+ abbreviation: UoE
+-
+ id: 1235
+ name: De Montfort University
+ abbreviation: "DMU"
+# ...
\ No newline at end of file
diff --git a/lib/data_cleanup/rules/org/fix_blank_abbreviation.rb b/lib/data_cleanup/rules/org/fix_blank_abbreviation.rb
new file mode 100644
index 0000000..40354b6
--- /dev/null
+++ b/lib/data_cleanup/rules/org/fix_blank_abbreviation.rb
@@ -0,0 +1,27 @@
+module DataCleanup
+ module Rules
+ module Org
+ class FixBlankAbbreviation < Rules::Base
+
+ YAML_FILE_PATH = Rails.root.join("lib", "data_cleanup", "rules", "org",
+ "fix_blank_abbreviation.yml")
+
+ def description
+ "Fix blank abbreviation on Org"
+ end
+
+ def call
+ if File.exists?(YAML_FILE_PATH)
+ YAML.load_file(YAML_FILE_PATH).each do |attributes|
+ attributes = attributes.with_indifferent_access
+ ::Org.where(name: attributes["name"], id: attributes['id'])
+ .update_all(abbreviation: attributes['abbreviation'])
+ end
+ else
+ raise "Please create a YAML file at #{YAML_FILE_PATH}"
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/data_cleanup/rules/org/fix_blank_feedback_email_msg.rb b/lib/data_cleanup/rules/org/fix_blank_feedback_email_msg.rb
new file mode 100644
index 0000000..cefdc38
--- /dev/null
+++ b/lib/data_cleanup/rules/org/fix_blank_feedback_email_msg.rb
@@ -0,0 +1,26 @@
+module DataCleanup
+ module Rules
+ module Org
+ class FixBlankFeedbackEmailMsg < Rules::Base
+
+ DEFAULT_MSG = <<~HTML
+Hello %{user_name}.
+
+ Your plan "%{plan_name}" has been submitted for feedback from an administrator
+ at your organisation. If you have questions
+ pertaining to this action, please contact us at %{organisation_email}.
+
+ HTML
+
+ def description
+ "Fix orgs where feedback_enabled is true"
+ end
+
+ def call
+ ::Org.where(feedback_enabled: true, feedback_email_msg: "")
+ .update_all(feedback_email_msg: DEFAULT_MSG)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/data_cleanup/rules/org/fix_blank_feedback_email_subject.rb b/lib/data_cleanup/rules/org/fix_blank_feedback_email_subject.rb
new file mode 100644
index 0000000..e190b22
--- /dev/null
+++ b/lib/data_cleanup/rules/org/fix_blank_feedback_email_subject.rb
@@ -0,0 +1,19 @@
+module DataCleanup
+ module Rules
+ module Org
+ class FixBlankFeedbackEmailSubject < Rules::Base
+
+ DEFAULT_SUBJECT = "%{application_name}: Your plan has been submitted for feedback"
+
+ def description
+ "Fix orgs where feedback_enabled is true"
+ end
+
+ def call
+ ::Org.where(feedback_enabled: true, feedback_email_subject: "")
+ .update_all(feedback_email_subject: DEFAULT_SUBJECT)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/data_cleanup/rules/org/fix_blank_is_other.rb b/lib/data_cleanup/rules/org/fix_blank_is_other.rb
new file mode 100644
index 0000000..b255720
--- /dev/null
+++ b/lib/data_cleanup/rules/org/fix_blank_is_other.rb
@@ -0,0 +1,16 @@
+module DataCleanup
+ module Rules
+ module Org
+ class FixBlankIsOther < Rules::Base
+
+ def description
+ "Fix nil values for is_other on Org"
+ end
+
+ def call
+ ::Org.where(is_other: nil).update_all(is_other: false)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/data_cleanup/rules/org/fix_blank_language.rb b/lib/data_cleanup/rules/org/fix_blank_language.rb
new file mode 100644
index 0000000..14ba0ff
--- /dev/null
+++ b/lib/data_cleanup/rules/org/fix_blank_language.rb
@@ -0,0 +1,18 @@
+module DataCleanup
+ module Rules
+ module Org
+ class FixBlankLanguage < Rules::Base
+
+ DEFAULT_LANGUAGE = Language.find_by(abbreviation: FastGettext.default_locale)
+
+ def description
+ "Fix blank language on Org"
+ end
+
+ def call
+ ::Org.where(language: nil).update_all(language_id: DEFAULT_LANGUAGE.id)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/data_cleanup/rules/phase/fix_duplicate_number.rb b/lib/data_cleanup/rules/phase/fix_duplicate_number.rb
new file mode 100644
index 0000000..e733dcd
--- /dev/null
+++ b/lib/data_cleanup/rules/phase/fix_duplicate_number.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+module DataCleanup
+ module Rules
+ # Fix duplicate number on Phase
+ module Phase
+ class FixDuplicateNumber < Rules::Base
+
+ def description
+ "Fix duplicate number on Phase"
+ end
+
+ def call
+ data = ::Phase.group(:number, :template_id)
+ .count
+ .select { |k,v| v > 1 }
+ data.each do |values, count|
+ number, template_id = *values
+ ids = ::Phase.where(template_id: template_id)
+ .order("number ASC, created_at ASC")
+ .pluck(:id)
+ template = ::Template.find(template_id)
+ ::Phase.update_numbers!(*ids, parent: template)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/data_cleanup/rules/plan/fix_blank_title.rb b/lib/data_cleanup/rules/plan/fix_blank_title.rb
new file mode 100644
index 0000000..8dc87a9
--- /dev/null
+++ b/lib/data_cleanup/rules/plan/fix_blank_title.rb
@@ -0,0 +1,18 @@
+module DataCleanup
+ module Rules
+ module Plan
+ class FixBlankTitle < Rules::Base
+
+ def description
+ "Fix blank title on Plan"
+ end
+
+ def call
+ ::Plan.where(title: [nil, '']).each do |plan|
+ plan.update(title: "My plan (#{plan.template.title})")
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/data_cleanup/rules/question/fix_duplicate_number.rb b/lib/data_cleanup/rules/question/fix_duplicate_number.rb
new file mode 100644
index 0000000..083b8fb
--- /dev/null
+++ b/lib/data_cleanup/rules/question/fix_duplicate_number.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+module DataCleanup
+ module Rules
+ # Fix duplicate number on Question
+ module Question
+ class FixDuplicateNumber < Rules::Base
+
+ def description
+ "Fix duplicate number on Question"
+ end
+
+ def call
+ data = ::Question.group(:number, :section_id)
+ .count
+ .select { |k,v| v > 1 }
+ data.each do |values, count|
+ number, section_id = *values
+ ids = ::Question.where(section_id: section_id)
+ .order("number ASC, created_at ASC")
+ .pluck(:id)
+ section = ::Section.find(section_id)
+ ::Question.update_numbers!(*ids, parent: section)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/data_cleanup/rules/question_format/fix_blank_description.rb b/lib/data_cleanup/rules/question_format/fix_blank_description.rb
new file mode 100644
index 0000000..7c8f06c
--- /dev/null
+++ b/lib/data_cleanup/rules/question_format/fix_blank_description.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+module DataCleanup
+ module Rules
+ # Fix blank description on QuestionFormat
+ module QuestionFormat
+ class FixBlankDescription < Rules::Base
+
+ def description
+ "Fix blank description on QuestionFormat"
+ end
+
+ def call
+ ::QuestionFormat.where(description: "").each do |qf|
+ qf.update!(description: "#{qf.title} format")
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/data_cleanup/rules/region/fix_blank_description.rb b/lib/data_cleanup/rules/region/fix_blank_description.rb
new file mode 100644
index 0000000..1ddee40
--- /dev/null
+++ b/lib/data_cleanup/rules/region/fix_blank_description.rb
@@ -0,0 +1,18 @@
+module DataCleanup
+ module Rules
+ module Region
+ class FixBlankDescription < Rules::Base
+
+ def description
+ "Fix blank description on region"
+ end
+
+ def call
+ ::Region.where(description: [nil, '']).each do |region|
+ region.update!(description: "#{region.name} region")
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/data_cleanup/rules/role/fix_blank_plan.rb b/lib/data_cleanup/rules/role/fix_blank_plan.rb
new file mode 100644
index 0000000..3e4219b
--- /dev/null
+++ b/lib/data_cleanup/rules/role/fix_blank_plan.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+module DataCleanup
+ module Rules
+ # Fix blank plan on Role
+ module Role
+ class FixBlankPlan < Rules::Base
+
+ def description
+ "Fix blank plan on Role"
+ end
+
+ def call
+ ::Role.where(plan: nil).destroy_all
+ end
+ end
+ end
+ end
+end
diff --git a/lib/data_cleanup/rules/section/fix_duplicate_number.rb b/lib/data_cleanup/rules/section/fix_duplicate_number.rb
new file mode 100644
index 0000000..c2c41c8
--- /dev/null
+++ b/lib/data_cleanup/rules/section/fix_duplicate_number.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+module DataCleanup
+ module Rules
+ # Fix duplicate number on Section
+ module Section
+ class FixDuplicateNumber < Rules::Base
+
+ def description
+ "Fix duplicate number on Section"
+ end
+
+ def call
+ data = ::Section.group(:number, :phase_id)
+ .count
+ .select { |k,v| v > 1 }
+ data.each do |values, count|
+ number, phase_id = *values
+ ids = ::Section.where(phase_id: phase_id)
+ .order("number ASC, created_at ASC")
+ .pluck(:id)
+ phase = ::Phase.find(phase_id)
+ ::Section.update_numbers!(*ids, parent: phase)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/data_cleanup/rules/section/fix_nil_published.rb b/lib/data_cleanup/rules/section/fix_nil_published.rb
new file mode 100644
index 0000000..cb75525
--- /dev/null
+++ b/lib/data_cleanup/rules/section/fix_nil_published.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+module DataCleanup
+ module Rules
+ # Fix nil published on Section
+ module Section
+ class FixNilPublished < Rules::Base
+
+ def description
+ "Fix nil published on Section"
+ end
+
+ def call
+ ::Section.where(published: nil).update_all(published: false)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/data_cleanup/rules/template/fix_blank_locale.rb b/lib/data_cleanup/rules/template/fix_blank_locale.rb
new file mode 100644
index 0000000..4fdd5e7
--- /dev/null
+++ b/lib/data_cleanup/rules/template/fix_blank_locale.rb
@@ -0,0 +1,17 @@
+module DataCleanup
+ module Rules
+ module Template
+ class FixBlankLocale < Rules::Base
+
+ def description
+ "Fix blank locale on template"
+ end
+
+ def call
+ ::Template.where(locale: nil)
+ .update_all(locale: FastGettext.default_locale)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/data_cleanup/rules/user_identifier/fix_blank_user.rb b/lib/data_cleanup/rules/user_identifier/fix_blank_user.rb
new file mode 100644
index 0000000..9676652
--- /dev/null
+++ b/lib/data_cleanup/rules/user_identifier/fix_blank_user.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+module DataCleanup
+ module Rules
+ # Fix blank user on UserIdentifier
+ module UserIdentifier
+ class FixBlankUser < Rules::Base
+
+ def description
+ "Fix UserIdentifier records with no User"
+ end
+
+ def call
+ ::UserIdentifier.where(user: nil).destroy_all
+ end
+ end
+ end
+ end
+end
diff --git a/lib/generators/data_cleanup_rule/USAGE b/lib/generators/data_cleanup_rule/USAGE
new file mode 100644
index 0000000..7264472
--- /dev/null
+++ b/lib/generators/data_cleanup_rule/USAGE
@@ -0,0 +1,8 @@
+Description:
+ Add a new rule for cleaning invalid database records
+
+Example:
+ rails generate data_cleanup_rule user/fix_missing_emails
+
+ This will create:
+ lib/tasks/data_cleanup/rules/user/fix_missing_emails.rb
diff --git a/lib/generators/data_cleanup_rule/data_cleanup_rule_generator.rb b/lib/generators/data_cleanup_rule/data_cleanup_rule_generator.rb
new file mode 100644
index 0000000..6e48031
--- /dev/null
+++ b/lib/generators/data_cleanup_rule/data_cleanup_rule_generator.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+# Generator class for creating a new Rule to clean DB records.
+class DataCleanupRuleGenerator < Rails::Generators::NamedBase
+ source_root File.expand_path('templates', __dir__)
+
+ # Copy the Rule template and create a new Rule file
+ def add_rule_file
+ template "rule.rb.erb", rule_path.to_s
+ end
+
+ private
+
+ # The name of the model we're fixing (e.g. 'User')
+ #
+ # Returns String
+ def model_name
+ file_path.split("/").first.classify
+ end
+
+ # The name of the Rule class we're creating (e.g. 'User::FixBlankEmail')
+ #
+ # Returns String
+ def rule_class_name
+ file_path.split("/").last.classify
+ end
+
+ # The file path for the new Rule class
+ #
+ # Returns String
+ def rule_path
+ Rails.root.join("lib", "data_cleanup", "rules", "#{file_path}.rb")
+ end
+
+ # A default description to populate the Rule#description method
+ #
+ # Returns String
+ def default_description
+ format("%s on %s",
+ rule_name: rule_class_name.underscore
+ .split("_")
+ .join(" ")
+ .capitalize,
+ model_name: model_name.classify)
+ end
+end
diff --git a/lib/generators/data_cleanup_rule/templates/rule.rb.erb b/lib/generators/data_cleanup_rule/templates/rule.rb.erb
new file mode 100644
index 0000000..bad37d1
--- /dev/null
+++ b/lib/generators/data_cleanup_rule/templates/rule.rb.erb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+module DataCleanup
+ module Rules
+ # <%= default_description %>
+ module <%= model_name %>
+ class <%= rule_class_name %> < Rules::Base
+
+ def description
+ "<%= default_description %>"
+ end
+
+ def call
+ ::<%= model_name %>.where(nil).update_all({})
+ end
+ end
+ end
+ end
+end
diff --git a/lib/tasks/heal_invalid_records.rake b/lib/tasks/heal_invalid_records.rake
new file mode 100644
index 0000000..18a017f
--- /dev/null
+++ b/lib/tasks/heal_invalid_records.rake
@@ -0,0 +1,47 @@
+require "data_cleanup"
+
+namespace :data_cleanup do
+
+ desc "Check each record on the DB is valid and report"
+ task :find_invalid_records => :environment do
+ models.each do |model|
+ DataCleanup::ModelCheck.new(model).call
+ end
+ DataCleanup::Reporting.prepare!
+ DataCleanup::Reporting.report
+ end
+
+ desc "Clean invalid records on the database"
+ task :clean_invalid_records => :environment do
+ Dir[rule_paths].each do |rule_path|
+ load rule_path
+ klass_name = rule_path.split("rules/").last.gsub(".rb", '').classify
+ model_name = klass_name.split("::").first
+ opt, models = ARGV[1].to_s.split("=")
+ case opt
+ when 'INCLUDE'
+ next unless model_name.in?(models.split(","))
+ when 'EXCLUDE'
+ next if model_name.in?(models.split(","))
+ else
+ raise ArgumentError, "Unknown option: #{opt}"
+ end
+ rule_class = DataCleanup::Rules.const_get(klass_name)
+ rule = rule_class.new
+ puts rule.description
+ rule.call
+ end
+ end
+
+ private
+
+ def rule_paths
+ @rule_paths ||= Rails.root.join("lib", "data_cleanup", "rules", "*", "*.rb")
+ end
+
+ def models
+ Dir[Rails.root.join("app", "models", "*.rb")].map do |model_path|
+ model_path.split("/").last.gsub(".rb", "").classify.constantize
+ end.sort_by(&:name)
+ end
+end
diff --git a/lib/tasks/upgrade.rake b/lib/tasks/upgrade.rake
index e4e86fe..8a10e0c 100644
--- a/lib/tasks/upgrade.rake
+++ b/lib/tasks/upgrade.rake
@@ -5,7 +5,7 @@
task clean_section_numbers: :environment do
Phase.all.each do |phase|
ids_in_order = phase.sections.order("number, created_at").pluck(:id)
- Section.update_numbers!(*ids_in_order, phase: phase)
+ Section.update_numbers!(*ids_in_order, parent: phase)
end
end
diff --git a/spec/factories/annotations.rb b/spec/factories/annotations.rb
index 04cef9d..fc8562c 100644
--- a/spec/factories/annotations.rb
+++ b/spec/factories/annotations.rb
@@ -16,5 +16,6 @@
question
org
text { Faker::Lorem.paragraph }
+ type { [0,1].sample }
end
end
diff --git a/spec/factories/guidance_groups.rb b/spec/factories/guidance_groups.rb
index 3e7ae69..f7e2b49 100644
--- a/spec/factories/guidance_groups.rb
+++ b/spec/factories/guidance_groups.rb
@@ -16,6 +16,7 @@
name { Faker::Lorem.unique.word }
org
published true
+ optional_subset false
trait :unpublished do
published false
end
diff --git a/spec/factories/guidances.rb b/spec/factories/guidances.rb
index b4863f4..dbc6929 100644
--- a/spec/factories/guidances.rb
+++ b/spec/factories/guidances.rb
@@ -15,6 +15,6 @@
factory :guidance do
text { Faker::Lorem.sentence }
guidance_group
- question_id { create(:question).id }
+ question
end
end
diff --git a/spec/factories/identifier_schemes.rb b/spec/factories/identifier_schemes.rb
index 11bacdf..34e2def 100644
--- a/spec/factories/identifier_schemes.rb
+++ b/spec/factories/identifier_schemes.rb
@@ -16,5 +16,8 @@
factory :identifier_scheme do
name { Faker::Company.unique.name }
description { Faker::StarWars.quote }
+ logo_url { Faker::Internet.url }
+ user_landing_url { Faker::Internet.url }
+ active true
end
end
diff --git a/spec/factories/languages.rb b/spec/factories/languages.rb
index 1eb5d11..3d227e5 100644
--- a/spec/factories/languages.rb
+++ b/spec/factories/languages.rb
@@ -13,7 +13,14 @@
factory :language do
name "English"
description "Test language English"
- abbreviation { SecureRandom.hex(2) }
+ abbreviation { ("a".."z").to_a.shuffle.take(2).join }
default_language true
+ trait :with_dialect do
+ abbreviation {
+ pre = ("a".."z").to_a.shuffle.take(2).join
+ suf = ("A".."Z").to_a.shuffle.take(2).join
+ [pre, suf].join("_")
+ }
+ end
end
end
diff --git a/spec/factories/orgs.rb b/spec/factories/orgs.rb
index 8c5eead..cfdcb3e 100644
--- a/spec/factories/orgs.rb
+++ b/spec/factories/orgs.rb
@@ -29,8 +29,12 @@
FactoryBot.define do
factory :org do
- sequence(:name) { |i| Faker::Company.unique.name + i.to_s }
+ name { Faker::Company.unique.name }
links { { "org" => [] } }
- abbreviation { SecureRandom.hex(2) }
+ abbreviation { self.name[0..10] }
+ feedback_enabled false
+ region { Region.first || create(:region) }
+ language { Language.first || create(:language) }
+ is_other false
end
end
diff --git a/spec/factories/phases.rb b/spec/factories/phases.rb
index 3adb7b4..8952830 100644
--- a/spec/factories/phases.rb
+++ b/spec/factories/phases.rb
@@ -19,5 +19,6 @@
description { Faker::Lorem.paragraph }
sequence(:number)
template
+ modifiable true
end
end
diff --git a/spec/factories/plans.rb b/spec/factories/plans.rb
index ee90ea9..ccfb538 100644
--- a/spec/factories/plans.rb
+++ b/spec/factories/plans.rb
@@ -37,5 +37,8 @@
principal_investigator_email { Faker::Internet.safe_email }
feedback_requested false
complete false
+ trait :creator do
+ after(:create) { |obj| obj.roles << create(:role, creator: true) }
+ end
end
end
diff --git a/spec/factories/question_formats.rb b/spec/factories/question_formats.rb
index 0336584..a79929c 100644
--- a/spec/factories/question_formats.rb
+++ b/spec/factories/question_formats.rb
@@ -15,5 +15,6 @@
factory :question_format do
title { Faker::Lorem.words(3).join }
description { Faker::Lorem.sentence }
+ formattype { QuestionFormat::FORMAT_TYPES.sample }
end
end
diff --git a/spec/factories/templates.rb b/spec/factories/templates.rb
index 539076e..9014287 100644
--- a/spec/factories/templates.rb
+++ b/spec/factories/templates.rb
@@ -24,5 +24,6 @@
org
title { Faker::Lorem.sentence }
description { Faker::Lorem.paragraph }
+ locale { "en_GB" }
end
end
diff --git a/spec/factories/themes.rb b/spec/factories/themes.rb
index a3c619d..9dc836d 100644
--- a/spec/factories/themes.rb
+++ b/spec/factories/themes.rb
@@ -14,5 +14,6 @@
factory :theme do
title { Faker::Lorem.sentence }
description { Faker::Lorem.paragraph }
+ locale "en_GB"
end
end
diff --git a/spec/models/annotation_spec.rb b/spec/models/annotation_spec.rb
new file mode 100644
index 0000000..cd62942
--- /dev/null
+++ b/spec/models/annotation_spec.rb
@@ -0,0 +1,19 @@
+require 'rails_helper'
+
+RSpec.describe Annotation, type: :model do
+
+ context "validations" do
+
+ subject { build(:annotation) }
+
+ it { is_expected.to validate_presence_of(:text) }
+
+ it { is_expected.to validate_presence_of(:org) }
+
+ it { is_expected.to validate_presence_of(:question) }
+
+ it { is_expected.to validate_presence_of(:type) }
+
+ end
+
+end
diff --git a/spec/models/answer_spec.rb b/spec/models/answer_spec.rb
new file mode 100644
index 0000000..6761242
--- /dev/null
+++ b/spec/models/answer_spec.rb
@@ -0,0 +1,19 @@
+require 'rails_helper'
+
+RSpec.describe Answer, type: :model do
+
+ context "validations" do
+ subject { build(:answer) }
+
+ it { is_expected.to validate_presence_of(:plan) }
+
+ it { is_expected.to validate_presence_of(:user) }
+
+ it { is_expected.to validate_presence_of(:question) }
+
+ it { is_expected.to validate_uniqueness_of(:question)
+ .scoped_to(:plan_id)
+ .with_message("must be unique") }
+ end
+
+end
diff --git a/spec/models/guidance_group_spec.rb b/spec/models/guidance_group_spec.rb
new file mode 100644
index 0000000..823a0c9
--- /dev/null
+++ b/spec/models/guidance_group_spec.rb
@@ -0,0 +1,25 @@
+require 'rails_helper'
+
+RSpec.describe GuidanceGroup, type: :model do
+
+ context "validations" do
+
+ it { is_expected.to validate_presence_of(:name) }
+
+ it { is_expected.to validate_uniqueness_of(:name)
+ .with_message('must be unique')
+ .scoped_to(:org_id) }
+
+ it { is_expected.to validate_presence_of(:org) }
+
+ it { is_expected.to allow_value(true).for(:optional_subset) }
+
+ it { is_expected.to allow_value(true).for(:published) }
+
+ it { is_expected.to allow_value(false).for(:optional_subset) }
+
+ it { is_expected.to allow_value(false).for(:published) }
+
+ end
+
+end
diff --git a/spec/models/guidance_spec.rb b/spec/models/guidance_spec.rb
new file mode 100644
index 0000000..20cc146
--- /dev/null
+++ b/spec/models/guidance_spec.rb
@@ -0,0 +1,17 @@
+require 'rails_helper'
+
+RSpec.describe Guidance, type: :model do
+
+ context "validations" do
+
+ it { is_expected.to validate_presence_of(:text) }
+
+ it { is_expected.to validate_presence_of(:guidance_group) }
+
+ it { is_expected.to allow_value(true).for(:published) }
+
+ it { is_expected.to allow_value(false).for(:published) }
+
+ end
+
+end
diff --git a/spec/models/identifier_scheme_spec.rb b/spec/models/identifier_scheme_spec.rb
new file mode 100644
index 0000000..2131076
--- /dev/null
+++ b/spec/models/identifier_scheme_spec.rb
@@ -0,0 +1,19 @@
+require 'rails_helper'
+
+RSpec.describe IdentifierScheme, type: :model do
+
+ context "validations" do
+
+ it { is_expected.to validate_presence_of(:name) }
+
+ it { is_expected.to validate_length_of(:name).is_at_most(30) }
+
+ it { is_expected.to allow_value(true).for(:name) }
+
+ it { is_expected.to allow_value(false).for(:name) }
+
+ it { is_expected.to_not allow_value(nil).for(:name) }
+
+ end
+
+end
diff --git a/spec/models/language_spec.rb b/spec/models/language_spec.rb
new file mode 100644
index 0000000..225b3fb
--- /dev/null
+++ b/spec/models/language_spec.rb
@@ -0,0 +1,28 @@
+require 'rails_helper'
+
+RSpec.describe Language, type: :model do
+
+ context "validations" do
+
+ subject { build(:language) }
+
+ it { is_expected.to validate_presence_of(:name) }
+
+ it { is_expected.to validate_length_of(:name).is_at_most(20) }
+
+ it { is_expected.to validate_presence_of(:abbreviation) }
+
+ it { is_expected.to validate_uniqueness_of(:abbreviation)
+ .with_message("must be unique") }
+
+ it { is_expected.to allow_values('en', 'en_GB').for(:abbreviation) }
+
+ it { is_expected.not_to allow_value('NOOP', 'en_', 'EN')
+ .for(:abbreviation) }
+
+ it { is_expected.to validate_length_of(:abbreviation).is_at_most(5) }
+
+
+ end
+
+end
diff --git a/spec/models/note_spec.rb b/spec/models/note_spec.rb
new file mode 100644
index 0000000..03f7627
--- /dev/null
+++ b/spec/models/note_spec.rb
@@ -0,0 +1,19 @@
+require 'rails_helper'
+
+RSpec.describe Note, type: :model do
+
+ context "validations" do
+
+ it { is_expected.to validate_presence_of(:text) }
+
+ it { is_expected.to validate_presence_of(:answer) }
+
+ it { is_expected.to validate_presence_of(:user) }
+
+ it { is_expected.to allow_values(true, false).for(:archived) }
+
+ it { is_expected.not_to allow_value(nil).for(:archived) }
+
+ end
+
+end
diff --git a/spec/models/notification_spec.rb b/spec/models/notification_spec.rb
new file mode 100644
index 0000000..be310a3
--- /dev/null
+++ b/spec/models/notification_spec.rb
@@ -0,0 +1,33 @@
+require 'rails_helper'
+
+RSpec.describe Notification, type: :model do
+
+ context "validations" do
+
+ it { is_expected.to validate_presence_of(:notification_type) }
+
+ it { is_expected.to validate_presence_of(:title) }
+
+ it { is_expected.to validate_presence_of(:level) }
+
+ it { is_expected.to validate_presence_of(:body) }
+
+ it { is_expected.to allow_values(true, false).for(:dismissable) }
+
+ it { is_expected.not_to allow_value(nil).for(:dismissable) }
+
+ it { is_expected.to validate_presence_of(:starts_at) }
+
+ it { is_expected.to validate_presence_of(:expires_at) }
+
+ it { is_expected.to allow_value(Date.today).for(:starts_at) }
+
+ it { is_expected.not_to allow_value(1.day.ago).for(:starts_at) }
+
+ it { is_expected.to allow_value(2.days.from_now).for(:expires_at) }
+
+ it { is_expected.not_to allow_value(Date.today).for(:expires_at) }
+
+ end
+
+end
diff --git a/spec/models/org_identifier_spec.rb b/spec/models/org_identifier_spec.rb
new file mode 100644
index 0000000..98d58d1
--- /dev/null
+++ b/spec/models/org_identifier_spec.rb
@@ -0,0 +1,23 @@
+require 'rails_helper'
+
+RSpec.describe OrgIdentifier, type: :model do
+
+ context "validations" do
+
+ it do
+ # https://github.com/thoughtbot/shoulda-matchers/issues/682
+ subject.identifier_scheme = create(:identifier_scheme)
+ is_expected.to validate_uniqueness_of(:identifier_scheme_id)
+ .scoped_to(:org_id)
+ .with_message("must be unique")
+ end
+
+ end
+
+ it { is_expected.to validate_presence_of(:identifier) }
+
+ it { is_expected.to validate_presence_of(:org) }
+
+ it { is_expected.to validate_presence_of(:identifier_scheme) }
+
+end
diff --git a/spec/models/org_spec.rb b/spec/models/org_spec.rb
new file mode 100644
index 0000000..3209373
--- /dev/null
+++ b/spec/models/org_spec.rb
@@ -0,0 +1,49 @@
+require 'rails_helper'
+
+RSpec.describe Org, type: :model do
+
+ context "validations" do
+
+ it { is_expected.to validate_presence_of(:name) }
+
+ it {
+ subject.name = "DMP Company"
+ is_expected.to validate_uniqueness_of(:name)
+ .with_message("must be unique")
+ }
+
+ it { is_expected.to validate_presence_of(:abbreviation) }
+
+ it { is_expected.to allow_values(true, false).for(:is_other) }
+
+ it { is_expected.not_to allow_value(nil).for(:is_other) }
+
+ it { is_expected.to validate_presence_of(:language) }
+
+ it "validates presence of contact_email if feedback_enabled" do
+ subject.feedback_enabled = true
+ is_expected.to validate_presence_of(:contact_email)
+ end
+
+ it "doesn't validate presence of contact_email if feedback_enabled nil" do
+ subject.feedback_enabled = false
+ is_expected.not_to validate_presence_of(:contact_email)
+ end
+
+ # validates :contact_email, presence: { message: PRESENCE_MESSAGE,
+ # if: :feedback_enabled }
+ #
+ # validates :org_type, presence: { message: PRESENCE_MESSAGE }
+ #
+ # validates :feedback_enabled, inclusion: { in: BOOLEAN_VALUES,
+ # message: INCLUSION_MESSAGE }
+ #
+ # validates :feedback_email_subject, presence: { message: PRESENCE_MESSAGE,
+ # if: :feedback_enabled }
+ #
+ # validates :feedback_email_msg, presence: { message: PRESENCE_MESSAGE,
+ # if: :feedback_enabled }
+ #
+ end
+
+end
diff --git a/spec/models/perm_spec.rb b/spec/models/perm_spec.rb
new file mode 100644
index 0000000..5a5e780
--- /dev/null
+++ b/spec/models/perm_spec.rb
@@ -0,0 +1,11 @@
+require 'rails_helper'
+
+RSpec.describe Perm, type: :model do
+ context "validations" do
+
+ it { is_expected.to validate_presence_of(:name) }
+
+ it { is_expected.to validate_uniqueness_of(:name)
+ .with_message("must be unique") }
+ end
+end
diff --git a/spec/models/phase_spec.rb b/spec/models/phase_spec.rb
new file mode 100644
index 0000000..fbcaeaa
--- /dev/null
+++ b/spec/models/phase_spec.rb
@@ -0,0 +1,20 @@
+require 'rails_helper'
+
+RSpec.describe Phase, type: :model do
+ context "validations" do
+ it { is_expected.to validate_presence_of(:title) }
+
+ it { is_expected.to validate_presence_of(:number) }
+
+ it { is_expected.to validate_presence_of(:template) }
+
+ it { is_expected.to validate_uniqueness_of(:number)
+ .scoped_to(:template_id)
+ .with_message("must be unique") }
+
+ it { is_expected.to allow_values(true, false).for(:modifiable) }
+
+ it { is_expected.not_to allow_value(nil).for(:modifiable) }
+
+ end
+end
diff --git a/spec/models/plan_spec.rb b/spec/models/plan_spec.rb
index 9b4e919..3c03568 100644
--- a/spec/models/plan_spec.rb
+++ b/spec/models/plan_spec.rb
@@ -2,9 +2,18 @@
describe Plan do
- describe "test" do
- subject { true }
- it { is_expected.to be_truthy }
+ context "validations" do
+ it { is_expected.to validate_presence_of(:title) }
+
+ it { is_expected.to validate_presence_of(:template) }
+
+ it { is_expected.to allow_values(true, false).for(:feedback_requested) }
+
+ it { is_expected.not_to allow_value(nil).for(:feedback_requested) }
+
+ it { is_expected.to allow_values(true, false).for(:complete) }
+
+ it { is_expected.not_to allow_value(nil).for(:complete) }
end
end
diff --git a/spec/models/pref_spec.rb b/spec/models/pref_spec.rb
new file mode 100644
index 0000000..1cd289a
--- /dev/null
+++ b/spec/models/pref_spec.rb
@@ -0,0 +1,8 @@
+require 'rails_helper'
+
+RSpec.describe Pref, type: :model do
+ context "validations" do
+ it { is_expected.to validate_presence_of(:user) }
+ it { is_expected.to validate_presence_of(:settings) }
+ end
+end
diff --git a/spec/models/question_format_spec.rb b/spec/models/question_format_spec.rb
new file mode 100644
index 0000000..ebc66f3
--- /dev/null
+++ b/spec/models/question_format_spec.rb
@@ -0,0 +1,31 @@
+require 'rails_helper'
+
+RSpec.describe QuestionFormat, type: :model do
+
+ context "validations" do
+
+ it { is_expected.to validate_presence_of(:title) }
+
+ it { is_expected.to validate_uniqueness_of(:title)
+ .with_message("must be unique") }
+
+ it { is_expected.to allow_values(true, false).for(:option_based) }
+
+ it { is_expected.not_to allow_value(nil).for(:option_based) }
+
+ it { is_expected.to allow_values(:textarea, :textfield, :radiobuttons,
+ :checkbox, :dropdown, :multiselectbox,
+ :date, :rda_metadata)
+ .for(:formattype) }
+
+ end
+
+ describe "#formattype" do
+
+ it "raises an exception when value not recognised" do
+ expect { subject.formattype = :foo }.to raise_error(ArgumentError)
+ end
+
+ end
+
+end
diff --git a/spec/models/question_option_spec.rb b/spec/models/question_option_spec.rb
new file mode 100644
index 0000000..90f2635
--- /dev/null
+++ b/spec/models/question_option_spec.rb
@@ -0,0 +1,18 @@
+require 'rails_helper'
+
+RSpec.describe QuestionOption, type: :model do
+
+ context "validations" do
+
+ it { is_expected.to validate_presence_of(:question) }
+
+ it { is_expected.to validate_presence_of(:text) }
+
+ it { is_expected.to validate_presence_of(:number) }
+
+ it { is_expected.to allow_values(true, false).for(:is_default) }
+
+ it { is_expected.not_to allow_value(nil).for(:is_default) }
+
+ end
+end
diff --git a/spec/models/question_spec.rb b/spec/models/question_spec.rb
new file mode 100644
index 0000000..77f4560
--- /dev/null
+++ b/spec/models/question_spec.rb
@@ -0,0 +1,30 @@
+require 'rails_helper'
+
+RSpec.describe Question, type: :model do
+
+ context "validations" do
+
+ it { is_expected.to validate_presence_of(:text) }
+
+ it { is_expected.to validate_presence_of(:number) }
+
+ it { is_expected.to validate_uniqueness_of(:number)
+ .scoped_to(:section_id)
+ .with_message("must be unique") }
+
+ it { is_expected.to validate_presence_of(:section) }
+
+
+ it { is_expected.to validate_presence_of(:question_format) }
+
+ it { is_expected.to allow_values(true, false).for(:option_comment_display) }
+
+ it { is_expected.to allow_value(nil).for(:option_comment_display) }
+
+ it { is_expected.to allow_values(true, false).for(:modifiable) }
+
+ it { is_expected.to allow_value(nil).for(:modifiable) }
+
+ end
+
+end
diff --git a/spec/models/region_spec.rb b/spec/models/region_spec.rb
new file mode 100644
index 0000000..b9b6b84
--- /dev/null
+++ b/spec/models/region_spec.rb
@@ -0,0 +1,14 @@
+require 'rails_helper'
+
+RSpec.describe Region, type: :model do
+ context "validations" do
+ it { is_expected.to validate_presence_of(:abbreviation) }
+
+ it { is_expected.to validate_uniqueness_of(:abbreviation)
+ .with_message("must be unique") }
+
+ it { is_expected.to validate_presence_of(:description) }
+
+ it { is_expected.to validate_presence_of(:name) }
+ end
+end
diff --git a/spec/models/role_spec.rb b/spec/models/role_spec.rb
new file mode 100644
index 0000000..7bd2f42
--- /dev/null
+++ b/spec/models/role_spec.rb
@@ -0,0 +1,21 @@
+require 'rails_helper'
+
+RSpec.describe Role, type: :model do
+
+ context "validations" do
+ it { is_expected.to validate_presence_of(:user) }
+
+ it { is_expected.to validate_presence_of(:plan) }
+
+ it { is_expected.to allow_values(true, false).for(:active) }
+
+ it { is_expected.not_to allow_value(nil).for(:active) }
+
+ it { is_expected.to validate_numericality_of(:access)
+ .only_integer
+ .is_greater_than(0)
+ .with_message("can't be less than zero") }
+
+ end
+
+end
diff --git a/spec/models/section_spec.rb b/spec/models/section_spec.rb
new file mode 100644
index 0000000..9be4d0f
--- /dev/null
+++ b/spec/models/section_spec.rb
@@ -0,0 +1,21 @@
+require 'rails_helper'
+
+RSpec.describe Section, type: :model do
+ context "validations" do
+ it { is_expected.to validate_presence_of(:title) }
+
+ it { is_expected.to validate_presence_of(:number) }
+
+ it { is_expected.to validate_presence_of(:phase) }
+
+ it { is_expected.to validate_uniqueness_of(:number)
+ .scoped_to(:phase_id)
+ .with_message("must be unique") }
+
+ it { is_expected.to allow_values(true, false).for(:published) }
+
+ it { is_expected.not_to allow_value(nil).for(:published) }
+
+ it { is_expected.to allow_values(true, false).for(:modifiable) }
+ end
+end
diff --git a/spec/models/template_spec.rb b/spec/models/template_spec.rb
new file mode 100644
index 0000000..434b166
--- /dev/null
+++ b/spec/models/template_spec.rb
@@ -0,0 +1,42 @@
+require 'rails_helper'
+
+RSpec.describe Template, type: :model do
+ context "validations" do
+ it { is_expected.to validate_presence_of(:title) }
+
+ it { is_expected.to allow_values(true, false).for(:published) }
+
+ it "validates uniqueness of version by family_id" do
+ template = create(:template, version: 1, family_id: 1234)
+ new_template = build(:template, version: 1, family_id: 1234)
+ expect(new_template).not_to be_valid
+ expect(new_template).to have(1).error_on(:version)
+ end
+
+ # This is currently being set in the defaults before validation
+ # it { is_expected.not_to allow_value(nil).for(:published) }
+
+ it { is_expected.to validate_presence_of(:org) }
+
+ it { is_expected.to validate_presence_of(:locale) }
+
+ it { is_expected.to allow_values(true, false).for(:is_default) }
+
+ # This is currently being set in the defaults before validation
+ # it { is_expected.not_to allow_value(nil).for(:is_default) }
+
+ # This is currently being set in the defaults before validation
+ # it { is_expected.to validate_presence_of(:version) }
+
+ # This is currently being set in the defaults before validation
+ # it { is_expected.to validate_presence_of(:visibility) }
+
+ # This is currently being set in the defaults before validation
+ # it { is_expected.to validate_presence_of(:family_id) }
+
+ it { is_expected.to allow_values(true, false).for(:archived) }
+
+ # This is currently being set in the defaults before validation
+ # it { is_expected.not_to allow_value(nil).for(:archived) }
+ end
+end
diff --git a/spec/models/theme_spec.rb b/spec/models/theme_spec.rb
new file mode 100644
index 0000000..2d1f31f
--- /dev/null
+++ b/spec/models/theme_spec.rb
@@ -0,0 +1,9 @@
+require 'rails_helper'
+
+RSpec.describe Theme, type: :model do
+ context "validations" do
+
+ it { is_expected.to validate_presence_of(:title) }
+
+ end
+end
diff --git a/spec/models/token_permission_type_spec.rb b/spec/models/token_permission_type_spec.rb
new file mode 100644
index 0000000..17956bd
--- /dev/null
+++ b/spec/models/token_permission_type_spec.rb
@@ -0,0 +1,7 @@
+require 'rails_helper'
+
+RSpec.describe TokenPermissionType, type: :model do
+ context "validations" do
+ it { is_expected.to validate_presence_of(:token_type) }
+ end
+end
diff --git a/spec/models/user_identifier_spec.rb b/spec/models/user_identifier_spec.rb
new file mode 100644
index 0000000..f49f212
--- /dev/null
+++ b/spec/models/user_identifier_spec.rb
@@ -0,0 +1,11 @@
+require 'rails_helper'
+
+RSpec.describe UserIdentifier, type: :model do
+ context "validations" do
+ it { is_expected.to validate_presence_of(:identifier) }
+
+ it { is_expected.to validate_presence_of(:user) }
+
+ it { is_expected.to validate_presence_of(:identifier_scheme) }
+ end
+end
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
new file mode 100644
index 0000000..0a4cb80
--- /dev/null
+++ b/spec/models/user_spec.rb
@@ -0,0 +1,24 @@
+require 'rails_helper'
+
+RSpec.describe User, type: :model do
+ context "validations" do
+ it { is_expected.to validate_presence_of(:email) }
+
+ it "should validate that email addres is unqique" do
+ subject.email = "text-email@example.com"
+ is_expected.to validate_uniqueness_of(:email)
+ .case_insensitive
+ .with_message("has already been taken")
+ end
+
+ it { is_expected.to allow_values("one@example.com", "foo-bar@ed.ac.uk")
+ .for(:email) }
+
+ it { is_expected.not_to allow_values("example.com", "foo bar@ed.ac.uk")
+ .for(:email) }
+
+ it { is_expected.to allow_values(true, false).for(:active) }
+
+ it { is_expected.not_to allow_value(nil).for(:active) }
+ end
+end
diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb
index 5ed51e8..09013da 100644
--- a/spec/rails_helper.rb
+++ b/spec/rails_helper.rb
@@ -6,7 +6,6 @@
abort("The Rails environment is running in production mode!") if Rails.env.production?
require 'rspec/rails'
# Add additional requires below this line. Rails is not loaded until this point!
-
# Requires supporting ruby files with custom matchers and macros, etc, in
# spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are
# run as spec files by default. This means that files in spec/support that end
diff --git a/spec/support/factory_bot.rb b/spec/support/factory_bot.rb
index 1ee3e3f..ce5b54a 100644
--- a/spec/support/factory_bot.rb
+++ b/spec/support/factory_bot.rb
@@ -1,5 +1,8 @@
RSpec.configure do |config|
+
+ config.include(FactoryBot::Syntax::Methods)
+
config.before(:suite) do
- FactoryBot.lint
+ # FactoryBot.lint
end
end
diff --git a/spec/support/shoulda.rb b/spec/support/shoulda.rb
new file mode 100644
index 0000000..afa6498
--- /dev/null
+++ b/spec/support/shoulda.rb
@@ -0,0 +1,6 @@
+require 'shoulda/matchers'
+
+RSpec.configure do |config|
+ config.include(Shoulda::Matchers::ActiveModel, type: :model)
+ config.include(Shoulda::Matchers::ActiveRecord, type: :model)
+end