Merge branch 'master' into 2-0-stable

* master: (206 commits)
  embedded groups layout changes
  embedded groups initial test
  Allow html requests for OrderCycleController#update
  Move applicator calls to OrderCycleForm
  Refactor OrderCycleForm to make logic clearer
  Extract schedule syncing logic into OrderCycleForm
  Add basic specs for OrderCyclesController#create
  Add basic OrderCycleForm to handle create/update logic
  Remove unnecessary respond_to blocks from OrderCyclesController
  Include admin users as managers on new enterprises
  Remove obsolete goWithoutHashFragments
  Simplify Navigation.go, not preserving hash fragments
  Only show change warning for open order cycles
  Use a SubscriptionsCount query object to provide counts to IndexOrderCycleSerializer
  Preload subscription counts for serialization in order cycle collection actions
  Request the subscription count for change warning each time, don't cache
  Ask user to confirm oc date change for open order cycles with subsciptions
  Fix ordering of Gemfile.lock
  Add rack-rewrite to handle redirects
  Renames product bulk edit action to index
  ...
This commit is contained in:
Pau Perez
2018-06-22 13:23:18 +02:00
307 changed files with 10996 additions and 4157 deletions

View File

@@ -2,7 +2,7 @@ version: "2"
plugins:
rubocop:
enabled: true
channel: "rubocop-0-48"
channel: "rubocop-0-55"
scss-lint:
enabled: false
duplication:
@@ -34,3 +34,4 @@ checks:
exclude_patterns:
- "spec/**/*"
- "vendor/**/*"
- "app/assets/javascripts/shared/*"

View File

@@ -9,6 +9,8 @@ context for others to understand it]
[List which features should be tested and how]
[Should we test on mobile?]
#### Release notes
[In case this should be present in the release notes, please write them or

1
.gitignore vendored
View File

@@ -32,7 +32,6 @@ public/stylesheets
public/images
public/spree
config/abr.yml
config/heroku_env.rb
config/newrelic.yml
config/initializers/feature_toggle.rb
config/initializers/db2fog.rb

View File

@@ -14,6 +14,8 @@ AllCops:
- 'vendor/**/*'
- 'node_modules/**/*'
- !ruby/regexp /old_and_unused\.rb$/
# The parser gem fails to parse this file with out current Ruby version.
- 'spec/factories.rb'
# OFN SETTINGS
# Cop settings that have been agreed upon by the OFN community
@@ -143,7 +145,11 @@ Style/TrailingCommaInArguments:
Enabled: false
StyleGuide: http://relaxed.ruby.style/#styletrailingcommainarguments
Style/TrailingCommaInLiteral:
Style/TrailingCommaInArrayLiteral:
Enabled: false
StyleGuide: http://relaxed.ruby.style/#styletrailingcommainliteral
Style/TrailingCommaInHashLiteral:
Enabled: false
StyleGuide: http://relaxed.ruby.style/#styletrailingcommainliteral

File diff suppressed because it is too large Load Diff

10
Gemfile
View File

@@ -2,7 +2,7 @@ source 'https://rubygems.org'
ruby "2.1.5"
git_source(:github) { |repo_name| "https://github.com/#{repo_name}.git" }
gem 'rails', '3.2.21'
gem 'rails', '~> 3.2.22'
gem 'rails-i18n', '~> 3.0.0'
gem 'i18n', '~> 0.6.11'
gem 'i18n-js', '~> 3.0.0'
@@ -25,7 +25,9 @@ gem 'spree_auth_devise', github: 'spree/spree_auth_devise', branch: '2-0-stable'
# - Change type of password from string to password to hide it in the form
gem 'spree_paypal_express', github: "spree-contrib/better_spree_paypal_express", branch: "2-0-stable"
gem 'stripe', '~> 3.3.1'
gem 'activemerchant'
# We need at least this version to have Digicert's root certificate
# which is needed for Pin Payments (and possibly others).
gem 'activemerchant', '~> 1.78'
gem 'oauth2', '~> 1.2.0' # Used for Stripe Connect
gem 'jwt', '~> 1.5'
@@ -60,6 +62,7 @@ gem 'geocoder'
gem 'gmaps4rails'
gem 'spinjs-rails'
gem 'rack-ssl', require: 'rack/ssl'
gem 'rack-rewrite'
gem 'custom_error_message', github: 'jeremydurham/custom-err-msg'
gem 'angularjs-file-upload-rails', '~> 1.1.6'
gem 'roadie-rails', '~> 1.0.3'
@@ -76,6 +79,7 @@ gem 'wkhtmltopdf-binary'
gem 'foreigner'
gem 'immigrant'
gem 'roo', '~> 2.7.0'
gem 'roo-xls', '~> 1.1.0'
gem 'whenever', require: false
@@ -110,7 +114,7 @@ group :test, :development do
gem 'fuubar', '~> 2.2.0'
gem 'rspec-rails', ">= 3.5.2"
gem 'shoulda-matchers'
gem 'factory_girl_rails', require: false
gem "factory_bot_rails", require: false
gem 'capybara', '>= 2.15.4'
gem 'database_cleaner', '0.7.1', require: false
gem 'awesome_print'

View File

@@ -122,12 +122,12 @@ GEM
remote: https://rubygems.org/
specs:
CFPropertyList (2.3.2)
actionmailer (3.2.21)
actionpack (= 3.2.21)
actionmailer (3.2.22.5)
actionpack (= 3.2.22.5)
mail (~> 2.5.4)
actionpack (3.2.21)
activemodel (= 3.2.21)
activesupport (= 3.2.21)
actionpack (3.2.22.5)
activemodel (= 3.2.22.5)
activesupport (= 3.2.22.5)
builder (~> 3.0.0)
erubis (~> 2.7.0)
journey (~> 1.0.4)
@@ -142,18 +142,18 @@ GEM
builder (>= 2.1.2, < 4.0.0)
i18n (>= 0.6.9)
nokogiri (~> 1.4)
activemodel (3.2.21)
activesupport (= 3.2.21)
activemodel (3.2.22.5)
activesupport (= 3.2.22.5)
builder (~> 3.0.0)
activerecord (3.2.21)
activemodel (= 3.2.21)
activesupport (= 3.2.21)
activerecord (3.2.22.5)
activemodel (= 3.2.22.5)
activesupport (= 3.2.22.5)
arel (~> 3.0.2)
tzinfo (~> 0.3.29)
activeresource (3.2.21)
activemodel (= 3.2.21)
activesupport (= 3.2.21)
activesupport (3.2.21)
activeresource (3.2.22.5)
activemodel (= 3.2.22.5)
activesupport (= 3.2.22.5)
activesupport (3.2.22.5)
i18n (~> 0.6, >= 0.6.4)
multi_json (~> 1.0)
acts-as-taggable-on (3.5.0)
@@ -267,10 +267,10 @@ GEM
eventmachine (1.2.3)
excon (0.45.4)
execjs (2.6.0)
factory_girl (4.9.0)
factory_bot (4.10.0)
activesupport (>= 3.0.0)
factory_girl_rails (4.9.0)
factory_girl (~> 4.9.0)
factory_bot_rails (4.10.0)
factory_bot (~> 4.10.0)
railties (>= 3.0.0)
faraday (0.9.2)
multipart-post (>= 1.2, < 3)
@@ -465,7 +465,7 @@ GEM
rb-fsevent (~> 0.9, >= 0.9.4)
rb-inotify (~> 0.9, >= 0.9.7)
lumberjack (1.0.12)
mail (2.5.4)
mail (2.5.5)
mime-types (~> 1.16)
treetop (~> 1.4.8)
method_source (0.9.0)
@@ -476,7 +476,7 @@ GEM
railties (>= 3.1)
money (5.1.1)
i18n (~> 0.6.0)
multi_json (1.12.1)
multi_json (1.13.1)
multi_xml (0.6.0)
multipart-post (2.0.0)
nenv (0.3.0)
@@ -534,28 +534,29 @@ GEM
rabl (0.8.4)
activesupport (>= 2.3.14)
rack (1.4.7)
rack-cache (1.7.0)
rack-cache (1.8.0)
rack (>= 0.4)
rack-livereload (0.3.16)
rack
rack-rewrite (1.5.1)
rack-ssl (1.3.4)
rack
rack-test (0.6.3)
rack (>= 1.0)
rails (3.2.21)
actionmailer (= 3.2.21)
actionpack (= 3.2.21)
activerecord (= 3.2.21)
activeresource (= 3.2.21)
activesupport (= 3.2.21)
rails (3.2.22.5)
actionmailer (= 3.2.22.5)
actionpack (= 3.2.22.5)
activerecord (= 3.2.22.5)
activeresource (= 3.2.22.5)
activesupport (= 3.2.22.5)
bundler (~> 1.0)
railties (= 3.2.21)
railties (= 3.2.22.5)
rails-i18n (3.0.1)
i18n (~> 0.5)
rails (>= 3.0.0, < 4.0.0)
railties (3.2.21)
actionpack (= 3.2.21)
activesupport (= 3.2.21)
railties (3.2.22.5)
actionpack (= 3.2.22.5)
activesupport (= 3.2.22.5)
rack-ssl (~> 1.3.2)
rake (>= 0.8.7)
rdoc (~> 3.4)
@@ -563,7 +564,7 @@ GEM
rainbow (2.2.2)
rake
raindrops (0.13.0)
rake (10.5.0)
rake (12.3.1)
ransack (0.7.2)
actionpack (~> 3.0)
activerecord (~> 3.0)
@@ -592,6 +593,10 @@ GEM
roo (2.7.1)
nokogiri (~> 1)
rubyzip (~> 1.1, < 2.0.0)
roo-xls (1.1.0)
nokogiri
roo (>= 2.0.0beta1, < 3)
spreadsheet (> 0.9.0)
rspec (3.7.0)
rspec-core (~> 3.7.0)
rspec-expectations (~> 3.7.0)
@@ -622,6 +627,7 @@ GEM
rainbow (>= 1.99.1, < 3.0)
ruby-progressbar (~> 1.7)
unicode-display_width (~> 1.0, >= 1.0.1)
ruby-ole (1.2.12.1)
ruby-progressbar (1.8.1)
rubyzip (1.2.0)
safe_yaml (1.0.4)
@@ -640,6 +646,8 @@ GEM
activesupport (>= 3.0.0)
spinjs-rails (1.3)
rails (>= 3.1)
spreadsheet (1.1.7)
ruby-ole (>= 1.0)
sprockets (2.2.3)
hike (~> 1.2)
multi_json (~> 1.0)
@@ -662,7 +670,7 @@ GEM
turbo-sprockets-rails3 (0.3.6)
railties (> 3.2.8, < 4.0.0)
sprockets (>= 2.0.0)
tzinfo (0.3.53)
tzinfo (0.3.54)
uglifier (2.7.1)
execjs (>= 0.3.0)
json (>= 1.8.0)
@@ -701,7 +709,7 @@ PLATFORMS
DEPENDENCIES
active_model_serializers
activemerchant
activemerchant (~> 1.78)
acts-as-taggable-on (~> 3.4)
andand
angular-rails-templates (~> 0.2.0)
@@ -727,7 +735,7 @@ DEPENDENCIES
delayed_job_active_record
diffy
eventmachine (>= 1.2.3)
factory_girl_rails
factory_bot_rails
figaro
foreigner
foundation-icons-sass-rails
@@ -765,13 +773,15 @@ DEPENDENCIES
pry-byebug (>= 3.4.3)
rabl
rack-livereload
rack-rewrite
rack-ssl
rails (= 3.2.21)
rails (~> 3.2.22)
rails-i18n (~> 3.0.0)
redcarpet
representative_view
roadie-rails (~> 1.0.3)
roo (~> 2.7.0)
roo-xls (~> 1.1.0)
rspec-rails (>= 3.5.2)
rspec-retry
rubocop (>= 0.49.1)
@@ -802,4 +812,4 @@ RUBY VERSION
ruby 2.1.5p273
BUNDLED WITH
1.16.1
1.16.2

View File

@@ -5,45 +5,7 @@ guard 'livereload' do
watch(%r{app/views/.+\.(erb|haml|slim)$})
watch(%r{app/helpers/.+\.rb})
watch(%r{public/.+\.(css|js|html)})
#watch(%r{config/locales/.+\.yml})
# Rails Assets Pipeline
watch(%r{(app|vendor)(/assets/\w+/(.+\.(css|js|html|png|jpg))).*}) { |m| "/assets/#{m[3]}" }
end
#guard 'rails' do
#watch('Gemfile.lock')
#watch(%r{^(config|lib)/.*})
#end
#guard 'zeus' do
## uses the .rspec file
## --colour --fail-fast --format documentation --tag ~slow
#watch(%r{^spec/.+_spec\.rb$})
#watch(%r{^app/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" }
#watch(%r{^app/(.+)\.haml$}) { |m| "spec/#{m[1]}.haml_spec.rb" }
#watch(%r{^lib/(.+)\.rb$}) { |m| "spec/lib/#{m[1]}_spec.rb" }
#watch(%r{^app/controllers/(.+)_(controller)\.rb$}) { |m| ["spec/routing/#{m[1]}_routing_spec.rb", "spec/#{m[2]}s/#{m[1]}_#{m[2]}_spec.rb", "spec/requests/#{m[1]}_spec.rb"] }
#end
#guard :rspec do
#watch(%r{^spec/.+_spec\.rb$})
#watch(%r{^lib/(.+)\.rb$}) { |m| "spec/lib/#{m[1]}_spec.rb" }
#watch('spec/spec_helper.rb') { "spec" }
## Rails example
#watch(%r{^app/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" }
#watch(%r{^app/(.*)(\.erb|\.haml|\.slim)$}) { |m| "spec/#{m[1]}#{m[2]}_spec.rb" }
#watch(%r{^app/controllers/(.+)_(controller)\.rb$}) { |m| ["spec/routing/#{m[1]}_routing_spec.rb", "spec/#{m[2]}s/#{m[1]}_#{m[2]}_spec.rb", "spec/acceptance/#{m[1]}_spec.rb"] }
#watch(%r{^spec/support/(.+)\.rb$}) { "spec" }
#watch('config/routes.rb') { "spec/routing" }
#watch('app/controllers/application_controller.rb') { "spec/controllers" }
## Capybara features specs
#watch(%r{^app/views/(.+)/.*\.(erb|haml|slim)$}) { |m| "spec/features/#{m[1]}_spec.rb" }
## Turnip features and steps
#watch(%r{^spec/acceptance/(.+)\.feature$})
#watch(%r{^spec/acceptance/steps/(.+)_steps\.rb$}) { |m| Dir[File.join("**/#{m[1]}.feature")][0] || 'spec/acceptance' }
#end

View File

@@ -103,6 +103,9 @@ Do not forget to run `rake tmp:cache:clear` after locales are updated to reload
* Lynne Davis (https://github.com/lin-d-hop)
* Paul Mackay (https://github.com/pmackay)
* Steve Pettitt (https://github.com/stveep)
* Matt Yorkley (https://github.com/Matt-Yorkley)
* Pau Pérez (https://github.com/sauloperez)
* Enrico Stano (https://github.com/enricostano)
## Licence

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

View File

@@ -36,6 +36,7 @@
//= require ./orders/orders
//= require ./order_cycles/order_cycles
//= require ./payment_methods/payment_methods
//= require ./product_import/product_import
//= require ./products/products
//= require ./resources/resources
//= require ./shipping_methods/shipping_methods

View File

@@ -28,6 +28,7 @@ angular.module("ofn.admin").controller "AdminProductEditCtrl", ($scope, $timeout
$scope.filterTaxons = [{id: "0", name: ""}].concat $scope.taxons
$scope.producerFilter = "0"
$scope.categoryFilter = "0"
$scope.importDateFilter = "0"
$scope.products = BulkProducts.products
$scope.filteredProducts = []
$scope.currentFilters = []
@@ -43,7 +44,7 @@ angular.module("ofn.admin").controller "AdminProductEditCtrl", ($scope, $timeout
.catch (message) ->
$scope.api_error_msg = message
$scope.$watchCollection '[query, producerFilter, categoryFilter]', ->
$scope.$watchCollection '[query, producerFilter, categoryFilter, importDateFilter]', ->
$scope.limit = 15 # Reset limit whenever searching
$scope.fetchProducts = ->
@@ -52,6 +53,9 @@ angular.module("ofn.admin").controller "AdminProductEditCtrl", ($scope, $timeout
$scope.resetProducts()
$scope.loading = false
$timeout ->
if $scope.showLatestImport
$scope.importDateFilter = $scope.importDates[1].id
$scope.resetProducts = ->
DirtyProducts.clear()
@@ -91,6 +95,7 @@ angular.module("ofn.admin").controller "AdminProductEditCtrl", ($scope, $timeout
$scope.query = ""
$scope.producerFilter = "0"
$scope.categoryFilter = "0"
$scope.importDateFilter = "0"
$scope.editWarn = (product, variant) ->
if (DirtyProducts.count() > 0 and confirm(t("unsaved_changes_confirmation"))) or (DirtyProducts.count() == 0)

View File

@@ -1,2 +0,0 @@
angular.module("ofn.admin").controller "enterprisesDashboardCtrl", ($scope) ->
$scope.activeTab = "hubs"

View File

@@ -0,0 +1,6 @@
angular.module("ofn.admin").directive "select2NoSearch", ($timeout) ->
restrict: 'CA'
link: (scope, element, attrs) ->
$timeout ->
element.select2
minimumResultsForSearch: Infinity

View File

@@ -0,0 +1,18 @@
# Used in enterprise new and edit forms to reset the state when the country is changed
angular.module("admin.enterprises").controller 'countryCtrl', ($scope, availableCountries) ->
$scope.countries = availableCountries
$scope.countriesById = $scope.countries.reduce (obj, country) ->
obj[country.id] = country
obj
, {}
$scope.$watch 'Enterprise.address.country_id', (newID, oldID) ->
$scope.clearState() unless $scope.addressStateMatchesCountry()
$scope.clearState = ->
$scope.Enterprise.address.state_id = null
$scope.addressStateMatchesCountry = ->
$scope.countriesById[$scope.Enterprise.address.country_id].states.some (state) ->
state.id == $scope.Enterprise.address.state_id

View File

@@ -11,6 +11,9 @@ angular.module("admin.enterprises")
$scope.$watch 'enterprise_form.$dirty', (newValue) ->
StatusMessage.display 'notice', t('admin.unsaved_changes') if newValue
$scope.$watch 'newManager', (newValue) ->
$scope.addManager($scope.newManager) if newValue
$scope.setFormDirty = ->
$scope.$apply ->
$scope.enterprise_form.$setDirty()
@@ -47,7 +50,7 @@ angular.module("admin.enterprises")
email: manager.email
confirmed: manager.confirmed
if (user for user in $scope.Enterprise.users when user.id == manager.id).length == 0
$scope.Enterprise.users.push manager
$scope.Enterprise.users.unshift(manager)
$scope.enterprise_form?.$setDirty()
else
alert ("#{manager.email}" + " " + t("is_already_manager"))

View File

@@ -0,0 +1,5 @@
angular.module("admin.enterprises").controller 'NewEnterpriseController', ($scope, defaultCountryID) ->
$scope.Enterprise =
address:
country_id: defaultCountryID
state_id: null

View File

@@ -0,0 +1,4 @@
angular.module("ofn.admin").filter "importDate", ($filter) ->
return (products, importDate) ->
return products if importDate == "0"
$filter('filter')( products, { import_date: importDate } )

View File

@@ -0,0 +1,23 @@
angular.module("admin.orderCycles").directive "changeWarning", (ConfirmDialog) ->
restrict: "A"
scope:
orderCycle: '=changeWarning'
link: (scope, element, attrs) ->
acknowledged = false
cancel = 'admin.order_cycles.date_warning.cancel'
proceed = 'admin.order_cycles.date_warning.proceed'
msg = 'admin.order_cycles.date_warning.msg'
options = { cancel: t(cancel), confirm: t(proceed) }
isOpen = (orderCycle) ->
moment(orderCycle.orders_open_at, "YYYY-MM-DD HH:mm:SS Z").isBefore() &&
moment(orderCycle.orders_close_at, "YYYY-MM-DD HH:mm:SS Z").isAfter()
element.focus ->
count = scope.orderCycle.subscriptions_count
return if acknowledged
return unless isOpen(scope.orderCycle)
return if count < 1
ConfirmDialog.open('info', t(msg, n: count), options).then ->
acknowledged = true
element.siblings('img').trigger('click')

View File

@@ -209,6 +209,7 @@ angular.module('admin.orderCycles').factory 'OrderCycle', ($resource, $window, S
delete order_cycle.editable_variants_for_incoming_exchanges
delete order_cycle.editable_variants_for_outgoing_exchanges
delete order_cycle.visible_variants_for_outgoing_exchanges
delete order_cycle.subscriptions_count
order_cycle
removeInactiveExchanges: (order_cycle) ->

View File

@@ -56,7 +56,4 @@ angular.module("admin.orders").directive 'customerSearchOverride', ->
return
$('#order_email').val customer.email
$('#user_id').val customer.user_id # modified
$('#guest_checkout_true').prop 'checked', false
$('#guest_checkout_false').prop 'checked', true
$('#guest_checkout_false').prop 'disabled', false
customer.email

View File

@@ -1,4 +1,4 @@
angular.module("ofn.admin").controller "DropdownPanelsCtrl", ($scope) ->
angular.module("admin.productImport").controller "DropdownPanelsCtrl", ($scope) ->
$scope.active = false
$scope.togglePanel = ->

View File

@@ -0,0 +1,12 @@
angular.module("admin.productImport").controller "ImportFeedbackCtrl", ($scope) ->
$scope.count = (items) ->
total = 0
angular.forEach items, (item) ->
total++
total
$scope.attribute_invalid = (attribute, line_number) ->
$scope.entries[line_number]['errors'][attribute] != undefined
$scope.ignore_fields = ['variant_unit', 'variant_unit_scale', 'unit_description']

View File

@@ -0,0 +1,161 @@
angular.module("admin.productImport").controller "ImportFormCtrl", ($scope, $http, $filter, ProductImportService, $timeout) ->
$scope.entries = {}
$scope.update_counts = {}
$scope.reset_counts = {}
$scope.updates = {}
$scope.updated_total = 0
$scope.updated_ids = []
$scope.update_errors = []
$scope.chunks = 0
$scope.completed = 0
$scope.percentage = "0%"
$scope.started = false
$scope.finished = false
$scope.countResettable = () ->
angular.forEach $scope.supplier_product_counts, (value, key) ->
$scope.reset_counts[key] = value
if $scope.update_counts[key]
$scope.reset_counts[key] -= $scope.update_counts[key]
$scope.resetProgress = () ->
$scope.chunks = 0
$scope.completed = 0
$scope.percentage = "0%"
$scope.started = false
$scope.finished = false
$scope.step = 'settings'
$scope.confirmSettings = () ->
$scope.step = 'import'
$scope.viewResults = () ->
$scope.countResettable()
$scope.step = 'results'
$scope.resetProgress()
$scope.acceptResults = () ->
$scope.step = 'save'
$scope.finalResults = () ->
$scope.step = 'complete'
$scope.start = () ->
$scope.started = true
$scope.percentage = "1%"
total = $scope.item_count
size = 100
$scope.chunks = Math.ceil(total / size)
i = 0
while i < $scope.chunks
start = (i*size)+1
end = (i+1)*size
if $scope.step == 'import'
$scope.processImport(start, end)
if $scope.step == 'save'
$scope.processSave(start, end)
i++
$scope.processImport = (start, end) ->
$scope.getSettings() if $scope.importSettings == null
$http(
url: $scope.import_url
method: 'POST'
data:
'start': start
'end': end
'filepath': $scope.filepath
'settings': $scope.importSettings
).success((data, status, headers, config) ->
angular.merge($scope.entries, angular.fromJson(data['entries']))
$scope.sortUpdates(data['reset_counts'])
$scope.updateProgress()
).error((data, status, headers, config) ->
$scope.exception = data
console.error(data)
)
$scope.importSettings = null
$scope.getSettings = () ->
$scope.importSettings = ProductImportService.getSettings()
$scope.sortUpdates = (data) ->
angular.forEach data, (value, key) ->
if (key in $scope.update_counts)
$scope.update_counts[key] += value['updates_count']
else
$scope.update_counts[key] = value['updates_count']
$scope.processSave = (start, end) ->
$scope.getSettings() if $scope.importSettings == null
$http(
url: $scope.save_url
method: 'POST'
data:
'start': start
'end': end
'filepath': $scope.filepath
'settings': $scope.importSettings
).success((data, status, headers, config) ->
$scope.sortResults(data['results'])
angular.forEach data['updated_ids'], (id) ->
$scope.updated_ids.push(id)
angular.forEach data['errors'], (error) ->
$scope.update_errors.push(error)
$scope.updateProgress()
).error((data, status, headers, config) ->
$scope.exception = data
console.error(data)
)
$scope.sortResults = (results) ->
angular.forEach results, (value, key) ->
if ($scope.updates[key] != undefined)
$scope.updates[key] += value
else
$scope.updates[key] = value
$scope.updated_total += value
$scope.resetAbsent = () ->
enterprises_to_reset = []
angular.forEach $scope.importSettings, (settings, enterprise) ->
if settings['reset_all_absent']
enterprises_to_reset.push(enterprise)
if enterprises_to_reset.length && $scope.updated_ids.length
$http(
url: $scope.reset_url
method: 'POST'
data:
'filepath': $scope.filepath
'settings': $scope.importSettings
'reset_absent': true,
'updated_ids': $scope.updated_ids,
'enterprises_to_reset': enterprises_to_reset
).success((data, status, headers, config) ->
console.log(data)
$scope.updates.products_reset = data
).error((data, status, headers, config) ->
console.error(data)
)
$scope.updateProgress = () ->
$scope.completed++
$scope.percentage = String(Math.round(($scope.completed / $scope.chunks) * 100)) + '%'
if $scope.completed == $scope.chunks
$scope.finished = true
$scope.resetAbsent() if $scope.step == 'save'

View File

@@ -1,12 +1,38 @@
angular.module("ofn.admin").controller "ImportOptionsFormCtrl", ($scope, $rootScope, ProductImportService) ->
angular.module("admin.productImport").controller "ImportOptionsFormCtrl", ($scope, $rootScope, ProductImportService) ->
$scope.toggleResetAbsent = () ->
confirmed = confirm t('js.product_import.confirmation') if $scope.resetAbsent
$scope.initForm = () ->
$scope.settings = {} if $scope.settings == undefined
$scope.settings[$scope.supplierId] = {
import_into: 'product_list'
defaults:
count_on_hand:
mode: 'overwrite_all'
on_hand:
mode: 'overwrite_all'
tax_category_id:
mode: 'overwrite_all'
shipping_category_id:
mode: 'overwrite_all'
available_on:
mode: 'overwrite_all'
}
$scope.import_into = 'product_list'
if confirmed or !$scope.resetAbsent
ProductImportService.updateResetAbsent($scope.supplierId, $scope.resetCount, $scope.resetAbsent)
$scope.updateImportInto = () ->
$scope.import_into = $scope.settings[$scope.supplierId]['import_into']
$scope.$watch 'settings', (updated) ->
ProductImportService.updateSettings(updated)
, true
$scope.toggleResetAbsent = (id) ->
checked = $scope.settings[id]['reset_all_absent']
confirmed = confirm t('js.product_import.confirmation') if checked
if confirmed or !checked
ProductImportService.updateResetAbsent($scope.supplierId, $scope.reset_counts[$scope.supplierId], checked)
else
$scope.resetAbsent = false
$scope.settings[id]['reset_all_absent'] = false
$scope.resetTotal = ProductImportService.resetTotal

View File

@@ -0,0 +1,32 @@
angular.module("admin.productImport").filter 'entriesFilterValid', ->
(entries, type) ->
if type == 'all'
return entries
filtered = {}
angular.forEach entries, (entry, line_number) ->
validates_as = entry.validates_as
if type == 'valid' and validates_as != '' \
or type == 'invalid' and validates_as == '' \
or type == 'create_product' and validates_as == 'new_product' or validates_as == 'new_variant' \
or type == 'update_product' and validates_as == 'existing_variant' \
or type == 'create_inventory' and validates_as == 'new_inventory_item' \
or type == 'update_inventory' and validates_as == 'existing_inventory_item'
filtered[line_number] = entry
filtered
angular.module("admin.productImport").filter 'entriesFilterSupplier', ->
(entries, supplier) ->
if supplier == 'all'
return entries
filtered = {}
angular.forEach entries, (entry, line_number) ->
if supplier == entry.attributes['supplier']
filtered[line_number] = entry
filtered

View File

@@ -0,0 +1,3 @@
angular.module("admin.productImport", ["ngResource"]).config ($httpProvider) ->
$httpProvider.defaults.headers.common["X-CSRF-Token"] = $("meta[name=csrf-token]").attr("content")
$httpProvider.defaults.headers.common["Accept"] = "application/json, text/javascript, */*"

View File

@@ -1,7 +1,8 @@
angular.module("ofn.admin").factory "ProductImportService", ($rootScope) ->
angular.module("admin.productImport").factory "ProductImportService", ($rootScope) ->
new class ProductImportService
suppliers: {}
resetTotal: 0
settings: {}
updateResetAbsent: (supplierId, resetCount, resetAbsent) ->
if resetAbsent
@@ -13,3 +14,8 @@ angular.module("ofn.admin").factory "ProductImportService", ($rootScope) ->
$rootScope.resetTotal = @resetTotal
updateSettings: (updated) ->
angular.merge(@settings, updated)
getSettings: () ->
@settings

View File

@@ -2,5 +2,7 @@ angular.module("ofn.admin").controller "ProductImageCtrl", ($scope, ProductImage
$scope.imageUploader = ProductImageService.imageUploader
$scope.imagePreview = ProductImageService.imagePreview
$scope.$watch 'product.image_url', (newValue) ->
$scope.imagePreview = newValue if newValue
$scope.$watch 'product.image_url', (newValue, oldValue) ->
if newValue != oldValue
$scope.imagePreview = newValue
$scope.uploadModal.close()

View File

@@ -0,0 +1,9 @@
angular.module("admin.utils").directive "datepicker", ->
require: "ngModel"
link: (scope, element, attrs, ngModel) ->
element.datepicker
dateFormat: "yy-mm-dd"
onSelect: (dateText, inst) ->
scope.$apply (scope) ->
# Fires ngModel.$parsers
ngModel.$setViewValue dateText

View File

@@ -25,7 +25,8 @@ angular.module("admin.utils").directive "tagsWithTranslation", ($timeout) ->
scope.object[scope.tagsAttr] ||= []
compileTagList()
scope.tagAdded = ->
scope.tagAdded = (tag)->
tag.text = tag.text.toLowerCase()
scope.onTagAdded()
compileTagList()

View File

@@ -25,6 +25,7 @@ angular.module("admin.variantOverrides").controller "AdminVariantOverridesCtrl",
$scope.resetSelectFilters = ->
$scope.producerFilter = 0
$scope.importDateFilter = '0'
$scope.query = ''
$scope.resetSelectFilters()

View File

@@ -0,0 +1,12 @@
angular.module("admin.variantOverrides").filter "importDate", ($filter, variantOverrides) ->
return (products, hub_id, date) ->
return [] if !hub_id
return $filter('filter')(products, (product) ->
return true if date == 0 or date == undefined or date == '0' or date == ''
angular.forEach product.variants (variant) ->
angular.forEach variantOverrides (vo) ->
if vo.variant_id == variant.id and vo.import_date == date
return true
false
, true)

View File

@@ -1 +1 @@
angular.module("admin.variantOverrides", ["admin.indexUtils", "admin.utils", "admin.dropdown", "admin.inventoryItems", 'ngTagsInput'])
angular.module("admin.variantOverrides", ["ofn.admin", "admin.indexUtils", "admin.utils", "admin.dropdown", "admin.inventoryItems", 'ngTagsInput'])

View File

@@ -6,7 +6,14 @@ Darkswarm.controller "ForgotCtrl", ($scope, $http, $location, AuthenticationServ
if $scope.spree_user.email != null
$http.post("/user/spree_user/password", {spree_user: $scope.spree_user}).success (data)->
$scope.sent = true
.error (data) ->
$scope.errors = t 'email_not_found'
.error (data, status) ->
$scope.errors = data.error
$scope.user_unconfirmed = (status == 401)
else
$scope.errors = t 'email_required'
$scope.resend_confirmation = ->
$http.post("/user/spree_user/confirmation", {spree_user: $scope.spree_user, return_url: $location.absUrl()}).success (data)->
$scope.messages = t('devise.confirmations.send_instructions')
.error (data) ->
$scope.errors = t('devise.confirmations.failed_to_send')

View File

@@ -1,6 +1,13 @@
Darkswarm.controller "LoginCtrl", ($scope, $timeout, $location, $http, $window, AuthenticationService, Redirections, Loading) ->
$scope.path = "/login"
$scope.modalMessage = null
$scope.$watch (->
AuthenticationService.modalMessage
), (newValue) ->
$scope.errors = newValue
$scope.submit = ->
Loading.message = t 'logging_in'
$http.post("/user/spree_user/sign_in", {spree_user: $scope.spree_user}).success (data)->
@@ -14,7 +21,7 @@ Darkswarm.controller "LoginCtrl", ($scope, $timeout, $location, $http, $window,
$scope.user_unconfirmed = (data.error == t('devise.failure.unconfirmed'))
$scope.resend_confirmation = ->
$http.post("/user/spree_user/confirmation", {spree_user: $scope.spree_user}).success (data)->
$http.post("/user/spree_user/confirmation", {spree_user: $scope.spree_user, return_url: $location.absUrl()}).success (data)->
$scope.messages = t('devise.confirmations.send_instructions')
.error (data) ->
$scope.errors = t('devise.confirmations.failed_to_send')

View File

@@ -1,4 +1,4 @@
Darkswarm.controller "CheckoutCtrl", ($scope, localStorageService, Checkout, CurrentUser, CurrentHub) ->
Darkswarm.controller "CheckoutCtrl", ($scope, localStorageService, Checkout, CurrentUser, CurrentHub, AuthenticationService, SpreeUser, $http) ->
$scope.Checkout = Checkout
$scope.submitted = false
@@ -20,8 +20,29 @@ Darkswarm.controller "CheckoutCtrl", ($scope, localStorageService, Checkout, Cur
$scope.purchase = (event, form) ->
event.preventDefault()
$scope.formdata = form
$scope.submitted = true
if form.$valid
if CurrentUser.id
$scope.validateForm(form)
else
$scope.ensureUserIsGuest()
$scope.validateForm = ->
if $scope.formdata.$valid
$scope.Checkout.purchase()
else
$scope.$broadcast 'purchaseFormInvalid', form
$scope.$broadcast 'purchaseFormInvalid', $scope.formdata
$scope.ensureUserIsGuest = (callback = null) ->
$http.post("/user/registered_email", {email: $scope.order.email}).success (data)->
if data.registered == true
$scope.promptLogin()
else
$scope.validateForm() if $scope.submitted
callback() if callback
$scope.promptLogin = ->
SpreeUser.spree_user.email = $scope.order.email
AuthenticationService.pushMessage t('devise.failure.already_registered')
AuthenticationService.open '/login'

View File

@@ -0,0 +1,8 @@
Darkswarm.controller "CountryCtrl", ($scope, availableCountries) ->
$scope.countries = availableCountries
$scope.countriesById = $scope.countries.reduce (obj, country) ->
obj[country.id] = country
obj
, {}

View File

@@ -1,8 +1,16 @@
Darkswarm.controller "DetailsCtrl", ($scope, $timeout) ->
Darkswarm.controller "DetailsCtrl", ($scope, $timeout, $http, CurrentUser, AuthenticationService, SpreeUser) ->
angular.extend(this, new FieldsetMixin($scope))
$scope.name = "details"
$scope.nextPanel = "billing"
$scope.login_or_next = (event) ->
event.preventDefault()
unless CurrentUser.id
$scope.ensureUserIsGuest($scope.next)
return
$scope.next()
$scope.summary = ->
[$scope.fullName(),
$scope.order.email,

View File

@@ -9,6 +9,10 @@ Darkswarm.controller "PaymentCtrl", ($scope, $timeout, savedCreditCards, Dates)
$scope.secrets.card_month = "1"
$scope.secrets.card_year = moment().year()
for card in savedCreditCards when card.is_default
$scope.secrets.selected_card = card.id
break
$scope.summary = ->
[$scope.Checkout.paymentMethod()?.name]

View File

@@ -1,6 +1,7 @@
Darkswarm.controller "CreditCardsCtrl", ($scope, $timeout, CreditCard, CreditCards, Dates) ->
Darkswarm.controller "CreditCardsCtrl", ($scope, CreditCard, CreditCards) ->
angular.extend(this, new FieldsetMixin($scope))
$scope.savedCreditCards = CreditCards.saved
$scope.setDefault = CreditCards.setDefault
$scope.CreditCard = CreditCard
$scope.secrets = CreditCard.secrets
$scope.showForm = CreditCard.show

View File

@@ -19,3 +19,4 @@ Darkswarm.controller "GroupPageCtrl", ($scope, group_enterprises, Enterprises, M
$scope.map = angular.copy MapConfiguration.options
$scope.mapMarkers = OfnMap.enterprise_markers visible_enterprises
$scope.embedded_layout = window.location.search.indexOf("embedded_shopfront=true") != -1

View File

@@ -1,12 +0,0 @@
Darkswarm.controller "ShoppingTabsCtrl", ($scope, $controller, Navigation, $location) ->
angular.extend this, $controller('TabsCtrl', {$scope: $scope})
$scope.tabs =
about: { active: Navigation.isActive('/about') }
producers: { active: Navigation.isActive('/producers') }
contact: { active: Navigation.isActive('/contact') }
groups: { active: Navigation.isActive('/groups') }
$scope.$on '$locationChangeStart', (event, url) ->
tab = $location.path().replace(/^\//, '')
$scope.tabs[tab]?.active = true

View File

@@ -11,8 +11,10 @@ Darkswarm.directive "shippingTypeSelector", ->
scope.selectors =
delivery: scope.filterSelectors.new
icon: "ofn-i_039-delivery"
translation_key: "hubs_delivery"
pickup: scope.filterSelectors.new
icon: "ofn-i_038-takeaway"
translation_key: "hubs_pickup"
scope.emit = ->
scope.shippingTypes =

View File

@@ -0,0 +1,6 @@
Darkswarm.directive "embeddedTargetBlank", ->
restrict: 'A'
compile: (element) ->
elems = (element.children().find("a"))
if window.location.search.indexOf("embedded_shopfront=true") != -1
elems.attr("target", "_blank")

View File

@@ -1,7 +1,13 @@
# This class deals with displaying things in the login modal. It chooses
# the modal tab templates and deals with switching tabs and passing data
# between the tabs. It has direct access to the instance of the login modal,
# and provides that access to other controllers as a service.
Darkswarm.factory "AuthenticationService", (Navigation, $modal, $location, Redirections, Loading)->
new class AuthenticationService
selectedPath: "/login"
modalMessage: null
constructor: ->
if $location.path() in ["/login", "/signup", "/forgot"] || location.pathname is '/register/auth'
@@ -32,6 +38,8 @@ Darkswarm.factory "AuthenticationService", (Navigation, $modal, $location, Redir
'registration_authentication.html'
else
'authentication.html'
pushMessage: (message) ->
@modalMessage = String(message)
select: (path)=>
@selectedPath = path

View File

@@ -1,6 +1,15 @@
Darkswarm.factory 'CreditCards', (savedCreditCards)->
Darkswarm.factory 'CreditCards', ($http, $filter, savedCreditCards, RailsFlashLoader)->
new class CreditCard
saved: savedCreditCards
saved: $filter('orderBy')(savedCreditCards,'-is_default')
add: (card) ->
@saved.push card
setDefault: (card) =>
card.is_default = true
for othercard in @saved when othercard != card
othercard.is_default = false
$http.put("/credit_cards/#{card.id}", is_default: true).then (data) ->
RailsFlashLoader.loadFlash({success: t('js.default_card_updated')})
, (response) ->
RailsFlashLoader.loadFlash({error: response.data.flash.error})

View File

@@ -3,6 +3,7 @@ Darkswarm.factory "EnterpriseModal", ($modal, $rootScope)->
new class EnterpriseModal
open: (enterprise)->
scope = $rootScope.$new(true) # Spawn an isolate to contain the enterprise
scope.embedded_layout = window.location.search.indexOf("embedded_shopfront=true") != -1
scope.enterprise = enterprise
$modal.open(templateUrl: "enterprise_modal.html", scope: scope)

View File

@@ -17,10 +17,9 @@ Darkswarm.factory 'Navigation', ($location, $window) ->
@navigate(path)
go: (path)->
if path.match /^http/
$window.location.href = path
else
$window.location.pathname = path
# The browser treats this like clicking on a link.
# It works for absolute paths, relative paths and URLs alike.
$window.location.href = path
reload: ->
$window.location.reload()

View File

@@ -6,5 +6,5 @@
%img.spinner{ src: "/assets/spinning-circles.svg", ng: { hide: "!imageUploader.isUploading" }}
%img.preview{ng: {src: "{{imagePreview}}", class: "{'faded': imageUploader.isUploading}"}}
%label{for: 'image-upload', class: 'button'} #{t('admin.products.bulk_edit.upload_an_image')}
%label{for: 'image-upload', class: 'button'} {{ 'admin.products.index.upload_an_image' | t }}
%input#image-upload{hidden: true, type: 'file', 'nv-file-select' => true, uploader: "imageUploader"}

View File

@@ -1,7 +1,7 @@
%tags-input{ template: 'admin/tag.html',
"placeholder" => t('admin.order_cycles.form.add_a_tag'),
ng: { model: 'object[tagsAttr]', class: "{'limit-reached': limitReached}"},
on: { tag: { added: 'tagAdded()', removed:'tagRemoved()' } } }
on: { tag: { added: 'tagAdded($tag)', removed:'tagRemoved()' } } }
%auto-complete{ ng: { if: "findTags" }, source: "findTags({query: $query})",
template: "admin/tag_autocomplete.html",
"min-length" => "0",

View File

@@ -2,24 +2,28 @@
%form{ ng: { controller: "ForgotCtrl", submit: "submit()" } }
.row
.large-12.columns
.alert-box.success.radius{"ng-show" => "sent"}
{{'password_reset_sent' | t}}
.alert-box.success{"ng-show" => "sent"}
{{ 'password_reset_sent' | t }}
%div{"ng-show" => "!sent"}
.alert-box.alert{"ng-show" => "errors != null"}
{{ errors }}
.alert-box.success{"ng-show" => "messages != null"}
{{ messages }}
.row
.large-12.columns
%label{for: "email"} {{'signup_email' | t}}
%input.title.input-text{name: "email",
type: "email",
id: "email",
tabindex: 1,
"ng-model" => "spree_user.email"}
.row
.large-12.columns
%input.button.primary{name: "commit",
tabindex: "3",
type: "submit",
value: "{{'reset_password' | t}}"}
.alert-box.alert{"ng-show" => "errors != null"}
{{ errors }}
%a{ng: {show: 'user_unconfirmed', click: 'resend_confirmation()'}}
= t('devise.confirmations.resend_confirmation_email')
.row
.large-12.columns
%label{for: "email"} {{'signup_email' | t}}
%input.title.input-text{name: "email",
type: "email",
id: "email",
tabindex: 1,
"ng-model" => "spree_user.email"}
.row
.large-12.columns
%input.button.primary{name: "commit",
tabindex: "3",
type: "submit",
value: "{{'reset_password' | t}}"}

View File

@@ -15,7 +15,7 @@
.about-container.pad-top
%img.enterprise-logo{"ng-src" => "{{::enterprise.logo}}", "ng-if" => "::enterprise.logo"}
%p.text-small{"ng-bind-html" => "::enterprise.long_description"}
%div{"ng-bind-html" => "::enterprise.long_description"}
.small-12.large-4.columns
%ng-include{src: "'partials/contact.html'"}
%ng-include{src: "'partials/follow.html'"}

View File

@@ -2,7 +2,7 @@
.highlight-top.row
.small-12.medium-7.large-8.columns
%h3{"ng-if" => "::enterprise.is_distributor"}
%a{"ng-href" => "{{::enterprise.path}}", "ofn-change-hub" => "enterprise"}
%a{"ng-href" => "{{::enterprise.path}}", "ofn-change-hub" => "enterprise", "ng-attr-target" => "{{ embedded_layout ? '_blank' : undefined}}"}
%i{"ng-class" => "::enterprise.icon_font"}
%span{"ng-bind" => "::enterprise.name"}
%h3{"ng-if" => "::!enterprise.is_distributor", "ng-class" => "::{'is_producer' : enterprise.is_primary_producer}"}

View File

@@ -14,7 +14,7 @@
{{'hubs_delivery' | t}}
.row
.columns.small-12
%a.cta-hub{"ng-href" => "{{::enterprise.path}}",
%a.cta-hub{"ng-href" => "{{::enterprise.path}}", "ng-attr-target" => "{{ embedded_layout ? '_blank' : undefined}}",
"ng-class" => "{primary: enterprise.active, secondary: !enterprise.active}",
"ofn-change-hub" => "enterprise"}
.hub-name{"ng-bind" => "::enterprise.name"}

View File

@@ -1,4 +1,4 @@
%ul.small-block-grid-2.medium-block-grid-4.large-block-grid-2
%active-selector{"ng-repeat" => "(name, selector) in selectors"}
%i{"ng-class" => "selector.icon"}
{{ name | capitalize }}
{{ selector.translation_key | t | capitalize }}

View File

@@ -1,3 +1,38 @@
.product-import-introduction {
h1, h2, h3, h4, h5, h6 {
margin: 1.5em 0 1em;
}
h6 {
font-size: 1em;
}
p {
margin-bottom: 1em;
}
span.category {
display: inline-block;
background-color: #f3f3f3;
padding: 0.4em 0.8em;
margin: 0 0.4em 0.5em 0;
}
table {
&.product-import-columns tr:hover td {
background-color: transparent;
}
thead th {
text-transform: none;
font-size: 100%;
text-align: left;
}
}
}
div.panel-section {
.neutral {
@@ -15,9 +50,7 @@ div.panel-section {
div.panel-header {
width: 100%;
//font-size: 1.5em;
clear: both;
//border: 1px solid #ccc;
float: left;
padding: 0.5em;
@@ -68,7 +101,6 @@ div.panel-section {
div.panel-content {
width: 100%;
clear: both;
//border: 1px solid #ccc;
margin-bottom: 0.5em;
background-color: #f9f9f9;
padding: 1.5em;
@@ -87,8 +119,6 @@ div.panel-section {
white-space: nowrap;
}
tr.error {
//background-color: #ffe6e4;
//color: #ee4728;
color: #c84C4c;
}
tr:hover td.invalid {
@@ -119,9 +149,7 @@ div.panel-section {
margin-bottom: 0.2em;
}
}
}
}
br.panels.clearfix {
@@ -132,12 +160,6 @@ table.import-settings {
background-color: transparent !important;
width: auto;
//select {
// width: 100%;
//}
tr {
}
tbody tr:hover td {
background-color: #f3f3f3;
}
@@ -159,14 +181,25 @@ table.import-settings {
padding-right: 2.5em;
}
tr:first-child td {
//border-top: 1px solid #eee;
border-top: 0;
}
tr:last-child td {
//border-top: 1px solid #eee;
border-bottom: 0;
}
div.select2-container {
width: 13.5em;
}
div.select2-container.select2-container-disabled {
a.select2-choice, span.select2-arrow {
background-color: #d5d5d5;
}
}
input[disabled], input:disabled {
background-color: #d5d5d5;
opacity: 1;
border-color: transparent;
color: white !important;
}
}
.panel-section.import-settings {
@@ -189,8 +222,6 @@ table.import-settings {
}
}
.post-save-results {
p {
font-size: 1.25em;
@@ -222,4 +253,65 @@ table.import-settings {
font-size: 1.05em;
margin-top: 0.4em;
}
}
}
form.product-import, div.post-save-results, div.import-wrapper {
input[type="submit"] {
margin-right: 0.5em;
}
input[type="submit"], button, a.button {
min-width: 8em;
text-align: center;
}
}
form.product-import, div.save-results {
transition: all linear 0.25s;
}
form.product-import.ng-hide, div.save-results.ng-hide {
opacity: 0;
}
div.import-wrapper {
div.progress-interface {
text-align: center;
transition: all linear 0.25s;
button:disabled {
background: #ccc !important;
}
}
div.progress-interface.ng-hide {
position: absolute;
width: 100%;
opacity: 0;
}
.post-save-results {
a.button{
float: left;
margin-right: 0.5em;
}
}
}
div.progress-bar {
height: 25px;
width: 30em;
max-width: 90%;
margin: 1em auto;
background: #f7f7f7;
padding: 3px;
border-radius: 0.3em;
border: 1px solid #eee;
span.progress-track{
display: block;
background: #b7ea53;
height: 100%;
border-radius: 0.3em;
box-shadow: inset 0 0 3px rgba(0,0,0,0.3);
transition: width 0.5s ease-in-out;
}
}

View File

@@ -0,0 +1,10 @@
.report__table {
margin-top: 2em;
}
.report__message {
margin-top: 2em;
border: 1px solid #cee1f4;
border-radius: .5em;
padding: .5em;
text-align: center;
}

View File

@@ -6,7 +6,7 @@
padding: 4em 2em;
@include fullbg;
background-color: black;
background-image: url("/assets/home/tagline-bg.jpg");
background-repeat: no-repeat;
background-position: center center;

View File

@@ -1,3 +1,5 @@
@import "typography";
body.embedded {
nav.top-bar {
ul.left, ul.center, ul.right li.current_hub {
@@ -28,6 +30,22 @@ body.embedded {
footer {
display: none;
}
.powered-by-embedded {
display: block;
}
.contact {
display: none;
}
.embedded-fullwidth {
width: 100%;
}
#group-page header {
display: none;
}
}
nav.top-bar ul.right li.powered-by {
@@ -51,6 +69,17 @@ nav.top-bar ul.right li.powered-by {
}
}
.powered-by-embedded {
opacity: 0.6;
@include headingFont;
font-size: 1rem;
font-weight: 300;
color: #555;
padding: 0 !important;
display: none;
margin-top: 6px;
}
.blocked-cookies {
text-align: center;
margin-bottom: 0 !important;

View File

@@ -2,18 +2,8 @@
@import "mixins";
@import "branding";
// Foundation overrides
#tabs .tabs-content > .content p {
max-width: 100% !important;
}
.tabs-content > .content {
padding-top: 0 !important;
}
// Tabs styling
#tabs {
.tabset-ctrl#shop-tabs {
background: url("/assets/gray_jean.png") top left repeat;
@include box-shadow(inset 0 2px 3px 0 rgba(0, 0, 0, 0.15));
@@ -21,29 +11,22 @@
display: block;
color: $dark-grey;
.header {
text-align: center;
text-transform: uppercase;
color: $dark-grey;
border-bottom: 1px solid $disabled-dark;
margin-top: 0.75rem;
margin-bottom: 0.75rem;
padding-bottom: 0.25rem;
font-size: 0.875rem;
}
.panel {
border-color: $clr-brick-bright;
background-color: rgba(255, 255, 255, 0);
}
dl dd {
.tab {
text-align: center;
border-top: 4px solid transparent;
@media all and (max-width: 640px) {
text-align: left;
}
>a {
outline: none;
display: block;
background-color: #efefef;
color: #222;
font-family: "Oswald", sans-serif;
}
a {
@include headingFont;
@@ -67,15 +50,8 @@
padding: 0.35em 0 0.65em 0;
text-shadow: none;
}
}
}
// inactive nav link
dl {
dd {
border-top: 4px solid transparent;
a:after {
&:after {
padding-left: 8px;
content: "";
visibility: hidden;
@@ -88,17 +64,13 @@
}
}
dd:hover {
&:hover {
a:after {
visibility: visible;
}
}
}
// active nav link
dl {
dd.active {
&.selected {
border-top: 4px solid $clr-brick;
@media all and (max-width: 640px) {
@@ -129,33 +101,44 @@
// content revealed in accordion
.tabs-content {
.tab-view {
margin-bottom: 0;
padding: 0;
background: none;
border: none;
& > .content {
background: none;
border: none;
img {
margin: 0px 0px 0px 40px;
}
img {
margin: 0px 0px 0px 40px;
p {
max-width: 100%;
@media all and (max-width: 768px) {
height: auto !important;
}
}
p {
max-width: 555px;
ul {
list-style-type: none;
padding-left: none;
}
@media all and (max-width: 768px) {
height: auto !important;
}
}
.header {
text-align: center;
text-transform: uppercase;
color: $dark-grey;
border-bottom: 1px solid $disabled-dark;
margin-top: 0.75rem;
margin-bottom: 0.75rem;
padding-bottom: 0.25rem;
font-size: 0.875rem;
}
ul {
list-style-type: none;
padding-left: none;
}
.panel {
padding-bottom: 1.25em;
}
.panel {
padding-bottom: 1.25em;
border-color: $clr-brick-bright;
background-color: rgba(255, 255, 255, 0);
}
}
}

View File

@@ -2,7 +2,7 @@
@import "mixins";
@import "branding";
.tabset-ctrl {
.tabset-ctrl:not(#shop-tabs) {
.tab-view {
padding-top: 30px;
}

View File

@@ -1,5 +1,7 @@
module Admin
class ManagerInvitationsController < Spree::Admin::BaseController
authorize_resource class: false
def create
@email = params[:email]
@enterprise = Enterprise.find(params[:enterprise_id])
@@ -28,6 +30,8 @@ module Admin
password = Devise.friendly_token
new_user = Spree::User.create(email: @email, unconfirmed_email: @email, password: password)
new_user.reset_password_token = Devise.friendly_token
# Same time as used in Devise's lib/devise/models/recoverable.rb.
new_user.reset_password_sent_at = Time.now.utc
new_user.save!
@enterprise.users << new_user

View File

@@ -1,7 +1,3 @@
require 'open_food_network/permissions'
require 'open_food_network/order_cycle_form_applicator'
require 'open_food_network/proxy_order_syncer'
module Admin
class OrderCyclesController < ResourceController
include OrderCyclesHelper
@@ -9,17 +5,14 @@ module Admin
prepend_before_filter :load_data_for_index, :only => :index
before_filter :require_coordinator, only: :new
before_filter :remove_protected_attrs, only: [:update]
before_filter :check_editable_schedule_ids, only: [:create, :update]
before_filter :require_order_cycle_set_params, only: [:bulk_update]
around_filter :protect_invalid_destroy, only: :destroy
create.after :sync_subscriptions
update.after :sync_subscriptions
def index
respond_to do |format|
format.html
format.json do
render_as_json @collection, ams_prefix: params[:ams_prefix], current_user: spree_current_user
render_as_json @collection, ams_prefix: params[:ams_prefix], current_user: spree_current_user, subscriptions_count: SubscriptionsCount.new(@collection)
end
end
end
@@ -43,51 +36,36 @@ module Admin
end
def create
@order_cycle = OrderCycle.new(params[:order_cycle])
@order_cycle_form = OrderCycleForm.new(@order_cycle, params, spree_current_user)
respond_to do |format|
if @order_cycle.save
OpenFoodNetwork::OrderCycleFormApplicator.new(@order_cycle, spree_current_user).go!
invoke_callbacks(:create, :after)
flash[:notice] = I18n.t(:order_cycles_create_notice)
format.html { redirect_to admin_order_cycles_path }
format.json { render :json => { success: true } }
else
format.html
format.json { render :json => { errors: @order_cycle.errors.full_messages }, status: :unprocessable_entity }
end
if @order_cycle_form.save
flash[:notice] = I18n.t(:order_cycles_create_notice)
render json: { success: true }
else
render json: { errors: @order_cycle.errors.full_messages }, status: :unprocessable_entity
end
end
def update
@order_cycle = OrderCycle.find params[:id]
@order_cycle_form = OrderCycleForm.new(@order_cycle, params, spree_current_user)
respond_to do |format|
if @order_cycle.update_attributes(params[:order_cycle])
unless params[:order_cycle][:incoming_exchanges].nil? && params[:order_cycle][:outgoing_exchanges].nil?
# Only update apply exchange information if it is actually submmitted
OpenFoodNetwork::OrderCycleFormApplicator.new(@order_cycle, spree_current_user).go!
end
invoke_callbacks(:update, :after)
if @order_cycle_form.save
respond_to do |format|
flash[:notice] = I18n.t(:order_cycles_update_notice) if params[:reloading] == '1'
format.html { redirect_to main_app.edit_admin_order_cycle_path(@order_cycle) }
format.json { render :json => { :success => true } }
else
format.json { render :json => { errors: @order_cycle.errors.full_messages }, status: :unprocessable_entity }
format.json { render json: { :success => true } }
end
else
render json: { errors: @order_cycle.errors.full_messages }, status: :unprocessable_entity
end
end
def bulk_update
if order_cycle_set.andand.save
respond_to do |format|
format.json { render_as_json @order_cycles, ams_prefix: 'index', current_user: spree_current_user }
end
render_as_json @order_cycles, ams_prefix: 'index', current_user: spree_current_user, subscriptions_count: SubscriptionsCount.new(@collection)
else
respond_to do |format|
order_cycle = order_cycle_set.collection.find{ |oc| oc.errors.present? }
format.json { render :json => { errors: order_cycle.errors.full_messages }, status: :unprocessable_entity }
end
order_cycle = order_cycle_set.collection.find{ |oc| oc.errors.present? }
render json: { errors: order_cycle.errors.full_messages }, status: :unprocessable_entity
end
end
@@ -193,29 +171,6 @@ module Admin
end
end
def check_editable_schedule_ids
return unless params[:order_cycle][:schedule_ids]
requested = params[:order_cycle][:schedule_ids].map(&:to_i)
@existing_schedule_ids = @order_cycle.persisted? ? @order_cycle.schedule_ids : []
permitted = Schedule.where(id: requested | @existing_schedule_ids).merge(OpenFoodNetwork::Permissions.new(spree_current_user).editable_schedules).pluck(:id)
result = @existing_schedule_ids
result |= (requested & permitted) # add any requested & permitted ids
result -= ((result & permitted) - requested) # remove any existing and permitted ids that were not specifically requested
params[:order_cycle][:schedule_ids] = result
end
def sync_subscriptions
return unless params[:order_cycle][:schedule_ids]
removed_ids = @existing_schedule_ids - @order_cycle.schedule_ids
new_ids = @order_cycle.schedule_ids - @existing_schedule_ids
if removed_ids.any? || new_ids.any?
schedules = Schedule.where(id: removed_ids + new_ids)
subscriptions = Subscription.where(schedule_id: schedules)
syncer = OpenFoodNetwork::ProxyOrderSyncer.new(subscriptions)
syncer.sync!
end
end
def order_cycles_from_set
remove_unauthorized_bulk_attrs
OrderCycle.where(id: params[:order_cycle_set][:collection_attributes].map{ |k,v| v[:id] })

View File

@@ -1,62 +1,95 @@
require 'roo'
class Admin::ProductImportController < Spree::Admin::BaseController
module Admin
class ProductImportController < Spree::Admin::BaseController
before_filter :validate_upload_presence, except: %i[index guide validate_data]
before_filter :validate_upload_presence, except: :index
def import
# Save uploaded file to tmp directory
@filepath = save_uploaded_file(params[:file])
@importer = ProductImporter.new(File.new(@filepath), editable_enterprises)
check_file_errors @importer
check_spreadsheet_has_data @importer
@tax_categories = Spree::TaxCategory.order('is_default DESC, name ASC')
@shipping_categories = Spree::ShippingCategory.order('name ASC')
end
def save
@importer = ProductImporter.new(File.new(params[:filepath]), editable_enterprises, params[:settings])
@importer.save_all if @importer.has_valid_entries?
end
private
def validate_upload_presence
unless params[:file] || (params[:filepath] && File.exist?(params[:filepath]))
redirect_to '/admin/product_import', notice: I18n.t(:product_import_file_not_found_notice)
return
def guide
@product_categories = Spree::Taxon.order('name ASC').pluck(:name).uniq
@tax_categories = Spree::TaxCategory.order('name ASC').pluck(:name)
@shipping_categories = Spree::ShippingCategory.order('name ASC').pluck(:name)
end
end
def check_file_errors(importer)
if importer.errors.present?
redirect_to '/admin/product_import', notice: @importer.errors.full_messages.to_sentence
return
def import
# Save uploaded file to tmp directory
@filepath = save_uploaded_file(params[:file])
@importer = ProductImport::ProductImporter.new(File.new(@filepath), spree_current_user, params[:settings])
@original_filename = params[:file].try(:original_filename)
check_file_errors @importer
check_spreadsheet_has_data @importer
@tax_categories = Spree::TaxCategory.order('is_default DESC, name ASC')
@shipping_categories = Spree::ShippingCategory.order('name ASC')
end
end
def check_spreadsheet_has_data(importer)
unless importer.item_count
redirect_to '/admin/product_import', notice: I18n.t(:product_import_no_data_in_spreadsheet_notice)
return
def validate_data
return unless process_data('validate')
render json: @importer.import_results, response: 200
end
end
def save_uploaded_file(upload)
filename = 'import' + Time.now.strftime('%d-%m-%Y-%H-%M-%S')
extension = '.' + upload.original_filename.split('.').last
directory = 'tmp/product_import'
Dir.mkdir(directory) unless File.exists?(directory)
File.open(Rails.root.join(directory, filename+extension), 'wb') do |f|
f.write(upload.read)
f.path
def save_data
return unless process_data('save')
render json: @importer.save_results, response: 200
end
end
# Define custom model class for Cancan permissions
def model_class
ProductImporter
def reset_absent_products
@importer = ProductImport::ProductImporter.new(File.new(params[:filepath]), spree_current_user, import_into: params[:import_into], enterprises_to_reset: params[:enterprises_to_reset], updated_ids: params[:updated_ids], settings: params[:settings])
if params.key?(:enterprises_to_reset) && params.key?(:updated_ids)
@importer.reset_absent(params[:updated_ids])
end
render json: @importer.products_reset_count
end
private
def validate_upload_presence
unless params[:file] || (params[:filepath] && File.exist?(params[:filepath]))
redirect_to '/admin/product_import', notice: I18n.t(:product_import_file_not_found_notice)
end
end
def process_data(method)
@importer = ProductImport::ProductImporter.new(File.new(params[:filepath]), spree_current_user, start: params[:start], end: params[:end], settings: params[:settings])
begin
@importer.send("#{method}_entries")
rescue StandardError => e
render json: e.message, response: 500
return false
end
true
end
def check_file_errors(importer)
if importer.errors.present?
redirect_to '/admin/product_import', notice: @importer.errors.full_messages.to_sentence
end
end
def check_spreadsheet_has_data(importer)
unless importer.item_count
redirect_to '/admin/product_import', notice: I18n.t(:product_import_no_data_in_spreadsheet_notice)
end
end
def save_uploaded_file(upload)
filename = 'import' + Time.zone.now.strftime('%d-%m-%Y-%H-%M-%S')
extension = '.' + upload.original_filename.split('.').last
directory = 'tmp/product_import'
Dir.mkdir(directory) unless File.exist?(directory)
File.open(Rails.root.join(directory, filename + extension), 'wb') do |f|
f.write(upload.read)
f.path
end
end
# Define custom model class for Cancan permissions
def model_class
ProductImport::ProductImporter
end
end
end

View File

@@ -3,6 +3,7 @@ require 'open_food_network/spree_api_key_loader'
module Admin
class VariantOverridesController < ResourceController
include OpenFoodNetwork::SpreeApiKeyLoader
include EnterprisesHelper
prepend_before_filter :load_data
before_filter :load_collection, only: [:bulk_update]
@@ -55,6 +56,20 @@ module Admin
variant_override_enterprises_per_hub
@inventory_items = InventoryItem.where(enterprise_id: @hubs)
@import_dates = inventory_import_dates.uniq.to_json
end
def inventory_import_dates
import_dates = VariantOverride.
select('DISTINCT variant_overrides.import_date').
where('variant_overrides.hub_id IN (?)
AND variant_overrides.import_date IS NOT NULL', editable_enterprises.collect(&:id)).
order('import_date DESC')
options = [{ id: '0', name: 'All' }]
import_dates.collect(&:import_date).map { |i| options.push(id: i.to_date, name: i.to_date.to_formatted_s(:long)) }
options
end
def load_collection

View File

@@ -4,6 +4,7 @@ class ApplicationController < ActionController::Base
protect_from_forgery
prepend_before_filter :restrict_iframes
before_filter :set_cache_headers # Issue #1213, prevent cart emptying via cache when using back button
include EnterprisesHelper
helper CssSplitter::ApplicationHelper
@@ -59,7 +60,7 @@ class ApplicationController < ActionController::Base
return if embedding_without_https?
response.headers.delete 'X-Frame-Options'
response.headers['Content-Security-Policy'] = "frame-ancestors #{embedded_shopfront_referer}"
response.headers['Content-Security-Policy'] = "frame-ancestors #{URI(request.referer).host.downcase}"
check_embedded_request
set_embedded_layout
@@ -152,4 +153,10 @@ class ApplicationController < ActionController::Base
nil
end
def set_cache_headers # https://jacopretorius.net/2014/01/force-page-to-reload-on-browser-back-in-rails.html
response.headers["Cache-Control"] = "no-cache, no-store, max-age=0, must-revalidate"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "Fri, 01 Jan 1990 00:00:00 GMT"
end
end

View File

@@ -208,7 +208,7 @@ class CheckoutController < Spree::CheckoutController
payment_method = Spree::PaymentMethod.find(params[:order][:payments_attributes].first[:payment_method_id])
return unless payment_method.kind_of?(Spree::Gateway::PayPalExpress)
render json: {path: spree.paypal_express_url(payment_method_id: payment_method.id)}, status: 200
render json: {path: spree.paypal_express_path(payment_method_id: payment_method.id)}, status: 200
true
end

View File

@@ -27,6 +27,7 @@ class ShopController < BaseController
if request.post?
if oc = OrderCycle.with_distributor(@distributor).active.find_by_id(params[:order_cycle_id])
current_order(true).set_order_cycle! oc
@current_order_cycle = oc
render partial: "json/order_cycle"
else
render status: 404, json: ""

View File

@@ -1,7 +1,20 @@
Spree::Admin::Orders::CustomerDetailsController.class_eval do
before_filter :set_guest_checkout_status, only: :update
# Inherit CanCan permissions for the current order
def model_class
load_order unless @order
@order
end
private
def set_guest_checkout_status
registered_user = Spree::User.find_by_email(params[:order][:email])
params[:order][:guest_checkout] = registered_user.nil?
return unless registered_user
@order.user_id = registered_user.id
end
end

View File

@@ -1,26 +1,74 @@
Spree::Admin::OverviewController.class_eval do
def index
# TODO was sorted with is_distributor DESC as well, not sure why or how we want ot sort this now
@enterprises = Enterprise.managed_by(spree_current_user).order('is_primary_producer ASC, name')
@enterprises = Enterprise
.managed_by(spree_current_user)
.order('is_primary_producer ASC, name')
@product_count = Spree::Product.active.managed_by(spree_current_user).count
@order_cycle_count = OrderCycle.active.managed_by(spree_current_user).count
unspecified = spree_current_user.owned_enterprises.where(sells: 'unspecified')
outside_referral = !URI(request.referer.to_s).path.match(/^\/admin/)
if OpenFoodNetwork::Permissions.new(spree_current_user).manages_one_enterprise? && !spree_current_user.admin?
@enterprise = @enterprises.first
if outside_referral && unspecified.any?
redirect_to main_app.welcome_admin_enterprise_path(@enterprise)
else
render "single_enterprise_dashboard"
end
if first_access
redirect_to enterprises_path
else
if outside_referral && unspecified.any?
redirect_to main_app.admin_enterprises_path
else
render "multi_enterprise_dashboard"
end
render dashboard_view
end
end
private
# Checks whether the user is accessing the admin for the first time
#
# @return [Boolean]
def first_access
outside_referral && incomplete_enterprise_registration?
end
# Checks whether the request comes from another admin page or not
#
# @return [Boolean]
def outside_referral
!URI(request.referer.to_s).path.match(%r{/admin})
end
# Checks that all of the enterprises owned by the current user have a 'sells'
# property specified, which indicates that the registration process has been
# completed
#
# @return [Boolean]
def incomplete_enterprise_registration?
@incomplete_enterprise_registration ||= spree_current_user
.owned_enterprises
.where(sells: 'unspecified')
.exists?
end
# Returns the appropriate enterprise path for the current user
#
# @return [String]
def enterprises_path
if managed_enterprises.size == 1
@enterprise = @enterprises.first
main_app.welcome_admin_enterprise_path(@enterprise)
else
main_app.admin_enterprises_path
end
end
# Returns the appropriate dashboard view for the current user
#
# @return [String]
def dashboard_view
if managed_enterprises.size == 1
@enterprise = @enterprises.first
:single_enterprise_dashboard
else
:multi_enterprise_dashboard
end
end
# Returns the list of enterprises the current user is manager of
#
# @return [ActiveRecord::Relation<Enterprise>]
def managed_enterprises
spree_current_user.enterprises
end
end

View File

@@ -4,8 +4,11 @@ require 'open_food_network/referer_parser'
Spree::Admin::ProductsController.class_eval do
include OpenFoodNetwork::SpreeApiKeyLoader
include OrderCyclesHelper
before_filter :load_form_data, :only => [:bulk_edit, :new, :create, :edit, :update]
before_filter :load_spree_api_key, :only => [:bulk_edit, :variant_overrides]
include EnterprisesHelper
before_filter :load_data
before_filter :load_form_data, :only => [:index, :new, :create, :edit, :update]
before_filter :load_spree_api_key, :only => [:index, :variant_overrides]
before_filter :strip_new_properties, only: [:create, :update]
respond_override create: { html: {
@@ -13,7 +16,7 @@ Spree::Admin::ProductsController.class_eval do
if params[:button] == "add_another"
redirect_to new_admin_product_path
else
redirect_to '/admin/products/bulk_edit'
redirect_to admin_products_path
end
},
failure: lambda {
@@ -23,6 +26,11 @@ Spree::Admin::ProductsController.class_eval do
def product_distributions
end
def index
@current_user = spree_current_user
@show_latest_import = params[:latest_import] || false
end
def bulk_update
collection_hash = Hash[params[:products].each_with_index.map { |p,i| [i,p] }]
product_set = Spree::ProductSet.new({:collection_attributes => collection_hash})
@@ -49,17 +57,6 @@ Spree::Admin::ProductsController.class_eval do
protected
def location_after_save_with_bulk_edit
referer_path = OpenFoodNetwork::RefererParser::path(request.referer)
if referer_path == '/admin/products/bulk_edit'
bulk_edit_admin_products_url
else
location_after_save_without_bulk_edit
end
end
alias_method_chain :location_after_save, :bulk_edit
def collection
# This method is copied directly from the spree product controller, except where we narrow the search below with the managed_by search to support
# enterprise users.
@@ -86,7 +83,7 @@ Spree::Admin::ProductsController.class_eval do
end
def collection_actions
[:index, :bulk_edit, :bulk_update]
[:index, :bulk_update]
end
@@ -95,6 +92,23 @@ Spree::Admin::ProductsController.class_eval do
def load_form_data
@producers = OpenFoodNetwork::Permissions.new(spree_current_user).managed_product_enterprises.is_primary_producer.by_name
@taxons = Spree::Taxon.order(:name)
@import_dates = product_import_dates.uniq.to_json
end
def product_import_dates
import_dates = Spree::Variant.
select('DISTINCT spree_variants.import_date').
joins(:product).
where('spree_products.supplier_id IN (?)', editable_enterprises.collect(&:id)).
where('spree_variants.import_date IS NOT NULL').
where(spree_variants: {is_master: false}).
where(spree_variants: {deleted_at: nil}).
order('spree_variants.import_date DESC')
options = [{id: '0', name: ''}]
import_dates.collect(&:import_date).map { |i| options.push(id: i.to_date, name: i.to_date.to_formatted_s(:long)) }
options
end
def strip_new_properties

View File

@@ -15,9 +15,11 @@ require 'open_food_network/payments_report'
require 'open_food_network/orders_and_fulfillments_report'
Spree::Admin::ReportsController.class_eval do
include Spree::ReportsHelper
helper_method :render_content?
before_filter :cache_search_state
# Fetches user's distributors, suppliers and order_cycles
before_filter :load_data, only: [:customers, :products_and_inventory, :order_cycle_management, :packing]
@@ -53,22 +55,21 @@ Spree::Admin::ReportsController.class_eval do
}
end
# Overide spree reports list.
# Override spree reports list.
def index
@reports = authorized_reports
respond_with(@reports)
end
# This action is short because we refactored it like bosses
def customers
@report_types = report_types[:customers]
@report_type = params[:report_type]
@report = OpenFoodNetwork::CustomersReport.new spree_current_user, params
@report = OpenFoodNetwork::CustomersReport.new spree_current_user, params, render_content?
render_report(@report.header, @report.table, params[:csv], "customers_#{timestamp}.csv")
end
def order_cycle_management
prepare_date_params params
params[:q] ||= {}
# -- Prepare form options
my_distributors = Enterprise.is_distributor.managed_by(spree_current_user)
@@ -84,15 +85,14 @@ Spree::Admin::ReportsController.class_eval do
@report_type = params[:report_type]
# -- Build Report with Order Grouper
@report = OpenFoodNetwork::OrderCycleManagementReport.new spree_current_user, params
@report = OpenFoodNetwork::OrderCycleManagementReport.new spree_current_user, params, render_content?
@table = @report.table_items
render_report(@report.header, @table, params[:csv], "order_cycle_management_#{timestamp}.csv")
end
def packing
# -- Prepare date parameters
prepare_date_params params
params[:q] ||= {}
# -- Prepare form options
my_distributors = Enterprise.is_distributor.managed_by(spree_current_user)
@@ -107,7 +107,7 @@ Spree::Admin::ReportsController.class_eval do
@report_type = params[:report_type]
# -- Build Report with Order Grouper
@report = OpenFoodNetwork::PackingReport.new spree_current_user, params
@report = OpenFoodNetwork::PackingReport.new spree_current_user, params, render_content?
order_grouper = OpenFoodNetwork::OrderGrouper.new @report.rules, @report.columns
@table = order_grouper.table(@report.table_items)
@@ -115,64 +115,26 @@ Spree::Admin::ReportsController.class_eval do
end
def orders_and_distributors
prepare_date_params params
permissions = OpenFoodNetwork::Permissions.new(spree_current_user)
@search = permissions.visible_orders.complete.not_state(:canceled).search(params[:q])
orders = @search.result
# If empty array is passed in, the where clause will return all line_items, which is bad
orders_with_hidden_details =
permissions.editable_orders.empty? ? orders : orders.where('id NOT IN (?)', permissions.editable_orders)
orders.select{ |order| orders_with_hidden_details.include? order }.each do |order|
# TODO We should really be hiding customer code here too, but until we
# have an actual association between order and customer, it's a bit tricky
order.bill_address.andand.assign_attributes(firstname: I18n.t('admin.reports.hidden'), lastname: "", phone: "", address1: "", address2: "", city: "", zipcode: "", state: nil)
order.ship_address.andand.assign_attributes(firstname: I18n.t('admin.reports.hidden'), lastname: "", phone: "", address1: "", address2: "", city: "", zipcode: "", state: nil)
order.assign_attributes(email: I18n.t('admin.reports.hidden'))
end
@report = OpenFoodNetwork::OrderAndDistributorReport.new orders
unless params[:csv]
render :html => @report
else
csv_string = CSV.generate do |csv|
csv << @report.header
@report.table.each { |row| csv << row }
end
send_data csv_string, :filename => "orders_and_distributors_#{timestamp}.csv"
end
@report = OpenFoodNetwork::OrderAndDistributorReport.new spree_current_user, params, render_content?
@search = @report.search
csv_file_name = "orders_and_distributors_#{timestamp}.csv"
render_report(@report.header, @report.table, params[:csv], csv_file_name)
end
def sales_tax
prepare_date_params params
@distributors = Enterprise.is_distributor.managed_by(spree_current_user)
@report_type = params[:report_type]
@report = OpenFoodNetwork::SalesTaxReport.new spree_current_user, params
unless params[:csv]
render :html => @report
else
csv_string = CSV.generate do |csv|
csv << @report.header
@report.table.each { |row| csv << row }
end
send_data csv_string, :filename => "sales_tax.csv"
end
@report = OpenFoodNetwork::SalesTaxReport.new spree_current_user, params, render_content?
render_report(@report.header, @report.table, params[:csv], "sales_tax.csv")
end
def bulk_coop
# -- Prepare date parameters
prepare_date_params params
# -- Prepare form options
@distributors = Enterprise.is_distributor.managed_by(spree_current_user)
@report_type = params[:report_type]
# -- Build Report with Order Grouper
@report = OpenFoodNetwork::BulkCoopReport.new spree_current_user, params
@report = OpenFoodNetwork::BulkCoopReport.new spree_current_user, params, render_content?
order_grouper = OpenFoodNetwork::OrderGrouper.new @report.rules, @report.columns
@table = order_grouper.table(@report.table_items)
csv_file_name = "bulk_coop_#{params[:report_type]}_#{timestamp}.csv"
@@ -181,15 +143,12 @@ Spree::Admin::ReportsController.class_eval do
end
def payments
# -- Prepare Date Params
prepare_date_params params
# -- Prepare Form Options
@distributors = Enterprise.is_distributor.managed_by(spree_current_user)
@report_type = params[:report_type]
# -- Build Report with Order Grouper
@report = OpenFoodNetwork::PaymentsReport.new spree_current_user, params
@report = OpenFoodNetwork::PaymentsReport.new spree_current_user, params, render_content?
order_grouper = OpenFoodNetwork::OrderGrouper.new @report.rules, @report.columns
@table = order_grouper.table(@report.table_items)
csv_file_name = "payments_#{timestamp}.csv"
@@ -198,8 +157,7 @@ Spree::Admin::ReportsController.class_eval do
end
def orders_and_fulfillment
# -- Prepare Date Params
prepare_date_params params
params[:q] ||= {}
# -- Prepare Form Options
permissions = OpenFoodNetwork::Permissions.new(spree_current_user)
@@ -216,7 +174,7 @@ Spree::Admin::ReportsController.class_eval do
@include_blank = I18n.t(:all)
# -- Build Report with Order Grouper
@report = OpenFoodNetwork::OrdersAndFulfillmentsReport.new spree_current_user, params
@report = OpenFoodNetwork::OrdersAndFulfillmentsReport.new spree_current_user, params, render_content?
order_grouper = OpenFoodNetwork::OrderGrouper.new @report.rules, @report.columns
@table = order_grouper.table(@report.table_items)
csv_file_name = "#{params[:report_type]}_#{timestamp}.csv"
@@ -226,60 +184,73 @@ Spree::Admin::ReportsController.class_eval do
def products_and_inventory
@report_types = report_types[:products_and_inventory]
if params[:report_type] != 'lettuce_share'
@report = OpenFoodNetwork::ProductsAndInventoryReport.new spree_current_user, params
else
@report = OpenFoodNetwork::LettuceShareReport.new spree_current_user, params
end
@report = if params[:report_type] != 'lettuce_share'
OpenFoodNetwork::ProductsAndInventoryReport.new spree_current_user, params, render_content?
else
OpenFoodNetwork::LettuceShareReport.new spree_current_user, params, render_content?
end
render_report(@report.header, @report.table, params[:csv], "products_and_inventory_#{timestamp}.csv")
end
def users_and_enterprises
# @report_types = report_types[:users_and_enterprises]
@report = OpenFoodNetwork::UsersAndEnterprisesReport.new params
@report = OpenFoodNetwork::UsersAndEnterprisesReport.new params, render_content?
render_report(@report.header, @report.table, params[:csv], "users_and_enterprises_#{timestamp}.csv")
end
def xero_invoices
if request.get?
params[:q] ||= {}
params[:q][:completed_at_gt] = Time.zone.today.beginning_of_month
params[:invoice_date] = Time.zone.today
params[:due_date] = Time.zone.today + 1.month
end
params[:q] ||= {}
@distributors = Enterprise.is_distributor.managed_by(spree_current_user)
@order_cycles = OrderCycle.active_or_complete.accessible_by(spree_current_user).order('orders_close_at DESC')
@report = OpenFoodNetwork::XeroInvoicesReport.new spree_current_user, params
@report = OpenFoodNetwork::XeroInvoicesReport.new spree_current_user, params, render_content?
render_report(@report.header, @report.table, params[:csv], "xero_invoices_#{timestamp}.csv")
end
def render_report(header, table, create_csv, csv_file_name)
unless create_csv
render :html => table
else
csv_string = CSV.generate do |csv|
csv << header
table.each { |row| csv << row }
end
send_data csv_string, :filename => csv_file_name
end
end
private
def prepare_date_params(params)
# -- Prepare parameters
params[:q] ||= {}
if params[:q][:completed_at_gt].blank?
params[:q][:completed_at_gt] = Time.zone.now.beginning_of_month
else
params[:q][:completed_at_gt] = Time.zone.parse(params[:q][:completed_at_gt]) rescue Time.zone.now.beginning_of_month
# Some actions are changing the `params` object. That is unfortunate Spree
# behavior and we are building on it. So we have to look at `params` early
# to check if we are searching or just displaying a report search form.
def cache_search_state
search_keys = [
# search parameter for ransack
:q,
# common in all reports, only set for CSV rendering
:csv,
# `button` is included in all forms. It's not important for searching,
# but the Users & Enterprises report doesn't have any other parameter
# for an empty search. So we use this one to display data.
:button,
# Some reports use filtering by enterprise or order cycle
:distributor_id,
:supplier_id,
:order_cycle_id,
# Xero Invoices can be filtered by date
:invoice_date,
:due_date
]
@searching = search_keys.any? { |key| params.key? key }
end
# We don't want to render data unless search params are supplied.
# Compiling data can take a long time.
def render_content?
@searching
end
def render_report(header, table, create_csv, csv_file_name)
send_data csv_report(header, table), filename: csv_file_name if create_csv
@header = header
@table = table
# Rendering HTML is the default.
end
def csv_report(header, table)
CSV.generate do |csv|
csv << header
table.each { |row| csv << row }
end
if params[:q] && !params[:q][:completed_at_lt].blank?
params[:q][:completed_at_lt] = Time.zone.parse(params[:q][:completed_at_lt]) rescue ""
end
params[:q][:meta_sort] ||= "completed_at.desc"
end
def load_data
@@ -289,42 +260,42 @@ Spree::Admin::ReportsController.class_eval do
distributors_of_my_products = Enterprise.with_distributed_products_outer.merge(Spree::Product.in_any_supplier(my_suppliers))
@distributors = my_distributors | distributors_of_my_products
# Load suppliers either owned by the user or supplying products their enterprises distribute.
suppliers_of_products_I_distribute = my_distributors.map { |d| Spree::Product.in_distributor(d) }.flatten.map(&:supplier).uniq
@suppliers = my_suppliers | suppliers_of_products_I_distribute
suppliers_of_products_i_distribute = my_distributors.map { |d| Spree::Product.in_distributor(d) }.flatten.map(&:supplier).uniq
@suppliers = my_suppliers | suppliers_of_products_i_distribute
@order_cycles = OrderCycle.active_or_complete.accessible_by(spree_current_user).order('orders_close_at DESC')
end
def authorized_reports
reports = {
:orders_and_distributors => {:name => I18n.t('admin.reports.orders_and_distributors.name'), :description => I18n.t('admin.reports.orders_and_distributors.description')},
:bulk_coop => {:name => I18n.t('admin.reports.bulk_coop.name'), :description => I18n.t('admin.reports.bulk_coop.description')},
:payments => {:name => I18n.t('admin.reports.payments.name'), :description => I18n.t('admin.reports.payments.description')},
:orders_and_fulfillment => {:name => I18n.t('admin.reports.orders_and_fulfillment.name'), :description => ''},
:customers => {:name => I18n.t('admin.reports.customers.name'), :description => ''},
:products_and_inventory => {:name => I18n.t('admin.reports.products_and_inventory.name'), :description => ''},
:sales_total => {:name => I18n.t('admin.reports.sales_total.name'), :description => I18n.t('admin.reports.sales_total.description')},
:users_and_enterprises => {:name => I18n.t('admin.reports.users_and_enterprises.name'), :description => I18n.t('admin.reports.users_and_enterprises.description')},
:order_cycle_management => {:name => I18n.t('admin.reports.order_cycle_management.name'), :description => ''},
:sales_tax => {:name => I18n.t('admin.reports.sales_tax.name'), :description => ''},
:xero_invoices => {:name => I18n.t('admin.reports.xero_invoices.name'), :description => I18n.t('admin.reports.xero_invoices.description')},
:packing => {:name => I18n.t('admin.reports.packing.name'), :description => ''}
}
all_reports = [
:orders_and_distributors,
:bulk_coop,
:payments,
:orders_and_fulfillment,
:customers,
:products_and_inventory,
:sales_total,
:users_and_enterprises,
:order_cycle_management,
:sales_tax,
:xero_invoices,
:packing
]
reports = all_reports.select { |action| can? action, :report }
reports.map { |report| [report, describe_report(report)] }.to_h
end
reports[:orders_and_fulfillment][:description] =
render_to_string(partial: 'orders_and_fulfillment_description', layout: false, locals: {report_types: report_types[:orders_and_fulfillment]}).html_safe
reports[:products_and_inventory][:description] =
render_to_string(partial: 'products_and_inventory_description', layout: false, locals: {report_types: report_types[:products_and_inventory]}).html_safe
reports[:customers][:description] =
render_to_string(partial: 'customers_description', layout: false, locals: {report_types: report_types[:customers]}).html_safe
reports[:order_cycle_management][:description] =
render_to_string(partial: 'order_cycle_management_description', layout: false, locals: {report_types: report_types[:order_cycle_management]}).html_safe
reports[:packing][:description] =
render_to_string(partial: 'packing_description', layout: false, locals: {report_types: report_types[:packing]}).html_safe
reports[:sales_tax][:description] =
render_to_string(partial: 'sales_tax_description', layout: false, locals: {report_types: report_types[:sales_tax]}).html_safe
# Return only reports the user is authorized to view.
reports.select { |action| can? action, :report }
def describe_report(report)
name = I18n.t(:name, scope: [:admin, :reports, report])
description = begin
I18n.t!(:description, scope: [:admin, :reports, report])
rescue I18n::MissingTranslationData
render_to_string(
partial: "#{report}_description",
layout: false,
locals: { report_types: report_types[report] }
).html_safe
end
{ name: name, description: description }
end
def timestamp

View File

@@ -16,6 +16,18 @@ module Spree
return render json: { flash: { error: I18n.t(:spree_gateway_error_flash_for_checkout, error: e.message) } }, status: 400
end
def update
@credit_card = Spree::CreditCard.find_by_id(params[:id])
return update_failed unless @credit_card
authorize! :update, @credit_card
if @credit_card.update_attributes(params[:credit_card])
render json: @credit_card, serializer: ::Api::CreditCardSerializer, status: :ok
else
update_failed
end
end
def destroy
@credit_card = Spree::CreditCard.find_by_id(params[:id])
if @credit_card
@@ -65,5 +77,9 @@ module Spree
card.user_id = spree_current_user.id
card
end
def update_failed
render json: { flash: { error: t(:card_could_not_be_updated) } }, status: 400
end
end
end

View File

@@ -15,4 +15,10 @@ Spree::UsersController.class_eval do
@orders = @orders.where('distributor_id != ?', Spree::Config.accounts_distributor_id)
end
# Endpoint for queries to check if a user is already registered
def registered_email
user = Spree.user_class.find_by_email params[:email]
render json: { registered: user.present? }
end
end

View File

@@ -8,6 +8,7 @@ class UserConfirmationsController < DeviseController
# POST /resource/confirmation
def create
set_return_url if params.key? :return_url
self.resource = resource_class.send_confirmation_instructions(resource_params)
if is_navigational_format?
@@ -30,6 +31,10 @@ class UserConfirmationsController < DeviseController
protected
def set_return_url
session[:confirmation_return_url] = params[:return_url]
end
def after_confirmation_path_for(resource)
result =
if resource.errors.empty?

View File

@@ -4,6 +4,8 @@ class UserPasswordsController < Spree::UserPasswordsController
before_filter :set_admin_redirect, only: :edit
def create
render_unconfirmed_response && return if user_unconfirmed?
self.resource = resource_class.send_reset_password_instructions(params[resource_name])
if resource.errors.empty?
@@ -15,7 +17,7 @@ class UserPasswordsController < Spree::UserPasswordsController
respond_with_navigational(resource) { render :new }
end
format.js do
render json: resource.errors, status: :unauthorized
render json: { error: t('email_not_found') }, status: :not_found
end
end
end
@@ -26,4 +28,13 @@ class UserPasswordsController < Spree::UserPasswordsController
def set_admin_redirect
session["spree_user_return_to"] = params[:return_to] if params[:return_to]
end
def render_unconfirmed_response
render json: { error: t('email_unconfirmed') }, status: :unauthorized
end
def user_unconfirmed?
user = Spree::User.find_by_email(params[:spree_user][:email])
user && !user.confirmed?
end
end

View File

@@ -66,20 +66,6 @@ module CheckoutHelper
Spree::Money.new order.total - order.total_tax, currency: order.currency
end
def checkout_state_options(source_address)
if source_address == :billing
address = @order.billing_address
elsif source_address == :shipping
address = @order.shipping_address
end
[[]] + address.country.states.map { |c| [c.name, c.id] }
end
def checkout_country_options
available_countries.map { |c| [c.name, c.id] }
end
def validated_input(name, path, args = {})
attributes = {
required: true,

View File

@@ -74,9 +74,11 @@ module InjectionHelper
end
def inject_saved_credit_cards
if spree_current_user
data = spree_current_user.credit_cards.with_payment_profile.all
end
data = if spree_current_user
spree_current_user.credit_cards.with_payment_profile.all
else
[]
end
inject_json_ams "savedCreditCards", data, Api::CreditCardSerializer
end

View File

@@ -19,4 +19,13 @@ module ShopHelper
spree_current_user.customer_of(current_distributor)
)
end
def shop_tabs
[
{ name: 'about', title: t(:shopping_tabs_about, distributor: current_distributor.name), cols: 6 },
{ name: 'producers', title: t(:label_producers), cols: 2 },
{ name: 'contact', title: t(:shopping_tabs_contact), cols: 2 },
{ name: 'groups', title: t(:label_groups), cols: 2 },
]
end
end

View File

@@ -25,7 +25,6 @@ class SubscriptionConfirmJob
def proxy_orders
ProxyOrder.not_canceled.where('confirmed_at IS NULL AND placed_at IS NOT NULL')
.joins(:order_cycle).merge(recently_closed_order_cycles)
.joins(:subscription).merge(Subscription.not_canceled.not_paused)
.joins(:order).merge(Spree::Order.complete)
end

View File

@@ -34,6 +34,8 @@ class SubscriptionPlacementJob
changes = cap_quantity_and_store_changes(order)
if order.line_items.where('quantity > 0').empty?
order.reload.adjustments.destroy_all
order.update!
return send_empty_email(order, changes)
end

View File

@@ -203,14 +203,6 @@ class Enterprise < ActiveRecord::Base
self.supplied_products.where('count_on_hand > 0').present?
end
def supplied_and_active_products_on_hand
self.supplied_products.where('spree_products.count_on_hand > 0').active
end
def active_products_in_order_cycles
self.supplied_and_active_products_on_hand.in_an_active_order_cycle
end
def to_param
permalink
end
@@ -382,7 +374,7 @@ class Enterprise < ActiveRecord::Base
end
def ensure_owner_is_manager
users << owner unless users.include?(owner) || owner.admin?
users << owner unless users.include?(owner)
end
def enforce_ownership_limit

View File

@@ -0,0 +1,28 @@
# Tells whether a particular feature is enabled or not
class FeatureFlags
# Constructor
#
# @param user [User]
def initialize(user)
@user = user
end
# Checks whether product import is enabled for the specified user
#
# @return [Boolean]
def product_import_enabled?
superadmin?
end
private
attr_reader :user
# Checks whether the specified user is a superadmin, with full control of the
# instance
#
# @return [Boolean]
def superadmin?
user.has_spree_role?('admin')
end
end

View File

@@ -0,0 +1,234 @@
module ProductImport
class EntryProcessor
attr_reader :inventory_created, :inventory_updated, :products_created, :variants_created, :variants_updated, :products_reset_count, :supplier_products, :total_supplier_products
def initialize(importer, validator, import_settings, spreadsheet_data, editable_enterprises, import_time, updated_ids)
@importer = importer
@validator = validator
@import_settings = import_settings
@spreadsheet_data = spreadsheet_data
@editable_enterprises = editable_enterprises
@import_time = import_time
@updated_ids = updated_ids
@inventory_created = 0
@inventory_updated = 0
@products_created = 0
@variants_created = 0
@variants_updated = 0
@products_reset_count = 0
@supplier_products = {}
@total_supplier_products = 0
end
def save_all(entries)
entries.each do |entry|
if import_into_inventory?(entry)
save_to_inventory(entry)
else
save_to_product_list(entry)
end
end
@importer.errors.add(:importer, I18n.t(:product_importer_products_save_error)) if total_saved_count.zero?
end
def count_existing_items
@spreadsheet_data.suppliers_index.each do |_supplier_name, supplier_id|
next unless supplier_id && permission_by_id?(supplier_id)
products_count =
if import_into_inventory_by_supplier?(supplier_id)
VariantOverride.where('variant_overrides.hub_id IN (?)', supplier_id).count
else
Spree::Variant.
not_deleted.
not_master.
joins(:product).
where('spree_products.supplier_id IN (?)', supplier_id).
count
end
@supplier_products[supplier_id] = products_count
@total_supplier_products += products_count
end
end
def reset_absent_items
# For selected enterprises; set stock to zero for all products/inventory
# that were not listed in the newly uploaded spreadsheet
return if total_saved_count.zero? || @updated_ids.empty? || !@import_settings.key?(:settings)
suppliers_to_reset_products = []
suppliers_to_reset_inventories = []
@import_settings[:settings].each do |enterprise_id, settings|
suppliers_to_reset_products.push enterprise_id if settings['reset_all_absent'] && permission_by_id?(enterprise_id) && !import_into_inventory_by_supplier?(enterprise_id)
suppliers_to_reset_inventories.push enterprise_id if settings['reset_all_absent'] && permission_by_id?(enterprise_id) && import_into_inventory_by_supplier?(enterprise_id)
end
unless suppliers_to_reset_inventories.empty?
@products_reset_count += VariantOverride.
where('variant_overrides.hub_id IN (?)
AND variant_overrides.id NOT IN (?)', suppliers_to_reset_inventories, @updated_ids).
update_all(count_on_hand: 0)
end
return if suppliers_to_reset_products.empty?
@products_reset_count += Spree::Variant.joins(:product).
where('spree_products.supplier_id IN (?)
AND spree_variants.id NOT IN (?)
AND spree_variants.is_master = false
AND spree_variants.deleted_at IS NULL', suppliers_to_reset_products, @updated_ids).
update_all(count_on_hand: 0)
end
def total_saved_count
@products_created + @variants_created + @variants_updated + @inventory_created + @inventory_updated
end
private
def save_to_inventory(entry)
save_new_inventory_item entry if entry.validates_as? 'new_inventory_item'
save_existing_inventory_item entry if entry.validates_as? 'existing_inventory_item'
end
def save_to_product_list(entry)
save_new_product entry if entry.validates_as? 'new_product'
if entry.validates_as? 'new_variant'
save_variant entry
@variants_created += 1
end
return unless entry.validates_as? 'existing_variant'
save_variant entry
@variants_updated += 1
end
def import_into_inventory?(entry)
entry.supplier_id && @import_settings[:settings][entry.supplier_id.to_s]['import_into'] == 'inventories'
end
def save_new_inventory_item(entry)
new_item = entry.product_object
assign_defaults(new_item, entry)
new_item.import_date = @import_time
if new_item.valid? && new_item.save
display_in_inventory(new_item, true)
@inventory_created += 1
@updated_ids.push new_item.id
else
@importer.errors.add("#{I18n.t('admin.product_import.model.line')} #{line_number}:", new_item.errors.full_messages)
end
end
def save_existing_inventory_item(entry)
existing_item = entry.product_object
assign_defaults(existing_item, entry)
existing_item.import_date = @import_time
if existing_item.valid? && existing_item.save
display_in_inventory(existing_item)
@inventory_updated += 1
@updated_ids.push existing_item.id
else
@importer.errors.add("#{I18n.t('admin.product_import.model.line')} #{line_number}:", existing_item.errors.full_messages)
end
end
def save_new_product(entry)
@already_created ||= {}
# If we've already added a new product with these attributes
# from this spreadsheet, mark this entry as a new variant with
# the new product id, as this is a now variant of that product...
if @already_created[entry.supplier_id] && @already_created[entry.supplier_id][entry.name]
product_id = @already_created[entry.supplier_id][entry.name]
@validator.mark_as_new_variant(entry, product_id)
return
end
product = Spree::Product.new
product.assign_attributes(entry.attributes.except('id'))
assign_defaults(product, entry)
if product.save
ensure_variant_updated(product, entry)
@products_created += 1
@updated_ids.push product.variants.first.id
else
@importer.errors.add("#{I18n.t('admin.product_import.model.line')} #{line_number}:", product.errors.full_messages)
end
@already_created[entry.supplier_id] = { entry.name => product.id }
end
def save_variant(entry)
variant = entry.product_object
assign_defaults(variant, entry)
variant.import_date = @import_time
if variant.valid? && variant.save
@updated_ids.push variant.id
true
else
@importer.errors.add("#{I18n.t('admin.product_import.model.line')} #{line_number}:", variant.errors.full_messages)
false
end
end
def assign_defaults(object, entry)
# Assigns a default value for a specified field e.g. category='Vegetables', setting this value
# either for all entries (overwrite_all), or only for those entries where the field was blank
# in the spreadsheet (overwrite_empty), depending on selected import settings
return unless @import_settings.key?(:settings) && @import_settings[:settings][entry.supplier_id.to_s] && @import_settings[:settings][entry.supplier_id.to_s]['defaults']
@import_settings[:settings][entry.supplier_id.to_s]['defaults'].each do |attribute, setting|
next unless setting['active']
case setting['mode']
when 'overwrite_all'
object.assign_attributes(attribute => setting['value'])
when 'overwrite_empty'
if object.send(attribute).blank? || ((attribute == 'on_hand' || attribute == 'count_on_hand') && entry.on_hand_nil)
object.assign_attributes(attribute => setting['value'])
end
end
end
end
def display_in_inventory(variant_override, is_new = false)
unless is_new
existing_item = InventoryItem.where(variant_id: variant_override.variant_id, enterprise_id: variant_override.hub_id).first
if existing_item
existing_item.assign_attributes(visible: true)
existing_item.save
return
end
end
InventoryItem.new(variant_id: variant_override.variant_id, enterprise_id: variant_override.hub_id, visible: true).save
end
def ensure_variant_updated(product, entry)
# Ensure attributes are correctly copied to a new product's variant
variant = product.variants.first
variant.display_name = entry.display_name if entry.display_name
variant.on_demand = entry.on_demand if entry.on_demand
variant.import_date = @import_time
variant.save
end
def permission_by_id?(supplier_id)
@editable_enterprises.value?(Integer(supplier_id))
end
def import_into_inventory_by_supplier?(supplier_id)
@import_settings[:settings] && @import_settings[:settings][supplier_id.to_s] && @import_settings[:settings][supplier_id.to_s]['import_into'] == 'inventories'
end
end
end

View File

@@ -0,0 +1,273 @@
module ProductImport
class EntryValidator
def initialize(current_user, import_time, spreadsheet_data, editable_enterprises, inventory_permissions, reset_counts, import_settings)
@current_user = current_user
@import_time = import_time
@spreadsheet_data = spreadsheet_data
@editable_enterprises = editable_enterprises
@inventory_permissions = inventory_permissions
@reset_counts = reset_counts
@import_settings = import_settings
end
def validate_all(entries)
entries.each do |entry|
supplier_validation(entry)
unit_fields_validation(entry)
next if entry.supplier_id.blank?
if import_into_inventory?(entry)
producer_validation(entry)
inventory_validation(entry)
else
category_validation(entry)
tax_and_shipping_validation(entry, 'tax', entry.tax_category, @spreadsheet_data.tax_index)
tax_and_shipping_validation(entry, 'shipping', entry.shipping_category, @spreadsheet_data.shipping_index)
product_validation(entry)
end
end
end
def mark_as_new_variant(entry, product_id)
new_variant = Spree::Variant.new(entry.attributes.except('id', 'product_id'))
new_variant.product_id = product_id
check_on_hand_nil(entry, new_variant)
if new_variant.valid?
entry.product_object = new_variant
entry.validates_as = 'new_variant' unless entry.errors?
else
mark_as_invalid(entry, product_validations: new_variant.errors)
end
end
private
def supplier_validation(entry)
supplier_name = entry.supplier
if supplier_name.blank?
mark_as_invalid(entry, attribute: "supplier", error: I18n.t(:error_required))
return
end
unless @spreadsheet_data.suppliers_index[supplier_name]
mark_as_invalid(entry, attribute: "supplier", error: I18n.t(:error_not_found_in_database, name: supplier_name))
return
end
unless permission_by_name?(supplier_name)
mark_as_invalid(entry, attribute: "supplier", error: I18n.t(:error_no_permission_for_enterprise, name: supplier_name))
return
end
entry.supplier_id = @spreadsheet_data.suppliers_index[supplier_name]
end
def unit_fields_validation(entry)
unit_types = ['g', 'kg', 't', 'ml', 'l', 'kl', '']
unless entry.units && entry.units.present?
mark_as_invalid(entry, attribute: 'units', error: I18n.t('admin.product_import.model.blank'))
end
return if import_into_inventory?(entry)
# unit_type must be valid type
if entry.unit_type && entry.unit_type.present?
unit_type = entry.unit_type.to_s.strip.downcase
mark_as_invalid(entry, attribute: 'unit_type', error: I18n.t('admin.product_import.model.incorrect_value')) unless unit_types.include?(unit_type)
return
end
# variant_unit_name must be present if unit_type not present
mark_as_invalid(entry, attribute: 'variant_unit_name', error: I18n.t('admin.product_import.model.conditional_blank')) unless entry.variant_unit_name && entry.variant_unit_name.present?
end
def producer_validation(entry)
producer_name = entry.producer
if producer_name.blank?
mark_as_invalid(entry, attribute: "producer", error: I18n.t('admin.product_import.model.blank'))
return
end
unless @spreadsheet_data.producers_index[producer_name]
mark_as_invalid(entry, attribute: "producer", error: "\"#{producer_name}\" #{I18n.t('admin.product_import.model.not_found')}")
return
end
unless inventory_permission?(entry.supplier_id, @spreadsheet_data.producers_index[producer_name])
mark_as_invalid(entry, attribute: "producer", error: "\"#{producer_name}\": #{I18n.t('admin.product_import.model.inventory_no_permission')}")
return
end
entry.producer_id = @spreadsheet_data.producers_index[producer_name]
end
def inventory_validation(entry)
# Checks a potential inventory item corresponds to a valid variant
match = Spree::Product.where(supplier_id: entry.producer_id, name: entry.name, deleted_at: nil).first
if match.nil?
mark_as_invalid(entry, attribute: 'name', error: I18n.t('admin.product_import.model.no_product'))
return
end
match.variants.each do |existing_variant|
unit_scale = match.variant_unit_scale
unscaled_units = entry.unscaled_units || 0
entry.unit_value = unscaled_units * unit_scale
if entry_matches_existing_variant?(entry, existing_variant)
variant_override = create_inventory_item(entry, existing_variant)
return validate_inventory_item(entry, variant_override)
end
end
mark_as_invalid(entry, attribute: 'product', error: I18n.t('admin.product_import.model.not_found'))
end
def entry_matches_existing_variant?(entry, existing_variant)
existing_variant.display_name == entry.display_name && existing_variant.unit_value == entry.unit_value.to_f
end
def category_validation(entry)
category_name = entry.category
if category_name.blank?
mark_as_invalid(entry, attribute: "category", error: I18n.t(:error_required))
return
end
if @spreadsheet_data.categories_index[category_name]
entry.primary_taxon_id = @spreadsheet_data.categories_index[category_name]
else
mark_as_invalid(entry, attribute: "category", error: I18n.t(:error_not_found_in_database, name: category_name))
end
end
def tax_and_shipping_validation(entry, type, category, index)
return if category.blank?
if index.key? category
entry.send("#{type}_category_id=", index[category])
else
mark_as_invalid(entry, attribute: "#{type}_category", error: I18n.t('admin.product_import.model.not_found'))
end
end
def product_validation(entry)
# Find product with matching supplier and name
match = Spree::Product.where(supplier_id: entry.supplier_id, name: entry.name, deleted_at: nil).first
# If no matching product was found, create a new product
if match.nil?
mark_as_new_product(entry)
return
end
# Otherwise, if a variant exists with matching display_name and unit_value, update it
match.variants.each do |existing_variant|
if entry_matches_existing_variant?(entry, existing_variant) && existing_variant.deleted_at.nil?
return mark_as_existing_variant(entry, existing_variant)
end
end
# Otherwise, a variant with sufficiently matching attributes doesn't exist; create a new one
mark_as_new_variant(entry, match.id)
end
def mark_as_new_product(entry)
new_product = Spree::Product.new
new_product.assign_attributes(entry.attributes.except('id'))
if new_product.valid?
entry.validates_as = 'new_product' unless entry.errors?
else
mark_as_invalid(entry, product_validations: new_product.errors)
end
end
def mark_as_existing_variant(entry, existing_variant)
existing_variant.assign_attributes(entry.attributes.except('id', 'product_id'))
check_on_hand_nil(entry, existing_variant)
if existing_variant.valid?
entry.product_object = existing_variant
entry.validates_as = 'existing_variant' unless entry.errors?
updates_count_per_supplier(entry.supplier_id) unless entry.errors?
else
mark_as_invalid(entry, product_validations: existing_variant.errors)
end
end
def permission_by_name?(supplier_name)
@editable_enterprises.key?(supplier_name)
end
def permission_by_id?(supplier_id)
@editable_enterprises.value?(Integer(supplier_id))
end
def inventory_permission?(supplier_id, producer_id)
@current_user.admin? || ( @inventory_permissions[supplier_id] && @inventory_permissions[supplier_id].include?(producer_id) )
end
def mark_as_invalid(entry, options = {})
entry.errors.add(options[:attribute], options[:error]) if options[:attribute] && options[:error]
entry.product_validations = options[:product_validations] if options[:product_validations]
end
def import_into_inventory?(entry)
entry.supplier_id && @import_settings[:settings][entry.supplier_id.to_s]['import_into'] == 'inventories'
end
def validate_inventory_item(entry, variant_override)
if variant_override.valid? && !entry.errors?
mark_as_inventory_item(entry, variant_override)
else
mark_as_invalid(entry, product_validations: variant_override.errors)
end
end
def create_inventory_item(entry, existing_variant)
existing_variant_override = VariantOverride.where(variant_id: existing_variant.id, hub_id: entry.supplier_id).first
variant_override = existing_variant_override || VariantOverride.new(variant_id: existing_variant.id, hub_id: entry.supplier_id)
variant_override.assign_attributes(count_on_hand: entry.on_hand, import_date: @import_time)
check_on_hand_nil(entry, variant_override)
variant_override.assign_attributes(entry.attributes.slice('price', 'on_demand'))
variant_override
end
def mark_as_inventory_item(entry, variant_override)
if variant_override.id
entry.validates_as = 'existing_inventory_item'
entry.product_object = variant_override
updates_count_per_supplier(entry.supplier_id) unless entry.errors?
else
entry.validates_as = 'new_inventory_item'
entry.product_object = variant_override
end
end
def updates_count_per_supplier(supplier_id)
if @reset_counts[supplier_id] && @reset_counts[supplier_id][:updates_count]
@reset_counts[supplier_id][:updates_count] += 1
else
@reset_counts[supplier_id] = { updates_count: 1 }
end
end
def check_on_hand_nil(entry, object)
return if entry.on_hand.present?
object.on_hand = 0 if object.respond_to?(:on_hand)
object.count_on_hand = 0 if object.respond_to?(:count_on_hand)
entry.on_hand_nil = true
end
end
end

View File

@@ -0,0 +1,252 @@
require 'roo'
module ProductImport
class ProductImporter
extend ActiveModel::Naming
include ActiveModel::Conversion
include ActiveModel::Validations
attr_reader :updated_ids
def initialize(file, current_user, import_settings = {})
unless file.is_a?(File)
errors.add(:importer, I18n.t(:product_importer_file_error))
return
end
@file = file
@sheet = open_spreadsheet
@entries = []
@import_time = Time.zone.now
@import_settings = import_settings || {}
@current_user = current_user
@editable_enterprises = {}
@inventory_permissions = {}
@reset_counts = {}
@updated_ids = []
init_product_importer if @sheet
end
def persisted?
false # ActiveModel
end
def entries?
@entries.count > 0
end
def valid_entries?
@entries.each do |entry|
return true if entry.validates_as.present?
end
false
end
def item_count
@sheet ? @sheet.last_row - 1 : 0
end
def reset_counts
# Return indexed data about existing product count, reset count, and updates count per supplier
@reset_counts.each do |supplier_id, values|
values[:updates_count] = 0 if values[:updates_count].blank?
if values[:updates_count] && values[:existing_products]
@reset_counts[supplier_id][:reset_count] = values[:existing_products] - values[:updates_count]
end
end
@reset_counts
end
def suppliers_index
index = @spreadsheet_data.suppliers_index
index.sort_by{ |_k, v| v.to_i }.reverse.to_h
end
def supplier_products
@processor.supplier_products
end
def total_supplier_products
@processor.total_supplier_products
end
def all_entries
@entries
end
def entries_json
entries = {}
@entries.each do |entry|
entries[entry.line_number] = {
attributes: entry.displayable_attributes,
validates_as: entry.validates_as,
errors: entry.invalid_attributes
}
end
entries.to_json
end
def table_headings
@entries.first.displayable_attributes.keys.map(&:humanize) if @entries.first
end
def products_created_count
@processor.products_created + @processor.variants_created
end
def products_updated_count
@processor.variants_updated
end
def inventory_created_count
@processor.inventory_created
end
def inventory_updated_count
@processor.inventory_updated
end
def products_reset_count
@processor.products_reset_count
end
def total_saved_count
@processor.total_saved_count
end
def import_results
{ entries: entries_json, reset_counts: reset_counts }
end
def save_results
{
results: {
products_created: products_created_count,
products_updated: products_updated_count,
inventory_created: inventory_created_count,
inventory_updated: inventory_updated_count,
products_reset: products_reset_count,
},
updated_ids: updated_ids,
errors: errors.full_messages
}
end
def validate_entries
@validator.validate_all(@entries)
end
def save_entries
validate_entries
save_all_valid
end
def reset_absent(updated_ids)
@products_created = updated_ids.count
@updated_ids = updated_ids
@processor.reset_absent_items
end
def permission_by_id?(supplier_id)
@editable_enterprises.value?(Integer(supplier_id))
end
private
def init_product_importer
init_permissions
if staged_import?
build_entries_in_range
else
build_entries
end
@spreadsheet_data = SpreadsheetData.new(@entries)
@validator = EntryValidator.new(@current_user, @import_time, @spreadsheet_data, @editable_enterprises, @inventory_permissions, @reset_counts, @import_settings)
@processor = EntryProcessor.new(self, @validator, @import_settings, @spreadsheet_data, @editable_enterprises, @import_time, @updated_ids)
@processor.count_existing_items unless staged_import?
end
def staged_import?
@import_settings && @import_settings.key?(:start) && @import_settings.key?(:end)
end
def init_permissions
permissions = OpenFoodNetwork::Permissions.new(@current_user)
permissions.editable_enterprises.
order('is_primary_producer ASC, name').
map { |e| @editable_enterprises[e.name] = e.id }
@inventory_permissions = permissions.variant_override_enterprises_per_hub
end
def open_spreadsheet
if accepted_mimetype
Roo::Spreadsheet.open(@file, extension: accepted_mimetype)
else
errors.add(:importer, I18n.t(:product_importer_spreadsheet_error))
delete_uploaded_file
nil
end
end
def accepted_mimetype
File.extname(@file.path).in?('.csv', '.xls', '.xlsx', '.ods') ? @file.path.split('.').last.to_sym : false
end
def headers
@sheet.row(1)
end
def rows
return [] unless @sheet && @sheet.last_row
(2..@sheet.last_row).map do |i|
@sheet.row(i)
end
end
def build_entries_in_range
start_line = @import_settings[:start]
end_line = @import_settings[:end]
(start_line..end_line).each do |i|
line_number = i + 1
row = @sheet.row(line_number)
row_data = Hash[[headers, row].transpose]
entry = SpreadsheetEntry.new(row_data)
entry.line_number = line_number
@entries.push entry
break if @sheet.last_row == line_number
end
end
def build_entries
rows.each_with_index do |row, i|
row_data = Hash[[headers, row].transpose]
entry = SpreadsheetEntry.new(row_data)
entry.line_number = i + 2
@entries.push entry
end
@entries
end
def save_all_valid
@processor.save_all(@entries)
@processor.reset_absent_items unless staged_import?
@processor.total_saved_count
end
def delete_uploaded_file
return unless @file.path == Rails.root.join('tmp', 'product_import').to_s
File.delete(@file)
end
end
end

View File

@@ -0,0 +1,72 @@
module ProductImport
class SpreadsheetData
def initialize(entries)
@entries = entries
end
def suppliers_index
@suppliers_index || create_suppliers_index
end
def producers_index
@producers_index = create_producers_index
end
def categories_index
@categories_index || create_categories_index
end
def tax_index
@tax_index || create_tax_index
end
def shipping_index
@shipping_index || create_shipping_index
end
private
def create_suppliers_index
@suppliers_index = {}
@entries.each do |entry|
supplier_name = entry.supplier
supplier_id = @suppliers_index[supplier_name] || Enterprise.find_by_name(supplier_name, select: 'id, name').try(:id)
@suppliers_index[supplier_name] = supplier_id
end
@suppliers_index
end
def create_producers_index
@producers_index = {}
@entries.each do |entry|
next unless entry.producer
producer_name = entry.producer
producer_id = @producers_index[producer_name] || Enterprise.find_by_name(producer_name, select: 'id, name').try(:id)
@producers_index[producer_name] = producer_id
end
@producers_index
end
def create_categories_index
@categories_index = {}
@entries.each do |entry|
category_name = entry.category
category_id = @categories_index[category_name] || Spree::Taxon.find_by_name(category_name, select: 'id, name').try(:id)
@categories_index[category_name] = category_id
end
@categories_index
end
def create_tax_index
@tax_index = {}
Spree::TaxCategory.select([:id, :name]).map { |tc| @tax_index[tc.name] = tc.id }
@tax_index
end
def create_shipping_index
@shipping_index = {}
Spree::ShippingCategory.select([:id, :name]).map { |sc| @shipping_index[sc.name] = sc.id }
@shipping_index
end
end
end

View File

@@ -0,0 +1,78 @@
module ProductImport
class SpreadsheetEntry
extend ActiveModel::Naming
include ActiveModel::Conversion
include ActiveModel::Validations
attr_accessor :line_number, :valid, :validates_as, :product_object, :product_validations, :on_hand_nil,
:has_overrides, :units, :unscaled_units, :unit_type, :tax_category, :shipping_category
attr_accessor :id, :product_id, :producer, :producer_id, :supplier, :supplier_id, :name, :display_name, :sku,
:unit_value, :unit_description, :variant_unit, :variant_unit_scale, :variant_unit_name,
:display_as, :category, :primary_taxon_id, :price, :on_hand, :count_on_hand, :on_demand,
:tax_category_id, :shipping_category_id, :description, :import_date
def initialize(attrs)
@validates_as = ''
assign_units attrs
end
def persisted?
false # ActiveModel
end
def validates_as?(type)
@validates_as == type
end
def errors?
errors.count > 0 || @product_validations
end
def attributes
attrs = {}
instance_variables.each do |var|
attrs[var.to_s.delete("@")] = instance_variable_get(var)
end
attrs.except(*non_product_attributes)
end
def displayable_attributes
# Modified attributes list for displaying in user feedback
attrs = {}
instance_variables.each do |var|
attrs[var.to_s.delete("@")] = instance_variable_get(var)
end
attrs.except(*non_product_attributes, *non_display_attributes)
end
def invalid_attributes
invalid_attrs = {}
errors = @product_validations ? self.errors.messages.merge(@product_validations.messages) : self.errors.messages
errors.each do |attr, message|
invalid_attrs[attr.to_s] = "#{attr.to_s.capitalize} #{message.first}"
end
invalid_attrs.except(*non_product_attributes, *non_display_attributes)
end
private
def assign_units(attrs)
units = UnitConverter.new(attrs)
units.converted_attributes.each do |attr, value|
if respond_to?("#{attr}=")
send("#{attr}=", value) unless non_product_attributes.include?(attr)
end
end
end
def non_display_attributes
['id', 'product_id', 'unscaled_units', 'variant_id', 'supplier_id', 'primary_taxon', 'primary_taxon_id', 'category_id', 'shipping_category_id', 'tax_category_id', 'variant_unit_scale', 'variant_unit', 'unit_value']
end
def non_product_attributes
['line_number', 'valid', 'errors', 'product_object', 'product_validations', 'inventory_validations', 'validates_as', 'save_type', 'on_hand_nil', 'has_overrides']
end
end
end

View File

@@ -0,0 +1,79 @@
module ProductImport
class UnitConverter
def initialize(attrs)
@attrs = attrs
convert_custom_unit_fields
end
def converted_attributes
@attrs
end
private
def convert_custom_unit_fields
# units unit_type variant_unit_name -> unit_value variant_unit_scale variant_unit
# 250 ml nil .... 0.25 0.001 volume
# 50 g nil .... 50 1 weight
# 2 kg nil .... 2000 1000 weight
# 1 nil bunches .... 1 null items
init_unit_values
assign_weight_or_volume_attributes if units_and_unit_type_present?
assign_item_attributes if units_and_variant_unit_name_present?
end
def unit_scales
{
'g' => { scale: 1, unit: 'weight' },
'kg' => { scale: 1000, unit: 'weight' },
't' => { scale: 1000000, unit: 'weight' },
'ml' => { scale: 0.001, unit: 'volume' },
'l' => { scale: 1, unit: 'volume' },
'kl' => { scale: 1000, unit: 'volume' }
}
end
def init_unit_values
@attrs['variant_unit'] = nil
@attrs['variant_unit_scale'] = nil
@attrs['unit_value'] = nil
return unless @attrs.key?('units') && @attrs['units'].present?
@attrs['unscaled_units'] = @attrs['units']
end
def assign_weight_or_volume_attributes
units = @attrs['units'].to_f
unit_type = @attrs['unit_type'].to_s.downcase
return unless valid_unit_type? unit_type
@attrs['variant_unit'] = unit_scales[unit_type][:unit]
@attrs['variant_unit_scale'] = unit_scales[unit_type][:scale]
@attrs['unit_value'] = (units || 0) * @attrs['variant_unit_scale']
end
def assign_item_attributes
units = @attrs['units'].to_f
@attrs['variant_unit'] = 'items'
@attrs['variant_unit_scale'] = nil
@attrs['unit_value'] = units || 1
end
def units_and_unit_type_present?
@attrs.key?('units') && @attrs.key?('unit_type') && @attrs['units'].present? && @attrs['unit_type'].present?
end
def units_and_variant_unit_name_present?
@attrs.key?('units') && @attrs.key?('variant_unit_name') && @attrs['units'].present? && @attrs['variant_unit_name'].present?
end
def valid_unit_type?(unit_type)
unit_scales.key? unit_type
end
end
end

View File

@@ -1,464 +0,0 @@
require 'roo'
class ProductImporter
extend ActiveModel::Naming
include ActiveModel::Conversion
include ActiveModel::Validations
attr_reader :total_supplier_products
def initialize(file, editable_enterprises, import_settings={})
if file.is_a?(File)
@file = file
@sheet = open_spreadsheet
@entries = []
@valid_entries = {}
@invalid_entries = {}
@products_to_create = {}
@variants_to_create = {}
@variants_to_update = {}
@products_created = 0
@variants_created = 0
@variants_updated = 0
@import_settings = import_settings
@editable_enterprises = {}
editable_enterprises.map { |e| @editable_enterprises[e.name] = e.id }
@total_supplier_products = 0
@products_to_reset = {}
@updated_ids = []
init_product_importer if @sheet
else
self.errors.add(:importer, I18n.t(:product_importer_file_error))
end
end
def persisted?
false #ActiveModel, not ActiveRecord
end
def has_valid_entries?
valid_count and valid_count > 0
end
def item_count
@sheet ? @sheet.last_row - 1 : 0
end
def products_to_reset
# Return indexed data about existing product count, reset count, and updates count per supplier
@products_to_reset.each do |supplier_id, values|
values[:updates_count] = 0 if values[:updates_count].blank?
if values[:updates_count] and values[:existing_products]
@products_to_reset[supplier_id][:reset_count] = values[:existing_products] - values[:updates_count]
end
end
@products_to_reset
end
def valid_count
@valid_entries.count
end
def invalid_count
@invalid_entries.count
end
def products_create_count
@products_to_create.count + @variants_to_create.count
end
def products_update_count
@variants_to_update.count
end
def suppliers_index
index = @suppliers_index || build_suppliers_index
index.sort_by{ |k,v| v.to_i }.reverse.to_h
end
def all_entries
invalid_entries.merge(products_to_create).merge(products_to_update).sort.to_h
end
def invalid_entries
@invalid_entries
end
def products_to_create
@products_to_create.merge(@variants_to_create)
end
def products_to_update
@variants_to_update
end
def products_created_count
@products_created + @variants_created
end
def products_updated_count
@variants_updated
end
def products_reset_count
@products_reset_count || 0
end
def total_saved_count
@products_created + @variants_created + @variants_updated
end
def save_all
save_all_valid
delete_uploaded_file
end
def permission_by_name?(supplier_name)
@editable_enterprises.has_key?(supplier_name)
end
def permission_by_id?(supplier_id)
@editable_enterprises.has_value?(Integer(supplier_id))
end
private
def init_product_importer
build_entries
build_categories_index
build_suppliers_index
validate_all
end
def open_spreadsheet
if accepted_mimetype
Roo::Spreadsheet.open(@file, extension: accepted_mimetype)
else
self.errors.add(:importer, I18n.t(:product_importer_spreadsheet_error))
delete_uploaded_file
nil
end
end
def accepted_mimetype
File.extname(@file.path).in?('.csv', '.xls', '.xlsx', '.ods') ? @file.path.split('.').last.to_sym : false
end
def headers
@sheet.row(1)
end
def rows
return [] unless @sheet and @sheet.last_row
(2..@sheet.last_row).map do |i|
@sheet.row(i)
end
end
def build_entries
rows.each_with_index do |row, i|
row_data = Hash[[headers, row].transpose]
entry = SpreadsheetEntry.new(row_data)
entry.line_number = i+2
@entries.push entry
end
@entries
end
def validate_all
@entries.each do |entry|
supplier_validation(entry)
category_validation(entry)
set_update_status(entry)
mark_as_valid(entry) unless entry_invalid?(entry.line_number)
end
count_existing_products
delete_uploaded_file if item_count.zero? or valid_count.zero?
end
def count_existing_products
@suppliers_index.each do |supplier_name, supplier_id|
if supplier_id and permission_by_id?(supplier_id)
products_count = Spree::Variant.joins(:product).
where('spree_products.supplier_id IN (?)
AND spree_variants.is_master = false
AND spree_variants.deleted_at IS NULL', supplier_id).
count
if @products_to_reset[supplier_id]
@products_to_reset[supplier_id][:existing_products] = products_count
else
@products_to_reset[supplier_id] = {existing_products: products_count}
end
@total_supplier_products += products_count
end
end
end
def entry_invalid?(line_number)
!!@invalid_entries[line_number]
end
def supplier_validation(entry)
supplier_name = entry.supplier
if supplier_name.blank?
mark_as_invalid(entry, attribute: "supplier", error: I18n.t(:error_required))
return
end
unless supplier_exists?(supplier_name)
mark_as_invalid(entry, attribute: "supplier", error: I18n.t(:error_not_found_in_database, name: supplier_name))
return
end
unless permission_by_name?(supplier_name)
mark_as_invalid(entry, attribute: "supplier", error: I18n.t(:error_no_permission_for_enterprise, name: supplier_name))
return
end
entry.supplier_id = @suppliers_index[supplier_name]
end
def supplier_exists?(supplier_name)
@suppliers_index[supplier_name]
end
def category_validation(entry)
category_name = entry.category
if category_name.blank?
mark_as_invalid(entry, attribute: "category", error: I18n.t(:error_required))
return
end
if category_exists?(category_name)
entry.primary_taxon_id = @categories_index[category_name]
else
mark_as_invalid(entry, attribute: "category", error: I18n.t(:error_not_found_in_database, name: category_name))
end
end
def category_exists?(category_name)
@categories_index[category_name]
end
def mark_as_valid(entry)
@valid_entries[entry.line_number] = entry
end
def mark_as_invalid(entry, options={})
entry.errors.add(options[:attribute], options[:error]) if options[:attribute] and options[:error]
entry.product_validations = options[:product_validations] if options[:product_validations]
@invalid_entries[entry.line_number] = entry
end
# Minimise db queries by getting a list of suppliers to look
# up, instead of doing a query for each entry in the spreadsheet
def build_suppliers_index
@suppliers_index = {}
@entries.each do |entry|
supplier_name = entry.supplier
supplier_id = @suppliers_index[supplier_name] ||
Enterprise.find_by_name(supplier_name, :select => 'id, name').try(:id)
@suppliers_index[supplier_name] = supplier_id
end
@suppliers_index
end
def build_categories_index
@categories_index = {}
@entries.each do |entry|
category_name = entry.category
category_id = @categories_index[category_name] ||
Spree::Taxon.find_by_name(category_name, :select => 'id, name').try(:id)
@categories_index[category_name] = category_id
end
@categories_index
end
def save_all_valid
already_created = {}
@products_to_create.each do |line_number, entry|
# If we've already added a new product with these attributes
# from this spreadsheet, mark this entry as a new variant with
# the new product id, as this is a now variant of that product...
if already_created[entry.supplier_id] and already_created[entry.supplier_id][entry.name]
product_id = already_created[entry.supplier_id][entry.name]
mark_as_new_variant(entry, product_id)
next
end
product = Spree::Product.new()
product.assign_attributes(entry.attributes.except('id'))
assign_defaults(product, entry.attributes)
if product.save
ensure_variant_updated(product, entry)
@products_created += 1
@updated_ids.push product.variants.first.id
else
self.errors.add("Line #{line_number}:", product.errors.full_messages) #TODO: change
end
already_created[entry.supplier_id] = {entry.name => product.id}
end
@variants_to_update.each do |line_number, entry|
variant = entry.product_object
assign_defaults(variant, entry.attributes)
if variant.valid? and variant.save
@variants_updated += 1
@updated_ids.push variant.id
else
self.errors.add("Line #{line_number}:", variant.errors.full_messages) #TODO: change
end
end
@variants_to_create.each do |line_number, entry|
new_variant = entry.product_object
assign_defaults(new_variant, entry.attributes)
if new_variant.valid? and new_variant.save
@variants_created += 1
@updated_ids.push new_variant.id
else
self.errors.add("Line #{line_number}:", new_variant.errors.full_messages)
end
end
self.errors.add(:importer, I18n.t(:product_importer_products_save_error)) if total_saved_count.zero?
reset_absent_products
total_saved_count
end
def reset_absent_products
return if total_saved_count.zero?
enterprises_to_reset = []
@import_settings.each do |enterprise_id, settings|
enterprises_to_reset.push enterprise_id if settings['reset_all_absent'] and permission_by_id?(enterprise_id)
end
unless enterprises_to_reset.empty? or @updated_ids.empty?
# For selected enterprises; set stock to zero for all products
# that were not present in the uploaded spreadsheet
@products_reset_count = Spree::Variant.joins(:product).
where('spree_products.supplier_id IN (?)
AND spree_variants.id NOT IN (?)
AND spree_variants.is_master = false
AND spree_variants.deleted_at IS NULL', enterprises_to_reset, @updated_ids).
update_all(count_on_hand: 0)
end
end
def assign_defaults(object, entry)
@import_settings[entry['supplier_id'].to_s]['defaults'].each do |attribute, setting|
case setting['mode']
when 'overwrite_all'
object.assign_attributes(attribute => setting['value'])
when 'overwrite_empty'
if object.send(attribute).blank? or (attribute == 'on_hand' and entry['on_hand_nil'])
object.assign_attributes(attribute => setting['value'])
end
end
end
end
def ensure_variant_updated(product, entry)
# Ensure display_name and on_demand are copied to new product's variant
if entry.display_name || entry.on_demand
variant = product.variants.first
variant.display_name = entry.display_name if entry.display_name
variant.on_demand = entry.on_demand if entry.on_demand
variant.save
end
end
def set_update_status(entry)
# Find product with matching supplier and name
match = Spree::Product.where(supplier_id: entry.supplier_id, name: entry.name, deleted_at: nil).first
# If no matching product was found, create a new product
if match.nil?
mark_as_new_product(entry)
return
end
# Otherwise, if a variant exists with matching display_name and unit_value, update it
match.variants.each do |existing_variant|
if existing_variant.display_name == entry.display_name && existing_variant.unit_value == Float(entry.unit_value)
mark_as_existing_variant(entry, existing_variant)
return
end
end
# Otherwise, a variant with sufficiently matching attributes doesn't exist; create a new one
mark_as_new_variant(entry, match.id)
end
def mark_as_new_product(entry)
new_product = Spree::Product.new()
new_product.assign_attributes(entry.attributes.except('id'))
if new_product.valid?
@products_to_create[entry.line_number] = entry unless entry_invalid?(entry.line_number)
else
mark_as_invalid(entry, product_validations: new_product.errors)
end
end
def mark_as_existing_variant(entry, existing_variant)
existing_variant.assign_attributes(entry.attributes.except('id', 'product_id'))
check_on_hand_nil(entry, existing_variant)
if existing_variant.valid?
entry.product_object = existing_variant
@variants_to_update[entry.line_number] = entry unless entry_invalid?(entry.line_number)
updates_count_per_supplier(entry.supplier_id) unless entry_invalid?(entry.line_number)
else
mark_as_invalid(entry, product_validations: existing_variant.errors)
end
end
def mark_as_new_variant(entry, product_id)
new_variant = Spree::Variant.new(entry.attributes.except('id', 'product_id'))
new_variant.product_id = product_id
check_on_hand_nil(entry, new_variant)
if new_variant.valid?
entry.product_object = new_variant
@variants_to_create[entry.line_number] = entry unless entry_invalid?(entry.line_number)
else
mark_as_invalid(entry, product_validations: new_variant.errors)
end
end
def updates_count_per_supplier(supplier_id)
if @products_to_reset[supplier_id] and @products_to_reset[supplier_id][:updates_count]
@products_to_reset[supplier_id][:updates_count] += 1
else
@products_to_reset[supplier_id] = {updates_count: 1}
end
end
def check_on_hand_nil(entry, variant)
if entry.on_hand.blank?
variant.on_hand = 0
entry.on_hand_nil = true
end
end
def delete_uploaded_file
# Only delete if file is in '/tmp/product_import' directory
if @file.path == Rails.root.join('tmp', 'product_import').to_s
File.delete(@file)
end
end
end

View File

@@ -1,68 +0,0 @@
# Class for defining spreadsheet entry objects for use in ProductImporter
class SpreadsheetEntry
extend ActiveModel::Naming
include ActiveModel::Conversion
include ActiveModel::Validations
attr_accessor :line_number, :valid, :product_object, :product_validations, :save_type, :on_hand_nil
attr_accessor :id, :product_id, :supplier, :supplier_id, :name, :display_name, :sku,
:unit_value, :unit_description, :variant_unit, :variant_unit_scale, :variant_unit_name,
:display_as, :category, :primary_taxon_id, :price, :on_hand, :on_demand,
:tax_category_id, :shipping_category_id, :description
def initialize(attrs)
@product_validations = {}
attrs.each do |k, v|
if self.respond_to?("#{k}=")
send("#{k}=", v) unless non_product_attributes.include?(k)
else
# Trying to assign unknown attribute. Record this and give feedback or just ignore silently?
end
end
end
def persisted?
false #ActiveModel
end
def has_errors?
self.errors.count > 0 or @product_validations.count > 0
end
def attributes
attrs = {}
self.instance_variables.each do |var|
attrs[var.to_s.delete("@")] = self.instance_variable_get(var)
end
attrs.except(*non_product_attributes)
end
def displayable_attributes
# Modified attributes list for displaying in user feedback
attrs = {}
self.instance_variables.each do |var|
attrs[var.to_s.delete("@")] = self.instance_variable_get(var)
end
attrs.except(*non_product_attributes, *non_display_attributes)
end
def invalid_attributes
invalid_attrs = {}
@product_validations.messages.merge(self.errors.messages).each do |attr, message|
invalid_attrs[attr.to_s] = "#{attr.to_s.capitalize} #{message.first}"
end
invalid_attrs.except(*non_product_attributes, *non_display_attributes)
end
private
def non_display_attributes
['id', 'product_id', 'variant_id', 'supplier_id', 'primary_taxon', 'primary_taxon_id', 'category_id', 'shipping_category_id', 'tax_category_id']
end
def non_product_attributes
['line_number', 'valid', 'errors', 'product_object', 'product_validations', 'save_type', 'on_hand_nil']
end
end

View File

@@ -61,7 +61,7 @@ class AbilityDecorator
order.user == user
end
can [:destroy], Spree::CreditCard do |credit_card|
can [:update, :destroy], Spree::CreditCard do |credit_card|
credit_card.user == user
end
end
@@ -127,12 +127,14 @@ class AbilityDecorator
can [:admin, :connect, :status, :destroy], StripeAccount do |stripe_account|
user.enterprises.include? stripe_account.enterprise
end
can [:admin, :create], :manager_invitation
end
def add_product_management_abilities(user)
# Enterprise User can only access products that they are a supplier for
can [:create], Spree::Product
can [:admin, :read, :update, :product_distributions, :seo, :group_buy_options, :bulk_edit, :bulk_update, :clone, :delete, :destroy], Spree::Product do |product|
can [:admin, :read, :index, :update, :product_distributions, :seo, :group_buy_options, :bulk_update, :clone, :delete, :destroy], Spree::Product do |product|
OpenFoodNetwork::Permissions.new(user).managed_product_enterprises.include? product.supplier
end
@@ -175,7 +177,7 @@ class AbilityDecorator
can [:admin, :index, :read, :search], Spree::Taxon
can [:admin, :index, :read, :create, :edit], Spree::Classification
can [:admin, :index, :import, :save], ProductImporter
can [:admin, :index, :guide, :import, :save, :save_data, :validate_data, :reset_absent_products], ProductImport::ProductImporter
# Reports page
can [:admin, :index, :customers, :orders_and_distributors, :group_buys, :bulk_coop, :payments, :orders_and_fulfillment, :products_and_inventory, :order_cycle_management, :packing], :report

View File

@@ -5,7 +5,7 @@ Spree::CreditCard.class_eval do
attr_accessible :cc_type, :last_digits
# For holding customer preference in memory
attr_accessible :save_requested_by_customer
attr_accessible :save_requested_by_customer, :is_default
attr_writer :save_requested_by_customer
# Should be able to remove once we reach Spree v2.2.0
@@ -14,15 +14,31 @@ Spree::CreditCard.class_eval do
belongs_to :user
after_create :ensure_single_default_card
after_save :ensure_single_default_card, if: :is_default_changed?
# Allows us to use a gateway_payment_profile_id to store Stripe Tokens
# Should be able to remove once we reach Spree v2.2.0
# Commit: https://github.com/spree/spree/commit/5a4d690ebc64b264bf12904a70187e7a8735ef3f
# See also: https://github.com/spree/spree_gateway/issues/111
def has_payment_profile? # rubocop:disable Style/PredicateName
def has_payment_profile? # rubocop:disable Naming/PredicateName
gateway_customer_profile_id.present? || gateway_payment_profile_id.present?
end
def save_requested_by_customer?
!!@save_requested_by_customer
end
private
def default_missing?
!user.credit_cards.exists?(is_default: true)
end
def ensure_single_default_card
return unless user
return unless is_default? || default_missing?
user.credit_cards.update_all(['is_default=(id=?)', id])
self.is_default = true
end
end

View File

@@ -17,6 +17,7 @@ Spree::Order.class_eval do
validates :customer, presence: true, if: :require_customer?
validate :products_available_from_new_distribution, :if => lambda { distributor_id_changed? || order_cycle_id_changed? }
validate :disallow_guest_order
attr_accessible :order_cycle_id, :distributor_id, :customer_id
before_validation :shipping_address_from_distributor
@@ -82,6 +83,20 @@ Spree::Order.class_eval do
errors.add(:base, I18n.t(:spree_order_availability_error)) unless DistributionChangeValidator.new(self).can_change_to_distribution?(distributor, order_cycle)
end
def using_guest_checkout?
require_email && !user.andand.id
end
def registered_email?
Spree.user_class.exists?(email: email)
end
def disallow_guest_order
if using_guest_checkout? && registered_email?
errors.add(:base, I18n.t('devise.failure.already_registered'))
end
end
def empty_with_clear_shipping_and_payments!
empty_without_clear_shipping_and_payments!
payments.clear

View File

@@ -174,6 +174,14 @@ Spree::Product.class_eval do
order_cycle.variants_distributed_by(distributor).where(product_id: self)
end
# Get the most recent import_date of a product's variants
def import_date
variants.map do |variant|
next if variant.import_date.blank?
variant.import_date
end.sort.last
end
# Build a product distribution for each distributor
def build_product_distributions_for_user user
Enterprise.is_distributor.managed_by(user).each do |distributor|

View File

@@ -10,13 +10,12 @@ Spree::Variant.class_eval do
remove_method :options_text if instance_methods(false).include? :options_text
include OpenFoodNetwork::VariantAndLineItemNaming
has_many :exchange_variants
has_many :exchanges, through: :exchange_variants
has_many :variant_overrides
has_many :inventory_items
attr_accessible :unit_value, :unit_description, :images_attributes, :display_as, :display_name
attr_accessible :unit_value, :unit_description, :images_attributes, :display_as, :display_name, :import_date
accepts_nested_attributes_for :images
validates_presence_of :unit_value,
@@ -30,10 +29,11 @@ Spree::Variant.class_eval do
after_save :refresh_products_cache
around_destroy :destruction
scope :with_order_cycles_inner, joins(exchanges: :order_cycle)
scope :not_deleted, where(deleted_at: nil)
scope :not_master, where(is_master: false)
scope :in_stock, where('spree_variants.count_on_hand > 0 OR spree_variants.on_demand=?', true)
scope :in_order_cycle, lambda { |order_cycle|
with_order_cycles_inner.
merge(Exchange.outgoing).
@@ -117,7 +117,6 @@ Spree::Variant.class_eval do
end
end
private
def update_weight_from_unit_value

View File

@@ -3,6 +3,8 @@ class VariantOverride < ActiveRecord::Base
acts_as_taggable
attr_accessor :import_date
belongs_to :hub, class_name: 'Enterprise'
belongs_to :variant, class_name: 'Spree::Variant'

View File

@@ -0,0 +1,2 @@
/ set_attributes "div[data-hook='customer_fields'] div.alpha"
/ attributes({class: "fullwidth"})

View File

@@ -0,0 +1 @@
remove "div[data-hook='customer_fields'] div.omega"

Some files were not shown because too many files have changed in this diff Show More