From ad4a6e17e2ce830239965c71c76ecb0f6ced12d9 Mon Sep 17 00:00:00 2001 From: Daniel O'Connor Date: Sat, 27 Jan 2024 00:38:33 +1030 Subject: [PATCH] iCalendar feed of plantings (#3588) * Register mime type * Add rough first pass * Index predictions, and add links * Trailing space * Fix implementation * Fix names, make public * Fix names, make public * Fix * Fix tyyos * Add date * If there are no predictions, keep growing * Add a todo * Fix factories * Specs --- Gemfile | 2 ++ Gemfile.lock | 4 +++ app/controllers/plantings_controller.rb | 2 +- app/views/plantings/index.html.haml | 2 ++ app/views/plantings/index.ics.erb | 42 +++++++++++++++++++++++++ config/initializers/mime_types.rb | 1 + spec/factories/planting.rb | 2 +- spec/features/planting_reminder_spec.rb | 8 ++--- spec/features/plantings/show_spec.rb | 2 +- spec/requests/plantings_spec.rb | 41 ++++++++++++++++++++++++ 10 files changed, 99 insertions(+), 7 deletions(-) create mode 100644 app/views/plantings/index.ics.erb diff --git a/Gemfile b/Gemfile index 0f9b17c83..8aeb821ab 100644 --- a/Gemfile +++ b/Gemfile @@ -127,6 +127,8 @@ gem 'faraday' gem 'rack-cors' +gem 'icalendar' + # for signups as requested by email service gem 'recaptcha' diff --git a/Gemfile.lock b/Gemfile.lock index a1fd8af5c..6e71ae32d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -315,6 +315,9 @@ GEM rails-i18n rainbow (>= 2.2.2, < 4.0) terminal-table (>= 1.5.1) + icalendar (2.10.1) + ice_cube (~> 0.16) + ice_cube (0.16.4) ice_nine (0.11.2) image_processing (1.12.2) mini_magick (>= 4.9.5, < 5) @@ -702,6 +705,7 @@ DEPENDENCIES haml_lint (>= 0.25.1) hashie (>= 3.5.3) i18n-tasks + icalendar jquery-rails jquery-ui-rails! jsonapi-resources diff --git a/app/controllers/plantings_controller.rb b/app/controllers/plantings_controller.rb index 6c02f5128..73a0538f2 100644 --- a/app/controllers/plantings_controller.rb +++ b/app/controllers/plantings_controller.rb @@ -3,6 +3,7 @@ class PlantingsController < DataController after_action :update_crop_medians, only: %i(create update destroy) after_action :update_planting_medians, only: :update + respond_to :ics, only: [:index] # TODO: This can be shifted up when all relevant controllers respond to ical def index @show_all = params[:all] == '1' @@ -29,7 +30,6 @@ class PlantingsController < DataController ) @filename = "Growstuff-#{specifics}Plantings-#{Time.zone.now.to_fs(:number)}.csv" - respond_with(@plantings) end diff --git a/app/views/plantings/index.html.haml b/app/views/plantings/index.html.haml index f20a0eba6..6a7cb4d09 100644 --- a/app/views/plantings/index.html.haml +++ b/app/views/plantings/index.html.haml @@ -30,6 +30,8 @@ = link_to (@owner ? member_plantings_path(@owner, format: format) : plantings_path(format: format)) do = icon 'fas', format.to_s = format.upcase + - if @owner + .badge.badge-info= link_to "iCal", member_plantings_path(@owner, format: 'ics', protocol: 'webcal', only_path: false) .badge.badge-success= link_to 'API Methods', '/api-docs' .col-md-10 diff --git a/app/views/plantings/index.ics.erb b/app/views/plantings/index.ics.erb new file mode 100644 index 000000000..c5f44b445 --- /dev/null +++ b/app/views/plantings/index.ics.erb @@ -0,0 +1,42 @@ +<% +# TODO Refactor to a Planting <-> Ical view class? +cal = Icalendar::Calendar.new +cal.description = "Plantings by #{@owner.login_name}" +@plantings.each do |planting| + + event = Icalendar::Event.new + + lines = [] + lines << "Quantity: #{planting['quantity'] ? planting['quantity'] : 'unknown' }" + lines << "Planted on: #{planting['planted_at'] ? planting['planted_at'] : 'unknown' }" + lines << "Sunniness: #{planting['sunniness'] ? planting['sunniness'] : 'unknown' }" + lines << "Planted from: #{planting['planted_from'] ? planting['planted_from'] : 'unknown' }" + lines << "First harvest from: #{planting['first_harvest_predicted_at'] ? planting['first_harvest_predicted_at'] : 'unknown' }" + lines << "Last harvest from: #{planting['last_harvest_predicted_at'] ? planting['last_harvest_predicted_at'] : 'unknown' }" + lines << "Finish predicted at: #{planting['finish_predicted_at'] ? planting['finish_predicted_at'] : 'unknown'}" + lines << "Finished at: #{planting['finished_at'] ? planting['finished_at'] : 'unknown' }" + + lines << planting.description + finish_date = Date.parse(planting['finished_at'] || planting['finish_predicted_at'] || planting['last_harvest_predicted_at']) rescue nil + + event.dtstart = Time.at(planting['created_at']) + event.dtend = finish_date || 1.day.from_now + event.summary = planting['crop_name'] + event.description = lines.join("\n") + event.ip_class = "PUBLIC" + event.url = planting_url(slug: planting['slug']) + + cal.add_event(event) + + if finish_date && finish_date > Date.today + todo = Icalendar::Todo.new + todo.dtstart = planting['first_harvest_predicted_at'] || finish_date || Date.today + todo.due = finish_date + todo.summary = "Harvest #{planting['crop_name']}" + + cal.add_todo(todo) + end +end +cal.publish +%> +<%= cal.to_ical %> diff --git a/config/initializers/mime_types.rb b/config/initializers/mime_types.rb index 6e1d16f02..690e74d6d 100644 --- a/config/initializers/mime_types.rb +++ b/config/initializers/mime_types.rb @@ -3,3 +3,4 @@ # Add new mime types for use in respond_to blocks: # Mime::Type.register "text/richtext", :rtf +Mime::Type.register "text/calendar", :ics diff --git a/spec/factories/planting.rb b/spec/factories/planting.rb index 8709dd32a..815d349c6 100644 --- a/spec/factories/planting.rb +++ b/spec/factories/planting.rb @@ -49,7 +49,7 @@ FactoryBot.define do crop { FactoryBot.create(:perennial_crop) } end - factory :predicatable_planting do + factory :predictable_planting do crop do crop = FactoryBot.create(:annual_crop) FactoryBot.create(:planting, crop:, planted_at: 10.days.ago) diff --git a/spec/features/planting_reminder_spec.rb b/spec/features/planting_reminder_spec.rb index 5e5adab69..7b2a5bec0 100644 --- a/spec/features/planting_reminder_spec.rb +++ b/spec/features/planting_reminder_spec.rb @@ -30,8 +30,8 @@ describe "Planting reminder email", :js do context "when member has some plantings" do # Bangs are used on the following 2 let blocks in order to ensure that the plantings are present # in the database before the email is generated: otherwise, they won't be present in the email. - let!(:p1) { FactoryBot.create(:predicatable_planting, planted_at: 10.days.ago, garden: member.gardens.first, owner: member) } - let!(:p2) { FactoryBot.create(:predicatable_planting, planted_at: 30.days.ago, garden: member.gardens.first, owner: member) } + let!(:p1) { FactoryBot.create(:predictable_planting, planted_at: 10.days.ago, garden: member.gardens.first, owner: member) } + let!(:p2) { FactoryBot.create(:predictable_planting, planted_at: 30.days.ago, garden: member.gardens.first, owner: member) } describe "lists plantings" do it { expect(mail).to have_content "Progress report" } @@ -50,8 +50,8 @@ describe "Planting reminder email", :js do context "when member has some harvests" do # Bangs are used on the following 2 let blocks in order to ensure that the plantings are present # in the database before the spec is run. - let!(:p1) { FactoryBot.create(:predicatable_planting, garden: member.gardens.first, owner: member, planted_at: 20.days.ago) } - let!(:p2) { FactoryBot.create(:predicatable_planting, garden: member.gardens.first, owner: member) } + let!(:p1) { FactoryBot.create(:predictable_planting, garden: member.gardens.first, owner: member, planted_at: 20.days.ago) } + let!(:p2) { FactoryBot.create(:predictable_planting, garden: member.gardens.first, owner: member) } let!(:h1) { FactoryBot.create(:harvest, owner: member, planting: p1, harvested_at: 1.day.ago) } let!(:h2) { FactoryBot.create(:harvest, owner: member, planting: p2, harvested_at: 3.days.ago) } diff --git a/spec/features/plantings/show_spec.rb b/spec/features/plantings/show_spec.rb index 8d8c8a396..8731d218b 100644 --- a/spec/features/plantings/show_spec.rb +++ b/spec/features/plantings/show_spec.rb @@ -26,7 +26,7 @@ describe "Display a planting", :js do end context 'Annual with predicted finish' do - let(:planting) { FactoryBot.create(:predicatable_planting, planted_at: 2.weeks.ago) } + let(:planting) { FactoryBot.create(:predictable_planting, planted_at: 2.weeks.ago) } it { expect(page).to have_text '28%' } it { expect(page).to have_text '14/50 days' } diff --git a/spec/requests/plantings_spec.rb b/spec/requests/plantings_spec.rb index 90a4b5348..9276aaf03 100644 --- a/spec/requests/plantings_spec.rb +++ b/spec/requests/plantings_spec.rb @@ -10,4 +10,45 @@ describe "Plantings" do response.status.should be(200) end end + + context "with a member" do + before do + @member = create(:interesting_member) + @predictable_planting = create(:predictable_planting, owner: @member) + @seedling_planting = create(:seedling_planting, owner: @member) + @seed_planting = create(:seed_planting, owner: @member) + @finished_planting = create(:finished_planting, owner: @member) + @annual_planting = create(:annual_planting, owner: @member) + @perennial_planting = create(:perennial_planting, owner: @member) + + Planting.reindex + end + + describe "GET /members/x/plantings.ics" do + it "works!" do + get member_plantings_path(@member, format: "ics") + + calendar = Icalendar::Parser.new(response.body, true).parse.first + expect(calendar.description[0].to_s).to eq "Plantings by #{@member.login_name}" + events = calendar.events + expect(events.length).to eq 6 # There are 7, but finished plantings aren't included + + # TODO: Better date comparison + # Predicted finish should be used + expect(events[1].summary.to_s).to include @predictable_planting.crop.name + expect(events[1].dtstart.to_datetime.to_i).to be_within(1.second).of @predictable_planting.created_at.to_i + expect(events[1].dtend.to_date).to eq @predictable_planting.finish_predicted_at + + # Actual finish should be used + # expect(events[4].dtend.to_date).to be_within(1.second).of @finised_planting.finished_at + + # Otherwise, tomorrow should be used + expect(events[2].dtend.to_date).to eq 1.day.from_now.to_date + + # TBA: Perennial and annual crops predictions of 'next' harvest date don't really fit + + response.status.should be(200) + end + end + end end