common/validations.rb
require 'date'
require 'time'
require 'barcode_check'
module JSONModel::Validations
extend JSONModel
def self.check_identifier(hash)
ids = (0...4).map {|i| hash["id_#{i}"]}
errors = []
if ids.reverse.drop_while {|elt| elt.to_s.empty?}.any? {|elt| elt.to_s.empty?}
errors << ["identifier", "must not contain blank entries"]
end
errors
end
[:archival_object, :accession, :resource].each do |type|
if JSONModel(type)
JSONModel(type).add_validation("#{type}_check_identifier") do |hash|
check_identifier(hash)
end
end
end
# ANW-1232: add validations to prevent software agents from having/saving record control or agent relation subrecords.
# These records are hidden from the forms but allowed through the schema (as agent_software inherits from abstract_agent) so these validations serve to prevent these subrecords from being added via API calls.
if JSONModel(:agent_software)
JSONModel(:agent_software).add_validation("check_agent_software_subrecords") do |hash|
check_agent_software_subrecords(hash)
end
end
[:agent_function, :agent_place, :agent_occupation, :agent_topic].each do |type|
if JSONModel(type)
JSONModel(type).add_validation("check_#{type}_subject_subrecord") do |hash|
check_agent_subject_subrecord(hash)
end
end
end
[:structured_date_label].each do |type|
if JSONModel(type)
JSONModel(type).add_validation("check_structured_date_label") do |hash|
check_structured_date_label(hash)
end
end
end
[:structured_date_single].each do |type|
if JSONModel(type)
JSONModel(type).add_validation("check_structured_date_single") do |hash|
check_structured_date_single(hash)
end
end
end
[:structured_date_range].each do |type|
if JSONModel(type)
JSONModel(type).add_validation("check_structured_date_range") do |hash|
check_structured_date_range(hash)
end
end
end
[:used_language].each do |type|
if JSONModel(type)
JSONModel(type).add_validation("check_used_language") do |hash|
check_used_language(hash)
end
end
end
[:agent_sources].each do |type|
if JSONModel(type)
JSONModel(type).add_validation("check_agent_sources") do |hash|
check_agent_sources(hash)
end
end
end
[:agent_alternate_set].each do |type|
if JSONModel(type)
JSONModel(type).add_validation("check_agent_alternate_set") do |hash|
check_agent_alternate_set(hash)
end
end
end
# Specification:
# https://www.pivotaltracker.com/story/show/41430143
# See also: https://www.pivotaltracker.com/story/show/51373893
def self.check_source(hash)
errors = []
# non-authorized forms don't need source or rules
return errors if !hash['authorized']
if hash["source"].nil?
if hash["rules"].nil?
errors << ["rules", "is required when 'source' is blank"]
errors << ["source", "is required when 'rules' is blank"]
end
end
errors
end
# https://www.pivotaltracker.com/story/show/51373893
def self.check_authority_id(hash)
warnings = []
if hash["source"].nil? && hash["authority_id"]
warnings << ["source", "is required if there is an authority id"]
end
warnings
end
def self.check_name(hash)
errors = []
errors << ["sort_name", "Property is required but was missing"] if hash["sort_name"].nil? and !hash["sort_name_auto_generate"]
errors
end
[:name_person, :name_family, :name_corporate_entity, :name_software].each do |type|
if JSONModel(type)
# ANW-429: make source and rules completely optional. Is this (check_source) the right validation to change? See:
# https://docs.google.com/spreadsheets/d/1fL44mUxo8D9o45NHsjKd21ljbvWJzNBQCIm4_Q_tcTU/edit#gid=0
# ^^ Cell 85
#JSONModel(type).add_validation("#{type}_check_source") do |hash|
#check_source(hash)
#end
JSONModel(type).add_validation("#{type}_check_name") do |hash|
check_name(hash)
end
JSONModel(type).add_validation("#{type}_check_authority_id", :warning) do |hash|
check_authority_id(hash)
end
end
end
# Take a date like YYYY or YYYY-MM and pad to YYYY-MM-DD
#
# Note: this might not yield a valid date. The only goal is that something
# valid on the way in remains valid on the way out.
#
def self.normalise_date(date)
negated = date.start_with?("-")
parts = date.gsub(/^-/, '').split(/-/)
# Pad out to the right length
padded = (parts + ['01', '01']).take(3)
(negated ? "-" : "") + padded.join("-")
end
# Returns a valid date or throws if the input is invalid.
def self.parse_sloppy_date(s)
begin
Date.strptime(normalise_date(s), '%Y-%m-%d')
rescue
raise ArgumentError.new($!)
end
end
def self.check_date(hash)
errors = []
begin
begin_date = parse_sloppy_date(hash['begin']) if hash['begin']
rescue ArgumentError => e
errors << ["begin", "not a valid date"]
end
begin
if hash['end']
# If padding our end date with months/days would cause it to fall before
# the start date (e.g. if the start date was '2000-05' and the end date
# just '2000'), use the start date in place of end.
end_s = if begin_date && hash['begin'] && hash['begin'].start_with?(hash['end'])
hash['begin']
else
hash['end']
end
end_date = parse_sloppy_date(end_s)
end
rescue ArgumentError
errors << ["end", "not a valid date"]
end
if begin_date && end_date && end_date < begin_date
errors << ["end", "must not be before begin"]
end
if hash["expression"].nil? && hash["begin"].nil? && hash["end"].nil?
errors << ["expression", "is required unless a begin or end date is given"]
errors << ["begin", "is required unless an expression or an end date is given"]
errors << ["end", "is required unless an expression or a begin date is given"]
end
errors
end
def self.check_structured_date_label(hash)
errors = []
if !hash["structured_date_range"] && !hash["structured_date_single"]
errors << ["structured_date_label", "must_specify_either_a_single_or_ranged_date"]
end
if hash["structured_date_range"] && hash["structured_date_single"]
errors << ["structured_date_single", "cannot specify both a single and ranged date"]
end
if hash["structured_date_range"] && hash["date_type_structured"] == "single"
errors << ["structured_date_range", "Must specify single date for date type of single"]
end
if hash["structured_date_single"] && hash["date_type_structured"] == "range"
errors << ["structured_date_range", "Must specify range date for date type of range"]
end
return errors
end
def self.check_structured_date_single(hash)
errors = []
if hash["date_role"].nil?
errors << ["date_role", "is required"]
end
has_expr_date = !hash["date_expression"].nil? &&
!hash["date_expression"].empty?
has_std_date = !hash["date_standardized"].nil?
errors << ["date_standardized", "or date expression is required"] unless has_expr_date || has_std_date
if has_std_date
errors = check_standard_date(hash["date_standardized"], errors)
end
return errors
end
def self.check_structured_date_range(hash)
errors = []
has_begin_expr_date = !hash["begin_date_expression"].nil? &&
!hash["begin_date_expression"].empty?
has_end_expr_date = !hash["end_date_expression"].nil? &&
!hash["end_date_expression"].empty?
has_begin_std_date = !hash["begin_date_standardized"].nil? &&
!hash["begin_date_standardized"].empty?
has_end_std_date = !hash["end_date_standardized"].nil? &&
!hash["end_date_standardized"].empty?
errors << ["begin_date_expression", "is required"] if !has_begin_expr_date && (!has_begin_std_date && !has_end_std_date)
errors << ["end_date_expression", "requires begin date expression to be defined"] if !has_begin_expr_date && has_end_expr_date
errors << ["end_date_standardized", "requires begin_date_standardized to be defined"] if (!has_begin_std_date && has_end_std_date)
if has_begin_std_date
errors = check_standard_date(hash["begin_date_standardized"], errors, "begin_date_standardized")
end
if has_end_std_date
errors = check_standard_date(hash["end_date_standardized"], errors, "end_date_standardized")
end
if errors.length == 0 && hash["begin_date_standardized"] && hash["end_date_standardized"]
begin
if hash["begin_date_standardized"]
bt = parse_sloppy_date(hash["begin_date_standardized"])
end
if hash["end_date_standardized"]
et = parse_sloppy_date(hash["end_date_standardized"])
end
rescue => e
errors << ["begin_date_standardized", "Error attempting to parsing dates"]
end
errors << ["begin_date_standardized", "requires that end dates are after begin dates"] if bt && et && bt > et
end
return errors
end
def self.check_agent_sources(hash)
errors = []
if (hash["source_entry"].nil? || hash["source_entry"].empty?) &&
(hash["descriptive_note"].nil? || hash["descriptive_note"].empty?) &&
(hash["file_uri"].nil? || hash["file_uri"].empty?)
errors << ["agent_sources", "Must specify one of Source Entry, Descriptive Note or File URI"]
end
return errors
end
def self.check_agent_alternate_set(hash)
errors = []
if (hash["set_component"].nil? || hash["set_component"].empty?) &&
(hash["descriptive_note"].nil? || hash["descriptive_note"].empty?) &&
(hash["file_uri"].nil? || hash["file_uri"].empty?)
errors << ["agent_sources", "Must specify one of Set Component, Descriptive Note or File URI"]
end
return errors
end
def self.check_agent_subject_subrecord(hash)
errors = []
if hash["subjects"].empty?
errors << ["subjects", "Must specify a primary subject"]
end
return errors
end
def self.check_agent_software_subrecords(hash)
errors = []
subrecords_disallowed = ["agent_record_identifiers", "agent_record_controls", "agent_other_agency_codes", "agent_conventions_declarations", "agent_maintenance_histories", "agent_sources", "agent_alternate_sets", "agent_resources"]
subrecords_disallowed.each do |sd|
unless hash[sd] == [] || hash[sd].nil?
errors << [sd, "subrecord not allowed for agent software"]
end
end
return errors
end
def self.check_used_language(hash)
errors = []
if hash["language"].nil? && hash["notes"].empty?
errors << ["language", "Must specify either language or a note."]
end
return errors
end
if JSONModel(:date)
JSONModel(:date).add_validation("check_date") do |hash|
check_date(hash)
end
end
def self.check_language(hash)
langs = hash['lang_materials'].map {|l| l['language_and_script']}.compact.reject {|e| e == [] }.flatten
errors = []
if langs == []
errors << :must_contain_at_least_one_language
end
errors
end
if JSONModel(:resource)
JSONModel(:resource).add_validation("check_language") do |hash|
check_language(hash)
end
end
def self.check_rights_statement(hash)
errors = []
if hash["rights_type"] == "copyright"
errors << ["status", "missing required property"] if hash["status"].nil?
errors << ["jurisdiction", "missing required property"] if hash["jurisdiction"].nil?
errors << ["start_date", "missing required property"] if hash["start_date"].nil?
elsif hash["rights_type"] == "license"
errors << ["license_terms", "missing required property"] if hash["license_terms"].nil?
errors << ["start_date", "missing required property"] if hash["start_date"].nil?
elsif hash["rights_type"] == "statute"
errors << ["statute_citation", "missing required property"] if hash["statute_citation"].nil?
errors << ["jurisdiction", "missing required property"] if hash["jurisdiction"].nil?
errors << ["start_date", "missing required property"] if hash["start_date"].nil?
elsif hash["rights_type"] == "other"
errors << ["other_rights_basis", "missing required property"] if hash["other_rights_basis"].nil?
errors << ["start_date", "missing required property"] if hash["start_date"].nil?
end
errors
end
if JSONModel(:rights_statement)
JSONModel(:rights_statement).add_validation("check_rights_statement") do |hash|
check_rights_statement(hash)
end
end
def self.check_location(hash)
errors = []
# When creating a location, a minimum of one of the following is required:
# * Barcode
# * Classification
# * Coordinate 1 Label AND Coordinate 1 Indicator
required_location_fields = [["barcode"],
["classification"],
["coordinate_1_indicator", "coordinate_1_label"]]
if !required_location_fields.any? { |fieldset| fieldset.all? {|field| hash[field]} }
errors << :location_fields_error
end
errors
end
if JSONModel(:location)
JSONModel(:location).add_validation("check_location") do |hash|
check_location(hash)
end
end
def self.check_container_location(hash)
errors = []
errors << ["end_date", "is required if status is previous"] if hash["end_date"].nil? and hash["status"] == "previous"
errors
end
if JSONModel(:container_location)
JSONModel(:container_location).add_validation("check_container_location") do |hash|
check_container_location(hash)
end
end
def self.check_instance(hash)
errors = []
if hash["instance_type"] == "digital_object"
errors << ["digital_object", "Can't be empty"] if hash["digital_object"].nil?
elsif hash["digital_object"] && hash["instance_type"] != "digital_object"
errors << ["instance_type", "An instance with a digital object reference must be of type 'digital_object'"]
elsif hash["instance_type"]
errors << ["sub_container", "Can't be empty"] if hash["sub_container"].nil?
end
errors
end
if JSONModel(:instance)
JSONModel(:instance).add_validation("check_instance") do |hash|
check_instance(hash)
end
end
def self.check_sub_container(hash)
errors = []
if (!hash["type_2"].nil? && hash["indicator_2"].nil?) || (hash["type_2"].nil? && !hash["indicator_2"].nil?)
errors << ["type_2", "container 2 requires both a type and indicator"]
end
if (hash["type_2"].nil? && hash["indicator_2"].nil? && (!hash["type_3"].nil? || !hash["indicator_3"].nil?))
errors << ["type_2", "container 2 is required if container 3 is provided"]
end
if (!hash["type_3"].nil? && hash["indicator_3"].nil?) || (hash["type_3"].nil? && !hash["indicator_3"].nil?)
errors << ["type_3", "container 3 requires both a type and indicator"]
end
errors
end
if JSONModel(:sub_container)
JSONModel(:sub_container).add_validation("check_sub_container") do |hash|
check_sub_container(hash)
end
end
def self.check_container_profile(hash)
errors = []
# Ensure depth, width and height have no more than 2 decimal places
["depth", "width", "height"].each do |k|
if hash[k] !~ /^\s*(?=.*[0-9])\d*(?:\.\d{1,2})?\s*$/
errors << [k, "must be a number with no more than 2 decimal places"]
end
end
# Ensure stacking limit is a positive integer if it has value
if !hash['stacking_limit'].nil? and hash['stacking_limit'] !~ /^\d+$/
errors << ['stacking_limit', 'must be a positive integer']
end
errors
end
if JSONModel(:container_profile)
JSONModel(:container_profile).add_validation("check_container_profile") do |hash|
check_container_profile(hash)
end
end
def self.check_collection_management(hash)
errors = []
if !hash["processing_total_extent"].nil? and hash["processing_total_extent_type"].nil?
errors << ["processing_total_extent_type", "is required if total extent is specified"]
end
[ "processing_hours_per_foot_estimate", "processing_total_extent", "processing_hours_total" ].each do |k|
if !hash[k].nil? and hash[k] !~ /^\-?\d{0,9}(\.\d{1,5})?$/
errors << [k, "must be a number with no more than nine digits and five decimal places"]
end
end
errors
end
if JSONModel(:collection_management)
JSONModel(:collection_management).add_validation("check_collection_management") do |hash|
check_collection_management(hash)
end
end
def self.check_user_defined(hash)
errors = []
["integer_1", "integer_2", "integer_3"].each do |k|
if !hash[k].nil? and hash[k] !~ /^\-?\d+$/
errors << [k, "must be an integer"]
end
end
["real_1", "real_2", "real_3"].each do |k|
if !hash[k].nil? and hash[k] !~ /^\-?\d{0,9}(\.\d{1,5})?$/
errors << [k, "must be a number with no more than nine digits and five decimal places"]
end
end
errors
end
if JSONModel(:user_defined)
JSONModel(:user_defined).add_validation("check_user-defined") do |hash|
check_user_defined(hash)
end
end
if JSONModel(:resource)
JSONModel(:resource).add_validation("check_resource_otherlevel") do |hash|
check_otherlevel(hash)
end
end
def self.check_otherlevel(hash)
errors = []
if hash["level"] == "otherlevel"
errors << ["other_level", "missing required property"] if hash["other_level"].nil?
end
errors
end
def self.check_archival_object(hash)
errors = []
if (!hash.has_key?("dates") || hash["dates"].empty?) && (!hash.has_key?("title") || hash["title"].empty?)
errors << ["dates", "one or more required (or enter a Title)"]
errors << ["title", "must not be an empty string (or enter a Date)"]
end
errors
end
if JSONModel(:archival_object)
JSONModel(:archival_object).add_validation("check_archival_object") do |hash|
check_archival_object(hash)
end
JSONModel(:archival_object).add_validation("check_archival_object_otherlevel") do |hash|
check_otherlevel(hash);
end
end
def self.check_digital_object_component(hash)
errors = []
fields = ["dates", "title", "label"]
if fields.all? {|field| !hash.has_key?(field) || hash[field].empty?}
fields.each do |field|
errors << [field, "you must provide a label, title or date"]
end
end
errors
end
JSONModel(:digital_object_component).add_validation("check_digital_object_component") do |hash|
check_digital_object_component(hash)
end
JSONModel(:event).add_validation("check_event") do |hash|
errors = []
if hash.has_key?("date") && hash.has_key?("timestamp")
errors << ["date", "Can't specify both a date and a timestamp"]
errors << ["timestamp", "Can't specify both a date and a timestamp"]
end
if !hash.has_key?("date") && !hash.has_key?("timestamp")
errors << ["date", "Must specify either a date or a timestamp"]
errors << ["timestamp", "Must specify either a date or a timestamp"]
end
if hash["timestamp"]
# Make sure we can parse it
begin
Time.parse(hash["timestamp"])
rescue ArgumentError
errors << ["timestamp", "Must be an ISO8601-formatted string"]
end
end
errors
end
[:agent_person, :agent_family, :agent_software, :agent_corporate_entity].each do |agent_type|
JSONModel(agent_type).add_validation("check_#{agent_type.to_s}") do |hash|
errors = []
if hash.has_key?("dates_of_existence") && hash["dates_of_existence"].find {|d| d['date_label'] != 'existence' }
errors << ["dates_of_existence", "Label must be 'existence' in this context"]
end
errors
end
end
[:note_multipart, :note_bioghist].each do |schema|
JSONModel(schema).add_validation("#{schema}_check_at_least_one_subnote") do |hash|
if Array(hash['subnotes']).empty?
[["subnotes", "Must contain at least one subnote"]]
else
[]
end
end
end
def self.check_restriction_date(hash)
errors = []
if (rr = hash['rights_restriction'])
begin
begin_date = Date.strptime(rr['begin'], '%Y-%m-%d') if rr['begin']
rescue ArgumentError => e
errors << ["rights_restriction__begin", "must be in YYYY-MM-DD format"]
end
begin
end_date = Date.strptime(rr['end'], '%Y-%m-%d') if rr['end']
rescue ArgumentError => e
errors << ["rights_restriction__end", "must be in YYYY-MM-DD format"]
end
if begin_date && end_date && end_date < begin_date
errors << ["rights_restriction__end", "must not be before begin"]
end
end
errors
end
if JSONModel(:note_multipart)
JSONModel(:note_multipart).add_validation("check_restriction_date") do |hash|
check_restriction_date(hash)
end
end
JSONModel(:find_and_replace_job).add_validation("only target properties on the target schemas") do |hash|
target_model = JSONModel(hash['record_type'].intern)
target_property = hash['property']
target_model.schema['properties'].has_key?(target_property) ? [] : [["property", "#{target_model.to_s} does not have a property named '#{target_property}'"]]
end
def self.check_location_profile(hash)
errors = []
# Ensure depth, width and height have no more than 2 decimal places
["depth", "width", "height"].each do |k|
if !hash[k].nil? && hash[k] !~ /\A\d+(\.\d\d?)?\Z/
errors << [k, "must be a number with no more than 2 decimal places"]
end
end
errors
end
if JSONModel(:location_profile)
JSONModel(:location_profile).add_validation("check_location_profile") do |hash|
check_location_profile(hash)
end
end
def self.check_field_query(hash)
errors = []
if (!hash.has_key?("value") || hash["value"].empty?) && hash["comparator"] != "empty"
errors << ["value", "Must specify either a value or use the 'empty' comparator"]
end
errors
end
if JSONModel(:field_query)
JSONModel(:field_query).add_validation("check_field_query") do |hash|
check_field_query(hash)
end
end
def self.check_date_field_query(hash)
errors = []
if (!hash.has_key?("value") || hash["value"].empty?) && hash["comparator"] != "empty"
errors << ["value", "Must specify either a value or use the 'empty' comparator"]
end
errors
end
if JSONModel(:date_field_query)
JSONModel(:date_field_query).add_validation("check_date_field_query") do |hash|
check_field_query(hash)
end
end
def self.check_rights_statement_external_document(hash)
errors = []
errors << ['identifier_type', 'missing required property'] if hash['identifier_type'].nil?
errors
end
if JSONModel(:rights_statement_external_document)
JSONModel(:rights_statement_external_document).add_validation("check_rights_statement_external_document") do |hash|
check_rights_statement_external_document(hash)
end
end
def self.check_assessment_monetary_value(hash)
errors = []
if monetary_value = hash['monetary_value']
unless monetary_value =~ /\A[0-9]+\z/ || monetary_value =~ /\A[0-9]+\.[0-9]{1,2}\z/
errors << ['monetary_value', "must be a number with no more than 2 decimal places"]
end
end
errors
end
if JSONModel(:assessment)
JSONModel(:assessment).add_validation("check_assessment_monetary_value") do |hash|
check_assessment_monetary_value(hash)
end
end
def self.check_survey_dates(hash)
errors = []
begin
begin_date = parse_sloppy_date(hash['survey_begin'])
rescue ArgumentError => e
errors << ["survey_begin", "not a valid date"]
end
begin
if hash['survey_end']
# If padding our end date with months/days would cause it to fall before
# the start date (e.g. if the start date was '2000-05' and the end date
# just '2000'), use the start date in place of end.
end_s = if begin_date && hash['survey_begin'] && hash['survey_begin'].start_with?(hash['survey_end'])
hash['survey_begin']
else
hash['survey_end']
end
end_date = parse_sloppy_date(end_s)
end
rescue ArgumentError
errors << ["survey_end", "not a valid date"]
end
if begin_date && end_date && end_date < begin_date
errors << ["survey_end", "must not be before begin"]
end
errors
end
def self.check_standard_date(date_standardized, errors, field_name = "date_standardized")
matches_y = (date_standardized =~ /^[\d]{1}$/) == 0
matches_y_mm = (date_standardized =~ /^[\d]{1}-[\d]{2}$/) == 0
matches_yy = (date_standardized =~ /^[\d]{2}$/) == 0
matches_yy_mm = (date_standardized =~ /^[\d]{2}-[\d]{2}$/) == 0
matches_yyy = (date_standardized =~ /^[\d]{3}$/) == 0
matches_yyy_mm = (date_standardized =~ /^[\d]{3}-[\d]{2}$/) == 0
matches_yyyy = (date_standardized =~ /^[\d]{4}$/) == 0
matches_yyyy_mm = (date_standardized =~ /^[\d]{4}-[\d]{2}$/) == 0
matches_yyyy_mm_dd = (date_standardized =~ /^[\d]{4}-[\d]{2}-[\d]{2}$/) == 0
matches_yyy_mm_dd = (date_standardized =~ /^[\d]{3}-[\d]{2}-[\d]{2}$/) == 0
matches_mm_yyyy = (date_standardized =~ /^[\d]{2}-[\d]{4}$/) == 0
matches_mm_dd_yyyy = (date_standardized =~ /^[\d]{4}-[\d]{2}-[\d]{2}$/) == 0
errors << [field_name, "must be in YYYY[YYY][YY][Y], YYYY[YYY][YY][Y]-MM, or YYYY-MM-DD format"] unless matches_yyyy || matches_yyyy_mm || matches_yyyy_mm_dd || matches_yyy || matches_yy || matches_y || matches_yyy_mm || matches_yy_mm || matches_y_mm || matches_mm_yyyy || matches_mm_dd_yyyy || matches_yyy_mm_dd
return errors
end
if JSONModel(:assessment)
JSONModel(:assessment).add_validation("check_survey_dates") do |hash|
check_survey_dates(hash)
end
end
end