# frozen_string_literal: true class Garden < ApplicationRecord extend FriendlyId include Geocodable include PhotoCapable include Ownable friendly_id :garden_slug, use: %i(slugged finders) has_many :plantings, dependent: :destroy has_many :crops, through: :plantings has_many :jobs, dependent: :destroy has_many :planting_histories, dependent: :destroy has_many :garden_collaborators, dependent: :destroy belongs_to :garden_type, optional: true # set up geocoding geocoded_by :location before_validation :strip_blanks after_validation :cleanup_area after_validation :geocode, if: :will_save_change_to_location? after_validation :empty_unwanted_geocodes, if: :will_save_change_to_location? after_validation :populate_wikidata_info, if: :will_save_change_to_location? after_save :mark_inactive_garden_plantings_as_finished scope :active, -> { where(active: true) } scope :inactive, -> { where(active: false) } scope :nurseries, -> { where(nursery: true) } scope :not_nurseries, -> { where(nursery: false) } scope :order_by_name, -> { order(Arel.sql("lower(name) asc")) } validates :location, length: { maximum: 255 } validates :slug, uniqueness: true validates :nursery, uniqueness: { scope: :owner_id, message: :one_nursery_per_member }, if: :nursery? validates :name, uniqueness: { scope: :owner_id } validates :name, format: { without: /\n/, message: :no_newlines }, allow_blank: false, presence: true, length: { maximum: 255 } validates :area, numericality: { only_integer: false, greater_than_or_equal_to: 0 }, allow_nil: true # The layout grid. Capped so a bed's map stays a sensible number of cells to # draw, and so shrinking it can't quietly strand a planting off the edge. MAX_GRID_SIZE = 50 validates :grid_columns, :grid_rows, numericality: { only_integer: true, greater_than_or_equal_to: 1, less_than_or_equal_to: MAX_GRID_SIZE } validate :grid_must_not_cut_off_plantings # The things on the layout that aren't plants. Each is a hash with a kind and a # position in grid cells (x, y). A line (a row, path, drip line, fence or # trellis) runs from there to (x2, y2), and an area (netting) is the rectangle # with those two corners. A label has text, and lines and areas can. LAYOUT_POINT_KINDS = %w(stone sprinkler tap stake label).freeze LAYOUT_SPAN_KINDS = %w(row path dripline fence trellis netting).freeze LAYOUT_FEATURE_KINDS = (LAYOUT_POINT_KINDS + LAYOUT_SPAN_KINDS).freeze MAX_LAYOUT_FEATURES = 200 MAX_LAYOUT_TEXT = 40 validate :layout_features_must_fit scope :located, lambda { where.not(gardens: { location: '' }) .where.not(gardens: { latitude: nil }) .where.not(gardens: { longitude: nil }) } AREA_UNITS_VALUES = { "square metres" => "square metre", "square feet" => "square foot", "hectares" => "hectare", "acres" => "acre" }.freeze validates :area_unit, inclusion: { in: AREA_UNITS_VALUES.values, message: :not_a_valid_area_unit }, allow_blank: true def cleanup_area self.area = nil if area&.zero? self.area_unit = nil if area.blank? end def garden_slug "#{owner.login_name}-#{name}".downcase.tr(' ', '-') end def to_s name end # When you mark a garden as inactive, all the plantings in it should be # marked as finished. This automates that. def mark_inactive_garden_plantings_as_finished return unless active == false plantings.current.each do |p| p.finished = true p.save end end # Makes this the owner's nursery. They can only have one, so any garden they # had marked as the nursery before goes back to being an ordinary garden. def make_nursery! transaction do self.class.where(owner_id:, nursery: true).where.not(id:).update_all(nursery: false) # rubocop:disable Rails/SkipsModelValidations update!(nursery: true) owner.accept_nursery! end end # Deactivate any gardens with no active plantings def self.archive!(time_limit: 3.years.ago, limit: 1000) Garden.active.not_nurseries.where("gardens.updated_at < ?", time_limit).order(updated_at: :asc).limit(limit).each do |active_garden| unless active_garden.plantings.active.any? active_garden.active = false active_garden.save end end end def populate_wikidata_info return false if location.blank? wd_id = WikidataService.find_wikidata_id(location) return false if wd_id.blank? self.location_wikidata_id = wd_id temps = WikidataService.fetch_temps(wd_id) self.highest_temp_c = temps[:highest_temp_c] self.lowest_temp_c = temps[:lowest_temp_c] true end # How many cells the bed's layout grid has, for callers that just want the size. def grid_cells grid_columns * grid_rows end def has_layout? layout_saved_at.present? || placed_plants.any? || layout_features.present? end # What's wrong with a list of layout features, if anything, as messages. Used # by the layout's save as well as by validation, so a drag can be checked # without a full save (which would geocode the garden every time). def layout_feature_problems(features, columns: grid_columns, rows: grid_rows) return ['The garden features must be a list'] unless features.is_a?(Array) return ["A bed can have at most #{MAX_LAYOUT_FEATURES} garden features"] if features.size > MAX_LAYOUT_FEATURES features.filter_map { |feature| layout_feature_problem(feature, columns, rows) }.uniq end # The plants currently drawn on this bed's layout. def placed_plants Plant.placed.where(planting_id: plantings.current.select(:id)) end # Every plant of every planting growing here, placed or not. What the layout # page may rearrange. def placed_or_owned_plants Plant.where(planting_id: plantings.current.select(:id)) end # Gives each planting growing here its plants, the first time the layout is # used, rather than making them for every planting whether or not it ever # goes on a layout. Plantings that already have plants are left alone, and # so is one composted down to none. All in one insert. def prepare_layout now = Time.current rows = plantings.current.where.not(id: Plant.select(:planting_id)).flat_map do |planting| Array.new(planting.plant_count) { { planting_id: planting.id, created_at: now, updated_at: now } } end Plant.insert_all(rows) if rows.any? # rubocop:disable Rails/SkipsModelValidations end protected def strip_blanks self.name = name.strip unless name.nil? end # Shrinking the grid must not leave a placed plant hanging over the edge, # which would otherwise only show up as a plant missing from the map. def grid_must_not_cut_off_plantings return if new_record? || grid_columns.blank? || grid_rows.blank? return unless will_save_change_to_grid_columns? || will_save_change_to_grid_rows? return if placed_plants.outside_grid(grid_columns, grid_rows).none? && layout_feature_problems(layout_features).empty? errors.add(:base, :grid_too_small_for_plantings) end def layout_features_must_fit return unless will_save_change_to_layout_features? layout_feature_problems(layout_features).each { |problem| errors.add(:base, problem) } end def layout_feature_problem(feature, columns, rows) known = feature.is_a?(Hash) && LAYOUT_FEATURE_KINDS.include?(feature['kind']) return "That isn't something that can go on the bed" unless known ends = [%w(x y)] ends << %w(x2 y2) if LAYOUT_SPAN_KINDS.include?(feature['kind']) return 'A garden feature is off the bed' unless ends.all? { |col, row| on_bed?(feature[col], feature[row], columns, rows) } text = feature['text'].to_s return 'A label needs something written on it' if feature['kind'] == 'label' && text.strip.empty? "A label or row name can be at most #{MAX_LAYOUT_TEXT} characters" if text.length > MAX_LAYOUT_TEXT end def on_bed?(col, row, columns, rows) col.is_a?(Numeric) && row.is_a?(Numeric) && col.between?(0, columns) && row.between?(0, rows) end end