diff --git a/app/assets/stylesheets/admin/advanced_settings.scss b/app/assets/stylesheets/admin/advanced_settings.scss index 84666363a8..614d91883b 100644 --- a/app/assets/stylesheets/admin/advanced_settings.scss +++ b/app/assets/stylesheets/admin/advanced_settings.scss @@ -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; } } diff --git a/app/assets/stylesheets/admin/components/buttons.scss b/app/assets/stylesheets/admin/components/buttons.scss new file mode 100644 index 0000000000..078c064c3c --- /dev/null +++ b/app/assets/stylesheets/admin/components/buttons.scss @@ -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; } + } +} \ No newline at end of file diff --git a/app/assets/stylesheets/admin/openfoodnetwork.scss b/app/assets/stylesheets/admin/openfoodnetwork.scss index 99f4a849bb..d946d203bc 100644 --- a/app/assets/stylesheets/admin/openfoodnetwork.scss +++ b/app/assets/stylesheets/admin/openfoodnetwork.scss @@ -1,10 +1,5 @@ @import "variables"; -input[type="submit"], input[type="button"], button, .button { - cursor: pointer; - font-size: 85%; -} - .text-center { text-align: center; } diff --git a/app/assets/stylesheets/admin/shared/forms.scss b/app/assets/stylesheets/admin/shared/forms.scss index 0acdbe92d8..850cf83fc2 100644 --- a/app/assets/stylesheets/admin/shared/forms.scss +++ b/app/assets/stylesheets/admin/shared/forms.scss @@ -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%; diff --git a/app/jobs/order_cycle_closing_job.rb b/app/jobs/order_cycle_closing_job.rb new file mode 100644 index 0000000000..e44d0a54a1 --- /dev/null +++ b/app/jobs/order_cycle_closing_job.rb @@ -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 diff --git a/app/models/order_cycle.rb b/app/models/order_cycle.rb index 48f0afb416..83a9b769fe 100644 --- a/app/models/order_cycle.rb +++ b/app/models/order_cycle.rb @@ -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 diff --git a/app/services/permitted_attributes/order_cycle.rb b/app/services/permitted_attributes/order_cycle.rb index 1a7c9047b3..dd74607168 100644 --- a/app/services/permitted_attributes/order_cycle.rb +++ b/app/services/permitted_attributes/order_cycle.rb @@ -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 diff --git a/app/views/admin/order_cycles/_advanced_settings.html.haml b/app/views/admin/order_cycles/_advanced_settings.html.haml index bfbd6e9c91..bf74af5c33 100644 --- a/app/views/admin/order_cycles/_advanced_settings.html.haml +++ b/app/views/admin/order_cycles/_advanced_settings.html.haml @@ -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') } diff --git a/app/views/admin/order_cycles/edit.html.haml b/app/views/admin/order_cycles/edit.html.haml index 5c700c5b18..35d0704ae4 100644 --- a/app/views/admin/order_cycles/edit.html.haml +++ b/app/views/admin/order_cycles/edit.html.haml @@ -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 diff --git a/config/locales/en.yml b/config/locales/en.yml index bff8d76dec..a14775b280 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -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" diff --git a/config/sidekiq.yml b/config/sidekiq.yml index 7e0eaf1ec7..3c4d93b6af 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -14,3 +14,5 @@ every: "5m" SubscriptionConfirmJob: every: "5m" + OrderCycleClosingJob: + every: "5m" diff --git a/db/migrate/20211129003853_add_processed_at_to_order_cycles.rb b/db/migrate/20211129003853_add_processed_at_to_order_cycles.rb new file mode 100644 index 0000000000..e3b746b4e7 --- /dev/null +++ b/db/migrate/20211129003853_add_processed_at_to_order_cycles.rb @@ -0,0 +1,5 @@ +class AddProcessedAtToOrderCycles < ActiveRecord::Migration[6.1] + def change + add_column :order_cycles, :processed_at, :datetime + end +end diff --git a/db/migrate/20211129014614_add_automatic_notifications_to_order_cycles.rb b/db/migrate/20211129014614_add_automatic_notifications_to_order_cycles.rb new file mode 100644 index 0000000000..0099dc9c35 --- /dev/null +++ b/db/migrate/20211129014614_add_automatic_notifications_to_order_cycles.rb @@ -0,0 +1,5 @@ +class AddAutomaticNotificationsToOrderCycles < ActiveRecord::Migration[6.1] + def change + add_column :order_cycles, :automatic_notifications, :boolean, default: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 831200e2f8..9ce9cd09fd 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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| diff --git a/spec/controllers/admin/order_cycles_controller_spec.rb b/spec/controllers/admin/order_cycles_controller_spec.rb index 9f9be23eb5..9ac31eb77a 100644 --- a/spec/controllers/admin/order_cycles_controller_spec.rb +++ b/spec/controllers/admin/order_cycles_controller_spec.rb @@ -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 diff --git a/spec/jobs/order_cycle_closing_job_spec.rb b/spec/jobs/order_cycle_closing_job_spec.rb new file mode 100644 index 0000000000..1e4ebcf61a --- /dev/null +++ b/spec/jobs/order_cycle_closing_job_spec.rb @@ -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 diff --git a/spec/models/order_cycle_spec.rb b/spec/models/order_cycle_spec.rb index 3a9fd7006f..19a94492ed 100644 --- a/spec/models/order_cycle_spec.rb +++ b/spec/models/order_cycle_spec.rb @@ -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.