mirror of
https://github.com/openfoodfoundation/openfoodnetwork
synced 2026-02-17 00:07:24 +00:00
Merge pull request #8598 from seballot/oc-notify-producers
Configuration to automatically notify producers on order cycle close
This commit is contained in:
@@ -4,18 +4,15 @@
|
||||
background-color: $spree-light-blue;
|
||||
border: 1px solid $pale-blue;
|
||||
margin-bottom: 20px;
|
||||
padding: 20px 20px 0px 20px;
|
||||
}
|
||||
|
||||
.row{
|
||||
margin: 0px -4px;
|
||||
|
||||
padding: 20px 0px;
|
||||
|
||||
.column.alpha, .columns.alpha {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.column.omega, .columns.omega {
|
||||
padding-right: 20px;
|
||||
}
|
||||
#toggle_settings {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
i {
|
||||
display: inline-flex;
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
63
app/assets/stylesheets/admin/components/buttons.scss
Normal file
63
app/assets/stylesheets/admin/components/buttons.scss
Normal file
@@ -0,0 +1,63 @@
|
||||
input[type="submit"], input[type="button"], button, .button {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
font-size: 85%;
|
||||
@include border-radius($border-radius);
|
||||
display: inline-block;
|
||||
padding: 8px 15px;
|
||||
border: none;
|
||||
background-color: $color-btn-bg;
|
||||
color: $color-btn-text;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600 !important;
|
||||
|
||||
&:before {
|
||||
font-weight: normal !important;
|
||||
}
|
||||
|
||||
&:visited, &:active, &:focus { color: $color-btn-text }
|
||||
|
||||
&:hover {
|
||||
background-color: $color-btn-hover-bg;
|
||||
color: $color-btn-hover-text;
|
||||
}
|
||||
|
||||
&:active:focus {
|
||||
box-shadow: 0 0 8px 0 darken($color-btn-hover-bg, 5) inset;
|
||||
}
|
||||
|
||||
&.fullwidth {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&.secondary {
|
||||
background-color: transparent;
|
||||
border: 1px solid $color-btn-bg;
|
||||
color: $color-btn-bg;
|
||||
|
||||
&:hover, &:active, &:focus {
|
||||
background-color: #ebf3fb;
|
||||
}
|
||||
|
||||
&:active:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
transform: translateY(-50%);
|
||||
font-size: 10px;
|
||||
text-transform: capitalize;
|
||||
padding: 0px 5px;
|
||||
border-radius: 3px;
|
||||
|
||||
&:before { padding: 0 }
|
||||
|
||||
&.danger { background-color: $warning-red; }
|
||||
&.success { background-color: $spree-green; }
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,5 @@
|
||||
@import "variables";
|
||||
|
||||
input[type="submit"], input[type="button"], button, .button {
|
||||
cursor: pointer;
|
||||
font-size: 85%;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -54,53 +54,6 @@ label {
|
||||
|
||||
.label-block label { display: block }
|
||||
|
||||
input[type="submit"],
|
||||
input[type="button"],
|
||||
button, .button {
|
||||
@include border-radius($border-radius);
|
||||
display: inline-block;
|
||||
padding: 8px 15px;
|
||||
border: none;
|
||||
background-color: $color-btn-bg;
|
||||
color: $color-btn-text;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600 !important;
|
||||
|
||||
&:before {
|
||||
font-weight: normal !important;
|
||||
}
|
||||
|
||||
&:visited, &:active, &:focus { color: $color-btn-text }
|
||||
|
||||
&:hover {
|
||||
background-color: $color-btn-hover-bg;
|
||||
color: $color-btn-hover-text;
|
||||
}
|
||||
|
||||
&:active:focus {
|
||||
box-shadow: 0 0 8px 0 darken($color-btn-hover-bg, 5) inset;
|
||||
}
|
||||
|
||||
&.fullwidth {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&.secondary {
|
||||
background-color: transparent;
|
||||
border: 1px solid $color-btn-bg;
|
||||
color: $color-btn-bg;
|
||||
|
||||
&:hover, &:active, &:focus {
|
||||
background-color: #ebf3fb;
|
||||
}
|
||||
|
||||
&:active:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
span.info {
|
||||
font-style: italic;
|
||||
font-size: 85%;
|
||||
|
||||
32
app/jobs/order_cycle_closing_job.rb
Normal file
32
app/jobs/order_cycle_closing_job.rb
Normal file
@@ -0,0 +1,32 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class OrderCycleClosingJob < ActiveJob::Base
|
||||
def perform
|
||||
return if recently_closed_order_cycles.empty?
|
||||
|
||||
send_notifications
|
||||
mark_as_processed
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def recently_closed_order_cycles
|
||||
@recently_closed_order_cycles ||= OrderCycle.closed.unprocessed.
|
||||
where(
|
||||
'order_cycles.orders_close_at BETWEEN (?) AND (?)', 1.hour.ago, Time.zone.now
|
||||
).select(:id, :automatic_notifications).to_a
|
||||
end
|
||||
|
||||
def send_notifications
|
||||
recently_closed_order_cycles.each do |oc|
|
||||
OrderCycleNotificationJob.perform_later(oc.id) if oc.automatic_notifications?
|
||||
end
|
||||
end
|
||||
|
||||
def mark_as_processed
|
||||
OrderCycle.where(id: recently_closed_order_cycles).update_all(
|
||||
processed_at: Time.zone.now,
|
||||
updated_at: Time.zone.now
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -28,6 +28,8 @@ class OrderCycle < ApplicationRecord
|
||||
|
||||
attr_accessor :incoming_exchanges, :outgoing_exchanges
|
||||
|
||||
before_update :reset_processed_at, if: :will_save_change_to_orders_close_at?
|
||||
|
||||
validates :name, :coordinator_id, presence: true
|
||||
validate :orders_close_at_after_orders_open_at?
|
||||
|
||||
@@ -52,6 +54,7 @@ class OrderCycle < ApplicationRecord
|
||||
where('order_cycles.orders_close_at < ?',
|
||||
Time.zone.now).order("order_cycles.orders_close_at DESC")
|
||||
}
|
||||
scope :unprocessed, -> { where(processed_at: nil) }
|
||||
scope :undated, -> { where('order_cycles.orders_open_at IS NULL OR orders_close_at IS NULL') }
|
||||
scope :dated, -> { where('orders_open_at IS NOT NULL AND orders_close_at IS NOT NULL') }
|
||||
|
||||
@@ -275,4 +278,10 @@ class OrderCycle < ApplicationRecord
|
||||
|
||||
errors.add(:orders_close_at, :after_orders_open_at)
|
||||
end
|
||||
|
||||
def reset_processed_at
|
||||
return unless orders_close_at.present? && orders_close_at_was.present?
|
||||
|
||||
self.processed_at = nil if orders_close_at > orders_close_at_was
|
||||
end
|
||||
end
|
||||
|
||||
@@ -16,6 +16,7 @@ module PermittedAttributes
|
||||
[
|
||||
:name, :orders_open_at, :orders_close_at, :coordinator_id,
|
||||
:preferred_product_selection_from_coordinator_inventory_only,
|
||||
:automatic_notifications,
|
||||
{ schedule_ids: [], coordinator_fee_ids: [] }
|
||||
]
|
||||
end
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
.row
|
||||
.alpha.omega.sixteen.columns
|
||||
%h3= t('.title')
|
||||
%h3= t('.title')
|
||||
|
||||
= form_for [main_app, :admin, @order_cycle] do |f|
|
||||
.row
|
||||
.six.columns.alpha
|
||||
.three.columns.alpha
|
||||
= f.label "enterprise_preferred_product_selection_from_coordinator_inventory_only", t('admin.order_cycles.edit.choose_products_from')
|
||||
.with-tip{'data-powertip' => t('.choose_product_tip', inventory: @order_cycle.coordinator.name)}
|
||||
.with-tip{ 'data-powertip' => t('.choose_product_tip', inventory: @order_cycle.coordinator.name) }
|
||||
%a= t('admin.whats_this')
|
||||
.four.columns
|
||||
= f.radio_button :preferred_product_selection_from_coordinator_inventory_only, true
|
||||
= f.label :preferred_product_selection_from_coordinator_inventory_only, t('.preferred_product_selection_from_coordinator_inventory_only_here')
|
||||
.six.columns.omega
|
||||
.four.columns.omega
|
||||
= f.radio_button :preferred_product_selection_from_coordinator_inventory_only, false
|
||||
= f.label :preferred_product_selection_from_coordinator_inventory_only, t('.preferred_product_selection_from_coordinator_inventory_only_all')
|
||||
|
||||
.row
|
||||
.alpha.three.columns
|
||||
= f.label :automatic_notifications, t('.automatic_notifications')
|
||||
.with-tip{ 'data-powertip' => t('.automatic_notifications_tip') }
|
||||
%a= t('admin.whats_this')
|
||||
.omega.eight.columns
|
||||
= f.check_box :automatic_notifications
|
||||
|
||||
.row
|
||||
.sixteen.columns.alpha.omega.text-center
|
||||
%input{ type: 'submit', value: t('.save_reload') }
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
- content_for :page_actions do
|
||||
- if can? :notify_producers, @order_cycle
|
||||
%li
|
||||
= button_to t(:notify_producers), main_app.notify_producers_admin_order_cycle_path, :id => 'admin_notify_producers', :confirm => t(:are_you_sure)
|
||||
- processed = @order_cycle.processed_at.present?
|
||||
- url = main_app.notify_producers_admin_order_cycle_path
|
||||
- confirm_msg = "#{t('.notify_producers_tip')} #{t(:are_you_sure)}"
|
||||
%a.button.icon-email.with-tip{ href: url, data: { method: 'post', confirm: confirm_msg }, 'data-powertip': t('.notify_producers_tip') }
|
||||
= processed ? t('.re_notify_producers') : t(:notify_producers)
|
||||
- if processed
|
||||
.badge.icon-ok.success
|
||||
|
||||
- content_for :page_title do
|
||||
= t :edit_order_cycle
|
||||
|
||||
@@ -1038,6 +1038,8 @@ en:
|
||||
back_to_list: "Back To List"
|
||||
save_and_back_to_list: "Save and Back to List"
|
||||
choose_products_from: "Choose Products From:"
|
||||
re_notify_producers: Re notify producers
|
||||
notify_producers_tip: This will send an email to each producer with the list of their orders.
|
||||
incoming:
|
||||
incoming: "Incoming"
|
||||
supplier: "Supplier"
|
||||
@@ -1078,6 +1080,8 @@ en:
|
||||
add_supplier: 'Add supplier'
|
||||
add_distributor: 'Add distributor'
|
||||
advanced_settings:
|
||||
automatic_notifications: Automatic notifications
|
||||
automatic_notifications_tip: Automatically notify producers with their orders via emails when order cycles close
|
||||
title: Advanced Settings
|
||||
choose_product_tip: You can restrict products incoming and outgoing to only %{inventory}'s inventory.
|
||||
preferred_product_selection_from_coordinator_inventory_only_here: Coordinator's Inventory Only
|
||||
@@ -2403,7 +2407,7 @@ See the %{link} to find out more about %{sitename}'s features and to start using
|
||||
new_order_cycle: "New Order Cycle"
|
||||
new_order_cycle_tooltip: "Open shop for a certain time period"
|
||||
select_a_coordinator_for_your_order_cycle: "Select a coordinator for your order cycle"
|
||||
notify_producers: 'Notify producers'
|
||||
notify_producers: 'Notify producers'
|
||||
edit_order_cycle: "Edit Order Cycle"
|
||||
roles: "Roles"
|
||||
update: "Update"
|
||||
|
||||
@@ -14,3 +14,5 @@
|
||||
every: "5m"
|
||||
SubscriptionConfirmJob:
|
||||
every: "5m"
|
||||
OrderCycleClosingJob:
|
||||
every: "5m"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddProcessedAtToOrderCycles < ActiveRecord::Migration[6.1]
|
||||
def change
|
||||
add_column :order_cycles, :processed_at, :datetime
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddAutomaticNotificationsToOrderCycles < ActiveRecord::Migration[6.1]
|
||||
def change
|
||||
add_column :order_cycles, :automatic_notifications, :boolean, default: false
|
||||
end
|
||||
end
|
||||
@@ -293,6 +293,8 @@ ActiveRecord::Schema.define(version: 2021_12_17_094141) do
|
||||
t.integer "coordinator_id"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.datetime "processed_at"
|
||||
t.boolean "automatic_notifications", default: false
|
||||
end
|
||||
|
||||
create_table "producer_properties", force: :cascade do |t|
|
||||
|
||||
@@ -216,6 +216,17 @@ module Admin
|
||||
spree_put :update, params.
|
||||
merge(order_cycle: { preferred_product_selection_from_coordinator_inventory_only: true })
|
||||
end
|
||||
|
||||
it "can update preference automatic_notifications" do
|
||||
expect(OrderCycleForm).to receive(:new).
|
||||
with(order_cycle,
|
||||
{ "automatic_notifications" => true },
|
||||
anything) { form_mock }
|
||||
allow(form_mock).to receive(:save) { true }
|
||||
|
||||
spree_put :update, params.
|
||||
merge(order_cycle: { automatic_notifications: true })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
27
spec/jobs/order_cycle_closing_job_spec.rb
Normal file
27
spec/jobs/order_cycle_closing_job_spec.rb
Normal file
@@ -0,0 +1,27 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
describe OrderCycleClosingJob do
|
||||
let(:order_cycle1) {
|
||||
create(:order_cycle, automatic_notifications: true, orders_close_at: Time.zone.now - 1.minute)
|
||||
}
|
||||
let(:order_cycle2) {
|
||||
create(:order_cycle, automatic_notifications: true, orders_close_at: Time.zone.now + 1.minute)
|
||||
}
|
||||
let(:order_cycle3) {
|
||||
create(:order_cycle, automatic_notifications: false, orders_close_at: Time.zone.now - 1.minute)
|
||||
}
|
||||
|
||||
it "sends notifications for recently closed order cycles with automatic notifications enabled" do
|
||||
expect(OrderCycleNotificationJob).to receive(:perform_later).with(order_cycle1.id)
|
||||
expect(OrderCycleNotificationJob).to_not receive(:perform_later).with(order_cycle2.id)
|
||||
expect(OrderCycleNotificationJob).to_not receive(:perform_later).with(order_cycle3.id)
|
||||
|
||||
OrderCycleClosingJob.perform_now
|
||||
end
|
||||
|
||||
it "marks order cycles as processed" do
|
||||
expect{ OrderCycleClosingJob.perform_now }.to change{ order_cycle1.reload.processed_at }
|
||||
end
|
||||
end
|
||||
@@ -371,7 +371,9 @@ describe OrderCycle do
|
||||
it "clones itself" do
|
||||
coordinator = create(:enterprise);
|
||||
oc = create(:simple_order_cycle,
|
||||
coordinator_fees: [create(:enterprise_fee, enterprise: coordinator)], preferred_product_selection_from_coordinator_inventory_only: true)
|
||||
coordinator_fees: [create(:enterprise_fee, enterprise: coordinator)],
|
||||
preferred_product_selection_from_coordinator_inventory_only: true,
|
||||
automatic_notifications: true)
|
||||
ex1 = create(:exchange, order_cycle: oc)
|
||||
ex2 = create(:exchange, order_cycle: oc)
|
||||
oc.clone!
|
||||
@@ -382,6 +384,7 @@ describe OrderCycle do
|
||||
expect(occ.orders_close_at).to be_nil
|
||||
expect(occ.coordinator).not_to be_nil
|
||||
expect(occ.preferred_product_selection_from_coordinator_inventory_only).to be true
|
||||
expect(occ.automatic_notifications).to eq(oc.automatic_notifications)
|
||||
expect(occ.coordinator).to eq(oc.coordinator)
|
||||
|
||||
expect(occ.coordinator_fee_ids).not_to be_empty
|
||||
@@ -542,6 +545,30 @@ describe OrderCycle do
|
||||
end
|
||||
end
|
||||
|
||||
describe "processed_at " do
|
||||
let!(:oc) {
|
||||
create(:simple_order_cycle, orders_open_at: 1.week.ago, orders_close_at: 1.day.ago, processed_at: 1.hour.ago)
|
||||
}
|
||||
|
||||
it "reset processed_at if close date change in future" do
|
||||
expect(oc.processed_at).to_not be_nil
|
||||
oc.update!(orders_close_at: 1.week.from_now)
|
||||
expect(oc.processed_at).to be_nil
|
||||
end
|
||||
|
||||
it "it does not reset processed_at if close date change in the past" do
|
||||
expect(oc.processed_at).to_not be_nil
|
||||
oc.update!(orders_close_at: 2.days.ago)
|
||||
expect(oc.processed_at).to_not be_nil
|
||||
end
|
||||
|
||||
it "it does not reset processed_at if close date do not change" do
|
||||
expect(oc.processed_at).to_not be_nil
|
||||
oc.update!(orders_open_at: 2.weeks.ago)
|
||||
expect(oc.processed_at).to_not be_nil
|
||||
end
|
||||
end
|
||||
|
||||
def core_exchange_attributes(exchange)
|
||||
exterior_attribute_keys = %w(id order_cycle_id created_at updated_at)
|
||||
exchange.attributes.
|
||||
|
||||
Reference in New Issue
Block a user