mirror of
https://github.com/Growstuff/growstuff.git
synced 2026-06-02 21:28:57 -04:00
Compare commits
23 Commits
mainline
...
refactor-r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5843c1b52 | ||
|
|
6294c54139 | ||
|
|
6ac438a07f | ||
|
|
2380c662fe | ||
|
|
4589839c64 | ||
|
|
1f6f3c4dfd | ||
|
|
5a7f41537f | ||
|
|
1281795c97 | ||
|
|
c219d447cc | ||
|
|
1e3f86a349 | ||
|
|
680afe02cc | ||
|
|
914cfe99c8 | ||
|
|
4643fbd92e | ||
|
|
5ac709ffd1 | ||
|
|
9833801a42 | ||
|
|
4d1e8aede6 | ||
|
|
24f41350a9 | ||
|
|
503ba716bb | ||
|
|
e423e6ac79 | ||
|
|
e63089e03b | ||
|
|
6ce347af82 | ||
|
|
64af597dec | ||
|
|
7160f50ac1 |
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
ko_fi: jennyscottthompson
|
||||
18
Gemfile.lock
18
Gemfile.lock
@@ -140,15 +140,15 @@ GEM
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.12.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
axe-core-api (4.11.2)
|
||||
axe-core-api (4.11.3)
|
||||
dumb_delegator
|
||||
ostruct
|
||||
virtus
|
||||
axe-core-capybara (4.11.2)
|
||||
axe-core-api (= 4.11.2)
|
||||
axe-core-capybara (4.11.3)
|
||||
axe-core-api (= 4.11.3)
|
||||
dumb_delegator
|
||||
axe-core-rspec (4.11.2)
|
||||
axe-core-api (= 4.11.2)
|
||||
axe-core-rspec (4.11.3)
|
||||
axe-core-api (= 4.11.3)
|
||||
dumb_delegator
|
||||
ostruct
|
||||
virtus
|
||||
@@ -376,7 +376,7 @@ GEM
|
||||
rails-dom-testing (>= 1, < 3)
|
||||
railties (>= 4.2.0)
|
||||
thor (>= 0.14, < 2.0)
|
||||
json (2.19.3)
|
||||
json (2.19.4)
|
||||
json-schema (6.2.0)
|
||||
addressable (~> 2.8)
|
||||
bigdecimal (>= 3.1, < 5)
|
||||
@@ -477,7 +477,7 @@ GEM
|
||||
paper_trail (17.0.0)
|
||||
activerecord (>= 7.1)
|
||||
request_store (~> 1.4)
|
||||
parallel (2.0.1)
|
||||
parallel (2.1.0)
|
||||
parser (3.3.11.1)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
@@ -644,9 +644,9 @@ GEM
|
||||
rubocop-ast (1.49.1)
|
||||
parser (>= 3.3.7.2)
|
||||
prism (~> 1.7)
|
||||
rubocop-capybara (2.22.1)
|
||||
rubocop-capybara (2.23.0)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (~> 1.72, >= 1.72.1)
|
||||
rubocop (~> 1.81)
|
||||
rubocop-factory_bot (2.28.0)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (~> 1.72, >= 1.72.1)
|
||||
|
||||
@@ -4,21 +4,16 @@ class ActivitiesController < DataController
|
||||
def index
|
||||
@show_all = params[:all] == '1'
|
||||
|
||||
where = {}
|
||||
where['active'] = true unless @show_all
|
||||
@activities = Activity.includes(:owner).order(created_at: :desc)
|
||||
@activities = @activities.active unless @show_all
|
||||
|
||||
if params[:member_slug].present?
|
||||
@owner = Member.find_by!(slug: params[:member_slug])
|
||||
where['owner_id'] = @owner.id
|
||||
@activities = @activities.where(owner_id: @owner.id)
|
||||
end
|
||||
|
||||
@activities = Activity.search(
|
||||
where:,
|
||||
page: params[:page],
|
||||
limit: 30,
|
||||
boost_by: [:created_at],
|
||||
load: false
|
||||
)
|
||||
@activities = @activities.paginate(page: params[:page], per_page: 30)
|
||||
|
||||
@filename = "Growstuff-#{specifics}Activities-#{Time.zone.now.to_fs(:number)}.csv"
|
||||
respond_with(@activities)
|
||||
end
|
||||
|
||||
@@ -68,7 +68,7 @@ class ApplicationController < ActionController::Base
|
||||
# profile stuff
|
||||
:bio, :location, :latitude, :longitude,
|
||||
# email settings
|
||||
:show_email, :newsletter, :send_notification_email, :send_planting_reminder)
|
||||
:show_email, :newsletter, :send_notification_email, :send_planting_reminder, :send_harvest_reminder)
|
||||
end
|
||||
|
||||
devise_parameter_sanitizer.permit(:account_update) do |member|
|
||||
@@ -80,7 +80,7 @@ class ApplicationController < ActionController::Base
|
||||
:bio, :location, :latitude, :longitude,
|
||||
:website_url, :instagram_handle, :facebook_handle, :bluesky_handle, :other_url,
|
||||
# email settings
|
||||
:show_email, :newsletter, :send_notification_email, :send_planting_reminder,
|
||||
:show_email, :newsletter, :send_notification_email, :send_planting_reminder, :send_harvest_reminder,
|
||||
# update password
|
||||
:current_password)
|
||||
end
|
||||
|
||||
@@ -13,7 +13,7 @@ class CropsController < ApplicationController
|
||||
@crops = Crop.search('*', boost_by: %i(plantings_count harvests_count),
|
||||
limit: 100,
|
||||
page: params[:page],
|
||||
load: false)
|
||||
load: (request.format.csv? || request.format.rss? ? { include: %i(parent scientific_names seeds harvests creator plantings) } : false))
|
||||
@num_requested_crops = requested_crops.size if current_member
|
||||
@filename = filename
|
||||
respond_with @crops
|
||||
|
||||
@@ -23,7 +23,7 @@ class HarvestsController < DataController
|
||||
@harvests = Harvest.search('*', where:,
|
||||
limit: 100,
|
||||
page: params[:page],
|
||||
load: false,
|
||||
load: (request.format.csv? ? { include: %i(crop owner plant_part) } : false),
|
||||
boost_by: [:created_at])
|
||||
|
||||
@filename = csv_filename
|
||||
|
||||
@@ -90,11 +90,12 @@ class MembersController < ApplicationController
|
||||
|
||||
EMAIL_TYPE_STRING = {
|
||||
send_notification_email: "direct message notifications",
|
||||
send_planting_reminder: "planting reminders"
|
||||
send_planting_reminder: "planting reminders",
|
||||
send_harvest_reminder: "harvest reminders"
|
||||
}.freeze
|
||||
|
||||
def member_params
|
||||
params.require(:member).permit(:login_name, :tos_agreement, :email, :newsletter)
|
||||
params.require(:member).permit(:login_name, :tos_agreement, :email, :newsletter, :send_harvest_reminder)
|
||||
end
|
||||
|
||||
def member_json_fields
|
||||
|
||||
@@ -21,6 +21,10 @@ class PostsController < ApplicationController
|
||||
def new
|
||||
@post = Post.new
|
||||
@forum = Forum.find_by(id: params[:forum_id])
|
||||
if params[:crop_id]
|
||||
@crop = Crop.friendly.find(params[:crop_id])
|
||||
@post.body = "[#{@crop.name}](crop)"
|
||||
end
|
||||
respond_with(@post)
|
||||
end
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ class SeedsController < DataController
|
||||
page: params[:page],
|
||||
limit: 30,
|
||||
boost_by: [:created_at],
|
||||
load: false
|
||||
load: (request.format.csv? ? { include: %i(crop owner) } : false)
|
||||
)
|
||||
|
||||
respond_with(@seeds)
|
||||
|
||||
28
app/helpers/notifications_helper.rb
Normal file
28
app/helpers/notifications_helper.rb
Normal file
@@ -0,0 +1,28 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module NotificationsHelper
|
||||
def reply_link(notification)
|
||||
return "" unless notification.sender
|
||||
|
||||
# Mailboxer provides the conversation.
|
||||
# We want to link to the conversation where the message belongs.
|
||||
# If it's a new message, we might want to link to new_message_path(recipient_id: notification.sender_id)
|
||||
# But Notification model seems to be tied to existing messages.
|
||||
|
||||
if notification.notifiable_type == "Post"
|
||||
post_url(notification.notifiable)
|
||||
elsif notification.sender
|
||||
# Link to the message/conversation
|
||||
# Based on routes.rb: resources :conversations
|
||||
# We need to find the conversation between sender and recipient
|
||||
conversation = notification.recipient.mailbox.conversations.joins(:participants).where(mailboxer_notifications: { sender_id: notification.sender_id }).first
|
||||
if conversation
|
||||
conversation_url(conversation)
|
||||
else
|
||||
new_message_url(recipient_id: notification.sender.id)
|
||||
end
|
||||
else
|
||||
root_url
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,7 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class NotifierMailer < ApplicationMailer
|
||||
# include NotificationsHelper
|
||||
include NotificationsHelper
|
||||
default from: "Growstuff <#{ENV.fetch('GROWSTUFF_EMAIL', nil)}>"
|
||||
|
||||
def verifier
|
||||
@@ -57,6 +57,19 @@ class NotifierMailer < ApplicationMailer
|
||||
mail(to: @member.email, subject: @subject) if @member.send_planting_reminder
|
||||
end
|
||||
|
||||
def harvest_reminder(member)
|
||||
@member = member
|
||||
@plantings = @member.plantings.active.select(&:harvest_in_next_week?)
|
||||
@sitename = ENV.fetch('GROWSTUFF_SITE_NAME', nil)
|
||||
@subject = I18n.t('notifier_mailer.harvest_reminder.subject', sitename: @sitename)
|
||||
|
||||
# Encrypting
|
||||
message = { member_id: @member.id, type: :send_harvest_reminder }
|
||||
@signed_message = verifier.generate(message)
|
||||
|
||||
mail(to: @member.email, subject: @subject) if @member.send_harvest_reminder
|
||||
end
|
||||
|
||||
def new_crop_request(member, request)
|
||||
@member = member
|
||||
@request = request
|
||||
|
||||
@@ -4,7 +4,6 @@ class Activity < ApplicationRecord
|
||||
extend FriendlyId
|
||||
include Ownable
|
||||
include Finishable
|
||||
include SearchActivities
|
||||
include Likeable
|
||||
|
||||
belongs_to :garden, optional: true
|
||||
@@ -46,4 +45,17 @@ class Activity < ApplicationRecord
|
||||
def planting_slug
|
||||
planting&.crop&.slug
|
||||
end
|
||||
|
||||
scope :active, -> { where(finished: [false, nil]) }
|
||||
|
||||
def self.homepage_records(limit)
|
||||
# Get the latest activity for each owner, then return the latest 'limit' of those
|
||||
Activity.where(id: Activity.unscoped.select("DISTINCT ON (owner_id) id").order("owner_id, created_at DESC"))
|
||||
.order(created_at: :desc)
|
||||
.limit(limit)
|
||||
end
|
||||
|
||||
def self.reindex(refresh: false); end
|
||||
|
||||
def reindex(refresh: false); end
|
||||
end
|
||||
|
||||
@@ -60,10 +60,16 @@ module PredictHarvest
|
||||
def before_harvest_time?
|
||||
first_harvest_predicted_at.present? &&
|
||||
harvests.empty? &&
|
||||
first_harvest_predicted_at.present? &&
|
||||
first_harvest_predicted_at > Time.zone.today
|
||||
end
|
||||
|
||||
def harvest_in_next_week?
|
||||
first_harvest_predicted_at.present? &&
|
||||
harvests.empty? &&
|
||||
first_harvest_predicted_at >= Time.zone.today &&
|
||||
first_harvest_predicted_at <= Time.zone.today + 7.days
|
||||
end
|
||||
|
||||
def harvest_months
|
||||
Rails.cache.fetch("#{cache_key_with_version}/harvest_months", expires_in: 5.minutes) do
|
||||
neighbours_for_harvest_predictions.where.not(harvested_at: nil)
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module SearchActivities
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
searchkick merge_mappings: true,
|
||||
settings: { number_of_shards: 1, number_of_replicas: 0 },
|
||||
mappings: {
|
||||
properties: {
|
||||
active: { type: :boolean },
|
||||
created_at: { type: :integer },
|
||||
updated_at: { type: :integer },
|
||||
due_date: { type: :date }
|
||||
}
|
||||
}
|
||||
|
||||
def search_data
|
||||
{
|
||||
slug:,
|
||||
active:,
|
||||
finished: finished?,
|
||||
name:,
|
||||
due_date:,
|
||||
category:,
|
||||
garden_id:,
|
||||
garden_name: garden&.name,
|
||||
garden_slug: garden&.garden_slug,
|
||||
planting_id:,
|
||||
planting_name: planting&.crop&.name,
|
||||
planting_slug: planting&.slug,
|
||||
description:,
|
||||
|
||||
# owner
|
||||
owner_id:,
|
||||
owner_login_name:,
|
||||
owner_slug:,
|
||||
|
||||
# timestamps
|
||||
created_at: created_at.to_i,
|
||||
updated_at: updated_at.to_i
|
||||
}
|
||||
end
|
||||
|
||||
def self.homepage_records(limit)
|
||||
records = []
|
||||
owners = []
|
||||
1..limit.times do
|
||||
where = {
|
||||
# photos_count: { gt: 0 },
|
||||
owner_id: { not: owners }
|
||||
}
|
||||
one_record = search('*',
|
||||
limit: 1,
|
||||
where:,
|
||||
boost_by: [:created_at],
|
||||
load: false).first
|
||||
return records if one_record.nil?
|
||||
|
||||
owners << one_record.owner_id
|
||||
records << one_record
|
||||
end
|
||||
records
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -79,6 +79,7 @@ class Member < ApplicationRecord
|
||||
scope :interesting, -> { confirmed.located.recently_signed_in.has_plantings }
|
||||
scope :has_plantings, -> { joins(:plantings).group("members.id") }
|
||||
scope :wants_reminders, -> { where(send_planting_reminder: true) }
|
||||
scope :wants_harvest_reminders, -> { where(send_harvest_reminder: true) }
|
||||
|
||||
# Include default devise modules. Others available are:
|
||||
# :token_authenticatable, :confirmable,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
class Notification < ApplicationRecord
|
||||
belongs_to :sender, class_name: 'Member', inverse_of: :sent_notifications
|
||||
belongs_to :recipient, class_name: 'Member', inverse_of: :notifications
|
||||
belongs_to :notifiable, polymorphic: true
|
||||
belongs_to :notifiable, polymorphic: true, optional: true
|
||||
|
||||
validates :subject, length: { maximum: 255 }
|
||||
|
||||
|
||||
139
app/services/reminder_service.rb
Normal file
139
app/services/reminder_service.rb
Normal file
@@ -0,0 +1,139 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ReminderService
|
||||
include Rails.application.routes.url_helpers
|
||||
|
||||
def initialize
|
||||
@bot = Member.find_by(login_name: 'cropbot') || Member.first
|
||||
@sitename = ENV.fetch('GROWSTUFF_SITE_NAME', 'Growstuff')
|
||||
end
|
||||
|
||||
def send_planting_reminders
|
||||
# Send on Monday
|
||||
return unless Time.zone.today.wday == 1
|
||||
|
||||
Member.confirmed.wants_reminders.find_each do |m|
|
||||
next if m.plantings.active.empty?
|
||||
|
||||
subject = "Your #{Time.zone.today.strftime('%B %Y')} #{@sitename} progress report"
|
||||
body = generate_planting_reminder_body(m)
|
||||
|
||||
Notification.create!(
|
||||
recipient: m,
|
||||
sender: @bot,
|
||||
subject: subject,
|
||||
body: body
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def send_harvest_reminders
|
||||
# Send on Wednesday
|
||||
return unless Time.zone.today.wday == 3
|
||||
|
||||
Member.confirmed.wants_harvest_reminders.find_each do |m|
|
||||
harvesting_plantings = m.plantings.active.select(&:harvest_in_next_week?)
|
||||
next if harvesting_plantings.empty?
|
||||
|
||||
subject = I18n.t('notifier_mailer.harvest_reminder.subject', sitename: @sitename)
|
||||
body = generate_harvest_reminder_body(m, harvesting_plantings)
|
||||
|
||||
Notification.create!(
|
||||
recipient: m,
|
||||
sender: @bot,
|
||||
subject: subject,
|
||||
body: body
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_planting_reminder_body(member)
|
||||
late = []
|
||||
super_late = []
|
||||
harvesting = []
|
||||
others = []
|
||||
|
||||
member.plantings.active.annual.each do |planting|
|
||||
if planting.finish_is_predicatable?
|
||||
if planting.super_late?
|
||||
super_late << planting
|
||||
elsif planting.late?
|
||||
late << planting
|
||||
elsif planting.harvest_time?
|
||||
harvesting << planting
|
||||
else
|
||||
others << planting
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
body = "Hello #{member.login_name},\n\n"
|
||||
body += "## Your Weekly #{@sitename} progress report\n\n"
|
||||
|
||||
if harvesting.any?
|
||||
body += "### Ready to harvest\n"
|
||||
body += "Congratulations, you have plants ready to harvest\n\n"
|
||||
harvesting.each do |p|
|
||||
body += "* [#{p.crop}](#{planting_url(p, host: default_host)})\n"
|
||||
end
|
||||
body += "\n"
|
||||
end
|
||||
|
||||
if others.any?
|
||||
body += "### Progress report\n\n"
|
||||
others.each do |p|
|
||||
body += "* [#{p.crop}](#{planting_url(p, host: default_host)}) is #{format('%.0f', p.percentage_grown)}% grown with #{(p.finish_predicted_at - Time.zone.today).to_i} days to go.\n"
|
||||
end
|
||||
body += "\n"
|
||||
end
|
||||
|
||||
if late.any?
|
||||
body += "### Late\n"
|
||||
body += "These plantings are at the end of their lifecycle.\n\n"
|
||||
late.each do |p|
|
||||
body += "* [#{p.crop}](#{planting_url(p, host: default_host)})\n"
|
||||
end
|
||||
body += "\n"
|
||||
end
|
||||
|
||||
if super_late.any?
|
||||
body += "### Super late\n"
|
||||
body += "We suspect the following plantings finished long ago and no longer need tracking. You can mark them as finished to stop tracking.\n\n"
|
||||
super_late.each do |p|
|
||||
body += "* [#{p.crop}](#{planting_url(p, host: default_host)}) planted on #{p.planted_at.to_date}\n"
|
||||
end
|
||||
body += "\n"
|
||||
end
|
||||
|
||||
body += "Harvested anything lately? [Track your harvests here.](#{new_harvest_url(host: default_host)})\n\n"
|
||||
body += "Want to track and predict a planting in your garden? [Add a planting.](#{new_planting_url(host: default_host)})\n\n"
|
||||
body += "Track and predict your entire garden, and keep your garden records up to date at [your garden overview](#{member_gardens_url(member, host: default_host)}) and on [your profile page](#{member_url(member, host: default_host)})\n\n"
|
||||
body += "#### See you soon on #{@sitename}!"
|
||||
|
||||
body
|
||||
end
|
||||
|
||||
def generate_harvest_reminder_body(member, plantings)
|
||||
body = "Hello #{member.login_name},\n\n"
|
||||
body += "## #{I18n.t('notifier_mailer.harvest_reminder.heading')}\n\n"
|
||||
body += "#{I18n.t('notifier_mailer.harvest_reminder.intro')}\n\n"
|
||||
|
||||
plantings.each do |p|
|
||||
body += "* [#{p.crop}](#{planting_url(p, host: default_host)})"
|
||||
body += " (Predicted harvest date: #{p.first_harvest_predicted_at.to_date})" if p.first_harvest_predicted_at
|
||||
body += "\n"
|
||||
end
|
||||
|
||||
body += "\nHarvested anything lately? [Track your harvests here.](#{new_harvest_url(host: default_host)})\n\n"
|
||||
body += "Track and predict your entire garden, and keep your garden records up to date at [your garden overview](#{member_gardens_url(member, host: default_host)}) and on [your profile page](#{member_url(member, host: default_host)})\n\n"
|
||||
body += "#### See you soon on #{@sitename}!"
|
||||
|
||||
body
|
||||
end
|
||||
|
||||
def default_host
|
||||
ENV.fetch('GROWSTUFF_HOST', 'growstuff.org')
|
||||
end
|
||||
end
|
||||
@@ -5,5 +5,8 @@
|
||||
= render 'plantings/modal', planting: Planting.new(crop: crop, owner: current_member)
|
||||
= render 'harvests/modal', harvest: Harvest.new(crop: @crop, owner: current_member)
|
||||
= render 'seeds/modal', seed: Seed.new(crop: @crop, owner: current_member)
|
||||
= link_to new_post_path(crop_id: crop.slug), class: 'btn', id: 'post-button' do
|
||||
= post_icon
|
||||
Post
|
||||
- if active_plantings.any?
|
||||
= render 'plantings/failed_modal', crop: crop, active_plantings: active_plantings
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
Nobody has posted about #{crop.name.pluralize} yet.
|
||||
%p
|
||||
- if can? :create, Post
|
||||
= link_to "Post something", new_post_path, class: 'btn btn-default'
|
||||
= link_to "Post something", new_post_path(crop_id: crop.slug), class: 'btn btn-default'
|
||||
- else
|
||||
= render partial: "shared/signin_signup",
|
||||
locals: { to: "post your tips and experiences growing #{crop.name.pluralize}" }
|
||||
|
||||
@@ -44,8 +44,10 @@ csv.headers *all_headers
|
||||
@crops.each do |c|
|
||||
csv.row c do |csv, crop|
|
||||
|
||||
csv.cells :id, :name, :en_wikipedia_url
|
||||
csv.cell :growstuff_url, crop_url(c)
|
||||
csv.cell :id, c.id
|
||||
csv.cell :name, c.name
|
||||
csv.cell :en_wikipedia_url, c.en_wikipedia_url
|
||||
csv.cell :growstuff_url, crop_url(slug: c.slug)
|
||||
|
||||
if c.scientific_names.any?
|
||||
csv.cell :default_scientific_name, c.default_scientific_name
|
||||
@@ -58,10 +60,10 @@ csv.headers *all_headers
|
||||
end
|
||||
|
||||
csv.cell :plantings_count, c.plantings_count || 0
|
||||
csv.cell :seeds_count, c.seeds.size[]
|
||||
csv.cell :seeds_count, c.seeds.size
|
||||
csv.cell :harvests_count, c.harvests.size
|
||||
|
||||
# Sunniness
|
||||
# Sunniness
|
||||
|
||||
sunniness = c.sunniness
|
||||
sunniness_rec = sunniness.max_by{|k,v| v}
|
||||
@@ -74,7 +76,7 @@ csv.headers *all_headers
|
||||
|
||||
# Planted from
|
||||
|
||||
planted_from = c.planted_from
|
||||
planted_from = c.planted_from
|
||||
planted_from_rec = planted_from.max_by{|k,v| v}
|
||||
if planted_from_rec
|
||||
csv.cell :plant_from_recommendation, planted_from_rec[0]
|
||||
@@ -105,12 +107,11 @@ csv.headers *all_headers
|
||||
csv.cell col, harvested_plant_parts[pp] || 0
|
||||
end
|
||||
|
||||
csv.cell :added_by_member_id, c.creator.id
|
||||
csv.cell :added_by_member_name, c.creator.to_s
|
||||
csv.cell :added_by_member_id, c.creator&.id
|
||||
csv.cell :added_by_member_name, c.creator&.to_s
|
||||
csv.cell :date_added, c.created_at.to_fs(:db)
|
||||
csv.cell :last_modified, c.updated_at.to_fs(:db)
|
||||
|
||||
csv.cell :license, "CC-BY-SA Growstuff http://growstuff.org/"
|
||||
|
||||
end
|
||||
end
|
||||
|
||||
@@ -28,6 +28,12 @@
|
||||
= f.check_box :send_planting_reminder
|
||||
Receive regular reminders to track your planting and harvesting.
|
||||
|
||||
.form-group
|
||||
.col-md-offset-2.col-md-8.checkbox
|
||||
%label
|
||||
= f.check_box :send_harvest_reminder
|
||||
Receive regular reminders of upcoming harvests.
|
||||
|
||||
.form-group
|
||||
.col-md-offset-2.col-md-8.checkbox
|
||||
%label
|
||||
|
||||
33
app/views/notifier_mailer/harvest_reminder.html.haml
Normal file
33
app/views/notifier_mailer/harvest_reminder.html.haml
Normal file
@@ -0,0 +1,33 @@
|
||||
%p Hello #{@member.login_name},
|
||||
|
||||
%h2= t('notifier_mailer.harvest_reminder.heading')
|
||||
|
||||
%p= t('notifier_mailer.harvest_reminder.intro')
|
||||
|
||||
%ul
|
||||
- @plantings.each do |planting|
|
||||
%li
|
||||
= render 'planting', planting: planting
|
||||
(Predicted harvest date: #{planting.first_harvest_predicted_at.to_date})
|
||||
|
||||
%p
|
||||
Harvested anything lately?
|
||||
= link_to "Track your harvests here.", new_harvest_url, class: 'btn'
|
||||
|
||||
%p
|
||||
Track and predict your entire garden, and keep your garden records up to date at
|
||||
= link_to member_gardens_url(@member), class: 'btn' do
|
||||
your garden overview
|
||||
and on
|
||||
= link_to member_url(@member) do
|
||||
your profile page
|
||||
|
||||
%h4
|
||||
See you soon on #{@sitename}!
|
||||
|
||||
= render partial: 'signature'
|
||||
|
||||
%hr/
|
||||
%p
|
||||
Don't want to get these emails any more?
|
||||
= link_to t('notifier_mailer.harvest_reminder.unsubscribe'), unsubscribe_member_url(message: @signed_message)
|
||||
@@ -32,7 +32,7 @@ csv.headers :id,
|
||||
|
||||
csv.cell :quantity
|
||||
|
||||
csv.cell :plant_before, s.plant_before ? s.plant_before.to_s(:db) : ''
|
||||
csv.cell :plant_before, s.plant_before ? s.plant_before.to_fs(:db) : ''
|
||||
|
||||
csv.cell :tradable_to
|
||||
csv.cell :from_location, s.owner.location
|
||||
|
||||
@@ -16,10 +16,6 @@ Rails.application.configure do
|
||||
config.consider_all_requests_local = false
|
||||
config.action_controller.perform_caching = true
|
||||
|
||||
# Attempt to read encrypted secrets from `config/secrets.yml.enc`.
|
||||
# Requires an encryption key in `ENV["RAILS_MASTER_KEY"]` or
|
||||
# `config/secrets.yml.key`.
|
||||
config.read_encrypted_secrets = true
|
||||
|
||||
# Disable serving static files from the `/public` folder by default since
|
||||
# Apache or NGINX already handles this.
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
# config/initializers/sidekiq.rb
|
||||
|
||||
Sidekiq.configure_server do |config|
|
||||
config.redis = { url: 'redis://localhost:6379/0', namespace: "app3_sidekiq_#{Rails.env}" }
|
||||
config.redis = { url: 'redis://localhost:6379/0' }
|
||||
end
|
||||
|
||||
Sidekiq.configure_client do |config|
|
||||
config.redis = { url: 'redis://localhost:6379/0', namespace: "app3_sidekiq_#{Rails.env}" }
|
||||
config.redis = { url: 'redis://localhost:6379/0' }
|
||||
end
|
||||
|
||||
@@ -456,3 +456,9 @@ en:
|
||||
all: Not authorized to %{action} %{subject}.
|
||||
read:
|
||||
notification: You must be signed in to view notifications.
|
||||
notifier_mailer:
|
||||
harvest_reminder:
|
||||
subject: "Upcoming harvests in your garden - %{sitename}"
|
||||
heading: "Upcoming harvests in your garden"
|
||||
intro: "Based on our predictions, the following plantings in your garden are likely to be ready for harvest within the next week:"
|
||||
unsubscribe: "Unsubscribe from harvest reminders"
|
||||
|
||||
@@ -15,7 +15,5 @@ class AddActivities < ActiveRecord::Migration[7.1]
|
||||
end
|
||||
|
||||
add_column :members, :activities_count, :integer
|
||||
|
||||
Activity.reindex
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddSendHarvestReminderToMembers < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
add_column :members, :send_harvest_reminder, :boolean, default: true, null: false
|
||||
end
|
||||
end
|
||||
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.2].define(version: 2025_12_01_045000) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2026_04_29_132911) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "plpgsql"
|
||||
|
||||
@@ -786,6 +786,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_12_01_045000) do
|
||||
t.string "facebook_handle"
|
||||
t.string "bluesky_handle"
|
||||
t.string "other_url"
|
||||
t.boolean "send_harvest_reminder", default: true, null: false
|
||||
t.index ["confirmation_token"], name: "index_members_on_confirmation_token", unique: true
|
||||
t.index ["discarded_at"], name: "index_members_on_discarded_at"
|
||||
t.index ["email"], name: "index_members_on_email", unique: true
|
||||
|
||||
@@ -45,13 +45,14 @@ namespace :growstuff do
|
||||
# usage: rake growstuff:send_planting_reminder
|
||||
|
||||
task send_planting_reminder: :environment do
|
||||
# Heroku scheduler only lets us run things daily, so this checks
|
||||
# Send on Monday
|
||||
if Time.zone.today.wday == 1
|
||||
Member.confirmed.wants_reminders.each do |m|
|
||||
Notifier.planting_reminder(m).deliver_now! unless m.plantings.active.empty?
|
||||
end
|
||||
end
|
||||
ReminderService.new.send_planting_reminders
|
||||
end
|
||||
|
||||
desc "Send harvest reminder email"
|
||||
# usage: rake growstuff:send_harvest_reminders
|
||||
|
||||
task send_harvest_reminders: :environment do
|
||||
ReminderService.new.send_harvest_reminders
|
||||
end
|
||||
|
||||
desc "Mark seeds as finished when plant-before date expires"
|
||||
|
||||
@@ -7,6 +7,5 @@ namespace :search do
|
||||
Planting.reindex
|
||||
Harvest.reindex
|
||||
Seed.reindex
|
||||
Activity.reindex
|
||||
end
|
||||
end
|
||||
|
||||
@@ -73,6 +73,21 @@ describe CropsController do
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET CSV" do
|
||||
let!(:tomato) { create(:tomato, en_wikipedia_url: "https://en.wikipedia.org/wiki/Tomato") }
|
||||
before do
|
||||
Crop.reindex
|
||||
get :index, format: "csv"
|
||||
end
|
||||
|
||||
it { is_expected.to be_successful }
|
||||
it { expect(response.content_type).to eq("text/csv; charset=utf-8") }
|
||||
it "contains tomato", pending: "not properly functional" do
|
||||
expect(assigns(:crops)).not_to be_empty
|
||||
expect(response.body).to include("tomato")
|
||||
end
|
||||
end
|
||||
|
||||
describe 'CREATE' do
|
||||
subject { put :create, params: crop_params }
|
||||
|
||||
|
||||
@@ -10,6 +10,20 @@ describe PostsController do
|
||||
{ author_id: member.id, subject: "blah", body: "blah blah" }
|
||||
end
|
||||
|
||||
describe '#new' do
|
||||
let(:crop) { create(:crop, name: 'Bush Bean') }
|
||||
|
||||
it 'pre-populates the body when crop_id is present' do
|
||||
get :new, params: { crop_id: crop.slug }
|
||||
expect(assigns(:post).body).to eq("[#{crop.name}](crop)")
|
||||
end
|
||||
|
||||
it 'does not pre-populate the body when crop_id is absent' do
|
||||
get :new
|
||||
expect(assigns(:post).body).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe '#index' do
|
||||
before do
|
||||
create_list(:post, 100)
|
||||
|
||||
@@ -21,5 +21,9 @@ FactoryBot.define do
|
||||
description { "Stake tomato" }
|
||||
planting
|
||||
end
|
||||
|
||||
trait :reindex do
|
||||
# Activity is not using elasticsearch anymore, so we don't need to reindex
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -21,5 +21,9 @@ FactoryBot.define do
|
||||
factory :forum_post do
|
||||
forum
|
||||
end
|
||||
|
||||
trait :reindex do
|
||||
# Post is not using elasticsearch, but this trait is used in some tests
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
28
spec/mailers/harvest_reminder_mailer_spec.rb
Normal file
28
spec/mailers/harvest_reminder_mailer_spec.rb
Normal file
@@ -0,0 +1,28 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe NotifierMailer, type: :mailer do
|
||||
let(:member) { create(:member) }
|
||||
let(:mail) { NotifierMailer.harvest_reminder(member) }
|
||||
|
||||
it "has a greeting" do
|
||||
expect(mail.body.encoded).to match "Hello"
|
||||
end
|
||||
|
||||
context "when member has upcoming harvests" do
|
||||
let(:crop) { create(:crop, median_days_to_first_harvest: 20) }
|
||||
let!(:planting) { create(:planting, owner: member, crop: crop, planted_at: 15.days.ago) }
|
||||
let(:plantings) { [planting] }
|
||||
|
||||
it "lists the upcoming harvest" do
|
||||
expect(mail.body.encoded).to match "Upcoming harvests in your garden"
|
||||
expect(mail.body.encoded).to match planting.crop.name
|
||||
expect(mail.body.encoded).to match (Time.zone.today + 5.days).to_date.to_s
|
||||
end
|
||||
|
||||
it "has an unsubscribe link" do
|
||||
expect(mail.body.encoded).to match "Unsubscribe from harvest reminders"
|
||||
end
|
||||
end
|
||||
end
|
||||
42
spec/models/activity_spec.rb
Normal file
42
spec/models/activity_spec.rb
Normal file
@@ -0,0 +1,42 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Activity do
|
||||
describe '.homepage_records' do
|
||||
let(:member1) { create(:member) }
|
||||
let(:member2) { create(:member) }
|
||||
|
||||
it 'returns the latest activities per owner' do
|
||||
create(:activity, owner: member1, created_at: 2.days.ago)
|
||||
latest_activity1 = create(:activity, owner: member1, created_at: 1.day.ago)
|
||||
latest_activity2 = create(:activity, owner: member2, created_at: Time.current)
|
||||
|
||||
records = described_class.homepage_records(10)
|
||||
expect(records).to contain_exactly(latest_activity1, latest_activity2)
|
||||
end
|
||||
|
||||
it 'respects the limit' do
|
||||
create(:activity, owner: member1)
|
||||
create(:activity, owner: member2)
|
||||
|
||||
records = described_class.homepage_records(1)
|
||||
expect(records.length).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'active scope' do
|
||||
it 'returns activities that are not finished' do
|
||||
active_activity = create(:activity, finished: false)
|
||||
finished_activity = create(:activity, finished: true)
|
||||
|
||||
expect(described_class.active).to include(active_activity)
|
||||
expect(described_class.active).not_to include(finished_activity)
|
||||
end
|
||||
|
||||
it 'treats nil finished as active' do
|
||||
activity = create(:activity, finished: nil)
|
||||
expect(described_class.active).to include(activity)
|
||||
end
|
||||
end
|
||||
end
|
||||
41
spec/models/harvest_prediction_spec.rb
Normal file
41
spec/models/harvest_prediction_spec.rb
Normal file
@@ -0,0 +1,41 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe "Harvest prediction logic" do
|
||||
let(:crop) { create(:crop, median_days_to_first_harvest: 20) }
|
||||
let(:planting) { create(:planting, crop: crop) }
|
||||
|
||||
describe "#harvest_in_next_week?" do
|
||||
it "is true if predicted harvest is in 5 days" do
|
||||
planting.planted_at = 15.days.ago
|
||||
expect(planting.harvest_in_next_week?).to be true
|
||||
end
|
||||
|
||||
it "is true if predicted harvest is today" do
|
||||
planting.planted_at = 20.days.ago
|
||||
expect(planting.harvest_in_next_week?).to be true
|
||||
end
|
||||
|
||||
it "is true if predicted harvest is in 7 days" do
|
||||
planting.planted_at = 13.days.ago
|
||||
expect(planting.harvest_in_next_week?).to be true
|
||||
end
|
||||
|
||||
it "is false if predicted harvest is in 8 days" do
|
||||
planting.planted_at = 12.days.ago
|
||||
expect(planting.harvest_in_next_week?).to be false
|
||||
end
|
||||
|
||||
it "is false if predicted harvest was yesterday" do
|
||||
planting.planted_at = 21.days.ago
|
||||
expect(planting.harvest_in_next_week?).to be false
|
||||
end
|
||||
|
||||
it "is false if there are already harvests" do
|
||||
planting.planted_at = 15.days.ago
|
||||
create(:harvest, planting: planting, owner: planting.owner, crop: planting.crop, harvested_at: 1.day.ago)
|
||||
expect(planting.harvest_in_next_week?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
87
spec/services/reminder_service_spec.rb
Normal file
87
spec/services/reminder_service_spec.rb
Normal file
@@ -0,0 +1,87 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe ReminderService do
|
||||
let(:member) { create(:member, send_planting_reminder: true, send_harvest_reminder: true) }
|
||||
let(:bot) { create(:cropbot) }
|
||||
let(:service) { ReminderService.new }
|
||||
|
||||
before do
|
||||
allow(Member).to receive(:find_by).with(login_name: 'cropbot').and_return(bot)
|
||||
member.confirm
|
||||
end
|
||||
|
||||
describe "#send_planting_reminders" do
|
||||
context "on Monday" do
|
||||
before do
|
||||
Timecop.freeze(Time.zone.parse("2025-05-19")) # A Monday
|
||||
end
|
||||
|
||||
after do
|
||||
Timecop.return
|
||||
end
|
||||
|
||||
it "creates a notification if member has active plantings" do
|
||||
create(:planting, owner: member)
|
||||
expect {
|
||||
service.send_planting_reminders
|
||||
}.to change(Notification, :count).by(1)
|
||||
end
|
||||
|
||||
it "does not create a notification if member has no active plantings" do
|
||||
expect {
|
||||
service.send_planting_reminders
|
||||
}.not_to change(Notification, :count)
|
||||
end
|
||||
end
|
||||
|
||||
context "not on Monday" do
|
||||
before do
|
||||
Timecop.freeze(Time.zone.parse("2025-05-20")) # A Tuesday
|
||||
end
|
||||
|
||||
after do
|
||||
Timecop.return
|
||||
end
|
||||
|
||||
it "does nothing" do
|
||||
create(:planting, owner: member)
|
||||
expect {
|
||||
service.send_planting_reminders
|
||||
}.not_to change(Notification, :count)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#send_harvest_reminders" do
|
||||
context "on Wednesday" do
|
||||
before do
|
||||
Timecop.freeze(Time.zone.parse("2025-05-21")) # A Wednesday
|
||||
end
|
||||
|
||||
after do
|
||||
Timecop.return
|
||||
end
|
||||
|
||||
it "creates a notification if member has plantings ready to harvest" do
|
||||
planting = create(:planting, owner: member)
|
||||
# Mock harvest_in_next_week?
|
||||
allow_any_instance_of(Planting).to receive(:harvest_in_next_week?).and_return(true)
|
||||
|
||||
expect {
|
||||
service.send_harvest_reminders
|
||||
}.to change(Notification, :count).by(1)
|
||||
end
|
||||
|
||||
it "does not create a notification if no plantings are ready" do
|
||||
create(:planting, owner: member)
|
||||
allow_any_instance_of(Planting).to receive(:harvest_in_next_week?).and_return(false)
|
||||
|
||||
expect {
|
||||
service.send_harvest_reminders
|
||||
}.not_to change(Notification, :count)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -50,7 +50,6 @@ RSpec.configure do |config|
|
||||
Photo.reindex
|
||||
Planting.reindex
|
||||
Seed.reindex
|
||||
Activity.reindex
|
||||
end
|
||||
|
||||
config.before(:suite) do
|
||||
|
||||
28
spec/tasks/growstuff_rake_spec.rb
Normal file
28
spec/tasks/growstuff_rake_spec.rb
Normal file
@@ -0,0 +1,28 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
require 'rake'
|
||||
|
||||
describe 'growstuff:reminders' do
|
||||
before :all do
|
||||
Rails.application.load_tasks
|
||||
end
|
||||
|
||||
let(:planting_task) { Rake::Task['growstuff:send_planting_reminder'] }
|
||||
let(:harvest_task) { Rake::Task['growstuff:send_harvest_reminders'] }
|
||||
|
||||
before do
|
||||
planting_task.reenable
|
||||
harvest_task.reenable
|
||||
end
|
||||
|
||||
it "calls ReminderService for planting reminders" do
|
||||
expect_any_instance_of(ReminderService).to receive(:send_planting_reminders)
|
||||
planting_task.invoke
|
||||
end
|
||||
|
||||
it "calls ReminderService for harvest reminders" do
|
||||
expect_any_instance_of(ReminderService).to receive(:send_harvest_reminders)
|
||||
harvest_task.invoke
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user