Merge pull request #329 from foodcoops/feature/order-schedule
Allow to specify an order schedule for new orders.
This commit is contained in:
commit
e63a34edd3
16 changed files with 204 additions and 8 deletions
2
Gemfile
2
Gemfile
|
@ -40,6 +40,8 @@ gem 'whenever', require: false # For defining cronjobs, see config/schedule.rb
|
||||||
gem 'protected_attributes'
|
gem 'protected_attributes'
|
||||||
gem 'ruby-units'
|
gem 'ruby-units'
|
||||||
gem 'attribute_normalizer'
|
gem 'attribute_normalizer'
|
||||||
|
gem 'ice_cube', github: 'wvengen/ice_cube', branch: 'issues/50-from_ical-rebased' # fork until merged
|
||||||
|
gem 'recurring_select'
|
||||||
|
|
||||||
# we use the git version of acts_as_versioned, and need to include it in this Gemfile
|
# we use the git version of acts_as_versioned, and need to include it in this Gemfile
|
||||||
gem 'acts_as_versioned', github: 'technoweenie/acts_as_versioned'
|
gem 'acts_as_versioned', github: 'technoweenie/acts_as_versioned'
|
||||||
|
|
15
Gemfile.lock
15
Gemfile.lock
|
@ -11,6 +11,13 @@ GIT
|
||||||
acts_as_versioned (0.6.0)
|
acts_as_versioned (0.6.0)
|
||||||
activerecord (>= 3.0.9)
|
activerecord (>= 3.0.9)
|
||||||
|
|
||||||
|
GIT
|
||||||
|
remote: git://github.com/wvengen/ice_cube.git
|
||||||
|
revision: addacaab715c70c5a9b736767ae98032e8efab92
|
||||||
|
branch: issues/50-from_ical-rebased
|
||||||
|
specs:
|
||||||
|
ice_cube (0.12.1)
|
||||||
|
|
||||||
PATH
|
PATH
|
||||||
remote: lib/foodsoft_messages
|
remote: lib/foodsoft_messages
|
||||||
specs:
|
specs:
|
||||||
|
@ -266,6 +273,12 @@ GEM
|
||||||
activesupport (>= 3.0)
|
activesupport (>= 3.0)
|
||||||
i18n
|
i18n
|
||||||
polyamorous (~> 1.1)
|
polyamorous (~> 1.1)
|
||||||
|
recurring_select (1.2.1)
|
||||||
|
coffee-rails (>= 3.1)
|
||||||
|
ice_cube (>= 0.11)
|
||||||
|
jquery-rails (>= 3.0)
|
||||||
|
rails (>= 3.2)
|
||||||
|
sass-rails (>= 3.1)
|
||||||
redis (3.1.0)
|
redis (3.1.0)
|
||||||
redis-namespace (1.5.1)
|
redis-namespace (1.5.1)
|
||||||
redis (~> 3.0, >= 3.0.4)
|
redis (~> 3.0, >= 3.0.4)
|
||||||
|
@ -423,6 +436,7 @@ DEPENDENCIES
|
||||||
haml-rails
|
haml-rails
|
||||||
i18n-js (~> 3.0.0.rc6)
|
i18n-js (~> 3.0.0.rc6)
|
||||||
i18n-spec
|
i18n-spec
|
||||||
|
ice_cube!
|
||||||
inherited_resources
|
inherited_resources
|
||||||
jquery-rails
|
jquery-rails
|
||||||
kaminari
|
kaminari
|
||||||
|
@ -442,6 +456,7 @@ DEPENDENCIES
|
||||||
rails-i18n
|
rails-i18n
|
||||||
rails-settings-cached
|
rails-settings-cached
|
||||||
ransack
|
ransack
|
||||||
|
recurring_select
|
||||||
resque
|
resque
|
||||||
rspec-core (~> 2.99)
|
rspec-core (~> 2.99)
|
||||||
rspec-rails
|
rspec-rails
|
||||||
|
|
|
@ -18,6 +18,7 @@
|
||||||
//= require stupidtable
|
//= require stupidtable
|
||||||
//= require touchclick
|
//= require touchclick
|
||||||
//= require delta_input
|
//= require delta_input
|
||||||
|
//= require recurring_select
|
||||||
|
|
||||||
// Load following statements, when DOM is ready
|
// Load following statements, when DOM is ready
|
||||||
$(function() {
|
$(function() {
|
||||||
|
|
|
@ -4,4 +4,5 @@
|
||||||
*= require token-input-bootstrappy
|
*= require token-input-bootstrappy
|
||||||
*= require bootstrap-datepicker
|
*= require bootstrap-datepicker
|
||||||
*= require list.unlist
|
*= require list.unlist
|
||||||
|
*= require recurring_select
|
||||||
*/
|
*/
|
|
@ -15,6 +15,7 @@ class Admin::ConfigsController < Admin::BaseController
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
|
parse_recurring_selects! params[:config][:order_schedule]
|
||||||
ActiveRecord::Base.transaction do
|
ActiveRecord::Base.transaction do
|
||||||
# TODO support nested configuration keys
|
# TODO support nested configuration keys
|
||||||
params[:config].each do |key, val|
|
params[:config].each do |key, val|
|
||||||
|
@ -36,4 +37,16 @@ class Admin::ConfigsController < Admin::BaseController
|
||||||
@tabs.uniq!
|
@tabs.uniq!
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# turn recurring rules into something palatable
|
||||||
|
def parse_recurring_selects!(config)
|
||||||
|
if config
|
||||||
|
for k in [:pickup, :ends] do
|
||||||
|
if config[k] and config[k][:recurr]
|
||||||
|
config[k][:recurr] = ActiveSupport::JSON.decode(config[k][:recurr])
|
||||||
|
config[k][:recurr] = FoodsoftDateUtil.rule_from(config[k][:recurr]).to_ical if config[k][:recurr]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -62,7 +62,7 @@ class OrdersController < ApplicationController
|
||||||
|
|
||||||
# Page to create a new order.
|
# Page to create a new order.
|
||||||
def new
|
def new
|
||||||
@order = Order.new starts: Time.now, ends: 4.days.from_now, supplier_id: params[:supplier_id]
|
@order = Order.new(supplier_id: params[:supplier_id]).init_dates
|
||||||
end
|
end
|
||||||
|
|
||||||
# Save a new order.
|
# Save a new order.
|
||||||
|
|
|
@ -6,11 +6,12 @@ module Admin::ConfigsHelper
|
||||||
# @param key [Symbol, String] Configuration key.
|
# @param key [Symbol, String] Configuration key.
|
||||||
# @param options [Hash] Options passed to the form builder.
|
# @param options [Hash] Options passed to the form builder.
|
||||||
# @option options [Boolean] :required Wether field is shown as required (default not).
|
# @option options [Boolean] :required Wether field is shown as required (default not).
|
||||||
|
# @option options [Array<IceCube::Rule>] :rules Rules for +as: :recurring_select+
|
||||||
# @return [String] Form input for configuration key.
|
# @return [String] Form input for configuration key.
|
||||||
# @todo find way to pass current value to time_zone input without using default
|
# @todo find way to pass current value to time_zone input without using default
|
||||||
def config_input(form, key, options = {}, &block)
|
def config_input(form, key, options = {}, &block)
|
||||||
return unless @cfg.allowed_key? key
|
return unless @cfg.allowed_key? key
|
||||||
options[:label] = config_input_label(form, key)
|
options[:label] ||= config_input_label(form, key)
|
||||||
options[:required] ||= false
|
options[:required] ||= false
|
||||||
options[:input_html] ||= {}
|
options[:input_html] ||= {}
|
||||||
config_input_field_options form, key, options[:input_html]
|
config_input_field_options form, key, options[:input_html]
|
||||||
|
@ -26,6 +27,7 @@ module Admin::ConfigsHelper
|
||||||
options[:default] = options[:input_html].delete(:value)
|
options[:default] = options[:input_html].delete(:value)
|
||||||
return form.input key, options, &block
|
return form.input key, options, &block
|
||||||
end
|
end
|
||||||
|
block ||= proc { config_input_field form, key, options.merge(options[:input_html]) } if options[:as] == :select_recurring
|
||||||
form.input key, options, &block
|
form.input key, options, &block
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -53,6 +55,11 @@ module Admin::ConfigsHelper
|
||||||
unchecked_value = options.delete(:unchecked_value) || 'false'
|
unchecked_value = options.delete(:unchecked_value) || 'false'
|
||||||
options[:checked] = 'checked' if v=options.delete(:value) and v!='false'
|
options[:checked] = 'checked' if v=options.delete(:value) and v!='false'
|
||||||
form.hidden_field(key, value: unchecked_value, as: :hidden) + form.check_box(key, options, checked_value, false)
|
form.hidden_field(key, value: unchecked_value, as: :hidden) + form.check_box(key, options, checked_value, false)
|
||||||
|
elsif options[:as] == :select_recurring
|
||||||
|
options[:value] = FoodsoftDateUtil.rule_from(options[:value])
|
||||||
|
options[:rules] ||= []
|
||||||
|
options[:rules].unshift options[:value] unless options[:value].blank?
|
||||||
|
form.select_recurring key, options.delete(:rules).uniq, options
|
||||||
else
|
else
|
||||||
form.input_field key, options
|
form.input_field key, options
|
||||||
end
|
end
|
||||||
|
@ -123,7 +130,7 @@ module Admin::ConfigsHelper
|
||||||
# set current value
|
# set current value
|
||||||
unless options.has_key?(:value)
|
unless options.has_key?(:value)
|
||||||
value = @cfg
|
value = @cfg
|
||||||
cfg_path.each {|n| value = value[n.to_sym] if value.respond_to? :[] }
|
cfg_path.each {|n| value = value[n] if value.respond_to? :[] }
|
||||||
options[:value] = value
|
options[:value] = value
|
||||||
end
|
end
|
||||||
options
|
options
|
||||||
|
|
|
@ -95,6 +95,21 @@ class Order < ActiveRecord::Base
|
||||||
!ends.nil? && ends < Time.now
|
!ends.nil? && ends < Time.now
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# sets up first guess of dates when initializing a new object
|
||||||
|
# I guess `def initialize` would work, but it's tricky http://stackoverflow.com/questions/1186400
|
||||||
|
def init_dates
|
||||||
|
self.starts ||= Time.now
|
||||||
|
if FoodsoftConfig[:order_schedule]
|
||||||
|
# try to be smart when picking a reference day
|
||||||
|
last = (DateTime.parse(FoodsoftConfig[:order_schedule][:initial]) rescue nil)
|
||||||
|
last ||= Order.finished.reorder(:starts).first.try(:starts)
|
||||||
|
last ||= self.starts
|
||||||
|
# adjust end date
|
||||||
|
self.ends ||= FoodsoftDateUtil.next_occurrence last, self.starts, FoodsoftConfig[:order_schedule][:ends]
|
||||||
|
end
|
||||||
|
self
|
||||||
|
end
|
||||||
|
|
||||||
# search GroupOrder of given Ordergroup
|
# search GroupOrder of given Ordergroup
|
||||||
def group_order(ordergroup)
|
def group_order(ordergroup)
|
||||||
group_orders.where(:ordergroup_id => ordergroup.id).first
|
group_orders.where(:ordergroup_id => ordergroup.id).first
|
||||||
|
|
|
@ -10,3 +10,9 @@
|
||||||
.input-prepend
|
.input-prepend
|
||||||
%span.add-on= t 'number.currency.format.unit'
|
%span.add-on= t 'number.currency.format.unit'
|
||||||
= config_input_field form, :minimum_balance, as: :decimal, class: 'input-small'
|
= config_input_field form, :minimum_balance, as: :decimal, class: 'input-small'
|
||||||
|
|
||||||
|
= form.simple_fields_for :order_schedule do |fields|
|
||||||
|
= fields.simple_fields_for 'ends' do |fields|
|
||||||
|
.fold-line
|
||||||
|
= config_input fields, 'recurr', as: :select_recurring, input_html: {class: 'input-xlarge'}
|
||||||
|
= config_input fields, 'time', input_html: {class: 'input-mini'}
|
||||||
|
|
|
@ -64,6 +64,15 @@ default: &defaults
|
||||||
# not fully enforced right now, since the check is only client-side
|
# not fully enforced right now, since the check is only client-side
|
||||||
# minimum_balance: 0
|
# minimum_balance: 0
|
||||||
|
|
||||||
|
# default order schedule, used to provide initial dates for new orders
|
||||||
|
# (recurring dates in ical format; no spaces!)
|
||||||
|
#order_schedule:
|
||||||
|
# ends:
|
||||||
|
# recurr: FREQ=WEEKLY;INTERVAL=2;BYDAY=MO
|
||||||
|
# time: '9:00'
|
||||||
|
# # reference point, this is generally the first pickup day; empty is often ok
|
||||||
|
# #initial:
|
||||||
|
|
||||||
# When use_nick is enabled, there will be a nickname field in the user form,
|
# When use_nick is enabled, there will be a nickname field in the user form,
|
||||||
# and the option to show a nickname instead of full name to foodcoop members.
|
# and the option to show a nickname instead of full name to foodcoop members.
|
||||||
# Members of a user's groups and administrators can still see full names.
|
# Members of a user's groups and administrators can still see full names.
|
||||||
|
|
|
@ -458,6 +458,11 @@ en:
|
||||||
mailing_list_subscribe: Email address where members can send an email to for subscribing.
|
mailing_list_subscribe: Email address where members can send an email to for subscribing.
|
||||||
minimum_balance: Members can only order when their account balance is above or equal to this amount.
|
minimum_balance: Members can only order when their account balance is above or equal to this amount.
|
||||||
name: The name of your foodcoop.
|
name: The name of your foodcoop.
|
||||||
|
order_schedule:
|
||||||
|
initial: Schedule starts at this date.
|
||||||
|
ends:
|
||||||
|
recurr: Schedule for default order closing date.
|
||||||
|
time: Default time when orders are closed.
|
||||||
page_footer: Shown on each page at the bottom. Enter "blank" to disable the footer completely.
|
page_footer: Shown on each page at the bottom. Enter "blank" to disable the footer completely.
|
||||||
pdf_add_page_breaks:
|
pdf_add_page_breaks:
|
||||||
order_by_articles: Put each article on a separate page.
|
order_by_articles: Put each article on a separate page.
|
||||||
|
@ -496,6 +501,11 @@ en:
|
||||||
mailing_list_subscribe: Mailing-list subscribe
|
mailing_list_subscribe: Mailing-list subscribe
|
||||||
minimum_balance: Minimum account balance
|
minimum_balance: Minimum account balance
|
||||||
name: Name
|
name: Name
|
||||||
|
order_schedule:
|
||||||
|
initial: Schedule start
|
||||||
|
ends:
|
||||||
|
recurr: Order ends
|
||||||
|
time: time
|
||||||
page_footer: Page footer
|
page_footer: Page footer
|
||||||
pdf_add_page_breaks: Page breaks
|
pdf_add_page_breaks: Page breaks
|
||||||
pdf_font_size: Font size
|
pdf_font_size: Font size
|
||||||
|
|
|
@ -42,7 +42,7 @@ module DateTimeAttributeValidate
|
||||||
self.instance_variable_get("@#{attribute}_date_value") || self.send("#{attribute}_date").try {|e| e.strftime('%Y-%m-%d')}
|
self.instance_variable_get("@#{attribute}_date_value") || self.send("#{attribute}_date").try {|e| e.strftime('%Y-%m-%d')}
|
||||||
end
|
end
|
||||||
define_method("#{attribute}_time_value") do
|
define_method("#{attribute}_time_value") do
|
||||||
self.instance_variable_get("@#{attribute}_time_value") || self.send("#{attribute}_time").try {|e| e.strftime('%H:%m')}
|
self.instance_variable_get("@#{attribute}_time_value") || self.send("#{attribute}_time").try {|e| e.strftime('%H:%M')}
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
30
lib/foodsoft_date_util.rb
Normal file
30
lib/foodsoft_date_util.rb
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
module FoodsoftDateUtil
|
||||||
|
# find next occurence given a recurring ical string and time
|
||||||
|
def self.next_occurrence(start=Time.now, from=start, options={})
|
||||||
|
if options[:recurr]
|
||||||
|
schedule = IceCube::Schedule.new(start)
|
||||||
|
schedule.add_recurrence_rule rule_from(options[:recurr])
|
||||||
|
# @todo handle ical parse errors
|
||||||
|
occ = (schedule.next_occurrence(from).to_time rescue nil)
|
||||||
|
else
|
||||||
|
occ = start
|
||||||
|
end
|
||||||
|
if occ and options[:time]
|
||||||
|
occ = occ.beginning_of_day.advance(seconds: Time.parse(options[:time]).seconds_since_midnight)
|
||||||
|
end
|
||||||
|
occ
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param p [String, Symbol, Hash, IceCube::Rule] What to return a rule from.
|
||||||
|
# @return [IceCube::Rule] Recurring rule
|
||||||
|
def self.rule_from(p)
|
||||||
|
if p.is_a? String
|
||||||
|
IceCube::Rule.from_ical(p)
|
||||||
|
elsif p.is_a? Hash
|
||||||
|
IceCube::Rule.from_hash(p)
|
||||||
|
else
|
||||||
|
p
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
|
@ -48,9 +48,18 @@ describe 'admin/configs', type: :feature do
|
||||||
|
|
||||||
def get_full_config
|
def get_full_config
|
||||||
cfg = FoodsoftConfig.to_hash.deep_dup
|
cfg = FoodsoftConfig.to_hash.deep_dup
|
||||||
cfg.each {|k,v| v.reject! {|k,v| v.blank?} if v.is_a? Hash}
|
compact_hash_deep!(cfg)
|
||||||
cfg.reject! {|k,v| v.blank?}
|
end
|
||||||
cfg
|
|
||||||
|
def compact_hash_deep!(h)
|
||||||
|
h.each do |k,v|
|
||||||
|
if v.is_a? Hash
|
||||||
|
compact_hash_deep!(v)
|
||||||
|
v.reject! {|k,v| v.blank?}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
h.reject! {|k,v| v.blank?}
|
||||||
|
h
|
||||||
end
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|
61
spec/integration/order_spec.rb
Normal file
61
spec/integration/order_spec.rb
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
require_relative '../spec_helper'
|
||||||
|
|
||||||
|
describe Order, type: :feature do
|
||||||
|
let(:admin) { create :user, groups:[create(:workgroup, role_orders: true)] }
|
||||||
|
let(:article) { create :article, unit_quantity: 1 }
|
||||||
|
let(:order) { create :order, supplier: article.supplier, article_ids: [article.id] } # need to ref article
|
||||||
|
let(:go1) { create :group_order, order: order }
|
||||||
|
let(:oa) { order.order_articles.find_by_article_id(article.id) }
|
||||||
|
let(:goa1) { create :group_order_article, group_order: go1, order_article: oa }
|
||||||
|
|
||||||
|
describe type: :feature, js: true do
|
||||||
|
before { login admin }
|
||||||
|
|
||||||
|
it 'can get to the new order page' do
|
||||||
|
article.supplier
|
||||||
|
visit orders_path
|
||||||
|
click_link_or_button I18n.t('orders.index.new_order')
|
||||||
|
click_link_or_button order.name
|
||||||
|
expect(page).to have_text I18n.t('orders.new.title')
|
||||||
|
expect(page).to have_text article.name
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'fills in the end date with a schedule' do
|
||||||
|
FoodsoftConfig[:time_zone] = 'UTC'
|
||||||
|
FoodsoftConfig[:order_schedule] = {ends: {recurr: 'FREQ=MONTHLY;BYMONTHDAY=1', time: '12:00'}}
|
||||||
|
visit new_order_path(supplier_id: article.supplier.id)
|
||||||
|
expect(page).to have_text I18n.t('orders.new.title')
|
||||||
|
expect(find_field('order_ends_time_value').value).to eq '12:00'
|
||||||
|
expect(find_field('order_ends_date_value').value).to eq Date.today.next_month.at_beginning_of_month.strftime('%Y-%m-%d')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'can create a new order' do
|
||||||
|
visit new_order_path(supplier_id: article.supplier.id)
|
||||||
|
expect(page).to have_text I18n.t('orders.new.title')
|
||||||
|
find('input[type="submit"]').click
|
||||||
|
expect(Order.count).to eq 1
|
||||||
|
expect(Order.first.supplier).to eq article.supplier
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'can close an order' do
|
||||||
|
setup_and_close_order
|
||||||
|
expect(order).to be_finished
|
||||||
|
expect(page).to_not have_link I18n.t('orders.index.action_end')
|
||||||
|
expect(oa.units_to_order).to eq 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def setup_and_close_order
|
||||||
|
# have at least something ordered
|
||||||
|
goa1.update_quantities 1, 0
|
||||||
|
oa.update_results!
|
||||||
|
# and close the order
|
||||||
|
visit orders_path
|
||||||
|
click_link_or_button I18n.t('orders.index.action_end')
|
||||||
|
accept_alert
|
||||||
|
sleep 0.8
|
||||||
|
order.reload
|
||||||
|
oa.reload
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
|
@ -44,4 +44,21 @@ describe Order do
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe 'with a default end date' do
|
||||||
|
let(:order) { create :order }
|
||||||
|
before do
|
||||||
|
FoodsoftConfig[:order_schedule] = {ends: {recurr: 'FREQ=WEEKLY;BYDAY=MO', time: '9:00'}}
|
||||||
|
order.init_dates
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'to have a correct date' do
|
||||||
|
expect(order.ends.to_date).to eq Date.today.next_week.at_beginning_of_week(:monday)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'to have a correct time' do
|
||||||
|
expect(order.ends.strftime('%H:%M')).to eq '09:00'
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|
Loading…
Reference in a new issue