Files
growstuff/app/models/crop.rb
T
Brenda Wallace 1701f77723 Add OCR task for seed packet photos and related specs
- Introduced a Rake task `packets:needing_ocr` to list photos of seed packets that lack text, outputting results as JSON lines.
- Created a Python script `read_packets.py` to process the output of the Rake task, downloading images and performing OCR to extract text.
- Updated model specs for `Crop` to ensure proper handling of photos associated with different seeds.
- Refactored API request specs for `Photos` and `Seeds`, removing unnecessary attributes from the response.
- Added tests for the new Rake task to ensure correct functionality and output format.
2026-10-28 20:23:35 +13:00

240 lines
7.7 KiB
Ruby

# 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