Class: AssertedDistribution

Overview

An AssertedDistribution is the Source-backed assertion that a record (e.g. Otu, BiologicalAssociation, etc.) is present in some *spatial area*. It requires a Citation indicating where/who made the assertion. In TaxonWorks the areas are drawn from GeographicAreas and Gazetteers.

AssertedDistributions can be asserts that the source indicates that a taxon is NOT present in an area. This is a “positive negative” in , i.e. the Source can be thought of recording evidence that a taxon is not present. TaxonWorks does not differentiate between types of negative evidence.

Defined Under Namespace

Modules: DwcExtensions

Constant Summary collapse

{
  'Conveyance' => ['Otu'],
  'Depiction' => ['Otu'],
  'Observation' => ['Otu']
}.freeze

Constants included from DwcExtensions

DwcExtensions::DWC_OCCURRENCE_MAP

Constants included from Shared::IsDwcOccurrence

Shared::IsDwcOccurrence::DWC_DELIMITER, Shared::IsDwcOccurrence::VIEW_EXCLUSIONS

Constants included from SoftValidation

SoftValidation::ANCESTORS_WITH_SOFT_VALIDATIONS

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Shared::PolymorphicAnnotator

#annotated_object_is_persisted?

Methods included from Shared::QueryBatchUpdate

#query_update

Methods included from Shared::IsData

#errors_excepting, #full_error_messages_excepting, #identical, #is_community?, #is_destroyable?, #is_editable?, #is_in_use?, #is_in_users_projects?, #metamorphosize, #similar

Methods included from DwcExtensions

#dwc_associated_references, #dwc_country, #dwc_county, #dwc_family, #dwc_genus, #dwc_infraspecific_epithet, #dwc_kingdom, #dwc_occurrence_status, #dwc_scientific_name, #dwc_specific_epithet, #dwc_state_province, #dwc_taxon_name_authorship, #dwc_taxon_rank

Methods included from Shared::IsDwcOccurrence

#dwc_occurrence_attribute_values, #dwc_occurrence_attributes, #dwc_occurrence_id, #get_dwc_occurrence, #set_dwc_occurrence

Methods included from Shared::HasPapertrail

#attribute_updated, #attribute_updater, #detect_version

Methods included from Shared::Identifiers

#dwc_occurrence_id, #identified?, #next_by_identifier, #previous_by_identifier, #reject_identifiers, #uri, #uuid

Methods included from Shared::OriginRelationship

#new_objects, #old_objects, #reject_origin_relationships, #set_origin

Methods included from Shared::Confidences

#reject_confidences

Methods included from Shared::Citations

#cited?, #mark_citations_for_destruction, #nomenclature_date, #origin_citation_source_id, #reject_citations, #sources_by_topic_id

Methods included from Shared::DataAttributes

#import_attributes, #internal_attributes, #keyword_value_hash, #reject_data_attributes

Methods included from Shared::Tags

#reject_tags, #tag_with, #tagged?, #tagged_with?

Methods included from Shared::Notes

#concatenated_notes_string, #reject_notes

Methods included from SoftValidation

#clear_soft_validations, #fix_for, #fix_soft_validations, #soft_fixed?, #soft_valid?, #soft_validate, #soft_validated?, #soft_validations, #soft_validators

Methods included from Housekeeping

#has_polymorphic_relationship?

Methods inherited from ApplicationRecord

transaction_with_retry

Instance Attribute Details

#asserted_distribution_object_idInteger

polymorphic object ID

Returns:

  • (Integer)


30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
# File 'app/models/asserted_distribution.rb', line 30

class AssertedDistribution < ApplicationRecord
  include Housekeeping
  include SoftValidation
  include Shared::Notes
  include Shared::Tags
  include Shared::DataAttributes # Why?
  include Shared::CitationRequired # !! must preceed Shared::Citations
  include Shared::Citations
  include Shared::Confidences
  include Shared::OriginRelationship
  include Shared::Identifiers
  include Shared::HasPapertrail
  include Shared::Taxonomy # at present must preceed DwcExtensions

  include AssertedDistribution::DwcExtensions
  include Shared::IsData

  include Shared::Maps
  include Shared::QueryBatchUpdate
  include Shared::PolymorphicAnnotator
  polymorphic_annotates('asserted_distribution_shape')
  polymorphic_annotates('asserted_distribution_object')

  originates_from 'Specimen', 'Lot', 'FieldOccurrence'

  # @return [Hash]
  #   of known country/state/county values
  attr_accessor :geographic_names

  delegate :geo_object, to: :asserted_distribution_shape

  # This only asserts when the asserted distribution object is polymorphic and
  # has a restriction on its type in order to be an AD.
  ASSERTED_DISTRIBUTION_OBJECT_RELATED_TYPES = {
    'Conveyance' => ['Otu'],
    'Depiction' => ['Otu'],
    'Observation' => ['Otu']
  }.freeze

  before_validation :unify_is_absent
  before_save do
    # TODO: handle non-otu types.
    self.no_dwc_occurrence = asserted_distribution_object_type != 'Otu'
  end

  validate :records_include_citation
  validate :object_shape_absence_triple_is_unique

  validate :asserted_distribution_object_has_allowed_type

  # TODO: deprecate scopes referencing single parameter where()
  scope :with_is_absent, -> { where('is_absent = true') }
  scope :without_is_absent, -> { where('is_absent = false OR is_absent is Null') }
  scope :with_geographic_area_array, -> (geographic_area_array) { where("asserted_distribution_shape_type = 'GeographicArea' AND asserted_distribution_shape_id IN (?)", geographic_area_array) }
  # Includes a `geographic_item_id` column, so !! may return more results than
  # there are ADs !!.
  scope :associated_with_geographic_items, -> {
    a = AssertedDistribution
      .where(asserted_distribution_shape_type: 'GeographicArea')
      .joins('JOIN geographic_areas ON asserted_distribution_shape_id = geographic_areas.id')
      .joins('JOIN geographic_areas_geographic_items on geographic_areas.id = geographic_areas_geographic_items.geographic_area_id')
      .joins('JOIN geographic_items on geographic_items.id = geographic_areas_geographic_items.geographic_item_id')
      .select('asserted_distributions.*, geographic_items.id geographic_item_id')

    b = AssertedDistribution
      .where(asserted_distribution_shape_type: 'Gazetteer')
      .joins('JOIN gazetteers ON asserted_distribution_shape_id = gazetteers.id')
      .joins('JOIN geographic_items on gazetteers.geographic_item_id = geographic_items.id')
      .select('asserted_distributions.*, geographic_items.id geographic_item_id')

    ::Queries.union(AssertedDistribution, [a, b])
  }
  scope :contributing_to_cached_maps, -> {
    # TODO: eventually include non-otu object types
    ad_ga_with_shape = AssertedDistribution
      .with_otus
      .joins("JOIN geographic_areas ON asserted_distributions.asserted_distribution_shape_type = 'GeographicArea' AND asserted_distributions.asserted_distribution_shape_id = geographic_areas.id")
      .joins('JOIN geographic_areas_geographic_items on geographic_areas.id = geographic_areas_geographic_items.geographic_area_id')

    ad_gaz = AssertedDistribution
      .with_otus
      .where(asserted_distribution_shape_type: 'Gazetteer')

    ::Queries.union(AssertedDistribution, [ad_ga_with_shape, ad_gaz])
      .without_is_absent
  }
  scope :with_otus, -> {
    joins("JOIN otus ON otus.id = asserted_distributions.asserted_distribution_object_id AND asserted_distributions.asserted_distribution_object_type = 'Otu'")
  }
  scope :with_taxon_names, -> {
    joins("JOIN otus ON otus.id = asserted_distributions.asserted_distribution_object_id AND asserted_distributions.asserted_distribution_object_type = 'Otu'
    JOIN taxon_names ON taxon_names.id = otus.taxon_name_id")
  }
  scope :with_biological_associations, -> {
    joins("JOIN biological_associations ON biological_associations.id = asserted_distributions.asserted_distribution_object_id AND asserted_distributions.asserted_distribution_object_type = 'BiologicalAssociation'")
  }
  scope :with_biological_associations_graphs, -> {
    joins("JOIN biological_associations_graphs ON biological_associations_graphs.id = asserted_distributions.asserted_distribution_object_id AND asserted_distributions.asserted_distribution_object_type = 'BiologicalAssociationsGraph'")
  }
  scope :with_otu_conveyances, -> {
    joins("JOIN conveyances ON conveyances.id = asserted_distributions.asserted_distribution_object_id AND asserted_distributions.asserted_distribution_object_type = 'Conveyance'
    AND conveyances.conveyance_object_type = 'Otu'")
  }
  scope :with_otu_depictions, -> {
    joins("JOIN depictions ON depictions.id = asserted_distributions.asserted_distribution_object_id AND asserted_distributions.asserted_distribution_object_type = 'Depiction'
    AND depictions.depiction_object_type = 'Otu'")
  }
  scope :with_otu_observations, -> {
    joins("JOIN observations ON observations.id = asserted_distributions.asserted_distribution_object_id AND asserted_distributions.asserted_distribution_object_type = 'Observation'
    AND observations.observation_object_type = 'Otu'")
  }

  soft_validate(:sv_conflicting_geographic_area, set: :conflicting_geographic_area, name: 'conflicting geographic area', description: 'conflicting geographic area')

  # getter for attr :geographic_names
  def geographic_names
    return @geographic_names if !@geographic_names.nil?
    # TODO: Possibly provide a2/a3 info from gazetteers??
    @geographic_names ||=
      asserted_distribution_shape.geographic_name_classification
        .delete_if{|k,v| v.nil?}
    @geographic_names ||= {}
  end

  # rubocop:disable Style/StringHashKeys
  # TODO: DRY with helper methods
  # @return [Hash] GeoJSON feature
  def to_geo_json_feature
    retval = {
      'type' => 'Feature',
      'geometry' => RGeo::GeoJSON.encode(geo_object),
      'properties' => {'asserted_distribution' => {'id' => self.id}}
    }
    retval
  end

  # rubocop:enable Style/StringHashKeys

  # @return [True]
  #   see citable.rb
  def requires_citation?
    true
  end

  def geographic_item
    asserted_distribution_shape.default_geographic_item
  end

  def otu
    return nil if asserted_distribution_object_type != 'Otu'

    asserted_distribution_object
  end

  def has_shape?
    asserted_distribution_shape.geographic_items.any?
  end

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      async_cutoff: params[:async_cutoff] || 26,
      klass: 'AssertedDistribution',
      object_filter_params: params[:asserted_distribution_query],
      object_params: params[:asserted_distribution],
      preview: params[:preview],
    )

    a = request.filter

    v1 = a.all.distinct.limit(2)
      .pluck(:asserted_distribution_shape_id, :asserted_distribution_shape_type)
      .uniq.count
    v2 = a.all.distinct.limit(2).pluck(:asserted_distribution_object_id).uniq.count

    cap = 0

    if v1 > 1 && v2 > 1 # many objects, many geographic areas
      cap = 0
      request.cap_reason = 'Records include multiple asserted distribution objects *and* multiple asserted distribution shapes.'
    elsif v1 > 1
      cap = 0
      request.cap_reason = 'May not update multiple shapes to one.' # TODO: revist constraint
    else
      cap = 2000
    end

    request.cap = cap

    query_batch_update(request)
  end

  def self.batch_template_create(params)
    async_cutoff = params[:async_cutoff] || 26
    klass = params[:object_type]
    a = "Queries::#{klass}::Filter".constantize.new(params[:object_query])

    r = BatchResponse.new({
      async: a.all.count > async_cutoff,
      preview: params[:preview],
      total_attempted: a.all.count,
      method: 'batch_template_create'
    })

    max_allowed = 250
    if r.total_attempted > max_allowed
      r.errors["Max #{max_allowed} query records allowed"] = 1
      return r
    end

    return r if r.async && r.preview

    if r.async
      object_ids = a.all.pluck(:id)
      user_id = params[:user_id]
      project_id = params[:project_id]
      AssertedDistribution
        .delay(run_at: 1.second.from_now, queue: :query_batch_update)
        .batch_create_from_params(
          params[:template_asserted_distribution], object_ids, klass,
          user_id, project_id
        )
    else
      self.transaction do
        template_params = params[:template_asserted_distribution]
        a.all.select(:id).find_each do |o|
          begin
            ad = update_or_create_by_template(template_params, o.id, klass)
            r.updated.push ad.id
          rescue ActiveRecord::RecordInvalid => e
            r.not_updated.push e.record.id

            r.errors[e.message] = 0 unless r.errors[e.message]

            r.errors[e.message] += 1
          end
        end
        raise ActiveRecord::Rollback if r.preview
      end
    end

    r
  end

  # Intended to be run in a background job.
  def self.batch_create_from_params(
    params, object_ids, object_type, user_id, project_id
  )
    Current.user_id = user_id
    Current.project_id = project_id

    object_ids.each do |object_id|
      begin
        update_or_create_by_template(params, object_id, object_type)
      rescue ActiveRecord::RecordInvalid => e
        # Just continue
      end
    end
  end

  # Raises on error.
  def self.update_or_create_by_template(template_params, object_id, object_type)
    ad = ::AssertedDistribution.find_by(
      asserted_distribution_object_id: object_id,
      asserted_distribution_object_type: object_type,
      asserted_distribution_shape_id:
        template_params[:asserted_distribution_shape_id],
      asserted_distribution_shape_type:
        template_params[:asserted_distribution_shape_type],
      is_absent: template_params[:is_absent]
    )

    if ad
      # Create/add the citation.
      # TODO: this can create a duplicate citation.
      ad.update!(template_params)
    else
      ad = ::AssertedDistribution.create!(
        template_params.merge({
          asserted_distribution_object_id: object_id,
          asserted_distribution_object_type: object_type
        })
      )
    end

    ad
  end

  def self.asserted_distributions_for_api_index(params, project_id)
    a = ::Queries::AssertedDistribution::Filter.new(params)
      .all
      .where(project_id: project_id)
      .includes(:citations, origin_citation: [:source])
      .includes(asserted_distribution_shape: :parent)
      .includes(:asserted_distribution_object)
      .order('asserted_distributions.id')
      .page(params[:page])
      .per(params[:per])

    if a.all.count > 50
      params['extend']&.delete('geo_json')
    end

    a
  end

  protected

  # Never record "false" in the datase, only true
  def unify_is_absent
    self.is_absent = nil if self.is_absent != true
  end

  def records_include_citation
    # !! Be careful making changes here: remember that conditions on one
    # association may/not affect other associations when the actual save
    # happens.
    # Maintain with FieldOccurrence#records_include_taxon_determination

    # Watch out, origin_citation and citations *do not* necessarily share the
    # same marked_for_destruction? info, even when origin_citation is a member
    # of citations.
    origin_citation_is_marked_for_destruction_on_citations =
      origin_citation.present? && citations.present? &&
      citations.count == 1 && !citations.first.id.nil? &&
      origin_citation.id == citations.first.id &&
      citations.first.marked_for_destruction?

    the_one_citation_is_marked_for_destruction_on_origin_citation =
      origin_citation.present? && citations.present? &&
      citations.count == 1 && !citations.first.id.nil? &&
      origin_citation&.id == citations.first.id &&
      origin_citation.marked_for_destruction?

    has_valid_source =
      source.present? &&
      !source.marked_for_destruction? && (
        !origin_citation.present? ||
        (!origin_citation.marked_for_destruction? &&
         !origin_citation_is_marked_for_destruction_on_citations)
      )

    has_valid_origin_citation =
      origin_citation.present? &&
      !origin_citation.marked_for_destruction? &&
      !origin_citation_is_marked_for_destruction_on_citations

    has_valid_citation =
      citations.count(&:marked_for_destruction?) < citations.size &&
       !the_one_citation_is_marked_for_destruction_on_origin_citation

    if !has_valid_source && !has_valid_origin_citation && !has_valid_citation
      errors.add(:base, 'required citation is not provided')
      return
    end

    # We loaded the citations association above. If it was empty, Rails caches
    # that value and *will not* add a citation created during save from source
    # or origin_citation unless we reset the citations association.
    if (source.present? || origin_citation.present?) && citations.size == 0
      association(:citations).reset
    end

    if source.present? && origin_citation.nil?
      association(:origin_citation).reset
    end
  end

  # @return [Boolean]
  def sv_conflicting_geographic_area
    # TODO: more expensive for gazetteers, which would require a spatial check.
    geographic_area = asserted_distribution_shape if asserted_distribution_shape_type == 'GeographicArea'
    return if geographic_area.nil?

    areas = [geographic_area.level0_id, geographic_area.level1_id, geographic_area.level2_id].compact
    if is_absent # this returns an array, not a single GA so test below is not right
      presence = AssertedDistribution
        .without_is_absent
        .with_geographic_area_array(areas)
        .where(asserted_distribution_object:)
      soft_validations.add(:geographic_area_id, "Taxon is reported as present in #{presence.first.asserted_distribution_shape.name}") unless presence.empty?
    else
      presence = AssertedDistribution
        .with_is_absent
        .where(asserted_distribution_object:)
        .with_geographic_area_array(areas)
      soft_validations.add(:geographic_area_id, "Taxon is reported as missing in #{presence.first.asserted_distribution_shape.name}") unless presence.empty?
    end
  end

  # DEPRECATED, unused (maybe)
  # @param [Hash] defaults
  # @return [AssertedDistribution]
  #   used to also stub an #origin_citation, as required
  def self.stub(defaults: {})
    a = AssertedDistribution.new(
      asserted_distribution_object_id:
        defaults[:asserted_distribution_object_id],
      asserted_distribution_object_type:
        defaults[:asserted_distribution_object_type],
      origin_citation_attributes: {source_id: defaults[:source_id]})
    a.origin_citation = Citation.new if defaults[:source_id].blank?
    a
  end

  # Currently only used in specs
  # @param [Hash] options of e.g., {asserted_distribution_object_id: 5, asserted_distribution_object_type: 'Otu' source_id: 5, geographic_areas: Array of {GeographicArea}}
  # @return [Array] an array of AssertedDistributions
  def self.stub_new(options = {})
    options.symbolize_keys!
    result = []
    options[:geographic_areas].each do |ga|
      result.push(
        AssertedDistribution.new(
          asserted_distribution_object_id: options[:otu_id],
          asserted_distribution_object_type: 'Otu',
          asserted_distribution_shape: ga,
          origin_citation_attributes: {source_id: options[:source_id]})
      )
    end
    result
  end

  def object_shape_absence_triple_is_unique
    if AssertedDistribution
        .where(
          asserted_distribution_object_type:, asserted_distribution_object_id:,
          asserted_distribution_shape_type:, asserted_distribution_shape_id:,
          is_absent:
        )
        .where.not(id:)
        .exists?

      # Put the error on asserted_distribution_object so that unify can handle
      # duplicates.
      errors.add(:asserted_distribution_object,
        'this shape, object, and present/absent combination already exists'
      )
    end
  end

  def asserted_distribution_object_has_allowed_type
    t = asserted_distribution_object_type&.to_s # STRING (not symbol)

    if !DISTRIBUTION_ASSERTABLE_TYPES.include?(t)
      errors.add(t || :base, " - the type of this asserted distribution's object can only be one of #{DISTRIBUTION_ASSERTABLE_TYPES}, not '#{t}'")
      return
    end

    if (
      (a = ASSERTED_DISTRIBUTION_OBJECT_RELATED_TYPES[t]) &&
      !a.include?(asserted_distribution_object&.send("#{t.underscore}_object_type"))
    )
     errors.add(t || :base,
       " - the target of this asserted distribution's object can only be in #{a}")
    end
  end
end

#asserted_distribution_object_typeString

polymorphic object type

Returns:

  • (String)


30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
# File 'app/models/asserted_distribution.rb', line 30

class AssertedDistribution < ApplicationRecord
  include Housekeeping
  include SoftValidation
  include Shared::Notes
  include Shared::Tags
  include Shared::DataAttributes # Why?
  include Shared::CitationRequired # !! must preceed Shared::Citations
  include Shared::Citations
  include Shared::Confidences
  include Shared::OriginRelationship
  include Shared::Identifiers
  include Shared::HasPapertrail
  include Shared::Taxonomy # at present must preceed DwcExtensions

  include AssertedDistribution::DwcExtensions
  include Shared::IsData

  include Shared::Maps
  include Shared::QueryBatchUpdate
  include Shared::PolymorphicAnnotator
  polymorphic_annotates('asserted_distribution_shape')
  polymorphic_annotates('asserted_distribution_object')

  originates_from 'Specimen', 'Lot', 'FieldOccurrence'

  # @return [Hash]
  #   of known country/state/county values
  attr_accessor :geographic_names

  delegate :geo_object, to: :asserted_distribution_shape

  # This only asserts when the asserted distribution object is polymorphic and
  # has a restriction on its type in order to be an AD.
  ASSERTED_DISTRIBUTION_OBJECT_RELATED_TYPES = {
    'Conveyance' => ['Otu'],
    'Depiction' => ['Otu'],
    'Observation' => ['Otu']
  }.freeze

  before_validation :unify_is_absent
  before_save do
    # TODO: handle non-otu types.
    self.no_dwc_occurrence = asserted_distribution_object_type != 'Otu'
  end

  validate :records_include_citation
  validate :object_shape_absence_triple_is_unique

  validate :asserted_distribution_object_has_allowed_type

  # TODO: deprecate scopes referencing single parameter where()
  scope :with_is_absent, -> { where('is_absent = true') }
  scope :without_is_absent, -> { where('is_absent = false OR is_absent is Null') }
  scope :with_geographic_area_array, -> (geographic_area_array) { where("asserted_distribution_shape_type = 'GeographicArea' AND asserted_distribution_shape_id IN (?)", geographic_area_array) }
  # Includes a `geographic_item_id` column, so !! may return more results than
  # there are ADs !!.
  scope :associated_with_geographic_items, -> {
    a = AssertedDistribution
      .where(asserted_distribution_shape_type: 'GeographicArea')
      .joins('JOIN geographic_areas ON asserted_distribution_shape_id = geographic_areas.id')
      .joins('JOIN geographic_areas_geographic_items on geographic_areas.id = geographic_areas_geographic_items.geographic_area_id')
      .joins('JOIN geographic_items on geographic_items.id = geographic_areas_geographic_items.geographic_item_id')
      .select('asserted_distributions.*, geographic_items.id geographic_item_id')

    b = AssertedDistribution
      .where(asserted_distribution_shape_type: 'Gazetteer')
      .joins('JOIN gazetteers ON asserted_distribution_shape_id = gazetteers.id')
      .joins('JOIN geographic_items on gazetteers.geographic_item_id = geographic_items.id')
      .select('asserted_distributions.*, geographic_items.id geographic_item_id')

    ::Queries.union(AssertedDistribution, [a, b])
  }
  scope :contributing_to_cached_maps, -> {
    # TODO: eventually include non-otu object types
    ad_ga_with_shape = AssertedDistribution
      .with_otus
      .joins("JOIN geographic_areas ON asserted_distributions.asserted_distribution_shape_type = 'GeographicArea' AND asserted_distributions.asserted_distribution_shape_id = geographic_areas.id")
      .joins('JOIN geographic_areas_geographic_items on geographic_areas.id = geographic_areas_geographic_items.geographic_area_id')

    ad_gaz = AssertedDistribution
      .with_otus
      .where(asserted_distribution_shape_type: 'Gazetteer')

    ::Queries.union(AssertedDistribution, [ad_ga_with_shape, ad_gaz])
      .without_is_absent
  }
  scope :with_otus, -> {
    joins("JOIN otus ON otus.id = asserted_distributions.asserted_distribution_object_id AND asserted_distributions.asserted_distribution_object_type = 'Otu'")
  }
  scope :with_taxon_names, -> {
    joins("JOIN otus ON otus.id = asserted_distributions.asserted_distribution_object_id AND asserted_distributions.asserted_distribution_object_type = 'Otu'
    JOIN taxon_names ON taxon_names.id = otus.taxon_name_id")
  }
  scope :with_biological_associations, -> {
    joins("JOIN biological_associations ON biological_associations.id = asserted_distributions.asserted_distribution_object_id AND asserted_distributions.asserted_distribution_object_type = 'BiologicalAssociation'")
  }
  scope :with_biological_associations_graphs, -> {
    joins("JOIN biological_associations_graphs ON biological_associations_graphs.id = asserted_distributions.asserted_distribution_object_id AND asserted_distributions.asserted_distribution_object_type = 'BiologicalAssociationsGraph'")
  }
  scope :with_otu_conveyances, -> {
    joins("JOIN conveyances ON conveyances.id = asserted_distributions.asserted_distribution_object_id AND asserted_distributions.asserted_distribution_object_type = 'Conveyance'
    AND conveyances.conveyance_object_type = 'Otu'")
  }
  scope :with_otu_depictions, -> {
    joins("JOIN depictions ON depictions.id = asserted_distributions.asserted_distribution_object_id AND asserted_distributions.asserted_distribution_object_type = 'Depiction'
    AND depictions.depiction_object_type = 'Otu'")
  }
  scope :with_otu_observations, -> {
    joins("JOIN observations ON observations.id = asserted_distributions.asserted_distribution_object_id AND asserted_distributions.asserted_distribution_object_type = 'Observation'
    AND observations.observation_object_type = 'Otu'")
  }

  soft_validate(:sv_conflicting_geographic_area, set: :conflicting_geographic_area, name: 'conflicting geographic area', description: 'conflicting geographic area')

  # getter for attr :geographic_names
  def geographic_names
    return @geographic_names if !@geographic_names.nil?
    # TODO: Possibly provide a2/a3 info from gazetteers??
    @geographic_names ||=
      asserted_distribution_shape.geographic_name_classification
        .delete_if{|k,v| v.nil?}
    @geographic_names ||= {}
  end

  # rubocop:disable Style/StringHashKeys
  # TODO: DRY with helper methods
  # @return [Hash] GeoJSON feature
  def to_geo_json_feature
    retval = {
      'type' => 'Feature',
      'geometry' => RGeo::GeoJSON.encode(geo_object),
      'properties' => {'asserted_distribution' => {'id' => self.id}}
    }
    retval
  end

  # rubocop:enable Style/StringHashKeys

  # @return [True]
  #   see citable.rb
  def requires_citation?
    true
  end

  def geographic_item
    asserted_distribution_shape.default_geographic_item
  end

  def otu
    return nil if asserted_distribution_object_type != 'Otu'

    asserted_distribution_object
  end

  def has_shape?
    asserted_distribution_shape.geographic_items.any?
  end

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      async_cutoff: params[:async_cutoff] || 26,
      klass: 'AssertedDistribution',
      object_filter_params: params[:asserted_distribution_query],
      object_params: params[:asserted_distribution],
      preview: params[:preview],
    )

    a = request.filter

    v1 = a.all.distinct.limit(2)
      .pluck(:asserted_distribution_shape_id, :asserted_distribution_shape_type)
      .uniq.count
    v2 = a.all.distinct.limit(2).pluck(:asserted_distribution_object_id).uniq.count

    cap = 0

    if v1 > 1 && v2 > 1 # many objects, many geographic areas
      cap = 0
      request.cap_reason = 'Records include multiple asserted distribution objects *and* multiple asserted distribution shapes.'
    elsif v1 > 1
      cap = 0
      request.cap_reason = 'May not update multiple shapes to one.' # TODO: revist constraint
    else
      cap = 2000
    end

    request.cap = cap

    query_batch_update(request)
  end

  def self.batch_template_create(params)
    async_cutoff = params[:async_cutoff] || 26
    klass = params[:object_type]
    a = "Queries::#{klass}::Filter".constantize.new(params[:object_query])

    r = BatchResponse.new({
      async: a.all.count > async_cutoff,
      preview: params[:preview],
      total_attempted: a.all.count,
      method: 'batch_template_create'
    })

    max_allowed = 250
    if r.total_attempted > max_allowed
      r.errors["Max #{max_allowed} query records allowed"] = 1
      return r
    end

    return r if r.async && r.preview

    if r.async
      object_ids = a.all.pluck(:id)
      user_id = params[:user_id]
      project_id = params[:project_id]
      AssertedDistribution
        .delay(run_at: 1.second.from_now, queue: :query_batch_update)
        .batch_create_from_params(
          params[:template_asserted_distribution], object_ids, klass,
          user_id, project_id
        )
    else
      self.transaction do
        template_params = params[:template_asserted_distribution]
        a.all.select(:id).find_each do |o|
          begin
            ad = update_or_create_by_template(template_params, o.id, klass)
            r.updated.push ad.id
          rescue ActiveRecord::RecordInvalid => e
            r.not_updated.push e.record.id

            r.errors[e.message] = 0 unless r.errors[e.message]

            r.errors[e.message] += 1
          end
        end
        raise ActiveRecord::Rollback if r.preview
      end
    end

    r
  end

  # Intended to be run in a background job.
  def self.batch_create_from_params(
    params, object_ids, object_type, user_id, project_id
  )
    Current.user_id = user_id
    Current.project_id = project_id

    object_ids.each do |object_id|
      begin
        update_or_create_by_template(params, object_id, object_type)
      rescue ActiveRecord::RecordInvalid => e
        # Just continue
      end
    end
  end

  # Raises on error.
  def self.update_or_create_by_template(template_params, object_id, object_type)
    ad = ::AssertedDistribution.find_by(
      asserted_distribution_object_id: object_id,
      asserted_distribution_object_type: object_type,
      asserted_distribution_shape_id:
        template_params[:asserted_distribution_shape_id],
      asserted_distribution_shape_type:
        template_params[:asserted_distribution_shape_type],
      is_absent: template_params[:is_absent]
    )

    if ad
      # Create/add the citation.
      # TODO: this can create a duplicate citation.
      ad.update!(template_params)
    else
      ad = ::AssertedDistribution.create!(
        template_params.merge({
          asserted_distribution_object_id: object_id,
          asserted_distribution_object_type: object_type
        })
      )
    end

    ad
  end

  def self.asserted_distributions_for_api_index(params, project_id)
    a = ::Queries::AssertedDistribution::Filter.new(params)
      .all
      .where(project_id: project_id)
      .includes(:citations, origin_citation: [:source])
      .includes(asserted_distribution_shape: :parent)
      .includes(:asserted_distribution_object)
      .order('asserted_distributions.id')
      .page(params[:page])
      .per(params[:per])

    if a.all.count > 50
      params['extend']&.delete('geo_json')
    end

    a
  end

  protected

  # Never record "false" in the datase, only true
  def unify_is_absent
    self.is_absent = nil if self.is_absent != true
  end

  def records_include_citation
    # !! Be careful making changes here: remember that conditions on one
    # association may/not affect other associations when the actual save
    # happens.
    # Maintain with FieldOccurrence#records_include_taxon_determination

    # Watch out, origin_citation and citations *do not* necessarily share the
    # same marked_for_destruction? info, even when origin_citation is a member
    # of citations.
    origin_citation_is_marked_for_destruction_on_citations =
      origin_citation.present? && citations.present? &&
      citations.count == 1 && !citations.first.id.nil? &&
      origin_citation.id == citations.first.id &&
      citations.first.marked_for_destruction?

    the_one_citation_is_marked_for_destruction_on_origin_citation =
      origin_citation.present? && citations.present? &&
      citations.count == 1 && !citations.first.id.nil? &&
      origin_citation&.id == citations.first.id &&
      origin_citation.marked_for_destruction?

    has_valid_source =
      source.present? &&
      !source.marked_for_destruction? && (
        !origin_citation.present? ||
        (!origin_citation.marked_for_destruction? &&
         !origin_citation_is_marked_for_destruction_on_citations)
      )

    has_valid_origin_citation =
      origin_citation.present? &&
      !origin_citation.marked_for_destruction? &&
      !origin_citation_is_marked_for_destruction_on_citations

    has_valid_citation =
      citations.count(&:marked_for_destruction?) < citations.size &&
       !the_one_citation_is_marked_for_destruction_on_origin_citation

    if !has_valid_source && !has_valid_origin_citation && !has_valid_citation
      errors.add(:base, 'required citation is not provided')
      return
    end

    # We loaded the citations association above. If it was empty, Rails caches
    # that value and *will not* add a citation created during save from source
    # or origin_citation unless we reset the citations association.
    if (source.present? || origin_citation.present?) && citations.size == 0
      association(:citations).reset
    end

    if source.present? && origin_citation.nil?
      association(:origin_citation).reset
    end
  end

  # @return [Boolean]
  def sv_conflicting_geographic_area
    # TODO: more expensive for gazetteers, which would require a spatial check.
    geographic_area = asserted_distribution_shape if asserted_distribution_shape_type == 'GeographicArea'
    return if geographic_area.nil?

    areas = [geographic_area.level0_id, geographic_area.level1_id, geographic_area.level2_id].compact
    if is_absent # this returns an array, not a single GA so test below is not right
      presence = AssertedDistribution
        .without_is_absent
        .with_geographic_area_array(areas)
        .where(asserted_distribution_object:)
      soft_validations.add(:geographic_area_id, "Taxon is reported as present in #{presence.first.asserted_distribution_shape.name}") unless presence.empty?
    else
      presence = AssertedDistribution
        .with_is_absent
        .where(asserted_distribution_object:)
        .with_geographic_area_array(areas)
      soft_validations.add(:geographic_area_id, "Taxon is reported as missing in #{presence.first.asserted_distribution_shape.name}") unless presence.empty?
    end
  end

  # DEPRECATED, unused (maybe)
  # @param [Hash] defaults
  # @return [AssertedDistribution]
  #   used to also stub an #origin_citation, as required
  def self.stub(defaults: {})
    a = AssertedDistribution.new(
      asserted_distribution_object_id:
        defaults[:asserted_distribution_object_id],
      asserted_distribution_object_type:
        defaults[:asserted_distribution_object_type],
      origin_citation_attributes: {source_id: defaults[:source_id]})
    a.origin_citation = Citation.new if defaults[:source_id].blank?
    a
  end

  # Currently only used in specs
  # @param [Hash] options of e.g., {asserted_distribution_object_id: 5, asserted_distribution_object_type: 'Otu' source_id: 5, geographic_areas: Array of {GeographicArea}}
  # @return [Array] an array of AssertedDistributions
  def self.stub_new(options = {})
    options.symbolize_keys!
    result = []
    options[:geographic_areas].each do |ga|
      result.push(
        AssertedDistribution.new(
          asserted_distribution_object_id: options[:otu_id],
          asserted_distribution_object_type: 'Otu',
          asserted_distribution_shape: ga,
          origin_citation_attributes: {source_id: options[:source_id]})
      )
    end
    result
  end

  def object_shape_absence_triple_is_unique
    if AssertedDistribution
        .where(
          asserted_distribution_object_type:, asserted_distribution_object_id:,
          asserted_distribution_shape_type:, asserted_distribution_shape_id:,
          is_absent:
        )
        .where.not(id:)
        .exists?

      # Put the error on asserted_distribution_object so that unify can handle
      # duplicates.
      errors.add(:asserted_distribution_object,
        'this shape, object, and present/absent combination already exists'
      )
    end
  end

  def asserted_distribution_object_has_allowed_type
    t = asserted_distribution_object_type&.to_s # STRING (not symbol)

    if !DISTRIBUTION_ASSERTABLE_TYPES.include?(t)
      errors.add(t || :base, " - the type of this asserted distribution's object can only be one of #{DISTRIBUTION_ASSERTABLE_TYPES}, not '#{t}'")
      return
    end

    if (
      (a = ASSERTED_DISTRIBUTION_OBJECT_RELATED_TYPES[t]) &&
      !a.include?(asserted_distribution_object&.send("#{t.underscore}_object_type"))
    )
     errors.add(t || :base,
       " - the target of this asserted distribution's object can only be in #{a}")
    end
  end
end

#asserted_distribution_shape_idInteger

polymorphic spatial shape ID

Returns:

  • (Integer)


30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
# File 'app/models/asserted_distribution.rb', line 30

class AssertedDistribution < ApplicationRecord
  include Housekeeping
  include SoftValidation
  include Shared::Notes
  include Shared::Tags
  include Shared::DataAttributes # Why?
  include Shared::CitationRequired # !! must preceed Shared::Citations
  include Shared::Citations
  include Shared::Confidences
  include Shared::OriginRelationship
  include Shared::Identifiers
  include Shared::HasPapertrail
  include Shared::Taxonomy # at present must preceed DwcExtensions

  include AssertedDistribution::DwcExtensions
  include Shared::IsData

  include Shared::Maps
  include Shared::QueryBatchUpdate
  include Shared::PolymorphicAnnotator
  polymorphic_annotates('asserted_distribution_shape')
  polymorphic_annotates('asserted_distribution_object')

  originates_from 'Specimen', 'Lot', 'FieldOccurrence'

  # @return [Hash]
  #   of known country/state/county values
  attr_accessor :geographic_names

  delegate :geo_object, to: :asserted_distribution_shape

  # This only asserts when the asserted distribution object is polymorphic and
  # has a restriction on its type in order to be an AD.
  ASSERTED_DISTRIBUTION_OBJECT_RELATED_TYPES = {
    'Conveyance' => ['Otu'],
    'Depiction' => ['Otu'],
    'Observation' => ['Otu']
  }.freeze

  before_validation :unify_is_absent
  before_save do
    # TODO: handle non-otu types.
    self.no_dwc_occurrence = asserted_distribution_object_type != 'Otu'
  end

  validate :records_include_citation
  validate :object_shape_absence_triple_is_unique

  validate :asserted_distribution_object_has_allowed_type

  # TODO: deprecate scopes referencing single parameter where()
  scope :with_is_absent, -> { where('is_absent = true') }
  scope :without_is_absent, -> { where('is_absent = false OR is_absent is Null') }
  scope :with_geographic_area_array, -> (geographic_area_array) { where("asserted_distribution_shape_type = 'GeographicArea' AND asserted_distribution_shape_id IN (?)", geographic_area_array) }
  # Includes a `geographic_item_id` column, so !! may return more results than
  # there are ADs !!.
  scope :associated_with_geographic_items, -> {
    a = AssertedDistribution
      .where(asserted_distribution_shape_type: 'GeographicArea')
      .joins('JOIN geographic_areas ON asserted_distribution_shape_id = geographic_areas.id')
      .joins('JOIN geographic_areas_geographic_items on geographic_areas.id = geographic_areas_geographic_items.geographic_area_id')
      .joins('JOIN geographic_items on geographic_items.id = geographic_areas_geographic_items.geographic_item_id')
      .select('asserted_distributions.*, geographic_items.id geographic_item_id')

    b = AssertedDistribution
      .where(asserted_distribution_shape_type: 'Gazetteer')
      .joins('JOIN gazetteers ON asserted_distribution_shape_id = gazetteers.id')
      .joins('JOIN geographic_items on gazetteers.geographic_item_id = geographic_items.id')
      .select('asserted_distributions.*, geographic_items.id geographic_item_id')

    ::Queries.union(AssertedDistribution, [a, b])
  }
  scope :contributing_to_cached_maps, -> {
    # TODO: eventually include non-otu object types
    ad_ga_with_shape = AssertedDistribution
      .with_otus
      .joins("JOIN geographic_areas ON asserted_distributions.asserted_distribution_shape_type = 'GeographicArea' AND asserted_distributions.asserted_distribution_shape_id = geographic_areas.id")
      .joins('JOIN geographic_areas_geographic_items on geographic_areas.id = geographic_areas_geographic_items.geographic_area_id')

    ad_gaz = AssertedDistribution
      .with_otus
      .where(asserted_distribution_shape_type: 'Gazetteer')

    ::Queries.union(AssertedDistribution, [ad_ga_with_shape, ad_gaz])
      .without_is_absent
  }
  scope :with_otus, -> {
    joins("JOIN otus ON otus.id = asserted_distributions.asserted_distribution_object_id AND asserted_distributions.asserted_distribution_object_type = 'Otu'")
  }
  scope :with_taxon_names, -> {
    joins("JOIN otus ON otus.id = asserted_distributions.asserted_distribution_object_id AND asserted_distributions.asserted_distribution_object_type = 'Otu'
    JOIN taxon_names ON taxon_names.id = otus.taxon_name_id")
  }
  scope :with_biological_associations, -> {
    joins("JOIN biological_associations ON biological_associations.id = asserted_distributions.asserted_distribution_object_id AND asserted_distributions.asserted_distribution_object_type = 'BiologicalAssociation'")
  }
  scope :with_biological_associations_graphs, -> {
    joins("JOIN biological_associations_graphs ON biological_associations_graphs.id = asserted_distributions.asserted_distribution_object_id AND asserted_distributions.asserted_distribution_object_type = 'BiologicalAssociationsGraph'")
  }
  scope :with_otu_conveyances, -> {
    joins("JOIN conveyances ON conveyances.id = asserted_distributions.asserted_distribution_object_id AND asserted_distributions.asserted_distribution_object_type = 'Conveyance'
    AND conveyances.conveyance_object_type = 'Otu'")
  }
  scope :with_otu_depictions, -> {
    joins("JOIN depictions ON depictions.id = asserted_distributions.asserted_distribution_object_id AND asserted_distributions.asserted_distribution_object_type = 'Depiction'
    AND depictions.depiction_object_type = 'Otu'")
  }
  scope :with_otu_observations, -> {
    joins("JOIN observations ON observations.id = asserted_distributions.asserted_distribution_object_id AND asserted_distributions.asserted_distribution_object_type = 'Observation'
    AND observations.observation_object_type = 'Otu'")
  }

  soft_validate(:sv_conflicting_geographic_area, set: :conflicting_geographic_area, name: 'conflicting geographic area', description: 'conflicting geographic area')

  # getter for attr :geographic_names
  def geographic_names
    return @geographic_names if !@geographic_names.nil?
    # TODO: Possibly provide a2/a3 info from gazetteers??
    @geographic_names ||=
      asserted_distribution_shape.geographic_name_classification
        .delete_if{|k,v| v.nil?}
    @geographic_names ||= {}
  end

  # rubocop:disable Style/StringHashKeys
  # TODO: DRY with helper methods
  # @return [Hash] GeoJSON feature
  def to_geo_json_feature
    retval = {
      'type' => 'Feature',
      'geometry' => RGeo::GeoJSON.encode(geo_object),
      'properties' => {'asserted_distribution' => {'id' => self.id}}
    }
    retval
  end

  # rubocop:enable Style/StringHashKeys

  # @return [True]
  #   see citable.rb
  def requires_citation?
    true
  end

  def geographic_item
    asserted_distribution_shape.default_geographic_item
  end

  def otu
    return nil if asserted_distribution_object_type != 'Otu'

    asserted_distribution_object
  end

  def has_shape?
    asserted_distribution_shape.geographic_items.any?
  end

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      async_cutoff: params[:async_cutoff] || 26,
      klass: 'AssertedDistribution',
      object_filter_params: params[:asserted_distribution_query],
      object_params: params[:asserted_distribution],
      preview: params[:preview],
    )

    a = request.filter

    v1 = a.all.distinct.limit(2)
      .pluck(:asserted_distribution_shape_id, :asserted_distribution_shape_type)
      .uniq.count
    v2 = a.all.distinct.limit(2).pluck(:asserted_distribution_object_id).uniq.count

    cap = 0

    if v1 > 1 && v2 > 1 # many objects, many geographic areas
      cap = 0
      request.cap_reason = 'Records include multiple asserted distribution objects *and* multiple asserted distribution shapes.'
    elsif v1 > 1
      cap = 0
      request.cap_reason = 'May not update multiple shapes to one.' # TODO: revist constraint
    else
      cap = 2000
    end

    request.cap = cap

    query_batch_update(request)
  end

  def self.batch_template_create(params)
    async_cutoff = params[:async_cutoff] || 26
    klass = params[:object_type]
    a = "Queries::#{klass}::Filter".constantize.new(params[:object_query])

    r = BatchResponse.new({
      async: a.all.count > async_cutoff,
      preview: params[:preview],
      total_attempted: a.all.count,
      method: 'batch_template_create'
    })

    max_allowed = 250
    if r.total_attempted > max_allowed
      r.errors["Max #{max_allowed} query records allowed"] = 1
      return r
    end

    return r if r.async && r.preview

    if r.async
      object_ids = a.all.pluck(:id)
      user_id = params[:user_id]
      project_id = params[:project_id]
      AssertedDistribution
        .delay(run_at: 1.second.from_now, queue: :query_batch_update)
        .batch_create_from_params(
          params[:template_asserted_distribution], object_ids, klass,
          user_id, project_id
        )
    else
      self.transaction do
        template_params = params[:template_asserted_distribution]
        a.all.select(:id).find_each do |o|
          begin
            ad = update_or_create_by_template(template_params, o.id, klass)
            r.updated.push ad.id
          rescue ActiveRecord::RecordInvalid => e
            r.not_updated.push e.record.id

            r.errors[e.message] = 0 unless r.errors[e.message]

            r.errors[e.message] += 1
          end
        end
        raise ActiveRecord::Rollback if r.preview
      end
    end

    r
  end

  # Intended to be run in a background job.
  def self.batch_create_from_params(
    params, object_ids, object_type, user_id, project_id
  )
    Current.user_id = user_id
    Current.project_id = project_id

    object_ids.each do |object_id|
      begin
        update_or_create_by_template(params, object_id, object_type)
      rescue ActiveRecord::RecordInvalid => e
        # Just continue
      end
    end
  end

  # Raises on error.
  def self.update_or_create_by_template(template_params, object_id, object_type)
    ad = ::AssertedDistribution.find_by(
      asserted_distribution_object_id: object_id,
      asserted_distribution_object_type: object_type,
      asserted_distribution_shape_id:
        template_params[:asserted_distribution_shape_id],
      asserted_distribution_shape_type:
        template_params[:asserted_distribution_shape_type],
      is_absent: template_params[:is_absent]
    )

    if ad
      # Create/add the citation.
      # TODO: this can create a duplicate citation.
      ad.update!(template_params)
    else
      ad = ::AssertedDistribution.create!(
        template_params.merge({
          asserted_distribution_object_id: object_id,
          asserted_distribution_object_type: object_type
        })
      )
    end

    ad
  end

  def self.asserted_distributions_for_api_index(params, project_id)
    a = ::Queries::AssertedDistribution::Filter.new(params)
      .all
      .where(project_id: project_id)
      .includes(:citations, origin_citation: [:source])
      .includes(asserted_distribution_shape: :parent)
      .includes(:asserted_distribution_object)
      .order('asserted_distributions.id')
      .page(params[:page])
      .per(params[:per])

    if a.all.count > 50
      params['extend']&.delete('geo_json')
    end

    a
  end

  protected

  # Never record "false" in the datase, only true
  def unify_is_absent
    self.is_absent = nil if self.is_absent != true
  end

  def records_include_citation
    # !! Be careful making changes here: remember that conditions on one
    # association may/not affect other associations when the actual save
    # happens.
    # Maintain with FieldOccurrence#records_include_taxon_determination

    # Watch out, origin_citation and citations *do not* necessarily share the
    # same marked_for_destruction? info, even when origin_citation is a member
    # of citations.
    origin_citation_is_marked_for_destruction_on_citations =
      origin_citation.present? && citations.present? &&
      citations.count == 1 && !citations.first.id.nil? &&
      origin_citation.id == citations.first.id &&
      citations.first.marked_for_destruction?

    the_one_citation_is_marked_for_destruction_on_origin_citation =
      origin_citation.present? && citations.present? &&
      citations.count == 1 && !citations.first.id.nil? &&
      origin_citation&.id == citations.first.id &&
      origin_citation.marked_for_destruction?

    has_valid_source =
      source.present? &&
      !source.marked_for_destruction? && (
        !origin_citation.present? ||
        (!origin_citation.marked_for_destruction? &&
         !origin_citation_is_marked_for_destruction_on_citations)
      )

    has_valid_origin_citation =
      origin_citation.present? &&
      !origin_citation.marked_for_destruction? &&
      !origin_citation_is_marked_for_destruction_on_citations

    has_valid_citation =
      citations.count(&:marked_for_destruction?) < citations.size &&
       !the_one_citation_is_marked_for_destruction_on_origin_citation

    if !has_valid_source && !has_valid_origin_citation && !has_valid_citation
      errors.add(:base, 'required citation is not provided')
      return
    end

    # We loaded the citations association above. If it was empty, Rails caches
    # that value and *will not* add a citation created during save from source
    # or origin_citation unless we reset the citations association.
    if (source.present? || origin_citation.present?) && citations.size == 0
      association(:citations).reset
    end

    if source.present? && origin_citation.nil?
      association(:origin_citation).reset
    end
  end

  # @return [Boolean]
  def sv_conflicting_geographic_area
    # TODO: more expensive for gazetteers, which would require a spatial check.
    geographic_area = asserted_distribution_shape if asserted_distribution_shape_type == 'GeographicArea'
    return if geographic_area.nil?

    areas = [geographic_area.level0_id, geographic_area.level1_id, geographic_area.level2_id].compact
    if is_absent # this returns an array, not a single GA so test below is not right
      presence = AssertedDistribution
        .without_is_absent
        .with_geographic_area_array(areas)
        .where(asserted_distribution_object:)
      soft_validations.add(:geographic_area_id, "Taxon is reported as present in #{presence.first.asserted_distribution_shape.name}") unless presence.empty?
    else
      presence = AssertedDistribution
        .with_is_absent
        .where(asserted_distribution_object:)
        .with_geographic_area_array(areas)
      soft_validations.add(:geographic_area_id, "Taxon is reported as missing in #{presence.first.asserted_distribution_shape.name}") unless presence.empty?
    end
  end

  # DEPRECATED, unused (maybe)
  # @param [Hash] defaults
  # @return [AssertedDistribution]
  #   used to also stub an #origin_citation, as required
  def self.stub(defaults: {})
    a = AssertedDistribution.new(
      asserted_distribution_object_id:
        defaults[:asserted_distribution_object_id],
      asserted_distribution_object_type:
        defaults[:asserted_distribution_object_type],
      origin_citation_attributes: {source_id: defaults[:source_id]})
    a.origin_citation = Citation.new if defaults[:source_id].blank?
    a
  end

  # Currently only used in specs
  # @param [Hash] options of e.g., {asserted_distribution_object_id: 5, asserted_distribution_object_type: 'Otu' source_id: 5, geographic_areas: Array of {GeographicArea}}
  # @return [Array] an array of AssertedDistributions
  def self.stub_new(options = {})
    options.symbolize_keys!
    result = []
    options[:geographic_areas].each do |ga|
      result.push(
        AssertedDistribution.new(
          asserted_distribution_object_id: options[:otu_id],
          asserted_distribution_object_type: 'Otu',
          asserted_distribution_shape: ga,
          origin_citation_attributes: {source_id: options[:source_id]})
      )
    end
    result
  end

  def object_shape_absence_triple_is_unique
    if AssertedDistribution
        .where(
          asserted_distribution_object_type:, asserted_distribution_object_id:,
          asserted_distribution_shape_type:, asserted_distribution_shape_id:,
          is_absent:
        )
        .where.not(id:)
        .exists?

      # Put the error on asserted_distribution_object so that unify can handle
      # duplicates.
      errors.add(:asserted_distribution_object,
        'this shape, object, and present/absent combination already exists'
      )
    end
  end

  def asserted_distribution_object_has_allowed_type
    t = asserted_distribution_object_type&.to_s # STRING (not symbol)

    if !DISTRIBUTION_ASSERTABLE_TYPES.include?(t)
      errors.add(t || :base, " - the type of this asserted distribution's object can only be one of #{DISTRIBUTION_ASSERTABLE_TYPES}, not '#{t}'")
      return
    end

    if (
      (a = ASSERTED_DISTRIBUTION_OBJECT_RELATED_TYPES[t]) &&
      !a.include?(asserted_distribution_object&.send("#{t.underscore}_object_type"))
    )
     errors.add(t || :base,
       " - the target of this asserted distribution's object can only be in #{a}")
    end
  end
end

#asserted_distribution_shape_typeString

polymorphic spatial shape type

Returns:

  • (String)


30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
# File 'app/models/asserted_distribution.rb', line 30

class AssertedDistribution < ApplicationRecord
  include Housekeeping
  include SoftValidation
  include Shared::Notes
  include Shared::Tags
  include Shared::DataAttributes # Why?
  include Shared::CitationRequired # !! must preceed Shared::Citations
  include Shared::Citations
  include Shared::Confidences
  include Shared::OriginRelationship
  include Shared::Identifiers
  include Shared::HasPapertrail
  include Shared::Taxonomy # at present must preceed DwcExtensions

  include AssertedDistribution::DwcExtensions
  include Shared::IsData

  include Shared::Maps
  include Shared::QueryBatchUpdate
  include Shared::PolymorphicAnnotator
  polymorphic_annotates('asserted_distribution_shape')
  polymorphic_annotates('asserted_distribution_object')

  originates_from 'Specimen', 'Lot', 'FieldOccurrence'

  # @return [Hash]
  #   of known country/state/county values
  attr_accessor :geographic_names

  delegate :geo_object, to: :asserted_distribution_shape

  # This only asserts when the asserted distribution object is polymorphic and
  # has a restriction on its type in order to be an AD.
  ASSERTED_DISTRIBUTION_OBJECT_RELATED_TYPES = {
    'Conveyance' => ['Otu'],
    'Depiction' => ['Otu'],
    'Observation' => ['Otu']
  }.freeze

  before_validation :unify_is_absent
  before_save do
    # TODO: handle non-otu types.
    self.no_dwc_occurrence = asserted_distribution_object_type != 'Otu'
  end

  validate :records_include_citation
  validate :object_shape_absence_triple_is_unique

  validate :asserted_distribution_object_has_allowed_type

  # TODO: deprecate scopes referencing single parameter where()
  scope :with_is_absent, -> { where('is_absent = true') }
  scope :without_is_absent, -> { where('is_absent = false OR is_absent is Null') }
  scope :with_geographic_area_array, -> (geographic_area_array) { where("asserted_distribution_shape_type = 'GeographicArea' AND asserted_distribution_shape_id IN (?)", geographic_area_array) }
  # Includes a `geographic_item_id` column, so !! may return more results than
  # there are ADs !!.
  scope :associated_with_geographic_items, -> {
    a = AssertedDistribution
      .where(asserted_distribution_shape_type: 'GeographicArea')
      .joins('JOIN geographic_areas ON asserted_distribution_shape_id = geographic_areas.id')
      .joins('JOIN geographic_areas_geographic_items on geographic_areas.id = geographic_areas_geographic_items.geographic_area_id')
      .joins('JOIN geographic_items on geographic_items.id = geographic_areas_geographic_items.geographic_item_id')
      .select('asserted_distributions.*, geographic_items.id geographic_item_id')

    b = AssertedDistribution
      .where(asserted_distribution_shape_type: 'Gazetteer')
      .joins('JOIN gazetteers ON asserted_distribution_shape_id = gazetteers.id')
      .joins('JOIN geographic_items on gazetteers.geographic_item_id = geographic_items.id')
      .select('asserted_distributions.*, geographic_items.id geographic_item_id')

    ::Queries.union(AssertedDistribution, [a, b])
  }
  scope :contributing_to_cached_maps, -> {
    # TODO: eventually include non-otu object types
    ad_ga_with_shape = AssertedDistribution
      .with_otus
      .joins("JOIN geographic_areas ON asserted_distributions.asserted_distribution_shape_type = 'GeographicArea' AND asserted_distributions.asserted_distribution_shape_id = geographic_areas.id")
      .joins('JOIN geographic_areas_geographic_items on geographic_areas.id = geographic_areas_geographic_items.geographic_area_id')

    ad_gaz = AssertedDistribution
      .with_otus
      .where(asserted_distribution_shape_type: 'Gazetteer')

    ::Queries.union(AssertedDistribution, [ad_ga_with_shape, ad_gaz])
      .without_is_absent
  }
  scope :with_otus, -> {
    joins("JOIN otus ON otus.id = asserted_distributions.asserted_distribution_object_id AND asserted_distributions.asserted_distribution_object_type = 'Otu'")
  }
  scope :with_taxon_names, -> {
    joins("JOIN otus ON otus.id = asserted_distributions.asserted_distribution_object_id AND asserted_distributions.asserted_distribution_object_type = 'Otu'
    JOIN taxon_names ON taxon_names.id = otus.taxon_name_id")
  }
  scope :with_biological_associations, -> {
    joins("JOIN biological_associations ON biological_associations.id = asserted_distributions.asserted_distribution_object_id AND asserted_distributions.asserted_distribution_object_type = 'BiologicalAssociation'")
  }
  scope :with_biological_associations_graphs, -> {
    joins("JOIN biological_associations_graphs ON biological_associations_graphs.id = asserted_distributions.asserted_distribution_object_id AND asserted_distributions.asserted_distribution_object_type = 'BiologicalAssociationsGraph'")
  }
  scope :with_otu_conveyances, -> {
    joins("JOIN conveyances ON conveyances.id = asserted_distributions.asserted_distribution_object_id AND asserted_distributions.asserted_distribution_object_type = 'Conveyance'
    AND conveyances.conveyance_object_type = 'Otu'")
  }
  scope :with_otu_depictions, -> {
    joins("JOIN depictions ON depictions.id = asserted_distributions.asserted_distribution_object_id AND asserted_distributions.asserted_distribution_object_type = 'Depiction'
    AND depictions.depiction_object_type = 'Otu'")
  }
  scope :with_otu_observations, -> {
    joins("JOIN observations ON observations.id = asserted_distributions.asserted_distribution_object_id AND asserted_distributions.asserted_distribution_object_type = 'Observation'
    AND observations.observation_object_type = 'Otu'")
  }

  soft_validate(:sv_conflicting_geographic_area, set: :conflicting_geographic_area, name: 'conflicting geographic area', description: 'conflicting geographic area')

  # getter for attr :geographic_names
  def geographic_names
    return @geographic_names if !@geographic_names.nil?
    # TODO: Possibly provide a2/a3 info from gazetteers??
    @geographic_names ||=
      asserted_distribution_shape.geographic_name_classification
        .delete_if{|k,v| v.nil?}
    @geographic_names ||= {}
  end

  # rubocop:disable Style/StringHashKeys
  # TODO: DRY with helper methods
  # @return [Hash] GeoJSON feature
  def to_geo_json_feature
    retval = {
      'type' => 'Feature',
      'geometry' => RGeo::GeoJSON.encode(geo_object),
      'properties' => {'asserted_distribution' => {'id' => self.id}}
    }
    retval
  end

  # rubocop:enable Style/StringHashKeys

  # @return [True]
  #   see citable.rb
  def requires_citation?
    true
  end

  def geographic_item
    asserted_distribution_shape.default_geographic_item
  end

  def otu
    return nil if asserted_distribution_object_type != 'Otu'

    asserted_distribution_object
  end

  def has_shape?
    asserted_distribution_shape.geographic_items.any?
  end

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      async_cutoff: params[:async_cutoff] || 26,
      klass: 'AssertedDistribution',
      object_filter_params: params[:asserted_distribution_query],
      object_params: params[:asserted_distribution],
      preview: params[:preview],
    )

    a = request.filter

    v1 = a.all.distinct.limit(2)
      .pluck(:asserted_distribution_shape_id, :asserted_distribution_shape_type)
      .uniq.count
    v2 = a.all.distinct.limit(2).pluck(:asserted_distribution_object_id).uniq.count

    cap = 0

    if v1 > 1 && v2 > 1 # many objects, many geographic areas
      cap = 0
      request.cap_reason = 'Records include multiple asserted distribution objects *and* multiple asserted distribution shapes.'
    elsif v1 > 1
      cap = 0
      request.cap_reason = 'May not update multiple shapes to one.' # TODO: revist constraint
    else
      cap = 2000
    end

    request.cap = cap

    query_batch_update(request)
  end

  def self.batch_template_create(params)
    async_cutoff = params[:async_cutoff] || 26
    klass = params[:object_type]
    a = "Queries::#{klass}::Filter".constantize.new(params[:object_query])

    r = BatchResponse.new({
      async: a.all.count > async_cutoff,
      preview: params[:preview],
      total_attempted: a.all.count,
      method: 'batch_template_create'
    })

    max_allowed = 250
    if r.total_attempted > max_allowed
      r.errors["Max #{max_allowed} query records allowed"] = 1
      return r
    end

    return r if r.async && r.preview

    if r.async
      object_ids = a.all.pluck(:id)
      user_id = params[:user_id]
      project_id = params[:project_id]
      AssertedDistribution
        .delay(run_at: 1.second.from_now, queue: :query_batch_update)
        .batch_create_from_params(
          params[:template_asserted_distribution], object_ids, klass,
          user_id, project_id
        )
    else
      self.transaction do
        template_params = params[:template_asserted_distribution]
        a.all.select(:id).find_each do |o|
          begin
            ad = update_or_create_by_template(template_params, o.id, klass)
            r.updated.push ad.id
          rescue ActiveRecord::RecordInvalid => e
            r.not_updated.push e.record.id

            r.errors[e.message] = 0 unless r.errors[e.message]

            r.errors[e.message] += 1
          end
        end
        raise ActiveRecord::Rollback if r.preview
      end
    end

    r
  end

  # Intended to be run in a background job.
  def self.batch_create_from_params(
    params, object_ids, object_type, user_id, project_id
  )
    Current.user_id = user_id
    Current.project_id = project_id

    object_ids.each do |object_id|
      begin
        update_or_create_by_template(params, object_id, object_type)
      rescue ActiveRecord::RecordInvalid => e
        # Just continue
      end
    end
  end

  # Raises on error.
  def self.update_or_create_by_template(template_params, object_id, object_type)
    ad = ::AssertedDistribution.find_by(
      asserted_distribution_object_id: object_id,
      asserted_distribution_object_type: object_type,
      asserted_distribution_shape_id:
        template_params[:asserted_distribution_shape_id],
      asserted_distribution_shape_type:
        template_params[:asserted_distribution_shape_type],
      is_absent: template_params[:is_absent]
    )

    if ad
      # Create/add the citation.
      # TODO: this can create a duplicate citation.
      ad.update!(template_params)
    else
      ad = ::AssertedDistribution.create!(
        template_params.merge({
          asserted_distribution_object_id: object_id,
          asserted_distribution_object_type: object_type
        })
      )
    end

    ad
  end

  def self.asserted_distributions_for_api_index(params, project_id)
    a = ::Queries::AssertedDistribution::Filter.new(params)
      .all
      .where(project_id: project_id)
      .includes(:citations, origin_citation: [:source])
      .includes(asserted_distribution_shape: :parent)
      .includes(:asserted_distribution_object)
      .order('asserted_distributions.id')
      .page(params[:page])
      .per(params[:per])

    if a.all.count > 50
      params['extend']&.delete('geo_json')
    end

    a
  end

  protected

  # Never record "false" in the datase, only true
  def unify_is_absent
    self.is_absent = nil if self.is_absent != true
  end

  def records_include_citation
    # !! Be careful making changes here: remember that conditions on one
    # association may/not affect other associations when the actual save
    # happens.
    # Maintain with FieldOccurrence#records_include_taxon_determination

    # Watch out, origin_citation and citations *do not* necessarily share the
    # same marked_for_destruction? info, even when origin_citation is a member
    # of citations.
    origin_citation_is_marked_for_destruction_on_citations =
      origin_citation.present? && citations.present? &&
      citations.count == 1 && !citations.first.id.nil? &&
      origin_citation.id == citations.first.id &&
      citations.first.marked_for_destruction?

    the_one_citation_is_marked_for_destruction_on_origin_citation =
      origin_citation.present? && citations.present? &&
      citations.count == 1 && !citations.first.id.nil? &&
      origin_citation&.id == citations.first.id &&
      origin_citation.marked_for_destruction?

    has_valid_source =
      source.present? &&
      !source.marked_for_destruction? && (
        !origin_citation.present? ||
        (!origin_citation.marked_for_destruction? &&
         !origin_citation_is_marked_for_destruction_on_citations)
      )

    has_valid_origin_citation =
      origin_citation.present? &&
      !origin_citation.marked_for_destruction? &&
      !origin_citation_is_marked_for_destruction_on_citations

    has_valid_citation =
      citations.count(&:marked_for_destruction?) < citations.size &&
       !the_one_citation_is_marked_for_destruction_on_origin_citation

    if !has_valid_source && !has_valid_origin_citation && !has_valid_citation
      errors.add(:base, 'required citation is not provided')
      return
    end

    # We loaded the citations association above. If it was empty, Rails caches
    # that value and *will not* add a citation created during save from source
    # or origin_citation unless we reset the citations association.
    if (source.present? || origin_citation.present?) && citations.size == 0
      association(:citations).reset
    end

    if source.present? && origin_citation.nil?
      association(:origin_citation).reset
    end
  end

  # @return [Boolean]
  def sv_conflicting_geographic_area
    # TODO: more expensive for gazetteers, which would require a spatial check.
    geographic_area = asserted_distribution_shape if asserted_distribution_shape_type == 'GeographicArea'
    return if geographic_area.nil?

    areas = [geographic_area.level0_id, geographic_area.level1_id, geographic_area.level2_id].compact
    if is_absent # this returns an array, not a single GA so test below is not right
      presence = AssertedDistribution
        .without_is_absent
        .with_geographic_area_array(areas)
        .where(asserted_distribution_object:)
      soft_validations.add(:geographic_area_id, "Taxon is reported as present in #{presence.first.asserted_distribution_shape.name}") unless presence.empty?
    else
      presence = AssertedDistribution
        .with_is_absent
        .where(asserted_distribution_object:)
        .with_geographic_area_array(areas)
      soft_validations.add(:geographic_area_id, "Taxon is reported as missing in #{presence.first.asserted_distribution_shape.name}") unless presence.empty?
    end
  end

  # DEPRECATED, unused (maybe)
  # @param [Hash] defaults
  # @return [AssertedDistribution]
  #   used to also stub an #origin_citation, as required
  def self.stub(defaults: {})
    a = AssertedDistribution.new(
      asserted_distribution_object_id:
        defaults[:asserted_distribution_object_id],
      asserted_distribution_object_type:
        defaults[:asserted_distribution_object_type],
      origin_citation_attributes: {source_id: defaults[:source_id]})
    a.origin_citation = Citation.new if defaults[:source_id].blank?
    a
  end

  # Currently only used in specs
  # @param [Hash] options of e.g., {asserted_distribution_object_id: 5, asserted_distribution_object_type: 'Otu' source_id: 5, geographic_areas: Array of {GeographicArea}}
  # @return [Array] an array of AssertedDistributions
  def self.stub_new(options = {})
    options.symbolize_keys!
    result = []
    options[:geographic_areas].each do |ga|
      result.push(
        AssertedDistribution.new(
          asserted_distribution_object_id: options[:otu_id],
          asserted_distribution_object_type: 'Otu',
          asserted_distribution_shape: ga,
          origin_citation_attributes: {source_id: options[:source_id]})
      )
    end
    result
  end

  def object_shape_absence_triple_is_unique
    if AssertedDistribution
        .where(
          asserted_distribution_object_type:, asserted_distribution_object_id:,
          asserted_distribution_shape_type:, asserted_distribution_shape_id:,
          is_absent:
        )
        .where.not(id:)
        .exists?

      # Put the error on asserted_distribution_object so that unify can handle
      # duplicates.
      errors.add(:asserted_distribution_object,
        'this shape, object, and present/absent combination already exists'
      )
    end
  end

  def asserted_distribution_object_has_allowed_type
    t = asserted_distribution_object_type&.to_s # STRING (not symbol)

    if !DISTRIBUTION_ASSERTABLE_TYPES.include?(t)
      errors.add(t || :base, " - the type of this asserted distribution's object can only be one of #{DISTRIBUTION_ASSERTABLE_TYPES}, not '#{t}'")
      return
    end

    if (
      (a = ASSERTED_DISTRIBUTION_OBJECT_RELATED_TYPES[t]) &&
      !a.include?(asserted_distribution_object&.send("#{t.underscore}_object_type"))
    )
     errors.add(t || :base,
       " - the target of this asserted distribution's object can only be in #{a}")
    end
  end
end

#geographic_namesObject

getter for attr :geographic_names



57
58
59
# File 'app/models/asserted_distribution.rb', line 57

def geographic_names
  @geographic_names
end

#is_absentBoolean

Returns a positive negative, when true then there exists an assertion that the taxon is not present in the spatial area.

Returns:

  • (Boolean)

    a positive negative, when true then there exists an assertion that the taxon is not present in the spatial area



30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
# File 'app/models/asserted_distribution.rb', line 30

class AssertedDistribution < ApplicationRecord
  include Housekeeping
  include SoftValidation
  include Shared::Notes
  include Shared::Tags
  include Shared::DataAttributes # Why?
  include Shared::CitationRequired # !! must preceed Shared::Citations
  include Shared::Citations
  include Shared::Confidences
  include Shared::OriginRelationship
  include Shared::Identifiers
  include Shared::HasPapertrail
  include Shared::Taxonomy # at present must preceed DwcExtensions

  include AssertedDistribution::DwcExtensions
  include Shared::IsData

  include Shared::Maps
  include Shared::QueryBatchUpdate
  include Shared::PolymorphicAnnotator
  polymorphic_annotates('asserted_distribution_shape')
  polymorphic_annotates('asserted_distribution_object')

  originates_from 'Specimen', 'Lot', 'FieldOccurrence'

  # @return [Hash]
  #   of known country/state/county values
  attr_accessor :geographic_names

  delegate :geo_object, to: :asserted_distribution_shape

  # This only asserts when the asserted distribution object is polymorphic and
  # has a restriction on its type in order to be an AD.
  ASSERTED_DISTRIBUTION_OBJECT_RELATED_TYPES = {
    'Conveyance' => ['Otu'],
    'Depiction' => ['Otu'],
    'Observation' => ['Otu']
  }.freeze

  before_validation :unify_is_absent
  before_save do
    # TODO: handle non-otu types.
    self.no_dwc_occurrence = asserted_distribution_object_type != 'Otu'
  end

  validate :records_include_citation
  validate :object_shape_absence_triple_is_unique

  validate :asserted_distribution_object_has_allowed_type

  # TODO: deprecate scopes referencing single parameter where()
  scope :with_is_absent, -> { where('is_absent = true') }
  scope :without_is_absent, -> { where('is_absent = false OR is_absent is Null') }
  scope :with_geographic_area_array, -> (geographic_area_array) { where("asserted_distribution_shape_type = 'GeographicArea' AND asserted_distribution_shape_id IN (?)", geographic_area_array) }
  # Includes a `geographic_item_id` column, so !! may return more results than
  # there are ADs !!.
  scope :associated_with_geographic_items, -> {
    a = AssertedDistribution
      .where(asserted_distribution_shape_type: 'GeographicArea')
      .joins('JOIN geographic_areas ON asserted_distribution_shape_id = geographic_areas.id')
      .joins('JOIN geographic_areas_geographic_items on geographic_areas.id = geographic_areas_geographic_items.geographic_area_id')
      .joins('JOIN geographic_items on geographic_items.id = geographic_areas_geographic_items.geographic_item_id')
      .select('asserted_distributions.*, geographic_items.id geographic_item_id')

    b = AssertedDistribution
      .where(asserted_distribution_shape_type: 'Gazetteer')
      .joins('JOIN gazetteers ON asserted_distribution_shape_id = gazetteers.id')
      .joins('JOIN geographic_items on gazetteers.geographic_item_id = geographic_items.id')
      .select('asserted_distributions.*, geographic_items.id geographic_item_id')

    ::Queries.union(AssertedDistribution, [a, b])
  }
  scope :contributing_to_cached_maps, -> {
    # TODO: eventually include non-otu object types
    ad_ga_with_shape = AssertedDistribution
      .with_otus
      .joins("JOIN geographic_areas ON asserted_distributions.asserted_distribution_shape_type = 'GeographicArea' AND asserted_distributions.asserted_distribution_shape_id = geographic_areas.id")
      .joins('JOIN geographic_areas_geographic_items on geographic_areas.id = geographic_areas_geographic_items.geographic_area_id')

    ad_gaz = AssertedDistribution
      .with_otus
      .where(asserted_distribution_shape_type: 'Gazetteer')

    ::Queries.union(AssertedDistribution, [ad_ga_with_shape, ad_gaz])
      .without_is_absent
  }
  scope :with_otus, -> {
    joins("JOIN otus ON otus.id = asserted_distributions.asserted_distribution_object_id AND asserted_distributions.asserted_distribution_object_type = 'Otu'")
  }
  scope :with_taxon_names, -> {
    joins("JOIN otus ON otus.id = asserted_distributions.asserted_distribution_object_id AND asserted_distributions.asserted_distribution_object_type = 'Otu'
    JOIN taxon_names ON taxon_names.id = otus.taxon_name_id")
  }
  scope :with_biological_associations, -> {
    joins("JOIN biological_associations ON biological_associations.id = asserted_distributions.asserted_distribution_object_id AND asserted_distributions.asserted_distribution_object_type = 'BiologicalAssociation'")
  }
  scope :with_biological_associations_graphs, -> {
    joins("JOIN biological_associations_graphs ON biological_associations_graphs.id = asserted_distributions.asserted_distribution_object_id AND asserted_distributions.asserted_distribution_object_type = 'BiologicalAssociationsGraph'")
  }
  scope :with_otu_conveyances, -> {
    joins("JOIN conveyances ON conveyances.id = asserted_distributions.asserted_distribution_object_id AND asserted_distributions.asserted_distribution_object_type = 'Conveyance'
    AND conveyances.conveyance_object_type = 'Otu'")
  }
  scope :with_otu_depictions, -> {
    joins("JOIN depictions ON depictions.id = asserted_distributions.asserted_distribution_object_id AND asserted_distributions.asserted_distribution_object_type = 'Depiction'
    AND depictions.depiction_object_type = 'Otu'")
  }
  scope :with_otu_observations, -> {
    joins("JOIN observations ON observations.id = asserted_distributions.asserted_distribution_object_id AND asserted_distributions.asserted_distribution_object_type = 'Observation'
    AND observations.observation_object_type = 'Otu'")
  }

  soft_validate(:sv_conflicting_geographic_area, set: :conflicting_geographic_area, name: 'conflicting geographic area', description: 'conflicting geographic area')

  # getter for attr :geographic_names
  def geographic_names
    return @geographic_names if !@geographic_names.nil?
    # TODO: Possibly provide a2/a3 info from gazetteers??
    @geographic_names ||=
      asserted_distribution_shape.geographic_name_classification
        .delete_if{|k,v| v.nil?}
    @geographic_names ||= {}
  end

  # rubocop:disable Style/StringHashKeys
  # TODO: DRY with helper methods
  # @return [Hash] GeoJSON feature
  def to_geo_json_feature
    retval = {
      'type' => 'Feature',
      'geometry' => RGeo::GeoJSON.encode(geo_object),
      'properties' => {'asserted_distribution' => {'id' => self.id}}
    }
    retval
  end

  # rubocop:enable Style/StringHashKeys

  # @return [True]
  #   see citable.rb
  def requires_citation?
    true
  end

  def geographic_item
    asserted_distribution_shape.default_geographic_item
  end

  def otu
    return nil if asserted_distribution_object_type != 'Otu'

    asserted_distribution_object
  end

  def has_shape?
    asserted_distribution_shape.geographic_items.any?
  end

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      async_cutoff: params[:async_cutoff] || 26,
      klass: 'AssertedDistribution',
      object_filter_params: params[:asserted_distribution_query],
      object_params: params[:asserted_distribution],
      preview: params[:preview],
    )

    a = request.filter

    v1 = a.all.distinct.limit(2)
      .pluck(:asserted_distribution_shape_id, :asserted_distribution_shape_type)
      .uniq.count
    v2 = a.all.distinct.limit(2).pluck(:asserted_distribution_object_id).uniq.count

    cap = 0

    if v1 > 1 && v2 > 1 # many objects, many geographic areas
      cap = 0
      request.cap_reason = 'Records include multiple asserted distribution objects *and* multiple asserted distribution shapes.'
    elsif v1 > 1
      cap = 0
      request.cap_reason = 'May not update multiple shapes to one.' # TODO: revist constraint
    else
      cap = 2000
    end

    request.cap = cap

    query_batch_update(request)
  end

  def self.batch_template_create(params)
    async_cutoff = params[:async_cutoff] || 26
    klass = params[:object_type]
    a = "Queries::#{klass}::Filter".constantize.new(params[:object_query])

    r = BatchResponse.new({
      async: a.all.count > async_cutoff,
      preview: params[:preview],
      total_attempted: a.all.count,
      method: 'batch_template_create'
    })

    max_allowed = 250
    if r.total_attempted > max_allowed
      r.errors["Max #{max_allowed} query records allowed"] = 1
      return r
    end

    return r if r.async && r.preview

    if r.async
      object_ids = a.all.pluck(:id)
      user_id = params[:user_id]
      project_id = params[:project_id]
      AssertedDistribution
        .delay(run_at: 1.second.from_now, queue: :query_batch_update)
        .batch_create_from_params(
          params[:template_asserted_distribution], object_ids, klass,
          user_id, project_id
        )
    else
      self.transaction do
        template_params = params[:template_asserted_distribution]
        a.all.select(:id).find_each do |o|
          begin
            ad = update_or_create_by_template(template_params, o.id, klass)
            r.updated.push ad.id
          rescue ActiveRecord::RecordInvalid => e
            r.not_updated.push e.record.id

            r.errors[e.message] = 0 unless r.errors[e.message]

            r.errors[e.message] += 1
          end
        end
        raise ActiveRecord::Rollback if r.preview
      end
    end

    r
  end

  # Intended to be run in a background job.
  def self.batch_create_from_params(
    params, object_ids, object_type, user_id, project_id
  )
    Current.user_id = user_id
    Current.project_id = project_id

    object_ids.each do |object_id|
      begin
        update_or_create_by_template(params, object_id, object_type)
      rescue ActiveRecord::RecordInvalid => e
        # Just continue
      end
    end
  end

  # Raises on error.
  def self.update_or_create_by_template(template_params, object_id, object_type)
    ad = ::AssertedDistribution.find_by(
      asserted_distribution_object_id: object_id,
      asserted_distribution_object_type: object_type,
      asserted_distribution_shape_id:
        template_params[:asserted_distribution_shape_id],
      asserted_distribution_shape_type:
        template_params[:asserted_distribution_shape_type],
      is_absent: template_params[:is_absent]
    )

    if ad
      # Create/add the citation.
      # TODO: this can create a duplicate citation.
      ad.update!(template_params)
    else
      ad = ::AssertedDistribution.create!(
        template_params.merge({
          asserted_distribution_object_id: object_id,
          asserted_distribution_object_type: object_type
        })
      )
    end

    ad
  end

  def self.asserted_distributions_for_api_index(params, project_id)
    a = ::Queries::AssertedDistribution::Filter.new(params)
      .all
      .where(project_id: project_id)
      .includes(:citations, origin_citation: [:source])
      .includes(asserted_distribution_shape: :parent)
      .includes(:asserted_distribution_object)
      .order('asserted_distributions.id')
      .page(params[:page])
      .per(params[:per])

    if a.all.count > 50
      params['extend']&.delete('geo_json')
    end

    a
  end

  protected

  # Never record "false" in the datase, only true
  def unify_is_absent
    self.is_absent = nil if self.is_absent != true
  end

  def records_include_citation
    # !! Be careful making changes here: remember that conditions on one
    # association may/not affect other associations when the actual save
    # happens.
    # Maintain with FieldOccurrence#records_include_taxon_determination

    # Watch out, origin_citation and citations *do not* necessarily share the
    # same marked_for_destruction? info, even when origin_citation is a member
    # of citations.
    origin_citation_is_marked_for_destruction_on_citations =
      origin_citation.present? && citations.present? &&
      citations.count == 1 && !citations.first.id.nil? &&
      origin_citation.id == citations.first.id &&
      citations.first.marked_for_destruction?

    the_one_citation_is_marked_for_destruction_on_origin_citation =
      origin_citation.present? && citations.present? &&
      citations.count == 1 && !citations.first.id.nil? &&
      origin_citation&.id == citations.first.id &&
      origin_citation.marked_for_destruction?

    has_valid_source =
      source.present? &&
      !source.marked_for_destruction? && (
        !origin_citation.present? ||
        (!origin_citation.marked_for_destruction? &&
         !origin_citation_is_marked_for_destruction_on_citations)
      )

    has_valid_origin_citation =
      origin_citation.present? &&
      !origin_citation.marked_for_destruction? &&
      !origin_citation_is_marked_for_destruction_on_citations

    has_valid_citation =
      citations.count(&:marked_for_destruction?) < citations.size &&
       !the_one_citation_is_marked_for_destruction_on_origin_citation

    if !has_valid_source && !has_valid_origin_citation && !has_valid_citation
      errors.add(:base, 'required citation is not provided')
      return
    end

    # We loaded the citations association above. If it was empty, Rails caches
    # that value and *will not* add a citation created during save from source
    # or origin_citation unless we reset the citations association.
    if (source.present? || origin_citation.present?) && citations.size == 0
      association(:citations).reset
    end

    if source.present? && origin_citation.nil?
      association(:origin_citation).reset
    end
  end

  # @return [Boolean]
  def sv_conflicting_geographic_area
    # TODO: more expensive for gazetteers, which would require a spatial check.
    geographic_area = asserted_distribution_shape if asserted_distribution_shape_type == 'GeographicArea'
    return if geographic_area.nil?

    areas = [geographic_area.level0_id, geographic_area.level1_id, geographic_area.level2_id].compact
    if is_absent # this returns an array, not a single GA so test below is not right
      presence = AssertedDistribution
        .without_is_absent
        .with_geographic_area_array(areas)
        .where(asserted_distribution_object:)
      soft_validations.add(:geographic_area_id, "Taxon is reported as present in #{presence.first.asserted_distribution_shape.name}") unless presence.empty?
    else
      presence = AssertedDistribution
        .with_is_absent
        .where(asserted_distribution_object:)
        .with_geographic_area_array(areas)
      soft_validations.add(:geographic_area_id, "Taxon is reported as missing in #{presence.first.asserted_distribution_shape.name}") unless presence.empty?
    end
  end

  # DEPRECATED, unused (maybe)
  # @param [Hash] defaults
  # @return [AssertedDistribution]
  #   used to also stub an #origin_citation, as required
  def self.stub(defaults: {})
    a = AssertedDistribution.new(
      asserted_distribution_object_id:
        defaults[:asserted_distribution_object_id],
      asserted_distribution_object_type:
        defaults[:asserted_distribution_object_type],
      origin_citation_attributes: {source_id: defaults[:source_id]})
    a.origin_citation = Citation.new if defaults[:source_id].blank?
    a
  end

  # Currently only used in specs
  # @param [Hash] options of e.g., {asserted_distribution_object_id: 5, asserted_distribution_object_type: 'Otu' source_id: 5, geographic_areas: Array of {GeographicArea}}
  # @return [Array] an array of AssertedDistributions
  def self.stub_new(options = {})
    options.symbolize_keys!
    result = []
    options[:geographic_areas].each do |ga|
      result.push(
        AssertedDistribution.new(
          asserted_distribution_object_id: options[:otu_id],
          asserted_distribution_object_type: 'Otu',
          asserted_distribution_shape: ga,
          origin_citation_attributes: {source_id: options[:source_id]})
      )
    end
    result
  end

  def object_shape_absence_triple_is_unique
    if AssertedDistribution
        .where(
          asserted_distribution_object_type:, asserted_distribution_object_id:,
          asserted_distribution_shape_type:, asserted_distribution_shape_id:,
          is_absent:
        )
        .where.not(id:)
        .exists?

      # Put the error on asserted_distribution_object so that unify can handle
      # duplicates.
      errors.add(:asserted_distribution_object,
        'this shape, object, and present/absent combination already exists'
      )
    end
  end

  def asserted_distribution_object_has_allowed_type
    t = asserted_distribution_object_type&.to_s # STRING (not symbol)

    if !DISTRIBUTION_ASSERTABLE_TYPES.include?(t)
      errors.add(t || :base, " - the type of this asserted distribution's object can only be one of #{DISTRIBUTION_ASSERTABLE_TYPES}, not '#{t}'")
      return
    end

    if (
      (a = ASSERTED_DISTRIBUTION_OBJECT_RELATED_TYPES[t]) &&
      !a.include?(asserted_distribution_object&.send("#{t.underscore}_object_type"))
    )
     errors.add(t || :base,
       " - the target of this asserted distribution's object can only be in #{a}")
    end
  end
end

#project_idInteger

the project ID

Returns:

  • (Integer)


30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
# File 'app/models/asserted_distribution.rb', line 30

class AssertedDistribution < ApplicationRecord
  include Housekeeping
  include SoftValidation
  include Shared::Notes
  include Shared::Tags
  include Shared::DataAttributes # Why?
  include Shared::CitationRequired # !! must preceed Shared::Citations
  include Shared::Citations
  include Shared::Confidences
  include Shared::OriginRelationship
  include Shared::Identifiers
  include Shared::HasPapertrail
  include Shared::Taxonomy # at present must preceed DwcExtensions

  include AssertedDistribution::DwcExtensions
  include Shared::IsData

  include Shared::Maps
  include Shared::QueryBatchUpdate
  include Shared::PolymorphicAnnotator
  polymorphic_annotates('asserted_distribution_shape')
  polymorphic_annotates('asserted_distribution_object')

  originates_from 'Specimen', 'Lot', 'FieldOccurrence'

  # @return [Hash]
  #   of known country/state/county values
  attr_accessor :geographic_names

  delegate :geo_object, to: :asserted_distribution_shape

  # This only asserts when the asserted distribution object is polymorphic and
  # has a restriction on its type in order to be an AD.
  ASSERTED_DISTRIBUTION_OBJECT_RELATED_TYPES = {
    'Conveyance' => ['Otu'],
    'Depiction' => ['Otu'],
    'Observation' => ['Otu']
  }.freeze

  before_validation :unify_is_absent
  before_save do
    # TODO: handle non-otu types.
    self.no_dwc_occurrence = asserted_distribution_object_type != 'Otu'
  end

  validate :records_include_citation
  validate :object_shape_absence_triple_is_unique

  validate :asserted_distribution_object_has_allowed_type

  # TODO: deprecate scopes referencing single parameter where()
  scope :with_is_absent, -> { where('is_absent = true') }
  scope :without_is_absent, -> { where('is_absent = false OR is_absent is Null') }
  scope :with_geographic_area_array, -> (geographic_area_array) { where("asserted_distribution_shape_type = 'GeographicArea' AND asserted_distribution_shape_id IN (?)", geographic_area_array) }
  # Includes a `geographic_item_id` column, so !! may return more results than
  # there are ADs !!.
  scope :associated_with_geographic_items, -> {
    a = AssertedDistribution
      .where(asserted_distribution_shape_type: 'GeographicArea')
      .joins('JOIN geographic_areas ON asserted_distribution_shape_id = geographic_areas.id')
      .joins('JOIN geographic_areas_geographic_items on geographic_areas.id = geographic_areas_geographic_items.geographic_area_id')
      .joins('JOIN geographic_items on geographic_items.id = geographic_areas_geographic_items.geographic_item_id')
      .select('asserted_distributions.*, geographic_items.id geographic_item_id')

    b = AssertedDistribution
      .where(asserted_distribution_shape_type: 'Gazetteer')
      .joins('JOIN gazetteers ON asserted_distribution_shape_id = gazetteers.id')
      .joins('JOIN geographic_items on gazetteers.geographic_item_id = geographic_items.id')
      .select('asserted_distributions.*, geographic_items.id geographic_item_id')

    ::Queries.union(AssertedDistribution, [a, b])
  }
  scope :contributing_to_cached_maps, -> {
    # TODO: eventually include non-otu object types
    ad_ga_with_shape = AssertedDistribution
      .with_otus
      .joins("JOIN geographic_areas ON asserted_distributions.asserted_distribution_shape_type = 'GeographicArea' AND asserted_distributions.asserted_distribution_shape_id = geographic_areas.id")
      .joins('JOIN geographic_areas_geographic_items on geographic_areas.id = geographic_areas_geographic_items.geographic_area_id')

    ad_gaz = AssertedDistribution
      .with_otus
      .where(asserted_distribution_shape_type: 'Gazetteer')

    ::Queries.union(AssertedDistribution, [ad_ga_with_shape, ad_gaz])
      .without_is_absent
  }
  scope :with_otus, -> {
    joins("JOIN otus ON otus.id = asserted_distributions.asserted_distribution_object_id AND asserted_distributions.asserted_distribution_object_type = 'Otu'")
  }
  scope :with_taxon_names, -> {
    joins("JOIN otus ON otus.id = asserted_distributions.asserted_distribution_object_id AND asserted_distributions.asserted_distribution_object_type = 'Otu'
    JOIN taxon_names ON taxon_names.id = otus.taxon_name_id")
  }
  scope :with_biological_associations, -> {
    joins("JOIN biological_associations ON biological_associations.id = asserted_distributions.asserted_distribution_object_id AND asserted_distributions.asserted_distribution_object_type = 'BiologicalAssociation'")
  }
  scope :with_biological_associations_graphs, -> {
    joins("JOIN biological_associations_graphs ON biological_associations_graphs.id = asserted_distributions.asserted_distribution_object_id AND asserted_distributions.asserted_distribution_object_type = 'BiologicalAssociationsGraph'")
  }
  scope :with_otu_conveyances, -> {
    joins("JOIN conveyances ON conveyances.id = asserted_distributions.asserted_distribution_object_id AND asserted_distributions.asserted_distribution_object_type = 'Conveyance'
    AND conveyances.conveyance_object_type = 'Otu'")
  }
  scope :with_otu_depictions, -> {
    joins("JOIN depictions ON depictions.id = asserted_distributions.asserted_distribution_object_id AND asserted_distributions.asserted_distribution_object_type = 'Depiction'
    AND depictions.depiction_object_type = 'Otu'")
  }
  scope :with_otu_observations, -> {
    joins("JOIN observations ON observations.id = asserted_distributions.asserted_distribution_object_id AND asserted_distributions.asserted_distribution_object_type = 'Observation'
    AND observations.observation_object_type = 'Otu'")
  }

  soft_validate(:sv_conflicting_geographic_area, set: :conflicting_geographic_area, name: 'conflicting geographic area', description: 'conflicting geographic area')

  # getter for attr :geographic_names
  def geographic_names
    return @geographic_names if !@geographic_names.nil?
    # TODO: Possibly provide a2/a3 info from gazetteers??
    @geographic_names ||=
      asserted_distribution_shape.geographic_name_classification
        .delete_if{|k,v| v.nil?}
    @geographic_names ||= {}
  end

  # rubocop:disable Style/StringHashKeys
  # TODO: DRY with helper methods
  # @return [Hash] GeoJSON feature
  def to_geo_json_feature
    retval = {
      'type' => 'Feature',
      'geometry' => RGeo::GeoJSON.encode(geo_object),
      'properties' => {'asserted_distribution' => {'id' => self.id}}
    }
    retval
  end

  # rubocop:enable Style/StringHashKeys

  # @return [True]
  #   see citable.rb
  def requires_citation?
    true
  end

  def geographic_item
    asserted_distribution_shape.default_geographic_item
  end

  def otu
    return nil if asserted_distribution_object_type != 'Otu'

    asserted_distribution_object
  end

  def has_shape?
    asserted_distribution_shape.geographic_items.any?
  end

  def self.batch_update(params)
    request = QueryBatchRequest.new(
      async_cutoff: params[:async_cutoff] || 26,
      klass: 'AssertedDistribution',
      object_filter_params: params[:asserted_distribution_query],
      object_params: params[:asserted_distribution],
      preview: params[:preview],
    )

    a = request.filter

    v1 = a.all.distinct.limit(2)
      .pluck(:asserted_distribution_shape_id, :asserted_distribution_shape_type)
      .uniq.count
    v2 = a.all.distinct.limit(2).pluck(:asserted_distribution_object_id).uniq.count

    cap = 0

    if v1 > 1 && v2 > 1 # many objects, many geographic areas
      cap = 0
      request.cap_reason = 'Records include multiple asserted distribution objects *and* multiple asserted distribution shapes.'
    elsif v1 > 1
      cap = 0
      request.cap_reason = 'May not update multiple shapes to one.' # TODO: revist constraint
    else
      cap = 2000
    end

    request.cap = cap

    query_batch_update(request)
  end

  def self.batch_template_create(params)
    async_cutoff = params[:async_cutoff] || 26
    klass = params[:object_type]
    a = "Queries::#{klass}::Filter".constantize.new(params[:object_query])

    r = BatchResponse.new({
      async: a.all.count > async_cutoff,
      preview: params[:preview],
      total_attempted: a.all.count,
      method: 'batch_template_create'
    })

    max_allowed = 250
    if r.total_attempted > max_allowed
      r.errors["Max #{max_allowed} query records allowed"] = 1
      return r
    end

    return r if r.async && r.preview

    if r.async
      object_ids = a.all.pluck(:id)
      user_id = params[:user_id]
      project_id = params[:project_id]
      AssertedDistribution
        .delay(run_at: 1.second.from_now, queue: :query_batch_update)
        .batch_create_from_params(
          params[:template_asserted_distribution], object_ids, klass,
          user_id, project_id
        )
    else
      self.transaction do
        template_params = params[:template_asserted_distribution]
        a.all.select(:id).find_each do |o|
          begin
            ad = update_or_create_by_template(template_params, o.id, klass)
            r.updated.push ad.id
          rescue ActiveRecord::RecordInvalid => e
            r.not_updated.push e.record.id

            r.errors[e.message] = 0 unless r.errors[e.message]

            r.errors[e.message] += 1
          end
        end
        raise ActiveRecord::Rollback if r.preview
      end
    end

    r
  end

  # Intended to be run in a background job.
  def self.batch_create_from_params(
    params, object_ids, object_type, user_id, project_id
  )
    Current.user_id = user_id
    Current.project_id = project_id

    object_ids.each do |object_id|
      begin
        update_or_create_by_template(params, object_id, object_type)
      rescue ActiveRecord::RecordInvalid => e
        # Just continue
      end
    end
  end

  # Raises on error.
  def self.update_or_create_by_template(template_params, object_id, object_type)
    ad = ::AssertedDistribution.find_by(
      asserted_distribution_object_id: object_id,
      asserted_distribution_object_type: object_type,
      asserted_distribution_shape_id:
        template_params[:asserted_distribution_shape_id],
      asserted_distribution_shape_type:
        template_params[:asserted_distribution_shape_type],
      is_absent: template_params[:is_absent]
    )

    if ad
      # Create/add the citation.
      # TODO: this can create a duplicate citation.
      ad.update!(template_params)
    else
      ad = ::AssertedDistribution.create!(
        template_params.merge({
          asserted_distribution_object_id: object_id,
          asserted_distribution_object_type: object_type
        })
      )
    end

    ad
  end

  def self.asserted_distributions_for_api_index(params, project_id)
    a = ::Queries::AssertedDistribution::Filter.new(params)
      .all
      .where(project_id: project_id)
      .includes(:citations, origin_citation: [:source])
      .includes(asserted_distribution_shape: :parent)
      .includes(:asserted_distribution_object)
      .order('asserted_distributions.id')
      .page(params[:page])
      .per(params[:per])

    if a.all.count > 50
      params['extend']&.delete('geo_json')
    end

    a
  end

  protected

  # Never record "false" in the datase, only true
  def unify_is_absent
    self.is_absent = nil if self.is_absent != true
  end

  def records_include_citation
    # !! Be careful making changes here: remember that conditions on one
    # association may/not affect other associations when the actual save
    # happens.
    # Maintain with FieldOccurrence#records_include_taxon_determination

    # Watch out, origin_citation and citations *do not* necessarily share the
    # same marked_for_destruction? info, even when origin_citation is a member
    # of citations.
    origin_citation_is_marked_for_destruction_on_citations =
      origin_citation.present? && citations.present? &&
      citations.count == 1 && !citations.first.id.nil? &&
      origin_citation.id == citations.first.id &&
      citations.first.marked_for_destruction?

    the_one_citation_is_marked_for_destruction_on_origin_citation =
      origin_citation.present? && citations.present? &&
      citations.count == 1 && !citations.first.id.nil? &&
      origin_citation&.id == citations.first.id &&
      origin_citation.marked_for_destruction?

    has_valid_source =
      source.present? &&
      !source.marked_for_destruction? && (
        !origin_citation.present? ||
        (!origin_citation.marked_for_destruction? &&
         !origin_citation_is_marked_for_destruction_on_citations)
      )

    has_valid_origin_citation =
      origin_citation.present? &&
      !origin_citation.marked_for_destruction? &&
      !origin_citation_is_marked_for_destruction_on_citations

    has_valid_citation =
      citations.count(&:marked_for_destruction?) < citations.size &&
       !the_one_citation_is_marked_for_destruction_on_origin_citation

    if !has_valid_source && !has_valid_origin_citation && !has_valid_citation
      errors.add(:base, 'required citation is not provided')
      return
    end

    # We loaded the citations association above. If it was empty, Rails caches
    # that value and *will not* add a citation created during save from source
    # or origin_citation unless we reset the citations association.
    if (source.present? || origin_citation.present?) && citations.size == 0
      association(:citations).reset
    end

    if source.present? && origin_citation.nil?
      association(:origin_citation).reset
    end
  end

  # @return [Boolean]
  def sv_conflicting_geographic_area
    # TODO: more expensive for gazetteers, which would require a spatial check.
    geographic_area = asserted_distribution_shape if asserted_distribution_shape_type == 'GeographicArea'
    return if geographic_area.nil?

    areas = [geographic_area.level0_id, geographic_area.level1_id, geographic_area.level2_id].compact
    if is_absent # this returns an array, not a single GA so test below is not right
      presence = AssertedDistribution
        .without_is_absent
        .with_geographic_area_array(areas)
        .where(asserted_distribution_object:)
      soft_validations.add(:geographic_area_id, "Taxon is reported as present in #{presence.first.asserted_distribution_shape.name}") unless presence.empty?
    else
      presence = AssertedDistribution
        .with_is_absent
        .where(asserted_distribution_object:)
        .with_geographic_area_array(areas)
      soft_validations.add(:geographic_area_id, "Taxon is reported as missing in #{presence.first.asserted_distribution_shape.name}") unless presence.empty?
    end
  end

  # DEPRECATED, unused (maybe)
  # @param [Hash] defaults
  # @return [AssertedDistribution]
  #   used to also stub an #origin_citation, as required
  def self.stub(defaults: {})
    a = AssertedDistribution.new(
      asserted_distribution_object_id:
        defaults[:asserted_distribution_object_id],
      asserted_distribution_object_type:
        defaults[:asserted_distribution_object_type],
      origin_citation_attributes: {source_id: defaults[:source_id]})
    a.origin_citation = Citation.new if defaults[:source_id].blank?
    a
  end

  # Currently only used in specs
  # @param [Hash] options of e.g., {asserted_distribution_object_id: 5, asserted_distribution_object_type: 'Otu' source_id: 5, geographic_areas: Array of {GeographicArea}}
  # @return [Array] an array of AssertedDistributions
  def self.stub_new(options = {})
    options.symbolize_keys!
    result = []
    options[:geographic_areas].each do |ga|
      result.push(
        AssertedDistribution.new(
          asserted_distribution_object_id: options[:otu_id],
          asserted_distribution_object_type: 'Otu',
          asserted_distribution_shape: ga,
          origin_citation_attributes: {source_id: options[:source_id]})
      )
    end
    result
  end

  def object_shape_absence_triple_is_unique
    if AssertedDistribution
        .where(
          asserted_distribution_object_type:, asserted_distribution_object_id:,
          asserted_distribution_shape_type:, asserted_distribution_shape_id:,
          is_absent:
        )
        .where.not(id:)
        .exists?

      # Put the error on asserted_distribution_object so that unify can handle
      # duplicates.
      errors.add(:asserted_distribution_object,
        'this shape, object, and present/absent combination already exists'
      )
    end
  end

  def asserted_distribution_object_has_allowed_type
    t = asserted_distribution_object_type&.to_s # STRING (not symbol)

    if !DISTRIBUTION_ASSERTABLE_TYPES.include?(t)
      errors.add(t || :base, " - the type of this asserted distribution's object can only be one of #{DISTRIBUTION_ASSERTABLE_TYPES}, not '#{t}'")
      return
    end

    if (
      (a = ASSERTED_DISTRIBUTION_OBJECT_RELATED_TYPES[t]) &&
      !a.include?(asserted_distribution_object&.send("#{t.underscore}_object_type"))
    )
     errors.add(t || :base,
       " - the target of this asserted distribution's object can only be in #{a}")
    end
  end
end

Class Method Details

.asserted_distributions_for_api_index(params, project_id) ⇒ Object



317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
# File 'app/models/asserted_distribution.rb', line 317

def self.asserted_distributions_for_api_index(params, project_id)
  a = ::Queries::AssertedDistribution::Filter.new(params)
    .all
    .where(project_id: project_id)
    .includes(:citations, origin_citation: [:source])
    .includes(asserted_distribution_shape: :parent)
    .includes(:asserted_distribution_object)
    .order('asserted_distributions.id')
    .page(params[:page])
    .per(params[:per])

  if a.all.count > 50
    params['extend']&.delete('geo_json')
  end

  a
end

.batch_create_from_params(params, object_ids, object_type, user_id, project_id) ⇒ Object

Intended to be run in a background job.



274
275
276
277
278
279
280
281
282
283
284
285
286
287
# File 'app/models/asserted_distribution.rb', line 274

def self.batch_create_from_params(
  params, object_ids, object_type, user_id, project_id
)
  Current.user_id = user_id
  Current.project_id = project_id

  object_ids.each do |object_id|
    begin
      update_or_create_by_template(params, object_id, object_type)
    rescue ActiveRecord::RecordInvalid => e
      # Just continue
    end
  end
end

.batch_template_create(params) ⇒ Object



221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
# File 'app/models/asserted_distribution.rb', line 221

def self.batch_template_create(params)
  async_cutoff = params[:async_cutoff] || 26
  klass = params[:object_type]
  a = "Queries::#{klass}::Filter".constantize.new(params[:object_query])

  r = BatchResponse.new({
    async: a.all.count > async_cutoff,
    preview: params[:preview],
    total_attempted: a.all.count,
    method: 'batch_template_create'
  })

  max_allowed = 250
  if r.total_attempted > max_allowed
    r.errors["Max #{max_allowed} query records allowed"] = 1
    return r
  end

  return r if r.async && r.preview

  if r.async
    object_ids = a.all.pluck(:id)
    user_id = params[:user_id]
    project_id = params[:project_id]
    AssertedDistribution
      .delay(run_at: 1.second.from_now, queue: :query_batch_update)
      .batch_create_from_params(
        params[:template_asserted_distribution], object_ids, klass,
        user_id, project_id
      )
  else
    self.transaction do
      template_params = params[:template_asserted_distribution]
      a.all.select(:id).find_each do |o|
        begin
          ad = update_or_create_by_template(template_params, o.id, klass)
          r.updated.push ad.id
        rescue ActiveRecord::RecordInvalid => e
          r.not_updated.push e.record.id

          r.errors[e.message] = 0 unless r.errors[e.message]

          r.errors[e.message] += 1
        end
      end
      raise ActiveRecord::Rollback if r.preview
    end
  end

  r
end

.batch_update(params) ⇒ Object



188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
# File 'app/models/asserted_distribution.rb', line 188

def self.batch_update(params)
  request = QueryBatchRequest.new(
    async_cutoff: params[:async_cutoff] || 26,
    klass: 'AssertedDistribution',
    object_filter_params: params[:asserted_distribution_query],
    object_params: params[:asserted_distribution],
    preview: params[:preview],
  )

  a = request.filter

  v1 = a.all.distinct.limit(2)
    .pluck(:asserted_distribution_shape_id, :asserted_distribution_shape_type)
    .uniq.count
  v2 = a.all.distinct.limit(2).pluck(:asserted_distribution_object_id).uniq.count

  cap = 0

  if v1 > 1 && v2 > 1 # many objects, many geographic areas
    cap = 0
    request.cap_reason = 'Records include multiple asserted distribution objects *and* multiple asserted distribution shapes.'
  elsif v1 > 1
    cap = 0
    request.cap_reason = 'May not update multiple shapes to one.' # TODO: revist constraint
  else
    cap = 2000
  end

  request.cap = cap

  query_batch_update(request)
end

.stub(defaults: {}) ⇒ AssertedDistribution (protected)

DEPRECATED, unused (maybe)

Parameters:

  • defaults (Hash) (defaults to: {})

Returns:



423
424
425
426
427
428
429
430
431
432
# File 'app/models/asserted_distribution.rb', line 423

def self.stub(defaults: {})
  a = AssertedDistribution.new(
    asserted_distribution_object_id:
      defaults[:asserted_distribution_object_id],
    asserted_distribution_object_type:
      defaults[:asserted_distribution_object_type],
    origin_citation_attributes: {source_id: defaults[:source_id]})
  a.origin_citation = Citation.new if defaults[:source_id].blank?
  a
end

.stub_new(options = {}) ⇒ Array (protected)

Currently only used in specs

Parameters:

  • options (Hash) (defaults to: {})

    of e.g., 5, asserted_distribution_object_type: ‘Otu’ source_id: 5, geographic_areas: Array of {GeographicArea}

Returns:

  • (Array)

    an array of AssertedDistributions



437
438
439
440
441
442
443
444
445
446
447
448
449
450
# File 'app/models/asserted_distribution.rb', line 437

def self.stub_new(options = {})
  options.symbolize_keys!
  result = []
  options[:geographic_areas].each do |ga|
    result.push(
      AssertedDistribution.new(
        asserted_distribution_object_id: options[:otu_id],
        asserted_distribution_object_type: 'Otu',
        asserted_distribution_shape: ga,
        origin_citation_attributes: {source_id: options[:source_id]})
    )
  end
  result
end

.update_or_create_by_template(template_params, object_id, object_type) ⇒ Object

Raises on error.



290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
# File 'app/models/asserted_distribution.rb', line 290

def self.update_or_create_by_template(template_params, object_id, object_type)
  ad = ::AssertedDistribution.find_by(
    asserted_distribution_object_id: object_id,
    asserted_distribution_object_type: object_type,
    asserted_distribution_shape_id:
      template_params[:asserted_distribution_shape_id],
    asserted_distribution_shape_type:
      template_params[:asserted_distribution_shape_type],
    is_absent: template_params[:is_absent]
  )

  if ad
    # Create/add the citation.
    # TODO: this can create a duplicate citation.
    ad.update!(template_params)
  else
    ad = ::AssertedDistribution.create!(
      template_params.merge({
        asserted_distribution_object_id: object_id,
        asserted_distribution_object_type: object_type
      })
    )
  end

  ad
end

Instance Method Details

#asserted_distribution_object_has_allowed_typeObject (protected)



470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
# File 'app/models/asserted_distribution.rb', line 470

def asserted_distribution_object_has_allowed_type
  t = asserted_distribution_object_type&.to_s # STRING (not symbol)

  if !DISTRIBUTION_ASSERTABLE_TYPES.include?(t)
    errors.add(t || :base, " - the type of this asserted distribution's object can only be one of #{DISTRIBUTION_ASSERTABLE_TYPES}, not '#{t}'")
    return
  end

  if (
    (a = ASSERTED_DISTRIBUTION_OBJECT_RELATED_TYPES[t]) &&
    !a.include?(asserted_distribution_object&.send("#{t.underscore}_object_type"))
  )
   errors.add(t || :base,
     " - the target of this asserted distribution's object can only be in #{a}")
  end
end

#geographic_itemObject



174
175
176
# File 'app/models/asserted_distribution.rb', line 174

def geographic_item
  asserted_distribution_shape.default_geographic_item
end

#has_shape?Boolean

Returns:

  • (Boolean)


184
185
186
# File 'app/models/asserted_distribution.rb', line 184

def has_shape?
  asserted_distribution_shape.geographic_items.any?
end

#object_shape_absence_triple_is_uniqueObject (protected)



452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
# File 'app/models/asserted_distribution.rb', line 452

def object_shape_absence_triple_is_unique
  if AssertedDistribution
      .where(
        asserted_distribution_object_type:, asserted_distribution_object_id:,
        asserted_distribution_shape_type:, asserted_distribution_shape_id:,
        is_absent:
      )
      .where.not(id:)
      .exists?

    # Put the error on asserted_distribution_object so that unify can handle
    # duplicates.
    errors.add(:asserted_distribution_object,
      'this shape, object, and present/absent combination already exists'
    )
  end
end

#otuObject



178
179
180
181
182
# File 'app/models/asserted_distribution.rb', line 178

def otu
  return nil if asserted_distribution_object_type != 'Otu'

  asserted_distribution_object
end

#records_include_citationObject (protected)



342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
# File 'app/models/asserted_distribution.rb', line 342

def records_include_citation
  # !! Be careful making changes here: remember that conditions on one
  # association may/not affect other associations when the actual save
  # happens.
  # Maintain with FieldOccurrence#records_include_taxon_determination

  # Watch out, origin_citation and citations *do not* necessarily share the
  # same marked_for_destruction? info, even when origin_citation is a member
  # of citations.
  origin_citation_is_marked_for_destruction_on_citations =
    origin_citation.present? && citations.present? &&
    citations.count == 1 && !citations.first.id.nil? &&
    origin_citation.id == citations.first.id &&
    citations.first.marked_for_destruction?

  the_one_citation_is_marked_for_destruction_on_origin_citation =
    origin_citation.present? && citations.present? &&
    citations.count == 1 && !citations.first.id.nil? &&
    origin_citation&.id == citations.first.id &&
    origin_citation.marked_for_destruction?

  has_valid_source =
    source.present? &&
    !source.marked_for_destruction? && (
      !origin_citation.present? ||
      (!origin_citation.marked_for_destruction? &&
       !origin_citation_is_marked_for_destruction_on_citations)
    )

  has_valid_origin_citation =
    origin_citation.present? &&
    !origin_citation.marked_for_destruction? &&
    !origin_citation_is_marked_for_destruction_on_citations

  has_valid_citation =
    citations.count(&:marked_for_destruction?) < citations.size &&
     !the_one_citation_is_marked_for_destruction_on_origin_citation

  if !has_valid_source && !has_valid_origin_citation && !has_valid_citation
    errors.add(:base, 'required citation is not provided')
    return
  end

  # We loaded the citations association above. If it was empty, Rails caches
  # that value and *will not* add a citation created during save from source
  # or origin_citation unless we reset the citations association.
  if (source.present? || origin_citation.present?) && citations.size == 0
    association(:citations).reset
  end

  if source.present? && origin_citation.nil?
    association(:origin_citation).reset
  end
end

#requires_citation?True

Returns see citable.rb.

Returns:

  • (True)

    see citable.rb



170
171
172
# File 'app/models/asserted_distribution.rb', line 170

def requires_citation?
  true
end

#sv_conflicting_geographic_areaBoolean (protected)

Returns:

  • (Boolean)


398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
# File 'app/models/asserted_distribution.rb', line 398

def sv_conflicting_geographic_area
  # TODO: more expensive for gazetteers, which would require a spatial check.
  geographic_area = asserted_distribution_shape if asserted_distribution_shape_type == 'GeographicArea'
  return if geographic_area.nil?

  areas = [geographic_area.level0_id, geographic_area.level1_id, geographic_area.level2_id].compact
  if is_absent # this returns an array, not a single GA so test below is not right
    presence = AssertedDistribution
      .without_is_absent
      .with_geographic_area_array(areas)
      .where(asserted_distribution_object:)
    soft_validations.add(:geographic_area_id, "Taxon is reported as present in #{presence.first.asserted_distribution_shape.name}") unless presence.empty?
  else
    presence = AssertedDistribution
      .with_is_absent
      .where(asserted_distribution_object:)
      .with_geographic_area_array(areas)
    soft_validations.add(:geographic_area_id, "Taxon is reported as missing in #{presence.first.asserted_distribution_shape.name}") unless presence.empty?
  end
end

#to_geo_json_featureHash

rubocop:disable Style/StringHashKeys TODO: DRY with helper methods

Returns:

  • (Hash)

    GeoJSON feature



157
158
159
160
161
162
163
164
# File 'app/models/asserted_distribution.rb', line 157

def to_geo_json_feature
  retval = {
    'type' => 'Feature',
    'geometry' => RGeo::GeoJSON.encode(geo_object),
    'properties' => {'asserted_distribution' => {'id' => self.id}}
  }
  retval
end

#unify_is_absentObject (protected)

Never record “false” in the datase, only true



338
339
340
# File 'app/models/asserted_distribution.rb', line 338

def unify_is_absent
  self.is_absent = nil if self.is_absent != true
end