Files

308 lines
12 KiB
Ruby

# frozen_string_literal: true
class GardensController < DataController
# Anyone can look at a member's inactive gardens, as they can their current ones.
# A garden's layout is public in the same way its show page is.
skip_before_action :authenticate_member!, only: %i(inactive layout)
skip_load_and_authorize_resource only: :inactive
def index
@owner = Member.find_by!(slug: params[:member_slug]) if params[:member_slug].present?
@gardens = @gardens.includes(:owner)
@gardens = @gardens.active
@own_gardens = @owner.present? && @owner == current_member
if @owner.present?
@nursery = @owner.nursery
@gardens = @gardens.not_nurseries # shown at the top instead
@gardens = @gardens.left_joins(:garden_collaborators)
@gardens = @gardens.where(owner: @owner).or(@gardens.where(garden_collaborators: { member: @owner }))
end
@gardens = @gardens.where.not(members: { confirmed_at: nil })
.order(:name).paginate(page: params[:page])
respond_with(@gardens)
end
# A member's gardens that are no longer active, as a gallery of what they grew.
def inactive
@owner = Member.confirmed.find_by!(slug: params[:member_slug])
owned = Garden.inactive.where(owner: @owner)
collaborating = Garden.inactive.where(id: GardenCollaborator.where(member: @owner).select(:garden_id))
@gardens = owned.or(collaborating)
.includes(:owner, plantings: { crop: { parent: :parent } })
.order(updated_at: :desc)
.paginate(page: params[:page], per_page: 12)
end
def show
@current_plantings = @garden.plantings.current.where.not(failed: true).includes(:crop, :owner).order(planted_at: :desc)
load_jobs
@finished_plantings = @garden.plantings.finished.includes(:crop)
@suggested_companions = Crop.approved.where(
id: CropCompanion.where(crop_a_id: @current_plantings.select(:crop_id)).select(:crop_b_id)
).order(:name)
@photos = @garden.photos.includes(:owner).order(created_at: :desc, id: :desc).paginate(page: params[:page])
@suggestion_count = MemberDashboard.new(current_member).easy_to_sow(40).size if @garden.active && @garden.owner == current_member
respond_with(@garden)
end
# A map of where things are planted in the bed. Anyone who can see the garden
# can see its layout; only the owner and collaborators can rearrange it.
def layout
@owner = @garden.owner
@editable = can?(:update_layout, @garden)
# Someone who can arrange the bed is here to use the layout, so this is when
# its plantings get their plants. Just looking doesn't make any.
@garden.prepare_layout if @editable
@layout = GardenLayoutSerializer.new(@garden, editable: @editable, resizable: can?(:update, @garden)).as_json
# The island reloads this after planting something new, since the planting
# form answers with a garden card rather than a layout.
render json: @layout if request.format.json?
end
# Saves the whole arrangement in one go, rather than a request per drag.
#
# Every plant is taken off the grid before the new positions are applied, so
# any plant the request leaves out ends up off the bed. A single bad placement
# rolls the whole thing back, leaving the bed as it was rather than half
# rearranged.
#
# A planting behaves like a stack: dragging one off it puts a plant on the
# bed, and the planting's quantity follows from how many plants it ends up
# with rather than being typed in. Composting a plant is the way back down.
def update_layout
@layout_errors = {}
placements = layout_params
composted = compost_params
features = features_params
saved = Garden.transaction do
# In case something was planted since the page was opened.
@garden.prepare_layout
compost(composted)
clear_positions
apply_placements(placements)
apply_features(features) unless features.nil?
raise ActiveRecord::Rollback if @layout_errors.present?
sync_quantities
@garden.update_column(:layout_saved_at, Time.current) # rubocop:disable Rails/SkipsModelValidations
true
end
return render json: { errors: @layout_errors }, status: :unprocessable_content unless saved
render json: GardenLayoutSerializer.new(@garden.reload, editable: true, resizable: can?(:update, @garden)).as_json
end
def new
@garden = Garden.new
return render json: GardenFormSerializer.new(@garden, member: current_member) if request.format.json?
respond_with(@garden)
end
def edit
return render json: GardenFormSerializer.new(@garden, member: current_member) if request.format.json?
respond_with(@garden)
end
def create
@garden.owner_id = current_member.id
return render_card_json(@garden.save) if request.format.json?
if @garden.save
link = new_job_path(name: 'Weed the garden bed', garden_id: @garden.id, due_date: 2.weeks.from_now.to_date)
flash[:notice] = t('gardens.created_prompt_html', link: link).html_safe
end
respond_with(@garden)
end
def update
saved = @garden.update(garden_params)
return render_card_json(saved) if request.format.json?
flash[:notice] = I18n.t('gardens.updated') if saved
respond_with(@garden)
end
def destroy
@garden.destroy
flash[:notice] = I18n.t('gardens.deleted')
redirect_to(member_gardens_path(@garden.owner))
end
# Lets a member use a garden they already have as their nursery.
def make_nursery
@garden.make_nursery!
redirect_to @garden, notice: t('gardens.nursery_made')
end
def stop_nursery
@garden.update!(nursery: false)
redirect_to @garden, notice: t('gardens.nursery_stopped')
end
def fetch_wikidata
if @garden.populate_wikidata_info
@garden.save
flash[:notice] = "Wikidata information updated."
else
flash[:alert] = "Could not find Wikidata information for this location."
end
redirect_to @garden
end
private
# A member who can look after the garden gets a to-do list of their own jobs
# for it. Everyone else sees the jobs people have planned there, as cards.
def load_jobs
if member_signed_in? && can?(:edit, @garden)
@notepad = JobsNotepad.new(current_member, garden: @garden)
else
@current_jobs = @garden.jobs.active.includes(:owner).order(created_at: :desc)
@finished_jobs = @garden.jobs.finished.includes(:owner).order(created_at: :desc)
end
end
# Takes every plant off the grid first, so the new arrangement is checked
# against a clean bed and two plants can swap cells in one save.
#
# The callers read the plants back afterwards rather than clearing the copies
# they already hold. Assigning nil to a loaded record and then assigning its
# old position back leaves it unchanged as far as dirty tracking is concerned,
# so the save would do nothing and the plant would stay off the grid — which
# is only visible as every plant but the one just dragged disappearing.
def clear_positions
Plant.where(planting_id: @garden.plantings.current.select(:id))
.update_all(bed_x: nil, bed_y: nil) # rubocop:disable Rails/SkipsModelValidations
end
# Collects the errors of any placement that wouldn't save, for the response.
def apply_placements(placements)
existing = @garden.placed_or_owned_plants.index_by(&:id)
# Plants this request already accounts for, so pulling from a stack doesn't
# grab one that another placement is about to use.
spoken_for = placements.filter_map { |placement| placement[:plant_id].presence&.to_i }.to_set
placements.each do |placement|
plant = plant_for(placement, existing, spoken_for)
next if plant.nil? # the error is already recorded
plant.assign_attributes(placement.slice(:bed_x, :bed_y, :diameter))
@layout_errors[plant.id || :new] = plant.errors.full_messages unless plant.save
end
end
# A placement names either a plant already on the bed, or just the planting it
# came off, which means one more of that crop.
def plant_for(placement, existing, spoken_for)
return known_plant(placement, existing) if placement[:plant_id].present?
planting = @garden.plantings.current.find_by(id: placement[:planting_id])
if planting.nil?
@layout_errors[placement[:planting_id]] = ['is not a planting in this garden']
return nil
end
take_from_stack(planting, spoken_for)
end
def known_plant(placement, existing)
plant = existing[placement[:plant_id].to_i]
@layout_errors[placement[:plant_id]] = ['is not a plant in this garden'] if plant.nil?
plant
end
# Uses up a plant the planting already has before making another, so a
# planting that says it has nine tomatoes maps those nine first.
def take_from_stack(planting, spoken_for)
spare = planting.plants.find { |plant| spoken_for.exclude?(plant.id) }
spoken_for << spare.id if spare
spare || planting.plants.build
end
# Composting is the one way the layout makes a planting smaller: the plant
# is gone, rather than lifted off the bed, and sync_quantities then counts
# one fewer.
def compost(plant_ids)
return if plant_ids.empty?
plants = @garden.placed_or_owned_plants.where(id: plant_ids)
(plant_ids - plants.ids).each { |id| @layout_errors[id] = ['is not a plant in this garden'] }
plants.destroy_all
end
# Stones, labels and rows are saved as a list on the garden, checked here and
# written directly: a full save would geocode the garden on every drag.
def apply_features(features)
problems = @garden.layout_feature_problems(features)
return @layout_errors[:features] = problems if problems.any?
@garden.update_column(:layout_features, features) # rubocop:disable Rails/SkipsModelValidations
end
# The number of plants is whatever ended up on and off the bed, so dragging
# one more onto the grid is what makes the planting bigger.
def sync_quantities
@garden.plantings.current.includes(:plants).find_each do |planting|
count = planting.plants.size
planting.update_column(:quantity, count) if planting.quantity != count # rubocop:disable Rails/SkipsModelValidations
end
end
# Permits each placement on its own rather than the whole params hash: the
# route's :member_slug and :slug, and the empty :garden that wrap_parameters
# adds to JSON requests, would otherwise count as unpermitted and raise.
def layout_params
placements = params[:placements]
return [] unless placements.is_a?(Array)
placements.map do |placement|
placement.permit(:plant_id, :planting_id, :bed_x, :bed_y, :diameter).to_h.symbolize_keys
end
end
# The stones, labels and rows, as plain hashes with numbers as numbers. Nil
# when the request doesn't send them, which leaves them as they are.
def features_params
features = params[:features]
return nil if features.nil?
return [] unless features.is_a?(Array)
features.map do |feature|
hash = feature.permit(:id, :kind, :x, :y, :x2, :y2, :text).to_h
%w(x y x2 y2).each { |key| hash[key] = Float(hash[key], exception: false) if hash.key?(key) }
hash.compact
end
end
# The ids of plants dropped in the compost bin.
def compost_params
ids = params[:composted]
return [] unless ids.is_a?(Array)
ids.filter_map { |id| Integer(id.to_s, exception: false) }
end
# Used by the React garden cards, which replace the garden's card with the
# returned one. No flash: nothing redirects, so it would turn up on the next page.
def render_card_json(saved)
if saved
card = GardenCardSerializer.collection([@garden], ability: current_ability, show_owner: false).first
render json: { garden: card }
else
render json: { errors: @garden.errors }, status: :unprocessable_content
end
end
def garden_params
params.require(:garden).permit(
:name, :slug, :description, :active,
:location, :latitude, :longitude, :area, :area_unit, :garden_type_id,
:location_wikidata_id, :lowest_temp_c, :highest_temp_c,
:grid_columns, :grid_rows
)
end
end