MushroomObserver/mushroom-observer

View on GitHub
app/models/abstract_model.rb

Summary

Maintainability
A
0 mins
Test Coverage
# frozen_string_literal: true

#
#  = Extensions to ApplicationRecord
#
#  == Methods
#
#  type_tag::           Language tag, e.g., :observation, :rss_log, etc.
#
#  == Scopes
#
#  Scopes for collecting objects created (or updated) on, before, after or
#  between a given "%Y-%m-%d" string(s).
#
#  Examples: Observation.created_between("2006-09-01", "2012-09-01")
#            Name.updated_after("2016-12-01")
#
#  created_on::
#  created_after::
#  created_before::
#  created_between::
#  updated_on::
#  updated_after::
#  updated_before::
#  updated_between::
#
#  ==== Extensions to "find"
#  safe_find::          Same as <tt>find(id)</tt> but return nil if not found.
#  find_object::        Look up an object by class name and id.
#  find_by_sql_with_limit::
#                       Add limit to a SQL query, then pass it to find_by_sql.
#  count_by_sql_wrapping_select_query::
#                       Wrap a normal SQL query in a count query,
#                       then pass it to count_by_sql.
#  revert_clone::       Clone and revert to old version
#                       (or return nil if version not found).
#  find_using_wildcards::
#                       Lookup instances with a string that may contain "*"s.
#
#  ==== Report "show" action for object/model
#  show_controller::    These two return the controller and action of the main
#  show_action::          page used to display this object.
#  show_url::           "Official" URL for this database object.
#  show_link_args::     "Official" link_to args for this database object.
#  index_action::       Name of action to display index of these objects
#  index_link_args::    link_to args for this database object's index.
#
#  ==== Callbacks
#  before_create::      Do several things before creating a new record.
#  after_create::       Do several more things after done creating new record.
#  before_update::      Do several things before commiting changes.
#  before_destroy::     Do some cleanup just before destroying an object.
#  id_was::             Returns what the id was from before destroy.
#  update_view_stats::  Updates the +num_views+ and +last_view+ fields.
#  update_user_before_save_version::
#                       Callback to update 'user' when versioned record changes.
#  save_without_our_callbacks::
#                       Post changes _without_ doing
#                       the +before_update+ callback above.
#
#  ==== Error handling
#  dump_errors::        Returns errors in one big printable string.
#  formatted_errors::   Returns errors as an array of printable strings.
#
#  ==== RSS log
#  autolog_events::     Configure which events are automatically logged.
#  has_rss_log?::       Can this model take an RssLog?
#  log::                Add line to RssLog.
#  orphan_log::         Add line to RssLog before destroying object.
#  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.
#  init_rss_log::       Create and attach RssLog if not already there.
#  autolog_created::    Callback to log creation.
#  autolog_updated::    Callback to log an update.
#  autolog_destroyed::  Callback to log destruction.
#
############################################################################

class AbstractModel < ApplicationRecord
  self.abstract_class = true

  def self.acts_like_model?
    true
  end

  def acts_like_model?
    true
  end

  # Language tag for name, e.g. :observation, :rss_log, etc.
  def self.type_tag
    name.underscore.to_sym
  end

  # Language tag for name, e.g. :observation, :rss_log, etc.
  def type_tag
    self.class.name.underscore.to_sym
  end

  ##############################################################################
  #
  #  :section: Scopes
  #
  ##############################################################################

  scope :created_on, lambda { |ymd_string|
    where(arel_table[:created_at].format("%Y-%m-%d") == ymd_string)
  }
  scope :created_after, lambda { |ymd_string|
    where(arel_table[:created_at].format("%Y-%m-%d") >= ymd_string)
  }
  scope :created_before, lambda { |ymd_string|
    where(arel_table[:created_at].format("%Y-%m-%d") <= ymd_string)
  }
  scope :created_between, lambda { |earliest, latest|
    where(arel_table[:created_at].format("%Y-%m-%d") >= earliest).
      where(arel_table[:created_at].format("%Y-%m-%d") <= latest)
  }
  scope :updated_on, lambda { |ymd_string|
    where(arel_table[:updated_at].format("%Y-%m-%d") == ymd_string)
  }
  scope :updated_after, lambda { |ymd_string|
    where(arel_table[:updated_at].format("%Y-%m-%d") >= ymd_string)
  }
  scope :updated_before, lambda { |ymd_string|
    where(arel_table[:updated_at].format("%Y-%m-%d") <= ymd_string)
  }
  scope :updated_between, lambda { |earliest, latest|
    where(arel_table[:updated_at].format("%Y-%m-%d") >= earliest).
      where(arel_table[:updated_at].format("%Y-%m-%d") <= latest)
  }

  ##############################################################################
  #
  #  :section: "Find" Extensions
  #
  ##############################################################################

  # Make full clone of the present instance, then revert it to an older version.
  # Returns +nil+ if +version+ not found.
  def revert_clone(version)
    return self if self.version == version

    result = self.class.find(id)
    result = nil unless result.revert_to(version)
    result
  end

  # Look up record with given ID, returning nil if it no longer exists.
  def self.safe_find(id)
    find(id)
  rescue ActiveRecord::RecordNotFound
    nil
  end

  # Look up an object given type and id.
  #
  #   # Look up the object a comment is attached to.
  #   # (Note that in this case this is equivalent to "self.object"!)
  #   obj = Comment.find_object(self.object_type, self.object_id)
  #
  def self.find_object(type, id)
    type.classify.constantize.find(id.to_i)
  rescue NameError, ActiveRecord::RecordNotFound
    nil
  end

  # Wrap a normal SQL query in a <tt>COUNT(*)</tt> query, then pass it to
  # count_by_sql.
  #
  #   sql = "SELECT id FROM names WHERE user_id = 123"
  #   num = Name.count_by_sql_wrapping_select_query(sql)
  #
  def self.count_by_sql_wrapping_select_query(sql)
    sql = sanitize_sql(sql)
    count_by_sql("select count(*) from (#{sql}) as my_table")
  end

  # Lookup all instances matching a given wildcard pattern.  If there are no
  # "*" in the pattern, it just does a regular find_by_xxx lookup.  A number
  # of convenience wrappers are included in the major models. Returns nil if
  # none found.
  #
  #   Project.find_using_wildcards("title", "FunDiS *")
  #   Project.find_by_title_with_wildcards("FunDiS *")
  #
  def self.find_using_wildcards(col, str)
    return send(:"find_by_#{col}", str) unless str.include?("*")

    safe_col = connection.quote_column_name(col)
    matches = where("#{safe_col} LIKE ?", str.tr("*", "%"))
    matches.empty? ? nil : matches
  end

  ##############################################################################
  #
  #  :section: Callbacks
  #
  ##############################################################################

  # This is called just before an object is created.
  # 1) It fills in 'created_at' and 'user' for new records.
  # 2) And it creates a new RssLog if this model accepts one, and logs its
  #    creation.
  before_create :set_user_and_autolog
  def set_user_and_autolog
    self.user_id ||= User.current_id if respond_to?(:user_id=)
    autolog_created if has_rss_log?
  end

  # This is called just after an object is created.
  # 1) It passes off to UserStats, where it will decide whether this affects a
  #    user's contribution score, and if so update it appropriately.
  # 2) It finishes attaching the new RssLog if one exists.
  after_create :update_contribution
  def update_contribution
    UserStats.update_contribution(:add, self)
    attach_rss_log_final_step if has_rss_log?
  end

  # This is called just before an object's changes are saved.
  # 1) It passes off to UserStats, where it will decide whether this affects a
  #    user's contribution score, and if so update it appropriately.
  # 2) It updates 'updated_at' whenever a record changes.
  # 3) It saves a message to the RssLog.
  #
  # *NOTE*: Use +save_without_our_callbacks+ to save a record without doing
  # either of these things.
  before_update :do_log_update
  def do_log_update
    UserStats.update_contribution(:chg, self)
    autolog_updated if has_rss_log? && !@save_without_our_callbacks
  end

  # This would be called just after an object's changes are saved, but we have
  # no need of such a callback yet.
  # def after_update
  # end

  # This is called just before an object is destroyed.
  # 1) It passes off to UserStats, where it will decide whether this affects a
  #    user's contribution score, and if so update it appropriately.
  # 2) It orphans the old RssLog if it had one.
  # 3) It also saves the id in case we needed to know what the id was later on.
  before_destroy :do_log_destroy
  def do_log_destroy
    UserStats.update_contribution(:del, self)
    autolog_destroyed if has_rss_log? && rss_log.present?
    @id_was = id
  end

  # This would be called just after an object is destroyed, but we have no need
  # of such a callback yet.
  # def after_destroy
  # end

  # Bypass the part of the +before_save+ callback that causes 'updated_at' to be
  # updated each time a record is saved.
  def save_without_our_callbacks
    @save_without_our_callbacks = true
    save
  end

  # Clears the +@save_without_our_callbacks+ flag after save.
  after_save :clear_callback_flag
  def clear_callback_flag
    @save_without_our_callbacks = nil
  end

  # Return id from before destroy.
  attr_reader :id_was

  # Handy callback a model may choose to use that updates 'user_id' whenever a
  # versioned record changes non-trivially.
  #
  #   acts_as_versioned ...
  #   before_save :update_user_if_save_version
  #
  def update_user_if_save_version
    self.user = User.current if save_version?
  end

  # Call this whenever a User requests the show_object page for an
  # object.  It updates the +num_views+ and +last_view+ fields.
  #
  #   def show
  #     @observation = Observation.find(params[:id].to_s)
  #     @observation.update_view_stats
  #     ...
  #   end
  #
  # *NOTE*: this turns off timestamp updating for this class and avoids touching
  # any RssLog, because it uses +save_without_our_callbacks+.
  #
  # *NOTE*: this saves the old stats for the page footer of show_observation,
  # show_name, etc. otherwise the footer will always show the last view as now!
  #
  def update_view_stats
    return unless respond_to?(:num_views=) || respond_to?(:last_view=)

    @old_num_views = num_views
    @old_last_view = last_view
    self.class.record_timestamps = false
    self.num_views = (num_views || 0) + 1 if respond_to?(:num_views=)
    self.last_view = Time.zone.now        if respond_to?(:last_view=)
    save_without_our_callbacks
    self.class.record_timestamps = true
  end

  def old_num_views
    @old_num_views.to_i
  end

  attr_reader :old_last_view

  ##############################################################################
  #
  #  :section: Error Handling
  #
  ##############################################################################

  # Dump out error messages for a given instance in a single string.  Useful
  # for debugging:
  #
  #   puts user.dump_errors if Rails.env == "test"
  #
  def dump_errors
    formatted_errors.join("; ")
  end

  # This collects all the error messages for a given instance, and returns
  # them as an array of strings, e.g. for flash_notice().  If an error
  # message is a complete sentence (i.e. starts with uppercase) it does
  # nothing with it; otherwise it prepends the class and attribute like this:
  # "is missing" becomes "Object attribute is missing." Errors are created
  # via validates (magically) or by explicit calls to
  #
  #   obj.errors.add(:attr, "message").
  def formatted_errors
    out = []
    errors.each do |error|
      attribute = error.attribute
      message = error.message
      if /^[A-Z]/.match?(message)
        out << message
      else
        name = attribute.to_s.to_sym.l
        obj = type_tag.to_s.upcase_first.to_sym.l
        out << "#{obj} #{name} #{message}."
      end
    end
    out
  end

  ##############################################################################
  #
  #  :section: Show Controller / Action
  #
  ##############################################################################

  # After all controllers are normalized, consider deleting the
  # normalized/unnormalized conditionals in this method, and delete the
  # sub-methods "controller_normalized?" and "class_defined?".
  # I don't think there will be relevant special cases,
  # i.e., searchable models with singular controller names. JDC 2020-08-02
  #
  # Return the name of the controller (as a string! see below)
  # that handles the "show_<object>" for this object.
  #
  #   Article.show_controller => :articles # for normalized controller
  #
  #   Name.show_controller => :name
  #
  # NOTE: `show_controller` MUST string-interpolate the controller name after
  # a leading forward slash, in order to explicitly specify a "top level"
  # controller. Took me a year to learn again what Joe learned two years ago:
  # Without the forward slash, requests from a nested controller will assume the
  # same nesting. It's very confusing to debug, and almost never what you want.
  #
  # Because of this misleading specificity, I'd like to move away from
  # `show_controller`, and methods composing paths by controller/action args,
  # in favor of Rails explicit path helpers as drawn by routes, but in some
  # cases `show_controller` is practical - some actions handle a polyvalent
  # object whose path cannot be easily interpolated. - AN 10/2022
  #
  def self.show_controller
    "/#{name.pluralize.underscore}" # Rails standard for most controllers
  end

  def show_controller
    self.class.show_controller
  end

  # Has controller been normalized to Rails 6.0 standards:
  #  plural controller name, CRUD action names standardized if they exist
  def self.controller_normalized?
    class_defined?("#{name.pluralize}Controller")
  end

  # stackoverflow.com/questions/45436514/ruby-check-if-controller-defined
  def self.class_defined?(klass)
    Object.const_get(klass)
  rescue StandardError
    false
  end

  # Return the name of the "index_<object>" action (as a symbol)
  # that displays search index for this object.
  #
  #   Article.index_action => :index # normalized controller
  #   Name.index_action => :index # unormalized
  #
  # WARNING.
  # 1. There is no standard Rails action name for displaying a **search** index.
  # 2. Some old MO object classes are not searchable, and thus
  #    lack an action that displays a **search** index.
  # 3. The Rails standard "index" lists **all** objects. And the
  #    corresponding old MO action that lists all objects is "list".
  # 4. Many old MO object classes have > 1 action that produce indices, BUT
  #    some object classes did not have a "list" action
  # So for "normalized" controllers.
  #   Best: each such controller has just one index action.
  #   Otherwise, perhaps define "index_action" in the individual object class.
  # JDC 2021-01-14
  def self.index_action
    :index
  end

  def index_action
    self.class.index_action
  end

  # Return the link_to args of the "index_<object>" action
  # (the index, indexed to a particular id)
  #
  #   Name.index_link_args(12) => {controller: "/names", action: :index,
  #                                id: 12}
  #   name.index_link_args     => {controller: "/names", action: :index,
  #                                id: 12}
  #
  def self.index_link_args(id)
    { controller: show_controller, action: index_action, id: id }
  end

  def index_link_args
    self.class.index_link_args(id)
  end

  # Return the name of the "show_<object>" action (as a symbol)
  # that displays this object.
  #
  #   Article.show_action => :show # normalized controller
  #
  #   Name.show_action => :show_name # unnormalized
  #   name.show_action => :show_name
  #
  def self.show_action
    :show
  end

  def show_action
    self.class.show_action
  end

  # Return the URL of the "show_<object>" action (as a string)
  #
  #   # normalized controller
  #   Article.show_url(12) => "https://mushroomobserver.org/articles/12"
  #
  #   # unnormalized controller
  #   Name.show_url(12) => "https://mushroomobserver.org/names/12"
  #   name.show_url     => "https://mushroomobserver.org/names/12"
  #
  # NOTE: show_controller now has leading forward slash,
  # to account for namespacing
  #
  def self.show_url(id)
    "#{MO.http_domain}#{show_controller}/#{id}"
  end

  def show_url
    self.class.show_url(id)
  end

  # Return the link_to args of the "show_<object>" action
  #
  #   Name.show_link_args(12) => {controller: "/names", action: :show,
  #                               id: 12}
  #   name.show_link_args     => {controller: "/names", action: :show,
  #                               id: 12}
  #
  def self.show_link_args(id)
    { controller: show_controller, action: show_action, id: id }
  end

  def show_link_args
    self.class.show_link_args(id)
  end

  # Return the URL for the EOL resource corresponding to this object.
  #
  #   name.eol_url => "http://eol.org/blah/blah/blah"
  #
  def eol_url
    triple = Triple.find_by(subject: show_url, predicate: eol_predicate)
    triple&.object
  end

  def self.eol_predicate
    ":eol#{name}"
  end

  def eol_predicate
    self.class.eol_predicate
  end

  ##############################################################################
  #
  #  :section: Edit Controller / Action
  #
  ##############################################################################

  def self.edit_controller
    show_controller
  end

  def edit_controller
    show_controller
  end

  # Return the name of the "edit_<object>" action (as a simple
  # lowercase string) that displays this object.
  def self.edit_action
    :edit
  end

  def edit_action
    self.class.edit_action
  end

  # Return the URL of the "edit_<object>" action
  #
  #   Name.edit_url(12) => "https://mushroomobserver.org/names/12/edit"
  #   name.edit_url     => "https://mushroomobserver.org/names/12/edit"
  #
  def self.edit_url(id)
    "#{MO.http_domain}/#{edit_controller}/#{id}/#{edit_action}"
  end

  def edit_url
    self.class.edit_url(id)
  end

  # Return the link_to args of the "edit_<object>" action
  #
  #   Name.edit_link_args(12) => {controller: "/names", action: :edit, id: 12}
  #   name.edit_link_args     => {controller: "/names", action: :edit, id: 12}
  #
  def self.edit_link_args(id)
    { controller: edit_controller, action: edit_action, id: id }
  end

  def edit_link_args
    self.class.edit_link_args(id)
  end

  ##############################################################################
  #
  #  :section: Destroy Controller / Action
  #
  ##############################################################################

  def self.destroy_controller
    show_controller
  end

  def destroy_controller
    show_controller
  end

  # Return the name of the "destroy_<object>" action (as a symbol)
  # that displays this object.
  #
  #   Article.destroy_action => :destroy
  #   Name.destroy_action => "destroy_name"
  #
  def self.destroy_action
    :destroy
  end

  def destroy_action
    self.class.destroy_action
  end

  # Return the URL of the "destroy_<object>" action.
  # For CRUD, must pass method: :delete or use destroy_button helper
  #
  #   Name.destroy_url(12) => "https://mushroomobserver.org/names/12"
  #   name.destroy_url     => "https://mushroomobserver.org/names/12"
  #
  def self.destroy_url(id)
    "#{MO.http_domain}/#{destroy_controller}/#{id}"
  end

  def destroy_url
    self.class.destroy_url(id)
  end

  # Return the link_to args of the "destroy_<object>" action
  #
  #   Name.destroy_link_args(12) =>
  #     {controller: "/names", action: :destroy, id: 12}
  #   name.destroy_link_args     =>
  #     {controller: "/names", action: :destroy, id: 12}
  #
  def self.destroy_link_args(id)
    { controller: destroy_controller, action: destroy_action, id: id }
  end

  def destroy_link_args
    self.class.destroy_link_args(id)
  end

  ##############################################################################
  #
  #  :section: RSS Log
  #
  ##############################################################################

  # By default do NOT automatically log creation/update/destruction.  Override
  # this with an array of zero or more of the following:
  # * :created -- automatically log creation
  # * :created! -- automatically log creation and raise to top of RSS feed
  # * :updated -- automatically log updates
  # * :updated! -- automatically log updates and raise to top of RSS feed
  # * :destroyed -- automatically log destruction
  # * :destroyed! -- automatically log destruction and raise to top of RSS feed
  class_attribute :autolog_events
  self.autolog_events = []

  # Is this model capable of attaching an RssLog?
  def self.has_rss_log?
    !!reflect_on_association(:rss_log)
  end

  # Is this model capable of attaching an RssLog?
  def has_rss_log?
    !!self.class.reflect_on_association(:rss_log)
  end

  # Add message to RssLog, creating one if necessary.
  #
  #    # Log that it was changed by @user, and "touch" the log so it appears
  #    # at the top of the RSS feed.
  #    obj.log(:log_observation_updated)
  #
  def log(tag, args = {})
    init_rss_log unless rss_log
    touch_when_logging unless new_record? ||
                              args[:touch] == false
    rss_log.add_with_date(tag, args)
  end

  # This allows a model to override touch in this context only, e.g.,
  # Observation caches a log_updated_at value so the activity index doesn't
  # have to do a join to rss_logs
  def touch_when_logging
    touch
  end

  # Add message to RssLog if you're about to destroy this object, creating new
  # RssLog if necessary.
  #
  #   # Log destruction of an Observation (can be destroyed already I think).
  #   orphan_log(:log_observation_destroyed)
  #
  def orphan_log(*)
    rss_log = init_rss_log(orphan: true)
    rss_log.orphan(format_name, *)
  end

  # Callback that logs creation.
  def autolog_created
    autolog_event(:created)
  end

  # Callback that logs update.
  def autolog_updated
    autolog_event(:updated)
  end

  # Callback that logs destruction.
  def autolog_destroyed
    autolog_event(:destroyed, orphan: true)
  end

  # Do we log this event? and how?
  def autolog_event(event, orphan: nil)
    return unless RunLevel.is_normal?

    if autolog_events.include?(event)
      touch = false
    elsif autolog_events.include?(:"#{event}!")
      touch = true
    else
      return
    end

    type = type_tag
    msg = :"log_#{type}_#{event}"
    orphan ? orphan_log(msg, touch: touch) : log(msg, touch: touch)
  end

  # Create RssLog and attach it if we don't already have one.  This is
  # primarily for the benefit of old objects that don't have RssLog's already.
  # All new objects automatically get one.
  def init_rss_log(orphan: false)
    return rss_log if rss_log

    rss_log = RssLog.new
    rss_log.created_at = created_at unless new_record?
    rss_log.send(:"#{type_tag}_id=", id) if id && !orphan
    rss_log.save
    attach_rss_log_first_step(rss_log) unless orphan
    rss_log
  end

  # Point object to its new RssLog and save the object unless we are sure
  # it will be saved later.
  def attach_rss_log_first_step(rss_log)
    will_save_later = new_record? || changed?
    self.rss_log_id = rss_log.id
    self.rss_log    = rss_log
    save unless will_save_later
  end

  # Fill in reverse-lookup id in RssLog after creating new record.
  def attach_rss_log_final_step
    return unless rss_log && (rss_log.send(:"#{type_tag}_id") != id)

    rss_log.send(:"#{type_tag}_id=", id)
    rss_log.save
  end

  # Add a note to the notes field with paragraph break between different notes.
  def add_note(note)
    self.notes = notes.present? ? "\n\n#{note}" : note
    save
  end

  def can_edit?(user = User.current)
    !respond_to?(:user) || (user && (self.user_id == user.id))
  end

  def string_with_id(str)
    id_str = id || "?"
    str + " (#{id_str})"
  end

  ##############################################################################
  #
  #  :section: versions
  #
  ##############################################################################

  # Replacement for "altered?"" method of cures_acts_as_versioned gem
  # The gem method is incompatible with Rails 2.2, and the gem is not maintained
  # TODO: replace the gem.
  # See notes at https://www.pivotaltracker.com/story/show/163189614
  def saved_version_changes?
    track_altered_attributes ? (version_if_changed - saved_changes.keys).length < version_if_changed.length : saved_changes? # rubocop:disable Layout/LineLength
  end
end