# encoding: utf-8 # # = Observation Model # # An Observation is a mushroom seen at a certain Location and time, as # recorded by a User. This is at the core of the site. It can have any # number of Image's, Naming's, Comment's, Interest's. # # == Voting # # Voting is still in a state of flux. At the moment User's create Naming's # and other User's Vote on them. We combine the Vote's for each Naming, cache # the Vote for each Naming in the Naming. However no Naming necessarily wins # -- instead Vote's are tallied for each Synonym (see calc_consensus for full # details). Thus the accepted Name of the winning Synonym is cached in the # Observation along with its winning Vote score. # # == Location # # An Observation can belong to either a defined Location (+location+, a # Location instance) or an undefined one (+where+, just a String), and even # occasionally both (see below). To make this a little easier, you can refer # to +place_name+ instead, which returns the name of whichever is present. # # *NOTE*: We were clearly having trouble making up our mind whether or not to # set +where+ when +location+ was present. The only safe heuristic is to use # +location+ if it's present, then fall back on +where+ -- +where+ may or may # not be set (or even accurate?) if +location+ is present. # # *NOTE*: If a mushroom is seen at a mushroom fair or an herbarium, we don't # necessarily know where the mushroom actually grew. In this case, we enter # the mushroom fair / herbarium as the +place_name+ and set the special flag # +is_collection_location+ to false. # # == Attributes # # id:: Locally unique numerical id, starting at 1. # sync_id:: Globally unique alphanumeric id, used to sync with remote servers. # created:: Date/time it was first created. # modified:: Date/time it was last modified. # user_id:: User that created it. # when:: Date it was seen. # where:: Where it was seen (just a String). # location:: Where it was seen (Location). # lat:: Exact latitude of location. # long:: Exact longitude of location. # alt:: Exact altitude of location. (meters) # is_collection_location:: Is this where it was growing? # name:: Consenus Name (never deprecated, never nil). # vote_cache:: Cache Vote score for the winning Name. # thumb_image:: Image to use as thumbnail (if any). # specimen:: Does User have a specimen available? # notes:: Arbitrary extra notes supplied by User. # num_views:: Number of times it has been viewed. # last_view:: Last time it was viewed. # # ==== "Fake" attributes # idstr:: Used by observer/reuse_image.rhtml. # place_name:: Wrapper on top of +where+ and +location+. Handles location_format. # # == Class methods # # refresh_vote_cache:: Refresh cache for all Observation's. # define_a_location:: Update any observations using the old "where" name. # # == Instance methods # # comments:: List of Comment's attached to this Observation. # interests:: List of Interest's attached to this Observation. # species_lists:: List of SpeciesList's that contain this Observation. # # ==== Name Formats # text_name:: Plain text. # format_name:: Textilized. # unique_text_name:: Plain text, with id added to make unique. # unique_format_name:: Textilized, with id added to make unique. # # ==== Namings and Votes # name:: Conensus Name instance. (never nil) # namings:: List of Naming's proposed for this Observation. # name_been_proposed?:: Has someone proposed this Name already? # owner_voted?:: Has the owner voted on a given Naming? # user_voted?:: Has a given User voted on a given Naming? # owners_vote:: Get the owner's Vote on a given Naming. # users_vote:: Get a given User's Vote on a given Naming. # owners_votes:: Get all of the onwer's Vote's for this Observation. # users_votes:: Get all of a given User's Vote's for this Observation. # is_owners_favorite?:: Is a given naming the owner's favorite? # is_users_favorite?:: Is a given naming a given user's favorite? # vote_percent:: Convert Vote score to percentage. # change_vote:: Change a given User's Vote for a given Naming. # consensus_naming:: Guess which Naming is responsible for consensus. # calc_consensus:: Calculate and cache the consensus naming/name. # review_status:: Decide what the review status is for this Observation. # lookup_naming:: Return corresponding Naming instance from this Observation's namings association. # dump_votes:: Dump all the Naming and Vote info as known by this Observation and its associations. # # ==== Images # images:: List of Image's attached to this Observation. # add_image:: Attach an Image. # remove_image:: Remove an Image. # # ==== Projects # has_edit_permission?:: Check if user has permission to edit this observation. # # ==== Logging # log_create_image:: Log addition of new Image. # log_reuse_image:: Log reuse of old Image. # log_update_image:: Log update to Image. # log_remove_image:: Log removal of Image. # log_destroy_image:: Log destruction of Image. # # ==== Callbacks # add_spl_callback:: After add: update contribution. # remove_spl_callback:: After remove: update contribution. # notify_species_lists:: Before destroy: log destruction on species_lists. # destroy_dependents:: After destroy: destroy Naming's. # notify_users_after_change:: After save: call notify_users (if important). # notify_users_after_destroy:: After destroy: call notify_users. # notify_users:: After save/destroy/image: send email. # announce_consensus_change:: After consensus changes: send email. # ################################################################################ class Observation < AbstractModel belongs_to :thumb_image, :class_name => "Image", :foreign_key => "thumb_image_id" belongs_to :name # (used to cache consensus name) belongs_to :location belongs_to :rss_log belongs_to :user has_many :votes has_many :comments, :as => :target, :dependent => :destroy has_many :interests, :as => :target, :dependent => :destroy # DO NOT use :dependent => :destroy -- this causes it to recalc the # consensus several times and send bogus emails!! has_many :namings has_and_belongs_to_many :images has_and_belongs_to_many :projects has_and_belongs_to_many :species_lists, :after_add => :add_spl_callback, :before_remove => :remove_spl_callback after_update :notify_users_after_change before_destroy :notify_species_lists after_destroy :notify_users_after_destroy after_destroy :destroy_dependents # Automatically (but silently) log destruction. self.autolog_events = [:destroyed] # Always returns empty string. (Used by # observer/reuse_image.rhtml.) def idstr '' end # Adds error if couldn't find image with the given id. (Used by # observer/reuse_image.rhtml.) def idstr=(id_field) id = id_field.to_i img = Image.find(:id => id) unless img errors.add(:thumb_image_id, :validate_observation_thumb_image_id_invalid.t) end end def raw_place_name if location location.name else self.where end end # Abstraction over +where+ and +location.display_name+. Returns Location # name as a string, preferring +location+ over +where+ wherever both exist. # Also applies the location_format of the current user (defaults to :postal). def place_name if location location.display_name elsif User.current_location_format == :scientific Location.reverse_name(self.where) else self.where end end # Set +where+ or +location+, depending on whether a Location is defined with # the given +display_name+. (Fills the other in with +nil+.) # Adjusts for the current user's location_format as well. def place_name=(place_name) place_name = place_name.strip_squeeze where = if User.current_location_format == :scientific Location.reverse_name(place_name) else place_name end if loc = Location.find_by_name(where) self.where = nil self.location = loc else self.where = where self.location = nil end end # Useful for forms in which date is entered in YYYYMMDD format: When form tag # helper creates input field, it reads obs.when_str and gets date in # YYYYMMDD. When form submits, assigning string to obs.when_str saves string # verbatim in @when_str, and if it is valid, sets the actual when field. # When you go to save the observation, it detects invalid format and prevents # save. When it renders form again, it notes the error, populates the input # field with the old invalid string for editing, and colors it red. def when_str if @when_str @when_str else self.when.strftime('%Y-%m-%d') end end def when_str=(x) @when_str = x self.when = x if Date.parse(x) return x end def lat=(x) val = Location.parse_latitude(x) val = x if val.nil? and !x.blank? write_attribute(:lat, val) end def long=(x) val = Location.parse_longitude(x) val = x if val.nil? and !x.blank? write_attribute(:long, val) end def alt=(x) val = Location.parse_altitude(x) val = x if val.nil? and !x.blank? write_attribute(:alt, val) end # Is lat/long more than 10% outside of location extents? def lat_long_dubious? result = false if lat and location delta_lat = location.north_south_distance / 10 delta_long = location.east_west_distance / 10 if lat > location.north + delta_lat or lat < location.south - delta_lat or long > location.east + delta_long or long < location.west - delta_long result = true end end return result end ############################################################################## # # :section: Namings and Votes # ############################################################################## # Name in plain text, never nil. def text_name name.search_name end # Name in plain text with id to make it unique, never nil. def unique_text_name "%s (%s)" % [name.search_name, id] end # Textile-marked-up name, never nil. def format_name name.observation_name end # Textile-marked-up name with id to make it unique, never nil. def unique_format_name "%s (%s)" % [name.observation_name, id] end # Look up the corresponding instance in our namings association. If we are # careful to keep all the operations within the tree of assocations of the # observations, we should never need to reload anything. def lookup_naming(naming) namings.select {|n| n == naming}.first or raise ActiveRecord::RecordNotFound, "Observation doesn't have naming with ID=#{naming.id}" end # Dump out the sitatuation as the observation sees it. Useful for debugging # problems with reloading requirements. def dump_votes namings.map do |n| "#{n.id} #{n.name.search_name}: " + (n.votes.empty? ? "no votes" : n.votes.map do |v| "#{v.user.login}=#{v.value}" + (v.favorite ? '(*)' : '') end.join(', ')) end.join("\n") end # Has anyone proposed a given Name yet for this observation? def name_been_proposed?(name) namings.select {|n| n.name == name}.length > 0 end # Has the owner voted on a given Naming? def owner_voted?(naming) !!lookup_naming(naming).users_vote(user) end # Has a given User owner voted on a given Naming? def user_voted?(naming, user) !!lookup_naming(naming).users_vote(user) end # Get the owner's Vote on a given Naming. def owners_vote(naming) lookup_naming(naming).users_vote(user) end # Get a given User's Vote on a given Naming. def users_vote(naming, user) lookup_naming(naming).users_vote(user) end # Returns true if a given naming has received the highest positive vote from # the owner of this observation. Note, multiple namings can return true for # a given observation. def is_owners_favorite?(naming) lookup_naming(naming).is_users_favorite?(user) end # Returns true if a given naming has received the highest positive vote from # the given user (among namings for this observation). Note, multiple # namings can return true for a given user and observation. def is_users_favorite?(naming, user) lookup_naming(naming).is_users_favorite?(user) end # Get a list of the owner's Votes for this Observation. def owners_votes users_votes(user) end # Get a list of this User's Votes for this Observation. def users_votes(user) result = [] for n in namings if v = n.users_vote(user) result << v end end return result end # Convert cached Vote score to percentage. def vote_percent Vote.percent(vote_cache) end # Change User's Vote for this naming. Automatically recalculates the # consensus for the Observation in question if anything is changed. Returns # true if something was changed. def change_vote(naming, value, user=User.current) result = false naming = lookup_naming(naming) vote = naming.users_vote(user) # This special value means destroy vote. if value == Vote.delete_vote if vote naming.votes.delete(vote) result = true # If this was one of the old favorites, we might have to elect new. if vote.favorite # Get the max positive vote. max = 0 for v in users_votes(user) if v.value > max max = v.value end end # If any, mark all votes at that level "favorite". if max > 0 for v in users_votes(user) if (v.value == max) and !v.favorite v.favorite = true v.save end end end end end # If no existing vote, or if changing value. elsif !vote || (vote.value != value) result = true # First downgrade any existing 100% votes (if casting a 100% vote). v80 = Vote.next_best_vote if value > v80 for v in users_votes(user) if v.value > v80 v.value = v80 v.save end end end # Is this vote going to become the favorite? favorite = false if value > 0 favorite = true for v in users_votes(user) # If any other vote higher, this is not the favorite. if v.value > value favorite = false break # If any other votes are lower, those will not be favorite. elsif (v.value < value) and v.favorite v.favorite = false v.save end end end # Create vote if none exists. if !vote naming.votes.create!( :user => user, :observation => self, :value => value, :favorite => favorite ) # Change vote if it exists. else vote.value = value vote.favorite = favorite vote.save end end # Update consensus if anything changed. calc_consensus if result return result end # Try to guess which Naming is responsible for the consensus. This will # always return a Naming, no matter how ambiguous, unless there are no # namings. def consensus_naming result = nil # First, get the Naming(s) for this Name, if any exists. matches = namings.select {|n| n.name_id == name_id} # If not, it means that a deprecated Synonym won. Look up all Namings # for Synonyms of the consensus Name. if matches == [] && name && name.synonym synonyms = name.synonyms matches = namings.select {|n| synonyms.include?(n.name)} end # Only one match -- easy! if matches.length == 1 result = matches.first # More than one match: take the one with the highest vote. elsif best_naming = matches.first best_value = matches.first.vote_cache for naming in matches if naming.vote_cache > best_value best_naming = naming best_value = naming.vote_cache end end result = best_naming end return result end # Get the community consensus on what the name should be. It just adds up # the votes weighted by user contribution, and picks the winner. To break a # tie it takes the one with the most votes (again weighted by contribution). # Failing that it takes the oldest one. Note, it lumps all synonyms together # when deciding the winning "taxon", using votes for the separate synonyms # only when there are multiple "accepted" names for the winning taxon. # # Returns Naming instance or nil. Refreshes vote_cache as a side-effect. def calc_consensus(debug=false) reload result = "" if debug # Gather votes for names and synonyms. Note that this is trickier than one # would expect since it is possible to propose several synonyms for a # single observation, and even worse perhaps, one can even propose the very # same name multiple times. Thus a user can potentially vote for a given # *name* (not naming) multiple times. Likewise, of course, for synonyms. # I choose the strongest vote in such cases. name_votes = {} # Records the strongest vote for a given name for a given user. taxon_votes = {} # Records the strongest vote for any names in a group of synonyms for a given user. name_ages = {} # Records the oldest date that a name was proposed. taxon_ages = {} # Records the oldest date that a taxon was proposed. user_wgts = {} # Caches user rankings. for naming in namings naming_id = naming.id name_id = naming.name_id name_ages[name_id] = naming.created if !name_ages[name_id] || naming.created < name_ages[name_id] sum_val = 0 sum_wgt = 0 # Go through all the votes for this naming. Should be zero or one per # user. for vote in naming.votes user_id = vote.user_id val = vote.value wgt = user_wgts[user_id] if wgt.nil? wgt = user_wgts[user_id] = vote.user_weight end # It may be possible in the future for us to weight some "special" # users zero, who knows... (It can cause a division by zero below if # we ignore zero weights.) if wgt > 0 # Calculate score for naming.vote_cache. sum_val += val * wgt sum_wgt += wgt # Record best vote for this user for this name. This will be used # later to determine which name wins in the case of the winning taxon # (see below) having multiple accepted names. name_votes[name_id] = {} if !name_votes[name_id] if !name_votes[name_id][user_id] || name_votes[name_id][user_id][0] < val name_votes[name_id][user_id] = [val, wgt] end # Record best vote for this user for this group of synonyms. (Since # not all taxa have synonyms, I've got to create a "fake" id that # uses the synonym id if it exists, else uses the name id, but still # keeps them separate.) taxon_id = naming.name.synonym ? "s" + naming.name.synonym_id.to_s : "n" + name_id.to_s taxon_ages[taxon_id] = naming.created if !taxon_ages[taxon_id] || naming.created < taxon_ages[taxon_id] taxon_votes[taxon_id] = {} if !taxon_votes[taxon_id] result += "raw vote: taxon_id=#{taxon_id}, name_id=#{name_id}, user_id=#{user_id}, val=#{val}
" if debug if !taxon_votes[taxon_id][user_id] || taxon_votes[taxon_id][user_id][0] < val taxon_votes[taxon_id][user_id] = [val, wgt] end end end # Note: this is used by consensus_naming(), not this method. value = sum_wgt > 0 ? sum_val.to_f / (sum_wgt + 1.0) : 0.0 if naming.vote_cache != value naming.vote_cache = value naming.save end end # Now that we've weeded out potential duplicate votes, we can combine them # safely. votes = {} for taxon_id in taxon_votes.keys vote = votes[taxon_id] = [0, 0] for user_id in taxon_votes[taxon_id].keys user_vote = taxon_votes[taxon_id][user_id] val = user_vote[0] wgt = user_vote[1] vote[0] += val * wgt vote[1] += wgt result += "vote: taxon_id=#{taxon_id}, user_id=#{user_id}, val=#{val}, wgt=#{wgt}
" if debug end end # Now we can determine the winner among the set of synonym-groups. (Nathan # calls these synonym-groups "taxa", because it better uniquely represents # the underlying mushroom taxon, while it might have multiple names.) best_val = nil best_wgt = nil best_age = nil best_id = nil for taxon_id in votes.keys wgt = votes[taxon_id][1] val = votes[taxon_id][0].to_f / (wgt + 1.0) age = taxon_ages[taxon_id] result += "#{taxon_id}: val=#{val} wgt=#{wgt} age=#{age}
" if debug if best_val.nil? || val > best_val || val == best_val && ( wgt > best_wgt || wgt == best_wgt && ( age < best_age )) best_val = val best_wgt = wgt best_age = age best_id = taxon_id end end result += "best: id=#{best_id}, val=#{best_val}, wgt=#{best_wgt}, age=#{best_age}
" if debug # Reverse our kludge that mashed names-without-synonyms and synonym-groups # together. In the end we just want a name. if best_id match = /^(.)(\d+)/.match(best_id) # Synonym id: go through namings and pick first one that belongs to this # synonym group. Any will do for our purposes, because we will convert # it to the currently accepted name below. if match[1] == "s" for naming in namings if naming.name.synonym_id.to_s == match[2] best = naming.name break end end else best = Name.find(match[2].to_i) end end result += "unmash: best=#{best ? best.text_name : "nil"}
" if debug # Now deal with synonymy properly. If there is a single accepted name, # great, otherwise we need to somehow disambiguate. if best && best.synonym # This does not allow the community to choose a deprecated synonym over # an approved synonym. See obs #45234 for reasonable-use case. # names = best.approved_synonyms # names = best.synonyms if names.length == 0 names = best.synonyms if names.length == 1 best = names.first elsif names.length > 1 result += "Multiple synonyms: #{names.map {|x| x.id}.join(', ')}
" if debug # First combine votes for each name; exactly analagous to what we did # with taxa above. votes = {} for name_id in name_votes.keys vote = votes[name_id] = [0, 0] for user_id in name_votes[name_id].keys user_vote = name_votes[name_id][user_id] val = user_vote[0] wgt = user_vote[1] vote[0] += val * wgt vote[1] += wgt result += "vote: name_id=#{self.name_id}, user_id=#{user_id}, val=#{val}, wgt=#{wgt}
" if debug end end # Now pick the winner among the ambiguous names. If none are voted on, # just pick the first one (I grow weary of these games). This latter # is all too real of a possibility: users may vigorously debate # deprecated names, then at some later date two *new* names are created # for the taxon, both are considered "accepted" until the scientific # community rules definitively. Now we have two possible names # winning, but no votes on either! If you have a problem with the one # I chose, then vote on the damned thing, already! :) best_val2 = nil best_wgt2 = nil best_age2 = nil best_id2 = nil for name in names name_id = name.id vote = votes[name_id] if vote wgt = vote[1] val = vote[0].to_f / (wgt + 1.0) age = name_ages[name_id] result += "#{self.name_id}: val=#{val} wgt=#{wgt} age=#{age}
" if debug if best_val2.nil? || val > best_val2 || val == best_val2 && ( wgt > best_wgt2 || wgt == best_wgt2 && ( age < best_age2 )) best_val2 = val best_wgt2 = wgt best_age2 = age best_id2 = name_id end end end result += "best: id=#{best_id2}, val=#{best_val2}, wgt=#{best_wgt2}, age=#{best_age2}
" if debug best = best_id2 ? Name.find(best_id2) : names.first end end result += "unsynonymize: best=#{best ? best.text_name : "nil"}
" if debug # This should only occur for observations created by # species_list.construct_observation(), which doesn't necessarily create # any votes associated with its naming. Therefore this should only ever # happen when there is a single naming, so there is nothing arbitray in # using first. (I think it can also happen if zero-weighted users are # voting.) best = namings.first.name if !best && namings && namings.length > 0 best = Name.unknown if !best result += "fallback: best=#{best ? best.text_name : 'nil'}" if debug # Make changes permanent. old = self.name if (self.name != best) or (self.vote_cache != best_val) self.name = best self.vote_cache = best_val self.save end # Log change if actually is a change. if best != old announce_consensus_change(old, best) end return result if debug end # Admin tool that refreshes the vote cache for all observations with a vote. def self.refresh_vote_cache for o in Observation.find(:all) o.calc_consensus end end # Return the review status based on the Vote's on the consensus Name by # current reviewers. Possible return values: # # unreviewed:: No reviewers have voted for the consensus. # inaccurate:: Some reviewer doubts the consensus (vote.value < 0). # unvetted:: Some reviewer is not completely confident in this naming (vote.value < Vote#maximum_vote). # vetted:: All reviewers that have voted on the current consensus fully support this name (vote.value = Vote#maximum_vote). # # *NOTE*: It probably makes sense to cache this result at some point. # # *NOTE*: This checks all Vote's for Synonym Naming's, taking each reviewer's # highest Vote (if they voted for multiple Synonym's). # def review_status # Get list of Name ids we care about. name_ids = [name_id] if name.synonym_id name_ids = Name.connection.select_values %( SELECT `id` FROM `names` WHERE `synonym_id` = '#{synonym_id}' ) end # Get list of User ids for reviewers. group = UserGroup.find_by_name('reviewers') user_ids = User.connection.select_values %( SELECT `user_id` FROM `user_groups_users` WHERE `user_group_id` = #{group.id} ) # Get all the reviewers' Vote's for these Name's. # Order of conditions makes no difference: query times are around 0.05 sec. data = Vote.connection.select_rows %( SELECT vote.user_id, vote.value FROM `votes`, `namings` WHERE votes.observation_id = #{id} AND votes.naming_id = namings.id AND namings.name_id IN (#{name_ids.map(&:to_s).uniq.join(',')}) AND votes.user_id IN (#{user_ids.map(&:to_s).uniq.join(',')}) ) # Get highest vote for each User. votes = {} for user_id, value in data value = value.to_f if votes[user_id] votes[user_id] = value if votes[user_id] < value else votes[user_id] = value end end # Apply heuristics to determine review status. status = :unreviewed v100 = Vote.maximum_vote.to_f for value in votes.values if value < 0 status = :inaccurate break elsif status != :inaccurate if value != v100 status = :unvetted elsif status == :unreviewed status = :vetted end end end return status end ################################################################################ # # :section: Images # ################################################################################ # Add Image to this Observation, making it the thumbnail if none set already. # Saves changes. Returns Image. def add_image(img) if !images.include?(img) images << img unless thumb_image self.thumb_image = img self.save end notify_users(:added_image) end return img end # Removes an Image from this Observation. If it's the thumbnail, changes # thumbnail to next available Image. Saves change to thumbnail, might save # change to Image. Returns Image. def remove_image(img) if images.include?(img) images.delete(img) if thumb_image_id == img.id if images != [] self.thumb_image = img2 = images.first else self.thumb_image = nil end self.save end notify_users(:removed_image) end return img end ################################################################################ # # :section: Projects # ################################################################################ def has_edit_permission?(user=User.current) Project.has_edit_permission?(self, user) end ############################################################################## # # :section: Logging # ############################################################################## # Logs addition of new Image. def log_create_image(image); log_image(:log_image_created, image, true); end # Logs addition of existing Image. def log_reuse_image(image); log_image(:log_image_reused, image, true); end # Logs update of Image. def log_update_image(image); log_image(:log_image_updated, image, false); end # Logs removal of Image. def log_remove_image(image); log_image(:log_image_removed, image, false); end # Logs destruction of Image. def log_destroy_image(image); log_image(:log_image_destroyed, image, false); end def log_image(tag, image, touch) # :nodoc: name = "#{:Image.t} ##{image.id || image.was || '??'}" log(tag, :name => name, :touch => touch) end ################################################################################ # # :section: Callbacks # ################################################################################ # Callback that updates a User's contribution after adding an Observation to # a SpeciesList. def add_spl_callback(o) SiteData.update_contribution(:add, :species_list_entries, user_id) end # Callback that updates a User's contribution after removing an Observation # from a SpeciesList. def remove_spl_callback(o) SiteData.update_contribution(:del, :species_list_entries, user_id) end # Callback that logs an Observation's destruction on all of its # SpeciesList's. (Also saves list of Namings so they can be destroyed # by hand afterword without causing superfluous calc_consensuses.) def notify_species_lists # Tell all the species lists it belonged to. for spl in species_lists spl.log(:log_observation_destroyed2, :name => unique_format_name, :touch => false) end # Save namings so we can delete them after it's dead. @old_namings = namings end # Callback that destroys an Observation's Naming's (carefully) after the # Observation is destroyed. def destroy_dependents for naming in @old_namings naming.observation = nil # (tells it not to recalc consensus) naming.destroy end end # Callback that sends email notifications after save. def notify_users_after_change if !id || when_changed? || where_changed? || location_id_changed? || notes_changed? || specimen_changed? || is_collection_location_changed? || thumb_image_id_changed? notify_users(:change) end end # Callback that sends email notifications after destroy. def notify_users_after_destroy notify_users(:destroy) end # Send email notifications upon change to Observation. Several actions are # possible: # # added_image:: Image was added. # removed_image:: Image was removed. # change:: Other changes (e.g. to notes). # destroy:: Observation destroyed. # # obs.images << Image.create # obs.notify_users(:added_image) # def notify_users(action) sender = user recipients = [] # Send to people who have registered interest. for interest in interests if interest.state recipients.push(interest.user) end end # Tell masochists who want to know about all observation changes. for user in User.find_all_by_email_observations_all(true) recipients.push(user) end # Send notification to all except the person who triggered the change. for recipient in recipients.uniq if recipient && recipient != sender if action == :destroy QueuedEmail::ObservationChange.destroy_observation(sender, recipient, self) elsif action == :change QueuedEmail::ObservationChange.change_observation(sender, recipient, self) else QueuedEmail::ObservationChange.change_images(sender, recipient, self, action) end end end end # Send email notifications upon change to consensus. # # old_name = obs.name # obs.name = new_name # obs.announce_consensus_change(old_name, new_name) # def announce_consensus_change(old_name, new_name) if old_name log(:log_consensus_changed, :old => old_name.observation_name, :new => new_name.observation_name) else log(:log_consensus_created, :name => new_name.observation_name) end # Change can trigger emails. owner = self.user sender = User.current recipients = [] # Tell owner of observation if they want. recipients.push(owner) if owner && owner.email_observations_consensus # Send to people who have registered interest. # Also remove everyone who has explicitly said they are NOT interested. for interest in interests if interest.state recipients.push(interest.user) else recipients.delete(interest.user) end end # Send notification to all except the person who triggered the change. for recipient in recipients.uniq - [sender] if recipient.created_here QueuedEmail::ConsensusChange.create_email(sender, recipient, self, old_name, new_name) end end end # After defining a location, update any lists using old "where" name. def self.define_a_location(location, old_name) connection.update(%( UPDATE observations SET `where` = NULL, location_id = #{location.id} WHERE `where` = "#{old_name.gsub('"', '\\"')}" )) # (no transactions necessary: creating location on foreign server # should initiate identical action) end ################################################################################ protected def validate # :nodoc: # Clean off leading/trailing whitespace from +where+. self.where = self.where.strip_squeeze if self.where self.where = nil if self.where == '' if !self.when self.when ||= Time.now # errors.add(:when, :validate_observation_when_missing.t) elsif self.when.is_a?(Date) && self.when > Date.today + 1.day errors.add(:when, "self.when=#{self.when.class.name}:#{self.when} Date.today=#{Date.today}") errors.add(:when, :validate_observation_future_time.t) elsif self.when.is_a?(Time) && self.when > Time.now + 1.day errors.add(:when, "self.when=#{self.when.class.name}:#{self.when} Time.now=#{Time.now+6.hours}") errors.add(:when, :validate_observation_future_time.t) end if !user && !User.current errors.add(:user, :validate_observation_user_missing.t) end if self.where.to_s.blank? && !location_id self.location = Location.unknown # errors.add(:where, :validate_observation_where_missing.t) elsif self.where.to_s.binary_length > 1024 errors.add(:where, :validate_observation_where_too_long.t) end if lat.blank? and !long.blank? or !lat.blank? and !Location.parse_latitude(lat) errors.add(:lat, :runtime_lat_long_error.t) end if !lat.blank? and long.blank? or !long.blank? and !Location.parse_longitude(long) errors.add(:long, :runtime_lat_long_error.t) end if !alt.blank? and !Location.parse_altitude(alt) errors.add(:alt, :runtime_altitude_error.t) end if @when_str and !Date.parse(@when_str) errors.add(:when_str, :runtime_date_should_be_yyyymmdd.t) end end end