Class: FieldOccurrence

Overview

A FieldOccurrence is

* A TaxonDetermination
* Done in the field (As defined by the CollectingEvent)

It is differentiated from a CollectionObject by:

* No physical individual is brought back to a physical collection.
* It requires a TaxonDetermination be present
* It is not Containable (or Loanable, etc.)

Direct Known Subclasses

BiologicalFieldOccurrence

Defined Under Namespace

Modules: DwcExtensions Classes: BiologicalFieldOccurrence

Constant Summary collapse

GRAPH_ENTRY_POINTS =
[:biological_associations, :taxon_determinations, :biocuration_classifications, :collecting_event, :origin_relationships]

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 DwcExtensions

#dwc_associated_media, #dwc_associated_taxa, #dwc_caste, #dwc_class, #dwc_date_identified, #dwc_family, #dwc_genus, #dwc_higher_classification, #dwc_identification_remarks, #dwc_individual_count, #dwc_infraspecific_epithet, #dwc_institution_code, #dwc_internal_attribute_for, #dwc_kingdom, #dwc_life_stage, #dwc_nomenclatural_code, #dwc_occurrence_remarks, #dwc_occurrence_status, #dwc_order, #dwc_other_catalog_numbers, #dwc_phylum, #dwc_previous_identifications, #dwc_scientific_name, #dwc_sex, #dwc_specific_epithet, #dwc_subfamily, #dwc_subtribe, #dwc_superfamily, #dwc_taxon_name_authorship, #dwc_taxon_rank, #dwc_tribe, #dwc_type_status, #dwc_verbatim_label, #is_fossil?

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::Taxonomy

#taxonomy_for_object

Methods included from Shared::BiologicalExtensions

#descendant_anatomical_part_ids, #missing_determination, #name_at_rank_string, #propagate_current_otu_change!, #reject_otus, #reject_taxon_determinations

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 Shared::QueryBatchUpdate

#query_update

Methods included from Shared::IsData

#errors_excepting, #full_error_messages_excepting, #identical, #is_community?, #is_in_use?, #similar

Methods included from Shared::Tags

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

Methods included from Shared::ProtocolRelationships

#machine_output?, #protocolled?, #reject_protocols

Methods included from Shared::OriginRelationship

#new_objects, #old_objects, #reject_origin_relationships, #set_origin

Methods included from Shared::Notes

#concatenated_notes_string, #reject_notes

Methods included from Shared::Identifiers

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

Methods included from Shared::HasPapertrail

#attribute_updated, #attribute_updater, #detect_version

Methods included from Shared::Conveyances

#has_conveyances?, #reject_conveyances, #reject_sounds, #sound_array=

Methods included from Shared::Depictions

#has_depictions?, #image_array=, #reject_depictions, #reject_images

Methods included from Shared::DataAttributes

#import_attributes, #internal_attributes, #keyword_value_hash, #reject_data_attributes

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, #requires_citation?, #sources_by_topic_id

Methods included from Housekeeping

#has_polymorphic_relationship?

Methods inherited from ApplicationRecord

transaction_with_retry

Instance Attribute Details

#is_absentBoolean

Returns a positive negative, when true then there exists an assertion that the taxon was not observed given the effort of the CollectingEvent. Inessence a confirmation/checksum of total=0 assertions. Total must be zero here.

Returns:

  • (Boolean)

    a positive negative, when true then there exists an assertion that the taxon was not observed given the effort of the CollectingEvent. Inessence a confirmation/checksum of total=0 assertions. Total must be zero here.



19
20
21
22
23
24
25
26
27
28
29
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
# File 'app/models/field_occurrence.rb', line 19

class FieldOccurrence < ApplicationRecord
  include GlobalID::Identification
  include Housekeeping

  include Shared::Citations
  include Shared::Confidences
  include Shared::DataAttributes
  include Shared::Depictions
  include Shared::Conveyances
  include Shared::HasPapertrail
  include Shared::Identifiers
  include Shared::Notes
  include Shared::Observations
  include Shared::OriginRelationship
  include Shared::ProtocolRelationships
  include Shared::Tags
  include Shared::IsData
  include Shared::QueryBatchUpdate
  include SoftValidation

  # At present must be before BiologicalExtensions
  include Shared::TaxonDeterminationRequired
  include Shared::BiologicalExtensions
  include Shared::BiologicalAssociationIndexHooks

  include Shared::Taxonomy
  include FieldOccurrence::DwcExtensions

  is_origin_for 'Specimen', 'Lot', 'Extract', 'AssertedDistribution', 'Sequence', 'Sound', 'AnatomicalPart'
  originates_from 'FieldOccurrence'

  GRAPH_ENTRY_POINTS = [:biological_associations, :taxon_determinations, :biocuration_classifications, :collecting_event, :origin_relationships]

  belongs_to :collecting_event, inverse_of: :field_occurrences
  belongs_to :ranged_lot_category, inverse_of: :ranged_lots

  has_many :georeferences, through: :collecting_event
  has_many :geographic_items, through: :georeferences

  # Hmmm- semantics here?
  # Should be observers?
  has_many :collectors, through: :collecting_event

  validates_presence_of :collecting_event

  validate :records_include_taxon_determination
  validate :check_that_either_total_or_ranged_lot_category_id_is_present
  validate :check_that_both_of_category_and_total_are_not_present
  validate :total_zero_when_absent
  validate :total_positive_when_present

  accepts_nested_attributes_for :collecting_event, allow_destroy: true, reject_if: :reject_collecting_event

  def requires_taxon_determination?
    true
  end

  # @return [ActiveRecord::Relation]
  #   BiologicalAssociationIndex records where this FieldOccurrence is subject or object
  def biological_association_indices
    BiologicalAssociationIndex.where('subject_id = ? AND subject_type = ?', id, self.class.base_class.name)
      .or(BiologicalAssociationIndex.where('object_id = ? AND object_type = ?', id, self.class.base_class.name))
  end

  # Convert a CollectionObject into a FieldOccurrence
  #
  # @param collection_object_id [Integer] the ID of the CollectionObject to transmute
  # @return [Integer, String] returns the new FieldOccurrence ID on success, or an error message on failure
  def self.transmute_collection_object(collection_object_id)
    co = CollectionObject.find_by(id: collection_object_id)

    return 'Collection object not found' if co.nil?

    return 'Collection object has loan items. Please remove or return loans before converting.' if co.loan_items.any?
    return 'Collection object has a repository assignment. Please remove repository before converting.' if co.repository_id.present?
    return 'Collection object has a current repository assignment. Please remove current repository before converting.' if co.current_repository_id.present?
    return 'Collection object has a preparation type. Please remove preparation type before converting.' if co.preparation_type_id.present?
    return 'Collection object has type materials. Please remove type materials before converting.' if co.type_materials.any?
    return 'Collection object has sqed depictions. Please remove sqed depictions before converting.' if co.sqed_depictions.any?
    return 'Collection object has extracts. Please remove extracts before converting.' if co.extracts.any?
    return 'Collection object has sequences. Please remove sequences before converting.' if co.sequences.any?

    fo = FieldOccurrence.new(
      total: co.total || 0,  # DB requires NOT NULL, use 0 for ranged lots
      collecting_event_id: co.collecting_event_id,
      ranged_lot_category_id: co.ranged_lot_category_id,
      project_id: co.project_id
    )

    begin
      FieldOccurrence.transaction do
        co.taxon_determinations.reload.each do |td|
          fo.taxon_determinations << td
        end
        co.reload

        fo.save!

        # Move all shared associations (notes, tags, identifiers, etc.)
        Utilities::Rails::Transmute.move_associations(co, fo)

        co.reload.destroy!
      end
    rescue TaxonWorks::Error => e
      return e.message
    rescue ActiveRecord::RecordInvalid => e
      return e.message
    rescue ActiveRecord::RecordNotDestroyed => e
      return co.errors.full_messages.join(', ')
    end

    fo.id
  end

  private

  def total_zero_when_absent
    errors.add(:total, 'Must be zero when absent.') if (total != 0) && is_absent
  end

  def total_positive_when_present
    # Allow total: 0 when ranged_lot_category is set (required by NOT NULL constraint)
    return if ranged_lot_category_id.present? && total == 0

    errors.add(:total, 'Must be positive when not absent.') if !is_absent && total.present? && total <= 0
  end

  def check_that_both_of_category_and_total_are_not_present
    # Allow total: 0 when ranged_lot_category is set (required by NOT NULL constraint)
    return if ranged_lot_category_id.present? && total == 0

    errors.add(:ranged_lot_category_id, 'Both ranged_lot_category and total can not be set') if ranged_lot_category_id.present? && total.present?
  end

  def check_that_either_total_or_ranged_lot_category_id_is_present
    errors.add(:base, 'Either total or a ranged lot category must be provided') if ranged_lot_category_id.blank? && total.blank?
  end

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

    # Watch out, taxon_determination and taxon_determination *do not*
    # necessarily share the same marked_for_destruction? info, even when
    # taxon_determination is a member of taxon_determinations.

    taxon_determination_is_marked_for_destruction_on_taxon_determinations =
      taxon_determination.present? && taxon_determinations.present? &&
      taxon_determinations.count == 1 && !taxon_determinations.first.id.nil? &&
      taxon_determination.id == taxon_determinations.first.id &&
      taxon_determinations.first.marked_for_destruction?

    the_one_taxon_determinations_is_marked_for_destruction_on_taxon_determination =
      taxon_determination.present? && taxon_determinations.present? &&
      taxon_determinations.count == 1 && !taxon_determinations.first.id.nil? &&
      taxon_determination.id == taxon_determinations.first.id &&
      taxon_determination.marked_for_destruction?

    has_valid_otu =
      otu.present? &&
      !otu.marked_for_destruction? && (
        !taxon_determination.present? ||
        (!taxon_determination.marked_for_destruction? &&
        !taxon_determination_is_marked_for_destruction_on_taxon_determinations)
      )

    has_valid_taxon_determination =
      taxon_determination.present? &&
      !taxon_determination.marked_for_destruction? &&
      !taxon_determination_is_marked_for_destruction_on_taxon_determinations

    has_valid_taxon_determinations =
     taxon_determinations.count(&:marked_for_destruction?) < taxon_determinations.size &&
     !the_one_taxon_determinations_is_marked_for_destruction_on_taxon_determination

    if !has_valid_otu && !has_valid_taxon_determination && !has_valid_taxon_determinations
      errors.add(:base, 'required taxon determination is not provided')
      return
    end

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

    if otu.present? && taxon_determination.nil?
      association(:taxon_determination).reset
    end
  end

  # Duplicated with CollectionObject
  def reject_collecting_event(attributed)
    reject = true
    CollectingEvent.core_attributes.each do |a|
      if attributed[a].present?
        reject = false
        break
      end
    end
    # !! does not account for georeferences_attributes!
    reject
  end

  # @param used_on [String]
  # @return [Scope]
  #    the max 10 most recently used collection_objects, as `used_on`
  def self.used_recently(user_id, project_id, used_on = '', ba_target = 'object')
    return [] if used_on != 'TaxonDetermination' && used_on != 'BiologicalAssociation'
    t = case used_on
        when 'TaxonDetermination'
          TaxonDetermination.arel_table
        when 'BiologicalAssociation'
          BiologicalAssociation.arel_table
        end
    if ba_target == 'subject'
      target_type = 'biological_association_subject_type'
      target_id = 'biological_association_subject_id'
    else
      target_type = 'biological_association_object_type'
      target_id = 'biological_association_object_id'
    end

    p = FieldOccurrence.arel_table

    # i is a select manager
    i = case used_on
        when 'BiologicalAssociation'
          t.project(t[target_id], t['updated_at']).from(t)
           .where(t[target_type].eq('FieldOccurrence'))
           .where(t['updated_at'].gt(1.week.ago))
           .where(t['updated_by_id'].eq(user_id))
           .where(t['project_id'].eq(project_id))
           .order(t['updated_at'].desc)
        else
          # TODO: update to reference new TaxonDetermination
          t.project(t['taxon_determination_object_id'], t['updated_at']).from(t)
           .where(t['taxon_determination_object_type'].eq('FieldOccurrence'))
           .where(t['updated_at'].gt( 1.week.ago ))
           .where(t['updated_by_id'].eq(user_id))
           .where(t['project_id'].eq(project_id))
           .order(t['updated_at'].desc)
        end

    # z is a table alias
    z = i.as('recent_t')

    j = case used_on
        when 'BiologicalAssociation'
          Arel::Nodes::InnerJoin.new(z, Arel::Nodes::On.new(
            z[target_id].eq(p['id'])
          ))
        else
          Arel::Nodes::InnerJoin.new(z, Arel::Nodes::On.new(
            z['taxon_determination_object_id'].eq(p['id'])))
        end

    FieldOccurrence.joins(j).pluck(:id).uniq
  end

  # @params target [String] currently only 'TaxonDetermination' is accepted
  # @return [Hash] field_occurrences optimized for user selection
  def self.select_optimized(user_id, project_id, target = nil, ba_target = 'object')
    h = {
      quick: [],
      pinboard: FieldOccurrence.pinned_by(user_id).where(project_id:).to_a,
      recent: []
    }

    if target && !(r = used_recently(user_id, project_id, target, ba_target)).empty?
      h[:recent] = FieldOccurrence.where(id: r.first(10)).to_a
      h[:quick] = (
        FieldOccurrence
          .pinned_by(user_id)
          .pinboard_inserted
          .where(project_id:).to_a  +
        FieldOccurrence.where(id: r.first(4)).to_a
      ).uniq
    else
      h[:recent] = FieldOccurrence
        .where(project_id:, updated_by_id: user_id)
        .order('updated_at DESC')
        .limit(10).to_a
      h[:quick] = FieldOccurrence
        .pinned_by(user_id)
        .pinboard_inserted
        .where(project_id:).to_a
    end

    h
  end

end

#totalInteger

Returns The enumerated number of things observed in the field, as asserted by *the collector* of a CollectingEvent. Must be zero if is_absent = true.

Returns:

  • (Integer)

    The enumerated number of things observed in the field, as asserted by *the collector* of a CollectingEvent. Must be zero if is_absent = true.



19
20
21
22
23
24
25
26
27
28
29
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
# File 'app/models/field_occurrence.rb', line 19

class FieldOccurrence < ApplicationRecord
  include GlobalID::Identification
  include Housekeeping

  include Shared::Citations
  include Shared::Confidences
  include Shared::DataAttributes
  include Shared::Depictions
  include Shared::Conveyances
  include Shared::HasPapertrail
  include Shared::Identifiers
  include Shared::Notes
  include Shared::Observations
  include Shared::OriginRelationship
  include Shared::ProtocolRelationships
  include Shared::Tags
  include Shared::IsData
  include Shared::QueryBatchUpdate
  include SoftValidation

  # At present must be before BiologicalExtensions
  include Shared::TaxonDeterminationRequired
  include Shared::BiologicalExtensions
  include Shared::BiologicalAssociationIndexHooks

  include Shared::Taxonomy
  include FieldOccurrence::DwcExtensions

  is_origin_for 'Specimen', 'Lot', 'Extract', 'AssertedDistribution', 'Sequence', 'Sound', 'AnatomicalPart'
  originates_from 'FieldOccurrence'

  GRAPH_ENTRY_POINTS = [:biological_associations, :taxon_determinations, :biocuration_classifications, :collecting_event, :origin_relationships]

  belongs_to :collecting_event, inverse_of: :field_occurrences
  belongs_to :ranged_lot_category, inverse_of: :ranged_lots

  has_many :georeferences, through: :collecting_event
  has_many :geographic_items, through: :georeferences

  # Hmmm- semantics here?
  # Should be observers?
  has_many :collectors, through: :collecting_event

  validates_presence_of :collecting_event

  validate :records_include_taxon_determination
  validate :check_that_either_total_or_ranged_lot_category_id_is_present
  validate :check_that_both_of_category_and_total_are_not_present
  validate :total_zero_when_absent
  validate :total_positive_when_present

  accepts_nested_attributes_for :collecting_event, allow_destroy: true, reject_if: :reject_collecting_event

  def requires_taxon_determination?
    true
  end

  # @return [ActiveRecord::Relation]
  #   BiologicalAssociationIndex records where this FieldOccurrence is subject or object
  def biological_association_indices
    BiologicalAssociationIndex.where('subject_id = ? AND subject_type = ?', id, self.class.base_class.name)
      .or(BiologicalAssociationIndex.where('object_id = ? AND object_type = ?', id, self.class.base_class.name))
  end

  # Convert a CollectionObject into a FieldOccurrence
  #
  # @param collection_object_id [Integer] the ID of the CollectionObject to transmute
  # @return [Integer, String] returns the new FieldOccurrence ID on success, or an error message on failure
  def self.transmute_collection_object(collection_object_id)
    co = CollectionObject.find_by(id: collection_object_id)

    return 'Collection object not found' if co.nil?

    return 'Collection object has loan items. Please remove or return loans before converting.' if co.loan_items.any?
    return 'Collection object has a repository assignment. Please remove repository before converting.' if co.repository_id.present?
    return 'Collection object has a current repository assignment. Please remove current repository before converting.' if co.current_repository_id.present?
    return 'Collection object has a preparation type. Please remove preparation type before converting.' if co.preparation_type_id.present?
    return 'Collection object has type materials. Please remove type materials before converting.' if co.type_materials.any?
    return 'Collection object has sqed depictions. Please remove sqed depictions before converting.' if co.sqed_depictions.any?
    return 'Collection object has extracts. Please remove extracts before converting.' if co.extracts.any?
    return 'Collection object has sequences. Please remove sequences before converting.' if co.sequences.any?

    fo = FieldOccurrence.new(
      total: co.total || 0,  # DB requires NOT NULL, use 0 for ranged lots
      collecting_event_id: co.collecting_event_id,
      ranged_lot_category_id: co.ranged_lot_category_id,
      project_id: co.project_id
    )

    begin
      FieldOccurrence.transaction do
        co.taxon_determinations.reload.each do |td|
          fo.taxon_determinations << td
        end
        co.reload

        fo.save!

        # Move all shared associations (notes, tags, identifiers, etc.)
        Utilities::Rails::Transmute.move_associations(co, fo)

        co.reload.destroy!
      end
    rescue TaxonWorks::Error => e
      return e.message
    rescue ActiveRecord::RecordInvalid => e
      return e.message
    rescue ActiveRecord::RecordNotDestroyed => e
      return co.errors.full_messages.join(', ')
    end

    fo.id
  end

  private

  def total_zero_when_absent
    errors.add(:total, 'Must be zero when absent.') if (total != 0) && is_absent
  end

  def total_positive_when_present
    # Allow total: 0 when ranged_lot_category is set (required by NOT NULL constraint)
    return if ranged_lot_category_id.present? && total == 0

    errors.add(:total, 'Must be positive when not absent.') if !is_absent && total.present? && total <= 0
  end

  def check_that_both_of_category_and_total_are_not_present
    # Allow total: 0 when ranged_lot_category is set (required by NOT NULL constraint)
    return if ranged_lot_category_id.present? && total == 0

    errors.add(:ranged_lot_category_id, 'Both ranged_lot_category and total can not be set') if ranged_lot_category_id.present? && total.present?
  end

  def check_that_either_total_or_ranged_lot_category_id_is_present
    errors.add(:base, 'Either total or a ranged lot category must be provided') if ranged_lot_category_id.blank? && total.blank?
  end

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

    # Watch out, taxon_determination and taxon_determination *do not*
    # necessarily share the same marked_for_destruction? info, even when
    # taxon_determination is a member of taxon_determinations.

    taxon_determination_is_marked_for_destruction_on_taxon_determinations =
      taxon_determination.present? && taxon_determinations.present? &&
      taxon_determinations.count == 1 && !taxon_determinations.first.id.nil? &&
      taxon_determination.id == taxon_determinations.first.id &&
      taxon_determinations.first.marked_for_destruction?

    the_one_taxon_determinations_is_marked_for_destruction_on_taxon_determination =
      taxon_determination.present? && taxon_determinations.present? &&
      taxon_determinations.count == 1 && !taxon_determinations.first.id.nil? &&
      taxon_determination.id == taxon_determinations.first.id &&
      taxon_determination.marked_for_destruction?

    has_valid_otu =
      otu.present? &&
      !otu.marked_for_destruction? && (
        !taxon_determination.present? ||
        (!taxon_determination.marked_for_destruction? &&
        !taxon_determination_is_marked_for_destruction_on_taxon_determinations)
      )

    has_valid_taxon_determination =
      taxon_determination.present? &&
      !taxon_determination.marked_for_destruction? &&
      !taxon_determination_is_marked_for_destruction_on_taxon_determinations

    has_valid_taxon_determinations =
     taxon_determinations.count(&:marked_for_destruction?) < taxon_determinations.size &&
     !the_one_taxon_determinations_is_marked_for_destruction_on_taxon_determination

    if !has_valid_otu && !has_valid_taxon_determination && !has_valid_taxon_determinations
      errors.add(:base, 'required taxon determination is not provided')
      return
    end

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

    if otu.present? && taxon_determination.nil?
      association(:taxon_determination).reset
    end
  end

  # Duplicated with CollectionObject
  def reject_collecting_event(attributed)
    reject = true
    CollectingEvent.core_attributes.each do |a|
      if attributed[a].present?
        reject = false
        break
      end
    end
    # !! does not account for georeferences_attributes!
    reject
  end

  # @param used_on [String]
  # @return [Scope]
  #    the max 10 most recently used collection_objects, as `used_on`
  def self.used_recently(user_id, project_id, used_on = '', ba_target = 'object')
    return [] if used_on != 'TaxonDetermination' && used_on != 'BiologicalAssociation'
    t = case used_on
        when 'TaxonDetermination'
          TaxonDetermination.arel_table
        when 'BiologicalAssociation'
          BiologicalAssociation.arel_table
        end
    if ba_target == 'subject'
      target_type = 'biological_association_subject_type'
      target_id = 'biological_association_subject_id'
    else
      target_type = 'biological_association_object_type'
      target_id = 'biological_association_object_id'
    end

    p = FieldOccurrence.arel_table

    # i is a select manager
    i = case used_on
        when 'BiologicalAssociation'
          t.project(t[target_id], t['updated_at']).from(t)
           .where(t[target_type].eq('FieldOccurrence'))
           .where(t['updated_at'].gt(1.week.ago))
           .where(t['updated_by_id'].eq(user_id))
           .where(t['project_id'].eq(project_id))
           .order(t['updated_at'].desc)
        else
          # TODO: update to reference new TaxonDetermination
          t.project(t['taxon_determination_object_id'], t['updated_at']).from(t)
           .where(t['taxon_determination_object_type'].eq('FieldOccurrence'))
           .where(t['updated_at'].gt( 1.week.ago ))
           .where(t['updated_by_id'].eq(user_id))
           .where(t['project_id'].eq(project_id))
           .order(t['updated_at'].desc)
        end

    # z is a table alias
    z = i.as('recent_t')

    j = case used_on
        when 'BiologicalAssociation'
          Arel::Nodes::InnerJoin.new(z, Arel::Nodes::On.new(
            z[target_id].eq(p['id'])
          ))
        else
          Arel::Nodes::InnerJoin.new(z, Arel::Nodes::On.new(
            z['taxon_determination_object_id'].eq(p['id'])))
        end

    FieldOccurrence.joins(j).pluck(:id).uniq
  end

  # @params target [String] currently only 'TaxonDetermination' is accepted
  # @return [Hash] field_occurrences optimized for user selection
  def self.select_optimized(user_id, project_id, target = nil, ba_target = 'object')
    h = {
      quick: [],
      pinboard: FieldOccurrence.pinned_by(user_id).where(project_id:).to_a,
      recent: []
    }

    if target && !(r = used_recently(user_id, project_id, target, ba_target)).empty?
      h[:recent] = FieldOccurrence.where(id: r.first(10)).to_a
      h[:quick] = (
        FieldOccurrence
          .pinned_by(user_id)
          .pinboard_inserted
          .where(project_id:).to_a  +
        FieldOccurrence.where(id: r.first(4)).to_a
      ).uniq
    else
      h[:recent] = FieldOccurrence
        .where(project_id:, updated_by_id: user_id)
        .order('updated_at DESC')
        .limit(10).to_a
      h[:quick] = FieldOccurrence
        .pinned_by(user_id)
        .pinboard_inserted
        .where(project_id:).to_a
    end

    h
  end

end

Class Method Details

.select_optimized(user_id, project_id, target = nil, ba_target = 'object') ⇒ Hash (private)

Returns field_occurrences optimized for user selection.

Returns:

  • (Hash)

    field_occurrences optimized for user selection



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
# File 'app/models/field_occurrence.rb', line 285

def self.select_optimized(user_id, project_id, target = nil, ba_target = 'object')
  h = {
    quick: [],
    pinboard: FieldOccurrence.pinned_by(user_id).where(project_id:).to_a,
    recent: []
  }

  if target && !(r = used_recently(user_id, project_id, target, ba_target)).empty?
    h[:recent] = FieldOccurrence.where(id: r.first(10)).to_a
    h[:quick] = (
      FieldOccurrence
        .pinned_by(user_id)
        .pinboard_inserted
        .where(project_id:).to_a  +
      FieldOccurrence.where(id: r.first(4)).to_a
    ).uniq
  else
    h[:recent] = FieldOccurrence
      .where(project_id:, updated_by_id: user_id)
      .order('updated_at DESC')
      .limit(10).to_a
    h[:quick] = FieldOccurrence
      .pinned_by(user_id)
      .pinboard_inserted
      .where(project_id:).to_a
  end

  h
end

.transmute_collection_object(collection_object_id) ⇒ Integer, String

Convert a CollectionObject into a FieldOccurrence

Parameters:

  • collection_object_id (Integer)

    the ID of the CollectionObject to transmute

Returns:

  • (Integer, String)

    returns the new FieldOccurrence ID on success, or an error message on failure



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
# File 'app/models/field_occurrence.rb', line 87

def self.transmute_collection_object(collection_object_id)
  co = CollectionObject.find_by(id: collection_object_id)

  return 'Collection object not found' if co.nil?

  return 'Collection object has loan items. Please remove or return loans before converting.' if co.loan_items.any?
  return 'Collection object has a repository assignment. Please remove repository before converting.' if co.repository_id.present?
  return 'Collection object has a current repository assignment. Please remove current repository before converting.' if co.current_repository_id.present?
  return 'Collection object has a preparation type. Please remove preparation type before converting.' if co.preparation_type_id.present?
  return 'Collection object has type materials. Please remove type materials before converting.' if co.type_materials.any?
  return 'Collection object has sqed depictions. Please remove sqed depictions before converting.' if co.sqed_depictions.any?
  return 'Collection object has extracts. Please remove extracts before converting.' if co.extracts.any?
  return 'Collection object has sequences. Please remove sequences before converting.' if co.sequences.any?

  fo = FieldOccurrence.new(
    total: co.total || 0,  # DB requires NOT NULL, use 0 for ranged lots
    collecting_event_id: co.collecting_event_id,
    ranged_lot_category_id: co.ranged_lot_category_id,
    project_id: co.project_id
  )

  begin
    FieldOccurrence.transaction do
      co.taxon_determinations.reload.each do |td|
        fo.taxon_determinations << td
      end
      co.reload

      fo.save!

      # Move all shared associations (notes, tags, identifiers, etc.)
      Utilities::Rails::Transmute.move_associations(co, fo)

      co.reload.destroy!
    end
  rescue TaxonWorks::Error => e
    return e.message
  rescue ActiveRecord::RecordInvalid => e
    return e.message
  rescue ActiveRecord::RecordNotDestroyed => e
    return co.errors.full_messages.join(', ')
  end

  fo.id
end

.used_recently(user_id, project_id, used_on = '', ba_target = 'object') ⇒ Scope (private)

Returns the max 10 most recently used collection_objects, as ‘used_on`.

Parameters:

  • used_on (String) (defaults to: '')

Returns:

  • (Scope)

    the max 10 most recently used collection_objects, as ‘used_on`



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
# File 'app/models/field_occurrence.rb', line 230

def self.used_recently(user_id, project_id, used_on = '', ba_target = 'object')
  return [] if used_on != 'TaxonDetermination' && used_on != 'BiologicalAssociation'
  t = case used_on
      when 'TaxonDetermination'
        TaxonDetermination.arel_table
      when 'BiologicalAssociation'
        BiologicalAssociation.arel_table
      end
  if ba_target == 'subject'
    target_type = 'biological_association_subject_type'
    target_id = 'biological_association_subject_id'
  else
    target_type = 'biological_association_object_type'
    target_id = 'biological_association_object_id'
  end

  p = FieldOccurrence.arel_table

  # i is a select manager
  i = case used_on
      when 'BiologicalAssociation'
        t.project(t[target_id], t['updated_at']).from(t)
         .where(t[target_type].eq('FieldOccurrence'))
         .where(t['updated_at'].gt(1.week.ago))
         .where(t['updated_by_id'].eq(user_id))
         .where(t['project_id'].eq(project_id))
         .order(t['updated_at'].desc)
      else
        # TODO: update to reference new TaxonDetermination
        t.project(t['taxon_determination_object_id'], t['updated_at']).from(t)
         .where(t['taxon_determination_object_type'].eq('FieldOccurrence'))
         .where(t['updated_at'].gt( 1.week.ago ))
         .where(t['updated_by_id'].eq(user_id))
         .where(t['project_id'].eq(project_id))
         .order(t['updated_at'].desc)
      end

  # z is a table alias
  z = i.as('recent_t')

  j = case used_on
      when 'BiologicalAssociation'
        Arel::Nodes::InnerJoin.new(z, Arel::Nodes::On.new(
          z[target_id].eq(p['id'])
        ))
      else
        Arel::Nodes::InnerJoin.new(z, Arel::Nodes::On.new(
          z['taxon_determination_object_id'].eq(p['id'])))
      end

  FieldOccurrence.joins(j).pluck(:id).uniq
end

Instance Method Details

#biological_association_indicesActiveRecord::Relation

Returns BiologicalAssociationIndex records where this FieldOccurrence is subject or object.

Returns:

  • (ActiveRecord::Relation)

    BiologicalAssociationIndex records where this FieldOccurrence is subject or object



78
79
80
81
# File 'app/models/field_occurrence.rb', line 78

def biological_association_indices
  BiologicalAssociationIndex.where('subject_id = ? AND subject_type = ?', id, self.class.base_class.name)
    .or(BiologicalAssociationIndex.where('object_id = ? AND object_type = ?', id, self.class.base_class.name))
end

#check_that_both_of_category_and_total_are_not_presentObject (private)



146
147
148
149
150
151
# File 'app/models/field_occurrence.rb', line 146

def check_that_both_of_category_and_total_are_not_present
  # Allow total: 0 when ranged_lot_category is set (required by NOT NULL constraint)
  return if ranged_lot_category_id.present? && total == 0

  errors.add(:ranged_lot_category_id, 'Both ranged_lot_category and total can not be set') if ranged_lot_category_id.present? && total.present?
end

#check_that_either_total_or_ranged_lot_category_id_is_presentObject (private)



153
154
155
# File 'app/models/field_occurrence.rb', line 153

def check_that_either_total_or_ranged_lot_category_id_is_present
  errors.add(:base, 'Either total or a ranged lot category must be provided') if ranged_lot_category_id.blank? && total.blank?
end

#records_include_taxon_determinationObject (private)



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
# File 'app/models/field_occurrence.rb', line 157

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

  # Watch out, taxon_determination and taxon_determination *do not*
  # necessarily share the same marked_for_destruction? info, even when
  # taxon_determination is a member of taxon_determinations.

  taxon_determination_is_marked_for_destruction_on_taxon_determinations =
    taxon_determination.present? && taxon_determinations.present? &&
    taxon_determinations.count == 1 && !taxon_determinations.first.id.nil? &&
    taxon_determination.id == taxon_determinations.first.id &&
    taxon_determinations.first.marked_for_destruction?

  the_one_taxon_determinations_is_marked_for_destruction_on_taxon_determination =
    taxon_determination.present? && taxon_determinations.present? &&
    taxon_determinations.count == 1 && !taxon_determinations.first.id.nil? &&
    taxon_determination.id == taxon_determinations.first.id &&
    taxon_determination.marked_for_destruction?

  has_valid_otu =
    otu.present? &&
    !otu.marked_for_destruction? && (
      !taxon_determination.present? ||
      (!taxon_determination.marked_for_destruction? &&
      !taxon_determination_is_marked_for_destruction_on_taxon_determinations)
    )

  has_valid_taxon_determination =
    taxon_determination.present? &&
    !taxon_determination.marked_for_destruction? &&
    !taxon_determination_is_marked_for_destruction_on_taxon_determinations

  has_valid_taxon_determinations =
   taxon_determinations.count(&:marked_for_destruction?) < taxon_determinations.size &&
   !the_one_taxon_determinations_is_marked_for_destruction_on_taxon_determination

  if !has_valid_otu && !has_valid_taxon_determination && !has_valid_taxon_determinations
    errors.add(:base, 'required taxon determination is not provided')
    return
  end

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

  if otu.present? && taxon_determination.nil?
    association(:taxon_determination).reset
  end
end

#reject_collecting_event(attributed) ⇒ Object (private)

Duplicated with CollectionObject



215
216
217
218
219
220
221
222
223
224
225
# File 'app/models/field_occurrence.rb', line 215

def reject_collecting_event(attributed)
  reject = true
  CollectingEvent.core_attributes.each do |a|
    if attributed[a].present?
      reject = false
      break
    end
  end
  # !! does not account for georeferences_attributes!
  reject
end

#requires_taxon_determination?Boolean

Returns:

  • (Boolean)


72
73
74
# File 'app/models/field_occurrence.rb', line 72

def requires_taxon_determination?
  true
end

#total_positive_when_presentObject (private)



139
140
141
142
143
144
# File 'app/models/field_occurrence.rb', line 139

def total_positive_when_present
  # Allow total: 0 when ranged_lot_category is set (required by NOT NULL constraint)
  return if ranged_lot_category_id.present? && total == 0

  errors.add(:total, 'Must be positive when not absent.') if !is_absent && total.present? && total <= 0
end

#total_zero_when_absentObject (private)



135
136
137
# File 'app/models/field_occurrence.rb', line 135

def total_zero_when_absent
  errors.add(:total, 'Must be zero when absent.') if (total != 0) && is_absent
end