# frozen_string_literal: true class Crop < ApplicationRecord has_paper_trail extend FriendlyId include PhotoCapable include OpenFarmData include GbifData include SearchCrops include Jobbable friendly_id :name, use: %i(slugged finders) ## ## Relationships belongs_to :creator, class_name: 'Member', optional: true, inverse_of: :created_crops belongs_to :requester, class_name: 'Member', optional: true, inverse_of: :requested_crops belongs_to :parent, class_name: 'Crop', optional: true, inverse_of: :varieties has_many :scientific_names, dependent: :delete_all has_many :alternate_names, dependent: :delete_all has_many :plantings, dependent: :destroy has_many :seeds, dependent: :destroy has_many :harvests, dependent: :destroy has_many :photo_associations, dependent: :delete_all, inverse_of: :crop has_many :photos, -> { where.not(id: PhotoAssociation.of_packets.select(:photo_id)) }, through: :photo_associations has_many :plant_parts, -> { joins_members.distinct.order("plant_parts.name") }, through: :harvests has_many :varieties, class_name: 'Crop', foreign_key: 'parent_id', dependent: :nullify, inverse_of: :parent has_many :crop_companions, foreign_key: :crop_a_id, dependent: :delete_all, inverse_of: :crop_a has_many :companions, through: :crop_companions, source: :crop_b, class_name: 'Crop' has_many :crop_job_templates, dependent: :destroy has_many :crop_posts, dependent: :delete_all has_many :posts, through: :crop_posts, dependent: :delete_all has_one :australian_food_classification_data, foreign_key: :public_food_key, primary_key: :public_food_key, inverse_of: :crop accepts_nested_attributes_for :scientific_names, allow_destroy: true, reject_if: :all_blank ## ## Scopes scope :recent, -> { approved.order(created_at: :desc) } scope :toplevel, -> { approved.where(parent_id: nil) } scope :popular, -> { approved.order(Arel.sql("plantings_count desc, lower(name) asc")) } scope :pending_approval, -> { where(approval_status: "pending") } scope :approved, -> { where(approval_status: "approved") } scope :rejected, -> { where(approval_status: "rejected") } scope :interesting, -> { approved.has_photos } scope :has_photos, -> { includes(:photos).where.not(photos: { id: nil }) } scope :joins_members, -> { joins("INNER JOIN members ON members.id = harvests.owner_id") } ## ## Validations # Reasons are only necessary when rejecting validates :reason_for_rejection, presence: true, if: :rejected? validate :must_be_rejected_if_rejected_reasons_present validate :must_have_meaningful_reason_for_rejection ## Wikipedia urls are only necessary when approving a crop validates :en_wikipedia_url, format: { with: %r{\Ahttps?://en\.wikipedia\.org/wiki/[[:alnum:]%_.()-]+\z}, message: :not_a_valid_wikipedia_url }, if: :approved? validates :en_youtube_url, format: { with: %r{\A(?:https?://)?(?:www\.)?(?:youtube(?:-nocookie)?\.com/(?:(?:v|e(?:mbed)?)/|\S*?[?&]v=)|youtu\.be/)[a-zA-Z0-9_-]{11}(?:[?&]\S*)?\z}, message: :not_a_valid_youtube_url }, allow_blank: true validates :name, uniqueness: { scope: :approval_status }, if: :pending? validates :default_diameter, allow_nil: true, numericality: { greater_than: 0, less_than_or_equal_to: Plant::MAX_DIAMETER } validates :icon, allow_blank: true, inclusion: { in: ->(_) { icon_names } } # Crop icons that ship with the app: Microsoft's Fluent Emoji (MIT licensed; # see COPYRIGHT there), one per food plant or flower they draw. ICON_DIR = Rails.root.join('app/assets/images/crops') def self.icon_names @icon_names ||= Dir[ICON_DIR.join('*.svg')].map { |path| File.basename(path, '.svg') }.sort end # Read once and kept, as crop chips ask for these a lot. def self.icon_svg(name) @icon_svgs ||= {} @icon_svgs[name] ||= ICON_DIR.join("#{name}.svg").read end def to_s name end # How many grid cells across one of these plants is drawn on a bed's layout. # Falls back to the parent crop's, as the icon does, so a variety of tomato # follows tomato unless it has its own. def layout_diameter default_diameter || parent&.layout_diameter end # This crop's icon as SVG: the one chosen for it, else the one OpenFarm had, # else its parent crop's, so a variety of tomato gets the tomato. Nil when # none of those has one; crops#show then serves a sprout. def svg_icon return self.class.icon_svg(icon) if icon.present? openfarm_svg_icon.presence || parent&.svg_icon end def to_param slug end def default_scientific_name scientific_names.first end # returns hash indicating whether this crop is grown in # sun/semi-shade/shade # key: sunniness (eg. 'sun') # value: count of how many times it's been used by plantings def sunniness count_uses_of_property 'sunniness' end # returns a hash of propagation methods (seed, seedling, etc), # key: propagation method (eg. 'seed') # value: count of how many times it's been used by plantings def planted_from count_uses_of_property 'planted_from' end # returns a hash of most harvested plant parts (fruit, seed, etc) # key: plant part (eg. 'fruit') # value: count of how many times it's been used by harvests def popular_plant_parts PlantPart.joins(:harvests) .where("crop_id = ?", id) .order(count_harvests_id: :desc) .group("plant_parts.id", "plant_parts.name") .count("harvests.id") end def perennial? perennial == true end def annual? perennial == false end def interesting? photos.size >= 3 || plantings_count >= 3 end def pending? approval_status == "pending" end def approved? approval_status == "approved" end def rejected? approval_status == "rejected" end def approval_statuses %w(rejected pending approved) end def reasons_for_rejection ["already in database", "not edible", "not enough information", "other"] end def rejection_explanation return rejection_notes if reason_for_rejection == "other" reason_for_rejection end def update_medians plantings.each(&:update_harvest_days!) update_lifespan_medians update_harvest_medians end def update_lifespan_medians # Median lifespan of plantings update(median_lifespan: Planting.where(crop: self).median(:lifespan)) end def update_harvest_medians update(median_days_to_first_harvest: Planting.where(crop: self).median(:days_to_first_harvest)) update(median_days_to_last_harvest: Planting.where(crop: self).median(:days_to_last_harvest)) end def self.case_insensitive_name(name) where(["lower(crops.name) = :value", { value: name.downcase }]) end def all_companions @all_companions ||= if parent (companions + parent.all_companions).uniq else companions end end before_destroy :destroy_reverse_companionships private def destroy_reverse_companionships CropCompanion.where(crop_b: self).destroy_all end def count_uses_of_property(col_name) plantings.unscoped .where(crop_id: id) .where.not(col_name => nil) .group(col_name) .count end def must_be_rejected_if_rejected_reasons_present return if rejected? return unless reason_for_rejection.present? || rejection_notes.present? errors.add(:approval_status, :rejection_reason_required) end def must_have_meaningful_reason_for_rejection return unless reason_for_rejection == "other" && rejection_notes.blank? errors.add(:rejection_notes, :rejection_notes_required) end end