/
observation.rb
3464 lines (3147 loc) · 122 KB
/
observation.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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
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
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#encoding: utf-8
class Observation < ApplicationRecord
include ActsAsElasticModel
include ObservationSearch
include ActsAsUUIDable
has_subscribers :to => {
:comments => {:notification => "activity", :include_owner => true},
:identifications => {:notification => "activity", :include_owner => true}
}
notifies_subscribers_of :user, :notification => "created_observations",
:queue_if => lambda { |observation| !observation.bulk_import }
earns_privilege UserPrivilege::SPEECH
earns_privilege UserPrivilege::ORGANIZER
earns_privilege UserPrivilege::COORDINATE_ACCESS
# Why aren't we using after_save? Because we need this to run before the
# after_create created by notifies_subscribers_of :public_places runs
after_create :update_observations_places
after_update :update_observations_places
notifies_subscribers_of :public_places,
notification: "new_observations",
on: :create,
queue_if: lambda {|observation|
observation.georeferenced? && !observation.bulk_import
},
if: lambda {|observation, place, subscription|
return false unless observation.georeferenced?
return true if subscription.taxon_id.blank?
return false if observation.taxon.blank?
return true if observation.taxon_id == subscription.taxon_id
observation.taxon.ancestor_ids.include?(subscription.taxon_id)
},
before_notify: lambda{|observation|
Observation.preload_associations( observation, [ :taxon, {
observations_places: {
place: :update_subscriptions_with_unsuspended_users
}
}] )
}
notifies_subscribers_of :taxon_and_ancestors,
notification: "new_observations",
queue_if: lambda {|observation| !observation.taxon_id.blank? && !observation.bulk_import},
if: lambda {|observation, taxon, subscription|
return true if observation.taxon_id == taxon.id
return false if observation.taxon.blank?
observation.taxon.ancestor_ids.include?(subscription.resource_id)
}
notifies_users :mentioned_users,
on: :save,
notification: "mention",
delay: true,
if: lambda {|u| u.prefers_receive_mentions? },
unless: lambda { |observation|
# description hasn't changed, so mentions haven't changed
return true unless observation.previous_changes[:description]
# description has changed, but neither version mentioned users
observation.previous_changes[:description].map do |d|
d ? d.mentioned_users.any? : false
end.none?
}
acts_as_taggable
acts_as_votable
acts_as_spammable fields: [ :description ],
comment_type: "item-description",
automated: false
include Ambidextrous
# Set to true if you want to skip the expensive updating of all the user's
# lists after saving. Useful if you're saving many observations at once and
# you want to update lists in a batch
attr_accessor :skip_refresh_check_lists, :skip_identifications,
:bulk_import, :skip_indexing, :editing_user_id, :skip_quality_metrics, :bulk_delete,
:taxon_introduced, :taxon_endemic, :taxon_native,
:skip_identification_indexing, :will_be_saved_with_photos, :skip_update_observations_places
# Set if you need to set the taxon from a name separate from the species
# guess
attr_accessor :taxon_name
# licensing extras
attr_accessor :make_license_default
attr_accessor :make_licenses_same
# coordinate system
attr_accessor :coordinate_system
attr_accessor :geo_x
attr_accessor :geo_y
attr_accessor :owners_identification_from_vision_requested
attr_accessor :localize_locale
attr_accessor :localize_place
# Track whether obscuration has changed over the life of this instance
attr_accessor :obscuration_changed
def captive_flag
@captive_flag ||= !quality_metrics.detect{|qm|
qm.user_id == user_id && qm.metric == QualityMetric::WILD && !qm.agree?
}.nil?
end
def captive_flag=(v)
@captive_flag = v
end
attr_accessor :force_quality_metrics
# custom project field errors
attr_accessor :custom_field_errors
MASS_ASSIGNABLE_ATTRIBUTES = [:make_license_default, :make_licenses_same]
M_TO_OBSCURE_THREATENED_TAXA = 10000
PLANETARY_RADIUS = 6370997.0
DEGREES_PER_RADIAN = 57.2958
FLOAT_REGEX = /[-+]?[0-9]*\.?[0-9]+/
COORDINATE_REGEX = /[^\d\,]*?(#{FLOAT_REGEX})[^\d\,]*?/
LAT_LON_SEPARATOR_REGEX = /[\,\s]\s*/
LAT_LON_REGEX = /#{COORDINATE_REGEX}#{LAT_LON_SEPARATOR_REGEX}#{COORDINATE_REGEX}/
COORDINATE_UNCERTAINTY_CELL_SIZE = 0.2
OPEN = "open"
PRIVATE = "private"
OBSCURED = "obscured"
GEOPRIVACIES = [OBSCURED, PRIVATE]
GEOPRIVACY_DESCRIPTIONS = {
OPEN => :open_description,
OBSCURED => :obscured_description,
PRIVATE => :private_description
}
RESEARCH_GRADE = "research"
CASUAL = "casual"
NEEDS_ID = "needs_id"
QUALITY_GRADES = [CASUAL, NEEDS_ID, RESEARCH_GRADE]
COMMUNITY_TAXON_SCORE_CUTOFF = (2.0 / 3)
LICENSES = [
["CC0", :cc_0_name, :cc_0_description],
["CC-BY", :cc_by_name, :cc_by_description],
["CC-BY-NC", :cc_by_nc_name, :cc_by_nc_description],
["CC-BY-SA", :cc_by_sa_name, :cc_by_sa_description],
["CC-BY-ND", :cc_by_nd_name, :cc_by_nd_description],
["CC-BY-NC-SA",:cc_by_nc_sa_name, :cc_by_nc_sa_description],
["CC-BY-NC-ND", :cc_by_nc_nd_name, :cc_by_nc_nd_description]
]
LICENSE_CODES = LICENSES.map{|row| row.first}
LICENSES.each do |code, name, description|
const_set code.gsub(/\-/, '_'), code
end
PREFERRED_LICENSES = [CC_BY, CC_BY_NC, CC0]
CSV_COLUMNS = [
"id",
"species_guess",
"scientific_name",
"common_name",
"iconic_taxon_name",
"taxon_id",
"num_identification_agreements",
"num_identification_disagreements",
"observed_on_string",
"observed_on",
"time_observed_at",
"time_zone",
"place_guess",
"latitude",
"longitude",
"positional_accuracy",
"private_place_guess",
"private_latitude",
"private_longitude",
"public_positional_accuracy",
"geoprivacy",
"taxon_geoprivacy",
"coordinates_obscured",
"positioning_method",
"positioning_device",
"user_id",
"user_login",
"user_name",
"created_at",
"updated_at",
"quality_grade",
"license",
"url",
"image_url",
"sound_url",
"tag_list",
"description",
"oauth_application_id",
"captive_cultivated"
]
BASIC_COLUMNS = [
"id",
"observed_on_string",
"observed_on",
"time_observed_at",
"time_zone",
"user_id",
"user_login",
"user_name",
"created_at",
"updated_at",
"quality_grade",
"license",
"url",
"image_url",
"sound_url",
"tag_list",
"description",
"num_identification_agreements",
"num_identification_disagreements",
"captive_cultivated",
"oauth_application_id"
]
GEO_COLUMNS = [
"place_guess",
"latitude",
"longitude",
"positional_accuracy",
"private_place_guess",
"private_latitude",
"private_longitude",
"public_positional_accuracy",
"geoprivacy",
"taxon_geoprivacy",
"coordinates_obscured",
"positioning_method",
"positioning_device",
"place_town_name",
"place_county_name",
"place_state_name",
"place_country_name",
"place_admin1_name",
"place_admin2_name"
]
TAXON_COLUMNS = [
"species_guess",
"scientific_name",
"common_name",
"iconic_taxon_name",
"taxon_id"
]
EXTRA_TAXON_COLUMNS = %w(
kingdom
phylum
subphylum
superclass
class
subclass
superorder
order
suborder
superfamily
family
subfamily
supertribe
tribe
subtribe
genus
genushybrid
species
hybrid
subspecies
variety
form
).map {| r | "taxon_#{r}_name" }.compact
ALL_EXPORT_COLUMNS = (
CSV_COLUMNS + BASIC_COLUMNS + GEO_COLUMNS + TAXON_COLUMNS + EXTRA_TAXON_COLUMNS
).uniq
WGS84_PROJ4 = "+proj=longlat +ellps=WGS84 +datum=WGS84 +no_defs"
ALLOWED_DESCRIPTION_TAGS = %w(
a
abbr
acronym
b
blockquote
br
cite
code
em
i
img
pre
s
small
strike
strong
sub
sup
)
preference :community_taxon, :boolean, :default => nil
belongs_to :user
belongs_to_with_uuid :taxon
belongs_to :community_taxon, :class_name => 'Taxon'
belongs_to :iconic_taxon, :class_name => 'Taxon',
:foreign_key => 'iconic_taxon_id'
belongs_to :oauth_application
belongs_to :site, :inverse_of => :observations
has_many :observation_photos, -> { order("id asc") }, :dependent => :destroy, :inverse_of => :observation
has_many :photos, :through => :observation_photos
# note last_observation and first_observation on listed taxa will get reset
# by CheckList.refresh_with_observation
has_many :listed_taxa, :foreign_key => 'last_observation_id'
has_many :first_listed_taxa, :class_name => "ListedTaxon", :foreign_key => 'first_observation_id'
has_many :first_check_listed_taxa, -> { where("listed_taxa.place_id IS NOT NULL") }, :class_name => "ListedTaxon", :foreign_key => 'first_observation_id'
has_many :comments, :as => :parent, :dependent => :destroy
has_many :annotations, as: :resource, dependent: :destroy
has_many :identifications, :dependent => :destroy
has_many :project_observations, :dependent => :destroy
has_many :project_observations_with_changes, -> {
joins(:model_attribute_changes) }, class_name: "ProjectObservation"
has_many :projects, :through => :project_observations
has_many :quality_metrics, :dependent => :destroy
has_many :observation_field_values, -> { order("id asc") }, :dependent => :destroy, :inverse_of => :observation
has_many :observation_fields, :through => :observation_field_values
has_many :observation_links
has_and_belongs_to_many :posts
has_many :observation_sounds, :dependent => :destroy, :inverse_of => :observation
has_many :sounds, :through => :observation_sounds
# not using dependent: :destroy for observations_places
# since that table has no ID column and Rails expect it
has_many :observations_places
has_many :observation_reviews, :dependent => :destroy
has_many :confirmed_reviews, -> { where("observation_reviews.reviewed = true") },
class_name: "ObservationReview"
FIELDS_TO_SEARCH_ON = %w(names tags description place)
accepts_nested_attributes_for :observation_field_values,
:allow_destroy => true,
:reject_if => lambda { |attrs| attrs[:value].blank? }
##
# Validations
#
validates_presence_of :user_id
validate :must_be_in_the_past,
:date_observed_must_be_before_date_created,
:must_not_be_a_range,
:must_not_be_on_null_island
validates :latitude, numericality: {
allow_blank: true,
less_than: 90,
greater_than: -90
}
validates :longitude, numericality: {
allow_blank: true,
less_than_or_equal_to: 180,
greater_than_or_equal_to: -180
}
validates_numericality_of :positional_accuracy,
allow_nil: true,
greater_than: 0,
if: Proc.new { |o| !o.positional_accuracy.nil? }
validates_length_of :observed_on_string, :maximum => 256, :allow_blank => true
validates_length_of :species_guess, :maximum => 256, :allow_blank => true
validates_length_of :place_guess, :maximum => 256, :allow_blank => true
validate do
# This should be a validation on cached_tag_list, but acts_as_taggable seems
# to set that after the validations run
if tag_list.join(", ").length > 750
errors.add( :tag_list, "must be under 750 characters total, no more than 256 characters per tag" )
end
end
validate do
unless coordinate_system.blank?
begin
RGeo::CoordSys::Proj4.new( coordinate_system )
rescue RGeo::Error::UnsupportedOperation
errors.add( :coordinate_system, "is not a valid Proj4 string" )
end
end
end
# See /config/locale/en.yml for field labels for `geo_x` and `geo_y`
validates_numericality_of :geo_x,
:allow_blank => true,
:message => "should be a number"
validates_numericality_of :geo_y,
:allow_blank => true,
:message => "should be a number"
validates_presence_of :geo_x, :if => proc {|o| o.geo_y.present? }
validates_presence_of :geo_y, :if => proc {|o| o.geo_x.present? }
validate do
if observed_on && ( new_record? || observed_on_changed? ) && observed_on < 130.years.ago
errors.add( :observed_on, :must_be_within_130_years )
end
end
before_validation :set_time_zone,
:munge_observed_on_with_chronic,
:set_time_in_time_zone,
:set_coordinates,
:nilify_positional_accuracy_if_zero
before_create :replace_inactive_taxon
before_save :strip_species_guess,
:set_taxon_from_species_guess,
:set_taxon_from_taxon_name,
:keep_old_taxon_id,
:set_latlon_from_place_guess,
:normalize_geoprivacy,
:set_license,
:trim_user_agent,
:update_identifications,
:set_taxon_geoprivacy,
:set_community_taxon_before_save,
:set_taxon_from_probable_taxon,
:reassess_coordinate_obscuration,
:set_geom_from_latlon,
:set_place_guess_from_latlon,
:obscure_place_guess,
:set_iconic_taxon
before_update :set_quality_grade
after_save :refresh_check_lists
after_save :update_default_license
after_save :update_all_licenses
after_save :update_taxon_counter_caches
after_save :update_quality_metrics
after_save :update_public_positional_accuracy
after_save :update_mappable
after_save :set_captive
after_save :set_taxon_photo
after_save :create_observation_review
after_save :reassess_annotations
after_create :set_uri
after_commit :update_user_counter_caches_after_create, on: :create
after_commit :update_user_counter_caches_after_destroy, on: :destroy
after_commit :update_user_counter_caches_after_update, on: :update
before_destroy :keep_old_taxon_id
after_destroy :refresh_check_lists,
:update_taxon_counter_caches, :create_deleted_observation,
:delete_observations_places
after_commit :reindex_identifications, :reindex_places, :reindex_projects
##
# Named scopes
#
# Area scopes
# scope :in_bounding_box, lambda { |swlat, swlng, nelat, nelng|
scope :in_bounding_box, lambda {|*args|
swlat, swlng, nelat, nelng, options = args
options ||= {}
if options[:private]
geom_col = "observations.private_geom"
lat_col = "observations.private_latitude"
lon_col = "observations.private_longitude"
else
geom_col = "observations.geom"
lat_col = "observations.latitude"
lon_col = "observations.longitude"
end
# resort to lat/lon cols for date-line spanning boxes
if swlng.to_f > 0 && nelng.to_f < 0
where("#{lat_col} > ? AND #{lat_col} < ? AND (#{lon_col} > ? OR #{lon_col} < ?)",
swlat.to_f, nelat.to_f, swlng.to_f, nelng.to_f)
else
where("ST_Intersects(
ST_MakeBox2D(ST_Point(#{swlng.to_f}, #{swlat.to_f}), ST_Point(#{nelng.to_f}, #{nelat.to_f})),
#{geom_col}
)")
end
} do
def distinct_taxon
group("taxon_id").where("taxon_id IS NOT NULL").includes(:taxon)
end
end
scope :in_place, lambda {|place|
place_id = if place.is_a?(Place)
place.id
elsif place.to_i == 0
begin
Place.find(place).try(&:id)
rescue ActiveRecord::RecordNotFound
-1
end
else
place.to_i
end
joins("JOIN place_geometries ON place_geometries.place_id = #{place_id}").
where("ST_Intersects(place_geometries.geom, observations.private_geom)")
}
# should use .select("DISTINCT observations.*")
scope :in_places, lambda {|place_ids|
joins("JOIN place_geometries ON place_geometries.place_id IN (#{place_ids.join(",")})").
where("ST_Intersects(place_geometries.geom, observations.private_geom)")
}
scope :in_taxons_range, lambda {|taxon|
taxon_id = taxon.is_a?(Taxon) ? taxon.id : taxon.to_i
joins("JOIN taxon_ranges ON taxon_ranges.taxon_id = #{taxon_id}").
where("ST_Intersects(taxon_ranges.geom, observations.private_geom)")
}
# possibly radius in kilometers
scope :near_point, Proc.new { |lat, lng, radius|
lat = lat.to_f
lng = lng.to_f
radius = radius.to_f
radius = 10.0 if radius == 0
planetary_radius = PLANETARY_RADIUS / 1000 # km
radius_degrees = radius / (2*Math::PI*planetary_radius) * 360.0
where("ST_DWithin(ST_Point(?,?), geom, ?)", lng.to_f, lat.to_f, radius_degrees)
}
# Has_property scopes
scope :has_taxon, lambda { |*args|
taxon_id = args.first
if taxon_id.nil?
where("taxon_id IS NOT NULL")
else
where("taxon_id IN (?)", taxon_id)
end
}
scope :has_iconic_taxa, lambda { |iconic_taxon_ids|
iconic_taxon_ids = [iconic_taxon_ids].flatten.map do |itid|
if itid.is_a?(Taxon)
itid.id
elsif itid.to_i == 0
Taxon::ICONIC_TAXA_BY_NAME[itid].try(:id)
else
itid
end
end.uniq
if iconic_taxon_ids.include?(nil)
where(
"observations.iconic_taxon_id IS NULL OR observations.iconic_taxon_id IN (?)",
iconic_taxon_ids
)
elsif !iconic_taxon_ids.empty?
where("observations.iconic_taxon_id IN (?)", iconic_taxon_ids)
end
}
scope :has_geo, -> { where("latitude IS NOT NULL AND longitude IS NOT NULL") }
scope :has_id_please, -> { where( "quality_grade = ?", NEEDS_ID ) }
scope :has_photos, -> { where("observation_photos_count > 0") }
scope :has_sounds, -> { where("observation_sounds_count > 0") }
scope :has_quality_grade, lambda {|quality_grade|
quality_grades = quality_grade.to_s.split(',') & Observation::QUALITY_GRADES
quality_grade = '' if quality_grades.size == 0
where("quality_grade IN (?)", quality_grades)
}
scope :verifiable, -> { where( "quality_grade IN (?)", [RESEARCH_GRADE, NEEDS_ID] ) }
# Find observations by a taxon object. Querying on taxa columns forces
# massive joins, it's a bit sluggish
scope :of, lambda { |taxon|
taxon = Taxon.find_by_id(taxon.to_i) unless taxon.is_a? Taxon
return where("1 = 2") unless taxon
c = taxon.descendant_conditions.to_sql
c[0] = "taxa.id = #{taxon.id} OR #{c[0]}"
joins(:taxon).where(c)
}
scope :with_identifications_of, lambda { |taxon|
taxon = Taxon.find_by_id( taxon.to_i ) unless taxon.is_a? Taxon
return where( "1 = 2" ) unless taxon
c = taxon.descendant_conditions.to_sql
c = c.gsub( '"taxa"."ancestry"', 'it."ancestry"' )
# I'm not using TaxonAncestor here b/c updating observations for changes
# in conservation status uses this scope, and when a cons status changes,
# we don't want to skip any taxa that have moved around the tree since the
# last time the denormalizer ran
select( "DISTINCT observations.*").
joins( :identifications ).
joins( "JOIN taxa it ON it.id = identifications.taxon_id" ).
where( "identifications.current AND (it.id = ? or #{c})", taxon.id )
}
scope :at_or_below_rank, lambda {|rank|
rank_level = Taxon::RANK_LEVELS[rank]
joins(:taxon).where("taxa.rank_level <= ?", rank_level)
}
# Find observations by user
scope :by, lambda {|user|
if user.is_a?( User ) || user.to_i > 0
where("observations.user_id = ?", user)
else
joins(:user).where("users.login = ?", user)
end
}
# Order observations by date and time observed
scope :latest, -> { order("observed_on DESC NULLS LAST, time_observed_at DESC NULLS LAST") }
scope :recently_added, -> { order("observations.id DESC") }
# TODO: Make this work for any SQL order statement, including multiple cols
scope :order_by, lambda { |order_sql|
pieces = order_sql.split
order_by = pieces[0]
order = pieces[1] || 'ASC'
extra = [pieces[2..-1]].flatten.join(' ')
extra = "NULLS LAST" if extra.blank?
options = {}
case order_by
when 'observed_on'
order "observed_on #{order} #{extra}, time_observed_at #{order} #{extra}"
when 'created_at'
order "observations.id #{order} #{extra}"
when 'project'
order("project_observations.id #{order} #{extra}").joins(:project_observations)
when 'votes'
order("cached_votes_total #{order} #{extra}")
else
order "#{order_by} #{order} #{extra}"
end
}
def self.identifications(agreement)
scope = Observation
scope = scope.includes(:identifications)
case agreement
when 'most_agree'
scope.where("num_identification_agreements > num_identification_disagreements")
when 'some_agree'
scope.where("num_identification_agreements > 0")
when 'most_disagree'
scope.where("num_identification_agreements < num_identification_disagreements")
else
scope
end
end
# Time based named scopes
scope :created_after, lambda { |time| where('created_at >= ?', time)}
scope :created_before, lambda { |time| where('created_at <= ?', time)}
scope :updated_after, lambda { |time| where('updated_at >= ?', time)}
scope :updated_before, lambda { |time| where('updated_at <= ?', time)}
scope :observed_after, lambda { |time| where('time_observed_at >= ?', time)}
scope :observed_before, lambda { |time| where('time_observed_at <= ?', time)}
scope :in_month, lambda {|month| where("EXTRACT(MONTH FROM observed_on) = ?", month)}
scope :week, lambda {|week| where("EXTRACT(WEEK FROM observed_on) = ?", week)}
scope :in_projects, lambda { |projects|
# NOTE using :include seems to trigger an erroneous eager load of
# observations that screws up sorting kueda 2011-07-22
joins(:project_observations).where("project_observations.project_id IN (?)", Project.slugs_to_ids(projects))
}
scope :on, lambda {|date| where(Observation.conditions_for_date(:observed_on, date)) }
scope :created_on, lambda {|date| where(Observation.conditions_for_date("observations.created_at", date))}
scope :license, lambda {|license|
if license == 'none'
where("observations.license IS NULL")
elsif LICENSE_CODES.include?(license)
where(:license => license)
else
where("observations.license IS NOT NULL")
end
}
scope :photo_license, lambda {|license|
license = license.to_s
scope = joins(:photos)
license_number = Photo.license_number_for_code(license)
if license == 'none'
scope.where("photos.license = 0")
elsif LICENSE_CODES.include?(license)
scope.where("photos.license = ?", license_number)
else
scope.where("photos.license > 0")
end
}
scope :has_observation_field, lambda{|*args|
field, value = args
join_name = "ofv_#{field.is_a?(ObservationField) ? field.id : field}"
scope = joins("LEFT OUTER JOIN observation_field_values #{join_name} ON #{join_name}.observation_id = observations.id").
where("#{join_name}.observation_field_id = ?", field)
scope = scope.where("#{join_name}.value = ?", value) unless value.blank?
scope
}
scope :between_hours, lambda{|h1, h2|
h1 = h1.to_i % 24
h2 = h2.to_i % 24
where("EXTRACT(hour FROM ((time_observed_at AT TIME ZONE 'GMT') AT TIME ZONE zic_time_zone)) BETWEEN ? AND ?", h1, h2)
}
scope :between_months, lambda{|m1, m2|
m1 = m1.to_i % 12
m2 = m2.to_i % 12
if m1 > m2
where("EXTRACT(month FROM observed_on) >= ? OR EXTRACT(month FROM observed_on) <= ?", m1, m2)
else
where("EXTRACT(month FROM observed_on) BETWEEN ? AND ?", m1, m2)
end
}
scope :between_dates, lambda{|d1, d2|
t1 = (Time.parse(CGI.unescape(d1.to_s)) rescue Time.now)
t2 = (Time.parse(CGI.unescape(d2.to_s)) rescue Time.now)
if d1.to_s.index(':')
where("time_observed_at BETWEEN ? AND ? OR (time_observed_at IS NULL AND observed_on BETWEEN ? AND ?)", t1, t2, t1.to_date, t2.to_date)
else
where("observed_on BETWEEN ? AND ?", t1, t2)
end
}
scope :dbsearch, lambda {|*args|
q, on = args
q = sanitize_query(q) unless q.blank?
case on
when 'species_guess'
where("observations.species_guess ILIKE", "%#{q}%")
when 'description'
where("observations.description ILIKE", "%#{q}%")
when 'place_guess'
where("observations.place_guess ILIKE", "%#{q}%")
when 'tags'
where("observations.cached_tag_list ILIKE", "%#{q}%")
else
where("observations.species_guess ILIKE ? OR observations.description ILIKE ? OR observations.cached_tag_list ILIKE ? OR observations.place_guess ILIKE ?",
"%#{q}%", "%#{q}%", "%#{q}%", "%#{q}%")
end
}
scope :reviewed_by, lambda { |users|
joins(:observation_reviews).where("observation_reviews.user_id IN (?)", users)
}
scope :not_reviewed_by, lambda { |users|
users = [ users ] unless users.is_a?(Array)
user_ids = users.map{ |u| ElasticModel.id_or_object(u) }
joins("LEFT JOIN observation_reviews ON (observations.id=observation_reviews.observation_id)
AND observation_reviews.user_id IN (#{ user_ids.join(',') })").
where("observation_reviews.id IS NULL")
}
def self.near_place(place)
place = (Place.find(place) rescue nil) unless place.is_a?(Place)
if place.swlat
Observation.in_bounding_box(place.swlat, place.swlng, place.nelat, place.nelng)
else
Observation.near_point(place.latitude, place.longitude)
end
end
def self.preload_for_component(observations, logged_in)
preloads = [
{ user: :stored_preferences },
{ taxon: { taxon_names: :place_taxon_names } },
:iconic_taxon,
{ identifications: [:stored_preferences, :moderator_actions] },
{ photos: [:flags, :user, :file_extension, :file_prefix, :moderator_actions] },
{ sounds: [:flags, :moderator_actions] },
:stored_preferences, :flags, :quality_metrics,
:votes_for,
:taggings
]
# why do we need taxon_descriptions when logged in?
if logged_in
preloads.delete(:iconic_taxon)
preloads << { iconic_taxon: :taxon_descriptions }
preloads << :project_observations
end
Observation.preload_associations(observations, preloads)
end
# help_txt_for :species_guess, <<-DESC
# Type a name for what you saw. It can be common or scientific, accurate
# or just a placeholder. When you enter it, we'll try to look it up and find
# the matching species of higher level taxon.
# DESC
#
# instruction_for :place_guess, "Type the name of a place"
# help_txt_for :place_guess, <<-DESC
# Enter the name of a place and we'll try to find where it is. If we find
# it, you can drag the map marker around to get more specific.
# DESC
def to_s
"<Observation #{self.id}: #{to_plain_s}>"
end
def to_plain_s(options = {})
# I18n.t( :observation_brief_something_by_user )
# I18n.t( :observation_brief_something_from_place )
# I18n.t( :observation_brief_something_from_place_by_user )
# I18n.t( :observation_brief_something_from_place_in_month_year_by_user )
# I18n.t( :observation_brief_something_from_place_on_day )
# I18n.t( :observation_brief_something_from_place_on_day_at_time )
# I18n.t( :observation_brief_something_from_place_on_day_at_time_by_user )
# I18n.t( :observation_brief_something_from_place_on_day_by_user )
# I18n.t( :observation_brief_something_on_day )
# I18n.t( :observation_brief_something_on_day_at_time )
# I18n.t( :observation_brief_something_on_day_at_time_by_user )
# I18n.t( :observation_brief_something_on_day_by_user )
# I18n.t( :observation_brief_taxon_by_user )
# I18n.t( :observation_brief_taxon_from_place )
# I18n.t( :observation_brief_taxon_from_place_by_user )
# I18n.t( :observation_brief_taxon_from_place_in_month_year_by_user )
# I18n.t( :observation_brief_taxon_from_place_on_day )
# I18n.t( :observation_brief_taxon_from_place_on_day_at_time )
# I18n.t( :observation_brief_taxon_from_place_on_day_at_time_by_user )
# I18n.t( :observation_brief_taxon_from_place_on_day_by_user )
# I18n.t( :observation_brief_taxon_on_day )
# I18n.t( :observation_brief_taxon_on_day_at_time )
# I18n.t( :observation_brief_taxon_on_day_at_time_by_user )
# I18n.t( :observation_brief_taxon_on_day_by_user )
i18n_vars = {}
key = if taxon
i18n_vars[:taxon] = if options[:viewer]
ApplicationController.render( partial: "taxa/taxon.txt", locals: { taxon: taxon, viewer: options[:viewer] } )
else
common_name( locale: I18n.locale )
end
i18n_vars[:taxon] ||= taxon.name
"taxon"
else
"something"
end
unless self.place_guess.blank? || options[:no_place_guess] || coordinates_obscured?
key += "_from_place"
i18n_vars[:place] = place_guess
end
if coordinates_viewable_by?( options[:viewer] )
unless self.observed_on.blank?
key += "_on_day"
i18n_vars[:day] = I18n.l( self.observed_on, format: :long )
end
unless self.time_observed_at.blank? || options[:no_time]
key += "_at_time"
i18n_vars[:time] = I18n.l( time_observed_at_in_zone, format: :compact )
end
elsif !observed_on.blank?
key += "_in_month_year"
i18n_vars[:month] = I18n.l( observed_on, format: "%B" )
i18n_vars[:year] = I18n.l( observed_on, format: "%Y" )
end
unless options[:no_user]
key += "_by_user"
i18n_vars[:user] = user.try_methods(:name, :login)
end
if key != "something"
key = "observation_brief_#{key}"
end
I18n.t( key, **i18n_vars.merge( default: I18n.t( :something ) ) )
end
def time_observed_at_utc
time_observed_at.try(:utc)
end
def serializable_hash(opts = nil)
# for some reason, in some cases options was still nil
options = opts ? opts.clone : { }
# making a deep copy of the options so they don't get modified
# This was more effective than options.deep_dup
if options[:include] && (options[:include].is_a?(Hash) || options[:include].is_a?(Array))
options[:include] = options[:include].marshal_copy
end
# don't use delete here, it will just remove the option for all
# subsequent records in an array
options[:include] = if options[:include].is_a?(Hash)
options[:include].map{|k,v| {k => v}}
else
[options[:include]].flatten.compact
end
options[:methods] ||= []
options[:methods] += [:created_at_utc, :updated_at_utc,
:time_observed_at_utc, :faves_count, :owners_identification_from_vision]
viewer = options[:viewer]
viewer = viewer.is_a?( User ) ? viewer : User.find_by_id( viewer.to_i )
options[:except] ||= []
options[:except] += [:user_agent]
if !options[:force_coordinate_visibility] && !coordinates_viewable_by?(
viewer,
ignore_collection_projects: options[:ignore_collection_projects]
)
options[:except] += [:private_latitude, :private_longitude, :geom, :private_geom, :private_place_guess]
options[:methods] << :coordinates_obscured
end
options[:except] += [:cached_tag_list, :geom, :private_geom]
options[:except].uniq!
options[:methods].uniq!
h = super(options)
h.each do |k,v|
h[k] = v.gsub(/<script.*script>/i, "") if v.is_a?(String)
end
h.force_utf8
end
#
# Return a time from observed_on and time_observed_at
#
def datetime
@datetime ||= if observed_on && errors[:observed_on].blank?
time_observed_at_in_zone ||
Time.new(observed_on.year,
observed_on.month,
observed_on.day, 0, 0, 0,
timezone_offset)
end
end
def timezone_object
# returns nil if the time_zone has an invalid value
(time_zone && ActiveSupport::TimeZone.new(time_zone)) ||
(zic_time_zone && ActiveSupport::TimeZone.new(zic_time_zone))
end
def timezone_offset
# returns nil if the time_zone has an invalid value
(timezone_object || ActiveSupport::TimeZone.new("UTC")).formatted_offset
end
# Return time_observed_at in the observation's time zone
def time_observed_at_in_zone
if self.time_observed_at
self.time_observed_at.in_time_zone(self.time_zone)
end
end
#
# Set all the time fields based on the contents of observed_on_string
#
def munge_observed_on_with_chronic( debug = false )
if observed_on_string.blank?
self.observed_on = nil
self.time_observed_at = nil
return true
end
# Only re-interpret the date if observed_on_string or time_zone changed
return unless observed_on_string_changed? || time_zone_changed?
date_string = observed_on_string.strip
tz_abbrev_pattern = /\s\(?([A-Z]{3,})\)?$/ # ends with (PDT)
tz_offset_pattern = /([+-]\d{4})$/ # contains -0800
tz_js_offset_pattern = /(GMT)?([+-]\d{4})/ # contains GMT-0800
tz_colon_offset_pattern = /(GMT|HSP)([+-]\d+:\d+)/ # contains (GMT-08:00)
tz_moment_offset_pattern = /\s([+-]\d{2})$/ # contains -08, +05, etc.
tz_failed_abbrev_pattern = /\(#{tz_colon_offset_pattern}\)/
if date_string =~ /#{tz_js_offset_pattern} #{tz_failed_abbrev_pattern}/
date_string = date_string.sub(tz_failed_abbrev_pattern, '').strip
end
tz_abbrev = date_string[tz_abbrev_pattern, 1]
# Rails timezone support doesn't seem to recognize this abbreviation, and
# frankly I have no idea where ActiveSupport::TimeZone::CODES comes from.
# In case that ever stops working or a less hackish solution is required,
# check out https://gist.github.com/kueda/3e6f77f64f792b4f119f
tz_abbrev = 'CET' if tz_abbrev == 'CEST'
# Abbreviations with synonyms at https://en.wikipedia.org/wiki/List_of_time_zone_abbreviations
problem_tz_abbrevs = %w(
AST
BRT
BST
CDT
CST
ECT
GST
IST
)
if ( iso8601_datetime = DateTime.iso8601( observed_on_string ) rescue nil )
date_string = observed_on_string
if observed_on_string =~ /[+-]\d{2}:?\d{2}/
parsed_time_zone = ActiveSupport::TimeZone[iso8601_datetime.offset * 24]
end
elsif ( parsed_time_zone = ActiveSupport::TimeZone::CODES[tz_abbrev] ||
parsed_time_zone = ActiveSupport::TimeZone::CODES.values.compact.detect{|c| c.name == tz_abbrev} )
date_string = observed_on_string.sub(tz_abbrev_pattern, '')
date_string = date_string.sub(tz_js_offset_pattern, '').strip
# If the parsed time zone is one of the ambiguous ones where we can't
# really know which one they're referring too, don't actually use the zone
# code we parsed out of the string
if problem_tz_abbrevs.include?( tz_abbrev )
parsed_time_zone = nil
end
puts "matched tz_abbrev_pattern: #{tz_abbrev}, parsed_time_zone: #{parsed_time_zone}" if debug
elsif (offset = date_string[tz_offset_pattern, 1]) &&
(n = offset.to_f / 100) &&
(key = n == 0 ? 0 : n.floor + (n%n.floor)/0.6) &&
(parsed_time_zone = ActiveSupport::TimeZone[key])
puts "matched tz_offset_pattern: #{offset}, parsed_time_zone: #{parsed_time_zone}" if debug
date_string = date_string.sub(tz_offset_pattern, '')