mirror of
https://github.com/Growstuff/growstuff.git
synced 2026-10-31 03:09:06 -04:00
Keep every packet label when a side is photographed again, and use the newest photo of a side. Before, giving a second photo the same label cleared the older photo's, which let it back into the crop's photos and default picture. Carry the label through the HTML photo picker (its search form and the links that add a photo), so a packet photo added without JavaScript is labelled. Have the OCR script treat any network failure on an image (DNS, timeout) as that image failing, so the rest of the batch still runs. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
172 lines
6.7 KiB
Ruby
172 lines
6.7 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class Seed < ApplicationRecord
|
|
extend FriendlyId
|
|
include PhotoCapable
|
|
include Finishable
|
|
include Ownable
|
|
include Jobbable
|
|
|
|
friendly_id :seed_slug, use: %i(slugged finders)
|
|
|
|
TRADABLE_TO_VALUES = %w(nowhere locally nationally internationally).freeze
|
|
ORGANIC_VALUES = ['certified organic', 'non-certified organic', 'conventional/non-organic', 'unknown'].freeze
|
|
GMO_VALUES = ['certified GMO-free', 'non-certified GMO-free', 'GMO', 'unknown'].freeze
|
|
HEIRLOOM_VALUES = %w(heirloom hybrid unknown).freeze
|
|
SOURCE_VALUES = ['seed catalogue', 'retail outlet', 'seed bank or similar institution',
|
|
'traded from another person', 'my own seed saving', 'other/unknown'].freeze
|
|
# A seed packet dated outside this is a typo, not a plan.
|
|
PLANT_BEFORE_YEARS = (1900..2100)
|
|
|
|
#
|
|
# Relationships
|
|
belongs_to :crop
|
|
belongs_to :parent_planting, class_name: 'Planting',
|
|
optional: true, inverse_of: :child_seeds # parent
|
|
has_many :child_plantings, class_name: 'Planting',
|
|
foreign_key: 'parent_seed_id', dependent: :nullify,
|
|
inverse_of: :parent_seed # children
|
|
|
|
#
|
|
# Validations
|
|
validates :crop, approved: true
|
|
validates :crop, presence: { message: :crop_not_found }
|
|
validates :quantity, allow_nil: true,
|
|
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
|
validates :days_until_maturity_min, allow_nil: true,
|
|
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
|
validates :days_until_maturity_max, allow_nil: true,
|
|
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
|
validates :row_spacing, allow_nil: true,
|
|
numericality: { only_integer: true, greater_than: 0 }
|
|
validates :variety, :batch_number, length: { maximum: 100 }
|
|
validates :packet_text, length: { maximum: 20_000 }
|
|
validate :plant_before_year_is_a_year
|
|
validates :tradable_to, allow_blank: false,
|
|
inclusion: { in: TRADABLE_TO_VALUES, message: :tradable_to_inclusion }
|
|
validates :organic, allow_blank: false,
|
|
inclusion: { in: ORGANIC_VALUES, message: :organic_inclusion }
|
|
validates :gmo, allow_blank: false,
|
|
inclusion: { in: GMO_VALUES, message: :gmo_inclusion }
|
|
validates :heirloom, allow_blank: false,
|
|
inclusion: { in: HEIRLOOM_VALUES, message: :heirloom_inclusion }
|
|
validates :source, allow_blank: true,
|
|
inclusion: { in: SOURCE_VALUES, message: :source_inclusion }
|
|
|
|
#
|
|
# Photos
|
|
# The photo of one side of the packet, front or back, if there is one. When a
|
|
# side has been photographed again, the newest photo. The older one keeps its
|
|
# label, as it is still a photo of a packet, and not of the crop.
|
|
def packet_photo(side)
|
|
photo_associations.where(label: side).order(:id).last&.photo
|
|
end
|
|
|
|
# Which side of the packet a photo of this seed is of, if it is of one. Uses
|
|
# the loaded photo associations, so a page of photos reads them once.
|
|
def label_of(photo)
|
|
photo_associations.find { |association| association.photo_id == photo.id }&.label
|
|
end
|
|
|
|
# The packet's front, if there is a photo of it. Otherwise the most liked
|
|
# photo that is not of the back.
|
|
def default_photo
|
|
packet_photo('front') || most_liked_photo
|
|
end
|
|
|
|
def most_liked_photo
|
|
photos.merge(PhotoAssociation.where(label: [nil, 'front'])).order(likes_count: :desc, created_at: :desc).first
|
|
end
|
|
|
|
#
|
|
# Delegations
|
|
delegate :name, to: :crop, prefix: true
|
|
delegate :location, :latitude, :longitude, to: :owner
|
|
delegate :login_name, :slug, :location, to: :owner, prefix: true
|
|
|
|
#
|
|
# Scopes
|
|
default_scope { joins(:owner).merge(Member.kept) } # Ensure owner exists
|
|
scope :tradable, -> { where.not(tradable_to: 'nowhere') }
|
|
scope :interesting, -> { tradable.has_location }
|
|
scope :has_location, -> { joins(:owner).where.not('members.location': nil) }
|
|
scope :recent, -> { order(created_at: :desc) }
|
|
scope :active, -> { where('finished <> true').where('finished_at IS NULL OR finished_at < ?', Time.zone.now) }
|
|
# Where seeds came from, as the filter chips name it.
|
|
SOURCES_BY_KIND = {
|
|
'own' => ['my own seed saving'],
|
|
'bought' => ['seed catalogue', 'retail outlet', 'seed bank or similar institution'],
|
|
'traded' => ['traded from another person']
|
|
}.freeze
|
|
scope :from_source, ->(kind) { where(source: SOURCES_BY_KIND.fetch(kind)) }
|
|
scope :heirlooms, -> { where(heirloom: 'heirloom') }
|
|
|
|
# Seeds of a crop and of its varieties: choosing tomato finds Brandywine
|
|
# tomato seeds too.
|
|
scope :of_crop, ->(crop) { where(crop_id: Crop.where(id: crop.id).or(Crop.where(parent_id: crop.id)).select(:id)) }
|
|
scope :expired, -> { active.where('plant_before < ?', Time.zone.today) }
|
|
|
|
# "Plant before" only ever means a season, not a day: seed keeps until the
|
|
# end of the year it is dated to. So members give the year alone, and it is
|
|
# stored as the last day of that year — the column stays a date, and the
|
|
# `expired` scope goes on working unchanged.
|
|
def plant_before_year
|
|
# What was typed, when it was not a year, so the member sees their own
|
|
# input beside the error rather than an emptied field.
|
|
@bad_plant_before_year || plant_before&.year
|
|
end
|
|
|
|
def plant_before_year=(year)
|
|
given = year.to_s.strip
|
|
@bad_plant_before_year = nil
|
|
if given.blank?
|
|
self.plant_before = nil
|
|
elsif given.match?(/\A\d{4}\z/) && PLANT_BEFORE_YEARS.cover?(given.to_i)
|
|
self.plant_before = Date.new(given.to_i, 12, 31)
|
|
else
|
|
# Kept so the member sees what they typed, with the error, instead of an
|
|
# emptied field.
|
|
@bad_plant_before_year = given
|
|
self.plant_before = nil
|
|
end
|
|
end
|
|
|
|
def tradable
|
|
tradable_to != 'nowhere'
|
|
end
|
|
|
|
# Whether the year to sow it by has gone.
|
|
def past_sow_by?
|
|
plant_before.present? && plant_before < Time.zone.today
|
|
end
|
|
|
|
# 'heirloom' or 'hybrid', or nil when it isn't known.
|
|
def heirloom_kind
|
|
heirloom unless heirloom == 'unknown'
|
|
end
|
|
|
|
def seed_slug
|
|
"#{owner.login_name}-#{crop}".downcase.tr(' ', '-')
|
|
end
|
|
|
|
def to_s
|
|
I18n.t('seeds.string', crop: crop.name, owner:)
|
|
end
|
|
|
|
def self.homepage_records(limit)
|
|
current.tradable
|
|
.where("plant_before IS NULL OR plant_before < ?", Time.zone.today)
|
|
.order(created_at: :desc)
|
|
.limit(limit)
|
|
end
|
|
|
|
private
|
|
|
|
def plant_before_year_is_a_year
|
|
return if @bad_plant_before_year.blank?
|
|
|
|
errors.add(:plant_before_year,
|
|
"must be a year between #{PLANT_BEFORE_YEARS.first} and #{PLANT_BEFORE_YEARS.last}")
|
|
end
|
|
end
|